tempest-db-js 0.2.0 → 0.4.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.
@@ -4,23 +4,38 @@
4
4
  function isDefaultValue(value) {
5
5
  return typeof value === "object" && value !== null && "kind" in value && (value.kind === "literal" || value.kind === "expression");
6
6
  }
7
+ function parseReference(ref, options) {
8
+ const dot = ref.lastIndexOf(".");
9
+ if (dot <= 0 || dot === ref.length - 1) {
10
+ throw new Error(`Invalid foreign key reference "${ref}"; expected "table.column".`);
11
+ }
12
+ return {
13
+ table: ref.slice(0, dot),
14
+ column: ref.slice(dot + 1),
15
+ onDelete: options?.onDelete,
16
+ onUpdate: options?.onUpdate
17
+ };
18
+ }
7
19
  var Column = class _Column {
8
- constructor(type, flags, defaultValue = null, onUpdateValue = null) {
20
+ constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null) {
9
21
  this.type = type;
10
22
  this.flags = flags;
11
23
  this.defaultValue = defaultValue;
12
24
  this.onUpdateValue = onUpdateValue;
25
+ this.reference = reference;
13
26
  }
14
27
  type;
15
28
  flags;
16
29
  defaultValue;
17
30
  onUpdateValue;
31
+ reference;
18
32
  primaryKey() {
19
33
  return new _Column(
20
34
  this.type,
21
35
  { ...this.flags, primaryKey: true, hasDefault: true },
22
36
  this.defaultValue,
23
- this.onUpdateValue
37
+ this.onUpdateValue,
38
+ this.reference
24
39
  );
25
40
  }
26
41
  notNull() {
@@ -28,7 +43,40 @@ var Column = class _Column {
28
43
  this.type,
29
44
  { ...this.flags, notNull: true },
30
45
  this.defaultValue,
31
- this.onUpdateValue
46
+ this.onUpdateValue,
47
+ this.reference
48
+ );
49
+ }
50
+ /**
51
+ * Add a `UNIQUE` constraint to the column (mirrors SQLAlchemy's
52
+ * `mapped_column(unique=True)`). DDL-only — does not change the inferred type.
53
+ */
54
+ unique() {
55
+ return new _Column(
56
+ this.type,
57
+ { ...this.flags, unique: true },
58
+ this.defaultValue,
59
+ this.onUpdateValue,
60
+ this.reference
61
+ );
62
+ }
63
+ /**
64
+ * Declare a foreign-key reference to another table's column, à la SQLAlchemy's
65
+ * `mapped_column(ForeignKey("table.column", ondelete=...))`. DDL-only — does
66
+ * not change the inferred type.
67
+ *
68
+ * @param ref The target as `"table.column"` (e.g. `"users.id"`).
69
+ * @param options Optional `onDelete` / `onUpdate` referential actions.
70
+ * @returns A new column carrying the reference.
71
+ * @throws Error When `ref` is not a valid `"table.column"` string.
72
+ */
73
+ references(ref, options) {
74
+ return new _Column(
75
+ this.type,
76
+ this.flags,
77
+ this.defaultValue,
78
+ this.onUpdateValue,
79
+ parseReference(ref, options)
32
80
  );
33
81
  }
34
82
  /**
@@ -41,7 +89,8 @@ var Column = class _Column {
41
89
  this.type,
42
90
  { ...this.flags, hasDefault: true },
43
91
  resolved,
44
- this.onUpdateValue
92
+ this.onUpdateValue,
93
+ this.reference
45
94
  );
46
95
  }
47
96
  /**
@@ -50,7 +99,7 @@ var Column = class _Column {
50
99
  */
51
100
  onUpdate(value) {
52
101
  const resolved = isDefaultValue(value) ? value : { kind: "literal", value };
53
- return new _Column(this.type, this.flags, this.defaultValue, resolved);
102
+ return new _Column(this.type, this.flags, this.defaultValue, resolved, this.reference);
54
103
  }
55
104
  };
56
105
  var columnsCache = /* @__PURE__ */ new WeakMap();
@@ -69,6 +118,9 @@ function columnsOf(model) {
69
118
  }
70
119
 
71
120
  // src/migrations/ir.ts
121
+ function constraintName(prefix, table, columns) {
122
+ return `${prefix}_${table}_${columns.join("_")}`;
123
+ }
72
124
  function reflectTable(model) {
73
125
  const columns = {};
74
126
  const primaryKey = [];
@@ -79,11 +131,32 @@ function reflectTable(model) {
79
131
  type: col.type,
80
132
  notNull: col.flags.notNull || isPk,
81
133
  primaryKey: isPk,
82
- default: col.defaultValue
134
+ default: col.defaultValue,
135
+ unique: col.flags.unique,
136
+ references: col.reference
83
137
  };
84
138
  if (isPk) primaryKey.push(name);
85
139
  }
86
- return { name: model.tablename, columns, primaryKey };
140
+ const uniqueConstraints = [];
141
+ const foreignKeys = [];
142
+ for (const c of model.tableArgs?.() ?? []) {
143
+ if (c.kind === "unique") {
144
+ uniqueConstraints.push({
145
+ name: c.name ?? constraintName("uq", model.tablename, c.columns),
146
+ columns: c.columns
147
+ });
148
+ } else {
149
+ foreignKeys.push({
150
+ name: c.name ?? constraintName("fk", model.tablename, c.columns),
151
+ columns: c.columns,
152
+ refTable: c.refTable,
153
+ refColumns: c.refColumns,
154
+ onDelete: c.onDelete,
155
+ onUpdate: c.onUpdate
156
+ });
157
+ }
158
+ }
159
+ return { name: model.tablename, columns, primaryKey, uniqueConstraints, foreignKeys };
87
160
  }
