clone

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

main
lofi / package / schema / compat.ts
compat.ts 8.0K
1/**2 * Revision↔schema compatibility: the manifest a build stamps next to its3 * service worker, and the classification the boot gate runs against the4 * schema version recorded beside the local data.5 *6 * The bundle and the data version independently, so an offline shell can be7 * older than the data another tab or device migrated. The fingerprint here is8 * lofi-owned and deliberately independent of the sync server's schema hash:9 * both sides of every comparison — the built manifest and the local stamp —10 * are produced by this module, so the only requirement is that the11 * normalization stays stable across releases. The `v1:` prefix names that12 * normalization; a future change ships as `v2:` and classifies against `v1:`13 * values as unrelated rather than silently equal.14 *15 * @module16 */1718import { nestedAppDeployTarget, type NestedAppRoot, nestedAppTables } from "./nested.ts";1920/** The schema range one build understands: its head and every known ancestor. */21export type SchemaVersionRange = {22 /** The fingerprint of the schema this build compiles against. */23 head: string;24 /** Fingerprints of the head and every ancestor schema (migration lineage). */25 lineage: readonly string[];26};2728/** The build-stamped compatibility manifest (`dist/lofi-schema.json`). */29export type SchemaCompatManifest = SchemaVersionRange & {30 /** Manifest format version. */31 v: 1;32 /** The build revision the schema range belongs to (the source hash). */33 revision: string;34};3536/**37 * How one build's supported schema range relates to the version the local38 * data was last written under.39 */40export type SchemaCompatClassification =41 /** No recorded local version — nothing constrains this build. */42 | "first-boot"43 /** The local data and this build share one schema head. */44 | "equal"45 /** This build's lineage contains the local version: migrations run forward. */46 | "code-ahead"47 /** The local data's lineage contains this build's head: the data is newer. */48 | "data-ahead"49 /** Neither range contains the other; treat like newer data, never write. */50 | "unrelated";5152// Canonical JSON: object keys sorted recursively, `undefined` entries dropped53// exactly as JSON serialization drops them (so an in-memory schema and its54// JSON snapshot fingerprint identically), and any `columns` array sorted by55// column name — the engine treats column order as insignificant, so the56// fingerprint must too. Everything else keeps its value verbatim.57function canonicalize(value: unknown, key?: string): unknown {58 if (Array.isArray(value)) {59 const entries = value.map((entry) => canonicalize(entry) ?? null);60 if (key === "columns") {61 entries.sort((left, right) => {62 const leftName = (left as { name?: unknown })?.name;63 const rightName = (right as { name?: unknown })?.name;64 return String(leftName).localeCompare(String(rightName));65 });66 }67 return entries;68 }69 if (value && typeof value === "object") {70 const sorted: Record<string, unknown> = {};71 for (const name of Object.keys(value as Record<string, unknown>).sort()) {72 const entry = canonicalize((value as Record<string, unknown>)[name], name);73 if (entry !== undefined) sorted[name] = entry;74 }75 return sorted;76 }77 return value;78}7980/**81 * Computes the stable lofi fingerprint of one compiled (wasm-form) schema:82 * `v1:` plus 16 hex characters of the SHA-256 of the canonicalized schema.83 */84export async function computeSchemaFingerprint(85 wasmSchema: Record<string, unknown>,86): Promise<string> {87 const input = new TextEncoder().encode(JSON.stringify(canonicalize(wasmSchema)));88 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));89 const hex = Array.from(digest.slice(0, 8))90 .map((byte) => byte.toString(16).padStart(2, "0"))91 .join("");92 return `v1:${hex}`;93}9495/**96 * The compiled wasm-form schema of an app value: a flat `defineApp` result97 * carries it directly, a nested root exposes it through its deploy target.98 * Throws when the value is neither.99 */100export function resolveAppWasmSchema(app: unknown): Record<string, unknown> {101 if (app && typeof app === "object") {102 // The app value arrives untyped from a loaded module, so the brand is103 // asserted and the null defense classifies non-nested values.104 if (nestedAppTables(app as NestedAppRoot) !== null) {105 const target = nestedAppDeployTarget(app as NestedAppRoot) as {106 wasmSchema?: Record<string, unknown>;107 };108 if (target.wasmSchema) return target.wasmSchema;109 }110 const wasmSchema = (app as { wasmSchema?: unknown }).wasmSchema;111 if (wasmSchema && typeof wasmSchema === "object") {112 return wasmSchema as Record<string, unknown>;113 }114 }115 throw new Error(116 "schema compatibility requires an app value from s.defineApp or s.defineNestedApp",117 );118}119120/**121 * Builds the schema version range for one app: the head fingerprint plus the122 * fingerprints of every ancestor schema (the committed migration snapshots).123 * The head is always part of the lineage; ancestor order is not significant —124 * classification is containment, not ordering.125 */126export async function buildSchemaVersionRange(127 app: unknown,128 ancestorSchemas: readonly Record<string, unknown>[] = [],129): Promise<SchemaVersionRange> {130 const head = await computeSchemaFingerprint(resolveAppWasmSchema(app));131 const lineage = new Set<string>();132 for (const ancestor of ancestorSchemas) {133 lineage.add(await computeSchemaFingerprint(ancestor));134 }135 lineage.add(head);136 return { head, lineage: [...lineage].sort() };137}138139/**140 * Classifies one build's supported schema range against the version range141 * recorded beside the local data. Containment decides direction: a build142 * whose lineage includes the local head is ahead of the data (migrations run143 * as usual), a local record whose lineage includes the build's head was144 * stamped by a newer build (the data is ahead), and two ranges that contain145 * neither head are unrelated.146 */147export function classifySchemaCompat(148 bundle: SchemaVersionRange,149 local: SchemaVersionRange | null,150): SchemaCompatClassification {151 if (!local) return "first-boot";152 if (local.head === bundle.head) return "equal";153 if (bundle.lineage.includes(local.head)) return "code-ahead";154 if (local.lineage.includes(bundle.head)) return "data-ahead";155 return "unrelated";156}157158function isVersionRange(value: unknown): value is SchemaVersionRange {159 if (!value || typeof value !== "object") return false;160 const range = value as { head?: unknown; lineage?: unknown };161 return typeof range.head === "string" && range.head.length > 0 &&162 Array.isArray(range.lineage) &&163 range.lineage.every((entry) => typeof entry === "string") &&164 range.lineage.includes(range.head);165}166167/** Parses an untrusted manifest value; `null` when the shape is not usable. */168export function parseSchemaCompatManifest(value: unknown): SchemaCompatManifest | null {169 if (!value || typeof value !== "object") return null;170 const manifest = value as { v?: unknown; revision?: unknown };171 if (manifest.v !== 1 || typeof manifest.revision !== "string") return null;172 if (!isVersionRange(value)) return null;173 const range = value as SchemaVersionRange;174 return { v: 1, revision: manifest.revision, head: range.head, lineage: [...range.lineage] };175}176177/** Parses a persisted local schema-version record; `null` when unusable. */178export function parseLocalSchemaVersion(value: unknown): SchemaVersionRange | null {179 if (typeof value !== "string" || !value) return null;180 try {181 const parsed = JSON.parse(value) as { v?: unknown };182 if (!parsed || typeof parsed !== "object" || parsed.v !== 1) return null;183 if (!isVersionRange(parsed)) return null;184 const range = parsed as unknown as SchemaVersionRange;185 return { head: range.head, lineage: [...range.lineage] };186 } catch {187 return null;188 }189}190191/** Serializes a local schema-version record for persistence beside the store. */192export function serializeLocalSchemaVersion(range: SchemaVersionRange): string {193 return JSON.stringify({ v: 1, head: range.head, lineage: [...range.lineage] });194}