feat!: le paquet crée un index et lui ajoute une référence, rien de plus

This commit is contained in:
Sylvain Duchesne
2026-08-21 19:56:11 +02:00
parent c2f9ff4674
commit ff78a70f14
26 changed files with 735 additions and 2651 deletions
+62 -138
View File
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test";
import { indexing } from "../src/indexing";
import { curate } from "../src/curator";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
import { indexingOn } from "../src/indexing";
import { INDEX_FIELD } from "../src/vocabulary";
import type { NextGraphPort, Nuri } from "../src/port";
import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill";
@@ -10,22 +9,18 @@ import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill
*
* ## What was wrong with the gate this replaces
*
* It mocked the polyfill to constants and drove exactly TWO of the adapter's seven
* methods — `addLiteralProperty` and (through `createIndex`) `createPublicDocument`
* and `openInbox`. Everything else was inert. Six methods could be gutted — write
* nothing, return `[]` — with the suite still green, and the gate's own promise
* ("every query it emits is read back") held only for the queries those two methods
* emitted. A destructive statement planted in `readDeposits` was never recorded,
* because `readDeposits` was never called. That matters most exactly there:
* retiring an applied deposit — the README's own open question — lands in
* `readDeposits`.
* It mocked the polyfill to constants and drove only the methods `create` happens to
* use. Everything else was inert: a method could be gutted — write nothing, return
* `[]` — with the suite still green, and the gate's own promise ("every query it
* emits is read back") held only for the queries those methods emitted. A
* destructive statement planted in a method nobody drove was never recorded.
*
* ## What holds now
*
* The adapter runs on `fake-polyfill.ts`, an in-memory polyfill that ANSWERS rather
* than returning constants, so the tests below are ordinary behavioural tests that
* happen to run through the real wiring. Gut any of the seven and something here
* goes red, because each one is now load-bearing for an outcome that is asserted.
* happen to run through the real wiring. Gut any of the four and something here goes
* red, because each one is load-bearing for an outcome that is asserted.
*
* Three properties are checked on top of behaviour, and each closes a hole the
* review walked through:
@@ -83,122 +78,39 @@ async function publish(port: NextGraphPort, field: string, value: string): Promi
return object;
}
// --- the whole loop, through the real adapter -----------------------------
// --- both acts, through the real adapter ----------------------------------
test("the real adapter carries the whole loop: create, publish, refer, curate, read", async () => {
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
test("the real adapter carries both acts: create, publish, add", async () => {
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
const article = await as("bob", async (bob) => {
const object = await publish(bob, FIELD, "2026-07-08");
await (await indexing(bob)).refer(index, object);
await indexingOn(bob).add(index, object);
return object;
});
const report = await as("alice", (alice) => curate(alice, index));
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]);
const entries = await as("alice", async (alice) => (await indexing(alice)).read(index));
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]);
});
test("a deposit made while the owner is connected is curated as it lands, with nobody asking", async () => {
const seen = await as("alice", async (alice) => {
const api = await indexing(alice);
const index = await api.createIndex(FIELD);
// An owner may deposit into her own index: `refer` is open to anyone, and here it
// keeps both sides on one session, which is all this fake models at a time.
const object = await publish(alice, FIELD, "2026-09-09");
await api.refer(index, object);
// NOTHING CALLS CURATION. The session is told a deposit landed on an inbox it
// watches, and processes that inbox itself — through the real adapter, so the
// address resolution and `inbox.watch` are the ones an application would get.
await world.deliverNotifications();
return { object, subjects: await alice.readDocument(index) };
});
expect(seen.subjects.find((s) => s.subject === seen.object)?.props[ENTRY_VALUE]).toEqual([
"2026-09-09",
// The index declares its field, and Bob's bare reference is waiting in its inbox.
// That is the whole of what these two acts do; making an entry of that reference
// is the business of the layer below, and nothing here can do it or ask for it.
expect(world.contentsOf(index)).toEqual([
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
]);
expect(world.depositsIn(index)).toEqual([{ from: "bob", payload: article, ts: 1 }]);
});
test("two indexes are two documents, each owned by whoever created it", async () => {
const [first, second] = await as("alice", async (alice) => [
await (await indexing(alice)).createIndex(FIELD),
await (await indexing(alice)).createIndex(FIELD),
await indexingOn(alice).create(FIELD),
await indexingOn(alice).create(FIELD),
]);
expect(first).not.toBe(second);
// A stranger holding the NURI still cannot write it — reaching is not owning.
await expect(
as("bob", (bob) => bob.addLiteralProperty(first, first, ENTRY_VALUE, "2026-01-01")),
as("bob", (bob) => bob.addLiteralProperty(first, first, INDEX_FIELD, "urn:forged")),
).rejects.toThrow(/only a document's owner writes to it/);
});
// --- the two answers a resolve may give, and why they must stay apart -----
test("a reference that could not be READ comes back unresolved", async () => {
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
const article = await as("bob", async (bob) => {
const object = await publish(bob, FIELD, "2026-07-08");
await (await indexing(bob)).refer(index, object);
return object;
});
// `readDoc` catches and yields `[]`, so a failed read arrives looking exactly
// like an object that holds nothing. Telling them apart is not possible; filing
// the failure as a FACT about the object is what must not happen.
world.breakReadsOf(article, "broker unreachable");
try {
const report = await as("alice", (alice) => curate(alice, index));
expect(report.outcomes).toEqual([
{ result: "unresolved", object: article, reason: expect.stringContaining("absent") },
]);
} finally {
world.healReadsOf(article);
}
});
test("an object that really carries nothing for the field is SKIPPED — a different answer", async () => {
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
const unrelated = await as("bob", async (bob) => {
const object = await publish(bob, "http://schema.org/name", "Anemone");
await (await indexing(bob)).refer(index, object);
return object;
});
// Paired with the test above ON PURPOSE. `resolveObject` is the only thing
// keeping these two apart: replace `resolutionFromRead(subjects)` with
// `{ state: "present", subjects }` and the unreadable object above is reported
// here's answer instead — a broker failure filed as a property of the object.
const report = await as("alice", (alice) => curate(alice, index));
expect(report.outcomes).toEqual([
{ result: "skipped", object: unrelated, reason: "no-field" },
]);
});
test("an EMPTY read is unresolved, straight off the adapter method", async () => {
// The same rule as the pair above, asserted directly rather than through a
// curation report, so the wiring of `resolutionFromRead` is held by two
// independent tests and not by one.
const blank = await as("alice", (alice) => alice.createPublicDocument());
const resolution = await as("alice", (alice) => alice.resolveObject(blank));
expect(resolution.state).toBe("unresolved");
expect(resolution.state === "unresolved" && resolution.reason).toContain("absent, unreadable");
});
test("a read that REJECTS outright is unresolved too, and names the failure", async () => {
world.breakReadUnion("session lost");
try {
const resolution = await as("alice", (alice) => alice.resolveObject("did:ng:o:whatever"));
expect(resolution.state).toBe("unresolved");
expect(resolution.state === "unresolved" && resolution.reason).toContain("session lost");
} finally {
world.healReadUnion();
}
});
// --- the inbox, from both sides -------------------------------------------
test("a document whose owner never opened an inbox REFUSES the deposit", async () => {
@@ -209,34 +121,33 @@ test("a document whose owner never opened an inbox REFUSES the deposit", async (
);
});
test("anyone may deposit into an index, only its owner may read what was deposited", async () => {
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
await as("bob", async (bob) => (await indexing(bob)).refer(index, "did:ng:o:some-object"));
test("anyone may hand a reference to an index they do not own", async () => {
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
await as("bob", async (bob) => indexingOn(bob).add(index, "did:ng:o:some-object"));
const own = await as("alice", (alice) => alice.readDeposits(index));
expect(own.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]);
expect(own[0]?.from).toBe("bob");
await expect(as("bob", (bob) => bob.readDeposits(index))).rejects.toThrow(
/may only READ your own/,
);
// Bob needed no permission and got no write, and the deposit carries who made it.
const waiting = world.depositsIn(index);
expect(waiting?.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]);
expect(waiting?.[0]?.from).toBe("bob");
expect(world.contentsOf(index)).toEqual([
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
]);
});
// --- what the adapter actually wrote --------------------------------------
test("readDocument returns what was written, and refuses a document that is no index", async () => {
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
const subjects = await as("alice", (alice) => alice.readDocument(index));
expect(subjects).toEqual([{ subject: index, graph: index, props: { [INDEX_FIELD]: [FIELD] } }]);
test("creating an index writes its field declaration, and nothing else", async () => {
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
expect(world.contentsOf(index)).toEqual([
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
]);
// …and an ordinary public document is left exactly as it was made.
const ordinary = await as("alice", (alice) => alice.createPublicDocument());
await expect(as("alice", async (alice) => (await indexing(alice)).read(ordinary))).rejects.toThrow(
/declares no index field/,
);
expect(world.contentsOf(ordinary)).toEqual([]);
});
test("the write is the polyfill's canonical anchored form: the document named once, as the anchor", async () => {
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
const writes = world.calls.filter((call) => call.entry === "docs.sparqlUpdate");
const last = writes.at(-1);
expect(last?.args[1]).toBe(`INSERT DATA { <${index}> <${INDEX_FIELD}> "${FIELD}" }`);
@@ -247,17 +158,15 @@ test("the write is the polyfill's canonical anchored form: the document named on
test("a hostile value lands as ONE inert literal, not as a second statement", async () => {
const HOSTILE = '" } ; DROP GRAPH <did:ng:o:doc-1> ; INSERT DATA { <a> <b> "c';
const doc = await as("alice", async (alice) => {
const object = await alice.createPublicDocument();
await alice.addLiteralProperty(object, object, ENTRY_VALUE, HOSTILE);
return object;
});
// A field is caller-supplied and goes straight into the statement, so it is the
// value this package really does have to survive.
const index = await as("alice", async (alice) => indexingOn(alice).create(HOSTILE));
// Semantic, not a quote count: the value round-trips through a parser that would
// have refused the query outright had the literal closed early — and had it
// closed early and still parsed, the extra subject would show up here.
expect(world.contentsOf(doc)).toEqual([
{ subject: doc, predicate: ENTRY_VALUE, values: [HOSTILE] },
expect(world.contentsOf(index)).toEqual([
{ subject: index, predicate: INDEX_FIELD, values: [HOSTILE] },
]);
});
@@ -278,9 +187,24 @@ test("nothing the adapter sent the polyfill, in any method, carries a destructiv
}
});
test("the port the entry point builds has NO operation that reads or removes", async () => {
// Building it must not touch the broker: `indexing(sessionId)` builds one for every
// handle, and only the calls on it talk to anything. This check lived on the
// published surface while `polyfillPort` was published; the port is internal now,
// and the properties it guards are not.
const { polyfillPort } = await import("../src/polyfill-adapter");
const port = polyfillPort({ sessionId: "session:alice" });
expect(typeof port.createPublicDocument).toBe("function");
expect(typeof port.addLiteralProperty).toBe("function");
const forbidden = Object.keys(port).filter((name) =>
/delete|remove|clear|drop|read|list|watch/i.test(name),
);
expect(forbidden).toEqual([]);
});
test("LAST — every method the adapter exposes was driven above", async () => {
// Read off the adapter itself, so there is no list to remember to extend: add an
// eighth method and this fails until something above actually calls it. That is
// Read off the adapter itself, so there is no list to remember to extend: add a
// fifth method and this fails until something above actually calls it. That is
// the whole answer to "the gate proves the methods it drives" — it now names them
// from the code rather than from a test's memory.
const { polyfillPort } = await import("../src/polyfill-adapter");
+37 -264
View File
@@ -1,11 +1,4 @@
import type {
IncomingDeposit,
NextGraphPort,
Nuri,
NuriLike,
ObjectResolution,
UnionSubject,
} from "../src/port";
import type { NextGraphPort, Nuri, NuriLike } from "../src/port";
import { asNuri } from "../src/nuri";
/**
@@ -18,60 +11,41 @@ import { asNuri } from "../src/nuri";
*
* - only a document's owner writes to it, and a read key never grants a write
* (`contract_polyfill-surface`, "Guarantees");
* - anyone may DEPOSIT into a document's inbox, but reading one THROWS for anyone
* but its owner (`inbox.read`'s `assertOwnInbox`: "you may DEPOSIT into anyone's
* inbox; you may only READ your own");
* - depositing into a document whose owner never opened an inbox THROWS
* (`inbox.postToDocument`), rather than silently going nowhere;
* - anyone may DEPOSIT into a document's inbox (`inbox.postToDocument`);
* - depositing into a document whose owner never opened an inbox THROWS, rather
* than silently going nowhere;
* - opening an inbox on a document is refused to anyone but its owner
* (`openDocumentInbox`: doing so publishes the document's address);
* - a document in a public store is readable by whoever knows its NURI;
* - a document that cannot be read REJECTS, and a rejection means "unknown",
* never "absent";
* - being TOLD what landed in an inbox is reading it, so watching one is refused to
* anyone but the document's owner, exactly as opening one is.
* - a document nobody created cannot be named: reaching for one REJECTS.
*
* ## Telling a watcher crosses the network, so it is a step of its own
* ## Reading is done from OUTSIDE the port, on purpose
*
* A deposit is stored the moment it is made — that is the durable fact, and it is
* what the owner's next connection finds. Notifying a session that is watching goes
* over the wire, and this fake holds those notifications until a test calls
* {@link FakeNextGraph.deliverNotifications}. A test that never calls it is a test
* in which the owner has not been told yet: a real state, and precisely the one the
* catch-up at connection exists for.
* This package neither reads a document nor reads an inbox — what an index receives
* is made into entries by the layer below. So `contentsOf` and `depositsIn` are
* inspections of this double, not operations of the port: a test asks what the two
* acts LEFT BEHIND, and cannot accidentally hand the package back a way to read.
*/
type Properties = Map<string, string[]>;
/** One session watching one document's inbox. */
interface Watch {
readonly doc: Nuri;
readonly user: string;
readonly onDeposits: () => Promise<void>;
/** One deposit this double is holding, as its inbox holds it. */
export interface StoredDeposit {
/** The depositor, as the polyfill defaults it to the current user. */
readonly from: string;
readonly payload: unknown;
readonly ts: number;
}
type Properties = Map<string, string[]>;
interface StoredDocument {
readonly nuri: Nuri;
readonly owner: string;
readonly subjects: Map<string, Properties>;
/** `undefined` until the owner opens one — the state `postToDocument` refuses. */
deposits: IncomingDeposit[] | undefined;
deposits: StoredDeposit[] | undefined;
}
export class FakeNextGraph {
readonly #documents = new Map<string, StoredDocument>();
/** Documents the broker currently cannot answer about. See `breakReadsOf`. */
readonly #unreachable = new Map<string, string>();
/** Inboxes the broker currently cannot READ. See `breakInboxReadsOf`. */
readonly #inboxUnreadable = new Map<string, string>();
/** Inboxes the broker currently refuses to WATCH. See `breakWatchingOf`. */
readonly #inboxUnwatchable = new Map<string, string>();
/** Every live watch, across every identity — a session watching its own inbox. */
#watches: Watch[] = [];
/** Notifications the broker has not handed over yet. See `deliverNotifications`. */
#undelivered: Watch[] = [];
/** Why a store listing cannot answer, when a test has made it fail. */
#listingFailure: string | undefined;
#documentCount = 0;
#clock = 0;
@@ -83,14 +57,6 @@ export class FakeNextGraph {
return network.#createDocument(user);
},
async resolveObject(doc: NuriLike): Promise<ObjectResolution> {
return network.#resolve(asNuri(doc));
},
async readDocument(doc: NuriLike): Promise<readonly UnionSubject[]> {
return network.#read(asNuri(doc));
},
async addLiteralProperty(
doc: NuriLike,
subject: string,
@@ -107,125 +73,27 @@ export class FakeNextGraph {
async depositTo(doc: NuriLike, payload: unknown): Promise<void> {
network.#deposit(user, asNuri(doc), payload);
},
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
return network.#readDeposits(user, asNuri(doc));
},
async watchDeposits(doc: NuriLike, onDeposits: () => Promise<void>): Promise<void> {
network.#watchDeposits(user, asNuri(doc), onDeposits);
},
async listPublicDocuments(): Promise<readonly Nuri[]> {
return network.#listDocuments(user);
},
};
}
/**
* Makes the broker unable to answer about a document — a transient failure, the
* state a curation run must survive without damaging the index. Reads of it
* REJECT, which is what the real surface does when it could not find out.
*/
breakReadsOf(doc: NuriLike, reason: string): void {
this.#unreachable.set(asNuri(doc), reason);
}
/** The broker can answer about this document again. */
healReadsOf(doc: NuriLike): void {
this.#unreachable.delete(asNuri(doc));
}
/**
* The broker serves the DOCUMENT but not its INBOX.
*
* Not a contrivance: upstream an inbox is a repo of its own, reached through an
* address `openDocumentInbox` resolves and read with that repo's capability,
* while the document itself is read by `readUnion`. Two repos, two reads — so
* one answering while the other does not is what a partial failure looks like,
* and it is the state that makes a catch-up fail on one index and no other.
*/
breakInboxReadsOf(doc: NuriLike, reason: string): void {
this.#inboxUnreadable.set(asNuri(doc), reason);
}
/** The inbox can be read again. */
healInboxReadsOf(doc: NuriLike): void {
this.#inboxUnreadable.delete(asNuri(doc));
}
/**
* The broker refuses to keep this session posted about that inbox, while
* everything else about it still works.
*
* Watching is a live subscription, set up and held open for as long as the
* session lasts; reading an inbox is one question and one answer. A subscription
* can be refused where a read succeeds, which is the state that leaves an index
* caught up but unwatched — deposits into it going unnoticed until the next
* connection, exactly as the failure this models says.
*/
breakWatchingOf(doc: NuriLike, reason: string): void {
this.#inboxUnwatchable.set(asNuri(doc), reason);
}
/** The inbox can be watched again. */
healWatchingOf(doc: NuriLike): void {
this.#inboxUnwatchable.delete(asNuri(doc));
}
/**
* Hands over every inbox notification the broker was holding, and waits for the
* sessions watching to finish with them — including notifications those very runs
* provoke, so this returns with nothing left in flight.
*/
async deliverNotifications(): Promise<void> {
while (this.#undelivered.length > 0) {
const batch = this.#undelivered;
this.#undelivered = [];
for (const watch of batch) await watch.onDeposits();
}
}
/**
* This identity's page is gone: every watch its sessions had opened stops, and
* anything the broker was about to tell them is dropped. The polyfill's watching
* lasts exactly as long as an identity stays connected, and so does this.
*
* Nothing durable is lost — the deposits are in their inboxes, which is what makes
* the catch-up at the next connection enough on its own.
*/
disconnect(user: string): void {
this.#undelivered = this.#undelivered.filter((watch) => watch.user !== user);
this.#watches = this.#watches.filter((watch) => watch.user !== user);
}
/**
* The store can no longer say which documents an identity has. Upstream throws
* rather than answer a listing it could not establish, so this does too.
*/
breakListing(reason: string): void {
this.#listingFailure = reason;
}
/** A NURI shaped like any other, that no document was ever created for. */
neverCreatedNuri(): Nuri {
return "did:ng:o:doc-never-created" as Nuri;
}
/**
* The document's owner REPLACING a value in their own document, through their
* own application — NOT through this package's port, which deliberately cannot
* delete anything.
*
* This is a real capability and it has to be modelled: an indexed object is an
* ordinary document whose owner keeps editing it, and an index tested only
* against frozen objects would be tested against a world that does not exist.
*/
ownerReplacesValue(doc: NuriLike, subject: string, predicate: string, value: string): void {
/** Every triple a document holds, read from outside the port. */
contentsOf(doc: NuriLike): { subject: string; predicate: string; values: string[] }[] {
const stored = this.#require(asNuri(doc));
const properties = stored.subjects.get(subject);
if (properties === undefined) throw new Error(`${String(doc)} has no subject ${subject}`);
properties.set(predicate, [value]);
const out: { subject: string; predicate: string; values: string[] }[] = [];
for (const [subject, properties] of stored.subjects) {
for (const [predicate, values] of properties) out.push({ subject, predicate, values });
}
return out;
}
/**
* What is waiting in a document's inbox, oldest first — or `null` when its owner
* never opened one, which is a state and not a failure.
*/
depositsIn(doc: NuriLike): readonly StoredDeposit[] | null {
const stored = this.#require(asNuri(doc));
if (stored.deposits === undefined) return null;
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
}
#createDocument(owner: string): Nuri {
@@ -236,53 +104,11 @@ export class FakeNextGraph {
}
#require(doc: Nuri): StoredDocument {
const broken = this.#unreachable.get(doc);
// "A rejection means 'unknown', never 'absent'."
if (broken !== undefined) throw new Error(`cannot reach ${doc}: ${broken}`);
const stored = this.#documents.get(doc);
if (stored === undefined) throw new Error(`cannot open ${doc}`);
return stored;
}
#read(doc: Nuri): UnionSubject[] {
const stored = this.#require(doc);
const out: UnionSubject[] = [];
for (const [subject, properties] of stored.subjects) {
// Built EXACTLY as `readUnion` builds it — a plain object literal filled by
// `(props[p] ??= []).push(o)` (`read-model.ts`). Neither detail is cosmetic:
// the literal inherits from `Object.prototype`, and `??=` does NOT assign
// over an inherited truthy member, so a predicate named `constructor` or
// `toString` leaves `.push` undefined and the read THROWS. Assigning plainly
// here made this double more forgiving than the real thing, and a test
// written against it asserted an outcome production can never produce.
const props: Record<string, string[]> = {};
for (const [predicate, values] of properties) {
for (const value of values) {
(props[predicate] ??= []).push(value);
}
}
out.push({ subject, graph: doc, props });
}
return out;
}
/**
* Two states, like the port: something is there, or nothing usable came back.
* A document that cannot be reached and a document that holds nothing both come
* back `unresolved` — this layer never has to tell them apart, and the reason
* string is the only difference.
*/
#resolve(doc: Nuri): ObjectResolution {
let subjects: UnionSubject[];
try {
subjects = this.#read(doc);
} catch (error) {
return { state: "unresolved", reason: String(error) };
}
if (subjects.length === 0) return { state: "unresolved", reason: `${doc} holds nothing` };
return { state: "present", subjects };
}
#add(user: string, doc: Nuri, subject: string, predicate: string, value: string): void {
const stored = this.#require(doc);
if (stored.owner !== user) {
@@ -323,65 +149,12 @@ export class FakeNextGraph {
}
this.#clock += 1;
stored.deposits.push({ from: user, payload, ts: this.#clock });
// Stored first, told afterwards: the deposit is a fact even if nobody is ever
// told, which is what makes the catch-up at connection sufficient on its own.
for (const watch of this.#watches) {
if (watch.doc === doc) this.#undelivered.push(watch);
}
}
#watchDeposits(user: string, doc: Nuri, onDeposits: () => Promise<void>): void {
const stored = this.#require(doc);
if (stored.owner !== user) {
throw new Error(
`${user} may not watch the inbox of ${doc}: being told what landed in an inbox ` +
"is reading it, and you may only READ your own",
);
}
const unwatchable = this.#inboxUnwatchable.get(doc);
// Refused AFTER the owner check: resolving the address is an owner-only act, so
// a stranger is turned away before any subscription is ever attempted.
if (unwatchable !== undefined) {
throw new Error(`cannot watch the inbox of ${doc}: ${unwatchable}`);
}
// Watching resolves the inbox address, and the call that resolves one opens it
// when there is none — the same idempotent call `openInbox` makes.
stored.deposits ??= [];
this.#watches.push({ doc, user, onDeposits });
}
#listDocuments(user: string): readonly Nuri[] {
if (this.#listingFailure !== undefined) {
throw new Error(`cannot list the public store: ${this.#listingFailure}`);
}
const mine: Nuri[] = [];
for (const stored of this.#documents.values()) {
if (stored.owner === user) mine.push(stored.nuri);
}
return mine;
}
#readDeposits(user: string, doc: Nuri): readonly IncomingDeposit[] {
const stored = this.#require(doc);
// No inbox → there is no address to read, which is a state and not an error.
if (stored.deposits === undefined) return [];
if (stored.owner !== user) {
throw new Error(
`${user} may not read the inbox of ${doc}: you may DEPOSIT into anyone's ` +
"inbox, you may only READ your own",
);
}
const unreadable = this.#inboxUnreadable.get(doc);
if (unreadable !== undefined) {
throw new Error(`cannot read the inbox of ${doc}: ${unreadable}`);
}
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
}
}
/**
* A public document holding one business entity with a value for `field` — what
* an application creates and then refers to an index.
* an application creates and then hands to an index.
*
* Built through the very same port an application has: nothing here reaches
* behind the surface to plant a state a real caller could not produce.
+35 -179
View File
@@ -1,5 +1,5 @@
import { mock } from "bun:test";
import type { Nuri, UnionSubject } from "../src/port";
import type { Nuri } from "../src/port";
/**
* `@ng-eventually/polyfill` itself, in memory — NOT a mock returning constants.
@@ -9,15 +9,12 @@ import type { Nuri, UnionSubject } from "../src/port";
* one stands BELOW the adapter, so the real adapter runs on top of it and every
* behavioural test becomes a test of the wiring too.
*
* ## Why a real implementation and not a recorder returning `[]`
* ## Why a real implementation and not a recorder returning constants
*
* The previous gate mocked `readUnion` to a constant `[]`. That single constant
* was an escape hatch: a destructive statement guarded by
* `if ((await readUnion([graph])).length > 0)` never ran, so it was never
* recorded, and the gate passed. A mock that always answers the same thing tests
* one state of the world. This answers what was actually written, so a curation
* run that adds an entry to a document already holding a descriptor DOES take the
* non-empty branch.
* A mock that always answers the same thing tests one state of the world, and a
* statement guarded by what a read returned would never run under it. This one
* answers what was actually written, so `contentsOf` and `depositsIn` report what
* the adapter really left behind rather than what a test arranged.
*
* ## Why the SPARQL is EXECUTED and not pattern-matched
*
@@ -33,29 +30,20 @@ import type { Nuri, UnionSubject } from "../src/port";
* ## Every rule below is one the polyfill actually enforces
*
* - only a document's owner writes to it (`docs.sparqlUpdate`'s `assertMayWrite`);
* - anyone may DEPOSIT into a document's inbox, but reading one throws for anyone
* but its owner (`inbox.read`'s `assertOwnInbox`);
* - depositing into a document whose owner never opened an inbox THROWS
* (`inbox.postToDocument`), rather than going nowhere;
* - a document with no inbox READS as `[]` — a state, not an error
* (`depositsForDocument`);
* - anyone may DEPOSIT into a document's inbox (`inbox.postToDocument`);
* - depositing into a document whose owner never opened an inbox THROWS, rather
* than going nowhere;
* - opening an inbox is refused to anyone but the document's owner
* (`openDocumentInbox`), and so is watching one, since being told what landed in an
* inbox is reading it;
* - watching lasts exactly as long as the identity stays connected: signing in as
* somebody else stops every watch the previous identity had opened
* (`contract_polyfill-surface`, "Guarantees");
* - `readUnion` swallows a failing document into `[]` (`readDoc`'s
* `try {…} catch { return [] }`), and may also reject outright;
* - `readUnion` builds each subject's props as a plain object literal filled by
* `(props[p] ??= []).push(o)` — inherited members and all.
* (`openDocumentInbox`: doing so publishes the document's address);
* - a document with no inbox has no address to read — a state, not an error.
*
* ## What is NOT modelled, and what happens then
*
* Anything the adapter reaches for on `docs`, `inbox` or `storeRegistry` that is
* absent here THROWS by name (see {@link namespace}) instead of returning
* `undefined` — so routing a query through `docs.sparqlQuery` is a red test with a
* message that says so. A brand-new TOP-LEVEL import is the one case that degrades:
* message that says so. This package reads nothing at all, so `readUnion` is not
* modelled either: reaching for it is the same red. A brand-new TOP-LEVEL import is the one case that degrades:
* Bun's `mock.module` materialises the module namespace from own keys, so a Proxy
* there is lost and an unmodelled top-level export arrives as `undefined`. Still
* red (`undefined is not a function`), just with a duller message.
@@ -265,25 +253,14 @@ export interface FakePolyfill {
signIn(user: string): void;
/** The session id for the signed-in identity, as `init`'s callback hands it over. */
sessionId(): string;
/**
* The broker can no longer answer about this document. `readDoc` catches and
* yields `[]`, so it arrives indistinguishable from an empty document — which is
* exactly the confusion `resolution.ts` exists to refuse.
*/
breakReadsOf(doc: string, reason: string): void;
healReadsOf(doc: string): void;
/** `readUnion` REJECTS outright — its session-level failure, not a per-document one. */
breakReadUnion(reason: string): void;
healReadUnion(): void;
/**
* Hands over every inbox notification the broker was holding, and waits for the
* watching session to finish with each — the callback is declared `void` upstream,
* so production never waits for it, and this does only because a test needs a point
* at which the work is over.
*/
deliverNotifications(): Promise<void>;
/** Every subject in a document, read from outside the adapter. */
/** Every triple in a document, read from outside the adapter. */
contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[];
/**
* What is waiting in a document's inbox, oldest first — or `null` when its owner
* never opened one. An INSPECTION, not an entry of the polyfill: this package
* never reads an inbox, so nothing it does may depend on being able to.
*/
depositsIn(doc: string): readonly Deposit[] | null;
}
/**
@@ -293,13 +270,7 @@ export interface FakePolyfill {
*/
export function installFakePolyfill(): FakePolyfill {
const documents = new Map<string, StoredDocument>();
const unreachable = new Map<string, string>();
const calls: RecordedCall[] = [];
/** address → the document whose inbox it is. Nothing else resolves one. */
const inboxAddresses = new Map<string, string>();
let watches: { readonly doc: string; readonly onDeposits: (d: Deposit[]) => unknown }[] = [];
let undelivered: { readonly doc: string; readonly onDeposits: (d: Deposit[]) => unknown }[] = [];
let unionFailure: string | undefined;
let currentUser = "nobody";
let documentCount = 0;
let clock = 0;
@@ -310,28 +281,6 @@ export function installFakePolyfill(): FakePolyfill {
return stored;
}
function readOne(doc: string): UnionSubject[] {
const broken = unreachable.get(doc);
if (broken !== undefined) throw new Error(`cannot reach ${doc}: ${broken}`);
const stored = require(doc);
const out: UnionSubject[] = [];
for (const [subject, properties] of stored.subjects) {
// Built EXACTLY as `readUnion` builds it — a plain object literal filled by
// `(props[p] ??= []).push(o)`. Neither detail is cosmetic: the literal
// inherits from `Object.prototype`, and `??=` does NOT assign over an
// inherited truthy member, so a predicate named `constructor` or `toString`
// leaves `.push` undefined and the read THROWS.
const props: Record<string, string[]> = {};
for (const [predicate, values] of properties) {
for (const value of values) {
(props[predicate] ??= []).push(value);
}
}
out.push({ subject, graph: doc as Nuri, props });
}
return out;
}
const docsImpl = {
async sparqlUpdate(sessionId: unknown, query: unknown, anchor?: unknown): Promise<unknown> {
if (typeof query !== "string") throw new Error("[fake-polyfill] the query must be a string");
@@ -371,7 +320,7 @@ export function installFakePolyfill(): FakePolyfill {
async sparqlQuery(_sessionId: unknown, query: unknown): Promise<never> {
// Reached only if this package starts querying, which it does not: it reads
// through `readUnion`. The message splits the two reasons someone lands here,
// nothing at all. The message splits the two reasons someone lands here,
// because one of them is an attempt to write through the read door.
if (typeof query === "string" && DESTRUCTIVE.test(blankLiterals(query))) {
throw new Error(
@@ -381,8 +330,8 @@ export function installFakePolyfill(): FakePolyfill {
);
}
throw new Error(
"[fake-polyfill] docs.sparqlQuery is not modelled — this package reads through " +
"`readUnion`. Model it READ-ONLY here before using it.",
"[fake-polyfill] docs.sparqlQuery is not modelled — this package does not read. " +
"Model it READ-ONLY here before using it.",
);
},
};
@@ -401,46 +350,8 @@ export function installFakePolyfill(): FakePolyfill {
const from = Object.hasOwn(opts, "from") ? (opts.from ?? null) : currentUser;
clock += 1;
stored.deposits.push({ from, payload: opts.payload ?? null, ts: clock });
// Stored first, told afterwards — and told over the wire, which is why the
// notification waits for `deliverNotifications` rather than firing inline.
for (const watch of watches) if (watch.doc === stored.nuri) undelivered.push(watch);
},
watch(targetInbox: unknown, onDeposits: unknown): () => void {
const doc = inboxAddresses.get(String(targetInbox));
if (doc === undefined) {
// "`inbox.post` refuses a target that is not an inbox" — so does watching one,
// and nothing outside `openDocumentInbox` ever hands an address out.
throw new Error(`[fake-polyfill] not an inbox address: ${String(targetInbox)}`);
}
if (require(doc).owner !== currentUser) {
throw new Error(
`${currentUser} may not watch the inbox of ${doc}: you may DEPOSIT into ` +
"anyone's inbox, you may only READ your own",
);
}
if (typeof onDeposits !== "function") {
throw new Error("[fake-polyfill] inbox.watch takes a callback");
}
const watch = { doc, onDeposits: onDeposits as (d: Deposit[]) => unknown };
watches.push(watch);
return () => {
watches = watches.filter((w) => w !== watch);
};
},
async readForDocument(doc: unknown): Promise<Deposit[]> {
const stored = require(String(doc));
// No inbox → there is no address to read, which is a state and not an error.
if (stored.deposits === undefined) return [];
if (stored.owner !== currentUser) {
throw new Error(
`${currentUser} may not read the inbox of ${String(doc)}: you may DEPOSIT into ` +
"anyone's inbox, you may only READ your own",
);
}
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
},
};
const storeRegistryImpl = {
@@ -464,41 +375,13 @@ export function installFakePolyfill(): FakePolyfill {
}
stored.deposits ??= [];
// Idempotent within a page: asking again for a document that already has one
// resolves that same address rather than adding a second inbox.
const address = `${stored.nuri}:inbox`;
inboxAddresses.set(address, stored.nuri);
return address;
},
async listMyEntityDocs(scope: unknown): Promise<Nuri[]> {
if (scope !== "public" && scope !== "mine") {
throw new Error(`[fake-polyfill] unknown scope ${JSON.stringify(scope)}`);
}
// "listMyEntityDocs returns a listing whose documents you can open, or it throws."
const mine: Nuri[] = [];
for (const stored of documents.values()) {
if (stored.owner === currentUser) mine.push(stored.nuri);
}
return mine;
// resolves that same address rather than opening a second inbox. The address
// goes no further — this package drops it, which is the point of `openInbox`
// returning nothing.
return `${stored.nuri}:inbox`;
},
};
async function readUnionImpl(docsLike: unknown): Promise<UnionSubject[]> {
if (unionFailure !== undefined) throw new Error(unionFailure);
const list = Array.isArray(docsLike) ? docsLike : [];
const out: UnionSubject[] = [];
for (const doc of [...new Set(list.filter(Boolean))]) {
try {
out.push(...readOne(String(doc)));
} catch (error) {
// `readDoc` is `try {…} catch { return [] }`: a failing document is skipped
// and never aborts the batch, so failure and emptiness arrive identical.
console.error("[fake-polyfill] read failed for", doc, String(error));
}
}
return out;
}
function userOfSession(sessionId: unknown): string {
const id = String(sessionId);
const user = id.startsWith("session:") ? id.slice("session:".length) : undefined;
@@ -513,8 +396,8 @@ export function installFakePolyfill(): FakePolyfill {
/**
* A namespace whose every member is recorded, and whose UNKNOWN members throw by
* name rather than arriving as `undefined`. `Object.hasOwn` and not `impl[name]`,
* because a plain object literal inherits `toString` and friends — the same trap
* `valuesOf` guards against in `src/index-document.ts`.
* because a plain object literal inherits `toString` and friends, and an
* inherited member is not a modelled entry.
*/
function namespace(name: string, impl: Record<string, (...args: never[]) => unknown>): unknown {
return new Proxy(impl, {
@@ -540,49 +423,22 @@ export function installFakePolyfill(): FakePolyfill {
docs: namespace("docs", docsImpl),
inbox: namespace("inbox", inboxImpl),
storeRegistry: namespace("storeRegistry", storeRegistryImpl),
async readUnion(docsLike: unknown) {
record("readUnion", [docsLike]);
return readUnionImpl(docsLike);
},
}));
return {
calls,
signIn(user: string) {
// "It lasts exactly as long as that identity stays connected — changing identity
// or clearing it stops it." This fake has ONE signed-in identity at a time, as a
// page does, so a watch cannot outlive the identity that opened it. Signing in as
// the same identity again is not a change, and stops nothing.
if (user !== currentUser) {
watches = [];
undelivered = [];
}
// This fake has ONE signed-in identity at a time, as a page does: a polyfill
// session IS one identity, and no call takes an identifier.
currentUser = user;
},
async deliverNotifications() {
while (undelivered.length > 0) {
const batch = undelivered;
undelivered = [];
for (const watch of batch) {
const stored = documents.get(watch.doc);
await watch.onDeposits([...(stored?.deposits ?? [])]);
}
}
},
sessionId() {
return `session:${currentUser}`;
},
breakReadsOf(doc: string, reason: string) {
unreachable.set(doc, reason);
},
healReadsOf(doc: string) {
unreachable.delete(doc);
},
breakReadUnion(reason: string) {
unionFailure = reason;
},
healReadUnion() {
unionFailure = undefined;
depositsIn(doc: string) {
const stored = require(doc);
if (stored.deposits === undefined) return null;
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
},
contentsOf(doc: string) {
const stored = require(doc);
-337
View File
@@ -1,337 +0,0 @@
import { expect, mock, test } from "bun:test";
import { indexing, type Indexing } from "../src/indexing";
import { coalescing } from "../src/coalescing";
import type { Nuri } from "../src/port";
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
/**
* WHEN an index is curated — the engagement `createIndex` makes about what becomes
* of what it created: the index is curated at its creator's next connection, and on
* each deposit while the creator is connected.
*
* Nothing below calls curation, because there is nothing to call. Every test here
* drives the two acts an application really has — connecting (obtaining a handle)
* and depositing — and asserts what the index holds afterwards.
*
* The case space is the creator's presence crossed with the deposit's timing:
* away when it was made, connected when it was made, and connected on an index
* that a previous session created. Plus the two that must NOT happen: a stranger
* connecting curates nothing, and a document that is no index is left alone.
*
* And crossing all of it, the three ways connecting can FAIL — it cannot look for
* its indexes, it cannot go through one, it cannot watch one. Each has its own test
* below, because each is a failure wearing the shape of an absence: the session
* carries on, the handle works, and an index quietly holds less than it should. The
* engagement is that none of them denies anything and none of them loses a deposit,
* which is only worth anything if it is exercised rather than asserted.
*/
const PUBLISHED_AT = "http://schema.org/datePublished";
function hardcodedInAppSource(nuri: Nuri): Nuri {
return nuri;
}
/**
* Alice creates an index, and then her page closes. That is the state most of these
* tests start from: an index exists, its creator is away, and nothing is watching
* it — so a deposit made now can only be seen at her next connection.
*/
async function aliceCreatesAnIndexAndLeaves(network: FakeNextGraph): Promise<Nuri> {
const alice = await indexing(network.portFor("alice"));
const index = await alice.createIndex(PUBLISHED_AT);
network.disconnect("alice");
return hardcodedInAppSource(index);
}
/**
* Runs `body` with this package's log stream captured, and reports HOW MANY
* failures it put there alongside whatever the body produced.
*
* The count, never the text: what a log line reads is for a human and nothing
* promises it, so a test that pinned the words would pin the one thing that is
* free to change. What is worth pinning is that a failure was reported AT ALL —
* harmless is not the same as invisible, and the whole risk here is a failure
* passing for an absence.
*/
async function capturingReports<T>(
body: () => Promise<T>,
): Promise<{ result: T; reports: number }> {
const reported = mock((..._args: unknown[]) => {});
const original = console.error;
console.error = reported;
try {
return { result: await body(), reports: reported.mock.calls.length };
} finally {
console.error = original;
}
}
// --- the deposits that piled up while the creator was away ----------------
test("an index is curated at its creator's next connection, with nobody asking", async () => {
const network = new FakeNextGraph();
const index = await aliceCreatesAnIndexAndLeaves(network);
// Bob deposits while Alice is away: her session is never told, and the deposit
// waits in the inbox where only she can see it.
const bobPort = network.portFor("bob");
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
await (await indexing(bobPort)).refer(index, article);
const carol = await indexing(network.portFor("carol"));
expect(await carol.read(index)).toEqual([]);
// Alice comes back. This is the whole of it: obtaining her handle IS the trigger.
const alice = await indexing(network.portFor("alice"));
expect(await alice.read(index)).toEqual([{ object: article, value: "2026-03-04" }]);
expect(await carol.read(index)).toEqual([{ object: article, value: "2026-03-04" }]);
});
test("a whole backlog is caught up, across every index the creator owns", async () => {
const network = new FakeNextGraph();
const alicePort = network.portFor("alice");
const bobPort = network.portFor("bob");
const first = await aliceCreatesAnIndexAndLeaves(network);
const second = await aliceCreatesAnIndexAndLeaves(network);
// An ordinary public document of Alice's, which is no index at all.
await publishObject(alicePort, PUBLISHED_AT, "2026-01-01");
const bob = await indexing(bobPort);
const early = await publishObject(bobPort, PUBLISHED_AT, "2026-01-02");
const late = await publishObject(bobPort, PUBLISHED_AT, "2026-05-06");
await bob.refer(first, early);
await bob.refer(first, late);
await bob.refer(second, late);
const alice = await indexing(alicePort);
expect((await alice.read(first)).map((e) => e.value)).toEqual(["2026-01-02", "2026-05-06"]);
expect(await alice.read(second)).toEqual([{ object: late, value: "2026-05-06" }]);
});
// --- the deposits that arrive while the creator is looking ----------------
test("a deposit made while the creator is connected is curated as it lands", async () => {
const network = new FakeNextGraph();
const alice = await indexing(network.portFor("alice"));
const index = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const bobPort = network.portFor("bob");
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
await (await indexing(bobPort)).refer(index, article);
// The index was created in THIS session, so the store search never saw it: what
// brings it under observation is `createIndex` itself.
await network.deliverNotifications();
expect(await alice.read(index)).toEqual([{ object: article, value: "2026-03-04" }]);
});
test("an index from a previous session is watched too, not merely caught up once", async () => {
const network = new FakeNextGraph();
const index = await aliceCreatesAnIndexAndLeaves(network);
const bobPort = network.portFor("bob");
const bob = await indexing(bobPort);
// Alice comes back to an index she created before, with nothing waiting in it.
const alice = await indexing(network.portFor("alice"));
expect(await alice.read(index)).toEqual([]);
// …and only now does Bob deposit. Nothing but the watch can carry this one.
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-07-08");
await bob.refer(index, article);
await network.deliverNotifications();
expect(await alice.read(index)).toEqual([{ object: article, value: "2026-07-08" }]);
});
test("a burst of deposits settles on the same index, whatever order they are told in", async () => {
const network = new FakeNextGraph();
const alice = await indexing(network.portFor("alice"));
const index = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const bobPort = network.portFor("bob");
const bob = await indexing(bobPort);
for (const date of ["2026-03-04", "2026-01-31", "2025-12-25"]) {
await bob.refer(index, await publishObject(bobPort, PUBLISHED_AT, date));
}
await network.deliverNotifications();
expect((await alice.read(index)).map((e) => e.value)).toEqual([
"2025-12-25",
"2026-01-31",
"2026-03-04",
]);
});
// --- what connecting must NOT do -----------------------------------------
test("connecting curates nothing for anyone but the creator", async () => {
const network = new FakeNextGraph();
const index = await aliceCreatesAnIndexAndLeaves(network);
const bobPort = network.portFor("bob");
const bob = await indexing(bobPort);
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
await bob.refer(index, article);
// Bob connects again, and Carol connects: neither owns the index, so neither can
// read its inbox — and connecting must not try, nor fail, nor write anything.
const carol = await indexing(network.portFor("carol"));
await indexing(bobPort);
await network.deliverNotifications();
expect(await carol.read(index)).toEqual([]);
});
test("a public document that is no index is left alone — no inbox, no entry", async () => {
const network = new FakeNextGraph();
const alicePort = network.portFor("alice");
const ordinary = await alicePort.createPublicDocument();
await indexing(alicePort);
// Had connecting treated every public document as an index, it would have opened
// an inbox on this one — which is exactly what makes a deposit possible.
const bob = await indexing(network.portFor("bob"));
await expect(bob.refer(ordinary, "did:ng:o:doc-9")).rejects.toThrow(/has no inbox/);
});
test("a session that could not look for its indexes is still a working handle", async () => {
const network = new FakeNextGraph();
const index = await aliceCreatesAnIndexAndLeaves(network);
const bobPort = network.portFor("bob");
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
await (await indexing(bobPort)).refer(index, article);
network.breakListing("broker unreachable");
const reported = mock((..._args: unknown[]) => {});
const original = console.error;
console.error = reported;
let alice: Indexing;
try {
alice = await indexing(network.portFor("alice"));
} finally {
console.error = original;
}
// Reading an index and depositing into one need none of that work, so nothing is
// denied — but the failure is on the log, because a silent one teaches nobody.
expect(await alice.read(index)).toEqual([]);
await alice.refer(index, article);
expect(reported).toHaveBeenCalledTimes(1);
expect(String(reported.mock.calls[0]?.[0])).toContain("public store could not be listed");
});
test("an index whose catch-up failed is still a working handle, and loses no deposit", async () => {
const network = new FakeNextGraph();
const stalled = await aliceCreatesAnIndexAndLeaves(network);
const healthy = await aliceCreatesAnIndexAndLeaves(network);
const bobPort = network.portFor("bob");
const bob = await indexing(bobPort);
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
await bob.refer(stalled, article);
await bob.refer(healthy, article);
// The broker answers about the document and not about its inbox. Two repos
// upstream, read with two capabilities, so this is a partial failure and not a
// contrived one — and it is what makes the catch-up fail on THIS index alone.
network.breakInboxReadsOf(stalled, "broker unreachable");
const { result: alice, reports } = await capturingReports(() =>
indexing(network.portFor("alice")),
);
// Obtaining the handle RESOLVED — reaching this line at all is the assertion.
// Reading the index it could not go through still works…
expect(await alice.read(stalled)).toEqual([]);
// …and so does depositing into it: neither ever depended on that work.
await alice.refer(stalled, article);
// The session is not poisoned either: the other index was caught up normally.
expect(await alice.read(healthy)).toEqual([{ object: article, value: "2026-03-04" }]);
expect(reports).toBe(1);
// And nothing was lost. The deposits never left the inbox, so the first
// connection that can read it puts them in — which is the whole reason a failed
// run is allowed to be this quiet.
network.healInboxReadsOf(stalled);
network.disconnect("alice");
const back = await indexing(network.portFor("alice"));
expect(await back.read(stalled)).toEqual([{ object: article, value: "2026-03-04" }]);
});
test("an index that could not be watched is still caught up, and the rest still notices", async () => {
const network = new FakeNextGraph();
const unwatched = await aliceCreatesAnIndexAndLeaves(network);
const watched = await aliceCreatesAnIndexAndLeaves(network);
const bobPort = network.portFor("bob");
const bob = await indexing(bobPort);
const waiting = await publishObject(bobPort, PUBLISHED_AT, "2026-01-01");
await bob.refer(unwatched, waiting);
// The subscription is refused; reading that same inbox still works. A watch is
// held open where a read is one question and one answer, so one can be turned
// down while the other is served.
network.breakWatchingOf(unwatched, "the broker refused the subscription");
const { result: alice, reports } = await capturingReports(() =>
indexing(network.portFor("alice")),
);
// The watch failed and the catch-up ran ANYWAY — the backlog is in. That is the
// order the code goes to some trouble to hold: failing to watch must not cost
// the deposits that were already waiting.
expect(await alice.read(unwatched)).toEqual([{ object: waiting, value: "2026-01-01" }]);
expect(reports).toBe(1);
// What the failure costs, exactly and no more: a deposit made from now on is not
// NOTICED on that index…
const late = await publishObject(bobPort, PUBLISHED_AT, "2026-02-02");
await bob.refer(unwatched, late);
await bob.refer(watched, late);
await network.deliverNotifications();
expect((await alice.read(unwatched)).map((e) => e.value)).toEqual(["2026-01-01"]);
// …while every other index of the very same session goes on noticing its own.
expect(await alice.read(watched)).toEqual([{ object: late, value: "2026-02-02" }]);
// "Until the next connection" is the whole of the damage, and the next
// connection is where it ends.
network.healWatchingOf(unwatched);
network.disconnect("alice");
const back = await indexing(network.portFor("alice"));
expect((await back.read(unwatched)).map((e) => e.value)).toEqual(["2026-01-01", "2026-02-02"]);
});
// --- the primitive that keeps a burst from piling up ----------------------
test("coalescing never runs twice at once, and grants exactly one more run", async () => {
const trace: string[] = [];
const ask = coalescing(async () => {
trace.push("start");
// Yields, so the asks below really do arrive while a run is in flight — which
// is the only situation this primitive exists for.
await Promise.resolve();
trace.push("end");
});
const first = ask();
const during = [ask(), ask(), ask()];
await Promise.all([first, ...during]);
// Three asks during one run earn ONE more run between them, not three — and not
// none, since a deposit that landed after the first run read the inbox would
// otherwise wait for the next connection.
expect(trace).toEqual(["start", "end", "start", "end"]);
// Runs never overlap: no "start" ever follows a "start".
expect(trace.join(" ")).not.toContain("start start");
// And an ask that arrives once everything is quiet is a run of its own.
await ask();
expect(trace.filter((step) => step === "start")).toHaveLength(3);
});
+91 -295
View File
@@ -1,11 +1,15 @@
import { expect, test } from "bun:test";
import { indexing, type Indexing } from "../src/indexing";
import { curate } from "../src/curator";
import { indexingOn, type Indexing } from "../src/indexing";
import type { Nuri } from "../src/port";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
import { INDEX_FIELD } from "../src/vocabulary";
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
/**
* The two acts, and nothing else — this package creates an index and hands one a
* reference. What becomes of that reference is the business of the layer that
* processes the index's inbox, and there is nothing here that could do it or ask
* for it.
*
* Each actor gets their own handle, and they share no variable carrying business
* data. The ONE value that crosses between them is the index's NURI — and that
* crossing is the mechanism this design names: an application references the
@@ -21,336 +25,128 @@ function hardcodedInAppSource(nuri: Nuri): Nuri {
type Port = ReturnType<FakeNextGraph["portFor"]>;
async function world(): Promise<{
function world(): {
network: FakeNextGraph;
alice: Indexing;
bob: Indexing;
carol: Indexing;
ports: { alice: Port; bob: Port; carol: Port };
}> {
ports: { alice: Port; bob: Port };
} {
const network = new FakeNextGraph();
const ports = {
alice: network.portFor("alice"),
bob: network.portFor("bob"),
carol: network.portFor("carol"),
};
// Three connections, none of which owns an index yet: there is nothing to catch up
// on and nothing to watch. What each of them does next is what these tests are about.
return {
network,
alice: await indexing(ports.alice),
bob: await indexing(ports.bob),
carol: await indexing(ports.carol),
ports,
};
const ports = { alice: network.portFor("alice"), bob: network.portFor("bob") };
// A handle is nothing but a port bound to one identity: building one reaches
// nothing, which is why there is nothing to await here.
return { network, alice: indexingOn(ports.alice), bob: indexingOn(ports.bob), ports };
}
/**
* These tests exercise the curation RULES, so they run the curator itself rather
* than wait for an inbox notification: what a run makes of a deposit is what is
* under test, not when the run happens. `inbox-processing.test.ts` covers the when.
*
* The deposits below therefore sit in their inbox, told to nobody, which is exactly
* the state an owner's next connection finds.
*/
// --- creating an index ----------------------------------------------------
test("any user creates an index in their public store, and it declares its field", async () => {
const { alice, ports } = await world();
const { alice, network } = world();
const index = await alice.createIndex(PUBLISHED_AT);
const index = await alice.create(PUBLISHED_AT);
// An ordinary document: what makes it an index is the field it declares, which
// a reader going straight to `readUnion` sees on the index's own subject.
const subjects = await ports.alice.readDocument(index);
const self = subjects.find((s) => s.subject === index);
expect(self?.props[INDEX_FIELD]).toEqual([PUBLISHED_AT]);
expect(await alice.read(index)).toEqual([]);
// An ordinary document: what makes it an index is the field it declares, on the
// index's own subject, which is where a reader of the document finds it.
expect(network.contentsOf(index)).toEqual([
{ subject: index, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
]);
});
test("reading a document that declares no index field is refused, not answered empty", async () => {
const { alice, ports } = await world();
const ordinary = await ports.alice.createPublicDocument();
await expect(alice.read(ordinary)).rejects.toThrow(/declares no index field/);
test("a new index is ready to receive: its inbox is open the moment create returns", async () => {
const { alice, bob, network } = world();
const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
// Nothing to open, register or remember — a stranger deposits straight away.
await bob.add(index, "did:ng:o:doc-9");
expect(network.depositsIn(index)).toHaveLength(1);
});
// --- the whole loop, across three people ----------------------------------
test("a field that could never match an object is refused at creation", async () => {
const { alice } = world();
// It cannot be corrected later — nothing here deletes — so it is refused now.
await expect(alice.create("")).rejects.toThrow(/cannot be changed later/);
await expect(alice.create(" ")).rejects.toThrow(/cannot be changed later/);
});
test("a stranger refers an object, the owner curates, and anyone reads the result", async () => {
const { alice, bob, carol, ports } = await world();
test("two indexes are two documents, each declaring its own field", async () => {
const { alice, network } = world();
const byDate = await alice.create(PUBLISHED_AT);
const byName = await alice.create(NAME);
expect(byDate).not.toBe(byName);
expect(network.contentsOf(byDate)).toEqual([
{ subject: byDate, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
]);
expect(network.contentsOf(byName)).toEqual([
{ subject: byName, predicate: INDEX_FIELD, values: [NAME] },
]);
});
// --- handing an index a reference -----------------------------------------
test("a stranger hands an index a reference, and it waits in the index's inbox", async () => {
const { alice, bob, ports, network } = world();
// Alice creates the index and its NURI goes into the application's source.
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
// Bob, who owns nothing of Alice's, creates his own public object and hands the
// index a reference to it. He needs no permission and gets no write.
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await bob.add(index, article);
// Nothing is in the index until its owner acts.
expect(await carol.read(indexNuri)).toEqual([]);
// THE WHOLE PAYLOAD is the reference: no wrapper, no claim, no copy of the value,
// and no index either — the inbox it landed in is what identifies one. This is the
// one thing this package hands the layer that will make an entry of it.
expect(network.depositsIn(index)).toEqual([
{ from: "bob", payload: article, ts: expect.any(Number) },
]);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-03-04" }]);
// Carol knows only the NURI from the application's source, and gets the entry.
const entries = await carol.read(indexNuri);
expect(entries).toEqual([{ object: article, value: "2026-03-04" }]);
// The entry is a usable reference: Carol opens the object straight from it,
// holding nothing but what she read out of the index.
const first = entries[0];
expect(first).toBeDefined();
const opened = await ports.carol.readDocument(first!.object);
expect(opened[0]?.props[PUBLISHED_AT]).toEqual(["2026-03-04"]);
// And it stayed a deposit: nothing wrote it into the index document.
expect(network.contentsOf(index)).toEqual([
{ subject: index, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
]);
});
test("an entry is a subject keyed by the object's NURI, so reading needs nothing new", async () => {
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
test("the same reference handed over twice is two deposits, and nothing is lost", async () => {
const { alice, bob, ports, network } = world();
const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await curate(ports.alice, indexNuri);
// What `readUnion([indexNuri])` hands an application that never loaded this
// package: the index's own subject, plus one subject per indexed object.
const subjects = await ports.bob.readDocument(indexNuri);
const entry = subjects.find((s) => s.subject === article);
expect(entry?.props[ENTRY_VALUE]).toEqual(["2026-03-04"]);
expect(subjects.map((s) => s.subject).sort()).toEqual([article, indexNuri].sort());
});
await bob.add(index, article);
await bob.add(index, article);
// --- only the owner curates ----------------------------------------------
test("nobody but the index's owner can curate it: the inbox is refused to others", async () => {
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await expect(curate(ports.bob, indexNuri)).rejects.toThrow(/may only READ your own/);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("nobody but the owner writes an index, whatever they know about it", async () => {
const { alice, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
await expect(
ports.bob.addLiteralProperty(indexNuri, "did:ng:o:forged", ENTRY_VALUE, "2999-01-01"),
).rejects.toThrow(/only a document's owner writes to it/);
await expect(ports.bob.openInbox(indexNuri)).rejects.toThrow(/may not open an inbox/);
// Nothing here de-duplicates: a deposit is an invitation to look, so repeating one
// is legitimate and cheap, and whoever processes the inbox is the one that settles.
expect(network.depositsIn(index)?.map((d) => d.payload)).toEqual([article, article]);
});
test("an index whose owner never opened an inbox refuses a deposit rather than losing it", async () => {
const { bob, ports } = await world();
const { bob, ports } = world();
// A public document that was never made into an index: no inbox was opened.
const notAnIndex = hardcodedInAppSource(await ports.alice.createPublicDocument());
await expect(bob.refer(notAnIndex, "did:ng:o:doc-9")).rejects.toThrow(/has no inbox/);
await expect(bob.add(notAnIndex, "did:ng:o:doc-9")).rejects.toThrow(/has no inbox/);
});
// --- adding is idempotent -------------------------------------------------
test("the same reference deposited twice produces one entry", async () => {
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await bob.refer(indexNuri, article);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([
{ result: "indexed", object: article, value: "2026-03-04" },
{ result: "unchanged", object: article },
]);
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-03-04" }]);
test("a payload that is not a reference is refused here, not deposited for someone else to find", async () => {
const { alice, bob } = world();
const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
// Both sides are checked, because both go on to name a document. An index's inbox
// takes anything anyone posts to it; what THIS package puts there is a NURI.
await expect(bob.add(index, "please index my article")).rejects.toThrow(/not a NURI/);
await expect(bob.add("http://example.org/index", "did:ng:o:doc-9")).rejects.toThrow(
/not a NURI/,
);
});
test("curating twice changes nothing the second time — deposits are not consumed", async () => {
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
// --- only the owner owns it -----------------------------------------------
await curate(ports.alice, indexNuri);
const before = await alice.read(indexNuri);
test("nobody but the owner writes an index, whatever they know about it", async () => {
const { alice, ports } = world();
const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
const second = await curate(ports.alice, indexNuri);
expect(second.outcomes).toEqual([{ result: "unchanged", object: article }]);
expect(await alice.read(indexNuri)).toEqual(before);
});
// --- a read that cannot answer must never cost the index anything ---------
test("a reference the broker cannot resolve is reported, and adds nothing", async () => {
const { network, alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const first = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
await bob.refer(indexNuri, first);
await curate(ports.alice, indexNuri);
const second = await publishObject(ports.bob, PUBLISHED_AT, "2026-02-02");
await bob.refer(indexNuri, second);
network.breakReadsOf(second, "broker unreachable");
const report = await curate(ports.alice, indexNuri);
const unresolved = report.outcomes.filter((o) => o.result === "unresolved");
expect(unresolved).toHaveLength(1);
expect(unresolved[0]).toMatchObject({ object: second });
// THE POINT: the entry that was already there is untouched.
expect(await alice.read(indexNuri)).toEqual([{ object: first, value: "2026-01-01" }]);
});
test("an already-indexed object survives its own reads failing, and is not even re-read", async () => {
const { network, alice, bob, carol, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
await bob.refer(indexNuri, article);
await curate(ports.alice, indexNuri);
// A passer-by nudges the index about an entry she found IN IT. Carol obtains
// the reference the only way she could in a real application — by reading the
// index whose NURI her app hardcodes — rather than being handed it by the test.
const seen = await carol.read(indexNuri);
const noticed = seen[0];
expect(noticed).toBeDefined();
// …and only then does the object become unreachable.
network.breakReadsOf(article, "broker unreachable");
await carol.refer(indexNuri, noticed!.object);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes.every((o) => o.result === "unchanged")).toBe(true);
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-01-01" }]);
});
test("a failed resolve is self-correcting: the next curation adds what it could not", async () => {
const { network, alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-05-06");
await bob.refer(indexNuri, article);
network.breakReadsOf(article, "broker unreachable");
expect((await curate(ports.alice, indexNuri)).outcomes[0]?.result).toBe("unresolved");
expect(await alice.read(indexNuri)).toEqual([]);
// The deposit is still there, so nothing has to be re-deposited.
network.healReadsOf(article);
expect((await curate(ports.alice, indexNuri)).outcomes[0]).toEqual({
result: "indexed",
object: article,
value: "2026-05-06",
});
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-05-06" }]);
});
test("a reference to something that was never created is reported, not silently dropped", async () => {
const { network, alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
await bob.refer(indexNuri, network.neverCreatedNuri());
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toHaveLength(1);
expect(report.outcomes[0]?.result).toBe("unresolved");
expect(await alice.read(indexNuri)).toEqual([]);
});
// --- an object that does not fit the index --------------------------------
test("an object carrying nothing for the index's field is not added", async () => {
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
// Exists, is public, is readable — but says nothing about the field this index
// is built on. OPEN QUESTION: this is the narrow behaviour, not a settled policy.
const object = await publishObject(ports.bob, NAME, "an object with no date");
await bob.refer(indexNuri, object);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "no-field" }]);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("an object carrying several values for the field is not added", async () => {
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
await ports.bob.addLiteralProperty(object, object, PUBLISHED_AT, "2026-09-09");
await bob.refer(indexNuri, object);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "several-values" }]);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("a payload that is not a reference is reported as foreign and changes nothing", async () => {
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
// Anyone may deposit anything into an inbox, so untrusted payloads do arrive.
await ports.bob.depositTo(indexNuri, { drop: "everything" });
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([
{ result: "indexed", object: article, value: "2026-03-04" },
{ result: "foreign", reason: "payload is not a reference" },
]);
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-03-04" }]);
});
test("an index referred to itself is skipped, so its declaration cannot become an entry", async () => {
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
await bob.refer(indexNuri, indexNuri);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([
{ result: "skipped", object: indexNuri, reason: "self-reference" },
]);
expect(await alice.read(indexNuri)).toEqual([]);
});
// --- indexing by a date is an instance of indexing by a field -------------
test("an index whose field is a date reads back in chronological order", async () => {
const { alice, bob, carol, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const march = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
const january = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-31");
const december = await publishObject(ports.bob, PUBLISHED_AT, "2025-12-25");
// Referred out of order, on purpose.
await bob.refer(indexNuri, march);
await bob.refer(indexNuri, december);
await bob.refer(indexNuri, january);
await curate(ports.alice, indexNuri);
expect((await carol.read(indexNuri)).map((e) => e.value)).toEqual([
"2025-12-25",
"2026-01-31",
"2026-03-04",
]);
});
test("two indexes over the same objects, on different fields, do not interfere", async () => {
const { alice, bob, ports } = await world();
const byDate = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const byName = hardcodedInAppSource(await alice.createIndex(NAME));
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await ports.bob.addLiteralProperty(object, object, NAME, "Anemone");
await bob.refer(byDate, object);
await bob.refer(byName, object);
await curate(ports.alice, byDate);
await curate(ports.alice, byName);
expect(await alice.read(byDate)).toEqual([{ object, value: "2026-03-04" }]);
expect(await alice.read(byName)).toEqual([{ object, value: "Anemone" }]);
await expect(
ports.bob.addLiteralProperty(index, "did:ng:o:forged", INDEX_FIELD, "http://schema.org/aaa"),
).rejects.toThrow(/only a document's owner writes to it/);
await expect(ports.bob.openInbox(index)).rejects.toThrow(/may not open an inbox/);
});
-299
View File
@@ -1,299 +0,0 @@
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([]);
});
+48 -40
View File
@@ -1,62 +1,70 @@
import { expect, test } from "bun:test";
import { expect, mock, test } from "bun:test";
// Deliberately the PACKAGE ENTRY POINT, not the modules behind it: this is the
// surface an application gets, and it must be usable on its own. It pulls in
// `polyfill-adapter.ts`, so this also proves the real `@ng-eventually/polyfill`
// still loads and still exports everything this package compiles against.
import {
indexing,
decodeReference,
polyfillPort,
ENTRY_VALUE,
INDEX_FIELD,
type IndexEntry,
type NextGraphPort,
} from "../src/index";
import * as published from "../src/index";
import { ENTRY_VALUE, INDEX_FIELD, indexing } from "../src/index";
import { indexingOn } from "../src/indexing";
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
const PUBLISHED_AT = "http://schema.org/datePublished";
test("the published surface carries the whole loop, end to end", async () => {
test("the published surface carries both acts, end to end", async () => {
const network = new FakeNextGraph();
const ownerPort: NextGraphPort = network.portFor("alice");
const strangerPort: NextGraphPort = network.portFor("bob");
const ownerPort = network.portFor("alice");
const strangerPort = network.portFor("bob");
const owner = await indexing(ownerPort);
const stranger = await indexing(strangerPort);
// `indexingOn` is what `indexing(sessionId)` calls once it has built the port for
// you: the two acts below are the published ones, reached here over an in-memory
// NextGraph. `adapter.test.ts` drives these same acts through the real adapter.
const owner = indexingOn(ownerPort);
const stranger = indexingOn(strangerPort);
const index = await owner.createIndex(PUBLISHED_AT);
const index = await owner.create(PUBLISHED_AT);
const article = await publishObject(strangerPort, PUBLISHED_AT, "2026-07-08");
await stranger.refer(index, article);
await stranger.add(index, article);
// NOBODY CURATES — there is nothing on this surface to call. Alice is connected,
// so her session is told a deposit landed and processes that inbox itself.
await network.deliverNotifications();
const entries: IndexEntry[] = await owner.read(index);
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]);
// What the two acts leave behind, and the whole of it: a document declaring its
// field, and a bare reference waiting in its inbox. Making an entry of that
// reference is the business of the layer below, and there is nothing on this
// surface that does it or asks for it.
expect(network.contentsOf(index)).toEqual([
{ subject: index, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
]);
expect(network.depositsIn(index)?.map((deposit) => deposit.payload)).toEqual([article]);
});
test("the published surface offers no way to run, aim or schedule curation", async () => {
const handle = await indexing(new FakeNextGraph().portFor("alice"));
// Read off the handle rather than from a list: an application gets these three
// acts and nothing else, and curation is not one of them.
expect(Object.keys(handle).sort()).toEqual(["createIndex", "read", "refer"]);
test("the published surface offers TWO acts, and neither reads nor processes anything", () => {
const handle = indexingOn(new FakeNextGraph().portFor("alice"));
// Read off the handle rather than from a list: an application gets these two acts
// and nothing else. Reading an index is not one of them, and neither is anything
// that would make its deposits into entries.
expect(Object.keys(handle).sort()).toEqual(["add", "create"]);
});
test("the published surface exposes the deposit decoder and the two IRIs it writes", () => {
expect(decodeReference("did:ng:o:doc-1")).toBe("did:ng:o:doc-1");
expect(decodeReference({ object: "did:ng:o:doc-1" })).toBeNull();
test("the package publishes ONE function and the two IRIs, and nothing else", () => {
// Read off the module rather than from a list of names to remember: publishing a
// helper again — a port builder, a deposit decoder, a read — fails here first.
expect(Object.keys(published).sort()).toEqual(["ENTRY_VALUE", "INDEX_FIELD", "indexing"]);
expect(INDEX_FIELD).toBe("urn:ng-helpers:index:field");
expect(ENTRY_VALUE).toBe("urn:ng-helpers:index:value");
});
test("polyfillPort is published and builds a port without a live session", () => {
// Constructing it must not touch the broker — an application wires it at
// startup, and only the calls on it talk to anything.
const port: NextGraphPort = polyfillPort({ sessionId: "session-under-test" });
expect(typeof port.resolveObject).toBe("function");
expect(typeof port.addLiteralProperty).toBe("function");
// The port has NO operation that could take an entry out of an index.
const removing = Object.keys(port).filter((name) => /delete|remove|clear|drop/i.test(name));
expect(removing).toEqual([]);
test("indexing takes a session id and reaches nothing at all", () => {
// The REAL polyfill, unconfigured — which would fail every call that needs a
// broker. Building the handle does not make one, so this passes without any
// arrangement, and there is nothing on the log stream to report. It also proves
// the entry point builds its own port: an application hands over the session id
// `init(…)` gave it, and never sees a port at all.
const reported = mock((..._args: unknown[]) => {});
const original = console.error;
console.error = reported;
try {
const handle = indexing("session-under-test");
expect(Object.keys(handle).sort()).toEqual(["add", "create"]);
} finally {
console.error = original;
}
expect(reported).not.toHaveBeenCalled();
});
+1 -14
View File
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test";
import { buildInsertTriple, escapeIri, escapeLiteral, isIriSafe } from "../src/sparql";
import { asNuri, isNuri } from "../src/nuri";
import { decodeReference } from "../src/deposit";
// --- what a reference is, and what it is not ------------------------------
@@ -24,18 +23,6 @@ test("asNuri throws on a non-reference rather than passing it on", () => {
expect(() => asNuri("nope")).toThrow(/not a NURI/);
});
test("a deposit is a bare reference — anything else decodes to null", () => {
expect(decodeReference("did:ng:o:doc-7")).toBe("did:ng:o:doc-7");
// The shapes a well-meaning caller might invent, all refused: the payload IS
// the reference, it is not wrapped and it carries nothing else.
expect(decodeReference({ object: "did:ng:o:doc-7" })).toBeNull();
expect(decodeReference({ assert: "published", object: "did:ng:o:doc-7" })).toBeNull();
expect(decodeReference(["did:ng:o:doc-7"])).toBeNull();
expect(decodeReference("please index did:ng:o:doc-7")).toBeNull();
expect(decodeReference(null)).toBeNull();
expect(decodeReference(7)).toBeNull();
});
// --- escaping -------------------------------------------------------------
test("escapeLiteral leaves no raw quote that could close a SPARQL literal", () => {
@@ -147,7 +134,7 @@ test("a tripwire: no destructive SPARQL keyword is written anywhere under src/",
// a keyword hidden behind the comment-stripper, `DELETE{` with no space, and a
// literal split across concatenated lines.
//
// What actually guards the invariant is `test/adapter-write-path.test.ts`,
// What actually guards the invariant is `test/adapter.test.ts`,
// which RUNS the adapter and reads back every query it emits; all four evasions
// fail there. This stays as a cheap tripwire that catches the obvious
// regression early and names the file — it is not the proof.