turbine-orm 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +17 -13
  2. package/dist/cjs/cli/config.js +20 -3
  3. package/dist/cjs/cli/destructive.js +47 -31
  4. package/dist/cjs/cli/index.js +273 -71
  5. package/dist/cjs/cli/mcp.js +788 -0
  6. package/dist/cjs/cli/migrate.js +95 -20
  7. package/dist/cjs/cli/studio.js +3 -2
  8. package/dist/cjs/client.js +267 -34
  9. package/dist/cjs/dialect.js +2 -0
  10. package/dist/cjs/generate.js +171 -7
  11. package/dist/cjs/index.js +4 -1
  12. package/dist/cjs/introspect.js +177 -4
  13. package/dist/cjs/query/batched-loader.js +148 -0
  14. package/dist/cjs/query/builder.js +714 -133
  15. package/dist/cjs/schema-builder.js +59 -4
  16. package/dist/cjs/schema-sql.js +315 -6
  17. package/dist/cjs/seed.js +66 -0
  18. package/dist/cli/config.d.ts +9 -2
  19. package/dist/cli/config.js +19 -3
  20. package/dist/cli/destructive.js +47 -31
  21. package/dist/cli/index.d.ts +52 -1
  22. package/dist/cli/index.js +272 -74
  23. package/dist/cli/mcp.d.ts +17 -0
  24. package/dist/cli/mcp.js +781 -0
  25. package/dist/cli/migrate.d.ts +37 -0
  26. package/dist/cli/migrate.js +92 -20
  27. package/dist/cli/studio.d.ts +3 -2
  28. package/dist/cli/studio.js +3 -2
  29. package/dist/client.d.ts +136 -1
  30. package/dist/client.js +267 -34
  31. package/dist/dialect.d.ts +17 -0
  32. package/dist/dialect.js +2 -0
  33. package/dist/generate.d.ts +17 -0
  34. package/dist/generate.js +171 -10
  35. package/dist/index.d.ts +4 -3
  36. package/dist/index.js +2 -0
  37. package/dist/introspect.d.ts +20 -1
  38. package/dist/introspect.js +175 -4
  39. package/dist/query/batched-loader.d.ts +29 -2
  40. package/dist/query/batched-loader.js +148 -1
  41. package/dist/query/builder.d.ts +156 -8
  42. package/dist/query/builder.js +715 -134
  43. package/dist/query/index.d.ts +1 -1
  44. package/dist/query/types.d.ts +113 -8
  45. package/dist/schema-builder.d.ts +73 -8
  46. package/dist/schema-builder.js +59 -4
  47. package/dist/schema-sql.d.ts +67 -0
  48. package/dist/schema-sql.js +310 -6
  49. package/dist/schema.d.ts +53 -0
  50. package/dist/seed.d.ts +4 -0
  51. package/dist/seed.js +63 -0
  52. package/package.json +2 -3
@@ -6,7 +6,28 @@
6
6
  */
7
7
  import pg from 'pg';
8
8
  import { postgresDialect } from './dialect.js';
9
+ import { UnsupportedFeatureError } from './errors.js';
10
+ import { pgConfActionToReferential, stripCheckWrapper } from './introspect.js';
9
11
  import { camelToSnake } from './schema.js';
