Heal legacy MessagePack cache envelopes

This commit is contained in:
Meta Vens 2026-05-07 22:28:38 +01:00
commit 526a47e06a
4 changed files with 137 additions and 5 deletions

View file

@ -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"}
{"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"}

View file

@ -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;
}

View file

@ -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<CultCacheEnvelope[]> {
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<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -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);
});