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
@@ -21,12 +21,14 @@ exports.migrationTimestamp = migrationTimestamp;
21
21
  exports.assertNoEmbeddedTransactions = assertNoEmbeddedTransactions;
22
22
  exports.collectUpDestructive = collectUpDestructive;
23
23
  exports.ensureTrackingTable = ensureTrackingTable;
24
+ exports.readAppliedMigrations = readAppliedMigrations;
24
25
  exports.parseMigrationFilename = parseMigrationFilename;
25
26
  exports.headerSafeName = headerSafeName;
26
27
  exports.sanitizeName = sanitizeName;
27
28
  exports.formatTimestamp = formatTimestamp;
28
29
  exports.getPendingMigrations = getPendingMigrations;
29
30
  exports.listMigrationFiles = listMigrationFiles;
31
+ exports.listIgnoredSqlFiles = listIgnoredSqlFiles;
30
32
  exports.parseMigrationContent = parseMigrationContent;
31
33
  exports.findTransactionControlStatements = findTransactionControlStatements;
32
34
  exports.parseMigrationSQL = parseMigrationSQL;
@@ -35,16 +37,25 @@ exports.isChecksumValid = isChecksumValid;
35
37
  exports.buildDiffMigrationBody = buildDiffMigrationBody;
36
38
  exports.createMigration = createMigration;
37
39
  exports.deriveLockId = deriveLockId;
40
+ exports.getCurrentDatabaseName = getCurrentDatabaseName;
41
+ exports.assertPinnableSchema = assertPinnableSchema;
42
+ exports.connectionStringForSchema = connectionStringForSchema;
43
+ exports.assertSchemaExists = assertSchemaExists;
38
44
  exports.acquireMigrationLock = acquireMigrationLock;
39
45
  exports.releaseMigrationLock = releaseMigrationLock;
40
46
  exports.runMigrationInTransaction = runMigrationInTransaction;
47
+ exports.predictOutOfOrder = predictOutOfOrder;
41
48
  exports.validateChecksums = validateChecksums;
49
+ exports.compareAppliedChecksums = compareAppliedChecksums;
50
+ exports.rebaselineChecksums = rebaselineChecksums;
42
51
  exports.formatChecksumMismatchError = formatChecksumMismatchError;
52
+ exports.assertUpHasStatements = assertUpHasStatements;
43
53
  exports.planMigrationDeploy = planMigrationDeploy;
44
54
  exports.inspectMigrationDeploy = inspectMigrationDeploy;
45
55
  exports.migrateUp = migrateUp;
46
56
  exports.migrateDeploy = migrateDeploy;
47
57
  exports.rollbackMigrations = rollbackMigrations;
58
+ exports.planMigrationRollback = planMigrationRollback;
48
59
  exports.migrateDown = migrateDown;
49
60
  exports.migrateStatus = migrateStatus;
50
61
  const node_crypto_1 = require("node:crypto");
@@ -52,6 +63,7 @@ const node_fs_1 = require("node:fs");
52
63
  const node_path_1 = require("node:path");
53
64
  const pg_1 = __importDefault(require("pg"));
54
65
  const index_js_1 = require("../adapters/index.js");
66
+ const connection_url_js_1 = require("../connection-url.js");
55
67
  const dialect_js_1 = require("../dialect.js");
56
68
  const errors_js_1 = require("../errors.js");
57
69
  const destructive_js_1 = require("./destructive.js");
@@ -185,6 +197,39 @@ async function getAppliedMigrations(client, dialect = dialect_js_1.postgresDiale
185
197
  const result = await client.query(dialect.buildMigrationSelectApplied(quotedTrackingTable(dialect)));
186
198
  return result.rows;
187
199
  }
200
+ /**
201
+ * The applied rows for a READ-ONLY caller: existence is TESTED, never created.
202
+ *
203
+ * `migrate status` used to reach the tracking table through
204
+ * {@link ensureTrackingTable}, which issues `CREATE TABLE IF NOT EXISTS`
205
+ * unconditionally. PostgreSQL checks the CREATE privilege on the schema BEFORE
206
+ * the `IF NOT EXISTS` short-circuit, so a read-only role got `permission denied
207
+ * for schema public` and a CI "is production up to date?" check could not run
208
+ * as one. A report must not need write rights to say what it can see: an
209
+ * absent table means "no migration has been applied here", which is the honest
210
+ * answer and the one `up` will act on.
211
+ *
212
+ * @internal exported for tests.
213
+ */
214
+ async function readAppliedMigrations(client, dialect = dialect_js_1.postgresDialect) {
215
+ // Asked of the schema the tracking table would be CREATED in, not of the
216
+ // whole search_path. Since a pin EXTENDS the caller's path
217
+ // (`connectionStringForSchema`), a bare `to_regclass('_turbine_migrations')`
218
+ // matches the FIRST such table anywhere on it, so a project moving to
219
+ // `schema: 'app'` with an old `public._turbine_migrations` still around read
220
+ // public's rows and reported them as app's. `ensureTrackingTable` has no such
221
+ // ambiguity: `CREATE TABLE IF NOT EXISTS` tests the CREATION target, which is
222
+ // `current_schema()`, and creates there even when the name is visible further
223
+ // down the path (measured). So this asks the writer's question. When it says
224
+ // present, the unqualified SELECT below resolves to that same table:
225
+ // `current_schema()` is the first EXISTING entry, and a table there shadows
226
+ // any later one.
227
+ const present = await client.query(`SELECT to_regclass(quote_ident(current_schema()) || '.' || quote_ident(${dialect.paramPlaceholder(1)})) IS NOT NULL AS present`, [TRACKING_TABLE]);
228
+ if (present.rows[0]?.present !== true)
229
+ return { applied: [], trackingTableExists: false };
230
+ const result = await client.query(dialect.buildMigrationSelectApplied(quotedTrackingTable(dialect)));
231
+ return { applied: result.rows, trackingTableExists: true };
232
+ }
188
233
  // ---------------------------------------------------------------------------
