Skip to content

Support safe offline intent rebasing - #59

Merged
rdlabo merged 5 commits into
mainfrom
feat/offline-conflict-policy
Aug 12, 2026
Merged

Support safe offline intent rebasing#59
rdlabo merged 5 commits into
mainfrom
feat/offline-conflict-policy

Conversation

@rdlabo

@rdlabo rdlabo commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add an opt-in aggregate reducer for rebasing commutative offline intents onto a newer confirmed replica revision
  • keep durable command identity, payload, payload hash, and FIFO order under Kit ownership
  • atomically persist the rebased base row, commands, and product-owned companion projections with the pull cursor
  • preserve conflict-by-default behavior when the product cannot safely reapply every pending intent

Why

Revision-sensitive writes and commutative deltas cannot share the same conflict rule. Previously, every unrelated server revision forced a pending delta into conflict and could lead users to discard a valid quantity intent. A product reducer can now recompute optimistic values from the new confirmed value without weakening idempotency or letting product code rewrite durable command payloads.

Verification

  • focused offline replica pull tests: 40/40
  • ESLint: pass
  • git diff --check: pass

Open in Devin Review

@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

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

QR Code

Use your smartphone camera to open QR code link.

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

@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 3 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +257 to +273
async #currentCompanionRows(
scope: OfflineScope,
commands: readonly OfflineCommand[],
): Promise<OfflineReplicaRow[]> {
const keys = new Map(
commands.flatMap((command) =>
(command.optimisticCompanions ?? []).map((companion) => [
`${companion.key.sourceKey}:${JSON.stringify(companion.key.identity)}`,
companion.key,
] as const),
),
);
const rows = await Promise.all(
[...keys.values()].map((key) => this.#repository.getReplicaRow(scope, key.sourceKey, key.identity)),
);
return rows.filter((row): row is OfflineReplicaRow => row !== null);
}

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.

🟡 削除待ちの関連行が「存在しない」と見なされ、再表示されてしまう

付随行の読み出しに削除待ち行を隠すAPIを使っている(getReplicaRow at projects/kit/offline/src/lib/offline-replica-pull.service.ts:270)ため、削除予定の行が製品側の再計算に渡されず、消えたはずの行が作り直される可能性があります。
Impact: ユーザーがオフライン中に削除した明細が、同期後に画面へ復活して見えることがあります。

pending_delete行の可視性とスコープ解決の不一致

IonicOfflineRepository.getReplicaRowvisibility === 'pending_delete' の行を null として返します(projects/kit/offline/src/lib/offline-repository.ts:290)。一方、同等の用途を持つ同期側のヘルパ #getCompanionRowgetReplicaRowIncludingPendingDelete を優先して使い、さらに key.userId / key.scopeId(companion key 自身のスコープ)でスコープを組み立てています(projects/kit/offline/src/lib/offline-sync.service.ts:1196-1201)。

新規の #currentCompanionRows は (1) 削除待ちを隠す getReplicaRow のみを使い、(2) companion key が持つスコープではなく pull の scope を使います。schema.scope === 'user' の集約では related が別 scopeId のコマンドを含み得る(projects/kit/offline/src/lib/offline-replica-pull.service.ts:81)ため、partition 分割されたcompanion行が解決できず欠落します。欠落した行は rebasePendingCommands に渡されないため、製品側は「存在しない」と判断して putRows で再生成し、削除待ちの状態を上書きしてしまいます。

Prompt for agents
offline-replica-pull.service.ts の #currentCompanionRows は、companion 行の取得に this.#repository.getReplicaRow(scope, ...) を使っている。offline-sync.service.ts の #getCompanionRow と同様に、(1) getReplicaRowIncludingPendingDelete があればそれを優先し、(2) スコープは pull の scope ではなく companion.key 自身の userId/scopeId から組み立てるべき。そうしないと削除待ち(pending_delete)の付随行や別 scopeId の付随行が製品の rebasePendingCommands に渡らず、製品が行を再生成して削除意図を打ち消す恐れがある。
Open in Devin Review

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

Comment on lines +173 to +191
const revisionChanged = related.some((command) => command.baseRevision !== change.serverRevision);
const rebase = revisionChanged
? ((await this.#executor.rebasePendingCommands?.(
related,
confirmedValues,
change.serverRevision,
await this.#currentCompanionRows(scope, related),
)) ?? null)
: null;
this.#assertRebasedValues(related, rebase?.optimisticValues ?? null);
const conflicted = revisionChanged && rebase === null;
const rebasedCommands = rebase
? related.map((command, index) => ({
...command,
baseRevision: change.serverRevision,
optimisticValue: rebase.optimisticValues[index],
}))
: [];
for (const command of rebasedCommands) putCommands.set(command.commandId, command);

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.

