clone

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

Merge pull request #108 from FelineStateMachine/feat/nested-namespaces

FelineStateMachine committed cc3e4cb
feat(schema): nested app namespaces over one sliceable schema (#107 part 1)
commit
cc3e4cbd356dbe3ea5b1e33727478481f08cc24a
parent
f29e9c9d46b02fa5947ac77b37414e476f762048
parent
21ee93e2eb2ec462f671efc2d4322bb43b76f20a
author
FelineStateMachine <dami.etoile@gmail.com>
13 files changed, +1401 -40
Mdeno.json4+-
Mdocs/README.md1+
Adocs/decisions/nested-namespaces-alpha53.md88+++++++
Mdocs/decisions/schema-facade-alpha53.md3+-
Adocs/examples/nested-namespaces.md116++++++++++
Mpackage/astro/mod.ts1+
Mpackage/runtime/runtime.ts9+-
Mpackage/schema/mod.ts105++++++---
Mpackage/schema/mod_test.ts31++-
Apackage/schema/nested.ts361++++++++++++++++++++++++++++++
Apackage/schema/nested_conformance_test.ts237++++++++++++++++++++
Apackage/schema/nested_test.ts222++++++++++++++++++
Apackage/schema/sliceable_test.ts263++++++++++++++++++++++
M deno.json +2 -2
@@ -54,9 +54,9 @@
5454 "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:conformance",5555 "doc": "deno run --allow-run tools/doc_lint.ts",5656 "docs:links": "deno run --allow-read tools/docs_links.ts",57- "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/testing package/tooling apps/reference/tests",57+ "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/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/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",
M docs/README.md +1 -0
A docs/decisions/nested-namespaces-alpha53.md +88 -0
@@ -0,0 +1,88 @@
1+# Nested app namespaces over one sliceable schema2+3+Date: 2026-07-18\4+Reviewed dependency: `jazz-tools@2.0.0-alpha.53`\5+Decision: adopt `defineSliceableApp` into the facade with conformance coverage; adopt a lofi-owned6+nested-namespace naming layer on top (issue #107, part 1)7+8+## Question9+10+Can one Jazz store host multiple app-level table namespaces (`taskapp.tasks` alongside11+`notesapp.notes`) without a second schema, hash, or migration lineage — and can the schema facade12+expose that shape without becoming the translating facade the13+[facade decision](schema-facade-alpha53.md) rejects?14+15+## Context and control16+17+The control is the facade baseline: `s.defineApp` produces a single flat table namespace, and18+`defineSliceableApp` was curated out as unexercised, with the recorded follow-up that it "joins the19+surface when a use case and tests exist." The pinned alpha already ships the core primitive: one20+compiled schema, typed sub-app views via `.slice(...)`. Slices select tables but do not rename them,21+so nesting has to be a naming layer above the DSL, not a DSL change.22+23+## Decision24+25+- **`defineSliceableApp` joins the curated re-export.** The use case exists (nested namespaces build26+ on it) and `package/schema/sliceable_test.ts` is the conformance coverage: slice-derived handles27+ query the real engine through the one shared store, per-slice permissions compile and merge into28+ one deployable bundle, and the migration surface works over a sliced schema. Facade member29+ identity is unchanged — the member is the Jazz original.30+- **A lofi-owned naming layer, clearly marked as such.** `s.defineNestedApp` flattens31+ `taskapp.tasks` to the global name `taskapp__tasks` (the separator is reserved; it is not `.`32+ because the permission builder reserves `${string}.${string}` keys for qualified-column `where`33+ entries), compiles one schema through `defineSliceableApp`, and returns per-namespace apps with34+ unprefixed typed handles. Handles are constructed exactly once, inside the call — the runtime keys35+ table stores by handle identity, so the single-construction shape is the only expressible one. Ref36+ targets written against namespace-local names (or `"<namespace>.<table>"` across namespaces) are37+ rewritten to the mangled global names by cloning the column builders; the author's definition38+ objects are never mutated.39+- **Per-namespace permissions via `s.defineNestedPermissions`.** The pinned `definePermissions` keys40+ compiled rules by the app object's property names and resolves foreign keys by real schema table41+ names, so it cannot be handed a namespace with unprefixed keys without compiling table names the42+ deployed schema does not contain. The lofi wrapper passes the mangled-key slice underneath and43+ remaps only the policy context to local names: the policy context exposes exactly that namespace's44+ tables, while compiled bundles stay keyed by the mangled global names and merge collision-free45+ through `s.mergeNestedPermissions` (duplicates throw).46+- **The runtime consumes a flattened table registry.** `runtimeTables()` previously walked47+ `Object.values(schema)` one level deep, which would silently skip tables inside namespace objects48+ during boot readiness and local→managed row migration. A nested root carries its flattened handle49+ registry (the same handle objects the namespaces expose, so store identity is preserved); flat50+ `defineApp` schemas keep the one-level walk.51+- **Migrations are authored over the flattened schema.** `s.flattenNestedSchema` exposes the global52+ definition; moving a table between namespaces is an ordinary `renameTableFrom` migration,53+ conformance-verified.54+55+The nested members (`defineNestedApp`, `defineNestedPermissions`, `mergeNestedPermissions`,56+`flattenNestedSchema`) are the one deliberate exception to the re-export rule: lofi-owned names for57+a lofi-owned feature, pinned by test to be lofi's functions and to collide with no Jazz member. They58+rename tables (the mangling); they do not reinterpret any Jazz semantics, so the translating facade59+remains rejected.60+61+## Procedure and evidence62+63+- `package/schema/nested.ts` — the naming layer; `package/schema/nested_test.ts` — mangling,64+ validation, ref rewriting, registry, and permissions-surface unit coverage.65+- `package/schema/sliceable_test.ts` — `defineSliceableApp` conformance over the real engine (slice66+ queries, merged per-slice permissions, migration over a sliced schema).67+- `package/schema/nested_conformance_test.ts` — nested handles, per-namespace policy isolation,68+ rewritten refs as working foreign keys, and the namespace-move migration over a real JazzServer.69+- `package/runtime/runtime.ts` — `runtimeTables()` consumes the flattened registry.70+- `deno task check` passes with the new suites wired into `test:unit` and `test:conformance`.71+72+## Contract delta73+74+The schema surface gains the nested-namespace members and `defineSliceableApp`; the facade promise75+("expose the pinned DSL through the re-export, never a second schema language") is unchanged for76+every Jazz member. Namespace and table names must not contain `__` or `.`.77+78+## Follow-up79+80+- G-set columns stay out of nested apps: under one store every table is a runtime sibling of every81+ namespace, so the g-set isolation guidance (own single-table app) cannot be satisfied inside a82+ nested app until the alpha.53 cross-table destabilization pin clears83+ ([conformance findings](schema-facade-alpha53.md#conformance-findings-alpha53), finding 4).84+ Documented in the [nested-namespaces example](../examples/nested-namespaces.md).85+- Issue #107 parts 2 and 3 (runtime-declared sync location; recovery envelope carrying the sink)86+ build on this layer and land separately.87+- The ref-target rewrite clones builders via their internal `_targetTable`/`_element` fields;88+ re-verify the shape on each alpha bump alongside the other pins.
M docs/decisions/schema-facade-alpha53.md +2 -1
A docs/examples/nested-namespaces.md +116 -0
@@ -0,0 +1,116 @@
1+# Nested app namespaces2+3+One project often carries more than one app-shaped thing — a task tracker and a notes surface, say —4+that should still share one Jazz store, one identity, and one durability story. `s.defineNestedApp`5+groups tables into app-level namespaces over a single compiled schema: one schema hash, one6+migration lineage, one query planner.7+8+## Declare namespaces9+10+```ts11+// src/schema.ts12+import { s } from "@nzip/lofi/schema";13+14+export const root = s.defineNestedApp({15+ taskapp: {16+ projects: s.table({ name: s.string() }),17+ tasks: s.table({18+ projectId: s.ref("projects"),19+ text: s.string(),20+ completed: s.boolean(),21+ }),22+ },23+ notesapp: {24+ notes: s.table({ title: s.string() }),25+ },26+});27+```28+29+`root.taskapp.tasks` and `root.notesapp.notes` are ordinary typed table handles — queries, inserts,30+and live stores treat them exactly like `s.defineApp` tables. Under the hood each nested table31+flattens to a global name with the reserved `__` separator (`taskapp__tasks`), so namespace and32+table names must not contain `__` or `.`.33+34+Handles are constructed once, inside `defineNestedApp`. The runtime keys table stores by handle35+identity, so this is the only expressible construction shape and duplicate subscriptions cannot36+arise — keep the declaration at module scope as with any lofi schema.37+38+Refs written against namespace-local names (`s.ref("projects")` above) resolve within the namespace.39+To reference a table in another namespace, qualify it:40+41+```ts42+attachment: s.ref("taskapp.tasks").optional(),43+```44+45+## Permissions per namespace46+47+Each namespace gets its own permissions module. `s.defineNestedPermissions` exposes only that48+namespace's tables, under their local names; the compiled output is keyed by the global (mangled)49+table names, so per-namespace bundles merge collision-free:50+51+```ts52+// src/permissions.ts53+import { s } from "@nzip/lofi/schema";54+import { root } from "./schema.ts";55+56+const taskPermissions = s.defineNestedPermissions(root.taskapp, ({ policy, session }) => {57+ policy.tasks.allowInsert.always();58+ policy.tasks.allowRead.where({ $createdBy: session.user_id });59+ policy.projects.allowInsert.always();60+ policy.projects.allowRead.always();61+});62+63+const notePermissions = s.defineNestedPermissions(root.notesapp, ({ policy, session }) => {64+ policy.notes.allowInsert.always();65+ policy.notes.allowRead.where({ $createdBy: session.user_id });66+});67+68+export default s.mergeNestedPermissions(taskPermissions, notePermissions);69+```70+71+Use `s.defineNestedPermissions` — not `s.definePermissions` — on a namespace: the pinned72+`definePermissions` keys its compiled rules by the object's property names, so calling it on a73+namespace directly would compile unprefixed table names the deployed schema does not contain.74+75+## Register the app76+77+The nested root is the schema value of the one lofi app; the runtime consumes its flattened table78+registry, so every nested table participates in boot readiness and local-to-managed row migration:79+80+```ts81+// src/app.ts82+import { defineLofiApp } from "@nzip/lofi";83+import { root } from "./schema.ts";84+85+export const app = defineLofiApp({86+ name: "my-suite",87+ databaseName: "my-suite",88+ schema: root,89+ storage: "durable",90+ sync: { adapter: "jazz" },91+});92+```93+94+## Migrations across namespaces95+96+Underneath it is still one schema, so the migration surface is unchanged. Author migrations over the97+flattened definition (`s.flattenNestedSchema`), and moving a table between namespaces is an ordinary98+`renameTableFrom`:99+100+```ts101+const migration = s.defineMigration({102+ from: s.defineSchema(s.flattenNestedSchema(v1)),103+ to: s.defineSchema(s.flattenNestedSchema(v2)),104+ renameTables: { notesapp__archive: s.renameTableFrom("taskapp__archive") },105+ migrate: {},106+});107+```108+109+## Known constraint: g-set columns110+111+The conformance findings pin an alpha.53 engine issue where a `g-set` column destabilizes writes to112+sibling tables in the same app (see113+[the facade decision record](../decisions/schema-facade-alpha53.md)). The standing guidance is to114+keep a g-set table in its own single-table app — and under a nested app every table is a runtime115+sibling of every namespace, so that isolation cannot be satisfied inside one store. Keep g-set116+columns out of nested apps until the upstream pin clears.
M package/astro/mod.ts +1 -0
M package/runtime/runtime.ts +8 -1
M package/schema/mod.ts +70 -35
@@ -23,48 +23,83 @@
2323 * @module2424 */2525 import { schema } from "jazz-tools";26+import {27+ defineNestedApp,28+ defineNestedPermissions,29+ flattenNestedSchema,30+ mergeNestedPermissions,31+} from "./nested.ts";2632 2733 /**28- * The curated schema DSL type: every member is the pinned Jazz 2 original,29- * unchanged. Deprecated members (`rename`) and surface lofi does not yet30- * exercise (`defineSliceableApp`) are omitted; everything else re-exports31- * one-to-one.34+ * The curated schema DSL type. Every Jazz member is the pinned Jazz 235+ * original, unchanged; deprecated members (`rename`) are omitted, and36+ * everything else re-exports one-to-one. The nested-namespace members37+ * (`defineNestedApp`, `defineNestedPermissions`, `mergeNestedPermissions`,38+ * `flattenNestedSchema`) are lofi-owned: a naming layer over the pinned39+ * `defineSliceableApp`, not part of the Jazz DSL.3240 */33-export type SchemaDsl = Pick<34- typeof schema,35- // Column constructors.36- | "string"37- | "boolean"38- | "int"39- | "float"40- | "timestamp"41- | "bytes"42- | "json"43- | "enum"44- | "ref"45- | "array"46- // Column migration operations.47- | "add"48- | "drop"49- | "renameFrom"50- // Tables, schemas, and apps.51- | "table"52- | "defineSchema"53- | "defineApp"54- // Schema migrations.55- | "defineMigration"56- | "renameTableFrom"57- // Permissions.58- | "definePermissions"59- | "permissionIntrospectionColumns"60->;41+export type SchemaDsl =42+ & Pick<43+ typeof schema,44+ // Column constructors.45+ | "string"46+ | "boolean"47+ | "int"48+ | "float"49+ | "timestamp"50+ | "bytes"51+ | "json"52+ | "enum"53+ | "ref"54+ | "array"55+ // Column migration operations.56+ | "add"57+ | "drop"58+ | "renameFrom"59+ // Tables, schemas, and apps.60+ | "table"61+ | "defineSchema"62+ | "defineApp"63+ | "defineSliceableApp"64+ // Schema migrations.65+ | "defineMigration"66+ | "renameTableFrom"67+ // Permissions.68+ | "definePermissions"69+ | "permissionIntrospectionColumns"70+ >71+ & {72+ defineNestedApp: typeof defineNestedApp;73+ defineNestedPermissions: typeof defineNestedPermissions;74+ mergeNestedPermissions: typeof mergeNestedPermissions;75+ flattenNestedSchema: typeof flattenNestedSchema;76+ };6177 6278 /**6379 * The lofi schema surface. Use in `src/schema.ts` and `src/permissions.ts` in64- * place of a raw `jazz-tools` import; member names and behavior are identical65- * to the pinned Jazz 2 DSL.80+ * place of a raw `jazz-tools` import; Jazz member names and behavior are81+ * identical to the pinned Jazz 2 DSL, and the nested-namespace members are82+ * the lofi naming layer documented on {@link defineNestedApp}.6683 */67-export const s: SchemaDsl = schema;84+export const s: SchemaDsl = {85+ ...schema,86+ defineNestedApp,87+ defineNestedPermissions,88+ mergeNestedPermissions,89+ flattenNestedSchema,90+};91+92+export {93+ defineNestedApp,94+ defineNestedPermissions,95+ flattenNestedSchema,96+ mergeNestedPermissions,97+ NESTED_SEPARATOR,98+ type NestedApp,99+ nestedAppDeployTarget,100+ nestedAppTables,101+ type NestedSchemaDefinition,102+} from "./nested.ts";68103 69104 /**70105 * Schema-authoring types re-exported from the pinned Jazz 2 DSL for use in
M package/schema/mod_test.ts +30 -1
@@ -1,5 +1,11 @@
11 import { schema } from "jazz-tools";2-import { s } from "./mod.ts";2+import {3+ defineNestedApp,4+ defineNestedPermissions,5+ flattenNestedSchema,6+ mergeNestedPermissions,7+ s,8+} from "./mod.ts";39 410 // The facade re-exports, never reimplements: every curated member must be the511 // pinned Jazz 2 original by identity, so semantics cannot drift.
@@ -20,6 +26,7 @@ const curatedMembers = [
2026 "table",2127 "defineSchema",2228 "defineApp",29+ "defineSliceableApp",2330 "defineMigration",2431 "renameTableFrom",2532 "definePermissions",
@@ -37,6 +44,28 @@ Deno.test("schema facade exposes every curated member as the Jazz original", ()
3744 }3845 });3946 47+// The nested-namespace members are the one deliberate exception to the48+// re-export rule: they are lofi-owned (a naming layer over the pinned49+// defineSliceableApp), so they must be lofi's functions, not Jazz members.50+Deno.test("the nested-namespace members are the lofi-owned originals", () => {51+ const lofiMembers = {52+ defineNestedApp,53+ defineNestedPermissions,54+ mergeNestedPermissions,55+ flattenNestedSchema,56+ } as const;57+ for (const [name, member] of Object.entries(lofiMembers)) {58+ if (s[name as keyof typeof lofiMembers] !== member) {59+ throw new Error(`facade member "${name}" is not the lofi nested-namespace original`);60+ }61+ if (name in schema) {62+ throw new Error(63+ `jazz-tools now provides "${name}" — resolve the collision with the lofi member`,64+ );65+ }66+ }67+});68+4069 Deno.test("a schema declared through the facade round-trips app and permissions", () => {4170 const app = s.defineApp({4271 tasks: s.table({
A package/schema/nested.ts +361 -0
@@ -0,0 +1,361 @@
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+ */14+import {15+ type App,16+ type CompiledPermissions,17+ type PolicyContext,18+ type Schema,19+ schema,20+ type SchemaDefinition,21+} from "jazz-tools";22+23+/**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+ */29+export const NESTED_SEPARATOR = "__";30+31+/** A nested schema definition: namespaces mapping to per-namespace tables. */32+export type NestedSchemaDefinition = Record<string, SchemaDefinition>;33+34+/**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+ */38+export type NestedApp<TDef extends NestedSchemaDefinition> = {39+ [NS in keyof TDef & string]: App<Schema<TDef[NS]>>;40+};41+42+const nestedAppMetaKey = Symbol.for("@nzip/lofi/nested-app");43+const namespaceMetaKey = Symbol.for("@nzip/lofi/nested-namespace");44+45+type 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+};52+53+type 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+};59+60+function 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+}71+72+function mangle(namespace: string, table: string): string {73+ return `${namespace}${NESTED_SEPARATOR}${table}`;74+}75+76+type ColumnBuilderLike = {77+ _references?: string;78+ _targetTable?: string;79+ _element?: ColumnBuilderLike;80+};81+82+type TableSourceLike = {83+ __jazzTableDefinition?: boolean;84+ columns?: Record<string, ColumnBuilderLike>;85+ indexedColumns?: readonly string[];86+};87+88+function 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+}95+96+// 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.100+function 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+}118+119+function 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+}147+148+function 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+}178+179+/**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+ */188+export 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+}205+206+/**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+ */234+export 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+}274+275+/**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+ */296+export 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+}316+317+/**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+ */322+export 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+}336+337+/**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+ */344+export 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+}349+350+/**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+ */355+export 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+}
A package/schema/nested_conformance_test.ts +237 -0
@@ -0,0 +1,237 @@
1+// Nested-namespace conformance: nested handles, per-namespace permissions,2+// and rewritten refs exercised against the real pinned Jazz engine, plus the3+// migration story — moving a table between namespaces is an ordinary4+// renameTableFrom migration over the flattened schema. The naming layer5+// itself is unit-tested in ./nested_test.ts.6+import { createDb } from "jazz-tools";7+import { createPolicyTestApp, deploy, startLocalJazzServer } from "jazz-tools/testing";8+import { assert } from "../runtime/test-assert.ts";9+import { nestedAppDeployTarget, s } from "./mod.ts";10+11+const root = s.defineNestedApp({12+ taskapp: {13+ projects: s.table({ name: s.string() }),14+ tasks: s.table({ projectId: s.ref("projects"), text: s.string() }),15+ },16+ notesapp: {17+ notes: s.table({ title: s.string(), secret: s.boolean() }),18+ },19+});20+21+const taskPermissions = s.defineNestedPermissions(root.taskapp, ({ policy }) => {22+ policy.projects.allowInsert.always();23+ policy.projects.allowRead.always();24+ policy.tasks.allowInsert.always();25+ policy.tasks.allowRead.always();26+ policy.tasks.allowUpdate.always();27+ policy.tasks.allowDelete.always();28+});29+const notePermissions = s.defineNestedPermissions(root.notesapp, ({ policy, session }) => {30+ policy.notes.allowInsert.always();31+ policy.notes.allowRead.where({ secret: false });32+ policy.notes.allowRead.where({ $createdBy: session.user_id });33+});34+const permissions = s.mergeNestedPermissions(taskPermissions, notePermissions);35+36+type ThrowExpectation = {37+ toThrow(expected?: unknown): void;38+ not: { toThrow(expected?: unknown): void };39+};40+41+function expectLike(value: unknown): ThrowExpectation {42+ const run = (): unknown => {43+ if (typeof value !== "function") throw new Error("expectLike requires a callback");44+ try {45+ value();46+ return undefined;47+ } catch (error) {48+ return error ?? new Error("threw undefined");49+ }50+ };51+ const matches = (error: unknown, expected: unknown): boolean => {52+ if (expected === undefined) return error !== undefined;53+ const message = error instanceof Error ? error.message : String(error);54+ return typeof expected === "string"55+ ? message.includes(expected)56+ : error instanceof (expected as typeof Error);57+ };58+ return {59+ toThrow(expected?: unknown) {60+ const error = run();61+ if (!matches(error, expected)) {62+ throw new Error(`expected callback to throw ${String(expected)}`);63+ }64+ },65+ not: {66+ toThrow(expected?: unknown) {67+ const error = run();68+ if (error !== undefined && (expected === undefined || matches(error, expected))) {69+ const message = error instanceof Error ? error.message : String(error);70+ throw new Error(`expected callback not to throw, received: ${message}`);71+ }72+ },73+ },74+ };75+}76+77+const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });78+79+Deno.test("nested handles round-trip through the real engine with per-namespace policies", async () => {80+ const testApp = await createPolicyTestApp(81+ nestedAppDeployTarget(root) as never,82+ permissions,83+ expectLike,84+ );85+ try {86+ const db = testApp.as(session("author"));87+88+ // Rewritten refs are real foreign keys: the where on the fk filters.89+ const project = await db.insert(root.taskapp.projects, { name: "launch" })90+ .wait({ tier: "global" });91+ const decoy = await db.insert(root.taskapp.projects, { name: "decoy" })92+ .wait({ tier: "global" });93+ await db.insert(root.taskapp.tasks, { projectId: project.id, text: "ship it" })94+ .wait({ tier: "global" });95+ await db.insert(root.taskapp.tasks, { projectId: decoy.id, text: "other" })96+ .wait({ tier: "global" });97+ const tasks = await db.all(root.taskapp.tasks.where({ projectId: project.id }));98+ assert(99+ tasks.length === 1 && tasks[0].text === "ship it",100+ `nested ref where returned ${tasks.length} rows`,101+ );102+103+ // Sibling namespaces share the one store but keep their own policies.104+ await db.insert(root.notesapp.notes, { title: "public", secret: false })105+ .wait({ tier: "global" });106+ await db.insert(root.notesapp.notes, { title: "private", secret: true })107+ .wait({ tier: "global" });108+ const viewer = testApp.as(session("viewer"));109+ const viewerNotes = await viewer.all(root.notesapp.notes.where({}));110+ assert(111+ viewerNotes.length === 1 && viewerNotes[0].title === "public",112+ `notesapp policy exposed ${viewerNotes.length} rows to a non-author`,113+ );114+ const viewerTasks = await viewer.all(root.taskapp.tasks.where({}));115+ assert(116+ viewerTasks.length === 2,117+ `taskapp allow-all read exposed ${viewerTasks.length} rows, expected 2`,118+ );119+ } finally {120+ await testApp.shutdown();121+ }122+});123+124+function secret(fill: number): string {125+ let binary = "";126+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);127+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");128+}129+130+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {131+ let timer: ReturnType<typeof setTimeout> | undefined;132+ try {133+ return await Promise.race([134+ operation,135+ new Promise<never>((_resolve, reject) => {136+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);137+ }),138+ ]);139+ } finally {140+ if (timer !== undefined) clearTimeout(timer);141+ }142+}143+144+Deno.test("moving a table between namespaces is an ordinary renameTableFrom migration", async () => {145+ const archiveTable = { archive: s.table({ label: s.string() }) };146+ const v1Def = { taskapp: archiveTable, notesapp: { notes: s.table({ title: s.string() }) } };147+ const v2Def = {148+ taskapp: { placeholder: s.table({ name: s.string() }) },149+ notesapp: { notes: s.table({ title: s.string() }), ...archiveTable },150+ };151+ const rootV1 = s.defineNestedApp(v1Def);152+ const rootV2 = s.defineNestedApp(v2Def);153+ // Migrations are authored over the flattened global schema — one schema,154+ // one migration lineage; the namespace move is just a table rename.155+ const migration = s.defineMigration({156+ from: s.defineSchema(s.flattenNestedSchema(v1Def) as never),157+ to: s.defineSchema(s.flattenNestedSchema(v2Def) as never),158+ renameTables: { notesapp__archive: s.renameTableFrom("taskapp__archive") },159+ migrate: {},160+ } as never);161+ const allowAll = (namespace: never, tables: string[]) =>162+ s.defineNestedPermissions(namespace, ({ policy }) => {163+ const rules = policy as unknown as Record<164+ string,165+ Record<"allowInsert" | "allowRead" | "allowUpdate" | "allowDelete", { always(): void }>166+ >;167+ for (const table of tables) {168+ rules[table].allowInsert.always();169+ rules[table].allowRead.always();170+ rules[table].allowUpdate.always();171+ rules[table].allowDelete.always();172+ }173+ });174+ const permissionsV1 = s.mergeNestedPermissions(175+ allowAll(rootV1.taskapp as never, ["archive"]),176+ allowAll(rootV1.notesapp as never, ["notes"]),177+ );178+ const permissionsV2 = s.mergeNestedPermissions(179+ allowAll(rootV2.taskapp as never, ["placeholder"]),180+ allowAll(rootV2.notesapp as never, ["notes", "archive"]),181+ );182+183+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });184+ const clients: Awaited<ReturnType<typeof createDb>>[] = [];185+ const client = async (fill: number) => {186+ const db = await createDb({187+ appId: server.appId,188+ serverUrl: server.url,189+ secret: secret(fill),190+ userBranch: "main",191+ driver: { type: "memory" },192+ });193+ clients.push(db);194+ return db;195+ };196+ try {197+ await deploy({198+ appId: server.appId,199+ serverUrl: server.url,200+ adminSecret: server.adminSecret,201+ schema: nestedAppDeployTarget(rootV1) as never,202+ permissions: permissionsV1,203+ });204+ const alice = await client(1);205+ const archived = await within(206+ alice.insert(rootV1.taskapp.archive, { label: "moved" }).wait({ tier: "global" }),207+ "v1 archive insert",208+ );209+210+ await within(211+ deploy({212+ appId: server.appId,213+ serverUrl: server.url,214+ adminSecret: server.adminSecret,215+ schema: nestedAppDeployTarget(rootV2) as never,216+ permissions: permissionsV2,217+ migration,218+ }),219+ "v2 deploy with the namespace-move migration",220+ );221+222+ const bob = await client(2);223+ const rows = await within(224+ bob.all(rootV2.notesapp.archive.where({ id: archived.id }), { tier: "global" }),225+ "v2 archive read",226+ );227+ assert(228+ rows.length === 1 && rows[0].label === "moved",229+ `row did not follow its table across namespaces: ${JSON.stringify(rows)}`,230+ );231+ } finally {232+ await Promise.allSettled(233+ clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),234+ );235+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);236+ }237+});
A package/schema/nested_test.ts +222 -0
@@ -0,0 +1,222 @@
1+// The nested-namespace naming layer, exercised without an engine: name2+// mangling, definition validation, ref-target rewriting, the flattened3+// runtime registry, and the per-namespace permissions surface. Engine4+// round-trips live in ./nested_conformance_test.ts.5+import { assert } from "../runtime/test-assert.ts";6+import {7+ flattenNestedSchema,8+ NESTED_SEPARATOR,9+ nestedAppDeployTarget,10+ nestedAppTables,11+ s,12+} from "./mod.ts";13+14+function throws(operation: () => unknown, fragment: string, label: string): void {15+ try {16+ operation();17+ } catch (error) {18+ const message = error instanceof Error ? error.message : String(error);19+ assert(20+ message.includes(fragment),21+ `${label}: threw "${message}", expected it to mention "${fragment}"`,22+ );23+ return;24+ }25+ throw new Error(`${label}: expected a throw mentioning "${fragment}"`);26+}27+28+function tableName(handle: unknown): string {29+ return (handle as { _table: string })._table;30+}31+32+const root = s.defineNestedApp({33+ taskapp: {34+ projects: s.table({ name: s.string() }),35+ tasks: s.table({ projectId: s.ref("projects"), text: s.string() }),36+ },37+ notesapp: {38+ notes: s.table({ title: s.string() }),39+ },40+});41+42+Deno.test("defineNestedApp flattens to mangled global names behind unprefixed handles", () => {43+ assert(44+ tableName(root.taskapp.tasks) === `taskapp${NESTED_SEPARATOR}tasks`,45+ `tasks handle targets ${tableName(root.taskapp.tasks)}`,46+ );47+ assert(48+ tableName(root.notesapp.notes) === `notesapp${NESTED_SEPARATOR}notes`,49+ `notes handle targets ${tableName(root.notesapp.notes)}`,50+ );51+ assert(52+ typeof (root.taskapp.tasks as { where?: unknown }).where === "function",53+ "namespace handles are not queryable",54+ );55+ assert(56+ !("taskapp__tasks" in root.taskapp),57+ "mangled names leaked into the namespace object",58+ );59+});60+61+Deno.test("nested table handles are constructed once and shared with the registry", () => {62+ const registry = nestedAppTables(root);63+ assert(registry !== null, "nested root exposes no table registry");64+ assert(registry.length === 3, `registry holds ${registry.length} handles, expected 3`);65+ for (const handle of [root.taskapp.projects, root.taskapp.tasks, root.notesapp.notes]) {66+ assert(67+ registry.includes(handle as object),68+ `registry is missing the ${tableName(handle)} handle by identity`,69+ );70+ }71+});72+73+Deno.test("nestedAppTables returns null for flat apps so the runtime keeps its one-level walk", () => {74+ const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });75+ assert(nestedAppTables(flat) === null, "flat app misdetected as nested");76+ assert(nestedAppTables(undefined) === null, "undefined misdetected as nested");77+});78+79+Deno.test("the deploy target exposes every table under its mangled global name", () => {80+ const target = nestedAppDeployTarget(root) as Record<string, unknown>;81+ for (const name of ["taskapp__projects", "taskapp__tasks", "notesapp__notes"]) {82+ assert(name in target, `deploy target is missing "${name}"`);83+ }84+ assert("wasmSchema" in target, "deploy target is missing wasmSchema");85+ throws(86+ () => nestedAppDeployTarget(root.taskapp),87+ "requires a root value",88+ "deploy target on a namespace",89+ );90+});91+92+Deno.test("nested definitions reject names that break the mangling scheme", () => {93+ const table = s.table({ name: s.string() });94+ throws(95+ () => flattenNestedSchema({ ["bad__ns"]: { items: table } }),96+ "reserved separator",97+ "separator in namespace",98+ );99+ throws(100+ () => flattenNestedSchema({ ns: { ["bad__table"]: table } }),101+ "reserved separator",102+ "separator in table",103+ );104+ throws(105+ () => flattenNestedSchema({ ["bad.ns"]: { items: table } }),106+ `must not contain "."`,107+ "dot in namespace",108+ );109+ throws(() => flattenNestedSchema({}), "at least one namespace", "empty definition");110+ throws(() => flattenNestedSchema({ ns: {} }), "at least one table", "empty namespace");111+});112+113+Deno.test("ref targets resolve within the namespace or as <namespace>.<table> across namespaces", () => {114+ const flat = flattenNestedSchema({115+ taskapp: {116+ projects: s.table({ name: s.string() }),117+ tasks: s.table({ projectId: s.ref("projects") }),118+ },119+ notesapp: {120+ notes: s.table({ taskId: s.ref("taskapp.tasks").optional() }),121+ },122+ }) as Record<string, { columns: Record<string, { _references?: string }> }>;123+ assert(124+ flat.taskapp__tasks.columns.projectId._references === "taskapp__projects",125+ `same-namespace ref rewrote to ${flat.taskapp__tasks.columns.projectId._references}`,126+ );127+ assert(128+ flat.notesapp__notes.columns.taskId._references === "taskapp__tasks",129+ `cross-namespace ref rewrote to ${flat.notesapp__notes.columns.taskId._references}`,130+ );131+132+ throws(133+ () =>134+ flattenNestedSchema({135+ ns: { items: s.table({ ownerId: s.ref("missing") }) },136+ }),137+ "is not a table in this namespace",138+ "unknown bare ref target",139+ );140+ throws(141+ () =>142+ flattenNestedSchema({143+ ns: { items: s.table({ ownerId: s.ref("other.missing") }) },144+ }),145+ "does not name a declared",146+ "unknown qualified ref target",147+ );148+ throws(149+ () =>150+ flattenNestedSchema({151+ ns: { items: s.table({ ownerId: s.ref("ns__items") }) },152+ }),153+ "reserved separator",154+ "mangled ref target",155+ );156+});157+158+Deno.test("ref rewriting clones builders instead of mutating the author's definition", () => {159+ const projectRef = s.ref("projects");160+ flattenNestedSchema({161+ taskapp: {162+ projects: s.table({ name: s.string() }),163+ tasks: s.table({ projectId: projectRef }),164+ },165+ });166+ assert(167+ (projectRef as unknown as { _references?: string })._references === "projects",168+ "flattening mutated the shared ref builder",169+ );170+});171+172+Deno.test("defineNestedPermissions exposes unprefixed policy names and compiles mangled keys", () => {173+ let observedPolicyKeys: string[] = [];174+ const compiled = s.defineNestedPermissions(root.taskapp, ({ policy }) => {175+ observedPolicyKeys = Object.keys(policy);176+ policy.tasks.allowRead.always();177+ policy.tasks.allowInsert.always();178+ policy.projects.allowRead.always();179+ });180+ assert(181+ observedPolicyKeys.includes("tasks") && observedPolicyKeys.includes("projects"),182+ `policy context exposes ${observedPolicyKeys.join(", ")}`,183+ );184+ assert(185+ !observedPolicyKeys.includes("notes") && !observedPolicyKeys.includes("taskapp__tasks"),186+ "policy context leaked other namespaces or mangled names",187+ );188+ const compiledKeys = Object.keys(compiled).sort();189+ assert(190+ compiledKeys.join(",") === "taskapp__projects,taskapp__tasks",191+ `compiled bundle keyed by ${compiledKeys.join(", ")}`,192+ );193+});194+195+Deno.test("defineNestedPermissions rejects values that are not nested namespaces", () => {196+ const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });197+ throws(198+ () => s.defineNestedPermissions(flat, () => {}),199+ "requires a namespace value",200+ "flat app as namespace",201+ );202+});203+204+Deno.test("per-namespace permission bundles merge collision-free; duplicates throw", () => {205+ const taskBundle = s.defineNestedPermissions(root.taskapp, ({ policy }) => {206+ policy.tasks.allowRead.always();207+ });208+ const noteBundle = s.defineNestedPermissions(root.notesapp, ({ policy }) => {209+ policy.notes.allowRead.always();210+ });211+ const merged = s.mergeNestedPermissions(taskBundle, noteBundle);212+ const keys = Object.keys(merged).sort();213+ assert(214+ keys.join(",") === "notesapp__notes,taskapp__tasks",215+ `merged bundle keyed by ${keys.join(", ")}`,216+ );217+ throws(218+ () => s.mergeNestedPermissions(taskBundle, taskBundle),219+ "more than once",220+ "duplicate bundle merge",221+ );222+});
A package/schema/sliceable_test.ts +263 -0
@@ -0,0 +1,263 @@
1+// Sliceable-app conformance: the facade decision record deferred2+// `defineSliceableApp` as unexercised; this suite is the coverage that lets3+// it join the surface. Slice-derived handles must query the real engine,4+// per-slice permissions must compile and merge into one deployable bundle,5+// and the migration surface must work over a sliced schema — nothing here6+// may rely on `defineApp`.7+import { createDb } from "jazz-tools";8+import { createPolicyTestApp, deploy, startLocalJazzServer } from "jazz-tools/testing";9+import { assert } from "../runtime/test-assert.ts";10+import { s } from "./mod.ts";11+12+const app = s.defineSliceableApp({13+ workspaces: s.table({ name: s.string() }),14+ tasks: s.table({ workspaceId: s.ref("workspaces"), text: s.string() }),15+ notes: s.table({ title: s.string(), secret: s.boolean() }),16+});17+18+// One slice per sub-app view; each slice call derives fresh typed handles19+// over the same compiled schema.20+const taskSlice = app.slice("workspaces", "tasks");21+const noteSlice = app.slice("notes");22+const deploySlice = app.slice("workspaces", "tasks", "notes");23+24+const taskPermissions = s.definePermissions(taskSlice, ({ policy }) => {25+ policy.workspaces.allowInsert.always();26+ policy.workspaces.allowRead.always();27+ policy.tasks.allowInsert.always();28+ policy.tasks.allowRead.always();29+ policy.tasks.allowUpdate.always();30+ policy.tasks.allowDelete.always();31+});32+const notePermissions = s.definePermissions(noteSlice, ({ policy, session }) => {33+ policy.notes.allowInsert.always();34+ // Non-secret notes are public; secret notes only readable by their author.35+ policy.notes.allowRead.where({ secret: false });36+ policy.notes.allowRead.where({ $createdBy: session.user_id });37+});38+39+const mergedPermissions = s.mergeNestedPermissions(taskPermissions, notePermissions);40+41+type ThrowExpectation = {42+ toThrow(expected?: unknown): void;43+ not: { toThrow(expected?: unknown): void };44+};45+46+function expectLike(value: unknown): ThrowExpectation {47+ const run = (): unknown => {48+ if (typeof value !== "function") throw new Error("expectLike requires a callback");49+ try {50+ value();51+ return undefined;52+ } catch (error) {53+ return error ?? new Error("threw undefined");54+ }55+ };56+ const matches = (error: unknown, expected: unknown): boolean => {57+ if (expected === undefined) return error !== undefined;58+ const message = error instanceof Error ? error.message : String(error);59+ return typeof expected === "string"60+ ? message.includes(expected)61+ : error instanceof (expected as typeof Error);62+ };63+ return {64+ toThrow(expected?: unknown) {65+ const error = run();66+ if (!matches(error, expected)) {67+ throw new Error(`expected callback to throw ${String(expected)}`);68+ }69+ },70+ not: {71+ toThrow(expected?: unknown) {72+ const error = run();73+ if (error !== undefined && (expected === undefined || matches(error, expected))) {74+ const message = error instanceof Error ? error.message : String(error);75+ throw new Error(`expected callback not to throw, received: ${message}`);76+ }77+ },78+ },79+ };80+}81+82+const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });83+84+Deno.test("slices select declared tables and reject unknown ones", () => {85+ assert(86+ "tasks" in taskSlice && !("notes" in taskSlice),87+ "slice exposes tables outside its selection",88+ );89+ assert(90+ "notes" in noteSlice && !("tasks" in noteSlice),91+ "single-table slice exposes unselected tables",92+ );93+ let message = "";94+ try {95+ app.slice("nonexistent" as never);96+ } catch (error) {97+ message = error instanceof Error ? error.message : String(error);98+ }99+ assert(100+ message.includes('unknown table "nonexistent"'),101+ `slice of an unknown table reported: ${message || "no error"}`,102+ );103+});104+105+Deno.test("slice-derived handles query the one real store, and merged per-slice policies bind to their tables", async () => {106+ const testApp = await createPolicyTestApp(107+ deploySlice as never,108+ mergedPermissions,109+ expectLike,110+ );111+ try {112+ const db = testApp.as(session("author"));113+114+ // Writes through one slice's handles are visible through another slice's115+ // fresh handles: slices are views over one schema, not separate stores.116+ const workspace = await db.insert(taskSlice.workspaces, { name: "home" })117+ .wait({ tier: "global" });118+ await db.insert(taskSlice.tasks, { workspaceId: workspace.id, text: "write tests" })119+ .wait({ tier: "global" });120+ const secondTaskSlice = app.slice("tasks");121+ const tasks = await db.all(secondTaskSlice.tasks.where({ workspaceId: workspace.id }));122+ assert(123+ tasks.length === 1 && tasks[0].text === "write tests",124+ `fresh slice handle read ${tasks.length} rows through the shared store`,125+ );126+127+ // The merged bundle keeps each slice's policy on its own table: tasks128+ // stay allow-all while the notes policy still gates secret rows.129+ await db.insert(noteSlice.notes, { title: "public", secret: false }).wait({ tier: "global" });130+ await db.insert(noteSlice.notes, { title: "private", secret: true }).wait({ tier: "global" });131+ const authorNotes = await db.all(noteSlice.notes.where({}));132+ assert(133+ authorNotes.length === 2,134+ `author reads ${authorNotes.length} notes, expected both alternatives to apply`,135+ );136+ const viewer = testApp.as(session("viewer"));137+ const viewerNotes = await viewer.all(noteSlice.notes.where({}));138+ assert(139+ viewerNotes.length === 1 && viewerNotes[0].title === "public",140+ `merged notes policy exposed ${viewerNotes.length} rows to a non-author`,141+ );142+ const viewerTasks = await viewer.all(taskSlice.tasks.where({}));143+ assert(144+ viewerTasks.length === 1,145+ `merged tasks policy exposed ${viewerTasks.length} rows, expected allow-all read`,146+ );147+ } finally {148+ await testApp.shutdown();149+ }150+});151+152+function secret(fill: number): string {153+ let binary = "";154+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);155+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");156+}157+158+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {159+ let timer: ReturnType<typeof setTimeout> | undefined;160+ try {161+ return await Promise.race([162+ operation,163+ new Promise<never>((_resolve, reject) => {164+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);165+ }),166+ ]);167+ } finally {168+ if (timer !== undefined) clearTimeout(timer);169+ }170+}171+172+Deno.test("migrations carry data forward over a sliced schema", async () => {173+ const v1 = s.defineSchema({174+ items: s.table({ label: s.string(), obsolete: s.boolean() }),175+ });176+ const v2 = s.defineSchema({177+ entries: s.table({ label: s.string(), priority: s.enum("low", "high") }),178+ });179+ const sliceV1 = s.defineSliceableApp(v1).slice("items");180+ const sliceV2 = s.defineSliceableApp(v2).slice("entries");181+ const migration = s.defineMigration({182+ from: v1,183+ to: v2,184+ renameTables: { entries: s.renameTableFrom("items") },185+ migrate: {186+ entries: {187+ priority: s.add.enum("low", "high", { default: "low" }),188+ obsolete: s.drop.boolean({ backwardsDefault: false }),189+ },190+ },191+ });192+ const allowAll = (slice: never, table: string) =>193+ s.definePermissions(slice, ({ policy }) => {194+ const rules = (policy as unknown as Record<195+ string,196+ Record<"allowInsert" | "allowRead" | "allowUpdate" | "allowDelete", { always(): void }>197+ >)[table];198+ rules.allowInsert.always();199+ rules.allowRead.always();200+ rules.allowUpdate.always();201+ rules.allowDelete.always();202+ });203+204+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });205+ const clients: Awaited<ReturnType<typeof createDb>>[] = [];206+ const client = async (fill: number) => {207+ const db = await createDb({208+ appId: server.appId,209+ serverUrl: server.url,210+ secret: secret(fill),211+ userBranch: "main",212+ driver: { type: "memory" },213+ });214+ clients.push(db);215+ return db;216+ };217+ try {218+ await deploy({219+ appId: server.appId,220+ serverUrl: server.url,221+ adminSecret: server.adminSecret,222+ schema: sliceV1,223+ permissions: allowAll(sliceV1 as never, "items"),224+ });225+ const alice = await client(1);226+ const itemV1 = await within(227+ alice.insert(sliceV1.items, { label: "carried", obsolete: true }).wait({ tier: "global" }),228+ "v1 item insert",229+ );230+231+ await within(232+ deploy({233+ appId: server.appId,234+ serverUrl: server.url,235+ adminSecret: server.adminSecret,236+ schema: sliceV2,237+ permissions: allowAll(sliceV2 as never, "entries"),238+ migration,239+ }),240+ "v2 deploy with migration",241+ );242+243+ const bob = await client(2);244+ const entries = await within(245+ bob.all(sliceV2.entries.where({ id: itemV1.id }), { tier: "global" }),246+ "v2 entry read",247+ );248+ assert(entries.length === 1, "v1 row lost across the sliced-schema migration");249+ assert(250+ entries[0].label === "carried" && entries[0].priority === "low",251+ `sliced migration read back ${JSON.stringify(entries[0])}`,252+ );253+ assert(254+ !("obsolete" in entries[0]),255+ `dropped column survived the sliced migration: ${JSON.stringify(entries[0])}`,256+ );257+ } finally {258+ await Promise.allSettled(259+ clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),260+ );261+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);262+ }263+});