0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

未来のセーブデータを無理に読まない――future schemaをfail-closedで保護する【第32回】

0
Last updated at Posted at 2026-09-11

4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第6章「課題に対応しながら設計を改善する」
第32回:未来のセーブデータを無理に読まない――future schemaをfail-closedで保護する

連載トップ・第0回はこちら

1. 今回のテーマ

前回は、

Schema 2
↓
Validation
↓
Backup
↓
Migration
↓
Schema 3

という流れで、

古いセーブデータを現在の形式へ引き継ぐ方法を考えました。

古い形式は、

こちらがその構造を知っている

ため、変換できます。

では、逆の場合はどうでしょうか。

現在のアプリが、

Save Schema 3

までしか知らないのに、

保存されているデータが、

Save Schema 99

だった場合です。

今回は、

現在のアプリが知らない未来のセーブデータを、どう安全に扱うか

を考えます。


2. 「古い」と「新しすぎる」は違う

現在のアプリがSchema 3だとします。

Schema 2なら、

Schema 2
↓
過去に自分たちが使っていた形式
↓
構造を知っている
↓
Migrationできる

と考えられます。

一方、

Schema 99なら、

Schema 99
↓
現在のアプリより新しい
↓
構造を知らない

です。

この違いは重要です。


3. 知らない構造は変換できない

例えば、未来のSchema 4で、

以下のような変更が入っているかもしれません。

新しい必須項目が追加された
既存項目の意味が変わった
ID体系が変更された
GameStateの構造が変わった

現在のSchema 3側には、

その情報がありません。

つまり、

Schema 4
↓
Schema 3へ変換

するための正しいルール自体を、

現在のアプリは知りません。


4. 「読めるところだけ読む」は危険

JavaScriptでは、

知らないプロパティがあっても、

一部の値を取り出せることがあります。

例えば、

以下は考え方を説明するため、実装を簡略化しています。

const state = rawSave.state;

const hp = state.playerStatus.hp;
const gold = state.playerStatus.gold;

このコードだけなら、

未来形式でも偶然動くかもしれません。

しかし、

一部が読めたことと、データ全体を正しく理解できたことは別です。


5. 読み込めたように見える方が危険

完全にエラーになるなら、

問題には気づきやすくなります。

しかし、

HPは読めた
ゴールドも読めた
町も表示された

ので、

たぶん大丈夫

としてしまう方が危険です。

例えば未来形式では、

あるフラグが必須になっている
依頼進行の意味が変わっている
イベント状態の表現が変わっている

かもしれません。

画面は開けても、

後でゲーム状態が壊れる可能性があります。


6. そこでfuture schemaを識別する

前回まで使ってきた、

saveSchemaVersion

がここでも役立ちます。

例えば、

現在の対応schemaを、

以下のように持っているとします。

以下は考え方を説明するため、実装を簡略化しています。

const CURRENT_SAVE_SCHEMA_VERSION = 3;

読み込んだデータが、

saveSchemaVersion = 99

なら、

現在より新しい形式だと判断できます。


7. current / old / futureを分ける

現在がSchema 3なら、

基本的には、

Schema 3
↓
current
Schema 1 / 2
↓
old
Schema 4以上
↓
future

と分けられます。

この分類によって、

処理も分けられます。


8. currentは通常ロード

Saved Schema 3
↓
現在形式としてValidation
↓
PASS
↓
通常ロード

です。


9. oldはMigration候補

Saved Schema 2
↓
旧形式としてValidation
↓
Migration
↓
Schema 3
↓
現在形式としてValidation
↓
通常ロード

です。

前回扱った流れです。


10. futureは通常ロードしない

future schemaは、

Saved Schema 99
↓
現在のアプリは構造を知らない
↓
通常ロードしない

とします。

ここでは、

Migrationもしません。

変換元の意味を知らないためです。


11. future schemaをunsupportedとして扱う

この状態を、

今回のセーブ設計では、

unsupported

