feat: un dépôt est aiguillé sur son type, comme en amont, et l'index a son bras
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
type: knowledge
|
type: knowledge
|
||||||
summary: Who does what along a deposit's path — the application deposits, this package applies capabilities and only capabilities, a layer above curates its own payloads, and upstream none of it is reachable yet
|
summary: Who does what along a deposit's path — the application deposits, this package DISPATCHES on what a deposit declares itself to be and applies it arm by arm, and upstream none of it is reachable yet
|
||||||
last_checked: 2026-08-20
|
last_checked: 2026-08-21
|
||||||
---
|
---
|
||||||
|
|
||||||
# Who processes an inbox
|
# Who processes an inbox
|
||||||
@@ -22,26 +22,30 @@ What it never does: **process an inbox so that a `ReadCap` sent to it arrives**.
|
|||||||
|
|
||||||
**Continuously, while the identity is connected** — `emulated-verifier/inbox-observer.ts`, `startObservingInboxes`, whose only caller is `connectedUser`: one subscription per inbox, and every push runs `processInbox` on it. *Which* inboxes exist is itself subscribed to, on two channels that do not overlap: the **register** (the User branch of the private store, where `openDocumentInbox` appends its record — so an inbox opened mid-session is picked up) and **what this identity holds** (`CapRegistry.onChange` — which is what makes a person's *first* session watched at all, since provisioning is lazy and they connect owning nothing). Push-driven throughout; it never polls. It stops on identity change, on disconnection, and on `configure` / `resetConfig` (`stopObservingInboxes`, called only from `shared-wallet/bootstrap`). Every step re-checks its holder, so a run that outlives its identity abandons rather than file into the wrong ring.
|
**Continuously, while the identity is connected** — `emulated-verifier/inbox-observer.ts`, `startObservingInboxes`, whose only caller is `connectedUser`: one subscription per inbox, and every push runs `processInbox` on it. *Which* inboxes exist is itself subscribed to, on two channels that do not overlap: the **register** (the User branch of the private store, where `openDocumentInbox` appends its record — so an inbox opened mid-session is picked up) and **what this identity holds** (`CapRegistry.onChange` — which is what makes a person's *first* session watched at all, since provisioning is lazy and they connect owning nothing). Push-driven throughout; it never polls. It stops on identity change, on disconnection, and on `configure` / `resetConfig` (`stopObservingInboxes`, called only from `shared-wallet/bootstrap`). Every step re-checks its holder, so a run that outlives its identity abandons rather than file into the wrong ring.
|
||||||
|
|
||||||
**What it applies: capabilities, and nothing else.** VERIFIED in `surface/inbox.ts`. `read` keys on `capOfPayload`, which accepts a payload only when its `kind` equals `LINK_KIND` *and* its `cap` passes `hasReadCap` (a NURI carrying its cap segment). Those are absorbed and withheld from the caller; every other deposit is returned as consumer data. `processInbox` then iterates **the caps that read just observed** (`capsSeenIn`) — not the deposits — and files each durably with `addLink`.
|
### Applying is a DISPATCH — one arm per declared type
|
||||||
|
|
||||||
So a deposit carrying anything else is read, counted, logged under the access-log flag, and **produces no effect** — not even a reported failure, because it is not a failed application: it is a deposit nobody claimed. `processInbox` hands it back to its caller, and both internal callers (`connectedUser`'s drain loop, the observation's `applyWhatArrived`) discard what they get.
|
`emulated-verifier/inbox-dispatch.ts`, `applyDeposit`. It is a `switch` over what a deposit DECLARES itself to be (`kindOf`), and the shape is the target's own: `Verifier::process_inbox(msg, content)` is literally `match content { … }`, one arm per `InboxMsgContent` variant, ending `_ => Err(VerifierError::NotImplemented)` (`engine/verifier/src/inbox_processor.rs`).
|
||||||
|
|
||||||
Two senses of *applied*, worth keeping apart: `read` learns a cap **in memory, for this session**; only `processInbox` makes it **durable**. `inbox.watch` re-reads on every push, so it absorbs caps and files none.
|
**What an arm owes**, read off upstream's clearest arm (`InboxMsgContent::ContactDetails`): **validate** the envelope — a missing `from_inbox` is `InvalidInboxPost`, a hard failure and not a skip; **build** its addresses from the typed content; **read** the recipient's own store to see whether the thing is already there (a SPARQL `ASK`); **decide idempotently** — `ContactAlreadyExists` rather than a duplicate; and only then **write**. An arm is a real program, not a branch.
|
||||||
|
|
||||||
|
The two arms this package has:
|
||||||
|
|
||||||
|
- **A `ReadCap` delivery** (`inbox-dispatch.LINK_KIND`, whose name is upstream's own word for the variant) — held for the owner's ring and filed durably with `branch-registers.addLink`, the emulated `AddLink { read_cap }`. A payload declaring itself one of these and carrying nothing that reads is REFUSED and reported, which is the envelope validation above; it used to be handed back as consumer data.
|
||||||
|
- **A bare `reference`** — a deposit whose payload IS a `did:ng:` reference and nothing else: no operation, no claim, no copy of any value. `emulated-verifier/index-deposit.ts`, `applyIndexDeposit`, resolves it and LOOKS: which document the inbox belongs to (the owner's own `AddInboxCap` pairing, `readInboxCapPairs`), what that document declares it indexes by (`urn:ng-helpers:index:field`, on the document's own NURI as subject), whether the reference is already in, what the referenced object says for that field — and only then writes ONE entry (`urn:ng-helpers:index:value`, subject = the object's NURI). Since anyone may deposit, a payload carrying an operation would be a licence to rewrite someone else's document, and a payload carrying a value would put in the index something the object does not say. **An index only ever grows**: nothing there removes an entry and nothing there can express a removal, which is what makes an empty read harmless — absent, unreadable and failed all mean *not added this time*, and a later deposit adds it. An unresolved reference is reported and costs nothing; an already-indexed object is passed over; a document declaring no field, or several, refuses rather than write something wrong — and that refusal leaves READING its entries untouched.
|
||||||
|
|
||||||
|
*(Those two IRIs are `@ng-helpers/indexing`'s namespace, kept letter for letter because renaming them is a data-format change nobody decided. This package writing another package's namespace is odd, and it is recorded as odd rather than acted on.)*
|
||||||
|
|
||||||
|
**A kind with no arm says so.** A payload declaring itself in this package's reserved `urn:ng-eventually:inbox:` namespace for a variant nothing here applies is REPORTED — upstream's `NotImplemented`, which is an answer. Until 2026-08-21 it was read, counted, and produced nothing at all: no effect and no reported failure, the "failure disguised as an absence" this package has closed repeatedly elsewhere.
|
||||||
|
|
||||||
|
**Consumer data is not a variant.** A payload that declares no kind of this package's is claimed by no arm and nothing is reported about it: it is not an unapplied message, it is not a message. That is why the RESERVED NAMESPACE is the discriminator and the mere presence of a `kind` field is not — `{ kind: "join" }` is a consumer's word for a consumer's message, and it stays one.
|
||||||
|
|
||||||
|
**What still reaches the caller.** Only a `ReadCap` is withheld: an application must never handle one. Everything else `read` found is returned unchanged, including the payloads an arm applies — a `reference` is public information, so withholding it would buy nothing. Two senses of *applied* stay apart: `read` learns a `ReadCap` **in memory, for this session**; only `processInbox` makes it **durable** and runs the dispatch. `inbox.watch` re-reads on every push, so it absorbs and files nothing.
|
||||||
|
|
||||||
**After a deposit whose recipient is absent.** A deposit that landed arms a deferred stand-in for the session the owner does not have. What it achieves is the only part written down outside the package: **deposits converge** — a share becomes readable without the recipient re-opening the page. *How* is deliberately not documented here. The mechanism is unpublished by design: nothing lets a caller ask for it, name another user's inbox, or turn it off, and the confidentiality of the whole shared-wallet emulation rests on its being unreachable. Its limit is stated rather than hidden — it lives in a page, so if the page goes away the deposit waits for its owner's next connection, which is the real path and not a repair.
|
**After a deposit whose recipient is absent.** A deposit that landed arms a deferred stand-in for the session the owner does not have. What it achieves is the only part written down outside the package: **deposits converge** — a share becomes readable without the recipient re-opening the page. *How* is deliberately not documented here. The mechanism is unpublished by design: nothing lets a caller ask for it, name another user's inbox, or turn it off, and the confidentiality of the whole shared-wallet emulation rests on its being unreachable. Its limit is stated rather than hidden — it lives in a page, so if the page goes away the deposit waits for its owner's next connection, which is the real path and not a repair.
|
||||||
|
|
||||||
**Nothing is ever consumed.** No path retires an applied deposit: an inbox here is re-readable where upstream it is a queue. That is what makes every abandon, every reported failure and every watch that could not be opened harmless — what was not applied is still there for the next push or the next connection.
|
**And it applies the registers, not the documents.** Running for an owner who is not the one holding the page, it takes the arms that write the verifier's own bookkeeping and stops there: writing application data into an absent owner's documents from a stranger's session is a different act, and this package does not take it. So a `reference` deposit is left — traced, not reported, because waiting is not failing — and the owner's own next connection applies it. Nothing is lost: see below.
|
||||||
|
|
||||||
## A package built on top — `@ng-helpers/indexing`
|
**Nothing is ever consumed.** No path retires an applied deposit: an inbox here is re-readable where upstream it is a queue. That is what makes every abandon, every reported refusal and every watch that could not be opened harmless — what was not applied is still there for the next push or the next connection. It is also what makes re-applying safe: every arm is idempotent, so seeing every deposit again on every run lands on the same result.
|
||||||
|
|
||||||
The layer below cannot curate an index: an index deposit is a bare reference (`deposit.ts`), which is exactly what the polyfill hands back untouched. So this package does for its own payloads what the polyfill does for caps, and does it itself:
|
|
||||||
|
|
||||||
- **finds** what to serve — `observeOwnIndexes` lists this identity's public documents and keeps those declaring an index field. Only the owner can, since reading an inbox is refused to everyone else;
|
|
||||||
- **watches, then processes**, in that order (`observation.ts`): a deposit landing between the two is processed twice, which costs nothing, where the other order loses it until the next connection;
|
|
||||||
- **applies** by resolving the reference and looking (`curator.ts`): the object is opened and indexed under the value it holds, or nothing happens. A deposit is an invitation to re-examine, never an instruction;
|
|
||||||
- **re-sees everything on every run**, since deposits are not consumed — affordable only because curation is convergent and an index only ever grows.
|
|
||||||
|
|
||||||
It holds exactly one inbox address, obtained from `storeRegistry.openDocumentInbox` in `polyfill-adapter.ts`, and no other.
|
|
||||||
|
|
||||||
## NextGraph itself
|
## NextGraph itself
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Applying an inbox deposit is a DISPATCH on what the deposit declares itself to be —
|
||||||
|
* one arm per type, exactly as the target does it.
|
||||||
|
*
|
||||||
|
* ── The shape is upstream's, read in its source ───────────────────────────
|
||||||
|
* `Verifier::process_inbox(msg, content)` is literally `match content { … }`, one arm per
|
||||||
|
* `InboxMsgContent` variant, and ends `_ => return Err(VerifierError::NotImplemented)`
|
||||||
|
* (`engine/verifier/src/inbox_processor.rs`). An arm is a real program, not a branch: the
|
||||||
|
* `ContactDetails` arm validates the envelope (a missing `from_inbox` is
|
||||||
|
* `InvalidInboxPost`, a hard failure and not a skip), builds its addresses from the typed
|
||||||
|
* content, queries the recipient's OWN store to see whether the thing already exists
|
||||||
|
* (a SPARQL `ASK`), decides idempotently (`ContactAlreadyExists` rather than a duplicate),
|
||||||
|
* and only then writes. Validate, read, decide, write — that is what an arm owes.
|
||||||
|
*
|
||||||
|
* ── What each of ours is ──────────────────────────────────────────────────
|
||||||
|
* - {@link LINK_KIND} — a deposit carrying a `ReadCap`. Applying it means holding it and
|
||||||
|
* filing it durably (`branch-registers.addLink`, the emulated `AddLink { read_cap }`).
|
||||||
|
* - a **bare reference** — a deposit that is a NURI and nothing else, applied by
|
||||||
|
* `index-deposit.ts`. It carries no tag on the wire because the payload IS the
|
||||||
|
* reference; upstream a `SocialQueryRequest` addresses its content the same way.
|
||||||
|
* - anything else that declares itself in this package's own namespace has **no arm**,
|
||||||
|
* and says so out loud — upstream's `NotImplemented`, which is an answer and not a
|
||||||
|
* shrug.
|
||||||
|
*
|
||||||
|
* ── Consumer data is not a variant ────────────────────────────────────────
|
||||||
|
* Upstream every message is typed and none is handed to an application; here a deposit
|
||||||
|
* may carry an arbitrary payload the consumer alone interprets, which is a divergence
|
||||||
|
* `surface/inbox.ts` already names. Such a payload declares no kind of ours, so no arm
|
||||||
|
* claims it and nothing is reported about it — it is not an unapplied message, it is not
|
||||||
|
* a message. That is why the reserved namespace is the discriminator and a bare `kind`
|
||||||
|
* field is not: `{ kind: "join" }` is a consumer's word, and it stays one.
|
||||||
|
*
|
||||||
|
* Never exported from the package: an application must have no way to register an arm,
|
||||||
|
* to aim the dispatch, or to turn one off.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addLink } from "./branch-registers";
|
||||||
|
import { applyIndexDeposit } from "./index-deposit";
|
||||||
|
import { getCaps } from "../shared-wallet/bootstrap";
|
||||||
|
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log";
|
||||||
|
import { hasReadCap, isNuri } from "../model/nuri";
|
||||||
|
import type { Nuri, PrincipalId, ReadCap } from "../model/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it is the
|
||||||
|
* same one at all three stages: `InboxMsgContent::Link` is the message
|
||||||
|
* (`engine/net/src/types.rs`, declared but payload-less so far), `AddLink { read_cap }` is
|
||||||
|
* where the recipient files it (`engine/repo/src/types.rs`), `RemoveLink` withdraws it.
|
||||||
|
* So giving access is: deposit a Link, and the recipient's processing files it.
|
||||||
|
*
|
||||||
|
* It travels the SAME channel as any other deposit, which is why key ROTATION needs no
|
||||||
|
* special case on the surface — a re-delivered cap is just another Link.
|
||||||
|
*/
|
||||||
|
const LINK_KIND = "urn:ng-eventually:inbox:link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The namespace every kind this package declares lives under — and the discriminator.
|
||||||
|
*
|
||||||
|
* `urn:ng-eventually:` is this library's reserved namespace; a consumer's own payload
|
||||||
|
* never lands in it, so "does this deposit declare itself to be one of ours?" has an
|
||||||
|
* answer that no application can accidentally give. Keying on the mere PRESENCE of a
|
||||||
|
* `kind` field would have claimed `{ kind: "join" }` — a consumer's word for a consumer's
|
||||||
|
* message.
|
||||||
|
*/
|
||||||
|
const DECLARED_NAMESPACE = "urn:ng-eventually:inbox:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bare-reference variant. It is a tag this module synthesizes, never one that travels:
|
||||||
|
* the payload of such a deposit is the reference itself, with no envelope at all (see
|
||||||
|
* `index-deposit.ts` for why it carries nothing else). Naming it here is what lets the
|
||||||
|
* match below have one arm per type instead of a shape test in the middle of it.
|
||||||
|
*/
|
||||||
|
const REFERENCE_KIND = "urn:ng-eventually:inbox:reference";
|
||||||
|
|
||||||
|
/** WHOSE inbox is being applied, and by whom — every arm acts for this identity alone. */
|
||||||
|
export interface Applying {
|
||||||
|
/** The inbox's owner. Every act an arm performs is done for them and for nobody else. */
|
||||||
|
readonly owner: PrincipalId;
|
||||||
|
/** The cap-registry ring of {@link owner} — `accountKey(owner)`. One rule keys it for
|
||||||
|
* both cases: the owner's own session, and a drain running under someone else's. */
|
||||||
|
readonly ring: string;
|
||||||
|
/** The inbox the deposit was found in. Upstream the address IS the identification of
|
||||||
|
* the recipient, so no arm needs the deposit to name a target. */
|
||||||
|
readonly inbox: Nuri;
|
||||||
|
/**
|
||||||
|
* Whether {@link owner} is ALSO the identity holding the session.
|
||||||
|
*
|
||||||
|
* It decides what an arm may do, and the line is not arbitrary. A drain running under
|
||||||
|
* SOMEONE ELSE's session (the deferred stand-in for an absent owner,
|
||||||
|
* `emulated-verifier/inbox-processor.ts`) usurps the owner's identity, and it does so
|
||||||
|
* for the verifier's own bookkeeping only — a register write on the owner's User
|
||||||
|
* branch. Writing APPLICATION data into the owner's documents from a stranger's page is
|
||||||
|
* a different act, and this package does not take it: the deposit is not consumed, so
|
||||||
|
* it stays for the owner's own next connection, which is the real path.
|
||||||
|
*/
|
||||||
|
readonly ownerHoldsTheSession: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this deposit DECLARES itself to be — `null` when it declares nothing of ours.
|
||||||
|
*
|
||||||
|
* The one place the wire form is turned into a variant, so `surface/inbox.ts` and the
|
||||||
|
* dispatch below cannot come to disagree about what counts as this package's business.
|
||||||
|
*/
|
||||||
|
// @provenance kindOf kind=aligned level=1 ref=engine/net/src/types.rs:InboxMsgContent — upstream the VARIANT is the type and nothing else can be sent; here it is read off a JSON payload, because a deposit carries an arbitrary one
|
||||||
|
export function kindOf(payload: unknown): string | null {
|
||||||
|
if (typeof payload === "string") return isNuri(payload) ? REFERENCE_KIND : null;
|
||||||
|
if (payload === null || typeof payload !== "object") return null;
|
||||||
|
const kind: unknown = (payload as { kind?: unknown }).kind;
|
||||||
|
return typeof kind === "string" && kind.startsWith(DECLARED_NAMESPACE) ? kind : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cap a Link deposit carries, or `null` when it carries none that reads. */
|
||||||
|
// @provenance capOfPayload kind=declared-not-wired level=1 ref=engine/net/src/types.rs:InboxMsgContent — the `Link` variant is DECLARED and carries nothing, so the cap this reads has no upstream field yet
|
||||||
|
export function capOfPayload(payload: unknown): ReadCap | null {
|
||||||
|
const p = payload as { kind?: unknown; cap?: unknown } | null;
|
||||||
|
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
|
||||||
|
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The payload of a Link deposit carrying `cap` — the one place `share` composes one. */
|
||||||
|
// @provenance linkPayload kind=declared-not-wired level=1 ref=engine/net/src/types.rs:InboxMsgContent — the composing half of the same declared-and-empty variant
|
||||||
|
export function linkPayload(cap: ReadCap): { kind: string; cap: ReadCap } {
|
||||||
|
return { kind: LINK_KIND, cap };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an arm's refusal goes: the package's own log stream, NOT gated by the access-log
|
||||||
|
* flag. A diagnostic may be opt-in; a message that could not be applied may not.
|
||||||
|
*
|
||||||
|
* It does not throw, and that is the rule this package already runs on: one unapplicable
|
||||||
|
* item is reported and denies nobody their session, nor the OTHER deposits their turn. A
|
||||||
|
* genuine I/O failure is a different thing and still propagates — the caller reports the
|
||||||
|
* whole inbox and moves to the next one.
|
||||||
|
*/
|
||||||
|
function report(ctx: Applying, why: string): void {
|
||||||
|
console.error(
|
||||||
|
accessLogPrefix() + " could not apply a deposit in " + shortNuri(ctx.inbox) +
|
||||||
|
" — it stays in it and the next attempt will try again: " + why,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply ONE deposit for the owner of its inbox. Answers whether it produced an effect —
|
||||||
|
* which is what a drain counts, and all any caller has ever needed from it.
|
||||||
|
*
|
||||||
|
* Nothing is consumed here, by any arm: an inbox stays re-readable, so every abandon and
|
||||||
|
* every refusal above is harmless — what was not applied is still there for the next push
|
||||||
|
* or the next connection.
|
||||||
|
*/
|
||||||
|
// @provenance applyDeposit kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — the same `match` over the declared content, one arm per variant, `NotImplemented` for the rest
|
||||||
|
export async function applyDeposit(payload: unknown, ctx: Applying): Promise<boolean> {
|
||||||
|
const kind = kindOf(payload);
|
||||||
|
switch (kind) {
|
||||||
|
case null:
|
||||||
|
// Consumer data. No arm claims it, and nothing is reported: it is not an unapplied
|
||||||
|
// message, it is not a message.
|
||||||
|
return false;
|
||||||
|
case LINK_KIND:
|
||||||
|
return applyLink(payload, ctx);
|
||||||
|
case REFERENCE_KIND:
|
||||||
|
return applyReference(payload as Nuri, ctx);
|
||||||
|
default:
|
||||||
|
// Upstream's `_ => Err(NotImplemented)`. A kind in this package's own namespace that
|
||||||
|
// no arm answers is a version skew, not consumer data — and until 2026-08-21 it was
|
||||||
|
// read, seen, and produced nothing at all: no effect and no reported failure, which
|
||||||
|
// is the "failure disguised as an absence" defect this package has closed repeatedly.
|
||||||
|
report(ctx, `no arm applies deposits of kind ${JSON.stringify(kind)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The **Link** arm: validate the envelope, hold the cap, file it durably.
|
||||||
|
*
|
||||||
|
* `learnFor` before `addLink`, and both NAMED rather than resolved: the ring is the one
|
||||||
|
* the caller decided for when its guard ran, not whoever happens to be connected when the
|
||||||
|
* `await` resumes. In the owner's own session the two are the same ring and the first act
|
||||||
|
* is a no-op — `read` already learned it in memory; only this makes it durable.
|
||||||
|
*
|
||||||
|
* A payload that declares itself a Link and carries no readable cap is REFUSED rather than
|
||||||
|
* passed over. That is the envelope validation upstream's arms open with (`from_inbox`
|
||||||
|
* missing is `InvalidInboxPost`, a hard failure), and passing it over would have been the
|
||||||
|
* same silence the `_` arm above exists to end.
|
||||||
|
*/
|
||||||
|
async function applyLink(payload: unknown, ctx: Applying): Promise<boolean> {
|
||||||
|
const cap = capOfPayload(payload);
|
||||||
|
if (cap === null) {
|
||||||
|
report(ctx, "a deposit declares itself a Link and carries no readable cap");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
getCaps().learnFor(ctx.ring, cap);
|
||||||
|
await addLink(cap, ctx.owner);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The **reference** arm: resolve it and look — see `index-deposit.ts` for the whole of it.
|
||||||
|
*
|
||||||
|
* Only in the owner's OWN session, for the reason {@link Applying.ownerHoldsTheSession}
|
||||||
|
* gives: an entry is application data in the owner's document, and a drain running under a
|
||||||
|
* stranger's session writes the verifier's registers and nothing else. Left waiting is not
|
||||||
|
* left silent, and it is not a failure either — the deposit is not consumed, so the owner's
|
||||||
|
* next connection applies it, which is the path this package documents.
|
||||||
|
*/
|
||||||
|
async function applyReference(reference: Nuri, ctx: Applying): Promise<boolean> {
|
||||||
|
if (!ctx.ownerHoldsTheSession) {
|
||||||
|
logStage(
|
||||||
|
"WAITING " + shortNuri(ctx.inbox) + " — a reference deposit is applied by its own " +
|
||||||
|
"owner's session; it stays for " + ctx.owner,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const application = await applyIndexDeposit(reference, ctx.inbox);
|
||||||
|
switch (application.outcome) {
|
||||||
|
case "applied":
|
||||||
|
logStage(
|
||||||
|
"INDEXED " + shortNuri(application.object) + " in the index behind " +
|
||||||
|
shortNuri(ctx.inbox) + " under " + JSON.stringify(application.value),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
case "unchanged":
|
||||||
|
return false;
|
||||||
|
case "skipped":
|
||||||
|
// A property of the OBJECT, not a failure: it resolved, and there is nothing to
|
||||||
|
// index it by. Traced, not reported — see `index-deposit.ts`.
|
||||||
|
logStage(
|
||||||
|
"NOT INDEXED " + shortNuri(application.object) + " — " + application.reason,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
case "refused":
|
||||||
|
report(ctx, application.why);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* The INDEX arm of the inbox dispatch — what this package does with a deposit that is a
|
||||||
|
* bare reference.
|
||||||
|
*
|
||||||
|
* ── The variant, and why it carries nothing else ──────────────────────────
|
||||||
|
* A reference deposit is a NURI and nothing more: no operation, no claim, no copy of any
|
||||||
|
* value. That is the shape upstream already uses — a `SocialQueryRequest` carries
|
||||||
|
* `definition_commit_body_ref`, a REFERENCE, and the recipient's `inbox_processor`
|
||||||
|
* composes its own SPARQL from what it resolves (`engine/verifier/src/inbox_processor.rs`).
|
||||||
|
* It carries no target document either, for the reason `surface/inbox.ts` gives at length:
|
||||||
|
* an inbox belongs to exactly one repo, so the ADDRESS identifies the recipient.
|
||||||
|
*
|
||||||
|
* Since anyone may deposit into any inbox, a payload that carried an operation would be a
|
||||||
|
* licence to rewrite someone else's document, and a payload that carried a value would let
|
||||||
|
* a depositor put in the index something the object does not say. So the arm does not act
|
||||||
|
* on a claim: it resolves the reference and LOOKS. What the object says is what is written.
|
||||||
|
*
|
||||||
|
* ── An index document is an ORDINARY document ─────────────────────────────
|
||||||
|
* Nothing marks one. What makes a document an index is that it declares, on its own NURI
|
||||||
|
* as subject, the FIELD it indexes by ({@link INDEX_FIELD}); each entry is then the
|
||||||
|
* indexed object's NURI as subject, carrying that object's value for the field
|
||||||
|
* ({@link ENTRY_VALUE}). The declaration lives IN the document on purpose: two
|
||||||
|
* applications pointing at the same index would otherwise curate it on two different
|
||||||
|
* fields and corrupt each other's entries.
|
||||||
|
*
|
||||||
|
* ── AN INDEX ONLY EVER GROWS ──────────────────────────────────────────────
|
||||||
|
* Nothing here removes an entry, and nothing here can express a removal. That is what
|
||||||
|
* makes the failure story trivial: since the only write is an addition, a read that comes
|
||||||
|
* back empty — object gone, unreadable, or the broker silent — can only ever mean "not
|
||||||
|
* added this time", and a later deposit adds it. Nothing has to tell an absence from a
|
||||||
|
* failure, so nothing can get that wrong. The price, stated plainly because someone will
|
||||||
|
* need it: an entry cannot be taken out of an index, by anyone, including its owner.
|
||||||
|
*
|
||||||
|
* Harmless is not the same as invisible: every reference that could not be resolved is
|
||||||
|
* REPORTED, because a failure that looks exactly like a normal outcome teaches nobody
|
||||||
|
* anything.
|
||||||
|
*
|
||||||
|
* ── The IRIs are another package's, deliberately unchanged ────────────────
|
||||||
|
* `urn:ng-helpers:index:field` / `:value` are the IRIs `@ng-helpers/indexing` already
|
||||||
|
* writes, and they are kept letter for letter: renaming them would be a data-format change
|
||||||
|
* that no one decided, and every index already written would stop being read. It IS odd
|
||||||
|
* for this package to write another package's namespace — that oddity is flagged, not
|
||||||
|
* acted on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readUnion } from "../surface/read-model";
|
||||||
|
import { sparqlUpdate } from "../surface/docs";
|
||||||
|
import { readInboxCapPairs } from "./branch-registers";
|
||||||
|
import { session } from "../shared-wallet/account-registry";
|
||||||
|
import { assertNuri, escapeLiteral } from "../surface/sparql";
|
||||||
|
import type { UnionSubject } from "../surface/read-model";
|
||||||
|
import type { Nuri } from "../model/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On the index document's own subject: the predicate an indexed object must carry,
|
||||||
|
* declared once by whoever created the index.
|
||||||
|
*/
|
||||||
|
// @provenance INDEX_FIELD kind=invention level=none ref=none — an index is an ordinary document upstream, and nothing there declares what a document indexes by
|
||||||
|
export const INDEX_FIELD = "urn:ng-helpers:index:field";
|
||||||
|
|
||||||
|
/** On an entry (subject = the indexed object's NURI): that object's value for the field. */
|
||||||
|
// @provenance ENTRY_VALUE kind=invention level=none ref=none — same: the entry shape is ours
|
||||||
|
export const ENTRY_VALUE = "urn:ng-helpers:index:value";
|
||||||
|
|
||||||
|
/** What applying ONE reference deposit came to. */
|
||||||
|
export type IndexApplication =
|
||||||
|
/** Resolved, and now in the index under the value the object holds. */
|
||||||
|
| { readonly outcome: "applied"; readonly object: Nuri; readonly value: string }
|
||||||
|
/** Resolved, and already in the index. Nothing was read again and nothing written. */
|
||||||
|
| { readonly outcome: "unchanged"; readonly object: Nuri }
|
||||||
|
/** Resolved, and deliberately not added — a property of the OBJECT, not a failure. */
|
||||||
|
| { readonly outcome: "skipped"; readonly object: Nuri; readonly reason: string }
|
||||||
|
/** Nothing was written, and something is wrong enough to say out loud. */
|
||||||
|
| { readonly outcome: "refused"; readonly why: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The values a subject carries for a predicate.
|
||||||
|
*
|
||||||
|
* OWN properties only. `props` arrives as a plain object literal (`readUnion` builds
|
||||||
|
* `const props: Record<string, string[]> = {}`), so it inherits from `Object.prototype`:
|
||||||
|
* an index declaring `constructor`, `toString` or `valueOf` as its field would otherwise
|
||||||
|
* find a FUNCTION where a list of values belongs, and spreading it throws. The non-string
|
||||||
|
* filter is the same caution — this data crossed a process boundary, and a value that is
|
||||||
|
* not a string has no business being compared or written as a literal.
|
||||||
|
*
|
||||||
|
* The two guards are deliberately redundant; they answer different questions. `hasOwn`
|
||||||
|
* asks whether the subject really carries this predicate, `Array.isArray` whether what
|
||||||
|
* came back has the shape the type promises.
|
||||||
|
*/
|
||||||
|
export function valuesOf(
|
||||||
|
subject: UnionSubject | undefined,
|
||||||
|
predicate: string,
|
||||||
|
): readonly string[] {
|
||||||
|
const props = subject?.props;
|
||||||
|
if (props === undefined || !Object.hasOwn(props, predicate)) return [];
|
||||||
|
const values: unknown = props[predicate];
|
||||||
|
if (!Array.isArray(values)) return [];
|
||||||
|
return values.filter((value): value is string => typeof value === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every field the document declares for itself — zero, one, or (wrongly) several. */
|
||||||
|
export function declaredFields(
|
||||||
|
subjects: readonly UnionSubject[],
|
||||||
|
index: Nuri,
|
||||||
|
): readonly string[] {
|
||||||
|
return valuesOf(
|
||||||
|
subjects.find((s) => s.subject === index),
|
||||||
|
INDEX_FIELD,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The objects already in the index — every subject that is NOT the document itself and
|
||||||
|
* carries at least one value for {@link ENTRY_VALUE}.
|
||||||
|
*
|
||||||
|
* At least one, never exactly one. Requiring exactly one was a hole big enough to drive
|
||||||
|
* the "only ever grows" guarantee through: two values for one object — which two curating
|
||||||
|
* sessions racing each other produce, since each only ever ADDS — made the entry read as
|
||||||
|
* ABSENT, so the index could SHRINK through nothing but additions.
|
||||||
|
*/
|
||||||
|
export function indexedObjects(
|
||||||
|
subjects: readonly UnionSubject[],
|
||||||
|
index: Nuri,
|
||||||
|
): ReadonlySet<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const subject of subjects) {
|
||||||
|
if (subject.subject === index) continue;
|
||||||
|
if (valuesOf(subject, ENTRY_VALUE).length > 0) out.add(subject.subject);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHICH document this inbox belongs to — the reverse of `openDocumentInbox`'s pairing.
|
||||||
|
*
|
||||||
|
* Read off the owner's own User branch (the emulated `AddInboxCap`, which pairs the
|
||||||
|
* document with the inbox opened on it), so the answer is the owner's own record and not
|
||||||
|
* something the deposit claimed. `null` when no document is paired with this inbox, which
|
||||||
|
* is the ordinary case for a PERSON's inbox: those belong to a user, not to a document.
|
||||||
|
*/
|
||||||
|
async function documentAddressedBy(inbox: Nuri): Promise<Nuri | null> {
|
||||||
|
const pairs = await readInboxCapPairs();
|
||||||
|
return pairs.find((p) => p.inbox === inbox)?.doc ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one reference deposit, for the owner of `inbox`.
|
||||||
|
*
|
||||||
|
* Resolves and looks, in this order: which document the inbox belongs to, what that
|
||||||
|
* document declares it indexes by, whether the reference is already in, what the
|
||||||
|
* referenced object says for the field — and only then writes ONE entry.
|
||||||
|
*
|
||||||
|
* Never removes anything and never rewrites an entry, so re-applying the same deposit —
|
||||||
|
* which happens on every drain, because deposits are not consumed — lands on the same
|
||||||
|
* result. Curation is convergent, and its outcome does not depend on the order references
|
||||||
|
* arrive in.
|
||||||
|
*/
|
||||||
|
// @provenance applyIndexDeposit kind=invention level=none ref=none — nothing upstream turns a deposited reference into an index entry; the whole arm is this library's own
|
||||||
|
export async function applyIndexDeposit(
|
||||||
|
reference: Nuri,
|
||||||
|
inbox: Nuri,
|
||||||
|
): Promise<IndexApplication> {
|
||||||
|
const index = await documentAddressedBy(inbox);
|
||||||
|
if (index === null) {
|
||||||
|
return {
|
||||||
|
outcome: "refused",
|
||||||
|
why:
|
||||||
|
"a bare reference is a deposit for an INDEX document, and this inbox is not a " +
|
||||||
|
`document's — nothing here says what to index it by: ${JSON.stringify(inbox)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reference === index) {
|
||||||
|
return { outcome: "skipped", object: reference, reason: "self-reference" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjects = await readUnion([index]);
|
||||||
|
const declared = declaredFields(subjects, index);
|
||||||
|
if (declared.length > 1) {
|
||||||
|
// Ambiguity refuses to curate and leaves READING alone. Picking one would silently
|
||||||
|
// corrupt the index: entries already written are never re-read, so they keep their
|
||||||
|
// old field's values while new ones arrive under the new field, and one list ends up
|
||||||
|
// ordered by two different properties. A quiet wrong answer is worse than a loud
|
||||||
|
// refusal — and this one is permanent, because nothing here deletes.
|
||||||
|
return {
|
||||||
|
outcome: "refused",
|
||||||
|
why:
|
||||||
|
`${index} declares ${declared.length} index fields ` +
|
||||||
|
`(${declared.map((f) => JSON.stringify(f)).join(", ")}) — refusing to curate rather ` +
|
||||||
|
"than pick one. Entries already in it stay readable. This cannot be undone " +
|
||||||
|
"(nothing here deletes): curate into a fresh index.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const field = declared[0];
|
||||||
|
if (field === undefined) {
|
||||||
|
return {
|
||||||
|
outcome: "refused",
|
||||||
|
why:
|
||||||
|
`${index} declares no index field (${INDEX_FIELD}) — either it is not an index, or ` +
|
||||||
|
"it could not be read: an unreadable document and an empty one are the same empty " +
|
||||||
|
"result here. Nothing was written. Retry before concluding it is malformed.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indexedObjects(subjects, index).has(reference)) {
|
||||||
|
// Already there. Nothing is re-read and nothing is rewritten — an entry, once made,
|
||||||
|
// is never touched again.
|
||||||
|
return { outcome: "unchanged", object: reference };
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved: readonly UnionSubject[];
|
||||||
|
try {
|
||||||
|
resolved = await readUnion([reference]);
|
||||||
|
} catch (error) {
|
||||||
|
return { outcome: "refused", why: `${reference}: ${String(error)}` };
|
||||||
|
}
|
||||||
|
if (resolved.length === 0) {
|
||||||
|
// ABSENT, unreadable, or the read failed — and this layer does not tell them apart,
|
||||||
|
// because nothing it does depends on the answer: an index only ever grows, so every
|
||||||
|
// reading leads to the same act, do not add and say so.
|
||||||
|
return {
|
||||||
|
outcome: "refused",
|
||||||
|
why:
|
||||||
|
`${reference}: the read came back empty — the object is absent, unreadable, or the ` +
|
||||||
|
"read failed; they are not distinguished here, and a later deposit adds it",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = resolved.flatMap((s) => [...valuesOf(s, field)]);
|
||||||
|
const value = values[0];
|
||||||
|
if (value === undefined) {
|
||||||
|
// NARROW, and an open question: an object carrying nothing for the field is simply
|
||||||
|
// not added. There is no key to index it by, and inventing one (a placeholder, the
|
||||||
|
// deposit time) would put in the index something the object does not say.
|
||||||
|
return { outcome: "skipped", object: reference, reason: `carries no ${field}` };
|
||||||
|
}
|
||||||
|
if (values.length > 1) {
|
||||||
|
return {
|
||||||
|
outcome: "skipped",
|
||||||
|
object: reference,
|
||||||
|
reason: `carries ${values.length} values for ${field} — which one would the entry hold?`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = await session();
|
||||||
|
// An INSERT and nothing else. The document is named ONCE, as the anchor: `sparqlUpdate`
|
||||||
|
// scopes the write to that repo's default graph, so the statement carries no `GRAPH <…>`
|
||||||
|
// wrapper — the canonical shape `surface/inbox.ts` explains beside its own write.
|
||||||
|
await sparqlUpdate(
|
||||||
|
s.sessionId,
|
||||||
|
`INSERT DATA { <${assertNuri(reference)}> <${ENTRY_VALUE}> "${escapeLiteral(value)}" }`,
|
||||||
|
index,
|
||||||
|
"indexEntry",
|
||||||
|
);
|
||||||
|
return { outcome: "applied", object: reference, value };
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
|
* Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
|
||||||
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
|
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
|
||||||
* decision). The mechanism itself knows no application domain: the consumer
|
* decision). The consumer supplies the inbox document NURI and interprets its own
|
||||||
* supplies the inbox document NURI and interprets the `payload`. (An example
|
* `payload`: this module knows no application domain, and the only payloads it reads
|
||||||
* consumer mapping, purely illustrative: a consumer might use one inbox for a
|
* are the ones that DECLARE themselves to this package — see
|
||||||
* registration deposit and another for submitting a reference to an index.)
|
* `emulated-verifier/inbox-dispatch.ts`, which owns that question and every arm that
|
||||||
|
* answers it. Everything else is handed back untouched.
|
||||||
*
|
*
|
||||||
* ── Real target vs this emulation ─────────────────────────────────────────
|
* ── Real target vs this emulation ─────────────────────────────────────────
|
||||||
* In real NextGraph, a message is sealed to the recipient's key and queued into
|
* In real NextGraph, a message is sealed to the recipient's key and queued into
|
||||||
@@ -31,12 +32,18 @@ import { depositInto, readForHolder } from "../emulated-verifier/register-write"
|
|||||||
import { subscribeDoc } from "./subscribe";
|
import { subscribeDoc } from "./subscribe";
|
||||||
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||||
import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
|
import { documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
|
||||||
import { mintCap } from "../emulated-verifier/caps";
|
import { mintCap } from "../emulated-verifier/caps";
|
||||||
import {
|
import {
|
||||||
scheduleInboxProcessing,
|
scheduleInboxProcessing,
|
||||||
traceProcessed,
|
traceProcessed,
|
||||||
} from "../emulated-verifier/inbox-processor";
|
} from "../emulated-verifier/inbox-processor";
|
||||||
|
import {
|
||||||
|
applyDeposit,
|
||||||
|
capOfPayload,
|
||||||
|
kindOf,
|
||||||
|
linkPayload,
|
||||||
|
} from "../emulated-verifier/inbox-dispatch";
|
||||||
import {
|
import {
|
||||||
accountKey,
|
accountKey,
|
||||||
inboxOwner,
|
inboxOwner,
|
||||||
@@ -45,7 +52,7 @@ import {
|
|||||||
lookupAccount,
|
lookupAccount,
|
||||||
} from "../shared-wallet/account-registry";
|
} from "../shared-wallet/account-registry";
|
||||||
import { escapeLiteral } from "./sparql";
|
import { escapeLiteral } from "./sparql";
|
||||||
import { hasReadCap, toNuri } from "../model/nuri";
|
import { toNuri } from "../model/nuri";
|
||||||
import {
|
import {
|
||||||
accessLogPrefix,
|
accessLogPrefix,
|
||||||
enabled as accessLogEnabled,
|
enabled as accessLogEnabled,
|
||||||
@@ -53,7 +60,7 @@ import {
|
|||||||
logStage,
|
logStage,
|
||||||
shortNuri,
|
shortNuri,
|
||||||
} from "../shared-wallet/access-log";
|
} from "../shared-wallet/access-log";
|
||||||
import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
|
import type { Nuri, NuriLike, PrincipalId } from "../model/types";
|
||||||
|
|
||||||
// --- deposit model --------------------------------------------------------
|
// --- deposit model --------------------------------------------------------
|
||||||
|
|
||||||
@@ -290,21 +297,16 @@ export async function postToDocument(docLike: NuriLike, opts: PostOptions): Prom
|
|||||||
// --- cap delivery ---------------------------------------------------------
|
// --- cap delivery ---------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it
|
* The payloads the last read of an inbox saw DECLARE themselves to this package —
|
||||||
* is the same one at all three stages: `InboxMsgContent::Link` is the message
|
* every variant of the dispatch, not only Links — awaiting the applying pass.
|
||||||
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
|
|
||||||
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
|
|
||||||
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
|
|
||||||
* the recipient processes their inbox and files it.
|
|
||||||
*
|
*
|
||||||
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
|
* Kept in memory for this session and nothing more: APPLYING them is
|
||||||
* no special case on the surface — a re-delivered cap is just another Link.
|
* {@link processInbox}'s job, because reading an inbox must not quietly write to a
|
||||||
|
* user's store. Reading the raw deposits a second time would mean re-parsing them, so
|
||||||
|
* what the read already decoded is what the pass works from.
|
||||||
*/
|
*/
|
||||||
const LINK_KIND = "urn:ng-eventually:inbox:link";
|
const seenByInbox = new Map<Nuri, unknown[]>();
|
||||||
|
function declaredIn(inbox: Nuri): unknown[] {
|
||||||
/** Links observed during the last read of an inbox, awaiting durable filing. */
|
|
||||||
const seenByInbox = new Map<Nuri, ReadCap[]>();
|
|
||||||
function capsSeenIn(inbox: Nuri): ReadCap[] {
|
|
||||||
return seenByInbox.get(inbox) ?? [];
|
return seenByInbox.get(inbox) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,12 +334,8 @@ function depositsFrom(result: unknown): Deposit[] {
|
|||||||
return deposits;
|
return deposits;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The cap a deposit carries, if it is a Link rather than consumer data. */
|
// What a deposit DECLARES itself to be, and what each declaration means, live in
|
||||||
function capOfPayload(payload: unknown): ReadCap | null {
|
// `emulated-verifier/inbox-dispatch.ts` — the `match` this surface hands its deposits to.
|
||||||
const p = payload as { kind?: unknown; cap?: unknown } | null;
|
|
||||||
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
|
|
||||||
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share ONE document with ONE recipient.
|
* Share ONE document with ONE recipient.
|
||||||
@@ -426,7 +424,7 @@ export async function share(doc: NuriLike, toUser: string): Promise<void> {
|
|||||||
`${JSON.stringify(toUser)}. Sharing does not create the person you share with.`,
|
`${JSON.stringify(toUser)}. Sharing does not create the person you share with.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
|
await post(await userInbox(toUser, "protected"), { payload: linkPayload(cap) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -529,13 +527,19 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
|||||||
// cold direct-read path opens the repo explicitly before calling `read`.
|
// cold direct-read path opens the repo explicitly before calling `read`.
|
||||||
const result = await sparqlQuery(sid, DEPOSITS_QUERY, undefined, targetInbox, "inboxRead");
|
const result = await sparqlQuery(sid, DEPOSITS_QUERY, undefined, targetInbox, "inboxRead");
|
||||||
const deposits = depositsFrom(result);
|
const deposits = depositsFrom(result);
|
||||||
// Links are infrastructure, not consumer data: they never reach the caller. They
|
// A KEY is the one thing that never reaches the caller: it is infrastructure, and the
|
||||||
// are only KEPT here (in memory, for this session) — FILING them durably is
|
// model's whole point is that an application never handles one. Every other deposit is
|
||||||
// `processInbox`'s job, because reading an inbox must not quietly write to a
|
// handed back exactly as before, INCLUDING the ones that declare themselves to this
|
||||||
// user's store. Filing fires the registry's change signal, which is what makes a
|
// package — a reference is public information, so withholding it would buy nothing and
|
||||||
// view that was empty for want of that cap re-read instead of staying stale.
|
// would take from an application something it can already read.
|
||||||
|
//
|
||||||
|
// What every declared payload does get is a place in the session's list, which is what
|
||||||
|
// {@link processInbox} works from. It is only KEPT here — APPLYING is that pass's job,
|
||||||
|
// because reading an inbox must not quietly write to a user's store. Filing fires the
|
||||||
|
// registry's change signal, which is what makes a view that was empty for want of a cap
|
||||||
|
// re-read instead of staying stale.
|
||||||
const delivered: Deposit[] = [];
|
const delivered: Deposit[] = [];
|
||||||
const links: ReadCap[] = [];
|
const declared: unknown[] = [];
|
||||||
// The ownership guard ran at entry; the filing happens several awaits later, and filing
|
// The ownership guard ran at entry; the filing happens several awaits later, and filing
|
||||||
// resolves WHO is holding at that moment. So an application switching identity in the
|
// resolves WHO is holding at that moment. So an application switching identity in the
|
||||||
// gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the
|
// gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the
|
||||||
@@ -546,15 +550,15 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
|||||||
// consumed by reading, so the next connection under the right identity files them.
|
// consumed by reading, so the next connection under the right identity files them.
|
||||||
const stillOwner = getCurrentUser() === owner;
|
const stillOwner = getCurrentUser() === owner;
|
||||||
for (const d of deposits) {
|
for (const d of deposits) {
|
||||||
|
if (kindOf(d.payload) !== null) declared.push(d.payload);
|
||||||
const cap = capOfPayload(d.payload);
|
const cap = capOfPayload(d.payload);
|
||||||
if (cap) {
|
if (cap) {
|
||||||
if (stillOwner) getCaps().learnFor(ownerKey, cap);
|
if (stillOwner) getCaps().learnFor(ownerKey, cap);
|
||||||
links.push(cap);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
delivered.push(d);
|
delivered.push(d);
|
||||||
}
|
}
|
||||||
if (links.length > 0) seenByInbox.set(targetInbox, links);
|
if (declared.length > 0) seenByInbox.set(targetInbox, declared);
|
||||||
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
|
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
|
||||||
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
|
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
|
||||||
// each — the exact visibility needed to trace materialization at the owner
|
// each — the exact visibility needed to trace materialization at the owner
|
||||||
@@ -659,16 +663,19 @@ export async function readSyncedForDocument(docLike: NuriLike): Promise<Deposit[
|
|||||||
/**
|
/**
|
||||||
* PROCESS an inbox: read it, and **apply** what it contains.
|
* PROCESS an inbox: read it, and **apply** what it contains.
|
||||||
*
|
*
|
||||||
* Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`,
|
* Applying is a DISPATCH on what each deposit declares itself to be, one arm per type,
|
||||||
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
|
* exactly as `Verifier::process_inbox` is a `match` over `InboxMsgContent` — the arms,
|
||||||
* the cap survives the session. Upstream this is what a verifier does when it
|
* and what a kind with no arm gets, are `emulated-verifier/inbox-dispatch.ts`. Applying a
|
||||||
* processes queued messages: an inbox is a **queue you consume**, not a store you
|
* {@link share} Link, for instance, means filing it durably (`branch-registers.addLink`,
|
||||||
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
|
* the emulated `AddLink { read_cap }` on the User branch of the private store) so the cap
|
||||||
* database, and it is the thing this replaces.
|
* survives the session. Upstream this is what a verifier does when it processes queued
|
||||||
|
* messages: an inbox is a **queue you consume**, not a store you re-read. Re-reading an
|
||||||
|
* inbox every session to recover caps is using a queue as a database, and it is the thing
|
||||||
|
* this replaces.
|
||||||
*
|
*
|
||||||
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
|
* Idempotent, arm by arm — `addLink` ignores a cap it already holds, an object already in
|
||||||
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
|
* an index is passed over — so processing twice (a second tab, a reconnect) costs nothing.
|
||||||
* {@link read} does — Links are never surfaced.
|
* Returns exactly what {@link read} returned: only a cap is ever withheld.
|
||||||
*
|
*
|
||||||
* ── The holder is captured, not re-resolved after the read ────────────────
|
* ── The holder is captured, not re-resolved after the read ────────────────
|
||||||
* The ownership guard runs at entry (inside {@link readSynced}) and the FILING happens a
|
* The ownership guard runs at entry (inside {@link readSynced}) and the FILING happens a
|
||||||
@@ -693,13 +700,18 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
|||||||
// WHO this processing is for, captured before the read that authorises it — see above.
|
// WHO this processing is for, captured before the read that authorises it — see above.
|
||||||
const holder = getCurrentUser();
|
const holder = getCurrentUser();
|
||||||
const deposits = await readSynced(targetInbox);
|
const deposits = await readSynced(targetInbox);
|
||||||
// `readSynced` already put every Link in memory for this session; now make
|
// `readSynced` already decoded every declared payload for this session; now APPLY them.
|
||||||
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
|
// Reading the raw deposits again would mean re-parsing, so the pass works from what the
|
||||||
// are taken from what the read just observed.
|
// read just observed.
|
||||||
const seen = capsSeenIn(targetInbox);
|
const declared = declaredIn(targetInbox);
|
||||||
seenByInbox.delete(targetInbox);
|
seenByInbox.delete(targetInbox);
|
||||||
for (const cap of seen) {
|
// `readSynced`'s guard refuses a session with no identity, so the holder captured above
|
||||||
// Re-checked per cap, not once: `addLink` reads and writes, so the identity can move
|
// cannot be null past it. Narrowed rather than defaulted: applying "for nobody" is
|
||||||
|
// precisely the mis-filing the capture exists to prevent, and it must not have a value.
|
||||||
|
if (holder === null) return deposits;
|
||||||
|
const ring = accountKey(holder);
|
||||||
|
for (const payload of declared) {
|
||||||
|
// Re-checked per item, not once: an arm reads and writes, so the identity can move
|
||||||
// between two of them just as easily as during the read.
|
// between two of them just as easily as during the read.
|
||||||
if (getCurrentUser() !== holder) {
|
if (getCurrentUser() !== holder) {
|
||||||
logStage(
|
logStage(
|
||||||
@@ -708,7 +720,13 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
|||||||
);
|
);
|
||||||
return deposits;
|
return deposits;
|
||||||
}
|
}
|
||||||
await addLink(cap, holder ?? undefined);
|
// The `match`, one arm per declared type — `emulated-verifier/inbox-dispatch.ts`.
|
||||||
|
await applyDeposit(payload, {
|
||||||
|
owner: holder,
|
||||||
|
ring,
|
||||||
|
inbox: targetInbox,
|
||||||
|
ownerHoldsTheSession: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return deposits;
|
return deposits;
|
||||||
}
|
}
|
||||||
@@ -733,8 +751,10 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
|||||||
* (`branch-registers.fileOwnStructure` / `fileOwnInbox`). It grants nothing new; it names
|
* (`branch-registers.fileOwnStructure` / `fileOwnInbox`). It grants nothing new; it names
|
||||||
* the ring those facts belong to.
|
* the ring those facts belong to.
|
||||||
*
|
*
|
||||||
* Consumer deposits are left where they are, exactly as {@link read} leaves them: an inbox
|
* Consumer deposits are left where they are, exactly as {@link read} leaves them — and so
|
||||||
* is a queue its owner consumes, and a Link is the only thing this may consume for it.
|
* is every deposit whose arm would write application data into the owner's own documents:
|
||||||
|
* an inbox is a queue its OWNER consumes, and what this may apply on somebody else's
|
||||||
|
* behalf is the verifier's own bookkeeping (see `Applying.ownerHoldsTheSession`).
|
||||||
*/
|
*/
|
||||||
async function processForOwner(targetInbox: Nuri): Promise<void> {
|
async function processForOwner(targetInbox: Nuri): Promise<void> {
|
||||||
// WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see
|
// WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see
|
||||||
@@ -782,13 +802,17 @@ async function processForOwner(targetInbox: Nuri): Promise<void> {
|
|||||||
);
|
);
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
for (const deposit of deposits) {
|
for (const deposit of deposits) {
|
||||||
const cap = capOfPayload(deposit.payload);
|
// The SAME `match` the owner's own session runs, with the holder named instead of
|
||||||
if (cap === null) continue; // consumer data — not this service's to consume
|
// resolved — and told whether the owner is the one holding the page, because an arm
|
||||||
// In memory first, then durably: the same order and the same two acts as
|
// that would write application data into the owner's documents must not run from a
|
||||||
// `read` + `processInbox`, with the holder named instead of resolved.
|
// stranger's session (`emulated-verifier/inbox-dispatch.ts`).
|
||||||
caps.learnFor(ownerRing, cap);
|
const done = await applyDeposit(deposit.payload, {
|
||||||
await addLink(cap, ownerId);
|
owner: ownerId,
|
||||||
applied += 1;
|
ring: ownerRing,
|
||||||
|
inbox: targetInbox,
|
||||||
|
ownerHoldsTheSession: getCurrentUser() === ownerId,
|
||||||
|
});
|
||||||
|
if (done) applied += 1;
|
||||||
}
|
}
|
||||||
traceProcessed(targetInbox, ownerRing, applied);
|
traceProcessed(targetInbox, ownerRing, applied);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
/**
|
||||||
|
* Applying an inbox deposit is a DISPATCH on what the deposit declares itself to be, and
|
||||||
|
* this suite is about the two things a `match` owes: every arm does real work, and the
|
||||||
|
* arm that does not exist SAYS SO.
|
||||||
|
*
|
||||||
|
* ── What each test would have looked like before ──────────────────────────
|
||||||
|
* Before 2026-08-21 `processInbox` iterated the caps a read had observed. A deposit of any
|
||||||
|
* other shape was read, counted, logged — and produced nothing: no effect, and no reported
|
||||||
|
* failure. So a reference deposit vanished, a malformed Link vanished, and a kind nobody
|
||||||
|
* had written an arm for vanished, all three indistinguishable from an empty inbox. Every
|
||||||
|
* test below fails against that code, and fails again if the arm it exercises is removed.
|
||||||
|
*
|
||||||
|
* ── Nobody is handed an inbox address ─────────────────────────────────────
|
||||||
|
* Each depositor names the DOCUMENT (`inbox.postToDocument`), which is all an application
|
||||||
|
* has; the address is resolved inside. The one address the TEST resolves for itself is
|
||||||
|
* used only to assert what landed, never given to an actor.
|
||||||
|
*/
|
||||||
|
import { test, expect, mock, afterEach } from "bun:test";
|
||||||
|
import { configure } from "../src/index";
|
||||||
|
import {
|
||||||
|
configureStoreRegistry,
|
||||||
|
resetCaps,
|
||||||
|
resetConfig,
|
||||||
|
resetStoreRegistry,
|
||||||
|
setCurrentUser,
|
||||||
|
} from "../src/shared-wallet/bootstrap";
|
||||||
|
import {
|
||||||
|
createEntityDoc,
|
||||||
|
resetRegistryCache,
|
||||||
|
resolveWriteGraph,
|
||||||
|
} from "../src/shared-wallet/account-registry";
|
||||||
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||||
|
import { openDocumentInbox } from "../src/emulated-verifier/branch-registers";
|
||||||
|
import { connectedUser } from "../src/emulated-verifier/connect";
|
||||||
|
import {
|
||||||
|
cancelScheduledInboxProcessing,
|
||||||
|
runScheduledInboxProcessingNow,
|
||||||
|
} from "../src/emulated-verifier/inbox-processor";
|
||||||
|
import { stopObservingInboxes } from "../src/emulated-verifier/inbox-observer";
|
||||||
|
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||||
|
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
|
||||||
|
import { ENTRY_VALUE, INDEX_FIELD } from "../src/emulated-verifier/index-deposit";
|
||||||
|
import { postToDocument } from "../src/surface/inbox";
|
||||||
|
import { readUnion } from "../src/surface/read-model";
|
||||||
|
import { sparqlUpdate } from "../src/surface/docs";
|
||||||
|
import type { Nuri } from "../src/model/types";
|
||||||
|
|
||||||
|
const SESSION: RegistrySession = { sessionId: "sid-dispatch", privateStoreId: "PRIV-DISPATCH" };
|
||||||
|
const SHIM = "urn:ng-eventually:shim";
|
||||||
|
const INBOX = "urn:ng-eventually:inbox";
|
||||||
|
/** The field the index documents below declare they index by. */
|
||||||
|
const WHEN = "urn:test:when";
|
||||||
|
|
||||||
|
interface Quad { g: string; s: string; p: string; o: string }
|
||||||
|
|
||||||
|
function unescapeLiteral(s: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
if (s[i] === "\\" && i + 1 < s.length) {
|
||||||
|
const next = s[++i];
|
||||||
|
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
||||||
|
} else out += s[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored per-doc read. */
|
||||||
|
function makeFakeNg() {
|
||||||
|
const quads: Quad[] = [];
|
||||||
|
let docCounter = 0;
|
||||||
|
|
||||||
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||||
|
|
||||||
|
const sparql_update = mock(async (...a: unknown[]) => {
|
||||||
|
const query = a[1] as string;
|
||||||
|
const anchor = a[2] as string | undefined;
|
||||||
|
if (!anchor) return undefined;
|
||||||
|
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
|
||||||
|
if (del) {
|
||||||
|
const [s0, p0] = [del[1]!, del[2]!];
|
||||||
|
for (let i = quads.length - 1; i >= 0; i--) {
|
||||||
|
const q = quads[i]!;
|
||||||
|
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||||
|
const sm = body.match(/<([^>]+)>/);
|
||||||
|
if (!sm) return undefined;
|
||||||
|
const s = sm[1]!;
|
||||||
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||||
|
while ((m = pairRe.exec(after)) !== null) {
|
||||||
|
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
||||||
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||||
|
quads.push({ g: anchor, s, p, o });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sparql_query = mock(async (...a: unknown[]) => {
|
||||||
|
const query = a[1] as string;
|
||||||
|
const anchor = a[3] as string | undefined;
|
||||||
|
const byPred = (pred: string, v: string) => ({
|
||||||
|
results: {
|
||||||
|
bindings: quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === pred)
|
||||||
|
.map((q) => ({ [v]: { value: q.o } })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (query.includes(`<${SHIM}:shimDoc>`)) return byPred(`${SHIM}:shimDoc`, "shimDoc");
|
||||||
|
if (query.includes(`<${SHIM}:id>`)) {
|
||||||
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||||
|
const only = subjM ? subjM[1]! : null;
|
||||||
|
const bySubject = new Map<string, Record<string, string>>();
|
||||||
|
for (const q of quads) {
|
||||||
|
if (q.g !== anchor) continue;
|
||||||
|
if (only !== null && q.s !== only) continue;
|
||||||
|
const rec = bySubject.get(q.s) ?? {};
|
||||||
|
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
||||||
|
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
||||||
|
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
||||||
|
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
||||||
|
bySubject.set(q.s, rec);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
results: {
|
||||||
|
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
|
||||||
|
id: { value: r.id! },
|
||||||
|
docPublic: { value: r.docPublic ?? "" },
|
||||||
|
docProtected: { value: r.docProtected ?? "" },
|
||||||
|
docPrivate: { value: r.docPrivate ?? "" },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (query.includes(`<${INBOX}:payload>`)) {
|
||||||
|
const bySubject = new Map<string, Record<string, string>>();
|
||||||
|
for (const q of quads) {
|
||||||
|
if (q.g !== anchor) continue;
|
||||||
|
const rec = bySubject.get(q.s) ?? {};
|
||||||
|
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
||||||
|
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
||||||
|
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
||||||
|
bySubject.set(q.s, rec);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
results: {
|
||||||
|
bindings: [...bySubject.values()]
|
||||||
|
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
||||||
|
.map((r) => {
|
||||||
|
const row: Record<string, { value: string }> = {
|
||||||
|
payload: { value: r.payload! },
|
||||||
|
ts: { value: r.ts! },
|
||||||
|
};
|
||||||
|
if (r.from !== undefined) row.from = { value: r.from };
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (query.includes(`<${SHIM}:inboxCap>`)) return byPred(`${SHIM}:inboxCap`, "c");
|
||||||
|
if (query.includes(`<${SHIM}:inboxAddress>`)) return byPred(`${SHIM}:inboxAddress`, "a");
|
||||||
|
if (query.includes(`<${SHIM}:readCap>`)) return byPred(`${SHIM}:readCap`, "c");
|
||||||
|
if (query.includes(`<${SHIM}:link>`)) return byPred(`${SHIM}:link`, "c");
|
||||||
|
if (query.includes(`${SHIM}:isInbox`)) return byPred(`${SHIM}:isInbox`, "i");
|
||||||
|
if (query.includes(`<${SHIM}:inboxOwner>`)) {
|
||||||
|
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:inboxOwner>/);
|
||||||
|
const subj = sm ? sm[1]! : null;
|
||||||
|
return {
|
||||||
|
results: {
|
||||||
|
bindings: quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxOwner` && q.s === subj)
|
||||||
|
.map((q) => ({ u: { value: q.o } })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (query.includes(`${SHIM}:docInbox`)) {
|
||||||
|
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
|
||||||
|
const pred = pm ? pm[1]! : "";
|
||||||
|
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
|
||||||
|
const subj = sm ? sm[1]! : null;
|
||||||
|
return {
|
||||||
|
results: {
|
||||||
|
bindings: quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
|
||||||
|
.map((q) => ({ d: { value: q.o } })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (query.includes(`<${SHIM}:exposedReadCap>`)) return byPred(`${SHIM}:exposedReadCap`, "c");
|
||||||
|
if (query.includes(`<${SHIM}:contains>`)) return byPred(`${SHIM}:contains`, "e");
|
||||||
|
return {
|
||||||
|
results: {
|
||||||
|
bindings: quads
|
||||||
|
.filter((q) => q.g === anchor)
|
||||||
|
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { doc_create, sparql_update, sparql_query, quads };
|
||||||
|
}
|
||||||
|
|
||||||
|
let fake: ReturnType<typeof makeFakeNg>;
|
||||||
|
|
||||||
|
function inject() {
|
||||||
|
fake = makeFakeNg();
|
||||||
|
configure({ ng: fake as never, useShape: (() => {}) as never });
|
||||||
|
configureStoreRegistry({ getSession: async () => SESSION });
|
||||||
|
resetRegistryCache();
|
||||||
|
resetOpenedRepos();
|
||||||
|
resetPublicStoreFetches();
|
||||||
|
resetCaps();
|
||||||
|
setCurrentUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cancelScheduledInboxProcessing();
|
||||||
|
stopObservingInboxes();
|
||||||
|
resetConfig();
|
||||||
|
resetStoreRegistry();
|
||||||
|
resetCaps();
|
||||||
|
resetRegistryCache();
|
||||||
|
resetOpenedRepos();
|
||||||
|
resetPublicStoreFetches();
|
||||||
|
setCurrentUser(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Write one triple into `doc`, as the consumer's own write path would. */
|
||||||
|
async function write(doc: Nuri, p: string, o: string): Promise<void> {
|
||||||
|
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every value the index holds for `object` — one per entry written. */
|
||||||
|
async function entryValuesFor(index: Nuri, object: Nuri): Promise<string[]> {
|
||||||
|
const subjects = await readUnion([index]);
|
||||||
|
return subjects.filter((s) => s.subject === object).flatMap((s) => s.props[ENTRY_VALUE] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the package reported, for the span of `run`. Restores `console.error` whatever
|
||||||
|
* happens — a suite that leaks a stub takes the next file down with it. */
|
||||||
|
async function reportedDuring(run: () => Promise<void>): Promise<string[]> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const real = console.error;
|
||||||
|
console.error = ((...args: unknown[]) => {
|
||||||
|
lines.push(args.map((a) => String(a)).join(" "));
|
||||||
|
}) as typeof console.error;
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} finally {
|
||||||
|
console.error = real;
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alice's index: a public document declaring the field it indexes by, with an inbox open
|
||||||
|
* on it so that anyone can hand it a reference. Returns the index and one PUBLIC object
|
||||||
|
* carrying a value for that field.
|
||||||
|
*/
|
||||||
|
async function aliceOpensAnIndex(fields: string[]): Promise<{ index: Nuri; object: Nuri }> {
|
||||||
|
setCurrentUser("alice");
|
||||||
|
const index = await createEntityDoc("alice", "public");
|
||||||
|
for (const field of fields) await write(index, INDEX_FIELD, field);
|
||||||
|
await openDocumentInbox(index);
|
||||||
|
const object = await createEntityDoc("alice", "public");
|
||||||
|
await write(object, WHEN, "2026-01-01");
|
||||||
|
setCurrentUser(null);
|
||||||
|
return { index, object };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bob has been in the page once, so he is someone the wallet knows. */
|
||||||
|
async function bobSignsInOnce(): Promise<void> {
|
||||||
|
setCurrentUser("bob");
|
||||||
|
await resolveWriteGraph("bob", "protected");
|
||||||
|
setCurrentUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bob hands `payload` to the owner of `doc`, naming the document and nothing else. */
|
||||||
|
async function bobDepositsInto(doc: Nuri, payload: unknown, ts: number): Promise<void> {
|
||||||
|
setCurrentUser("bob");
|
||||||
|
await postToDocument(doc, { payload, ts });
|
||||||
|
setCurrentUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alice connects — which restores her registers and drains every inbox she holds. */
|
||||||
|
async function aliceConnects(): Promise<void> {
|
||||||
|
setCurrentUser("alice");
|
||||||
|
await connectedUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the reference arm -------------------------------------------------------
|
||||||
|
|
||||||
|
test("an entry appears in the index from a bare reference deposited by a stranger", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
const { index, object } = await aliceOpensAnIndex([WHEN]);
|
||||||
|
|
||||||
|
// The whole payload is the reference: no operation, no claim, and no copy of the value.
|
||||||
|
// What lands in the index is what the OBJECT says, which is why a stranger depositing
|
||||||
|
// achieves exactly what the owner would have.
|
||||||
|
await bobDepositsInto(index, object, 1);
|
||||||
|
expect(await entryValuesFor(index, object)).toEqual([]);
|
||||||
|
|
||||||
|
await aliceConnects();
|
||||||
|
|
||||||
|
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reference nobody can resolve is reported, and costs the index nothing", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
const { index, object } = await aliceOpensAnIndex([WHEN]);
|
||||||
|
|
||||||
|
// One resolvable reference and one that names nothing. The second must not take the
|
||||||
|
// first down with it, and must not leave the index short of an entry either.
|
||||||
|
await bobDepositsInto(index, object, 1);
|
||||||
|
await bobDepositsInto(index, "did:ng:o:nowhere", 2);
|
||||||
|
|
||||||
|
const reported = await reportedDuring(aliceConnects);
|
||||||
|
|
||||||
|
expect(reported.some((l) => /did:ng:o:nowhere/.test(l))).toBe(true);
|
||||||
|
expect(reported.some((l) => /the read came back empty/.test(l))).toBe(true);
|
||||||
|
// Costs nothing: the resolvable one is in, and nothing was written for the other.
|
||||||
|
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
|
||||||
|
expect(await entryValuesFor(index, "did:ng:o:nowhere")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an object already in the index is passed over — the same reference twice makes ONE entry", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
const { index, object } = await aliceOpensAnIndex([WHEN]);
|
||||||
|
|
||||||
|
// Deposits are never consumed, so a curating pass sees every deposit on every run. That
|
||||||
|
// is affordable only because a reference already in the index is passed over: without
|
||||||
|
// that check the second deposit writes a second value for the same object, and an entry
|
||||||
|
// carrying two values is exactly what once made the index SHRINK through additions.
|
||||||
|
await bobDepositsInto(index, object, 1);
|
||||||
|
await bobDepositsInto(index, object, 2);
|
||||||
|
|
||||||
|
await aliceConnects();
|
||||||
|
|
||||||
|
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an index declaring NO field refuses, loudly, rather than writing something wrong", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
// A document with an inbox and no declaration: either it is not an index, or it could
|
||||||
|
// not be read — and here those are the same empty result.
|
||||||
|
const { index, object } = await aliceOpensAnIndex([]);
|
||||||
|
|
||||||
|
await bobDepositsInto(index, object, 1);
|
||||||
|
const reported = await reportedDuring(aliceConnects);
|
||||||
|
|
||||||
|
expect(reported.some((l) => /declares no index field/.test(l))).toBe(true);
|
||||||
|
expect(await entryValuesFor(index, object)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an index declaring SEVERAL fields refuses rather than picking one", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
// Entries already written are never re-read, so curating under a second field would
|
||||||
|
// leave one list ordered by two different properties — a quiet wrong answer.
|
||||||
|
const { index, object } = await aliceOpensAnIndex([WHEN, "urn:test:other"]);
|
||||||
|
|
||||||
|
await bobDepositsInto(index, object, 1);
|
||||||
|
const reported = await reportedDuring(aliceConnects);
|
||||||
|
|
||||||
|
expect(reported.some((l) => /declares 2 index fields/.test(l))).toBe(true);
|
||||||
|
expect(await entryValuesFor(index, object)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reference deposit is left for its owner's own session, not applied from a stranger's", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
const { index, object } = await aliceOpensAnIndex([WHEN]);
|
||||||
|
|
||||||
|
await bobDepositsInto(index, object, 1);
|
||||||
|
// The deferred stand-in for an absent owner runs under BOB's session. It files the
|
||||||
|
// verifier's own registers for an absent owner and stops there: writing application data
|
||||||
|
// into Alice's documents from Bob's page is a different act, and it is not taken.
|
||||||
|
setCurrentUser("bob");
|
||||||
|
const reported = await reportedDuring(async () => {
|
||||||
|
await runScheduledInboxProcessingNow();
|
||||||
|
});
|
||||||
|
setCurrentUser(null);
|
||||||
|
expect(await entryValuesFor(index, object)).toEqual([]);
|
||||||
|
// Waiting is not failing, and it must not be reported as one: the arm did not try and
|
||||||
|
// fall short, it is not this session's to run. Running it anyway would report a refusal
|
||||||
|
// here every twenty seconds — Bob's User branch pairs no document with Alice's inbox.
|
||||||
|
expect(reported.filter((l) => /could not apply a deposit/.test(l))).toEqual([]);
|
||||||
|
|
||||||
|
// Nothing was consumed, so Alice's own next connection applies it — the real path.
|
||||||
|
await aliceConnects();
|
||||||
|
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- the cap arm -------------------------------------------------------------
|
||||||
|
|
||||||
|
test("a deposit declaring itself a Link and carrying no readable cap is reported, not passed over", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
const { index } = await aliceOpensAnIndex([WHEN]);
|
||||||
|
|
||||||
|
// A bare reference where a ReadCap belongs — the one type confusion that would invert
|
||||||
|
// the model if it were filed, and the shape a version skew produces. Upstream an arm
|
||||||
|
// opens by validating its envelope and a malformed one is a hard failure, not a skip.
|
||||||
|
await bobDepositsInto(index, { kind: "urn:ng-eventually:inbox:link", cap: "did:ng:o:bare" }, 1);
|
||||||
|
|
||||||
|
const reported = await reportedDuring(aliceConnects);
|
||||||
|
|
||||||
|
expect(reported.some((l) => /declares itself a Link and carries no readable cap/.test(l))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- the arm that does not exist ---------------------------------------------
|
||||||
|
|
||||||
|
test("a kind no arm answers says so — the dispatch's `NotImplemented`", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
const { index } = await aliceOpensAnIndex([WHEN]);
|
||||||
|
|
||||||
|
// A deposit declaring itself in THIS package's reserved namespace, for a variant nothing
|
||||||
|
// here applies. Upstream that is `_ => Err(NotImplemented)`; here it used to be silence.
|
||||||
|
await bobDepositsInto(index, { kind: "urn:ng-eventually:inbox:not-a-thing" }, 1);
|
||||||
|
|
||||||
|
const reported = await reportedDuring(aliceConnects);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reported.some((l) => /no arm applies deposits of kind "urn:ng-eventually:inbox:not-a-thing"/.test(l)),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a consumer payload is not a variant: no arm claims it and nothing is reported", async () => {
|
||||||
|
inject();
|
||||||
|
await bobSignsInOnce();
|
||||||
|
const { index } = await aliceOpensAnIndex([WHEN]);
|
||||||
|
|
||||||
|
// `kind` is an ordinary word an application may use for its own messages. Keying the
|
||||||
|
// dispatch on the mere presence of the field would have turned every one of them into an
|
||||||
|
// unapplied message reported at each drain.
|
||||||
|
await bobDepositsInto(index, { kind: "join", who: "bob" }, 1);
|
||||||
|
|
||||||
|
const reported = await reportedDuring(aliceConnects);
|
||||||
|
|
||||||
|
expect(reported.filter((l) => /could not apply a deposit/.test(l))).toEqual([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user