@c9up/atlas 0.1.13 → 0.1.15

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 (48) hide show
  1. package/db.win32-x64-msvc.node +0 -0
  2. package/dist/AtlasProvider.d.ts +12 -0
  3. package/dist/AtlasProvider.d.ts.map +1 -1
  4. package/dist/AtlasProvider.js +19 -1
  5. package/dist/AtlasProvider.js.map +1 -1
  6. package/dist/console/schemaCheckCommand.d.ts +37 -0
  7. package/dist/console/schemaCheckCommand.d.ts.map +1 -0
  8. package/dist/console/schemaCheckCommand.js +49 -0
  9. package/dist/console/schemaCheckCommand.js.map +1 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/schema/Migration.d.ts +4 -3
  15. package/dist/schema/Migration.d.ts.map +1 -1
  16. package/dist/schema/Migration.js +4 -3
  17. package/dist/schema/Migration.js.map +1 -1
  18. package/dist/schema/SchemaCheck.d.ts +61 -0
  19. package/dist/schema/SchemaCheck.d.ts.map +1 -0
  20. package/dist/schema/SchemaCheck.js +204 -0
  21. package/dist/schema/SchemaCheck.js.map +1 -0
  22. package/dist/schema/TableBuilder.d.ts +7 -1
  23. package/dist/schema/TableBuilder.d.ts.map +1 -1
  24. package/dist/schema/TableBuilder.js +13 -5
  25. package/dist/schema/TableBuilder.js.map +1 -1
  26. package/dist/schema/introspect.d.ts +34 -0
  27. package/dist/schema/introspect.d.ts.map +1 -0
  28. package/dist/schema/introspect.js +83 -0
  29. package/dist/schema/introspect.js.map +1 -0
  30. package/dist/schema/raw.d.ts +26 -0
  31. package/dist/schema/raw.d.ts.map +1 -0
  32. package/dist/schema/raw.js +33 -0
  33. package/dist/schema/raw.js.map +1 -0
  34. package/dist/services/db.d.ts +12 -1
  35. package/dist/services/db.d.ts.map +1 -1
  36. package/dist/services/db.js +5 -0
  37. package/dist/services/db.js.map +1 -1
  38. package/index.win32-x64-msvc.node +0 -0
  39. package/package.json +1 -1
  40. package/src/AtlasProvider.ts +29 -1
  41. package/src/console/schemaCheckCommand.ts +62 -0
  42. package/src/index.ts +19 -0
  43. package/src/schema/Migration.ts +8 -5
  44. package/src/schema/SchemaCheck.ts +273 -0
  45. package/src/schema/TableBuilder.ts +14 -5
  46. package/src/schema/introspect.ts +133 -0
  47. package/src/schema/raw.ts +34 -0
  48. package/src/services/db.ts +17 -1
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Schema verification — reconciles each model's `@Column` metadata against the
3
+ * LIVE database schema (via {@link introspectTable}) and reports drift BEFORE
4
+ * it bites at runtime. This is what pure-JS ORMs (Lucid) structurally cannot
5
+ * do; atlas can because its driver introspects the real database.
6
+ *
7
+ * Four drift categories:
8
+ * - `missing-table` — the model's table does not exist.
9
+ * - `missing-in-db` — a model column maps to a non-existent DB column
10
+ * (typo → `did you mean`).
11
+ * - `type-mismatch` — a declared `@Column({ type })` clashes with the DB
12
+ * column's type (conservative: only clear num↔text).
13
+ * - `missing-in-model` — a NOT NULL DB column with no default that no model
14
+ * property maps to → inserts will fail.
15
+ *
16
+ * The check is dialect-agnostic; only {@link introspectTable} is dialect-aware.
17
+ * Kept free of any `@c9up/ream` import — atlas stays framework-agnostic.
18
+ */
19
+
20
+ import { getColumnMetadata, getEntityMetadata } from "../decorators/entity.js";
21
+ import { getNamingStrategy } from "../naming/NamingStrategy.js";
22
+ import type { AtlasDialect } from "../query/native.js";
23
+ import {
24
+ type IntrospectedColumn,
25
+ introspectTable,
26
+ type SchemaIntrospectable,
27
+ } from "./introspect.js";
28
+
29
+ type Constructor = new (...args: unknown[]) => unknown;
30
+
31
+ export type SchemaFindingKind =
32
+ | "missing-table"
33
+ | "missing-in-db"
34
+ | "type-mismatch"
35
+ | "missing-in-model";
36
+
37
+ export interface SchemaFinding {
38
+ entity: string;
39
+ table: string;
40
+ kind: SchemaFindingKind;
41
+ column: string;
42
+ detail: string;
43
+ /** A close DB column name, for typo diagnostics (`did you mean`). */
44
+ suggestion?: string;
45
+ }
46
+
47
+ // ─── Type compatibility (conservative) ───────────────────────────────
48
+
49
+ /**
50
+ * Coarse group of a type string. We only ever flag a mismatch between the two
51
+ * UNAMBIGUOUS groups (`num` vs `text`); everything storage-dependent
52
+ * (date/time, binary, blob — SQLite stores these as TEXT/NUMERIC) is `other`
53
+ * and never flagged, so the check has NO false positives.
54
+ */
55
+ function typeGroup(raw: string): "num" | "text" | "other" {
56
+ const t = raw.toLowerCase();
57
+ if (/(^|[^a-z])(int|serial|decimal|numeric|real|float|double|bool)/.test(t)) {
58
+ return "num";
59
+ }
60
+ if (/char|text|clob|string|uuid|json/.test(t)) return "text";
61
+ return "other";
62
+ }
63
+
64
+ /** Compatible unless the model and DB types are in clearly-different groups. */
65
+ export function typesCompatible(modelType: string, dbType: string): boolean {
66
+ const m = typeGroup(modelType);
67
+ const d = typeGroup(dbType);
68
+ if (m === "other" || d === "other") return true;
69
+ return m === d;
70
+ }
71
+
72
+ // ─── `did you mean` (atlas-local; no @c9up/ream import) ───────────────
73
+
74
+ function levenshtein(a: string, b: string): number {
75
+ const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
76
+ for (let i = 1; i <= a.length; i++) {
77
+ let prev = dp[0];
78
+ dp[0] = i;
79
+ for (let j = 1; j <= b.length; j++) {
80
+ const tmp = dp[j];
81
+ dp[j] = Math.min(
82
+ dp[j] + 1,
83
+ dp[j - 1] + 1,
84
+ prev + (a[i - 1] === b[j - 1] ? 0 : 1),
85
+ );
86
+ prev = tmp;
87
+ }
88
+ }
89
+ return dp[b.length];
90
+ }
91
+
92
+ /** Closest candidate within edit distance 2 (typo suggestion), else undefined. */
93
+ export function suggestColumn(
94
+ name: string,
95
+ candidates: string[],
96
+ ): string | undefined {
97
+ let best: string | undefined;
98
+ let bestD = Number.POSITIVE_INFINITY;
99
+ for (const c of candidates) {
100
+ const d = levenshtein(name, c);
101
+ if (d < bestD) {
102
+ bestD = d;
103
+ best = c;
104
+ }
105
+ }
106
+ return bestD <= 2 ? best : undefined;
107
+ }
108
+
109
+ // ─── Reconciler ──────────────────────────────────────────────────────
110
+
111
+ function reconcile(
112
+ entityName: string,
113
+ table: string,
114
+ cols: ReturnType<typeof getColumnMetadata>,
115
+ columnNameOf: (property: string) => string,
116
+ dbCols: IntrospectedColumn[],
117
+ ): SchemaFinding[] {
118
+ const findings: SchemaFinding[] = [];
119
+ const dbByName = new Map(dbCols.map((c) => [c.name, c]));
120
+ const dbNames = dbCols.map((c) => c.name);
121
+ const mappedDbColumns = new Set<string>();
122
+
123
+ for (const col of cols) {
124
+ const dbName = columnNameOf(col.propertyKey);
125
+ mappedDbColumns.add(dbName);
126
+ const dbCol = dbByName.get(dbName);
127
+ if (!dbCol) {
128
+ findings.push({
129
+ entity: entityName,
130
+ table,
131
+ kind: "missing-in-db",
132
+ column: dbName,
133
+ detail: `model property \`${col.propertyKey}\` maps to column \`${dbName}\`, which does not exist`,
134
+ suggestion: suggestColumn(dbName, dbNames),
135
+ });
136
+ continue;
137
+ }
138
+ if (col.type && !typesCompatible(col.type, dbCol.type)) {
139
+ findings.push({
140
+ entity: entityName,
141
+ table,
142
+ kind: "type-mismatch",
143
+ column: dbName,
144
+ detail: `declared \`${col.type}\` but column is \`${dbCol.type}\``,
145
+ });
146
+ }
147
+ }
148
+
149
+ // Reverse drift — a NOT NULL column with no default that no model property
150
+ // writes: every insert omitting it fails. The dangerous, easy-to-miss case
151
+ // (e.g. a migration added a column the model never caught up to). PKs are
152
+ // excluded (DB-generated).
153
+ for (const dbCol of dbCols) {
154
+ if (dbCol.primaryKey) continue;
155
+ if (!dbCol.nullable && !dbCol.hasDefault && !mappedDbColumns.has(dbCol.name)) {
156
+ findings.push({
157
+ entity: entityName,
158
+ table,
159
+ kind: "missing-in-model",
160
+ column: dbCol.name,
161
+ detail: `column \`${dbCol.name}\` is NOT NULL with no default but no model property maps to it — inserts will fail`,
162
+ });
163
+ }
164
+ }
165
+ return findings;
166
+ }
167
+
168
+ // ─── Public API ──────────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Reconcile every given entity against the live database. Pass the app's model
172
+ * classes (atlas has no global entity registry by design — mirror Lucid, where
173
+ * `ace` is pointed at your models). Returns a flat list of findings (empty when
174
+ * the schema and models agree).
175
+ */
176
+ export async function checkSchema(
177
+ entities: readonly Constructor[],
178
+ db: SchemaIntrospectable,
179
+ dialect: AtlasDialect,
180
+ ): Promise<SchemaFinding[]> {
181
+ const findings: SchemaFinding[] = [];
182
+ for (const entity of entities) {
183
+ const meta = getEntityMetadata(entity);
184
+ if (!meta?.tableName) continue; // not an @Entity — skip silently
185
+ const table = meta.tableName;
186
+ const entityName = entity.name;
187
+
188
+ const dbCols = await introspectTable(db, dialect, table);
189
+ if (dbCols === null) {
190
+ findings.push({
191
+ entity: entityName,
192
+ table,
193
+ kind: "missing-table",
194
+ column: table,
195
+ detail: `table \`${table}\` does not exist in the database — run your migrations`,
196
+ });
197
+ continue;
198
+ }
199
+
200
+ const strategy = getNamingStrategy(entity);
201
+ findings.push(
202
+ ...reconcile(
203
+ entityName,
204
+ table,
205
+ getColumnMetadata(entity),
206
+ (p) => strategy.columnName(p),
207
+ dbCols,
208
+ ),
209
+ );
210
+ }
211
+ return findings;
212
+ }
213
+
214
+ /** Render findings as a didactic, Adonis-style diff (grouped per table). */
215
+ export function formatSchemaFindings(findings: SchemaFinding[]): string {
216
+ if (findings.length === 0) return "[atlas:check] schema OK — models match the database.";
217
+ const byTable = new Map<string, SchemaFinding[]>();
218
+ for (const f of findings) {
219
+ const key = `${f.table} (${f.entity})`;
220
+ const list = byTable.get(key) ?? [];
221
+ list.push(f);
222
+ byTable.set(key, list);
223
+ }
224
+ const lines: string[] = [
225
+ `[atlas:check] ${findings.length} schema issue(s) found:`,
226
+ ];
227
+ for (const [key, list] of byTable) {
228
+ lines.push(`\n ${key}`);
229
+ for (const f of list) {
230
+ const hint = f.suggestion ? ` — did you mean \`${f.suggestion}\`?` : "";
231
+ lines.push(` ✗ ${f.column}: ${f.detail}${hint}`);
232
+ }
233
+ }
234
+ return lines.join("\n");
235
+ }
236
+
237
+ /**
238
+ * Boot-time guard: run {@link checkSchema} and either throw (fail-fast, for CI /
239
+ * dev startup) or warn (non-blocking). Returns the findings. `mode` defaults to
240
+ * `"throw"` — a schema mismatch is a misconfiguration that should stop the boot
241
+ * before requests serve stale assumptions.
242
+ */
243
+ export async function verifySchema(
244
+ entities: readonly Constructor[],
245
+ db: SchemaIntrospectable,
246
+ dialect: AtlasDialect,
247
+ opts: { mode?: "throw" | "warn" } = {},
248
+ ): Promise<SchemaFinding[]> {
249
+ const findings = await checkSchema(entities, db, dialect);
250
+ if (findings.length > 0) {
251
+ const report = formatSchemaFindings(findings);
252
+ if ((opts.mode ?? "throw") === "throw") {
253
+ throw new Error(report);
254
+ }
255
+ console.warn(report);
256
+ }
257
+ return findings;
258
+ }
259
+
260
+ /**
261
+ * CLI body: run the check, print the report (diff or the OK line), and return
262
+ * the process exit code (`0` = schema matches, `1` = drift). Used by the
263
+ * `atlas:check` console command; safe to call from any script.
264
+ */
265
+ export async function runSchemaCheck(
266
+ entities: readonly Constructor[],
267
+ db: SchemaIntrospectable,
268
+ dialect: AtlasDialect,
269
+ ): Promise<number> {
270
+ const findings = await checkSchema(entities, db, dialect);
271
+ console.log(formatSchemaFindings(findings));
272
+ return findings.length > 0 ? 1 : 0;
273
+ }
@@ -12,6 +12,8 @@ import {
12
12
  compileStatementNative,
13
13
  getAtlasDialect,
14
14
  } from "../query/native.js";
