@chidchanun/bcp 0.1.14 → 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.
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Database Migrations
|
|
2
|
+
|
|
3
|
+
BCP Framework `0.1.15` adds MySQL migration commands to the `bcp` CLI.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
Database migrations currently use the MySQL adapter from `bcp/database`.
|
|
8
|
+
|
|
9
|
+
Configure the same environment variables used by your application:
|
|
10
|
+
|
|
11
|
+
```env
|
|
12
|
+
DB_HOST=localhost
|
|
13
|
+
DB_PORT=3306
|
|
14
|
+
DB_USER=root
|
|
15
|
+
DB_PASSWORD=
|
|
16
|
+
DB_NAME=bcp_app
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A project using migrations must have `mysql2` installed. Applications created with the MySQL preset already include it.
|
|
20
|
+
|
|
21
|
+
## Create a migration
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bcp db create create_users
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
BCP creates an ordered TypeScript file in `migrations/`:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
migrations/
|
|
31
|
+
20260827040506_create_users.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A generated migration exports `up()` and `down()`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import type {
|
|
38
|
+
TransactionDatabase,
|
|
39
|
+
} from "bcp/database";
|
|
40
|
+
|
|
41
|
+
export async function up(
|
|
42
|
+
db: TransactionDatabase
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
await db.execute(`
|
|
45
|
+
CREATE TABLE users (
|
|
46
|
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
47
|
+
email VARCHAR(255) NOT NULL UNIQUE
|
|
48
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
49
|
+
`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function down(
|
|
53
|
+
db: TransactionDatabase
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
await db.execute(
|
|
56
|
+
"DROP TABLE users"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Migration filenames use a UTC timestamp prefix so migrations have a stable execution order.
|
|
62
|
+
|
|
63
|
+
## Run pending migrations
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
bcp db migrate
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
BCP creates the internal `_bcp_migrations` table when needed, detects files that have not been applied, and runs all pending migrations in filename order.
|
|
70
|
+
|
|
71
|
+
All migrations applied by one `bcp db migrate` command share the same batch number. Each individual migration runs inside its own database transaction. The migration record is inserted in the same transaction as `up()`.
|
|
72
|
+
|
|
73
|
+
## Check status
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bcp db status
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The command reports applied and pending migration files together with the batch number for applied migrations.
|
|
80
|
+
|
|
81
|
+
## Roll back
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bcp db rollback
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Rollback reverses only the latest migration batch. Migrations in that batch run from newest to oldest, and each `down()` runs in a transaction together with removal of its migration record.
|
|
88
|
+
|
|
89
|
+
BCP refuses to roll back an applied migration when its migration file is missing.
|
|
90
|
+
|
|
91
|
+
## Project root
|
|
92
|
+
|
|
93
|
+
All database commands support the normal BCP project-root option:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
bcp db status --root ./apps/admin
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Environment files
|
|
100
|
+
|
|
101
|
+
Database commands load the BCP development environment files before connecting, so the same local database settings used by `bcp dev` can be reused by migration commands.
|
|
102
|
+
|
|
103
|
+
## Current scope
|
|
104
|
+
|
|
105
|
+
`0.1.15` migration execution supports MySQL. PostgreSQL, SQLite and MongoDB presets remain available to `create-bcp-app`, but framework-managed migrations for those adapters are planned for later releases.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chidchanun/bcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/packages/cli/src/args.ts
CHANGED
|
@@ -7,9 +7,17 @@ export type CliCommand =
|
|
|
7
7
|
| "start"
|
|
8
8
|
| "routes"
|
|
9
9
|
| "update"
|
|
10
|
+
| "db"
|
|
10
11
|
| "help"
|
|
11
12
|
| "version";
|
|
12
13
|
|
|
14
|
+
export type DatabaseCliAction =
|
|
15
|
+
| "migrate"
|
|
16
|
+
| "status"
|
|
17
|
+
| "rollback"
|
|
18
|
+
| "create"
|
|
19
|
+
| "help";
|
|
20
|
+
|
|
13
21
|
export interface CliOptions {
|
|
14
22
|
command: CliCommand;
|
|
15
23
|
|
|
@@ -24,6 +32,10 @@ export interface CliOptions {
|
|
|
24
32
|
updateCheck?: boolean;
|
|
25
33
|
|
|
26
34
|
updateDryRun?: boolean;
|
|
35
|
+
|
|
36
|
+
dbAction?: DatabaseCliAction;
|
|
37
|
+
|
|
38
|
+
dbMigrationName?: string;
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
export function parseCliArgs(
|
|
@@ -49,6 +61,12 @@ export function parseCliArgs(
|
|
|
49
61
|
let updateDryRun =
|
|
50
62
|
false;
|
|
51
63
|
|
|
64
|
+
let dbAction:
|
|
65
|
+
DatabaseCliAction | undefined;
|
|
66
|
+
|
|
67
|
+
let dbMigrationName:
|
|
68
|
+
string | undefined;
|
|
69
|
+
|
|
52
70
|
let commandSet = false;
|
|
53
71
|
|
|
54
72
|
for (
|
|
@@ -62,7 +80,14 @@ export function parseCliArgs(
|
|
|
62
80
|
argument === "-h" ||
|
|
63
81
|
argument === "--help"
|
|
64
82
|
) {
|
|
65
|
-
|
|
83
|
+
if (
|
|
84
|
+
commandSet &&
|
|
85
|
+
command === "db"
|
|
86
|
+
) {
|
|
87
|
+
dbAction = "help";
|
|
88
|
+
} else {
|
|
89
|
+
command = "help";
|
|
90
|
+
}
|
|
66
91
|
continue;
|
|
67
92
|
}
|
|
68
93
|
|
|
@@ -221,6 +246,44 @@ export function parseCliArgs(
|
|
|
221
246
|
continue;
|
|
222
247
|
}
|
|
223
248
|
|
|
249
|
+
if (
|
|
250
|
+
commandSet &&
|
|
251
|
+
command === "db"
|
|
252
|
+
) {
|
|
253
|
+
if (
|
|
254
|
+
dbAction === undefined
|
|
255
|
+
) {
|
|
256
|
+
if (
|
|
257
|
+
argument === "migrate" ||
|
|
258
|
+
argument === "status" ||
|
|
259
|
+
argument === "rollback" ||
|
|
260
|
+
argument === "create" ||
|
|
261
|
+
argument === "help"
|
|
262
|
+
) {
|
|
263
|
+
dbAction =
|
|
264
|
+
argument;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
throw new Error(
|
|
269
|
+
`Unknown database command: ${argument}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (
|
|
274
|
+
dbAction === "create" &&
|
|
275
|
+
dbMigrationName === undefined
|
|
276
|
+
) {
|
|
277
|
+
dbMigrationName =
|
|
278
|
+
argument;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Unexpected argument: ${argument}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
224
287
|
if (commandSet) {
|
|
225
288
|
throw new Error(
|
|
226
289
|
`Unexpected argument: ${argument}`
|
|
@@ -233,6 +296,7 @@ export function parseCliArgs(
|
|
|
233
296
|
argument === "start" ||
|
|
234
297
|
argument === "routes" ||
|
|
235
298
|
argument === "update" ||
|
|
299
|
+
argument === "db" ||
|
|
236
300
|
argument === "help" ||
|
|
237
301
|
argument === "version"
|
|
238
302
|
) {
|
|
@@ -266,6 +330,12 @@ export function parseCliArgs(
|
|
|
266
330
|
updateTarget,
|
|
267
331
|
updateCheck,
|
|
268
332
|
updateDryRun,
|
|
333
|
+
...(command === "db"
|
|
334
|
+
? {
|
|
335
|
+
dbAction,
|
|
336
|
+
dbMigrationName,
|
|
337
|
+
}
|
|
338
|
+
: {}),
|
|
269
339
|
};
|
|
270
340
|
}
|
|
271
341
|
|
|
@@ -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
|
+
}
|
|
@@ -66,6 +66,11 @@ switch (cliOptions.command) {
|
|
|
66
66
|
break;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
case "db": {
|
|
70
|
+
await runDatabaseCommand();
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
case "help": {
|
|
70
75
|
printHelp();
|
|
71
76
|
break;
|
|
@@ -381,6 +386,86 @@ async function runUpdate() {
|
|
|
381
386
|
});
|
|
382
387
|
}
|
|
383
388
|
|
|
389
|
+
async function runDatabaseCommand() {
|
|
390
|
+
const action =
|
|
391
|
+
cliOptions.dbAction;
|
|
392
|
+
|
|
393
|
+
if (
|
|
394
|
+
action === undefined ||
|
|
395
|
+
action === "help"
|
|
396
|
+
) {
|
|
397
|
+
printDatabaseHelp();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const rootDirectory =
|
|
402
|
+
resolveProjectRoot(
|
|
403
|
+
cliOptions.rootDirectory
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
process.env.NODE_ENV =
|
|
407
|
+
process.env.NODE_ENV ??
|
|
408
|
+
"development";
|
|
409
|
+
|
|
410
|
+
const {
|
|
411
|
+
loadEnvironment,
|
|
412
|
+
} =
|
|
413
|
+
await import(
|
|
414
|
+
"../../env/src/index.js"
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const environment =
|
|
418
|
+
loadEnvironment(
|
|
419
|
+
rootDirectory,
|
|
420
|
+
"development"
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
if (
|
|
424
|
+
environment.files.length > 0
|
|
425
|
+
) {
|
|
426
|
+
console.log(
|
|
427
|
+
`[BCP Env] development: ${environment.files.join(", ")}`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const {
|
|
432
|
+
runDatabaseMigrationCommand,
|
|
433
|
+
} =
|
|
434
|
+
await import(
|
|
435
|
+
"./database-migrations.js"
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
if (
|
|
439
|
+
action === "create"
|
|
440
|
+
) {
|
|
441
|
+
await runDatabaseMigrationCommand({
|
|
442
|
+
rootDirectory,
|
|
443
|
+
action,
|
|
444
|
+
name:
|
|
445
|
+
cliOptions.dbMigrationName,
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const {
|
|
451
|
+
db,
|
|
452
|
+
} =
|
|
453
|
+
await import(
|
|
454
|
+
"../../client/src/database.js"
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
await runDatabaseMigrationCommand({
|
|
459
|
+
rootDirectory,
|
|
460
|
+
action,
|
|
461
|
+
database:
|
|
462
|
+
db,
|
|
463
|
+
});
|
|
464
|
+
} finally {
|
|
465
|
+
await db.close();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
384
469
|
function installShutdownHandlers(
|
|
385
470
|
server: {
|
|
386
471
|
stop(): Promise<void>;
|
|
@@ -521,6 +606,28 @@ function formatBytes(
|
|
|
521
606
|
).toFixed(2)} MiB`;
|
|
522
607
|
}
|
|
523
608
|
|
|
609
|
+
function printDatabaseHelp() {
|
|
610
|
+
console.log(`
|
|
611
|
+
BCP Database Migrations
|
|
612
|
+
|
|
613
|
+
Usage:
|
|
614
|
+
bcp db <command> [arguments]
|
|
615
|
+
|
|
616
|
+
Commands:
|
|
617
|
+
create <name> Create a timestamped migration in migrations/
|
|
618
|
+
migrate Run all pending migrations
|
|
619
|
+
status Show applied and pending migrations
|
|
620
|
+
rollback Roll back the latest migration batch
|
|
621
|
+
help Show database command help
|
|
622
|
+
|
|
623
|
+
Examples:
|
|
624
|
+
bcp db create create_users
|
|
625
|
+
bcp db migrate
|
|
626
|
+
bcp db status
|
|
627
|
+
bcp db rollback
|
|
628
|
+
`);
|
|
629
|
+
}
|
|
630
|
+
|
|
524
631
|
function printHelp() {
|
|
525
632
|
console.log(`
|
|
526
633
|
BCP Framework v${FRAMEWORK_VERSION}
|
|
@@ -534,6 +641,7 @@ Commands:
|
|
|
534
641
|
start Start the standalone production build
|
|
535
642
|
routes Print discovered page and API routes
|
|
536
643
|
update [target] Update BCP Framework (default target: latest)
|
|
644
|
+
db <command> Manage database migrations
|
|
537
645
|
help Show this help message
|
|
538
646
|
version Show framework version
|
|
539
647
|
|
|
@@ -558,6 +666,9 @@ Examples:
|
|
|
558
666
|
bcp routes
|
|
559
667
|
bcp update
|
|
560
668
|
bcp update --check
|
|
561
|
-
bcp
|
|
669
|
+
bcp db create create_users
|
|
670
|
+
bcp db migrate
|
|
671
|
+
bcp db status
|
|
672
|
+
bcp db rollback
|
|
562
673
|
`);
|
|
563
674
|
}
|