@c9up/atlas 0.1.19 → 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 (250) hide show
  1. package/README.md +63 -14
  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 +66 -7
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +205 -39
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +181 -5
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -33
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +127 -0
  16. package/dist/BaseModel.d.ts.map +1 -0
  17. package/dist/BaseModel.js +250 -0
  18. package/dist/BaseModel.js.map +1 -0
  19. package/dist/BaseRepository.d.ts +81 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1433 -387
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ConnectionManager.d.ts +106 -0
  24. package/dist/ConnectionManager.d.ts.map +1 -0
  25. package/dist/ConnectionManager.js +228 -0
  26. package/dist/ConnectionManager.js.map +1 -0
  27. package/dist/ModelQuery.d.ts +577 -41
  28. package/dist/ModelQuery.d.ts.map +1 -1
  29. package/dist/ModelQuery.js +2188 -291
  30. package/dist/ModelQuery.js.map +1 -1
  31. package/dist/Transaction.d.ts +71 -1
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +138 -24
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/adapters/NapiDbAdapter.d.ts +44 -4
  36. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  37. package/dist/adapters/NapiDbAdapter.js +131 -13
  38. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  39. package/dist/console/contract.d.ts +62 -0
  40. package/dist/console/contract.d.ts.map +1 -0
  41. package/dist/console/contract.js +38 -0
  42. package/dist/console/contract.js.map +1 -0
  43. package/dist/console/factoryCommands.d.ts +23 -0
  44. package/dist/console/factoryCommands.d.ts.map +1 -0
  45. package/dist/console/factoryCommands.js +62 -0
  46. package/dist/console/factoryCommands.js.map +1 -0
  47. package/dist/console/migrationCommands.d.ts +78 -0
  48. package/dist/console/migrationCommands.d.ts.map +1 -0
  49. package/dist/console/migrationCommands.js +327 -0
  50. package/dist/console/migrationCommands.js.map +1 -0
  51. package/dist/console/schemaCheckCommand.d.ts +11 -17
  52. package/dist/console/schemaCheckCommand.d.ts.map +1 -1
  53. package/dist/console/schemaCheckCommand.js +21 -15
  54. package/dist/console/schemaCheckCommand.js.map +1 -1
  55. package/dist/console/schemaDumpCommand.d.ts +30 -0
  56. package/dist/console/schemaDumpCommand.d.ts.map +1 -0
  57. package/dist/console/schemaDumpCommand.js +69 -0
  58. package/dist/console/schemaDumpCommand.js.map +1 -0
  59. package/dist/console/schemaGenerateCommand.d.ts +100 -0
  60. package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
  61. package/dist/console/schemaGenerateCommand.js +246 -0
  62. package/dist/console/schemaGenerateCommand.js.map +1 -0
  63. package/dist/console/seederCommands.d.ts +46 -0
  64. package/dist/console/seederCommands.d.ts.map +1 -0
  65. package/dist/console/seederCommands.js +136 -0
  66. package/dist/console/seederCommands.js.map +1 -0
  67. package/dist/decorators/entity.d.ts +50 -8
  68. package/dist/decorators/entity.d.ts.map +1 -1
  69. package/dist/decorators/entity.js +42 -2
  70. package/dist/decorators/entity.js.map +1 -1
  71. package/dist/decorators/hooks.d.ts +3 -3
  72. package/dist/decorators/hooks.d.ts.map +1 -1
  73. package/dist/decorators/hooks.js.map +1 -1
  74. package/dist/events.d.ts +69 -0
  75. package/dist/events.d.ts.map +1 -0
  76. package/dist/events.js +82 -0
  77. package/dist/events.js.map +1 -0
  78. package/dist/factories.d.ts +8 -0
  79. package/dist/factories.d.ts.map +1 -0
  80. package/dist/factories.js +8 -0
  81. package/dist/factories.js.map +1 -0
  82. package/dist/index.d.ts +15 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +14 -3
  85. package/dist/index.js.map +1 -1
  86. package/dist/lucid-schema.d.ts +15 -0
  87. package/dist/lucid-schema.d.ts.map +1 -0
  88. package/dist/lucid-schema.js +16 -0
  89. package/dist/lucid-schema.js.map +1 -0
  90. package/dist/metadata-keys.d.ts +3 -2
  91. package/dist/metadata-keys.d.ts.map +1 -1
  92. package/dist/naming/NamingStrategy.d.ts +7 -0
  93. package/dist/naming/NamingStrategy.d.ts.map +1 -1
  94. package/dist/naming/NamingStrategy.js +16 -0
  95. package/dist/naming/NamingStrategy.js.map +1 -1
  96. package/dist/orm.d.ts +14 -0
  97. package/dist/orm.d.ts.map +1 -0
  98. package/dist/orm.js +14 -0
  99. package/dist/orm.js.map +1 -0
  100. package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
  101. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
  102. package/dist/query/DatabaseQueryBuilder.js +1798 -0
  103. package/dist/query/DatabaseQueryBuilder.js.map +1 -0
  104. package/dist/query/DmlBuilder.d.ts +62 -0
  105. package/dist/query/DmlBuilder.d.ts.map +1 -0
  106. package/dist/query/DmlBuilder.js +84 -0
  107. package/dist/query/DmlBuilder.js.map +1 -0
  108. package/dist/query/QueryBuilder.d.ts +5 -0
  109. package/dist/query/QueryBuilder.d.ts.map +1 -1
  110. package/dist/query/QueryBuilder.js +7 -0
  111. package/dist/query/QueryBuilder.js.map +1 -1
  112. package/dist/query/RawQueryBuilder.d.ts +47 -0
  113. package/dist/query/RawQueryBuilder.d.ts.map +1 -0
  114. package/dist/query/RawQueryBuilder.js +138 -0
  115. package/dist/query/RawQueryBuilder.js.map +1 -0
  116. package/dist/query/interpolate.d.ts +45 -0
  117. package/dist/query/interpolate.d.ts.map +1 -0
  118. package/dist/query/interpolate.js +51 -0
  119. package/dist/query/interpolate.js.map +1 -0
  120. package/dist/query/native.d.ts +8 -0
  121. package/dist/query/native.d.ts.map +1 -1
  122. package/dist/query/native.js +14 -0
  123. package/dist/query/native.js.map +1 -1
  124. package/dist/query/operators.d.ts +4 -0
  125. package/dist/query/operators.d.ts.map +1 -0
  126. package/dist/query/operators.js +27 -0
  127. package/dist/query/operators.js.map +1 -0
  128. package/dist/schema/Migration.d.ts +50 -3
  129. package/dist/schema/Migration.d.ts.map +1 -1
  130. package/dist/schema/Migration.js +60 -24
  131. package/dist/schema/Migration.js.map +1 -1
  132. package/dist/schema/MigrationRunner.d.ts +95 -34
  133. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  134. package/dist/schema/MigrationRunner.js +523 -68
  135. package/dist/schema/MigrationRunner.js.map +1 -1
  136. package/dist/schema/Schema.d.ts +114 -0
  137. package/dist/schema/Schema.d.ts.map +1 -1
  138. package/dist/schema/Schema.js +272 -3
  139. package/dist/schema/Schema.js.map +1 -1
  140. package/dist/schema/SchemaDumper.d.ts +121 -0
  141. package/dist/schema/SchemaDumper.d.ts.map +1 -0
  142. package/dist/schema/SchemaDumper.js +365 -0
  143. package/dist/schema/SchemaDumper.js.map +1 -0
  144. package/dist/schema/Seeder.d.ts +13 -0
  145. package/dist/schema/Seeder.d.ts.map +1 -1
  146. package/dist/schema/Seeder.js +28 -5
  147. package/dist/schema/Seeder.js.map +1 -1
  148. package/dist/schema/TableBuilder.d.ts +274 -16
  149. package/dist/schema/TableBuilder.d.ts.map +1 -1
  150. package/dist/schema/TableBuilder.js +656 -52
  151. package/dist/schema/TableBuilder.js.map +1 -1
  152. package/dist/schema/catalog.d.ts +71 -0
  153. package/dist/schema/catalog.d.ts.map +1 -0
  154. package/dist/schema/catalog.js +158 -0
  155. package/dist/schema/catalog.js.map +1 -0
  156. package/dist/schema/introspect.d.ts +1 -1
  157. package/dist/schema/introspect.d.ts.map +1 -1
  158. package/dist/schema/introspect.js +26 -11
  159. package/dist/schema/introspect.js.map +1 -1
  160. package/dist/schema/types.d.ts +150 -1
  161. package/dist/schema/types.d.ts.map +1 -1
  162. package/dist/schema/types.js +11 -0
  163. package/dist/schema/types.js.map +1 -1
  164. package/dist/seeders.d.ts +7 -0
  165. package/dist/seeders.d.ts.map +1 -0
  166. package/dist/seeders.js +7 -0
  167. package/dist/seeders.js.map +1 -0
  168. package/dist/services/db.d.ts +122 -5
  169. package/dist/services/db.d.ts.map +1 -1
  170. package/dist/services/db.js +187 -14
  171. package/dist/services/db.js.map +1 -1
  172. package/dist/testing/DatabaseCleanup.d.ts +33 -7
  173. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  174. package/dist/testing/DatabaseCleanup.js +54 -24
  175. package/dist/testing/DatabaseCleanup.js.map +1 -1
  176. package/dist/testing/DbAssertions.d.ts +48 -0
  177. package/dist/testing/DbAssertions.d.ts.map +1 -0
  178. package/dist/testing/DbAssertions.js +80 -0
  179. package/dist/testing/DbAssertions.js.map +1 -0
  180. package/dist/testing/Factory.d.ts +166 -15
  181. package/dist/testing/Factory.d.ts.map +1 -1
  182. package/dist/testing/Factory.js +497 -23
  183. package/dist/testing/Factory.js.map +1 -1
  184. package/dist/testing/TestUtils.d.ts +49 -0
  185. package/dist/testing/TestUtils.d.ts.map +1 -0
  186. package/dist/testing/TestUtils.js +79 -0
  187. package/dist/testing/TestUtils.js.map +1 -0
  188. package/dist/testing/index.d.ts +3 -1
  189. package/dist/testing/index.d.ts.map +1 -1
  190. package/dist/testing/index.js +3 -1
  191. package/dist/testing/index.js.map +1 -1
  192. package/dist/types/relations.d.ts +31 -0
  193. package/dist/types/relations.d.ts.map +1 -0
  194. package/dist/types/relations.js +20 -0
  195. package/dist/types/relations.js.map +1 -0
  196. package/index.darwin-arm64.node +0 -0
  197. package/index.darwin-x64.node +0 -0
  198. package/index.linux-arm64-gnu.node +0 -0
  199. package/index.linux-x64-gnu.node +0 -0
  200. package/index.win32-x64-msvc.node +0 -0
  201. package/package.json +32 -2
  202. package/scripts/guard-publish.mjs +15 -0
  203. package/src/AtlasProvider.ts +305 -52
  204. package/src/BaseEntity.ts +460 -41
  205. package/src/BaseModel.ts +461 -0
  206. package/src/BaseRepository.ts +1710 -421
  207. package/src/ConnectionManager.ts +298 -0
  208. package/src/ModelQuery.ts +2998 -335
  209. package/src/Transaction.ts +227 -30
  210. package/src/adapters/NapiDbAdapter.ts +224 -17
  211. package/src/console/contract.ts +96 -0
  212. package/src/console/factoryCommands.ts +73 -0
  213. package/src/console/migrationCommands.ts +448 -0
  214. package/src/console/schemaCheckCommand.ts +26 -22
  215. package/src/console/schemaDumpCommand.ts +96 -0
  216. package/src/console/schemaGenerateCommand.ts +380 -0
  217. package/src/console/seederCommands.ts +174 -0
  218. package/src/decorators/entity.ts +71 -8
  219. package/src/decorators/hooks.ts +5 -3
  220. package/src/events.ts +117 -0
  221. package/src/factories.ts +10 -0
  222. package/src/index.ts +69 -3
  223. package/src/lucid-schema.ts +22 -0
  224. package/src/metadata-keys.ts +3 -2
  225. package/src/naming/NamingStrategy.ts +23 -0
  226. package/src/orm.ts +13 -0
  227. package/src/query/DatabaseQueryBuilder.ts +2724 -0
  228. package/src/query/DmlBuilder.ts +131 -0
  229. package/src/query/QueryBuilder.ts +8 -0
  230. package/src/query/RawQueryBuilder.ts +173 -0
  231. package/src/query/interpolate.ts +73 -0
  232. package/src/query/native.ts +29 -0
  233. package/src/query/operators.ts +27 -0
  234. package/src/schema/Migration.ts +71 -3
  235. package/src/schema/MigrationRunner.ts +676 -69
  236. package/src/schema/Schema.ts +359 -3
  237. package/src/schema/SchemaDumper.ts +518 -0
  238. package/src/schema/Seeder.ts +47 -10
  239. package/src/schema/TableBuilder.ts +783 -52
  240. package/src/schema/catalog.ts +236 -0
  241. package/src/schema/introspect.ts +26 -9
  242. package/src/schema/types.ts +137 -2
  243. package/src/seeders.ts +16 -0
  244. package/src/services/db.ts +361 -21
  245. package/src/testing/DatabaseCleanup.ts +78 -25
  246. package/src/testing/DbAssertions.ts +127 -0
  247. package/src/testing/Factory.ts +837 -40
  248. package/src/testing/TestUtils.ts +123 -0
  249. package/src/testing/index.ts +12 -1
  250. package/src/types/relations.ts +43 -0
