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.
package/README.md CHANGED
@@ -344,6 +344,30 @@ const db = turbine({
344
344
  });
345
345
  ```
346
346
 
347
+ ### Relation loading and wire encoding
348
+
349
+ A few client options tune how `with` relations are loaded and encoded. All are optional and default to today's behavior.
350
+
351
+ ```typescript
352
+ const db = turbine({
353
+ connectionString: process.env.DATABASE_URL,
354
+ // How with-clause relations resolve: 'join' (default, one correlated-subquery
355
+ // statement) or 'batched' (base query + one flat follow-up per relation).
356
+ // Override per query on findMany/findFirst/findUnique. See Relations.
357
+ relationLoadStrategy: 'join',
358
+ // 'positional' (Postgres-only) drops repeated JSON keys from relation
359
+ // subqueries — ~39% fewer wire bytes on wide relations, byte-identical output.
360
+ // Default 'object'.
361
+ jsonEncoding: 'object',
362
+ // Parse `timestamp` (without time zone) as UTC — the Prisma/Rails/Django
363
+ // convention — so results don't shift with the server's local zone.
364
+ // Default true; set false for the legacy local-time interpretation.
365
+ utcTimestamps: true,
366
+ });
367
+ ```
368
+
369
+ Run `npx turbine doctor` to catch relations whose child-side FK lacks a covering index — the correlated-subquery strategy probes the child once per parent row, so a missing FK index costs a full scan per parent.
370
+
347
371
  ### Middleware
348
372
 
349
373
  Middleware wraps every query. It runs **after SQL generation**, so it can observe what's about to execute (`params.model`, `params.action`, `params.args`), measure timing, and transform the result returned by `next()` — but it cannot change the query itself.
@@ -579,6 +603,7 @@ Commands:
579
603
  migrate status Show applied/pending migrations
580
604
  seed Run seed file
581
605
  status Show database schema summary
606
+ doctor Check relations for missing FK indexes (--fix emits migration)
582
607
  studio Launch local read-only Studio web UI
583
608
  observe Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)
584
609
 
@@ -636,6 +661,12 @@ npx turbine migrate down
636
661
  npx turbine migrate status
637
662
  ```
638
663
 
664
+ **Destructive migrations require explicit confirmation.** If a pending migration (or a DOWN
665
+ section being rolled back) contains data-destroying SQL — `DROP TABLE`, `DROP COLUMN`,
666
+ `TRUNCATE`, `DELETE FROM`, `UPDATE` without `WHERE`, `ALTER COLUMN … TYPE` — Turbine refuses
667
+ to run it and prints an itemized report. Interactively you must type `destroy my data` and
668
+ then `yes`; in CI you must pass `--allow-destructive`. A refused batch applies nothing.
669
+
639
670
  ## Studio
640
671
 
641
672
  The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local, read-only web UI for exploring your database — no mutations, no writes, and since v0.19 **no raw-SQL surface at all**: every query is composed visually in the ORM and compiled by the same validated query builder your application uses.
@@ -890,7 +921,7 @@ Turbine maps Postgres types to TypeScript:
890
921
  | `int8` / `bigint` | `number` | Values > `Number.MAX_SAFE_INTEGER` (2^53 - 1) are returned as `string` at runtime to avoid precision loss. This affects < 0.01% of use cases (auto-increment IDs, counts, etc. are all safe). |
891
922
  | `numeric`, `money` | `string` | Arbitrary precision — kept as string to avoid JS float issues |
892
923
  | `text`, `varchar`, `uuid`, `citext` | `string` | |
893
- | `timestamptz`, `timestamp`, `date` | `Date` | |
924
+ | `timestamptz`, `timestamp`, `date` | `Date` | `timestamp` (without time zone) is parsed as UTC by default (Prisma/Rails/Django convention), so the same row yields the same instant in every region. Opt out with `utcTimestamps: false`. |
894
925
  | `boolean` | `boolean` | |
895
926
  | `json`, `jsonb` | `unknown` | |