88
161
  function reflectSchema(models) {
89
162
  const tables = {};
@@ -128,6 +201,10 @@ function invert(op) {
128
201
  return { kind: "rename_column", table: op.table, from: op.to, to: op.from };
129
202
  case "recreate_table":
130
203
  return { kind: "recreate_table", from: op.to, to: op.from };
204
+ case "add_constraint":
205
+ return { kind: "drop_constraint", table: op.table, constraint: op.constraint };
206
+ case "drop_constraint":
207
+ return { kind: "add_constraint", table: op.table, constraint: op.constraint };
131
208
  case "execute":
132
209
  if (op.down === null) {
133
210
  throw new IrreversibleMigration("execute() operation has no down SQL");
@@ -140,8 +217,8 @@ function invertAll(ops) {
140
217
  }
141
218
 
142
219
  // src/migrations/ddl.ts
143
- function quoteId(name) {
144
- return `"${name.replace(/"/g, '""')}"`;
220
+ function quoteId(name, dialect) {
221
+ return dialect === "mysql" ? `\`${name.replace(/`/g, "``")}\`` : `"${name.replace(/"/g, '""')}"`;
145
222
  }
146
223
  function quoteLiteral(value) {
147
224
  return `'${value.replace(/'/g, "''")}'`;
@@ -166,6 +243,45 @@ function renderColumnType(type, dialect) {
166
243
  return "TEXT";
167
244
  }
168
245
  }
246
+ if (dialect === "mysql") {
247
+ switch (kind) {
248
+ case "smallint":
249
+ return "SMALLINT";
250
+ case "integer":
251
+ return "INT";
252
+ case "bigint":
253
+ return "BIGINT";
254
+ case "numeric":
255
+ return meta.precision !== void 0 ? `DECIMAL(${meta.precision}${meta.scale !== void 0 ? `, ${meta.scale}` : ""})` : "DECIMAL";
256
+ case "real":
257
+ return "FLOAT";
258
+ case "double":
259
+ return "DOUBLE";
260
+ case "varchar":
261
+ return `VARCHAR(${meta.length ?? 255})`;
262
+ case "char":
263
+ return `CHAR(${meta.length ?? 255})`;
264
+ case "text":
265
+ return "TEXT";
266
+ case "boolean":
267
+ return "TINYINT(1)";
268
+ case "date":
269
+ return "DATE";
270
+ case "time":
271
+ return "TIME";
272
+ case "datetime":
273
+ case "timestamp":
274
+ return "DATETIME";
275
+ case "blob":
276
+ return "BLOB";
277
+ case "json":
278
+ return "JSON";
279
+ case "uuid":
280
+ return "CHAR(36)";
281
+ case "enum":
282
+ return `ENUM(${(meta.values ?? []).map(quoteLiteral).join(", ")})`;
283
+ }
284
+ }
169
285
  switch (kind) {
170
286
  case "smallint":
171
287
  return "SMALLINT";
@@ -210,29 +326,66 @@ function renderDefault(def, dialect) {
210
326
  if (typeof expr === "object") return expr.raw;
211
327
  switch (expr) {
212
328
  case "now":
213
- return dialect === "sqlite" ? "CURRENT_TIMESTAMP" : "now()";
329
+ return dialect === "postgresql" ? "now()" : "CURRENT_TIMESTAMP";
214
330
  case "current_date":
215
331
  return "CURRENT_DATE";
216
332
  case "current_time":
217
333
  return "CURRENT_TIME";
218
334
  case "uuidv4":
219
- return dialect === "sqlite" ? "(lower(hex(randomblob(16))))" : "gen_random_uuid()";
335
+ if (dialect === "postgresql") return "gen_random_uuid()";
336
+ if (dialect === "mysql") return "(UUID())";
337
+ return "(lower(hex(randomblob(16))))";
220
338
  }
221
339
  }
222
340
  const value = def.value;
223
341
  if (value === null) return "NULL";
224
342
  if (typeof value === "boolean") {
225
- return dialect === "sqlite" ? value ? "1" : "0" : value ? "TRUE" : "FALSE";
343
+ return dialect === "postgresql" ? value ? "TRUE" : "FALSE" : value ? "1" : "0";
226
344
  }
227
345
  if (typeof value === "number" || typeof value === "bigint") return String(value);
228
346
  if (value instanceof Date) return quoteLiteral(value.toISOString());
229
347
  if (typeof value === "object") return quoteLiteral(JSON.stringify(value));
230
348
  return quoteLiteral(String(value));
231
349
  }
350
+ function renderFkAction(action) {
351
+ return action.toUpperCase();
352
+ }
353
+ function renderFkActions(fk) {
354
+ let sql = "";
355
+ if (fk.onDelete) sql += ` ON DELETE ${renderFkAction(fk.onDelete)}`;
356
+ if (fk.onUpdate) sql += ` ON UPDATE ${renderFkAction(fk.onUpdate)}`;
357
+ return sql;
358
+ }
359
+ function columnConstraintSuffix(col, dialect) {
360
+ let sql = "";
361
+ if (col.unique) sql += " UNIQUE";
362
+ if (col.references) {
363
+ const ref = col.references;
364
+ sql += ` REFERENCES ${quoteId(ref.table, dialect)} (${quoteId(ref.column, dialect)})`;
365
+ sql += renderFkActions(ref);
366
+ }
367
+ return sql;
368
+ }
369
+ function renderUniqueConstraint(uc, dialect) {
370
+ const cols = uc.columns.map((c) => quoteId(c, dialect)).join(", ");
371
+ return `CONSTRAINT ${quoteId(uc.name, dialect)} UNIQUE (${cols})`;
372
+ }
373
+ function renderForeignKeyConstraint(fk, dialect) {
374
+ const cols = fk.columns.map((c) => quoteId(c, dialect)).join(", ");
375
+ const refCols = fk.refColumns.map((c) => quoteId(c, dialect)).join(", ");
376
+ return `CONSTRAINT ${quoteId(fk.name, dialect)} FOREIGN KEY (${cols}) REFERENCES ${quoteId(fk.refTable, dialect)} (${refCols})${renderFkActions(fk)}`;
377
+ }
378
+ function tableConstraintClauses(table, dialect) {
379
+ return [
380
+ ...table.uniqueConstraints.map((uc) => renderUniqueConstraint(uc, dialect)),
381
+ ...table.foreignKeys.map((fk) => renderForeignKeyConstraint(fk, dialect))
382
+ ];
383
+ }
232
384
  function renderColumnDef(col, dialect) {
233
- let sql = `${quoteId(col.name)} ${renderColumnType(col.type, dialect)}`;
385
+ let sql = `${quoteId(col.name, dialect)} ${renderColumnType(col.type, dialect)}`;
234
386
  if (col.notNull) sql += " NOT NULL";
235
387
  if (col.default !== null) sql += ` DEFAULT ${renderDefault(col.default, dialect)}`;
388
+ sql += columnConstraintSuffix(col, dialect);
236
389
  return sql;
237
390
  }
238
391
  function enumTypeName(table, column) {
@@ -252,23 +405,29 @@ function renderCreateTable(table, dialect) {
252
405
  if (dialect === "postgresql" && c.type.kind === "enum") {
253
406
  const typeName = enumTypeName(table.name, c.name);
254
407
  const values = (c.type.meta.values ?? []).map(quoteLiteral).join(", ");
255
- typeStmts.push(`CREATE TYPE ${quoteId(typeName)} AS ENUM (${values})`);
256
- let def = `${quoteId(c.name)} ${quoteId(typeName)}`;
408
+ typeStmts.push(`CREATE TYPE ${quoteId(typeName, dialect)} AS ENUM (${values})`);
409
+ let def = `${quoteId(c.name, dialect)} ${quoteId(typeName, dialect)}`;
257
410
  if (c.notNull) def += " NOT NULL";
258
411
  if (c.default !== null) def += ` DEFAULT ${renderDefault(c.default, dialect)}`;
259
- return def;
412
+ return def + columnConstraintSuffix(c, dialect);
260
413
  }
261
414
  if (dialect === "postgresql" && isAutoIncrementPk(table, c)) {
262
- return `${quoteId(c.name)} ${postgresSerialType(c.type.kind)}`;
415
+ return `${quoteId(c.name, dialect)} ${postgresSerialType(c.type.kind)}${columnConstraintSuffix(c, dialect)}`;
416
+ }
417
+ if (dialect === "mysql" && isAutoIncrementPk(table, c)) {
418
+ return `${quoteId(c.name, dialect)} ${renderColumnType(c.type, dialect)} NOT NULL AUTO_INCREMENT${columnConstraintSuffix(c, dialect)}`;
263
419
  }
264
420
  return renderColumnDef(c, dialect);
265
421
  });
266
422
  if (table.primaryKey.length > 0) {
267
- cols.push(`PRIMARY KEY (${table.primaryKey.map(quoteId).join(", ")})`);
423
+ cols.push(
424
+ `PRIMARY KEY (${table.primaryKey.map((c) => quoteId(c, dialect)).join(", ")})`
425
+ );
268
426
  }
427
+ cols.push(...tableConstraintClauses(table, dialect));
269
428
  return [
270
429
  ...typeStmts,
271
- `CREATE TABLE ${quoteId(table.name)} (
430
+ `CREATE TABLE ${quoteId(table.name, dialect)} (
272
431
  ${cols.join(",\n ")}
273
432
  )`
274
433
  ];
@@ -278,60 +437,97 @@ function renderOperation(op, dialect) {
278
437
  case "create_table":
279
438
  return renderCreateTable(op.table, dialect);
280
439
  case "drop_table":
281
- return [`DROP TABLE ${quoteId(op.table.name)}`];
440
+ return [`DROP TABLE ${quoteId(op.table.name, dialect)}`];
282
441
  case "rename_table":
283
- return [`ALTER TABLE ${quoteId(op.from)} RENAME TO ${quoteId(op.to)}`];
442
+ return dialect === "mysql" ? [`RENAME TABLE ${quoteId(op.from, dialect)} TO ${quoteId(op.to, dialect)}`] : [
443
+ `ALTER TABLE ${quoteId(op.from, dialect)} RENAME TO ${quoteId(op.to, dialect)}`
444
+ ];
284
445
  case "add_column":
285
446
  return [
286
- `ALTER TABLE ${quoteId(op.table)} ADD COLUMN ${renderColumnDef(op.column, dialect)}`
447
+ `ALTER TABLE ${quoteId(op.table, dialect)} ADD COLUMN ${renderColumnDef(op.column, dialect)}`
287
448
  ];
288
449
  case "drop_column":
289
- return [`ALTER TABLE ${quoteId(op.table)} DROP COLUMN ${quoteId(op.column.name)}`];
450
+ return [
451
+ `ALTER TABLE ${quoteId(op.table, dialect)} DROP COLUMN ${quoteId(op.column.name, dialect)}`
452
+ ];
290
453
  case "rename_column":
291
454
  return [
292
- `ALTER TABLE ${quoteId(op.table)} RENAME COLUMN ${quoteId(op.from)} TO ${quoteId(op.to)}`
455
+ `ALTER TABLE ${quoteId(op.table, dialect)} RENAME COLUMN ${quoteId(op.from, dialect)} TO ${quoteId(op.to, dialect)}`
293
456
  ];
294
457
  case "alter_column":
295
458
  return renderAlterColumn(op.table, op.to, dialect);
296
459
  case "recreate_table":
297
- return dialect === "sqlite" ? renderSqliteRebuild(op.from, op.to) : renderPostgresTableDiff(op.from, op.to);
460
+ return dialect === "sqlite" ? renderSqliteRebuild(op.from, op.to) : renderTableDiff(op.from, op.to, dialect);
461
+ case "add_constraint":
462
+ return renderAddConstraint(op.table, op.constraint, dialect);
463
+ case "drop_constraint":
464
+ return renderDropConstraint(op.table, op.constraint, dialect);
298
465
  case "execute":
299
466
  return [op.up];
300
467
  }
301
468
  }
469
+ function renderAddConstraint(table, constraint, dialect) {
470
+ if (dialect === "sqlite") {
471
+ throw new Error(
472
+ `add_constraint on SQLite needs a table-rebuild (recreate_table); constraint ${constraint.constraint.name} on ${table}`
473
+ );
474
+ }
475
+ const clause = constraint.type === "unique" ? renderUniqueConstraint(constraint.constraint, dialect) : renderForeignKeyConstraint(constraint.constraint, dialect);
476
+ return [`ALTER TABLE ${quoteId(table, dialect)} ADD ${clause}`];
477
+ }
478
+ function renderDropConstraint(table, constraint, dialect) {
479
+ if (dialect === "sqlite") {
480
+ throw new Error(
481
+ `drop_constraint on SQLite needs a table-rebuild (recreate_table); constraint ${constraint.constraint.name} on ${table}`
482
+ );
483
+ }
484
+ const t = quoteId(table, dialect);
485
+ const name = quoteId(constraint.constraint.name, dialect);
486
+ if (dialect === "mysql") {
487
+ return [
488
+ constraint.type === "unique" ? `ALTER TABLE ${t} DROP INDEX ${name}` : `ALTER TABLE ${t} DROP FOREIGN KEY ${name}`
489
+ ];
490
+ }
491
+ return [`ALTER TABLE ${t} DROP CONSTRAINT ${name}`];
492
+ }
302
493
  function renderSqliteRebuild(from, to) {
303
494
  const tmp = `__new_${to.name}`;
304
495
  const common = Object.keys(to.columns).filter((c) => c in from.columns);
305
496
  const cols = Object.values(to.columns).map((c) => renderColumnDef(c, "sqlite"));
306
497
  if (to.primaryKey.length > 0) {
307
- cols.push(`PRIMARY KEY (${to.primaryKey.map(quoteId).join(", ")})`);
498
+ cols.push(
499
+ `PRIMARY KEY (${to.primaryKey.map((c) => quoteId(c, "sqlite")).join(", ")})`
500
+ );
308
501
  }
309
- const commonSql = common.map(quoteId).join(", ");
502
+ cols.push(...tableConstraintClauses(to, "sqlite"));
503
+ const commonSql = common.map((c) => quoteId(c, "sqlite")).join(", ");
310
504
  return [
311
505
  "PRAGMA foreign_keys=off",
312
- `CREATE TABLE ${quoteId(tmp)} (
506
+ `CREATE TABLE ${quoteId(tmp, "sqlite")} (
313
507
  ${cols.join(",\n ")}
314
508
  )`,
315
- common.length > 0 ? `INSERT INTO ${quoteId(tmp)} (${commonSql}) SELECT ${commonSql} FROM ${quoteId(from.name)}` : `-- no common columns to copy from ${from.name}`,
316
- `DROP TABLE ${quoteId(from.name)}`,
317
- `ALTER TABLE ${quoteId(tmp)} RENAME TO ${quoteId(to.name)}`,
509
+ common.length > 0 ? `INSERT INTO ${quoteId(tmp, "sqlite")} (${commonSql}) SELECT ${commonSql} FROM ${quoteId(from.name, "sqlite")}` : `-- no common columns to copy from ${from.name}`,
510
+ `DROP TABLE ${quoteId(from.name, "sqlite")}`,
511
+ `ALTER TABLE ${quoteId(tmp, "sqlite")} RENAME TO ${quoteId(to.name, "sqlite")}`,
318
512
  "PRAGMA foreign_keys=on"
319
513
  ];
320
514
  }
321
- function renderPostgresTableDiff(from, to) {
515
+ function renderTableDiff(from, to, dialect) {
322
516
  const stmts = [];
323
517
  for (const [name, col] of Object.entries(to.columns)) {
324
518
  if (!(name in from.columns)) {
325
519
  stmts.push(
326
- `ALTER TABLE ${quoteId(to.name)} ADD COLUMN ${renderColumnDef(col, "postgresql")}`
520
+ `ALTER TABLE ${quoteId(to.name, dialect)} ADD COLUMN ${renderColumnDef(col, dialect)}`
327
521
  );
328
522
  } else {
329
- stmts.push(...renderAlterColumn(to.name, col, "postgresql"));
523
+ stmts.push(...renderAlterColumn(to.name, col, dialect));
330
524
  }
331
525
  }
332
526
  for (const name of Object.keys(from.columns)) {
333
527
  if (!(name in to.columns)) {
334
- stmts.push(`ALTER TABLE ${quoteId(to.name)} DROP COLUMN ${quoteId(name)}`);
528
+ stmts.push(
529
+ `ALTER TABLE ${quoteId(to.name, dialect)} DROP COLUMN ${quoteId(name, dialect)}`
530
+ );
335
531
  }
336
532
  }
337
533
  return stmts;
@@ -339,11 +535,16 @@ function renderPostgresTableDiff(from, to) {
339
535
  function renderAlterColumn(table, to, dialect) {
340
536
  if (dialect === "sqlite") {
341
537
  throw new Error(
342
- `alter_column on SQLite needs batch/table-rebuild (Phase 6e); column ${table}.${to.name}`
538
+ `alter_column on SQLite needs a table-rebuild (recreate_table); column ${table}.${to.name}`
343
539
  );
344
540
  }
345
- const t = quoteId(table);
346
- const c = quoteId(to.name);
541
+ if (dialect === "mysql") {
542
+ return [
543
+ `ALTER TABLE ${quoteId(table, dialect)} MODIFY COLUMN ${renderColumnDef(to, dialect)}`
544
+ ];
545
+ }
546
+ const t = quoteId(table, dialect);
547
+ const c = quoteId(to.name, dialect);
347
548
  const stmts = [
348
549
  `ALTER TABLE ${t} ALTER COLUMN ${c} TYPE ${renderColumnType(to.type, dialect)}`
349
550
  ];
@@ -362,9 +563,74 @@ function columnSignature(col) {
362
563
  type: col.type,
363
564
  notNull: col.notNull,
364
565
  primaryKey: col.primaryKey,
365
- default: col.default
566
+ default: col.default,
567
+ unique: col.unique,
568
+ references: col.references
366
569
  });
367
570
  }
571
+ function uniqueSignature(uc) {
572
+ return JSON.stringify({ columns: uc.columns });
573
+ }
574
+ function foreignKeySignature(fk) {
575
+ return JSON.stringify({
576
+ columns: fk.columns,
577
+ refTable: fk.refTable,
578
+ refColumns: fk.refColumns,
579
+ onDelete: fk.onDelete ?? null,
580
+ onUpdate: fk.onUpdate ?? null
581
+ });
582
+ }
583
+ function diffConstraints(current, target) {
584
+ const ops = [];
585
+ const table = target.name;
586
+ const currentUq = new Map(current.uniqueConstraints.map((u) => [u.name, u]));
587
+ const targetUq = new Map(target.uniqueConstraints.map((u) => [u.name, u]));
588
+ for (const [name, cur] of currentUq) {
589
+ const tgt = targetUq.get(name);
590
+ if (!tgt || uniqueSignature(cur) !== uniqueSignature(tgt)) {
591
+ ops.push(dropUnique(table, cur));
592
+ }
593
+ }
594
+ for (const [name, tgt] of targetUq) {
595
+ const cur = currentUq.get(name);
596
+ if (!cur || uniqueSignature(cur) !== uniqueSignature(tgt)) {
597
+ ops.push(addUnique(table, tgt));
598
+ }
599
+ }
600
+ const currentFk = new Map(current.foreignKeys.map((f) => [f.name, f]));
601
+ const targetFk = new Map(target.foreignKeys.map((f) => [f.name, f]));
602
+ for (const [name, cur] of currentFk) {
603
+ const tgt = targetFk.get(name);
604
+ if (!tgt || foreignKeySignature(cur) !== foreignKeySignature(tgt)) {
605
+ ops.push(dropForeignKey(table, cur));
606
+ }
607
+ }
608
+ for (const [name, tgt] of targetFk) {
609
+ const cur = currentFk.get(name);
610
+ if (!cur || foreignKeySignature(cur) !== foreignKeySignature(tgt)) {
611
+ ops.push(addForeignKey(table, tgt));
612
+ }
613
+ }
614
+ return ops;
615
+ }
616
+ function uniqueNamed(uc) {
617
+ return { type: "unique", constraint: uc };
618
+ }
619
+ function foreignKeyNamed(fk) {
620
+ return { type: "foreignKey", constraint: fk };
621
+ }
622
+ function addUnique(table, uc) {
623
+ return { kind: "add_constraint", table, constraint: uniqueNamed(uc) };
624
+ }
625
+ function dropUnique(table, uc) {
626
+ return { kind: "drop_constraint", table, constraint: uniqueNamed(uc) };
627
+ }
628
+ function addForeignKey(table, fk) {
629
+ return { kind: "add_constraint", table, constraint: foreignKeyNamed(fk) };
630
+ }
631
+ function dropForeignKey(table, fk) {
632
+ return { kind: "drop_constraint", table, constraint: foreignKeyNamed(fk) };
633
+ }
368
634
  function diffSchema(current, target) {
369
635
  const ops = [];
370
636
  const drops = [];
@@ -393,6 +659,7 @@ function diffSchema(current, target) {
393
659
  ops.push({ kind: "drop_column", table: name, column: currentCol });
394
660
  }
395
661
  }
662
+ ops.push(...diffConstraints(currentTable, targetTable));
396
663
  }
397
664
  for (const [name, currentTable] of Object.entries(current.tables)) {
398
665
  if (!target.tables[name]) {
@@ -535,6 +802,14 @@ var Op = class {
535
802
  recreateTable(from, to) {
536
803
  this.run({ kind: "recreate_table", from, to });
537
804
  }
805
+ /** Add a table-level unique / foreign-key constraint. */
806
+ addConstraint(table, constraint) {
807
+ this.run({ kind: "add_constraint", table, constraint });
808
+ }
809
+ /** Drop a table-level unique / foreign-key constraint. */
810
+ dropConstraint(table, constraint) {
811
+ this.run({ kind: "drop_constraint", table, constraint });
812
+ }
538
813
  /** Raw SQL escape hatch (e.g. a data migration). `down` may be `null`. */
539
814
  execute(up, down = null) {
540
815
  this.run({ kind: "execute", up, down });
@@ -634,6 +909,112 @@ var MigrationRunner = class {
634
909
  return reverted;
635
910
  }
636
911
  };
912
+ function quoteIdent(name, dialect) {
913
+ return dialect === "mysql" ? `\`${name.replace(/`/g, "``")}\`` : `"${name.replace(/"/g, '""')}"`;
914
+ }
915
+ function placeholder(index, dialect) {
916
+ return dialect === "postgresql" ? `$${index}` : "?";
917
+ }
918
+ var AsyncMigrationRunner = class {
919
+ constructor(driver, dialect) {
920
+ this.driver = driver;
921
+ this.dialect = dialect;
922
+ this.vt = quoteIdent(VERSION_TABLE, dialect);
923
+ }
924
+ driver;
925
+ dialect;
926
+ vt;
927
+ /** Create the version-tracking table if it does not exist. */
928
+ async ensureVersionTable() {
929
+ await this.driver.execute(
930
+ `CREATE TABLE IF NOT EXISTS ${this.vt} (revision ${this.textType()} PRIMARY KEY, applied_at ${this.textType()} NOT NULL, down_revision ${this.textType()} NOT NULL)`,
931
+ []
932
+ );
933
+ }
934
+ /** A portable "text" column type for the version table. */
935
+ textType() {
936
+ return this.dialect === "mysql" ? "VARCHAR(255)" : "TEXT";
937
+ }
938
+ /** The set of applied revision ids. */
939
+ async applied() {
940
+ await this.ensureVersionTable();
941
+ const { rows } = await this.driver.execute(`SELECT revision FROM ${this.vt}`, []);
942
+ return new Set(rows.map((r) => String(r.revision)));
943
+ }
944
+ async runOps(ops) {
945
+ for (const op of ops) {
946
+ for (const stmt of renderOperation(op, this.dialect)) {
947
+ const trimmed = stmt.trim();
948
+ if (trimmed.length === 0 || trimmed.startsWith("--")) continue;
949
+ await this.driver.execute(stmt, []);
950
+ }
951
+ }
952
+ }
953
+ async record(migration, appliedAt) {
954
+ const p = (i) => placeholder(i, this.dialect);
955
+ await this.driver.execute(
956
+ `INSERT INTO ${this.vt} (revision, applied_at, down_revision) VALUES (${p(1)}, ${p(2)}, ${p(3)})`,
957
+ [migration.revision, appliedAt, migration.downRevision.join(",")]
958
+ );
959
+ }
960
+ async forget(revision) {
961
+ await this.driver.execute(
962
+ `DELETE FROM ${this.vt} WHERE revision = ${placeholder(1, this.dialect)}`,
963
+ [revision]
964
+ );
965
+ }
966
+ /**
967
+ * Apply all pending migrations up to the head(s), in DAG order.
968
+ *
969
+ * @param migrations All known migrations.
970
+ * @param appliedAt Timestamp string to stamp on each applied revision.
971
+ * @returns The revision ids that were applied this run.
972
+ */
973
+ async upgrade(migrations, appliedAt) {
974
+ const done = await this.applied();
975
+ const ordered = topoOrder(migrations);
976
+ const ran = [];
977
+ for (const migration of ordered) {
978
+ if (done.has(migration.revision)) continue;
979
+ const op = new Op();
980
+ migration.up(op);
981
+ await this.runOps(op.operations);
982
+ await this.record(migration, appliedAt);
983
+ ran.push(migration.revision);
984
+ }
985
+ return ran;
986
+ }
987
+ /**
988
+ * Revert the last `steps` applied migrations (default 1), newest first.
989
+ *
990
+ * @param migrations All known migrations.
991
+ * @param steps How many applied revisions to roll back.
992
+ * @returns The revision ids that were reverted.
993
+ */
994
+ async downgrade(migrations, steps = 1) {
995
+ const done = await this.applied();
996
+ const ordered = topoOrder(migrations).filter((m) => done.has(m.revision));
997
+ const toRevert = ordered.slice(-steps).reverse();
998
+ const reverted = [];
999
+ for (const migration of toRevert) {
1000
+ const op = new Op();
1001
+ try {
1002
+ migration.down(op);
1003
+ } catch {
1004
+ op.operations.length = 0;
1005
+ }
1006
+ if (op.operations.length === 0) {
1007
+ const upOp = new Op();
1008
+ migration.up(upOp);
1009
+ op.operations.push(...invertAll(upOp.operations));
1010
+ }
1011
+ await this.runOps(op.operations);
1012
+ await this.forget(migration.revision);
1013
+ reverted.push(migration.revision);
1014
+ }
1015
+ return reverted;
1016
+ }
1017
+ };
637
1018
 
638
1019
  // src/migrations/introspect.ts
639
1020
  function sqliteAffinity(declared) {
@@ -658,6 +1039,39 @@ function affinityToKind(affinity) {
658
1039
  return "text";
659
1040
  }
660
1041
  }
1042
+ function sqliteForeignKeys(driver, table) {
1043
+ const rows = driver.execute(`PRAGMA foreign_key_list(${JSON.stringify(table)})`, []).rows;
1044
+ const byId = /* @__PURE__ */ new Map();
1045
+ for (const r of rows) {
1046
+ const list = byId.get(r.id) ?? [];
1047
+ list.push(r);
1048
+ byId.set(r.id, list);
1049
+ }
1050
+ const fks = [];
1051
+ for (const group of byId.values()) {
1052
+ const ordered = [...group].sort((a, b) => a.seq - b.seq);
1053
+ const columns = ordered.map((r) => r.from);
1054
+ fks.push({
1055
+ name: `fk_${table}_${columns.join("_")}`,
1056
+ columns,
1057
+ refTable: ordered[0]?.table ?? "",
1058
+ refColumns: ordered.map((r) => r.to)
1059
+ });
1060
+ }
1061
+ return fks;
1062
+ }
1063
+ function sqliteUniques(driver, table) {
1064
+ const indexes = driver.execute(`PRAGMA index_list(${JSON.stringify(table)})`, []).rows;
1065
+ const uniques = [];
1066
+ for (const idx of indexes) {
1067
+ if (Number(idx.unique) !== 1 || idx.origin === "pk") continue;
1068
+ const cols = driver.execute(`PRAGMA index_info(${JSON.stringify(idx.name)})`, []).rows.sort((a, b) => a.seqno - b.seqno).map((c) => c.name);
1069
+ if (cols.length > 0) {
1070
+ uniques.push({ name: `uq_${table}_${cols.join("_")}`, columns: cols });
1071
+ }
1072
+ }
1073
+ return uniques;
1074
+ }
661
1075
  function introspectSqlite(driver) {
662
1076
  const tablesRows = driver.execute(
663
1077
  "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'tempest_db_js_migrations'",
@@ -677,14 +1091,39 @@ function introspectSqlite(driver) {
677
1091
  type: { kind: affinityToKind(affinity), meta: {} },
678
1092
  notNull: Number(col.notnull) === 1 || isPk,
679
1093
  primaryKey: isPk,
680
- default: null
1094
+ default: null,
1095
+ unique: false,
1096
+ references: null
681
1097
  };
682
1098
  if (isPk) primaryKey.push(col.name);
683
1099
  }
684
- tables[tableName] = { name: tableName, columns, primaryKey };
1100
+ tables[tableName] = {
1101
+ name: tableName,
1102
+ columns,
1103
+ primaryKey,
1104
+ uniqueConstraints: sqliteUniques(driver, tableName),
1105
+ foreignKeys: sqliteForeignKeys(driver, tableName)
1106
+ };
685
1107
  }
686
1108
  return { tables };
687
1109
  }
1110
+ function constraintKeys(table) {
1111
+ const fks = /* @__PURE__ */ new Set();
1112
+ const uniques = /* @__PURE__ */ new Set();
1113
+ for (const col of Object.values(table.columns)) {
1114
+ if (col.unique) uniques.add(col.name);
1115
+ if (col.references) {
1116
+ fks.add(`${col.name}=>${col.references.table}(${col.references.column})`);
1117
+ }
1118
+ }
1119
+ for (const uc of table.uniqueConstraints) {
1120
+ uniques.add([...uc.columns].sort().join(","));
1121
+ }
1122
+ for (const fk of table.foreignKeys) {
1123
+ fks.add(`${fk.columns.join(",")}=>${fk.refTable}(${fk.refColumns.join(",")})`);
1124
+ }
1125
+ return { fks, uniques };
1126
+ }
688
1127
  function checkDrift(driver, models) {
689
1128
  const actual = introspectSqlite(driver);
690
1129
  const expected = reflectSchema(models);
@@ -722,6 +1161,34 @@ function checkDrift(driver, models) {
722
1161
  );
723
1162
  }
724
1163
  }
1164
+ const expectedKeys = constraintKeys(expectedTable);
1165
+ const actualKeys = constraintKeys(actualTable);
1166
+ for (const fk of expectedKeys.fks) {
1167
+ if (!actualKeys.fks.has(fk)) {
1168
+ issues.push(`foreign key "${tableName}: ${fk}" is missing from the database`);
1169
+ }
1170
+ }
1171
+ for (const fk of actualKeys.fks) {
1172
+ if (!expectedKeys.fks.has(fk)) {
1173
+ issues.push(
1174
+ `foreign key "${tableName}: ${fk}" exists in the database but not in the model`
1175
+ );
1176
+ }
1177
+ }
1178
+ for (const uq of expectedKeys.uniques) {
1179
+ if (!actualKeys.uniques.has(uq)) {
1180
+ issues.push(
1181
+ `unique constraint "${tableName}: (${uq})" is missing from the database`
1182
+ );
1183
+ }
1184
+ }
1185
+ for (const uq of actualKeys.uniques) {
1186
+ if (!expectedKeys.uniques.has(uq)) {
1187
+ issues.push(
1188
+ `unique constraint "${tableName}: (${uq})" exists in the database but not in the model`
1189
+ );
1190
+ }
1191
+ }
725
1192
  }
726
1193
  for (const tableName of Object.keys(actual.tables)) {
727
1194
  if (!expected.tables[tableName]) {
@@ -783,14 +1250,59 @@ async function introspectPostgres(driver) {
783
1250
  },
784
1251
  notNull: col.is_nullable === "NO" || isPk,
785
1252
  primaryKey: isPk,
786
- default: null
1253
+ default: null,
1254
+ unique: false,
1255
+ references: null
787
1256
  };
788
1257
  if (isPk) primaryKey.push(name);
789
1258
  }
790
- tables[tableName] = { name: tableName, columns, primaryKey };
1259
+ tables[tableName] = {
1260
+ name: tableName,
1261
+ columns,
1262
+ primaryKey,
1263
+ uniqueConstraints: await postgresUniques(driver, tableName),
1264
+ foreignKeys: await postgresForeignKeys(driver, tableName)
1265
+ };
791
1266
  }
