diff --git a/src/brainy.ts b/src/brainy.ts index 59fbe20c..069a77c0 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -3831,13 +3831,45 @@ export class Brainy implements BrainyInterface { * @returns The operation to add to the caller's batch, or `null` when * nothing could be done (already narrated + tracked as degraded). */ + /** + * @description A JSON-safe view of a record bound for the metadata-index + * crossing. The seam's metadata is JSON-safe BY CONTRACT (a native provider + * serializes it; u64 ints as Number corrupt above 2^53) — but + * {@link resolveVerbEndpointInts} MIRRORS the resolved endpoint ints onto + * the verb object itself as BigInt (`verb.sourceInt`/`targetInt`), so a + * verb object reused as index metadata carried BigInts into + * JSON.stringify, which throws, aborting the whole transaction (found by + * the first joint pair gate). 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. + * @param metadata - The candidate index-metadata record. + * @returns The same object when already JSON-safe, else a shallow copy + * without the BigInt-valued keys. + */ + private static jsonSafeIndexMetadata(metadata: unknown): unknown { + if (metadata === null || typeof metadata !== 'object') return metadata + const rec = metadata as Record + let hasBigint = false + for (const k in rec) { + if (typeof rec[k] === 'bigint') { hasBigint = true; break } + } + if (!hasBigint) return metadata + const out: Record = {} + for (const k in rec) { + if (typeof rec[k] !== 'bigint') out[k] = rec[k] + } + return out + } + private metadataIndexRetractionOp( id: string, metadata: unknown, context: string ): Operation | null { if (metadata) { - return new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata, this.indexWriteGeneration) + return new RemoveFromMetadataIndexOperation( + this.metadataIndex, id, Brainy.jsonSafeIndexMetadata(metadata), this.indexWriteGeneration + ) } const prov = this.metadataIndex as unknown as { removeEntityById?: (id: string) => Promise @@ -5114,7 +5146,7 @@ export class Brainy implements BrainyInterface { // read above); `updatedMetadata` is the raw stored record just // persisted — the same shape relate()/rebuild() use to add. tx.addOperation( - new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, existing, this.indexWriteGeneration) + new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, Brainy.jsonSafeIndexMetadata(existing), this.indexWriteGeneration) ) tx.addOperation( new AddToMetadataIndexOperation(this.metadataIndex, params.id, updatedMetadata, this.indexWriteGeneration) diff --git a/tests/integration/verb-metadata-rows.test.ts b/tests/integration/verb-metadata-rows.test.ts index 79ce00d0..ff08132a 100644 --- a/tests/integration/verb-metadata-rows.test.ts +++ b/tests/integration/verb-metadata-rows.test.ts @@ -187,4 +187,40 @@ describe('verb metadata rows — the live path matches the rebuild walk', () => expect(await index.getIds('tag', 'parity-f')).toEqual([]) }) + + + it('the metadata crossing never carries BigInt endpoint ints — a cascade delete after graph resolution survives JSON', async () => { + // resolveVerbEndpointInts MIRRORS the resolved u64 ints onto the verb + // object as BigInt (verb.sourceInt/targetInt). A provider that JSON- + // serializes the metadata crossing dies on BigInt — found by the first + // joint pair gate. This pin drives the exact shape: relate (graph legs + // resolve ints), then remove the source entity (the cascade passes the + // SAME verb object to the retraction), through a provider shim that + // enforces the JSON-safety contract the way a native provider does. + const employee = await brain.add({ data: 'cascade employee', type: 'person' }) + const invoice = await brain.add({ data: 'cascade invoice', type: 'document' }) + await brain.relate({ from: employee, to: invoice, type: 'relatedTo' }) + const mgr: any = (brain as any).metadataIndex + const origRemove = mgr.removeFromIndex.bind(mgr) + const seen: unknown[] = [] + mgr.removeFromIndex = async (id: string, metadata?: unknown, generation?: bigint) => { + seen.push(metadata) + JSON.stringify(metadata) // the contract: throws on BigInt, exactly like a native crossing + return origRemove(id, metadata, generation) + } + try { + await brain.remove(employee) // cascades the relation's retraction + } finally { + mgr.removeFromIndex = origRemove + } + expect(seen.length).toBeGreaterThan(0) + for (const m of seen) { + if (m && typeof m === 'object') { + for (const [k, v] of Object.entries(m as Record)) { + expect(typeof v, `metadata key ${k} must be JSON-safe`).not.toBe('bigint') + } + } + } + }) + })