fix(common): preserve string contract for GQL history responses (#6244)
This commit is contained in:
parent
aee017ced3
commit
7036ab5b53
5 changed files with 172 additions and 8 deletions
|
|
@ -106,6 +106,18 @@ export function translateToNewGQLHistory(x: any): GQLHistoryEntry {
|
||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decodes a JSON-stringified wire value back to a string. Returns the
|
||||||
|
// raw value if parsing yields a non-string (object, array, etc.) so
|
||||||
|
// callers always receive a string per the GQLHistoryEntry contract.
|
||||||
|
export const decodeGQLHistoryResponse = (value: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return typeof parsed === "string" ? parsed : value
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const defaultRESTHistoryState = {
|
export const defaultRESTHistoryState = {
|
||||||
state: [] as RESTHistoryEntry[],
|
state: [] as RESTHistoryEntry[],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -709,6 +709,74 @@ describe("PersistenceService", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it(`v=2 migration repairs entries in "${graphqlHistoryKey}" whose response field is a non-string and writes a pre-v2 backup`, async () => {
|
||||||
|
// Pre-fix sync writes round-tripped via JSON.stringify/parse,
|
||||||
|
// leaving entries with object-shaped response in localStorage.
|
||||||
|
const corruptedEntries = [
|
||||||
|
{ ...GQL_HISTORY_MOCK[0], response: {} },
|
||||||
|
{ ...GQL_HISTORY_MOCK[0], response: null },
|
||||||
|
]
|
||||||
|
await setStoreItem(graphqlHistoryKey, corruptedEntries)
|
||||||
|
|
||||||
|
const setItemSpy = spyOnSetItem()
|
||||||
|
|
||||||
|
await invokeSetupLocalPersistence()
|
||||||
|
|
||||||
|
// Original is preserved at the pre-v2 backup key for recovery.
|
||||||
|
expect(setItemSpy).toHaveBeenCalledWith(
|
||||||
|
`${graphqlHistoryKey}-pre-v2-backup`,
|
||||||
|
expect.stringContaining(JSON.stringify(corruptedEntries))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repaired data is written back to the live key with response coerced.
|
||||||
|
// - Object response {} stringifies to "{}", which appears in the
|
||||||
|
// serialized payload as `"response":"{}"`.
|
||||||
|
// - Null response stringifies to "null", which appears in the
|
||||||
|
// serialized payload as `"response":"null"` and preserves the
|
||||||
|
// original semantic of an empty payload.
|
||||||
|
expect(setItemSpy).toHaveBeenCalledWith(
|
||||||
|
graphqlHistoryKey,
|
||||||
|
expect.stringContaining('"response":"{}"')
|
||||||
|
)
|
||||||
|
expect(setItemSpy).toHaveBeenCalledWith(
|
||||||
|
graphqlHistoryKey,
|
||||||
|
expect.stringContaining('"response":"null"')
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schema version bumps to 2, so the migration won't run again.
|
||||||
|
expect(setItemSpy).toHaveBeenCalledWith(
|
||||||
|
schemaVersionKey,
|
||||||
|
expect.stringMatching(/"data":"2"/)
|
||||||
|
)
|
||||||
|
|
||||||
|
// No Zod-failure backup since the migration repaired the shape
|
||||||
|
// before validation could reject it.
|
||||||
|
expect(toastErrorFn).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(graphqlHistoryKey)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`v=2 migration is a no-op when "${graphqlHistoryKey}" entries already have string responses`, async () => {
|
||||||
|
// Clean entries — response is already a string per the contract.
|
||||||
|
await setStoreItem(graphqlHistoryKey, GQL_HISTORY_MOCK)
|
||||||
|
|
||||||
|
const setItemSpy = spyOnSetItem()
|
||||||
|
|
||||||
|
await invokeSetupLocalPersistence()
|
||||||
|
|
||||||
|
// No backup write since needsRepair was false.
|
||||||
|
expect(setItemSpy).not.toHaveBeenCalledWith(
|
||||||
|
`${graphqlHistoryKey}-pre-v2-backup`,
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schema version still bumps to 2 so the migration is recorded as run.
|
||||||
|
expect(setItemSpy).toHaveBeenCalledWith(
|
||||||
|
schemaVersionKey,
|
||||||
|
expect.stringMatching(/"data":"2"/)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it(`GQL history schema parsing succeeds if there is no "${graphqlHistoryKey}" key present in localStorage where the fallback of "[]" is chosen`, async () => {
|
it(`GQL history schema parsing succeeds if there is no "${graphqlHistoryKey}" key present in localStorage where the fallback of "[]" is chosen`, async () => {
|
||||||
window.localStorage.removeItem(graphqlHistoryKey)
|
window.localStorage.removeItem(graphqlHistoryKey)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,76 @@ const migrations: Migration[] = [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Coerce gqlHistory entries with non-string `response` to string,
|
||||||
|
// backing up originals at `${GQL_HISTORY}-pre-v2-backup`.
|
||||||
|
version: 2,
|
||||||
|
migrate: async () => {
|
||||||
|
const result = await Store.get<unknown>(
|
||||||
|
STORE_NAMESPACE,
|
||||||
|
STORE_KEYS.GQL_HISTORY
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!E.isRight(result) || !Array.isArray(result.right)) return
|
||||||
|
|
||||||
|
const entries = result.right
|
||||||
|
// Only target entries with own `response` field that's non-string;
|
||||||
|
// unrelated schema mismatches still go through the Zod-fail backup.
|
||||||
|
const needsRepair = entries.some(
|
||||||
|
(entry) =>
|
||||||
|
typeof entry === "object" &&
|
||||||
|
entry !== null &&
|
||||||
|
Object.prototype.hasOwnProperty.call(entry, "response") &&
|
||||||
|
typeof (entry as { response?: unknown }).response !== "string"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!needsRepair) return
|
||||||
|
|
||||||
|
// Throw on backup or repair write failure so `runMigrations` can
|
||||||
|
// skip the schema_version bump and retry on the next launch. The
|
||||||
|
// alternative — log-and-continue — would mark the migration done
|
||||||
|
// while leaving poisoned data in place, with no future retry path.
|
||||||
|
const backupResult = await Store.set(
|
||||||
|
STORE_NAMESPACE,
|
||||||
|
`${STORE_KEYS.GQL_HISTORY}-pre-v2-backup`,
|
||||||
|
entries
|
||||||
|
)
|
||||||
|
if (E.isLeft(backupResult)) {
|
||||||
|
throw new Error(
|
||||||
|
`[v2 migration] failed to write pre-v2 backup: ${backupResult.left.kind}: ${backupResult.left.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const repaired = entries.map((entry) => {
|
||||||
|
if (typeof entry !== "object" || entry === null) return entry
|
||||||
|
const e = entry as Record<string, unknown>
|
||||||
|
if (
|
||||||
|
!Object.prototype.hasOwnProperty.call(e, "response") ||
|
||||||
|
typeof e.response === "string"
|
||||||
|
) {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Use JSON.stringify(e.response) directly so a `null` payload
|
||||||
|
// serializes to the string `"null"` rather than `'""'`. Either
|
||||||
|
// form satisfies the `response: z.string()` schema, but `"null"`
|
||||||
|
// preserves the original semantic of an empty payload (e.g. a
|
||||||
|
// subscription that produced no data) and avoids re-stringifying
|
||||||
|
// on the next sync write.
|
||||||
|
return { ...e, response: JSON.stringify(e.response) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const repairResult = await Store.set(
|
||||||
|
STORE_NAMESPACE,
|
||||||
|
STORE_KEYS.GQL_HISTORY,
|
||||||
|
repaired
|
||||||
|
)
|
||||||
|
if (E.isLeft(repairResult)) {
|
||||||
|
throw new Error(
|
||||||
|
`[v2 migration] failed to write repaired ${STORE_KEYS.GQL_HISTORY}: ${repairResult.left.kind}: ${repairResult.left.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -235,13 +305,25 @@ export class PersistenceService extends Service {
|
||||||
)
|
)
|
||||||
const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "0"
|
const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "0"
|
||||||
const currentVersion = perhapsVersion ?? "0"
|
const currentVersion = perhapsVersion ?? "0"
|
||||||
const targetVersion = "1"
|
const targetVersion = "2"
|
||||||
|
|
||||||
if (currentVersion !== targetVersion) {
|
if (currentVersion !== targetVersion) {
|
||||||
for (const migration of migrations) {
|
try {
|
||||||
if (migration.version > parseInt(currentVersion)) {
|
for (const migration of migrations) {
|
||||||
await migration.migrate()
|
if (migration.version > parseInt(currentVersion)) {
|
||||||
|
await migration.migrate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A migration that throws (e.g. v2 repair on a degraded store)
|
||||||
|
// aborts the schema_version bump so the next launch retries
|
||||||
|
// from the same currentVersion rather than recording an
|
||||||
|
// incomplete migration as done.
|
||||||
|
console.error(
|
||||||
|
"[persistence] migration failed; schema_version not advanced:",
|
||||||
|
err
|
||||||
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await Store.set(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION, targetVersion)
|
await Store.set(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION, targetVersion)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
graphqlHistoryStore,
|
graphqlHistoryStore,
|
||||||
deleteGraphqlHistoryEntry,
|
deleteGraphqlHistoryEntry,
|
||||||
clearGraphqlHistory,
|
clearGraphqlHistory,
|
||||||
|
decodeGQLHistoryResponse,
|
||||||
} from "@hoppscotch/common/newstore/history"
|
} from "@hoppscotch/common/newstore/history"
|
||||||
import { translateToNewRequest, translateToGQLRequest } from "@hoppscotch/data"
|
import { translateToNewRequest, translateToGQLRequest } from "@hoppscotch/data"
|
||||||
import { HistoryPlatformDef } from "@hoppscotch/common/platform/history"
|
import { HistoryPlatformDef } from "@hoppscotch/common/platform/history"
|
||||||
|
|
@ -109,7 +110,7 @@ async function loadHistoryEntries() {
|
||||||
const gqlHistoryEntries: GQLHistoryEntry[] = gqlEntries.map((entry) => ({
|
const gqlHistoryEntries: GQLHistoryEntry[] = gqlEntries.map((entry) => ({
|
||||||
v: 1,
|
v: 1,
|
||||||
request: translateToGQLRequest(JSON.parse(entry.request)),
|
request: translateToGQLRequest(JSON.parse(entry.request)),
|
||||||
response: JSON.parse(entry.responseMetadata),
|
response: decodeGQLHistoryResponse(entry.responseMetadata),
|
||||||
star: entry.isStarred,
|
star: entry.isStarred,
|
||||||
updatedOn: new Date(entry.executedOn),
|
updatedOn: new Date(entry.executedOn),
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
|
@ -175,7 +176,7 @@ function setupUserHistoryCreatedSubscription() {
|
||||||
v: 1,
|
v: 1,
|
||||||
id,
|
id,
|
||||||
request: translateToGQLRequest(JSON.parse(request)),
|
request: translateToGQLRequest(JSON.parse(request)),
|
||||||
response: JSON.parse(responseMetadata),
|
response: decodeGQLHistoryResponse(responseMetadata),
|
||||||
star: isStarred,
|
star: isStarred,
|
||||||
updatedOn: new Date(executedOn),
|
updatedOn: new Date(executedOn),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
graphqlHistoryStore,
|
graphqlHistoryStore,
|
||||||
deleteGraphqlHistoryEntry,
|
deleteGraphqlHistoryEntry,
|
||||||
clearGraphqlHistory,
|
clearGraphqlHistory,
|
||||||
|
decodeGQLHistoryResponse,
|
||||||
} from "@hoppscotch/common/newstore/history"
|
} from "@hoppscotch/common/newstore/history"
|
||||||
import { translateToNewRequest, translateToGQLRequest } from "@hoppscotch/data"
|
import { translateToNewRequest, translateToGQLRequest } from "@hoppscotch/data"
|
||||||
import { HistoryPlatformDef } from "@hoppscotch/common/platform/history"
|
import { HistoryPlatformDef } from "@hoppscotch/common/platform/history"
|
||||||
|
|
@ -112,7 +113,7 @@ async function loadHistoryEntries() {
|
||||||
const gqlHistoryEntries: GQLHistoryEntry[] = gqlEntries.map((entry) => ({
|
const gqlHistoryEntries: GQLHistoryEntry[] = gqlEntries.map((entry) => ({
|
||||||
v: 1,
|
v: 1,
|
||||||
request: translateToGQLRequest(JSON.parse(entry.request)),
|
request: translateToGQLRequest(JSON.parse(entry.request)),
|
||||||
response: JSON.parse(entry.responseMetadata),
|
response: decodeGQLHistoryResponse(entry.responseMetadata),
|
||||||
star: entry.isStarred,
|
star: entry.isStarred,
|
||||||
updatedOn: new Date(entry.executedOn),
|
updatedOn: new Date(entry.executedOn),
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
|
@ -178,7 +179,7 @@ function setupUserHistoryCreatedSubscription() {
|
||||||
v: 1,
|
v: 1,
|
||||||
id,
|
id,
|
||||||
request: translateToGQLRequest(JSON.parse(request)),
|
request: translateToGQLRequest(JSON.parse(request)),
|
||||||
response: JSON.parse(responseMetadata),
|
response: decodeGQLHistoryResponse(responseMetadata),
|
||||||
star: isStarred,
|
star: isStarred,
|
||||||
updatedOn: new Date(executedOn),
|
updatedOn: new Date(executedOn),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue