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 +21 -0
- package/README.md +24 -0
- package/dist/audit.d.ts +61 -0
- package/dist/audit.js +124 -0
- package/dist/connectors/sendgrid.d.ts +21 -0
- package/dist/connectors/sendgrid.js +13 -0
- package/dist/connectors/stripe.d.ts +30 -0
- package/dist/connectors/stripe.js +20 -0
- package/dist/connectors/twilio.d.ts +21 -0
- package/dist/connectors/twilio.js +12 -0
- package/dist/connectors/webhook.d.ts +24 -0
- package/dist/connectors/webhook.js +19 -0
- package/dist/core.d.ts +45 -0
- package/dist/core.js +112 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +31 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/keys.d.ts +7 -0
- package/dist/keys.js +31 -0
- package/dist/store/base.d.ts +40 -0
- package/dist/store/base.js +1 -0
- package/dist/store/httpStore.d.ts +30 -0
- package/dist/store/httpStore.js +56 -0
- package/dist/store/memoryStore.d.ts +20 -0
- package/dist/store/memoryStore.js +56 -0
- package/dist/store/redisStore.d.ts +36 -0
- package/dist/store/redisStore.js +106 -0
- package/package.json +77 -0
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).
|
package/dist/audit.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|