transact()'s delete legs (direct unrelate and the noun-remove cascade) hand the SAME verb object to the graph-retraction op and the metadata-retraction op. The metadata leg sanitized at PLAN time, when the verb was still clean, so the wrap returned the same reference — then the graph op's execute-time endpoint resolution (deliberately deferred for same-batch forward refs) mirrored BigInt sourceInt/targetInt onto the shared object, and the metadata op crossed the seam with them. A strict provider rightly refuses that crossing, so every transact-wrapped edge delete aborted; direct unrelate() resolves ints at build time, before its sanitize, which is why no existing gate saw it. The JSON-safe view now lives in a shared leaf (utils/jsonSafeIndexMetadata) and is applied INSIDE AddToMetadataIndexOperation and RemoveFromMetadataIndexOperation at execute and rollback time — the one place no plan-vs-execute ordering can bypass. Pins: the fleet repro, the cascade shape, a mixed batch, and unit pins that mutate the entity after construction against a strict seam (5 red before, 5 green after).
47 lines
2.2 KiB
TypeScript
47 lines
2.2 KiB
TypeScript
/**
|
|
* @module utils/jsonSafeIndexMetadata
|
|
* @description The metadata-index crossing's JSON-safety law, as a leaf
|
|
* function both the coordinator and the transaction operations share.
|
|
*
|
|
* The seam's metadata is JSON-safe BY CONTRACT (a native provider serializes
|
|
* it; u64 ints as Number corrupt above 2^53) — but `resolveVerbEndpointInts`
|
|
* MIRRORS the resolved endpoint ints onto the verb object itself as BigInt
|
|
* (`verb.sourceInt`/`targetInt`), so a verb object reused as index metadata
|
|
* carries BigInts into JSON.stringify, which throws, aborting the whole
|
|
* transaction. Endpoint ints ride their OWN op params on the graph legs — the
|
|
* metadata crossing drops every BigInt-valued top-level key instead of
|
|
* guessing at a lossy numeric encoding.
|
|
*
|
|
* WHY THIS IS A LEAF MODULE, ENFORCED AT THE CROSSING: sanitizing only at
|
|
* operation-construction time is not enough. `transact()`'s delete legs pass
|
|
* the SAME verb object to both the graph-retraction op (whose endpoint-int
|
|
* thunk deliberately resolves at EXECUTE time, for same-batch forward refs)
|
|
* and the metadata-retraction op. At plan time the verb is still clean, so a
|
|
* plan-time sanitize returns the same reference — then the graph op executes
|
|
* first, mirrors the BigInt ints onto the shared object, and the metadata op
|
|
* crosses the seam with them (found by the first fleet adoption of the native
|
|
* pair: every transact-wrapped edge delete aborted). The crossing itself is
|
|
* the only place ordering cannot bypass.
|
|
*/
|
|
|
|
/**
|
|
* A JSON-safe view of a record bound for the metadata-index crossing.
|
|
*
|
|
* @param metadata - The candidate index-metadata record.
|
|
* @returns The same object when already JSON-safe, else a shallow copy
|
|
* without the BigInt-valued keys.
|
|
*/
|
|
export function jsonSafeIndexMetadata(metadata: unknown): unknown {
|
|
if (metadata === null || typeof metadata !== 'object') return metadata
|
|
const rec = metadata as Record<string, unknown>
|
|
let hasBigint = false
|
|
for (const k in rec) {
|
|
if (typeof rec[k] === 'bigint') { hasBigint = true; break }
|
|
}
|
|
if (!hasBigint) return metadata
|
|
const out: Record<string, unknown> = {}
|
|
for (const k in rec) {
|
|
if (typeof rec[k] !== 'bigint') out[k] = rec[k]
|
|
}
|
|
return out
|
|
}
|