@pineliner/odb-client 1.5.2 → 1.5.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"odblite-socket.d.ts","sourceRoot":"","sources":["../../../src/database/adapters/odblite-socket.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAkC,MAAM,UAAU,CAAA;AAExG,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAA;AA+KjE,MAAM,WAAW,mBAAoB,SAAQ,YAAY;CAAG;AAE5D,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,MAAM,EAAE,mBAAmB;IAIjC,OAAO,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAMzC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAmBpC;AAED;;mCAEmC;AACnC,eAAO,MAAM,sBAAsB,IAAI,CAAA;AAEvC,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,sBAAsB,CAAA;AAEtD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,YAAY,CAO1D;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,kBAAmB,YAAW,eAAe;IACxD,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA6C;IAClE,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,WAAW,CAAI;gBAEX,IAAI,EAAE;QAAE,MAAM,CAAC,EAAE,mBAAmB,CAAC;QAAC,IAAI,EAAE,GAAG,CAAA;KAAE;YAK/C,IAAI;IAWlB,oEAAoE;YACtD,SAAS;IAgBvB;;8CAE0C;YAC5B,YAAY;IAY1B;uFACmF;IACnF,OAAO,CAAC,UAAU;IAgBZ,OAAO,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAIzC,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAG5C,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAGpC"}
1
+ {"version":3,"file":"odblite-socket.d.ts","sourceRoot":"","sources":["../../../src/database/adapters/odblite-socket.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAkC,MAAM,UAAU,CAAA;AAExG,OAAO,EAAkD,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAA;AA8MnG,MAAM,WAAW,mBAAoB,SAAQ,YAAY;CAAG;AAE5D,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,MAAM,EAAE,mBAAmB;IAIjC,OAAO,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAMzC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAmBpC;AAED;;mCAEmC;AACnC,eAAO,MAAM,sBAAsB,IAAI,CAAA;AAEvC,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,sBAAsB,CAAA;AAEtD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,YAAY,CAO1D;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,kBAAmB,YAAW,eAAe;IACxD,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA6C;IAClE,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,WAAW,CAAI;gBAEX,IAAI,EAAE;QAAE,MAAM,CAAC,EAAE,mBAAmB,CAAC;QAAC,IAAI,EAAE,GAAG,CAAA;KAAE;YAK/C,IAAI;IAWlB,oEAAoE;YACtD,SAAS;IAgBvB;;8CAE0C;YAC5B,YAAY;IAY1B;uFACmF;IACnF,OAAO,CAAC,UAAU;IAgBZ,OAAO,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAIzC,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAG5C,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAGpC"}
@@ -17,6 +17,15 @@ export interface SocketTarget {
17
17
  hostname?: string;
18
18
  port?: number;
19
19
  }
20
+ /** An interactive transaction was pinned to a socket connection that has since been reset (router
21
+ * restart / socket drop). The tx's server-side connection — and its uncommitted writes — are gone, so
22
+ * it CANNOT be resumed on a fresh connection; the caller must retry the whole transaction. Thrown
23
+ * instead of silently reconnecting and sending a continuation frame on the new connection (which the
24
+ * server rejects as "stream N is not open" — a confusing, non-actionable error). */
25
+ export declare class InteractiveTxConnectionLostError extends Error {
26
+ readonly code = "ODB_TX_CONNECTION_LOST";
27
+ constructor();
28
+ }
20
29
  export declare class SocketClient {
21
30
  private readonly target;
22
31
  private socket;
@@ -25,12 +34,18 @@ export declare class SocketClient {
25
34
  private readonly pending;
26
35
  private nextId;
27
36
  private nextStreamId;
37
+ private epoch;
28
38
  private ready;
29
39
  constructor(target: SocketTarget);
30
40
  /** Reject + clear every in-flight request — called on disconnect/error/close so a
31
41
  * dropped connection FAILS its requests (the caller retries) instead of hanging. */
32
42
  private failPending;
33
43
  private connect;
44
+ /** Current connection generation — an interactive tx captures this when it opens (its BEGIN lands). */
45
+ get connectionEpoch(): number;
46
+ /** True only if the live socket is the SAME generation the tx opened on. False after any drop (socket
47
+ * cleared) or reconnect (epoch bumped) — the tx's pinned server connection is gone either way. */
48
+ isConnectionLive(epoch: number | undefined): boolean;
34
49
  /** Reconnect with bounded backoff — bridges a router restart so a request landing in the down-window
35
50
  * waits it out instead of failing. After the last attempt the error propagates (caller retries / the
36
51
  * auto-adapter can fall back to HTTP). Backoff: 50·100·200·400·800·1600ms ≈ 3s total by default. */
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/database/adapters/socket/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA;AAE5F;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAqBD,qBAAa,YAAY;IASX,OAAO,CAAC,QAAQ,CAAC,MAAM;IARnC,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IACrD,OAAO,CAAC,MAAM,CAAI;IAClB,OAAO,CAAC,YAAY,CAAI;IACxB,OAAO,CAAC,KAAK,CAAe;gBAEC,MAAM,EAAE,YAAY;IAOjD;yFACqF;IACrF,OAAO,CAAC,WAAW;YAQL,OAAO;IAsDrB;;yGAEqG;YACvF,gBAAgB;IAc9B;;8FAE0F;IAC1F,WAAW,IAAI,MAAM;IAIf,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IA0BnG,KAAK,IAAI,IAAI;CAKd"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/database/adapters/socket/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA;AAE5F;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAqBD;;;;qFAIqF;AACrF,qBAAa,gCAAiC,SAAQ,KAAK;IACzD,QAAQ,CAAC,IAAI,4BAA2B;;CAKzC;AAED,qBAAa,YAAY;IAYX,OAAO,CAAC,QAAQ,CAAC,MAAM;IAXnC,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IACrD,OAAO,CAAC,MAAM,CAAI;IAClB,OAAO,CAAC,YAAY,CAAI;IAGxB,OAAO,CAAC,KAAK,CAAI;IACjB,OAAO,CAAC,KAAK,CAAe;gBAEC,MAAM,EAAE,YAAY;IAOjD;yFACqF;IACrF,OAAO,CAAC,WAAW;YAQL,OAAO;IAuDrB,uGAAuG;IACvG,IAAI,eAAe,IAAI,MAAM,CAE5B;IAED;uGACmG;IACnG,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO;IAIpD;;yGAEqG;YACvF,gBAAgB;IAc9B;;8FAE0F;IAC1F,WAAW,IAAI,MAAM;IAIf,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IA0BnG,KAAK,IAAI,IAAI;CAKd"}
@@ -0,0 +1,30 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { Connection } from '../types';
3
+ /**
4
+ * Ambient transaction context — the "smart underneath" that lets an `await`-a-write-inside-a-transaction
5
+ * pattern work on a single-writer engine WITHOUT deadlocking, and without touching business code.
6
+ *
7
+ * The deadlock: a transaction holds the tenant's one writer slot; a nested write (e.g. an event subscriber
8
+ * running during an awaited `eventBus.publish`) that grabs a FRESH connection queues behind the tx on that
9
+ * same slot, while the tx waits for it → cycle. The fix is not to change the subscriber — it's to make the
10
+ * nested write REUSE the transaction's own connection (its pinned stream) so it runs sequentially on the
11
+ * one writer instead of contending for it.
12
+ *
13
+ * `AsyncLocalStorage` propagates through `await`, so any code running inside `transaction(fn)`'s async flow
14
+ * — including subscribers invoked during an awaited publish — can look up the open tx connection and JOIN
15
+ * it. Same technique as Prisma interactive transactions / TypeORM / .NET `TransactionScope` / Spring
16
+ * `@Transactional` propagation. Semantics: a joined write is PART of the transaction (commits/rolls back
17
+ * with it) — which is exactly what "a write issued inside a transaction" should mean. Writes that must be
18
+ * independent stay independent by running AFTER commit (fire-and-forget / post-commit), where the context
19
+ * has already exited and `ambientTx` returns undefined.
20
+ */
21
+ export declare const txContext: AsyncLocalStorage<{
22
+ tenant: string;
23
+ conn: Connection;
24
+ }>;
25
+ /** The open transaction's connection for `tenant`, if the current async flow is inside one — else
26
+ * undefined. Connection accessors (e.g. the app's getConnectionByTenantHash) return this to join the tx. */
27
+ export declare function ambientTx(tenant: string): Connection | undefined;
28
+ /** Run `fn` with `conn` bound as the ambient transaction for `tenant`. Used by transaction() wrappers. */
29
+ export declare function runInTransactionContext<T>(tenant: string, conn: Connection, fn: () => Promise<T>): Promise<T>;
30
+ //# sourceMappingURL=tx-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tx-context.d.ts","sourceRoot":"","sources":["../../../src/database/adapters/tx-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAE1C;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,SAAS;YAAmC,MAAM;UAAQ,UAAU;EAAK,CAAA;AAEtF;6GAC6G;AAC7G,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAGhE;AAED,0GAA0G;AAC1G,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAE7G"}
package/dist/index.cjs CHANGED
@@ -464,18 +464,22 @@ var __webpack_exports__ = {};
464
464
  (()=>{
465
465
  __webpack_require__.r(__webpack_exports__);
466
466
  __webpack_require__.d(__webpack_exports__, {
467
+ txContext: ()=>txContext,
467
468
  gte: ()=>orm.gte,
468
469
  ODBLiteTransaction: ()=>ODBLiteTransaction,
469
470
  updateSet: ()=>src_updateSet,
470
471
  sql: ()=>sql_parser_sql,
471
472
  isNotNull: ()=>orm.isNotNull,
473
+ ambientTx: ()=>ambientTx,
472
474
  LibSQLAdapter: ()=>LibSQLAdapter,
473
475
  ORM: ()=>orm.ORM,
474
476
  join: ()=>src_join,
475
477
  lte: ()=>orm.lte,
476
478
  default: ()=>odblite,
477
- splitSQLStatements: ()=>splitSQLStatements,
479
+ runInTransactionContext: ()=>runInTransactionContext,
478
480
  ne: ()=>orm.ne,
481
+ splitSQLStatements: ()=>splitSQLStatements,
482
+ InteractiveTxConnectionLostError: ()=>InteractiveTxConnectionLostError,
479
483
  gt: ()=>orm.gt,
480
484
  eq: ()=>orm.eq,
481
485
  fragment: ()=>fragment,
@@ -2258,6 +2262,13 @@ var __webpack_exports__ = {};
2258
2262
  const RECONNECT_ATTEMPTS = Number(globalThis.process?.env?.ODB_SOCKET_RECONNECT_ATTEMPTS) || 6;
2259
2263
  const RECONNECT_BASE_MS = Number(globalThis.process?.env?.ODB_SOCKET_RECONNECT_BASE_MS) || 50;
2260
2264
  const sleep = (ms)=>new Promise((r)=>setTimeout(r, ms));
2265
+ class InteractiveTxConnectionLostError extends Error {
2266
+ code = 'ODB_TX_CONNECTION_LOST';
2267
+ constructor(){
2268
+ super('odblite: interactive transaction lost its connection (router restart / socket reset) — retry the transaction');
2269
+ this.name = 'InteractiveTxConnectionLostError';
2270
+ }
2271
+ }
2261
2272
  class SocketClient {
2262
2273
  target;
2263
2274
  socket;
@@ -2266,6 +2277,7 @@ var __webpack_exports__ = {};
2266
2277
  pending = new Map();
2267
2278
  nextId = 1;
2268
2279
  nextStreamId = 1;
2280
+ epoch = 0;
2269
2281
  ready;
2270
2282
  constructor(target){
2271
2283
  this.target = target;
@@ -2328,6 +2340,13 @@ var __webpack_exports__ = {};
2328
2340
  }
2329
2341
  });
2330
2342
  this.writer = new FrameWriter(this.socket);
2343
+ this.epoch++;
2344
+ }
2345
+ get connectionEpoch() {
2346
+ return this.epoch;
2347
+ }
2348
+ isConnectionLive(epoch) {
2349
+ return !!this.socket && this.epoch === epoch;
2331
2350
  }
2332
2351
  async connectWithRetry() {
2333
2352
  let lastErr;
@@ -2380,6 +2399,18 @@ var __webpack_exports__ = {};
2380
2399
  this.failPending(new Error('odblite socket closed'));
2381
2400
  }
2382
2401
  }
2402
+ var external_node_async_hooks_ = __webpack_require__("node:async_hooks");
2403
+ const txContext = new external_node_async_hooks_.AsyncLocalStorage();
2404
+ function ambientTx(tenant) {
2405
+ const store = txContext.getStore();
2406
+ return store && store.tenant === tenant ? store.conn : void 0;
2407
+ }
2408
+ function runInTransactionContext(tenant, conn, fn) {
2409
+ return txContext.run({
2410
+ tenant,
2411
+ conn
2412
+ }, fn);
2413
+ }
2383
2414
  function toQueryResult(res) {
2384
2415
  if ('error' === res.type) throw new Error(res.error.message);
2385
2416
  const r = res.response.result;
@@ -2406,6 +2437,7 @@ var __webpack_exports__ = {};
2406
2437
  databaseHash;
2407
2438
  sql;
2408
2439
  beginPending = false;
2440
+ openEpoch;
2409
2441
  constructor(client, tenant, streamId){
2410
2442
  this.client = client;
2411
2443
  this.tenant = tenant;
@@ -2428,6 +2460,8 @@ var __webpack_exports__ = {};
2428
2460
  return res;
2429
2461
  }
2430
2462
  async streamExec(sql, params = []) {
2463
+ const opening = this.beginPending;
2464
+ if (!opening && !this.client.isConnectionLive(this.openEpoch)) throw new InteractiveTxConnectionLostError();
2431
2465
  const reqs = [];
2432
2466
  if (this.beginPending) {
2433
2467
  reqs.push({
@@ -2446,6 +2480,7 @@ var __webpack_exports__ = {};
2446
2480
  }
2447
2481
  });
2448
2482
  const results = await this.client.pipeline(this.tenant, reqs, this.streamId);
2483
+ if (opening) this.openEpoch = this.client.connectionEpoch;
2449
2484
  if (reqs.length > 1 && results[0]?.type === 'error') throw new Error(results[0].error.message);
2450
2485
  const res = results[results.length - 1];
2451
2486
  if (!res) throw new Error('odblite-socket: empty stream response');
@@ -2455,6 +2490,8 @@ var __webpack_exports__ = {};
2455
2490
  const s = 'string' == typeof sql ? sql : sql.sql;
2456
2491
  const p = 'string' == typeof sql ? params : sql.args ?? [];
2457
2492
  if (null != this.streamId) return await this.streamExec(s, p);
2493
+ const amb = ambientTx(this.tenant);
2494
+ if (amb && amb !== this) return amb.query(s, p);
2458
2495
  return toQueryResult(await this.one([
2459
2496
  {
2460
2497
  type: 'execute',
@@ -2469,6 +2506,8 @@ var __webpack_exports__ = {};
2469
2506
  const s = 'string' == typeof sql ? sql : sql.sql;
2470
2507
  const p = 'string' == typeof sql ? params : sql.args ?? [];
2471
2508
  if (null != this.streamId) return this.streamExec(s, p);
2509
+ const amb = ambientTx(this.tenant);
2510
+ if (amb && amb !== this) return amb.execute(s, p);
2472
2511
  return toQueryResult(await this.one([
2473
2512
  {
2474
2513
  type: 'execute',
@@ -2500,14 +2539,16 @@ var __webpack_exports__ = {};
2500
2539
  if (null != this.streamId) return fn(this);
2501
2540
  const tx = new OdbliteSocketConnection(this.client, this.tenant, this.client.allocStream());
2502
2541
  tx.beginPending = true;
2503
- try {
2504
- const out = await fn(tx);
2505
- if (!tx.beginPending) await tx.streamExec('COMMIT');
2506
- return out;
2507
- } catch (e) {
2508
- if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(()=>{});
2509
- throw e;
2510
- }
2542
+ return runInTransactionContext(this.tenant, tx, async ()=>{
2543
+ try {
2544
+ const out = await fn(tx);
2545
+ if (!tx.beginPending) await tx.streamExec('COMMIT');
2546
+ return out;
2547
+ } catch (e) {
2548
+ if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(()=>{});
2549
+ throw e;
2550
+ }
2551
+ });
2511
2552
  }
2512
2553
  begin(fn) {
2513
2554
  return this.transaction(fn);
@@ -2945,6 +2986,7 @@ exports.BunSQLiteAdapter = __webpack_exports__.BunSQLiteAdapter;
2945
2986
  exports.ConnectionError = __webpack_exports__.ConnectionError;
2946
2987
  exports.DatabaseManager = __webpack_exports__.DatabaseManager;
2947
2988
  exports.HTTPClient = __webpack_exports__.HTTPClient;
2989
+ exports.InteractiveTxConnectionLostError = __webpack_exports__.InteractiveTxConnectionLostError;
2948
2990
  exports.LibSQLAdapter = __webpack_exports__.LibSQLAdapter;
2949
2991
  exports.ODBLiteAdapter = __webpack_exports__.ODBLiteAdapter;
2950
2992
  exports.ODBLiteClient = __webpack_exports__.ODBLiteClient;
@@ -2956,6 +2998,7 @@ exports.QueryError = __webpack_exports__.QueryError;
2956
2998
  exports.SQLParser = __webpack_exports__.SQLParser;
2957
2999
  exports.ServiceClient = __webpack_exports__.ServiceClient;
2958
3000
  exports.SimpleTransaction = __webpack_exports__.SimpleTransaction;
3001
+ exports.ambientTx = __webpack_exports__.ambientTx;
2959
3002
  exports.and = __webpack_exports__.and;
2960
3003
  exports.convertTemplateToQuery = __webpack_exports__.convertTemplateToQuery;
2961
3004
  exports.createORM = __webpack_exports__.createORM;
@@ -2980,6 +3023,7 @@ exports.or = __webpack_exports__.or;
2980
3023
  exports.parseSQL = __webpack_exports__.parseSQL;
2981
3024
  exports.raw = __webpack_exports__.raw;
2982
3025
  exports.rawSQL = __webpack_exports__.rawSQL;
3026
+ exports.runInTransactionContext = __webpack_exports__.runInTransactionContext;
2983
3027
  exports.set = __webpack_exports__.set;
2984
3028
  exports.splitSQLStatements = __webpack_exports__.splitSQLStatements;
2985
3029
  exports.sql = __webpack_exports__.sql;
@@ -2987,6 +3031,7 @@ exports.sqlFragment = __webpack_exports__.sqlFragment;
2987
3031
  exports.sqlJoin = __webpack_exports__.sqlJoin;
2988
3032
  exports.sqlTemplate = __webpack_exports__.sqlTemplate;
2989
3033
  exports.sqlWhere = __webpack_exports__.sqlWhere;
3034
+ exports.txContext = __webpack_exports__.txContext;
2990
3035
  exports.updateSet = __webpack_exports__.updateSet;
2991
3036
  exports.where = __webpack_exports__.where;
2992
3037
  for(var __rspack_i in __webpack_exports__)if (-1 === [
@@ -2994,6 +3039,7 @@ for(var __rspack_i in __webpack_exports__)if (-1 === [
2994
3039
  "ConnectionError",
2995
3040
  "DatabaseManager",
2996
3041
  "HTTPClient",
3042
+ "InteractiveTxConnectionLostError",
2997
3043
  "LibSQLAdapter",
2998
3044
  "ODBLiteAdapter",
2999
3045
  "ODBLiteClient",
@@ -3005,6 +3051,7 @@ for(var __rspack_i in __webpack_exports__)if (-1 === [
3005
3051
  "SQLParser",
3006
3052
  "ServiceClient",
3007
3053
  "SimpleTransaction",
3054
+ "ambientTx",
3008
3055
  "and",
3009
3056
  "convertTemplateToQuery",
3010
3057
  "createORM",
@@ -3029,6 +3076,7 @@ for(var __rspack_i in __webpack_exports__)if (-1 === [
3029
3076
  "parseSQL",
3030
3077
  "raw",
3031
3078
  "rawSQL",
3079
+ "runInTransactionContext",
3032
3080
  "set",
3033
3081
  "splitSQLStatements",
3034
3082
  "sql",
@@ -3036,6 +3084,7 @@ for(var __rspack_i in __webpack_exports__)if (-1 === [
3036
3084
  "sqlJoin",
3037
3085
  "sqlTemplate",
3038
3086
  "sqlWhere",
3087
+ "txContext",
3039
3088
  "updateSet",
3040
3089
  "where"
3041
3090
  ].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
package/dist/index.d.ts CHANGED
@@ -9,6 +9,8 @@ export { ORM, Transaction as ORMTransaction, createORM, eq, ne, gt, gte, lt, lte
9
9
  export type { ODBLiteConfig, ODBLiteConnection, QueryResult, Transaction, SQLFragment, PreparedQuery, QueryParameter, PrimitiveType, Row } from './types.ts';
10
10
  export type { ServiceClientConfig, ODBLiteDatabase, ODBLiteNode } from './service/service-client.ts';
11
11
  export type { TenancyMode, BackendType, QueryResult as DatabaseQueryResult, Connection, DatabaseAdapter, PreparedStatement as DatabasePreparedStatement, DatabaseManagerConfig, MigrationConfig, BunSQLiteConfig, LibSQLConfig, ODBLiteConfig as DatabaseODBLiteConfig, TenantInfo, ParsedStatements, SQLParserOptions, SqlQuery, SqlFragment, } from './database/index.ts';
12
+ export { ambientTx, txContext, runInTransactionContext } from './database/adapters/tx-context.ts';
13
+ export { InteractiveTxConnectionLostError } from './database/adapters/socket/client.ts';
12
14
  export { ODBLiteError, ConnectionError, QueryError } from './types.ts';
13
15
  export declare const raw: typeof ODBLiteClient.raw, identifier: typeof ODBLiteClient.identifier, where: typeof ODBLiteClient.where, insertValues: typeof ODBLiteClient.insertValues, updateSet: typeof ODBLiteClient.updateSet, join: typeof ODBLiteClient.join;
14
16
  export { odblite as default } from './core/client.ts';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGjD,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAGhE,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAG5D,OAAO,EACL,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,gBAAgB,EAChB,aAAa,EACb,cAAc,EAEd,GAAG,IAAI,WAAW,EAClB,GAAG,IAAI,MAAM,EACb,KAAK,EACL,QAAQ,IAAI,WAAW,EACvB,IAAI,IAAI,OAAO,EACf,GAAG,EACH,KAAK,IAAI,QAAQ,EACjB,sBAAsB,GACvB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,EACL,GAAG,EACH,WAAW,IAAI,cAAc,EAC7B,SAAS,EACT,EAAE,EACF,EAAE,EACF,EAAE,EACF,GAAG,EACH,EAAE,EACF,GAAG,EACH,IAAI,EACJ,OAAO,EACP,MAAM,EACN,SAAS,EACT,GAAG,EACH,EAAE,EACF,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAC;AAGxB,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,WAAW,EACX,WAAW,EACX,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,EACb,GAAG,EACJ,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,mBAAmB,EACnB,eAAe,EACf,WAAW,EACZ,MAAM,6BAA6B,CAAC;AAGrC,YAAY,EACV,WAAW,EACX,WAAW,EACX,WAAW,IAAI,mBAAmB,EAClC,UAAU,EACV,eAAe,EACf,iBAAiB,IAAI,yBAAyB,EAC9C,qBAAqB,EACrB,eAAe,EACf,eAAe,EACf,YAAY,EACZ,aAAa,IAAI,qBAAqB,EACtC,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,QAAQ,EACR,WAAW,GACZ,MAAM,qBAAqB,CAAC;AAG7B,OAAO,EACL,YAAY,EACZ,eAAe,EACf,UAAU,EACX,MAAM,YAAY,CAAC;AAGpB,eAAO,MACL,GAAG,4BACH,UAAU,mCACV,KAAK,8BACL,YAAY,qCACZ,SAAS,kCACT,IAAI,2BACW,CAAC;AAGlB,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGjD,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAGhE,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAG5D,OAAO,EACL,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,gBAAgB,EAChB,aAAa,EACb,cAAc,EAEd,GAAG,IAAI,WAAW,EAClB,GAAG,IAAI,MAAM,EACb,KAAK,EACL,QAAQ,IAAI,WAAW,EACvB,IAAI,IAAI,OAAO,EACf,GAAG,EACH,KAAK,IAAI,QAAQ,EACjB,sBAAsB,GACvB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,EACL,GAAG,EACH,WAAW,IAAI,cAAc,EAC7B,SAAS,EACT,EAAE,EACF,EAAE,EACF,EAAE,EACF,GAAG,EACH,EAAE,EACF,GAAG,EACH,IAAI,EACJ,OAAO,EACP,MAAM,EACN,SAAS,EACT,GAAG,EACH,EAAE,EACF,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAC;AAGxB,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,WAAW,EACX,WAAW,EACX,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,EACb,GAAG,EACJ,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,mBAAmB,EACnB,eAAe,EACf,WAAW,EACZ,MAAM,6BAA6B,CAAC;AAGrC,YAAY,EACV,WAAW,EACX,WAAW,EACX,WAAW,IAAI,mBAAmB,EAClC,UAAU,EACV,eAAe,EACf,iBAAiB,IAAI,yBAAyB,EAC9C,qBAAqB,EACrB,eAAe,EACf,eAAe,EACf,YAAY,EACZ,aAAa,IAAI,qBAAqB,EACtC,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,QAAQ,EACR,WAAW,GACZ,MAAM,qBAAqB,CAAC;AAI7B,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,uBAAuB,EAAE,MAAM,mCAAmC,CAAC;AAClG,OAAO,EAAE,gCAAgC,EAAE,MAAM,sCAAsC,CAAC;AAGxF,OAAO,EACL,YAAY,EACZ,eAAe,EACf,UAAU,EACX,MAAM,YAAY,CAAC;AAGpB,eAAO,MACL,GAAG,4BACH,UAAU,mCACV,KAAK,8BACL,YAAY,qCACZ,SAAS,kCACT,IAAI,2BACW,CAAC;AAGlB,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -2168,6 +2168,13 @@ const REQUEST_TIMEOUT_MS = Number(globalThis.process?.env?.ODB_SOCKET_REQUEST_TI
2168
2168
  const RECONNECT_ATTEMPTS = Number(globalThis.process?.env?.ODB_SOCKET_RECONNECT_ATTEMPTS) || 6;
2169
2169
  const RECONNECT_BASE_MS = Number(globalThis.process?.env?.ODB_SOCKET_RECONNECT_BASE_MS) || 50;
2170
2170
  const sleep = (ms)=>new Promise((r)=>setTimeout(r, ms));
2171
+ class InteractiveTxConnectionLostError extends Error {
2172
+ code = 'ODB_TX_CONNECTION_LOST';
2173
+ constructor(){
2174
+ super('odblite: interactive transaction lost its connection (router restart / socket reset) — retry the transaction');
2175
+ this.name = 'InteractiveTxConnectionLostError';
2176
+ }
2177
+ }
2171
2178
  class SocketClient {
2172
2179
  target;
2173
2180
  socket;
@@ -2176,6 +2183,7 @@ class SocketClient {
2176
2183
  pending = new Map();
2177
2184
  nextId = 1;
2178
2185
  nextStreamId = 1;
2186
+ epoch = 0;
2179
2187
  ready;
2180
2188
  constructor(target){
2181
2189
  this.target = target;
@@ -2238,6 +2246,13 @@ class SocketClient {
2238
2246
  }
2239
2247
  });
2240
2248
  this.writer = new FrameWriter(this.socket);
2249
+ this.epoch++;
2250
+ }
2251
+ get connectionEpoch() {
2252
+ return this.epoch;
2253
+ }
2254
+ isConnectionLive(epoch) {
2255
+ return !!this.socket && this.epoch === epoch;
2241
2256
  }
2242
2257
  async connectWithRetry() {
2243
2258
  let lastErr;
@@ -2290,6 +2305,18 @@ class SocketClient {
2290
2305
  this.failPending(new Error('odblite socket closed'));
2291
2306
  }
2292
2307
  }
2308
+ const external_node_async_hooks_ = __webpack_require__("node:async_hooks");
2309
+ const txContext = new external_node_async_hooks_.AsyncLocalStorage();
2310
+ function ambientTx(tenant) {
2311
+ const store = txContext.getStore();
2312
+ return store && store.tenant === tenant ? store.conn : void 0;
2313
+ }
2314
+ function runInTransactionContext(tenant, conn, fn) {
2315
+ return txContext.run({
2316
+ tenant,
2317
+ conn
2318
+ }, fn);
2319
+ }
2293
2320
  function toQueryResult(res) {
2294
2321
  if ('error' === res.type) throw new Error(res.error.message);
2295
2322
  const r = res.response.result;
@@ -2316,6 +2343,7 @@ class OdbliteSocketConnection {
2316
2343
  databaseHash;
2317
2344
  sql;
2318
2345
  beginPending = false;
2346
+ openEpoch;
2319
2347
  constructor(client, tenant, streamId){
2320
2348
  this.client = client;
2321
2349
  this.tenant = tenant;
@@ -2338,6 +2366,8 @@ class OdbliteSocketConnection {
2338
2366
  return res;
2339
2367
  }
2340
2368
  async streamExec(sql, params = []) {
2369
+ const opening = this.beginPending;
2370
+ if (!opening && !this.client.isConnectionLive(this.openEpoch)) throw new InteractiveTxConnectionLostError();
2341
2371
  const reqs = [];
2342
2372
  if (this.beginPending) {
2343
2373
  reqs.push({
@@ -2356,6 +2386,7 @@ class OdbliteSocketConnection {
2356
2386
  }
2357
2387
  });
2358
2388
  const results = await this.client.pipeline(this.tenant, reqs, this.streamId);
2389
+ if (opening) this.openEpoch = this.client.connectionEpoch;
2359
2390
  if (reqs.length > 1 && results[0]?.type === 'error') throw new Error(results[0].error.message);
2360
2391
  const res = results[results.length - 1];
2361
2392
  if (!res) throw new Error('odblite-socket: empty stream response');
@@ -2365,6 +2396,8 @@ class OdbliteSocketConnection {
2365
2396
  const s = 'string' == typeof sql ? sql : sql.sql;
2366
2397
  const p = 'string' == typeof sql ? params : sql.args ?? [];
2367
2398
  if (null != this.streamId) return await this.streamExec(s, p);
2399
+ const amb = ambientTx(this.tenant);
2400
+ if (amb && amb !== this) return amb.query(s, p);
2368
2401
  return toQueryResult(await this.one([
2369
2402
  {
2370
2403
  type: 'execute',
@@ -2379,6 +2412,8 @@ class OdbliteSocketConnection {
2379
2412
  const s = 'string' == typeof sql ? sql : sql.sql;
2380
2413
  const p = 'string' == typeof sql ? params : sql.args ?? [];
2381
2414
  if (null != this.streamId) return this.streamExec(s, p);
2415
+ const amb = ambientTx(this.tenant);
2416
+ if (amb && amb !== this) return amb.execute(s, p);
2382
2417
  return toQueryResult(await this.one([
2383
2418
  {
2384
2419
  type: 'execute',
@@ -2410,14 +2445,16 @@ class OdbliteSocketConnection {
2410
2445
  if (null != this.streamId) return fn(this);
2411
2446
  const tx = new OdbliteSocketConnection(this.client, this.tenant, this.client.allocStream());
2412
2447
  tx.beginPending = true;
2413
- try {
2414
- const out = await fn(tx);
2415
- if (!tx.beginPending) await tx.streamExec('COMMIT');
2416
- return out;
2417
- } catch (e) {
2418
- if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(()=>{});
2419
- throw e;
2420
- }
2448
+ return runInTransactionContext(this.tenant, tx, async ()=>{
2449
+ try {
2450
+ const out = await fn(tx);
2451
+ if (!tx.beginPending) await tx.streamExec('COMMIT');
2452
+ return out;
2453
+ } catch (e) {
2454
+ if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(()=>{});
2455
+ throw e;
2456
+ }
2457
+ });
2421
2458
  }
2422
2459
  begin(fn) {
2423
2460
  return this.transaction(fn);
@@ -2864,4 +2901,4 @@ var lte = orm.lte;
2864
2901
  var ne = orm.ne;
2865
2902
  var or = orm.or;
2866
2903
  export default odblite;
2867
- export { BunSQLiteAdapter, ConnectionError, DatabaseManager, HTTPClient, LibSQLAdapter, ODBLiteAdapter, ODBLiteClient, ODBLiteError, ODBLiteTransaction, ORM, ServiceClient, SimpleTransaction, Transaction as ORMTransaction, and, convertTemplateToQuery, createORM_0 as createORM, empty, eq, fragment, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, odblite, or, parseSQL, set, splitSQLStatements, sql_parser_SQLParser as SQLParser, sql_parser_sql as sql, sql_template_fragment as sqlFragment, sql_template_join as sqlJoin, sql_template_raw as rawSQL, sql_template_sql as sqlTemplate, sql_template_where as sqlWhere, src_identifier as identifier, src_insertValues as insertValues, src_join as join, src_raw as raw, src_updateSet as updateSet, src_where as where, types_QueryError as QueryError };
2904
+ export { BunSQLiteAdapter, ConnectionError, DatabaseManager, HTTPClient, InteractiveTxConnectionLostError, LibSQLAdapter, ODBLiteAdapter, ODBLiteClient, ODBLiteError, ODBLiteTransaction, ORM, ServiceClient, SimpleTransaction, Transaction as ORMTransaction, ambientTx, and, convertTemplateToQuery, createORM_0 as createORM, empty, eq, fragment, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, odblite, or, parseSQL, runInTransactionContext, set, splitSQLStatements, sql_parser_SQLParser as SQLParser, sql_parser_sql as sql, sql_template_fragment as sqlFragment, sql_template_join as sqlJoin, sql_template_raw as rawSQL, sql_template_sql as sqlTemplate, sql_template_where as sqlWhere, src_identifier as identifier, src_insertValues as insertValues, src_join as join, src_raw as raw, src_updateSet as updateSet, src_where as where, txContext, types_QueryError as QueryError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pineliner/odb-client",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "description": "Isomorphic client for ODB-Lite with postgres.js-like template string SQL support",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -1,6 +1,7 @@
1
1
  import type { BackendType, Connection, DatabaseAdapter, PreparedStatement, QueryResult } from '../types'
2
2
  import { ODBLiteAdapter } from './odblite'
3
3
  import { SocketClient, InteractiveTxConnectionLostError, type SocketTarget } from './socket/client'
4
+ import { runInTransactionContext, ambientTx } from './tx-context'
4
5
  import {
5
6
  toHrana,
6
7
  fromHrana,
@@ -113,6 +114,8 @@ class OdbliteSocketConnection implements Connection {
113
114
  const s = typeof sql === 'string' ? sql : sql.sql
114
115
  const p = typeof sql === 'string' ? params : (sql.args ?? [])
115
116
  if (this.streamId != null) return (await this.streamExec(s, p)) as QueryResult<T>
117
+ const amb = ambientTx(this.tenant) // autocommit conn, but inside a tx for my tenant? → join it (see execute)
118
+ if (amb && amb !== this) return amb.query<T>(s, p)
116
119
  return toQueryResult(await this.one([{ type: 'execute', stmt: { sql: s, args: p.map(toHrana) } }])) as QueryResult<T>
117
120
  }
118
121
 
@@ -120,6 +123,14 @@ class OdbliteSocketConnection implements Connection {
120
123
  const s = typeof sql === 'string' ? sql : sql.sql
121
124
  const p = typeof sql === 'string' ? params : (sql.args ?? [])
122
125
  if (this.streamId != null) return this.streamExec(s, p) // on the pinned stream — runs immediately, NOT buffered
126
+ // AMBIENT-TX ROUTING (odb-client owns this — the app stays oblivious): this is an autocommit connection
127
+ // (streamId == null), but if the current async flow is INSIDE an open transaction for the SAME tenant
128
+ // (e.g. an event subscriber writing during an awaited publish), route the statement onto that
129
+ // transaction's pinned stream so it JOINS the tx — sequential on the single writer — instead of taking a
130
+ // fresh autocommit write that would deadlock behind the tx on the writer slot. Same tenant key on both
131
+ // sides (this.tenant), so no auth/database-hash translation for a caller to get wrong.
132
+ const amb = ambientTx(this.tenant)
133
+ if (amb && amb !== this) return amb.execute(s, p)
123
134
  return toQueryResult(await this.one([{ type: 'execute', stmt: { sql: s, args: p.map(toHrana) } }]))
124
135
  }
125
136
 
@@ -148,14 +159,19 @@ class OdbliteSocketConnection implements Connection {
148
159
  if (this.streamId != null) return fn(this) // nested → join the already-open stream
149
160
  const tx = new OdbliteSocketConnection(this.client, this.tenant, this.client.allocStream())
150
161
  tx.beginPending = true // BEGIN folds into the first statement's frame (one fewer round-trip)
151
- try {
152
- const out = await fn(tx)
153
- if (!tx.beginPending) await tx.streamExec('COMMIT') // a statement ran BEGIN was sent COMMIT it
154
- return out // else fn issued no statements → empty tx, BEGIN never sent, zero round-trips
155
- } catch (e) {
156
- if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(() => {}) // best-effort; server also reaps
157
- throw e
158
- }
162
+ // Bind `tx` as the AMBIENT transaction for this tenant while fn runs, so a nested write (e.g. an event
163
+ // subscriber during an awaited publish) that fetches a connection JOINS this stream instead of grabbing
164
+ // a fresh one that would deadlock on the single writer slot. AsyncLocalStorage follows the awaits in fn.
165
+ return runInTransactionContext(this.tenant, tx, async () => {
166
+ try {
167
+ const out = await fn(tx)
168
+ if (!tx.beginPending) await tx.streamExec('COMMIT') // a statement ran → BEGIN was sent → COMMIT it
169
+ return out // else fn issued no statements → empty tx, BEGIN never sent, zero round-trips
170
+ } catch (e) {
171
+ if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(() => {}) // best-effort; server also reaps
172
+ throw e
173
+ }
174
+ })
159
175
  }
160
176
  begin<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
161
177
  return this.transaction(fn)
@@ -0,0 +1,34 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { Connection } from '../types'
3
+
4
+ /**
5
+ * Ambient transaction context — the "smart underneath" that lets an `await`-a-write-inside-a-transaction
6
+ * pattern work on a single-writer engine WITHOUT deadlocking, and without touching business code.
7
+ *
8
+ * The deadlock: a transaction holds the tenant's one writer slot; a nested write (e.g. an event subscriber
9
+ * running during an awaited `eventBus.publish`) that grabs a FRESH connection queues behind the tx on that
10
+ * same slot, while the tx waits for it → cycle. The fix is not to change the subscriber — it's to make the
11
+ * nested write REUSE the transaction's own connection (its pinned stream) so it runs sequentially on the
12
+ * one writer instead of contending for it.
13
+ *
14
+ * `AsyncLocalStorage` propagates through `await`, so any code running inside `transaction(fn)`'s async flow
15
+ * — including subscribers invoked during an awaited publish — can look up the open tx connection and JOIN
16
+ * it. Same technique as Prisma interactive transactions / TypeORM / .NET `TransactionScope` / Spring
17
+ * `@Transactional` propagation. Semantics: a joined write is PART of the transaction (commits/rolls back
18
+ * with it) — which is exactly what "a write issued inside a transaction" should mean. Writes that must be
19
+ * independent stay independent by running AFTER commit (fire-and-forget / post-commit), where the context
20
+ * has already exited and `ambientTx` returns undefined.
21
+ */
22
+ export const txContext = new AsyncLocalStorage<{ tenant: string; conn: Connection }>()
23
+
24
+ /** The open transaction's connection for `tenant`, if the current async flow is inside one — else
25
+ * undefined. Connection accessors (e.g. the app's getConnectionByTenantHash) return this to join the tx. */
26
+ export function ambientTx(tenant: string): Connection | undefined {
27
+ const store = txContext.getStore()
28
+ return store && store.tenant === tenant ? store.conn : undefined
29
+ }
30
+
31
+ /** Run `fn` with `conn` bound as the ambient transaction for `tenant`. Used by transaction() wrappers. */
32
+ export function runInTransactionContext<T>(tenant: string, conn: Connection, fn: () => Promise<T>): Promise<T> {
33
+ return txContext.run({ tenant, conn }, fn)
34
+ }
package/src/index.ts CHANGED
@@ -90,6 +90,11 @@ export type {
90
90
  SqlFragment,
91
91
  } from './database/index.ts';
92
92
 
93
+ // Ambient transaction context — connection accessors call ambientTx(tenant) to JOIN an open transaction
94
+ // (so a write issued inside a tx reuses its connection instead of deadlocking on the single writer slot).
95
+ export { ambientTx, txContext, runInTransactionContext } from './database/adapters/tx-context.ts';
96
+ export { InteractiveTxConnectionLostError } from './database/adapters/socket/client.ts';
97
+
93
98
  // Export error classes
94
99
  export {
95
100
  ODBLiteError,