@beignet/core 0.0.1 → 0.0.3

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 (287) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +202 -8
  3. package/dist/application/index.d.ts +93 -9
  4. package/dist/application/index.d.ts.map +1 -1
  5. package/dist/application/index.js +11 -11
  6. package/dist/application/index.js.map +1 -1
  7. package/dist/client/client.d.ts +73 -12
  8. package/dist/client/client.d.ts.map +1 -1
  9. package/dist/client/client.js +37 -12
  10. package/dist/client/client.js.map +1 -1
  11. package/dist/client/index.d.ts +12 -0
  12. package/dist/client/index.d.ts.map +1 -1
  13. package/dist/client/index.js +6 -0
  14. package/dist/client/index.js.map +1 -1
  15. package/dist/client/types.d.ts +69 -8
  16. package/dist/client/types.d.ts.map +1 -1
  17. package/dist/config/index.d.ts +84 -0
  18. package/dist/config/index.d.ts.map +1 -1
  19. package/dist/config/index.js +36 -0
  20. package/dist/config/index.js.map +1 -1
  21. package/dist/contracts/contract-builder.d.ts +49 -22
  22. package/dist/contracts/contract-builder.d.ts.map +1 -1
  23. package/dist/contracts/contract-builder.js +48 -21
  24. package/dist/contracts/contract-builder.js.map +1 -1
  25. package/dist/contracts/contract-group.d.ts +35 -19
  26. package/dist/contracts/contract-group.d.ts.map +1 -1
  27. package/dist/contracts/contract-group.js +35 -19
  28. package/dist/contracts/contract-group.js.map +1 -1
  29. package/dist/contracts/contract-like.d.ts +4 -4
  30. package/dist/contracts/contract-like.d.ts.map +1 -1
  31. package/dist/contracts/contract-like.js +2 -1
  32. package/dist/contracts/contract-like.js.map +1 -1
  33. package/dist/contracts/index.d.ts +28 -0
  34. package/dist/contracts/index.d.ts.map +1 -1
  35. package/dist/contracts/index.js +12 -0
  36. package/dist/contracts/index.js.map +1 -1
  37. package/dist/contracts/openapi-meta.d.ts +8 -8
  38. package/dist/contracts/openapi-meta.d.ts.map +1 -1
  39. package/dist/contracts/path-template.d.ts +27 -0
  40. package/dist/contracts/path-template.d.ts.map +1 -1
  41. package/dist/contracts/path-template.js +6 -0
  42. package/dist/contracts/path-template.js.map +1 -1
  43. package/dist/contracts/types.d.ts +104 -10
  44. package/dist/contracts/types.d.ts.map +1 -1
  45. package/dist/contracts/types.js +15 -0
  46. package/dist/contracts/types.js.map +1 -1
  47. package/dist/contracts/utils.d.ts +6 -0
  48. package/dist/contracts/utils.d.ts.map +1 -1
  49. package/dist/contracts/utils.js +6 -0
  50. package/dist/contracts/utils.js.map +1 -1
  51. package/dist/domain/entity.d.ts +22 -11
  52. package/dist/domain/entity.d.ts.map +1 -1
  53. package/dist/domain/entity.js +5 -1
  54. package/dist/domain/entity.js.map +1 -1
  55. package/dist/domain/events.d.ts +5 -2
  56. package/dist/domain/events.d.ts.map +1 -1
  57. package/dist/domain/events.js +4 -1
  58. package/dist/domain/events.js.map +1 -1
  59. package/dist/domain/value-object.d.ts +19 -9
  60. package/dist/domain/value-object.d.ts.map +1 -1
  61. package/dist/domain/value-object.js +5 -1
  62. package/dist/domain/value-object.js.map +1 -1
  63. package/dist/errors/catalog.d.ts +40 -16
  64. package/dist/errors/catalog.d.ts.map +1 -1
  65. package/dist/errors/catalog.js +18 -7
  66. package/dist/errors/catalog.js.map +1 -1
  67. package/dist/errors/response.d.ts +16 -4
  68. package/dist/errors/response.d.ts.map +1 -1
  69. package/dist/errors/response.js +3 -3
  70. package/dist/errors/response.js.map +1 -1
  71. package/dist/errors/validation.d.ts +10 -1
  72. package/dist/errors/validation.d.ts.map +1 -1
  73. package/dist/errors/validation.js +3 -0
  74. package/dist/errors/validation.js.map +1 -1
  75. package/dist/events/index.d.ts +133 -0
  76. package/dist/events/index.d.ts.map +1 -1
  77. package/dist/events/index.js +30 -0
  78. package/dist/events/index.js.map +1 -1
  79. package/dist/idempotency/index.d.ts +355 -0
  80. package/dist/idempotency/index.d.ts.map +1 -0
  81. package/dist/idempotency/index.js +360 -0
  82. package/dist/idempotency/index.js.map +1 -0
  83. package/dist/jobs/index.d.ts +248 -4
  84. package/dist/jobs/index.d.ts.map +1 -1
  85. package/dist/jobs/index.js +183 -1
  86. package/dist/jobs/index.js.map +1 -1
  87. package/dist/mail/index.d.ts +149 -0
  88. package/dist/mail/index.d.ts.map +1 -1
  89. package/dist/mail/index.js +30 -0
  90. package/dist/mail/index.js.map +1 -1
  91. package/dist/notifications/index.d.ts +369 -0
  92. package/dist/notifications/index.d.ts.map +1 -0
  93. package/dist/notifications/index.js +310 -0
  94. package/dist/notifications/index.js.map +1 -0
  95. package/dist/openapi/index.d.ts +132 -16
  96. package/dist/openapi/index.d.ts.map +1 -1
  97. package/dist/openapi/index.js +1 -1
  98. package/dist/openapi/index.js.map +1 -1
  99. package/dist/outbox/index.d.ts +474 -0
  100. package/dist/outbox/index.d.ts.map +1 -0
  101. package/dist/outbox/index.js +538 -0
  102. package/dist/outbox/index.js.map +1 -0
  103. package/dist/pagination/index.d.ts +166 -0
  104. package/dist/pagination/index.d.ts.map +1 -0
  105. package/dist/pagination/index.js +96 -0
  106. package/dist/pagination/index.js.map +1 -0
  107. package/dist/ports/audit.d.ts +271 -0
  108. package/dist/ports/audit.d.ts.map +1 -1
  109. package/dist/ports/audit.js +128 -0
  110. package/dist/ports/audit.js.map +1 -1
  111. package/dist/ports/auth.d.ts +70 -0
  112. package/dist/ports/auth.d.ts.map +1 -1
  113. package/dist/ports/auth.js +30 -0
  114. package/dist/ports/auth.js.map +1 -1
  115. package/dist/ports/cache.d.ts +41 -0
  116. package/dist/ports/cache.d.ts.map +1 -1
  117. package/dist/ports/cache.js +10 -0
  118. package/dist/ports/cache.js.map +1 -1
  119. package/dist/ports/clock.d.ts +38 -0
  120. package/dist/ports/clock.d.ts.map +1 -1
  121. package/dist/ports/clock.js +20 -0
  122. package/dist/ports/clock.js.map +1 -1
  123. package/dist/ports/id-generator.d.ts +37 -0
  124. package/dist/ports/id-generator.d.ts.map +1 -1
  125. package/dist/ports/id-generator.js +22 -0
  126. package/dist/ports/id-generator.js.map +1 -1
  127. package/dist/ports/index.d.ts +83 -0
  128. package/dist/ports/index.d.ts.map +1 -1
  129. package/dist/ports/index.js +41 -5
  130. package/dist/ports/index.js.map +1 -1
  131. package/dist/ports/logger.d.ts +56 -0
  132. package/dist/ports/logger.d.ts.map +1 -1
  133. package/dist/ports/logger.js +17 -0
  134. package/dist/ports/logger.js.map +1 -1
  135. package/dist/ports/policy.d.ts +132 -0
  136. package/dist/ports/policy.d.ts.map +1 -1
  137. package/dist/ports/policy.js +45 -0
  138. package/dist/ports/policy.js.map +1 -1
  139. package/dist/ports/rate-limit.d.ts +25 -0
  140. package/dist/ports/rate-limit.d.ts.map +1 -1
  141. package/dist/ports/rate-limit.js +10 -0
  142. package/dist/ports/rate-limit.js.map +1 -1
  143. package/dist/ports/redaction.d.ts +101 -0
  144. package/dist/ports/redaction.d.ts.map +1 -1
  145. package/dist/ports/redaction.js +59 -0
  146. package/dist/ports/redaction.js.map +1 -1
  147. package/dist/ports/storage.d.ts +100 -0
  148. package/dist/ports/storage.d.ts.map +1 -1
  149. package/dist/ports/storage.js +10 -0
  150. package/dist/ports/storage.js.map +1 -1
  151. package/dist/ports/testing.d.ts +47 -0
  152. package/dist/ports/testing.d.ts.map +1 -1
  153. package/dist/ports/testing.js +23 -0
  154. package/dist/ports/testing.js.map +1 -1
  155. package/dist/ports/unit-of-work.d.ts +60 -3
  156. package/dist/ports/unit-of-work.d.ts.map +1 -1
  157. package/dist/ports/unit-of-work.js +11 -2
  158. package/dist/ports/unit-of-work.js.map +1 -1
  159. package/dist/providers/instrumentation.d.ts +205 -1
  160. package/dist/providers/instrumentation.d.ts.map +1 -1
  161. package/dist/providers/instrumentation.js +14 -0
  162. package/dist/providers/instrumentation.js.map +1 -1
  163. package/dist/providers/provider.d.ts +14 -1
  164. package/dist/providers/provider.d.ts.map +1 -1
  165. package/dist/providers/provider.js.map +1 -1
  166. package/dist/schedules/index.d.ts +246 -0
  167. package/dist/schedules/index.d.ts.map +1 -1
  168. package/dist/schedules/index.js +27 -0
  169. package/dist/schedules/index.js.map +1 -1
  170. package/dist/server/health.d.ts +14 -5
  171. package/dist/server/health.d.ts.map +1 -1
  172. package/dist/server/health.js +5 -2
  173. package/dist/server/health.js.map +1 -1
  174. package/dist/server/hooks/auth.d.ts +68 -26
  175. package/dist/server/hooks/auth.d.ts.map +1 -1
  176. package/dist/server/hooks/auth.js +44 -55
  177. package/dist/server/hooks/auth.js.map +1 -1
  178. package/dist/server/hooks/cors.d.ts +27 -0
  179. package/dist/server/hooks/cors.d.ts.map +1 -1
  180. package/dist/server/hooks/cors.js +12 -0
  181. package/dist/server/hooks/cors.js.map +1 -1
  182. package/dist/server/hooks/errors.d.ts +15 -6
  183. package/dist/server/hooks/errors.d.ts.map +1 -1
  184. package/dist/server/hooks/errors.js.map +1 -1
  185. package/dist/server/hooks/index.d.ts +4 -1
  186. package/dist/server/hooks/index.d.ts.map +1 -1
  187. package/dist/server/hooks/index.js +3 -0
  188. package/dist/server/hooks/index.js.map +1 -1
  189. package/dist/server/hooks/logging.d.ts +36 -0
  190. package/dist/server/hooks/logging.d.ts.map +1 -1
  191. package/dist/server/hooks/logging.js +6 -0
  192. package/dist/server/hooks/logging.js.map +1 -1
  193. package/dist/server/hooks/rate-limit.d.ts +33 -0
  194. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  195. package/dist/server/hooks/rate-limit.js +11 -0
  196. package/dist/server/hooks/rate-limit.js.map +1 -1
  197. package/dist/server/http.d.ts +222 -0
  198. package/dist/server/http.d.ts.map +1 -1
  199. package/dist/server/http.js +20 -1
  200. package/dist/server/http.js.map +1 -1
  201. package/dist/server/index.d.ts +19 -1
  202. package/dist/server/index.d.ts.map +1 -1
  203. package/dist/server/index.js +7 -1
  204. package/dist/server/index.js.map +1 -1
  205. package/dist/server/openapi.d.ts +5 -3
  206. package/dist/server/openapi.d.ts.map +1 -1
  207. package/dist/server/openapi.js +4 -2
  208. package/dist/server/openapi.js.map +1 -1
  209. package/dist/server/providers/loadProviderConfig.d.ts +9 -0
  210. package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
  211. package/dist/server/providers/loadProviderConfig.js +9 -0
  212. package/dist/server/providers/loadProviderConfig.js.map +1 -1
  213. package/dist/server/server.d.ts +159 -19
  214. package/dist/server/server.d.ts.map +1 -1
  215. package/dist/server/server.js +72 -31
  216. package/dist/server/server.js.map +1 -1
  217. package/dist/testing/index.d.ts +171 -0
  218. package/dist/testing/index.d.ts.map +1 -0
  219. package/dist/testing/index.js +127 -0
  220. package/dist/testing/index.js.map +1 -0
  221. package/dist/uploads/client.d.ts +278 -0
  222. package/dist/uploads/client.d.ts.map +1 -0
  223. package/dist/uploads/client.js +428 -0
  224. package/dist/uploads/client.js.map +1 -0
  225. package/dist/uploads/index.d.ts +361 -0
  226. package/dist/uploads/index.d.ts.map +1 -0
  227. package/dist/uploads/index.js +543 -0
  228. package/dist/uploads/index.js.map +1 -0
  229. package/package.json +31 -2
  230. package/src/application/index.ts +85 -22
  231. package/src/client/client.ts +73 -12
  232. package/src/client/index.ts +12 -0
  233. package/src/client/types.ts +70 -9
  234. package/src/config/index.ts +86 -0
  235. package/src/contracts/contract-builder.ts +49 -22
  236. package/src/contracts/contract-group.ts +35 -19
  237. package/src/contracts/contract-like.ts +4 -4
  238. package/src/contracts/index.ts +28 -1
  239. package/src/contracts/openapi-meta.ts +8 -8
  240. package/src/contracts/path-template.ts +27 -0
  241. package/src/contracts/types.ts +111 -10
  242. package/src/contracts/utils.ts +6 -0
  243. package/src/domain/entity.ts +22 -11
  244. package/src/domain/events.ts +5 -2
  245. package/src/domain/value-object.ts +19 -9
  246. package/src/errors/catalog.ts +40 -16
  247. package/src/errors/response.ts +16 -4
  248. package/src/errors/validation.ts +10 -1
  249. package/src/events/index.ts +134 -0
  250. package/src/idempotency/index.ts +767 -0
  251. package/src/jobs/index.ts +437 -5
  252. package/src/mail/index.ts +149 -0
  253. package/src/notifications/index.ts +771 -0
  254. package/src/openapi/index.ts +133 -16
  255. package/src/outbox/index.ts +1104 -0
  256. package/src/pagination/index.ts +278 -0
  257. package/src/ports/audit.ts +271 -0
  258. package/src/ports/auth.ts +70 -0
  259. package/src/ports/cache.ts +41 -0
  260. package/src/ports/clock.ts +38 -0
  261. package/src/ports/id-generator.ts +37 -0
  262. package/src/ports/index.ts +106 -11
  263. package/src/ports/logger.ts +56 -0
  264. package/src/ports/policy.ts +133 -0
  265. package/src/ports/rate-limit.ts +25 -0
  266. package/src/ports/redaction.ts +101 -0
  267. package/src/ports/storage.ts +100 -0
  268. package/src/ports/testing.ts +47 -0
  269. package/src/ports/unit-of-work.ts +60 -3
  270. package/src/providers/instrumentation.ts +211 -1
  271. package/src/providers/provider.ts +14 -1
  272. package/src/schedules/index.ts +247 -0
  273. package/src/server/health.ts +14 -5
  274. package/src/server/hooks/auth.ts +105 -120
  275. package/src/server/hooks/cors.ts +27 -0
  276. package/src/server/hooks/errors.ts +15 -6
  277. package/src/server/hooks/index.ts +4 -5
  278. package/src/server/hooks/logging.ts +36 -0
  279. package/src/server/hooks/rate-limit.ts +33 -0
  280. package/src/server/http.ts +249 -1
  281. package/src/server/index.ts +19 -1
  282. package/src/server/openapi.ts +5 -3
  283. package/src/server/providers/loadProviderConfig.ts +9 -0
  284. package/src/server/server.ts +296 -30
  285. package/src/testing/index.ts +348 -0
  286. package/src/uploads/client.ts +861 -0
  287. package/src/uploads/index.ts +1067 -0
