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_conformance_test.ts
nested_conformance_test.ts 8.7K
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.6import { createDb } from "jazz-tools";7import { createPolicyTestApp, deploy, startLocalJazzServer } from "jazz-tools/testing";8import { assert } from "../runtime/test-assert.ts";9import { nestedAppDeployTarget, s } from "./mod.ts";1011const 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});2021const 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});29const 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});34const permissions = s.mergeNestedPermissions(taskPermissions, notePermissions);3536type ThrowExpectation = {37 toThrow(expected?: unknown): void;38 not: { toThrow(expected?: unknown): void };39};4041function 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}7677const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });7879Deno.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"));8788 // 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 );102103 // 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});123124function 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}129130async 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}143144Deno.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 );182183 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 );209210 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 );221222 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});