JEPA4Japan · tutorials

Chapter 13: Time Machines and Old Boxes

3,345 words 16 min read #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Use transactions for undo/redo, validation and idempotent migrations for versioned data, and keep document, session, and presence separate.

Course progress Course outline 18 of 18 lessons available

Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates

  1. 01 Chapter 1: Do Not Draw Yet—Canvas Is Not a Product Architecture available now
  2. 02 Chapter 2: A Sheet of Pixels That Forgets available now
  3. 03 Chapter 3: Turn Drawing into a Replayable Recipe available now
  4. 04 Chapter 4: Four Maps and a Camera available now

Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools

  1. 05 Chapter 5: Give the Pixel World a Registry available now
  2. 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
  3. 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
  4. 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
  5. 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now

Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery

  1. 10 Chapter 10: Make the Editor Feel Right available now
  2. 11 Chapter 11: Drawn Text Is Not Editable Text available now
  3. 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
  4. 13 Chapter 13: Time Machines and Old Boxes Current lesson
  5. 14 Chapter 14: Looking Correct Is Not Being Correct available now

Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI

  1. 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
  2. 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades available now
  3. 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
  4. 18 Chapter 18: People and AI Edit the Same Ledger available now

Start with a game a five-year-old can understand

The one truth in this chapter: History handles time within the current play session; Migration handles time across versions of old boxes.

Prepare a toy car, three photos of a tabletop, and three cardboard boxes. First, have the child push the car from A to B, then from B to C. A photo records each step, so pressing “go back” once can return the car from C to B; that is Undo within the current session. Next, label the boxes v1, v2, and v3: the v1 box says only “red car,” v2 also records its position, and v3 replaces the color with a structured paint label. If you find a v1 box three years later, you must not hand its missing fields directly to today’s game rules. Inspect it first, then replace the boxes one version at a time.

Predict the answer before continuing: if the child drags continuously from A to B and their hand passes through 87 points, should one Undo move the car back one pixel or all the way to A? It should return to A, because one gesture is one Transaction. Now predict whether it makes sense to photograph “the cursor is hovering over the car” and restore that later when reopening the box. It does not; that was only a fleeting state on the tabletop.

  1. Complete one actionPush from A to B
  2. Keep a reversible recordOne photo per gesture
  3. Inspect the old box labelReject corrupt data
  4. Replace each box in orderv1 → v2 → v3
  5. Change the lock only when completeAtomic save
Say the result first: if the power fails halfway through migration, can the old box still be opened? Yes. Never overwrite the old slot until the new slot has been written successfully.

This game has two timelines. The photo stack belongs to the history of actions since the page was opened; version numbers belong to the history of the data format. Undo cannot replace Migration, and Migration should not manufacture a sequence of user actions that can be undone.

Translate the toys into Canvas

Toy worldCanvas LabExact responsibility
The intent to push the carCommandDomain actions such as MoveShape and ResizeShape
Points the hand actually passed throughOperation / transient previewHigh-frequency execution detail that does not enter History one item at a time
Difference between before and after photosPatchA data change that can be applied and reversed
A photo of the entire tabletopSnapshotThe complete Document at a revision
One press-to-release gestureTransactionAn atomic commit and one Undo Step
Moving three cars togetherBatchMultiple Commands that all succeed or all fail
Combining 87 in-progress photosCoalescingOne continuous Drag becomes one History entry
Reversing only this child’s actionLocal Undo / Undo ScopeDoes not erase changes a remote user made later
The v1 box labelSchema VersionThe persistence format, not the application version
The box inspectorValidationValidates untrusted input before it reaches runtime
The box-replacement lineUp MigrationConverts an old format one version at a time into the current format
Returning to an old boxDown MigrationOffered only when it is explicitly reversible and tested
A backpack beside the tableSessionCurrent page, Camera, and Selection; may be saved locally
A waving hand’s positionPresenceCursor and online state; safe to discard when stale

Where the analogy stops: a real Patch is not a photograph. It must handle stable IDs, references, concurrency, and failure. A real IndexedDB Transaction is not a business Undo Transaction either; both merely require “everything succeeds or everything fails.” Snapshots are easy to restore but large. An Operation Log is easy to synchronize incrementally but requires replay, compaction, and a versioned protocol. The fact that boxes can be swapped backward in the analogy does not mean every production Migration can go Down: splitting, deleting, or encrypting data may be irreversible.

