readStoreStatus classifies a store against this app's slice (ok / no_schema / schema_out_of_date / schema_drift; drift is surfaced, never repaired) and provisionStore creates or merges the slice over the stored head schema verbatim: missing tables append, sibling tables and policies carry through byte-for-byte, and the change publishes as schema + added-table migration + a permissions bundle chained to the head. The app's own compiled schema is registered and connected to the head so its clients' declared hash is never disconnected. Any generated change naming a table outside the app's declared namespaces is a hard error before publish. Core of #109.
- commit
- c62c2e99b23fa7c1197a0b843a0ed7a21505c7f2
- parent
- cc3e4cbd356dbe3ea5b1e33727478481f08cc24a
- author
- Dami <dami.etoile@gmail.com>
| M | deno.json | 2 | +- |
| M | docs/ | 1 | + |
| A | docs/ | 81 | ++++ |
| M | package/ | 1 | + |
| M | package/ | 9 | + |
| A | package/ | 544 | ++++++++++++++++++++++++++++++ |
| A | package/ | 235 | +++++++++++++ |
@@ -56,7 +56,7 @@
5656 "docs:links": "deno run --allow-read tools/docs_links.ts",5757 "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/mod_test.ts package/schema/nested_test.ts package/testing package/tooling apps/reference/tests",5858 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",59- "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",59+ "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/store_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",6060 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",6161 "test": "deno task test:unit",6262 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",@@ -43,6 +43,7 @@ shortest product overview and command summary. AI agents can ingest these docs a
4343 - [Binary and structured payloads](examples/payloads.md)4444 - [Schema evolution](examples/schema-evolution.md)4545 - [Nested app namespaces](examples/nested-namespaces.md)46+- [Store provisioning](store-provisioning.md)4647 - [Optional installed-app recipes](recipes/README.md)4748 - [Advanced device-auth primitive](auth.md)4849 @@ -0,0 +1,81 @@
1+# Store provisioning2+3+A user-supplied sync store (a self-hosted node reached by ticket) is shared infrastructure: another4+app's tables may already live in it. Provisioning is how an app creates or updates **its own slice**5+of such a store — as an explicit user opt-in, never as part of normal sync.6+7+Two principles govern it:8+9+1. **Store changes are opt-in.** Enrolling a sync ticket attaches transport only. Creating or10+ updating the store's schema requires the store's **admin secret** — supplying it is the opt-in.11+2. **An app may only touch its own namespaces.** A nested app (`s.defineNestedApp`) declares12+ namespaces, and every change provisioning generates is confined to tables under them. Sibling13+ tables and their policies carry through a merge byte-for-byte. This keeps apps honest: many apps14+ can share one store, each owning its slice, none able to clobber another through the framework15+ surface. A flat `s.defineApp` schema has no namespace and therefore may only provision a store it16+ wholly owns.17+18+## Classify before you connect19+20+```ts21+import { readStoreStatus } from "@nzip/lofi/schema";22+23+const status = await readStoreStatus(root, { serverUrl, appId, adminSecret });24+```25+26+| State | Meaning | Remedy |27+| -------------------- | ---------------------------------------------------------------- | -------------------- |28+| `ok` | The enforced schema carries this app's slice exactly. | none |29+| `no_schema` | Nothing is deployed. **Writes against such a store hang.** | opt-in create |30+| `schema_out_of_date` | The store lacks some of this app's tables. | opt-in update |31+| `schema_drift` | The store's copy of this app's namespaces differs unexplainably. | surfaced, never auto |32+33+`no_schema` is why classification exists beyond provisioning: against an empty store the engine's34+writes hang rather than fail, so an app should reach this state — and prompt the user — before ever35+attaching sync to a fresh store.36+37+## Provision38+39+```ts40+import { provisionStore } from "@nzip/lofi/schema";41+import { permissions, root } from "./schema.ts";42+43+const result = await provisionStore({44+ app: root,45+ permissions, // the app's merged per-namespace bundle46+ target: { serverUrl, appId, adminSecret },47+});48+// result.status → "created" | "updated" | "unchanged"49+```50+51+What a merge actually does, in order:52+53+1. Fetches the store's head schema and permissions bundle verbatim.54+2. Appends this app's missing tables to the stored schema object — everything else keeps its exact55+ serialization, because the schema hash is serialization-sensitive.56+3. Publishes the union schema, a migration from the current head (`added`-table lenses only), and57+ the union permissions bundle — sibling policies recovered from the store and preserved unchanged,58+ this app's policies swapped in for its own tables — chained to the current head.59+4. Registers this app's own compiled schema and connects it to the new head. Clients declare their60+ compiled schema hash on the wire, and the server only serves hashes connected to the enforced61+ head — without this edge, the joining app's clients would hang on their own tables.62+63+Existing clients of other apps continue untouched across a merge: their hash stays connected through64+the migration chain, and their policies were never rewritten.65+66+Drift refuses loudly (`StoreProvisionError` with code `schema-drift` and the differing tables); so67+does a permissions bundle naming a table outside the app's namespaces (`outside-namespace`), before68+any request is made.69+70+## Current limits71+72+- Table **additions** are the supported merge shape. Evolving columns of already-deployed tables73+ (add/drop/rename) still goes through the app's own migration deploy flow; forwarding those through74+ provisioning is follow-on work on [#109](https://github.com/FelineStateMachine/lofi/issues/109).75+- `readStoreStatus` compares structurally; it does not verify migration connectivity. A76+ `provisionStore` run (even an `unchanged` one) establishes the connectivity edge for this app's77+ schema, so run it once per app version as part of the opt-in.78+- The admin secret grants full store administration; the namespace confinement is a framework79+ honesty boundary, not a defense against a hostile secret holder. Ticket-scoped provisioning (no80+ raw secret pasting) is tracked on the node side81+ ([lofi-node#2](https://github.com/FelineStateMachine/lofi-node/issues/2)).@@ -65,6 +65,7 @@ export const accessFiles = [
6565 export const schemaFiles = [6666 "mod.ts",6767 "nested.ts",68+ "store.ts",6869 ] as const;6970 7071 /** Preact modules vendored into `.lofi/`; kept in lockstep by the manifest test. */@@ -101,6 +101,15 @@ export {
101101 type NestedSchemaDefinition,102102 } from "./nested.ts";103103 104+export {105+ provisionStore,106+ readStoreStatus,107+ StoreProvisionError,108+ type StoreProvisionResult,109+ type StoreStatus,110+ type StoreTarget,111+} from "./store.ts";112+104113 /**105114 * Schema-authoring types re-exported from the pinned Jazz 2 DSL for use in106115 * application signatures. Row types for UI code come from `@nzip/lofi`@@ -0,0 +1,544 @@
1+/**2+ * Opt-in store provisioning for nested apps: classify a sync store's deployed3+ * schema against this app's slice, and create or update **only this app's4+ * namespaces** in it.5+ *6+ * A user-supplied store is shared infrastructure — another app's tables may7+ * already live in it. Provisioning therefore works as a merge over the stored8+ * schema: the head schema is fetched verbatim, this app's tables are appended9+ * or verified, every other table (and its policies) is preserved byte-for-byte,10+ * and the change deploys as an ordinary migration advancing the store's one11+ * permissions head. The safety invariant is enforced here, before anything is12+ * published: an app may only create tables under its own declared namespaces,13+ * and any generated change naming a table outside them is a hard error.14+ *15+ * Every operation needs the store's admin secret — supplying it is the user's16+ * opt-in. Nothing in the normal sync path calls this module.17+ *18+ * @module19+ */20+import type { CompiledPermissions } from "jazz-tools";21+import { NESTED_SEPARATOR, nestedAppDeployTarget, nestedAppTables } from "./nested.ts";22+23+/** The store to provision: where it is, which app it hosts, and the admin opt-in. */24+export type StoreTarget = {25+ /** The http(s) sync server URL (a ticket URL keeps its secret base path). */26+ serverUrl: string;27+ /** The Jazz app id of the store. */28+ appId: string;29+ /** The store's admin secret — administration rides on top of transport access. */30+ adminSecret: string;31+};32+33+/** How a store's deployed schema relates to this app's slice. */34+export type StoreStatus =35+ /** Nothing is deployed; writes against the store would hang. Remedy: create. */36+ | { state: "no_schema" }37+ /** The enforced schema carries this app's slice exactly. */38+ | { state: "ok"; headHash: string }39+ /**40+ * The enforced schema lacks some of this app's tables (first enrollment of41+ * this app, or a newer app version adding tables). Remedy: update.42+ */43+ | { state: "schema_out_of_date"; headHash: string; missingTables: readonly string[] }44+ /**45+ * The store's copy of this app's own namespaces differs from what the app46+ * declares in a way its lineage does not explain. Never auto-repaired.47+ */48+ | { state: "schema_drift"; headHash: string; driftedTables: readonly string[] };49+50+/** The outcome of a provisioning run. */51+export type StoreProvisionResult = {52+ /** `created` deployed an initial schema; `updated` merged this app's slice. */53+ status: "created" | "updated" | "unchanged";54+ /** The schema hash now enforcing permissions. */55+ headHash: string;56+};57+58+/** Raised when provisioning is refused or the store rejects a request. */59+export class StoreProvisionError extends Error {60+ /** Stable error class name for diagnostics and error boundaries. */61+ override readonly name = "StoreProvisionError";62+ /** Stable category for provisioning flows. */63+ readonly code: "http" | "schema-drift" | "not-nested" | "outside-namespace";64+ /** Creates a provisioning rejection with a stable category. */65+ constructor(66+ code: "http" | "schema-drift" | "not-nested" | "outside-namespace",67+ message: string,68+ ) {69+ super(message);70+ this.code = code;71+ }72+}73+74+type WasmColumn = {75+ name: string;76+ column_type: unknown;77+ nullable?: boolean;78+ references?: string;79+ default?: unknown;80+ merge_strategy?: unknown;81+};82+type WasmTable = { columns: WasmColumn[] };83+type WasmSchemaMap = Record<string, WasmTable>;84+85+type PermissionsHead = {86+ schemaHash: string;87+ version: number;88+ parentBundleObjectId: string | null;89+ bundleObjectId: string;90+};91+92+// ---------------------------------------------------------------------------93+// Wire encoding of a freshly compiled permissions bundle.94+//95+// The store's write endpoints take policy literals as tagged values, but the96+// pinned DSL's compiled output carries raw JS values, and the normalization97+// that bridges them is not a public jazz-tools export (the module that could98+// provide it is not browser-safe). This section mirrors that normalization;99+// the store conformance suite deploys through it against the real server, so100+// an alpha bump that changes the wire contract fails there loudly. Policies101+// read back from the store are already wire-form and are never re-encoded.102+// ---------------------------------------------------------------------------103+104+function isRecord(value: unknown): value is Record<string, unknown> {105+ return typeof value === "object" && value !== null && !Array.isArray(value);106+}107+108+const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;109+110+function encodeLiteral(value: unknown): unknown {111+ if (value === null) return { type: "Null" };112+ if (value instanceof Date) return { type: "Timestamp", value: value.getTime() };113+ if (value instanceof Uint8Array) {114+ throw new StoreProvisionError(115+ "outside-namespace",116+ "bytes literals in policy conditions are not supported by store provisioning",117+ );118+ }119+ if (typeof value === "boolean") return { type: "Boolean", value };120+ if (typeof value === "number") {121+ if (!Number.isFinite(value)) {122+ throw new StoreProvisionError("http", "policy literals only support finite numbers");123+ }124+ if (!Number.isInteger(value)) return { type: "Double", value };125+ if (value >= -2147483648 && value <= 2147483647) return { type: "Integer", value };126+ if (Number.isSafeInteger(value)) return { type: "BigInt", value };127+ return { type: "Double", value };128+ }129+ if (typeof value === "string") {130+ return UUID_LIKE.test(value) ? { type: "Uuid", value } : { type: "Text", value };131+ }132+ if (Array.isArray(value)) return { type: "Array", value: value.map(encodeLiteral) };133+ if (isRecord(value) && typeof value.type === "string") return value;134+ throw new StoreProvisionError(135+ "http",136+ "policy literals must be scalars, arrays, Date, or tagged values",137+ );138+}139+140+function encodePolicyValue(value: unknown): unknown {141+ if (isRecord(value) && value.type === "SessionRef") return value;142+ const raw = isRecord(value) && "value" in value ? value.value : value;143+ return { type: "Literal", value: encodeLiteral(raw) };144+}145+146+function encodeRelationValueRef(value: unknown): unknown {147+ if (isRecord(value) && "Literal" in value) return { Literal: encodeLiteral(value.Literal) };148+ return value;149+}150+151+function encodeRelationPredicate(predicate: unknown): unknown {152+ if (!isRecord(predicate)) return predicate;153+ if ("Cmp" in predicate && isRecord(predicate.Cmp)) {154+ return { Cmp: { ...predicate.Cmp, right: encodeRelationValueRef(predicate.Cmp.right) } };155+ }156+ if ("Contains" in predicate && isRecord(predicate.Contains)) {157+ return {158+ Contains: { ...predicate.Contains, right: encodeRelationValueRef(predicate.Contains.right) },159+ };160+ }161+ if ("In" in predicate && isRecord(predicate.In)) {162+ return {163+ In: {164+ ...predicate.In,165+ values: (predicate.In.values as unknown[]).map(encodeRelationValueRef),166+ },167+ };168+ }169+ if ("And" in predicate) {170+ return { And: (predicate.And as unknown[]).map(encodeRelationPredicate) };171+ }172+ if ("Or" in predicate) return { Or: (predicate.Or as unknown[]).map(encodeRelationPredicate) };173+ if ("Not" in predicate) return { Not: encodeRelationPredicate(predicate.Not) };174+ return predicate;175+}176+177+function encodeRelationExpr(expr: unknown): unknown {178+ if (!isRecord(expr)) return expr;179+ if ("Filter" in expr && isRecord(expr.Filter)) {180+ return {181+ Filter: {182+ input: encodeRelationExpr(expr.Filter.input),183+ predicate: encodeRelationPredicate(expr.Filter.predicate),184+ },185+ };186+ }187+ if ("Union" in expr && isRecord(expr.Union)) {188+ return { Union: { inputs: (expr.Union.inputs as unknown[]).map(encodeRelationExpr) } };189+ }190+ if ("Join" in expr && isRecord(expr.Join)) {191+ return {192+ Join: {193+ ...expr.Join,194+ left: encodeRelationExpr(expr.Join.left),195+ right: encodeRelationExpr(expr.Join.right),196+ },197+ };198+ }199+ if ("Gather" in expr && isRecord(expr.Gather)) {200+ return {201+ Gather: {202+ ...expr.Gather,203+ seed: encodeRelationExpr(expr.Gather.seed),204+ step: encodeRelationExpr(expr.Gather.step),205+ },206+ };207+ }208+ for (const wrapper of ["Project", "Distinct", "OrderBy", "Offset", "Limit"]) {209+ const inner = expr[wrapper];210+ if (isRecord(inner)) {211+ return { [wrapper]: { ...inner, input: encodeRelationExpr(inner.input) } };212+ }213+ }214+ return expr;215+}216+217+function encodePolicyExpr(expr: unknown): unknown {218+ if (!isRecord(expr)) return expr;219+ switch (expr.type) {220+ case "Cmp":221+ case "Contains":222+ return { ...expr, value: encodePolicyValue(expr.value) };223+ case "SessionCmp":224+ case "SessionContains":225+ return {226+ ...expr,227+ value: encodeLiteral(isRecord(expr.value) ? expr.value.value : expr.value),228+ };229+ case "InList":230+ return { ...expr, values: (expr.values as unknown[]).map(encodePolicyValue) };231+ case "SessionInList":232+ return {233+ ...expr,234+ values: (expr.values as unknown[]).map((value) =>235+ encodeLiteral(isRecord(value) ? value.value : value)236+ ),237+ };238+ case "Exists":239+ return { ...expr, condition: encodePolicyExpr(expr.condition) };240+ case "ExistsRel":241+ return { ...expr, rel: encodeRelationExpr(expr.rel) };242+ case "And":243+ case "Or":244+ return { ...expr, exprs: (expr.exprs as unknown[]).map(encodePolicyExpr) };245+ case "Not":246+ return { ...expr, expr: encodePolicyExpr(expr.expr) };247+ default:248+ return expr;249+ }250+}251+252+function encodeOwnPermissions(permissions: CompiledPermissions): Record<string, unknown> {253+ const encoded: Record<string, unknown> = {};254+ for (const [table, policies] of Object.entries(permissions as Record<string, unknown>)) {255+ if (!isRecord(policies)) continue;256+ const table_policies: Record<string, unknown> = {};257+ for (const operation of ["select", "insert", "update", "delete"]) {258+ const policy = policies[operation];259+ if (!isRecord(policy)) continue;260+ const normalized: Record<string, unknown> = {};261+ if (policy.using !== undefined) normalized.using = encodePolicyExpr(policy.using);262+ if (policy.with_check !== undefined) {263+ normalized.with_check = encodePolicyExpr(policy.with_check);264+ }265+ table_policies[operation] = normalized;266+ }267+ encoded[table] = table_policies;268+ }269+ return encoded;270+}271+272+async function adminFetch(273+ target: StoreTarget,274+ path: string,275+ init?: { method: "POST"; body: unknown },276+): Promise<unknown> {277+ const base = target.serverUrl.replace(/\/+$/, "");278+ const response = await fetch(`${base}/apps/${target.appId}/${path}`, {279+ method: init?.method ?? "GET",280+ headers: {281+ "X-Jazz-Admin-Secret": target.adminSecret,282+ ...(init ? { "Content-Type": "application/json" } : {}),283+ },284+ ...(init ? { body: JSON.stringify(init.body) } : {}),285+ });286+ const text = await response.text();287+ if (!response.ok) {288+ throw new StoreProvisionError(289+ "http",290+ `store request ${path} failed: HTTP ${response.status} ${text.slice(0, 200)}`,291+ );292+ }293+ try {294+ return text ? JSON.parse(text) : {};295+ } catch {296+ throw new StoreProvisionError("http", `store request ${path} returned non-JSON`);297+ }298+}299+300+// Structural table comparison in the spirit of the engine's own schema301+// equality: column order is irrelevant; every attribute that changes storage302+// or merge behavior is significant.303+function normalizeColumns(table: WasmTable): string {304+ const columns = [...table.columns].sort((a, b) => a.name.localeCompare(b.name)).map((c) => ({305+ name: c.name,306+ column_type: c.column_type,307+ nullable: c.nullable === true,308+ references: c.references ?? null,309+ default: c.default ?? null,310+ merge_strategy: c.merge_strategy ?? null,311+ }));312+ return JSON.stringify(columns);313+}314+315+function sameTable(a: WasmTable, b: WasmTable): boolean {316+ return normalizeColumns(a) === normalizeColumns(b);317+}318+319+function namespaceOf(tableName: string): string | null {320+ const index = tableName.indexOf(NESTED_SEPARATOR);321+ return index > 0 ? tableName.slice(0, index) : null;322+}323+324+type AppSlice = {325+ /** This app's tables in wire (wasm) form, keyed by mangled global name. */326+ tables: WasmSchemaMap;327+ /** This app's declared namespace prefixes. */328+ namespaces: ReadonlySet<string>;329+};330+331+function appSlice(app: unknown): AppSlice {332+ if (nestedAppTables(app) === null) {333+ throw new StoreProvisionError(334+ "not-nested",335+ "store provisioning requires a nested app: the namespace is what scopes the changes an " +336+ "app may make to a shared store",337+ );338+ }339+ const deployTarget = nestedAppDeployTarget(app) as { wasmSchema: WasmSchemaMap };340+ const tables: WasmSchemaMap = {};341+ const namespaces = new Set<string>();342+ for (const [name, table] of Object.entries(deployTarget.wasmSchema)) {343+ tables[name] = table;344+ const namespace = namespaceOf(name);345+ if (namespace) namespaces.add(namespace);346+ }347+ return { tables, namespaces };348+}349+350+type StoreArtifacts = {351+ head: PermissionsHead | null;352+ permissions: Record<string, unknown>;353+ schema: WasmSchemaMap;354+};355+356+async function fetchStore(target: StoreTarget): Promise<StoreArtifacts> {357+ const hashes = await adminFetch(target, "schemas") as { hashes?: string[] };358+ const bundle = await adminFetch(target, "admin/permissions") as {359+ head?: PermissionsHead | null;360+ permissions?: Record<string, unknown>;361+ };362+ const head = bundle.head ?? null;363+ if (!head || !hashes.hashes?.length) return { head: null, permissions: {}, schema: {} };364+ const stored = await adminFetch(target, `schema/${encodeURIComponent(head.schemaHash)}`) as {365+ schema?: WasmSchemaMap;366+ };367+ return { head, permissions: bundle.permissions ?? {}, schema: stored.schema ?? {} };368+}369+370+function classify(slice: AppSlice, artifacts: StoreArtifacts): StoreStatus {371+ if (!artifacts.head) return { state: "no_schema" };372+ const headHash = artifacts.head.schemaHash;373+ const missingTables: string[] = [];374+ const driftedTables: string[] = [];375+ for (const [name, expected] of Object.entries(slice.tables)) {376+ const stored = artifacts.schema[name];377+ if (!stored) missingTables.push(name);378+ else if (!sameTable(stored, expected)) driftedTables.push(name);379+ }380+ // A stored table under this app's namespaces that the app does not declare381+ // is drift too: either another writer reached into the slug, or the app is382+ // older than the store — both need a human, not an auto-merge.383+ for (const name of Object.keys(artifacts.schema)) {384+ const namespace = namespaceOf(name);385+ if (namespace && slice.namespaces.has(namespace) && !(name in slice.tables)) {386+ driftedTables.push(name);387+ }388+ }389+ if (driftedTables.length) return { state: "schema_drift", headHash, driftedTables };390+ if (missingTables.length) return { state: "schema_out_of_date", headHash, missingTables };391+ return { state: "ok", headHash };392+}393+394+/**395+ * Classifies the store's deployed schema against this app's slice without396+ * changing anything. `no_schema` matters beyond provisioning: against an397+ * empty store, client writes hang rather than fail, so callers should reach398+ * this state before ever attaching sync to a fresh store.399+ */400+export async function readStoreStatus(app: unknown, target: StoreTarget): Promise<StoreStatus> {401+ return classify(appSlice(app), await fetchStore(target));402+}403+404+function assertOwnNamespacePermissions(405+ slice: AppSlice,406+ permissions: CompiledPermissions,407+): void {408+ for (const table of Object.keys(permissions)) {409+ const namespace = namespaceOf(table);410+ if (!namespace || !slice.namespaces.has(namespace)) {411+ throw new StoreProvisionError(412+ "outside-namespace",413+ `permissions bundle names table "${table}" outside this app's namespaces ` +414+ `(${[...slice.namespaces].join(", ")}) — an app may only provision its own slice`,415+ );416+ }417+ }418+}419+420+// A client declares its own compiled schema hash on the wire, and the server421+// only serves hashes connected (via migrations) to the head enforcing422+// permissions — a disconnected client's writes to its own tables hang. So423+// provisioning also registers this app's own-slice schema and, when it is not424+// the head itself, publishes the connecting migration (sibling tables as425+// `added` lenses). Runs on every provision, so an `unchanged` pass self-heals426+// a store whose slice arrived without the edge.427+async function ensureSliceConnectivity(428+ target: StoreTarget,429+ slice: AppSlice,430+ headHash: string,431+ headSchema: WasmSchemaMap,432+): Promise<void> {433+ const own = await adminFetch(target, "admin/schemas", {434+ method: "POST",435+ body: { schema: slice.tables },436+ }) as { hash: string };437+ if (own.hash === headHash) return;438+ const connectivity = await adminFetch(439+ target,440+ `admin/schema-connectivity?fromHash=${encodeURIComponent(own.hash)}&toHash=${441+ encodeURIComponent(headHash)442+ }`,443+ ) as { connected?: boolean };444+ if (connectivity.connected) return;445+ const siblingTables = Object.keys(headSchema).filter((name) => !(name in slice.tables));446+ await adminFetch(target, "admin/migrations", {447+ method: "POST",448+ body: {449+ fromHash: own.hash,450+ toHash: headHash,451+ forward: siblingTables.map((table) => ({ table, added: true, operations: [] })),452+ },453+ });454+}455+456+/**457+ * Creates or updates this app's slice in the store. The stored head schema is458+ * fetched verbatim and only extended: missing tables of this app's namespaces459+ * are appended (key order of everything else preserved — the schema hash is460+ * serialization-sensitive), sibling tables and their policies carry through461+ * untouched, and the change publishes as schema + `added`-table migration +462+ * a permissions bundle chained to the current head. The app's own compiled463+ * schema is additionally registered and connected to the head, so this app's464+ * clients — which declare that schema on the wire — are never disconnected.465+ * Drift is never repaired: a store whose copy of this app's namespaces466+ * differs from the declaration throws with the differing tables.467+ */468+export async function provisionStore(options: {469+ app: unknown;470+ permissions: CompiledPermissions;471+ target: StoreTarget;472+}): Promise<StoreProvisionResult> {473+ const slice = appSlice(options.app);474+ assertOwnNamespacePermissions(slice, options.permissions);475+ const artifacts = await fetchStore(options.target);476+ const status = classify(slice, artifacts);477+478+ if (status.state === "schema_drift") {479+ throw new StoreProvisionError(480+ "schema-drift",481+ `store tables [${status.driftedTables.join(", ")}] differ from this app's declaration; ` +482+ "drift is surfaced, never auto-repaired",483+ );484+ }485+ if (status.state === "ok") {486+ await ensureSliceConnectivity(options.target, slice, status.headHash, artifacts.schema);487+ return { status: "unchanged", headHash: status.headHash };488+ }489+490+ const ownPermissions = encodeOwnPermissions(options.permissions);491+492+ if (status.state === "no_schema") {493+ const published = await adminFetch(options.target, "admin/schemas", {494+ method: "POST",495+ body: { schema: slice.tables },496+ }) as { hash: string };497+ await adminFetch(options.target, "admin/permissions", {498+ method: "POST",499+ body: {500+ schemaHash: published.hash,501+ permissions: ownPermissions,502+ expectedParentBundleObjectId: null,503+ },504+ });505+ return { status: "created", headHash: published.hash };506+ }507+508+ // schema_out_of_date: append this app's missing tables to the stored schema509+ // and swap in this app's policies, leaving every sibling entry untouched.510+ const union: WasmSchemaMap = { ...artifacts.schema };511+ for (const name of status.missingTables) union[name] = slice.tables[name];512+ const unionPermissions: Record<string, unknown> = {};513+ for (const [table, policies] of Object.entries(artifacts.permissions)) {514+ const namespace = namespaceOf(table);515+ if (!(namespace && slice.namespaces.has(namespace))) unionPermissions[table] = policies;516+ }517+ for (const [table, policies] of Object.entries(ownPermissions)) {518+ unionPermissions[table] = policies;519+ }520+521+ const head = artifacts.head as PermissionsHead;522+ const published = await adminFetch(options.target, "admin/schemas", {523+ method: "POST",524+ body: { schema: union },525+ }) as { hash: string };526+ await adminFetch(options.target, "admin/migrations", {527+ method: "POST",528+ body: {529+ fromHash: head.schemaHash,530+ toHash: published.hash,531+ forward: status.missingTables.map((table) => ({ table, added: true, operations: [] })),532+ },533+ });534+ await adminFetch(options.target, "admin/permissions", {535+ method: "POST",536+ body: {537+ schemaHash: published.hash,538+ permissions: unionPermissions,539+ expectedParentBundleObjectId: head.bundleObjectId,540+ },541+ });542+ await ensureSliceConnectivity(options.target, slice, published.hash, union);543+ return { status: "updated", headHash: published.hash };544+}@@ -0,0 +1,235 @@
1+// Store-provisioning conformance over a real JazzServer: the classifier's2+// four states, the create and slice-merge paths through the raw admin3+// endpoints, old-hash client continuity across a merge, sibling-policy4+// preservation (the honesty proof), and the namespace-confinement guards.5+import { createDb } from "jazz-tools";6+import { startLocalJazzServer } from "jazz-tools/testing";7+import { assert } from "../runtime/test-assert.ts";8+import {9+ nestedAppDeployTarget,10+ provisionStore,11+ readStoreStatus,12+ s,13+ StoreProvisionError,14+ type StoreTarget,15+} from "./mod.ts";16+17+// The first app on the store.18+const taskRoot = s.defineNestedApp({19+ taskapp: { tasks: s.table({ text: s.string(), secret: s.boolean() }) },20+});21+const taskPermissions = s.defineNestedPermissions(taskRoot.taskapp, ({ policy, session }) => {22+ policy.tasks.allowInsert.always();23+ // The gate the second app's merge must preserve verbatim.24+ policy.tasks.allowRead.where({ secret: false });25+ policy.tasks.allowRead.where({ $createdBy: session.user_id });26+ policy.tasks.allowUpdate.always();27+ policy.tasks.allowDelete.always();28+});29+30+// The second app, enrolling later against the occupied store.31+const noteRoot = s.defineNestedApp({32+ notesapp: { notes: s.table({ title: s.string() }) },33+});34+const notePermissions = s.defineNestedPermissions(noteRoot.notesapp, ({ policy }) => {35+ policy.notes.allowInsert.always();36+ policy.notes.allowRead.always();37+ policy.notes.allowUpdate.always();38+ policy.notes.allowDelete.always();39+});40+41+function secret(fill: number): string {42+ let binary = "";43+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);44+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");45+}46+47+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {48+ let timer: ReturnType<typeof setTimeout> | undefined;49+ try {50+ return await Promise.race([51+ operation,52+ new Promise<never>((_resolve, reject) => {53+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);54+ }),55+ ]);56+ } finally {57+ if (timer !== undefined) clearTimeout(timer);58+ }59+}60+61+Deno.test("a store is classified and provisioned slice by slice, preserving its other tenants", async () => {62+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });63+ const target: StoreTarget = {64+ serverUrl: server.url,65+ appId: server.appId,66+ adminSecret: server.adminSecret,67+ };68+ const clients: Awaited<ReturnType<typeof createDb>>[] = [];69+ const client = async (fill: number) => {70+ const db = await createDb({71+ appId: server.appId,72+ serverUrl: server.url,73+ secret: secret(fill),74+ userBranch: "main",75+ driver: { type: "memory" },76+ });77+ clients.push(db);78+ return db;79+ };80+ try {81+ // A fresh store has nothing deployed — the state where writes would hang.82+ const empty = await readStoreStatus(taskRoot, target);83+ assert(empty.state === "no_schema", `fresh store classified as ${empty.state}`);84+85+ // Create: the first app's slice becomes the store schema.86+ const created = await provisionStore({87+ app: taskRoot,88+ permissions: taskPermissions,89+ target,90+ });91+ assert(created.status === "created", `initial provisioning reported ${created.status}`);92+ const afterCreate = await readStoreStatus(taskRoot, target);93+ assert(94+ afterCreate.state === "ok" && afterCreate.headHash === created.headHash,95+ `provisioned store classified as ${afterCreate.state}`,96+ );97+ const again = await provisionStore({ app: taskRoot, permissions: taskPermissions, target });98+ assert(again.status === "unchanged", "re-provisioning an ok store must be a no-op");99+100+ const alice = await client(1);101+ const task = await within(102+ alice.insert(taskRoot.taskapp.tasks, { text: "visible", secret: false })103+ .wait({ tier: "global" }),104+ "task insert after create",105+ );106+ await within(107+ alice.insert(taskRoot.taskapp.tasks, { text: "hidden", secret: true })108+ .wait({ tier: "global" }),109+ "gated task insert",110+ );111+112+ // The second app sees the occupied store as out of date for ITS slice.113+ const beforeMerge = await readStoreStatus(noteRoot, target);114+ assert(115+ beforeMerge.state === "schema_out_of_date" &&116+ beforeMerge.missingTables.join(",") === "notesapp__notes",117+ `second app classified the store as ${JSON.stringify(beforeMerge)}`,118+ );119+120+ // Merge: only notesapp tables are added; taskapp rides through verbatim.121+ const merged = await provisionStore({122+ app: noteRoot,123+ permissions: notePermissions,124+ target,125+ });126+ assert(merged.status === "updated", `slice merge reported ${merged.status}`);127+ const afterMerge = await readStoreStatus(noteRoot, target);128+ assert(afterMerge.state === "ok", `merged store classified as ${afterMerge.state} for notes`);129+ const taskView = await readStoreStatus(taskRoot, target);130+ assert(taskView.state === "ok", `merged store classified as ${taskView.state} for tasks`);131+132+ // Old-hash continuity: the first app's client keeps working untouched.133+ await within(134+ alice.insert(taskRoot.taskapp.tasks, { text: "after merge", secret: false })135+ .wait({ tier: "global" }),136+ "old-hash insert after merge",137+ );138+139+ // The new slice is live for the joining app's own clients — provisioning140+ // registered its compiled schema and connected it to the head, so its141+ // declared hash is never disconnected.142+ const bob = await client(2);143+ await within(144+ bob.insert(noteRoot.notesapp.notes, { title: "first note" }).wait({ tier: "global" }),145+ "notes insert after merge",146+ );147+148+ // The honesty proof: notesapp's merge preserved taskapp's read gate. A149+ // Db binds to one app's schema, so the non-author viewer is its own150+ // client speaking the first app's handles.151+ const carol = await client(3);152+ const viewerTasks = await within(153+ carol.all(taskRoot.taskapp.tasks.where({}), { tier: "global" }),154+ "viewer task read after merge",155+ );156+ assert(157+ viewerTasks.length === 2 && viewerTasks.every((row) => row.secret === false),158+ `sibling policy not preserved: viewer sees ${viewerTasks.length} rows`,159+ );160+ const authorTasks = await within(161+ alice.all(taskRoot.taskapp.tasks.where({ id: task.id }), { tier: "global" }),162+ "author task read after merge",163+ );164+ assert(authorTasks.length === 1, "author lost access to pre-merge rows");165+166+ // Drift: an app whose declaration disagrees with the store's copy of its167+ // own namespace is surfaced and refused, never repaired.168+ const driftedRoot = s.defineNestedApp({169+ taskapp: { tasks: s.table({ text: s.string(), secret: s.boolean(), extra: s.int() }) },170+ });171+ const drifted = await readStoreStatus(driftedRoot, target);172+ assert(173+ drifted.state === "schema_drift" &&174+ drifted.driftedTables.join(",") === "taskapp__tasks",175+ `drifted app classified the store as ${JSON.stringify(drifted)}`,176+ );177+ const driftedPermissions = s.defineNestedPermissions(178+ driftedRoot.taskapp,179+ ({ policy }) => policy.tasks.allowRead.always(),180+ );181+ let driftError = "";182+ try {183+ await provisionStore({ app: driftedRoot, permissions: driftedPermissions, target });184+ } catch (error) {185+ driftError = error instanceof StoreProvisionError ? error.code : String(error);186+ }187+ assert(driftError === "schema-drift", `drifted provisioning reported "${driftError}"`);188+ } finally {189+ await Promise.allSettled(190+ clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),191+ );192+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);193+ }194+});195+196+Deno.test("provisioning is confined to the app's own namespaces", async () => {197+ const target: StoreTarget = {198+ serverUrl: "http://127.0.0.1:1",199+ appId: "00000000-0000-0000-0000-000000000000",200+ adminSecret: "unused",201+ };202+203+ // A flat app has no namespace and therefore no tenant boundary.204+ const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });205+ let flatError = "";206+ try {207+ await readStoreStatus(flat, target);208+ } catch (error) {209+ flatError = error instanceof StoreProvisionError ? error.code : String(error);210+ }211+ assert(flatError === "not-nested", `flat app reported "${flatError}"`);212+213+ // A permissions bundle naming a table outside the app's namespaces is a214+ // hard error before any request is made (the target above is unreachable).215+ const outOfSlug = {216+ ...notePermissions,217+ taskapp__tasks: (taskPermissions as Record<string, unknown>).taskapp__tasks,218+ } as typeof notePermissions;219+ let slugError = "";220+ try {221+ await provisionStore({ app: noteRoot, permissions: outOfSlug, target });222+ } catch (error) {223+ slugError = error instanceof StoreProvisionError ? error.code : String(error);224+ }225+ assert(slugError === "outside-namespace", `out-of-slug bundle reported "${slugError}"`);226+});227+228+Deno.test("the app slice derives from the deploy target's compiled schema", () => {229+ const deployTarget = nestedAppDeployTarget(taskRoot) as { wasmSchema: Record<string, unknown> };230+ assert(231+ "taskapp__tasks" in deployTarget.wasmSchema,232+ "expected the mangled table in the compiled wasm schema — the classifier compares this " +233+ "form against the stored schema",234+ );235+});