189
234
  // File operations
190
235
  // ---------------------------------------------------------------------------
@@ -274,6 +319,21 @@ function listMigrationFiles(migrationsDir) {
274
319
  }
275
320
  return files;
276
321
  }
322
+ /**
323
+ * `.sql` files in `migrationsDir` that the runner NEVER sees, because their
324
+ * name lacks the `YYYYMMDDHHMMSS_<name>.sql` shape {@link listMigrationFiles}
325
+ * requires (a hand-written `add_thing.sql`, a 13-digit prefix). Such a file
326
+ * used to be skipped in silence by `status`, `up` and `deploy` alike, so a
327
+ * misnamed migration simply never ran and nothing said so. The CLI prints one
328
+ * warning per entry.
329
+ */
330
+ function listIgnoredSqlFiles(migrationsDir) {
331
+ if (!(0, node_fs_1.existsSync)(migrationsDir))
332
+ return [];
333
+ return (0, node_fs_1.readdirSync)(migrationsDir)
334
+ .filter((f) => f.endsWith('.sql') && parseMigrationFilename(f) === null)
335
+ .sort();
336
+ }
277
337
  /**
278
338
  * The `-- turbine:no-transaction` directive: when present in a migration's
279
339
  * header (before `-- UP`), the runner applies the file WITHOUT wrapping it in
@@ -694,11 +754,156 @@ function deriveLockId(databaseName) {
694
754
  * Fetch the current database name from the connected client. Used to derive
695
755
  * the advisory lock ID so concurrent migrations in sibling databases do not
696
756
  * contend on one another.
757
+ *
758
+ * Asked of `current_database()` and NOT parsed out of the connection string:
759
+ * the string may carry no path at all (a `PGDATABASE` environment default, a
760
+ * service file, a `.pgpass` entry), and a lock id derived from an empty name
761
+ * would put every such database on ONE lock. The empty-string fallback is the
762
+ * same hazard one step further in, so it is here to be seen rather than to be
763
+ * relied on.
764
+ *
765
+ * @internal exported for tests. `deriveLockId` is tested for stability and for
766
+ * staying inside the positive int4 range; this is the value it is given, and an
767
+ * untested input to a tested function is an untested pair.
697
768
  */
698
769
  async function getCurrentDatabaseName(client) {
699
770
  const result = await client.query(`SELECT current_database()`);
700
771
  return result.rows[0]?.current_database ?? '';
701
772
  }
