@beignet/provider-db-drizzle 0.0.36 → 0.0.38

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 (48) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +130 -11
  3. package/dist/mysql/index.d.ts +27 -8
  4. package/dist/mysql/index.d.ts.map +1 -1
  5. package/dist/mysql/index.js +50 -52
  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 +31 -5
  12. package/dist/postgres/index.d.ts.map +1 -1
  13. package/dist/postgres/index.js +41 -51
  14. package/dist/postgres/index.js.map +1 -1
  15. package/dist/postgres/schema.d.ts +17 -0
  16. package/dist/postgres/schema.d.ts.map +1 -1
  17. package/dist/postgres/schema.js +1 -0
  18. package/dist/postgres/schema.js.map +1 -1
  19. package/dist/shared/idempotency-core.d.ts +2 -0
  20. package/dist/shared/idempotency-core.d.ts.map +1 -1
  21. package/dist/shared/idempotency-core.js +56 -8
  22. package/dist/shared/idempotency-core.js.map +1 -1
  23. package/dist/shared/outbox-core.d.ts.map +1 -1
  24. package/dist/shared/outbox-core.js +2 -0
  25. package/dist/shared/outbox-core.js.map +1 -1
  26. package/dist/shared/rows.d.ts +1 -0
  27. package/dist/shared/rows.d.ts.map +1 -1
  28. package/dist/shared/rows.js +13 -0
  29. package/dist/shared/rows.js.map +1 -1
  30. package/dist/sqlite/index.d.ts +20 -2
  31. package/dist/sqlite/index.d.ts.map +1 -1
  32. package/dist/sqlite/index.js +38 -50
  33. package/dist/sqlite/index.js.map +1 -1
  34. package/dist/sqlite/schema.d.ts +19 -0
  35. package/dist/sqlite/schema.d.ts.map +1 -1
  36. package/dist/sqlite/schema.js +1 -0
  37. package/dist/sqlite/schema.js.map +1 -1
  38. package/package.json +3 -2
  39. package/skills/database-provider/SKILL.md +29 -1
  40. package/src/mysql/index.ts +122 -45
  41. package/src/mysql/schema.ts +1 -0
  42. package/src/postgres/index.ts +119 -42
  43. package/src/postgres/schema.ts +1 -0
  44. package/src/shared/idempotency-core.ts +88 -7
  45. package/src/shared/outbox-core.ts +2 -0
  46. package/src/shared/rows.ts +13 -0
  47. package/src/sqlite/index.ts +103 -36
  48. package/src/sqlite/schema.ts +1 -0
@@ -41,16 +41,17 @@ import {
41
41
  type AnyProviderConfigSchema,
42
42
  createProvider,
43
43
  createProviderInstrumentation,
44
+ type ProviderInstrumentationTarget,
44
45
  type ServiceProvider,
45
46
  } from "@beignet/core/providers";
46
47
  import { type ExtractTablesWithRelations, type SQL, sql } from "drizzle-orm";
47
48
  import type {
48
49
  MySqlDatabase,
49
50
  MySqlQueryResultHKT,
50
- MySqlTransaction,
51
51
  MySqlTransactionConfig,
52
52
  PreparedQueryHKTBase,
53
53
  } from "drizzle-orm/mysql-core";
54
+ import { MySqlTransaction } from "drizzle-orm/mysql-core";
54
55
  import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
55
56
  import type { Pool, PoolOptions } from "mysql2/promise";
56
57
  import * as mysql from "mysql2/promise";
@@ -192,6 +193,7 @@ export function createDrizzleMysqlUnitOfWork<
192
193
  const events = createDomainEventRecorder();
193
194
 