として扱います。

つまり、

データが壊れている

とは判断しません。

正確には、

データは存在しているが、現在のアプリでは対応していない

という状態です。


12. unsupportedとinvalidを分ける

ここが今回の重要なポイントの一つです。

例えば、

saveSchemaVersion = 99

で、

その形式としては正常なデータだったとします。

現在のアプリが知らないだけです。

これは、

unsupported

です。

一方、

JSONとして読めない
必須項目が欠けている
想定外の値になっている

なら、

invalid

です。


13. 状態を整理する

例えば、

次のように考えられます。

compatible
↓
現在形式として利用可能

migratable
↓
古いがMigration可能

unsupported
↓
正常かもしれないが現在のアプリでは未対応

invalid
↓
データそのものに問題がある

全部を、

ロードできない

の一種類にまとめないことが重要です。


14. TypeScriptで結果を分ける

例えば、

以下は考え方を説明するため、実装を簡略化しています。

type SaveCompatibility =
  | {
      status: "compatible";
      saveData: SaveData;
    }
  | {
      status: "migratable";
      fromSchemaVersion: number;
    }
  | {
      status: "unsupported";
      schemaVersion: number;
    }
  | {
      status: "invalid";
    };

このようにすると、

呼び出し側も、

compatibleならロード
migratableならMigration
unsupportedなら保護
invalidなら復旧処理

と分岐できます。


15. なぜunknownではなくunsupportedなのか

単に、

unknown

としてもよさそうです。

ただ、

今回表したいのは、

何なのか全く分からない

だけではありません。

saveSchemaVersionは認識できた
↓
ただし現在のアプリの対応範囲外

です。

そのため、

unsupported

という扱いの方が、

状態の意味を明確にできます。


16. fail-closedとは何か

今回採用している考え方が、

fail-closed

です。

簡単に言えば、

安全に扱えると確認できなければ、通常処理へ進めない

という方向です。

例えば、

対応している
↓
ロードする

一方、

対応しているか判断できない
↓
とりあえずロードする

とはしません。


17. fail-openとの違い

逆に、

よく分からないけれど
動きそうなので通す

方向を取ると、

結果的にfail-open寄りになります。

セーブデータでは、

これが危険になる場合があります。

なぜなら、

ロード後にデータを書き換える操作まで続く可能性があるからです。


18. 本当に怖いのは「読むこと」だけではない

future schemaで特に注意したいのは、

ロードそのものだけではありません。

例えば、

未来形式のセーブが、

slot_2

に入っていたとします。

現在の古いアプリで、

それを正しく読めなかったとします。

そのあと、

プレイヤーが同じslot_2へ通常セーブしたらどうなるでしょうか。


19. 古いアプリが未来データを上書きしてしまう

例えば、

slot_2

Schema 4
↓
未来のアプリで作成

というデータを、

Schema 3までしか知らないアプリで開いたとします。

そこで、

読めない
↓
空きスロット扱い
↓
Schema 3で上書き

してしまうと、

未来形式のセーブを消してしまいます。

これは避けたい動作です。


20. unsupportedは「空きスロット」ではない

つまり、

ロードできない

からといって、

空きスロット

ではありません。

整理すると、

empty
↓
データが存在しない
unsupported
↓
データは存在する
ただし現在のアプリでは扱えない

です。

この違いが重要です。


21. 通常上書きも拒否する

そこでfuture schemaについては、

通常ロード
↓
拒否

だけでなく、

通常上書き
↓
拒否

とします。

つまり、

理解できないデータを、理解できないまま壊さない

ことを優先します。


22. ロードと上書きの両方にガードを置く

例えば、

以下は考え方を説明するため、実装を簡略化しています。

function canLoadSave(
  compatibility: SaveCompatibility
): boolean {
  return compatibility.status === "compatible";
}

上書き側にも、

同様の判断が必要です。

以下は考え方を説明するため、実装を簡略化しています。

