@beignet/provider-db-drizzle 0.0.37 → 0.0.39

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 (45) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +49 -11
  3. package/dist/mysql/index.d.ts +2 -1
  4. package/dist/mysql/index.d.ts.map +1 -1
  5. package/dist/mysql/index.js +13 -2
  6. package/dist/mysql/index.js.map +1 -1
  7. package/dist/mysql/schema.d.ts +17 -0
  8. package/dist/mysql/schema.d.ts.map +1 -1
  9. package/dist/mysql/schema.js +1 -0
  10. package/dist/mysql/schema.js.map +1 -1
  11. package/dist/postgres/index.d.ts.map +1 -1
  12. package/dist/postgres/index.js +1 -0
  13. package/dist/postgres/index.js.map +1 -1
  14. package/dist/postgres/schema.d.ts +17 -0
  15. package/dist/postgres/schema.d.ts.map +1 -1
  16. package/dist/postgres/schema.js +1 -0
  17. package/dist/postgres/schema.js.map +1 -1
  18. package/dist/shared/idempotency-core.d.ts +2 -0
  19. package/dist/shared/idempotency-core.d.ts.map +1 -1
  20. package/dist/shared/idempotency-core.js +56 -8
  21. package/dist/shared/idempotency-core.js.map +1 -1
  22. package/dist/shared/outbox-core.d.ts.map +1 -1
  23. package/dist/shared/outbox-core.js +2 -0
  24. package/dist/shared/outbox-core.js.map +1 -1
  25. package/dist/shared/rows.d.ts +1 -0
  26. package/dist/shared/rows.d.ts.map +1 -1
  27. package/dist/shared/rows.js +13 -0
  28. package/dist/shared/rows.js.map +1 -1
  29. package/dist/sqlite/index.d.ts.map +1 -1
  30. package/dist/sqlite/index.js +1 -0
  31. package/dist/sqlite/index.js.map +1 -1
  32. package/dist/sqlite/schema.d.ts +19 -0
  33. package/dist/sqlite/schema.d.ts.map +1 -1
  34. package/dist/sqlite/schema.js +1 -0
  35. package/dist/sqlite/schema.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/mysql/index.ts +19 -2
  38. package/src/mysql/schema.ts +1 -0
  39. package/src/postgres/index.ts +1 -0
  40. package/src/postgres/schema.ts +1 -0
  41. package/src/shared/idempotency-core.ts +88 -7
  42. package/src/shared/outbox-core.ts +2 -0
  43. package/src/shared/rows.ts +13 -0
  44. package/src/sqlite/index.ts +1 -0
  45. package/src/sqlite/schema.ts +1 -0
@@ -13,6 +13,7 @@
13
13
 
