From f7f627bb34d2865602dc24ec9a5fa2561dc38776 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:38:44 -0700 Subject: [PATCH] feat(function): add once run-at-most-once wrapper Add a new wellcrafted/function subpath whose first export, once(fn), runs the wrapped function at most once: the first call invokes it and caches the result, and every later call returns that cached result while ignoring any arguments passed after the first. This is a generic, dependency-free combinator that previously lived mis-homed in a heavy CRDT package; consumers can now reach it without that dependency. The subpath is additive, so no existing import changes. --- .changeset/once-function.md | 5 ++++ package.json | 4 +++ src/function.test.ts | 60 +++++++++++++++++++++++++++++++++++++ src/function.ts | 43 ++++++++++++++++++++++++++ tsdown.config.ts | 1 + 5 files changed, 113 insertions(+) create mode 100644 .changeset/once-function.md create mode 100644 src/function.test.ts create mode 100644 src/function.ts diff --git a/.changeset/once-function.md b/.changeset/once-function.md new file mode 100644 index 0000000..8656ef0 --- /dev/null +++ b/.changeset/once-function.md @@ -0,0 +1,5 @@ +--- +"wellcrafted": minor +--- + +Add `once` at the new `wellcrafted/function` subpath. `once(fn)` runs the wrapped function at most once: the first call invokes it and caches the result, and every later call returns that cached result while ignoring any arguments passed after the first. It is a dependency-free combinator for idempotent disposal and lazy one-time initialization. diff --git a/package.json b/package.json index d981617..55ec8c3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "types": "./dist/brand.d.ts", "import": "./dist/brand.js" }, + "./function": { + "types": "./dist/function.d.ts", + "import": "./dist/function.js" + }, "./query": { "types": "./dist/query/index.d.ts", "import": "./dist/query/index.js" diff --git a/src/function.test.ts b/src/function.test.ts new file mode 100644 index 0000000..82aae60 --- /dev/null +++ b/src/function.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, expectTypeOf, it } from "bun:test"; +import { once } from "./function.js"; + +describe("once", () => { + // ============================================================================= + // Runs at most once + // ============================================================================= + + it("runs the wrapped fn only on the first call", () => { + let calls = 0; + const wrapped = once(() => { + calls++; + }); + wrapped(); + wrapped(); + wrapped(); + expect(calls).toBe(1); + }); + + // ============================================================================= + // Caches the first result + // ============================================================================= + + it("returns the first result on every later call", () => { + let n = 0; + const wrapped = once(() => ++n); + expect(wrapped()).toBe(1); + expect(wrapped()).toBe(1); + expect(n).toBe(1); + }); + + it("caches the same object reference across calls", () => { + const wrapped = once(() => ({ id: Math.random() })); + expect(wrapped()).toBe(wrapped()); + }); + + // ============================================================================= + // Later arguments are ignored + // ============================================================================= + + it("passes the first call args and ignores later ones", () => { + const seen: number[] = []; + const wrapped = once((x: number) => { + seen.push(x); + return x; + }); + expect(wrapped(1)).toBe(1); + expect(wrapped(2)).toBe(1); + expect(seen).toEqual([1]); + }); + + // ============================================================================= + // Types: the wrapper mirrors the wrapped function's signature + // ============================================================================= + + it("preserves the wrapped function's parameter and return types", () => { + const wrapped = once((a: number, b: string) => `${a}${b}`); + expectTypeOf(wrapped).toEqualTypeOf<(a: number, b: string) => string>(); + }); +}); diff --git a/src/function.ts b/src/function.ts new file mode 100644 index 0000000..6251610 --- /dev/null +++ b/src/function.ts @@ -0,0 +1,43 @@ +/** + * Run-at-most-once wrapper. Wrap `fn` so the first call invokes it and caches the result; + * every later call is a no-op that returns that same cached result. Arguments passed after + * the first call are ignored. + * + * Canonical use: an idempotent `[Symbol.dispose]` whose teardown is reachable from more than + * one path and must not run twice. `once` makes that guarantee declarative instead of a + * hand-rolled `let disposed` flag. + * + * This is for the pure "this function body runs at most once" case. A boolean that is ALSO + * read by other methods to short-circuit a dead object is a liveness flag, not a once-guard; + * keep that boolean, `once` does not replace it. + * + * @example + * ```ts + * import { once } from "wellcrafted/function"; + * + * const init = once(() => expensiveSetup()); + * init(); // runs expensiveSetup() + * init(); // returns the cached result, does not run again + * ``` + * + * @example Idempotent disposal + * ```ts + * import { once } from "wellcrafted/function"; + * + * const dispose = once(() => closeConnection()); + * // safe to call from multiple teardown paths; closeConnection() runs at most once + * ``` + */ +export function once( + fn: (...args: TArgs) => TReturn, +): (...args: TArgs) => TReturn { + let called = false; + let result: TReturn; + return (...args: TArgs): TReturn => { + if (!called) { + called = true; + result = fn(...args); + } + return result; + }; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 222697b..ce0102a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "src/logger/index.ts", "src/json.ts", "src/brand.ts", + "src/function.ts", "src/query/index.ts", "src/standard-schema/index.ts", "src/testing.ts",