turbine-orm 0.25.0 → 0.27.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.
@@ -12,6 +12,7 @@ exports.escapeLike = escapeLike;
12
12
  exports.fnv1a64Hex = fnv1a64Hex;
13
13
  exports.sqlToPreparedName = sqlToPreparedName;
14
14
  exports.buildCorrelation = buildCorrelation;
15
+ exports.parseDbDate = parseDbDate;
15
16
  // ---------------------------------------------------------------------------
16
17
  // Identifier quoting — prevents SQL injection via table/column names
17
18
  // ---------------------------------------------------------------------------
@@ -139,3 +140,38 @@ function buildCorrelation(leftRef, leftColumns, rightRef, rightColumns) {
139
140
  .map((col, i) => `${leftRef}.${quoteIdent(col)} = ${rightRef}.${quoteIdent(rightCols[i])}`)
140
141
  .join(' AND ');
141
142
  }
143
+ /**
144
+ * Matches an explicit timezone suffix on a date-time string: a trailing `Z`
145
+ * or a `±HH`, `±HHMM`, `±HH:MM` offset.
146
+ */
147
+ const TZ_SUFFIX_RE = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/;
148
+ /**
149
+ * Parse a database date-time string deterministically.
150
+ *
151
+ * Postgres `timestamp` (without time zone) values arrive with no offset —
152
+ * both from the driver and from `json_agg`/`json_build_object` subquery JSON
153
+ * (`2026-07-07T17:15:41.896`). JavaScript's `new Date()` interprets such
154
+ * strings in the SERVER'S LOCAL TIME ZONE, so the same row parses to a
155
+ * different instant depending on where the code runs. The universal ORM
156
+ * convention (Prisma, Rails, Django) is to treat offset-less timestamps as
157
+ * UTC — that is also the only interpretation that round-trips: Postgres
158
+ * stores exactly the wall-clock fields you sent.
159
+ *
160
+ * Strings that carry an explicit offset (`timestamptz` output) are parsed
161
+ * as-is.
162
+ */
163
+ function parseDbDate(value) {
164
+ // Date-only values (`2026-07-07`, from `date` columns in json_agg output)
165
+ // have no time to zone-pin — and their `-07` tail must not be read as an
166
+ // offset. JS parses bare ISO dates as UTC midnight already.
167
+ if (!value.includes(':'))
168
+ return new Date(value);
169
+ if (TZ_SUFFIX_RE.test(value)) {
170
+ // JS Date can't parse colon-less (`-0430`) or bare-hour (`+02`) offsets —
171
+ // normalize both to `±HH:MM`. Postgres emits the bare-hour form for
172
+ // whole-hour zones in some text outputs.
173
+ return new Date(value.replace(/([+-]\d{2})(\d{2})$/, '$1:$2').replace(/([+-]\d{2})$/, '$1:00'));
174
+ }
175
+ // normalize `YYYY-MM-DD HH:MM:SS` (driver form) to ISO before pinning UTC
176
+ return new Date(`${value.replace(' ', 'T')}Z`);
177
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Destructive-migration detection.
3
+ *
4
+ * `migrate up`/`down` execute user-authored SQL files verbatim, which is the
5
+ * one place data loss can hide: a `DROP TABLE` or a `DELETE FROM` in a
6
+ * migration runs with no ceremony. This module scans migration SQL for
7
+ * statements that can destroy data so the CLI can force an explicit,
8
+ * interactive confirmation (and the programmatic API can refuse by default).
9
+ *
10
+ * Deliberately conservative in BOTH directions:
11
+ * - comments and string literals are stripped first, so `-- DROP TABLE foo`
12
+ * or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
13
+ * - anything that removes rows, columns, tables, or schemas — or rewrites a
14
+ * column's type (a potentially lossy cast) — is flagged. `DROP INDEX`,
15
+ * `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
16
+ * structures; no row data lost).
17
+ */
18
+ export type DestructiveKind = 'drop-table' | 'drop-schema' | 'drop-column' | 'truncate' | 'delete' | 'update-without-where' | 'alter-column-type';
19
+ export interface DestructiveStatement {
20
+ /** The offending SQL statement (trimmed, possibly long — display truncated) */
21
+ statement: string;
22
+ kind: DestructiveKind;
23
+ /** Best-effort extracted object name (table, schema, or table.column) */
24
+ target: string;
25
+ }
26
+ /** Human explanation per kind, used in CLI output. */
27
+ export declare const DESTRUCTIVE_KIND_LABEL: Record<DestructiveKind, string>;
28
+ /**
29
+ * Scan SQL (one file's worth; may contain many `;`-separated statements) and
30
+ * return every statement that can destroy data.
31
+ */
32
+ export declare function scanDestructiveSql(sql: string): DestructiveStatement[];
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Destructive-migration detection.
3
+ *
4
+ * `migrate up`/`down` execute user-authored SQL files verbatim, which is the
5
+ * one place data loss can hide: a `DROP TABLE` or a `DELETE FROM` in a
6
+ * migration runs with no ceremony. This module scans migration SQL for
7
+ * statements that can destroy data so the CLI can force an explicit,
8
+ * interactive confirmation (and the programmatic API can refuse by default).
9
+ *
10
+ * Deliberately conservative in BOTH directions:
11
+ * - comments and string literals are stripped first, so `-- DROP TABLE foo`
12
+ * or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
13
+ * - anything that removes rows, columns, tables, or schemas — or rewrites a
14
+ * column's type (a potentially lossy cast) — is flagged. `DROP INDEX`,
15
+ * `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
16
+ * structures; no row data lost).
17
+ */
18
+ /** Human explanation per kind, used in CLI output. */
19
+ export const DESTRUCTIVE_KIND_LABEL = {
20
+ 'drop-table': 'drops a table and ALL its rows',
21
+ 'drop-schema': 'drops an entire schema',
22
+ 'drop-column': 'drops a column and its data in every row',
23
+ truncate: 'deletes every row',
24
+ delete: 'deletes rows',
25
+ 'update-without-where': 'rewrites every row (no WHERE clause)',
26
+ 'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
27
+ };
28
+ /** Strip -- line comments, C-style block comments, and quoted literals. */
29
+ function stripCommentsAndStrings(sql) {
30
+ let out = '';
31
+ let i = 0;
32
+ while (i < sql.length) {
33
+ const two = sql.slice(i, i + 2);
34
+ if (two === '--') {
35
+ const nl = sql.indexOf('\n', i);
36
+ i = nl === -1 ? sql.length : nl; // keep the newline
37
+ }
38
+ else if (two === '/*') {
39
+ const end = sql.indexOf('*/', i + 2);
40
+ i = end === -1 ? sql.length : end + 2;
41
+ out += ' ';
42
+ }
43
+ else if (sql[i] === "'") {
44
+ // single-quoted literal ('' escapes a quote)
45
+ let j = i + 1;
46
+ while (j < sql.length) {
47
+ if (sql[j] === "'" && sql[j + 1] === "'")
48
+ j += 2;
49
+ else if (sql[j] === "'")
50
+ break;
51
+ else
52
+ j++;
53
+ }
54
+ i = j + 1;
55
+ out += "''";
56
+ }
57
+ else if (sql[i] === '$' && /^\$[a-zA-Z_]*\$/.test(sql.slice(i))) {
58
+ // dollar-quoted literal ($$...$$ / $tag$...$tag$)
59
+ const tag = sql.slice(i).match(/^\$[a-zA-Z_]*\$/)?.[0] ?? '$$';
60
+ const end = sql.indexOf(tag, i + tag.length);
61
+ i = end === -1 ? sql.length : end + tag.length;
62
+ out += "''";
63
+ }
64
+ else {
65
+ out += sql[i];
66
+ i++;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ /** Unquote a "quoted" identifier for display. */
72
+ const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
73
+ const IDENT = String.raw `("[^"]+"|[a-zA-Z_][\w$]*)(\.("[^"]+"|[a-zA-Z_][\w$]*))?`;
74
+ /**
75
+ * Scan SQL (one file's worth; may contain many `;`-separated statements) and
76
+ * return every statement that can destroy data.
77
+ */
78
+ export function scanDestructiveSql(sql) {
79
+ const found = [];
80
+ const cleaned = stripCommentsAndStrings(sql);
81
+ for (const rawStmt of cleaned.split(';')) {
82
+ const stmt = rawStmt.trim();
83
+ if (!stmt)
84
+ continue;
85
+ const display = stmt.replace(/\s+/g, ' ');
86
+ let m;
87
+ if ((m = stmt.match(new RegExp(String.raw `^DROP\s+TABLE\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i')))) {
88
+ found.push({
89
+ statement: display,
90
+ kind: 'drop-table',
91
+ target: ident(m[4] ? `${ident(m[2])}.${ident(m[4])}` : m[2]),
92
+ });
93
+ }
94
+ else if ((m = stmt.match(new RegExp(String.raw `^DROP\s+SCHEMA\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i')))) {
95
+ found.push({ statement: display, kind: 'drop-schema', target: ident(m[2]) });
96
+ }
97
+ else if ((m = stmt.match(new RegExp(String.raw `^TRUNCATE\s+(TABLE\s+)?(ONLY\s+)?${IDENT}`, 'i')))) {
98
+ found.push({
99
+ statement: display,
100
+ kind: 'truncate',
101
+ target: ident(m[5] ? `${ident(m[3])}.${ident(m[5])}` : m[3]),
102
+ });
103
+ }
104
+ else if ((m = stmt.match(new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bDROP\s+COLUMN\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i')))) {
105
+ found.push({ statement: display, kind: 'drop-column', target: `${ident(m[3])}.${ident(m[7])}` });
106
+ }
107
+ else if ((m = stmt.match(new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bALTER\s+(COLUMN\s+)?${IDENT}\s+(SET\s+DATA\s+)?TYPE\b`, 'i')))) {
108
+ found.push({ statement: display, kind: 'alter-column-type', target: `${ident(m[3])}.${ident(m[7])}` });
109
+ }
110
+ else if ((m = stmt.match(new RegExp(String.raw `^DELETE\s+FROM\s+(ONLY\s+)?${IDENT}`, 'i')))) {
111
+ found.push({ statement: display, kind: 'delete', target: ident(m[2]) });
112
+ }
113
+ else if ((m = stmt.match(new RegExp(String.raw `^UPDATE\s+(ONLY\s+)?${IDENT}\b`, 'i'))) &&
114
+ !/\bWHERE\b/i.test(stmt)) {
115
+ found.push({ statement: display, kind: 'update-without-where', target: ident(m[2]) });
116
+ }
117
+ }
118
+ return found;
119
+ }
@@ -12,6 +12,7 @@
12
12
  * turbine migrate status — Show migration status
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
+ * turbine doctor — Check relations for missing FK indexes (--fix emits migration)
15
16
  * turbine studio — Launch local read-only web UI
16
17
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
17
18
  *
package/dist/cli/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * turbine migrate status — Show migration status
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
+ * turbine doctor — Check relations for missing FK indexes (--fix emits migration)
15
16
  * turbine studio — Launch local read-only web UI
16
17
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
17
18
  *
@@ -24,6 +25,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writ
24
25
  import { dirname, relative, resolve } from 'node:path';
25
26
  import { pathToFileURL } from 'node:url';
26
27
  import { generate } from '../generate.js';
28
+ import { findMissingRelationIndexes } from '../index-advisor.js';
27
29
  import { introspect } from '../introspect.js';
28
30
  import { schemaDiff, schemaPush } from '../schema-sql.js';
29
31
  import { configTemplate, findConfigFile, loadConfig, looksLikeSchemaFilePath, resolveConfig } from './config.js';
@@ -88,6 +90,12 @@ function parseArgs() {
88
90
  case '--allow-empty':
89
91
  result.allowEmpty = true;
90
92
  break;
93
+ case '--fix':
94
+ result.fix = true;
95
+ break;
96
+ case '--allow-destructive':
97
+ result.allowDestructive = true;
98
+ break;
91
99
  case '--force':
92
100
  case '-f':
93
101
  result.force = true;
@@ -744,11 +752,35 @@ async function cmdMigrateUp(args, config) {
744
752
  console.log(` ${dim('Proceed only if you are intentionally rewriting migration history.')}`);
745
753
  newline();
746
754
  }
755
+ if (args.allowDestructive) {
756
+ warn('--allow-destructive is set — data-destroying statements in migrations WILL run.');
757
+ newline();
758
+ }
747
759
  const spinner = new Spinner('Applying migrations').start();
748
- const result = await migrateUp(url, config.migrationsDir, {
749
- step: args.step,
750
- allowDrift: args.allowDrift,
751
- });
760
+ let result;
761
+ try {
762
+ result = await migrateUp(url, config.migrationsDir, {
763
+ step: args.step,
764
+ allowDrift: args.allowDrift,
765
+ allowDestructive: args.allowDestructive,
766
+ });
767
+ }
768
+ catch (err) {
769
+ if (!isDestructiveRefusal(err))
770
+ throw err;
771
+ spinner.stop();
772
+ if (!(await confirmDestructive(err.message))) {
773
+ error('Aborted — no migrations were applied and no data was touched.');
774
+ newline();
775
+ process.exit(1);
776
+ }
777
+ spinner.start();
778
+ result = await migrateUp(url, config.migrationsDir, {
779
+ step: args.step,
780
+ allowDrift: args.allowDrift,
781
+ allowDestructive: true,
782
+ });
783
+ }
752
784
  if (result.applied.length === 0 && result.errors.length === 0) {
753
785
  spinner.succeed('All migrations are up to date');
754
786
  newline();
@@ -771,6 +803,44 @@ async function cmdMigrateUp(args, config) {
771
803
  }
772
804
  newline();
773
805
  }
806
+ /** True when the error is migrate up/down's destructive-statement refusal. */
807
+ function isDestructiveRefusal(err) {
808
+ return err instanceof Error && err.message.includes('DESTRUCTIVE');
809
+ }
810
+ /**
811
+ * Triple confirmation for destructive migrations:
812
+ * 1. show the full itemized report (statement kinds + targets),
813
+ * 2. require typing the literal phrase `destroy my data`,
814
+ * 3. require a final explicit `yes`.
815
+ * Non-interactive shells (CI, pipes) can never pass this — they must use the
816
+ * explicit `--allow-destructive` flag instead. Anything but exact answers aborts.
817
+ */
818
+ async function confirmDestructive(report) {
819
+ newline();
820
+ error('DESTRUCTIVE MIGRATION DETECTED');
821
+ newline();
822
+ for (const line of report.split('\n'))
823
+ console.log(` ${line.includes('[turbine]') ? line.replace('[turbine] ', '') : line}`);
824
+ newline();
825
+ if (!process.stdin.isTTY) {
826
+ console.log(` ${dim('Non-interactive shell: rerun with')} ${cyan('--allow-destructive')} ${dim('to proceed.')}`);
827
+ newline();
828
+ return false;
829
+ }
830
+ const { createInterface } = await import('node:readline/promises');
831
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
832
+ try {
833
+ console.log(` ${yellow('This will permanently destroy data. There is no undo.')}`);
834
+ const phrase = await rl.question(` Type ${bold('destroy my data')} to continue, anything else to abort: `);
835
+ if (phrase.trim() !== 'destroy my data')
836
+ return false;
837
+ const finalAnswer = await rl.question(` Final confirmation — apply the destructive statements above? Type ${bold('yes')}: `);
838
+ return finalAnswer.trim() === 'yes';
839
+ }
840
+ finally {
841
+ rl.close();
842
+ }
843
+ }
774
844
  async function cmdMigrateDown(args, config) {
775
845
  banner();
776
846
  const url = requireUrl(config);
@@ -778,9 +848,28 @@ async function cmdMigrateDown(args, config) {
778
848
  label('Migrations', config.migrationsDir);
779
849
  newline();
780
850
  const spinner = new Spinner('Rolling back migration(s)').start();
781
- const result = await migrateDown(url, config.migrationsDir, {
782
- step: args.step ?? 1,
783
- });
851
+ let result;
852
+ try {
853
+ result = await migrateDown(url, config.migrationsDir, {
854
+ step: args.step ?? 1,
855
+ allowDestructive: args.allowDestructive,
856
+ });
857
+ }
858
+ catch (err) {
859
+ if (!isDestructiveRefusal(err))
860
+ throw err;
861
+ spinner.stop();
862
+ if (!(await confirmDestructive(err.message))) {
863
+ error('Aborted — nothing was rolled back and no data was touched.');
864
+ newline();
865
+ process.exit(1);
866
+ }
867
+ spinner.start();
868
+ result = await migrateDown(url, config.migrationsDir, {
869
+ step: args.step ?? 1,
870
+ allowDestructive: true,
871
+ });
872
+ }
784
873
  if (result.rolledBack.length === 0 && result.errors.length === 0) {
785
874
  spinner.succeed('No migrations to roll back');
786
875
  newline();
@@ -974,6 +1063,80 @@ async function cmdStatus(_args, config) {
974
1063
  }
975
1064
  }
976
1065
  // ---------------------------------------------------------------------------
1066
+ // Command: doctor — relation/index health check
1067
+ // ---------------------------------------------------------------------------
1068
+ async function cmdDoctor(args, config) {
1069
+ banner();
1070
+ const url = requireUrl(config);
1071
+ label('Database', redactUrl(url));
1072
+ label('Schema', config.schema);
1073
+ newline();
1074
+ const spinner = new Spinner('Introspecting database').start();
1075
+ const schema = await introspect({
1076
+ connectionString: url,
1077
+ schema: config.schema,
1078
+ include: config.include.length ? config.include : undefined,
1079
+ exclude: config.exclude.length ? config.exclude : undefined,
1080
+ });
1081
+ const missing = findMissingRelationIndexes(schema);
1082
+ if (missing.length === 0) {
1083
+ spinner.succeed('Every relation probe is backed by an index');
1084
+ newline();
1085
+ return;
1086
+ }
1087
+ spinner.succeed(`Scanned ${bold(String(Object.keys(schema.tables).length))} tables`);
1088
+ warn(`Found ${bold(String(missing.length))} unindexed relation probe(s)`);
1089
+ newline();
1090
+ // Row counts put the findings in severity order: a missing index on a 300-row
1091
+ // table is noise; on a 300K-row table it is the whole page load.
1092
+ const rowCounts = new Map();
1093
+ {
1094
+ const { Pool } = (await import('pg')).default;
1095
+ const pool = new Pool({ connectionString: url, max: 1 });
1096
+ try {
1097
+ const tables = [...new Set(missing.map((m) => m.table))];
1098
+ const res = await pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
1099
+ FROM pg_class c
1100
+ JOIN pg_namespace n ON n.oid = c.relnamespace
1101
+ WHERE n.nspname = $1 AND c.relname = ANY($2)`, [config.schema, tables]);
1102
+ for (const row of res.rows)
1103
+ rowCounts.set(row.relname, Math.max(0, Number(row.reltuples)));
1104
+ }
1105
+ finally {
1106
+ await pool.end();
1107
+ }
1108
+ }
1109
+ missing.sort((a, b) => (rowCounts.get(b.table) ?? 0) - (rowCounts.get(a.table) ?? 0));
1110
+ console.log(` ${dim('Turbine loads relations as correlated subqueries — the child table is probed')}`);
1111
+ console.log(` ${dim('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
1112
+ newline();
1113
+ for (const m of missing) {
1114
+ const rows = rowCounts.get(m.table);
1115
+ const rowsLabel = rows !== undefined ? `~${rows.toLocaleString()} rows` : 'row count unknown';
1116
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(m.table))} ${dim(`(${m.columns.join(', ')})`)} ${gray(rowsLabel)}`);
1117
+ for (const p of m.probes) {
1118
+ console.log(` ${dim(symbols.tee)} probed by ${p.from}.${blue(p.relation)} ${dim(`(${p.type})`)}`);
1119
+ }
1120
+ console.log(` ${dim(symbols.teeEnd)} ${green(m.createSql)}`);
1121
+ newline();
1122
+ }
1123
+ if (args.fix) {
1124
+ const up = missing.map((m) => m.createSql).join('\n');
1125
+ const down = missing.map((m) => m.dropSql).join('\n');
1126
+ const file = createMigration(config.migrationsDir, 'add_relation_fk_indexes', { up, down });
1127
+ success(`Created migration: ${bold(file.filename)}`);
1128
+ newline();
1129
+ console.log(` ${dim('Review it, then apply with:')} ${cyan('npx turbine migrate up')}`);
1130
+ console.log(` ${dim('Large, hot tables: consider running the statements manually with')} ${cyan('CREATE INDEX CONCURRENTLY')}`);
1131
+ console.log(` ${dim('(cannot run inside a transaction, so it is not emitted in the migration).')}`);
1132
+ newline();
1133
+ }
1134
+ else {
1135
+ console.log(` ${dim('Generate a fix migration with:')} ${cyan('npx turbine doctor --fix')}`);
1136
+ newline();
1137
+ }
1138
+ }
1139
+ // ---------------------------------------------------------------------------
977
1140
  // Command: studio — local read-only web UI
978
1141
  // ---------------------------------------------------------------------------
979
1142
  async function cmdStudio(args, config) {
@@ -1194,6 +1357,7 @@ function showMigrateHelp() {
1194
1357
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
1195
1358
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1196
1359
  console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
1360
+ console.log(` ${cyan('--allow-destructive')} Run data-destroying migration statements without the interactive confirm`);
1197
1361
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1198
1362
  newline();
1199
1363
  console.log(` ${bold('Examples:')}`);
@@ -1252,6 +1416,7 @@ function showHelp() {
1252
1416
  console.log(` ${dim('status')} Show applied/pending migrations`);
1253
1417
  console.log(` ${cyan('seed')} Run seed file`);
1254
1418
  console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1419
+ console.log(` ${cyan('doctor')} Check relations for missing FK indexes ${dim('(--fix emits migration)')}`);
1255
1420
  console.log(` ${cyan('studio')} Launch local read-only web UI`);
1256
1421
  console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
1257
1422
  newline();
@@ -1404,6 +1569,9 @@ async function main() {
1404
1569
  case 'info':
1405
1570
  await cmdStatus(args, config);
1406
1571
  break;
1572
+ case 'doctor':
1573
+ await cmdDoctor(args, config);
1574
+ break;
1407
1575
  case 'studio':
1408
1576
  await cmdStudio(args, config);
1409
1577
  break;
@@ -115,6 +115,8 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
115
115
  step?: number;
116
116
  allowDrift?: boolean;
117
117
  force?: boolean /** @deprecated use allowDrift */;
118
+ /** Run migrations even when they contain data-destroying statements. Default false. */
119
+ allowDestructive?: boolean;
118
120
  adapter?: DatabaseAdapter;
119
121
  dialect?: Dialect;
120
122
  }): Promise<{
@@ -134,6 +136,7 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
134
136
  */
135
137
  export declare function migrateDown(connectionString: string, migrationsDir: string, options?: {
136
138
  step?: number;
139
+ allowDestructive?: boolean;
137
140
  adapter?: DatabaseAdapter;
138
141
  dialect?: Dialect;
139
142
  }): Promise<{
@@ -18,6 +18,7 @@ import pg from 'pg';
18
18
  import { postgresql } from '../adapters/index.js';
19
19
  import { postgresDialect } from '../dialect.js';
20
20
  import { MigrationError } from '../errors.js';
21
+ import { DESTRUCTIVE_KIND_LABEL, scanDestructiveSql } from './destructive.js';
21
22
  // ---------------------------------------------------------------------------
22
23
  // Tracking table management
23
24
  // ---------------------------------------------------------------------------
@@ -351,6 +352,35 @@ export async function migrateUp(connectionString, migrationsDir, options) {
351
352
  if (options?.step != null && options.step > 0) {
352
353
  pending = pending.slice(0, options.step);
353
354
  }
355
+ // Data-loss gate: refuse to run pending migrations containing destructive
356
+ // statements unless the caller has EXPLICITLY opted in. The CLI layers an
357
+ // interactive typed confirmation on top of this; programmatic callers must
358
+ // pass `allowDestructive: true`. Safe-by-default is the whole point — a
359
+ // DROP TABLE should never run just because a file exists.
360
+ if (!options?.allowDestructive) {
361
+ const offenders = [];
362
+ for (const file of pending) {
363
+ const { up } = parseMigrationSQL(file.path);
364
+ if (!up)
365
+ continue;
366
+ const hits = scanDestructiveSql(up);
367
+ if (hits.length > 0)
368
+ offenders.push({ file: file.filename, hits });
369
+ }
370
+ if (offenders.length > 0) {
371
+ const lines = ['[turbine] Refusing to apply migrations containing DESTRUCTIVE statements:', ''];
372
+ for (const o of offenders) {
373
+ lines.push(` ${o.file}`);
374
+ for (const h of o.hits) {
375
+ lines.push(` - [${h.kind}] ${h.target} — ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
376
+ }
377
+ }
378
+ lines.push('');
379
+ lines.push('Review the statements above. To proceed: run `npx turbine migrate up` interactively');
380
+ lines.push('and confirm, pass --allow-destructive, or set allowDestructive: true programmatically.');
381
+ throw new MigrationError(lines.join('\n'));
382
+ }
383
+ }
354
384
  const results = [];
355
385
  const errors = [];
356
386
  for (const file of pending) {
@@ -418,6 +448,36 @@ export async function migrateDown(connectionString, migrationsDir, options) {
418
448
  const fileMap = new Map(allFiles.map((f) => [f.name, f]));
419
449
  // Reverse order — rollback most recent first
420
450
  const toRollback = applied.reverse().slice(0, options?.step ?? 1);
451
+ // Same data-loss gate as migrateUp — DOWN sections routinely contain
452
+ // DROP TABLE (the legitimate reverse of a CREATE), which still destroys
453
+ // every row written since the migration ran. Explicit opt-in required.
454
+ if (!options?.allowDestructive) {
455
+ const offenders = [];
456
+ for (const migration of toRollback) {
457
+ const file = fileMap.get(migration.name);
458
+ if (!file)
459
+ continue;
460
+ const { down } = parseMigrationSQL(file.path);
461
+ if (!down)
462
+ continue;
463
+ const hits = scanDestructiveSql(down);
464
+ if (hits.length > 0)
465
+ offenders.push({ file: file.filename, hits });
466
+ }
467
+ if (offenders.length > 0) {
468
+ const lines = ['[turbine] Refusing to roll back migrations whose DOWN sections are DESTRUCTIVE:', ''];
469
+ for (const o of offenders) {
470
+ lines.push(` ${o.file}`);
471
+ for (const h of o.hits) {
472
+ lines.push(` - [${h.kind}] ${h.target} — ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
473
+ }
474
+ }
475
+ lines.push('');
476
+ lines.push('To proceed: run `npx turbine migrate down` interactively and confirm, pass');
477
+ lines.push('--allow-destructive, or set allowDestructive: true programmatically.');
478
+ throw new MigrationError(lines.join('\n'));
479
+ }
480
+ }
421
481
  const results = [];
422
482
  const errors = [];
423
483
  for (const migration of toRollback) {
package/dist/cli/ui.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare const symbols: {
30
30
  readonly bullet: "*" | "•";
31
31
  readonly arrow: "→" | "->";
32
32
  readonly arrowRight: ">" | "▸";
33
- readonly info: "" | "i";
33
+ readonly info: "i" | "";
34
34
  readonly warning: "⚠" | "!";
35
35
  readonly dot: "." | "∙";
36
36
  readonly line: "─" | "-";
package/dist/client.d.ts CHANGED
@@ -26,7 +26,7 @@ import { type Dialect } from './dialect.js';
26
26
  import { type ErrorMessageMode } from './errors.js';
27
27
  import { type ObserveConfig, type ObserveHandle } from './observe.js';
28
28
  import { type PipelineOptions, type PipelineResults } from './pipeline.js';
29
- import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
29
+ import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy } from './query/index.js';
30
30
  import { type NotificationHandler, type Subscription } from './realtime.js';
31
31
  import type { SchemaMetadata } from './schema.js';
32
32
  import { TypedSqlQuery } from './typed-sql.js';
@@ -140,6 +140,44 @@ export interface TurbineConfig {
140
140
  defaultLimit?: number;
141
141
  /** Log a warning when findMany() is called without a limit (default: false) */
142
142
  warnOnUnlimited?: boolean;
143
+ /**
144
+ * Interpret Postgres `timestamp` (without time zone) values as UTC — both
145
+ * at the driver level (OID 1114 type parser, registered only when Turbine
146
+ * owns the pool) and when coercing nested-relation JSON dates. This is the
147
+ * Prisma/Rails/Django convention and makes results independent of the
148
+ * server's local time zone. Default: `true`. Set `false` for the legacy
149
+ * local-time interpretation.
150
+ */
151
+ utcTimestamps?: boolean;
152
+ /**
153
+ * Default strategy for resolving `with`-clause relations, applied to every
154
+ * `findMany`/`findUnique`/`findFirst` unless overridden per query.
155
+ *
156
+ * - `'join'` (default) — one SQL statement using correlated
157
+ * `json_agg(json_build_object(...))` subqueries.
158
+ * - `'batched'` — run the base query, then one flat follow-up query per
159
+ * relation (`WHERE fk = ANY($1)`), stitching children client-side. Wins
160
+ * when child FK columns are unindexed or result sets are large.
161
+ *
162
+ * Precedence: per-query `relationLoadStrategy` arg > this config > `'join'`.
163
+ */
164
+ relationLoadStrategy?: RelationLoadStrategy;
165
+ /**
166
+ * How nested-relation subqueries encode each row's JSON.
167
+ *
168
+ * - `'object'` (default) — `json_agg(json_build_object('key', v, …))`. Every
169
+ * key name is repeated in every nested object of every row.
170
+ * - `'positional'` — `json_agg(json_build_array(v, …))`. Turbine knows the
171
+ * column order at build time, so it emits a key-less array and maps
172
+ * positions back to keys client-side. Same information, a fraction of the
173
+ * bytes on wide/deeply-nested `with` trees. Parsed output is byte-identical
174
+ * to `'object'`.
175
+ *
176
+ * Postgres-only in v1: setting `'positional'` on a non-Postgres engine throws
177
+ * `UnsupportedFeatureError` (E017) when a `with` clause is present. Default:
178
+ * `'object'` (today's behavior, byte-unchanged).
179
+ */
180
+ jsonEncoding?: 'object' | 'positional';
143
181
  /**
144
182
  * Controls how `NotFoundError` (and other where-aware errors) format their
145
183
  * messages.
@@ -263,6 +301,7 @@ export declare class TurbineClient {
263
301
  /** The schema metadata this client was built from */
264
302
  readonly schema: SchemaMetadata;
265
303
  private static int8ParserRegistered;
304
+ private static utcTimestampParserRegistered;
266
305
  private readonly logging;
267
306
  /** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
268
307
  private readonly dialect;
package/dist/client.js CHANGED
@@ -197,6 +197,7 @@ export class TurbineClient {
197
197
  /** The schema metadata this client was built from */
198
198
  schema;
199
199
  static int8ParserRegistered = false;
200
+ static utcTimestampParserRegistered = false;
200
201
  logging;
201
202
  /** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
202
203
  dialect;
@@ -244,6 +245,16 @@ export class TurbineClient {
244
245
  });
245
246
  TurbineClient.int8ParserRegistered = true;
246
247
  }
248
+ // Parse `timestamp` (OID 1114) as UTC instead of server-local time. The
249
+ // pg driver's default hands back a Date built in the process's local zone,
250
+ // so the same row yields a different instant per deployment region. The
251
+ // ORM convention (Prisma, Rails, Django) — and the only interpretation
252
+ // that round-trips what Postgres stores — is UTC. Same ownership rule as
253
+ // the int8 parser: never mutate parser state on external pools.
254
+ if (!config.pool && config.utcTimestamps !== false && !TurbineClient.utcTimestampParserRegistered) {
255
+ pg.types.setTypeParser(1114, (val) => new Date(`${val.replace(' ', 'T')}Z`));
256
+ TurbineClient.utcTimestampParserRegistered = true;
257
+ }
247
258
  this.logging = config.logging ?? false;
248
259
  this.dialect = config.dialect ?? postgresDialect;
249
260
  this.schema = schema;
@@ -253,6 +264,9 @@ export class TurbineClient {
253
264
  this.queryOptions = {
254
265
  defaultLimit: config.defaultLimit,
255
266
  warnOnUnlimited: config.warnOnUnlimited,
267
+ utcTimestamps: config.utcTimestamps,
268
+ relationLoadStrategy: config.relationLoadStrategy,
269
+ jsonEncoding: config.jsonEncoding,
256
270
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
257
271
  sqlCache: config.sqlCache ?? true,
258
272
  dialect: config.dialect,