@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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/lib/api/auth/permissions.d.ts +10 -0
  3. package/dist/lib/api/auth/permissions.d.ts.map +1 -1
  4. package/dist/lib/api/auth/permissions.js +7 -0
  5. package/dist/lib/api/rate-limit.d.ts.map +1 -1
  6. package/dist/lib/api/rate-limit.js +3 -0
  7. package/dist/lib/api/request-origin.d.ts +26 -0
  8. package/dist/lib/api/request-origin.d.ts.map +1 -0
  9. package/dist/lib/api/request-origin.js +46 -0
  10. package/dist/lib/auth.d.ts.map +1 -1
  11. package/dist/lib/auth.js +8 -12
  12. package/dist/lib/entities/external-api-generator.d.ts.map +1 -1
  13. package/dist/lib/entities/external-api-generator.js +3 -1
  14. package/dist/lib/services/subscription.service.d.ts.map +1 -1
  15. package/dist/lib/services/subscription.service.js +3 -0
  16. package/dist/lib/utils/cors.d.ts +7 -0
  17. package/dist/lib/utils/cors.d.ts.map +1 -1
  18. package/dist/lib/utils/cors.js +8 -0
  19. package/dist/routes/api/v1/users/[id]/meta/[key]/route.d.ts.map +1 -1
  20. package/dist/routes/api/v1/users/[id]/meta/[key]/route.js +13 -0
  21. package/dist/routes/api/v1/users/[id]/route.d.ts.map +1 -1
  22. package/dist/routes/api/v1/users/[id]/route.js +17 -0
  23. package/dist/templates/projects/blog/package.json +3 -3
  24. package/dist/templates/projects/crm/package.json +3 -3
  25. package/dist/templates/projects/productivity/package.json +3 -3
  26. package/package.json +8 -5
  27. package/scripts/build/auth-readiness.mjs +4 -0
  28. package/scripts/build/registry/__tests__/build-output.test.mjs +1 -1
  29. package/scripts/build/registry/__tests__/project-mode-root-first.test.mjs +6 -6
  30. package/scripts/build/registry/__tests__/write-places.test.mjs +1 -1
  31. package/scripts/build/registry/contracts/__tests__/contracts-wire.test.mjs +1 -1
  32. package/scripts/build/registry/contracts/__tests__/contracts.test.mjs +1 -1
  33. package/scripts/build/registry/contracts/render.mjs +3 -3
  34. package/scripts/build/registry/host/README.md +2 -2
  35. package/scripts/build/registry/host/__tests__/facade-emitter.test.mjs +9 -9
  36. package/scripts/build/registry/host/__tests__/host-mode.test.mjs +1 -1
  37. package/scripts/build/registry/host/__tests__/host-project-composition.test.mjs +1 -1
  38. package/scripts/build/registry/host/__tests__/host-project.test.mjs +2 -2
  39. package/scripts/build/registry/host/__tests__/host-robustness.test.mjs +65 -2
  40. package/scripts/build/registry/host/facade-emitter.mjs +2 -2
  41. package/scripts/build/registry/host/generation.mjs +54 -0
  42. package/scripts/build/registry/host/next-route-exports.json +2 -2
  43. package/scripts/build/registry/host/prepare.mjs +3 -0
  44. package/scripts/db/migration-time-limit.mjs +216 -34
  45. package/scripts/db/run-migrations.mjs +11 -179
  46. package/scripts/db/verify-theme-migrations.mjs +1 -1
  47. package/templates/projects/blog/package.json +3 -3
  48. package/templates/projects/crm/package.json +3 -3
  49. 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. Postgres keeps nothing the migration had not committed, but
39
- // what it committed before it was stopped, with a COMMIT in the file or in a
40
- // procedure it calls, stays; the error says so, and what to do about it.
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
- // A file has to leave its session outside a transaction, with or without a
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, and under a
87
- // limit the record gets RECORD_WAIT_MS instead, whatever the limit: on the
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, however it fails, the error says the file ran to the
94
- // end and is not recorded, and gives the INSERT that records it; the runner
95
- // prints that instead of calling the file failed.
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 rolledBack = await rollBackLeftTransaction(client, await answered);
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
- const setting = await client.query({ text: 'SHOW standard_conforming_strings', rowMode: 'array' });
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
- if (limit) {
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, runMigrationSql, recordMigration, migrationFailure } from './migration-time-limit.mjs';
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
- await runMigrationSql(client, { sql, limit: TIME_LIMIT, connectionString: MIGRATION_URL });
110
-
111
- // Record successful migration
112
- await recordMigration(client, { file, table: '_migrations', row: { filename: file }, limit: TIME_LIMIT });
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 runMigrationSql(client, { sql, limit: TIME_LIMIT, connectionString: MIGRATION_URL });
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 runMigrationSql(client, { sql, limit: TIME_LIMIT, connectionString: MIGRATION_URL });
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.5' } }, null, 2));
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": "^15.0.0 || ^16.0.0",
17
+ "next": "~16.3.6",
18
18
  "next-intl": "^4.0.0",
19
- "react": "^19.0.0",
20
- "react-dom": "^19.0.0",
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": "^15.0.0 || ^16.0.0",
16
+ "next": "~16.3.6",
17
17
  "next-intl": "^4.0.0",
18
- "react": "^19.0.0",
19
- "react-dom": "^19.0.0",
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": "^15.0.0 || ^16.0.0",
21
- "react": "^19.0.0",
22
- "react-dom": "^19.0.0",
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": {