@c9up/atlas 0.2.0 → 0.2.1

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 (234) hide show
  1. package/README.md +8 -0
  2. package/db.darwin-arm64.node +0 -0
  3. package/db.darwin-x64.node +0 -0
  4. package/db.linux-arm64-gnu.node +0 -0
  5. package/db.linux-x64-gnu.node +0 -0
  6. package/db.win32-x64-msvc.node +0 -0
  7. package/dist/AtlasProvider.d.ts +60 -7
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +204 -38
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +17 -0
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js.map +1 -1
  14. package/dist/BaseModel.d.ts +63 -27
  15. package/dist/BaseModel.d.ts.map +1 -1
  16. package/dist/BaseModel.js +108 -51
  17. package/dist/BaseModel.js.map +1 -1
  18. package/dist/BaseRepository.d.ts +11 -1
  19. package/dist/BaseRepository.d.ts.map +1 -1
  20. package/dist/BaseRepository.js +95 -20
  21. package/dist/BaseRepository.js.map +1 -1
  22. package/dist/ConnectionManager.d.ts +106 -0
  23. package/dist/ConnectionManager.d.ts.map +1 -0
  24. package/dist/ConnectionManager.js +228 -0
  25. package/dist/ConnectionManager.js.map +1 -0
  26. package/dist/ModelQuery.d.ts +256 -43
  27. package/dist/ModelQuery.d.ts.map +1 -1
  28. package/dist/ModelQuery.js +899 -231
  29. package/dist/ModelQuery.js.map +1 -1
  30. package/dist/Transaction.d.ts +54 -1
  31. package/dist/Transaction.d.ts.map +1 -1
  32. package/dist/Transaction.js +121 -59
  33. package/dist/Transaction.js.map +1 -1
  34. package/dist/adapters/NapiDbAdapter.d.ts +11 -0
  35. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  36. package/dist/adapters/NapiDbAdapter.js +35 -7
  37. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  38. package/dist/console/contract.d.ts +62 -0
  39. package/dist/console/contract.d.ts.map +1 -0
  40. package/dist/console/contract.js +38 -0
  41. package/dist/console/contract.js.map +1 -0
  42. package/dist/console/factoryCommands.d.ts +23 -0
  43. package/dist/console/factoryCommands.d.ts.map +1 -0
  44. package/dist/console/factoryCommands.js +62 -0
  45. package/dist/console/factoryCommands.js.map +1 -0
  46. package/dist/console/migrationCommands.d.ts +38 -8
  47. package/dist/console/migrationCommands.d.ts.map +1 -1
  48. package/dist/console/migrationCommands.js +157 -50
  49. package/dist/console/migrationCommands.js.map +1 -1
  50. package/dist/console/schemaCheckCommand.d.ts +11 -17
  51. package/dist/console/schemaCheckCommand.d.ts.map +1 -1
  52. package/dist/console/schemaCheckCommand.js +21 -15
  53. package/dist/console/schemaCheckCommand.js.map +1 -1
  54. package/dist/console/schemaDumpCommand.d.ts +30 -0
  55. package/dist/console/schemaDumpCommand.d.ts.map +1 -0
  56. package/dist/console/schemaDumpCommand.js +69 -0
  57. package/dist/console/schemaDumpCommand.js.map +1 -0
  58. package/dist/console/schemaGenerateCommand.d.ts +100 -0
  59. package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
  60. package/dist/console/schemaGenerateCommand.js +246 -0
  61. package/dist/console/schemaGenerateCommand.js.map +1 -0
  62. package/dist/console/seederCommands.d.ts +46 -0
  63. package/dist/console/seederCommands.d.ts.map +1 -0
  64. package/dist/console/seederCommands.js +136 -0
  65. package/dist/console/seederCommands.js.map +1 -0
  66. package/dist/decorators/entity.d.ts +13 -2
  67. package/dist/decorators/entity.d.ts.map +1 -1
  68. package/dist/decorators/entity.js +10 -0
  69. package/dist/decorators/entity.js.map +1 -1
  70. package/dist/decorators/hooks.d.ts +3 -3
  71. package/dist/decorators/hooks.d.ts.map +1 -1
  72. package/dist/decorators/hooks.js.map +1 -1
  73. package/dist/events.d.ts +5 -0
  74. package/dist/events.d.ts.map +1 -1
  75. package/dist/events.js.map +1 -1
  76. package/dist/factories.d.ts +8 -0
  77. package/dist/factories.d.ts.map +1 -0
  78. package/dist/factories.js +8 -0
  79. package/dist/factories.js.map +1 -0
  80. package/dist/index.d.ts +11 -5
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +12 -4
  83. package/dist/index.js.map +1 -1
  84. package/dist/lucid-schema.d.ts +15 -0
  85. package/dist/lucid-schema.d.ts.map +1 -0
  86. package/dist/lucid-schema.js +16 -0
  87. package/dist/lucid-schema.js.map +1 -0
  88. package/dist/orm.d.ts +14 -0
  89. package/dist/orm.d.ts.map +1 -0
  90. package/dist/orm.js +14 -0
  91. package/dist/orm.js.map +1 -0
  92. package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
  93. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
  94. package/dist/query/DatabaseQueryBuilder.js +1798 -0
  95. package/dist/query/DatabaseQueryBuilder.js.map +1 -0
  96. package/dist/query/DmlBuilder.d.ts +62 -0
  97. package/dist/query/DmlBuilder.d.ts.map +1 -0
  98. package/dist/query/DmlBuilder.js +84 -0
  99. package/dist/query/DmlBuilder.js.map +1 -0
  100. package/dist/query/QueryBuilder.d.ts +5 -0
  101. package/dist/query/QueryBuilder.d.ts.map +1 -1
  102. package/dist/query/QueryBuilder.js +7 -0
  103. package/dist/query/QueryBuilder.js.map +1 -1
  104. package/dist/query/RawQueryBuilder.d.ts +47 -0
  105. package/dist/query/RawQueryBuilder.d.ts.map +1 -0
  106. package/dist/query/RawQueryBuilder.js +138 -0
  107. package/dist/query/RawQueryBuilder.js.map +1 -0
  108. package/dist/query/interpolate.d.ts +45 -0
  109. package/dist/query/interpolate.d.ts.map +1 -0
  110. package/dist/query/interpolate.js +51 -0
  111. package/dist/query/interpolate.js.map +1 -0
  112. package/dist/query/native.d.ts +8 -0
  113. package/dist/query/native.d.ts.map +1 -1
  114. package/dist/query/native.js +14 -0
  115. package/dist/query/native.js.map +1 -1
  116. package/dist/query/operators.d.ts +4 -0
  117. package/dist/query/operators.d.ts.map +1 -0
  118. package/dist/query/operators.js +27 -0
  119. package/dist/query/operators.js.map +1 -0
  120. package/dist/schema/Migration.d.ts +31 -7
  121. package/dist/schema/Migration.d.ts.map +1 -1
  122. package/dist/schema/Migration.js +34 -7
  123. package/dist/schema/Migration.js.map +1 -1
  124. package/dist/schema/MigrationRunner.d.ts +56 -6
  125. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  126. package/dist/schema/MigrationRunner.js +382 -112
  127. package/dist/schema/MigrationRunner.js.map +1 -1
  128. package/dist/schema/Schema.d.ts +59 -2
  129. package/dist/schema/Schema.d.ts.map +1 -1
  130. package/dist/schema/Schema.js +143 -9
  131. package/dist/schema/Schema.js.map +1 -1
  132. package/dist/schema/SchemaDumper.d.ts +121 -0
  133. package/dist/schema/SchemaDumper.d.ts.map +1 -0
  134. package/dist/schema/SchemaDumper.js +365 -0
  135. package/dist/schema/SchemaDumper.js.map +1 -0
  136. package/dist/schema/Seeder.d.ts +13 -0
  137. package/dist/schema/Seeder.d.ts.map +1 -1
  138. package/dist/schema/Seeder.js +28 -5
  139. package/dist/schema/Seeder.js.map +1 -1
  140. package/dist/schema/TableBuilder.d.ts +27 -8
  141. package/dist/schema/TableBuilder.d.ts.map +1 -1
  142. package/dist/schema/TableBuilder.js +50 -12
  143. package/dist/schema/TableBuilder.js.map +1 -1
  144. package/dist/schema/catalog.d.ts +25 -1
  145. package/dist/schema/catalog.d.ts.map +1 -1
  146. package/dist/schema/catalog.js +69 -22
  147. package/dist/schema/catalog.js.map +1 -1
  148. package/dist/schema/introspect.d.ts +1 -1
  149. package/dist/schema/introspect.d.ts.map +1 -1
  150. package/dist/schema/introspect.js +26 -11
  151. package/dist/schema/introspect.js.map +1 -1
  152. package/dist/seeders.d.ts +7 -0
  153. package/dist/seeders.d.ts.map +1 -0
  154. package/dist/seeders.js +7 -0
  155. package/dist/seeders.js.map +1 -0
  156. package/dist/services/db.d.ts +120 -9
  157. package/dist/services/db.d.ts.map +1 -1
  158. package/dist/services/db.js +182 -26
  159. package/dist/services/db.js.map +1 -1
  160. package/dist/testing/DatabaseCleanup.d.ts +29 -6
  161. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  162. package/dist/testing/DatabaseCleanup.js +44 -17
  163. package/dist/testing/DatabaseCleanup.js.map +1 -1
  164. package/dist/testing/DbAssertions.d.ts +48 -0
  165. package/dist/testing/DbAssertions.d.ts.map +1 -0
  166. package/dist/testing/DbAssertions.js +80 -0
  167. package/dist/testing/DbAssertions.js.map +1 -0
  168. package/dist/testing/Factory.d.ts +111 -25
  169. package/dist/testing/Factory.d.ts.map +1 -1
  170. package/dist/testing/Factory.js +331 -56
  171. package/dist/testing/Factory.js.map +1 -1
  172. package/dist/testing/TestUtils.d.ts +49 -0
  173. package/dist/testing/TestUtils.d.ts.map +1 -0
  174. package/dist/testing/TestUtils.js +79 -0
  175. package/dist/testing/TestUtils.js.map +1 -0
  176. package/dist/testing/index.d.ts +3 -1
  177. package/dist/testing/index.d.ts.map +1 -1
  178. package/dist/testing/index.js +3 -1
  179. package/dist/testing/index.js.map +1 -1
  180. package/dist/types/relations.d.ts +31 -0
  181. package/dist/types/relations.d.ts.map +1 -0
  182. package/dist/types/relations.js +20 -0
  183. package/dist/types/relations.js.map +1 -0
  184. package/index.darwin-arm64.node +0 -0
  185. package/index.darwin-x64.node +0 -0
  186. package/index.linux-arm64-gnu.node +0 -0
  187. package/index.linux-x64-gnu.node +0 -0
  188. package/index.win32-x64-msvc.node +0 -0
  189. package/package.json +30 -2
  190. package/src/AtlasProvider.ts +297 -51
  191. package/src/BaseEntity.ts +29 -0
  192. package/src/BaseModel.ts +174 -37
  193. package/src/BaseRepository.ts +136 -17
  194. package/src/ConnectionManager.ts +298 -0
  195. package/src/ModelQuery.ts +1309 -260
  196. package/src/Transaction.ts +194 -60
  197. package/src/adapters/NapiDbAdapter.ts +72 -14
  198. package/src/console/contract.ts +96 -0
  199. package/src/console/factoryCommands.ts +73 -0
  200. package/src/console/migrationCommands.ts +253 -63
  201. package/src/console/schemaCheckCommand.ts +26 -22
  202. package/src/console/schemaDumpCommand.ts +96 -0
  203. package/src/console/schemaGenerateCommand.ts +380 -0
  204. package/src/console/seederCommands.ts +174 -0
  205. package/src/decorators/entity.ts +18 -2
  206. package/src/decorators/hooks.ts +5 -3
  207. package/src/events.ts +5 -0
  208. package/src/factories.ts +10 -0
  209. package/src/index.ts +50 -3
  210. package/src/lucid-schema.ts +22 -0
  211. package/src/orm.ts +13 -0
  212. package/src/query/DatabaseQueryBuilder.ts +2724 -0
  213. package/src/query/DmlBuilder.ts +131 -0
  214. package/src/query/QueryBuilder.ts +8 -0
  215. package/src/query/RawQueryBuilder.ts +173 -0
  216. package/src/query/interpolate.ts +73 -0
  217. package/src/query/native.ts +29 -0
  218. package/src/query/operators.ts +27 -0
  219. package/src/schema/Migration.ts +36 -7
  220. package/src/schema/MigrationRunner.ts +483 -119
  221. package/src/schema/Schema.ts +168 -19
  222. package/src/schema/SchemaDumper.ts +518 -0
  223. package/src/schema/Seeder.ts +47 -10
  224. package/src/schema/TableBuilder.ts +48 -11
  225. package/src/schema/catalog.ts +94 -24
  226. package/src/schema/introspect.ts +26 -9
  227. package/src/seeders.ts +16 -0
  228. package/src/services/db.ts +343 -31
  229. package/src/testing/DatabaseCleanup.ts +73 -21
  230. package/src/testing/DbAssertions.ts +127 -0
  231. package/src/testing/Factory.ts +559 -79
  232. package/src/testing/TestUtils.ts +123 -0
  233. package/src/testing/index.ts +12 -1
  234. package/src/types/relations.ts +43 -0
