Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
# @suaveplan/control-surface-schema - Universal canonical schema for DAW-style control surfaces: ControlSurface root, ControlNode (Group | Control) discriminated union, 18 ControlKind variants, 6 Binding variants, 4 GroupLayout variants, Zod validators, forward migrations, DFS traversal, duplicate-id and binding-reference cross-check. Zero runtime deps beyond error, types, tslib, zod.
Universal canonical schema for DAW-style control surfaces. Owns the
`ControlSurface` root document, the `ControlNode = Group | Control`
discriminated tree, the 18 `ControlKind` variants, the 6 `Binding`
variants, the 4 `GroupLayout` variants, and the Zod schemas / validators
/ forward migrations every renderer reads. Zero runtime dependencies
beyond `@suaveplan/error`, `@suaveplan/types`, `tslib`, and `zod`.
<!-- begin:auto-docs -->
## Documentation tree
- [constants](./src/constants/constants.md) — The constants module owns the single source of truth for the current schema version of `@suaveplan/control-surface-sche…
- [constants](./src/constants/constants.md) — The constants module owns the single source of truth for the current schema version of `@suaveplan/control-surface-sche…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/control-surface-schema`. Every entrypoint of the packag…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/control-surface-schema`. Every entrypoint of the packag…
- [identity](./src/identity/identity.md) — The identity module owns the structural cross-checks that run after a candidate surface passes its Zod parse: duplicate…
- [identity](./src/identity/identity.md) — The identity module owns the structural cross-checks that run after a candidate surface passes its Zod parse: duplicate…
- [schemas](./src/schemas/control-surface-schema.md) — This module owns the Zod schemas that mirror every TypeScript type in the package: `TransformSchema`, `BindingSchema`,…
- [control-surface-schema](./src/schemas/control-surface-schema.md) — This module owns the Zod schemas that mirror every TypeScript type in the package: `TransformSchema`, `BindingSchema`,…
- [upgrade](./src/upgrade/upgrade.md) — This module owns `upgradeSurface` and the `MigrationRegistry` chain that walks an older schema-version document forward…
- [upgrade](./src/upgrade/upgrade.md) — This module owns `upgradeSurface` and the `MigrationRegistry` chain that walks an older schema-version document forward…
- [validate](./src/validate/validate.md) — This module exports `validateSurface` — the canonical entrypoint for parsing and structurally cross-validating a `Contr…
- [validate](./src/validate/validate.md) — This module exports `validateSurface` — the canonical entrypoint for parsing and structurally cross-validating a `Contr…
- [walk](./src/walk/walk.md) — This module exports `walkSurface` — the canonical depth-first traversal helper for `ControlSurface` documents. Every cr…
- [walk](./src/walk/walk.md) — This module exports `walkSurface` — the canonical depth-first traversal helper for `ControlSurface` documents. Every cr…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/control-surface-schema
```
## Quick start
```ts
import {
CURRENT_SCHEMA_VERSION,
validateSurface,
walkSurface,
} from "@suaveplan/control-surface-schema";
const result = validateSurface({
id: "surface-1",
name: "Empty",
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: [],
},
});
if (result.error !== undefined) throw result.error;
walkSurface(result.surface, (node) => console.log(node.id));
```
## Surface document shape
```text
ControlSurface
├── id, name, version, createdAt, updatedAt
├── skin: { id; vars? }
└── root: ControlNode
├── Group { type; id; layout; transform; children[]; label?; collapsible? }
└── Control { type; id; kind; transform; label?; appearance?; binding?; range?; behaviour? }
```
The 18 `ControlKind` literals: `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`.
The 6 `Binding` variants: `pluginParam`, `transport`, `midiOut`,
`midiIn`, `dmx`, `compound` (flat-only).
The 4 `GroupLayout` variants: `grid`, `absolute`, `stack`, `free3d` —
only `free3d` admits z-axis transforms.
## Worked examples
### 4-pad drum machine (grid layout, drum-pad kind)
```ts
const drumMachine = {
id: "drum-4",
name: "4-pad",
version: CURRENT_SCHEMA_VERSION,
createdAt: 0,
updatedAt: 0,
skin: { id: "default" },
root: {
type: "group",
id: "root",
layout: { kind: "grid", columns: 4, rows: 1, gap: 8 },
transform: { position: { x: 0, y: 0 } },
children: Array.from({ length: 4 }, (_, i) => ({
type: "control",
id: `pad-${i}`,
kind: "drum-pad",
transform: { position: { x: 0, y: 0 } },
binding: { kind: "midiOut", portId: "out", channel: 9, cc: 60 + i },
})),
},
};
```
### 8-channel mixer (stack-row of stack-columns)
```ts
const mixer = {
/* … */
root: {
type: "group",
id: "mixer",
layout: { kind: "stack", direction: "row", gap: 4 },
transform: { position: { x: 0, y: 0 } },
children: Array.from({ length: 8 }, (_, ch) => ({
type: "group",
id: `ch-${ch}`,
layout: { kind: "stack", direction: "column", gap: 2 },
transform: { position: { x: 0, y: 0 } },
children: [
{
type: "control",
id: `ch-${ch}-vol`,
kind: "slider-v",
transform: { position: { x: 0, y: 0 } },
binding: {
kind: "pluginParam",
pluginId: "mixer",
paramPath: `channels/${ch}/volume`,
curve: "log",
},
},
],
})),
},
};
```
### Piano keyboard (absolute layout, piano-key kind)
```ts
const piano = {
/* … */
root: {
type: "group",
id: "keyboard",
layout: { kind: "absolute" },
transform: { position: { x: 0, y: 0 } },
children: Array.from({ length: 88 }, (_, key) => ({
type: "control",
id: `key-${21 + key}`,
kind: "piano-key",
transform: { position: { x: key * 22, y: 0 } },
binding: { kind: "midiOut", portId: "out", channel: 0, cc: 21 + key },
})),
},
};
```
### Traktor-style DJ surface (3D free layout)
```ts
const traktor = {
/* … */
root: {
type: "group",
id: "deck",
layout: { kind: "free3d" },
transform: { position: { x: 0, y: 0 } },
children: [
{
type: "control",
id: "jog",
kind: "jog-wheel",
transform: { position: { x: 0, y: 0, z: 0.1 } },
binding: { kind: "midiOut", portId: "deck-a", channel: 0, cc: 32 },
},
],
},
};
```
## Modules
- [Constants](./src/constants.md) — `CURRENT_SCHEMA_VERSION`.
- [Error codes](./src/error-codes.md) — `CONTROL_SURFACE_SCHEMA_CODES`, `SchemaError`.
- [Transform types](./src/types/transform.md)
- [Binding types](./src/types/binding.md)
- [Range types](./src/types/range.md)
- [Action types](./src/types/action.md)
- [Behaviour types](./src/types/behaviour.md)
- [Appearance types](./src/types/appearance.md)
- [Control-kind types](./src/types/control-kind.md)
- [Group-layout types](./src/types/group-layout.md)
- [Control-node types](./src/types/control-node.md)
- [Control-surface types](./src/types/control-surface.md)
- [Validate](./src/validate/validate.md)
- [Upgrade](./src/upgrade/upgrade.md)
- [Walk](./src/walk/walk.md)
- [Identity](./src/identity/identity.md)

View File

@@ -0,0 +1,80 @@
{
"name": "@suaveplan/control-surface-schema",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal canonical schema for DAW-style control surfaces: ControlSurface root, ControlNode (Group | Control) discriminated union, 18 ControlKind variants, 6 Binding variants, 4 GroupLayout variants, Zod validators, forward migrations, DFS traversal, duplicate-id and binding-reference cross-check. Zero runtime deps beyond error, types, tslib, zod.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./error-codes": {
"import": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" },
"require": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" }
},
"./constants": {
"import": { "types": "./dist/constants/index.d.ts", "default": "./dist/constants/index.js" },
"require": { "types": "./dist/constants/index.d.ts", "default": "./dist/constants/index.js" }
},
"./schemas/control-surface-schema": {
"import": { "types": "./dist/schemas/control-surface-schema.d.ts", "default": "./dist/schemas/control-surface-schema.js" },
"require": { "types": "./dist/schemas/control-surface-schema.d.ts", "default": "./dist/schemas/control-surface-schema.js" }
},
"./validate/validate": {
"import": { "types": "./dist/validate/validate.d.ts", "default": "./dist/validate/validate.js" },
"require": { "types": "./dist/validate/validate.d.ts", "default": "./dist/validate/validate.js" }
},
"./upgrade/upgrade": {
"import": { "types": "./dist/upgrade/upgrade.d.ts", "default": "./dist/upgrade/upgrade.js" },
"require": { "types": "./dist/upgrade/upgrade.d.ts", "default": "./dist/upgrade/upgrade.js" }
},
"./walk/walk": {
"import": { "types": "./dist/walk/walk.d.ts", "default": "./dist/walk/walk.js" },
"require": { "types": "./dist/walk/walk.d.ts", "default": "./dist/walk/walk.js" }
},
"./identity/identity": {
"import": { "types": "./dist/identity/identity.d.ts", "default": "./dist/identity/identity.js" },
"require": { "types": "./dist/identity/identity.d.ts", "default": "./dist/identity/identity.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,56 @@
/**
* Test-only fixture builders. Excluded from coverage + docs-check via the
* `__fixtures__/` directory convention in `vitest.config.ts` and
* `scripts/docs-check.ts`.
*/
import { CURRENT_SCHEMA_VERSION } from "../constants/constants.js";
import type { Binding } from "../types/binding.types.js";
import type { Control, ControlNode } from "../types/control-node.types.js";
import type { ControlSurface } from "../types/control-surface.types.js";
import type { Transform } from "../types/transform.types.js";
export function flatTransform(z?: number): Transform {
return z === undefined
? { position: { x: 0, y: 0 } }
: { position: { x: 0, y: 0, z } };
}
export function makeSurface(
root: ControlNode,
overrides: Partial<ControlSurface> = {}
): ControlSurface {
return {
id: "surface-1",
name: "test",
version: CURRENT_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 1,
skin: { id: "default" },
root,
...overrides,
};
}
export function makeRoot(
children: ReadonlyArray<ControlNode> = []
): ControlNode {
return {
type: "group",
id: "root",
layout: { kind: "absolute" },
transform: { position: { x: 0, y: 0 } },
children,
};
}
export function makeKnob(id: string, binding?: Binding): Control {
return binding === undefined
? { type: "control", id, kind: "knob", transform: flatTransform() }
: {
type: "control",
id,
kind: "knob",
transform: flatTransform(),
binding,
};
}

View File

@@ -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.

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CURRENT_SCHEMA_VERSION } from "./constants.js";
describe("constants", () => {
it("exposes a stable semver string", { timeout: 5000 }, () => {
expect(CURRENT_SCHEMA_VERSION).toBe("0.1.0");
expect(/^\d+\.\d+\.\d+$/.test(CURRENT_SCHEMA_VERSION)).toBe(true);
});
});

View File

@@ -0,0 +1,10 @@
/**
* Current schema version for `@suaveplan/control-surface-schema`.
*
* Bumped on every breaking change. `upgradeSurface` consults the migration
* registry to walk older documents forward to this version.
*/
export const CURRENT_SCHEMA_VERSION = "0.1.0" as const;
/** Type literal of the current schema version. */
export type CurrentSchemaVersion = typeof CURRENT_SCHEMA_VERSION;

View File

@@ -0,0 +1,2 @@
export type { CurrentSchemaVersion } from "./constants.js";
export { CURRENT_SCHEMA_VERSION } from "./constants.js";

View File

@@ -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.

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CONTROL_SURFACE_SCHEMA_CODES,
CONTROL_SURFACE_SCHEMA_ERROR_METADATA,
SchemaError,
} from "./error-codes.js";
describe("error-codes registry", () => {
it("registers every documented code under the CONTROL_SURFACE_SCHEMA namespace", {
timeout: 5000,
}, () => {
const codes = Object.values(CONTROL_SURFACE_SCHEMA_CODES);
expect(codes).toEqual([
"CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT",
"CONTROL_SURFACE_SCHEMA.UNSUPPORTED_VERSION",
"CONTROL_SURFACE_SCHEMA.UPGRADE_FAILED",
"CONTROL_SURFACE_SCHEMA.DUPLICATE_ID",
"CONTROL_SURFACE_SCHEMA.BINDING_REFERENCE_MISSING",
]);
for (const code of codes) {
expect(CONTROL_SURFACE_SCHEMA_ERROR_METADATA[code]).toBeDefined();
expect(
CONTROL_SURFACE_SCHEMA_ERROR_METADATA[code]?.description.length
).toBeGreaterThan(0);
}
});
it("attaches code + context onto SchemaError", { timeout: 5000 }, () => {
const err = new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.DUPLICATE_ID, {
context: { ids: ["x"] },
});
expect(err.code).toBe("CONTROL_SURFACE_SCHEMA.DUPLICATE_ID");
expect(err.context).toEqual({ ids: ["x"] });
expect(err.name).toBe("SchemaError");
expect(err instanceof Error).toBe(true);
});
it("freezes the metadata table", { timeout: 5000 }, () => {
expect(Object.isFrozen(CONTROL_SURFACE_SCHEMA_ERROR_METADATA)).toBe(
true
);
});
});

View File

@@ -0,0 +1,80 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/control-surface-schema`.
*
* Every code is namespaced under `CONTROL_SURFACE_SCHEMA.*` and threaded
* through `SchemaError` (the package-local concrete subclass) so callers can
* narrow by `instanceof SchemaError && error.code === "..."`.
*/
export const CONTROL_SURFACE_SCHEMA_CODES = defineCodes(
"CONTROL_SURFACE_SCHEMA",
[
"INVALID_DOCUMENT",
"UNSUPPORTED_VERSION",
"UPGRADE_FAILED",
"DUPLICATE_ID",
"BINDING_REFERENCE_MISSING",
] as const
);
export type ControlSurfaceSchemaErrorCode =
(typeof CONTROL_SURFACE_SCHEMA_CODES)[keyof typeof CONTROL_SURFACE_SCHEMA_CODES];
export interface ControlSurfaceSchemaErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const CONTROL_SURFACE_SCHEMA_ERROR_METADATA: Readonly<
Record<ControlSurfaceSchemaErrorCode, ControlSurfaceSchemaErrorMetadata>
> = Object.freeze({
[CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT]: {
description:
"The candidate document failed Zod parse against the current schema.",
severity: "error",
},
[CONTROL_SURFACE_SCHEMA_CODES.UNSUPPORTED_VERSION]: {
description:
"The document version is not registered in the migration chain.",
severity: "error",
},
[CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED]: {
description:
"A registered migration threw or produced an invalid document.",
severity: "error",
},
[CONTROL_SURFACE_SCHEMA_CODES.DUPLICATE_ID]: {
description:
"Two or more nodes in the surface tree share the same `id`.",
severity: "error",
},
[CONTROL_SURFACE_SCHEMA_CODES.BINDING_REFERENCE_MISSING]: {
description:
"A binding references a `pluginId` absent from the supplied catalog.",
severity: "error",
},
});
/**
* Concrete error type emitted by every entrypoint of this package.
*
* The constructor accepts only `(code, options?)` — the i18n message key is
* derived from the code so call sites stay terse. Widening from the
* abstract `SuaveplanError`'s `protected` base constructor to a `public`
* narrower one is the type-level difference that justifies this subclass.
*/
export class SchemaError extends SuaveplanError<ControlSurfaceSchemaErrorCode> {
public constructor(
code: ControlSurfaceSchemaErrorCode,
options?: SuaveplanErrorOptions
) {
super(code, codeToMessageKey(code), options);
}
}
function codeToMessageKey(code: ControlSurfaceSchemaErrorCode): string {
// "CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT" -> "control_surface_schema.invalid_document"
return code.toLowerCase();
}

View File

@@ -0,0 +1,9 @@
export type {
ControlSurfaceSchemaErrorCode,
ControlSurfaceSchemaErrorMetadata,
} from "./error-codes.js";
export {
CONTROL_SURFACE_SCHEMA_CODES,
CONTROL_SURFACE_SCHEMA_ERROR_METADATA,
SchemaError,
} from "./error-codes.js";

View File

@@ -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.

View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
flatTransform,
makeKnob,
makeRoot,
makeSurface,
} from "../__fixtures__/fixtures.js";
import {
CONTROL_SURFACE_SCHEMA_CODES,
SchemaError,
} from "../error-codes/error-codes.js";
import type { Binding } from "../types/binding.types.js";
import type { Group } from "../types/control-node.types.js";
import {
assertBindingReferences,
assertUniqueIds,
checkBindingReferences,
checkUniqueIds,
collectPluginReferences,
findDuplicateIds,
} from "./identity.js";
describe("identity — duplicate ids", () => {
it("returns an empty list for a tree with all-unique ids", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([makeKnob("a"), makeKnob("b")]) as Group
);
expect(findDuplicateIds(surface)).toEqual([]);
});
it("identifies the offending id when two controls share one", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([makeKnob("x"), makeKnob("x")]) as Group
);
expect(findDuplicateIds(surface)).toEqual(["x"]);
});
it("throws DUPLICATE_ID with context.ids on assertUniqueIds", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([makeKnob("x"), makeKnob("x")]) as Group
);
let caught: unknown;
try {
assertUniqueIds(surface);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SchemaError);
const err = caught as SchemaError;
expect(err.code).toBe(CONTROL_SURFACE_SCHEMA_CODES.DUPLICATE_ID);
expect(err.context).toEqual({ ids: ["x"] });
});
it("returns without throwing when ids are unique", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([makeKnob("a"), makeKnob("b")]) as Group
);
expect(() => assertUniqueIds(surface)).not.toThrow();
});
it("checkUniqueIds returns undefined on unique-ids and SchemaError on dupes", {
timeout: 5000,
}, () => {
const ok = makeSurface(
makeRoot([makeKnob("a"), makeKnob("b")]) as Group
);
const dup = makeSurface(
makeRoot([makeKnob("x"), makeKnob("x")]) as Group
);
expect(checkUniqueIds(ok)).toBeUndefined();
const err = checkUniqueIds(dup);
expect(err).toBeInstanceOf(SchemaError);
expect(err?.code).toBe(CONTROL_SURFACE_SCHEMA_CODES.DUPLICATE_ID);
});
});
describe("identity — binding references", () => {
const pluginBinding: Binding = {
kind: "pluginParam",
pluginId: "p1",
paramPath: "vol",
};
it("collects pluginId references from leaf bindings", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([makeKnob("k1", pluginBinding)]) as Group
);
expect([...collectPluginReferences(surface)]).toEqual(["p1"]);
});
it("walks into compound bindings to collect nested references", {
timeout: 5000,
}, () => {
const compound: Binding = {
kind: "compound",
bindings: [
pluginBinding,
{ kind: "transport", field: "play" },
{
kind: "pluginParam",
pluginId: "p2",
paramPath: "pan",
},
],
};
const surface = makeSurface(
makeRoot([makeKnob("k", compound)]) as Group
);
expect([...collectPluginReferences(surface)].sort()).toEqual([
"p1",
"p2",
]);
});
it("ignores controls with no binding when collecting references", {
timeout: 5000,
}, () => {
const surface = makeSurface(makeRoot([makeKnob("k")]) as Group);
expect([...collectPluginReferences(surface)]).toEqual([]);
});
it("returns no references when binding is non-plugin (transport / midi / dmx)", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([
makeKnob("a", { kind: "transport", field: "play" }),
makeKnob("b", {
kind: "midiOut",
portId: "p",
channel: 0,
cc: 1,
}),
makeKnob("c", { kind: "dmx", universe: 0, channel: 1 }),
]) as Group
);
expect([...collectPluginReferences(surface)]).toEqual([]);
});
it("accepts references present in the catalog", { timeout: 5000 }, () => {
const surface = makeSurface(
makeRoot([makeKnob("k", pluginBinding)]) as Group
);
expect(() =>
assertBindingReferences(surface, new Set(["p1"]))
).not.toThrow();
});
it("throws BINDING_REFERENCE_MISSING with context.missing on absent reference", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([
makeKnob("k", {
kind: "pluginParam",
pluginId: "p99",
paramPath: "vol",
}),
]) as Group
);
let caught: unknown;
try {
assertBindingReferences(surface, new Set(["p1"]));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SchemaError);
const err = caught as SchemaError;
expect(err.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.BINDING_REFERENCE_MISSING
);
expect(err.context).toEqual({ missing: "p99" });
});
it("ignores controls when walked but never recurses into a leaf control's transform", {
timeout: 5000,
}, () => {
const groupWithControl: Group = {
type: "group",
id: "root",
layout: { kind: "absolute" },
transform: flatTransform(),
children: [
{
type: "control",
id: "c",
kind: "knob",
transform: flatTransform(),
},
],
};
const surface = makeSurface(groupWithControl);
expect([...collectPluginReferences(surface)]).toEqual([]);
});
it("checkBindingReferences returns undefined on success and SchemaError on missing", {
timeout: 5000,
}, () => {
const ok = makeSurface(
makeRoot([
makeKnob("k", {
kind: "pluginParam",
pluginId: "p1",
paramPath: "v",
}),
]) as Group
);
const missing = makeSurface(
makeRoot([
makeKnob("k", {
kind: "pluginParam",
pluginId: "p99",
paramPath: "v",
}),
]) as Group
);
expect(checkBindingReferences(ok, new Set(["p1"]))).toBeUndefined();
const err = checkBindingReferences(missing, new Set(["p1"]));
expect(err).toBeInstanceOf(SchemaError);
expect(err?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.BINDING_REFERENCE_MISSING
);
});
});

