Files
ng-helpers/test/fake-nextgraph.ts
T

347 lines
13 KiB
TypeScript

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<string, string[]>;
/** One session watching one document's inbox. */
interface Watch {
readonly doc: Nuri;
readonly user: string;
readonly onDeposits: () => Promise<void>;
}
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;
}
export class FakeNextGraph {
readonly #documents = new Map<string, StoredDocument>();
/** Documents the broker currently cannot answer about. See `breakReadsOf`. */
readonly #unreachable = 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;
/** 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<Nuri> {
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,
predicate: string,
value: string,
): Promise<void> {
network.#add(user, asNuri(doc), subject, predicate, value);
},
async openInbox(doc: NuriLike): Promise<void> {
network.#openInbox(user, asNuri(doc));
},
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));
}
/**
* 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 {
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<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) {
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>): 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<Nuri> {
const object = await port.createPublicDocument();
await port.addLiteralProperty(object, object, field, value);
return object;
}