clone

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

fix/175-template-quality-pass
lofi / package / runtime / write-journal.ts
write-journal.ts 11.2K
1/**2 * Package-owned durable write journal.3 *4 * The substrate of the effect system: one device-local record per write,5 * persisting enough to rebuild pending sync state and outstanding effect6 * obligations after a reload. The journal lives beside the runtime's7 * persistent storage — OPFS when the browser provides it, `localStorage`8 * otherwise — and is readable at boot before sync connects. Entries are keyed9 * by write id; effect obligations are keyed by `(write id, effect name)`, and10 * the composed journal id is the idempotency key handlers receive.11 *12 * The journal never stores column values. Boot-reconciliation equality probes13 * compare per-column keyed hashes (HMAC-SHA-256 under a random per-journal14 * key), so the journal is not a second greppable plaintext copy of row data.15 * This does not protect low-entropy values against brute force by an attacker16 * holding the whole storage — that attacker's access already equals the local17 * store's own at-rest posture.18 *19 * @module20 */2122import { sha256 } from "@noble/hashes/sha2";2324/**25 * One obligation's durable status. `pending` until the handler completes;26 * `failed` handlers re-arm at boot until quarantine retires them as27 * `failed-permanent`; `expired` marks a delivery window that closed before28 * the obligation could be delivered. Retired statuses never run again and29 * make the entry prunable.30 */31export type JournalEffectStatus =32 | "pending"33 | "done"34 | "failed"35 | "expired"36 | "failed-permanent";3738/** One effect obligation's durable state within a journaled write. */39export type JournalEffectState = {40 /** The obligation's durable status. */41 status: JournalEffectStatus;42 /** How many times the handler has been started, across reloads. */43 attempts: number;44 /** The last handler failure message, for diagnostics. */45 lastError: string | null;46 /** Epoch milliseconds when the delivery window closes, or `null` for none. */47 expiresAt: number | null;48};4950/** The durable fate of a journaled write. */51export type JournalWriteStage = "saved" | "synced" | "rejected";5253/** One journaled write: identity, snapshot, fate, and effect obligations. */54export type JournalWriteRecord = {55 /** The stable package-owned write id. */56 writeId: string;57 /** The declaring verb's name, or `null` for writes without a verb. */58 verb: string | null;59 /** The written table's name. */60 table: string;61 /** Which operation the write performed. */62 op: "insert" | "update" | "remove";63 /** The written row's id. */64 rowId: string;65 /** The vendor batch id used to attribute adjudicated verdicts. */66 batchId: string | null;67 /**68 * Keyed per-column hashes of the written values (all columns for inserts,69 * the changed columns for updates, none for removes), sufficient for the70 * boot-reconciliation equality probe. Values themselves are never stored.71 */72 rowHashes: Record<string, string>;73 /** The write's durable fate so far. */74 stage: JournalWriteStage;75 /** The structured rejection cause, when the stage is `rejected`. */76 cause: "denied" | "expired" | null;77 /** The adjudicated rejection code, when the stage is `rejected`. */78 code: string | null;79 /** The adjudicated rejection reason, when the stage is `rejected`. */80 reason: string | null;81 /** Epoch milliseconds when the write was journaled. */82 createdAt: number;83 /** Epoch milliseconds when the intent's lifespan ends, or `null` for none. */84 expiresAt: number | null;85 /** Effect obligations keyed by effect name. */86 effects: Record<string, JournalEffectState>;87};8889/** The persisted journal document. */90export type JournalDocument = {91 /** Storage format version. */92 version: 1;93 /** The random per-journal key (hex) the column-value hashes are keyed with. */94 hashKey: string;95 /** Journaled writes keyed by write id. */96 writes: Record<string, JournalWriteRecord>;97};9899/**100 * Where the journal document persists. The default resolves OPFS, then101 * `localStorage`, then memory; tests inject a deterministic implementation.102 */103export type JournalStorage = {104 /** Reads the persisted document text, or `null` when none exists. */105 load(): Promise<string | null>;106 /** Replaces the persisted document text. */107 save(text: string): Promise<void>;108};109110/** The composed idempotency key for one `(write id, effect name)` obligation. */111export function journalIdFor(writeId: string, effectName: string): string {112 return `${writeId}:${effectName}`;113}114115function randomHashKey(): string {116 const bytes = crypto.getRandomValues(new Uint8Array(32));117 return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");118}119120/** An empty journal document with a freshly generated hash key. */121export function emptyJournal(): JournalDocument {122 return { version: 1, hashKey: randomHashKey(), writes: {} };123}124125// One HMAC-SHA-256 block; noble's sha256 keeps this synchronous, so hashes126// exist before the journal entry is first persisted.127function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {128 const blockSize = 64;129 const normalizedKey = key.length > blockSize ? sha256(key) : key;130 const inner = new Uint8Array(blockSize).fill(0x36);131 const outer = new Uint8Array(blockSize).fill(0x5c);132 for (let index = 0; index < normalizedKey.length; index += 1) {133 inner[index] ^= normalizedKey[index];134 outer[index] ^= normalizedKey[index];135 }136 const message1 = new Uint8Array(blockSize + message.length);137 message1.set(inner);138 message1.set(message, blockSize);139 const innerHash = sha256(message1);140 const message2 = new Uint8Array(blockSize + innerHash.length);141 message2.set(outer);142 message2.set(innerHash, blockSize);143 return sha256(message2);144}145146function hexKeyBytes(hex: string): Uint8Array {147 const bytes = new Uint8Array(hex.length >> 1);148 for (let index = 0; index < bytes.length; index += 1) {149 bytes[index] = parseInt(hex.slice(index * 2, index * 2 + 2), 16);150 }151 return bytes;152}153154/**155 * The keyed hash of one JSON-normalized column value under a journal's hash156 * key: what the journal persists instead of the value, and what the157 * boot-reconciliation probe computes over live rows for the equality check.158 */159export function hashJournalValue(hashKey: string, value: unknown): string {160 const canonical = JSON.stringify(value === undefined ? null : value) ?? "null";161 const digest = hmacSha256(hexKeyBytes(hashKey), new TextEncoder().encode(canonical));162 return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");163}164165/**166 * Parses persisted journal text; unreadable or foreign text is an empty167 * journal. Entries from earlier formats without column hashes are dropped, so168 * no plaintext-era record survives a load.169 */170export function parseJournal(text: string | null): JournalDocument {171 if (!text) return emptyJournal();172 try {173 const value = JSON.parse(text) as Partial<JournalDocument> | null;174 if (175 value && value.version === 1 && typeof value.hashKey === "string" && value.hashKey &&176 value.writes && typeof value.writes === "object"177 ) {178 const writes: Record<string, JournalWriteRecord> = {};179 for (const [writeId, entry] of Object.entries(value.writes)) {180 const record = entry as Partial<JournalWriteRecord>;181 if (!record.rowHashes || typeof record.rowHashes !== "object") continue;182 writes[writeId] = entry as JournalWriteRecord;183 }184 return { version: 1, hashKey: value.hashKey, writes };185 }186 } catch {187 // Fall through: a corrupt journal must not brick boot.188 }189 return emptyJournal();190}191192/** An in-memory {@link JournalStorage} for tests and storage-less runtimes. */193export function createMemoryJournalStorage(initial: string | null = null): JournalStorage & {194 /** The currently persisted text, for assertions. */195 text(): string | null;196} {197 let stored = initial;198 return {199 load: () => Promise.resolve(stored),200 save(text: string) {201 stored = text;202 return Promise.resolve();203 },204 text: () => stored,205 };206}207208type OpfsDirectory = {209 getFileHandle(210 name: string,211 options?: { create?: boolean },212 ): Promise<{213 getFile(): Promise<{ text(): Promise<string> }>;214 createWritable(): Promise<{ write(text: string): Promise<void>; close(): Promise<void> }>;215 }>;216};217218function opfsStorage(fileName: string): JournalStorage | null {219 const storage = (globalThis.navigator as Navigator | undefined)?.storage as220 | { getDirectory?: () => Promise<OpfsDirectory> }221 | undefined;222 if (typeof storage?.getDirectory !== "function") return null;223 const directory = () => storage.getDirectory!();224 return {225 async load() {226 try {227 const handle = await (await directory()).getFileHandle(fileName);228 return await (await handle.getFile()).text();229 } catch {230 return null;231 }232 },233 async save(text: string) {234 const handle = await (await directory()).getFileHandle(fileName, { create: true });235 const writable = await handle.createWritable();236 await writable.write(text);237 await writable.close();238 },239 };240}241242function localStorageStorage(key: string): JournalStorage | null {243 if (typeof localStorage === "undefined") return null;244 return {245 load() {246 try {247 return Promise.resolve(localStorage.getItem(key));248 } catch {249 return Promise.resolve(null);250 }251 },252 save(text: string) {253 try {254 localStorage.setItem(key, text);255 } catch {256 // A private-mode quota failure degrades to session-only pending state.257 }258 return Promise.resolve();259 },260 };261}262263/**264 * Resolves the default journal storage for one app id: an OPFS file when the265 * browser provides it, `localStorage` otherwise, and memory as a last resort,266 * so the journal is always readable at boot before sync connects.267 */268export function createDefaultJournalStorage(appId: string): JournalStorage {269 return opfsStorage(`lofi-write-journal-${appId}.json`) ??270 localStorageStorage(`lofi:write-journal:${appId}`) ??271 createMemoryJournalStorage();272}273274/**275 * The journal's single reader and writer: an in-memory document with276 * serialized, coalesced persistence. Mutations apply synchronously in memory;277 * `flush()` awaits the trailing persisted state.278 */279export class WriteJournal {280 readonly #storage: JournalStorage;281 #document: JournalDocument = emptyJournal();282 #chain: Promise<void> = Promise.resolve();283 #loaded = false;284285 /** Creates a journal over one storage location. */286 constructor(storage: JournalStorage) {287 this.#storage = storage;288 }289290 /** Loads the persisted document once; later calls return the memory state. */291 async load(): Promise<JournalDocument> {292 if (!this.#loaded) {293 this.#document = parseJournal(await this.#storage.load());294 this.#loaded = true;295 }296 return this.#document;297 }298299 /** The current in-memory document. */300 get document(): JournalDocument {301 return this.#document;302 }303304 /** Applies one mutation to the document and schedules persistence. */305 update(mutate: (document: JournalDocument) => void): void {306 mutate(this.#document);307 const snapshot = JSON.stringify(this.#document);308 this.#chain = this.#chain309 .then(() => this.#storage.save(snapshot))310 .catch(() => undefined);311 }312313 /** Resolves once every scheduled persistence has settled. */314 flush(): Promise<void> {315 return this.#chain;316 }317}