docs: declarer ce que ng-helpers consomme, et publier les deux declarations
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
---
|
||||
type: contract
|
||||
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
|
||||
pulled_from: git@gitea.reconnexion.apps.gueraud.net:Reconnexion/ng-eventually.git/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md
|
||||
pulled_version: d44063961682f84d4b804e5b349cd0a791dd9550
|
||||
pulled_at: 2026-09-21
|
||||
---
|
||||
|
||||
# contract_polyfill-surface — `@ng-eventually/polyfill`
|
||||
|
||||
## Scope
|
||||
|
||||
This package is a polyfill of NextGraph's SDK.
|
||||
|
||||
This package covers placement (creating an application's documents in one of its three stores), reading (a document's subjects, one-shot or reactive, by shape), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
|
||||
|
||||
### Deployment requirements
|
||||
|
||||
An application using this package must:
|
||||
|
||||
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
|
||||
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first;
|
||||
- **declare this package exactly once**, so that everything in the application resolves to one copy of it — see the single-copy rule under `## Non-guarantees`, which is the one packaging mistake this surface cannot protect you from.
|
||||
|
||||
**Obtaining it.** This package is not published to a public registry, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and the rules below.
|
||||
|
||||
## Surface
|
||||
|
||||
Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type is published only when a published signature uses it. The load-bearing signatures:
|
||||
|
||||
```ts
|
||||
// ── bootstrap ────────────────────────────────────────────────────────────
|
||||
export function configure(c: EventuallyConfig): void;
|
||||
export interface EventuallyConfig {
|
||||
ng: NgLike; // the `ng` object from @ng-org/web
|
||||
useShape: UseShapeLike; // `useShape` from @ng-org/orm
|
||||
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
|
||||
debugAccessLog?: boolean;
|
||||
init?: (...args: any[]) => any;
|
||||
initNg?: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
// ── identity — one await before the application renders ──────────────────
|
||||
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
|
||||
|
||||
// ── addressing ───────────────────────────────────────────────────────────
|
||||
export type Nuri = `did:ng:${string}`;
|
||||
export type NuriLike = Nuri | string;
|
||||
// The SDK's own scope, re-exported unchanged. It scopes a READ, on two axes and no third.
|
||||
export type Scope = { graphs?: string[] | string; subjects?: string[] };
|
||||
|
||||
// ── placement: where an application's documents live ─────────────────────
|
||||
// A store is named by its REFERENCE — one accessor per store, and `createEntityDoc` takes
|
||||
// one of them. Omit it and the document goes to the private store.
|
||||
export const storeRegistry: { // no identity parameter — a session is one user's
|
||||
privateStore(): Promise<Nuri>;
|
||||
protectedStore(): Promise<Nuri>;
|
||||
publicStore(): Promise<Nuri>;
|
||||
createEntityDoc(store?: NuriLike): Promise<Nuri>;
|
||||
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
|
||||
};
|
||||
|
||||
// ── reading ──────────────────────────────────────────────────────────────
|
||||
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
|
||||
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
|
||||
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
|
||||
// TWO positional arguments — the same pair `useShape` takes, and the same `Scope`.
|
||||
// `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in.
|
||||
export function useShapeQuery<T = UnionSubject>(shapeType: unknown, scope: Scope): ShapeObservable<T>;
|
||||
export interface ShapeObservable<T = UnionSubject> {
|
||||
getSnapshot(): ShapeQuery<T>; // stable reference until the state changes
|
||||
subscribe(onChange: () => void): () => void; // returns its own unsubscribe
|
||||
refetch(): void; // re-resolve and re-read now; never polls
|
||||
}
|
||||
export interface ShapeQuery<T = UnionSubject> {
|
||||
data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown;
|
||||
}
|
||||
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||
|
||||
// ── low-level document / SPARQL primitives ───────────────────────────────
|
||||
export const docs: {
|
||||
// `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`).
|
||||
// It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it
|
||||
// fails for real (`Deserialization error of session_id JsValue("1")`).
|
||||
docCreate(sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown): Promise<Nuri>;
|
||||
sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
||||
// Returns the commits the update produced, as upstream does (it typed this `void` until
|
||||
// 2026-08-14 while already relaying the value). A caller that ignores it is unaffected.
|
||||
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
||||
};
|
||||
|
||||
// ── inbox: giving to read, and depositing ────────────────────────────────
|
||||
export const inbox: {
|
||||
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
|
||||
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
|
||||
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
|
||||
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
|
||||
readForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||
readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
|
||||
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
|
||||
// an alias with no call site, and no counterpart upstream. Use `read`.
|
||||
};
|
||||
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
|
||||
|
||||
// ── the wrapped SDK objects ──────────────────────────────────────────────
|
||||
export const ng: NG; // call this instead of the `ng` passed to `configure`
|
||||
// `NG` is upstream's own type (`@ng-org/web`), 88 typed
|
||||
// members; it was `Record<string, any>` until 2026-08-14
|
||||
export function init(...args: any[]): any; // likewise — not the `init` passed to `configure`
|
||||
export function initNg(...args: any[]): any;
|
||||
```
|
||||
|
||||
## Guarantees
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
`urn:ng-eventually:` is reserved, and the reservation has two faces. In a document: triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned. In a deposit: two payload shapes are this package's own and it acts on them — a payload that IS a reference (a bare `did:ng:…` string and nothing else), and an object whose `kind` property is a string starting with `urn:ng-eventually:inbox:`. **Every other payload is yours, and is delivered to you untouched.**
|
||||
|
||||
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal 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.
|
||||
|
||||
Only a document's owner writes to it. Holding its read key never grants a write.
|
||||
|
||||
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
|
||||
|
||||
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
|
||||
|
||||
**You never resolve an inbox address, on either side.** You deposit by naming a document (`inbox.postToDocument`), and you read what was left on your own by naming it too — `inbox.readForDocument(doc)` at any time, or `inbox.readSyncedForDocument(doc)` on a page that has just loaded. The second is the one to call when an empty answer has to MEAN empty: a session that has just loaded has synced neither the document nor its inbox, and an unsynced read of either comes back empty with no error — so the ungated form can answer `[]` for a document whose inbox holds messages. `readSyncedForDocument` waits for both before answering. `inbox.readSynced` is the same guarantee on an inbox you already hold the address of, which no application does: it takes an address, so nothing here hands you one.
|
||||
|
||||
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
|
||||
|
||||
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, it throws instead of returning, and a rejected call must not be rendered past — the interface would show an empty account rather than an empty screen. A single queue that could not be drained is reported and does not reject: reaching your queues is infrastructure and must succeed, applying one deposit is data and must never cost you the session. So a resolved call means your own capabilities are restored; it does not promise that every deposit waiting for you has been applied, and those that were not stay in their queue.
|
||||
|
||||
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it stays down, provided the identifier reached the other side. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
|
||||
|
||||
**What decides which identity you get.** No call takes an identifier — not `configure`, not `init`, not `ensureIdentity` — so an application never chooses one, never keeps one, and never hands one over. It is settled once per page load, from the page itself, and the answer depends on which side of the broker round-trip is asking:
|
||||
|
||||
- **Before the round-trip, on your own top-level page — the barrier decides.** Whatever is already known fills the field, and the person may change it; the identity is the value they confirm. Confirming publishes that value into the address bar as `?ng-id=` and records it in this browser.
|
||||
- **After the round-trip, on the page the broker loads back — `?ng-id=` decides**, and the barrier stays down. The parameter also wins over anything the browser remembers, on either side: it is read first, and reading it replaces what was remembered. Arrive with neither — a URL that dropped the parameter, and a partition that remembers nothing — and the barrier asks on that side too.
|
||||
- **Failing both — whatever this browsing context last recorded.** This is the only path on which an identity is adopted with nobody confirming it. If the address bar lost `?ng-id=` while a *different* identifier was on record here, that different identity is adopted, and nothing is raised anywhere; if nothing was on record either, the barrier asks again on that side too.
|
||||
|
||||
**`?ng-id=` exists because it is the only thing that crosses.** Your page before the round-trip and your page after it sit in two separate storage partitions — nothing the browser remembers on one side is visible on the other, and the address bar is the sole channel between them. The package writes the parameter itself, without navigating. **An application that owns its URL must let it survive**: a router that drops query parameters it does not know, or a redirect that rebuilds the URL, sends the round-trip off without the identifier, and the consequence is the silent mis-identification above rather than an error. In a context that can neither write the address bar nor use storage, the barrier simply asks on both sides.
|
||||
|
||||
**Being remembered is a prefill, never a decision.** A top-level reload asks again every time, with the field already filled — one click, no typing. A remembered identifier is therefore not a signed-in state, and two tabs, two browsers or two devices do not share one: each keeps its own record, and only a URL carrying `?ng-id=` puts a second context under the same identity.
|
||||
|
||||
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
|
||||
|
||||
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
|
||||
|
||||
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
|
||||
|
||||
**A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
|
||||
|
||||
**A read is scoped by document and by subject, never by store.** `Scope` has two axes and there is no third: `graphs` names one document's graph per entry — `did:ng:i` or `""` for the whole local dataset, `[]` or `undefined` for no objects at all — and `subjects` filters subject IRIs literally, with no prefix or hierarchy matching. There is no call that lists the documents in a store, and naming a store in `graphs` is not the question you meant: a store's graph holds its containment triples and nothing of the shape you asked for, so it answers empty rather than raising. That is the target's own shape, not a narrowing this package chose.
|
||||
|
||||
**A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
|
||||
|
||||
**The watching is in place by the time `ensureIdentity()` resolves**, whatever else that call made of its own work: a connection that could not restore something still rejects, and the identity it settled is watched all the same. It lasts exactly as long as that identity stays connected — changing identity or clearing it stops it, and whoever connects next is watched in their own right, so nothing of the previous one keeps applying.
|
||||
|
||||
**A deposit this package applies is delivered ONCE, and a failure is not retried.** It is taken out of its inbox before the arm that handles it runs, so it is gone whether that arm succeeded or not — the same order the target has, where the broker removes a message from the queue as it hands it over and the verifier merely logs what it could not apply. A failure is reported on this package's own log stream (`console.error`, carrying this package's prefix), never gated by `debugAccessLog` — a diagnostic may be opt-in, a failure may not — and then the next deposit is processed. **Do not count on a failed deposit being applied later: there is no retry, no dead-letter queue and no way to ask what was lost.** If an arrival matters to your application, the record you keep of it must be your own. What this buys is the end of the opposite behaviour: an inbox that grew for ever, and one malformed deposit re-reported at every arrival and every connection for the life of the session.
|
||||
|
||||
**An unapplicable item and a failed one are not the same event.** A deposit this package cannot make sense of is reported and the next one is processed — one malformed item never holds up the queue behind it. A deposit whose handling could not be COMPLETED is different: the drain STOPS, the inbox is reported, and everything not yet taken out is preserved. Only the deposit in flight is lost. So a failing broker costs one deposit per attempt, not the whole queue, and a drain that stopped is retried by your next `ensureIdentity()`.
|
||||
|
||||
**A reference this package cannot read counts as failed, not as absent** — including a document you hold no capability for. That is deliberate and it costs latency: such a deposit stops one drain, and the deposits behind it land on the next connection instead of this one. The alternative was worse and was measured: treating "cannot read it" as "it is not there" destroyed deposits on a transient outage, and destroyed the re-deposits sent to repair them, because a failed capability fetch is remembered for the session. Only a document actually queried, from a synced repo, that still reads empty is called absent. **Consequence to plan for:** a queue holding many references you cannot read drains one per connection.
|
||||
|
||||
**`inbox.processInbox` now REJECTS on a broker failure**, where it used to resolve — including when a document a deposit referenced could not be READ. A referenced document that is merely absent is a different case: that is reported and the queue carries on. Awaiting it, handle the rejection: the deposit it was applying at that moment is already consumed, the rest of that inbox is intact, and calling again resumes from the next one. Connection-time draining is unaffected — `ensureIdentity()` reports such a queue and still grants the session, exactly as before.
|
||||
|
||||
**Failing to apply one inbox still denies nothing else.** The other inboxes were never involved, the session is not refused, and deposits this package does not claim — your own payloads — are untouched and stay readable through `inbox.read` and `inbox.watch`.
|
||||
|
||||
**Any number of subscriptions on one document coexist.** Opening a document, watching an inbox and following a scope no longer silence one another. Until 2026-08-17 a second `subscribeDoc` on a document killed the first, silently — nothing rejected, the first caller's unsubscribe still appeared to work, and what an application saw was a view that stopped re-reading and an inbox that stopped notifying, with no trace anywhere near the cause. A subscriber that joins a document somebody else already opened is handed the initial `State` its own subscription would have pushed it, so joining late is not the same as never firing; and unsubscribing silences that caller and no other, including when it happens from inside a push.
|
||||
|
||||
## Non-guarantees
|
||||
|
||||
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
|
||||
|
||||
**Naming an identity proves nothing about who named it.** Any visitor may type any identifier at the barrier, or arrive on a URL that already carries one, and act as that identity — the wallet and its password are handed out on the barrier itself. The identifier is a choice of space, never a proof: anyone who knows one can act as it, so do not treat `ensureIdentity()`'s answer as an authenticated subject.
|
||||
|
||||
**No live read through `useShape` against a deployed broker.** The reactive subscription it opens is dispatched by method name at two hops below this package, and a broker deployed before that name last changed does not recognise it: the read never starts, its set stays empty, its readiness never settles, and one console error is the only trace — indistinguishable from "this scope is empty". The cause is upstream in NextGraph and the remedy is a broker redeployment; nothing in this package can work around it. Use `useShapeQuery` for a reactive read and `readUnion` for a one-shot one — neither goes through that path.
|
||||
|
||||
**No revocation.** `inbox.share` cannot be undone.
|
||||
|
||||
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
|
||||
|
||||
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
|
||||
|
||||
**No mailbox model.** Do not build on the raw deposit list.
|
||||
|
||||
**No cross-broker reference.** A returned reference resolves for users of the same broker.
|
||||
|
||||
**`openDocumentInbox` does not coalesce across PAGES.** Two tabs — or two sessions of the same person — that open the same document's inbox in the same moment can each create one, and the document is left with two: its owner drains one while deposits arrive in the other. Nothing raises, nothing reports it, and neither page can detect it afterwards. It is not an oversight to be patched later: a branch MERGES records rather than refusing the second, so there is no "create only if absent" to build the guarantee on, and the address a depositor reads is a separate record from the one the owner resolves — so the two cannot even be made to agree on which of the pair won. Open a document's inbox from one place: the page that creates the document, or one call the rest of the interface waits on.
|
||||
|
||||
**TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
## Change policy
|
||||
|
||||
**Semver, and majors are the normal case.** This surface converges on a NextGraph that does not ship yet, so most steps toward the target remove or narrow something — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
|
||||
|
||||
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), or it reports a state you did not have to handle before (`useShapeQuery` publishing `isError` where it used to publish a synced-empty snapshot). 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.5`), 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.5` 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.5`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.5`, the string you pinned, never the version it is heading for.
|
||||
|
||||
`1.0.0-dev.5` is the largest **major** of the line so far: it renames one call, changes the parameter of two, and removes three. Every change is one step toward the surface NextGraph will ship, so none of them is reversible by asking. Symbol by symbol, with what replaces it:
|
||||
|
||||
- **`watchShape(shapeType, scope)` → `useShapeQuery(shapeType, scope)`.** The name is the target's own; `watchShape` appears nowhere in NextGraph and spends a verb the ORM already uses for signal observation. Everything you read back is unchanged — the same `ShapeObservable`, the same `ShapeQuery` snapshot, the same three states.
|
||||
- **`Scope` is no longer `"public" | "protected" | "private"`.** The published `Scope` is now the SDK's own, `{ graphs?: string[] | string; subjects?: string[] }`, re-exported from `@ng-org/orm`, and it is what `useShapeQuery` takes. `watchShape(Shape, "protected")` becomes `useShapeQuery(Shape, { graphs: "did:ng:i" })` for everything you hold, or `useShapeQuery(Shape, { graphs: [doc1, doc2] })` for named documents. **There is no translation for "the documents in my protected store"**: that question is not expressible against the target, and the three strings were this package's own way of asking it.
|
||||
- **`storeRegistry.createEntityDoc(scope)` → `createEntityDoc(store?)`.** `store` is a reference, not a word: `await storeRegistry.protectedStore()`, `publicStore()` or `privateStore()`. Omitting it places the document in the private store, exactly as `doc_create` does when no store is given. `createEntityDoc("protected")` becomes `createEntityDoc(await storeRegistry.protectedStore())`.
|
||||
- **`storeRegistry.listMyEntityDocs(scope)` is REMOVED, with no replacement call.** There is no `doc_list` upstream, at any level. What replaces it is the read itself: ask `useShapeQuery` for a shape over `{ graphs: "did:ng:i" }` and what you hold of that shape comes back. An application that listed its documents in order to read them was doing in two steps what the read does in one — but it must now write something the shape can match, typically an `rdf:type` triple, since nothing indexes a document for you.
|
||||
- **`storeRegistry.resolveScopeGraph(scope)` is REMOVED.** It handed out a value whose stated use was a read scope, and a store's graph holds its containment triples and nothing else — so pointing a shape query at it matches nothing and says so with an empty set, silently. If you were using it as a write anchor, use the store's reference from the accessor of the same name.
|
||||
- **`storeRegistry.resolveWriteGraph(scope)` is REMOVED.** It returned the very reference `privateStore()` / `protectedStore()` / `publicStore()` return. One value, one published name.
|
||||
|
||||
`1.0.0-dev.4` before it narrowed a **guarantee** and added a rejection: a deposit this package applies is now consumed, so a failure that used to be retried at the next arrival or the next connection is now final and reported once; and `inbox.processInbox` rejects on a broker failure where it used to resolve. An application that leaned on the retry — however implicitly — has to keep its own record instead, and one that awaited `processInbox` without a `catch` now has a rejection to handle. `1.0.0-dev.3` before that narrowed an existing call the same way: two payload shapes an application could carry freely became this package's own, and one it cannot apply is reported. All of them land inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
|
||||
|
||||
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|
||||
@@ -1,59 +1,76 @@
|
||||
---
|
||||
type: usage
|
||||
summary: The nine polyfill entries the indexing layer stands on, the constraints it holds itself to, and what it had to build for want of a published helper
|
||||
against: "@ng-eventually/polyfill@1.0.0-dev.1"
|
||||
summary: The polyfill entries the indexing layer and its end-to-end application stand on - placement, one anchored write, the deposit that becomes an entry, and the one-shot read
|
||||
against: "@ng-eventually/polyfill@1.0.0-dev.5 (pulled d44063961682f84d4b804e5b349cd0a791dd9550)"
|
||||
---
|
||||
|
||||
# usage_ng-helpers — `@ng-helpers/indexing` on `polyfill-surface`
|
||||
# usage_ng-helpers - `@ng-helpers/indexing` on `polyfill-surface`
|
||||
|
||||
The consumer is the indexing layer: an index is an ordinary public document, contributions reach it through its inbox, and its owner's session curates it by going through that inbox. NextGraph has no indexing concept, so nothing of what a deposit *means* belongs upstream — the polyfill's inbox stays generic and carries opaque payloads, and this layer decides what they say.
|
||||
The consumer is the indexing layer: an index is an ordinary public document that declares a field, anyone may hand it a reference by depositing into it, and its entries appear because the layer below applies that deposit. NextGraph has no indexing concept, so nothing of what a deposit MEANS belongs upstream - the polyfill's inbox stays generic and carries an opaque payload.
|
||||
|
||||
The whole runtime dependency passes through **one file**, the adapter that builds our `NextGraphPort`. Everything else in the package is written against that port, so a change to the engagement breaks exactly one file and nothing else. The types are imported type-only, so they are literally the published ones rather than a copy that can drift.
|
||||
Two callers here, and they consume different things. The **library** (`src/`) reaches the polyfill from exactly ONE file, `src/polyfill-adapter.ts`: everything else is written against an internal port, so a change to the engagement breaks that one file and nothing else. The **end-to-end application** (`e2e/indexing-app.ts`, `e2e/bridge.ts`) is written the way a consumer of `@ng-helpers/indexing` writes one, and consumes the polyfill directly for its bootstrap and for reading - which is the point: if something is awkward there, it is awkward for every application.
|
||||
|
||||
## Consumed surface
|
||||
|
||||
**Placement** — `storeRegistry.createEntityDoc("public")`, for the index document itself; `storeRegistry.listMyEntityDocs("public")`, to find again, at each connection, the indexes this identity owns; `storeRegistry.openDocumentInbox(doc)`, at creation to open one and afterwards to resolve its address.
|
||||
**Library - placement, write, inbox** (`src/polyfill-adapter.ts`)
|
||||
|
||||
**Reading** — `readUnion([doc])` and the type `UnionSubject`. Used both for the index document and for resolving a deposited reference.
|
||||
- `storeRegistry.createEntityDoc(...)` - the index document, in its creator's public store. **The call site still passes the string `"public"`**, the argument form this engagement withdrew; moving it to the store reference (`await storeRegistry.publicStore()`) is ours to do, and until it is done this one call does not hold against the version pinned above.
|
||||
- `storeRegistry.openDocumentInbox(doc)` - called once, at creation, under the owner. The address it returns is deliberately dropped.
|
||||
- `docs.sparqlUpdate(sessionId, update, anchor)` - the only write this package makes, always an anchored `INSERT DATA` of one triple.
|
||||
- `inbox.postToDocument(doc, { payload })` - the deposit, where the payload IS a bare `did:ng:` reference.
|
||||
- Types `Nuri` and `NuriLike`, type-only, so they are literally the published ones rather than a copy that can drift.
|
||||
|
||||
**Writing** — `docs.sparqlUpdate(sessionId, update, anchor)`, with the document named ONCE as the anchor so the statement carries no `GRAPH <…>` wrapper. It is the only write this package makes, and it is always an `INSERT DATA` of literal triples.
|
||||
**End-to-end application - bootstrap and reading** (`e2e/indexing-app.ts`, `e2e/bridge.ts`)
|
||||
|
||||
**Inbox** — `inbox.postToDocument(doc, { payload })` for depositing, `inbox.readForDocument(doc)` for the owner going through it, `inbox.watch(address, onDeposits)` so that a deposit made while the owner is connected is applied as it lands, and the shape of `Deposit` (`from` / `payload` / `ts`), which our `IncomingDeposit` mirrors.
|
||||
- `configure({ ng, useShape, init, sharedWallet })`, this package's own `init(callback, true, [])` for the `session_id` its callback delivers, and `ensureIdentity()`.
|
||||
- `readUnion([doc])` and the type `UnionSubject` - how an index is read, and how a referenced document is read. See `## Needs`.
|
||||
- `docs.sparqlQuery(sessionId, query, undefined, anchor)` - an anchored SELECT, as the second of the two ways anyone reads an index.
|
||||
- `storeRegistry.listMyEntityDocs("public")` - **withdrawn by this engagement**, and still called in three places here. What it was asked is in `## Needs`.
|
||||
|
||||
**Types** — `Nuri`, `NuriLike`, `PrincipalId`, `UnionSubject`.
|
||||
|
||||
**Bootstrap, in the end-to-end application only** — `configure`, this package's `init` (for the `sessionId` its callback delivers), and `ensureIdentity`.
|
||||
|
||||
Everything else on the engagement is offered and NOT consumed: `watchShape`, `useShape`, `subscribeDoc`/`subscribeDocs`, `docs.docCreate`, `docs.sparqlQuery`, `storeRegistry.resolveScopeGraph`/`resolveWriteGraph`, `inbox.share`/`post`/`read`/`readSynced`/`readSyncedForDocument`/`processInbox`, `ng`, `initNg`. It is safely evolvable as far as this layer is concerned.
|
||||
**Not consumed**, and safely evolvable as far as this repository is concerned: `useShapeQuery`, `useShape` (a stub is handed to `configure` and never called), `subscribeDoc` / `subscribeDocs`, `docs.docCreate`, the three store accessors, `inbox.share` / `post` / `read` / `readForDocument` / `readSynced` / `readSyncedForDocument` / `processInbox` / `watch`, `ng`, `initNg`, and the types `Scope`, `ShapeObservable`, `ShapeQuery`, `Deposit` and `PrincipalId`.
|
||||
|
||||
## Constraints
|
||||
|
||||
**One port is one identity.** The polyfill's session is one user's and no call takes an identifier, so an `Indexing` handle is one person's. Two users mean two handles — which is also what keeps our multi-actor tests honest: a depositor obtains the index NURI the way an application does, never through a shared variable.
|
||||
**A bare reference deposited into a document's inbox becomes an entry on that document.** This is the single guarantee the whole layer rests on: `add` deposits a `did:ng:` reference and nothing else, precisely because that shape is this engagement's own and it acts on it. Nothing of indexing's meaning travels in the payload, and there is no second shape to fall back on.
|
||||
|
||||
**`sessionId` is relayed, never converted.** We carry it at the engagement's own `string | number` and hand it back untouched.
|
||||
**One handle is one identity.** The session is one user's and no call takes an identifier, so an `Indexing` handle is one person's. Two users mean two handles - which is also what keeps the multi-actor end-to-end journeys honest: a depositor obtains the index reference the way an application does.
|
||||
|
||||
**The write is add-only, and structurally so.** There is no delete builder anywhere in this package, and the only statement it can compose is an anchored `INSERT DATA`. Our own tests execute that SPARQL against an engine that refuses anything else, so a removal is unrunnable rather than merely undetected. This constrains what we ask of the engagement: we need exactly one write primitive and no more.
|
||||
**`sessionId` is relayed, never converted.** It is carried at the engagement's own `string | number` and handed back untouched.
|
||||
|
||||
**The inbox is opened at creation, from one place — and resolved, never opened, afterwards.** The engagement disclaims coalescing `openDocumentInbox` across pages, so the only call that can CREATE one is in `createIndex`, under its owner, at the moment the document is created. Every later call is on a document whose inbox already exists, where the same entry resolves the address instead; the engagement guarantees that idempotence within a page, which is the level our watching lives at.
|
||||
**The write is add-only, and structurally so.** There is no delete builder anywhere in this package and the only statement it can compose is an anchored `INSERT DATA`; the unit suite executes that SPARQL against an engine that refuses anything else, so a removal is unrunnable rather than merely absent. What this asks of the engagement is exactly one write primitive and no more.
|
||||
|
||||
**One session watches its own indexes, and nothing stops it.** `inbox.watch`'s unsubscribe is deliberately dropped: our watching lasts exactly as long as the identity is connected, which is what the engagement does with the inboxes it watches on its own account. It also means the inbox address never leaves the one adapter function that resolves it — everything above names a document.
|
||||
**The document is named once, as the anchor.** The statement carries no `GRAPH <...>` wrapper, because the anchor already scopes the write to that repo's default graph. Naming it twice would be two chances to disagree.
|
||||
|
||||
**Our watching relies on subscriptions coexisting on one document.** `inbox.watch` opens a `subscribeDoc` on the inbox document, and the engagement already watches every inbox this identity may read. Before subscriptions coexisted, one of those two would have silenced the other with nothing raised anywhere. Verified in the resolved copy: the fan-out holds a set of listeners.
|
||||
**A document's inbox is opened from ONE place** - `create`, under the owner, at the moment the document is created - and no address is held anywhere above that call. The non-guarantee about `openDocumentInbox` not coalescing across pages is therefore respected by construction rather than by care.
|
||||
|
||||
**Every payload out of an inbox is untrusted input.** Anyone may deposit anything, so nothing read from a deposit reaches a query before being checked; a non-reference is reported, never thrown on.
|
||||
**This package declares the polyfill a PEER dependency**, so the application above remains the one place it is named. The single-copy rule is the one packaging mistake the surface cannot protect anyone from, and a library that declared it a direct dependency would be manufacturing exactly that.
|
||||
|
||||
**An empty read is never treated as "empty".** Nothing in this layer reads `[]` from `readUnion` as "a valid index that happens to hold nothing" — the descriptor check refuses a document declaring no field, and that refusal aborts curation before a single write.
|
||||
**Every NURI is validated before it reaches a query or a deposit**, because no type guard is published (see `## Frictions`).
|
||||
|
||||
**This layer stays out of `urn:ng-eventually:` entirely.** Its predicates are `urn:ng-helpers:` and the subjects it writes are always `did:ng:` references, so nothing it writes falls under the reserved prefix and nothing it writes is dropped on read.
|
||||
|
||||
**An empty read is never concluded from.** Where the end-to-end suite reads an index it polls to a bound and reports what the index held instead, rather than reading once and calling an empty answer an answer.
|
||||
|
||||
**The identity `ensureIdentity()` returns is opaque here** - checked for non-emptiness, carried, printed in a run report, never parsed or rendered as a name.
|
||||
|
||||
## Frictions
|
||||
|
||||
**The engagement does not say what `readUnion` does with a document it cannot read.** It says what it returns for a document it can, and it says that a rejection means "unknown, never absent" — but not whether an unreadable document inside the list comes back as a rejection or is swallowed into the result. We assume the worst (swallowed, therefore indistinguishable from empty) and code defensively around it. A sentence in `## Guarantees` settling this would replace a guess we are carrying in every read path.
|
||||
**The acquisition channel carries no version, so the Change policy has nothing to bite on.** The engagement asks for a pinned version and says the channel by which the source reaches an application is agreed with that application; nothing has been agreed here. This repository reaches the package as a filesystem link and declares the peer range `*`, so what it compiles against is whatever branch happens to be checked out at the other end - and the `ref:` pinned for this contract is a branch, for the same reason. The `against:` line above is the only pin that actually exists on this side. A tag-addressable, fetchable source would turn the whole semver discipline from a description into a mechanism.
|
||||
|
||||
**No published NURI type guard.** `## Guarantees` states plainly that no type guard is published, so this layer carries its own — and it needs one, because a NURI arrives here from an untrusted inbox deposit and must be checked before it can be written between angle brackets. The check we wrote is a guess at what the engagement considers a valid `Nuri`, and a wrong guess is either a rejected legitimate reference or an injected one.
|
||||
**No published NURI type guard.** `## Guarantees` says so plainly, so this layer carries its own. It is a guess at what the engagement considers a valid `Nuri`, and a wrong guess either rejects a legitimate reference or lets one through into a query.
|
||||
|
||||
**No published escaping helpers.** The engagement lists no `escapeIri`/`escapeLiteral`, and we compose SPARQL against `docs.sparqlUpdate` — so this package carries its own escaping rather than reach into the provider's internals. That is a security-relevant duplication of something the provider certainly already has: two implementations of the same rule, one of which is not the one the provider tests.
|
||||
**No published escaping helpers.** The engagement lists no `escapeIri` / `escapeLiteral`, and this package composes SPARQL against `docs.sparqlUpdate` - so it carries its own escaping rather than reach into the provider. That is a security-relevant duplication: two implementations of one rule, and the one running here is not the one the provider tests.
|
||||
|
||||
**Watching a document's inbox needs an address, and the only call that hands one out is the one that creates one.** The engagement is explicit that an application never resolves an inbox address, and it publishes `inbox.watch(address, …)` with no `watchForDocument(doc, …)` beside `readForDocument`. So the one thing an owner cannot avoid — being told about deposits on its own document — is also the one place this layer must hold an address, obtained from `openDocumentInbox`, whose other job is to create. A `watchForDocument(doc, onDeposits)` would close that gap and keep the "never resolve an address" rule whole.
|
||||
**No published way to write triples above the raw SPARQL primitive.** `docs.sparqlUpdate` is the level this layer had to align on, which is where the two frictions above come from. An "add these triples to this document" entry would delete the query composition, the escaping and the NURI validation from this package in a single move.
|
||||
|
||||
**The version declared and the version the guarantees describe disagree.** The resolved copy's `package.json` says `1.0.0-dev.1`, while the engagement dates the continuous inbox observation and the coexisting subscriptions to `1.0.0-dev.2`. The code has them, so nothing is broken; but a pin cannot express what we actually depend on, and `against:` above names the string we resolve rather than the one the guarantees belong to.
|
||||
**`readUnion` has no stated behaviour for a document it cannot read.** `## Guarantees` says what it returns for a document it can, and it says elsewhere that a rejection means "unknown, never absent" - but not whether an unreadable document inside the list comes back as a rejection or is swallowed into the result. The worst is assumed (swallowed, therefore indistinguishable from empty) and every read path here is written defensively around that guess. One sentence in `## Guarantees` would replace it.
|
||||
|
||||
**No published way to write triples above the raw SPARQL primitive.** `docs.sparqlUpdate` is the level we had to align on, which is why the two frictions above exist at all. A published "add these triples to this document" would remove the query composition, the escaping and the NURI validation from this layer in one move.
|
||||
**Everything now routed through `useShapeQuery` is out of reach of an application that does not wire the ORM.** This repository's application hands `configure` a `useShape` stub, because it reads through `readUnion`. That was a free choice under the previous surface; it is now the thing that puts the replacement for the withdrawn store enumeration out of reach, and it will put the replacement for any future one out of reach too.
|
||||
|
||||
## Needs
|
||||
|
||||
**A one-shot read of a named document's subjects that needs no ORM - `readUnion` as it stands, or something that answers the same question.** `@ng-helpers/indexing` publishes to its OWN consumers that an index is an ordinary document, read with `readUnion([index])` or an anchored SELECT and recognised by two IRIs; that sentence is in this package's published entry point, and the application above is written to it. This engagement itself names `readUnion` the one-shot read, and names it the remedy for the reactive path that a deployed broker does not answer. Withdrawing it removes the read this layer tells every consumer to use, and leaves the anchored SELECT as the only fallback - which puts every reader of an index back on raw SPARQL, which is where the three frictions above come from. If it has to go, what is needed in its place is a one-shot, non-reactive read of the subjects of a document whose reference is already held, with no ORM wired and no shape declared.
|
||||
|
||||
**A way to observe which documents an identity's public store contains.** The withdrawn `listMyEntityDocs` had exactly one use here and it is an observation, not a business read: after injecting a failure into the inbox step of `create`, the suite asks whether a document nobody named was left behind in the store. No reference for that document is held - discovering it IS the question - so the stated replacement does not reach it: a shape query is not scoped to a store, it requires the document to carry something a shape matches (an index document carries one `urn:ng-helpers:` predicate and no type triple), and it needs an ORM this application does not wire. The Change policy's reason for the removal is understood and the shape of an answer is the provider's to choose; what is stated here is the question left unanswerable.
|
||||
|
||||
**A way to tell "examined and discarded" from "not examined yet".** A deposit this engagement applies is consumed whether or not it produced an entry, and a refusal goes to the provider's own log stream - nothing an application can observe, and by the engagement's own words there is no way to ask what was lost. The cost here is concrete: the journey that checks that an object carrying nothing for an index's field is NOT indexed cannot be waited for, because no reading distinguishes "the deposit was examined and skipped" from "the deposit has not been examined yet" - and this repository's suite says so in its own header rather than hiding it, declaring that check weaker than its name. Anything observable from the application - a per-deposit outcome, or merely a count that moves when a queue has been drained - would make it a real one. This layer holds nothing of its own on purpose: an index only ever grows, and the reference is the whole of what is deposited.
|
||||
|
||||
Reference in New Issue
Block a user