deduplex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zach Audan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # deduplex
2
+
3
+ Idempotency guard for AI agent tool calls — stop duplicate side effects from retries.
4
+
5
+ ```ts
6
+ import { Guard, deriveKey, guard } from "deduplex";
7
+
8
+ const g = new Guard();
9
+
10
+ const chargeCustomer = guard<[string, number], string>(
11
+ (customerId, amount) => deriveKey([customerId, amount], "charge"),
12
+ g,
13
+ )(async (customerId, amount) => {
14
+ return stripe.charges.create({ customer: customerId, amount });
15
+ });
16
+ ```
17
+
18
+ A second call with the same derived key returns the original result instead of re-executing; a genuine concurrent race raises `DuplicateInProgressError` by default (or waits, with `onRace: "wait"`); every attempt/block/completion is recorded in a hash-chained `AuditLog` verifiable via `g.auditLog.verify()`.
19
+
20
+ - `deduplex/redis` — `RedisStore` for multi-process deployments (any `ioredis`-compatible client)
21
+ - `deduplex/http` — `HttpStore` to run against the hosted API server
22
+ - `deduplex/connectors/{stripe,twilio,sendgrid,webhook}` — pre-built idempotent wrappers for common side-effecting calls
23
+
24
+ Full documentation, the Python SDK, the hosted API server, and the project roadmap live in the [main repository](https://github.com/zachaudan/double-check-harness).
@@ -0,0 +1,61 @@
1
+ export type AuditEventType = "attempt" | "completed" | "failed" | "blocked_duplicate" | "blocked_after_failure" | "blocked_race" | "wait_timeout";
2
+ export interface AuditEntry {
3
+ readonly seq: number;
4
+ readonly key: string;
5
+ readonly event: AuditEventType;
6
+ readonly payload: Record<string, unknown>;
7
+ readonly timestamp: number;
8
+ readonly prevHash: string;
9
+ readonly hash: string;
10
+ }
11
+ export declare const GENESIS_HASH: string;
12
+ export interface AuditSink {
13
+ append(entry: AuditEntry): void;
14
+ all(): AuditEntry[];
15
+ }
16
+ export declare class InMemoryAuditSink implements AuditSink {
17
+ private entries;
18
+ append(entry: AuditEntry): void;
19
+ all(): AuditEntry[];
20
+ }
21
+ /**
22
+ * Append-only, hash-chained log of every guarded action.
23
+ *
24
+ * This is the artifact a compliance/audit-tier customer actually pays for: a log they (or
25
+ * their regulator) can independently verify was not edited after the fact, not just a table
26
+ * of rows in a database that anyone with write access could quietly change. Each entry's hash
27
+ * covers its own payload AND the previous entry's hash, so altering or deleting any historical
28
+ * entry breaks the chain from that point forward.
29
+ */
30
+ export declare class AuditLog {
31
+ private sink;
32
+ constructor(sink?: AuditSink);
33
+ record(key: string, event: AuditEventType, payload?: Record<string, unknown>): AuditEntry;
34
+ entries(): AuditEntry[];
35
+ /**
36
+ * Walk the chain and confirm every entry's hash and prevHash link up correctly.
37
+ *
38
+ * Returns [isValid, firstBrokenSeq]. A broken chain means either the log was tampered with,
39
+ * or entries were reordered/deleted — either way, an integrity failure a customer's auditor
40
+ * would want surfaced immediately, not silently ignored.
41
+ */
42
+ verify(): [boolean, number | null];
43
+ export(): AuditEntry[];
44
+ /**
45
+ * The artifact meant to actually leave the system for an auditor: the full entry list plus
46
+ * the chain-integrity verdict and a generation timestamp, computed fresh at export time
47
+ * rather than left for the recipient to (maybe) recompute themselves.
48
+ */
49
+ exportManifest(): {
50
+ generatedAt: number;
51
+ entryCount: number;
52
+ chainValid: boolean;
53
+ brokenAtSeq: number | null;
54
+ entries: AuditEntry[];
55
+ };
56
+ /**
57
+ * CSV export for auditors/spreadsheet tools that don't want raw JSON. The `payload` column
58
+ * is itself JSON-encoded since it's a nested object with a variable shape.
59
+ */
60
+ exportCsv(): string;
61
+ }
package/dist/audit.js ADDED
@@ -0,0 +1,124 @@
1
+ import { createHash } from "node:crypto";
2
+ export const GENESIS_HASH = "0".repeat(64);
3
+ function canonical(obj) {
4
+ return JSON.stringify(obj);
5
+ }
6
+ function computeHash(entry) {
7
+ const body = canonical({
8
+ seq: entry.seq,
9
+ key: entry.key,
10
+ event: entry.event,
11
+ payload: entry.payload,
12
+ timestamp: entry.timestamp,
13
+ prevHash: entry.prevHash,
14
+ });
15
+ return createHash("sha256").update(body, "utf8").digest("hex");
16
+ }
17
+ export class InMemoryAuditSink {
18
+ entries = [];
19
+ append(entry) {
20
+ this.entries.push(entry);
21
+ }
22
+ all() {
23
+ return [...this.entries];
24
+ }
25
+ }
26
+ /**
27
+ * Append-only, hash-chained log of every guarded action.
28
+ *
29
+ * This is the artifact a compliance/audit-tier customer actually pays for: a log they (or
30
+ * their regulator) can independently verify was not edited after the fact, not just a table
31
+ * of rows in a database that anyone with write access could quietly change. Each entry's hash
32
+ * covers its own payload AND the previous entry's hash, so altering or deleting any historical
33
+ * entry breaks the chain from that point forward.
34
+ */
35
+ export class AuditLog {
36
+ sink;
37
+ constructor(sink) {
38
+ this.sink = sink ?? new InMemoryAuditSink();
39
+ }
40
+ record(key, event, payload = {}) {
41
+ const existing = this.sink.all();
42
+ const seq = existing.length;
43
+ const prevHash = existing.length > 0 ? existing[existing.length - 1].hash : GENESIS_HASH;
44
+ const base = {
45
+ seq,
46
+ key,
47
+ event,
48
+ payload,
49
+ timestamp: Date.now() / 1000,
50
+ prevHash,
51
+ };
52
+ const entry = { ...base, hash: computeHash(base) };
53
+ this.sink.append(entry);
54
+ return entry;
55
+ }
56
+ entries() {
57
+ return this.sink.all();
58
+ }
59
+ /**
60
+ * Walk the chain and confirm every entry's hash and prevHash link up correctly.
61
+ *
62
+ * Returns [isValid, firstBrokenSeq]. A broken chain means either the log was tampered with,
63
+ * or entries were reordered/deleted — either way, an integrity failure a customer's auditor
64
+ * would want surfaced immediately, not silently ignored.
65
+ */
66
+ verify() {
67
+ const entries = this.entries();
68
+ let expectedPrev = GENESIS_HASH;
69
+ for (const entry of entries) {
70
+ const recomputed = computeHash({
71
+ seq: entry.seq,
72
+ key: entry.key,
73
+ event: entry.event,
74
+ payload: entry.payload,
75
+ timestamp: entry.timestamp,
76
+ prevHash: entry.prevHash,
77
+ });
78
+ if (entry.prevHash !== expectedPrev || entry.hash !== recomputed) {
79
+ return [false, entry.seq];
80
+ }
81
+ expectedPrev = entry.hash;
82
+ }
83
+ return [true, null];
84
+ }
85
+ export() {
86
+ return this.entries();
87
+ }
88
+ /**
89
+ * The artifact meant to actually leave the system for an auditor: the full entry list plus
90
+ * the chain-integrity verdict and a generation timestamp, computed fresh at export time
91
+ * rather than left for the recipient to (maybe) recompute themselves.
92
+ */
93
+ exportManifest() {
94
+ const [chainValid, brokenAtSeq] = this.verify();
95
+ const entries = this.export();
96
+ return {
97
+ generatedAt: Date.now() / 1000,
98
+ entryCount: entries.length,
99
+ chainValid,
100
+ brokenAtSeq,
101
+ entries,
102
+ };
103
+ }
104
+ /**
105
+ * CSV export for auditors/spreadsheet tools that don't want raw JSON. The `payload` column
106
+ * is itself JSON-encoded since it's a nested object with a variable shape.
107
+ */
108
+ exportCsv() {
109
+ const escape = (value) => `"${value.replace(/"/g, '""')}"`;
110
+ const rows = [["seq", "key", "event", "payload", "timestamp", "prevHash", "hash"]];
111
+ for (const e of this.export()) {
112
+ rows.push([
113
+ String(e.seq),
114
+ e.key,
115
+ e.event,
116
+ JSON.stringify(e.payload),
117
+ String(e.timestamp),
118
+ e.prevHash,
119
+ e.hash,
120
+ ]);
121
+ }
122
+ return rows.map((row) => row.map(escape).join(",")).join("\n") + "\n";
123
+ }
124
+ }
@@ -0,0 +1,21 @@
1
+ import { Guard } from "../core.js";
2
+ interface SendGridClient {
3
+ send(mail: Record<string, unknown>): Promise<unknown> | unknown;
4
+ }
5
+ export interface SendEmailOptions {
6
+ to: string;
7
+ from: string;
8
+ subject: string;
9
+ content: string;
10
+ namespace?: string;
11
+ [extra: string]: unknown;
12
+ }
13
+ /**
14
+ * Send an email exactly once for the given (to, from, subject, content, extras).
15
+ *
16
+ * SendGrid's `@sendgrid/mail` client has no native idempotency key, so the Guard is the only
17
+ * line of defense. `sendgridClient` is anything exposing `.send(mail)` — the default export
18
+ * of `@sendgrid/mail` after `setApiKey(...)` satisfies this directly.
19
+ */
20
+ export declare function sendEmail(guard: Guard, sendgridClient: SendGridClient, options: SendEmailOptions): Promise<unknown>;
21
+ export {};
@@ -0,0 +1,13 @@
1
+ import { deriveKey } from "../keys.js";
2
+ /**
3
+ * Send an email exactly once for the given (to, from, subject, content, extras).
4
+ *
5
+ * SendGrid's `@sendgrid/mail` client has no native idempotency key, so the Guard is the only
6
+ * line of defense. `sendgridClient` is anything exposing `.send(mail)` — the default export
7
+ * of `@sendgrid/mail` after `setApiKey(...)` satisfies this directly.
8
+ */
9
+ export async function sendEmail(guard, sendgridClient, options) {
10
+ const { to, from, subject, content, namespace = "sendgrid.email", ...extra } = options;
11
+ const key = deriveKey([to, from, subject, content, extra], namespace);
12
+ return guard.execute(key, () => sendgridClient.send({ to, from, subject, text: content, ...extra }));
13
+ }
@@ -0,0 +1,30 @@
1
+ import { Guard } from "../core.js";
2
+ interface StripeChargeClient {
3
+ Charge: {
4
+ create(params: Record<string, unknown>): Promise<unknown> | unknown;
5
+ };
6
+ }
7
+ interface StripePaymentIntentClient {
8
+ PaymentIntent: {
9
+ create(params: Record<string, unknown>): Promise<unknown> | unknown;
10
+ };
11
+ }
12
+ export interface ChargeOptions {
13
+ customer: string;
14
+ amount: number;
15
+ currency?: string;
16
+ namespace?: string;
17
+ [extra: string]: unknown;
18
+ }
19
+ /**
20
+ * Create a Stripe charge exactly once for the given (customer, amount, currency, extras).
21
+ *
22
+ * `stripeClient` is the `stripe` package instance (or anything exposing
23
+ * `.Charge.create(...)`), so this connector never imports `stripe` itself. The derived key
24
+ * is also passed through as Stripe's own `idempotency_key`, so a retry that races past the
25
+ * local Guard is still deduplicated by Stripe itself.
26
+ */
27
+ export declare function charge(guard: Guard, stripeClient: StripeChargeClient, options: ChargeOptions): Promise<unknown>;
28
+ /** Same guarantee as `charge`, for the newer PaymentIntents API. */
29
+ export declare function createPaymentIntent(guard: Guard, stripeClient: StripePaymentIntentClient, options: ChargeOptions): Promise<unknown>;
30
+ export {};
@@ -0,0 +1,20 @@
1
+ import { deriveKey } from "../keys.js";
2
+ /**
3
+ * Create a Stripe charge exactly once for the given (customer, amount, currency, extras).
4
+ *
5
+ * `stripeClient` is the `stripe` package instance (or anything exposing
6
+ * `.Charge.create(...)`), so this connector never imports `stripe` itself. The derived key
7
+ * is also passed through as Stripe's own `idempotency_key`, so a retry that races past the
8
+ * local Guard is still deduplicated by Stripe itself.
9
+ */
10
+ export async function charge(guard, stripeClient, options) {
11
+ const { customer, amount, currency = "usd", namespace = "stripe.charge", ...extra } = options;
12
+ const key = deriveKey([customer, amount, currency, extra], namespace);
13
+ return guard.execute(key, () => stripeClient.Charge.create({ customer, amount, currency, idempotency_key: key, ...extra }));
14
+ }
15
+ /** Same guarantee as `charge`, for the newer PaymentIntents API. */
16
+ export async function createPaymentIntent(guard, stripeClient, options) {
17
+ const { customer, amount, currency = "usd", namespace = "stripe.payment_intent", ...extra } = options;
18
+ const key = deriveKey([customer, amount, currency, extra], namespace);
19
+ return guard.execute(key, () => stripeClient.PaymentIntent.create({ customer, amount, currency, idempotency_key: key, ...extra }));
20
+ }
@@ -0,0 +1,21 @@
1
+ import { Guard } from "../core.js";
2
+ interface TwilioClient {
3
+ messages: {
4
+ create(params: Record<string, unknown>): Promise<unknown> | unknown;
5
+ };
6
+ }
7
+ export interface SendSmsOptions {
8
+ to: string;
9
+ from: string;
10
+ body: string;
11
+ namespace?: string;
12
+ [extra: string]: unknown;
13
+ }
14
+ /**
15
+ * Send an SMS exactly once for the given (to, from, body, extras).
16
+ *
17
+ * Twilio's Messages API has no native idempotency key, so the Guard is the only line of
18
+ * defense here. `twilioClient` is anything exposing `.messages.create(...)`.
19
+ */
20
+ export declare function sendSms(guard: Guard, twilioClient: TwilioClient, options: SendSmsOptions): Promise<unknown>;
21
+ export {};
@@ -0,0 +1,12 @@
1
+ import { deriveKey } from "../keys.js";
2
+ /**
3
+ * Send an SMS exactly once for the given (to, from, body, extras).
4
+ *
5
+ * Twilio's Messages API has no native idempotency key, so the Guard is the only line of
6
+ * defense here. `twilioClient` is anything exposing `.messages.create(...)`.
7
+ */
8
+ export async function sendSms(guard, twilioClient, options) {
9
+ const { to, from, body, namespace = "twilio.sms", ...extra } = options;
10
+ const key = deriveKey([to, from, body, extra], namespace);
11
+ return guard.execute(key, () => twilioClient.messages.create({ to, from, body, ...extra }));
12
+ }
@@ -0,0 +1,24 @@
1
+ import { Guard } from "../core.js";
2
+ interface HttpClient {
3
+ post(url: string, options: {
4
+ json: Record<string, unknown>;
5
+ headers: Record<string, string>;
6
+ }): Promise<unknown> | unknown;
7
+ }
8
+ export interface WebhookPostOptions {
9
+ namespace?: string;
10
+ headers?: Record<string, string>;
11
+ idempotencyHeader?: string | null;
12
+ [extra: string]: unknown;
13
+ }
14
+ /**
15
+ * POST a JSON payload to any webhook/REST endpoint exactly once for the given
16
+ * (url, payload, extras).
17
+ *
18
+ * `httpClient` is anything exposing `.post(url, { json, headers })`. Since most REST APIs
19
+ * that care about idempotency read it from a request header rather than a body field, the
20
+ * derived key is also sent as `idempotencyHeader` (default `Idempotency-Key`, the de facto
21
+ * convention Stripe/GitHub/etc. use) unless the caller passes `idempotencyHeader: null`.
22
+ */
23
+ export declare function post(guard: Guard, httpClient: HttpClient, url: string, payload: Record<string, unknown>, options?: WebhookPostOptions): Promise<unknown>;
24
+ export {};
@@ -0,0 +1,19 @@
1
+ import { deriveKey } from "../keys.js";
2
+ /**
3
+ * POST a JSON payload to any webhook/REST endpoint exactly once for the given
4
+ * (url, payload, extras).
5
+ *
6
+ * `httpClient` is anything exposing `.post(url, { json, headers })`. Since most REST APIs
7
+ * that care about idempotency read it from a request header rather than a body field, the
8
+ * derived key is also sent as `idempotencyHeader` (default `Idempotency-Key`, the de facto
9
+ * convention Stripe/GitHub/etc. use) unless the caller passes `idempotencyHeader: null`.
10
+ */
11
+ export async function post(guard, httpClient, url, payload, options = {}) {
12
+ const { namespace = "webhook.post", headers = {}, idempotencyHeader = "Idempotency-Key", ...extra } = options;
13
+ const key = deriveKey([url, payload, extra], namespace);
14
+ const mergedHeaders = { ...headers };
15
+ if (idempotencyHeader) {
16
+ mergedHeaders[idempotencyHeader] = key;
17
+ }
18
+ return guard.execute(key, () => httpClient.post(url, { json: payload, headers: mergedHeaders, ...extra }));
19
+ }
package/dist/core.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { AuditLog } from "./audit.js";
2
+ import type { Store } from "./store/base.js";
3
+ export type OnRace = "raise" | "wait";
4
+ export interface GuardOptions {
5
+ store?: Store;
6
+ auditLog?: AuditLog;
7
+ defaultTtlSeconds?: number;
8
+ onRace?: OnRace;
9
+ waitPollIntervalMs?: number;
10
+ waitTimeoutMs?: number;
11
+ }
12
+ /**
13
+ * The core idempotency guard: wraps a side-effecting async function so that two calls with
14
+ * the same key produce the effect exactly once.
15
+ *
16
+ * A Guard is stateless configuration over a Store + AuditLog — construct one per application
17
+ * (or per tool-type namespace) and reuse it across calls; the Store is what actually holds
18
+ * per-key state.
19
+ */
20
+ export declare class Guard {
21
+ readonly store: Store;
22
+ readonly auditLog: AuditLog;
23
+ private readonly defaultTtlSeconds;
24
+ private readonly onRace;
25
+ private readonly waitPollIntervalMs;
26
+ private readonly waitTimeoutMs;
27
+ constructor(options?: GuardOptions);
28
+ /**
29
+ * Run `fn` exactly once for `key`, no matter how many times `execute` is called with that
30
+ * key. `fn` takes no arguments — bind any needed arguments via closure before calling
31
+ * execute, since the key itself is the caller's declaration of "these are the arguments
32
+ * that make two calls the same action."
33
+ */
34
+ execute<T>(key: string, fn: () => Promise<T> | T, ttlSeconds?: number): Promise<T>;
35
+ private handleRace;
36
+ }
37
+ /**
38
+ * Wraps an existing async function with idempotency-guarded execution.
39
+ *
40
+ * `keyFn` receives the same arguments as the wrapped function and returns the idempotency key
41
+ * string — this keeps key derivation explicit and visible at the call site instead of hidden
42
+ * magic, since getting the key wrong (too broad or too narrow) is the single most
43
+ * consequential mistake a user of this library can make.
44
+ */
45
+ export declare function guard<Args extends unknown[], T>(keyFn: (...args: Args) => string, guardInstance?: Guard): (fn: (...args: Args) => Promise<T> | T) => (...args: Args) => Promise<T>;
package/dist/core.js ADDED
@@ -0,0 +1,112 @@
1
+ import { AuditLog } from "./audit.js";
2
+ import { DuplicateInProgressError, StoreError } from "./errors.js";
3
+ import { MemoryStore } from "./store/memoryStore.js";
4
+ function sleep(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+ /**
8
+ * The core idempotency guard: wraps a side-effecting async function so that two calls with
9
+ * the same key produce the effect exactly once.
10
+ *
11
+ * A Guard is stateless configuration over a Store + AuditLog — construct one per application
12
+ * (or per tool-type namespace) and reuse it across calls; the Store is what actually holds
13
+ * per-key state.
14
+ */
15
+ export class Guard {
16
+ store;
17
+ auditLog;
18
+ defaultTtlSeconds;
19
+ onRace;
20
+ waitPollIntervalMs;
21
+ waitTimeoutMs;
22
+ constructor(options = {}) {
23
+ this.store = options.store ?? new MemoryStore();
24
+ this.auditLog = options.auditLog ?? new AuditLog();
25
+ this.defaultTtlSeconds = options.defaultTtlSeconds ?? 86400;
26
+ this.onRace = options.onRace ?? "raise";
27
+ this.waitPollIntervalMs = options.waitPollIntervalMs ?? 50;
28
+ this.waitTimeoutMs = options.waitTimeoutMs ?? 5000;
29
+ }
30
+ /**
31
+ * Run `fn` exactly once for `key`, no matter how many times `execute` is called with that
32
+ * key. `fn` takes no arguments — bind any needed arguments via closure before calling
33
+ * execute, since the key itself is the caller's declaration of "these are the arguments
34
+ * that make two calls the same action."
35
+ */
36
+ async execute(key, fn, ttlSeconds) {
37
+ const ttl = ttlSeconds ?? this.defaultTtlSeconds;
38
+ const existing = await this.store.acquire(key, ttl);
39
+ if (existing === null) {
40
+ // We won the race — we are the one execution of this action.
41
+ this.auditLog.record(key, "attempt");
42
+ try {
43
+ const result = await fn();
44
+ await this.store.markDone(key, result);
45
+ this.auditLog.record(key, "completed");
46
+ return result;
47
+ }
48
+ catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ await this.store.markFailed(key, message);
51
+ this.auditLog.record(key, "failed", { error: message });
52
+ throw err;
53
+ }
54
+ }
55
+ if (existing.status === "done") {
56
+ this.auditLog.record(key, "blocked_duplicate", { reusedResult: true });
57
+ return existing.result;
58
+ }
59
+ if (existing.status === "failed") {
60
+ // A prior attempt failed terminally. Do not silently resurrect it as success; surface
61
+ // the original failure so the caller can decide whether to actually retry under a
62
+ // fresh key (a deliberate choice) rather than an accidental duplicate.
63
+ this.auditLog.record(key, "blocked_after_failure", { error: existing.error ?? null });
64
+ throw new StoreError(`Action "${key}" previously failed: ${existing.error}. ` +
65
+ "Retry with a new key if this is an intentional new attempt.");
66
+ }
67
+ // Status is "in_progress": a genuine concurrent race, not a retry-after-completion.
68
+ return this.handleRace(key);
69
+ }
70
+ async handleRace(key) {
71
+ if (this.onRace === "raise") {
72
+ this.auditLog.record(key, "blocked_race");
73
+ throw new DuplicateInProgressError(key);
74
+ }
75
+ // onRace === "wait": poll until the in-flight caller finishes, or time out.
76
+ const deadline = Date.now() + this.waitTimeoutMs;
77
+ while (Date.now() < deadline) {
78
+ await sleep(this.waitPollIntervalMs);
79
+ const record = await this.store.get(key);
80
+ if (record === null) {
81
+ // Released without completing — nothing to reuse; caller must decide next step.
82
+ throw new DuplicateInProgressError(key);
83
+ }
84
+ if (record.status === "done") {
85
+ this.auditLog.record(key, "blocked_duplicate", { reusedResult: true, waited: true });
86
+ return record.result;
87
+ }
88
+ if (record.status === "failed") {
89
+ throw new StoreError(`Action "${key}" previously failed: ${record.error}`);
90
+ }
91
+ }
92
+ this.auditLog.record(key, "wait_timeout");
93
+ throw new DuplicateInProgressError(key);
94
+ }
95
+ }
96
+ /**
97
+ * Wraps an existing async function with idempotency-guarded execution.
98
+ *
99
+ * `keyFn` receives the same arguments as the wrapped function and returns the idempotency key
100
+ * string — this keeps key derivation explicit and visible at the call site instead of hidden
101
+ * magic, since getting the key wrong (too broad or too narrow) is the single most
102
+ * consequential mistake a user of this library can make.
103
+ */
104
+ export function guard(keyFn, guardInstance) {
105
+ const g = guardInstance ?? new Guard();
106
+ return (fn) => {
107
+ return (...args) => {
108
+ const key = keyFn(...args);
109
+ return g.execute(key, () => fn(...args));
110
+ };
111
+ };
112
+ }
@@ -0,0 +1,21 @@
1
+ export declare class DoubleCheckError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /**
5
+ * Thrown when a second caller races in on a key whose first call hasn't finished yet.
6
+ *
7
+ * This is a concurrency race, not a normal retry-after-completion. The caller must decide
8
+ * whether to back off and poll, or surface the error — the harness will not guess for them,
9
+ * since guessing wrong here is exactly the class of bug this library exists to prevent.
10
+ */
11
+ export declare class DuplicateInProgressError extends DoubleCheckError {
12
+ readonly key: string;
13
+ constructor(key: string);
14
+ }
15
+ /**
16
+ * Thrown when the backing store fails in a way that makes the idempotency guarantee unsafe,
17
+ * or when a key's prior attempt failed terminally and must not be silently replayed as success.
18
+ */
19
+ export declare class StoreError extends DoubleCheckError {
20
+ constructor(message: string);
21
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,31 @@
1
+ export class DoubleCheckError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "DoubleCheckError";
5
+ }
6
+ }
7
+ /**
8
+ * Thrown when a second caller races in on a key whose first call hasn't finished yet.
9
+ *
10
+ * This is a concurrency race, not a normal retry-after-completion. The caller must decide
11
+ * whether to back off and poll, or surface the error — the harness will not guess for them,
12
+ * since guessing wrong here is exactly the class of bug this library exists to prevent.
13
+ */
14
+ export class DuplicateInProgressError extends DoubleCheckError {
15
+ key;
16
+ constructor(key) {
17
+ super(`Action with key "${key}" is already in progress`);
18
+ this.name = "DuplicateInProgressError";
19
+ this.key = key;
20
+ }
21
+ }
22
+ /**
23
+ * Thrown when the backing store fails in a way that makes the idempotency guarantee unsafe,
24
+ * or when a key's prior attempt failed terminally and must not be silently replayed as success.
25
+ */
26
+ export class StoreError extends DoubleCheckError {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "StoreError";
30
+ }
31
+ }
@@ -0,0 +1,8 @@
1
+ export { Guard, guard } from "./core.js";
2
+ export type { GuardOptions, OnRace } from "./core.js";
3
+ export { deriveKey } from "./keys.js";
4
+ export { DoubleCheckError, DuplicateInProgressError, StoreError } from "./errors.js";
5
+ export { AuditLog, InMemoryAuditSink, GENESIS_HASH } from "./audit.js";
6
+ export type { AuditEntry, AuditEventType, AuditSink } from "./audit.js";
7
+ export { MemoryStore } from "./store/memoryStore.js";
8
+ export type { Record_, Status, Store } from "./store/base.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { Guard, guard } from "./core.js";
2
+ export { deriveKey } from "./keys.js";
3
+ export { DoubleCheckError, DuplicateInProgressError, StoreError } from "./errors.js";
4
+ export { AuditLog, InMemoryAuditSink, GENESIS_HASH } from "./audit.js";
5
+ export { MemoryStore } from "./store/memoryStore.js";
package/dist/keys.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Derive a stable idempotency key from a set of values.
3
+ *
4
+ * A namespace should be supplied per tool/action type (e.g. "stripe.charge") to avoid
5
+ * accidental key collisions between unrelated tools that happen to share argument values.
6
+ */
7
+ export declare function deriveKey(parts: unknown[], namespace?: string): string;
package/dist/keys.js ADDED
@@ -0,0 +1,31 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Canonicalize a value for stable hashing: object keys are sorted recursively so that
4
+ * argument ordering (e.g. differing key order in an options object at different call sites)
5
+ * never produces a different key for what is semantically the same action.
6
+ */
7
+ function canonicalize(value) {
8
+ if (Array.isArray(value)) {
9
+ return value.map(canonicalize);
10
+ }
11
+ if (value !== null && typeof value === "object") {
12
+ const sortedKeys = Object.keys(value).sort();
13
+ const result = {};
14
+ for (const k of sortedKeys) {
15
+ result[k] = canonicalize(value[k]);
16
+ }
17
+ return result;
18
+ }
19
+ return value;
20
+ }
21
+ /**
22
+ * Derive a stable idempotency key from a set of values.
23
+ *
24
+ * A namespace should be supplied per tool/action type (e.g. "stripe.charge") to avoid
25
+ * accidental key collisions between unrelated tools that happen to share argument values.
26
+ */
27
+ export function deriveKey(parts, namespace) {
28
+ const canonical = JSON.stringify(canonicalize({ namespace: namespace ?? null, parts }));
29
+ const digest = createHash("sha256").update(canonical, "utf8").digest("hex");
30
+ return namespace ? `${namespace}:${digest}` : digest;
31
+ }
@@ -0,0 +1,40 @@
1
+ export type Status = "in_progress" | "done" | "failed";
2
+ export interface Record_ {
3
+ key: string;
4
+ status: Status;
5
+ result?: unknown;
6
+ error?: string;
7
+ createdAt: number;
8
+ updatedAt: number;
9
+ }
10
+ /**
11
+ * Backing store for idempotency state.
12
+ *
13
+ * The contract every implementation must uphold: `acquire` must be atomic. Two concurrent
14
+ * callers racing on the same key must never both receive "you have the lock" — exactly one
15
+ * does, and the other gets back the existing (in-progress or done) record instead. This is
16
+ * the property that makes the guarantee real under concurrency, not just under sequential
17
+ * retries, which is the case a naive "check then write" implementation gets wrong.
18
+ */
19
+ export interface Store {
20
+ /**
21
+ * Attempt to claim `key` for execution.
22
+ *
23
+ * Returns `null` if the caller acquired the lock (i.e. is the first/only caller and should
24
+ * proceed to execute). Returns the existing Record if someone else already holds or has
25
+ * completed this key (i.e. the caller must NOT execute again).
26
+ */
27
+ acquire(key: string, ttlSeconds: number): Promise<Record_ | null>;
28
+ /** Record a successful outcome for `key`, unblocking future duplicate callers with the
29
+ * cached result instead of re-executing. */
30
+ markDone(key: string, result: unknown): Promise<void>;
31
+ /** Record a failed outcome. Whether a failed action should be retried under a new key is a
32
+ * policy decision left to the caller/Guard, not the store. */
33
+ markFailed(key: string, error: string): Promise<void>;
34
+ /** Fetch the current record for `key`, if any, without acquiring it. */
35
+ get(key: string): Promise<Record_ | null>;
36
+ /** Release a claimed-but-never-completed key (e.g. after an unrecoverable crash during
37
+ * execution), so a future retry is not permanently blocked on a record that will never
38
+ * resolve. Should be used deliberately, not on every failure. */
39
+ release(key: string): Promise<void>;
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import type { Record_, Store } from "./base.js";
2
+ export interface HttpStoreOptions {
3
+ baseUrl: string;
4
+ apiKey: string;
5
+ fetchImpl?: typeof fetch;
6
+ }
7
+ /**
8
+ * Store implementation that delegates to the Deduplex hosted API instead of a
9
+ * local Redis/memory store. This is the piece that turns the SDK from "self-hosted library"
10
+ * into "hosted product": point `Guard` at an `HttpStore` and every idempotency decision,
11
+ * along with the tamper-evident audit trail, is made and persisted server-side, viewable in
12
+ * the dashboard, with no infrastructure for the caller to run.
13
+ *
14
+ * The server owns the actual atomicity guarantee (via its own SQL-level `INSERT ... ON
15
+ * CONFLICT`); this class is a thin, honest HTTP client over that contract — it does not
16
+ * attempt to add its own client-side locking, since a network client cannot make a remote
17
+ * operation atomic that the server itself doesn't guarantee.
18
+ */
19
+ export declare class HttpStore implements Store {
20
+ private baseUrl;
21
+ private apiKey;
22
+ private fetchImpl;
23
+ constructor(options: HttpStoreOptions);
24
+ private request;
25
+ acquire(key: string, ttlSeconds: number): Promise<Record_ | null>;
26
+ markDone(key: string, result: unknown): Promise<void>;
27
+ markFailed(key: string, error: string): Promise<void>;
28
+ get(key: string): Promise<Record_ | null>;
29
+ release(key: string): Promise<void>;
30
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Store implementation that delegates to the Deduplex hosted API instead of a
3
+ * local Redis/memory store. This is the piece that turns the SDK from "self-hosted library"
4
+ * into "hosted product": point `Guard` at an `HttpStore` and every idempotency decision,
5
+ * along with the tamper-evident audit trail, is made and persisted server-side, viewable in
6
+ * the dashboard, with no infrastructure for the caller to run.
7
+ *
8
+ * The server owns the actual atomicity guarantee (via its own SQL-level `INSERT ... ON
9
+ * CONFLICT`); this class is a thin, honest HTTP client over that contract — it does not
10
+ * attempt to add its own client-side locking, since a network client cannot make a remote
11
+ * operation atomic that the server itself doesn't guarantee.
12
+ */
13
+ export class HttpStore {
14
+ baseUrl;
15
+ apiKey;
16
+ fetchImpl;
17
+ constructor(options) {
18
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
19
+ this.apiKey = options.apiKey;
20
+ this.fetchImpl = options.fetchImpl ?? fetch;
21
+ }
22
+ async request(method, path, body) {
23
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
24
+ method,
25
+ headers: {
26
+ Authorization: `Bearer ${this.apiKey}`,
27
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
28
+ },
29
+ body: body !== undefined ? JSON.stringify(body) : undefined,
30
+ });
31
+ if (res.status === 401) {
32
+ throw new Error("Deduplex: invalid or missing API key");
33
+ }
34
+ const json = (await res.json().catch(() => ({})));
35
+ return { status: res.status, json };
36
+ }
37
+ async acquire(key, ttlSeconds) {
38
+ const { json } = await this.request("POST", `/v1/keys/${encodeURIComponent(key)}/acquire`, { ttlSeconds });
39
+ return json.acquired ? null : json.record;
40
+ }
41
+ async markDone(key, result) {
42
+ await this.request("POST", `/v1/keys/${encodeURIComponent(key)}/done`, { result });
43
+ }
44
+ async markFailed(key, error) {
45
+ await this.request("POST", `/v1/keys/${encodeURIComponent(key)}/failed`, { error });
46
+ }
47
+ async get(key) {
48
+ const { status, json } = await this.request("GET", `/v1/keys/${encodeURIComponent(key)}`);
49
+ if (status === 404)
50
+ return null;
51
+ return json.record ?? null;
52
+ }
53
+ async release(key) {
54
+ await this.request("DELETE", `/v1/keys/${encodeURIComponent(key)}`);
55
+ }
56
+ }
@@ -0,0 +1,20 @@
1
+ import type { Record_, Store } from "./base.js";
2
+ /**
3
+ * Single-process, in-memory store. Good for local dev, tests, and single-instance
4
+ * deployments — not for anything running across multiple processes/machines, since state
5
+ * lives only in this process's memory. Use RedisStore for real distributed deployments.
6
+ *
7
+ * Node is single-threaded, but `acquire` is still written to be safe under interleaved async
8
+ * execution: the check-and-set happens synchronously within one microtask, so no `await`
9
+ * splits the read from the write and lets a second concurrent call slip through.
10
+ */
11
+ export declare class MemoryStore implements Store {
12
+ private records;
13
+ private ttls;
14
+ private expireIfNeeded;
15
+ acquire(key: string, ttlSeconds: number): Promise<Record_ | null>;
16
+ markDone(key: string, result: unknown): Promise<void>;
17
+ markFailed(key: string, error: string): Promise<void>;
18
+ get(key: string): Promise<Record_ | null>;
19
+ release(key: string): Promise<void>;
20
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Single-process, in-memory store. Good for local dev, tests, and single-instance
3
+ * deployments — not for anything running across multiple processes/machines, since state
4
+ * lives only in this process's memory. Use RedisStore for real distributed deployments.
5
+ *
6
+ * Node is single-threaded, but `acquire` is still written to be safe under interleaved async
7
+ * execution: the check-and-set happens synchronously within one microtask, so no `await`
8
+ * splits the read from the write and lets a second concurrent call slip through.
9
+ */
10
+ export class MemoryStore {
11
+ records = new Map();
12
+ ttls = new Map();
13
+ expireIfNeeded(key) {
14
+ const expiry = this.ttls.get(key);
15
+ if (expiry !== undefined && Date.now() >= expiry) {
16
+ this.records.delete(key);
17
+ this.ttls.delete(key);
18
+ }
19
+ }
20
+ async acquire(key, ttlSeconds) {
21
+ this.expireIfNeeded(key);
22
+ const existing = this.records.get(key);
23
+ if (existing !== undefined) {
24
+ return existing;
25
+ }
26
+ const now = Date.now() / 1000;
27
+ const record = { key, status: "in_progress", createdAt: now, updatedAt: now };
28
+ this.records.set(key, record);
29
+ this.ttls.set(key, Date.now() + ttlSeconds * 1000);
30
+ return null;
31
+ }
32
+ async markDone(key, result) {
33
+ const record = this.records.get(key);
34
+ if (!record)
35
+ return;
36
+ record.status = "done";
37
+ record.result = result;
38
+ record.updatedAt = Date.now() / 1000;
39
+ }
40
+ async markFailed(key, error) {
41
+ const record = this.records.get(key);
42
+ if (!record)
43
+ return;
44
+ record.status = "failed";
45
+ record.error = error;
46
+ record.updatedAt = Date.now() / 1000;
47
+ }
48
+ async get(key) {
49
+ this.expireIfNeeded(key);
50
+ return this.records.get(key) ?? null;
51
+ }
52
+ async release(key) {
53
+ this.records.delete(key);
54
+ this.ttls.delete(key);
55
+ }
56
+ }
@@ -0,0 +1,36 @@
1
+ import type { Record_, Store } from "./base.js";
2
+ export interface RedisLike {
3
+ get(key: string): Promise<string | null>;
4
+ set(key: string, value: string, mode: "PX", ttlMs: number): Promise<string | null>;
5
+ del(key: string): Promise<number>;
6
+ pttl(key: string): Promise<number>;
7
+ defineCommand(name: string, definition: {
8
+ numberOfKeys: number;
9
+ lua: string;
10
+ }): void;
11
+ [command: string]: unknown;
12
+ }
13
+ /**
14
+ * Distributed store backed by Redis. Safe across multiple processes/machines, which is the
15
+ * deployment shape any real hosted or multi-instance self-hosted setup needs — this is the
16
+ * store that makes the concurrency guarantee (Store.acquire's contract) hold true when two
17
+ * different servers race on the same idempotency key at the same time.
18
+ *
19
+ * Accepts any client satisfying `RedisLike` (a real `ioredis.Redis`, or a compatible mock in
20
+ * tests) rather than depending on the `ioredis` package directly.
21
+ */
22
+ export declare class RedisStore implements Store {
23
+ private client;
24
+ private prefix;
25
+ private scriptsRegistered;
26
+ constructor(client: RedisLike, keyPrefix?: string);
27
+ private ensureScripts;
28
+ private rkey;
29
+ private toRecord;
30
+ acquire(key: string, ttlSeconds: number): Promise<Record_ | null>;
31
+ private update;
32
+ markDone(key: string, result: unknown): Promise<void>;
33
+ markFailed(key: string, error: string): Promise<void>;
34
+ get(key: string): Promise<Record_ | null>;
35
+ release(key: string): Promise<void>;
36
+ }
@@ -0,0 +1,106 @@
1
+ // Atomically claim a key only if it doesn't already exist. This is the operation that makes
2
+ // concurrent races safe: two processes calling this script on the same key at the same instant
3
+ // will not both get "created" back — Redis serializes script execution, so exactly one caller
4
+ // wins and the other sees the record the winner just wrote.
5
+ const ACQUIRE_SCRIPT = `
6
+ local existing = redis.call('GET', KEYS[1])
7
+ if existing then
8
+ return existing
9
+ end
10
+ redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
11
+ return false
12
+ `;
13
+ const UPDATE_SCRIPT = `
14
+ local existing = redis.call('GET', KEYS[1])
15
+ if not existing then
16
+ return 0
17
+ end
18
+ local ttl = redis.call('PTTL', KEYS[1])
19
+ if ttl < 0 then ttl = ARGV[2] end
20
+ redis.call('SET', KEYS[1], ARGV[1], 'PX', ttl)
21
+ return 1
22
+ `;
23
+ /**
24
+ * Distributed store backed by Redis. Safe across multiple processes/machines, which is the
25
+ * deployment shape any real hosted or multi-instance self-hosted setup needs — this is the
26
+ * store that makes the concurrency guarantee (Store.acquire's contract) hold true when two
27
+ * different servers race on the same idempotency key at the same time.
28
+ *
29
+ * Accepts any client satisfying `RedisLike` (a real `ioredis.Redis`, or a compatible mock in
30
+ * tests) rather than depending on the `ioredis` package directly.
31
+ */
32
+ export class RedisStore {
33
+ client;
34
+ prefix;
35
+ scriptsRegistered = false;
36
+ constructor(client, keyPrefix = "dch:") {
37
+ this.client = client;
38
+ this.prefix = keyPrefix;
39
+ }
40
+ ensureScripts() {
41
+ if (this.scriptsRegistered)
42
+ return;
43
+ this.client.defineCommand("acquireScript", { numberOfKeys: 1, lua: ACQUIRE_SCRIPT });
44
+ this.client.defineCommand("updateScript", { numberOfKeys: 1, lua: UPDATE_SCRIPT });
45
+ this.scriptsRegistered = true;
46
+ }
47
+ rkey(key) {
48
+ return `${this.prefix}${key}`;
49
+ }
50
+ toRecord(key, raw) {
51
+ const data = JSON.parse(raw);
52
+ return {
53
+ key,
54
+ status: data.status,
55
+ result: data.result,
56
+ error: data.error ?? undefined,
57
+ createdAt: data.createdAt,
58
+ updatedAt: data.updatedAt,
59
+ };
60
+ }
61
+ async acquire(key, ttlSeconds) {
62
+ this.ensureScripts();
63
+ const now = Date.now() / 1000;
64
+ const payload = {
65
+ status: "in_progress",
66
+ result: null,
67
+ error: null,
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ };
71
+ const acquireScript = this.client["acquireScript"];
72
+ const existing = await acquireScript(this.rkey(key), JSON.stringify(payload), ttlSeconds * 1000);
73
+ if (!existing)
74
+ return null;
75
+ return this.toRecord(key, existing);
76
+ }
77
+ async update(key, status, extra) {
78
+ this.ensureScripts();
79
+ const existingRaw = await this.client.get(this.rkey(key));
80
+ const createdAt = existingRaw ? JSON.parse(existingRaw).createdAt : Date.now() / 1000;
81
+ const payload = {
82
+ status,
83
+ result: extra.result ?? null,
84
+ error: extra.error ?? null,
85
+ createdAt,
86
+ updatedAt: Date.now() / 1000,
87
+ };
88
+ const updateScript = this.client["updateScript"];
89
+ await updateScript(this.rkey(key), JSON.stringify(payload), 86400 * 1000);
90
+ }
91
+ async markDone(key, result) {
92
+ await this.update(key, "done", { result });
93
+ }
94
+ async markFailed(key, error) {
95
+ await this.update(key, "failed", { error });
96
+ }
97
+ async get(key) {
98
+ const raw = await this.client.get(this.rkey(key));
99
+ if (raw === null)
100
+ return null;
101
+ return this.toRecord(key, raw);
102
+ }
103
+ async release(key) {
104
+ await this.client.del(this.rkey(key));
105
+ }
106
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "deduplex",
3
+ "version": "0.1.0",
4
+ "description": "Idempotency guard for AI agent tool calls — stop duplicate side effects from retries.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./redis": {
14
+ "types": "./dist/store/redisStore.d.ts",
15
+ "import": "./dist/store/redisStore.js"
16
+ },
17
+ "./http": {
18
+ "types": "./dist/store/httpStore.d.ts",
19
+ "import": "./dist/store/httpStore.js"
20
+ },
21
+ "./connectors/stripe": {
22
+ "types": "./dist/connectors/stripe.d.ts",
23
+ "import": "./dist/connectors/stripe.js"
24
+ },
25
+ "./connectors/twilio": {
26
+ "types": "./dist/connectors/twilio.d.ts",
27
+ "import": "./dist/connectors/twilio.js"
28
+ },
29
+ "./connectors/sendgrid": {
30
+ "types": "./dist/connectors/sendgrid.d.ts",
31
+ "import": "./dist/connectors/sendgrid.js"
32
+ },
33
+ "./connectors/webhook": {
34
+ "types": "./dist/connectors/webhook.d.ts",
35
+ "import": "./dist/connectors/webhook.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.json",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
45
+ "prepublishOnly": "npm run build && npm test"
46
+ },
47
+ "keywords": [
48
+ "idempotency",
49
+ "agents",
50
+ "llm",
51
+ "retries",
52
+ "reliability"
53
+ ],
54
+ "author": "Zach Audan",
55
+ "license": "MIT",
56
+ "homepage": "https://server-beta-seven-23.vercel.app",
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "peerDependencies": {
61
+ "ioredis": "^5.0.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "ioredis": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "^22.7.5",
70
+ "get-port": "^7.1.0",
71
+ "ioredis": "^5.4.1",
72
+ "ioredis-mock": "^8.9.0",
73
+ "tsx": "^4.19.2",
74
+ "typescript": "^5.5.4",
75
+ "vitest": "^5.0.0"
76
+ }
77
+ }