@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 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
+ [![npm version](https://img.shields.io/npm/v/@lacspace/idempotency?color=%2316a34a&label=npm)](https://www.npmjs.com/package/@lacspace/idempotency)
8
+ [![install size](https://packagephobia.com/badge?p=@lacspace/idempotency)](https://packagephobia.com/result?p=@lacspace/idempotency)
9
+ [![minzipped](https://img.shields.io/bundlephobia/minzip/@lacspace/idempotency?label=minzip)](https://bundlephobia.com/package/@lacspace/idempotency)
10
+ [![types](https://img.shields.io/badge/types-included-blue)](https://www.npmjs.com/package/@lacspace/idempotency)
11
+ [![license](https://img.shields.io/npm/l/@lacspace/idempotency?color=green)](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":";;;AAqCO,IAAM,wBAAA,GAAN,cAAuC,KAAA,CAAM;AAAA,EAElD,YAA4B,GAAA,EAAa;AACvC,IAAA,KAAA,CAAM,CAAA,kCAAA,EAAqC,GAAG,CAAA,yBAAA,CAA2B,CAAA;AAD/C,IAAA,IAAA,CAAA,GAAA,GAAA,GAAA;AAD5B,IAAA,IAAA,CAAS,IAAA,GAAO,UAAA;AAGd,IAAA,IAAA,CAAK,IAAA,GAAO,0BAAA;AAAA,EACd;AACF;AAEO,IAAM,wBAAA,GAAN,cAAuC,KAAA,CAAM;AAAA,EAElD,YAA4B,GAAA,EAAa;AACvC,IAAA,KAAA,CAAM,CAAA,iBAAA,EAAoB,GAAG,CAAA,8CAAA,CAAgD,CAAA;AADnD,IAAA,IAAA,CAAA,GAAA,GAAA,GAAA;AAD5B,IAAA,IAAA,CAAS,IAAA,GAAO,WAAA;AAGd,IAAA,IAAA,CAAK,IAAA,GAAO,0BAAA;AAAA,EACd;AACF;AAEO,IAAM,aAAA,GAAN,cAA4B,KAAA,CAAM;AAAA,EAEvC,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AAFf,IAAA,IAAA,CAAS,IAAA,GAAO,gBAAA;AAGd,IAAA,IAAA,CAAK,IAAA,GAAO,eAAA;AAAA,EACd;AACF;AAIO,IAAM,yBAAN,MAAyD;AAAA,EAE9D,WAAA,CAA6B,KAAA,GAAQ,EAAA,GAAK,EAAA,GAAK,KAAK,GAAA,EAAM;AAA7B,IAAA,IAAA,CAAA,KAAA,GAAA,KAAA;AAD7B,IAAA,IAAA,CAAiB,GAAA,uBAAU,GAAA,EAAqD;AAAA,EACrB;AAAA,EAE3D,IAAI,GAAA,EAA4C;AAC9C,IAAA,MAAM,CAAA,GAAI,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,GAAG,CAAA;AAC1B,IAAA,IAAI,CAAC,GAAG,OAAO,MAAA;AACf,IAAA,IAAI,IAAA,CAAK,GAAA,EAAI,GAAI,CAAA,CAAE,GAAA,EAAK;AAAE,MAAA,IAAA,CAAK,GAAA,CAAI,OAAO,GAAG,CAAA;AAAG,MAAA,OAAO,MAAA;AAAA,IAAW;AAClE,IAAA,OAAO,CAAA,CAAE,GAAA;AAAA,EACX;AAAA,EACA,MAAA,CAAO,KAAa,MAAA,EAAoC;AACtD,IAAA,IAAI,IAAA,CAAK,GAAA,CAAI,GAAG,CAAA,EAAG,OAAO,KAAA;AAC1B,IAAA,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,GAAA,EAAK,EAAE,GAAA,EAAK,MAAA,EAAQ,GAAA,EAAK,IAAA,CAAK,GAAA,EAAI,GAAI,IAAA,CAAK,KAAA,EAAO,CAAA;AAC/D,IAAA,OAAO,IAAA;AAAA,EACT;AAAA,EACA,GAAA,CAAI,KAAa,MAAA,EAAiC;AAChD,IAAA,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,GAAA,EAAK,EAAE,GAAA,EAAK,MAAA,EAAQ,GAAA,EAAK,IAAA,CAAK,GAAA,EAAI,GAAI,IAAA,CAAK,KAAA,EAAO,CAAA;AAAA,EACjE;AAAA,EACA,OAAO,GAAA,EAAmB;AACxB,IAAA,IAAA,CAAK,GAAA,CAAI,OAAO,GAAG,CAAA;AAAA,EACrB;AACF;AAIA,SAAS,gBAAgB,KAAA,EAAwB;AAC/C,EAAA,IAAI,KAAA,KAAU,QAAQ,OAAO,KAAA,KAAU,UAAU,OAAO,IAAA,CAAK,UAAU,KAAK,CAAA;AAC5E,EAAA,IAAI,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA,EAAG,OAAO,CAAA,CAAA,EAAI,KAAA,CAAM,GAAA,CAAI,eAAe,CAAA,CAAE,IAAA,CAAK,GAAG,CAAC,CAAA,CAAA,CAAA;AACzE,EAAA,MAAM,IAAA,GAAO,MAAA,CAAO,IAAA,CAAK,KAAgC,EAAE,IAAA,EAAK;AAChE,EAAA,OAAO,CAAA,CAAA,EAAI,KAAK,GAAA,CAAI,CAAC,MAAM,CAAA,EAAG,IAAA,CAAK,UAAU,CAAC,CAAC,IAAI,eAAA,CAAiB,KAAA,CAAkC,CAAC,CAAC,CAAC,EAAE,CAAA,CAAE,IAAA,CAAK,GAAG,CAAC,CAAA,CAAA,CAAA;AACxH;AAGO,SAAS,YAAY,KAAA,EAAwB;AAClD,EAAA,MAAM,CAAA,GAAI,gBAAgB,KAAK,CAAA;AAC/B,EAAA,IAAI,CAAA,GAAI,UAAA;AACR,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,CAAE,QAAQ,CAAA,EAAA,EAAK;AAAE,IAAA,CAAA,IAAK,CAAA,CAAE,WAAW,CAAC,CAAA;AAAG,IAAA,CAAA,GAAK,CAAA,IAAA,CAAM,CAAA,IAAK,CAAA,KAAM,CAAA,IAAK,CAAA,CAAA,IAAM,KAAK,CAAA,CAAA,IAAM,CAAA,IAAK,CAAA,CAAA,IAAM,CAAA,IAAK,EAAA,CAAA,CAAA,KAAU,CAAA;AAAA,EAAG;AACpI,EAAA,OAAA,CAAQ,MAAM,CAAA,EAAG,QAAA,CAAS,EAAE,CAAA,CAAE,QAAA,CAAS,GAAG,GAAG,CAAA;AAC/C;AA0BA,IAAM,KAAA,GAAQ,CAAC,EAAA,KAA8B,IAAI,OAAA,CAAQ,CAAC,CAAA,KAAM,UAAA,CAAW,CAAA,EAAG,EAAE,CAAC,CAAA;AAI1E,IAAM,cAAN,MAAkB;AAAA,EAKvB,WAAA,CAAY,IAAA,GAA4D,EAAC,EAAG;AAF5E,IAAA,IAAA,CAAiB,QAAA,uBAAe,GAAA,EAA8B;AAG5D,IAAA,IAAA,CAAK,KAAA,GAAQ,IAAA,CAAK,KAAA,IAAS,IAAI,sBAAA,EAAuB;AACtD,IAAA,IAAA,CAAK,WAAA,GAAc,KAAK,WAAA,IAAe,KAAA;AAAA,EACzC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,GAAA,CAAO,GAAA,EAAa,EAAA,EAA0B,IAAA,GAAmB,EAAC,EAA0B;AAEhG,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,GAAG,CAAA;AACpC,IAAA,IAAI,MAAA,SAAe,EAAE,KAAA,EAAA,CAAQ,MAAM,MAAA,EAAQ,KAAA,EAAO,UAAU,IAAA,EAAK;AAEjE,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,KAAA,IAAS,IAAA,CAAK,KAAA;AACjC,IAAA,MAAM,WAAA,GAAc,IAAA,CAAK,WAAA,IAAe,IAAA,CAAK,WAAA;AAC7C,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,IAAO,IAAA,CAAK,GAAA,EAAI;AACjC,IAAA,MAAM,MAAA,GAA4B,EAAE,MAAA,EAAQ,aAAA,EAAe,aAAa,IAAA,CAAK,WAAA,EAAa,WAAW,GAAA,EAAI;AAIzG,IAAA,MAAM,QAAQ,YAAmD;AAC/D,MAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,GAAA,CAAI,GAAG,CAAA;AACpC,MAAA,IAAI,QAAA,EAAU;AACZ,QAAA,IAAI,KAAK,WAAA,IAAe,QAAA,CAAS,eAAe,QAAA,CAAS,WAAA,KAAgB,KAAK,WAAA,EAAa;AACzF,UAAA,MAAM,IAAI,yBAAyB,GAAG,CAAA;AAAA,QACxC;AACA,QAAA,IAAI,QAAA,CAAS,WAAW,WAAA,EAAa,OAAO,EAAE,KAAA,EAAO,QAAA,CAAS,KAAA,EAAY,KAAA,EAAO,KAAA,EAAM;AACvF,QAAA,IAAI,QAAA,CAAS,WAAW,QAAA,EAAU;AAChC,UAAA,IAAI,aAAa,MAAM,IAAI,aAAA,CAAc,QAAA,CAAS,SAAS,8BAA8B,CAAA;AACzF,UAAA,MAAM,KAAA,CAAM,OAAO,GAAG,CAAA;AAAA,QACxB,CAAA,MAAO;AACL,UAAA,IAAA,CAAK,IAAA,CAAK,UAAA,IAAc,OAAA,MAAa,MAAA,SAAe,EAAE,KAAA,EAAO,MAAM,IAAA,CAAK,QAAW,KAAA,EAAO,GAAA,EAAK,IAAI,CAAA,EAAG,OAAO,KAAA,EAAM;AACnH,UAAA,MAAM,IAAI,yBAAyB,GAAG,CAAA;AAAA,QACxC;AAAA,MACF;AACA,MAAA,MAAM,OAAA,GAAU,MAAM,KAAA,CAAM,MAAA,CAAO,KAAK,MAAM,CAAA;AAC9C,MAAA,IAAI,CAAC,OAAA,EAAS;AACZ,QAAA,IAAA,CAAK,IAAA,CAAK,UAAA,IAAc,OAAA,MAAa,MAAA,SAAe,EAAE,KAAA,EAAO,MAAM,IAAA,CAAK,QAAW,KAAA,EAAO,GAAA,EAAK,IAAI,CAAA,EAAG,OAAO,KAAA,EAAM;AACnH,QAAA,MAAM,IAAI,yBAAyB,GAAG,CAAA;AAAA,MACxC;AACA,MAAA,IAAI;AACF,QAAA,MAAM,KAAA,GAAQ,MAAM,EAAA,EAAG;AACvB,QAAA,MAAM,MAAM,GAAA,CAAI,GAAA,EAAK,EAAE,MAAA,EAAQ,aAAa,KAAA,EAAO,WAAA,EAAa,IAAA,CAAK,WAAA,EAAa,WAAW,MAAA,CAAO,SAAA,EAAW,aAAa,IAAA,CAAK,GAAA,IAAO,CAAA;AACxI,QAAA,OAAO,EAAE,KAAA,EAAO,KAAA,EAAO,IAAA,EAAK;AAAA,MAC9B,SAAS,GAAA,EAAK;AACZ,QAAA,MAAM,UAAU,GAAA,YAAe,KAAA,GAAQ,GAAA,CAAI,OAAA,GAAU,OAAO,GAAG,CAAA;AAC/D,QAAA,IAAI,WAAA,QAAmB,KAAA,CAAM,GAAA,CAAI,KAAK,EAAE,MAAA,EAAQ,UAAU,KAAA,EAAO,OAAA,EAAS,aAAa,IAAA,CAAK,WAAA,EAAa,WAAW,MAAA,CAAO,SAAA,EAAW,aAAa,IAAA,CAAK,GAAA,IAAO,CAAA;AAAA,aAC1J,MAAM,KAAA,CAAM,MAAA,CAAO,GAAG,CAAA;AAC3B,QAAA,MAAM,GAAA;AAAA,MACR;AAAA,IACF,CAAA,IAAK,OAAA,CAAQ,MAAM,KAAK,QAAA,CAAS,MAAA,CAAO,GAAG,CAAC,CAAA;AAE5C,IAAA,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,GAAA,EAAK,IAAI,CAAA;AAC3B,IAAA,MAAM,MAAM,MAAM,IAAA;AAClB,IAAA,OAAO,EAAE,KAAA,EAAO,GAAA,CAAI,OAAO,QAAA,EAAU,CAAC,IAAI,KAAA,EAAM;AAAA,EAClD;AAAA,EAEA,MAAc,OAAA,CAAW,KAAA,EAAyB,GAAA,EAAa,IAAA,EAA8B;AAC3F,IAAA,MAAM,QAAA,GAAW,KAAK,cAAA,IAAkB,EAAA;AACxC,IAAA,MAAM,OAAA,GAAU,KAAK,aAAA,IAAiB,GAAA;AACtC,IAAA,MAAM,KAAA,GAAQ,KAAK,GAAA,EAAI;AACvB,IAAA,WAAS;AACP,MAAA,MAAM,GAAA,GAAM,MAAM,KAAA,CAAM,GAAA,CAAI,GAAG,CAAA;AAC/B,MAAA,IAAI,CAAC,GAAA,IAAO,GAAA,CAAI,MAAA,KAAW,WAAA,EAAa;AACtC,QAAA,IAAI,GAAA,EAAK,MAAA,KAAW,WAAA,EAAa,OAAO,GAAA,CAAI,KAAA;AAC5C,QAAA,MAAM,IAAI,yBAAyB,GAAG,CAAA;AAAA,MACxC;AACA,MAAA,IAAI,GAAA,CAAI,WAAW,QAAA,EAAU,MAAM,IAAI,aAAA,CAAc,GAAA,CAAI,SAAS,mBAAmB,CAAA;AACrF,MAAA,IAAI,IAAA,CAAK,KAAI,GAAI,KAAA,GAAQ,SAAS,MAAM,IAAI,yBAAyB,GAAG,CAAA;AACxE,MAAA,MAAM,MAAM,QAAQ,CAAA;AAAA,IACtB;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,MAAA,CAAO,GAAA,EAAa,KAAA,EAAyC;AACjE,IAAA,MAAA,CAAO,KAAA,IAAS,IAAA,CAAK,KAAA,EAAO,MAAA,CAAO,GAAG,CAAA;AAAA,EACxC;AACF;AAIA,IAAM,MAAA,GAAS,IAAI,WAAA,EAAY;AASxB,SAAS,UAAA,CAAc,GAAA,EAAa,EAAA,EAA0B,IAAA,EAA0C;AAC7G,EAAA,OAAO,MAAA,CAAO,GAAA,CAAI,GAAA,EAAK,EAAA,EAAI,IAAI,CAAA;AACjC","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"]}
@@ -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 };
@@ -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
+ }