Nested app namespaces
One project often carries more than one app-shaped thing — a task tracker and a notes surface, say —
that should still share one Jazz store, one identity, and one durability story. s.defineNestedApp
groups tables into app-level namespaces over a single compiled schema: one schema hash, one
migration lineage, one query planner.
Declare namespaces
// src/schema.ts
import { s } from "@nzip/lofi/schema";
export const root = s.defineNestedApp({
taskapp: {
projects: s.table({ name: s.string() }),
tasks: s.table({
projectId: s.ref("projects"),
text: s.string(),
completed: s.boolean(),
}),
},
notesapp: {
notes: s.table({ title: s.string() }),
},
});
root.taskapp.tasks and root.notesapp.notes are ordinary typed table handles — queries, inserts,
and live stores treat them exactly like s.defineApp tables. Under the hood each nested table
flattens to a global name with the reserved __ separator (taskapp__tasks), so namespace and
table names must not contain __ or ..
Handles are constructed once, inside defineNestedApp. The runtime keys table stores by handle
identity, so this is the only expressible construction shape and duplicate subscriptions cannot
arise — keep the declaration at module scope as with any lofi schema.
Refs written against namespace-local names (s.ref("projects") above) resolve within the namespace.
To reference a table in another namespace, qualify it:
attachment: s.ref("taskapp.tasks").optional(),
Permissions per namespace
Each namespace gets its own permissions module. s.defineNestedPermissions exposes only that
namespace's tables, under their local names; the compiled output is keyed by the global (mangled)
table names, so per-namespace bundles merge collision-free:
// src/permissions.ts
import { s } from "@nzip/lofi/schema";
import { root } from "./schema.ts";
const taskPermissions = s.defineNestedPermissions(root.taskapp, ({ policy, session }) => {
policy.tasks.allowInsert.always();
policy.tasks.allowRead.where({ $createdBy: session.user_id });
policy.projects.allowInsert.always();
policy.projects.allowRead.always();
});
const notePermissions = s.defineNestedPermissions(root.notesapp, ({ policy, session }) => {
policy.notes.allowInsert.always();
policy.notes.allowRead.where({ $createdBy: session.user_id });
});
export default s.mergeNestedPermissions(taskPermissions, notePermissions);
Use s.defineNestedPermissions — not s.definePermissions — on a namespace: the pinned
definePermissions keys its compiled rules by the object's property names, so calling it on a
namespace directly would compile unprefixed table names the deployed schema does not contain.
Register the app
The nested root is the schema value of the one lofi app; the runtime consumes its flattened table registry, so every nested table participates in boot readiness and local-to-managed row migration:
// src/app.ts
import { defineLofiApp } from "@nzip/lofi";
import { root } from "./schema.ts";
export const app = defineLofiApp({
name: "my-suite",
databaseName: "my-suite",
schema: root,
storage: "durable",
sync: { adapter: "jazz" },
});
Migrations across namespaces
Underneath it is still one schema, so the migration surface is unchanged. Author migrations over the
flattened definition (s.flattenNestedSchema), and moving a table between namespaces is an ordinary
renameTableFrom:
const migration = s.defineMigration({
from: s.defineSchema(s.flattenNestedSchema(v1)),
to: s.defineSchema(s.flattenNestedSchema(v2)),
renameTables: { notesapp__archive: s.renameTableFrom("taskapp__archive") },
migrate: {},
});
Known constraint: g-set columns
The conformance findings pin an alpha.53 engine issue where a g-set column destabilizes writes to
sibling tables in the same app (see
the facade decision record). The standing guidance is to
keep a g-set table in its own single-table app — and under a nested app every table is a runtime
sibling of every namespace, so that isolation cannot be satisfied inside one store. Keep g-set
columns out of nested apps until the upstream pin clears.