tempest-db-js 0.3.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  📖 **Documentation:** [Português (BR)](https://mauriciobenjamin700.github.io/tempest-db-js/) · [English (US)](https://mauriciobenjamin700.github.io/tempest-db-js/en/)
7
7
 
8
- > ✅ **Status: alpha (v0.3.0), published on [npm](https://www.npmjs.com/package/tempest-db-js).** The full path works end-to-end — declarative models, typed query builder (aggregations, `DISTINCT`, upsert), **real SQLite + PostgreSQL execution**, a **MySQL** dialect, joins, relations, Alembic-style migrations (sync + **async** runner) with a `tempest-db` CLI, a typed `BaseRepository`, and an opt-in active-record layer. The public API may still shift before v1.0.
8
+ > ✅ **Status: alpha (v0.4.0), published on [npm](https://www.npmjs.com/package/tempest-db-js).** The full path works end-to-end — declarative models with **foreign keys, UNIQUE and table constraints**, typed query builder (aggregations, `DISTINCT`, upsert), **real SQLite + PostgreSQL execution**, a **MySQL** dialect, joins, relations, Alembic-style migrations (sync + **async** runner) with a `tempest-db` CLI, a typed `BaseRepository`, and an opt-in active-record layer. The public API may still shift before v1.0.
9
9
 
10
10
  ## Why tempest-db-js
11
11
 
@@ -71,6 +71,7 @@ Sessions and engines are **disposable** — `using session = engine.session()` (
71
71
 
72
72
  Typed extras, each with a [docs recipe](https://mauriciobenjamin700.github.io/tempest-db-js/):
73
73
 
74
+ - **Schema constraints** — column `.unique()` / `.references("users.id", { onDelete })` and composite/named table constraints via `static tableArgs = () => [unique(...), foreignKey(...)]` (SQLAlchemy `ForeignKey`/`__table_args__` style). Rendered across all dialects; reversible in migrations.
74
75
  - **Aggregations** — `select(Order).aggregate(["status"], { n: count(), total: sum("amount") })` → rows typed as `{ status; n; total }`. Plus `.distinct()`.
75
76
  - **Upsert** — `insert(Row).values(...).onConflictDoUpdate(["key"], { ... })` / `.onConflictDoNothing(["key"])` (portable SQLite ↔ PostgreSQL).
76
77
  - **Active-record (opt-in)** — `activeRecord(User, session)` → `save`/`update`/`delete`/`reload` over `.data`; the plain-object default is unchanged.
@@ -105,7 +106,7 @@ HTTP integration recipes (Hono, Express, Fastify) live in the [docs](https://mau
105
106
 
106
107
  ## Roadmap
107
108
 
108
- See [ROADMAP.md](./ROADMAP.md). Shipped (v0.3.0): SQLite + PostgreSQL execution (both tested in CI, Postgres against a live database), a MySQL dialect, joins, relations, sync + async migration runners with a `tempest-db` CLI, repository, aggregations/upsert, opt-in active-record. Next: MySQL execution in CI + `RETURNING` round-trip, async CLI wiring, then `tempest-ts-sdk`.
109
+ See [ROADMAP.md](./ROADMAP.md). Shipped (v0.4.0): declarative schema with foreign keys / UNIQUE / table constraints, SQLite + PostgreSQL execution (both tested in CI, Postgres against a live database), a MySQL dialect, joins, relations, sync + async migration runners with a `tempest-db` CLI, repository, aggregations/upsert, opt-in active-record. Next: MySQL execution in CI + `RETURNING` round-trip, async CLI wiring, then `tempest-ts-sdk`.
109
110
 
110
111
  ## Development
111
112
 
package/dist/bin.cjs CHANGED
@@ -38,6 +38,10 @@ function invert(op) {
38
38
  return { kind: "rename_column", table: op.table, from: op.to, to: op.from };
39
39
  case "recreate_table":
40
40
  return { kind: "recreate_table", from: op.to, to: op.from };
41
+ case "add_constraint":
42
+ return { kind: "drop_constraint", table: op.table, constraint: op.constraint };
43
+ case "drop_constraint":
44
+ return { kind: "add_constraint", table: op.table, constraint: op.constraint };
41
45
  case "execute":
42
46
  if (op.down === null) {
43
47
  throw new IrreversibleMigration("execute() operation has no down SQL");
@@ -227,10 +231,45 @@ function renderDefault(def, dialect) {
227
231
  if (typeof value === "object") return quoteLiteral(JSON.stringify(value));
228
232
  return quoteLiteral(String(value));
229
233
  }
234
+ function renderFkAction(action) {
235
+ return action.toUpperCase();
236
+ }
237
+ function renderFkActions(fk) {
238
+ let sql = "";
239
+ if (fk.onDelete) sql += ` ON DELETE ${renderFkAction(fk.onDelete)}`;
240
+ if (fk.onUpdate) sql += ` ON UPDATE ${renderFkAction(fk.onUpdate)}`;
241
+ return sql;
242
+ }
243
+ function columnConstraintSuffix(col, dialect) {
244
+ let sql = "";
245
+ if (col.unique) sql += " UNIQUE";
246
+ if (col.references) {
247
+ const ref = col.references;
248
+ sql += ` REFERENCES ${quoteId(ref.table, dialect)} (${quoteId(ref.column, dialect)})`;
249
+ sql += renderFkActions(ref);
250
+ }
251
+ return sql;
252
+ }
253
+ function renderUniqueConstraint(uc, dialect) {
254
+ const cols = uc.columns.map((c) => quoteId(c, dialect)).join(", ");
255
+ return `CONSTRAINT ${quoteId(uc.name, dialect)} UNIQUE (${cols})`;
256
+ }
257
+ function renderForeignKeyConstraint(fk, dialect) {
258
+ const cols = fk.columns.map((c) => quoteId(c, dialect)).join(", ");
259
+ const refCols = fk.refColumns.map((c) => quoteId(c, dialect)).join(", ");
260
+ return `CONSTRAINT ${quoteId(fk.name, dialect)} FOREIGN KEY (${cols}) REFERENCES ${quoteId(fk.refTable, dialect)} (${refCols})${renderFkActions(fk)}`;
261
+ }
262
+ function tableConstraintClauses(table, dialect) {
263
+ return [
264
+ ...table.uniqueConstraints.map((uc) => renderUniqueConstraint(uc, dialect)),
265
+ ...table.foreignKeys.map((fk) => renderForeignKeyConstraint(fk, dialect))
266
+ ];
267
+ }
230
268
  function renderColumnDef(col, dialect) {
231
269
  let sql = `${quoteId(col.name, dialect)} ${renderColumnType(col.type, dialect)}`;
232
270
  if (col.notNull) sql += " NOT NULL";
233
271
  if (col.default !== null) sql += ` DEFAULT ${renderDefault(col.default, dialect)}`;
272
+ sql += columnConstraintSuffix(col, dialect);
234
273
  return sql;
235
274
  }
236
275
  function enumTypeName(table, column) {
@@ -254,13 +293,13 @@ function renderCreateTable(table, dialect) {
254
293
  let def = `${quoteId(c.name, dialect)} ${quoteId(typeName, dialect)}`;
255
294
  if (c.notNull) def += " NOT NULL";
256
295
  if (c.default !== null) def += ` DEFAULT ${renderDefault(c.default, dialect)}`;
257
- return def;
296
+ return def + columnConstraintSuffix(c, dialect);
258
297
  }
259
298
  if (dialect === "postgresql" && isAutoIncrementPk(table, c)) {
260
- return `${quoteId(c.name, dialect)} ${postgresSerialType(c.type.kind)}`;
299
+ return `${quoteId(c.name, dialect)} ${postgresSerialType(c.type.kind)}${columnConstraintSuffix(c, dialect)}`;
261
300
  }
262
301
  if (dialect === "mysql" && isAutoIncrementPk(table, c)) {
263
- return `${quoteId(c.name, dialect)} ${renderColumnType(c.type, dialect)} NOT NULL AUTO_INCREMENT`;
302
+ return `${quoteId(c.name, dialect)} ${renderColumnType(c.type, dialect)} NOT NULL AUTO_INCREMENT${columnConstraintSuffix(c, dialect)}`;
264
303
  }
265
304
  return renderColumnDef(c, dialect);
266
305
  });
@@ -269,6 +308,7 @@ function renderCreateTable(table, dialect) {
269
308
  `PRIMARY KEY (${table.primaryKey.map((c) => quoteId(c, dialect)).join(", ")})`
270
309
  );
271
310
  }
311
+ cols.push(...tableConstraintClauses(table, dialect));
272
312
  return [
273
313
  ...typeStmts,
274
314
  `CREATE TABLE ${quoteId(table.name, dialect)} (
@@ -302,10 +342,38 @@ function renderOperation(op, dialect) {
302
342
  return renderAlterColumn(op.table, op.to, dialect);
303
343
  case "recreate_table":
304
344
  return dialect === "sqlite" ? renderSqliteRebuild(op.from, op.to) : renderTableDiff(op.from, op.to, dialect);
345
+ case "add_constraint":
346
+ return renderAddConstraint(op.table, op.constraint, dialect);
347
+ case "drop_constraint":
348
+ return renderDropConstraint(op.table, op.constraint, dialect);
305
349
  case "execute":
306
350
  return [op.up];
307
351
  }
308
352
  }
353
+ function renderAddConstraint(table, constraint, dialect) {
354
+ if (dialect === "sqlite") {
355
+ throw new Error(
356
+ `add_constraint on SQLite needs a table-rebuild (recreate_table); constraint ${constraint.constraint.name} on ${table}`
357
+ );
358
+ }
359
+ const clause = constraint.type === "unique" ? renderUniqueConstraint(constraint.constraint, dialect) : renderForeignKeyConstraint(constraint.constraint, dialect);
360
+ return [`ALTER TABLE ${quoteId(table, dialect)} ADD ${clause}`];
361
+ }
362
+ function renderDropConstraint(table, constraint, dialect) {
363
+ if (dialect === "sqlite") {
364
+ throw new Error(
365
+ `drop_constraint on SQLite needs a table-rebuild (recreate_table); constraint ${constraint.constraint.name} on ${table}`
366
+ );
367
+ }
368
+ const t = quoteId(table, dialect);
369
+ const name = quoteId(constraint.constraint.name, dialect);
370
+ if (dialect === "mysql") {
371
+ return [
372
+ constraint.type === "unique" ? `ALTER TABLE ${t} DROP INDEX ${name}` : `ALTER TABLE ${t} DROP FOREIGN KEY ${name}`
373
+ ];
374
+ }
375
+ return [`ALTER TABLE ${t} DROP CONSTRAINT ${name}`];
376
+ }
309
377
  function renderSqliteRebuild(from, to) {
310
378
  const tmp = `__new_${to.name}`;
311
379
  const common = Object.keys(to.columns).filter((c) => c in from.columns);
@@ -315,6 +383,7 @@ function renderSqliteRebuild(from, to) {
315
383
  `PRIMARY KEY (${to.primaryKey.map((c) => quoteId(c, "sqlite")).join(", ")})`
316
384
  );
317
385
  }
386
+ cols.push(...tableConstraintClauses(to, "sqlite"));
318
387
  const commonSql = common.map((c) => quoteId(c, "sqlite")).join(", ");
319
388
  return [
320
389
  "PRAGMA foreign_keys=off",
@@ -378,9 +447,74 @@ function columnSignature(col) {
378
447
  type: col.type,
379
448
  notNull: col.notNull,
380
449
  primaryKey: col.primaryKey,
381
- default: col.default
450
+ default: col.default,
451
+ unique: col.unique,
452
+ references: col.references
382
453
  });
383
454
  }
455
+ function uniqueSignature(uc) {
456
+ return JSON.stringify({ columns: uc.columns });
457
+ }
458
+ function foreignKeySignature(fk) {
459
+ return JSON.stringify({
460
+ columns: fk.columns,
461
+ refTable: fk.refTable,
462
+ refColumns: fk.refColumns,
463
+ onDelete: fk.onDelete ?? null,
464
+ onUpdate: fk.onUpdate ?? null
465
+ });
466
+ }
467
+ function diffConstraints(current, target) {
468
+ const ops = [];
469
+ const table = target.name;
470
+ const currentUq = new Map(current.uniqueConstraints.map((u) => [u.name, u]));
471
+ const targetUq = new Map(target.uniqueConstraints.map((u) => [u.name, u]));
472
+ for (const [name, cur] of currentUq) {
473
+ const tgt = targetUq.get(name);
474
+ if (!tgt || uniqueSignature(cur) !== uniqueSignature(tgt)) {
475
+ ops.push(dropUnique(table, cur));
476
+ }
477
+ }
478
+ for (const [name, tgt] of targetUq) {
479
+ const cur = currentUq.get(name);
480
+ if (!cur || uniqueSignature(cur) !== uniqueSignature(tgt)) {
481
+ ops.push(addUnique(table, tgt));
482
+ }
483
+ }
484
+ const currentFk = new Map(current.foreignKeys.map((f) => [f.name, f]));
485
+ const targetFk = new Map(target.foreignKeys.map((f) => [f.name, f]));
486
+ for (const [name, cur] of currentFk) {
487
+ const tgt = targetFk.get(name);
488
+ if (!tgt || foreignKeySignature(cur) !== foreignKeySignature(tgt)) {
489
+ ops.push(dropForeignKey(table, cur));
490
+ }
491
+ }
492
+ for (const [name, tgt] of targetFk) {
493
+ const cur = currentFk.get(name);
494
+ if (!cur || foreignKeySignature(cur) !== foreignKeySignature(tgt)) {
495
+ ops.push(addForeignKey(table, tgt));
496
+ }
497
+ }
498
+ return ops;
499
+ }
500
+ function uniqueNamed(uc) {
501
+ return { type: "unique", constraint: uc };
502
+ }
503
+ function foreignKeyNamed(fk) {
504
+ return { type: "foreignKey", constraint: fk };
505
+ }
506
+ function addUnique(table, uc) {
507
+ return { kind: "add_constraint", table, constraint: uniqueNamed(uc) };
508
+ }
509
+ function dropUnique(table, uc) {
510
+ return { kind: "drop_constraint", table, constraint: uniqueNamed(uc) };
511
+ }
512
+ function addForeignKey(table, fk) {
513
+ return { kind: "add_constraint", table, constraint: foreignKeyNamed(fk) };
514
+ }
515
+ function dropForeignKey(table, fk) {
516
+ return { kind: "drop_constraint", table, constraint: foreignKeyNamed(fk) };
517
+ }
384
518
  function diffSchema(current, target) {
385
519
  const ops = [];
386
520
  const drops = [];
@@ -409,6 +543,7 @@ function diffSchema(current, target) {
409
543
  ops.push({ kind: "drop_column", table: name, column: currentCol });
410
544
  }
411
545
  }
546
+ ops.push(...diffConstraints(currentTable, targetTable));
412
547
  }
413
548
  for (const [name, currentTable] of Object.entries(current.tables)) {
414
549
  if (!target.tables[name]) {
@@ -476,23 +611,38 @@ function heads(migrations) {
476
611
  function isDefaultValue(value) {
477
612
  return typeof value === "object" && value !== null && "kind" in value && (value.kind === "literal" || value.kind === "expression");
478
613
  }
614
+ function parseReference(ref, options) {
615
+ const dot = ref.lastIndexOf(".");
616
+ if (dot <= 0 || dot === ref.length - 1) {
617
+ throw new Error(`Invalid foreign key reference "${ref}"; expected "table.column".`);
618
+ }
619
+ return {
620
+ table: ref.slice(0, dot),
621
+ column: ref.slice(dot + 1),
622
+ onDelete: options?.onDelete,
623
+ onUpdate: options?.onUpdate
624
+ };
625
+ }
479
626
  var Column = class _Column {
480
- constructor(type, flags, defaultValue = null, onUpdateValue = null) {
627
+ constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null) {
481
628
  this.type = type;
482
629
  this.flags = flags;
483
630
  this.defaultValue = defaultValue;
484
631
  this.onUpdateValue = onUpdateValue;
632
+ this.reference = reference;
485
633
  }
486
634
  type;
487
635
  flags;
488
636
  defaultValue;
489
637
  onUpdateValue;
638
+ reference;
490
639
  primaryKey() {
491
640
  return new _Column(
492
641
  this.type,
493
642
  { ...this.flags, primaryKey: true, hasDefault: true },
494
643
  this.defaultValue,
495
- this.onUpdateValue
644
+ this.onUpdateValue,
645
+ this.reference
496
646
  );
497
647
  }
498
648
  notNull() {
@@ -500,7 +650,40 @@ var Column = class _Column {
500
650
  this.type,
501
651
  { ...this.flags, notNull: true },
502
652
  this.defaultValue,
503
- this.onUpdateValue
653
+ this.onUpdateValue,
654
+ this.reference
655
+ );
656
+ }
657
+ /**
658
+ * Add a `UNIQUE` constraint to the column (mirrors SQLAlchemy's
659
+ * `mapped_column(unique=True)`). DDL-only — does not change the inferred type.
660
+ */
661
+ unique() {
662
+ return new _Column(
663
+ this.type,
664
+ { ...this.flags, unique: true },
665
+ this.defaultValue,
666
+ this.onUpdateValue,
667
+ this.reference
668
+ );
669
+ }
670
+ /**
671
+ * Declare a foreign-key reference to another table's column, à la SQLAlchemy's
672
+ * `mapped_column(ForeignKey("table.column", ondelete=...))`. DDL-only — does
673
+ * not change the inferred type.
674
+ *
675
+ * @param ref The target as `"table.column"` (e.g. `"users.id"`).
676
+ * @param options Optional `onDelete` / `onUpdate` referential actions.
677
+ * @returns A new column carrying the reference.
678
+ * @throws Error When `ref` is not a valid `"table.column"` string.
679
+ */
680
+ references(ref, options) {
681
+ return new _Column(
682
+ this.type,
683
+ this.flags,
684
+ this.defaultValue,
685
+ this.onUpdateValue,
686
+ parseReference(ref, options)
504
687
  );
505
688
  }
506
689
  /**
@@ -513,7 +696,8 @@ var Column = class _Column {
513
696
  this.type,
514
697
  { ...this.flags, hasDefault: true },
515
698
  resolved,
516
- this.onUpdateValue
699
+ this.onUpdateValue,
700
+ this.reference
517
701
  );
518
702
  }
519
703
  /**
@@ -522,7 +706,7 @@ var Column = class _Column {
522
706
  */
523
707
  onUpdate(value) {
524
708
  const resolved = isDefaultValue(value) ? value : { kind: "literal", value };
525
- return new _Column(this.type, this.flags, this.defaultValue, resolved);
709
+ return new _Column(this.type, this.flags, this.defaultValue, resolved, this.reference);
526
710
  }
527
711
  };
528
712
  var columnsCache = /* @__PURE__ */ new WeakMap();
@@ -541,6 +725,9 @@ function columnsOf(model) {
541
725
  }
542
726
 
543
727
  // src/migrations/ir.ts
728
+ function constraintName(prefix, table, columns) {
729
+ return `${prefix}_${table}_${columns.join("_")}`;
730
+ }
544
731
  function reflectTable(model) {
545
732
  const columns = {};
546
733
  const primaryKey = [];
@@ -551,11 +738,32 @@ function reflectTable(model) {
551
738
  type: col.type,
552
739
  notNull: col.flags.notNull || isPk,
553
740
  primaryKey: isPk,
554
- default: col.defaultValue
741
+ default: col.defaultValue,
742
+ unique: col.flags.unique,
743
+ references: col.reference
555
744
  };
556
745
  if (isPk) primaryKey.push(name);
557
746
  }
558
- return { name: model.tablename, columns, primaryKey };
747
+ const uniqueConstraints = [];
748
+ const foreignKeys = [];
749
+ for (const c of model.tableArgs?.() ?? []) {
750
+ if (c.kind === "unique") {
751
+ uniqueConstraints.push({
752
+ name: c.name ?? constraintName("uq", model.tablename, c.columns),
753
+ columns: c.columns
754
+ });
755
+ } else {
756
+ foreignKeys.push({
757
+ name: c.name ?? constraintName("fk", model.tablename, c.columns),
758
+ columns: c.columns,
759
+ refTable: c.refTable,
760
+ refColumns: c.refColumns,
761
+ onDelete: c.onDelete,
762
+ onUpdate: c.onUpdate
763
+ });
764
+ }
765
+ }
766
+ return { name: model.tablename, columns, primaryKey, uniqueConstraints, foreignKeys };
559
767
  }
560
768
  function reflectSchema(models) {
561
769
  const tables = {};
@@ -592,6 +800,39 @@ function affinityToKind(affinity) {
592
800
  return "text";
593
801
  }
594
802
  }
803
+ function sqliteForeignKeys(driver, table) {
804
+ const rows = driver.execute(`PRAGMA foreign_key_list(${JSON.stringify(table)})`, []).rows;
805
+ const byId = /* @__PURE__ */ new Map();
806
+ for (const r of rows) {
807
+ const list = byId.get(r.id) ?? [];
808
+ list.push(r);
809
+ byId.set(r.id, list);
810
+ }
811
+ const fks = [];
812
+ for (const group of byId.values()) {
813
+ const ordered = [...group].sort((a, b) => a.seq - b.seq);
814
+ const columns = ordered.map((r) => r.from);
815
+ fks.push({
816
+ name: `fk_${table}_${columns.join("_")}`,
817
+ columns,
818
+ refTable: ordered[0]?.table ?? "",
819
+ refColumns: ordered.map((r) => r.to)
820
+ });
821
+ }
822
+ return fks;
823
+ }
824
+ function sqliteUniques(driver, table) {
825
+ const indexes = driver.execute(`PRAGMA index_list(${JSON.stringify(table)})`, []).rows;
826
+ const uniques = [];
827
+ for (const idx of indexes) {
828
+ if (Number(idx.unique) !== 1 || idx.origin === "pk") continue;
829
+ const cols = driver.execute(`PRAGMA index_info(${JSON.stringify(idx.name)})`, []).rows.sort((a, b) => a.seqno - b.seqno).map((c) => c.name);
830
+ if (cols.length > 0) {
831
+ uniques.push({ name: `uq_${table}_${cols.join("_")}`, columns: cols });
832
+ }
833
+ }
834
+ return uniques;
835
+ }
595
836
  function introspectSqlite(driver) {
596
837
  const tablesRows = driver.execute(
597
838
  "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'tempest_db_js_migrations'",
@@ -611,14 +852,39 @@ function introspectSqlite(driver) {
611
852
  type: { kind: affinityToKind(affinity), meta: {} },
612
853
  notNull: Number(col.notnull) === 1 || isPk,
613
854
  primaryKey: isPk,
614
- default: null
855
+ default: null,
856
+ unique: false,
857
+ references: null
615
858
  };
616
859
  if (isPk) primaryKey.push(col.name);
617
860
  }
618
- tables[tableName] = { name: tableName, columns, primaryKey };
861
+ tables[tableName] = {
862
+ name: tableName,
863
+ columns,
864
+ primaryKey,
865
+ uniqueConstraints: sqliteUniques(driver, tableName),
866
+ foreignKeys: sqliteForeignKeys(driver, tableName)
867
+ };
619
868
  }
620
869
  return { tables };
621
870
  }
871
+ function constraintKeys(table) {
872
+ const fks = /* @__PURE__ */ new Set();
873
+ const uniques = /* @__PURE__ */ new Set();
874
+ for (const col of Object.values(table.columns)) {
875
+ if (col.unique) uniques.add(col.name);
876
+ if (col.references) {
877
+ fks.add(`${col.name}=>${col.references.table}(${col.references.column})`);
878
+ }
879
+ }
880
+ for (const uc of table.uniqueConstraints) {
881
+ uniques.add([...uc.columns].sort().join(","));
882
+ }
883
+ for (const fk of table.foreignKeys) {
884
+ fks.add(`${fk.columns.join(",")}=>${fk.refTable}(${fk.refColumns.join(",")})`);
885
+ }
886
+ return { fks, uniques };
887
+ }
622
888
  function checkDrift(driver, models) {
623
889
  const actual = introspectSqlite(driver);
624
890
  const expected = reflectSchema(models);
@@ -656,6 +922,34 @@ function checkDrift(driver, models) {
656
922
  );
657
923
  }
658
924
  }
925
+ const expectedKeys = constraintKeys(expectedTable);
926
+ const actualKeys = constraintKeys(actualTable);
927
+ for (const fk of expectedKeys.fks) {
928
+ if (!actualKeys.fks.has(fk)) {
929
+ issues.push(`foreign key "${tableName}: ${fk}" is missing from the database`);
930
+ }
931
+ }
932
+ for (const fk of actualKeys.fks) {
933
+ if (!expectedKeys.fks.has(fk)) {
934
+ issues.push(
935
+ `foreign key "${tableName}: ${fk}" exists in the database but not in the model`
936
+ );
937
+ }
938
+ }
939
+ for (const uq of expectedKeys.uniques) {
940
+ if (!actualKeys.uniques.has(uq)) {
941
+ issues.push(
942
+ `unique constraint "${tableName}: (${uq})" is missing from the database`
943
+ );
944
+ }
945
+ }
946
+ for (const uq of actualKeys.uniques) {
947
+ if (!expectedKeys.uniques.has(uq)) {
948
+ issues.push(
949
+ `unique constraint "${tableName}: (${uq})" exists in the database but not in the model`
950
+ );
951
+ }
952
+ }
659
953
  }
660
954
  for (const tableName of Object.keys(actual.tables)) {
661
955
  if (!expected.tables[tableName]) {
@@ -671,7 +965,9 @@ function columnShape(col) {
671
965
  type: col.type,
672
966
  notNull: col.notNull,
673
967
  primaryKey: col.primaryKey,
674
- default: col.default
968
+ default: col.default,
969
+ unique: col.unique,
970
+ references: col.references
675
971
  });
676
972
  }
677
973
  function tableShape(columns) {
@@ -790,6 +1086,14 @@ var Op = class {
790
1086
  recreateTable(from, to) {
791
1087
  this.run({ kind: "recreate_table", from, to });
792
1088
  }
1089
+ /** Add a table-level unique / foreign-key constraint. */
1090
+ addConstraint(table, constraint) {
1091
+ this.run({ kind: "add_constraint", table, constraint });
1092
+ }
1093
+ /** Drop a table-level unique / foreign-key constraint. */
1094
+ dropConstraint(table, constraint) {
1095
+ this.run({ kind: "drop_constraint", table, constraint });
1096
+ }
793
1097
  /** Raw SQL escape hatch (e.g. a data migration). `down` may be `null`. */
794
1098
  execute(up, down = null) {
795
1099
  this.run({ kind: "execute", up, down });
@@ -952,6 +1256,30 @@ function applyOperation(schema, op) {
952
1256
  }
953
1257
  break;
954
1258
  }
1259
+ case "add_constraint": {
1260
+ const t = tables[op.table];
1261
+ if (t) {
1262
+ tables[op.table] = op.constraint.type === "unique" ? {
1263
+ ...t,
1264
+ uniqueConstraints: [...t.uniqueConstraints, op.constraint.constraint]
1265
+ } : { ...t, foreignKeys: [...t.foreignKeys, op.constraint.constraint] };
1266
+ }
1267
+ break;
1268
+ }
1269
+ case "drop_constraint": {
1270
+ const t = tables[op.table];
1271
+ if (t) {
1272
+ const dropName = op.constraint.constraint.name;
1273
+ tables[op.table] = op.constraint.type === "unique" ? {
1274
+ ...t,
1275
+ uniqueConstraints: t.uniqueConstraints.filter((u) => u.name !== dropName)
1276
+ } : {
1277
+ ...t,
1278
+ foreignKeys: t.foreignKeys.filter((f) => f.name !== dropName)
1279
+ };
1280
+ }
1281
+ break;
1282
+ }
955
1283
  }
956
1284
  return { tables };
957
1285
  }