clone

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

feat/141-effect-library
lofi / package / tooling / schema-manifest.ts
schema-manifest.ts 4.1K
1/**2 * Build-time generation of the revision↔schema compatibility manifest3 * (`dist/lofi-schema.json`).4 *5 * The head fingerprint comes from the project's own schema module, and the6 * lineage from the committed migration snapshots (each snapshot is a prior7 * schema in wasm form). Both are computable offline at build time; the boot8 * gate later compares the shipped range against the version recorded beside9 * the local data.10 *11 * @module12 */1314import { join, resolve } from "node:path";15import { pathToFileURL } from "node:url";16import {17 buildSchemaVersionRange,18 resolveAppWasmSchema,19 type SchemaCompatManifest,20} from "../schema/compat.ts";21import { type NestedAppRoot, nestedAppTables } from "../schema/nested.ts";2223function isAppValue(value: unknown): boolean {24 if (!value || typeof value !== "object") return false;25 // The probe inspects arbitrary module exports; nestedAppTables answers null26 // for values that lack the nested-app root marker.27 if (nestedAppTables(value as NestedAppRoot) !== null) return true;28 const wasmSchema = (value as { wasmSchema?: unknown }).wasmSchema;29 return Boolean(wasmSchema && typeof wasmSchema === "object");30}3132/**33 * Resolves the app value from a schema module's exports: the `app` export34 * when it is one, otherwise the single exported app-shaped value. Throws an35 * actionable error when none (or more than one candidate) is found.36 */37export function findSchemaAppExport(moduleExports: Record<string, unknown>): unknown {38 if (isAppValue(moduleExports.app)) return moduleExports.app;39 const candidates = Object.entries(moduleExports).filter(([, value]) => isAppValue(value));40 if (candidates.length === 1) return candidates[0][1];41 throw new Error(42 candidates.length === 043 ? "src/schema.ts does not export an app value (s.defineApp or s.defineNestedApp); " +44 "the schema compatibility manifest needs one to fingerprint the schema"45 : `src/schema.ts exports several app values (${46 candidates.map(([name]) => name).join(", ")47 }); export the deployed one as \`app\``,48 );49}5051async function readSnapshotSchemas(52 snapshotsDir: string,53): Promise<Record<string, unknown>[]> {54 let entries: Deno.DirEntry[];55 try {56 entries = [...Deno.readDirSync(snapshotsDir)];57 } catch (error) {58 if (error instanceof Deno.errors.NotFound) return [];59 throw error;60 }61 const schemas: Record<string, unknown>[] = [];62 for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {63 if (!entry.isFile || !entry.name.endsWith(".json")) continue;64 const parsed = JSON.parse(await Deno.readTextFile(join(snapshotsDir, entry.name)));65 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {66 schemas.push(parsed as Record<string, unknown>);67 }68 }69 return schemas;70}7172/**73 * Generates the compatibility manifest for one project: imports the schema74 * module, fingerprints its compiled schema as the head, and fingerprints75 * every committed migration snapshot (`src/migrations/snapshots/*.json`) as76 * the lineage.77 */78export async function generateSchemaCompatManifest(options: {79 /** The build revision (source hash) this manifest belongs to. */80 revision: string;81 /** Project root; defaults to the current working directory. */82 root?: string;83}): Promise<SchemaCompatManifest> {84 const root = resolve(options.root ?? Deno.cwd());85 const schemaPath = join(root, "src", "schema.ts");86 let moduleExports: Record<string, unknown>;87 try {88 moduleExports = await import(pathToFileURL(schemaPath).href) as Record<string, unknown>;89 } catch (error) {90 throw new Error(91 `failed to load ${schemaPath} for the schema compatibility manifest: ${92 error instanceof Error ? error.message : String(error)93 }`,94 );95 }96 const app = findSchemaAppExport(moduleExports);97 // Resolve eagerly so a malformed app fails here with the module named.98 resolveAppWasmSchema(app);99 const ancestors = await readSnapshotSchemas(join(root, "src", "migrations", "snapshots"));100 const range = await buildSchemaVersionRange(app, ancestors);101 return { v: 1, revision: options.revision, head: range.head, lineage: range.lineage };102}