CultMesh TS: a document is read only under an id its type declares
Soul F3 and F4, and F1 in CultMesh's schema descriptor. - The schema descriptor built from a definition uses schemaIdentityOf, so a handle's schema id is the id the cache stamps. - Store documents (F3): a handle reads the record under its schema id, or, for a definition, under an id the definition lists as compatible, as the cache resolves it. The catalog-entry name/version match and both parse fallbacks are deleted: a record under another id is not this document, whatever its payload parses as. - Peer snapshots (F4): syncDocumentFromPeerSnapshot and definition-named peer-snapshot handles take the peer's record at the key under the type's schema id only. The shape filter is deleted and no match is not-found. P8a (syncing A at k while the peer holds only B at k overwrote the local B and then threw) and P8b (a peer's C stored as A) write nothing now. - HELD: a peer-snapshot handle named by a bare schema-id string still takes the first record at the key. Eve Electron reads the C# Aetheria daemon that way (EveElectron src/cultmesh-provider-client.mjs:129-142): the daemon stamps SHA-256 schema ids and the client asks by "gamecult.eve.provider_advertisement.v1", so only the fallback finds its records. How a TS reader names a C#-hosted schema is raised as a fork; the fallback goes when it is ruled. Tests: the store- and peer-snapshot tests that asserted shape routing are replaced by tests of the refusal, including P8a and P8b, and one that pins the held fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0ee98e8c2e
commit
9118e24976
2 changed files with 93 additions and 148 deletions
|
|
@ -5,6 +5,7 @@ import {
|
|||
CultCache,
|
||||
SchemaConflictError,
|
||||
SingleFileMessagePackBackingStore,
|
||||
schemaIdentityOf,
|
||||
type AnyCultCacheDocumentDefinition,
|
||||
type CacheBackingStore,
|
||||
type CultCacheEnvelope,
|
||||
|
|
@ -2549,7 +2550,7 @@ export function cultMeshDocumentFromStore(
|
|||
const documentId = options.documentId ?? resolvedSchemaId;
|
||||
const read = async () => {
|
||||
const records = await store.pullAll();
|
||||
const record = resolveCultMeshStoreDocumentRecord(records, schema, schemaOrDefinition);
|
||||
const record = resolveCultMeshStoreDocumentRecord(records, schemaOrDefinition);
|
||||
if (!record) {
|
||||
throw new Error(`CultMesh store document '${documentId}' did not contain schema ${resolvedSchemaId}.`);
|
||||
}
|
||||
|
|
@ -2673,16 +2674,7 @@ export function cultMeshDocumentFromPeerSnapshot(
|
|||
{
|
||||
timeoutMs: options.timeoutMs,
|
||||
messageIdPrefix: options.messageIdPrefix ?? documentId,
|
||||
accepts: typeof schemaOrDefinition === "string"
|
||||
? undefined
|
||||
: record => {
|
||||
try {
|
||||
schemaOrDefinition.schema.parse(decodeCultNetRawDocumentPayload(record));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
firstRecordAtKey: typeof schemaOrDefinition === "string",
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -4004,14 +3996,6 @@ export class CultMeshNode {
|
|||
{
|
||||
timeoutMs: options.timeoutMs,
|
||||
messageIdPrefix: options.messageIdPrefix ?? key,
|
||||
accepts: record => {
|
||||
try {
|
||||
definition.schema.parse(decodeCultNetRawDocumentPayload(record));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
await this.documents.applyRawDocumentPutMessage(this.cache, {
|
||||
|
|
@ -6170,12 +6154,8 @@ export class CultMesh {
|
|||
function cultMeshSchemaFromDefinition(
|
||||
definition: AnyCultCacheDocumentDefinition,
|
||||
): CultMeshDocumentSchemaDescriptor {
|
||||
return {
|
||||
type: definition.type,
|
||||
schemaId: definition.schemaId ?? definition.type,
|
||||
schemaName: definition.schemaName,
|
||||
schemaVersion: definition.schemaVersion,
|
||||
};
|
||||
const { schemaId, schemaName, schemaVersion } = schemaIdentityOf(definition);
|
||||
return { type: definition.type, schemaId, schemaName, schemaVersion };
|
||||
}
|
||||
|
||||
function normalizeCultMeshDocumentSchema(
|
||||
|
|
@ -6243,57 +6223,22 @@ function inferCultMeshSchemaName(schemaId: string | undefined): string | undefin
|
|||
return /^\d+$/.test(version) ? schemaId.slice(0, marker) : undefined;
|
||||
}
|
||||
|
||||
// The record a store document reads: the one under the handle's schema id, else, for a definition, one under an id the
|
||||
// definition lists as compatible, as CultCache resolves it. A record under any other id is not this document, whatever
|
||||
// its payload parses as.
|
||||
function resolveCultMeshStoreDocumentRecord(
|
||||
records: readonly CultCacheEnvelope[],
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
schemaOrDefinition: string | AnyCultCacheDocumentDefinition,
|
||||
): CultCacheEnvelope | undefined {
|
||||
const schemaId = schema.schemaId ?? schema.type;
|
||||
return (
|
||||
records.find(candidate => schemaId !== undefined && candidate.schemaId === schemaId) ??
|
||||
records.find(candidate =>
|
||||
cultMeshEnvelopeMatchesSchema(candidate, schema) &&
|
||||
(typeof schemaOrDefinition === "string" ||
|
||||
cultMeshEnvelopeParsesAsDefinition(candidate, schemaOrDefinition))) ??
|
||||
(typeof schemaOrDefinition === "string"
|
||||
? undefined
|
||||
: records.find(candidate => cultMeshEnvelopeParsesAsDefinition(candidate, schemaOrDefinition)))
|
||||
);
|
||||
const schemaIds = cultMeshAcceptedSchemaIds(schemaOrDefinition);
|
||||
return records.find(candidate => candidate.schemaId === schemaIds[0])
|
||||
?? records.find(candidate => candidate.schemaId !== undefined && schemaIds.includes(candidate.schemaId));
|
||||
}
|
||||
|
||||
function cultMeshEnvelopeMatchesSchema(
|
||||
envelope: CultCacheEnvelope,
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
): boolean {
|
||||
const entry = envelope.catalogEntry;
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
schema.schemaId &&
|
||||
(entry.schemaId === schema.schemaId ||
|
||||
envelope.schemaId === schema.schemaId ||
|
||||
entry.compatibleSchemaIds?.includes(schema.schemaId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
schema.schemaName &&
|
||||
entry.schemaName === schema.schemaName &&
|
||||
(schema.schemaVersion ?? "") === (entry.schemaVersion ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
function cultMeshEnvelopeParsesAsDefinition(
|
||||
envelope: CultCacheEnvelope,
|
||||
definition: AnyCultCacheDocumentDefinition,
|
||||
): boolean {
|
||||
try {
|
||||
definition.schema.parse(decode(envelope.payload));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
function cultMeshAcceptedSchemaIds(schemaOrDefinition: string | AnyCultCacheDocumentDefinition): readonly string[] {
|
||||
return typeof schemaOrDefinition === "string"
|
||||
? [schemaOrDefinition]
|
||||
: schemaIdentityOf(schemaOrDefinition).compatibleSchemaIds;
|
||||
}
|
||||
|
||||
function cultMeshSchemaLabel(schema: CultMeshDocumentSchemaDescriptor): string {
|
||||
|
|
@ -6336,6 +6281,13 @@ function normalizeRudpDocumentPut(
|
|||
};
|
||||
}
|
||||
|
||||
// The peer's record at the key under exactly this schema id. A record at the key under another id is another type's, and
|
||||
// is never taken in its place; none under this id is not-found.
|
||||
//
|
||||
// HELD (fork raised to Self, 2026-09-30): a handle named by a bare schema-id string still takes the first record at the
|
||||
// key when none carries that id. Eve Electron reads the C# Aetheria daemon this way: the daemon stamps SHA-256 schema ids
|
||||
// and the client asks by "gamecult.eve.provider_advertisement.v1", so only this fallback finds its records. It is removed
|
||||
// when the fork on how a TS reader names a C#-hosted schema is ruled. Definition handles and sync never take it.
|
||||
async function requestCultNetRawSnapshotDocument(
|
||||
peerOrProvider: CultNetPeer | (() => CultNetPeer | Promise<CultNetPeer>),
|
||||
schemaId: string,
|
||||
|
|
@ -6343,7 +6295,7 @@ async function requestCultNetRawSnapshotDocument(
|
|||
options: {
|
||||
timeoutMs?: number;
|
||||
messageIdPrefix?: string;
|
||||
accepts?: (document: CultNetRawDocumentRecord) => boolean;
|
||||
firstRecordAtKey?: boolean;
|
||||
} = {},
|
||||
): Promise<CultNetRawDocumentRecord> {
|
||||
const peer = await resolveCultNetPeer(peerOrProvider);
|
||||
|
|
@ -6352,11 +6304,9 @@ async function requestCultNetRawSnapshotDocument(
|
|||
messageId,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
const candidates = response.documents.filter(candidate => candidate.recordKey === recordKey);
|
||||
const document =
|
||||
candidates.find(candidate => candidate.schemaId === schemaId) ??
|
||||
(options.accepts ? candidates.find(options.accepts) : undefined) ??
|
||||
candidates[0];
|
||||
const atKey = response.documents.filter(candidate => candidate.recordKey === recordKey);
|
||||
const document = atKey.find(candidate => candidate.schemaId === schemaId)
|
||||
?? (options.firstRecordAtKey ? atKey[0] : undefined);
|
||||
if (!document) {
|
||||
throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${recordKey}.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -357,57 +357,43 @@ test("CultMesh TS documents with both capabilities require an immutable writer c
|
|||
assert.deepEqual(writes, ["authoritative:canonical", "prediction:predicted"]);
|
||||
});
|
||||
|
||||
test("CultMesh TS store document handles choose compatible foreign schema payloads", async () => {
|
||||
const store = {
|
||||
async pullAll() {
|
||||
return [
|
||||
{
|
||||
key: "note:foreign",
|
||||
type: "runtime.generated.incompatible-note",
|
||||
schemaId: "runtime.generated.incompatible-note.v1",
|
||||
storedAt: "2026-06-27T00:00:00Z",
|
||||
payload: encode({ nope: "not the requested shape" }),
|
||||
catalogEntry: {
|
||||
schemaId: "runtime.generated.incompatible-note.v1",
|
||||
schemaName: "runtime.generated.incompatible-note",
|
||||
schemaVersion: "runtime.generated.incompatible-note.v1",
|
||||
contentHash: "runtime.generated.incompatible-note.v1",
|
||||
canonicalSchemaJson: "{}",
|
||||
compatibleSchemaIds: ["runtime.generated.incompatible-note.v1"],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "note:foreign",
|
||||
type: "runtime.generated.cultmesh.note",
|
||||
schemaId: "runtime.generated.cultmesh.note.ui.42",
|
||||
storedAt: "2026-06-27T00:00:01Z",
|
||||
payload: encode({
|
||||
noteId: "note:foreign",
|
||||
body: "foreign schema id, local store shape",
|
||||
}),
|
||||
catalogEntry: {
|
||||
schemaId: "runtime.generated.cultmesh.note.ui.42",
|
||||
schemaName: "cultmesh.note",
|
||||
schemaVersion: "cultmesh.note.v1",
|
||||
contentHash: "runtime.generated.cultmesh.note.ui.42",
|
||||
canonicalSchemaJson: "{}",
|
||||
compatibleSchemaIds: ["runtime.generated.cultmesh.note.ui.42"],
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
// A store document reads the record under its own schema id, or under an id its definition lists as compatible. A record
|
||||
// under any other id is another type's, and is never read in its place because its payload happens to parse.
|
||||
test("CultMesh TS store document handles read only records under an id their definition declares", async () => {
|
||||
const envelope = (schemaId: string, value: unknown) => ({
|
||||
key: "note:store",
|
||||
type: schemaId,
|
||||
schemaId,
|
||||
storedAt: "2026-06-27T00:00:00Z",
|
||||
payload: encode(value),
|
||||
});
|
||||
const storeOf = (...records: ReturnType<typeof envelope>[]) => ({
|
||||
async pullAll() { return records; },
|
||||
async push() {},
|
||||
async delete() {},
|
||||
};
|
||||
|
||||
const document = CultMesh.documentFromStore(store, noteDocument, {
|
||||
documentId: "note:foreign.store",
|
||||
});
|
||||
const foreign = envelope("runtime.generated.cultmesh.note.ui.42", { noteId: "note:store", body: "foreign id, local shape" });
|
||||
|
||||
assert.deepEqual(await document.latest(), {
|
||||
noteId: "note:foreign",
|
||||
body: "foreign schema id, local store shape",
|
||||
await assert.rejects(
|
||||
CultMesh.documentFromStore(storeOf(foreign), noteDocument).latest(),
|
||||
/did not contain schema cultmesh\.note\.v0/,
|
||||
);
|
||||
await assert.rejects(
|
||||
CultMesh.documentFromStore(storeOf(foreign), "cultmesh.note.v0").latest(),
|
||||
/did not contain schema cultmesh\.note\.v0/,
|
||||
);
|
||||
|
||||
const own = envelope("cultmesh.note.v0", { noteId: "note:store", body: "own id" });
|
||||
assert.equal((await CultMesh.documentFromStore(storeOf(foreign, own), noteDocument).latest()).body, "own id");
|
||||
|
||||
const renamed = defineDocumentType({
|
||||
type: "cultmesh.note-renamed",
|
||||
schemaId: "cultmesh.note-renamed.v1",
|
||||
compatibleSchemaIds: ["cultmesh.note.v0"],
|
||||
schema: noteDocument.schema,
|
||||
});
|
||||
assert.equal((await CultMesh.documentFromStore(storeOf(foreign, own), renamed).latest()).body, "own id");
|
||||
await assert.rejects(CultMesh.documentFromStore(storeOf(foreign), renamed).latest(), /did not contain schema/);
|
||||
});
|
||||
|
||||
test("CultMesh TS reactive documents submit predictions from explicit updates", async () => {
|
||||
|
|
@ -3169,21 +3155,22 @@ test("CultMesh TS syncs remote RUDP snapshots into a local node", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("CultMesh TS peer snapshot handles choose compatible foreign schema payloads", async () => {
|
||||
// A peer snapshot is read, and synced, only under the requested type's schema id. A record the peer holds at the key under
|
||||
// another id is another type's: it is not-found for this type, and syncing never writes it anywhere.
|
||||
test("CultMesh TS peer snapshots never take another type's record at the key", async () => {
|
||||
const connectionId = 0x10203049;
|
||||
const node = await CultMesh.startNode(
|
||||
const source = await CultMesh.startNode(
|
||||
join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-foreign-snapshot-")), "node.ccmp"),
|
||||
{
|
||||
documents: [incompatibleNoteDocument, foreignNoteDocument],
|
||||
},
|
||||
{ documents: [incompatibleNoteDocument, foreignNoteDocument] },
|
||||
);
|
||||
await node.put(incompatibleNoteDocument, "note:foreign", {
|
||||
nope: "not the requested shape",
|
||||
});
|
||||
await node.put(foreignNoteDocument, "note:foreign", {
|
||||
noteId: "note:foreign",
|
||||
body: "foreign schema id, local shape",
|
||||
});
|
||||
await source.put(incompatibleNoteDocument, "note:b", { nope: "remote b" });
|
||||
// Parses as a note: shape routing would store it as one.
|
||||
await source.put(foreignNoteDocument, "note:c", { noteId: "note:c", body: "remote c, note-shaped" });
|
||||
const target = await CultMesh.startNode(
|
||||
join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-foreign-target-")), "node.ccmp"),
|
||||
{ documents: [noteDocument, incompatibleNoteDocument] },
|
||||
);
|
||||
await target.put(incompatibleNoteDocument, "note:b", { nope: "local b" });
|
||||
|
||||
const server = CultMesh.createRudpDocumentServer(
|
||||
"cultmesh-ts-rudp-foreign-snapshot-server",
|
||||
|
|
@ -3193,7 +3180,7 @@ test("CultMesh TS peer snapshot handles choose compatible foreign schema payload
|
|||
defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }),
|
||||
defineCultNetDocumentBinding({ definition: foreignNoteDocument }),
|
||||
]),
|
||||
getCache: () => node.cache,
|
||||
getCache: () => source.cache,
|
||||
bindHost: "127.0.0.1",
|
||||
bindPort: 0,
|
||||
resendDelayMs: 25,
|
||||
|
|
@ -3219,20 +3206,28 @@ test("CultMesh TS peer snapshot handles choose compatible foreign schema payload
|
|||
},
|
||||
);
|
||||
|
||||
const foreign = CultMesh.documentFromPeerSnapshot(
|
||||
peer,
|
||||
noteDocument,
|
||||
"note:foreign",
|
||||
{
|
||||
documentId: "note:foreign.local",
|
||||
timeoutMs: 1_000,
|
||||
},
|
||||
for (const key of ["note:b", "note:c"]) {
|
||||
const notFound = new RegExp(`did not return cultmesh\\.note\\.v0 at ${key}`);
|
||||
await assert.rejects(
|
||||
CultMesh.documentFromPeerSnapshot(peer, noteDocument, key, { timeoutMs: 1_000 }).latest(),
|
||||
notFound,
|
||||
);
|
||||
await assert.rejects(target.syncDocumentFromPeerSnapshot(peer, noteDocument, key, { timeoutMs: 1_000 }), notFound);
|
||||
assert.equal(target.get(noteDocument, key), undefined, key);
|
||||
}
|
||||
// The failed sync of a note at "note:b" left the local record of the other type there untouched.
|
||||
assert.deepEqual(target.getRequired(incompatibleNoteDocument, "note:b"), { nope: "local b" });
|
||||
// HELD for the schema-naming fork: a handle named by a bare id string still takes the first record at the key, which
|
||||
// is how Eve Electron reads the C# daemon's hash-stamped records.
|
||||
assert.deepEqual(
|
||||
await CultMesh.documentFromPeerSnapshot(peer, "cultmesh.note.v0", "note:c", { timeoutMs: 1_000 }).latest(),
|
||||
{ noteId: "note:c", body: "remote c, note-shaped" },
|
||||
);
|
||||
// Its own type's record under its own id still syncs.
|
||||
assert.deepEqual(
|
||||
await target.syncDocumentFromPeerSnapshot(peer, incompatibleNoteDocument, "note:b", { timeoutMs: 1_000 }),
|
||||
{ nope: "remote b" },
|
||||
);
|
||||
|
||||
assert.deepEqual(await foreign.latest(), {
|
||||
noteId: "note:foreign",
|
||||
body: "foreign schema id, local shape",
|
||||
});
|
||||
} finally {
|
||||
peer?.close();
|
||||
server.close();
|
||||
|
|
|
|||
Loading…
Reference in a new issue