From 526a47e06a4f040cc40db24de4d969d42eb61fbe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 7 May 2026 22:28:38 +0100 Subject: [PATCH] Heal legacy MessagePack cache envelopes --- ...le-file-messagepack-backing-store.d.ts.map | 2 +- dist/single-file-messagepack-backing-store.js | 42 ++++++++++++++- src/single-file-messagepack-backing-store.ts | 54 ++++++++++++++++++- test/cult-cache.test.ts | 44 ++++++++++++++- 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/dist/single-file-messagepack-backing-store.d.ts.map b/dist/single-file-messagepack-backing-store.d.ts.map index 5f266f3..172b69a 100644 --- a/dist/single-file-messagepack-backing-store.d.ts.map +++ b/dist/single-file-messagepack-backing-store.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"single-file-messagepack-backing-store.d.ts","sourceRoot":"","sources":["../src/single-file-messagepack-backing-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAWpF,qBAAa,iCAAkC,YAAW,iBAAiB;;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAId,QAAQ,EAAE,MAAM;IAItB,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAcvC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,OAAO,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAiDzF"} \ No newline at end of file +{"version":3,"file":"single-file-messagepack-backing-store.d.ts","sourceRoot":"","sources":["../src/single-file-messagepack-backing-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAmBpF,qBAAa,iCAAkC,YAAW,iBAAiB;;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAId,QAAQ,EAAE,MAAM;IAItB,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAiCvC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,OAAO,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAiDzF"} \ No newline at end of file diff --git a/dist/single-file-messagepack-backing-store.js b/dist/single-file-messagepack-backing-store.js index a685a67..38023b4 100644 --- a/dist/single-file-messagepack-backing-store.js +++ b/dist/single-file-messagepack-backing-store.js @@ -12,6 +12,12 @@ const envelopeSchema = zod_1.z.object({ storedAt: zod_1.z.string().min(1), }); const envelopeArraySchema = zod_1.z.array(envelopeSchema); +const legacyEnvelopeArraySchema = zod_1.z.array(zod_1.z.object({ + key: zod_1.z.string().min(1), + type: zod_1.z.string().min(1), + payload: zod_1.z.unknown(), + storedAt: zod_1.z.string().min(1), +})); class SingleFileMessagePackBackingStore { filePath; #writeQueue = Promise.resolve(); @@ -21,7 +27,23 @@ class SingleFileMessagePackBackingStore { async pullAll() { try { const data = await (0, promises_1.readFile)(this.filePath); - return envelopeArraySchema.parse((0, msgpack_1.decode)(data)); + const decoded = legacyEnvelopeArraySchema.parse((0, msgpack_1.decode)(data)); + let repairedLegacyPayload = false; + const normalized = decoded.map((entry) => { + const payload = normalizePayload(entry.payload); + if (payload !== entry.payload) { + repairedLegacyPayload = true; + } + return { + ...entry, + payload, + }; + }); + const parsed = envelopeArraySchema.parse(normalized); + if (repairedLegacyPayload) { + await this.#writeAll(parsed); + } + return parsed; } catch (error) { const code = error.code; @@ -88,3 +110,21 @@ class SingleFileMessagePackBackingStore { } } exports.SingleFileMessagePackBackingStore = SingleFileMessagePackBackingStore; +function normalizePayload(payload) { + if (payload instanceof Uint8Array) { + return payload; + } + if (isObject(payload) && + payload.type === "Buffer" && + Array.isArray(payload.data) && + payload.data.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) { + return Uint8Array.from(payload.data); + } + if (Array.isArray(payload) && payload.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) { + return Uint8Array.from(payload); + } + return (0, msgpack_1.encode)(payload); +} +function isObject(value) { + return typeof value === "object" && value !== null; +} diff --git a/src/single-file-messagepack-backing-store.ts b/src/single-file-messagepack-backing-store.ts index 6618d7d..eed4229 100644 --- a/src/single-file-messagepack-backing-store.ts +++ b/src/single-file-messagepack-backing-store.ts @@ -14,6 +14,14 @@ const envelopeSchema = z.object({ }); const envelopeArraySchema = z.array(envelopeSchema); +const legacyEnvelopeArraySchema = z.array( + z.object({ + key: z.string().min(1), + type: z.string().min(1), + payload: z.unknown(), + storedAt: z.string().min(1), + }), +); export class SingleFileMessagePackBackingStore implements CacheBackingStore { readonly filePath: string; @@ -27,7 +35,26 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { async pullAll(): Promise { try { const data = await readFile(this.filePath); - return envelopeArraySchema.parse(decode(data)) as CultCacheEnvelope[]; + const decoded = legacyEnvelopeArraySchema.parse(decode(data)); + let repairedLegacyPayload = false; + const normalized = decoded.map((entry) => { + const payload = normalizePayload(entry.payload); + if (payload !== entry.payload) { + repairedLegacyPayload = true; + } + + return { + ...entry, + payload, + }; + }); + const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[]; + + if (repairedLegacyPayload) { + await this.#writeAll(parsed); + } + + return parsed; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { @@ -109,3 +136,28 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } } + +function normalizePayload(payload: unknown): Uint8Array { + if (payload instanceof Uint8Array) { + return payload; + } + + if ( + isObject(payload) && + payload.type === "Buffer" && + Array.isArray(payload.data) && + payload.data.every((value) => Number.isInteger(value) && value >= 0 && value <= 255) + ) { + return Uint8Array.from(payload.data); + } + + if (Array.isArray(payload) && payload.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) { + return Uint8Array.from(payload); + } + + return encode(payload); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/test/cult-cache.test.ts b/test/cult-cache.test.ts index 6d4e65d..a006eab 100644 --- a/test/cult-cache.test.ts +++ b/test/cult-cache.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { encode } from "@msgpack/msgpack"; +import { decode, encode } from "@msgpack/msgpack"; import { z } from "zod"; import { CultCache } from "../src/cult-cache"; @@ -270,3 +270,43 @@ test("CultCache can ingest raw envelopes without re-encoding the payload", async assert.deepEqual(target.getRequired(noteDocument, "note:hello"), applied); assert.deepEqual(target.getRequiredEnvelope(noteDocument, "note:hello").payload, envelope.payload); }); + +test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => { + const noteDocument = defineDocumentType({ + type: "note", + schema: z.object({ + title: z.string(), + body: z.string(), + }), + }); + + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "legacy.msgpack"); + const envelopePayload = { + title: "Hello", + body: "world", + }; + + await writeFile( + storePath, + encode([ + { + key: "note:hello", + type: "note", + payload: envelopePayload, + storedAt: new Date().toISOString(), + }, + ]), + ); + + const cache = CultCache.builder() + .withDocumentType(noteDocument) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + + await cache.pullAllBackingStores(); + assert.deepEqual(cache.getRequired(noteDocument, "note:hello"), envelopePayload); + + const rewritten = decode(await readFile(storePath)) as Array<{ payload: unknown }>; + assert.equal(rewritten.length, 1); + assert.ok(rewritten[0]?.payload instanceof Uint8Array); +});