View File

@@ -0,0 +1,106 @@
import {
CONTROL_SURFACE_SCHEMA_CODES,
SchemaError,
} from "../error-codes/error-codes.js";
import type { Binding } from "../types/binding.types.js";
import type { ControlNode } from "../types/control-node.types.js";
import type { ControlSurface } from "../types/control-surface.types.js";
import { walkSurface } from "../walk/walk.js";
/**
* Walk the surface, collect every node id, and return a list of ids that
* appear more than once. Used by `validateSurface` before any other
* structural cross-check runs.
*/
export function findDuplicateIds(
surface: ControlSurface
): ReadonlyArray<string> {
const seen = new Set<string>();
const dup = new Set<string>();
walkSurface(surface, (node: ControlNode) => {
if (seen.has(node.id)) dup.add(node.id);
else seen.add(node.id);
});
return [...dup];
}
/**
* Throws `DUPLICATE_ID` carrying the offending ids on `context.ids` when
* any duplicate exists.
*/
export function assertUniqueIds(surface: ControlSurface): void {
const err = checkUniqueIds(surface);
if (err !== undefined) throw err;
}
/**
* Non-throwing variant. Returns the `SchemaError` instead of throwing when
* duplicates exist, or `undefined` when ids are unique.
*/
export function checkUniqueIds(
surface: ControlSurface
): SchemaError | undefined {
const dup = findDuplicateIds(surface);
if (dup.length === 0) return undefined;
return new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.DUPLICATE_ID, {
context: { ids: dup },
});
}
function collectPluginRefs(binding: Binding, out: Set<string>): void {
if (binding.kind === "pluginParam") {
out.add(binding.pluginId);
return;
}
if (binding.kind === "compound") {
for (const leaf of binding.bindings) collectPluginRefs(leaf, out);
}
}
/**
* Return the set of `pluginId` strings referenced by every binding in the
* surface (including those nested inside a `compound`).
*/
export function collectPluginReferences(
surface: ControlSurface
): ReadonlySet<string> {
const refs = new Set<string>();
walkSurface(surface, (node: ControlNode) => {
if (node.type !== "control" || node.binding === undefined) return;
collectPluginRefs(node.binding, refs);
});
return refs;
}
/**
* Cross-check every plugin reference against the supplied catalog. Throws
* `BINDING_REFERENCE_MISSING` on the first missing reference with the
* offending id on `context.missing`.
*/
export function assertBindingReferences(
surface: ControlSurface,
catalog: ReadonlySet<string>
): void {
const err = checkBindingReferences(surface, catalog);
if (err !== undefined) throw err;
}
/**
* Non-throwing variant. Returns the `SchemaError` for the first missing
* reference, or `undefined` when every reference resolves.
*/
export function checkBindingReferences(
surface: ControlSurface,
catalog: ReadonlySet<string>
): SchemaError | undefined {
const refs = collectPluginReferences(surface);
for (const id of refs) {
if (!catalog.has(id)) {
return new SchemaError(
CONTROL_SURFACE_SCHEMA_CODES.BINDING_REFERENCE_MISSING,
{ context: { missing: id } }
);
}
}
return undefined;
}

View File

@@ -0,0 +1,8 @@
export {
assertBindingReferences,
assertUniqueIds,
checkBindingReferences,
checkUniqueIds,
collectPluginReferences,
findDuplicateIds,
} from "./identity.js";

View File

@@ -0,0 +1,104 @@
// Constants
export type { CurrentSchemaVersion } from "./constants/index.js";
export { CURRENT_SCHEMA_VERSION } from "./constants/index.js";
// Error registry
export type {
ControlSurfaceSchemaErrorCode,
ControlSurfaceSchemaErrorMetadata,
} from "./error-codes/index.js";
export {
CONTROL_SURFACE_SCHEMA_CODES,
CONTROL_SURFACE_SCHEMA_ERROR_METADATA,
SchemaError,
} from "./error-codes/index.js";
// Identity
export {
assertBindingReferences,
assertUniqueIds,
checkBindingReferences,
checkUniqueIds,
collectPluginReferences,
findDuplicateIds,
} from "./identity/identity.js";
// Schemas
export {
AppearanceSchema,
BehaviourSchema,
BindingSchema,
ControlKindSchema,
ControlNodeSchema,
ControlSurfaceSchema,
ControlSurfaceVersionedSchema,
GroupLayoutSchema,
RangeSchema,
TransformSchema,
} from "./schemas/control-surface-schema.js";
// Types
export type {
Action,
EmitEventAction,
ResetToDefaultAction,
SetToAction,
ToggleGroupCollapseAction,
} from "./types/action.types.js";
export type {
Appearance,
Icon,
IconSource,
RenderHint,
} from "./types/appearance.types.js";
export type { Behaviour } from "./types/behaviour.types.js";
export type {
Binding,
CompoundBinding,
CurvePoint,
DmxBinding,
LeafBinding,
MidiInBinding,
MidiOutBinding,
ParamCurve,
PluginParameterBinding,
TransportBinding,
TransportField,
} from "./types/binding.types.js";
export type { ControlKind } from "./types/control-kind.types.js";
export { CONTROL_KIND_VALUES } from "./types/control-kind.types.js";
export type {
Control,
ControlNode,
Group,
} from "./types/control-node.types.js";
export type {
ControlSurface,
SkinReference,
} from "./types/control-surface.types.js";
export type {
AbsoluteLayout,
Free3DLayout,
GridLayout,
GroupLayout,
StackLayout,
} from "./types/group-layout.types.js";
export type { Range } from "./types/range.types.js";
export type {
EulerRotation,
Scale3,
Transform,
Vec2,
Vec3,
} from "./types/transform.types.js";
// Upgrade
export type { Migration, UpgradeOptions } from "./upgrade/upgrade.js";
export {
defaultRegistry,
MigrationRegistry,
upgradeSurface,
} from "./upgrade/upgrade.js";
// Validate
export type { ValidateOptions, ValidateResult } from "./validate/validate.js";
export { validateSurface } from "./validate/validate.js";
// Walk
export type { SurfaceVisitor } from "./walk/walk.js";
export { walkSurface } from "./walk/walk.js";

View File

@@ -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.

View File

