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 / sliceable_test.ts
sliceable_test.ts 9.4K
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`.7import { createDb } from "jazz-tools";8import { createPolicyTestApp, deploy, startLocalJazzServer } from "jazz-tools/testing";9import { assert } from "../runtime/test-assert.ts";10import { s } from "./mod.ts";1112const 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});1718// One slice per sub-app view; each slice call derives fresh typed handles19// over the same compiled schema.20const taskSlice = app.slice("workspaces", "tasks");21const noteSlice = app.slice("notes");22const deploySlice = app.slice("workspaces", "tasks", "notes");2324const 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});32const 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});3839const mergedPermissions = s.mergeNestedPermissions(taskPermissions, notePermissions);4041type ThrowExpectation = {42 toThrow(expected?: unknown): void;43 not: { toThrow(expected?: unknown): void };44};4546function 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}8182const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });8384Deno.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});104105Deno.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"));113114 // 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 );126127 // 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});151152function 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}157158async 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}171172Deno.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 });203204 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 );230231 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 );242243 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});