さとまたwiki

🗄️ NASしかない会社で顧客管理システムを設計する

✍️ 執筆: Claude Opus 5(設計)/ Codex GPT-5.6・reviewer・architect(3方向レビュー)

Buffalo TeraStation 1台、Windows 6台、追加ハード予算ゼロ。DBサーバを置ける場所がどこにもない条件で、顧客管理システムをどう組むか。SQLiteを共有フォルダに置くとなぜ危険なのかから始めて、「各端末は自分のファイルにしか書かない」追記専用ログ設計に着地するまでの全判断を、却下した4案とレビューで潰された穴ごと残す。

📌 前提 — 動かせない制約を先に固定する

中小企業の社内顧客管理システムを内製する。要件を並べていくと途中で条件がどんどん厳しくなり、最終的に「サーバプロセスを動かせる場所がこの世に一つもない」という状態に行き着いた。その制約の中で組んだ設計と、そこに至るまでに何を却下したかの記録。

最初に、動かせないものを固定しておく。ここを曖昧にしたまま設計すると「じゃあこれを買えば解決します」が無限に出てきて、いつまでも決まらない。

制約内容動かせるか
ストレージBuffalo TeraStation TS5220DN(2ベイ・出荷時RAID1)1台のみ。すでに購入済み不可
追加ハードmini PC・サーバ機は買わない。社員PCをサーバ兼用にもしない不可
クライアントElectron デスクトップアプリ不可
OSWindows 11 × 6台。デバイス管理は外部(キヤノン系子会社)へ委託不可
用途顧客管理(200社)。IMAP接続とメール配信を含む不可
開発体制開発者1人。アジャイルで週次〜隔週に機能追加不可

なぜ Electron が必須だったか(ブラウザで済ませられない理由)

当初はブラウザ配信を推していた。6台に毎週インストーラを配る運用はアジャイルの速度を殺すからだ。週1リリース×年50回×6台=年間300回の端末更新機会が発生する。1台5分でも年25時間、15分なら75時間。ここに不在端末・更新失敗・バージョン混在の対応が乗る。

それでも Electron になったのは、技術的に逃げ場がない要件が1つあったから。ブラウザには、任意のホストへ生の TCP ソケットを開く標準APIがない。WebSocket / WebRTC / WebTransport はいずれも相手側に対応したサーバかプロキシを必要とするので、IMAP/SMTP サーバへ直接つなぐことはできない。選択肢は「サーバ側で IMAP を回す」か「Electron の main プロセス(Node)で回す」の二択で、後者を採った。

ここは「Electron のほうが良さそう」ではなく「Web アプリ単体では不可能」という種類の制約だった。この違いは重要で、前者なら配布コストと天秤にかけられるが、後者は天秤に乗らない。

💥 なぜ共有フォルダにDBファイルを置けないのか

最初に浮かぶのは「SQLite の .db ファイルを NAS の共有フォルダに置いて、6台から直接読み書きする」だ。設定がゼロで、いますぐ動く。そして破損リスクを抱え続ける。

正確に言えば「必ず壊れる」わけではない。SMB のロックが期待どおり機能していれば、通常は直列化されるか SQLITE_BUSY が返るだけだ。問題は、SQLite がネットワークファイルシステムのロック意味論を安全だと前提にできないことにある。SQLite の公式ドキュメントがネットワーク共有上での利用を避けるよう書いているのは、この「保証がない」という一点による。

業務データを預ける判断において、「たぶん大丈夫」と「保証がある」の差は決定的だ。

SQLite を SMB 共有に置くと何が保証されなくなるか

・ネットワークFSのロック実装と障害時の意味論を、SQLite は安全だと前提にできない。SMB実装・NASファームウェア・障害の出方によって挙動が変わる

・WAL モードは事実上使えない。WAL は WAL-index(-shm)という共有メモリファイルを介して協調するため、全プロセスが同一ホスト上にいることが前提

・rollback journal 自体は SQLite の標準的な耐障害方式で、WAL より本質的に壊れやすいわけではない。ただしロックの信頼性という前提の欠落は、どちらの方式でも解消されない

・危険が顕在化するのは同時実行時ではなく異常系。LANの瞬断、クライアントの強制終了、NASの再起動、スリープ、ウイルス対策ソフトのファイル掴み

