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.
- package/README.md +13 -9
- package/dist/cjs/cli/config.d.ts +7 -1
- package/dist/cjs/cli/config.js +11 -2
- package/dist/cjs/cli/destructive.d.ts +1 -1
- package/dist/cjs/cli/destructive.js +307 -9
- package/dist/cjs/cli/index.js +252 -42
- package/dist/cjs/cli/mcp.d.ts +23 -0
- package/dist/cjs/cli/mcp.js +190 -152
- package/dist/cjs/cli/migrate.d.ts +243 -3
- package/dist/cjs/cli/migrate.js +432 -43
- package/dist/cjs/cli/sql-statements.js +27 -0
- package/dist/cjs/cli/studio.d.ts +0 -1
- package/dist/cjs/cli/studio.js +9 -7
- package/dist/cjs/client.d.ts +8 -1
- package/dist/cjs/client.js +7 -0
- package/dist/cjs/connection-url.d.ts +82 -0
- package/dist/cjs/connection-url.js +187 -1
- package/dist/cjs/errors.d.ts +112 -12
- package/dist/cjs/errors.js +558 -114
- package/dist/cjs/generate.js +47 -15
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/introspect.d.ts +33 -0
- package/dist/cjs/introspect.js +54 -1
- package/dist/cjs/mssql.js +21 -1
- package/dist/cjs/nested-write.js +85 -14
- package/dist/cjs/pipeline-submittable.d.ts +2 -0
- package/dist/cjs/pipeline-submittable.js +88 -3
- package/dist/cjs/pipeline.js +13 -1
- package/dist/cjs/powdb-introspect.d.ts +5 -1
- package/dist/cjs/powdb-introspect.js +5 -1
- package/dist/cjs/powql.d.ts +14 -0
- package/dist/cjs/powql.js +44 -4
- package/dist/cjs/prisma-compat.js +95 -8
- package/dist/cjs/query/aggregates.js +56 -6
- package/dist/cjs/query/builder.d.ts +76 -13
- package/dist/cjs/query/builder.js +188 -58
- package/dist/cjs/query/compound-unique.d.ts +76 -0
- package/dist/cjs/query/compound-unique.js +129 -0
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/types.d.ts +65 -11
- package/dist/cjs/query/where.d.ts +85 -19
- package/dist/cjs/query/where.js +262 -47
- package/dist/cjs/query/writes.d.ts +11 -2
- package/dist/cjs/query/writes.js +116 -21
- package/dist/cjs/seed.d.ts +16 -0
- package/dist/cjs/seed.js +16 -0
- package/dist/cli/config.d.ts +7 -1
- package/dist/cli/config.js +11 -2
- package/dist/cli/destructive.d.ts +1 -1
- package/dist/cli/destructive.js +307 -9
- package/dist/cli/index.js +254 -44
- package/dist/cli/mcp.d.ts +23 -0
- package/dist/cli/mcp.js +187 -150
- package/dist/cli/migrate.d.ts +243 -3
- package/dist/cli/migrate.js +423 -45
- package/dist/cli/sql-statements.js +27 -0
- package/dist/cli/studio.d.ts +0 -1
- package/dist/cli/studio.js +10 -7
- package/dist/client.d.ts +8 -1
- package/dist/client.js +7 -0
- package/dist/connection-url.d.ts +82 -0
- package/dist/connection-url.js +183 -0
- package/dist/errors.d.ts +112 -12
- package/dist/errors.js +558 -114
- package/dist/generate.js +47 -15
- package/dist/index.d.ts +1 -1
- package/dist/introspect.d.ts +33 -0
- package/dist/introspect.js +53 -1
- package/dist/mssql.js +21 -1
- package/dist/nested-write.js +85 -14
- package/dist/pipeline-submittable.d.ts +2 -0
- package/dist/pipeline-submittable.js +87 -3
- package/dist/pipeline.js +14 -2
- package/dist/powdb-introspect.d.ts +5 -1
- package/dist/powdb-introspect.js +5 -1
- package/dist/powql.d.ts +14 -0
- package/dist/powql.js +45 -5
- package/dist/prisma-compat.js +96 -9
- package/dist/query/aggregates.js +56 -6
- package/dist/query/builder.d.ts +76 -13
- package/dist/query/builder.js +188 -58
- package/dist/query/compound-unique.d.ts +76 -0
- package/dist/query/compound-unique.js +126 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +65 -11
- package/dist/query/where.d.ts +85 -19
- package/dist/query/where.js +260 -47
- package/dist/query/writes.d.ts +11 -2
- package/dist/query/writes.js +117 -22
- package/dist/seed.d.ts +16 -0
- package/dist/seed.js +16 -0
- package/package.json +3 -3
- package/skills/turbine-orm/SKILL.md +37 -10
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
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
|
-
|
|
911
|
-
|
|
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 =
|
|
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
|
-
|
|
928
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1260
|
-
const applied = await
|
|
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));
|