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