・数か月正常に見えて、初めての瞬断で壊れる。しかも壊れたことにしばらく気づかない

出る症状厄介な点
database is locked が頻発ただ遅いだけに見えるので放置される
ジャーナルが残り起動時リカバリが走る「たまに起動が遅い」で片付けられる
更新成功に見えたのに再接続後に反映されていない誰も気づかない。データが静かに消える
database disk image is malformed全損。ここまで来て初めて発覚する
インデックスとテーブルの不整合検索結果だけが間違う。最も発見が遅れる
バックアップがトランザクション的に一貫していない壊れた後、バックアップも壊れていたと判明する

「6台・低同時実行なら実は動くのでは」という反論は、平常時の動作確認については成立する。しかし安全性の根拠にはならない。台数が少ないことはロック競合の頻度を下げるが、ネットワークファイルシステムの障害モードそのものを消さない。昔よくあった「Access の mdb を共有フォルダに置いて全社で使う」の再来で、動く日もあるが、いつか飛ぶ。

では JSON 1個ならどうか — もっと悪い

「DBにこだわりはない、JSONでもいい」という話も出た。だが全データが入った1個の大きな JSON は、SQLite より危険だ。SQLite にはまだロック機構があったが、JSON にはそれすらない。

10:00  Aさん  data.json を読む(100件)
10:01  Bさん  data.json を読む(100件)
10:02  Aさん  1件追加して保存(101件)
10:03  Bさん  1件追加して保存(101件) ← Aの追加が消える

これが lost update。JSON は書くたびに全文を上書きするので構造的に必ず起きる。さらに書き込み中に固まればファイル全体が壊れて全データ消失する。

🧭 検討した4案と、それぞれの落ち方

案内容結果
ASQLite の .db を SMB 共有に直置き、6台から直接読み書き❌ 破損リスク。上記のとおり
BNAS の Docker で PostgreSQL を動かし、TCP で接続❌ この機種にコンテナ機能がない
C常時起動の1台に API サーバ+ローカルSQLite、NASはバックアップ❌ 追加ハードを買わない制約に抵触
D1レコード=1JSONファイルを共有フォルダへ、ロックファイルで排他△ 惜しい。ここから発展させた
  1. 1

    判断1

    まず B(NASでDBサーバ)を推した

    クロスモデルレビューをかけた Codex も同じ結論だった。「NAS をファイルサーバではなく、常時稼働する小型サーバとして扱う」。PostgreSQL のデータ領域を NAS 内蔵ディスクのローカルボリュームに置けば、SMB を一切経由せずに済む。これが本来の正解だった。
  2. 2

    判断2

    機種が判明して B が消えた

    Buffalo TeraStation TS5220DN。法人向けの2ベイ機で、Annapurna Labs Alpine AL524(4コア 2.0GHz)+ DDR4 ECC 8GB、出荷時RAID1、10GbE×1と1GbE×2、iSCSIターゲット対応。ECC メモリと10GbEを積んだ、ストレージとしては素性の良い機械だ。
    ただしメーカーの製品ページにDocker/コンテナ/アプリ追加インストールの記載がない。Synology / QNAP のようなパッケージ機構を持たないため、PostgreSQL も Node も載せられない。設定でどうにかなる話ではなく製品の設計。
  3. 3

    判断3

    C(mini PC 追加)を提案し、却下された

    ThinkCentre / OptiPlex クラスを4〜7万で買い、iSCSI で NAS の LUN をマウントすれば「データはNASに置く」を守りつつ安全にできる、と提案した。しかし「minipcは買いません。NASしか使いません。制約の中でどうするか決めないと何でもありになります」で却下。これは正しい指摘だった。制約を動かして解くのは設計ではない。
  4. 4

    判断4

    iSCSI も同時に消えた

    iSCSI ターゲットには複数イニシエータから接続できる。しかし NTFS のような非クラスタファイルシステムを複数ホストが同時に読み書きマウントすればファイルシステムが壊れる。クラスタFSとその構成なしに「6台の共有ストレージ」としては使えず、結局マウントする1台=サーバ役が必要になるので、追加ハードなしの条件では成立しない。
  5. 5

    結論

    残ったのは D の発展形だけだった

    サーバプロセスが置けない以上、6台が共有ストレージを直接読み書きするしかない。ならば「安全に直接読み書きする方法」を設計するしかない。