@@ -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 {
41
+ /**
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>;
21
83
  /**
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:
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`.
120
+ */
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).
26
133
  */
27
- raw(sql: string, params?: unknown[]): RawSql;
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,23 +164,248 @@ export function getDb(): AsyncDatabaseConnection | undefined {
49
164
  return instance;
50
165
  }
51
166
 
52
- const db: DbService = new Proxy({} as DbService, {
53
- get(_target, prop) {
54
- // `raw` is a pure builder (no connection needed) — available pre-boot too.
55
- if (prop === "raw") {
56
- return (sql: string, params: unknown[] = []) => new RawSql(sql, params);
57
- }
58
- if (!instance) {
59
- throw new Error(
60
- "[atlas] db singleton accessed before AtlasProvider.boot() ran. " +
61
- "Check that `@c9up/atlas/provider` is listed in your reamrc.ts " +
62
- "providers and that `config/database.ts` defines at least one " +
63
- "connection.",
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();
171
+
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
+ */
176
+ export function registerConnection(
177
+ name: string,
178
+ connection: AsyncDatabaseConnection,
179
+ config: ConnectionConfig = {},
180
+ ): void {
181
+ manager.register(name, config, connection);
182
+ }
183
+
184
+ /** @internal Unregister a named connection IF it still points at `connection` (no close). */
185
+ export function unregisterConnection(
186
+ name: string,
187
+ connection: AsyncDatabaseConnection,
188
+ ): void {
189
+ manager.deregister(name, connection);
190
+ }
191
+
192
+ /** @internal Resolve a live named connection (for `BaseModel.connection`), or `undefined`. */
193
+ export function getConnection(
194
+ name: string,
195
+ ): AsyncDatabaseConnection | undefined {
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
218
+ }
219
+
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),
64
255
  );
65
- }
66
- const value = Reflect.get(instance, prop, instance);
67
- return typeof value === "function" ? value.bind(instance) : value;
68
- },
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),
281
+ );
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;
69
409
  });
