JEPA4Japan · チュートリアル

第13章:タイムマシンと古い箱

7,493文字 19分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

TransactionでUndo/Redoを管理し、検証と冪等Migrationで版をまたぐデータを扱い、Document、Session、Presenceを分離します。

コース進捗 コース目次 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 つの真実: History は現在のプレイ セッション内の時間を処理します。 Migration は、古いボックスのバージョン間の時間を処理します。

おもちゃの車、卓上の写真 3 枚、段ボール箱 3 つを用意します。まず、子供に車を A から B まで押してもらい、次に B から C まで押してもらいます。写真に各ステップが記録されるので、「戻る」を 1 回押すと、車を C から B まで戻すことができます。つまり、現在のセッション内の Undo です。次に、ボックスに v1、v2、v3 というラベルを付けます。v1 ボックスには「赤い車」のみが表示され、v2 ではその位置も記録され、v3 では色が構造化ペイント ラベルに置き換えられます。 3 年後に v1 ボックスを見つけた場合、その欠落しているフィールドを今日のゲーム ルールに直接渡してはなりません。まずそれを調べてから、ボックスを一度に 1 つのバージョンずつ交換します。

続行する前に答えを予想してください。子供が A から B まで継続的にドラッグし、その手が 87 点を通過した場合、Undo で車を 1 ピクセル戻す必要がありますか、それとも A までずっと移動する必要がありますか? 1 つのジェスチャは 1 つの Transaction であるため、A に戻る必要があります。ここで、「カーソルが車の上に浮かんでいる」写真を撮影し、後で箱を再度開くときにそれを復元することが意味があるかどうかを予測します。そうではありません。それは机の上のほんの一瞬の状態にすぎませんでした。

  1. 1つの操作を完了するAからBへ押す
  2. 元に戻せる記録を残す1ジェスチャーにつき1枚
  3. 古い箱のラベルを調べる壊れたデータを拒否する
  4. 箱を順番に交換するv1 → v2 → v3
  5. 完成後にだけ鍵を替えるアトミック保存
最初に結果を言います。移行の途中で電源が落ちた場合、古いボックスはまだ開くことができますか?はい。新しいスロットが正常に書き込まれるまで、古いスロットを上書きしないでください。

このゲームには 2 つのタイムラインがあります。写真スタックは、ページを開いてからのアクションの履歴に属します。バージョン番号はデータ形式の履歴に属します。 Undo は Migration を置き換えることはできません。また、Migration は元に戻すことができる一連のユーザー アクションを作成すべきではありません。

おもちゃを Canvas の概念に置き換える

おもちゃの世界Canvas Lab正確な責任
車を押す意図CommandMoveShape や ResizeShape などの Domain アクション
実際に手を通したポイントOperation / 一時的なプレビューHistoryを一度に1項目ずつ入力しない高頻度実行詳細
操作前と操作後の写真の違いPatch適用および元に戻すことができるデータ変更
テーブル天板全体の写真Snapshotリビジョンでの完全な Document
1 回押して放すジェスチャTransactionアトミックコミットと 1 つの Undo ステップ
3台の車を一緒に移動するBatchすべて成功またはすべて失敗する複数のコマンド
制作途中の写真87枚を結合Coalescing1 回の連続ドラッグが 1 つの History エントリになります
この子のアクションのみを取り消すLocal Undo / Undo Scopeリモート ユーザーが後で行った変更は消去されません
v1ボックスラベルSchema Versionアプリケーションのバージョンではなく、永続化フォーマット
箱の検査官Validation信頼できない入力が実行時に達する前に検証します。
ボックス交換ラインUp Migration古い形式を一度に 1 バージョンずつ現在の形式に変換します
古い箱に戻すDown Migration明示的に元に戻すことができ、テストされている場合にのみ提供されます
テーブルの横にあるバックパックSession現在のページ、Camera、および Selection。ローカルに保存される可能性があります
振る手の位置PresenceCursor およびオンライン状態。古くなったら廃棄しても安全

