clone

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

main
lofi / package / schema / migration_test.ts
migration_test.ts 7.1K
1// Migration conformance: the facade's schema-evolution members exercised2// against a real JazzServer — defineSchema, defineMigration, renameTableFrom,3// and the column ops add/drop/renameFrom. A v1 deployment takes writes, then4// v2 deploys with the migration lens and both schema versions read the same5// rows. Runs in its own process before the conformance suite (see the6// test:conformance task).7import { createDb } from "jazz-tools";8import { deploy, startLocalJazzServer } from "jazz-tools/testing";9import { assert } from "../runtime/test-assert.ts";10import { s } from "./mod.ts";1112const v1 = s.defineSchema({13 todos: s.table({ title: s.string(), done: s.boolean(), detail: s.string() }),14 projects: s.table({ name: s.string() }),15});16const v2 = s.defineSchema({17 todos: s.table({18 title: s.string(),19 note: s.string(),20 priority: s.enum("low", "medium", "high"),21 }),22 workspaces: s.table({ name: s.string() }),23});2425const appV1 = s.defineApp(v1);26const appV2 = s.defineApp(v2);2728const migration = s.defineMigration({29 from: v1,30 to: v2,31 renameTables: { workspaces: s.renameTableFrom("projects") },32 migrate: {33 todos: {34 priority: s.add.enum("low", "medium", "high", { default: "medium" }),35 note: s.renameFrom("detail"),36 done: s.drop.boolean({ backwardsDefault: false }),37 },38 },39});4041type PolicyRules = {42 allowInsert: { always(): void };43 allowRead: { always(): void };44 allowUpdate: { always(): void };45 allowDelete: { always(): void };46};47function allowAll(app: unknown, tables: string[]) {48 return s.definePermissions(49 app as Parameters<typeof s.definePermissions>[0],50 ({ policy }) => {51 const rules = policy as unknown as Record<string, PolicyRules>;52 for (const table of tables) {53 rules[table].allowInsert.always();54 rules[table].allowRead.always();55 rules[table].allowUpdate.always();56 rules[table].allowDelete.always();57 }58 },59 );60}61const permissionsV1 = allowAll(appV1, ["todos", "projects"]);62const permissionsV2 = allowAll(appV2, ["todos", "workspaces"]);6364function secret(fill: number): string {65 let binary = "";66 for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);67 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");68}6970async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {71 let timer: ReturnType<typeof setTimeout> | undefined;72 try {73 return await Promise.race([74 operation,75 new Promise<never>((_resolve, reject) => {76 timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);77 }),78 ]);79 } finally {80 if (timer !== undefined) clearTimeout(timer);81 }82}8384Deno.test("schema migrations carry data forward across add, drop, rename, and table rename", async () => {85 const server = await startLocalJazzServer({ allowLocalFirstAuth: true });86 const clients: Awaited<ReturnType<typeof createDb>>[] = [];87 const client = async (fill: number) => {88 const db = await createDb({89 appId: server.appId,90 serverUrl: server.url,91 secret: secret(fill),92 userBranch: "main",93 driver: { type: "memory" },94 });95 clients.push(db);96 return db;97 };98 try {99 await deploy({100 appId: server.appId,101 serverUrl: server.url,102 adminSecret: server.adminSecret,103 schema: appV1,104 permissions: permissionsV1,105 });106 const alice = await client(1);107 const todoV1 = await within(108 alice.insert(appV1.todos, { title: "first", done: true, detail: "carried" })109 .wait({ tier: "global" }),110 "v1 todo insert",111 );112 const projectV1 = await within(113 alice.insert(appV1.projects, { name: "renamed-table" }).wait({ tier: "global" }),114 "v1 project insert",115 );116117 const unmigrated = await within(118 deploy({119 appId: server.appId,120 serverUrl: server.url,121 adminSecret: server.adminSecret,122 schema: appV2,123 permissions: permissionsV2,124 }).then(125 (result) => ({ outcome: "resolved" as const, result }),126 (error) => ({127 outcome: "rejected" as const,128 message: error instanceof Error ? error.message : String(error),129 }),130 ),131 "v2 deploy without migration",132 );133 assert(134 unmigrated.outcome === "resolved" &&135 unmigrated.result.migration?.status === "missing",136 `deploy without a required migration no longer reports status "missing": ${137 JSON.stringify(unmigrated)138 }`,139 );140141 await within(142 deploy({143 appId: server.appId,144 serverUrl: server.url,145 adminSecret: server.adminSecret,146 schema: appV2,147 permissions: permissionsV2,148 migration,149 }),150 "v2 deploy with migration",151 );152153 // Forward lens: the v1 row arrives under v2 with the renamed column's154 // data, the added column's default, and without the dropped column.155 const bob = await client(2);156 const todosV2 = await within(157 bob.all(appV2.todos.where({ id: todoV1.id }), { tier: "global" }),158 "v2 todo read",159 );160 const migrated = todosV2[0];161 assert(migrated !== undefined, "v1 todo lost across the migration");162 assert(163 migrated.title === "first" && migrated.note === "carried",164 `renameFrom: v1 detail read back as note=${JSON.stringify(migrated.note)}`,165 );166 assert(167 migrated.priority === "medium",168 `add: v2 column read back ${JSON.stringify(migrated.priority)}, not its default`,169 );170 assert(171 !("done" in migrated),172 `drop: v1 column still present under v2: ${JSON.stringify(migrated)}`,173 );174175 const workspacesV2 = await within(176 bob.all(appV2.workspaces.where({ id: projectV1.id }), { tier: "global" }),177 "v2 workspace read",178 );179 assert(180 workspacesV2.length === 1 && workspacesV2[0].name === "renamed-table",181 `renameTableFrom: v1 project row not readable under the renamed table`,182 );183184 // Backward lens (generated automatically): a client still on v1 handles185 // reads a v2-written row with the rename reversed and the dropped186 // column's backwardsDefault filled in.187 const todoV2 = await within(188 bob.insert(appV2.todos, { title: "second", note: "fresh", priority: "high" })189 .wait({ tier: "global" }),190 "v2 todo insert",191 );192 const backward = (await within(193 alice.all(appV1.todos.where({ id: todoV2.id }), { tier: "global" }),194 "v1 read of v2 row",195 ))[0];196 assert(backward !== undefined, "v2 todo invisible to the v1 reader");197 assert(198 backward.title === "second" && backward.detail === "fresh",199 `backward rename: v2 note read back as detail=${JSON.stringify(backward.detail)}`,200 );201 assert(202 backward.done === false,203 `backward drop: backwardsDefault read back ${JSON.stringify(backward.done)}`,204 );205 assert(206 !("priority" in backward),207 `backward add: v2-only column leaked into the v1 view: ${JSON.stringify(backward)}`,208 );209 } finally {210 await Promise.allSettled(211 clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),212 );213 await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);214 }215});