@proteinjs/db 1.49.1 → 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 +16 -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/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/package.json +2 -2
- 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
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// EnvInfo.isDev() requires a GlobalDataStorage implementation that only exists in a running app
|
|
2
|
+
// (the same mock the containment suite uses).
|
|
3
|
+
jest.mock('@proteinjs/server-api', () => ({
|
|
4
|
+
EnvInfo: { isDev: () => true },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The world the runner sees, planted per test: the system db (a ledger in memory — the runner's
|
|
9
|
+
* every read and write lands here, so writes are OBSERVABLE) and the build's declared migrations
|
|
10
|
+
* (the reflection source-record loaders for the migration table, which this harness has no
|
|
11
|
+
* reflection graph to serve).
|
|
12
|
+
*/
|
|
13
|
+
const mockWorld = {
|
|
14
|
+
db: undefined as unknown as FakeLedgerDb,
|
|
15
|
+
loaders: [] as { loader: { table: { name: string }; record: unknown } }[],
|
|
16
|
+
};
|
|
17
|
+
jest.mock('../src/Db', () => ({
|
|
18
|
+
...jest.requireActual('../src/Db'),
|
|
19
|
+
getDbAsSystem: () => mockWorld.db,
|
|
20
|
+
}));
|
|
21
|
+
jest.mock('../src/source/SourceRecord', () => ({
|
|
22
|
+
...jest.requireActual('../src/source/SourceRecord'),
|
|
23
|
+
getSourceRecordLoaders: () => mockWorld.loaders,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { Logger, Log, DefaultLogWriter } from '@proteinjs/logger';
|
|
27
|
+
import { MigrationRunner } from '../src/MigrationRunner';
|
|
28
|
+
import { Migration, MigrationEstimate, MigrationTable } from '../src/tables/MigrationTable';
|
|
29
|
+
import { SourceRecordRepo } from '../src/source/SourceRecordRepo';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The plan before the run (the estimate door): every pending migration's estimate is logged as
|
|
33
|
+
* one line BEFORE the series' first run, a migration without an estimate says so, and the dry run
|
|
34
|
+
* (`planPendingMigrations`) reads the estimates and applies nothing — no run, no ledger write.
|
|
35
|
+
*
|
|
36
|
+
* Pinned on OUTCOMES: the order of events (log lines vs. run effects) in one shared list, the
|
|
37
|
+
* writes the fake ledger received, the plan's own numbers.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** A migration ledger in memory, with the reads and writes the runner makes. */
|
|
41
|
+
class FakeLedgerDb {
|
|
42
|
+
rows: Migration[] = [];
|
|
43
|
+
writes: Partial<Migration>[] = [];
|
|
44
|
+
exists = true;
|
|
45
|
+
async tableExists(): Promise<boolean> {
|
|
46
|
+
return this.exists;
|
|
47
|
+
}
|
|
48
|
+
async query(): Promise<Migration[]> {
|
|
49
|
+
return this.rows.map((row) => ({ ...row }));
|
|
50
|
+
}
|
|
51
|
+
async get(_table: unknown, { id }: { id: string }): Promise<Migration | undefined> {
|
|
52
|
+
const row = this.rows.find((candidate) => candidate.id === id);
|
|
53
|
+
return row ? { ...row } : undefined;
|
|
54
|
+
}
|
|
55
|
+
async update(_table: unknown, partial: Partial<Migration>): Promise<void> {
|
|
56
|
+
this.writes.push(partial);
|
|
57
|
+
const row = this.rows.find((candidate) => candidate.id === partial.id);
|
|
58
|
+
if (row) {
|
|
59
|
+
Object.assign(row, partial);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type RunnerInternals = { logger: Logger };
|
|
65
|
+
|
|
66
|
+
describe('MigrationRunner — the plan before the run', () => {
|
|
67
|
+
const migrationTable = new MigrationTable();
|
|
68
|
+
let events: string[];
|
|
69
|
+
let db: FakeLedgerDb;
|
|
70
|
+
let plantedIds: string[];
|
|
71
|
+
|
|
72
|
+
/** A source declaration registered in this process (what init's source sync registers). */
|
|
73
|
+
const plant = (
|
|
74
|
+
id: string,
|
|
75
|
+
overrides: Partial<Migration> & { estimate?: () => Promise<MigrationEstimate> } = {}
|
|
76
|
+
): Migration => {
|
|
77
|
+
const migration = {
|
|
78
|
+
id,
|
|
79
|
+
description: `plan test migration ${id}`,
|
|
80
|
+
run: async () => {
|
|
81
|
+
events.push(`run:${id}`);
|
|
82
|
+
return `${id} output`;
|
|
83
|
+
},
|
|
84
|
+
...overrides,
|
|
85
|
+
} as Migration;
|
|
86
|
+
new SourceRecordRepo().loadSourceRecord(migrationTable.name, migration);
|
|
87
|
+
plantedIds.push(id);
|
|
88
|
+
return migration;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** A ledger row for a planted migration, as the source sync lands it. */
|
|
92
|
+
const ledgerRow = (
|
|
93
|
+
migration: Migration,
|
|
94
|
+
status: Migration['status'] = 'proposed',
|
|
95
|
+
extra: Partial<Migration> = {}
|
|
96
|
+
) => {
|
|
97
|
+
db.rows.push({ id: migration.id, description: migration.description, status, ...extra } as Migration);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const runner = () => {
|
|
101
|
+
const instance = new MigrationRunner();
|
|
102
|
+
(instance as unknown as RunnerInternals).logger = new Logger({
|
|
103
|
+
name: 'MigrationRunner',
|
|
104
|
+
logWriter: {
|
|
105
|
+
write: (log: Log) => {
|
|
106
|
+
events.push(`log:${log.message}`);
|
|
107
|
+
},
|
|
108
|
+
} as unknown as DefaultLogWriter,
|
|
109
|
+
});
|
|
110
|
+
return instance;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const planLines = () => events.filter((event) => event.startsWith('log:Migration plan'));
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
events = [];
|
|
117
|
+
db = new FakeLedgerDb();
|
|
118
|
+
plantedIds = [];
|
|
119
|
+
mockWorld.db = db;
|
|
120
|
+
mockWorld.loaders = [];
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
// The repo is a process-wide static: unregister what this test planted so ids never leak
|
|
125
|
+
// between tests (a stale registration would resolve a later test's ghost row).
|
|
126
|
+
const repo = new SourceRecordRepo() as unknown as { constructor: { SOURCE_RECORD_MAP: Record<string, unknown> } };
|
|
127
|
+
for (const id of plantedIds) {
|
|
128
|
+
delete repo.constructor.SOURCE_RECORD_MAP[`${migrationTable.name}:${id}`];
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('logs each pending migration’s plan line and the total BEFORE the first run, and the series still runs', async () => {
|
|
133
|
+
const estimated = plant('plan-a-estimated', {
|
|
134
|
+
estimate: async () => ({
|
|
135
|
+
units: 40,
|
|
136
|
+
unit: 'roots',
|
|
137
|
+
secondsPerUnit: 0.3,
|
|
138
|
+
note: 'measured on a rehearsal',
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
const unestimated = plant('plan-b-unestimated');
|
|
142
|
+
ledgerRow(estimated);
|
|
143
|
+
ledgerRow(unestimated);
|
|
144
|
+
|
|
145
|
+
const summary = await runner().runPendingMigrations();
|
|
146
|
+
|
|
147
|
+
expect(summary.applied).toEqual(['plan-a-estimated', 'plan-b-unestimated']);
|
|
148
|
+
expect(planLines()).toEqual([
|
|
149
|
+
'log:Migration plan: (plan-a-estimated) plan test migration plan-a-estimated — 40 roots, ≈ 12 s at 0.3 s per unit (measured on a rehearsal)',
|
|
150
|
+
'log:Migration plan: (plan-b-unestimated) plan test migration plan-b-unestimated — no estimate declared',
|
|
151
|
+
'log:Migration plan total: 2 pending, ≈ 12 s projected over 1 of them, 1 without a projection (plan-b-unestimated)',
|
|
152
|
+
]);
|
|
153
|
+
// Every plan line lands before the FIRST run effect — the reader knows the whole plan before
|
|
154
|
+
// a row changes.
|
|
155
|
+
const firstRun = events.indexOf('run:plan-a-estimated');
|
|
156
|
+
expect(firstRun).toBeGreaterThan(-1);
|
|
157
|
+
for (const line of planLines()) {
|
|
158
|
+
expect(events.indexOf(line)).toBeLessThan(firstRun);
|
|
159
|
+
}
|
|
160
|
+
expect(events.indexOf('run:plan-b-unestimated')).toBeGreaterThan(firstRun);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('the dry run reads the estimates and applies nothing — no run, no ledger write, an unresolved row reported not stamped', async () => {
|
|
164
|
+
let estimateReads = 0;
|
|
165
|
+
const pendingA = plant('dry-a', {
|
|
166
|
+
estimate: async () => {
|
|
167
|
+
estimateReads++;
|
|
168
|
+
return { units: 3, unit: 'rows', secondsPerUnit: 2 };
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
const pendingB = plant('dry-b', {
|
|
172
|
+
estimate: async () => {
|
|
173
|
+
estimateReads++;
|
|
174
|
+
return { units: 10, unit: 'rows', secondsPerUnit: 0.5 };
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
const counted = plant('dry-c-counted-only', {
|
|
178
|
+
estimate: async () => ({ units: 5, unit: 'items', note: 'no timing yet' }),
|
|
179
|
+
});
|
|
180
|
+
const applied = plant('dry-d-applied', { estimate: async () => ({ units: 999, unit: 'rows', secondsPerUnit: 9 }) });
|
|
181
|
+
const manual = plant('dry-e-manual', { manual: true } as Partial<Migration>);
|
|
182
|
+
const retired = plant('dry-f-retired');
|
|
183
|
+
ledgerRow(pendingA);
|
|
184
|
+
ledgerRow(pendingB, 'failure');
|
|
185
|
+
ledgerRow(counted, 'running');
|
|
186
|
+
ledgerRow(applied, 'success');
|
|
187
|
+
ledgerRow(manual);
|
|
188
|
+
ledgerRow(retired, 'proposed', { retired: true });
|
|
189
|
+
db.rows.push({ id: 'dry-g-ghost', description: 'loader deleted after an old release ran it' } as Migration);
|
|
190
|
+
|
|
191
|
+
const plan = await runner().planPendingMigrations();
|
|
192
|
+
|
|
193
|
+
expect(events.filter((event) => event.startsWith('run:'))).toEqual([]);
|
|
194
|
+
expect(db.writes).toEqual([]);
|
|
195
|
+
expect(estimateReads).toBe(2);
|
|
196
|
+
expect(plan.pending.map((entry) => entry.id)).toEqual(['dry-a', 'dry-b', 'dry-c-counted-only']);
|
|
197
|
+
expect(plan.pending[0]).toEqual({
|
|
198
|
+
id: 'dry-a',
|
|
199
|
+
description: 'plan test migration dry-a',
|
|
200
|
+
estimate: { units: 3, unit: 'rows', secondsPerUnit: 2 },
|
|
201
|
+
projectedSeconds: 6,
|
|
202
|
+
});
|
|
203
|
+
expect(plan.pending[1].projectedSeconds).toBe(5);
|
|
204
|
+
expect(plan.pending[2]).toEqual({
|
|
205
|
+
id: 'dry-c-counted-only',
|
|
206
|
+
description: 'plan test migration dry-c-counted-only',
|
|
207
|
+
estimate: { units: 5, unit: 'items', note: 'no timing yet' },
|
|
208
|
+
unprojectedReason: 'no seconds per unit declared',
|
|
209
|
+
});
|
|
210
|
+
expect(plan.projectedSeconds).toBe(11);
|
|
211
|
+
expect(plan.projected).toBe(2);
|
|
212
|
+
expect(plan.unprojected).toEqual(['dry-c-counted-only']);
|
|
213
|
+
expect(plan.alreadyApplied).toEqual(['dry-d-applied']);
|
|
214
|
+
expect(plan.skippedManual).toEqual(['dry-e-manual']);
|
|
215
|
+
expect(plan.retired).toEqual(['dry-f-retired']);
|
|
216
|
+
expect(plan.unresolved).toEqual(['dry-g-ghost']);
|
|
217
|
+
expect(plan.freshDatabase).toBe(false);
|
|
218
|
+
expect(planLines()).toEqual([
|
|
219
|
+
'log:Migration plan: (dry-a) plan test migration dry-a — 3 rows, ≈ 6 s at 2 s per unit',
|
|
220
|
+
'log:Migration plan: (dry-b) plan test migration dry-b — 10 rows, ≈ 5 s at 0.5 s per unit',
|
|
221
|
+
'log:Migration plan: (dry-c-counted-only) plan test migration dry-c-counted-only — 5 items, no seconds per unit declared (no timing yet)',
|
|
222
|
+
'log:Migration plan total: 3 pending, ≈ 11 s projected over 2 of them, 1 without a projection (dry-c-counted-only)',
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('plans a migration the build declares that has no ledger row yet — after the ledger’s pending rows, in id order', async () => {
|
|
227
|
+
const inLedger = plant('new-0-in-ledger', {
|
|
228
|
+
estimate: async () => ({ units: 1, unit: 'rows', secondsPerUnit: 1 }),
|
|
229
|
+
});
|
|
230
|
+
ledgerRow(inLedger);
|
|
231
|
+
const declaredOnly = (id: string, estimate?: () => Promise<MigrationEstimate>) => ({
|
|
232
|
+
loader: {
|
|
233
|
+
table: { name: migrationTable.name },
|
|
234
|
+
record: { id, description: `declared ${id}`, run: async () => events.push(`run:${id}`), estimate },
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
mockWorld.loaders = [
|
|
238
|
+
declaredOnly('new-z-later', async () => ({ units: 4, unit: 'rows', secondsPerUnit: 10, note: 'new this build' })),
|
|
239
|
+
declaredOnly('new-m-manual-later'),
|
|
240
|
+
{
|
|
241
|
+
loader: {
|
|
242
|
+
table: { name: 'some_other_source_table' },
|
|
243
|
+
record: { id: 'not-a-migration', run: async () => undefined },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
declaredOnly('new-b-earlier'),
|
|
247
|
+
];
|
|
248
|
+
(mockWorld.loaders[1].loader.record as Migration).manual = true;
|
|
249
|
+
(mockWorld.loaders[1].loader.record as Migration).preSchemaSync = false;
|
|
250
|
+
(mockWorld.loaders[3].loader.record as Migration).preSchemaSync = true;
|
|
251
|
+
|
|
252
|
+
const plan = await runner().planPendingMigrations();
|
|
253
|
+
|
|
254
|
+
expect(plan.pending.map((entry) => entry.id)).toEqual(['new-0-in-ledger', 'new-b-earlier', 'new-z-later']);
|
|
255
|
+
expect(plan.pending[1]).toEqual({
|
|
256
|
+
id: 'new-b-earlier',
|
|
257
|
+
description: 'declared new-b-earlier',
|
|
258
|
+
preSchemaSync: true,
|
|
259
|
+
unprojectedReason: 'no estimate declared',
|
|
260
|
+
});
|
|
261
|
+
expect(plan.pending[2].projectedSeconds).toBe(40);
|
|
262
|
+
expect(plan.projectedSeconds).toBe(41);
|
|
263
|
+
expect(plan.skippedManual).toEqual(['new-m-manual-later']);
|
|
264
|
+
expect(events.filter((event) => event.startsWith('run:'))).toEqual([]);
|
|
265
|
+
expect(db.writes).toEqual([]);
|
|
266
|
+
expect(planLines()[1]).toBe(
|
|
267
|
+
'log:Migration plan: (new-b-earlier) declared new-b-earlier [runs at init, before schema sync] — no estimate declared'
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('on a fresh database (no ledger table) lists every declared migration and reads no estimate', async () => {
|
|
272
|
+
db.exists = false;
|
|
273
|
+
mockWorld.loaders = [
|
|
274
|
+
{
|
|
275
|
+
loader: {
|
|
276
|
+
table: { name: migrationTable.name },
|
|
277
|
+
record: {
|
|
278
|
+
id: 'fresh-a',
|
|
279
|
+
description: 'declared fresh-a',
|
|
280
|
+
run: async () => events.push('run:fresh-a'),
|
|
281
|
+
estimate: async () => {
|
|
282
|
+
throw new Error('the estimate must not be read on a fresh database — its tables do not exist');
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
const plan = await runner().planPendingMigrations();
|
|
290
|
+
|
|
291
|
+
expect(plan.freshDatabase).toBe(true);
|
|
292
|
+
expect(plan.pending).toEqual([
|
|
293
|
+
{ id: 'fresh-a', description: 'declared fresh-a', unprojectedReason: 'fresh database, nothing to count' },
|
|
294
|
+
]);
|
|
295
|
+
expect(plan.projectedSeconds).toBe(0);
|
|
296
|
+
expect(plan.unprojected).toEqual(['fresh-a']);
|
|
297
|
+
expect(planLines()).toEqual([
|
|
298
|
+
'log:Migration plan: (fresh-a) declared fresh-a — fresh database, nothing to count',
|
|
299
|
+
'log:Migration plan total: 1 pending, ≈ 0 s projected over 0 of them, 1 without a projection (fresh-a)',
|
|
300
|
+
]);
|
|
301
|
+
expect(db.writes).toEqual([]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('an estimate that throws fails the plan loudly — a plan that cannot count is not a plan', async () => {
|
|
305
|
+
const broken = plant('broken-estimate', {
|
|
306
|
+
estimate: async () => {
|
|
307
|
+
throw new Error('count query failed');
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
ledgerRow(broken);
|
|
311
|
+
|
|
312
|
+
await expect(runner().planPendingMigrations()).rejects.toThrow('count query failed');
|
|
313
|
+
expect(events.filter((event) => event.startsWith('run:'))).toEqual([]);
|
|
314
|
+
expect(db.writes).toEqual([]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('a throwing estimate names the migration in the failure — the reader of the plan Job’s log knows which count failed', async () => {
|
|
318
|
+
const fine = plant('names-a-fine', { estimate: async () => ({ units: 1, unit: 'rows', secondsPerUnit: 1 }) });
|
|
319
|
+
const broken = plant('names-b-broken', {
|
|
320
|
+
estimate: async () => {
|
|
321
|
+
throw new Error('count query failed');
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
ledgerRow(fine);
|
|
325
|
+
ledgerRow(broken);
|
|
326
|
+
|
|
327
|
+
await expect(runner().planPendingMigrations()).rejects.toThrow(
|
|
328
|
+
'Migration plan: (names-b-broken) plan test migration names-b-broken — the estimate failed: count query failed'
|
|
329
|
+
);
|
|
330
|
+
expect(events.filter((event) => event.startsWith('run:'))).toEqual([]);
|
|
331
|
+
expect(db.writes).toEqual([]);
|
|
332
|
+
// The line for the migration before it landed; no line for the one that could not count.
|
|
333
|
+
expect(planLines()).toEqual([
|
|
334
|
+
'log:Migration plan: (names-a-fine) plan test migration names-a-fine — 1 rows, ≈ 1 s at 1 s per unit',
|
|
335
|
+
]);
|
|
336
|
+
});
|
|
337
|
+
});
|