Files
ng-helpers/test/only-grows.test.ts
T

300 lines
14 KiB
TypeScript

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("curating a document that declares no field refuses, and writes nothing", async () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const bobPort = network.portFor("bob");
// A document of Alice's with an inbox open and a reference waiting in it, and no
// field declared. This is also the shape an INDEX arrives in when it could not be
// read — the real `readUnion` turns a failed read into `[]` — so the two are one
// case here, and the refusal has to hold for both.
const noField = await ownerPort.createPublicDocument();
await ownerPort.openInbox(noField);
const article = await publishObject(bobPort, FIELD, "2026-01-01");
await (await indexing(bobPort)).refer(noField, article);
await expect(curate(ownerPort, noField)).rejects.toThrow(/declares no index field/);
// It refused instead of curating on a field it does not have, and it refused
// BEFORE writing: the document is still empty, so no entry was invented for it,
// and the deposit is still in the inbox for a run that knows what to do with it.
expect(await ownerPort.readDocument(noField)).toEqual([]);
expect(await ownerPort.readDeposits(noField)).toHaveLength(1);
});
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([]);
});