Heal legacy MessagePack cache envelopes
This commit is contained in:
parent
ed3f2b8dd7
commit
526a47e06a
4 changed files with 137 additions and 5 deletions
|
|
@ -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"}
|
||||
42
dist/single-file-messagepack-backing-store.js
vendored
42
dist/single-file-messagepack-backing-store.js
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue