clone

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

feat/184-task-notice-migration
lofi / package / schema / nested.ts
nested.ts 13.9K
1/**2 * Nested app namespaces over one Jazz store.3 *4 * `defineNestedApp` groups tables into app-level namespaces5 * (`taskapp.tasks`, `notesapp.notes`) while compiling a single runtime6 * schema underneath — one hash, one migration lineage, one query planner.7 * Namespacing is a lofi-owned naming layer: each nested table flattens to a8 * global table name with a reserved separator, `defineSliceableApp` compiles9 * the flat schema once, and every namespace receives typed handles under its10 * unprefixed local names.11 *12 * @module13 */14import {15 type App,16 type CompiledPermissions,17 type PolicyContext,18 type Schema,19 schema,20 type SchemaDefinition,21} from "jazz-tools";22import { registerEncryptedColumns } from "./encrypted.ts";2324/**25 * The reserved separator between a namespace and a table in the flattened26 * global table name. It is deliberately not `.`: the permission builder27 * reserves `${string}.${string}` keys for qualified-column `where` entries.28 * Namespace and table names must not contain it.29 */30export const NESTED_SEPARATOR = "__";3132/** A nested schema definition: namespaces mapping to per-namespace tables. */33export type NestedSchemaDefinition = Record<string, SchemaDefinition>;3435/**36 * The value returned by {@link defineNestedApp}: one typed app per namespace,37 * each exposing that namespace's tables under their unprefixed local names.38 */39export type NestedApp<TDef extends NestedSchemaDefinition> = {40 [NS in keyof TDef & string]: App<Schema<TDef[NS]>>;41};4243declare const nestedAppRootBrand: unique symbol;4445/**46 * The opaque marker of a nested-app root. Only {@link defineNestedApp}47 * produces values of this type, so APIs that require a root — store48 * provisioning and the nested-table introspection helpers — state that49 * precondition in their signatures instead of failing at runtime. The marker50 * is type-level only; no property is added to the returned object.51 */52export type NestedAppRoot = {53 readonly [nestedAppRootBrand]: true;54};5556const nestedAppMetaKey = Symbol.for("@nzip/lofi/nested-app");57const namespaceMetaKey = Symbol.for("@nzip/lofi/nested-namespace");5859type NamespaceMeta = {60 namespace: string;61 /** The mangled-key slice app: `definePermissions` keys rules by app object keys. */62 permissionTarget: object;63 /** Pairs of [local table name, mangled global table name]. */64 tableNames: ReadonlyArray<readonly [string, string]>;65};6667type NestedAppMeta = {68 /** Every namespace's table handles, flattened — the runtime table registry. */69 tables: readonly object[];70 /** A slice over every table, keyed by mangled global names, for deploy tooling. */71 deployTarget: object;72};7374function assertNamePart(kind: "namespace" | "table", name: string): void {75 if (!name.trim()) throw new Error(`nested app ${kind} name must not be empty`);76 if (name.includes(".")) {77 throw new Error(`nested app ${kind} name "${name}" must not contain "."`);78 }79 if (name.includes(NESTED_SEPARATOR)) {80 throw new Error(81 `nested app ${kind} name "${name}" must not contain the reserved separator "${NESTED_SEPARATOR}"`,82 );83 }84}8586function mangle(namespace: string, table: string): string {87 return `${namespace}${NESTED_SEPARATOR}${table}`;88}8990type ColumnBuilderLike = {91 _references?: string;92 _targetTable?: string;93 _element?: ColumnBuilderLike;94};9596type TableSourceLike = {97 __jazzTableDefinition?: boolean;98 columns?: Record<string, ColumnBuilderLike>;99 indexedColumns?: readonly string[];100};101102function tableColumns(source: unknown): Record<string, ColumnBuilderLike> {103 const like = source as TableSourceLike;104 if (like && typeof like === "object" && like.__jazzTableDefinition === true && like.columns) {105 return like.columns;106 }107 return source as Record<string, ColumnBuilderLike>;108}109110// Rebuilds a column builder with its ref target renamed, preserving the111// builder's prototype so `_build` and the `_references` getter keep working.112// Builders are cloned, never mutated: the author's definition objects stay113// reusable.114function cloneWithTarget(builder: ColumnBuilderLike, target: string): ColumnBuilderLike {115 const clone = Object.create(116 Object.getPrototypeOf(builder),117 Object.getOwnPropertyDescriptors(builder),118 ) as ColumnBuilderLike;119 if (Object.hasOwn(builder, "_targetTable")) {120 clone._targetTable = target;121 return clone;122 }123 if (builder._element && builder._element._references !== undefined) {124 clone._element = cloneWithTarget(builder._element, target);125 return clone;126 }127 throw new Error(128 `nested app ref column has an unrecognized builder shape; ` +129 `cannot rewrite its target "${builder._references}"`,130 );131}132133function resolveRefTarget(134 definition: NestedSchemaDefinition,135 namespace: string,136 column: string,137 target: string,138): string {139 if (target.includes(NESTED_SEPARATOR)) {140 throw new Error(141 `ref target "${target}" (${namespace}, column "${column}") must use namespace names, ` +142 `not the reserved separator "${NESTED_SEPARATOR}"`,143 );144 }145 if (target.includes(".")) {146 const [otherNamespace, otherTable, ...rest] = target.split(".");147 if (rest.length > 0 || !definition[otherNamespace]?.[otherTable]) {148 throw new Error(149 `ref target "${target}" (${namespace}, column "${column}") does not name a ` +150 `declared "<namespace>.<table>"`,151 );152 }153 return mangle(otherNamespace, otherTable);154 }155 if (definition[namespace]?.[target]) return mangle(namespace, target);156 throw new Error(157 `ref target "${target}" (${namespace}, column "${column}") is not a table in this ` +158 `namespace; reference another namespace as "<namespace>.<table>"`,159 );160}161162function rewriteTableRefs(163 definition: NestedSchemaDefinition,164 namespace: string,165 source: unknown,166): unknown {167 const columns = tableColumns(source);168 const rewritten: Record<string, ColumnBuilderLike> = {};169 let changed = false;170 for (const [name, builder] of Object.entries(columns)) {171 const target = builder?._references;172 if (typeof target === "string") {173 rewritten[name] = cloneWithTarget(174 builder,175 resolveRefTarget(definition, namespace, name, target),176 );177 changed = true;178 } else {179 rewritten[name] = builder;180 }181 }182 if (!changed) return source;183 const like = source as TableSourceLike;184 return like.__jazzTableDefinition === true185 ? {186 __jazzTableDefinition: true,187 columns: rewritten,188 ...(like.indexedColumns ? { indexedColumns: like.indexedColumns } : {}),189 }190 : rewritten;191}192193/**194 * Flattens a nested schema definition into the single global table namespace195 * that actually compiles and deploys: `taskapp.tasks` becomes196 * `taskapp__tasks`. Ref targets written against namespace-local names (or as197 * `"<namespace>.<table>"` across namespaces) are rewritten to the mangled198 * global names. Use the flattened definitions as the `from`/`to` schemas when199 * authoring migrations over a nested app; moving a table between namespaces200 * is then an ordinary `renameTableFrom` migration.201 */202export function flattenNestedSchema(definition: NestedSchemaDefinition): SchemaDefinition {203 const flat: Record<string, unknown> = {};204 const namespaces = Object.entries(definition);205 if (namespaces.length === 0) throw new Error("nested app must declare at least one namespace");206 for (const [namespace, tables] of namespaces) {207 assertNamePart("namespace", namespace);208 const names = Object.keys(tables);209 if (names.length === 0) {210 throw new Error(`nested app namespace "${namespace}" must declare at least one table`);211 }212 for (const name of names) {213 assertNamePart("table", name);214 flat[mangle(namespace, name)] = rewriteTableRefs(definition, namespace, tables[name]);215 }216 }217 return flat as SchemaDefinition;218}219220/**221 * Declares nested app namespaces over one compiled schema. Each namespace's222 * tables become ordinary typed table handles under their unprefixed names:223 *224 * ```ts225 * export const root = s.defineNestedApp({226 * taskapp: {227 * tasks: s.table({ text: s.string(), completed: s.boolean() }),228 * },229 * notesapp: {230 * notes: s.table({ title: s.string() }),231 * },232 * });233 * // root.taskapp.tasks and root.notesapp.notes are ordinary table handles.234 * ```235 *236 * Handles are constructed exactly once, here — the runtime keys table stores237 * by handle identity, so this is the only expressible construction shape and238 * duplicate subscriptions cannot arise. Declare permissions per namespace239 * with {@link defineNestedPermissions} and combine the compiled bundles with240 * {@link mergeNestedPermissions}.241 *242 * Known constraint (alpha.53): every table is a runtime sibling of every243 * namespace, so the g-set isolation guidance cannot be satisfied inside one244 * store — keep g-set columns out of nested apps until the upstream245 * destabilization pin clears (see the conformance findings in246 * docs/decisions/schema-facade-alpha53.md).247 */248export function defineNestedApp<const TDef extends NestedSchemaDefinition>(249 definition: TDef,250): NestedApp<TDef> & NestedAppRoot {251 const flat = flattenNestedSchema(definition);252 registerEncryptedColumns(flat);253 const sliceable = schema.defineSliceableApp(flat as never);254 const root: Record<string, unknown> = {};255 const allTables: object[] = [];256 for (const [namespace, tables] of Object.entries(definition)) {257 const locals = Object.keys(tables);258 const mangled = locals.map((name) => mangle(namespace, name));259 const slice = sliceable.slice(260 ...(mangled as [string, ...string[]]),261 ) as unknown as Record<string, unknown>;262 const namespaceApp: Record<string, unknown> = {};263 for (let index = 0; index < locals.length; index++) {264 const handle = slice[mangled[index]] as object;265 namespaceApp[locals[index]] = handle;266 allTables.push(handle);267 }268 namespaceApp.union = slice.union;269 namespaceApp.wasmSchema = sliceable.wasmSchema;270 Object.defineProperty(namespaceApp, namespaceMetaKey, {271 value: {272 namespace,273 permissionTarget: slice,274 tableNames: locals.map((local, index) => [local, mangled[index]] as const),275 } satisfies NamespaceMeta,276 });277 root[namespace] = namespaceApp;278 }279 Object.defineProperty(root, nestedAppMetaKey, {280 value: {281 tables: allTables,282 deployTarget: sliceable.slice(283 ...(Object.keys(flat) as unknown as [string, ...string[]]),284 ),285 } satisfies NestedAppMeta,286 });287 return root as NestedApp<TDef> & NestedAppRoot;288}289290/**291 * Declares permissions for one namespace of a nested app. The policy context292 * exposes only that namespace's tables, under their unprefixed local names;293 * the compiled output is a flat record keyed by the mangled global table294 * names, so per-namespace bundles merge collision-free into the one deployed295 * bundle:296 *297 * ```ts298 * export const taskPermissions = s.defineNestedPermissions(299 * root.taskapp,300 * ({ policy }) => {301 * policy.tasks.allowRead.always();302 * },303 * );304 * ```305 *306 * Use this — not `s.definePermissions` — for nested namespaces: the pinned307 * `definePermissions` keys compiled rules by the app object's property308 * names, so calling it on a namespace directly would compile unprefixed309 * table names that the deployed schema does not contain.310 */311export function defineNestedPermissions<TApp extends object>(312 namespace: TApp,313 factory: (ctx: PolicyContext<TApp>) => void,314): CompiledPermissions {315 const meta = (namespace as { [namespaceMetaKey]?: NamespaceMeta })[namespaceMetaKey];316 if (!meta) {317 throw new Error(318 "defineNestedPermissions requires a namespace value from defineNestedApp",319 );320 }321 return schema.definePermissions(meta.permissionTarget as never, (ctx) => {322 const mangledPolicy = ctx.policy as unknown as Record<string, unknown>;323 const policy: Record<string, unknown> = {324 exists: mangledPolicy.exists,325 union: mangledPolicy.union,326 };327 for (const [local, mangled] of meta.tableNames) policy[local] = mangledPolicy[mangled];328 factory({ ...ctx, policy } as unknown as PolicyContext<TApp>);329 });330}331332/**333 * Combines per-namespace compiled permission bundles into the one bundle a334 * nested app deploys. Namespaced table names cannot collide by construction;335 * a duplicate key means the same namespace was compiled twice and throws.336 */337export function mergeNestedPermissions(338 ...bundles: readonly CompiledPermissions[]339): CompiledPermissions {340 const merged: CompiledPermissions = {};341 for (const bundle of bundles) {342 for (const [table, policies] of Object.entries(bundle)) {343 if (table in merged) {344 throw new Error(`merged permissions declare table "${table}" more than once`);345 }346 merged[table] = policies;347 }348 }349 return merged;350}351352/**353 * The flattened table-handle registry of a nested app root from354 * {@link defineNestedApp}. The runtime consumes this so nested tables355 * participate in boot readiness and local-to-managed row migration; the356 * handles are the same objects the namespaces expose, so store identity is357 * preserved. As runtime defense for callers that cast past the358 * {@link NestedAppRoot} requirement, a value without the root marker returns359 * `null`.360 */361export function nestedAppTables(app: NestedAppRoot): readonly object[] | null {362 if (!app || typeof app !== "object") return null;363 const meta = (app as { [nestedAppMetaKey]?: NestedAppMeta })[nestedAppMetaKey];364 return meta?.tables ?? null;365}366367/**368 * An app-like value over every table of a nested app root from369 * {@link defineNestedApp}, keyed by the mangled global table names — the370 * shape schema deploy tooling and the policy test harness expect. As runtime371 * defense for callers that cast past the {@link NestedAppRoot} requirement,372 * a value without the root marker throws.373 */374export function nestedAppDeployTarget(app: NestedAppRoot): object {375 const meta = app && typeof app === "object"376 ? (app as { [nestedAppMetaKey]?: NestedAppMeta })[nestedAppMetaKey]377 : undefined;378 if (!meta) throw new Error("nestedAppDeployTarget requires a root value from defineNestedApp");379 return meta.deployTarget;380}