decodeDocumentPayload refuses bytes after the first value by count, from the checkPayloadDepth walk's end, with the shared clause; readWire's own trailing check is deleted. Walks bytes-after-outcomes.msgpack on the plain, codec, store and putEnvelope paths. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
193 lines
9.5 KiB
TypeScript
193 lines
9.5 KiB
TypeScript
import { resolveDocumentFormatter } from "./document-formatter";
|
|
import { checkPayloadDepth } from "./payload-depth";
|
|
import type {
|
|
AnyCultCacheDocumentDefinition,
|
|
CultCacheSchemaCatalogMember,
|
|
CultCacheDocumentDefinition,
|
|
CultCacheDocumentRegistry,
|
|
CultCacheDocumentValue,
|
|
CultCacheSchema,
|
|
} from "./types";
|
|
|
|
export function defineDocumentType<TSchema extends CultCacheSchema>(
|
|
definition: CultCacheDocumentDefinition<TSchema>,
|
|
): CultCacheDocumentDefinition<TSchema> {
|
|
if (!definition.type || definition.type.trim().length === 0) {
|
|
throw new Error("CultCache document types must declare a non-empty type.");
|
|
}
|
|
|
|
for (const [fieldName, fieldValue] of [
|
|
["schemaId", definition.schemaId],
|
|
["schemaName", definition.schemaName],
|
|
["schemaVersion", definition.schemaVersion],
|
|
["contentHash", definition.contentHash],
|
|
["canonicalSchemaJson", definition.canonicalSchemaJson],
|
|
] as const) {
|
|
if (fieldValue !== undefined && fieldValue.trim().length === 0) {
|
|
throw new Error(`CultCache document type "${definition.type}" declares an empty ${fieldName}.`);
|
|
}
|
|
}
|
|
|
|
if (definition.name !== undefined && typeof definition.name !== "string" && typeof definition.name !== "function") {
|
|
throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`);
|
|
}
|
|
|
|
const declared = new Set<string>();
|
|
for (const [kind, set] of [["index", definition.indexes], ["role", definition.roles]] as const) {
|
|
const names = new Set<string>();
|
|
for (const name of Array.isArray(set) ? set.map((entry) => entry.name) : set ? Object.keys(set) : []) {
|
|
if (!name || name.trim().length === 0) {
|
|
throw new Error(`CultCache document type "${definition.type}" declares ${kind === "index" ? "an index" : "a role"} with an empty name.`);
|
|
}
|
|
if (names.has(name)) {
|
|
throw new Error(`CultCache document type "${definition.type}" declares duplicate ${kind} "${name}".`);
|
|
}
|
|
if (kind === "role" && declared.has(name)) {
|
|
throw new Error(`CultCache document type "${definition.type}" declares "${name}" as both an index and a role; an index already names its role.`);
|
|
}
|
|
names.add(name);
|
|
}
|
|
for (const name of names) declared.add(name);
|
|
}
|
|
|
|
if (definition.members) {
|
|
const seenSlots = new Set<number>();
|
|
for (const member of definition.members) {
|
|
if (!Number.isInteger(member.slot) || member.slot < 0) {
|
|
throw new Error(`CultCache document type "${definition.type}" declares an invalid schema member slot.`);
|
|
}
|
|
if (seenSlots.has(member.slot)) {
|
|
throw new Error(`CultCache document type "${definition.type}" declares duplicate schema member slot ${member.slot}.`);
|
|
}
|
|
seenSlots.add(member.slot);
|
|
if (!member.memberName || member.memberName.trim().length === 0) {
|
|
throw new Error(`CultCache document type "${definition.type}" declares a schema member with an empty name.`);
|
|
}
|
|
if (!member.typeName || member.typeName.trim().length === 0) {
|
|
throw new Error(`CultCache document type "${definition.type}" declares schema member "${member.memberName}" with an empty type.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
resolveDocumentFormatter(definition);
|
|
return Object.freeze(definition.codec ? { ...definition, members: resolveDocumentMembers(definition) } : { ...definition });
|
|
}
|
|
|
|
/**
|
|
* The bytes of one document, as they travel in a store envelope, a CultNet record or a realtime frame.
|
|
*
|
|
* At run time this is the same `Uint8Array`, so frames encode as they always did. At compile time it is opaque: it is not
|
|
* assignable to or from `Uint8Array` or `ArrayLike<number>`, so no byte decoder accepts it. The only reader is
|
|
* `decodeDocumentPayload(definition, payload)`, which needs the definition that says how the bytes are laid out, and the
|
|
* only writer is `encodeDocumentPayload(definition, value)`. The two escapes are named: `documentPayloadFromBytes` where
|
|
* bytes enter (a validated record, a store read, tests, a writer of pre-encoded or signed bytes) and
|
|
* `documentPayloadBytes` where they leave (a store write, a frame encode, hashing or signing).
|
|
*
|
|
* The key is a plain string, not a unique symbol, so two installed copies of this package stay compatible.
|
|
*
|
|
* What the type guarantees: a payload cannot be read or assigned directly (a decoder call, `const b: Uint8Array = p`,
|
|
* `new Uint8Array(p)` fail to compile). What it does not: the runtime value is a `Uint8Array`, so these routes read it
|
|
* without the owner: `instanceof Uint8Array` or `ArrayBuffer.isView` narrowing, `Object.values`, `Object.assign` onto a
|
|
* `Uint8Array`, a JSON round trip, `Reflect.get`, and a cast. A codec or formatter document read that way is raw bytes,
|
|
* not the document. The sanctioned escape for bytes is `documentPayloadBytes`; the reader is `decodeDocumentPayload`.
|
|
*/
|
|
export interface DocumentPayload {
|
|
readonly "cultcache.documentPayload": true;
|
|
readonly byteLength: number;
|
|
}
|
|
|
|
export function documentPayloadFromBytes(bytes: Uint8Array): DocumentPayload {
|
|
return bytes as unknown as DocumentPayload;
|
|
}
|
|
|
|
export function documentPayloadBytes(payload: DocumentPayload): Uint8Array {
|
|
return payload as unknown as Uint8Array;
|
|
}
|
|
|
|
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH, checkPayloadDepth } from "./payload-depth";
|
|
|
|
// The payload nesting bound covers the MessagePack the library itself writes: the plain default formatter and a codec. A
|
|
// user formatter's bytes are opaque and in any format; its format and bounds are its own.
|
|
function writesLibraryMessagePack(definition: AnyCultCacheDocumentDefinition): boolean {
|
|
return !definition.formatter;
|
|
}
|
|
|
|
// The one reader of a document payload: through the definition's formatter, codec or plain map, then its schema. A
|
|
// reader holds the definition or it has no business reading the bytes, so there is no definition-less form.
|
|
export function decodeDocumentPayload<TDefinition extends AnyCultCacheDocumentDefinition>(
|
|
definition: TDefinition,
|
|
payload: DocumentPayload,
|
|
): CultCacheDocumentValue<TDefinition> {
|
|
const bytes = documentPayloadBytes(payload);
|
|
if (writesLibraryMessagePack(definition)) {
|
|
// A payload is exactly one MessagePack value: bytes after the first are refused by count, from the walk's end, before
|
|
// any formatter reads; the tail is never walked or echoed. A payload that ends early stays the decoder's to refuse.
|
|
const subject = `document type "${definition.type}" payload`;
|
|
const end = checkPayloadDepth(bytes, subject);
|
|
if (end !== undefined && end < bytes.length) throw new Error(`${subject} has ${bytes.length - end} bytes after its first MessagePack value.`);
|
|
}
|
|
const decoded = resolveDocumentFormatter(definition).decode(bytes);
|
|
return definition.schema.parse(decoded) as CultCacheDocumentValue<TDefinition>;
|
|
}
|
|
|
|
// The one writer: the definition's schema first, then its formatter, codec or plain map.
|
|
export function encodeDocumentPayload(definition: AnyCultCacheDocumentDefinition, value: unknown): DocumentPayload {
|
|
const bytes = resolveDocumentFormatter(definition).encode(definition.schema.parse(value));
|
|
if (writesLibraryMessagePack(definition)) checkPayloadDepth(bytes, `document type "${definition.type}" value`);
|
|
return documentPayloadFromBytes(bytes);
|
|
}
|
|
|
|
// Whether the definition declares `name` as a role alias (either `roles` form).
|
|
export function declaresRole(definition: AnyCultCacheDocumentDefinition, name: string): boolean {
|
|
const roles = definition.roles;
|
|
return Array.isArray(roles) ? roles.some((entry) => entry.name === name) : roles ? Object.hasOwn(roles, name) : false;
|
|
}
|
|
|
|
// The schema members of a document: the ones it declares, else those its codec derives, else none.
|
|
export function resolveDocumentMembers(definition: AnyCultCacheDocumentDefinition): readonly CultCacheSchemaCatalogMember[] {
|
|
if (definition.members || !definition.codec) {
|
|
return definition.members ?? [];
|
|
}
|
|
const codec = definition.codec;
|
|
const indexAlias = (slotName: string) => {
|
|
for (const set of [definition.indexes, definition.roles]) {
|
|
const alias = !Array.isArray(set) && set ? Object.entries(set).find(([, accessor]) => accessor === slotName)?.[0] : undefined;
|
|
if (alias !== undefined) return alias;
|
|
}
|
|
return null;
|
|
};
|
|
return codec.members.map((member, index) => ({
|
|
...member,
|
|
isName: (definition.name as unknown) === codec.names[index],
|
|
indexAlias: indexAlias(codec.names[index]),
|
|
}));
|
|
}
|
|
|
|
// The identity a definition's records carry: the schema id they are stamped with, the schema name and version, and every id
|
|
// the definition answers to (its own first). Every CultCache runtime surface and every CultNet or CultMesh layer over it
|
|
// derives a definition's identity here and nowhere else, so a record's id means the same type wherever it is read.
|
|
export interface CultCacheSchemaIdentity {
|
|
readonly schemaId: string;
|
|
readonly schemaName: string;
|
|
readonly schemaVersion: string;
|
|
readonly compatibleSchemaIds: readonly string[];
|
|
}
|
|
|
|
export function schemaIdentityOf(definition: AnyCultCacheDocumentDefinition): CultCacheSchemaIdentity {
|
|
const schemaName = definition.schemaName ?? definition.type;
|
|
const schemaId = definition.schemaId ?? schemaName;
|
|
return {
|
|
schemaId,
|
|
schemaName,
|
|
schemaVersion: definition.schemaVersion ?? `${schemaName}.v1`,
|
|
compatibleSchemaIds: [...new Set([schemaId, ...(definition.compatibleSchemaIds ?? [])])],
|
|
};
|
|
}
|
|
|
|
export function defineDocumentRegistry<
|
|
TDefinitions extends readonly AnyCultCacheDocumentDefinition[],
|
|
>(...definitions: TDefinitions): CultCacheDocumentRegistry<TDefinitions> {
|
|
return Object.freeze({
|
|
definitions: Object.freeze([...definitions]) as TDefinitions,
|
|
});
|
|
}
|