turbine-orm 0.77.1 → 0.78.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.
Files changed (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
@@ -16,8 +16,9 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
16
16
  import { join } from 'node:path';
17
17
  import pg from 'pg';
18
18
  import { postgresql } from '../adapters/index.js';
19
+ import { isPlainSchemaIdentifier, parseSearchPathValue, withSearchPathOption } from '../connection-url.js';
19
20
  import { postgresDialect } from '../dialect.js';
20
- import { MigrationError } from '../errors.js';
21
+ import { MigrationError, ValidationError } from '../errors.js';
21
22
  import { DESTRUCTIVE_KIND_LABEL, scanDestructiveSql } from './destructive.js';
22
23
  import { splitSqlStatements, tokenizeSql } from './sql-statements.js';
23
24
  /**
@@ -155,6 +156,39 @@ async function getAppliedMigrations(client, dialect = postgresDialect) {
155
156
  const result = await client.query(dialect.buildMigrationSelectApplied(quotedTrackingTable(dialect)));
156
157
  return result.rows;
157
158
  }
159
+ /**
160
+ * The applied rows for a READ-ONLY caller: existence is TESTED, never created.
161
+ *
162
+ * `migrate status` used to reach the tracking table through
163
+ * {@link ensureTrackingTable}, which issues `CREATE TABLE IF NOT EXISTS`
164
+ * unconditionally. PostgreSQL checks the CREATE privilege on the schema BEFORE
165
+ * the `IF NOT EXISTS` short-circuit, so a read-only role got `permission denied
166
+ * for schema public` and a CI "is production up to date?" check could not run
167
+ * as one. A report must not need write rights to say what it can see: an
168
+ * absent table means "no migration has been applied here", which is the honest
169
+ * answer and the one `up` will act on.
170
+ *
171
+ * @internal exported for tests.
172
+ */
173
+ export async function readAppliedMigrations(client, dialect = postgresDialect) {
174
+ // Asked of the schema the tracking table would be CREATED in, not of the
175
+ // whole search_path. Since a pin EXTENDS the caller's path
176
+ // (`connectionStringForSchema`), a bare `to_regclass('_turbine_migrations')`
177
+ // matches the FIRST such table anywhere on it, so a project moving to
178
+ // `schema: 'app'` with an old `public._turbine_migrations` still around read
179
+ // public's rows and reported them as app's. `ensureTrackingTable` has no such
180
+ // ambiguity: `CREATE TABLE IF NOT EXISTS` tests the CREATION target, which is
181
+ // `current_schema()`, and creates there even when the name is visible further
182
+ // down the path (measured). So this asks the writer's question. When it says
183
+ // present, the unqualified SELECT below resolves to that same table:
184
+ // `current_schema()` is the first EXISTING entry, and a table there shadows
185
+ // any later one.
186
+ const present = await client.query(`SELECT to_regclass(quote_ident(current_schema()) || '.' || quote_ident(${dialect.paramPlaceholder(1)})) IS NOT NULL AS present`, [TRACKING_TABLE]);
187
+ if (present.rows[0]?.present !== true)
188
+ return { applied: [], trackingTableExists: false };
189
+ const result = await client.query(dialect.buildMigrationSelectApplied(quotedTrackingTable(dialect)));
190
+ return { applied: result.rows, trackingTableExists: true };
191
+ }
158
192
  // ---------------------------------------------------------------------------
159
193
  // File operations
160
194
  // ---------------------------------------------------------------------------
@@ -244,6 +278,21 @@ export function listMigrationFiles(migrationsDir) {
244
278
  }
245
279
  return files;
246
280
  }
281
+ /**
282
+ * `.sql` files in `migrationsDir` that the runner NEVER sees, because their
283
+ * name lacks the `YYYYMMDDHHMMSS_<name>.sql` shape {@link listMigrationFiles}
284
+ * requires (a hand-written `add_thing.sql`, a 13-digit prefix). Such a file
285
+ * used to be skipped in silence by `status`, `up` and `deploy` alike, so a
286
+ * misnamed migration simply never ran and nothing said so. The CLI prints one
287
+ * warning per entry.
288
+ */
289
+ export function listIgnoredSqlFiles(migrationsDir) {
290
+ if (!existsSync(migrationsDir))
291
+ return [];
292
+ return readdirSync(migrationsDir)
293
+ .filter((f) => f.endsWith('.sql') && parseMigrationFilename(f) === null)
294
+ .sort();
295
+ }
247
296
  /**
248
297
  * The `-- turbine:no-transaction` directive: when present in a migration's
249
298
  * header (before `-- UP`), the runner applies the file WITHOUT wrapping it in
@@ -664,11 +713,156 @@ export function deriveLockId(databaseName) {
664
713
  * Fetch the current database name from the connected client. Used to derive
665
714
  * the advisory lock ID so concurrent migrations in sibling databases do not
666
715
  * contend on one another.
716
+ *
717
+ * Asked of `current_database()` and NOT parsed out of the connection string:
718
+ * the string may carry no path at all (a `PGDATABASE` environment default, a
719
+ * service file, a `.pgpass` entry), and a lock id derived from an empty name
720
+ * would put every such database on ONE lock. The empty-string fallback is the
721
+ * same hazard one step further in, so it is here to be seen rather than to be
722
+ * relied on.
723
+ *
724
+ * @internal exported for tests. `deriveLockId` is tested for stability and for
725
+ * staying inside the positive int4 range; this is the value it is given, and an
726
+ * untested input to a tested function is an untested pair.
667
727
  */
