From f80d627d3faa81e0c51f4dfacd61b20f62b9bcd5 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sat, 11 Apr 2026 12:15:09 +0200 Subject: [PATCH 1/2] feat(async): stabilize Lazy --- async/deno.json | 2 +- async/mod.ts | 1 + async/unstable_lazy.ts | 242 -------------------------------- async/unstable_lazy_test.ts | 270 ------------------------------------ 4 files changed, 2 insertions(+), 513 deletions(-) delete mode 100644 async/unstable_lazy.ts delete mode 100644 async/unstable_lazy_test.ts diff --git a/async/deno.json b/async/deno.json index dc84df585695..19a68fe56222 100644 --- a/async/deno.json +++ b/async/deno.json @@ -20,7 +20,7 @@ "./unstable-wait-for": "./unstable_wait_for.ts", "./unstable-semaphore": "./unstable_semaphore.ts", "./unstable-circuit-breaker": "./unstable_circuit_breaker.ts", - "./unstable-lazy": "./unstable_lazy.ts", + "./lazy": "./lazy.ts", "./unstable-pool-settled": "./unstable_pool_settled.ts", "./unstable-channel": "./unstable_channel.ts" } diff --git a/async/mod.ts b/async/mod.ts index 4cab1dc2b491..de31ea55f712 100644 --- a/async/mod.ts +++ b/async/mod.ts @@ -25,3 +25,4 @@ export * from "./pool.ts"; export * from "./tee.ts"; export * from "./retry.ts"; export * from "./all_keyed.ts"; +export * from "./lazy.ts"; diff --git a/async/unstable_lazy.ts b/async/unstable_lazy.ts deleted file mode 100644 index f9852b60b6b6..000000000000 --- a/async/unstable_lazy.ts +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright 2018-2026 the Deno authors. MIT license. -// This module is browser compatible. - -/** - * Options for {@linkcode Lazy.prototype.get}. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - */ -export interface LazyGetOptions { - /** - * Signal used to abort the wait for initialization. - * - * Aborting does not cancel the underlying initializer — it only rejects the - * caller's promise. Other callers and any in-flight initialization are - * unaffected. - */ - signal?: AbortSignal; -} - -/** - * A lazy value that is initialized at most once, with built-in deduplication of - * concurrent callers. Prevents the common race where two concurrent `get()` calls - * both trigger the initializer; only one initialization runs and all callers share - * the same promise. - * - * If the initializer rejects, the error is propagated to all concurrent callers - * and the internal state is cleared — the next {@linkcode Lazy.prototype.get} - * call will re-run the initializer. Compose with {@linkcode retry} for - * automatic back-off on transient failures. - * - * @example Concurrent deduplication - * - * ```ts - * import { Lazy } from "@std/async/unstable-lazy"; - * import { assertEquals } from "@std/assert"; - * - * let initCount = 0; - * const config = new Lazy(async () => { - * initCount++; - * return 42; - * }); - * - * const [a, b] = await Promise.all([config.get(), config.get()]); - * assertEquals(a, 42); - * assertEquals(b, 42); - * assertEquals(initCount, 1); - * ``` - * - * @example Composing with retry - * - * ```ts ignore - * import { Lazy } from "@std/async/unstable-lazy"; - * import { retry } from "@std/async/retry"; - * - * const db = new Lazy(() => - * retry(() => connectDb(), { minTimeout: 100, maxAttempts: 3 }) - * ); - * await db.get(); - * ``` - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @typeParam T The type of the lazily initialized value. - */ -export class Lazy { - #init: () => T | Promise; - #promise: Promise | undefined = undefined; - #value: T | undefined = undefined; - #settled = false; - - /** - * Creates a new lazy value. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @param init Initializer function, called at most once (until {@linkcode reset}). - */ - constructor(init: () => T | Promise) { - this.#init = init; - } - - /** - * Returns the cached value, initializing it on first call. Concurrent callers - * share the same in-flight promise — the initializer is never invoked more - * than once at a time. - * - * Always returns a promise, even when the initializer is synchronous. - * - * @example Usage - * ```ts no-assert - * import { Lazy } from "@std/async/unstable-lazy"; - * - * const config = new Lazy(async () => ({ loaded: true })); - * const value = await config.get(); - * ``` - * - * @example Abort a slow initialization - * ```ts - * import { Lazy } from "@std/async/unstable-lazy"; - * import { assertRejects } from "@std/assert"; - * - * const slow = new Lazy(() => new Promise(() => {})); - * const controller = new AbortController(); - * controller.abort(new Error("timed out")); - * await assertRejects( - * () => slow.get({ signal: controller.signal }), - * Error, - * "timed out", - * ); - * ``` - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @param options Optional settings for this call. - * @returns The cached or newly initialized value. - */ - get(options?: LazyGetOptions): Promise { - if (this.#settled) return Promise.resolve(this.#value as T); - const signal = options?.signal; - if (signal?.aborted) return Promise.reject(signal.reason); - - if (this.#promise === undefined) { - const p = new Promise((resolve, reject) => { - Promise.resolve().then(() => this.#init()).then( - (value) => { - if (this.#promise === p) { - this.#value = value; - this.#settled = true; - } - resolve(value); - }, - (err) => { - if (this.#promise === p) { - this.#promise = undefined; - } - reject(err); - }, - ); - }); - this.#promise = p; - } - - if (!signal) return this.#promise; - - return new Promise((resolve, reject) => { - const abort = () => reject(signal.reason); - signal.addEventListener("abort", abort, { once: true }); - this.#promise!.then( - (value) => { - signal.removeEventListener("abort", abort); - resolve(value); - }, - (err) => { - signal.removeEventListener("abort", abort); - reject(err); - }, - ); - }); - } - - /** - * Whether the value has been successfully initialized. - * - * @example Check initialization state - * ```ts - * import { Lazy } from "@std/async/unstable-lazy"; - * import { assertEquals } from "@std/assert"; - * - * const lazy = new Lazy(() => 42); - * assertEquals(lazy.initialized, false); - * await lazy.get(); - * assertEquals(lazy.initialized, true); - * ``` - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @returns `true` if the value has been initialized, `false` otherwise. - */ - get initialized(): boolean { - return this.#settled; - } - - /** - * Returns the value if already resolved, or indicates that it is not yet - * available. The discriminated union avoids ambiguity when `T` itself can - * be `undefined`. - * - * @example Fast-path when already initialized - * ```ts - * import { Lazy } from "@std/async/unstable-lazy"; - * import { assertEquals } from "@std/assert"; - * - * const config = new Lazy(async () => ({ port: 8080 })); - * await config.get(); - * - * const result = config.peek(); - * assertEquals(result, { ok: true, value: { port: 8080 } }); - * ``` - * - * @example Not yet initialized - * ```ts - * import { Lazy } from "@std/async/unstable-lazy"; - * import { assertEquals } from "@std/assert"; - * - * const lazy = new Lazy(() => 42); - * assertEquals(lazy.peek(), { ok: false }); - * ``` - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @returns `{ ok: true, value }` if the value has been initialized, or - * `{ ok: false }` if not yet initialized or still in-flight. - */ - peek(): { ok: true; value: T } | { ok: false } { - return this.#settled - ? { ok: true, value: this.#value as T } - : { ok: false }; - } - - /** - * Resets the lazy so the next {@linkcode get} re-runs the initializer. Does - * not cancel an in-flight initialization; callers that already have the - * promise will still receive its result. - * - * @example Force reload - * ```ts ignore - * import { Lazy } from "@std/async/unstable-lazy"; - * - * const config = new Lazy(async () => loadConfig()); - * await config.get(); - * config.reset(); - * const fresh = await config.get(); - * ``` - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - */ - reset(): void { - this.#promise = undefined; - this.#value = undefined; - this.#settled = false; - } -} diff --git a/async/unstable_lazy_test.ts b/async/unstable_lazy_test.ts deleted file mode 100644 index 5db6fb7de2c9..000000000000 --- a/async/unstable_lazy_test.ts +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright 2018-2026 the Deno authors. MIT license. - -import { assertEquals, assertRejects, assertStrictEquals } from "@std/assert"; -import { Lazy } from "./unstable_lazy.ts"; - -Deno.test("Lazy.get() initializes and returns sync value", async () => { - const lazy = new Lazy(() => 42); - const value = await lazy.get(); - assertEquals(value, 42); -}); - -Deno.test("Lazy.get() deduplicates concurrent callers", async () => { - let initCount = 0; - const lazy = new Lazy(async () => { - initCount++; - await Promise.resolve(); - return 100; - }); - const [a, b] = await Promise.all([lazy.get(), lazy.get()]); - assertStrictEquals(a, 100); - assertStrictEquals(b, 100); - assertEquals(initCount, 1); -}); - -Deno.test("Lazy.get() retries after rejection", async () => { - let attempts = 0; - const lazy = new Lazy(() => { - attempts++; - if (attempts < 2) { - return Promise.reject(new Error("fail")); - } - return Promise.resolve(1); - }); - try { - await lazy.get(); - } catch { - // Expected: first attempt rejects - } - const value = await lazy.get(); - assertEquals(value, 1); - assertEquals(attempts, 2); -}); - -Deno.test("Lazy.get() propagates rejection to all concurrent callers", async () => { - const lazy = new Lazy(() => Promise.reject(new Error("init failed"))); - const [resultA, resultB] = await Promise.allSettled([lazy.get(), lazy.get()]); - assertEquals(resultA.status, "rejected"); - assertEquals(resultB.status, "rejected"); - assertEquals( - (resultA as PromiseRejectedResult).reason?.message, - "init failed", - ); - assertEquals( - (resultB as PromiseRejectedResult).reason?.message, - "init failed", - ); -}); - -Deno.test("Lazy.initialized reflects lifecycle", async () => { - const holder: { resolve: (v: number) => void } = { resolve: () => {} }; - const lazy = new Lazy( - () => - new Promise((res) => { - holder.resolve = res; - }), - ); - - assertEquals(lazy.initialized, false); - - const getPromise = lazy.get(); - await Promise.resolve(); - assertEquals(lazy.initialized, false); - - holder.resolve(1); - await getPromise; - assertEquals(lazy.initialized, true); - - lazy.reset(); - assertEquals(lazy.initialized, false); -}); - -Deno.test("Lazy.initialized is false after rejection", async () => { - const lazy = new Lazy(() => Promise.reject(new Error("fail"))); - try { - await lazy.get(); - } catch { - // expected - } - assertEquals(lazy.initialized, false); -}); - -Deno.test("Lazy.peek() reflects lifecycle", async () => { - const holder: { resolve: (v: number) => void } = { resolve: () => {} }; - const lazy = new Lazy( - () => - new Promise((res) => { - holder.resolve = res; - }), - ); - - // Before init - assertEquals(lazy.peek(), { ok: false }); - - // In-flight - const getPromise = lazy.get(); - await Promise.resolve(); - assertEquals(lazy.peek(), { ok: false }); - - // After init - holder.resolve(1); - await getPromise; - assertEquals(lazy.peek(), { ok: true, value: 1 }); - - // After reset - lazy.reset(); - assertEquals(lazy.peek(), { ok: false }); - - // After rejected init - const failing = new Lazy(() => Promise.reject(new Error("fail"))); - try { - await failing.get(); - } catch { - // expected - } - assertEquals(failing.peek(), { ok: false }); -}); - -Deno.test("Lazy.peek() disambiguates T = undefined", async () => { - const lazy = new Lazy(() => undefined); - assertEquals(lazy.peek(), { ok: false }); - await lazy.get(); - assertEquals(lazy.peek(), { ok: true, value: undefined }); -}); - -Deno.test("Lazy.peek() returns { ok: false } while in-flight", async () => { - const holder: { resolve: (v: number) => void } = { resolve: () => {} }; - const lazy = new Lazy( - () => - new Promise((res) => { - holder.resolve = res; - }), - ); - const getPromise = lazy.get(); - await Promise.resolve(); - assertEquals(lazy.peek(), { ok: false }); - holder.resolve(99); - assertEquals(await getPromise, 99); -}); - -Deno.test("Lazy.peek() returns { ok: true, value } after initialization", async () => { - const lazy = new Lazy(() => 42); - await lazy.get(); - assertEquals(lazy.peek(), { ok: true, value: 42 }); -}); - -Deno.test("Lazy.reset() causes re-initialization", async () => { - let initCount = 0; - const lazy = new Lazy(() => { - initCount++; - return initCount; - }); - assertEquals(await lazy.get(), 1); - lazy.reset(); - assertEquals(await lazy.get(), 2); - assertEquals(initCount, 2); -}); - -Deno.test("Lazy.reset() does not affect in-flight initialization", async () => { - const holder: { resolve: (v: string) => void } = { resolve: () => {} }; - const lazy = new Lazy( - () => - new Promise((res) => { - holder.resolve = res; - }), - ); - const getPromise = lazy.get(); - await Promise.resolve(); - lazy.reset(); - holder.resolve("ok"); - const value = await getPromise; - assertEquals(value, "ok"); -}); - -Deno.test("Lazy.get() resolves falsy values correctly", async (t) => { - await t.step("0", async () => { - const lazy = new Lazy(() => 0); - assertEquals(await lazy.get(), 0); - assertEquals(lazy.peek(), { ok: true, value: 0 }); - }); - - await t.step("false", async () => { - const lazy = new Lazy(() => false); - assertEquals(await lazy.get(), false); - assertEquals(lazy.peek(), { ok: true, value: false }); - }); - - await t.step("empty string", async () => { - const lazy = new Lazy(() => ""); - assertEquals(await lazy.get(), ""); - assertEquals(lazy.peek(), { ok: true, value: "" }); - }); - - await t.step("null", async () => { - const lazy = new Lazy(() => null); - assertEquals(await lazy.get(), null); - assertEquals(lazy.peek(), { ok: true, value: null }); - }); -}); - -Deno.test("Lazy.get() rejects immediately with already-aborted signal", async () => { - const lazy = new Lazy(() => 42); - const reason = new Error("aborted"); - await assertRejects( - () => lazy.get({ signal: AbortSignal.abort(reason) }), - Error, - "aborted", - ); - assertEquals(lazy.peek(), { ok: false }); -}); - -Deno.test("Lazy.get() rejects when signal is aborted during initialization", async () => { - const lazy = new Lazy( - () => new Promise(() => {}), - ); - const controller = new AbortController(); - const getPromise = lazy.get({ signal: controller.signal }); - controller.abort(new Error("cancelled")); - await assertRejects( - () => getPromise, - Error, - "cancelled", - ); -}); - -Deno.test("Lazy.get() signal does not affect other callers", async () => { - const holder: { resolve: (v: number) => void } = { resolve: () => {} }; - const lazy = new Lazy( - () => - new Promise((res) => { - holder.resolve = res; - }), - ); - const controller = new AbortController(); - const abortable = lazy.get({ signal: controller.signal }); - const normal = lazy.get(); - controller.abort(new Error("cancelled")); - - await assertRejects(() => abortable, Error, "cancelled"); - - holder.resolve(42); - assertEquals(await normal, 42); - assertEquals(lazy.peek(), { ok: true, value: 42 }); -}); - -Deno.test("Lazy.get() with signal rejects when initializer fails", async () => { - const lazy = new Lazy(() => Promise.reject(new Error("boom"))); - const controller = new AbortController(); - await assertRejects( - () => lazy.get({ signal: controller.signal }), - Error, - "boom", - ); -}); - -Deno.test("Lazy.get() signal is ignored after successful initialization", async () => { - const lazy = new Lazy(() => 42); - await lazy.get(); - const value = await lazy.get({ signal: AbortSignal.abort() }); - assertEquals(value, 42); -}); From 1456c04ca8b250485c57b66666bdba27f4f4cb73 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sat, 11 Apr 2026 12:15:22 +0200 Subject: [PATCH 2/2] feat(async): stabilize Lazy --- async/lazy.ts | 228 ++++++++++++++++++++++++++++++++++ async/lazy_test.ts | 299 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 async/lazy.ts create mode 100644 async/lazy_test.ts diff --git a/async/lazy.ts b/async/lazy.ts new file mode 100644 index 000000000000..fb63a5f5c992 --- /dev/null +++ b/async/lazy.ts @@ -0,0 +1,228 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * Options for {@linkcode Lazy.prototype.get}. + */ +export interface LazyGetOptions { + /** + * Signal used to abort the wait for initialization. + * + * Aborting does not cancel the underlying initializer — it only rejects the + * caller's promise. Other callers and any in-flight initialization are + * unaffected. + */ + signal?: AbortSignal; +} + +/** + * A lazy value that is initialized at most once, with built-in deduplication of + * concurrent callers. Prevents the common race where two concurrent `get()` calls + * both trigger the initializer; only one initialization runs and all callers share + * the same promise. + * + * If the initializer rejects, the error is propagated to all concurrent callers + * and the internal state is cleared — the next {@linkcode Lazy.prototype.get} + * call will re-run the initializer. Compose with {@linkcode retry} for + * automatic back-off on transient failures. + * + * @example Concurrent deduplication + * + * ```ts + * import { Lazy } from "@std/async/lazy"; + * import { assertEquals } from "@std/assert"; + * + * let initCount = 0; + * const config = new Lazy(async () => { + * initCount++; + * return 42; + * }); + * + * const [a, b] = await Promise.all([config.get(), config.get()]); + * assertEquals(a, 42); + * assertEquals(b, 42); + * assertEquals(initCount, 1); + * ``` + * + * @example Composing with retry + * + * ```ts ignore + * import { Lazy } from "@std/async/lazy"; + * import { retry } from "@std/async/retry"; + * + * const db = new Lazy(() => + * retry(() => connectDb(), { minTimeout: 100, maxAttempts: 3 }) + * ); + * await db.get(); + * ``` + * + * @typeParam T The type of the lazily initialized value. + */ +export class Lazy { + #init: () => T | Promise; + #promise: Promise | undefined = undefined; + #value: T | undefined = undefined; + #settled = false; + + /** + * Creates a new lazy value. + * + * @param init Initializer function, called at most once (until {@linkcode reset}). + */ + constructor(init: () => T | Promise) { + this.#init = init; + } + + /** + * Returns the cached value, initializing it on first call. Concurrent callers + * share the same in-flight promise — the initializer is never invoked more + * than once at a time. + * + * Always returns a promise, even when the initializer is synchronous. + * + * @example Usage + * ```ts no-assert + * import { Lazy } from "@std/async/lazy"; + * + * const config = new Lazy(async () => ({ loaded: true })); + * const value = await config.get(); + * ``` + * + * @example Abort a slow initialization + * ```ts + * import { Lazy } from "@std/async/lazy"; + * import { assertRejects } from "@std/assert"; + * + * const slow = new Lazy(() => new Promise(() => {})); + * const controller = new AbortController(); + * controller.abort(new Error("timed out")); + * await assertRejects( + * () => slow.get({ signal: controller.signal }), + * Error, + * "timed out", + * ); + * ``` + * + * @param options Optional settings for this call. + * @returns The cached or newly initialized value. + */ + get(options?: LazyGetOptions): Promise { + if (this.#settled) return Promise.resolve(this.#value as T); + const signal = options?.signal; + if (signal?.aborted) return Promise.reject(signal.reason); + + if (this.#promise === undefined) { + const p = new Promise((resolve, reject) => { + Promise.resolve().then(() => this.#init()).then( + (value) => { + if (this.#promise === p) { + this.#value = value; + this.#settled = true; + } + resolve(value); + }, + (err) => { + if (this.#promise === p) { + this.#promise = undefined; + } + reject(err); + }, + ); + }); + this.#promise = p; + } + + if (!signal) return this.#promise; + + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + this.#promise!.then( + (value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }, + (err) => { + signal.removeEventListener("abort", abort); + reject(err); + }, + ); + }); + } + + /** + * Whether the value has been successfully initialized. + * + * @example Check initialization state + * ```ts + * import { Lazy } from "@std/async/lazy"; + * import { assertEquals } from "@std/assert"; + * + * const lazy = new Lazy(() => 42); + * assertEquals(lazy.initialized, false); + * await lazy.get(); + * assertEquals(lazy.initialized, true); + * ``` + * + * @returns `true` if the value has been initialized, `false` otherwise. + */ + get initialized(): boolean { + return this.#settled; + } + + /** + * Returns the value if already resolved, or indicates that it is not yet + * available. The discriminated union avoids ambiguity when `T` itself can + * be `undefined`. + * + * @example Fast-path when already initialized + * ```ts + * import { Lazy } from "@std/async/lazy"; + * import { assertEquals } from "@std/assert"; + * + * const config = new Lazy(async () => ({ port: 8080 })); + * await config.get(); + * + * const result = config.peek(); + * assertEquals(result, { ok: true, value: { port: 8080 } }); + * ``` + * + * @example Not yet initialized + * ```ts + * import { Lazy } from "@std/async/lazy"; + * import { assertEquals } from "@std/assert"; + * + * const lazy = new Lazy(() => 42); + * assertEquals(lazy.peek(), { ok: false }); + * ``` + * + * @returns `{ ok: true, value }` if the value has been initialized, or + * `{ ok: false }` if not yet initialized or still in-flight. + */ + peek(): { ok: true; value: T } | { ok: false } { + return this.#settled + ? { ok: true, value: this.#value as T } + : { ok: false }; + } + + /** + * Resets the lazy so the next {@linkcode get} re-runs the initializer. Does + * not cancel an in-flight initialization; callers that already have the + * promise will still receive its result. + * + * @example Force reload + * ```ts ignore + * import { Lazy } from "@std/async/lazy"; + * + * const config = new Lazy(async () => loadConfig()); + * await config.get(); + * config.reset(); + * const fresh = await config.get(); + * ``` + */ + reset(): void { + this.#promise = undefined; + this.#value = undefined; + this.#settled = false; + } +} diff --git a/async/lazy_test.ts b/async/lazy_test.ts new file mode 100644 index 000000000000..33e60d713445 --- /dev/null +++ b/async/lazy_test.ts @@ -0,0 +1,299 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { assertEquals, assertRejects, assertStrictEquals } from "@std/assert"; +import { Lazy } from "./lazy.ts"; + +Deno.test("Lazy.get() initializes and returns sync value", async () => { + const lazy = new Lazy(() => 42); + const value = await lazy.get(); + assertEquals(value, 42); +}); + +Deno.test("Lazy.get() deduplicates concurrent callers", async () => { + let initCount = 0; + const lazy = new Lazy(async () => { + initCount++; + await Promise.resolve(); + return 100; + }); + const [a, b] = await Promise.all([lazy.get(), lazy.get()]); + assertStrictEquals(a, 100); + assertStrictEquals(b, 100); + assertEquals(initCount, 1); +}); + +Deno.test("Lazy.get() retries after rejection", async () => { + let attempts = 0; + const lazy = new Lazy(() => { + attempts++; + if (attempts < 2) { + return Promise.reject(new Error("fail")); + } + return Promise.resolve(1); + }); + try { + await lazy.get(); + } catch { + // Expected: first attempt rejects + } + const value = await lazy.get(); + assertEquals(value, 1); + assertEquals(attempts, 2); +}); + +Deno.test("Lazy.get() propagates rejection to all concurrent callers", async () => { + const lazy = new Lazy(() => Promise.reject(new Error("init failed"))); + const [resultA, resultB] = await Promise.allSettled([lazy.get(), lazy.get()]); + assertEquals(resultA.status, "rejected"); + assertEquals(resultB.status, "rejected"); + assertEquals( + (resultA as PromiseRejectedResult).reason?.message, + "init failed", + ); + assertEquals( + (resultB as PromiseRejectedResult).reason?.message, + "init failed", + ); +}); + +Deno.test("Lazy.initialized reflects lifecycle", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + + assertEquals(lazy.initialized, false); + + const getPromise = lazy.get(); + await Promise.resolve(); + assertEquals(lazy.initialized, false); + + holder.resolve(1); + await getPromise; + assertEquals(lazy.initialized, true); + + lazy.reset(); + assertEquals(lazy.initialized, false); +}); + +Deno.test("Lazy.initialized is false after rejection", async () => { + const lazy = new Lazy(() => Promise.reject(new Error("fail"))); + try { + await lazy.get(); + } catch { + // expected + } + assertEquals(lazy.initialized, false); +}); + +Deno.test("Lazy.peek() reflects lifecycle", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + + // Before init + assertEquals(lazy.peek(), { ok: false }); + + // In-flight + const getPromise = lazy.get(); + await Promise.resolve(); + assertEquals(lazy.peek(), { ok: false }); + + // After init + holder.resolve(1); + await getPromise; + assertEquals(lazy.peek(), { ok: true, value: 1 }); + + // After reset + lazy.reset(); + assertEquals(lazy.peek(), { ok: false }); + + // After rejected init + const failing = new Lazy(() => Promise.reject(new Error("fail"))); + try { + await failing.get(); + } catch { + // expected + } + assertEquals(failing.peek(), { ok: false }); +}); + +Deno.test("Lazy.peek() disambiguates T = undefined", async () => { + const lazy = new Lazy(() => undefined); + assertEquals(lazy.peek(), { ok: false }); + await lazy.get(); + assertEquals(lazy.peek(), { ok: true, value: undefined }); +}); + +Deno.test("Lazy.peek() returns { ok: false } while in-flight", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + const getPromise = lazy.get(); + await Promise.resolve(); + assertEquals(lazy.peek(), { ok: false }); + holder.resolve(99); + assertEquals(await getPromise, 99); +}); + +Deno.test("Lazy.peek() returns { ok: true, value } after initialization", async () => { + const lazy = new Lazy(() => 42); + await lazy.get(); + assertEquals(lazy.peek(), { ok: true, value: 42 }); +}); + +Deno.test("Lazy.reset() causes re-initialization", async () => { + let initCount = 0; + const lazy = new Lazy(() => { + initCount++; + return initCount; + }); + assertEquals(await lazy.get(), 1); + lazy.reset(); + assertEquals(await lazy.get(), 2); + assertEquals(initCount, 2); +}); + +Deno.test("Lazy.reset() does not affect in-flight initialization", async () => { + const holder: { resolve: (v: string) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + const getPromise = lazy.get(); + await Promise.resolve(); + lazy.reset(); + holder.resolve("ok"); + const value = await getPromise; + assertEquals(value, "ok"); +}); + +Deno.test("Lazy.get() after reset() during in-flight triggers fresh init", async () => { + let initCount = 0; + const holders: { resolve: (v: number) => void }[] = []; + const lazy = new Lazy( + () => + new Promise((res) => { + initCount++; + holders.push({ resolve: res }); + }), + ); + + const first = lazy.get(); + await Promise.resolve(); + assertEquals(initCount, 1); + + lazy.reset(); + const second = lazy.get(); + await Promise.resolve(); + assertEquals(initCount, 2); + + holders[0]!.resolve(1); + holders[1]!.resolve(2); + + assertEquals(await first, 1); + assertEquals(await second, 2); + assertEquals(lazy.initialized, true); + assertEquals(lazy.peek(), { ok: true, value: 2 }); +}); + +Deno.test("Lazy.get() resolves falsy values correctly", async (t) => { + await t.step("0", async () => { + const lazy = new Lazy(() => 0); + assertEquals(await lazy.get(), 0); + assertEquals(lazy.peek(), { ok: true, value: 0 }); + }); + + await t.step("false", async () => { + const lazy = new Lazy(() => false); + assertEquals(await lazy.get(), false); + assertEquals(lazy.peek(), { ok: true, value: false }); + }); + + await t.step("empty string", async () => { + const lazy = new Lazy(() => ""); + assertEquals(await lazy.get(), ""); + assertEquals(lazy.peek(), { ok: true, value: "" }); + }); + + await t.step("null", async () => { + const lazy = new Lazy(() => null); + assertEquals(await lazy.get(), null); + assertEquals(lazy.peek(), { ok: true, value: null }); + }); +}); + +Deno.test("Lazy.get() rejects immediately with already-aborted signal", async () => { + const lazy = new Lazy(() => 42); + const reason = new Error("aborted"); + await assertRejects( + () => lazy.get({ signal: AbortSignal.abort(reason) }), + Error, + "aborted", + ); + assertEquals(lazy.peek(), { ok: false }); +}); + +Deno.test("Lazy.get() rejects when signal is aborted during initialization", async () => { + const lazy = new Lazy( + () => new Promise(() => {}), + ); + const controller = new AbortController(); + const getPromise = lazy.get({ signal: controller.signal }); + controller.abort(new Error("cancelled")); + await assertRejects( + () => getPromise, + Error, + "cancelled", + ); +}); + +Deno.test("Lazy.get() signal does not affect other callers", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + const controller = new AbortController(); + const abortable = lazy.get({ signal: controller.signal }); + const normal = lazy.get(); + controller.abort(new Error("cancelled")); + + await assertRejects(() => abortable, Error, "cancelled"); + + holder.resolve(42); + assertEquals(await normal, 42); + assertEquals(lazy.peek(), { ok: true, value: 42 }); +}); + +Deno.test("Lazy.get() with signal rejects when initializer fails", async () => { + const lazy = new Lazy(() => Promise.reject(new Error("boom"))); + const controller = new AbortController(); + await assertRejects( + () => lazy.get({ signal: controller.signal }), + Error, + "boom", + ); +}); + +Deno.test("Lazy.get() signal is ignored after successful initialization", async () => { + const lazy = new Lazy(() => 42); + await lazy.get(); + const value = await lazy.get({ signal: AbortSignal.abort() }); + assertEquals(value, 42); +});