@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
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @cqrs-ddd/pipeline-idempotency
|
|
2
|
+
|
|
3
|
+
Idempotency for `@cqrs-ddd/pipeline`: a command runs once per operation key, a duplicate
|
|
4
|
+
gets the stored response, a key reused with another payload is refused, and with a
|
|
5
|
+
replay scope a response is replayed only to a caller authorized under the same scope.
|
|
6
|
+
Memory, Redis and PostgreSQL stores are included; `toHttpResponse()` from
|
|
7
|
+
`@cqrs-ddd/pipeline-idempotency/http` turns a conflict into a 409 or 422 answer.
|
|
8
|
+
|
|
9
|
+
**Documentation:** [guide](https://aristoteliss.github.io/ddd-cqrs/packages/pipeline-idempotency/) · [API reference](https://aristoteliss.github.io/ddd-cqrs/api/cqrs-ddd/pipeline-idempotency/) · [all packages](https://aristoteliss.github.io/ddd-cqrs/)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @cqrs-ddd/pipeline-idempotency @cqrs-ddd/pipeline
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node.js 22.12 or later. Published as an ES module; a CommonJS application loads
|
|
18
|
+
it with `require()`.
|
|
19
|
+
|
|
20
|
+
## Example
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
const pipeline = createPipeline({
|
|
24
|
+
behaviors: [new IdempotencyBehavior(new MemoryIdempotencyStore())],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const charge = pipeline.wrap(
|
|
28
|
+
{ name: 'charge', kind: 'command' },
|
|
29
|
+
idempotent({ keyFactory: (context) => (context.request as { key: string }).key }),
|
|
30
|
+
)(async (input: { key: string; amount: number }) => payments.charge(input));
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
An idempotency key is an operation identity: partition it by tenant and principal, for
|
|
34
|
+
example with `createPartitionedIdempotencyKeyFactory()`.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
Dual-licensed under **AGPLv3** and a **Commercial License**. See `LICENSE` and
|
|
39
|
+
`COMMERCIAL_LICENSE.txt`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/constants/tokens.ts"],"names":[],"mappings":"AAEA,8EAA8E;AAC9E,eAAO,MAAM,0BAA0B,WAAa,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/constants/tokens.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAEtE,8EAA8E;AAC9E,MAAM,CAAC,MAAM,0BAA0B,GAAG,UAAU,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which post-execution step failed.
|
|
3
|
+
*
|
|
4
|
+
* - `snapshot` — the handler's response could not be serialized into a replayable
|
|
5
|
+
* record (a cycle, a function, an unsupported type).
|
|
6
|
+
* - `store` — the record was serializable but the store rejected the write.
|
|
7
|
+
*/
|
|
8
|
+
export type IdempotencyFinalizationPhase = 'snapshot' | 'store';
|
|
9
|
+
/**
|
|
10
|
+
* Raised when the business handler succeeded but the idempotency record could
|
|
11
|
+
* not be finalized.
|
|
12
|
+
*
|
|
13
|
+
* Retrying the request may repeat business side effects even though the caller
|
|
14
|
+
* received an error. Consumers can use {@link executionSucceeded} to distinguish this state from a
|
|
15
|
+
* handler failure and make an explicit retry/reconciliation decision, and
|
|
16
|
+
* {@link phase} to tell an unusable response apart from an unavailable store.
|
|
17
|
+
*
|
|
18
|
+
* In both phases the claim is deliberately retained until its TTL expires. The
|
|
19
|
+
* side effects have already happened, so releasing the key would let the very
|
|
20
|
+
* next retry repeat them immediately. Retention does not prevent all duplicates
|
|
21
|
+
* — it only prevents immediate reentry while the claim is still live.
|
|
22
|
+
*/
|
|
23
|
+
export declare class IdempotencyCompletionError extends Error {
|
|
24
|
+
readonly key: string;
|
|
25
|
+
readonly claimId: string;
|
|
26
|
+
readonly cause: unknown;
|
|
27
|
+
readonly phase: IdempotencyFinalizationPhase;
|
|
28
|
+
readonly name = "IdempotencyCompletionError";
|
|
29
|
+
readonly executionSucceeded: true;
|
|
30
|
+
constructor(key: string, claimId: string, cause: unknown, phase?: IdempotencyFinalizationPhase);
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=idempotency-completion.error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency-completion.error.d.ts","sourceRoot":"","sources":["../../src/errors/idempotency-completion.error.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,MAAM,4BAA4B,GAAG,UAAU,GAAG,OAAO,CAAC;AAEhE;;;;;;;;;;;;;GAaG;AACH,qBAAa,0BAA2B,SAAQ,KAAK;aAKjC,GAAG,EAAE,MAAM;aACX,OAAO,EAAE,MAAM;aACf,KAAK,EAAE,OAAO;aACd,KAAK,EAAE,4BAA4B;IAPrD,SAAkB,IAAI,gCAAgC;IACtD,QAAQ,CAAC,kBAAkB,OAAiB;IAE5C,YACkB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,OAAO,EACd,KAAK,GAAE,4BAAsC,EAO9D;CACF"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* Copyright (C) 2026-present Aristotelis — see repository license. */
|
|
2
|
+
/**
|
|
3
|
+
* Raised when the business handler succeeded but the idempotency record could
|
|
4
|
+
* not be finalized.
|
|
5
|
+
*
|
|
6
|
+
* Retrying the request may repeat business side effects even though the caller
|
|
7
|
+
* received an error. Consumers can use {@link executionSucceeded} to distinguish this state from a
|
|
8
|
+
* handler failure and make an explicit retry/reconciliation decision, and
|
|
9
|
+
* {@link phase} to tell an unusable response apart from an unavailable store.
|
|
10
|
+
*
|
|
11
|
+
* In both phases the claim is deliberately retained until its TTL expires. The
|
|
12
|
+
* side effects have already happened, so releasing the key would let the very
|
|
13
|
+
* next retry repeat them immediately. Retention does not prevent all duplicates
|
|
14
|
+
* — it only prevents immediate reentry while the claim is still live.
|
|
15
|
+
*/
|
|
16
|
+
export class IdempotencyCompletionError extends Error {
|
|
17
|
+
key;
|
|
18
|
+
claimId;
|
|
19
|
+
cause;
|
|
20
|
+
phase;
|
|
21
|
+
name = 'IdempotencyCompletionError';
|
|
22
|
+
executionSucceeded = true;
|
|
23
|
+
constructor(key, claimId, cause, phase = 'store') {
|
|
24
|
+
super(phase === 'snapshot'
|
|
25
|
+
? `Handler execution succeeded, but its response for idempotency key "${key}" could not be serialized into a replayable record. The claim is retained until it expires so the side effects are not immediately repeated.`
|
|
26
|
+
: `Handler execution succeeded, but the idempotency record for key "${key}" could not be completed.`);
|
|
27
|
+
this.key = key;
|
|
28
|
+
this.claimId = claimId;
|
|
29
|
+
this.cause = cause;
|
|
30
|
+
this.phase = phase;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=idempotency-completion.error.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency-completion.error.js","sourceRoot":"","sources":["../../src/errors/idempotency-completion.error.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAWtE;;;;;;;;;;;;;GAaG;AACH,MAAM,OAAO,0BAA2B,SAAQ,KAAK;IAKjC,GAAG;IACH,OAAO;IACP,KAAK;IACL,KAAK;IAPL,IAAI,GAAG,4BAA4B,CAAC;IAC7C,kBAAkB,GAAG,IAAa,CAAC;IAE5C,YACkB,GAAW,EACX,OAAe,EACf,KAAc,EACd,KAAK,GAAiC,OAAO;QAE7D,KAAK,CACH,KAAK,KAAK,UAAU;YAClB,CAAC,CAAC,sEAAsE,GAAG,8IAA8I;YACzN,CAAC,CAAC,oEAAoE,GAAG,2BAA2B,CACvG,CAAC;mBATc,GAAG;uBACH,OAAO;qBACP,KAAK;qBACL,KAAK;IAOvB,CAAC;CACF"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Why an idempotent request was rejected. */
|
|
2
|
+
export type IdempotencyConflictReason = 'in_progress' | 'key_reuse' | 'replay_scope';
|
|
3
|
+
/**
|
|
4
|
+
* Thrown by {@link IdempotencyBehavior} when a request cannot be served:
|
|
5
|
+
*
|
|
6
|
+
* - `in_progress` — an identical request is still running (`409 Conflict`);
|
|
7
|
+
* - `key_reuse` — the key was already used with a **different** payload
|
|
8
|
+
* (`422 Unprocessable Entity`);
|
|
9
|
+
* - `replay_scope` — the stored response was authorized under a different scope
|
|
10
|
+
* than the current caller's, or the record predates scope capture
|
|
11
|
+
* (`409 Conflict`). The handler is not re-executed and the record is kept.
|
|
12
|
+
*
|
|
13
|
+
* `toHttpResponse` of `@cqrs-ddd/pipeline-idempotency/http` maps it to its HTTP status.
|
|
14
|
+
*/
|
|
15
|
+
export declare class IdempotencyConflictError extends Error {
|
|
16
|
+
/** The idempotency key in conflict. */
|
|
17
|
+
readonly key: string;
|
|
18
|
+
/** The request that was rejected, e.g. `CreateUserCommand`. */
|
|
19
|
+
readonly requestName: string;
|
|
20
|
+
/** Why the request was rejected. */
|
|
21
|
+
readonly reason: IdempotencyConflictReason;
|
|
22
|
+
/**
|
|
23
|
+
* Suggested HTTP status: `409` for `in_progress` and `replay_scope`, `422` for
|
|
24
|
+
* `key_reuse`.
|
|
25
|
+
*/
|
|
26
|
+
readonly statusCode: number;
|
|
27
|
+
constructor(params: {
|
|
28
|
+
key: string;
|
|
29
|
+
requestName: string;
|
|
30
|
+
reason: IdempotencyConflictReason;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=idempotency-conflict.error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency-conflict.error.d.ts","sourceRoot":"","sources":["../../src/errors/idempotency-conflict.error.ts"],"names":[],"mappings":"AAEA,8CAA8C;AAC9C,MAAM,MAAM,yBAAyB,GACjC,aAAa,GACb,WAAW,GACX,cAAc,CAAC;AA0BnB;;;;;;;;;;;GAWG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;IACjD,uCAAuC;IACvC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,oCAAoC;IACpC,QAAQ,CAAC,MAAM,EAAE,yBAAyB,CAAC;IAC3C;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAE5B,YAAY,MAAM,EAAE;QAClB,GAAG,EAAE,MAAM,CAAC;QACZ,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,EAAE,yBAAyB,CAAC;KACnC,EAOA;CACF"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Copyright (C) 2026-present Aristotelis — see repository license. */
|
|
2
|
+
function conflictMessage(params) {
|
|
3
|
+
switch (params.reason) {
|
|
4
|
+
case 'in_progress':
|
|
5
|
+
return (`A request with idempotency key "${params.key}" is already in ` +
|
|
6
|
+
`progress for ${params.requestName}`);
|
|
7
|
+
case 'replay_scope':
|
|
8
|
+
return (`Idempotency key "${params.key}" holds a ${params.requestName} response ` +
|
|
9
|
+
'authorized under a different scope than this caller; it cannot be replayed');
|
|
10
|
+
default:
|
|
11
|
+
return (`Idempotency key "${params.key}" was already used for ` +
|
|
12
|
+
`${params.requestName} with a different payload`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Thrown by {@link IdempotencyBehavior} when a request cannot be served:
|
|
17
|
+
*
|
|
18
|
+
* - `in_progress` — an identical request is still running (`409 Conflict`);
|
|
19
|
+
* - `key_reuse` — the key was already used with a **different** payload
|
|
20
|
+
* (`422 Unprocessable Entity`);
|
|
21
|
+
* - `replay_scope` — the stored response was authorized under a different scope
|
|
22
|
+
* than the current caller's, or the record predates scope capture
|
|
23
|
+
* (`409 Conflict`). The handler is not re-executed and the record is kept.
|
|
24
|
+
*
|
|
25
|
+
* `toHttpResponse` of `@cqrs-ddd/pipeline-idempotency/http` maps it to its HTTP status.
|
|
26
|
+
*/
|
|
27
|
+
export class IdempotencyConflictError extends Error {
|
|
28
|
+
/** The idempotency key in conflict. */
|
|
29
|
+
key;
|
|
30
|
+
/** The request that was rejected, e.g. `CreateUserCommand`. */
|
|
31
|
+
requestName;
|
|
32
|
+
/** Why the request was rejected. */
|
|
33
|
+
reason;
|
|
34
|
+
/**
|
|
35
|
+
* Suggested HTTP status: `409` for `in_progress` and `replay_scope`, `422` for
|
|
36
|
+
* `key_reuse`.
|
|
37
|
+
*/
|
|
38
|
+
statusCode;
|
|
39
|
+
constructor(params) {
|
|
40
|
+
super(conflictMessage(params));
|
|
41
|
+
this.name = 'IdempotencyConflictError';
|
|
42
|
+
this.key = params.key;
|
|
43
|
+
this.requestName = params.requestName;
|
|
44
|
+
this.reason = params.reason;
|
|
45
|
+
this.statusCode = params.reason === 'key_reuse' ? 422 : 409;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=idempotency-conflict.error.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency-conflict.error.js","sourceRoot":"","sources":["../../src/errors/idempotency-conflict.error.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAQtE,SAAS,eAAe,CAAC,MAIxB;IACC,QAAQ,MAAM,CAAC,MAAM,EAAE,CAAC;QACtB,KAAK,aAAa;YAChB,OAAO,CACL,mCAAmC,MAAM,CAAC,GAAG,kBAAkB;gBAC/D,gBAAgB,MAAM,CAAC,WAAW,EAAE,CACrC,CAAC;QACJ,KAAK,cAAc;YACjB,OAAO,CACL,oBAAoB,MAAM,CAAC,GAAG,aAAa,MAAM,CAAC,WAAW,YAAY;gBACzE,4EAA4E,CAC7E,CAAC;QACJ;YACE,OAAO,CACL,oBAAoB,MAAM,CAAC,GAAG,yBAAyB;gBACvD,GAAG,MAAM,CAAC,WAAW,2BAA2B,CACjD,CAAC;IACN,CAAC;AACH,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,wBAAyB,SAAQ,KAAK;IACjD,uCAAuC;IAC9B,GAAG,CAAS;IACrB,+DAA+D;IACtD,WAAW,CAAS;IAC7B,oCAAoC;IAC3B,MAAM,CAA4B;IAC3C;;;OAGG;IACM,UAAU,CAAS;IAE5B,YAAY,MAIX;QACC,KAAK,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC;QAC/B,IAAI,CAAC,IAAI,GAAG,0BAA0B,CAAC;QACvC,IAAI,CAAC,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC;QACtB,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,WAAW,CAAC;QACtC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;QAC5B,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IAC9D,CAAC;CACF"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { MissingPartitionError } from '@cqrs-ddd/pipeline';
|
|
2
|
+
/** Which dimension of the idempotency key could not be resolved. */
|
|
3
|
+
export type IdempotencyPartitionDimension = 'tenant' | 'principal' | 'operation';
|
|
4
|
+
/**
|
|
5
|
+
* Raised before a key is claimed when a dimension required by
|
|
6
|
+
* {@link createPartitionedIdempotencyKeyFactory} cannot be resolved.
|
|
7
|
+
*
|
|
8
|
+
* Omitting a missing tenant or principal would
|
|
9
|
+
* merge isolation domains: every caller whose identity could not be resolved
|
|
10
|
+
* would share one deduplication namespace, so one caller's completed operation
|
|
11
|
+
* could be replayed to another, or suppress another's first execution.
|
|
12
|
+
*/
|
|
13
|
+
export declare class MissingIdempotencyPartitionError extends MissingPartitionError<IdempotencyPartitionDimension> {
|
|
14
|
+
readonly name = "MissingIdempotencyPartitionError";
|
|
15
|
+
constructor(requestName: string, dimension: IdempotencyPartitionDimension, remedy: string);
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=missing-partition.error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"missing-partition.error.d.ts","sourceRoot":"","sources":["../../src/errors/missing-partition.error.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,oEAAoE;AACpE,MAAM,MAAM,6BAA6B,GACrC,QAAQ,GACR,WAAW,GACX,WAAW,CAAC;AAEhB;;;;;;;;GAQG;AACH,qBAAa,gCAAiC,SAAQ,qBAAqB,CAAC,6BAA6B,CAAC;IACxG,SAAkB,IAAI,sCAAsC;IAE5D,YACE,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,6BAA6B,EACxC,MAAM,EAAE,MAAM,EAGf;CACF"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* Copyright (C) 2026-present Aristotelis — see repository license. */
|
|
2
|
+
import { MissingPartitionError } from '@cqrs-ddd/pipeline';
|
|
3
|
+
/**
|
|
4
|
+
* Raised before a key is claimed when a dimension required by
|
|
5
|
+
* {@link createPartitionedIdempotencyKeyFactory} cannot be resolved.
|
|
6
|
+
*
|
|
7
|
+
* Omitting a missing tenant or principal would
|
|
8
|
+
* merge isolation domains: every caller whose identity could not be resolved
|
|
9
|
+
* would share one deduplication namespace, so one caller's completed operation
|
|
10
|
+
* could be replayed to another, or suppress another's first execution.
|
|
11
|
+
*/
|
|
12
|
+
export class MissingIdempotencyPartitionError extends MissingPartitionError {
|
|
13
|
+
name = 'MissingIdempotencyPartitionError';
|
|
14
|
+
constructor(requestName, dimension, remedy) {
|
|
15
|
+
super('Idempotency', requestName, dimension, remedy);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=missing-partition.error.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"missing-partition.error.js","sourceRoot":"","sources":["../../src/errors/missing-partition.error.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAEtE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAQ3D;;;;;;;;GAQG;AACH,MAAM,OAAO,gCAAiC,SAAQ,qBAAoD;IACtF,IAAI,GAAG,kCAAkC,CAAC;IAE5D,YACE,WAAmB,EACnB,SAAwC,EACxC,MAAc;QAEd,KAAK,CAAC,aAAa,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;IACvD,CAAC;CACF"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type IPipelineContext } from '@cqrs-ddd/pipeline';
|
|
2
|
+
/**
|
|
3
|
+
* The idempotency decision of one execution as flat attributes:
|
|
4
|
+
* `idempotency.replayed` once `IdempotencyBehavior` decided whether to replay,
|
|
5
|
+
* and `idempotency.ownership_lost: true` only when the claim was lost while the
|
|
6
|
+
* handler ran. `{}` when the behavior did not run. The key is never included:
|
|
7
|
+
* it carries the tenant, the principal and the client's operation id.
|
|
8
|
+
*
|
|
9
|
+
* The result is plain data and needs no telemetry package: put it on a span, a
|
|
10
|
+
* log line, an audit record or a metric label (bounded values only).
|
|
11
|
+
*
|
|
12
|
+
* @param context - The pipeline execution, read after `IdempotencyBehavior` ran.
|
|
13
|
+
* @returns The attributes; empty when the behavior did not run.
|
|
14
|
+
*
|
|
15
|
+
* @example Span attributes
|
|
16
|
+
* With `AttributesBehavior` of `@cqrs-ddd/pipeline-opentelemetry`:
|
|
17
|
+
* ```ts
|
|
18
|
+
* createPipeline({
|
|
19
|
+
* globalBehaviors: {
|
|
20
|
+
* before: [TraceBehavior, [AttributesBehavior, { factories: [buildIdempotencyAttributes] }]],
|
|
21
|
+
* },
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @example Audit metadata
|
|
26
|
+
* `audit()` is declared outside `idempotent()`, so the record is built after its decision:
|
|
27
|
+
* ```ts
|
|
28
|
+
* @pipeline.wrap(
|
|
29
|
+
* { kind: 'command' },
|
|
30
|
+
* audit({ action: 'order.create', metadata: buildIdempotencyAttributes }),
|
|
31
|
+
* idempotent({ keyFactory }),
|
|
32
|
+
* )
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @example A log line from a custom behavior, after the chain ran
|
|
36
|
+
* ```ts
|
|
37
|
+
* async handle(context: IPipelineContext, next: NextDelegate) {
|
|
38
|
+
* try {
|
|
39
|
+
* return await next();
|
|
40
|
+
* } finally {
|
|
41
|
+
* this.logger.log({ request: context.requestName, ...buildIdempotencyAttributes(context) });
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildIdempotencyAttributes(context: IPipelineContext): Record<string, boolean>;
|
|
47
|
+
//# sourceMappingURL=build-attributes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-attributes.d.ts","sourceRoot":"","sources":["../../src/helpers/build-attributes.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,oBAAoB,CAAC;AAM5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,gBAAgB,GACxB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAYzB"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* Copyright (C) 2026-present Aristotelis — see repository license. */
|
|
2
|
+
import { getPipelineItem, hasPipelineItem, } from '@cqrs-ddd/pipeline';
|
|
3
|
+
import { IDEMPOTENCY_OWNERSHIP_LOST_ITEM_TOKEN, IDEMPOTENCY_REPLAYED_ITEM_TOKEN, } from '../idempotency.behavior.js';
|
|
4
|
+
/**
|
|
5
|
+
* The idempotency decision of one execution as flat attributes:
|
|
6
|
+
* `idempotency.replayed` once `IdempotencyBehavior` decided whether to replay,
|
|
7
|
+
* and `idempotency.ownership_lost: true` only when the claim was lost while the
|
|
8
|
+
* handler ran. `{}` when the behavior did not run. The key is never included:
|
|
9
|
+
* it carries the tenant, the principal and the client's operation id.
|
|
10
|
+
*
|
|
11
|
+
* The result is plain data and needs no telemetry package: put it on a span, a
|
|
12
|
+
* log line, an audit record or a metric label (bounded values only).
|
|
13
|
+
*
|
|
14
|
+
* @param context - The pipeline execution, read after `IdempotencyBehavior` ran.
|
|
15
|
+
* @returns The attributes; empty when the behavior did not run.
|
|
16
|
+
*
|
|
17
|
+
* @example Span attributes
|
|
18
|
+
* With `AttributesBehavior` of `@cqrs-ddd/pipeline-opentelemetry`:
|
|
19
|
+
* ```ts
|
|
20
|
+
* createPipeline({
|
|
21
|
+
* globalBehaviors: {
|
|
22
|
+
* before: [TraceBehavior, [AttributesBehavior, { factories: [buildIdempotencyAttributes] }]],
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @example Audit metadata
|
|
28
|
+
* `audit()` is declared outside `idempotent()`, so the record is built after its decision:
|
|
29
|
+
* ```ts
|
|
30
|
+
* @pipeline.wrap(
|
|
31
|
+
* { kind: 'command' },
|
|
32
|
+
* audit({ action: 'order.create', metadata: buildIdempotencyAttributes }),
|
|
33
|
+
* idempotent({ keyFactory }),
|
|
34
|
+
* )
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @example A log line from a custom behavior, after the chain ran
|
|
38
|
+
* ```ts
|
|
39
|
+
* async handle(context: IPipelineContext, next: NextDelegate) {
|
|
40
|
+
* try {
|
|
41
|
+
* return await next();
|
|
42
|
+
* } finally {
|
|
43
|
+
* this.logger.log({ request: context.requestName, ...buildIdempotencyAttributes(context) });
|
|
44
|
+
* }
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function buildIdempotencyAttributes(context) {
|
|
49
|
+
const attributes = {};
|
|
50
|
+
if (hasPipelineItem(context, IDEMPOTENCY_REPLAYED_ITEM_TOKEN)) {
|
|
51
|
+
attributes['idempotency.replayed'] =
|
|
52
|
+
getPipelineItem(context, IDEMPOTENCY_REPLAYED_ITEM_TOKEN) === true;
|
|
53
|
+
}
|
|
54
|
+
if (getPipelineItem(context, IDEMPOTENCY_OWNERSHIP_LOST_ITEM_TOKEN) === true) {
|
|
55
|
+
attributes['idempotency.ownership_lost'] = true;
|
|
56
|
+
}
|
|
57
|
+
return attributes;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=build-attributes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-attributes.js","sourceRoot":"","sources":["../../src/helpers/build-attributes.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAEtE,OAAO,EACL,eAAe,EACf,eAAe,GAEhB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,qCAAqC,EACrC,+BAA+B,GAChC,MAAM,4BAA4B,CAAC;AAEpC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,UAAU,0BAA0B,CACxC,OAAyB;IAEzB,MAAM,UAAU,GAA4B,EAAE,CAAC;IAC/C,IAAI,eAAe,CAAC,OAAO,EAAE,+BAA+B,CAAC,EAAE,CAAC;QAC9D,UAAU,CAAC,sBAAsB,CAAC;YAChC,eAAe,CAAC,OAAO,EAAE,+BAA+B,CAAC,KAAK,IAAI,CAAC;IACvE,CAAC;IACD,IACE,eAAe,CAAC,OAAO,EAAE,qCAAqC,CAAC,KAAK,IAAI,EACxE,CAAC;QACD,UAAU,CAAC,4BAA4B,CAAC,GAAG,IAAI,CAAC;IAClD,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Produces a stable SHA-256 hex digest of an acyclic JSON-serializable value,
|
|
3
|
+
* with object keys sorted so semantically-equal payloads hash identically
|
|
4
|
+
* regardless of property order. Used to detect an idempotency key being reused
|
|
5
|
+
* with a different body.
|
|
6
|
+
*/
|
|
7
|
+
export declare function fingerprintValue(value: unknown): string;
|
|
8
|
+
//# sourceMappingURL=fingerprint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fingerprint.d.ts","sourceRoot":"","sources":["../../src/helpers/fingerprint.ts"],"names":[],"mappings":"AAKA;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEvD"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Copyright (C) 2026-present Aristotelis — see repository license. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { stableStringify } from '@cqrs-ddd/safe-stringify';
|
|
4
|
+
/**
|
|
5
|
+
* Produces a stable SHA-256 hex digest of an acyclic JSON-serializable value,
|
|
6
|
+
* with object keys sorted so semantically-equal payloads hash identically
|
|
7
|
+
* regardless of property order. Used to detect an idempotency key being reused
|
|
8
|
+
* with a different body.
|
|
9
|
+
*/
|
|
10
|
+
export function fingerprintValue(value) {
|
|
11
|
+
return createHash('sha256').update(stableStringify(value)).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=fingerprint.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fingerprint.js","sourceRoot":"","sources":["../../src/helpers/fingerprint.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAEtE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAE3D;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC3E,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PipelineBehaviorTuple } from '@cqrs-ddd/pipeline';
|
|
2
|
+
import { IdempotencyBehavior } from '../idempotency.behavior.js';
|
|
3
|
+
import type { IdempotencyBehaviorOptions } from '../interfaces/idempotency-options.interface.js';
|
|
4
|
+
export type IdempotencyIntentOptions = Omit<IdempotencyBehaviorOptions, 'keyFactory'> & ({
|
|
5
|
+
keyFactory: NonNullable<IdempotencyBehaviorOptions['keyFactory']>;
|
|
6
|
+
inheritModuleKey?: never;
|
|
7
|
+
} | {
|
|
8
|
+
inheritModuleKey: true;
|
|
9
|
+
keyFactory?: never;
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* Returns an idempotency behavior entry for `pipeline.wrap()` or `createPipeline({ globalBehaviors })`.
|
|
13
|
+
* @param options A key factory and idempotency options, or `inheritModuleKey: true`
|
|
14
|
+
* when the behavior's constructor defaults supply the key factory. The inheritance marker is not
|
|
15
|
+
* forwarded to the behavior and does not validate module configuration.
|
|
16
|
+
* @returns The behavior class and options tuple.
|
|
17
|
+
* Keys must distinguish operations and any tenant/principal scope that changes
|
|
18
|
+
* the authorized response; a shared key can replay another caller's response.
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* export const createUser = pipeline.wrap(
|
|
22
|
+
* { name: 'createUser', kind: 'command' },
|
|
23
|
+
* idempotent({ keyFactory: createUserKey, ttl: 86_400_000 }),
|
|
24
|
+
* )(users.create);
|
|
25
|
+
*
|
|
26
|
+
* // The key factory comes from the IdempotencyBehavior constructor defaults.
|
|
27
|
+
* const entry = idempotent({ inheritModuleKey: true });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function idempotent(options: IdempotencyIntentOptions): PipelineBehaviorTuple<IdempotencyBehavior, IdempotencyBehaviorOptions>;
|
|
31
|
+
//# sourceMappingURL=idempotency.intent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency.intent.d.ts","sourceRoot":"","sources":["../../src/helpers/idempotency.intent.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,gDAAgD,CAAC;AAEjG,MAAM,MAAM,wBAAwB,GAAG,IAAI,CACzC,0BAA0B,EAC1B,YAAY,CACb,GACC,CACI;IACE,UAAU,EAAE,WAAW,CAAC,0BAA0B,CAAC,YAAY,CAAC,CAAC,CAAC;IAClE,gBAAgB,CAAC,EAAE,KAAK,CAAC;CAC1B,GACD;IAAE,gBAAgB,EAAE,IAAI,CAAC;IAAC,UAAU,CAAC,EAAE,KAAK,CAAA;CAAE,CACjD,CAAC;AAEJ;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,wBAAwB,GAChC,qBAAqB,CAAC,mBAAmB,EAAE,0BAA0B,CAAC,CAGxE"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* Copyright (C) 2026-present Aristotelis — see repository license. */
|
|
2
|
+
import { IdempotencyBehavior } from '../idempotency.behavior.js';
|
|
3
|
+
/**
|
|
4
|
+
* Returns an idempotency behavior entry for `pipeline.wrap()` or `createPipeline({ globalBehaviors })`.
|
|
5
|
+
* @param options A key factory and idempotency options, or `inheritModuleKey: true`
|
|
6
|
+
* when the behavior's constructor defaults supply the key factory. The inheritance marker is not
|
|
7
|
+
* forwarded to the behavior and does not validate module configuration.
|
|
8
|
+
* @returns The behavior class and options tuple.
|
|
9
|
+
* Keys must distinguish operations and any tenant/principal scope that changes
|
|
10
|
+
* the authorized response; a shared key can replay another caller's response.
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* export const createUser = pipeline.wrap(
|
|
14
|
+
* { name: 'createUser', kind: 'command' },
|
|
15
|
+
* idempotent({ keyFactory: createUserKey, ttl: 86_400_000 }),
|
|
16
|
+
* )(users.create);
|
|
17
|
+
*
|
|
18
|
+
* // The key factory comes from the IdempotencyBehavior constructor defaults.
|
|
19
|
+
* const entry = idempotent({ inheritModuleKey: true });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function idempotent(options) {
|
|
23
|
+
const { inheritModuleKey: _inheritModuleKey, ...behaviorOptions } = options;
|
|
24
|
+
return [IdempotencyBehavior, behaviorOptions];
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=idempotency.intent.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency.intent.js","sourceRoot":"","sources":["../../src/helpers/idempotency.intent.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAGtE,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAejE;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,UAAU,CACxB,OAAiC;IAEjC,MAAM,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,GAAG,eAAe,EAAE,GAAG,OAAO,CAAC;IAC5E,OAAO,CAAC,mBAAmB,EAAE,eAAe,CAAC,CAAC;AAChD,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IdempotencyRecord, JsonValue } from '../interfaces/idempotency-record.interface.js';
|
|
2
|
+
/** Convert a replay value to the representation shared by durable stores. */
|
|
3
|
+
export declare function toJsonSnapshot(value: unknown): JsonValue | undefined;
|
|
4
|
+
/** Clone a record with the same JSON boundary used by Redis and Postgres. */
|
|
5
|
+
export declare function cloneIdempotencyRecord(record: IdempotencyRecord): IdempotencyRecord;
|
|
6
|
+
//# sourceMappingURL=json-snapshot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json-snapshot.d.ts","sourceRoot":"","sources":["../../src/helpers/json-snapshot.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,iBAAiB,EACjB,SAAS,EACV,MAAM,+CAA+C,CAAC;AAEvD,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CAUpE;AAED,6EAA6E;AAC7E,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,iBAAiB,GACxB,iBAAiB,CAQnB"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/* Copyright (C) 2026-present Aristotelis — see repository license. */
|
|
2
|
+
import { toStrictJsonValue } from '@cqrs-ddd/safe-stringify';
|
|
3
|
+
/** Convert a replay value to the representation shared by durable stores. */
|
|
4
|
+
export function toJsonSnapshot(value) {
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
try {
|
|
8
|
+
return toStrictJsonValue(value);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new TypeError('Idempotency responses must be acyclic JSON-serializable values.');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Clone a record with the same JSON boundary used by Redis and Postgres. */
|
|
15
|
+
export function cloneIdempotencyRecord(record) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(JSON.stringify(record));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new TypeError('Idempotency records must contain only acyclic JSON-serializable values.');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=json-snapshot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json-snapshot.js","sourceRoot":"","sources":["../../src/helpers/json-snapshot.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAEtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAM7D,6EAA6E;AAC7E,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAE1C,IAAI,CAAC;QACH,OAAO,iBAAiB,CAAC,KAAK,CAAc,CAAC;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,SAAS,CACjB,iEAAiE,CAClE,CAAC;IACJ,CAAC;AACH,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,sBAAsB,CACpC,MAAyB;IAEzB,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAsB,CAAC;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,SAAS,CACjB,yEAAyE,CAC1E,CAAC;IACJ,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type IPipelineContext, type TenantPartitionOptions } from '@cqrs-ddd/pipeline';
|
|
2
|
+
import type { IdempotencyKeyFactory } from '../interfaces/idempotency-options.interface.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the principal performing an operation.
|
|
5
|
+
*
|
|
6
|
+
* Return one identifier, or several segments when the identity has more than one
|
|
7
|
+
* dimension — for example `['service', clientId]`, so a service and a user that
|
|
8
|
+
* share an id string never share a namespace. Read it from authenticated context,
|
|
9
|
+
* never from a request body field the caller controls.
|
|
10
|
+
*/
|
|
11
|
+
export type IdempotencyPrincipalFactory = (context: IPipelineContext) => string | readonly string[] | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Resolves the identity of one operation within the principal's namespace: a
|
|
14
|
+
* client-supplied `Idempotency-Key`, or a business identifier the operation is
|
|
15
|
+
* deduplicated by.
|
|
16
|
+
*/
|
|
17
|
+
export type IdempotencyOperationFactory = (context: IPipelineContext) => string | undefined;
|
|
18
|
+
/** Options for {@link createPartitionedIdempotencyKeyFactory}. */
|
|
19
|
+
export interface PartitionedIdempotencyKeyOptions extends TenantPartitionOptions {
|
|
20
|
+
/** Who performs the operation. Required, and never defaulted. */
|
|
21
|
+
principal: IdempotencyPrincipalFactory;
|
|
22
|
+
/** Which operation this is, within that principal's namespace. */
|
|
23
|
+
operation: IdempotencyOperationFactory;
|
|
24
|
+
/**
|
|
25
|
+
* Name of the operation kind. Defaults to `context.requestName`; pass a stable
|
|
26
|
+
* value when the request class may be renamed without the operation changing.
|
|
27
|
+
*/
|
|
28
|
+
action?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Leading namespace segment. Change it only deliberately: a new namespace
|
|
31
|
+
* abandons every claim stored under the old one, so a completed operation can
|
|
32
|
+
* execute again until those records expire.
|
|
33
|
+
*/
|
|
34
|
+
version?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Behavior when the operation identity is absent.
|
|
37
|
+
*
|
|
38
|
+
* - `throw` — the request is rejected.
|
|
39
|
+
* - `skip` — no key is produced, so the request runs without deduplication.
|
|
40
|
+
* Use this for an optional client `Idempotency-Key` header.
|
|
41
|
+
*
|
|
42
|
+
* A missing principal always throws: there is no safe namespace to fall back to.
|
|
43
|
+
*
|
|
44
|
+
* @default 'throw'
|
|
45
|
+
*/
|
|
46
|
+
onMissingOperation?: 'throw' | 'skip';
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Creates an {@link IdempotencyKeyFactory} whose keys partition by tenant,
|
|
50
|
+
* principal and operation, and fail closed when any of them is missing.
|
|
51
|
+
*
|
|
52
|
+
* Segments are escaped and joined with `joinKeySegments` from
|
|
53
|
+
* `@cqrs-ddd/safe-stringify`, so values
|
|
54
|
+
* containing the separator — email addresses, external subject claims, composite
|
|
55
|
+
* ids — cannot make two different operations collide on one key.
|
|
56
|
+
*
|
|
57
|
+
* The key is an operation identity. Do not fold permissions into it: a key that
|
|
58
|
+
* changed with permissions would let the same side effect run again. Bind replay
|
|
59
|
+
* to the caller's authorization with `replayScopeFactory` instead.
|
|
60
|
+
*
|
|
61
|
+
* The produced key is
|
|
62
|
+
* `[version:]<tenantId>:<principal…>:<action>:<operation>`, with the tenant
|
|
63
|
+
* segment omitted when `includeTenant` is `false`.
|
|
64
|
+
*
|
|
65
|
+
* @throws {MissingIdempotencyPartitionError} When the tenant or principal is
|
|
66
|
+
* absent, or the operation is absent and `onMissingOperation` is `'throw'`.
|
|
67
|
+
*
|
|
68
|
+
* @example Per authenticated principal, deduplicated by a business identifier
|
|
69
|
+
* ```ts
|
|
70
|
+
* const createOrderKey = createPartitionedIdempotencyKeyFactory({
|
|
71
|
+
* version: 'v1',
|
|
72
|
+
* action: 'order.create',
|
|
73
|
+
* principal: (ctx) => ['user', ctx.items.get(CURRENT_USER_ID) as string],
|
|
74
|
+
* operation: (ctx) => (ctx.request as CreateOrderCommand).externalRef,
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* class CreateOrderHandler {
|
|
78
|
+
* @pipeline.wrap({ kind: 'command' }, [IdempotencyBehavior, { keyFactory: createOrderKey }])
|
|
79
|
+
* async handle(command: CreateOrderCommand) {}
|
|
80
|
+
* }
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* @example Optional client-supplied Idempotency-Key
|
|
84
|
+
* ```ts
|
|
85
|
+
* createPartitionedIdempotencyKeyFactory({
|
|
86
|
+
* principal: readUserId,
|
|
87
|
+
* operation: (ctx) => (ctx.request as PayCommand).idempotencyKey,
|
|
88
|
+
* onMissingOperation: 'skip',
|
|
89
|
+
* });
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export declare function createPartitionedIdempotencyKeyFactory(options: PartitionedIdempotencyKeyOptions & {
|
|
93
|
+
onMissingOperation?: 'throw';
|
|
94
|
+
}): (context: IPipelineContext) => string;
|
|
95
|
+
export declare function createPartitionedIdempotencyKeyFactory(options: PartitionedIdempotencyKeyOptions): IdempotencyKeyFactory;
|
|
96
|
+
//# sourceMappingURL=partitioned-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"partitioned-key.d.ts","sourceRoot":"","sources":["../../src/helpers/partitioned-key.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,EAE5B,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gDAAgD,CAAC;AAE5F;;;;;;;GAOG;AACH,MAAM,MAAM,2BAA2B,GAAG,CACxC,OAAO,EAAE,gBAAgB,KACtB,MAAM,GAAG,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;AAE5C;;;;GAIG;AACH,MAAM,MAAM,2BAA2B,GAAG,CACxC,OAAO,EAAE,gBAAgB,KACtB,MAAM,GAAG,SAAS,CAAC;AAExB,kEAAkE;AAClE,MAAM,WAAW,gCACf,SAAQ,sBAAsB;IAC9B,iEAAiE;IACjE,SAAS,EAAE,2BAA2B,CAAC;IAEvC,kEAAkE;IAClE,SAAS,EAAE,2BAA2B,CAAC;IAEvC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,sCAAsC,CACpD,OAAO,EAAE,gCAAgC,GAAG;IAAE,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAE,GAC3E,CAAC,OAAO,EAAE,gBAAgB,KAAK,MAAM,CAAC;AACzC,wBAAgB,sCAAsC,CACpD,OAAO,EAAE,gCAAgC,GACxC,qBAAqB,CAAC"}
|