@@ -0,0 +1,767 @@
1
+ /**
2
+ * @beignet/core/idempotency
3
+ *
4
+ * Idempotency primitives for retry-safe commands, webhooks, and jobs.
5
+ */
6
+
7
+ /**
8
+ * Value or promise of that value.
9
+ */
10
+ export type MaybePromise<T> = T | Promise<T>;
11
+
12
+ /**
13
+ * Primitive value accepted inside an idempotency scope object.
14
+ */
15
+ export type IdempotencyScopeValue =
16
+ | string
17
+ | number
18
+ | boolean
19
+ | null
20
+ | undefined;
21
+
22
+ /**
23
+ * Logical scope for idempotency keys.
24
+ *
25
+ * String scopes are used as-is. Object scopes are normalized by sorting keys and
26
+ * joining key/value pairs so adapters can build stable storage keys.
27
+ */
28
+ export type IdempotencyScope = string | Record<string, IdempotencyScopeValue>;
29
+
30
+ /**
31
+ * Scope mode that HTTP hooks can use when deriving an idempotency scope.
32
+ */
33
+ export type IdempotencyScopeMode =
34
+ | "global"
35
+ | "actor"
36
+ | "tenant"
37
+ | "actor-tenant";
38
+
39
+ /**
40
+ * Contract metadata for idempotency-aware routes.
41
+ */
42
+ export interface IdempotencyMeta {
43
+ /**
44
+ * Whether this operation requires an idempotency key at the HTTP boundary.
45
+ *
46
+ * This is metadata for hooks, docs, and generated OpenAPI. Use cases should
47
+ * still call `runIdempotently(...)` for workflows that must be retry-safe.
48
+ */
49
+ required?: boolean;
50
+
51
+ /**
52
+ * Header that carries the idempotency key.
53
+ *
54
+ * Default: "idempotency-key".
55
+ */
56
+ header?: string;
57
+
58
+ /**
59
+ * How to scope idempotency keys when an HTTP hook derives the scope.
60
+ *
61
+ * Default: "global".
62
+ */
63
+ scope?: IdempotencyScopeMode;
64
+
65
+ /**
66
+ * Time-to-live for reserved and completed keys.
67
+ */
68
+ ttlSec?: number;
69
+ }
70
+
71
+ /**
72
+ * Input for reserving an idempotency key.
73
+ */
74
+ export interface IdempotencyReserveInput {
75
+ /**
76
+ * Operation namespace, usually a use-case or route name.
77
+ */
78
+ namespace: string;
79
+ /**
80
+ * Client-provided idempotency key.
81
+ */
82
+ key: string;
83
+ /**
84
+ * Logical scope for this key.
85
+ */
86
+ scope?: IdempotencyScope;
87
+ /**
88
+ * Fingerprint of the logical command payload.
89
+ */
90
+ fingerprint: string;
91
+ /**
92
+ * Optional key time-to-live in seconds.
93
+ */
94
+ ttlSec?: number;
95
+ }
96
+
97
+ /**
98
+ * Result of reserving an idempotency key.
99
+ */
100
+ export type IdempotencyReservation =
101
+ | {
102
+ status: "reserved";
103
+ namespace: string;
104
+ key: string;
105
+ scopeKey: string;
106
+ fingerprint: string;
107
+ reservedAt: Date;
108
+ expiresAt: Date | null;
109
+ }
110
+ | {
111
+ status: "replay";
112
+ namespace: string;
113
+ key: string;
114
+ scopeKey: string;
115
+ fingerprint: string;
116
+ result: unknown;
117
+ reservedAt: Date;
118
+ completedAt: Date;
119
+ expiresAt: Date | null;
120
+ }
121
+ | {
122
+ status: "inProgress";
123
+ namespace: string;
124
+ key: string;
125
+ scopeKey: string;
126
+ fingerprint: string;
127
+ reservedAt: Date;
128
+ expiresAt: Date | null;
129
+ }
130
+ | {
131
+ status: "conflict";
132
+ namespace: string;
133
+ key: string;
134
+ scopeKey: string;
135
+ storedFingerprint: string;
136
+ receivedFingerprint: string;
137
+ reservedAt: Date;
138
+ completedAt?: Date;
139
+ expiresAt: Date | null;
140
+ };
141
+
142
+ /**
143
+ * Input for marking an idempotency key complete.
144
+ */
145
+ export interface IdempotencyCompleteInput {
146
+ /**
147
+ * Operation namespace.
148
+ */
149
+ namespace: string;
150
+ /**
151
+ * Client-provided idempotency key.
152
+ */
153
+ key: string;
154
+ /**
155
+ * Logical scope for this key.
156
+ */
157
+ scope?: IdempotencyScope;
158
+ /**
159
+ * Fingerprint that must match the reserved operation.
160
+ */
161
+ fingerprint: string;
162
+ /**
163
+ * Result to replay for future matching requests.
164
+ */
165
+ result?: unknown;
166
+ }
167
+
168
+ /**
169
+ * Input for releasing or marking a failed idempotency reservation.
170
+ */
171
+ export interface IdempotencyFailInput {
172
+ /**
173
+ * Operation namespace.
174
+ */
175
+ namespace: string;
176
+ /**
177
+ * Client-provided idempotency key.
178
+ */
179
+ key: string;
180
+ /**
181
+ * Logical scope for this key.
182
+ */
183
+ scope?: IdempotencyScope;
184
+ /**
185
+ * Fingerprint that must match the reserved operation.
186
+ */
187
+ fingerprint: string;
188
+ /**
189
+ * Error that caused the protected operation to fail.
190
+ */
191
+ error?: unknown;
192
+ }
193
+
194
+ /**
195
+ * App-facing idempotency port.
196
+ */
197
+ export interface IdempotencyPort {
198
+ /**
199
+ * Atomically reserve a key for work, replay an already completed result, or
200
+ * report that the key is in progress/conflicting.
201
+ */
202
+ reserve(input: IdempotencyReserveInput): Promise<IdempotencyReservation>;
203
+
204
+ /**
205
+ * Mark a reserved key as complete and store the result that may be replayed.
206
+ */
207
+ complete(input: IdempotencyCompleteInput): Promise<void>;
208
+
209
+ /**
210
+ * Release or mark a reserved key after the protected work fails.
211
+ */
212
+ fail(input: IdempotencyFailInput): Promise<void>;
213
+ }
214
+
215
+ /**
216
+ * Options for reserving an idempotency key around a protected operation.
217
+ */
218
+ export interface RunIdempotentlyOptions<Result>
219
+ extends IdempotencyReserveInput {
220
+ /**
221
+ * Protected operation to run after the key is reserved.
222
+ */
223
+ run: () => MaybePromise<Result>;
224
+ /**
225
+ * Replay behavior for completed matching reservations.
226
+ *
227
+ * Defaults to returning the stored result. Use `"error"` when callers need to
228
+ * distinguish replay from first execution.
229
+ */
230
+ replay?: "return" | "error";
231
+ }
232
+
233
+ /**
234
+ * Options for `createIdempotencyFingerprint(...)`.
235
+ */
236
+ export interface CreateIdempotencyFingerprintOptions {
237
+ /**
238
+ * Omit values from the fingerprint input. Use this for the idempotency key
239
+ * itself or other request metadata that does not define the logical command.
240
+ *
241
+ * String paths can be top-level keys (`"idempotencyKey"`) or dotted paths
242
+ * (`"metadata.requestId"`). Array paths avoid ambiguity when keys contain
243
+ * dots.
244
+ */
245
+ omit?: readonly (string | readonly string[])[];
246
+ }
247
+
248
+ /**
249
+ * In-memory idempotency store for tests and local examples.
250
+ */
251
+ export interface MemoryIdempotencyStore extends IdempotencyPort {
252
+ /**
253
+ * Current store entries.
254
+ */
255
+ readonly entries: readonly MemoryIdempotencyEntry[];
256
+ /**
257
+ * Remove all entries.
258
+ */
259
+ clear(): void;
260
+ }
261
+
262
+ /**
263
+ * Snapshot entry from the memory idempotency store.
264
+ */
265
+ export interface MemoryIdempotencyEntry {
266
+ /**
267
+ * Operation namespace.
268
+ */
269
+ namespace: string;
270
+ /**
271
+ * Client-provided idempotency key.
272
+ */
273
+ key: string;
274
+ /**
275
+ * Normalized scope key.
276
+ */
277
+ scopeKey: string;
278
+ /**
279
+ * Fingerprint of the logical command payload.
280
+ */
281
+ fingerprint: string;
282
+ /**
283
+ * Memory store status.
284
+ */
285
+ status: "in-progress" | "completed";
286
+ /**
287
+ * Stored result for completed entries.
288
+ */
289
+ result?: unknown;
290
+ /**
291
+ * Reservation timestamp.
292
+ */
293
+ reservedAt: Date;
294
+ /**
295
+ * Completion timestamp.
296
+ */
297
+ completedAt?: Date;
298
+ /**
299
+ * Expiration timestamp, or null for no expiration.
300
+ */
301
+ expiresAt: Date | null;
302
+ }
303
+
304
+ /**
305
+ * Error thrown when an idempotency key is reused with a different fingerprint.
306
+ */
307
+ export class IdempotencyConflictError extends Error {
308
+ readonly namespace: string;
309
+ readonly key: string;
310
+ readonly scopeKey: string;
311
+ readonly storedFingerprint: string;
312
+ readonly receivedFingerprint: string;
313
+
314
+ constructor(args: {
315
+ namespace: string;
316
+ key: string;
317
+ scopeKey: string;
318
+ storedFingerprint: string;
319
+ receivedFingerprint: string;
320
+ }) {
321
+ super(
322
+ `Idempotency key "${args.key}" conflicts with a different payload in namespace "${args.namespace}".`,
323
+ );
324
+ this.name = "IdempotencyConflictError";
325
+ this.namespace = args.namespace;
326
+ this.key = args.key;
327
+ this.scopeKey = args.scopeKey;
328
+ this.storedFingerprint = args.storedFingerprint;
329
+ this.receivedFingerprint = args.receivedFingerprint;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Error thrown when an idempotency key is already reserved by in-progress work.
335
+ */
336
+ export class IdempotencyInProgressError extends Error {
337
+ readonly namespace: string;
338
+ readonly key: string;
339
+ readonly scopeKey: string;
340
+
341
+ constructor(args: { namespace: string; key: string; scopeKey: string }) {
342
+ super(
343
+ `Idempotency key "${args.key}" is already in progress in namespace "${args.namespace}".`,
344
+ );
345
+ this.name = "IdempotencyInProgressError";
346
+ this.namespace = args.namespace;
347
+ this.key = args.key;
348
+ this.scopeKey = args.scopeKey;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Error thrown when replay is disabled for a completed idempotency key.
354
+ */
355
+ export class IdempotencyReplayError extends Error {
356
+ readonly namespace: string;
357
+ readonly key: string;
358
+ readonly scopeKey: string;
359
+
360
+ constructor(args: { namespace: string; key: string; scopeKey: string }) {
361
+ super(
362
+ `Idempotency key "${args.key}" already completed in namespace "${args.namespace}".`,
363
+ );
364
+ this.name = "IdempotencyReplayError";
365
+ this.namespace = args.namespace;
366
+ this.key = args.key;
367
+ this.scopeKey = args.scopeKey;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Error thrown when fingerprint input cannot be canonicalized.
373
+ */
374
+ export class IdempotencyFingerprintError extends Error {
375
+ constructor(message: string) {
376
+ super(message);
377
+ this.name = "IdempotencyFingerprintError";
378
+ }
379
+ }
380
+
381
+ type CanonicalValue =
382
+ | null
383
+ | string
384
+ | number
385
+ | boolean
386
+ | readonly CanonicalValue[]
387
+ | { readonly [key: string]: CanonicalValue };
388
+
389
+ type MemoryRecord = MemoryIdempotencyEntry;
390
+
391
+ function assertNonEmptyString(name: string, value: string): void {
392
+ if (typeof value !== "string" || value.trim().length === 0) {
393
+ throw new Error(`${name} must be a non-empty string`);
394
+ }
395
+ }
396
+
397
+ function assertTtl(ttlSec: number | undefined): void {
398
+ if (ttlSec === undefined) return;
399
+ if (!Number.isInteger(ttlSec) || ttlSec <= 0) {
400
+ throw new Error("ttlSec must be a positive integer when provided");
401
+ }
402
+ }
403
+
404
+ function normalizeScopeValue(value: IdempotencyScopeValue): string {
405
+ if (value === undefined) return "";
406
+ if (value === null) return "null";
407
+ return String(value);
408
+ }
409
+
410
+ /**
411
+ * Normalize an idempotency scope into a stable string.
412
+ */
413
+ export function normalizeIdempotencyScope(
414
+ scope: IdempotencyScope | undefined,
415
+ ): string {
416
+ if (scope === undefined) return "global";
417
+ if (typeof scope === "string") return scope;
418
+
419
+ return Object.keys(scope)
420
+ .sort()
421
+ .map((key) => `${key}:${normalizeScopeValue(scope[key])}`)
422
+ .join("|");
423
+ }
424
+
425
+ /**
426
+ * Create the stable storage key for an idempotency operation.
427
+ */
428
+ export function createIdempotencyStorageKey(input: {
429
+ namespace: string;
430
+ key: string;
431
+ scope?: IdempotencyScope;
432
+ }): string {
433
+ assertNonEmptyString("namespace", input.namespace);
434
+ assertNonEmptyString("key", input.key);
435
+
436
+ return [
437
+ input.namespace,
438
+ normalizeIdempotencyScope(input.scope),
439
+ input.key,
440
+ ].join("\u0000");
441
+ }
442
+
443
+ function resolveExpiresAt(ttlSec: number | undefined, now: Date): Date | null {
444
+ assertTtl(ttlSec);
445
+ return ttlSec === undefined ? null : new Date(now.getTime() + ttlSec * 1000);
446
+ }
447
+
448
+ function isExpired(entry: MemoryRecord, now: Date): boolean {
449
+ return entry.expiresAt !== null && entry.expiresAt.getTime() <= now.getTime();
450
+ }
451
+
452
+ function reservationFromRecord(
453
+ record: MemoryRecord,
454
+ receivedFingerprint: string,
455
+ ): IdempotencyReservation {
456
+ if (record.fingerprint !== receivedFingerprint) {
457
+ return {
458
+ status: "conflict",
459
+ namespace: record.namespace,
460
+ key: record.key,
461
+ scopeKey: record.scopeKey,
462
+ storedFingerprint: record.fingerprint,
463
+ receivedFingerprint,
464
+ reservedAt: record.reservedAt,
465
+ completedAt: record.completedAt,
466
+ expiresAt: record.expiresAt,
467
+ };
468
+ }
469
+
470
+ if (record.status === "completed" && record.completedAt) {
471
+ return {
472
+ status: "replay",
473
+ namespace: record.namespace,
474
+ key: record.key,
475
+ scopeKey: record.scopeKey,
476
+ fingerprint: record.fingerprint,
477
+ result: record.result,
478
+ reservedAt: record.reservedAt,
479
+ completedAt: record.completedAt,
480
+ expiresAt: record.expiresAt,
481
+ };
482
+ }
483
+
484
+ return {
485
+ status: "inProgress",
486
+ namespace: record.namespace,
487
+ key: record.key,
488
+ scopeKey: record.scopeKey,
489
+ fingerprint: record.fingerprint,
490
+ reservedAt: record.reservedAt,
491
+ expiresAt: record.expiresAt,
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Create an in-memory idempotency store for tests and local examples.
497
+ *
498
+ * The memory store is process-local and not suitable for multi-process
499
+ * production deployments.
500
+ */
501
+ export function createMemoryIdempotencyStore(): MemoryIdempotencyStore {
502
+ const records = new Map<string, MemoryRecord>();
503
+
504
+ return {
505
+ get entries() {
506
+ return [...records.values()];
507
+ },
508
+
509
+ async reserve(input) {
510
+ assertNonEmptyString("namespace", input.namespace);
511
+ assertNonEmptyString("key", input.key);
512
+ assertNonEmptyString("fingerprint", input.fingerprint);
513
+ assertTtl(input.ttlSec);
514
+
515
+ const now = new Date();
516
+ const storageKey = createIdempotencyStorageKey(input);
517
+ const existing = records.get(storageKey);
518
+
519
+ if (existing && !isExpired(existing, now)) {
520
+ return reservationFromRecord(existing, input.fingerprint);
521
+ }
522
+
523
+ if (existing) {
524
+ records.delete(storageKey);
525
+ }
526
+
527
+ const record: MemoryRecord = {
528
+ namespace: input.namespace,
529
+ key: input.key,
530
+ scopeKey: normalizeIdempotencyScope(input.scope),
531
+ fingerprint: input.fingerprint,
532
+ status: "in-progress",
533
+ reservedAt: now,
534
+ expiresAt: resolveExpiresAt(input.ttlSec, now),
535
+ };
536
+
537
+ records.set(storageKey, record);
538
+
539
+ return {
540
+ status: "reserved",
541
+ namespace: record.namespace,
542
+ key: record.key,
543
+ scopeKey: record.scopeKey,
544
+ fingerprint: record.fingerprint,
545
+ reservedAt: record.reservedAt,
546
+ expiresAt: record.expiresAt,
547
+ };
548
+ },
549
+
550
+ async complete(input) {
551
+ assertNonEmptyString("namespace", input.namespace);
552
+ assertNonEmptyString("key", input.key);
553
+ assertNonEmptyString("fingerprint", input.fingerprint);
554
+
555
+ const storageKey = createIdempotencyStorageKey(input);
556
+ const existing = records.get(storageKey);
557
+ if (!existing || existing.fingerprint !== input.fingerprint) return;
558
+
559
+ existing.status = "completed";
560
+ existing.result = input.result;
561
+ existing.completedAt = new Date();
562
+ },
563
+
564
+ async fail(input) {
565
+ assertNonEmptyString("namespace", input.namespace);
566
+ assertNonEmptyString("key", input.key);
567
+ assertNonEmptyString("fingerprint", input.fingerprint);
568
+
569
+ const storageKey = createIdempotencyStorageKey(input);
570
+ const existing = records.get(storageKey);
571
+ if (!existing || existing.fingerprint !== input.fingerprint) return;
572
+ if (existing.status === "completed") return;
573
+
574
+ records.delete(storageKey);
575
+ },
576
+
577
+ clear() {
578
+ records.clear();
579
+ },
580
+ };
581
+ }
582
+
583
+ /**
584
+ * Run an operation behind an idempotency reservation.
585
+ *
586
+ * The flow is: reserve the key, replay completed matching results, reject
587
+ * in-progress/conflicting keys, run the operation for new reservations, then
588
+ * complete or fail the reservation. Callers are responsible for choosing a
589
+ * namespace, scope, key, and fingerprint that match their business operation.
590
+ */
591
+ export async function runIdempotently<Result>(
592
+ idempotency: IdempotencyPort,
593
+ options: RunIdempotentlyOptions<Result>,
594
+ ): Promise<Result> {
595
+ const operation = {
596
+ namespace: options.namespace,
597
+ key: options.key,
598
+ scope: options.scope,
599
+ fingerprint: options.fingerprint,
600
+ ttlSec: options.ttlSec,
601
+ };
602
+ const reservation = await idempotency.reserve(operation);
603
+
604
+ switch (reservation.status) {
605
+ case "replay": {
606
+ if (options.replay === "error") {
607
+ throw new IdempotencyReplayError(reservation);
608
+ }
609
+ return reservation.result as Result;
610
+ }
611
+ case "inProgress": {
612
+ throw new IdempotencyInProgressError(reservation);
613
+ }
614
+ case "conflict": {
615
+ throw new IdempotencyConflictError(reservation);
616
+ }
617
+ case "reserved": {
618
+ try {
619
+ const result = await options.run();
620
+ await idempotency.complete({
621
+ namespace: operation.namespace,
622
+ key: operation.key,
623
+ scope: operation.scope,
624
+ fingerprint: operation.fingerprint,
625
+ result,
626
+ });
627
+ return result;
628
+ } catch (error) {
629
+ await idempotency.fail({
630
+ namespace: operation.namespace,
631
+ key: operation.key,
632
+ scope: operation.scope,
633
+ fingerprint: operation.fingerprint,
634
+ error,
635
+ });
636
+ throw error;
637
+ }
638
+ }
639
+ }
640
+ }
641
+
642
+ function normalizeOmitPath(
643
+ path: string | readonly string[],
644
+ ): readonly string[] {
645
+ if (typeof path === "string") return path.split(".").filter(Boolean);
646
+ return path.map(String);
647
+ }
648
+
649
+ function shouldOmitPath(
650
+ path: readonly string[],
651
+ omit: readonly (string | readonly string[])[],
652
+ ): boolean {
653
+ return omit.some((entry) => {
654
+ const omitPath = normalizeOmitPath(entry);
655
+ if (omitPath.length !== path.length) return false;
656
+ return omitPath.every((segment, index) => segment === path[index]);
657
+ });
658
+ }
659
+
660
+ function canonicalize(
661
+ value: unknown,
662
+ options: CreateIdempotencyFingerprintOptions,
663
+ path: readonly string[],
664
+ seen: WeakSet<object>,
665
+ ): CanonicalValue | undefined {
666
+ if (shouldOmitPath(path, options.omit ?? [])) {
667
+ return undefined;
668
+ }
669
+
670
+ if (value === undefined || typeof value === "function") {
671
+ return undefined;
672
+ }
673
+
674
+ if (
675
+ value === null ||
676
+ typeof value === "string" ||
677
+ typeof value === "boolean"
678
+ ) {
679
+ return value;
680
+ }
681
+
682
+ if (typeof value === "number") {
683
+ if (!Number.isFinite(value)) {
684
+ throw new IdempotencyFingerprintError(
685
+ "Cannot fingerprint non-finite numeric values.",
686
+ );
687
+ }
688
+ return value;
689
+ }
690
+
691
+ if (typeof value === "bigint") {
692
+ return value.toString();
693
+ }
694
+
695
+ if (value instanceof Date) {
696
+ return value.toISOString();
697
+ }
698
+
699
+ if (Array.isArray(value)) {
700
+ return value.map(
701
+ (item, index) =>
702
+ canonicalize(item, options, [...path, String(index)], seen) ?? null,
703
+ );
704
+ }
705
+
706
+ if (typeof value === "object") {
707
+ if (seen.has(value)) {
708
+ throw new IdempotencyFingerprintError(
709
+ "Cannot fingerprint circular values.",
710
+ );
711
+ }
712
+ seen.add(value);
713
+
714
+ const result: Record<string, CanonicalValue> = {};
715
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
716
+ const nestedValue = canonicalize(
717
+ (value as Record<string, unknown>)[key],
718
+ options,
719
+ [...path, key],
720
+ seen,
721
+ );
722
+ if (nestedValue !== undefined) {
723
+ result[key] = nestedValue;
724
+ }
725
+ }
726
+
727
+ seen.delete(value);
728
+ return result;
729
+ }
730
+
731
+ throw new IdempotencyFingerprintError(
732
+ `Cannot fingerprint value of type "${typeof value}".`,
733
+ );
734
+ }
735
+
736
+ function bytesToHex(bytes: ArrayBuffer): string {
737
+ return [...new Uint8Array(bytes)]
738
+ .map((byte) => byte.toString(16).padStart(2, "0"))
739
+ .join("");
740
+ }
741
+
742
+ /**
743
+ * Create a SHA-256 fingerprint from a canonicalized value.
744
+ *
745
+ * Object keys are sorted, `undefined` and functions are omitted, `Date` values
746
+ * become ISO strings, BigInts become strings, and circular or non-finite values
747
+ * throw. Exact omit paths may be supplied as dotted strings or string arrays.
748
+ */
749
+ export async function createIdempotencyFingerprint(
750
+ value: unknown,
751
+ options: CreateIdempotencyFingerprintOptions = {},
752
+ ): Promise<string> {
753
+ if (!globalThis.crypto?.subtle) {
754
+ throw new IdempotencyFingerprintError(
755
+ "Cannot create an idempotency fingerprint because Web Crypto is unavailable.",
756
+ );
757
+ }
758
+
759
+ const canonical = canonicalize(value, options, [], new WeakSet());
760
+ const json = JSON.stringify(canonical ?? null);
761
+ const digest = await globalThis.crypto.subtle.digest(
762
+ "SHA-256",
763
+ new TextEncoder().encode(json),
764
+ );
765
+
766
+ return `sha256:${bytesToHex(digest)}`;
767
+ }