clone

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

fix/175-template-quality-pass
lofi / package / schema / store.ts
store.ts 24.3K
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 {25 NESTED_SEPARATOR,26 nestedAppDeployTarget,27 type NestedAppRoot,28 nestedAppTables,29} from "./nested.ts";3031export type { NestedAppRoot } from "./nested.ts";3233/** The store to provision: where it is, which app it hosts, and the admin opt-in. */34export type StoreTarget = {35 /** The http(s) sync server URL (a ticket URL keeps its secret base path). */36 serverUrl: string;37 /** The Jazz app id of the store. */38 appId: string;39 /**40 * The store's admin secret — administration rides on top of transport41 * access. Omit it when `serverUrl` is a provision-scoped app-connect42 * ticket URL: the node's gate injects its own admin secret for such43 * requests (and strips any inbound one), so the secret never transits the44 * client.45 */46 adminSecret?: string;47};4849/**50 * How a store's deployed schema relates to this app's slice. State literals51 * are snake_case because they relay the sync node's status vocabulary52 * verbatim.53 */54export type StoreStatus =55 /** Nothing is deployed; writes against the store would hang. Remedy: create. */56 | { state: "no_schema" }57 /** The enforced schema carries this app's slice exactly. */58 | { state: "ok"; headHash: string }59 /**60 * The enforced schema lacks some of this app's tables (first enrollment of61 * this app, or a newer app version adding tables). Remedy: update.62 */63 | { state: "schema_out_of_date"; headHash: string; missingTables: readonly string[] }64 /**65 * The store's copy of this app's own namespaces differs from what the app66 * declares in a way its lineage does not explain. Never auto-repaired.67 */68 | { state: "schema_drift"; headHash: string; driftedTables: readonly string[] };6970/** The outcome of a provisioning run. */71export type StoreProvisionResult = {72 /** `created` deployed an initial schema; `updated` merged this app's slice. */73 status: "created" | "updated" | "unchanged";74 /** The schema hash now enforcing permissions. */75 headHash: string;76};7778/** Raised when provisioning is refused or the store rejects a request. */79export class StoreProvisionError extends Error {80 /** Stable error class name for diagnostics and error boundaries. */81 override readonly name = "StoreProvisionError";82 /**83 * Stable category for provisioning flows. `http` — the store answered a84 * request with a failure or unparseable body. `schema-drift` — the store's85 * copy of this app's namespaces differs from the declaration; surfaced,86 * never auto-repaired. `not-nested` — the supplied app value is not a87 * nested-app root. `outside-namespace` — the permissions bundle names a88 * table outside this app's declared namespaces. `policy-literal` — a policy89 * condition carries a literal value the wire encoding does not support.90 */91 readonly code: "http" | "schema-drift" | "not-nested" | "outside-namespace" | "policy-literal";92 /** Creates a provisioning rejection with a stable category. */93 constructor(94 code: StoreProvisionError["code"],95 message: string,96 ) {97 super(message);98 this.code = code;99 }100}101102type WasmColumn = {103 name: string;104 column_type: unknown;105 nullable?: boolean;106 references?: string;107 default?: unknown;108 merge_strategy?: unknown;109};110type WasmTable = { columns: WasmColumn[] };111type WasmSchemaMap = Record<string, WasmTable>;112113type PermissionsHead = {114 schemaHash: string;115 version: number;116 parentBundleObjectId: string | null;117 bundleObjectId: string;118};119120// ---------------------------------------------------------------------------121// Wire encoding of a freshly compiled permissions bundle.122//123// The store's write endpoints take policy literals as tagged values, but the124// pinned DSL's compiled output carries raw JS values, and the normalization125// that bridges them is not a public jazz-tools export (the module that could126// provide it is not browser-safe). This section mirrors that normalization;127// the store conformance suite deploys through it against the real server, so128// an alpha bump that changes the wire contract fails there loudly. Policies129// read back from the store are already wire-form and are never re-encoded.130// ---------------------------------------------------------------------------131132function isRecord(value: unknown): value is Record<string, unknown> {133 return typeof value === "object" && value !== null && !Array.isArray(value);134}135136const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;137138function encodeLiteral(value: unknown): unknown {139 if (value === null) return { type: "Null" };140 if (value instanceof Date) return { type: "Timestamp", value: value.getTime() };141 if (value instanceof Uint8Array) {142 throw new StoreProvisionError(143 "policy-literal",144 "bytes literals in policy conditions are not supported by store provisioning",145 );146 }147 if (typeof value === "boolean") return { type: "Boolean", value };148 if (typeof value === "number") {149 if (!Number.isFinite(value)) {150 throw new StoreProvisionError(151 "policy-literal",152 "policy literals only support finite numbers",153 );154 }155 if (!Number.isInteger(value)) return { type: "Double", value };156 if (value >= -2147483648 && value <= 2147483647) return { type: "Integer", value };157 if (Number.isSafeInteger(value)) return { type: "BigInt", value };158 return { type: "Double", value };159 }160 if (typeof value === "string") {161 return UUID_LIKE.test(value) ? { type: "Uuid", value } : { type: "Text", value };162 }163 if (Array.isArray(value)) return { type: "Array", value: value.map(encodeLiteral) };164 if (isRecord(value) && typeof value.type === "string") return value;165 throw new StoreProvisionError(166 "policy-literal",167 "policy literals must be scalars, arrays, Date, or tagged values",168 );169}170171function encodePolicyValue(value: unknown): unknown {172 if (isRecord(value) && value.type === "SessionRef") return value;173 const raw = isRecord(value) && "value" in value ? value.value : value;174 return { type: "Literal", value: encodeLiteral(raw) };175}176177function encodeRelationValueRef(value: unknown): unknown {178 if (isRecord(value) && "Literal" in value) return { Literal: encodeLiteral(value.Literal) };179 return value;180}181182function encodeRelationPredicate(predicate: unknown): unknown {183 if (!isRecord(predicate)) return predicate;184 if ("Cmp" in predicate && isRecord(predicate.Cmp)) {185 return { Cmp: { ...predicate.Cmp, right: encodeRelationValueRef(predicate.Cmp.right) } };186 }187 if ("Contains" in predicate && isRecord(predicate.Contains)) {188 return {189 Contains: { ...predicate.Contains, right: encodeRelationValueRef(predicate.Contains.right) },190 };191 }192 if ("In" in predicate && isRecord(predicate.In)) {193 return {194 In: {195 ...predicate.In,196 values: (predicate.In.values as unknown[]).map(encodeRelationValueRef),197 },198 };199 }200 if ("And" in predicate) {201 return { And: (predicate.And as unknown[]).map(encodeRelationPredicate) };202 }203 if ("Or" in predicate) return { Or: (predicate.Or as unknown[]).map(encodeRelationPredicate) };204 if ("Not" in predicate) return { Not: encodeRelationPredicate(predicate.Not) };205 return predicate;206}207208function encodeRelationExpr(expr: unknown): unknown {209 if (!isRecord(expr)) return expr;210 if ("Filter" in expr && isRecord(expr.Filter)) {211 return {212 Filter: {213 input: encodeRelationExpr(expr.Filter.input),214 predicate: encodeRelationPredicate(expr.Filter.predicate),215 },216 };217 }218 if ("Union" in expr && isRecord(expr.Union)) {219 return { Union: { inputs: (expr.Union.inputs as unknown[]).map(encodeRelationExpr) } };220 }221 if ("Join" in expr && isRecord(expr.Join)) {222 return {223 Join: {224 ...expr.Join,225 left: encodeRelationExpr(expr.Join.left),226 right: encodeRelationExpr(expr.Join.right),227 },228 };229 }230 if ("Gather" in expr && isRecord(expr.Gather)) {231 return {232 Gather: {233 ...expr.Gather,234 seed: encodeRelationExpr(expr.Gather.seed),235 step: encodeRelationExpr(expr.Gather.step),236 },237 };238 }239 for (const wrapper of ["Project", "Distinct", "OrderBy", "Offset", "Limit"]) {240 const inner = expr[wrapper];241 if (isRecord(inner)) {242 return { [wrapper]: { ...inner, input: encodeRelationExpr(inner.input) } };243 }244 }245 return expr;246}247248function encodePolicyExpr(expr: unknown): unknown {249 if (!isRecord(expr)) return expr;250 switch (expr.type) {251 case "Cmp":252 case "Contains":253 return { ...expr, value: encodePolicyValue(expr.value) };254 case "SessionCmp":255 case "SessionContains":256 return {257 ...expr,258 value: encodeLiteral(isRecord(expr.value) ? expr.value.value : expr.value),259 };260 case "InList":261 return { ...expr, values: (expr.values as unknown[]).map(encodePolicyValue) };262 case "SessionInList":263 return {264 ...expr,265 values: (expr.values as unknown[]).map((value) =>266 encodeLiteral(isRecord(value) ? value.value : value)267 ),268 };269 case "Exists":270 return { ...expr, condition: encodePolicyExpr(expr.condition) };271 case "ExistsRel":272 return { ...expr, rel: encodeRelationExpr(expr.rel) };273 case "And":274 case "Or":275 return { ...expr, exprs: (expr.exprs as unknown[]).map(encodePolicyExpr) };276 case "Not":277 return { ...expr, expr: encodePolicyExpr(expr.expr) };278 default:279 return expr;280 }281}282283function encodeOwnPermissions(permissions: CompiledPermissions): Record<string, unknown> {284 const encoded: Record<string, unknown> = {};285 for (const [table, policies] of Object.entries(permissions as Record<string, unknown>)) {286 if (!isRecord(policies)) continue;287 const table_policies: Record<string, unknown> = {};288 for (const operation of ["select", "insert", "update", "delete"]) {289 const policy = policies[operation];290 if (!isRecord(policy)) continue;291 const normalized: Record<string, unknown> = {};292 if (policy.using !== undefined) normalized.using = encodePolicyExpr(policy.using);293 if (policy.with_check !== undefined) {294 normalized.with_check = encodePolicyExpr(policy.with_check);295 }296 table_policies[operation] = normalized;297 }298 encoded[table] = table_policies;299 }300 return encoded;301}302303async function adminFetch(304 target: StoreTarget,305 path: string,306 init?: { method: "POST"; body: unknown },307): Promise<unknown> {308 const base = target.serverUrl.replace(/\/+$/, "");309 const response = await fetch(`${base}/apps/${target.appId}/${path}`, {310 method: init?.method ?? "GET",311 headers: {312 ...(target.adminSecret ? { "X-Jazz-Admin-Secret": target.adminSecret } : {}),313 ...(init ? { "Content-Type": "application/json" } : {}),314 },315 ...(init ? { body: JSON.stringify(init.body) } : {}),316 });317 const text = await response.text();318 if (!response.ok) {319 throw new StoreProvisionError(320 "http",321 `store request ${path} failed: HTTP ${response.status} ${text.slice(0, 200)}`,322 );323 }324 try {325 return text ? JSON.parse(text) : {};326 } catch {327 throw new StoreProvisionError("http", `store request ${path} returned non-JSON`);328 }329}330331// Structural table comparison in the spirit of the engine's own schema332// equality: column order is irrelevant; every attribute that changes storage333// or merge behavior is significant.334function normalizeColumns(table: WasmTable): string {335 const columns = [...table.columns].sort((a, b) => a.name.localeCompare(b.name)).map((c) => ({336 name: c.name,337 column_type: c.column_type,338 nullable: c.nullable === true,339 references: c.references ?? null,340 default: c.default ?? null,341 merge_strategy: c.merge_strategy ?? null,342 }));343 return JSON.stringify(columns);344}345346function sameTable(a: WasmTable, b: WasmTable): boolean {347 return normalizeColumns(a) === normalizeColumns(b);348}349350function namespaceOf(tableName: string): string | null {351 const index = tableName.indexOf(NESTED_SEPARATOR);352 return index > 0 ? tableName.slice(0, index) : null;353}354355type AppSlice = {356 /** This app's tables in wire (wasm) form, keyed by mangled global name. */357 tables: WasmSchemaMap;358 /** This app's declared namespace prefixes. */359 namespaces: ReadonlySet<string>;360};361362function appSlice(app: NestedAppRoot): AppSlice {363 if (nestedAppTables(app) === null) {364 throw new StoreProvisionError(365 "not-nested",366 "store provisioning requires a nested app: the namespace is what scopes the changes an " +367 "app may make to a shared store",368 );369 }370 const deployTarget = nestedAppDeployTarget(app) as { wasmSchema: WasmSchemaMap };371 const tables: WasmSchemaMap = {};372 const namespaces = new Set<string>();373 for (const [name, table] of Object.entries(deployTarget.wasmSchema)) {374 tables[name] = table;375 const namespace = namespaceOf(name);376 if (namespace) namespaces.add(namespace);377 }378 return { tables, namespaces };379}380381type StoreArtifacts = {382 head: PermissionsHead | null;383 permissions: Record<string, unknown>;384 schema: WasmSchemaMap;385};386387async function fetchStore(target: StoreTarget): Promise<StoreArtifacts> {388 const hashes = await adminFetch(target, "schemas") as { hashes?: string[] };389 const bundle = await adminFetch(target, "admin/permissions") as {390 head?: PermissionsHead | null;391 permissions?: Record<string, unknown>;392 };393 const head = bundle.head ?? null;394 if (!head || !hashes.hashes?.length) return { head: null, permissions: {}, schema: {} };395 const stored = await adminFetch(target, `schema/${encodeURIComponent(head.schemaHash)}`) as {396 schema?: WasmSchemaMap;397 };398 return { head, permissions: bundle.permissions ?? {}, schema: stored.schema ?? {} };399}400401function classify(slice: AppSlice, artifacts: StoreArtifacts): StoreStatus {402 if (!artifacts.head) return { state: "no_schema" };403 const headHash = artifacts.head.schemaHash;404 const missingTables: string[] = [];405 const driftedTables: string[] = [];406 for (const [name, expected] of Object.entries(slice.tables)) {407 const stored = artifacts.schema[name];408 if (!stored) missingTables.push(name);409 else if (!sameTable(stored, expected)) driftedTables.push(name);410 }411 // A stored table under this app's namespaces that the app does not declare412 // is drift too: either another writer reached into the slug, or the app is413 // older than the store — both need a human, not an auto-merge.414 for (const name of Object.keys(artifacts.schema)) {415 const namespace = namespaceOf(name);416 if (namespace && slice.namespaces.has(namespace) && !(name in slice.tables)) {417 driftedTables.push(name);418 }419 }420 if (driftedTables.length) return { state: "schema_drift", headHash, driftedTables };421 if (missingTables.length) return { state: "schema_out_of_date", headHash, missingTables };422 return { state: "ok", headHash };423}424425/**426 * Classifies the store's deployed schema against this app's slice without427 * changing anything. `app` must be a nested-app root from428 * `s.defineNestedApp` — the namespace is what scopes an app's view of a429 * shared store. `no_schema` matters beyond provisioning: against an empty430 * store, client writes hang rather than fail, so callers should reach this431 * state before ever attaching sync to a fresh store.432 */433export async function readStoreStatus(434 app: NestedAppRoot,435 target: StoreTarget,436): Promise<StoreStatus> {437 return classify(appSlice(app), await fetchStore(target));438}439440function assertOwnNamespacePermissions(441 slice: AppSlice,442 permissions: CompiledPermissions,443): void {444 for (const table of Object.keys(permissions)) {445 const namespace = namespaceOf(table);446 if (!namespace || !slice.namespaces.has(namespace)) {447 throw new StoreProvisionError(448 "outside-namespace",449 `permissions bundle names table "${table}" outside this app's namespaces ` +450 `(${[...slice.namespaces].join(", ")}) — an app may only provision its own slice`,451 );452 }453 }454}455456// A client declares its own compiled schema hash on the wire, and the server457// only serves hashes connected (via migrations) to the head enforcing458// permissions — a disconnected client's writes to its own tables hang. So459// provisioning also registers this app's own-slice schema and, when it is not460// the head itself, publishes the connecting migration (sibling tables as461// `added` lenses). Runs on every provision, so an `unchanged` pass self-heals462// a store whose slice arrived without the edge.463async function ensureSliceConnectivity(464 target: StoreTarget,465 slice: AppSlice,466 headHash: string,467 headSchema: WasmSchemaMap,468): Promise<void> {469 const own = await adminFetch(target, "admin/schemas", {470 method: "POST",471 body: { schema: slice.tables },472 }) as { hash: string };473 if (own.hash === headHash) return;474 const connectivity = await adminFetch(475 target,476 `admin/schema-connectivity?fromHash=${encodeURIComponent(own.hash)}&toHash=${477 encodeURIComponent(headHash)478 }`,479 ) as { connected?: boolean };480 if (connectivity.connected) return;481 const siblingTables = Object.keys(headSchema).filter((name) => !(name in slice.tables));482 await adminFetch(target, "admin/migrations", {483 method: "POST",484 body: {485 fromHash: own.hash,486 toHash: headHash,487 forward: siblingTables.map((table) => ({ table, added: true, operations: [] })),488 },489 });490}491492/** What {@link provisionStore} deploys, and where. */493export type ProvisionStoreOptions = {494 /** The nested-app root (from `s.defineNestedApp`) whose slice is provisioned. */495 app: NestedAppRoot;496 /** The app's compiled permissions bundle for its own namespaces. */497 permissions: CompiledPermissions;498 /** The store to provision, with its admin opt-in. */499 target: StoreTarget;500};501502/**503 * Creates or updates this app's slice in the store. The stored head schema is504 * fetched verbatim and only extended: missing tables of this app's namespaces505 * are appended (key order of everything else preserved — the schema hash is506 * serialization-sensitive), sibling tables and their policies carry through507 * untouched, and the change publishes as schema + `added`-table migration +508 * a permissions bundle chained to the current head. The app's own compiled509 * schema is additionally registered and connected to the head, so this app's510 * clients — which declare that schema on the wire — are never disconnected.511 * Drift is never repaired: a store whose copy of this app's namespaces512 * differs from the declaration throws with the differing tables.513 */514export async function provisionStore(515 options: ProvisionStoreOptions,516): Promise<StoreProvisionResult> {517 const slice = appSlice(options.app);518 assertOwnNamespacePermissions(slice, options.permissions);519 const artifacts = await fetchStore(options.target);520 const status = classify(slice, artifacts);521522 if (status.state === "schema_drift") {523 throw new StoreProvisionError(524 "schema-drift",525 `store tables [${status.driftedTables.join(", ")}] differ from this app's declaration; ` +526 "drift is surfaced, never auto-repaired",527 );528 }529 if (status.state === "ok") {530 await ensureSliceConnectivity(options.target, slice, status.headHash, artifacts.schema);531 return { status: "unchanged", headHash: status.headHash };532 }533534 const ownPermissions = encodeOwnPermissions(options.permissions);535536 if (status.state === "no_schema") {537 const published = await adminFetch(options.target, "admin/schemas", {538 method: "POST",539 body: { schema: slice.tables },540 }) as { hash: string };541 await adminFetch(options.target, "admin/permissions", {542 method: "POST",543 body: {544 schemaHash: published.hash,545 permissions: ownPermissions,546 expectedParentBundleObjectId: null,547 },548 });549 return { status: "created", headHash: published.hash };550 }551552 // schema_out_of_date: append this app's missing tables to the stored schema553 // and swap in this app's policies, leaving every sibling entry untouched.554 const union: WasmSchemaMap = { ...artifacts.schema };555 for (const name of status.missingTables) union[name] = slice.tables[name];556 const unionPermissions: Record<string, unknown> = {};557 for (const [table, policies] of Object.entries(artifacts.permissions)) {558 const namespace = namespaceOf(table);559 if (!(namespace && slice.namespaces.has(namespace))) unionPermissions[table] = policies;560 }561 for (const [table, policies] of Object.entries(ownPermissions)) {562 unionPermissions[table] = policies;563 }564565 const head = artifacts.head as PermissionsHead;566 const published = await adminFetch(options.target, "admin/schemas", {567 method: "POST",568 body: { schema: union },569 }) as { hash: string };570 await adminFetch(options.target, "admin/migrations", {571 method: "POST",572 body: {573 fromHash: head.schemaHash,574 toHash: published.hash,575 forward: status.missingTables.map((table) => ({ table, added: true, operations: [] })),576 },577 });578 await adminFetch(options.target, "admin/permissions", {579 method: "POST",580 body: {581 schemaHash: published.hash,582 permissions: unionPermissions,583 expectedParentBundleObjectId: head.bundleObjectId,584 },585 });586 await ensureSliceConnectivity(options.target, slice, published.hash, union);587 return { status: "updated", headHash: published.hash };588}589590/**591 * The result of the ticket-scoped store-status preflight. State literals are592 * snake_case because they relay the sync node's status vocabulary verbatim.593 */594export type TicketStoreStatus =595 /** A schema is deployed; `headHash` is the newest stored schema hash. */596 | { state: "deployed"; appId: string; headHash: string }597 /** Nothing is deployed — writes would hang; surface the provisioning opt-in. */598 | { state: "no_schema"; appId: string }599 /** The node is reachable but its store is not (gate answered 502). */600 | { state: "store_unavailable" }601 /** The ticket was revoked or is unknown — treat the stored sink as dead. */602 | { state: "ticket_rejected" }603 /** The endpoint does not exist here (open-mode node or plain Jazz server). */604 | { state: "unsupported" };605606/**607 * The metadata-only store preflight any valid app-connect ticket may call —608 * no admin capability required. A self-hosted node answers609 * `GET <ticket.url>/store-status` from its own loopback store, which is what610 * lets a sync-only client learn `no_schema` (where writes hang) before ever611 * attaching sync, and prompt toward the provisioning opt-in instead.612 */613export async function readTicketStoreStatus(ticketUrl: string): Promise<TicketStoreStatus> {614 let response: Response;615 try {616 response = await fetch(`${ticketUrl.replace(/\/+$/, "")}/store-status`);617 } catch {618 return { state: "store_unavailable" };619 }620 if (response.status === 401) {621 await response.body?.cancel();622 return { state: "ticket_rejected" };623 }624 if (response.status === 502) {625 await response.body?.cancel();626 return { state: "store_unavailable" };627 }628 if (!response.ok) {629 await response.body?.cancel();630 return { state: "unsupported" };631 }632 try {633 const body = await response.json() as {634 v?: number;635 appId?: string;636 schema?: { deployed?: boolean; headHash?: string };637 };638 if (body.v !== 1 || typeof body.appId !== "string" || !body.schema) {639 return { state: "unsupported" };640 }641 return body.schema.deployed && typeof body.schema.headHash === "string"642 ? { state: "deployed", appId: body.appId, headHash: body.schema.headHash }643 : { state: "no_schema", appId: body.appId };644 } catch {645 return { state: "unsupported" };646 }647}