773
+ /**
774
+ * The connection string the migration runner (and `turbine seed`) connects
775
+ * with for a configured schema: unchanged for the default, pinned otherwise.
776
+ *
777
+ * "Not configured" emits NOTHING: with no `schema` at all the role's own
778
+ * search_path decides, as it always has. A CONFIGURED schema is applied as the
779
+ * `options=-c search_path=...` startup parameter through
780
+ * {@link withSearchPathOption}, never as a `SET`, so the migration's own DDL,
781
+ * its `_turbine_migrations` tracking table, and the lock connection all resolve
782
+ * unqualified names in the schema the rest of the CLI (`push`, `generate`,
783
+ * `doctor`, Studio) already reads. Before this the runner had no schema at all,
784
+ * and a code-first project on `schema: 'app'` pushed its tables into `app`,
785
+ * diffed against `app`, and then applied the resulting migration into `public`.
786
+ *
787
+ * `public` is pinned like any other name, and used to be exempted on the
788
+ * reasoning that it is the default anyway. It is not: `search_path` is a
789
+ * role/database/connection-string setting, so on a role whose path is
790
+ * `app, public` a project configured `schema: 'public'` had `push` creating in
791
+ * `public` (its own pin is unconditional) while `migrate up` created in `app`.
792
+ * That is the same split this function exists to close, reintroduced for the
793
+ * one schema most projects actually use. `schema-sql.ts`'s `pinSearchPath` is
794
+ * the other half of the rule and the two now answer identically: pin
795
+ * unconditionally, and EXTEND the caller's path rather than replace it. The
796
+ * extension is why `inheritedSchemas` exists, since a startup parameter cannot
797
+ * read the path it is about to override; {@link connectMigrationClient} probes
798
+ * for it. Left empty, the emitted parameter is byte-identical to before.
799
+ *
800
+ * The helper is a pure leaf and returns `null` for a name it cannot emit safely
801
+ * or a string it cannot rewrite; here each becomes the E003 the rest of the CLI
802
+ * raises for a bad configured value, because a connection parameter is a
803
+ * literal and a name that can carry a space can carry a second `-c`.
804
+ *
805
+ * @internal exported for the seed runner and tests.
806
+ */
807
+ /**
808
+ * Refuse a configured schema NAME the CLI cannot emit into a connection
809
+ * parameter, before anything opens a connection with it.
810
+ *
811
+ * Separate from {@link assertSchemaExists}, and it has to run FIRST: they answer
812
+ * different questions and only one of them has a useful answer for a name like
813
+ * `bad name`. Asking the catalog first reports "schema does not exist" and
814
+ * suggests `CREATE SCHEMA "bad name"`, which is true and useless; the real
815
+ * problem is that a value carrying whitespace cannot go into `options=-c` at
816
+ * all, because libpq splits that parameter on spaces and a second `-c` would
817
+ * set any GUC it liked.
818
+ *
819
+ * @internal exported for tests.
820
+ */
821
+ function assertPinnableSchema(schema) {
822
+ if ((0, connection_url_js_1.isPlainSchemaIdentifier)(schema))
823
+ return;
824
+ throw new errors_js_1.ValidationError(`Cannot pin search_path to "${schema}": a schema name used as a connection parameter must be a plain ` +
825
+ `identifier (letters, digits, "_" and "$", not starting with a digit).`);
826
+ }
827
+ function connectionStringForSchema(connectionString, schema, inheritedSchemas = []) {
828
+ if (schema === undefined || schema === '')
829
+ return connectionString;
830
+ assertPinnableSchema(schema);
831
+ const pinned = (0, connection_url_js_1.withSearchPathOption)(connectionString, schema, inheritedSchemas);
832
+ if (pinned === null) {
833
+ throw new errors_js_1.ValidationError('Cannot pin search_path on a connection string that is not a URL (expected postgres://... or postgresql://...).');
834
+ }
835
+ return pinned;
836
+ }
837
+ /**
838
+ * Refuse a configured schema that does not exist in this database.
839
+ *
840
+ * Postgres does not validate `search_path`: pinning it to a namespace that does
841
+ * not exist succeeds, and the first unqualified statement then fails with
842
+ * `relation "..." does not exist` or "no schema has been selected to create in",
843
+ * neither of which names the schema or the setting that caused it. This is the
844
+ * ONE place that question is asked, and it is asked of the CATALOG
845
+ * (`to_regnamespace`, bound as a parameter) rather than of the connection's own
846
+ * resolved `current_schema()`, so it gives the same answer whether the client
847
+ * calling it is pinned or not. That is what lets `turbine seed` use it: the seed
848
+ * runs its callback in a child process and could not have been checked through
849
+ * the pin at all.
850
+ *
851
+ * @internal exported for the seed runner and tests.
852
+ */
853
+ async function assertSchemaExists(client, schema) {
854
+ const present = await client.query('SELECT to_regnamespace($1) IS NOT NULL AS present', [
855
+ schema,
856
+ ]);
857
+ if (present.rows[0]?.present === true)
858
+ return;
859
+ throw new errors_js_1.MigrationError(`Schema "${schema}" does not exist in this database. ` +
860
+ `Postgres accepts a missing namespace in search_path without complaint, so running anyway would ` +
861
+ `fail on the first unqualified statement with a message that names neither. ` +
862
+ `Create the schema first (CREATE SCHEMA "${schema}"), or correct the schema name in turbine.config.ts ` +
863
+ `(or the --schema flag).`);
864
+ }
865
+ /**
866
+ * Open the runner's primary connection for `schema`, and hand back the string
867
+ * the second (lock-only) connection must use too.
868
+ *
869
+ * TWO connections are opened when a schema is configured, and the first one is
870
+ * not an accident. A `search_path` startup parameter is in force before the
871
+ * connection's first statement, which is exactly what makes it safe (it cannot
872
+ * leak onto a pooled backend the way a `SET` does) and exactly what stops it
873
+ * reading the path it is about to override. So the probe connects with the
874
+ * caller's string untouched, asks the two questions that need the caller's own
875
+ * view (does the schema exist, and what does this connection resolve through),
876
+ * and closes; the real connection is then pinned to the target FOLLOWED BY what
877
+ * the probe saw. Without the second half, pinning breaks any migration that
878
+ * names an extension type unqualified, because `citext` / `vector` / `postgis`
879
+ * live in a schema the pin had discarded.
880
+ */
881
+ async function connectMigrationClient(connectionString, schema) {
882
+ const inherited = schema === undefined || schema === '' ? [] : await probeConnectionSchemas(connectionString, schema);
883
+ const pinned = connectionStringForSchema(connectionString, schema, inherited);
884
+ const client = new pg_1.default.Client({ connectionString: pinned });
885
+ await client.connect();
886
+ return { client, connectionString: pinned };
887
+ }
888
+ /**
889
+ * Ask an UNPINNED connection the two questions a pin cannot answer for itself,
890
+ * and close it again.
891
+ */
892
+ async function probeConnectionSchemas(connectionString, schema) {
893
+ // Before the connection, not after: a name that cannot be pinned has to say
894
+ // so in those terms, see assertPinnableSchema.
895
+ assertPinnableSchema(schema);
896
+ const probe = new pg_1.default.Client({ connectionString });
897
+ await probe.connect();
898
+ try {
899
+ await assertSchemaExists(probe, schema);
900
+ const shown = await probe.query('SHOW search_path');
901
+ return (0, connection_url_js_1.parseSearchPathValue)(shown.rows[0]?.search_path ?? '');
902
+ }
903
+ finally {
904
+ await probe.end();
905
+ }
906
+ }
702
907
  /** Open the second, lock-only connection. Separated so tests can fake it. */