15
+ import { RawSql } from "../query/QueryBuilder.js";
16
+ import { type DefaultValue, renderDefaultValue } from "./raw.js";
15
17
  import {
16
18
  type ColumnDefinition,
17
19
  type ColumnType,
@@ -128,7 +130,7 @@ export class TableBuilder {
128
130
  * the helper dialect-aware.
129
131
  */
130
132
  id(): this {
131
- return this.uuid("id").primary().defaultTo("gen_random_uuid()");
133
+ return this.uuid("id").primary().defaultTo(new RawSql("gen_random_uuid()"));
132
134
  }
133
135
 
134
136
  /**
@@ -155,8 +157,8 @@ export class TableBuilder {
155
157
  * the helper dialect-aware.
156
158
  */
157
159
  timestamps(): this {
158
- this.timestamp("created_at").notNullable().defaultTo("NOW()");
159
- this.timestamp("updated_at").notNullable().defaultTo("NOW()");
160
+ this.timestamp("created_at").notNullable().defaultTo(new RawSql("NOW()"));
161
+ this.timestamp("updated_at").notNullable().defaultTo(new RawSql("NOW()"));
160
162
  return this;
161
163
  }
162
164
 
@@ -182,8 +184,15 @@ export class TableBuilder {
182
184
  return this;
183
185
  }
184
186
 
185
- defaultTo(value: string): this {
186
- if (this.#currentColumn) this.#currentColumn.defaultValue = value;
187
+ /**
188
+ * Set a column default. JS literals are quoted/escaped (`'x'`, `123`,
189
+ * `true` — Lucid/Knex semantics); wrap SQL expressions in {@link raw} (or
190
+ * use `Migration.now()`) to emit them verbatim.
191
+ */
192
+ defaultTo(value: DefaultValue): this {
193
+ if (this.#currentColumn) {
194
+ this.#currentColumn.defaultValue = renderDefaultValue(value);
195
+ }
187
196
  return this;
188
197
  }
189
198
 
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Live schema introspection — reads the ACTUAL table/column shape from the
3
+ * connected database, per dialect. The reconciler in `SchemaCheck.ts` diffs
4
+ * this against each model's `@Column` metadata.
5
+ *
6
+ * SQLite uses `pragma_table_info`; Postgres and MySQL use `information_schema`.
7
+ * Table names come from `@Entity(...)` (developer-defined, trusted) — they are
8
+ * validated against a strict identifier pattern before interpolation, since
9
+ * `pragma_table_info(...)` cannot bind its argument.
10
+ */
11
+
12
+ import type { AtlasDialect } from "../query/native.js";
13
+
14
+ /**
15
+ * Minimal connection surface the check needs: a row-returning `query`. Both the
16
+ * real `AsyncDatabaseConnection` and the test `Database` satisfy it, so the
17
+ * checker doesn't depend on the full driver interface.
18
+ */
19
+ export interface SchemaIntrospectable {
20
+ query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
21
+ }
22
+
23
+ /** One column as it actually exists in the database. */
24
+ export interface IntrospectedColumn {
25
+ name: string;
26
+ /** Raw dialect type string (e.g. `INTEGER`, `character varying`, `varchar`). */
27
+ type: string;
28
+ nullable: boolean;
29
+ hasDefault: boolean;
30
+ primaryKey: boolean;
31
+ }
32
+
33
+ /** A safe SQL identifier (table name from `@Entity`). */
34
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
+
36
+ function assertIdent(name: string): void {
37
+ if (!IDENT.test(name)) {
38
+ throw new Error(
39
+ `[atlas:check] refusing to introspect unsafe table identifier: ${JSON.stringify(name)}`,
40
+ );
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Introspect one table. Returns its columns, or `null` when the table does not
46
+ * exist in the database (a distinct, reportable drift — not an error).
47
+ */
48
+ export async function introspectTable(
49
+ db: SchemaIntrospectable,
50
+ dialect: AtlasDialect,
51
+ table: string,
52
+ ): Promise<IntrospectedColumn[] | null> {
53
+ assertIdent(table);
54
+ switch (dialect) {
55
+ case "sqlite":
56
+ return introspectSqlite(db, table);
57
+ case "postgres":
58
+ return introspectPostgres(db, table);
59
+ case "mysql":
60
+ return introspectMysql(db, table);
61
+ }
62
+ }
63
+
64
+ async function introspectSqlite(
65
+ db: SchemaIntrospectable,
66
+ table: string,
67
+ ): Promise<IntrospectedColumn[] | null> {
68
+ const rows = await db.query(
69
+ `SELECT * FROM pragma_table_info('${table}')`,
70
+ );
71
+ if (rows.length === 0) return null; // unknown table → no columns
72
+ return rows.map((r) => ({
73
+ name: String(r.name),
74
+ type: String(r.type ?? ""),
75
+ nullable: Number(r.notnull) === 0,
76
+ hasDefault: r.dflt_value !== null && r.dflt_value !== undefined,
77
+ primaryKey: Number(r.pk) > 0,
78
+ }));
79
+ }
80
+
81
+ async function introspectPostgres(
82
+ db: SchemaIntrospectable,
83
+ table: string,
84
+ ): Promise<IntrospectedColumn[] | null> {
85
+ const cols = await db.query(
86
+ `SELECT column_name, data_type, is_nullable, column_default
87
+ FROM information_schema.columns
88
+ WHERE table_schema = current_schema() AND table_name = $1
89
+ ORDER BY ordinal_position`,
90
+ [table],
91
+ );
92
+ if (cols.length === 0) return null;
93
+ const pkRows = await db.query(
94
+ `SELECT kcu.column_name
95
+ FROM information_schema.table_constraints tc
96
+ JOIN information_schema.key_column_usage kcu
97
+ ON kcu.constraint_name = tc.constraint_name
98
+ AND kcu.table_schema = tc.table_schema
99
+ WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY'`,
100
+ [table],
101
+ );
102
+ const pks = new Set(pkRows.map((r) => String(r.column_name)));
103
+ return cols.map((r) => ({
104
+ name: String(r.column_name),
105
+ type: String(r.data_type ?? ""),
106
+ nullable: String(r.is_nullable).toUpperCase() === "YES",
107
+ hasDefault: r.column_default !== null && r.column_default !== undefined,
108
+ primaryKey: pks.has(String(r.column_name)),
109
+ }));
110
+ }
111
+
112
+ async function introspectMysql(
113
+ db: SchemaIntrospectable,
114
+ table: string,
115
+ ): Promise<IntrospectedColumn[] | null> {
116
+ const rows = await db.query(
117
+ `SELECT column_name, data_type, is_nullable, column_default, column_key
118
+ FROM information_schema.columns
119
+ WHERE table_schema = DATABASE() AND table_name = ?
120
+ ORDER BY ordinal_position`,
121
+ [table],
122
+ );
123
+ if (rows.length === 0) return null;
124
+ return rows.map((r) => ({
125
+ name: String(r.column_name ?? r.COLUMN_NAME),
126
+ type: String(r.data_type ?? r.DATA_TYPE ?? ""),
127
+ nullable: String(r.is_nullable ?? r.IS_NULLABLE).toUpperCase() === "YES",
128
+ hasDefault:
129
+ (r.column_default ?? r.COLUMN_DEFAULT) !== null &&
130
+ (r.column_default ?? r.COLUMN_DEFAULT) !== undefined,
131
+ primaryKey: String(r.column_key ?? r.COLUMN_KEY ?? "").toUpperCase() === "PRI",
132
+ }));
133
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Default-value rendering for the schema builder — Lucid/Knex semantics.
3
+ *
4
+ * `defaultTo` quotes JS literals and emits raw SQL only when given a `RawSql`
5
+ * (the single raw-expression type atlas uses everywhere, incl. queries),
6
+ * produced in a migration by `this.now()` / `this.raw(...)`:
7
+ *
8
+ * t.text('status').defaultTo('new') // → DEFAULT 'new' (quoted literal)
9
+ * t.boolean('active').defaultTo(false) // → DEFAULT false
10
+ * t.integer('count').defaultTo(0) // → DEFAULT 0
11
+ * t.uuid('id').defaultTo(this.raw('gen_random_uuid()')) // → DEFAULT gen_random_uuid()
12
+ * t.timestamp('created_at').defaultTo(this.now()) // → DEFAULT NOW()/CURRENT_TIMESTAMP
13
+ *
14
+ * Before this, `defaultTo` wrote its argument verbatim, so a bare
15
+ * `defaultTo('new')` produced the invalid `DEFAULT new` and string defaults had
16
+ * to be hand-quoted (`defaultTo("'new'")`) — a footgun Lucid/Knex avoid.
17
+ */
18
+
19
+ import { RawSql } from "../query/QueryBuilder.js";
20
+
21
+ /** Accepted `defaultTo` argument: a JS literal or a raw SQL expression. */
22
+ export type DefaultValue = string | number | boolean | RawSql;
23
+
24
+ /**
25
+ * Render a `defaultTo` argument to the SQL fragment stored as the column
26
+ * default. Literals are quoted/escaped; a {@link RawSql} passes through verbatim.
27
+ */
28
+ export function renderDefaultValue(value: DefaultValue): string {
29
+ if (value instanceof RawSql) return value.sql;
30
+ if (typeof value === "number") return String(value);
31
+ if (typeof value === "boolean") return value ? "true" : "false";
32
+ // String literal — single-quote and escape embedded quotes (SQL standard).
33
+ return `'${value.replace(/'/g, "''")}'`;
34
+ }
@@ -14,6 +14,18 @@
14
14
  */
15
15
 
16
16
  import type { AsyncDatabaseConnection } from "../adapters/NapiDbAdapter.js";
17
+ import { RawSql } from "../query/QueryBuilder.js";
18
+
19
+ /** The `db` singleton surface: the bound connection plus the AdonisJS-style `db.raw()` builder. */
20
+ export interface DbService extends AsyncDatabaseConnection {
21
+ /**
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:
24
+ *
25
+ * t.uuid('id').defaultTo(db.raw('gen_random_uuid()'))
26
+ */
27
+ raw(sql: string, params?: unknown[]): RawSql;
28
+ }
17
29
 
18
30
  let instance: AsyncDatabaseConnection | undefined;
19
31
 
@@ -37,8 +49,12 @@ export function getDb(): AsyncDatabaseConnection | undefined {
37
49
  return instance;
38
50
  }
39
51
 
40
- const db: AsyncDatabaseConnection = new Proxy({} as AsyncDatabaseConnection, {
52
+ const db: DbService = new Proxy({} as DbService, {
41
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
+ }
42
58
  if (!instance) {
43
59
  throw new Error(
44
60
  "[atlas] db singleton accessed before AtlasProvider.boot() ran. " +