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