function canOverwriteSave(
  compatibility: SaveCompatibility
): boolean {
  return (
    compatibility.status === "compatible" ||
    compatibility.status === "migratable"
  );
}

実際の条件は設計によって変わりますが、

重要なのは、

ロード側だけで保護を終わらせないこと

です。


23. UIにも違いを伝える

保存システム内部で、

unsupported

と判断できても、

UIで、

壊れています

と表示してしまうと、

意味が変わってしまいます。

例えば、

このセーブデータは、
現在のバージョンでは読み込めません

のように、

現在のアプリ側の対応範囲の問題だと分かる表示にできます。


24. 「削除してください」とすぐ誘導しない

future schemaは、

データそのものが壊れているとは限りません。

新しいアプリへ戻せば、

正常に使える可能性があります。

そのため、

対応していません
↓
削除してください

と即座に誘導するのも慎重に考える必要があります。

まずは、

保持する

という選択肢があります。


25. unsupportedをquarantineしない

後続の記事では、

壊れたセーブを、

quarantine

へ隔離する仕組みを扱います。

しかし、

future schemaは、

単純な破損ではありません。

unsupported
≠
corrupted

です。

そのため、

future schemaを見つけたからといって、

破損データと同じ処理へ流す必要はありません。


26. invalidとunsupportedで復旧方針も変わる

例えば、

invalid

なら、

backupがある?
↓
正常backupから復旧できる?

という処理が考えられます。

一方、

unsupported

なら、

現在のアプリでは扱わない
↓
元データを保持する

ことが中心になります。

状態を分けたことで、

処理方針も分けられます。


27. スロット単位で判定する

今回のWeb RPGには、

3つのセーブスロットがあります。

例えば、

slot_1
Schema 3
↓
compatible

slot_2
Schema 2
↓
migratable

slot_3
Schema 99
↓
unsupported

という状態も考えられます。


28. 一つのunsupportedで全部を止めない

slot_3がfuture schemaだからといって、

セーブ機能全体を使用禁止

にする必要はありません。

例えば、

slot_1
↓
通常利用

slot_2
↓
Migration

slot_3
↓
保護

とできます。

第31回と同じく、

問題の影響範囲をスロット単位に閉じ込める

考え方です。


29. 判定処理は一か所に集めたい

例えば、

セーブ一覧画面、

ロード処理、

上書き処理、

起動時復元で、

それぞれ別々に、

future schemaか?

を判断すると、

条件がずれる可能性があります。

そこで、

SaveData
↓
Compatibility Check
↓
compatible / migratable / unsupported / invalid

という判定を一か所へ集めます。


30. UIは判定結果を利用する

例えば、

compatible
↓
[ロード]

migratable
↓
Migration後[ロード]

unsupported
↓
[ロード不可][通常上書き不可]

invalid
↓
復旧処理へ

とできます。

UI自身がschema番号を細かく解釈しないようにします。


31. appVersionではなくsaveSchemaVersionを見る

ここでも、

第30回で分けた意味が効いてきます。

例えば、

appVersion = 2.0.0

だから、

future schemaとは限りません。

アプリは新しくても、

セーブ形式は同じかもしれません。

future schemaかどうかの判断では、

saveSchemaVersion

を基準にします。


32. 保存形式のversionは処理を決める情報になる

つまり、

saveSchemaVersionは、

単にログへ記録する数字ではありません。

どのparserを使うか
Migrationできるか
通常ロードできるか
上書きしてよいか

を決める材料です。

セーブ形式のversionが、

実際の制御へつながっています。


33. future schemaをテストする

こうした保護は、

コードを書くだけでなく、

実際の動作として確認したいところです。

例えば、

saveSchemaVersion = 99

のセーブを用意します。

そして、

セーブ一覧を開く
↓
future schemaを検出
↓
通常ロードできない
↓
通常上書きできない
↓
元データが残る

ことを確認します。


34. 「ロードできない」だけではテスト不足

テストで、