@@ -0,0 +1,242 @@
import z from "zod";
import { CURRENT_SCHEMA_VERSION } from "../constants/constants.js";
import { CONTROL_KIND_VALUES } from "../types/control-kind.types.js";
/* -------------------------------------------------------------------------- */
/* Transform */
/* -------------------------------------------------------------------------- */
const _Vec2Schema = z.object({
x: z.number(),
y: z.number(),
});
const Vec3Schema = z.object({
x: z.number(),
y: z.number(),
z: z.number().optional(),
});
const EulerSchema = z.object({
x: z.number().optional(),
y: z.number().optional(),
z: z.number().optional(),
});
const ScaleSchema = z.object({
x: z.number().optional(),
y: z.number().optional(),
z: z.number().optional(),
});
export const TransformSchema = z.object({
position: Vec3Schema,
rotation: EulerSchema.optional(),
scale: ScaleSchema.optional(),
});
/* -------------------------------------------------------------------------- */
/* Range / Appearance / Behaviour */
/* -------------------------------------------------------------------------- */
export const RangeSchema = z.object({
min: z.number(),
max: z.number(),
step: z.number().optional(),
default: z.number().optional(),
units: z.string().optional(),
});
const IconSchema = z.object({
kind: z.enum(["url", "svg", "skinAsset"]),
value: z.string(),
});
export const AppearanceSchema = z.object({
skinKey: z.string().optional(),
renderHint: z.enum(["auto", "2d", "3d"]).optional(),
cssVars: z.record(z.string(), z.string()).optional(),
tooltip: z.string().optional(),
icon: IconSchema.optional(),
});
const ActionSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("resetToDefault") }),
z.object({ kind: z.literal("setTo"), value: z.number() }),
z.object({
kind: z.literal("toggleGroupCollapse"),
targetId: z.string().min(1),
}),
z.object({
kind: z.literal("emitEvent"),
eventId: z.string().min(1),
payload: z.unknown().optional(),
}),
]);
export const BehaviourSchema = z.object({
momentaryDelayMs: z.number().nonnegative().optional(),
latchSticky: z.boolean().optional(),
doubleTapAction: ActionSchema.optional(),
longPressAction: ActionSchema.optional(),
longPressDelayMs: z.number().nonnegative().optional(),
stepOverride: z.number().positive().optional(),
});
/* -------------------------------------------------------------------------- */
/* Binding */
/* -------------------------------------------------------------------------- */
const CurvePointSchema = z.object({
at: z.number(),
value: z.number(),
});
const ParamCurveSchema: z.ZodType = z.union([
z.literal("linear"),
z.literal("log"),
z.literal("exp"),
z.object({
kind: z.literal("custom"),
points: z.array(CurvePointSchema).min(2),
}),
]);
const PluginParameterBindingSchema = z.object({
kind: z.literal("pluginParam"),
pluginId: z.string().min(1),
paramPath: z.string().min(1),
curve: ParamCurveSchema.optional(),
});
const TransportBindingSchema = z.object({
kind: z.literal("transport"),
field: z.enum(["play", "stop", "tempo", "position", "loop"]),
});
const MidiOutBindingSchema = z.object({
kind: z.literal("midiOut"),
portId: z.string().min(1),
channel: z.number().int().min(0).max(15),
cc: z.number().int().min(0).max(127),
range: z.tuple([z.number(), z.number()]).optional(),
});
const MidiInBindingSchema = z.object({
kind: z.literal("midiIn"),
portId: z.string().min(1),
channel: z.number().int().min(0).max(15),
cc: z.number().int().min(0).max(127),
range: z.tuple([z.number(), z.number()]).optional(),
});
const DmxBindingSchema = z.object({
kind: z.literal("dmx"),
universe: z.number().int().min(0),
channel: z.number().int().min(1).max(512),
range: z.tuple([z.number().min(0), z.number().max(255)]).optional(),
});
const LeafBindingSchema = z.discriminatedUnion("kind", [
PluginParameterBindingSchema,
TransportBindingSchema,
MidiOutBindingSchema,
MidiInBindingSchema,
DmxBindingSchema,
]);
const CompoundBindingSchema = z.object({
kind: z.literal("compound"),
bindings: z.array(LeafBindingSchema).min(1),
});
export const BindingSchema = z.discriminatedUnion("kind", [
PluginParameterBindingSchema,
TransportBindingSchema,
MidiOutBindingSchema,
MidiInBindingSchema,
DmxBindingSchema,
CompoundBindingSchema,
]);
/* -------------------------------------------------------------------------- */
/* GroupLayout */
/* -------------------------------------------------------------------------- */
export const GroupLayoutSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("grid"),
columns: z.number().int().positive(),
rows: z.number().int().positive(),
gap: z.number().nonnegative(),
}),
z.object({ kind: z.literal("absolute") }),
z.object({
kind: z.literal("stack"),
direction: z.enum(["row", "column"]),
gap: z.number().nonnegative(),
}),
z.object({ kind: z.literal("free3d") }),
]);
/* -------------------------------------------------------------------------- */
/* ControlNode (recursive) */
/* -------------------------------------------------------------------------- */
export const ControlKindSchema = z.enum(CONTROL_KIND_VALUES);
const ControlLeafSchema = z.object({
type: z.literal("control"),
id: z.string().min(1),
kind: ControlKindSchema,
label: z.string().optional(),
transform: TransformSchema,
appearance: AppearanceSchema.optional(),
binding: BindingSchema.optional(),
range: RangeSchema.optional(),
behaviour: BehaviourSchema.optional(),
});
// `z.ZodType<ControlNode>` would force `exactOptionalPropertyTypes` parity
// across the recursive boundary; here we lean on `unknown` for the lazy
// recursion target and re-narrow at the consumer (`validateSurface` casts
// `parsed.data` to `ControlSurface`).
export const ControlNodeSchema: z.ZodType<unknown> = z.lazy(() =>
z.discriminatedUnion("type", [
z.object({
type: z.literal("group"),
id: z.string().min(1),
label: z.string().optional(),
layout: GroupLayoutSchema,
transform: TransformSchema,
children: z.array(ControlNodeSchema),
collapsible: z.boolean().optional(),
}),
ControlLeafSchema,
])
);
const SkinReferenceSchema = z.object({
id: z.string().min(1),
vars: z.record(z.string(), z.string()).optional(),
});
/**
* Strict root schema. `version` is pinned to `CURRENT_SCHEMA_VERSION`; use
* `ControlSurfaceVersionedSchema` to parse documents whose version is not
* yet resolved (e.g. inside `upgradeSurface`).
*/
export const ControlSurfaceSchema = z.object({
id: z.string().min(1),
name: z.string(),
version: z.literal(CURRENT_SCHEMA_VERSION),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(),
skin: SkinReferenceSchema,
root: ControlNodeSchema,
});
/** Loose root schema — accepts any semver-shaped `version`. */
export const ControlSurfaceVersionedSchema = ControlSurfaceSchema.extend({
version: z.string().min(1),
});

View File

@@ -0,0 +1,12 @@
export {
AppearanceSchema,
BehaviourSchema,
BindingSchema,
ControlKindSchema,
ControlNodeSchema,
ControlSurfaceSchema,
ControlSurfaceVersionedSchema,
GroupLayoutSchema,
RangeSchema,
TransformSchema,
} from "./control-surface-schema.js";

View File

@@ -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.

View File

@@ -0,0 +1,34 @@
/**
* Action — what a behaviour hook invokes when triggered.
*
* Four closed variants:
* - `resetToDefault` — restore the control to its `Range.default` (no payload)
* - `setTo` — set the control to a specific numeric value
* - `toggleGroupCollapse` — flip the `collapsed` state of a target group
* - `emitEvent` — emit a renderer-defined event with an opaque JSON payload
*/
export interface ResetToDefaultAction {
readonly kind: "resetToDefault";
}
export interface SetToAction {
readonly kind: "setTo";
readonly value: number;
}
export interface ToggleGroupCollapseAction {
readonly kind: "toggleGroupCollapse";
readonly targetId: string;
}
export interface EmitEventAction {
readonly kind: "emitEvent";
readonly eventId: string;
readonly payload?: unknown;
}
export type Action =
| ResetToDefaultAction
| SetToAction
| ToggleGroupCollapseAction
| EmitEventAction;

View File

@@ -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.

View File

@@ -0,0 +1,23 @@
/**
* Appearance — visual / cosmetic data attached to a `Control` or `Group`.
*
* `skinKey` picks a sub-asset out of the surface's skin bundle; `renderHint`
* tells the renderer whether to draw a flat 2D primitive or invoke the 3D
* skin model; `cssVars` and `tooltip` are passed straight through.
*/
export type RenderHint = "auto" | "2d" | "3d";
export type IconSource = "url" | "svg" | "skinAsset";
export interface Icon {
readonly kind: IconSource;
readonly value: string;
}
export interface Appearance {
readonly skinKey?: string;
readonly renderHint?: RenderHint;
readonly cssVars?: Readonly<Record<string, string>>;
readonly tooltip?: string;
readonly icon?: Icon;
}

View File

@@ -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.

View File

@@ -0,0 +1,24 @@
import type { Action } from "./action.types.js";
/**
* Behaviour — interaction modifiers applied to a `Control`.
*
* Renderers consult this block to know whether a button is momentary or
* latching, what gesture sticky-latches it, and what action fires on
* double-tap or long-press. All fields are optional; an absent block means
* "default behaviour for the kind."
*/
export interface Behaviour {
/** Minimum hold (ms) before a momentary control reports release. */
readonly momentaryDelayMs?: number;
/** If true, a latching control stays on after gesture release. */
readonly latchSticky?: boolean;
/** Action to fire on double-tap. */
readonly doubleTapAction?: Action;
/** Action to fire on long-press. */
readonly longPressAction?: Action;
/** Minimum hold (ms) before long-press fires. */
readonly longPressDelayMs?: number;
/** Hard step quantisation override (takes precedence over Range.step). */
readonly stepOverride?: number;
}

View File

@@ -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.

View File

@@ -0,0 +1,72 @@
/**
* Binding — what a control drives.
*
* Six closed variants. The `compound` variant flattens a list of leaves so
* one physical control can drive a plugin parameter, a MIDI CC, and a DMX
* channel simultaneously. Nested compounds are not allowed — the validator
* rejects them at parse time.
*/
/** Pluggable mapping curve for a `pluginParam` binding. */
export type ParamCurve =
| "linear"
| "log"
| "exp"
| { readonly kind: "custom"; readonly points: ReadonlyArray<CurvePoint> };
export interface CurvePoint {
readonly at: number;
readonly value: number;
}
export interface PluginParameterBinding {
readonly kind: "pluginParam";
readonly pluginId: string;
readonly paramPath: string;
readonly curve?: ParamCurve;
}
export type TransportField = "play" | "stop" | "tempo" | "position" | "loop";
export interface TransportBinding {
readonly kind: "transport";
readonly field: TransportField;
}
export interface MidiOutBinding {
readonly kind: "midiOut";
readonly portId: string;
readonly channel: number;
readonly cc: number;
readonly range?: readonly [number, number];
}
export interface MidiInBinding {
readonly kind: "midiIn";
readonly portId: string;
readonly channel: number;
readonly cc: number;
readonly range?: readonly [number, number];
}
export interface DmxBinding {
readonly kind: "dmx";
readonly universe: number;
readonly channel: number;
readonly range?: readonly [number, number];
}
/** Flat-only — a CompoundBinding's children may NOT themselves be compound. */
export type LeafBinding =
| PluginParameterBinding
| TransportBinding
| MidiOutBinding
| MidiInBinding
| DmxBinding;
export interface CompoundBinding {
readonly kind: "compound";
readonly bindings: ReadonlyArray<LeafBinding>;
}
export type Binding = LeafBinding | CompoundBinding;

