2台のパソコンで、同じ台帳に1件ずつ案件を登録します。どちらも「登録完了」と出ます。
ところが、あとで台帳を開くと、1件しかありません。エラーも警告も出ていません。
前の版の計算ツールで、実際にこれが起きる手順がありました。使い始めてまだ日が浅く、実害は出ていません。ただ、作りの上では起きます。
前回は、台帳に人・時間が残っていないことを書きました。今回は、その台帳がどこに置かれていたかの話です。
前の版は実務で使っているので、記事用に名前と鍵を外した「標本版」を作り、画面と計算が元と同じであることを確かめたうえで動かしています。
台帳の置き場は、3つあった
前の版の台帳は、次の3か所に置けるように作っていました。
| 置き場 | 中身 | 役割のつもり |
|---|---|---|
| ブラウザの中 | そのパソコンで登録した案件 | 手元の控え |
| 共有ファイル | ネットワーク上のJSONファイル1つ | 複数のパソコンで共有 |
| サーバー | 社内のサーバーに送る | 将来の本命 |
サーバーを使わずに共有ファイルを置いたのは、組織のネットワークの決まりが年々厳しくなっていて、サーバーを立てる形では動かせなかったからです。ブラウザから直接ファイルを読み書きできる仕組み(File System Access API)を使えば、決まりの中で動く。そう考えて選びました。
ところが、標本版でコードを追ってみると、サーバーに送る処理は書いてあるのに、どこからも呼ばれていませんでした。画面には「サーバー同期」というボタンがあります。押したときに動くのは、共有ファイルを読み込む処理です。
実際に動いていたのは、ブラウザの中と共有ファイルの2つ。そして、その2つのどちらが本物か、どこにも決めていませんでした。

1件が消える手順
登録ボタンを押したとき、前の版は次の順で動きます。
- 手元(ブラウザの中)の台帳だけを見て、いちばん大きい番号に1を足して登録番号を決める
- 手元の台帳に追加する
- 手元の台帳全体で、共有ファイルを上書きする
3で、書く前に共有ファイルを読み直していません。
台帳を開いたときは、手元と共有ファイルを合わせます。合わせ方の規則はこれだけです。
// 同じ登録番号があれば、作成日時が新しい方を残す
function mergeLedger(a, b) {
const map = {};
[...a, ...b].forEach(p => {
if (!map[p.id] || p.createdAt > map[p.id].createdAt) map[p.id] = p;
});
return Object.values(map);
}
この3つが重なると、こうなります。パソコンAとB、どちらの手元にも001〜005の5件がある状態から始めます。
| 手順 | 操作 | Aの手元 | Bの手元 | 共有ファイル |
|---|---|---|---|---|
| 1 | Aが案件Xを登録 | 001〜005、006=X | 001〜005 | Aの手元で上書き → 006=X |
| 2 | Bが案件Yを登録 | 同上 | 手元の最大は005 → 006=Y | Bの手元で上書き → 006=Y(Xが消える) |
| 3 | Aが台帳を開く | 006が2つ → 新しい方のYを残す(AからもXが消える) | — | 006=Y |
Xは、どこにも残りません。画面にも何も出ません。
標本版で、Aの画面をそのまま再現してみます。登録した直後、Aの台帳の一番上には006のXがあります。

Bが書いた共有ファイルを読み込み、前の版の合わせ方の関数をそのまま通したあとの、同じ画面です。

件数は6件のまま、番号も006のまま。中身だけがYに入れ替わっています。台帳を見ても、何かが消えたことには気づけません。
前回、「2台が同時に登録すると同じ番号ができる」と書きました。正確には、同時である必要はありません。Bが、Aの登録を取り込む前に登録すれば起きます。Bが朝から台帳の画面を開かずに計算だけしていれば、それで条件はそろいます。
番号が重ならなくても、手順2の時点で共有ファイルからXは消えています。Bの手元にXがないまま、ファイルを丸ごと上書きするからです。

消したはずの案件が、戻ってくる
逆のことも起きます。
- Aが案件Zを削除する。Aの手元と共有ファイルからZが消える
- Bの手元には、Zがまだ残っている
- Bが別の案件を登録する。Bの手元(Zを含む)で共有ファイルを上書きする
- Aが台帳を開くと、Zが戻ってくる
台帳には「削除した」という記録がありません。合わせる規則は「ある方を残す」なので、どちらか一方にでも残っていれば、案件は生き返ります。「全件削除」で共有ファイルを空にしても、他のパソコンが次に登録すれば全件が戻ります。

足りなかったのは、3つの「1か所」
コードの1行1行は、どれも素直です。手元の最大番号に1を足す。手元の台帳を保存する。同じ番号なら新しい方を残す。1台で使っている限り、何も起きません。
崩れたのは、台帳が2か所に分かれたときに、次の3つを決めていなかったからでした。
| 決めていなかったこと | 起きたこと |
|---|---|
| 正本はどれか | 手元とファイルが、互いを上書きし合う |
| 番号をどこで振るか | それぞれの手元で振るので、番号が重なる |
| 削除をどう残すか | 「ない」ことしか表せないので、消したものが戻る |
3つとも、答えは「1か所に決める」です。正本は1つ。番号はその正本が振る。削除も正本に記録する。
これは、積算そのものについて最初に書いたことと同じ形をしています。誰が計算しても同じ答えになるように、手順と根拠を1つに決める。台帳も、どのパソコンから見ても同じ中身になるように、置き場を1つに決める。計算の正しさで気をつけていたことを、保存のほうでは決めていませんでした。
全国版では、正本を先に決める
前の版は、これからも要望に合わせて版を上げ、帳票の書式を増やしていくつもりです。版が上がれば、台帳に持たせる項目も変わります。正本が決まっていないまま版が混ざると、古い版のパソコンが、新しい項目を持たない台帳で共有ファイルを丸ごと上書きする、ということも起きます。書式を増やす前に、置き場を決めておく必要がありました。
全国版の試作は、前回書いたとおり、共有の機能を外してブラウザの中だけに保存しています。複数のパソコンで使う形に戻すときは、ファイルを置き場にするのではなく、データベースを正本にするつもりです。
データベースなら、番号は正本の側で振れます。2人が同時に登録しても、番号は重なりません。削除も「消した」という印を付けた行として残せます。
CREATE TABLE project (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 番号は正本が振る
name TEXT NOT NULL,
hours REAL NOT NULL, -- 人・時間(主役)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, -- 直した日時
deleted_at TEXT -- 消した記録
);
次からは、この表を実際に組みながら、PythonとSQLの側から書いていきます。

共有している表を、1つ思い浮かべてください
複数の人が開く表を、1つ思い浮かべてください。ネットワーク上のExcelでも、共有フォルダの台帳でも構いません。
その表は、最後に保存した人の内容で、まるごと置き換わる作りになっていませんか。2人が同時に開いて、それぞれ1行足して保存したら、両方の行が残りますか。
残らないなら、それは誰かの操作ミスではなく、正本と番号を振る場所が決まっていないからです。表が1枚のうちは、問題になりません。2枚になった日に初めて出てきます。