turbine-orm 0.34.0 → 0.36.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 +18 -16
- package/dist/cjs/cli/index.js +109 -16
- package/dist/cjs/cli/migrate.js +78 -3
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +333 -22
- package/dist/cjs/cli/ui.js +7 -1
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/dialect.js +2 -1
- package/dist/cjs/errors.js +41 -1
- package/dist/cjs/generate.js +23 -2
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mssql.js +27 -5
- package/dist/cjs/mysql.js +4 -0
- package/dist/cjs/powdb.js +197 -25
- package/dist/cjs/powql.js +515 -51
- package/dist/cjs/query/aggregates.js +683 -0
- package/dist/cjs/query/batched-loader.js +2 -0
- package/dist/cjs/query/builder.js +361 -4508
- package/dist/cjs/query/filters.js +12 -0
- package/dist/cjs/query/relations.js +1698 -0
- package/dist/cjs/query/where-compile.js +180 -0
- package/dist/cjs/query/where.js +1491 -0
- package/dist/cjs/query/writes.js +680 -0
- package/dist/cjs/schema-builder.js +6 -0
- package/dist/cjs/schema-metadata.js +4 -0
- package/dist/cjs/schema-sql.js +265 -3
- package/dist/cjs/sqlite.js +4 -1
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.js +111 -18
- package/dist/cli/migrate.d.ts +24 -1
- package/dist/cli/migrate.js +77 -3
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +46 -13
- package/dist/cli/studio.js +331 -23
- package/dist/cli/ui.js +7 -1
- package/dist/client.d.ts +32 -5
- package/dist/client.js +26 -4
- package/dist/dialect.d.ts +28 -6
- package/dist/dialect.js +2 -1
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +39 -0
- package/dist/generate.js +23 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mssql.js +27 -5
- package/dist/mysql.js +4 -0
- package/dist/powdb.d.ts +135 -9
- package/dist/powdb.js +197 -25
- package/dist/powql.d.ts +166 -4
- package/dist/powql.js +516 -52
- package/dist/query/aggregates.d.ts +74 -0
- package/dist/query/aggregates.js +641 -0
- package/dist/query/batched-loader.d.ts +6 -0
- package/dist/query/batched-loader.js +2 -0
- package/dist/query/builder.d.ts +98 -830
- package/dist/query/builder.js +366 -4513
- package/dist/query/deferred.d.ts +13 -2
- package/dist/query/filters.d.ts +7 -0
- package/dist/query/filters.js +11 -0
- package/dist/query/relations.d.ts +441 -0
- package/dist/query/relations.js +1627 -0
- package/dist/query/types.d.ts +25 -6
- package/dist/query/where-compile.d.ts +139 -0
- package/dist/query/where-compile.js +175 -0
- package/dist/query/where.d.ts +494 -0
- package/dist/query/where.js +1431 -0
- package/dist/query/writes.d.ts +131 -0
- package/dist/query/writes.js +626 -0
- package/dist/schema-builder.d.ts +18 -3
- package/dist/schema-builder.js +6 -0
- package/dist/schema-metadata.js +4 -0
- package/dist/schema-sql.d.ts +60 -3
- package/dist/schema-sql.js +261 -4
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +4 -1
- package/package.json +4 -4
package/dist/schema-builder.js
CHANGED
|
@@ -94,6 +94,7 @@ function resolveColumn(def) {
|
|
|
94
94
|
vectorDimensions: def.dimensions ?? null,
|
|
95
95
|
isArray: def.array ?? false,
|
|
96
96
|
check: def.check ?? null,
|
|
97
|
+
pii: def.pii ?? false,
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
/** Type guard: is this index declaration a doc-field expression index? */
|
|
@@ -247,6 +248,7 @@ export class ColumnBuilder {
|
|
|
247
248
|
vectorDimensions: null,
|
|
248
249
|
isArray: false,
|
|
249
250
|
check: null,
|
|
251
|
+
pii: false,
|
|
250
252
|
};
|
|
251
253
|
}
|
|
252
254
|
serial() {
|
|
@@ -353,6 +355,10 @@ export class ColumnBuilder {
|
|
|
353
355
|
this._config.check = expression;
|
|
354
356
|
return this;
|
|
355
357
|
}
|
|
358
|
+
pii() {
|
|
359
|
+
this._config.pii = true;
|
|
360
|
+
return this;
|
|
361
|
+
}
|
|
356
362
|
array() {
|
|
357
363
|
this._config.isArray = true;
|
|
358
364
|
return this;
|
package/dist/schema-metadata.js
CHANGED
|
@@ -337,6 +337,10 @@ export function schemaDefToMetadata(def) {
|
|
|
337
337
|
arrayType: pgArrayType(base),
|
|
338
338
|
pgArrayType: pgArrayType(base),
|
|
339
339
|
...(serial ? { isGenerated: true } : {}),
|
|
340
|
+
// PII is a code-first declaration; carry it through so query-layer
|
|
341
|
+
// default-projection exclusion and Studio redaction can honor it.
|
|
342
|
+
// Only set when true, keeping output byte-stable for untagged schemas.
|
|
343
|
+
...(config.pii ? { pii: true } : {}),
|
|
340
344
|
...(config.maxLength != null ? { maxLength: config.maxLength } : {}),
|
|
341
345
|
};
|
|
342
346
|
columns.push(col);
|
package/dist/schema-sql.d.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* Converts a SchemaDef (from defineSchema) into executable DDL statements.
|
|
5
5
|
* Also provides diff and push commands for syncing schema to a live database.
|
|
6
6
|
*/
|
|
7
|
+
import { type DestructiveStatement } from './cli/destructive.js';
|
|
7
8
|
import { type Dialect } from './dialect.js';
|
|
9
|
+
import { ValidationError } from './errors.js';
|
|
8
10
|
import { type ReferentialAction } from './schema.js';
|
|
9
|
-
import type
|
|
11
|
+
import { type ColumnIndexDef, type SchemaDef, type TableDef } from './schema-builder.js';
|
|
10
12
|
export interface SchemaSqlOptions {
|
|
11
13
|
/** SQL dialect used for DDL generation. Defaults to PostgreSQL. */
|
|
12
14
|
dialect?: Dialect;
|
|
@@ -27,6 +29,32 @@ export declare function referentialActionToSql(action: ReferentialAction): strin
|
|
|
27
29
|
* followed by CREATE INDEX statements for foreign key columns.
|
|
28
30
|
*/
|
|
29
31
|
export declare function schemaToSQL(schema: SchemaDef, options?: SchemaSqlOptions): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Compare a declared plain index against a pg_indexes `indexdef` string.
|
|
34
|
+
* Returns a human-readable description of the first mismatch (uniqueness or
|
|
35
|
+
* column list), or null when the definitions agree. Expression/partial indexes
|
|
36
|
+
* in the DB never structurally match a plain column list, which is the
|
|
37
|
+
* intended outcome: the operator gets a warning rather than a silent skip.
|
|
38
|
+
*/
|
|
39
|
+
export declare function describeIndexDefMismatch(idx: ColumnIndexDef, indexdef: string): string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Pure decision for the "index exists in the DB but is not declared" warning
|
|
42
|
+
* pass. Extracted so the scope rule is unit-testable without a live database.
|
|
43
|
+
*
|
|
44
|
+
* The pass runs only when the table opts into index management by DEFINING an
|
|
45
|
+
* `indexes` array (`indexesDefined: true`), even when that array is empty or
|
|
46
|
+
* all-doc-field: deleting the last declared index must NOT silence the pass.
|
|
47
|
+
* Tables with no `indexes` key stay silent (the user is not managing indexes
|
|
48
|
+
* there). Recognized names (declared, unique-constraint / FK-column, `*_pkey`)
|
|
49
|
+
* are never flagged; everything else in the DB yields one warning.
|
|
50
|
+
*/
|
|
51
|
+
export declare function undeclaredIndexWarnings(opts: {
|
|
52
|
+
tableName: string;
|
|
53
|
+
indexesDefined: boolean;
|
|
54
|
+
dbIndexNames: Iterable<string>;
|
|
55
|
+
declaredNames: ReadonlySet<string>;
|
|
56
|
+
recognizedNames: ReadonlySet<string>;
|
|
57
|
+
}): string[];
|
|
30
58
|
export interface AlterColumnDef {
|
|
31
59
|
/** Column name in snake_case */
|
|
32
60
|
column: string;
|
|
@@ -119,6 +147,14 @@ export declare function diffCheckConstraints(table: string, schemaChecks: readon
|
|
|
119
147
|
* DDL is needed to make the database match the schema definition.
|
|
120
148
|
*/
|
|
121
149
|
export declare function schemaDiff(schema: SchemaDef, connectionString: string): Promise<DiffResult>;
|
|
150
|
+
/**
|
|
151
|
+
* Scan a set of diff statements for data-destroying operations, using the same
|
|
152
|
+
* conservative scanner (`scanDestructiveSql`) that gates `migrate up`/`down`.
|
|
153
|
+
* Push mostly emits additive DDL, but a type change surfaces as a lossy
|
|
154
|
+
* `ALTER COLUMN ... TYPE` cast, exactly the kind of silent data loss `push`
|
|
155
|
+
* must never apply without an explicit opt-in.
|
|
156
|
+
*/
|
|
157
|
+
export declare function findDestructivePushStatements(statements: readonly string[]): DestructiveStatement[];
|
|
122
158
|
export interface PushResult {
|
|
123
159
|
/** Number of statements executed */
|
|
124
160
|
statementsExecuted: number;
|
|
@@ -129,15 +165,36 @@ export interface PushResult {
|
|
|
129
165
|
/** Tables altered */
|
|
130
166
|
tablesAltered: string[];
|
|
131
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Thrown by {@link schemaPush} when the diff contains data-destroying statements
|
|
170
|
+
* and `allowDestructive` was not set. A typed subclass of {@link ValidationError}
|
|
171
|
+
* (same `TURBINE_E003` code, no new taxonomy entry) so callers can branch on
|
|
172
|
+
* `instanceof DestructivePushRefusal` instead of sniffing the message text. The
|
|
173
|
+
* offending statements are carried on `.destructive` for programmatic display.
|
|
174
|
+
*/
|
|
175
|
+
export declare class DestructivePushRefusal extends ValidationError {
|
|
176
|
+
/** The destructive statements the push refused to apply. */
|
|
177
|
+
readonly destructive: DestructiveStatement[];
|
|
178
|
+
constructor(destructive: DestructiveStatement[]);
|
|
179
|
+
}
|
|
132
180
|
/**
|
|
133
181
|
* Push a schema definition to a live database.
|
|
134
182
|
*
|
|
135
183
|
* Computes the diff, then executes the resulting DDL statements in a
|
|
136
|
-
* single transaction.
|
|
137
|
-
*
|
|
184
|
+
* single transaction. It will NOT drop tables or columns.
|
|
185
|
+
*
|
|
186
|
+
* Data-loss gate: if the diff contains a destructive statement (e.g. a lossy
|
|
187
|
+
* `ALTER COLUMN ... TYPE` cast), `schemaPush` throws a
|
|
188
|
+
* {@link DestructivePushRefusal} (a {@link ValidationError} subclass carrying
|
|
189
|
+
* the offending statements on `.destructive`) listing the statements UNLESS
|
|
190
|
+
* `allowDestructive: true` is passed. The CLI (`turbine push`) catches this and
|
|
191
|
+
* prompts for the same typed confirmation as `migrate up`; programmatic callers
|
|
192
|
+
* must opt in explicitly.
|
|
138
193
|
*/
|
|
139
194
|
export declare function schemaPush(schema: SchemaDef, connectionString: string, options?: {
|
|
140
195
|
dryRun?: boolean;
|
|
196
|
+
allowDestructive?: boolean;
|
|
197
|
+
precomputedDiff?: DiffResult;
|
|
141
198
|
}): Promise<PushResult>;
|
|
142
199
|
/**
|
|
143
200
|
* Generate the full DDL as a single formatted string.
|
package/dist/schema-sql.js
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* Also provides diff and push commands for syncing schema to a live database.
|
|
6
6
|
*/
|
|
7
7
|
import pg from 'pg';
|
|
8
|
+
import { DESTRUCTIVE_KIND_LABEL, scanDestructiveSql } from './cli/destructive.js';
|
|
8
9
|
import { postgresDialect } from './dialect.js';
|
|
9
|
-
import { UnsupportedFeatureError } from './errors.js';
|
|
10
|
+
import { UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
10
11
|
import { pgConfActionToReferential, stripCheckWrapper } from './introspect.js';
|
|
11
12
|
import { camelToSnake } from './schema.js';
|
|
13
|
+
import { isDocFieldIndexDef, } from './schema-builder.js';
|
|
12
14
|
/** Map a {@link ReferentialAction} to its SQL keyword form. */
|
|
13
15
|
export function referentialActionToSql(action) {
|
|
14
16
|
switch (action) {
|
|
@@ -110,6 +112,11 @@ export function schemaToSQL(schema, options) {
|
|
|
110
112
|
const indexes = generateForeignKeyIndexes(table, dialect);
|
|
111
113
|
statements.push(...indexes);
|
|
112
114
|
}
|
|
115
|
+
// Generate CREATE INDEX / CREATE UNIQUE INDEX for user-declared indexes.
|
|
116
|
+
for (const tableName of sorted) {
|
|
117
|
+
const table = schema.tables[tableName];
|
|
118
|
+
statements.push(...generateDeclaredIndexes(table, dialect));
|
|
119
|
+
}
|
|
113
120
|
return statements;
|
|
114
121
|
}
|
|
115
122
|
/**
|
|
@@ -327,10 +334,16 @@ function normalizeDefault(val) {
|
|
|
327
334
|
*/
|
|
328
335
|
function generateForeignKeyIndexes(table, dialect = postgresDialect) {
|
|
329
336
|
const indexes = [];
|
|
337
|
+
// A declared index whose deterministic name matches the auto FK-index name
|
|
338
|
+
// takes precedence (it may be UNIQUE); emitting both would fail at apply time
|
|
339
|
+
// with "relation already exists".
|
|
340
|
+
const declared = declaredPlainIndexNames(table);
|
|
330
341
|
for (const [fieldName, config] of Object.entries(table.columns)) {
|
|
331
342
|
if (config.referencesTarget) {
|
|
332
343
|
const snakeName = camelToSnake(fieldName);
|
|
333
344
|
const indexName = `idx_${table.name}_${snakeName}`;
|
|
345
|
+
if (declared.has(indexName))
|
|
346
|
+
continue;
|
|
334
347
|
indexes.push(dialect.buildCreateIndexStatement({
|
|
335
348
|
name: dialect.quoteIdentifier(indexName),
|
|
336
349
|
table: dialect.quoteIdentifier(table.name),
|
|
@@ -340,6 +353,116 @@ function generateForeignKeyIndexes(table, dialect = postgresDialect) {
|
|
|
340
353
|
}
|
|
341
354
|
return indexes;
|
|
342
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* The deterministic SQL index name for a declared plain (column-list) index:
|
|
358
|
+
* the user-supplied `name`, else `idx_<table>_<col1>_<col2>...`, mirroring the
|
|
359
|
+
* FK-index convention in {@link generateForeignKeyIndexes}.
|
|
360
|
+
*/
|
|
361
|
+
function declaredIndexName(tableName, idx) {
|
|
362
|
+
if (idx.name)
|
|
363
|
+
return idx.name;
|
|
364
|
+
const cols = idx.columns.map(camelToSnake);
|
|
365
|
+
return `idx_${tableName}_${cols.join('_')}`;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Compare a declared plain index against a pg_indexes `indexdef` string.
|
|
369
|
+
* Returns a human-readable description of the first mismatch (uniqueness or
|
|
370
|
+
* column list), or null when the definitions agree. Expression/partial indexes
|
|
371
|
+
* in the DB never structurally match a plain column list, which is the
|
|
372
|
+
* intended outcome: the operator gets a warning rather than a silent skip.
|
|
373
|
+
*/
|
|
374
|
+
export function describeIndexDefMismatch(idx, indexdef) {
|
|
375
|
+
const dbUnique = /^\s*CREATE\s+UNIQUE\s+INDEX\b/i.test(indexdef);
|
|
376
|
+
const wantUnique = idx.unique === true;
|
|
377
|
+
if (dbUnique !== wantUnique) {
|
|
378
|
+
return wantUnique
|
|
379
|
+
? 'declared UNIQUE, existing index is not unique'
|
|
380
|
+
: 'existing index is UNIQUE, declaration is not';
|
|
381
|
+
}
|
|
382
|
+
// pg_indexes.indexdef always reads `CREATE [UNIQUE] INDEX name ON tbl
|
|
383
|
+
// USING method (col, ...) [WHERE ...]`; anchor on the USING clause so a
|
|
384
|
+
// partial index's WHERE parentheses are never mistaken for the column list.
|
|
385
|
+
const parenMatch = indexdef.match(/USING\s+\w+\s*\(([^)]*)\)/i) ?? indexdef.match(/\(([^)]*)\)/);
|
|
386
|
+
const dbCols = parenMatch
|
|
387
|
+
? parenMatch[1].split(',').map((c) => c
|
|
388
|
+
.trim()
|
|
389
|
+
.replace(/\s+(ASC|DESC|NULLS\s+(FIRST|LAST))\b/gi, '')
|
|
390
|
+
.replace(/^"(.*)"$/, '$1')
|
|
391
|
+
.trim())
|
|
392
|
+
: [];
|
|
393
|
+
const wantCols = idx.columns.map(camelToSnake);
|
|
394
|
+
if (dbCols.length !== wantCols.length || dbCols.some((c, i) => c !== wantCols[i])) {
|
|
395
|
+
return `declared columns (${wantCols.join(', ')}) differ from existing (${dbCols.join(', ') || 'unparsed'})`;
|
|
396
|
+
}
|
|
397
|
+
if (/\bWHERE\b/i.test(indexdef))
|
|
398
|
+
return 'existing index is partial (has a WHERE clause)';
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Pure decision for the "index exists in the DB but is not declared" warning
|
|
403
|
+
* pass. Extracted so the scope rule is unit-testable without a live database.
|
|
404
|
+
*
|
|
405
|
+
* The pass runs only when the table opts into index management by DEFINING an
|
|
406
|
+
* `indexes` array (`indexesDefined: true`), even when that array is empty or
|
|
407
|
+
* all-doc-field: deleting the last declared index must NOT silence the pass.
|
|
408
|
+
* Tables with no `indexes` key stay silent (the user is not managing indexes
|
|
409
|
+
* there). Recognized names (declared, unique-constraint / FK-column, `*_pkey`)
|
|
410
|
+
* are never flagged; everything else in the DB yields one warning.
|
|
411
|
+
*/
|
|
412
|
+
export function undeclaredIndexWarnings(opts) {
|
|
413
|
+
if (!opts.indexesDefined)
|
|
414
|
+
return [];
|
|
415
|
+
const out = [];
|
|
416
|
+
for (const dbIdxName of opts.dbIndexNames) {
|
|
417
|
+
if (opts.declaredNames.has(dbIdxName) || opts.recognizedNames.has(dbIdxName) || dbIdxName.endsWith('_pkey')) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
out.push(`index "${dbIdxName}" on "${opts.tableName}" exists in the database but is not declared in the schema. ` +
|
|
421
|
+
`Turbine does not drop indexes automatically; drop it in a manual migration if it is no longer needed.`);
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
/** The deterministic SQL names of every declared plain index on a table. */
|
|
426
|
+
function declaredPlainIndexNames(table) {
|
|
427
|
+
const names = new Set();
|
|
428
|
+
for (const idx of table.indexes ?? []) {
|
|
429
|
+
if (isDocFieldIndexDef(idx) || idx.columns.length === 0)
|
|
430
|
+
continue;
|
|
431
|
+
names.add(declaredIndexName(table.name, idx));
|
|
432
|
+
}
|
|
433
|
+
return names;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Build the `CREATE [UNIQUE] INDEX` statement for a declared plain index.
|
|
437
|
+
* `buildCreateIndexStatement` has no `unique` hook, so unique indexes are
|
|
438
|
+
* emitted directly here (still fully identifier-quoted via the dialect).
|
|
439
|
+
*/
|
|
440
|
+
function buildDeclaredIndexStatement(tableName, idx, dialect) {
|
|
441
|
+
const cols = idx.columns.map(camelToSnake);
|
|
442
|
+
if (cols.length === 0)
|
|
443
|
+
return null;
|
|
444
|
+
const name = declaredIndexName(tableName, idx);
|
|
445
|
+
const quotedCols = cols.map((c) => dialect.quoteIdentifier(c)).join(', ');
|
|
446
|
+
const unique = idx.unique ? 'UNIQUE ' : '';
|
|
447
|
+
return `CREATE ${unique}INDEX ${dialect.quoteIdentifier(name)} ON ${dialect.quoteIdentifier(tableName)}(${quotedCols});`;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Generate `CREATE INDEX` / `CREATE UNIQUE INDEX` for a table's user-declared
|
|
451
|
+
* plain-column indexes. PowDB doc-field expression indexes ({@link DocFieldIndexDef})
|
|
452
|
+
* are skipped here (those stay PowDB-only, emitted by `powqlSchemaDDL`) and have
|
|
453
|
+
* no SQL equivalent.
|
|
454
|
+
*/
|
|
455
|
+
function generateDeclaredIndexes(table, dialect = postgresDialect) {
|
|
456
|
+
const out = [];
|
|
457
|
+
for (const idx of table.indexes ?? []) {
|
|
458
|
+
if (isDocFieldIndexDef(idx))
|
|
459
|
+
continue; // PowDB-only, no SQL emission
|
|
460
|
+
const stmt = buildDeclaredIndexStatement(table.name, idx, dialect);
|
|
461
|
+
if (stmt)
|
|
462
|
+
out.push(stmt);
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
343
466
|
/**
|
|
344
467
|
* Build the `ADD CONSTRAINT ... FOREIGN KEY` statement for a FK with the given
|
|
345
468
|
* referential actions. Default (`no action`) clauses are omitted, matching how
|
|
@@ -532,6 +655,16 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
532
655
|
dbChecks[row.table_name] = [];
|
|
533
656
|
dbChecks[row.table_name].push({ name: row.conname, expression: stripCheckWrapper(row.definition) });
|
|
534
657
|
}
|
|
658
|
+
// Existing index NAMES per table (for the declared-index diff). Covers PK,
|
|
659
|
+
// unique-constraint, FK, and user indexes alike; the diff only ADDs declared
|
|
660
|
+
// indexes whose name is missing and never auto-drops any (see below).
|
|
661
|
+
const indexResult = await client.query(`SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = 'public'`);
|
|
662
|
+
const dbIndexes = {};
|
|
663
|
+
for (const row of indexResult.rows) {
|
|
664
|
+
if (!dbIndexes[row.tablename])
|
|
665
|
+
dbIndexes[row.tablename] = new Map();
|
|
666
|
+
dbIndexes[row.tablename].set(row.indexname, row.indexdef);
|
|
667
|
+
}
|
|
535
668
|
// Build a set of DDL-facing snake_case table names that the schema defines.
|
|
536
669
|
const schemaDdlNames = new Set();
|
|
537
670
|
for (const def of Object.values(schema.tables))
|
|
@@ -564,6 +697,8 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
564
697
|
result.statements.push(generateCreateTable(tableDef, resolveRef, dialect));
|
|
565
698
|
const fkIndexes = generateForeignKeyIndexes(tableDef, dialect);
|
|
566
699
|
result.statements.push(...fkIndexes);
|
|
700
|
+
// User-declared indexes on a brand-new table (reversed by the DROP TABLE).
|
|
701
|
+
result.statements.push(...generateDeclaredIndexes(tableDef, dialect));
|
|
567
702
|
// Reverse: DROP TABLE (with indexes — they drop automatically)
|
|
568
703
|
result.reverseStatements.unshift(`DROP TABLE IF EXISTS ${dialect.quoteIdentifier(ddlName)} CASCADE;`);
|
|
569
704
|
}
|
|
@@ -747,6 +882,67 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
747
882
|
`To intentionally replace it, rename the constraint or drop/re-add it in a manual migration.`);
|
|
748
883
|
}
|
|
749
884
|
}
|
|
885
|
+
// --- User-declared indexes (TableDef.indexes) ---
|
|
886
|
+
// ADD any declared plain index whose NAME is missing from the DB (reverse:
|
|
887
|
+
// DROP INDEX). Doc-field indexes are PowDB-only and skipped. We never
|
|
888
|
+
// auto-drop DB indexes not in the schema, matching the column-drop posture.
|
|
889
|
+
// When the table declares at least one index (the user is actively managing
|
|
890
|
+
// indexes here), unrecognized extra DB indexes are surfaced as warnings so
|
|
891
|
+
// the operator can drop them by hand if intended. Recognized = PK, unique
|
|
892
|
+
// constraint, or FK-column index names, which the schema never declares.
|
|
893
|
+
// Run the whole index-management pass whenever the table DEFINES an
|
|
894
|
+
// `indexes` array (even empty or all-doc-field): a table with an `indexes`
|
|
895
|
+
// key is one the user is actively managing, so undeclared DB indexes stay
|
|
896
|
+
// worth surfacing. Deleting the last declared index must NOT silence the
|
|
897
|
+
// warning pass. Tables with no `indexes` key stay silent (not managed).
|
|
898
|
+
const declaredPlain = (tableDef.indexes ?? []).filter((i) => !isDocFieldIndexDef(i));
|
|
899
|
+
if (tableDef.indexes !== undefined) {
|
|
900
|
+
const dbIdx = dbIndexes[tableName] ?? new Map();
|
|
901
|
+
const dbIdxNames = new Set(dbIdx.keys());
|
|
902
|
+
const declaredNames = new Set();
|
|
903
|
+
for (const idx of declaredPlain) {
|
|
904
|
+
if (idx.columns.length === 0)
|
|
905
|
+
continue;
|
|
906
|
+
const name = declaredIndexName(tableName, idx);
|
|
907
|
+
declaredNames.add(name);
|
|
908
|
+
const existingDef = dbIdx.get(name);
|
|
909
|
+
if (existingDef === undefined) {
|
|
910
|
+
const stmt = buildDeclaredIndexStatement(tableName, idx, dialect);
|
|
911
|
+
if (!stmt)
|
|
912
|
+
continue;
|
|
913
|
+
result.statements.push(stmt);
|
|
914
|
+
result.reverseStatements.unshift(`DROP INDEX IF EXISTS ${dialect.quoteIdentifier(name)};`);
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
// Name matches an existing index: verify the definition agrees.
|
|
918
|
+
// Matching is by name, so a definition drift (a declared UNIQUE
|
|
919
|
+
// index colliding with the plain auto FK index, or a changed
|
|
920
|
+
// column list) would otherwise be silently skipped, leaving the
|
|
921
|
+
// declared guarantee unenforced. Warn, never drop.
|
|
922
|
+
const mismatch = describeIndexDefMismatch(idx, existingDef);
|
|
923
|
+
if (mismatch) {
|
|
924
|
+
result.warnings.push(`index "${name}" on "${tableName}": the declared definition does not match the existing ` +
|
|
925
|
+
`database index (${mismatch}). Turbine matches indexes by name and never drops them ` +
|
|
926
|
+
`automatically; drop and recreate it in a manual migration to apply the declared definition.`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// Recognized (never-declared) index names: unique-constraint + FK-column.
|
|
931
|
+
const recognized = new Set(Object.values(dbUniques[tableName] ?? {}));
|
|
932
|
+
for (const [fieldName, config] of Object.entries(tableDef.columns)) {
|
|
933
|
+
if (config.referencesTarget)
|
|
934
|
+
recognized.add(`idx_${tableName}_${camelToSnake(fieldName)}`);
|
|
935
|
+
}
|
|
936
|
+
for (const w of undeclaredIndexWarnings({
|
|
937
|
+
tableName,
|
|
938
|
+
indexesDefined: tableDef.indexes !== undefined,
|
|
939
|
+
dbIndexNames: dbIdxNames,
|
|
940
|
+
declaredNames,
|
|
941
|
+
recognizedNames: recognized,
|
|
942
|
+
})) {
|
|
943
|
+
result.warnings.push(w);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
750
946
|
}
|
|
751
947
|
return result;
|
|
752
948
|
}
|
|
@@ -836,15 +1032,69 @@ function defaultsMatch(schemaDefault, dbDefault) {
|
|
|
836
1032
|
const b = normalizeDbDefault(dbDefault).toLowerCase().trim();
|
|
837
1033
|
return a === b;
|
|
838
1034
|
}
|
|
1035
|
+
// ---------------------------------------------------------------------------
|
|
1036
|
+
// Schema Push — execute the diff against a live database
|
|
1037
|
+
// ---------------------------------------------------------------------------
|
|
1038
|
+
/**
|
|
1039
|
+
* Scan a set of diff statements for data-destroying operations, using the same
|
|
1040
|
+
* conservative scanner (`scanDestructiveSql`) that gates `migrate up`/`down`.
|
|
1041
|
+
* Push mostly emits additive DDL, but a type change surfaces as a lossy
|
|
1042
|
+
* `ALTER COLUMN ... TYPE` cast, exactly the kind of silent data loss `push`
|
|
1043
|
+
* must never apply without an explicit opt-in.
|
|
1044
|
+
*/
|
|
1045
|
+
export function findDestructivePushStatements(statements) {
|
|
1046
|
+
const hits = [];
|
|
1047
|
+
for (const stmt of statements)
|
|
1048
|
+
hits.push(...scanDestructiveSql(stmt));
|
|
1049
|
+
return hits;
|
|
1050
|
+
}
|
|
1051
|
+
/** Format the destructive-push refusal message (mirrors the migrate gate copy). */
|
|
1052
|
+
function formatDestructivePushError(hits) {
|
|
1053
|
+
const lines = ['[turbine] Refusing to apply schema changes containing DESTRUCTIVE statements:', ''];
|
|
1054
|
+
for (const h of hits) {
|
|
1055
|
+
lines.push(` - [${h.kind}] ${h.target}: ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
|
|
1056
|
+
}
|
|
1057
|
+
lines.push('');
|
|
1058
|
+
lines.push('Review the statements above. To proceed: run `npx turbine push` interactively');
|
|
1059
|
+
lines.push('and confirm, pass --allow-destructive, or set allowDestructive: true programmatically.');
|
|
1060
|
+
return lines.join('\n');
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Thrown by {@link schemaPush} when the diff contains data-destroying statements
|
|
1064
|
+
* and `allowDestructive` was not set. A typed subclass of {@link ValidationError}
|
|
1065
|
+
* (same `TURBINE_E003` code, no new taxonomy entry) so callers can branch on
|
|
1066
|
+
* `instanceof DestructivePushRefusal` instead of sniffing the message text. The
|
|
1067
|
+
* offending statements are carried on `.destructive` for programmatic display.
|
|
1068
|
+
*/
|
|
1069
|
+
export class DestructivePushRefusal extends ValidationError {
|
|
1070
|
+
/** The destructive statements the push refused to apply. */
|
|
1071
|
+
destructive;
|
|
1072
|
+
constructor(destructive) {
|
|
1073
|
+
super(formatDestructivePushError(destructive));
|
|
1074
|
+
this.name = 'DestructivePushRefusal';
|
|
1075
|
+
this.destructive = destructive;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
839
1078
|
/**
|
|
840
1079
|
* Push a schema definition to a live database.
|
|
841
1080
|
*
|
|
842
1081
|
* Computes the diff, then executes the resulting DDL statements in a
|
|
843
|
-
* single transaction.
|
|
844
|
-
*
|
|
1082
|
+
* single transaction. It will NOT drop tables or columns.
|
|
1083
|
+
*
|
|
1084
|
+
* Data-loss gate: if the diff contains a destructive statement (e.g. a lossy
|
|
1085
|
+
* `ALTER COLUMN ... TYPE` cast), `schemaPush` throws a
|
|
1086
|
+
* {@link DestructivePushRefusal} (a {@link ValidationError} subclass carrying
|
|
1087
|
+
* the offending statements on `.destructive`) listing the statements UNLESS
|
|
1088
|
+
* `allowDestructive: true` is passed. The CLI (`turbine push`) catches this and
|
|
1089
|
+
* prompts for the same typed confirmation as `migrate up`; programmatic callers
|
|
1090
|
+
* must opt in explicitly.
|
|
845
1091
|
*/
|
|
846
1092
|
export async function schemaPush(schema, connectionString, options = {}) {
|
|
847
|
-
|
|
1093
|
+
// Accept a precomputed diff so a caller (the CLI) can diff ONCE, show the
|
|
1094
|
+
// plan, confirm, and apply the EXACT statements it displayed. Without this,
|
|
1095
|
+
// schemaPush would re-diff on the post-confirmation retry, so a concurrent
|
|
1096
|
+
// schema change between confirm and apply could alter the applied set (TOCTOU).
|
|
1097
|
+
const diff = options.precomputedDiff ?? (await schemaDiff(schema, connectionString));
|
|
848
1098
|
const result = {
|
|
849
1099
|
statementsExecuted: 0,
|
|
850
1100
|
statements: diff.statements,
|
|
@@ -854,6 +1104,13 @@ export async function schemaPush(schema, connectionString, options = {}) {
|
|
|
854
1104
|
if (options.dryRun || diff.statements.length === 0) {
|
|
855
1105
|
return result;
|
|
856
1106
|
}
|
|
1107
|
+
// Destructive-statement gate: refuse silent data loss unless opted in.
|
|
1108
|
+
if (!options.allowDestructive) {
|
|
1109
|
+
const destructive = findDestructivePushStatements(diff.statements);
|
|
1110
|
+
if (destructive.length > 0) {
|
|
1111
|
+
throw new DestructivePushRefusal(destructive);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
857
1114
|
// Execute all statements in a transaction
|
|
858
1115
|
const client = new pg.Client({ connectionString });
|
|
859
1116
|
await client.connect();
|
package/dist/schema.d.ts
CHANGED
|
@@ -110,6 +110,16 @@ export interface ColumnMetadata {
|
|
|
110
110
|
* Present only when `isGeneratedStored` is true and the catalog exposed it.
|
|
111
111
|
*/
|
|
112
112
|
generationExpression?: string;
|
|
113
|
+
/**
|
|
114
|
+
* True when this column holds personally identifiable information (PII).
|
|
115
|
+
* Tagged in `defineSchema` (`pii: true`) and carried through generated
|
|
116
|
+
* metadata. A PII column is EXCLUDED from default projections: it comes back
|
|
117
|
+
* only when explicitly named in `select` or when the query passes
|
|
118
|
+
* `includePii: true` (full opt-in). Studio redacts PII cells by default.
|
|
119
|
+
* Optional / defaults to `false`; untagged schemas behave exactly as before.
|
|
120
|
+
* Introspection never auto-tags PII (it is a code-first declaration).
|
|
121
|
+
*/
|
|
122
|
+
pii?: boolean;
|
|
113
123
|
/** Whether this is an array column */
|
|
114
124
|
isArray: boolean;
|
|
115
125
|
/** Dialect-specific array/bulk-insert type token when needed. */
|
package/dist/sqlite.js
CHANGED
|
@@ -372,6 +372,9 @@ export const sqliteDialect = {
|
|
|
372
372
|
supportsAdvisoryLock: false,
|
|
373
373
|
// No FROM-clause LATERAL: the opt-in lateral pick plan is Postgres-only.
|
|
374
374
|
supportsLateralJoin: false,
|
|
375
|
+
// SQLite explains a compiled query with `EXPLAIN QUERY PLAN` (four columns:
|
|
376
|
+
// id, parent, notused, detail), overriding the inherited Postgres `EXPLAIN`.
|
|
377
|
+
explainQuery: { prefix: 'EXPLAIN QUERY PLAN' },
|
|
375
378
|
// json_group_array / json_object have no inline ORDER BY argument, so every
|
|
376
379
|
// ordered to-many relation is forced through the inner-subquery rewrite.
|
|
377
380
|
aggSupportsInlineOrderBy: false,
|
|
@@ -423,7 +426,7 @@ export const sqliteDialect = {
|
|
|
423
426
|
return JSON.stringify(values ?? []);
|
|
424
427
|
},
|
|
425
428
|
buildReturningClause(selection = '*') {
|
|
426
|
-
return ` RETURNING ${selection}`;
|
|
429
|
+
return ` RETURNING ${selection === '*' ? '*' : selection.join(', ')}`;
|
|
427
430
|
},
|
|
428
431
|
buildInsertStatement(input) {
|
|
429
432
|
return (`INSERT INTO ${input.table} (${input.columns.join(', ')}) ` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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": {
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"lint": "biome check src/",
|
|
75
75
|
"lint:fix": "biome check --write src/",
|
|
76
76
|
"format": "biome format --write src/",
|
|
77
|
-
"prepublishOnly": "npm run build && npm run typecheck && npm run lint && npm run test:unit",
|
|
77
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm run lint && npm run test:unit && npm run size",
|
|
78
78
|
"prepack": "node scripts/strip-prepare.mjs",
|
|
79
79
|
"postpack": "node scripts/restore-prepare.mjs",
|
|
80
80
|
"size": "size-limit",
|
|
@@ -103,8 +103,8 @@
|
|
|
103
103
|
"@size-limit/esbuild": "^12.1.0",
|
|
104
104
|
"@size-limit/file": "^12.1.0",
|
|
105
105
|
"@types/node": "^26.1.0",
|
|
106
|
-
"@zvndev/powdb-client": "^0.
|
|
107
|
-
"@zvndev/powdb-embedded": "^0.
|
|
106
|
+
"@zvndev/powdb-client": "^0.15.0",
|
|
107
|
+
"@zvndev/powdb-embedded": "^0.15.0",
|
|
108
108
|
"c8": "^11.0.0",
|
|
109
109
|
"husky": "^9.1.7",
|
|
110
110
|
"lint-staged": "^17.0.8",
|