From 142a2e1385d5dbc71d3d1ab11bd66b31f052e517 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:34:56 +0200 Subject: [PATCH] cultcache-ts: the single-file store replaces a record by schema and key SingleFileMessagePackBackingStore.push and delete matched an existing record by `candidate.type === entry.type`. A record read back carries its catalog entry's schema name as its type and a fresh write carries the definition's type, so for any definition whose type is not its schema name a rewrite appended a duplicate and a delete removed nothing. The bug has been present since the monorepo import (5c69bfc5). isSameRecord now decides identity the way the cache resolves a persisted record to a definition: same key, and the stored record's schema id is one the written envelope answers to (its own or a declared compatible id), else its schema name, else its type label (legacy envelopes). push and delete share it. Follow-up: once hands/variants-c2a merges, schemaIdentityOf (426c956a) should own the set of ids and names an envelope answers to, and this function should read it rather than re-derive it from the catalog entry. Co-Authored-By: Claude Opus 5.5 --- .../single-file-messagepack-backing-store.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index b7df1454..3aca6302 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -97,9 +97,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { async push(entry: CultCacheEnvelope): Promise { await this.#enqueue(async () => { const existing = await this.pullAll(); - const filtered = existing.filter( - (candidate) => !(candidate.type === entry.type && candidate.key === entry.key), - ); + const filtered = existing.filter((candidate) => !isSameRecord(candidate, entry)); filtered.push(entry); await this.#writeAll(filtered); }); @@ -108,9 +106,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { async delete(entry: CultCacheEnvelope): Promise { await this.#enqueue(async () => { const existing = await this.pullAll(); - const filtered = existing.filter( - (candidate) => !(candidate.type === entry.type && candidate.key === entry.key), - ); + const filtered = existing.filter((candidate) => !isSameRecord(candidate, entry)); await this.#writeAll(filtered); }); } @@ -270,6 +266,26 @@ function schemaIdForEnvelope(entry: CultCacheEnvelope): string { return entry.schemaId ?? entry.type; } +/** + * A record is its schema and its key. An envelope's `type` is a label, not the schema: a record + * read back carries its catalog entry's schema name, while one being written carries its + * definition's type, and the two differ whenever a definition names its schema. So a stored record + * is the one being written or deleted when the keys match and the stored record names the written + * envelope's schema the way the cache resolves a persisted record to a definition: by a schema id + * the written envelope answers to (its own and every id its catalog entry declares compatible), + * else by its schema name, else by its type. + */ +function isSameRecord(stored: CultCacheEnvelope, written: CultCacheEnvelope): boolean { + if (stored.key !== written.key) { + return false; + } + + const writtenSchemaIds = [schemaIdForEnvelope(written), ...(written.catalogEntry?.compatibleSchemaIds ?? [])]; + return writtenSchemaIds.some((schemaId) => schemaId === stored.schemaId) + || stored.type === written.catalogEntry?.schemaName + || stored.type === written.type; +} + function encodeCatalogEntry(entry: CultCacheSchemaCatalogEntry): unknown[] { return [ entry.schemaId,