792
1267
  return { tables };
793
1268
  }
1269
+ async function postgresForeignKeys(driver, table) {
1270
+ const result = await driver.execute(
1271
+ `SELECT c.conname AS name,
1272
+ (SELECT array_agg(a.attname ORDER BY k.ord)
1273
+ FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
1274
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS cols,
1275
+ cf.relname AS ref_table,
1276
+ (SELECT array_agg(a.attname ORDER BY k.ord)
1277
+ FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord)
1278
+ JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum) AS ref_cols
1279
+ FROM pg_constraint c
1280
+ JOIN pg_class cf ON cf.oid = c.confrelid
1281
+ WHERE c.contype = 'f' AND c.conrelid = $1::regclass`,
1282
+ [table]
1283
+ );
1284
+ return result.rows.map((r) => ({
1285
+ name: String(r.name),
1286
+ columns: r.cols ?? [],
1287
+ refTable: String(r.ref_table),
1288
+ refColumns: r.ref_cols ?? []
1289
+ }));
1290
+ }
1291
+ async function postgresUniques(driver, table) {
1292
+ const result = await driver.execute(
1293
+ `SELECT c.conname AS name,
1294
+ (SELECT array_agg(a.attname ORDER BY k.ord)
1295
+ FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
1296
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS cols
1297
+ FROM pg_constraint c
1298
+ WHERE c.contype = 'u' AND c.conrelid = $1::regclass`,
1299
+ [table]
1300
+ );
1301
+ return result.rows.map((r) => ({
1302
+ name: String(r.name),
1303
+ columns: r.cols ?? []
1304
+ }));
1305
+ }
794
1306
  async function checkDriftPostgres(driver, models) {
795
1307
  const actual = await introspectPostgres(driver);
796
1308
  const expected = reflectSchema(models);
@@ -823,6 +1335,20 @@ async function checkDriftPostgres(driver, models) {
823
1335
  );
824
1336
  }
