clone

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

feat/prf-provision-custody
lofi / package / runtime / resource-lifecycle.ts
resource-lifecycle.ts 4.6K
1export type SerializedResourceState<T> = {2 // Package-owned resource lifecycle.3 value: T | null;4 promise: Promise<T> | null;5 queue: Promise<void>;6 recreation: Promise<T> | null;7};89export function createSerializedResourceState<T>(): SerializedResourceState<T> {10 return { value: null, promise: null, queue: Promise.resolve(), recreation: null };11}1213function enqueue<T, R>(14 state: SerializedResourceState<T>,15 operation: () => Promise<R>,16): Promise<R> {17 const result = state.queue.then(operation, operation);18 state.queue = result.then(() => undefined, () => undefined);19 return result;20}2122export function getResource<T>(23 state: SerializedResourceState<T>,24 create: () => Promise<T>,25): Promise<T> {26 if (state.promise) return state.promise;27 const pending = enqueue(state, async () => {28 if (state.value) return state.value;29 const value = await create();30 state.value = value;31 return value;32 });33 const tracked = pending.catch((error) => {34 if (state.promise === tracked) state.promise = null;35 throw error;36 });37 state.promise = tracked;38 return tracked;39}4041function swapResource<T>(42 state: SerializedResourceState<T>,43 prepare: (() => Promise<void>) | null,44 create: () => Promise<T>,45 destroy: (value: T) => Promise<void>,46): Promise<T> {47 const recreation = enqueue(state, async () => {48 if (prepare) await prepare();49 const previous = state.value;50 state.value = null;51 state.promise = null;52 if (previous) await destroy(previous);53 // Browser SharedWorker port shutdown completes on a later task even after54 // the Db shutdown promise resolves. Yield once before opening the replacement55 // so the new persistent client does not attach to the retiring broker.56 if (previous) await new Promise((resolve) => setTimeout(resolve, 50));57 const value = await create();58 state.value = value;59 state.promise = Promise.resolve(value);60 return value;61 });62 const tracked = recreation.finally(() => {63 if (state.recreation === tracked) state.recreation = null;64 });65 state.recreation = tracked;66 return tracked;67}6869export function recreateResource<T>(70 state: SerializedResourceState<T>,71 create: () => Promise<T>,72 destroy: (value: T) => Promise<void>,73): Promise<T> {74 if (state.recreation) return state.recreation;75 return swapResource(state, null, create, destroy);76}7778/**79 * Replaces a serialized resource after an async preparation step. Unlike80 * {@link recreateResource}, replacement carries a payload (the preparation),81 * so it never aliases to an in-flight recreation: it queues behind it and82 * always runs its own preparation before installing the replacement.83 */84export function replaceResource<T>(85 state: SerializedResourceState<T>,86 prepare: () => Promise<void>,87 create: () => Promise<T>,88 destroy: (value: T) => Promise<void>,89): Promise<T> {90 return swapResource(state, prepare, create, destroy);91}9293export function shutdownResource<T>(94 state: SerializedResourceState<T>,95 destroy: (value: T) => Promise<void>,96): Promise<void> {97 return enqueue(state, async () => {98 const value = state.value;99 state.value = null;100 state.promise = null;101 if (value) await destroy(value);102 });103}104105export class AdapterDisposedError extends Error {106 override name = "AdapterDisposedError";107}108109export class AdapterLifecycle<Dependency, Instance> {110 #disposed = false;111 #generation = 0;112 #instance: Instance | null = null;113 #promise: Promise<Instance> | null = null;114115 get(116 acquire: () => Promise<Dependency>,117 attach: (dependency: Dependency) => Instance,118 ): Promise<Instance> {119 if (this.#disposed) return Promise.reject(new AdapterDisposedError("adapter module disposed"));120 if (this.#promise) return this.#promise;121 const generation = this.#generation;122 const pending = acquire().then((dependency) => {123 if (this.#disposed || generation !== this.#generation) {124 throw new AdapterDisposedError("adapter lifecycle changed before attach");125 }126 const instance = attach(dependency);127 this.#instance = instance;128 return instance;129 });130 const tracked = pending.catch((error) => {131 if (this.#promise === tracked) this.#promise = null;132 throw error;133 });134 this.#promise = tracked;135 return tracked;136 }137138 async release(close: (instance: Instance) => Promise<void>): Promise<void> {139 this.#generation += 1;140 const instance = this.#instance;141 this.#instance = null;142 this.#promise = null;143 if (instance) await close(instance);144 }145146 dispose(close: (instance: Instance) => Promise<void>): void {147 this.#disposed = true;148 void this.release(close);149 }150}