JEPA4Japan · チュートリアル

第5章:ピクセル世界に台帳を作る

5,073文字 16分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

安定したID、Scene Graph、Shape/Asset/Binding Record、Rendererから独立したドメイン変換境界を設計します。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 公開中
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 現在のレッスン
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 公開中
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 公開中
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 公開中

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 公開中
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 公開中
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 公開中
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 公開中

5歳児にもわかるゲームから始めよう

家族写真を1枚撮り、写真と家族台帳Cardの束を机へ置きます。写真からわかるのは「ここに3人いる」ことです。一方Cardには、一人ひとりの名前、変わらない番号、誰がどの家族に属するか、誰が誰の前に並ぶか、誰と誰が手をつないでいるかが書かれています。写真から一人を切り取ってみます。その人の名前と家族関係は、Cardから自動的に消えるでしょうか。写真を水彩画へ交換すると、その人の番号を変える必要があるでしょうか。

まず予想してください。2人の子どもがどちらも「青ちゃん」と呼ばれる場合、名前だけで同じ一人を安定して見つけられるでしょうか。AのCardに「私の親はB」とあり、BのCardにも「私の親はA」とある場合、親を上へたどり続けると何が起きるでしょう。

  1. 一意な番号を発行する見た目が変わってもIdentityは変わらない
  2. 関係を登録するParent/Child、順序、接続
  3. 台帳を検査するOrphanとCycleを拒否する
  4. Projectionを選ぶCanvasまたはDOM
写真が示すのは今日の見た目だけです。「それは誰か」に答えるのは台帳です。

本章で伝える唯一の真実は、Document Modelが事実のSourceであり、RendererはProjectionにすぎないということです。PixelにはStable ID、Parent/Child関係、Permission、Connection、Business Identityは含まれません。

おもちゃをCanvasに翻訳する

家族台帳の遊びCanvas Lab責任
台帳全体DocumentRecordDocument VersionとPage Entry
各家族のPagePageRecord1つのSceneとRoot Shapeの順序
人物CardShapeRecordStable ID、Type、Parent、Local Transform、Property
家族写真/添付物AssetRecordImageなどのResource Metadata
手をつなぐ関係BindingRecordConnectorとTargetのSemantic Connection
親と子Parent/Child Scene GraphLocal TransformがAncestorを通ってWorldになる
前列と後列Z-OrderDrawingとHitのPriorityを決める
Photographer一覧Renderer Registrytype → どうProjectionするか
身長を測る道具Geometry Registrytype → Geometry Query
今日誰を指しているかSession SelectionDurable Documentには属さない

比喩には限界があります。Shapeには現実の「親」がいない場合があり、Groupも人間ではありません。1つのAssetを複数のShapeが参照でき、BindingはParent/Childでもありません。現実の台帳Cardはあまり変わりませんが、Editor DocumentはCommandで頻繁にUpdateされます。この比喩が強調するのはIdentityとRelationshipであり、すべてのSubtreeをNested JSONで保存することではありません。RuntimeではNormalized Recordsを使い、ID単位でUpdateとValidationを行います。

まず誤った直感を捨てる

  • 「Canvasに描けたなら保存もできている」。Bitmapが保存するのは色だけです。Reload、Resize、Semantic Data Exportの際にObjectを取り戻せません。
  • 「CanvasRenderingContext2DをShapeへ入れると便利だ」。ContextはSerializeできず、ModelをRendererへ固定します。ShapeにはDomain Dataだけを保存します。
  • 「Selectionも一緒に保存すれば戻ったとき便利だ」。Selection、Hover、Cameraは通常Sessionです。Multiplayer Presenceはさらに別の層です。Documentへ混ぜるとUndo、共同編集、Persistenceを汚染します。
  • 「Object IDにはArray Indexを使う」。並べ替え、挿入、共同編集でIndexは変わります。IdentityはStableかつImmutableでなければなりません。
  • 「GroupはAbsolute座標を1つ保存すればよい」。子NodeにはLocal Transformが必要です。World TransformはScene Graphから得るDerived Stateであり、両方保存するとDriftします。
  • 「未知のShapeはすぐ捨てる」。新ClientのDataを旧Clientが開いたとき、Silent DeleteはDocumentを破壊します。元Recordを隔離・保持し、Compatibility Placeholderを表示します。

本番用バックパック

前提となる契約

