first commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# constants
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The constants module owns the single source of truth for the current schema
|
||||
version of `@suaveplan/control-surface-schema`. Every other module — the
|
||||
Zod schemas, the validator, the upgrade engine, the migration registry —
|
||||
imports `CURRENT_SCHEMA_VERSION` from this file so that bumping a release
|
||||
touches exactly one literal. The version drives both the strict-root Zod
|
||||
literal check and the loop-termination condition inside `upgradeSurface`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single exported value `CURRENT_SCHEMA_VERSION` of literal type `"0.1.0"`.
|
||||
- Companion `CurrentSchemaVersion` type alias for use in API surfaces that
|
||||
pin a parameter to the current release.
|
||||
- Zero runtime dependencies — pure const.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CURRENT_SCHEMA_VERSION } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const draft = {
|
||||
id: "abc",
|
||||
name: "draft",
|
||||
version: CURRENT_SCHEMA_VERSION,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
skin: { id: "default" },
|
||||
root: {
|
||||
type: "group",
|
||||
id: "root",
|
||||
layout: { kind: "absolute" },
|
||||
transform: { position: { x: 0, y: 0 } },
|
||||
children: [],
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the literal type as a generic parameter for a build-time check:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
type CurrentSchemaVersion,
|
||||
upgradeSurface,
|
||||
} from "@suaveplan/control-surface-schema";
|
||||
|
||||
declare function persistAtCurrent<V extends CurrentSchemaVersion>(
|
||||
version: V,
|
||||
): void;
|
||||
|
||||
const upgraded = upgradeSurface(rawDocFromDisk);
|
||||
persistAtCurrent(upgraded.version as CurrentSchemaVersion);
|
||||
// The signature pins the call site to whatever version this build emits;
|
||||
// a stale literal becomes a typecheck error at the call site.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CURRENT_SCHEMA_VERSION` — readonly string literal `"0.1.0"`.
|
||||
- `type CurrentSchemaVersion` — typeof `CURRENT_SCHEMA_VERSION`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The version literal is intentionally narrow (`as const`) so consumer code
|
||||
can rely on type narrowing in switch statements over versioned documents.
|
||||
A breaking change to the schema MUST bump this constant AND register the
|
||||
forward migration before the release ships; the pre-publish gate fails if
|
||||
`validateSurface` produces a different version than `CURRENT_SCHEMA_VERSION`
|
||||
on the same document `upgradeSurface` returned, which catches missed bumps.
|
||||
Consumers that hard-code the literal in their own typed shapes should
|
||||
import the type alias rather than re-derive it, so a future schema
|
||||
version bump only requires updating the version string in this file plus
|
||||
the registered migration in `src/upgrade/upgrade.ts`. The constant lives
|
||||
outside the schema module so circular imports between Zod schemas and the
|
||||
upgrade pipeline never form.
|
||||
@@ -0,0 +1,93 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable error-code registry for
|
||||
`@suaveplan/control-surface-schema`. Every entrypoint of the package
|
||||
(`validateSurface`, `upgradeSurface`, `assertUniqueIds`,
|
||||
`assertBindingReferences`, `MigrationRegistry.register`) throws or returns a
|
||||
`SchemaError` whose `code` is drawn from this registry, so downstream code
|
||||
can branch on stable string literals without parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- Five namespaced codes under the `CONTROL_SURFACE_SCHEMA.*` prefix:
|
||||
`INVALID_DOCUMENT`, `UNSUPPORTED_VERSION`, `UPGRADE_FAILED`,
|
||||
`DUPLICATE_ID`, `BINDING_REFERENCE_MISSING`.
|
||||
- Frozen `CONTROL_SURFACE_SCHEMA_ERROR_METADATA` table giving each code a
|
||||
human-readable `description` and a `severity` (`error` for everything in
|
||||
this package — nothing recoverable).
|
||||
- Concrete `SchemaError` subclass widening the `SuaveplanError` constructor
|
||||
to public so callers can rethrow without subclassing again.
|
||||
- Typed `ControlSurfaceSchemaErrorCode` union for exhaustive `switch`
|
||||
narrowing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CONTROL_SURFACE_SCHEMA_CODES,
|
||||
SchemaError,
|
||||
validateSurface,
|
||||
} from "@suaveplan/control-surface-schema";
|
||||
|
||||
const result = validateSurface(unknown);
|
||||
if (result.error !== undefined) {
|
||||
if (result.error.code === CONTROL_SURFACE_SCHEMA_CODES.DUPLICATE_ID) {
|
||||
console.error("Duplicate ids:", result.error.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Exhaustive narrowing across every code in the registry:
|
||||
|
||||
```ts
|
||||
import {
|
||||
type ControlSurfaceSchemaErrorCode,
|
||||
SchemaError,
|
||||
} from "@suaveplan/control-surface-schema";
|
||||
|
||||
function report(err: SchemaError): string {
|
||||
const code = err.code satisfies ControlSurfaceSchemaErrorCode;
|
||||
switch (code) {
|
||||
case "CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT":
|
||||
return "Document failed Zod parse.";
|
||||
case "CONTROL_SURFACE_SCHEMA.UNSUPPORTED_VERSION":
|
||||
return "No migration registered for this version.";
|
||||
case "CONTROL_SURFACE_SCHEMA.UPGRADE_FAILED":
|
||||
return "A migration threw or produced an invalid output.";
|
||||
case "CONTROL_SURFACE_SCHEMA.DUPLICATE_ID":
|
||||
return `Duplicate ids: ${JSON.stringify(err.context)}`;
|
||||
case "CONTROL_SURFACE_SCHEMA.BINDING_REFERENCE_MISSING":
|
||||
return `Missing plugin: ${JSON.stringify(err.context)}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CONTROL_SURFACE_SCHEMA_CODES` — readonly registry built via `defineCodes`.
|
||||
Keys are the bare code names (`INVALID_DOCUMENT`, etc); values are the
|
||||
fully-namespaced strings (`"CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT"`).
|
||||
- `type ControlSurfaceSchemaErrorCode` — union of every value in the registry.
|
||||
- `interface ControlSurfaceSchemaErrorMetadata` — `{ description; severity }`.
|
||||
- `CONTROL_SURFACE_SCHEMA_ERROR_METADATA` — frozen `Record<Code, Metadata>`.
|
||||
- `class SchemaError extends SuaveplanError<ControlSurfaceSchemaErrorCode>` —
|
||||
public constructor `(code, messageKey, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The metadata table is `Object.freeze`d on module load so consumers cannot
|
||||
mutate per-code description text at runtime. The base `SuaveplanError`
|
||||
constructor is `protected` to enforce a subclass-per-package discipline;
|
||||
this module's `SchemaError` widens it to `public` so test fixtures and
|
||||
integration adapters can synthesise schema errors without further
|
||||
subclassing. The codes are stable across schema versions — `upgradeSurface`
|
||||
re-uses `UPGRADE_FAILED` for every failure mode in the migration pipeline
|
||||
to keep the consumer narrowing surface small.
|
||||
@@ -0,0 +1,84 @@
|
||||
# identity
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The identity module owns the structural cross-checks that run after a
|
||||
candidate surface passes its Zod parse: duplicate-id detection and
|
||||
binding-reference cross-check against an optional plugin catalog.
|
||||
`validateSurface` invokes these helpers in order and surfaces their
|
||||
typed errors as part of its result union.
|
||||
|
||||
## Features
|
||||
|
||||
- `findDuplicateIds` — walks the tree, returns the ids that appear more
|
||||
than once. No throws; pure inspection.
|
||||
- `assertUniqueIds` — wraps `findDuplicateIds` with a typed throw of
|
||||
`SchemaError("DUPLICATE_ID")` carrying `context.ids`.
|
||||
- `collectPluginReferences` — walks the tree, returns the set of
|
||||
`pluginId` strings referenced by every `pluginParam` binding,
|
||||
including those nested inside a `compound`.
|
||||
- `assertBindingReferences` — cross-checks every plugin reference against
|
||||
a supplied `ReadonlySet<string>` and throws `BINDING_REFERENCE_MISSING`
|
||||
with `context.missing` on the first offender.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Detect duplicate ids without throwing:
|
||||
|
||||
```ts
|
||||
import { findDuplicateIds } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const offenders = findDuplicateIds(surface);
|
||||
if (offenders.length > 0) console.warn("Duplicate node ids:", offenders);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Cross-check plugin references against a runtime-registered catalog:
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertBindingReferences,
|
||||
collectPluginReferences,
|
||||
SchemaError,
|
||||
} from "@suaveplan/control-surface-schema";
|
||||
|
||||
const catalog = new Set(pluginRegistry.list().map((p) => p.id));
|
||||
const referenced = collectPluginReferences(surface);
|
||||
console.log("Referenced plugins:", [...referenced]);
|
||||
|
||||
try {
|
||||
assertBindingReferences(surface, catalog);
|
||||
} catch (err) {
|
||||
if (err instanceof SchemaError) {
|
||||
renderMissingPluginPlaceholder(err.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `findDuplicateIds(surface): ReadonlyArray<string>`.
|
||||
- `checkUniqueIds(surface): SchemaError | undefined` — non-throwing variant.
|
||||
- `assertUniqueIds(surface): void` — throws `SchemaError("DUPLICATE_ID")`.
|
||||
- `collectPluginReferences(surface): ReadonlySet<string>`.
|
||||
- `checkBindingReferences(surface, catalog): SchemaError | undefined` —
|
||||
non-throwing variant.
|
||||
- `assertBindingReferences(surface, catalog: ReadonlySet<string>): void` —
|
||||
throws `SchemaError("BINDING_REFERENCE_MISSING")`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both `assertUniqueIds` and `assertBindingReferences` are designed to
|
||||
throw rather than return a result union because they are typically
|
||||
composed inside `validateSurface`, which already owns the
|
||||
`ValidateResult` shape. The throw-then-catch keeps the helper signatures
|
||||
small and lets external callers reuse them directly when they prefer
|
||||
exception-style control flow. The plugin reference walker descends into
|
||||
`compound` bindings but stops there because compound bindings are flat
|
||||
by spec — a nested compound is rejected at Zod parse time before this
|
||||
walker can see it.
|
||||
@@ -0,0 +1,84 @@
|
||||
# control-surface-schema (Zod)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the Zod schemas that mirror every TypeScript type in the
|
||||
package: `TransformSchema`, `BindingSchema`, `RangeSchema`,
|
||||
`AppearanceSchema`, `BehaviourSchema`, `GroupLayoutSchema`,
|
||||
`ControlNodeSchema`, `ControlSurfaceSchema`, and the loose
|
||||
`ControlSurfaceVersionedSchema` used inside the upgrade pipeline. The
|
||||
schemas are the runtime source of truth for `validateSurface` and
|
||||
`upgradeSurface`.
|
||||
|
||||
## Features
|
||||
|
||||
- One schema per exported type — symmetrical with the type module.
|
||||
- Discriminated unions on `kind` (`Binding`, `GroupLayout`, `Action`)
|
||||
and on `type` (`ControlNode`), so parse errors point at the precise
|
||||
variant that failed.
|
||||
- Recursive `ControlNodeSchema` via `z.lazy` so groups can nest to any
|
||||
depth without exploding the schema graph.
|
||||
- MIDI / DMX numeric ranges encoded with `.int().min().max()` matching
|
||||
the hardware envelope; the validator rejects out-of-range channel and
|
||||
CC values at parse time.
|
||||
- Compound bindings use `LeafBindingSchema` (no nested compound) to
|
||||
enforce the flat-only invariant declaratively.
|
||||
- `ControlSurfaceSchema` pins `version` to `CURRENT_SCHEMA_VERSION` so a
|
||||
document at a different version fails parse; `ControlSurfaceVersionedSchema`
|
||||
loosens this to any non-empty string for use inside migrations.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Parse an unknown document via `safeParse`:
|
||||
|
||||
```ts
|
||||
import { ControlSurfaceSchema } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const result = ControlSurfaceSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
console.error(issue.path.join("."), issue.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Compose a sub-schema for an editor that only wants to validate the root
|
||||
group, not the full surface envelope:
|
||||
|
||||
```ts
|
||||
import { ControlNodeSchema, GroupLayoutSchema } from "@suaveplan/control-surface-schema";
|
||||
import z from "zod";
|
||||
|
||||
const RootGroupOnlySchema = z.object({
|
||||
layout: GroupLayoutSchema,
|
||||
children: z.array(ControlNodeSchema),
|
||||
});
|
||||
|
||||
const parsed = RootGroupOnlySchema.parse(payload);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TransformSchema`, `RangeSchema`, `AppearanceSchema`, `BehaviourSchema`,
|
||||
`BindingSchema`, `GroupLayoutSchema`, `ControlKindSchema`,
|
||||
`ControlNodeSchema`, `ControlSurfaceSchema`,
|
||||
`ControlSurfaceVersionedSchema` — Zod schemas mirroring the
|
||||
corresponding types in `src/types/`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Validators downstream call `safeParse`, never `parse`, so a malformed
|
||||
input becomes a typed `SchemaError("INVALID_DOCUMENT")` rather than a
|
||||
raw thrown Zod error. The recursive `ControlNodeSchema` is typed as
|
||||
`z.ZodType<unknown>` so the `exactOptionalPropertyTypes` mismatch
|
||||
between Zod's `.optional()` (which yields `T | undefined`) and the
|
||||
schema's `T?` fields doesn't force the lazy boundary to a less precise
|
||||
type; the runtime check still rejects malformed nodes, and the consumer
|
||||
casts `parsed.data` to `ControlSurface` after the structural checks
|
||||
(z-axis, duplicate ids, binding refs) confirm the document is valid.
|
||||
@@ -0,0 +1,60 @@
|
||||
# action types
|
||||
|
||||
## Purpose
|
||||
|
||||
The action type module declares the closed discriminated union of side
|
||||
effects a behaviour hook can trigger. Actions are referenced from
|
||||
`Behaviour.doubleTapAction` / `Behaviour.longPressAction` and are
|
||||
interpreted by renderers — the schema itself never executes them.
|
||||
|
||||
## Features
|
||||
|
||||
- Four closed variants: `resetToDefault`, `setTo`, `toggleGroupCollapse`,
|
||||
`emitEvent`.
|
||||
- `payload` on `emitEvent` is typed as `unknown` so renderers can pass
|
||||
arbitrary JSON; downstream listeners narrow the payload themselves.
|
||||
- All variants are `readonly` value objects — actions never mutate the
|
||||
surface document.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
A reset-to-default on double tap:
|
||||
|
||||
```ts
|
||||
import type { Action } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const reset: Action = { kind: "resetToDefault" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Emit a custom event with a structured payload on long-press:
|
||||
|
||||
```ts
|
||||
import type { Action } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const punchIn: Action = {
|
||||
kind: "emitEvent",
|
||||
eventId: "transport.punchIn",
|
||||
payload: { armedTracks: ["kick", "snare"], at: Date.now() },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ResetToDefaultAction` — `{ kind: "resetToDefault" }`.
|
||||
- `interface SetToAction` — `{ kind: "setTo"; value: number }`.
|
||||
- `interface ToggleGroupCollapseAction` — `{ kind; targetId: string }`.
|
||||
- `interface EmitEventAction` — `{ kind; eventId: string; payload?: unknown }`.
|
||||
- `type Action` — union of the four variants above.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `targetId` on `toggleGroupCollapse` is a string that must match a
|
||||
`Group.id` somewhere in the surface tree. The schema does not validate
|
||||
the reference at parse time — that check is the renderer's responsibility
|
||||
because target groups can be created lazily by other actions. Tests that
|
||||
exercise the toggle behaviour stage a known-good target before invoking
|
||||
the action so the renderer's lookup is deterministic. `emitEvent.payload`
|
||||
is `unknown` rather than a structured type to avoid leaking renderer
|
||||
event vocabularies into the schema package.
|
||||
@@ -0,0 +1,71 @@
|
||||
# appearance types
|
||||
|
||||
## Purpose
|
||||
|
||||
The appearance type module declares the cosmetic and skin-binding block
|
||||
attached to a `Control` or `Group`. The block is the bridge between the
|
||||
canonical surface document and the renderer's skin bundle: it carries the
|
||||
asset key, the render hint (2D vs 3D), CSS variable overrides, the tooltip
|
||||
string, and an optional inline icon.
|
||||
|
||||
## Features
|
||||
|
||||
- `skinKey` — string addressing a sub-asset in the surface's skin bundle.
|
||||
- `renderHint` — `"auto" | "2d" | "3d"` for renderer dispatch.
|
||||
- `cssVars` — string-to-string map applied as scoped custom properties.
|
||||
- `tooltip` — free-form text shown on hover / focus.
|
||||
- `icon` — discriminated `{ kind: "url" | "svg" | "skinAsset"; value }`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
A knob with a tooltip and a skin key:
|
||||
|
||||
```ts
|
||||
import type { Appearance } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const appearance: Appearance = {
|
||||
skinKey: "knob.brass-large",
|
||||
tooltip: "Channel gain",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A 3D-only control with CSS variable overrides and an inline SVG icon:
|
||||
|
||||
```ts
|
||||
import type { Appearance } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const appearance: Appearance = {
|
||||
skinKey: "fader.linear",
|
||||
renderHint: "3d",
|
||||
cssVars: {
|
||||
"--fader-cap": "#ff5500",
|
||||
"--fader-track": "rgba(255,255,255,0.2)",
|
||||
},
|
||||
tooltip: "Master output",
|
||||
icon: {
|
||||
kind: "svg",
|
||||
value: "<svg viewBox=\"0 0 16 16\"><rect width=\"16\" height=\"16\"/></svg>",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type RenderHint` — `"auto" | "2d" | "3d"`.
|
||||
- `type IconSource` — `"url" | "svg" | "skinAsset"`.
|
||||
- `interface Icon` — `{ kind: IconSource; value: string }`.
|
||||
- `interface Appearance` — fields `skinKey`, `renderHint`, `cssVars`,
|
||||
`tooltip`, `icon`, all optional.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Appearance is the only block whose contents may differ between renderers
|
||||
on the same surface document — a 2D React renderer ignores the `3d`
|
||||
render hint, and a 3D renderer falls back to the skin's "auto" variant
|
||||
when the hint is missing. The `cssVars` map is intentionally `string ->
|
||||
string` to mirror DOM custom properties; rich units (lengths, angles)
|
||||
serialise to strings without loss. Inline SVG is supported because some
|
||||
device skins want one-off pictograms that don't justify a separate skin
|
||||
asset.
|
||||
@@ -0,0 +1,66 @@
|
||||
# behaviour types
|
||||
|
||||
## Purpose
|
||||
|
||||
The behaviour type module declares the optional interaction modifier block
|
||||
attached to a `Control`. The block tells renderers how to interpret the
|
||||
control kind beyond its default behaviour — whether a button is momentary
|
||||
or latching, what gesture sticky-latches it, what action fires on
|
||||
double-tap or long-press, and whether the range step is overridden.
|
||||
|
||||
## Features
|
||||
|
||||
- `momentaryDelayMs` — minimum hold (ms) before release is reported.
|
||||
- `latchSticky` — when true, latching controls stay on after gesture release.
|
||||
- `doubleTapAction` — `Action` fired when the renderer detects a double tap.
|
||||
- `longPressAction` — `Action` fired when the renderer detects a long press.
|
||||
- `longPressDelayMs` — minimum hold (ms) before long-press fires.
|
||||
- `stepOverride` — hard quantisation that takes precedence over `Range.step`.
|
||||
- Every field is optional; an absent block means "default per ControlKind".
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
A simple momentary button that fires `resetToDefault` on double tap:
|
||||
|
||||
```ts
|
||||
import type { Behaviour } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const behaviour: Behaviour = {
|
||||
momentaryDelayMs: 50,
|
||||
doubleTapAction: { kind: "resetToDefault" },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A sticky latch with a long-press emit:
|
||||
|
||||
```ts
|
||||
import type { Behaviour } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const armBehaviour: Behaviour = {
|
||||
latchSticky: true,
|
||||
longPressDelayMs: 400,
|
||||
longPressAction: {
|
||||
kind: "emitEvent",
|
||||
eventId: "track.solo.exclusive",
|
||||
},
|
||||
stepOverride: 0.001,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface Behaviour` — fields `momentaryDelayMs`, `latchSticky`,
|
||||
`doubleTapAction`, `longPressAction`, `longPressDelayMs`, `stepOverride`,
|
||||
all optional.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The behaviour block is intentionally renderer-agnostic. The schema does
|
||||
not specify how the renderer detects double taps or long presses — that
|
||||
is OS- and device-specific. The shape only commits to the *intent*: if
|
||||
the renderer detects the gesture, it fires the registered action. Step
|
||||
override lives here, not on `Range`, because the same control might want
|
||||
to expose different snap behaviour depending on gesture (coarse during
|
||||
drag, fine during long-press) — the renderer composes both fields.
|
||||
@@ -0,0 +1,74 @@
|
||||
# binding types
|
||||
|
||||
## Purpose
|
||||
|
||||
The binding type module declares the six closed variants of `Binding` —
|
||||
the data structure that wires an atomic control to whatever it drives in
|
||||
the runtime. Bindings are the bridge between the surface document and the
|
||||
audio / MIDI / DMX / transport buses that downstream renderers and engines
|
||||
own; everything else in the schema is layout and cosmetics.
|
||||
|
||||
## Features
|
||||
|
||||
- Five leaf variants: `pluginParam`, `transport`, `midiOut`, `midiIn`, `dmx`.
|
||||
- One composite variant: `compound`, which holds an array of leaf bindings
|
||||
so a single physical control can drive several destinations at once.
|
||||
- Compound bindings are flat-only — the validator rejects compounds that
|
||||
contain compounds, keeping the binding graph one level deep.
|
||||
- `ParamCurve` discriminated subtype on `pluginParam`: `"linear"`, `"log"`,
|
||||
`"exp"`, or a `{ kind: "custom"; points }` table.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
A simple plugin-parameter binding:
|
||||
|
||||
```ts
|
||||
import type { Binding } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const volume: Binding = {
|
||||
kind: "pluginParam",
|
||||
pluginId: "mixer",
|
||||
paramPath: "channels/0/volume",
|
||||
curve: "log",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A compound binding driving plugin + MIDI + DMX simultaneously:
|
||||
|
||||
```ts
|
||||
import type { Binding } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const masterKnob: Binding = {
|
||||
kind: "compound",
|
||||
bindings: [
|
||||
{ kind: "pluginParam", pluginId: "master", paramPath: "level" },
|
||||
{ kind: "midiOut", portId: "main", channel: 0, cc: 7 },
|
||||
{ kind: "dmx", universe: 0, channel: 1, range: [0, 255] },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface PluginParameterBinding` — `{ kind: "pluginParam"; pluginId; paramPath; curve? }`.
|
||||
- `type ParamCurve` — `"linear" | "log" | "exp" | { kind: "custom"; points }`.
|
||||
- `interface CurvePoint` — `{ at: number; value: number }`.
|
||||
- `interface TransportBinding` — `{ kind: "transport"; field: TransportField }`.
|
||||
- `type TransportField` — `"play" | "stop" | "tempo" | "position" | "loop"`.
|
||||
- `interface MidiOutBinding` / `MidiInBinding` — `{ kind; portId; channel; cc; range? }`.
|
||||
- `interface DmxBinding` — `{ kind: "dmx"; universe; channel; range? }`.
|
||||
- `interface CompoundBinding` — `{ kind: "compound"; bindings: LeafBinding[] }`.
|
||||
- `type LeafBinding` — union of the five non-compound variants.
|
||||
- `type Binding` — `LeafBinding | CompoundBinding`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The compound variant deliberately excludes nested compounds: nesting would
|
||||
make audience resolution recursive without adding expressive power because
|
||||
arrays already flatten the destinations. Custom curves require at least
|
||||
two points so the validator can interpolate; the Zod schema enforces
|
||||
`.min(2)` at parse time. MIDI channel and CC values are bounded to the
|
||||
hardware range (`0–15` and `0–127` respectively); DMX channels are
|
||||
bounded to `1–512` per universe.
|
||||
@@ -0,0 +1,61 @@
|
||||
# control-kind types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the closed, finite enumeration of the 18 atomic control
|
||||
primitives every renderer must support. The set is intentionally final at
|
||||
v0.1.0; expanding it requires a schema-version bump and a registered
|
||||
migration so consumer renderers can refuse documents they cannot draw.
|
||||
|
||||
## Features
|
||||
|
||||
- `CONTROL_KIND_VALUES` — readonly tuple of the 18 string literals, in
|
||||
spec order: `knob`, `slider-v`, `slider-h`, `button-momentary`,
|
||||
`button-latching`, `switch-2`, `switch-3`, `led`, `digital-display`,
|
||||
`meter-vu`, `meter-peak`, `meter-spectral`, `xy-pad`, `jog-wheel`,
|
||||
`piano-key`, `drum-pad`, `text-label`, `image`.
|
||||
- `type ControlKind` — derived union of those 18 literals.
|
||||
- The Zod schema (`ControlKindSchema`) consumes the same tuple so a
|
||||
rename or addition requires touching exactly one source.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Use the union in a typed renderer dispatch:
|
||||
|
||||
```ts
|
||||
import type { ControlKind } from "@suaveplan/control-surface-schema";
|
||||
|
||||
function renderHint(kind: ControlKind): "knob" | "slider" | "other" {
|
||||
if (kind === "knob") return "knob";
|
||||
if (kind === "slider-v" || kind === "slider-h") return "slider";
|
||||
return "other";
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Iterate every kind to register a renderer factory per kind:
|
||||
|
||||
```ts
|
||||
import { CONTROL_KIND_VALUES } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const registry = new Map<string, () => HTMLElement>();
|
||||
for (const kind of CONTROL_KIND_VALUES) {
|
||||
registry.set(kind, () => document.createElement(kind === "led" ? "span" : "div"));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CONTROL_KIND_VALUES` — readonly tuple of the 18 literal strings.
|
||||
- `type ControlKind` — `(typeof CONTROL_KIND_VALUES)[number]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`CONTROL_KIND_VALUES` is exported as a runtime constant (not just a type)
|
||||
so renderers can iterate, generate dispatch tables, build documentation,
|
||||
and seed property-test arbitraries from a single source of truth. The
|
||||
validator rejects unknown kinds at parse time; renderers that cannot
|
||||
draw a registered kind should fall back to a generic placeholder rather
|
||||
than throw, but the validator never permits a non-registered literal to
|
||||
reach renderer code.
|
||||
@@ -0,0 +1,77 @@
|
||||
# control-node types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the recursive `ControlNode = Group | Control`
|
||||
discriminated union — the tree shape that makes up the body of every
|
||||
`ControlSurface` document. Every renderer walks this shape; every editor
|
||||
mutates this shape; every persistence layer serialises this shape.
|
||||
|
||||
## Features
|
||||
|
||||
- `Group` — internal node carrying a layout strategy, an optional label,
|
||||
a transform, and an ordered list of children (other groups or controls).
|
||||
- `Control` — leaf node carrying one of the 18 atomic `ControlKind`
|
||||
primitives plus its optional binding, numeric range, behaviour
|
||||
modifiers, and appearance data.
|
||||
- `ControlNode` — discriminated union by the `type` field (`"group"` /
|
||||
`"control"`); the validator uses this discriminant for fast parsing.
|
||||
- `Group.children` is `ReadonlyArray<ControlNode>` so trees are
|
||||
structurally immutable.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
A single-knob surface root:
|
||||
|
||||
```ts
|
||||
import type { ControlNode } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const knob: ControlNode = {
|
||||
type: "control",
|
||||
id: "vol",
|
||||
kind: "knob",
|
||||
transform: { position: { x: 0, y: 0 } },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A nested group tree with a collapsible drum-pad subgroup:
|
||||
|
||||
```ts
|
||||
import type { Group } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const drumGroup: Group = {
|
||||
type: "group",
|
||||
id: "drums",
|
||||
label: "Drum pads",
|
||||
collapsible: true,
|
||||
layout: { kind: "grid", columns: 4, rows: 4, gap: 8 },
|
||||
transform: { position: { x: 0, y: 0 } },
|
||||
children: Array.from({ length: 16 }, (_, i) => ({
|
||||
type: "control",
|
||||
id: `pad-${i}`,
|
||||
kind: "drum-pad",
|
||||
transform: { position: { x: (i % 4) * 64, y: Math.floor(i / 4) * 64 } },
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface Group` — `{ type; id; label?; layout; transform; children;
|
||||
collapsible? }`.
|
||||
- `interface Control` — `{ type; id; kind; label?; transform; appearance?;
|
||||
binding?; range?; behaviour? }`.
|
||||
- `type ControlNode` — `Group | Control`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both node types share the discriminant field `type`, the `id` (which
|
||||
must be unique across the entire surface tree), and the `transform`
|
||||
block. Splitting layout (`Group.layout`) from kind (`Control.kind`) keeps
|
||||
the validator's z-axis discipline check on the parent side rather than
|
||||
forcing leaf controls to know which layout encloses them. The `children`
|
||||
array is `ReadonlyArray` to discourage in-place tree mutation; renderers
|
||||
that need to edit produce a new array rather than mutating the surface
|
||||
document.
|
||||
@@ -0,0 +1,79 @@
|
||||
# control-surface types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the canonical `ControlSurface` root document and its
|
||||
`SkinReference` companion. These are the types persisted to storage,
|
||||
exchanged over the wire, and validated at every save and load boundary.
|
||||
Every other type in the package is reachable from `ControlSurface.root`.
|
||||
|
||||
## Features
|
||||
|
||||
- `id` — uuid identifying this specific surface document.
|
||||
- `name` — free-form human-readable label.
|
||||
- `version` — semver matching the schema this document targets; pinned
|
||||
to `CURRENT_SCHEMA_VERSION` after `upgradeSurface`.
|
||||
- `createdAt` / `updatedAt` — millisecond Unix timestamps.
|
||||
- `skin` — `SkinReference` pointing at a separately-published skin
|
||||
package plus optional CSS variable overrides.
|
||||
- `root` — the top-level `ControlNode` (typically a `Group`).
|
||||
- `SkinReference` — `{ id; vars? }`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
A minimal valid surface with an empty root group:
|
||||
|
||||
```ts
|
||||
import type { ControlSurface } from "@suaveplan/control-surface-schema";
|
||||
import { CURRENT_SCHEMA_VERSION } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const surface: ControlSurface = {
|
||||
id: "01997cda-7340-7000-8000-000000000001",
|
||||
name: "Empty surface",
|
||||
version: CURRENT_SCHEMA_VERSION,
|
||||
createdAt: 1700_000_000_000,
|
||||
updatedAt: 1700_000_000_000,
|
||||
skin: { id: "default" },
|
||||
root: {
|
||||
type: "group",
|
||||
id: "root",
|
||||
layout: { kind: "absolute" },
|
||||
transform: { position: { x: 0, y: 0 } },
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A skin reference with CSS variable overrides applied surface-wide:
|
||||
|
||||
```ts
|
||||
import type { SkinReference } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const skin: SkinReference = {
|
||||
id: "control-surface-skin-native-instruments",
|
||||
vars: {
|
||||
"--accent": "#ff7700",
|
||||
"--knob-radius": "32px",
|
||||
"--label-font": "Inter, sans-serif",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SkinReference` — `{ id: string; vars?: Record<string, string> }`.
|
||||
- `interface ControlSurface` — `{ id; name; version; createdAt; updatedAt;
|
||||
skin; root }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every field on `ControlSurface` is `readonly` because surface documents
|
||||
are passed by reference and consumed by parallel renderers; mutation
|
||||
would race. `version` is validated by the strict-root Zod schema as the
|
||||
literal `CURRENT_SCHEMA_VERSION`; the loose `ControlSurfaceVersionedSchema`
|
||||
accepts any non-empty string and is used only inside `upgradeSurface`.
|
||||
`createdAt` and `updatedAt` are millisecond timestamps to match the
|
||||
broader Suaveplan convention; the schema does not require `updatedAt`
|
||||
to be >= `createdAt` because some migrations reset both.
|
||||
@@ -0,0 +1,57 @@
|
||||
# group-layout types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the closed discriminated union of layout strategies
|
||||
a `Group` node can adopt. The layout drives both visual placement and the
|
||||
z-axis discipline check inside the validator: `free3d` is the only layout
|
||||
that admits nonzero `position.z` on its descendants; every other layout
|
||||
is strictly two-dimensional.
|
||||
|
||||
## Features
|
||||
|
||||
- Four closed variants: `grid`, `absolute`, `stack`, `free3d`.
|
||||
- `grid` carries `columns`, `rows`, and `gap` (positive integers + gap≥0).
|
||||
- `absolute` is fields-free — the renderer reads `transform.position`
|
||||
directly.
|
||||
- `stack` carries `direction: "row" | "column"` and `gap`.
|
||||
- `free3d` is fields-free — descendants may carry full 3D transforms.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
An absolute layout group for hand-placed controls:
|
||||
|
||||
```ts
|
||||
import type { GroupLayout } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const layout: GroupLayout = { kind: "absolute" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A 4×4 drum-pad grid with eight-pixel gutters and a per-row stack inside:
|
||||
|
||||
```ts
|
||||
import type { GroupLayout } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const pads: GroupLayout = { kind: "grid", columns: 4, rows: 4, gap: 8 };
|
||||
const row: GroupLayout = { kind: "stack", direction: "row", gap: 4 };
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface GridLayout` — `{ kind: "grid"; columns; rows; gap }`.
|
||||
- `interface AbsoluteLayout` — `{ kind: "absolute" }`.
|
||||
- `interface StackLayout` — `{ kind: "stack"; direction; gap }`.
|
||||
- `interface Free3DLayout` — `{ kind: "free3d" }`.
|
||||
- `type GroupLayout` — union of the four above.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The validator iterates the tree, switching the "z allowed" flag on when
|
||||
it descends into a `free3d` group and off when it descends into anything
|
||||
else. This means nesting a `grid` inside a `free3d` re-disables z-axis
|
||||
transforms for that subtree — the rule is per-immediate-parent, not
|
||||
global. The `stack` direction is intentionally a string literal so that
|
||||
serialised documents stay self-describing; binary packers can map the two
|
||||
options to a single bit if size matters.
|
||||
@@ -0,0 +1,56 @@
|
||||
# range types
|
||||
|
||||
## Purpose
|
||||
|
||||
The range type module declares the optional numeric envelope attached to
|
||||
a `Control` node. The fields cover the minimum information a renderer
|
||||
needs to lay out tick marks, snap behaviour, default values, and read-out
|
||||
labels without consulting an external plugin database.
|
||||
|
||||
## Features
|
||||
|
||||
- `min` / `max` — closed numeric interval (inclusive on both ends).
|
||||
- `step` — optional quantisation increment; absent means continuous.
|
||||
- `default` — optional initial value used by the `resetToDefault` action.
|
||||
- `units` — optional free-form string for the read-out label ("dB", "Hz", "%").
|
||||
- All fields are `readonly` — surfaces are immutable documents.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
A linear volume knob `0..1` with a default of 0.75 and a label "lin":
|
||||
|
||||
```ts
|
||||
import type { Range } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const volume: Range = { min: 0, max: 1, default: 0.75, units: "lin" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A logarithmic frequency knob with a 1 Hz step quantum:
|
||||
|
||||
```ts
|
||||
import type { Range } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const cutoff: Range = {
|
||||
min: 20,
|
||||
max: 20_000,
|
||||
step: 1,
|
||||
default: 1_000,
|
||||
units: "Hz",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface Range` — `{ min: number; max: number; step?: number; default?: number; units?: string }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Range` is intentionally narrow — the schema does NOT encode the mapping
|
||||
curve here. Curves live on the binding (`PluginParameterBinding.curve`)
|
||||
because the same control kind can drive multiple bindings via a compound
|
||||
binding, each with its own curve. Renderers that need a single visible
|
||||
curve fall back to the first leaf in a compound. The `default` field is
|
||||
the source of truth for the `resetToDefault` action; controls without a
|
||||
`default` ignore that action rather than throw at runtime.
|
||||
@@ -0,0 +1,59 @@
|
||||
# transform types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the geometric primitives shared by every node Transform
|
||||
in `@suaveplan/control-surface-schema`. The types are intentionally minimal
|
||||
so that a flat 2D renderer can ignore the optional 3D fields while a VR or
|
||||
R3F renderer can populate them without re-validating the surface format.
|
||||
|
||||
## Features
|
||||
|
||||
- `Vec2` — base 2D coordinate (`x`, `y`).
|
||||
- `Vec3` — extends `Vec2` with an optional `z` axis. `z` is only honoured
|
||||
by descendants of a `free3d` layout group; the validator rejects nonzero
|
||||
`z` under any other layout.
|
||||
- `EulerRotation` — optional Euler angles in radians, each axis independent.
|
||||
- `Scale3` — optional anisotropic scale, each axis independent.
|
||||
- `Transform` — `{ position: Vec3; rotation?: EulerRotation; scale?: Scale3 }`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
A flat 2D control transform:
|
||||
|
||||
```ts
|
||||
import type { Transform } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const flat: Transform = { position: { x: 120, y: 64 } };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A full 3D transform under a `free3d` layout group with rotation and scale:
|
||||
|
||||
```ts
|
||||
import type { Transform } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const tilted: Transform = {
|
||||
position: { x: 0, y: 0, z: 0.5 },
|
||||
rotation: { x: 0, y: Math.PI / 4, z: 0 },
|
||||
scale: { x: 1.2, y: 1.2, z: 1 },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface Vec2 { x: number; y: number }`.
|
||||
- `interface Vec3 extends Vec2 { z?: number }`.
|
||||
- `interface EulerRotation { x?: number; y?: number; z?: number }`.
|
||||
- `interface Scale3 { x?: number; y?: number; z?: number }`.
|
||||
- `interface Transform { position: Vec3; rotation?: EulerRotation; scale?: Scale3 }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All fields are `readonly` — surfaces are immutable documents passed by
|
||||
reference. The shape was chosen so a 2D JSON serialisation never carries
|
||||
the optional axes; renderers that only support 2D can run a `position.x` /
|
||||
`position.y` projection without conditional logic. The validator enforces
|
||||
that `position.z` outside `free3d` is either undefined or zero, so the
|
||||
two-dimensional invariant survives round-tripping through any consumer.
|
||||
@@ -0,0 +1,85 @@
|
||||
# upgrade
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns `upgradeSurface` and the `MigrationRegistry` chain that
|
||||
walks an older schema-version document forward to the current version.
|
||||
Without an explicit upgrade path consumers cannot read documents created
|
||||
by previous releases of the package; with it, each release registers a
|
||||
single forward edge and old documents survive arbitrarily many bumps.
|
||||
|
||||
## Features
|
||||
|
||||
- `MigrationRegistry` — ordered registry keyed by `from` version, with
|
||||
`register`, `get`, and `list` accessors.
|
||||
- `Migration` — `{ from; to; apply: (unknown) => unknown }` — pure
|
||||
function transforming a versioned document one step forward.
|
||||
- `defaultRegistry` — package-wide instance consumers extend as new
|
||||
schema versions ship.
|
||||
- `upgradeSurface` — walks the registry chain, validates the output
|
||||
against the strict-root schema, and returns a typed `ControlSurface`.
|
||||
- Five distinct failure modes mapped onto two error codes:
|
||||
`INVALID_DOCUMENT` (envelope not parseable) and `UNSUPPORTED_VERSION`
|
||||
(no migration registered) plus `UPGRADE_FAILED` (every other failure
|
||||
during the chain walk).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Read a v0.0.9 document and walk it forward through the default chain:
|
||||
|
||||
```ts
|
||||
import { upgradeSurface } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const surface = upgradeSurface(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
console.log("Loaded surface", surface.id, "at", surface.version);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Register a custom migration in an isolated registry for tests:
|
||||
|
||||
```ts
|
||||
import {
|
||||
MigrationRegistry,
|
||||
upgradeSurface,
|
||||
} from "@suaveplan/control-surface-schema";
|
||||
|
||||
const registry = new MigrationRegistry();
|
||||
registry.register({
|
||||
from: "0.0.9",
|
||||
to: "0.1.0",
|
||||
apply: (doc) => {
|
||||
const next = { ...(doc as Record<string, unknown>) };
|
||||
next.version = "0.1.0";
|
||||
if (typeof next.skin === "string") next.skin = { id: next.skin };
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
const upgraded = upgradeSurface(legacyDoc, { registry });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface Migration` — `{ from; to; apply }`.
|
||||
- `class MigrationRegistry` — `register(migration)`, `get(from)`, `list()`.
|
||||
- `defaultRegistry: MigrationRegistry` — package-wide chain.
|
||||
- `interface UpgradeOptions` — `{ registry? }`.
|
||||
- `upgradeSurface(input: unknown, options?: UpgradeOptions): ControlSurface`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Migrations are chained by `from` version, not by index, so the registry
|
||||
is a true DAG keyed by source version. Duplicate registration of the
|
||||
same `from` rejects with `UPGRADE_FAILED` because forking forward edges
|
||||
would make the resolution non-deterministic. The walker keeps a hop
|
||||
counter equal to `registry.list().length + 1` so a corrupt registry
|
||||
that loops back to a prior version trips `UPGRADE_FAILED` instead of
|
||||
running forever. After the chain completes the function re-runs
|
||||
`validateSurface` to catch migrations that emit structurally invalid
|
||||
documents — the schema is the source of truth, not the migration's word
|
||||
that it produced a valid output.
|
||||
@@ -0,0 +1,76 @@
|
||||
# validate
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exports `validateSurface` — the canonical entrypoint for
|
||||
parsing and structurally cross-validating a `ControlSurface` document.
|
||||
Every save and load boundary in the wider system calls this function
|
||||
before trusting an unknown payload. The return type is an explicit
|
||||
discriminated union so callers always handle failure without throwing.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod safe-parse against the strict-root `ControlSurfaceSchema`
|
||||
(`version` pinned to `CURRENT_SCHEMA_VERSION`).
|
||||
- Z-axis discipline check: rejects nonzero `transform.position.z` on
|
||||
any descendant of a non-`free3d` layout.
|
||||
- Duplicate-id detection across the entire tree.
|
||||
- Optional binding-reference cross-check when `options.pluginCatalog`
|
||||
is supplied.
|
||||
- Typed `ValidateResult` union — `{ surface }` on success, `{ error }`
|
||||
on any failure, with `SchemaError.code` carrying the precise reason.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Parse a surface from disk and reject on any failure:
|
||||
|
||||
```ts
|
||||
import { validateSurface } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const raw = JSON.parse(await fs.readFile(path, "utf8"));
|
||||
const result = validateSurface(raw);
|
||||
if (result.error !== undefined) {
|
||||
throw result.error;
|
||||
}
|
||||
const surface = result.surface;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Cross-check plugin references against a runtime catalog:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CONTROL_SURFACE_SCHEMA_CODES,
|
||||
validateSurface,
|
||||
} from "@suaveplan/control-surface-schema";
|
||||
|
||||
const catalog = new Set(plugins.map((p) => p.id));
|
||||
const result = validateSurface(raw, { pluginCatalog: catalog });
|
||||
if (result.error?.code === CONTROL_SURFACE_SCHEMA_CODES.BINDING_REFERENCE_MISSING) {
|
||||
showMissingPluginToast(result.error.context);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ValidateOptions` — `{ pluginCatalog?: ReadonlySet<string> }`.
|
||||
- `type ValidateResult` — `{ surface: ControlSurface; error?: undefined }`
|
||||
on success, `{ surface?: undefined; error: SchemaError }` on failure.
|
||||
- `validateSurface(input: unknown, options?: ValidateOptions): ValidateResult`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function never throws — every failure is captured and re-emitted as
|
||||
a `SchemaError` on `result.error`. The Zod parse runs first because it
|
||||
is the cheapest fail-fast; the structural checks (z-axis discipline,
|
||||
duplicate id, binding reference) only see well-formed documents and can
|
||||
therefore use the typed `ControlSurface` view directly. The pluginCatalog
|
||||
check is opt-in because validating the surface against a catalog at load
|
||||
time is a different concern from validating that the surface itself is
|
||||
internally consistent — server-side persistence often skips the catalog
|
||||
check until the document is mounted in a runtime.
|
||||
67
docs/media/audio/control-surface-schema/src/walk/walk.md
Normal file
67
docs/media/audio/control-surface-schema/src/walk/walk.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# walk
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exports `walkSurface` — the canonical depth-first traversal
|
||||
helper for `ControlSurface` documents. Every cross-cutting check in the
|
||||
package (duplicate-id detection, binding-reference collection, z-axis
|
||||
discipline) consumes the same traversal so a future change to traversal
|
||||
order updates one site rather than three.
|
||||
|
||||
## Features
|
||||
|
||||
- Pre-order DFS, visiting parent groups before their children.
|
||||
- Provides the root-first parent stack to the visitor so consumers can
|
||||
emit path-qualified diagnostics without re-deriving the path.
|
||||
- Stack-safe for the bounded tree sizes the schema permits (depth and
|
||||
width are bounded by what a renderer can sensibly draw).
|
||||
- The visitor is read-only by contract; mutation during traversal is
|
||||
undefined behaviour and not protected against.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Count every node and print the id list:
|
||||
|
||||
```ts
|
||||
import { walkSurface } from "@suaveplan/control-surface-schema";
|
||||
|
||||
const ids: string[] = [];
|
||||
walkSurface(surface, (node) => ids.push(node.id));
|
||||
console.log(ids.length, ids);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Emit path-qualified diagnostics by joining the parent stack:
|
||||
|
||||
```ts
|
||||
import { walkSurface } from "@suaveplan/control-surface-schema";
|
||||
|
||||
walkSurface(surface, (node, parents) => {
|
||||
const path = [...parents.map((p) => p.id), node.id].join(" / ");
|
||||
if (node.type === "control" && node.binding?.kind === "pluginParam") {
|
||||
console.log(`[${path}] -> ${node.binding.pluginId}/${node.binding.paramPath}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SurfaceVisitor` — `(node: ControlNode, parents: ReadonlyArray<Group>) => void`.
|
||||
- `walkSurface(surface: ControlSurface, visitor: SurfaceVisitor): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The visitor is called exactly once per node in pre-order DFS. Leaf
|
||||
control nodes never receive a recursive visit because they have no
|
||||
children; group nodes recurse via `node.children` in their declared
|
||||
order. The parent stack is rebuilt at each level by spreading the
|
||||
incoming stack with the current group appended, which trades a small
|
||||
amount of allocation for visitor-side immutability. Callers that need
|
||||
post-order traversal compose the visitor themselves; the package does
|
||||
not expose a separate post-order helper because no consumer needed it
|
||||
at v0.1.0.
|
||||
Reference in New Issue
Block a user