Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 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
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes Current lesson
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
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.
- Complete one actionPush from A to B
- Keep a reversible recordOne photo per gesture
- Inspect the old box labelReject corrupt data
- Replace each box in orderv1 → v2 → v3
- Change the lock only when completeAtomic save
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 world | Canvas Lab | Exact responsibility |
|---|---|---|
| The intent to push the car | Command | Domain actions such as MoveShape and ResizeShape |
| Points the hand actually passed through | Operation / transient preview | High-frequency execution detail that does not enter History one item at a time |
| Difference between before and after photos | Patch | A data change that can be applied and reversed |
| A photo of the entire tabletop | Snapshot | The complete Document at a revision |
| One press-to-release gesture | Transaction | An atomic commit and one Undo Step |
| Moving three cars together | Batch | Multiple Commands that all succeed or all fail |
| Combining 87 in-progress photos | Coalescing | One continuous Drag becomes one History entry |
| Reversing only this child’s action | Local Undo / Undo Scope | Does not erase changes a remote user made later |
The v1 box label | Schema Version | The persistence format, not the application version |
| The box inspector | Validation | Validates untrusted input before it reaches runtime |
| The box-replacement line | Up Migration | Converts an old format one version at a time into the current format |
| Returning to an old box | Down Migration | Offered only when it is explicitly reversible and tested |
| A backpack beside the table | Session | Current page, Camera, and Selection; may be saved locally |
| A waving hand’s position | Presence | Cursor 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.pushon everypointermoveis 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/missingprotocol. - “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 failure | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Crash halfway through staging | Shapes or JSON are incomplete | Two-slot revision/checksum | Promote to committed atomically only after validation completes | Reload fault test | Discard staging and read the old slot |
v1 lacks x | Renderer receives NaN | INVALID_V1_SHAPE | Structural validation at entry | Missing-field fixture | Quarantine Payload and offer recovery |
| Run the recovery entry twice | Dimensions grow twice | Idempotent snapshot diff | A step accepts only its declared source version; current version returns directly | load(load(v1)) | Rerun from the original backup |
| Throw midway through v2→v3 | Half the records have fill; half have color | Input hash and missing output | Build a new object in a pure function, then validate | Inject an exception at record N | Preserve committed v2 |
| Undo across a Page Switch | Nothing appears to happen | step.pageId differs from Session page | Navigate and Focus the affected object after Undo | Two-page replay | Keep Document consistent |
| Asset deleted while still referenced | Export or rendering fails | Semantic missing asset validation | Tombstone/fallback; never pretend it is ready | Dangling-asset fixture | Show Missing Asset |
| Old client opens data saved by new client | Unknown Record is consumed | schemaVersion/protocol logs | Old client is read-only or rejects explicitly | Forward-version fixture | Preserve original bytes and require upgrade |
Pass with evidence
| Automated evidence | Manual evidence | Passing condition |
|---|---|---|
| Migration fixtures, idempotence tests, Command Inversion, two-slot crash reload | Open an old revision in the recovery UI and verify the notice, choices, and Document contents | All automated tests pass; a person can identify the recovery source and no partial record exists |
| Static scope audit and Store integration tests | Switch pages, refresh, and use two tabs while observing Session and Presence lifetimes | Document, Session, and Presence never persist one another |
| Property to prove | Authoritative evidence |
|---|---|
| Create/Move/Resize/Delete are reversible | apply→invert property test for every Command |
| Drag occupies one step | One History Step per transactionId |
| Old data never reaches runtime directly | loadDocument(unknown): DocV3 is the only entry point |
| Migration is independently reliable | v1/v2 fixtures, idempotence, and fault-injection tests |
| Crash/concurrency never creates a partial write or silent overwrite | Two-slot reload, single-transaction pointer switch, two-tab revision-conflict test, and checksum |
| The three state scopes are not mixed | Scope 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.