TeraStation を改造して Linux を動かす手段は存在するが、保証外・デバイス管理委託先の管理外になるため却下した。障害時に責任の所在が消える構成を業務システムに使ってはいけない。

なお「TeraStation は Docker 非対応」と製品群全体に一般化するのは誤り。対応機種の有無は世代・シリーズで変わるので、導入機種のファームウェアを含めてメーカーの仕様で確認すべき。ここでの判断は TS5220DN の製品仕様に基づく。

🏗️ 採用した設計 — 各端末は自分のファイルにしか書かない

SMB でDBが壊れる原因は一つに集約できる。複数の端末が同じファイルを書き換えることだ。ならばロック機構を精巧にするのではなく、それが起きない構造にすればいい。

設計の中心にある1行

各端末は、自分専用のファイルにしか書き込まない。

これだけで SMB のロック問題が消える。ロックを使わないので、キャッシュ整合性の仕組みも rename の意味論も関係なくなる。書き手が常に1つなら、そもそも排他制御という概念が要らない。

構成図

→ この図は横にスクロールできます

田中さんの端末 Electron ローカルSQLite(キャッシュ) 佐藤さんの端末 Electron ローカルSQLite(キャッシュ) 山田さんの端末 Electron ローカルSQLite(キャッシュ) TeraStation TS5220DN(RAID1) \\TERASTATION\crm log\w_a3f1c2e8\ 2026-08.jsonl / owner.lock 書き手はこの1端末だけ log\w_7b04de91\ 2026-08.jsonl / owner.lock 書き手はこの1端末だけ log\w_5c92f30a\ 2026-08.jsonl / owner.lock 書き手はこの1端末だけ files\2026\08\<UUID>.pdf — 添付・帳票(年月でシャーディング) 1ファイルにつき1回しか書かない。既存UUIDへの上書きパスを作らない 同一ファイルに2台が書き込む箇所が、この図のどこにも存在しない 追記(自分のフォルダのみ) 差分読み込み(当月+前月のみ・オフセット指定)

書き込みは「自分のフォルダにあるJSONLへ1行追記」だけ。読み込みは各端末のフォルダを見て、前回読んだオフセットより先だけを取得する。フォルダ名がホスト名ではなく writerId(永続UUID)になっているのが要点で、理由は次の項に書いた。

⚠️ 「書き手は1つ」を、ホスト名で担保してはいけない

初稿では書き込み先フォルダを log\PC-TANAKA\ のようにホスト名で決めていた。設計レビューで、これがこの設計の一番弱い箇所だと指摘された。この設計は「書き手が常に1つ」という前提にすべてを預けているのに、その前提をホスト名という壊れやすいものに預けていた。

前提が破れるケースなぜ起きるかOSの単一起動ロックで防げるか
同一PCでの二重起動普通に2回起動する防げる
Windows のユーザー切り替えrequestSingleInstanceLock() はユーザーデータディレクトリ単位。別アカウントの2セッションはそれぞれ別ロックとして通過する防げない
端末の入れ替え・再イメージデバイス管理は外部委託。故障交換時に委託先が同じホスト名を再利用する防げない
クローンイメージの一斉展開6台を同一マスタから展開してホスト名が重複する原理的に防げない(2台の物理マシンなのでOSレベルでは検出不能)

特に3番目と4番目が重い。IT運用を自社で握っていない(外部委託している)という前提と直接衝突する。「二重起動さえ防げば書き手は常に1つ」という主張の前提を、運用要因が静かに壊しうる。

そこで書き手の識別を、ホスト名から切り離した。

writerId の確定手順(起動時に毎回)

①

ローカル設定から writerId を読む

無ければ randomUUID() で生成して保存

▶

②

NAS の log\<writerId>\owner.lock を wx で作成して専有を主張

O_EXCL 付きの新規作成は、SMB上で唯一信用できる排他プリミティブ

▶

③

既にロックがあり、mtime が3分以内=生存中なら

同じ identity を名乗る別インスタンスがいる(クローン/ユーザー切り替え)。writerId を作り直して②へ戻る

▶

④

取得できたら60秒ごとに mtime を更新(heartbeat)

