clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / docs / store-provisioning.md
store-provisioning.md 5.9K rendered
1# Store provisioning23A user-supplied sync store (a self-hosted node reached by ticket) is shared infrastructure: another4app's tables may already live in it. Provisioning is how an app creates or updates **its own slice**5of such a store — as an explicit user opt-in, never as part of normal sync.67Two principles govern it:891. **Store changes are opt-in.** Enrolling a sync ticket attaches transport only. Creating or10 updating the store's schema requires store administration: either the store's **admin secret**,11 or a **provision-scoped app-connect ticket** (`ticket issue --provision` on lofi-node), whose12 gate injects the node's admin secret itself — the secret never transits the client, and13 possession of the ticket is the opt-in.142. **An app may only touch its own namespaces.** A nested app (`s.defineNestedApp`) declares15 namespaces, and every change provisioning generates is confined to tables under them. Sibling16 tables and their policies carry through a merge byte-for-byte. This keeps apps honest: many apps17 can share one store, each owning its slice, none able to clobber another through the framework18 surface. A flat `s.defineApp` schema has no namespace and therefore may only provision a store it19 wholly owns.2021Provisioning lives at `@nzip/lofi/schema/store`, deliberately separate from `@nzip/lofi/schema`: the22schema facade is bundled and executed by the Jazz schema loader when deriving the deployed schema,23so the authoring surface stays free of the provisioning client.2425## Classify before you connect2627With store administration (admin secret, or a provision-scoped ticket URL as `serverUrl` with28`adminSecret` omitted — in a browser app the provision URL comes from the runtime's capability29custody: held in memory after enrollment, or unlocked through its passkey ceremony with30`unlockProvisionCapability`; see [Sync and recovery](sync-and-recovery.md)):3132```ts33import { readStoreStatus } from "@nzip/lofi/schema/store";3435const status = await readStoreStatus(root, { serverUrl, appId, adminSecret });36```3738Without it — any valid sync ticket may call the node's metadata-only preflight, which is how a39sync-only client learns `no_schema` before ever attaching sync:4041```ts42import { readTicketStoreStatus } from "@nzip/lofi/schema/store";4344const preflight = await readTicketStoreStatus(ticketUrl);45// → { state: "deployed", appId, headHash } | { state: "no_schema", appId }46// | { state: "store_unavailable" } | { state: "ticket_rejected" } | { state: "unsupported" }47```4849| State | Meaning | Remedy |50| -------------------- | ---------------------------------------------------------------- | -------------------- |51| `ok` | The enforced schema carries this app's slice exactly. | none |52| `no_schema` | Nothing is deployed. **Writes against such a store hang.** | opt-in create |53| `schema_out_of_date` | The store lacks some of this app's tables. | opt-in update |54| `schema_drift` | The store's copy of this app's namespaces differs unexplainably. | surfaced, never auto |5556`no_schema` is why classification exists beyond provisioning: against an empty store the engine's57writes hang rather than fail, so an app should reach this state — and prompt the user — before ever58attaching sync to a fresh store.5960## Provision6162```ts63import { provisionStore } from "@nzip/lofi/schema/store";64import { permissions, root } from "./schema.ts";6566const result = await provisionStore({67 app: root,68 permissions, // the app's merged per-namespace bundle69 target: { serverUrl, appId, adminSecret },70});71// result.status → "created" | "updated" | "unchanged"72```7374What a merge actually does, in order:75761. Fetches the store's head schema and permissions bundle verbatim.772. Appends this app's missing tables to the stored schema object — everything else keeps its exact78 serialization, because the schema hash is serialization-sensitive.793. Publishes the union schema, a migration from the current head (`added`-table lenses only), and80 the union permissions bundle — sibling policies recovered from the store and preserved unchanged,81 this app's policies swapped in for its own tables — chained to the current head.824. Registers this app's own compiled schema and connects it to the new head. Clients declare their83 compiled schema hash on the wire, and the server only serves hashes connected to the enforced84 head — without this edge, the joining app's clients would hang on their own tables.8586Existing clients of other apps continue untouched across a merge: their hash stays connected through87the migration chain, and their policies were never rewritten.8889Drift refuses loudly (`StoreProvisionError` with code `schema-drift` and the differing tables); so90does a permissions bundle naming a table outside the app's namespaces (`outside-namespace`), before91any request is made.9293## Current limits9495- Table **additions** are the supported merge shape. Evolving columns of already-deployed tables96 (add/drop/rename) still goes through the app's own migration deploy flow; forwarding those through97 provisioning is follow-on work on [#109](https://github.com/FelineStateMachine/lofi/issues/109).98- `readStoreStatus` compares structurally; it does not verify migration connectivity. A99 `provisionStore` run (even an `unchanged` one) establishes the connectivity edge for this app's100 schema, so run it once per app version as part of the opt-in.101- Store administration (the admin secret, or a provision ticket) grants full control; the namespace102 confinement is a framework honesty boundary, not a defense against a hostile administrator.103 Provision-scoped tickets and the store-status preflight are the node-side contract from104 [lofi-node#2](https://github.com/FelineStateMachine/lofi-node/issues/2), shipped in lofi-node's105 `docs/app-ticket.md`.