@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.
- package/dist/database/adapters/odblite-socket.d.ts.map +1 -1
- package/dist/database/adapters/socket/client.d.ts +15 -0
- package/dist/database/adapters/socket/client.d.ts.map +1 -1
- package/dist/database/adapters/tx-context.d.ts +30 -0
- package/dist/database/adapters/tx-context.d.ts.map +1 -0
- package/dist/index.cjs +58 -9
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -9
- package/package.json +1 -1
- package/src/database/adapters/odblite-socket.ts +24 -8
- package/src/database/adapters/tx-context.ts +34 -0
- package/src/index.ts +5 -0
|
@@ -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,
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
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,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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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,
|