View File

@@ -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.

View File

@@ -0,0 +1,31 @@
/**
* `ControlKind` — closed enumeration of the 18 atomic control primitives
* every renderer must support.
*
* The set is intentionally final at v0.1.0; adding a kind requires a schema
* version bump and a registered migration. Renderers that don't know a kind
* are expected to fall back to a generic placeholder, but the validator
* still rejects unknown literals at parse time.
*/
export const CONTROL_KIND_VALUES = [
"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",
] as const;
export type ControlKind = (typeof CONTROL_KIND_VALUES)[number];

View File

@@ -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.

View File

@@ -0,0 +1,43 @@
import type { Appearance } from "./appearance.types.js";
import type { Behaviour } from "./behaviour.types.js";
import type { Binding } from "./binding.types.js";
import type { ControlKind } from "./control-kind.types.js";
import type { GroupLayout } from "./group-layout.types.js";
import type { Range } from "./range.types.js";
import type { Transform } from "./transform.types.js";
/**
* Group — internal node in the control-surface tree.
*
* Carries a layout strategy, an optional label, a transform applied to the
* group as a whole, and an ordered list of children (other groups or leaf
* controls).
*/
export interface Group {
readonly type: "group";
readonly id: string;
readonly label?: string;
readonly layout: GroupLayout;
readonly transform: Transform;
readonly children: ReadonlyArray<ControlNode>;
readonly collapsible?: boolean;
}
/**
* Control — leaf node carrying one of the 18 atomic control kinds plus its
* optional binding, numeric range, behaviour modifiers, and appearance data.
*/
export interface Control {
readonly type: "control";
readonly id: string;
readonly kind: ControlKind;
readonly label?: string;
readonly transform: Transform;
readonly appearance?: Appearance;
readonly binding?: Binding;
readonly range?: Range;
readonly behaviour?: Behaviour;
}
/** Discriminated union — every node in the tree is one of these two. */
export type ControlNode = Group | Control;

View File

@@ -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.

View File

@@ -0,0 +1,32 @@
import type { ControlNode } from "./control-node.types.js";
/**
* Skin reference attached to a `ControlSurface`.
*
* `id` resolves to a separately-published skin package (e.g.
* `control-surface-skin-native-instruments`); `vars` is an optional CSS
* variable override map applied across the whole surface.
*/
export interface SkinReference {
readonly id: string;
readonly vars?: Readonly<Record<string, string>>;
}
/**
* `ControlSurface` — the canonical root document persisted to storage and
* exchanged over the wire.
*
* Every field is validated at parse time:
* - `version` MUST equal `CURRENT_SCHEMA_VERSION` after `upgradeSurface`
* - `createdAt` and `updatedAt` are millisecond Unix timestamps
* - `root` is the top-level `ControlNode` (typically a `Group`).
*/
export interface ControlSurface {
readonly id: string;
readonly name: string;
readonly version: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly skin: SkinReference;
readonly root: ControlNode;
}

View File

@@ -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.

View File

@@ -0,0 +1,33 @@
/**
* `GroupLayout` — closed discriminated union of 4 layout strategies.
*
* `free3d` is the only layout that admits z-axis transforms on its
* descendants; the validator rejects non-zero `position.z` for children of
* any other layout kind.
*/
export interface GridLayout {
readonly kind: "grid";
readonly columns: number;
readonly rows: number;
readonly gap: number;
}
export interface AbsoluteLayout {
readonly kind: "absolute";
}
export interface StackLayout {
readonly kind: "stack";
readonly direction: "row" | "column";
readonly gap: number;
}
export interface Free3DLayout {
readonly kind: "free3d";
}
export type GroupLayout =
| GridLayout
| AbsoluteLayout
| StackLayout
| Free3DLayout;

View File

@@ -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.

View File

@@ -0,0 +1,14 @@
/**
* Numeric range and quantisation hint for a `Control`.
*
* Renderers consult `min`, `max`, `step`, `default`, and `units` to lay out
* tick marks, snap behaviour, and read-out labels. All five fields are
* optional so untyped switches / buttons can omit the block.
*/
export interface Range {
readonly min: number;
readonly max: number;
readonly step?: number;
readonly default?: number;
readonly units?: string;
}

View File

@@ -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.

View File

@@ -0,0 +1,42 @@
/**
* Geometric primitives shared by every node Transform.
*
* 2D-only surfaces leave `z`, `rotation`, and `scale` undefined; 3D / VR
* renderers populate them. The layout-discipline check enforces that
* non-`free3d` groups may not carry nonzero z on any descendant.
*/
export interface Vec2 {
readonly x: number;
readonly y: number;
}
export interface Vec3 extends Vec2 {
readonly z?: number;
}
/** Optional rotation expressed as Euler angles in radians. */
export interface EulerRotation {
readonly x?: number;
readonly y?: number;
readonly z?: number;
}
/** Optional anisotropic scale. */
export interface Scale3 {
readonly x?: number;
readonly y?: number;
readonly z?: number;
}
/**
* Node transform.
*
* `position` is mandatory; `rotation` and `scale` are optional because most
* 2D surfaces never need them. Validators reject nonzero `position.z` when
* the enclosing group's layout is not `free3d`.
*/
export interface Transform {
readonly position: Vec3;
readonly rotation?: EulerRotation;
readonly scale?: Scale3;
}

View File

@@ -0,0 +1,9 @@
export type {
Migration,
UpgradeOptions,
} from "./upgrade.js";
export {
defaultRegistry,
MigrationRegistry,
upgradeSurface,
} from "./upgrade.js";

View File

@@ -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.

View File

@@ -0,0 +1,319 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeRoot, makeSurface } from "../__fixtures__/fixtures.js";
import { CURRENT_SCHEMA_VERSION } from "../constants/constants.js";
import {
CONTROL_SURFACE_SCHEMA_CODES,
SchemaError,
} from "../error-codes/error-codes.js";
import type { Group } from "../types/control-node.types.js";
import { MigrationRegistry, upgradeSurface } from "./upgrade.js";
describe("upgradeSurface — happy path", () => {
it("returns the input unchanged when version already matches", {
timeout: 5000,
}, () => {
const surface = makeSurface(makeRoot() as Group);
const upgraded = upgradeSurface(surface, {
registry: new MigrationRegistry(),
});
expect(upgraded).toEqual(surface);
});
it("walks one hop through a registered migration", {
timeout: 5000,
}, () => {
const registry = new MigrationRegistry();
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: (doc) => ({
...(doc as Record<string, unknown>),
version: CURRENT_SCHEMA_VERSION,
}),
});
const result = upgradeSurface(
{ ...makeSurface(makeRoot() as Group), version: "0.0.9" },
{ registry }
);
expect(result.version).toBe(CURRENT_SCHEMA_VERSION);
});
it("walks a multi-hop chain in order", { timeout: 5000 }, () => {
const registry = new MigrationRegistry();
registry.register({
from: "0.0.7",
to: "0.0.8",
apply: (doc) => ({
...(doc as Record<string, unknown>),
version: "0.0.8",
}),
});
registry.register({
from: "0.0.8",
to: "0.0.9",
apply: (doc) => ({
...(doc as Record<string, unknown>),
version: "0.0.9",
}),
});
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: (doc) => ({
...(doc as Record<string, unknown>),
version: CURRENT_SCHEMA_VERSION,
}),
});
const result = upgradeSurface(
{ ...makeSurface(makeRoot() as Group), version: "0.0.7" },
{ registry }
);
expect(result.version).toBe(CURRENT_SCHEMA_VERSION);
});
it("uses the defaultRegistry when no registry is supplied", {
timeout: 5000,
}, () => {
const surface = makeSurface(makeRoot() as Group);
expect(upgradeSurface(surface)).toEqual(surface);
});
});
describe("upgradeSurface — rejection", () => {
it("rejects non-object input with INVALID_DOCUMENT", {
timeout: 5000,
}, () => {
expect(() => upgradeSurface(null)).toThrowError(SchemaError);
try {
upgradeSurface(null);
} catch (err) {
expect((err as SchemaError).code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT
);
}
});
it("rejects unknown version with UNSUPPORTED_VERSION", {
timeout: 5000,
}, () => {
try {
upgradeSurface({ version: "99.0.0" });
} catch (err) {
expect((err as SchemaError).code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.UNSUPPORTED_VERSION
);
expect((err as SchemaError).context).toEqual({ version: "99.0.0" });
return;
}
throw new Error("expected throw");
});
it("wraps a throwing migration in UPGRADE_FAILED", {
timeout: 5000,
}, () => {
const registry = new MigrationRegistry();
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: () => {
throw new Error("boom");
},
});
try {
upgradeSurface({ version: "0.0.9" }, { registry });
} catch (err) {
expect((err as SchemaError).code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED
);
expect((err as SchemaError).context).toEqual({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
reason: "threw",
});
return;
}
throw new Error("expected throw");
});
it("rejects a migration that does not advance the version", {
timeout: 5000,
}, () => {
const registry = new MigrationRegistry();
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: (doc) => ({
...(doc as Record<string, unknown>),
version: "0.0.9",
}),
});
expect(() =>
upgradeSurface({ version: "0.0.9" }, { registry })
).toThrow(SchemaError);
});
it("rejects a migration that returns a non-versioned shape", {
timeout: 5000,
}, () => {
const registry = new MigrationRegistry();
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: () => 42,
});
try {
upgradeSurface({ version: "0.0.9" }, { registry });
} catch (err) {
expect((err as SchemaError).code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED
);
return;
}
throw new Error("expected throw");
});
it("rejects a final document that fails strict validation", {
timeout: 5000,
}, () => {
const registry = new MigrationRegistry();
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: () => ({
id: "x",
version: CURRENT_SCHEMA_VERSION,
// missing required fields
}),
});
try {
upgradeSurface({ version: "0.0.9" }, { registry });
} catch (err) {
expect((err as SchemaError).code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED
);
return;
}
throw new Error("expected throw");
});
it("rejects when the upgraded surface fails validateSurface (duplicate ids after migration)", {
timeout: 5000,
}, () => {
const dupRoot: Group = {
type: "group",
id: "root",
layout: { kind: "absolute" },
transform: { position: { x: 0, y: 0 } },
children: [
{
type: "control",
id: "dup",
kind: "knob",
transform: { position: { x: 0, y: 0 } },
},
{
type: "control",
id: "dup",
kind: "knob",
transform: { position: { x: 0, y: 0 } },
},
],
};
const surface = { ...makeSurface(dupRoot), version: "0.0.9" };
const registry = new MigrationRegistry();
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: (doc) => ({
...(doc as Record<string, unknown>),
version: CURRENT_SCHEMA_VERSION,
}),
});
try {
upgradeSurface(surface, { registry });
} catch (err) {
expect((err as SchemaError).code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED
);
return;
}
throw new Error("expected throw");
});
});
describe("upgradeSurface — loop guard", () => {
it("rejects a cyclic migration chain with UPGRADE_FAILED(reason=loop)", {
timeout: 5000,
}, () => {
const registry = new MigrationRegistry();
// Two registered edges form a cycle A → B → A. Neither edge advances
// to CURRENT_SCHEMA_VERSION, so the loop counter trips.
registry.register({
from: "0.0.7",
to: "0.0.8",
apply: (doc) => ({
...(doc as Record<string, unknown>),
version: "0.0.8",
}),
});
registry.register({
from: "0.0.8",
to: "0.0.7",
apply: (doc) => ({
...(doc as Record<string, unknown>),
version: "0.0.7",
}),
});
try {
upgradeSurface({ version: "0.0.7" }, { registry });
} catch (err) {
expect((err as SchemaError).code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED
);
expect((err as SchemaError).context).toMatchObject({
reason: "loop",
});
return;
}
throw new Error("expected throw");
});
});
describe("MigrationRegistry", () => {
it("rejects duplicate registration of the same `from`", {
timeout: 5000,
}, () => {
const registry = new MigrationRegistry();
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: (doc) => doc,
});
try {
registry.register({
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: (doc) => doc,
});
} catch (err) {
expect((err as SchemaError).code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED
);
return;
}
throw new Error("expected throw");
});
it("exposes get() and list() snapshots", { timeout: 5000 }, () => {
const registry = new MigrationRegistry();
const m = {
from: "0.0.9",
to: CURRENT_SCHEMA_VERSION,
apply: (doc: unknown) => doc,
};
registry.register(m);
expect(registry.get("0.0.9")).toBe(m);
expect(registry.get("missing")).toBeUndefined();
expect(registry.list()).toEqual([m]);
});
});

