@cyberart-io/engine 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/audio.md ADDED
@@ -0,0 +1,152 @@
1
+ # Audio unlock broker and cues
2
+
3
+ Page-level unlock, channel mix, and frame-stepped cue scheduling. One user gesture unlocks authorized carts onto a single audio graph. Cue traces are deterministic; **PCM output is not**.
4
+
5
+ Back to the [package README](../README.md). Related: [presentation cue](presentation-cue.md) (scheduling), [asset resolver](asset-resolver.md) (`ASSET_READY_EVENT` / `ASSET_FAILED_EVENT`), [runtime group](runtime-group.md), [deterministic mode](deterministic-mode.md).
6
+
7
+ ## One-command reproduce (this repo)
8
+
9
+ ```bash
10
+ pnpm exec vitest run packages/engine/src/canvas/cyb-67-audio.repro.spec.ts
11
+ ```
12
+
13
+ ## Why this exists
14
+
15
+ Browser autoplay policy, load timing, and several carts on one page make it easy to start extra `AudioContext`s or invent per-cart mute/timing rules. Hosts create **one** `createAudioBroker` and pass it into `createRuntime` / `createRuntimeGroup`. Carts that only set `metadata.audio: 'tone'` keep the existing `start()` / `unlockAudio()` path when the broker is omitted.
16
+
17
+ Silent carts never import Tone. Destroying a cart tears down that participant’s channels; it does not close Tone for remaining carts. There is still **one audio graph per page**.
18
+
19
+ ## `createAudioBroker(options?)`
20
+
21
+ ```ts
22
+ import { createAudioBroker, createRuntime, createRuntimeGroup } from '@cyberart-io/engine';
23
+
24
+ const broker = createAudioBroker({
25
+ // tests inject a no-op; default dynamically loads the Tone adapter
26
+ toneStart: async () => undefined,
27
+ reducedSensory: false,
28
+ });
29
+
30
+ await broker.unlock(); // first call runs toneStart; later calls share status
31
+ broker.authorize('effects');
32
+ broker.authorize('ambience');
33
+
34
+ const group = createRuntimeGroup({
35
+ audioBroker: broker,
36
+ participants: [/* … */],
37
+ });
38
+ ```
39
+
40
+ | Member | Meaning |
41
+ |---|---|
42
+ | `unlock()` | Idempotent. First call runs `toneStart`. Default dynamically loads the Tone adapter: `load()` when `navigator.userAgent` matches `/Headless/i` (same as `AnimationManager.unlockAudio`), otherwise `unlock()`. Returns `{ state, error? }`. |
43
+ | `status()` | `locked` / `unlocking` / `unlocked` / `failed`. |
44
+ | `authorize(id)` / `revoke(id)` / `isAuthorized(id)` | Runtime-group participant ids. Empty set = all cues allowed (single-cart host). |
45
+ | `setChannelGain(id, gain)` | Channel gain in `0…1`. |
46
+ | `setPriority(id, n)` | Higher-priority active cues duck lower-priority channels (`DEFAULT_DUCK_GAIN` = `0.25`). |
47
+ | `mute()` / `unmute()` | Page mute. Cues still record; playback is skipped (`audio.cue.skipped`, reason `muted`). |
48
+ | `muteChannel(id)` / `unmuteChannel(id)` | Per-channel mute. |
49
+ | `duck(id, gain?)` / `unduck(id)` | Manual duck; `unduck` restores auto priority ducking. |
50
+ | `effectiveGain(id)` | `0` when page/channel muted, else `gain * duckGain`. |
51
+ | `handleHostEvent(event)` | Records `ASSET_READY_EVENT` / `ASSET_FAILED_EVENT` by payload `id`. |
52
+ | `teardown(participantId)` | Drop that cart’s authorization, channels, and active cues. Does **not** close Tone. |
53
+ | `inspect()` | JSON-serializable `{ status, reducedSensory, muted, authorized, channels, assets }`. |
54
+ | `destroy()` | Tear down all participants and listeners. Unlock status stays `unlocked` if it was; the shared context is left running. |
55
+
56
+ `reducedSensory: true` skips playback and still records cue events (`audio.cue.skipped`, reason `reduced-sensory`). It does **not** pass presentation `reducedMotion`, so authored `delayFrames` still appear on `atFrame`.
57
+
58
+ `createAudioCueTimeline()` with **no** broker starts cues (no unauthorized skip). Pass a broker when you need authorization, mute, or asset readiness.
59
+
60
+ Repeat (`CueSpec.repeat`) keeps the audio record across cycles: each presentation `cue.started` can emit another `audio.cue.started`, and `noteCueEnded` runs at the end of a cycle so ducking does not stick after the first loop.
61
+
62
+ `createRuntime({ audioBroker, audioParticipantId })` authorizes that id, routes mailbox `ASSET_*` events into the broker, and `teardown`s the id on `runtime.destroy()`. `createRuntimeGroup({ audioBroker })` passes each participant id. Carts that only declare `metadata.audio: 'tone'` still unlock on `start()` / `unlockAudio()` when **no** broker is passed.
63
+
64
+ ## `createAudioCueTimeline(options?)` / `scheduleAudioCue`
65
+
66
+ Composes [`createPresentationTimeline`](presentation-cue.md). Drive `step` from the same clock as deterministic `cart.step`. Duplicate policy, delay, and easing are the presentation cue’s.
67
+
68
+ ```ts
69
+ import {
70
+ createAudioCueTimeline,
71
+ scheduleAudioCue,
72
+ AUDIO_CUE_SCHEDULED_EVENT,
73
+ AUDIO_CUE_STARTED_EVENT,
74
+ } from '@cyberart-io/engine';
75
+
76
+ const timeline = createAudioCueTimeline({
77
+ broker,
78
+ originFrame: 0,
79
+ reducedSensory: false,
80
+ });
81
+
82
+ scheduleAudioCue(timeline, {
83
+ name: 'overlay',
84
+ idempotencyKey: 'ripple-overlay',
85
+ assetId: 'overlay',
86
+ participantId: 'ambience',
87
+ channelId: 'ambience',
88
+ durationFrames: 4,
89
+ delayFrames: 1,
90
+ onDuplicate: 'ignore',
91
+ });
92
+
93
+ timeline.step(6);
94
+ ```
95
+
96
+ `AudioCueSpec` is `CueSpec` plus `assetId` (required), `channelId?`, `participantId?`, `priority?`, `gain?`. `channelId` defaults to `participantId` or `'master'`.
97
+
98
+ | `onDuplicate` | Effect |
99
+ |---|---|
100
+ | `replace` (presentation default) | Replace the live cue; emit a new `audio.cue.scheduled`. |
101
+ | `ignore` | Keep the existing cue; no second `scheduled`. |
102
+ | `reject` | `{ ok: false, reason: 'duplicate' }`. |
103
+
104
+ ## Cue events (stable names)
105
+
106
+ | `type` | When |
107
+ |---|---|
108
+ | `audio.cue.scheduled` | `play` / `scheduleAudioCue` accepted |
109
+ | `audio.cue.started` | Presentation start, authorized, not muted, asset ready |
110
+ | `audio.cue.skipped` | Reduced-sensory, muted, or unauthorized (reasons above) |
111
+ | `audio.cue.failed` | Failed audio asset (`asset-failed`) or cart teardown (`torn-down`) |
112
+
113
+ Each event: `{ type, atFrame, name, idempotencyKey, assetId, channelId, participantId?, reason?, progress }`. Two identical `play` / `step` / asset-event tapes produce identical lists.
114
+
115
+ A failed `ASSET_FAILED_EVENT` for the cue’s `assetId` emits `audio.cue.failed` and does not wait on I/O. Unknown assets stay scheduled until ready, failed, or the presentation cue completes (then `asset-failed` so `step` cannot hang).
116
+
117
+ ## Headless adapter
118
+
119
+ Event-only. No Web Audio. Import from **`@cyberart-io/engine/headless`** (also re-exported from `@cyberart-io/engine`).
120
+
121
+ ```ts
122
+ import { createHeadlessAudioAdapter } from '@cyberart-io/engine/headless';
123
+
124
+ const adapter = createHeadlessAudioAdapter({ originFrame: 0 });
125
+ await adapter.unlock();
126
+ adapter.handleHostEvent({
127
+ type: 'cyberart.asset.ready',
128
+ kind: 'state',
129
+ payload: { id: 'overlay' },
130
+ });
131
+ adapter.play({ /* AudioCueSpec */ });
132
+ adapter.step(4);
133
+ const { events } = adapter.snapshot();
134
+ adapter.destroy();
135
+ ```
136
+
137
+ Replay two runs with the same seed/tape and compare `events`. Do not compare speakers, meters, or decoded PCM — those are not deterministic across machines or browsers.
138
+
139
+ ## Host wiring
140
+
141
+ ```ts
142
+ const broker = createAudioBroker();
143
+ button.addEventListener('click', () => broker.unlock());
144
+
145
+ const runtime = createRuntime({
146
+ container,
147
+ audioBroker: broker,
148
+ audio: 'tone', // still required for start() to unlock when the cart declares it
149
+ });
150
+ ```
151
+
152
+ `runtime.unlockAudio()` / `cart.start()` call `broker.unlock()` when the cart (or `createRuntime({ audio })` hint) needs audio. A silent cart still never imports Tone.
@@ -0,0 +1,112 @@
1
+ # Executable modules
2
+
3
+ Trusted, versioned factories behind a host allowlist. The engine never `eval`s or `new Function`s untrusted strings. Unknown ids, wrong versions, and unauthorized refs fail closed without invoking. A throwing module does not stop the next allowlisted call.
4
+
5
+ Related: [capability manifest](capability-manifest.md) (`modules.refs`).
6
+
7
+ Back to the [package README](../README.md).
8
+
9
+ ## One-command reproduce (this repo)
10
+
11
+ ```bash
12
+ pnpm exec vitest run packages/engine/src/canvas/cyb-27-executable-module.repro.spec.ts
13
+ ```
14
+
15
+ ## `createExecutableModuleHost(options)`
16
+
17
+ ```ts
18
+ import { createExecutableModuleHost } from '@cyberart-io/engine';
19
+
20
+ const overlayCalls = { n: 0 };
21
+ const host = createExecutableModuleHost({
22
+ allowlist: [
23
+ { id: 'overlay-fx', version: '1.0.0' },
24
+ { id: 'host.module', version: '1.0.0' },
25
+ ],
26
+ limits: { maxInvokeMs: 16, maxInvokesPerTurn: 4 },
27
+ capabilities: { seed: '0x27' },
28
+ modules: [
29
+ {
30
+ id: 'overlay-fx',
31
+ version: '1.0.0',
32
+ create: (capabilities) => ({
33
+ invoke: (input, { signal }) => {
34
+ if (signal.aborted) return;
35
+ overlayCalls.n += 1;
36
+ return { seed: capabilities.seed, input };
37
+ },
38
+ }),
39
+ },
40
+ {
41
+ id: 'host.module',
42
+ version: '1.0.0',
43
+ create: () => ({
44
+ invoke: () => {
45
+ throw new Error('host.module boom');
46
+ },
47
+ }),
48
+ },
49
+ ],
50
+ });
51
+
52
+ const allowed = await host.invoke({ id: 'overlay-fx', version: '1.0.0' }, { tape: 'cyb-27' });
53
+ // allowed.ok === true — overlay-fx ran
54
+
55
+ await host.invoke({ id: 'leak.module', version: '1.0.0' });
56
+ // { ok: false, error: { code: 'unknown-module' | 'not-allowlisted' } }
57
+
58
+ await host.invoke({ id: 'overlay-fx', version: '2.0.0' });
59
+ // { ok: false, error: { code: 'version-mismatch' } } — factory is not called
60
+
61
+ const boom = await host.invoke({ id: 'host.module', version: '1.0.0' });
62
+ // boom.error.code === 'invoke-failed'; overlay-fx still runs next:
63
+ await host.invoke({ id: 'overlay-fx', version: '1.0.0' });
64
+
65
+ host.beginTurn();
66
+ host.destroy();
67
+ ```
68
+
69
+ Register only trusted `create` functions you compiled with the host. Carts declare required refs on the capability manifest; they do not ship source strings into this API.
70
+
71
+ ### Options
72
+
73
+ | Option | Default | Meaning |
74
+ |---|---|---|
75
+ | `allowlist` | required | `{ id, version }[]`. `id` may be a dotted segment pattern (`host.*`). **Version is always exact** — `*` never loads. |
76
+ | `modules` | required | Trusted factories keyed by exact `id` + `version`. Duplicate refs throw. |
77
+ | `limits.maxInvokeMs` | none | Cooperative timeout. `invoke` receives `AbortSignal`; hanging thenables fail with `timeout`. |
78
+ | `limits.maxInvokesPerTurn` | none | Allowlisted invoke budget. Reset with `beginTurn()`. |
79
+ | `capabilities` | `{}` | Frozen bag passed into each factory. Nested plain objects are frozen; class instances stay shared handles. |
80
+
81
+ Per-registration `capabilities` overlay the host bag, then the result is frozen.
82
+
83
+ ### Handle
84
+
85
+ | Member | Meaning |
86
+ |---|---|
87
+ | `load(ref)` | Instantiate the factory if the exact ref is registered and allowlisted. Idempotent. |
88
+ | `invoke(ref, input?)` | `load` if needed, then call `invoke`. Returns `{ ok: true, value }` or `{ ok: false, error }`. |
89
+ | `beginTurn()` | Increment the turn counter and reset `maxInvokesPerTurn`. |
90
+ | `inspect()` | Allowlist, registered/loaded refs, turn, diagnostics. |
91
+ | `destroy()` | Drop the registry, abort in-flight signals, call optional instance `destroy()`. Idempotent. |
92
+
93
+ `error.code` is one of: `invalid-ref`, `unknown-module`, `version-mismatch`, `not-allowlisted`, `load-failed`, `invoke-failed`, `timeout`, `rate-limited`, `destroyed`. Failures are recorded on `inspect().diagnostics`. Sibling modules and the cart continue.
94
+
95
+ ## Isolation
96
+
97
+ This boundary is **in-process**. Factories run on the cart’s thread; there is no worker, iframe, or separate origin. Isolation is the allowlist, exact id+version matching, a frozen capability bag, invoke budgets, and `{ ok: false }` on throw so a sibling can still run. Do not pass untrusted source strings. Untrusted code needs a host-owned worker/iframe **outside** this API.
98
+
99
+ ## Capability manifest
100
+
101
+ Optional additive key (omitted on existing carts):
102
+
103
+ ```ts
104
+ modules: {
105
+ refs: [
106
+ { id: 'overlay-fx', version: '1.0.0' },
107
+ { id: 'host.module', version: '1.0.0' },
108
+ ],
109
+ }
110
+ ```
111
+
112
+ `validateCapabilityManifest` returns `unsupported-module` when the host does not list a required exact ref on `host.modules.refs`.
package/docs/midi.md ADDED
@@ -0,0 +1,128 @@
1
+ # MIDI controller
2
+
3
+ Host-owned live MIDI in and out. Carts and hosts construct `MidiManager`. The runtime does not pass it into `getDefaultState`. This is controller I/O (Web MIDI), not MIDI file playback.
4
+
5
+ Capability manifests already list `midi` under `integrations`. Related: [capability manifest](capability-manifest.md).
6
+
7
+ Back to the [package README](../README.md).
8
+
9
+ ## One-command reproduce (this repo)
10
+
11
+ ```bash
12
+ pnpm exec vitest run packages/engine/src/canvas/cyb-19-midi.repro.spec.ts
13
+ ```
14
+
15
+ ## Why this exists
16
+
17
+ A piece that maps a keyboard or fader to parameters needs the same API in a browser (real ports) and in CI (no hardware). `inject` delivers inbound note / CC / pitch without `navigator.requestMIDIAccess`. `send` writes note-on/off, CC, pitch, or raw bytes through an injectable port. Missing Web MIDI or a denied permission is a structured result, so silent carts keep running.
18
+
19
+ ## `new MidiManager(options?)`
20
+
21
+ ```ts
22
+ import { MidiManager } from '@cyberart-io/engine';
23
+
24
+ const sent: number[][] = [];
25
+ const midi = new MidiManager({
26
+ output: {
27
+ send(data) {
28
+ sent.push([...data]);
29
+ },
30
+ },
31
+ });
32
+
33
+ const stop = midi.subscribe('note', (message) => {
34
+ if (message.kind === 'noteon') {
35
+ // message.channel 0–15, message.note, message.velocity, message.status
36
+ }
37
+ });
38
+
39
+ midi.inject({ kind: 'noteon', channel: 0, note: 60, velocity: 100 });
40
+ midi.sendNoteOn(0, 64, 90);
41
+ midi.sendCc(3, 1, 127);
42
+ midi.send([0xf8]);
43
+
44
+ const access = await midi.requestAccess();
45
+ // jsdom / Node: { ok: false, reason: 'unavailable' }
46
+ // user gesture denied: { ok: false, reason: 'denied' }
47
+ // browser with ports: { ok: true, inputs, outputs, sysexEnabled }
48
+
49
+ stop();
50
+ midi.destroy();
51
+ ```
52
+
53
+ Headless and jsdom have no `navigator.requestMIDIAccess`. Inject and send still work when you pass a test `output`.
54
+
55
+ ### Options
56
+
57
+ | Option | Default | Meaning |
58
+ |---|---|---|
59
+ | `output` | none | Port used by `send`. Tests pass a recording fake. When omitted, `requestAccess` adopts the first hardware output and re-adopts after hotplug if that port disconnects. A constructor `output` is never replaced. |
60
+ | `requestMIDIAccess` | `navigator.requestMIDIAccess` | Override for tests (fake access or a rejecting function). |
61
+
62
+ `statechange` on the access object attaches new inputs and **detaches disconnected ports immediately** (not only on `destroy()`). Stale `midimessage` listeners are removed when the input leaves `access.inputs`.
63
+
64
+ ### Inbound
65
+
66
+ | Member | Meaning |
67
+ |---|---|
68
+ | `subscribe(kind, listener)` | `kind`: `note` (on and off) \| `cc` \| `pitch` \| `raw` \| `*`. Returns unsubscribe. |
69
+ | `inject(input)` | Deliver without hardware. Voice fields, `Uint8Array`, or raw `number[]`. No-op after `destroy`. |
70
+
71
+ ### Outbound
72
+
73
+ | Member | Meaning |
74
+ |---|---|
75
+ | `sendNoteOn(channel, note, velocity?)` | Status `0x90 \| channel`. Velocity default 100. |
76
+ | `sendNoteOff(channel, note, velocity?)` | Status `0x80 \| channel`. Velocity default 0. |
77
+ | `sendCc(channel, controller, value)` | Status `0xB0 \| channel`. |
78
+ | `sendPitch(channel, value)` | 14-bit pitch bend `0–16383` (center `8192`). Status `0xE0 \| channel`. |
79
+ | `send(bytes)` | Raw bytes through the port. |
80
+
81
+ Each send returns `{ ok: true, data }` or `{ ok: false, reason: 'no-port' \| 'invalid' \| 'destroyed' }`. Out-of-range channel (not 0–15), 7-bit data, or pitch is `invalid`, not a throw.
82
+
83
+ ### Access and teardown
84
+
85
+ | Member | Meaning |
86
+ |---|---|
87
+ | `requestAccess({ sysex? })` | Wraps Web MIDI when present. Never throws. |
88
+ | `destroy()` | Removes hardware listeners and subscribers. Idempotent. |
89
+
90
+ `requestAccess` results:
91
+
92
+ | Result | When |
93
+ |---|---|
94
+ | `{ ok: true, inputs, outputs, sysexEnabled }` | Access granted. Inputs are attached; a constructor `output` is kept. |
95
+ | `{ ok: false, reason: 'unavailable' }` | No `navigator.requestMIDIAccess` (headless / jsdom / insecure context). |
96
+ | `{ ok: false, reason: 'denied' }` | The request rejected (permission or security). |
97
+ | `{ ok: false, reason: 'destroyed' }` | `destroy()` ran before the promise settled, or after teardown. |
98
+
99
+ ## Types, channels, status bytes
100
+
101
+ Channel is **0–15** (MIDI channels 1–16) in the status low nibble. Command lives in the high nibble.
102
+
103
+ | Export | Value | Role |
104
+ |---|---|---|
105
+ | `MIDI_NOTE_OFF` | `0x80` | Note Off command |
106
+ | `MIDI_NOTE_ON` | `0x90` | Note On command |
107
+ | `MIDI_CONTROL_CHANGE` | `0xB0` | Control Change command |
108
+ | `MIDI_PITCH_BEND` | `0xE0` | Pitch Bend command |
109
+ | `MIDI_CHANNEL_MIN` / `MIDI_CHANNEL_MAX` | `0` / `15` | Valid channel range |
110
+ | `MIDI_DATA_MAX` | `127` | Max 7-bit data byte (note, velocity, CC) |
111
+ | `MIDI_PITCH_CENTER` / `MIDI_PITCH_MAX` | `8192` / `16383` | 14-bit pitch bend |
112
+ | `midiStatus(command, channel)` | `(command & 0xF0) \| (channel & 0x0F)` | Build a status byte |
113
+ | `midiChannelFromStatus(status)` | `status & 0x0F` | Channel 0–15 from a status byte |
114
+ | `isMidiChannel(value)` | boolean | Integer 0–15 |
115
+ | `isMidiData(value)` | boolean | Integer 0–127 (`MIDI_DATA_MAX`) |
116
+ | `encodeMidiMessage` / `parseMidiBytes` | bytes ↔ `MidiMessage` | Shared by inject and hardware |
117
+
118
+ `MidiMessage` kinds: `noteon` / `noteoff` / `cc` / `pitch` / `raw`. Each carries `status` and a copied `data` `Uint8Array`. Note-on with velocity `0` parses as `noteoff` (MIDI convention); the status byte stays `0x9n`.
119
+
120
+ Public types for the `requestMIDIAccess` option (tests inject a fake; browsers pass the real Web MIDI objects):
121
+
122
+ | Type | Role |
123
+ |---|---|
124
+ | `MidiRequestAccess` | `(options?: { sysex?: boolean }) => Promise<MidiAccessLike>` |
125
+ | `MidiAccessLike` | `inputs` / `outputs` (`forEach`), `sysexEnabled`, optional `statechange` |
126
+ | `MidiInputLike` | `addEventListener` / `removeEventListener` for `midimessage` (`event.data`) |
127
+
128
+ Hosts that need a controller construct `MidiManager` themselves. Do not add it to `getDefaultState`.
@@ -0,0 +1,102 @@
1
+ # Versioned snapshots
2
+
3
+ Portable save envelope for carts and embedding hosts. Schema **1** is the previous engine-owned `CartStateBundle` (`CART_STATE_BUNDLE_VERSION = 1`). Schema **2** wraps that blob in `engineState` and keeps host-owned data in `hostState` / `hostStateRef`. Cyberart migrates envelopes in memory; the host owns persistence (local storage, a database, cross-user saves). This package is not the database of record.
4
+
5
+ Related: [deterministic mode](deterministic-mode.md) (`rng` / clock), [capability manifest](capability-manifest.md) (cart id). Back to the [package README](../README.md).
6
+
7
+ ## One-command reproduce (this repo)
8
+
9
+ ```bash
10
+ pnpm exec vitest run packages/engine/src/canvas/cyb-50-snapshots.repro.spec.ts
11
+ ```
12
+
13
+ ## Scheme (`SNAPSHOT_SCHEMA_VERSION = 2`)
14
+
15
+ `ENGINE_SNAPSHOT_RUNTIME` is `'0.0.5'`. It is a snapshot runtime id, independent of the npm package version — bump whichever one the change actually belongs to.
16
+
17
+ | Field | Meaning |
18
+ |---|---|
19
+ | `schemaVersion` | Envelope schema. Current **2**. |
20
+ | `runtimeVersion` | `ENGINE_SNAPSHOT_RUNTIME` at save. |
21
+ | `cart` | `{ id, version, generative? }` — exact cart version (`'0'` if unversioned). |
22
+ | `modules` | Optional `{ id, version }[]`. |
23
+ | `seed` | Deterministic token hash. |
24
+ | `rng` | Optional `RandomState` (sfc32 snapshot). Recorded when available; cart `importState` still does not replay the PRNG. |
25
+ | `clock` | `framesElapsed`, optional `elapsedSinceStart` / `now` / `frameRate`. |
26
+ | `engineState` | The existing `CartStateBundle` (bundle `version` remains **1**). |
27
+ | `hostState` | Host-owned JSON payload. Opaque to the engine. |
28
+ | `hostStateRef` | Host persistence pointer (slot id, URL, …). Not loaded by the engine. |
29
+ | `assets` | Optional `{ id, version?, ref? }[]` content version references. |
30
+ | `createdAt` | ISO-8601 creation time. Migrated v1 bundles use `1970-01-01T00:00:00.000Z`. |
31
+ | `provenance` | Optional `{ source?, integrity?: { alg, hash } }`. Hosts fill integrity; the engine does not hash. |
32
+
33
+ `cart.id` is required. `snapshotFromCartBundle` and the 1→2 migration fail closed when the legacy bundle has no `cartId` — they do not stamp `'unknown'`.
34
+
35
+ `defineSnapshot` / `parseSnapshot` / `validateSnapshot` return `{ ok: true, snapshot }` or `{ ok: false, errors }`. They do not throw. JSON round-trip: `JSON.parse(JSON.stringify(snapshot))` equals the snapshot.
36
+
37
+ Legacy cart bundles (no `schemaVersion`, have `version` / `seed` / `framesElapsed` / `state`) parse as schema **1**. `exportState` / `importState` still use that blob so existing carts keep working. `importState` also accepts a current envelope: it migrates if needed and restores `engineState` only.
38
+
39
+ ## Migrations
40
+
41
+ ```ts
42
+ import {
43
+ applySnapshotMigrations,
44
+ createEngineSnapshotMigrations,
45
+ createSnapshotMigrationRegistry,
46
+ defineSnapshot,
47
+ parseSnapshot,
48
+ } from '@cyberart-io/engine';
49
+
50
+ const defined = defineSnapshot({
51
+ cart: { id: 'art.cart', version: '3' },
52
+ seed: '0x' + '50'.repeat(32),
53
+ clock: { framesElapsed: 12 },
54
+ engineState: {
55
+ version: 1,
56
+ cartId: 'art.cart',
57
+ seed: '0x' + '50'.repeat(32),
58
+ framesElapsed: 12,
59
+ state: { n: 12 },
60
+ },
61
+ hostState: { sceneId: 'alpha' },
62
+ createdAt: '2026-01-15T00:00:00.000Z',
63
+ });
64
+ if (!defined.ok) throw new Error(defined.errors.map((e) => e.detail).join('; '));
65
+
66
+ const registry = createSnapshotMigrationRegistry({
67
+ migrations: createEngineSnapshotMigrations(),
68
+ });
69
+ const migrated = applySnapshotMigrations(legacyBundle, registry);
70
+ ```
71
+
72
+ `applySnapshotMigrations` clones the input, walks `from → from+1` until schema 2, and validates each step. The original object is left unchanged, including when a step is missing or a migrate function fails.
73
+
74
+ | Diagnostic `code` | When |
75
+ |---|---|
76
+ | `invalid-json` | `parseSnapshot` received unparseable text. |
77
+ | `invalid-snapshot` | Root value is not a JSON object, or a legacy blob is missing. |
78
+ | `invalid-field` | A required string/number/object field is the wrong shape. |
79
+ | `invalid-schema` | `validateSnapshot` / `defineSnapshot` saw the wrong `schemaVersion`. |
80
+ | `invalid-engine-state` | Nested `CartStateBundle` failed `parseCartStateBundle`. |
81
+ | `unsupported-schema` | `schemaVersion` is newer than 2. |
82
+ | `missing-migration` | No registered step from the snapshot's schema to the next integer. |
83
+ | `invalid-migration-result` | A `migrate` function threw, produced the wrong schema, or failed validation. |
84
+
85
+ Disjoint live cart state still throws `IncompatibleCartStateError` from `importState` / `importSnapshot` after the envelope has been migrated. Missing envelope paths surface as `missing-migration` diagnostics; `importSnapshot` wraps those details in `IncompatibleCartStateError`.
86
+
87
+ The current engine table is only 1→2 (`SNAPSHOT_SCHEMA_VERSION` is 2). When a later envelope schema ships, compose host migrations after that table; each step must advance by exactly one. Duplicate `from` values throw when the registry is created.
88
+
89
+ ## Runtime
90
+
91
+ ```ts
92
+ const envelope = await cart.exportSnapshot({
93
+ cartVersion: '3',
94
+ hostState: { sceneId: 'alpha' },
95
+ modules: [{ id: 'overlay.module', version: '1' }],
96
+ });
97
+ await cart.importSnapshot(envelope);
98
+ await cart.importState(legacyBundle); // still a CartStateBundle
99
+ await cart.importState(envelope); // envelope → engineState
100
+ ```
101
+
102
+ `exportSnapshot` wraps `exportState()` plus clock / rng. `importSnapshot` always migrates. Hosts restore `hostState` themselves. W/E localStorage helpers still store the cart bundle; `parseStoredCartState` migrates a stored envelope first and returns null if the path is missing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyberart-io/engine",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "CyberArt host engine: mount carts, events, capability manifests, geometry, runtime groups, and a Node/jsdom headless entry.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",