sqlstack 3.4.0 → 3.6.0-dev.0

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 (83) hide show
  1. package/dist/cjs/codegen/generateManifest.d.ts +3 -1
  2. package/dist/cjs/codegen/generateManifest.d.ts.map +1 -1
  3. package/dist/cjs/codegen/generateManifest.js +195 -3
  4. package/dist/cjs/codegen/generateManifest.js.map +1 -1
  5. package/dist/cjs/core/errors.d.ts +9 -0
  6. package/dist/cjs/core/errors.d.ts.map +1 -1
  7. package/dist/cjs/core/errors.js +10 -0
  8. package/dist/cjs/core/errors.js.map +1 -1
  9. package/dist/cjs/core/fileResolution.d.ts +7 -0
  10. package/dist/cjs/core/fileResolution.d.ts.map +1 -1
  11. package/dist/cjs/core/fileResolution.js +53 -14
  12. package/dist/cjs/core/fileResolution.js.map +1 -1
  13. package/dist/cjs/core/metadata.d.ts +5 -0
  14. package/dist/cjs/core/metadata.d.ts.map +1 -1
  15. package/dist/cjs/core/metadata.js.map +1 -1
  16. package/dist/cjs/core/resources.d.ts +36 -0
  17. package/dist/cjs/core/resources.d.ts.map +1 -0
  18. package/dist/cjs/core/resources.js +98 -0
  19. package/dist/cjs/core/resources.js.map +1 -0
  20. package/dist/cjs/core/sourceScanResolver.d.ts +8 -14
  21. package/dist/cjs/core/sourceScanResolver.d.ts.map +1 -1
  22. package/dist/cjs/core/sourceScanResolver.js +8 -20
  23. package/dist/cjs/core/sourceScanResolver.js.map +1 -1
  24. package/dist/cjs/decorators/query.d.ts +14 -0
  25. package/dist/cjs/decorators/query.d.ts.map +1 -1
  26. package/dist/cjs/decorators/query.js +208 -40
  27. package/dist/cjs/decorators/query.js.map +1 -1
  28. package/dist/cjs/family.d.ts.map +1 -1
  29. package/dist/cjs/family.js +3 -1
  30. package/dist/cjs/family.js.map +1 -1
  31. package/dist/cjs/index.d.ts +3 -0
  32. package/dist/cjs/index.d.ts.map +1 -1
  33. package/dist/cjs/index.js +6 -2
  34. package/dist/cjs/index.js.map +1 -1
  35. package/dist/cjs/run.d.ts +180 -6
  36. package/dist/cjs/run.d.ts.map +1 -1
  37. package/dist/cjs/run.js +158 -17
  38. package/dist/cjs/run.js.map +1 -1
  39. package/dist/cjs/runtime.d.ts +14 -0
  40. package/dist/cjs/runtime.d.ts.map +1 -1
  41. package/dist/cjs/runtime.js +30 -0
  42. package/dist/cjs/runtime.js.map +1 -1
  43. package/dist/cjs/sql.d.ts +19 -0
  44. package/dist/cjs/sql.d.ts.map +1 -1
  45. package/dist/cjs/sql.js +66 -28
  46. package/dist/cjs/sql.js.map +1 -1
  47. package/dist/cjs/stack.d.ts +14 -0
  48. package/dist/cjs/stack.d.ts.map +1 -1
  49. package/dist/cjs/stack.js +20 -0
  50. package/dist/cjs/stack.js.map +1 -1
  51. package/dist/cjs/transactions.d.ts +60 -0
  52. package/dist/cjs/transactions.d.ts.map +1 -1
  53. package/dist/cjs/transactions.js +575 -3
  54. package/dist/cjs/transactions.js.map +1 -1
  55. package/dist/esm/codegen/generateManifest.js +195 -3
  56. package/dist/esm/codegen/generateManifest.js.map +1 -1
  57. package/dist/esm/core/errors.js +10 -0
  58. package/dist/esm/core/errors.js.map +1 -1
  59. package/dist/esm/core/fileResolution.js +52 -14
  60. package/dist/esm/core/fileResolution.js.map +1 -1
  61. package/dist/esm/core/metadata.js.map +1 -1
  62. package/dist/esm/core/resources.js +91 -0
  63. package/dist/esm/core/resources.js.map +1 -0
  64. package/dist/esm/core/sourceScanResolver.js +8 -19
  65. package/dist/esm/core/sourceScanResolver.js.map +1 -1
  66. package/dist/esm/decorators/query.js +214 -46
  67. package/dist/esm/decorators/query.js.map +1 -1
  68. package/dist/esm/family.js +3 -1
  69. package/dist/esm/family.js.map +1 -1
  70. package/dist/esm/index.js +3 -1
  71. package/dist/esm/index.js.map +1 -1
  72. package/dist/esm/run.js +153 -18
  73. package/dist/esm/run.js.map +1 -1
  74. package/dist/esm/runtime.js +28 -0
  75. package/dist/esm/runtime.js.map +1 -1
  76. package/dist/esm/sql.js +63 -27
  77. package/dist/esm/sql.js.map +1 -1
  78. package/dist/esm/stack.js +21 -1
  79. package/dist/esm/stack.js.map +1 -1
  80. package/dist/esm/transactions.js +571 -3
  81. package/dist/esm/transactions.js.map +1 -1
  82. package/package.json +1 -1
  83. package/readme.md +201 -171