Kill the wrong intuitions first

  • “Calling history.push on every pointermove is the most faithful approach.” One user intention becomes dozens of Undo steps while memory and collaboration traffic explode. Preview changes at high frequency; commit only on release.
  • “Saving JSON.stringify(engineState) is enough.” Engine state mixes Camera, Hover, DOM references, and caches. Durable Document, Local Session, and Ephemeral Presence have different lifetimes and permissions.
  • “A TypeScript type means JSON does not need validation.” Types disappear after compilation. Values arriving from IndexedDB, the server, the clipboard, and old clients are all untrusted.
  • “A Migration can mutate the object it just read.” An exception halfway through leaves a half-new, half-old object. Each step should be a pure function: preserve its input and validate its output again.
  • “Always delete Unknown Records.” An old client may silently consume records written by a new client. Either quarantine them while preserving the original bytes or refuse to open the document in writable mode.
  • “A successful Autosave means the Asset succeeded.” Binary assets from Chapter 12 have an independent lifecycle from Document Records; references need a ready/pending/missing protocol.
  • “Undo simply restores an old Snapshot.” In multiplayer work, that also erases remote changes. Local Undo must reverse a local Transaction and revalidate it against the current authoritative state.

Production backpack

Prerequisites

Chapter 5 provides stable IDs and Document/Page/Shape/Asset/Binding records. Chapter 9 defines the Transaction Boundary between Preview and Commit. Chapter 12 provides Asset state and secure import. History accepts only validated Commands and never reads Renderer pixels. The persistence boundary accepts only the current DocumentV3; every external Payload goes through unknown → validate envelope → migrate → validate current first.

Formal knowledge

A Command expresses what the user wants to do, an Operation expresses the smallest execution-level change, a Patch preserves before and after values, and a Snapshot preserves complete state. Create, Move, Resize, and Delete each implement apply and invert. A Batch validates all child commands first, then increments the revision once. A Transaction captures its baseline on Pointer Down, updates only Preview while moving, and commits on Pointer Up. Coalescing combines continuous updates sharing the same transactionId. A successful new local Command clears the Redo stack.

Undo Scope must be explicit: one page or the entire Document, whether it crosses a Page Switch, and whether Selection is restored along with the action. Canvas Lab chooses “global Document History; Page Switch does not enter History; after Undo, Session navigates to the affected page and selects the affected IDs.” Hover, Cursor, camera animation, alignment guides, and uncommitted text are Transient State. Selection History stores only stable-ID hints, filters them after deletion, and never mixes Session into a Document Patch.

Autosave uses Debounce to absorb commits that happen close together, but a flush before the page becomes hidden or closes is only best effort; beforeunload is not a reliable database. Crash Recovery uses two IndexedDB slots: the committed pointer always refers to the previous complete Envelope. After staging has been fully written and validated, the new revision must be written and the pointer switched within the same readwrite transaction. If the transaction aborts or the process crashes, continue reading the old pointer; never overwrite the only old copy first. If two tabs save concurrently, revision-conditional writes or a single-writer protocol are also required. IndexedDB’s transaction atomicity does not automatically prevent business-level lost updates. Server Persistence then performs a conditional write with {documentId, revision, schemaVersion, checksum}. A conflict must never silently become Last-Write-Wins.

The Schema includes record discriminators, required fields, numeric ranges, reference rules, and schemaVersion. Validation checks both structure and semantics: unique IDs, acyclic Parents, existing Pages, and valid Asset references. An Up Migration crosses exactly one version, such as v1→v2 or v2→v3. Each step should be deterministic, accept only its declared source version, preserve its input, and validate its output. Use Idempotence precisely here: v1ToV2(v1) is not itself a function that can be applied twice, because the second input is no longer v1. What must be idempotent is the recovery entry point—the current version does not change when passed through loadDocument again, and rerunning from the original committed payload after a crash produces the same result. Down Migration is not a default promise. When a release must be rolled back, identify which new records cannot be represented and refuse a downgrade write. Handle Forward Compatibility with read-only opening, an Unknown Records quarantine, and a backup of the original Payload; never pretend to understand it.

