feat!: retirer readUnion de la surface publiee, et lire un document par requete ancree

readUnion etait N requetes ancrees plus une politique de lot : une lecture
qui echoue avalee en reponse vide, un document dont on ne detient pas la cle
ecarte au lieu d'etre refuse, un regroupement en sacs de proprietes, et le
filtrage du prefixe reserve. Rien en amont ne repond a cette composition, et
son annotation citait sparql_query -- une citation exacte attachee a une
affirmation fausse. Une composition ne s'aligne pas sur une fonction.

Ce qui reste est ce qu'il y avait dessous : docs.sparqlQuery ancre sur un
document, qui va deja chercher la cle servie par le magasin public -- donc
une reference nue suffit toujours pour lire un document public, sans rien
changer a useShapeQuery. Lire plusieurs documents, c'est cet appel une fois
par document, et la tolerance a une lecture qui echoue revient a l'appelant,
la ou l'amont la laisse : une requete ancree sur un depot que le verifier ne
detient pas rejette.

Cote src, l'unique lecture interne restante devient read-document.ts : un
document, une requete ancree, precedee de l'ouverture du depot -- barriere
que index-deposit consulte pour distinguer une absence d'un depot non
synchronise. Le harnais e2e et l'exemple composent la leur, avec leur catch
visible au point d'appel.

