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". */ type Properties = Map; 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(); #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)); }, }; } /** * 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)); } /** 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 }); } #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; }