70
410
 
71
411
  export default db;
@@ -5,44 +5,97 @@
5
5
  */
6
6
 
7
7
  import type { AsyncDatabaseConnection } from "../adapters/NapiDbAdapter.js";
8
- import { compileStatementNative, getAtlasDialect } from "../query/native.js";
8
+ import { AtlasError } from "../errors.js";
9
+ import { compileStatementNative } from "../query/native.js";
10
+ import { listUserTables, runWithoutForeignKeys } from "../schema/catalog.js";
11
+ import type { TransactionClient } from "../Transaction.js";
12
+
13
+ /** What {@link useTransaction} hands back: the pinned trx plus its teardown. */
14
+ export interface TestTransaction {
15
+ /**
16
+ * The interactive transaction, pinned to ONE pooled connection. Run every
17
+ * query the test makes through THIS handle (`trx.query` / `trx.execute`, or
18
+ * `repo.useTransaction(trx)`), never the pooled `db` — that is what makes the
19
+ * rollback isolate the test.
20
+ */
21
+ trx: TransactionClient;
22
+ /** Roll everything the test did back and return the connection to the pool. */
23
+ rollback: () => Promise<void>;
24
+ }
9
25
 
10
26
  /**
11
- * Wrap a test in a savepoint that is rolled back after.
27
+ * Isolate a test by running it inside a transaction that is rolled back after
28
+ * (Adonis Lucid parity — Lucid's tests use `db.transaction()` the same way).
29
+ *
30
+ * The earlier implementation issued `SAVEPOINT` / `ROLLBACK` through the pooled
31
+ * `db`, so with `poolMax > 1` the savepoint and its rollback — and the test's
32
+ * own queries — could each land on a DIFFERENT pooled connection, and the
33
+ * "isolation" silently did nothing. This pins a single interactive transaction
34
+ * instead: every query on the returned `trx` runs on that one connection, and
35
+ * `rollback()` reverts them together, correctly on any pool size.
12
36
  */