Document is the shared, durable, migratable source of truth. Session is one user’s Camera, current Page, Selection, and Tool on one device; it may be saved separately and locally. Presence is Cursor, Viewport, and online state with a TTL; broadcast it, but never put it in a recovery snapshot. All three may be represented as Records, but their scope, storage location, permissions, and cleanup policy must remain different.

Evidence and compatibility (verified 2026-08-29)

IndexedDB provides transactional object storage in the browser; verify its API and availability constraints in the MDN IndexedDB API. Page lifecycle and background freezing cannot rely only on unload events; see Chrome Page Lifecycle. structuredClone() can copy many structures, but it is not Schema Validation; see supported types in the MDN structured clone documentation. The server must still validate permissions, versions, and semantics. Client validation only reports errors earlier; it is not a trust boundary.

Engineering increment for this chapter

Starting point: Chapter 12’s Document can only be exported manually as JSON, and Drag mutates records directly in progress. Finish line: Create/Move/Resize/Delete can be undone; a continuous Drag is one Undo; v1/v2 migrate safely to v3; Autosave recovers from crashes; Session is stored separately.

Add these interfaces and files:

  • src/engine/history/History.ts: Transaction, Batch, Coalescing, Undo/Redo;
  • src/engine/persistence/schema.ts: versioned Envelope and runtime validation;
  • src/engine/persistence/migrations.ts: pure v1→v2→v3 functions;
  • src/engine/persistence/DocumentRepository.ts: two-slot Autosave and Crash Recovery;
  • src/engine/session/SessionRepository.ts: stores only Page/Camera/Selection;
  • src/engine/persistence/__tests__/migrations.test.ts: independent Migration evidence.

The following is an executable minimal core. It does not hide critical migration logic behind ellipses:

type ShapeV1 = { id: string; type: 'rect'; x: number; y: number; color?: string };
type DocV1 = { schemaVersion: 1; shapes: ShapeV1[] };
type ShapeV2 = ShapeV1 & { w: number; h: number };
type DocV2 = { schemaVersion: 2; pageId: string; shapes: ShapeV2[] };
export type ShapeV3 = Omit<ShapeV2, 'color'> & { fill: { kind: 'solid'; color: string } };
export type DocV3 = {
  schemaVersion: 3;
  pages: Array<{ id: string; shapeIds: string[] }>;
  shapes: ShapeV3[];
};

const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null;
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const validId = (v: unknown): v is string => typeof v === 'string' && /^[a-z]+-[a-z0-9-]+$/.test(v);

function assertV1(v: unknown): asserts v is DocV1 {
  if (!object(v) || v.schemaVersion !== 1 || !Array.isArray(v.shapes))
    throw new Error('INVALID_V1');
  for (const s of v.shapes)
    if (!object(s) || !validId(s.id) || s.type !== 'rect' || !finite(s.x) || !finite(s.y))
      throw new Error('INVALID_V1_SHAPE');
}
function assertV2(v: unknown): asserts v is DocV2 {
  if (!object(v) || v.schemaVersion !== 2 || !validId(v.pageId) || !Array.isArray(v.shapes))
    throw new Error('INVALID_V2');
  for (const s of v.shapes)
    if (
      !object(s) ||
      !validId(s.id) ||
      s.type !== 'rect' ||
      !finite(s.x) ||
      !finite(s.y) ||
      !finite(s.w) ||
      !finite(s.h)
    )
      throw new Error('INVALID_V2_SHAPE');
}
export function assertV3(v: unknown): asserts v is DocV3 {
  if (!object(v) || v.schemaVersion !== 3 || !Array.isArray(v.pages) || !Array.isArray(v.shapes))
    throw new Error('INVALID_V3');
  const ids = new Set<string>();
  for (const s of v.shapes) {
    if (
      !object(s) ||
      !validId(s.id) ||
      ids.has(s.id) ||
      s.type !== 'rect' ||
      !finite(s.x) ||
      !finite(s.y) ||
      !finite(s.w) ||
      !finite(s.h) ||
      !object(s.fill) ||
      s.fill.kind !== 'solid' ||
      typeof s.fill.color !== 'string'
    )
      throw new Error('INVALID_V3_SHAPE');
    ids.add(s.id);
  }
  const pageIds = new Set<string>(),
    placed = new Set<string>();
  for (const p of v.pages) {
    if (!object(p) || !validId(p.id) || pageIds.has(p.id) || !Array.isArray(p.shapeIds))
      throw new Error('INVALID_V3_PAGE');
    pageIds.add(p.id);
    for (const id of p.shapeIds) {
      if (typeof id !== 'string' || !ids.has(id) || placed.has(id))
        throw new Error('INVALID_V3_PAGE');
      placed.add(id);
    }
  }
  if (placed.size !== ids.size) throw new Error('INVALID_V3_ORPHAN_SHAPE');
}