類推はここで終わります。本物の Patch は写真ではありません。安定した ID、参照、同時実行性、および障害を処理する必要があります。本物の IndexedDB Transaction はビジネス Undo Transaction でもありません。どちらも「すべてが成功するか、すべてが失敗するか」を必要とするだけです。スナップショットは復元が簡単ですが、サイズが大きくなります。 Operation Log は増分同期が簡単ですが、再生、圧縮、およびバージョン管理されたプロトコルが必要です。このアナロジーでボックスを逆方向に交換できるという事実は、すべての本番 Migration がダウンする可能性があることを意味するわけではありません。データの分割、削除、または暗号化は元に戻せない可能性があります。

まず誤った思い込みを捨てる

  • 「すべての pointermove で history.push を呼び出すのが最も忠実なアプローチです。」 メモリとコラボレーション トラフィックが爆発的に増加する中、1 つのユーザー意図は数十の Undo ステップになります。 Preview は高頻度で変化します。リリース時にのみコミットします。
  • 「JSON.stringify(engineState) を保存するだけで十分です。」 エンジン状態には、Camera、Hover、DOM の参照とキャッシュが混在します。 Durable Document、Local Session、および Ephemeral Presence は、有効期間と権限が異なります。
  • 「TypeScript 型は、JSON に検証が必要ないことを意味します。」 型はコンパイル後に消えます。 IndexedDB、サーバー、クリップボード、および古いクライアントから到着する値はすべて信頼できません。
  • 「Migration は、読み取ったばかりのオブジェクトを変更できます。」 途中で例外が発生すると、半分新しく、半分古いオブジェクトが残ります。各ステップは純粋な関数である必要があります。入力を保存し、出力を再度検証します。
  • 「不明なレコードは常に削除します。」 古いクライアントは、新しいクライアントによって書き込まれたレコードをサイレントに消費する可能性があります。元のバイトを保持したままそれらを隔離するか、書き込み可能モードで文書を開くことを拒否します。
  • 「Autosave が成功したということは、Asset が成功したことを意味します。」 第 12 章のバイナリ アセットには、Document レコードから独立したライフサイクルがあります。参照には ready/pending/missing プロトコルが必要です。
  • 「Undo は古い Snapshot を復元するだけです。」 マルチプレイヤー作業では、リモートでの変更も消去されます。 Local Undo はローカル Transaction を反転し、現在の正本となる状態に対して再検証しなければなりません。

本番環境に必要な知識

前提条件

第 5 章では、安定した ID と Document/Page/Shape/Asset/Binding レコードについて説明します。第 9 章では、Preview と Commit の間の Transaction Boundary を定義します。第 12 章では、Asset 状態と安全なインポートについて説明します。 History は検証されたコマンドのみを受け入れ、Renderer ピクセルを読み取りません。永続化境界は現在の DocumentV3 のみを受け入れます。すべての外部 Payload は最初に unknown → validate envelope → migrate → validate current を通過します。

体系的な知識

Command はユーザーがやりたいことを表し、Operation は最小の実行レベルの変更を表し、Patch は前後の値を保持し、Snapshot は完全な状態を保持します。作成、移動、サイズ変更、および削除は、それぞれ apply および invert を実装します。 Batch は、最初にすべての子コマンドを検証してから、リビジョンを 1 回増分します。 Transaction は、Pointer Down でベースラインをキャプチャし、移動中に Preview のみを更新し、Pointer Up でコミットします。 Coalescing は、同じ transactionId を共有する継続的な更新を組み合わせます。新しいローカル Command が成功すると、Redo スタックがクリアされます。

Undo Scope は明示的である必要があります: 1 ページまたは Document 全体、Page スイッチを通過するかどうか、アクションとともに Selection が復元されるかどうか。 Canvas Lab は「グローバル Document History; Page スイッチは History に入りません。Undo の後、Session は影響を受けるページに移動し、影響を受ける ID を選択します。」を選択します。 Hover、Cursor、カメラ アニメーション、位置合わせガイド、およびコミットされていないテキストは Transient State です。 Selection History は安定した ID ヒントのみを保存し、削除後にフィルタリングし、Session を Document Patch に混合することはありません。

