tempest-db-js 0.3.0 → 0.5.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.
@@ -1,58 +1,162 @@
1
1
  'use strict';
2
2
 
3
+ // src/expressions.ts
4
+ function renderPortableToken(token, dialect) {
5
+ switch (token) {
6
+ case "now":
7
+ return dialect === "postgresql" ? "now()" : "CURRENT_TIMESTAMP";
8
+ case "current_date":
9
+ return "CURRENT_DATE";
10
+ case "current_time":
11
+ return "CURRENT_TIME";
12
+ case "uuidv4":
13
+ if (dialect === "postgresql") return "gen_random_uuid()";
14
+ if (dialect === "mysql") return "(UUID())";
15
+ return "(lower(hex(randomblob(16))))";
16
+ }
17
+ }
18
+
3
19
  // src/index.ts
4
20
  function isDefaultValue(value) {
5
21
  return typeof value === "object" && value !== null && "kind" in value && (value.kind === "literal" || value.kind === "expression");
6
22
  }
23
+ function bindsParameters(value) {
24
+ return value.kind === "expression" && typeof value.expression === "object" && "parts" in value.expression;
25
+ }
26
+ function parseReference(ref, options) {
27
+ const dot = ref.lastIndexOf(".");
28
+ if (dot <= 0 || dot === ref.length - 1) {
29
+ throw new Error(`Invalid foreign key reference "${ref}"; expected "table.column".`);
30
+ }
31
+ return {
32
+ table: ref.slice(0, dot),
33
+ column: ref.slice(dot + 1),
34
+ onDelete: options?.onDelete,
35
+ onUpdate: options?.onUpdate
36
+ };
37
+ }
7
38
  var Column = class _Column {
8
- constructor(type, flags, defaultValue = null, onUpdateValue = null) {
39
+ constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null, dbName = null) {
9
40
  this.type = type;
10
41
  this.flags = flags;
11
42
  this.defaultValue = defaultValue;
12
43
  this.onUpdateValue = onUpdateValue;
44
+ this.reference = reference;
45
+ this.dbName = dbName;
13
46
  }
14
47
  type;
15
48
  flags;
16
49
  defaultValue;
17
50
  onUpdateValue;
18
- primaryKey() {
51
+ reference;
52
+ dbName;
53
+ /** Clone this column with one facet replaced, carrying every other over. */
54
+ derive(patch) {
19
55
  return new _Column(
20
56
  this.type,
21
- { ...this.flags, primaryKey: true, hasDefault: true },
22
- this.defaultValue,
23
- this.onUpdateValue
57
+ patch.flags ?? this.flags,
58
+ patch.defaultValue !== void 0 ? patch.defaultValue : this.defaultValue,
59
+ patch.onUpdateValue !== void 0 ? patch.onUpdateValue : this.onUpdateValue,
60
+ patch.reference !== void 0 ? patch.reference : this.reference,
61
+ patch.dbName !== void 0 ? patch.dbName : this.dbName
24
62
  );
25
63
  }
64
+ primaryKey() {
65
+ return this.derive({ flags: { ...this.flags, primaryKey: true, hasDefault: true } });
66
+ }
26
67
  notNull() {
27
- return new _Column(
28
- this.type,
29
- { ...this.flags, notNull: true },
30
- this.defaultValue,
31
- this.onUpdateValue
32
- );
68
+ return this.derive({ flags: { ...this.flags, notNull: true } });
69
+ }
70
+ /**
71
+ * Add a `UNIQUE` constraint to the column (mirrors SQLAlchemy's
72
+ * `mapped_column(unique=True)`). DDL-only — does not change the inferred type.
73
+ */
74
+ unique() {
75
+ return this.derive({ flags: { ...this.flags, unique: true } });
76
+ }
77
+ /**
78
+ * Map this property to a differently-named database column, à la SQLAlchemy's
79
+ * `mapped_column("consumer_name")` (Django's `db_column`, Prisma's `@map`).
80
+ *
81
+ * The override applies everywhere the name reaches SQL — select, insert,
82
+ * update, delete, where, order by, group by, returning, conflict targets, the
83
+ * migration IR and the drift check — while the TypeScript row keeps the
84
+ * property name. Use it to keep a `snake_case` schema behind a `camelCase`
85
+ * model; {@link Model.naming} does the same for a whole table at once.
86
+ *
87
+ * @param dbName The real column name in the database.
88
+ * @returns A new column bound to that name.
89
+ * @throws Error When `dbName` is empty.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * class ApiKey extends Model {
94
+ * static tablename = "api_keys";
95
+ * consumerName = column.text().name("consumer_name").notNull();
96
+ * }
97
+ * ```
98
+ */
99
+ name(dbName) {
100
+ if (dbName.length === 0) {
101
+ throw new Error("column.name() requires a non-empty database column name.");
102
+ }
103
+ return this.derive({ dbName });
104
+ }
105
+ /**
106
+ * Declare a foreign-key reference to another table's column, à la SQLAlchemy's
107
+ * `mapped_column(ForeignKey("table.column", ondelete=...))`. DDL-only — does
108
+ * not change the inferred type.
109
+ *
110
+ * @param ref The target as `"table.column"` (e.g. `"users.id"`).
111
+ * @param options Optional `onDelete` / `onUpdate` referential actions.
112
+ * @returns A new column carrying the reference.
113
+ * @throws Error When `ref` is not a valid `"table.column"` string.
114
+ */
115
+ references(ref, options) {
116
+ return this.derive({ reference: parseReference(ref, options) });
33
117
  }
34
118
  /**
35
119
  * Set the insert-time default: a constant value of type `T`, or a portable
36
120
  * server-side expression from {@link sql} (e.g. `sql.now()`, `sql.uuidv4()`).
121
+ *
122
+ * @param value The literal default, or a {@link sql} expression.
123
+ * @returns A new column carrying the default.
124
+ * @throws Error When given a `sql.expr` fragment — a `DEFAULT` clause has
125
+ * nowhere to bind parameters; use `sql.raw()` for a verbatim expression.
37
126
  */
38
127
  default(value) {
39
128
  const resolved = isDefaultValue(value) ? value : { kind: "literal", value };
40
- return new _Column(
41
- this.type,
42
- { ...this.flags, hasDefault: true },
43
- resolved,
44
- this.onUpdateValue
45
- );
129
+ if (bindsParameters(resolved)) {
130
+ throw new Error(
131
+ "sql.expr`...` binds parameters and cannot be a column default \u2014 use sql.raw() for a verbatim DEFAULT expression."
132
+ );
133
+ }
134
+ return this.derive({
135
+ flags: { ...this.flags, hasDefault: true },
136
+ defaultValue: resolved
137
+ });
46
138
  }
47
139
  /**
48
140
  * Re-apply a value whenever the row is updated (e.g. an `updated_at` column
49
141
  * with `sql.now()`). Mirrors SQLAlchemy's `onupdate`.
142
+ *
143
+ * @param value The literal value, or a {@link sql} expression.
144
+ * @returns A new column carrying the on-update value.
145
+ * @throws Error When given a `sql.expr` fragment (see {@link Column.default}).
50
146
  */
51
147
  onUpdate(value) {
52
148
  const resolved = isDefaultValue(value) ? value : { kind: "literal", value };
53
- return new _Column(this.type, this.flags, this.defaultValue, resolved);
149
+ if (bindsParameters(resolved)) {
150
+ throw new Error(
151
+ "sql.expr`...` binds parameters and cannot be an onUpdate default \u2014 use sql.raw() for a verbatim expression."
152
+ );
153
+ }
154
+ return this.derive({ onUpdateValue: resolved });
54
155
  }
55
156
  };
157
+ function toSnakeCase(name) {
158
+ return name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
159
+ }
56
160
  var columnsCache = /* @__PURE__ */ new WeakMap();
57
161
  function columnsOf(model) {
58
162
  const cached = columnsCache.get(model);
@@ -67,23 +171,75 @@ function columnsOf(model) {
67
171
  columnsCache.set(model, out);
68
172
  return out;
69
173
  }
174
+ var nameMapCache = /* @__PURE__ */ new WeakMap();
175
+ function columnNamesOf(model) {
176
+ const cached = nameMapCache.get(model);
177
+ if (cached !== void 0) return cached;
178
+ const strategy = model.naming ?? "preserve";
179
+ const map = {};
180
+ const seen = /* @__PURE__ */ new Map();
181
+ let renamed = false;
182
+ for (const [prop, col] of Object.entries(columnsOf(model))) {
183
+ const dbName = col.dbName ?? (strategy === "snake_case" ? toSnakeCase(prop) : prop);
184
+ const collision = seen.get(dbName);
185
+ if (collision !== void 0) {
186
+ throw new Error(
187
+ `${model.tablename}: properties "${collision}" and "${prop}" both map to column "${dbName}".`
188
+ );
189
+ }
190
+ seen.set(dbName, prop);
191
+ map[prop] = dbName;
192
+ if (dbName !== prop) renamed = true;
193
+ }
194
+ const result = renamed ? map : null;
195
+ nameMapCache.set(model, result);
196
+ return result;
197
+ }
70
198
 
71
199
  // src/migrations/ir.ts
200
+ function constraintName(prefix, table, columns) {
201
+ return `${prefix}_${table}_${columns.join("_")}`;
202
+ }
72
203
  function reflectTable(model) {
204
+ const names = columnNamesOf(model);
205
+ const toColumn = (prop) => names?.[prop] ?? prop;
73
206
  const columns = {};
74
207
  const primaryKey = [];
75
- for (const [name, col] of Object.entries(columnsOf(model))) {
208
+ for (const [prop, col] of Object.entries(columnsOf(model))) {
76
209
  const isPk = col.flags.primaryKey;
210
+ const name = toColumn(prop);
77
211
  columns[name] = {
78
212
  name,
79
213
  type: col.type,
80
214
  notNull: col.flags.notNull || isPk,
81
215
  primaryKey: isPk,
82
- default: col.defaultValue
216
+ default: col.defaultValue,
217
+ unique: col.flags.unique,
218
+ references: col.reference
83
219
  };
84
220
  if (isPk) primaryKey.push(name);
85
221
  }
86
- return { name: model.tablename, columns, primaryKey };
222
+ const uniqueConstraints = [];
223
+ const foreignKeys = [];
224
+ for (const c of model.tableArgs?.() ?? []) {
225
+ const cols = c.columns.map(toColumn);
226
+ if (c.kind === "unique") {
227
+ uniqueConstraints.push({
228
+ name: c.name ?? constraintName("uq", model.tablename, cols),
229
+ columns: cols
230
+ });
231
+ } else {
232
+ foreignKeys.push({
233
+ name: c.name ?? constraintName("fk", model.tablename, cols),
234
+ columns: cols,
235
+ refTable: c.refTable,
236
+ refColumns: c.refColumns,
237
+ onDelete: c.onDelete,
238
+ onUpdate: c.onUpdate
239
+ });
240
+ }
241
+ }
242
+ return { name: model.tablename, columns, primaryKey, uniqueConstraints, foreignKeys };
87
243
  }
88
244
  function reflectSchema(models) {
89
245
  const tables = {};
@@ -128,6 +284,10 @@ function invert(op) {
128
284
  return { kind: "rename_column", table: op.table, from: op.to, to: op.from };
129
285
  case "recreate_table":
130
286
  return { kind: "recreate_table", from: op.to, to: op.from };
287
+ case "add_constraint":
288
+ return { kind: "drop_constraint", table: op.table, constraint: op.constraint };
289
+ case "drop_constraint":
290
+ return { kind: "add_constraint", table: op.table, constraint: op.constraint };
131
291
  case "execute":
132
292
  if (op.down === null) {
133
293
  throw new IrreversibleMigration("execute() operation has no down SQL");
@@ -162,6 +322,8 @@ function renderColumnType(type, dialect) {
162
322
  return "NUMERIC";
163
323
  case "blob":
164
324
  return "BLOB";
325
+ case "array":
326
+ throw new Error(unsupportedArray("sqlite"));
165
327
  default:
166
328
  return "TEXT";
167
329
  }
@@ -203,6 +365,8 @@ function renderColumnType(type, dialect) {
203
365
  return "CHAR(36)";
204
366
  case "enum":
205
367
  return `ENUM(${(meta.values ?? []).map(quoteLiteral).join(", ")})`;
368
+ case "array":
369
+ throw new Error(unsupportedArray("mysql"));
206
370
  }
207
371
  }
208
372
  switch (kind) {
@@ -241,27 +405,39 @@ function renderColumnType(type, dialect) {
241
405
  return "UUID";
242
406
  case "enum":
243
407
  return "TEXT";
408
+ case "array":
409
+ return `${renderColumnType(arrayElement(meta.element), dialect)}[]`;
244
410
  }
245
411
  }
246
- function renderDefault(def, dialect) {
412
+ function unsupportedArray(dialect) {
413
+ return `column.array() is PostgreSQL-only \u2014 ${dialect} has no native array type. Model the column as JSON there, accepting that array operators will not work.`;
414
+ }
415
+ function arrayElement(element) {
416
+ if (!element) {
417
+ throw new Error(
418
+ "An array column has no element type \u2014 build it with column.array()."
419
+ );
420
+ }
421
+ return element;
422
+ }
423
+ function renderDefault(def, dialect, type) {
247
424
  if (def.kind === "expression") {
248
425
  const expr = def.expression;
249
- if (typeof expr === "object") return expr.raw;
250
- switch (expr) {
251
- case "now":
252
- return dialect === "postgresql" ? "now()" : "CURRENT_TIMESTAMP";
253
- case "current_date":
254
- return "CURRENT_DATE";
255
- case "current_time":
256
- return "CURRENT_TIME";
257
- case "uuidv4":
258
- if (dialect === "postgresql") return "gen_random_uuid()";
259
- if (dialect === "mysql") return "(UUID())";
260
- return "(lower(hex(randomblob(16))))";
426
+ if (typeof expr === "object") {
427
+ if ("raw" in expr) return expr.raw;
428
+ throw new Error(
429
+ "sql.expr`...` binds parameters and cannot be rendered as a DEFAULT \u2014 use sql.raw()."
430
+ );
261
431
  }
432
+ return renderPortableToken(expr, dialect);
262
433
  }
263
434
  const value = def.value;
264
435
  if (value === null) return "NULL";
436
+ if (Array.isArray(value) && type?.kind === "array") {
437
+ const elementType = renderColumnType(arrayElement(type.meta.element), dialect);
438
+ const items = value.map((v) => renderDefault({ kind: "literal", value: v }, dialect));
439
+ return `ARRAY[${items.join(", ")}]::${elementType}[]`;
440
+ }
265
441
  if (typeof value === "boolean") {
266
442
  return dialect === "postgresql" ? value ? "TRUE" : "FALSE" : value ? "1" : "0";
267
443
  }
@@ -270,10 +446,47 @@ function renderDefault(def, dialect) {
270
446
  if (typeof value === "object") return quoteLiteral(JSON.stringify(value));
271
447
  return quoteLiteral(String(value));
272
448
  }
449
+ function renderFkAction(action) {
450
+ return action.toUpperCase();
451
+ }
452
+ function renderFkActions(fk) {
453
+ let sql = "";
454
+ if (fk.onDelete) sql += ` ON DELETE ${renderFkAction(fk.onDelete)}`;
455
+ if (fk.onUpdate) sql += ` ON UPDATE ${renderFkAction(fk.onUpdate)}`;
456
+ return sql;
457
+ }
458
+ function columnConstraintSuffix(col, dialect) {
459
+ let sql = "";
460
+ if (col.unique) sql += " UNIQUE";
461
+ if (col.references) {
462
+ const ref = col.references;
463
+ sql += ` REFERENCES ${quoteId(ref.table, dialect)} (${quoteId(ref.column, dialect)})`;
464
+ sql += renderFkActions(ref);
465
+ }
466
+ return sql;
467
+ }
468
+ function renderUniqueConstraint(uc, dialect) {
469
+ const cols = uc.columns.map((c) => quoteId(c, dialect)).join(", ");
470
+ return `CONSTRAINT ${quoteId(uc.name, dialect)} UNIQUE (${cols})`;
471
+ }
472
+ function renderForeignKeyConstraint(fk, dialect) {
473
+ const cols = fk.columns.map((c) => quoteId(c, dialect)).join(", ");
474
+ const refCols = fk.refColumns.map((c) => quoteId(c, dialect)).join(", ");
475
+ return `CONSTRAINT ${quoteId(fk.name, dialect)} FOREIGN KEY (${cols}) REFERENCES ${quoteId(fk.refTable, dialect)} (${refCols})${renderFkActions(fk)}`;
476
+ }
477
+ function tableConstraintClauses(table, dialect) {
478
+ return [
479
+ ...table.uniqueConstraints.map((uc) => renderUniqueConstraint(uc, dialect)),
480
+ ...table.foreignKeys.map((fk) => renderForeignKeyConstraint(fk, dialect))
481
+ ];
482
+ }
273
483
  function renderColumnDef(col, dialect) {
274
484
  let sql = `${quoteId(col.name, dialect)} ${renderColumnType(col.type, dialect)}`;
275
485
  if (col.notNull) sql += " NOT NULL";
276
- if (col.default !== null) sql += ` DEFAULT ${renderDefault(col.default, dialect)}`;
486
+ if (col.default !== null) {
487
+ sql += ` DEFAULT ${renderDefault(col.default, dialect, col.type)}`;
488
+ }
489
+ sql += columnConstraintSuffix(col, dialect);
277
490
  return sql;
278
491
  }
279
492
  function enumTypeName(table, column) {
@@ -296,14 +509,16 @@ function renderCreateTable(table, dialect) {
296
509
  typeStmts.push(`CREATE TYPE ${quoteId(typeName, dialect)} AS ENUM (${values})`);
297
510
  let def = `${quoteId(c.name, dialect)} ${quoteId(typeName, dialect)}`;
298
511
  if (c.notNull) def += " NOT NULL";
299
- if (c.default !== null) def += ` DEFAULT ${renderDefault(c.default, dialect)}`;
300
- return def;
512
+ if (c.default !== null) {
513
+ def += ` DEFAULT ${renderDefault(c.default, dialect, c.type)}`;
514
+ }
515
+ return def + columnConstraintSuffix(c, dialect);
301
516
  }
302
517
  if (dialect === "postgresql" && isAutoIncrementPk(table, c)) {
303
- return `${quoteId(c.name, dialect)} ${postgresSerialType(c.type.kind)}`;
518
+ return `${quoteId(c.name, dialect)} ${postgresSerialType(c.type.kind)}${columnConstraintSuffix(c, dialect)}`;
304
519
  }
305
520
  if (dialect === "mysql" && isAutoIncrementPk(table, c)) {
306
- return `${quoteId(c.name, dialect)} ${renderColumnType(c.type, dialect)} NOT NULL AUTO_INCREMENT`;
521
+ return `${quoteId(c.name, dialect)} ${renderColumnType(c.type, dialect)} NOT NULL AUTO_INCREMENT${columnConstraintSuffix(c, dialect)}`;
307
522
  }
308
523
  return renderColumnDef(c, dialect);
309
524
  });
@@ -312,6 +527,7 @@ function renderCreateTable(table, dialect) {
312
527
  `PRIMARY KEY (${table.primaryKey.map((c) => quoteId(c, dialect)).join(", ")})`
313
528
  );
314
529
  }
530
+ cols.push(...tableConstraintClauses(table, dialect));
315
531
  return [
316
532
  ...typeStmts,
317
533
  `CREATE TABLE ${quoteId(table.name, dialect)} (
@@ -345,10 +561,38 @@ function renderOperation(op, dialect) {
345
561
  return renderAlterColumn(op.table, op.to, dialect);
346
562
  case "recreate_table":
347
563
  return dialect === "sqlite" ? renderSqliteRebuild(op.from, op.to) : renderTableDiff(op.from, op.to, dialect);
564
+ case "add_constraint":
565
+ return renderAddConstraint(op.table, op.constraint, dialect);
566
+ case "drop_constraint":
567
+ return renderDropConstraint(op.table, op.constraint, dialect);
348
568
  case "execute":
349
569
  return [op.up];
350
570
  }
351
571
  }
572
+ function renderAddConstraint(table, constraint, dialect) {
573
+ if (dialect === "sqlite") {
574
+ throw new Error(
575
+ `add_constraint on SQLite needs a table-rebuild (recreate_table); constraint ${constraint.constraint.name} on ${table}`
576
+ );
577
+ }
578
+ const clause = constraint.type === "unique" ? renderUniqueConstraint(constraint.constraint, dialect) : renderForeignKeyConstraint(constraint.constraint, dialect);
579
+ return [`ALTER TABLE ${quoteId(table, dialect)} ADD ${clause}`];
580
+ }
581
+ function renderDropConstraint(table, constraint, dialect) {
582
+ if (dialect === "sqlite") {
583
+ throw new Error(
584
+ `drop_constraint on SQLite needs a table-rebuild (recreate_table); constraint ${constraint.constraint.name} on ${table}`
585
+ );
586
+ }
587
+ const t = quoteId(table, dialect);
588
+ const name = quoteId(constraint.constraint.name, dialect);
589
+ if (dialect === "mysql") {
590
+ return [
591
+ constraint.type === "unique" ? `ALTER TABLE ${t} DROP INDEX ${name}` : `ALTER TABLE ${t} DROP FOREIGN KEY ${name}`
592
+ ];
593
+ }
594
+ return [`ALTER TABLE ${t} DROP CONSTRAINT ${name}`];
595
+ }
352
596
  function renderSqliteRebuild(from, to) {
353
597
  const tmp = `__new_${to.name}`;
354
598
  const common = Object.keys(to.columns).filter((c) => c in from.columns);
@@ -358,6 +602,7 @@ function renderSqliteRebuild(from, to) {
358
602
  `PRIMARY KEY (${to.primaryKey.map((c) => quoteId(c, "sqlite")).join(", ")})`
359
603
  );
360
604
  }
605
+ cols.push(...tableConstraintClauses(to, "sqlite"));
361
606
  const commonSql = common.map((c) => quoteId(c, "sqlite")).join(", ");
362
607
  return [
363
608
  "PRAGMA foreign_keys=off",
@@ -410,7 +655,7 @@ function renderAlterColumn(table, to, dialect) {
410
655
  to.notNull ? `ALTER TABLE ${t} ALTER COLUMN ${c} SET NOT NULL` : `ALTER TABLE ${t} ALTER COLUMN ${c} DROP NOT NULL`
411
656
  );
412
657
  stmts.push(
413
- to.default !== null ? `ALTER TABLE ${t} ALTER COLUMN ${c} SET DEFAULT ${renderDefault(to.default, dialect)}` : `ALTER TABLE ${t} ALTER COLUMN ${c} DROP DEFAULT`
658
+ to.default !== null ? `ALTER TABLE ${t} ALTER COLUMN ${c} SET DEFAULT ${renderDefault(to.default, dialect, to.type)}` : `ALTER TABLE ${t} ALTER COLUMN ${c} DROP DEFAULT`
414
659
  );
415
660
  return stmts;
416
661
  }
@@ -421,9 +666,74 @@ function columnSignature(col) {
421
666
  type: col.type,
422
667
  notNull: col.notNull,
423
668
  primaryKey: col.primaryKey,
424
- default: col.default
669
+ default: col.default,
670
+ unique: col.unique,
671
+ references: col.references
672
+ });
673
+ }
674
+ function uniqueSignature(uc) {
675
+ return JSON.stringify({ columns: uc.columns });
676
+ }
677
+ function foreignKeySignature(fk) {
678
+ return JSON.stringify({
679
+ columns: fk.columns,
680
+ refTable: fk.refTable,
681
+ refColumns: fk.refColumns,
682
+ onDelete: fk.onDelete ?? null,
683
+ onUpdate: fk.onUpdate ?? null
425
684
  });
426
685
  }
686
+ function diffConstraints(current, target) {
687
+ const ops = [];
688
+ const table = target.name;
689
+ const currentUq = new Map(current.uniqueConstraints.map((u) => [u.name, u]));
690
+ const targetUq = new Map(target.uniqueConstraints.map((u) => [u.name, u]));
691
+ for (const [name, cur] of currentUq) {
692
+ const tgt = targetUq.get(name);
693
+ if (!tgt || uniqueSignature(cur) !== uniqueSignature(tgt)) {
694
+ ops.push(dropUnique(table, cur));
695
+ }
696
+ }
697
+ for (const [name, tgt] of targetUq) {
698
+ const cur = currentUq.get(name);
699
+ if (!cur || uniqueSignature(cur) !== uniqueSignature(tgt)) {
700
+ ops.push(addUnique(table, tgt));
701
+ }
702
+ }
703
+ const currentFk = new Map(current.foreignKeys.map((f) => [f.name, f]));
704
+ const targetFk = new Map(target.foreignKeys.map((f) => [f.name, f]));
705
+ for (const [name, cur] of currentFk) {
706
+ const tgt = targetFk.get(name);
707
+ if (!tgt || foreignKeySignature(cur) !== foreignKeySignature(tgt)) {
708
+ ops.push(dropForeignKey(table, cur));
709
+ }
710
+ }
711
+ for (const [name, tgt] of targetFk) {
712
+ const cur = currentFk.get(name);
713
+ if (!cur || foreignKeySignature(cur) !== foreignKeySignature(tgt)) {
714
+ ops.push(addForeignKey(table, tgt));
715
+ }
716
+ }
717
+ return ops;
718
+ }
719
+ function uniqueNamed(uc) {
720
+ return { type: "unique", constraint: uc };
721
+ }
722
+ function foreignKeyNamed(fk) {
723
+ return { type: "foreignKey", constraint: fk };
724
+ }
725
+ function addUnique(table, uc) {
726
+ return { kind: "add_constraint", table, constraint: uniqueNamed(uc) };
727
+ }
728
+ function dropUnique(table, uc) {
729
+ return { kind: "drop_constraint", table, constraint: uniqueNamed(uc) };
730
+ }
731
+ function addForeignKey(table, fk) {
732
+ return { kind: "add_constraint", table, constraint: foreignKeyNamed(fk) };
733
+ }
734
+ function dropForeignKey(table, fk) {
735
+ return { kind: "drop_constraint", table, constraint: foreignKeyNamed(fk) };
736
+ }
427
737
  function diffSchema(current, target) {
428
738
  const ops = [];
429
739
  const drops = [];
@@ -452,6 +762,7 @@ function diffSchema(current, target) {
452
762
  ops.push({ kind: "drop_column", table: name, column: currentCol });
453
763
  }
454
764
  }
765
+ ops.push(...diffConstraints(currentTable, targetTable));
455
766
  }
456
767
  for (const [name, currentTable] of Object.entries(current.tables)) {
457
768
  if (!target.tables[name]) {
@@ -594,6 +905,14 @@ var Op = class {
594
905
  recreateTable(from, to) {
595
906
  this.run({ kind: "recreate_table", from, to });
596
907
  }
908
+ /** Add a table-level unique / foreign-key constraint. */
909
+ addConstraint(table, constraint) {
910
+ this.run({ kind: "add_constraint", table, constraint });
911
+ }
912
+ /** Drop a table-level unique / foreign-key constraint. */
913
+ dropConstraint(table, constraint) {
914
+ this.run({ kind: "drop_constraint", table, constraint });
915
+ }
597
916
  /** Raw SQL escape hatch (e.g. a data migration). `down` may be `null`. */
598
917
  execute(up, down = null) {
599
918
  this.run({ kind: "execute", up, down });
@@ -823,6 +1142,39 @@ function affinityToKind(affinity) {
823
1142
  return "text";
824
1143
  }
825
1144
  }
1145
+ function sqliteForeignKeys(driver, table) {
1146
+ const rows = driver.execute(`PRAGMA foreign_key_list(${JSON.stringify(table)})`, []).rows;
1147
+ const byId = /* @__PURE__ */ new Map();
1148
+ for (const r of rows) {
1149
+ const list = byId.get(r.id) ?? [];
1150
+ list.push(r);
1151
+ byId.set(r.id, list);
1152
+ }
1153
+ const fks = [];
1154
+ for (const group of byId.values()) {
1155
+ const ordered = [...group].sort((a, b) => a.seq - b.seq);
1156
+ const columns = ordered.map((r) => r.from);
1157
+ fks.push({
1158
+ name: `fk_${table}_${columns.join("_")}`,
1159
+ columns,
1160
+ refTable: ordered[0]?.table ?? "",
1161
+ refColumns: ordered.map((r) => r.to)
1162
+ });
1163
+ }
1164
+ return fks;
1165
+ }
1166
+ function sqliteUniques(driver, table) {
1167
+ const indexes = driver.execute(`PRAGMA index_list(${JSON.stringify(table)})`, []).rows;
1168
+ const uniques = [];
1169
+ for (const idx of indexes) {
1170
+ if (Number(idx.unique) !== 1 || idx.origin === "pk") continue;
1171
+ const cols = driver.execute(`PRAGMA index_info(${JSON.stringify(idx.name)})`, []).rows.sort((a, b) => a.seqno - b.seqno).map((c) => c.name);
1172
+ if (cols.length > 0) {
1173
+ uniques.push({ name: `uq_${table}_${cols.join("_")}`, columns: cols });
1174
+ }
1175
+ }
1176
+ return uniques;
1177
+ }
826
1178
  function introspectSqlite(driver) {
827
1179
  const tablesRows = driver.execute(
828
1180
  "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'tempest_db_js_migrations'",
@@ -842,14 +1194,39 @@ function introspectSqlite(driver) {
842
1194
  type: { kind: affinityToKind(affinity), meta: {} },
843
1195
  notNull: Number(col.notnull) === 1 || isPk,
844
1196
  primaryKey: isPk,
845
- default: null
1197
+ default: null,
1198
+ unique: false,
1199
+ references: null
846
1200
  };
847
1201
  if (isPk) primaryKey.push(col.name);
848
1202
  }
849
- tables[tableName] = { name: tableName, columns, primaryKey };
1203
+ tables[tableName] = {
1204
+ name: tableName,
1205
+ columns,
1206
+ primaryKey,
1207
+ uniqueConstraints: sqliteUniques(driver, tableName),
1208
+ foreignKeys: sqliteForeignKeys(driver, tableName)
1209
+ };
850
1210
  }
851
1211
  return { tables };
852
1212
  }
1213
+ function constraintKeys(table) {
1214
+ const fks = /* @__PURE__ */ new Set();
1215
+ const uniques = /* @__PURE__ */ new Set();
1216
+ for (const col of Object.values(table.columns)) {
1217
+ if (col.unique) uniques.add(col.name);
1218
+ if (col.references) {
1219
+ fks.add(`${col.name}=>${col.references.table}(${col.references.column})`);
1220
+ }
1221
+ }
1222
+ for (const uc of table.uniqueConstraints) {
1223
+ uniques.add([...uc.columns].sort().join(","));
1224
+ }
1225
+ for (const fk of table.foreignKeys) {
1226
+ fks.add(`${fk.columns.join(",")}=>${fk.refTable}(${fk.refColumns.join(",")})`);
1227
+ }
1228
+ return { fks, uniques };
1229
+ }
853
1230
  function checkDrift(driver, models) {
854
1231
  const actual = introspectSqlite(driver);
855
1232
  const expected = reflectSchema(models);
@@ -887,6 +1264,34 @@ function checkDrift(driver, models) {
887
1264
  );
888
1265
  }
889
1266
  }
1267
+ const expectedKeys = constraintKeys(expectedTable);
1268
+ const actualKeys = constraintKeys(actualTable);
1269
+ for (const fk of expectedKeys.fks) {
1270
+ if (!actualKeys.fks.has(fk)) {
1271
+ issues.push(`foreign key "${tableName}: ${fk}" is missing from the database`);
1272
+ }
1273
+ }
1274
+ for (const fk of actualKeys.fks) {
1275
+ if (!expectedKeys.fks.has(fk)) {
1276
+ issues.push(
1277
+ `foreign key "${tableName}: ${fk}" exists in the database but not in the model`
1278
+ );
1279
+ }
1280
+ }
1281
+ for (const uq of expectedKeys.uniques) {
1282
+ if (!actualKeys.uniques.has(uq)) {
1283
+ issues.push(
1284
+ `unique constraint "${tableName}: (${uq})" is missing from the database`
1285
+ );
1286
+ }
1287
+ }
1288
+ for (const uq of actualKeys.uniques) {
1289
+ if (!expectedKeys.uniques.has(uq)) {
1290
+ issues.push(
1291
+ `unique constraint "${tableName}: (${uq})" exists in the database but not in the model`
1292
+ );
1293
+ }
1294
+ }
890
1295
  }
891
1296
  for (const tableName of Object.keys(actual.tables)) {
892
1297
  if (!expected.tables[tableName]) {
@@ -895,6 +1300,54 @@ function checkDrift(driver, models) {
895
1300
  }
896
1301
  return issues;
897
1302
  }
1303
+ function pgTypeToColumnType(dataType, udtName) {
1304
+ if (dataType.toLowerCase() === "array") {
1305
+ return {
1306
+ kind: "array",
1307
+ meta: { element: { kind: pgUdtToKind(udtName.replace(/^_/, "")), meta: {} } }
1308
+ };
1309
+ }
1310
+ return { kind: pgTypeToKind(dataType, udtName), meta: {} };
1311
+ }
1312
+ function pgUdtToKind(udtName) {
1313
+ switch (udtName) {
1314
+ case "int2":
1315
+ return "smallint";
1316
+ case "int4":
1317
+ return "integer";
1318
+ case "int8":
1319
+ return "bigint";
1320
+ case "float4":
1321
+ return "real";
1322
+ case "float8":
1323
+ return "double";
1324
+ case "numeric":
1325
+ return "numeric";
1326
+ case "varchar":
1327
+ return "varchar";
1328
+ case "bpchar":
1329
+ return "char";
1330
+ case "bool":
1331
+ return "boolean";
1332
+ case "date":
1333
+ return "date";
1334
+ case "time":
1335
+ case "timetz":
1336
+ return "time";
1337
+ case "timestamp":
1338
+ case "timestamptz":
1339
+ return "timestamp";
1340
+ case "bytea":
1341
+ return "blob";
1342
+ case "json":
1343
+ case "jsonb":
1344
+ return "json";
1345
+ case "uuid":
1346
+ return "uuid";
1347
+ default:
1348
+ return "text";
1349
+ }
1350
+ }
898
1351
  function pgTypeToKind(dataType, udtName) {
899
1352
  const t = dataType.toLowerCase();
900
1353
  if (t === "user-defined") return "enum";
@@ -942,20 +1395,66 @@ async function introspectPostgres(driver) {
942
1395
  const isPk = pkSet.has(name);
943
1396
  columns[name] = {
944
1397
  name,
945
- type: {
946
- kind: pgTypeToKind(String(col.data_type), String(col.udt_name)),
947
- meta: {}
948
- },
1398
+ type: pgTypeToColumnType(String(col.data_type), String(col.udt_name)),
949
1399
  notNull: col.is_nullable === "NO" || isPk,
950
1400
  primaryKey: isPk,
951
- default: null
1401
+ default: null,
1402
+ unique: false,
1403
+ references: null
952
1404
  };
953
1405
  if (isPk) primaryKey.push(name);
954
1406
  }
955
- tables[tableName] = { name: tableName, columns, primaryKey };
1407
+ tables[tableName] = {
1408
+ name: tableName,
1409
+ columns,
1410
+ primaryKey,
1411
+ uniqueConstraints: await postgresUniques(driver, tableName),
1412
+ foreignKeys: await postgresForeignKeys(driver, tableName)
1413
+ };
956
1414
  }
957
1415
  return { tables };
958
1416
  }
1417
+ async function postgresForeignKeys(driver, table) {
1418
+ const result = await driver.execute(
1419
+ `SELECT c.conname AS name,
1420
+ (SELECT array_agg(a.attname ORDER BY k.ord)
1421
+ FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
1422
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS cols,
1423
+ cf.relname AS ref_table,
1424
+ (SELECT array_agg(a.attname ORDER BY k.ord)
1425
+ FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord)
1426
+ JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum) AS ref_cols
1427
+ FROM pg_constraint c
1428
+ JOIN pg_class cf ON cf.oid = c.confrelid
1429
+ WHERE c.contype = 'f' AND c.conrelid = $1::regclass`,
1430
+ [table]
1431
+ );
1432
+ return result.rows.map((r) => ({
1433
+ name: String(r.name),
1434
+ columns: r.cols ?? [],
1435
+ refTable: String(r.ref_table),
1436
+ refColumns: r.ref_cols ?? []
1437
+ }));
1438
+ }
1439
+ async function postgresUniques(driver, table) {
1440
+ const result = await driver.execute(
1441
+ `SELECT c.conname AS name,
1442
+ (SELECT array_agg(a.attname ORDER BY k.ord)
1443
+ FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
1444
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS cols
1445
+ FROM pg_constraint c
1446
+ WHERE c.contype = 'u' AND c.conrelid = $1::regclass`,
1447
+ [table]
1448
+ );
1449
+ return result.rows.map((r) => ({
1450
+ name: String(r.name),
1451
+ columns: r.cols ?? []
1452
+ }));
1453
+ }
1454
+ function describeKind(type) {
1455
+ if (type.kind !== "array") return type.kind;
1456
+ return `${type.meta.element ? describeKind(type.meta.element) : "unknown"}[]`;
1457
+ }
959
1458
  async function checkDriftPostgres(driver, models) {
960
1459
  const actual = await introspectPostgres(driver);
961
1460
  const expected = reflectSchema(models);
@@ -972,9 +1471,9 @@ async function checkDriftPostgres(driver, models) {
972
1471
  issues.push(`column "${tableName}.${colName}" is missing from the database`);
973
1472
  continue;
974
1473
  }
975
- if (expectedCol.type.kind !== actualCol.type.kind) {
1474
+ if (describeKind(expectedCol.type) !== describeKind(actualCol.type)) {
976
1475
  issues.push(
977
- `column "${tableName}.${colName}" type differs: model ${expectedCol.type.kind}, db ${actualCol.type.kind}`
1476
+ `column "${tableName}.${colName}" type differs: model ${describeKind(expectedCol.type)}, db ${describeKind(actualCol.type)}`
978
1477
  );
979
1478
  }
980
1479
  if (expectedCol.notNull !== actualCol.notNull) {
@@ -988,6 +1487,20 @@ async function checkDriftPostgres(driver, models) {
988
1487
  );
989
1488
  }
990
1489
  }
1490
+ const expectedKeys = constraintKeys(expectedTable);
1491
+ const actualKeys = constraintKeys(actualTable);
1492
+ for (const fk of expectedKeys.fks) {
1493
+ if (!actualKeys.fks.has(fk)) {
1494
+ issues.push(`foreign key "${tableName}: ${fk}" is missing from the database`);
1495
+ }
1496
+ }
1497
+ for (const uq of expectedKeys.uniques) {
1498
+ if (!actualKeys.uniques.has(uq)) {
1499
+ issues.push(
1500
+ `unique constraint "${tableName}: (${uq})" is missing from the database`
1501
+ );
1502
+ }
1503
+ }
991
1504
  }
992
1505
  for (const tableName of Object.keys(actual.tables)) {
993
1506
  if (!expected.tables[tableName]) {
@@ -1059,6 +1572,30 @@ function applyOperation(schema, op) {
1059
1572
  }
1060
1573
  break;
1061
1574
  }
1575
+ case "add_constraint": {
1576
+ const t = tables[op.table];
1577
+ if (t) {
1578
+ tables[op.table] = op.constraint.type === "unique" ? {
1579
+ ...t,
1580
+ uniqueConstraints: [...t.uniqueConstraints, op.constraint.constraint]
1581
+ } : { ...t, foreignKeys: [...t.foreignKeys, op.constraint.constraint] };
1582
+ }
1583
+ break;
1584
+ }
1585
+ case "drop_constraint": {
1586
+ const t = tables[op.table];
1587
+ if (t) {
1588
+ const dropName = op.constraint.constraint.name;
1589
+ tables[op.table] = op.constraint.type === "unique" ? {
1590
+ ...t,
1591
+ uniqueConstraints: t.uniqueConstraints.filter((u) => u.name !== dropName)
1592
+ } : {
1593
+ ...t,
1594
+ foreignKeys: t.foreignKeys.filter((f) => f.name !== dropName)
1595
+ };
1596
+ }
1597
+ break;
1598
+ }
1062
1599
  }
1063
1600
  return { tables };
1064
1601
  }
@@ -1080,7 +1617,9 @@ function columnShape(col) {
1080
1617
  type: col.type,
1081
1618
  notNull: col.notNull,
1082
1619
  primaryKey: col.primaryKey,
1083
- default: col.default
1620
+ default: col.default,
1621
+ unique: col.unique,
1622
+ references: col.references
1084
1623
  });
1085
1624
  }
1086
1625
  function tableShape(columns) {