1/// <reference path="./env.d.ts" />2/**3 * The boot compatibility gate between the running bundle's supported schema4 * range and the schema version the local data was last written under.5 *6 * A long-lived offline shell can be older than the data another tab or7 * device migrated. Booting that old code read-write onto newer data is a8 * defect, not an author choice, so the gate refuses writes (reads continue)9 * and names the one remediation: update the app. Code ahead of data is the10 * normal path — migrations run as they always have — and the gate then11 * stamps the local record forward. The gate never hard-fails boot: reads go12 * through the engine's own machinery, and a truly unreadable store surfaces13 * as a startup failure there.14 *15 * The bundle's range comes from the build-stamped `lofi-schema.json`, which16 * ships in the precache so an offline shell still knows its own range; the17 * local record lives in `localStorage` beside the store, readable before the18 * runtime fully boots. Zero configuration is safe: in development the gate19 * is inert, a missing manifest leaves writes enabled, and the framework20 * renders a minimal default banner that apps may replace via the optional21 * `pwa` block of `defineLofiApp` and the `useSchemaCompat` Preact hook.22 *23 * @module24 */2526import {27 classifySchemaCompat,28 parseLocalSchemaVersion,29 parseSchemaCompatManifest,30 type SchemaVersionRange,31 serializeLocalSchemaVersion,32} from "../schema/compat.ts";33import { getLofiApp } from "./app.ts";34import { anchorAppId } from "./data-sink.ts";35import { type PwaController, pwaController } from "./pwa.ts";3637/** Why writes are refused: newer-schema data, or a stale tab after a swap. */38export type SchemaCompatReason = "schema" | "stale-tab";3940/** The compatibility state exposed through diagnostics and the Preact hook. */41export type SchemaCompatState =42 /** No verdict: development mode, no manifest, or the check is in flight. */43 | { state: "unchecked"; reason: "inactive" | "development" | "no-manifest" | "pending" }44 /** Writes are allowed; `classification` says why. */45 | { state: "compatible"; classification: "first-boot" | "equal" | "code-ahead" }46 /** The data is newer than this code: read-only until the app updates. */47 | { state: "data-ahead"; reason: SchemaCompatReason; message: string }48 /** An update that resolves the mismatch is installing or applying. */49 | { state: "updating"; message: string };5051const dataAheadMessage =52 "This device's data was saved by a newer version of the app, so editing is paused to protect " +53 "it. Reading still works. Update the app to continue editing.";54const staleTabMessage =55 "A newer version of the app is now active in another tab. Reload this tab to continue editing.";56const updatingMessage = "Updating the app… editing resumes when the update finishes.";5758/**59 * Raised when a mutation is refused because the local data is ahead of the60 * running code. It surfaces through the ordinary error path of whatever61 * performed the write — a verb call's handle fails, a table62 * `insert`/`update`/`remove` rejects — with no journal entry or effect;63 * reads keep working. The remediation is updating the app: watch64 * {@link SchemaCompatState} via `useSchemaCompat` or65 * {@link subscribeSchemaCompat}, and offer `applyPwaUpdate` when the update66 * is ready.67 */68export class SchemaCompatibilityError extends Error {69 /** Stable error class name for diagnostics and error boundaries. */70 override readonly name = "SchemaCompatibilityError";71 /** Stable category for write-refusal classification. */72 readonly code = "schema-data-ahead";73}7475/** True when an error is a write refused because local data is ahead of this code. */76export function isSchemaCompatibilityError(error: unknown): error is SchemaCompatibilityError {77 return error instanceof SchemaCompatibilityError;78}7980/** The update surface the gate drives as its remediation. */81export type SchemaCompatUpdateSurface = Pick<82 PwaController,83 "getState" | "subscribe" | "checkForUpdate"84>;8586/** Injectable browser surfaces used to test the gate deterministically. */87export type SchemaCompatGateDependencies = {88 readonly production?: () => boolean;89 /** Loads the raw bundle manifest value; `null` means the file is absent. */90 readonly loadManifest?: () => Promise<unknown>;91 readonly storage?: () => Pick<Storage, "getItem" | "setItem"> | undefined;92 readonly storageKey?: () => string;93 /** Target for cross-tab `storage` events; defaults to the window. */94 readonly storageEvents?: () => EventTarget | undefined;95 readonly controller?: () => SchemaCompatUpdateSurface | undefined;96 /** Upper bound a write waits for the pending verdict; defaults to 8 seconds. */97 readonly settleTimeoutMs?: number;98};99100/** The boot compatibility gate between bundle schema range and local data. */101export type SchemaCompatGate = {102 /** Starts the check once; later calls are no-ops. */103 start(): void;104 /** The current compatibility state snapshot. */105 getState(): SchemaCompatState;106 /** Subscribes to state changes; the listener runs immediately. */107 subscribe(listener: (state: SchemaCompatState) => void): () => void;108 /** Resolves when writes are allowed; rejects with {@link SchemaCompatibilityError}. */109 assertWritable(): Promise<void>;110 /** Records that the runtime opened read-write; stamps the local version. */111 markRuntimeWritable(): void;112 /** Marks this tab stale after another tab's worker swap; refuses writes. */113 markStaleTab(): void;114};115116function defaultLoadManifest(): Promise<unknown> {117 const base = new URL(import.meta.env.BASE_URL, document.location.origin);118 if (!base.pathname.endsWith("/")) base.pathname += "/";119 return fetch(new URL("lofi-schema.json", base)).then(120 (response) => {121 if (response.status === 404) {122 void response.body?.cancel();123 return null;124 }125 if (!response.ok) {126 void response.body?.cancel();127 throw new Error(`schema manifest failed: HTTP ${response.status}`);128 }129 return response.json();130 },131 );132}133134/**135 * Creates an isolated compatibility gate over injectable browser surfaces.136 * The package-wide wrappers ({@link getSchemaCompatState},137 * {@link subscribeSchemaCompat}, {@link assertSchemaWritable}) all read the138 * shared gate that boot starts; this factory exists for isolated instances —139 * deterministic tests and custom hosts that manage their own gate.140 */141export function createSchemaCompatGate(142 dependencies: SchemaCompatGateDependencies = {},143): SchemaCompatGate {144 const subscribers = new Set<(state: SchemaCompatState) => void>();145 let state: SchemaCompatState = { state: "unchecked", reason: "inactive" };146 let bundle: SchemaVersionRange | null = null;147 let staleTab = false;148 let started = false;149 let stamped = false;150 let runtimeWritable = false;151 let settled = false;152 const settleWaiters = new Set<() => void>();153154 const storage = () => {155 if (dependencies.storage) return dependencies.storage();156 try {157 return typeof localStorage === "undefined" ? undefined : localStorage;158 } catch {159 return undefined;160 }161 };162 const storageKey = () => dependencies.storageKey?.() ?? `lofi:schema-version:${anchorAppId}`;163 const controller = () => (dependencies.controller ? dependencies.controller() : pwaController);164 const settleTimeoutMs = dependencies.settleTimeoutMs ?? 8_000;165166 const publish = (next: SchemaCompatState) => {167 state = next;168 for (const subscriber of subscribers) subscriber(state);169 };170171 const settle = () => {172 if (settled) return;173 settled = true;174 for (const waiter of settleWaiters) waiter();175 settleWaiters.clear();176 };177178 const readLocal = (): SchemaVersionRange | null => {179 try {180 return parseLocalSchemaVersion(storage()?.getItem(storageKey()) ?? null);181 } catch {182 return null;183 }184 };185186 const stamp = () => {187 if (!bundle || stamped) return;188 stamped = true;189 try {190 storage()?.setItem(storageKey(), serializeLocalSchemaVersion(bundle));191 } catch {192 // A private-mode storage failure must not affect a working runtime.193 }194 };195196 // The overlay: while the mismatch stands and an update is actually moving,197 // the surfaced state is "updating" rather than a bare refusal.198 const effectiveState = (base: SchemaCompatState): SchemaCompatState => {199 if (base.state !== "data-ahead") return base;200 const update = controller()?.getState().update;201 return update === "installing" || update === "applying"202 ? { state: "updating", message: updatingMessage }203 : base;204 };205206 let base: SchemaCompatState = state;207 const setBase = (next: SchemaCompatState) => {208 base = next;209 publish(effectiveState(base));210 };211212 const evaluate = () => {213 if (staleTab) {214 setBase({ state: "data-ahead", reason: "stale-tab", message: staleTabMessage });215 return;216 }217 if (!bundle) return;218 const classification = classifySchemaCompat(bundle, readLocal());219 if (classification === "data-ahead" || classification === "unrelated") {220 setBase({ state: "data-ahead", reason: "schema", message: dataAheadMessage });221 // The remediation is a newer bundle; kick a bounded update check. Later222 // foreground signals re-check through the controller's own machinery.223 void controller()?.checkForUpdate();224 return;225 }226 setBase({227 state: "compatible",228 classification: classification === "first-boot" ? "first-boot" : classification,229 });230 if (runtimeWritable) stamp();231 };232233 const attachPwaOverlay = () => {234 controller()?.subscribe(() => {235 publish(effectiveState(base));236 });237 };238239 const attachStorageEvents = () => {240 const target = dependencies.storageEvents241 ? dependencies.storageEvents()242 : (typeof window === "undefined" ? undefined : window);243 // Another tab advancing the stamp while this one runs is the live form of244 // the same hazard; re-evaluate so this tab flips to read-only in place.245 target?.addEventListener("storage", (event) => {246 const key = (event as StorageEvent).key;247 if (key === null || key === storageKey()) evaluate();248 });249 };250251 return {252 start() {253 if (started) return;254 started = true;255 if (!(dependencies.production?.() ?? import.meta.env.PROD)) {256 setBase({ state: "unchecked", reason: "development" });257 settle();258 return;259 }260 setBase({ state: "unchecked", reason: "pending" });261 attachPwaOverlay();262 attachStorageEvents();263 const load = dependencies.loadManifest ?? defaultLoadManifest;264 void Promise.resolve().then(load).then(265 (value) => {266 const manifest = value === null ? null : parseSchemaCompatManifest(value);267 if (!manifest) {268 setBase({ state: "unchecked", reason: "no-manifest" });269 } else {270 bundle = { head: manifest.head, lineage: manifest.lineage };271 evaluate();272 }273 settle();274 },275 () => {276 // An unreachable manifest must not brick a working app; the shell277 // that matters offline serves it from its own precache.278 setBase({ state: "unchecked", reason: "no-manifest" });279 settle();280 },281 );282 },283 getState: () => state,284 subscribe(listener) {285 subscribers.add(listener);286 listener(state);287 return () => subscribers.delete(listener);288 },289 async assertWritable() {290 if (started && !settled) {291 await new Promise<void>((resolve) => {292 settleWaiters.add(resolve);293 const timer = setTimeout(resolve, settleTimeoutMs);294 settleWaiters.add(() => clearTimeout(timer));295 });296 }297 if (state.state === "data-ahead" || state.state === "updating") {298 throw new SchemaCompatibilityError(299 state.state === "updating" ? updatingMessage : state.message,300 );301 }302 },303 markRuntimeWritable() {304 runtimeWritable = true;305 if (base.state === "compatible") stamp();306 },307 markStaleTab() {308 staleTab = true;309 evaluate();310 },311 };312}313314/** Shared gate used by boot, the runtime, and the optional Preact bindings. */315export const schemaCompatGate: SchemaCompatGate = createSchemaCompatGate();316317/** Returns the shared gate's current compatibility state snapshot. */318export function getSchemaCompatState(): SchemaCompatState {319 return schemaCompatGate.getState();320}321322/** Subscribes to shared compatibility state; returns an unsubscribe function. */323export function subscribeSchemaCompat(324 listener: (state: SchemaCompatState) => void,325): () => void {326 return schemaCompatGate.subscribe(listener);327}328329/** Resolves when writes are allowed; rejects with {@link SchemaCompatibilityError}. */330export function assertSchemaWritable(): Promise<void> {331 return schemaCompatGate.assertWritable();332}333334/** One-line label for diagnostic surfaces (development inspector, DeviceStatus). */335export function describeSchemaCompat(state: SchemaCompatState): string {336 switch (state.state) {337 case "unchecked":338 switch (state.reason) {339 case "development":340 return "not checked (development)";341 case "no-manifest":342 return "not checked (no compatibility manifest)";343 case "pending":344 return "checking…";345 default:346 return "not checked";347 }348 case "compatible":349 return state.classification === "code-ahead"350 ? "compatible (migrations pending)"351 : "compatible";352 case "data-ahead":353 return state.reason === "stale-tab"354 ? "read-only — reload this tab"355 : "read-only — data is newer than this app version";356 case "updating":357 return "updating…";358 }359}360361// ---------------------------------------------------------------------------362// Default banner: the zero-configuration surface for read-only mode. A plain363// DOM element (no UI framework) so it works in any app; authors opt out with364// `pwa: { updateBanner: "none" }` and render their own from useSchemaCompat.365// ---------------------------------------------------------------------------366367const bannerClass = "lofi-schema-compat-banner";368369function bannerAction(state: SchemaCompatState): { label: string; run: () => void } | null {370 if (state.state !== "data-ahead") return null;371 if (state.reason === "stale-tab") {372 return { label: "Reload", run: () => location.reload() };373 }374 if (pwaController.getState().update === "ready") {375 return { label: "Update app", run: () => void pwaController.applyUpdate() };376 }377 return { label: "Check for updates", run: () => void pwaController.checkForUpdate() };378}379380function renderDefaultBanner(state: SchemaCompatState): void {381 const existing = document.querySelector(`.${bannerClass}`);382 if (state.state !== "data-ahead" && state.state !== "updating") {383 existing?.remove();384 return;385 }386 const banner = existing ?? document.createElement("div");387 banner.textContent = "";388 banner.className = bannerClass;389 banner.setAttribute("role", "alert");390 (banner as HTMLElement).style.cssText =391 "position:fixed;inset:auto 0 0 0;z-index:2147483000;display:flex;gap:0.75rem;" +392 "align-items:center;justify-content:center;flex-wrap:wrap;padding:0.75rem 1rem;" +393 "background:#1f2430;color:#fff;font:14px/1.4 system-ui,sans-serif;text-align:center;";394 const message = document.createElement("span");395 message.textContent = state.message;396 banner.append(message);397 const action = bannerAction(state);398 if (action) {399 const button = document.createElement("button");400 button.type = "button";401 button.textContent = action.label;402 button.style.cssText =403 "padding:0.35rem 0.9rem;border:1px solid #fff;border-radius:0.4rem;background:none;" +404 "color:inherit;font:inherit;cursor:pointer;";405 button.addEventListener("click", action.run);406 banner.append(button);407 }408 if (!existing) document.body.append(banner);409}410411/**412 * Mounts the framework's minimal read-only banner unless the app opted out.413 * Called once by boot; safe without a defined app (defaults apply).414 */415export function mountDefaultCompatBanner(): void {416 if (typeof document === "undefined") return;417 let preference: "default" | "none" = "default";418 try {419 preference = getLofiApp().pwa?.updateBanner ?? "default";420 } catch {421 // No app definition yet; the safe default applies.422 }423 if (preference === "none") return;424 schemaCompatGate.subscribe(renderDefaultBanner);425 // Re-render on update-state changes so the action label tracks readiness.426 pwaController.subscribe(() => renderDefaultBanner(schemaCompatGate.getState()));427}