Timelinx

Dispatch Model

How dispatch() processes operations with rolling validation and invariant checks

dispatch() is the mutation gate for TimelineState. It receives the current state and a transaction, then returns either an accepted next state or a typed rejection. It does not mutate the input state, update history, notify subscribers, or render UI.

Pipeline

StepWhat happens
1. Transaction arrivesA labeled batch of one or more OperationPrimitive objects is passed to dispatch().
2. Validate rolling stateEach primitive is checked against the state produced by previous primitives in the same transaction.
3. Apply pure updateapplyOperation() creates a proposed immutable state using structural sharing.
4. Check invariantsThe whole proposed state is scanned for timeline-level violations.
5. Commit or rejectAccepted transactions bump timeline.version once; rejected transactions return the original state untouched.

The Actual Algorithm

Every call follows this order:

  1. Start with proposedState = state.
  2. For each operation in the transaction, validate that operation against the current proposedState.
  3. If validation fails, return { accepted: false, reason, message } immediately.
  4. If validation passes, apply that operation to produce the next rolling proposedState.
  5. After every operation has applied, run checkInvariants(proposedState).
  6. If invariants fail, reject with INVARIANT_VIOLATED.
  7. If invariants pass, freeze the returned containers, bump timeline.version once, and return { accepted: true, nextState }.

Rolling validation is intentional. A split edit can delete a clip, then insert the two replacement clips in the same transaction. The inserts validate against the post-delete state, so they do not falsely overlap the original clip.

import { dispatch } from '@timelinx/core';

const result = dispatch(currentState, transaction);

if (result.accepted) {
  render(result.nextState);
} else {
  report(result.reason, result.message);
}

Commit Semantics

  • Accepted transactions create a new TimelineState.
  • Rejected transactions leave the original state untouched.
  • timeline.version increments once per accepted transaction, not once per operation.
  • The dispatcher uses structural sharing where possible so selectors and React hooks can avoid unnecessary re-renders.
  • The returned asset registry is wrapped to reject runtime set, delete, and clear calls.

What Validation Catches Before Apply

Per-operation validators reject edits that are locally impossible:

  • Missing clips, tracks, markers, effects, keyframes, groups, or assets.
  • Locked tracks.
  • Overlaps on the target track.
  • Timeline and media bounds problems.
  • Track/media type mismatches.
  • Duplicate IDs.
  • Invalid speed, opacity, effect index, transition range, or marker range.

What Invariants Catch After Apply

Whole-document invariants catch structural problems in the proposed result:

AreaExamples
Identity and schemaSCHEMA_VERSION_MISMATCH and DUPLICATE_ID protect migrations and stable references.
Track structureTRACK_NOT_SORTED, OVERLAP, TRACK_TYPE_MISMATCH, INVALID_OPACITY, link group references, and track group references.
Clip timeMEDIA_BOUNDS_INVALID, DURATION_MISMATCH, CLIP_BEYOND_TIMELINE, SPEED_INVALID, and INVALID_RANGE.
Editorial metadataMARKER_OUT_OF_BOUNDS, IN_OUT_INVALID, BEAT_GRID_INVALID, CAPTION_OUT_OF_BOUNDS, and CAPTION_OVERLAP.
EffectsEFFECT_NOT_FOUND, KEYFRAME_NOT_FOUND, KEYFRAME_ORDER_VIOLATION, EFFECT_INDEX_OUT_OF_RANGE, and INVALID_RENDER_STAGE.

The dispatcher reports invariant failures through INVARIANT_VIOLATED and includes the collected violation messages in message.

Transaction Shape

const transaction = {
  id: 'tx-add-track-and-clip',
  label: 'Add track and first clip',
  timestamp: Date.now(),
  operations: [
    { type: 'ADD_TRACK', track },
    { type: 'INSERT_CLIP', trackId: track.id, clip },
  ],
};

Operations are ordered. Later operations can depend on earlier operations, and the whole batch remains atomic.

Where History Fits

History is deliberately outside dispatch():

const result = dispatch(state, transaction);

if (result.accepted) {
  history = pushHistory(history, result.nextState);
}

In React apps, TimelineEngine from @timelinx/react performs this coordination for you and publishes a new snapshot to subscribed hooks.

On this page