@prairielearn/migrations 1.1.0 → 1.2.1
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/.turbo/turbo-build.log +2 -0
- package/CHANGELOG.md +19 -0
- package/README.md +145 -0
- package/dist/batched-migrations/batched-migration-job.d.ts +42 -0
- package/dist/batched-migrations/batched-migration-job.js +25 -0
- package/dist/batched-migrations/batched-migration-job.js.map +1 -0
- package/dist/batched-migrations/batched-migration-job.sql +12 -0
- package/dist/batched-migrations/batched-migration-runner.d.ts +29 -0
- package/dist/batched-migrations/batched-migration-runner.js +136 -0
- package/dist/batched-migrations/batched-migration-runner.js.map +1 -0
- package/dist/batched-migrations/batched-migration-runner.sql +93 -0
- package/dist/batched-migrations/batched-migration-runner.test.js +185 -0
- package/dist/batched-migrations/batched-migration-runner.test.js.map +1 -0
- package/dist/batched-migrations/batched-migration.d.ts +79 -0
- package/dist/batched-migrations/batched-migration.js +73 -0
- package/dist/batched-migrations/batched-migration.js.map +1 -0
- package/dist/batched-migrations/batched-migration.sql +95 -0
- package/dist/batched-migrations/batched-migrations-runner.d.ts +63 -0
- package/dist/batched-migrations/batched-migrations-runner.js +272 -0
- package/dist/batched-migrations/batched-migrations-runner.js.map +1 -0
- package/dist/batched-migrations/batched-migrations-runner.sql +35 -0
- package/dist/batched-migrations/batched-migrations-runner.test.js +116 -0
- package/dist/batched-migrations/batched-migrations-runner.test.js.map +1 -0
- package/dist/batched-migrations/fixtures/20230406184103_successful_migration.d.ts +9 -0
- package/dist/batched-migrations/fixtures/20230406184103_successful_migration.js +14 -0
- package/dist/batched-migrations/fixtures/20230406184103_successful_migration.js.map +1 -0
- package/dist/batched-migrations/fixtures/20230407230446_no_rows_migration.d.ts +8 -0
- package/dist/batched-migrations/fixtures/20230407230446_no_rows_migration.js +16 -0
- package/dist/batched-migrations/fixtures/20230407230446_no_rows_migration.js.map +1 -0
- package/dist/batched-migrations/index.d.ts +3 -0
- package/dist/batched-migrations/index.js +18 -0
- package/dist/batched-migrations/index.js.map +1 -0
- package/dist/index.d.ts +3 -12
- package/dist/index.js +15 -192
- package/dist/index.js.map +1 -1
- package/dist/load-migrations.d.ts +8 -0
- package/dist/load-migrations.js +60 -0
- package/dist/load-migrations.js.map +1 -0
- package/dist/load-migrations.test.d.ts +1 -0
- package/dist/{index.test.js → load-migrations.test.js} +12 -65
- package/dist/load-migrations.test.js.map +1 -0
- package/dist/migrations/fixtures/20230407210430_insert_user.d.ts +1 -0
- package/dist/migrations/fixtures/20230407210430_insert_user.js.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.js +6 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/migrations.d.ts +6 -0
- package/dist/migrations/migrations.js +158 -0
- package/dist/migrations/migrations.js.map +1 -0
- package/dist/migrations/migrations.test.d.ts +1 -0
- package/dist/migrations/migrations.test.js +78 -0
- package/dist/migrations/migrations.test.js.map +1 -0
- package/package.json +15 -12
- package/schema-migrations/20230303193423_batched_migrations__create.sql +49 -0
- package/src/batched-migrations/batched-migration-job.sql +12 -0
- package/src/batched-migrations/batched-migration-job.ts +34 -0
- package/src/batched-migrations/batched-migration-runner.sql +93 -0
- package/src/batched-migrations/batched-migration-runner.test.ts +208 -0
- package/src/batched-migrations/batched-migration-runner.ts +215 -0
- package/src/batched-migrations/batched-migration.sql +95 -0
- package/src/batched-migrations/batched-migration.ts +129 -0
- package/src/batched-migrations/batched-migrations-runner.sql +35 -0
- package/src/batched-migrations/batched-migrations-runner.test.ts +111 -0
- package/src/batched-migrations/batched-migrations-runner.ts +327 -0
- package/src/batched-migrations/fixtures/20230406184103_successful_migration.ts +13 -0
- package/src/batched-migrations/fixtures/20230406184107_failing_migration.js +16 -0
- package/src/batched-migrations/fixtures/20230407230446_no_rows_migration.ts +15 -0
- package/src/batched-migrations/index.ts +21 -0
- package/src/index.ts +20 -201
- package/src/{index.test.ts → load-migrations.test.ts} +8 -73
- package/src/load-migrations.ts +76 -0
- package/src/migrations/index.ts +1 -0
- package/src/migrations/migrations.test.ts +80 -0
- package/src/migrations/migrations.ts +149 -0
- package/tsconfig.json +1 -1
- package/dist/fixtures/20230407210430_insert_user.js.map +0 -1
- package/dist/index.test.js.map +0 -1
- /package/dist/{fixtures/20230407210430_insert_user.d.ts → batched-migrations/batched-migration-runner.test.d.ts} +0 -0
- /package/dist/{index.test.d.ts → batched-migrations/batched-migrations-runner.test.d.ts} +0 -0
- /package/dist/{fixtures → migrations/fixtures}/20230407210409_create_users.sql +0 -0
- /package/dist/{fixtures → migrations/fixtures}/20230407210430_insert_user.js +0 -0
- /package/dist/{index.sql → migrations/migrations.sql} +0 -0
- /package/src/{fixtures → migrations/fixtures}/20230407210409_create_users.sql +0 -0
- /package/src/{fixtures → migrations/fixtures}/20230407210430_insert_user.ts +0 -0
- /package/src/{index.sql → migrations/migrations.sql} +0 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import EventEmitter from 'node:events';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
|
+
import { loadSqlEquiv, queryValidatedZeroOrOneRow } from '@prairielearn/postgres';
|
|
5
|
+
import { doWithLock, tryWithLock } from '@prairielearn/named-locks';
|
|
6
|
+
|
|
7
|
+
import { MigrationFile, readAndValidateMigrationsFromDirectories } from '../load-migrations';
|
|
8
|
+
import {
|
|
9
|
+
BatchedMigrationRowSchema,
|
|
10
|
+
BatchedMigrationRow,
|
|
11
|
+
insertBatchedMigration,
|
|
12
|
+
BatchedMigrationStatus,
|
|
13
|
+
selectBatchedMigrationForTimestamp,
|
|
14
|
+
updateBatchedMigrationStatus,
|
|
15
|
+
BatchedMigrationImplementation,
|
|
16
|
+
validateBatchedMigrationImplementation,
|
|
17
|
+
} from './batched-migration';
|
|
18
|
+
import { BatchedMigrationRunner } from './batched-migration-runner';
|
|
19
|
+
|
|
20
|
+
const sql = loadSqlEquiv(__filename);
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MIN_VALUE = 1n;
|
|
23
|
+
const DEFAULT_BATCH_SIZE = 1_000;
|
|
24
|
+
const DEFAULT_WORK_DURATION_MS = 60_000;
|
|
25
|
+
const DEFAULT_SLEEP_DURATION_MS = 30_000;
|
|
26
|
+
const EXTENSIONS = ['.js', '.ts', '.mjs', '.mts'];
|
|
27
|
+
|
|
28
|
+
interface BatchedMigrationRunnerOptions {
|
|
29
|
+
project: string;
|
|
30
|
+
directories: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface BatchedMigrationStartOptions {
|
|
34
|
+
workDurationMs?: number;
|
|
35
|
+
sleepDurationMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BatchedMigrationFinalizeOptions {
|
|
39
|
+
logProgress?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class BatchedMigrationsRunner extends EventEmitter {
|
|
43
|
+
private readonly options: BatchedMigrationRunnerOptions;
|
|
44
|
+
private readonly lockName: string;
|
|
45
|
+
private running = false;
|
|
46
|
+
private migrationFiles: MigrationFile[] | null = null;
|
|
47
|
+
private abortController = new AbortController();
|
|
48
|
+
|
|
49
|
+
constructor(options: BatchedMigrationRunnerOptions) {
|
|
50
|
+
super();
|
|
51
|
+
this.options = options;
|
|
52
|
+
this.lockName = `batched-migrations:${this.options.project}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private lockNameForTimestamp(timestamp: string) {
|
|
56
|
+
return `${this.lockName}:${timestamp}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private getMigrationFiles = async () => {
|
|
60
|
+
if (!this.migrationFiles) {
|
|
61
|
+
this.migrationFiles = await readAndValidateMigrationsFromDirectories(
|
|
62
|
+
this.options.directories,
|
|
63
|
+
EXTENSIONS
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return this.migrationFiles;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
private async getMigrationForIdentifier(identifier: string): Promise<MigrationFile | null> {
|
|
70
|
+
const timestamp = identifier.split('_')[0];
|
|
71
|
+
|
|
72
|
+
const migrationFiles = await this.getMigrationFiles();
|
|
73
|
+
const migrationFile = migrationFiles.find((m) => m.timestamp === timestamp);
|
|
74
|
+
return migrationFile ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Loads the implementation for the migration with the given identifier. The identifier
|
|
79
|
+
* must start with a 14-character timestamp. It may optionally be followed by
|
|
80
|
+
* an underscore with additional characters, which are ignored. These should
|
|
81
|
+
* typically be used to provide a human-readable name for the migration.
|
|
82
|
+
*/
|
|
83
|
+
private async loadMigrationImplementation(migrationFile: MigrationFile) {
|
|
84
|
+
// We use dynamic imports to handle both CJS and ESM modules.
|
|
85
|
+
const migrationModulePath = path.join(migrationFile.directory, migrationFile.filename);
|
|
86
|
+
const migrationModule = await import(migrationModulePath);
|
|
87
|
+
|
|
88
|
+
const migrationImplementation = migrationModule.default as BatchedMigrationImplementation;
|
|
89
|
+
validateBatchedMigrationImplementation(migrationImplementation);
|
|
90
|
+
return migrationImplementation;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async enqueueBatchedMigration(identifier: string) {
|
|
94
|
+
const migrationFile = await this.getMigrationForIdentifier(identifier);
|
|
95
|
+
if (!migrationFile) {
|
|
96
|
+
throw new Error(`No migration found for identifier ${identifier}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const migrationImplementation = await this.loadMigrationImplementation(migrationFile);
|
|
100
|
+
const migrationParameters = await migrationImplementation.getParameters();
|
|
101
|
+
|
|
102
|
+
// If `max` is null, that implies that there are no rows to process, so
|
|
103
|
+
// we can immediately mark the migration as finished.
|
|
104
|
+
const status: BatchedMigrationStatus =
|
|
105
|
+
migrationParameters.max === null ? 'succeeded' : 'pending';
|
|
106
|
+
|
|
107
|
+
const minValue = BigInt(migrationParameters.min ?? DEFAULT_MIN_VALUE);
|
|
108
|
+
const maxValue = BigInt(migrationParameters.max ?? minValue);
|
|
109
|
+
const batchSize = migrationParameters.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
110
|
+
|
|
111
|
+
await insertBatchedMigration({
|
|
112
|
+
project: this.options.project,
|
|
113
|
+
filename: migrationFile.filename,
|
|
114
|
+
timestamp: migrationFile.timestamp,
|
|
115
|
+
batch_size: batchSize,
|
|
116
|
+
min_value: minValue,
|
|
117
|
+
max_value: maxValue,
|
|
118
|
+
status,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async finalizeBatchedMigration(identifier: string, options?: BatchedMigrationFinalizeOptions) {
|
|
123
|
+
const timestamp = identifier.split('_')[0];
|
|
124
|
+
|
|
125
|
+
let migration = await selectBatchedMigrationForTimestamp(this.options.project, timestamp);
|
|
126
|
+
|
|
127
|
+
if (migration.status === 'succeeded') return;
|
|
128
|
+
|
|
129
|
+
// If the migration isn't already in the finalizing state, mark it as such.
|
|
130
|
+
if (migration.status !== 'finalizing') {
|
|
131
|
+
migration = await updateBatchedMigrationStatus(migration.id, 'finalizing');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await doWithLock(this.lockNameForTimestamp(timestamp), { autoRenew: true }, async () => {
|
|
135
|
+
const migrationFile = await this.getMigrationForIdentifier(identifier);
|
|
136
|
+
if (!migrationFile) {
|
|
137
|
+
throw new Error(`No migration found for identifier ${identifier}`);
|
|
138
|
+
}
|
|
139
|
+
const migrationImplementation = await this.loadMigrationImplementation(migrationFile);
|
|
140
|
+
|
|
141
|
+
const runner = new BatchedMigrationRunner(migration, migrationImplementation, {
|
|
142
|
+
// Always log progress unless explicitly disabled.
|
|
143
|
+
logProgress: options?.logProgress ?? true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Because we don't give any arguments to `run()`, it will run until it
|
|
147
|
+
// has attempted every job.
|
|
148
|
+
await runner.run();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
migration = await selectBatchedMigrationForTimestamp(this.options.project, timestamp);
|
|
152
|
+
|
|
153
|
+
if (migration.status === 'succeeded') return;
|
|
154
|
+
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Expected batched migration with identifier ${identifier} to be marked as 'succeeded', but it is '${migration.status}'.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
start(options: BatchedMigrationStartOptions = {}) {
|
|
161
|
+
if (this.running) {
|
|
162
|
+
throw new Error('BatchedMigrationsRunner is already running');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.loop(options);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async loop({ workDurationMs, sleepDurationMs }: BatchedMigrationStartOptions) {
|
|
169
|
+
workDurationMs ??= DEFAULT_WORK_DURATION_MS;
|
|
170
|
+
sleepDurationMs ??= DEFAULT_SLEEP_DURATION_MS;
|
|
171
|
+
|
|
172
|
+
this.running = true;
|
|
173
|
+
while (this.running) {
|
|
174
|
+
if (this.abortController.signal.aborted) {
|
|
175
|
+
// We assign this here so that `stop()` can tell when this loop is done
|
|
176
|
+
// processing jobs.
|
|
177
|
+
this.running = false;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let didWork = false;
|
|
182
|
+
try {
|
|
183
|
+
didWork = await this.maybePerformWork(workDurationMs);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
this.emit('error', err);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// If we did work, we'll immediately try again since there's probably more
|
|
189
|
+
// work to be done. If not, we'll sleep for a while - maybe some more work
|
|
190
|
+
// will become available!
|
|
191
|
+
if (!didWork) {
|
|
192
|
+
// We provide the signal here so that we can more quickly stop things
|
|
193
|
+
// when we're shutting down.
|
|
194
|
+
try {
|
|
195
|
+
await sleep(sleepDurationMs, null, { ref: false, signal: this.abortController.signal });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// We don't care about errors here, they should only ever occur when
|
|
198
|
+
// the AbortController is aborted. Continue to the next iteration of
|
|
199
|
+
// the loop so we can shut down.
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async getOrStartMigration(): Promise<BatchedMigrationRow | null> {
|
|
207
|
+
return doWithLock(this.lockName, {}, async () => {
|
|
208
|
+
let migration = await queryValidatedZeroOrOneRow(
|
|
209
|
+
sql.select_running_migration,
|
|
210
|
+
{ project: this.options.project },
|
|
211
|
+
BatchedMigrationRowSchema
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (!migration) {
|
|
215
|
+
migration = await queryValidatedZeroOrOneRow(
|
|
216
|
+
sql.start_next_pending_migration,
|
|
217
|
+
{ project: this.options.project },
|
|
218
|
+
BatchedMigrationRowSchema
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return migration;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async maybePerformWork(durationMs: number): Promise<boolean> {
|
|
227
|
+
const migration = await this.getOrStartMigration();
|
|
228
|
+
if (!migration) {
|
|
229
|
+
// No work to do. Handle this case.
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// This server may not yet know about the current running migration. If
|
|
234
|
+
// that's the case, we'll just skip it for now.
|
|
235
|
+
const migrationFile = await this.getMigrationForIdentifier(migration.timestamp);
|
|
236
|
+
if (!migrationFile) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let didWork = false;
|
|
241
|
+
await tryWithLock(
|
|
242
|
+
this.lockNameForTimestamp(migrationFile.timestamp),
|
|
243
|
+
{ autoRenew: true },
|
|
244
|
+
async () => {
|
|
245
|
+
didWork = true;
|
|
246
|
+
const migrationImplementation = await this.loadMigrationImplementation(migrationFile);
|
|
247
|
+
|
|
248
|
+
const runner = new BatchedMigrationRunner(migration, migrationImplementation);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
await runner.run({ signal: this.abortController.signal, durationMs });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
this.emit('error', err);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return didWork;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async stop() {
|
|
262
|
+
this.abortController.abort();
|
|
263
|
+
|
|
264
|
+
// Spin until we're no longer running.
|
|
265
|
+
while (this.running) {
|
|
266
|
+
await sleep(1000);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let runner: BatchedMigrationsRunner | null = null;
|
|
272
|
+
|
|
273
|
+
function assertRunner(
|
|
274
|
+
runner: BatchedMigrationsRunner | null
|
|
275
|
+
): asserts runner is BatchedMigrationsRunner {
|
|
276
|
+
if (!runner) throw new Error('Batched migrations not initialized');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function initBatchedMigrations(options: BatchedMigrationRunnerOptions) {
|
|
280
|
+
if (runner) throw new Error('Batched migrations already initialized');
|
|
281
|
+
runner = new BatchedMigrationsRunner(options);
|
|
282
|
+
return runner;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function startBatchedMigrations(options: BatchedMigrationStartOptions = {}) {
|
|
286
|
+
assertRunner(runner);
|
|
287
|
+
runner.start(options);
|
|
288
|
+
return runner;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function stopBatchedMigrations() {
|
|
292
|
+
assertRunner(runner);
|
|
293
|
+
await runner.stop();
|
|
294
|
+
runner = null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Given a batched migration identifier like `20230406184103_migration`,
|
|
299
|
+
* enqueues it for execution by creating a row in the `batched_migrations`
|
|
300
|
+
* table.
|
|
301
|
+
*
|
|
302
|
+
* Despite taking a full identifier, only the timestamp is used to uniquely
|
|
303
|
+
* identify the batched migration. The remaining part is just used to make
|
|
304
|
+
* calls more human-readable.
|
|
305
|
+
*
|
|
306
|
+
* @param identifier The identifier of the batched migration to enqueue.
|
|
307
|
+
*/
|
|
308
|
+
export async function enqueueBatchedMigration(identifier: string) {
|
|
309
|
+
assertRunner(runner);
|
|
310
|
+
await runner.enqueueBatchedMigration(identifier);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Given a batched migration identifier like `20230406184103_migration`,
|
|
315
|
+
* synchronously runs it to completion. An error will be thrown if the final
|
|
316
|
+
* status of the migration is not `succeeded`.
|
|
317
|
+
*
|
|
318
|
+
* @param identifier The identifier of the batched migration to finalize.
|
|
319
|
+
* @param options Options for finalizing the batched migration.
|
|
320
|
+
*/
|
|
321
|
+
export async function finalizeBatchedMigration(
|
|
322
|
+
identifier: string,
|
|
323
|
+
options?: BatchedMigrationFinalizeOptions
|
|
324
|
+
) {
|
|
325
|
+
assertRunner(runner);
|
|
326
|
+
await runner.finalizeBatchedMigration(identifier, options);
|
|
327
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const { makeBatchedMigration } = require('../batched-migration');
|
|
3
|
+
|
|
4
|
+
module.exports = makeBatchedMigration({
|
|
5
|
+
async getParameters() {
|
|
6
|
+
return {
|
|
7
|
+
min: 2n,
|
|
8
|
+
max: 200n,
|
|
9
|
+
batchSize: 20,
|
|
10
|
+
};
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
async execute(_min, _max) {
|
|
14
|
+
throw new Error('Testing failure');
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { makeBatchedMigration } from '../batched-migration';
|
|
2
|
+
|
|
3
|
+
export default makeBatchedMigration({
|
|
4
|
+
async getParameters() {
|
|
5
|
+
return {
|
|
6
|
+
// Simulates the case where there are no rows to process. A null
|
|
7
|
+
// max value is what we would get for some query like
|
|
8
|
+
// `SELECT MAX(id) FROM table;`.
|
|
9
|
+
max: null,
|
|
10
|
+
batchSize: 10,
|
|
11
|
+
};
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
async execute(_min: bigint, _max: bigint) {},
|
|
15
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
BatchedMigrationRow,
|
|
3
|
+
BatchedMigrationStatus,
|
|
4
|
+
makeBatchedMigration,
|
|
5
|
+
selectAllBatchedMigrations,
|
|
6
|
+
selectBatchedMigration,
|
|
7
|
+
selectBatchedMigrationForTimestamp,
|
|
8
|
+
retryFailedBatchedMigrationJobs,
|
|
9
|
+
} from './batched-migration';
|
|
10
|
+
export {
|
|
11
|
+
BatchedMigrationJobRow,
|
|
12
|
+
BatchedMigrationJobStatus,
|
|
13
|
+
selectRecentJobsWithStatus,
|
|
14
|
+
} from './batched-migration-job';
|
|
15
|
+
export {
|
|
16
|
+
initBatchedMigrations,
|
|
17
|
+
startBatchedMigrations,
|
|
18
|
+
stopBatchedMigrations,
|
|
19
|
+
enqueueBatchedMigration,
|
|
20
|
+
finalizeBatchedMigration,
|
|
21
|
+
} from './batched-migrations-runner';
|
package/src/index.ts
CHANGED
|
@@ -1,203 +1,22 @@
|
|
|
1
|
-
import fs from 'fs-extra';
|
|
2
1
|
import path from 'path';
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
autoRenew: true,
|
|
25
|
-
},
|
|
26
|
-
async () => {
|
|
27
|
-
logger.verbose(`Acquired lock ${lockName}`);
|
|
28
|
-
await initWithLock(migrationDir, project);
|
|
29
|
-
}
|
|
30
|
-
);
|
|
31
|
-
logger.verbose(`Released lock ${lockName}`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Timestamp prefixes will be of the form `YYYYMMDDHHMMSS`, which will have 14 digits.
|
|
36
|
-
* If this code is still around in the year 10000... good luck.
|
|
37
|
-
*/
|
|
38
|
-
const MIGRATION_FILENAME_REGEX = /^([0-9]{14})_.+\.[a-z]+$/;
|
|
39
|
-
|
|
40
|
-
interface MigrationFile {
|
|
41
|
-
filename: string;
|
|
42
|
-
timestamp: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function readAndValidateMigrationsFromDirectory(
|
|
46
|
-
dir: string,
|
|
47
|
-
extensions: string[]
|
|
48
|
-
): Promise<MigrationFile[]> {
|
|
49
|
-
const migrationFiles = (await fs.readdir(dir)).filter((m) =>
|
|
50
|
-
extensions.some((e) => m.endsWith(e))
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
const migrations = migrationFiles.map((mf) => {
|
|
54
|
-
const match = mf.match(MIGRATION_FILENAME_REGEX);
|
|
55
|
-
|
|
56
|
-
if (!match) {
|
|
57
|
-
throw new Error(`Invalid migration filename: ${mf}`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const timestamp = match[1] ?? null;
|
|
61
|
-
|
|
62
|
-
if (timestamp === null) {
|
|
63
|
-
throw new Error(`Migration ${mf} does not have a timestamp`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
filename: mf,
|
|
68
|
-
timestamp,
|
|
69
|
-
};
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// First pass: validate that all migrations have a unique timestamp prefix.
|
|
73
|
-
// This will avoid data loss and conflicts in unexpected scenarios.
|
|
74
|
-
const seenTimestamps = new Set();
|
|
75
|
-
for (const migration of migrations) {
|
|
76
|
-
const { filename, timestamp } = migration;
|
|
77
|
-
|
|
78
|
-
if (timestamp !== null) {
|
|
79
|
-
if (seenTimestamps.has(timestamp)) {
|
|
80
|
-
throw new Error(`Duplicate migration timestamp: ${timestamp} (${filename})`);
|
|
81
|
-
}
|
|
82
|
-
seenTimestamps.add(timestamp);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return migrations;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function sortMigrationFiles(migrationFiles: MigrationFile[]): MigrationFile[] {
|
|
90
|
-
return migrationFiles.sort((a, b) => {
|
|
91
|
-
return a.timestamp.localeCompare(b.timestamp);
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function getMigrationsToExecute(
|
|
96
|
-
migrationFiles: MigrationFile[],
|
|
97
|
-
executedMigrations: { timestamp: string | null }[]
|
|
98
|
-
): MigrationFile[] {
|
|
99
|
-
// If no migrations have ever been run, run them all.
|
|
100
|
-
if (executedMigrations.length === 0) {
|
|
101
|
-
return migrationFiles;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const executedMigrationTimestamps = new Set(executedMigrations.map((m) => m.timestamp));
|
|
105
|
-
return migrationFiles.filter((m) => !executedMigrationTimestamps.has(m.timestamp));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export async function initWithLock(migrationDir: string, project: string) {
|
|
109
|
-
logger.verbose('Starting DB schema migration');
|
|
110
|
-
|
|
111
|
-
// Create the migrations table if needed
|
|
112
|
-
await sqldb.queryAsync(sql.create_migrations_table, {});
|
|
113
|
-
|
|
114
|
-
// Apply necessary changes to the migrations table as needed.
|
|
115
|
-
try {
|
|
116
|
-
await sqldb.queryAsync('SELECT project FROM migrations;', {});
|
|
117
|
-
} catch (err: any) {
|
|
118
|
-
if (err.routine === 'errorMissingColumn') {
|
|
119
|
-
logger.info('Altering migrations table');
|
|
120
|
-
await sqldb.queryAsync(sql.add_projects_column, {});
|
|
121
|
-
} else {
|
|
122
|
-
throw err;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
await sqldb.queryAsync('SELECT timestamp FROM migrations;', {});
|
|
127
|
-
} catch (err: any) {
|
|
128
|
-
if (err.routine === 'errorMissingColumn') {
|
|
129
|
-
logger.info('Altering migrations table again');
|
|
130
|
-
await sqldb.queryAsync(sql.add_timestamp_column, {});
|
|
131
|
-
} else {
|
|
132
|
-
throw err;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
let allMigrations = await sqldb.queryAsync(sql.get_migrations, { project });
|
|
137
|
-
|
|
138
|
-
const migrationFiles = await readAndValidateMigrationsFromDirectory(migrationDir, [
|
|
139
|
-
'.sql',
|
|
140
|
-
'.js',
|
|
141
|
-
'.ts',
|
|
142
|
-
'.mjs',
|
|
143
|
-
]);
|
|
144
|
-
|
|
145
|
-
// Validation: if we not all previously-executed migrations have timestamps,
|
|
146
|
-
// prompt the user to deploy an earlier version that includes both indexes
|
|
147
|
-
// and timestamps.
|
|
148
|
-
const migrationsMissingTimestamps = allMigrations.rows.filter((m) => !m.timestamp);
|
|
149
|
-
if (migrationsMissingTimestamps.length > 0) {
|
|
150
|
-
throw new Error(
|
|
151
|
-
[
|
|
152
|
-
'The following migrations are missing timestamps:',
|
|
153
|
-
migrationsMissingTimestamps.map((m) => ` ${m.filename}`),
|
|
154
|
-
// This revision was the most recent commit to `master` before the
|
|
155
|
-
// code handling indexes was removed.
|
|
156
|
-
'You must deploy revision 1aa43c7348fa24cf636413d720d06a2fa9e38ef2 first.',
|
|
157
|
-
].join('\n')
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Refetch the list of migrations from the database.
|
|
162
|
-
allMigrations = await sqldb.queryAsync(sql.get_migrations, { project });
|
|
163
|
-
|
|
164
|
-
// Sort the migration files into execution order.
|
|
165
|
-
const sortedMigrationFiles = sortMigrationFiles(migrationFiles);
|
|
166
|
-
|
|
167
|
-
// Figure out which migrations have to be applied.
|
|
168
|
-
const migrationsToExecute = getMigrationsToExecute(sortedMigrationFiles, allMigrations.rows);
|
|
169
|
-
|
|
170
|
-
for (const { filename, timestamp } of migrationsToExecute) {
|
|
171
|
-
if (allMigrations.rows.length === 0) {
|
|
172
|
-
// if we are running all the migrations then log at a lower level
|
|
173
|
-
logger.verbose(`Running migration ${filename}`);
|
|
174
|
-
} else {
|
|
175
|
-
logger.info(`Running migration ${filename}`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const migrationPath = path.join(migrationDir, filename);
|
|
179
|
-
if (filename.endsWith('.sql')) {
|
|
180
|
-
const migrationSql = await fs.readFile(migrationPath, 'utf8');
|
|
181
|
-
try {
|
|
182
|
-
await sqldb.queryAsync(migrationSql, {});
|
|
183
|
-
} catch (err) {
|
|
184
|
-
error.addData(err, { sqlFile: filename });
|
|
185
|
-
throw err;
|
|
186
|
-
}
|
|
187
|
-
} else {
|
|
188
|
-
const migrationModule = await import(migrationPath);
|
|
189
|
-
const implementation = migrationModule.default;
|
|
190
|
-
if (typeof implementation !== 'function') {
|
|
191
|
-
throw new Error(`Migration ${filename} does not export a default function`);
|
|
192
|
-
}
|
|
193
|
-
await implementation();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Record the migration.
|
|
197
|
-
await sqldb.queryAsync(sql.insert_migration, {
|
|
198
|
-
filename: filename,
|
|
199
|
-
timestamp,
|
|
200
|
-
project,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
}
|
|
3
|
+
export { init } from './migrations';
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
BatchedMigrationRow,
|
|
7
|
+
BatchedMigrationStatus,
|
|
8
|
+
BatchedMigrationJobRow,
|
|
9
|
+
BatchedMigrationJobStatus,
|
|
10
|
+
makeBatchedMigration,
|
|
11
|
+
initBatchedMigrations,
|
|
12
|
+
startBatchedMigrations,
|
|
13
|
+
stopBatchedMigrations,
|
|
14
|
+
enqueueBatchedMigration,
|
|
15
|
+
finalizeBatchedMigration,
|
|
16
|
+
selectAllBatchedMigrations,
|
|
17
|
+
selectBatchedMigration,
|
|
18
|
+
selectBatchedMigrationForTimestamp,
|
|
19
|
+
selectRecentJobsWithStatus,
|
|
20
|
+
} from './batched-migrations';
|
|
21
|
+
|
|
22
|
+
export const SCHEMA_MIGRATIONS_PATH = path.resolve(__dirname, '..', 'schema-migrations');
|