export function v1ToV2(input: DocV1): DocV2 {
  return {
    schemaVersion: 2,
    pageId: 'page-main',
    shapes: input.shapes.map((s) => ({ ...s, w: 120, h: 80 })),
  };
}
export function v2ToV3(input: DocV2): DocV3 {
  const shapes = input.shapes.map(({ color = '#64748b', ...s }) => ({
    ...s,
    fill: { kind: 'solid' as const, color },
  }));
  return {
    schemaVersion: 3,
    pages: [{ id: input.pageId, shapeIds: shapes.map((s) => s.id) }],
    shapes,
  };
}
export function loadDocument(raw: unknown): DocV3 {
  if (!object(raw) || !Number.isInteger(raw.schemaVersion)) throw new Error('INVALID_ENVELOPE');
  let current: unknown = structuredClone(raw);
  if ((current as { schemaVersion: number }).schemaVersion === 1) {
    assertV1(current);
    current = v1ToV2(current);
  }
  if ((current as { schemaVersion: number }).schemaVersion === 2) {
    assertV2(current);
    current = v2ToV3(current);
  }
  assertV3(current);
  return current;
}

type Patch = { id: string; before: ShapeV3 | null; after: ShapeV3 | null };
type Step = { transactionId: string; patches: Patch[]; pageId: string; selectedIds: string[] };
export class History {
  private undoStack: Step[] = [];
  private redoStack: Step[] = [];
  commit(step: Step) {
    this.undoStack.push(structuredClone(step));
    this.redoStack.length = 0;
  }
  undo(applyAtomically: (patches: Patch[]) => void): Step | undefined {
    const step = this.undoStack.at(-1);
    if (!step) return;
    applyAtomically(step.patches.map((p) => ({ id: p.id, before: p.after, after: p.before })));
    this.undoStack.pop();
    this.redoStack.push(step);
    return step;
  }
  redo(applyAtomically: (patches: Patch[]) => void): Step | undefined {
    const step = this.redoStack.at(-1);
    if (!step) return;
    applyAtomically(step.patches);
    this.redoStack.pop();
    this.undoStack.push(step);
    return step;
  }
}

Back to the toys: the 87 intermediate positions merely say where the child’s hand passed. commit({ transactionId }) is what takes a reversible photo. loadDocument is the box inspector; it never hands v1 directly to today’s Renderer. applyAtomically is not just a reassuring function name: the implementation must validate the entire Patch group against an invisible copy first, then replace the Document once. If it can mutate halfway and then throw, keeping the Step on the History stack cannot rescue a half-written Document.

Migration and History must be tested separately:

import { describe, expect, it } from 'vitest';
import { History } from '../../history/History';
import { loadDocument, v1ToV2, v2ToV3 } from '../migrations';

describe('versioned persistence', () => {
  const v1 = {
    schemaVersion: 1 as const,
    shapes: [{ id: 'shape-a', type: 'rect' as const, x: 1, y: 2, color: '#f00' }],
  };
  it('migrates v1 → v3 deterministically and does not change when loaded again', () => {
    const once = loadDocument(v1);
    expect(loadDocument(once)).toEqual(once);
    expect(v2ToV3(v1ToV2(v1))).toEqual(once);
  });
  it('commits one reversible step for a continuous drag', () => {
    const history = new History();
    const applied: unknown[] = [];
    history.commit({
      transactionId: 'drag-1',
      pageId: 'page-main',
      selectedIds: ['shape-a'],
      patches: [
        {
          id: 'shape-a',
          before: loadDocument(v1).shapes[0],
          after: { ...loadDocument(v1).shapes[0], x: 90 },
        },
      ],
    });
    history.undo((p) => applied.push(p));
    expect(applied).toHaveLength(1);
    expect((applied[0] as Array<{ after: { x: number } }>)[0].after.x).toBe(1);
  });
  it('rejects a broken reference instead of sending it to the Renderer', () => {
    expect(() =>
      loadDocument({
        schemaVersion: 3,
        pages: [{ id: 'page-main', shapeIds: ['shape-missing'] }],
        shapes: [],
      }),
    ).toThrow('INVALID_V3_PAGE');
  });
});

