@proteinjs/db 1.49.0 → 1.50.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/CHANGELOG.md +27 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/MigrationRunner.d.ts +96 -1
- package/dist/src/MigrationRunner.d.ts.map +1 -1
- package/dist/src/MigrationRunner.js +285 -48
- package/dist/src/MigrationRunner.js.map +1 -1
- package/dist/src/Table.d.ts +5 -1
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +26 -0
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.js.map +1 -1
- package/dist/test/MigrationPlan.test.d.ts +2 -0
- package/dist/test/MigrationPlan.test.d.ts.map +1 -0
- package/dist/test/MigrationPlan.test.js +448 -0
- package/dist/test/MigrationPlan.test.js.map +1 -0
- package/dist/test/reusable/CrudTests.d.ts.map +1 -1
- package/dist/test/reusable/CrudTests.js +72 -0
- package/dist/test/reusable/CrudTests.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/package.json +3 -3
- package/src/MigrationRunner.ts +276 -39
- package/src/Table.ts +5 -1
- package/src/tables/MigrationTable.ts +27 -0
- package/test/MigrationPlan.test.ts +337 -0
- package/test/reusable/CrudTests.ts +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.0",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"test": "jest --passWithNoTests"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proteinjs/db-query": "^1.10.
|
|
44
|
+
"@proteinjs/db-query": "^1.10.2",
|
|
45
45
|
"@proteinjs/logger": "^1.2.0",
|
|
46
46
|
"@proteinjs/reflection": "^1.2.0",
|
|
47
47
|
"@proteinjs/serializer": "^1.1.10",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "3b2c70b4497cc965fa829726a88fdcd2e1f2377d"
|
|
70
70
|
}
|
package/src/MigrationRunner.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { SourceRecordRepo } from './source/SourceRecordRepo';
|
|
|
5
5
|
import { SourceRecordSyncRunner } from './source/SourceRecordSyncRunner';
|
|
6
6
|
import { getSourceRecordLoaders } from './source/SourceRecord';
|
|
7
7
|
import { MigrationRunnerService, getMigrationRunnerService } from './services/MigrationRunnerService';
|
|
8
|
-
import { Migration, MigrationTable } from './tables/MigrationTable';
|
|
8
|
+
import { Migration, MigrationEstimate, MigrationTable } from './tables/MigrationTable';
|
|
9
9
|
import { QueryBuilderFactory } from './QueryBuilderFactory';
|
|
10
10
|
import { TableManager } from './schema/TableManager';
|
|
11
11
|
import { Service } from '@proteinjs/service';
|
|
@@ -43,6 +43,62 @@ export interface MigrationSeriesSummary {
|
|
|
43
43
|
notAttempted: string[];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The plan for the pending series, read BEFORE anything runs: each pending migration's estimate
|
|
48
|
+
* ({@link Migration.estimate}) and the projection over the ones that declared seconds per unit.
|
|
49
|
+
* {@link MigrationRunner.planPendingMigrations} returns it without applying anything (the dry
|
|
50
|
+
* run); {@link MigrationRunner.runPendingMigrations} logs it before its first run. Ids appear in
|
|
51
|
+
* the order the series would run them.
|
|
52
|
+
*/
|
|
53
|
+
export interface MigrationPlan {
|
|
54
|
+
/** The migrations the series would run, in order, each with its estimate when declared. */
|
|
55
|
+
pending: MigrationPlanEntry[];
|
|
56
|
+
/** Seconds projected over the entries that declared `secondsPerUnit` — the deadline's input. */
|
|
57
|
+
projectedSeconds: number;
|
|
58
|
+
/** How many entries the projection covers. */
|
|
59
|
+
projected: number;
|
|
60
|
+
/** Ids the projection does NOT cover: no estimate declared, or no seconds per unit. */
|
|
61
|
+
unprojected: string[];
|
|
62
|
+
/** Excluded by the explicit `manual` flag. */
|
|
63
|
+
skippedManual: string[];
|
|
64
|
+
/** Already in 'success' status. */
|
|
65
|
+
alreadyApplied: string[];
|
|
66
|
+
/** Ledger rows with no source record in this build (the run stamps these retired; the plan only reports). */
|
|
67
|
+
unresolved: string[];
|
|
68
|
+
/** Rows carrying `retired: true`. */
|
|
69
|
+
retired: string[];
|
|
70
|
+
/**
|
|
71
|
+
* True when the ledger table does not exist: a database no schema sync has touched, so every
|
|
72
|
+
* declared migration runs over empty tables — the estimates are not read (nothing to count).
|
|
73
|
+
*/
|
|
74
|
+
freshDatabase: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MigrationPlanEntry {
|
|
78
|
+
id: string;
|
|
79
|
+
description: string;
|
|
80
|
+
/**
|
|
81
|
+
* Runs during `Db.init()` before schema sync rather than in the series
|
|
82
|
+
* ({@link Migration.preSchemaSync}) — planned all the same: a deploy's deadline covers init.
|
|
83
|
+
*/
|
|
84
|
+
preSchemaSync?: boolean;
|
|
85
|
+
estimate?: MigrationEstimate;
|
|
86
|
+
/** `ceil(units × secondsPerUnit)` when the estimate declares seconds per unit. */
|
|
87
|
+
projectedSeconds?: number;
|
|
88
|
+
/** Why the entry carries no projection, in the words the plan line uses. */
|
|
89
|
+
unprojectedReason?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The classification of a ledger row against this build's source declarations. */
|
|
93
|
+
interface LedgerClassification {
|
|
94
|
+
/** The source declarations that would run, in series order. */
|
|
95
|
+
pending: Migration[];
|
|
96
|
+
skippedManual: string[];
|
|
97
|
+
alreadyApplied: string[];
|
|
98
|
+
unresolved: string[];
|
|
99
|
+
retired: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
46
102
|
export class MigrationRunner implements MigrationRunnerService {
|
|
47
103
|
private logger = new Logger({ name: this.constructor.name });
|
|
48
104
|
public serviceMetadata: Service['serviceMetadata'] = {
|
|
@@ -176,6 +232,9 @@ export class MigrationRunner implements MigrationRunnerService {
|
|
|
176
232
|
* build — until un-retired on the Migrations page; skipped, reported as `retired`.
|
|
177
233
|
* - the FIRST failure STOPS the series (later migrations may build on earlier ones). The
|
|
178
234
|
* caller exits non-zero, the Job fails, the rollout does not advance.
|
|
235
|
+
* - the PLAN comes first: before the first run, every pending migration's estimate
|
|
236
|
+
* ({@link Migration.estimate}) is logged as one line and the series' total as another — the
|
|
237
|
+
* same lines {@link planPendingMigrations} produces on its own for a dry run.
|
|
179
238
|
*
|
|
180
239
|
* EXPAND-CONTRACT INVARIANT (documented at this seam on purpose): every automated migration —
|
|
181
240
|
* and the schema sync that fronts it — must be backward-compatible with the STILL-RUNNING old
|
|
@@ -188,56 +247,40 @@ export class MigrationRunner implements MigrationRunnerService {
|
|
|
188
247
|
async runPendingMigrations(): Promise<MigrationSeriesSummary> {
|
|
189
248
|
const migrationTable: Table<Migration> = new MigrationTable();
|
|
190
249
|
const db = getDbAsSystem();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
250
|
+
const ledger = await db.query(migrationTable, this.ledgerInSeriesOrder(migrationTable));
|
|
251
|
+
const sourceRecordRepo = new SourceRecordRepo();
|
|
252
|
+
const classified = this.classifyLedger(ledger, (id) =>
|
|
253
|
+
sourceRecordRepo.getSourceRecord<Migration>(migrationTable.name, id)
|
|
254
|
+
);
|
|
255
|
+
for (const id of classified.unresolved) {
|
|
256
|
+
// Stamp, don't just skip: the ledger must remember the source class was gone. If the
|
|
257
|
+
// class ships again in a later build, the row stays excluded until a human un-retires it
|
|
258
|
+
// on the Migrations page — a returned loader id is not consent to auto-run.
|
|
259
|
+
await db.update(migrationTable, { id, retired: true } as Partial<Migration>);
|
|
260
|
+
}
|
|
196
261
|
|
|
197
262
|
const summary: MigrationSeriesSummary = {
|
|
198
263
|
applied: [],
|
|
199
|
-
skippedManual:
|
|
200
|
-
alreadyApplied:
|
|
201
|
-
unresolved:
|
|
202
|
-
retired:
|
|
264
|
+
skippedManual: classified.skippedManual,
|
|
265
|
+
alreadyApplied: classified.alreadyApplied,
|
|
266
|
+
unresolved: classified.unresolved,
|
|
267
|
+
retired: classified.retired,
|
|
203
268
|
notAttempted: [],
|
|
204
269
|
};
|
|
205
|
-
const
|
|
206
|
-
const pending: Migration[] = [];
|
|
207
|
-
for (const row of ledger) {
|
|
208
|
-
if (row.retired) {
|
|
209
|
-
summary.retired.push(row.id);
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
const source = sourceRecordRepo.getSourceRecord<Migration>(migrationTable.name, row.id);
|
|
213
|
-
if (!source) {
|
|
214
|
-
// Stamp, don't just skip: the ledger must remember the source class was gone. If the
|
|
215
|
-
// class ships again in a later build, the row stays excluded until a human un-retires it
|
|
216
|
-
// on the Migrations page — a returned loader id is not consent to auto-run.
|
|
217
|
-
await db.update(migrationTable, { id: row.id, retired: true } as Partial<Migration>);
|
|
218
|
-
summary.unresolved.push(row.id);
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
if (source.manual) {
|
|
222
|
-
summary.skippedManual.push(row.id);
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
if (row.status === 'success') {
|
|
226
|
-
summary.alreadyApplied.push(row.id);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
pending.push(row);
|
|
230
|
-
}
|
|
231
|
-
|
|
270
|
+
const pending = classified.pending;
|
|
232
271
|
this.logger.info({
|
|
233
272
|
message: `Running ${pending.length} pending migration${pending.length === 1 ? '' : 's'} in series, oldest-first`,
|
|
234
273
|
obj: {
|
|
235
|
-
pending: pending.map((
|
|
274
|
+
pending: pending.map((migration) => migration.id),
|
|
236
275
|
skippedManual: summary.skippedManual,
|
|
237
276
|
skippedRetired: summary.retired,
|
|
238
277
|
stampedRetired: summary.unresolved,
|
|
239
278
|
},
|
|
240
279
|
});
|
|
280
|
+
// The plan, BEFORE the first run: every pending migration's estimate as one line, then the
|
|
281
|
+
// total — the reader of the Job's log knows what the series is about to do and how long the
|
|
282
|
+
// projection says it takes, before a row changes.
|
|
283
|
+
await this.estimateSeries(pending, { ...classified, freshDatabase: false });
|
|
241
284
|
for (let i = 0; i < pending.length; i++) {
|
|
242
285
|
const outcome = await this.ensureMigrationRun(pending[i].id);
|
|
243
286
|
if (outcome.status === 'success') {
|
|
@@ -249,7 +292,7 @@ export class MigrationRunner implements MigrationRunnerService {
|
|
|
249
292
|
description: outcome.description,
|
|
250
293
|
failureMessage: outcome.failureMessage,
|
|
251
294
|
};
|
|
252
|
-
summary.notAttempted = pending.slice(i + 1).map((
|
|
295
|
+
summary.notAttempted = pending.slice(i + 1).map((migration) => migration.id);
|
|
253
296
|
break;
|
|
254
297
|
}
|
|
255
298
|
|
|
@@ -257,6 +300,52 @@ export class MigrationRunner implements MigrationRunnerService {
|
|
|
257
300
|
return summary;
|
|
258
301
|
}
|
|
259
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The DRY RUN — the plan and nothing else: what {@link runPendingMigrations} would run, with
|
|
305
|
+
* each migration's estimate ({@link Migration.estimate}, read-only by contract) and the
|
|
306
|
+
* projection over the ones that declared seconds per unit. Applies NOTHING: no `Db.init()`
|
|
307
|
+
* (the deploy entrypoint calls this INSTEAD of init — init would create the database, run the
|
|
308
|
+
* pre-schema-sync migrations, sync the schema and sync the source records), no run, no ledger
|
|
309
|
+
* write — an unresolved row is reported, not stamped.
|
|
310
|
+
*
|
|
311
|
+
* Runs before init by construction, so the declarations come from the BUILD (the reflection
|
|
312
|
+
* source-record loaders for the migration table) joined to the ledger as it stands: a row's
|
|
313
|
+
* status says whether its migration is pending; a declaration with no row yet is what this
|
|
314
|
+
* release's source sync will insert as 'proposed' — pending, planned after the ledger's own
|
|
315
|
+
* pending rows (their `created` is older than a row born at the next init), in id order among
|
|
316
|
+
* themselves. Source records already registered in this process (init has run, or a test
|
|
317
|
+
* planted them) take precedence over the build's declaration of the same id — the same object
|
|
318
|
+
* after init.
|
|
319
|
+
*
|
|
320
|
+
* A database the schema sync has never touched has no ledger table: every declared migration
|
|
321
|
+
* is listed, no estimate is read (their tables do not exist either — nothing to count), and
|
|
322
|
+
* the plan says `freshDatabase`. An estimate that throws fails the plan loudly — a plan that
|
|
323
|
+
* cannot count is not a plan.
|
|
324
|
+
*/
|
|
325
|
+
async planPendingMigrations(): Promise<MigrationPlan> {
|
|
326
|
+
const migrationTable: Table<Migration> = new MigrationTable();
|
|
327
|
+
const db = getDbAsSystem();
|
|
328
|
+
const declared = this.declaredMigrations(migrationTable);
|
|
329
|
+
const sourceRecordRepo = new SourceRecordRepo();
|
|
330
|
+
const sourceOf = (id: string) =>
|
|
331
|
+
sourceRecordRepo.getSourceRecord<Migration>(migrationTable.name, id) ?? declared.get(id);
|
|
332
|
+
const freshDatabase = !(await db.tableExists(migrationTable));
|
|
333
|
+
const ledger = freshDatabase ? [] : await db.query(migrationTable, this.ledgerInSeriesOrder(migrationTable));
|
|
334
|
+
const classified = this.classifyLedger(ledger, sourceOf);
|
|
335
|
+
const inLedger = new Set(ledger.map((row) => row.id));
|
|
336
|
+
const unsynced = Array.from(declared.values())
|
|
337
|
+
.filter((migration) => !inLedger.has(migration.id))
|
|
338
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
339
|
+
for (const migration of unsynced) {
|
|
340
|
+
if (migration.manual) {
|
|
341
|
+
classified.skippedManual.push(migration.id);
|
|
342
|
+
} else {
|
|
343
|
+
classified.pending.push(migration);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return await this.estimateSeries(classified.pending, { ...classified, freshDatabase });
|
|
347
|
+
}
|
|
348
|
+
|
|
260
349
|
// The db is taken as a provider, resolved inside this async body: on the service path,
|
|
261
350
|
// constructing the Db is itself run infrastructure — its failure must REJECT the detached
|
|
262
351
|
// promise, not throw synchronously from runMigration (only a bogus id may reach the client).
|
|
@@ -346,4 +435,152 @@ export class MigrationRunner implements MigrationRunnerService {
|
|
|
346
435
|
|
|
347
436
|
return parts.join(' ');
|
|
348
437
|
}
|
|
438
|
+
|
|
439
|
+
/** The ledger in SERIES order: oldest-first by the row's `created`, id tiebreak. */
|
|
440
|
+
private ledgerInSeriesOrder(migrationTable: Table<Migration>) {
|
|
441
|
+
return new QueryBuilderFactory().createQueryBuilder(migrationTable).sort([
|
|
442
|
+
{ field: 'created', desc: false },
|
|
443
|
+
{ field: 'id', desc: false },
|
|
444
|
+
]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* One owner of "what does a ledger row mean for the series": retired rows are skipped; rows
|
|
449
|
+
* with no source record are unresolved (the RUN stamps them retired; the plan reports them);
|
|
450
|
+
* `manual` declarations are excluded; 'success' rows are already applied; anything else is
|
|
451
|
+
* pending — including 'running' (a crashed earlier run). Pure over the rows: no writes.
|
|
452
|
+
*/
|
|
453
|
+
private classifyLedger(ledger: Migration[], sourceOf: (id: string) => Migration | undefined): LedgerClassification {
|
|
454
|
+
const classified: LedgerClassification = {
|
|
455
|
+
pending: [],
|
|
456
|
+
skippedManual: [],
|
|
457
|
+
alreadyApplied: [],
|
|
458
|
+
unresolved: [],
|
|
459
|
+
retired: [],
|
|
460
|
+
};
|
|
461
|
+
for (const row of ledger) {
|
|
462
|
+
if (row.retired) {
|
|
463
|
+
classified.retired.push(row.id);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const source = sourceOf(row.id);
|
|
467
|
+
if (!source) {
|
|
468
|
+
classified.unresolved.push(row.id);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (source.manual) {
|
|
472
|
+
classified.skippedManual.push(row.id);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (row.status === 'success') {
|
|
476
|
+
classified.alreadyApplied.push(row.id);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
classified.pending.push(source);
|
|
480
|
+
}
|
|
481
|
+
return classified;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** This build's migration declarations by id — the reflection source-record loaders for the migration table. */
|
|
485
|
+
private declaredMigrations(migrationTable: Table<Migration>): Map<string, Migration> {
|
|
486
|
+
const declared = new Map<string, Migration>();
|
|
487
|
+
for (const { loader } of getSourceRecordLoaders<Migration>()) {
|
|
488
|
+
if (loader.table.name === migrationTable.name) {
|
|
489
|
+
const migration = loader.record as Migration;
|
|
490
|
+
declared.set(migration.id, migration);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return declared;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The plan's lines: each pending migration's estimate (read here — the one call of
|
|
498
|
+
* {@link Migration.estimate}), logged as one line, then the total. Read-only by the door's
|
|
499
|
+
* contract; nothing here writes.
|
|
500
|
+
*/
|
|
501
|
+
private async estimateSeries(
|
|
502
|
+
pending: Migration[],
|
|
503
|
+
context: Omit<MigrationPlan, 'pending' | 'projectedSeconds' | 'projected' | 'unprojected'>
|
|
504
|
+
): Promise<MigrationPlan> {
|
|
505
|
+
const plan: MigrationPlan = {
|
|
506
|
+
pending: [],
|
|
507
|
+
projectedSeconds: 0,
|
|
508
|
+
projected: 0,
|
|
509
|
+
unprojected: [],
|
|
510
|
+
skippedManual: context.skippedManual,
|
|
511
|
+
alreadyApplied: context.alreadyApplied,
|
|
512
|
+
unresolved: context.unresolved,
|
|
513
|
+
retired: context.retired,
|
|
514
|
+
freshDatabase: context.freshDatabase,
|
|
515
|
+
};
|
|
516
|
+
for (const migration of pending) {
|
|
517
|
+
const entry: MigrationPlanEntry = {
|
|
518
|
+
id: migration.id,
|
|
519
|
+
description: migration.description,
|
|
520
|
+
...(migration.preSchemaSync ? { preSchemaSync: true } : {}),
|
|
521
|
+
};
|
|
522
|
+
if (context.freshDatabase) {
|
|
523
|
+
entry.unprojectedReason = 'fresh database, nothing to count';
|
|
524
|
+
} else if (!migration.estimate) {
|
|
525
|
+
entry.unprojectedReason = 'no estimate declared';
|
|
526
|
+
} else {
|
|
527
|
+
entry.estimate = await this.readEstimate(migration);
|
|
528
|
+
if (entry.estimate.secondsPerUnit === undefined) {
|
|
529
|
+
entry.unprojectedReason = 'no seconds per unit declared';
|
|
530
|
+
} else {
|
|
531
|
+
entry.projectedSeconds = Math.ceil(entry.estimate.units * entry.estimate.secondsPerUnit);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (entry.projectedSeconds === undefined) {
|
|
535
|
+
plan.unprojected.push(entry.id);
|
|
536
|
+
} else {
|
|
537
|
+
plan.projected++;
|
|
538
|
+
plan.projectedSeconds += entry.projectedSeconds;
|
|
539
|
+
}
|
|
540
|
+
plan.pending.push(entry);
|
|
541
|
+
this.logger.info({ message: this.planLine(entry) });
|
|
542
|
+
}
|
|
543
|
+
const without =
|
|
544
|
+
plan.unprojected.length > 0
|
|
545
|
+
? `, ${plan.unprojected.length} without a projection (${plan.unprojected.join(', ')})`
|
|
546
|
+
: '';
|
|
547
|
+
this.logger.info({
|
|
548
|
+
message:
|
|
549
|
+
`Migration plan total: ${plan.pending.length} pending, ≈ ${plan.projectedSeconds} s projected over ` +
|
|
550
|
+
`${plan.projected} of them${without}`,
|
|
551
|
+
obj: plan as any,
|
|
552
|
+
});
|
|
553
|
+
return plan;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The one call of {@link Migration.estimate}. A throw fails the plan loudly, NAMING the migration:
|
|
558
|
+
* the reader of a plan that could not count must know which count failed, and the estimate's own
|
|
559
|
+
* message ("count query failed") does not say.
|
|
560
|
+
*/
|
|
561
|
+
private async readEstimate(migration: Migration): Promise<MigrationEstimate> {
|
|
562
|
+
try {
|
|
563
|
+
return await migration.estimate!();
|
|
564
|
+
} catch (error: any) {
|
|
565
|
+
const failure = new Error(
|
|
566
|
+
`Migration plan: (${migration.id}) ${migration.description} — the estimate failed: ${error?.message ?? error}`
|
|
567
|
+
);
|
|
568
|
+
(failure as Error & { cause?: unknown }).cause = error;
|
|
569
|
+
throw failure;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** One line per pending migration: the count, the unit, the projection when there is one, the note. */
|
|
574
|
+
private planLine(entry: MigrationPlanEntry): string {
|
|
575
|
+
const head = `Migration plan: (${entry.id}) ${entry.description}${entry.preSchemaSync ? ' [runs at init, before schema sync]' : ''} — `;
|
|
576
|
+
if (!entry.estimate) {
|
|
577
|
+
return `${head}${entry.unprojectedReason}`;
|
|
578
|
+
}
|
|
579
|
+
const { units, unit, secondsPerUnit, note } = entry.estimate;
|
|
580
|
+
const projection =
|
|
581
|
+
entry.projectedSeconds === undefined
|
|
582
|
+
? entry.unprojectedReason
|
|
583
|
+
: `≈ ${entry.projectedSeconds} s at ${secondsPerUnit} s per unit`;
|
|
584
|
+
return `${head}${units} ${unit}, ${projection}${note ? ` (${note})` : ''}`;
|
|
585
|
+
}
|
|
349
586
|
}
|
package/src/Table.ts
CHANGED
|
@@ -217,8 +217,12 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
|
|
|
217
217
|
};
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* A record's data fields: its methods are not columns — a required one (`run`) and an OPTIONAL
|
|
222
|
+
* one (`estimate?`, whose type is the function or `undefined`) alike.
|
|
223
|
+
*/
|
|
220
224
|
type ExcludeFunctions<T> = {
|
|
221
|
-
[P in keyof T as T[P] extends Function ? never : P]: T[P];
|
|
225
|
+
[P in keyof T as NonNullable<T[P]> extends Function ? never : P]: T[P];
|
|
222
226
|
};
|
|
223
227
|
|
|
224
228
|
type RequiredProps<T> = {
|
|
@@ -3,6 +3,26 @@ import { BooleanColumn, DateTimeColumn, ObjectColumn, StringColumn } from '../Co
|
|
|
3
3
|
import { Table } from '../Table';
|
|
4
4
|
import { SourceRecord, withSourceRecordColumns } from '../source/SourceRecord';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* What a migration's run would touch, counted READ-ONLY before the series runs: the plan a
|
|
8
|
+
* deploy reads before it commits to the run ({@link MigrationRunner.planPendingMigrations}), and
|
|
9
|
+
* the number a caller sizes the run's deadline from.
|
|
10
|
+
*/
|
|
11
|
+
export interface MigrationEstimate {
|
|
12
|
+
/** How many units the run would touch — zero when nothing is left to do (a rerun over converted data). */
|
|
13
|
+
units: number;
|
|
14
|
+
/** The unit's name, a plural noun ("roots", "rows"). */
|
|
15
|
+
unit: string;
|
|
16
|
+
/**
|
|
17
|
+
* Seconds one unit takes, when the migration has measured it (a rehearsal over a copy of a live
|
|
18
|
+
* database); the plan projects `units × secondsPerUnit`. Absent, the plan reports the count and
|
|
19
|
+
* no projection.
|
|
20
|
+
*/
|
|
21
|
+
secondsPerUnit?: number;
|
|
22
|
+
/** Where the numbers come from, and anything the reader of the plan should know. */
|
|
23
|
+
note?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
export interface Migration extends SourceRecord {
|
|
7
27
|
/**
|
|
8
28
|
* The declaring loader's class name (`BackfillOnboardingStateForExistingAccounts`) — the
|
|
@@ -61,6 +81,13 @@ export interface Migration extends SourceRecord {
|
|
|
61
81
|
duration?: string;
|
|
62
82
|
output?: any;
|
|
63
83
|
run: () => Promise<any | void>;
|
|
84
|
+
/**
|
|
85
|
+
* The estimate door: a READ-ONLY count of the work `run` would do ({@link MigrationEstimate}).
|
|
86
|
+
* Optional — the plan logs "no estimate declared" without it. Read before the series runs (the
|
|
87
|
+
* plan lines {@link MigrationRunner.runPendingMigrations} logs before its first run) and by the
|
|
88
|
+
* dry run that applies nothing ({@link MigrationRunner.planPendingMigrations}). Never writes.
|
|
89
|
+
*/
|
|
90
|
+
estimate?: () => Promise<MigrationEstimate>;
|
|
64
91
|
}
|
|
65
92
|
|
|
66
93
|
export class MigrationTable extends Table<Migration> {
|