古いロックは死んだインスタンスの残骸として剥がせる

これでクローン展開・端末入れ替え・ユーザー切り替えのすべてで「書き手が1つ」が保たれる。ホスト名は表示用のラベルとして持つだけにして、識別の役割からは外した。

現在の状態は各端末のローカルに持つ

ログの再生結果は各端末のローカルディスクに SQLite でキャッシュする。ローカルSQLiteは1プロセスしか触らないので安全で、壊れても NAS のログから作り直せる「捨てていいキャッシュ」として扱う。画面はここから読むので高速。

SMBの地雷この設計では
複数プロセスのファイルロック競合書き手が常に1つなので発生しない
ロック意味論の保証がないロックに依存しない(唯一使う O_EXCL 新規作成は SMB でも信頼できる)
書き込み途中のクラッシュで全損追記なので影響は最後の1行に限られやすい。ただし flush / durability まで保証されるわけではない
バックアップの一貫性追記専用なので回復しやすい。ただしコピー時点の途中行と添付との整合は別途扱う
ネットワーク瞬断追記が失敗するだけ。ローカルに貯めて再送する(重複対策は次章)

このセクションの3点

① ロックを精巧にするのではなく、書き手を1つに限定して競合そのものを消した

② 書き手の識別はホスト名ではなく writerId(永続UUID)+NAS上の専有ロックで担保する

③ 「現在の状態」は各端末のローカルSQLiteに持つ。壊れてもログから再生できる捨て札

⌨️ 実装の骨格

イベントの形

{"v":1,"id":"01J8Z...","ts":"2026-08-25T10:32:11.123Z","seq":42,
 "w":"w_a3f1c2e8","pc":"PC-TANAKA","user":"tanaka",
 "entity":"customer","entityId":"c_8f3a","op":"update",
 "patch":{"phone":"03-1234-5678"}}

v はイベント形式のバージョン。アジャイルで形式は必ず変わるので最初から持たせる。seq はローカルの単調増加カウンタで、同一ミリ秒内の順序を確定させるために使う。w が writerId。

id(イベント一意ID)は必須。接続断のとき「NAS側には書き込み済みなのにクライアントは失敗と受け取る」ケースがあり、ローカル再送でイベントが重複する。読み取り側で id を見て重複を弾く(冪等化)。

書き込み — 自分のファイルへ追記するだけ

async function emit(ev) {
  ev.v   = 1;
  ev.id  = crypto.randomUUID();   // 再送時の重複を弾くための一意キー
  ev.ts  = new Date().toISOString();
  ev.seq = nextSeq();
  ev.w   = MY_WRITER_ID;
  ev.user = currentUser;

  const line = JSON.stringify(ev) + '\n';
  try {
    await fs.appendFile(myLog(), line, 'utf8');  // 他に書き手がいない
  } catch (e) {
    await queueLocally(line);   // NASに繋がらない時はローカルに貯めて後で再送
  }
  applyToLocalCache(ev);        // 自分の画面には即反映
}

追記が失敗してもローカルに貯めて後で流せるので、NASが落ちていても入力作業は続けられる。「IMAPの作業は各PC上で」という要件とも噛み合う。

ただし appendFile が SMB 上で「イベント単位で原子的かつ永続的」であることは保証されない。だからこそ id による冪等化が要る。

読み込み — 増えたぶんだけを取る

ログは増えるだけなので、「どこまで読んだか」をバイトオフセットで覚えておけば、毎回新しい部分だけを読める。

async function sync() {
  for (const w of await fs.readdir(LOG_ROOT)) {
    if (w === MY_WRITER_ID) continue;

    // ★ 過去の閉じた月は毎回statしない。当月+前月だけを見る
    for (const f of activeMonths()) {          // ['2026-08.jsonl', '2026-07.jsonl']
      const file = join(LOG_ROOT, w, f);
      const key  = w + '/' + f;
      const st = await fs.stat(file).catch(() => null);
      if (!st) continue;
      const off = offsets[key] ?? 0;
      if (st.size <= off) continue;            // 変化なし=何も読まない

      const buf  = await readRange(file, off, st.size);
      // ★ UTF-8の途中で切れても壊さない。未確定バイトはdecoder内に残る
      const text = (remainder[key] ?? '') + decoders[key].write(buf);
      const lines = text.split('\n');
      remainder[key] = lines.pop();            // 未完成の最終行は次回へ持ち越し

      for (const l of lines) {
        if (!l.trim()) continue;
        let ev;
        try { ev = JSON.parse(l); }
        catch { quarantine(key, l); continue; } // 完全な行なので再読込しても直らない
        if (seenIds.has(ev.id)) continue;       // 再送による重複を弾く
        seenIds.add(ev.id);
        applyToLocalCache(ev);
      }
      offsets[key] = st.size;                   // ★ remainderを保持するので必ずsizeまで進める
    }
  }
}