Autosave はデバウンスを使用して、近くで発生するコミットを吸収しますが、ページが非表示になるか閉じる前のフラッシュはベスト エフォートにすぎません。 beforeunload は信頼できるデータベースではありません。 Crash Recovery は 2 つの IndexedDB スロットを使用します。committed ポインターは常に前の完全なエンベロープを参照します。 staging が完全に書き込まれて検証された後、新しいリビジョンを書き込んで、同じ readwrite トランザクション内でポインターを切り替える必要があります。トランザクションが中止されるか、プロセスがクラッシュした場合は、古いポインタの読み取りを続けます。最初に唯一の古いコピーを上書きしないでください。 2 つのタブを同時に保存する場合は、リビジョン条件付き書き込みまたはシングルライター プロトコルも必要です。 IndexedDB のトランザクション アトミック性は、ビジネス レベルの更新の喪失を自動的に防ぐわけではありません。次に、Server Persistence は {documentId, revision, schemaVersion, checksum} で条件付き書き込みを実行します。競合が黙って Last-Write-Wins になってはいけません。

Schema には、レコード識別子、必須フィールド、数値範囲、参照ルール、および schemaVersion が含まれます。 Validation は、構造とセマンティクスの両方 (一意の ID、非循環の親、既存のページ、および有効な Asset 参照) をチェックします。 Up Migration は、v1→v2 または v2→v3 など、1 つのバージョンのみを横断します。各ステップは決定的である必要があり、宣言されたソース バージョンのみを受け入れ、入力を保存し、出力を検証する必要があります。ここでは Idempotence を正確に使用してください。2 番目の入力は v1 ではないため、v1ToV2(v1) 自体は 2 回適用できる関数ではありません。冪等である必要があるのはリカバリ エントリ ポイントです。現在のバージョンは loadDocument を再度通過しても変更されず、クラッシュ後に元のコミットされたペイロードから再実行しても同じ結果が得られます。 Down Migration はデフォルトの約束ではありません。リリースをロールバックする必要がある場合は、どの新しいレコードを表現できないかを特定し、ダウングレード書き込みを拒否します。読み取り専用で開き、不明なレコードの隔離、元の Payload のバックアップを使用して Forward Compatibility を処理します。決して理解したふりをしないでください。

Document は、共有され、耐久性があり、移行可能な信頼できる情報源です。 Session は、1 つのデバイス上の 1 人のユーザーの Camera、現在の Page、Selection、および Tool です。個別にローカルに保存できます。 Presence は Cursor、ビューポート、TTL 付きのオンライン状態です。ブロードキャストすることはできますが、リカバリ スナップショットには決して入れないでください。 3 つすべてがレコードとして表される場合がありますが、scope、保存場所、アクセス許可、およびクリーンアップ ポリシーは異なるままでなければなりません。

根拠と互換性(確認日:2026-08-29)

IndexedDB は、ブラウザーにトランザクション オブジェクト ストレージを提供します。 MDN IndexedDB API で API と可用性の制約を確認してください。 Page ライフサイクルとバックグラウンド フリーズは、アンロード イベントだけに依存することはできません。 Chrome Page ライフサイクル を参照してください。 structuredClone() は多くの構造をコピーできますが、Schema Validation ではありません。 MDN 構造化クローンのドキュメント でサポートされているタイプを参照してください。サーバーは、アクセス許可、バージョン、セマンティクスを検証する必要があります。クライアント検証では、エラーが早期に報告されるだけです。それは信頼境界ではありません。

本章の実装ステップ

開始点: 第 12 章の Document は JSON として手動でのみエクスポートでき、進行中のレコードをドラッグで直接変更します。 終了ライン: 作成/移動/サイズ変更/削除は元に戻すことができます。連続ドラッグは 1 Undo です。 v1/v2 は v3 に安全に移行します。 Autosave はクラッシュから回復します。 Sessionは別途保管されます。