668
- async function getCurrentDatabaseName(client) {
728
+ export async function getCurrentDatabaseName(client) {
669
729
  const result = await client.query(`SELECT current_database()`);
670
730
  return result.rows[0]?.current_database ?? '';
671
731
  }
732
+ /**
733
+ * The connection string the migration runner (and `turbine seed`) connects
734
+ * with for a configured schema: unchanged for the default, pinned otherwise.
735
+ *
736
+ * "Not configured" emits NOTHING: with no `schema` at all the role's own
737
+ * search_path decides, as it always has. A CONFIGURED schema is applied as the
738
+ * `options=-c search_path=...` startup parameter through
739
+ * {@link withSearchPathOption}, never as a `SET`, so the migration's own DDL,
740
+ * its `_turbine_migrations` tracking table, and the lock connection all resolve
741
+ * unqualified names in the schema the rest of the CLI (`push`, `generate`,
742
+ * `doctor`, Studio) already reads. Before this the runner had no schema at all,
743
+ * and a code-first project on `schema: 'app'` pushed its tables into `app`,
744
+ * diffed against `app`, and then applied the resulting migration into `public`.
745
+ *
746
+ * `public` is pinned like any other name, and used to be exempted on the
747
+ * reasoning that it is the default anyway. It is not: `search_path` is a
748
+ * role/database/connection-string setting, so on a role whose path is
749
+ * `app, public` a project configured `schema: 'public'` had `push` creating in
750
+ * `public` (its own pin is unconditional) while `migrate up` created in `app`.
751
+ * That is the same split this function exists to close, reintroduced for the
752
+ * one schema most projects actually use. `schema-sql.ts`'s `pinSearchPath` is
753
+ * the other half of the rule and the two now answer identically: pin
754
+ * unconditionally, and EXTEND the caller's path rather than replace it. The
755
+ * extension is why `inheritedSchemas` exists, since a startup parameter cannot
756
+ * read the path it is about to override; {@link connectMigrationClient} probes
757
+ * for it. Left empty, the emitted parameter is byte-identical to before.
758
+ *
759
+ * The helper is a pure leaf and returns `null` for a name it cannot emit safely
760
+ * or a string it cannot rewrite; here each becomes the E003 the rest of the CLI
761
+ * raises for a bad configured value, because a connection parameter is a
762
+ * literal and a name that can carry a space can carry a second `-c`.
763
+ *
764
+ * @internal exported for the seed runner and tests.
765
+ */
766
+ /**
767
+ * Refuse a configured schema NAME the CLI cannot emit into a connection
768
+ * parameter, before anything opens a connection with it.
769
+ *
770
+ * Separate from {@link assertSchemaExists}, and it has to run FIRST: they answer
771
+ * different questions and only one of them has a useful answer for a name like
772
+ * `bad name`. Asking the catalog first reports "schema does not exist" and
773
+ * suggests `CREATE SCHEMA "bad name"`, which is true and useless; the real
774
+ * problem is that a value carrying whitespace cannot go into `options=-c` at
775
+ * all, because libpq splits that parameter on spaces and a second `-c` would
776
+ * set any GUC it liked.
777
+ *
778
+ * @internal exported for tests.
779
+ */
780
+ export function assertPinnableSchema(schema) {
781
+ if (isPlainSchemaIdentifier(schema))
782
+ return;
783
+ throw new ValidationError(`Cannot pin search_path to "${schema}": a schema name used as a connection parameter must be a plain ` +
784
+ `identifier (letters, digits, "_" and "$", not starting with a digit).`);
785
+ }
786
+ export function connectionStringForSchema(connectionString, schema, inheritedSchemas = []) {
787
+ if (schema === undefined || schema === '')
788
+ return connectionString;
789
+ assertPinnableSchema(schema);
790
+ const pinned = withSearchPathOption(connectionString, schema, inheritedSchemas);
791
+ if (pinned === null) {
792
+ throw new ValidationError('Cannot pin search_path on a connection string that is not a URL (expected postgres://... or postgresql://...).');
793
+ }
794
+ return pinned;
795
+ }
796
+ /**
797
+ * Refuse a configured schema that does not exist in this database.
798
+ *
799
+ * Postgres does not validate `search_path`: pinning it to a namespace that does
800
+ * not exist succeeds, and the first unqualified statement then fails with
801
+ * `relation "..." does not exist` or "no schema has been selected to create in",
802
+ * neither of which names the schema or the setting that caused it. This is the
803
+ * ONE place that question is asked, and it is asked of the CATALOG
804
+ * (`to_regnamespace`, bound as a parameter) rather than of the connection's own
805
+ * resolved `current_schema()`, so it gives the same answer whether the client
806
+ * calling it is pinned or not. That is what lets `turbine seed` use it: the seed
807
+ * runs its callback in a child process and could not have been checked through
808
+ * the pin at all.
809
+ *
810
+ * @internal exported for the seed runner and tests.
811
+ */
812
+ export async function assertSchemaExists(client, schema) {
813
+ const present = await client.query('SELECT to_regnamespace($1) IS NOT NULL AS present', [
814
+ schema,
815
+ ]);
816
+ if (present.rows[0]?.present === true)
817
+ return;
818
+ throw new MigrationError(`Schema "${schema}" does not exist in this database. ` +
819
+ `Postgres accepts a missing namespace in search_path without complaint, so running anyway would ` +
820
+ `fail on the first unqualified statement with a message that names neither. ` +
821
+ `Create the schema first (CREATE SCHEMA "${schema}"), or correct the schema name in turbine.config.ts ` +
822
+ `(or the --schema flag).`);
823
+ }
824
+ /**
825
+ * Open the runner's primary connection for `schema`, and hand back the string
826
+ * the second (lock-only) connection must use too.
827
+ *
828
+ * TWO connections are opened when a schema is configured, and the first one is
829
+ * not an accident. A `search_path` startup parameter is in force before the
830
+ * connection's first statement, which is exactly what makes it safe (it cannot
831
+ * leak onto a pooled backend the way a `SET` does) and exactly what stops it
832
+ * reading the path it is about to override. So the probe connects with the
833
+ * caller's string untouched, asks the two questions that need the caller's own
834
+ * view (does the schema exist, and what does this connection resolve through),
835
+ * and closes; the real connection is then pinned to the target FOLLOWED BY what
836
+ * the probe saw. Without the second half, pinning breaks any migration that
837
+ * names an extension type unqualified, because `citext` / `vector` / `postgis`
838
+ * live in a schema the pin had discarded.
839
+ */
840
+ async function connectMigrationClient(connectionString, schema) {
841
+ const inherited = schema === undefined || schema === '' ? [] : await probeConnectionSchemas(connectionString, schema);
842
+ const pinned = connectionStringForSchema(connectionString, schema, inherited);
843
+ const client = new pg.Client({ connectionString: pinned });
844
+ await client.connect();
845
+ return { client, connectionString: pinned };
846
+ }
847
+ /**
848
+ * Ask an UNPINNED connection the two questions a pin cannot answer for itself,
849
+ * and close it again.
850
+ */
851
+ async function probeConnectionSchemas(connectionString, schema) {
852
+ // Before the connection, not after: a name that cannot be pinned has to say
853
+ // so in those terms, see assertPinnableSchema.
854
+ assertPinnableSchema(schema);
855
+ const probe = new pg.Client({ connectionString });
856
+ await probe.connect();
857
+ try {
858
+ await assertSchemaExists(probe, schema);
859
+ const shown = await probe.query('SHOW search_path');
860
+ return parseSearchPathValue(shown.rows[0]?.search_path ?? '');
861
+ }
862
+ finally {
863
+ await probe.end();
864
+ }
865
+ }
672
866
  /** Open the second, lock-only connection. Separated so tests can fake it. */
673
867
  async function openLockConnection(connectionString) {
674
868
  const client = new pg.Client({ connectionString });
@@ -768,6 +962,24 @@ export async function runMigrationInTransaction(client, body, tracking) {
768
962
  return err instanceof Error ? err.message : String(err);
769
963
  }
770
964
  }
965
+ /**
966
+ * Which of `pending` would be applied out of timestamp order, given `applied`.
967
+ *
968
+ * The rule `migrateUp` applies, extracted so the dry run cannot drift from it.
969
+ *
970
+ * @internal exported for tests.
971
+ */
972
+ export function predictOutOfOrder(applied, pending) {
973
+ const newestPrior = applied
974
+ .map((m) => ({ ts: migrationTimestamp(m.name), name: m.name }))
975
+ .filter((m) => m.ts !== null)
976
+ .sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0))[0];
977
+ if (!newestPrior)
978
+ return [];
979
+ return pending
980
+ .filter((file) => file.timestamp && file.timestamp < newestPrior.ts)
981
+ .map((file) => ({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` }));
982
+ }
771
983
  /**
772
984
  * Validate that applied migration files have not been modified or deleted since they were run.
773
985
  * Returns an array of mismatched migrations (empty if all are clean).
@@ -776,9 +988,33 @@ export async function runMigrationInTransaction(client, body, tracking) {
776
988
  */
777
989
  export async function validateChecksums(client, migrationsDir, dialect = postgresDialect) {
778
990
  const applied = await getAppliedMigrations(client, dialect);
991
+ const { mismatches, legacyUpgrades } = compareAppliedChecksums(applied, migrationsDir);
992
+ for (const upgrade of legacyUpgrades) {
993
+ await client.query(dialect.buildMigrationUpdateChecksum(quotedTrackingTable(dialect)), [
994
+ upgrade.checksum,
995
+ upgrade.name,
996
+ ]);
997
+ }
998
+ return mismatches;
999
+ }
1000
+ /**
1001
+ * THE drift comparison, over rows already read: which applied migrations no
1002
+ * longer match their file, and which are merely carrying a pre-v0.6 hash.
1003
+ *
1004
+ * Split out of {@link validateChecksums} so a DRY RUN can ask the same question
1005
+ * without answering it in the database. `validateChecksums` reads through
1006
+ * `getAppliedMigrations`, which CREATES the tracking table, and then WRITES the
1007
+ * legacy-hash upgrades; a preview must do neither. A dry run that re-derived
1008
+ * drift its own way is how `migrate down --dry-run` came to green-light a batch
1009
+ * the real command refuses, so the rule lives here once and both paths call it.
1010
+ *
1011
+ * @internal exported for tests.
1012
+ */
1013
+ export function compareAppliedChecksums(applied, migrationsDir) {
779
1014
  const allFiles = listMigrationFiles(migrationsDir);
780
1015
  const fileMap = new Map(allFiles.map((f) => [f.name, f]));
781
1016
  const mismatches = [];
1017
+ const legacyUpgrades = [];
782
1018
  for (const migration of applied) {
783
1019
  const file = fileMap.get(migration.name);
784
1020
  if (!file) {
@@ -798,10 +1034,7 @@ export async function validateChecksums(client, migrationsDir, dialect = postgre
798
1034
  // matches what was stored. A legacy row whose file HAS changed falls
799
1035
  // through to the mismatch path below (bypassable with --allow-drift).
800
1036
  if (canUpgradeLegacyChecksum(migration.checksum, content)) {
801
- await client.query(dialect.buildMigrationUpdateChecksum(quotedTrackingTable(dialect)), [
802
- currentHash,
803
- migration.name,
804
- ]);
1037
+ legacyUpgrades.push({ name: migration.name, checksum: currentHash });
805
1038
  continue;
806
1039
  }
807
1040
  mismatches.push({
@@ -812,13 +1045,49 @@ export async function validateChecksums(client, migrationsDir, dialect = postgre
812
1045
  });
813
1046
  }
814
1047
  }
815
- return mismatches;
1048
+ return { mismatches, legacyUpgrades };
816
1049
  }
817
- export function formatChecksumMismatchError(mismatches) {
1050
+ /**
1051
+ * Rewrite the stored checksum of each MODIFIED migration to its file's current
1052
+ * hash, and report what changed.
1053
+ *
1054
+ * This is what `--allow-drift` was always documented to mean ("when
1055
+ * intentionally rewriting history") and never did. The flag let the run
1056
+ * proceed and left the stored hash alone, so the very next `up` was blocked
1057
+ * again, forever: the only ways out were reverting the file byte-for-byte or
1058
+ * editing `_turbine_migrations` by hand. Recording the new hash is what makes
1059
+ * the flag a one-time decision instead of a permanent one.
1060
+ *
1061
+ * DELETED files are deliberately not touched. There is no content to hash, and
1062
+ * the row is the only remaining record that the migration ran; rewriting or
1063
+ * removing it would erase history rather than re-baseline it.
1064
+ *
1065
+ * @internal exported for tests.
1066
+ */
1067
+ export async function rebaselineChecksums(client, migrationsDir, mismatches, dialect = postgresDialect) {
1068
+ const fileMap = new Map(listMigrationFiles(migrationsDir).map((f) => [f.name, f]));
1069
+ const done = [];
1070
+ for (const mismatch of mismatches) {
1071
+ if (mismatch.type !== 'modified')
1072
+ continue;
1073
+ const file = fileMap.get(mismatch.name);
1074
+ if (!file)
1075
+ continue;
1076
+ const current = checksum(readFileSync(file.path, 'utf-8'));
1077
+ await client.query(dialect.buildMigrationUpdateChecksum(quotedTrackingTable(dialect)), [current, mismatch.name]);
1078
+ done.push({ name: mismatch.name, from: mismatch.expected, to: current });
1079
+ }
1080
+ return done;
1081
+ }
1082
+ export function formatChecksumMismatchError(mismatches,
1083
+ // `down` reuses this report, and a rollback refusal that says it is
1084
+ // "refusing to apply pending migrations" sends the reader after the wrong
1085
+ // command. Default keeps `up` / `deploy` byte-identical.
1086
+ action = 'apply pending migrations') {
818
1087
  const modified = mismatches.filter((m) => m.type === 'modified');
819
1088
  const missing = mismatches.filter((m) => m.type === 'missing');
820
1089
  const lines = [
821
- 'Migration drift detected, refusing to apply pending migrations.',
1090
+ `Migration drift detected, refusing to ${action}.`,
822
1091
  '',
823
1092
  'Applied migrations should be immutable. The following files no longer match their applied state:',
824
1093
  '',
@@ -831,18 +1100,55 @@ export function formatChecksumMismatchError(mismatches) {
831
1100
  }
832
1101
  lines.push('');
833
1102
  lines.push('Fix one of these:');
834
- lines.push(' 1. Restore the file(s) to their original content, OR');
835
- if (modified.length > 0) {
836
- // `migrate down` needs the file on disk to read its DOWN section, so it is
837
- // only a remedy for MODIFIED files, never for deleted ones.
838
- lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down` (modified files only), OR');
1103
+ const remedies = ['Restore the file(s) to their original content'];
1104
+ // `migrate down` needs the file on disk to read its DOWN section, so it is
1105
+ // only a remedy for MODIFIED files, never for deleted ones. It is also not a
1106
+ // remedy for `down` ITSELF: offering the command that just refused sends the
1107
+ // reader in a circle.
1108
+ if (modified.length > 0 && action === 'apply pending migrations') {
1109
+ remedies.push('Roll back the affected migrations with `npx turbine migrate down` (modified files only)');
839
1110
  }
840
1111
  if (missing.length > 0) {
841
- lines.push(' (deleted files cannot be rolled back: restore the file, then run `migrate down` if needed), OR');
1112
+ remedies.push('Restore each deleted file (a deleted migration cannot be rolled back: its DOWN is gone)');
1113
+ }
1114
+ // The flag no longer skips the check, it RE-BASELINES: validation still runs
1115
+ // and its result is written back to `_turbine_migrations`. Said three
1116
+ // different ways in one release ("disabled", "bypass", "re-baseline"), only
1117
+ // the last of which was true, so every copy of it now says the same thing.
1118
+ remedies.push('Pass `--allow-drift` to record the on-disk content as the applied state ' +
1119
+ '(advanced: it REWRITES migration history, and the SQL that already ran is not re-run)');
1120
+ for (const [index, remedy] of remedies.entries()) {
1121
+ lines.push(` ${index + 1}. ${remedy}${index === remedies.length - 1 ? '.' : ', OR'}`);
842
1122
  }
843
- lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced, make sure you know what you are doing).');
844
1123
  return lines.join('\n');
845
1124
  }
1125
+ /**
1126
+ * Refuse a batch containing a migration whose UP section runs NOTHING.
1127
+ *
1128
+ * The untouched `migrate create` scaffold is exactly this: a `-- UP` marker
1129
+ * followed by `-- Write your migration SQL here`. It used to be applied and
1130
+ * recorded, and the developer who then filled the file in was refused as
1131
+ * drift; with `--allow-drift` the file was "already applied" and its SQL never
1132
+ * ran, and the only exit was editing `_turbine_migrations` by hand. The runner
1133
+ * already refuses a file with no marker on the grounds that "nothing would run,
1134
+ * and the migration would still be recorded as applied": a marker over zero
1135
+ * executable statements records the same thing. Same rule for a
1136
+ * `-- turbine:no-transaction` file. Pre-flight over the whole batch, alongside
1137
+ * {@link assertNoEmbeddedTransactions}, so nothing is applied first.
1138
+ *
1139
+ * @internal exported for tests.
1140
+ */
1141
+ export function assertUpHasStatements(files) {
1142
+ for (const file of files) {
1143
+ const { up } = parseMigrationSQL(file.path);
1144
+ const executable = tokenizeSql(up).filter((s) => !s.commentOnly && s.stripped.trim() !== '');
1145
+ if (executable.length > 0)
1146
+ continue;
1147
+ throw new MigrationError(`Migration ${file.filename} has an UP section with no executable statement (comments only). ` +
1148
+ `Applying it would record the migration as applied while running nothing, and filling the file in ` +
1149
+ `afterwards would then be refused as drift. Write the UP section or delete the file.`);
1150
+ }
1151
+ }
846
1152
  /**
847
1153
  * Build a deploy plan from local migration files and applied migration rows.
848
1154
  * This is pure file-system planning; callers with a database connection should
@@ -877,27 +1183,22 @@ export function planMigrationDeploy(migrationsDir, applied) {
877
1183
  });
878
1184
  }
879
1185
  }
880
- return {
881
- pending: allFiles.filter((f) => !appliedNames.has(f.name)),
882
- mismatches,
883
- };
1186
+ const pending = allFiles.filter((f) => !appliedNames.has(f.name));
1187
+ return { pending, mismatches, outOfOrder: predictOutOfOrder(applied, pending) };
884
1188
  }
885
1189
  /**
886
1190
  * Inspect deploy status without applying migrations.
887
1191
  */
888
- export async function inspectMigrationDeploy(connectionString, migrationsDir) {
889
- const client = new pg.Client({ connectionString });
890
- await client.connect();
1192
+ export async function inspectMigrationDeploy(connectionString, migrationsDir, options) {
1193
+ const { client } = await connectMigrationClient(connectionString, options?.schema);
891
1194
  const dialect = migrationDialect();
892
1195
  try {
893
1196
  await ensureTrackingTable(client, dialect);
894
1197
  const mismatches = await validateChecksums(client, migrationsDir, dialect);
895
1198
  const applied = await getAppliedMigrations(client, dialect);
896
1199
  const appliedNames = new Set(applied.map((m) => m.name));
897
- return {
898
- pending: listMigrationFiles(migrationsDir).filter((f) => !appliedNames.has(f.name)),
899
- mismatches,
900
- };
1200
+ const pending = listMigrationFiles(migrationsDir).filter((f) => !appliedNames.has(f.name));
1201
+ return { pending, mismatches, outOfOrder: predictOutOfOrder(applied, pending) };
901
1202
  }
902
1203
  finally {
903
1204
  await client.end();
@@ -918,11 +1219,11 @@ export async function inspectMigrationDeploy(connectionString, migrationsDir) {
918
1219
  * this check (the CLI exposes this as `--allow-drift`).
919
1220
  */
920
1221
  export async function migrateUp(connectionString, migrationsDir, options) {
921
- const client = new pg.Client({ connectionString });
922
- await client.connect();
1222
+ const { client, connectionString: url } = await connectMigrationClient(connectionString, options?.schema);
923
1223
  // Treat `force` as an alias for `allowDrift` for backwards compatibility.
924
1224
  const allowDrift = options?.allowDrift === true || options?.force === true;
925
1225
  const dialect = migrationDialect();
1226
+ const rebaselined = [];
926
1227
  try {
927
1228
  // Derive an advisory lock ID per-database so concurrent migrations in
928
1229
  // sibling databases on the same Postgres cluster do not contend.
@@ -933,7 +1234,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
933
1234
  // table-lock adapter gets its OWN connection so the per-migration
934
1235
  // BEGIN/COMMIT below cannot end the transaction the lock lives in.
935
1236
  const adapter = options?.adapter;
936
- const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(connectionString));
1237
+ const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(url));
937
1238
  if (!lock.acquired) {
938
1239
  throw new MigrationError('Could not acquire migration lock, another migration is already running');
939
1240
  }
@@ -945,11 +1246,14 @@ export async function migrateUp(connectionString, migrationsDir, options) {
945
1246
  // migration history no longer agree, so we BLOCK the run by default.
946
1247
  // Users can pass `allowDrift: true` (CLI: `--allow-drift`) to force past
947
1248
  // the block when they are intentionally rewriting history.
948
- if (!allowDrift) {
949
- const mismatches = await validateChecksums(client, migrationsDir, dialect);
950
- if (mismatches.length > 0) {
1249
+ // Computed on BOTH paths. Without the flag a mismatch blocks the run;
1250
+ // with it, the drifted files are re-baselined so the decision is made
1251
+ // once rather than on every future run (see rebaselineChecksums).
1252
+ const mismatches = await validateChecksums(client, migrationsDir, dialect);
1253
+ if (mismatches.length > 0) {
1254
+ if (!allowDrift)
951
1255
  throw new MigrationError(formatChecksumMismatchError(mismatches));
952
- }
1256
+ rebaselined.push(...(await rebaselineChecksums(client, migrationsDir, mismatches, dialect)));
953
1257
  }
954
1258
  const applied = await getAppliedMigrations(client, dialect);
955
1259
  const appliedNames = new Set(applied.map((m) => m.name));
@@ -969,6 +1273,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
969
1273
  // transactions cannot be run safely at all, so it is refused for everyone,
970
1274
  // deploy included. Pre-flight over the whole batch, so nothing is applied.
971
1275
  assertNoEmbeddedTransactions(pending, 'up');
1276
+ assertUpHasStatements(pending);
972
1277
  // Destructive statements in the pending batch, computed once. Returned in
973
1278
  // the result regardless of the gate so `deploy` can print a notice even
974
1279
  // though it proceeds by design.
@@ -1051,7 +1356,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
1051
1356
  results.push(file);
1052
1357
  flagOutOfOrder(file);
1053
1358
  }
1054
- return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
1359
+ return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied, rebaselined };
1055
1360
  }
1056
1361
  finally {
1057
1362
  await releaseMigrationLock(lock, client);
@@ -1072,6 +1377,7 @@ export async function migrateDeploy(connectionString, migrationsDir, options) {
1072
1377
  allowDrift: options?.allowDrift === true,
1073
1378
  allowDestructive: true,
1074
1379
  adapter: options?.adapter,
1380
+ schema: options?.schema,
1075
1381
  });
1076
1382
  }
1077
1383
  /**
@@ -1139,6 +1445,59 @@ export async function rollbackMigrations(client, toRollback, fileMap, deleteAppl
1139
1445
  }
1140
1446
  return { rolledBack: results, errors };
1141
1447
  }
1448
+ /**
1449
+ * What `migrateDown` WOULD roll back, computed by the same rule it uses, and
1450
+ * where it would stop.
1451
+ *
1452
+ * `migrate down --dry-run` used to build its list from `migrateStatus`, which
1453
+ * sorts by filename and drops applied migrations whose file is missing. The
1454
+ * real run walks the tracking table newest-APPLIED first and STOPS at the first
1455
+ * migration it cannot read, so with a deleted file the two named different
1456
+ * migrations: the dry run reported the one after the gap. A dry run that names
1457
+ * the wrong migration is not a dry run, so both now read the same function.
1458
+ *
1459
+ * `stoppedAt` is the migration the real run would fail on (a missing file, or a
1460
+ * missing DOWN section) together with the message it would print; `toRollback`
1461
+ * is what it would get through first.
1462
+ */
1463
+ export async function planMigrationRollback(connectionString, migrationsDir, options) {
1464
+ const { client } = await connectMigrationClient(connectionString, options?.schema);
1465
+ const dialect = migrationDialect();
1466
+ try {
1467
+ const { applied } = await readAppliedMigrations(client, dialect);
1468
+ // The drift gate the real rollback runs, asked of the same rows by the same
1469
+ // function. Without it the preview reported a batch `migrate down` refuses
1470
+ // outright, which is the failure this whole function exists to prevent: a
1471
+ // dry run that disagrees with the run is not a dry run. Read-only, so the
1472
+ // legacy-hash upgrades `validateChecksums` would write are left for the
1473
+ // real run; they are not drift and never reach the refusal.
1474
+ if (options?.allowDrift !== true) {
1475
+ const { mismatches } = compareAppliedChecksums(applied, migrationsDir);
1476
+ if (mismatches.length > 0)
1477
+ throw new MigrationError(formatChecksumMismatchError(mismatches, 'roll back'));
1478
+ }
1479
+ const fileMap = new Map(listMigrationFiles(migrationsDir).map((f) => [f.name, f]));
1480
+ const batch = applied.reverse().slice(0, options?.step ?? 1);
1481
+ const toRollback = [];
1482
+ for (const migration of batch) {
1483
+ const file = fileMap.get(migration.name);
1484
+ if (!file) {
1485
+ return {
1486
+ toRollback,
1487
+ stoppedAt: { name: migration.name, error: `Migration file not found for "${migration.name}"` },
1488
+ };
1489
+ }
1490
+ if (!parseMigrationSQL(file.path).down) {
1491
+ return { toRollback, stoppedAt: { name: migration.name, error: 'No DOWN section found in migration file' } };
1492
+ }
1493
+ toRollback.push(file);
1494
+ }
1495
+ return { toRollback, stoppedAt: null };
1496
+ }
1497
+ finally {
1498
+ await client.end();
1499
+ }
1500
+ }
1142
1501
  /**
1143
1502
  * Rollback the last N migrations (DOWN).
1144
1503
  *
@@ -1148,8 +1507,7 @@ export async function rollbackMigrations(client, toRollback, fileMap, deleteAppl
1148
1507
  * - Properly reverses changes in reverse application order
1149
1508
  */
1150
1509
  export async function migrateDown(connectionString, migrationsDir, options) {
1151
- const client = new pg.Client({ connectionString });
1152
- await client.connect();
1510
+ const { client, connectionString: url } = await connectMigrationClient(connectionString, options?.schema);
1153
1511
  const dialect = migrationDialect();
1154
1512
  try {
1155
1513
  // Derive a per-database advisory lock ID so concurrent migrations in
@@ -1157,15 +1515,30 @@ export async function migrateDown(connectionString, migrationsDir, options) {
1157
1515
  const dbName = await getCurrentDatabaseName(client);
1158
1516
  const lockId = deriveLockId(dbName);
1159
1517
  const adapter = options?.adapter;
1160
- const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(connectionString));
1518
+ const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(url));
1161
1519
  if (!lock.acquired) {
1162
1520
  throw new MigrationError('Could not acquire migration lock, another migration is already running');
1163
1521
  }
1164
1522
  try {
1165
1523
  await ensureTrackingTable(client, dialect);
1524
+ // THE SAME DRIFT QUESTION `up` ASKS, and it matters more here: the DOWN
1525
+ // that runs is read from the CURRENT file, so rolling back a drifted
1526
+ // migration executes SQL that was never the applied file's DOWN. This
1527
+ // used to happen with no check and no warning.
1528
+ // VALIDATED here, REBASELINED further down. The two used to happen
1529
+ // together, above every gate, so a rollback the destructive gate then
1530
+ // refused had already rewritten `_turbine_migrations` by the time the CLI
1531
+ // printed "nothing was rolled back and no data was touched". A refusal
1532
+ // must touch nothing, which means the only write on this path happens
1533
+ // after the last thing that can refuse.
1534
+ const rebaselined = [];
1535
+ const mismatches = await validateChecksums(client, migrationsDir, dialect);
1536
+ if (mismatches.length > 0 && options?.allowDrift !== true) {
1537
+ throw new MigrationError(formatChecksumMismatchError(mismatches, 'roll back'));
1538
+ }
1166
1539
  const applied = await getAppliedMigrations(client, dialect);
1167
1540
  if (applied.length === 0) {
1168
- return { rolledBack: [], errors: [] };
1541
+ return { rolledBack: [], errors: [], rebaselined };
1169
1542
  }
1170
1543
  const allFiles = listMigrationFiles(migrationsDir);
1171
1544
  const fileMap = new Map(allFiles.map((f) => [f.name, f]));
@@ -1204,7 +1577,13 @@ export async function migrateDown(connectionString, migrationsDir, options) {
1204
1577
  throw new MigrationError(lines.join('\n'));
1205
1578
  }
1206
1579
  }
1207
- return await rollbackMigrations(client, toRollback, fileMap, dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect)));
1580
+ // Past every gate: nothing below this line can refuse, so the history
1581
+ // rewrite `--allow-drift` asks for is finally safe to perform.
1582
+ if (mismatches.length > 0) {
1583
+ rebaselined.push(...(await rebaselineChecksums(client, migrationsDir, mismatches, dialect)));
1584
+ }
1585
+ const result = await rollbackMigrations(client, toRollback, fileMap, dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect)));
1586
+ return { ...result, rebaselined };
1208
1587
  }
