import { expect, mock, test } from "bun:test"; import { indexing } from "../src/indexing"; import { curate } from "../src/curator"; import { entriesOf, entryValue } from "../src/index-document"; import { resolutionFromFailure, resolutionFromRead } from "../src/resolution"; import type { Nuri, UnionSubject } from "../src/port"; import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary"; import { FakeNextGraph, publishObject } from "./fake-nextgraph"; /** * The invariant this package is built around — an index only ever grows — and the * hole that was in it. * * `entriesOf` used to require EXACTLY ONE value per entry, so a subject carrying * two read as absent. An index could therefore SHRINK through nothing but * additions: no delete involved, the guarantee defeated by the one operation * meant to uphold it. These tests pin the fix at both levels. */ const FIELD = "http://schema.org/datePublished"; function subject(iri: string, values: string[]): UnionSubject { return { subject: iri, graph: "did:ng:o:index" as Nuri, props: { [ENTRY_VALUE]: values } }; } test("an entry with several values still reads as one entry, deterministically", () => { const s = subject("did:ng:o:a", ["2026-02-02", "2026-01-01"]); expect(entryValue(s)).toBe("2026-01-01"); // Order of arrival must not change the answer: two readers must agree. expect(entryValue(subject("did:ng:o:a", ["2026-01-01", "2026-02-02"]))).toBe("2026-01-01"); }); test("a subject with no value at all is not an entry", () => { expect(entryValue({ subject: "did:ng:o:a", graph: "did:ng:o:i" as Nuri, props: {} })).toBeUndefined(); expect(entryValue(subject("did:ng:o:a", []))).toBeUndefined(); }); test("entriesOf keeps a multi-valued entry instead of dropping it", () => { const index = "did:ng:o:index" as Nuri; const entries = entriesOf([subject("did:ng:o:a", ["2026-02-02", "2026-01-01"])], index); expect(entries).toEqual([{ object: "did:ng:o:a" as Nuri, value: "2026-01-01" }]); }); test("one stray non-NURI subject cannot make every real entry unreadable", () => { const index = "did:ng:o:index" as Nuri; // An index document is an ordinary document; its owner may put anything in it. // This used to THROW out of `entriesOf`, losing the whole index to one triple. const entries = entriesOf( [ subject("http://example.org/not-a-nuri", ["2026-02-02"]), subject("did:ng:o:real", ["2026-01-01"]), ], index, ); expect(entries).toEqual([{ object: "did:ng:o:real" as Nuri, value: "2026-01-01" }]); }); test("an entry whose value is the empty string is still an entry", () => { const index = "did:ng:o:index" as Nuri; expect(entriesOf([subject("did:ng:o:a", [""])], index)).toEqual([ { object: "did:ng:o:a" as Nuri, value: "" }, ]); }); test("adding a second value to an entry cannot make it disappear", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const owner = await indexing(ownerPort); const index = await owner.createIndex(FIELD); const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01"); await (await indexing(network.portFor("bob"))).refer(index, article); await curate(ownerPort, index); // A pure ADD — the only write this package has. Before the fix this emptied // `read()` while both triples sat in the document. await ownerPort.addLiteralProperty(index, article, ENTRY_VALUE, "2026-02-02"); expect(await owner.read(index)).toEqual([{ object: article, value: "2026-01-01" }]); }); test("a raced double-add settles, and does not make every later run re-add", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const owner = await indexing(ownerPort); const index = await owner.createIndex(FIELD); const bobPort = network.portFor("bob"); const article = await publishObject(bobPort, FIELD, "2026-01-01"); await (await indexing(bobPort)).refer(index, article); await curate(ownerPort, index); // What two curation runs racing each other leave behind: the object's owner // edited it between their reads, so each added its own value. network.ownerReplacesValue(article, article, FIELD, "2026-02-02"); await ownerPort.addLiteralProperty(index, article, ENTRY_VALUE, "2026-02-02"); // The entry is still there, and the curator recognises it as already indexed — // before the fix it was invisible, so every run added yet another value. const report = await curate(ownerPort, index); expect(report.outcomes).toEqual([{ result: "unchanged", object: article }]); expect(await owner.read(index)).toEqual([{ object: article, value: "2026-01-01" }]); }); // --- the descriptor follows the SAME rule, for the same reason ------------ test("a second declared field stops curation LOUDLY and costs no entry", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const owner = await indexing(ownerPort); const bobPort = network.portFor("bob"); const index = await owner.createIndex(FIELD); for (const date of ["2026-01-01", "2026-02-02", "2026-03-03"]) { await (await indexing(bobPort)).refer(index, await publishObject(bobPort, FIELD, date)); } await curate(ownerPort, index); expect(await owner.read(index)).toHaveLength(3); // One add-only write through the published surface — and the SMALLER string, the // direction in which "smallest wins" would have switched the index onto it. await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, "http://schema.org/aaa"); // Reading is untouched: an entry already written is a fact, and does not become // unreadable because the declaration above it turned ambiguous. expect(await owner.read(index)).toHaveLength(3); // Curating refuses, and says why instead of quietly picking one. await expect(curate(ownerPort, index)).rejects.toThrow(/declares 2 index fields/); }); test("a mixed-field index is never produced: curation refuses before adding anything", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const owner = await indexing(ownerPort); const bobPort = network.portFor("bob"); const NAME = "http://schema.org/name"; const index = await owner.createIndex(NAME); const first = await publishObject(bobPort, NAME, "Anemone"); await (await indexing(bobPort)).refer(index, first); await curate(ownerPort, index); // "…/datePublished" < "…/name", so under "smallest wins" the new field took over // while `first` kept its old value forever — one list ordered by two properties. await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, FIELD); const second = await publishObject(bobPort, FIELD, "2026-02-02"); await (await indexing(bobPort)).refer(index, second); await expect(curate(ownerPort, index)).rejects.toThrow(/refusing to curate rather than pick one/); expect(await owner.read(index)).toEqual([{ object: first, value: "Anemone" }]); }); test("an index declaring no field at all is still refused", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const ordinary = await ownerPort.createPublicDocument(); await expect((await indexing(ownerPort)).read(ordinary)).rejects.toThrow(/declares no index field/); }); test("a field that could never match an object is refused at creation", async () => { const owner = await indexing(new FakeNextGraph().portFor("alice")); // It cannot be corrected later — nothing here deletes — so it is refused now. await expect(owner.createIndex("")).rejects.toThrow(/cannot be changed later/); await expect(owner.createIndex(" ")).rejects.toThrow(/cannot be changed later/); }); // --- a field named like an Object.prototype member ------------------------ test("a field colliding with Object.prototype neither crashes nor is silently mis-read", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const owner = await indexing(ownerPort); const bobPort = network.portFor("bob"); for (const field of ["constructor", "toString", "valueOf", "hasOwnProperty"]) { const index = await owner.createIndex(field); // An object that CARRIES such a predicate cannot be read at all: `readUnion` // fills `props` with `(props[p] ??= []).push(o)`, and `??=` does not assign // over the inherited member, so `.push` is undefined and the read throws. // Upstream's behaviour, mirrored by the double — so this comes back as a // failure to resolve, NOT as an entry. const carries = await publishObject(bobPort, field, "a value"); // An object that merely LACKS it must still resolve cleanly: reading the field // off a plain object literal would otherwise hand back an inherited function. const lacks = await publishObject(bobPort, "http://schema.org/name", "unrelated"); await (await indexing(bobPort)).refer(index, lacks); await (await indexing(bobPort)).refer(index, carries); const report = await curate(ownerPort, index); expect(report.outcomes[0]).toEqual({ result: "skipped", object: lacks, reason: "no-field" }); expect(report.outcomes[1]?.result).toBe("unresolved"); expect(await owner.read(index)).toEqual([]); } }); // --- the resolution rule, which used to be unreachable in the adapter ----- test("an empty read resolves as unresolved — never as an object with no field", () => { const resolution = resolutionFromRead([]); expect(resolution.state).toBe("unresolved"); // The distinction that matters: had this said `present`, the curator would have // filed a FAILED read as `skipped: "no-field"` — a fact about the object. expect(resolution.state === "unresolved" && resolution.reason).toContain("absent, unreadable"); }); test("a non-empty read resolves as present, carrying the subjects through", () => { const subjects = [subject("did:ng:o:a", ["v"])]; expect(resolutionFromRead(subjects)).toEqual({ state: "present", subjects }); }); test("a read that threw resolves as unresolved, naming the error", () => { const resolution = resolutionFromFailure(new Error("broker unreachable")); expect(resolution).toEqual({ state: "unresolved", reason: "Error: broker unreachable" }); }); // --- a failure must SURFACE, not just be returned ------------------------- test("an unresolved reference is warned about, not only reported", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const owner = await indexing(ownerPort); const index = await owner.createIndex(FIELD); const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01"); await (await indexing(network.portFor("bob"))).refer(index, article); network.breakReadsOf(article, "broker unreachable"); const warn = mock((..._args: unknown[]) => {}); const original = console.warn; console.warn = warn; try { await curate(ownerPort, index); } finally { console.warn = original; } expect(warn).toHaveBeenCalledTimes(1); expect(String(warn.mock.calls[0]?.[0])).toContain("broker unreachable"); }); test("a normal run warns about nothing", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const owner = await indexing(ownerPort); const index = await owner.createIndex(FIELD); const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01"); await (await indexing(network.portFor("bob"))).refer(index, article); const warn = mock((..._args: unknown[]) => {}); const original = console.warn; console.warn = warn; let report; try { report = await curate(ownerPort, index); } finally { console.warn = original; } // Assert the run actually DID something — otherwise this passes for a curation // that indexed nothing at all, which would warn about nothing either. expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-01-01" }]); expect(warn).not.toHaveBeenCalled(); }); // --- an unreadable index must not be diagnosed as a malformed one --------- test("an index that could not be read is refused, and says so without blaming the document", async () => { const network = new FakeNextGraph(); const ownerPort = network.portFor("alice"); const owner = await indexing(ownerPort); const index = await owner.createIndex(FIELD); network.breakReadsOf(index, "broker unreachable"); // The real `readUnion` turns a failed read into `[]`, so the failure arrives // looking like a blank document. Whatever the shape, nothing may be written. await expect(curate(ownerPort, index)).rejects.toThrow(); await expect(owner.read(index)).rejects.toThrow(); network.healReadsOf(index); expect(await owner.read(index)).toEqual([]); });