次のインターフェイスとファイルを追加します。

  • src/engine/history/History.ts: Transaction, Batch, Coalescing, Undo/Redo;
  • src/engine/persistence/schema.ts: バージョン管理されたエンベロープとランタイム検証。
  • src/engine/persistence/migrations.ts: 純粋な v1→v2→v3 関数。
  • src/engine/persistence/DocumentRepository.ts: 2 スロット Autosave および Crash Recovery。
  • src/engine/session/SessionRepository.ts: Page/Camera/Selection のみを格納します。
  • src/engine/persistence/__tests__/migrations.test.ts: 独立した Migration 証拠。

以下は実行可能な最小限のコアです。重要な移行ロジックが省略記号の背後に隠れることはありません。

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;
  }
}

おもちゃの話に戻ります。87 個の中間位置は、単に子供の手がどこを通過したかを示しているだけです。 commit({ transactionId }) はリバーシブル写真を撮るものです。 loadDocument はボックス検査官です。 v1 を今日の Renderer に直接渡すことはありません。 applyAtomically は、単なる安心させる関数名ではありません。実装では、最初に Patch グループ全体を不可視のコピーに対して検証し、次に Document を 1 回置き換える必要があります。途中で変異してからスローする可能性がある場合、ステップを History スタックに保持しても、書きかけの Document を救出することはできません。

Migration と History は個別にテストする必要があります。

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