ロードボタンが失敗した

だけ確認しても十分ではありません。

例えば、

裏でデータを削除していたら、

future schemaの保護にはなっていません。

確認したいのは、

ロード拒否
+
上書き拒否
+
元データ保持

です。


35. エラーではなく仕様として扱う

future schemaを見つけること自体は、

必ずしもアプリの不具合ではありません。

例えば、

ユーザーが、

新しいアプリで保存
↓
何らかの理由で古いアプリを開く

ことで発生します。

つまり、

予想可能な状態です。

そのため、

例外が出たので失敗

だけではなく、

仕様として定義された状態

として扱います。


36. セーブデータはアプリより長く残ることがある

アプリ本体は、

更新されます。

しかし、

セーブデータは、

以前の版から引き継がれ続けます。

さらに、

新しい版
↓
古い版

という逆方向の組み合わせも発生します。

そのため、

セーブ設計では、

時間の前後をまたいだ互換性

を考える必要があります。


37. 「できるだけ読む」より「壊さない」

セーブ互換性を作っていると、

つい、

何とか全部読み込みたい

と考えます。

しかし、

知らない構造まで無理に読もうとすると、

かえって危険です。

そこで、

知っている
↓
読む

古いが知っている
↓
Migrationする

知らない
↓
止める

とします。


38. fail-closedは消極的な設計ではない

分からないから止める

というと、

機能不足のようにも見えます。

しかし、

セーブデータでは、

停止することで守れるものがあります。

プレイヤーの進行
未来形式のデータ
別バージョンで再利用できる可能性

です。

つまり、

扱えないデータを積極的に保護する設計

とも言えます。


39. current / old / future / invalidを整理する

ここまでをまとめると、

Current Schema
↓
compatible
↓
通常ロード
Old Supported Schema
↓
migratable
↓
backup
↓
Migration
↓
通常ロード
Future Schema
↓
unsupported
↓
通常ロード拒否
↓
通常上書き拒否
↓
元データ保持
Broken Data
↓
invalid
↓
復旧処理へ

となります。


40. 今回のポイント

今回のポイントは3つです。

  • 現在のアプリより新しいfuture schemaは、意味を知らないため無理に読み込まない
  • future schemaはinvalidではなくunsupportedとして区別する
  • 通常ロードだけでなく通常上書きも拒否し、理解できないデータを古いアプリから守る

整理すると、

理解できる
↓
処理する

古いが理解できる
↓
Migrationする

理解できない
↓
壊さず止める

です。

future schemaへの対応で大切なのは、

現在のアプリで読み込むことではなく、現在のアプリが知らない旅を壊さないこと

です。


41. 次回

次回は、

localStorageからIndexedDBへ、既存セーブを失わずに移行する【第33回】

です。

ここまでの第30~32回では、

SaveDataそのものの形式

を扱ってきました。

次は、

保存する場所そのものを、

localStorage
↓
IndexedDB

へ変更します。

正式公開版では、

Dexie.jsを使ってIndexedDBへ移行し、

旧localStorageからの初回自動移行も行っています。

さらに、

migration marker

を使って、

移行済みデータを再び古い保存先から巻き戻さない

仕組みも入れました。

次回は、

保存形式だけでなく、保存媒体そのものを変更するとき、既存セーブをどう守るか

を整理します。

 
 
この記事を最後まで読んでいただき、ありがとうございます。

「いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。

もし気に入っていただけましたら、フォローもよろしくお願いします。


前の記事

第31回 古いセーブデータを新しい形式へ引き継ぐ――Migrationの考え方

次の記事

第33回 localStorageからIndexedDBへ、既存セーブを失わずに移行する

連載トップ

第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ

note関連記事

セーブ形式を変えても、旅を失わないために #19

noteでは、future schemaをunsupportedとして扱う理由、通常ロードだけでなく通常上書きも拒否する考え方、unsupportedinvalidを分けた理由を制作側の視点から書いています。

note記事を読む

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?