@cqrs-ddd/pipeline-idempotency 0.5.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.
Files changed (76) hide show
  1. package/COMMERCIAL_LICENSE.txt +34 -0
  2. package/LICENSE +661 -0
  3. package/README.md +39 -0
  4. package/dist/constants/tokens.d.ts +3 -0
  5. package/dist/constants/tokens.d.ts.map +1 -0
  6. package/dist/constants/tokens.js +4 -0
  7. package/dist/constants/tokens.js.map +1 -0
  8. package/dist/errors/idempotency-completion.error.d.ts +32 -0
  9. package/dist/errors/idempotency-completion.error.d.ts.map +1 -0
  10. package/dist/errors/idempotency-completion.error.js +33 -0
  11. package/dist/errors/idempotency-completion.error.js.map +1 -0
  12. package/dist/errors/idempotency-conflict.error.d.ts +33 -0
  13. package/dist/errors/idempotency-conflict.error.d.ts.map +1 -0
  14. package/dist/errors/idempotency-conflict.error.js +48 -0
  15. package/dist/errors/idempotency-conflict.error.js.map +1 -0
  16. package/dist/errors/missing-partition.error.d.ts +17 -0
  17. package/dist/errors/missing-partition.error.d.ts.map +1 -0
  18. package/dist/errors/missing-partition.error.js +18 -0
  19. package/dist/errors/missing-partition.error.js.map +1 -0
  20. package/dist/helpers/build-attributes.d.ts +47 -0
  21. package/dist/helpers/build-attributes.d.ts.map +1 -0
  22. package/dist/helpers/build-attributes.js +59 -0
  23. package/dist/helpers/build-attributes.js.map +1 -0
  24. package/dist/helpers/fingerprint.d.ts +8 -0
  25. package/dist/helpers/fingerprint.d.ts.map +1 -0
  26. package/dist/helpers/fingerprint.js +13 -0
  27. package/dist/helpers/fingerprint.js.map +1 -0
  28. package/dist/helpers/idempotency.intent.d.ts +31 -0
  29. package/dist/helpers/idempotency.intent.d.ts.map +1 -0
  30. package/dist/helpers/idempotency.intent.js +26 -0
  31. package/dist/helpers/idempotency.intent.js.map +1 -0
  32. package/dist/helpers/json-snapshot.d.ts +6 -0
  33. package/dist/helpers/json-snapshot.d.ts.map +1 -0
  34. package/dist/helpers/json-snapshot.js +23 -0
  35. package/dist/helpers/json-snapshot.js.map +1 -0
  36. package/dist/helpers/partitioned-key.d.ts +96 -0
  37. package/dist/helpers/partitioned-key.d.ts.map +1 -0
  38. package/dist/helpers/partitioned-key.js +36 -0
  39. package/dist/helpers/partitioned-key.js.map +1 -0
  40. package/dist/http.d.ts +20 -0
  41. package/dist/http.d.ts.map +1 -0
  42. package/dist/http.js +26 -0
  43. package/dist/http.js.map +1 -0
  44. package/dist/idempotency.behavior.d.ts +117 -0
  45. package/dist/idempotency.behavior.d.ts.map +1 -0
  46. package/dist/idempotency.behavior.js +332 -0
  47. package/dist/idempotency.behavior.js.map +1 -0
  48. package/dist/index.d.ts +21 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +19 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/interfaces/idempotency-options.interface.d.ts +97 -0
  53. package/dist/interfaces/idempotency-options.interface.d.ts.map +1 -0
  54. package/dist/interfaces/idempotency-options.interface.js +3 -0
  55. package/dist/interfaces/idempotency-options.interface.js.map +1 -0
  56. package/dist/interfaces/idempotency-record.interface.d.ts +52 -0
  57. package/dist/interfaces/idempotency-record.interface.d.ts.map +1 -0
  58. package/dist/interfaces/idempotency-record.interface.js +3 -0
  59. package/dist/interfaces/idempotency-record.interface.js.map +1 -0
  60. package/dist/interfaces/idempotency-store.interface.d.ts +59 -0
  61. package/dist/interfaces/idempotency-store.interface.d.ts.map +1 -0
  62. package/dist/interfaces/idempotency-store.interface.js +3 -0
  63. package/dist/interfaces/idempotency-store.interface.js.map +1 -0
  64. package/dist/stores/memory.store.d.ts +48 -0
  65. package/dist/stores/memory.store.d.ts.map +1 -0
  66. package/dist/stores/memory.store.js +119 -0
  67. package/dist/stores/memory.store.js.map +1 -0
  68. package/dist/stores/postgres.store.d.ts +72 -0
  69. package/dist/stores/postgres.store.d.ts.map +1 -0
  70. package/dist/stores/postgres.store.js +187 -0
  71. package/dist/stores/postgres.store.js.map +1 -0
  72. package/dist/stores/redis.store.d.ts +62 -0
  73. package/dist/stores/redis.store.d.ts.map +1 -0
  74. package/dist/stores/redis.store.js +79 -0
  75. package/dist/stores/redis.store.js.map +1 -0
  76. package/package.json +71 -0
@@ -0,0 +1,97 @@
1
+ import type { IPipelineContext } from '@cqrs-ddd/pipeline';
2
+ import type { IdempotencyRequestKind } from './idempotency-record.interface.js';
3
+ /**
4
+ * Derives the digest of the authorization scope a request runs under.
5
+ *
6
+ * Returned value is compared, not interpreted: any deterministic string works,
7
+ * and it should cover every dimension whose change must stop a stored response
8
+ * from being replayed — the effective permission rules and the trusted context
9
+ * their conditions resolve against.
10
+ *
11
+ * Throw when the required context is missing rather than returning a placeholder;
12
+ * the behavior calls this **before** claiming the key, so a throw prevents the
13
+ * operation from being claimed at all. Returning `undefined` means "this request
14
+ * has no scope", which cannot be replayed against a record that has one.
15
+ */
16
+ export type IdempotencyReplayScopeFactory = (context: IPipelineContext) => string | undefined;
17
+ /**
18
+ * Derives the idempotency key for a request from the pipeline context. A common
19
+ * HTTP pattern is to copy the `Idempotency-Key` header into the CQRS command at
20
+ * the controller boundary and read it from `context.request`. An upstream
21
+ * behavior may alternatively place application metadata in `context.items`.
22
+ * Return `undefined` to skip deduplication for this request.
23
+ */
24
+ export type IdempotencyKeyFactory = (context: IPipelineContext) => string | undefined;
25
+ /**
26
+ * Per-handler idempotency options, shallow-merged over the constructor defaults.
27
+ *
28
+ * @example Tenant/principal-scoped command idempotency
29
+ * ```ts
30
+ * // Fails closed when the tenant or principal is missing and escapes every
31
+ * // segment; never build a key with a template string.
32
+ * const createOrderKey = createPartitionedIdempotencyKeyFactory({
33
+ * action: 'order.create',
34
+ * principal: (ctx) => ['user', ctx.items.get(CURRENT_USER_ID) as string],
35
+ * operation: (ctx) => (ctx.request as CreateOrderCommand).idempotencyKey,
36
+ * });
37
+ *
38
+ * class CreateOrderHandler {
39
+ * @pipeline.wrap({ kind: 'command' }, [IdempotencyBehavior, {
40
+ * keyFactory: createOrderKey,
41
+ * ttl: 24 * 60 * 60 * 1000,
42
+ * }])
43
+ * async handle(command: CreateOrderCommand) {}
44
+ * }
45
+ * ```
46
+ */
47
+ export interface IdempotencyBehaviorOptions {
48
+ /**
49
+ * Derives the idempotency key from the request/context. **Required** for the
50
+ * behavior to do anything — without a key (or when it returns `undefined`)
51
+ * the handler runs normally. Include tenant/principal ownership whenever a
52
+ * replay would otherwise bypass handler-level authorization.
53
+ */
54
+ keyFactory?: IdempotencyKeyFactory;
55
+ /**
56
+ * How long a key is remembered, in milliseconds. After this window the key
57
+ * may be reused and a fresh execution occurs. Successful completion restarts
58
+ * the TTL for the replay record. Must be a positive safe integer. Default
59
+ * `86_400_000` (24h).
60
+ */
61
+ ttl?: number;
62
+ /**
63
+ * Which request kinds this policy applies to. Default `['command']` — queries
64
+ * are naturally idempotent and usually want `@cqrs-ddd/pipeline-cache`
65
+ * instead.
66
+ */
67
+ scope?: IdempotencyRequestKind[];
68
+ /**
69
+ * Hash the request payload and reject a later call that reuses the same key
70
+ * with a *different* body (`422`). Default `true`. Disable if your key already
71
+ * fully identifies the payload.
72
+ */
73
+ fingerprint?: boolean;
74
+ /**
75
+ * Binds replay to the caller's authorization scope while keeping the operation
76
+ * key stable.
77
+ *
78
+ * The digest is captured when the key is claimed and stored on the record. A
79
+ * later duplicate may only replay the stored response when its digest matches;
80
+ * a mismatch, or a record stored without one, raises
81
+ * `IdempotencyConflictError` with reason `replay_scope` (`409`). The record is
82
+ * neither deleted nor re-executed, so a permission change can never cause the
83
+ * side effect to run twice.
84
+ *
85
+ * Configure it for any operation whose response or effect depends on the
86
+ * caller's permissions. Without it, replay is bound only by the key and the
87
+ * payload fingerprint.
88
+ */
89
+ replayScopeFactory?: IdempotencyReplayScopeFactory;
90
+ /**
91
+ * When the handler throws, release the key so the client can safely retry
92
+ * (`true`, default). Set `false` to keep the key claimed and surface a
93
+ * conflict on retry (favors strict at-most-once over retryability).
94
+ */
95
+ releaseOnError?: boolean;
96
+ }
97
+ //# sourceMappingURL=idempotency-options.interface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency-options.interface.d.ts","sourceRoot":"","sources":["../../src/interfaces/idempotency-options.interface.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mCAAmC,CAAC;AAEhF;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAC1C,OAAO,EAAE,gBAAgB,KACtB,MAAM,GAAG,SAAS,CAAC;AAExB;;;;;;GAMG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAClC,OAAO,EAAE,gBAAgB,KACtB,MAAM,GAAG,SAAS,CAAC;AAExB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,WAAW,0BAA0B;IACzC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,qBAAqB,CAAC;IAEnC;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;;OAIG;IACH,KAAK,CAAC,EAAE,sBAAsB,EAAE,CAAC;IAEjC;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IAEtB;;;;;;;;;;;;;;OAcG;IACH,kBAAkB,CAAC,EAAE,6BAA6B,CAAC;IAEnD;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B"}
@@ -0,0 +1,3 @@
1
+ /* Copyright (C) 2026-present Aristotelis — see repository license. */
2
+ export {};
3
+ //# sourceMappingURL=idempotency-options.interface.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency-options.interface.js","sourceRoot":"","sources":["../../src/interfaces/idempotency-options.interface.ts"],"names":[],"mappings":"AAAA,sEAAsE"}
@@ -0,0 +1,52 @@
1
+ /** Pipeline request kinds an idempotency policy can apply to. */
2
+ export type IdempotencyRequestKind = 'command' | 'query' | 'event' | 'unknown';
3
+ /** Lifecycle state of an idempotency key. */
4
+ export type IdempotencyStatus = 'in_progress' | 'completed';
5
+ /** Values that have one portable representation across all bundled stores. */
6
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
7
+ [key: string]: JsonValue;
8
+ };
9
+ /**
10
+ * The durable record stored under an idempotency key. While a handler runs the
11
+ * record is `in_progress`; once it succeeds the record flips to `completed` and
12
+ * carries the captured {@link response} for replay.
13
+ */
14
+ export interface IdempotencyRecord {
15
+ /** The idempotency key this record is stored under. */
16
+ key: string;
17
+ /** Whether the original request is still running or has completed. */
18
+ status: IdempotencyStatus;
19
+ /** The request type name, e.g. `CreateUserCommand`. */
20
+ requestName: string;
21
+ /**
22
+ * Unique owner token for the execution that claimed this record. Every claim
23
+ * `IdempotencyBehavior` makes sets it, and `completeIfOwned` and
24
+ * `deleteIfOwned` compare against it. It may be absent from a record written
25
+ * through the unconditional `set` or claimed outside the behavior; such a
26
+ * record can still be read and replayed, but no owner check matches it.
27
+ */
28
+ claimId?: string;
29
+ /**
30
+ * Stable hash of the original request payload. Used to detect a key being
31
+ * reused with a *different* body (a client bug or replay attack).
32
+ */
33
+ fingerprint?: string;
34
+ /**
35
+ * Digest of the authorization scope the original execution ran under, when the
36
+ * behavior is configured with a `replayScopeFactory`.
37
+ *
38
+ * Replay compares this against the current caller's scope and refuses on a
39
+ * mismatch, so a response authorized under one set of permissions is never
40
+ * returned to a caller who no longer holds them. It is deliberately **not**
41
+ * part of the key: a key that changed with permissions would let the same
42
+ * side effect execute again.
43
+ */
44
+ replayScope?: string;
45
+ /** JSON snapshot of the handler response, captured once completed. */
46
+ response?: JsonValue;
47
+ /** ISO-8601 timestamp the key was first claimed. */
48
+ createdAt: string;
49
+ /** ISO-8601 timestamp the original request completed, when applicable. */
50
+ completedAt?: string;
51
+ }
52
+ //# sourceMappingURL=idempotency-record.interface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency-record.interface.d.ts","sourceRoot":"","sources":["../../src/interfaces/idempotency-record.interface.ts"],"names":[],"mappings":"AAEA,iEAAiE;AACjE,MAAM,MAAM,sBAAsB,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,SAAS,CAAC;AAE/E,6CAA6C;AAC7C,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D,8EAA8E;AAC9E,MAAM,MAAM,SAAS,GACjB,IAAI,GACJ,OAAO,GACP,MAAM,GACN,MAAM,GACN,SAAS,EAAE,GACX;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAEjC;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,uDAAuD;IACvD,GAAG,EAAE,MAAM,CAAC;IACZ,sEAAsE;IACtE,MAAM,EAAE,iBAAiB,CAAC;IAC1B,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB"}
@@ -0,0 +1,3 @@
1
+ /* Copyright (C) 2026-present Aristotelis — see repository license. */
2
+ export {};
3
+ //# sourceMappingURL=idempotency-record.interface.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency-record.interface.js","sourceRoot":"","sources":["../../src/interfaces/idempotency-record.interface.ts"],"names":[],"mappings":"AAAA,sEAAsE"}
@@ -0,0 +1,59 @@
1
+ import type { IdempotencyRecord } from './idempotency-record.interface.js';
2
+ /** A value that may be returned synchronously or as a promise. */
3
+ export type MaybePromise<T> = T | Promise<T>;
4
+ /**
5
+ * Backend-agnostic store for idempotency records — the single seam every
6
+ * storage backend implements. Because {@link IdempotencyBehavior} depends only
7
+ * on this interface, swapping the backend (memory, Redis, Postgres, your own)
8
+ * is a one-line change of the store passed to `IdempotencyBehavior`; handlers never
9
+ * change.
10
+ *
11
+ * Concurrent duplicate exclusion hinges on {@link setIfAbsent} being **atomic**
12
+ * — it must claim the key only if no live record exists (e.g. Redis `SET NX PX`,
13
+ * or a Postgres conditional upsert that replaces only expired conflicts).
14
+ * Atomic claiming prevents two live duplicates from both acquiring the same
15
+ * key; it does not by itself imply exactly-once execution across failures/retries.
16
+ *
17
+ * Ownership-sensitive transitions use `claimId`. A stale execution whose claim
18
+ * expired must never complete or delete a newer execution's record. Built-in
19
+ * stores implement this with an atomic compare-and-set / compare-and-delete.
20
+ */
21
+ export interface IdempotencyStore {
22
+ /**
23
+ * Returns the live (non-expired) record for `key`, or `undefined` if there is
24
+ * none.
25
+ */
26
+ get(key: string): MaybePromise<IdempotencyRecord | undefined>;
27
+ /**
28
+ * Atomically stores `record` **only if** no live record exists for `key`.
29
+ *
30
+ * @returns `true` if the key was claimed by this call, `false` if a live
31
+ * record already existed.
32
+ */
33
+ setIfAbsent(key: string, record: IdempotencyRecord, ttlMs: number): MaybePromise<boolean>;
34
+ /**
35
+ * Atomically replaces the live `in_progress` record with `record` only when
36
+ * the existing record is still owned by `claimId`.
37
+ *
38
+ * @returns `true` when this claim completed its own record; `false` when the
39
+ * claim expired, was replaced, or was otherwise no longer owned by the caller.
40
+ */
41
+ completeIfOwned(key: string, claimId: string, record: IdempotencyRecord, ttlMs: number): MaybePromise<boolean>;
42
+ /**
43
+ * Atomically removes the live record only when it is still owned by
44
+ * `claimId`. Used by `releaseOnError` so a stale failure cannot delete a
45
+ * newer execution's claim.
46
+ */
47
+ deleteIfOwned(key: string, claimId: string): MaybePromise<boolean>;
48
+ /**
49
+ * Unconditional overwrite for administrative or custom use. Pipeline
50
+ * execution completion uses {@link completeIfOwned}, not this method.
51
+ */
52
+ set(key: string, record: IdempotencyRecord, ttlMs: number): MaybePromise<void>;
53
+ /**
54
+ * Unconditional delete for administrative or custom use. Pipeline
55
+ * failure release uses {@link deleteIfOwned}, not this method.
56
+ */
57
+ delete(key: string): MaybePromise<void>;
58
+ }
59
+ //# sourceMappingURL=idempotency-store.interface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency-store.interface.d.ts","sourceRoot":"","sources":["../../src/interfaces/idempotency-store.interface.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAE3E,kEAAkE;AAClE,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAE7C;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IAE9D;;;;;OAKG;IACH,WAAW,CACT,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,YAAY,CAAC,OAAO,CAAC,CAAC;IAEzB;;;;;;OAMG;IACH,eAAe,CACb,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,YAAY,CAAC,OAAO,CAAC,CAAC;IAEzB;;;;OAIG;IACH,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC;IAEnE;;;OAGG;IACH,GAAG,CACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,YAAY,CAAC,IAAI,CAAC,CAAC;IAEtB;;;OAGG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;CACzC"}
@@ -0,0 +1,3 @@
1
+ /* Copyright (C) 2026-present Aristotelis — see repository license. */
2
+ export {};
3
+ //# sourceMappingURL=idempotency-store.interface.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency-store.interface.js","sourceRoot":"","sources":["../../src/interfaces/idempotency-store.interface.ts"],"names":[],"mappings":"AAAA,sEAAsE"}
@@ -0,0 +1,48 @@
1
+ import type { IdempotencyRecord } from '../interfaces/idempotency-record.interface.js';
2
+ import type { IdempotencyStore } from '../interfaces/idempotency-store.interface.js';
3
+ export interface MemoryIdempotencyStoreOptions {
4
+ /** Maximum number of entries allowed in the store. Defaults to 10,000. */
5
+ maxEntries?: number;
6
+ /**
7
+ * Interval in milliseconds for periodic background cleanup of expired entries.
8
+ * Defaults to 30,000 ms. If 0 or negative, periodic cleanup timer is disabled.
9
+ */
10
+ cleanupIntervalMs?: number;
11
+ }
12
+ /**
13
+ * Zero-dependency, in-process {@link IdempotencyStore} backed by a `Map` with
14
+ * per-entry expiry and bounded memory capacity. The **default** store — ideal
15
+ * for a single instance, tests, or local development.
16
+ *
17
+ * Because Node runs JavaScript on a single thread, {@link setIfAbsent},
18
+ * {@link completeIfOwned}, and {@link deleteIfOwned} are effectively atomic
19
+ * here. State is **not** shared across processes, so for a multi-instance
20
+ * deployment use {@link RedisIdempotencyStore} or
21
+ * {@link PostgresIdempotencyStore}.
22
+ */
23
+ export declare class MemoryIdempotencyStore implements IdempotencyStore {
24
+ private readonly entries;
25
+ private readonly maxEntries;
26
+ private readonly cleanupTimer?;
27
+ constructor(options?: MemoryIdempotencyStoreOptions);
28
+ /** Current number of active (stored) entries. */
29
+ get size(): number;
30
+ /**
31
+ * Iterates through all entries and removes any whose TTL has elapsed.
32
+ * Returns the count of deleted entries.
33
+ */
34
+ cleanupExpired(): number;
35
+ /** Stops the periodic cleanup timer and releases timer resources. */
36
+ destroy(): void;
37
+ private ensureCapacity;
38
+ get(key: string): IdempotencyRecord | undefined;
39
+ setIfAbsent(key: string, record: IdempotencyRecord, ttlMs: number): boolean;
40
+ completeIfOwned(key: string, claimId: string, record: IdempotencyRecord, ttlMs: number): boolean;
41
+ deleteIfOwned(key: string, claimId: string): boolean;
42
+ set(key: string, record: IdempotencyRecord, ttlMs: number): void;
43
+ delete(key: string): void;
44
+ private write;
45
+ /** Returns the entry for `key` if present and not expired, pruning if it is. */
46
+ private live;
47
+ }
48
+ //# sourceMappingURL=memory.store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.store.d.ts","sourceRoot":"","sources":["../../src/stores/memory.store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+CAA+C,CAAC;AACvF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8CAA8C,CAAC;AAOrF,MAAM,WAAW,6BAA6B;IAC5C,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;GAUG;AACH,qBAAa,sBAAuB,YAAW,gBAAgB;IAC7D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAiB;IAE/C,YAAY,OAAO,CAAC,EAAE,6BAA6B,EAWlD;IAED,iDAAiD;IACjD,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;;OAGG;IACH,cAAc,IAAI,MAAM,CAUvB;IAED,qEAAqE;IACrE,OAAO,IAAI,IAAI,CAId;IAED,OAAO,CAAC,cAAc;IAYtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS,CAG9C;IAED,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAO1E;IAED,eAAe,CACb,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,OAAO,CAWT;IAED,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAMnD;IAED,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAK/D;IAED,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAExB;IAED,OAAO,CAAC,KAAK;IAOb,gFAAgF;IAChF,OAAO,CAAC,IAAI;CASb"}
@@ -0,0 +1,119 @@
1
+ /* Copyright (C) 2026-present Aristotelis — see repository license. */
2
+ import { cloneIdempotencyRecord } from '../helpers/json-snapshot.js';
3
+ /**
4
+ * Zero-dependency, in-process {@link IdempotencyStore} backed by a `Map` with
5
+ * per-entry expiry and bounded memory capacity. The **default** store — ideal
6
+ * for a single instance, tests, or local development.
7
+ *
8
+ * Because Node runs JavaScript on a single thread, {@link setIfAbsent},
9
+ * {@link completeIfOwned}, and {@link deleteIfOwned} are effectively atomic
10
+ * here. State is **not** shared across processes, so for a multi-instance
11
+ * deployment use {@link RedisIdempotencyStore} or
12
+ * {@link PostgresIdempotencyStore}.
13
+ */
14
+ export class MemoryIdempotencyStore {
15
+ entries = new Map();
16
+ maxEntries;
17
+ cleanupTimer;
18
+ constructor(options) {
19
+ this.maxEntries = options?.maxEntries ?? 10_000;
20
+ const interval = options?.cleanupIntervalMs ?? 30_000;
21
+ if (interval > 0) {
22
+ this.cleanupTimer = setInterval(() => {
23
+ this.cleanupExpired();
24
+ }, interval);
25
+ if (typeof this.cleanupTimer?.unref === 'function') {
26
+ this.cleanupTimer.unref();
27
+ }
28
+ }
29
+ }
30
+ /** Current number of active (stored) entries. */
31
+ get size() {
32
+ return this.entries.size;
33
+ }
34
+ /**
35
+ * Iterates through all entries and removes any whose TTL has elapsed.
36
+ * Returns the count of deleted entries.
37
+ */
38
+ cleanupExpired() {
39
+ const now = Date.now();
40
+ let count = 0;
41
+ for (const [key, entry] of this.entries) {
42
+ if (entry.expiresAt <= now) {
43
+ this.entries.delete(key);
44
+ count++;
45
+ }
46
+ }
47
+ return count;
48
+ }
49
+ /** Stops the periodic cleanup timer and releases timer resources. */
50
+ destroy() {
51
+ if (this.cleanupTimer) {
52
+ clearInterval(this.cleanupTimer);
53
+ }
54
+ }
55
+ ensureCapacity() {
56
+ if (this.entries.size < this.maxEntries) {
57
+ return;
58
+ }
59
+ this.cleanupExpired();
60
+ if (this.entries.size >= this.maxEntries) {
61
+ throw new Error(`MemoryIdempotencyStore capacity (${this.maxEntries}) reached: cannot evict active or unexpired claims.`);
62
+ }
63
+ }
64
+ get(key) {
65
+ const record = this.live(key)?.record;
66
+ return record ? cloneIdempotencyRecord(record) : undefined;
67
+ }
68
+ setIfAbsent(key, record, ttlMs) {
69
+ if (this.live(key)) {
70
+ return false;
71
+ }
72
+ this.ensureCapacity();
73
+ this.write(key, record, ttlMs);
74
+ return true;
75
+ }
76
+ completeIfOwned(key, claimId, record, ttlMs) {
77
+ const current = this.live(key);
78
+ if (current?.record.status !== 'in_progress' ||
79
+ current.record.claimId !== claimId) {
80
+ return false;
81
+ }
82
+ this.write(key, record, ttlMs);
83
+ return true;
84
+ }
85
+ deleteIfOwned(key, claimId) {
86
+ const current = this.live(key);
87
+ if (!current || current.record.claimId !== claimId) {
88
+ return false;
89
+ }
90
+ return this.entries.delete(key);
91
+ }
92
+ set(key, record, ttlMs) {
93
+ if (!this.entries.has(key)) {
94
+ this.ensureCapacity();
95
+ }
96
+ this.write(key, record, ttlMs);
97
+ }
98
+ delete(key) {
99
+ this.entries.delete(key);
100
+ }
101
+ write(key, record, ttlMs) {
102
+ this.entries.set(key, {
103
+ record: cloneIdempotencyRecord(record),
104
+ expiresAt: Date.now() + ttlMs,
105
+ });
106
+ }
107
+ /** Returns the entry for `key` if present and not expired, pruning if it is. */
108
+ live(key) {
109
+ const entry = this.entries.get(key);
110
+ if (!entry)
111
+ return undefined;
112
+ if (entry.expiresAt <= Date.now()) {
113
+ this.entries.delete(key);
114
+ return undefined;
115
+ }
116
+ return entry;
117
+ }
118
+ }
119
+ //# sourceMappingURL=memory.store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.store.js","sourceRoot":"","sources":["../../src/stores/memory.store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ import type { IdempotencyRecord } from '../interfaces/idempotency-record.interface.js';
2
+ import type { IdempotencyStore } from '../interfaces/idempotency-store.interface.js';
3
+ /** A single returned row, keyed by column name. */
4
+ export interface PostgresRowLike {
5
+ [column: string]: unknown;
6
+ }
7
+ /** Minimal structural shape of a `pg` query result. */
8
+ export interface PostgresQueryResultLike {
9
+ rows: PostgresRowLike[];
10
+ rowCount?: number | null;
11
+ }
12
+ /**
13
+ * Minimal structural shape of a `pg` `Pool` / `Client`. Declared locally so this
14
+ * package does not hard-depend on `pg` — a real `Pool` or `Client` satisfies it.
15
+ * Add it in your app: `pnpm add pg`.
16
+ */
17
+ export interface PostgresQueryableLike {
18
+ query(text: string, values?: unknown[]): Promise<PostgresQueryResultLike>;
19
+ }
20
+ /** Options for {@link PostgresIdempotencyStore}. */
21
+ export interface PostgresIdempotencyStoreOptions {
22
+ /**
23
+ * Destination table, optionally schema-qualified (e.g. `app.idempotency_keys`).
24
+ * Default `'idempotency_keys'`. Validated as a safe SQL identifier.
25
+ */
26
+ table?: string;
27
+ }
28
+ /**
29
+ * SQL that creates the idempotency table and its `expires_at` index. Run it
30
+ * from an application migration, not at request time.
31
+ *
32
+ * `response` holds the response as JSON text rather than `jsonb`: `jsonb`
33
+ * rejects the `\u0000` and unpaired-surrogate escapes `JSON.stringify` writes,
34
+ * and a completed response must always be storable and replay exactly.
35
+ *
36
+ * @param table - Table name (validated). Default `'idempotency_keys'`.
37
+ * @returns SQL statements required by {@link PostgresIdempotencyStore}.
38
+ */
39
+ export declare function createIdempotencyTableSql(table?: string): string;
40
+ /**
41
+ * {@link IdempotencyStore} backed by **Postgres** (`pg`) — a drop-in
42
+ * replacement that shares state across instances without a separate Redis.
43
+ *
44
+ * Create the table once with {@link createIdempotencyTableSql}. Atomicity of
45
+ * {@link setIfAbsent} comes from a conditional `INSERT … ON CONFLICT … DO
46
+ * UPDATE`: an absent key is inserted, an expired key is atomically replaced,
47
+ * and a live conflict is left untouched. Completion/release also compare
48
+ * `claim_id` in the same SQL statement, so a stale execution cannot mutate a
49
+ * newer claim. The table name is validated as a plain SQL identifier (it is
50
+ * interpolated, not parameterized); all values are passed as bound parameters.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import { Pool } from 'pg';
55
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
56
+ * await pool.query(createIdempotencyTableSql());
57
+ * const store = new PostgresIdempotencyStore(pool);
58
+ * ```
59
+ */
60
+ export declare class PostgresIdempotencyStore implements IdempotencyStore {
61
+ private readonly db;
62
+ private readonly table;
63
+ constructor(db: PostgresQueryableLike, options?: PostgresIdempotencyStoreOptions);
64
+ get(key: string): Promise<IdempotencyRecord | undefined>;
65
+ setIfAbsent(key: string, record: IdempotencyRecord, ttlMs: number): Promise<boolean>;
66
+ completeIfOwned(key: string, claimId: string, record: IdempotencyRecord, ttlMs: number): Promise<boolean>;
67
+ deleteIfOwned(key: string, claimId: string): Promise<boolean>;
68
+ set(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void>;
69
+ delete(key: string): Promise<void>;
70
+ private toValues;
71
+ }
72
+ //# sourceMappingURL=postgres.store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.store.d.ts","sourceRoot":"","sources":["../../src/stores/postgres.store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,iBAAiB,EAElB,MAAM,+CAA+C,CAAC;AACvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8CAA8C,CAAC;AAErF,mDAAmD;AACnD,MAAM,WAAW,eAAe;IAC9B,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,eAAe,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;CAC3E;AAED,oDAAoD;AACpD,MAAM,WAAW,+BAA+B;IAC9C;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAeD;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,SAAqB,GAAG,MAAM,CAe5E;AA6CD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,wBAAyB,YAAW,gBAAgB;IAI7D,OAAO,CAAC,QAAQ,CAAC,EAAE;IAHrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAE/B,YACmB,EAAE,EAAE,qBAAqB,EAC1C,OAAO,GAAE,+BAAoC,EAG9C;IAEK,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAW7D;IAEK,WAAW,CACf,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAoBlB;IAEK,eAAe,CACnB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAoBlB;IAEK,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlE;IAEK,GAAG,CACP,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAiBf;IAEK,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvC;IAED,OAAO,CAAC,QAAQ;CAajB"}