896
927
  | `bytea` | `Buffer` | |
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ /**
3
+ * Destructive-migration detection.
4
+ *
5
+ * `migrate up`/`down` execute user-authored SQL files verbatim, which is the
6
+ * one place data loss can hide: a `DROP TABLE` or a `DELETE FROM` in a
7
+ * migration runs with no ceremony. This module scans migration SQL for
8
+ * statements that can destroy data so the CLI can force an explicit,
9
+ * interactive confirmation (and the programmatic API can refuse by default).
10
+ *
11
+ * Deliberately conservative in BOTH directions:
12
+ * - comments and string literals are stripped first, so `-- DROP TABLE foo`
13
+ * or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
14
+ * - anything that removes rows, columns, tables, or schemas — or rewrites a
15
+ * column's type (a potentially lossy cast) — is flagged. `DROP INDEX`,
16
+ * `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
17
+ * structures; no row data lost).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.DESTRUCTIVE_KIND_LABEL = void 0;
21
+ exports.scanDestructiveSql = scanDestructiveSql;
22
+ /** Human explanation per kind, used in CLI output. */
23
+ exports.DESTRUCTIVE_KIND_LABEL = {
24
+ 'drop-table': 'drops a table and ALL its rows',
25
+ 'drop-schema': 'drops an entire schema',
26
+ 'drop-column': 'drops a column and its data in every row',
27
+ truncate: 'deletes every row',
28
+ delete: 'deletes rows',
29
+ 'update-without-where': 'rewrites every row (no WHERE clause)',
30
+ 'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
31
+ };
32
+ /** Strip -- line comments, C-style block comments, and quoted literals. */
33
+ function stripCommentsAndStrings(sql) {
34
+ let out = '';
35
+ let i = 0;
36
+ while (i < sql.length) {
37
+ const two = sql.slice(i, i + 2);
38
+ if (two === '--') {
39
+ const nl = sql.indexOf('\n', i);
40
+ i = nl === -1 ? sql.length : nl; // keep the newline
41
+ }
42
+ else if (two === '/*') {
43
+ const end = sql.indexOf('*/', i + 2);
44
+ i = end === -1 ? sql.length : end + 2;
45
+ out += ' ';
46
+ }
47
+ else if (sql[i] === "'") {
48
+ // single-quoted literal ('' escapes a quote)
49
+ let j = i + 1;
50
+ while (j < sql.length) {
51
+ if (sql[j] === "'" && sql[j + 1] === "'")
52
+ j += 2;
53
+ else if (sql[j] === "'")
54
+ break;
55
+ else
56
+ j++;
57
+ }
58
+ i = j + 1;
59
+ out += "''";
60
+ }
61
+ else if (sql[i] === '$' && /^\$[a-zA-Z_]*\$/.test(sql.slice(i))) {
62
+ // dollar-quoted literal ($$...$$ / $tag$...$tag$)
63
+ const tag = sql.slice(i).match(/^\$[a-zA-Z_]*\$/)?.[0] ?? '$$';
64
+ const end = sql.indexOf(tag, i + tag.length);
65
+ i = end === -1 ? sql.length : end + tag.length;
66
+ out += "''";
67
+ }
68
+ else {
69
+ out += sql[i];
70
+ i++;
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ /** Unquote a "quoted" identifier for display. */
76
+ const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
77
+ const IDENT = String.raw `("[^"]+"|[a-zA-Z_][\w$]*)(\.("[^"]+"|[a-zA-Z_][\w$]*))?`;
78
+ /**
79
+ * Scan SQL (one file's worth; may contain many `;`-separated statements) and
80
+ * return every statement that can destroy data.
81
+ */
82
+ function scanDestructiveSql(sql) {
83
+ const found = [];
84
+ const cleaned = stripCommentsAndStrings(sql);
85
+ for (const rawStmt of cleaned.split(';')) {
86
+ const stmt = rawStmt.trim();
87
+ if (!stmt)
88
+ continue;
89
+ const display = stmt.replace(/\s+/g, ' ');
90
+ let m;
91
+ if ((m = stmt.match(new RegExp(String.raw `^DROP\s+TABLE\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i')))) {
92
+ found.push({
93
+ statement: display,
94
+ kind: 'drop-table',
95
+ target: ident(m[4] ? `${ident(m[2])}.${ident(m[4])}` : m[2]),
96
+ });
97
+ }
98
+ else if ((m = stmt.match(new RegExp(String.raw `^DROP\s+SCHEMA\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i')))) {
99
+ found.push({ statement: display, kind: 'drop-schema', target: ident(m[2]) });
100
+ }
101
+ else if ((m = stmt.match(new RegExp(String.raw `^TRUNCATE\s+(TABLE\s+)?(ONLY\s+)?${IDENT}`, 'i')))) {
102
+ found.push({
103
+ statement: display,
104
+ kind: 'truncate',
105
+ target: ident(m[5] ? `${ident(m[3])}.${ident(m[5])}` : m[3]),
106
+ });
107
+ }
108
+ 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')))) {
109
+ found.push({ statement: display, kind: 'drop-column', target: `${ident(m[3])}.${ident(m[7])}` });
110
+ }
111
+ 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')))) {
112
+ found.push({ statement: display, kind: 'alter-column-type', target: `${ident(m[3])}.${ident(m[7])}` });
113
+ }
114
+ else if ((m = stmt.match(new RegExp(String.raw `^DELETE\s+FROM\s+(ONLY\s+)?${IDENT}`, 'i')))) {
115
+ found.push({ statement: display, kind: 'delete', target: ident(m[2]) });
116
+ }
117
+ else if ((m = stmt.match(new RegExp(String.raw `^UPDATE\s+(ONLY\s+)?${IDENT}\b`, 'i'))) &&
118
+ !/\bWHERE\b/i.test(stmt)) {
119
+ found.push({ statement: display, kind: 'update-without-where', target: ident(m[2]) });
120
+ }
121
+ }
122
+ return found;
123
+ }
@@ -13,6 +13,7 @@
13
13
  * turbine migrate status — Show migration status
14
14
  * turbine seed — Run seed file
15
15
  * turbine status — Show schema summary
16
+ * turbine doctor — Check relations for missing FK indexes (--fix emits migration)
16
17
  * turbine studio — Launch local read-only web UI
17
18
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
18
19
  *
@@ -59,6 +60,7 @@ const node_fs_1 = require("node:fs");
59
60
  const node_path_1 = require("node:path");
60
61
  const node_url_1 = require("node:url");
61
62
  const generate_js_1 = require("../generate.js");
63
+ const index_advisor_js_1 = require("../index-advisor.js");
62
64
  const introspect_js_1 = require("../introspect.js");
63
65
  const schema_sql_js_1 = require("../schema-sql.js");
64
66
  const config_js_1 = require("./config.js");
@@ -123,6 +125,12 @@ function parseArgs() {
123
125
  case '--allow-empty':
124
126
  result.allowEmpty = true;
125
127
  break;
128
+ case '--fix':
129
+ result.fix = true;
130
+ break;
131
+ case '--allow-destructive':
132
+ result.allowDestructive = true;
133
+ break;
126
134
  case '--force':
127
135
  case '-f':
128
136
  result.force = true;
@@ -779,11 +787,35 @@ async function cmdMigrateUp(args, config) {
779
787
  console.log(` ${(0, ui_js_1.dim)('Proceed only if you are intentionally rewriting migration history.')}`);
780
788
  (0, ui_js_1.newline)();
781
789
  }
790
+ if (args.allowDestructive) {
791
+ (0, ui_js_1.warn)('--allow-destructive is set — data-destroying statements in migrations WILL run.');
792
+ (0, ui_js_1.newline)();
793
+ }
782
794
  const spinner = new ui_js_1.Spinner('Applying migrations').start();
783
- const result = await (0, migrate_js_1.migrateUp)(url, config.migrationsDir, {
784
- step: args.step,
785
- allowDrift: args.allowDrift,
786
- });
795
+ let result;
796
+ try {
797
+ result = await (0, migrate_js_1.migrateUp)(url, config.migrationsDir, {
798
+ step: args.step,
799
+ allowDrift: args.allowDrift,
800
+ allowDestructive: args.allowDestructive,
801
+ });
802
+ }
803
+ catch (err) {
804
+ if (!isDestructiveRefusal(err))
805
+ throw err;
806
+ spinner.stop();
807
+ if (!(await confirmDestructive(err.message))) {
808
+ (0, ui_js_1.error)('Aborted — no migrations were applied and no data was touched.');
809
+ (0, ui_js_1.newline)();
810
+ process.exit(1);
811
+ }
812
+ spinner.start();
813
+ result = await (0, migrate_js_1.migrateUp)(url, config.migrationsDir, {
814
+ step: args.step,
815
+ allowDrift: args.allowDrift,
816
+ allowDestructive: true,
817
+ });
818
+ }
787
819
  if (result.applied.length === 0 && result.errors.length === 0) {
788
820
  spinner.succeed('All migrations are up to date');
789
821
  (0, ui_js_1.newline)();
@@ -806,6 +838,44 @@ async function cmdMigrateUp(args, config) {
806
838
  }
807
839
  (0, ui_js_1.newline)();
808
840
  }
841
+ /** True when the error is migrate up/down's destructive-statement refusal. */
842
+ function isDestructiveRefusal(err) {
843
+ return err instanceof Error && err.message.includes('DESTRUCTIVE');
844
+ }
845
+ /**
846
+ * Triple confirmation for destructive migrations:
847
+ * 1. show the full itemized report (statement kinds + targets),
848
+ * 2. require typing the literal phrase `destroy my data`,
849
+ * 3. require a final explicit `yes`.
850
+ * Non-interactive shells (CI, pipes) can never pass this — they must use the
851
+ * explicit `--allow-destructive` flag instead. Anything but exact answers aborts.
852
+ */
853
+ async function confirmDestructive(report) {
854
+ (0, ui_js_1.newline)();
855
+ (0, ui_js_1.error)('DESTRUCTIVE MIGRATION DETECTED');
856
+ (0, ui_js_1.newline)();
857
+ for (const line of report.split('\n'))
858
+ console.log(` ${line.includes('[turbine]') ? line.replace('[turbine] ', '') : line}`);
859
+ (0, ui_js_1.newline)();
860
+ if (!process.stdin.isTTY) {
861
+ console.log(` ${(0, ui_js_1.dim)('Non-interactive shell: rerun with')} ${(0, ui_js_1.cyan)('--allow-destructive')} ${(0, ui_js_1.dim)('to proceed.')}`);
862
+ (0, ui_js_1.newline)();
863
+ return false;
864
+ }
865
+ const { createInterface } = await Promise.resolve().then(() => __importStar(require('node:readline/promises')));
866
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
867
+ try {
868
+ console.log(` ${(0, ui_js_1.yellow)('This will permanently destroy data. There is no undo.')}`);
869
+ const phrase = await rl.question(` Type ${(0, ui_js_1.bold)('destroy my data')} to continue, anything else to abort: `);
870
+ if (phrase.trim() !== 'destroy my data')
871
+ return false;
872
+ const finalAnswer = await rl.question(` Final confirmation — apply the destructive statements above? Type ${(0, ui_js_1.bold)('yes')}: `);
873
+ return finalAnswer.trim() === 'yes';
874
+ }
875
+ finally {
876
+ rl.close();
877
+ }
878
+ }
809
879
  async function cmdMigrateDown(args, config) {
810
880
  (0, ui_js_1.banner)();
811
881
  const url = requireUrl(config);
@@ -813,9 +883,28 @@ async function cmdMigrateDown(args, config) {
813
883
  (0, ui_js_1.label)('Migrations', config.migrationsDir);
814
884
  (0, ui_js_1.newline)();
815
885
  const spinner = new ui_js_1.Spinner('Rolling back migration(s)').start();
816
- const result = await (0, migrate_js_1.migrateDown)(url, config.migrationsDir, {
817
- step: args.step ?? 1,
818
- });
886
+ let result;
887
+ try {
888
+ result = await (0, migrate_js_1.migrateDown)(url, config.migrationsDir, {
889
+ step: args.step ?? 1,
890
+ allowDestructive: args.allowDestructive,
891
+ });
892
+ }
893
+ catch (err) {
894
+ if (!isDestructiveRefusal(err))
895
+ throw err;
896
+ spinner.stop();
897
+ if (!(await confirmDestructive(err.message))) {
898
+ (0, ui_js_1.error)('Aborted — nothing was rolled back and no data was touched.');
899
+ (0, ui_js_1.newline)();
900
+ process.exit(1);
901
+ }
902
+ spinner.start();
903
+ result = await (0, migrate_js_1.migrateDown)(url, config.migrationsDir, {
904
+ step: args.step ?? 1,
905
+ allowDestructive: true,
906
+ });
907
+ }
819
908
  if (result.rolledBack.length === 0 && result.errors.length === 0) {
820
909
  spinner.succeed('No migrations to roll back');
821
910
  (0, ui_js_1.newline)();
@@ -1009,6 +1098,80 @@ async function cmdStatus(_args, config) {
1009
1098
  }
1010
1099
  }
1011
1100
  // ---------------------------------------------------------------------------
1101
+ // Command: doctor — relation/index health check
1102
+ // ---------------------------------------------------------------------------
1103
+ async function cmdDoctor(args, config) {
1104
+ (0, ui_js_1.banner)();
1105
+ const url = requireUrl(config);
1106
+ (0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
1107
+ (0, ui_js_1.label)('Schema', config.schema);
1108
+ (0, ui_js_1.newline)();
1109
+ const spinner = new ui_js_1.Spinner('Introspecting database').start();
1110
+ const schema = await (0, introspect_js_1.introspect)({
1111
+ connectionString: url,
1112
+ schema: config.schema,
1113
+ include: config.include.length ? config.include : undefined,
1114
+ exclude: config.exclude.length ? config.exclude : undefined,
1115
+ });
1116
+ const missing = (0, index_advisor_js_1.findMissingRelationIndexes)(schema);
1117
+ if (missing.length === 0) {
1118
+ spinner.succeed('Every relation probe is backed by an index');
1119
+ (0, ui_js_1.newline)();
1120
+ return;
1121
+ }
1122
+ spinner.succeed(`Scanned ${(0, ui_js_1.bold)(String(Object.keys(schema.tables).length))} tables`);
1123
+ (0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(missing.length))} unindexed relation probe(s)`);
1124
+ (0, ui_js_1.newline)();
1125
+ // Row counts put the findings in severity order: a missing index on a 300-row
1126
+ // table is noise; on a 300K-row table it is the whole page load.
1127
+ const rowCounts = new Map();
1128
+ {
1129
+ const { Pool } = (await Promise.resolve().then(() => __importStar(require('pg')))).default;
1130
+ const pool = new Pool({ connectionString: url, max: 1 });
1131
+ try {
1132
+ const tables = [...new Set(missing.map((m) => m.table))];
1133
+ const res = await pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
1134
+ FROM pg_class c
1135
+ JOIN pg_namespace n ON n.oid = c.relnamespace
1136
+ WHERE n.nspname = $1 AND c.relname = ANY($2)`, [config.schema, tables]);
1137
+ for (const row of res.rows)
1138
+ rowCounts.set(row.relname, Math.max(0, Number(row.reltuples)));
1139
+ }
1140
+ finally {
1141
+ await pool.end();
1142
+ }
1143
+ }
1144
+ missing.sort((a, b) => (rowCounts.get(b.table) ?? 0) - (rowCounts.get(a.table) ?? 0));
1145
+ console.log(` ${(0, ui_js_1.dim)('Turbine loads relations as correlated subqueries — the child table is probed')}`);
1146
+ console.log(` ${(0, ui_js_1.dim)('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
1147
+ (0, ui_js_1.newline)();
1148
+ for (const m of missing) {
1149
+ const rows = rowCounts.get(m.table);
1150
+ const rowsLabel = rows !== undefined ? `~${rows.toLocaleString()} rows` : 'row count unknown';
1151
+ console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(m.table))} ${(0, ui_js_1.dim)(`(${m.columns.join(', ')})`)} ${(0, ui_js_1.gray)(rowsLabel)}`);
1152
+ for (const p of m.probes) {
1153
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} probed by ${p.from}.${(0, ui_js_1.blue)(p.relation)} ${(0, ui_js_1.dim)(`(${p.type})`)}`);
1154
+ }
1155
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(m.createSql)}`);
1156
+ (0, ui_js_1.newline)();
1157
+ }
1158
+ if (args.fix) {
1159
+ const up = missing.map((m) => m.createSql).join('\n');
1160
+ const down = missing.map((m) => m.dropSql).join('\n');
1161
+ const file = (0, migrate_js_1.createMigration)(config.migrationsDir, 'add_relation_fk_indexes', { up, down });
1162
+ (0, ui_js_1.success)(`Created migration: ${(0, ui_js_1.bold)(file.filename)}`);
1163
+ (0, ui_js_1.newline)();
1164
+ console.log(` ${(0, ui_js_1.dim)('Review it, then apply with:')} ${(0, ui_js_1.cyan)('npx turbine migrate up')}`);
1165
+ console.log(` ${(0, ui_js_1.dim)('Large, hot tables: consider running the statements manually with')} ${(0, ui_js_1.cyan)('CREATE INDEX CONCURRENTLY')}`);
1166
+ console.log(` ${(0, ui_js_1.dim)('(cannot run inside a transaction, so it is not emitted in the migration).')}`);
1167
+ (0, ui_js_1.newline)();
1168
+ }
1169
+ else {
1170
+ console.log(` ${(0, ui_js_1.dim)('Generate a fix migration with:')} ${(0, ui_js_1.cyan)('npx turbine doctor --fix')}`);
1171
+ (0, ui_js_1.newline)();
1172
+ }
1173
+ }
1174
+ // ---------------------------------------------------------------------------
1012
1175
  // Command: studio — local read-only web UI
1013
1176
  // ---------------------------------------------------------------------------
1014
1177
  async function cmdStudio(args, config) {
@@ -1229,6 +1392,7 @@ function showMigrateHelp() {
1229
1392
  console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
1230
1393
  console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1231
1394
  console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
1395
+ console.log(` ${(0, ui_js_1.cyan)('--allow-destructive')} Run data-destroying migration statements without the interactive confirm`);
1232
1396
  console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1233
1397
  (0, ui_js_1.newline)();
1234
1398
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
@@ -1287,6 +1451,7 @@ function showHelp() {
1287
1451
  console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
1288
1452
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
1289
1453
  console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
1454
+ console.log(` ${(0, ui_js_1.cyan)('doctor')} Check relations for missing FK indexes ${(0, ui_js_1.dim)('(--fix emits migration)')}`);
1290
1455
  console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI`);
1291
1456
  console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
1292
1457
  (0, ui_js_1.newline)();
@@ -1439,6 +1604,9 @@ async function main() {
1439
1604
  case 'info':
1440
1605
  await cmdStatus(args, config);
1441
1606
  break;
1607
+ case 'doctor':
1608
+ await cmdDoctor(args, config);
1609
+ break;
1442
1610
  case 'studio':
1443
1611
  await cmdStudio(args, config);
1444
1612
  break;
@@ -35,6 +35,7 @@ const pg_1 = __importDefault(require("pg"));
35
35
  const index_js_1 = require("../adapters/index.js");
36
36
  const dialect_js_1 = require("../dialect.js");
37
37
  const errors_js_1 = require("../errors.js");
38
+ const destructive_js_1 = require("./destructive.js");
38
39
  // ---------------------------------------------------------------------------
39
40
  // Tracking table management
40
41
  // ---------------------------------------------------------------------------
@@ -368,6 +369,35 @@ async function migrateUp(connectionString, migrationsDir, options) {
368
369
  if (options?.step != null && options.step > 0) {
369
370
  pending = pending.slice(0, options.step);
370
371
  }
372
+ // Data-loss gate: refuse to run pending migrations containing destructive
373
+ // statements unless the caller has EXPLICITLY opted in. The CLI layers an
374
+ // interactive typed confirmation on top of this; programmatic callers must
375
+ // pass `allowDestructive: true`. Safe-by-default is the whole point — a
376
+ // DROP TABLE should never run just because a file exists.
377
+ if (!options?.allowDestructive) {
378
+ const offenders = [];
379
+ for (const file of pending) {
380
+ const { up } = parseMigrationSQL(file.path);
381
+ if (!up)
382
+ continue;
383
+ const hits = (0, destructive_js_1.scanDestructiveSql)(up);
384
+ if (hits.length > 0)
385
+ offenders.push({ file: file.filename, hits });
386
+ }
387
+ if (offenders.length > 0) {
388
+ const lines = ['[turbine] Refusing to apply migrations containing DESTRUCTIVE statements:', ''];
389
+ for (const o of offenders) {
390
+ lines.push(` ${o.file}`);
391
+ for (const h of o.hits) {
392
+ lines.push(` - [${h.kind}] ${h.target} — ${destructive_js_1.DESTRUCTIVE_KIND_LABEL[h.kind]}`);
393
+ }
394
+ }
395
+ lines.push('');
396
+ lines.push('Review the statements above. To proceed: run `npx turbine migrate up` interactively');
397
+ lines.push('and confirm, pass --allow-destructive, or set allowDestructive: true programmatically.');
398
+ throw new errors_js_1.MigrationError(lines.join('\n'));
399
+ }
400
+ }
371
401
  const results = [];
372
402
  const errors = [];
373
403
  for (const file of pending) {
@@ -435,6 +465,36 @@ async function migrateDown(connectionString, migrationsDir, options) {
435
465
  const fileMap = new Map(allFiles.map((f) => [f.name, f]));
436
466
  // Reverse order — rollback most recent first
437
467
  const toRollback = applied.reverse().slice(0, options?.step ?? 1);
468
+ // Same data-loss gate as migrateUp — DOWN sections routinely contain
469
+ // DROP TABLE (the legitimate reverse of a CREATE), which still destroys
470
+ // every row written since the migration ran. Explicit opt-in required.
471
+ if (!options?.allowDestructive) {
472
+ const offenders = [];
473
+ for (const migration of toRollback) {
474
+ const file = fileMap.get(migration.name);
475
+ if (!file)
476
+ continue;
477
+ const { down } = parseMigrationSQL(file.path);
478
+ if (!down)
479
+ continue;
480
+ const hits = (0, destructive_js_1.scanDestructiveSql)(down);
481
+ if (hits.length > 0)
482
+ offenders.push({ file: file.filename, hits });
483
+ }
484
+ if (offenders.length > 0) {
485
+ const lines = ['[turbine] Refusing to roll back migrations whose DOWN sections are DESTRUCTIVE:', ''];
486
+ for (const o of offenders) {
487
+ lines.push(` ${o.file}`);
488
+ for (const h of o.hits) {
489
+ lines.push(` - [${h.kind}] ${h.target} — ${destructive_js_1.DESTRUCTIVE_KIND_LABEL[h.kind]}`);
490
+ }
491
+ }
492
+ lines.push('');
493
+ lines.push('To proceed: run `npx turbine migrate down` interactively and confirm, pass');
494
+ lines.push('--allow-destructive, or set allowDestructive: true programmatically.');
495
+ throw new errors_js_1.MigrationError(lines.join('\n'));
496
+ }
497
+ }
438
498
  const results = [];
439
499
  const errors = [];
440
500
  for (const migration of toRollback) {
@@ -205,6 +205,7 @@ class TurbineClient {
205
205
  /** The schema metadata this client was built from */
206
206
  schema;
207
207
  static int8ParserRegistered = false;
208
+ static utcTimestampParserRegistered = false;
208
209
  logging;
209
210
  /** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
210
211
  dialect;
@@ -252,6 +253,16 @@ class TurbineClient {
252
253
  });
253
254
  TurbineClient.int8ParserRegistered = true;
254
255
  }
256
+ // Parse `timestamp` (OID 1114) as UTC instead of server-local time. The
257
+ // pg driver's default hands back a Date built in the process's local zone,
258
+ // so the same row yields a different instant per deployment region. The
259
+ // ORM convention (Prisma, Rails, Django) — and the only interpretation
260
+ // that round-trips what Postgres stores — is UTC. Same ownership rule as
261
+ // the int8 parser: never mutate parser state on external pools.
262
+ if (!config.pool && config.utcTimestamps !== false && !TurbineClient.utcTimestampParserRegistered) {
263
+ pg_1.default.types.setTypeParser(1114, (val) => new Date(`${val.replace(' ', 'T')}Z`));
264
+ TurbineClient.utcTimestampParserRegistered = true;
265
+ }
255
266
  this.logging = config.logging ?? false;
256
267
  this.dialect = config.dialect ?? dialect_js_1.postgresDialect;
257
268
  this.schema = schema;
@@ -261,6 +272,9 @@ class TurbineClient {
261
272
  this.queryOptions = {
262
273
  defaultLimit: config.defaultLimit,
263
274
  warnOnUnlimited: config.warnOnUnlimited,
275
+ utcTimestamps: config.utcTimestamps,
276
+ relationLoadStrategy: config.relationLoadStrategy,
277
+ jsonEncoding: config.jsonEncoding,
264
278
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
265
279
  sqlCache: config.sqlCache ?? true,
266
280
  dialect: config.dialect,
@@ -68,8 +68,32 @@ exports.postgresDialect = {
68
68
  },
69
69
  buildJsonObject(pairs) {
70
70
  const args = pairs.map(([key, expr]) => `'${this.escapeStringLiteral(key)}', ${expr}`);
71
+ // Postgres caps function calls at 100 arguments (= 50 key/value pairs).
72
+ // Wide tables (or wide select+relation trees) exceed that, so chunk into
73
+ // multiple jsonb_build_object calls merged with `||`, cast back to json.
74
+ if (pairs.length > 50) {
75
+ const chunks = [];
76
+ for (let i = 0; i < args.length; i += 50) {
77
+ chunks.push(`jsonb_build_object(${args.slice(i, i + 50).join(', ')})`);
78
+ }
79
+ return `(${chunks.join(' || ')})::json`;
80
+ }
71
81
  return `json_build_object(${args.join(', ')})`;
72
82
  },
83
+ buildJsonArray(exprs) {
84
+ // Mirror buildJsonObject's chunking at the SAME 50-element threshold: for
85
+ // wide rows, concatenate 50-element jsonb_build_array calls with `||` (which
86
+ // concatenates jsonb arrays) and cast back to json. `jsonb ||` preserves
87
+ // element order, so positions map back to keys unchanged after decode.
88
+ if (exprs.length > 50) {
89
+ const chunks = [];
90
+ for (let i = 0; i < exprs.length; i += 50) {
91
+ chunks.push(`jsonb_build_array(${exprs.slice(i, i + 50).join(', ')})`);
92
+ }
93
+ return `(${chunks.join(' || ')})::json`;
94
+ }
95
+ return `json_build_array(${exprs.join(', ')})`;
96
+ },
73
97
  buildJsonArrayAgg(jsonObjectExpr, orderBy) {
74
98
  const suffix = orderBy ? ` ${orderBy}` : '';
75
99
  return `COALESCE(json_agg(${jsonObjectExpr}${suffix}), ${this.emptyJsonArrayLiteral})`;
Binary file
package/dist/cjs/mssql.js CHANGED
@@ -807,7 +807,9 @@ function buildForJsonSubquery(dialect, ctx) {
807
807
  return buildForJsonManyToMany(dialect, ctx, { colSelect, buildNested, buildPaging, hasLimit });
808
808
  }
809
809
  const isToOne = relDef.type === 'belongsTo' || relDef.type === 'hasOne';
810
- const correlation = isToOne
810
+ // Correlation direction is about WHERE THE FK LIVES, not cardinality:
811
+ // belongsTo has it on the source; hasMany AND hasOne have it on the target.
812
+ const correlation = relDef.type === 'belongsTo'
811
813
  ? dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
812
814
  : dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
813
815
  // ----- to-one (belongsTo / hasOne): single object, no paging --------------