Files
ng-eventually/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md

29 KiB

type, summary
type summary
contract The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer

contract_polyfill-surface — @ng-eventually/polyfill

Scope

This package is a polyfill of NextGraph's SDK.

This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), 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:

// ── 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;
export type Scope    = "public" | "protected" | "private";

// ── placement: where an application's documents live ─────────────────────
export const storeRegistry: {          // no identity parameter — a session is one user's
  createEntityDoc(scope: Scope): Promise<Nuri>;
  listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
  resolveScopeGraph(scope: Scope): Promise<Nuri>;
  resolveWriteGraph(scope: Scope): 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. `ShapeQuery` is what you
// READ BACK (the snapshot), never what you pass in.
export function watchShape<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 listMyEntityDocs, 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 does not clear on its own the way an unreachable inbox does: a deposit is not consumed, so the same refusal is reported again at every arrival on that inbox and at every connection, for as long as the deposit sits there. Carry your own data in an object with a kind of your own — { kind: "join", … } is yours and stays yours — and never as a bare reference on its own.

Only a document's owner writes to it. Holding its read key never grants a write.

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. watchShape 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.

The same rule reaches what a call hands BACK, not only what it looked up first: listMyEntityDocs returns a listing whose documents you can open, or it throws. It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same Nuri[], and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.

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 watchShape 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.

Failing to apply one inbox denies nothing. It is reported on this package's own log stream (console.error, carrying this package's prefix) and never gated by debugAccessLog — a diagnostic may be opt-in, a failure may not. The deposit stays in its queue, so the next arrival on that inbox, or the next connection, applies it; the other inboxes were never involved, and nobody is refused anything.

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 watchShape 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 and inbox.materialize all left this way), or an existing call narrows: it now throws where it returned (listMyEntityDocs refuses a listing whose documents you could not open; ensureIdentity rejects rather than resolve on an incomplete restore), or it reports a state you did not have to handle before (watchShape 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.3), which sorts below that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.

Tags carry the package name, because this repository publishes more than one engagement and their versions move independently: polyfill/v1.0.0-dev.3 is this package, ng-e2e-helpers/v… is the other one. A bare v… tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.

1.0.0 is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is 1.0.0-dev.3, on a branch: pin that string exactly, and anchor your usage_ leaf's against: on it — against: @ng-eventually/polyfill@1.0.0-dev.3, the string you pinned, never the version it is heading for. 1.0.0-dev.3 narrows an existing call rather than moving a signature: two payload shapes an application could carry freely are now this package's own, and one it cannot apply is reported — a state you did not have to handle before, which is a major by the rules above. It lands inside the pre-release line rather than on a new major number because 1.0.0 has not been cut, so there is nothing yet to break away from.

There is no changelog file and no deprecation window: the sections above are the release note. A removal or a narrowing lands in ## Surface and ## Guarantees in the same version that ships it, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — ## Guarantees and ## Non-guarantees before ## Surface, because that is where a narrowing shows up first.