@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
@@ -7,6 +7,7 @@
7
7
  * they are framework introspection, not user queries, so they stay raw SQL.
8
8
  */
9
9
 
10
+ import { AtlasError } from "../errors.js";
10
11
  import type { AtlasDialect } from "../query/native.js";
11
12
 
12
13
  /**
@@ -22,6 +23,20 @@ export interface CatalogConnection {
22
23
  // Return value is ignored — the two adapter shapes disagree on it
23
24
  // (`void` vs `{ rowsAffected }`), and these helpers only run it for effect.
24
25
  execute(sql: string, params?: unknown[]): Promise<unknown>;
26
+ /**
27
+ * Optional: run a batch of statements atomically on ONE pinned pooled
28
+ * connection. When present, {@link runWithoutForeignKeys} uses it so the FK
29
+ * toggle and the operations can't scatter across the pool.
30
+ */
31
+ runInTransaction?(
32
+ batch: readonly { sql: string; params?: unknown[] }[],
33
+ ): Promise<number>;
34
+ /**
35
+ * Optional MANAGED interactive transaction pinned to ONE connection (Lucid
36
+ * `db.transaction(cb)`). When present, {@link runWithoutForeignKeys} uses it
37
+ * on MySQL so the FK restore runs in a real `finally` on that same connection.
38
+ */
39
+ transaction?<T>(callback: (trx: CatalogConnection) => Promise<T>): Promise<T>;
25
40
  }
26
41
 
27
42
  /**
@@ -37,6 +52,12 @@ export interface ListTablesOptions {
37
52
  * because it re-creates the tracking table afterwards.
38
53
  */
39
54
  includeFrameworkTables?: boolean;
55
+ /**
56
+ * PostgreSQL only — restrict the listing to these schemas instead of just the
57
+ * current one (Adonis Lucid `schemaGeneration.schemas`). Ignored on
58
+ * sqlite/mysql, which have no schema namespace of this kind.
59
+ */
60
+ schemas?: string[];
40
61
  }
41
62
 
42
63
  /** List every base table in the current schema/database, for `dialect`. */
