clone

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

feat/prf-provision-custody
lofi / package / schema / store.ts
store.ts 23.0K
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 store administration — supplying the admin secret,16 * or holding a provision-scoped app-connect ticket whose gate injects it, is17 * the user's opt-in. Nothing in the normal sync path calls this module; the18 * one exception is {@link readTicketStoreStatus}, the metadata-only preflight19 * any valid ticket may call.20 *21 * @module22 */23import type { CompiledPermissions } from "jazz-tools";24import { NESTED_SEPARATOR, nestedAppDeployTarget, nestedAppTables } from "./nested.ts";2526/** The store to provision: where it is, which app it hosts, and the admin opt-in. */27export type StoreTarget = {28 /** The http(s) sync server URL (a ticket URL keeps its secret base path). */29 serverUrl: string;30 /** The Jazz app id of the store. */31 appId: string;32 /**33 * The store's admin secret — administration rides on top of transport34 * access. Omit it when `serverUrl` is a provision-scoped app-connect35 * ticket URL: the node's gate injects its own admin secret for such36 * requests (and strips any inbound one), so the secret never transits the37 * client.38 */39 adminSecret?: string;40};4142/** How a store's deployed schema relates to this app's slice. */43export type StoreStatus =44 /** Nothing is deployed; writes against the store would hang. Remedy: create. */45 | { state: "no_schema" }46 /** The enforced schema carries this app's slice exactly. */47 | { state: "ok"; headHash: string }48 /**49 * The enforced schema lacks some of this app's tables (first enrollment of50 * this app, or a newer app version adding tables). Remedy: update.51 */52 | { state: "schema_out_of_date"; headHash: string; missingTables: readonly string[] }53 /**54 * The store's copy of this app's own namespaces differs from what the app55 * declares in a way its lineage does not explain. Never auto-repaired.56 */57 | { state: "schema_drift"; headHash: string; driftedTables: readonly string[] };5859/** The outcome of a provisioning run. */60export type StoreProvisionResult = {61 /** `created` deployed an initial schema; `updated` merged this app's slice. */62 status: "created" | "updated" | "unchanged";63 /** The schema hash now enforcing permissions. */64 headHash: string;65};6667/** Raised when provisioning is refused or the store rejects a request. */68export class StoreProvisionError extends Error {69 /** Stable error class name for diagnostics and error boundaries. */70 override readonly name = "StoreProvisionError";71 /** Stable category for provisioning flows. */72 readonly code: "http" | "schema-drift" | "not-nested" | "outside-namespace";73 /** Creates a provisioning rejection with a stable category. */74 constructor(75 code: "http" | "schema-drift" | "not-nested" | "outside-namespace",76 message: string,77 ) {78 super(message);79 this.code = code;80 }81}8283type WasmColumn = {84 name: string;85 column_type: unknown;86 nullable?: boolean;87 references?: string;88 default?: unknown;89 merge_strategy?: unknown;90};91type WasmTable = { columns: WasmColumn[] };92type WasmSchemaMap = Record<string, WasmTable>;9394type PermissionsHead = {95 schemaHash: string;96 version: number;97 parentBundleObjectId: string | null;98 bundleObjectId: string;99};100101// ---------------------------------------------------------------------------102// Wire encoding of a freshly compiled permissions bundle.103//104// The store's write endpoints take policy literals as tagged values, but the105// pinned DSL's compiled output carries raw JS values, and the normalization106// that bridges them is not a public jazz-tools export (the module that could107// provide it is not browser-safe). This section mirrors that normalization;108// the store conformance suite deploys through it against the real server, so109// an alpha bump that changes the wire contract fails there loudly. Policies110// read back from the store are already wire-form and are never re-encoded.111// ---------------------------------------------------------------------------112113function isRecord(value: unknown): value is Record<string, unknown> {114 return typeof value === "object" && value !== null && !Array.isArray(value);115}116117const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;118119function encodeLiteral(value: unknown): unknown {120 if (value === null) return { type: "Null" };121 if (value instanceof Date) return { type: "Timestamp", value: value.getTime() };122 if (value instanceof Uint8Array) {123 throw new StoreProvisionError(124 "outside-namespace",125 "bytes literals in policy conditions are not supported by store provisioning",126 );127 }128 if (typeof value === "boolean") return { type: "Boolean", value };129 if (typeof value === "number") {130 if (!Number.isFinite(value)) {131 throw new StoreProvisionError("http", "policy literals only support finite numbers");132 }133 if (!Number.isInteger(value)) return { type: "Double", value };134 if (value >= -2147483648 && value <= 2147483647) return { type: "Integer", value };135 if (Number.isSafeInteger(value)) return { type: "BigInt", value };136 return { type: "Double", value };137 }138 if (typeof value === "string") {139 return UUID_LIKE.test(value) ? { type: "Uuid", value } : { type: "Text", value };140 }141 if (Array.isArray(value)) return { type: "Array", value: value.map(encodeLiteral) };142 if (isRecord(value) && typeof value.type === "string") return value;143 throw new StoreProvisionError(144 "http",145 "policy literals must be scalars, arrays, Date, or tagged values",146 );147}148149function encodePolicyValue(value: unknown): unknown {150 if (isRecord(value) && value.type === "SessionRef") return value;151 const raw = isRecord(value) && "value" in value ? value.value : value;152 return { type: "Literal", value: encodeLiteral(raw) };153}154155function encodeRelationValueRef(value: unknown): unknown {156 if (isRecord(value) && "Literal" in value) return { Literal: encodeLiteral(value.Literal) };157 return value;158}159160function encodeRelationPredicate(predicate: unknown): unknown {161 if (!isRecord(predicate)) return predicate;162 if ("Cmp" in predicate && isRecord(predicate.Cmp)) {163 return { Cmp: { ...predicate.Cmp, right: encodeRelationValueRef(predicate.Cmp.right) } };164 }165 if ("Contains" in predicate && isRecord(predicate.Contains)) {166 return {167 Contains: { ...predicate.Contains, right: encodeRelationValueRef(predicate.Contains.right) },168 };169 }170 if ("In" in predicate && isRecord(predicate.In)) {171 return {172 In: {173 ...predicate.In,174 values: (predicate.In.values as unknown[]).map(encodeRelationValueRef),175 },176 };177 }178 if ("And" in predicate) {179 return { And: (predicate.And as unknown[]).map(encodeRelationPredicate) };180 }181 if ("Or" in predicate) return { Or: (predicate.Or as unknown[]).map(encodeRelationPredicate) };182 if ("Not" in predicate) return { Not: encodeRelationPredicate(predicate.Not) };183 return predicate;184}185186function encodeRelationExpr(expr: unknown): unknown {187 if (!isRecord(expr)) return expr;188 if ("Filter" in expr && isRecord(expr.Filter)) {189 return {190 Filter: {191 input: encodeRelationExpr(expr.Filter.input),192 predicate: encodeRelationPredicate(expr.Filter.predicate),193 },194 };195 }196 if ("Union" in expr && isRecord(expr.Union)) {197 return { Union: { inputs: (expr.Union.inputs as unknown[]).map(encodeRelationExpr) } };198 }199 if ("Join" in expr && isRecord(expr.Join)) {200 return {201 Join: {202 ...expr.Join,203 left: encodeRelationExpr(expr.Join.left),204 right: encodeRelationExpr(expr.Join.right),205 },206 };207 }208 if ("Gather" in expr && isRecord(expr.Gather)) {209 return {210 Gather: {211 ...expr.Gather,212 seed: encodeRelationExpr(expr.Gather.seed),213 step: encodeRelationExpr(expr.Gather.step),214 },215 };216 }217 for (const wrapper of ["Project", "Distinct", "OrderBy", "Offset", "Limit"]) {218 const inner = expr[wrapper];219 if (isRecord(inner)) {220 return { [wrapper]: { ...inner, input: encodeRelationExpr(inner.input) } };221 }222 }223 return expr;224}225226function encodePolicyExpr(expr: unknown): unknown {227 if (!isRecord(expr)) return expr;228 switch (expr.type) {229 case "Cmp":230 case "Contains":231 return { ...expr, value: encodePolicyValue(expr.value) };232 case "SessionCmp":233 case "SessionContains":234 return {235 ...expr,236 value: encodeLiteral(isRecord(expr.value) ? expr.value.value : expr.value),237 };238 case "InList":239 return { ...expr, values: (expr.values as unknown[]).map(encodePolicyValue) };240 case "SessionInList":241 return {242 ...expr,243 values: (expr.values as unknown[]).map((value) =>244 encodeLiteral(isRecord(value) ? value.value : value)245 ),246 };247 case "Exists":248 return { ...expr, condition: encodePolicyExpr(expr.condition) };249 case "ExistsRel":250 return { ...expr, rel: encodeRelationExpr(expr.rel) };251 case "And":252 case "Or":253 return { ...expr, exprs: (expr.exprs as unknown[]).map(encodePolicyExpr) };254 case "Not":255 return { ...expr, expr: encodePolicyExpr(expr.expr) };256 default:257 return expr;258 }259}260261function encodeOwnPermissions(permissions: CompiledPermissions): Record<string, unknown> {262 const encoded: Record<string, unknown> = {};263 for (const [table, policies] of Object.entries(permissions as Record<string, unknown>)) {264 if (!isRecord(policies)) continue;265 const table_policies: Record<string, unknown> = {};266 for (const operation of ["select", "insert", "update", "delete"]) {267 const policy = policies[operation];268 if (!isRecord(policy)) continue;269 const normalized: Record<string, unknown> = {};270 if (policy.using !== undefined) normalized.using = encodePolicyExpr(policy.using);271 if (policy.with_check !== undefined) {272 normalized.with_check = encodePolicyExpr(policy.with_check);273 }274 table_policies[operation] = normalized;275 }276 encoded[table] = table_policies;277 }278 return encoded;279}280281async function adminFetch(282 target: StoreTarget,283 path: string,284 init?: { method: "POST"; body: unknown },285): Promise<unknown> {286 const base = target.serverUrl.replace(/\/+$/, "");287 const response = await fetch(`${base}/apps/${target.appId}/${path}`, {288 method: init?.method ?? "GET",289 headers: {290 ...(target.adminSecret ? { "X-Jazz-Admin-Secret": target.adminSecret } : {}),291 ...(init ? { "Content-Type": "application/json" } : {}),292 },293 ...(init ? { body: JSON.stringify(init.body) } : {}),294 });295 const text = await response.text();296 if (!response.ok) {297 throw new StoreProvisionError(298 "http",299 `store request ${path} failed: HTTP ${response.status} ${text.slice(0, 200)}`,300 );301 }302 try {303 return text ? JSON.parse(text) : {};304 } catch {305 throw new StoreProvisionError("http", `store request ${path} returned non-JSON`);306 }307}308309// Structural table comparison in the spirit of the engine's own schema310// equality: column order is irrelevant; every attribute that changes storage311// or merge behavior is significant.312function normalizeColumns(table: WasmTable): string {313 const columns = [...table.columns].sort((a, b) => a.name.localeCompare(b.name)).map((c) => ({314 name: c.name,315 column_type: c.column_type,316 nullable: c.nullable === true,317 references: c.references ?? null,318 default: c.default ?? null,319 merge_strategy: c.merge_strategy ?? null,320 }));321 return JSON.stringify(columns);322}323324function sameTable(a: WasmTable, b: WasmTable): boolean {325 return normalizeColumns(a) === normalizeColumns(b);326}327328function namespaceOf(tableName: string): string | null {329 const index = tableName.indexOf(NESTED_SEPARATOR);330 return index > 0 ? tableName.slice(0, index) : null;331}332333type AppSlice = {334 /** This app's tables in wire (wasm) form, keyed by mangled global name. */335 tables: WasmSchemaMap;336 /** This app's declared namespace prefixes. */337 namespaces: ReadonlySet<string>;338};339340function appSlice(app: unknown): AppSlice {341 if (nestedAppTables(app) === null) {342 throw new StoreProvisionError(343 "not-nested",344 "store provisioning requires a nested app: the namespace is what scopes the changes an " +345 "app may make to a shared store",346 );347 }348 const deployTarget = nestedAppDeployTarget(app) as { wasmSchema: WasmSchemaMap };349 const tables: WasmSchemaMap = {};350 const namespaces = new Set<string>();351 for (const [name, table] of Object.entries(deployTarget.wasmSchema)) {352 tables[name] = table;353 const namespace = namespaceOf(name);354 if (namespace) namespaces.add(namespace);355 }356 return { tables, namespaces };357}358359type StoreArtifacts = {360 head: PermissionsHead | null;361 permissions: Record<string, unknown>;362 schema: WasmSchemaMap;363};364365async function fetchStore(target: StoreTarget): Promise<StoreArtifacts> {366 const hashes = await adminFetch(target, "schemas") as { hashes?: string[] };367 const bundle = await adminFetch(target, "admin/permissions") as {368 head?: PermissionsHead | null;369 permissions?: Record<string, unknown>;370 };371 const head = bundle.head ?? null;372 if (!head || !hashes.hashes?.length) return { head: null, permissions: {}, schema: {} };373 const stored = await adminFetch(target, `schema/${encodeURIComponent(head.schemaHash)}`) as {374 schema?: WasmSchemaMap;375 };376 return { head, permissions: bundle.permissions ?? {}, schema: stored.schema ?? {} };377}378379function classify(slice: AppSlice, artifacts: StoreArtifacts): StoreStatus {380 if (!artifacts.head) return { state: "no_schema" };381 const headHash = artifacts.head.schemaHash;382 const missingTables: string[] = [];383 const driftedTables: string[] = [];384 for (const [name, expected] of Object.entries(slice.tables)) {385 const stored = artifacts.schema[name];386 if (!stored) missingTables.push(name);387 else if (!sameTable(stored, expected)) driftedTables.push(name);388 }389 // A stored table under this app's namespaces that the app does not declare390 // is drift too: either another writer reached into the slug, or the app is391 // older than the store — both need a human, not an auto-merge.392 for (const name of Object.keys(artifacts.schema)) {393 const namespace = namespaceOf(name);394 if (namespace && slice.namespaces.has(namespace) && !(name in slice.tables)) {395 driftedTables.push(name);396 }397 }398 if (driftedTables.length) return { state: "schema_drift", headHash, driftedTables };399 if (missingTables.length) return { state: "schema_out_of_date", headHash, missingTables };400 return { state: "ok", headHash };401}402403/**404 * Classifies the store's deployed schema against this app's slice without405 * changing anything. `no_schema` matters beyond provisioning: against an406 * empty store, client writes hang rather than fail, so callers should reach407 * this state before ever attaching sync to a fresh store.408 */409export async function readStoreStatus(app: unknown, target: StoreTarget): Promise<StoreStatus> {410 return classify(appSlice(app), await fetchStore(target));411}412413function assertOwnNamespacePermissions(414 slice: AppSlice,415 permissions: CompiledPermissions,416): void {417 for (const table of Object.keys(permissions)) {418 const namespace = namespaceOf(table);419 if (!namespace || !slice.namespaces.has(namespace)) {420 throw new StoreProvisionError(421 "outside-namespace",422 `permissions bundle names table "${table}" outside this app's namespaces ` +423 `(${[...slice.namespaces].join(", ")}) — an app may only provision its own slice`,424 );425 }426 }427}428429// A client declares its own compiled schema hash on the wire, and the server430// only serves hashes connected (via migrations) to the head enforcing431// permissions — a disconnected client's writes to its own tables hang. So432// provisioning also registers this app's own-slice schema and, when it is not433// the head itself, publishes the connecting migration (sibling tables as434// `added` lenses). Runs on every provision, so an `unchanged` pass self-heals435// a store whose slice arrived without the edge.436async function ensureSliceConnectivity(437 target: StoreTarget,438 slice: AppSlice,439 headHash: string,440 headSchema: WasmSchemaMap,441): Promise<void> {442 const own = await adminFetch(target, "admin/schemas", {443 method: "POST",444 body: { schema: slice.tables },445 }) as { hash: string };446 if (own.hash === headHash) return;447 const connectivity = await adminFetch(448 target,449 `admin/schema-connectivity?fromHash=${encodeURIComponent(own.hash)}&toHash=${450 encodeURIComponent(headHash)451 }`,452 ) as { connected?: boolean };453 if (connectivity.connected) return;454 const siblingTables = Object.keys(headSchema).filter((name) => !(name in slice.tables));455 await adminFetch(target, "admin/migrations", {456 method: "POST",457 body: {458 fromHash: own.hash,459 toHash: headHash,460 forward: siblingTables.map((table) => ({ table, added: true, operations: [] })),461 },462 });463}464465/**466 * Creates or updates this app's slice in the store. The stored head schema is467 * fetched verbatim and only extended: missing tables of this app's namespaces468 * are appended (key order of everything else preserved — the schema hash is469 * serialization-sensitive), sibling tables and their policies carry through470 * untouched, and the change publishes as schema + `added`-table migration +471 * a permissions bundle chained to the current head. The app's own compiled472 * schema is additionally registered and connected to the head, so this app's473 * clients — which declare that schema on the wire — are never disconnected.474 * Drift is never repaired: a store whose copy of this app's namespaces475 * differs from the declaration throws with the differing tables.476 */477export async function provisionStore(options: {478 app: unknown;479 permissions: CompiledPermissions;480 target: StoreTarget;481}): Promise<StoreProvisionResult> {482 const slice = appSlice(options.app);483 assertOwnNamespacePermissions(slice, options.permissions);484 const artifacts = await fetchStore(options.target);485 const status = classify(slice, artifacts);486487 if (status.state === "schema_drift") {488 throw new StoreProvisionError(489 "schema-drift",490 `store tables [${status.driftedTables.join(", ")}] differ from this app's declaration; ` +491 "drift is surfaced, never auto-repaired",492 );493 }494 if (status.state === "ok") {495 await ensureSliceConnectivity(options.target, slice, status.headHash, artifacts.schema);496 return { status: "unchanged", headHash: status.headHash };497 }498499 const ownPermissions = encodeOwnPermissions(options.permissions);500501 if (status.state === "no_schema") {502 const published = await adminFetch(options.target, "admin/schemas", {503 method: "POST",504 body: { schema: slice.tables },505 }) as { hash: string };506 await adminFetch(options.target, "admin/permissions", {507 method: "POST",508 body: {509 schemaHash: published.hash,510 permissions: ownPermissions,511 expectedParentBundleObjectId: null,512 },513 });514 return { status: "created", headHash: published.hash };515 }516517 // schema_out_of_date: append this app's missing tables to the stored schema518 // and swap in this app's policies, leaving every sibling entry untouched.519 const union: WasmSchemaMap = { ...artifacts.schema };520 for (const name of status.missingTables) union[name] = slice.tables[name];521 const unionPermissions: Record<string, unknown> = {};522 for (const [table, policies] of Object.entries(artifacts.permissions)) {523 const namespace = namespaceOf(table);524 if (!(namespace && slice.namespaces.has(namespace))) unionPermissions[table] = policies;525 }526 for (const [table, policies] of Object.entries(ownPermissions)) {527 unionPermissions[table] = policies;528 }529530 const head = artifacts.head as PermissionsHead;531 const published = await adminFetch(options.target, "admin/schemas", {532 method: "POST",533 body: { schema: union },534 }) as { hash: string };535 await adminFetch(options.target, "admin/migrations", {536 method: "POST",537 body: {538 fromHash: head.schemaHash,539 toHash: published.hash,540 forward: status.missingTables.map((table) => ({ table, added: true, operations: [] })),541 },542 });543 await adminFetch(options.target, "admin/permissions", {544 method: "POST",545 body: {546 schemaHash: published.hash,547 permissions: unionPermissions,548 expectedParentBundleObjectId: head.bundleObjectId,549 },550 });551 await ensureSliceConnectivity(options.target, slice, published.hash, union);552 return { status: "updated", headHash: published.hash };553}554555/** The result of the ticket-scoped store-status preflight. */556export type TicketStoreStatus =557 /** A schema is deployed; `headHash` is the newest stored schema hash. */558 | { state: "deployed"; appId: string; headHash: string }559 /** Nothing is deployed — writes would hang; surface the provisioning opt-in. */560 | { state: "no_schema"; appId: string }561 /** The node is reachable but its store is not (gate answered 502). */562 | { state: "store_unavailable" }563 /** The ticket was revoked or is unknown — treat the stored sink as dead. */564 | { state: "ticket_rejected" }565 /** The endpoint does not exist here (open-mode node or plain Jazz server). */566 | { state: "unsupported" };567568/**569 * The metadata-only store preflight any valid app-connect ticket may call —570 * no admin capability required. A self-hosted node answers571 * `GET <ticket.url>/store-status` from its own loopback store, which is what572 * lets a sync-only client learn `no_schema` (where writes hang) before ever573 * attaching sync, and prompt toward the provisioning opt-in instead.574 */575export async function readTicketStoreStatus(ticketUrl: string): Promise<TicketStoreStatus> {576 let response: Response;577 try {578 response = await fetch(`${ticketUrl.replace(/\/+$/, "")}/store-status`);579 } catch {580 return { state: "store_unavailable" };581 }582 if (response.status === 401) {583 await response.body?.cancel();584 return { state: "ticket_rejected" };585 }586 if (response.status === 502) {587 await response.body?.cancel();588 return { state: "store_unavailable" };589 }590 if (!response.ok) {591 await response.body?.cancel();592 return { state: "unsupported" };593 }594 try {595 const body = await response.json() as {596 v?: number;597 appId?: string;598 schema?: { deployed?: boolean; headHash?: string };599 };600 if (body.v !== 1 || typeof body.appId !== "string" || !body.schema) {601 return { state: "unsupported" };602 }603 return body.schema.deployed && typeof body.schema.headHash === "string"604 ? { state: "deployed", appId: body.appId, headHash: body.schema.headHash }605 : { state: "no_schema", appId: body.appId };606 } catch {607 return { state: "unsupported" };608 }609}