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.
Files changed (76) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/client.js +26 -4
  8. package/dist/cjs/dialect.js +2 -1
  9. package/dist/cjs/errors.js +41 -1
  10. package/dist/cjs/generate.js +23 -2
  11. package/dist/cjs/index.js +4 -2
  12. package/dist/cjs/mssql.js +27 -5
  13. package/dist/cjs/mysql.js +4 -0
  14. package/dist/cjs/powdb.js +197 -25
  15. package/dist/cjs/powql.js +515 -51
  16. package/dist/cjs/query/aggregates.js +683 -0
  17. package/dist/cjs/query/batched-loader.js +2 -0
  18. package/dist/cjs/query/builder.js +361 -4508
  19. package/dist/cjs/query/filters.js +12 -0
  20. package/dist/cjs/query/relations.js +1698 -0
  21. package/dist/cjs/query/where-compile.js +180 -0
  22. package/dist/cjs/query/where.js +1491 -0
  23. package/dist/cjs/query/writes.js +680 -0
  24. package/dist/cjs/schema-builder.js +6 -0
  25. package/dist/cjs/schema-metadata.js +4 -0
  26. package/dist/cjs/schema-sql.js +265 -3
  27. package/dist/cjs/sqlite.js +4 -1
  28. package/dist/cli/index.d.ts +8 -2
  29. package/dist/cli/index.js +111 -18
  30. package/dist/cli/migrate.d.ts +24 -1
  31. package/dist/cli/migrate.js +77 -3
  32. package/dist/cli/studio-ui.generated.js +1 -1
  33. package/dist/cli/studio.d.ts +46 -13
  34. package/dist/cli/studio.js +331 -23
  35. package/dist/cli/ui.js +7 -1
  36. package/dist/client.d.ts +32 -5
  37. package/dist/client.js +26 -4
  38. package/dist/dialect.d.ts +28 -6
  39. package/dist/dialect.js +2 -1
  40. package/dist/errors.d.ts +36 -0
  41. package/dist/errors.js +39 -0
  42. package/dist/generate.js +23 -2
  43. package/dist/index.d.ts +3 -3
  44. package/dist/index.js +2 -2
  45. package/dist/mssql.js +27 -5
  46. package/dist/mysql.js +4 -0
  47. package/dist/powdb.d.ts +135 -9
  48. package/dist/powdb.js +197 -25
  49. package/dist/powql.d.ts +166 -4
  50. package/dist/powql.js +516 -52
  51. package/dist/query/aggregates.d.ts +74 -0
  52. package/dist/query/aggregates.js +641 -0
  53. package/dist/query/batched-loader.d.ts +6 -0
  54. package/dist/query/batched-loader.js +2 -0
  55. package/dist/query/builder.d.ts +98 -830
  56. package/dist/query/builder.js +366 -4513
  57. package/dist/query/deferred.d.ts +13 -2
  58. package/dist/query/filters.d.ts +7 -0
  59. package/dist/query/filters.js +11 -0
  60. package/dist/query/relations.d.ts +441 -0
  61. package/dist/query/relations.js +1627 -0
  62. package/dist/query/types.d.ts +25 -6
  63. package/dist/query/where-compile.d.ts +139 -0
  64. package/dist/query/where-compile.js +175 -0
  65. package/dist/query/where.d.ts +494 -0
  66. package/dist/query/where.js +1431 -0
  67. package/dist/query/writes.d.ts +131 -0
  68. package/dist/query/writes.js +626 -0
  69. package/dist/schema-builder.d.ts +18 -3
  70. package/dist/schema-builder.js +6 -0
  71. package/dist/schema-metadata.js +4 -0
  72. package/dist/schema-sql.d.ts +60 -3
  73. package/dist/schema-sql.js +261 -4
  74. package/dist/schema.d.ts +10 -0
  75. package/dist/sqlite.js +4 -1
  76. package/package.json +4 -4
@@ -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;
@@ -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);
@@ -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 { SchemaDef, TableDef } from './schema-builder.js';
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. This is a destructive operation for ADD/ALTER —
137
- * it will NOT drop tables or columns unless explicitly configured.
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.
@@ -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. This is a destructive operation for ADD/ALTER —
844
- * it will NOT drop tables or columns unless explicitly configured.
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
- const diff = await schemaDiff(schema, connectionString);
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.34.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.13.0",
107
- "@zvndev/powdb-embedded": "^0.13.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",