@lacspace/idempotency 1.0.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 +51 -0
- package/README.md +114 -0
- package/dist/index.cjs +156 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +105 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Lacspace Free Licence
|
|
2
|
+
Version 1.0, August 2026
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Lacspace
|
|
5
|
+
|
|
6
|
+
PREAMBLE
|
|
7
|
+
|
|
8
|
+
This software is published by Lacspace under the Lacspace Free Licence — a free,
|
|
9
|
+
permissive licence that lets you use this software for any purpose, including in
|
|
10
|
+
commercial products and services, at no cost. It grants the same freedoms as
|
|
11
|
+
common permissive open-source licences; the only condition is that this notice
|
|
12
|
+
travels with the software. The canonical, always-current text of this licence is
|
|
13
|
+
maintained at https://lacspace.com/licenses/lacspace-free-1.0
|
|
14
|
+
|
|
15
|
+
GRANT OF RIGHTS
|
|
16
|
+
|
|
17
|
+
Permission is hereby granted, free of charge, to any person or organisation
|
|
18
|
+
obtaining a copy of this software and its associated documentation and data files
|
|
19
|
+
(the "Software"), to deal in the Software without restriction, including without
|
|
20
|
+
limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
21
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
|
22
|
+
Software is furnished to do so, subject to the conditions below. These rights are
|
|
23
|
+
granted for any purpose, personal or commercial, and are perpetual, worldwide,
|
|
24
|
+
non-exclusive, and royalty-free.
|
|
25
|
+
|
|
26
|
+
CONDITIONS
|
|
27
|
+
|
|
28
|
+
The above copyright notice, this permission notice, and the name of this licence
|
|
29
|
+
("Lacspace Free Licence") shall be included in all copies or substantial portions
|
|
30
|
+
of the Software.
|
|
31
|
+
|
|
32
|
+
TRADEMARKS
|
|
33
|
+
|
|
34
|
+
This licence does not grant permission to use the trade names, trademarks, service
|
|
35
|
+
marks, logos, or product names of Lacspace, except as required to reproduce the
|
|
36
|
+
notice above or to describe the origin of the Software in a truthful manner.
|
|
37
|
+
|
|
38
|
+
DISCLAIMER OF WARRANTY AND LIMITATION OF LIABILITY
|
|
39
|
+
|
|
40
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
41
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
42
|
+
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
43
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
|
|
44
|
+
AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
|
|
45
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
The Lacspace Free Licence is a source-available, permissive licence and is not (as
|
|
50
|
+
of this version) an OSI-approved licence. In substance it grants the same freedoms
|
|
51
|
+
as the MIT Licence. Learn more at https://lacspace.com/licenses
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @lacspace/idempotency
|
|
4
|
+
|
|
5
|
+
**Make any operation exactly-once with an idempotency key — replay results on retries, safe under concurrency.**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@lacspace/idempotency)
|
|
8
|
+
[](https://packagephobia.com/result?p=@lacspace/idempotency)
|
|
9
|
+
[](https://bundlephobia.com/package/@lacspace/idempotency)
|
|
10
|
+
[](https://www.npmjs.com/package/@lacspace/idempotency)
|
|
11
|
+
[](https://github.com/lacspace/npm-packages/blob/main/LICENSE)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
> The "don't double-charge the card, don't send the email twice" pattern. A client retries; a webhook fires again; a user double-clicks — and your operation runs **once**, replaying the stored result. Every existing library is welded to a framework (Hono, AWS Lambda); this is the framework-agnostic primitive.
|
|
16
|
+
|
|
17
|
+
- ♻️ **Exactly-once** — a repeat key replays the cached result instead of re-running
|
|
18
|
+
- 🔒 **Concurrency-safe** — in-flight de-dupe in-process, atomic create-if-absent for shared stores, plus a conflict/wait policy
|
|
19
|
+
- 🔎 Optional **request fingerprint** — catch a key reused with a different payload (Stripe-style)
|
|
20
|
+
- 🧩 Pluggable store (in-memory built in; bring your own Redis / KV / SQL) · zero deps · isomorphic
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @lacspace/idempotency # or pnpm add / yarn add / bun add
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Exactly-once in one call
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { idempotent } from "@lacspace/idempotency";
|
|
32
|
+
|
|
33
|
+
// in a POST handler — the client sends an Idempotency-Key header
|
|
34
|
+
const key = request.headers.get("idempotency-key")!;
|
|
35
|
+
|
|
36
|
+
const { value, replayed } = await idempotent(key, () => chargeCard(order));
|
|
37
|
+
// first request: runs chargeCard, stores the result → replayed: false
|
|
38
|
+
// any retry with the same key: returns the SAME result → replayed: true (no second charge)
|
|
39
|
+
|
|
40
|
+
return Response.json(value);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Bring your own store
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { Idempotency, MemoryIdempotencyStore } from "@lacspace/idempotency";
|
|
47
|
+
|
|
48
|
+
const idem = new Idempotency({ store: new MemoryIdempotencyStore(60 * 60 * 1000) });
|
|
49
|
+
// implement { get, create, set, delete } over Redis/KV/SQL for multi-instance apps
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Detect key reuse (different payload, same key)
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { fingerprint } from "@lacspace/idempotency";
|
|
56
|
+
|
|
57
|
+
await idem.run(key, () => createOrder(body), { fingerprint: fingerprint(body) });
|
|
58
|
+
// reusing the key with a different body throws IdempotencyKeyReuseError
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Concurrency
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// Two requests, same key, at the same time:
|
|
65
|
+
const [a, b] = await Promise.all([
|
|
66
|
+
idem.run(key, work),
|
|
67
|
+
idem.run(key, work),
|
|
68
|
+
]);
|
|
69
|
+
// work() runs ONCE; both get the same value. a.replayed=false, b.replayed=true
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Across processes/instances (shared store), a second call finds an in-progress record and either throws `IdempotencyConflictError` (default) or waits for the result with `{ onConflict: "wait" }`.
|
|
73
|
+
|
|
74
|
+
## Behaviour
|
|
75
|
+
|
|
76
|
+
| Situation | Result |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| New key | runs `fn`, stores result, `replayed: false` |
|
|
79
|
+
| Repeat key (completed) | replays stored value, `replayed: true` |
|
|
80
|
+
| `fn` throws | key is cleared → next call retries (unless `cacheErrors: true`) |
|
|
81
|
+
| `cacheErrors: true` + prior failure | replays a `ReplayedError` |
|
|
82
|
+
| Same key in progress (same process) | de-duped — awaits the one execution |
|
|
83
|
+
| Same key in progress (other instance) | `IdempotencyConflictError`, or waits with `onConflict: "wait"` |
|
|
84
|
+
| Same key, different `fingerprint` | `IdempotencyKeyReuseError` |
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
| Export | Description |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `idempotent(key, fn, opts?)` | run at-most-once via a shared in-memory store |
|
|
91
|
+
| `new Idempotency({ store?, cacheErrors? })` | engine bound to a store |
|
|
92
|
+
| `.run(key, fn, opts?)` → `{ value, replayed }` | the core method |
|
|
93
|
+
| `.forget(key)` | clear a key so it can run fresh |
|
|
94
|
+
| `MemoryIdempotencyStore(ttlMs?)` · `IdempotencyStore` | store + interface |
|
|
95
|
+
| `fingerprint(payload)` | stable, order-independent request signature |
|
|
96
|
+
| `IdempotencyConflictError` · `IdempotencyKeyReuseError` · `ReplayedError` | typed errors |
|
|
97
|
+
|
|
98
|
+
## Licensing
|
|
99
|
+
|
|
100
|
+
This package is **free** under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — MIT-equivalent freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
|
|
101
|
+
|
|
102
|
+
Not every Lacspace package is free. We also offer **Commercial** (paid), **Client-specific**, and **Private** (proprietary) packages under separate terms. See the full **[Lacspace Licence Centre](https://lacspace.com/licenses)**.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
<div align="center">
|
|
107
|
+
|
|
108
|
+
**Part of the Lacspace ecosystem — zero-dependency, isomorphic TypeScript packages.**
|
|
109
|
+
|
|
110
|
+
[All packages ↗](https://lacspace.com/packages) · [npm org ↗](https://www.npmjs.com/org/lacspace) · [Licence Centre ↗](https://lacspace.com/licenses) · [GitHub ↗](https://github.com/lacspace/npm-packages)
|
|
111
|
+
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div align="center"><sub>Built with care by <a href="https://lacspace.com">Lacspace</a> · Lacspace Free Licence · <a href="https://github.com/lacspace/npm-packages">source</a></sub></div>
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var IdempotencyConflictError = class extends Error {
|
|
5
|
+
constructor(key) {
|
|
6
|
+
super(`An operation for idempotency key "${key}" is already in progress.`);
|
|
7
|
+
this.key = key;
|
|
8
|
+
this.code = "conflict";
|
|
9
|
+
this.name = "IdempotencyConflictError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var IdempotencyKeyReuseError = class extends Error {
|
|
13
|
+
constructor(key) {
|
|
14
|
+
super(`Idempotency key "${key}" was reused with a different request payload.`);
|
|
15
|
+
this.key = key;
|
|
16
|
+
this.code = "key-reuse";
|
|
17
|
+
this.name = "IdempotencyKeyReuseError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var ReplayedError = class extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = "replayed-error";
|
|
24
|
+
this.name = "ReplayedError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var MemoryIdempotencyStore = class {
|
|
28
|
+
constructor(ttlMs = 24 * 60 * 60 * 1e3) {
|
|
29
|
+
this.ttlMs = ttlMs;
|
|
30
|
+
this.map = /* @__PURE__ */ new Map();
|
|
31
|
+
}
|
|
32
|
+
get(key) {
|
|
33
|
+
const e = this.map.get(key);
|
|
34
|
+
if (!e) return void 0;
|
|
35
|
+
if (Date.now() > e.exp) {
|
|
36
|
+
this.map.delete(key);
|
|
37
|
+
return void 0;
|
|
38
|
+
}
|
|
39
|
+
return e.rec;
|
|
40
|
+
}
|
|
41
|
+
create(key, record) {
|
|
42
|
+
if (this.get(key)) return false;
|
|
43
|
+
this.map.set(key, { rec: record, exp: Date.now() + this.ttlMs });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
set(key, record) {
|
|
47
|
+
this.map.set(key, { rec: record, exp: Date.now() + this.ttlMs });
|
|
48
|
+
}
|
|
49
|
+
delete(key) {
|
|
50
|
+
this.map.delete(key);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
function stableStringify(value) {
|
|
54
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
55
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
56
|
+
const keys = Object.keys(value).sort();
|
|
57
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
58
|
+
}
|
|
59
|
+
function fingerprint(value) {
|
|
60
|
+
const s = stableStringify(value);
|
|
61
|
+
let h = 2166136261;
|
|
62
|
+
for (let i = 0; i < s.length; i++) {
|
|
63
|
+
h ^= s.charCodeAt(i);
|
|
64
|
+
h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
|
|
65
|
+
}
|
|
66
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
67
|
+
}
|
|
68
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
69
|
+
var Idempotency = class {
|
|
70
|
+
constructor(opts = {}) {
|
|
71
|
+
this.inflight = /* @__PURE__ */ new Map();
|
|
72
|
+
this.store = opts.store ?? new MemoryIdempotencyStore();
|
|
73
|
+
this.cacheErrors = opts.cacheErrors ?? false;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Run `fn` at most once for `key`. A repeat call replays the stored result.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* const { value, replayed } = await idem.run(idempotencyKey, () => charge(order));
|
|
80
|
+
*/
|
|
81
|
+
async run(key, fn, opts = {}) {
|
|
82
|
+
const flying = this.inflight.get(key);
|
|
83
|
+
if (flying) return { value: (await flying).value, replayed: true };
|
|
84
|
+
const store = opts.store ?? this.store;
|
|
85
|
+
const cacheErrors = opts.cacheErrors ?? this.cacheErrors;
|
|
86
|
+
const now = opts.now ?? Date.now();
|
|
87
|
+
const record = { status: "in-progress", fingerprint: opts.fingerprint, createdAt: now };
|
|
88
|
+
const exec = (async () => {
|
|
89
|
+
const existing = await store.get(key);
|
|
90
|
+
if (existing) {
|
|
91
|
+
if (opts.fingerprint && existing.fingerprint && existing.fingerprint !== opts.fingerprint) {
|
|
92
|
+
throw new IdempotencyKeyReuseError(key);
|
|
93
|
+
}
|
|
94
|
+
if (existing.status === "completed") return { value: existing.value, fresh: false };
|
|
95
|
+
if (existing.status === "failed") {
|
|
96
|
+
if (cacheErrors) throw new ReplayedError(existing.error ?? "Operation previously failed.");
|
|
97
|
+
await store.delete(key);
|
|
98
|
+
} else {
|
|
99
|
+
if ((opts.onConflict ?? "throw") === "wait") return { value: await this.waitFor(store, key, opts), fresh: false };
|
|
100
|
+
throw new IdempotencyConflictError(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const created = await store.create(key, record);
|
|
104
|
+
if (!created) {
|
|
105
|
+
if ((opts.onConflict ?? "throw") === "wait") return { value: await this.waitFor(store, key, opts), fresh: false };
|
|
106
|
+
throw new IdempotencyConflictError(key);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const value = await fn();
|
|
110
|
+
await store.set(key, { status: "completed", value, fingerprint: opts.fingerprint, createdAt: record.createdAt, completedAt: Date.now() });
|
|
111
|
+
return { value, fresh: true };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
if (cacheErrors) await store.set(key, { status: "failed", error: message, fingerprint: opts.fingerprint, createdAt: record.createdAt, completedAt: Date.now() });
|
|
115
|
+
else await store.delete(key);
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
})().finally(() => this.inflight.delete(key));
|
|
119
|
+
this.inflight.set(key, exec);
|
|
120
|
+
const out = await exec;
|
|
121
|
+
return { value: out.value, replayed: !out.fresh };
|
|
122
|
+
}
|
|
123
|
+
async waitFor(store, key, opts) {
|
|
124
|
+
const interval = opts.pollIntervalMs ?? 50;
|
|
125
|
+
const timeout = opts.waitTimeoutMs ?? 1e4;
|
|
126
|
+
const start = Date.now();
|
|
127
|
+
for (; ; ) {
|
|
128
|
+
const rec = await store.get(key);
|
|
129
|
+
if (!rec || rec.status === "completed") {
|
|
130
|
+
if (rec?.status === "completed") return rec.value;
|
|
131
|
+
throw new IdempotencyConflictError(key);
|
|
132
|
+
}
|
|
133
|
+
if (rec.status === "failed") throw new ReplayedError(rec.error ?? "Operation failed.");
|
|
134
|
+
if (Date.now() - start > timeout) throw new IdempotencyConflictError(key);
|
|
135
|
+
await sleep(interval);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Forget a key so its operation can run fresh again. */
|
|
139
|
+
async forget(key, store) {
|
|
140
|
+
await (store ?? this.store).delete(key);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
var shared = new Idempotency();
|
|
144
|
+
function idempotent(key, fn, opts) {
|
|
145
|
+
return shared.run(key, fn, opts);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
exports.Idempotency = Idempotency;
|
|
149
|
+
exports.IdempotencyConflictError = IdempotencyConflictError;
|
|
150
|
+
exports.IdempotencyKeyReuseError = IdempotencyKeyReuseError;
|
|
151
|
+
exports.MemoryIdempotencyStore = MemoryIdempotencyStore;
|
|
152
|
+
exports.ReplayedError = ReplayedError;
|
|
153
|
+
exports.fingerprint = fingerprint;
|
|
154
|
+
exports.idempotent = idempotent;
|
|
155
|
+
//# sourceMappingURL=index.cjs.map
|
|
156
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/idempotency\n *\n * Make any operation exactly-once with an idempotency key — the \"run this at\n * most once, and replay the stored result on retries\" pattern that payment APIs\n * and webhook handlers need. Framework-agnostic (every existing lib is locked to\n * Hono / AWS Lambda), zero-dependency, isomorphic.\n *\n * - Replay the cached result for a repeated key (never double-charge / double-send)\n * - Safe under concurrency: in-flight de-dupe in-process, atomic \"create-if-absent\"\n * for shared stores, and a conflict/wait policy for the rest\n * - Optional request fingerprint → detect a key reused with a different payload\n * - Pluggable store (in-memory built in; bring your own Redis/KV/SQL)\n */\n\nexport type RecordStatus = \"in-progress\" | \"completed\" | \"failed\";\n\nexport interface IdempotencyRecord<T = unknown> {\n status: RecordStatus;\n value?: T;\n error?: string;\n /** Optional request signature to detect key reuse with different params. */\n fingerprint?: string;\n createdAt: number;\n completedAt?: number;\n}\n\nexport interface IdempotencyStore {\n get(key: string): IdempotencyRecord | undefined | Promise<IdempotencyRecord | undefined>;\n /** Atomically create an in-progress record only if the key is absent. Returns true when created. */\n create(key: string, record: IdempotencyRecord): boolean | Promise<boolean>;\n set(key: string, record: IdempotencyRecord): void | Promise<void>;\n delete(key: string): void | Promise<void>;\n}\n\n/* ------------------------------ errors ------------------------------ */\n\nexport class IdempotencyConflictError extends Error {\n readonly code = \"conflict\";\n constructor(public readonly key: string) {\n super(`An operation for idempotency key \"${key}\" is already in progress.`);\n this.name = \"IdempotencyConflictError\";\n }\n}\n\nexport class IdempotencyKeyReuseError extends Error {\n readonly code = \"key-reuse\";\n constructor(public readonly key: string) {\n super(`Idempotency key \"${key}\" was reused with a different request payload.`);\n this.name = \"IdempotencyKeyReuseError\";\n }\n}\n\nexport class ReplayedError extends Error {\n readonly code = \"replayed-error\";\n constructor(message: string) {\n super(message);\n this.name = \"ReplayedError\";\n }\n}\n\n/* ------------------------------ in-memory store ------------------------------ */\n\nexport class MemoryIdempotencyStore implements IdempotencyStore {\n private readonly map = new Map<string, { rec: IdempotencyRecord; exp: number }>();\n constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) {}\n\n get(key: string): IdempotencyRecord | undefined {\n const e = this.map.get(key);\n if (!e) return undefined;\n if (Date.now() > e.exp) { this.map.delete(key); return undefined; }\n return e.rec;\n }\n create(key: string, record: IdempotencyRecord): boolean {\n if (this.get(key)) return false;\n this.map.set(key, { rec: record, exp: Date.now() + this.ttlMs });\n return true;\n }\n set(key: string, record: IdempotencyRecord): void {\n this.map.set(key, { rec: record, exp: Date.now() + this.ttlMs });\n }\n delete(key: string): void {\n this.map.delete(key);\n }\n}\n\n/* ------------------------------ fingerprint ------------------------------ */\n\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== \"object\") return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(\",\")}]`;\n const keys = Object.keys(value as Record<string, unknown>).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(\",\")}}`;\n}\n\n/** Stable fingerprint of a request payload (order-independent). Pass it as `fingerprint`. */\nexport function fingerprint(value: unknown): string {\n const s = stableStringify(value);\n let h = 0x811c9dc5;\n for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; }\n return (h >>> 0).toString(16).padStart(8, \"0\");\n}\n\n/* ------------------------------ options & result ------------------------------ */\n\nexport interface RunOptions {\n store?: IdempotencyStore;\n /** Request signature; a mismatch on the same key throws {@link IdempotencyKeyReuseError}. */\n fingerprint?: string;\n /** What to do when another call for the key is in progress. Default \"throw\". */\n onConflict?: \"throw\" | \"wait\";\n /** Cache failures too (replay the error). Default false → failures are retryable. */\n cacheErrors?: boolean;\n /** Poll interval when waiting (ms). Default 50. */\n pollIntervalMs?: number;\n /** Max time to wait for an in-progress op (ms). Default 10_000. */\n waitTimeoutMs?: number;\n /** Override \"now\" (ms) — for tests. */\n now?: number;\n}\n\nexport interface RunResult<T> {\n value: T;\n /** True when the result came from a previous run (a replay), not a fresh execution. */\n replayed: boolean;\n}\n\nconst sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));\n\n/* ------------------------------ the engine ------------------------------ */\n\nexport class Idempotency {\n private readonly store: IdempotencyStore;\n private readonly cacheErrors: boolean;\n private readonly inflight = new Map<string, Promise<unknown>>();\n\n constructor(opts: { store?: IdempotencyStore; cacheErrors?: boolean } = {}) {\n this.store = opts.store ?? new MemoryIdempotencyStore();\n this.cacheErrors = opts.cacheErrors ?? false;\n }\n\n /**\n * Run `fn` at most once for `key`. A repeat call replays the stored result.\n *\n * @example\n * const { value, replayed } = await idem.run(idempotencyKey, () => charge(order));\n */\n async run<T>(key: string, fn: () => Promise<T> | T, opts: RunOptions = {}): Promise<RunResult<T>> {\n // Same-process single-flight: concurrent duplicates await the same execution.\n const flying = this.inflight.get(key) as Promise<{ value: T; fresh: boolean }> | undefined;\n if (flying) return { value: (await flying).value, replayed: true };\n\n const store = opts.store ?? this.store;\n const cacheErrors = opts.cacheErrors ?? this.cacheErrors;\n const now = opts.now ?? Date.now();\n const record: IdempotencyRecord = { status: \"in-progress\", fingerprint: opts.fingerprint, createdAt: now };\n\n // Register the in-flight promise SYNCHRONOUSLY, before any await, so\n // truly-concurrent callers see it and don't race on create().\n const exec = (async (): Promise<{ value: T; fresh: boolean }> => {\n const existing = await store.get(key);\n if (existing) {\n if (opts.fingerprint && existing.fingerprint && existing.fingerprint !== opts.fingerprint) {\n throw new IdempotencyKeyReuseError(key);\n }\n if (existing.status === \"completed\") return { value: existing.value as T, fresh: false };\n if (existing.status === \"failed\") {\n if (cacheErrors) throw new ReplayedError(existing.error ?? \"Operation previously failed.\");\n await store.delete(key); // retryable → clear and re-run below\n } else {\n if ((opts.onConflict ?? \"throw\") === \"wait\") return { value: await this.waitFor<T>(store, key, opts), fresh: false };\n throw new IdempotencyConflictError(key);\n }\n }\n const created = await store.create(key, record);\n if (!created) {\n if ((opts.onConflict ?? \"throw\") === \"wait\") return { value: await this.waitFor<T>(store, key, opts), fresh: false };\n throw new IdempotencyConflictError(key);\n }\n try {\n const value = await fn();\n await store.set(key, { status: \"completed\", value, fingerprint: opts.fingerprint, createdAt: record.createdAt, completedAt: Date.now() });\n return { value, fresh: true };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n if (cacheErrors) await store.set(key, { status: \"failed\", error: message, fingerprint: opts.fingerprint, createdAt: record.createdAt, completedAt: Date.now() });\n else await store.delete(key);\n throw err;\n }\n })().finally(() => this.inflight.delete(key));\n\n this.inflight.set(key, exec);\n const out = await exec;\n return { value: out.value, replayed: !out.fresh };\n }\n\n private async waitFor<T>(store: IdempotencyStore, key: string, opts: RunOptions): Promise<T> {\n const interval = opts.pollIntervalMs ?? 50;\n const timeout = opts.waitTimeoutMs ?? 10_000;\n const start = Date.now();\n for (;;) {\n const rec = await store.get(key);\n if (!rec || rec.status === \"completed\") {\n if (rec?.status === \"completed\") return rec.value as T;\n throw new IdempotencyConflictError(key); // vanished mid-wait\n }\n if (rec.status === \"failed\") throw new ReplayedError(rec.error ?? \"Operation failed.\");\n if (Date.now() - start > timeout) throw new IdempotencyConflictError(key);\n await sleep(interval);\n }\n }\n\n /** Forget a key so its operation can run fresh again. */\n async forget(key: string, store?: IdempotencyStore): Promise<void> {\n await (store ?? this.store).delete(key);\n }\n}\n\n/* ------------------------------ functional default ------------------------------ */\n\nconst shared = new Idempotency();\n\n/**\n * Run `fn` at most once for `key`, using a shared in-memory store (pass\n * `opts.store` for your own). Returns `{ value, replayed }`.\n *\n * @example\n * const { value } = await idempotent(req.headers[\"idempotency-key\"], () => createOrder(body));\n */\nexport function idempotent<T>(key: string, fn: () => Promise<T> | T, opts?: RunOptions): Promise<RunResult<T>> {\n return shared.run(key, fn, opts);\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lacspace/idempotency
|
|
3
|
+
*
|
|
4
|
+
* Make any operation exactly-once with an idempotency key — the "run this at
|
|
5
|
+
* most once, and replay the stored result on retries" pattern that payment APIs
|
|
6
|
+
* and webhook handlers need. Framework-agnostic (every existing lib is locked to
|
|
7
|
+
* Hono / AWS Lambda), zero-dependency, isomorphic.
|
|
8
|
+
*
|
|
9
|
+
* - Replay the cached result for a repeated key (never double-charge / double-send)
|
|
10
|
+
* - Safe under concurrency: in-flight de-dupe in-process, atomic "create-if-absent"
|
|
11
|
+
* for shared stores, and a conflict/wait policy for the rest
|
|
12
|
+
* - Optional request fingerprint → detect a key reused with a different payload
|
|
13
|
+
* - Pluggable store (in-memory built in; bring your own Redis/KV/SQL)
|
|
14
|
+
*/
|
|
15
|
+
type RecordStatus = "in-progress" | "completed" | "failed";
|
|
16
|
+
interface IdempotencyRecord<T = unknown> {
|
|
17
|
+
status: RecordStatus;
|
|
18
|
+
value?: T;
|
|
19
|
+
error?: string;
|
|
20
|
+
/** Optional request signature to detect key reuse with different params. */
|
|
21
|
+
fingerprint?: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
completedAt?: number;
|
|
24
|
+
}
|
|
25
|
+
interface IdempotencyStore {
|
|
26
|
+
get(key: string): IdempotencyRecord | undefined | Promise<IdempotencyRecord | undefined>;
|
|
27
|
+
/** Atomically create an in-progress record only if the key is absent. Returns true when created. */
|
|
28
|
+
create(key: string, record: IdempotencyRecord): boolean | Promise<boolean>;
|
|
29
|
+
set(key: string, record: IdempotencyRecord): void | Promise<void>;
|
|
30
|
+
delete(key: string): void | Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
declare class IdempotencyConflictError extends Error {
|
|
33
|
+
readonly key: string;
|
|
34
|
+
readonly code = "conflict";
|
|
35
|
+
constructor(key: string);
|
|
36
|
+
}
|
|
37
|
+
declare class IdempotencyKeyReuseError extends Error {
|
|
38
|
+
readonly key: string;
|
|
39
|
+
readonly code = "key-reuse";
|
|
40
|
+
constructor(key: string);
|
|
41
|
+
}
|
|
42
|
+
declare class ReplayedError extends Error {
|
|
43
|
+
readonly code = "replayed-error";
|
|
44
|
+
constructor(message: string);
|
|
45
|
+
}
|
|
46
|
+
declare class MemoryIdempotencyStore implements IdempotencyStore {
|
|
47
|
+
private readonly ttlMs;
|
|
48
|
+
private readonly map;
|
|
49
|
+
constructor(ttlMs?: number);
|
|
50
|
+
get(key: string): IdempotencyRecord | undefined;
|
|
51
|
+
create(key: string, record: IdempotencyRecord): boolean;
|
|
52
|
+
set(key: string, record: IdempotencyRecord): void;
|
|
53
|
+
delete(key: string): void;
|
|
54
|
+
}
|
|
55
|
+
/** Stable fingerprint of a request payload (order-independent). Pass it as `fingerprint`. */
|
|
56
|
+
declare function fingerprint(value: unknown): string;
|
|
57
|
+
interface RunOptions {
|
|
58
|
+
store?: IdempotencyStore;
|
|
59
|
+
/** Request signature; a mismatch on the same key throws {@link IdempotencyKeyReuseError}. */
|
|
60
|
+
fingerprint?: string;
|
|
61
|
+
/** What to do when another call for the key is in progress. Default "throw". */
|
|
62
|
+
onConflict?: "throw" | "wait";
|
|
63
|
+
/** Cache failures too (replay the error). Default false → failures are retryable. */
|
|
64
|
+
cacheErrors?: boolean;
|
|
65
|
+
/** Poll interval when waiting (ms). Default 50. */
|
|
66
|
+
pollIntervalMs?: number;
|
|
67
|
+
/** Max time to wait for an in-progress op (ms). Default 10_000. */
|
|
68
|
+
waitTimeoutMs?: number;
|
|
69
|
+
/** Override "now" (ms) — for tests. */
|
|
70
|
+
now?: number;
|
|
71
|
+
}
|
|
72
|
+
interface RunResult<T> {
|
|
73
|
+
value: T;
|
|
74
|
+
/** True when the result came from a previous run (a replay), not a fresh execution. */
|
|
75
|
+
replayed: boolean;
|
|
76
|
+
}
|
|
77
|
+
declare class Idempotency {
|
|
78
|
+
private readonly store;
|
|
79
|
+
private readonly cacheErrors;
|
|
80
|
+
private readonly inflight;
|
|
81
|
+
constructor(opts?: {
|
|
82
|
+
store?: IdempotencyStore;
|
|
83
|
+
cacheErrors?: boolean;
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* Run `fn` at most once for `key`. A repeat call replays the stored result.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* const { value, replayed } = await idem.run(idempotencyKey, () => charge(order));
|
|
90
|
+
*/
|
|
91
|
+
run<T>(key: string, fn: () => Promise<T> | T, opts?: RunOptions): Promise<RunResult<T>>;
|
|
92
|
+
private waitFor;
|
|
93
|
+
/** Forget a key so its operation can run fresh again. */
|
|
94
|
+
forget(key: string, store?: IdempotencyStore): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Run `fn` at most once for `key`, using a shared in-memory store (pass
|
|
98
|
+
* `opts.store` for your own). Returns `{ value, replayed }`.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const { value } = await idempotent(req.headers["idempotency-key"], () => createOrder(body));
|
|
102
|
+
*/
|
|
103
|
+
declare function idempotent<T>(key: string, fn: () => Promise<T> | T, opts?: RunOptions): Promise<RunResult<T>>;
|
|
104
|
+
|
|
105
|
+
export { Idempotency, IdempotencyConflictError, IdempotencyKeyReuseError, type IdempotencyRecord, type IdempotencyStore, MemoryIdempotencyStore, type RecordStatus, ReplayedError, type RunOptions, type RunResult, fingerprint, idempotent };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lacspace/idempotency
|
|
3
|
+
*
|
|
4
|
+
* Make any operation exactly-once with an idempotency key — the "run this at
|
|
5
|
+
* most once, and replay the stored result on retries" pattern that payment APIs
|
|
6
|
+
* and webhook handlers need. Framework-agnostic (every existing lib is locked to
|
|
7
|
+
* Hono / AWS Lambda), zero-dependency, isomorphic.
|
|
8
|
+
*
|
|
9
|
+
* - Replay the cached result for a repeated key (never double-charge / double-send)
|
|
10
|
+
* - Safe under concurrency: in-flight de-dupe in-process, atomic "create-if-absent"
|
|
11
|
+
* for shared stores, and a conflict/wait policy for the rest
|
|
12
|
+
* - Optional request fingerprint → detect a key reused with a different payload
|
|
13
|
+
* - Pluggable store (in-memory built in; bring your own Redis/KV/SQL)
|
|
14
|
+
*/
|
|
15
|
+
type RecordStatus = "in-progress" | "completed" | "failed";
|
|
16
|
+
interface IdempotencyRecord<T = unknown> {
|
|
17
|
+
status: RecordStatus;
|
|
18
|
+
value?: T;
|
|
19
|
+
error?: string;
|
|
20
|
+
/** Optional request signature to detect key reuse with different params. */
|
|
21
|
+
fingerprint?: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
completedAt?: number;
|
|
24
|
+
}
|
|
25
|
+
interface IdempotencyStore {
|
|
26
|
+
get(key: string): IdempotencyRecord | undefined | Promise<IdempotencyRecord | undefined>;
|
|
27
|
+
/** Atomically create an in-progress record only if the key is absent. Returns true when created. */
|
|
28
|
+
create(key: string, record: IdempotencyRecord): boolean | Promise<boolean>;
|
|
29
|
+
set(key: string, record: IdempotencyRecord): void | Promise<void>;
|
|
30
|
+
delete(key: string): void | Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
declare class IdempotencyConflictError extends Error {
|
|
33
|
+
readonly key: string;
|
|
34
|
+
readonly code = "conflict";
|
|
35
|
+
constructor(key: string);
|
|
36
|
+
}
|
|
37
|
+
declare class IdempotencyKeyReuseError extends Error {
|
|
38
|
+
readonly key: string;
|
|
39
|
+
readonly code = "key-reuse";
|
|
40
|
+
constructor(key: string);
|
|
41
|
+
}
|
|
42
|
+
declare class ReplayedError extends Error {
|
|
43
|
+
readonly code = "replayed-error";
|
|
44
|
+
constructor(message: string);
|
|
45
|
+
}
|
|
46
|
+
declare class MemoryIdempotencyStore implements IdempotencyStore {
|
|
47
|
+
private readonly ttlMs;
|
|
48
|
+
private readonly map;
|
|
49
|
+
constructor(ttlMs?: number);
|
|
50
|
+
get(key: string): IdempotencyRecord | undefined;
|
|
51
|
+
create(key: string, record: IdempotencyRecord): boolean;
|
|
52
|
+
set(key: string, record: IdempotencyRecord): void;
|
|
53
|
+
delete(key: string): void;
|
|
54
|
+
}
|
|
55
|
+
/** Stable fingerprint of a request payload (order-independent). Pass it as `fingerprint`. */
|
|
56
|
+
declare function fingerprint(value: unknown): string;
|
|
57
|
+
interface RunOptions {
|
|
58
|
+
store?: IdempotencyStore;
|
|
59
|
+
/** Request signature; a mismatch on the same key throws {@link IdempotencyKeyReuseError}. */
|
|
60
|
+
fingerprint?: string;
|
|
61
|
+
/** What to do when another call for the key is in progress. Default "throw". */
|
|
62
|
+
onConflict?: "throw" | "wait";
|
|
63
|
+
/** Cache failures too (replay the error). Default false → failures are retryable. */
|
|
64
|
+
cacheErrors?: boolean;
|
|
65
|
+
/** Poll interval when waiting (ms). Default 50. */
|
|
66
|
+
pollIntervalMs?: number;
|
|
67
|
+
/** Max time to wait for an in-progress op (ms). Default 10_000. */
|
|
68
|
+
waitTimeoutMs?: number;
|
|
69
|
+
/** Override "now" (ms) — for tests. */
|
|
70
|
+
now?: number;
|
|
71
|
+
}
|
|
72
|
+
interface RunResult<T> {
|
|
73
|
+
value: T;
|
|
74
|
+
/** True when the result came from a previous run (a replay), not a fresh execution. */
|
|
75
|
+
replayed: boolean;
|
|
76
|
+
}
|
|
77
|
+
declare class Idempotency {
|
|
78
|
+
private readonly store;
|
|
79
|
+
private readonly cacheErrors;
|
|
80
|
+
private readonly inflight;
|
|
81
|
+
constructor(opts?: {
|
|
82
|
+
store?: IdempotencyStore;
|
|
83
|
+
cacheErrors?: boolean;
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* Run `fn` at most once for `key`. A repeat call replays the stored result.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* const { value, replayed } = await idem.run(idempotencyKey, () => charge(order));
|
|
90
|
+
*/
|
|
91
|
+
run<T>(key: string, fn: () => Promise<T> | T, opts?: RunOptions): Promise<RunResult<T>>;
|
|
92
|
+
private waitFor;
|
|
93
|
+
/** Forget a key so its operation can run fresh again. */
|
|
94
|
+
forget(key: string, store?: IdempotencyStore): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Run `fn` at most once for `key`, using a shared in-memory store (pass
|
|
98
|
+
* `opts.store` for your own). Returns `{ value, replayed }`.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const { value } = await idempotent(req.headers["idempotency-key"], () => createOrder(body));
|
|
102
|
+
*/
|
|
103
|
+
declare function idempotent<T>(key: string, fn: () => Promise<T> | T, opts?: RunOptions): Promise<RunResult<T>>;
|
|
104
|
+
|
|
105
|
+
export { Idempotency, IdempotencyConflictError, IdempotencyKeyReuseError, type IdempotencyRecord, type IdempotencyStore, MemoryIdempotencyStore, type RecordStatus, ReplayedError, type RunOptions, type RunResult, fingerprint, idempotent };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var IdempotencyConflictError = class extends Error {
|
|
3
|
+
constructor(key) {
|
|
4
|
+
super(`An operation for idempotency key "${key}" is already in progress.`);
|
|
5
|
+
this.key = key;
|
|
6
|
+
this.code = "conflict";
|
|
7
|
+
this.name = "IdempotencyConflictError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var IdempotencyKeyReuseError = class extends Error {
|
|
11
|
+
constructor(key) {
|
|
12
|
+
super(`Idempotency key "${key}" was reused with a different request payload.`);
|
|
13
|
+
this.key = key;
|
|
14
|
+
this.code = "key-reuse";
|
|
15
|
+
this.name = "IdempotencyKeyReuseError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var ReplayedError = class extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = "replayed-error";
|
|
22
|
+
this.name = "ReplayedError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var MemoryIdempotencyStore = class {
|
|
26
|
+
constructor(ttlMs = 24 * 60 * 60 * 1e3) {
|
|
27
|
+
this.ttlMs = ttlMs;
|
|
28
|
+
this.map = /* @__PURE__ */ new Map();
|
|
29
|
+
}
|
|
30
|
+
get(key) {
|
|
31
|
+
const e = this.map.get(key);
|
|
32
|
+
if (!e) return void 0;
|
|
33
|
+
if (Date.now() > e.exp) {
|
|
34
|
+
this.map.delete(key);
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
return e.rec;
|
|
38
|
+
}
|
|
39
|
+
create(key, record) {
|
|
40
|
+
if (this.get(key)) return false;
|
|
41
|
+
this.map.set(key, { rec: record, exp: Date.now() + this.ttlMs });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
set(key, record) {
|
|
45
|
+
this.map.set(key, { rec: record, exp: Date.now() + this.ttlMs });
|
|
46
|
+
}
|
|
47
|
+
delete(key) {
|
|
48
|
+
this.map.delete(key);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
function stableStringify(value) {
|
|
52
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
53
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
54
|
+
const keys = Object.keys(value).sort();
|
|
55
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
56
|
+
}
|
|
57
|
+
function fingerprint(value) {
|
|
58
|
+
const s = stableStringify(value);
|
|
59
|
+
let h = 2166136261;
|
|
60
|
+
for (let i = 0; i < s.length; i++) {
|
|
61
|
+
h ^= s.charCodeAt(i);
|
|
62
|
+
h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
|
|
63
|
+
}
|
|
64
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
65
|
+
}
|
|
66
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
67
|
+
var Idempotency = class {
|
|
68
|
+
constructor(opts = {}) {
|
|
69
|
+
this.inflight = /* @__PURE__ */ new Map();
|
|
70
|
+
this.store = opts.store ?? new MemoryIdempotencyStore();
|
|
71
|
+
this.cacheErrors = opts.cacheErrors ?? false;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run `fn` at most once for `key`. A repeat call replays the stored result.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* const { value, replayed } = await idem.run(idempotencyKey, () => charge(order));
|
|
78
|
+
*/
|
|
79
|
+
async run(key, fn, opts = {}) {
|
|
80
|
+
const flying = this.inflight.get(key);
|
|
81
|
+
if (flying) return { value: (await flying).value, replayed: true };
|
|
82
|
+
const store = opts.store ?? this.store;
|
|
83
|
+
const cacheErrors = opts.cacheErrors ?? this.cacheErrors;
|
|
84
|
+
const now = opts.now ?? Date.now();
|
|
85
|
+
const record = { status: "in-progress", fingerprint: opts.fingerprint, createdAt: now };
|
|
86
|
+
const exec = (async () => {
|
|
87
|
+
const existing = await store.get(key);
|
|
88
|
+
if (existing) {
|
|
89
|
+
if (opts.fingerprint && existing.fingerprint && existing.fingerprint !== opts.fingerprint) {
|
|
90
|
+
throw new IdempotencyKeyReuseError(key);
|
|
91
|
+
}
|
|
92
|
+
if (existing.status === "completed") return { value: existing.value, fresh: false };
|
|
93
|
+
if (existing.status === "failed") {
|
|
94
|
+
if (cacheErrors) throw new ReplayedError(existing.error ?? "Operation previously failed.");
|
|
95
|
+
await store.delete(key);
|
|
96
|
+
} else {
|
|
97
|
+
if ((opts.onConflict ?? "throw") === "wait") return { value: await this.waitFor(store, key, opts), fresh: false };
|
|
98
|
+
throw new IdempotencyConflictError(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const created = await store.create(key, record);
|
|
102
|
+
if (!created) {
|
|
103
|
+
if ((opts.onConflict ?? "throw") === "wait") return { value: await this.waitFor(store, key, opts), fresh: false };
|
|
104
|
+
throw new IdempotencyConflictError(key);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const value = await fn();
|
|
108
|
+
await store.set(key, { status: "completed", value, fingerprint: opts.fingerprint, createdAt: record.createdAt, completedAt: Date.now() });
|
|
109
|
+
return { value, fresh: true };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
if (cacheErrors) await store.set(key, { status: "failed", error: message, fingerprint: opts.fingerprint, createdAt: record.createdAt, completedAt: Date.now() });
|
|
113
|
+
else await store.delete(key);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
})().finally(() => this.inflight.delete(key));
|
|
117
|
+
this.inflight.set(key, exec);
|
|
118
|
+
const out = await exec;
|
|
119
|
+
return { value: out.value, replayed: !out.fresh };
|
|
120
|
+
}
|
|
121
|
+
async waitFor(store, key, opts) {
|
|
122
|
+
const interval = opts.pollIntervalMs ?? 50;
|
|
123
|
+
const timeout = opts.waitTimeoutMs ?? 1e4;
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
for (; ; ) {
|
|
126
|
+
const rec = await store.get(key);
|
|
127
|
+
if (!rec || rec.status === "completed") {
|
|
128
|
+
if (rec?.status === "completed") return rec.value;
|
|
129
|
+
throw new IdempotencyConflictError(key);
|
|
130
|
+
}
|
|
131
|
+
if (rec.status === "failed") throw new ReplayedError(rec.error ?? "Operation failed.");
|
|
132
|
+
if (Date.now() - start > timeout) throw new IdempotencyConflictError(key);
|
|
133
|
+
await sleep(interval);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Forget a key so its operation can run fresh again. */
|
|
137
|
+
async forget(key, store) {
|
|
138
|
+
await (store ?? this.store).delete(key);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
var shared = new Idempotency();
|
|
142
|
+
function idempotent(key, fn, opts) {
|
|
143
|
+
return shared.run(key, fn, opts);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { Idempotency, IdempotencyConflictError, IdempotencyKeyReuseError, MemoryIdempotencyStore, ReplayedError, fingerprint, idempotent };
|
|
147
|
+
//# sourceMappingURL=index.js.map
|
|
148
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @lacspace/idempotency\n *\n * Make any operation exactly-once with an idempotency key — the \"run this at\n * most once, and replay the stored result on retries\" pattern that payment APIs\n * and webhook handlers need. Framework-agnostic (every existing lib is locked to\n * Hono / AWS Lambda), zero-dependency, isomorphic.\n *\n * - Replay the cached result for a repeated key (never double-charge / double-send)\n * - Safe under concurrency: in-flight de-dupe in-process, atomic \"create-if-absent\"\n * for shared stores, and a conflict/wait policy for the rest\n * - Optional request fingerprint → detect a key reused with a different payload\n * - Pluggable store (in-memory built in; bring your own Redis/KV/SQL)\n */\n\nexport type RecordStatus = \"in-progress\" | \"completed\" | \"failed\";\n\nexport interface IdempotencyRecord<T = unknown> {\n status: RecordStatus;\n value?: T;\n error?: string;\n /** Optional request signature to detect key reuse with different params. */\n fingerprint?: string;\n createdAt: number;\n completedAt?: number;\n}\n\nexport interface IdempotencyStore {\n get(key: string): IdempotencyRecord | undefined | Promise<IdempotencyRecord | undefined>;\n /** Atomically create an in-progress record only if the key is absent. Returns true when created. */\n create(key: string, record: IdempotencyRecord): boolean | Promise<boolean>;\n set(key: string, record: IdempotencyRecord): void | Promise<void>;\n delete(key: string): void | Promise<void>;\n}\n\n/* ------------------------------ errors ------------------------------ */\n\nexport class IdempotencyConflictError extends Error {\n readonly code = \"conflict\";\n constructor(public readonly key: string) {\n super(`An operation for idempotency key \"${key}\" is already in progress.`);\n this.name = \"IdempotencyConflictError\";\n }\n}\n\nexport class IdempotencyKeyReuseError extends Error {\n readonly code = \"key-reuse\";\n constructor(public readonly key: string) {\n super(`Idempotency key \"${key}\" was reused with a different request payload.`);\n this.name = \"IdempotencyKeyReuseError\";\n }\n}\n\nexport class ReplayedError extends Error {\n readonly code = \"replayed-error\";\n constructor(message: string) {\n super(message);\n this.name = \"ReplayedError\";\n }\n}\n\n/* ------------------------------ in-memory store ------------------------------ */\n\nexport class MemoryIdempotencyStore implements IdempotencyStore {\n private readonly map = new Map<string, { rec: IdempotencyRecord; exp: number }>();\n constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) {}\n\n get(key: string): IdempotencyRecord | undefined {\n const e = this.map.get(key);\n if (!e) return undefined;\n if (Date.now() > e.exp) { this.map.delete(key); return undefined; }\n return e.rec;\n }\n create(key: string, record: IdempotencyRecord): boolean {\n if (this.get(key)) return false;\n this.map.set(key, { rec: record, exp: Date.now() + this.ttlMs });\n return true;\n }\n set(key: string, record: IdempotencyRecord): void {\n this.map.set(key, { rec: record, exp: Date.now() + this.ttlMs });\n }\n delete(key: string): void {\n this.map.delete(key);\n }\n}\n\n/* ------------------------------ fingerprint ------------------------------ */\n\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== \"object\") return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(\",\")}]`;\n const keys = Object.keys(value as Record<string, unknown>).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(\",\")}}`;\n}\n\n/** Stable fingerprint of a request payload (order-independent). Pass it as `fingerprint`. */\nexport function fingerprint(value: unknown): string {\n const s = stableStringify(value);\n let h = 0x811c9dc5;\n for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; }\n return (h >>> 0).toString(16).padStart(8, \"0\");\n}\n\n/* ------------------------------ options & result ------------------------------ */\n\nexport interface RunOptions {\n store?: IdempotencyStore;\n /** Request signature; a mismatch on the same key throws {@link IdempotencyKeyReuseError}. */\n fingerprint?: string;\n /** What to do when another call for the key is in progress. Default \"throw\". */\n onConflict?: \"throw\" | \"wait\";\n /** Cache failures too (replay the error). Default false → failures are retryable. */\n cacheErrors?: boolean;\n /** Poll interval when waiting (ms). Default 50. */\n pollIntervalMs?: number;\n /** Max time to wait for an in-progress op (ms). Default 10_000. */\n waitTimeoutMs?: number;\n /** Override \"now\" (ms) — for tests. */\n now?: number;\n}\n\nexport interface RunResult<T> {\n value: T;\n /** True when the result came from a previous run (a replay), not a fresh execution. */\n replayed: boolean;\n}\n\nconst sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));\n\n/* ------------------------------ the engine ------------------------------ */\n\nexport class Idempotency {\n private readonly store: IdempotencyStore;\n private readonly cacheErrors: boolean;\n private readonly inflight = new Map<string, Promise<unknown>>();\n\n constructor(opts: { store?: IdempotencyStore; cacheErrors?: boolean } = {}) {\n this.store = opts.store ?? new MemoryIdempotencyStore();\n this.cacheErrors = opts.cacheErrors ?? false;\n }\n\n /**\n * Run `fn` at most once for `key`. A repeat call replays the stored result.\n *\n * @example\n * const { value, replayed } = await idem.run(idempotencyKey, () => charge(order));\n */\n async run<T>(key: string, fn: () => Promise<T> | T, opts: RunOptions = {}): Promise<RunResult<T>> {\n // Same-process single-flight: concurrent duplicates await the same execution.\n const flying = this.inflight.get(key) as Promise<{ value: T; fresh: boolean }> | undefined;\n if (flying) return { value: (await flying).value, replayed: true };\n\n const store = opts.store ?? this.store;\n const cacheErrors = opts.cacheErrors ?? this.cacheErrors;\n const now = opts.now ?? Date.now();\n const record: IdempotencyRecord = { status: \"in-progress\", fingerprint: opts.fingerprint, createdAt: now };\n\n // Register the in-flight promise SYNCHRONOUSLY, before any await, so\n // truly-concurrent callers see it and don't race on create().\n const exec = (async (): Promise<{ value: T; fresh: boolean }> => {\n const existing = await store.get(key);\n if (existing) {\n if (opts.fingerprint && existing.fingerprint && existing.fingerprint !== opts.fingerprint) {\n throw new IdempotencyKeyReuseError(key);\n }\n if (existing.status === \"completed\") return { value: existing.value as T, fresh: false };\n if (existing.status === \"failed\") {\n if (cacheErrors) throw new ReplayedError(existing.error ?? \"Operation previously failed.\");\n await store.delete(key); // retryable → clear and re-run below\n } else {\n if ((opts.onConflict ?? \"throw\") === \"wait\") return { value: await this.waitFor<T>(store, key, opts), fresh: false };\n throw new IdempotencyConflictError(key);\n }\n }\n const created = await store.create(key, record);\n if (!created) {\n if ((opts.onConflict ?? \"throw\") === \"wait\") return { value: await this.waitFor<T>(store, key, opts), fresh: false };\n throw new IdempotencyConflictError(key);\n }\n try {\n const value = await fn();\n await store.set(key, { status: \"completed\", value, fingerprint: opts.fingerprint, createdAt: record.createdAt, completedAt: Date.now() });\n return { value, fresh: true };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n if (cacheErrors) await store.set(key, { status: \"failed\", error: message, fingerprint: opts.fingerprint, createdAt: record.createdAt, completedAt: Date.now() });\n else await store.delete(key);\n throw err;\n }\n })().finally(() => this.inflight.delete(key));\n\n this.inflight.set(key, exec);\n const out = await exec;\n return { value: out.value, replayed: !out.fresh };\n }\n\n private async waitFor<T>(store: IdempotencyStore, key: string, opts: RunOptions): Promise<T> {\n const interval = opts.pollIntervalMs ?? 50;\n const timeout = opts.waitTimeoutMs ?? 10_000;\n const start = Date.now();\n for (;;) {\n const rec = await store.get(key);\n if (!rec || rec.status === \"completed\") {\n if (rec?.status === \"completed\") return rec.value as T;\n throw new IdempotencyConflictError(key); // vanished mid-wait\n }\n if (rec.status === \"failed\") throw new ReplayedError(rec.error ?? \"Operation failed.\");\n if (Date.now() - start > timeout) throw new IdempotencyConflictError(key);\n await sleep(interval);\n }\n }\n\n /** Forget a key so its operation can run fresh again. */\n async forget(key: string, store?: IdempotencyStore): Promise<void> {\n await (store ?? this.store).delete(key);\n }\n}\n\n/* ------------------------------ functional default ------------------------------ */\n\nconst shared = new Idempotency();\n\n/**\n * Run `fn` at most once for `key`, using a shared in-memory store (pass\n * `opts.store` for your own). Returns `{ value, replayed }`.\n *\n * @example\n * const { value } = await idempotent(req.headers[\"idempotency-key\"], () => createOrder(body));\n */\nexport function idempotent<T>(key: string, fn: () => Promise<T> | T, opts?: RunOptions): Promise<RunResult<T>> {\n return shared.run(key, fn, opts);\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lacspace/idempotency",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Make any operation exactly-once with an idempotency key — replay stored results on retries, safe under concurrency, with optional request fingerprinting. Framework-agnostic, pluggable store, zero-dependency, isomorphic.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"idempotency",
|
|
31
|
+
"idempotency-key",
|
|
32
|
+
"idempotent",
|
|
33
|
+
"exactly-once",
|
|
34
|
+
"deduplication",
|
|
35
|
+
"dedupe",
|
|
36
|
+
"retry-safe",
|
|
37
|
+
"payment-safety",
|
|
38
|
+
"webhook-idempotency",
|
|
39
|
+
"stripe-idempotency",
|
|
40
|
+
"concurrency",
|
|
41
|
+
"isomorphic",
|
|
42
|
+
"typescript"
|
|
43
|
+
],
|
|
44
|
+
"author": "Lacspace <contact@lacspace.com>",
|
|
45
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
46
|
+
"homepage": "https://lacspace.com/packages",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/lacspace/npm-packages.git",
|
|
50
|
+
"directory": "idempotency"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/lacspace/npm-packages/issues"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
}
|
|
61
|
+
}
|