前提は第4章のMatrix2DとSpace Namingです。Canvas LabではDocumentにDurable Stateだけを含めます。SessionState={selection,camera,activeTool}は分離し、Presenceは本章のDocumentへ一切入りません。すべてのWriteはCommand Boundaryを通り、RendererはRead-only Snapshotを受け取ります。Business Domain ObjectのIDはCanvas RecordへAdaptした後も保持します。

正式な知識

Stable IDはImmutableなIdentityであり、Display Nameではありません。DocumentはPage、Shape、Asset、Bindingを集約します。PageはVisible Sceneを指定します。ShapeはTypeとPropertyを持つVisual Recordです。GroupはParent/ChildでScene Graphを構成します。AssetはBinary Resourceを独立管理します。BindingはConnectorやPortなど、ShapeをまたぐRelationshipを表現します。Parent IDと各PageのRoot ListがTree/Forestを作るため、CycleやDocumentをまたぐDangling Referenceを禁止しなければなりません。

Z-Orderを偶然のObject Iteration順序に任せてはいけません。各ParentのchildIds順序、または明示的に比較可能なKeyを使えます。Conflict時には決定論的なTie-breakが必要です。Local TransformはRecordへ保存し、World TransformはAncestor Chainから得るDerived Stateです。Selection Bounds、World Bounds、Visibility、Cached PathもDerived Stateであり、Source of Truthへ書き戻しません。

Domain Modelは注文、倉庫、承認などのBusiness Conceptを記述します。Visual ModelはRectangle、Text、Connection Portを記述します。その間にAdapterを置きます。Business Domain Object → Canvas Document Record → Geometry/Renderer → Pixels or DOMです。1つのBusiness Objectが複数Shapeに対応する場合もあれば、ShapeがBusiness上の意味を持たない付箋である場合もあります。両者を直接同一視してはいけません。

Normalized RecordsはWire上でRecord ArrayまたはID Dictionaryとして表現でき、Runtime IndexにはMapを使います。Deserializationでは最初にSchema Validationを行い、その後Indexを作ってDuplicate ID、未知Record、Orphan、Cycle、Order Conflictを検査します。Immutable Identityとは、「ID変更」が通常のUpdateではないという意味です。Copyするなら新IDを作り、Bindingを明示的に修復します。

Renderer Registryはshape.type → ShapeRenderer、Geometry Registryはshape.type → ShapeGeometryProviderを担当します。RendererがなくてもGeometryとDocumentは動作でき、RendererはUnknown Placeholderを返せます。Command Boundaryは「動かしたい」という意図をMoveShapeCommandへ変換し、PermissionとPreconditionを検証してAtomicにCommitします。UIやRendererがMapを勝手に変更することを防ぎます。

根拠と互換性

ここで扱うCore TypeはJavaScript Dataだけに依存し、Canvas/DOMには依存しません。そのためNode Testで実行できます。Wire FormatにはJSONで表現できる値だけを使います。Map、Set、DOMMatrix、FunctionをJSON Documentとして直接保存することはできません。Process内SnapshotへstructuredCloneを使っても、Long-term Persistence Formatと取り違えてはいけません。

上記Sourceの確認日は2026-08-29です。Long-term Compatibilityは明示的なSchema Versionと後のMigrationが担います。JS Engineの偶然のObject Property順序やPrototypeに依存してはいけません。

この章のエンジニアリング増分

開始点: 第3章の一時的なShape[]にselectedIdsが混ざっています。到達点: Durable DocumentとSessionを分離し、5種類のRecord、2つのRegistry、Validation、Command InterfaceをCanvasなしのNode環境で実行できます。

canvas-lab/src/lab/ch05/
  records.ts
  document-store.ts
  registries.ts
  document-store.test.ts

完全なCore TypeとValidatorです。