12
+ /** Map a {@link ReferentialAction} to its SQL keyword form. */
13
+ export function referentialActionToSql(action) {
14
+ switch (action) {
15
+ case 'cascade':
16
+ return 'CASCADE';
17
+ case 'restrict':
18
+ return 'RESTRICT';
19
+ case 'set null':
20
+ return 'SET NULL';
21
+ case 'set default':
22
+ return 'SET DEFAULT';
23
+ case 'no action':
24
+ return 'NO ACTION';
25
+ }
26
+ }
27
+ /** Single-quote-escape an enum label for a `CREATE TYPE ... AS ENUM` literal. */
28
+ function quoteEnumLabel(label) {
29
+ return `'${label.replace(/'/g, "''")}'`;
30
+ }
10
31
  /**
11
32
  * Whether a resolved column type is an auto-increment pseudo-type (SERIAL /
12
33
  * BIGSERIAL). These carry an implicit sequence default and NOT NULL, and their
@@ -16,6 +37,38 @@ import { camelToSnake } from './schema.js';
16
37
  function isSerialType(type) {
17
38
  return type === 'SERIAL' || type === 'BIGSERIAL';
18
39
  }
40
+ /** Whether any column in the schema is a pgvector column. */
41
+ function schemaHasVectorColumn(schema) {
42
+ for (const table of Object.values(schema.tables)) {
43
+ for (const col of Object.values(table.columns)) {
44
+ if (col.vectorDimensions != null)
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ /** Build a `CREATE TYPE "<name>" AS ENUM ('a', 'b')` statement. */
51
+ function generateCreateEnumType(enumName, labels, dialect) {
52
+ const values = labels.map(quoteEnumLabel).join(', ');
53
+ return `CREATE TYPE ${dialect.quoteIdentifier(enumName)} AS ENUM (${values});`;
54
+ }
55
+ /**
56
+ * Resolve the DDL type token for a column: an enum type name, a `vector(n)`
57
+ * literal, or the dialect's scalar type — with a trailing `[]` for arrays.
58
+ */
59
+ function resolveDdlType(config, dialect) {
60
+ let base;
61
+ if (config.enumName) {
62
+ base = dialect.quoteIdentifier(config.enumName);
63
+ }
64
+ else if (config.vectorDimensions != null) {
65
+ base = `vector(${config.vectorDimensions})`;
66
+ }
67
+ else {
68
+ base = dialect.buildColumnType({ type: config.type, maxLength: config.maxLength });
69
+ }
70
+ return config.isArray ? `${base}[]` : base;
71
+ }
19
72
  // ---------------------------------------------------------------------------
20
73
  // SQL Generation — SchemaDef → CREATE TABLE statements
21
74
  // ---------------------------------------------------------------------------
@@ -27,10 +80,25 @@ function isSerialType(type) {
27
80
  */
28
81
  export function schemaToSQL(schema, options) {
29
82
  const dialect = options?.dialect ?? postgresDialect;
83
+ const extensions = options?.extensions ?? 'auto';
30
84
  const statements = [];
31
85
  // Topologically sort tables by their foreign key references
32
86
  const sorted = topologicalSort(schema);
33
87
  const resolveRef = makeRefResolver(schema);
88
+ // pgvector extension line — only when a vector column exists. Postgres-only:
89
+ // a dialect that can't do pgvector must not silently emit broken DDL.
90
+ if (schemaHasVectorColumn(schema)) {
91
+ if (!dialect.supportsVector) {
92
+ throw new UnsupportedFeatureError('vector columns', dialect.name, 'pgvector is a PostgreSQL-only feature.');
93
+ }
94
+ statements.push(extensions === 'manual'
95
+ ? '-- Requires the pgvector extension: run `CREATE EXTENSION IF NOT EXISTS vector;` before applying.'
96
+ : 'CREATE EXTENSION IF NOT EXISTS vector;');
97
+ }
98
+ // CREATE TYPE for every schema-level enum, before the tables that use them.
99
+ for (const [enumName, labels] of Object.entries(schema.enums ?? {})) {
100
+ statements.push(generateCreateEnumType(enumName, labels, dialect));
101
+ }
34
102
  // Generate CREATE TABLE statements
35
103
  for (const tableName of sorted) {
36
104
  const table = schema.tables[tableName];
@@ -147,6 +215,12 @@ function generateCreateTable(table, resolveRef, dialect = postgresDialect) {
147
215
  const cols = compositePk.map((c) => dialect.quoteIdentifier(camelToSnake(c)));
148
216
  columnDefs.push(dialect.buildPrimaryKeyConstraint(cols));
149
217
  }
218
+ // Table-level CHECK constraints (named → CONSTRAINT "name" CHECK (...)).
219
+ for (const chk of table.checks ?? []) {
220
+ columnDefs.push(chk.name
221
+ ? `CONSTRAINT ${dialect.quoteIdentifier(chk.name)} CHECK (${chk.expression})`
222
+ : `CHECK (${chk.expression})`);
223
+ }
150
224
  return dialect.buildCreateTableStatement({
151
225
  table: dialect.quoteIdentifier(tableName),
152
226
  definitions: columnDefs,
@@ -185,16 +259,33 @@ function generateColumnDef(fieldName, config, resolveRef, dialect = postgresDial
185
259
  };
186
260
  }
187
261
  }
188
- return dialect.buildColumnDefinition({
262
+ // Resolve the DDL type (enum name / vector(n) / scalar, plus [] for arrays).
263
+ // Passed as a fully-formed `type` token with no maxLength so the dialect
264
+ // doesn't re-apply VARCHAR(n) on top of it.
265
+ const ddlType = resolveDdlType(config, dialect);
266
+ let def = dialect.buildColumnDefinition({
189
267
  name: dialect.quoteIdentifier(snakeName),
190
- type: config.type,
191
- maxLength: config.maxLength,
268
+ type: ddlType,
269
+ maxLength: null,
192
270
  primaryKey: config.isPrimaryKey,
193
271
  unique: config.isUnique,
194
272
  notNull,
195
273
  defaultValue,
196
274
  references,
197
275
  });
276
+ // Referential actions follow the REFERENCES clause (which buildColumnDefinition
277
+ // emits last). Postgres omits ON DELETE/UPDATE for the default NO ACTION.
278
+ if (references) {
279
+ if (config.onDelete)
280
+ def += ` ON DELETE ${referentialActionToSql(config.onDelete)}`;
281
+ if (config.onUpdate)
282
+ def += ` ON UPDATE ${referentialActionToSql(config.onUpdate)}`;
283
+ }
284
+ // Column-level CHECK constraint (raw SQL expression, user-authored).
285
+ if (config.check) {
286
+ def += ` CHECK (${config.check})`;
287
+ }
288
+ return def;
198
289
  }
199
290
  /**
200
291
  * Normalize a default value from the user's schema definition to valid SQL.
@@ -249,6 +340,94 @@ function generateForeignKeyIndexes(table, dialect = postgresDialect) {
249
340
  }
250
341
  return indexes;
251
342
  }
343
+ /**
344
+ * Build the `ADD CONSTRAINT ... FOREIGN KEY` statement for a FK with the given
345
+ * referential actions. Default (`no action`) clauses are omitted, matching how
346
+ * Postgres normalizes them, so re-diffing is stable.
347
+ */
348
+ export function buildAddForeignKeyStatement(table, constraintName, column, targetTable, targetColumn, onDelete, onUpdate, dialect = postgresDialect) {
349
+ const q = (s) => dialect.quoteIdentifier(s);
350
+ let sql = `ALTER TABLE ${q(table)} ADD CONSTRAINT ${q(constraintName)} FOREIGN KEY (${q(column)}) REFERENCES ${q(targetTable)}(${q(targetColumn)})`;
351
+ if (onDelete !== 'no action')
352
+ sql += ` ON DELETE ${referentialActionToSql(onDelete)}`;
353
+ if (onUpdate !== 'no action')
354
+ sql += ` ON UPDATE ${referentialActionToSql(onUpdate)}`;
355
+ return `${sql};`;
356
+ }
357
+ /**
358
+ * Decide whether a FK's referential actions changed. When they differ, returns
359
+ * the DROP + ADD CONSTRAINT statements (and their reverse) — Postgres has no
360
+ * `ALTER CONSTRAINT` for referential actions, so drop-and-recreate is the only
361
+ * path. Returns null when the actions already match.
362
+ */
363
+ export function diffReferentialAction(table, db, schemaOnDelete, schemaOnUpdate, dialect = postgresDialect) {
364
+ if (db.onDelete === schemaOnDelete && db.onUpdate === schemaOnUpdate)
365
+ return null;
366
+ const q = (s) => dialect.quoteIdentifier(s);
367
+ const drop = `ALTER TABLE ${q(table)} DROP CONSTRAINT ${q(db.constraintName)};`;
368
+ const add = buildAddForeignKeyStatement(table, db.constraintName, db.column, db.targetTable, db.targetColumn, schemaOnDelete, schemaOnUpdate, dialect);
369
+ const reverseAdd = buildAddForeignKeyStatement(table, db.constraintName, db.column, db.targetTable, db.targetColumn, db.onDelete, db.onUpdate, dialect);
370
+ return { statements: [drop, add], reverseStatements: [drop, reverseAdd] };
371
+ }
372
+ /**
373
+ * Compute append-only enum value changes. Returns `ALTER TYPE ... ADD VALUE`
374
+ * statements for labels present in the schema but not the DB (in order), plus a
375
+ * destructive warning for any DB label the schema dropped or any reorder —
376
+ * Postgres cannot remove or reorder enum values without recreating the type.
377
+ */
378
+ export function diffEnumValues(enumName, schemaLabels, dbLabels, dialect = postgresDialect) {
379
+ const statements = [];
380
+ const warnings = [];
381
+ const dbSet = new Set(dbLabels);
382
+ for (const label of schemaLabels) {
383
+ if (!dbSet.has(label)) {
384
+ statements.push(`ALTER TYPE ${dialect.quoteIdentifier(enumName)} ADD VALUE ${quoteEnumLabel(label)};`);
385
+ }
386
+ }
387
+ const schemaSet = new Set(schemaLabels);
388
+ const removed = dbLabels.filter((l) => !schemaSet.has(l));
389
+ if (removed.length > 0) {
390
+ warnings.push(`Enum "${enumName}": labels [${removed.join(', ')}] exist in the database but not the schema. ` +
391
+ `Postgres cannot remove enum values in place — recreate the type manually if intended.`);
392
+ }
393
+ return { statements, warnings };
394
+ }
395
+ /**
396
+ * Diff a table's CHECK constraints (matched by name). Adds constraints missing
397
+ * from the DB, drops DB constraints absent from the schema, and drop+adds when a
398
+ * same-named constraint's expression changed. Expression comparison is a naive
399
+ * whitespace-insensitive match — semantically-equal-but-different-spelled
400
+ * expressions may re-emit (documented; harmless drop+add).
401
+ */
402
+ export function diffCheckConstraints(table, schemaChecks, dbChecks, dialect = postgresDialect) {
403
+ const q = (s) => dialect.quoteIdentifier(s);
404
+ const statements = [];
405
+ const reverseStatements = [];
406
+ const dbByName = new Map(dbChecks.map((c) => [c.name, c]));
407
+ const schemaByName = new Map(schemaChecks.map((c) => [c.name, c]));
408
+ const norm = (e) => e.replace(/\s+/g, ' ').trim();
409
+ const addStmt = (c) => `ALTER TABLE ${q(table)} ADD CONSTRAINT ${q(c.name)} CHECK (${c.expression});`;
410
+ const dropStmt = (name) => `ALTER TABLE ${q(table)} DROP CONSTRAINT ${q(name)};`;
411
+ for (const sc of schemaChecks) {
412
+ const existing = dbByName.get(sc.name);
413
+ if (!existing) {
414
+ statements.push(addStmt(sc));
415
+ reverseStatements.push(dropStmt(sc.name));
416
+ }
417
+ else if (norm(existing.expression) !== norm(sc.expression)) {
418
+ // Expression changed → drop + add.
419
+ statements.push(dropStmt(sc.name), addStmt(sc));
420
+ reverseStatements.push(dropStmt(sc.name), addStmt(existing));
421
+ }
422
+ }
423
+ for (const dc of dbChecks) {
424
+ if (!schemaByName.has(dc.name)) {
425
+ statements.push(dropStmt(dc.name));
426
+ reverseStatements.push(addStmt(dc));
427
+ }
428
+ }
429
+ return { statements, reverseStatements };
430
+ }
252
431
  /**
253
432
  * Compare a SchemaDef against a live Postgres database and return the diff.
254
433
  *
@@ -303,12 +482,78 @@ export async function schemaDiff(schema, connectionString) {
303
482
  dbUniques[row.table_name] = {};
304
483
  dbUniques[row.table_name][row.column_name] = row.constraint_name;
305
484
  }
485
+ // Existing enums (typname → ordered labels) for CREATE TYPE / ADD VALUE diff.
486
+ const enumResult = await client.query(`SELECT t.typname, e.enumlabel
487
+ FROM pg_type t
488
+ JOIN pg_enum e ON t.oid = e.enumtypid
489
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
490
+ WHERE n.nspname = 'public'
491
+ ORDER BY t.typname, e.enumsortorder`);
492
+ const dbEnums = {};
493
+ for (const row of enumResult.rows) {
494
+ if (!dbEnums[row.typname])
495
+ dbEnums[row.typname] = [];
496
+ dbEnums[row.typname].push(row.enumlabel);
497
+ }
498
+ // Existing single-column FK referential actions, keyed by table → column.
499
+ const fkResult = await client.query(`SELECT rel.relname AS table_name, con.conname AS constraint_name,
500
+ att.attname AS column, tgt.relname AS target_table,
501
+ tatt.attname AS target_column, con.confdeltype, con.confupdtype
502
+ FROM pg_constraint con
503
+ JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
504
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
505
+ JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
506
+ JOIN pg_catalog.pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = con.conkey[1]
507
+ JOIN pg_catalog.pg_attribute tatt ON tatt.attrelid = con.confrelid AND tatt.attnum = con.confkey[1]
508
+ WHERE con.contype = 'f' AND n.nspname = 'public'
509
+ AND array_length(con.conkey, 1) = 1`);
510
+ const dbForeignKeys = {};
511
+ for (const row of fkResult.rows) {
512
+ if (!dbForeignKeys[row.table_name])
513
+ dbForeignKeys[row.table_name] = {};
514
+ dbForeignKeys[row.table_name][row.column] = {
515
+ constraintName: row.constraint_name,
516
+ column: row.column,
517
+ targetTable: row.target_table,
518
+ targetColumn: row.target_column,
519
+ onDelete: pgConfActionToReferential(row.confdeltype),
520
+ onUpdate: pgConfActionToReferential(row.confupdtype),
521
+ };
522
+ }
523
+ // Existing CHECK constraints (contype='c'), keyed by table.
524
+ const checkResult = await client.query(`SELECT rel.relname AS table_name, con.conname, pg_get_constraintdef(con.oid) AS definition
525
+ FROM pg_constraint con
526
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
527
+ JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
528
+ WHERE con.contype = 'c' AND n.nspname = 'public'`);
529
+ const dbChecks = {};
530
+ for (const row of checkResult.rows) {
531
+ if (!dbChecks[row.table_name])
532
+ dbChecks[row.table_name] = [];
533
+ dbChecks[row.table_name].push({ name: row.conname, expression: stripCheckWrapper(row.definition) });
534
+ }
306
535
  // Build a set of DDL-facing snake_case table names that the schema defines.
307
536
  const schemaDdlNames = new Set();
308
537
  for (const def of Object.values(schema.tables))
309
538
  schemaDdlNames.add(def.name);
310
- const result = { create: [], alter: [], drop: [], statements: [], reverseStatements: [] };
539
+ const result = { create: [], alter: [], drop: [], statements: [], reverseStatements: [], warnings: [] };
311
540
  const resolveRef = makeRefResolver(schema);
541
+ // --- Enums: CREATE TYPE for new enums (before tables), ADD VALUE for grown
542
+ // ones, and a warning for any destructive removal/reorder. New CREATE
543
+ // TYPEs go first so tables that reference them create cleanly. ---
544
+ for (const [enumName, labels] of Object.entries(schema.enums ?? {})) {
545
+ const existing = dbEnums[enumName];
546
+ if (!existing) {
547
+ result.statements.push(generateCreateEnumType(enumName, labels, dialect));
548
+ result.reverseStatements.unshift(`DROP TYPE IF EXISTS ${dialect.quoteIdentifier(enumName)};`);
549
+ }
550
+ else {
551
+ const { statements, warnings } = diffEnumValues(enumName, labels, existing, dialect);
552
+ result.statements.push(...statements);
553
+ result.warnings.push(...warnings);
554
+ // ADD VALUE cannot be reversed (Postgres can't drop enum values).
555
+ }
556
+ }
312
557
  // Tables to create (in schema but not in DB)
313
558
  const sorted = topologicalSort(schema);
314
559
  for (const tableKey of sorted) {
@@ -359,7 +604,9 @@ export async function schemaDiff(schema, connectionString) {
359
604
  // BIGSERIAL (int8) before 0.24.0 — `push` won't try to shrink them.
360
605
  const expectedUdt = schemaTypeToUdt(config);
361
606
  if (expectedUdt && !isSerialType(config.type) && dbCol.udtName !== expectedUdt) {
362
- const sqlType = config.type === 'VARCHAR' && config.maxLength ? `VARCHAR(${config.maxLength})` : config.type;
607
+ // resolveDdlType handles enum names, vector(n), arrays, and VARCHAR(n)
608
+ // config.type alone would emit the internal ENUM/VECTOR sentinels here.
609
+ const sqlType = resolveDdlType(config, dialect);
363
610
  const oldSqlType = udtToSqlType(dbCol.udtName, dbCol.maxLength);
364
611
  const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${dialect.quoteIdentifier(snakeName)} TYPE ${sqlType} USING ${dialect.quoteIdentifier(snakeName)}::${sqlType};`;
365
612
  const reverseSql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${dialect.quoteIdentifier(snakeName)} TYPE ${oldSqlType} USING ${dialect.quoteIdentifier(snakeName)}::${oldSqlType};`;
@@ -452,6 +699,54 @@ export async function schemaDiff(schema, connectionString) {
452
699
  if (alterDef.columns.length > 0) {
453
700
  result.alter.push(alterDef);
454
701
  }
702
+ // --- Referential action changes on existing single-column FKs ---
703
+ // Postgres has no ALTER CONSTRAINT for actions → DROP + ADD CONSTRAINT.
704
+ const tableFks = dbForeignKeys[tableName] ?? {};
705
+ for (const [fieldName, config] of Object.entries(tableDef.columns)) {
706
+ if (!config.referencesTarget)
707
+ continue;
708
+ const snakeName = camelToSnake(fieldName);
709
+ const dbFk = tableFks[snakeName];
710
+ if (!dbFk)
711
+ continue; // FK not present in DB yet (or composite) — skip
712
+ const change = diffReferentialAction(tableName, dbFk, config.onDelete ?? 'no action', config.onUpdate ?? 'no action', dialect);
713
+ if (change) {
714
+ result.statements.push(...change.statements);
715
+ for (const rev of change.reverseStatements.slice().reverse())
716
+ result.reverseStatements.unshift(rev);
717
+ }
718
+ }
719
+ // --- Named table-level CHECK constraints ---
720
+ // Presence-only diffing: ADD checks whose NAME is missing from the DB.
721
+ // We do NOT auto-drop DB checks absent from the schema (column-level /
722
+ // inline checks carry auto-generated names the code-first schema never
723
+ // sees), and we do NOT drop+add on expression mismatch: pg_get_constraintdef
724
+ // canonicalizes expressions (casts, ANY(ARRAY[...]) rewrites), so authored
725
+ // text almost never string-matches the stored form — comparing would emit a
726
+ // spurious full-table-revalidating drop+add on every diff. An apparent
727
+ // mismatch surfaces as a warning instead; rename the constraint to
728
+ // intentionally replace its expression. Unnamed schema checks are skipped
729
+ // (no stable identity to diff on).
730
+ const namedSchemaChecks = (tableDef.checks ?? [])
731
+ .filter((c) => typeof c.name === 'string' && c.name.length > 0)
732
+ .map((c) => ({ name: c.name, expression: c.expression }));
733
+ const tableDbChecks = dbChecks[tableName] ?? [];
734
+ const dbCheckByName = new Map(tableDbChecks.map((c) => [c.name, c]));
735
+ const normExpr = (e) => e.replace(/\s+/g, ' ').trim();
736
+ for (const sc of namedSchemaChecks) {
737
+ const existing = dbCheckByName.get(sc.name);
738
+ const addSql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ADD CONSTRAINT ${dialect.quoteIdentifier(sc.name)} CHECK (${sc.expression});`;
739
+ const dropSql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} DROP CONSTRAINT ${dialect.quoteIdentifier(sc.name)};`;
740
+ if (!existing) {
741
+ result.statements.push(addSql);
742
+ result.reverseStatements.unshift(dropSql);
743
+ }
744
+ else if (normExpr(existing.expression) !== normExpr(sc.expression)) {
745
+ result.warnings.push(`check constraint "${sc.name}" on "${tableName}": stored expression differs from the schema text ` +
746
+ `(Postgres canonicalizes CHECK expressions, so this is usually cosmetic). ` +
747
+ `To intentionally replace it, rename the constraint or drop/re-add it in a manual migration.`);
748
+ }
749
+ }
455
750
  }
456
751
  return result;
457
752
  }
@@ -463,6 +758,11 @@ export async function schemaDiff(schema, connectionString) {
463
758
  * Map a schema column type to its expected PostgreSQL UDT name.
464
759
  */
465
760
  function schemaTypeToUdt(config) {
761
+ // Enum columns: udt is the enum type name. Vector columns: udt is `vector`.
762
+ if (config.enumName)
763
+ return config.enumName;
764
+ if (config.vectorDimensions != null)
765
+ return 'vector';
466
766
  const map = {
467
767
  SERIAL: 'int4',
468
768
  BIGSERIAL: 'int8',
@@ -481,7 +781,11 @@ function schemaTypeToUdt(config) {
481
781
  NUMERIC: 'numeric',
482
782
  BYTEA: 'bytea',
483
783
  };
484
- return map[config.type] ?? null;
784
+ const base = map[config.type] ?? null;
785
+ // Postgres names an array type `_<element>` (e.g. `_text` for text[]).
786
+ if (base && config.isArray)
787
+ return `_${base}`;
788
+ return base;
485
789
  }
486
790
  /**
487
791
  * Reverse map: PostgreSQL UDT name → SQL type (for generating reverse ALTER TYPE).
package/dist/schema.d.ts CHANGED
@@ -35,6 +35,28 @@ export interface TableMetadata {
35
35
  relations: Record<string, RelationDef>;
36
36
  /** Indexes on this table */
37
37
  indexes: IndexMetadata[];
38
+ /**
39
+ * Named `CHECK` constraints on this table (introspected from
40
+ * `pg_constraint` where `contype = 'c'`, excluding NOT NULL artifacts).
41
+ * Optional / defaults to `[]` for back-compat.
42
+ */
43
+ checks?: readonly CheckMetadata[];
44
+ /**
45
+ * True when this metadata entry describes a database **view** or
46
+ * **materialized view** rather than a base table (introspected with the
47
+ * `includeViews` option). Views are read-only: every write builder
48
+ * (`create`/`update`/`upsert`/`delete` + their `*Many` forms) throws a
49
+ * `ValidationError` (E003). A view without a primary key is additionally
50
+ * excluded from the `findUnique`-family generated accessor types.
51
+ * Optional / defaults to `false` for back-compat.
52
+ */
53
+ isView?: boolean;
54
+ }
55
+ export interface CheckMetadata {
56
+ /** Constraint name (system-generated or user-supplied). */
57
+ name: string;
58
+ /** The check expression source (`pg_get_constraintdef` inner text). */
59
+ expression: string;
38
60
  }
39
61
  export interface ColumnMetadata {
40
62
  /** snake_case column name */
@@ -61,6 +83,23 @@ export interface ColumnMetadata {
61
83
  * emits the `auto` modifier. Optional / defaults to `false` for back-compat.
62
84
  */
63
85
  isGenerated?: boolean;
86
+ /**
87
+ * True when this is a Postgres **`GENERATED ALWAYS AS (expr) STORED`** column
88
+ * (`information_schema.columns.is_generated = 'ALWAYS'`). Distinct from
89
+ * {@link isGenerated} — which flags a server-*assigned* identity/serial value
90
+ * that a client MAY still override — a STORED generated column's value is
91
+ * *computed from other columns* and can NEVER be supplied on insert/update
92
+ * (Postgres rejects it). Codegen therefore omits it from `*Create`/`*Update`
93
+ * input types, and the write builders reject any `data` containing it with a
94
+ * {@link ValidationError} (E003). Optional / defaults to `false`.
95
+ */
96
+ isGeneratedStored?: boolean;
97
+ /**
98
+ * The generation expression for a {@link isGeneratedStored} column
99
+ * (`information_schema.columns.generation_expression`), e.g. `price * qty`.
100
+ * Present only when `isGeneratedStored` is true and the catalog exposed it.
101
+ */
102
+ generationExpression?: string;
64
103
  /** Whether this is an array column */
65
104
  isArray: boolean;
66
105
  /** Dialect-specific array/bulk-insert type token when needed. */
@@ -70,8 +109,22 @@ export interface ColumnMetadata {
70
109
  /** Max character length (for varchar) */
71
110
  maxLength?: number;
72
111
  }
112
+ /**
113
+ * PostgreSQL referential action for a foreign key's `ON DELETE` / `ON UPDATE`
114
+ * clause. `'no action'` is the implicit default (matches Postgres) and is
115
+ * omitted from emitted DDL.
116
+ */
117
+ export type ReferentialAction = 'cascade' | 'restrict' | 'set null' | 'set default' | 'no action';
73
118
  export interface RelationDef {
74
119
  type: 'hasMany' | 'hasOne' | 'belongsTo' | 'manyToMany';
120
+ /**
121
+ * FK `ON DELETE` action (introspected from `pg_constraint.confdeltype`).
122
+ * Present on `belongsTo`/`hasMany` relations derived from a real FK; omitted
123
+ * when unknown (e.g. `defineSchema`-only metadata) or `'no action'`.
124
+ */
125
+ onDelete?: ReferentialAction;
126
+ /** FK `ON UPDATE` action (introspected from `pg_constraint.confupdtype`). */
127
+ onUpdate?: ReferentialAction;
75
128
  /** Relation name (camelCase, used as the field name) */
76
129
  name: string;
77
130
  /** Source table */
package/dist/seed.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { TurbineClient } from './client.js';
2
+ export type SeedFunction = (db: TurbineClient) => Promise<void> | void;
3
+ export type DefinedSeed = () => Promise<void>;
4
+ export declare function defineSeed(fn: SeedFunction): DefinedSeed;
package/dist/seed.js ADDED
@@ -0,0 +1,63 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { TurbineClient } from './client.js';
5
+ import { ConnectionError } from './errors.js';
6
+ const emptySchema = { tables: {}, enums: {} };
7
+ function entryUrl() {
8
+ const entry = process.argv[1];
9
+ if (!entry)
10
+ return null;
11
+ try {
12
+ return pathToFileURL(realpathSync(entry)).href;
13
+ }
14
+ catch {
15
+ return pathToFileURL(resolve(entry)).href;
16
+ }
17
+ }
18
+ function callerUrl() {
19
+ const stack = new Error().stack;
20
+ if (!stack)
21
+ return null;
22
+ for (const line of stack.split('\n').slice(2)) {
23
+ const fileUrl = line.match(/(file:\/\/\/[^):]+):\d+:\d+/)?.[1];
24
+ if (fileUrl && !fileUrl.endsWith('/seed.ts') && !fileUrl.endsWith('/seed.js'))
25
+ return fileUrl;
26
+ const filePath = line.match(/\(?((?:\/|[A-Za-z]:\\)[^):]+):\d+:\d+\)?/)?.[1];
27
+ if (filePath && !filePath.endsWith('/src/seed.ts') && !filePath.endsWith('\\src\\seed.ts')) {
28
+ return pathToFileURL(resolve(filePath)).href;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+ function isDirectSeedModule() {
34
+ const entry = entryUrl();
35
+ const caller = callerUrl();
36
+ return process.env.NODE_TEST_CONTEXT === undefined && !!entry && !!caller && entry === caller;
37
+ }
38
+ async function runSeed(fn) {
39
+ const connectionString = process.env.DATABASE_URL;
40
+ if (!connectionString) {
41
+ throw new ConnectionError('[turbine] DATABASE_URL is required to run this seed.');
42
+ }
43
+ const db = new TurbineClient({ connectionString }, emptySchema);
44
+ try {
45
+ await fn(db);
46
+ }
47
+ finally {
48
+ await db.disconnect();
49
+ }
50
+ }
51
+ export function defineSeed(fn) {
52
+ const run = () => runSeed(fn);
53
+ if (isDirectSeedModule()) {
54
+ queueMicrotask(() => {
55
+ run().catch((err) => {
56
+ const message = err instanceof Error ? err.message : String(err);
57
+ console.error(message);
58
+ process.exitCode = 1;
59
+ });
60
+ });
61
+ }
62
+ return run;
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,7 +67,6 @@
67
67
  "typecheck": "tsc --noEmit --project tsconfig.test.json",
68
68
  "generate": "tsx src/cli/index.ts generate",
69
69
  "status": "tsx src/cli/index.ts status",
70
- "examples": "tsx examples/examples.ts",
71
70
  "dogfood": "tsx examples/dogfood.ts",
72
71
  "test": "tsx --test --test-concurrency=1 src/test/*.test.ts",
73
72
  "test:unit": "DATABASE_URL= tsx --test src/test/*.test.ts",
@@ -117,7 +116,7 @@
117
116
  "peerDependencies": {
118
117
  "@zvndev/powdb-client": "^0.7.1 || ^0.8.0",
119
118
  "@zvndev/powdb-embedded": "^0.7.1 || ^0.8.0",
120
- "mssql": "^10.0.0 || ^11.0.0",
119
+ "mssql": "^10.0.0 || ^11.0.0 || ^12.0.0",
121
120
  "mysql2": "^3.0.0"
122
121
  },
123
122
  "peerDependenciesMeta": {