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/cli/migrate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
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
|
-
|
|
881
|
-
|
|
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 =
|
|
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
|
-
|
|
898
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1230
|
-
const applied = await
|
|
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));
|