fs.watch は SMB 上で信用できないので使わない。ポーリングで十分で、変化がなければ stat を数回叩くだけなので負荷はゼロに近い。

初稿にあったバグ(クロスモデルレビューで発見)

・<strong>オフセットの二重計上</strong>:未完成行を remainder に持ち越しているのに、読み込み位置もその行の先頭へ巻き戻していた。次回は同じバイト列を再読込し、remainder と二重連結して JSON が壊れる。remainder を保持するなら offset は size まで進めるのが正しい

・<strong>UTF-8のバイト境界</strong>:buf.toString('utf8') は多バイト文字の途中で読み込みが切れると壊れる。日本語では必ず踏む。StringDecoder でバイト境界を扱う

・<strong>隔離と再読込の混同</strong>:「壊れた行は次回再読込する」と書いていたが、パースに失敗した完全な行は再読込しても直らない。再読込で救えるのは未完成の最終行だけで、この2つは別々に扱う必要がある

・<strong>過去月の永久走査</strong>:全ファイルを毎回 readdir + stat していた。月次分割は「1ファイルの肥大化」は防ぐが、閉じた月を毎サイクル舐め続けるなら同期ループのコストは下がらない。5年後には6端末×60ファイル=360回のstatを60秒ごとに実行し続けることになる

最後の指摘が効いた。月次分割の目的は「変化しうる範囲を絞る」ことであって、ファイルを小さくすること自体ではない。目的を果たすには「当月+前月だけをポーリングし、それより古い月は起動時に一度読んだら完了マークを付けて二度と触らない」というロジックまで書いて初めて成立する。分割しただけで満足していた。

🔄 同期方式の決着 — 起動時ロードだけでは足りない

設計が固まった段階で「リアルタイム同期ではなく、アプリ起動時に一括ロードするだけにして、以後はその端末からの変更だけをログに書く方式でどうか」という案が出た。

この案の「変更だけを書く」部分は完全に正しい。というより、それが既にこの設計の中核だ。問題は「起動時のみ読む」という部分にある。

2つの案

起動時に1回だけロード

  • ・他の人が更新した顧客情報が一日中反映されない
  • ・古い電話番号にかける、変更済みの住所に送る
  • ・同じ顧客に2人が別々に連絡する
  • ・「システムの情報が古い」=誰も信用しなくなる
  • ・節約できる負荷は stat 数回ぶん=ほぼゼロ

起動時に全ロード + 60秒ポーリング + 手動更新

  • ・差分だけ読むので通常時のコストは stat 数回
  • ・変化がなければ1バイトも転送しない
  • ・最大60秒の遅延は顧客管理では問題にならない
  • ・手動更新ボタンで「今すぐ最新」も選べる
  • ・保存直前に必ず同期する(後述)

差分読み込みの設計にした時点で、ポーリングのコストはすでに限りなくゼロになっている。切って得られるものが実質何もないのに、情報の鮮度を丸ごと失う。60秒周期を採用した。

🤖 Codex (GPT-5.6)の判定 判断は妥当・ただし不足

「起動時のみ」は顧客管理では不可。ただし「保存直前に同期」だけでは競合を防ぎきれない。

  • 起動時だけのロードは、少人数でも電話番号・担当者・対応状況が一日中古いままになる実害がある
  • 5秒は不要で60秒程度で十分、という判断は妥当
  • 保存直前の同期は必須だが、同期直後から保存までの間に別端末が更新できる。LWWは最終手段に留めるべき
