clone

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

fix: make the Jazz migration deployable

Dami committed b13c424
commit
b13c4245c832c18628b2ff9175dbba3bef992795
parent
f6ce64d72ca8a6cbf579c02a7419480b3ec06f11
author
Dami <dami.etoile@gmail.com>
8 files changed, +166 -2
Mapps/reference/astro.config.ts10++
Mapps/reference/src/app.ts4+-
Rmigrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts → apps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts0
Rmigrations/snapshots/20260715T194819-6c62fec42c35.json → apps/reference/src/migrations/snapshots/20260715T194819-6c62fec42c35.json0
Rmigrations/snapshots/20260715T194947-ff85ac1d97ee.json → apps/reference/src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json0
Mdeno.json5+-
Mdocs/devx-contract.md6+
Atools/check_migrations.ts143++++++++++++++++++++++++++++++
M apps/reference/astro.config.ts +10 -0
M apps/reference/src/app.ts +3 -1
R migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts → apps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts +0 -0

No content changes.

R migrations/snapshots/20260715T194819-6c62fec42c35.json → apps/reference/src/migrations/snapshots/20260715T194819-6c62fec42c35.json +0 -0

No content changes.

R migrations/snapshots/20260715T194947-ff85ac1d97ee.json → apps/reference/src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json +0 -0

No content changes.

