create-windy 0.2.26 → 0.2.28
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/dist/cli.js +36 -1
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/server/src/foundation.ts +1 -1
- package/template/apps/server/src/module-bootstrap.ts +11 -2
- package/template/apps/server/src/module-migrations.test.ts +43 -0
- package/template/apps/server/src/module-migrations.ts +19 -0
- package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +14 -0
- package/template/docs/architecture/module-manifest-composition.md +11 -0
- package/template/packages/database/drizzle/0032_marvelous_pepper_potts.sql +2 -0
- package/template/packages/database/drizzle/meta/0032_snapshot.json +7399 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/index.ts +1 -0
- package/template/packages/database/package.json +4 -1
- package/template/packages/database/src/postgres-module-migrations.integration.test.ts +170 -0
- package/template/packages/database/src/postgres-module-migrations.ts +69 -0
- package/template/packages/database/src/runner.test.ts +5 -1
- package/template/packages/database/src/runner.ts +17 -2
- package/template/packages/database/src/schema/migration-history.ts +3 -0
- package/template/packages/database/src/sql-store.test.ts +7 -1
- package/template/packages/database/src/sql-store.ts +13 -3
- package/template/packages/database/src/store.ts +2 -0
|
@@ -225,6 +225,13 @@
|
|
|
225
225
|
"when": 1784837513572,
|
|
226
226
|
"tag": "0031_panoramic_typhoid_mary",
|
|
227
227
|
"breakpoints": true
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"idx": 32,
|
|
231
|
+
"version": "7",
|
|
232
|
+
"when": 1784922914250,
|
|
233
|
+
"tag": "0032_marvelous_pepper_potts",
|
|
234
|
+
"breakpoints": true
|
|
228
235
|
}
|
|
229
236
|
]
|
|
230
237
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@southwind-ai/database",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -23,5 +23,8 @@
|
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"pg": "^8.22.0"
|
|
26
29
|
}
|
|
27
30
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MIGRATION_BUNDLE_FORMAT_VERSION,
|
|
4
|
+
type MigrationBundle,
|
|
5
|
+
} from "@southwind-ai/shared";
|
|
6
|
+
import { Pool } from "pg";
|
|
7
|
+
import { runPostgresMigrationBundles } from "./postgres-module-migrations.js";
|
|
8
|
+
|
|
9
|
+
const databaseUrl = process.env.MODULE_MIGRATION_TEST_DATABASE_URL;
|
|
10
|
+
const describePostgres = databaseUrl ? describe : describe.skip;
|
|
11
|
+
|
|
12
|
+
describePostgres("PostgreSQL Module Migration 启动执行", () => {
|
|
13
|
+
let pool: Pool;
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await pool?.end();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("并发启动按拓扑串行、记录完整历史、重复幂等并拒绝 checksum 漂移", async () => {
|
|
24
|
+
const base = bundle("customer-domain", "1.2.0", [
|
|
25
|
+
migration(
|
|
26
|
+
"customer-domain",
|
|
27
|
+
"customer-domain.0040",
|
|
28
|
+
"create table module_migration_base (id integer primary key)",
|
|
29
|
+
),
|
|
30
|
+
]);
|
|
31
|
+
const feature = bundle(
|
|
32
|
+
"case-domain",
|
|
33
|
+
"2.0.0",
|
|
34
|
+
[
|
|
35
|
+
migration(
|
|
36
|
+
"case-domain",
|
|
37
|
+
"case-domain.0041",
|
|
38
|
+
"create table module_migration_feature (id integer primary key)",
|
|
39
|
+
["customer-domain.0040"],
|
|
40
|
+
),
|
|
41
|
+
],
|
|
42
|
+
["customer-domain"],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const concurrent = await Promise.all([
|
|
46
|
+
runPostgresMigrationBundles({ pool, bundles: [feature, base] }),
|
|
47
|
+
runPostgresMigrationBundles({ pool, bundles: [feature, base] }),
|
|
48
|
+
]);
|
|
49
|
+
expect(concurrent.flatMap(({ applied }) => applied).sort()).toEqual([
|
|
50
|
+
"case-domain.0041",
|
|
51
|
+
"customer-domain.0040",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const history = await pool.query<{
|
|
55
|
+
id: string;
|
|
56
|
+
module: string;
|
|
57
|
+
module_version: string;
|
|
58
|
+
migration_order: number;
|
|
59
|
+
checksum: string;
|
|
60
|
+
}>(
|
|
61
|
+
`select id, module, module_version, migration_order, checksum
|
|
62
|
+
from windy_migration_history
|
|
63
|
+
where id = any($1::text[])
|
|
64
|
+
order by migration_order`,
|
|
65
|
+
[["customer-domain.0040", "case-domain.0041"]],
|
|
66
|
+
);
|
|
67
|
+
expect(history.rows).toEqual([
|
|
68
|
+
{
|
|
69
|
+
id: "customer-domain.0040",
|
|
70
|
+
module: "customer-domain",
|
|
71
|
+
module_version: "1.2.0",
|
|
72
|
+
migration_order: 0,
|
|
73
|
+
checksum: "sha256:customer-domain.0040",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "case-domain.0041",
|
|
77
|
+
module: "case-domain",
|
|
78
|
+
module_version: "2.0.0",
|
|
79
|
+
migration_order: 1,
|
|
80
|
+
checksum: "sha256:case-domain.0041",
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const repeated = await runPostgresMigrationBundles({
|
|
85
|
+
pool,
|
|
86
|
+
bundles: [feature, base],
|
|
87
|
+
});
|
|
88
|
+
expect(repeated.applied).toEqual([]);
|
|
89
|
+
|
|
90
|
+
const driftedBase = bundle("customer-domain", "1.2.0", [
|
|
91
|
+
{
|
|
92
|
+
...base.migrations[0]!,
|
|
93
|
+
checksum: "sha256:changed",
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
await expect(
|
|
97
|
+
runPostgresMigrationBundles({
|
|
98
|
+
pool,
|
|
99
|
+
bundles: [driftedBase, feature],
|
|
100
|
+
}),
|
|
101
|
+
).rejects.toThrow("checksum 与当前 Bundle 不一致");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("任一 statement 失败会回滚本次全部 schema 与 history", async () => {
|
|
105
|
+
const broken = bundle("broken-domain", "1.0.0", [
|
|
106
|
+
migration(
|
|
107
|
+
"broken-domain",
|
|
108
|
+
"broken-domain.0001",
|
|
109
|
+
"create table module_migration_rollback (id integer primary key)",
|
|
110
|
+
),
|
|
111
|
+
migration(
|
|
112
|
+
"broken-domain",
|
|
113
|
+
"broken-domain.0002",
|
|
114
|
+
"select * from module_migration_missing_relation",
|
|
115
|
+
["broken-domain.0001"],
|
|
116
|
+
),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
runPostgresMigrationBundles({ pool, bundles: [broken] }),
|
|
121
|
+
).rejects.toThrow("broken-domain.0002");
|
|
122
|
+
const state = await pool.query<{
|
|
123
|
+
relation: string | null;
|
|
124
|
+
history_count: string;
|
|
125
|
+
}>(
|
|
126
|
+
`select
|
|
127
|
+
to_regclass('public.module_migration_rollback')::text as relation,
|
|
128
|
+
(select count(*)::text from windy_migration_history
|
|
129
|
+
where module = 'broken-domain') as history_count`,
|
|
130
|
+
);
|
|
131
|
+
expect(state.rows[0]).toEqual({
|
|
132
|
+
relation: null,
|
|
133
|
+
history_count: "0",
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function bundle(
|
|
139
|
+
module: string,
|
|
140
|
+
version: string,
|
|
141
|
+
migrations: MigrationBundle["migrations"],
|
|
142
|
+
dependencies: readonly string[] = [],
|
|
143
|
+
): MigrationBundle {
|
|
144
|
+
return {
|
|
145
|
+
formatVersion: MIGRATION_BUNDLE_FORMAT_VERSION,
|
|
146
|
+
module,
|
|
147
|
+
version,
|
|
148
|
+
dependencies,
|
|
149
|
+
migrations,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function migration(
|
|
154
|
+
module: string,
|
|
155
|
+
id: string,
|
|
156
|
+
sql: string,
|
|
157
|
+
dependsOn: readonly string[] = [],
|
|
158
|
+
): MigrationBundle["migrations"][number] {
|
|
159
|
+
return {
|
|
160
|
+
id,
|
|
161
|
+
module,
|
|
162
|
+
description: id,
|
|
163
|
+
direction: "up",
|
|
164
|
+
checksum: `sha256:${id}`,
|
|
165
|
+
dependsOn,
|
|
166
|
+
async run(context) {
|
|
167
|
+
await context.execute({ sql });
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { MigrationBundle } from "@southwind-ai/shared";
|
|
2
|
+
import { PlatformMigrationRunner, type MigrationRunReport } from "./runner.js";
|
|
3
|
+
import {
|
|
4
|
+
createPostgresStatementExecutor,
|
|
5
|
+
PostgresMigrationHistoryStore,
|
|
6
|
+
type QueryableClient,
|
|
7
|
+
} from "./sql-store.js";
|
|
8
|
+
|
|
9
|
+
const MODULE_MIGRATION_LOCK = "windy.module-migrations.v1";
|
|
10
|
+
|
|
11
|
+
export interface PostgresTransactionClient extends QueryableClient {
|
|
12
|
+
release(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PostgresTransactionPool {
|
|
16
|
+
connect(): Promise<PostgresTransactionClient>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PostgresMigrationBundleOptions {
|
|
20
|
+
pool: PostgresTransactionPool;
|
|
21
|
+
bundles: readonly MigrationBundle[];
|
|
22
|
+
now?: () => Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runPostgresMigrationBundles(
|
|
26
|
+
options: PostgresMigrationBundleOptions,
|
|
27
|
+
): Promise<MigrationRunReport> {
|
|
28
|
+
const client = await options.pool.connect();
|
|
29
|
+
let transactionOpen = false;
|
|
30
|
+
try {
|
|
31
|
+
await client.query("begin");
|
|
32
|
+
transactionOpen = true;
|
|
33
|
+
await client.query("select pg_advisory_xact_lock(hashtext($1))", [
|
|
34
|
+
MODULE_MIGRATION_LOCK,
|
|
35
|
+
]);
|
|
36
|
+
const runner = new PlatformMigrationRunner({
|
|
37
|
+
dialect: "postgresql",
|
|
38
|
+
store: new PostgresMigrationHistoryStore(client),
|
|
39
|
+
execute: createPostgresStatementExecutor(client),
|
|
40
|
+
now: options.now,
|
|
41
|
+
recordFailures: false,
|
|
42
|
+
});
|
|
43
|
+
const report = await runner.runBundles(options.bundles);
|
|
44
|
+
const failure = report.failed[0];
|
|
45
|
+
if (failure) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Module Migration ${failure.id} 执行失败:${failure.error}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
await client.query("commit");
|
|
51
|
+
transactionOpen = false;
|
|
52
|
+
return report;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (transactionOpen) await rollbackWithoutMasking(client);
|
|
55
|
+
throw error;
|
|
56
|
+
} finally {
|
|
57
|
+
client.release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function rollbackWithoutMasking(
|
|
62
|
+
client: PostgresTransactionClient,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
await client.query("rollback");
|
|
66
|
+
} catch {
|
|
67
|
+
// 原始 migration 错误决定启动失败原因,连接释放后由 Pool 负责处置。
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -141,7 +141,11 @@ describe("PlatformMigrationRunner", () => {
|
|
|
141
141
|
"select 'feature.001'",
|
|
142
142
|
"select 'feature.002'",
|
|
143
143
|
]);
|
|
144
|
-
expect((await store.listApplied())[0]
|
|
144
|
+
expect((await store.listApplied())[0]).toMatchObject({
|
|
145
|
+
checksum: "sha256:system-1",
|
|
146
|
+
moduleVersion: "1.0.0",
|
|
147
|
+
migrationOrder: 0,
|
|
148
|
+
});
|
|
145
149
|
});
|
|
146
150
|
|
|
147
151
|
test("repeat bundle 跳过 checksum 相同的已应用迁移", async () => {
|
|
@@ -17,11 +17,14 @@ export interface MigrationRunnerOptions {
|
|
|
17
17
|
store: MigrationHistoryStore;
|
|
18
18
|
execute?: (statement: MigrationStatement) => Promise<void>;
|
|
19
19
|
now?: () => Date;
|
|
20
|
+
recordFailures?: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface MigrationPlanItem {
|
|
23
24
|
id: string;
|
|
24
25
|
module: string;
|
|
26
|
+
moduleVersion: string;
|
|
27
|
+
migrationOrder: number;
|
|
25
28
|
description: string;
|
|
26
29
|
direction: "up" | "down";
|
|
27
30
|
checksum: string;
|
|
@@ -62,9 +65,15 @@ export class PlatformMigrationRunner {
|
|
|
62
65
|
): Promise<MigrationPlanItem[]> {
|
|
63
66
|
const ordered = validateAndOrderBundles(bundles);
|
|
64
67
|
const applied = appliedById(await this.options.store.listApplied());
|
|
68
|
+
let migrationOrder = 0;
|
|
65
69
|
return ordered.flatMap((bundle) =>
|
|
66
70
|
orderBundleMigrations(bundle).map((migration) =>
|
|
67
|
-
planItem(
|
|
71
|
+
planItem(
|
|
72
|
+
migration,
|
|
73
|
+
bundle.version,
|
|
74
|
+
migrationOrder++,
|
|
75
|
+
applied.get(migration.id),
|
|
76
|
+
),
|
|
68
77
|
),
|
|
69
78
|
);
|
|
70
79
|
}
|
|
@@ -119,7 +128,7 @@ export class PlatformMigrationRunner {
|
|
|
119
128
|
} catch (error) {
|
|
120
129
|
const message = error instanceof Error ? error.message : String(error);
|
|
121
130
|
failed.push({ id: item.id, error: message });
|
|
122
|
-
if (!dryRun) {
|
|
131
|
+
if (!dryRun && this.options.recordFailures !== false) {
|
|
123
132
|
await this.recordResult(item, stepStartedAt, stepStatements, message);
|
|
124
133
|
}
|
|
125
134
|
break;
|
|
@@ -147,6 +156,8 @@ export class PlatformMigrationRunner {
|
|
|
147
156
|
await this.options.store.record({
|
|
148
157
|
id: item.id,
|
|
149
158
|
module: item.module,
|
|
159
|
+
moduleVersion: item.moduleVersion,
|
|
160
|
+
migrationOrder: item.migrationOrder,
|
|
150
161
|
direction: item.direction,
|
|
151
162
|
status: error ? "failed" : "applied",
|
|
152
163
|
checksum: item.checksum,
|
|
@@ -164,6 +175,8 @@ function appliedById(entries: readonly MigrationHistoryEntry[]) {
|
|
|
164
175
|
|
|
165
176
|
function planItem(
|
|
166
177
|
migration: BundleMigrationDefinition,
|
|
178
|
+
moduleVersion: string,
|
|
179
|
+
migrationOrder: number,
|
|
167
180
|
applied: MigrationHistoryEntry | undefined,
|
|
168
181
|
): MigrationPlanItem {
|
|
169
182
|
if (applied?.module !== undefined && applied.module !== migration.module) {
|
|
@@ -182,6 +195,8 @@ function planItem(
|
|
|
182
195
|
return {
|
|
183
196
|
id: migration.id,
|
|
184
197
|
module: migration.module,
|
|
198
|
+
moduleVersion,
|
|
199
|
+
migrationOrder,
|
|
185
200
|
description: migration.description,
|
|
186
201
|
direction: migration.direction,
|
|
187
202
|
checksum: migration.checksum,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
index,
|
|
3
|
+
integer,
|
|
3
4
|
jsonb,
|
|
4
5
|
pgTable,
|
|
5
6
|
text,
|
|
@@ -12,6 +13,8 @@ export const windyMigrationHistory = pgTable(
|
|
|
12
13
|
{
|
|
13
14
|
id: varchar("id", { length: 160 }).primaryKey(),
|
|
14
15
|
module: varchar("module", { length: 96 }).notNull(),
|
|
16
|
+
moduleVersion: varchar("module_version", { length: 64 }),
|
|
17
|
+
migrationOrder: integer("migration_order"),
|
|
15
18
|
direction: varchar("direction", { length: 16 }).notNull(),
|
|
16
19
|
status: varchar("status", { length: 16 }).notNull(),
|
|
17
20
|
checksum: varchar("checksum", { length: 128 }),
|
|
@@ -24,6 +24,8 @@ describe("PostgresMigrationHistoryStore", () => {
|
|
|
24
24
|
{
|
|
25
25
|
id: "system.001",
|
|
26
26
|
module: "system",
|
|
27
|
+
module_version: "1.0.0",
|
|
28
|
+
migration_order: 0,
|
|
27
29
|
direction: "up",
|
|
28
30
|
status: "applied",
|
|
29
31
|
checksum: null,
|
|
@@ -39,6 +41,8 @@ describe("PostgresMigrationHistoryStore", () => {
|
|
|
39
41
|
{
|
|
40
42
|
id: "system.001",
|
|
41
43
|
module: "system",
|
|
44
|
+
moduleVersion: "1.0.0",
|
|
45
|
+
migrationOrder: 0,
|
|
42
46
|
direction: "up",
|
|
43
47
|
status: "applied",
|
|
44
48
|
startedAt: "2026-07-09T00:00:00.000Z",
|
|
@@ -55,6 +59,8 @@ describe("PostgresMigrationHistoryStore", () => {
|
|
|
55
59
|
await store.record({
|
|
56
60
|
id: "system.001",
|
|
57
61
|
module: "system",
|
|
62
|
+
moduleVersion: "1.0.0",
|
|
63
|
+
migrationOrder: 0,
|
|
58
64
|
direction: "up",
|
|
59
65
|
status: "applied",
|
|
60
66
|
startedAt: "2026-07-09T00:00:00.000Z",
|
|
@@ -63,7 +69,7 @@ describe("PostgresMigrationHistoryStore", () => {
|
|
|
63
69
|
});
|
|
64
70
|
|
|
65
71
|
expect(client.calls[0]?.sql).toContain("on conflict (id) do update");
|
|
66
|
-
expect(client.calls[0]?.params?.[
|
|
72
|
+
expect(client.calls[0]?.params?.[7]).toBe(
|
|
67
73
|
JSON.stringify(["create table users (id varchar(64))"]),
|
|
68
74
|
);
|
|
69
75
|
});
|
|
@@ -15,6 +15,8 @@ export interface QueryableClient {
|
|
|
15
15
|
interface MigrationHistoryRow {
|
|
16
16
|
id: string;
|
|
17
17
|
module: string;
|
|
18
|
+
module_version: string | null;
|
|
19
|
+
migration_order: number | null;
|
|
18
20
|
direction: "up" | "down";
|
|
19
21
|
status: "applied" | "failed";
|
|
20
22
|
checksum: string | null;
|
|
@@ -29,7 +31,8 @@ export class PostgresMigrationHistoryStore implements MigrationHistoryStore {
|
|
|
29
31
|
|
|
30
32
|
async listApplied(): Promise<MigrationHistoryEntry[]> {
|
|
31
33
|
const result = await this.client.query<MigrationHistoryRow>(
|
|
32
|
-
`select id, module,
|
|
34
|
+
`select id, module, module_version, migration_order, direction, status,
|
|
35
|
+
checksum, statements, error, started_at, finished_at
|
|
33
36
|
from windy_migration_history
|
|
34
37
|
where status = $1
|
|
35
38
|
order by finished_at asc`,
|
|
@@ -42,10 +45,13 @@ export class PostgresMigrationHistoryStore implements MigrationHistoryStore {
|
|
|
42
45
|
async record(entry: MigrationHistoryEntry): Promise<void> {
|
|
43
46
|
await this.client.query(
|
|
44
47
|
`insert into windy_migration_history
|
|
45
|
-
(id, module,
|
|
46
|
-
|
|
48
|
+
(id, module, module_version, migration_order, direction, status,
|
|
49
|
+
checksum, statements, error, started_at, finished_at)
|
|
50
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)
|
|
47
51
|
on conflict (id) do update set
|
|
48
52
|
module = excluded.module,
|
|
53
|
+
module_version = excluded.module_version,
|
|
54
|
+
migration_order = excluded.migration_order,
|
|
49
55
|
direction = excluded.direction,
|
|
50
56
|
status = excluded.status,
|
|
51
57
|
checksum = excluded.checksum,
|
|
@@ -56,6 +62,8 @@ export class PostgresMigrationHistoryStore implements MigrationHistoryStore {
|
|
|
56
62
|
[
|
|
57
63
|
entry.id,
|
|
58
64
|
entry.module,
|
|
65
|
+
entry.moduleVersion || null,
|
|
66
|
+
entry.migrationOrder ?? null,
|
|
59
67
|
entry.direction,
|
|
60
68
|
entry.status,
|
|
61
69
|
entry.checksum || null,
|
|
@@ -86,6 +94,8 @@ function mapHistoryRow(row: MigrationHistoryRow): MigrationHistoryEntry {
|
|
|
86
94
|
return {
|
|
87
95
|
id: row.id,
|
|
88
96
|
module: row.module,
|
|
97
|
+
moduleVersion: row.module_version || undefined,
|
|
98
|
+
migrationOrder: row.migration_order ?? undefined,
|
|
89
99
|
direction: row.direction,
|
|
90
100
|
status: row.status,
|
|
91
101
|
startedAt: toIsoString(row.started_at),
|