Version majeure : 1.0.0-dev.8.
This commit is contained in:
Sylvain Duchesne
2026-09-21 14:08:05 +02:00
parent 05a0f7b148
commit 0c21adebf2
18 changed files with 659 additions and 516 deletions
+1
View File
@@ -7,3 +7,4 @@
- TOUCHED packages/polyfill/src/surface/use-shape-query.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/index.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/surface/orm.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/surface/read-document.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
@@ -66,8 +66,10 @@ export const storeRegistry: { // no identity parameter — a session is
};
// ── reading ──────────────────────────────────────────────────────────────
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
// Reading a document BY NAME is `docs.sparqlQuery` below - one anchored query, one
// document. `readUnion` and `UnionSubject` were published here until `1.0.0-dev.8`;
// nothing takes their place, because they were that call in a loop plus policies of
// this package's own. See `## Change policy`.
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
// TWO positional arguments — the same pair `useShape` takes, both of them the SDK's own
// types. `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in, and
@@ -141,15 +143,15 @@ export function initNg(...args: any[]): any;
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
A returned reference carries no key — not `createEntityDoc`, not the three store accessors, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
A returned reference carries no key — not `createEntityDoc`, not the three store accessors. A reference found inside a document yields a name, not a key.
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
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.
**An anchored `docs.sparqlQuery` reads exactly the document you name, and returns its triples as written.** The anchor makes that document's graph the query's default graph, so a body with no `GRAPH <...>` wrapper reads that document and nothing else; a body that DOES carry one iterates the named graphs regardless of the anchor, so do not write one. Subjects come back as the document carries them — several subjects in one document is allowed and each stays its own, since a subject may be any IRI. Reading several documents is this call once per document; there is no call that takes a list, and composing one with an anchorless query instead would silently widen what the read reaches. Recommended placement is one document per business entity: access is granted per document.
`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.**
`urn:ng-eventually:` is reserved, and the reservation has two faces. In a document: this package writes its own registers under subjects carrying that prefix, in the same graph as your data — so **an anchored `docs.sparqlQuery` returns them alongside your triples, and it is yours to ignore them**. Until `1.0.0-dev.8` the removed `readUnion` dropped them for you; nothing does now. This is not a gain of rights: the two registers an ordinary document carries are a document's inbox address and, for a public document, the read key its store already serves to whoever asks. Nothing else of this package's is written into your documents. Do not write under that prefix yourself. 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 is reported ONCE and then the deposit is gone: this package consumes what it claims, whether or not the arm succeeded. Until `1.0.0-dev.4` the same refusal came back at every arrival on that inbox and at every connection, for as long as the deposit sat there. The same now applies when the referenced document resolves but carries **nothing for the field its index declares** — a draft, say: the reference is discarded and reported, where before it sat in the queue and would have been indexed by a later drain once the document gained that field. Deposit the reference again after the document says what it needs to say. 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.
@@ -241,9 +243,9 @@ Where a call must first find out whether something already exists — a document
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
**`getObjects` does not fetch a public store's key for you either**, for the same reason as the two shape reads below: it goes through the same ORM query and spends no round-trip asking. Read the document once by naming it (`readUnion`, `docs.sparqlQuery`) and it appears.
**`getObjects` does not fetch a public store's key for you either**, for the same reason as the two shape reads below: it goes through the same ORM query and spends no round-trip asking. Read the document once by naming it — `docs.sparqlQuery` anchored on it — and it appears.
**Neither shape read fetches a public store's key for you.** A document in a public store gives up its key to whoever asks, but asking is a round-trip and neither `useShape` nor `useShapeQuery` spends one — since `1.0.0-dev.6` the second reads through the SDK and no longer opens a document of its own. So a public document you have read nowhere else does not appear in either. Read it once by naming it (`readUnion`, `docs.sparqlQuery`) and both pick it up: the key lands in what you hold, and that is a change both of them re-read on.
**Neither shape read fetches a public store's key for you.** A document in a public store gives up its key to whoever asks, but asking is a round-trip and neither `useShape` nor `useShapeQuery` spends one — since `1.0.0-dev.6` the second reads through the SDK and no longer opens a document of its own. So a public document you have read nowhere else does not appear in either. Read it once by naming it — `docs.sparqlQuery` anchored on it, which is the whole of what `readUnion` did for this before it was removed in `1.0.0-dev.8` — and both pick it up: the key lands in what you hold, and that is a change both of them re-read on.
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
@@ -253,15 +255,22 @@ Where a call must first find out whether something already exists — a document
What each level means here, in this package's own terms:
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession`, `inbox.materialize`, `storeRegistry.listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` all left this way), **or** a published symbol is renamed (`watchShape` became `useShapeQuery`), **or** an existing call narrows: it now throws where it returned (`ensureIdentity` rejects rather than resolve on an incomplete restore), it reports a state you did not have to handle before (`useShapeQuery` publishing `isError` where it used to publish a synced-empty snapshot), or what it HANDS BACK changes shape (`useShapeQuery`'s `data` carrying the shape's own objects where it carried property bags). A signature change a caller must react to counts; one that only accepts more than before does not.
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession`, `inbox.materialize`, `storeRegistry.listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `readUnion` and `UnionSubject` all left this way), **or** a published symbol is renamed (`watchShape` became `useShapeQuery`), **or** an existing call narrows: it now throws where it returned (`ensureIdentity` rejects rather than resolve on an incomplete restore), it reports a state you did not have to handle before (`useShapeQuery` publishing `isError` where it used to publish a synced-empty snapshot), or what it HANDS BACK changes shape (`useShapeQuery`'s `data` carrying the shape's own objects where it carried property bags). A signature change a caller must react to counts; one that only accepts more than before does not.
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
**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.7`), 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.
**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.8`), 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.7` 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.8` 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.7`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.7`, the string you pinned, never the version it is heading for.
`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.8`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.8`, the string you pinned, never the version it is heading for.
`1.0.0-dev.8` is a **major**, and it is one removal: **`readUnion` and `UnionSubject` are gone from this surface, and nothing replaces them.**
- **What they were.** `readUnion(docs)` issued one anchored `docs.sparqlQuery` per document, in parallel, and added four policies of its own on top: it swallowed a document it could not read into an empty answer, it dropped a document whose key you do not hold instead of refusing it, it grouped the rows into per-(document, subject) property bags, and it removed the reserved-prefix registers from the result. Nothing at any level of NextGraph answers that composition. Its own provenance annotation cited `sparql_query`, which is a citation a reader checks, finds correct, and moves on from - while the claim attached to it was false. **A composition cannot be aligned on a single function**, and that is the rule this removal serves.
- **What to call instead.** `docs.sparqlQuery(sessionId, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, doc)` - the primitive `readUnion` was built on, published beside it all along. It is the same anchored query and the same public-store key fetch, so **a bare `reference` is still enough to read a document in a public store**: nothing about that property moves. Reading several documents is this call once per document.
- **What comes back to you, and it is not a detail.** Three of the four policies were the batch's, and they are now yours: a read that FAILS rejects instead of reading as an empty document, so whether one unreadable document ends your loop is your decision at your call site; a document you hold no key for is REFUSED out loud instead of being dropped from a result; and the rows are rows - grouping them into objects is yours, or use `getObjects` / `useShapeQuery`, which return the SDK's own typed objects. The fourth is in `## Guarantees`: subjects under `urn:ng-eventually:` are no longer filtered out for you.
- **Why the losses are alignment rather than regression.** Upstream, an anchored query on a repo the verifier does not hold REJECTS (`resolve_target_for_sparql` -> `NgError::RepoNotFound`), which reaches JavaScript as a rejected promise. A "skip this one and carry on" policy is something the target does not have and will not grow, so a caller that learned it would have had to unlearn it. The one thing that genuinely disappears is the convenience, and it is three lines at your call site.
`1.0.0-dev.7` is a **major**, and what makes it one is not the two calls it adds but the rule it starts enforcing. Symbol by symbol:
@@ -273,7 +282,7 @@ What each level means here, in this package's own terms:
`1.0.0-dev.6` is a **major**, and all of it is one change: `useShapeQuery` reads through NextGraph's OWN shape query instead of a read this package built. Nothing was renamed and nothing was removed from the entry, but three things move under a caller:
- **`data` items are the shape's objects, not property bags.** `ShapeQuery<T>`'s `T` is now the shape's type: each item carries `@id`, `@graph` and one field per predicate the shape DECLARES, under that predicate's readable name. `note.props["urn:notebook:title"]?.[0]` becomes `note.title`, and `note.graph` becomes `note["@graph"]`. **A predicate your shape does not name is not projected**, so a shape written only to match — an `rdf:type` constraint and nothing else — now comes back with no fields at all: declare the ones you read. `readUnion` is unchanged and still returns `UnionSubject`.
- **`data` items are the shape's objects, not property bags.** `ShapeQuery<T>`'s `T` is now the shape's type: each item carries `@id`, `@graph` and one field per predicate the shape DECLARES, under that predicate's readable name. `note.props["urn:notebook:title"]?.[0]` becomes `note.title`, and `note.graph` becomes `note["@graph"]`. **A predicate your shape does not name is not projected**, so a shape written only to match — an `rdf:type` constraint and nothing else — now comes back with no fields at all: declare the ones you read. (`readUnion` was unchanged by this version, and was removed in `1.0.0-dev.8`.)
- **`shapeType` is typed.** It was `unknown`; it is `ShapeType<T>` (the SDK's own, re-exported here), so the snapshot is typed without a cast — and a hand-written shape object must now satisfy that type, which means `readablePredicate`, `minCardinality` and `maxCardinality` on every predicate.
- **`configure` takes one more thing.** `ormSubscription: RdfOrmSubscription.getOrCreate`, plus `initNg` and a call to this package's `initNg(ng, session)` once the session has arrived. Optional in the type, required in practice for this read: without it `useShapeQuery` publishes `isError` naming the field.
+26 -20
View File
@@ -1,6 +1,6 @@
# API contract — what `@ng-eventually/polyfill` exposes today, and what the future SDK should expose per subject
> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion`; `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises.
> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion` (itself removed in `1.0.0-dev.8` — see § 6); `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises.
**Scope: the APP-FACING contract only.** Everything reachable from the published entry point, and nothing else. The library's internal modules — the shim machinery, the read paths, the boundary guards — are held to the same standard (as close as possible to what NextGraph does or plans) but have their own document, `docs/internal-contract.md`: a consumer never reads that one, a maintainer does. This split was made on 2026-08-03, together with the export change described in § 15.
@@ -257,9 +257,9 @@ export function useShapeQuery<T extends BaseType = BaseType>(
): ShapeObservable<T>;
```
**What it reads THROUGH, since `1.0.0-dev.6`: the SDK's own shape query.** `RdfOrmSubscription.getOrCreate(shapeType, scope)`, injected at `configure` as `ormSubscription` (§ 1). Four steps went with the change and they were one mechanism: resolving the logical scope to a document set (`listMyEntityDocs`), opening those documents through `ensureReposOpen` for a sync barrier, `readUnion` over them, and filtering the union by the shape's `@type` IRI. All four existed because the parameter had to be TRANSLATED; once it is upstream's own `Scope`, the question goes to the thing that answers it, and `graphs` / `subjects` / `did:ng:i` are read by the engine literally. It does NOT delegate to the SDK's `useShape` — that is React's hook and its `DeepSignalSet` cannot carry the request state, which is the whole reason this symbol exists. What is taken from the SDK is the QUERY; the observable, the snapshot and the state tracking stay here, and so does the capability filter (`emulated-verifier/read-filter.ts`, applied per `@graph` on what comes back, exactly where § 4 applies it).
**What it reads THROUGH, since `1.0.0-dev.6`: the SDK's own shape query.** `RdfOrmSubscription.getOrCreate(shapeType, scope)`, injected at `configure` as `ormSubscription` (§ 1). Four steps went with the change and they were one mechanism: resolving the logical scope to a document set (`listMyEntityDocs`), opening those documents through `ensureReposOpen` for a sync barrier, an anchored read over each, and filtering the result by the shape's `@type` IRI. All four existed because the parameter had to be TRANSLATED; once it is upstream's own `Scope`, the question goes to the thing that answers it, and `graphs` / `subjects` / `did:ng:i` are read by the engine literally. It does NOT delegate to the SDK's `useShape` — that is React's hook and its `DeepSignalSet` cannot carry the request state, which is the whole reason this symbol exists. What is taken from the SDK is the QUERY; the observable, the snapshot and the state tracking stay here, and so does the capability filter (`emulated-verifier/read-filter.ts`, applied per `@graph` on what comes back, exactly where § 4 applies it).
**Consequences a caller sees.** `data` items are now the SHAPE's own objects — `@id`, `@graph` and the shape's readable predicates — where they were `UnionSubject` property bags; `shapeType` is typed `ShapeType<T>` instead of `unknown`, so `T` follows the shape and the snapshot is typed without a cast; and the read no longer opens a document of its own, so a public-store document whose cap has not been fetched by some other read does not appear until it has been (the same limit § 4 carries, and the same `CapRegistry.onChange` recovery).
**Consequences a caller sees.** `data` items are now the SHAPE's own objects — `@id`, `@graph` and the shape's readable predicates — where they were the property bags the then-published `readUnion` returned; `shapeType` is typed `ShapeType<T>` instead of `unknown`, so `T` follows the shape and the snapshot is typed without a cast; and the read no longer opens a document of its own, so a public-store document whose cap has not been fetched by some other read does not appear until it has been (the same limit § 4 carries, and the same `CapRegistry.onChange` recovery).
**The name and the scope, since `1.0.0-dev.5`.** It was `watchShape(shapeType, "public" | "protected" | "private")`. `watchShape` has ZERO occurrences in `nextgraph-rs` and spends `watch`, a verb the ORM already spends on signal observation; the target's own name is `useShapeQuery`, announced by NextGraph's developers and on record in `.project/rules/nextgraph/surface-mirrors-the-sdk.md`, which is what the symbol's `announced-not-written` annotation cites. The three literals were this package's own and named the scope of a READ, which the SDK answers on two axes instead: `graphs` (one document's graph per entry, `did:ng:i` or `""` for the whole dataset, `[]` or `undefined` for no objects at all) and `subjects`. Nothing is translated any more: the `Scope` object a caller writes is the object the SDK receives.
@@ -284,31 +284,37 @@ So the constraint on the bet: the target can already answer "synced?" (`readyPro
---
## 6. One-shot listing — the read-model
## 6. One-shot reading by name — the anchored query, and nothing above it
### Today — `@ng-eventually/polyfill`
```ts
// read-model.ts:60
export interface UnionSubject {
subject: string; // the subject IRI as the document carries it — any IRI, not a Nuri
graph: Nuri; // the document reference the caller passed, unchanged
props: Record<string, string[]>;
}
// read-model.ts:166
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]>;
// docs.ts — the SDK's own primitive, and the whole of what this package offers here
export async function sparqlQuery(
sessionId: string | number,
query: string,
base?: string,
anchorLike?: NuriLike,
): Promise<unknown>;
```
Behaviour: one anchored `sparql_query` per doc (default-graph body, no `GRAPH` wrapper), parallel, per-doc failure tolerance, cap filter applied inside, machinery subjects dropped.
**`readUnion` and `UnionSubject` were published here until `1.0.0-dev.8`, and are gone.** `readUnion(docs)` issued one anchored `sparql_query` per document, in parallel, tolerated a per-document failure by swallowing it, applied the capability filter inside and dropped machinery subjects. Every one of those beyond the first is a POLICY, and none of them has a counterpart at any level: it was N calls plus a batch policy, and its annotation cited `sparql_query` — a citation that is exact attached to a claim that is false. **A composition cannot be aligned on a single function.**
**Grouping is per (document, subject), and subjects come back as written.** A document holding several subjects yields several entries — one each, properties never merged across subjects; the same subject IRI seen in two documents stays two entries, told apart by `graph`. This matches level 3, where an object carries `@id` and `@graph` as two distinct read-only properties and the ORM fabricates an `@id` when the writer leaves it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`, `":q:"`) — several objects per graph is the provided case, and `@id` is what distinguishes them inside a `@graph`. Only `graph` is a `Nuri`; `subject` is typed `string` because an RDF subject may be any IRI. One document per business entity remains the recommended placement (a key is per repo, so isolating an entity needs a repo of its own), but that is a recommendation about writing — the read reports what is there rather than making the other arrangement invisible.
What replaces it is what was underneath: `docs.sparqlQuery(sessionId, query, undefined, doc)`, once per document you want. Reading several is that call in a loop, and the two policies come back to the caller, where upstream leaves them:
- **a failure is a failure.** The anchored query REJECTS for a document the verifier does not hold (`resolve_target_for_sparql` → `NgError::RepoNotFound`, `engine/verifier/src/request_processor.rs`), and this package refuses it earlier still for a document you hold no capability for. Whether one unreadable document ends your batch is now your decision, written at your call site rather than assumed by a primitive.
- **shaping is yours.** The grouping into per-(document, subject) property bags was `UnionSubject`, a polyfill type with nothing upstream behind it. A SELECT's rows come back as rows; a caller that wants objects should use `getObjects` (§ 6bis) or `useShapeQuery` (§ 5), which are the SDK's own typed reads.
- **the reserved prefix is yours to ignore.** `readUnion` dropped subjects under `urn:ng-eventually:` for you. A raw anchored read does not — see `## Guarantees` in the published contract, which now says so.
**Several subjects in one document is the PROVIDED case, whichever read is used.** Level 3 carries `@id` and `@graph` as two distinct read-only properties and fabricates an `@id` when the writer leaves it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`, `":q:"`), so a read must report subjects as written and never fold a document into one bag. One document per business entity remains the recommended placement (a key is per repo, so isolating an entity needs a repo of its own), but that is a recommendation about writing.
### Target
Two verified counterparts, one per level; neither returns `UnionSubject` — that grouping is lib-invented:
Two verified counterparts, one per level:
```ts
// level 2, VERIFIED — the primitive readUnion composes: index.d.ts:295, source sdk/js/lib-wasm/src/lib.rs:352 (nodejs) / :555 (web)
// level 2, VERIFIED — the primitive, published here as `docs.sparqlQuery`: index.d.ts:295,
// source sdk/js/lib-wasm/src/lib.rs:352 (nodejs) / :555 (web)
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
// level 3, VERIFIED — the one-shot typed read: sdk/js/orm/src/connector/getObjects.ts:23
@@ -318,7 +324,7 @@ export async function getObjects<T extends BaseType>(
); // returns a deep-cloned Set of matching objects
```
The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query to that repo's graph as default graph (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:256-285`), an anchorless query unions every named graph in the session store (same function, `UserSite → None` → `set_default_graph_as_union`). At migration `readUnion` survives as composition (the anchored per-doc read is native); a consumer that wants typed results should be on `useShape`/`getObjects`, not on `UnionSubject` — the property-bag shape is a polyfill artifact, kept generic precisely so the consumer maps it into its own types and can drop it later.
The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query to that repo's graph as default graph (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:256-285`), an anchorless query unions every named graph in the session store (same function, `UserSite → None` → `set_default_graph_as_union`). Reading several documents must therefore stay N ANCHORED queries: replacing them with one anchorless query is a silent widening of what a read can reach, and on a shared wallet it is O(wallet size).
---
@@ -794,12 +800,12 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
### Places the current surface teaches something to unlearn
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `useShapeQuery`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `useShapeQuery`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
- ~~**`share` is importable from both entries.**~~ **FIXED 2026-08-07** with the entry merge: there is one entry and one `share`, under `inbox`.
- **One entry means the import line no longer says what disappears.** Until 2026-08-07 a second import path (`/polyfill`) WAS the deletion list. It is now the `POLYFILL-ERA` block in `src/index.ts`, this appendix's note above, and the per-subject rulings in this document. That is a documentation-carried signal where it used to be a mechanical one — the appendix is pinned by a test, the grouping is not.
- **`inbox.read` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.
- ~~**`watchShape`'s "planned `useShape` upgrade"**~~ **ANSWERED in `1.0.0-dev.5`.** The claim was in the module header with no provenance anywhere. It has one now, and it is the strongest kind available to something the target has not written: NextGraph's developers stated the direction, the statement is written down in `.project/rules/nextgraph/surface-mirrors-the-sdk.md` with who said it and when, and the symbol carries `kind=announced-not-written` citing that record. The name went with it — `useShapeQuery`, not `watchShape` (§ 5).
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `useShapeQuery`'s design already assumes).
- ~~**`UnionSubject` property bags**~~ — REMOVED in `1.0.0-dev.8`, with `readUnion`. Nothing of the read model is published any more: an application reads one document with `docs.sparqlQuery` and shapes the rows itself, or reads by shape and gets the SDK's own objects.
- **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta.
---
@@ -811,7 +817,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
### `@ng-eventually/polyfill` — `src/index.ts` (the only entry since 2026-08-07)
```text
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, GetObjectsLike, InsertObjectLike, NG, NgLike, Nuri, NuriLike, OrmSubscriptionLike, PrincipalId, RdfOrmConfig, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, getObjects, init, initNg, insertObject, ng, readUnion, subscribeDoc, subscribeDocs, useShape, useShapeQuery
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, GetObjectsLike, InsertObjectLike, NG, NgLike, Nuri, NuriLike, OrmSubscriptionLike, PrincipalId, RdfOrmConfig, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, getObjects, init, initNg, insertObject, ng, subscribeDoc, subscribeDocs, useShape, useShapeQuery
docs: docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch
storeRegistry: createEntityDoc, openDocumentInbox, privateStore, protectedStore, publicStore
+23 -6
View File
@@ -34,7 +34,6 @@ import {
ensureIdentity,
inbox,
init,
readUnion,
storeRegistry,
subscribeDoc,
initNg as initNgSignals,
@@ -237,12 +236,30 @@ function currentNotes(): Note[] {
* to change when the real SDK takes that same string.
*/
async function readSharedNote(reference: string): Promise<Note | null> {
const [note] = await readUnion([reference]);
if (!note) return null;
const s = await sessionReady;
// ONE anchored query, naming the note. The anchor scopes the read to that document's
// own graph, so the body carries no `GRAPH <...>` wrapper; the library asks the public
// store for the key on the way in, which is what makes a bare reference enough.
//
// This was `readUnion([reference])` until `1.0.0-dev.8`. What an application composes
// itself now is the projection below - the SELECT and the grouping - because the
// primitive that did it was a batch nothing upstream answers.
const result = await docs.sparqlQuery(
s.session_id,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
reference,
);
const rows =
(result as { results?: { bindings?: Array<Record<string, { value: string }>> } })
.results?.bindings ?? [];
const values = (predicate: string): string[] =>
rows.filter((r) => r["p"]?.value === predicate).map((r) => r["o"]!.value);
if (rows.length === 0) return null;
return {
doc: note.graph,
title: note.props[TITLE]?.[0] ?? "(sans titre)",
body: note.props[BODY]?.[0] ?? "",
doc: reference as Nuri,
title: values(TITLE)[0] ?? "(sans titre)",
body: values(BODY)[0] ?? "",
};
}
+100 -20
View File
@@ -26,7 +26,6 @@ import {
init,
subscribeDoc,
subscribeDocs,
readUnion,
inbox,
storeRegistry,
useShape as libUseShape,
@@ -130,6 +129,52 @@ function asAnchor(s?: string): Nuri | undefined {
return s === undefined ? undefined : asNuri(s);
}
/** The reserved prefix: triples whose SUBJECT falls under it are this package's own
* machinery, not the document's data. `readUnion` dropped them for the caller; an
* application composing its own read drops them itself, and the contract says so. */
const RESERVED_SUBJECT_PREFIX = "urn:ng-eventually:";
/**
* Read ONE document with the published anchored query and group its rows per subject -
* what an application composes since `readUnion` was removed in `1.0.0-dev.8`.
*
* It THROWS, exactly like `docs.sparqlQuery` under it: a document that cannot be read is
* a failure, never an empty answer. Every caller below that wants to carry on past one
* says so with a `catch` of its own, which is where upstream leaves that decision too.
*/
async function readOneDocument(
sessionId: string,
docLike: string,
): Promise<Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }>> {
const doc = asNuri(docLike);
// Anchored -> default graph = this repo, so the body carries no `GRAPH <...>` wrapper.
const res: unknown = await docs.sparqlQuery(
sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
);
const rows = Array.isArray(res)
? (res as Array<Record<string, { value: string } | undefined>>)
: ((res as { results?: { bindings?: Array<Record<string, { value: string } | undefined>> } })
.results?.bindings ?? []);
const bySubject = new Map<string, { subject: string; graph: Nuri; props: Record<string, string[]> }>();
for (const row of rows) {
const subject = row["s"]?.value;
const p = row["p"]?.value;
const o = row["o"]?.value;
if (subject === undefined || !p || o === undefined) continue;
if (subject.startsWith(RESERVED_SUBJECT_PREFIX)) continue;
let entry = bySubject.get(subject);
if (!entry) {
entry = { subject, graph: doc, props: {} };
bySubject.set(subject, entry);
}
(entry.props[p] ??= []).push(o);
}
return [...bySubject.values()];
}
// ── The broker session, resolved once the iframe connects ──────────────────
interface BrokerSession {
session_id: string;
@@ -435,13 +480,16 @@ const identity = new IdentityStore(
};
},
// ── read-model ───────────────────────────────────────────────────────────
// ── the anchored document read ───────────────────────────────────────────
/**
* Create N docs, write a marker triple into each (anchored default graph), then
* readUnion over them → one subject per doc. Optionally inject a bad NURI to
* prove per-doc tolerance (the bad one is skipped, the batch survives).
* Create N docs, write a marker triple into each (anchored default graph), then read
* them ONE BY ONE with the published anchored query. Optionally inject a bad NURI, to
* show where the tolerance lives now: at THIS call site, as a visible `catch`, not
* inside a primitive. `readUnion` used to swallow it and answer with the rest, which
* is a policy upstream does not have - an anchored query on a repo the verifier does
* not know REJECTS (`resolve_target_for_sparql` -> `NgError::RepoNotFound`).
*/
async readUnionOverDocs(n: number, includeBad: boolean) {
async readDocsOneByOne(n: number, includeBad: boolean) {
const s = await sessionReady;
const docNuris: Nuri[] = [];
for (let i = 0; i < n; i++) {
@@ -454,7 +502,7 @@ const identity = new IdentityStore(
docNuris.push(d);
}
// Two SUBJECTS in the LAST document, so the probe can tell "one entry per document"
// from "one entry per subject". Until 2026-08-10 `readUnion` folded every triple of a
// from "one entry per subject". Until 2026-08-10 the read folded every triple of a
// document into one bag keyed by the document, and this step stayed green throughout
// because it only ever counted entries and never looked at what they were.
if (n > 0) {
@@ -465,22 +513,36 @@ const identity = new IdentityStore(
);
}
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const subjects = await readUnion(toRead);
const subjects: Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }> = [];
const skipped: string[] = [];
for (const d of toRead) {
try {
subjects.push(...(await readOneDocument(s.session_id, d)));
} catch {
// THIS harness chooses to carry on. Another caller may choose not to - that is
// the point of the decision living here.
skipped.push(d);
}
}
return {
docNuris,
subjectCount: subjects.length,
// What each entry actually IS — the assertion the count could not make.
subjectIris: subjects.map((x) => x.subject),
graphs: subjects.map((x) => x.graph),
skipped,
subjects,
};
},
/**
* readUnion possession gate: create a doc as owner O (whose keyring gets its
* cap), then read it as a DIFFERENT identity, which holds nothing → dropped.
* The stranger has the document's NURI in hand throughout: naming is not reading.
* The possession gate on the anchored read: create a doc as owner O (whose keyring
* gets its cap), then read it as a DIFFERENT identity, which holds nothing. The
* stranger has the document's NURI in hand throughout: naming is not reading.
*
* The stranger is now REFUSED rather than handed an empty list - `readUnion` dropped
* an unreachable document from its batch, and there is no batch to drop it from.
*/
async readUnionCapGate() {
async anchoredReadCapGate() {
const s = await sessionReady;
resetCaps();
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
@@ -488,12 +550,18 @@ const identity = new IdentityStore(
setCurrentUser("owner-O");
getCaps().open(doc, "protected");
setCurrentUser("someone-else");
const asStranger = await readUnion([doc]);
let strangerRefused = false;
let strangerCount = -1;
try {
strangerCount = (await readOneDocument(s.session_id, doc)).length;
} catch {
strangerRefused = true;
}
setCurrentUser("owner-O");
const asOwner = await readUnion([doc]);
const asOwner = await readOneDocument(s.session_id, doc);
resetCaps();
setCurrentUser(null);
return { strangerCount: asStranger.length, ownerCount: asOwner.length };
return { strangerRefused, strangerCount, ownerCount: asOwner.length };
},
// ── reactivity (doc_subscribe) ───────────────────────────────────────────
@@ -704,9 +772,9 @@ const identity = new IdentityStore(
/**
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
* account's entity docs of `scope` (listMyEntityDocs → readUserStore) and readUnion
* them, purely from the persistent wallet — nothing from phase 1's session state
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
* account's entity docs of `scope` (listMyEntityDocs → readUserStore) and read each
* with its own anchored query, purely from the persistent wallet — nothing from
* phase 1's session state carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
* the broker-login bootstrap already opens the user's repos, so rawRowCount is
* non-zero even without the heal — this is a reconnection REGRESSION guard, not a
@@ -725,7 +793,7 @@ const identity = new IdentityStore(
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
// many rows the bare anchored query resolves for a not-yet-opened repo (the
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
// readUnion's open step.
// `readOneDocument`'s open step.
//
// Placed AFTER `listMyEntityDocs`, which is what restores the caps of the user's
// own documents from the Store branch. Before it, the boundary refuses the read
@@ -738,7 +806,18 @@ const identity = new IdentityStore(
} catch (e: any) {
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
}
const subjects = await readUnion(listed.length ? listed : [asNuri(entityNuri)]);
// One anchored read per listed document, and the tolerance is HERE, visibly: a
// reconnect probe that lost one document to a broker hiccup still reports what the
// others held, because THIS caller decided so. The primitive rejects.
const subjects: Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }> = [];
const unread: string[] = [];
for (const d of listed.length ? listed : [asNuri(entityNuri)]) {
try {
subjects.push(...(await readOneDocument(s.session_id, d)));
} catch {
unread.push(d);
}
}
const markers: string[] = [];
for (const subj of subjects) {
for (const vals of Object.values(subj.props)) {
@@ -751,6 +830,7 @@ const identity = new IdentityStore(
listedCount: listed.length,
foundEntity: listed.includes(asNuri(entityNuri)),
subjectCount: subjects.length,
unread,
markerPresent: markers.includes(marker),
markers,
};
+25 -13
View File
@@ -259,13 +259,13 @@ async function main(): Promise<void> {
);
});
// ── read-model ──────────────────────────────────────────────────────────
console.log("\n── read-model ──");
await step("readUnion returns one entry per SUBJECT, with the subject it was written under", async () => {
// ── the anchored document read ──────────────────────────────────────────
console.log("\n── the anchored document read ──");
await step("an anchored read returns one entry per SUBJECT, with the subject it was written under", async () => {
// 3 documents, and the last carries TWO subjects → 4 entries, not 3. Counting alone
// could not distinguish grouping-by-document from grouping-by-subject, which is why
// this step stayed green while `readUnion` conflated them (fixed 2026-08-10).
const r = await sdk<any>(frame, "readUnionOverDocs", 3, false);
// this step stayed green while the read conflated them (fixed 2026-08-10).
const r = await sdk<any>(frame, "readDocsOneByOne", 3, false);
const iris: string[] = r.subjectIris ?? [];
check(
"one entry per subject, not per document",
@@ -283,14 +283,26 @@ async function main(): Promise<void> {
JSON.stringify(r.graphs),
);
});
await step("readUnion per-doc tolerance (bad NURI skipped)", async () => {
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
// 2 documents, the last carrying two subjects → 3 entries.
check("bad NURI does not abort the batch", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`);
await step("a document that cannot be read REJECTS, and the caller decides to carry on", async () => {
const r = await sdk<any>(frame, "readDocsOneByOne", 2, true);
// 2 documents, the last carrying two subjects → 3 entries. The bad NURI did not
// reduce that, and it is named in `skipped` — the tolerance is the harness's own
// `catch`, not a policy hidden in a primitive (`readUnion` carried one until
// `1.0.0-dev.8`; upstream an unknown repo rejects, so there was nothing to align it on).
check("the readable documents still answer", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`);
check(
"the unreadable one is REPORTED as skipped, not silently absent",
(r.skipped ?? []).length === 1,
`skipped=${JSON.stringify(r.skipped)}`,
);
});
await step("readUnion cap gate", async () => {
const r = await sdk<any>(frame, "readUnionCapGate");
check("cap gate drops doc for stranger, keeps for owner", r.strangerCount === 0 && r.ownerCount === 1, `stranger=${r.strangerCount} owner=${r.ownerCount}`);
await step("anchored read cap gate", async () => {
const r = await sdk<any>(frame, "anchoredReadCapGate");
check(
"cap gate REFUSES the stranger, and the owner reads",
r.strangerRefused === true && r.ownerCount === 1,
`strangerRefused=${r.strangerRefused} strangerCount=${r.strangerCount} owner=${r.ownerCount}`,
);
});
// ── reactivity (doc_subscribe) ──────────────────────────────────────────
@@ -544,7 +556,7 @@ async function main(): Promise<void> {
// session must relist + re-read its OWN persisted markers, POLLING for the sync —
// the wait IS the normal path. NB the observed cost is dominated by broker READ
// latency, not pure sync-lag: ONE reconnectRead cycle (open-repo heal awaits the
// initial-state push per repo + anti-fork retry budget + anchored readUnion, all
// initial-state push per repo + anti-fork retry budget + the anchored reads, all
// round-tripping the real broker) measures ~90-105s. So the poll deadline is a
// generous MULTI-cycle bound (120s past post-connect) rather than a tight 30s — a
// single slow cycle must not be mistaken for a sync failure. We report the observed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ng-eventually/polyfill",
"version": "1.0.0-dev.7",
"version": "1.0.0-dev.8",
"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",
@@ -54,14 +54,14 @@
* acted on.
*/
import { readUnion } from "../surface/read-model";
import { readDocumentSubjects } from "../surface/read-document";
import { sparqlUpdate } from "../surface/docs";
import { readInboxCapPairs } from "./branch-registers";
import { getSyncState } from "./open-repo";
import { mustNotAttempt } from "./reach";
import { session } from "../shared-wallet/account-registry";
import { assertNuri, escapeLiteral } from "../surface/sparql";
import type { UnionSubject } from "../surface/read-model";
import type { DocumentSubject } from "../surface/read-document";
import type { Nuri } from "../model/types";
/**
@@ -109,14 +109,19 @@ export type IndexApplication =
* This is the question consumption forced, and it has three sources of "no rows", only one
* of which is an absence:
*
* - the read THREW — `onUnreadable` fired. Transient. Not an absence.
* - the document was NEVER ATTEMPTED, because this holder holds no cap for it and
* `readUnion` drops it through `mustNotAttempt` before issuing a query. This looked
* deterministic and is not: for a stranger's PUBLIC document — the reference arm's
* central case — possession is acquired by a network fetch that swallows every error
* and MEMOISES the failure (`public-store.ts`). One outage on that fetch therefore
* presents as "no cap", and it stays that way for the session, so classing it as an
* absence destroyed the deposit AND every re-deposit after it. Not an absence.
* - the read THREW. Transient. Not an absence. Its callers return before reaching this
* function at all, which is why `readFailed` below can only be false today; the
* parameter stays because the answer for a failed read is part of the question this
* function exists to answer, and hiding it in the call order would lose it.
* - the document COULD NOT BE REACHED, because this holder holds no cap for it. Since
* `1.0.0-dev.8` that arrives as a THROW from `docs.sparqlQuery` rather than as a
* silently dropped document, so the previous bullet catches it - but the reasoning
* is the same one and worth keeping: it looked deterministic and is not. For a
* stranger's PUBLIC document - the reference arm's central case - possession is
* acquired by a network fetch that swallows every error and MEMOISES the failure
* (`public-store.ts`). One outage on that fetch therefore presents as "no cap", and
* it stays that way for the session, so classing it as an absence destroyed the
* deposit AND every re-deposit after it. Not an absence.
* - the repo is PRESENT BUT UNSYNCED — and this is the one that cost round 6. A fresh
* session over a persistent wallet has not received the repo's commits, so the anchored
* query legitimately matches nothing and **raises no error at all**. Classing that as an
@@ -144,8 +149,8 @@ function noRowsMeansAbsent(doc: Nuri, readFailed: boolean): boolean {
/**
* 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`:
* OWN properties only. `props` arrives as a plain object literal (`readDocumentSubjects`
* builds `props: {}`), 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
@@ -156,7 +161,7 @@ function noRowsMeansAbsent(doc: Nuri, readFailed: boolean): boolean {
* came back has the shape the type promises.
*/
export function valuesOf(
subject: UnionSubject | undefined,
subject: DocumentSubject | undefined,
predicate: string,
): readonly string[] {
const props = subject?.props;
@@ -168,7 +173,7 @@ export function valuesOf(
/** Every field the document declares for itself — zero, one, or (wrongly) several. */
export function declaredFields(
subjects: readonly UnionSubject[],
subjects: readonly DocumentSubject[],
index: Nuri,
): readonly string[] {
return valuesOf(
@@ -187,7 +192,7 @@ export function declaredFields(
* ABSENT, so the index could SHRINK through nothing but additions.
*/
export function indexedObjects(
subjects: readonly UnionSubject[],
subjects: readonly DocumentSubject[],
index: Nuri,
): ReadonlySet<string> {
const out = new Set<string>();
@@ -244,16 +249,22 @@ export async function applyIndexDeposit(
return { outcome: "skipped", object: reference, reason: "self-reference" };
}
// Told whether the read FAILED, rather than left to guess from an empty answer. The two
// demand opposite handling now that deposits are consumed — see the `unreadable` outcome.
// A BOOLEAN, not the error value: `throw null` and `throw undefined` are legal, and a
// sentinel comparison against `null` made exactly those failures invisible.
// A FAILED read and an EMPTY one demand opposite handling now that deposits are
// consumed - see the `unreadable` outcome - so they are never allowed to arrive as the
// same value. `readDocumentSubjects` throws where it used to hand the error to a
// callback and answer `[]`, which is the same distinction made by the language instead
// of by a convention. Tracked as a BOOLEAN, not as the error value: `throw null` and
// `throw undefined` are legal, and a sentinel comparison against `null` made exactly
// those failures invisible.
let indexReadFailed = false;
let indexError: unknown;
const subjects = await readUnion([index], (_doc, error) => {
let subjects: readonly DocumentSubject[] = [];
try {
subjects = await readDocumentSubjects(index, "indexRead");
} catch (error) {
indexReadFailed = true;
indexError = error;
});
}
if (indexReadFailed) {
return {
outcome: "unreadable",
@@ -297,18 +308,18 @@ export async function applyIndexDeposit(
return { outcome: "unchanged", object: reference };
}
let resolved: readonly UnionSubject[];
let resolved: readonly DocumentSubject[] = [];
let referenceReadFailed = false;
let referenceError: unknown;
try {
resolved = await readUnion([reference], (_doc, error) => {
resolved = await readDocumentSubjects(reference, "indexReference");
} catch (error) {
referenceReadFailed = true;
referenceError = error;
});
} catch (error) {
return { outcome: "unreadable", why: `${reference}: ${String(error)}` };
}
// The BROKER failed. Stop the drain: everything still queued has to survive an outage,
// The read did not answer - a broker outage, or a reference this holder cannot reach at
// all, which `docs.sparqlQuery` now refuses out loud instead of leaving as an empty
// answer. Stop the drain either way: everything still queued has to survive an outage,
// and this deposit is already gone.
if (referenceReadFailed) {
return {
+7 -5
View File
@@ -125,11 +125,13 @@ export * as inbox from "./surface/inbox";
export * as docs from "./surface/docs";
export { subscribeDoc, subscribeDocs, docChangeType } from "./surface/subscribe";
export type { DocChange, DocChangeType, Unsubscribe } from "./surface/subscribe";
// `readUnion` is exposed as a function, not under a `readModel` namespace: "model" is
// neither the target's vocabulary nor neutral glue, and the namespace bought nothing —
// it held one published function. Renamed 2026-08-03 by the vocabulary check.
export { readUnion } from "./surface/read-model";
export type { UnionSubject } from "./surface/read-model";
// `readUnion` and `UnionSubject` were published here until `1.0.0-dev.8`. They are gone,
// and nothing replaces them: a `document read` over several documents was N anchored
// queries plus a batch policy, which no level of the target answers - so its provenance
// cited `sparql_query`, a citation that is exact attached to a claim that is false. A
// composition cannot be aligned on a single function. Reading one document by name is
// `docs.sparqlQuery(sessionId, query, undefined, doc)`, which IS upstream's own primitive
// and is published beside this line; reading several is that call, once per document.
export * as storeRegistry from "./surface/placement";
// SDK type re-exports — so the app imports these from @ng-eventually/polyfill too, not from
@@ -0,0 +1,141 @@
/**
* read-document - read ONE document with an anchored SPARQL query, and group the rows
* it returns per subject. Library-internal: nothing here is published.
*
* -- What this replaced, and why the batch went ----------------------------
* Until `1.0.0-dev.8` this library published `readUnion(docs)`: the same anchored read,
* issued once per document, in parallel, with a batch policy of its own (a failing
* document was swallowed into an empty result unless the caller passed a callback to be
* told). Nothing upstream has a counterpart for that - it was N calls plus a policy -
* and its provenance annotation cited `sparql_query`, a citation that is exact attached
* to a claim that is false. **A composition cannot be aligned on a single function.**
*
* Every caller in production passed ONE document. So the batch is gone, and what is left
* is the thing upstream really does have: one anchored `sparql_query`. There is no
* swallow left to explain either - this throws, and the caller decides, which is the
* only honest answer once "one document failed" and "the read failed" are the same
* event.
*
* -- Why ANCHORED, and never anchorless -----------------------------------
* An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)` is
* restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` ->
* `Some(repo_graph_name)`, which becomes the query's DEFAULT graph. A body with no
* `GRAPH` wrapper reads only that default graph, so only that document's triples - O(1)
* in the document's own size, independent of how many other graphs the local store holds.
*
* The footgun this avoids: an anchorless query (`anchor` undefined -> `UserSite` ->
* `set_default_graph_as_union`) spans EVERY named graph in the session store. On a shared
* wallet that accumulates across runs, that is O(wallet size) - the observed ~90s
* timeouts. Replacing several anchored reads with one anchorless query would also be a
* silent WIDENING of what a read can reach, so it is not an optimisation available here.
*
* NB (verified, `docs/read-model.md` probe step 4): an explicit `GRAPH ?g { ... }` body
* iterates the named graphs regardless of the default graph, so an anchor does not bound
* such a body. This read therefore uses a default-graph body - no `GRAPH` wrapper - so
* the anchor's one-repo restriction actually applies.
*
* -- The open in front of it is the EMULATION, not the query --------------
* On a fresh session over the same persistent wallet the target repo is present but
* UNSYNCED, and an anchored query against it legitimately matches nothing - no error,
* just zero rows. So {@link ensureRepoOpen} runs first and awaits the sync barrier, which
* is also where a document in a PUBLIC store hands over its `ReadCap`. That step is
* `polyfill-era` machinery: it is what one shared wallet costs, it is annotated where it
* lives (`emulated-verifier/open-repo.ts`), and it goes at migration with the shim.
*
* Calling it here rather than leaving it to the caller is deliberate and load-bearing:
* `emulated-verifier/index-deposit.ts` decides "absent" versus "unreadable" from
* {@link getSyncState}, and a read that never opened the repo would leave that state
* `unknown` - which that arm reads as "a zero-row answer MEANS it". Skipping the open
* would therefore turn a cold start into a destroyed deposit queue, silently. It is the
* failure that path has paid for twice; the barrier is not an optimisation to move.
*/
import { sparqlQuery } from "./docs";
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { isMachinerySubject } from "../emulated-verifier/machinery";
import { session } from "../shared-wallet/account-registry";
import type { Nuri } from "../model/types";
/**
* One subject read from a document, with its properties (predicate -> values).
*
* `subject` is typed `string`, not `Nuri`: a subject is an ordinary RDF subject and may
* be ANY IRI. An application that writes its entity under the document's own reference
* gets a reference back here, but that is its convention, not this type's promise - a
* document may hold several subjects under IRIs of its writer's choosing, and each comes
* back as written.
*/
export interface DocumentSubject {
/** The subject IRI (`?s`), exactly as the document carries it. */
subject: string;
/** predicate IRI -> the list of object values (literals or IRIs) for it. */
props: Record<string, string[]>;
}
/** Tolerant extraction of SPARQL SELECT bindings across the shapes `ng` can return. */
function bindings(
result: unknown,
): Array<Record<string, { value: string } | undefined>> {
if (!result) return [];
if (Array.isArray(result))
return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
/**
* Read `doc`'s triples with one anchored query and return them grouped per subject.
*
* **Throws** on anything that stops the read: a reference this holder may not reach
* (`docs.sparqlQuery` refuses it), a broker that does not answer, a query that rejects.
* That is the whole point of having no batch any more - there is no other document for a
* failure to be tolerated in favour of, so "it failed" reaches the caller as a failure
* rather than as an empty answer. A document that is genuinely empty, or whose repo has
* not synced, returns `[]` WITHOUT throwing - and telling those two apart is the
* caller's job ({@link getSyncState} is what answers it).
*
* A document holding SEVERAL subjects yields several entries - one per distinct subject,
* carrying that subject as written. Properties of different subjects are never merged.
* Placing one business entity per document stays the recommended practice (a key is per
* repo, so isolation needs a repo per entity), but that is a recommendation about
* WRITING: the read reports what is there, and does not get to enforce it by making the
* other arrangement invisible.
*
* The library's own compartments live as reserved SUBJECTS inside the very documents a
* consumer reads (the Header branch carrying a document's inbox address is the first).
* They are machinery, not the entity's properties, and are dropped here - once, for
* every compartment present and future.
*/
// @provenance readDocumentSubjects kind=invention level=none ref=none - the anchored query it issues is `docs.sparqlQuery`, aligned and annotated THERE; what is added here has no upstream counterpart at any level: the cold-start open a shared wallet forces, and the grouping of a SELECT's rows into per-subject property bags. Internal, and named as ours so a later reader knows it is ours to change
export async function readDocumentSubjects(
doc: Nuri,
label = "readDocument",
): Promise<DocumentSubject[]> {
// The cold-start open, and the public store's chance to hand over its key, BEFORE the
// anchored read - see the module header for why this cannot move to the caller.
await ensureRepoOpen(doc);
const { sessionId } = await session();
// Anchored to `doc` -> default graph = this repo. No `GRAPH ?g` wrapper, so the
// anchor's one-repo restriction applies.
const rows = bindings(
await sparqlQuery(sessionId, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, doc, label),
);
const bySubject = new Map<string, DocumentSubject>();
for (const row of rows) {
const s = row.s?.value;
if (isMachinerySubject(s)) continue;
const p = row.p?.value;
const o = row.o?.value;
if (s === undefined || !p || o === undefined) continue;
let entry = bySubject.get(s);
if (!entry) {
entry = { subject: s, props: {} };
bySubject.set(s, entry);
}
(entry.props[p] ??= []).push(o);
}
return [...bySubject.values()];
}
-282
View File
@@ -1,282 +0,0 @@
/**
* read-model — the listing primitive of the polyfill: read a bounded, by-need set
* of documents, each with its own anchored `sparql_query`, and return the triples
* grouped per subject. This is the mechanism documented in docs/read-model.md.
*
* ── Why per-doc anchored, rather than an anchorless union-scan ─────────────
* An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)`
* is restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` →
* `Some(repo_graph_name)`, which becomes the query's default graph. A body with no
* `GRAPH` wrapper reads only that default graph → only that doc's triples, O(1) per
* doc, independent of how many other graphs the local store holds.
*
* The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` →
* `set_default_graph_as_union`) spans EVERY named graph currently in the session
* store. On a shared / bloated wallet that accumulates across runs, that is
* O(wallet size) → the observed ~90s timeouts. So the read path never union-scans
* all graphs — it reads exactly the bounded by-need set, one anchored query per doc.
*
* NB (verified, docs/read-model.md § probe step 4): an explicit `GRAPH ?g { … }`
* body iterates the named graphs regardless of the default graph, so an anchor does
* not bound such a body. The per-doc read therefore uses a default-graph body (no
* `GRAPH` wrapper) so the anchor's one-repo restriction actually applies.
*
* ── Why not the reactive ORM fan-out ──────────────────────────────────────
* `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of
* per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing
* is instead a set of one-shot anchored `sparql_query`s. There is no reactive
* union query, so reactivity is assembled by re-querying on a change signal.
*
* ── Generic by construction ───────────────────────────────────────────────
* No application domain here: the consumer passes the doc NURIs to read (from
* the discovery index for public events, or its own scope docs for my-entities)
* and interprets the returned per-subject property bags. All NextGraph I/O routes
* through the T01.a `docs` primitives (the real injected `ng`), so this module
* imports no `@ng-org` package.
*
* At the real multi-store migration the per-doc anchored read is unchanged (native
* SPARQL, anchored to one repo); only bringing a repo into the session (open by cap)
* changes — the anchored query already resolves a same-session repo directly.
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getCaps, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { mustNotAttempt } from "../emulated-verifier/reach";
import { ensureReposOpen } from "../emulated-verifier/open-repo";
import { assertNuri } from "./sparql";
import { toNuri } from "../model/nuri";
import { isMachinerySubject } from "../emulated-verifier/machinery";
import type { Nuri, NuriLike } from "../model/types";
// Keep the primitives referenced so tree-shaking never drops the import used by
// the (side-effecting) open step below; `docCreate`/`sparqlUpdate` are not used
// here but the module intentionally depends only on the docs primitive surface.
void docCreate;
void sparqlUpdate;
/** One subject read from a doc, with its properties (predicate → values). */
// @provenance UnionSubject kind=invention level=none ref=none — no upstream type groups triples per (document, subject); a polyfill property bag, to be mapped into app types at the boundary
export interface UnionSubject {
/**
* The subject IRI (`?s`), exactly as the document carries it.
*
* Typed `string`, not `Nuri`: a subject is an ordinary RDF subject and may be ANY
* IRI. An application that writes its entity under the document's own NURI gets a
* NURI back here, but that is its convention, not this type's promise — a document
* may hold several subjects under IRIs of the consumer's choosing, and each comes
* back as written.
*
* The need that once made this `Nuri` is real and is met by {@link graph}: a
* consumer must be able to pass back what it just read without a cast —
* `shareNote(note.doc)`, `leaveMessage(note.doc)` — and a cast at that boundary
* would re-open exactly the confusion the template literal types exist to close.
* `graph` is the document reference, so that is the field to carry around.
*/
subject: string;
/**
* The document this subject was read from — the reference the caller passed to
* {@link readUnion}, unchanged. This is the anchor, it identifies the document
* stably, and it is what goes back into any call of this surface.
*/
graph: Nuri;
/** predicate IRI → the list of object values (literals or IRIs) for it. */
props: Record<string, string[]>;
}
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function bindings(
result: unknown,
): Array<Record<string, { value: string } | undefined>> {
if (!result) return [];
if (Array.isArray(result))
return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
async function sessionId(): Promise<string | number> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
/**
* Read one doc with an anchored default-graph query, tolerant per-doc.
*
* The anchor (`doc` NURI) restricts the query to that repo's graph as the default
* graph (`resolve_target_for_sparql(Repo)` → `Some(repo_graph_name)`); a body with
* no `GRAPH` wrapper reads exactly that default graph → only this doc's triples.
* This is O(1) in the doc's own size and independent of the rest of the (possibly
* bloated / shared) session store — it never iterates other graphs.
*
* COLD-START (fresh session, same persistent wallet): the repo is NOT in
* `self.repos` until something opens it, and an anchored query against an unopened
* repo silently returns 0 rows (never `RepoNotFound`). {@link readUnion} therefore
* opens the batch's repos ({@link ensureReposOpen}) BEFORE this read runs, so the
* anchored query resolves a same-session repo directly. A genuinely-absent repo
* still yields `[]` (in isolation, never aborting the others). Returns the doc's
* rows, or `[]` on failure.
*
* At the real multi-store migration this becomes a real sync: opening a per-user
* store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO).
*/
async function readDoc(
sid: string | number,
doc: Nuri,
onUnreadable?: (doc: Nuri, error: unknown) => void,
): Promise<Array<Record<string, { value: string } | undefined>>> {
try {
const nuri = assertNuri(doc);
// Anchored to `nuri` → default graph = this repo. No `GRAPH ?g` wrapper, so
// the anchor's one-repo restriction applies (an explicit `GRAPH ?g` body would
// iterate all named graphs regardless of the anchor — see docs § probe step 4).
const res = await sparqlQuery(
sid,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
nuri,
"readDoc",
);
return bindings(res);
} catch (error) {
console.error("[read-model] read failed for", doc, error);
// The swallow STAYS — a batch read has always tolerated one bad document, and a caller
// building a list from optional values must not lose the whole read to one hole. What is
// new (2026-08-26) is that the failure is no longer INDISTINGUISHABLE from an empty
// document: a caller that needs to tell them apart says so by passing `onUnreadable`.
//
// It needed telling apart because of inbox consumption. A deposit is now retired before
// its arm runs, so an arm that reads `[]` and concludes "absent, nothing to do" turns a
// broker hiccup into permanent loss — and one that concludes "must be a failure" lets a
// single unresolvable reference stall every deposit behind it. Only the truth serves.
onUnreadable?.(doc, error);
return [];
}
}
/**
* Read a BOUNDED, by-need set of docs — each with its OWN anchored query — and
* return the triples grouped per subject. `docs` are the NURIs to read (the
* consumer resolves them by need — index for public, own scope docs for mine).
* Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
* batch.
*
* A document holding SEVERAL subjects yields several entries — one per distinct
* subject, carrying that subject as written, all sharing the document as their
* `graph`. Properties of different subjects are never merged. Placing one business
* entity per document stays the recommended practice (a key is per repo, so
* isolation needs a repo per entity), but it is a recommendation about writing: the
* read reports what is there.
*
* ── Why this enumerates, and why that is OURS and not the target's ────────
* Upstream DOES have a query that spans many documents: `resolve_target_for_sparql`
* (`engine/verifier/src/request_processor.rs`) accepts `UserSite` — or no target at
* all — and returns `Ok(None)`, which the verifier itself annotates "QUERYING UNION
* GRAPH". It is refused for updates (`InvalidTarget`) and allowed for reads. So
* "the target cannot read several documents at once" is FALSE, and this path must
* not be read as compensating for a missing upstream capability.
*
* What makes that union unusable HERE is our own emulation: one shared wallet holds
* EVERY virtual identity, so a scan over all graphs crosses identity boundaries that
* upstream does not have (there, a wallet is one person's). The cost argument —
* O(wallet size) — follows from the same artefact. Both reasons are `polyfill-era`.
*
* Each doc is therefore read with an anchored default-graph query, O(1) per doc,
* in parallel via `Promise.all`.
*
* OPEN, and deliberately not settled here: whether enumerating is what an
* application should be taught at all. Targeting `PrivateStore` is a first-class
* option upstream, and whether it reaches a document's CONTENTS — or only the store
* repo's own graph, documents being repos of their own — decides it. Until that is
* established, treat this shape as unconfirmed rather than as the aligned answer.
*/
// @provenance readUnion kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:sparql_query — one anchored query per document — upstream's own anchoring (`resolve_target_for_sparql`), composed client-side
// @provenance readUnion.enumeration kind=invention level=none ref=none — upstream offers a union query over many documents (`UserSite`/no target → union graph); enumerating instead is OURS, forced by the shared wallet, and UNCONFIRMED as the shape to teach — see the docstring's open question
// @provenance readUnion.swallow kind=invention level=none ref=none — a failing document is skipped and reads as empty; upstream has no batch read at all, so it has no counterpart for what a partial failure should do
export async function readUnion(
docsLike: NuriLike[],
onUnreadable?: (doc: Nuri, error: unknown) => void,
): Promise<UnionSubject[]> {
const sid = await sessionId();
// Drop the empties BEFORE validating, not after: this call has always tolerated a
// list with holes in it — a scope index can carry a blank entry, and a caller
// building a list from optional values should not have to compact it. Validating
// first turned that tolerance into a throw, which took down a whole reconnect run.
// Empty is absence, and absence is not a malformed reference.
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
if (unique.length === 0) return [];
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
// target repos are not yet in `self.repos`, so an anchored read would return 0
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
// initial-state push before the anchored reads. No-op once opened / when the
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
//
// Called on the WHOLE set, before the boundary is consulted, because opening is also
// where a document in a PUBLIC store hands over its cap (see public-store.ts): a
// document filtered out first would never get the chance to answer. `ensureRepoOpen`
// still refuses to open what this user may not touch — it asks, it does not enter.
await ensureReposOpen(unique);
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
// hold before reading anything: upstream you cannot address a repo you have no cap
// for, so asking about one is not "a read that will be refused", it is a read that
// has no meaning. (The passage points enforce rule 1 regardless — see reach.ts — so
// a lapse here is caught, not exploited.)
const reachable = unique.filter((d) => !mustNotAttempt(d));
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
const perDoc = await Promise.all(
reachable.map(async (d) => ({
doc: assertNuri(d),
rows: await readDoc(sid, d, onUnreadable),
})),
);
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
// already excluded these, so this loop should never drop anything. The read is
// anchored per document, so the unit of possession is the document: the cap key is
// the doc NURI, whatever subjects that document turns out to carry.
const caps = getCaps();
// Keyed by (document, subject) — an entry is one subject INSIDE one graph, which is
// the identity upstream gives an object too: the ORM carries `@id` and `@graph` as
// two distinct read-only properties, and fabricates an `@id` when the writer leaves
// it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`). Several objects per
// graph is therefore the PROVIDED case, and `@id` is what tells them apart within a
// `@graph`. Two documents carrying the same subject IRI stay two entries: they are
// two objects, distinguished by their graph.
//
// Placing one business entity per document remains the recommended practice — a key
// is per repo, so isolating an entity requires a repo of its own. That is a
// recommendation about WRITING, and the read does not get to enforce it by making
// the other arrangement invisible.
const bySubject = new Map<string, UnionSubject>();
for (const { doc, rows } of perDoc) {
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
// Anchored to `doc`, so every row belongs to `doc` — hence `graph` is the caller's
// reference to it. The subject comes back exactly as the document carries it.
for (const row of rows) {
// The polyfill's own compartments live as reserved SUBJECTS inside the very
// documents the consumer reads (the Header branch carrying a document's inbox
// address is the first). They are machinery, not this entity's properties —
// drop them here, once, for every compartment present and future.
const s = row.s?.value;
if (isMachinerySubject(s)) continue;
const p = row.p?.value;
const o = row.o?.value;
if (s === undefined || !p || o === undefined) continue;
// NUL cannot appear in an IRI, so the pair never collides with either half.
const key = `${doc}\u0000${s}`;
let entry = bySubject.get(key);
if (!entry) {
entry = { subject: s, graph: doc, props: {} };
bySubject.set(key, entry);
}
(entry.props[p] ??= []).push(o);
}
}
return [...bySubject.values()];
}
@@ -40,8 +40,8 @@ import { insertObject } from "../src/surface/orm";
import type { InsertObjectLike } from "../src/model/types";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
import { readDocumentSubjects } from "../src/surface/read-document";
import { sparqlQuery, sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
/**
@@ -242,7 +242,7 @@ function makeFakeNg() {
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
// The anchored per-document read (`SELECT ?s ?p ?o`) — the document's content.
return {
results: {
bindings: quads
@@ -281,12 +281,25 @@ async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
/**
* The values `p` carries in `doc`, as the current holder reads them.
*
* ONE document, one anchored query - what `readUnion` did per entry before it was
* removed in `1.0.0-dev.8`. The difference that shows up all over this file: a document
* the holder may NOT reach used to be dropped from the batch and read as `[]`, and now
* the read is REFUSED. Both say "you cannot read this"; only the second says it out
* loud, which is why {@link expectRefused} exists rather than another `toEqual([])`.
*/
async function readValues(doc: Nuri, p: string): Promise<string[]> {
const subjects = await readDocumentSubjects(doc);
return subjects.flatMap((s) => s.props[p] ?? []);
}
/** Assert the current holder is REFUSED `doc`: the answer to "I hold no cap for this". */
async function expectRefused(doc: Nuri, p: string): Promise<void> {
await expect(readValues(doc, p)).rejects.toThrow(/does not hold this document/i);
}
/**
* Alice's world: a protected document holding a secret, and a public document that
* REFERS to it by bare NURI.
@@ -326,13 +339,13 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
// store, so the store serves him its cap — he never received a key from anyone.
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
const refs = await readValues(pubDoc, REFERS_TO);
const ref = referenceFoundIn(refs);
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
expect(hasCap(ref)).toBe(false);
expect(await readValues([ref], SECRET)).toEqual([]);
await expectRefused(ref, SECRET);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
@@ -348,10 +361,10 @@ test("Charlie: same public document, same reference — and he reads through it"
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
const ref = referenceFoundIn(await readValues(pubDoc, REFERS_TO));
expect(ref).toBe(protDoc);
expect(hasCap(ref)).toBe(true);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(ref, SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
@@ -363,13 +376,18 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
await share(protDoc, "charlie");
setCurrentUser("bob");
const bobSees = await readValues([protDoc], SECRET);
// Obtained under BOB, so what is compared below is each holder's own answer to the
// same question. His is a refusal; hers is the content.
const bobIsRefused = await readValues(protDoc, SECRET).then(
() => false,
() => true,
);
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET);
const charlieSees = await readValues(protDoc, SECRET);
expect(bobSees).toEqual([]);
expect(bobIsRefused).toBe(true);
expect(charlieSees).toEqual(["the-protected-content"]);
});
@@ -381,10 +399,10 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob");
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
const ref = referenceFoundIn(await readValues(pubDoc, REFERS_TO));
// Before: named, unreadable.
expect(await readValues([ref], SECRET)).toEqual([]);
await expectRefused(ref, SECRET);
// A reader that re-reads whenever what it holds changes — this is exactly what
// `watchShape` wires internally, played here on an ad-hoc read.
@@ -392,7 +410,13 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
let latest: string[] = [];
const unsub = getCaps().onChange(() => {
reread += 1;
void readValues([ref], SECRET).then((v) => (latest = v));
// While the cap is still missing the read is REFUSED rather than empty, and a reader
// keeps what it had - which is what any real view does, and what makes `latest`
// meaningful when the cap finally lands.
void readValues(ref, SECRET).then(
(v) => (latest = v),
() => {},
);
});
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
@@ -410,7 +434,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
// …and the read that was empty now yields the content.
expect(hasCap(ref)).toBe(true);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(ref, SECRET)).toEqual(["the-protected-content"]);
unsub();
});
@@ -420,14 +444,27 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
// (a bare reference) yields a different outcome depending on the store, and never
// because a key travelled.
//
// It is also the property `readUnion`'s removal rests on. `readUnion([doc])` was how a
// stranger reached a public index from its reference alone; what actually did the work
// was the anchored `docs.sparqlQuery` underneath, which fetches the public store's key
// at its own door (`surface/docs.ts`). This test exercises exactly that path with no
// batch above it, which is why the key fetch is asserted here rather than assumed.
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob has been given nothing but the two NURIs.
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
expect(await readValues([protDoc], SECRET)).toEqual([]);
// Bob has been given nothing but the two NURIs — no key, for either of them.
expect(getCaps().isEnforcing()).toBe(true);
expect(hasCap(pubDoc)).toBe(false);
expect(hasCap(protDoc)).toBe(false);
expect((await readValues(pubDoc, REFERS_TO)).length).toBe(1);
// …and the key he now holds for it came from the DOCUMENT, on the way through the
// anchored read. Nothing in this file handed it to him.
expect(hasCap(pubDoc)).toBe(true);
await expectRefused(protDoc, SECRET);
// And what he obtained for the public one is a READ grant, not a write right: a
// public store serves its read cap, no store hands out the write cap.
@@ -449,7 +486,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
// Bob connects: the library restores + drains, with nothing asked of the app.
setCurrentUser("bob");
await connectedUser();
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(protDoc, SECRET)).toEqual(["the-protected-content"]);
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
// cap, then re-arm the emulation so the boundary is actually in force again.
@@ -468,7 +505,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(hasCap(protDoc)).toBe(true);
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(protDoc, SECRET)).toEqual(["the-protected-content"]);
});
test("connecting a user that does not exist provisions nothing", async () => {
@@ -494,7 +531,7 @@ test("a deposit is addressed to an inbox, never to a document", async () => {
await expect(post(protDoc, { payload: { x: 1 }, ts: 1 })).rejects.toThrow(/not an inbox/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // untouched
});
// REGRESSION (second adversarial pass). The write guard reads ownership from the store
@@ -538,10 +575,10 @@ test("the owner writes to her own public note, even after opening it from its re
setCurrentUser("bob");
await createEntityDoc("bob", "private"); // re-arms the emulation
setCurrentUser("alice");
await readValues([pubDoc], SECRET); // this is what files the served cap
await readValues(pubDoc, SECRET); // this is what files the served cap
await write(pubDoc, SECRET, "v2"); // must not throw
expect((await readValues([pubDoc], SECRET)).includes("v2")).toBe(true);
expect((await readValues(pubDoc, SECRET)).includes("v2")).toBe(true);
});
// Direction 2 — TOO LAX. A cap received in an inbox let its recipient WRITE into the
@@ -557,11 +594,11 @@ test("a cap received in an inbox reads, and does NOT write", async () => {
setCurrentUser("bob");
await readInbox(BOB_INBOX);
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // he reads it
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // he reads it
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/WRITE cap/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // untouched
});
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
@@ -726,18 +763,37 @@ test("concurrent opens by a NON-owner are all refused, and leave no residue", as
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
test("the inbox address is machinery: a SUBJECT beside the data, never mixed into it", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
// Both halves are asserted, because `readUnion`'s removal changed which one an
// APPLICATION sees. The compartment is a distinct reserved SUBJECT, so it is never
// mixed into the entity's properties whichever read is used - that is the property
// that protects the data, and it is structural.
const subjects = await readDocumentSubjects(doc);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
// The filter that DROPS it, however, lives in this library's internal read. A raw
// anchored `docs.sparqlQuery` - which is what an application composes now that
// `readUnion` is gone - returns the Header-branch rows too. That is not a gain of
// rights: the two registers an entity document carries (`inboxAddress`,
// `exposedReadCap`) are the compartment meant for what any reader may see, and the
// cap-bearing `inboxCap` register lives in its owner's PRIVATE store, not here.
const raw = await sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"test",
);
const rawSubjects = (raw as { results: { bindings: Array<{ s: { value: string } }> } })
.results.bindings.map((b) => b.s.value);
expect(rawSubjects).toContain(`${SHIM}:headerBranch`);
});
// CONNECTING APPLIES WHAT WAS DEPOSITED — and the honest scope of that claim.
@@ -775,7 +831,7 @@ test("connecting applies the Links waiting for me, and leaves consumer deposits
await connectedUser();
expect(hasCap(carolDoc)).toBe(true); // the Link was applied
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
expect(await readValues(protDoc, SECRET)).toEqual([]); // (protDoc holds no secret here)
const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
});
@@ -47,7 +47,7 @@ import {
} from "../src/emulated-verifier/inbox-processor";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { share } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { readDocumentSubjects } from "../src/surface/read-document";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
@@ -282,9 +282,10 @@ async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** The values `p` carries in `docs`, as the CURRENT holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
/** The values `p` carries in `doc`, as the CURRENT holder reads them - one document,
* one anchored query, which is all `readUnion` ever did per entry. */
async function readValues(doc: Nuri, p: string): Promise<string[]> {
const subjects = await readDocumentSubjects(doc);
return subjects.flatMap((s) => s.props[p] ?? []);
}
@@ -335,7 +336,10 @@ test("a deposit is drained for the inbox's OWNER, while the depositor still hold
// connected, Alice would now hold a capability nobody gave her.
expect(getCurrentUser()).toBe("alice");
expect(caps.capForHolder("alice", carolDoc)).toBeUndefined();
expect(await readValues([carolDoc], SECRET)).toEqual([]);
// …and the read says so out loud. It used to answer `[]`, because `readUnion` dropped a
// document it could not attempt; the anchored read refuses it instead, which asserts the
// same fact more strongly - Alice is not merely shown nothing, she is told no.
await expect(readValues(carolDoc, SECRET)).rejects.toThrow(/does not hold this document/i);
expect(await linksFiledFor("alice")).toEqual([]);
// …and both Links landed in Bob's space instead.
@@ -371,7 +375,7 @@ test("what the drain filed for the owner is DURABLE: he restores it with his inb
expect(getCaps().capFor(aliceDoc)).toBeUndefined(); // he holds nothing yet
await connectedUser();
expect(getCaps().capFor(aliceDoc)).toBeDefined();
expect(await readValues([aliceDoc], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(aliceDoc, SECRET)).toEqual(["the-protected-content"]);
});
// --- coalescing --------------------------------------------------------------
@@ -525,7 +529,6 @@ test("nothing about the deferred service reaches the published surface", () => {
"initNg",
"insertObject",
"ng",
"readUnion",
"storeRegistry",
"subscribeDoc",
"subscribeDocs",
@@ -41,7 +41,7 @@ import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifi
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 { readDocumentSubjects } from "../src/surface/read-document";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
@@ -261,7 +261,7 @@ async function write(doc: Nuri, p: string, o: string): Promise<void> {
/** 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]);
const subjects = await readDocumentSubjects(index);
return subjects.filter((s) => s.subject === object).flatMap((s) => s.props[ENTRY_VALUE] ?? []);
}
@@ -340,9 +340,10 @@ test("a reference nobody can resolve is reported, and costs the index nothing",
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
// A reference this holder cannot even attempt — no cap, so `readUnion` drops it before
// issuing a query — and a resolvable one BEHIND it, which is what makes the question
// answerable at all.
// A reference this holder cannot reach — no cap, so the anchored read REFUSES it (until
// `1.0.0-dev.8` `readUnion` dropped it before issuing a query, which reached this arm as
// an empty answer instead of a failure) — and a resolvable one BEHIND it, which is what
// makes the question answerable at all.
await bobDepositsInto(index, "did:ng:o:nowhere", 1);
await bobDepositsInto(index, object, 2);
@@ -560,9 +561,9 @@ test("an EMPTY but readable object is absent — reported, and the queue behind
// A document Alice really holds and that really reads — it just has no triples. This is
// the genuine clean-empty read, and until 2026-08-26 NOTHING exercised it: the only test
// of this branch pointed at `did:ng:o:nowhere`, which `readUnion`'s possession gate drops
// before any query is issued. So the branch that decides "absent vs unreadable" on a real
// read was uncovered, which is how two opposite misclassifications shipped through it.
// of this branch pointed at `did:ng:o:nowhere`, which the possession gate refuses before
// any rows exist. So the branch that decides "absent vs unreadable" on a real read was
// uncovered, which is how two opposite misclassifications shipped through it.
setCurrentUser("alice");
const empty = await createEntityDoc("alice", "public");
setCurrentUser(null);
@@ -29,7 +29,7 @@
*/
import { test, expect, describe, mock, beforeEach } from "bun:test";
import { configure, storeRegistry, readUnion } from "../src/index";
import { configure, storeRegistry, docs } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
@@ -44,6 +44,25 @@ const KEY_READ = `<${SHIM}:readCap>`;
const TITLE = "urn:test:title";
/**
* The titles `doc` carries, read the way an APPLICATION reads one document since
* `1.0.0-dev.8`: the published anchored `docs.sparqlQuery`, naming the document. It
* replaces `readUnion([doc])`, which was the same anchored query with a batch around it.
*/
async function titlesIn(doc: Nuri): Promise<string[]> {
const res = await docs.sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"readDoc",
);
const rows = (res as { results?: { bindings?: Array<Record<string, { value: string }>> } })
.results?.bindings ?? [];
return rows.filter((r) => r["p"]?.value === TITLE).map((r) => r["o"]!.value);
}
/**
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query
* `lostAt` accepts — that one included, and every one after it, for the rest of the page.
@@ -121,8 +140,9 @@ describe("listMyEntityDocs answers a verified listing or surfaces", () => {
await signIn("alice");
expect(await listMyEntityDocs("alice", scope)).toContain(note);
const subjects = await readUnion([note]);
expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
// Read the way an application reads one document now that `readUnion` is gone: the
// published anchored query, naming the document it was just handed.
expect(await titlesIn(note)).toEqual(["the note"]);
});
}
+11 -8
View File
@@ -13,13 +13,16 @@
* - doc_subscribe is a mock that records calls, fires the callback once
* (simulating the initial State push), then returns an unsubscribe fn.
*
* We test ensureRepoOpen via readUnion (from read-model) because that is the
* production caller — it gates on ensureReposOpen internally.
* We test ensureRepoOpen via `readDocumentSubjects` (from `surface/read-document`)
* because that is the production caller — it opens the document before its anchored
* read, and `emulated-verifier/index-deposit.ts` depends on that ordering to tell an
* absence from an unsynced repo. It was `readUnion` until `1.0.0-dev.8`; the integration
* path is the same one, minus the batch.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { readUnion } from "../src/surface/read-model";
import { readDocumentSubjects } from "../src/surface/read-document";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
@@ -115,7 +118,7 @@ describe("ensureRepoOpen", () => {
expect(ng.subscribeCallOrder[0]).toBe("did:ng:o:a");
// sparql_query was called AFTER subscribe (ensureRepoOpen guarantees ordering)
const result = await readUnion(["did:ng:o:a"]);
const result = await readDocumentSubjects("did:ng:o:a");
expect(result.length).toBe(1);
expect(result[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
});
@@ -127,7 +130,7 @@ describe("ensureRepoOpen", () => {
inject(ng);
// Do NOT call ensureRepoOpen — subscribed Set remains empty
// Query directly (bypass readUnion which calls ensureReposOpen internally)
// Query directly (bypass `readDocumentSubjects`, which opens the repo internally)
const result = await ng.sparql_query("sid-or", "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, "did:ng:o:a");
const bindings = (result as any).results.bindings;
expect(bindings.length).toBe(0); // not subscribed → 0 rows (confirms fake design)
@@ -203,15 +206,15 @@ describe("ensureReposOpen", () => {
expect(ng.doc_subscribe).toHaveBeenCalledTimes(0);
});
it("readUnion triggers doc_subscribe then returns data (integration path)", async () => {
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
});
inject(ng);
const subjects = await readUnion(["did:ng:o:p"]);
const subjects = await readDocumentSubjects("did:ng:o:p");
// doc_subscribe was called as part of ensureReposOpen inside readUnion
// doc_subscribe was called as part of the `ensureRepoOpen` inside the read
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(subjects.length).toBe(1);
expect(subjects[0]!.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
@@ -1,24 +1,53 @@
/**
* read-document.test.ts - the internal single-document read (`src/surface/read-document.ts`).
*
* This file was `read-model.test.ts` until `1.0.0-dev.8`, when the published `readUnion`
* went. Three of its cases tested the BATCH POLICY rather than the read, and a batch no
* longer exists, so each was re-pointed at the property that replaced it rather than
* dropped:
*
* - "a doc that fails to read is SKIPPED, not aborting the batch" -> a read that fails
* THROWS. With no other document to carry on for, swallowing a failure would leave
* "it failed" and "it is empty" as the same answer, which is the family of defect
* this package has closed repeatedly.
* - "returns [] for an empty doc SET (no query)" -> a document that holds nothing is
* read, answers, and returns `[]`. That is the surviving distinction: an empty ANSWER.
* - "the same subject IRI in two documents stays two entries, told apart by `graph`" ->
* two reads, each reporting its own document. Nothing can merge across documents any
* more, because nothing shares a call; `graph` went with the batch, since the caller
* names exactly one document and already holds its reference.
*
* One BEHAVIOUR CHANGE is pinned below rather than left to be discovered: a document the
* holder has no capability for used to be dropped silently from the batch, and now makes
* the read THROW - the refusal belongs to `docs.sparqlQuery`, which is the published door
* this read goes through and which has always refused out loud.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import { readUnion } from "../src/surface/read-model";
import { readDocumentSubjects } from "../src/surface/read-document";
import type { Nuri } from "../src/model/types";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps } from "../src/shared-wallet/bootstrap";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
// The cap registry is process-wide, so each inject() starts from an empty one:
// once ANY cap exists the possession gate is in force for every reader, and a
// suite that never declares caps must not inherit another suite's.
afterAll(() => {
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
});
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
// NO anchorless union scan: each doc is read independently by its own anchor. Each
// entity subject IRI IS its own document NURI (writeEntity convention), so the
// fixture keys triples by the doc NURI and returns them for the matching anchor.
// NO anchorless union scan: a document is read by its own anchor. Each entity subject
// IRI IS its own document NURI (writeEntity convention), so the fixture keys triples by
// the doc NURI and returns them for the matching anchor.
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
return {
doc_create: mock(async () => "did:ng:o:new"),
@@ -26,7 +55,7 @@ function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
// Every read is ANCHORED to one doc NURI — never anchorless.
if (anchor === undefined) {
throw new Error("read-model must NEVER run an anchorless (union) query");
throw new Error("read-document must NEVER run an anchorless (union) query");
}
const doc = anchor as string;
const triples = triplesByDoc[doc];
@@ -44,10 +73,12 @@ function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const ng = fakeNgWith(triplesByDoc);
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
return ng;
@@ -56,29 +87,27 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
test("readUnion reads each doc with its OWN anchored query (never anchorless)", async () => {
test("reads the document with its OWN anchored query (never anchorless)", async () => {
const ng = inject({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
const a = await readDocumentSubjects("did:ng:o:a");
const b = await readDocumentSubjects("did:ng:o:b");
// One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set).
// One anchored query per read = 2 sparql_query calls, each anchored (c[3] set).
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
expect(anchored.length).toBe(2);
// The anchors are exactly the requested doc NURIs.
expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual(
new Set(["did:ng:o:a", "did:ng:o:b"]),
);
// The anchors are exactly the documents that were named.
expect(anchored.map((c: unknown[]) => c[3])).toEqual(["did:ng:o:a", "did:ng:o:b"]);
expect(subjects.length).toBe(2);
const a = subjects.find((s) => s.subject === "did:ng:o:a")!;
expect(a.props[`${FP}title`]).toEqual(["A"]);
expect(a.graph).toBe("did:ng:o:a");
expect(a.map((s) => s.subject)).toEqual(["did:ng:o:a"]);
expect(a[0]!.props[`${FP}title`]).toEqual(["A"]);
expect(b[0]!.props[`${FP}title`]).toEqual(["B"]);
});
test("readUnion groups predicates per subject", async () => {
test("groups predicates per subject", async () => {
inject({
"did:ng:o:p": [
[TYPE, `${FP}Participation`],
@@ -86,20 +115,22 @@ test("readUnion groups predicates per subject", async () => {
[`${FP}user`, "urn:festipod:user:x"],
],
});
const s = (await readUnion(["did:ng:o:p"]))[0]!;
const s = (await readDocumentSubjects("did:ng:o:p"))[0]!;
expect(s.subject).toBe("did:ng:o:p");
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
});
test("readUnion returns [] for an empty doc set (no query)", async () => {
test("a document that holds nothing is READ, answers, and returns []", async () => {
// The surviving half of the old "empty doc set" case, and the one that matters: an
// empty ANSWER is not a failure, and the query really was issued to obtain it. A caller
// deciding "absent versus unreadable" depends on exactly that difference.
const ng = inject({});
const subjects = await readUnion([]);
expect(subjects).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
expect(await readDocumentSubjects("did:ng:o:empty")).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
test("a doc that fails to read is skipped, not aborting the batch", async () => {
test("a read that FAILS throws, rather than reading as an empty document", async () => {
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
const orig = ng.sparql_query;
// Make the anchored read throw for the bad doc only.
@@ -107,49 +138,62 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
return orig(sid, query, base, anchor);
}) as any;
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
// The bad doc failed its read but the good one still lists.
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
// The batch used to swallow this one and carry on with the good document. There is no
// "carry on" left, so the failure reaches the caller as a failure — which is the ONLY
// thing that keeps it distinguishable from a document that is genuinely empty.
await expect(readDocumentSubjects("did:ng:o:bad")).rejects.toThrow(/RepoNotFound/);
// The neighbour is unaffected: nothing is shared between two reads.
expect((await readDocumentSubjects("did:ng:o:ok")).map((s) => s.subject)).toEqual([
"did:ng:o:ok",
]);
});
// The possession gate, at the read-model's own level: once ANY cap exists, a doc
// whose cap is not in what the current holder holds is dropped — however well its
// NURI resolves. Before the first cap the gate is inert (no regression).
test("readUnion drops a doc whose cap the holder does not hold", async () => {
// The possession gate: once ANY cap exists, a document whose cap is not in what the
// current holder holds is REFUSED — however well its NURI resolves. Before the first cap
// the gate is inert (no regression).
test("a document whose cap the holder does not hold is refused, out loud", async () => {
inject({
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
});
const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"];
const mine = "did:ng:o:mine" as Nuri;
const theirs = "did:ng:o:theirs" as Nuri;
// Inert: no cap issued yet → everything flows through.
expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both);
expect((await readDocumentSubjects(mine)).map((s) => s.subject)).toEqual([mine]);
expect((await readDocumentSubjects(theirs)).map((s) => s.subject)).toEqual([theirs]);
// One cap issued → possession is now the rule for every document.
// One cap issued → possession is now the rule for every document. BEHAVIOUR CHANGE:
// the batch dropped the unheld document silently and answered with the rest; a single
// read has no rest, so `docs.sparqlQuery` refuses it the way it always has — loudly.
setCurrentUser("alice");
getCaps().mint("did:ng:o:mine");
expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]);
getCaps().mint(mine);
expect((await readDocumentSubjects(mine)).map((s) => s.subject)).toEqual([mine]);
await expect(readDocumentSubjects(theirs)).rejects.toThrow(/does not hold this document/i);
// …and for every holder: bob holds nothing, so bob reads nothing.
setCurrentUser("bob");
expect(await readUnion(both)).toEqual([]);
await expect(readDocumentSubjects(mine)).rejects.toThrow(/does not hold this document/i);
});
test("readUnion tolerates holes in the list, and refuses a malformed reference", async () => {
// Two different things that must not be conflated, and conflating them broke a whole
// reconnect run: an EMPTY entry is absence — a scope index can carry one, and a caller
// assembling a list from optional values should not have to compact it — while a
// non-reference is a caller mistake worth a loud error. Validating before filtering
// turned the first into the second.
test("a malformed reference is refused, loudly", async () => {
// A non-reference is a caller mistake worth a loud error, never an empty read: swallowing
// it is how a mistake becomes an absence nobody can explain. The list tolerance this
// used to sit beside went with the batch — there is no list to carry a hole any more.
inject({});
await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]);
await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i);
await expect(readDocumentSubjects("not-a-nuri" as Nuri)).rejects.toThrow(
/not a NextGraph reference/i,
);
});
// ── several subjects inside one document ───────────────────────────────────
@@ -167,7 +211,7 @@ function injectTriples(triplesByDoc: Record<string, Array<[string, string, strin
sparql_update: mock(async () => undefined),
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
if (anchor === undefined) {
throw new Error("read-model must NEVER run an anchorless (union) query");
throw new Error("read-document must NEVER run an anchorless (union) query");
}
const triples = triplesByDoc[anchor as string];
if (!triples) return { results: { bindings: [] } };
@@ -183,10 +227,12 @@ function injectTriples(triplesByDoc: Record<string, Array<[string, string, strin
}),
};
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng, useShape: () => undefined });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
return ng;
@@ -203,7 +249,7 @@ test("two subjects in ONE document come back as two entries, unmixed", async ()
],
});
const subjects = await readUnion(["did:ng:o:d"]);
const subjects = await readDocumentSubjects("did:ng:o:d");
// Under the old per-document fold this was ONE entry subject'd `did:ng:o:d`,
// whose `${FP}title` carried BOTH ["A","B"] — two entities conflated into one bag,
@@ -215,34 +261,31 @@ test("two subjects in ONE document come back as two entries, unmixed", async ()
const bob = subjects.find((s) => s.subject === "urn:app:bob")!;
expect(alice.props[`${FP}title`]).toEqual(["A"]);
expect(bob.props[`${FP}title`]).toEqual(["B"]);
// Both were read from the same document, and `graph` is the reference the caller
// passed — that is what identifies the document and what goes back into the SDK.
expect(alice.graph).toBe("did:ng:o:d");
expect(bob.graph).toBe("did:ng:o:d");
});
test("a subject written under an IRI of its own is NOT re-labelled with the doc NURI", async () => {
injectTriples({
"did:ng:o:d": [["urn:app:thing", `${FP}title`, "T"]],
});
const [only] = await readUnion(["did:ng:o:d"]);
const [only] = await readDocumentSubjects("did:ng:o:d");
// The old fold reported `did:ng:o:d` here, a subject the document never carried.
expect(only!.subject).toBe("urn:app:thing");
expect(only!.graph).toBe("did:ng:o:d");
});
test("the SAME subject IRI in two documents stays two entries, told apart by graph", async () => {
test("the SAME subject IRI in two documents is never merged - two reads, two answers", async () => {
injectTriples({
"did:ng:o:a": [["urn:app:shared", `${FP}title`, "in-a"]],
"did:ng:o:b": [["urn:app:shared", `${FP}title`, "in-b"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
expect(subjects.length).toBe(2);
expect(subjects.map((s) => s.graph).sort()).toEqual(["did:ng:o:a", "did:ng:o:b"]);
// Never merged across documents: an object is identified by its subject WITHIN a graph.
expect(subjects.find((s) => s.graph === "did:ng:o:a")!.props[`${FP}title`]).toEqual(["in-a"]);
expect(subjects.find((s) => s.graph === "did:ng:o:b")!.props[`${FP}title`]).toEqual(["in-b"]);
const a = await readDocumentSubjects("did:ng:o:a");
const b = await readDocumentSubjects("did:ng:o:b");
// Two documents carrying one subject IRI are two objects. The batch had to say so with
// a `graph` field on every entry; a read that names ONE document says it structurally,
// because nothing of one read's answer can reach the other's.
expect(a.map((s) => s.subject)).toEqual(["urn:app:shared"]);
expect(b.map((s) => s.subject)).toEqual(["urn:app:shared"]);
expect(a[0]!.props[`${FP}title`]).toEqual(["in-a"]);
expect(b[0]!.props[`${FP}title`]).toEqual(["in-b"]);
});
test("machinery is dropped per subject, and the real subjects beside it survive", async () => {
@@ -252,7 +295,7 @@ test("machinery is dropped per subject, and the real subjects beside it survive"
["urn:app:entity", `${FP}title`, "kept"],
],
});
const subjects = await readUnion(["did:ng:o:d"]);
const subjects = await readDocumentSubjects("did:ng:o:d");
expect(subjects.map((s) => s.subject)).toEqual(["urn:app:entity"]);
expect(subjects[0]!.props[`${FP}title`]).toEqual(["kept"]);
});
@@ -263,5 +306,5 @@ test("a document holding ONLY machinery yields no entry at all", async () => {
["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"],
],
});
expect(await readUnion(["did:ng:o:d"])).toEqual([]);
expect(await readDocumentSubjects("did:ng:o:d")).toEqual([]);
});
@@ -31,7 +31,7 @@
*/
import { test, expect, describe, mock, afterAll, beforeEach } from "bun:test";
import { configure, docs, storeRegistry, readUnion } from "../src/index";
import { configure, docs, storeRegistry } from "../src/index";
import {
configureStoreRegistry,
setCurrentUser,
@@ -56,6 +56,25 @@ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/** The application's own predicate — the note's title, in the document it created. */
const TITLE = "urn:test:title";
/**
* The titles `doc` carries, read the way an APPLICATION reads one document since
* `1.0.0-dev.8`: the published anchored `docs.sparqlQuery`, naming the document. It
* replaces `readUnion([doc])`, which was the same anchored query with a batch around it.
*/
async function titlesIn(doc: Nuri): Promise<string[]> {
const res = await docs.sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"readDoc",
);
const rows = (res as { results?: { bindings?: Array<Record<string, { value: string }>> } })
.results?.bindings ?? [];
return rows.filter((r) => r["p"]?.value === TITLE).map((r) => r["o"]!.value);
}
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
// --- the fake broker -------------------------------------------------------
@@ -354,9 +373,11 @@ describe("after a reload, the creator comes back to its own document", () => {
});
}
// Shape 2 — listed, but does reading it answer? `readUnion` is the library's own
// listing read, and it DROPS a document whose cap the reader does not hold: a failure
// that arrives as an absence, which is the family this project has been bitten by.
// Shape 2 — listed, but does reading it answer? The anchored `docs.sparqlQuery` is what
// an application reads one document with, and it REFUSES a document whose cap the reader
// does not hold. It did not always: until `1.0.0-dev.8` the published read for this was
// `readUnion`, which DROPPED such a document — a failure arriving as an absence, which
// is the family this project has been bitten by, and half the reason that call went.
for (const scope of ALL_SCOPES) {
test(`[${scope}] reading it by reference answers its content`, async () => {
const quads: Quad[] = [];
@@ -367,8 +388,7 @@ describe("after a reload, the creator comes back to its own document", () => {
// The application kept the reference (a route, a deep link) and reads it directly,
// without listing its store first.
const subjects = await readUnion([note]);
expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
expect(await titlesIn(note)).toEqual(["the note"]);
});
}