diff --git a/packages/flow-schema/scripts/generate.mjs b/packages/flow-schema/scripts/generate.mjs index 09aefa1..1f45bf8 100644 --- a/packages/flow-schema/scripts/generate.mjs +++ b/packages/flow-schema/scripts/generate.mjs @@ -6,7 +6,7 @@ // is committed; CI runs `gen` and fails on any diff (drift guard). import { compileFromFile } from 'json-schema-to-typescript'; -import { mkdir, writeFile, copyFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -22,11 +22,24 @@ await mkdir(genDir, { recursive: true }); // consumers don't reach outside the package at runtime. await copyFile(schemaPath, resolve(genDir, 'flow.v1.schema.json')); -// 2. Emit the model types from the schema. const banner = '// GENERATED from schema/flow.v1.schema.json by scripts/generate.mjs — do not edit.\n' + '// Run `pnpm gen` after changing the schema; CI fails on drift.\n'; +// 2. Emit the schema as a TS module. The runtime imports this instead of +// the bundled JSON file: a JSON import needs `with { type: 'json' }`, +// which older consumer bundlers (e.g. wrangler 3's pinned esbuild) +// cannot parse in the published dist. +const schemaSource = JSON.parse(await readFile(schemaPath, 'utf8')); +const schemaTs = + banner + + '\nconst schema = ' + + JSON.stringify(schemaSource, null, 2) + + ' as const;\n\nexport default schema;\n'; +await writeFile(resolve(genDir, 'schema.ts'), schemaTs); + +// 3. Emit the model types from the schema. + const ts = await compileFromFile(schemaPath, { bannerComment: banner, additionalProperties: true, // unknown fields are accepted (matches both twins) @@ -35,4 +48,4 @@ const ts = await compileFromFile(schemaPath, { await writeFile(resolve(genDir, 'model.ts'), ts); -console.log('generated src/generated/model.ts and bundled flow.v1.schema.json'); +console.log('generated src/generated/{model,schema}.ts and bundled flow.v1.schema.json'); diff --git a/packages/flow-schema/src/generated/schema.ts b/packages/flow-schema/src/generated/schema.ts new file mode 100644 index 0000000..244eb9b --- /dev/null +++ b/packages/flow-schema/src/generated/schema.ts @@ -0,0 +1,380 @@ +// GENERATED from schema/flow.v1.schema.json by scripts/generate.mjs — do not edit. +// Run `pnpm gen` after changing the schema; CI fails on drift. + +const schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.wavekat.com/flow/v1.json", + "title": "Flow", + "description": "The WaveKat call-flow (\"Receptionist\") document, schema_version 1. This file is the single source of truth for the document SHAPE: every consumer (the @wavekat/flow-schema npm package, the wavekat-flow Rust crate) generates its model types from this file. Semantic rules that a JSON Schema cannot express (graph reachability, exit-set exactness, hours/timezone math, DTMF digit validity, prompt length) are NOT encoded here — they live in each language's validator and are pinned by the shared conformance corpus. Unknown fields are permitted structurally, matching both implementations (Rust serde ignores them; the TS parser surfaces a non-blocking warning).", + "type": "object", + "required": [ + "schema_version", + "id", + "name", + "entry", + "nodes" + ], + "properties": { + "schema_version": { + "description": "Document format version. This file describes version 1 only; a document declaring another version validates against that version's schema file, not this one.", + "type": "integer", + "const": 1 + }, + "id": { + "description": "Opaque platform-assigned id (flow_…). Treated as a label; appears in traces.", + "type": "string" + }, + "name": { + "description": "Human name shown in the editor and the read-only viewer.", + "type": "string" + }, + "version": { + "description": "Platform-assigned publish counter, bumped on publish. Defaults to 1 for a hand-written or generated pre-publish document. Distinct from schema_version.", + "type": "integer", + "minimum": 0, + "default": 1 + }, + "entry": { + "description": "Node id where execution begins.", + "type": "string" + }, + "nodes": { + "description": "The flat node set, keyed by human-meaningful node id and string-referenced by exits. Order is irrelevant to execution.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Node" + } + }, + "ui": { + "description": "Presentation metadata (canvas positions, annotations). Preserved on round-trip, never read by the engine. Opaque by design so a canvas editor can add layout fields without a schema bump." + } + }, + "$defs": { + "Prompt": { + "description": "What a component speaks: TTS text (a bare string) or a reference to a pre-rendered audio asset shipped alongside the flow.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "audio" + ], + "properties": { + "audio": { + "description": "Audio asset ref. A generated-clip ref is a voice_prompts id matching ^vprompt_[a-z0-9]+$.", + "type": "string" + } + } + } + ] + }, + "MessageTone": { + "description": "The cue a message node plays between its prompt and the start of recording.", + "type": "string", + "enum": [ + "beep", + "none" + ] + }, + "Exits": { + "description": "Wired exits: exit name → target node id. Which names are valid is a property of the node's kind and is checked by the validator, not by this schema.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "TimeRange": { + "description": "One open window: open–close as \"HH:MM\" (24-hour). Ordering and overnight rules are enforced by the validator.", + "type": "object", + "required": [ + "open", + "close" + ], + "properties": { + "open": { + "type": "string" + }, + "close": { + "type": "string" + } + } + }, + "WeeklySchedule": { + "description": "Open ranges per weekday; a missing or empty day means closed all day.", + "type": "object", + "properties": { + "mon": { + "type": "array", + "items": { + "$ref": "#/$defs/TimeRange" + } + }, + "tue": { + "type": "array", + "items": { + "$ref": "#/$defs/TimeRange" + } + }, + "wed": { + "type": "array", + "items": { + "$ref": "#/$defs/TimeRange" + } + }, + "thu": { + "type": "array", + "items": { + "$ref": "#/$defs/TimeRange" + } + }, + "fri": { + "type": "array", + "items": { + "$ref": "#/$defs/TimeRange" + } + }, + "sat": { + "type": "array", + "items": { + "$ref": "#/$defs/TimeRange" + } + }, + "sun": { + "type": "array", + "items": { + "$ref": "#/$defs/TimeRange" + } + } + } + }, + "HoursException": { + "description": "A single-date override of the weekly schedule (holiday / special hours).", + "type": "object", + "required": [ + "date" + ], + "properties": { + "date": { + "description": "\"YYYY-MM-DD\" in the flow's timezone.", + "type": "string" + }, + "closed": { + "description": "Closed all day regardless of ranges.", + "type": "boolean", + "default": false + }, + "ranges": { + "type": "array", + "items": { + "$ref": "#/$defs/TimeRange" + } + } + } + }, + "Node": { + "description": "One node: a component (kind + its config) plus its wired exits. An internally-tagged union on `kind`.", + "oneOf": [ + { + "$ref": "#/$defs/GreetingNode" + }, + { + "$ref": "#/$defs/HoursNode" + }, + { + "$ref": "#/$defs/MenuNode" + }, + { + "$ref": "#/$defs/RingNode" + }, + { + "$ref": "#/$defs/MessageNode" + }, + { + "$ref": "#/$defs/TransferNode" + }, + { + "$ref": "#/$defs/HangupNode" + } + ] + }, + "GreetingNode": { + "description": "Speak a prompt, then continue. Exit: next.", + "type": "object", + "required": [ + "kind", + "prompt" + ], + "properties": { + "kind": { + "const": "greeting" + }, + "prompt": { + "$ref": "#/$defs/Prompt" + }, + "exits": { + "$ref": "#/$defs/Exits" + } + } + }, + "HoursNode": { + "description": "Branch on the business's weekly schedule + holiday overrides. Exits: open, closed.", + "type": "object", + "required": [ + "kind", + "schedule", + "timezone" + ], + "properties": { + "kind": { + "const": "hours" + }, + "schedule": { + "$ref": "#/$defs/WeeklySchedule" + }, + "timezone": { + "description": "IANA zone (America/New_York). Validated to resolve at load.", + "type": "string" + }, + "exceptions": { + "type": "array", + "items": { + "$ref": "#/$defs/HoursException" + } + }, + "exits": { + "$ref": "#/$defs/Exits" + } + } + }, + "MenuNode": { + "description": "Speak a prompt and collect a DTMF choice. Exits: one per digit in options, plus no_input and invalid.", + "type": "object", + "required": [ + "kind", + "prompt", + "options" + ], + "properties": { + "kind": { + "const": "menu" + }, + "prompt": { + "$ref": "#/$defs/Prompt" + }, + "options": { + "description": "Digit key → human label. The exit for a digit is exits[digit]. Valid digit keys are enforced by the validator.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "retries": { + "type": "integer", + "minimum": 0, + "default": 1 + }, + "timeout_secs": { + "type": "integer", + "minimum": 0, + "default": 5 + }, + "exits": { + "$ref": "#/$defs/Exits" + } + } + }, + "RingNode": { + "description": "Ring the human for a window. answered is an implicit terminal; the only wired exit is no_answer.", + "type": "object", + "required": [ + "kind", + "timeout_secs" + ], + "properties": { + "kind": { + "const": "ring" + }, + "timeout_secs": { + "type": "integer", + "minimum": 0 + }, + "exits": { + "$ref": "#/$defs/Exits" + } + } + }, + "MessageNode": { + "description": "Voicemail: speak a prompt, record, transcribe, notify. Terminal.", + "type": "object", + "required": [ + "kind", + "prompt" + ], + "properties": { + "kind": { + "const": "message" + }, + "prompt": { + "$ref": "#/$defs/Prompt" + }, + "max_secs": { + "type": "integer", + "minimum": 0, + "default": 120 + }, + "tone": { + "allOf": [ + { + "$ref": "#/$defs/MessageTone" + } + ], + "default": "beep" + }, + "exits": { + "$ref": "#/$defs/Exits" + } + } + }, + "TransferNode": { + "description": "Blind-transfer to an external number. Terminal.", + "type": "object", + "required": [ + "kind", + "target" + ], + "properties": { + "kind": { + "const": "transfer" + }, + "target": { + "type": "string" + }, + "exits": { + "$ref": "#/$defs/Exits" + } + } + }, + "HangupNode": { + "description": "Speak an optional goodbye and end the call. Terminal.", + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "hangup" + }, + "prompt": { + "$ref": "#/$defs/Prompt" + }, + "exits": { + "$ref": "#/$defs/Exits" + } + } + } + } +} as const; + +export default schema; diff --git a/packages/flow-schema/src/index.ts b/packages/flow-schema/src/index.ts index 7db61e3..137e41f 100644 --- a/packages/flow-schema/src/index.ts +++ b/packages/flow-schema/src/index.ts @@ -14,7 +14,10 @@ import { Ajv2020 } from 'ajv/dist/2020.js'; import type { ErrorObject } from 'ajv'; -import schema from './generated/flow.v1.schema.json' with { type: 'json' }; +// The schema as a generated TS module, not a JSON import: import +// attributes (`with { type: 'json' }`) don't survive every consumer +// bundler. The raw JSON is still shipped at the `./schema/v1` export. +import schema from './generated/schema.js'; // Model: generated types + hand-written constants & helpers. export * from './model.js';