@@ -46,21 +67,33 @@ export async function listUserTables(
46
67
  options: ListTablesOptions = {},
47
68
  ): Promise<string[]> {
48
69
  let sql: string;
70
+ const params: unknown[] = [];
49
71
  switch (dialect) {
50
72
  case "sqlite":
51
73
  sql =
52
74
  "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'";
53
75
  break;
54
76
  case "postgres":
55
- sql =
56
- "SELECT tablename AS name FROM pg_tables WHERE schemaname = current_schema()";
77
+ // Restrict to the given schemas (Lucid `schemas`) or the current one.
78
+ // Bind each schema as its own scalar placeholder — atlas's driver binds
79
+ // arrays as text, so `schemaname = ANY($1)` would fail; an IN list works.
80
+ if (options.schemas && options.schemas.length > 0) {
81
+ const placeholders = options.schemas.map((_, i) => `$${i + 1}`);
82
+ params.push(...options.schemas);
83
+ sql = `SELECT tablename AS name FROM pg_tables WHERE schemaname IN (${placeholders.join(", ")})`;
84
+ } else {
85
+ sql =
86
+ "SELECT tablename AS name FROM pg_tables WHERE schemaname = current_schema()";
87
+ }
57
88
  break;
58
89
  case "mysql":
90
+ // CAST to CHAR: MySQL returns information_schema identifier columns as
91
+ // binary (Uint8Array), which the string filter below would drop.
59
92
  sql =
60
- "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'";
93
+ "SELECT CAST(table_name AS CHAR) AS name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'";
61
94
  break;
62
95
  }
63
- const rows = await db.query<{ name: unknown }>(sql);
96
+ const rows = await db.query<{ name: unknown }>(sql, params);
64
97
  const names: string[] = [];
65
98
  for (const row of rows) {
66
99
  if (typeof row.name !== "string") continue;
@@ -139,28 +172,65 @@ export async function columnExists(
139
172
  * order. Postgres has no session-level switch (it uses `TRUNCATE … CASCADE` /
140
173
  * `DROP … CASCADE` instead), so this is a no-op there.
141
174
  */
142
- export async function withoutForeignKeys<T>(
175
+ export async function runWithoutForeignKeys(
143
176
  db: CatalogConnection,
144
177
  dialect: AtlasDialect,
145
- fn: () => Promise<T>,
146
- ): Promise<T> {
147
- const off =
148
- dialect === "sqlite"
149
- ? "PRAGMA foreign_keys = OFF"
150
- : dialect === "mysql"
151
- ? "SET FOREIGN_KEY_CHECKS = 0"
152
- : null;
153
- const on =
154
- dialect === "sqlite"
155
- ? "PRAGMA foreign_keys = ON"
156
- : dialect === "mysql"
157
- ? "SET FOREIGN_KEY_CHECKS = 1"
158
- : null;
178
+ statements: ReadonlyArray<{ sql: string; params?: unknown[] }>,
179
+ ): Promise<void> {
180
+ if (statements.length === 0) return;
159
181
 
160
- if (off) await db.execute(off);
161
- try {
162
- return await fn();
163
- } finally {
164
- if (on) await db.execute(on);
182
+ // MySQL: `SET FOREIGN_KEY_CHECKS` is a SESSION variable (not transactional),
183
+ // so it MUST be restored in a real `finally` on the SAME pinned connection —
184
+ // otherwise a mid-batch failure leaves the pooled connection with FK checks
185
+ // off. Use the managed interactive transaction: SET 0 → ops → finally SET 1,
186
+ // all on the one pinned connection, restore guaranteed even on error.
187
+ if (dialect === "mysql") {
188
+ // MySQL `SET FOREIGN_KEY_CHECKS` is a SESSION variable (not transactional),
189
+ // so it MUST be restored in a real `finally` on the SAME pinned connection.
190
+ // That needs the managed interactive transaction: SET 0 → ops → finally
191
+ // SET 1, restore guaranteed even on error. Without transaction() there is
192
+ // no safe restore — a mid-batch failure would leave the pooled connection
193
+ // with FK checks OFF (a poisoned connection). Refuse rather than corrupt.
194
+ if (!db.transaction) {
195
+ throw new AtlasError(
196
+ "E_MYSQL_FK_REQUIRES_TRANSACTION",
197
+ "Suspending MySQL foreign-key checks needs an interactive transaction() to restore them in a finally — a session-level SET can't be rolled back, so a failure mid-batch would leave the pooled connection with FK checks disabled. This adapter has none.",
198
+ {
199
+ hint: "Use a real connection (createNapiConnection) or an adapter that implements transaction().",
200
+ },
201
+ );
202
+ }
203
+ await db.transaction(async (trx) => {
204
+ try {
205
+ await trx.execute("SET FOREIGN_KEY_CHECKS = 0");
206
+ for (const stmt of statements) {
207
+ await trx.execute(stmt.sql, stmt.params);
208
+ }
209
+ } finally {
210
+ await trx.execute("SET FOREIGN_KEY_CHECKS = 1");
211
+ }
212
+ });
213
+ return;
214
+ }
215
+
216
+ // sqlite/postgres: run the FK toggle + ops on ONE pinned connection via
217
+ // runInTransaction so a pool can't scatter the connection-local toggle away
218
+ // from the ops.
219
+ // - sqlite: `PRAGMA defer_foreign_keys` is transaction-scoped and auto-resets
220
+ // at txn end (no leak); plain `PRAGMA foreign_keys` is ignored in a txn;
221
+ // - postgres: no session toggle — drops use CASCADE and `truncateAll` uses
222
+ // `TRUNCATE … CASCADE`, so pg statements are already FK-safe.
223
+ const batch: Array<{ sql: string; params?: unknown[] }> = [];
224
+ if (dialect === "sqlite") {
225
+ batch.push({ sql: "PRAGMA defer_foreign_keys = ON" });
226
+ }
227
+ batch.push(...statements);
228
+ if (db.runInTransaction) {
229
+ await db.runInTransaction(batch);
230
+ return;
231
+ }
232
+ // No transaction support at all (a bare fake): best effort, single connection.
233
+ for (const stmt of batch) {
234
+ await db.execute(stmt.sql, stmt.params);
165
235
  }
166
236
  }
@@ -49,15 +49,17 @@ export async function introspectTable(
49
49
  db: SchemaIntrospectable,
50
50
  dialect: AtlasDialect,
51
51
  table: string,
52
+ schema?: string,
52
53
  ): Promise<IntrospectedColumn[] | null> {
53
54
  assertIdent(table);
55
+ if (schema !== undefined) assertIdent(schema);
54
56
  switch (dialect) {
55
57
  case "sqlite":
56
58
  return introspectSqlite(db, table);
57
59
  case "postgres":
58
- return introspectPostgres(db, table);
60
+ return introspectPostgres(db, table, schema);
59
61
  case "mysql":
60
- return introspectMysql(db, table);
62
+ return introspectMysql(db, table, schema);
61
63
  }
62
64
  }
63
65
 
@@ -79,13 +81,17 @@ async function introspectSqlite(
79
81
  async function introspectPostgres(
80
82
  db: SchemaIntrospectable,
81
83
  table: string,
84
+ schema?: string,
82
85
  ): Promise<IntrospectedColumn[] | null> {
86
+ // Target a specific schema when given (Lucid `schemas`), else the current one.
87
+ const schemaPred = schema ? "$2" : "current_schema()";
88
+ const args = schema ? [table, schema] : [table];
83
89
  const cols = await db.query(
84
90
  `SELECT column_name, data_type, is_nullable, column_default
85
91
  FROM information_schema.columns
86
- WHERE table_schema = current_schema() AND table_name = $1
92
+ WHERE table_schema = ${schemaPred} AND table_name = $1
87
93
  ORDER BY ordinal_position`,
88
- [table],
94
+ args,
89
95
  );
90
96
  if (cols.length === 0) return null;
91
97
  const pkRows = await db.query(
@@ -94,8 +100,9 @@ async function introspectPostgres(
94
100
  JOIN information_schema.key_column_usage kcu
95
101
  ON kcu.constraint_name = tc.constraint_name
96
102
  AND kcu.table_schema = tc.table_schema
97
- WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY'`,
98
- [table],
103
+ WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY'
104
+ AND tc.table_schema = ${schemaPred}`,
105
+ args,
99
106
  );
100
107
  const pks = new Set(pkRows.map((r) => String(r.column_name)));
101
108
  return cols.map((r) => ({
@@ -110,13 +117,23 @@ async function introspectPostgres(
110
117
  async function introspectMysql(
111
118
  db: SchemaIntrospectable,
112
119
  table: string,
120
+ schema?: string,
113
121
  ): Promise<IntrospectedColumn[] | null> {
122
+ // In MySQL a "schema" IS a database; target it when given, else DATABASE().
123
+ const schemaPred = schema ? "?" : "DATABASE()";
124
+ const args = schema ? [schema, table] : [table];
125
+ // CAST the identifier/enum columns to CHAR: MySQL returns information_schema
126
+ // text as binary (Uint8Array), which `String(...)` would turn into byte lists.
114
127
  const rows = await db.query(
115
- `SELECT column_name, data_type, is_nullable, column_default, column_key
128
+ `SELECT CAST(column_name AS CHAR) AS column_name,
129
+ CAST(data_type AS CHAR) AS data_type,
130
+ CAST(is_nullable AS CHAR) AS is_nullable,
131
+ column_default,
132
+ CAST(column_key AS CHAR) AS column_key
116
133
  FROM information_schema.columns
117
- WHERE table_schema = DATABASE() AND table_name = ?
134
+ WHERE table_schema = ${schemaPred} AND table_name = ?
118
135
  ORDER BY ordinal_position`,
119
- [table],
136
+ args,
120
137
  );
121
138
  if (rows.length === 0) return null;
122
139
  return rows.map((r) => ({
package/src/seeders.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `@c9up/atlas/seeders` — Adonis Lucid seeders parity subpath. The `BaseSeeder`
3
+ * base class plus the runners and the `make:seeder` / `db:seed` console commands.
4
+ */
5
+
6
+ export {
7
+ dbSeedCommand,
8
+ makeSeederCommand,
9
+ type SeederCommandOptions,
10
+ } from "./console/seederCommands.js";
11
+ export {
12
+ BaseSeeder,
13
+ runSeederDirectory,
14
+ runSeeders,
15
+ Seeder,
16
+ } from "./schema/Seeder.js";
@@ -13,18 +13,133 @@
13
13
  * container hooks).
14
14
  */
15
15
 
16
+ import type { ConnectionConfig } from "../AtlasProvider.js";
16
17
  import type { AsyncDatabaseConnection } from "../adapters/NapiDbAdapter.js";
18
+ import { ConnectionManager } from "../ConnectionManager.js";
19
+ import {
20
+ DatabaseQueryBuilder,
21
+ type QueryExecutor,
22
+ } from "../query/DatabaseQueryBuilder.js";
23
+ import type { AtlasDialect } from "../query/native.js";
17
24
  import { RawSql } from "../query/QueryBuilder.js";
25
+ import {
26
+ RawQueryBuilder,
27
+ resolveRawBindings,
28
+ } from "../query/RawQueryBuilder.js";
29
+ import { truncateAll } from "../testing/DatabaseCleanup.js";
18
30
 
19
- /** The `db` singleton surface: the bound connection plus the AdonisJS-style `db.raw()` builder. */
20
- export interface DbService extends AsyncDatabaseConnection {
31
+ /** Options accepted by the Lucid query-builder entry points. */
32
+ export interface DbQueryOptions {
33
+ /** Route the query through this transaction client (Lucid `{ client: trx }`). */
34
+ client?: QueryExecutor;
35
+ /** `'read'` rejects writes on this builder (Lucid `db.query({ mode: 'read' })`). */
36
+ mode?: "read" | "write";
37
+ }
38
+
39
+ /** Options for {@link DbService.connection} (Lucid read/write replica routing). */
40
+ export interface ConnectionOptions {
21
41
  /**
22
- * Build a raw SQL expression AdonisJS `db.raw()` / `Database.raw()`. Use it
23
- * for query fragments and for column defaults that are SQL expressions:
42
+ * `'read'` scopes the returned service to reads: its query builders reject
43
+ * writes (insert/update/delete/increment/decrement). `'write'` (default) is
44
+ * unrestricted. Atlas has no replica pool, so `mode` is a write-guard rather
45
+ * than a routing hint — the guard is the security-relevant half of Lucid's
46
+ * read/write modes.
47
+ */
48
+ mode?: "read" | "write";
49
+ }
50
+
51
+ /**
52
+ * The `db` service surface — Adonis Lucid's `Database` service. Exposes the
53
+ * query builders (`query`/`from`/`table`/`insertQuery`), raw execution
54
+ * (`rawQuery`), the `raw()` fragment builder, connection scoping (`connection`),
55
+ * and the transaction/DDL methods forwarded from the bound connection.
56
+ */
57
+ export interface DbService {
58
+ /** A connection-level query builder (Lucid `db.query()`), optionally on a trx. */
59
+ query(options?: DbQueryOptions): DatabaseQueryBuilder;
60
+ /** Query builder with the table pre-selected (Lucid `db.from(table)`). */
61
+ from(table: string): DatabaseQueryBuilder;
62
+ /**
63
+ * Query builder on a derived-table source (Lucid `db.from(subquery, alias)`) —
64
+ * a builder OR a callback that builds one.
65
+ */
66
+ from(
67
+ subquery: DatabaseQueryBuilder | ((query: DatabaseQueryBuilder) => void),
68
+ alias?: string,
69
+ ): DatabaseQueryBuilder;
70
+ /** Insert/write builder with the table pre-selected (Lucid `db.table(table)`). */
71
+ table(table: string): DatabaseQueryBuilder;
72
+ /** An insert builder (Lucid `db.insertQuery()`), optionally on a trx. */
73
+ insertQuery(options?: DbQueryOptions): DatabaseQueryBuilder;
74
+ /**
75
+ * A chainable raw query (Lucid `db.rawQuery(sql, bindings)`). Thenable — can be
76
+ * awaited directly — and exposes `toSQL`/`toQuery`/`debug`/`timeout`/
77
+ * `reporterData`. Bindings may be positional (`?`/`??`) or named (`:name`/`:name:`).
78
+ */
79
+ rawQuery<T = Record<string, unknown>>(
80
+ sql: string,
81
+ bindings?: unknown[] | Record<string, unknown>,
82
+ ): RawQueryBuilder<T>;
83
+ /**
84
+ * Scope the service to a connection (Lucid `db.connection(name)`). Called with
85
+ * no name it returns the default connection's service (Lucid `db.connection()`).
86
+ * Pass `{ mode: 'read' }` to reject writes on the returned service (Lucid
87
+ * `db.connection(name, { mode: 'read' })`).
88
+ */
89
+ connection(name?: string, options?: ConnectionOptions): DbService;
90
+ /**
91
+ * Query builder for a model whose class is resolved at runtime (Lucid
92
+ * `db.modelQuery(Model)`). For static code prefer `Model.query()` directly.
93
+ */
94
+ modelQuery<Q>(model: { query(): Q }): Q;
95
+ /**
96
+ * Build a raw SQL expression — AdonisJS `db.raw()`. For query fragments and
97
+ * column defaults that are SQL expressions:
24
98
  *
25
99
  * t.uuid('id').defaultTo(db.raw('gen_random_uuid()'))
100
+ *
101
+ * Bindings may be positional (`?`/`??`) or named (`:name`/`:name:`).
102
+ */
103
+ raw(sql: string, params?: unknown[] | Record<string, unknown>): RawSql;
104
+ /**
105
+ * A column reference — Adonis Lucid `db.ref('posts.created_at')`. Use it where
106
+ * a value position must be read as a column (e.g. `orderBy(db.ref(col), 'desc')`).
107
+ * The identifier is validated and dialect-quoted; it is NOT a value binding.
108
+ */
109
+ ref(column: string): RawSql;
110
+ /** Run a statement for effect (forwarded to the connection). */
111
+ execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>;
112
+ /** Managed/manual interactive transaction (forwarded, Lucid `db.transaction`). */
113
+ transaction: AsyncDatabaseConnection["transaction"];
114
+ /** Atomic batch (forwarded). */
115
+ runInTransaction: AsyncDatabaseConnection["runInTransaction"];
116
+ /**
117
+ * Empty a table (Lucid `truncate`). `TRUNCATE TABLE` on Postgres/MySQL (with
118
+ * `CASCADE` when `cascade` is set, Postgres only); `DELETE FROM` on SQLite,
119
+ * which has no `TRUNCATE`.
26
120
  */
27
- raw(sql: string, params?: unknown[]): RawSql;
121
+ truncate(table: string, cascade?: boolean): Promise<void>;
122
+ /**
123
+ * Empty every user table (Lucid `truncateAllTables`). Framework tables
124
+ * (`ream_*`) and dialect internals are left alone; pass `ignoreTables` to spare
125
+ * more. Foreign keys are suspended so delete order doesn't matter.
126
+ */
127
+ truncateAllTables(ignoreTables?: readonly string[]): Promise<void>;
128
+ /**
129
+ * Try to acquire a session-level advisory lock, non-blocking (Lucid
130
+ * `getAdvisoryLock`). Postgres `pg_try_advisory_lock`, MySQL `GET_LOCK(key, 0)`.
131
+ * A string key is hashed to the integer Postgres requires. Returns whether the
132
+ * lock was acquired. **Throws on SQLite** (no advisory locks — Lucid parity).
133
+ */
134
+ getAdvisoryLock(key: string | number): Promise<boolean>;
135
+ /** Release an advisory lock taken with {@link getAdvisoryLock}. Throws on SQLite. */
136
+ releaseAdvisoryLock(key: string | number): Promise<boolean>;
137
+ /** The full Lucid connection manager (`add`/`connect`/`patch`/`release`/nodes/events). */
138
+ readonly manager: ConnectionManager;
139
+ /** The bound connection's dialect. */
140
+ readonly dialect: AtlasDialect;
141
+ ping(): Promise<void>;
142
+ close(): Promise<void>;
28
143
  }
29
144
 
30
145
  let instance: AsyncDatabaseConnection | undefined;
@@ -49,51 +164,248 @@ export function getDb(): AsyncDatabaseConnection | undefined {
49
164
  return instance;
50
165
  }
51
166
 
52
- // Named-connection registry backs `BaseModel.connection = 'analytics'` so a
53
- // model can resolve a non-default connection from a plain import (AdonisJS
54
- // `static connection`). Populated by AtlasProvider for every opened connection.
55
- const namedConnections = new Map<string, AsyncDatabaseConnection>();
167
+ // The shared connection manager (Lucid `db.manager`) the single owner of named
168
+ // connections. Backs `BaseModel.connection = 'analytics'` so a model resolves a
169
+ // non-default connection from a plain import (AdonisJS `static connection`).
170
+ const manager = new ConnectionManager();
56
171
 
57
- /** @internal Register a named connection (called by AtlasProvider.boot per connection). */
172
+ /**
173
+ * @internal Register an already-open named connection (called by AtlasProvider,
174
+ * which opens connections itself). Records the config on the node too.
175
+ */
58
176
  export function registerConnection(
59
177
  name: string,
60
178
  connection: AsyncDatabaseConnection,
179
+ config: ConnectionConfig = {},
61
180
  ): void {
62
- namedConnections.set(name, connection);
181
+ manager.register(name, config, connection);
63
182
  }
64
183
 
65
- /** @internal Unregister a named connection IF it still points at `connection`. */
184
+ /** @internal Unregister a named connection IF it still points at `connection` (no close). */
66
185
  export function unregisterConnection(
67
186
  name: string,
68
187
  connection: AsyncDatabaseConnection,
69
188
  ): void {
70
- if (namedConnections.get(name) === connection) namedConnections.delete(name);
189
+ manager.deregister(name, connection);
71
190
  }
72
191
 
73
- /** @internal Resolve a named connection (for `BaseModel.connection`), or `undefined`. */
192
+ /** @internal Resolve a live named connection (for `BaseModel.connection`), or `undefined`. */
74
193
  export function getConnection(
75
194
  name: string,
76
195
  ): AsyncDatabaseConnection | undefined {
77
- return namedConnections.get(name);
196
+ return manager.connection(name);
197
+ }
198
+
199
+ /** @internal The shared connection manager (for the AtlasProvider lifecycle-event bridge). */
200
+ export function connectionManager(): ConnectionManager {
201
+ return manager;
202
+ }
203
+
204
+ /**
205
+ * Coerce an advisory-lock key to the integer Postgres `pg_*_advisory_lock`
206
+ * requires. Numeric keys pass through; string keys are hashed deterministically
207
+ * (FNV-1a, 32-bit) so lock and unlock agree. Stable within a process is all that
208
+ * matters — atlas never shares a lock namespace with Knex.
209
+ */
210
+ function advisoryLockKey(key: string | number): number {
211
+ if (typeof key === "number") return Math.trunc(key);
212
+ let hash = 0x811c9dc5;
213
+ for (let i = 0; i < key.length; i++) {
214
+ hash ^= key.charCodeAt(i);
215
+ hash = Math.imul(hash, 0x01000193);
216
+ }
217
+ return hash | 0; // signed 32-bit — fits Postgres int/bigint
78
218
  }
79
219
 
80
- const db: DbService = new Proxy({} as DbService, {
81
- get(_target, prop) {
82
- // `raw` is a pure builder (no connection needed) — available pre-boot too.
83
- if (prop === "raw") {
84
- return (sql: string, params: unknown[] = []) => new RawSql(sql, params);
85
- }
86
- if (!instance) {
87
- throw new Error(
88
- "[atlas] db singleton accessed before AtlasProvider.boot() ran. " +
89
- "Check that `@c9up/atlas/provider` is listed in your reamrc.ts " +
90
- "providers and that `config/database.ts` defines at least one " +
91
- "connection.",
220
+ /** Validate + dialect-quote a (dot-qualified) identifier. Rejects anything unsafe. */
221
+ function quoteIdent(name: string, dialect: AtlasDialect): string {
222
+ const q = dialect === "mysql" ? "`" : '"';
223
+ return name
224
+ .split(".")
225
+ .map((seg) => {
226
+ if (seg === "*") return seg;
227
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) {
228
+ throw new Error(
229
+ `atlas: invalid identifier segment '${seg}' in '${name}'`,
230
+ );
231
+ }
232
+ return `${q}${seg}${q}`;
233
+ })
234
+ .join(".");
235
+ }
236
+
237
+ /** Build a {@link DbService} over a resolver that yields the live connection. */
238
+ export function createDbService(
239
+ resolve: () => AsyncDatabaseConnection,
240
+ readOnly = false,
241
+ ): DbService {
242
+ const opts = readOnly ? { readOnly } : undefined;
243
+ // Per-call `{ mode: 'read' }` also yields a write-guarded builder, even on an
244
+ // unscoped service (Lucid `db.query({ mode: 'read' })`).
245
+ const optsFor = (options?: DbQueryOptions) =>
246
+ readOnly || options?.mode === "read" ? { readOnly: true } : undefined;
247
+ return {
248
+ query(options) {
249
+ const conn = resolve();
250
+ return new DatabaseQueryBuilder(
251
+ options?.client ?? conn,
252
+ conn.dialect,
253
+ "",
254
+ optsFor(options),
255
+ );
256
+ },
257
+ from(
258
+ source:
259
+ | string
260
+ | DatabaseQueryBuilder
261
+ | ((query: DatabaseQueryBuilder) => void),
262
+ alias?: string,
263
+ ) {
264
+ const conn = resolve();
265
+ const builder = new DatabaseQueryBuilder(conn, conn.dialect, "", opts);
266
+ return typeof source === "string"
267
+ ? builder.from(source)
268
+ : builder.from(source, alias);
269
+ },
270
+ table(table) {
271
+ const conn = resolve();
272
+ return new DatabaseQueryBuilder(conn, conn.dialect, table, opts);
273
+ },
274
+ insertQuery(options) {
275
+ const conn = resolve();
276
+ return new DatabaseQueryBuilder(
277
+ options?.client ?? conn,
278
+ conn.dialect,
279
+ "",
280
+ optsFor(options),
92
281
  );
93
- }
94
- const value = Reflect.get(instance, prop, instance);
95
- return typeof value === "function" ? value.bind(instance) : value;
96
- },
282
+ },
283
+ rawQuery(sql, bindings = []) {
284
+ const conn = resolve();
285
+ return new RawQueryBuilder(conn, conn.dialect, sql, bindings);
286
+ },
287
+ connection(name, connOptions) {
288
+ // No name → the default connection's service (Lucid `db.connection()`).
289
+ if (name === undefined) {
290
+ return createDbService(resolve, connOptions?.mode === "read");
291
+ }
292
+ return createDbService(() => {
293
+ const conn = getConnection(name);
294
+ if (!conn) {
295
+ throw new Error(
296
+ `[atlas] no connection registered under '${name}'. Is it in config/database.ts connections?`,
297
+ );
298
+ }
299
+ return conn;
300
+ }, connOptions?.mode === "read");
301
+ },
302
+ modelQuery(model) {
303
+ return model.query();
304
+ },
305
+ raw(sql, params = []) {
306
+ // Resolve `??`/named bindings only when present, so the common
307
+ // positional/no-binding path (and Postgres `::casts`) is untouched.
308
+ const hasNamed = !Array.isArray(params);
309
+ const hasIdent = typeof sql === "string" && sql.includes("??");
310
+ if (!hasNamed && !hasIdent) {
311
+ return new RawSql(sql, params as unknown[]);
312
+ }
313
+ const resolved = resolveRawBindings(sql, params, resolve().dialect);
314
+ return new RawSql(resolved.sql, resolved.params);
315
+ },
316
+ ref(column) {
317
+ return new RawSql(quoteIdent(column, resolve().dialect), []);
318
+ },
319
+ async truncate(table, cascade) {
320
+ const conn = resolve();
321
+ const t = quoteIdent(table, conn.dialect);
322
+ // SQLite has no TRUNCATE — DELETE clears the table (Lucid does the same).
323
+ const sql =
324
+ conn.dialect === "sqlite"
325
+ ? `DELETE FROM ${t}`
326
+ : conn.dialect === "postgres"
327
+ ? `TRUNCATE TABLE ${t}${cascade ? " CASCADE" : ""}`
328
+ : `TRUNCATE TABLE ${t}`;
329
+ await conn.execute(sql, []);
330
+ },
331
+ truncateAllTables(ignoreTables = []) {
332
+ return truncateAll(resolve(), ignoreTables);
333
+ },
334
+ async getAdvisoryLock(key) {
335
+ const conn = resolve();
336
+ if (conn.dialect === "sqlite") {
337
+ throw new Error(
338
+ "[atlas] advisory locks are not supported on SQLite (Postgres/MySQL only).",
339
+ );
340
+ }
341
+ if (conn.dialect === "postgres") {
342
+ const rows = await conn.query<{ locked: boolean }>(
343
+ "SELECT pg_try_advisory_lock($1) AS locked",
344
+ [advisoryLockKey(key)],
345
+ );
346
+ return Boolean(rows[0]?.locked);
347
+ }
348
+ // MySQL: GET_LOCK(name, 0) → non-blocking try (1 acquired, 0 busy).
349
+ const rows = await conn.query<{ locked: number }>(
350
+ "SELECT GET_LOCK(?, 0) AS locked",
351
+ [String(key)],
352
+ );
353
+ return Number(rows[0]?.locked) === 1;
354
+ },
355
+ async releaseAdvisoryLock(key) {
356
+ const conn = resolve();
357
+ if (conn.dialect === "sqlite") {
358
+ throw new Error(
359
+ "[atlas] advisory locks are not supported on SQLite (Postgres/MySQL only).",
360
+ );
361
+ }
362
+ if (conn.dialect === "postgres") {
363
+ const rows = await conn.query<{ released: boolean }>(
364
+ "SELECT pg_advisory_unlock($1) AS released",
365
+ [advisoryLockKey(key)],
366
+ );
367
+ return Boolean(rows[0]?.released);
368
+ }
369
+ const rows = await conn.query<{ released: number }>(
370
+ "SELECT RELEASE_LOCK(?) AS released",
371
+ [String(key)],
372
+ );
373
+ return Number(rows[0]?.released) === 1;
374
+ },
375
+ get manager() {
376
+ return manager;
377
+ },
378
+ execute(sql, params) {
379
+ return resolve().execute(sql, params);
380
+ },
381
+ get transaction() {
382
+ return resolve().transaction?.bind(resolve());
383
+ },
384
+ runInTransaction(batch) {
385
+ return resolve().runInTransaction(batch);
386
+ },
387
+ get dialect() {
388
+ return resolve().dialect;
389
+ },
390
+ ping() {
391
+ return resolve().ping();
392
+ },
393
+ close() {
394
+ return resolve().close();
395
+ },
396
+ };
397
+ }
398
+
399
+ const db: DbService = createDbService(() => {
400
+ if (!instance) {
401
+ throw new Error(
402
+ "[atlas] db singleton accessed before AtlasProvider.boot() ran. " +
403
+ "Check that `@c9up/atlas/provider` is listed in your reamrc.ts " +
404
+ "providers and that `config/database.ts` defines at least one " +
405
+ "connection.",
406
+ );
407
+ }
408
+ return instance;
97
409
  });
98
410
 
99
411
  export default db;