View File

@@ -0,0 +1,151 @@
import z from "zod";
import { CURRENT_SCHEMA_VERSION } from "../constants/constants.js";
import {
CONTROL_SURFACE_SCHEMA_CODES,
SchemaError,
} from "../error-codes/error-codes.js";
import { ControlSurfaceVersionedSchema } from "../schemas/control-surface-schema.js";
import type { ControlSurface } from "../types/control-surface.types.js";
import { validateSurface } from "../validate/validate.js";
/**
* A registered migration step rewrites a document at version `from` into
* shape `to`. Migrations are pure functions; they receive a plain JSON-shaped
* `unknown` and return the next-shape `unknown`.
*/
export interface Migration {
readonly from: string;
readonly to: string;
readonly apply: (input: unknown) => unknown;
}
const DEFAULT_VERSIONED = z
.object({ version: z.string().min(1) })
.passthrough();
/**
* Chain of registered migrations. Insertion order is preserved; lookup is
* by `from` version so chains may fork (one source version with multiple
* forward edges is not supported — register only the canonical edge).
*/
export class MigrationRegistry {
private readonly byFrom = new Map<string, Migration>();
/** Register a migration. Re-registering the same `from` is rejected. */
public register(migration: Migration): void {
if (this.byFrom.has(migration.from)) {
throw new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED, {
context: { from: migration.from, reason: "duplicate" },
});
}
this.byFrom.set(migration.from, migration);
}
public get(from: string): Migration | undefined {
return this.byFrom.get(from);
}
/** Snapshot of registered migrations in insertion order. */
public list(): ReadonlyArray<Migration> {
return [...this.byFrom.values()];
}
}
/**
* Default registry. Empty at v0.1.0 — there is no prior schema to migrate
* from yet. Consumers extend it via `defaultRegistry.register(...)` once
* a v0.2.0 lands.
*/
export const defaultRegistry = new MigrationRegistry();
export interface UpgradeOptions {
readonly registry?: MigrationRegistry;
}
/**
* Walk a document forward through the migration chain to
* `CURRENT_SCHEMA_VERSION`. Returns the validated `ControlSurface`.
*
* Throws `UNSUPPORTED_VERSION` when no migration is registered for the
* document's current version (and the document is not already current).
* Throws `UPGRADE_FAILED` when a migration throws, returns a non-object,
* or fails to advance the version. Throws `INVALID_DOCUMENT` when the
* input shape is not a `{ version: string, ... }` envelope or when the
* final document fails strict validation.
*/
export function upgradeSurface(
input: unknown,
options: UpgradeOptions = {}
): ControlSurface {
const registry = options.registry ?? defaultRegistry;
const envelope = DEFAULT_VERSIONED.safeParse(input);
if (!envelope.success) {
throw new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT, {
context: { issues: envelope.error.issues.length },
});
}
let current: unknown = input;
let version = envelope.data.version;
let hops = 0;
const maxHops = registry.list().length + 1;
while (version !== CURRENT_SCHEMA_VERSION) {
if (hops >= maxHops) {
throw new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED, {
context: { version, reason: "loop" },
});
}
hops += 1;
const step = registry.get(version);
if (step === undefined) {
throw new SchemaError(
CONTROL_SURFACE_SCHEMA_CODES.UNSUPPORTED_VERSION,
{ context: { version } }
);
}
const next = applyMigration(step, current);
const nextEnvelope = DEFAULT_VERSIONED.safeParse(next);
if (!nextEnvelope.success || nextEnvelope.data.version !== step.to) {
throw new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED, {
context: {
from: step.from,
expected: step.to,
reason: "bad_output",
},
});
}
current = next;
version = nextEnvelope.data.version;
}
const versioned = ControlSurfaceVersionedSchema.safeParse(current);
if (!versioned.success) {
throw new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED, {
context: {
issues: versioned.error.issues.length,
reason: "invalid_final",
},
});
}
const finalised = { ...versioned.data, version: CURRENT_SCHEMA_VERSION };
const result = validateSurface(finalised);
if (result.error !== undefined) {
throw new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED, {
cause: result.error,
context: { reason: "invalid_final" },
});
}
return result.surface;
}
function applyMigration(step: Migration, input: unknown): unknown {
try {
return step.apply(input);
} catch (error) {
throw new SchemaError(CONTROL_SURFACE_SCHEMA_CODES.UPGRADE_FAILED, {
context: { from: step.from, to: step.to, reason: "threw" },
cause: error,
});
}
}

View File

@@ -0,0 +1,5 @@
export type {
ValidateOptions,
ValidateResult,
} from "./validate.js";
export { validateSurface } from "./validate.js";

View File

@@ -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.

View File

