clone

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

main
lofi / docs / store-provisioning.md
store-provisioning.md 6.4K 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. Ticket enrollment enforces exactly that: enrolling against a store59that answers `no_schema` (or rejects the ticket) is rolled back with `SyncEnrollmentError` instead60of being kept, so provisioning happens first and the device never sits silently on a store that61cannot serve it.6263The browser runtime runs this preflight itself on every boot that connects managed sync to a64ticket-gated sink, recording the answer as `storeStatus` in runtime diagnostics. It never blocks65local readiness and never provisions anything; it only names the state, so a schema-less store shows66up as a boot diagnostic instead of a hanging first write.6768## Provision6970```ts71import { provisionStore } from "@nzip/lofi/schema/store";72import { permissions, root } from "./schema.ts";7374const result = await provisionStore({75 app: root,76 permissions, // the app's merged per-namespace bundle77 target: { serverUrl, appId, adminSecret },78});79// result.status → "created" | "updated" | "unchanged"80```8182What a merge actually does, in order:83841. Fetches the store's head schema and permissions bundle verbatim.852. Appends this app's missing tables to the stored schema object — everything else keeps its exact86 serialization, because the schema hash is serialization-sensitive.873. Publishes the union schema, a migration from the current head (`added`-table lenses only), and88 the union permissions bundle — sibling policies recovered from the store and preserved unchanged,89 this app's policies swapped in for its own tables — chained to the current head.904. Registers this app's own compiled schema and connects it to the new head. Clients declare their91 compiled schema hash on the wire, and the server only serves hashes connected to the enforced92 head — without this edge, the joining app's clients would hang on their own tables.9394Existing clients of other apps continue untouched across a merge: their hash stays connected through95the migration chain, and their policies were never rewritten.9697Drift refuses loudly (`StoreProvisionError` with code `schema-drift` and the differing tables); so98does a permissions bundle naming a table outside the app's namespaces (`outside-namespace`), before99any request is made.100101## Current limits102103- Table **additions** are the supported merge shape. Evolving columns of already-deployed tables104 (add/drop/rename) still goes through the app's own migration deploy flow; forwarding those through105 provisioning at enrollment is planned.106- `readStoreStatus` compares structurally; it does not verify migration connectivity. A107 `provisionStore` run (even an `unchanged` one) establishes the connectivity edge for this app's108 schema, so run it once per app version as part of the opt-in.109- Store administration (the admin secret, or a provision ticket) grants full control; the namespace110 confinement is a framework honesty boundary, not a defense against a hostile administrator.111 Provision-scoped tickets and the store-status preflight are the node-side contract from112 [lofi-node#2](https://github.com/FelineStateMachine/lofi-node/issues/2), shipped in lofi-node's113 `docs/app-ticket.md`.