@ductape/cli 0.2.23 → 0.3.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/CHANGELOG.md +7 -0
- package/README.md +7 -0
- package/dist/commands/migrate-codebase.d.ts +176 -0
- package/dist/commands/migrate-codebase.js +753 -0
- package/dist/commands/migration-artifact.d.ts +32 -0
- package/dist/commands/migration-artifact.js +48 -0
- package/dist/commands/migration-assurance.d.ts +10 -0
- package/dist/commands/migration-assurance.js +120 -0
- package/dist/commands/migration-database.d.ts +21 -0
- package/dist/commands/migration-database.js +162 -0
- package/dist/commands/migration-e2e.d.ts +68 -0
- package/dist/commands/migration-e2e.js +189 -0
- package/dist/commands/migration-environments.d.ts +20 -0
- package/dist/commands/migration-environments.js +58 -0
- package/dist/commands/migration-generated.d.ts +68 -0
- package/dist/commands/migration-generated.js +217 -0
- package/dist/commands/migration-portfolio.d.ts +88 -0
- package/dist/commands/migration-portfolio.js +223 -0
- package/dist/commands/migration-products.d.ts +23 -0
- package/dist/commands/migration-products.js +88 -0
- package/dist/commands/migration-review.d.ts +248 -0
- package/dist/commands/migration-review.js +624 -0
- package/dist/commands/migration-secrets.d.ts +24 -0
- package/dist/commands/migration-secrets.js +93 -0
- package/dist/commands/migration-slice.d.ts +132 -0
- package/dist/commands/migration-slice.js +266 -0
- package/dist/commands/migration-verification.d.ts +12 -0
- package/dist/commands/migration-verification.js +194 -0
- package/dist/commands/products.d.ts +1 -0
- package/dist/commands/products.js +33 -1
- package/dist/commands/secrets.d.ts +8 -0
- package/dist/commands/secrets.js +44 -0
- package/dist/index.js +354 -2
- package/dist/lib/migration-artifact.d.ts +13 -0
- package/dist/lib/migration-artifact.js +103 -0
- package/package.json +8 -1
- package/API_PARITY.md +0 -58
- package/DB_MIGRATE_PLAN.md +0 -223
- package/ductape.example.ts +0 -18
- package/src/commands/apply.ts +0 -153
- package/src/commands/apps-import.ts +0 -43
- package/src/commands/apps.ts +0 -92
- package/src/commands/cloud.ts +0 -68
- package/src/commands/completion.ts +0 -119
- package/src/commands/db-migrate.ts +0 -209
- package/src/commands/db-schema.ts +0 -429
- package/src/commands/db.ts +0 -63
- package/src/commands/generate.ts +0 -74
- package/src/commands/graph.ts +0 -51
- package/src/commands/init.ts +0 -39
- package/src/commands/install.ts +0 -76
- package/src/commands/link.ts +0 -84
- package/src/commands/login.ts +0 -145
- package/src/commands/logout.ts +0 -7
- package/src/commands/platform.ts +0 -123
- package/src/commands/products.ts +0 -228
- package/src/commands/profiles.ts +0 -23
- package/src/commands/resources.ts +0 -213
- package/src/commands/secrets.ts +0 -46
- package/src/commands/unlink.ts +0 -15
- package/src/commands/whoami.ts +0 -27
- package/src/commands/workspaces.ts +0 -98
- package/src/index.ts +0 -523
- package/src/lib/api-client.ts +0 -88
- package/src/lib/app-import.ts +0 -82
- package/src/lib/apply-loaders.ts +0 -30
- package/src/lib/config.ts +0 -230
- package/src/lib/context-store.ts +0 -77
- package/src/lib/db-methods.ts +0 -56
- package/src/lib/db-types.ts +0 -104
- package/src/lib/encryption.ts +0 -12
- package/src/lib/forms/enums.ts +0 -52
- package/src/lib/forms/index.ts +0 -8
- package/src/lib/forms/prompt.ts +0 -209
- package/src/lib/forms/schemas.ts +0 -288
- package/src/lib/forms/types.ts +0 -27
- package/src/lib/http.ts +0 -76
- package/src/lib/integrations.ts +0 -110
- package/src/lib/interactive-opts.ts +0 -13
- package/src/lib/migration-files.ts +0 -58
- package/src/lib/output.ts +0 -39
- package/src/lib/platform-api.ts +0 -209
- package/src/lib/proxy/context.ts +0 -94
- package/src/lib/proxy/db-proxy.ts +0 -40
- package/src/lib/proxy/graph-proxy.ts +0 -64
- package/src/lib/proxy/sdk-proxy.ts +0 -58
- package/src/lib/read-body.ts +0 -57
- package/src/lib/resources.ts +0 -160
- package/src/lib/schema-loader.ts +0 -152
- package/src/lib/snippets.ts +0 -165
- package/src/lib/templates.ts +0 -157
- package/src/lib/workspace-context.ts +0 -43
- package/src/lib/workspaces.ts +0 -283
- package/test/apply-loaders.test.ts +0 -40
- package/test/encryption.test.ts +0 -20
- package/test/forms.test.ts +0 -25
- package/test/http.test.ts +0 -15
- package/test/platform-api.test.ts +0 -42
- package/test/resources.test.ts +0 -23
- package/test/workspace-resolve.test.ts +0 -29
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const MIGRATION_ARTIFACT_SCHEMAS: {
|
|
2
|
+
version: number;
|
|
3
|
+
artifacts: {
|
|
4
|
+
$schema: string;
|
|
5
|
+
title: string;
|
|
6
|
+
type: string;
|
|
7
|
+
required: string[];
|
|
8
|
+
properties: {
|
|
9
|
+
version: {
|
|
10
|
+
const: number;
|
|
11
|
+
};
|
|
12
|
+
artifact_kind: {
|
|
13
|
+
const: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
additionalProperties: boolean;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
19
|
+
export declare function runMigrationArtifactValidate(opts: {
|
|
20
|
+
file: string;
|
|
21
|
+
json?: boolean;
|
|
22
|
+
}): void;
|
|
23
|
+
export declare function runMigrationArtifactRecover(opts: {
|
|
24
|
+
file: string;
|
|
25
|
+
json?: boolean;
|
|
26
|
+
}): void;
|
|
27
|
+
export declare function runMigrationArtifactMigrate(opts: {
|
|
28
|
+
file: string;
|
|
29
|
+
output: string;
|
|
30
|
+
json?: boolean;
|
|
31
|
+
}): void;
|
|
32
|
+
export declare function runMigrationArtifactSchemas(json?: boolean): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { printJson } from '../lib/output.js';
|
|
3
|
+
import { migrateMigrationJson, readMigrationJson, recoverMigrationJson, } from '../lib/migration-artifact.js';
|
|
4
|
+
export const MIGRATION_ARTIFACT_SCHEMAS = {
|
|
5
|
+
version: 1,
|
|
6
|
+
artifacts: [
|
|
7
|
+
'ai_migration_review_ledger',
|
|
8
|
+
'ductape_migration_slice',
|
|
9
|
+
'ductape_migration_portfolio',
|
|
10
|
+
'ductape_migration_partition_proposal',
|
|
11
|
+
'ductape_migration_e2e_baseline',
|
|
12
|
+
'ductape_migration_generated_vendor_evidence',
|
|
13
|
+
'ductape_migration_assurance',
|
|
14
|
+
'ductape_migration_verification_matrix',
|
|
15
|
+
'ductape_database_baseline',
|
|
16
|
+
'ductape_database_data',
|
|
17
|
+
'ductape_database_cutover',
|
|
18
|
+
'ductape_review_ledger_signature',
|
|
19
|
+
].map((artifact_kind) => ({
|
|
20
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
21
|
+
title: artifact_kind,
|
|
22
|
+
type: 'object',
|
|
23
|
+
required: ['version', 'artifact_kind'],
|
|
24
|
+
properties: {
|
|
25
|
+
version: { const: 1 },
|
|
26
|
+
artifact_kind: { const: artifact_kind },
|
|
27
|
+
},
|
|
28
|
+
additionalProperties: true,
|
|
29
|
+
})),
|
|
30
|
+
};
|
|
31
|
+
export function runMigrationArtifactValidate(opts) {
|
|
32
|
+
const value = readMigrationJson(opts.file);
|
|
33
|
+
const schema = MIGRATION_ARTIFACT_SCHEMAS.artifacts.find((candidate) => candidate.properties.artifact_kind.const === value.artifact_kind);
|
|
34
|
+
if (!schema || value.version !== 1)
|
|
35
|
+
throw new Error('[ARTIFACT_VERSION_UNSUPPORTED] Unknown artifact kind or version.');
|
|
36
|
+
printJson({ file: path.resolve(opts.file), valid: true, artifact_kind: value.artifact_kind, version: value.version }, Boolean(opts.json));
|
|
37
|
+
}
|
|
38
|
+
export function runMigrationArtifactRecover(opts) {
|
|
39
|
+
recoverMigrationJson(opts.file);
|
|
40
|
+
printJson({ file: path.resolve(opts.file), recovered: true }, Boolean(opts.json));
|
|
41
|
+
}
|
|
42
|
+
export function runMigrationArtifactMigrate(opts) {
|
|
43
|
+
migrateMigrationJson(opts.file, opts.output);
|
|
44
|
+
printJson({ source: path.resolve(opts.file), output: path.resolve(opts.output), version: 1 }, Boolean(opts.json));
|
|
45
|
+
}
|
|
46
|
+
export function runMigrationArtifactSchemas(json) {
|
|
47
|
+
printJson(MIGRATION_ARTIFACT_SCHEMAS, Boolean(json));
|
|
48
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { printJson } from '../lib/output.js';
|
|
3
|
+
import { readMigrationJson } from '../lib/migration-artifact.js';
|
|
4
|
+
function read(file) {
|
|
5
|
+
return readMigrationJson(file);
|
|
6
|
+
}
|
|
7
|
+
function evidenceComplete(value) {
|
|
8
|
+
const item = value;
|
|
9
|
+
return Boolean(item?.requirement?.trim() && item.evidence?.trim() &&
|
|
10
|
+
Array.isArray(item.tests) && item.tests.length &&
|
|
11
|
+
item.tests.every((test) => typeof test === 'string' && test.trim()));
|
|
12
|
+
}
|
|
13
|
+
function requireEvidenceArray(blockers, label, values) {
|
|
14
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
15
|
+
blockers.push(`${label}: at least one evidence record is required`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
values.forEach((value, index) => {
|
|
19
|
+
if (!evidenceComplete(value))
|
|
20
|
+
blockers.push(`${label}[${index}]: requirement, evidence, and tests are required`);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const requiredSession = [
|
|
24
|
+
'frontend_to_backend', 'backend_to_event', 'event_to_feature', 'scheduled_work',
|
|
25
|
+
'refresh', 'revocation', 'logout', 'account_switching', 'expiry', 'actor_audit_not_authorization',
|
|
26
|
+
];
|
|
27
|
+
const requiredFeature = [
|
|
28
|
+
'duplicate_delivery', 'signal_handling', 'compensation', 'restart_recovery',
|
|
29
|
+
'layered_retry_budget', 'fallback', 'quota', 'health_check',
|
|
30
|
+
];
|
|
31
|
+
const requiredComponents = {
|
|
32
|
+
graph: ['schema', 'edges', 'indexes', 'traversal', 'reconciliation'],
|
|
33
|
+
vector: ['dimensions', 'metric', 'embedding_version', 'metadata_schema', 'batching', 'quality'],
|
|
34
|
+
storage: ['object_keys', 'metadata', 'acl', 'encryption', 'retention', 'multipart', 'checksums'],
|
|
35
|
+
cache: ['keys', 'ttl', 'invalidation', 'stampede', 'consistency', 'fallback'],
|
|
36
|
+
notifications: ['templates', 'channels', 'providers', 'suppression', 'retry', 'delivery', 'privacy'],
|
|
37
|
+
analytics: ['event_schema', 'identity', 'consent', 'masking', 'delivery', 'dashboard_parity'],
|
|
38
|
+
};
|
|
39
|
+
function requireNamedRequirements(blockers, label, values, required) {
|
|
40
|
+
requireEvidenceArray(blockers, label, values);
|
|
41
|
+
const names = new Set(Array.isArray(values) ? values.map((value) => value?.requirement) : []);
|
|
42
|
+
for (const requirement of required) {
|
|
43
|
+
if (!names.has(requirement))
|
|
44
|
+
blockers.push(`${label}: missing requirement ${requirement}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function validateMigrationAssurance(file) {
|
|
48
|
+
const resolved = path.resolve(file);
|
|
49
|
+
const value = read(resolved);
|
|
50
|
+
if (value.artifact_kind !== 'ductape_migration_assurance' || value.version !== 1) {
|
|
51
|
+
throw new Error('Invalid migration assurance artifact version.');
|
|
52
|
+
}
|
|
53
|
+
const blockers = [];
|
|
54
|
+
requireNamedRequirements(blockers, 'session_flows', value.session_flows, requiredSession);
|
|
55
|
+
requireNamedRequirements(blockers, 'feature_resilience', value.feature_resilience, requiredFeature);
|
|
56
|
+
requireEvidenceArray(blockers, 'third_party_calls', value.third_party_calls);
|
|
57
|
+
for (const [index, call] of (value.third_party_calls ?? []).entries()) {
|
|
58
|
+
for (const field of [
|
|
59
|
+
'call_path', 'client', 'base_url', 'auth', 'headers', 'timeout', 'retries',
|
|
60
|
+
'pagination', 'rate_limits', 'error_normalization', 'webhook_replay',
|
|
61
|
+
]) {
|
|
62
|
+
if (!String(call?.[field] ?? '').trim())
|
|
63
|
+
blockers.push(`third_party_calls[${index}].${field}: required`);
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(call.wrapper_chain) || !call.wrapper_chain.length) {
|
|
66
|
+
blockers.push(`third_party_calls[${index}].wrapper_chain: at least one inspected call site is required`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const [component, requirements] of Object.entries(requiredComponents)) {
|
|
70
|
+
requireNamedRequirements(blockers, `components.${component}`, value.components?.[component], requirements);
|
|
71
|
+
}
|
|
72
|
+
requireEvidenceArray(blockers, 'provider_migrations', value.provider_migrations);
|
|
73
|
+
for (const language of ['typescript', 'go', 'java', 'dotnet']) {
|
|
74
|
+
requireEvidenceArray(blockers, `runtime.procedures.${language}`, value.runtime?.procedures?.[language]);
|
|
75
|
+
}
|
|
76
|
+
for (const kind of ['request', 'query', 'event', 'feature', 'external_effect']) {
|
|
77
|
+
requireEvidenceArray(blockers, `runtime.correlations.${kind}`, value.runtime?.correlations?.[kind]);
|
|
78
|
+
}
|
|
79
|
+
for (const concern of [
|
|
80
|
+
'trace_log_compatibility', 'load_concurrency', 'lifecycle_recovery',
|
|
81
|
+
'redaction', 'monitoring_alerts',
|
|
82
|
+
]) {
|
|
83
|
+
requireEvidenceArray(blockers, `runtime.${concern}`, value.runtime?.[concern]);
|
|
84
|
+
}
|
|
85
|
+
const matrices = new Map((value.sdk_matrix ?? []).map((entry) => [entry.language, entry]));
|
|
86
|
+
for (const language of ['typescript', 'go', 'java', 'dotnet']) {
|
|
87
|
+
const matrix = matrices.get(language);
|
|
88
|
+
if (!matrix) {
|
|
89
|
+
blockers.push(`sdk_matrix: missing ${language}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!matrix.package?.trim() || !matrix.version?.trim() || !matrix.unsupported_policy?.trim()) {
|
|
93
|
+
blockers.push(`sdk_matrix.${language}: package, version, and unsupported_policy are required`);
|
|
94
|
+
}
|
|
95
|
+
for (const field of ['lifecycle_requirements', 'error_mapping', 'examples', 'anti_patterns']) {
|
|
96
|
+
if (!Array.isArray(matrix[field]) || !matrix[field].length) {
|
|
97
|
+
blockers.push(`sdk_matrix.${language}.${field}: at least one entry is required`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!Array.isArray(matrix.capabilities) || !matrix.capabilities.length) {
|
|
101
|
+
blockers.push(`sdk_matrix.${language}.capabilities: at least one capability is required`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
matrix.capabilities.forEach((capability, index) => {
|
|
105
|
+
if (!capability.name?.trim() || !capability.installed_evidence?.trim() ||
|
|
106
|
+
!Array.isArray(capability.parity_tests) || !capability.parity_tests.length) {
|
|
107
|
+
blockers.push(`sdk_matrix.${language}.capabilities[${index}]: installed evidence and parity tests are required`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { file: resolved, ready: blockers.length === 0, blockers };
|
|
113
|
+
}
|
|
114
|
+
export function runMigrationAssuranceValidate(opts) {
|
|
115
|
+
const result = validateMigrationAssurance(opts.file);
|
|
116
|
+
printJson(result, Boolean(opts.json));
|
|
117
|
+
if (opts.strict && !result.ready) {
|
|
118
|
+
throw new Error('Migration assurance is not ready: resolve every reported blocker.');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare function initDatabaseMigration(opts: {
|
|
2
|
+
definition: string;
|
|
3
|
+
output: string;
|
|
4
|
+
}): {
|
|
5
|
+
output: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function validateDatabaseMigration(directory: string): {
|
|
8
|
+
directory: string;
|
|
9
|
+
ready: boolean;
|
|
10
|
+
blockers: string[];
|
|
11
|
+
};
|
|
12
|
+
export declare function runMigrationDatabaseInit(opts: {
|
|
13
|
+
definition: string;
|
|
14
|
+
output: string;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
}): void;
|
|
17
|
+
export declare function runMigrationDatabaseValidate(opts: {
|
|
18
|
+
directory: string;
|
|
19
|
+
strict?: boolean;
|
|
20
|
+
json?: boolean;
|
|
21
|
+
}): void;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { printJson } from '../lib/output.js';
|
|
5
|
+
import { readMigrationJson, writeMigrationJson } from '../lib/migration-artifact.js';
|
|
6
|
+
const REQUIRED = {
|
|
7
|
+
baseline: ['database_tag', 'owners', 'providers', 'code_schema_evidence', 'migration_history_evidence', 'applied_history_evidence', 'live_snd_schema_evidence', 'proposed_schema_evidence', 'drift', 'objects', 'security', 'topology', 'compatibility', 'performance_baseline'],
|
|
8
|
+
data: ['transformations', 'batching', 'checkpoints', 'idempotency', 'resume_strategy', 'validation', 'reconciliation', 'failure_recovery', 'pii_controls', 'retention', 'seed_data'],
|
|
9
|
+
cutover: ['phases', 'expand', 'backfill', 'dual_compatibility', 'contract', 'deployment_order', 'migration_locking', 'backup', 'restore_test', 'rollback', 'irreversible_changes', 'monitoring', 'reconciliation', 'approval'],
|
|
10
|
+
};
|
|
11
|
+
function read(file) { return readMigrationJson(file); }
|
|
12
|
+
function missing(value, keys) {
|
|
13
|
+
return keys.filter(k => value[k] == null || value[k] === '' || (Array.isArray(value[k]) && !value[k].length));
|
|
14
|
+
}
|
|
15
|
+
function digest(file) {
|
|
16
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function object(value) {
|
|
19
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
20
|
+
}
|
|
21
|
+
function text(value) {
|
|
22
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
23
|
+
}
|
|
24
|
+
export function initDatabaseMigration(opts) {
|
|
25
|
+
const d = read(path.resolve(opts.definition));
|
|
26
|
+
const out = path.resolve(opts.output);
|
|
27
|
+
fs.mkdirSync(out, { recursive: true });
|
|
28
|
+
for (const name of ['baseline', 'data', 'cutover']) {
|
|
29
|
+
if (!d[name] || typeof d[name] !== 'object')
|
|
30
|
+
throw new Error(`Definition requires ${name}.`);
|
|
31
|
+
writeMigrationJson(path.join(out, `database-${name}.json`), { version: 1, artifact_kind: `ductape_database_${name}`, ...d[name] }, { createOnly: true });
|
|
32
|
+
}
|
|
33
|
+
return { output: out };
|
|
34
|
+
}
|
|
35
|
+
export function validateDatabaseMigration(directory) {
|
|
36
|
+
const dir = path.resolve(directory);
|
|
37
|
+
const blockers = [];
|
|
38
|
+
const artifacts = {};
|
|
39
|
+
for (const name of ['baseline', 'data', 'cutover']) {
|
|
40
|
+
const file = path.join(dir, `database-${name}.json`);
|
|
41
|
+
if (!fs.existsSync(file)) {
|
|
42
|
+
blockers.push(`${name}: artifact missing`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const artifact = read(file);
|
|
46
|
+
artifacts[name] = artifact;
|
|
47
|
+
if (artifact.version !== 1 || artifact.artifact_kind !== `ductape_database_${name}`) {
|
|
48
|
+
blockers.push(`${name}: unsupported artifact version or kind`);
|
|
49
|
+
}
|
|
50
|
+
for (const field of missing(artifact, REQUIRED[name]))
|
|
51
|
+
blockers.push(`${name}.${field}: evidence required`);
|
|
52
|
+
if (!text(artifact.migration_id))
|
|
53
|
+
blockers.push(`${name}.migration_id: required for cross-artifact consistency`);
|
|
54
|
+
}
|
|
55
|
+
const baseline = artifacts.baseline;
|
|
56
|
+
if (baseline) {
|
|
57
|
+
if (!text(baseline.database_tag) || !Array.isArray(baseline.owners) || !baseline.owners.every(text) ||
|
|
58
|
+
!Array.isArray(baseline.providers) || !baseline.providers.every((provider) => object(provider) && text(provider.provider) && text(provider.environment) && text(provider.evidence))) {
|
|
59
|
+
blockers.push('baseline: database_tag, owners, and typed provider environment evidence are required');
|
|
60
|
+
}
|
|
61
|
+
const snapshots = baseline.schema_snapshots;
|
|
62
|
+
const snapshotKinds = new Set();
|
|
63
|
+
if (!Array.isArray(snapshots) || !snapshots.length) {
|
|
64
|
+
blockers.push('baseline.schema_snapshots: typed schema provenance is required');
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
for (const [index, snapshot] of snapshots.entries()) {
|
|
68
|
+
if (!object(snapshot) || !text(snapshot.kind) || !text(snapshot.database_tag) ||
|
|
69
|
+
!text(snapshot.structure_hash) || !text(snapshot.source_file) || !text(snapshot.checksum) ||
|
|
70
|
+
!text(snapshot.captured_at) || !text(snapshot.evidence)) {
|
|
71
|
+
blockers.push(`baseline.schema_snapshots[${index}]: kind, database_tag, structure hash, source checksum, timestamp, and evidence are required`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
snapshotKinds.add(snapshot.kind);
|
|
75
|
+
const sourceFile = path.resolve(snapshot.source_file);
|
|
76
|
+
if (!fs.existsSync(sourceFile) || digest(sourceFile) !== snapshot.checksum) {
|
|
77
|
+
blockers.push(`baseline.schema_snapshots[${index}]: source evidence is stale or missing`);
|
|
78
|
+
}
|
|
79
|
+
if (snapshot.database_tag !== baseline.database_tag) {
|
|
80
|
+
blockers.push(`baseline.schema_snapshots[${index}]: database_tag mismatch`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const kind of ['code', 'migration', 'applied', 'live', 'proposed']) {
|
|
84
|
+
if (!snapshotKinds.has(kind))
|
|
85
|
+
blockers.push(`baseline.schema_snapshots: missing ${kind} structure`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const comparison = baseline.structural_comparison;
|
|
89
|
+
if (!object(comparison) || !['matched', 'approved_differences'].includes(String(comparison.status)) ||
|
|
90
|
+
!Array.isArray(comparison.differences) || !text(comparison.evidence) ||
|
|
91
|
+
(comparison.status === 'approved_differences' && !comparison.differences.length)) {
|
|
92
|
+
blockers.push('baseline.structural_comparison: matched or evidenced approved differences are required');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const data = artifacts.data;
|
|
96
|
+
if (data) {
|
|
97
|
+
if (!Array.isArray(data.transformations) || !data.transformations.length) {
|
|
98
|
+
blockers.push('data.transformations: at least one versioned source/target mapping is required');
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (const [index, transformation] of data.transformations.entries()) {
|
|
102
|
+
if (!object(transformation) || !text(transformation.id) || !text(transformation.version) ||
|
|
103
|
+
!text(transformation.source) || !text(transformation.target) ||
|
|
104
|
+
!Array.isArray(transformation.field_mappings) || !transformation.field_mappings.length ||
|
|
105
|
+
!transformation.field_mappings.every((mapping) => object(mapping) && text(mapping.source) && text(mapping.target) && text(mapping.transformation))) {
|
|
106
|
+
blockers.push(`data.transformations[${index}]: versioned source/target field mappings are required`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const batching = data.batching;
|
|
111
|
+
if (!object(batching) || !Number.isInteger(batching.batch_size) || Number(batching.batch_size) <= 0 ||
|
|
112
|
+
!Number.isInteger(batching.max_concurrency) || Number(batching.max_concurrency) <= 0 ||
|
|
113
|
+
!Number.isFinite(batching.memory_limit_mb) || Number(batching.memory_limit_mb) <= 0 ||
|
|
114
|
+
!Number.isFinite(batching.timeout_ms) || Number(batching.timeout_ms) <= 0) {
|
|
115
|
+
blockers.push('data.batching: positive batch_size, max_concurrency, memory_limit_mb, and timeout_ms are required');
|
|
116
|
+
}
|
|
117
|
+
const checkpoints = data.checkpoints;
|
|
118
|
+
if (!object(checkpoints) || !text(checkpoints.store) || !text(checkpoints.key) ||
|
|
119
|
+
checkpoints.atomic !== true || !text(checkpoints.replay_test)) {
|
|
120
|
+
blockers.push('data.checkpoints: persistent store, key, atomic writes, and replay test evidence are required');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const cutover = artifacts.cutover;
|
|
124
|
+
if (cutover) {
|
|
125
|
+
const phases = cutover.phases;
|
|
126
|
+
if (!Array.isArray(phases) || !phases.length) {
|
|
127
|
+
blockers.push('cutover.phases: ordered phases are required');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
const ids = new Set(phases.filter(object).map((phase) => phase.id));
|
|
131
|
+
const orders = phases.filter(object).map((phase) => Number(phase.order));
|
|
132
|
+
if (new Set(orders).size !== orders.length || orders.some((order) => !Number.isInteger(order) || order < 1)) {
|
|
133
|
+
blockers.push('cutover.phases: unique positive order values are required');
|
|
134
|
+
}
|
|
135
|
+
for (const [index, phase] of phases.entries()) {
|
|
136
|
+
if (!object(phase) || !text(phase.id) || !Array.isArray(phase.prerequisites) ||
|
|
137
|
+
!phase.prerequisites.every((required) => ids.has(required))) {
|
|
138
|
+
blockers.push(`cutover.phases[${index}]: id and existing prerequisites are required`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const ids = ['baseline', 'data', 'cutover'].map((name) => artifacts[name]?.migration_id);
|
|
144
|
+
if (ids.every(text) && new Set(ids).size !== 1)
|
|
145
|
+
blockers.push('database artifacts use different migration_id values');
|
|
146
|
+
if (baseline && data && cutover) {
|
|
147
|
+
for (const [name, artifact] of [['data', data], ['cutover', cutover]]) {
|
|
148
|
+
if (artifact.database_tag !== baseline.database_tag)
|
|
149
|
+
blockers.push(`${name}.database_tag does not match baseline`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { directory: dir, ready: !blockers.length, blockers };
|
|
153
|
+
}
|
|
154
|
+
export function runMigrationDatabaseInit(opts) {
|
|
155
|
+
printJson(initDatabaseMigration(opts), Boolean(opts.json));
|
|
156
|
+
}
|
|
157
|
+
export function runMigrationDatabaseValidate(opts) {
|
|
158
|
+
const r = validateDatabaseMigration(opts.directory);
|
|
159
|
+
printJson(r, Boolean(opts.json));
|
|
160
|
+
if (opts.strict && !r.ready)
|
|
161
|
+
throw new Error('Database migration is not ready: resolve every reported blocker.');
|
|
162
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
interface E2EDefinition {
|
|
2
|
+
command: string;
|
|
3
|
+
suite_files: string[];
|
|
4
|
+
baseline: {
|
|
5
|
+
status: 'passed' | 'failed';
|
|
6
|
+
evidence: string[];
|
|
7
|
+
environment: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
interface FinalEvidence {
|
|
11
|
+
command: string;
|
|
12
|
+
status: 'passed' | 'failed';
|
|
13
|
+
evidence: string[];
|
|
14
|
+
environment: string;
|
|
15
|
+
suite_root: string;
|
|
16
|
+
}
|
|
17
|
+
interface E2EManifest {
|
|
18
|
+
version: 1;
|
|
19
|
+
artifact_kind: 'ductape_migration_e2e_baseline';
|
|
20
|
+
source: string;
|
|
21
|
+
created_at: string;
|
|
22
|
+
command: string;
|
|
23
|
+
suite: Array<{
|
|
24
|
+
file: string;
|
|
25
|
+
checksum: string;
|
|
26
|
+
}>;
|
|
27
|
+
baseline: E2EDefinition['baseline'];
|
|
28
|
+
final: (FinalEvidence & {
|
|
29
|
+
recorded_at: string;
|
|
30
|
+
}) | null;
|
|
31
|
+
}
|
|
32
|
+
export declare function initializeMigrationE2E(opts: {
|
|
33
|
+
source: string;
|
|
34
|
+
definition: string;
|
|
35
|
+
output?: string;
|
|
36
|
+
}): {
|
|
37
|
+
path: string;
|
|
38
|
+
manifest: E2EManifest;
|
|
39
|
+
};
|
|
40
|
+
export declare function recordMigrationE2EFinal(manifestFile: string, evidenceFile: string): E2EManifest;
|
|
41
|
+
export declare function validateMigrationE2E(manifestFile: string): {
|
|
42
|
+
manifest: string;
|
|
43
|
+
ready: boolean;
|
|
44
|
+
suite_files: number;
|
|
45
|
+
blockers: string[];
|
|
46
|
+
};
|
|
47
|
+
export declare function validateMigrationE2EBaseline(manifestFile: string): {
|
|
48
|
+
manifest: string;
|
|
49
|
+
ready: boolean;
|
|
50
|
+
blockers: string[];
|
|
51
|
+
};
|
|
52
|
+
export declare function runMigrationE2EInit(opts: {
|
|
53
|
+
source: string;
|
|
54
|
+
definition: string;
|
|
55
|
+
output?: string;
|
|
56
|
+
json?: boolean;
|
|
57
|
+
}): void;
|
|
58
|
+
export declare function runMigrationE2EFinal(opts: {
|
|
59
|
+
manifest: string;
|
|
60
|
+
evidence: string;
|
|
61
|
+
json?: boolean;
|
|
62
|
+
}): void;
|
|
63
|
+
export declare function runMigrationE2EValidate(opts: {
|
|
64
|
+
manifest: string;
|
|
65
|
+
strict?: boolean;
|
|
66
|
+
json?: boolean;
|
|
67
|
+
}): void;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { printJson } from '../lib/output.js';
|
|
5
|
+
import { readMigrationJson, writeMigrationJson } from '../lib/migration-artifact.js';
|
|
6
|
+
function readJson(file) {
|
|
7
|
+
return readMigrationJson(file);
|
|
8
|
+
}
|
|
9
|
+
function checksum(file) {
|
|
10
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
function relativeFile(source, file) {
|
|
13
|
+
if (!file || path.isAbsolute(file))
|
|
14
|
+
throw new Error(`E2E suite file must be repository-relative: ${file}`);
|
|
15
|
+
const normalized = path.normalize(file).replaceAll('\\', '/');
|
|
16
|
+
if (normalized === '..' || normalized.startsWith('../')) {
|
|
17
|
+
throw new Error(`E2E suite file escapes the source repository: ${file}`);
|
|
18
|
+
}
|
|
19
|
+
const resolved = path.resolve(source, normalized);
|
|
20
|
+
if (!fs.statSync(resolved).isFile())
|
|
21
|
+
throw new Error(`E2E suite file does not exist: ${normalized}`);
|
|
22
|
+
return normalized;
|
|
23
|
+
}
|
|
24
|
+
function requireEvidence(value, label) {
|
|
25
|
+
if (!value.command?.trim())
|
|
26
|
+
throw new Error(`${label} command is required.`);
|
|
27
|
+
if (value.status !== 'passed')
|
|
28
|
+
throw new Error(`${label} must have passed before it can be recorded.`);
|
|
29
|
+
if (!value.environment?.trim())
|
|
30
|
+
throw new Error(`${label} environment evidence is required.`);
|
|
31
|
+
if (!Array.isArray(value.evidence) || value.evidence.length === 0 ||
|
|
32
|
+
value.evidence.some((item) => !item?.trim())) {
|
|
33
|
+
throw new Error(`${label} requires at least one non-empty evidence entry.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function initializeMigrationE2E(opts) {
|
|
37
|
+
const source = path.resolve(opts.source);
|
|
38
|
+
if (!fs.statSync(source).isDirectory())
|
|
39
|
+
throw new Error(`Migration source is not a directory: ${source}`);
|
|
40
|
+
const definition = readJson(path.resolve(opts.definition));
|
|
41
|
+
requireEvidence({ command: definition.command, ...definition.baseline }, 'Baseline E2E run');
|
|
42
|
+
if (!Array.isArray(definition.suite_files) || definition.suite_files.length === 0) {
|
|
43
|
+
throw new Error('Baseline E2E suite_files must contain at least one test or fixture.');
|
|
44
|
+
}
|
|
45
|
+
const files = [...new Set(definition.suite_files.map((file) => relativeFile(source, file)))].sort();
|
|
46
|
+
if (files.length !== definition.suite_files.length)
|
|
47
|
+
throw new Error('Baseline E2E suite_files contains duplicates.');
|
|
48
|
+
const output = path.resolve(opts.output ?? path.join(source, 'ductape', 'migration-guidance', 'e2e-baseline.json'));
|
|
49
|
+
if (fs.existsSync(output))
|
|
50
|
+
throw new Error(`Migration E2E baseline already exists: ${output}`);
|
|
51
|
+
const manifest = {
|
|
52
|
+
version: 1,
|
|
53
|
+
artifact_kind: 'ductape_migration_e2e_baseline',
|
|
54
|
+
source,
|
|
55
|
+
created_at: new Date().toISOString(),
|
|
56
|
+
command: definition.command,
|
|
57
|
+
suite: files.map((file) => ({ file, checksum: checksum(path.join(source, file)) })),
|
|
58
|
+
baseline: definition.baseline,
|
|
59
|
+
final: null,
|
|
60
|
+
};
|
|
61
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
62
|
+
writeMigrationJson(output, manifest, { createOnly: true });
|
|
63
|
+
return { path: output, manifest };
|
|
64
|
+
}
|
|
65
|
+
export function recordMigrationE2EFinal(manifestFile, evidenceFile) {
|
|
66
|
+
const resolved = path.resolve(manifestFile);
|
|
67
|
+
const manifest = readJson(resolved);
|
|
68
|
+
if (manifest.artifact_kind !== 'ductape_migration_e2e_baseline') {
|
|
69
|
+
throw new Error('Invalid migration E2E baseline manifest.');
|
|
70
|
+
}
|
|
71
|
+
if (manifest.final)
|
|
72
|
+
throw new Error('Final E2E evidence is already recorded.');
|
|
73
|
+
const evidence = readJson(path.resolve(evidenceFile));
|
|
74
|
+
requireEvidence(evidence, 'Final E2E run');
|
|
75
|
+
if (evidence.command !== manifest.command) {
|
|
76
|
+
throw new Error('Final E2E command must exactly match the original baseline command.');
|
|
77
|
+
}
|
|
78
|
+
if (!evidence.suite_root?.trim() || !path.isAbsolute(evidence.suite_root) ||
|
|
79
|
+
!fs.existsSync(evidence.suite_root) || !fs.statSync(evidence.suite_root).isDirectory()) {
|
|
80
|
+
throw new Error('Final E2E suite_root must be an existing absolute migrated-codebase directory.');
|
|
81
|
+
}
|
|
82
|
+
const changed = manifest.suite.filter(({ file, checksum: expected }) => {
|
|
83
|
+
try {
|
|
84
|
+
return checksum(path.join(manifest.source, file)) !== expected;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
if (changed.length) {
|
|
91
|
+
throw new Error(`Original E2E baseline changed or is missing: ${changed.map((item) => item.file).join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
const migratedMismatch = manifest.suite.filter(({ file, checksum: expected }) => {
|
|
94
|
+
try {
|
|
95
|
+
return checksum(path.join(evidence.suite_root, file)) !== expected;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
if (migratedMismatch.length) {
|
|
102
|
+
throw new Error(`Migrated codebase is not using the original E2E suite: ${migratedMismatch.map((item) => item.file).join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
manifest.final = { ...evidence, recorded_at: new Date().toISOString() };
|
|
105
|
+
writeMigrationJson(resolved, manifest, { backup: true });
|
|
106
|
+
return manifest;
|
|
107
|
+
}
|
|
108
|
+
export function validateMigrationE2E(manifestFile) {
|
|
109
|
+
const resolved = path.resolve(manifestFile);
|
|
110
|
+
const manifest = readJson(resolved);
|
|
111
|
+
if (manifest.artifact_kind !== 'ductape_migration_e2e_baseline') {
|
|
112
|
+
throw new Error('Invalid migration E2E baseline manifest.');
|
|
113
|
+
}
|
|
114
|
+
const blockers = [];
|
|
115
|
+
if (!manifest.suite?.length)
|
|
116
|
+
blockers.push('original E2E suite is empty');
|
|
117
|
+
for (const item of manifest.suite ?? []) {
|
|
118
|
+
try {
|
|
119
|
+
if (checksum(path.join(manifest.source, item.file)) !== item.checksum) {
|
|
120
|
+
blockers.push(`${item.file}: original baseline checksum changed`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
blockers.push(`${item.file}: original baseline file is missing`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (manifest.baseline?.status !== 'passed' || !manifest.baseline.evidence?.length) {
|
|
128
|
+
blockers.push('passing baseline E2E evidence is missing');
|
|
129
|
+
}
|
|
130
|
+
if (!manifest.final)
|
|
131
|
+
blockers.push('final run against the original E2E suite is missing');
|
|
132
|
+
else {
|
|
133
|
+
if (manifest.final.command !== manifest.command)
|
|
134
|
+
blockers.push('final command differs from baseline command');
|
|
135
|
+
if (manifest.final.status !== 'passed' || !manifest.final.evidence?.length) {
|
|
136
|
+
blockers.push('passing final E2E evidence is missing');
|
|
137
|
+
}
|
|
138
|
+
for (const item of manifest.suite ?? []) {
|
|
139
|
+
try {
|
|
140
|
+
if (checksum(path.join(manifest.final.suite_root, item.file)) !== item.checksum) {
|
|
141
|
+
blockers.push(`${item.file}: migrated suite differs from original baseline`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
blockers.push(`${item.file}: migrated suite file is missing`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { manifest: resolved, ready: blockers.length === 0, suite_files: manifest.suite?.length ?? 0, blockers };
|
|
150
|
+
}
|
|
151
|
+
export function validateMigrationE2EBaseline(manifestFile) {
|
|
152
|
+
const resolved = path.resolve(manifestFile);
|
|
153
|
+
const manifest = readJson(resolved);
|
|
154
|
+
if (manifest.artifact_kind !== 'ductape_migration_e2e_baseline') {
|
|
155
|
+
throw new Error('Invalid migration E2E baseline manifest.');
|
|
156
|
+
}
|
|
157
|
+
const blockers = [];
|
|
158
|
+
if (!manifest.suite?.length)
|
|
159
|
+
blockers.push('original E2E suite is empty');
|
|
160
|
+
for (const item of manifest.suite ?? []) {
|
|
161
|
+
try {
|
|
162
|
+
if (checksum(path.join(manifest.source, item.file)) !== item.checksum) {
|
|
163
|
+
blockers.push(`${item.file}: original baseline checksum changed`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
blockers.push(`${item.file}: original baseline file is missing`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (manifest.baseline?.status !== 'passed' || !manifest.baseline.evidence?.length) {
|
|
171
|
+
blockers.push('passing baseline E2E evidence is missing');
|
|
172
|
+
}
|
|
173
|
+
return { manifest: resolved, ready: blockers.length === 0, blockers };
|
|
174
|
+
}
|
|
175
|
+
export function runMigrationE2EInit(opts) {
|
|
176
|
+
const result = initializeMigrationE2E(opts);
|
|
177
|
+
printJson({ path: result.path, suite_files: result.manifest.suite.length }, Boolean(opts.json));
|
|
178
|
+
}
|
|
179
|
+
export function runMigrationE2EFinal(opts) {
|
|
180
|
+
const manifest = recordMigrationE2EFinal(opts.manifest, opts.evidence);
|
|
181
|
+
printJson({ manifest: path.resolve(opts.manifest), final_status: manifest.final?.status }, Boolean(opts.json));
|
|
182
|
+
}
|
|
183
|
+
export function runMigrationE2EValidate(opts) {
|
|
184
|
+
const result = validateMigrationE2E(opts.manifest);
|
|
185
|
+
printJson(result, Boolean(opts.json));
|
|
186
|
+
if (opts.strict && !result.ready) {
|
|
187
|
+
throw new Error('Migration E2E gate is not ready: resolve every reported blocker.');
|
|
188
|
+
}
|
|
189
|
+
}
|