703
908
  async function openLockConnection(connectionString) {
704
909
  const client = new pg_1.default.Client({ connectionString });
@@ -798,6 +1003,24 @@ async function runMigrationInTransaction(client, body, tracking) {
798
1003
  return err instanceof Error ? err.message : String(err);
799
1004
  }
800
1005
  }
1006
+ /**
1007
+ * Which of `pending` would be applied out of timestamp order, given `applied`.
1008
+ *
1009
+ * The rule `migrateUp` applies, extracted so the dry run cannot drift from it.
1010
+ *
1011
+ * @internal exported for tests.
1012
+ */
1013
+ function predictOutOfOrder(applied, pending) {
1014
+ const newestPrior = applied
1015
+ .map((m) => ({ ts: migrationTimestamp(m.name), name: m.name }))
1016
+ .filter((m) => m.ts !== null)
1017
+ .sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0))[0];
1018
+ if (!newestPrior)
1019
+ return [];
1020
+ return pending
1021
+ .filter((file) => file.timestamp && file.timestamp < newestPrior.ts)
1022
+ .map((file) => ({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` }));
1023
+ }
801
1024
  /**
802
1025
  * Validate that applied migration files have not been modified or deleted since they were run.
803
1026
  * Returns an array of mismatched migrations (empty if all are clean).
@@ -806,9 +1029,33 @@ async function runMigrationInTransaction(client, body, tracking) {
806
1029
  */
807
1030
  async function validateChecksums(client, migrationsDir, dialect = dialect_js_1.postgresDialect) {
808
1031
  const applied = await getAppliedMigrations(client, dialect);
1032
+ const { mismatches, legacyUpgrades } = compareAppliedChecksums(applied, migrationsDir);
1033
+ for (const upgrade of legacyUpgrades) {
1034
+ await client.query(dialect.buildMigrationUpdateChecksum(quotedTrackingTable(dialect)), [
1035
+ upgrade.checksum,
1036
+ upgrade.name,
1037
+ ]);
1038
+ }
1039
+ return mismatches;
1040
+ }
1041
+ /**
1042
+ * THE drift comparison, over rows already read: which applied migrations no
1043
+ * longer match their file, and which are merely carrying a pre-v0.6 hash.
1044
+ *
1045
+ * Split out of {@link validateChecksums} so a DRY RUN can ask the same question
1046
+ * without answering it in the database. `validateChecksums` reads through
1047
+ * `getAppliedMigrations`, which CREATES the tracking table, and then WRITES the
1048
+ * legacy-hash upgrades; a preview must do neither. A dry run that re-derived
1049
+ * drift its own way is how `migrate down --dry-run` came to green-light a batch
1050
+ * the real command refuses, so the rule lives here once and both paths call it.
1051
+ *
1052
+ * @internal exported for tests.
1053
+ */
1054
+ function compareAppliedChecksums(applied, migrationsDir) {
809
1055
  const allFiles = listMigrationFiles(migrationsDir);
810
1056
  const fileMap = new Map(allFiles.map((f) => [f.name, f]));
811
1057
  const mismatches = [];
1058
+ const legacyUpgrades = [];
812
1059
  for (const migration of applied) {
813
1060
  const file = fileMap.get(migration.name);
814
1061
  if (!file) {
@@ -828,10 +1075,7 @@ async function validateChecksums(client, migrationsDir, dialect = dialect_js_1.p
828
1075
  // matches what was stored. A legacy row whose file HAS changed falls
829
1076
  // through to the mismatch path below (bypassable with --allow-drift).
830
1077
  if (canUpgradeLegacyChecksum(migration.checksum, content)) {
831
- await client.query(dialect.buildMigrationUpdateChecksum(quotedTrackingTable(dialect)), [
832
- currentHash,
833
- migration.name,
834
- ]);
1078
+ legacyUpgrades.push({ name: migration.name, checksum: currentHash });
835
1079
  continue;
836
1080
  }
837
1081
  mismatches.push({
@@ -842,13 +1086,49 @@ async function validateChecksums(client, migrationsDir, dialect = dialect_js_1.p
842
1086
  });
843
1087
  }
844
1088
  }
845
- return mismatches;
1089
+ return { mismatches, legacyUpgrades };
846
1090
  }
847
- function formatChecksumMismatchError(mismatches) {
1091
+ /**
1092
+ * Rewrite the stored checksum of each MODIFIED migration to its file's current
1093
+ * hash, and report what changed.
1094
+ *
1095
+ * This is what `--allow-drift` was always documented to mean ("when
1096
+ * intentionally rewriting history") and never did. The flag let the run
1097
+ * proceed and left the stored hash alone, so the very next `up` was blocked
1098
+ * again, forever: the only ways out were reverting the file byte-for-byte or
1099
+ * editing `_turbine_migrations` by hand. Recording the new hash is what makes
1100
+ * the flag a one-time decision instead of a permanent one.
1101
+ *
1102
+ * DELETED files are deliberately not touched. There is no content to hash, and
1103
+ * the row is the only remaining record that the migration ran; rewriting or
1104
+ * removing it would erase history rather than re-baseline it.
1105
+ *
1106
+ * @internal exported for tests.
1107
+ */
1108
+ async function rebaselineChecksums(client, migrationsDir, mismatches, dialect = dialect_js_1.postgresDialect) {
1109
+ const fileMap = new Map(listMigrationFiles(migrationsDir).map((f) => [f.name, f]));
1110
+ const done = [];
1111
+ for (const mismatch of mismatches) {
1112
+ if (mismatch.type !== 'modified')
1113
+ continue;
1114
+ const file = fileMap.get(mismatch.name);
1115
+ if (!file)
1116
+ continue;
1117
+ const current = checksum((0, node_fs_1.readFileSync)(file.path, 'utf-8'));
1118
+ await client.query(dialect.buildMigrationUpdateChecksum(quotedTrackingTable(dialect)), [current, mismatch.name]);
1119
+ done.push({ name: mismatch.name, from: mismatch.expected, to: current });
1120
+ }
1121
+ return done;
1122
+ }
1123
+ function formatChecksumMismatchError(mismatches,
1124
+ // `down` reuses this report, and a rollback refusal that says it is
1125
+ // "refusing to apply pending migrations" sends the reader after the wrong
1126
+ // command. Default keeps `up` / `deploy` byte-identical.
1127
+ action = 'apply pending migrations') {
848
1128
  const modified = mismatches.filter((m) => m.type === 'modified');
849
1129
  const missing = mismatches.filter((m) => m.type === 'missing');
850
1130
  const lines = [
851
- 'Migration drift detected, refusing to apply pending migrations.',
1131
+ `Migration drift detected, refusing to ${action}.`,
852
1132
  '',
853
1133
  'Applied migrations should be immutable. The following files no longer match their applied state:',
854
1134
  '',
@@ -861,18 +1141,55 @@ function formatChecksumMismatchError(mismatches) {
861
1141
  }
862
1142
  lines.push('');
863
1143
  lines.push('Fix one of these:');
864
- lines.push(' 1. Restore the file(s) to their original content, OR');
865
- if (modified.length > 0) {
866
- // `migrate down` needs the file on disk to read its DOWN section, so it is
867
- // only a remedy for MODIFIED files, never for deleted ones.
868
- lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down` (modified files only), OR');
1144
+ const remedies = ['Restore the file(s) to their original content'];
1145
+ // `migrate down` needs the file on disk to read its DOWN section, so it is
1146
+ // only a remedy for MODIFIED files, never for deleted ones. It is also not a
1147
+ // remedy for `down` ITSELF: offering the command that just refused sends the
1148
+ // reader in a circle.
1149
+ if (modified.length > 0 && action === 'apply pending migrations') {
1150
+ remedies.push('Roll back the affected migrations with `npx turbine migrate down` (modified files only)');
869
1151
  }
870
1152
  if (missing.length > 0) {
871
- lines.push(' (deleted files cannot be rolled back: restore the file, then run `migrate down` if needed), OR');
1153
+ remedies.push('Restore each deleted file (a deleted migration cannot be rolled back: its DOWN is gone)');
1154
+ }
1155
+ // The flag no longer skips the check, it RE-BASELINES: validation still runs
1156
+ // and its result is written back to `_turbine_migrations`. Said three
1157
+ // different ways in one release ("disabled", "bypass", "re-baseline"), only
1158
+ // the last of which was true, so every copy of it now says the same thing.
1159
+ remedies.push('Pass `--allow-drift` to record the on-disk content as the applied state ' +
1160
+ '(advanced: it REWRITES migration history, and the SQL that already ran is not re-run)');
1161
+ for (const [index, remedy] of remedies.entries()) {
1162
+ lines.push(` ${index + 1}. ${remedy}${index === remedies.length - 1 ? '.' : ', OR'}`);
872
1163
  }
873
- lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced, make sure you know what you are doing).');
874
1164
  return lines.join('\n');
875
1165
  }
1166
+ /**
1167
+ * Refuse a batch containing a migration whose UP section runs NOTHING.
1168
+ *
1169
+ * The untouched `migrate create` scaffold is exactly this: a `-- UP` marker
1170
+ * followed by `-- Write your migration SQL here`. It used to be applied and
1171
+ * recorded, and the developer who then filled the file in was refused as
1172
+ * drift; with `--allow-drift` the file was "already applied" and its SQL never
1173
+ * ran, and the only exit was editing `_turbine_migrations` by hand. The runner
1174
+ * already refuses a file with no marker on the grounds that "nothing would run,
1175
+ * and the migration would still be recorded as applied": a marker over zero
1176
+ * executable statements records the same thing. Same rule for a
1177
+ * `-- turbine:no-transaction` file. Pre-flight over the whole batch, alongside
1178
+ * {@link assertNoEmbeddedTransactions}, so nothing is applied first.
1179
+ *
1180
+ * @internal exported for tests.
1181
+ */
1182
+ function assertUpHasStatements(files) {
1183
+ for (const file of files) {
1184
+ const { up } = parseMigrationSQL(file.path);
1185
+ const executable = (0, sql_statements_js_1.tokenizeSql)(up).filter((s) => !s.commentOnly && s.stripped.trim() !== '');
1186
+ if (executable.length > 0)
1187
+ continue;
1188
+ throw new errors_js_1.MigrationError(`Migration ${file.filename} has an UP section with no executable statement (comments only). ` +
1189
+ `Applying it would record the migration as applied while running nothing, and filling the file in ` +
1190
+ `afterwards would then be refused as drift. Write the UP section or delete the file.`);
1191
+ }
1192
+ }
876
1193
  /**
877
1194
  * Build a deploy plan from local migration files and applied migration rows.
878
1195
  * This is pure file-system planning; callers with a database connection should
@@ -907,27 +1224,22 @@ function planMigrationDeploy(migrationsDir, applied) {
907
1224
  });
908
1225
  }
909
1226
  }
910
- return {
911
- pending: allFiles.filter((f) => !appliedNames.has(f.name)),
912
- mismatches,
913
- };
1227
+ const pending = allFiles.filter((f) => !appliedNames.has(f.name));
1228
+ return { pending, mismatches, outOfOrder: predictOutOfOrder(applied, pending) };
914
1229
  }
915
1230
  /**
916
1231
  * Inspect deploy status without applying migrations.
917
1232
  */
918
- async function inspectMigrationDeploy(connectionString, migrationsDir) {
919
- const client = new pg_1.default.Client({ connectionString });
920
- await client.connect();
1233
+ async function inspectMigrationDeploy(connectionString, migrationsDir, options) {
1234
+ const { client } = await connectMigrationClient(connectionString, options?.schema);
921
1235
  const dialect = migrationDialect();
922
1236
  try {
923
1237
  await ensureTrackingTable(client, dialect);
924
1238
  const mismatches = await validateChecksums(client, migrationsDir, dialect);
925
1239
  const applied = await getAppliedMigrations(client, dialect);
926
1240
  const appliedNames = new Set(applied.map((m) => m.name));
927
- return {
928
- pending: listMigrationFiles(migrationsDir).filter((f) => !appliedNames.has(f.name)),
929
- mismatches,
930
- };
1241
+ const pending = listMigrationFiles(migrationsDir).filter((f) => !appliedNames.has(f.name));
1242
+ return { pending, mismatches, outOfOrder: predictOutOfOrder(applied, pending) };
931
1243
  }
932
1244
  finally {
933
1245
  await client.end();
@@ -948,11 +1260,11 @@ async function inspectMigrationDeploy(connectionString, migrationsDir) {
948
1260
  * this check (the CLI exposes this as `--allow-drift`).
949
1261
  */
950
1262
  async function migrateUp(connectionString, migrationsDir, options) {
951
- const client = new pg_1.default.Client({ connectionString });
952
- await client.connect();
1263
+ const { client, connectionString: url } = await connectMigrationClient(connectionString, options?.schema);
953
1264
  // Treat `force` as an alias for `allowDrift` for backwards compatibility.
954
1265
  const allowDrift = options?.allowDrift === true || options?.force === true;
955
1266
  const dialect = migrationDialect();
1267
+ const rebaselined = [];
956
1268
  try {
957
1269
  // Derive an advisory lock ID per-database so concurrent migrations in
958
1270
  // sibling databases on the same Postgres cluster do not contend.
@@ -963,7 +1275,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
963
1275
  // table-lock adapter gets its OWN connection so the per-migration
964
1276
  // BEGIN/COMMIT below cannot end the transaction the lock lives in.
965
1277
  const adapter = options?.adapter;
966
- const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(connectionString));
1278
+ const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(url));
967
1279
  if (!lock.acquired) {
968
1280
  throw new errors_js_1.MigrationError('Could not acquire migration lock, another migration is already running');
969
1281
  }
@@ -975,11 +1287,14 @@ async function migrateUp(connectionString, migrationsDir, options) {
975
1287
  // migration history no longer agree, so we BLOCK the run by default.
976
1288
  // Users can pass `allowDrift: true` (CLI: `--allow-drift`) to force past
977
1289
  // the block when they are intentionally rewriting history.
978
- if (!allowDrift) {
979
- const mismatches = await validateChecksums(client, migrationsDir, dialect);
980
- if (mismatches.length > 0) {
1290
+ // Computed on BOTH paths. Without the flag a mismatch blocks the run;
1291
+ // with it, the drifted files are re-baselined so the decision is made
1292
+ // once rather than on every future run (see rebaselineChecksums).
1293
+ const mismatches = await validateChecksums(client, migrationsDir, dialect);
1294
+ if (mismatches.length > 0) {
1295
+ if (!allowDrift)
981
1296
  throw new errors_js_1.MigrationError(formatChecksumMismatchError(mismatches));
982
- }
1297
+ rebaselined.push(...(await rebaselineChecksums(client, migrationsDir, mismatches, dialect)));
983
1298
  }
984
1299
  const applied = await getAppliedMigrations(client, dialect);
985
1300
  const appliedNames = new Set(applied.map((m) => m.name));
@@ -999,6 +1314,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
999
1314
  // transactions cannot be run safely at all, so it is refused for everyone,
1000
1315
  // deploy included. Pre-flight over the whole batch, so nothing is applied.
1001
1316
  assertNoEmbeddedTransactions(pending, 'up');
1317
+ assertUpHasStatements(pending);
1002
1318
  // Destructive statements in the pending batch, computed once. Returned in
1003
1319
  // the result regardless of the gate so `deploy` can print a notice even
1004
1320
  // though it proceeds by design.
@@ -1081,7 +1397,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
1081
1397
  results.push(file);
1082
1398
  flagOutOfOrder(file);
1083
1399
  }
1084
- return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
1400
+ return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied, rebaselined };
1085
1401
  }
1086
1402
  finally {
1087
1403
  await releaseMigrationLock(lock, client);
@@ -1102,6 +1418,7 @@ async function migrateDeploy(connectionString, migrationsDir, options) {
1102
1418
  allowDrift: options?.allowDrift === true,
1103
1419
  allowDestructive: true,
1104
1420
  adapter: options?.adapter,
1421
+ schema: options?.schema,
1105
1422
  });
1106
1423
  }
1107
1424
  /**
@@ -1169,6 +1486,59 @@ async function rollbackMigrations(client, toRollback, fileMap, deleteApplied) {
1169
1486
  }
1170
1487
  return { rolledBack: results, errors };
1171
1488
  }
1489
+ /**
1490
+ * What `migrateDown` WOULD roll back, computed by the same rule it uses, and
1491
+ * where it would stop.
1492
+ *
1493
+ * `migrate down --dry-run` used to build its list from `migrateStatus`, which
1494
+ * sorts by filename and drops applied migrations whose file is missing. The
1495
+ * real run walks the tracking table newest-APPLIED first and STOPS at the first
1496
+ * migration it cannot read, so with a deleted file the two named different
1497
+ * migrations: the dry run reported the one after the gap. A dry run that names
1498
+ * the wrong migration is not a dry run, so both now read the same function.
1499
+ *
1500
+ * `stoppedAt` is the migration the real run would fail on (a missing file, or a
1501
+ * missing DOWN section) together with the message it would print; `toRollback`
1502
+ * is what it would get through first.
1503
+ */
1504
+ async function planMigrationRollback(connectionString, migrationsDir, options) {
1505
+ const { client } = await connectMigrationClient(connectionString, options?.schema);
1506
+ const dialect = migrationDialect();
1507
+ try {
1508
+ const { applied } = await readAppliedMigrations(client, dialect);
1509
+ // The drift gate the real rollback runs, asked of the same rows by the same
1510
+ // function. Without it the preview reported a batch `migrate down` refuses
1511
+ // outright, which is the failure this whole function exists to prevent: a
1512
+ // dry run that disagrees with the run is not a dry run. Read-only, so the
1513
+ // legacy-hash upgrades `validateChecksums` would write are left for the
1514
+ // real run; they are not drift and never reach the refusal.
1515
+ if (options?.allowDrift !== true) {
1516
+ const { mismatches } = compareAppliedChecksums(applied, migrationsDir);
1517
+ if (mismatches.length > 0)
1518
+ throw new errors_js_1.MigrationError(formatChecksumMismatchError(mismatches, 'roll back'));
1519
+ }
1520
+ const fileMap = new Map(listMigrationFiles(migrationsDir).map((f) => [f.name, f]));
1521
+ const batch = applied.reverse().slice(0, options?.step ?? 1);
1522
+ const toRollback = [];
1523
+ for (const migration of batch) {
1524
+ const file = fileMap.get(migration.name);
1525
+ if (!file) {
1526
+ return {
1527
+ toRollback,
1528
+ stoppedAt: { name: migration.name, error: `Migration file not found for "${migration.name}"` },
1529
+ };
1530
+ }
1531
+ if (!parseMigrationSQL(file.path).down) {
1532
+ return { toRollback, stoppedAt: { name: migration.name, error: 'No DOWN section found in migration file' } };
1533
+ }
1534
+ toRollback.push(file);
1535
+ }
1536
+ return { toRollback, stoppedAt: null };
1537
+ }
1538
+ finally {
1539
+ await client.end();
1540
+ }
1541
+ }
1172
1542
  /**
1173
1543
  * Rollback the last N migrations (DOWN).
1174
1544
  *
@@ -1178,8 +1548,7 @@ async function rollbackMigrations(client, toRollback, fileMap, deleteApplied) {
1178
1548
  * - Properly reverses changes in reverse application order
1179
1549
  */
1180
1550
  async function migrateDown(connectionString, migrationsDir, options) {
1181
- const client = new pg_1.default.Client({ connectionString });
1182
- await client.connect();
1551
+ const { client, connectionString: url } = await connectMigrationClient(connectionString, options?.schema);
1183
1552
  const dialect = migrationDialect();
1184
1553
  try {
1185
1554
  // Derive a per-database advisory lock ID so concurrent migrations in
@@ -1187,15 +1556,30 @@ async function migrateDown(connectionString, migrationsDir, options) {
1187
1556
  const dbName = await getCurrentDatabaseName(client);
1188
1557
  const lockId = deriveLockId(dbName);
1189
1558
  const adapter = options?.adapter;
1190
- const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(connectionString));
1559
+ const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(url));
1191
1560
  if (!lock.acquired) {
1192
1561
  throw new errors_js_1.MigrationError('Could not acquire migration lock, another migration is already running');
1193
1562
  }
1194
1563
  try {
1195
1564
  await ensureTrackingTable(client, dialect);
1565
+ // THE SAME DRIFT QUESTION `up` ASKS, and it matters more here: the DOWN
1566
+ // that runs is read from the CURRENT file, so rolling back a drifted
1567
+ // migration executes SQL that was never the applied file's DOWN. This
1568
+ // used to happen with no check and no warning.
1569
+ // VALIDATED here, REBASELINED further down. The two used to happen
1570
+ // together, above every gate, so a rollback the destructive gate then
1571
+ // refused had already rewritten `_turbine_migrations` by the time the CLI
1572
+ // printed "nothing was rolled back and no data was touched". A refusal
1573
+ // must touch nothing, which means the only write on this path happens
1574
+ // after the last thing that can refuse.
1575
+ const rebaselined = [];
1576
+ const mismatches = await validateChecksums(client, migrationsDir, dialect);
1577
+ if (mismatches.length > 0 && options?.allowDrift !== true) {
1578
+ throw new errors_js_1.MigrationError(formatChecksumMismatchError(mismatches, 'roll back'));
1579
+ }
1196
1580
  const applied = await getAppliedMigrations(client, dialect);
1197
1581
  if (applied.length === 0) {
1198
- return { rolledBack: [], errors: [] };
1582
+ return { rolledBack: [], errors: [], rebaselined };
1199
1583
  }
1200
1584
  const allFiles = listMigrationFiles(migrationsDir);
1201
1585
  const fileMap = new Map(allFiles.map((f) => [f.name, f]));
@@ -1234,7 +1618,13 @@ async function migrateDown(connectionString, migrationsDir, options) {
1234
1618
  throw new errors_js_1.MigrationError(lines.join('\n'));
1235
1619
  }
1236
1620
  }
1237
- return await rollbackMigrations(client, toRollback, fileMap, dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect)));
1621
+ // Past every gate: nothing below this line can refuse, so the history
1622
+ // rewrite `--allow-drift` asks for is finally safe to perform.
1623
+ if (mismatches.length > 0) {
1624
+ rebaselined.push(...(await rebaselineChecksums(client, migrationsDir, mismatches, dialect)));
1625
+ }
1626
+ const result = await rollbackMigrations(client, toRollback, fileMap, dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect)));
1627
+ return { ...result, rebaselined };
1238
1628
  }
