feat!: le paquet crée un index et lui ajoute une référence, rien de plus
This commit is contained in:
+62
-138
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user