13
37
  export async function useTransaction(
14
38
  db: AsyncDatabaseConnection,
15
- ): Promise<() => Promise<void>> {
16
- await db.execute("SAVEPOINT test_savepoint");
17
- return async () => {
18
- await db.execute("ROLLBACK TO SAVEPOINT test_savepoint");
19
- await db.execute("RELEASE SAVEPOINT test_savepoint");
20
- };
39
+ ): Promise<TestTransaction> {
40
+ if (typeof db.transaction !== "function") {
41
+ throw new AtlasError(
42
+ "E_NO_INTERACTIVE_TRANSACTION",
43
+ "useTransaction() needs a connection with an interactive transaction() (a real napi connection); this adapter has none.",
44
+ {
45
+ hint: "Use createNapiConnection(), or drive rollback yourself if your adapter can't pin a connection.",
46
+ },
47
+ );
48
+ }
49
+ // Manual (callback-less) mode: a pinned TransactionClient the test drives and
50
+ // tears down via rollback() — no cross-pool savepoint scatter.
51
+ const trx = await db.transaction();
52
+ return { trx, rollback: () => trx.rollback() };
21
53
  }
22
54
 
23
55
  /**
24
- * Truncate all user tables (excludes ream_* framework tables and SQLite internals).
56
+ * Empty every user table (leaves `ream_*` framework tables and dialect
57
+ * internals alone). Works on all three dialects — the table list comes from the
58
+ * shared dialect-aware catalog helper, not a SQLite-only `sqlite_master` query.
25
59
  *
26
- * The SELECT on `sqlite_master` is SQLite-specific introspection and is kept
27
- * as raw SQL intentionally it's not a user query. The resulting DELETEs
28
- * go through the Rust compiler.
60
+ * Foreign keys are suspended for the duration so the delete order doesn't
61
+ * matter. The FK toggle and every DELETE run on ONE pinned connection (via
62
+ * `runWithoutForeignKeys` `runInTransaction`), so a connection pool can't
63
+ * scatter the connection-local `PRAGMA`/`SET` away from the deletes. The row
64
+ * removal goes through the Rust compiler (`DELETE` — cross-dialect, unlike
65
+ * `TRUNCATE` which auto-commits on MySQL).
29
66
  */
