@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.
- package/COMMERCIAL_LICENSE.txt +34 -0
- package/LICENSE +661 -0
- package/README.md +39 -0
- package/dist/constants/tokens.d.ts +3 -0
- package/dist/constants/tokens.d.ts.map +1 -0
- package/dist/constants/tokens.js +4 -0
- package/dist/constants/tokens.js.map +1 -0
- package/dist/errors/idempotency-completion.error.d.ts +32 -0
- package/dist/errors/idempotency-completion.error.d.ts.map +1 -0
- package/dist/errors/idempotency-completion.error.js +33 -0
- package/dist/errors/idempotency-completion.error.js.map +1 -0
- package/dist/errors/idempotency-conflict.error.d.ts +33 -0
- package/dist/errors/idempotency-conflict.error.d.ts.map +1 -0
- package/dist/errors/idempotency-conflict.error.js +48 -0
- package/dist/errors/idempotency-conflict.error.js.map +1 -0
- package/dist/errors/missing-partition.error.d.ts +17 -0
- package/dist/errors/missing-partition.error.d.ts.map +1 -0
- package/dist/errors/missing-partition.error.js +18 -0
- package/dist/errors/missing-partition.error.js.map +1 -0
- package/dist/helpers/build-attributes.d.ts +47 -0
- package/dist/helpers/build-attributes.d.ts.map +1 -0
- package/dist/helpers/build-attributes.js +59 -0
- package/dist/helpers/build-attributes.js.map +1 -0
- package/dist/helpers/fingerprint.d.ts +8 -0
- package/dist/helpers/fingerprint.d.ts.map +1 -0
- package/dist/helpers/fingerprint.js +13 -0
- package/dist/helpers/fingerprint.js.map +1 -0
- package/dist/helpers/idempotency.intent.d.ts +31 -0
- package/dist/helpers/idempotency.intent.d.ts.map +1 -0
- package/dist/helpers/idempotency.intent.js +26 -0
- package/dist/helpers/idempotency.intent.js.map +1 -0
- package/dist/helpers/json-snapshot.d.ts +6 -0
- package/dist/helpers/json-snapshot.d.ts.map +1 -0
- package/dist/helpers/json-snapshot.js +23 -0
- package/dist/helpers/json-snapshot.js.map +1 -0
- package/dist/helpers/partitioned-key.d.ts +96 -0
- package/dist/helpers/partitioned-key.d.ts.map +1 -0
- package/dist/helpers/partitioned-key.js +36 -0
- package/dist/helpers/partitioned-key.js.map +1 -0
- package/dist/http.d.ts +20 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +26 -0
- package/dist/http.js.map +1 -0
- package/dist/idempotency.behavior.d.ts +117 -0
- package/dist/idempotency.behavior.d.ts.map +1 -0
- package/dist/idempotency.behavior.js +332 -0
- package/dist/idempotency.behavior.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/idempotency-options.interface.d.ts +97 -0
- package/dist/interfaces/idempotency-options.interface.d.ts.map +1 -0
- package/dist/interfaces/idempotency-options.interface.js +3 -0
- package/dist/interfaces/idempotency-options.interface.js.map +1 -0
- package/dist/interfaces/idempotency-record.interface.d.ts +52 -0
- package/dist/interfaces/idempotency-record.interface.d.ts.map +1 -0
- package/dist/interfaces/idempotency-record.interface.js +3 -0
- package/dist/interfaces/idempotency-record.interface.js.map +1 -0
- package/dist/interfaces/idempotency-store.interface.d.ts +59 -0
- package/dist/interfaces/idempotency-store.interface.d.ts.map +1 -0
- package/dist/interfaces/idempotency-store.interface.js +3 -0
- package/dist/interfaces/idempotency-store.interface.js.map +1 -0
- package/dist/stores/memory.store.d.ts +48 -0
- package/dist/stores/memory.store.d.ts.map +1 -0
- package/dist/stores/memory.store.js +119 -0
- package/dist/stores/memory.store.js.map +1 -0
- package/dist/stores/postgres.store.d.ts +72 -0
- package/dist/stores/postgres.store.d.ts.map +1 -0
- package/dist/stores/postgres.store.js +187 -0
- package/dist/stores/postgres.store.js.map +1 -0
- package/dist/stores/redis.store.d.ts +62 -0
- package/dist/stores/redis.store.d.ts.map +1 -0
- package/dist/stores/redis.store.js +79 -0
- package/dist/stores/redis.store.js.map +1 -0
- 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
|
+
}
|