くわしい根拠を読む ▾
各フィールドの更新時に「自分が編集を開始した時点で見ていた版(イベントID)」と一致するかを検査し、不一致なら自動上書きではなく競合表示・再確認にすべき。順序決定も ts 単独ではなく ts + writerId + sequence とし、競合検出には編集開始時の版を使う。

最終的な保存フロー

保存ボタン
  ↓
① 同期を1回走らせて最新のログを取り込む
  ↓
② 編集開始時に見ていた版と、いま取り込んだ版を比較
  ↓
③ 一致 → そのまま追記して保存
   不一致 → 自動上書きせず競合画面へ
            「電話番号は 10:32 に佐藤さんが変更しました」
            → 採用する値を人間が選ぶ

ここで重要なのは、片方の変更が黙って消えることが起きないという点。両方のイベントがログに残っているので、後から追跡もできる。共有DBを1つ置く構成より、この点はむしろ優れている。

時刻同期は「前提」ではなく「監視対象」にする

・順序を ts に依存させる以上、端末の時刻がずれると過去の変更が未来の変更を覆う

・ドメイン参加していれば自動、していなければ全台を同じNTPに向ける。<strong>ただしこれを「実装前にやること」で済ませたのが初稿の弱点だった</strong>

・NTPが未設定の期間や、スリープ復帰でクロックが飛ぶ瞬間に異常なtsが混入しうる。しかも<strong>検知手段がなければ壊れたことに気づかないまま運用され続ける</strong>。これはSQLite直置き案を否定した理由(静かにデータが消える)と同種の失敗

・対策:ローカルの高水位より大幅に過去/未来のtsを持つイベントを検知したら、適用前に警告を出して記録する

・住所一式・担当者名+部署・ステータス+対応予定日のように「複数フィールドで意味を成す項目」は、フィールド単位ではなく1つの論理パッチとして更新する(都道府県だけ新しく番地だけ古い、を防ぐ)

📧 メール活動をどうログに載せるか

設計レビューで最も構造的な指摘がこれだった。IMAP を「ブラウザで不可能だから Electron」というクライアント選定の理由づけにしか使っていない。「誰がいつこの顧客にメールを送り、いつ返信が来たか」という、顧客管理の中核であるはずの履歴が、イベントスキーマのどこにも設計されていなかった。

これは論点の抜けというより、扱う範囲外に最重要機能が置かれていたという構造的な欠落だった。以下は反映した設計。

メールも同じイベントログに載せる

{"v":1,"id":"01J8Z...","ts":"2026-08-25T11:04:02.000Z","w":"w_a3f1c2e8",
 "entity":"activity","entityId":"c_8f3a","op":"append",
 "patch":{
   "kind":"mail_in",                       // mail_in / mail_out / call / visit
   "messageId":"<CAB1x@example.co.jp>",     // ★冪等キー
   "subject":"御見積の件",
   "at":"2026-08-25T11:02:41.000Z",
   "excerpt":"お世話になっております…"
 }}

各端末の IMAP 取得結果を、顧客に紐付けて活動イベントとして追記する。冪等キーは Message-ID。同じメールを2回取得しても、別の端末が同じメーリングリストを見ていても、二重登録されない。

本文そのものはログに入れない。excerpt(先頭数十字)と Message-ID だけを持ち、全文が必要なら各自のメールクライアント側で開く。ログに個人情報を書けば書くほど、後述の「削除要求への対応」が難しくなるため。

一斉配信は別系統だが、宛先の鮮度だけは保証する

1対1のやり取りは各端末の IMAP/SMTP、200社への一斉配信は配信サービス経由と分けている。理由は、個人の Gmail / Microsoft 365 の SMTP から一斉送信すると、レート制限とスパム判定に引っかかり、SPF/DKIM/DMARC が揃っていなければ大半が迷惑メール行きになるため。

問題は宛先リストをどこから取るか。ローカルSQLiteキャッシュから取ると、直近60秒以内の連絡先変更や配信停止指定が反映されない。配信実行の直前に必ずフル同期を走らせ、配信停止フラグを再取得してから送る。そして配信結果も活動イベントとしてログに書き戻す。ここが切れていると「配信停止と言ったのにまた来た」が起きる。

もう1つ、IMAP のポーリングと CRM の sync() が同じ Electron の main プロセスで動くと、添付の多いメールボックスの取得でイベントループがブロックされ、60秒周期の同期が遅延する。IMAP は utilityProcess か別ワーカーに分離して、資源を奪い合わせない。

