Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abfb619c76 | |||
| 3dfc231005 | |||
| 1b4fcb567e |
@@ -2,10 +2,12 @@
|
||||
type: overview
|
||||
summary: What an application may rely on from @ng-eventually/polyfill, and what it will have to delete
|
||||
triggers:
|
||||
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit]
|
||||
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit, IRI, urn, namespace, vocabulary, ontology, predicate]
|
||||
paths:
|
||||
- "packages/polyfill/src/index.ts"
|
||||
- "packages/polyfill/src/surface/**"
|
||||
- "packages/polyfill/src/emulated-verifier/machinery.ts"
|
||||
- "packages/polyfill/src/emulated-verifier/index-deposit.ts"
|
||||
- "packages/polyfill/README.md"
|
||||
- "examples/notebook/**"
|
||||
- "docs/api-contract.md"
|
||||
@@ -36,4 +38,6 @@ This repo is the **provider** of `polyfill-surface`; consuming applications live
|
||||
- `polyfill-surface/contract_polyfill-surface` — the engagement itself, written from the caller's point of view.
|
||||
- `rule_no-divergence-from-nextgraph` — the test that decides what may be built at all.
|
||||
- `knowledge_what-an-app-deletes-at-migration` — the two fates a published symbol can have.
|
||||
- `knowledge_what-an-iri-says-upstream` — the same two fates for a written *triple*: NextGraph's own vocabulary, ours, and which of our IRIs must outlive the migration.
|
||||
- `knowledge_what-an-inbox-is-upstream` — what the target actually offers around inboxes, and where a choice of ours begins.
|
||||
- `knowledge_who-processes-an-inbox` — the division of labour along a deposit's path: what this package applies, what it merely delivers, and what a layer above owes itself.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: What NextGraph's own IRI vocabulary is, what this package writes instead, and why scaffolding triples and durable application data must not share a namespace
|
||||
last_checked: 2026-08-21
|
||||
---
|
||||
|
||||
# What an IRI says upstream, and what ours says
|
||||
|
||||
Read at the source in `nextgraph-rs` (`213338f6`). Every claim points at a symbol or a module rather than a line, so `git grep` settles any doubt after the code moves. Two questions are answered here and they are not the same one: *what does the target's vocabulary look like?* and *what does a triple this package writes promise about its own future?*
|
||||
|
||||
## Upstream defines exactly one namespace, and it is the engine's
|
||||
|
||||
`NG_ONTOLOGY = "did:ng:x:ng#"` (`engine/verifier/src/types.rs`) is the only vocabulary namespace anything upstream declares as a constant. Three terms sit beside it — `NG_ONTOLOGY_ABOUT` (`#a`), `NG_ONTOLOGY_TITLE` (`#n`), `NG_ONTOLOGY_CLASS` (`#c`) — a document's description, its title, its class.
|
||||
|
||||
Those three are **not the whole namespace**. The same `did:ng:x:ng#` carries engine protocol state, written as inline string literals rather than named constants: `ng:site` / `ng:protected` and their `_inbox` counterparts, which `import_contact_from_qrcode` writes into a contact document (`engine/verifier/src/request_processor.rs`), and `ng:social_query_id` / `ng:social_query_forwarder` / `ng:social_query_started` / `ng:social_query_sparql`, which the social-query machinery reads and writes (`engine/verifier/src/inbox_processor.rs`). So the namespace is engine-owned in a broader sense than "document metadata": it is where the engine keeps what it needs to keep.
|
||||
|
||||
Everything else that looks like a NextGraph vocabulary lives **only in test fixtures**: `did:ng:x:contact#…`, `did:ng:x:core#…` and `did:ng:x:contact:class#…` in `sdk/rust/src/tests/big_contact_schema.json`, `contact_data.sparql`, `orm_create_patches.rs`, `orm_creation.rs`. No engine code writes them. There is no registry, no parser, no stability promise behind any of them.
|
||||
|
||||
## `x` is not an address — vocabulary and addresses share a scheme and nothing keeps them apart
|
||||
|
||||
The NURI grammar knows `o v b c d a g h j k l n s t u` and the bare `did:ng:i` — the regexes in `engine/net/src/types.rs` and every constructor of `NuriV0` (`engine/net/src/app_protocol.rs`). Neither `x` nor `z` nor `ex` is among them: a vocabulary IRI is simply a string that starts like an address and matches no rule.
|
||||
|
||||
The fixtures make the point sharper. They also use `did:ng:k:contact:tag#…` and `did:ng:k:contact:phoneNumber#…` for controlled values — and `k` **is** in the grammar, as the key half of a `:j:…:k:…` or `:c:…:k:…` pair. The letter is already spent; the vocabulary form matches nothing anyway. Nobody arbitrated that collision because nobody had to: no registry exists to arbitrate it in.
|
||||
|
||||
## `did:ng:x:core#value` is not "a field's value"
|
||||
|
||||
It is the payload slot of a **reified value node**, and reading it as a generic field accessor gets the model backwards. The shape, verifiable in `sdk/rust/src/tests/contact_data.sparql`:
|
||||
|
||||
- the entity carries `did:ng:x:contact#email` → a **value node** (its own IRI);
|
||||
- the value node carries `did:ng:x:core#value` → the literal;
|
||||
- the value node also carries the qualifiers: `core#source` (where this value came from), `core#type` (an IRI such as `did:ng:k:contact:phoneNumber#home`), `core#hidden`, `core#selected`.
|
||||
|
||||
**The field name lives in the parent's predicate**, never in the value node. And the payload slot splits by RDF term kind rather than by field: `core#value` holds a literal, `core#valueDateTime` / `core#valueDate` a typed date, `core#valueIRI` an IRI. `:class#` (as in `did:ng:x:contact:class#SocialContact`) is a convention observed in exactly one vocabulary, the contact fixture.
|
||||
|
||||
## There is no extension convention, and nothing upstream filters or reserves
|
||||
|
||||
No reserved segment, no procedure to claim one, no prohibition — nowhere in the tree. What the examples do instead is either `PREFIX ex: <did:ng:z:>` (`sdk/js/orm/src/tests/shapes/shex/testShape.shex`, the shex under `sdk/js/examples/`, `sdk/rust/src/tests/video_schema.json`) or plain web ontologies — vcard, foaf, schema.org, which the engine itself uses for a contact's name and email. One example spells `did:ng:n:g:x:social:contact#` (`sdk/js/examples/react-ldo/src/.shapes/contact.shex` and the generated files beside it), an older form matching neither the fixtures nor the grammar: the scheme is **not self-consistent across the tree**.
|
||||
|
||||
**Nothing upstream filters or reserves a namespace.** The engine stores and returns arbitrary IRIs. The single prefix-sensitive path is in `engine/verifier/src/commits/transaction.rs`, which matches `NG_ONTOLOGY_ABOUT` / `NG_ONTOLOGY_TITLE` among a commit's inserts and removes only to push a tab-info update when a title or description changed — and only for updates whose `branch_type.is_header()`. The triples stay in the store and stay queryable; nothing is hidden from a reader. **Separation upstream is by BRANCH, not by prefix** — a header branch is a different CRDT with its own topic, which is why upstream needs no reserved prefix at all.
|
||||
|
||||
The `urn:` strings that appear upstream are unit-test fixtures and nothing else: `urn:g`, `urn:child:dg`, `urn:child:sg` inside `#[cfg(test)] mod tests` in `engine/verifier/src/orm/graph/utils.rs`, and `urn:test:…` under `sdk/rust/src/tests/`. They are not vocabulary and imply nothing.
|
||||
|
||||
## What this package writes
|
||||
|
||||
**`urn:ng-eventually:` — `MACHINERY_NS`** (`packages/polyfill/src/emulated-verifier/machinery.ts`), in two families: `urn:ng-eventually:shim:…`, the compartments `shared-wallet/account-registry.ts` uses to emulate a repo's branches inside one document, and `urn:ng-eventually:inbox:…`, the deposit envelope and the deposit kinds `emulated-verifier/inbox-dispatch.ts` dispatches on.
|
||||
|
||||
**Triples under it are dropped on read.** `isMachinerySubject` is called from `readUnion` (`packages/polyfill/src/surface/read-model.ts`), which skips every row whose **subject** starts with the prefix before the caller ever sees it. By subject only — a machinery predicate on an ordinary subject comes straight through.
|
||||
|
||||
**That filtering has no upstream counterpart: it is an invention, and the source annotates it as one** (`kind=invention level=none`). It exists because this package has no branches, so a compartment upstream keeps in a separate CRDT has to live as a reserved subject in the same graph. Do not read our reservation as mirroring anything upstream — nothing there is filtered out of a document's own graph.
|
||||
|
||||
**The index arm writes another package's namespace.** `urn:ng-helpers:index:field` and `urn:ng-helpers:index:value` (`packages/polyfill/src/emulated-verifier/index-deposit.ts`) are the IRIs `@ng-helpers/indexing` already writes, kept letter for letter because renaming them would orphan every index already written. It is odd for this package to write a namespace it does not own. Recorded here as an oddity; it is not this leaf's to fix.
|
||||
|
||||
## Two kinds of triple, two fates — the distinction this leaf exists for
|
||||
|
||||
**Scaffolding** is what this package writes to emulate what the engine will do: the branch compartments, the deposit envelope. It is transitory. It disappears at migration together with the read filter that hides it, and being invisible to a caller is exactly right for it. `urn:ng-eventually:` is its home.
|
||||
|
||||
**Durable application data** is what an application means: an index entry written into a public document, read by third parties, which must **survive** migration. It is deliberately *not* under `urn:ng-eventually:` — which is precisely why the index IRIs pass the read filter and reach the caller.
|
||||
|
||||
Naming them alike would say the wrong thing about both: it would promise the scaffolding a future it does not have, and it would put the durable data one widened filter away from disappearing. The test at every new IRI: **does this triple die at migration, or does it have to outlive it?** Scaffolding goes under `urn:ng-eventually:`; anything an application or a third party reads must not. This is the triple-level twin of [[knowledge_what-an-app-deletes-at-migration]] — there, the two fates of a published symbol; here, the two fates of a written triple.
|
||||
|
||||
## What is settled, and what is a bet
|
||||
|
||||
**Settled, read at the source.** Nothing upstream reserves, filters or validates a namespace; the engine stores and returns arbitrary IRIs; there is no extension convention to comply with and no procedure for claiming a segment. **Nothing upstream binds our choice, and nothing upstream breaks if we keep a `urn:` scheme.**
|
||||
|
||||
**A bet.** That upstream will not later claim `urn:` or begin separating by prefix. Nothing says it will; nothing says it will not — and *the absence of an implementation says nothing about what the target will do* ([[rule_no-divergence-from-nextgraph]]). The exposure is small and it falls on the scaffolding side, which is the half designed to disappear.
|
||||
|
||||
**A bet, and the more expensive one.** Writing `urn:ng-helpers:` from here. Two packages now share one data format with no owner and no agreement about who may change it.
|
||||
|
||||
**Not a bet — a divergence, if it were ever done.** Putting our own terms under `did:ng:x:…`. That segment is the target's vocabulary space, `did:ng:x:ng#` is the engine's own, and `x` means nothing to the address grammar — so borrowing it would dress an invention in the target's vocabulary, which [[rule_no-divergence-from-nextgraph]] names as the most dangerous kind of all.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
type: knowledge
|
||||
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-21
|
||||
---
|
||||
|
||||
# Who processes an inbox
|
||||
|
||||
[[knowledge_what-an-inbox-is-upstream]] settles what the target *offers*: a document may be given an inbox, nothing above the engine exposes one, and the processor set admits nothing from outside. It says nothing about who does the work **here**. That is this leaf.
|
||||
|
||||
The question is hard to answer out loud — *"does the polyfill process the inbox automatically?"* took one agent a long exchange to fail at — because **processing means a different thing at each layer**. Naming the actor first makes it easy. Everything below is read off the code; symbols are cited so `git grep` settles any doubt.
|
||||
|
||||
## The application
|
||||
|
||||
It **deposits** by naming a document (`inbox.postToDocument`) or a person (`inbox.share`), **reads its own** with `inbox.readForDocument` / `readSyncedForDocument`, and may **watch** one with `inbox.watch`.
|
||||
|
||||
What it never does: **process an inbox so that a `ReadCap` sent to it arrives**. `inbox.processInbox` is published, but no application has to call it — connecting drains, and every arrival afterwards is applied. It never resolves someone else's inbox address (the only published call that returns one is `storeRegistry.openDocumentInbox`, on a document it owns), and it has no way to reach, hurry or disable the deferred stand-in below.
|
||||
|
||||
## `@ng-eventually/polyfill`
|
||||
|
||||
**At connection** — `emulated-verifier/connect.ts`, `connectedUser`: restore every durable register, then `processInbox` on each inbox this identity holds, then start the continuous observation. Failing to apply one queue is reported and denies nobody their session; failing to *reach* the queues rejects.
|
||||
|
||||
**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.
|
||||
|
||||
### Applying is a DISPATCH — one arm per declared type
|
||||
|
||||
`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`).
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
## NextGraph itself
|
||||
|
||||
The **broker** routes a sealed message by inbox key (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs`) and queues it while the recipient is away. The **recipient's own verifier** applies it inline as it arrives — `LocalBrokerMessage::Inbox` → `session.verifier.inbox(&msg, from_queue)` in `sdk/rust/src/local_broker.rs`; `from_queue` distinguishes the backlog handed over at connection from the messages that follow, and **both go through the same door**, which is why the continuous regime above is the rule and the connection drain the special case.
|
||||
|
||||
And **none of it is reachable from JS**: the processor set is closed, the variants that would carry a payload carry nothing, and the post command has no handler arm — all in [[knowledge_what-an-inbox-is-upstream]]. Every actor above except the broker and that verifier is therefore ours, emulating one thing: a session applying its own inbox, continuously.
|
||||
@@ -120,7 +120,9 @@ What was shared with you becomes readable after `ensureIdentity()`.
|
||||
|
||||
`readUnion` returns one entry per distinct subject present in a document. `subject` is that subject's IRI exactly as written, and is a `string`, because a subject may be any IRI; `graph` is the document reference you passed in, and is the `Nuri` to hand back to this surface. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Several objects in one document are allowed. Recommended placement is one document per business entity: access is granted per document.
|
||||
|
||||
`urn:ng-eventually:` is reserved. Triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned.
|
||||
`urn:ng-eventually:` is reserved, and the reservation has two faces. In a document: triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned. In a deposit: two payload shapes are this package's own and it acts on them — a payload that IS a reference (a bare `did:ng:…` string and nothing else), and an object whose `kind` property is a string starting with `urn:ng-eventually:inbox:`. **Every other payload is yours, and is delivered to you untouched.**
|
||||
|
||||
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal does not clear on its own the way an unreachable inbox does: a deposit is not consumed, so the same refusal is reported again at every arrival on that inbox and at every connection, for as long as the deposit sits there. Carry your own data in an object with a `kind` of your own — `{ kind: "join", … }` is yours and stays yours — and never as a bare reference on its own.
|
||||
|
||||
Only a document's owner writes to it. Holding its read key never grants a write.
|
||||
|
||||
@@ -202,8 +204,8 @@ What each level means here, in this package's own terms:
|
||||
|
||||
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.2` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.3` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
|
||||
|
||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.2`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.2`, the string you pinned, never the version it is heading for. `1.0.0-dev.2` added the continuous inbox observation and the coexisting document subscriptions above and moved no signature — a minor, landing inside the pre-release line because `1.0.0` has not been cut.
|
||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.3`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.3`, the string you pinned, never the version it is heading for. `1.0.0-dev.3` narrows an existing call rather than moving a signature: two payload shapes an application could carry freely are now this package's own, and one it cannot apply is reported — a state you did not have to handle before, which is a **major** by the rules above. It lands inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
|
||||
|
||||
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ng-eventually/polyfill",
|
||||
"version": "1.0.0-dev.2",
|
||||
"version": "1.0.0-dev.3",
|
||||
"type": "module",
|
||||
"description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,11 @@
|
||||
*
|
||||
* ── Watching is not applying ──────────────────────────────────────────────
|
||||
* `inbox.watch` looks like this and is not: it NOTIFIES an application that made a call and
|
||||
* named one inbox, and it applies nothing. This applies, on the whole set, without anybody
|
||||
* asking — because processing an inbox is the library's job and not the app's, which is the
|
||||
* same ruling `connect.ts` opens with.
|
||||
* named one inbox. Its read does absorb the caps it passes over into memory for the session
|
||||
* — that much is unavoidable, reading is what surfaces them — but it FILES nothing durably,
|
||||
* which only `processInbox` does. This applies, on the whole set, without anybody asking —
|
||||
* because processing an inbox is the library's job and not the app's, which is the same
|
||||
* ruling `connect.ts` opens with.
|
||||
*
|
||||
* ── Subscription, never a poll ────────────────────────────────────────────
|
||||
* Every half is push-driven. The inboxes themselves are subscribed to individually. WHICH
|
||||
|
||||
@@ -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
|
||||
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
|
||||
* decision). The mechanism itself knows no application domain: the consumer
|
||||
* supplies the inbox document NURI and interprets the `payload`. (An example
|
||||
* consumer mapping, purely illustrative: a consumer might use one inbox for a
|
||||
* registration deposit and another for submitting a reference to an index.)
|
||||
* decision). The consumer supplies the inbox document NURI and interprets its own
|
||||
* `payload`: this module knows no application domain, and the only payloads it reads
|
||||
* are the ones that DECLARE themselves to this package — see
|
||||
* `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 ─────────────────────────────────────────
|
||||
* 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 { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||||
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 {
|
||||
scheduleInboxProcessing,
|
||||
traceProcessed,
|
||||
} from "../emulated-verifier/inbox-processor";
|
||||
import {
|
||||
applyDeposit,
|
||||
capOfPayload,
|
||||
kindOf,
|
||||
linkPayload,
|
||||
} from "../emulated-verifier/inbox-dispatch";
|
||||
import {
|
||||
accountKey,
|
||||
inboxOwner,
|
||||
@@ -45,7 +52,7 @@ import {
|
||||
lookupAccount,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import { hasReadCap, toNuri } from "../model/nuri";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import {
|
||||
accessLogPrefix,
|
||||
enabled as accessLogEnabled,
|
||||
@@ -53,7 +60,7 @@ import {
|
||||
logStage,
|
||||
shortNuri,
|
||||
} from "../shared-wallet/access-log";
|
||||
import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
|
||||
import type { Nuri, NuriLike, PrincipalId } from "../model/types";
|
||||
|
||||
// --- deposit model --------------------------------------------------------
|
||||
|
||||
@@ -290,21 +297,16 @@ export async function postToDocument(docLike: NuriLike, opts: PostOptions): Prom
|
||||
// --- cap delivery ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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: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.
|
||||
* The payloads the last read of an inbox saw DECLARE themselves to this package —
|
||||
* every variant of the dispatch, not only Links — awaiting the applying pass.
|
||||
*
|
||||
* 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.
|
||||
* Kept in memory for this session and nothing more: APPLYING them is
|
||||
* {@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";
|
||||
|
||||
/** Links observed during the last read of an inbox, awaiting durable filing. */
|
||||
const seenByInbox = new Map<Nuri, ReadCap[]>();
|
||||
function capsSeenIn(inbox: Nuri): ReadCap[] {
|
||||
const seenByInbox = new Map<Nuri, unknown[]>();
|
||||
function declaredIn(inbox: Nuri): unknown[] {
|
||||
return seenByInbox.get(inbox) ?? [];
|
||||
}
|
||||
|
||||
@@ -332,12 +334,8 @@ function depositsFrom(result: unknown): Deposit[] {
|
||||
return deposits;
|
||||
}
|
||||
|
||||
/** The cap a deposit carries, if it is a Link rather than consumer data. */
|
||||
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;
|
||||
}
|
||||
// What a deposit DECLARES itself to be, and what each declaration means, live in
|
||||
// `emulated-verifier/inbox-dispatch.ts` — the `match` this surface hands its deposits to.
|
||||
|
||||
/**
|
||||
* 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.`,
|
||||
);
|
||||
}
|
||||
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`.
|
||||
const result = await sparqlQuery(sid, DEPOSITS_QUERY, undefined, targetInbox, "inboxRead");
|
||||
const deposits = depositsFrom(result);
|
||||
// Links are infrastructure, not consumer data: they never reach the caller. They
|
||||
// are only KEPT here (in memory, for this session) — FILING them durably is
|
||||
// `processInbox`'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 that cap re-read instead of staying stale.
|
||||
// A KEY is the one thing that never reaches the caller: it is infrastructure, and the
|
||||
// model's whole point is that an application never handles one. Every other deposit is
|
||||
// handed back exactly as before, INCLUDING the ones that declare themselves to this
|
||||
// package — a reference is public information, so withholding it would buy nothing and
|
||||
// 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 links: ReadCap[] = [];
|
||||
const declared: unknown[] = [];
|
||||
// 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
|
||||
// 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.
|
||||
const stillOwner = getCurrentUser() === owner;
|
||||
for (const d of deposits) {
|
||||
if (kindOf(d.payload) !== null) declared.push(d.payload);
|
||||
const cap = capOfPayload(d.payload);
|
||||
if (cap) {
|
||||
if (stillOwner) getCaps().learnFor(ownerKey, cap);
|
||||
links.push(cap);
|
||||
continue;
|
||||
}
|
||||
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
|
||||
// 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
|
||||
@@ -659,16 +663,19 @@ export async function readSyncedForDocument(docLike: NuriLike): Promise<Deposit[
|
||||
/**
|
||||
* PROCESS an inbox: read it, and **apply** what it contains.
|
||||
*
|
||||
* Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`,
|
||||
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
|
||||
* the cap 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.
|
||||
* Applying is a DISPATCH on what each deposit declares itself to be, one arm per type,
|
||||
* exactly as `Verifier::process_inbox` is a `match` over `InboxMsgContent` — the arms,
|
||||
* and what a kind with no arm gets, are `emulated-verifier/inbox-dispatch.ts`. Applying a
|
||||
* {@link share} Link, for instance, means filing it durably (`branch-registers.addLink`,
|
||||
* the emulated `AddLink { read_cap }` on the User branch of the private store) so the cap
|
||||
* 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
|
||||
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
|
||||
* {@link read} does — Links are never surfaced.
|
||||
* Idempotent, arm by arm — `addLink` ignores a cap it already holds, an object already in
|
||||
* an index is passed over — so processing twice (a second tab, a reconnect) costs nothing.
|
||||
* Returns exactly what {@link read} returned: only a cap is ever withheld.
|
||||
*
|
||||
* ── The holder is captured, not re-resolved after the read ────────────────
|
||||
* 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.
|
||||
const holder = getCurrentUser();
|
||||
const deposits = await readSynced(targetInbox);
|
||||
// `readSynced` already put every Link in memory for this session; now make
|
||||
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
|
||||
// are taken from what the read just observed.
|
||||
const seen = capsSeenIn(targetInbox);
|
||||
// `readSynced` already decoded every declared payload for this session; now APPLY them.
|
||||
// Reading the raw deposits again would mean re-parsing, so the pass works from what the
|
||||
// read just observed.
|
||||
const declared = declaredIn(targetInbox);
|
||||
seenByInbox.delete(targetInbox);
|
||||
for (const cap of seen) {
|
||||
// Re-checked per cap, not once: `addLink` reads and writes, so the identity can move
|
||||
// `readSynced`'s guard refuses a session with no identity, so the holder captured above
|
||||
// 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.
|
||||
if (getCurrentUser() !== holder) {
|
||||
logStage(
|
||||
@@ -708,7 +720,13 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -733,8 +751,10 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
||||
* (`branch-registers.fileOwnStructure` / `fileOwnInbox`). It grants nothing new; it names
|
||||
* the ring those facts belong to.
|
||||
*
|
||||
* Consumer deposits are left where they are, exactly as {@link read} leaves them: an inbox
|
||||
* is a queue its owner consumes, and a Link is the only thing this may consume for it.
|
||||
* Consumer deposits are left where they are, exactly as {@link read} leaves them — and so
|
||||
* 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> {
|
||||
// WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see
|
||||
@@ -782,13 +802,17 @@ async function processForOwner(targetInbox: Nuri): Promise<void> {
|
||||
);
|
||||
let applied = 0;
|
||||
for (const deposit of deposits) {
|
||||
const cap = capOfPayload(deposit.payload);
|
||||
if (cap === null) continue; // consumer data — not this service's to consume
|
||||
// In memory first, then durably: the same order and the same two acts as
|
||||
// `read` + `processInbox`, with the holder named instead of resolved.
|
||||
caps.learnFor(ownerRing, cap);
|
||||
await addLink(cap, ownerId);
|
||||
applied += 1;
|
||||
// The SAME `match` the owner's own session runs, with the holder named instead of
|
||||
// resolved — and told whether the owner is the one holding the page, because an arm
|
||||
// that would write application data into the owner's documents must not run from a
|
||||
// stranger's session (`emulated-verifier/inbox-dispatch.ts`).
|
||||
const done = await applyDeposit(deposit.payload, {
|
||||
owner: ownerId,
|
||||
ring: ownerRing,
|
||||
inbox: targetInbox,
|
||||
ownerHoldsTheSession: getCurrentUser() === ownerId,
|
||||
});
|
||||
if (done) applied += 1;
|
||||
}
|
||||
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