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
package/readme.md CHANGED
@@ -16,6 +16,7 @@ Write real SQL next to your code, and use small, composable decorators to bind,
16
16
  - [Error Handling](#error-handling)
17
17
  - [Transactions](#transactions)
18
18
  - [Inline SQL](#inline-sql)
19
+ - [Choosing the Database at Call Time](#choosing-the-database-at-call-time)
19
20
  - [Advanced: Direct Database Access](#advanced-direct-database-access)
20
21
  - [Database Support](#database-support)
21
22
  - [Philosophy](#philosophy)
@@ -78,15 +79,16 @@ Load this file once during app startup.
78
79
 
79
80
  ```ts
80
81
  // src/users/index.ts
81
- import { QueryBinder, Query, SqlStackError } from "sqlstack";
82
+ import { QueryBinder, Query, run } from "sqlstack";
82
83
 
83
84
  @QueryBinder() // looks for .sql files in the same folder by default
84
85
  export class UsersRepository {
85
86
  @Query()
86
87
  async findByEmail(params: { email: string }): Promise<User[]> {
87
- // Implementation bodies are never called; this method exists
88
- // only to define the TypeScript signature for @Query.
89
- throw new SqlStackError("replaced by @Query");
88
+ // run() executes this method's SQL (findByEmail.sql) with the method's
89
+ // arguments; the result type is inferred from the declared return type.
90
+ // Code before/after `await run()` runs normally (logging, try/catch, ...).
91
+ return run();
90
92
  }
91
93
  }
92
94
  ```
@@ -116,7 +118,7 @@ const users = await repo.findByEmail({ email: "alice@example.com" });
116
118
  console.log(users); // User[]
117
119
  ```
118
120
 
119
- That's it! The `@Query` decorator intercepts the method, loads and executes the SQL, and returns results.
121
+ That's it! The `@Query` decorator wraps the method; the body's `return run()` loads and executes the SQL and returns the shaped results. (Older code whose body is a placeholder `throw` still works — the wrapper falls back to running the SQL — but `return run()` is the supported form.)
120
122
 
121
123
  You can then add more methods on the same repository that use sqlstack’s SQL helpers:
122
124
 
@@ -125,22 +127,22 @@ You can then add more methods on the same repository that use sqlstack’s SQL h
125
127
  export class UsersRepository {
126
128
  @Query()
127
129
  async findByEmail(params: { email: string }): Promise<User[]> {
128
- throw new SqlStackError("replaced by @Query");
130
+ return run();
129
131
  }
130
132
 
131
133
  @Query()
132
134
  async updateProfile(params: { id: string; name?: string; status?: string }): Promise<WriteResult> {
133
- throw new SqlStackError("replaced by @Query");
135
+ return run();
134
136
  }
135
137
 
136
138
  @Query()
137
139
  async createUser(params: { id: string; email: string; name?: string }): Promise<WriteResult> {
138
- throw new SqlStackError("replaced by @Query");
140
+ return run();
139
141
  }
140
142
 
141
143
  @Query()
142
144
  async search(filters: { name?: string; status?: string }): Promise<User[]> {
143
- throw new SqlStackError("replaced by @Query");
145
+ return run();
144
146
  }
145
147
  }
146
148
  ```
@@ -407,13 +409,13 @@ SqlStackDB.register('sqlite-existing', createSqliteDb(sqlite));
407
409
  Use `@QueryBinder({ db: "name" })` to set a default database for all methods in a class:
408
410
 
409
411
  ```ts
410
- import { QueryBinder, Query, SqlStackError } from "sqlstack";
412
+ import { QueryBinder, Query, run } from "sqlstack";
411
413
 
412
414
  @QueryBinder({ db: "analytics" }) // all methods use "analytics"
413
415
  export class ReportsRepo {
414
416
  @Query()
415
417
  async topPages(_a: { since: string }): Promise<Row[]> {
416
- throw new SqlStackError("replaced");
418
+ return run();
417
419
  }
418
420
  }
419
421
  ```
@@ -427,12 +429,12 @@ Use `@Query({ db: "name" })` to override the class default for a specific method
427
429
  export class MixedRepo {
428
430
  @Query() // uses "primary"
429
431
  async dailySummary(_a: { date: string }): Promise<Row[]> {
430
- throw new SqlStackError("replaced");
432
+ return run();
431
433
  }
432
434
 
433
435
  @Query({ db: "analytics" }) // override to use "analytics"
434
436
  async topPages(_a: { since: string }): Promise<Row[]> {
435
- throw new SqlStackError("replaced");
437
+ return run();
436
438
  }
437
439
  }
438
440
  ```
@@ -514,7 +516,7 @@ No argument to `@Query()` → looks in the same folder as the class file:
514
516
  class UsersRepo {
515
517
  @Query()
516
518
  async findByEmail(_a: { email: string }): Promise<User[]> {
517
- throw new Error();
519
+ return run();
518
520
  }
519
521
  }
520
522
  ```
@@ -560,14 +562,14 @@ Use decorators to add behavior to your methods. They compose freely and always e
560
562
  Provide default parameter values:
561
563
 
562
564
  ```ts
563
- import { QueryBinder, Defaults, Query, SqlStackError } from "sqlstack";
565
+ import { QueryBinder, Defaults, Query, run } from "sqlstack";
564
566
 
565
567
  @QueryBinder()
566
568
  class UsersRepo {
567
569
  @Defaults({ status: "active", limit: 10 })
568
570
  @Query()
569
571
  async listUsers(_a: { status?: string; limit?: number }): Promise<User[]> {
570
- throw new SqlStackError();
572
+ return run();
571
573
  }
572
574
  }
573
575
  ```
@@ -583,14 +585,14 @@ await repo.listUsers({ status: "inactive" }); // { status: "inactive", limit: 10
583
585
  Add pagination with `:offset` and `:limit` parameters. Pages are 1-based by default:
584
586
 
585
587
  ```ts
586
- import { QueryBinder, Page, Query, SqlStackError } from "sqlstack";
588
+ import { QueryBinder, Page, Query, run } from "sqlstack";
587
589
 
588
590
  @QueryBinder()
589
591
  class UsersRepo {
590
592
  @Page(10) // limit = 10, pages 1-based
591
593
  @Query()
592
594
  async listUsers(params: { status?: string; page?: number }): Promise<User[]> {
593
- throw new SqlStackError();
595
+ return run();
594
596
  }
595
597
  }
596
598
  ```
@@ -623,7 +625,7 @@ await repo.list(userId, { page: 2 }); // positional id + trailing option
623
625
  Shape single-query results without parameters:
624
626
 
625
627
  ```ts
626
- import { QueryBinder, Single, Only, Exist, None, Query, SqlStackError } from "sqlstack";
628
+ import { QueryBinder, Single, Only, Exist, None, Query, run } from "sqlstack";
627
629
 
628
630
  @QueryBinder()
629
631
  class UsersRepo {
@@ -631,28 +633,28 @@ class UsersRepo {
631
633
  @Single
632
634
  @Query()
633
635
  async firstOrNull(_a: { email: string }): Promise<User | null> {
634
- throw new SqlStackError();
636
+ return run();
635
637
  }
636
638
 
637
639
  // @Only → require exactly one row; throw if 0 or >1
638
640
  @Only
639
641
  @Query()
640
642
  async exactlyOne(_a: { id: string }): Promise<User> {
641
- throw new SqlStackError();
643
+ return run();
642
644
  }
643
645
 
644
646
  // @Exist → require at least one row; return first row; throw if none
645
647
  @Exist
646
648
  @Query()
647
649
  async requireOne(_a: { email: string }): Promise<User> {
648
- throw new SqlStackError();
650
+ return run();
649
651
  }
650
652
 
651
653
  // @None → require zero rows; return null; throw if any rows
652
654
  @None
653
655
  @Query()
654
656
  async ensureNone(_a: { status: string }): Promise<null> {
655
- throw new SqlStackError();
657
+ return run();
656
658
  }
657
659
  }
658
660
  ```
@@ -670,7 +672,7 @@ Validate query results against a JSON Schema:
670
672
 
671
673
  ```ts
672
674
  import schema from "./user.row.schema.json" assert { type: "json" };
673
- import { QueryBinder, ValidateResult, Single, Query, SqlStackError } from "sqlstack";
675
+ import { QueryBinder, ValidateResult, Single, Query, run } from "sqlstack";
674
676
 
675
677
  @QueryBinder()
676
678
  class UsersRepo {
@@ -678,7 +680,7 @@ class UsersRepo {
678
680
  @Single
679
681
  @Query()
680
682
  async findByEmail(_a: { email: string }): Promise<User | null> {
681
- throw new SqlStackError();
683
+ return run();
682
684
  }
683
685
  }
684
686
  ```
@@ -735,7 +737,7 @@ class Repo {
735
737
  WHERE g.id = :id
736
738
  ORDER BY m.name, e.name
737
739
  ` })
738
- async getGroup(_a: { id: string }): Promise<any | null> { throw new Error('replaced'); }
740
+ async getGroup(_a: { id: string }): Promise<any | null> { return run(); }
739
741
  }
740
742
  ```
741
743
 
@@ -806,13 +808,13 @@ Returns an array of rows, which can be shaped via decorators:
806
808
  class Repo {
807
809
  @Query()
808
810
  async allUsers(): Promise<User[]> {
809
- throw new Error();
811
+ return run();
810
812
  }
811
813
 
812
814
  @Single
813
815
  @Query()
814
816
  async firstUser(): Promise<User | null> {
815
- throw new Error();
817
+ return run();
816
818
  }
817
819
  }
818
820
  ```
@@ -867,16 +869,18 @@ This lets you start with very simple queries and incrementally adopt more comple
867
869
 
868
870
  ## Error Handling
869
871
 
870
- sqlstack provides a base error class and specific error types. Import and catch as needed:
872
+ sqlstack throws; it does not return result objects. Its errors extend `SqlStackError` (with one historical exception, noted below). Import and catch as needed:
871
873
 
872
874
  ```ts
873
- import { SqlStackError } from "sqlstack";
875
+ import { SqlStackError, DatabaseError } from "sqlstack";
874
876
  import { ExactlyOneRowError, NoRowsError, ValidationError } from "sqlstack/errors";
875
877
 
876
878
  try {
877
879
  const user = await repo.findByEmail({ email: "alice@example.com" });
878
880
  } catch (err) {
879
- if (err instanceof ExactlyOneRowError) {
881
+ if (err instanceof DatabaseError) {
882
+ console.log("statement failed:", err.sql, err.code, err.cause);
883
+ } else if (err instanceof ExactlyOneRowError) {
880
884
  console.log("@Only: expected exactly one row, got a different number");
881
885
  } else if (err instanceof NoRowsError) {
882
886
  console.log("@Exist: expected at least one row, got none");
@@ -891,220 +895,165 @@ try {
891
895
  ```
892
896
 
893
897
  Available error classes:
894
- - `SqlStackError` — base class for all sqlstack errors
898
+ - `SqlStackError` — base class for all sqlstack errors (also used for usage errors such as calling `run()` outside a `@Query` method)
899
+ - `DatabaseError` — a statement failed. `.sql` holds the statement (with placeholders), `.cause` holds the original error (the driver's error, or the macro/binding/name-resolution error), and `.code` copies the cause's string driver code (`SQLITE_CONSTRAINT_UNIQUE`, `23505`, `ER_DUP_ENTRY`, ...) when present
895
900
  - `ExactlyOneRowError` — thrown by `@Only` when row count ≠ 1
896
901
  - `NoRowsError` — thrown by `@Exist` when no rows found
902
+ - `RowsExistError` — thrown by `@None` when rows exist
903
+ - `ExpectedRowsError` — a shape decorator was applied to a statement that produced no result set
897
904
  - `ValidationError` — thrown by `@ValidateResult` on schema mismatch
898
- - Database driver errors — pass through unchanged
905
+
906
+ **The error contract.** Driver errors never pass through raw: they are always wrapped in `DatabaseError` with the original in `.cause`. Parameter-binding errors such as `Missing named parameter :email` are a `DatabaseError` with the original in `.cause` in every `run(...)` form, including the zero-argument `run()`. For the `run(...)` forms that take arguments (`run(query)`, `run(db, query)`, `run(db, params)`), *every* failure before result shaping — macro errors from an `` sql`...` `` fragment, parameter-binding errors, an unknown database name — is a `DatabaseError` with the original in `.cause`; invalid arguments are `SqlStackError`. Errors thrown by your own code — the method body or a `@Transform` function — propagate unchanged.
899
907
 
900
908
  ---
901
909
 
902
910
  ## Transactions
903
911
 
904
- Run multiple queries as a single atomic unit. If any query fails, all changes are rolled back automatically.
905
-
906
- ### Basic Usage with `@transaction` Decorator
907
-
908
- Mark a method with `@transaction` to wrap it in a transaction:
912
+ `@transaction` on a method means *everything this method touches is transactional*. You don't name a database: each repository already knows its own (`@QueryBinder({ db })`, the stack default, or the active family binding).
909
913
 
910
914
  ```ts
911
- import { transaction, currentTransaction } from "sqlstack";
915
+ import { transaction } from "sqlstack";
912
916
 
913
- class UserService {
917
+ class SpawnService {
914
918
  @transaction
915
- async createUserWithProfile(data: { email: string; name: string }) {
916
- await this.usersRepo.insert(data);
917
- await this.profilesRepo.insert({ userId: data.id, bio: "" });
919
+ async commitSpawnIdentity(input: Input): Promise<Spawned | SpawnRejected> {
920
+ const root = await this.agents.ensureRoot({ conversationId: input.rootConversationId });
921
+ await this.agents.insertRunning({ agentId: input.agentId, parentId: root.id });
922
+
923
+ if (input.group && !(await this.groups.canJoin(input.group.id))) {
924
+ transaction.rollback(); // mark; nothing is sent yet
925
+ await this.agents.recordRejection({ agentId: input.agentId }); // still runs, still visible here
926
+ return { rejected: true }; // returns normally; everything rolls back
927
+ }
928
+
929
+ transaction.afterCommit(() => this.publishSpawned(input)); // runs only if the commit lands
930
+ return { agentId: input.agentId, rootId: root.id }; // return → commit
918
931
  }
919
932
  }
920
-
921
- // If either insert fails, both are rolled back
922
- await new UserService().createUserWithProfile({ email: "alice@example.com", name: "Alice" });
923
933
  ```
924
934
 
925
- Queries in `@Query` decorated methods automatically participate in the active transaction:
935
+ Queries from `@Query` methods take part automatically. So do `run(sql...)` and `run("name", ...)`:
926
936
 
927
937
  ```ts
928
938
  @QueryBinder()
929
939
  class UsersRepo {
930
940
  @Query({ sql: 'INSERT INTO users (email, name) VALUES (:email, :name)' })
931
941
  async insert(data: { email: string; name: string }): Promise<WriteResult> {
932
- throw new Error("replaced by @Query");
933
- }
934
- }
935
-
936
- @QueryBinder()
937
- class ProfilesRepo {
938
- @Query({ sql: 'INSERT INTO profiles (user_id, bio) VALUES (:userId, :bio)' })
939
- async insert(data: { userId: string; bio: string }): Promise<WriteResult> {
940
- throw new Error("replaced by @Query");
942
+ return run();
941
943
  }
942
944
  }
943
945
  ```
944
946
 
945
- ### Commit and Rollback
947
+ ### Rules
948
+
949
+ 1. **Opened on first touch.** Nothing is opened when the method starts. The first query that resolves to a database with no open transaction in this flow opens one on that database: `BEGIN IMMEDIATE` on SQLite, `BEGIN` on a pooled Postgres client, `START TRANSACTION` on a pooled MySQL connection. A `@transaction` method that issues no SQL opens nothing.
950
+ 2. **Return commits, throw rolls back.** If the method returns normally, every transaction the scope opened is committed, in the order they were opened. If it throws, they are all rolled back and the error is rethrown.
951
+ 3. **`transaction.rollback()` is a mark, not a throw.** The call is synchronous and marks the innermost scope. SQL after it still runs inside the open transaction and is visible to the method. When the method returns, the scope rolls back instead of committing, and the return value still reaches the caller. Pass a database name or a `Database` handle (`transaction.rollback("analytics")`, `transaction.rollback(analyticsDb)`) to mark only that database. The target must have a transaction open in the current flow; otherwise `SqlStackError` is thrown.
952
+ 4. **`await transaction.restart()`.** This rolls back everything the scope has done so far, right away, and fresh transactions open on the next touch. Use it when the work after a failure must persist, such as writing a rejection record after a failed statement. It is the only portable form after a *failed statement* on Postgres, where the whole transaction is aborted until `ROLLBACK`. `afterCommit` hooks registered before the restart are dropped; `afterSettled` hooks stay.
953
+ 5. **Nesting uses savepoints.** A `@transaction` method called from inside another one is a piece of the outer one. On entry it issues `SAVEPOINT` on every transaction already open, and a transaction first opened inside it gets its savepoint right after `BEGIN`. An inner `rollback()`, or an inner throw, becomes `ROLLBACK TO SAVEPOINT` and the outer scope continues. Inner success becomes `RELEASE SAVEPOINT`, and the inner `afterCommit` hooks move up to the outermost scope. The savepoint statements are the same on SQLite, Postgres and MySQL.
954
+ 6. **Hooks.** `transaction.afterCommit(fn)` runs `fn` after the outermost scope commits, in registration order; it is dropped on rollback or on any mark. `transaction.afterSettled(fn)` runs after the outermost scope finishes either way, which makes it the place for cleanup. Hooks run *outside* the scope, so a hook that is itself `@transaction` opens a fresh, independent scope. A hook that throws makes the method's promise reject after all hooks have run; the commit has already happened by then.
955
+ 7. **Two databases are coordinated, not atomic.** Both commit at return and both roll back on a mark. A failure between the two commits, however, leaves them inconsistent. When a scope opens its second database it emits one `sqlstack.transaction.multi_database` event with the database names, so these cases are visible. Cross-database side effects that must not be half-applied belong in `afterCommit`.
956
+ 8. **SQLite queues.** Independent async flows that open a transaction on the same SQLite connection wait their turn instead of failing with "cannot start a transaction within a transaction". Postgres and MySQL get isolation from the pool and need no queue.
946
957
 
947
- **Commit on success:** If the transaction method returns normally, all queries are committed.
958
+ `transaction.rollback`, `transaction.restart`, `transaction.afterCommit` and `transaction.afterSettled` throw `SqlStackError` when called outside a `@transaction` method.
948
959
 
949
- **Rollback on error:** If the method throws, all queries are rolled back and the error is rethrown:
960
+ ### Nested Scopes
950
961
 
951
962
  ```ts
952
963
  class UserService {
953
964
  @transaction
954
- async createUser() {
955
- await this.usersRepo.insert({ email: "bob@example.com" });
956
- throw new Error("something went wrong");
957
- // Rollback happens here; insert is undone
965
+ async createUser(email: string) {
966
+ await this.usersRepo.insert({ email });
967
+ await this.createDefaultProfile(); // SAVEPOINT … RELEASE SAVEPOINT
968
+ await this.usersRepo.markReady({ email });
958
969
  }
959
- }
960
-
961
- await new UserService().createUser(); // throws "something went wrong"
962
- ```
963
-
964
- ### Lazy Transactions
965
-
966
- By default, transactions use **lazy mode**: they don't start (no `BEGIN`) until the first query runs. This avoids overhead if the method never queries:
967
-
968
- ```ts
969
- @transaction({ lazy: true }) // default behavior
970
- async readOnlyOperation() {
971
- // No database transaction started yet
972
- const data = await this.repo.fetch();
973
-
974
- // Process data without holding a transaction
975
- return processData(data);
976
- }
977
- ```
978
-
979
- Disable lazy mode with `lazy: false` to start the transaction immediately:
980
-
981
- ```ts
982
- @transaction({ lazy: false })
983
- async mustStartTransaction() {
984
- // BEGIN is executed immediately, even before first query
985
- }
986
- ```
987
970
 
988
- ### Accessing the Current Transaction
989
-
990
- Use `currentTransaction()` to access the active transaction context within a method:
991
-
992
- ```ts
993
- class UserService {
994
971
  @transaction
995
- async createUserWithValidation(data: { email: string }) {
996
- const user = await this.usersRepo.insert(data);
997
-
998
- // Validate user was inserted
999
- const check = await this.usersRepo.findById(user.id);
1000
- if (!check) {
1001
- // Mark transaction for rollback and throw a custom error
1002
- currentTransaction()?.rollbackOnly(new Error("User not found after insert"));
1003
- return; // exit gracefully
1004
- }
1005
-
1006
- return user;
972
+ async createDefaultProfile() {
973
+ await this.profilesRepo.insert({ name: "Default Profile" });
974
+ if (await this.profilesRepo.isDuplicate()) transaction.rollback(); // undoes only this piece
1007
975
  }
1008
976
  }
1009
977
  ```
1010
978
 
1011
- ### Rollback with Custom Error
1012
-
1013
- Use `rollbackOnly(error)` to mark a transaction for rollback while specifying which error should be thrown:
979
+ ### Restart After a Failed Statement
1014
980
 
1015
981
  ```ts
1016
- class UserService {
982
+ class JoinService {
1017
983
  @transaction
1018
- async createUser(email: string) {
984
+ async join(input: JoinInput) {
1019
985
  try {
1020
- await this.usersRepo.insert({ email });
1021
- } catch (dbError) {
1022
- // Roll back and throw a sanitized error
1023
- currentTransaction()?.rollbackOnly(new Error("Failed to create user"));
1024
- return; // rethrow happens automatically
986
+ await this.members.insert(input);
987
+ } catch (err) {
988
+ await transaction.restart(); // the failed work is gone
989
+ await this.rejections.insert({ id: input.id }); // this persists
990
+ return { joined: false };
1025
991
  }
992
+ return { joined: true };
1026
993
  }
1027
994
  }
1028
995
  ```
1029
996
 
1030
- You can also throw `RollbackTransactionError` to explicitly signal rollback while optionally wrapping another error:
997
+ ### Observing Multi-Database Scopes
1031
998
 
1032
999
  ```ts
1033
- import { RollbackTransactionError } from "sqlstack";
1034
-
1035
- class UserService {
1036
- @transaction
1037
- async createUser(email: string) {
1038
- await this.usersRepo.insert({ email });
1000
+ import { onTransactionEvent } from "sqlstack";
1039
1001
 
1040
- // Roll back and throw the wrapped error
1041
- throw new RollbackTransactionError("Transaction cancelled", new Error("User rejected"));
1042
- // Caller sees: Error("User rejected")
1043
- }
1044
- }
1002
+ const unsubscribe = onTransactionEvent((event) => {
1003
+ // { type: "sqlstack.transaction.multi_database", databases: ["primary", "analytics"] }
1004
+ logger.warn(event.type, { databases: event.databases });
1005
+ });
1045
1006
  ```
1046
1007
 
1047
- ### Using `withTransaction()` Function
1008
+ ### Name-Based Transactions (`@transaction({ db })`, `withTransaction`)
1048
1009
 
1049
- For programmatic control, use the `withTransaction()` function:
1010
+ The name-based form from earlier versions still works as it always has. It opens a transaction on one named database only, and queries on other databases run outside it:
1050
1011
 
1051
1012
  ```ts
1052
- import { withTransaction } from "sqlstack";
1013
+ import { transaction, withTransaction, currentTransaction, RollbackTransactionError } from "sqlstack";
1053
1014
 
1054
- const result = await withTransaction(async () => {
1055
- const user = await this.usersRepo.insert({ email: "charlie@example.com" });
1056
- await this.profilesRepo.insert({ userId: user.id });
1057
- return user;
1058
- }, { db: "primary" });
1059
- ```
1060
-
1061
- ### Multiple Databases
1062
-
1063
- Transactions are per-database. Run transactions on different databases simultaneously:
1064
-
1065
- ```ts
1066
1015
  class MultiDbService {
1067
- @transaction({ db: "primary" })
1016
+ @transaction({ db: "primary" }) // lazy: BEGIN on the first query to "primary"
1068
1017
  async insertUser() {
1069
1018
  await this.usersRepo.insert({ email: "dave@example.com" });
1070
1019
  }
1071
1020
 
1072
- @transaction({ db: "analytics" })
1073
- async logAnalytics() {
1074
- await this.analyticsRepo.insert({ event: "user_created" });
1075
- }
1021
+ @transaction({ db: "primary", lazy: false }) // BEGIN immediately
1022
+ async mustStart() {}
1076
1023
  }
1077
1024
 
1078
- // Both transactions run independently
1079
- await Promise.all([
1080
- new MultiDbService().insertUser(),
1081
- new MultiDbService().logAnalytics(),
1082
- ]);
1025
+ const result = await withTransaction(async () => {
1026
+ const user = await this.usersRepo.insert({ email: "charlie@example.com" });
1027
+ await this.profilesRepo.insert({ userId: user.id });
1028
+ return user;
1029
+ }, { db: "primary" });
1083
1030
  ```
1084
1031
 
1085
- ### Nested Transactions (Savepoints)
1032
+ On SQLite this form issues a plain `BEGIN` and doesn't queue. `@transaction({ lazy: false })` with no `db` opens eagerly on the default database.
1086
1033
 
1087
- Multiple `@transaction` decorators stack safely. Only the outermost transaction commits/rolls back:
1034
+ `currentTransaction()?.rollbackOnly(error?)` marks the active transaction for rollback *and throws* at exit: it throws `error` if one was given, otherwise `RollbackTransactionError`. Throwing `new RollbackTransactionError(message, cause)` rolls back and rethrows `cause`. Both work inside a scope-based `@transaction` too:
1088
1035
 
1089
1036
  ```ts
1090
1037
  class UserService {
1091
1038
  @transaction
1092
1039
  async createUser(email: string) {
1093
- await this.usersRepo.insert({ email });
1094
- await this.createDefaultProfile();
1040
+ try {
1041
+ await this.usersRepo.insert({ email });
1042
+ } catch {
1043
+ currentTransaction()?.rollbackOnly(new Error("Failed to create user")); // caller sees this error
1044
+ return;
1045
+ }
1095
1046
  }
1096
1047
 
1097
- @transaction // nested decorator
1098
- async createDefaultProfile() {
1099
- await this.profilesRepo.insert({ name: "Default Profile" });
1048
+ @transaction
1049
+ async cancel(email: string) {
1050
+ await this.usersRepo.insert({ email });
1051
+ throw new RollbackTransactionError("Transaction cancelled", new Error("User rejected")); // caller sees "User rejected"
1100
1052
  }
1101
1053
  }
1102
-
1103
- // Outer transaction commits both inserts
1104
- await new UserService().createUser("eve@example.com");
1105
1054
  ```
1106
1055
 
1107
- All queries use the same transaction context. If the inner method throws, the outer transaction rolls back everything.
1056
+ When code is mixed, a name-based transaction that is open in the flow is joined by queries on that database. The name-based transaction keeps its own commit and rollback rules: a scope's `rollback()` mark or `restart()` doesn't apply to it.
1108
1057
 
1109
1058
  ### Connection Pooling and Resource Cleanup
1110
1059
 
@@ -1184,7 +1133,7 @@ async createUser() {
1184
1133
 
1185
1134
  ## Inline SQL
1186
1135
 
1187
- For simple or dynamic queries, use `@Query({ sql: "..." })` with inline SQL:
1136
+ For simple queries, use `@Query({ sql: "..." })` with inline SQL:
1188
1137
 
1189
1138
  ```ts
1190
1139
  @QueryBinder()
@@ -1195,18 +1144,99 @@ class Repo {
1195
1144
  WHERE email = :email
1196
1145
  ` })
1197
1146
  async findByEmail(_a: { email: string }): Promise<User[]> {
1198
- throw new Error("replaced");
1147
+ return run();
1199
1148
  }
1200
1149
  }
1201
1150
  ```
1202
1151
 
1203
1152
  sqlstack applies the same parameter binding and result shaping as file-based SQL.
1204
1153
 
1154
+ For SQL built in code, pass an `` sql`...` `` fragment to `run`. The fragment replaces the method's SQL file; its `${}` values are the bound parameters (the method's own arguments are not auto-bound). The database still comes from `@Query({ db })` / `@QueryBinder({ db })` / the default, the active transaction is joined, and the shape/transform/validation decorators still apply:
1155
+
1156
+ ```ts
1157
+ import { QueryBinder, Query, Single, run, sql } from "sqlstack";
1158
+
1159
+ @QueryBinder()
1160
+ class Repo {
1161
+ @Single
1162
+ @Query()
1163
+ async findByEmail(email: string): Promise<User | null> {
1164
+ return run(sql`SELECT id, email, name FROM users WHERE email = ${email}`);
1165
+ }
1166
+ }
1167
+ ```
1168
+
1169
+ SQL is never accepted as a plain string — always build it with the `sql` tag, and pass the fragment itself (not its `.toSQL()` output) so `run` can bind it for the target dialect.
1170
+
1171
+ > **Deprecated:** a `@Query` body that *returns* an `` sql`...` `` fragment (instead of `` return run(sql`...`) ``) still executes, but only type-checks with an `as any`-style cast. It emits a one-time `DeprecationWarning` (`SQLSTACK_FRAGMENT_BODY`) per method and will be removed in the next major version.
1172
+
1173
+ ---
1174
+
1175
+ ## Choosing the Database at Call Time
1176
+
1177
+ When the database handle is only known at runtime (per-tenant or per-thread databases, a handle passed into a repository method), pass it to `run` as the first argument. The handle is anything with `query(sql, params)` — a sqlstack `Database` from `createPgDb` / `createMysqlDb` / `createSqliteDb`, or your application's own wrapper — or the name of a registered database. sqlstack never opens databases by path.
1178
+
1179
+ ### `run(db, query)` — anywhere
1180
+
1181
+ ```ts
1182
+ import { run, sql } from "sqlstack";
1183
+
1184
+ const rows = await run<ImportRow[]>(accountsDb, sql`
1185
+ SELECT 1 AS present FROM data_root_imports
1186
+ WHERE data_root = ${dataRoot} AND kind = ${kind}
1187
+ LIMIT 1
1188
+ `);
1189
+
1190
+ await run(accountsDb, sql`INSERT INTO data_root_imports :insert(${{ data_root: dataRoot, kind }})`);
1191
+ ```
1192
+
1193
+ Outside a `@Query` method this returns the raw result: a rows array for statements that produce a result set, `{ rowsAffected, lastInsertId? }` otherwise. Without an explicit type argument the result is `unknown`, never `any`. Inside a `@Query` method, `` return run(db, sql`...`) `` runs the fragment on `db` and applies the method's shape/transform/validation decorators; the result type is inferred from the method's declared return type.
1194
+
1195
+ ### `run(db, params)` — inside a `@Query` method
1196
+
1197
+ Runs the method's own SQL (its `.sql` file or inline `@Query({ sql })`) against `db`, binding `params` — an object for `:named` placeholders, an array for `:arg1, :arg2, ...`. In this form the method's raw arguments are **not** auto-bound; `params` is the whole binding input. `@Defaults`, `@Page`, shaping, transform, and validation still apply.
1198
+
1199
+ ```ts
1200
+ @QueryBinder()
1201
+ class AccountsRepo {
1202
+ @Single
1203
+ @Query() // AccountsRepo/findByEmail.sql: SELECT * FROM accounts WHERE email = :email
1204
+ async findByEmail(db: Database, params: { email: string }): Promise<Account | null> {
1205
+ return run(db, params);
1206
+ }
1207
+ }
1208
+ ```
1209
+
1210
+ The SQL file is located through the same resolver as `run()` (under an active IoC environment that means the root's registered `SqlStack`). Calling `run(db, params)` outside a `@Query` method throws `SqlStackError` — there is no SQL to run.
1211
+
1212
+ ### The forms at a glance
1213
+
1214
+ | Call | Where | SQL | Database | Binding |
1215
+ |---|---|---|---|---|
1216
+ | `run()` | `@Query` body | method's file / inline SQL | decorator context | method arguments |
1217
+ | `run(query)` | `@Query` body | the fragment | decorator context | fragment values |
1218
+ | `run(db, query)` | anywhere | the fragment | `db` | fragment values |
1219
+ | `run(db, params)` | `@Query` body | method's file / inline SQL | `db` | `params` only |
1220
+
1221
+ Type rules (enforced by the overloads, and mirrored at runtime for untyped callers): SQL strings are rejected, `.toSQL()` output (`{ sql, params }`) is rejected, the first argument must be a handle or a name, and params must be an object or array. All forms follow the [error contract](#error-handling): failures surface as `DatabaseError` with the original error in `.cause`.
1222
+
1223
+ ### Dialects
1224
+
1225
+ `run` binds fragments for the target database: `$1, $2, ...` for Postgres, `?` for MySQL and SQLite (including inside `:insert`, `:update`, `:filter`, and `:batch_insert`). The dialect comes from the handle's `dialect` property and defaults to `sqlite` when the handle has none. `fragment.toSQL()` itself is unchanged and always emits `?`.
1226
+
1227
+ ### Transactions
1228
+
1229
+ A database passed to `run` joins an active transaction (from `@transaction` / `withTransaction`) that was opened **on that same database**: by name, or — for a handle — when it is the very `Database` object registered under the transaction's name. A handle that is not registered with sqlstack (for example an application-owned SQLite connection) never joins a sqlstack transaction; manage its transactions with your driver.
1230
+
1231
+ ### Inside `@Query`, all `run` calls belong to the method
1232
+
1233
+ `run` finds the enclosing `@Query` invocation through async context. Any `run(...)` executed while a `@Query` method body is running — including inside helper functions the body awaits — is treated as that method's query and gets its shape/transform/validation decorators. Keep `run(db, query)` calls meant to return raw results out of `@Query` bodies.
1234
+
1205
1235
  ---
1206
1236
 
1207
1237
  ## Advanced: Direct Database Access
1208
1238
 
1209
- Every `Database` instance exposes the underlying driver via `.conn`, so you can drop down to raw queries when needed:
1239
+ For SQL that should stay typed and dialect-safe, prefer `` run(db, sql`...`) `` (see [Choosing the Database at Call Time](#choosing-the-database-at-call-time)). Every `Database` instance also exposes the underlying driver via `.conn`, so you can drop down to raw driver calls when needed:
1210
1240
 
1211
1241
  ```ts
1212
1242
  import { SqlStackDB } from 'sqlstack/registry';
@@ -1238,7 +1268,7 @@ More adapters are planned.
1238
1268
 
1239
1269
  ### Dialect Inference
1240
1270
 
1241
- The placeholder style is inferred from the selected database. Override per-method with `@Query({ dialect: "pg" })` if needed.
1271
+ The placeholder style is inferred from the selected database. Override per-method with `@Query({ dialect: "postgres" })` if needed.
1242
1272
 
1243
1273
  ---
1244
1274