clone

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

main
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 /** Parent obligation retaining this child until it commits, or `null`. */58 retainedBy?: string | null;59 /** The declaring verb's name, or `null` for writes without a verb. */60 verb: string | null;61 /** The written table's name. */62 table: string;63 /** Which operation the write performed. */64 op: "insert" | "update" | "remove";65 /** The written row's id. */66 rowId: string;67 /** The vendor batch id used to attribute adjudicated verdicts. */68 batchId: string | null;69 /**70 * Keyed per-column hashes of the written values (all columns for inserts,71 * the changed columns for updates, none for removes), sufficient for the72 * boot-reconciliation equality probe. Values themselves are never stored.73 */74 rowHashes: Record<string, string>;75 /** The write's durable fate so far. */76 stage: JournalWriteStage;77 /** The structured rejection cause, when the stage is `rejected`. */78 cause: "denied" | "expired" | null;79 /** The adjudicated rejection code, when the stage is `rejected`. */80 code: string | null;81 /** The adjudicated rejection reason, when the stage is `rejected`. */82 reason: string | null;83 /** Epoch milliseconds when the write was journaled. */84 createdAt: number;85 /** Epoch milliseconds when the intent's lifespan ends, or `null` for none. */86 expiresAt: number | null;87 /** Effect obligations keyed by effect name. */88 effects: Record<string, JournalEffectState>;89};9091/** The persisted journal document. */92export type JournalDocument = {93 /** Storage format version. */94 version: 1;95 /** The random per-journal key (hex) the column-value hashes are keyed with. */96 hashKey: string;97 /** Journaled writes keyed by write id. */98 writes: Record<string, JournalWriteRecord>;99};100101/**102 * Where the journal document persists. The default resolves OPFS, then103 * `localStorage`, then memory; tests inject a deterministic implementation.104 */105export type JournalStorage = {106 /** Reads the persisted document text, or `null` when none exists. */107 load(): Promise<string | null>;108 /** Replaces the persisted document text. */109 save(text: string): Promise<void>;110};111112/** The composed idempotency key for one `(write id, effect name)` obligation. */113export function journalIdFor(writeId: string, effectName: string): string {114 return `${writeId}:${effectName}`;115}116117function randomHashKey(): string {118 const bytes = crypto.getRandomValues(new Uint8Array(32));119 return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");120}121122/** An empty journal document with a freshly generated hash key. */123export function emptyJournal(): JournalDocument {124 return { version: 1, hashKey: randomHashKey(), writes: {} };125}126127// One HMAC-SHA-256 block; noble's sha256 keeps this synchronous, so hashes128// exist before the journal entry is first persisted.129function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {130 const blockSize = 64;131 const normalizedKey = key.length > blockSize ? sha256(key) : key;132 const inner = new Uint8Array(blockSize).fill(0x36);133 const outer = new Uint8Array(blockSize).fill(0x5c);134 for (let index = 0; index < normalizedKey.length; index += 1) {135 inner[index] ^= normalizedKey[index];136 outer[index] ^= normalizedKey[index];137 }138 const message1 = new Uint8Array(blockSize + message.length);139 message1.set(inner);140 message1.set(message, blockSize);141 const innerHash = sha256(message1);142 const message2 = new Uint8Array(blockSize + innerHash.length);143 message2.set(outer);144 message2.set(innerHash, blockSize);145 return sha256(message2);146}147148function hexKeyBytes(hex: string): Uint8Array {149 const bytes = new Uint8Array(hex.length >> 1);150 for (let index = 0; index < bytes.length; index += 1) {151 bytes[index] = parseInt(hex.slice(index * 2, index * 2 + 2), 16);152 }153 return bytes;154}155156/**157 * The keyed hash of one JSON-normalized column value under a journal's hash158 * key: what the journal persists instead of the value, and what the159 * boot-reconciliation probe computes over live rows for the equality check.160 */161export function hashJournalValue(hashKey: string, value: unknown): string {162 const canonical = JSON.stringify(value === undefined ? null : value) ?? "null";163 const digest = hmacSha256(hexKeyBytes(hashKey), new TextEncoder().encode(canonical));164 return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");165}166167/**168 * Parses persisted journal text; unreadable or foreign text is an empty169 * journal. Entries from earlier formats without column hashes are dropped, so170 * no plaintext-era record survives a load.171 */172export function parseJournal(text: string | null): JournalDocument {173 if (!text) return emptyJournal();174 try {175 const value = JSON.parse(text) as Partial<JournalDocument> | null;176 if (177 value && value.version === 1 && typeof value.hashKey === "string" && value.hashKey &&178 value.writes && typeof value.writes === "object"179 ) {180 const writes: Record<string, JournalWriteRecord> = {};181 for (const [writeId, entry] of Object.entries(value.writes)) {182 const record = entry as Partial<JournalWriteRecord>;183 if (!record.rowHashes || typeof record.rowHashes !== "object") continue;184 writes[writeId] = entry as JournalWriteRecord;185 }186 return { version: 1, hashKey: value.hashKey, writes };187 }188 } catch {189 // Fall through: a corrupt journal must not brick boot.190 }191 return emptyJournal();192}193194/** An in-memory {@link JournalStorage} for tests and storage-less runtimes. */195export function createMemoryJournalStorage(initial: string | null = null): JournalStorage & {196 /** The currently persisted text, for assertions. */197 text(): string | null;198} {199 let stored = initial;200 return {201 load: () => Promise.resolve(stored),202 save(text: string) {203 stored = text;204 return Promise.resolve();205 },206 text: () => stored,207 };208}209210type OpfsDirectory = {211 getFileHandle(212 name: string,213 options?: { create?: boolean },214 ): Promise<{215 getFile(): Promise<{ text(): Promise<string> }>;216 createWritable(): Promise<{ write(text: string): Promise<void>; close(): Promise<void> }>;217 }>;218};219220function opfsStorage(fileName: string): JournalStorage | null {221 const storage = (globalThis.navigator as Navigator | undefined)?.storage as222 | { getDirectory?: () => Promise<OpfsDirectory> }223 | undefined;224 if (typeof storage?.getDirectory !== "function") return null;225 const directory = () => storage.getDirectory!();226 return {227 async load() {228 try {229 const handle = await (await directory()).getFileHandle(fileName);230 return await (await handle.getFile()).text();231 } catch {232 return null;233 }234 },235 async save(text: string) {236 const handle = await (await directory()).getFileHandle(fileName, { create: true });237 const writable = await handle.createWritable();238 await writable.write(text);239 await writable.close();240 },241 };242}243244function localStorageStorage(key: string): JournalStorage | null {245 if (typeof localStorage === "undefined") return null;246 return {247 load() {248 try {249 return Promise.resolve(localStorage.getItem(key));250 } catch {251 return Promise.resolve(null);252 }253 },254 save(text: string) {255 localStorage.setItem(key, text);256 return Promise.resolve();257 },258 };259}260261/**262 * Resolves the default journal storage for one app id: an OPFS file when the263 * browser provides it, `localStorage` otherwise, and memory as a last resort,264 * so the journal is always readable at boot before sync connects.265 */266export function createDefaultJournalStorage(appId: string): JournalStorage {267 return opfsStorage(`lofi-write-journal-${appId}.json`) ??268 localStorageStorage(`lofi:write-journal:${appId}`) ??269 createMemoryJournalStorage();270}271272/**273 * The journal's single reader and writer: an in-memory document with274 * serialized, coalesced persistence. Mutations apply synchronously in memory;275 * `flush()` awaits the trailing persisted state.276 */277export class WriteJournal {278 readonly #storage: JournalStorage;279 #document: JournalDocument = emptyJournal();280 #chain: Promise<void> = Promise.resolve();281 #lastAttempt: Promise<void> = Promise.resolve();282 #loaded = false;283284 /** Creates a journal over one storage location. */285 constructor(storage: JournalStorage) {286 this.#storage = storage;287 }288289 /** Loads the persisted document once; later calls return the memory state. */290 async load(): Promise<JournalDocument> {291 if (!this.#loaded) {292 this.#document = parseJournal(await this.#storage.load());293 this.#loaded = true;294 }295 return this.#document;296 }297298 /** The current in-memory document. */299 get document(): JournalDocument {300 return this.#document;301 }302303 /** Applies one mutation to the document and schedules persistence. */304 update(mutate: (document: JournalDocument) => void): void {305 mutate(this.#document);306 const snapshot = JSON.stringify(this.#document);307 const attempt = this.#chain.then(() => this.#storage.save(snapshot));308 this.#lastAttempt = attempt;309 this.#chain = attempt.catch(() => undefined);310 }311312 /** Resolves once every scheduled persistence has settled. */313 flush(): Promise<void> {314 return this.#lastAttempt;315 }316}