1209
1588
  finally {
1210
1589
  await releaseMigrationLock(lock, client);
@@ -1221,13 +1600,12 @@ export async function migrateDown(connectionString, migrationsDir, options) {
1221
1600
  * unchanged pre-v0.6 row reports the same way `migrate up` treats it (valid,
1222
1601
  * pending an in-place hash upgrade) rather than looking like drift.
1223
1602
  */
1224
- export async function migrateStatus(connectionString, migrationsDir) {
1225
- const client = new pg.Client({ connectionString });
1226
- await client.connect();
1603
+ export async function migrateStatus(connectionString, migrationsDir, options) {
1604
+ const { client } = await connectMigrationClient(connectionString, options?.schema);
1227
1605
  const dialect = migrationDialect();
1228
1606
  try {
1229
- await ensureTrackingTable(client, dialect);
1230
- const applied = await getAppliedMigrations(client, dialect);
1607
+ // READ ONLY: never create the tracking table here, see readAppliedMigrations.
1608
+ const { applied } = await readAppliedMigrations(client, dialect);
1231
1609
  const appliedMap = new Map(applied.map((m) => [m.name, m]));
1232
1610
  const allFiles = listMigrationFiles(migrationsDir);
1233
1611
  const fileNames = new Set(allFiles.map((f) => f.name));