import type { IncomingDeposit, NextGraphPort, Nuri, NuriLike, ObjectResolution, UnionSubject, } from "../src/port"; import { asNuri } from "../src/nuri"; /** * An in-memory NextGraph, standing in for `@ng-eventually/polyfill` behind * `NextGraphPort`. * * Every rule below is one the polyfill actually enforces, annotated with where it * comes from — the published contract, or the code implementing it. The point is * that no test here can reach a state the real system does not produce: * * - 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; * - 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. * * ## Telling a watcher crosses the network, so it is a step of its own * * 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. */ type Properties = Map; /** One session watching one document's inbox. */ interface Watch { readonly doc: Nuri; readonly user: string; readonly onDeposits: () => Promise; } interface StoredDocument { readonly nuri: Nuri; readonly owner: string; readonly subjects: Map; /** `undefined` until the owner opens one — the state `postToDocument` refuses. */ deposits: IncomingDeposit[] | undefined; } export class FakeNextGraph { readonly #documents = new Map(); /** Documents the broker currently cannot answer about. See `breakReadsOf`. */ readonly #unreachable = new Map(); /** 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; /** A handle bound to one identity, exactly as a polyfill session is one user's. */ portFor(user: string): NextGraphPort { const network = this; return { async createPublicDocument(): Promise { return network.#createDocument(user); }, async resolveObject(doc: NuriLike): Promise { return network.#resolve(asNuri(doc)); }, async readDocument(doc: NuriLike): Promise { return network.#read(asNuri(doc)); }, async addLiteralProperty( doc: NuriLike, subject: string, predicate: string, value: string, ): Promise { network.#add(user, asNuri(doc), subject, predicate, value); }, async openInbox(doc: NuriLike): Promise { network.#openInbox(user, asNuri(doc)); }, async depositTo(doc: NuriLike, payload: unknown): Promise { network.#deposit(user, asNuri(doc), payload); }, async readDeposits(doc: NuriLike): Promise { return network.#readDeposits(user, asNuri(doc)); }, async watchDeposits(doc: NuriLike, onDeposits: () => Promise): Promise { network.#watchDeposits(user, asNuri(doc), onDeposits); }, async listPublicDocuments(): Promise { 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)); } /** * 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 { 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 { 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]); } #createDocument(owner: string): Nuri { this.#documentCount += 1; const nuri = `did:ng:o:doc-${this.#documentCount}` as Nuri; this.#documents.set(nuri, { nuri, owner, subjects: new Map(), deposits: undefined }); return nuri; } #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 = {}; 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) { throw new Error( `${user} may not write ${doc}: only a document's owner writes to it, and ` + "holding its read key never grants a write", ); } let properties = stored.subjects.get(subject); if (properties === undefined) { properties = new Map(); stored.subjects.set(subject, properties); } const values = properties.get(predicate); if (values === undefined) { properties.set(predicate, [value]); return; } // An INSERT of a triple already present is a no-op in RDF: a graph is a set. if (!values.includes(value)) values.push(value); } #openInbox(user: string, doc: Nuri): void { const stored = this.#require(doc); if (stored.owner !== user) { throw new Error( `${user} may not open an inbox on ${doc}: opening one publishes the ` + "document's address, so a non-owner would route the owner's deposits to itself", ); } stored.deposits ??= []; } #deposit(user: string, doc: Nuri, payload: unknown): void { const stored = this.#require(doc); if (stored.deposits === undefined) { throw new Error(`${doc} has no inbox — its owner never opened one`); } 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 { 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", ); } // 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", ); } 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. * * 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. */ export async function publishObject( port: NextGraphPort, field: string, value: string, ): Promise { const object = await port.createPublicDocument(); await port.addLiteralProperty(object, object, field, value); return object; }