diff --git a/README.md b/README.md index 0c90247..7aa3fbb 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,22 @@ Re-throws if `fn` throws (and leaves the key retryable). ### `store.status(key): Promise<"pending" | "done" | "failed" | "absent">` +### `store.wrap(prefix, fn, { key?, leaseMs? })` + +Returns a callable that runs `fn` through `store.once` each time it is invoked. +By default, the per-call key is derived from `prefix` plus a stable JSON string +of the arguments; pass `key` to choose the logical unit of work explicitly. + +```ts +const sendOnce = once.wrap( + "daily-digest", + async (day: string) => sendDigest(day), + { key: (day) => day }, +); + +await sendOnce(today); // uses key `daily-digest:${today}` +``` + ### `store.reset(key): Promise` Forget a key (marker + any stale lock). diff --git a/src/index.ts b/src/index.ts index 75188eb..fed58f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,11 @@ export interface OnceOptions { leaseMs?: number; } +export interface WrapOptions extends OnceOptions { + /** Derive the per-call key suffix from the wrapped function arguments. */ + key?: (...args: Args) => string; +} + const DEFAULT_LEASE_MS = 15 * 60 * 1000; export class OnceStore { @@ -146,6 +151,24 @@ export class OnceStore { } } + /** + * Return a callable that routes every invocation through `once`. + * + * By default the key is `${prefix}:${stableJson(args)}`; provide `key` when + * the logical unit of work is more precise than the full argument list. + */ + wrap( + prefix: string, + fn: (...args: Args) => Promise | T, + opts: WrapOptions = {}, + ): (...args: Args) => Promise> { + const { key: keyFn, ...onceOpts } = opts; + return async (...args: Args) => { + const suffix = keyFn ? keyFn(...args) : stableJson(args); + return this.once(`${prefix}:${suffix}`, () => fn(...args), onceOpts); + }; + } + /** Current status of a key, or `"absent"` if never seen. */ async status(key: string): Promise { const marker = await this.readMarker(this.markerPath(key)); @@ -256,3 +279,38 @@ export class OnceStore { export function createOnceStore(opts: OnceStoreOptions): OnceStore { return new OnceStore(opts); } + +function stableJson(value: unknown): string { + return JSON.stringify(sortJsonValue(value, new WeakSet())); +} + +function sortJsonValue(value: unknown, seen: WeakSet): unknown { + if (Array.isArray(value)) return value.map((item) => sortJsonValue(item, seen)); + if (!value || typeof value !== "object") return value; + + const toJSON = (value as { toJSON?: unknown }).toJSON; + if (typeof toJSON === "function") { + return sortJsonValue(toJSON.call(value), seen); + } + + if (seen.has(value)) { + throw new TypeError( + "Cannot derive an effect-once key from circular arguments; provide a custom key", + ); + } + + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + throw new TypeError( + "Cannot derive an effect-once key from non-plain arguments; provide a custom key", + ); + } + + seen.add(value); + const sorted: Record = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = sortJsonValue((value as Record)[key], seen); + } + seen.delete(value); + return sorted; +} diff --git a/test/effect-once.test.ts b/test/effect-once.test.ts index 72201ab..76afdbf 100644 --- a/test/effect-once.test.ts +++ b/test/effect-once.test.ts @@ -102,4 +102,97 @@ describe("OnceStore", () => { expect(removed).toBe(1); expect(await store.status("old")).toBe("absent"); }); + + it("wraps a function and deduplicates by derived argument key", async () => { + const store = createOnceStore({ dir }); + let calls = 0; + const sendOnce = store.wrap("daily-digest", async (day: string) => { + calls += 1; + return `sent:${day}`; + }); + + const first = await sendOnce("2026-05-31"); + const duplicate = await sendOnce("2026-05-31"); + const nextDay = await sendOnce("2026-06-01"); + + expect(first).toMatchObject({ ran: true, value: "sent:2026-05-31" }); + expect(duplicate).toMatchObject({ ran: false, reason: "already-done" }); + expect(nextDay.ran).toBe(true); + expect(calls).toBe(2); + }); + + it("wraps a function with a custom key function", async () => { + const store = createOnceStore({ dir }); + let calls = 0; + const sendOnce = store.wrap( + "notify", + async (message: { id: string; text: string }) => { + calls += 1; + return message.text; + }, + { key: (message) => message.id }, + ); + + await sendOnce({ id: "m1", text: "hello" }); + const duplicate = await sendOnce({ id: "m1", text: "hello again" }); + + expect(duplicate.ran).toBe(false); + expect(calls).toBe(1); + }); + + it("derives stable default keys for reordered plain object arguments", async () => { + const store = createOnceStore({ dir }); + let calls = 0; + const sendOnce = store.wrap("payload", async (_message: { id: string; text: string }) => { + calls += 1; + return calls; + }); + + const first = await sendOnce({ id: "m1", text: "hello" }); + const duplicate = await sendOnce({ text: "hello", id: "m1" }); + + expect(first.ran).toBe(true); + expect(duplicate).toMatchObject({ ran: false, reason: "already-done" }); + expect(calls).toBe(1); + }); + + it("uses toJSON values when deriving default keys", async () => { + const store = createOnceStore({ dir }); + let calls = 0; + const sendOnce = store.wrap("dated", async (_day: Date) => { + calls += 1; + return calls; + }); + + await sendOnce(new Date("2026-05-31T00:00:00.000Z")); + const next = await sendOnce(new Date("2026-06-01T00:00:00.000Z")); + + expect(next.ran).toBe(true); + expect(calls).toBe(2); + }); + + it("requires a custom key for non-plain default-key arguments", async () => { + const store = createOnceStore({ dir }); + const sendOnce = store.wrap("map", async (_value: Map) => "sent"); + + await expect(sendOnce(new Map([["id", "m1"]]))).rejects.toThrow( + "provide a custom key", + ); + }); + + it("keeps wrapped failures retryable", async () => { + const store = createOnceStore({ dir }); + let calls = 0; + const flakyOnce = store.wrap("flaky", async (id: string) => { + calls += 1; + if (calls === 1) throw new Error("boom"); + return id; + }); + + await expect(flakyOnce("a")).rejects.toThrow("boom"); + const retry = await flakyOnce("a"); + + expect(retry).toMatchObject({ ran: true, value: "a", status: "done" }); + expect(calls).toBe(2); + }); });