14
14
  import {
15
15
  createIdempotencyStorageKey,
16
+ DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC,
16
17
  type IdempotencyCompleteInput,
17
18
  type IdempotencyFailInput,
18
19
  type IdempotencyPort,
@@ -57,6 +58,8 @@ export interface IdempotencyPortCoreOptions {
57
58
  * Clock used by tests and deterministic environments.
58
59
  */
59
60
  now?: () => Date;
61
+ /** Token factory used for deterministic tests. */
62
+ createReservationToken?: () => string;
60
63
  }
61
64
 
62
65
  /**
@@ -84,7 +87,7 @@ export type IdempotencyMutationErrorConstructor = new (
84
87
  export function formatIdempotencyMutationErrorMessage(
85
88
  args: IdempotencyMutationErrorArgs,
86
89
  ): string {
87
- return `Idempotency ${args.action} for key "${args.key}" in namespace "${args.namespace}" matched no in-progress reservation with this fingerprint. The reservation may be missing, expired, already completed or released, or reserved with a different fingerprint.`;
90
+ return `Idempotency ${args.action} for key "${args.key}" in namespace "${args.namespace}" matched no in-progress reservation with this fingerprint and reservation token. The reservation may be missing, expired, already completed or released, or owned by a different execution.`;
88
91
  }
89
92
 
90
93
  function nowFrom(options: IdempotencyPortCoreOptions): Date {
@@ -117,6 +120,7 @@ function validateReserveInput(input: IdempotencyReserveInput): void {
117
120
  assertNonEmptyString("key", input.key);
118
121
  assertNonEmptyString("fingerprint", input.fingerprint);
119
122
  assertIdempotencyTtl(input.ttlSec);
123
+ assertIdempotencyTtl(input.reservationTtlSec);
120
124
  }
121
125
 
122
126
  function validateMutationInput(
@@ -125,6 +129,65 @@ function validateMutationInput(
125
129
  assertNonEmptyString("namespace", input.namespace);
126
130
  assertNonEmptyString("key", input.key);
127
131
  assertNonEmptyString("fingerprint", input.fingerprint);
132
+ assertNonEmptyString("reservationToken", input.reservationToken);
133
+ }
134
+
135
+ type StoredReservationState = {
136
+ reservationToken: string;
137
+ replayTtlSec: number | null;
138
+ };
139
+
140
+ function encodeReservationState(
141
+ reservationToken: string,
142
+ replayTtlSec: number | undefined,
143
+ ): string {
144
+ return JSON.stringify({
145
+ reservationToken,
146
+ replayTtlSec: replayTtlSec ?? null,
147
+ });
148
+ }
149
+
150
+ function decodeReservationState(
151
+ value: string | null,
152
+ ): StoredReservationState | undefined {
153
+ if (value === null) return undefined;
154
+ try {
155
+ const parsed = JSON.parse(value) as {
156
+ reservationToken?: unknown;
157
+ replayTtlSec?: unknown;
158
+ };
159
+ if (
160
+ typeof parsed.reservationToken !== "string" ||
161
+ (parsed.replayTtlSec !== null &&
162
+ (!Number.isInteger(parsed.replayTtlSec) ||
163
+ Number(parsed.replayTtlSec) <= 0))
164
+ ) {
165
+ return undefined;
166
+ }
167
+ return {
168
+ reservationToken: parsed.reservationToken,
169
+ replayTtlSec:
170
+ parsed.replayTtlSec === null ? null : Number(parsed.replayTtlSec),
171
+ };
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ }
176
+
177
+ function createRandomReservationToken(): string {
178
+ if (typeof globalThis.crypto?.randomUUID === "function") {
179
+ return globalThis.crypto.randomUUID();
180
+ }
181
+
182
+ const bytes = new Uint8Array(16);
183
+ globalThis.crypto?.getRandomValues?.(bytes);
184
+ if (bytes.some((value) => value !== 0)) {
185
+ return Array.from(bytes, (value) =>
186
+ value.toString(16).padStart(2, "0"),
187
+ ).join("");
188
+ }
189
+
190
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
128
191
  }
129
192
 
130
193
  /**
@@ -172,7 +235,15 @@ export function createIdempotencyPortCore(
172
235
 
173
236
  const now = nowFrom(options);
174
237
  const nowIso = encodeTimestamp(now);
175
- const expiresAt = resolveIdempotencyExpiresAt(input.ttlSec, now);
238
+ const expiresAt = resolveIdempotencyExpiresAt(
239
+ input.reservationTtlSec ?? DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC,
240
+ now,
241
+ );
242
+ const reservationToken = encodeReservationState(
243
+ (options.createReservationToken ?? createRandomReservationToken)(),
244
+ input.ttlSec,
245
+ );
246
+ const reservationJson = reservationToken;
176
247
  const storageKey = createIdempotencyStorageKey(input);
177
248
  const scopeKey = normalizeIdempotencyScope(input.scope);
178
249
  const reserved = (): IdempotencyReservation => ({
@@ -181,6 +252,7 @@ export function createIdempotencyPortCore(
181
252
  key: input.key,
182
253
  scopeKey,
183
254
  fingerprint: input.fingerprint,
255
+ reservationToken,
184
256
  reservedAt: now,
185
257
  expiresAt,
186
258
  });
@@ -204,7 +276,7 @@ export function createIdempotencyPortCore(
204
276
  ${scopeKey},
205
277
  ${input.fingerprint},
206
278
  'in-progress',
207
- null,
279
+ ${reservationJson},
208
280
  ${nowIso},
209
281
  null,
210
282
  ${expiresAt ? encodeTimestamp(expiresAt) : null},
@@ -225,7 +297,7 @@ export function createIdempotencyPortCore(
225
297
  scope_key = ${scopeKey},
226
298
  fingerprint = ${input.fingerprint},
227
299
  status = 'in-progress',
228
- result_json = null,
300
+ result_json = ${reservationJson},
229
301
  reserved_at = ${nowIso},
230
302
  completed_at = null,
231
303
  expires_at = ${expiresAt ? encodeTimestamp(expiresAt) : null},
@@ -265,8 +337,14 @@ export function createIdempotencyPortCore(
265
337
  async complete(input) {
266
338
  validateMutationInput(input);
267
339
 
268
- const nowIso = encodeTimestamp(nowFrom(options));
340
+ const completedAt = nowFrom(options);
341
+ const completedAtIso = encodeTimestamp(completedAt);
269
342
  const storageKey = createIdempotencyStorageKey(input);
343
+ const reservationState = decodeReservationState(input.reservationToken);
344
+ const expiresAt = resolveIdempotencyExpiresAt(
345
+ reservationState?.replayTtlSec ?? undefined,
346
+ completedAt,
347
+ );
270
348
  const serializedResult = JSON.stringify(input.result);
271
349
  const resultJson =
272
350
  serializedResult === undefined ? null : serializedResult;
@@ -275,10 +353,12 @@ export function createIdempotencyPortCore(
275
353
  set
276
354
  status = 'completed',
277
355
  result_json = ${resultJson},
278
- completed_at = ${nowIso},
279
- updated_at = ${nowIso}
356
+ completed_at = ${completedAtIso},
357
+ expires_at = ${expiresAt ? encodeTimestamp(expiresAt) : null},
358
+ updated_at = ${completedAtIso}
280
359
  where storage_key = ${storageKey}
281
360
  and fingerprint = ${input.fingerprint}
361
+ and result_json = ${input.reservationToken}
282
362
  and status = 'in-progress'`);
283
363
 
284
364
  assertIdempotencyMutationSucceeded("complete", input, rowsAffected);
@@ -291,6 +371,7 @@ export function createIdempotencyPortCore(
291
371
  const rowsAffected = await executor.run(sql`delete from ${executor.table}
292
372
  where storage_key = ${storageKey}
293
373
  and fingerprint = ${input.fingerprint}
374
+ and result_json = ${input.reservationToken}
294
375
  and status = 'in-progress'`);
295
376
 
296
377
  assertIdempotencyMutationSucceeded("fail", input, rowsAffected);
@@ -362,6 +362,7 @@ export function createOutboxPortCore(
362
362
  kind,
363
363
  name,
364
364
  payload_json,
365
+ trace_context_json,
365
366
  status,
366
367
  attempts,
367
368
  max_attempts,
@@ -378,6 +379,7 @@ export function createOutboxPortCore(
378
379
  ${message.kind},
379
380
  ${message.name},
380
381
  ${JSON.stringify(message.payload)},
382
+ ${message.trace ? JSON.stringify(message.trace) : null},
381
383
  ${message.status},
382
384
  ${message.attempts},
383
385
  ${message.maxAttempts},
@@ -19,6 +19,7 @@ import {
19
19
  type OutboxMessageKind,
20
20
  type OutboxMessageStatus,
21
21
  } from "@beignet/core/outbox";
22
+ import { parseTraceCarrier, type TraceCarrier } from "@beignet/core/tracing";
22
23
 
23
24
  /**
24
25
  * Encode a timestamp for storage. All dialects store ISO-8601 UTC strings.
@@ -56,6 +57,7 @@ export interface OutboxRow {
56
57
  kind: OutboxMessageKind;
57
58
  name: string;
58
59
  payload_json: string;
60
+ trace_context_json: string | null;
59
61
  status: OutboxMessageStatus;
60
62
  attempts: number;
61
63
  max_attempts: number;
@@ -69,15 +71,26 @@ export interface OutboxRow {
69
71
  updated_at: string;
70
72
  }
71
73
 
74
+ function parseStoredTrace(value: string | null): TraceCarrier | undefined {
75
+ if (!value) return undefined;
76
+ try {
77
+ return parseTraceCarrier(JSON.parse(value));
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
72
83
  /**
73
84
  * Decode a raw outbox row into an `OutboxMessage`.
74
85
  */
75
86
  export function rowToMessage(row: OutboxRow): OutboxMessage {
87
+ const trace = parseStoredTrace(row.trace_context_json);
76
88
  return {
77
89
  id: row.id,
78
90
  kind: row.kind,
79
91
  name: row.name,
80
92
  payload: JSON.parse(row.payload_json) as OutboxJsonValue,
93
+ ...(trace ? { trace } : {}),
81
94
  status: row.status,
82
95
  attempts: row.attempts,
83
96
  maxAttempts: row.max_attempts,
@@ -408,6 +408,7 @@ export function createDrizzleSqliteOutboxSetupStatements(
408
408
  kind text NOT NULL,
409
409
  name text NOT NULL,
410
410
  payload_json text NOT NULL,
411
+ trace_context_json text,
411
412
  status text NOT NULL,
412
413
  attempts integer DEFAULT 0 NOT NULL,
413
414
  max_attempts integer DEFAULT 3 NOT NULL,
@@ -43,6 +43,7 @@ export const outboxMessages = sqliteTable(
43
43
  kind: text("kind").notNull(),
44
44
  name: text("name").notNull(),
45
45
  payloadJson: text("payload_json").notNull(),
46
+ traceContextJson: text("trace_context_json"),
46
47
  status: text("status").notNull(),
47
48
  attempts: integer("attempts").notNull().default(0),
48
49
  maxAttempts: integer("max_attempts").notNull().default(3),