describe('バージョン付き永続化', () => {
  const v1 = {
    schemaVersion: 1 as const,
    shapes: [{ id: 'shape-a', type: 'rect' as const, x: 1, y: 2, color: '#f00' }],
  };
  it('v1 → v3 を決定的に移行し、再読み込みしても変化しない', () => {
    const once = loadDocument(v1);
    expect(loadDocument(once)).toEqual(once);
    expect(v2ToV3(v1ToV2(v1))).toEqual(once);
  });
  it('連続ドラッグを1つの可逆ステップとしてコミットする', () => {
    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('壊れた参照を Renderer に渡さず拒否する', () => {
    expect(() =>
      loadDocument({
        schemaVersion: 3,
        pages: [{ id: 'page-main', shapeIds: ['shape-missing'] }],
        shapes: [],
      }),
    ).toThrow('INVALID_V3_PAGE');
  });
});

npm exec vitest run src/engine/persistence src/engine/history を実行します。すべての Migration、リカバリ エントリ冪等性、破損分離、Command Inversion、および「スタックを移動せずに applyAtomically をスローする」テストに合格することが期待されます。ブラウザで npm exec playwright test tests/crash-recovery.spec.ts を実行します。ステージングの書き込み、リビジョンの書き込み、およびコミットされたポインターの切り替え中に、個別にリロードを強制します。リカバリでは、以前のコミットされた状態または完全な新しいコミットされた状態のみが得られる場合があり、Document の半分になることはありません。次に、2 つのタブを開き、同じベース リビジョンから保存します。一方は、他方を黙って上書きするのではなく、競合を受け取る必要があります。

意図的に壊してみる

注入された失敗症状証拠修正回帰テスト回復
ステージング途中でクラッシュする図形または JSON が不完全です2 スロットのリビジョン/チェックサム検証が完了した後にのみアトミックにコミット済みに昇格しますリロード障害テストステージングを破棄し、古いスロットを読み取ります
v1 には x がありませんRenderer は NaN を受信しますINVALID_V1_SHAPEエントリー時の構造検証欠落フィールドのフィクスチャPayload を隔離し、回復を提供する
リカバリエントリを 2 回実行します寸法は2倍に成長します冪等スナップショットの差分ステップは、宣言されたソース バージョンのみを受け入れます。現在のバージョンは直接返されますload(load(v1))元のバックアップから再実行する
v2→v3の途中で投げるレコードの半分が埋まっています。半分は色がある入力ハッシュと欠落した出力Build 純粋関数内の新しいオブジェクト、その後検証レコード N に例外を挿入するコミットされた v2 を保持する
Page スイッチ間の Undo何も起こらないようですstep.pageId は Session ページとは異なりますUndo の後に影響を受けるオブジェクトに移動してフォーカスします2ページのリプレイDocument の一貫性を保つ
Asset は参照されたまま削除されましたExport またはレンダリングが失敗するセマンティック missing asset 検証トゥームストーン/フォールバック。決して準備ができているふりをしないでくださいダングリングアセット固定具欠落している Asset を表示
古いクライアントが新しいクライアントによって保存されたデータを開くUnknown Record が消費されますスキーマバージョン/プロトコルのログ古いクライアントは読み取り専用であるか、明示的に拒否されます前バージョンのフィクスチャ元のバイトを保持し、アップグレードが必要

根拠を示して合格する

自動化された証拠手動による証拠合格条件
Migration フィクスチャ、べき等テスト、Command Inversion、2 スロット クラッシュ リロードリカバリ UI で古いリビジョンを開き、通知、選択肢、および Document の内容を確認します。すべての自動テストに合格します。人は回復源を特定できるが、部分的な記録は存在しない
静的スコープ監査と Store 統合テストSession および Presence のライフタイムを観察しながら、ページを切り替え、更新し、2 つのタブを使用しますDocument、Session、および Presence は互いに永続化することはありません
証明すべき財産権威ある証拠
作成/移動/サイズ変更/削除は元に戻すことができますCommandごとにapply→invertプロパティテスト
ドラッグは 1 ステップを占有しますtransactionId ごとに 1 つの History ステップ
古いデータがランタイムに直接到達することはありませんloadDocument(unknown): DocV3 が唯一のエントリ ポイントです
Migration は独立して信頼性がありますv1/v2 フィクスチャ、冪等性、およびフォールト挿入テスト
クラッシュ/同時実行により部分書き込みやサイレント上書きが発生することはありません2 スロットのリロード、単一トランザクション ポインター スイッチ、2 つのタブのリビジョン競合テスト、およびチェックサム
3 つの状態スコープは混在していませんDocument/Session/Presence ストアの範囲監査
  • Migration には独立したテストがあります。入力は保持され、出力は検証され、ロードを繰り返すと同じ結果が返されます。
  • Document と Session は異なるキー、スキーマ、およびリポジトリを使用します。 Presence には永続書き込みがありません。
  • Hover、アライメントガイド、トランジェント Preview、および Cursor は、Undo または Autosave に入ることはありません。
  • 古いクライアントは、Unknown Record をサイレントに削除して結果を書き戻すことはありません。
  • 新しい Command が Redo をクリアします。 Batch が失敗した場合、リビジョンは増加しません。
  • リカバリ UI は、単に「問題が発生しました」と言うのではなく、どのリビジョンが復元されたかを説明します。

5歳児に説明する

「Command」、「Transaction」、または「Migration」という言葉を使わずに、これを説明してください。車をテーブルの左側から右側にドラッグし続けた後、戻るボタンを一度押すと、車が最初の位置に戻るのはなぜですか? 3 年前のボックスを今日のゲームテーブルに直接放り投げてはいけないのはなぜでしょうか?なぜ今友達が手を振っている場所を永久の箱に封印すべきではないのでしょうか?

専門用語を使わない適切な答え

1 回の完全なプッシュには 1 つの目的があるため、手を放した後、「前後」の写真を 1 枚撮ります。一度戻ると車は押す前の位置に戻ります。古い箱は今のものとは仕切りが違うので、まず壊れているものがないか確認し、すべてを1箱ずつ移動し、新しい箱が完成してから元の箱と交換します。共有されたおもちゃの説明書は長期間保存する必要があり、自分のビューは個人のバックパックに入れることができ、友人の手の場所はすぐに変わるため、接続を切った後は簡単に忘れることができます。