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 +32 -1
- package/dist/cjs/cli/destructive.js +123 -0
- package/dist/cjs/cli/index.js +175 -7
- package/dist/cjs/cli/migrate.js +60 -0
- package/dist/cjs/client.js +14 -0
- package/dist/cjs/dialect.js +24 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/mssql.js +3 -1
- package/dist/cjs/query/batched-loader.js +392 -0
- package/dist/cjs/query/builder.js +548 -60
- package/dist/cjs/query/utils.js +36 -0
- package/dist/cli/destructive.d.ts +32 -0
- package/dist/cli/destructive.js +119 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +175 -7
- package/dist/cli/migrate.d.ts +3 -0
- package/dist/cli/migrate.js +60 -0
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +40 -1
- package/dist/client.js +14 -0
- package/dist/dialect.d.ts +11 -0
- package/dist/dialect.js +24 -0
- package/dist/index-advisor.d.ts +83 -0
- package/dist/index-advisor.js +0 -0
- package/dist/mssql.js +3 -1
- package/dist/query/batched-loader.d.ts +120 -0
- package/dist/query/batched-loader.js +386 -0
- package/dist/query/builder.d.ts +118 -1
- package/dist/query/builder.js +549 -61
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +37 -2
- package/dist/query/utils.d.ts +16 -0
- package/dist/query/utils.js +35 -0
- package/package.json +1 -1
package/dist/cjs/query/utils.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/cli/index.d.ts
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
|
*
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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;
|
package/dist/cli/migrate.d.ts
CHANGED
|
@@ -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<{
|
package/dist/cli/migrate.js
CHANGED
|
@@ -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: "
|
|
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,
|