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 / nested.ts
nested.ts 13.0K
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";2223/**24 * The reserved separator between a namespace and a table in the flattened25 * global table name. It is deliberately not `.`: the permission builder26 * reserves `${string}.${string}` keys for qualified-column `where` entries.27 * Namespace and table names must not contain it.28 */29export const NESTED_SEPARATOR = "__";3031/** A nested schema definition: namespaces mapping to per-namespace tables. */32export type NestedSchemaDefinition = Record<string, SchemaDefinition>;3334/**35 * The value returned by {@link defineNestedApp}: one typed app per namespace,36 * each exposing that namespace's tables under their unprefixed local names.37 */38export type NestedApp<TDef extends NestedSchemaDefinition> = {39 [NS in keyof TDef & string]: App<Schema<TDef[NS]>>;40};4142const nestedAppMetaKey = Symbol.for("@nzip/lofi/nested-app");43const namespaceMetaKey = Symbol.for("@nzip/lofi/nested-namespace");4445type NamespaceMeta = {46 namespace: string;47 /** The mangled-key slice app: `definePermissions` keys rules by app object keys. */48 permissionTarget: object;49 /** Pairs of [local table name, mangled global table name]. */50 tableNames: ReadonlyArray<readonly [string, string]>;51};5253type NestedAppMeta = {54 /** Every namespace's table handles, flattened — the runtime table registry. */55 tables: readonly object[];56 /** A slice over every table, keyed by mangled global names, for deploy tooling. */57 deployTarget: object;58};5960function assertNamePart(kind: "namespace" | "table", name: string): void {61 if (!name.trim()) throw new Error(`nested app ${kind} name must not be empty`);62 if (name.includes(".")) {63 throw new Error(`nested app ${kind} name "${name}" must not contain "."`);64 }65 if (name.includes(NESTED_SEPARATOR)) {66 throw new Error(67 `nested app ${kind} name "${name}" must not contain the reserved separator "${NESTED_SEPARATOR}"`,68 );69 }70}7172function mangle(namespace: string, table: string): string {73 return `${namespace}${NESTED_SEPARATOR}${table}`;74}7576type ColumnBuilderLike = {77 _references?: string;78 _targetTable?: string;79 _element?: ColumnBuilderLike;80};8182type TableSourceLike = {83 __jazzTableDefinition?: boolean;84 columns?: Record<string, ColumnBuilderLike>;85 indexedColumns?: readonly string[];86};8788function tableColumns(source: unknown): Record<string, ColumnBuilderLike> {89 const like = source as TableSourceLike;90 if (like && typeof like === "object" && like.__jazzTableDefinition === true && like.columns) {91 return like.columns;92 }93 return source as Record<string, ColumnBuilderLike>;94}9596// Rebuilds a column builder with its ref target renamed, preserving the97// builder's prototype so `_build` and the `_references` getter keep working.98// Builders are cloned, never mutated: the author's definition objects stay99// reusable.100function cloneWithTarget(builder: ColumnBuilderLike, target: string): ColumnBuilderLike {101 const clone = Object.create(102 Object.getPrototypeOf(builder),103 Object.getOwnPropertyDescriptors(builder),104 ) as ColumnBuilderLike;105 if (Object.hasOwn(builder, "_targetTable")) {106 clone._targetTable = target;107 return clone;108 }109 if (builder._element && builder._element._references !== undefined) {110 clone._element = cloneWithTarget(builder._element, target);111 return clone;112 }113 throw new Error(114 `nested app ref column has an unrecognized builder shape; ` +115 `cannot rewrite its target "${builder._references}"`,116 );117}118119function resolveRefTarget(120 definition: NestedSchemaDefinition,121 namespace: string,122 column: string,123 target: string,124): string {125 if (target.includes(NESTED_SEPARATOR)) {126 throw new Error(127 `ref target "${target}" (${namespace}, column "${column}") must use namespace names, ` +128 `not the reserved separator "${NESTED_SEPARATOR}"`,129 );130 }131 if (target.includes(".")) {132 const [otherNamespace, otherTable, ...rest] = target.split(".");133 if (rest.length > 0 || !definition[otherNamespace]?.[otherTable]) {134 throw new Error(135 `ref target "${target}" (${namespace}, column "${column}") does not name a ` +136 `declared "<namespace>.<table>"`,137 );138 }139 return mangle(otherNamespace, otherTable);140 }141 if (definition[namespace]?.[target]) return mangle(namespace, target);142 throw new Error(143 `ref target "${target}" (${namespace}, column "${column}") is not a table in this ` +144 `namespace; reference another namespace as "<namespace>.<table>"`,145 );146}147148function rewriteTableRefs(149 definition: NestedSchemaDefinition,150 namespace: string,151 source: unknown,152): unknown {153 const columns = tableColumns(source);154 const rewritten: Record<string, ColumnBuilderLike> = {};155 let changed = false;156 for (const [name, builder] of Object.entries(columns)) {157 const target = builder?._references;158 if (typeof target === "string") {159 rewritten[name] = cloneWithTarget(160 builder,161 resolveRefTarget(definition, namespace, name, target),162 );163 changed = true;164 } else {165 rewritten[name] = builder;166 }167 }168 if (!changed) return source;169 const like = source as TableSourceLike;170 return like.__jazzTableDefinition === true171 ? {172 __jazzTableDefinition: true,173 columns: rewritten,174 ...(like.indexedColumns ? { indexedColumns: like.indexedColumns } : {}),175 }176 : rewritten;177}178179/**180 * Flattens a nested schema definition into the single global table namespace181 * that actually compiles and deploys: `taskapp.tasks` becomes182 * `taskapp__tasks`. Ref targets written against namespace-local names (or as183 * `"<namespace>.<table>"` across namespaces) are rewritten to the mangled184 * global names. Use the flattened definitions as the `from`/`to` schemas when185 * authoring migrations over a nested app; moving a table between namespaces186 * is then an ordinary `renameTableFrom` migration.187 */188export function flattenNestedSchema(definition: NestedSchemaDefinition): SchemaDefinition {189 const flat: Record<string, unknown> = {};190 const namespaces = Object.entries(definition);191 if (namespaces.length === 0) throw new Error("nested app must declare at least one namespace");192 for (const [namespace, tables] of namespaces) {193 assertNamePart("namespace", namespace);194 const names = Object.keys(tables);195 if (names.length === 0) {196 throw new Error(`nested app namespace "${namespace}" must declare at least one table`);197 }198 for (const name of names) {199 assertNamePart("table", name);200 flat[mangle(namespace, name)] = rewriteTableRefs(definition, namespace, tables[name]);201 }202 }203 return flat as SchemaDefinition;204}205206/**207 * Declares nested app namespaces over one compiled schema. Each namespace's208 * tables become ordinary typed table handles under their unprefixed names:209 *210 * ```ts211 * export const root = s.defineNestedApp({212 * taskapp: {213 * tasks: s.table({ text: s.string(), completed: s.boolean() }),214 * },215 * notesapp: {216 * notes: s.table({ title: s.string() }),217 * },218 * });219 * // root.taskapp.tasks and root.notesapp.notes are ordinary table handles.220 * ```221 *222 * Handles are constructed exactly once, here — the runtime keys table stores223 * by handle identity, so this is the only expressible construction shape and224 * duplicate subscriptions cannot arise. Declare permissions per namespace225 * with {@link defineNestedPermissions} and combine the compiled bundles with226 * {@link mergeNestedPermissions}.227 *228 * Known constraint (alpha.53): every table is a runtime sibling of every229 * namespace, so the g-set isolation guidance cannot be satisfied inside one230 * store — keep g-set columns out of nested apps until the upstream231 * destabilization pin clears (see the conformance findings in232 * docs/decisions/schema-facade-alpha53.md).233 */234export function defineNestedApp<const TDef extends NestedSchemaDefinition>(235 definition: TDef,236): NestedApp<TDef> {237 const flat = flattenNestedSchema(definition);238 const sliceable = schema.defineSliceableApp(flat as never);239 const root: Record<string, unknown> = {};240 const allTables: object[] = [];241 for (const [namespace, tables] of Object.entries(definition)) {242 const locals = Object.keys(tables);243 const mangled = locals.map((name) => mangle(namespace, name));244 const slice = sliceable.slice(245 ...(mangled as [string, ...string[]]),246 ) as unknown as Record<string, unknown>;247 const namespaceApp: Record<string, unknown> = {};248 for (let index = 0; index < locals.length; index++) {249 const handle = slice[mangled[index]] as object;250 namespaceApp[locals[index]] = handle;251 allTables.push(handle);252 }253 namespaceApp.union = slice.union;254 namespaceApp.wasmSchema = sliceable.wasmSchema;255 Object.defineProperty(namespaceApp, namespaceMetaKey, {256 value: {257 namespace,258 permissionTarget: slice,259 tableNames: locals.map((local, index) => [local, mangled[index]] as const),260 } satisfies NamespaceMeta,261 });262 root[namespace] = namespaceApp;263 }264 Object.defineProperty(root, nestedAppMetaKey, {265 value: {266 tables: allTables,267 deployTarget: sliceable.slice(268 ...(Object.keys(flat) as unknown as [string, ...string[]]),269 ),270 } satisfies NestedAppMeta,271 });272 return root as NestedApp<TDef>;273}274275/**276 * Declares permissions for one namespace of a nested app. The policy context277 * exposes only that namespace's tables, under their unprefixed local names;278 * the compiled output is a flat record keyed by the mangled global table279 * names, so per-namespace bundles merge collision-free into the one deployed280 * bundle:281 *282 * ```ts283 * export const taskPermissions = s.defineNestedPermissions(284 * root.taskapp,285 * ({ policy }) => {286 * policy.tasks.allowRead.always();287 * },288 * );289 * ```290 *291 * Use this — not `s.definePermissions` — for nested namespaces: the pinned292 * `definePermissions` keys compiled rules by the app object's property293 * names, so calling it on a namespace directly would compile unprefixed294 * table names that the deployed schema does not contain.295 */296export function defineNestedPermissions<TApp extends object>(297 namespace: TApp,298 factory: (ctx: PolicyContext<TApp>) => void,299): CompiledPermissions {300 const meta = (namespace as { [namespaceMetaKey]?: NamespaceMeta })[namespaceMetaKey];301 if (!meta) {302 throw new Error(303 "defineNestedPermissions requires a namespace value from defineNestedApp",304 );305 }306 return schema.definePermissions(meta.permissionTarget as never, (ctx) => {307 const mangledPolicy = ctx.policy as unknown as Record<string, unknown>;308 const policy: Record<string, unknown> = {309 exists: mangledPolicy.exists,310 union: mangledPolicy.union,311 };312 for (const [local, mangled] of meta.tableNames) policy[local] = mangledPolicy[mangled];313 factory({ ...ctx, policy } as unknown as PolicyContext<TApp>);314 });315}316317/**318 * Combines per-namespace compiled permission bundles into the one bundle a319 * nested app deploys. Namespaced table names cannot collide by construction;320 * a duplicate key means the same namespace was compiled twice and throws.321 */322export function mergeNestedPermissions(323 ...bundles: readonly CompiledPermissions[]324): CompiledPermissions {325 const merged: CompiledPermissions = {};326 for (const bundle of bundles) {327 for (const [table, policies] of Object.entries(bundle)) {328 if (table in merged) {329 throw new Error(`merged permissions declare table "${table}" more than once`);330 }331 merged[table] = policies;332 }333 }334 return merged;335}336337/**338 * The flattened table-handle registry of a nested app, or `null` when the339 * value is not a nested app root. The runtime consumes this so nested tables340 * participate in boot readiness and local-to-managed row migration; the341 * handles are the same objects the namespaces expose, so store identity is342 * preserved.343 */344export function nestedAppTables(app: unknown): readonly object[] | null {345 if (!app || typeof app !== "object") return null;346 const meta = (app as { [nestedAppMetaKey]?: NestedAppMeta })[nestedAppMetaKey];347 return meta?.tables ?? null;348}349350/**351 * An app-like value over every table of a nested app, keyed by the mangled352 * global table names — the shape schema deploy tooling and the policy test353 * harness expect. Throws when the value is not a nested app root.354 */355export function nestedAppDeployTarget(app: unknown): object {356 const meta = app && typeof app === "object"357 ? (app as { [nestedAppMetaKey]?: NestedAppMeta })[nestedAppMetaKey]358 : undefined;359 if (!meta) throw new Error("nestedAppDeployTarget requires a root value from defineNestedApp");360 return meta.deployTarget;361}