30
- export async function truncateAll(db: AsyncDatabaseConnection): Promise<void> {
31
- // SQL LIKE `_` is a single-char wildcard, not a literal underscore, so the
32
- // `ESCAPE '\'` clause makes `\_` a literal underscore. The exclusion targets
33
- // names starting with `ream_` (the convention for framework-private tables,
34
- // including `ream_migrations`) and SQLite's own `sqlite_%` tables.
35
- const tables = await db.query(
36
- "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'ream\\_%' ESCAPE '\\' AND name NOT LIKE 'sqlite_%'",
67
+ export async function truncateAll(
68
+ db: AsyncDatabaseConnection,
69
+ ignoreTables: readonly string[] = [],
70
+ ): Promise<void> {
71
+ // The connection's own dialect, not the module default — correct even when
72
+ // an app runs several connections on different engines.
73
+ const dialect = db.dialect;
74
+ const ignore = new Set(ignoreTables);
75
+ const tables = (await listUserTables(db, dialect)).filter(
76
+ (t) => !ignore.has(t),
37
77
  );
38
- const dialect = getAtlasDialect();
39
- for (const row of tables) {
40
- const name = row.name;
41
- if (typeof name !== "string") continue;
78
+ if (tables.length === 0) return;
79
+
80
+ // Postgres: a plain DELETE respects FKs immediately (atlas FKs aren't
81
+ // DEFERRABLE), so deleting a parent before its children raises 23503 — and
82
+ // nothing orders the tables. `TRUNCATE … CASCADE` is transactional on pg,
83
+ // order-independent, and needs no session-level FK toggle to leak. (Names
84
+ // come from the DB catalog; quote them for identifiers with embedded quotes.)
85
+ if (dialect === "postgres") {
86
+ const list = tables.map((t) => `"${t.replace(/"/g, '""')}"`).join(", ");
87
+ await db.execute(`TRUNCATE ${list} RESTART IDENTITY CASCADE`);
88
+ return;
89
+ }
90
+
91
+ // MySQL/SQLite: DELETE (TRUNCATE auto-commits on MySQL, breaking test
92
+ // transaction isolation), with FK suspension on ONE pinned connection.
93
+ const statements = tables.map((name) => {
42
94
  const compiled = compileStatementNative(
43
95
  { kind: "delete", table: name, wheres: [] },
44
96
  dialect,
45
97
  );
46
- await db.execute(compiled.statements[0], compiled.params);
47
- }
98
+ return { sql: compiled.statements[0], params: compiled.params };
99
+ });
100
+ await runWithoutForeignKeys(db, dialect, statements);
48
101
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `db()` — a helix plugin (AdonisJS Lucid database-assertions parity) that
3
+ * injects database assertions on the test context as `ctx.db`:
4
+ *
5
+ * // tests/bootstrap.ts
6
+ * import { configure } from "@c9up/helix";
7
+ * import { db } from "@c9up/atlas/testing";
8
+ * await configure({ plugins: [db(connection)] });
9
+ *
10
+ * test("registers the user", async ({ db }) => {
11
+ * await db.assertHas("users", { email: "a@b.c" });
12
+ * await db.assertCount("users", 1);
13
+ * });
14
+ *
15
+ * Values are always bound as parameters; table/column identifiers are validated
16
+ * against a strict pattern (letters, digits, `_`, `.`) and quoted, so neither a
17
+ * value nor an identifier can inject SQL.
18
+ *
19
+ * NOTE (namespace): the AdonisJS docs expose these as `db.assert*`; the exact
20
+ * surface is re-verified against the Adonis docs when available. Model-based
21
+ * `assertModelExists`/`assertModelMissing` (entity PK metadata) are pending.
22
+ */
23
+
24
+ import type { Plugin } from "@c9up/helix";
25
+
26
+ /** The minimal connection the assertions need — a parameterized query runner. */
27
+ export interface DbConnectionLike {
28
+ query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
29
+ }
30
+
31
+ /** Reject identifiers that could break out of the quoted context. */
32
+ function quoteIdent(name: string): string {
33
+ if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(name)) {
34
+ throw new Error(`db assertion: unsafe identifier ${JSON.stringify(name)}`);
35
+ }
36
+ return `"${name.replace(/\./g, '"."')}"`;
37
+ }
38
+
39
+ async function countRows(
40
+ conn: DbConnectionLike,
41
+ table: string,
42
+ payload?: Record<string, unknown>,
43
+ ): Promise<number> {
44
+ let where = "";
45
+ let params: unknown[] = [];
46
+ if (payload) {
47
+ const keys = Object.keys(payload);
48
+ if (keys.length > 0) {
49
+ where = ` WHERE ${keys.map((k) => `${quoteIdent(k)} = ?`).join(" AND ")}`;
50
+ params = keys.map((k) => payload[k]);
51
+ }
52
+ }
53
+ const rows = await conn.query(
54
+ `SELECT COUNT(*) AS c FROM ${quoteIdent(table)}${where}`,
55
+ params,
56
+ );
57
+ return Number(rows[0]?.c ?? 0);
58
+ }
59
+
60
+ /** The `ctx.db` assertion surface (AdonisJS Lucid database-assertions parity). */
61
+ export interface DbAssertions {
62
+ /** At least one row in `table` matches every column in `payload`. */
63
+ assertHas(table: string, payload: Record<string, unknown>): Promise<void>;
64
+ /** No row in `table` matches `payload`. */
65
+ assertMissing(table: string, payload: Record<string, unknown>): Promise<void>;
66
+ /** `table` has exactly `expected` rows (optionally matching `payload`). */
67
+ assertCount(
68
+ table: string,
69
+ expected: number,
70
+ payload?: Record<string, unknown>,
71
+ ): Promise<void>;
72
+ /** `table` has no rows at all. */
73
+ assertEmpty(table: string): Promise<void>;
74
+ }
75
+
76
+ /** Build the assertion surface bound to a connection. */
77
+ export function createDbAssertions(conn: DbConnectionLike): DbAssertions {
78
+ return {
79
+ async assertHas(table, payload) {
80
+ const n = await countRows(conn, table, payload);
81
+ if (n === 0) {
82
+ throw new Error(
83
+ `Expected "${table}" to have a row matching ${JSON.stringify(payload)}, found none.`,
84
+ );
85
+ }
86
+ },
87
+ async assertMissing(table, payload) {
88
+ const n = await countRows(conn, table, payload);
89
+ if (n > 0) {
90
+ throw new Error(
91
+ `Expected "${table}" to have NO row matching ${JSON.stringify(payload)}, found ${n}.`,
92
+ );
93
+ }
94
+ },
95
+ async assertCount(table, expected, payload) {
96
+ const n = await countRows(conn, table, payload);
97
+ if (n !== expected) {
98
+ const scope = payload ? ` matching ${JSON.stringify(payload)}` : "";
99
+ throw new Error(
100
+ `Expected "${table}" to have ${expected} row(s)${scope}, found ${n}.`,
101
+ );
102
+ }
103
+ },
104
+ async assertEmpty(table) {
105
+ const n = await countRows(conn, table);
106
+ if (n > 0) {
107
+ throw new Error(`Expected "${table}" to be empty, found ${n} row(s).`);
108
+ }
109
+ },
110
+ };
111
+ }
112
+
113
+ /** The `db()` helix plugin — registers {@link DbAssertions} as `ctx.db`. */
114
+ export function db(conn: DbConnectionLike): Plugin {
115
+ const assertions = createDbAssertions(conn);
116
+ return (api) => {
117
+ api.context.macro("db", assertions);
118
+ };
119
+ }
120
+
121
+ // Typing side of the plugin — importing `@c9up/atlas/testing` augments the
122
+ // helix test context with `db` (the Japa pattern).
123
+ declare module "@c9up/helix" {
124
+ interface TestContext {
125
+ db: DbAssertions;
126
+ }
127
+ }