@zerotal/orm 1.0.0

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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -0
  4. package/package.json +58 -0
  5. package/src/casts/Cast.ts +200 -0
  6. package/src/commands/DbSeedCommand.ts +71 -0
  7. package/src/commands/MakeFactoryCommand.ts +59 -0
  8. package/src/commands/MakeMigrationCommand.ts +109 -0
  9. package/src/commands/MakeModelCommand.ts +83 -0
  10. package/src/commands/MakeSeederCommand.ts +50 -0
  11. package/src/commands/MigrateCommand.ts +60 -0
  12. package/src/commands/MigrateFreshCommand.ts +41 -0
  13. package/src/commands/MigrateGenerateCommand.ts +110 -0
  14. package/src/commands/MigrateRollbackCommand.ts +43 -0
  15. package/src/commands/MigrateStatusCommand.ts +49 -0
  16. package/src/commands/_loadMigrations.ts +34 -0
  17. package/src/commands/index.ts +30 -0
  18. package/src/config.ts +182 -0
  19. package/src/conventions.ts +67 -0
  20. package/src/db/DB.ts +486 -0
  21. package/src/db/NPlusOneDetector.ts +176 -0
  22. package/src/db/QueryBuilder.ts +2458 -0
  23. package/src/db/ReadWriteRouter.ts +96 -0
  24. package/src/db/TransactionContext.ts +13 -0
  25. package/src/db/dialects/MysqlDialect.ts +57 -0
  26. package/src/db/dialects/PostgresDialect.ts +55 -0
  27. package/src/db/dialects/SqliteDialect.ts +54 -0
  28. package/src/db/dialects/index.ts +25 -0
  29. package/src/db/dialects/types.ts +67 -0
  30. package/src/db/resolver.ts +30 -0
  31. package/src/db/sql-types.ts +12 -0
  32. package/src/db/types.ts +296 -0
  33. package/src/errors/MassAssignmentError.ts +25 -0
  34. package/src/errors/MigrationError.ts +18 -0
  35. package/src/errors/ModelNotFoundError.ts +21 -0
  36. package/src/errors/NPlusOneError.ts +6 -0
  37. package/src/errors/RelationNotLoadedError.ts +19 -0
  38. package/src/errors/StateError.ts +18 -0
  39. package/src/errors/TransactionError.ts +13 -0
  40. package/src/errors/UnsupportedDialectError.ts +18 -0
  41. package/src/errors/index.ts +7 -0
  42. package/src/events.ts +112 -0
  43. package/src/global.d.ts +17 -0
  44. package/src/implicitBinding.ts +73 -0
  45. package/src/index.ts +255 -0
  46. package/src/model/BaseModel.ts +2499 -0
  47. package/src/model/ModelQueryBuilder.ts +1808 -0
  48. package/src/model/Observer.ts +73 -0
  49. package/src/model/OrmContext.ts +71 -0
  50. package/src/model/ReactiveProxy.ts +53 -0
  51. package/src/model/SoftDeletes.ts +108 -0
  52. package/src/model/State.ts +290 -0
  53. package/src/model/decorators/_metadata.ts +211 -0
  54. package/src/model/decorators/_registerRelation.ts +20 -0
  55. package/src/model/decorators/belongsTo.ts +38 -0
  56. package/src/model/decorators/column.ts +278 -0
  57. package/src/model/decorators/hasMany.ts +34 -0
  58. package/src/model/decorators/hasManyThrough.ts +50 -0
  59. package/src/model/decorators/hasOne.ts +34 -0
  60. package/src/model/decorators/hasOneThrough.ts +40 -0
  61. package/src/model/decorators/manyToMany.ts +55 -0
  62. package/src/model/decorators/morphMany.ts +38 -0
  63. package/src/model/decorators/morphOne.ts +38 -0
  64. package/src/model/decorators/morphTo.ts +51 -0
  65. package/src/model/decorators/morphToMany.ts +49 -0
  66. package/src/model/decorators/morphedByMany.ts +46 -0
  67. package/src/model/decorators/table.ts +124 -0
  68. package/src/model/hooks/HookRegistry.ts +110 -0
  69. package/src/model/mixins.ts +536 -0
  70. package/src/model/payload.ts +114 -0
  71. package/src/model/relations/RelationRegistry.ts +184 -0
  72. package/src/observability.ts +210 -0
  73. package/src/provider/DatabaseProvider.ts +266 -0
  74. package/src/schema/Blueprint.ts +900 -0
  75. package/src/schema/ColumnDefinition.ts +517 -0
  76. package/src/schema/Migration.ts +34 -0
  77. package/src/schema/MigrationCodegen.ts +108 -0
  78. package/src/schema/MigrationRunner.ts +351 -0
  79. package/src/schema/ModelInspector.ts +133 -0
  80. package/src/schema/Schema.ts +140 -0
  81. package/src/schema/SchemaDiffer.ts +137 -0
  82. package/src/schema/SchemaInspector.ts +164 -0
  83. package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
  84. package/src/schema/autoMigrate.ts +154 -0
  85. package/src/schema/index.ts +28 -0
  86. package/src/seeding/Seeder.ts +46 -0
  87. package/src/support/identifiers.ts +62 -0
