turbine-orm 0.45.0 → 0.46.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/dist/cjs/cli/index.js +245 -52
- package/dist/cjs/cli/migrate.js +221 -16
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +450 -0
- package/dist/cli/index.d.ts +5 -1
- package/dist/cli/index.js +246 -53
- package/dist/cli/migrate.d.ts +41 -9
- package/dist/cli/migrate.js +220 -16
- package/dist/index-advisor.d.ts +30 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +208 -0
- package/dist/index-stats.js +408 -0
- package/package.json +1 -1
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -25,6 +25,7 @@ exports.formatTimestamp = formatTimestamp;
|
|
|
25
25
|
exports.getPendingMigrations = getPendingMigrations;
|
|
26
26
|
exports.listMigrationFiles = listMigrationFiles;
|
|
27
27
|
exports.parseMigrationContent = parseMigrationContent;
|
|
28
|
+
exports.splitSqlStatements = splitSqlStatements;
|
|
28
29
|
exports.parseMigrationSQL = parseMigrationSQL;
|
|
29
30
|
exports.buildDiffMigrationBody = buildDiffMigrationBody;
|
|
30
31
|
exports.createMigration = createMigration;
|
|
@@ -146,21 +147,37 @@ function listMigrationFiles(migrationsDir) {
|
|
|
146
147
|
return files;
|
|
147
148
|
}
|
|
148
149
|
/**
|
|
149
|
-
*
|
|
150
|
+
* The `-- turbine:no-transaction` directive: when present in a migration's
|
|
151
|
+
* header (before `-- UP`), the runner applies the file WITHOUT wrapping it in
|
|
152
|
+
* BEGIN/COMMIT and runs one statement per `client.query()` call. Required for
|
|
153
|
+
* `CREATE INDEX CONCURRENTLY`, which Postgres forbids inside any transaction,
|
|
154
|
+
* including the implicit transaction a multi-statement simple query creates.
|
|
155
|
+
*/
|
|
156
|
+
const NO_TRANSACTION_DIRECTIVE = /^--\s*turbine:no-transaction\s*$/i;
|
|
157
|
+
/**
|
|
158
|
+
* Parse migration content string into UP and DOWN sections plus directives.
|
|
150
159
|
* Exported for unit testing.
|
|
151
160
|
*/
|
|
152
161
|
function parseMigrationContent(content) {
|
|
153
162
|
const lines = content.split('\n');
|
|
154
163
|
let section = 'none';
|
|
164
|
+
let noTransaction = false;
|
|
155
165
|
const upLines = [];
|
|
156
166
|
const downLines = [];
|
|
157
167
|
for (const line of lines) {
|
|
158
|
-
const trimmed = line.trim()
|
|
159
|
-
|
|
168
|
+
const trimmed = line.trim();
|
|
169
|
+
const upper = trimmed.toUpperCase();
|
|
170
|
+
// The directive is only honored in the header (before -- UP), so it can
|
|
171
|
+
// never be smuggled in via a DOWN-section comment.
|
|
172
|
+
if (section === 'none' && NO_TRANSACTION_DIRECTIVE.test(trimmed)) {
|
|
173
|
+
noTransaction = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (upper === '-- UP') {
|
|
160
177
|
section = 'up';
|
|
161
178
|
continue;
|
|
162
179
|
}
|
|
163
|
-
if (
|
|
180
|
+
if (upper === '-- DOWN') {
|
|
164
181
|
section = 'down';
|
|
165
182
|
continue;
|
|
166
183
|
}
|
|
@@ -172,8 +189,144 @@ function parseMigrationContent(content) {
|
|
|
172
189
|
return {
|
|
173
190
|
up: upLines.join('\n').trim(),
|
|
174
191
|
down: downLines.join('\n').trim(),
|
|
192
|
+
noTransaction,
|
|
175
193
|
};
|
|
176
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Split a SQL script into individual statements on top-level semicolons.
|
|
197
|
+
*
|
|
198
|
+
* A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
|
|
199
|
+
* string, a double-quoted identifier, a dollar-quoted body, a line comment
|
|
200
|
+
* (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
|
|
201
|
+
* split. This is the one production-destroying failure mode of no-transaction
|
|
202
|
+
* migrations (a partial statement executed against production), so the behavior
|
|
203
|
+
* is pinned by exhaustive unit tests.
|
|
204
|
+
*
|
|
205
|
+
* Comment-only fragments are dropped; every returned statement is trimmed and
|
|
206
|
+
* carries no trailing semicolon.
|
|
207
|
+
*/
|
|
208
|
+
function splitSqlStatements(sql) {
|
|
209
|
+
const statements = [];
|
|
210
|
+
let current = '';
|
|
211
|
+
let i = 0;
|
|
212
|
+
const n = sql.length;
|
|
213
|
+
while (i < n) {
|
|
214
|
+
const ch = sql[i];
|
|
215
|
+
const next = sql[i + 1];
|
|
216
|
+
// Line comment: consume to end of line (kept verbatim in the statement).
|
|
217
|
+
if (ch === '-' && next === '-') {
|
|
218
|
+
let j = i;
|
|
219
|
+
while (j < n && sql[j] !== '\n')
|
|
220
|
+
j++;
|
|
221
|
+
current += sql.slice(i, j);
|
|
222
|
+
i = j;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// Block comment (Postgres allows nesting: /* /* */ */).
|
|
226
|
+
if (ch === '/' && next === '*') {
|
|
227
|
+
let depth = 1;
|
|
228
|
+
let j = i + 2;
|
|
229
|
+
current += '/*';
|
|
230
|
+
while (j < n && depth > 0) {
|
|
231
|
+
if (sql[j] === '/' && sql[j + 1] === '*') {
|
|
232
|
+
depth++;
|
|
233
|
+
current += '/*';
|
|
234
|
+
j += 2;
|
|
235
|
+
}
|
|
236
|
+
else if (sql[j] === '*' && sql[j + 1] === '/') {
|
|
237
|
+
depth--;
|
|
238
|
+
current += '*/';
|
|
239
|
+
j += 2;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
current += sql[j];
|
|
243
|
+
j++;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
i = j;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// Single-quoted string ('' is an escaped quote, stays inside the string).
|
|
250
|
+
if (ch === "'") {
|
|
251
|
+
let j = i + 1;
|
|
252
|
+
current += "'";
|
|
253
|
+
while (j < n) {
|
|
254
|
+
if (sql[j] === "'" && sql[j + 1] === "'") {
|
|
255
|
+
current += "''";
|
|
256
|
+
j += 2;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (sql[j] === "'") {
|
|
260
|
+
current += "'";
|
|
261
|
+
j++;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
current += sql[j];
|
|
265
|
+
j++;
|
|
266
|
+
}
|
|
267
|
+
i = j;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
// Double-quoted identifier ("" is an escaped quote).
|
|
271
|
+
if (ch === '"') {
|
|
272
|
+
let j = i + 1;
|
|
273
|
+
current += '"';
|
|
274
|
+
while (j < n) {
|
|
275
|
+
if (sql[j] === '"' && sql[j + 1] === '"') {
|
|
276
|
+
current += '""';
|
|
277
|
+
j += 2;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (sql[j] === '"') {
|
|
281
|
+
current += '"';
|
|
282
|
+
j++;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
current += sql[j];
|
|
286
|
+
j++;
|
|
287
|
+
}
|
|
288
|
+
i = j;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
// Dollar-quoted body ($tag$ ... $tag$; tag is empty or an identifier, never
|
|
292
|
+
// digit-leading, so a `$1` parameter placeholder is not mistaken for one).
|
|
293
|
+
if (ch === '$') {
|
|
294
|
+
const tagMatch = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/.exec(sql.slice(i));
|
|
295
|
+
if (tagMatch) {
|
|
296
|
+
const tag = tagMatch[0];
|
|
297
|
+
const end = sql.indexOf(tag, i + tag.length);
|
|
298
|
+
if (end === -1) {
|
|
299
|
+
current += sql.slice(i);
|
|
300
|
+
i = n;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
current += sql.slice(i, end + tag.length);
|
|
304
|
+
i = end + tag.length;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Top-level statement terminator.
|
|
309
|
+
if (ch === ';') {
|
|
310
|
+
const trimmed = current.trim();
|
|
311
|
+
if (trimmed)
|
|
312
|
+
statements.push(trimmed);
|
|
313
|
+
current = '';
|
|
314
|
+
i++;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
current += ch;
|
|
318
|
+
i++;
|
|
319
|
+
}
|
|
320
|
+
const tail = current.trim();
|
|
321
|
+
if (tail)
|
|
322
|
+
statements.push(tail);
|
|
323
|
+
return statements.filter((s) => !isCommentOnlyStatement(s));
|
|
324
|
+
}
|
|
325
|
+
/** True when a fragment contains nothing but comments and whitespace. */
|
|
326
|
+
function isCommentOnlyStatement(stmt) {
|
|
327
|
+
const withoutComments = stmt.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
|
|
328
|
+
return withoutComments.trim().length === 0;
|
|
329
|
+
}
|
|
177
330
|
/**
|
|
178
331
|
* Parse a migration file into UP and DOWN sections.
|
|
179
332
|
*/
|
|
@@ -332,6 +485,9 @@ function buildDiffMigrationBody(diff) {
|
|
|
332
485
|
* - `autoContent`: pre-populate UP/DOWN from a schema diff.
|
|
333
486
|
* - `options.recipe`: scaffold a named recipe (see {@link MIGRATION_RECIPES}).
|
|
334
487
|
* Mutually exclusive with `autoContent`; an unknown recipe throws.
|
|
488
|
+
* - `options.header`: extra header line(s) injected BEFORE `-- UP` (the only
|
|
489
|
+
* place a `-- turbine:no-transaction` directive is honored). Used with
|
|
490
|
+
* `autoContent`.
|
|
335
491
|
*/
|
|
336
492
|
function createMigration(migrationsDir, name, autoContent, options) {
|
|
337
493
|
(0, node_fs_1.mkdirSync)(migrationsDir, { recursive: true });
|
|
@@ -360,10 +516,11 @@ ${body.down}
|
|
|
360
516
|
`;
|
|
361
517
|
}
|
|
362
518
|
else if (autoContent) {
|
|
363
|
-
|
|
519
|
+
const headerBlock = options?.header ? `${options.header}\n` : '';
|
|
520
|
+
template = `-- Migration: ${name} (auto-generated)
|
|
364
521
|
-- Created: ${now.toISOString()}
|
|
365
522
|
-- Review this file before running: npx turbine migrate up
|
|
366
|
-
|
|
523
|
+
${headerBlock}
|
|
367
524
|
-- UP
|
|
368
525
|
${autoContent.up}
|
|
369
526
|
|
|
@@ -646,25 +803,54 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
646
803
|
const results = [];
|
|
647
804
|
const errors = [];
|
|
648
805
|
const outOfOrder = [];
|
|
806
|
+
const noTransactionApplied = [];
|
|
807
|
+
const flagOutOfOrder = (file) => {
|
|
808
|
+
// Flag an out-of-order apply: this file's timestamp is older than a
|
|
809
|
+
// migration that was already applied before this run started.
|
|
810
|
+
if (newestPrior && file.timestamp && file.timestamp < newestPrior.ts) {
|
|
811
|
+
outOfOrder.push({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` });
|
|
812
|
+
}
|
|
813
|
+
};
|
|
649
814
|
for (const file of pending) {
|
|
650
|
-
const
|
|
815
|
+
const parsed = parseMigrationSQL(file.path);
|
|
816
|
+
const up = parsed.up;
|
|
651
817
|
if (!up) {
|
|
652
818
|
errors.push({ file, error: 'No UP section found in migration file' });
|
|
653
819
|
continue;
|
|
654
820
|
}
|
|
655
821
|
const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
|
|
656
822
|
const hash = checksum(content);
|
|
823
|
+
const insertApplied = dialect.buildMigrationInsertApplied(quotedTrackingTable(dialect));
|
|
824
|
+
if (parsed.noTransaction) {
|
|
825
|
+
// No BEGIN/COMMIT: run ONE statement per query() call (a multi-statement
|
|
826
|
+
// simple query would be wrapped in an implicit transaction, breaking
|
|
827
|
+
// CREATE INDEX CONCURRENTLY). Recording happens only after all statements
|
|
828
|
+
// succeed: a mid-file failure leaves earlier (idempotent) statements
|
|
829
|
+
// applied and the migration unrecorded, so a rerun resumes it.
|
|
830
|
+
options?.onNoTransaction?.(file);
|
|
831
|
+
noTransactionApplied.push(file);
|
|
832
|
+
try {
|
|
833
|
+
for (const stmt of splitSqlStatements(up)) {
|
|
834
|
+
await client.query(stmt);
|
|
835
|
+
}
|
|
836
|
+
await client.query(insertApplied, [file.name, hash]);
|
|
837
|
+
results.push(file);
|
|
838
|
+
flagOutOfOrder(file);
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
842
|
+
errors.push({ file, error: msg });
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
657
847
|
try {
|
|
658
848
|
await client.query('BEGIN');
|
|
659
849
|
await client.query(up);
|
|
660
|
-
await client.query(
|
|
850
|
+
await client.query(insertApplied, [file.name, hash]);
|
|
661
851
|
await client.query('COMMIT');
|
|
662
852
|
results.push(file);
|
|
663
|
-
|
|
664
|
-
// migration that was already applied before this run started.
|
|
665
|
-
if (newestPrior && file.timestamp && file.timestamp < newestPrior.ts) {
|
|
666
|
-
outOfOrder.push({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` });
|
|
667
|
-
}
|
|
853
|
+
flagOutOfOrder(file);
|
|
668
854
|
}
|
|
669
855
|
catch (err) {
|
|
670
856
|
await client.query('ROLLBACK');
|
|
@@ -674,7 +860,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
674
860
|
break;
|
|
675
861
|
}
|
|
676
862
|
}
|
|
677
|
-
return { applied: results, errors, destructive, outOfOrder };
|
|
863
|
+
return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
|
|
678
864
|
}
|
|
679
865
|
finally {
|
|
680
866
|
await releaseLock(client, lockId, adapter);
|
|
@@ -771,15 +957,34 @@ async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
771
957
|
});
|
|
772
958
|
continue;
|
|
773
959
|
}
|
|
774
|
-
const
|
|
960
|
+
const parsed = parseMigrationSQL(file.path);
|
|
961
|
+
const down = parsed.down;
|
|
775
962
|
if (!down) {
|
|
776
963
|
errors.push({ file, error: 'No DOWN section found in migration file' });
|
|
777
964
|
continue;
|
|
778
965
|
}
|
|
966
|
+
const deleteApplied = dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect));
|
|
967
|
+
if (parsed.noTransaction) {
|
|
968
|
+
// Untransacted rollback (DROP INDEX CONCURRENTLY IF EXISTS), one
|
|
969
|
+
// statement per query() call: same contract as the untransacted UP.
|
|
970
|
+
try {
|
|
971
|
+
for (const stmt of splitSqlStatements(down)) {
|
|
972
|
+
await client.query(stmt);
|
|
973
|
+
}
|
|
974
|
+
await client.query(deleteApplied, [migration.name]);
|
|
975
|
+
results.push(file);
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
979
|
+
errors.push({ file, error: msg });
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
779
984
|
try {
|
|
780
985
|
await client.query('BEGIN');
|
|
781
986
|
await client.query(down);
|
|
782
|
-
await client.query(
|
|
987
|
+
await client.query(deleteApplied, [migration.name]);
|
|
783
988
|
await client.query('COMMIT');
|
|
784
989
|
results.push(file);
|
|
785
990
|
}
|
|
Binary file
|