Skip to content

Simplify offline reconciliation around immutable intents - #69

Merged
rdlabo merged 1 commit into
mainfrom
refactor/offline-intent-reconciliation
Aug 13, 2026
Merged

Simplify offline reconciliation around immutable intents#69
rdlabo merged 1 commit into
mainfrom
refactor/offline-intent-reconciliation

Conversation

@rdlabo

@rdlabo rdlabo commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What changed

  • replace persisted optimistic snapshots and companion before/after images with immutable command payloads plus local-only footprint keys
  • add one product-owned, deterministic aggregate intent projector used by enqueue, batch, replacement, discard, pull, restart, and ACK reconciliation
  • keep successful commands as awaiting_pull until an authoritative change acknowledges the exact command and remote identity
  • persist reconciliation identity so delete commands can reconcile after their local remote ID is released
  • add a typed conflict outcome for revision-sensitive intents while preserving visible optimistic state and advancing only the confirmed baseline
  • move the fresh internal repository baseline to schema v2 and reduce duplicated state-transition tests through shared helpers

Why

The previous model persisted several representations of the same pending business state: command optimistic values, companion before/after rows, current replica values, and executor-produced confirmed patches. Keeping those representations consistent across response loss, pull, discard, replacement, and restart created most of the offline implementation's maintenance cost.

The new contract has three owners: pull owns confirmed state, Outbox owns immutable ordered intent, and one pure projector derives the visible aggregate. Kit owns the transaction and validates identity, scope, footprint, ACK, and conflict boundaries.

Compatibility

This is a breaking synchronized-offline contract and a fresh internal storage baseline. Consumers must register OFFLINE_AGGREGATE_INTENT_PROJECTOR, migrate command creation away from optimisticValue/companion snapshots, and provide authoritative reconciliation targets on pull. Read-cache-only consumers are unchanged.

Validation

  • full Kit suite: 44 files / 812 tests
  • offline suite: 15 files / 477 tests
  • focused projector and pull: 62 tests
  • focused sync and SQLite: 202 tests
  • Kit ESLint and Prettier
  • Angular package and demo production build
  • independent manager review: APPROVE

Integration order

Merge and release this PR before the dependent Winecode app/Hono PR.

@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for rdlabo-ionic-angular-library ready!

