@nextsparkjs/core 0.1.0-beta.193 → 0.1.0-beta.194
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 +2 -2
- package/dist/lib/api/auth/permissions.d.ts +10 -0
- package/dist/lib/api/auth/permissions.d.ts.map +1 -1
- package/dist/lib/api/auth/permissions.js +7 -0
- package/dist/lib/api/rate-limit.d.ts.map +1 -1
- package/dist/lib/api/rate-limit.js +3 -0
- package/dist/lib/api/request-origin.d.ts +26 -0
- package/dist/lib/api/request-origin.d.ts.map +1 -0
- package/dist/lib/api/request-origin.js +46 -0
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/auth.js +8 -12
- package/dist/lib/entities/external-api-generator.d.ts.map +1 -1
- package/dist/lib/entities/external-api-generator.js +3 -1
- package/dist/lib/services/subscription.service.d.ts.map +1 -1
- package/dist/lib/services/subscription.service.js +3 -0
- package/dist/lib/utils/cors.d.ts +7 -0
- package/dist/lib/utils/cors.d.ts.map +1 -1
- package/dist/lib/utils/cors.js +8 -0
- package/dist/routes/api/v1/users/[id]/meta/[key]/route.d.ts.map +1 -1
- package/dist/routes/api/v1/users/[id]/meta/[key]/route.js +13 -0
- package/dist/routes/api/v1/users/[id]/route.d.ts.map +1 -1
- package/dist/routes/api/v1/users/[id]/route.js +17 -0
- package/dist/templates/projects/blog/package.json +3 -3
- package/dist/templates/projects/crm/package.json +3 -3
- package/dist/templates/projects/productivity/package.json +3 -3
- package/package.json +8 -5
- package/scripts/build/auth-readiness.mjs +4 -0
- package/scripts/build/registry/__tests__/build-output.test.mjs +1 -1
- package/scripts/build/registry/__tests__/project-mode-root-first.test.mjs +6 -6
- package/scripts/build/registry/__tests__/write-places.test.mjs +1 -1
- package/scripts/build/registry/contracts/__tests__/contracts-wire.test.mjs +1 -1
- package/scripts/build/registry/contracts/__tests__/contracts.test.mjs +1 -1
- package/scripts/build/registry/contracts/render.mjs +3 -3
- package/scripts/build/registry/host/README.md +2 -2
- package/scripts/build/registry/host/__tests__/facade-emitter.test.mjs +9 -9
- package/scripts/build/registry/host/__tests__/host-mode.test.mjs +1 -1
- package/scripts/build/registry/host/__tests__/host-project-composition.test.mjs +1 -1
- package/scripts/build/registry/host/__tests__/host-project.test.mjs +2 -2
- package/scripts/build/registry/host/__tests__/host-robustness.test.mjs +65 -2
- package/scripts/build/registry/host/facade-emitter.mjs +2 -2
- package/scripts/build/registry/host/generation.mjs +54 -0
- package/scripts/build/registry/host/next-route-exports.json +2 -2
- package/scripts/build/registry/host/prepare.mjs +3 -0
- package/scripts/db/migration-time-limit.mjs +216 -34
- package/scripts/db/run-migrations.mjs +11 -179
- package/scripts/db/verify-theme-migrations.mjs +1 -1
- package/templates/projects/blog/package.json +3 -3
- package/templates/projects/crm/package.json +3 -3
- package/templates/projects/productivity/package.json +3 -3
|
@@ -35,11 +35,15 @@
|
|
|
35
35
|
// then says the limit had not run out.
|
|
36
36
|
//
|
|
37
37
|
// A migration stopped either way is not recorded as run, and the next run starts
|
|
38
|
-
// the file over.
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// the file over. A file runs in one transaction with its record (see
|
|
39
|
+
// runAndRecordMigration), so nothing it changed stays but effects that are not
|
|
40
|
+
// transactional, such as a sequence; the error says so. A file that runs
|
|
41
|
+
// outside that transaction, with NO_TRANSACTION_MARKER, keeps what it committed
|
|
42
|
+
// before it was stopped, with a COMMIT in the file or in a procedure it calls;
|
|
43
|
+
// the error says so, and what to do about it.
|
|
41
44
|
//
|
|
42
|
-
//
|
|
45
|
+
// The rest of this section is about a file that runs outside the runner's
|
|
46
|
+
// transaction. It has to leave its session outside a transaction, with or without a
|
|
43
47
|
// limit. Recording it goes on the same session, so a record sent while the file
|
|
44
48
|
// still has a transaction open would go inside that transaction, and be undone
|
|
45
49
|
// with it when the connection closes. A file that ends inside a transaction it
|
|
@@ -83,16 +87,19 @@
|
|
|
83
87
|
// not reported.
|
|
84
88
|
//
|
|
85
89
|
// The limit is for the file, not for recording it as run. A migration that ran
|
|
86
|
-
// to the end is recorded in its tracking table on the same session,
|
|
87
|
-
//
|
|
90
|
+
// to the end is recorded in its tracking table on the same session, inside the
|
|
91
|
+
// runner's transaction unless the file runs outside it, and under a
|
|
92
|
+
// limit the record, and the COMMIT after it, get RECORD_WAIT_MS instead, whatever the limit: on the
|
|
88
93
|
// server as a SET LOCAL statement_timeout sent with the INSERT, together with
|
|
89
94
|
// lock_timeout off (on Postgres 13 and later both hold for the INSERT whatever
|
|
90
95
|
// the limit or the migration left on the session), and on the client as the
|
|
91
96
|
// wait for the answer. A tracking table another session holds a lock on is
|
|
92
97
|
// waited for up to then, and a server that stops answering is given up on then.
|
|
93
|
-
// When the record fails
|
|
94
|
-
//
|
|
95
|
-
//
|
|
98
|
+
// When the record fails inside the runner's transaction, that transaction is
|
|
99
|
+
// rolled back with the file. When it fails for a file that ran outside it,
|
|
100
|
+
// however it fails, the error says the file ran to the end and is not recorded,
|
|
101
|
+
// and gives the INSERT that records it; the runner prints that instead of
|
|
102
|
+
// calling the file failed.
|
|
96
103
|
//
|
|
97
104
|
// The limit holds whatever the database URL says. A URL that sets
|
|
98
105
|
// statement_timeout or query_timeout itself, `?statement_timeout=0` included,
|
|
@@ -110,6 +117,9 @@ const CONNECT_MS = 10000;
|
|
|
110
117
|
|
|
111
118
|
export const TIME_LIMIT_VARIABLE = 'MIGRATION_TIMEOUT_SECONDS';
|
|
112
119
|
|
|
120
|
+
/** The line a migration file starts with, among the comments before its first statement, to run outside the runner's transaction. */
|
|
121
|
+
export const NO_TRANSACTION_MARKER = '-- nextspark:no-transaction';
|
|
122
|
+
|
|
113
123
|
/** How long recording a migration that ran to the end may take, under a limit. */
|
|
114
124
|
export const RECORD_WAIT_MS = 60000;
|
|
115
125
|
|
|
@@ -152,7 +162,8 @@ export function ignoredParametersNotice(connectionString, limit) {
|
|
|
152
162
|
}
|
|
153
163
|
|
|
154
164
|
/** What a migration stopped partway leaves behind, and what to do before running the migrations again. */
|
|
155
|
-
function leftBehind({ stillRunning, prepared = NONE_PREPARED }) {
|
|
165
|
+
function leftBehind({ stillRunning, prepared = NONE_PREPARED, inTransaction = false }) {
|
|
166
|
+
if (inTransaction) return stillRunning ? `It may still be running there, but ${UNCOMMITTED}` : NOTHING_STAYS;
|
|
156
167
|
const committed = stillRunning
|
|
157
168
|
? 'It may still be running there, and what it commits, with a COMMIT in the file or in a procedure it calls, stays in the database.'
|
|
158
169
|
: `What it had ${mayLeavePrepared(prepared) ? 'neither committed nor left prepared' : 'not committed'} is gone, but what it committed ` +
|
|
@@ -211,8 +222,13 @@ async function rollBackLeftTransaction(client, status) {
|
|
|
211
222
|
* transaction is rolled back, so it is never recorded as run. A file that
|
|
212
223
|
* leaves prepared a transaction it names in PREPARE TRANSACTION fails too,
|
|
213
224
|
* however it ended, naming it and how to resolve it; nothing resolves it here.
|
|
225
|
+
*
|
|
226
|
+
* `inTransaction` when the file runs inside the transaction runAndRecordMigration
|
|
227
|
+
* opened for it: that transaction is rolled back when the file fails, and left
|
|
228
|
+
* open when it does not, and the status the server answered the file with is
|
|
229
|
+
* returned ('T' unless the file ended that transaction itself).
|
|
214
230
|
*/
|
|
215
|
-
export async function runMigrationSql(client, { sql, limit, connectionString }) {
|
|
231
|
+
export async function runMigrationSql(client, { sql, limit, connectionString, inTransaction = false }) {
|
|
216
232
|
// Read before listening for the file's own ReadyForQuery: these queries' are answered, and consumed, first.
|
|
217
233
|
const named = await transactionsTheFileNames(client, sql);
|
|
218
234
|
const preparedNow = async ({ onSession }) =>
|
|
@@ -236,12 +252,13 @@ export async function runMigrationSql(client, { sql, limit, connectionString })
|
|
|
236
252
|
elapsedMs < limit.statementMs
|
|
237
253
|
? `${cancelled}, before ${TIME_LIMIT_VARIABLE} (${limit.seconds} s) ran out`
|
|
238
254
|
: `did not finish within ${limit.seconds} s (${TIME_LIMIT_VARIABLE}); ${cancelled}`;
|
|
239
|
-
throw new Error(`${stopped}: ${error.message}. ${leftBehind({ stillRunning: false, prepared })}`);
|
|
255
|
+
throw new Error(`${stopped}: ${error.message}. ${leftBehind({ stillRunning: false, prepared, inTransaction })}`);
|
|
240
256
|
}
|
|
241
257
|
|
|
242
258
|
if (!limit || error.message !== 'Query read timeout') {
|
|
243
259
|
// Nothing but an answer from the server says the file is no longer running there
|
|
244
260
|
const prepared = await preparedNow({ onSession: answeredByServer });
|
|
261
|
+
if (inTransaction) throw failedInRunnerTransaction(error, rolledBack, { stillRunning: !answeredByServer });
|
|
245
262
|
throw failedInsideTransaction(error, rolledBack, prepared, { stillRunning: !answeredByServer });
|
|
246
263
|
}
|
|
247
264
|
|
|
@@ -255,12 +272,15 @@ export async function runMigrationSql(client, { sql, limit, connectionString })
|
|
|
255
272
|
throw new Error(
|
|
256
273
|
`did not finish within ${limit.seconds} s (${TIME_LIMIT_VARIABLE}); ` +
|
|
257
274
|
(ended === true
|
|
258
|
-
? `its session on the server was ended. ${leftBehind({ stillRunning: false, prepared })}`
|
|
259
|
-
: `its session on the server could not be ended: ${ended}. ${leftBehind({ stillRunning: true, prepared })}`)
|
|
275
|
+
? `its session on the server was ended. ${leftBehind({ stillRunning: false, prepared, inTransaction })}`
|
|
276
|
+
: `its session on the server could not be ended: ${ended}. ${leftBehind({ stillRunning: true, prepared, inTransaction })}`)
|
|
260
277
|
);
|
|
261
278
|
}
|
|
262
279
|
|
|
263
|
-
const
|
|
280
|
+
const status = await answered;
|
|
281
|
+
// The runner's transaction, still open, is what the file is expected to leave
|
|
282
|
+
if (inTransaction) return status;
|
|
283
|
+
const rolledBack = await rollBackLeftTransaction(client, status);
|
|
264
284
|
const prepared = await preparedNow({ onSession: true });
|
|
265
285
|
if (rolledBack) {
|
|
266
286
|
throw new Error(
|
|
@@ -304,6 +324,38 @@ function failedInsideTransaction(error, rolledBack, prepared = NONE_PREPARED, {
|
|
|
304
324
|
return new Error(withSentences(`${error.message}. ${outcome}`, sentences), { cause: error });
|
|
305
325
|
}
|
|
306
326
|
|
|
327
|
+
/** What a ROLLBACK leaves of a file that ran in the runner's transaction, said as a clause. */
|
|
328
|
+
const UNCOMMITTED =
|
|
329
|
+
'it ran in one transaction with its record, which the runner did not commit, so nothing it changed stays in the database, ' +
|
|
330
|
+
'except effects that are not transactional, such as a sequence advanced with nextval or set with setval; ' +
|
|
331
|
+
'it is not recorded as run, and the next run starts the file over.';
|
|
332
|
+
|
|
333
|
+
/** UNCOMMITTED as a sentence of its own. */
|
|
334
|
+
const NOTHING_STAYS = `It${UNCOMMITTED.slice(2)}`;
|
|
335
|
+
|
|
336
|
+
/** What to do with a file Postgres refuses to run inside a transaction block. */
|
|
337
|
+
const OUTSIDE_TRANSACTION_HINT =
|
|
338
|
+
`A statement that cannot run inside a transaction block needs the file to start with the line ${NO_TRANSACTION_MARKER}, ` +
|
|
339
|
+
'and the file then has to be safe to run again: such a file is recorded only after it has run.';
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The error a file that failed inside the runner's transaction fails with: the
|
|
343
|
+
* server's own, with what the rollback leaves. `rolledBack` says how that
|
|
344
|
+
* transaction ended when the server answered the file; when it did not, the
|
|
345
|
+
* connection is ended and the transaction with it.
|
|
346
|
+
*/
|
|
347
|
+
function failedInRunnerTransaction(error, rolledBack, { stillRunning }) {
|
|
348
|
+
const outcome = stillRunning
|
|
349
|
+
? `It may still be running there, but ${UNCOMMITTED}`
|
|
350
|
+
: rolledBack && rolledBack !== 'that transaction was rolled back'
|
|
351
|
+
? `It ran in one transaction with its record, and ${rolledBack}: nothing it changed stays, except effects that are not ` +
|
|
352
|
+
'transactional, such as a sequence advanced with nextval or set with setval; it is not recorded as run, and the next run starts the file over.'
|
|
353
|
+
: NOTHING_STAYS;
|
|
354
|
+
// 25001: a statement Postgres refuses in a transaction block; 2D000: a DO block or procedure that commits
|
|
355
|
+
const hint = error.code === '25001' || error.code === '2D000' ? ` ${OUTSIDE_TRANSACTION_HINT}` : '';
|
|
356
|
+
return new Error(`${error.message}. ${outcome}${hint}`, { cause: error });
|
|
357
|
+
}
|
|
358
|
+
|
|
307
359
|
/** How long a connection of the run's own, beside the migration's, waits to connect and for each answer. */
|
|
308
360
|
function sideConnectionWaitMs(limit) {
|
|
309
361
|
return limit ? Math.min(5000, limit.statementMs) : 5000;
|
|
@@ -323,8 +375,7 @@ async function transactionsTheFileNames(client, sql) {
|
|
|
323
375
|
if (!/prepare/i.test(sql)) return [];
|
|
324
376
|
let constants = [];
|
|
325
377
|
try {
|
|
326
|
-
|
|
327
|
-
constants = preparedTransactionConstants(sql, { standardConformingStrings: setting.rows[0]?.[0] !== 'off' });
|
|
378
|
+
constants = preparedTransactionConstants(sql, { standardConformingStrings: await standardConformingStrings(client) });
|
|
328
379
|
if (constants.length === 0) return [];
|
|
329
380
|
const rows = await preparedAmong(client, constants);
|
|
330
381
|
const named = new Map();
|
|
@@ -492,12 +543,7 @@ const DOLLAR_QUOTE = /\$(?:[A-Za-z_-
|
|
|
492
543
|
* the server refuses the query it is in.
|
|
493
544
|
*/
|
|
494
545
|
export function preparedTransactionConstants(sql, { standardConformingStrings = true } = {}) {
|
|
495
|
-
const tokens =
|
|
496
|
-
for (let start = afterSpace(sql, 0); start < sql.length; ) {
|
|
497
|
-
const [kind, end] = tokenAt(sql, start, standardConformingStrings);
|
|
498
|
-
tokens.push({ kind, start, end, word: kind === 'word' ? sql.slice(start, end).replace(/[A-Z]/g, letter => letter.toLowerCase()) : null });
|
|
499
|
-
start = afterSpace(sql, end);
|
|
500
|
-
}
|
|
546
|
+
const tokens = tokensOf(sql, standardConformingStrings);
|
|
501
547
|
return tokens.flatMap((token, index) => {
|
|
502
548
|
const startsStatement = index === 0 || tokens[index - 1].kind === ';';
|
|
503
549
|
const constant = tokens[index + 2];
|
|
@@ -507,6 +553,56 @@ export function preparedTransactionConstants(sql, { standardConformingStrings =
|
|
|
507
553
|
});
|
|
508
554
|
}
|
|
509
555
|
|
|
556
|
+
/** The tokens of a query string, read as preparedTransactionConstants describes, with each word in lower case. */
|
|
557
|
+
function tokensOf(sql, standardConformingStrings) {
|
|
558
|
+
const tokens = [];
|
|
559
|
+
for (let start = afterSpace(sql, 0); start < sql.length; ) {
|
|
560
|
+
const [kind, end] = tokenAt(sql, start, standardConformingStrings);
|
|
561
|
+
tokens.push({ kind, start, end, word: kind === 'word' ? sql.slice(start, end).replace(/[A-Z]/g, letter => letter.toLowerCase()) : null });
|
|
562
|
+
start = afterSpace(sql, end);
|
|
563
|
+
}
|
|
564
|
+
return tokens;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* The first statement at the top level of a query string that opens, ends or
|
|
569
|
+
* prepares a transaction, as its leading keywords in capitals (BEGIN, START
|
|
570
|
+
* TRANSACTION, COMMIT, END, ROLLBACK, ABORT, PREPARE TRANSACTION), or null when
|
|
571
|
+
* there is none. ROLLBACK TO a savepoint is not one, and neither is the END
|
|
572
|
+
* that closes a BEGIN ATOMIC function body. The string is read as
|
|
573
|
+
* preparedTransactionConstants reads it. Postgres runs these only as statements
|
|
574
|
+
* of the query itself: in a DO block, a function or a procedure called inside a
|
|
575
|
+
* transaction block it refuses a COMMIT or ROLLBACK.
|
|
576
|
+
*/
|
|
577
|
+
export function transactionControlStatement(sql, { standardConformingStrings = true } = {}) {
|
|
578
|
+
const tokens = tokensOf(sql, standardConformingStrings);
|
|
579
|
+
let atomicBodies = 0;
|
|
580
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
581
|
+
const { word } = tokens[index];
|
|
582
|
+
const next = tokens[index + 1]?.word;
|
|
583
|
+
if (index > 0 && tokens[index - 1].kind !== ';') {
|
|
584
|
+
if (word === 'begin' && next === 'atomic') atomicBodies++;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (atomicBodies > 0) {
|
|
588
|
+
if (word === 'end') atomicBodies--;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (word === 'prepare' && next === 'transaction') return 'PREPARE TRANSACTION';
|
|
592
|
+
if (word === 'start' && next === 'transaction') return 'START TRANSACTION';
|
|
593
|
+
if (word === 'rollback') {
|
|
594
|
+
const after = next === 'work' || next === 'transaction' ? tokens[index + 2]?.word : next;
|
|
595
|
+
if (after !== 'to') return 'ROLLBACK';
|
|
596
|
+
} else if (['begin', 'commit', 'end', 'abort'].includes(word)) return word.toUpperCase();
|
|
597
|
+
}
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Whether the comments a migration file starts with include NO_TRANSACTION_MARKER as a line of its own. */
|
|
602
|
+
export function runsOutsideTransaction(sql) {
|
|
603
|
+
return /^[ \t]*--[ \t]*nextspark:no-transaction[ \t]*$/m.test(sql.slice(0, afterSpace(sql, 0)));
|
|
604
|
+
}
|
|
605
|
+
|
|
510
606
|
/** Where the whitespace and comments from `i` on end. */
|
|
511
607
|
function afterSpace(sql, i) {
|
|
512
608
|
for (;;) {
|
|
@@ -639,18 +735,9 @@ export class MigrationNotRecordedError extends Error {}
|
|
|
639
735
|
* written.
|
|
640
736
|
*/
|
|
641
737
|
export async function recordMigration(client, { file, table, row, limit, waitMs = RECORD_WAIT_MS }) {
|
|
642
|
-
const insert =
|
|
643
|
-
`INSERT INTO ${escapeIdentifier(table)} (${Object.keys(row).map(escapeIdentifier).join(', ')}) ` +
|
|
644
|
-
`VALUES (${Object.values(row).map(escapeLiteral).join(', ')})`;
|
|
738
|
+
const insert = recordInsert(table, row);
|
|
645
739
|
try {
|
|
646
|
-
|
|
647
|
-
await client.query({
|
|
648
|
-
text: `SET LOCAL statement_timeout = ${waitMs}; SET LOCAL lock_timeout = 0; ${insert}`,
|
|
649
|
-
query_timeout: waitMs,
|
|
650
|
-
});
|
|
651
|
-
} else {
|
|
652
|
-
await client.query(insert);
|
|
653
|
-
}
|
|
740
|
+
await sendRecord(client, insert, { limit, waitMs });
|
|
654
741
|
} catch (error) {
|
|
655
742
|
const recording = `recording it as run in ${escapeIdentifier(table)}`;
|
|
656
743
|
const outcome =
|
|
@@ -665,6 +752,101 @@ export async function recordMigration(client, { file, table, row, limit, waitMs
|
|
|
665
752
|
}
|
|
666
753
|
}
|
|
667
754
|
|
|
755
|
+
/** The INSERT that records a migration as the row given (column → value) of its tracking table. */
|
|
756
|
+
function recordInsert(table, row) {
|
|
757
|
+
return (
|
|
758
|
+
`INSERT INTO ${escapeIdentifier(table)} (${Object.keys(row).map(escapeIdentifier).join(', ')}) ` +
|
|
759
|
+
`VALUES (${Object.values(row).map(escapeLiteral).join(', ')})`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Sends the INSERT that records a migration, under a limit with `waitMs` on the
|
|
765
|
+
* server, as SET LOCAL statement_timeout with lock_timeout off, and on the client.
|
|
766
|
+
* Inside a transaction the SET LOCAL holds until it ends, COMMIT included.
|
|
767
|
+
*/
|
|
768
|
+
async function sendRecord(client, insert, { limit, waitMs }) {
|
|
769
|
+
if (limit) {
|
|
770
|
+
await client.query({
|
|
771
|
+
text: `SET LOCAL statement_timeout = ${waitMs}; SET LOCAL lock_timeout = 0; ${insert}`,
|
|
772
|
+
query_timeout: waitMs,
|
|
773
|
+
});
|
|
774
|
+
} else {
|
|
775
|
+
await client.query(insert);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Runs a migration file and records it as run in the row given of its tracking
|
|
781
|
+
* table, both in one transaction: BEGIN, the file, the INSERT, COMMIT, on the
|
|
782
|
+
* migration's session. A run stopped anywhere before the COMMIT, by an error,
|
|
783
|
+
* the limit, a kill of the process or a crash of the server, leaves neither the
|
|
784
|
+
* file's changes nor its record, and the next run starts the file over; once the
|
|
785
|
+
* COMMIT is through, both are there and the next run skips it. Effects that are
|
|
786
|
+
* not transactional, such as a sequence advanced with nextval, are the exception.
|
|
787
|
+
*
|
|
788
|
+
* A file that opens, ends or prepares a transaction itself, with a statement at
|
|
789
|
+
* its top level (see transactionControlStatement), cannot run inside that
|
|
790
|
+
* transaction, and is not run: it fails saying so. A file that starts with the
|
|
791
|
+
* line NO_TRANSACTION_MARKER runs as it is instead, outside any transaction of
|
|
792
|
+
* the runner's, and is recorded once it has run (runMigrationSql, then
|
|
793
|
+
* recordMigration): a run stopped between the two starts it over, so such a file
|
|
794
|
+
* has to be safe to run again. That is also how a statement Postgres refuses
|
|
795
|
+
* inside a transaction block (CREATE INDEX CONCURRENTLY, VACUUM) runs.
|
|
796
|
+
*
|
|
797
|
+
* The record and the COMMIT get RECORD_WAIT_MS under a limit, as recordMigration's
|
|
798
|
+
* record does. A COMMIT that gets no answer may or may not have gone through,
|
|
799
|
+
* and the error says so: the file and its record went through together or not
|
|
800
|
+
* at all.
|
|
801
|
+
*/
|
|
802
|
+
export async function runAndRecordMigration(client, { file, sql, table, row, limit, connectionString, waitMs = RECORD_WAIT_MS }) {
|
|
803
|
+
if (runsOutsideTransaction(sql)) {
|
|
804
|
+
await runMigrationSql(client, { sql, limit, connectionString });
|
|
805
|
+
return recordMigration(client, { file, table, row, limit, waitMs });
|
|
806
|
+
}
|
|
807
|
+
const control = /\b(?:begin|start|commit|end|rollback|abort|prepare)\b/i.test(sql)
|
|
808
|
+
? transactionControlStatement(sql, { standardConformingStrings: await standardConformingStrings(client) })
|
|
809
|
+
: null;
|
|
810
|
+
if (control) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`the file was not run: it has a ${control} statement, so it manages transactions itself, and the runner runs each file ` +
|
|
813
|
+
'in one transaction with its record. Take its transaction statements out, or, when it has to manage its own transactions ' +
|
|
814
|
+
`or run outside one, start it with the line ${NO_TRANSACTION_MARKER}; such a file is recorded only after it has run, ` +
|
|
815
|
+
'so it has to be safe to run again. It is not recorded as run.'
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
await client.query('BEGIN');
|
|
820
|
+
const status = await runMigrationSql(client, { sql, limit, connectionString, inTransaction: true });
|
|
821
|
+
// The file ended the runner's transaction after all: what it did is committed, so it is recorded as recordMigration records
|
|
822
|
+
if (status !== 'T') return recordMigration(client, { file, table, row, limit, waitMs });
|
|
823
|
+
|
|
824
|
+
let step = `recording it as run in ${escapeIdentifier(table)}`;
|
|
825
|
+
try {
|
|
826
|
+
await sendRecord(client, recordInsert(table, row), { limit, waitMs });
|
|
827
|
+
step = 'committing it together with its record';
|
|
828
|
+
await client.query(limit ? { text: 'COMMIT', query_timeout: waitMs } : 'COMMIT');
|
|
829
|
+
} catch (error) {
|
|
830
|
+
const answered = error instanceof DatabaseError;
|
|
831
|
+
// A failed record leaves the transaction failed, and a failed COMMIT has rolled it back; one with no answer ends with the connection
|
|
832
|
+
if (answered) await client.query('ROLLBACK').catch(() => {});
|
|
833
|
+
else await client.end().catch(() => {});
|
|
834
|
+
const failed = error.message === 'Query read timeout' ? `got no answer within ${waitMs / 1000} s` : `failed: ${error.message}`;
|
|
835
|
+
const outcome =
|
|
836
|
+
!answered && step.startsWith('committing')
|
|
837
|
+
? 'Whether the COMMIT went through is not known, but the file and its record went through together or not at all: ' +
|
|
838
|
+
'the next run skips the file if it did, and starts it over if it did not.'
|
|
839
|
+
: NOTHING_STAYS;
|
|
840
|
+
throw new Error(`the file ran to the end, but ${step} ${failed}. ${outcome}`, { cause: error });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Whether the session reads quoted strings under standard_conforming_strings, as it reads a migration file. */
|
|
845
|
+
async function standardConformingStrings(client) {
|
|
846
|
+
const setting = await client.query({ text: 'SHOW standard_conforming_strings', rowMode: 'array' });
|
|
847
|
+
return setting.rows[0]?.[0] !== 'off';
|
|
848
|
+
}
|
|
849
|
+
|
|
668
850
|
/** The line the runner prints for a migration that failed. */
|
|
669
851
|
export function migrationFailure(file, error) {
|
|
670
852
|
return error instanceof MigrationNotRecordedError ? error.message : `Failed to execute ${file}: ${error.message}`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { migrationTimeLimit, migrationClient, ignoredParametersNotice,
|
|
3
|
+
import { migrationTimeLimit, migrationClient, ignoredParametersNotice, runAndRecordMigration, migrationFailure } from './migration-time-limit.mjs';
|
|
4
4
|
import { getConfig } from '../build/registry/config.mjs';
|
|
5
5
|
|
|
6
6
|
const projectConfig = getConfig();
|
|
@@ -106,10 +106,10 @@ async function runMigrations() {
|
|
|
106
106
|
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
|
107
107
|
|
|
108
108
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
// Runs the file and records it in one transaction (see runAndRecordMigration)
|
|
110
|
+
await runAndRecordMigration(client, {
|
|
111
|
+
file, sql, table: '_migrations', row: { filename: file }, limit: TIME_LIMIT, connectionString: MIGRATION_URL,
|
|
112
|
+
});
|
|
113
113
|
|
|
114
114
|
console.log(`✅ Successfully executed ${file}\n`);
|
|
115
115
|
} catch (error) {
|
|
@@ -141,54 +141,6 @@ async function getActivePlugins() {
|
|
|
141
141
|
return projectConfig.plugins;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// Run content-level migrations (theme/plugin root-level, not entity-specific)
|
|
145
|
-
async function runContentMigrations(client, migrationsPath, sourceType, sourceName) {
|
|
146
|
-
const files = fs.readdirSync(migrationsPath)
|
|
147
|
-
.filter(f => f.endsWith('.sql'))
|
|
148
|
-
.sort();
|
|
149
|
-
|
|
150
|
-
if (files.length === 0) {
|
|
151
|
-
return 0;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let executedCount = 0;
|
|
155
|
-
|
|
156
|
-
for (const file of files) {
|
|
157
|
-
// Check if migration has already been executed
|
|
158
|
-
const result = await client.query(
|
|
159
|
-
'SELECT * FROM "_content_migrations" WHERE source_type = $1 AND source_name = $2 AND filename = $3',
|
|
160
|
-
[sourceType, sourceName, file]
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
if (result.rows.length > 0) {
|
|
164
|
-
console.log(` ⏭️ ${file} (already executed)`);
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Read and execute migration
|
|
169
|
-
console.log(` 🔄 ${file}...`);
|
|
170
|
-
const sql = fs.readFileSync(path.join(migrationsPath, file), 'utf8');
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
await client.query(sql);
|
|
174
|
-
|
|
175
|
-
// Record successful migration with source tracking
|
|
176
|
-
await client.query(
|
|
177
|
-
'INSERT INTO "_content_migrations" (source_type, source_name, filename) VALUES ($1, $2, $3)',
|
|
178
|
-
[sourceType, sourceName, file]
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
console.log(` ✅ ${file} executed successfully`);
|
|
182
|
-
executedCount++;
|
|
183
|
-
} catch (error) {
|
|
184
|
-
console.error(` ❌ Failed to execute ${file}:`, error.message);
|
|
185
|
-
throw error;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return executedCount;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
144
|
// Helper: Check if a filename is a sample_data migration
|
|
193
145
|
function isSampleDataMigration(filename) {
|
|
194
146
|
return filename.toLowerCase().includes('sample_data') || filename.toLowerCase().includes('sample-data');
|
|
@@ -280,12 +232,13 @@ async function executeContentMigration(client, migration) {
|
|
|
280
232
|
console.log(` 🔄 ${filename}...`);
|
|
281
233
|
const sql = fs.readFileSync(fullPath, 'utf8');
|
|
282
234
|
|
|
283
|
-
await
|
|
284
|
-
await recordMigration(client, {
|
|
235
|
+
await runAndRecordMigration(client, {
|
|
285
236
|
file: filename,
|
|
237
|
+
sql,
|
|
286
238
|
table: '_content_migrations',
|
|
287
239
|
row: { source_type: sourceType, source_name: sourceName, filename },
|
|
288
240
|
limit: TIME_LIMIT,
|
|
241
|
+
connectionString: MIGRATION_URL,
|
|
289
242
|
});
|
|
290
243
|
|
|
291
244
|
console.log(` ✅ ${filename} executed successfully`);
|
|
@@ -311,12 +264,13 @@ async function executeEntityMigration(client, migration) {
|
|
|
311
264
|
console.log(`${indent}🔄 ${filename}...`);
|
|
312
265
|
const sql = fs.readFileSync(fullPath, 'utf8');
|
|
313
266
|
|
|
314
|
-
await
|
|
315
|
-
await recordMigration(client, {
|
|
267
|
+
await runAndRecordMigration(client, {
|
|
316
268
|
file: filename,
|
|
269
|
+
sql,
|
|
317
270
|
table: '_entity_migrations',
|
|
318
271
|
row: { entity_name: entityName, source_type: sourceType, source_name: sourceName, filename },
|
|
319
272
|
limit: TIME_LIMIT,
|
|
273
|
+
connectionString: MIGRATION_URL,
|
|
320
274
|
});
|
|
321
275
|
|
|
322
276
|
console.log(`${indent}✅ ${filename} executed successfully`);
|
|
@@ -593,128 +547,6 @@ async function runEntityMigrations() {
|
|
|
593
547
|
}
|
|
594
548
|
}
|
|
595
549
|
|
|
596
|
-
// Recursively discover and run migrations from entity directories
|
|
597
|
-
async function discoverAndRunMigrations(client, baseDir, sourceType, sourceName, depth = 0) {
|
|
598
|
-
let stats = { entities: 0, migrations: 0 };
|
|
599
|
-
|
|
600
|
-
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
601
|
-
|
|
602
|
-
for (const entry of entries) {
|
|
603
|
-
if (!entry.isDirectory()) continue;
|
|
604
|
-
|
|
605
|
-
const entityPath = path.join(baseDir, entry.name);
|
|
606
|
-
const entityStats = await processEntityDirectory(client, entityPath, entry.name, sourceType, sourceName, depth);
|
|
607
|
-
|
|
608
|
-
stats.entities += entityStats.entities;
|
|
609
|
-
stats.migrations += entityStats.migrations;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
return stats;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Process a single entity directory and its potential children
|
|
616
|
-
async function processEntityDirectory(client, entityPath, entityName, sourceType, sourceName, depth) {
|
|
617
|
-
let stats = { entities: 0, migrations: 0 };
|
|
618
|
-
const indent = ' '.repeat(depth + 1);
|
|
619
|
-
|
|
620
|
-
// Check if this directory has migrations
|
|
621
|
-
const migrationsPath = path.join(entityPath, 'migrations');
|
|
622
|
-
if (fs.existsSync(migrationsPath)) {
|
|
623
|
-
const migrationFiles = fs.readdirSync(migrationsPath)
|
|
624
|
-
.filter(f => f.endsWith('.sql'));
|
|
625
|
-
|
|
626
|
-
if (migrationFiles.length > 0) {
|
|
627
|
-
console.log(`${indent}📁 ${entityName} (${migrationFiles.length} migration(s))`);
|
|
628
|
-
const executed = await runMigrationsFromDir(client, migrationsPath, entityName, sourceType, sourceName, depth + 1);
|
|
629
|
-
stats.entities++;
|
|
630
|
-
stats.migrations += executed;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Intelligently detect and process children entities
|
|
635
|
-
const childrenStats = await discoverChildrenEntities(client, entityPath, entityName, sourceType, sourceName, depth);
|
|
636
|
-
stats.entities += childrenStats.entities;
|
|
637
|
-
stats.migrations += childrenStats.migrations;
|
|
638
|
-
|
|
639
|
-
return stats;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Intelligent children entity detection
|
|
643
|
-
async function discoverChildrenEntities(client, entityPath, parentEntityName, sourceType, sourceName, depth) {
|
|
644
|
-
let stats = { entities: 0, migrations: 0 };
|
|
645
|
-
const indent = ' '.repeat(depth + 1);
|
|
646
|
-
|
|
647
|
-
// Check for both 'children' and 'childs' for backward compatibility
|
|
648
|
-
const possibleChildrenDirs = ['children', 'childs'];
|
|
649
|
-
|
|
650
|
-
for (const childDirName of possibleChildrenDirs) {
|
|
651
|
-
const childrenDir = path.join(entityPath, childDirName);
|
|
652
|
-
|
|
653
|
-
if (fs.existsSync(childrenDir)) {
|
|
654
|
-
console.log(`${indent}🔍 Found ${childDirName} entities in ${parentEntityName}`);
|
|
655
|
-
|
|
656
|
-
// Recursively process children entities
|
|
657
|
-
const childStats = await discoverAndRunMigrations(client, childrenDir, sourceType, sourceName, depth + 1);
|
|
658
|
-
stats.entities += childStats.entities;
|
|
659
|
-
stats.migrations += childStats.migrations;
|
|
660
|
-
|
|
661
|
-
// Only process the first found children directory to avoid duplicates
|
|
662
|
-
break;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
return stats;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Helper function to run migrations from a specific directory
|
|
670
|
-
async function runMigrationsFromDir(client, migrationsPath, entityName, sourceType, sourceName, depth = 0) {
|
|
671
|
-
const files = fs.readdirSync(migrationsPath)
|
|
672
|
-
.filter(f => f.endsWith('.sql'))
|
|
673
|
-
.sort();
|
|
674
|
-
|
|
675
|
-
if (files.length === 0) {
|
|
676
|
-
return 0;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
let executedCount = 0;
|
|
680
|
-
const indent = ' '.repeat(depth + 1);
|
|
681
|
-
|
|
682
|
-
for (const file of files) {
|
|
683
|
-
// Check if migration has already been executed for this entity
|
|
684
|
-
const result = await client.query(
|
|
685
|
-
'SELECT * FROM "_entity_migrations" WHERE entity_name = $1 AND filename = $2',
|
|
686
|
-
[entityName, file]
|
|
687
|
-
);
|
|
688
|
-
|
|
689
|
-
if (result.rows.length > 0) {
|
|
690
|
-
console.log(`${indent}⏭️ ${file} (already executed)`);
|
|
691
|
-
continue;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// Read and execute migration
|
|
695
|
-
console.log(`${indent}🔄 ${file}...`);
|
|
696
|
-
const sql = fs.readFileSync(path.join(migrationsPath, file), 'utf8');
|
|
697
|
-
|
|
698
|
-
try {
|
|
699
|
-
await client.query(sql);
|
|
700
|
-
|
|
701
|
-
// Record successful migration with source tracking
|
|
702
|
-
await client.query(
|
|
703
|
-
'INSERT INTO "_entity_migrations" (entity_name, source_type, source_name, filename) VALUES ($1, $2, $3, $4)',
|
|
704
|
-
[entityName, sourceType, sourceName, file]
|
|
705
|
-
);
|
|
706
|
-
|
|
707
|
-
console.log(`${indent}✅ ${file} executed successfully`);
|
|
708
|
-
executedCount++;
|
|
709
|
-
} catch (error) {
|
|
710
|
-
console.error(`${indent}❌ Failed to execute ${file}:`, error.message);
|
|
711
|
-
throw error;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
return executedCount;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
550
|
// Main migration runner
|
|
719
551
|
async function runAllMigrations() {
|
|
720
552
|
console.log("🚀 Starting migration process...\n");
|
|
@@ -153,7 +153,7 @@ async function main() {
|
|
|
153
153
|
extracted = fs.mkdtempSync(path.join(os.tmpdir(), `nextspark-template-${theme}-`));
|
|
154
154
|
fs.cpSync(located.sourceDir, extracted, { recursive: true });
|
|
155
155
|
fs.writeFileSync(path.join(extracted, 'nextspark.config.ts'), `export default { plugins: [], template: { name: ${JSON.stringify(theme)}, version: 'test' } }\n`);
|
|
156
|
-
fs.writeFileSync(path.join(extracted, 'package.json'), JSON.stringify({ name: `nextspark-template-${theme}`, private: true, dependencies: { next: '16.3.
|
|
156
|
+
fs.writeFileSync(path.join(extracted, 'package.json'), JSON.stringify({ name: `nextspark-template-${theme}`, private: true, dependencies: { next: '16.3.6' } }, null, 2));
|
|
157
157
|
runDir = extracted;
|
|
158
158
|
}
|
|
159
159
|
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
"@nextsparkjs/core": ">=0.1.0-0",
|
|
15
15
|
"@nextsparkjs/testing": ">=0.1.0-0",
|
|
16
16
|
"lucide-react": "^0.539.0",
|
|
17
|
-
"next": "
|
|
17
|
+
"next": "~16.3.6",
|
|
18
18
|
"next-intl": "^4.0.0",
|
|
19
|
-
"react": "^19.
|
|
20
|
-
"react-dom": "^19.
|
|
19
|
+
"react": "^19.2.0",
|
|
20
|
+
"react-dom": "^19.2.0",
|
|
21
21
|
"zod": "^4.0.0"
|
|
22
22
|
},
|
|
23
23
|
"nextspark": {
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
"@nextsparkjs/core": ">=0.1.0-0",
|
|
14
14
|
"@nextsparkjs/testing": ">=0.1.0-0",
|
|
15
15
|
"lucide-react": "^0.539.0",
|
|
16
|
-
"next": "
|
|
16
|
+
"next": "~16.3.6",
|
|
17
17
|
"next-intl": "^4.0.0",
|
|
18
|
-
"react": "^19.
|
|
19
|
-
"react-dom": "^19.
|
|
18
|
+
"react": "^19.2.0",
|
|
19
|
+
"react-dom": "^19.2.0",
|
|
20
20
|
"zod": "^4.0.0"
|
|
21
21
|
},
|
|
22
22
|
"nextspark": {
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"@nextsparkjs/core": ">=0.1.0-0",
|
|
18
18
|
"@nextsparkjs/testing": ">=0.1.0-0",
|
|
19
19
|
"lucide-react": "^0.539.0",
|
|
20
|
-
"next": "
|
|
21
|
-
"react": "^19.
|
|
22
|
-
"react-dom": "^19.
|
|
20
|
+
"next": "~16.3.6",
|
|
21
|
+
"react": "^19.2.0",
|
|
22
|
+
"react-dom": "^19.2.0",
|
|
23
23
|
"zod": "^4.0.0"
|
|
24
24
|
},
|
|
25
25
|
"nextspark": {
|