825
1337
  }
1338
+ const expectedKeys = constraintKeys(expectedTable);
1339
+ const actualKeys = constraintKeys(actualTable);
1340
+ for (const fk of expectedKeys.fks) {
1341
+ if (!actualKeys.fks.has(fk)) {
1342
+ issues.push(`foreign key "${tableName}: ${fk}" is missing from the database`);
1343
+ }
1344
+ }
1345
+ for (const uq of expectedKeys.uniques) {
1346
+ if (!actualKeys.uniques.has(uq)) {
1347
+ issues.push(
1348
+ `unique constraint "${tableName}: (${uq})" is missing from the database`
1349
+ );
1350
+ }
1351
+ }
826
1352
  }
827
1353
  for (const tableName of Object.keys(actual.tables)) {
828
1354
  if (!expected.tables[tableName]) {
@@ -894,6 +1420,30 @@ function applyOperation(schema, op) {
894
1420
  }
895
1421
  break;
896
1422
  }
1423
+ case "add_constraint": {
1424
+ const t = tables[op.table];
1425
+ if (t) {
1426
+ tables[op.table] = op.constraint.type === "unique" ? {
1427
+ ...t,
1428
+ uniqueConstraints: [...t.uniqueConstraints, op.constraint.constraint]
1429
+ } : { ...t, foreignKeys: [...t.foreignKeys, op.constraint.constraint] };
1430
+ }
1431
+ break;
1432
+ }
1433
+ case "drop_constraint": {
1434
+ const t = tables[op.table];
1435
+ if (t) {
1436
+ const dropName = op.constraint.constraint.name;
1437
+ tables[op.table] = op.constraint.type === "unique" ? {
1438
+ ...t,
1439
+ uniqueConstraints: t.uniqueConstraints.filter((u) => u.name !== dropName)
1440
+ } : {
1441
+ ...t,
1442
+ foreignKeys: t.foreignKeys.filter((f) => f.name !== dropName)
1443
+ };
1444
+ }
1445
+ break;
1446
+ }
897
1447
  }
898
1448
  return { tables };
899
1449
  }
@@ -915,7 +1465,9 @@ function columnShape(col) {
915
1465
  type: col.type,
916
1466
  notNull: col.notNull,
917
1467
  primaryKey: col.primaryKey,
918
- default: col.default
1468
+ default: col.default,
1469
+ unique: col.unique,
1470
+ references: col.references
919
1471
  });
920
1472
  }
921
1473
  function tableShape(columns) {
@@ -1123,6 +1675,7 @@ function runMigrationCli(argv, config) {
1123
1675
  }
1124
1676
  }
1125
1677
 
1678
+ exports.AsyncMigrationRunner = AsyncMigrationRunner;
1126
1679
  exports.CyclicMigrationGraph = CyclicMigrationGraph;
1127
1680
  exports.IrreversibleMigration = IrreversibleMigration;
1128
1681
  exports.MigrationRunner = MigrationRunner;