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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 23:34:56 +02:00
commit 142a2e1385

View file

@ -97,9 +97,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
async push(entry: CultCacheEnvelope): Promise<void> {
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<void> {
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,