1239
1629
  finally {
1240
1630
  await releaseMigrationLock(lock, client);
@@ -1251,13 +1641,12 @@ async function migrateDown(connectionString, migrationsDir, options) {
1251
1641
  * unchanged pre-v0.6 row reports the same way `migrate up` treats it (valid,
1252
1642
  * pending an in-place hash upgrade) rather than looking like drift.
1253
1643
  */
1254
- async function migrateStatus(connectionString, migrationsDir) {
1255
- const client = new pg_1.default.Client({ connectionString });
1256
- await client.connect();
1644
+ async function migrateStatus(connectionString, migrationsDir, options) {
1645
+ const { client } = await connectMigrationClient(connectionString, options?.schema);
1257
1646
  const dialect = migrationDialect();
1258
1647
  try {
1259
- await ensureTrackingTable(client, dialect);
1260
- const applied = await getAppliedMigrations(client, dialect);
1648
+ // READ ONLY: never create the tracking table here, see readAppliedMigrations.
1649
+ const { applied } = await readAppliedMigrations(client, dialect);
1261
1650
  const appliedMap = new Map(applied.map((m) => [m.name, m]));
1262
1651
  const allFiles = listMigrationFiles(migrationsDir);
1263
1652
  const fileNames = new Set(allFiles.map((f) => f.name));