@@ -17,7 +17,11 @@ exports.currentTransaction = currentTransaction;
17
17
  exports.hasTransactionContext = hasTransactionContext;
18
18
  exports.currentTransactionFor = currentTransactionFor;
19
19
  exports.withTransaction = withTransaction;
20
+ exports.onTransactionEvent = onTransactionEvent;
21
+ exports.getTransactionTarget = getTransactionTarget;
22
+ exports.getTransactionTargetForHandle = getTransactionTargetForHandle;
20
23
  exports.getActiveTransaction = getActiveTransaction;
24
+ exports.getActiveTransactionForDatabase = getActiveTransactionForDatabase;
21
25
  const node_async_hooks_1 = require("node:async_hooks");
22
26
  const adapters_1 = require("./adapters");
23
27
  const errors_1 = require("./core/errors");
@@ -95,6 +99,8 @@ class TxState {
95
99
  this.started = false;
96
100
  this.completed = false;
97
101
  this.rollbackOnly = false;
102
+ /** SQLite opening statement: name-based transactions keep plain BEGIN. */
103
+ this.sqliteBegin = 'BEGIN';
98
104
  this.entry = entry;
99
105
  this.db = entry.db;
100
106
  this.options = {
@@ -140,7 +146,7 @@ class TxState {
140
146
  return;
141
147
  }
142
148
  // sqlite: existing connection is already bound
143
- await this.db.query('BEGIN', []);
149
+ await this.db.query(this.sqliteBegin, []);
144
150
  this.started = true;
145
151
  }
146
152
  async query(sql, params) {
@@ -175,6 +181,9 @@ class TxState {
175
181
  finally {
176
182
  this.txDb = undefined;
177
183
  this.releaseFn = undefined;
184
+ const onRelease = this.onRelease;
185
+ this.onRelease = undefined;
186
+ onRelease?.();
178
187
  }
179
188
  }
180
189
  async finishOnSuccess() {
@@ -274,7 +283,533 @@ async function withTransaction(fn, options) {
274
283
  };
275
284
  return isNewStore ? await als.run(store, run) : await run();
276
285
  }
277
- exports.transaction = function (...args) {
286
+ // ---------------------------------------------------------------------------
287
+ // Scope-based @transaction (docs/transactions.md)
288
+ // ---------------------------------------------------------------------------
289
+ /**
290
+ * One @transaction method invocation. The root scope (no parent) owns every
291
+ * transaction opened in the flow and commits/rolls them back at its exit; a
292
+ * nested scope is a piece of its parent, delimited by savepoints.
293
+ */
294
+ class TxScope {
295
+ constructor(parent) {
296
+ this.closed = false;
297
+ this.markAll = false;
298
+ this.marked = new Set();
299
+ this.afterCommit = [];
300
+ this.afterSettled = [];
301
+ /** Nested scopes: the savepoint this scope holds on each open transaction. */
302
+ this.savepoints = [];
303
+ // Root-only bookkeeping.
304
+ /** Transactions opened in this flow, in open order (root commits them). */
305
+ this.opened = [];
306
+ this.opening = new Map();
307
+ /** Databases this root has opened (multi_database event), with their reported names. */
308
+ this.seen = [];
309
+ this.savepointSeq = 0;
310
+ /** Siblings share one savepoint stack; descendants queue on their own parent. */
311
+ this.childTail = Promise.resolve();
312
+ this.parent = parent;
313
+ this.root = parent ? parent.root : this;
314
+ this.depth = parent ? parent.depth + 1 : 1;
315
+ }
316
+ get hasMarks() {
317
+ return this.markAll || this.marked.size > 0;
318
+ }
319
+ isMarked(state) {
320
+ return this.markAll || this.marked.has(state);
321
+ }
322
+ reserveChild() {
323
+ const ready = this.childTail;
324
+ let release;
325
+ this.childTail = new Promise((resolve) => {
326
+ release = resolve;
327
+ });
328
+ return { ready, release };
329
+ }
330
+ async waitForChildren() {
331
+ let observed;
332
+ do {
333
+ observed = this.childTail;
334
+ await observed;
335
+ } while (observed !== this.childTail);
336
+ }
337
+ }
338
+ const eventListeners = new Set();
339
+ /**
340
+ * Observe transaction trace events. Today the only event is
341
+ * `sqlstack.transaction.multi_database`, emitted once per root scope the
342
+ * moment it opens its second database. Returns an unsubscribe function.
343
+ * Listener errors are ignored so observation never affects a transaction.
344
+ */
345
+ function onTransactionEvent(listener) {
346
+ eventListeners.add(listener);
347
+ return () => {
348
+ eventListeners.delete(listener);
349
+ };
350
+ }
351
+ function emitTransactionEvent(event) {
352
+ for (const listener of [...eventListeners]) {
353
+ try {
354
+ listener(event);
355
+ }
356
+ catch {
357
+ // observation must never affect the transaction
358
+ }
359
+ }
360
+ }
361
+ /** Innermost scope of this flow that has not exited yet. */
362
+ function activeScope(store) {
363
+ let scope = store?.scope;
364
+ while (scope && scope.closed)
365
+ scope = scope.parent;
366
+ return scope;
367
+ }
368
+ function requireScope(api) {
369
+ const scope = activeScope(getStore());
370
+ if (!scope) {
371
+ throw new errors_1.SqlStackError(`transaction.${api}() called outside a @transaction method.`);
372
+ }
373
+ return scope;
374
+ }
375
+ // SQLite serialisation: one connection carries one transaction at a time, so
376
+ // independent flows opening a scope transaction on the same connection wait
377
+ // for the previous holder to release instead of failing with "cannot start a
378
+ // transaction within a transaction". Keyed by the Database object (the
379
+ // connection), which is the entry's database.
380
+ const sqliteTails = new WeakMap();
381
+ async function acquireSqliteTurn(db) {
382
+ const previous = sqliteTails.get(db) ?? Promise.resolve();
383
+ let release;
384
+ const mine = new Promise((resolve) => {
385
+ release = resolve;
386
+ });
387
+ sqliteTails.set(db, previous.then(() => mine));
388
+ await previous;
389
+ let released = false;
390
+ return () => {
391
+ if (released)
392
+ return;
393
+ released = true;
394
+ release();
395
+ };
396
+ }
397
+ async function issueSavepoint(scope, state) {
398
+ const name = `sqlstack_sp_${scope.depth}_${++scope.root.savepointSeq}`;
399
+ await state.query(`SAVEPOINT ${name}`, []);
400
+ scope.savepoints.push({ state, name });
401
+ }
402
+ // A Database handle given to run(db, ...) that a @transaction scope opens a
403
+ // transaction on gets one synthetic entry per handle object, so the scope's
404
+ // TxState is keyed by the handle itself. Never registered anywhere; the name is
405
+ // a label only (excluded from name matching in transaction.rollback(name)).
406
+ const HANDLE_LABEL = 'handle';
407
+ const handleEntries = new WeakMap();
408
+ const syntheticEntries = new WeakSet();
409
+ function handleEntryFor(db) {
410
+ let entry = handleEntries.get(db);
411
+ if (!entry) {
412
+ entry = { name: HANDLE_LABEL, db: db, owned: false, stack: undefined };
413
+ handleEntries.set(db, entry);
414
+ syntheticEntries.add(entry);
415
+ }
416
+ return entry;
417
+ }
418
+ function isHandleEntry(entry) {
419
+ return syntheticEntries.has(entry);
420
+ }
421
+ /**
422
+ * Whether a transaction keyed by `other` is the transaction `entry` must join:
423
+ * the same entry, or — when either side was opened through a handle — the
424
+ * same database object (identity). Two registered entries that merely share
425
+ * a Database are left as they were.
426
+ */
427
+ function sameTransactionTarget(entry, other) {
428
+ if (entry === other)
429
+ return true;
430
+ return (isHandleEntry(entry) || isHandleEntry(other)) && entry.db === other.db;
431
+ }
432
+ function findOpenTransaction(store, entry) {
433
+ const direct = store.txByDb.get(entry);
434
+ if (direct)
435
+ return direct;
436
+ for (const [key, state] of store.txByDb) {
437
+ if (!state.completed && sameTransactionTarget(entry, key))
438
+ return state;
439
+ }
440
+ return undefined;
441
+ }
442
+ function findPendingOpen(root, entry) {
443
+ const direct = root.opening.get(entry);
444
+ if (direct)
445
+ return direct;
446
+ for (const [key, pending] of root.opening) {
447
+ if (sameTransactionTarget(entry, key))
448
+ return pending;
449
+ }
450
+ return undefined;
451
+ }
452
+ /** Name reported for `entry` in the multi_database event. */
453
+ function reportedName(store, entry) {
454
+ if (!isHandleEntry(entry))
455
+ return entry.name;
456
+ // A handle that is the database of a registered entry this flow knows uses that name.
457
+ for (const key of store.txByDb.keys()) {
458
+ if (!isHandleEntry(key) && key.db === entry.db)
459
+ return key.name;
460
+ }
461
+ return HANDLE_LABEL;
462
+ }
463
+ async function openScopedTransaction(store, scope, entry) {
464
+ const existing = findOpenTransaction(store, entry);
465
+ if (existing)
466
+ return existing;
467
+ const root = scope.root;
468
+ const pending = findPendingOpen(root, entry);
469
+ if (pending)
470
+ return await pending;
471
+ const open = (async () => {
472
+ const state = new TxState(entry, undefined);
473
+ state.sqliteBegin = 'BEGIN IMMEDIATE';
474
+ const dialect = entry.db.dialect ?? 'sqlite';
475
+ // A flow that already holds this connection must not wait on itself.
476
+ const holdsConnection = root.opened.some((s) => s.db === entry.db && !s.completed);
477
+ if (dialect === 'sqlite' && !holdsConnection) {
478
+ state.onRelease = await acquireSqliteTurn(entry.db);
479
+ }
480
+ try {
481
+ await state.ensureStarted();
482
+ }
483
+ catch (err) {
484
+ await state.release();
485
+ throw err;
486
+ }
487
+ store.txByDb.set(entry, state);
488
+ root.opened.push(state);
489
+ if (!root.seen.some((s) => sameTransactionTarget(entry, s.entry))) {
490
+ root.seen.push({ entry, name: reportedName(store, entry) });
491
+ if (root.seen.length === 2) {
492
+ emitTransactionEvent({
493
+ type: 'sqlstack.transaction.multi_database',
494
+ databases: root.seen.map((s) => s.name),
495
+ });
496
+ }
497
+ }
498
+ // First opened inside a nested scope: every enclosing nested scope gets
499
+ // its savepoint right after BEGIN, outermost first.
500
+ const chain = [];
501
+ for (let s = scope; s && s.parent; s = s.parent)
502
+ chain.unshift(s);
503
+ for (const s of chain)
504
+ await issueSavepoint(s, state);
505
+ return state;
506
+ })();
507
+ root.opening.set(entry, open);
508
+ try {
509
+ return await open;
510
+ }
511
+ finally {
512
+ root.opening.delete(entry);
513
+ }
514
+ }
515
+ /**
516
+ * @internal The transaction a query on `entry` runs in: the one already open
517
+ * in this flow (including one a handle of the same database opened), or —
518
+ * while a @transaction scope is active — one opened on that entry by the
519
+ * query itself (on first execution, not on resolution). Undefined means "run
520
+ * on the database directly".
521
+ */
522
+ function getTransactionTarget(entry) {
523
+ const store = getStore();
524
+ const open = store ? findOpenTransaction(store, entry) : undefined;
525
+ if (open)
526
+ return open;
527
+ const scope = activeScope(store);
528
+ if (!store || !scope)
529
+ return undefined;
530
+ return {
531
+ async query(sql, params) {
532
+ if (scope.root.closed)
533
+ return await entry.db.query(sql, params);
534
+ const state = await openScopedTransaction(store, scope, entry);
535
+ return await state.query(sql, params);
536
+ },
537
+ };
538
+ }
539
+ /**
540
+ * @internal The transaction a query on a Database handle (run(db, ...)) runs
541
+ * in. A transaction already open in this flow on that exact object is joined
542
+ * (identity; registered or handle-opened). Inside a @transaction scope with
543
+ * none open, the query opens one on the handle on first execution, exactly as
544
+ * the name-resolved path does. Outside any scope the behaviour is unchanged:
545
+ * join by identity or run directly (undefined).
546
+ */
547
+ function getTransactionTargetForHandle(db) {
548
+ const store = getStore();
549
+ if (!store)
550
+ return undefined;
551
+ for (const state of store.txByDb.values()) {
552
+ if (state.db === db)
553
+ return state;
554
+ }
555
+ const scope = activeScope(store);
556
+ if (!scope)
557
+ return undefined;
558
+ return {
559
+ async query(sql, params) {
560
+ if (scope.root.closed)
561
+ return (await db.query(sql, params));
562
+ const state = await openScopedTransaction(store, scope, handleEntryFor(db));
563
+ return await state.query(sql, params);
564
+ },
565
+ };
566
+ }
567
+ function dropFromStore(store, state) {
568
+ if (store.txByDb.get(state.entry) === state)
569
+ store.txByDb.delete(state.entry);
570
+ }
571
+ async function runHooks(hooks) {
572
+ let failed = false;
573
+ let error;
574
+ for (const hook of hooks) {
575
+ try {
576
+ // Outside every scope: a hook that is itself @transaction opens a fresh one.
577
+ await als.exit(() => hook());
578
+ }
579
+ catch (err) {
580
+ if (!failed) {
581
+ failed = true;
582
+ error = err;
583
+ }
584
+ }
585
+ }
586
+ return { failed, error };
587
+ }
588
+ /** Roll back and release every transaction the root owns. Returns the first failure. */
589
+ async function rollbackOwned(root, store) {
590
+ let failed = false;
591
+ let error;
592
+ for (const state of root.opened) {
593
+ try {
594
+ await state.rollbackIfStarted();
595
+ }
596
+ catch (err) {
597
+ if (!failed) {
598
+ failed = true;
599
+ error = err;
600
+ }
601
+ }
602
+ finally {
603
+ try {
604
+ await state.release();
605
+ }
606
+ catch (err) {
607
+ if (!failed) {
608
+ failed = true;
609
+ error = err;
610
+ }
611
+ }
612
+ dropFromStore(store, state);
613
+ }
614
+ }
615
+ root.opened.length = 0;
616
+ return { failed, error };
617
+ }
618
+ async function exitRootOnSuccess(root, store) {
619
+ // Name-based API compatibility: currentTransaction()?.rollbackOnly(err)
620
+ // rolls everything back and throws, exactly as it does for withTransaction.
621
+ const legacyMarked = root.opened.find((s) => s.rollbackOnly);
622
+ if (legacyMarked) {
623
+ const err = typeof legacyMarked.rollbackError !== 'undefined' ? legacyMarked.rollbackError : new errors_1.RollbackTransactionError();
624
+ await rollbackOwned(root, store);
625
+ await runHooks(root.afterSettled);
626
+ throw err;
627
+ }
628
+ let failed = false;
629
+ let error;
630
+ for (const state of root.opened) {
631
+ try {
632
+ if (failed || root.isMarked(state))
633
+ await state.rollbackIfStarted();
634
+ else
635
+ await state.commitIfStarted();
636
+ }
637
+ catch (err) {
638
+ if (!failed) {
639
+ failed = true;
640
+ error = err;
641
+ }
642
+ }
643
+ finally {
644
+ try {
645
+ await state.release();
646
+ }
647
+ catch (err) {
648
+ if (!failed) {
649
+ failed = true;
650
+ error = err;
651
+ }
652
+ }
653
+ dropFromStore(store, state);
654
+ }
655
+ }
656
+ root.opened.length = 0;
657
+ const commitHooks = !failed && !root.hasMarks ? await runHooks(root.afterCommit) : { failed: false, error: undefined };
658
+ const settledHooks = await runHooks(root.afterSettled);
659
+ if (failed)
660
+ throw error;
661
+ if (commitHooks.failed)
662
+ throw commitHooks.error;
663
+ if (settledHooks.failed)
664
+ throw settledHooks.error;
665
+ }
666
+ async function exitRootOnError(root, store) {
667
+ await rollbackOwned(root, store);
668
+ await runHooks(root.afterSettled);
669
+ }
670
+ async function exitNestedOnSuccess(scope) {
671
+ const parent = scope.parent;
672
+ try {
673
+ for (const { state, name } of scope.savepoints) {
674
+ if (state.completed)
675
+ continue;
676
+ if (scope.isMarked(state))
677
+ await state.query(`ROLLBACK TO SAVEPOINT ${name}`, []);
678
+ await state.query(`RELEASE SAVEPOINT ${name}`, []);
679
+ }
680
+ if (!scope.hasMarks)
681
+ parent.afterCommit.push(...scope.afterCommit);
682
+ }
683
+ finally {
684
+ parent.afterSettled.push(...scope.afterSettled);
685
+ }
686
+ }
687
+ async function exitNestedOnError(scope) {
688
+ for (const { state, name } of scope.savepoints) {
689
+ if (state.completed)
690
+ continue;
691
+ try {
692
+ await state.query(`ROLLBACK TO SAVEPOINT ${name}`, []);
693
+ await state.query(`RELEASE SAVEPOINT ${name}`, []);
694
+ }
695
+ catch {
696
+ // the original error wins; the root rolls back everything if it propagates
697
+ }
698
+ }
699
+ scope.parent.afterSettled.push(...scope.afterSettled);
700
+ }
701
+ async function runInScope(fn) {
702
+ const outer = getStore();
703
+ const parent = activeScope(outer);
704
+ const scope = new TxScope(parent);
705
+ const store = { txByDb: outer?.txByDb ?? new Map(), scope };
706
+ // Reserve synchronously, before yielding: a parent must see even queued children.
707
+ const turn = parent?.reserveChild();
708
+ try {
709
+ if (turn)
710
+ await turn.ready;
711
+ return await als.run(store, async () => {
712
+ let out;
713
+ try {
714
+ if (parent) {
715
+ // Siblings enter one at a time, so releasing one cannot erase another's savepoint.
716
+ for (const state of scope.root.opened) {
717
+ if (!state.completed)
718
+ await issueSavepoint(scope, state);
719
+ }
720
+ }
721
+ out = await fn();
722
+ }
723
+ catch (err) {
724
+ // Promise.all may reject while another child is still writing. Keep this
725
+ // scope active until those children settle, then roll back their work too.
726
+ await scope.waitForChildren();
727
+ scope.closed = true;
728
+ if (parent) {
729
+ await exitNestedOnError(scope);
730
+ throw err;
731
+ }
732
+ await exitRootOnError(scope, store);
733
+ throw isRollbackTransactionError(err) ? unwrapRollbackCause(err) : err;
734
+ }
735
+ await scope.waitForChildren();
736
+ scope.closed = true;
737
+ if (parent)
738
+ await exitNestedOnSuccess(scope);
739
+ else
740
+ await exitRootOnSuccess(scope, store);
741
+ return out;
742
+ });
743
+ }
744
+ finally {
745
+ // Release only after savepoint cleanup and descendants, on success or failure.
746
+ turn?.release();
747
+ }
748
+ }
749
+ function matchOpenTransaction(scope, target) {
750
+ const open = scope.root.opened.filter((s) => !s.completed);
751
+ const state = typeof target === 'string'
752
+ ? open.find((s) => !isHandleEntry(s.entry) && s.entry.name === target)
753
+ : open.find((s) => s.db === target);
754
+ if (!state) {
755
+ throw new errors_1.SqlStackError(typeof target === 'string'
756
+ ? `transaction.rollback("${target}"): no transaction is open on a database named "${target}" in this scope.`
757
+ : 'transaction.rollback(db): no transaction is open on that database handle in this scope.');
758
+ }
759
+ return state;
760
+ }
761
+ /**
762
+ * Mark the innermost @transaction scope for rollback. Synchronous and
763
+ * non-throwing for the method: SQL after it still runs inside the open
764
+ * transaction; at scope exit the scope rolls back instead of committing and
765
+ * the method's return value still reaches the caller. With a target (a
766
+ * registered database name or a Database handle), only that database's open
767
+ * transaction is marked.
768
+ */
769
+ function markRollback(target) {
770
+ const scope = requireScope('rollback');
771
+ if (typeof target === 'undefined') {
772
+ scope.markAll = true;
773
+ return;
774
+ }
775
+ scope.marked.add(matchOpenTransaction(scope, target));
776
+ }
777
+ /**
778
+ * Roll back everything the innermost scope has done so far, now. The root
779
+ * scope rolls back and releases its transactions (the next query opens fresh
780
+ * ones); a nested scope rolls back to its savepoints. Marks and the scope's
781
+ * pending afterCommit hooks are cleared; afterSettled hooks stay.
782
+ */
783
+ async function restartScope() {
784
+ const scope = requireScope('restart');
785
+ const store = getStore();
786
+ if (scope.parent) {
787
+ for (const { state, name } of scope.savepoints) {
788
+ if (state.completed)
789
+ continue;
790
+ await state.query(`ROLLBACK TO SAVEPOINT ${name}`, []);
791
+ }
792
+ }
793
+ else {
794
+ const result = await rollbackOwned(scope, store);
795
+ if (result.failed)
796
+ throw result.error;
797
+ }
798
+ scope.markAll = false;
799
+ scope.marked.clear();
800
+ scope.afterCommit = [];
801
+ }
802
+ function registerAfterCommit(fn) {
803
+ if (typeof fn !== 'function')
804
+ throw new errors_1.SqlStackError('transaction.afterCommit(fn) requires a function.');
805
+ requireScope('afterCommit').afterCommit.push(fn);
806
+ }
807
+ function registerAfterSettled(fn) {
808
+ if (typeof fn !== 'function')
809
+ throw new errors_1.SqlStackError('transaction.afterSettled(fn) requires a function.');
810
+ requireScope('afterSettled').afterSettled.push(fn);
811
+ }
812
+ const transactionDecorator = function (...args) {
278
813
  // @transaction
279
814
  if (args.length === 3 && typeof args[2]?.value === 'function') {
280
815
  const [target, prop, desc] = args;
@@ -287,14 +822,51 @@ exports.transaction = function (...args) {
287
822
  wrapMethod(target, prop, desc, options);
288
823
  };
289
824
  };
825
+ exports.transaction = Object.assign(transactionDecorator, {
826
+ rollback: markRollback,
827
+ restart: restartScope,
828
+ afterCommit: registerAfterCommit,
829
+ afterSettled: registerAfterSettled,
830
+ });
831
+ /**
832
+ * A name (`{ db }`) or an eager open (`lazy: false`) keeps the name-based
833
+ * behaviour of withTransaction. Everything else is a scope: no entry is
834
+ * resolved; queries open transactions on first touch.
835
+ */
836
+ function isNameBased(options) {
837
+ return typeof options?.db !== 'undefined' || options?.lazy === false;
838
+ }
290
839
  function wrapMethod(_target, _prop, desc, options) {
291
840
  const original = desc.value;
841
+ if (isNameBased(options)) {
842
+ desc.value = async function (...callArgs) {
843
+ return await withTransaction(() => original.apply(this, callArgs), options);
844
+ };
845
+ return;
846
+ }
292
847
  desc.value = async function (...callArgs) {
293
- return await withTransaction(() => original.apply(this, callArgs), options);
848
+ return await runInScope(() => original.apply(this, callArgs));
294
849
  };
295
850
  }
296
851
  function getActiveTransaction(entry) {
297
852
  const store = getStore();
298
853
  return store?.txByDb.get(entry);
299
854
  }
855
+ /**
856
+ * Active transaction whose registered database is exactly `db` (object
857
+ * identity), if any. `run(db, ...)` uses this so a handle passed at call time
858
+ * joins a transaction opened on the same database (by `@transaction` or
859
+ * `withTransaction`) instead of bypassing it. A handle that was never
860
+ * registered with a SqlStack (or the legacy registry) never matches.
861
+ */
862
+ function getActiveTransactionForDatabase(db) {
863
+ const store = getStore();
864
+ if (!store)
865
+ return undefined;
866
+ for (const state of store.txByDb.values()) {
867
+ if (state.db === db)
868
+ return state;
869
+ }
870
+ return undefined;
871
+ }
300
872
  //# sourceMappingURL=transactions.js.map