🟡 競合状態のまま送信できない変更が「同期中」と表示され続ける

すでに競合や拒否で止まっている変更まで新しいサーバー値へ載せ替えてしまう(rebasePendingCommands 呼び出し at projects/kit/offline/src/lib/offline-replica-pull.service.ts:174-183)ため、送信できない状態のまま行だけが同期中の表示になります。
Impact: いつまでも送られない編集が「同期待ち」と表示され、ユーザーが競合に気付けなくなります。

コマンド状態を考慮しないrebaseと行のsyncState決定

relatedcommands.filter(...) で集約に属する全コマンドを対象にしており、state によるフィルタがありません(projects/kit/offline/src/lib/offline-replica-pull.service.ts:123-127)。そのため直前のpullで state: 'conflict' / lastErrorCode: 'remote_revision' になったコマンドも rebase 対象になります。

rebase が成功すると conflicted は false になり、行は syncState: 'pending'offline-replica-pull.service.ts:200)になりますが、コマンド側は baseRevisionoptimisticValue だけが更新され state/lastErrorCode は 'conflict' のまま残ります(offline-replica-pull.service.ts:184-191)。

送信側は head が pending/retry_wait でなければ集約の送信を打ち切るため(projects/kit/offline/src/lib/offline-sync.service.ts:924,932)、このコマンドは永久に送信されません。また #replicaState では conflict コマンドの行は 'conflict' 表示になるはず(offline-sync.service.ts:1120-1122)で、行の 'pending' 表示とも矛盾します。blocked_auth / rejected のコマンドでも同様です。

Prompt for agents
offline-replica-pull.service.ts の rebase 経路は、集約に属する全コマンド(related)をそのまま rebasePendingCommands に渡し、成功時に baseRevision/optimisticValue のみ更新している。conflict / rejected / blocked_auth のように送信が止まっているコマンドが含まれる場合、rebase 成功で行の syncState が 'pending' になる一方コマンドは送信不能のままとなり、状態が矛盾する。rebase 対象を pending/retry_wait の集約に限定する(それ以外が含まれる場合は従来通り conflict 扱いにする)か、rebase 成功時にコマンド状態と lastErrorCode もリセットするか、いずれかの方針を決めて整合させること。
Open in Devin Review

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

Comment on lines +60 to +66
export interface OfflinePendingRebase {
/** Recomputed optimistic values in the original durable FIFO order. */
optimisticValues: readonly unknown[];
/** Product-owned companion rows rematerialized from the new confirmed value. */
putRows?: readonly OfflineReplicaRow[];
removeRows?: readonly OfflineReplicaRowKey[];
}

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.

🟡 新しく公開された型にドキュメントコメントが無い

公開エクスポートされる型定義にJSDocが付与されていない(OfflinePendingRebase at projects/kit/offline/src/lib/offline-command-executor.ts:60)ため、リポジトリ規約に違反しています。
Impact: 公開APIのドキュメント品質が規約どおりに保たれません。

AGENTS.md の要求

AGENTS.md「When modifying this repo」3項に「Every public class, function, and type must have a JSDoc comment.」とあります。OfflinePendingRebaseprojects/kit/offline/src/public-api.ts:7 経由で公開されますが、インターフェース本体にJSDocがありません(メンバの removeRows にもコメントがありません)。

Suggested change
export interface OfflinePendingRebase {
/** Recomputed optimistic values in the original durable FIFO order. */
optimisticValues: readonly unknown[];
/** Product-owned companion rows rematerialized from the new confirmed value. */
putRows?: readonly OfflineReplicaRow[];
removeRows?: readonly OfflineReplicaRowKey[];
}
/** Result of a product-owned rebase of pending intents onto a newer confirmed revision. */
export interface OfflinePendingRebase {
/** Recomputed optimistic values in the original durable FIFO order. */
optimisticValues: readonly unknown[];
/** Product-owned companion rows rematerialized from the new confirmed value. */
putRows?: readonly OfflineReplicaRow[];
/** Product-owned companion rows that no longer exist after the rebase. */
removeRows?: readonly OfflineReplicaRowKey[];
}
Open in Devin Review

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

@rdlabo
rdlabo merged commit eba80bb into main Aug 12, 2026
12 checks passed
@rdlabo
rdlabo deleted the feat/offline-conflict-policy branch August 12, 2026 09:01
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