M deno.json +4 -1
@@ -23,14 +23,17 @@
2323 "tasks": {2424 "check:env": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env tools/check_env.ts",2525 "check:secrets": "deno run --allow-env=JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read --allow-run=git tools/check_secret_leaks.ts",26+ "check:migrations": "deno run --allow-read=apps/reference/src/migrations --allow-run tools/check_migrations.ts",2627 "check:astro": "LOFI_SKIP_JAZZ_MANAGED=1 JAZZ_ADMIN_SECRET= BACKEND_SECRET= deno run -A npm:astro@7.0.9 check --root apps/reference",27- "check": "deno fmt --check && deno lint && deno check tools/*.ts migrations/*.ts spikes/jazz2-baseline/*.ts spikes/jazz2-baseline/src/*.ts apps/reference/*.ts apps/reference/src/**/*.ts apps/reference/src/**/*.tsx && deno task check:astro && deno task check:env && deno task check:secrets",28+ "check": "deno fmt --check && deno lint && deno check tools/*.ts spikes/jazz2-baseline/*.ts spikes/jazz2-baseline/src/*.ts apps/reference/*.ts apps/reference/src/**/*.ts apps/reference/src/**/*.tsx && deno task check:astro && deno task check:env && deno task check:secrets && deno task check:migrations && deno task test",2829 "test:unit": "deno test --allow-read --allow-write=. --allow-run=git tools apps/reference/src/_lofi apps/reference/tests",2930 "test": "deno task test:unit",3031 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",3132 "test:golden:checkout": "deno run -A tools/golden_path_checkout.ts",3233 "dev": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env --allow-write=apps/reference/.env --allow-run tools/run_reference.ts dev",3334 "build": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env,apps/reference/dist --allow-write=apps/reference/.env,apps/reference/dist --allow-run tools/run_reference.ts build",35+ "schema:validate": "deno run -A npm:jazz-tools@2.0.0-alpha.53 validate --schema-dir apps/reference/src",36+ "schema:deploy": "deno run --env-file=.env -A npm:jazz-tools@2.0.0-alpha.53 deploy --schema-dir apps/reference/src --migrations-dir apps/reference/src/migrations",3437 "preview": "deno run --allow-net --allow-read=apps/reference/dist tools/preview.ts",3538 "spike:jazz2:dev": "deno run --allow-read=spikes/jazz2-baseline/.env --allow-write=spikes/jazz2-baseline/.env --allow-run tools/run_jazz2_spike.ts local",3639 "spike:jazz2:cloud": "deno run --allow-read=spikes/jazz2-baseline/.env --allow-write=spikes/jazz2-baseline/.env --allow-run tools/run_jazz2_spike.ts cloud",
M docs/devx-contract.md +6 -0
A tools/check_migrations.ts +143 -0
@@ -0,0 +1,143 @@
1+import { schema as s } from "jazz-tools";2+import { referenceApp } from "../apps/reference/src/app.ts";3+import migration from "../apps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts";4+5+const SCHEMA_DIR = "apps/reference/src";6+const MIGRATIONS_DIR = `${SCHEMA_DIR}/migrations`;7+const SNAPSHOTS_DIR = `${MIGRATIONS_DIR}/snapshots`;8+const JAZZ_TOOLS = "npm:jazz-tools@2.0.0-alpha.53";9+10+function fail(message: string): never {11+ throw new Error(`migration contract: ${message}`);12+}13+14+async function jazz(args: string[]): Promise<string> {15+ const output = await new Deno.Command(Deno.execPath(), {16+ args: ["run", "-A", JAZZ_TOOLS, ...args, "--schema-dir", SCHEMA_DIR],17+ stdout: "piped",18+ stderr: "piped",19+ }).output();20+ if (!output.success) {21+ const error = new TextDecoder().decode(output.stderr).trim();22+ fail(`Jazz CLI failed: ${error || `exit ${output.code}`}`);23+ }24+ return new TextDecoder().decode(output.stdout).trim();25+}26+27+async function jazzSnapshot(hash: string): Promise<unknown> {28+ const output = await new Deno.Command(Deno.execPath(), {29+ args: [30+ "run",31+ "-A",32+ JAZZ_TOOLS,33+ "schema",34+ "export",35+ "--schema-hash",36+ hash,37+ "--migrations-dir",38+ MIGRATIONS_DIR,39+ ],40+ stdout: "piped",41+ stderr: "piped",42+ }).output();43+ if (!output.success) {44+ const error = new TextDecoder().decode(output.stderr).trim();45+ fail(`snapshot ${hash} does not hash to its filename: ${error || `exit ${output.code}`}`);46+ }47+ return JSON.parse(new TextDecoder().decode(output.stdout));48+}49+50+function hashField(source: string, field: "fromHash" | "toHash"): string {51+ const match = source.match(new RegExp(`${field}:\\s*["']([0-9a-f]{12})["']`));52+ if (!match) fail(`reviewed migration is missing a 12-character ${field}`);53+ return match[1];54+}55+56+const hashOutput = await jazz(["schema", "hash"]);57+const currentHash = hashOutput.match(/Current schema hash:\s*([0-9a-f]{12})/)?.[1];58+if (!currentHash) fail(`could not read the current hash from Jazz CLI output: ${hashOutput}`);59+60+const migrationNames: string[] = [];61+for await (const entry of Deno.readDir(MIGRATIONS_DIR)) {62+ if (entry.isFile && entry.name.endsWith(".ts")) migrationNames.push(entry.name);63+}64+if (migrationNames.length === 0) fail(`no reviewed migrations found in ${MIGRATIONS_DIR}`);65+66+let currentEdgeFound = false;67+for (const name of migrationNames.sort()) {68+ const source = await Deno.readTextFile(`${MIGRATIONS_DIR}/${name}`);69+ const fromHash = hashField(source, "fromHash");70+ const toHash = hashField(source, "toHash");71+ if (!name.endsWith(`-${fromHash}-${toHash}.ts`)) {72+ fail(`${name} does not encode its declared ${fromHash} -> ${toHash} edge`);73+ }74+ for (const hash of [fromHash, toHash]) {75+ const snapshotNames = [];76+ for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {77+ if (entry.isFile && entry.name.endsWith(`-${hash}.json`)) snapshotNames.push(entry.name);78+ }79+ if (snapshotNames.length !== 1) {80+ fail(`${name} requires exactly one ${hash} snapshot; found ${snapshotNames.length}`);81+ }82+ const snapshot = JSON.parse(83+ await Deno.readTextFile(`${SNAPSHOTS_DIR}/${snapshotNames[0]}`),84+ );85+ if (JSON.stringify(await jazzSnapshot(hash)) !== JSON.stringify(snapshot)) {86+ fail(`${snapshotNames[0]} does not round-trip through Jazz's hash loader`);87+ }88+ }89+ if (toHash === currentHash) currentEdgeFound = true;90+}91+if (!currentEdgeFound) {92+ fail(`current schema ${currentHash} has no reviewed incoming migration edge`);93+}94+95+const currentSnapshots: string[] = [];96+for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {97+ if (entry.isFile && entry.name.endsWith(`-${currentHash}.json`)) {98+ currentSnapshots.push(entry.name);99+ }100+}101+if (currentSnapshots.length !== 1) {102+ fail(`current schema ${currentHash} must have exactly one snapshot`);103+}104+105+const currentSnapshot = JSON.parse(106+ await Deno.readTextFile(`${SNAPSHOTS_DIR}/${currentSnapshots[0]}`),107+);108+if (JSON.stringify(referenceApp.schema.wasmSchema) !== JSON.stringify(currentSnapshot)) {109+ fail(`current snapshot ${currentSnapshots[0]} does not match schema.ts`);110+}111+112+const previousSnapshotName = [];113+for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {114+ if (entry.isFile && entry.name.endsWith("-6c62fec42c35.json")) {115+ previousSnapshotName.push(entry.name);116+ }117+}118+if (previousSnapshotName.length !== 1) fail("the M1 source snapshot is missing or ambiguous");119+const previousSnapshot = JSON.parse(120+ await Deno.readTextFile(`${SNAPSHOTS_DIR}/${previousSnapshotName[0]}`),121+);122+if (JSON.stringify(s.defineApp(migration.from).wasmSchema) !== JSON.stringify(previousSnapshot)) {123+ fail("the reviewed lens source does not match the M1 snapshot");124+}125+if (JSON.stringify(s.defineApp(migration.to).wasmSchema) !== JSON.stringify(currentSnapshot)) {126+ fail("the reviewed lens target does not match the current snapshot");127+}128+129+const expectedForward = [{130+ table: "tasks",131+ renamedFrom: "notes",132+ operations: [133+ { type: "rename", column: "body", value: "text" },134+ { type: "introduce", column: "completed", sqlType: "BOOLEAN", value: false },135+ ],136+}];137+if (JSON.stringify(migration.forward) !== JSON.stringify(expectedForward)) {138+ fail("the reviewed lens must rename notes/body and add completed=false");139+}140+141+console.log(142+ `migration contract: ${migrationNames.length} reviewed edge(s), current schema ${currentHash}`,143+);