export type Transform = Readonly<{
  x: number;
  y: number;
  rotation: number;
  scaleX: number;
  scaleY: number;
}>;
export type DocumentRecord = Readonly<{
  id: string;
  type: 'document';
  schemaVersion: 1;
  pageIds: readonly string[];
}>;
export type PageRecord = Readonly<{
  id: string;
  type: 'page';
  name: string;
  rootShapeIds: readonly string[];
}>;
export type ShapeRecord = Readonly<{
  id: string;
  type: 'shape';
  shapeType: string;
  pageId: string;
  parentId: string | null;
  childIds: readonly string[];
  transform: Transform;
  props: Readonly<Record<string, unknown>>;
  businessObjectId: string | null;
}>;
export type AssetRecord = Readonly<{
  id: string;
  type: 'asset';
  kind: 'image';
  source: string;
  width: number;
  height: number;
}>;
export type BindingRecord = Readonly<{
  id: string;
  type: 'binding';
  bindingType: string;
  fromShapeId: string;
  toShapeId: string;
  props: Readonly<Record<string, unknown>>;
}>;
export type AnyRecord = DocumentRecord | PageRecord | ShapeRecord | AssetRecord | BindingRecord;
export type WireDocument = Readonly<{ records: readonly AnyRecord[] }>;
export type RuntimeDocument = Readonly<{
  document: DocumentRecord;
  pages: ReadonlyMap<string, PageRecord>;
  shapes: ReadonlyMap<string, ShapeRecord>;
  assets: ReadonlyMap<string, AssetRecord>;
  bindings: ReadonlyMap<string, BindingRecord>;
  unknown: readonly AnyRecord[];
}>;
export type SessionState = {
  selectedShapeIds: ReadonlySet<string>;
  camera: { x: number; y: number; zoom: number };
  activeTool: string;
};

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isStringList(value: unknown): value is readonly string[] {
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function assertUnique(label: string, values: readonly string[]): void {
  if (new Set(values).size !== values.length) throw new Error(`${label} contains duplicate IDs`);
}
function parseRecords(input: unknown): AnyRecord[] {
  if (!isObject(input) || !Array.isArray(input.records))
    throw new Error('Document payload must contain a records array');
  const records: AnyRecord[] = [];
  for (const value of input.records) {
    if (!isObject(value) || typeof value.id !== 'string' || value.id.length === 0)
      throw new Error('Every record needs a non-empty string id');
    if (value.type === 'document') {
      if (value.schemaVersion !== 1 || !isStringList(value.pageIds))
        throw new Error(`Invalid document record: ${value.id}`);
    } else if (value.type === 'page') {
      if (typeof value.name !== 'string' || !isStringList(value.rootShapeIds))
        throw new Error(`Invalid page record: ${value.id}`);
    } else if (value.type === 'shape') {
      const parentIsValid = value.parentId === null || typeof value.parentId === 'string';
      const businessIdIsValid =
        value.businessObjectId === null || typeof value.businessObjectId === 'string';
      const transform = value.transform;
      const transformIsValid =
        isObject(transform) &&
        ['x', 'y', 'rotation', 'scaleX', 'scaleY'].every((key) => Number.isFinite(transform[key]));
      if (
        typeof value.shapeType !== 'string' ||
        typeof value.pageId !== 'string' ||
        !parentIsValid ||
        !isStringList(value.childIds) ||
        !transformIsValid ||
        !isObject(value.props) ||
        !businessIdIsValid
      )
        throw new Error(`Invalid shape record: ${value.id}`);
    } else if (value.type === 'asset') {
      if (
        value.kind !== 'image' ||
        typeof value.source !== 'string' ||
        !Number.isFinite(value.width) ||
        !Number.isFinite(value.height)
      )
        throw new Error(`Invalid asset record: ${value.id}`);
    } else if (value.type === 'binding') {
      if (
        typeof value.bindingType !== 'string' ||
        typeof value.fromShapeId !== 'string' ||
        typeof value.toShapeId !== 'string' ||
        !isObject(value.props)
      )
        throw new Error(`Invalid binding record: ${value.id}`);
    } else {
      throw new Error(`Unknown record type on ${value.id}`);
    }
    records.push(value as AnyRecord);
  }
  return records;
}

export function loadDocument(wire: unknown, knownShapeTypes: ReadonlySet<string>): RuntimeDocument {
  const records = parseRecords(wire);
  const ids = new Set<string>();
  for (const record of records) {
    if (ids.has(record.id)) throw new Error(`Duplicate id: ${record.id}`);
    ids.add(record.id);
  }
  const documents = records.filter((r): r is DocumentRecord => r.type === 'document');
  if (documents.length !== 1) throw new Error('Exactly one document record is required');
  const pages = new Map(
    records.filter((r): r is PageRecord => r.type === 'page').map((r) => [r.id, r]),
  );
  const allShapes = records.filter((r): r is ShapeRecord => r.type === 'shape');
  const shapes = new Map(allShapes.map((r) => [r.id, r]));
  const assets = new Map(
    records.filter((r): r is AssetRecord => r.type === 'asset').map((r) => [r.id, r]),
  );
  const bindings = new Map(
    records.filter((r): r is BindingRecord => r.type === 'binding').map((r) => [r.id, r]),
  );
  assertUnique('document.pageIds', documents[0].pageIds);
  const documentPageIds = new Set(documents[0].pageIds);
  for (const pageId of documentPageIds)
    if (!pages.has(pageId)) throw new Error(`Missing page: ${pageId}`);
  for (const page of pages.values()) {
    if (!documentPageIds.has(page.id)) throw new Error(`Page ${page.id} is outside the document`);
    assertUnique(`Page ${page.id} rootShapeIds`, page.rootShapeIds);
    for (const rootId of page.rootShapeIds) {
      const root = shapes.get(rootId);
      if (!root || root.pageId !== page.id || root.parentId !== null)
        throw new Error(`Broken root order ${page.id} -> ${rootId}`);
    }
  }
  for (const shape of allShapes) {
    const page = pages.get(shape.pageId);
    if (!page || !documentPageIds.has(shape.pageId))
      throw new Error(`Shape ${shape.id} has missing page ${shape.pageId}`);
    assertUnique(`Shape ${shape.id} childIds`, shape.childIds);
    if (shape.parentId) {
      const parent = shapes.get(shape.parentId);
      if (!parent) throw new Error(`Shape ${shape.id} has missing parent ${shape.parentId}`);
      if (parent.pageId !== shape.pageId || !parent.childIds.includes(shape.id))
        throw new Error(`Broken parent link ${shape.parentId} -> ${shape.id}`);
      if (page.rootShapeIds.includes(shape.id))
        throw new Error(`Child shape ${shape.id} also appears in root order`);
    } else if (!page.rootShapeIds.includes(shape.id)) {
      throw new Error(`Root shape ${shape.id} is missing from page order`);
    }
    for (const childId of shape.childIds) {
      const child = shapes.get(childId);
      if (!child || child.parentId !== shape.id || child.pageId !== shape.pageId)
        throw new Error(`Broken child link ${shape.id} -> ${childId}`);
    }
  }
  const visiting = new Set<string>(),
    visited = new Set<string>();
  const visit = (id: string): void => {
    if (visiting.has(id)) throw new Error(`Group cycle at ${id}`);
    if (visited.has(id)) return;
    visiting.add(id);
    for (const child of shapes.get(id)?.childIds ?? []) visit(child);
    visiting.delete(id);
    visited.add(id);
  };
  for (const id of shapes.keys()) visit(id);
  for (const binding of bindings.values()) {
    if (!shapes.has(binding.fromShapeId) || !shapes.has(binding.toShapeId))
      throw new Error(`Binding ${binding.id} has a missing endpoint`);
  }
  const unknown = allShapes.filter((shape) => !knownShapeTypes.has(shape.shapeType));
  return { document: documents[0], pages, shapes, assets, bindings, unknown };
}

export type DocumentCommand =
  | { type: 'move-shape'; shapeId: string; dx: number; dy: number }
  | { type: 'delete-shape'; shapeId: string };

export function executeCommand(runtime: RuntimeDocument, command: DocumentCommand): WireDocument {
  if (command.type !== 'move-shape')
    throw new Error('Delete requires a cascade policy and is not implemented');
  const target = runtime.shapes.get(command.shapeId);
  if (!target) throw new Error(`Unknown shape: ${command.shapeId}`);
  const moved: ShapeRecord = {
    ...target,
    transform: {
      ...target.transform,
      x: target.transform.x + command.dx,
      y: target.transform.y + command.dy,
    },
  };
  const records: AnyRecord[] = [runtime.document, ...runtime.pages.values()];
  for (const shape of runtime.shapes.values()) records.push(shape.id === moved.id ? moved : shape);
  records.push(...runtime.assets.values(), ...runtime.bindings.values());
  return { records };
}

parseRecordsは本章の最小限で動作するSchema Gateです。未知のrecord type、欠損Field、非有限Transformは、Mapを作る前に失敗します。一方、未知のshapeTypeはCompatibility Recordとして保持します。Production ProjectではこのCodeをZod、Valibot、JSON Schemaへ置換できますが、「先にValidationし、後でNormalizeする」というBoundaryは削除できません。rootShapeIds/childIdsはSibling Z-Orderも担います。UniquenessとParent/Child双方向の一貫性を両方検査しなければ、「Stableな順序」は願望にすぎません。

Delete Commandは現時点で意図的に拒否します。Parent、Child、Binding、Asset Referenceにcascade、reparent、rejectのどれを適用するかはADRで決める必要があり、SilentにOrphanを残してはいけません。これは「明示的なBoundary」であり、空実装を完成品に見せかけているのではありません。

Registry InterfaceはGeometryをCanvasから独立させます。

export type Bounds = { x: number; y: number; width: number; height: number };
export interface ShapeGeometryProvider {
  localBounds(shape: ShapeRecord): Bounds;
  containsLocalPoint(shape: ShapeRecord, point: { x: number; y: number }): boolean;
}
export interface ShapeRenderer<TTarget> {
  render(shape: ShapeRecord, target: TTarget): void;
}
export class Registry<T> {
  private entries = new Map<string, T>();
  constructor(private readonly fallback?: T) {}
  register(type: string, implementation: T): void {
    if (this.entries.has(type)) throw new Error(`Duplicate registry entry: ${type}`);
    this.entries.set(type, implementation);
  }
  resolve(type: string): T {
    const value = this.entries.get(type);
    if (value) return value;
    if (this.fallback) return this.fallback;
    throw new Error(`Missing registry entry: ${type}`);
  }
}

Renderer Registryはcompatibility rendererをfallbackとして使います。Geometry Registryは通常、偽のGeometryを作るべきではないため、fallbackを渡さず、Capability欠損を明示的に失敗させられます。これにより、「未知のPlaceholderを描ける」ことを「未知のShapeを正確にHitできる」と誤解せずに済みます。

TestでDuplicate ID、Cycle、Identity、Renderer置換をCoverします。

import { describe, expect, it } from 'vitest';
import { loadDocument, type AnyRecord, type SessionState, type WireDocument } from './records';
import { Registry } from './registries';

const base = (): AnyRecord[] => [
  { id: 'doc', type: 'document', schemaVersion: 1, pageIds: ['page'] },
  { id: 'page', type: 'page', name: 'Main', rootShapeIds: ['order'] },
  {
    id: 'order',
    type: 'shape',
    shapeType: 'business-card',
    pageId: 'page',
    parentId: null,
    childIds: [],
    transform: { x: 10, y: 20, rotation: 0, scaleX: 1, scaleY: 1 },
    props: { label: '注文' },
    businessObjectId: 'order-42',
  },
];
describe('document invariants', () => {
  it('rejects duplicate immutable identities', () => {
    const records = base();
    records.push({ ...records[2] });
    expect(() => loadDocument({ records } as WireDocument, new Set(['business-card']))).toThrow(
      'Duplicate',
    );
  });
  it('rejects a group cycle', () => {
    const records = base();
    records[1] = { ...records[1], rootShapeIds: [] } as AnyRecord;
    records[2] = { ...records[2], parentId: 'group', childIds: ['group'] } as AnyRecord;
    records.push({
      id: 'group',
      type: 'shape',
      shapeType: 'group',
      pageId: 'page',
      parentId: 'order',
      childIds: ['order'],
      transform: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
      props: {},
      businessObjectId: null,
    });
    expect(() => loadDocument({ records }, new Set(['business-card', 'group']))).toThrow('cycle');
  });
  it('rejects duplicate root z-order entries before rendering', () => {
    const records = base();
    records[1] = { ...records[1], rootShapeIds: ['order', 'order'] } as AnyRecord;
    expect(() => loadDocument({ records }, new Set(['business-card']))).toThrow('duplicate');
  });
  it('preserves identity and Session when a renderer is replaced', () => {
    const runtime = loadDocument({ records: base() }, new Set(['business-card']));
    const session: SessionState = {
      selectedShapeIds: new Set(['order']),
      camera: { x: 0, y: 0, zoom: 1 },
      activeTool: 'select',
    };
    const before = JSON.stringify([...runtime.shapes.values()]);
    const canvas = new Registry<(id: string) => string>(() => 'unknown placeholder');
    const dom = new Registry<(id: string) => string>(() => '<div>unknown</div>');
    canvas.register('business-card', (id) => `canvas:${id}`);
    dom.register('business-card', (id) => `<article data-id="${id}" />`);
    expect(canvas.resolve('business-card')('order')).not.toBe(
      dom.resolve('business-card')('order'),
    );
    expect(JSON.stringify([...runtime.shapes.values()])).toBe(before);
    expect(runtime.shapes.get('order')?.businessObjectId).toBe('order-42');
    expect([...session.selectedShapeIds]).toEqual(['order']);
  });
  it('round-trips an unknown shape and resolves a compatibility renderer', () => {
    const records = base();
    records[2] = { ...records[2], shapeType: 'future-card', props: { future: 7 } } as AnyRecord;
    const runtime = loadDocument({ records }, new Set(['business-card']));
    const renderers = new Registry<(id: string) => string>((id) => `unknown:${id}`);
    expect(runtime.unknown.map((record) => record.id)).toEqual(['order']);
    expect(runtime.shapes.get('order')?.props).toEqual({ future: 7 });
    expect(renderers.resolve('future-card')('order')).toBe('unknown:order');
  });
});

npx vitest run src/lab/ch05/document-store.test.tsを実行し、5 passedを期待します。続けて第3章のVisual Testをもう一度実行します。Rendererは新しいRuntimeDocumentからRecordを読みますが、Pixelは同じままでなければなりません。台帳に戻ると、Photographerや画風を替えてもCard番号は変わらず、今日誰を指しているかも永久記録へは書かれません。

わざと壊す

注入する故障症状根拠修正Regression Test復旧
子を処理せずParentを削除する子ShapeからWorldへの経路がないvalidatorがmissing parentを報告するreject/reparent/cascade policyを明示する各Delete戦略をTestするTransaction Snapshotから復元する
A→B→AのGroup CycleRecursionがStack Overflowする、または停止するDFS visiting setが検出するCommit前にCycleを拒否する2 Node/Self Cycle Test悪いCommandを拒否する
Wire ArrayにDuplicate ID後の値が前の値をSilentに上書きするNormalize前後でCountが異なるMap作成前にIDを検査するduplicate test悪いpayloadを隔離する
同じParent内の順序が重複/衝突するDrawing順序が不安定2回のrender traceが異なる単一のchildIds順序とUniqueness検証permutation testStableな順序を再構築する
Shape Typeが未知空白になる、またはCrashするunknown Listが空でないRecordを保持し、Placeholderを表示し、Read-onlyにするround-trip unknown props元Recordを削除しない
RendererがないそのTypeだけProjectionできないRegistry resolveがfallbackへ到達するcompatibility rendererDocument/Geometry Testは引き続き動くfallbackをLoadする
DocumentにSelectionを混ぜるSave/共同編集でGhost Selectionが生じるschemaにselectedが現れるSessionStoreへ移すPersistence SnapshotにselectionがないFieldを削除してMigrationする

毎回、「注入→症状→Error Code/Record IDを検証→ValidationまたはTransactionを修正→Node Regression→最後の有効Documentから復旧」の順序に従います。Rendererのtry/catchでModel破損を飲み込んではいけません。事実上のErrorを視覚的な空白に見せかけてしまいます。

根拠を示して合格する

Gate自動/手動根拠
DocumentがRendererに依存しない自動records moduleにDOM/Canvas importがなく、Node Testが通る
Renderer置換がDocumentを変えない自動Canvas/DOMの2実装の前後でJSONが同一
Selectionを失わずPersistenceも汚染しない自動Sessionが独立して残り、Document SnapshotにSelectionがない
Persistence Formatを書き直さない自動異なるRegistryが同じWireDocumentを読む
Business IDがStable自動/手動adapter/renderer swap後もorder-42のまま
  • 5種類のRecordと、Group、Parent/Child、Z-Order、Local Transformに明示的な契約がある。
  • Domain Model、Visual Model、Geometry、Rendererの4層が互いになりすましていない。
  • Normalized RecordsはIndex作成前にDuplicate IDを検査する。
  • Derived StateとSession StateをDurable Documentへ書き込まない。
  • Renderer/Geometry Registryを独立して置換でき、実装欠損には明示的なCompatibility Behaviorがある。
  • Parent削除、Cycle、未知Shape、順序Conflict、Renderer欠損を注入し、すべて復旧した。

5歳児に説明する

「Document」「Renderer」「Scene Graph」「Registry」「Derived State」という言葉を使わずに答えてください。家族写真をCartoonへ替えても、子どもの身分証番号を変えてはいけないのはなぜでしょう。「今日誰を指しているか」を永久の家族台帳へ書いてはいけないのはなぜでしょう。

専門用語を使わない合格回答を開く

写真とCartoonは、同じ家族を違う見た目で表したものです。子どもは同じ子どもであり、名前、番号、家族関係は画材が変わっても変わりません。誰かを指すことは、この瞬間にあなたがしていることです。明日別の人が本を開いたとき、あなたの手の動きまで引き継ぐ必要はありません。描き方では変わらない名前と関係を永久のCardへ書き、今日の指差しは別の一時的な紙へ記します。そうすれば画家を替えても、閉じて開き直しても、人を取り違えません。