sqlstack 3.3.0 → 3.5.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.
- package/dist/cjs/adapters/sqlite.d.ts.map +1 -1
- package/dist/cjs/adapters/sqlite.js +4 -1
- package/dist/cjs/adapters/sqlite.js.map +1 -1
- package/dist/cjs/codegen/generateManifest.d.ts +3 -1
- package/dist/cjs/codegen/generateManifest.d.ts.map +1 -1
- package/dist/cjs/codegen/generateManifest.js +195 -3
- package/dist/cjs/codegen/generateManifest.js.map +1 -1
- package/dist/cjs/core/errors.d.ts +9 -0
- package/dist/cjs/core/errors.d.ts.map +1 -1
- package/dist/cjs/core/errors.js +10 -0
- package/dist/cjs/core/errors.js.map +1 -1
- package/dist/cjs/core/fileResolution.d.ts +7 -0
- package/dist/cjs/core/fileResolution.d.ts.map +1 -1
- package/dist/cjs/core/fileResolution.js +53 -14
- package/dist/cjs/core/fileResolution.js.map +1 -1
- package/dist/cjs/core/metadata.d.ts +5 -0
- package/dist/cjs/core/metadata.d.ts.map +1 -1
- package/dist/cjs/core/metadata.js.map +1 -1
- package/dist/cjs/core/resources.d.ts +36 -0
- package/dist/cjs/core/resources.d.ts.map +1 -0
- package/dist/cjs/core/resources.js +98 -0
- package/dist/cjs/core/resources.js.map +1 -0
- package/dist/cjs/core/sourceScanResolver.d.ts +8 -14
- package/dist/cjs/core/sourceScanResolver.d.ts.map +1 -1
- package/dist/cjs/core/sourceScanResolver.js +8 -20
- package/dist/cjs/core/sourceScanResolver.js.map +1 -1
- package/dist/cjs/decorators/query.d.ts +14 -0
- package/dist/cjs/decorators/query.d.ts.map +1 -1
- package/dist/cjs/decorators/query.js +208 -40
- package/dist/cjs/decorators/query.js.map +1 -1
- package/dist/cjs/family.d.ts.map +1 -1
- package/dist/cjs/family.js +3 -1
- package/dist/cjs/family.js.map +1 -1
- package/dist/cjs/index.d.ts +4 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/run.d.ts +180 -6
- package/dist/cjs/run.d.ts.map +1 -1
- package/dist/cjs/run.js +158 -17
- package/dist/cjs/run.js.map +1 -1
- package/dist/cjs/runtime.d.ts +14 -0
- package/dist/cjs/runtime.d.ts.map +1 -1
- package/dist/cjs/runtime.js +30 -0
- package/dist/cjs/runtime.js.map +1 -1
- package/dist/cjs/sql.d.ts +19 -0
- package/dist/cjs/sql.d.ts.map +1 -1
- package/dist/cjs/sql.js +66 -28
- package/dist/cjs/sql.js.map +1 -1
- package/dist/cjs/stack.d.ts +16 -0
- package/dist/cjs/stack.d.ts.map +1 -1
- package/dist/cjs/stack.js +60 -27
- package/dist/cjs/stack.js.map +1 -1
- package/dist/cjs/testing/builder.d.ts.map +1 -1
- package/dist/cjs/testing/builder.js +14 -5
- package/dist/cjs/testing/builder.js.map +1 -1
- package/dist/cjs/testing/fixture-provenance.d.ts +3 -0
- package/dist/cjs/testing/fixture-provenance.d.ts.map +1 -0
- package/dist/cjs/testing/fixture-provenance.js +21 -0
- package/dist/cjs/testing/fixture-provenance.js.map +1 -0
- package/dist/cjs/testing/index.d.ts +2 -0
- package/dist/cjs/testing/index.d.ts.map +1 -1
- package/dist/cjs/testing/index.js +5 -1
- package/dist/cjs/testing/index.js.map +1 -1
- package/dist/cjs/testing/postgres/database.d.ts +9 -1
- package/dist/cjs/testing/postgres/database.d.ts.map +1 -1
- package/dist/cjs/testing/postgres/database.js +17 -13
- package/dist/cjs/testing/postgres/database.js.map +1 -1
- package/dist/cjs/testing/resource.d.ts +76 -0
- package/dist/cjs/testing/resource.d.ts.map +1 -0
- package/dist/cjs/testing/resource.js +117 -0
- package/dist/cjs/testing/resource.js.map +1 -0
- package/dist/cjs/testing/sqlite/builder.d.ts.map +1 -1
- package/dist/cjs/testing/sqlite/builder.js +10 -2
- package/dist/cjs/testing/sqlite/builder.js.map +1 -1
- package/dist/cjs/testing/sqlite/database.d.ts +11 -1
- package/dist/cjs/testing/sqlite/database.d.ts.map +1 -1
- package/dist/cjs/testing/sqlite/database.js +32 -18
- package/dist/cjs/testing/sqlite/database.js.map +1 -1
- package/dist/cjs/transactions.d.ts +66 -0
- package/dist/cjs/transactions.d.ts.map +1 -1
- package/dist/cjs/transactions.js +584 -3
- package/dist/cjs/transactions.js.map +1 -1
- package/dist/esm/adapters/sqlite.js +4 -1
- package/dist/esm/adapters/sqlite.js.map +1 -1
- package/dist/esm/codegen/generateManifest.js +195 -3
- package/dist/esm/codegen/generateManifest.js.map +1 -1
- package/dist/esm/core/errors.js +10 -0
- package/dist/esm/core/errors.js.map +1 -1
- package/dist/esm/core/fileResolution.js +52 -14
- package/dist/esm/core/fileResolution.js.map +1 -1
- package/dist/esm/core/metadata.js.map +1 -1
- package/dist/esm/core/resources.js +91 -0
- package/dist/esm/core/resources.js.map +1 -0
- package/dist/esm/core/sourceScanResolver.js +8 -19
- package/dist/esm/core/sourceScanResolver.js.map +1 -1
- package/dist/esm/decorators/query.js +214 -46
- package/dist/esm/decorators/query.js.map +1 -1
- package/dist/esm/family.js +3 -1
- package/dist/esm/family.js.map +1 -1
- package/dist/esm/index.js +4 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/run.js +153 -18
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runtime.js +28 -0
- package/dist/esm/runtime.js.map +1 -1
- package/dist/esm/sql.js +63 -27
- package/dist/esm/sql.js.map +1 -1
- package/dist/esm/stack.js +61 -28
- package/dist/esm/stack.js.map +1 -1
- package/dist/esm/testing/builder.js +14 -5
- package/dist/esm/testing/builder.js.map +1 -1
- package/dist/esm/testing/fixture-provenance.js +15 -0
- package/dist/esm/testing/fixture-provenance.js.map +1 -0
- package/dist/esm/testing/index.js +1 -0
- package/dist/esm/testing/index.js.map +1 -1
- package/dist/esm/testing/postgres/database.js +17 -13
- package/dist/esm/testing/postgres/database.js.map +1 -1
- package/dist/esm/testing/resource.js +106 -0
- package/dist/esm/testing/resource.js.map +1 -0
- package/dist/esm/testing/sqlite/builder.js +10 -2
- package/dist/esm/testing/sqlite/builder.js.map +1 -1
- package/dist/esm/testing/sqlite/database.js +32 -18
- package/dist/esm/testing/sqlite/database.js.map +1 -1
- package/dist/esm/transactions.js +579 -3
- package/dist/esm/transactions.js.map +1 -1
- package/package.json +1 -1
- 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,
|
|
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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
130
|
+
return run();
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
@Query()
|
|
132
134
|
async updateProfile(params: { id: string; name?: string; status?: string }): Promise<WriteResult> {
|
|
133
|
-
|
|
135
|
+
return run();
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
@Query()
|
|
137
139
|
async createUser(params: { id: string; email: string; name?: string }): Promise<WriteResult> {
|
|
138
|
-
|
|
140
|
+
return run();
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
@Query()
|
|
142
144
|
async search(filters: { name?: string; status?: string }): Promise<User[]> {
|
|
143
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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> {
|
|
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
|
-
|
|
811
|
+
return run();
|
|
810
812
|
}
|
|
811
813
|
|
|
812
814
|
@Single
|
|
813
815
|
@Query()
|
|
814
816
|
async firstUser(): Promise<User | null> {
|
|
815
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
915
|
+
import { transaction } from "sqlstack";
|
|
912
916
|
|
|
913
|
-
class
|
|
917
|
+
class SpawnService {
|
|
914
918
|
@transaction
|
|
915
|
-
async
|
|
916
|
-
await this.
|
|
917
|
-
await this.
|
|
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
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
958
|
+
`transaction.rollback`, `transaction.restart`, `transaction.afterCommit` and `transaction.afterSettled` throw `SqlStackError` when called outside a `@transaction` method.
|
|
948
959
|
|
|
949
|
-
|
|
960
|
+
### Nested Scopes
|
|
950
961
|
|
|
951
962
|
```ts
|
|
952
963
|
class UserService {
|
|
953
964
|
@transaction
|
|
954
|
-
async createUser() {
|
|
955
|
-
await this.usersRepo.insert({ email
|
|
956
|
-
|
|
957
|
-
|
|
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
|
|
996
|
-
|
|
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
|
-
###
|
|
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
|
|
982
|
+
class JoinService {
|
|
1017
983
|
@transaction
|
|
1018
|
-
async
|
|
984
|
+
async join(input: JoinInput) {
|
|
1019
985
|
try {
|
|
1020
|
-
await this.
|
|
1021
|
-
} catch (
|
|
1022
|
-
//
|
|
1023
|
-
|
|
1024
|
-
return
|
|
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
|
-
|
|
997
|
+
### Observing Multi-Database Scopes
|
|
1031
998
|
|
|
1032
999
|
```ts
|
|
1033
|
-
import {
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
###
|
|
1008
|
+
### Name-Based Transactions (`@transaction({ db })`, `withTransaction`)
|
|
1048
1009
|
|
|
1049
|
-
|
|
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: "
|
|
1073
|
-
async
|
|
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
|
-
|
|
1079
|
-
await
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
|
1098
|
-
async
|
|
1099
|
-
await this.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
|