@chidchanun/bcp 0.1.13 → 0.1.15
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/docs/database-migrations.md +105 -0
- package/docs/database.md +127 -0
- package/package.json +6 -1
- package/packages/cli/src/args.ts +71 -1
- package/packages/cli/src/database-migrations.ts +708 -0
- package/packages/cli/src/index.ts +112 -1
- package/packages/client/src/database.ts +492 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
pathToFileURL,
|
|
5
|
+
} from "node:url";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
TransactionDatabase,
|
|
9
|
+
} from "../../client/src/database.js";
|
|
10
|
+
|
|
11
|
+
export type DatabaseMigrationAction =
|
|
12
|
+
| "migrate"
|
|
13
|
+
| "status"
|
|
14
|
+
| "rollback"
|
|
15
|
+
| "create";
|
|
16
|
+
|
|
17
|
+
export interface MigrationDatabase {
|
|
18
|
+
query<T = unknown>(
|
|
19
|
+
sql: string,
|
|
20
|
+
parameters?: readonly unknown[] | Record<string, unknown>
|
|
21
|
+
): Promise<T>;
|
|
22
|
+
execute<T = unknown>(
|
|
23
|
+
sql: string,
|
|
24
|
+
parameters?: readonly unknown[] | Record<string, unknown>
|
|
25
|
+
): Promise<T>;
|
|
26
|
+
transaction<T>(
|
|
27
|
+
callback: (
|
|
28
|
+
database: TransactionDatabase
|
|
29
|
+
) => Promise<T>
|
|
30
|
+
): Promise<T>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DatabaseMigrationCommandOptions {
|
|
34
|
+
rootDirectory: string;
|
|
35
|
+
action: DatabaseMigrationAction;
|
|
36
|
+
name?: string;
|
|
37
|
+
database?: MigrationDatabase;
|
|
38
|
+
now?: Date;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MigrationStatus {
|
|
42
|
+
name: string;
|
|
43
|
+
status: "applied" | "pending";
|
|
44
|
+
batch: number | null;
|
|
45
|
+
appliedAt: string | Date | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MigrationRecord {
|
|
49
|
+
name: string;
|
|
50
|
+
batch: number;
|
|
51
|
+
applied_at: string | Date;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface LoadedMigration {
|
|
55
|
+
name: string;
|
|
56
|
+
filePath: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface MigrationModule {
|
|
60
|
+
up?: (
|
|
61
|
+
database: TransactionDatabase
|
|
62
|
+
) => Promise<void> | void;
|
|
63
|
+
down?: (
|
|
64
|
+
database: TransactionDatabase
|
|
65
|
+
) => Promise<void> | void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const MIGRATION_DIRECTORY =
|
|
69
|
+
"migrations";
|
|
70
|
+
const MIGRATION_TABLE =
|
|
71
|
+
"_bcp_migrations";
|
|
72
|
+
const MIGRATION_FILE_PATTERN =
|
|
73
|
+
/^\d{14}_[a-z0-9][a-z0-9_-]*\.(?:ts|mts|js|mjs)$/;
|
|
74
|
+
|
|
75
|
+
export async function runDatabaseMigrationCommand(
|
|
76
|
+
options: DatabaseMigrationCommandOptions
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const rootDirectory =
|
|
79
|
+
path.resolve(
|
|
80
|
+
options.rootDirectory
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
options.action === "create"
|
|
85
|
+
) {
|
|
86
|
+
const file =
|
|
87
|
+
createMigrationFile(
|
|
88
|
+
rootDirectory,
|
|
89
|
+
options.name,
|
|
90
|
+
options.now
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
console.log(
|
|
94
|
+
`[BCP DB] Created migration: ${path.relative(rootDirectory, file)}`
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const database =
|
|
100
|
+
options.database;
|
|
101
|
+
|
|
102
|
+
if (!database) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"BCP DB: a database connection is required for this command."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await ensureMigrationTable(
|
|
109
|
+
database
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
options.action === "status"
|
|
114
|
+
) {
|
|
115
|
+
const statuses =
|
|
116
|
+
await getMigrationStatus(
|
|
117
|
+
rootDirectory,
|
|
118
|
+
database
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
printMigrationStatus(
|
|
122
|
+
statuses
|
|
123
|
+
);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
options.action === "migrate"
|
|
129
|
+
) {
|
|
130
|
+
await migratePending(
|
|
131
|
+
rootDirectory,
|
|
132
|
+
database
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await rollbackLatestBatch(
|
|
138
|
+
rootDirectory,
|
|
139
|
+
database
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createMigrationFile(
|
|
144
|
+
rootDirectory: string,
|
|
145
|
+
name: string | undefined,
|
|
146
|
+
now = new Date()
|
|
147
|
+
): string {
|
|
148
|
+
const slug =
|
|
149
|
+
normalizeMigrationName(
|
|
150
|
+
name
|
|
151
|
+
);
|
|
152
|
+
const directory =
|
|
153
|
+
path.join(
|
|
154
|
+
rootDirectory,
|
|
155
|
+
MIGRATION_DIRECTORY
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
fs.mkdirSync(
|
|
159
|
+
directory,
|
|
160
|
+
{
|
|
161
|
+
recursive: true,
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const timestamp =
|
|
166
|
+
formatMigrationTimestamp(
|
|
167
|
+
now
|
|
168
|
+
);
|
|
169
|
+
let suffix = 1;
|
|
170
|
+
let fileName =
|
|
171
|
+
`${timestamp}_${slug}.ts`;
|
|
172
|
+
let filePath =
|
|
173
|
+
path.join(
|
|
174
|
+
directory,
|
|
175
|
+
fileName
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
while (
|
|
179
|
+
fs.existsSync(
|
|
180
|
+
filePath
|
|
181
|
+
)
|
|
182
|
+
) {
|
|
183
|
+
suffix++;
|
|
184
|
+
fileName =
|
|
185
|
+
`${timestamp}_${slug}_${suffix}.ts`;
|
|
186
|
+
filePath =
|
|
187
|
+
path.join(
|
|
188
|
+
directory,
|
|
189
|
+
fileName
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const source =
|
|
194
|
+
`import type {\n` +
|
|
195
|
+
` TransactionDatabase,\n` +
|
|
196
|
+
`} from "bcp/database";\n\n` +
|
|
197
|
+
`export async function up(\n` +
|
|
198
|
+
` db: TransactionDatabase\n` +
|
|
199
|
+
`): Promise<void> {\n` +
|
|
200
|
+
` // Write the forward migration here.\n` +
|
|
201
|
+
` void db;\n` +
|
|
202
|
+
`}\n\n` +
|
|
203
|
+
`export async function down(\n` +
|
|
204
|
+
` db: TransactionDatabase\n` +
|
|
205
|
+
`): Promise<void> {\n` +
|
|
206
|
+
` // Write the rollback migration here.\n` +
|
|
207
|
+
` void db;\n` +
|
|
208
|
+
`}\n`;
|
|
209
|
+
|
|
210
|
+
fs.writeFileSync(
|
|
211
|
+
filePath,
|
|
212
|
+
source,
|
|
213
|
+
{
|
|
214
|
+
encoding: "utf8",
|
|
215
|
+
flag: "wx",
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return filePath;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function listMigrationFiles(
|
|
223
|
+
rootDirectory: string
|
|
224
|
+
): LoadedMigration[] {
|
|
225
|
+
const directory =
|
|
226
|
+
path.join(
|
|
227
|
+
rootDirectory,
|
|
228
|
+
MIGRATION_DIRECTORY
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
!fs.existsSync(
|
|
233
|
+
directory
|
|
234
|
+
)
|
|
235
|
+
) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return fs.readdirSync(
|
|
240
|
+
directory,
|
|
241
|
+
{
|
|
242
|
+
withFileTypes: true,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
.filter(
|
|
246
|
+
(entry) =>
|
|
247
|
+
entry.isFile() &&
|
|
248
|
+
/\.(?:ts|mts|js|mjs)$/.test(
|
|
249
|
+
entry.name
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
.map(
|
|
253
|
+
(entry) => {
|
|
254
|
+
if (
|
|
255
|
+
!MIGRATION_FILE_PATTERN.test(
|
|
256
|
+
entry.name
|
|
257
|
+
)
|
|
258
|
+
) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`BCP DB: invalid migration filename "${entry.name}". Use \`bcp db create <name>\` to create ordered migrations.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
name:
|
|
266
|
+
entry.name,
|
|
267
|
+
filePath:
|
|
268
|
+
path.join(
|
|
269
|
+
directory,
|
|
270
|
+
entry.name
|
|
271
|
+
),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
.sort(
|
|
276
|
+
(left, right) =>
|
|
277
|
+
left.name.localeCompare(
|
|
278
|
+
right.name
|
|
279
|
+
)
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function getMigrationStatus(
|
|
284
|
+
rootDirectory: string,
|
|
285
|
+
database: MigrationDatabase
|
|
286
|
+
): Promise<MigrationStatus[]> {
|
|
287
|
+
const files =
|
|
288
|
+
listMigrationFiles(
|
|
289
|
+
rootDirectory
|
|
290
|
+
);
|
|
291
|
+
const applied =
|
|
292
|
+
await readAppliedMigrations(
|
|
293
|
+
database
|
|
294
|
+
);
|
|
295
|
+
const appliedByName =
|
|
296
|
+
new Map(
|
|
297
|
+
applied.map(
|
|
298
|
+
(record) => [
|
|
299
|
+
record.name,
|
|
300
|
+
record,
|
|
301
|
+
]
|
|
302
|
+
)
|
|
303
|
+
);
|
|
304
|
+
const statuses:
|
|
305
|
+
MigrationStatus[] =
|
|
306
|
+
files.map(
|
|
307
|
+
(migration) => {
|
|
308
|
+
const record =
|
|
309
|
+
appliedByName.get(
|
|
310
|
+
migration.name
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return record
|
|
314
|
+
? {
|
|
315
|
+
name:
|
|
316
|
+
migration.name,
|
|
317
|
+
status:
|
|
318
|
+
"applied",
|
|
319
|
+
batch:
|
|
320
|
+
record.batch,
|
|
321
|
+
appliedAt:
|
|
322
|
+
record.applied_at,
|
|
323
|
+
}
|
|
324
|
+
: {
|
|
325
|
+
name:
|
|
326
|
+
migration.name,
|
|
327
|
+
status:
|
|
328
|
+
"pending",
|
|
329
|
+
batch:
|
|
330
|
+
null,
|
|
331
|
+
appliedAt:
|
|
332
|
+
null,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
for (
|
|
338
|
+
const record
|
|
339
|
+
of applied
|
|
340
|
+
) {
|
|
341
|
+
if (
|
|
342
|
+
!files.some(
|
|
343
|
+
(file) =>
|
|
344
|
+
file.name ===
|
|
345
|
+
record.name
|
|
346
|
+
)
|
|
347
|
+
) {
|
|
348
|
+
statuses.push({
|
|
349
|
+
name:
|
|
350
|
+
record.name,
|
|
351
|
+
status:
|
|
352
|
+
"applied",
|
|
353
|
+
batch:
|
|
354
|
+
record.batch,
|
|
355
|
+
appliedAt:
|
|
356
|
+
record.applied_at,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return statuses;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function migratePending(
|
|
365
|
+
rootDirectory: string,
|
|
366
|
+
database: MigrationDatabase
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const files =
|
|
369
|
+
listMigrationFiles(
|
|
370
|
+
rootDirectory
|
|
371
|
+
);
|
|
372
|
+
const applied =
|
|
373
|
+
await readAppliedMigrations(
|
|
374
|
+
database
|
|
375
|
+
);
|
|
376
|
+
const appliedNames =
|
|
377
|
+
new Set(
|
|
378
|
+
applied.map(
|
|
379
|
+
(record) =>
|
|
380
|
+
record.name
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
const pending =
|
|
384
|
+
files.filter(
|
|
385
|
+
(migration) =>
|
|
386
|
+
!appliedNames.has(
|
|
387
|
+
migration.name
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
if (
|
|
392
|
+
pending.length === 0
|
|
393
|
+
) {
|
|
394
|
+
console.log(
|
|
395
|
+
"[BCP DB] No pending migrations."
|
|
396
|
+
);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const batch =
|
|
401
|
+
applied.reduce(
|
|
402
|
+
(
|
|
403
|
+
maximum,
|
|
404
|
+
record
|
|
405
|
+
) =>
|
|
406
|
+
Math.max(
|
|
407
|
+
maximum,
|
|
408
|
+
record.batch
|
|
409
|
+
),
|
|
410
|
+
0
|
|
411
|
+
) + 1;
|
|
412
|
+
|
|
413
|
+
for (
|
|
414
|
+
const migration
|
|
415
|
+
of pending
|
|
416
|
+
) {
|
|
417
|
+
const module =
|
|
418
|
+
await loadMigrationModule(
|
|
419
|
+
migration
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
await database.transaction(
|
|
423
|
+
async (transaction) => {
|
|
424
|
+
await module.up?.(
|
|
425
|
+
transaction
|
|
426
|
+
);
|
|
427
|
+
await transaction.execute(
|
|
428
|
+
`INSERT INTO ${MIGRATION_TABLE} (name, batch) VALUES (?, ?)`,
|
|
429
|
+
[
|
|
430
|
+
migration.name,
|
|
431
|
+
batch,
|
|
432
|
+
]
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
console.log(
|
|
438
|
+
`[BCP DB] Migrated: ${migration.name} (batch ${batch})`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function rollbackLatestBatch(
|
|
444
|
+
rootDirectory: string,
|
|
445
|
+
database: MigrationDatabase
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
const applied =
|
|
448
|
+
await readAppliedMigrations(
|
|
449
|
+
database
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
if (
|
|
453
|
+
applied.length === 0
|
|
454
|
+
) {
|
|
455
|
+
console.log(
|
|
456
|
+
"[BCP DB] Nothing to rollback."
|
|
457
|
+
);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const latestBatch =
|
|
462
|
+
applied.reduce(
|
|
463
|
+
(
|
|
464
|
+
maximum,
|
|
465
|
+
record
|
|
466
|
+
) =>
|
|
467
|
+
Math.max(
|
|
468
|
+
maximum,
|
|
469
|
+
record.batch
|
|
470
|
+
),
|
|
471
|
+
0
|
|
472
|
+
);
|
|
473
|
+
const files =
|
|
474
|
+
new Map(
|
|
475
|
+
listMigrationFiles(
|
|
476
|
+
rootDirectory
|
|
477
|
+
).map(
|
|
478
|
+
(migration) => [
|
|
479
|
+
migration.name,
|
|
480
|
+
migration,
|
|
481
|
+
]
|
|
482
|
+
)
|
|
483
|
+
);
|
|
484
|
+
const rollbackRecords =
|
|
485
|
+
applied
|
|
486
|
+
.filter(
|
|
487
|
+
(record) =>
|
|
488
|
+
record.batch ===
|
|
489
|
+
latestBatch
|
|
490
|
+
)
|
|
491
|
+
.reverse();
|
|
492
|
+
|
|
493
|
+
for (
|
|
494
|
+
const record
|
|
495
|
+
of rollbackRecords
|
|
496
|
+
) {
|
|
497
|
+
if (
|
|
498
|
+
!files.has(
|
|
499
|
+
record.name
|
|
500
|
+
)
|
|
501
|
+
) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`BCP DB: cannot rollback ${record.name}; the migration file is missing.`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
for (
|
|
509
|
+
const record
|
|
510
|
+
of rollbackRecords
|
|
511
|
+
) {
|
|
512
|
+
const migration =
|
|
513
|
+
files.get(
|
|
514
|
+
record.name
|
|
515
|
+
)!;
|
|
516
|
+
const module =
|
|
517
|
+
await loadMigrationModule(
|
|
518
|
+
migration
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
await database.transaction(
|
|
522
|
+
async (transaction) => {
|
|
523
|
+
await module.down?.(
|
|
524
|
+
transaction
|
|
525
|
+
);
|
|
526
|
+
await transaction.execute(
|
|
527
|
+
`DELETE FROM ${MIGRATION_TABLE} WHERE name = ?`,
|
|
528
|
+
[
|
|
529
|
+
record.name,
|
|
530
|
+
]
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
console.log(
|
|
536
|
+
`[BCP DB] Rolled back: ${record.name} (batch ${latestBatch})`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function ensureMigrationTable(
|
|
542
|
+
database: MigrationDatabase
|
|
543
|
+
): Promise<void> {
|
|
544
|
+
await database.execute(
|
|
545
|
+
`CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (` +
|
|
546
|
+
`id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, ` +
|
|
547
|
+
`name VARCHAR(255) NOT NULL UNIQUE, ` +
|
|
548
|
+
`batch INT UNSIGNED NOT NULL, ` +
|
|
549
|
+
`applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` +
|
|
550
|
+
`) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function readAppliedMigrations(
|
|
555
|
+
database: MigrationDatabase
|
|
556
|
+
): Promise<MigrationRecord[]> {
|
|
557
|
+
return database.query<
|
|
558
|
+
MigrationRecord[]
|
|
559
|
+
>(
|
|
560
|
+
`SELECT name, batch, applied_at FROM ${MIGRATION_TABLE} ORDER BY id ASC`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function loadMigrationModule(
|
|
565
|
+
migration: LoadedMigration
|
|
566
|
+
): Promise<Required<MigrationModule>> {
|
|
567
|
+
const url =
|
|
568
|
+
pathToFileURL(
|
|
569
|
+
migration.filePath
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
url.searchParams.set(
|
|
573
|
+
"bcp-migration",
|
|
574
|
+
`${Date.now()}-${Math.random()}`
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
const module =
|
|
578
|
+
await import(
|
|
579
|
+
url.href
|
|
580
|
+
) as MigrationModule;
|
|
581
|
+
|
|
582
|
+
if (
|
|
583
|
+
typeof module.up !==
|
|
584
|
+
"function" ||
|
|
585
|
+
typeof module.down !==
|
|
586
|
+
"function"
|
|
587
|
+
) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`BCP DB: migration ${migration.name} must export async up(db) and down(db) functions.`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
up:
|
|
595
|
+
module.up,
|
|
596
|
+
down:
|
|
597
|
+
module.down,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function normalizeMigrationName(
|
|
602
|
+
value: string | undefined
|
|
603
|
+
): string {
|
|
604
|
+
const normalized =
|
|
605
|
+
String(
|
|
606
|
+
value ?? ""
|
|
607
|
+
)
|
|
608
|
+
.trim()
|
|
609
|
+
.toLowerCase()
|
|
610
|
+
.replace(
|
|
611
|
+
/[^a-z0-9]+/g,
|
|
612
|
+
"_"
|
|
613
|
+
)
|
|
614
|
+
.replace(
|
|
615
|
+
/^_+|_+$/g,
|
|
616
|
+
""
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
if (!normalized) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
"BCP DB: migration name is required. Example: bcp db create create_users"
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return normalized;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function formatMigrationTimestamp(
|
|
629
|
+
value: Date
|
|
630
|
+
): string {
|
|
631
|
+
if (
|
|
632
|
+
Number.isNaN(
|
|
633
|
+
value.getTime()
|
|
634
|
+
)
|
|
635
|
+
) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
"BCP DB: invalid migration timestamp."
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return [
|
|
642
|
+
value.getUTCFullYear(),
|
|
643
|
+
value.getUTCMonth() + 1,
|
|
644
|
+
value.getUTCDate(),
|
|
645
|
+
value.getUTCHours(),
|
|
646
|
+
value.getUTCMinutes(),
|
|
647
|
+
value.getUTCSeconds(),
|
|
648
|
+
]
|
|
649
|
+
.map(
|
|
650
|
+
(
|
|
651
|
+
part,
|
|
652
|
+
index
|
|
653
|
+
) =>
|
|
654
|
+
index === 0
|
|
655
|
+
? String(part)
|
|
656
|
+
.padStart(
|
|
657
|
+
4,
|
|
658
|
+
"0"
|
|
659
|
+
)
|
|
660
|
+
: String(part)
|
|
661
|
+
.padStart(
|
|
662
|
+
2,
|
|
663
|
+
"0"
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
.join("");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function printMigrationStatus(
|
|
670
|
+
statuses: MigrationStatus[]
|
|
671
|
+
): void {
|
|
672
|
+
console.log("");
|
|
673
|
+
console.log(
|
|
674
|
+
"BCP Database Migrations"
|
|
675
|
+
);
|
|
676
|
+
console.log("");
|
|
677
|
+
|
|
678
|
+
if (
|
|
679
|
+
statuses.length === 0
|
|
680
|
+
) {
|
|
681
|
+
console.log(
|
|
682
|
+
" (no migrations)"
|
|
683
|
+
);
|
|
684
|
+
console.log("");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
for (
|
|
689
|
+
const migration
|
|
690
|
+
of statuses
|
|
691
|
+
) {
|
|
692
|
+
const marker =
|
|
693
|
+
migration.status ===
|
|
694
|
+
"applied"
|
|
695
|
+
? "✓"
|
|
696
|
+
: "○";
|
|
697
|
+
const batch =
|
|
698
|
+
migration.batch === null
|
|
699
|
+
? "pending"
|
|
700
|
+
: `batch ${migration.batch}`;
|
|
701
|
+
|
|
702
|
+
console.log(
|
|
703
|
+
` ${marker} ${migration.name} — ${batch}`
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
console.log("");
|
|
708
|
+
}
|