sqlstack 1.0.21 → 1.0.22

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 (2) hide show
  1. package/package.json +1 -1
  2. package/readme.md +284 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlstack",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "description": "",
5
5
  "license": "ISC",
6
6
  "author": "",
package/readme.md CHANGED
@@ -14,6 +14,7 @@ Write real SQL next to your code, and use small, composable decorators to bind,
14
14
  - [How It Works](#how-it-works)
15
15
  - [Result Types](#result-types)
16
16
  - [Error Handling](#error-handling)
17
+ - [Transactions](#transactions)
17
18
  - [Inline SQL](#inline-sql)
18
19
  - [Advanced: Direct Database Access](#advanced-direct-database-access)
19
20
  - [Database Support](#database-support)
@@ -897,6 +898,289 @@ Available error classes:
897
898
 
898
899
  ---
899
900
 
901
+ ## Transactions
902
+
903
+ Run multiple queries as a single atomic unit. If any query fails, all changes are rolled back automatically.
904
+
905
+ ### Basic Usage with `@transaction` Decorator
906
+
907
+ Mark a method with `@transaction` to wrap it in a transaction:
908
+
909
+ ```ts
910
+ import { transaction, currentTransaction } from "sqlstack";
911
+
912
+ class UserService {
913
+ @transaction
914
+ async createUserWithProfile(data: { email: string; name: string }) {
915
+ await this.usersRepo.insert(data);
916
+ await this.profilesRepo.insert({ userId: data.id, bio: "" });
917
+ }
918
+ }
919
+
920
+ // If either insert fails, both are rolled back
921
+ await new UserService().createUserWithProfile({ email: "alice@example.com", name: "Alice" });
922
+ ```
923
+
924
+ Queries in `@Query` decorated methods automatically participate in the active transaction:
925
+
926
+ ```ts
927
+ @QueryBinder()
928
+ class UsersRepo {
929
+ @Query({ sql: 'INSERT INTO users (email, name) VALUES (:email, :name)' })
930
+ async insert(data: { email: string; name: string }): Promise<WriteResult> {
931
+ throw new Error("replaced by @Query");
932
+ }
933
+ }
934
+
935
+ @QueryBinder()
936
+ class ProfilesRepo {
937
+ @Query({ sql: 'INSERT INTO profiles (user_id, bio) VALUES (:userId, :bio)' })
938
+ async insert(data: { userId: string; bio: string }): Promise<WriteResult> {
939
+ throw new Error("replaced by @Query");
940
+ }
941
+ }
942
+ ```
943
+
944
+ ### Commit and Rollback
945
+
946
+ **Commit on success:** If the transaction method returns normally, all queries are committed.
947
+
948
+ **Rollback on error:** If the method throws, all queries are rolled back and the error is rethrown:
949
+
950
+ ```ts
951
+ class UserService {
952
+ @transaction
953
+ async createUser() {
954
+ await this.usersRepo.insert({ email: "bob@example.com" });
955
+ throw new Error("something went wrong");
956
+ // Rollback happens here; insert is undone
957
+ }
958
+ }
959
+
960
+ await new UserService().createUser(); // throws "something went wrong"
961
+ ```
962
+
963
+ ### Lazy Transactions
964
+
965
+ 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:
966
+
967
+ ```ts
968
+ @transaction({ lazy: true }) // default behavior
969
+ async readOnlyOperation() {
970
+ // No database transaction started yet
971
+ const data = await this.repo.fetch();
972
+
973
+ // Process data without holding a transaction
974
+ return processData(data);
975
+ }
976
+ ```
977
+
978
+ Disable lazy mode with `lazy: false` to start the transaction immediately:
979
+
980
+ ```ts
981
+ @transaction({ lazy: false })
982
+ async mustStartTransaction() {
983
+ // BEGIN is executed immediately, even before first query
984
+ }
985
+ ```
986
+
987
+ ### Accessing the Current Transaction
988
+
989
+ Use `currentTransaction()` to access the active transaction context within a method:
990
+
991
+ ```ts
992
+ class UserService {
993
+ @transaction
994
+ async createUserWithValidation(data: { email: string }) {
995
+ const user = await this.usersRepo.insert(data);
996
+
997
+ // Validate user was inserted
998
+ const check = await this.usersRepo.findById(user.id);
999
+ if (!check) {
1000
+ // Mark transaction for rollback and throw a custom error
1001
+ currentTransaction()?.rollbackOnly(new Error("User not found after insert"));
1002
+ return; // exit gracefully
1003
+ }
1004
+
1005
+ return user;
1006
+ }
1007
+ }
1008
+ ```
1009
+
1010
+ ### Rollback with Custom Error
1011
+
1012
+ Use `rollbackOnly(error)` to mark a transaction for rollback while specifying which error should be thrown:
1013
+
1014
+ ```ts
1015
+ class UserService {
1016
+ @transaction
1017
+ async createUser(email: string) {
1018
+ try {
1019
+ await this.usersRepo.insert({ email });
1020
+ } catch (dbError) {
1021
+ // Roll back and throw a sanitized error
1022
+ currentTransaction()?.rollbackOnly(new Error("Failed to create user"));
1023
+ return; // rethrow happens automatically
1024
+ }
1025
+ }
1026
+ }
1027
+ ```
1028
+
1029
+ You can also throw `RollbackTransactionError` to explicitly signal rollback while optionally wrapping another error:
1030
+
1031
+ ```ts
1032
+ import { RollbackTransactionError } from "sqlstack";
1033
+
1034
+ class UserService {
1035
+ @transaction
1036
+ async createUser(email: string) {
1037
+ await this.usersRepo.insert({ email });
1038
+
1039
+ // Roll back and throw the wrapped error
1040
+ throw new RollbackTransactionError("Transaction cancelled", new Error("User rejected"));
1041
+ // Caller sees: Error("User rejected")
1042
+ }
1043
+ }
1044
+ ```
1045
+
1046
+ ### Using `withTransaction()` Function
1047
+
1048
+ For programmatic control, use the `withTransaction()` function:
1049
+
1050
+ ```ts
1051
+ import { withTransaction } from "sqlstack";
1052
+
1053
+ const result = await withTransaction(async () => {
1054
+ const user = await this.usersRepo.insert({ email: "charlie@example.com" });
1055
+ await this.profilesRepo.insert({ userId: user.id });
1056
+ return user;
1057
+ }, { db: "primary" });
1058
+ ```
1059
+
1060
+ ### Multiple Databases
1061
+
1062
+ Transactions are per-database. Run transactions on different databases simultaneously:
1063
+
1064
+ ```ts
1065
+ class MultiDbService {
1066
+ @transaction({ db: "primary" })
1067
+ async insertUser() {
1068
+ await this.usersRepo.insert({ email: "dave@example.com" });
1069
+ }
1070
+
1071
+ @transaction({ db: "analytics" })
1072
+ async logAnalytics() {
1073
+ await this.analyticsRepo.insert({ event: "user_created" });
1074
+ }
1075
+ }
1076
+
1077
+ // Both transactions run independently
1078
+ await Promise.all([
1079
+ new MultiDbService().insertUser(),
1080
+ new MultiDbService().logAnalytics(),
1081
+ ]);
1082
+ ```
1083
+
1084
+ ### Nested Transactions (Savepoints)
1085
+
1086
+ Multiple `@transaction` decorators stack safely. Only the outermost transaction commits/rolls back:
1087
+
1088
+ ```ts
1089
+ class UserService {
1090
+ @transaction
1091
+ async createUser(email: string) {
1092
+ await this.usersRepo.insert({ email });
1093
+ await this.createDefaultProfile();
1094
+ }
1095
+
1096
+ @transaction // nested decorator
1097
+ async createDefaultProfile() {
1098
+ await this.profilesRepo.insert({ name: "Default Profile" });
1099
+ }
1100
+ }
1101
+
1102
+ // Outer transaction commits both inserts
1103
+ await new UserService().createUser("eve@example.com");
1104
+ ```
1105
+
1106
+ All queries use the same transaction context. If the inner method throws, the outer transaction rolls back everything.
1107
+
1108
+ ### Connection Pooling and Resource Cleanup
1109
+
1110
+ For **Postgres** and **MySQL**, transactions acquire connections from the pool and release them when complete:
1111
+
1112
+ **Postgres:**
1113
+ ```ts
1114
+ import { createPgDb } from "sqlstack/adapters";
1115
+ import { Pool } from "pg";
1116
+
1117
+ const pool = new Pool({ connectionString: process.env.PG_URL });
1118
+ SqlStackDB.register("primary", createPgDb(pool));
1119
+
1120
+ class UserService {
1121
+ @transaction({ db: "primary" })
1122
+ async createUser() {
1123
+ // A client is acquired from the pool for this transaction
1124
+ await this.usersRepo.insert({ email: "frank@example.com" });
1125
+ // Client is released back to the pool after commit/rollback
1126
+ }
1127
+ }
1128
+ ```
1129
+
1130
+ **MySQL:**
1131
+ ```ts
1132
+ import { createMysqlDb } from "sqlstack/adapters";
1133
+ import mysql from "mysql2/promise";
1134
+
1135
+ const pool = mysql.createPool({ uri: process.env.MYSQL_URL });
1136
+ SqlStackDB.register("primary", createMysqlDb(pool));
1137
+
1138
+ class UserService {
1139
+ @transaction({ db: "primary" })
1140
+ async createUser() {
1141
+ // A connection is acquired from the pool
1142
+ await this.usersRepo.insert({ email: "grace@example.com" });
1143
+ // Connection is released back to the pool
1144
+ }
1145
+ }
1146
+ ```
1147
+
1148
+ **SQLite** uses the existing connection (better-sqlite3 doesn't support pooling).
1149
+
1150
+ ### Error Handling in Transactions
1151
+
1152
+ Errors are always rethrown after rollback:
1153
+
1154
+ ```ts
1155
+ class UserService {
1156
+ @transaction
1157
+ async createUser() {
1158
+ await this.usersRepo.insert({ email: "henry@example.com" });
1159
+ throw new Error("oops");
1160
+ // Rollback happens, then "oops" is thrown to caller
1161
+ }
1162
+ }
1163
+
1164
+ try {
1165
+ await new UserService().createUser();
1166
+ } catch (err) {
1167
+ console.error(err.message); // "oops"
1168
+ }
1169
+ ```
1170
+
1171
+ If rollback itself fails, that error is thrown (and the original error is lost):
1172
+
1173
+ ```ts
1174
+ @transaction
1175
+ async createUser() {
1176
+ await this.usersRepo.insert({ email: "ivan@example.com" });
1177
+ throw new Error("original error");
1178
+ // If ROLLBACK SQL fails, the rollback error is thrown instead
1179
+ }
1180
+ ```
1181
+
1182
+ ---
1183
+
900
1184
  ## Inline SQL
901
1185
 
902
1186
  For simple or dynamic queries, use `@Query({ sql: "..." })` with inline SQL: