[h1-request] 同期v2: 集約ストアと push/pull(1伝票単位・rev・冪等) #54

Open
opened 2026-10-09 19:56:39 +00:00 by joe · 1 comment
Owner

背景

v1 の SyncRecord は (device_id, entity_type, uuid) 単位のミラーで、o2 自身の編集や複数端末による同一伝票の編集を表現できない。伝票と明細が別エンティティで届くため、1伝票の整合も保証できない。

依頼内容(ADR-0002 §3〜§6・§11)

  • 集約ストア: (tenant_id, entity_type, uuid) 単位で1件。集約ごとに単調増加の rev を o2 が採番。起源端末・最終更新端末を記録
  • POST /api/sync/v2/push: v2 バンドル(§4。伝票は本体+明細+関連+入金消込を1バンドル)を受理
    • 冪等: (device_id, entity_type, uuid, hash) 受理済みなら duplicate
    • 楽観的排他: 新規 or base_rev == current_rev → accepted(rev)
    • 作業データ(project/daily_report/todo/case/memorandum)で base_rev < current_rev → HLC 比較で後勝ち(負けたら stale_overridden+o2 版)
    • 伝票・記録系で base_rev < current_rev → conflict(受信版は保存、現行版は変えない。合議は別 Issue)
    • 発行ロック済み集約への変更 → rejected_locked(ロック解除も含め一切受け付けない)
    • 締めロック範囲内(例外不適用)→ rejected_fiscal(判定値は締め日の Issue で提供。先行実装時は常に通過でよい)
    • accepted 時に既存の射影(SyncProjectionService / H1AccountingService)を実行
  • GET /api/sync/v2/pull?since=: kind=aggregate(テナント内の他端末・o2 起源の更新)。自端末が送って受理済みの集約は rev 通知のみ
  • v1 で受理済みデータの v2 集約ストアへの移行(明細は親伝票へ統合)

受け入れ条件

  • 上記の全判定パターンに fixture とテストがある
  • 同一バンドルの再送で二重計上されない
  • v1 の既存テストが全て通る
  • テナント分離テストが通る

契約の正本: ADR-0002(ブランチ docs/adr-0002-offline-first-sync-v2。未マージなら先に main へマージしてから着手)
https://git.cyberius.biz/joe/o2/src/branch/docs/adr-0002-offline-first-sync-v2/docs/adr/0002-offline-first-sync-v2.md

h1 側の内部設計(参考): https://git.cyberius.biz/joe/h1-c2/src/branch/main/docs/superpowers/specs/2026-10-09-offline-first-sync-design.md

前提: h1 は本体単体で完全稼働し、o2 接続時のみ同期する。v1(/api/sync/push|pull・/api/sync/corrections*)は h1 の移行完了まで並行稼働を維持すること(v1 を壊さない)。
検証データ: 入出力例を tests/fixtures/sync_v2/*.json に追加する。h1 は同じ fixture を Dart テストで検証する(sales_calculation fixture と同じ運用)。

## 背景 v1 の `SyncRecord` は `(device_id, entity_type, uuid)` 単位のミラーで、o2 自身の編集や複数端末による同一伝票の編集を表現できない。伝票と明細が別エンティティで届くため、1伝票の整合も保証できない。 ## 依頼内容(ADR-0002 §3〜§6・§11) - 集約ストア: `(tenant_id, entity_type, uuid)` 単位で1件。集約ごとに単調増加の `rev` を o2 が採番。起源端末・最終更新端末を記録 - `POST /api/sync/v2/push`: v2 バンドル(§4。伝票は本体+明細+関連+入金消込を1バンドル)を受理 - 冪等: `(device_id, entity_type, uuid, hash)` 受理済みなら `duplicate` - 楽観的排他: 新規 or `base_rev == current_rev` → `accepted(rev)` - 作業データ(project/daily_report/todo/case/memorandum)で `base_rev < current_rev` → HLC 比較で後勝ち(負けたら `stale_overridden`+o2 版) - 伝票・記録系で `base_rev < current_rev` → `conflict`(受信版は保存、現行版は変えない。合議は別 Issue) - 発行ロック済み集約への変更 → `rejected_locked`(ロック解除も含め一切受け付けない) - 締めロック範囲内(例外不適用)→ `rejected_fiscal`(判定値は締め日の Issue で提供。先行実装時は常に通過でよい) - accepted 時に既存の射影(`SyncProjectionService` / `H1AccountingService`)を実行 - `GET /api/sync/v2/pull?since=`: `kind=aggregate`(テナント内の他端末・o2 起源の更新)。自端末が送って受理済みの集約は rev 通知のみ - v1 で受理済みデータの v2 集約ストアへの移行(明細は親伝票へ統合) ## 受け入れ条件 - [ ] 上記の全判定パターンに fixture とテストがある - [ ] 同一バンドルの再送で二重計上されない - [ ] v1 の既存テストが全て通る - [ ] テナント分離テストが通る --- **契約の正本**: ADR-0002(ブランチ `docs/adr-0002-offline-first-sync-v2`。未マージなら先に main へマージしてから着手) https://git.cyberius.biz/joe/o2/src/branch/docs/adr-0002-offline-first-sync-v2/docs/adr/0002-offline-first-sync-v2.md **h1 側の内部設計**(参考): https://git.cyberius.biz/joe/h1-c2/src/branch/main/docs/superpowers/specs/2026-10-09-offline-first-sync-design.md **前提**: h1 は本体単体で完全稼働し、o2 接続時のみ同期する。v1(`/api/sync/push|pull`・`/api/sync/corrections*`)は h1 の移行完了まで**並行稼働を維持**すること(v1 を壊さない)。 **検証データ**: 入出力例を `tests/fixtures/sync_v2/*.json` に追加する。h1 は同じ fixture を Dart テストで検証する(`sales_calculation` fixture と同じ運用)。
Author
Owner

実装完了(o2側)— 集約ストアと push/pull

ADR-0002 を main にマージ(6107637)し、Issue #54 を実装しました。全 6358 passed。

実装

  • 集約ストア: SyncAggregate((tenant_id, entity_type, uuid) 一意、rev 採番、data(header/items/relations/allocations/materials)、hash、origin_device_id/last_device_id、doc_date、locked、deleted、hlc)
  • 冪等: SyncInboxV2((device_id, entity_type, uuid, hash))
  • push: POST /api/sync/v2/push。判定マトリクス(duplicate/rejected_fiscal(フック)/rejected_locked/accepted/stale_overridden(作業データHLC後勝ち)/conflict(伝票・記録系)/master_decision(フック=当面 accepted))。発行ロックは発行系・受領系の false→true のみ受理、解除は不受理。accepted 時に射影/会計射影を実行
  • pull: GET /api/sync/v2/pull?since=(SyncV2Event の単調増加 id をカーソル、kind=aggregate。他 kind は枠)
  • v1 並行稼働: v1(/api/sync/push|pull・corrections)は不変。v1→v2 移行サービス+CLI(scripts/migrate_sync_v1_to_v2.py、明細は親伝票へ統合)
  • 検証データ: tests/fixtures/sync_v2/*.json(各判定の入出力例・v1移行)
  • migration d5e6f7a8b9c0(syncaggregate/syncinboxv2/syncv2event/syncconflict)

引き継ぎフック(後続 Issue)

  • #55 conflict: SyncConflict に受信版保存済み(解決APIは未実装)
  • #56 master: master_decision() → 当面 accepted
  • #57 fiscal / lock_exception: is_fiscal_locked() / has_lock_exception() → 当面 False

注意(インフラ)

  • 現在 リモートへの git push が SSH タイムアウトで失敗しています(www.cyberius.biz:18)。実装はローカル main(7c50050)にマージ済み・o2.db 適用済みで、push は回復後に実施します。
## 実装完了(o2側)— 集約ストアと push/pull ADR-0002 を main にマージ(`6107637`)し、Issue #54 を実装しました。全 **6358 passed**。 ### 実装 - **集約ストア**: `SyncAggregate`(`(tenant_id, entity_type, uuid)` 一意、`rev` 採番、`data`(header/items/relations/allocations/materials)、`hash`、`origin_device_id`/`last_device_id`、`doc_date`、`locked`、`deleted`、`hlc`) - **冪等**: `SyncInboxV2`(`(device_id, entity_type, uuid, hash)`) - **push**: `POST /api/sync/v2/push`。判定マトリクス(`duplicate`/`rejected_fiscal`(フック)/`rejected_locked`/`accepted`/`stale_overridden`(作業データHLC後勝ち)/`conflict`(伝票・記録系)/`master_decision`(フック=当面 accepted))。発行ロックは発行系・受領系の `false→true` のみ受理、解除は不受理。accepted 時に射影/会計射影を実行 - **pull**: `GET /api/sync/v2/pull?since=`(`SyncV2Event` の単調増加 id をカーソル、`kind=aggregate`。他 kind は枠) - **v1 並行稼働**: v1(`/api/sync/push|pull`・corrections)は不変。v1→v2 移行サービス+CLI(`scripts/migrate_sync_v1_to_v2.py`、明細は親伝票へ統合) - **検証データ**: `tests/fixtures/sync_v2/*.json`(各判定の入出力例・v1移行) - migration `d5e6f7a8b9c0`(`syncaggregate`/`syncinboxv2`/`syncv2event`/`syncconflict`) ### 引き継ぎフック(後続 Issue) - #55 conflict: `SyncConflict` に受信版保存済み(解決APIは未実装) - #56 master: `master_decision()` → 当面 accepted - #57 fiscal / lock_exception: `is_fiscal_locked()` / `has_lock_exception()` → 当面 False ### 注意(インフラ) - 現在 **リモートへの git push が SSH タイムアウトで失敗**しています(`www.cyberius.biz:18`)。実装はローカル main(`7c50050`)にマージ済み・`o2.db` 適用済みで、**push は回復後に実施**します。
Sign in to join this conversation.
No labels
h1-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
joe/o2#54
No description provided.