@@ -0,0 +1,351 @@
1
+ import type { SQLInstance } from "../db/sql-types.ts";
2
+ import type { Migration } from "./Migration.ts";
3
+ import { MigrationError } from "../errors/MigrationError.ts";
4
+ import { FrameworkEvents } from "@zerotal/core";
5
+ import { MigrationRan } from "../events.ts";
6
+ import { dialectFor } from "../db/QueryBuilder.ts";
7
+ import { getDialect } from "../db/dialects/index.ts";
8
+
9
+ // ── Public types ──────────────────────────────────────────────────────────────
10
+
11
+ export interface MigrationEntry {
12
+ /** Unique migration name, e.g. "2024_01_01_000000_create_users_table". */
13
+ name: string;
14
+ migration: Migration;
15
+ }
16
+
17
+ export interface MigrationRecord {
18
+ name: string;
19
+ instance: Migration;
20
+ }
21
+
22
+ export interface MigrationStatus {
23
+ name: string;
24
+ ran: boolean;
25
+ batch?: number;
26
+ ranAt?: Date;
27
+ }
28
+
29
+ interface MigrationRow {
30
+ id: number;
31
+ migration: string;
32
+ batch: number;
33
+ ran_at: string;
34
+ }
35
+
36
+ // ── MigrationRunner ───────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Executes and tracks migrations against a `Bun.sql` connection.
40
+ *
41
+ * Applied migrations are recorded in a tracking table (default `"migrations"`) by
42
+ * name and batch, so re-runs skip already-applied entries and rollbacks can undo a
43
+ * whole batch. Each `up()` runs inside its own transaction; a failure rolls that
44
+ * migration back and surfaces as a {@link MigrationError} without affecting
45
+ * already-committed migrations. Every run/rollback emits a `MigrationRan`
46
+ * framework event (success or failure) for observability.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * const runner = new MigrationRunner({ connection: sql });
51
+ * await runner.runFromDirectory('./database/migrations'); // apply pending
52
+ * await runner.rollbackFromDirectory('./database/migrations'); // undo last batch
53
+ * ```
54
+ */
55
+ export class MigrationRunner {
56
+ private readonly _conn: SQLInstance;
57
+ private readonly _table: string;
58
+
59
+ /**
60
+ * @param options.connection - The `Bun.sql` connection to run DDL/DML against.
61
+ * @param options.table - Tracking-table name; defaults to `"migrations"`.
62
+ */
63
+ constructor(options: { connection: SQLInstance; table?: string }) {
64
+ this._conn = options.connection;
65
+ this._table = options.table ?? "migrations";
66
+ }
67
+
68
+ /**
69
+ * Run all pending migrations from the provided list.
70
+ *
71
+ * - Skips any entry whose name is already in the migrations table.
72
+ * - Executes pending entries in the order given; assigns them all to a new batch.
73
+ * - Returns the names of migrations that were actually executed.
74
+ */
75
+ async run(entries: MigrationEntry[]): Promise<string[]> {
76
+ await this._ensureTable();
77
+ const ran = await this._ranNames();
78
+ const batch = (await this._lastBatch()) + 1;
79
+
80
+ const pendingEntries = entries.filter((e) => !ran.has(e.name));
81
+ const executed: string[] = [];
82
+
83
+ for (const entry of pendingEntries) {
84
+ const start = performance.now();
85
+ try {
86
+ // Each migration runs in its own transaction so a failure rolls back
87
+ // that migration's DDL without affecting already-committed ones.
88
+ await this._conn.begin(async () => {
89
+ await entry.migration.up();
90
+ });
91
+ } catch (err) {
92
+ const cause = err instanceof Error ? err : new Error(String(err));
93
+ FrameworkEvents.emit(
94
+ new MigrationRan(
95
+ entry.name,
96
+ "up",
97
+ Math.round(performance.now() - start),
98
+ false,
99
+ cause.message,
100
+ ),
101
+ );
102
+ throw new MigrationError(entry.name, cause);
103
+ }
104
+ FrameworkEvents.emit(
105
+ new MigrationRan(entry.name, "up", Math.round(performance.now() - start), true),
106
+ );
107
+ await this._record(entry.name, batch);
108
+ executed.push(entry.name);
109
+ }
110
+
111
+ return executed;
112
+ }
113
+
114
+ /**
115
+ * Roll back the last batch of migrations in reverse order.
116
+ *
117
+ * `entries` must include all migration objects so the runner can locate
118
+ * the correct `down()` implementation for each name in the last batch.
119
+ *
120
+ * Returns the names of migrations that were rolled back.
121
+ */
122
+ async rollback(entries: MigrationEntry[]): Promise<string[]> {
123
+ await this._ensureTable();
124
+ const batch = await this._lastBatch();
125
+ if (batch === 0) return [];
126
+
127
+ const batchNames = await this._namesForBatch(batch);
128
+ const byName = new Map(entries.map((e) => [e.name, e]));
129
+
130
+ // A recorded migration whose file is gone has no down() to run, so the batch cannot be
131
+ // rolled back correctly. Refuse before touching anything: silently skipping it left the
132
+ // schema and the tracking table inconsistent — the batch's other migrations were undone
133
+ // while their records stayed — and left reset() looping forever, since the batch never
134
+ // emptied and _lastBatch() kept returning it.
135
+ const missing = batchNames.filter((name) => !byName.has(name));
136
+ if (missing.length > 0) {
137
+ throw new Error(
138
+ `[Zerotal ORM] Cannot roll back batch ${batch}: no migration file found for ` +
139
+ `${missing.map((n) => `"${n}"`).join(", ")}.\n` +
140
+ `Restore the file(s), or delete the row(s) from the migrations table if the ` +
141
+ `migration is genuinely gone and its schema change is already reversed.`,
142
+ );
143
+ }
144
+
145
+ // Reverse order — last migration is undone first
146
+ const toRollback = [...batchNames].reverse().map((n) => byName.get(n)!);
147
+ const rolledBack: string[] = [];
148
+
149
+ for (const entry of toRollback) {
150
+ const start = performance.now();
151
+ try {
152
+ await entry.migration.down();
153
+ } catch (err) {
154
+ const cause = err instanceof Error ? err : new Error(String(err));
155
+ FrameworkEvents.emit(
156
+ new MigrationRan(
157
+ entry.name,
158
+ "down",
159
+ Math.round(performance.now() - start),
160
+ false,
161
+ cause.message,
162
+ ),
163
+ );
164
+ throw err;
165
+ }
166
+ FrameworkEvents.emit(
167
+ new MigrationRan(entry.name, "down", Math.round(performance.now() - start), true),
168
+ );
169
+ await this._deleteRecord(entry.name);
170
+ rolledBack.push(entry.name);
171
+ }
172
+
173
+ return rolledBack;
174
+ }
175
+
176
+ /**
177
+ * Return names of migrations from `entries` that have not yet been run.
178
+ */
179
+ async pending(entries: MigrationEntry[]): Promise<string[]> {
180
+ await this._ensureTable();
181
+ const ran = await this._ranNames();
182
+ return entries.filter((e) => !ran.has(e.name)).map((e) => e.name);
183
+ }
184
+
185
+ /**
186
+ * Roll back every batch, running `down()` from newest to oldest.
187
+ */
188
+ async reset(entries: MigrationEntry[]): Promise<void> {
189
+ await this._ensureTable();
190
+ let batch = await this._lastBatch();
191
+ while (batch > 0) {
192
+ await this.rollback(entries);
193
+ batch = await this._lastBatch();
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Report which migrations from `entries` have run.
199
+ */
200
+ async status(entries: MigrationEntry[]): Promise<MigrationStatus[]> {
201
+ await this._ensureTable();
202
+ const records = await this._allRecords();
203
+ const byName = new Map(records.map((r) => [r.migration, r]));
204
+
205
+ return entries.map((e) => {
206
+ const rec = byName.get(e.name);
207
+ return rec
208
+ ? {
209
+ name: e.name,
210
+ ran: true,
211
+ batch: rec.batch,
212
+ ranAt: new Date(rec.ran_at),
213
+ }
214
+ : { name: e.name, ran: false };
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Load migration files from a directory, sort alphabetically, and run.
220
+ *
221
+ * Files must export a default class that extends Migration.
222
+ * Skips non-.ts files.
223
+ */
224
+ async runFromDirectory(dir: string): Promise<string[]> {
225
+ const entries = await this._loadDirectory(dir);
226
+ return this.run(entries);
227
+ }
228
+
229
+ /**
230
+ * Load migration files from a directory and roll back the last batch.
231
+ * @returns Names of the migrations that were rolled back.
232
+ */
233
+ async rollbackFromDirectory(dir: string): Promise<string[]> {
234
+ const entries = await this._loadDirectory(dir);
235
+ return this.rollback(entries);
236
+ }
237
+
238
+ // ── Private helpers ───────────────────────────────────────────────────────
239
+
240
+ private async _ensureTable(): Promise<void> {
241
+ // The tracking table is the very first DDL a project ever runs, so a SQLite-only
242
+ // `INTEGER PRIMARY KEY AUTOINCREMENT` here meant `migrate` against PostgreSQL or MySQL
243
+ // failed before it reached a single application migration.
244
+ const id = getDialect(dialectFor(this._conn)).autoIncrementColumn("id");
245
+ await this._ddl(`
246
+ CREATE TABLE IF NOT EXISTS ${this._table} (
247
+ ${id},
248
+ migration TEXT NOT NULL UNIQUE,
249
+ batch INTEGER NOT NULL,
250
+ ran_at TEXT NOT NULL
251
+ )
252
+ `);
253
+ }
254
+
255
+ private async _ranNames(): Promise<Set<string>> {
256
+ const rows = await this._select<{ migration: string }>(`SELECT migration FROM ${this._table}`);
257
+ return new Set(rows.map((r) => r.migration));
258
+ }
259
+
260
+ private async _lastBatch(): Promise<number> {
261
+ const rows = await this._select<{ b: number | null }>(
262
+ `SELECT MAX(batch) as b FROM ${this._table}`,
263
+ );
264
+ return rows[0]?.b ?? 0;
265
+ }
266
+
267
+ private async _namesForBatch(batch: number): Promise<string[]> {
268
+ const rows = await this._selectParam<{ migration: string }>(
269
+ `SELECT migration FROM ${this._table} WHERE batch = ? ORDER BY id ASC`,
270
+ batch,
271
+ );
272
+ return rows.map((r) => r.migration);
273
+ }
274
+
275
+ private async _allRecords(): Promise<MigrationRow[]> {
276
+ return this._select<MigrationRow>(
277
+ `SELECT id, migration, batch, ran_at FROM ${this._table} ORDER BY id ASC`,
278
+ );
279
+ }
280
+
281
+ private async _record(name: string, batch: number): Promise<void> {
282
+ const now = new Date().toISOString();
283
+ await this._exec(
284
+ `INSERT INTO ${this._table} (migration, batch, ran_at) VALUES (?, ?, ?)`,
285
+ name,
286
+ batch,
287
+ now,
288
+ );
289
+ }
290
+
291
+ private async _deleteRecord(name: string): Promise<void> {
292
+ await this._exec(`DELETE FROM ${this._table} WHERE migration = ?`, name);
293
+ }
294
+
295
+ /** Execute a DDL string with no parameters. */
296
+ private async _ddl(sql: string): Promise<void> {
297
+ const strings = [sql];
298
+ const tpl = Object.assign(strings, {
299
+ raw: strings,
300
+ }) as TemplateStringsArray;
301
+ await this._conn(tpl);
302
+ }
303
+
304
+ /** SELECT with no bound parameters. */
305
+ private async _select<T>(sql: string): Promise<T[]> {
306
+ return this._exec0<T>(sql);
307
+ }
308
+
309
+ /** SELECT with exactly one bound parameter. */
310
+ private async _selectParam<T>(sql: string, value: unknown): Promise<T[]> {
311
+ const parts = sql.split("?");
312
+ const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
313
+ return this._conn<T>(tpl, value);
314
+ }
315
+
316
+ /** DML with N bound parameters. */
317
+ private async _exec(sql: string, ...values: unknown[]): Promise<void> {
318
+ const parts = sql.split("?");
319
+ const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
320
+ await this._conn(tpl, ...values);
321
+ }
322
+
323
+ private async _exec0<T>(sql: string): Promise<T[]> {
324
+ const strings = [sql];
325
+ const tpl = Object.assign(strings, {
326
+ raw: strings,
327
+ }) as TemplateStringsArray;
328
+ return this._conn<T>(tpl);
329
+ }
330
+
331
+ private async _loadDirectory(dir: string): Promise<MigrationEntry[]> {
332
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
333
+ const { readdirSync } = require("node:fs") as typeof import("node:fs");
334
+ const files = readdirSync(dir)
335
+ .filter((f: string) => f.endsWith(".ts") || f.endsWith(".js"))
336
+ .sort();
337
+
338
+ const entries: MigrationEntry[] = [];
339
+ for (const file of files) {
340
+ const fullPath = `${dir}/${file}`;
341
+ // Dynamic import — each migration file must have a default export
342
+ const mod = (await import(fullPath)) as { default: new () => Migration };
343
+ const Ctor = mod.default;
344
+ entries.push({
345
+ name: file.replace(/\.(ts|js)$/, ""),
346
+ migration: new Ctor(),
347
+ });
348
+ }
349
+ return entries;
350
+ }
351
+ }
@@ -0,0 +1,133 @@
1
+ import path from "node:path";
2
+ import type { ColumnOptions } from "../model/decorators/column.ts";
3
+ import { columnRegistry, columnsFor } from "../model/decorators/_metadata.ts";
4
+
5
+ // ── Model schema descriptor ───────────────────────────────────────────────────
6
+
7
+ export interface ModelColumn {
8
+ name: string;
9
+ type: ColumnOptions["type"]; // 'string' | 'number' | 'boolean' | 'datetime' | 'json'
10
+ nullable: boolean;
11
+ primary: boolean;
12
+ default: unknown;
13
+ }
14
+
15
+ export interface ModelSchema {
16
+ table: string;
17
+ primaryKey: string;
18
+ timestamps: boolean;
19
+ softDeletes: boolean;
20
+ columns: ModelColumn[];
21
+ }
22
+
23
+ /**
24
+ * Map a model property name to its database column name. Models declare columns in camelCase
25
+ * (`twoFactorSecret`) but the ORM reads/writes snake_case (`two_factor_secret`) — see
26
+ * BaseModel's `toSnake`. Schema generation (synchronize, migrate:generate) and schema diffing
27
+ * MUST use this so generated/compared column names match the runtime convention. Idempotent for
28
+ * names that are already snake_case.
29
+ */
30
+ export function columnDbName(name: string): string {
31
+ return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
32
+ }
33
+
34
+ // ── Prototype-chain column collection ─────────────────────────────────────────
35
+
36
+ /**
37
+ * Walk up the prototype chain of `ctor`, collecting `@column()` definitions
38
+ * from every ancestor that has an entry in `columnRegistry`.
39
+ *
40
+ * Child-class columns win over parent-class columns when names collide —
41
+ * the walk stops at `Function.prototype` (the top of the JS class hierarchy).
42
+ *
43
+ * This handles `AdminUser extends User extends BaseModel` correctly: columns
44
+ * declared on `User` appear in `AdminUser`'s schema without needing to be
45
+ * re-declared.
46
+ */
47
+ function collectColumns(ctor: Function): Map<string, ColumnOptions> | null {
48
+ // columnsFor walks the prototype chain (child overrides parent) and mirrors each
49
+ // class's metadata into columnRegistry on first read.
50
+ return columnsFor(ctor);
51
+ }
52
+
53
+ function toModelColumns(fields: Map<string, ColumnOptions>): ModelColumn[] {
54
+ const columns: ModelColumn[] = [];
55
+ for (const [name, opts] of fields.entries()) {
56
+ columns.push({
57
+ name,
58
+ type: opts.type,
59
+ nullable: opts.nullable ?? false,
60
+ primary: opts.primary ?? false,
61
+ default: opts.default,
62
+ });
63
+ }
64
+ return columns;
65
+ }
66
+
67
+ // ── ModelInspector ────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Reads model class metadata from `columnRegistry` (populated when a model's `@table`
71
+ * decorator drains its queued `@column` registrations at class-definition time) and
72
+ * returns a structured schema description for each registered model.
73
+ *
74
+ * Prototype-chain walking: columns declared on a parent class are inherited
75
+ * by child classes, matching normal TypeScript class semantics.
76
+ *
77
+ * Used by `migrate:generate` to compare model intent against the live DB.
78
+ */
79
+ export const ModelInspector = {
80
+ /**
81
+ * Dynamically import all files matching `pattern` (relative to `cwd`).
82
+ * Importing a model file runs its `@table` decorator, which registers the class's
83
+ * columns into `columnRegistry` at definition time — so `all()` can enumerate them.
84
+ */
85
+ async load(pattern: string, cwd = process.cwd()): Promise<void> {
86
+ const glob = new Bun.Glob(pattern);
87
+ const files = await Array.fromAsync(glob.scan({ cwd }));
88
+ for (const file of files) {
89
+ await import(path.resolve(cwd, file));
90
+ }
91
+ },
92
+
93
+ /**
94
+ * Return a `ModelSchema` for every class registered in `columnRegistry`
95
+ * that has a non-empty `static table` property.
96
+ *
97
+ * Skips anonymous classes, the `BaseModel` base class, and any class that
98
+ * hasn't set `static table`. Walks the prototype chain so that inherited
99
+ * columns are included automatically.
100
+ */
101
+ all(): ModelSchema[] {
102
+ const schemas: ModelSchema[] = [];
103
+
104
+ for (const [ctor] of columnRegistry.entries()) {
105
+ const schema = ModelInspector.fromClass(ctor);
106
+ if (schema) schemas.push(schema);
107
+ }
108
+
109
+ return schemas;
110
+ },
111
+
112
+ /**
113
+ * Read the schema for a single model class.
114
+ * Returns null if the class has no `static table` or no `@column()` fields
115
+ * anywhere in its prototype chain.
116
+ */
117
+ fromClass(ctor: Function): ModelSchema | null {
118
+ const M = ctor as unknown as Record<string, unknown>;
119
+ const table = M["table"] as string | undefined;
120
+ if (!table) return null;
121
+
122
+ const fields = collectColumns(ctor);
123
+ if (!fields) return null;
124
+
125
+ return {
126
+ table,
127
+ primaryKey: (M["primaryKey"] as string | undefined) ?? "id",
128
+ timestamps: (M["timestamps"] as boolean | undefined) ?? true,
129
+ softDeletes: (M["softDeletes"] as boolean | undefined) ?? false,
130
+ columns: toModelColumns(fields),
131
+ };
132
+ },
133
+ };
@@ -0,0 +1,140 @@
1
+ import { _getDbConnection } from "../db/DB.ts";
2
+ import { _getDialect } from "../model/BaseModel.ts";
3
+ import { getDialect } from "../db/dialects/index.ts";
4
+ import { Blueprint } from "./Blueprint.ts";
5
+
6
+ // ── Helpers ───────────────────────────────────────────────────────────────────
7
+
8
+ /**
9
+ * Execute a DDL statement that contains no bound parameters.
10
+ * Constructs a TemplateStringsArray from a plain string so we can call the
11
+ * Bun SQL tagged-template function without interpolating anything.
12
+ */
13
+ async function ddl(sql: string): Promise<void> {
14
+ const conn = _getDbConnection();
15
+ const strings = [sql];
16
+ const tpl = Object.assign(strings, { raw: strings }) as TemplateStringsArray;
17
+ await conn(tpl);
18
+ }
19
+
20
+ /**
21
+ * Execute a query with bound `?` parameters, returning rows.
22
+ * We need this for parameterised introspection queries (hasTable, hasColumn).
23
+ */
24
+ async function query<T = Record<string, unknown>>(sql: string, params: unknown[]): Promise<T[]> {
25
+ const conn = _getDbConnection();
26
+ const parts = sql.split("?");
27
+ const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
28
+ return conn<T>(tpl, ...params);
29
+ }
30
+
31
+ // ── Schema facade ─────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * The schema-builder facade — the entry point used inside migration `up()`/`down()`
35
+ * methods to issue DDL against the active `Bun.sql` connection.
36
+ *
37
+ * Each mutating helper constructs a {@link Blueprint}, runs the caller's callback to
38
+ * record the desired columns/indexes/constraints, compiles the blueprint to SQL,
39
+ * and executes the statements. Introspection helpers ({@link Schema.hasTable},
40
+ * {@link Schema.hasColumn}) are dialect-aware and use bound parameters.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * // Create
45
+ * await Schema.create('users', (table) => {
46
+ * table.id();
47
+ * table.string('email').unique();
48
+ * table.timestamps();
49
+ * });
50
+ *
51
+ * // Alter
52
+ * await Schema.table('users', (table) => {
53
+ * table.string('name').nullable();
54
+ * });
55
+ *
56
+ * // Drop
57
+ * await Schema.drop('users');
58
+ * ```
59
+ */
60
+ export const Schema = {
61
+ /**
62
+ * Create a table: `CREATE TABLE table_name ( … )`.
63
+ *
64
+ * @param table - Table name.
65
+ * @param callback - Receives a {@link Blueprint} to define columns/indexes/constraints.
66
+ * @throws Rejects if the table already exists — use {@link Schema.createIfNotExists}
67
+ * for idempotent runs.
68
+ */
69
+ async create(table: string, callback: (bp: Blueprint) => void): Promise<void> {
70
+ const bp = new Blueprint();
71
+ callback(bp);
72
+ for (const sql of bp.toCreateSQL(table, _getDialect())) {
73
+ await ddl(sql);
74
+ }
75
+ },
76
+
77
+ /**
78
+ * Idempotent create: `CREATE TABLE IF NOT EXISTS table_name ( … )` followed by
79
+ * each index statement. Safe to run repeatedly.
80
+ */
81
+ async createIfNotExists(table: string, callback: (bp: Blueprint) => void): Promise<void> {
82
+ const bp = new Blueprint();
83
+ callback(bp);
84
+ const [create, ...indexes] = bp.toCreateSQL(table, _getDialect());
85
+ if (create) {
86
+ await ddl(create.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS "));
87
+ }
88
+ for (const idx of indexes) await ddl(idx);
89
+ },
90
+
91
+ /**
92
+ * Modify an existing table: `ALTER TABLE ADD COLUMN / DROP COLUMN /
93
+ * RENAME COLUMN` plus `CREATE INDEX IF NOT EXISTS`. The blueprint is compiled
94
+ * for the connection's active dialect (see {@link Blueprint.toAlterSQL}).
95
+ */
96
+ async table(name: string, callback: (bp: Blueprint) => void): Promise<void> {
97
+ const bp = new Blueprint();
98
+ callback(bp);
99
+ for (const sql of bp.toAlterSQL(name, _getDialect())) {
100
+ await ddl(sql);
101
+ }
102
+ },
103
+
104
+ /** `DROP TABLE table_name` */
105
+ async drop(table: string): Promise<void> {
106
+ await ddl(`DROP TABLE ${table}`);
107
+ },
108
+
109
+ /** `DROP TABLE IF EXISTS table_name` */
110
+ async dropIfExists(table: string): Promise<void> {
111
+ await ddl(`DROP TABLE IF EXISTS ${table}`);
112
+ },
113
+
114
+ /** `ALTER TABLE from RENAME TO to` */
115
+ async rename(from: string, to: string): Promise<void> {
116
+ await ddl(`ALTER TABLE ${from} RENAME TO ${to}`);
117
+ },
118
+
119
+ /**
120
+ * Returns true if the table exists in the current schema.
121
+ * Dialect-aware: sqlite_master on SQLite, information_schema on
122
+ * PostgreSQL/MySQL.
123
+ */
124
+ async hasTable(table: string): Promise<boolean> {
125
+ const { sql, params } = getDialect(_getDialect()).hasTableSql(table);
126
+ const rows = await query(sql, params);
127
+ return rows.length > 0;
128
+ },
129
+
130
+ /**
131
+ * Returns true if `column` exists in `table`.
132
+ * Dialect-aware: pragma_table_info() on SQLite, information_schema on
133
+ * PostgreSQL/MySQL. All inputs are bound parameters — never inlined.
134
+ */
135
+ async hasColumn(table: string, column: string): Promise<boolean> {
136
+ const { sql, params } = getDialect(_getDialect()).hasColumnSql(table, column);
137
+ const rows = await query(sql, params);
138
+ return rows.length > 0;
139
+ },
140
+ };