From 8c645a8775a057b2e1c241de19d23b5fe261ea9e Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Sun, 9 Aug 2026 13:58:08 +1200 Subject: [PATCH] feat: derive and set a document's schema_version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authoring tool should not make the author think about the format's version number. `requiredSchemaVersion` reports the lowest version that can carry a document's components; `setSchemaVersion` restates the declared one through the CST, so comments and key order survive. Together they let an editor keep a draft at the lowest version that can run it — which is also the widest, since an engine only runs versions it knows. Adding a `book` step raises the document on its own; removing it lowers it again. This does not migrate stored documents. The format's promise that a v1 flow keeps working is unchanged, and the comment on KIND_MIN_SCHEMA_VERSION now says which of the two it is talking about. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AFE4mZNdkfPUBbTTwW4e7A --- crates/wavekat-flow/src/validate.rs | 65 ++++++++++++++++++++++ packages/flow-schema/src/index.ts | 1 + packages/flow-schema/src/model.ts | 28 +++++++++- packages/flow-schema/src/mutate.ts | 20 +++++++ packages/flow-schema/test/model.test.ts | 69 ++++++++++++++++++++++++ packages/flow-schema/test/mutate.test.ts | 23 ++++++++ 6 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 packages/flow-schema/test/model.test.ts diff --git a/crates/wavekat-flow/src/validate.rs b/crates/wavekat-flow/src/validate.rs index f5b3af7..a3baeda 100644 --- a/crates/wavekat-flow/src/validate.rs +++ b/crates/wavekat-flow/src/validate.rs @@ -152,6 +152,24 @@ fn kind_min_schema_version(kind: &str) -> i64 { } } +/// The lowest `schema_version` that can carry this document's components — +/// the version it *needs*, as against the one it declares. +/// +/// The engine has no use for this; it is here for authoring tools, which +/// set the declared version from it so the number tracks the steps the +/// author placed rather than being one more thing they must know about. +/// A document needing nothing newer stays at 1, which is also the widest: +/// every engine in the field can run it. +/// +/// Twin: `model.ts` `requiredSchemaVersion`. +pub fn required_schema_version(flow: &Flow) -> i64 { + flow.nodes + .values() + .map(|node| kind_min_schema_version(node.kind())) + .max() + .unwrap_or(1) +} + /// Validate a parsed flow. `Ok(())` means safe to publish / execute; `Err` /// carries every problem found. pub fn validate(flow: &Flow) -> Result<(), Vec> { @@ -744,4 +762,51 @@ nodes: .iter() .any(|e| matches!(e, ValidationError::Hours { .. }))); } + + // ── `required_schema_version` ──────────────────────────────────────── + + const BOOKING: &str = r#" +schema_version: 2 +id: f +name: n +entry: appointment +nodes: + appointment: + kind: book + prompt: When suits you? + confirm_prompt: You're booked for + timezone: UTC + schedule: + tue: [{ open: "09:00", close: "17:00" }] + duration_mins: 30 + exits: + booked: bye + no_slots: bye + no_input: bye + unavailable: bye + bye: + kind: hangup +"#; + + #[test] + fn a_document_of_v1_components_needs_only_v1() { + assert_eq!(required_schema_version(&parse(LUIGIS)), 1); + } + + #[test] + fn a_document_rises_to_what_its_newest_component_needs() { + assert_eq!(required_schema_version(&parse(BOOKING)), 2); + } + + #[test] + fn a_document_with_no_steps_needs_v1() { + let src = r#" +schema_version: 1 +id: f +name: n +entry: bye +nodes: {} +"#; + assert_eq!(required_schema_version(&parse(src)), 1); + } } diff --git a/packages/flow-schema/src/index.ts b/packages/flow-schema/src/index.ts index 1570fee..c5b847c 100644 --- a/packages/flow-schema/src/index.ts +++ b/packages/flow-schema/src/index.ts @@ -58,5 +58,6 @@ export { setExit, setNodePosition, setNodeValue, + setSchemaVersion, stampIdentity, } from './mutate.js'; diff --git a/packages/flow-schema/src/model.ts b/packages/flow-schema/src/model.ts index 44b39b2..18efaca 100644 --- a/packages/flow-schema/src/model.ts +++ b/packages/flow-schema/src/model.ts @@ -103,8 +103,12 @@ export type ComponentKind = (typeof COMPONENT_KINDS)[number]; * * A record rather than a list of new kinds per version: adding a kind is * then one entry the compiler demands, and the check reads as a - * comparison instead of a search. Documents are never rewritten, so a - * v1 flow keeps working forever — it simply may not use `book`. + * comparison instead of a search. + * + * The format never migrates a document: a v1 flow keeps working forever, + * it simply may not use `book`. That is a promise about *stored* + * documents and is not in tension with an authoring tool raising a draft + * it is editing — see {@link requiredSchemaVersion}. * * Twin: `validate.rs` `kind_min_schema_version`. */ @@ -119,6 +123,26 @@ export const KIND_MIN_SCHEMA_VERSION: Record = { book: 2, }; +/** + * The lowest `schema_version` that can carry this document's components — + * the version it *needs*, as against the one it declares. + * + * For an authoring tool, this is the whole of what a version bump means: + * the author adds a step and the number follows, instead of becoming + * something they have to know about and set by hand. Pairing it with + * {@link setSchemaVersion} keeps a draft at the lowest version that can + * run it, which is also the widest — a document that needs nothing newer + * stays runnable by every engine in the field. + * + * The floor of 1 is load-bearing: `Math.max()` of nothing is `-Infinity`, + * and a document with no steps yet is exactly what a new draft is. + * + * Twin: `validate.rs` `required_schema_version`. + */ +export function requiredSchemaVersion(flow: Flow): number { + return Math.max(1, ...Object.values(flow.nodes).map((node) => KIND_MIN_SCHEMA_VERSION[node.kind])); +} + /** * What a `message` node plays between its prompt and the start of * recording — the caller's "start talking now" cue. A closed set: the Rust diff --git a/packages/flow-schema/src/mutate.ts b/packages/flow-schema/src/mutate.ts index bf4fb71..7b3b591 100644 --- a/packages/flow-schema/src/mutate.ts +++ b/packages/flow-schema/src/mutate.ts @@ -269,6 +269,26 @@ export function stampIdentity( }); } +/** + * Restate the document's declared `schema_version`. + * + * Machine-managed, like the identity fields above: an authoring tool sets + * it from {@link requiredSchemaVersion} so the number tracks the steps the + * author actually placed, rather than being a thing they have to know + * about. It lowers as readily as it raises — a draft that no longer needs + * the newer component goes back to the widest version that can run it. + * + * This does not migrate a document. Nothing here rewrites a stored flow's + * *contents* to suit another version; the format's promise that an old + * document keeps working is untouched. + */ +export function setSchemaVersion(source: string, version: number): EditResult { + return withDoc(source, (doc) => { + doc.set('schema_version', version); + return undefined; + }); +} + /** * A freshly-added node's starting shape, per kind. English placeholder * copy — the web editor passes its own localized shape instead; this is diff --git a/packages/flow-schema/test/model.test.ts b/packages/flow-schema/test/model.test.ts new file mode 100644 index 0000000..9d0f02e --- /dev/null +++ b/packages/flow-schema/test/model.test.ts @@ -0,0 +1,69 @@ +// The version a document *needs*, as opposed to the one it declares. +// An authoring tool asks this to keep the two in step: the author adds a +// step, and the number follows on its own rather than becoming something +// they have to know about. + +import { describe, expect, it } from 'vitest'; + +import { checkFlow } from '../src/check.js'; +import { requiredSchemaVersion } from '../src/model.js'; +import type { Flow } from '../src/model.js'; + +function parsed(source: string): Flow { + const { flow, issues } = checkFlow(source); + if (!flow) throw new Error(`fixture failed to parse: ${JSON.stringify(issues)}`); + return flow; +} + +const V1_ONLY = `schema_version: 1 +id: flow_1 +name: Test +entry: welcome +nodes: + welcome: + kind: greeting + prompt: Hi! + exits: { next: bye } + bye: + kind: hangup +`; + +const WITH_BOOK = `schema_version: 2 +id: flow_1 +name: Test +entry: appointment +nodes: + appointment: + kind: book + prompt: When suits you? + confirm_prompt: You're booked for + timezone: UTC + duration_mins: 30 + schedule: + mon: [{ open: "09:00", close: "17:00" }] + exits: { booked: bye, no_slots: bye, no_input: bye, unavailable: bye } + bye: + kind: hangup +`; + +describe('requiredSchemaVersion', () => { + it('is 1 for a document built only from components v1 had', () => { + expect(requiredSchemaVersion(parsed(V1_ONLY))).toBe(1); + }); + + it('rises to the newest version any one component needs', () => { + expect(requiredSchemaVersion(parsed(WITH_BOOK))).toBe(2); + }); + + it('falls back again once the component that raised it is gone', () => { + const flow = parsed(WITH_BOOK); + delete flow.nodes.appointment; + expect(requiredSchemaVersion(flow)).toBe(1); + }); + + it('is 1 for a document with no steps at all', () => { + // The starter document an author lands on. `Math.max()` of nothing is + // -Infinity, so the floor is load-bearing, not decorative. + expect(requiredSchemaVersion({ ...parsed(V1_ONLY), nodes: {} })).toBe(1); + }); +}); diff --git a/packages/flow-schema/test/mutate.test.ts b/packages/flow-schema/test/mutate.test.ts index 10d7c59..e32905f 100644 --- a/packages/flow-schema/test/mutate.test.ts +++ b/packages/flow-schema/test/mutate.test.ts @@ -21,6 +21,7 @@ import { setExit, setNodePosition, setNodeValue, + setSchemaVersion, stampIdentity, } from '../src/mutate.js'; import { COMPONENT_KINDS } from '../src/model.js'; @@ -237,3 +238,25 @@ describe('stampIdentity', () => { expect(stampIdentity('nodes: [', { id: 'x' })).toEqual({ ok: false, error: 'parse_failed' }); }); }); + +describe('setSchemaVersion', () => { + it('restates the declared version, leaving the document otherwise alone', () => { + const source = edited(setSchemaVersion(SOURCE, 2)); + const { flow } = checkFlow(source); + expect(flow?.schema_version).toBe(2); + expect(flow?.entry).toBe('welcome'); + expect(source).toContain('swap this for your own greeting'); + expect(source).toContain('ui:'); + }); + + it('lowers the version as readily as it raises it', () => { + // An authoring tool drops a draft back to the widest version that can + // still run it once the component that forced the bump is removed. + const raised = edited(setSchemaVersion(SOURCE, 2)); + expect(checkFlow(edited(setSchemaVersion(raised, 1))).flow?.schema_version).toBe(1); + }); + + it('fails cleanly on unparseable text', () => { + expect(setSchemaVersion('nodes: [', 2)).toEqual({ ok: false, error: 'parse_failed' }); + }); +});