Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions crates/wavekat-flow/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValidationError>> {
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions packages/flow-schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,6 @@ export {
setExit,
setNodePosition,
setNodeValue,
setSchemaVersion,
stampIdentity,
} from './mutate.js';
28 changes: 26 additions & 2 deletions packages/flow-schema/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/
Expand All @@ -119,6 +123,26 @@ export const KIND_MIN_SCHEMA_VERSION: Record<ComponentKind, number> = {
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
Expand Down
20 changes: 20 additions & 0 deletions packages/flow-schema/src/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions packages/flow-schema/test/model.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 23 additions & 0 deletions packages/flow-schema/test/mutate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
setExit,
setNodePosition,
setNodeValue,
setSchemaVersion,
stampIdentity,
} from '../src/mutate.js';
import { COMPONENT_KINDS } from '../src/model.js';
Expand Down Expand Up @@ -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' });
});
});
Loading