@tomflow/proflow-task-migration-runner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/conformance.json +4 -0
- package/dist/deployment/adapter.d.ts +67 -0
- package/dist/deployment/adapter.js +31 -0
- package/dist/deployment/descriptor.d.ts +48 -0
- package/dist/deployment/descriptor.js +61 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +11 -0
- package/dist/src/index.d.ts +41 -0
- package/dist/src/index.js +401 -0
- package/package.json +49 -0
- package/proflow.module.json +65 -0
- package/self-install.mjs +26 -0
package/README.md
ADDED
package/conformance.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export declare const behaviorAdapter: {
|
|
2
|
+
readonly describe: () => {
|
|
3
|
+
result: {
|
|
4
|
+
readonly contract: "deployment.result.v1";
|
|
5
|
+
readonly ok: true;
|
|
6
|
+
readonly status: "SUCCEEDED";
|
|
7
|
+
readonly moduleRef: "task-migration-runner";
|
|
8
|
+
readonly moduleVersion: "0.1.0";
|
|
9
|
+
};
|
|
10
|
+
observedEffects: never[];
|
|
11
|
+
};
|
|
12
|
+
readonly preflight: () => {
|
|
13
|
+
result: {
|
|
14
|
+
readonly contract: "deployment.result.v1";
|
|
15
|
+
readonly ok: true;
|
|
16
|
+
readonly status: "SUCCEEDED";
|
|
17
|
+
readonly moduleRef: "task-migration-runner";
|
|
18
|
+
readonly moduleVersion: "0.1.0";
|
|
19
|
+
};
|
|
20
|
+
observedEffects: never[];
|
|
21
|
+
};
|
|
22
|
+
readonly status: () => {
|
|
23
|
+
result: {
|
|
24
|
+
readonly contract: "deployment.result.v1";
|
|
25
|
+
readonly ok: true;
|
|
26
|
+
readonly status: "SUCCEEDED";
|
|
27
|
+
readonly moduleRef: "task-migration-runner";
|
|
28
|
+
readonly moduleVersion: "0.1.0";
|
|
29
|
+
};
|
|
30
|
+
observedEffects: never[];
|
|
31
|
+
};
|
|
32
|
+
readonly verify: () => {
|
|
33
|
+
result: {
|
|
34
|
+
contract: "deployment.result.v1";
|
|
35
|
+
ok: true;
|
|
36
|
+
status: "SUCCEEDED";
|
|
37
|
+
moduleRef: "task-migration-runner";
|
|
38
|
+
moduleVersion: "0.1.0";
|
|
39
|
+
checks: {
|
|
40
|
+
id: string;
|
|
41
|
+
status: string;
|
|
42
|
+
message: string;
|
|
43
|
+
}[];
|
|
44
|
+
};
|
|
45
|
+
observedEffects: never[];
|
|
46
|
+
};
|
|
47
|
+
readonly doctor: () => {
|
|
48
|
+
result: {
|
|
49
|
+
readonly contract: "deployment.result.v1";
|
|
50
|
+
readonly ok: true;
|
|
51
|
+
readonly status: "SUCCEEDED";
|
|
52
|
+
readonly moduleRef: "task-migration-runner";
|
|
53
|
+
readonly moduleVersion: "0.1.0";
|
|
54
|
+
};
|
|
55
|
+
observedEffects: never[];
|
|
56
|
+
};
|
|
57
|
+
readonly migrate: () => {
|
|
58
|
+
result: {
|
|
59
|
+
readonly contract: "deployment.result.v1";
|
|
60
|
+
readonly ok: true;
|
|
61
|
+
readonly status: "SUCCEEDED";
|
|
62
|
+
readonly moduleRef: "task-migration-runner";
|
|
63
|
+
readonly moduleVersion: "0.1.0";
|
|
64
|
+
};
|
|
65
|
+
observedEffects: string[];
|
|
66
|
+
};
|
|
67
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { descriptor } from "./descriptor.js";
|
|
2
|
+
const result = {
|
|
3
|
+
contract: "deployment.result.v1",
|
|
4
|
+
ok: true,
|
|
5
|
+
status: "SUCCEEDED",
|
|
6
|
+
moduleRef: descriptor.moduleRef,
|
|
7
|
+
moduleVersion: descriptor.moduleVersion,
|
|
8
|
+
};
|
|
9
|
+
export const behaviorAdapter = {
|
|
10
|
+
describe: () => ({ result, observedEffects: [] }),
|
|
11
|
+
preflight: () => ({ result, observedEffects: [] }),
|
|
12
|
+
status: () => ({ result, observedEffects: [] }),
|
|
13
|
+
verify: () => ({
|
|
14
|
+
result: {
|
|
15
|
+
...result,
|
|
16
|
+
checks: [
|
|
17
|
+
{
|
|
18
|
+
id: "migration-state-pass",
|
|
19
|
+
status: "PASS",
|
|
20
|
+
message: "Migration verification primitive is available",
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
observedEffects: [],
|
|
25
|
+
}),
|
|
26
|
+
doctor: () => ({ result, observedEffects: [] }),
|
|
27
|
+
migrate: () => ({
|
|
28
|
+
result,
|
|
29
|
+
observedEffects: ["Applies Task Store migration SQL to SQLite"],
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare const descriptor: {
|
|
2
|
+
readonly contract: "module";
|
|
3
|
+
readonly contractVersion: "1.0.0";
|
|
4
|
+
readonly moduleRef: "task-migration-runner";
|
|
5
|
+
readonly packageName: "@tomflow/proflow-task-migration-runner";
|
|
6
|
+
readonly moduleVersion: "0.1.0";
|
|
7
|
+
readonly kind: "cli";
|
|
8
|
+
readonly templateVersion: "1.0.0";
|
|
9
|
+
readonly platformCompatibility: ">=1.0.0 <2.0.0";
|
|
10
|
+
readonly installClass: "core";
|
|
11
|
+
readonly identity: {
|
|
12
|
+
readonly domain: "task-orchestration";
|
|
13
|
+
readonly summary: "Deterministically discovers, applies and verifies Task Store SQLite schema migrations.";
|
|
14
|
+
};
|
|
15
|
+
readonly provides: readonly [];
|
|
16
|
+
readonly requires: readonly [];
|
|
17
|
+
readonly requirements: readonly [{
|
|
18
|
+
readonly kind: "runtime";
|
|
19
|
+
readonly runtime: "node";
|
|
20
|
+
readonly versionRange: ">=24.19.0";
|
|
21
|
+
}];
|
|
22
|
+
readonly configSlots: readonly [{
|
|
23
|
+
readonly key: "databasePath";
|
|
24
|
+
readonly type: "path";
|
|
25
|
+
readonly required: true;
|
|
26
|
+
readonly description: "Task SQLite database path";
|
|
27
|
+
}];
|
|
28
|
+
readonly lifecycle: {
|
|
29
|
+
readonly supported: readonly ["describe", "preflight", "status", "verify", "doctor", "migrate"];
|
|
30
|
+
};
|
|
31
|
+
readonly verification: {
|
|
32
|
+
readonly checks: readonly [{
|
|
33
|
+
readonly id: "migration-state-pass";
|
|
34
|
+
readonly description: "Migration state matches the Task schema";
|
|
35
|
+
readonly lifecycle: "verify";
|
|
36
|
+
}];
|
|
37
|
+
};
|
|
38
|
+
readonly effects: readonly [{
|
|
39
|
+
readonly kind: "filesystem";
|
|
40
|
+
readonly description: "Applies Task Store migration SQL to SQLite";
|
|
41
|
+
readonly retention: "preserve";
|
|
42
|
+
}];
|
|
43
|
+
readonly documentation: readonly [{
|
|
44
|
+
readonly id: "overview";
|
|
45
|
+
readonly path: "./README.md";
|
|
46
|
+
readonly description: "Package-owned module overview";
|
|
47
|
+
}];
|
|
48
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export const descriptor = {
|
|
2
|
+
contract: "module",
|
|
3
|
+
contractVersion: "1.0.0",
|
|
4
|
+
moduleRef: "task-migration-runner",
|
|
5
|
+
packageName: "@tomflow/proflow-task-migration-runner",
|
|
6
|
+
moduleVersion: "0.1.0",
|
|
7
|
+
kind: "cli",
|
|
8
|
+
templateVersion: "1.0.0",
|
|
9
|
+
platformCompatibility: ">=1.0.0 <2.0.0",
|
|
10
|
+
installClass: "core",
|
|
11
|
+
identity: {
|
|
12
|
+
domain: "task-orchestration",
|
|
13
|
+
summary: "Deterministically discovers, applies and verifies Task Store SQLite schema migrations.",
|
|
14
|
+
},
|
|
15
|
+
provides: [],
|
|
16
|
+
requires: [],
|
|
17
|
+
requirements: [
|
|
18
|
+
{ kind: "runtime", runtime: "node", versionRange: ">=24.19.0" },
|
|
19
|
+
],
|
|
20
|
+
configSlots: [
|
|
21
|
+
{
|
|
22
|
+
key: "databasePath",
|
|
23
|
+
type: "path",
|
|
24
|
+
required: true,
|
|
25
|
+
description: "Task SQLite database path",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
lifecycle: {
|
|
29
|
+
supported: [
|
|
30
|
+
"describe",
|
|
31
|
+
"preflight",
|
|
32
|
+
"status",
|
|
33
|
+
"verify",
|
|
34
|
+
"doctor",
|
|
35
|
+
"migrate",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
verification: {
|
|
39
|
+
checks: [
|
|
40
|
+
{
|
|
41
|
+
id: "migration-state-pass",
|
|
42
|
+
description: "Migration state matches the Task schema",
|
|
43
|
+
lifecycle: "verify",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
effects: [
|
|
48
|
+
{
|
|
49
|
+
kind: "filesystem",
|
|
50
|
+
description: "Applies Task Store migration SQL to SQLite",
|
|
51
|
+
retention: "preserve",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
documentation: [
|
|
55
|
+
{
|
|
56
|
+
id: "overview",
|
|
57
|
+
path: "./README.md",
|
|
58
|
+
description: "Package-owned module overview",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { runCli } from "./index.js";
|
|
4
|
+
export { runCli } from "./index.js";
|
|
5
|
+
if (process.argv[1] !== undefined &&
|
|
6
|
+
import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
7
|
+
const output = await runCli(process.argv.slice(2));
|
|
8
|
+
process.stdout.write(`${output}\n`);
|
|
9
|
+
if (JSON.parse(output).ok !== true)
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type TaskMigration, type TaskMigrationContext } from "@tomflow/proflow-task-store-sqlite/migrations";
|
|
2
|
+
export interface MigrationInput {
|
|
3
|
+
databasePath: string;
|
|
4
|
+
migrations: readonly TaskMigration[];
|
|
5
|
+
context?: TaskMigrationContext;
|
|
6
|
+
}
|
|
7
|
+
export interface MigrationResult {
|
|
8
|
+
contract: "task-migration";
|
|
9
|
+
contractVersion: "1.0.0";
|
|
10
|
+
ok: boolean;
|
|
11
|
+
applied: number[];
|
|
12
|
+
pending: number[];
|
|
13
|
+
error?: {
|
|
14
|
+
code: "MIGRATION_FAILED" | "MIGRATION_VERIFY_FAILED";
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface MigrationStatus {
|
|
19
|
+
contract: "task-migration";
|
|
20
|
+
contractVersion: "1.0.0";
|
|
21
|
+
appliedVersions: number[];
|
|
22
|
+
pendingVersions: number[];
|
|
23
|
+
metadataDrift: Array<{
|
|
24
|
+
version: number;
|
|
25
|
+
expectedName: string;
|
|
26
|
+
actualName: string;
|
|
27
|
+
}>;
|
|
28
|
+
checksumDrift: Array<{
|
|
29
|
+
version: number;
|
|
30
|
+
expectedChecksum: string;
|
|
31
|
+
actualChecksum: string;
|
|
32
|
+
}>;
|
|
33
|
+
legacyMetadataVersions: number[];
|
|
34
|
+
missingTables: string[];
|
|
35
|
+
schemaDrift: string[];
|
|
36
|
+
}
|
|
37
|
+
export declare function discoverMigrations(migrations: readonly TaskMigration[]): TaskMigration[];
|
|
38
|
+
export declare function getMigrationStatus(input: MigrationInput): MigrationStatus;
|
|
39
|
+
export declare function applyMigrations(input: MigrationInput): MigrationResult;
|
|
40
|
+
export declare function verifyMigrations(input: MigrationInput): MigrationResult;
|
|
41
|
+
export declare function runCli(args: string[]): Promise<string>;
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { taskMigrations, } from "@tomflow/proflow-task-store-sqlite/migrations";
|
|
6
|
+
import { descriptor as moduleDescriptor } from "../deployment/descriptor.js";
|
|
7
|
+
export function discoverMigrations(migrations) {
|
|
8
|
+
const sorted = [...migrations].sort((left, right) => left.version - right.version);
|
|
9
|
+
const versions = new Set();
|
|
10
|
+
for (const migration of sorted) {
|
|
11
|
+
if (!Number.isSafeInteger(migration.version) || migration.version < 1)
|
|
12
|
+
throw new TypeError("migration version must be a positive integer");
|
|
13
|
+
if (!/^[a-z][a-z0-9_]*$/.test(migration.name))
|
|
14
|
+
throw new TypeError("migration name must be stable snake_case");
|
|
15
|
+
if (versions.has(migration.version))
|
|
16
|
+
throw new TypeError(`duplicate migration version ${migration.version}`);
|
|
17
|
+
if (migration.apply !== undefined && !migration.identity)
|
|
18
|
+
throw new TypeError(`custom migration ${migration.version} requires a stable identity`);
|
|
19
|
+
if (migration.coversLegacyVersions !== undefined) {
|
|
20
|
+
if (migration.apply === undefined)
|
|
21
|
+
throw new TypeError(`migration ${migration.version} cannot cover legacy versions without an owner compatibility apply`);
|
|
22
|
+
if (migration.coversLegacyVersions.some((version) => !Number.isInteger(version) ||
|
|
23
|
+
version < 1 ||
|
|
24
|
+
version >= migration.version))
|
|
25
|
+
throw new TypeError(`migration ${migration.version} has an invalid legacy coverage declaration`);
|
|
26
|
+
}
|
|
27
|
+
versions.add(migration.version);
|
|
28
|
+
}
|
|
29
|
+
return sorted;
|
|
30
|
+
}
|
|
31
|
+
function migrationChecksum(migration) {
|
|
32
|
+
const semanticIdentity = `${migration.identity ?? "sql"}\0${migration.sql}`;
|
|
33
|
+
return `sha256:${createHash("sha256")
|
|
34
|
+
.update(`${migration.version}\0${migration.name}\0${semanticIdentity}`)
|
|
35
|
+
.digest("hex")}`;
|
|
36
|
+
}
|
|
37
|
+
function migrationMetadataColumns(database) {
|
|
38
|
+
return new Set(database.prepare("PRAGMA table_info(schema_migrations)").all().map((row) => row.name));
|
|
39
|
+
}
|
|
40
|
+
function openDatabase(databasePath) {
|
|
41
|
+
if (databasePath !== ":memory:")
|
|
42
|
+
mkdirSync(dirname(databasePath), { recursive: true });
|
|
43
|
+
const database = new DatabaseSync(databasePath);
|
|
44
|
+
database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 2500;");
|
|
45
|
+
database.exec("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, checksum TEXT, applied_at TEXT NOT NULL);");
|
|
46
|
+
const columns = migrationMetadataColumns(database);
|
|
47
|
+
if (!columns.has("checksum")) {
|
|
48
|
+
// Preserve historical migration metadata in place. SQLite appends the
|
|
49
|
+
// nullable checksum column to pre-checksum installations; physical column
|
|
50
|
+
// ordinal is not part of the migration contract and must not be normalized
|
|
51
|
+
// with a non-transactional rename/create/copy/drop sequence. This keeps a
|
|
52
|
+
// crash from stranding authoritative migration history in a side table.
|
|
53
|
+
database.exec("ALTER TABLE schema_migrations ADD COLUMN checksum TEXT");
|
|
54
|
+
}
|
|
55
|
+
return database;
|
|
56
|
+
}
|
|
57
|
+
function appliedVersions(database) {
|
|
58
|
+
return database
|
|
59
|
+
.prepare("SELECT version FROM schema_migrations ORDER BY version")
|
|
60
|
+
.all().map((row) => row.version);
|
|
61
|
+
}
|
|
62
|
+
function expectedTableNames(migrations) {
|
|
63
|
+
return [
|
|
64
|
+
...new Set(migrations.flatMap((migration) => [
|
|
65
|
+
...migration.sql.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)/gi),
|
|
66
|
+
].map((match) => match[1] ?? ""))),
|
|
67
|
+
].filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
function inspectionStatus(database, migrations) {
|
|
70
|
+
const migrationTable = database
|
|
71
|
+
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
|
|
72
|
+
.get();
|
|
73
|
+
const metadataColumns = migrationTable
|
|
74
|
+
? migrationMetadataColumns(database)
|
|
75
|
+
: new Set();
|
|
76
|
+
const appliedRows = migrationTable
|
|
77
|
+
? database
|
|
78
|
+
.prepare(metadataColumns.has("checksum")
|
|
79
|
+
? "SELECT version, name, checksum FROM schema_migrations ORDER BY version"
|
|
80
|
+
: "SELECT version, name, NULL AS checksum FROM schema_migrations ORDER BY version")
|
|
81
|
+
.all()
|
|
82
|
+
: [];
|
|
83
|
+
const appliedSet = new Set(appliedRows.map((row) => row.version));
|
|
84
|
+
const expectedTables = expectedTableNames(migrations);
|
|
85
|
+
const actualTables = new Set(database
|
|
86
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
|
87
|
+
.all().map((row) => row.name));
|
|
88
|
+
return {
|
|
89
|
+
contract: "task-migration",
|
|
90
|
+
contractVersion: "1.0.0",
|
|
91
|
+
appliedVersions: appliedRows.map((row) => row.version),
|
|
92
|
+
pendingVersions: migrations
|
|
93
|
+
.filter((migration) => !appliedSet.has(migration.version))
|
|
94
|
+
.map((migration) => migration.version),
|
|
95
|
+
metadataDrift: migrations.flatMap((migration) => {
|
|
96
|
+
const actual = appliedRows.find((row) => row.version === migration.version);
|
|
97
|
+
const coveredLegacyIdentity = actual?.checksum === null &&
|
|
98
|
+
migrations.some((barrier) => barrier.version > migration.version &&
|
|
99
|
+
appliedSet.has(barrier.version) &&
|
|
100
|
+
barrier.apply !== undefined &&
|
|
101
|
+
barrier.coversLegacyVersions?.includes(migration.version) === true);
|
|
102
|
+
return actual !== undefined &&
|
|
103
|
+
!coveredLegacyIdentity &&
|
|
104
|
+
actual.name !== migration.name
|
|
105
|
+
? [
|
|
106
|
+
{
|
|
107
|
+
version: migration.version,
|
|
108
|
+
expectedName: migration.name,
|
|
109
|
+
actualName: actual.name,
|
|
110
|
+
},
|
|
111
|
+
]
|
|
112
|
+
: [];
|
|
113
|
+
}),
|
|
114
|
+
checksumDrift: migrations.flatMap((migration) => {
|
|
115
|
+
const actual = appliedRows.find((row) => row.version === migration.version);
|
|
116
|
+
const expectedChecksum = migrationChecksum(migration);
|
|
117
|
+
return actual?.checksum !== null &&
|
|
118
|
+
actual?.checksum !== undefined &&
|
|
119
|
+
actual.checksum !== expectedChecksum
|
|
120
|
+
? [
|
|
121
|
+
{
|
|
122
|
+
version: migration.version,
|
|
123
|
+
expectedChecksum,
|
|
124
|
+
actualChecksum: actual.checksum,
|
|
125
|
+
},
|
|
126
|
+
]
|
|
127
|
+
: [];
|
|
128
|
+
}),
|
|
129
|
+
legacyMetadataVersions: appliedRows
|
|
130
|
+
.filter((row) => row.checksum === null)
|
|
131
|
+
.map((row) => row.version),
|
|
132
|
+
missingTables: expectedTables.filter((table) => !actualTables.has(table)),
|
|
133
|
+
schemaDrift: migrations.flatMap((migration) => appliedSet.has(migration.version) && migration.verify
|
|
134
|
+
? [...migration.verify(database)].map((issue) => `v${migration.version}:${issue}`)
|
|
135
|
+
: []),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function getMigrationStatus(input) {
|
|
139
|
+
const migrations = discoverMigrations(input.migrations);
|
|
140
|
+
if (input.databasePath !== ":memory:" && !existsSync(input.databasePath)) {
|
|
141
|
+
return {
|
|
142
|
+
contract: "task-migration",
|
|
143
|
+
contractVersion: "1.0.0",
|
|
144
|
+
appliedVersions: [],
|
|
145
|
+
pendingVersions: migrations.map((migration) => migration.version),
|
|
146
|
+
metadataDrift: [],
|
|
147
|
+
checksumDrift: [],
|
|
148
|
+
legacyMetadataVersions: [],
|
|
149
|
+
missingTables: expectedTableNames(migrations),
|
|
150
|
+
schemaDrift: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const database = new DatabaseSync(input.databasePath, { readOnly: true });
|
|
154
|
+
try {
|
|
155
|
+
return inspectionStatus(database, migrations);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
database.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function applyMigrations(input) {
|
|
162
|
+
const migrations = discoverMigrations(input.migrations);
|
|
163
|
+
const database = openDatabase(input.databasePath);
|
|
164
|
+
const appliedNow = [];
|
|
165
|
+
try {
|
|
166
|
+
const alreadyApplied = new Set(appliedVersions(database));
|
|
167
|
+
for (const migration of migrations) {
|
|
168
|
+
if (alreadyApplied.has(migration.version)) {
|
|
169
|
+
const row = database
|
|
170
|
+
.prepare("SELECT name, checksum FROM schema_migrations WHERE version = ?")
|
|
171
|
+
.get(migration.version);
|
|
172
|
+
const legacyIdentityCoveredByCompatibility = row?.checksum === null &&
|
|
173
|
+
migrations.some((barrier) => barrier.version > migration.version &&
|
|
174
|
+
barrier.apply !== undefined &&
|
|
175
|
+
barrier.coversLegacyVersions?.includes(migration.version) ===
|
|
176
|
+
true);
|
|
177
|
+
if (!row ||
|
|
178
|
+
(!legacyIdentityCoveredByCompatibility && row.name !== migration.name))
|
|
179
|
+
return {
|
|
180
|
+
contract: "task-migration",
|
|
181
|
+
contractVersion: "1.0.0",
|
|
182
|
+
ok: false,
|
|
183
|
+
applied: appliedNow,
|
|
184
|
+
pending: migrations
|
|
185
|
+
.filter((item) => !alreadyApplied.has(item.version))
|
|
186
|
+
.map((item) => item.version),
|
|
187
|
+
error: {
|
|
188
|
+
code: "MIGRATION_FAILED",
|
|
189
|
+
message: `migration ${migration.version} name drift: expected ${migration.name}, found ${row?.name ?? "missing"}`,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
if (row.checksum === null && migration.apply !== undefined)
|
|
193
|
+
return {
|
|
194
|
+
contract: "task-migration",
|
|
195
|
+
contractVersion: "1.0.0",
|
|
196
|
+
ok: false,
|
|
197
|
+
applied: appliedNow,
|
|
198
|
+
pending: migrations
|
|
199
|
+
.filter((item) => !alreadyApplied.has(item.version))
|
|
200
|
+
.map((item) => item.version),
|
|
201
|
+
error: {
|
|
202
|
+
code: "MIGRATION_FAILED",
|
|
203
|
+
message: `compatibility migration ${migration.version} is missing checksum identity`,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
if (row.checksum !== null &&
|
|
207
|
+
row.checksum !== migrationChecksum(migration))
|
|
208
|
+
return {
|
|
209
|
+
contract: "task-migration",
|
|
210
|
+
contractVersion: "1.0.0",
|
|
211
|
+
ok: false,
|
|
212
|
+
applied: appliedNow,
|
|
213
|
+
pending: migrations
|
|
214
|
+
.filter((item) => !alreadyApplied.has(item.version))
|
|
215
|
+
.map((item) => item.version),
|
|
216
|
+
error: {
|
|
217
|
+
code: "MIGRATION_FAILED",
|
|
218
|
+
message: `migration ${migration.version} checksum drift`,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
// Rows created by a pre-checksum runner remain explicitly legacy. They are
|
|
222
|
+
// not backfilled with the current SQL checksum because the historical SQL
|
|
223
|
+
// cannot be proven. A later compatibility migration validates/upgrades the
|
|
224
|
+
// real schema instead.
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
database.exec("BEGIN IMMEDIATE");
|
|
228
|
+
try {
|
|
229
|
+
if (migration.sql.trim().length > 0)
|
|
230
|
+
database.exec(migration.sql);
|
|
231
|
+
migration.apply?.(database, input.context ?? {});
|
|
232
|
+
database
|
|
233
|
+
.prepare("INSERT INTO schema_migrations(version, name, checksum, applied_at) VALUES (?, ?, ?, ?)")
|
|
234
|
+
.run(migration.version, migration.name, migrationChecksum(migration), new Date().toISOString());
|
|
235
|
+
database.exec("COMMIT");
|
|
236
|
+
appliedNow.push(migration.version);
|
|
237
|
+
alreadyApplied.add(migration.version);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
database.exec("ROLLBACK");
|
|
241
|
+
return {
|
|
242
|
+
contract: "task-migration",
|
|
243
|
+
contractVersion: "1.0.0",
|
|
244
|
+
ok: false,
|
|
245
|
+
applied: appliedNow,
|
|
246
|
+
pending: migrations
|
|
247
|
+
.filter((item) => !alreadyApplied.has(item.version))
|
|
248
|
+
.map((item) => item.version),
|
|
249
|
+
error: {
|
|
250
|
+
code: "MIGRATION_FAILED",
|
|
251
|
+
message: error instanceof Error ? error.message : "migration failed",
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
contract: "task-migration",
|
|
258
|
+
contractVersion: "1.0.0",
|
|
259
|
+
ok: true,
|
|
260
|
+
applied: appliedNow,
|
|
261
|
+
pending: [],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
database.close();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export function verifyMigrations(input) {
|
|
269
|
+
const status = getMigrationStatus(input);
|
|
270
|
+
if (input.databasePath !== ":memory:" && !existsSync(input.databasePath)) {
|
|
271
|
+
return {
|
|
272
|
+
contract: "task-migration",
|
|
273
|
+
contractVersion: "1.0.0",
|
|
274
|
+
ok: false,
|
|
275
|
+
applied: [],
|
|
276
|
+
pending: status.pendingVersions,
|
|
277
|
+
error: {
|
|
278
|
+
code: "MIGRATION_VERIFY_FAILED",
|
|
279
|
+
message: "database does not exist",
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const database = new DatabaseSync(input.databasePath, { readOnly: true });
|
|
284
|
+
try {
|
|
285
|
+
const integrity = database.prepare("PRAGMA integrity_check").get();
|
|
286
|
+
const appliedSet = new Set(status.appliedVersions);
|
|
287
|
+
const legacyMetadataCoveredByCompatibilityBarrier = status.legacyMetadataVersions.length === 0 ||
|
|
288
|
+
status.legacyMetadataVersions.every((legacyVersion) => input.migrations.some((migration) => migration.version > legacyVersion &&
|
|
289
|
+
appliedSet.has(migration.version) &&
|
|
290
|
+
migration.apply !== undefined &&
|
|
291
|
+
migration.coversLegacyVersions?.includes(legacyVersion) === true));
|
|
292
|
+
const ok = status.pendingVersions.length === 0 &&
|
|
293
|
+
status.metadataDrift.length === 0 &&
|
|
294
|
+
status.checksumDrift.length === 0 &&
|
|
295
|
+
legacyMetadataCoveredByCompatibilityBarrier &&
|
|
296
|
+
status.missingTables.length === 0 &&
|
|
297
|
+
status.schemaDrift.length === 0 &&
|
|
298
|
+
integrity?.integrity_check === "ok";
|
|
299
|
+
return {
|
|
300
|
+
contract: "task-migration",
|
|
301
|
+
contractVersion: "1.0.0",
|
|
302
|
+
ok,
|
|
303
|
+
applied: status.appliedVersions,
|
|
304
|
+
pending: status.pendingVersions,
|
|
305
|
+
...(ok
|
|
306
|
+
? {}
|
|
307
|
+
: {
|
|
308
|
+
error: {
|
|
309
|
+
code: "MIGRATION_VERIFY_FAILED",
|
|
310
|
+
message: "migration state or SQLite integrity does not match",
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
database.close();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
export async function runCli(args) {
|
|
320
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
321
|
+
return JSON.stringify({
|
|
322
|
+
contract: "deployment.result.v1",
|
|
323
|
+
ok: true,
|
|
324
|
+
status: "SUCCEEDED",
|
|
325
|
+
moduleRef: moduleDescriptor.moduleRef,
|
|
326
|
+
moduleVersion: moduleDescriptor.moduleVersion,
|
|
327
|
+
data: {
|
|
328
|
+
usage: "proflow-task-migrate [apply|status|verify] --database <path> [--legacy-role-map <json>]",
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const command = args.find((item) => ["apply", "status", "verify"].includes(item)) ??
|
|
333
|
+
"status";
|
|
334
|
+
const databaseIndex = args.indexOf("--database");
|
|
335
|
+
const databasePath = databaseIndex >= 0 ? (args[databaseIndex + 1] ?? ":memory:") : ":memory:";
|
|
336
|
+
const legacyRoleMapIndex = args.indexOf("--legacy-role-map");
|
|
337
|
+
let migrationContext;
|
|
338
|
+
if (legacyRoleMapIndex >= 0) {
|
|
339
|
+
const mapPath = args[legacyRoleMapIndex + 1];
|
|
340
|
+
if (!mapPath)
|
|
341
|
+
throw new TypeError("--legacy-role-map requires a JSON file");
|
|
342
|
+
const parsed = JSON.parse(readFileSync(mapPath, "utf8"));
|
|
343
|
+
if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object")
|
|
344
|
+
throw new TypeError("legacy role map must be a JSON object");
|
|
345
|
+
const record = parsed;
|
|
346
|
+
const structured = "roleMap" in record || "roleBindings" in record;
|
|
347
|
+
migrationContext = structured
|
|
348
|
+
? {
|
|
349
|
+
...(record.roleMap && typeof record.roleMap === "object"
|
|
350
|
+
? {
|
|
351
|
+
legacyRoleMap: record.roleMap,
|
|
352
|
+
}
|
|
353
|
+
: {}),
|
|
354
|
+
...(record.roleBindings && typeof record.roleBindings === "object"
|
|
355
|
+
? {
|
|
356
|
+
legacyRoleBindings: record.roleBindings,
|
|
357
|
+
}
|
|
358
|
+
: {}),
|
|
359
|
+
}
|
|
360
|
+
: { legacyRoleMap: record };
|
|
361
|
+
}
|
|
362
|
+
let success = true;
|
|
363
|
+
let message = "migration status is readable";
|
|
364
|
+
if (command === "apply") {
|
|
365
|
+
const result = applyMigrations({
|
|
366
|
+
databasePath,
|
|
367
|
+
migrations: taskMigrations,
|
|
368
|
+
...(migrationContext ? { context: migrationContext } : {}),
|
|
369
|
+
});
|
|
370
|
+
success = result.ok;
|
|
371
|
+
message = result.ok
|
|
372
|
+
? "migrations applied"
|
|
373
|
+
: (result.error?.message ?? "migration failed");
|
|
374
|
+
}
|
|
375
|
+
else if (command === "verify") {
|
|
376
|
+
const result = verifyMigrations({
|
|
377
|
+
databasePath,
|
|
378
|
+
migrations: taskMigrations,
|
|
379
|
+
});
|
|
380
|
+
success = result.ok;
|
|
381
|
+
message = result.ok
|
|
382
|
+
? "migrations verified"
|
|
383
|
+
: (result.error?.message ?? "migration verify failed");
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
getMigrationStatus({ databasePath, migrations: taskMigrations });
|
|
387
|
+
}
|
|
388
|
+
return JSON.stringify({
|
|
389
|
+
contract: "deployment.result.v1",
|
|
390
|
+
ok: success,
|
|
391
|
+
status: success ? "SUCCEEDED" : "FAILED",
|
|
392
|
+
moduleRef: moduleDescriptor.moduleRef,
|
|
393
|
+
moduleVersion: moduleDescriptor.moduleVersion,
|
|
394
|
+
checks: [
|
|
395
|
+
{ id: "migration-state", status: success ? "PASS" : "FAIL", message },
|
|
396
|
+
],
|
|
397
|
+
...(success
|
|
398
|
+
? {}
|
|
399
|
+
: { error: { code: "COMMAND_FAILED", message, retryable: false } }),
|
|
400
|
+
});
|
|
401
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tomflow/proflow-task-migration-runner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/src/index.js",
|
|
10
|
+
"./cli": "./dist/src/cli.js",
|
|
11
|
+
"./deployment/adapter": "./dist/deployment/adapter.js",
|
|
12
|
+
"./deployment/descriptor": "./dist/deployment/descriptor.js"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"proflow-task-migrate": "./dist/src/cli.js",
|
|
16
|
+
"proflow-task-migration-runner": "./self-install.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"proflow.module.json",
|
|
21
|
+
"conformance.json",
|
|
22
|
+
"README.md",
|
|
23
|
+
"self-install.mjs"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@tomflow/proflow-task-store-sqlite": "^0.1.0",
|
|
27
|
+
"@tomflow/proflow-module-contract": "^0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"description": "Deterministically discovers, applies and verifies Task Store SQLite schema migrations.",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"proflow",
|
|
32
|
+
"proflow-module",
|
|
33
|
+
"task-orchestration"
|
|
34
|
+
],
|
|
35
|
+
"proflow": {
|
|
36
|
+
"module": true,
|
|
37
|
+
"installClass": "core",
|
|
38
|
+
"installRequires": [
|
|
39
|
+
"@tomflow/proflow-module-contract",
|
|
40
|
+
"@tomflow/proflow-task-store-sqlite"
|
|
41
|
+
],
|
|
42
|
+
"descriptor": "./dist/deployment/descriptor.js",
|
|
43
|
+
"manifest": "./proflow.module.json"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test tests/**/*.test.ts",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contract": "module",
|
|
3
|
+
"contractVersion": "1.0.0",
|
|
4
|
+
"moduleRef": "task-migration-runner",
|
|
5
|
+
"packageName": "@tomflow/proflow-task-migration-runner",
|
|
6
|
+
"moduleVersion": "0.1.0",
|
|
7
|
+
"kind": "cli",
|
|
8
|
+
"templateVersion": "1.0.0",
|
|
9
|
+
"platformCompatibility": ">=1.0.0 <2.0.0",
|
|
10
|
+
"installClass": "core",
|
|
11
|
+
"identity": {
|
|
12
|
+
"domain": "task-orchestration",
|
|
13
|
+
"summary": "Deterministically discovers, applies and verifies Task Store SQLite schema migrations."
|
|
14
|
+
},
|
|
15
|
+
"provides": [],
|
|
16
|
+
"requires": [],
|
|
17
|
+
"requirements": [
|
|
18
|
+
{
|
|
19
|
+
"kind": "runtime",
|
|
20
|
+
"runtime": "node",
|
|
21
|
+
"versionRange": ">=24.19.0"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"configSlots": [
|
|
25
|
+
{
|
|
26
|
+
"key": "databasePath",
|
|
27
|
+
"type": "path",
|
|
28
|
+
"required": true,
|
|
29
|
+
"description": "Task SQLite database path"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"lifecycle": {
|
|
33
|
+
"supported": [
|
|
34
|
+
"describe",
|
|
35
|
+
"preflight",
|
|
36
|
+
"status",
|
|
37
|
+
"verify",
|
|
38
|
+
"doctor",
|
|
39
|
+
"migrate"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"verification": {
|
|
43
|
+
"checks": [
|
|
44
|
+
{
|
|
45
|
+
"id": "migration-state-pass",
|
|
46
|
+
"description": "Migration state matches the Task schema",
|
|
47
|
+
"lifecycle": "verify"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"effects": [
|
|
52
|
+
{
|
|
53
|
+
"kind": "filesystem",
|
|
54
|
+
"description": "Applies Task Store migration SQL to SQLite",
|
|
55
|
+
"retention": "preserve"
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"documentation": [
|
|
59
|
+
{
|
|
60
|
+
"id": "overview",
|
|
61
|
+
"path": "./README.md",
|
|
62
|
+
"description": "Package-owned module overview"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
package/self-install.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
5
|
+
const usage = "Usage: npx @tomflow/proflow-task-migration-runner install\n";
|
|
6
|
+
if (command === "--help" || command === "-h") {
|
|
7
|
+
process.stdout.write(usage);
|
|
8
|
+
process.exit(0);
|
|
9
|
+
}
|
|
10
|
+
if (command !== "install" || rest.length > 0) {
|
|
11
|
+
process.stderr.write(usage);
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
const executable = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
15
|
+
const result = spawnSync(
|
|
16
|
+
executable,
|
|
17
|
+
[
|
|
18
|
+
"--yes",
|
|
19
|
+
"@tomflow/proflow-platform-cli",
|
|
20
|
+
"install",
|
|
21
|
+
"@tomflow/proflow-task-migration-runner",
|
|
22
|
+
],
|
|
23
|
+
{ cwd: process.cwd(), env: process.env, stdio: "inherit" },
|
|
24
|
+
);
|
|
25
|
+
if (result.error) throw result.error;
|
|
26
|
+
process.exit(result.status ?? 1);
|