@@ -0,0 +1,357 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
flatTransform,
makeKnob,
makeRoot,
makeSurface,
} from "../__fixtures__/fixtures.js";
import { CURRENT_SCHEMA_VERSION } from "../constants/constants.js";
import { CONTROL_SURFACE_SCHEMA_CODES } from "../error-codes/error-codes.js";
import type { Group } from "../types/control-node.types.js";
import { validateSurface } from "./validate.js";
describe("validateSurface — happy path", () => {
it("accepts the minimal empty surface", { timeout: 5000 }, () => {
const result = validateSurface({
id: "s",
name: "x",
version: CURRENT_SCHEMA_VERSION,
createdAt: 0,
updatedAt: 0,
skin: { id: "default" },
root: {
type: "group",
id: "root",
layout: { kind: "absolute" },
transform: { position: { x: 0, y: 0 } },
children: [],
},
});
expect(result.error).toBeUndefined();
expect(result.surface?.id).toBe("s");
});
it("accepts a control leaf as the surface root", { timeout: 5000 }, () => {
const result = validateSurface(
makeSurface({
type: "control",
id: "c",
kind: "knob",
transform: flatTransform(),
})
);
expect(result.surface).toBeDefined();
});
});
describe("validateSurface — rejection", () => {
it("rejects an empty object with INVALID_DOCUMENT", {
timeout: 5000,
}, () => {
const result = validateSurface({});
expect(result.error?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT
);
});
it("rejects unknown ControlKind", { timeout: 5000 }, () => {
const malformed: unknown = {
...makeSurface(makeRoot() as Group),
root: {
type: "group",
id: "root",
layout: { kind: "absolute" },
transform: flatTransform(),
children: [
{
type: "control",
id: "k",
kind: "not-a-thing",
transform: flatTransform(),
},
],
},
};
const result = validateSurface(malformed);
expect(result.error?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT
);
});
it("rejects unknown root version", { timeout: 5000 }, () => {
const result = validateSurface({
...makeSurface(makeRoot() as Group),
version: "99.0.0",
});
expect(result.error?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT
);
});
it("rejects duplicate ids with DUPLICATE_ID", { timeout: 5000 }, () => {
const surface = makeSurface(
makeRoot([makeKnob("x"), makeKnob("x")]) as Group
);
const result = validateSurface(surface);
expect(result.error?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.DUPLICATE_ID
);
expect(result.error?.context).toEqual({ ids: ["x"] });
});
});
describe("validateSurface — binding variants", () => {
it("round-trips a pluginParam binding through JSON", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([
makeKnob("k", {
kind: "pluginParam",
pluginId: "p1",
paramPath: "channels/0/volume",
curve: "log",
}),
]) as Group
);
const validated = validateSurface(surface);
expect(validated.surface).toBeDefined();
const json = JSON.parse(JSON.stringify(validated.surface));
const reparsed = validateSurface(json);
expect(reparsed.surface).toEqual(validated.surface);
});
it("accepts every leaf binding variant", { timeout: 5000 }, () => {
const surface = makeSurface(
makeRoot([
makeKnob("a", {
kind: "pluginParam",
pluginId: "p",
paramPath: "x",
}),
makeKnob("b", { kind: "transport", field: "tempo" }),
makeKnob("c", {
kind: "midiOut",
portId: "p",
channel: 1,
cc: 7,
}),
makeKnob("d", {
kind: "midiIn",
portId: "p",
channel: 1,
cc: 7,
}),
makeKnob("e", { kind: "dmx", universe: 0, channel: 1 }),
]) as Group
);
expect(validateSurface(surface).surface).toBeDefined();
});
it("accepts a flat compound binding", { timeout: 5000 }, () => {
const surface = makeSurface(
makeRoot([
makeKnob("k", {
kind: "compound",
bindings: [
{ kind: "pluginParam", pluginId: "p", paramPath: "v" },
{
kind: "midiOut",
portId: "p",
channel: 0,
cc: 1,
},
],
}),
]) as Group
);
expect(validateSurface(surface).surface).toBeDefined();
});
it("rejects a nested compound binding", { timeout: 5000 }, () => {
const malformed: unknown = {
...makeSurface(makeRoot() as Group),
root: {
type: "group",
id: "root",
layout: { kind: "absolute" },
transform: flatTransform(),
children: [
{
type: "control",
id: "k",
kind: "knob",
transform: flatTransform(),
binding: {
kind: "compound",
bindings: [{ kind: "compound", bindings: [] }],
},
},
],
},
};
const result = validateSurface(malformed);
expect(result.error?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT
);
});
it("accepts pluginParam with custom curve points", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([
makeKnob("k", {
kind: "pluginParam",
pluginId: "p",
paramPath: "x",
curve: {
kind: "custom",
points: [
{ at: 0, value: 0 },
{ at: 1, value: 1 },
],
},
}),
]) as Group
);
expect(validateSurface(surface).surface).toBeDefined();
});
});
describe("validateSurface — layout z-axis discipline", () => {
it("rejects nonzero z under a grid layout", { timeout: 5000 }, () => {
const root: Group = {
type: "group",
id: "root",
layout: { kind: "grid", columns: 4, rows: 4, gap: 8 },
transform: flatTransform(),
children: [
{
type: "control",
id: "k",
kind: "knob",
transform: { position: { x: 0, y: 0, z: 1 } },
},
],
};
const result = validateSurface(makeSurface(root));
expect(result.error?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT
);
});
it("accepts nonzero z under free3d", { timeout: 5000 }, () => {
const root: Group = {
type: "group",
id: "root",
layout: { kind: "free3d" },
transform: flatTransform(),
children: [
{
type: "control",
id: "k",
kind: "knob",
transform: { position: { x: 0, y: 0, z: 1 } },
},
],
};
expect(validateSurface(makeSurface(root)).surface).toBeDefined();
});
it("rejects z under a free3d > grid descendant", { timeout: 5000 }, () => {
const inner: Group = {
type: "group",
id: "inner",
layout: { kind: "grid", columns: 1, rows: 1, gap: 0 },
transform: flatTransform(),
children: [
{
type: "control",
id: "k",
kind: "knob",
transform: { position: { x: 0, y: 0, z: 1 } },
},
],
};
const root: Group = {
type: "group",
id: "root",
layout: { kind: "free3d" },
transform: flatTransform(),
children: [inner],
};
const result = validateSurface(makeSurface(root));
expect(result.error?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT
);
});
it("accepts position.z = 0 explicitly", { timeout: 5000 }, () => {
const root: Group = {
type: "group",
id: "root",
layout: { kind: "grid", columns: 1, rows: 1, gap: 0 },
transform: flatTransform(),
children: [
{
type: "control",
id: "k",
kind: "knob",
transform: { position: { x: 0, y: 0, z: 0 } },
},
],
};
expect(validateSurface(makeSurface(root)).surface).toBeDefined();
});
it("ignores z-axis on a non-group root", { timeout: 5000 }, () => {
const surface = makeSurface({
type: "control",
id: "lone",
kind: "knob",
transform: { position: { x: 0, y: 0, z: 5 } },
});
expect(validateSurface(surface).surface).toBeDefined();
});
});
describe("validateSurface — pluginCatalog cross-check", () => {
it("succeeds when every reference is in the catalog", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([
makeKnob("k", {
kind: "pluginParam",
pluginId: "p1",
paramPath: "v",
}),
]) as Group
);
expect(
validateSurface(surface, { pluginCatalog: new Set(["p1"]) }).surface
).toBeDefined();
});
it("rejects with BINDING_REFERENCE_MISSING when catalog omits a reference", {
timeout: 5000,
}, () => {
const surface = makeSurface(
makeRoot([
makeKnob("k", {
kind: "pluginParam",
pluginId: "p99",
paramPath: "v",
}),
]) as Group
);
const result = validateSurface(surface, {
pluginCatalog: new Set(["p1"]),
});
expect(result.error?.code).toBe(
CONTROL_SURFACE_SCHEMA_CODES.BINDING_REFERENCE_MISSING
);
expect(result.error?.context).toEqual({ missing: "p99" });
});
});

View File

@@ -0,0 +1,115 @@
import type { z } from "zod";
import {
CONTROL_SURFACE_SCHEMA_CODES,
SchemaError,
} from "../error-codes/error-codes.js";
import {
checkBindingReferences,
checkUniqueIds,
} from "../identity/identity.js";
import { ControlSurfaceSchema } from "../schemas/control-surface-schema.js";
import type { ControlNode } from "../types/control-node.types.js";
import type { ControlSurface } from "../types/control-surface.types.js";
export interface ValidateOptions {
/**
* Optional set of valid `pluginId` strings. When supplied, every binding
* reference is cross-checked and missing references reject with
* `BINDING_REFERENCE_MISSING`.
*/
readonly pluginCatalog?: ReadonlySet<string>;
}
export type ValidateResult =
| { readonly surface: ControlSurface; readonly error?: undefined }
| { readonly surface?: undefined; readonly error: SchemaError };
function hasZ(node: ControlNode): boolean {
const z = node.transform.position.z;
return z !== undefined && z !== 0;
}
function visitForZ(
node: ControlNode,
allowed: boolean,
issues: string[]
): void {
if (!allowed && hasZ(node)) {
issues.push(
`Non-zero position.z on node ${node.id} outside a free3d layout`
);
}
if (node.type !== "group") return;
const childAllowed = node.layout.kind === "free3d";
for (const child of node.children) visitForZ(child, childAllowed, issues);
}
/**
* Enforce the spec rule "only `free3d` admits z-axis transforms on its
* descendants." Returns a list of human-readable issues (empty when OK).
*/
function checkLayoutZAxisDiscipline(surface: ControlSurface): string[] {
const issues: string[] = [];
const root = surface.root;
const rootAllowed =
root.type === "group" ? root.layout.kind === "free3d" : false;
// The root itself is not constrained — only the children of a non-free3d
// group are. Visit the root with `allowed=true` so its own z is ignored,
// then recurse with the appropriate per-layout allowance.
if (root.type === "group") {
for (const child of root.children)
visitForZ(child, rootAllowed, issues);
}
return issues;
}
/**
* Parse + cross-validate a candidate control-surface document.
*
* On success returns `{ surface }`; on any failure returns `{ error }` with
* one of the `CONTROL_SURFACE_SCHEMA.*` codes attached.
*/
export function validateSurface(
input: unknown,
options: ValidateOptions = {}
): ValidateResult {
const parsed = ControlSurfaceSchema.safeParse(input);
if (!parsed.success) {
return {
error: new SchemaError(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT,
{ context: { issues: toIssueContext(parsed.error) } }
),
};
}
const surface = parsed.data as ControlSurface;
const zIssues = checkLayoutZAxisDiscipline(surface);
if (zIssues.length > 0) {
return {
error: new SchemaError(
CONTROL_SURFACE_SCHEMA_CODES.INVALID_DOCUMENT,
{ context: { issues: zIssues } }
),
};
}
const idError = checkUniqueIds(surface);
if (idError !== undefined) return { error: idError };
if (options.pluginCatalog !== undefined) {
const refError = checkBindingReferences(surface, options.pluginCatalog);
if (refError !== undefined) return { error: refError };
}
return { surface };
}
function toIssueContext(
error: z.ZodError
): ReadonlyArray<{ readonly path: string; readonly message: string }> {
return error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
}));
}

View File

@@ -0,0 +1,2 @@
export type { SurfaceVisitor } from "./walk.js";
export { walkSurface } from "./walk.js";

