@spinajs/orm 2.0.486 → 2.0.488
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/lib/cjs/builders.d.ts +57 -0
- package/lib/cjs/builders.d.ts.map +1 -1
- package/lib/cjs/builders.js +94 -1
- package/lib/cjs/builders.js.map +1 -1
- package/lib/cjs/config/orm.d.ts +24 -0
- package/lib/cjs/config/orm.d.ts.map +1 -0
- package/lib/cjs/config/orm.js +26 -0
- package/lib/cjs/config/orm.js.map +1 -0
- package/lib/cjs/decorators.d.ts +3 -2
- package/lib/cjs/decorators.d.ts.map +1 -1
- package/lib/cjs/decorators.js +19 -2
- package/lib/cjs/decorators.js.map +1 -1
- package/lib/cjs/descriptor.d.ts +29 -1
- package/lib/cjs/descriptor.d.ts.map +1 -1
- package/lib/cjs/descriptor.js +37 -0
- package/lib/cjs/descriptor.js.map +1 -1
- package/lib/cjs/index.d.ts +6 -0
- package/lib/cjs/index.d.ts.map +1 -1
- package/lib/cjs/index.js +6 -0
- package/lib/cjs/index.js.map +1 -1
- package/lib/cjs/interfaces.d.ts +58 -2
- package/lib/cjs/interfaces.d.ts.map +1 -1
- package/lib/cjs/interfaces.js +18 -1
- package/lib/cjs/interfaces.js.map +1 -1
- package/lib/cjs/migration-environment.d.ts +70 -0
- package/lib/cjs/migration-environment.d.ts.map +1 -0
- package/lib/cjs/migration-environment.js +134 -0
- package/lib/cjs/migration-environment.js.map +1 -0
- package/lib/cjs/migration-runner.d.ts +108 -0
- package/lib/cjs/migration-runner.d.ts.map +1 -0
- package/lib/cjs/migration-runner.js +216 -0
- package/lib/cjs/migration-runner.js.map +1 -0
- package/lib/cjs/migration-service.d.ts +300 -0
- package/lib/cjs/migration-service.d.ts.map +1 -0
- package/lib/cjs/migration-service.js +829 -0
- package/lib/cjs/migration-service.js.map +1 -0
- package/lib/cjs/migration-sources.d.ts +85 -0
- package/lib/cjs/migration-sources.d.ts.map +1 -0
- package/lib/cjs/migration-sources.js +277 -0
- package/lib/cjs/migration-sources.js.map +1 -0
- package/lib/cjs/orm.d.ts +77 -15
- package/lib/cjs/orm.d.ts.map +1 -1
- package/lib/cjs/orm.js +156 -150
- package/lib/cjs/orm.js.map +1 -1
- package/lib/cjs/quoting.d.ts +33 -0
- package/lib/cjs/quoting.d.ts.map +1 -0
- package/lib/cjs/quoting.js +48 -0
- package/lib/cjs/quoting.js.map +1 -0
- package/lib/cjs/source-file.d.ts +10 -0
- package/lib/cjs/source-file.d.ts.map +1 -0
- package/lib/cjs/source-file.js +41 -0
- package/lib/cjs/source-file.js.map +1 -0
- package/lib/cjs/statements.d.ts +51 -1
- package/lib/cjs/statements.d.ts.map +1 -1
- package/lib/cjs/statements.js +73 -1
- package/lib/cjs/statements.js.map +1 -1
- package/lib/cjs/symbols.d.ts +11 -0
- package/lib/cjs/symbols.d.ts.map +1 -1
- package/lib/cjs/symbols.js +12 -1
- package/lib/cjs/symbols.js.map +1 -1
- package/lib/mjs/builders.d.ts +57 -0
- package/lib/mjs/builders.d.ts.map +1 -1
- package/lib/mjs/builders.js +94 -1
- package/lib/mjs/builders.js.map +1 -1
- package/lib/mjs/config/orm.d.ts +24 -0
- package/lib/mjs/config/orm.d.ts.map +1 -0
- package/lib/mjs/config/orm.js +24 -0
- package/lib/mjs/config/orm.js.map +1 -0
- package/lib/mjs/decorators.d.ts +3 -2
- package/lib/mjs/decorators.d.ts.map +1 -1
- package/lib/mjs/decorators.js +19 -2
- package/lib/mjs/decorators.js.map +1 -1
- package/lib/mjs/descriptor.d.ts +29 -1
- package/lib/mjs/descriptor.d.ts.map +1 -1
- package/lib/mjs/descriptor.js +36 -1
- package/lib/mjs/descriptor.js.map +1 -1
- package/lib/mjs/index.d.ts +6 -0
- package/lib/mjs/index.d.ts.map +1 -1
- package/lib/mjs/index.js +6 -0
- package/lib/mjs/index.js.map +1 -1
- package/lib/mjs/interfaces.d.ts +58 -2
- package/lib/mjs/interfaces.d.ts.map +1 -1
- package/lib/mjs/interfaces.js +17 -0
- package/lib/mjs/interfaces.js.map +1 -1
- package/lib/mjs/migration-environment.d.ts +70 -0
- package/lib/mjs/migration-environment.d.ts.map +1 -0
- package/lib/mjs/migration-environment.js +128 -0
- package/lib/mjs/migration-environment.js.map +1 -0
- package/lib/mjs/migration-runner.d.ts +108 -0
- package/lib/mjs/migration-runner.d.ts.map +1 -0
- package/lib/mjs/migration-runner.js +211 -0
- package/lib/mjs/migration-runner.js.map +1 -0
- package/lib/mjs/migration-service.d.ts +300 -0
- package/lib/mjs/migration-service.d.ts.map +1 -0
- package/lib/mjs/migration-service.js +824 -0
- package/lib/mjs/migration-service.js.map +1 -0
- package/lib/mjs/migration-sources.d.ts +85 -0
- package/lib/mjs/migration-sources.d.ts.map +1 -0
- package/lib/mjs/migration-sources.js +236 -0
- package/lib/mjs/migration-sources.js.map +1 -0
- package/lib/mjs/orm.d.ts +77 -15
- package/lib/mjs/orm.d.ts.map +1 -1
- package/lib/mjs/orm.js +158 -152
- package/lib/mjs/orm.js.map +1 -1
- package/lib/mjs/quoting.d.ts +33 -0
- package/lib/mjs/quoting.d.ts.map +1 -0
- package/lib/mjs/quoting.js +45 -0
- package/lib/mjs/quoting.js.map +1 -0
- package/lib/mjs/source-file.d.ts +10 -0
- package/lib/mjs/source-file.d.ts.map +1 -0
- package/lib/mjs/source-file.js +38 -0
- package/lib/mjs/source-file.js.map +1 -0
- package/lib/mjs/statements.d.ts +51 -1
- package/lib/mjs/statements.d.ts.map +1 -1
- package/lib/mjs/statements.js +71 -0
- package/lib/mjs/statements.js.map +1 -1
- package/lib/mjs/symbols.d.ts +11 -0
- package/lib/mjs/symbols.d.ts.map +1 -1
- package/lib/mjs/symbols.js +11 -0
- package/lib/mjs/symbols.js.map +1 -1
- package/lib/tsconfig.cjs.tsbuildinfo +1 -1
- package/lib/tsconfig.mjs.tsbuildinfo +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.DefaultMigrationService = exports.OrmMigrationService = exports.MIGRATION_LOCK_MAX_STEALS = exports.MIGRATION_LOCK_STALE_AFTER = exports.MIGRATION_LOCK_TIMEOUT = exports.MIGRATION_LOCK_POLL_INTERVAL = exports.MIGRATION_TABLE_NAME = void 0;
|
|
13
|
+
exports.migrationChecksum = migrationChecksum;
|
|
14
|
+
const di_1 = require("@spinajs/di");
|
|
15
|
+
const log_common_1 = require("@spinajs/log-common");
|
|
16
|
+
const node_crypto_1 = require("node:crypto");
|
|
17
|
+
const node_os_1 = require("node:os");
|
|
18
|
+
const driver_js_1 = require("./driver.js");
|
|
19
|
+
const interfaces_js_1 = require("./interfaces.js");
|
|
20
|
+
const exceptions_js_1 = require("./exceptions.js");
|
|
21
|
+
exports.MIGRATION_TABLE_NAME = 'spinajs_migration';
|
|
22
|
+
exports.MIGRATION_LOCK_POLL_INTERVAL = 500;
|
|
23
|
+
exports.MIGRATION_LOCK_TIMEOUT = 30_000;
|
|
24
|
+
exports.MIGRATION_LOCK_STALE_AFTER = 600_000;
|
|
25
|
+
/**
|
|
26
|
+
* How many times one `acquireLock()` call may remove a lock row it judged stale. A steal is
|
|
27
|
+
* not proof the row is gone - a DELETE can succeed and remove nothing - so without a cap the
|
|
28
|
+
* stale branch is free to warn and retry forever.
|
|
29
|
+
*/
|
|
30
|
+
exports.MIGRATION_LOCK_MAX_STEALS = 3;
|
|
31
|
+
/**
|
|
32
|
+
* Fingerprint of a migration's source, used to detect a migration that was edited
|
|
33
|
+
* after it had already been applied.
|
|
34
|
+
*/
|
|
35
|
+
function migrationChecksum(type) {
|
|
36
|
+
return (0, node_crypto_1.createHash)('sha256').update(type.toString()).digest('hex');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Per-connection migration execution contract. Configure an alternative
|
|
40
|
+
* implementation with db.Connections[n].Migration.Service (DI token).
|
|
41
|
+
*/
|
|
42
|
+
let OrmMigrationService = class OrmMigrationService {
|
|
43
|
+
constructor(driver) {
|
|
44
|
+
this.driver = driver;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
exports.OrmMigrationService = OrmMigrationService;
|
|
48
|
+
exports.OrmMigrationService = OrmMigrationService = __decorate([
|
|
49
|
+
(0, di_1.NewInstance)(),
|
|
50
|
+
__metadata("design:paramtypes", [driver_js_1.OrmDriver])
|
|
51
|
+
], OrmMigrationService);
|
|
52
|
+
class DefaultMigrationService extends OrmMigrationService {
|
|
53
|
+
get table() {
|
|
54
|
+
return this.driver.Options.Migration?.Table ?? exports.MIGRATION_TABLE_NAME;
|
|
55
|
+
}
|
|
56
|
+
get lockTable() {
|
|
57
|
+
return `${this.table}_lock`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Creates `name` unless it is already there, tolerating a second process that creates it in
|
|
61
|
+
* the window between the probe and the CREATE.
|
|
62
|
+
*
|
|
63
|
+
* That window cannot be closed with a lock: the lock table is one of the tables being created
|
|
64
|
+
* here, so it cannot guard its own creation. Two processes booting together therefore both see
|
|
65
|
+
* "absent" and both issue a CREATE, and the loser must not take the whole boot down with it.
|
|
66
|
+
* Only a table that really is present afterwards excuses the failure - anything else ( no
|
|
67
|
+
* permission, bad DDL, dead connection ) is a genuine error and is rethrown.
|
|
68
|
+
*
|
|
69
|
+
* Returns true when the table was *absent at probe time* - which is not the same as "this
|
|
70
|
+
* process created it", since the lost-race path returns true too. Callers use it to skip the
|
|
71
|
+
* legacy upgrade path: a table that appeared inside the race window was created by a peer
|
|
72
|
+
* running this same DDL, so it already carries the current shape.
|
|
73
|
+
*/
|
|
74
|
+
async createTableIfAbsent(name, columns) {
|
|
75
|
+
// a builder executes at most once, so every statement needs a fresh SchemaQueryBuilder
|
|
76
|
+
const db = this.driver.Options.Database;
|
|
77
|
+
if (await this.driver.schema().tableExists(name, db)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await this.driver.schema().createTable(name, columns);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (!(await this.driver.schema().tableExists(name, db))) {
|
|
85
|
+
throw new exceptions_js_1.OrmException(`Could not create migration table ${name} on connection ${this.driver.Options.Name}: ${err.message}`, undefined, undefined, undefined, err);
|
|
86
|
+
}
|
|
87
|
+
this.Log.trace(`Migration table ${name} on connection ${this.driver.Options.Name} was created concurrently by another process - continuing`);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
async ensureStorage() {
|
|
92
|
+
// a builder executes at most once, so every statement needs a fresh SchemaQueryBuilder
|
|
93
|
+
const schema = () => this.driver.schema();
|
|
94
|
+
const db = this.driver.Options.Database;
|
|
95
|
+
// "was absent when we probed", not "we created it" - a lost race reports absent too
|
|
96
|
+
const wasAbsent = await this.createTableIfAbsent(this.table, (t) => {
|
|
97
|
+
t.string('Migration').unique().notNull();
|
|
98
|
+
t.dateTime('CreatedAt').notNull();
|
|
99
|
+
t.dateTime('StartedAt').notNull();
|
|
100
|
+
t.dateTime('FinishedAt');
|
|
101
|
+
t.dateTime('RolledBackAt');
|
|
102
|
+
t.text('Logs');
|
|
103
|
+
t.string('Checksum', 64);
|
|
104
|
+
t.int('Batch').notNull().default().value(1);
|
|
105
|
+
});
|
|
106
|
+
if (!wasAbsent) {
|
|
107
|
+
const cols = (await this.driver.tableInfo(this.table, db)) ?? [];
|
|
108
|
+
const has = (n) => cols.some((c) => c.Name === n);
|
|
109
|
+
if (!has('StartedAt') || !has('FinishedAt') || !has('RolledBackAt') || !has('Logs') || !has('Checksum') || !has('Batch')) {
|
|
110
|
+
await schema().alterTable(this.table, (t) => {
|
|
111
|
+
if (!has('StartedAt'))
|
|
112
|
+
t.dateTime('StartedAt').addColumn();
|
|
113
|
+
if (!has('FinishedAt'))
|
|
114
|
+
t.dateTime('FinishedAt').addColumn();
|
|
115
|
+
if (!has('RolledBackAt'))
|
|
116
|
+
t.dateTime('RolledBackAt').addColumn();
|
|
117
|
+
if (!has('Logs'))
|
|
118
|
+
t.text('Logs').addColumn();
|
|
119
|
+
if (!has('Checksum'))
|
|
120
|
+
t.string('Checksum', 64).addColumn();
|
|
121
|
+
if (!has('Batch')) {
|
|
122
|
+
// added nullable and backfilled below - a NOT NULL column cannot be bolted
|
|
123
|
+
// onto a table that already has rows
|
|
124
|
+
const batch = t.int('Batch');
|
|
125
|
+
batch.default().value(1);
|
|
126
|
+
batch.addColumn();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
await this.backfillLegacyRows();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await this.createTableIfAbsent(this.lockTable, (t) => {
|
|
133
|
+
t.int('Id').unique().notNull();
|
|
134
|
+
t.dateTime('AcquiredAt').notNull();
|
|
135
|
+
t.string('Owner', 255).notNull();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async records() {
|
|
139
|
+
return ((await this.driver.select().from(this.table)) ?? []);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Fills the columns the upgrade above has just added. A row written before they existed carries
|
|
143
|
+
* nothing but `CreatedAt`, and a NULL `FinishedAt` reads as "never applied" - so without this
|
|
144
|
+
* every migration the deployment ran years ago would run again over a schema that already has
|
|
145
|
+
* it. `CreatedAt` is the only timestamp such a row has, so it is treated as both start and
|
|
146
|
+
* finish.
|
|
147
|
+
*
|
|
148
|
+
* Row by row through the update builder rather than as three set-based `UPDATE`s, and that is
|
|
149
|
+
* the point of the method: a set-based statement has to name the table itself, and the only
|
|
150
|
+
* way to do that here is raw SQL. `Migration.Table` is configuration - a name that needs
|
|
151
|
+
* quoting ( a reserved word, a dot, a space ) would then break this path alone, and only on a
|
|
152
|
+
* deployment that already has rows, which is the least reachable corner in the file. The
|
|
153
|
+
* builder quotes it exactly as every other statement in this class does. The cost is one UPDATE
|
|
154
|
+
* per legacy row, on the single boot that performs the upgrade and never again.
|
|
155
|
+
*/
|
|
156
|
+
async backfillLegacyRows() {
|
|
157
|
+
for (const r of await this.records()) {
|
|
158
|
+
const patch = {};
|
|
159
|
+
// `== null` on purpose - the column is either absent from the row object ( undefined ) or
|
|
160
|
+
// present and NULL, depending on how the driver hydrates a freshly added column
|
|
161
|
+
if (r.StartedAt == null) {
|
|
162
|
+
patch.StartedAt = r.CreatedAt;
|
|
163
|
+
}
|
|
164
|
+
// `Logs` set with a NULL `FinishedAt` is the FAILED state. It cannot occur on a table this
|
|
165
|
+
// old - there was no Logs column to write - but stamping a finish over one would silently
|
|
166
|
+
// unblock a half-applied migration, so the guard is stated rather than assumed away.
|
|
167
|
+
if (r.FinishedAt == null && !r.Logs) {
|
|
168
|
+
patch.FinishedAt = r.CreatedAt;
|
|
169
|
+
}
|
|
170
|
+
if (r.Batch == null) {
|
|
171
|
+
patch.Batch = 1;
|
|
172
|
+
}
|
|
173
|
+
if (Object.keys(patch).length === 0) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
await this.driver.update().in(this.table).update(patch).where({ Migration: r.Migration });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Migrations that finished successfully and were not rolled back - the raw rows, unmerged with
|
|
181
|
+
* the registry.
|
|
182
|
+
*
|
|
183
|
+
* A convenience on this class rather than part of `OrmMigrationService`: nothing in the ORM,
|
|
184
|
+
* the runner or the CLI calls it, because they all need the registry merged in and go through
|
|
185
|
+
* `status()`. It is kept because a subclass, a script or a health check reaching for "what does
|
|
186
|
+
* this connection think it has applied?" should not have to reimplement the applied-gate, and
|
|
187
|
+
* getting that gate subtly wrong ( "a row exists" rather than the FinishedAt NOT NULL and
|
|
188
|
+
* RolledBackAt NULL pair ) is the classic way to re-run a migration.
|
|
189
|
+
*/
|
|
190
|
+
async applied() {
|
|
191
|
+
return (await this.records()).filter((r) => r.FinishedAt !== null && r.FinishedAt !== undefined && !r.RolledBackAt);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Opens a migration's row: a fresh one, or a reset of whatever a previous failed or
|
|
195
|
+
* rolled-back attempt left behind.
|
|
196
|
+
*/
|
|
197
|
+
async upsertStart(name, existing) {
|
|
198
|
+
const now = new Date();
|
|
199
|
+
if (existing) {
|
|
200
|
+
await this.driver.update().in(this.table).update({ StartedAt: now, FinishedAt: null, RolledBackAt: null, Logs: null }).where({ Migration: name });
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
await this.driver.insert().into(this.table).values({ Migration: name, CreatedAt: now, StartedAt: now, FinishedAt: null, RolledBackAt: null, Logs: null, Checksum: null, Batch: 0 });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Closes a migration's row as applied. The batch number is stamped here rather than at
|
|
208
|
+
* insert time, so a row that never finishes carries no batch to be rolled back later.
|
|
209
|
+
*/
|
|
210
|
+
async markFinished(name, batch, checksum) {
|
|
211
|
+
await this.driver.update().in(this.table).update({ FinishedAt: new Date(), Batch: batch, Checksum: checksum }).where({ Migration: name });
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Records why a migration died. Failed state is `FinishedAt` NULL *and* `Logs` set - the pair
|
|
215
|
+
* `assertNoFailed` matches on - so this write establishes both rather than assuming the row
|
|
216
|
+
* already carries a NULL `FinishedAt`.
|
|
217
|
+
*
|
|
218
|
+
* It cannot assume it: a migration that was applied and later rolled back is pending again
|
|
219
|
+
* while still holding the old `FinishedAt`/`RolledBackAt` timestamps, and the reset
|
|
220
|
+
* `upsertStart` issued for the retry is inside the transaction that just unwound. Writing only
|
|
221
|
+
* `Logs` would leave `FinishedAt` set, and a half-applied migration would slip past the block.
|
|
222
|
+
*/
|
|
223
|
+
async markFailed(name, err) {
|
|
224
|
+
await this.driver
|
|
225
|
+
.update()
|
|
226
|
+
.in(this.table)
|
|
227
|
+
.update({ Logs: `${err.message}\n${err.stack ?? ''}`, FinishedAt: null, RolledBackAt: null })
|
|
228
|
+
.where({ Migration: name });
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A half-applied migration means the database is in a state nobody described. Refuse to
|
|
232
|
+
* pile more schema changes on top of it.
|
|
233
|
+
*/
|
|
234
|
+
assertNoFailed(records) {
|
|
235
|
+
const failed = records.find((r) => !r.FinishedAt && r.Logs);
|
|
236
|
+
if (failed) {
|
|
237
|
+
throw new exceptions_js_1.OrmException(`Migration ${failed.Migration} on connection ${this.driver.Options.Name} failed previously and blocks migration runs. Inspect Logs column, fix the database manually, then run orm.Migration.resolve('${failed.Migration}', 'applied') or ('rolled-back'). That call reaches registered migrations only - if this one's class is gone, remove its row from ${this.table} by hand instead.`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The shape of a row whose run never reached an outcome: `StartedAt`, written by `upsertStart`,
|
|
242
|
+
* and neither of the two writes that close it - `markFinished`'s `FinishedAt` or `markFailed`'s
|
|
243
|
+
* `Logs`. Nothing in this class produces it deliberately; a process killed between the start and
|
|
244
|
+
* the outcome does.
|
|
245
|
+
*
|
|
246
|
+
* `RolledBackAt` is excluded on purpose. `resolve('rolled-back')` also leaves `FinishedAt` and
|
|
247
|
+
* `Logs` NULL with `StartedAt` set, and that row is pending because somebody said so - not
|
|
248
|
+
* abandoned.
|
|
249
|
+
*
|
|
250
|
+
* The predicate says nothing about how much of the migration reached the database. It says only
|
|
251
|
+
* that nobody recorded the answer, which is exactly why it is worth surfacing.
|
|
252
|
+
*/
|
|
253
|
+
isInterrupted(rec) {
|
|
254
|
+
return !!rec.StartedAt && !rec.FinishedAt && !rec.Logs && !rec.RolledBackAt;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Is a migration run in flight on this connection right now? Read, never acquired: the caller is
|
|
258
|
+
* `status()`, which must not block behind the run it is reporting on.
|
|
259
|
+
*
|
|
260
|
+
* The lock row is the only honest signal available, and it is judged exactly as `acquireLock`
|
|
261
|
+
* judges it - a row younger than `StaleAfter` means somebody is inside a run, an older one means
|
|
262
|
+
* the holder is presumed dead. Freshness rather than mere presence is what makes this usable
|
|
263
|
+
* here: a process killed mid-migration leaves BOTH its open tracking row and its lock row
|
|
264
|
+
* behind, so "a lock row exists" would hide every crash this is meant to surface, permanently.
|
|
265
|
+
*
|
|
266
|
+
* Two deliberate consequences. For `StaleAfter` after a crash the answer is "running" and the
|
|
267
|
+
* open row is not yet reported as interrupted - the same window in which `acquireLock` still
|
|
268
|
+
* waits for the holder, and with the same client-clock caveat documented there. And
|
|
269
|
+
* `Lock.Enabled: false` removes the signal altogether, so the answer is "not running": an open
|
|
270
|
+
* row then always reads as interrupted, which is right for the crash and wrong only for a report
|
|
271
|
+
* taken while a run is genuinely in progress.
|
|
272
|
+
*/
|
|
273
|
+
async runInProgress() {
|
|
274
|
+
const { enabled, staleAfter } = this.lockOptions();
|
|
275
|
+
if (!enabled) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const rows = ((await this.driver.select().from(this.lockTable).where({ Id: 1 })) ?? []);
|
|
279
|
+
const holder = rows[0];
|
|
280
|
+
if (!holder) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
const acquiredAt = holder.AcquiredAt instanceof Date ? holder.AcquiredAt : new Date(holder.AcquiredAt);
|
|
284
|
+
// NaN from an unreadable timestamp compares false, which reports "not running" - the same
|
|
285
|
+
// bias as everywhere else here: a warning an operator can dismiss beats a silence they cannot
|
|
286
|
+
return Date.now() - acquiredAt.getTime() <= staleAfter;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Warns about every migration this run is about to re-run whose row says a previous attempt was
|
|
290
|
+
* started and never closed. No lock check is needed here, unlike in `status()`: this runs inside
|
|
291
|
+
* `withLock`, so the only run in flight on this connection is this one.
|
|
292
|
+
*
|
|
293
|
+
* It warns rather than blocks, and that is a judgement call worth stating. The row records that
|
|
294
|
+
* a run STARTED, not that anything reached the database, so blocking would escalate "unknown" to
|
|
295
|
+
* "refuse to migrate" - and it would do so for the common, harmless shapes too: an idempotent
|
|
296
|
+
* `CREATE TABLE` that had not run yet, or any migration on a `PerMigration` / `PerRun`
|
|
297
|
+
* connection, whose transaction unwound the partial work when the process died. In those cases
|
|
298
|
+
* re-running from the top is exactly right, and a block would turn every OOM kill during a long
|
|
299
|
+
* migration into an operator ticket.
|
|
300
|
+
*
|
|
301
|
+
* The case that is genuinely dangerous is `Transaction.Mode: None` ( the default ) plus
|
|
302
|
+
* non-idempotent DML: half the INSERTs are already in, nothing recorded which half, and the
|
|
303
|
+
* re-run applies them again. Non-idempotent DDL is the recoverable version of the same thing -
|
|
304
|
+
* it fails, and the failed row then blocks properly. Neither is detectable from here, so the
|
|
305
|
+
* warning describes them and leaves the decision with the operator, who is also the only party
|
|
306
|
+
* that can look at the data.
|
|
307
|
+
*/
|
|
308
|
+
warnOnInterrupted(records, pending) {
|
|
309
|
+
for (const u of pending) {
|
|
310
|
+
const rec = records.find((r) => r.Migration === u.name);
|
|
311
|
+
if (!rec || !this.isInterrupted(rec)) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// rendered without `new Date(...)`: a dialect that hands timestamps back as strings would
|
|
315
|
+
// make that an Invalid Date on some formats, and `toISOString()` on one THROWS - out of a
|
|
316
|
+
// warning, which would replace the diagnosis with a crash
|
|
317
|
+
const started = rec.StartedAt instanceof Date ? rec.StartedAt.toISOString() : String(rec.StartedAt);
|
|
318
|
+
this.Log.warn(`Migration ${u.name} on connection ${this.driver.Options.Name} was STARTED and never finished - its tracking row carries StartedAt with neither FinishedAt nor Logs, so the process running it was killed mid-migration ( started ${started} ). It is being RE-RUN from the top and nothing recorded how far the first attempt got. Under Migration.Transaction.Mode None ( the default ) whatever it had already applied is still in the database: non-idempotent DDL will fail and land in the failed state, non-idempotent data changes will be applied a second time and nothing will say so. Check what the first attempt left behind, or record the truth with orm.Migration.resolve('${u.name}', 'applied') or ('rolled-back') before running again.`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
transactionMode() {
|
|
322
|
+
return this.driver.Options.Migration?.Transaction?.Mode ?? interfaces_js_1.MigrationTransactionMode.None;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* True when this migration must run outside any wrapping transaction ( TypeORM parity:
|
|
326
|
+
* `public transaction = false` on the migration class - needed for DDL that cannot be
|
|
327
|
+
* transacted, such as MySQL index rebuilds ).
|
|
328
|
+
*
|
|
329
|
+
* That declaration is an *instance* field, assigned in the constructor, so it never reaches
|
|
330
|
+
* the prototype - the resolved instance is the only place it can be read from. A prototype
|
|
331
|
+
* getter or a static property is honoured too, so a migration may also opt out without
|
|
332
|
+
* being constructed.
|
|
333
|
+
*/
|
|
334
|
+
optedOutOfTransaction(u, instance) {
|
|
335
|
+
return instance?.transaction === false || u.type.prototype?.transaction === false || u.type?.transaction === false;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Advisory only: transpilation differences move the checksum as readily as an edit does,
|
|
339
|
+
* so this warns and never blocks.
|
|
340
|
+
*/
|
|
341
|
+
warnOnChecksumDrift(u, records) {
|
|
342
|
+
const rec = records.find((r) => r.Migration === u.name);
|
|
343
|
+
if (rec?.Checksum && rec.Checksum !== migrationChecksum(u.type)) {
|
|
344
|
+
this.Log.warn(`Migration ${u.name} source changed since it was applied (checksum mismatch). This is advisory - transpilation differences also change the checksum.`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
lockOptions() {
|
|
348
|
+
const cfg = this.driver.Options.Migration?.Lock;
|
|
349
|
+
return {
|
|
350
|
+
enabled: cfg?.Enabled ?? true,
|
|
351
|
+
timeout: cfg?.Timeout ?? exports.MIGRATION_LOCK_TIMEOUT,
|
|
352
|
+
staleAfter: cfg?.StaleAfter ?? exports.MIGRATION_LOCK_STALE_AFTER,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Identity written into the lock row. It exists to answer "who is holding this?" when a run
|
|
357
|
+
* blocks, so it has to survive being read on another machine.
|
|
358
|
+
*/
|
|
359
|
+
lockOwner() {
|
|
360
|
+
return `${(0, node_os_1.hostname)()}:${process.pid}`;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Takes the single row of the lock table, waiting for whoever has it.
|
|
364
|
+
*
|
|
365
|
+
* The row is claimed by INSERT rather than by "SELECT then INSERT": `Id` is unique, so the
|
|
366
|
+
* database decides the winner in one statement and two processes racing here cannot both
|
|
367
|
+
* succeed. A refused insert is therefore read as "somebody else holds it" - which is also why
|
|
368
|
+
* the holder is re-read afterwards rather than guessed at.
|
|
369
|
+
*
|
|
370
|
+
* Staleness is judged against the *client* clock: `AcquiredAt` is written here as
|
|
371
|
+
* `new Date()` and compared to this host's `Date.now()`. That is sound for the case this
|
|
372
|
+
* lock is built for - one process migrating, crashing, and restarting to find its own
|
|
373
|
+
* abandoned row - but on hosts whose clocks disagree the window is off by the skew, which
|
|
374
|
+
* shows up as stealing too early or waiting too long. Stamping `AcquiredAt` from the
|
|
375
|
+
* database ( a driver-level `CURRENT_TIMESTAMP` default and a server-side comparison ) would
|
|
376
|
+
* remove the assumption; it needs dialect support that does not exist here yet.
|
|
377
|
+
*/
|
|
378
|
+
async acquireLock() {
|
|
379
|
+
const { timeout, staleAfter } = this.lockOptions();
|
|
380
|
+
const owner = this.lockOwner();
|
|
381
|
+
const start = Date.now();
|
|
382
|
+
const expired = () => Date.now() - start > timeout;
|
|
383
|
+
let steals = 0;
|
|
384
|
+
for (;;) {
|
|
385
|
+
try {
|
|
386
|
+
await this.driver.insert().into(this.lockTable).values({ Id: 1, AcquiredAt: new Date(), Owner: owner });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
// deliberately unguarded: a select that dies here means the connection is gone, and that
|
|
391
|
+
// has to surface as itself rather than as a lock timeout thirty seconds later
|
|
392
|
+
const rows = ((await this.driver.select().from(this.lockTable).where({ Id: 1 })) ?? []);
|
|
393
|
+
const holder = rows[0];
|
|
394
|
+
if (holder) {
|
|
395
|
+
const acquiredAt = holder.AcquiredAt instanceof Date ? holder.AcquiredAt : new Date(holder.AcquiredAt);
|
|
396
|
+
const stale = Date.now() - acquiredAt.getTime() > staleAfter;
|
|
397
|
+
// A process that died mid-run leaves its row behind and nothing else will ever clear
|
|
398
|
+
// it. The attempts are capped because a steal is not proof the row is gone - a DELETE
|
|
399
|
+
// can succeed and remove nothing, and the read that follows then finds the same row.
|
|
400
|
+
// Uncapped, that is a sleepless loop warning once per turn and never timing out.
|
|
401
|
+
if (stale && steals < exports.MIGRATION_LOCK_MAX_STEALS) {
|
|
402
|
+
steals++;
|
|
403
|
+
// Loud, because the alternative reading is a live run that outlasted StaleAfter - in
|
|
404
|
+
// which case two migration runs are now in flight and somebody has to know
|
|
405
|
+
this.Log.warn(`Stealing stale migration lock on connection ${this.driver.Options.Name}, held by ${holder.Owner} since ${acquiredAt.toISOString()} ( older than ${staleAfter}ms ). If that process is still alive, two migration runs are now in flight - raise Migration.Lock.StaleAfter above the longest run.`);
|
|
406
|
+
await this.driver.del().from(this.lockTable).where({ Id: 1 });
|
|
407
|
+
// straight back to the INSERT: the row should be free now, and sleeping a poll
|
|
408
|
+
// interval for a lock nobody holds is pure boot latency. Only when the deadline has
|
|
409
|
+
// already passed does this fall through to the throw below
|
|
410
|
+
if (!expired()) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (expired()) {
|
|
415
|
+
throw new exceptions_js_1.OrmException(`Could not acquire migration lock on connection ${this.driver.Options.Name} within ${timeout}ms - held by ${holder.Owner} since ${acquiredAt.toISOString()}${steals > 0 ? `, and ${steals} attempt(s) to remove it as stale left it in place - delete the row from ${this.lockTable} by hand` : ''}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else if (expired()) {
|
|
419
|
+
// no row to blame, so the insert is failing for its own reasons ( missing table, lost
|
|
420
|
+
// connection ). Carrying that message is the only thing that makes this diagnosable
|
|
421
|
+
throw new exceptions_js_1.OrmException(`Could not acquire migration lock on connection ${this.driver.Options.Name} within ${timeout}ms - no lock row is present, the last insert failed with: ${err.message}`, undefined, undefined, undefined, err);
|
|
422
|
+
}
|
|
423
|
+
await new Promise((r) => setTimeout(r, exports.MIGRATION_LOCK_POLL_INTERVAL));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Drops the lock row unconditionally rather than only the row this process wrote. A run whose
|
|
429
|
+
* lock was stolen as stale would otherwise have nothing to release, and the alternative -
|
|
430
|
+
* deleting only `Owner = ours` - leaves the table holding a row nobody will clear if the owner
|
|
431
|
+
* string ever changes underneath a run. Losing a stolen lock is the lesser harm: the thief
|
|
432
|
+
* already assumed the run was dead.
|
|
433
|
+
*/
|
|
434
|
+
async releaseLock() {
|
|
435
|
+
await this.driver.del().from(this.lockTable).where({ Id: 1 });
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Concurrency guard around a whole run: one migration run per connection at a time, across
|
|
439
|
+
* processes. Note the release is `finally` - a run that throws must not leave the connection
|
|
440
|
+
* locked until the staleness window expires.
|
|
441
|
+
*/
|
|
442
|
+
async withLock(fn) {
|
|
443
|
+
if (!this.lockOptions().enabled) {
|
|
444
|
+
return fn();
|
|
445
|
+
}
|
|
446
|
+
await this.acquireLock();
|
|
447
|
+
try {
|
|
448
|
+
return await fn();
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
try {
|
|
452
|
+
await this.releaseLock();
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
// a throw out of `finally` replaces whatever the run was already throwing, and the
|
|
456
|
+
// migration error is the one the operator actually needs - the likeliest reason the
|
|
457
|
+
// release died is the same dead connection that killed the run. The lock is left behind
|
|
458
|
+
// instead: it goes stale and the next run steals it, which is what StaleAfter is for.
|
|
459
|
+
this.Log.error(`Could not release the migration lock on connection ${this.driver.Options.Name}: ${err.message}. It will block further runs until it goes stale ( Migration.Lock.StaleAfter ) or its row is deleted from ${this.lockTable} by hand.`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async up(units, options) {
|
|
464
|
+
await this.ensureStorage();
|
|
465
|
+
return await this.withLock(async () => {
|
|
466
|
+
const records = await this.records();
|
|
467
|
+
this.assertNoFailed(records);
|
|
468
|
+
const isApplied = (n) => records.some((r) => r.Migration === n && r.FinishedAt && !r.RolledBackAt);
|
|
469
|
+
const pending = units.filter((u) => !isApplied(u.name));
|
|
470
|
+
if (pending.length === 0) {
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
// before the fake branch as well as the real one: `fake: true` on a row nobody closed
|
|
474
|
+
// records "applied" over a migration whose actual effect is unknown, which is the outcome
|
|
475
|
+
// most worth being told about
|
|
476
|
+
this.warnOnInterrupted(records, pending);
|
|
477
|
+
// Resolved up front, once each. `transaction = false` only exists on a constructed
|
|
478
|
+
// migration, so the segmenting below cannot be decided without instances - and resolving
|
|
479
|
+
// a second time at execution would build every migration twice.
|
|
480
|
+
const instances = new Map();
|
|
481
|
+
for (const u of pending) {
|
|
482
|
+
instances.set(u.name, await this.driver.Container.resolve(u.type, [this.driver]));
|
|
483
|
+
}
|
|
484
|
+
const instanceOf = (u) => instances.get(u.name);
|
|
485
|
+
const optedOut = (u) => this.optedOutOfTransaction(u, instances.get(u.name));
|
|
486
|
+
const batch = Math.max(0, ...records.filter((r) => r.FinishedAt && !r.RolledBackAt).map((r) => r.Batch ?? 0)) + 1;
|
|
487
|
+
const executed = [];
|
|
488
|
+
if (options?.fake) {
|
|
489
|
+
const stamp = new Date();
|
|
490
|
+
for (const u of pending) {
|
|
491
|
+
const existing = records.find((r) => r.Migration === u.name);
|
|
492
|
+
if (existing) {
|
|
493
|
+
await this.driver
|
|
494
|
+
.update()
|
|
495
|
+
.in(this.table)
|
|
496
|
+
.update({ StartedAt: stamp, FinishedAt: stamp, RolledBackAt: null, Logs: null, Batch: batch, Checksum: migrationChecksum(u.type) })
|
|
497
|
+
.where({ Migration: u.name });
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
await this.driver
|
|
501
|
+
.insert()
|
|
502
|
+
.into(this.table)
|
|
503
|
+
.values({ Migration: u.name, CreatedAt: stamp, StartedAt: stamp, FinishedAt: stamp, RolledBackAt: null, Logs: null, Checksum: migrationChecksum(u.type), Batch: batch });
|
|
504
|
+
}
|
|
505
|
+
executed.push(instanceOf(u));
|
|
506
|
+
this.Log.info(`Migration ${u.name}: faked (recorded without executing)`);
|
|
507
|
+
}
|
|
508
|
+
return executed;
|
|
509
|
+
}
|
|
510
|
+
const startAndRun = async (u) => {
|
|
511
|
+
const migration = instanceOf(u);
|
|
512
|
+
// the snapshot taken at the top of the run is still accurate here: a unit appears at
|
|
513
|
+
// most once in `pending`, and nothing writes its row before this point
|
|
514
|
+
const existing = records.find((r) => r.Migration === u.name);
|
|
515
|
+
await this.upsertStart(u.name, existing);
|
|
516
|
+
this.warnOnChecksumDrift(u, records);
|
|
517
|
+
await migration.up(this.driver);
|
|
518
|
+
await this.markFinished(u.name, batch, migrationChecksum(u.type));
|
|
519
|
+
executed.push(migration);
|
|
520
|
+
this.Log.info(`Migration ${u.name}:up() success !`);
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Only ever called once the transaction that was running the migration has unwound - a
|
|
524
|
+
* failure row written inside it would be rolled back with everything else, leaving no
|
|
525
|
+
* trace of what broke.
|
|
526
|
+
*/
|
|
527
|
+
const recordFailure = async (name, err) => {
|
|
528
|
+
try {
|
|
529
|
+
// deliberately re-read rather than reuse the run's snapshot: this has to observe
|
|
530
|
+
// post-rollback state, which may have lost the row `upsertStart` inserted
|
|
531
|
+
const fresh = (await this.records()).find((r) => r.Migration === name);
|
|
532
|
+
if (!fresh) {
|
|
533
|
+
await this.upsertStart(name, undefined);
|
|
534
|
+
}
|
|
535
|
+
await this.markFailed(name, err);
|
|
536
|
+
}
|
|
537
|
+
catch (bookkeeping) {
|
|
538
|
+
// the likeliest reason up() died is a connection that is now gone, which kills this
|
|
539
|
+
// write too. The migration error is what the caller needs; losing it to a secondary
|
|
540
|
+
// failure would hide both the migration name and the root cause.
|
|
541
|
+
this.Log.error(`Migration ${name} on connection ${this.driver.Options.Name} failed, and the failure row could not be written: ${bookkeeping.message}. The tracking table may not reflect that this migration is half-applied - verify it by hand.`);
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
const failure = (name, err) => new exceptions_js_1.OrmException(`Migration ${name} failed on connection ${this.driver.Options.Name}: ${err.message}`, undefined, undefined, undefined, err);
|
|
545
|
+
const execute = async (u, wrap) => {
|
|
546
|
+
try {
|
|
547
|
+
if (wrap) {
|
|
548
|
+
await this.driver.transaction(async () => {
|
|
549
|
+
await startAndRun(u);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
await startAndRun(u);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
await recordFailure(u.name, err);
|
|
558
|
+
throw failure(u.name, err);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
const mode = this.transactionMode();
|
|
562
|
+
if (mode === interfaces_js_1.MigrationTransactionMode.PerRun) {
|
|
563
|
+
// One transaction per run of consecutive non-opted-out migrations. An opted-out one
|
|
564
|
+
// splits the run rather than being silently dragged into the shared transaction.
|
|
565
|
+
const queue = [...pending];
|
|
566
|
+
while (queue.length > 0) {
|
|
567
|
+
const head = queue.shift();
|
|
568
|
+
if (optedOut(head)) {
|
|
569
|
+
await execute(head, false);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const segment = [head];
|
|
573
|
+
while (queue.length > 0 && !optedOut(queue[0])) {
|
|
574
|
+
segment.push(queue.shift());
|
|
575
|
+
}
|
|
576
|
+
let current = head;
|
|
577
|
+
try {
|
|
578
|
+
await this.driver.transaction(async () => {
|
|
579
|
+
for (const u of segment) {
|
|
580
|
+
current = u;
|
|
581
|
+
await startAndRun(u);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
catch (err) {
|
|
586
|
+
await recordFailure(current.name, err);
|
|
587
|
+
throw failure(current.name, err);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
else if (mode === interfaces_js_1.MigrationTransactionMode.PerMigration) {
|
|
592
|
+
for (const u of pending) {
|
|
593
|
+
await execute(u, !optedOut(u));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
for (const u of pending) {
|
|
598
|
+
await execute(u, false);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return executed;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
async down(units, options) {
|
|
605
|
+
await this.ensureStorage();
|
|
606
|
+
return await this.withLock(async () => {
|
|
607
|
+
const records = await this.records();
|
|
608
|
+
const appliedRows = records.filter((r) => r.FinishedAt && !r.RolledBackAt);
|
|
609
|
+
// a failed row is not applied, so it is simply absent from the set below. Deliberate -
|
|
610
|
+
// down() is a recovery path and blocking it would leave operators with only resolve() -
|
|
611
|
+
// but silently stepping around it hides that the connection stays blocked afterwards
|
|
612
|
+
const failedRows = records.filter((r) => !r.FinishedAt && r.Logs);
|
|
613
|
+
if (failedRows.length > 0) {
|
|
614
|
+
// every one of them has to be cleared: assertNoFailed blocks on the first failed row it
|
|
615
|
+
// finds, so resolving one of two leaves the connection just as blocked as before
|
|
616
|
+
this.Log.warn(`Migration(s) ${failedRows.map((r) => r.Migration).join(', ')} on connection ${this.driver.Options.Name} are in failed state and are skipped by this rollback - the schema may end up reverted while every later up() stays blocked. Clear each of them with orm.Migration.resolve('${failedRows[0].Migration}', 'applied') or ('rolled-back').`);
|
|
617
|
+
}
|
|
618
|
+
if (appliedRows.length === 0) {
|
|
619
|
+
return [];
|
|
620
|
+
}
|
|
621
|
+
// default is the last batch alone: one `up` run is one unit of work, so one `down`
|
|
622
|
+
// undoes exactly that run rather than the whole history
|
|
623
|
+
let target = appliedRows;
|
|
624
|
+
if (!options?.all) {
|
|
625
|
+
const lastBatch = Math.max(...appliedRows.map((r) => r.Batch ?? 0));
|
|
626
|
+
target = appliedRows.filter((r) => (r.Batch ?? 0) === lastBatch);
|
|
627
|
+
}
|
|
628
|
+
// a row with no registered unit can never be rolled back - its class is gone, so there is
|
|
629
|
+
// no down() to run and dropping the row alone would lie about the schema. Nothing can be
|
|
630
|
+
// done about it here, but returning [] without a word leaves it applied forever
|
|
631
|
+
const orphans = target.filter((r) => !units.some((u) => u.name === r.Migration));
|
|
632
|
+
if (orphans.length > 0) {
|
|
633
|
+
this.Log.warn(`Migration(s) ${orphans.map((r) => r.Migration).join(', ')} on connection ${this.driver.Options.Name} are recorded as applied but no registered migration matches them (file deleted or renamed). They cannot be rolled back and stay applied - restore the migration file, or remove the row by hand once the schema is undone.`);
|
|
634
|
+
}
|
|
635
|
+
// newest first - a migration has to be undone before the one it was built on top of.
|
|
636
|
+
// Same-timestamp migrations fall back to reverse name, mirroring the forward order
|
|
637
|
+
const toRun = units.filter((u) => target.some((r) => r.Migration === u.name)).sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : b.name.localeCompare(a.name)));
|
|
638
|
+
if (toRun.length === 0) {
|
|
639
|
+
return [];
|
|
640
|
+
}
|
|
641
|
+
// resolved up front, once each - `transaction = false` is an instance field, so the
|
|
642
|
+
// segmentation below cannot be decided without instances, and resolving again at
|
|
643
|
+
// execution time would construct every migration twice
|
|
644
|
+
const instances = new Map();
|
|
645
|
+
for (const u of toRun) {
|
|
646
|
+
instances.set(u.name, await this.driver.Container.resolve(u.type, [this.driver]));
|
|
647
|
+
}
|
|
648
|
+
const optedOut = (u) => this.optedOutOfTransaction(u, instances.get(u.name));
|
|
649
|
+
const executed = [];
|
|
650
|
+
const runOne = async (u) => {
|
|
651
|
+
const migration = instances.get(u.name);
|
|
652
|
+
if (!options?.fake) {
|
|
653
|
+
await migration.down(this.driver);
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
// the row is dropped rather than stamped RolledBackAt: a deleted row and a
|
|
657
|
+
// rolled-back one are both "pending" to `up()`, and dropping keeps the table
|
|
658
|
+
// holding only migrations that are actually present in the database
|
|
659
|
+
await this.driver.del().from(this.table).where({ Migration: u.name });
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
// down() already succeeded, so outside a transaction the schema is reverted while
|
|
663
|
+
// the row still claims the migration is applied - the one state nothing downstream
|
|
664
|
+
// can detect. Inside a transaction the wrapper unwinds both, so it is not a lie there
|
|
665
|
+
if (!options?.fake && this.driver.CurrentTransaction === undefined) {
|
|
666
|
+
this.Log.error(`Migration ${u.name} on connection ${this.driver.Options.Name}: down() completed but its tracking row could not be removed (${err.message}). The schema is reverted while the table still reports this migration as applied - delete the row from ${this.table} by hand, or every later up() will skip it.`);
|
|
667
|
+
}
|
|
668
|
+
throw err;
|
|
669
|
+
}
|
|
670
|
+
executed.push(migration);
|
|
671
|
+
this.Log.info(`Migration ${u.name}: ${options?.fake ? 'faked (record removed without executing)' : 'down() success !'}`);
|
|
672
|
+
};
|
|
673
|
+
/**
|
|
674
|
+
* No `Logs` row is written on a failed rollback, unlike `up()`: `Logs` set with a NULL
|
|
675
|
+
* `FinishedAt` is the state `assertNoFailed` blocks on, and stamping it here would take a
|
|
676
|
+
* connection whose only remaining recovery path is `down()` and lock that path shut too.
|
|
677
|
+
* The name and connection are carried on the exception instead.
|
|
678
|
+
*/
|
|
679
|
+
const failure = (name, err) => new exceptions_js_1.OrmException(`Migration ${name} failed to roll back on connection ${this.driver.Options.Name}: ${err.message}`, undefined, undefined, undefined, err);
|
|
680
|
+
const execute = async (u, wrap) => {
|
|
681
|
+
try {
|
|
682
|
+
if (wrap) {
|
|
683
|
+
await this.driver.transaction(async () => {
|
|
684
|
+
await runOne(u);
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
await runOne(u);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
throw failure(u.name, err);
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
const mode = this.transactionMode();
|
|
696
|
+
if (options?.fake) {
|
|
697
|
+
// nothing is executed, so there is no schema change worth wrapping
|
|
698
|
+
for (const u of toRun) {
|
|
699
|
+
await execute(u, false);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
else if (mode === interfaces_js_1.MigrationTransactionMode.PerRun) {
|
|
703
|
+
// one transaction per stretch of consecutive non-opted-out migrations, exactly as `up()`
|
|
704
|
+
// segments. Refusing the whole rollback instead would leave a PerRun connection holding
|
|
705
|
+
// one `transaction = false` migration able to up() but never able to down() as a whole
|
|
706
|
+
const queue = [...toRun];
|
|
707
|
+
while (queue.length > 0) {
|
|
708
|
+
const head = queue.shift();
|
|
709
|
+
if (optedOut(head)) {
|
|
710
|
+
await execute(head, false);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const segment = [head];
|
|
714
|
+
while (queue.length > 0 && !optedOut(queue[0])) {
|
|
715
|
+
segment.push(queue.shift());
|
|
716
|
+
}
|
|
717
|
+
let current = head;
|
|
718
|
+
try {
|
|
719
|
+
await this.driver.transaction(async () => {
|
|
720
|
+
for (const u of segment) {
|
|
721
|
+
current = u;
|
|
722
|
+
await runOne(u);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
throw failure(current.name, err);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
else if (mode === interfaces_js_1.MigrationTransactionMode.PerMigration) {
|
|
732
|
+
for (const u of toRun) {
|
|
733
|
+
await execute(u, !optedOut(u));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
for (const u of toRun) {
|
|
738
|
+
await execute(u, false);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return executed;
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
async status(units) {
|
|
745
|
+
await this.ensureStorage();
|
|
746
|
+
// deliberately unlocked, unlike up()/down(): this is a read-only report and taking the lock
|
|
747
|
+
// would make it block for the whole Timeout behind any running migration - exactly when
|
|
748
|
+
// somebody is asking what is going on
|
|
749
|
+
const records = await this.records();
|
|
750
|
+
// read once for the whole report rather than per entry: every row on this connection is
|
|
751
|
+
// judged against the same "is anything running right now?", and asking N times would let the
|
|
752
|
+
// answer change halfway down a single report
|
|
753
|
+
const running = await this.runInProgress();
|
|
754
|
+
return units.map((u) => {
|
|
755
|
+
const rec = records.find((r) => r.Migration === u.name);
|
|
756
|
+
const applied = !!(rec?.FinishedAt && !rec.RolledBackAt);
|
|
757
|
+
const failed = !!(rec && !rec.FinishedAt && rec.Logs);
|
|
758
|
+
return {
|
|
759
|
+
name: u.name,
|
|
760
|
+
connection: this.driver.Options.Name,
|
|
761
|
+
applied,
|
|
762
|
+
failed,
|
|
763
|
+
rolledBack: !!rec?.RolledBackAt,
|
|
764
|
+
pending: !applied && !failed,
|
|
765
|
+
// a live run legitimately holds an open row, so it is only "interrupted" once nobody is
|
|
766
|
+
// there to close it
|
|
767
|
+
interrupted: !!rec && !running && this.isInterrupted(rec),
|
|
768
|
+
batch: rec?.Batch ?? null,
|
|
769
|
+
startedAt: rec?.StartedAt ?? null,
|
|
770
|
+
finishedAt: rec?.FinishedAt ?? null,
|
|
771
|
+
checksumMismatch: !!(rec?.Checksum && rec.Checksum !== migrationChecksum(u.type)),
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async resolve(name, action, unit) {
|
|
776
|
+
await this.ensureStorage();
|
|
777
|
+
// deliberately unlocked, unlike up()/down(): this is the recovery path for a run that died,
|
|
778
|
+
// quite possibly while holding the lock. Blocking it behind that lock would shut the only
|
|
779
|
+
// door left open
|
|
780
|
+
const records = await this.records();
|
|
781
|
+
const rec = records.find((r) => r.Migration === name);
|
|
782
|
+
// Two shapes may be forced, and they are the two whose real outcome nobody recorded: FAILED
|
|
783
|
+
// ( FinishedAt NULL, Logs set ) and INTERRUPTED ( StartedAt set, neither FinishedAt nor Logs -
|
|
784
|
+
// a run killed before it could write either ). Anything else is healthy, rolled back or
|
|
785
|
+
// absent, and rewriting it would destroy state nobody asked to lose.
|
|
786
|
+
//
|
|
787
|
+
// Deliberately without a lock check, exactly as the method comment above says: this is the
|
|
788
|
+
// recovery path for a run that died, quite possibly still holding the lock. The cost is that
|
|
789
|
+
// an operator who forces a row while a run is genuinely in progress overwrites it - in a
|
|
790
|
+
// deployment running one migration process at a time, which is the supported shape, the run
|
|
791
|
+
// in progress is the operator's own.
|
|
792
|
+
if (!rec || (!(!rec.FinishedAt && rec.Logs) && !this.isInterrupted(rec))) {
|
|
793
|
+
throw new exceptions_js_1.OrmException(`Migration ${name} is not in failed state on connection ${this.driver.Options.Name}, and was not interrupted either - nothing to resolve`);
|
|
794
|
+
}
|
|
795
|
+
if (action === 'applied') {
|
|
796
|
+
// A batch has to be stamped here, exactly as `markFinished` would have: `upsertStart`
|
|
797
|
+
// inserts `Batch: 0` and a first-time failure never reached `markFinished`, so a row
|
|
798
|
+
// resolved without this stays at 0. `down()`'s default target is max(Batch) among applied
|
|
799
|
+
// rows, so a batch-0 row is silently excluded from every default rollback the moment any
|
|
800
|
+
// other row carries a real batch - reachable only via `{ all: true }`. Same rule as `up()`:
|
|
801
|
+
// one past the highest applied batch, which also puts it in the next default rollback.
|
|
802
|
+
const batch = Math.max(0, ...records.filter((r) => r.FinishedAt && !r.RolledBackAt).map((r) => r.Batch ?? 0)) + 1;
|
|
803
|
+
const patch = { FinishedAt: new Date(), Batch: batch };
|
|
804
|
+
// The checksum can only come from the migration class, which this call does not have
|
|
805
|
+
// unless the caller supplies it. Without it the column stays NULL and drift can never be
|
|
806
|
+
// reported for this migration - a NULL is preferable to inventing a fingerprint for
|
|
807
|
+
// source that was never verified against the database.
|
|
808
|
+
if (unit) {
|
|
809
|
+
patch.Checksum = migrationChecksum(unit.type);
|
|
810
|
+
}
|
|
811
|
+
await this.driver.update().in(this.table).update(patch).where({ Migration: name });
|
|
812
|
+
this.Log.info(`Migration ${name} resolved as applied (batch ${batch})${unit ? '' : ' - Checksum left NULL, drift cannot be detected for it'}`);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
// Logs is cleared, not merely annotated with RolledBackAt. Failed state is the pair
|
|
816
|
+
// `FinishedAt` NULL *and* `Logs` set, and a rolled-back resolution leaves FinishedAt
|
|
817
|
+
// NULL - so a row that kept its Logs would still trip `assertNoFailed` and block every
|
|
818
|
+
// later run, which is the exact thing this call exists to undo.
|
|
819
|
+
await this.driver.update().in(this.table).update({ RolledBackAt: new Date(), Logs: null }).where({ Migration: name });
|
|
820
|
+
this.Log.info(`Migration ${name} resolved as rolled-back (pending again)`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
exports.DefaultMigrationService = DefaultMigrationService;
|
|
825
|
+
__decorate([
|
|
826
|
+
(0, log_common_1.Logger)('ORM'),
|
|
827
|
+
__metadata("design:type", log_common_1.Log)
|
|
828
|
+
], DefaultMigrationService.prototype, "Log", void 0);
|
|
829
|
+
//# sourceMappingURL=migration-service.js.map
|