Name Link
🔨 Latest commit bf6de81
🔍 Latest deploy log https://app.netlify.com/projects/rdlabo-ionic-angular-library/deploys/6a7d4354ac924300087946f1
😎 Deploy Preview https://deploy-preview-69--rdlabo-ionic-angular-library.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@rdlabo
rdlabo marked this pull request as ready for review August 13, 2026 04:09
@rdlabo
rdlabo merged commit 2341cbe into main Aug 13, 2026
12 checks passed
@rdlabo
rdlabo deleted the refactor/offline-intent-reconciliation branch August 13, 2026 04:10

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +315 to +325
const identity =
command.reconciliationIdentity ??
(row.identity.kind === 'generated'
? row.identity.remoteId === null
? null
: { remoteId: row.identity.remoteId }
: row.identity.kind === 'natural'
? { naturalKey: row.identity.naturalKey }
: null);
if (!identity) throw new Error(`Awaiting-pull command "${command.commandId}" has no remote identity.`);
canonicalOfflineRemoteIdentity(this.#entitySchema(command.sourceKey), identity);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 サーバーがIDを返さない新規作成では、以降の同期がすべて失敗し続け復旧できない

サーバーの受理後に残る作成コマンドから、リモート識別子が無い場合に例外を投げてしまう(throw new Error(...has no remote identity) at projects/kit/offline/src/lib/offline-replica-pull.service.ts:324)ため、そのスコープの同期が毎回失敗し、ユーザーはその項目を取り消すこともできなくなります。
Impact: 一度この状態になると、そのユーザーのオフラインデータが二度とサーバーと同期されず、アプリ側からの復旧手段もありません。

awaiting_pull コマンドがリモート識別子を持てない経路

#completeCommandLocked (projects/kit/offline/src/lib/offline-sync.service.ts:1260-1275) は、送信成功したコマンドを awaiting_pull にし、reconciliationIdentityresolvedRemoteId !== null のときだけ付与します。生成識別子の新規作成で executor が remoteId を返さない(例: 202 応答や本文なし応答)場合、resolvedRemoteIdnull のままで reconciliationIdentity は付きません。レプリカ行の identity.remoteIdnull のままです。

次回以降の #reconciliationTargets (projects/kit/offline/src/lib/offline-replica-pull.service.ts:307-329) は awaiting_pull の全コマンドについて識別子を要求し、両方 null のため必ず throw します。この呼び出しは pull ループ先頭(変更が空でも実行される箇所)にあるため、以後そのスコープの pull はページ内容に関わらず常に失敗します。さらに #assertDiscardable (projects/kit/offline/src/lib/offline-sync.service.ts:852-857) が awaiting_pull の破棄を禁止しているため、ユーザー操作でも解消できません。

Prompt for agents
生成識別子のコマンドが送信成功したにもかかわらずサーバーが remoteId を返さない場合、reconciliationIdentity もレプリカ行の remoteId も存在しないため、#reconciliationTargets が例外を投げて当該スコープの pull が恒久的に失敗します。awaiting_pull コマンドは破棄も禁止されているため復旧経路がありません。対応案としては、(a) 送信完了時 (offline-sync.service.ts の #completeCommandLocked) にリモート識別子を確定できない場合は awaiting_pull にせず従来どおりコマンドを完了扱いにする、(b) 識別子なしターゲットを commandId のみで照合できる形にして pull リクエストに載せる、(c) 少なくとも throw ではなく当該コマンドをアテンション状態へ遷移させて他スコープ/他コマンドの同期を止めない、のいずれかを検討してください。
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +247 to 261
const remaining = related.filter((command) => !removeCommandIds.has(command.commandId));
const confirmedRow: OfflineReplicaRow = {
...existing,
values: rebasedCommands.at(-1)?.optimisticValue ?? (hasPending ? existing.values : confirmedValues),
confirmedValues,
serverRevision: change.serverRevision,
fetchedAt: Date.now(),
syncState: conflicted ? 'conflict' : hasPending ? 'pending' : 'confirmed',
});
if (conflicted) {
for (const command of related) {
putCommands.set(command.commandId, {
...command,
state: 'conflict',
retryAt: null,
lastErrorCode: 'remote_revision',
});
}
values: remaining.length > 0 ? existing.values : confirmedValues,
syncState: remaining.length > 0 ? 'pending' : 'confirmed',
};
putRows.push(confirmedRow);
if (remaining.length > 0) rematerializeAfter.push(remaining[0]!);
for (const command of remaining) {
putCommands.set(command.commandId, this.#executor.withServerRevision(command, change.serverRevision));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 送信失敗で「要対応」になった項目が、次の同期で通常の保留中に戻り警告が消える

要対応状態の項目を、残っている変更として通常どおり再計算・再基準化してしまう(withServerRevision(command, change.serverRevision) at projects/kit/offline/src/lib/offline-replica-pull.service.ts:259)ため、拒否・認証ブロックされた項目の警告表示が消え、後で再送すると新しいサーバー値を無検証で上書きします。
Impact: ユーザーが解決すべき失敗項目が通常の保留中と見分けられなくなり、再送時に他端末の更新を上書きしてデータを失う恐れがあります。

状態遷移の詳細

以前は rebaseEligiblepending / retry_wait のみ)でない場合は remote_revision conflict へ遷移させていました。新実装では related から削除済みコマンドを除いた remainingrejected / blocked_auth のコマンドも含まれ、無条件に withServerRevisionbaseRevision が新しいサーバーリビジョンへ進みます(projects/kit/offline/src/lib/offline-replica-pull.service.ts:247-261)。

さらに #rematerializePendingAggregates (projects/kit/offline/src/lib/offline-replica-pull.service.ts:431-446) は、state === 'conflict' のときだけ conflict 扱いにし、それ以外(rejected / blocked_auth)は通常の projector 呼び出しへ進みます。コーディネーター側の検証は残コマンドがある場合にレプリカ行の syncState'pending' であることを必須にしている(projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts:136-138)ため、#persistFailedCommand が書き込んだ rejected / blocked_auth の行状態は次回 pull で必ず pending に戻されます('rejected' を保とうとする projector を書くと逆に pull 全体が例外で失敗します)。

この再計算は当該ページに含まれない集約に対しても実行される(remaining に全コマンドが入るため)ので、無関係な pull でも要対応行が pending に戻ります。同じ制約は trigger: 'local' の再計算(discard / enqueue)にも適用され、conflict 行も pending へ戻ります。

Prompt for agents
pull の外部変更適用と #rematerializePendingAggregates が、rejected / blocked_auth 状態のコマンドを pending と同様に扱っています。具体的には (1) 該当コマンドの baseRevision が withServerRevision で新リビジョンへ進み、(2) OfflineReplicaMutationCoordinator#assertBaseProjection が残コマンドありの場合にレプリカ行 syncState を 'pending' に固定するため、行のアテンション状態(rejected/blocked_auth/conflict)が失われます。conflict と同じく、attention 状態のコマンドを持つ集約は自動再基準化の対象外にし、行の syncState もそのアテンション状態を保持できるように検証条件を緩める(あるいは Kit 側で attention 状態を上書き適用する)方針を検討してください。ローカル trigger(discard / enqueue)でも同じ制約が働く点に注意が必要です。
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant