@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,187 @@
1
+ /* Copyright (C) 2026-present Aristotelis — see repository license. */
2
+ /** Allows `table` or `schema.table` made of unquoted SQL identifiers only. */
3
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
4
+ function assertSafeTable(table) {
5
+ if (!SAFE_IDENTIFIER.test(table)) {
6
+ throw new Error(`Invalid idempotency table name "${table}". ` +
7
+ 'Use an unquoted identifier like "idempotency_keys" or "schema.idempotency_keys".');
8
+ }
9
+ return table;
10
+ }
11
+ /**
12
+ * SQL that creates the idempotency table and its `expires_at` index. Run it
13
+ * from an application migration, not at request time.
14
+ *
15
+ * `response` holds the response as JSON text rather than `jsonb`: `jsonb`
16
+ * rejects the `\u0000` and unpaired-surrogate escapes `JSON.stringify` writes,
17
+ * and a completed response must always be storable and replay exactly.
18
+ *
19
+ * @param table - Table name (validated). Default `'idempotency_keys'`.
20
+ * @returns SQL statements required by {@link PostgresIdempotencyStore}.
21
+ */
22
+ export function createIdempotencyTableSql(table = 'idempotency_keys') {
23
+ const name = assertSafeTable(table);
24
+ return `CREATE TABLE IF NOT EXISTS ${name} (
25
+ key TEXT PRIMARY KEY,
26
+ status TEXT NOT NULL,
27
+ request_name TEXT NOT NULL,
28
+ claim_id TEXT,
29
+ fingerprint TEXT,
30
+ replay_scope TEXT,
31
+ response TEXT,
32
+ created_at TIMESTAMPTZ NOT NULL,
33
+ completed_at TIMESTAMPTZ,
34
+ expires_at TIMESTAMPTZ NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS ${indexName(name)} ON ${name} (expires_at);`;
37
+ }
38
+ /** Derives a safe index name from a (possibly schema-qualified) table name. */
39
+ function indexName(table) {
40
+ const base = table.includes('.') ? table.split('.').pop() : table;
41
+ return `${base}_expires_at_idx`;
42
+ }
43
+ function toIso(value) {
44
+ return value instanceof Date ? value.toISOString() : String(value);
45
+ }
46
+ function mapRow(key, row) {
47
+ return {
48
+ key,
49
+ status: row.status,
50
+ requestName: row.request_name,
51
+ claimId: row.claim_id ?? undefined,
52
+ fingerprint: row.fingerprint ?? undefined,
53
+ replayScope: row.replay_scope ?? undefined,
54
+ // SQL NULL: no response stored. The text 'null' is an explicit null response.
55
+ response: row.has_response
56
+ ? JSON.parse(row.response)
57
+ : undefined,
58
+ createdAt: toIso(row.created_at),
59
+ completedAt: row.completed_at ? toIso(row.completed_at) : undefined,
60
+ };
61
+ }
62
+ /**
63
+ * Validates a lease TTL before it is interpolated into a PostgreSQL interval.
64
+ *
65
+ * Lease expiry is computed and compared using the database clock. The returned
66
+ * string is safe to concatenate only because the input is first constrained to a
67
+ * positive safe integer.
68
+ */
69
+ function assertLeaseTtl(ttlMs) {
70
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
71
+ throw new TypeError(`Idempotency lease TTL must be a positive safe integer in milliseconds, received ${ttlMs}.`);
72
+ }
73
+ return String(ttlMs);
74
+ }
75
+ /**
76
+ * {@link IdempotencyStore} backed by **Postgres** (`pg`) — a drop-in
77
+ * replacement that shares state across instances without a separate Redis.
78
+ *
79
+ * Create the table once with {@link createIdempotencyTableSql}. Atomicity of
80
+ * {@link setIfAbsent} comes from a conditional `INSERT … ON CONFLICT … DO
81
+ * UPDATE`: an absent key is inserted, an expired key is atomically replaced,
82
+ * and a live conflict is left untouched. Completion/release also compare
83
+ * `claim_id` in the same SQL statement, so a stale execution cannot mutate a
84
+ * newer claim. The table name is validated as a plain SQL identifier (it is
85
+ * interpolated, not parameterized); all values are passed as bound parameters.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * import { Pool } from 'pg';
90
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
91
+ * await pool.query(createIdempotencyTableSql());
92
+ * const store = new PostgresIdempotencyStore(pool);
93
+ * ```
94
+ */
95
+ export class PostgresIdempotencyStore {
96
+ db;
97
+ table;
98
+ constructor(db, options = {}) {
99
+ this.db = db;
100
+ this.table = assertSafeTable(options.table ?? 'idempotency_keys');
101
+ }
102
+ async get(key) {
103
+ const result = await this.db.query(`SELECT status, request_name, claim_id, fingerprint, replay_scope, response,
104
+ response IS NOT NULL AS has_response,
105
+ created_at, completed_at
106
+ FROM ${this.table}
107
+ WHERE key = $1 AND expires_at > now()`, [key]);
108
+ const row = result.rows[0];
109
+ return row ? mapRow(key, row) : undefined;
110
+ }
111
+ async setIfAbsent(key, record, ttlMs) {
112
+ const result = await this.db.query(`INSERT INTO ${this.table} AS current_record
113
+ (key, status, request_name, claim_id, fingerprint, replay_scope, response, created_at, completed_at, expires_at)
114
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now() + ($10 || ' milliseconds')::interval)
115
+ ON CONFLICT (key) DO UPDATE SET
116
+ status = EXCLUDED.status,
117
+ request_name = EXCLUDED.request_name,
118
+ claim_id = EXCLUDED.claim_id,
119
+ fingerprint = EXCLUDED.fingerprint,
120
+ replay_scope = EXCLUDED.replay_scope,
121
+ response = EXCLUDED.response,
122
+ created_at = EXCLUDED.created_at,
123
+ completed_at = EXCLUDED.completed_at,
124
+ expires_at = EXCLUDED.expires_at
125
+ WHERE current_record.expires_at <= now()
126
+ RETURNING key`, [key, ...this.toValues(record, ttlMs)]);
127
+ return result.rows.length > 0;
128
+ }
129
+ async completeIfOwned(key, claimId, record, ttlMs) {
130
+ const result = await this.db.query(`UPDATE ${this.table}
131
+ SET status = $3,
132
+ request_name = $4,
133
+ claim_id = $5,
134
+ fingerprint = $6,
135
+ replay_scope = $7,
136
+ response = $8,
137
+ created_at = $9,
138
+ completed_at = $10,
139
+ expires_at = now() + ($11 || ' milliseconds')::interval
140
+ WHERE key = $1
141
+ AND claim_id = $2
142
+ AND status = 'in_progress'
143
+ AND expires_at > now()
144
+ RETURNING key`, [key, claimId, ...this.toValues(record, ttlMs)]);
145
+ return result.rows.length > 0;
146
+ }
147
+ async deleteIfOwned(key, claimId) {
148
+ const result = await this.db.query(`DELETE FROM ${this.table}
149
+ WHERE key = $1
150
+ AND claim_id = $2
151
+ AND expires_at > now()
152
+ RETURNING key`, [key, claimId]);
153
+ return result.rows.length > 0;
154
+ }
155
+ async set(key, record, ttlMs) {
156
+ await this.db.query(`INSERT INTO ${this.table}
157
+ (key, status, request_name, claim_id, fingerprint, replay_scope, response, created_at, completed_at, expires_at)
158
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now() + ($10 || ' milliseconds')::interval)
159
+ ON CONFLICT (key) DO UPDATE SET
160
+ status = EXCLUDED.status,
161
+ request_name = EXCLUDED.request_name,
162
+ claim_id = EXCLUDED.claim_id,
163
+ fingerprint = EXCLUDED.fingerprint,
164
+ replay_scope = EXCLUDED.replay_scope,
165
+ response = EXCLUDED.response,
166
+ created_at = EXCLUDED.created_at,
167
+ completed_at = EXCLUDED.completed_at,
168
+ expires_at = EXCLUDED.expires_at`, [key, ...this.toValues(record, ttlMs)]);
169
+ }
170
+ async delete(key) {
171
+ await this.db.query(`DELETE FROM ${this.table} WHERE key = $1`, [key]);
172
+ }
173
+ toValues(record, ttlMs) {
174
+ return [
175
+ record.status,
176
+ record.requestName,
177
+ record.claimId ?? null,
178
+ record.fingerprint ?? null,
179
+ record.replayScope ?? null,
180
+ record.response === undefined ? null : JSON.stringify(record.response),
181
+ record.createdAt,
182
+ record.completedAt ?? null,
183
+ assertLeaseTtl(ttlMs),
184
+ ];
185
+ }
186
+ }
187
+ //# sourceMappingURL=postgres.store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.store.js","sourceRoot":"","sources":["../../src/stores/postgres.store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,62 @@
1
+ import type { IdempotencyRecord } from '../interfaces/idempotency-record.interface.js';
2
+ import type { IdempotencyStore } from '../interfaces/idempotency-store.interface.js';
3
+ /**
4
+ * Minimal structural shape of a node-redis client (`redis` v4 or later). Declared
5
+ * locally so this package does not hard-depend on `redis` — a real client
6
+ * satisfies it. Add it in your app: `pnpm add redis`.
7
+ *
8
+ * `set` must support the `NX` (set-if-absent) and `PX` (ms TTL) options and
9
+ * return `null` when `NX` prevented the write. `eval` is used for atomic
10
+ * owner-aware completion/release transitions.
11
+ */
12
+ export interface RedisClientLike {
13
+ get(key: string): Promise<string | null>;
14
+ set(key: string, value: string, options?: {
15
+ PX?: number;
16
+ NX?: boolean;
17
+ }): Promise<string | null>;
18
+ del(key: string): Promise<number>;
19
+ eval(script: string, options: {
20
+ keys: string[];
21
+ arguments: string[];
22
+ }): Promise<unknown>;
23
+ }
24
+ /** Options for {@link RedisIdempotencyStore}. */
25
+ export interface RedisIdempotencyStoreOptions {
26
+ /** Prefix applied to every Redis key. Default `'idempotency:'`. */
27
+ keyPrefix?: string;
28
+ }
29
+ /**
30
+ * {@link IdempotencyStore} backed by **Redis** (node-redis) — a drop-in
31
+ * replacement for the memory store that shares state across instances.
32
+ *
33
+ * Atomicity comes from Redis itself: {@link setIfAbsent} issues
34
+ * `SET key value PX <ttl> NX`, which sets the key only if it does not already
35
+ * exist and returns `null` otherwise. Owner-aware completion and release use
36
+ * Lua scripts so a stale execution cannot overwrite/delete a newer claim.
37
+ * TTL is enforced by Redis, so expired keys never linger.
38
+ *
39
+ * A stored value that is not valid JSON fails closed: {@link get} throws, and
40
+ * the owner-aware writes cannot prove ownership of it, so they leave it
41
+ * untouched and return `false`.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * import { createClient } from 'redis';
46
+ * const client = createClient({ url: process.env.REDIS_URL });
47
+ * await client.connect();
48
+ * const store = new RedisIdempotencyStore(client);
49
+ * ```
50
+ */
51
+ export declare class RedisIdempotencyStore implements IdempotencyStore {
52
+ private readonly client;
53
+ private readonly prefix;
54
+ constructor(client: RedisClientLike, options?: RedisIdempotencyStoreOptions);
55
+ get(key: string): Promise<IdempotencyRecord | undefined>;
56
+ setIfAbsent(key: string, record: IdempotencyRecord, ttlMs: number): Promise<boolean>;
57
+ completeIfOwned(key: string, claimId: string, record: IdempotencyRecord, ttlMs: number): Promise<boolean>;
58
+ deleteIfOwned(key: string, claimId: string): Promise<boolean>;
59
+ set(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void>;
60
+ delete(key: string): Promise<void>;
61
+ }
62
+ //# sourceMappingURL=redis.store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis.store.d.ts","sourceRoot":"","sources":["../../src/stores/redis.store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+CAA+C,CAAC;AACvF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8CAA8C,CAAC;AAqBrF;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACzC,GAAG,CACD,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,OAAO,CAAA;KAAE,GACtC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,IAAI,CACF,MAAM,EAAE,MAAM,EACd,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,EAAE,CAAC;QAAC,SAAS,EAAE,MAAM,EAAE,CAAA;KAAE,GAC/C,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB;AAED,iDAAiD;AACjD,MAAM,WAAW,4BAA4B;IAC3C,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,qBAAsB,YAAW,gBAAgB;IAI1D,OAAO,CAAC,QAAQ,CAAC,MAAM;IAHzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,YACmB,MAAM,EAAE,eAAe,EACxC,OAAO,GAAE,4BAAiC,EAG3C;IAEK,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAG7D;IAEK,WAAW,CACf,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAOlB;IAEK,eAAe,CACnB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAMlB;IAEK,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMlE;IAEK,GAAG,CACP,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAIf;IAEK,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvC;CACF"}
@@ -0,0 +1,79 @@
1
+ /* Copyright (C) 2026-present Aristotelis — see repository license. */
2
+ const COMPLETE_IF_OWNED_SCRIPT = `
3
+ local raw = redis.call('GET', KEYS[1])
4
+ if not raw then return 0 end
5
+ local ok, current = pcall(cjson.decode, raw)
6
+ if not ok then return 0 end
7
+ if current.status ~= 'in_progress' or current.claimId ~= ARGV[1] then return 0 end
8
+ redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
9
+ return 1
10
+ `;
11
+ const DELETE_IF_OWNED_SCRIPT = `
12
+ local raw = redis.call('GET', KEYS[1])
13
+ if not raw then return 0 end
14
+ local ok, current = pcall(cjson.decode, raw)
15
+ if not ok then return 0 end
16
+ if current.claimId ~= ARGV[1] then return 0 end
17
+ return redis.call('DEL', KEYS[1])
18
+ `;
19
+ /**
20
+ * {@link IdempotencyStore} backed by **Redis** (node-redis) — a drop-in
21
+ * replacement for the memory store that shares state across instances.
22
+ *
23
+ * Atomicity comes from Redis itself: {@link setIfAbsent} issues
24
+ * `SET key value PX <ttl> NX`, which sets the key only if it does not already
25
+ * exist and returns `null` otherwise. Owner-aware completion and release use
26
+ * Lua scripts so a stale execution cannot overwrite/delete a newer claim.
27
+ * TTL is enforced by Redis, so expired keys never linger.
28
+ *
29
+ * A stored value that is not valid JSON fails closed: {@link get} throws, and
30
+ * the owner-aware writes cannot prove ownership of it, so they leave it
31
+ * untouched and return `false`.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * import { createClient } from 'redis';
36
+ * const client = createClient({ url: process.env.REDIS_URL });
37
+ * await client.connect();
38
+ * const store = new RedisIdempotencyStore(client);
39
+ * ```
40
+ */
41
+ export class RedisIdempotencyStore {
42
+ client;
43
+ prefix;
44
+ constructor(client, options = {}) {
45
+ this.client = client;
46
+ this.prefix = options.keyPrefix ?? 'idempotency:';
47
+ }
48
+ async get(key) {
49
+ const raw = await this.client.get(this.prefix + key);
50
+ return raw ? JSON.parse(raw) : undefined;
51
+ }
52
+ async setIfAbsent(key, record, ttlMs) {
53
+ const result = await this.client.set(this.prefix + key, JSON.stringify(record), { PX: ttlMs, NX: true });
54
+ return result !== null;
55
+ }
56
+ async completeIfOwned(key, claimId, record, ttlMs) {
57
+ const result = await this.client.eval(COMPLETE_IF_OWNED_SCRIPT, {
58
+ keys: [this.prefix + key],
59
+ arguments: [claimId, JSON.stringify(record), String(ttlMs)],
60
+ });
61
+ return Number(result) === 1;
62
+ }
63
+ async deleteIfOwned(key, claimId) {
64
+ const result = await this.client.eval(DELETE_IF_OWNED_SCRIPT, {
65
+ keys: [this.prefix + key],
66
+ arguments: [claimId],
67
+ });
68
+ return Number(result) === 1;
69
+ }
70
+ async set(key, record, ttlMs) {
71
+ await this.client.set(this.prefix + key, JSON.stringify(record), {
72
+ PX: ttlMs,
73
+ });
74
+ }
75
+ async delete(key) {
76
+ await this.client.del(this.prefix + key);
77
+ }
78
+ }
79
+ //# sourceMappingURL=redis.store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis.store.js","sourceRoot":"","sources":["../../src/stores/redis.store.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAKtE,MAAM,wBAAwB,GAAG;;;;;;;;CAQhC,CAAC;AAEF,MAAM,sBAAsB,GAAG;;;;;;;CAO9B,CAAC;AA+BF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,OAAO,qBAAqB;IAIb,MAAM;IAHR,MAAM,CAAS;IAEhC,YACmB,MAAuB,EACxC,OAAO,GAAiC,EAAE;sBADzB,MAAM;QAGvB,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,SAAS,IAAI,cAAc,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC;QACrD,OAAO,GAAG,CAAC,CAAC,CAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAuB,CAAC,CAAC,CAAC,SAAS,CAAC;IAClE,CAAC;IAED,KAAK,CAAC,WAAW,CACf,GAAW,EACX,MAAyB,EACzB,KAAa;QAEb,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAClC,IAAI,CAAC,MAAM,GAAG,GAAG,EACjB,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EACtB,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,CACxB,CAAC;QACF,OAAO,MAAM,KAAK,IAAI,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,eAAe,CACnB,GAAW,EACX,OAAe,EACf,MAAyB,EACzB,KAAa;QAEb,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,wBAAwB,EAAE;YAC9D,IAAI,EAAE,CAAC,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC;YACzB,SAAS,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;SAC5D,CAAC,CAAC;QACH,OAAO,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9B,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,GAAW,EAAE,OAAe;QAC9C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,sBAAsB,EAAE;YAC5D,IAAI,EAAE,CAAC,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC;YACzB,SAAS,EAAE,CAAC,OAAO,CAAC;SACrB,CAAC,CAAC;QACH,OAAO,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9B,CAAC;IAED,KAAK,CAAC,GAAG,CACP,GAAW,EACX,MAAyB,EACzB,KAAa;QAEb,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE;YAC/D,EAAE,EAAE,KAAK;SACV,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC;IAC3C,CAAC;CACF"}
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@cqrs-ddd/pipeline-idempotency",
3
+ "version": "0.5.0",
4
+ "description": "Idempotency for @cqrs-ddd/pipeline: deduplicates commands by an operation key and replays completed responses within the caller's scope",
5
+ "author": "Aristotelis",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=22.12.0"
10
+ },
11
+ "keywords": [
12
+ "pipeline",
13
+ "cqrs",
14
+ "idempotency",
15
+ "idempotent",
16
+ "deduplication",
17
+ "retry-safe",
18
+ "behavior"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/aristoteliss/ddd-cqrs.git",
23
+ "directory": "packages/pipeline-idempotency"
24
+ },
25
+ "homepage": "https://aristoteliss.github.io/ddd-cqrs/packages/pipeline-idempotency/",
26
+ "bugs": {
27
+ "url": "https://github.com/aristoteliss/ddd-cqrs/issues"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./http": {
35
+ "types": "./dist/http.d.ts",
36
+ "default": "./dist/http.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "main": "dist/index.js",
41
+ "types": "dist/index.d.ts",
42
+ "files": [
43
+ "dist",
44
+ "LICENSE",
45
+ "COMMERCIAL_LICENSE.txt"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "peerDependencies": {
51
+ "@cqrs-ddd/pipeline": "^0.5.0"
52
+ },
53
+ "devDependencies": {
54
+ "@vitest/coverage-v8": "^5.0.3",
55
+ "vitest": "^5.0.3",
56
+ "@cqrs-ddd/pipeline": "0.5.0"
57
+ },
58
+ "dependencies": {
59
+ "@cqrs-ddd/untyped": "^0.5.0",
60
+ "@cqrs-ddd/safe-stringify": "^0.5.0"
61
+ },
62
+ "scripts": {
63
+ "build": "tsc -p tsconfig.build.json",
64
+ "build:watch": "tsc -p tsconfig.build.json --watch",
65
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
66
+ "rebuild": "pnpm run clean && pnpm run build",
67
+ "lint": "tsc --noEmit",
68
+ "test": "vitest run",
69
+ "test:watch": "vitest"
70
+ }
71
+ }