Run npm exec vitest run src/engine/persistence src/engine/history. Expect every Migration, recovery-entry idempotence, corruption isolation, Command Inversion, and “applyAtomically throws without moving the stack” test to pass. In a browser, run npm exec playwright test tests/crash-recovery.spec.ts. Force a reload separately while writing staging, writing the revision, and switching the committed pointer. Recovery may yield only the previous committed state or the complete new committed state—never half a Document. Then open two tabs and save from the same base revision. One must receive a conflict instead of silently overwriting the other.

Break it on purpose

Injected failureSymptomEvidenceFixRegression testRecovery
Crash halfway through stagingShapes or JSON are incompleteTwo-slot revision/checksumPromote to committed atomically only after validation completesReload fault testDiscard staging and read the old slot
v1 lacks xRenderer receives NaNINVALID_V1_SHAPEStructural validation at entryMissing-field fixtureQuarantine Payload and offer recovery
Run the recovery entry twiceDimensions grow twiceIdempotent snapshot diffA step accepts only its declared source version; current version returns directlyload(load(v1))Rerun from the original backup
Throw midway through v2→v3Half the records have fill; half have colorInput hash and missing outputBuild a new object in a pure function, then validateInject an exception at record NPreserve committed v2
Undo across a Page SwitchNothing appears to happenstep.pageId differs from Session pageNavigate and Focus the affected object after UndoTwo-page replayKeep Document consistent
Asset deleted while still referencedExport or rendering failsSemantic missing asset validationTombstone/fallback; never pretend it is readyDangling-asset fixtureShow Missing Asset
Old client opens data saved by new clientUnknown Record is consumedschemaVersion/protocol logsOld client is read-only or rejects explicitlyForward-version fixturePreserve original bytes and require upgrade

Pass with evidence

Automated evidenceManual evidencePassing condition
Migration fixtures, idempotence tests, Command Inversion, two-slot crash reloadOpen an old revision in the recovery UI and verify the notice, choices, and Document contentsAll automated tests pass; a person can identify the recovery source and no partial record exists
Static scope audit and Store integration testsSwitch pages, refresh, and use two tabs while observing Session and Presence lifetimesDocument, Session, and Presence never persist one another
Property to proveAuthoritative evidence
Create/Move/Resize/Delete are reversibleapply→invert property test for every Command
Drag occupies one stepOne History Step per transactionId
Old data never reaches runtime directlyloadDocument(unknown): DocV3 is the only entry point
Migration is independently reliablev1/v2 fixtures, idempotence, and fault-injection tests
Crash/concurrency never creates a partial write or silent overwriteTwo-slot reload, single-transaction pointer switch, two-tab revision-conflict test, and checksum
The three state scopes are not mixedScope audit of the Document/Session/Presence Stores
  • Migration has independent tests; input is preserved, output is validated, and repeated loading returns the same result.
  • Document and Session use different keys, Schemas, and Repositories; Presence has no durable write.
  • Hover, alignment guides, transient Preview, and Cursor never enter Undo or Autosave.
  • An old client never silently deletes an Unknown Record and writes the result back.
  • A new Command clears Redo; a failed Batch does not increment the revision.
  • Recovery UI explains which revision was restored instead of merely saying “something went wrong.”

Explain it to a five-year-old

Without using the words “Command,” “Transaction,” or “Migration,” explain this: why should pressing Back once return the car to where it began after you drag it continuously from the left side of the table to the right? Why can’t a three-year-old box be dumped directly onto today’s game table? Why shouldn’t the place where your friend is waving right now be sealed into the permanent box?

A good jargon-free answer

One complete push has one purpose, so after you let go we take one “before and after” photo. Going back once returns the car to where it was before that push. An old box has different compartments from today’s, so we first check for broken things, move everything one box at a time, and replace the original only when the new box is complete. The shared toy instructions should be saved for a long time, your own view can go into your personal backpack, and the place where your friend’s hand happens to be changes quickly, so it can simply be forgotten after they disconnect.