clone

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

feat/fix-sync-enrollment-startup
lofi / package / schema / nested_test.ts
nested_test.ts 7.9K
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.5import { assert } from "../runtime/test-assert.ts";6import {7 flattenNestedSchema,8 NESTED_SEPARATOR,9 nestedAppDeployTarget,10 type NestedAppRoot,11 nestedAppTables,12 s,13} from "./mod.ts";1415function throws(operation: () => unknown, fragment: string, label: string): void {16 try {17 operation();18 } catch (error) {19 const message = error instanceof Error ? error.message : String(error);20 assert(21 message.includes(fragment),22 `${label}: threw "${message}", expected it to mention "${fragment}"`,23 );24 return;25 }26 throw new Error(`${label}: expected a throw mentioning "${fragment}"`);27}2829function tableName(handle: unknown): string {30 return (handle as { _table: string })._table;31}3233const root = s.defineNestedApp({34 taskapp: {35 projects: s.table({ name: s.string() }),36 tasks: s.table({ projectId: s.ref("projects"), text: s.string() }),37 },38 notesapp: {39 notes: s.table({ title: s.string() }),40 },41});4243Deno.test("defineNestedApp flattens to mangled global names behind unprefixed handles", () => {44 assert(45 tableName(root.taskapp.tasks) === `taskapp${NESTED_SEPARATOR}tasks`,46 `tasks handle targets ${tableName(root.taskapp.tasks)}`,47 );48 assert(49 tableName(root.notesapp.notes) === `notesapp${NESTED_SEPARATOR}notes`,50 `notes handle targets ${tableName(root.notesapp.notes)}`,51 );52 assert(53 typeof (root.taskapp.tasks as { where?: unknown }).where === "function",54 "namespace handles are not queryable",55 );56 assert(57 !("taskapp__tasks" in root.taskapp),58 "mangled names leaked into the namespace object",59 );60});6162Deno.test("nested table handles are constructed once and shared with the registry", () => {63 const registry = nestedAppTables(root);64 assert(registry !== null, "nested root exposes no table registry");65 assert(registry.length === 3, `registry holds ${registry.length} handles, expected 3`);66 for (const handle of [root.taskapp.projects, root.taskapp.tasks, root.notesapp.notes]) {67 assert(68 registry.includes(handle as object),69 `registry is missing the ${tableName(handle)} handle by identity`,70 );71 }72});7374Deno.test("nestedAppTables returns null for flat apps so the runtime keeps its one-level walk", () => {75 // The casts model dynamic callers that bypass the NestedAppRoot brand; the76 // runtime defense must still answer null instead of misdetecting.77 const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });78 assert(nestedAppTables(flat as unknown as NestedAppRoot) === null, "flat app misdetected");79 assert(80 nestedAppTables(undefined as unknown as NestedAppRoot) === null,81 "undefined misdetected as nested",82 );83});8485Deno.test("the deploy target exposes every table under its mangled global name", () => {86 const target = nestedAppDeployTarget(root) as Record<string, unknown>;87 for (const name of ["taskapp__projects", "taskapp__tasks", "notesapp__notes"]) {88 assert(name in target, `deploy target is missing "${name}"`);89 }90 assert("wasmSchema" in target, "deploy target is missing wasmSchema");91 throws(92 // A namespace is not the root; the cast models a dynamic caller and the93 // runtime defense must refuse it.94 () => nestedAppDeployTarget(root.taskapp as unknown as NestedAppRoot),95 "requires a root value",96 "deploy target on a namespace",97 );98});99100Deno.test("nested definitions reject names that break the mangling scheme", () => {101 const table = s.table({ name: s.string() });102 throws(103 () => flattenNestedSchema({ ["bad__ns"]: { items: table } }),104 "reserved separator",105 "separator in namespace",106 );107 throws(108 () => flattenNestedSchema({ ns: { ["bad__table"]: table } }),109 "reserved separator",110 "separator in table",111 );112 throws(113 () => flattenNestedSchema({ ["bad.ns"]: { items: table } }),114 `must not contain "."`,115 "dot in namespace",116 );117 throws(() => flattenNestedSchema({}), "at least one namespace", "empty definition");118 throws(() => flattenNestedSchema({ ns: {} }), "at least one table", "empty namespace");119});120121Deno.test("ref targets resolve within the namespace or as <namespace>.<table> across namespaces", () => {122 const flat = flattenNestedSchema({123 taskapp: {124 projects: s.table({ name: s.string() }),125 tasks: s.table({ projectId: s.ref("projects") }),126 },127 notesapp: {128 notes: s.table({ taskId: s.ref("taskapp.tasks").optional() }),129 },130 }) as Record<string, { columns: Record<string, { _references?: string }> }>;131 assert(132 flat.taskapp__tasks.columns.projectId._references === "taskapp__projects",133 `same-namespace ref rewrote to ${flat.taskapp__tasks.columns.projectId._references}`,134 );135 assert(136 flat.notesapp__notes.columns.taskId._references === "taskapp__tasks",137 `cross-namespace ref rewrote to ${flat.notesapp__notes.columns.taskId._references}`,138 );139140 throws(141 () =>142 flattenNestedSchema({143 ns: { items: s.table({ ownerId: s.ref("missing") }) },144 }),145 "is not a table in this namespace",146 "unknown bare ref target",147 );148 throws(149 () =>150 flattenNestedSchema({151 ns: { items: s.table({ ownerId: s.ref("other.missing") }) },152 }),153 "does not name a declared",154 "unknown qualified ref target",155 );156 throws(157 () =>158 flattenNestedSchema({159 ns: { items: s.table({ ownerId: s.ref("ns__items") }) },160 }),161 "reserved separator",162 "mangled ref target",163 );164});165166Deno.test("ref rewriting clones builders instead of mutating the author's definition", () => {167 const projectRef = s.ref("projects");168 flattenNestedSchema({169 taskapp: {170 projects: s.table({ name: s.string() }),171 tasks: s.table({ projectId: projectRef }),172 },173 });174 assert(175 (projectRef as unknown as { _references?: string })._references === "projects",176 "flattening mutated the shared ref builder",177 );178});179180Deno.test("defineNestedPermissions exposes unprefixed policy names and compiles mangled keys", () => {181 let observedPolicyKeys: string[] = [];182 const compiled = s.defineNestedPermissions(root.taskapp, ({ policy }) => {183 observedPolicyKeys = Object.keys(policy);184 policy.tasks.allowRead.always();185 policy.tasks.allowInsert.always();186 policy.projects.allowRead.always();187 });188 assert(189 observedPolicyKeys.includes("tasks") && observedPolicyKeys.includes("projects"),190 `policy context exposes ${observedPolicyKeys.join(", ")}`,191 );192 assert(193 !observedPolicyKeys.includes("notes") && !observedPolicyKeys.includes("taskapp__tasks"),194 "policy context leaked other namespaces or mangled names",195 );196 const compiledKeys = Object.keys(compiled).sort();197 assert(198 compiledKeys.join(",") === "taskapp__projects,taskapp__tasks",199 `compiled bundle keyed by ${compiledKeys.join(", ")}`,200 );201});202203Deno.test("defineNestedPermissions rejects values that are not nested namespaces", () => {204 const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });205 throws(206 () => s.defineNestedPermissions(flat, () => {}),207 "requires a namespace value",208 "flat app as namespace",209 );210});211212Deno.test("per-namespace permission bundles merge collision-free; duplicates throw", () => {213 const taskBundle = s.defineNestedPermissions(root.taskapp, ({ policy }) => {214 policy.tasks.allowRead.always();215 });216 const noteBundle = s.defineNestedPermissions(root.notesapp, ({ policy }) => {217 policy.notes.allowRead.always();218 });219 const merged = s.mergeNestedPermissions(taskBundle, noteBundle);220 const keys = Object.keys(merged).sort();221 assert(222 keys.join(",") === "notesapp__notes,taskapp__tasks",223 `merged bundle keyed by ${keys.join(", ")}`,224 );225 throws(226 () => s.mergeNestedPermissions(taskBundle, taskBundle),227 "more than once",228 "duplicate bundle merge",229 );230});