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.
- package/README.md +17 -13
- package/dist/cjs/cli/config.js +20 -3
- package/dist/cjs/cli/destructive.js +47 -31
- package/dist/cjs/cli/index.js +273 -71
- package/dist/cjs/cli/mcp.js +788 -0
- package/dist/cjs/cli/migrate.js +95 -20
- package/dist/cjs/cli/studio.js +3 -2
- package/dist/cjs/client.js +267 -34
- package/dist/cjs/dialect.js +2 -0
- package/dist/cjs/generate.js +171 -7
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/introspect.js +177 -4
- package/dist/cjs/query/batched-loader.js +148 -0
- package/dist/cjs/query/builder.js +714 -133
- package/dist/cjs/schema-builder.js +59 -4
- package/dist/cjs/schema-sql.js +315 -6
- package/dist/cjs/seed.js +66 -0
- package/dist/cli/config.d.ts +9 -2
- package/dist/cli/config.js +19 -3
- package/dist/cli/destructive.js +47 -31
- package/dist/cli/index.d.ts +52 -1
- package/dist/cli/index.js +272 -74
- package/dist/cli/mcp.d.ts +17 -0
- package/dist/cli/mcp.js +781 -0
- package/dist/cli/migrate.d.ts +37 -0
- package/dist/cli/migrate.js +92 -20
- package/dist/cli/studio.d.ts +3 -2
- package/dist/cli/studio.js +3 -2
- package/dist/client.d.ts +136 -1
- package/dist/client.js +267 -34
- package/dist/dialect.d.ts +17 -0
- package/dist/dialect.js +2 -0
- package/dist/generate.d.ts +17 -0
- package/dist/generate.js +171 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -0
- package/dist/introspect.d.ts +20 -1
- package/dist/introspect.js +175 -4
- package/dist/query/batched-loader.d.ts +29 -2
- package/dist/query/batched-loader.js +148 -1
- package/dist/query/builder.d.ts +156 -8
- package/dist/query/builder.js +715 -134
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +113 -8
- package/dist/schema-builder.d.ts +73 -8
- package/dist/schema-builder.js +59 -4
- package/dist/schema-sql.d.ts +67 -0
- package/dist/schema-sql.js +310 -6
- package/dist/schema.d.ts +53 -0
- package/dist/seed.d.ts +4 -0
- package/dist/seed.js +63 -0
- package/package.json +2 -3
package/dist/schema-sql.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
191
|
-
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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": {
|