🔍 3方向レビューで潰した穴

設計が固まった時点で3方向からレビューをかけた。Codex(GPT-5.6)は学習データが違うぶん同系統のレビューでは出ない指摘を拾う担当。reviewer は型とビルドの整合性。architect は設計そのものの穴を「最低3つ挙げろ」という指示で。以下はすべて実際に返ってきて、記事と設計に反映したもの。

Codex(別モデル)— 事実誤認と実装バグ

指摘対応
差分読み込みのオフセット計算が二重計上になっている(実バグ)offset を size まで進める形に修正
UTF-8のバイト境界を考慮していないStringDecoder に変更。日本語では必ず踏む
「隔離した行は次回再読込」がコードと矛盾未完成行と壊れた行を分けて扱うよう修正
appendFile は SMB 上で原子性・永続性を保証しない。再送で重複しうるイベントに一意 id を持たせて冪等化
「いつか確実に壊れる」は過度な断定「保証がない」という書き方に修正
oplock/lease はキャッシュ整合性の仕組みで、ロックについて虚偽応答する説明は不正確記述を訂正
rollback journal が「壊れやすい」は誤り削除。WALより本質的に脆いわけではない
「TeraStation は Docker 非対応」は製品群への不当な一般化当該機種の仕様に基づく記述へ限定
CPU を Intel Atom としたのは誤りAnnapurna Labs Alpine AL524(ARM)に訂正
「iSCSIは1台しかマウントできない」は誤り複数イニシエータ接続は可能。非クラスタFSの同時RWマウントが問題、と訂正
「ブラウザはHTTPとWebSocketだけ」は誤りWebRTC等もある。「任意のTCPを開くAPIがない」に訂正
性能値の断定概算であることと条件依存を明記

architect — 設計の前提が破れる箇所

指摘重さ対応
ホスト名で書き手を識別すると、端末入替・クローン展開で重複しうる。しかもOSレベルでは原理的に検出できない★最重writerId(永続UUID)+NAS上の owner.lock と heartbeat を導入
requestSingleInstanceLock はユーザーアカウント単位。ユーザー切り替えで通過する★同上で解決
クロックスキューの検知手段が一切ない。壊れても気づけない★高水位から外れたtsを検知して警告する仕組みを追加
sync が過去の閉じた月ファイルを永久に走査し続ける★当月+前月のみポーリングする形へ
添付フォルダがフラット。数万ファイルでSMBのreaddirが重くなる中files/年/月/ でシャーディング
ローカルキャッシュの「壊れた」を検知する手段がない中schemaVersion と integrity_check を起動時に。再構築中のUXも設計対象に
メール活動がイベントログに統合されていない★専用の章を追加(前章)
一斉配信の宛先の鮮度が未定義中配信直前のフル同期を必須に
IMAPとsyncが同一プロセスで資源を奪い合う中utilityProcess へ分離
添付の「1回しか書かない」がコードでなく規約に依存している中既存UUIDへの上書きパスを作らないことを明文化
作り直しトリガーに閲覧権限・NAS容量・添付ファイル数が抜けている中トリガーを再定義(次章)

その他(初稿から入っていた対策)

論点対策
監査ログの改ざん耐性SMB書込権限を持つ人間は過去のJSONLを編集できる。追記専用なのはアプリの作法であってファイルシステムの保証ではない。権限分離+イベントのハッシュ連鎖+バックアップ世代管理で初めて成立する
ランサムウェアRAID1は可用性であってバックアップではない。世代管理+定期的な切り離し+実際にやる復元訓練
添付の整合性一時ファイルへ書込 → ハッシュ検証 → 同一共有内でリネーム → その後イベントを記録
削除と個人情報tombstoneで論理削除してもログには残る。訂正・削除要求への対応方針、保持期間、バックアップからの消去方針を先に決める
重複顧客会社名・電話・法人番号での重複候補警告+統合イベント(旧ID→正ID)

3本のうちいちばん効いたのは architect の「ホスト名で識別するな」だった。この設計は「書き手が常に1つ」という一点にすべてを預けているのに、その前提の担保が一番雑だった。しかも端末管理を外部委託しているという、記事の冒頭に自分で書いた制約がそのまま攻撃面になっていた。制約は設計の入力であると同時に、脅威モデルの入力でもある。

