Timelinx

Transactions

Atomic multi-operation edits in @timelinx/core

A transaction is a labeled, atomic batch of operation primitives. It is the smallest unit of commit, undo history, user-facing labeling, and validation.

type Transaction = {
  readonly id: string;
  readonly label: string;
  readonly timestamp: number;
  readonly operations: readonly OperationPrimitive[];
};

Key Properties

PropertyMeaning
AtomicIf any primitive rejects, none of the transaction is committed. Callers keep rendering the previous state.
OrderedOperations apply in array order. Later operations validate against the rolling state from earlier operations.
History-friendlyOne accepted transaction should become one history entry. A split, ripple trim, or paste operation should undo as one user action.
SerializableTransactions are plain data. They can be logged, tested, compressed, replayed, or produced by tools and AI suggestion layers.

Operation Groups

GroupOperations
ClipMOVE_CLIP, RESIZE_CLIP, SLICE_CLIP, DELETE_CLIP, INSERT_CLIP, SET_MEDIA_BOUNDS, SET_CLIP_*.
TrackADD_TRACK, DELETE_TRACK, REORDER_TRACK, SET_TRACK_HEIGHT, SET_TRACK_NAME, blend and opacity updates.
AssetREGISTER_ASSET, UNREGISTER_ASSET, SET_ASSET_STATUS.
TimelineRENAME_TIMELINE, SET_TIMELINE_DURATION, SET_TIMELINE_START_TC, SET_SEQUENCE_SETTINGS.
Markers and rangesMarker operations, in/out points, and beat grid operations.
Creative metadataCaptions, effects, keyframes, transitions, link groups, track groups, transform, and audio properties.

These primitives are intentionally lower-level than UI actions. A UI action can map to one primitive or many.

Compound Edit Example

Split a clip by replacing it with two clips in one transaction:

const mediaSplit = toFrame(
  original.mediaIn + (splitFrame - original.timelineStart),
);

const result = dispatch(state, {
  id: 'tx-split-clip',
  label: 'Split interview clip',
  timestamp: Date.now(),
  operations: [
    { type: 'DELETE_CLIP', clipId: original.id },
    {
      type: 'INSERT_CLIP',
      trackId: track.id,
      clip: createClip({
        id: 'clip-left',
        assetId: original.assetId,
        trackId: track.id,
        timelineStart: original.timelineStart,
        timelineEnd: splitFrame,
        mediaIn: original.mediaIn,
        mediaOut: mediaSplit,
      }),
    },
    {
      type: 'INSERT_CLIP',
      trackId: track.id,
      clip: createClip({
        id: 'clip-right',
        assetId: original.assetId,
        trackId: track.id,
        timelineStart: splitFrame,
        timelineEnd: original.timelineEnd,
        mediaIn: mediaSplit,
        mediaOut: original.mediaOut,
      }),
    },
  ],
});

If either replacement clip is invalid, the original clip remains in place.

Ordering Patterns

Some edits require careful operation order:

  • Split: DELETE_CLIP before the replacement INSERT_CLIP operations.
  • Ripple delete: delete the target clip, then move following clips left.
  • Ripple insert: move following clips right, then insert the incoming clip.
  • Roll trim: resize the outgoing clip and incoming clip in the same transaction.
  • Slip: keep timeline bounds stable and update media bounds with SET_MEDIA_BOUNDS.

Tools should preview with provisional state during gestures and return a transaction on commit. The dispatcher remains the only place where the document changes.

Rejection Handling

const result = dispatch(state, transaction);

if (!result.accepted) {
  switch (result.reason) {
    case 'OVERLAP':
    case 'LOCKED_TRACK':
    case 'ASSET_MISSING':
      showInlineEditError(result.message);
      break;
    default:
      logUnexpectedRejection(result);
  }
}

Prefer handling specific reasons close to the UI action. For example, an insert operation can show an overlap affordance, while an ASSET_MISSING rejection can tell the user to re-link media.

On this page