194
195
  const result = await options.db.transaction(async (tx) => {
196
+ markMysqlTransactionClient(tx as object);
195
197
  const txPorts = options.createTransactionPorts(
196
198
  tx as DrizzleMysqlTransaction<TSchema>,
197
199
  events,
@@ -300,6 +302,12 @@ function isMysqlDeadlockError(error: unknown): boolean {
300
302
 
301
303
  const MYSQL_DEADLOCK_RETRY_BASE_MS = 10;
302
304
  const MYSQL_DEADLOCK_RETRY_MAX_MS = 250;
305
+ const mysqlTransactionClients = new WeakSet<object>();
306
+
307
+ function markMysqlTransactionClient<T extends object>(client: T): T {
308
+ mysqlTransactionClients.add(client);
309
+ return client;
310
+ }
303
311
 
304
312
  function mysqlDeadlockRetryDelayMs(attempt: number): number {
305
313
  const exponentialDelay = Math.min(
@@ -343,6 +351,8 @@ interface DrizzleMysqlSqlExecutor
343
351
  function createMysqlExecutor<TSchema extends Record<string, unknown>>(
344
352
  db: DrizzleMysqlDatabase<TSchema>,
345
353
  table: SQL,
354
+ transactionScoped = db instanceof MySqlTransaction ||
355
+ mysqlTransactionClients.has(db as object),
346
356
  ): DrizzleMysqlSqlExecutor {
347
357
  return {
348
358
  table,
@@ -391,13 +401,20 @@ function createMysqlExecutor<TSchema extends Record<string, unknown>>(
391
401
  },
392
402
 
393
403
  withTransaction(fn) {
404
+ if (transactionScoped) return undefined;
394
405
  if ("transaction" in db && typeof db.transaction === "function") {
395
406
  // Outbox claims and idempotency reservations are small Beignet-owned
396
407
  // transactions with no app callbacks. Retrying a deadlock victim is
397
408
  // therefore safe and lets contenders classify against the winner.
398
409
  return runMysqlInternalTransaction(() =>
399
410
  db.transaction((tx) =>
400
- fn(createMysqlExecutor(tx as DrizzleMysqlDatabase<TSchema>, table)),
411
+ fn(
412
+ createMysqlExecutor(
413
+ markMysqlTransactionClient(tx as DrizzleMysqlDatabase<TSchema>),
414
+ table,
415
+ true,
416
+ ),
417
+ ),
401
418
  ),
402
419
  );
403
420
  }
@@ -509,6 +526,7 @@ export function createDrizzleMysqlOutboxSetupStatements(
509
526
  kind varchar(32) NOT NULL,
510
527
  name varchar(255) NOT NULL,
511
528
  payload_json longtext NOT NULL,
529
+ trace_context_json longtext,
512
530
  status varchar(32) NOT NULL,
513
531
  attempts int NOT NULL DEFAULT 0,
514
532
  max_attempts int NOT NULL DEFAULT 3,
@@ -712,12 +730,22 @@ export type DrizzleMysqlProvider<
712
730
  Record<PortName, DbPort<TSchema>>
713
731
  >;
714
732
 
733
+ /** Drizzle MySQL provider backed by an app-supplied pool. */
734
+ export type InjectedDrizzleMysqlProvider<
735
+ TSchema extends Record<string, unknown>,
736
+ PortName extends string = "db",
737
+ > = ServiceProvider<
738
+ unknown,
739
+ AnyProviderConfigSchema<void>,
740
+ Record<PortName, DbPort<TSchema>>
741
+ >;
742
+
715
743
  /**
716
744
  * Options for creating a Drizzle MySQL provider.
717
745
  *
718
746
  * @template TSchema - The Drizzle schema type
719
747
  */
720
- export interface DrizzleMysqlProviderOptions<
748
+ interface DrizzleMysqlProviderBaseOptions<
721
749
  TSchema extends Record<string, unknown>,
722
750
  PortName extends string = "db",
723
751
  > {
@@ -735,12 +763,6 @@ export interface DrizzleMysqlProviderOptions<
735
763
  */
736
764
  portName?: PortName;
737
765
 
738
- /**
739
- * Optional mysql2 pool options merged into the pool created from
740
- * `MYSQL_DB_URL` (e.g. `connectionLimit`, `timezone`, TLS settings).
741
- */
742
- pool?: Omit<PoolOptions, "uri">;
743
-
744
766
  /**
745
767
  * Drizzle relational-query mode. Use "planetscale" when connecting to
746
768
  * PlanetScale; defaults to "default".
@@ -748,6 +770,26 @@ export interface DrizzleMysqlProviderOptions<
748
770
  mode?: "default" | "planetscale";
749
771
  }
750
772
 
773
+ export type DrizzleMysqlProviderOptions<
774
+ TSchema extends Record<string, unknown>,
775
+ PortName extends string = "db",
776
+ > = DrizzleMysqlProviderBaseOptions<TSchema, PortName> &
777
+ (
778
+ | {
779
+ /** Pool options for the provider-created `MYSQL_DB_URL` pool. */
780
+ pool?: Omit<PoolOptions, "uri">;
781
+ client?: never;
782
+ closeOnStop?: never;
783
+ }
784
+ | {
785
+ /** App-supplied mysql2 pool, caller-owned by default. */
786
+ client: Pool;
787
+ pool?: never;
788
+ /** Close the injected pool when the provider stops. @default false */
789
+ closeOnStop?: boolean;
790
+ }
791
+ );
792
+
751
793
  /**
752
794
  * Create a Drizzle MySQL provider factory.
753
795
  *
@@ -769,14 +811,83 @@ export interface DrizzleMysqlProviderOptions<
769
811
  * export const drizzleMysqlProvider = createDrizzleMysqlProvider({ schema });
770
812
  * ```
771
813
  */
814
+ export function createDrizzleMysqlProvider<
815
+ TSchema extends Record<string, unknown>,
816
+ PortName extends string = "db",
817
+ >(
818
+ options: DrizzleMysqlProviderBaseOptions<TSchema, PortName> & {
819
+ client: Pool;
820
+ pool?: never;
821
+ closeOnStop?: boolean;
822
+ },
823
+ ): InjectedDrizzleMysqlProvider<TSchema, PortName>;
824
+ export function createDrizzleMysqlProvider<
825
+ TSchema extends Record<string, unknown>,
826
+ PortName extends string = "db",
827
+ >(
828
+ options: DrizzleMysqlProviderBaseOptions<TSchema, PortName> & {
829
+ pool?: Omit<PoolOptions, "uri">;
830
+ client?: never;
831
+ closeOnStop?: never;
832
+ },
833
+ ): DrizzleMysqlProvider<TSchema, PortName>;
772
834
  export function createDrizzleMysqlProvider<
773
835
  TSchema extends Record<string, unknown>,
774
836
  PortName extends string = "db",
775
837
  >(
776
838
  options: DrizzleMysqlProviderOptions<TSchema, PortName>,
777
- ): DrizzleMysqlProvider<TSchema, PortName> {
839
+ ):
840
+ | DrizzleMysqlProvider<TSchema, PortName>
841
+ | InjectedDrizzleMysqlProvider<TSchema, PortName> {
778
842
  const { schema, portName = "db", mode = "default" } = options;
779
843
 
844
+ const setupPool = (ports: unknown, pool: Pool, closeOnStop: boolean) => {
845
+ const instrumentation = createProviderInstrumentation(
846
+ ports as ProviderInstrumentationTarget,
847
+ {
848
+ providerName: "drizzle-mysql",
849
+ watcher: "db",
850
+ },
851
+ );
852
+ const logger = createDbQueryLogger(instrumentation, portName);
853
+ const db: MySql2Database<TSchema> = logger
854
+ ? drizzle(pool, { schema, logger, mode })
855
+ : drizzle(pool, { schema, mode });
856
+ const dbPort: DbPort<TSchema> = {
857
+ drizzle: db,
858
+ pool,
859
+ checkHealth: () => checkMysqlHealth(pool),
860
+ };
861
+
862
+ return {
863
+ ports: { [portName]: dbPort } as Record<PortName, DbPort<TSchema>>,
864
+ async stop() {
865
+ if (!closeOnStop) return;
866
+ try {
867
+ await pool.end();
868
+ } catch (error) {
869
+ console.error(
870
+ "[drizzleMysqlProvider] Error closing database connection:",
871
+ error,
872
+ );
873
+ }
874
+ },
875
+ };
876
+ };
877
+
878
+ if (options.client) {
879
+ return createProvider<
880
+ unknown,
881
+ AnyProviderConfigSchema<void>,
882
+ Record<PortName, DbPort<TSchema>>
883
+ >({
884
+ name: "drizzle-mysql",
885
+ setup({ ports }) {
886
+ return setupPool(ports, options.client, options.closeOnStop ?? false);
887
+ },
888
+ });
889
+ }
890
+
780
891
  return createProvider({
781
892
  name: "drizzle-mysql",
782
893
 
@@ -798,41 +909,7 @@ export function createDrizzleMysqlProvider<
798
909
  ...options.pool,
799
910
  });
800
911
 
801
- const instrumentation = createProviderInstrumentation(ports, {
802
- providerName: "drizzle-mysql",
803
- watcher: "db",
804
- });
805
-
806
- const logger = createDbQueryLogger(instrumentation, portName);
807
-
808
- // 2. Create typed Drizzle instance (mode is required when a schema is
809
- // provided to drizzle-orm/mysql2)
810
- const db: MySql2Database<TSchema> = logger
811
- ? drizzle(pool, { schema, logger, mode })
812
- : drizzle(pool, { schema, mode });
813
-
814
- // 3. Build a typed DbPort
815
- const dbPort: DbPort<TSchema> = {
816
- drizzle: db,
817
- pool,
818
- checkHealth: () => checkMysqlHealth(pool),
819
- };
820
-
821
- return {
822
- ports: { [portName]: dbPort } as Record<PortName, DbPort<TSchema>>,
823
- async stop() {
824
- // Gracefully close the database connection pool
825
- try {
826
- await dbPort.pool.end();
827
- } catch (error) {
828
- // Log error but don't throw - we're shutting down anyway
829
- console.error(
830
- "[drizzleMysqlProvider] Error closing database connection:",
831
- error,
832
- );
833
- }
834
- },
835
- };
912
+ return setupPool(ports, pool, true);
836
913
  },
837
914
  });
838
915
  }
@@ -49,6 +49,7 @@ export const outboxMessages = mysqlTable(
49
49
  kind: varchar("kind", { length: 32 }).notNull(),
50
50
  name: varchar("name", { length: 255 }).notNull(),
51
51
  payloadJson: longtext("payload_json").notNull(),
52
+ traceContextJson: longtext("trace_context_json"),
52
53
  status: varchar("status", { length: 32 }).notNull(),
53
54
  attempts: int("attempts").notNull().default(0),
54
55
  maxAttempts: int("max_attempts").notNull().default(3),
@@ -41,6 +41,7 @@ import {
41
41
  type AnyProviderConfigSchema,
42
42
  createProvider,
43
43
  createProviderInstrumentation,
44
+ type ProviderInstrumentationTarget,
44
45
  type ServiceProvider,
45
46
  } from "@beignet/core/providers";
46
47
  import { type ExtractTablesWithRelations, type SQL, sql } from "drizzle-orm";
@@ -401,6 +402,7 @@ export function createDrizzlePostgresOutboxSetupStatements(
401
402
  kind text NOT NULL,
402
403
  name text NOT NULL,
403
404
  payload_json text NOT NULL,
405
+ trace_context_json text,
404
406
  status text NOT NULL,
405
407
  attempts integer DEFAULT 0 NOT NULL,
406
408
  max_attempts integer DEFAULT 3 NOT NULL,
@@ -605,12 +607,24 @@ export type DrizzlePostgresProvider<
605
607
  Record<PortName, DbPort<TSchema>>
606
608
  >;
607
609
 
610
+ /**
611
+ * Drizzle Postgres provider backed by an app-supplied pool.
612
+ */
613
+ export type InjectedDrizzlePostgresProvider<
614
+ TSchema extends Record<string, unknown>,
615
+ PortName extends string = "db",
616
+ > = ServiceProvider<
617
+ unknown,
618
+ AnyProviderConfigSchema<void>,
619
+ Record<PortName, DbPort<TSchema>>
620
+ >;
621
+
608
622
  /**
609
623
  * Options for creating a Drizzle Postgres provider.
610
624
  *
611
625
  * @template TSchema - The Drizzle schema type
612
626
  */
613
- export interface DrizzlePostgresProviderOptions<
627
+ interface DrizzlePostgresProviderBaseOptions<
614
628
  TSchema extends Record<string, unknown>,
615
629
  PortName extends string = "db",
616
630
  > {
@@ -627,14 +641,34 @@ export interface DrizzlePostgresProviderOptions<
627
641
  * with its own env prefix instead.
628
642
  */
629
643
  portName?: PortName;
630
-
631
- /**
632
- * Optional node-postgres pool configuration merged into the pool created
633
- * from `POSTGRES_DB_URL`, e.g. `max`, `ssl`, or timeouts.
634
- */
635
- pool?: Omit<PoolConfig, "connectionString">;
636
644
  }
637
645
 
646
+ export type DrizzlePostgresProviderOptions<
647
+ TSchema extends Record<string, unknown>,
648
+ PortName extends string = "db",
649
+ > = DrizzlePostgresProviderBaseOptions<TSchema, PortName> &
650
+ (
651
+ | {
652
+ /**
653
+ * Optional node-postgres pool configuration merged into the pool
654
+ * created from `POSTGRES_DB_URL`.
655
+ */
656
+ pool?: Omit<PoolConfig, "connectionString">;
657
+ client?: never;
658
+ closeOnStop?: never;
659
+ }
660
+ | {
661
+ /**
662
+ * App-supplied node-postgres pool. Injected pools do not require
663
+ * provider configuration and remain caller-owned by default.
664
+ */
665
+ client: Pool;
666
+ pool?: never;
667
+ /** Close the injected pool when the provider stops. @default false */
668
+ closeOnStop?: boolean;
669
+ }
670
+ );
671
+
638
672
  /**
639
673
  * Create a Drizzle Postgres provider factory.
640
674
  *
@@ -658,14 +692,90 @@ export interface DrizzlePostgresProviderOptions<
658
692
  * export const drizzlePostgresProvider = createDrizzlePostgresProvider({ schema });
659
693
  * ```
660
694
  */
695
+ export function createDrizzlePostgresProvider<
696
+ TSchema extends Record<string, unknown>,
697
+ PortName extends string = "db",
698
+ >(
699
+ options: DrizzlePostgresProviderBaseOptions<TSchema, PortName> & {
700
+ client: Pool;
701
+ pool?: never;
702
+ closeOnStop?: boolean;
703
+ },
704
+ ): InjectedDrizzlePostgresProvider<TSchema, PortName>;
705
+ export function createDrizzlePostgresProvider<
706
+ TSchema extends Record<string, unknown>,
707
+ PortName extends string = "db",
708
+ >(
709
+ options: DrizzlePostgresProviderBaseOptions<TSchema, PortName> & {
710
+ pool?: Omit<PoolConfig, "connectionString">;
711
+ client?: never;
712
+ closeOnStop?: never;
713
+ },
714
+ ): DrizzlePostgresProvider<TSchema, PortName>;
661
715
  export function createDrizzlePostgresProvider<
662
716
  TSchema extends Record<string, unknown>,
663
717
  PortName extends string = "db",
664
718
  >(
665
719
  options: DrizzlePostgresProviderOptions<TSchema, PortName>,
666
- ): DrizzlePostgresProvider<TSchema, PortName> {
720
+ ):
721
+ | DrizzlePostgresProvider<TSchema, PortName>
722
+ | InjectedDrizzlePostgresProvider<TSchema, PortName> {
723
+ return createDrizzlePostgresProviderImplementation(options);
724
+ }
725
+
726
+ function createDrizzlePostgresProviderImplementation<
727
+ TSchema extends Record<string, unknown>,
728
+ PortName extends string = "db",
729
+ >(options: DrizzlePostgresProviderOptions<TSchema, PortName>) {
667
730
  const { schema, portName = "db" } = options;
668
731
 
732
+ const setupPool = (ports: unknown, pool: Pool, closeOnStop: boolean) => {
733
+ const instrumentation = createProviderInstrumentation(
734
+ ports as ProviderInstrumentationTarget,
735
+ {
736
+ providerName: "drizzle-postgres",
737
+ watcher: "db",
738
+ },
739
+ );
740
+ const logger = createDbQueryLogger(instrumentation, portName);
741
+ const db: NodePgDatabase<TSchema> = logger
742
+ ? drizzle(pool, { schema, logger })
743
+ : drizzle(pool, { schema });
744
+ const dbPort: DbPort<TSchema> = {
745
+ drizzle: db,
746
+ pool,
747
+ checkHealth: () => checkPostgresHealth(pool),
748
+ };
749
+
750
+ return {
751
+ ports: { [portName]: dbPort } as Record<PortName, DbPort<TSchema>>,
752
+ async stop() {
753
+ if (!closeOnStop) return;
754
+ try {
755
+ await pool.end();
756
+ } catch (error) {
757
+ console.error(
758
+ "[drizzlePostgresProvider] Error closing database connection:",
759
+ error,
760
+ );
761
+ }
762
+ },
763
+ };
764
+ };
765
+
766
+ if (options.client) {
767
+ return createProvider<
768
+ unknown,
769
+ AnyProviderConfigSchema<void>,
770
+ Record<PortName, DbPort<TSchema>>
771
+ >({
772
+ name: "drizzle-postgres",
773
+ setup({ ports }) {
774
+ return setupPool(ports, options.client, options.closeOnStop ?? false);
775
+ },
776
+ });
777
+ }
778
+
669
779
  return createProvider({
670
780
  name: "drizzle-postgres",
671
781
 
@@ -687,40 +797,7 @@ export function createDrizzlePostgresProvider<
687
797
  ...options.pool,
688
798
  });
689
799
 
690
- const instrumentation = createProviderInstrumentation(ports, {
691
- providerName: "drizzle-postgres",
692
- watcher: "db",
693
- });
694
-
695
- const logger = createDbQueryLogger(instrumentation, portName);
696
-
697
- // 2. Create typed Drizzle instance
698
- const db: NodePgDatabase<TSchema> = logger
699
- ? drizzle(pool, { schema, logger })
700
- : drizzle(pool, { schema });
701
-
702
- // 3. Build a typed DbPort
703
- const dbPort: DbPort<TSchema> = {
704
- drizzle: db,
705
- pool,
706
- checkHealth: () => checkPostgresHealth(pool),
707
- };
708
-
709
- return {
710
- ports: { [portName]: dbPort } as Record<PortName, DbPort<TSchema>>,
711
- async stop() {
712
- // Gracefully close the connection pool
713
- try {
714
- await dbPort.pool.end();
715
- } catch (error) {
716
- // Log error but don't throw - we're shutting down anyway
717
- console.error(
718
- "[drizzlePostgresProvider] Error closing database connection:",
719
- error,
720
- );
721
- }
722
- },
723
- };
800
+ return setupPool(ports, pool, true);
724
801
  },
725
802
  });
726
803
  }
@@ -43,6 +43,7 @@ export const outboxMessages = pgTable(
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),
@@ -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},