From b90239888f30745edc07326e46f6fd1c8252c8c9 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 19:58:18 -0400 Subject: [PATCH 01/12] feat(schema): add version parsing and comparison utilities --- packages/schema/src/__tests__/version.test.ts | 49 +++++++++++++++++++ packages/schema/src/version.ts | 25 ++++++++++ 2 files changed, 74 insertions(+) create mode 100644 packages/schema/src/__tests__/version.test.ts create mode 100644 packages/schema/src/version.ts diff --git a/packages/schema/src/__tests__/version.test.ts b/packages/schema/src/__tests__/version.test.ts new file mode 100644 index 0000000..5eddb42 --- /dev/null +++ b/packages/schema/src/__tests__/version.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { parseVersion, compareVersions, isMajorBump } from '../version.js' + +describe('parseVersion', () => { + it('parses flowprint/1.0', () => { + expect(parseVersion('flowprint/1.0')).toEqual({ major: 1, minor: 0 }) + }) + + it('parses flowprint/2.13', () => { + expect(parseVersion('flowprint/2.13')).toEqual({ major: 2, minor: 13 }) + }) + + it('throws on invalid format', () => { + expect(() => parseVersion('invalid')).toThrow('Invalid version format') + expect(() => parseVersion('flowprint/')).toThrow() + expect(() => parseVersion('flowprint/abc')).toThrow() + expect(() => parseVersion('')).toThrow() + }) +}) + +describe('compareVersions', () => { + it('returns 0 for equal versions', () => { + expect(compareVersions('flowprint/1.0', 'flowprint/1.0')).toBe(0) + }) + + it('returns negative when a < b', () => { + expect(compareVersions('flowprint/1.0', 'flowprint/1.1')).toBeLessThan(0) + expect(compareVersions('flowprint/1.9', 'flowprint/2.0')).toBeLessThan(0) + }) + + it('returns positive when a > b', () => { + expect(compareVersions('flowprint/1.1', 'flowprint/1.0')).toBeGreaterThan(0) + expect(compareVersions('flowprint/2.0', 'flowprint/1.9')).toBeGreaterThan(0) + }) + + it('compares major before minor', () => { + expect(compareVersions('flowprint/2.0', 'flowprint/1.99')).toBeGreaterThan(0) + }) +}) + +describe('isMajorBump', () => { + it('returns false for same major', () => { + expect(isMajorBump('flowprint/1.0', 'flowprint/1.5')).toBe(false) + }) + + it('returns true for different major', () => { + expect(isMajorBump('flowprint/1.0', 'flowprint/2.0')).toBe(true) + }) +}) diff --git a/packages/schema/src/version.ts b/packages/schema/src/version.ts new file mode 100644 index 0000000..961b998 --- /dev/null +++ b/packages/schema/src/version.ts @@ -0,0 +1,25 @@ +export interface ParsedVersion { + major: number + minor: number +} + +const VERSION_RE = /^flowprint\/(\d+)\.(\d+)$/ + +export function parseVersion(version: string): ParsedVersion { + const match = VERSION_RE.exec(version) + if (!match) { + throw new Error(`Invalid version format: "${version}". Expected "flowprint/X.Y"`) + } + return { major: Number(match[1]), minor: Number(match[2]) } +} + +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a) + const pb = parseVersion(b) + if (pa.major !== pb.major) return pa.major - pb.major + return pa.minor - pb.minor +} + +export function isMajorBump(from: string, to: string): boolean { + return parseVersion(from).major !== parseVersion(to).major +} From e4631ef52eb1ec6d710bc0ff39f9ece3cef0246f Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 19:58:19 -0400 Subject: [PATCH 02/12] feat(schema): add migration type definitions Add foundational types for the schema migration engine: Transform union type (addField, removeField, renameField, renameNodeType, setDefault, changeFieldType), MigrationRule, MigrationChangelog, MigrationResult discriminated union, and MigrationError. These types are the foundation that all other migration tasks depend on. --- packages/schema/src/types.ts | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts index 504a62d..b747a00 100644 --- a/packages/schema/src/types.ts +++ b/packages/schema/src/types.ts @@ -6,6 +6,8 @@ * part of the public API but not part of the JSON Schema. */ +import type { FlowprintServiceBlueprint } from './types.generated.js' + // Re-export all generated types export type { FlowprintServiceBlueprint as FlowprintDocument, @@ -25,6 +27,9 @@ export type { Position, } from './types.generated.js' +/** Local alias for use within this file */ +type FlowprintDocument = FlowprintServiceBlueprint + /** * Result of validating a Flowprint document. */ @@ -70,3 +75,122 @@ export interface Edge { /** Edge type: normal flow, error handling, or default branch */ type: 'normal' | 'error' | 'default' } + +// ── Migration Types ───────────────────────────────────────────── + +export type Transform = + | AddFieldTransform + | RemoveFieldTransform + | RenameFieldTransform + | RenameNodeTypeTransform + | SetDefaultTransform + | ChangeFieldTypeTransform + +export interface AddFieldTransform { + type: 'addField' + scope: 'nodes' | 'metadata' + /** When scope is 'nodes', only apply to nodes of this type. Omit to apply to all nodes. */ + nodeType?: string + field: string + value: unknown +} + +export interface RemoveFieldTransform { + type: 'removeField' + scope: 'nodes' | 'metadata' + nodeType?: string + field: string +} + +export interface RenameFieldTransform { + type: 'renameField' + scope: 'nodes' | 'metadata' + nodeType?: string + from: string + to: string +} + +export interface RenameNodeTypeTransform { + type: 'renameNodeType' + from: string + to: string +} + +export interface SetDefaultTransform { + type: 'setDefault' + scope: 'nodes' | 'metadata' + nodeType?: string + field: string + value: unknown +} + +export interface ChangeFieldTypeTransform { + type: 'changeFieldType' + scope: 'nodes' + nodeType?: string + field: string + convert: (value: unknown) => unknown +} + +export interface MigrationRule { + from: string + to: string + required: boolean + notable: boolean + description: string + transforms: Transform[] + custom?: (doc: FlowprintDocument) => FlowprintDocument + down?: (doc: FlowprintDocument) => FlowprintDocument +} + +export interface MigrationChangelog { + from: string + to: string + entries: MigrationChangelogEntry[] +} + +export interface MigrationChangelogEntry { + version: string + description: string + required: boolean + notable: boolean + transforms: string[] +} + +export type MigrationResult = + | MigrationResultMigrated + | MigrationResultError + | MigrationResultFutureVersion + | MigrationResultCurrent + +export interface MigrationResultMigrated { + status: 'migrated' + doc: FlowprintDocument + changelog: MigrationChangelog + fromVersion: string + toVersion: string +} + +export interface MigrationResultError { + status: 'error' + originalDoc: FlowprintDocument + error: MigrationError +} + +export interface MigrationResultFutureVersion { + status: 'future_version' + doc: FlowprintDocument + documentVersion: string + currentToolVersion: string +} + +export interface MigrationResultCurrent { + status: 'current' + doc: FlowprintDocument +} + +export interface MigrationError { + failedRule: string + reason: string + stepIndex: number +} From 28f08ae986bbf5f8ddf11cec7422e498d4ee2ee6 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:00:49 -0400 Subject: [PATCH 03/12] feat(schema): implement declarative transform executor Add applyTransform and describeTransform functions that handle 6 transform types: addField, removeField, renameField, renameNodeType, setDefault, changeFieldType. Scoped transforms use a shared applyToScope helper for nodes (optionally filtered by nodeType) or metadata. Includes 28 tests covering all transform types and human-readable descriptions. --- .../schema/src/__tests__/transforms.test.ts | 404 ++++++++++++++++++ packages/schema/src/transforms.ts | 85 ++++ 2 files changed, 489 insertions(+) create mode 100644 packages/schema/src/__tests__/transforms.test.ts create mode 100644 packages/schema/src/transforms.ts diff --git a/packages/schema/src/__tests__/transforms.test.ts b/packages/schema/src/__tests__/transforms.test.ts new file mode 100644 index 0000000..deab24e --- /dev/null +++ b/packages/schema/src/__tests__/transforms.test.ts @@ -0,0 +1,404 @@ +import { describe, it, expect } from 'vitest' +import { applyTransform, describeTransform } from '../transforms.js' +import type { FlowprintDocument, Transform } from '../types.js' + +function makeDoc( + nodeOverrides?: Record>, +): FlowprintDocument { + return { + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + start: { + type: 'trigger', + lane: 'main', + label: 'Start', + trigger_type: 'manual', + manual: {}, + next: 'step1', + }, + step1: { + type: 'action', + lane: 'main', + label: 'Step 1', + next: 'done', + ...nodeOverrides?.step1, + }, + wait1: { + type: 'wait', + lane: 'main', + label: 'Wait', + event: 'timer', + next: 'done', + ...nodeOverrides?.wait1, + }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'completed' }, + }, + } as FlowprintDocument +} + +describe('applyTransform', () => { + describe('addField', () => { + it('adds field to all nodes when no nodeType filter', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'addField', + scope: 'nodes', + field: 'priority', + value: 'normal', + } + const result = applyTransform(doc, transform) + for (const node of Object.values(result.nodes)) { + expect((node as Record).priority).toBe('normal') + } + }) + + it('adds field only to matching nodeType', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'addField', + scope: 'nodes', + nodeType: 'action', + field: 'retries', + value: 3, + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).retries).toBe(3) + expect((result.nodes.start as Record).retries).toBeUndefined() + expect((result.nodes.wait1 as Record).retries).toBeUndefined() + expect((result.nodes.done as Record).retries).toBeUndefined() + }) + + it('does not overwrite existing field', () => { + const doc = makeDoc({ step1: { priority: 'high' } }) + const transform: Transform = { + type: 'addField', + scope: 'nodes', + field: 'priority', + value: 'normal', + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).priority).toBe('high') + }) + + it('adds field to metadata scope', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'addField', + scope: 'metadata', + field: 'owner', + value: 'team-a', + } + const result = applyTransform(doc, transform) + expect((result.metadata as Record).owner).toBe('team-a') + }) + + it('creates metadata object if absent', () => { + const doc = makeDoc() + delete (doc as Record).metadata + const transform: Transform = { + type: 'addField', + scope: 'metadata', + field: 'version_policy', + value: 'strict', + } + const result = applyTransform(doc, transform) + expect(result.metadata).toBeDefined() + expect((result.metadata as Record).version_policy).toBe('strict') + }) + }) + + describe('removeField', () => { + it('removes field from filtered nodes', () => { + const doc = makeDoc({ step1: { deprecated: true }, wait1: { deprecated: true } }) + const transform: Transform = { + type: 'removeField', + scope: 'nodes', + nodeType: 'action', + field: 'deprecated', + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).deprecated).toBeUndefined() + // wait1 is type 'wait', so it should not be affected + expect((result.nodes.wait1 as Record).deprecated).toBe(true) + }) + + it('removes field from metadata', () => { + const doc = makeDoc() + ;(doc as Record).metadata = { legacy: 'yes', owner: 'team-a' } + const transform: Transform = { + type: 'removeField', + scope: 'metadata', + field: 'legacy', + } + const result = applyTransform(doc, transform) + expect((result.metadata as Record).legacy).toBeUndefined() + expect((result.metadata as Record).owner).toBe('team-a') + }) + + it('is a no-op if field does not exist', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'removeField', + scope: 'nodes', + field: 'nonexistent', + } + const result = applyTransform(doc, transform) + // Should not throw, nodes remain unchanged + expect(result.nodes.step1).toBeDefined() + }) + }) + + describe('renameField', () => { + it('renames field on matching nodes', () => { + const doc = makeDoc({ step1: { timeout: 30 } }) + const transform: Transform = { + type: 'renameField', + scope: 'nodes', + nodeType: 'action', + from: 'timeout', + to: 'timeout_seconds', + } + const result = applyTransform(doc, transform) + const step1 = result.nodes.step1 as Record + expect(step1.timeout_seconds).toBe(30) + expect(step1.timeout).toBeUndefined() + }) + + it('skips nodes that do not have the field', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'renameField', + scope: 'nodes', + from: 'nonexistent', + to: 'new_name', + } + const result = applyTransform(doc, transform) + // Should not throw, no field to rename + for (const node of Object.values(result.nodes)) { + expect((node as Record).new_name).toBeUndefined() + } + }) + + it('renames field in metadata', () => { + const doc = makeDoc() + ;(doc as Record).metadata = { old_key: 'value' } + const transform: Transform = { + type: 'renameField', + scope: 'metadata', + from: 'old_key', + to: 'new_key', + } + const result = applyTransform(doc, transform) + const meta = result.metadata as Record + expect(meta.new_key).toBe('value') + expect(meta.old_key).toBeUndefined() + }) + }) + + describe('renameNodeType', () => { + it('renames matching node types', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'renameNodeType', + from: 'action', + to: 'task', + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).type).toBe('task') + }) + + it('leaves non-matching node types unchanged', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'renameNodeType', + from: 'action', + to: 'task', + } + const result = applyTransform(doc, transform) + expect((result.nodes.start as Record).type).toBe('trigger') + expect((result.nodes.wait1 as Record).type).toBe('wait') + expect((result.nodes.done as Record).type).toBe('terminal') + }) + }) + + describe('setDefault', () => { + it('sets value when field is missing', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'setDefault', + scope: 'nodes', + nodeType: 'action', + field: 'retries', + value: 0, + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).retries).toBe(0) + }) + + it('sets value when field is null', () => { + const doc = makeDoc({ step1: { retries: null } }) + const transform: Transform = { + type: 'setDefault', + scope: 'nodes', + nodeType: 'action', + field: 'retries', + value: 0, + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).retries).toBe(0) + }) + + it('skips when field already has a value', () => { + const doc = makeDoc({ step1: { retries: 5 } }) + const transform: Transform = { + type: 'setDefault', + scope: 'nodes', + nodeType: 'action', + field: 'retries', + value: 0, + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).retries).toBe(5) + }) + + it('sets default in metadata scope', () => { + const doc = makeDoc() + ;(doc as Record).metadata = {} + const transform: Transform = { + type: 'setDefault', + scope: 'metadata', + field: 'env', + value: 'production', + } + const result = applyTransform(doc, transform) + expect((result.metadata as Record).env).toBe('production') + }) + }) + + describe('changeFieldType', () => { + it('converts field using provided function', () => { + const doc = makeDoc({ step1: { timeout: '30' }, wait1: { timeout: '60' } }) + const transform: Transform = { + type: 'changeFieldType', + scope: 'nodes', + field: 'timeout', + convert: (v) => Number(v), + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).timeout).toBe(30) + expect((result.nodes.wait1 as Record).timeout).toBe(60) + }) + + it('only converts matching nodeType when specified', () => { + const doc = makeDoc({ step1: { timeout: '30' }, wait1: { timeout: '60' } }) + const transform: Transform = { + type: 'changeFieldType', + scope: 'nodes', + nodeType: 'action', + field: 'timeout', + convert: (v) => Number(v), + } + const result = applyTransform(doc, transform) + expect((result.nodes.step1 as Record).timeout).toBe(30) + // wait1 is not 'action', should remain a string + expect((result.nodes.wait1 as Record).timeout).toBe('60') + }) + + it('skips nodes that do not have the field', () => { + const doc = makeDoc() + const transform: Transform = { + type: 'changeFieldType', + scope: 'nodes', + field: 'nonexistent', + convert: (v) => String(v), + } + // Should not throw + const result = applyTransform(doc, transform) + expect(result.nodes.step1).toBeDefined() + }) + }) +}) + +describe('describeTransform', () => { + it('describes addField', () => { + const t: Transform = { + type: 'addField', + scope: 'nodes', + field: 'priority', + value: 'normal', + } + expect(describeTransform(t)).toBe('Added "priority" field to nodes') + }) + + it('describes addField with nodeType', () => { + const t: Transform = { + type: 'addField', + scope: 'nodes', + nodeType: 'action', + field: 'retries', + value: 3, + } + expect(describeTransform(t)).toBe('Added "retries" field to action nodes') + }) + + it('describes removeField', () => { + const t: Transform = { + type: 'removeField', + scope: 'metadata', + field: 'legacy', + } + expect(describeTransform(t)).toBe('Removed "legacy" field from metadata') + }) + + it('describes renameField', () => { + const t: Transform = { + type: 'renameField', + scope: 'nodes', + nodeType: 'wait', + from: 'timeout', + to: 'timeout_seconds', + } + expect(describeTransform(t)).toBe( + 'Renamed "timeout" to "timeout_seconds" in wait nodes', + ) + }) + + it('describes renameNodeType', () => { + const t: Transform = { type: 'renameNodeType', from: 'action', to: 'task' } + expect(describeTransform(t)).toBe('Renamed node type "action" to "task"') + }) + + it('describes setDefault', () => { + const t: Transform = { + type: 'setDefault', + scope: 'nodes', + field: 'retries', + value: 0, + } + expect(describeTransform(t)).toBe('Set default "retries" = 0 in nodes') + }) + + it('describes changeFieldType', () => { + const t: Transform = { + type: 'changeFieldType', + scope: 'nodes', + field: 'timeout', + convert: (v) => Number(v), + } + expect(describeTransform(t)).toBe('Converted "timeout" field type in nodes') + }) + + it('describes changeFieldType with nodeType', () => { + const t: Transform = { + type: 'changeFieldType', + scope: 'nodes', + nodeType: 'action', + field: 'timeout', + convert: (v) => Number(v), + } + expect(describeTransform(t)).toBe('Converted "timeout" field type in action nodes') + }) +}) diff --git a/packages/schema/src/transforms.ts b/packages/schema/src/transforms.ts new file mode 100644 index 0000000..92a8c93 --- /dev/null +++ b/packages/schema/src/transforms.ts @@ -0,0 +1,85 @@ +import type { FlowprintDocument, Transform } from './types.js' + +export function applyTransform( + doc: FlowprintDocument, + transform: Transform, +): FlowprintDocument { + switch (transform.type) { + case 'addField': + return applyToScope(doc, transform, (rec) => { + if (!(transform.field in rec)) { + rec[transform.field] = transform.value + } + }) + case 'removeField': + return applyToScope(doc, transform, (rec) => { + delete rec[transform.field] + }) + case 'renameField': + return applyToScope(doc, transform, (rec) => { + if (transform.from in rec) { + rec[transform.to] = rec[transform.from] + delete rec[transform.from] + } + }) + case 'renameNodeType': + for (const node of Object.values(doc.nodes)) { + if (node.type === transform.from) { + ;(node as Record).type = transform.to + } + } + return doc + case 'setDefault': + return applyToScope(doc, transform, (rec) => { + if (rec[transform.field] === undefined || rec[transform.field] === null) { + rec[transform.field] = transform.value + } + }) + case 'changeFieldType': + for (const node of Object.values(doc.nodes)) { + if (transform.nodeType && node.type !== transform.nodeType) continue + const rec = node as Record + if (transform.field in rec) { + rec[transform.field] = transform.convert(rec[transform.field]) + } + } + return doc + } +} + +function applyToScope( + doc: FlowprintDocument, + transform: { scope: 'nodes' | 'metadata'; nodeType?: string }, + fn: (rec: Record) => void, +): FlowprintDocument { + if (transform.scope === 'nodes') { + for (const node of Object.values(doc.nodes)) { + if (transform.nodeType && node.type !== transform.nodeType) continue + fn(node as Record) + } + } else if (transform.scope === 'metadata') { + doc.metadata = doc.metadata ?? ({} as Record) + fn(doc.metadata as Record) + } + return doc +} + +export function describeTransform(transform: Transform): string { + const scopeLabel = (t: { scope?: string; nodeType?: string }) => + t.nodeType ? `${t.nodeType} nodes` : (t.scope ?? 'nodes') + + switch (transform.type) { + case 'addField': + return `Added "${transform.field}" field to ${scopeLabel(transform)}` + case 'removeField': + return `Removed "${transform.field}" field from ${scopeLabel(transform)}` + case 'renameField': + return `Renamed "${transform.from}" to "${transform.to}" in ${scopeLabel(transform)}` + case 'renameNodeType': + return `Renamed node type "${transform.from}" to "${transform.to}"` + case 'setDefault': + return `Set default "${transform.field}" = ${JSON.stringify(transform.value)} in ${scopeLabel(transform)}` + case 'changeFieldType': + return `Converted "${transform.field}" field type in ${scopeLabel(transform)}` + } +} From 1371f5d80833778a0ac7d33ef9121ef004a3543d Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:01:31 -0400 Subject: [PATCH 04/12] feat(schema): add migration registry and 1.0 schema snapshot --- packages/schema/src/migrations/index.ts | 13 + .../schema/src/migrations/schemas/1.0.json | 776 ++++++++++++++++++ packages/schema/tsconfig.json | 2 +- 3 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 packages/schema/src/migrations/index.ts create mode 100644 packages/schema/src/migrations/schemas/1.0.json diff --git a/packages/schema/src/migrations/index.ts b/packages/schema/src/migrations/index.ts new file mode 100644 index 0000000..8b41d57 --- /dev/null +++ b/packages/schema/src/migrations/index.ts @@ -0,0 +1,13 @@ +import type { MigrationRule } from '../types.js' +import schema_1_0 from './schemas/1.0.json' with { type: 'json' } + +/** The current schema version this tool writes. */ +export const CURRENT_VERSION = 'flowprint/1.0' + +/** Ordered list of all migration rules. Empty until the first schema bump. */ +export const migrationRules: MigrationRule[] = [] + +/** Immutable JSON Schema snapshots for per-step validation. Keyed by version string. */ +export const schemaSnapshots: Record = { + 'flowprint/1.0': schema_1_0 as object, +} diff --git a/packages/schema/src/migrations/schemas/1.0.json b/packages/schema/src/migrations/schemas/1.0.json new file mode 100644 index 0000000..8267990 --- /dev/null +++ b/packages/schema/src/migrations/schemas/1.0.json @@ -0,0 +1,776 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://flowprint.dev/schema/flowprint/1.0", + "title": "Flowprint Service Blueprint", + "description": "A service blueprint definition mapping business processes to code entry points", + "type": "object", + "required": ["schema", "name", "version", "lanes", "nodes"], + "additionalProperties": false, + "properties": { + "schema": { + "type": "string", + "pattern": "^flowprint/\\d+\\.\\d+$", + "description": "Schema version identifier (e.g. flowprint/1.0)" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Machine-readable blueprint name" + }, + "version": { + "type": "string", + "minLength": 1, + "description": "Semantic version of this blueprint" + }, + "description": { + "type": "string", + "description": "Human-readable description of the blueprint" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom key-value metadata (e.g. owner, domain, tags)" + }, + "secrets": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["description"], + "additionalProperties": false, + "properties": { + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of what this secret is for" + } + } + }, + "description": "External secrets this workflow needs at runtime (names and descriptions only, never values)" + }, + "workflow": { + "type": "object", + "additionalProperties": false, + "properties": { + "task_queue": { + "type": "string", + "minLength": 1, + "description": "Temporal task queue name" + }, + "execution_timeout": { + "type": "string", + "minLength": 1, + "description": "Overall workflow execution timeout (e.g. 1h, 30m)" + }, + "input_type": { + "type": "string", + "minLength": 1, + "description": "TypeScript type name for workflow input" + }, + "input_type_import": { + "type": "string", + "minLength": 1, + "description": "Module path to import input type from (default: ./types)" + } + }, + "description": "Workflow-level execution configuration" + }, + "lanes": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/Lane" + }, + "description": "Swimlane definitions keyed by lane ID" + }, + "nodes": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/Node" + }, + "description": "Node definitions keyed by node ID" + } + }, + "definitions": { + "Lane": { + "type": "object", + "required": ["label", "visibility", "order"], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable lane display name" + }, + "visibility": { + "type": "string", + "enum": ["external", "internal"], + "description": "Whether this lane is customer-facing (external) or internal" + }, + "order": { + "type": "integer", + "minimum": 0, + "description": "Display order (0 = topmost lane)" + }, + "height": { + "type": "number", + "minimum": 140, + "description": "Optional custom lane height in pixels (minimum 140)" + } + } + }, + "EntryPoint": { + "type": "object", + "required": ["file", "symbol"], + "additionalProperties": false, + "properties": { + "file": { + "type": "string", + "minLength": 1, + "description": "Relative file path from repo root" + }, + "symbol": { + "type": "string", + "minLength": 1, + "description": "Function/method name in the file" + } + } + }, + "ErrorHandler": { + "type": "object", + "additionalProperties": false, + "properties": { + "retry": { + "type": "object", + "required": ["limit"], + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of retry attempts" + }, + "backoff": { + "type": "string", + "enum": ["linear", "exponential"], + "description": "Backoff strategy for retries" + } + } + }, + "catch": { + "type": "string", + "minLength": 1, + "description": "Node ID to handle the error" + } + } + }, + "Node": { + "oneOf": [ + { "$ref": "#/definitions/ActionNode" }, + { "$ref": "#/definitions/SwitchNode" }, + { "$ref": "#/definitions/ParallelNode" }, + { "$ref": "#/definitions/WaitNode" }, + { "$ref": "#/definitions/ErrorNode" }, + { "$ref": "#/definitions/TerminalNode" }, + { "$ref": "#/definitions/TriggerNode" } + ] + }, + "ActionNode": { + "type": "object", + "required": ["type", "lane", "label"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "action" + }, + "lane": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "notes": { + "type": "string", + "description": "Markdown notes for documentation and design rationale" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "entry_points": { + "type": "array", + "items": { + "$ref": "#/definitions/EntryPoint" + } + }, + "rules": { + "$ref": "#/definitions/RulesRef" + }, + "position": { + "$ref": "#/definitions/Position" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Named input mapping: parameter name to expression" + }, + "compensation": { + "$ref": "#/definitions/EntryPoint", + "description": "Compensation function for saga-style rollback" + }, + "temporal": { + "$ref": "#/definitions/TemporalConfig", + "description": "Per-activity Temporal configuration" + }, + "next": { + "type": "string", + "minLength": 1 + }, + "error": { + "$ref": "#/definitions/ErrorHandler" + } + } + }, + "SwitchNode": { + "type": "object", + "required": ["type", "lane", "label"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "switch" + }, + "lane": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "notes": { + "type": "string", + "description": "Markdown notes for documentation and design rationale" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "entry_points": { + "type": "array", + "items": { + "$ref": "#/definitions/EntryPoint" + } + }, + "rules": { + "$ref": "#/definitions/RulesRef" + }, + "position": { + "$ref": "#/definitions/Position" + }, + "cases": { + "type": "array", + "items": { + "type": "object", + "required": ["when", "next"], + "additionalProperties": false, + "properties": { + "when": { + "type": "string", + "minLength": 1 + }, + "next": { + "type": "string", + "minLength": 1 + } + } + } + }, + "default": { + "type": "string", + "minLength": 1 + } + } + }, + "ParallelNode": { + "type": "object", + "required": ["type", "lane", "label", "branches", "join"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "parallel" + }, + "lane": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "notes": { + "type": "string", + "description": "Markdown notes for documentation and design rationale" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "entry_points": { + "type": "array", + "items": { + "$ref": "#/definitions/EntryPoint" + } + }, + "position": { + "$ref": "#/definitions/Position" + }, + "branches": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "join": { + "type": "string" + }, + "join_strategy": { + "type": "string", + "enum": ["all", "first"] + } + } + }, + "WaitNode": { + "type": "object", + "required": ["type", "lane", "label", "event"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "wait" + }, + "lane": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "notes": { + "type": "string", + "description": "Markdown notes for documentation and design rationale" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "entry_points": { + "type": "array", + "items": { + "$ref": "#/definitions/EntryPoint" + } + }, + "position": { + "$ref": "#/definitions/Position" + }, + "event": { + "type": "string", + "minLength": 1, + "description": "Event name to wait for" + }, + "event_type": { + "type": "string", + "minLength": 1, + "description": "TypeScript type name for signal payload" + }, + "event_type_import": { + "type": "string", + "minLength": 1, + "description": "Module path to import event type from (default: ./types)" + }, + "timeout": { + "type": "string", + "minLength": 1, + "description": "Duration string (e.g. 7d, 24h, 30m)" + }, + "next": { + "type": "string", + "minLength": 1 + }, + "timeout_next": { + "type": "string", + "minLength": 1, + "description": "Node to route to when timeout expires" + } + } + }, + "ErrorNode": { + "type": "object", + "required": ["type", "lane", "label"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "error" + }, + "lane": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "notes": { + "type": "string", + "description": "Markdown notes for documentation and design rationale" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "entry_points": { + "type": "array", + "items": { + "$ref": "#/definitions/EntryPoint" + } + }, + "position": { + "$ref": "#/definitions/Position" + }, + "next": { + "type": "string", + "minLength": 1 + } + } + }, + "TerminalNode": { + "type": "object", + "required": ["type", "lane", "label", "outcome"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "terminal" + }, + "lane": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "notes": { + "type": "string", + "description": "Markdown notes for documentation and design rationale" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "position": { + "$ref": "#/definitions/Position" + }, + "outcome": { + "type": "string", + "enum": ["success", "failure"], + "description": "Whether this terminal represents a successful or failed outcome" + } + } + }, + "TriggerNode": { + "type": "object", + "required": ["type", "lane", "label", "trigger_type", "next"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "trigger" + }, + "lane": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "notes": { + "type": "string", + "description": "Markdown notes for documentation and design rationale" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "position": { + "$ref": "#/definitions/Position" + }, + "trigger_type": { + "type": "string", + "enum": ["schedule", "webhook", "event", "manual"] + }, + "next": { + "type": "string", + "minLength": 1, + "description": "Node ID that this trigger initiates" + }, + "schedule": { + "type": "object", + "additionalProperties": false, + "properties": { + "cron": { + "type": "string", + "minLength": 1 + }, + "timezone": { + "type": "string" + } + } + }, + "webhook": { + "type": "object", + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] + }, + "path": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "event": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": { + "type": "string" + }, + "type": { + "type": "string" + }, + "filter": { + "type": "string" + } + } + }, + "manual": { + "type": "object", + "additionalProperties": false, + "properties": { + "form_fields": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean"] + }, + "required": { + "type": "boolean" + } + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "trigger_type": { "const": "schedule" } }, + "required": ["trigger_type"] + }, + "then": { + "required": ["schedule"], + "properties": { + "webhook": false, + "event": false, + "manual": false + } + } + }, + { + "if": { + "properties": { "trigger_type": { "const": "webhook" } }, + "required": ["trigger_type"] + }, + "then": { + "required": ["webhook"], + "properties": { + "schedule": false, + "event": false, + "manual": false + } + } + }, + { + "if": { + "properties": { "trigger_type": { "const": "event" } }, + "required": ["trigger_type"] + }, + "then": { + "required": ["event"], + "properties": { + "schedule": false, + "webhook": false, + "manual": false + } + } + }, + { + "if": { + "properties": { "trigger_type": { "const": "manual" } }, + "required": ["trigger_type"] + }, + "then": { + "required": ["manual"], + "properties": { + "schedule": false, + "webhook": false, + "event": false + } + } + } + ] + }, + "RulesRef": { + "type": "object", + "required": ["file"], + "additionalProperties": false, + "properties": { + "file": { + "type": "string", + "minLength": 1, + "description": "Relative path to .rules.yaml file" + }, + "evaluator": { + "type": "string", + "minLength": 1, + "description": "Evaluator plugin name (default: builtin)" + } + } + }, + "Position": { + "type": "object", + "required": ["x", "y"], + "additionalProperties": false, + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" } + } + }, + "TemporalConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "start_to_close_timeout": { + "type": "string", + "minLength": 1, + "description": "Maximum time an activity can take from start to completion" + }, + "schedule_to_close_timeout": { + "type": "string", + "minLength": 1, + "description": "Maximum time from scheduling to completion including retries" + }, + "heartbeat_timeout": { + "type": "string", + "minLength": 1, + "description": "Maximum time between heartbeats" + }, + "retry": { + "type": "object", + "additionalProperties": false, + "properties": { + "max_attempts": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of retry attempts" + }, + "backoff_coefficient": { + "type": "number", + "description": "Retry backoff multiplier" + }, + "initial_interval": { + "type": "string", + "minLength": 1, + "description": "Initial retry interval" + }, + "max_interval": { + "type": "string", + "minLength": 1, + "description": "Maximum retry interval" + }, + "non_retryable_errors": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Error types that should not be retried" + } + }, + "description": "Retry policy configuration" + } + }, + "description": "Temporal Activity configuration" + } + } +} diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json index 4b7f0ac..cfd75ec 100644 --- a/packages/schema/tsconfig.json +++ b/packages/schema/tsconfig.json @@ -6,5 +6,5 @@ "rootDir": "src", "resolveJsonModule": true }, - "include": ["src"] + "include": ["src", "src/**/*.json"] } From 9e4b33881fb19dbefc573f30e694ef6ed4a4141c Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:04:56 -0400 Subject: [PATCH 05/12] feat(schema): implement core migrate() function with per-step validation --- packages/schema/src/__tests__/migrate.test.ts | 418 ++++++++++++++++++ packages/schema/src/migrate.ts | 150 +++++++ 2 files changed, 568 insertions(+) create mode 100644 packages/schema/src/__tests__/migrate.test.ts create mode 100644 packages/schema/src/migrate.ts diff --git a/packages/schema/src/__tests__/migrate.test.ts b/packages/schema/src/__tests__/migrate.test.ts new file mode 100644 index 0000000..dd42eee --- /dev/null +++ b/packages/schema/src/__tests__/migrate.test.ts @@ -0,0 +1,418 @@ +import { describe, it, expect } from 'vitest' +import { migrate, buildMigrationPath } from '../migrate.js' +import type { FlowprintDocument, MigrationRule, Transform } from '../types.js' + +function makeDoc(schemaVersion = 'flowprint/1.0'): FlowprintDocument { + return { + schema: schemaVersion, + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + start: { + type: 'trigger', + lane: 'main', + label: 'Start', + trigger_type: 'manual', + manual: {}, + next: 'done', + }, + step1: { type: 'action', lane: 'main', label: 'Step', next: 'done' }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'completed' }, + }, + } as FlowprintDocument +} + +const permissiveSchema = { + type: 'object', + required: ['schema', 'name', 'version', 'lanes', 'nodes'], + properties: { + schema: { type: 'string' }, + name: { type: 'string' }, + version: { type: 'string' }, + lanes: { type: 'object' }, + nodes: { type: 'object' }, + }, +} + +describe('migrate', () => { + describe('status: current', () => { + it('returns current when doc is at CURRENT_VERSION', () => { + const doc = makeDoc('flowprint/1.0') + const result = migrate(doc, { currentVersion: 'flowprint/1.0' }) + expect(result.status).toBe('current') + expect(result).toEqual({ status: 'current', doc }) + }) + }) + + describe('status: future_version', () => { + it('returns future_version when doc version > current', () => { + const doc = makeDoc('flowprint/2.0') + const result = migrate(doc, { currentVersion: 'flowprint/1.0', rules: [] }) + expect(result.status).toBe('future_version') + if (result.status === 'future_version') { + expect(result.documentVersion).toBe('flowprint/2.0') + expect(result.currentToolVersion).toBe('flowprint/1.0') + expect(result.doc).toEqual(doc) + } + }) + }) + + describe('status: migrated', () => { + it('applies migration and returns migrated result', () => { + const doc = makeDoc('flowprint/1.0') + const rules: MigrationRule[] = [ + { + from: 'flowprint/1.0', + to: 'flowprint/1.1', + required: true, + notable: false, + description: 'Add priority field to all nodes', + transforms: [ + { type: 'addField', scope: 'nodes', field: 'priority', value: 'normal' } as Transform, + ], + }, + ] + const schemas: Record = { + 'flowprint/1.1': permissiveSchema, + } + const result = migrate(doc, { + rules, + schemas, + currentVersion: 'flowprint/1.1', + }) + expect(result.status).toBe('migrated') + if (result.status === 'migrated') { + expect(result.doc.schema).toBe('flowprint/1.1') + expect(result.fromVersion).toBe('flowprint/1.0') + expect(result.toVersion).toBe('flowprint/1.1') + expect(result.changelog.from).toBe('flowprint/1.0') + expect(result.changelog.to).toBe('flowprint/1.1') + expect(result.changelog.entries).toHaveLength(1) + expect(result.changelog.entries[0].version).toBe('flowprint/1.1') + expect(result.changelog.entries[0].description).toBe('Add priority field to all nodes') + // Verify the transform was applied + const nodes = result.doc.nodes as Record> + for (const node of Object.values(nodes)) { + expect(node.priority).toBe('normal') + } + } + }) + + it('does not mutate the original document', () => { + const doc = makeDoc('flowprint/1.0') + const originalSchema = doc.schema + const originalNodeKeys = Object.keys(doc.nodes) + const rules: MigrationRule[] = [ + { + from: 'flowprint/1.0', + to: 'flowprint/1.1', + required: true, + notable: false, + description: 'Add priority field', + transforms: [ + { type: 'addField', scope: 'nodes', field: 'priority', value: 'normal' } as Transform, + ], + }, + ] + const schemas: Record = { + 'flowprint/1.1': permissiveSchema, + } + migrate(doc, { rules, schemas, currentVersion: 'flowprint/1.1' }) + + // Original document should be unchanged + expect(doc.schema).toBe(originalSchema) + expect(Object.keys(doc.nodes)).toEqual(originalNodeKeys) + for (const node of Object.values(doc.nodes)) { + expect((node as Record).priority).toBeUndefined() + } + }) + + it('applies multi-step chain in sequence (1.0 -> 1.1 -> 1.2)', () => { + const doc = makeDoc('flowprint/1.0') + const rules: MigrationRule[] = [ + { + from: 'flowprint/1.0', + to: 'flowprint/1.1', + required: true, + notable: false, + description: 'Step 1: add priority', + transforms: [ + { type: 'addField', scope: 'nodes', field: 'priority', value: 'normal' } as Transform, + ], + }, + { + from: 'flowprint/1.1', + to: 'flowprint/1.2', + required: false, + notable: true, + description: 'Step 2: add tags', + transforms: [ + { type: 'addField', scope: 'nodes', field: 'tags', value: [] } as Transform, + ], + }, + ] + const schemas: Record = { + 'flowprint/1.1': permissiveSchema, + 'flowprint/1.2': permissiveSchema, + } + const result = migrate(doc, { + rules, + schemas, + currentVersion: 'flowprint/1.2', + }) + expect(result.status).toBe('migrated') + if (result.status === 'migrated') { + expect(result.doc.schema).toBe('flowprint/1.2') + expect(result.fromVersion).toBe('flowprint/1.0') + expect(result.toVersion).toBe('flowprint/1.2') + expect(result.changelog.entries).toHaveLength(2) + expect(result.changelog.entries[0].version).toBe('flowprint/1.1') + expect(result.changelog.entries[1].version).toBe('flowprint/1.2') + // Both transforms applied + const nodes = result.doc.nodes as Record> + for (const node of Object.values(nodes)) { + expect(node.priority).toBe('normal') + expect(node.tags).toEqual([]) + } + } + }) + + it('executes custom transform after declarative transforms', () => { + const doc = makeDoc('flowprint/1.0') + const executionOrder: string[] = [] + const rules: MigrationRule[] = [ + { + from: 'flowprint/1.0', + to: 'flowprint/1.1', + required: true, + notable: false, + description: 'Custom after declarative', + transforms: [ + { type: 'addField', scope: 'nodes', field: 'priority', value: 'normal' } as Transform, + ], + custom: (d: FlowprintDocument) => { + // At this point, declarative transforms should have already been applied + const nodes = d.nodes as Record> + const firstNode = Object.values(nodes)[0] + if (firstNode.priority === 'normal') { + executionOrder.push('custom_after_declarative') + } + // Custom transform modifies the doc further + ;(d as Record).description = 'Modified by custom' + return d + }, + }, + ] + const schemas: Record = { + 'flowprint/1.1': permissiveSchema, + } + const result = migrate(doc, { + rules, + schemas, + currentVersion: 'flowprint/1.1', + }) + expect(result.status).toBe('migrated') + if (result.status === 'migrated') { + expect(executionOrder).toEqual(['custom_after_declarative']) + expect(result.doc.description).toBe('Modified by custom') + } + }) + }) + + describe('status: error', () => { + it('returns error when custom transform throws', () => { + const doc = makeDoc('flowprint/1.0') + const rules: MigrationRule[] = [ + { + from: 'flowprint/1.0', + to: 'flowprint/1.1', + required: true, + notable: false, + description: 'Throws error', + transforms: [], + custom: () => { + throw new Error('Custom transform failed!') + }, + }, + ] + const result = migrate(doc, { + rules, + schemas: { 'flowprint/1.1': permissiveSchema }, + currentVersion: 'flowprint/1.1', + }) + expect(result.status).toBe('error') + if (result.status === 'error') { + expect(result.error.reason).toContain('Custom transform failed!') + expect(result.error.failedRule).toBe('flowprint/1.0 \u2192 flowprint/1.1') + expect(result.error.stepIndex).toBe(0) + expect(result.originalDoc).toEqual(doc) + } + }) + + it('returns error when per-step validation fails', () => { + const doc = makeDoc('flowprint/1.0') + // Rule that removes 'type' from all nodes + const rules: MigrationRule[] = [ + { + from: 'flowprint/1.0', + to: 'flowprint/1.1', + required: true, + notable: false, + description: 'Remove type field', + transforms: [{ type: 'removeField', scope: 'nodes', field: 'type' } as Transform], + }, + ] + // Strict schema that requires 'type' on every node + const strictSchema = { + type: 'object', + required: ['schema', 'name', 'version', 'lanes', 'nodes'], + properties: { + schema: { type: 'string' }, + name: { type: 'string' }, + version: { type: 'string' }, + lanes: { type: 'object' }, + nodes: { + type: 'object', + additionalProperties: { + type: 'object', + required: ['type'], + properties: { + type: { type: 'string' }, + }, + }, + }, + }, + } + const result = migrate(doc, { + rules, + schemas: { 'flowprint/1.1': strictSchema }, + currentVersion: 'flowprint/1.1', + }) + expect(result.status).toBe('error') + if (result.status === 'error') { + expect(result.error.reason).toContain('Per-step validation failed') + expect(result.error.failedRule).toBe('flowprint/1.0 \u2192 flowprint/1.1') + expect(result.error.stepIndex).toBe(0) + expect(result.originalDoc).toEqual(doc) + } + }) + + it('returns error when no migration path exists', () => { + const doc = makeDoc('flowprint/1.0') + const result = migrate(doc, { + rules: [], + schemas: {}, + currentVersion: 'flowprint/1.5', + }) + expect(result.status).toBe('error') + if (result.status === 'error') { + expect(result.error.reason).toContain('No migration path found') + expect(result.error.stepIndex).toBe(-1) + expect(result.originalDoc).toEqual(doc) + } + }) + + it('stops at first failing step and returns original document', () => { + const doc = makeDoc('flowprint/1.0') + const rules: MigrationRule[] = [ + { + from: 'flowprint/1.0', + to: 'flowprint/1.1', + required: true, + notable: false, + description: 'Step 1: succeeds', + transforms: [ + { type: 'addField', scope: 'nodes', field: 'priority', value: 'normal' } as Transform, + ], + }, + { + from: 'flowprint/1.1', + to: 'flowprint/1.2', + required: true, + notable: false, + description: 'Step 2: fails', + transforms: [], + custom: () => { + throw new Error('Step 2 exploded') + }, + }, + ] + const schemas: Record = { + 'flowprint/1.1': permissiveSchema, + 'flowprint/1.2': permissiveSchema, + } + const result = migrate(doc, { + rules, + schemas, + currentVersion: 'flowprint/1.2', + }) + expect(result.status).toBe('error') + if (result.status === 'error') { + expect(result.error.stepIndex).toBe(1) + expect(result.error.failedRule).toBe('flowprint/1.1 \u2192 flowprint/1.2') + expect(result.error.reason).toContain('Step 2 exploded') + // Returns the original (unmutated) document, not the partially migrated one + expect(result.originalDoc).toEqual(doc) + expect(result.originalDoc.schema).toBe('flowprint/1.0') + } + }) + }) +}) + +describe('buildMigrationPath', () => { + const rules: MigrationRule[] = [ + { + from: 'flowprint/1.0', + to: 'flowprint/1.1', + required: true, + notable: false, + description: 'v1.0 to v1.1', + transforms: [], + }, + { + from: 'flowprint/1.1', + to: 'flowprint/1.2', + required: true, + notable: false, + description: 'v1.1 to v1.2', + transforms: [], + }, + { + from: 'flowprint/1.2', + to: 'flowprint/1.3', + required: false, + notable: true, + description: 'v1.2 to v1.3', + transforms: [], + }, + ] + + it('finds single-step path', () => { + const path = buildMigrationPath('flowprint/1.0', 'flowprint/1.1', rules) + expect(path).toHaveLength(1) + expect(path[0].from).toBe('flowprint/1.0') + expect(path[0].to).toBe('flowprint/1.1') + }) + + it('finds multi-step path', () => { + const path = buildMigrationPath('flowprint/1.0', 'flowprint/1.3', rules) + expect(path).toHaveLength(3) + expect(path[0].from).toBe('flowprint/1.0') + expect(path[0].to).toBe('flowprint/1.1') + expect(path[1].from).toBe('flowprint/1.1') + expect(path[1].to).toBe('flowprint/1.2') + expect(path[2].from).toBe('flowprint/1.2') + expect(path[2].to).toBe('flowprint/1.3') + }) + + it('returns empty array when no path exists', () => { + const path = buildMigrationPath('flowprint/1.5', 'flowprint/1.6', rules) + expect(path).toEqual([]) + }) + + it('returns empty array when from === to', () => { + const path = buildMigrationPath('flowprint/1.0', 'flowprint/1.0', rules) + expect(path).toEqual([]) + }) +}) diff --git a/packages/schema/src/migrate.ts b/packages/schema/src/migrate.ts new file mode 100644 index 0000000..29a5c9a --- /dev/null +++ b/packages/schema/src/migrate.ts @@ -0,0 +1,150 @@ +import Ajv from 'ajv' +import type { + FlowprintDocument, + MigrationRule, + MigrationResult, + MigrationChangelogEntry, +} from './types.js' +import { compareVersions } from './version.js' +import { applyTransform, describeTransform } from './transforms.js' +import { + CURRENT_VERSION as DEFAULT_CURRENT_VERSION, + migrationRules as defaultRules, + schemaSnapshots as defaultSchemas, +} from './migrations/index.js' + +export interface MigrateOptions { + rules?: MigrationRule[] + schemas?: Record + currentVersion?: string +} + +/** + * Migrate a Flowprint document from its current schema version to the target version. + * Pure function — does not mutate the input document. + */ +export function migrate(doc: FlowprintDocument, options?: MigrateOptions): MigrationResult { + const currentVersion = options?.currentVersion ?? DEFAULT_CURRENT_VERSION + const rules = options?.rules ?? defaultRules + const schemas = options?.schemas ?? defaultSchemas + const docVersion = doc.schema + + // Already at current version + if (docVersion === currentVersion) { + return { status: 'current', doc } + } + + // Document from the future + if (compareVersions(docVersion, currentVersion) > 0) { + return { + status: 'future_version', + doc, + documentVersion: docVersion, + currentToolVersion: currentVersion, + } + } + + // Build forward migration chain + const path = buildMigrationPath(docVersion, currentVersion, rules) + if (path.length === 0) { + return { + status: 'error', + originalDoc: doc, + error: { + failedRule: `${docVersion} \u2192 ${currentVersion}`, + reason: `No migration path found from ${docVersion} to ${currentVersion}`, + stepIndex: -1, + }, + } + } + + // Deep clone to avoid mutating the original + let current = structuredClone(doc) + const entries: MigrationChangelogEntry[] = [] + + for (let i = 0; i < path.length; i++) { + const rule = path[i] + try { + // Apply declarative transforms + for (const transform of rule.transforms) { + current = applyTransform(current, transform) + } + + // Apply custom transform if present + if (rule.custom) { + current = rule.custom(current) + } + + // Update schema version + ;(current as Record).schema = rule.to + + // Per-step validation against target version's schema snapshot + const targetSchema = schemas[rule.to] + if (targetSchema) { + const ajv = new Ajv({ allErrors: true }) + const validate = ajv.compile(targetSchema) + if (!validate(current)) { + const messages = + validate.errors?.map((e) => e.message).join('; ') ?? 'Unknown validation error' + return { + status: 'error', + originalDoc: doc, + error: { + failedRule: `${rule.from} \u2192 ${rule.to}`, + reason: `Per-step validation failed: ${messages}`, + stepIndex: i, + }, + } + } + } + + // Record changelog entry + entries.push({ + version: rule.to, + description: rule.description, + required: rule.required, + notable: rule.notable, + transforms: rule.transforms.map(describeTransform), + }) + } catch (err) { + return { + status: 'error', + originalDoc: doc, + error: { + failedRule: `${rule.from} \u2192 ${rule.to}`, + reason: err instanceof Error ? err.message : String(err), + stepIndex: i, + }, + } + } + } + + return { + status: 'migrated', + doc: current, + changelog: { from: docVersion, to: currentVersion, entries }, + fromVersion: docVersion, + toVersion: currentVersion, + } +} + +/** + * Build an ordered chain of migration rules from `from` to `to`. + * Returns empty array if no path exists or from === to. + */ +export function buildMigrationPath( + from: string, + to: string, + rules: MigrationRule[], +): MigrationRule[] { + if (from === to) return [] + const path: MigrationRule[] = [] + let current = from + while (current !== to) { + const next = rules.find((r) => r.from === current) + if (!next) return [] + path.push(next) + current = next.to + } + return path +} From 989e1b3005dd25268608ea28079d5e1913e8ea1e Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:06:59 -0400 Subject: [PATCH 06/12] feat(schema): export migration engine from package index Add migration types, migrate(), buildMigrationPath(), transform utilities, version helpers, and migration registry to the schema package's public API. Fix strict-mode TS errors in migrate.ts (non-null assertion for array indexing, double cast for Record). --- packages/schema/src/index.ts | 27 +++++++++++++++++++++++++++ packages/schema/src/migrate.ts | 4 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 08ad037..8f2cd2c 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -19,6 +19,22 @@ export type { ValidationError, OrderedNode, Edge, + MigrationRule, + MigrationResult, + MigrationResultMigrated, + MigrationResultError, + MigrationResultFutureVersion, + MigrationResultCurrent, + MigrationChangelog, + MigrationChangelogEntry, + MigrationError, + Transform, + AddFieldTransform, + RemoveFieldTransform, + RenameFieldTransform, + RenameNodeTypeTransform, + SetDefaultTransform, + ChangeFieldTypeTransform, } from './types.js' // Validation @@ -52,3 +68,14 @@ export { // Serialization export { serialize } from './serialize.js' + +// Migration engine +export { migrate, buildMigrationPath } from './migrate.js' +export type { MigrateOptions } from './migrate.js' +export { CURRENT_VERSION, migrationRules, schemaSnapshots } from './migrations/index.js' + +// Transforms +export { applyTransform, describeTransform } from './transforms.js' + +// Version utilities +export { parseVersion, compareVersions, isMajorBump } from './version.js' diff --git a/packages/schema/src/migrate.ts b/packages/schema/src/migrate.ts index 29a5c9a..9bd7ecf 100644 --- a/packages/schema/src/migrate.ts +++ b/packages/schema/src/migrate.ts @@ -63,7 +63,7 @@ export function migrate(doc: FlowprintDocument, options?: MigrateOptions): Migra const entries: MigrationChangelogEntry[] = [] for (let i = 0; i < path.length; i++) { - const rule = path[i] + const rule = path[i]! try { // Apply declarative transforms for (const transform of rule.transforms) { @@ -76,7 +76,7 @@ export function migrate(doc: FlowprintDocument, options?: MigrateOptions): Migra } // Update schema version - ;(current as Record).schema = rule.to + ;(current as unknown as Record).schema = rule.to // Per-step validation against target version's schema snapshot const targetSchema = schemas[rule.to] From 420e329b97f01610c1550a383025cc11e89d44df Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:09:54 -0400 Subject: [PATCH 07/12] feat(cli): add flowprint migrate command with dry-run support --- packages/cli/src/__tests__/migrate.test.ts | 78 ++++++++++++++++++++ packages/cli/src/commands/migrate.ts | 86 ++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 3 files changed, 166 insertions(+) create mode 100644 packages/cli/src/__tests__/migrate.test.ts create mode 100644 packages/cli/src/commands/migrate.ts diff --git a/packages/cli/src/__tests__/migrate.test.ts b/packages/cli/src/__tests__/migrate.test.ts new file mode 100644 index 0000000..f47b7fa --- /dev/null +++ b/packages/cli/src/__tests__/migrate.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { resolve, join } from 'node:path' +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' + +const CLI = resolve(__dirname, '../../dist/index.js') + +function run( + args: string[], + cwd?: string, +): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync('node', [CLI, ...args], { + encoding: 'utf-8', + cwd: cwd ?? resolve(__dirname, '../../../..'), + stdio: ['pipe', 'pipe', 'pipe'], + }) + return { stdout, stderr: '', exitCode: 0 } + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; status?: number } + return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', exitCode: e.status ?? 1 } + } +} + +const VALID_1_0 = `schema: flowprint/1.0 +name: test +version: "1.0.0" +lanes: + main: + label: Main + visibility: external + order: 0 +nodes: + done: + type: terminal + lane: main + label: Done + outcome: completed +` + +describe('flowprint migrate', () => { + it('exits 0 when document is already at current version', () => { + const tmp = mkdtempSync(join(tmpdir(), 'fp-migrate-')) + const file = join(tmp, 'test.flowprint.yaml') + writeFileSync(file, VALID_1_0) + try { + const { exitCode, stdout } = run(['migrate', file]) + expect(exitCode).toBe(0) + expect(stdout).toContain('current') + } finally { + rmSync(tmp, { recursive: true }) + } + }) + + it('shows help text with exit codes', () => { + const { stdout } = run(['migrate', '--help']) + expect(stdout).toContain('Exit codes') + expect(stdout).toContain('dry-run') + }) + + it('exits non-zero for unreadable file', () => { + const { exitCode } = run(['migrate', '/nonexistent/file.yaml']) + expect(exitCode).not.toBe(0) + }) + + it('--dry-run does not modify the file', () => { + const tmp = mkdtempSync(join(tmpdir(), 'fp-migrate-')) + const file = join(tmp, 'test.flowprint.yaml') + writeFileSync(file, VALID_1_0) + try { + run(['migrate', '--dry-run', file]) + expect(readFileSync(file, 'utf-8')).toBe(VALID_1_0) + } finally { + rmSync(tmp, { recursive: true }) + } + }) +}) diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts new file mode 100644 index 0000000..108a1da --- /dev/null +++ b/packages/cli/src/commands/migrate.ts @@ -0,0 +1,86 @@ +import { Command } from 'commander' +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import chalk from 'chalk' +import { parse } from 'yaml' +import { + migrate, + serialize, + CURRENT_VERSION, +} from '@ruminaider/flowprint-schema' +import type { FlowprintDocument } from '@ruminaider/flowprint-schema' + +export const migrateCommand = new Command('migrate') + .description('Migrate .flowprint.yaml files to the current schema version') + .argument('', 'Path to .flowprint.yaml file') + .option('--dry-run', 'Show what would change without writing') + .option('--output ', 'Write migrated output to a different file') + .addHelpText( + 'after', + '\nExit codes:\n 0 Already current or successfully migrated\n 1 Migration error\n 2 Future version (tool needs updating)', + ) + .action(async (file: string, opts: { dryRun?: boolean; output?: string }) => { + const filePath = resolve(file) + let content: string + try { + content = readFileSync(filePath, 'utf-8') + } catch { + console.error(chalk.red(`File not found or unreadable: ${file}`)) + process.exit(1) + } + + let doc: FlowprintDocument + try { + doc = parse(content) as FlowprintDocument + } catch (err) { + console.error( + chalk.red(`Failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`), + ) + process.exit(1) + } + + const result = migrate(doc) + + switch (result.status) { + case 'current': + console.log(chalk.green(`${file}: already at ${CURRENT_VERSION} (current)`)) + process.exit(0) + break + + case 'future_version': + console.error( + chalk.yellow( + `${file}: uses ${result.documentVersion}, but this tool only supports up to ${result.currentToolVersion}. Update your tool.`, + ), + ) + process.exit(2) + break + + case 'error': + console.error(chalk.red(`${file}: migration failed`)) + console.error(chalk.red(` Rule: ${result.error.failedRule}`)) + console.error(chalk.red(` Reason: ${result.error.reason}`)) + if (result.error.stepIndex >= 0) { + console.error(chalk.red(` Step index: ${result.error.stepIndex}`)) + } + process.exit(1) + break + + case 'migrated': { + console.log(chalk.green(`${file}: migrated ${result.fromVersion} → ${result.toVersion}`)) + for (const entry of result.changelog.entries) { + console.log(chalk.gray(` ${entry.version}: ${entry.description}`)) + } + if (opts.dryRun) { + console.log(chalk.yellow('\n(dry run — no files written)')) + } else { + const yaml = serialize(result.doc) + const outputPath = opts.output ? resolve(opts.output) : filePath + writeFileSync(outputPath, yaml, 'utf-8') + console.log(chalk.green(` Written to: ${outputPath}`)) + } + process.exit(0) + break + } + } + }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cff55dd..79e3b9f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ import { initCommand } from './commands/init.js' import { generateCommand } from './commands/generate.js' import { runCommand } from './commands/run.js' import { testCommand } from './commands/test.js' +import { migrateCommand } from './commands/migrate.js' const program = new Command() @@ -27,5 +28,6 @@ program.addCommand(initCommand) program.addCommand(generateCommand) program.addCommand(runCommand) program.addCommand(testCommand) +program.addCommand(migrateCommand) program.parse() From e14ab091da087ba839f598ad612fd761741af432 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:23:28 -0400 Subject: [PATCH 08/12] feat(editor): add migration banner and modal components --- .../editor/src/components/MigrationBanner.tsx | 56 +++++++++++++ .../editor/src/components/MigrationModal.tsx | 51 ++++++++++++ packages/editor/src/index.ts | 6 ++ packages/editor/src/styles/index.css | 2 + .../editor/src/styles/migration-banner.css | 61 +++++++++++++++ .../editor/src/styles/migration-modal.css | 78 +++++++++++++++++++ 6 files changed, 254 insertions(+) create mode 100644 packages/editor/src/components/MigrationBanner.tsx create mode 100644 packages/editor/src/components/MigrationModal.tsx create mode 100644 packages/editor/src/styles/migration-banner.css create mode 100644 packages/editor/src/styles/migration-modal.css diff --git a/packages/editor/src/components/MigrationBanner.tsx b/packages/editor/src/components/MigrationBanner.tsx new file mode 100644 index 0000000..6c3fda5 --- /dev/null +++ b/packages/editor/src/components/MigrationBanner.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react' +import type { MigrationResult } from '@ruminaider/flowprint-schema' + +export interface MigrationBannerProps { + result: MigrationResult + onDismiss?: () => void +} + +export function MigrationBanner({ result, onDismiss }: MigrationBannerProps) { + const [expanded, setExpanded] = useState(false) + + if (result.status === 'current') return null + + const variant = + result.status === 'migrated' + ? 'info' + : result.status === 'future_version' + ? 'warning' + : 'error' + + const message = + result.status === 'migrated' + ? `Migrated from ${result.fromVersion} \u2192 ${result.toVersion}` + : result.status === 'future_version' + ? `This document uses ${result.documentVersion}. Update your tool to edit it.` + : `Migration failed at ${result.error.failedRule}: ${result.error.reason}` + + return ( +
+ {message} + {result.status === 'migrated' && result.changelog.entries.length > 0 && ( + + )} + {onDismiss && variant === 'info' && ( + + )} + {expanded && result.status === 'migrated' && ( +
    + {result.changelog.entries.map((entry, i) => ( +
  • + {entry.version}: {entry.description} +
  • + ))} +
+ )} +
+ ) +} diff --git a/packages/editor/src/components/MigrationModal.tsx b/packages/editor/src/components/MigrationModal.tsx new file mode 100644 index 0000000..a0dcf3e --- /dev/null +++ b/packages/editor/src/components/MigrationModal.tsx @@ -0,0 +1,51 @@ +import { useRef, useEffect } from 'react' +import type { MigrationChangelog } from '@ruminaider/flowprint-schema' + +export interface MigrationModalProps { + open: boolean + changelog: MigrationChangelog + forced?: boolean + onAccept: () => void +} + +export function MigrationModal({ open, changelog, forced, onAccept }: MigrationModalProps) { + const dialogRef = useRef(null) + + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + if (open && !dialog.open) dialog.showModal() + if (!open && dialog.open) dialog.close() + }, [open]) + + return ( + +

+ {forced ? 'Upgrade Required' : "What's New"} +

+

+ Upgrading from {changelog.from} → {changelog.to} +

+
    + {changelog.entries.map((entry, i) => ( +
  • + {entry.version} + {entry.description} + {entry.transforms.length > 0 && ( +
      + {entry.transforms.map((t, j) => ( +
    • {t}
    • + ))} +
    + )} +
  • + ))} +
+
+ +
+
+ ) +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index dd2f263..c2c626c 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -65,3 +65,9 @@ export type { RulesDataMap, RulesDataEntry } from './contexts/RulesDataContext' // Theme export { useTheme } from './hooks/useTheme' export type { ThemeMode, ResolvedTheme } from './hooks/useTheme' + +// Migration UI +export { MigrationBanner } from './components/MigrationBanner' +export type { MigrationBannerProps } from './components/MigrationBanner' +export { MigrationModal } from './components/MigrationModal' +export type { MigrationModalProps } from './components/MigrationModal' diff --git a/packages/editor/src/styles/index.css b/packages/editor/src/styles/index.css index a52d6ea..86d0278 100644 --- a/packages/editor/src/styles/index.css +++ b/packages/editor/src/styles/index.css @@ -11,3 +11,5 @@ @import './zoom.css'; @import './bottom-panel.css'; @import './rules-editor.css'; +@import './migration-banner.css'; +@import './migration-modal.css'; diff --git a/packages/editor/src/styles/migration-banner.css b/packages/editor/src/styles/migration-banner.css new file mode 100644 index 0000000..dfd9fd1 --- /dev/null +++ b/packages/editor/src/styles/migration-banner.css @@ -0,0 +1,61 @@ +/* ── Migration Banner ─────────────────────── */ + +.fp-migration-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: var(--fp-text-sm); + font-family: var(--fp-font-sans); + border-bottom: 1px solid; +} + +.fp-migration-banner--info { + background: var(--fp-info-bg, #dbeafe); + border-color: var(--fp-info-border, #93c5fd); + color: var(--fp-info-text, #1e40af); +} + +.fp-migration-banner--warning { + background: var(--fp-warning-bg, #fef3c7); + border-color: var(--fp-warning-border, #fcd34d); + color: var(--fp-warning-text, #92400e); +} + +.fp-migration-banner--error { + background: var(--fp-error-bg, #fee2e2); + border-color: var(--fp-error-border, #fca5a5); + color: var(--fp-error-text, #991b1b); +} + +.fp-migration-banner__message { + flex: 1; +} + +.fp-migration-banner__toggle, +.fp-migration-banner__dismiss { + background: none; + border: none; + cursor: pointer; + font-size: var(--fp-text-sm); + color: inherit; + padding: 0; +} + +.fp-migration-banner__toggle { + text-decoration: underline; +} + +.fp-migration-banner__dismiss { + font-size: 18px; + line-height: 1; +} + +.fp-migration-banner__changelog { + width: 100%; + margin: 4px 0 0; + padding: 0 0 0 20px; + font-size: var(--fp-text-xs); + list-style: disc; +} diff --git a/packages/editor/src/styles/migration-modal.css b/packages/editor/src/styles/migration-modal.css new file mode 100644 index 0000000..b7c14a4 --- /dev/null +++ b/packages/editor/src/styles/migration-modal.css @@ -0,0 +1,78 @@ +/* ── Migration Modal ──────────────────────── */ + +.fp-migration-modal { + max-width: 480px; + width: 100%; + border: 1px solid var(--fp-border-default, #e2e8f0); + border-radius: var(--fp-radius-lg, 8px); + padding: var(--fp-space-6, 24px); + font-family: var(--fp-font-sans); +} + +.fp-migration-modal::backdrop { + background: rgba(0, 0, 0, 0.4); +} + +.fp-migration-modal__title { + margin: 0 0 4px; + font-size: 18px; +} + +.fp-migration-modal__subtitle { + margin: 0 0 16px; + font-size: var(--fp-text-sm); + color: var(--fp-text-secondary, #64748b); +} + +.fp-migration-modal__entries { + list-style: none; + padding: 0; + margin: 0 0 20px; +} + +.fp-migration-modal__entry { + padding: 8px 0; + border-bottom: 1px solid var(--fp-border-default, #e2e8f0); +} + +.fp-migration-modal__entry:last-child { + border-bottom: none; +} + +.fp-migration-modal__entry strong { + display: block; + font-size: var(--fp-text-sm); + margin-bottom: 2px; +} + +.fp-migration-modal__entry span { + font-size: var(--fp-text-sm); + color: var(--fp-text-secondary, #64748b); +} + +.fp-migration-modal__transforms { + font-size: var(--fp-text-xs); + color: var(--fp-text-secondary, #64748b); + padding-left: 16px; + margin-top: 4px; +} + +.fp-migration-modal__actions { + display: flex; + justify-content: flex-end; +} + +.fp-migration-modal__accept { + padding: 8px 16px; + border: none; + border-radius: var(--fp-radius-md, 4px); + background: var(--fp-color-primary, #3b82f6); + color: var(--fp-text-on-accent, white); + font-size: var(--fp-text-sm); + font-family: var(--fp-font-sans); + cursor: pointer; +} + +.fp-migration-modal__accept:hover { + opacity: 0.9; +} From 51e635e6cbb4e3fd398aa562b6eb05c356982c53 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:28:24 -0400 Subject: [PATCH 09/12] feat(app): integrate migration engine into file open lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure file-load flow to: parse → migrate → validate → load. All three file-open code paths (native FS, fallback input, project directory) now run the migration engine before validation. A MigrationBanner renders above the editor for non-current documents. --- packages/app/src/App.tsx | 21 +++- packages/app/src/hooks/useFileManager.test.ts | 18 ++-- packages/app/src/hooks/useFileManager.ts | 95 +++++++++++++++---- .../app/src/hooks/useProjectDirectory.test.ts | 11 ++- packages/app/src/hooks/useProjectDirectory.ts | 58 ++++++++--- 5 files changed, 157 insertions(+), 46 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index dc3dd43..f06d1af 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,5 +1,10 @@ import { useState, useCallback } from 'react' -import { FlowprintEditor, useTheme, useSymbolSearch } from '@ruminaider/flowprint-editor' +import { + FlowprintEditor, + MigrationBanner, + useTheme, + useSymbolSearch, +} from '@ruminaider/flowprint-editor' import type { RulesDataMap } from '@ruminaider/flowprint-editor' import '@ruminaider/flowprint-editor/styles.css' import type { FlowprintDocument } from '@ruminaider/flowprint-schema' @@ -106,6 +111,14 @@ export function App() { void settingsHook.updateSettings({ theme: next }) }, [settings.theme, settingsHook]) + // Use the migration result from whichever hook opened the file + const activeMigrationResult = + fileManager.migrationResult ?? projectDirectory.migrationResult ?? null + const dismissMigration = useCallback(() => { + fileManager.clearMigrationResult() + projectDirectory.clearMigrationResult() + }, [fileManager, projectDirectory]) + const handleClose = useCallback(() => { if (fileManager.dirty) { if (!window.confirm('You have unsaved changes. Discard and return to the welcome screen?')) { @@ -116,7 +129,8 @@ export function App() { fileManager.setDirty(false) setRulesDataMap({}) setError(null) - }, [fileManager]) + dismissMigration() + }, [fileManager, dismissMigration]) return (
+ {activeMigrationResult && activeMigrationResult.status !== 'current' && ( + + )}
({ - validateYaml: vi.fn(), + validate: vi.fn(), + migrate: vi.fn(), serialize: vi.fn(), })) @@ -111,8 +112,9 @@ describe('useFileManager', () => { beforeEach(() => { vi.clearAllMocks() - // Default: validation passes - vi.mocked(validateYaml).mockReturnValue({ + // Default: migration returns current, validation passes + vi.mocked(migrate).mockReturnValue({ status: 'current' }) + vi.mocked(validate).mockReturnValue({ valid: true, errors: [], }) @@ -169,7 +171,8 @@ describe('useFileManager', () => { types: [{ description: 'Flowprint YAML', accept: { 'text/yaml': ['.yaml', '.yml'] } }], multiple: false, }) - expect(validateYaml).toHaveBeenCalledWith(VALID_YAML) + expect(migrate).toHaveBeenCalledWith(MOCK_DOC) + expect(validate).toHaveBeenCalledWith(MOCK_DOC) expect(onDocLoaded).toHaveBeenCalledWith(MOCK_DOC, 'flow.flowprint.yaml') expect(result.current.fileName).toBe('flow.flowprint.yaml') expect(result.current.dirty).toBe(false) @@ -188,7 +191,8 @@ describe('useFileManager', () => { await result.current.openFile() }) - expect(validateYaml).toHaveBeenCalled() + expect(migrate).toHaveBeenCalledWith(MOCK_DOC) + expect(validate).toHaveBeenCalledWith(MOCK_DOC) expect(onDocLoaded).toHaveBeenCalledWith(MOCK_DOC, 'fallback.flowprint.yaml') expect(result.current.fileName).toBe('fallback.flowprint.yaml') expect(result.current.dirty).toBe(false) @@ -202,7 +206,7 @@ describe('useFileManager', () => { const onDocLoaded = vi.fn() const onError = vi.fn() - vi.mocked(validateYaml).mockReturnValue({ + vi.mocked(validate).mockReturnValue({ valid: false, errors: [ { path: '/schema', message: 'Missing required property: schema', severity: 'error' }, diff --git a/packages/app/src/hooks/useFileManager.ts b/packages/app/src/hooks/useFileManager.ts index c98207a..336a05a 100644 --- a/packages/app/src/hooks/useFileManager.ts +++ b/packages/app/src/hooks/useFileManager.ts @@ -1,7 +1,7 @@ import { useState, useRef, useCallback, useMemo } from 'react' import { parse } from 'yaml' -import { validateYaml, serialize } from '@ruminaider/flowprint-schema' -import type { FlowprintDocument } from '@ruminaider/flowprint-schema' +import { validate, migrate, serialize } from '@ruminaider/flowprint-schema' +import type { FlowprintDocument, MigrationResult } from '@ruminaider/flowprint-schema' export interface UseFileManagerOptions { doc: FlowprintDocument @@ -19,6 +19,8 @@ export interface UseFileManagerReturn { dirty: boolean setDirty(dirty: boolean): void supportsNativeFS: boolean + migrationResult: MigrationResult | null + clearMigrationResult(): void } const YAML_PICKER_TYPES: FilePickerAcceptType[] = [ @@ -40,6 +42,7 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe const [filePath, setFilePath] = useState(null) const [dirty, setDirty] = useState(false) const [hasFileHandle, setHasFileHandle] = useState(false) + const [migrationResult, setMigrationResult] = useState(null) const handleRef = useRef(null) const supportsNativeFS = useMemo(() => hasNativeFS(), []) @@ -62,21 +65,44 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe const file = await handle.getFile() const text = await file.text() - const result = validateYaml(text) - if (!result.valid) { - const firstError = result.errors[0] - const msg = firstError - ? `Invalid flowprint file: ${firstError.message} (at ${firstError.path})` - : 'Invalid flowprint file' - throw new Error(msg) + let parsed: FlowprintDocument + try { + parsed = parse(text) as FlowprintDocument + } catch (err) { + throw new Error( + `Failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const migration = migrate(parsed) + setMigrationResult(migration) + + // Determine which doc to load + const docToLoad = + migration.status === 'migrated' + ? migration.doc + : migration.status === 'error' + ? migration.originalDoc + : parsed + + // Run structural validation (skip for future_version — tool can't validate future schemas) + if (migration.status !== 'future_version') { + const validation = validate(docToLoad) + if (!validation.valid) { + const firstError = validation.errors.find((e) => e.severity === 'error') + if (firstError) { + throw new Error( + `Invalid flowprint file: ${firstError.message} (at ${firstError.path})`, + ) + } + } } - const parsed = parse(text) as FlowprintDocument setHandle(handle) setFileName(file.name) setFilePath(file.name) setDirty(false) - onDocLoaded(parsed, file.name) + onDocLoaded(docToLoad, file.name) }, [onDocLoaded, setHandle]) const openFileFallback = useCallback((): Promise => { @@ -96,21 +122,44 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe void file .text() .then((text) => { - const result = validateYaml(text) - if (!result.valid) { - const firstError = result.errors[0] - const msg = firstError - ? `Invalid flowprint file: ${firstError.message} (at ${firstError.path})` - : 'Invalid flowprint file' - throw new Error(msg) + let parsed: FlowprintDocument + try { + parsed = parse(text) as FlowprintDocument + } catch (err) { + throw new Error( + `Failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const migration = migrate(parsed) + setMigrationResult(migration) + + // Determine which doc to load + const docToLoad = + migration.status === 'migrated' + ? migration.doc + : migration.status === 'error' + ? migration.originalDoc + : parsed + + // Run structural validation (skip for future_version — tool can't validate future schemas) + if (migration.status !== 'future_version') { + const validation = validate(docToLoad) + if (!validation.valid) { + const firstError = validation.errors.find((e) => e.severity === 'error') + if (firstError) { + throw new Error( + `Invalid flowprint file: ${firstError.message} (at ${firstError.path})`, + ) + } + } } - const parsed = parse(text) as FlowprintDocument setHandle(null) setFileName(file.name) setFilePath(null) setDirty(false) - onDocLoaded(parsed, file.name) + onDocLoaded(docToLoad, file.name) resolve() }) .catch((err: unknown) => { @@ -206,6 +255,10 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe } }, [writeToHandle, saveFileAs, onError]) + const clearMigrationResult = useCallback(() => { + setMigrationResult(null) + }, []) + return { openFile, saveFile, @@ -216,5 +269,7 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe dirty, setDirty, supportsNativeFS, + migrationResult, + clearMigrationResult, } } diff --git a/packages/app/src/hooks/useProjectDirectory.test.ts b/packages/app/src/hooks/useProjectDirectory.test.ts index 068daaa..e0ca486 100644 --- a/packages/app/src/hooks/useProjectDirectory.test.ts +++ b/packages/app/src/hooks/useProjectDirectory.test.ts @@ -1,13 +1,14 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unsafe-assignment */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { validateYaml, validateRulesYaml } from '@ruminaider/flowprint-schema' +import { validate, migrate, validateRulesYaml } from '@ruminaider/flowprint-schema' import type { FlowprintDocument } from '@ruminaider/flowprint-schema' import { parse } from 'yaml' import { useProjectDirectory } from './useProjectDirectory' vi.mock('@ruminaider/flowprint-schema', () => ({ - validateYaml: vi.fn(), + validate: vi.fn(), + migrate: vi.fn(), validateRulesYaml: vi.fn(), })) @@ -111,7 +112,8 @@ describe('useProjectDirectory', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(validateYaml).mockReturnValue({ valid: true, errors: [] }) + vi.mocked(migrate).mockReturnValue({ status: 'current' }) + vi.mocked(validate).mockReturnValue({ valid: true, errors: [] }) vi.mocked(validateRulesYaml).mockReturnValue({ valid: true, errors: [] }) vi.mocked(parse).mockReturnValue(MOCK_DOC) @@ -176,7 +178,8 @@ describe('useProjectDirectory', () => { await result.current.openProject() }) - expect(validateYaml).toHaveBeenCalledWith(VALID_YAML) + expect(migrate).toHaveBeenCalledWith(MOCK_DOC_NO_RULES) + expect(validate).toHaveBeenCalledWith(MOCK_DOC_NO_RULES) expect(onDocLoaded).toHaveBeenCalledWith(MOCK_DOC_NO_RULES, 'flow.flowprint.yaml') expect(result.current.projectName).toBe('my-project') expect(onRulesResolved).toHaveBeenCalledWith({}) diff --git a/packages/app/src/hooks/useProjectDirectory.ts b/packages/app/src/hooks/useProjectDirectory.ts index f460ed9..ef14b8f 100644 --- a/packages/app/src/hooks/useProjectDirectory.ts +++ b/packages/app/src/hooks/useProjectDirectory.ts @@ -1,7 +1,7 @@ import { useState, useRef, useCallback, useMemo } from 'react' import { parse } from 'yaml' -import { validateYaml, validateRulesYaml } from '@ruminaider/flowprint-schema' -import type { FlowprintDocument } from '@ruminaider/flowprint-schema' +import { validate, migrate, validateRulesYaml } from '@ruminaider/flowprint-schema' +import type { FlowprintDocument, MigrationResult } from '@ruminaider/flowprint-schema' import type { RulesDataMap, RulesDataEntry } from '@ruminaider/flowprint-editor' // File System Access API type augmentation (Chrome/Edge only) @@ -25,6 +25,8 @@ export interface UseProjectDirectoryReturn { refreshRules(): Promise projectName: string | null supportsDirectoryPicker: boolean + migrationResult: MigrationResult | null + clearMigrationResult(): void } function hasDirectoryPicker(): boolean { @@ -125,6 +127,7 @@ export function useProjectDirectory( const { onDocLoaded, onRulesResolved, onError } = options const [projectName, setProjectName] = useState(null) + const [migrationResult, setMigrationResult] = useState(null) const dirHandleRef = useRef(null) const lastDocRef = useRef(null) @@ -166,22 +169,45 @@ export function useProjectDirectory( const file = await blueprintHandle.getFile() const text = await file.text() - const result = validateYaml(text) - if (!result.valid) { - const firstError = result.errors[0] - const msg = firstError - ? `Invalid flowprint file: ${firstError.message} (at ${firstError.path})` - : 'Invalid flowprint file' - throw new Error(msg) + let parsed: FlowprintDocument + try { + parsed = parse(text) as FlowprintDocument + } catch (err) { + throw new Error( + `Failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const migration = migrate(parsed) + setMigrationResult(migration) + + // Determine which doc to load + const docToLoad = + migration.status === 'migrated' + ? migration.doc + : migration.status === 'error' + ? migration.originalDoc + : parsed + + // Run structural validation (skip for future_version — tool can't validate future schemas) + if (migration.status !== 'future_version') { + const validation = validate(docToLoad) + if (!validation.valid) { + const firstError = validation.errors.find((e) => e.severity === 'error') + if (firstError) { + throw new Error( + `Invalid flowprint file: ${firstError.message} (at ${firstError.path})`, + ) + } + } } - const doc = parse(text) as FlowprintDocument dirHandleRef.current = dirHandle - lastDocRef.current = doc + lastDocRef.current = docToLoad setProjectName(dirHandle.name) - onDocLoaded(doc, file.name) + onDocLoaded(docToLoad, file.name) - await resolveRulesForDoc(dirHandle, doc) + await resolveRulesForDoc(dirHandle, docToLoad) } catch (err) { if (isAbortError(err)) return if (onError && err instanceof Error) { @@ -204,10 +230,16 @@ export function useProjectDirectory( } }, [resolveRulesForDoc, onError]) + const clearMigrationResult = useCallback(() => { + setMigrationResult(null) + }, []) + return { openProject, refreshRules, projectName, supportsDirectoryPicker, + migrationResult, + clearMigrationResult, } } From bf5a86baf89ffd1ac614b1adbd8753f5ccd3014c Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:34:20 -0400 Subject: [PATCH 10/12] test(schema): add migration integration tests and fix typecheck errors Add migrate-integration.test.ts with roundtrip (migrate -> validate -> serialize) and future_version tests. Fix typecheck errors in migration test files: use correct outcome enum values, add non-null assertions for array access, and cast through unknown for Record. Fix app test mocks to include required doc property in MigrationResult. --- packages/app/src/hooks/useFileManager.test.ts | 2 +- .../app/src/hooks/useProjectDirectory.test.ts | 2 +- .../src/__tests__/migrate-integration.test.ts | 67 +++++++++++++++++++ packages/schema/src/__tests__/migrate.test.ts | 30 ++++----- .../schema/src/__tests__/transforms.test.ts | 10 +-- 5 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 packages/schema/src/__tests__/migrate-integration.test.ts diff --git a/packages/app/src/hooks/useFileManager.test.ts b/packages/app/src/hooks/useFileManager.test.ts index ee10f9c..ce58f9d 100644 --- a/packages/app/src/hooks/useFileManager.test.ts +++ b/packages/app/src/hooks/useFileManager.test.ts @@ -113,7 +113,7 @@ describe('useFileManager', () => { vi.clearAllMocks() // Default: migration returns current, validation passes - vi.mocked(migrate).mockReturnValue({ status: 'current' }) + vi.mocked(migrate).mockReturnValue({ status: 'current', doc: MOCK_DOC }) vi.mocked(validate).mockReturnValue({ valid: true, errors: [], diff --git a/packages/app/src/hooks/useProjectDirectory.test.ts b/packages/app/src/hooks/useProjectDirectory.test.ts index e0ca486..4868e04 100644 --- a/packages/app/src/hooks/useProjectDirectory.test.ts +++ b/packages/app/src/hooks/useProjectDirectory.test.ts @@ -112,7 +112,7 @@ describe('useProjectDirectory', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(migrate).mockReturnValue({ status: 'current' }) + vi.mocked(migrate).mockReturnValue({ status: 'current', doc: MOCK_DOC }) vi.mocked(validate).mockReturnValue({ valid: true, errors: [] }) vi.mocked(validateRulesYaml).mockReturnValue({ valid: true, errors: [] }) vi.mocked(parse).mockReturnValue(MOCK_DOC) diff --git a/packages/schema/src/__tests__/migrate-integration.test.ts b/packages/schema/src/__tests__/migrate-integration.test.ts new file mode 100644 index 0000000..d1dc9cb --- /dev/null +++ b/packages/schema/src/__tests__/migrate-integration.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { parse } from 'yaml' +import { migrate, serialize, validate, CURRENT_VERSION } from '../index.js' +import type { FlowprintDocument } from '../types.js' + +describe('migration integration', () => { + it('current-version doc: migrate → validate → serialize roundtrip', () => { + const yaml = `schema: flowprint/1.0 +name: integration-test +version: "1.0.0" +lanes: + main: + label: Main + visibility: external + order: 0 +nodes: + start: + type: trigger + lane: main + label: Start + trigger_type: manual + manual: {} + next: process + process: + type: action + lane: main + label: Process + next: done + done: + type: terminal + lane: main + label: Done + outcome: success` + + const doc = parse(yaml) as FlowprintDocument + const result = migrate(doc) + expect(result.status).toBe('current') + + if (result.status === 'current') { + const validation = validate(result.doc) + expect(validation.valid).toBe(true) + + const serialized = serialize(result.doc) + const reparsed = parse(serialized) as FlowprintDocument + expect(reparsed.schema).toBe(CURRENT_VERSION) + expect(reparsed.name).toBe('integration-test') + } + }) + + it('future-version doc: returns future_version status', () => { + const doc = { + schema: 'flowprint/99.0', + name: 'future', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, + }, + } as FlowprintDocument + + const result = migrate(doc) + expect(result.status).toBe('future_version') + if (result.status === 'future_version') { + expect(result.documentVersion).toBe('flowprint/99.0') + } + }) +}) diff --git a/packages/schema/src/__tests__/migrate.test.ts b/packages/schema/src/__tests__/migrate.test.ts index dd42eee..a708623 100644 --- a/packages/schema/src/__tests__/migrate.test.ts +++ b/packages/schema/src/__tests__/migrate.test.ts @@ -18,7 +18,7 @@ function makeDoc(schemaVersion = 'flowprint/1.0'): FlowprintDocument { next: 'done', }, step1: { type: 'action', lane: 'main', label: 'Step', next: 'done' }, - done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'completed' }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, }, } as FlowprintDocument } @@ -89,8 +89,8 @@ describe('migrate', () => { expect(result.changelog.from).toBe('flowprint/1.0') expect(result.changelog.to).toBe('flowprint/1.1') expect(result.changelog.entries).toHaveLength(1) - expect(result.changelog.entries[0].version).toBe('flowprint/1.1') - expect(result.changelog.entries[0].description).toBe('Add priority field to all nodes') + expect(result.changelog.entries[0]!.version).toBe('flowprint/1.1') + expect(result.changelog.entries[0]!.description).toBe('Add priority field to all nodes') // Verify the transform was applied const nodes = result.doc.nodes as Record> for (const node of Object.values(nodes)) { @@ -167,8 +167,8 @@ describe('migrate', () => { expect(result.fromVersion).toBe('flowprint/1.0') expect(result.toVersion).toBe('flowprint/1.2') expect(result.changelog.entries).toHaveLength(2) - expect(result.changelog.entries[0].version).toBe('flowprint/1.1') - expect(result.changelog.entries[1].version).toBe('flowprint/1.2') + expect(result.changelog.entries[0]!.version).toBe('flowprint/1.1') + expect(result.changelog.entries[1]!.version).toBe('flowprint/1.2') // Both transforms applied const nodes = result.doc.nodes as Record> for (const node of Object.values(nodes)) { @@ -195,11 +195,11 @@ describe('migrate', () => { // At this point, declarative transforms should have already been applied const nodes = d.nodes as Record> const firstNode = Object.values(nodes)[0] - if (firstNode.priority === 'normal') { + if (firstNode?.priority === 'normal') { executionOrder.push('custom_after_declarative') } // Custom transform modifies the doc further - ;(d as Record).description = 'Modified by custom' + ;(d as unknown as Record).description = 'Modified by custom' return d }, }, @@ -391,19 +391,19 @@ describe('buildMigrationPath', () => { it('finds single-step path', () => { const path = buildMigrationPath('flowprint/1.0', 'flowprint/1.1', rules) expect(path).toHaveLength(1) - expect(path[0].from).toBe('flowprint/1.0') - expect(path[0].to).toBe('flowprint/1.1') + expect(path[0]!.from).toBe('flowprint/1.0') + expect(path[0]!.to).toBe('flowprint/1.1') }) it('finds multi-step path', () => { const path = buildMigrationPath('flowprint/1.0', 'flowprint/1.3', rules) expect(path).toHaveLength(3) - expect(path[0].from).toBe('flowprint/1.0') - expect(path[0].to).toBe('flowprint/1.1') - expect(path[1].from).toBe('flowprint/1.1') - expect(path[1].to).toBe('flowprint/1.2') - expect(path[2].from).toBe('flowprint/1.2') - expect(path[2].to).toBe('flowprint/1.3') + expect(path[0]!.from).toBe('flowprint/1.0') + expect(path[0]!.to).toBe('flowprint/1.1') + expect(path[1]!.from).toBe('flowprint/1.1') + expect(path[1]!.to).toBe('flowprint/1.2') + expect(path[2]!.from).toBe('flowprint/1.2') + expect(path[2]!.to).toBe('flowprint/1.3') }) it('returns empty array when no path exists', () => { diff --git a/packages/schema/src/__tests__/transforms.test.ts b/packages/schema/src/__tests__/transforms.test.ts index deab24e..710a537 100644 --- a/packages/schema/src/__tests__/transforms.test.ts +++ b/packages/schema/src/__tests__/transforms.test.ts @@ -34,7 +34,7 @@ function makeDoc( next: 'done', ...nodeOverrides?.wait1, }, - done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'completed' }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, }, } as FlowprintDocument } @@ -97,7 +97,7 @@ describe('applyTransform', () => { it('creates metadata object if absent', () => { const doc = makeDoc() - delete (doc as Record).metadata + delete (doc as unknown as Record).metadata const transform: Transform = { type: 'addField', scope: 'metadata', @@ -127,7 +127,7 @@ describe('applyTransform', () => { it('removes field from metadata', () => { const doc = makeDoc() - ;(doc as Record).metadata = { legacy: 'yes', owner: 'team-a' } + ;(doc as unknown as Record).metadata = { legacy: 'yes', owner: 'team-a' } const transform: Transform = { type: 'removeField', scope: 'metadata', @@ -184,7 +184,7 @@ describe('applyTransform', () => { it('renames field in metadata', () => { const doc = makeDoc() - ;(doc as Record).metadata = { old_key: 'value' } + ;(doc as unknown as Record).metadata = { old_key: 'value' } const transform: Transform = { type: 'renameField', scope: 'metadata', @@ -266,7 +266,7 @@ describe('applyTransform', () => { it('sets default in metadata scope', () => { const doc = makeDoc() - ;(doc as Record).metadata = {} + ;(doc as unknown as Record).metadata = {} const transform: Transform = { type: 'setDefault', scope: 'metadata', From a698bde00bf3136a5abe6eb95511650a9a6544ea Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:44:14 -0400 Subject: [PATCH 11/12] fix(app): enforce read-only mode for future-version documents When a document's migration status is 'future_version', the editor is now set to readOnly and the Save/Save As buttons are disabled. --- packages/app/src/App.tsx | 2 ++ packages/app/src/components/Header.tsx | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index f06d1af..db8da57 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -213,6 +213,7 @@ export function App() { setSettingsOpen(true) }} onClose={handleClose} + saveDisabled={activeMigrationResult?.status === 'future_version'} /> {activeMigrationResult && activeMigrationResult.status !== 'current' && ( @@ -224,6 +225,7 @@ export function App() { theme={settings.theme} symbolSearch={symbolSearch ?? undefined} rulesDataMap={rulesDataMap} + readOnly={activeMigrationResult?.status === 'future_version'} showYamlPreview showExportButton style={{ width: '100%', height: '100%' }} diff --git a/packages/app/src/components/Header.tsx b/packages/app/src/components/Header.tsx index 0552c00..0fad452 100644 --- a/packages/app/src/components/Header.tsx +++ b/packages/app/src/components/Header.tsx @@ -13,6 +13,7 @@ export interface HeaderProps { onSaveAs: () => void onSettings: () => void onClose?: () => void + saveDisabled?: boolean } const THEME_LABELS: Record = { @@ -34,9 +35,11 @@ const btnStyle: React.CSSProperties = { function HeaderButton({ onClick, children, + disabled, }: { onClick: () => void children: React.ReactNode + disabled?: boolean }) { const [hovered, setHovered] = useState(false) return ( @@ -45,9 +48,12 @@ function HeaderButton({ onClick={onClick} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} + disabled={disabled} style={{ ...btnStyle, - background: hovered ? '#252434' : '#1C1B25', + background: hovered && !disabled ? '#252434' : '#1C1B25', + opacity: disabled ? 0.4 : 1, + cursor: disabled ? 'not-allowed' : 'pointer', }} > {children} @@ -67,6 +73,7 @@ export function Header({ onSaveAs, onSettings, onClose, + saveDisabled, }: HeaderProps) { return (
Open Project )} - Save - Save As + Save + Save As Settings Theme: {THEME_LABELS[themeMode]} From 73e15ad88372695f66078616a5b553c025c1cc55 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:48:48 -0400 Subject: [PATCH 12/12] feat(app): wire MigrationModal for required/notable/major migrations --- packages/app/src/App.tsx | 44 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index db8da57..06f8a43 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,13 +1,15 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useMemo, useEffect } from 'react' import { FlowprintEditor, MigrationBanner, + MigrationModal, useTheme, useSymbolSearch, } from '@ruminaider/flowprint-editor' import type { RulesDataMap } from '@ruminaider/flowprint-editor' import '@ruminaider/flowprint-editor/styles.css' import type { FlowprintDocument } from '@ruminaider/flowprint-schema' +import { isMajorBump } from '@ruminaider/flowprint-schema' import { Header } from './components/Header' import { WelcomeScreen } from './components/WelcomeScreen' import { NewBlueprintWizard } from './components/NewBlueprintWizard' @@ -26,6 +28,7 @@ export function App() { const [settingsOpen, setSettingsOpen] = useState(false) const [error, setError] = useState(null) const [rulesDataMap, setRulesDataMap] = useState({}) + const [migrationAccepted, setMigrationAccepted] = useState(false) const settingsHook = useSettings() const { settings } = settingsHook @@ -119,6 +122,27 @@ export function App() { projectDirectory.clearMigrationResult() }, [fileManager, projectDirectory]) + const needsMigrationModal = useMemo(() => { + if (!activeMigrationResult || activeMigrationResult.status !== 'migrated') return false + const hasRequiredOrNotable = activeMigrationResult.changelog.entries.some( + (e) => e.required || e.notable, + ) + const isMajor = isMajorBump( + activeMigrationResult.fromVersion, + activeMigrationResult.toVersion, + ) + return hasRequiredOrNotable || isMajor + }, [activeMigrationResult]) + + const isForcedUpgrade = useMemo(() => { + if (!activeMigrationResult || activeMigrationResult.status !== 'migrated') return false + return isMajorBump(activeMigrationResult.fromVersion, activeMigrationResult.toVersion) + }, [activeMigrationResult]) + + useEffect(() => { + setMigrationAccepted(false) + }, [activeMigrationResult]) + const handleClose = useCallback(() => { if (fileManager.dirty) { if (!window.confirm('You have unsaved changes. Discard and return to the welcome screen?')) { @@ -213,11 +237,22 @@ export function App() { setSettingsOpen(true) }} onClose={handleClose} - saveDisabled={activeMigrationResult?.status === 'future_version'} + saveDisabled={ + activeMigrationResult?.status === 'future_version' || + (needsMigrationModal && !migrationAccepted) + } /> {activeMigrationResult && activeMigrationResult.status !== 'current' && ( )} + {needsMigrationModal && activeMigrationResult?.status === 'migrated' && ( + setMigrationAccepted(true)} + /> + )}