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-handle.ts
write-handle.ts 11.2K
1/**2 * Package-owned per-write handle.3 *4 * One write, observed as a monotonic stage machine. The handle is thenable —5 * `await write` resolves at `saved`, the local-durability contract every6 * existing mutation keeps — and exposes the later sync fate as level-triggered7 * state: stage and reason are properties, `saved` and `synced` are promises,8 * and subscribers always receive the current truth immediately, so no9 * consumer can miss a transition by attaching late.10 *11 * @module12 */1314/**15 * The closed, framework-owned write lifecycle. Stages are monotonic:16 * `saving → saved → syncing → synced | rejected`. `syncing` is reserved for a17 * runtime that can observe the transport; the current storage engine exposes18 * no such signal, so today writes move from `saved` directly to `synced` or19 * `rejected` and no handle ever reports `syncing`. Branch on `saved` vs20 * settled, not on seeing `syncing`.21 */22export type WriteStage = "saving" | "saved" | "syncing" | "synced" | "rejected";2324/** Why a write settled as `rejected`: the structured cause, code, and reason. */25export type WriteRejection = {26 /**27 * The structured cause: `denied` for a store verdict, `expired` for an28 * intent retired past its lifespan. The current runtime never retires a29 * locally accepted write, so every rejection today is `denied`; an overdue30 * intent surfaces as `PendingWriteSummary.expired` instead. `expired`31 * exists so handlers written now stay correct if retirement ships.32 */33 cause: "denied" | "expired";34 /** The sync node's rejection code, or `null` when none was carried. */35 code: string | null;36 /** Human-readable reason text from the adjudicating node. */37 reason: string;38};3940/**41 * Raised through {@link WriteHandle.synced} when a write settles as42 * `rejected`: the store adjudicated the write and denied it permanently.43 */44export class WriteRejectedError extends Error {45 /** Stable error class name for diagnostics and error boundaries. */46 override readonly name = "WriteRejectedError";47 /**48 * The structured cause: `denied` or `expired`. The current runtime never49 * retires a locally accepted write, so every rejection today is `denied`;50 * an overdue intent surfaces as `PendingWriteSummary.expired` instead.51 * `expired` exists so handlers written now stay correct if retirement52 * ships.53 */54 readonly rejectionCause: "denied" | "expired";55 /** The adjudicated rejection code, or `null` when none was carried. */56 readonly code: string | null;57 /** The journal id of the rejected write. */58 readonly writeId: string;5960 /** Creates the settled rejection carried by a write's `synced` promise. */61 constructor(writeId: string, rejection: WriteRejection) {62 super(rejection.reason);63 this.writeId = writeId;64 this.code = rejection.code;65 this.rejectionCause = rejection.cause;66 }67}6869/** True when an error is a write the store adjudicated and denied permanently. */70export function isWriteRejectedError(error: unknown): error is WriteRejectedError {71 return error instanceof WriteRejectedError;72}7374const stageOrder: Record<WriteStage, number> = {75 saving: 0,76 saved: 1,77 syncing: 2,78 synced: 3,79 rejected: 3,80};8182type Resolver<T> = { resolve: (value: T) => void; reject: (error: unknown) => void };8384function exposedPromise<T>(): { promise: Promise<T>; resolver: Resolver<T> } {85 let resolver: Resolver<T>;86 const promise = new Promise<T>((resolve, reject) => {87 resolver = { resolve, reject };88 });89 // An author is free to never read a stage promise; the handle itself keeps90 // each one observed so an unconsumed rejection cannot escape as a global91 // unhandled-rejection event.92 promise.catch(() => undefined);93 return { promise, resolver: resolver! };94}9596/**97 * The runtime-only mutator half of a write handle. Only the write ledger98 * holds a handle's controller — the handle itself exposes no way to advance99 * or settle, so application code can observe a write's fate but never forge100 * it.101 */102export type WriteHandleController<T> = {103 /** Records the vendor batch id once the write is accepted. */104 setBatchId(batchId: string | null): void;105 /**106 * Advances the monotonic stage machine; later stages never regress. The107 * value supplied at (or before) `saved` is the one both stage promises108 * resolve with.109 */110 advance(stage: Exclude<WriteStage, "rejected">, value?: T): void;111 /** Settles the write as `rejected` with the adjudicated reason. */112 reject(rejection: WriteRejection): void;113 /** Fails every stage promise without entering the `rejected` stage. */114 fail(error: unknown): void;115};116117// Controllers are handed out exactly once, at construction, through118// createWriteHandle. The map is module-private, so no code outside this119// module can reach a handle's mutators through its public surface.120const controllers = new WeakMap<WriteHandle<unknown>, WriteHandleController<unknown>>();121122/**123 * Creates a handle in `saving` together with its controller — the only path124 * to a handle's lifecycle mutators. The ledger keeps the controller and hands125 * the observe-only handle to application code.126 */127export function createWriteHandle<T>(128 writeId: string,129): { handle: WriteHandle<T>; controller: WriteHandleController<T> } {130 const handle = new WriteHandle<T>(writeId);131 return {132 handle,133 controller: controllers.get(handle as WriteHandle<unknown>)! as WriteHandleController<T>,134 };135}136137/**138 * A single write observed through the author-facing lifecycle.139 *140 * `await write` (the thenable) resolves at `saved` with the write's value —141 * for inserts, the created row. `write.synced` resolves when the store142 * confirms the write and rejects with {@link WriteRejectedError} when the143 * store denies it. `stage` and `reason` are current-state properties;144 * `subscribe` notifies immediately and on every later change.145 *146 * On a device without managed sync there is no store to confirm anything:147 * local durability is settlement, and the handle reaches `synced` as soon as148 * it is `saved`. In Preact components, render a handle with `useWrite` and149 * the app-wide pending set with `usePendingWrites`.150 *151 * Handles are issued by the runtime and are observe-only: the lifecycle152 * mutators live on a controller the ledger keeps at construction, so no153 * consumer of a handle can advance or settle it.154 *155 * @example156 * ```ts157 * const write = placeOrder({ sku, quantity }); // a verb returns a WriteHandle158 * const order = await write; // resolves at saved — safe to navigate159 * write.synced.catch((error) => {160 * if (error instanceof WriteRejectedError) showDenied(error.message);161 * });162 * ```163 */164export class WriteHandle<T> implements PromiseLike<T> {165 readonly #writeId: string;166 readonly #saved: Promise<T>;167 readonly #synced: Promise<T>;168 readonly #savedResolver: Resolver<T>;169 readonly #syncedResolver: Resolver<T>;170 readonly #listeners = new Set<() => void>();171 #stage: WriteStage = "saving";172 #reason: WriteRejection | null = null;173 #value: T | undefined;174 #valueSet = false;175 #batchId: string | null = null;176177 /**178 * Creates a handle in `saving`. The lifecycle mutators are handed to the179 * module-internal factory at construction; a directly constructed handle180 * has no reachable controller and therefore never advances.181 */182 constructor(writeId: string) {183 this.#writeId = writeId;184 const saved = exposedPromise<T>();185 const synced = exposedPromise<T>();186 this.#saved = saved.promise;187 this.#savedResolver = saved.resolver;188 this.#synced = synced.promise;189 this.#syncedResolver = synced.resolver;190 controllers.set(this as WriteHandle<unknown>, {191 setBatchId: (batchId) => {192 this.#batchId = batchId;193 },194 advance: (stage, value) => this.#advance(stage, value as T | undefined),195 reject: (rejection) => this.#reject(rejection),196 fail: (error) => this.#fail(error),197 });198 }199200 /** The stable journal id of this write. */201 get writeId(): string {202 return this.#writeId;203 }204205 /** The vendor batch id attributing adjudicated verdicts, once known. */206 get batchId(): string | null {207 return this.#batchId;208 }209210 /** The current lifecycle stage. Monotonic; never moves backwards. */211 get stage(): WriteStage {212 return this.#stage;213 }214215 /** The rejection carried by a `rejected` write; `null` in every other stage. */216 get reason(): WriteRejection | null {217 return this.#reason;218 }219220 /** Resolves with the write's value once the write is durable on this device. */221 get saved(): Promise<T> {222 return this.#saved;223 }224225 /**226 * Resolves with the write's value once the store confirms the write, and227 * rejects with {@link WriteRejectedError} when the store denies it.228 */229 get synced(): Promise<T> {230 return this.#synced;231 }232233 /**234 * Awaiting the handle resolves at `saved` — the same local-durability235 * contract as every other mutation promise in the package.236 */237 then<Fulfilled = T, Rejected = never>(238 onfulfilled?: ((value: T) => Fulfilled | PromiseLike<Fulfilled>) | null,239 onrejected?: ((reason: unknown) => Rejected | PromiseLike<Rejected>) | null,240 ): Promise<Fulfilled | Rejected> {241 return this.#saved.then(onfulfilled, onrejected);242 }243244 /**245 * Observes stage changes. The listener runs immediately with the current246 * state and again after every stage advance, so late subscribers see the247 * same truth as early ones. Returns an idempotent unsubscribe function.248 */249 subscribe(listener: () => void): () => void {250 this.#listeners.add(listener);251 listener();252 let active = true;253 return () => {254 if (!active) return;255 active = false;256 this.#listeners.delete(listener);257 };258 }259260 // Advances the monotonic stage machine; later stages never regress. The261 // value supplied at (or before) `saved` is the one both stage promises262 // resolve with. Reachable only through the construction-time controller.263 #advance(stage: Exclude<WriteStage, "rejected">, value?: T): void {264 if (this.#stage === "synced" || this.#stage === "rejected") return;265 if (!this.#valueSet && value !== undefined) {266 this.#value = value;267 this.#valueSet = true;268 }269 if (stageOrder[stage] <= stageOrder[this.#stage]) return;270 this.#stage = stage;271 if (stageOrder[stage] >= stageOrder.saved) this.#savedResolver.resolve(this.#value as T);272 if (stage === "synced") this.#syncedResolver.resolve(this.#value as T);273 this.#emit();274 }275276 // Settles the write as `rejected` with the adjudicated reason.277 #reject(rejection: WriteRejection): void {278 if (this.#stage === "synced" || this.#stage === "rejected") return;279 this.#stage = "rejected";280 this.#reason = rejection;281 // A verdict arrives after local acceptance in the normal flow, but a282 // handle refused before `saved` must still settle both promises.283 const error = new WriteRejectedError(this.#writeId, rejection);284 this.#savedResolver.reject(error);285 this.#syncedResolver.reject(error);286 this.#emit();287 }288289 // Fails every stage promise without entering the `rejected` stage.290 #fail(error: unknown): void {291 if (this.#stage === "synced" || this.#stage === "rejected") return;292 this.#savedResolver.reject(error);293 this.#syncedResolver.reject(error);294 this.#emit();295 }296297 #emit(): void {298 for (const listener of this.#listeners) listener();299 }300}