Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>`

Forget a key (marker + any stale lock).
Expand Down
58 changes: 58 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export interface OnceOptions {
leaseMs?: number;
}

export interface WrapOptions<Args extends unknown[]> 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 {
Expand Down Expand Up @@ -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<Args extends unknown[], T>(
prefix: string,
fn: (...args: Args) => Promise<T> | T,
opts: WrapOptions<Args> = {},
): (...args: Args) => Promise<OnceResult<T>> {
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<MarkerStatus | "absent"> {
const marker = await this.readMarker(this.markerPath(key));
Expand Down Expand Up @@ -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<object>): 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<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
sorted[key] = sortJsonValue((value as Record<string, unknown>)[key], seen);
}
seen.delete(value);
return sorted;
}
93 changes: 93 additions & 0 deletions test/effect-once.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => "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);
});
});
Loading