View 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.

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flatTransform, makeSurface } from "../__fixtures__/fixtures.js";
import type {
Control,
ControlNode,
Group,
} from "../types/control-node.types.js";
import { walkSurface } from "./walk.js";
function group(id: string, children: ReadonlyArray<ControlNode>): Group {
return {
type: "group",
id,
layout: { kind: "absolute" },
transform: flatTransform(),
children,
};
}
function control(id: string): Control {
return { type: "control", id, kind: "knob", transform: flatTransform() };
}
describe("walkSurface", () => {
it("visits a single-root surface once with empty parent stack", {
timeout: 5000,
}, () => {
const root = group("root", []);
const visited: Array<{ id: string; parents: string[] }> = [];
walkSurface(makeSurface(root), (node, parents) =>
visited.push({ id: node.id, parents: parents.map((p) => p.id) })
);
expect(visited).toEqual([{ id: "root", parents: [] }]);
});
it("visits root → A → [B, C → [D]] → E in DFS pre-order", {
timeout: 5000,
}, () => {
const tree = group("root", [
group("A", [control("B"), group("C", [control("D")])]),
control("E"),
]);
const ids: string[] = [];
walkSurface(makeSurface(tree), (n) => ids.push(n.id));
expect(ids).toEqual(["root", "A", "B", "C", "D", "E"]);
});
it("passes the root-first parent stack to every visit", {
timeout: 5000,
}, () => {
const tree = group("root", [group("A", [control("B")])]);
const stacks = new Map<string, string[]>();
walkSurface(makeSurface(tree), (node, parents) => {
stacks.set(
node.id,
parents.map((p) => p.id)
);
});
expect(stacks.get("root")).toEqual([]);
expect(stacks.get("A")).toEqual(["root"]);
expect(stacks.get("B")).toEqual(["root", "A"]);
});
it("visits nine nodes when a group contains two groups with three controls each", {
timeout: 5000,
}, () => {
const tree = group("root", [
group("g1", [control("c1"), control("c2"), control("c3")]),
group("g2", [control("c4"), control("c5"), control("c6")]),
]);
let count = 0;
walkSurface(makeSurface(tree), () => {
count += 1;
});
expect(count).toBe(9);
});
});

View File

@@ -0,0 +1,41 @@
import type { ControlNode, Group } from "../types/control-node.types.js";
import type { ControlSurface } from "../types/control-surface.types.js";
/**
* Visitor callback signature for `walkSurface`.
*
* @param node - the node currently being visited
* @param parents - root-first stack of ancestors (empty for the root)
*/
export type SurfaceVisitor = (
node: ControlNode,
parents: ReadonlyArray<Group>
) => void;
function visitNode(
node: ControlNode,
parents: ReadonlyArray<Group>,
visitor: SurfaceVisitor
): void {
visitor(node, parents);
if (node.type === "group") {
const nextParents = [...parents, node];
for (const child of node.children) {
visitNode(child, nextParents, visitor);
}
}
}
/**
* Depth-first traversal of every node in a `ControlSurface`.
*
* The visitor is called exactly once per node, in pre-order DFS, with the
* root-first parent stack. Mutating the surface during traversal is
* undefined behaviour — the visitor is read-only by contract.
*/
export function walkSurface(
surface: ControlSurface,
visitor: SurfaceVisitor
): void {
visitNode(surface.root, [], visitor);
}

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { CURRENT_SCHEMA_VERSION } from "../src/constants/constants.js";
import { CONTROL_KIND_VALUES } from "../src/types/control-kind.types.js";
import type {
Control,
ControlNode,
Group,
} from "../src/types/control-node.types.js";
import type { ControlSurface } from "../src/types/control-surface.types.js";
import { validateSurface } from "../src/validate/validate.js";
import { walkSurface } from "../src/walk/walk.js";
const idArb = fc.uuidV(7).map((s) => s.toString());
const controlKindArb = fc.constantFrom(...CONTROL_KIND_VALUES);
const controlLeafArb: fc.Arbitrary<Control> = fc.record({
type: fc.constant("control" as const),
id: idArb,
kind: controlKindArb,
transform: fc.record({
position: fc.record({
x: fc.integer({ min: -1000, max: 1000 }),
y: fc.integer({ min: -1000, max: 1000 }),
}),
}),
});
const nodeArb: fc.Arbitrary<ControlNode> = fc.letrec<{
node: ControlNode;
group: Group;
}>((tie) => ({
node: fc.oneof({ depthSize: "small" }, controlLeafArb, tie("group")),
group: fc.record({
type: fc.constant("group" as const),
id: idArb,
layout: fc.constant({ kind: "absolute" as const }),
transform: fc.record({
position: fc.record({
x: fc.integer({ min: -100, max: 100 }),
y: fc.integer({ min: -100, max: 100 }),
}),
}),
children: fc.array(tie("node"), { maxLength: 4 }),
}),
})).node;
const surfaceArb: fc.Arbitrary<ControlSurface> = fc.record({
id: idArb,
name: fc.string({ minLength: 1, maxLength: 16 }),
version: fc.constant(CURRENT_SCHEMA_VERSION),
createdAt: fc.integer({ min: 0, max: 2 ** 40 }),
updatedAt: fc.integer({ min: 0, max: 2 ** 40 }),
skin: fc.record({ id: fc.constant("default") }),
root: nodeArb,
});
function dedupeIds(
node: ControlNode,
seen: Set<string>,
depth = 0
): ControlNode {
let id = node.id;
while (seen.has(id)) id = `${node.id}-${seen.size}-${depth}`;
seen.add(id);
if (node.type === "control") return { ...node, id };
return {
...node,
id,
children: node.children.map((c) => dedupeIds(c, seen, depth + 1)),
};
}
describe("property tests", () => {
it("validator accepts well-formed random surfaces", {
timeout: 5000,
}, () => {
fc.assert(
fc.property(surfaceArb, (s) => {
const surface: ControlSurface = {
...s,
root: dedupeIds(s.root, new Set()),
};
const result = validateSurface(surface);
if (result.error !== undefined) {
throw result.error;
}
expect(result.surface).toBeDefined();
}),
{ numRuns: 50 }
);
});
it("walkSurface visits every node exactly once", { timeout: 5000 }, () => {
fc.assert(
fc.property(surfaceArb, (s) => {
const surface: ControlSurface = {
...s,
root: dedupeIds(s.root, new Set()),
};
const validated = validateSurface(surface);
expect(validated.surface).toBeDefined();
const seen = new Set<string>();
walkSurface(validated.surface!, (n) => seen.add(n.id));
let count = 0;
const tally = (n: ControlNode): void => {
count += 1;
if (n.type === "group") {
for (const c of n.children) tally(c);
}
};
tally(validated.surface!.root);
expect(seen.size).toBe(count);
}),
{ numRuns: 30 }
);
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CURRENT_SCHEMA_VERSION } from "../src/constants/constants.js";
import type { ControlSurface } from "../src/types/control-surface.types.js";
import { validateSurface } from "../src/validate/validate.js";
import { walkSurface } from "../src/walk/walk.js";
const fixture: ControlSurface = {
id: "round-trip",
name: "8-channel mixer",
version: CURRENT_SCHEMA_VERSION,
createdAt: 1700_000_000_000,
updatedAt: 1700_000_001_000,
skin: { id: "default", vars: { "--accent": "#0af" } },
root: {
type: "group",
id: "root",
layout: { kind: "stack", direction: "row", gap: 4 },
transform: { position: { x: 0, y: 0 } },
children: Array.from({ length: 8 }, (_, channel) => ({
type: "group",
id: `ch-${channel}`,
layout: { kind: "stack", direction: "column", gap: 2 },
transform: { position: { x: channel * 60, y: 0 } },
children: [
{
type: "control",
id: `ch-${channel}-vol`,
kind: "slider-v",
transform: { position: { x: 0, y: 0 } },
range: { min: 0, max: 1, default: 0.75, units: "lin" },
binding: {
kind: "pluginParam",
pluginId: "mixer",
paramPath: `channels/${channel}/volume`,
curve: "log",
},
},
{
type: "control",
id: `ch-${channel}-mute`,
kind: "button-latching",
transform: { position: { x: 0, y: 80 } },
binding: {
kind: "midiOut",
portId: "main",
channel: 0,
cc: 16 + channel,
},
},
],
})),
},
};
describe("round-trip — JSON → validate → walk → re-serialise", () => {
it("preserves every node through JSON.parse(JSON.stringify(...)) and re-validation", {
timeout: 5000,
}, () => {
const first = validateSurface(fixture);
expect(first.surface).toBeDefined();
const jsonString = JSON.stringify(first.surface);
const parsed = JSON.parse(jsonString);
const second = validateSurface(parsed);
expect(second.surface).toEqual(first.surface);
const ids: string[] = [];
walkSurface(second.surface!, (n) => ids.push(n.id));
// 1 root + 8 channel groups + 8 volume + 8 mute = 25
expect(ids.length).toBe(25);
expect(ids[0]).toBe("root");
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"error-codes": "./src/error-codes/index.ts",
constants: "./src/constants/index.ts",
"schemas/control-surface-schema":
"./src/schemas/control-surface-schema.ts",
"validate/validate": "./src/validate/validate.ts",
"upgrade/upgrade": "./src/upgrade/upgrade.ts",
"walk/walk": "./src/walk/walk.ts",
"identity/identity": "./src/identity/identity.ts",
},
formats: ["es"],
},
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "zod", "tslib"],
},
outDir: "./dist",
},
plugins: [dts({ tsconfigPath: "./tsconfig.lib.json", entryRoot: "./src" })],
});

View File

@@ -0,0 +1,6 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 5_000,
additionalCoverageExcludes: ["src/__fixtures__/**"],
});