@@ -5,8 +5,14 @@
5
5
  */
6
6
 
7
7
  import { randomBytes } from "node:crypto";
8
+ import { EventEmitter } from "node:events";
8
9
  import type { TransactionOptions } from "./adapters/NapiDbAdapter.js";
9
10
  import type { DatabaseConnection } from "./BaseRepository.js";
11
+ import {
12
+ makeTransactionQueryBuilders,
13
+ type TransactionQueryBuilders,
14
+ } from "./query/DatabaseQueryBuilder.js";
15
+ import { getAtlasDialect } from "./query/native.js";
10
16
  import {
11
17
  isTransactionClient,
12
18
  TRANSACTION_BRAND,
@@ -15,7 +21,9 @@ import {
15
21
  /** A post-commit / post-rollback side effect (Lucid `trx.after(...)`). */
16
22
  export type AfterHook = () => void | Promise<void>;
17
23
 
18
- export interface TransactionClient extends DatabaseConnection {
24
+ export interface TransactionClient
25
+ extends Omit<DatabaseConnection, "query">,
26
+ TransactionQueryBuilders {
19
27
  commit(): Promise<void>;
20
28
  rollback(): Promise<void>;
21
29
  /**
@@ -27,10 +35,134 @@ export interface TransactionClient extends DatabaseConnection {
27
35
  * succeed).
28
36
  */
29
37
  after(event: "commit" | "rollback", cb: AfterHook): void;
38
+ /**
39
+ * Subscribe to the client's `commit`/`rollback` EventEmitter (Lucid — the trx
40
+ * client IS a Node EventEmitter). These are SYNCHRONOUS, pre-hook notifications
41
+ * fired the moment this client commits/rolls back — DISTINCT from {@link after},
42
+ * which are durable post-commit hooks that forward to the root when nested.
43
+ */
44
+ on(event: "commit" | "rollback", cb: AfterHook): this;
45
+ /** One-shot {@link on} (Lucid/Node EventEmitter `once`). */
46
+ once(event: "commit" | "rollback", cb: AfterHook): this;
47
+ /** Remove a listener added via {@link on}/{@link once} (Node EventEmitter `off`). */
48
+ off(event: "commit" | "rollback", cb: AfterHook): this;
49
+ /**
50
+ * Open a nested transaction (Lucid `const sp = await trx.transaction()`),
51
+ * implemented as a SAVEPOINT on the same pinned connection. Managed when given
52
+ * a callback (auto RELEASE / ROLLBACK TO), manual otherwise. Works on all three
53
+ * dialects — MySQL's SAVEPOINT statements (which can't be prepared) route
54
+ * through the text protocol in the napi layer.
55
+ */
56
+ transaction(): Promise<TransactionClient>;
57
+ transaction(options: TransactionOptions): Promise<TransactionClient>;
58
+ transaction<T>(
59
+ callback: (trx: TransactionClient) => Promise<T> | T,
60
+ options?: TransactionOptions,
61
+ ): Promise<T>;
30
62
  readonly isNested: boolean;
31
63
  readonly [TRANSACTION_BRAND]: true;
32
64
  }
33
65
 
66
+ /**
67
+ * Build the overloaded `transaction()` method for a client. Managed when given a
68
+ * callback (auto RELEASE / ROLLBACK TO), manual otherwise — both open a SAVEPOINT
69
+ * on `getParent()`. The impl signature is deliberately broader than the overloads.
70
+ */
71
+ export function makeNestedTransactionFn(getParent: () => TransactionClient) {
72
+ function tx(): Promise<TransactionClient>;
73
+ function tx(options: TransactionOptions): Promise<TransactionClient>;
74
+ function tx<T>(
75
+ callback: (trx: TransactionClient) => Promise<T> | T,
76
+ options?: TransactionOptions,
77
+ ): Promise<T>;
78
+ function tx(
79
+ arg1?:
80
+ | TransactionOptions
81
+ | ((trx: TransactionClient) => Promise<unknown> | unknown),
82
+ arg2?: TransactionOptions,
83
+ ): Promise<unknown> {
84
+ const parent = getParent();
85
+ const callback = typeof arg1 === "function" ? arg1 : undefined;
86
+ const options = typeof arg1 === "function" ? arg2 : arg1;
87
+ return callback
88
+ ? runManagedSavepoint(parent, callback, options)
89
+ : openSavepoint(parent);
90
+ }
91
+ return tx;
92
+ }
93
+
94
+ /** Managed nested savepoint: open, run the callback, RELEASE on success / ROLLBACK TO on throw. */
95
+ async function runManagedSavepoint<T>(
96
+ parent: TransactionClient,
97
+ callback: (trx: TransactionClient) => Promise<T> | T,
98
+ _options?: TransactionOptions,
99
+ ): Promise<T> {
100
+ const sp = await openSavepoint(parent);
101
+ try {
102
+ const result = await callback(sp);
103
+ await sp.commit();
104
+ return result;
105
+ } catch (err) {
106
+ try {
107
+ await sp.rollback();
108
+ } catch {
109
+ /* best-effort */
110
+ }
111
+ throw err;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Open a SAVEPOINT-backed nested transaction client on `parent`. Shared by the
117
+ * standalone {@link transaction} helper, `trx.transaction()`, and the napi pinned
118
+ * client. Commit RELEASEs the savepoint and forwards hooks to the parent (a
119
+ * nested commit isn't durable until the root commits); rollback does ROLLBACK TO
120
+ * + RELEASE and fires the local rollback hooks.
121
+ */
122
+ export async function openSavepoint(
123
+ parent: TransactionClient,
124
+ ): Promise<TransactionClient> {
125
+ const name = `sp_${randomBytes(6).toString("hex")}`;
126
+ await parent.execute(`SAVEPOINT ${name}`, []);
127
+ const commitHooks: AfterHook[] = [];
128
+ const rollbackHooks: AfterHook[] = [];
129
+ const evt = makeTrxEvents();
130
+ const base = {
131
+ execute: parent.execute.bind(parent),
132
+ query: parent.query.bind(parent),
133
+ async commit(): Promise<void> {
134
+ await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
135
+ evt.emit("commit"); // synchronous EventEmitter notification (this savepoint)
136
+ for (const hook of commitHooks) parent.after("commit", hook);
137
+ for (const hook of rollbackHooks) parent.after("rollback", hook);
138
+ },
139
+ async rollback(): Promise<void> {
140
+ await parent.execute(`ROLLBACK TO SAVEPOINT ${name}`, []);
141
+ try {
142
+ await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
143
+ } catch {
144
+ /* best-effort — the savepoint is already logically unwound */
145
+ }
146
+ evt.emit("rollback");
147
+ await runAfterHooks(rollbackHooks);
148
+ },
149
+ after(event: "commit" | "rollback", cb: AfterHook): void {
150
+ (event === "commit" ? commitHooks : rollbackHooks).push(cb);
151
+ },
152
+ on: evt.on,
153
+ once: evt.once,
154
+ off: evt.off,
155
+ isNested: true,
156
+ [TRANSACTION_BRAND]: true as const,
157
+ };
158
+ const trx: TransactionClient = Object.assign(
159
+ base,
160
+ makeTransactionQueryBuilders(base, getAtlasDialect()),
161
+ { transaction: makeNestedTransactionFn(() => trx) },
162
+ );
163
+ return trx;
164
+ }
165
+
34
166
  /**
35
167
  * Run every registered after-hook, swallowing errors — a post-commit side
36
168
  * effect must never surface a failure on a transaction the caller already saw
@@ -46,68 +178,59 @@ export async function runAfterHooks(hooks: AfterHook[]): Promise<void> {
46
178
  }
47
179
  }
48
180
 
181
+ /**
182
+ * The `on`/`once`/`off` EventEmitter surface for a transaction client, plus an
183
+ * internal `emit` the client fires at commit/rollback. Distinct from `after`
184
+ * hooks: these are synchronous, per-client, pre-hook notifications (Lucid — the
185
+ * trx client is a Node EventEmitter). `on`/`once`/`off` return the client for
186
+ * chaining via their `this` binding.
187
+ */
188
+ export function makeTrxEvents(): {
189
+ on(
190
+ this: TransactionClient,
191
+ e: "commit" | "rollback",
192
+ cb: AfterHook,
193
+ ): TransactionClient;
194
+ once(
195
+ this: TransactionClient,
196
+ e: "commit" | "rollback",
197
+ cb: AfterHook,
198
+ ): TransactionClient;
199
+ off(
200
+ this: TransactionClient,
201
+ e: "commit" | "rollback",
202
+ cb: AfterHook,
203
+ ): TransactionClient;
204
+ emit(e: "commit" | "rollback"): void;
205
+ } {
206
+ const ee = new EventEmitter();
207
+ return {
208
+ on(e, cb) {
209
+ ee.on(e, cb);
210
+ return this;
211
+ },
212
+ once(e, cb) {
213
+ ee.once(e, cb);
214
+ return this;
215
+ },
216
+ off(e, cb) {
217
+ ee.off(e, cb);
218
+ return this;
219
+ },
220
+ emit(e) {
221
+ ee.emit(e);
222
+ },
223
+ };
224
+ }
225
+
49
226
  export async function transaction<T>(
50
227
  db: DatabaseConnection,
51
228
  callback: (trx: TransactionClient) => Promise<T> | T,
52
229
  options?: TransactionOptions,
53
230
  ): Promise<T> {
231
+ // Nested (the caller passed a live trx) → a SAVEPOINT-backed managed savepoint.
54
232
  if (isTransactionClient(db)) {
55
- const parent = db;
56
- const name = `sp_${randomBytes(6).toString("hex")}`;
57
- await parent.execute(`SAVEPOINT ${name}`, []);
58
-
59
- const commitHooks: AfterHook[] = [];
60
- const rollbackHooks: AfterHook[] = [];
61
-
62
- const trx: TransactionClient = {
63
- execute: parent.execute.bind(parent),
64
- query: parent.query.bind(parent),
65
- async commit() {
66
- await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
67
- // A nested commit is NOT durable until the root commits — forward BOTH
68
- // hook sets to the parent. Commit hooks fire on the real (root) commit
69
- // and drop if the outer later rolls back. Rollback hooks must ALSO
70
- // forward: this savepoint's released work is folded into the parent, so
71
- // an outer rollback undoes it too — dropping them here would strand any
72
- // in-memory restoration a nested caller registered on `after('rollback')`
73
- // (the work IS rolled back, just by the parent). The parent fires exactly
74
- // one of its two hook sets, so no double-run.
75
- for (const hook of commitHooks) parent.after("commit", hook);
76
- for (const hook of rollbackHooks) parent.after("rollback", hook);
77
- },
78
- async rollback() {
79
- await parent.execute(`ROLLBACK TO SAVEPOINT ${name}`, []);
80
- // ROLLBACK TO leaves the savepoint ESTABLISHED — release it so it doesn't
81
- // stay stacked on the connection through a long outer transaction with
82
- // many nested failures. Best-effort: the rollback already unwound the
83
- // work, so a RELEASE failure must not surface. (PG/MySQL/SQLite all
84
- // accept RELEASE after ROLLBACK TO.)
85
- try {
86
- await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
87
- } catch {
88
- /* best-effort — the savepoint is already logically unwound */
89
- }
90
- await runAfterHooks(rollbackHooks);
91
- },
92
- after(event, cb) {
93
- (event === "commit" ? commitHooks : rollbackHooks).push(cb);
94
- },
95
- isNested: true,
96
- [TRANSACTION_BRAND]: true,
97
- };
98
-
99
- try {
100
- const result = await callback(trx);
101
- await trx.commit();
102
- return result;
103
- } catch (err) {
104
- try {
105
- await trx.rollback();
106
- } catch {
107
- /* best-effort */
108
- }
109
- throw err;
110
- }
233
+ return runManagedSavepoint(db, callback, options);
111
234
  }
112
235
 
113
236
  // Pinned interactive transaction (napi-backed): db.transaction() acquires ONE
@@ -125,24 +248,35 @@ export async function transaction<T>(
125
248
 
126
249
  const commitHooks: AfterHook[] = [];
127
250
  const rollbackHooks: AfterHook[] = [];
251
+ const evt = makeTrxEvents();
128
252
 
129
- const trx: TransactionClient = {
253
+ const base = {
130
254
  execute: db.execute.bind(db),
131
255
  query: db.query.bind(db),
132
256
  async commit() {
133
257
  await db.execute("COMMIT", []);
258
+ evt.emit("commit"); // synchronous EventEmitter notification
134
259
  await runAfterHooks(commitHooks);
135
260
  },
136
261
  async rollback() {
137
262
  await db.execute("ROLLBACK", []);
263
+ evt.emit("rollback");
138
264
  await runAfterHooks(rollbackHooks);
139
265
  },
140
- after(event, cb) {
266
+ after(event: "commit" | "rollback", cb: AfterHook) {
141
267
  (event === "commit" ? commitHooks : rollbackHooks).push(cb);
142
268
  },
269
+ on: evt.on,
270
+ once: evt.once,
271
+ off: evt.off,
143
272
  isNested: false,
144
- [TRANSACTION_BRAND]: true,
273
+ [TRANSACTION_BRAND]: true as const,
145
274
  };
275
+ const trx: TransactionClient = Object.assign(
276
+ base,
277
+ makeTransactionQueryBuilders(base, getAtlasDialect()),
278
+ { transaction: makeNestedTransactionFn(() => trx) },
279
+ );
146
280
 
147
281
  try {
148
282
  const result = await callback(trx);
@@ -3,8 +3,11 @@
3
3
  */
4
4
 
5
5
  import { emitDbQuery, hasDbQueryListeners } from "../events.js";
6
+ import { makeTransactionQueryBuilders } from "../query/DatabaseQueryBuilder.js";
6
7
  import {
7
8
  type AfterHook,
9
+ makeNestedTransactionFn,
10
+ makeTrxEvents,
8
11
  runAfterHooks,
9
12
  type TransactionClient,
10
13
  } from "../Transaction.js";
@@ -86,6 +89,17 @@ export interface QueryMeta {
86
89
  * `debug: false` — this is what `ModelQuery.debug()` sets.
87
90
  */
88
91
  debug?: boolean;
92
+ /**
93
+ * Arbitrary metadata attached to the `db:query` event (Adonis Lucid
94
+ * `reporterData`) — request id, user id, feature flag, … — for listeners.
95
+ */
96
+ reporterData?: Record<string, unknown>;
97
+ /**
98
+ * Server-side statement timeout in ms (Lucid `timeout(ms, { cancel: true })`).
99
+ * Routes to the driver's `statement_timeout` (Postgres) / `MAX_EXECUTION_TIME`
100
+ * (MySQL SELECT) so the server aborts the query — not just the client race.
101
+ */
102
+ serverTimeoutMs?: number;
89
103
  }
90
104
 
91
105
  /** Per-connection observability settings (Lucid's `debug` connection option). */
@@ -149,7 +163,7 @@ export interface AsyncDatabaseConnection {
149
163
  /** Shape of the NAPI ReamTransaction handle returned by `ReamDatabase.begin()`. */
150
164
  interface NapiReamTransaction {
151
165
  query(sql: string, paramsJson: string): Promise<string>;
152
- execute(sql: string, paramsJson: string): Promise<number>;
166
+ execute(sql: string, paramsJson: string): Promise<string>;
153
167
  commit(): Promise<void>;
154
168
  rollback(): Promise<void>;
155
169
  }
@@ -157,7 +171,19 @@ interface NapiReamTransaction {
157
171
  /** Shape of the NAPI ReamDatabase class. */
158
172
  interface NapiReamDatabase {
159
173
  query(sql: string, paramsJson: string): Promise<string>;
160
- execute(sql: string, paramsJson: string): Promise<number>;
174
+ execute(sql: string, paramsJson: string): Promise<string>;
175
+ /** query with a server-side statement timeout (Lucid `timeout(ms,{cancel:true})`). */
176
+ queryTimed(
177
+ sql: string,
178
+ paramsJson: string,
179
+ timeoutMs: number,
180
+ ): Promise<string>;
181
+ /** execute with a server-side statement timeout (Postgres). */
182
+ executeTimed(
183
+ sql: string,
184
+ paramsJson: string,
185
+ timeoutMs: number,
186
+ ): Promise<string>;
161
187
  runInTransaction(batchJson: string): Promise<number>;
162
188
  begin(isolationLevel?: string): Promise<NapiReamTransaction>;
163
189
  close(): Promise<void>;
@@ -267,16 +293,22 @@ export async function createNapiConnection(
267
293
  // COMMIT / ROLLBACK is durable (Lucid `trx.after(...)`), errors swallowed.
268
294
  const commitHooks: AfterHook[] = [];
269
295
  const rollbackHooks: AfterHook[] = [];
270
- return {
296
+ const evt = makeTrxEvents();
297
+ const base = {
271
298
  async execute(
272
299
  sql: string,
273
300
  params: unknown[] = [],
274
- ): Promise<{ rowsAffected: number }> {
275
- const affected = await native.execute(
301
+ ): Promise<{ rowsAffected: number; lastInsertId?: number }> {
302
+ const json = await native.execute(
276
303
  sql,
277
304
  JSON.stringify(params, napiReplacer),
278
305
  );
279
- return { rowsAffected: affected };
306
+ const r = JSON.parse(json);
307
+ return {
308
+ rowsAffected: Number(r.rows_affected),
309
+ lastInsertId:
310
+ r.last_insert_id == null ? undefined : Number(r.last_insert_id),
311
+ };
280
312
  },
281
313
  async query<T = Record<string, unknown>>(
282
314
  sql: string,
@@ -290,18 +322,32 @@ export async function createNapiConnection(
290
322
  },
291
323
  async commit(): Promise<void> {
292
324
  await native.commit();
325
+ evt.emit("commit"); // synchronous EventEmitter notification (Lucid trx.on)
293
326
  await runAfterHooks(commitHooks);
294
327
  },
295
328
  async rollback(): Promise<void> {
296
329
  await native.rollback();
330
+ evt.emit("rollback");
297
331
  await runAfterHooks(rollbackHooks);
298
332
  },
299
333
  after(event: "commit" | "rollback", cb: AfterHook): void {
300
334
  (event === "commit" ? commitHooks : rollbackHooks).push(cb);
301
335
  },
336
+ on: evt.on,
337
+ once: evt.once,
338
+ off: evt.off,
302
339
  isNested: false,
303
- [TRANSACTION_BRAND]: true,
340
+ [TRANSACTION_BRAND]: true as const,
304
341
  };
342
+ // Lucid: the transaction client is also a query-builder entry point
343
+ // (trx.table()/from()/insertQuery()), routed through THIS pinned connection.
344
+ // `trx.transaction()` opens a SAVEPOINT-backed nested client on this one.
345
+ const client: TransactionClient = Object.assign(
346
+ base,
347
+ makeTransactionQueryBuilders(base, dialect),
348
+ { transaction: makeNestedTransactionFn(() => client) },
349
+ );
350
+ return client;
305
351
  }
306
352
 
307
353
  // Lucid-compatible `transaction`: managed when given a callback (auto
@@ -362,6 +408,7 @@ export async function createNapiConnection(
362
408
  method: meta?.method,
363
409
  ddl: meta?.ddl,
364
410
  inTransaction: false,
411
+ reporterData: meta?.reporterData,
365
412
  });
366
413
  return result;
367
414
  } catch (error) {
@@ -375,6 +422,7 @@ export async function createNapiConnection(
375
422
  ddl: meta?.ddl,
376
423
  inTransaction: false,
377
424
  error: error instanceof Error ? error : new Error(String(error)),
425
+ reporterData: meta?.reporterData,
378
426
  });
379
427
  throw error;
380
428
  }
@@ -389,7 +437,11 @@ export async function createNapiConnection(
389
437
  meta?: QueryMeta,
390
438
  ): Promise<T[]> {
391
439
  return observed(sql, params, meta, async () => {
392
- const json = await db.query(sql, JSON.stringify(params, napiReplacer));
440
+ const paramsJson = JSON.stringify(params, napiReplacer);
441
+ const json =
442
+ meta?.serverTimeoutMs != null
443
+ ? await db.queryTimed(sql, paramsJson, meta.serverTimeoutMs)
444
+ : await db.query(sql, paramsJson);
393
445
  return JSON.parse(json, napiReviver) as T[];
394
446
  });
395
447
  },
@@ -398,13 +450,19 @@ export async function createNapiConnection(
398
450
  sql: string,
399
451
  params: unknown[] = [],
400
452
  meta?: QueryMeta,
401
- ): Promise<{ rowsAffected: number }> {
453
+ ): Promise<{ rowsAffected: number; lastInsertId?: number }> {
402
454
  return observed(sql, params, meta, async () => {
403
- const affected = await db.execute(
404
- sql,
405
- JSON.stringify(params, napiReplacer),
406
- );
407
- return { rowsAffected: affected };
455
+ const paramsJson = JSON.stringify(params, napiReplacer);
456
+ const json =
457
+ meta?.serverTimeoutMs != null
458
+ ? await db.executeTimed(sql, paramsJson, meta.serverTimeoutMs)
459
+ : await db.execute(sql, paramsJson);
460
+ const r = JSON.parse(json);
461
+ return {
462
+ rowsAffected: Number(r.rows_affected),
463
+ lastInsertId:
464
+ r.last_insert_id == null ? undefined : Number(r.last_insert_id),
465
+ };
408
466
  });
409
467
  },
410
468
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The console command contract, declared locally.
3
+ *
4
+ * Atlas stays framework-agnostic: it must not import `@c9up/ream`, so it
5
+ * describes the shape Ream's console kernel dispatches against rather than
6
+ * importing it. Same reasoning as the previous `AtlasCommand` interface — only
7
+ * the shape changed, from a plain object to a class carrying its inputs as
8
+ * metadata.
9
+ *
10
+ * Ream's decorators (`@args` / `@flags`) live in the framework, so the helpers
11
+ * below build the same metadata without them.
12
+ */
13
+
14
+ export interface CommandOptions {
15
+ /** Boot the application before `run()`. Off by default. */
16
+ startApp?: boolean;
17
+ staysAlive?: boolean;
18
+ allowUnknownFlags?: boolean;
19
+ }
20
+
21
+ export interface ArgumentMetaData {
22
+ type: "string" | "spread";
23
+ propertyName: string;
24
+ argumentName: string;
25
+ description?: string;
26
+ required: boolean;
27
+ default?: string | string[];
28
+ }
29
+
30
+ export interface FlagMetaData {
31
+ type: "string" | "boolean" | "number" | "array";
32
+ propertyName: string;
33
+ flagName: string;
34
+ description?: string;
35
+ alias: string[];
36
+ default?: string | string[] | number | boolean;
37
+ required: boolean;
38
+ }
39
+
40
+ /** The static side the kernel reads. */
41
+ export interface AtlasCommandClass {
42
+ new (): { run(): Promise<void> | void };
43
+ commandName: string;
44
+ description: string;
45
+ options?: CommandOptions;
46
+ args?: readonly ArgumentMetaData[];
47
+ flags?: readonly FlagMetaData[];
48
+ help?: string | string[];
49
+ }
50
+
51
+ /** `startServer` → `start-server`, matching the framework's decorators. */
52
+ function dashCase(value: string): string {
53
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
54
+ }
55
+
56
+ export function flag(
57
+ propertyName: string,
58
+ type: FlagMetaData["type"],
59
+ options: {
60
+ flagName?: string;
61
+ description?: string;
62
+ alias?: string[];
63
+ default?: FlagMetaData["default"];
64
+ required?: boolean;
65
+ } = {},
66
+ ): FlagMetaData {
67
+ return {
68
+ type,
69
+ propertyName,
70
+ flagName: options.flagName ?? dashCase(propertyName),
71
+ description: options.description,
72
+ alias: options.alias ?? [],
73
+ default: options.default,
74
+ required: options.required ?? false,
75
+ };
76
+ }
77
+
78
+ export function argument(
79
+ propertyName: string,
80
+ options: {
81
+ type?: ArgumentMetaData["type"];
82
+ argumentName?: string;
83
+ description?: string;
84
+ required?: boolean;
85
+ default?: ArgumentMetaData["default"];
86
+ } = {},
87
+ ): ArgumentMetaData {
88
+ return {
89
+ type: options.type ?? "string",
90
+ propertyName,
91
+ argumentName: options.argumentName ?? dashCase(propertyName),
92
+ description: options.description,
93
+ required: options.required ?? options.default === undefined,
94
+ default: options.default,
95
+ };
96
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Factory console command — `make:factory` (Adonis Lucid). Scaffolds a model
3
+ * factory file. Same shape as the migration/seeder commands: a plain
4
+ * `{ name, description, run }` object registered in `reamrc.commands`.
5
+ *
6
+ * @example
7
+ * // commands/make-factory.ts
8
+ * import { makeFactoryCommand } from '@c9up/atlas'
9
+ * export default makeFactoryCommand({ factoriesDir: 'database/factories' })
10
+ * // run: <console-entry> make:factory User
11
+ */
12
+
13
+ import * as fsp from "node:fs/promises";
14
+ import * as path from "node:path";
15
+ import { camelToSnake } from "../utils/casing.js";
16
+ import { assertSafeName } from "../utils/safePath.js";
17
+ import { type AtlasCommandClass, argument } from "./contract.js";
18
+
19
+ export interface FactoryCommandOptions {
20
+ /** Directory the factory files are scaffolded into. */
21
+ factoriesDir: string;
22
+ }
23
+
24
+ /** Scaffold body for a fresh factory (`make:factory <Model>`). */
25
+ function factoryStub(model: string): string {
26
+ return `import { factory } from '@c9up/atlas'
27
+ import ${model} from '#models/${model.toLowerCase()}'
28
+
29
+ export const ${model}Factory = factory(${model}, ({ faker }) => ({
30
+ // email: faker.internet.email(),
31
+ }))
32
+ `;
33
+ }
34
+
35
+ /**
36
+ * `make:factory <Model>` — scaffold `<Model>Factory.ts` in `factoriesDir`. The
37
+ * name is validated (no path separators / traversal) and written with `wx` so an
38
+ * existing factory is never clobbered.
39
+ */
40
+ export function makeFactoryCommand(
41
+ options: FactoryCommandOptions,
42
+ ): AtlasCommandClass {
43
+ return class MakeFactory {
44
+ static commandName = "make:factory";
45
+ static description = "Scaffold a new model factory file";
46
+ // Pure filesystem work: no reason to boot the app and open a connection.
47
+ static options = { startApp: false };
48
+ static args = [
49
+ argument("model", { description: "Model the factory is built for" }),
50
+ ];
51
+
52
+ declare model: string;
53
+
54
+ async run(): Promise<void> {
55
+ // A missing model is caught by the kernel before `run()` — it reports
56
+ // the required argument by name, so there is no usage check here.
57
+ // Lucid convention: `make:factory User` → `user_factory.ts`
58
+ // (`BlogPost` → `blog_post_factory.ts`).
59
+ const fileName = `${camelToSnake(this.model)}_factory.ts`;
60
+ try {
61
+ assertSafeName(fileName, "FACTORY_INVALID", "factory");
62
+ } catch {
63
+ console.error(`[atlas] invalid factory name: ${this.model}`);
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ const filePath = path.join(options.factoriesDir, fileName);
68
+ await fsp.mkdir(options.factoriesDir, { recursive: true });
69
+ await fsp.writeFile(filePath, factoryStub(this.model), { flag: "wx" });
70
+ console.log(`Created ${filePath}`);
71
+ }
72
+ };
73
+ }