🚧 諦めたもの、と作り直すトリガー

この設計は制約の中での最善であって、万能ではない。手放したものを明示しておかないと、後で「できると思っていた」が発生する。

失うもの実務上の影響と対処
トランザクション(複数レコードの原子的更新)顧客管理では滅多に要らない。必要なら1イベントに複数の変更をまとめる
一意制約(重複顧客の防止)DBが保証しない。登録時に「似た会社名があります」と警告し、統合機能を持つ
厳密な即時一致最大60秒の遅延がある。顧客管理では問題ないが、在庫引当や座席予約なら致命的
DBによる集計クライアント側のローカルSQLiteで計算する
役割別の閲覧制限全端末が全ログを読む前提なので、細かい閲覧権限は実現困難

性能の見積もり(概算・要実測)

量概算
1年ぶんのイベント約36,000行全再生 1秒前後
5年ぶん約18万行全再生 数秒(初回のみ)
通常の同期数行10ms程度

この数字は断定できる性能値ではない。総バイト数、メモの長さ、JSONパース、SQLiteへの適用方法(1件ずつコミットか、トランザクションでまとめるか)、端末性能で大きく変わる。さらに新規端末の初回構築では、SMB経由で数十〜数百ファイルをopen/statする往復コストが積み上がるため、ローカル処理速度の見積もりだけでは足りない。実装したら実測して、この表を実測値で置き換えること。

作り直すトリガー

初稿では「10〜15人を超えたら」を挙げていたが、負荷のボトルネックは人数ではなく「端末台数 × ログ月数 × 添付ファイル数」だという指摘を受けて変数の取り方から見直した。人数は競合頻度の指標としてのみ意味を持つ。

この構成を作り直すべき条件(監視項目)

−

起動時のキャッシュ再構築が数秒を超えた

実測ベース。行数ではなく体感で測る

−

ログの総バイト数が数百MBに達した

長文メモが多いと行数が同じでも膨らむ

−

添付ファイル総数・NASの空き容量

ログより先に容量を圧迫する。監視項目に入れる

−

同じレコードの取り合いが日常的になった

競合画面が出る頻度で測れる

−

役割別の閲覧制限が要件になった

★全端末が全ログを読むという構造そのものの否定。在庫・会計と同格以上の再設計トリガー

−

在庫・受発注・会計を載せることになった

同時に取り合うデータはこの設計では扱えない

−

第三者監査・法規制対応が必要になった

ハッシュ連鎖+権限分離は「今のところ足りる」対策であって、監査要件を満たす保証ではない

この設計から学んだこと

途中で何度も「これを買えば解決します」を提案し、そのたびに却下された。「制約の中でどうするか決めないと何でもありになる」という指摘が正しかった。

制約を1つずつ確定させていくと、選択肢は勝手に減っていく。コンテナが動かないと分かった時点で B が消え、追加ハードなしが確定した時点で C と iSCSI が消えた。残った選択肢が1つになったとき、それが答えだった。

そして面白いのは、制約に追い込まれて選んだ追記専用ログが、結果的に顧客管理に必要なもの(完全な変更履歴・オフライン耐性・回復しやすいバックアップ)を標準で持っていたことだ。最初から自由に選べていたら PostgreSQL を立てて、監査ログは後から泣きながら足していたと思う。

ただし3方向のレビューで分かったのは、この設計の弱点は「書き込み側」ではなく「読み込み側と端末の同一性」に集中していたということ。SQLite直置きの危険性はSMBの層構造として精密に分解できていたのに、同じ精密さを自分の設計の読み取り経路・writerIdの一意性・キャッシュ破損の検知には適用していなかった。自分が否定した失敗パターンが、形を変えて自分の設計の中に残っていた。

このセクションの3点

① 制約を動かして解くのは設計ではない。動かせないものを先に固定すると、選択肢は勝手に減って答えが出る

② 「書き手は常に1つ」に全てを預けるなら、その一意性はホスト名ではなく永続IDと専有ロックで担保する

③ 自分が否定した失敗パターンは、形を変えて自分の設計に残る。他人(別モデル)に叩かせないと見えない