Skip to content

feat(llc, persistence): add offline support for reactions - #2847

Open
VelikovPetar wants to merge 15 commits into
masterfrom
feature/FLU-506_add_reactions_offline_support
Open

feat(llc, persistence): add offline support for reactions#2847
VelikovPetar wants to merge 15 commits into
masterfrom
feature/FLU-506_add_reactions_offline_support

Conversation

@VelikovPetar

@VelikovPetar VelikovPetar commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Submit a pull request

Linear: FLU-506

Github Issue: #

CLA

  • I have signed the Stream CLA (required).
  • The code changes follow best practices
  • Code changes are tested (add some information if not applicable)

Description of the pull request

Adds offline support for reactions with a layered replay model. When a reaction add/remove fails with a transient/offline error, the optimistic change is kept and the operation is queued for replay when the connection recovers, instead of being reverted. Terminal (server-rejected) failures still roll back; only transient/offline errors are queued.

  • In-memory queue (always on). The queue lives in memory for the session and is the single source replay runs from — so reactions replay after network jank or a short outage for every client, whether or not offline storage is enabled.
  • Persistence (durability layer). When offline storage is enabled, the queue is additionally mirrored to the database, so queued operations survive process death and are re-hydrated into memory on the next connect.

Low-level client (stream_chat):

  • PendingOperation model and internal ReactionPendingOperation (add/delete) describing a queued reaction op.
  • PendingOperationsManager (@internal) owns the in-memory queue and its lifecycle: enqueue (write-through to persistence when enabled), hydrate (DB → memory at connect), clear (on disconnect — prevents a user's ops leaking into the next session), and replay (single-attempt, per-op, FIFO, on connection recovery before /sync and channel re-query so the server sees mutations first).
  • Channel.sendReaction / Channel.deleteReaction enqueue on any retriable error and keep the optimistic change, rolling back only on terminal errors.
  • ChatPersistenceClient.insertPendingOperation returns the stored row id (used to correlate memory and DB entries).

Persistence (stream_chat_persistence):

  • PendingOperations Drift table, PendingOperationDao, entity, and mapper, wired into DriftChatDatabase and StreamChatPersistenceClient; insertPendingOperation returns the autoincrement id.

Tests:

  • Unit tests for the pending-operation model, DAO, and PendingOperationsManager (persistence on/off, hydrate, clear, insert-failure fallback, and FIFO / transient-keep / terminal-drop replay).
  • Updated channel/client tests and mocks, including a cross-user-leak regression on disconnectUser.
  • Updated sample_app E2E reactions integration test.

Screenshots / Videos

No UI changes.

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds durable pending-operation storage for reaction mutations, queues retriable offline reaction sends/deletes, and replays them in order after websocket recovery. Persistence includes Drift schema, DAO, mappings, client methods, mocks, and tests.

Changes

Offline reaction operations

Layer / File(s) Summary
Reaction queue contract and optimistic handling
packages/stream_chat/lib/src/client/channel.dart, packages/stream_chat/lib/src/client/reaction_pending_operation.dart, packages/stream_chat/lib/src/core/models/pending_operation.dart, packages/stream_chat/lib/src/db/chat_persistence_client.dart, packages/stream_chat/test/src/client/*, packages/stream_chat/test/src/core/models/*, packages/stream_chat/test/src/mocks.dart
Retriable reaction failures are queued when persistence is available, optimistic state is retained, and other failures roll back local state.
Pending-operation database storage
packages/stream_chat_persistence/lib/src/entity/*, packages/stream_chat_persistence/lib/src/mapper/*, packages/stream_chat_persistence/lib/src/dao/*, packages/stream_chat_persistence/lib/src/db/*, packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart, packages/stream_chat_persistence/test/*
Adds the pending-operation Drift table, mappings, DAO, database wiring, schema version, persistence methods, and storage tests.
Reconnect replay orchestration
packages/stream_chat/lib/src/client/client.dart, packages/stream_chat/lib/src/client/pending_operation_replayer.dart, packages/stream_chat/test/src/client/client_test.dart, packages/stream_chat/test/src/client/pending_operation_replayer_test.dart
Replays queued operations after recovery, deletes successful or terminally rejected entries, retains transient failures, and isolates per-operation errors.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StreamChatClient
  participant PendingOperationReplayer
  participant ChatPersistenceClient
  participant StreamChatAPI
  StreamChatClient->>PendingOperationReplayer: replay on connectionRecovered
  PendingOperationReplayer->>ChatPersistenceClient: getPendingOperations
  PendingOperationReplayer->>StreamChatAPI: replay reaction operation
  StreamChatAPI-->>PendingOperationReplayer: success or terminal response
  PendingOperationReplayer->>ChatPersistenceClient: deletePendingOperation
Loading

Possibly related PRs

Suggested reviewers: renefloor, xsahil03x

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: offline reaction support across client and persistence layers.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/FLU-506_add_reactions_offline_support

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

⚠️ Database Entity Files Modified

The following database entity files have been modified in this PR:

packages/stream_chat_persistence/lib/src/entity/entity.dart
packages/stream_chat_persistence/lib/src/entity/pending_operations.dart

📝 Remember to:

  1. ✅ Database schema version bumped to 1036.
  2. Update entity schema tests if necessary.

Note: This comment is automatically generated by the CI workflow.

# Conflicts:
#	packages/stream_chat/CHANGELOG.md
// optimistic `ownReactions` for messages that previously had none.
state?.replaceMessage(message);
}
rethrow;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not entirely sure if we should always rethrow here, maybe just in the non-retry-able case?

@VelikovPetar
VelikovPetar marked this pull request as ready for review July 28, 2026 14:05
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.31250% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.06%. Comparing base (34cd23c) to head (09c99b8).

Files with missing lines Patch % Lines
...hat/lib/src/client/pending_operations_manager.dart 91.52% 5 Missing ⚠️
...am_chat/lib/src/core/models/pending_operation.dart 91.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2847      +/-   ##
==========================================
+ Coverage   73.96%   74.06%   +0.09%     
==========================================
  Files         435      440       +5     
  Lines       28149    28272     +123     
==========================================
+ Hits        20821    20939     +118     
- Misses       7328     7333       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🧹 Nitpick comments (3)
packages/stream_chat_persistence/lib/src/dao/pending_operation_dao.dart (1)

19-23: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider bounding the queue.

getPendingOperations loads the entire table into memory and the queue has no cap, so a long offline session (or an operation that keeps failing retriably) can grow it without bound. A limit on the read, or a retention cap/TTL on insert, would keep replay predictable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stream_chat_persistence/lib/src/dao/pending_operation_dao.dart`
around lines 19 - 23, Bound the pending-operation queue in getPendingOperations
by applying an explicit maximum read limit while preserving ascending id order,
using the project’s established queue-size configuration or constant if
available. Ensure replay remains predictable without loading the entire
pendingOperations table into memory.
packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart (1)

61-82: 🗄️ Data Integrity & Integration | 🔵 Trivial

Destructive migration will silently drop unreplayed pending operations on future schema bumps.

onUpgrade deletes and recreates every table (including the new pending_operations table) whenever schemaVersion changes. For the other tables this is a harmless cache eviction, but pending_operations now holds durable, not-yet-acknowledged user mutations — a future schema bump before replay would silently drop a user's queued reaction/delete with no error surfaced.

Consider special-casing pending_operations in the migration (e.g. preserve/re-insert its rows across onUpgrade), or at minimum documenting this data-loss risk for maintainers bumping schemaVersion going forward.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart` around
lines 61 - 82, Update the MigrationStrategy.onUpgrade flow so schema bumps do
not silently delete durable rows from the pending_operations table: preserve and
restore its pending operations while recreating the other tables, or otherwise
explicitly retain that table and migrate it safely. Keep the existing
destructive cache migration for non-durable tables, and ensure queued mutations
remain available for replay after upgrades.
packages/stream_chat/test/src/client/pending_operation_replayer_test.dart (1)

38-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated reaction pending-operation test scaffolding across two files. addOp and stubSendReactionOk are copy-pasted verbatim between the two suites; the shared root cause is a missing common test helper for building/stubbing reaction pending operations.

  • packages/stream_chat/test/src/client/pending_operation_replayer_test.dart#L38-L62: keep as the canonical definition (or move) and have the other site import it.
  • packages/stream_chat/test/src/client/client_test.dart#L5470-L5489: replace this local copy with an import of the shared helper instead of redefining addOp/stubSendReactionOk.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stream_chat/test/src/client/pending_operation_replayer_test.dart`
around lines 38 - 62, The reaction pending-operation helpers addOp and
stubSendReactionOk are duplicated across the test suites. Keep or extract the
canonical shared definitions from
packages/stream_chat/test/src/client/pending_operation_replayer_test.dart:38-62,
then update packages/stream_chat/test/src/client/client_test.dart:5470-5489 to
import and reuse them, removing its local copies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/stream_chat/lib/src/client/channel.dart`:
- Around line 1685-1695: Update _enqueuePendingOperation to return whether
insertPendingOperation succeeded instead of swallowing the outcome. At both call
sites, including sendReaction, only retain the optimistic state when enqueue
succeeds; when it fails, fall back to state?.replaceMessage(message) so the
local reaction is reconciled.

In `@packages/stream_chat/lib/src/client/pending_operation_replayer.dart`:
- Around line 89-110: Validate operation.targetMessageId before returning either
deferred replay closure in _replayCallFor, and treat a null value as malformed
so the existing caller drops it through its malformed-payload handling. Replace
the deferred non-null assertions used by sendReaction and deleteReaction with
the validated value while preserving the existing replay behavior for valid
operations.

In `@packages/stream_chat/lib/src/db/chat_persistence_client.dart`:
- Around line 359-367: Update the doc comments for insertPendingOperation,
getPendingOperations, and deletePendingOperation to explicitly warn that the
default no-op implementations silently discard pending operations and can
prevent optimistic reaction replay; instruct custom ChatPersistenceClient
implementations to override all three methods together when persistence is
enabled.

---

Nitpick comments:
In `@packages/stream_chat_persistence/lib/src/dao/pending_operation_dao.dart`:
- Around line 19-23: Bound the pending-operation queue in getPendingOperations
by applying an explicit maximum read limit while preserving ascending id order,
using the project’s established queue-size configuration or constant if
available. Ensure replay remains predictable without loading the entire
pendingOperations table into memory.

In `@packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart`:
- Around line 61-82: Update the MigrationStrategy.onUpgrade flow so schema bumps
do not silently delete durable rows from the pending_operations table: preserve
and restore its pending operations while recreating the other tables, or
otherwise explicitly retain that table and migrate it safely. Keep the existing
destructive cache migration for non-durable tables, and ensure queued mutations
remain available for replay after upgrades.

In `@packages/stream_chat/test/src/client/pending_operation_replayer_test.dart`:
- Around line 38-62: The reaction pending-operation helpers addOp and
stubSendReactionOk are duplicated across the test suites. Keep or extract the
canonical shared definitions from
packages/stream_chat/test/src/client/pending_operation_replayer_test.dart:38-62,
then update packages/stream_chat/test/src/client/client_test.dart:5470-5489 to
import and reuse them, removing its local copies.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 18d78a56-10bc-41d9-8129-154bb6dd524d

📥 Commits

Reviewing files that changed from the base of the PR and between 44b3bc5 and ff7a4b1.

📒 Files selected for processing (25)
  • packages/stream_chat/CHANGELOG.md
  • packages/stream_chat/lib/src/client/channel.dart
  • packages/stream_chat/lib/src/client/client.dart
  • packages/stream_chat/lib/src/client/pending_operation_replayer.dart
  • packages/stream_chat/lib/src/client/reaction_pending_operation.dart
  • packages/stream_chat/lib/src/core/models/pending_operation.dart
  • packages/stream_chat/lib/src/db/chat_persistence_client.dart
  • packages/stream_chat/lib/stream_chat.dart
  • packages/stream_chat/test/src/client/channel_test.dart
  • packages/stream_chat/test/src/client/client_test.dart
  • packages/stream_chat/test/src/client/pending_operation_replayer_test.dart
  • packages/stream_chat/test/src/core/models/pending_operation_test.dart
  • packages/stream_chat/test/src/mocks.dart
  • packages/stream_chat_persistence/CHANGELOG.md
  • packages/stream_chat_persistence/lib/src/dao/dao.dart
  • packages/stream_chat_persistence/lib/src/dao/pending_operation_dao.dart
  • packages/stream_chat_persistence/lib/src/dao/pending_operation_dao.g.dart
  • packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart
  • packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart
  • packages/stream_chat_persistence/lib/src/entity/entity.dart
  • packages/stream_chat_persistence/lib/src/entity/pending_operations.dart
  • packages/stream_chat_persistence/lib/src/mapper/mapper.dart
  • packages/stream_chat_persistence/lib/src/mapper/pending_operation_mapper.dart
  • packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart
  • packages/stream_chat_persistence/test/src/dao/pending_operation_dao_test.dart

Comment thread packages/stream_chat/lib/src/client/channel.dart Outdated
Comment thread packages/stream_chat/lib/src/client/pending_operation_replayer.dart Outdated
Comment thread packages/stream_chat/lib/src/db/chat_persistence_client.dart Outdated
- Roll back the optimistic reaction when enqueueing the pending operation
  fails, so the local state can no longer diverge from the server.
- Validate targetMessageId eagerly in the replayer so a malformed operation
  is dropped instead of being retried forever.
- Document the default no-op pending-operation methods on
  ChatPersistenceClient and group them at the bottom of the public surface.
- Add persistence-client delegation tests for the pending-operation methods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@VelikovPetar

Copy link
Copy Markdown
Contributor Author

Thanks for the nitpicks — decisions on each:

Bound the queue (pending_operation_dao.dart): leaving unbounded for now. With single-attempt replay, entries are removed on accept or terminal reject, so the queue is bounded by activity within a single offline session; a hard cap raises its own "which end to drop" question.

Destructive migration drops pending ops (drift_chat_database.dart): accepted tradeoff, not changing. The persistence layer is a disposable cache with destructive migration by design; a schemaVersion bump dropping unreplayed operations is understood and acceptable.

Duplicated test helpers: leaving the small duplication. The two suites test different layers (replayer semantics vs. client wiring) and the helpers are tiny.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart`:
- Around line 4275-4574: The generated Drift artifact was edited directly
instead of being regenerated. Update the source Drift table and database
definitions corresponding to PendingOperations, then run the project’s Drift
code-generation command to recreate drift_chat_database.g.dart and keep the
table, PendingOperationEntity, and PendingOperationsCompanion definitions
synchronized.
- Around line 4315-4358: Regenerate the Drift-generated table code with Drift
2.33.0 so PendingOperationsTable.validateIntegrity includes payload metadata
validation and the required-column missing check for payload. Ensure the
generated handling covers both provided values and inserts that omit the
required converted payload column.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 624dca50-9bf9-44ce-84bb-7c2169f8ad9a

📥 Commits

Reviewing files that changed from the base of the PR and between 2314f94 and 37f972b.

📒 Files selected for processing (2)
  • packages/stream_chat_persistence/lib/src/dao/pending_operation_dao.g.dart
  • packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/stream_chat_persistence/lib/src/dao/pending_operation_dao.g.dart

VelikovPetar and others added 3 commits July 30, 2026 12:53
# Conflicts:
#	packages/stream_chat/test/src/client/client_test.dart
Introduce an always-on in-memory pending-operation queue so a reaction
that fails on a transient/offline error is replayed on reconnect for
every client, not only those with offline persistence. This gives
same-session resilience to network jank and short outages. When
persistence is enabled the queue is additionally mirrored to the
database, so operations survive process death and are re-hydrated into
memory on the next connect.

- Expand `PendingOperationReplayer` into `PendingOperationsManager`
  (`@internal`): it owns the in-memory queue and its lifecycle —
  `enqueue` (write-through to persistence), `hydrate` (DB -> memory at
  connect), `clear` (on disconnect), and `replay` (single-attempt,
  per-op, FIFO, before `/sync` and channel re-query).
- `Channel.sendReaction` / `deleteReaction` now enqueue on any retriable
  error and keep the optimistic change, rolling back only on terminal
  (server-rejected) errors — regardless of whether persistence is on.
- Clear the in-memory queue on `disconnectUser` so a user's queued
  operations can never replay under the next connected user.
- `ChatPersistenceClient.insertPendingOperation` now returns the stored
  row id, used to correlate memory and DB entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	packages/stream_chat/CHANGELOG.md
#	packages/stream_chat_persistence/CHANGELOG.md

@renefloor renefloor 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.

Review: offline reactions (2 independent passes + cross-examination)

Reviewed this twice with independent lenses — runtime correctness/concurrency and data-model/API/tests — then had both passes cross-examine each other's findings against the code. What follows is the converged set. dart analyze --fatal-infos is clean on both packages and all touched tests pass (604 in stream_chat, 75 in stream_chat_persistence), so everything below is logic or design, not lint.

The design is sound and several non-obvious things are right: replay correctly bypasses the optimistic path by calling _client.sendReaction rather than Channel.sendReaction (no double update, no recursive re-enqueue); the persisted mirror is genuinely user-scoped (db_$userId.sqlite); the stored reaction payload is byte-for-byte what MessageApi.sendReaction puts on the wire; flush() clears the new table via allTables; and the schema bump plus drop-and-recreate migration is correct. The layered memory/persistence split is the right shape.

Inline comments cover one blocker and six majors, plus a type-safety suggestion for PendingOperation. Minor findings are omitted deliberately.

Blocker

  • clear() does not cancel an in-flight replay(), so queued operations keep firing after disconnectUser() — and if a reconnect has landed, they are sent under the next user's credentials, with a follow-on deletePendingOperation against that user's database. ~3-line fix.

Major

  • clear() resets _memorySeq, so session ids are reused and a stale replay silently deletes a new operation. Nothing is logged.
  • The "replay before re-query" invariant only holds when replay succeeds. On a retriable failure the reaction is kept locally and then wiped by sync()/queryChannelsOnline(), because Message.updateWith takes reactionGroups wholesale from the server. The reaction visibly disappears on reconnect and reappears later — worse than the pre-PR rollback.
  • The replay loop does not fail fast on connection-level errors, and it awaits on the critical path of recovery: N queued operations × Dio timeout blocks sync() and channel recovery. Non-StreamChatNetworkError failures also pin an operation forever.
  • Replay has a single trigger (connectionRecovered); a trigger dropped under _isReplaying is never rescheduled, and connectWebSocket: false never replays at all.
  • PendingOperations has no createdAt and no attempt/terminal marker — no TTL, no cap, no way to give up, no cleanup on message/channel deletion. onUpgrade already drops and recreates every table in this PR, so adding the columns is free now and costs another cache-wiping bump later.
  • The enqueue wiring is untested: deleting the enqueue calls from channel.dart fails zero tests.

Questions I could not settle from the code

  1. Is disconnectUser during an in-flight reconnect a supported flow? It determines how urgent the blocker is — though since the failure mode is a cross-user write, I would not ship it either way.
  2. Is ChatPersistenceClient intended to be implements-able? Adding the three new members is a source break for implements users (concrete bodies only rescue extends), and CLAUDE.md names this class explicitly. Non-blocking, but it wants a 🔄 Changed changelog line — or a base modifier behind a deprecation if implements was never intended.
  3. Is re-replaying an operation the server already terminally rejected acceptable after a failed DB delete? A terminal marker column answers this and the TTL gap at once.

One changelog note worth adding regardless: the schema bump means this release wipes every user's local cache on upgrade. Pre-existing mechanism, but this PR is what triggers it.

Comment on lines +116 to +119
try {
// Copy so removals during replay don't mutate the list being iterated.
final operations = List.of(_operations);
for (final operation in operations) {

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.

Blocker — clear() does not cancel an in-flight replay, so operations can be sent under the next user's credentials.

replay() iterates this snapshot, awaiting one network call per operation. clear() (L90-93) only empties _operations — it sets no cancellation flag, the loop never re-checks membership, and _isReplaying self-clears in the finally, so nothing stops the iteration either.

Sequence (verified against this branch with a throwaway test):

  1. User A has [m1, m2] queued; connection recovers, replay() starts, sendReaction(m1) is in flight on a slow network.
  2. disconnectUser() runs → pendingOperationsManager.clear() (client.dart:2561). Queue is empty.
  3. m1 completes. The loop continues over its stale copy and calls _client.sendReaction(m2, ...).

verify(sendReaction('m2', ...)).called(1) passes after clear(). If connectUser(B) has landed by step 3, _client holds B's token, so A's reaction is posted as B.

Second-order effect on the same path: _remove(id) then calls deletePendingOperation(id) against B's database (L99-101 early-returns only on !persistenceEnabled || id < 0). Ids are per-DB autoincrement from 1, so A's id: 1 plausibly deletes B's row 1.

The disconnectUser regression test in client_test.dart only covers replay() invoked after clear(), not clear() during a replay — so this is not caught.

Suggested fix — an epoch bumped by clear() and checked per iteration and before _remove:

int _generation = 0;

void clear() {
  _operations.clear();
  _generation++;
}

// inside replay():
final generation = _generation;
for (final operation in operations) {
  if (generation != _generation) return; // cleared mid-replay
  ...
}

Checking _operations.any((it) => it.id == operation.id) before each call would also work, but an epoch is cheaper and additionally protects the _remove call.

Comment on lines +90 to +93
void clear() {
_operations.clear();
_memorySeq = 0;
}

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.

Major — resetting _memorySeq makes session ids collide, and a stale replay then silently deletes a new operation.

_nextMemoryId() returns --_memorySeq, so after clear() the next memory-only operation gets -1 again. _remove(id) matches purely on it.id == id (L98). Combined with the in-flight-replay issue above:

  1. Persistence off. m1 (id -1) and m2 (id -2) queued. replay() starts, m1 in flight.
  2. clear() (disconnect). New session enqueues m99 → id -1.
  3. m1 succeeds → the stale loop calls _remove(-1)removeWhere deletes m99.

The user's reaction is never sent, local optimistic state keeps showing it, and nothing is logged. Ids need to stay unique for the process lifetime:

Suggested change
void clear() {
_operations.clear();
_memorySeq = 0;
}
void clear() {
_operations.clear();
// Do NOT reset `_memorySeq`: session ids must stay unique for the whole
// process lifetime, otherwise a replay that is still draining its snapshot
// can `_remove` an id that now belongs to a new session's operation.
}

This is the demonstrated instance of a broader gap: nothing owns the id value domain. PendingOperation.id is documented as "the database autoincrement id" while this class also mints negative session ids; insertPendingOperation's contract never states that ids must be positive and unique; _remove overloads the sign as a storage-tier discriminator (L99); and hydrate() trusts id! unvalidated, so a custom ChatPersistenceClient returning a null id throws into the blanket catch and pins that operation in memory for the rest of the process. See the type-safety suggestion on pending_operation.dart for a fix that removes the whole class of problem.

Comment on lines +145 to +153
try {
await call();
} on StreamChatNetworkError catch (error) {
// Keep transient failures for the next recovery.
if (error.isRetriable) continue;
}

// Accepted or terminally rejected by the server — drop it.
await _remove(operation.id!);

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.

Major — no fail-fast on connection-level failures, and non-network errors pin an operation forever.

Two separate problems in this block:

1. The loop continues past connection errors instead of breaking. A replay that is doomed after operation #1 (still offline, DNS failure, captive portal) still attempts every remaining operation, each burning a full Dio timeout. With N queued operations that is N × timeout seconds — and because client.dart:621 awaits this whole thing, it blocks sync() and channel recovery for that entire time. Recommend breaking out on the first StreamChatNetworkErrorType.connectionError / *Timeout, since those say nothing about the next operation but everything about the connection.

2. Only StreamChatNetworkError is caught here. Anything else — a TypeError from an unexpected response shape, a raw DioException, a StateError — escapes to the outer catch at L154, gets logged, and the operation is neither removed nor marked. With no attempt counter on the row (see the PendingOperations table comment), such a poison operation is retried on every reconnect for the lifetime of the install. The observable symptom is "silently stuck", not a retry storm, because nothing re-triggers replay in between.

Worth noting the related isRetriable sharp edge, since this line is where it decides whether local state is kept: isRetriable => data == null classifies 429 and any enveloped 5xx as terminal, so those get rolled back rather than queued. True offline (connectionError, no body) does queue correctly, so the headline path works — but the canonical "retry later" responses currently don't.

Comment on lines +111 to +114
/// Replays each queued operation against the server in insertion order.
Future<void> replay() async {
if (_isReplaying) return;
_isReplaying = true;

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.

Major — replay has exactly one trigger, a dropped trigger is never rescheduled, and there is one supported mode where it never fires at all.

git grep pendingOperationsManager on this branch returns a single replay() call site: the connectionRecovered branch at client.dart:621. Three consequences:

  1. Retriable failure while the WebSocket stays up strands the queue. A receive timeout leaves operations queued, but there is no timer, no backoff and no RetryQueue/RetryPolicy hook — they wait for the next full disconnect → connect cycle.
  2. if (_isReplaying) return; drops the trigger silently. There is no "replay again when the current pass finishes" flag, so a recovery that lands while a replay is draining is simply lost. Combined with (1), operations can sit for the rest of the session while local state stays diverged — and, per the client.dart comment, visibly wrong after the re-query.
  3. connectUser(..., connectWebSocket: false) never produces a status transition, so queued operations never replay in connection-less mode. That is a documented public flag, and the feature is inert there.

(3) is the sharpest of the three — worth either handling explicitly or documenting as unsupported. The guard itself is also completely uncovered by tests; a test that calls replay() twice against an unresolved future would pin the intended behaviour.

Comment on lines +175 to +204
Future<void> Function()? _replayCallFor(PendingOperation operation) {
switch (operation.type) {
case ReactionPendingOperation.addType:
final targetMessageId = operation.targetMessageId;
if (targetMessageId == null) {
throw StateError('Missing targetMessageId for ${operation.type}');
}
final reaction = Reaction.fromJson(
operation.payload[ReactionPendingOperation.reactionKey] as Map<String, dynamic>,
);
final skipPush = operation.payload[ReactionPendingOperation.skipPushKey] as bool? ?? false;
final enforceUnique = operation.payload[ReactionPendingOperation.enforceUniqueKey] as bool? ?? false;
return () => _client.sendReaction(
targetMessageId,
reaction,
skipPush: skipPush,
enforceUnique: enforceUnique,
);
case ReactionPendingOperation.deleteType:
final targetMessageId = operation.targetMessageId;
if (targetMessageId == null) {
throw StateError('Missing targetMessageId for ${operation.type}');
}
final reactionType = operation.payload[ReactionPendingOperation.reactionTypeKey] as String;
return () => _client.deleteReaction(targetMessageId, reactionType);
default:
// Unknown operation type — cannot be replayed by this version.
return 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.

Major — inconsistent strictness at the parse boundary; the silent ?? false is a real divergence bug, not just a style issue.

Within one function there are three different policies for missing data:

  • targetMessageId missing → throw StateError (L179-181, L195-197)
  • reaction → unchecked as Map<String, dynamic> (L183)
  • skip_push / enforce_uniquesilent as bool? ?? false (L185-186)

The third one is the problem. enforceUnique is the flag the optimistic update already applied (channel.dart:1613 calls addMyReaction(..., enforceUnique: enforceUnique)), so if the key is ever missing or renamed, replay sends enforceUnique: false: local state shows one reaction replacing the previous one, while the server keeps both. Then the post-recovery re-query overwrites reactionGroups from server truth (see the client.dart thread) and the user ends up with two chips they never asked for. Silent, permanent, user-visible.

Also note the StateErrors exist only because targetMessageId is nullable on the model and in the DB column while being required by every operation type that exists — two of the eight manager tests exist purely to prove an impossible state is detected.

Minimum fix here is consistency — treat a missing enforce_unique/skip_push exactly like a missing targetMessageId, so it lands in the "unreplayable, drop it" path rather than silently changing semantics:

final skipPush = operation.payload[ReactionPendingOperation.skipPushKey] as bool;
final enforceUnique = operation.payload[ReactionPendingOperation.enforceUniqueKey] as bool;

The better fix is to stop parsing at replay time altogether — see the suggestion on pending_operation.dart, which makes this whole function exhaustive and cast-free.

Comment on lines +618 to +621
// Replay pending offline operations (e.g. reactions) BEFORE any
// server-state refresh, so the server has each mutation before a re-query
// returns state that would otherwise clobber the optimistic change.
await pendingOperationsManager.replay();

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.

Major — the stated invariant only holds when replay succeeds; when it fails retriably the re-query wipes exactly the state this feature is meant to protect.

The comment is right about ordering, but it is load-bearing in a way the code doesn't guarantee. If replay() fails retriably, the operation stays queued and the optimistic reaction stays in local state — and then sync() (L627) and queryChannelsOnline() (L632) run immediately and overwrite the message from server truth.

Concretely, Message.updateWith returns other.copyWith(...) where other is the incoming server payload, and its preserve list is localCreatedAt/localUpdatedAt/localDeletedAt, deletedForMe, poll, sharedLocation, ownReactions, quotedMessage. reactionGroups and latestReactions are not in it, so both come wholesale from the server. Reaction chips render from reactionGroups (stream_message_reactions.dart:83; ownReactions only styles a chip that reactionGroups already says exists).

Net user-visible behaviour: the reaction added offline disappears on reconnect and reappears at some later recovery when replay finally lands. That is worse than the pre-PR rollback, which was at least immediate and self-consistent.

Two options: skip the re-query for channels that still have queued operations, or re-apply the optimistic state after reconciliation. Either way the invariant should be stated as "holds only if replay succeeded", because right now the failure path is the interesting one.

Separately, this await puts an unbounded queue on the critical path of connection recovery — it runs ahead of sync() and channel re-query, so N queued operations × per-request latency directly delays offline-event sync. Combined with the missing fail-fast in the replay loop, a flaky reconnect can block recovery for N × Dio timeout. Worth either bounding/coalescing the queue (there is no dedup of (messageId, type), so an offline user tapping the same reaction N times produces N operations and N round-trips) or moving replay off the blocking path.

Comment on lines +10 to +21
class PendingOperations extends Table {
/// Autoincrement id.
IntColumn get id => integer().autoIncrement()();

/// The operation-type discriminator (e.g. `reaction.add`).
TextColumn get type => text()();

/// The id of the message the operation targets, if any.
TextColumn get targetMessageId => text().nullable()();

/// The operation-specific value fields, stored as JSON.
TextColumn get payload => text().map(MapConverter())();

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.

Major — no createdAt and no attempt/terminal marker, and this is the one moment when adding them is free.

schemaVersion already moves 1000 + 35 → 1000 + 36 in this PR, and MigrationStrategy.onUpgrade (drift_chat_database.dart:74-81) drops and recreates every table on any version change. So these columns cost nothing right now, and cost another cache-wiping schema bump later.

What their absence causes today:

  • No TTL and no cap. A user offline for a week accumulates unbounded rows that all replay serially on reconnect — and there is no data with which to implement expiry later.
  • No attempt/failure count. A permanently-failing operation can never be given up on. This is what makes the non-StreamChatNetworkError case in PendingOperationsManager.replay() unrecoverable, and what makes a failed deletePendingOperation unbounded: _remove drops the operation from memory first, so if the DB delete fails the row survives, hydrate() reloads it on the next connectUser, and it is replayed again — including operations the server already terminally rejected.
  • No channel_cid and no FK to Messages. Contrast reactions.dart:11, which does .references(Messages, #id, onDelete: KeyAction.cascade). Nothing in deleteChannels / deleteMessageByIds / truncate touches this table, so queued reactions for locally-deleted messages survive and replay.

Every other entity in this package (locations, polls, channels) carries timestamps; this one carries none.

Suggested change
class PendingOperations extends Table {
/// Autoincrement id.
IntColumn get id => integer().autoIncrement()();
/// The operation-type discriminator (e.g. `reaction.add`).
TextColumn get type => text()();
/// The id of the message the operation targets, if any.
TextColumn get targetMessageId => text().nullable()();
/// The operation-specific value fields, stored as JSON.
TextColumn get payload => text().map(MapConverter())();
class PendingOperations extends Table {
/// Autoincrement id.
IntColumn get id => integer().autoIncrement()();
/// The operation-type discriminator (e.g. `reaction.add`).
TextColumn get type => text()();
/// The id of the message the operation targets, if any.
TextColumn get targetMessageId => text().nullable()();
/// The operation-specific value fields, stored as JSON.
TextColumn get payload => text().map(MapConverter())();
/// When the operation was queued, used to expire stale operations.
DateTimeColumn get createdAt => dateTime()();
/// How many times replay has been attempted, used to give up on an
/// operation that keeps failing rather than retrying it forever.
IntColumn get attempts => integer().withDefault(const Constant(0))();
}

A createdAt column also unlocks a safer policy for the forward-incompatible case in replay(): an operation whose type is unknown to this version (written by a newer app, read after a downgrade or during a staged rollout) is currently deleted, silently destroying recorded user intent. With a timestamp you can skip-and-expire instead of dropping on sight.

Comment on lines +3120 to +3147
test(
'a retriable failure keeps the optimistic reaction for replay',
() async {
const type = 'like';
final message = Message(id: 'offline-msg', state: MessageState.sent);
final reaction = Reaction(
type: type,
messageId: message.id,
user: client.state.currentUser,
);

// data == null → retriable/offline error.
when(
() => client.sendReaction(message.id, reaction),
).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError));

await expectLater(
channel.sendReaction(message, reaction),
throwsA(isA<StreamChatNetworkError>()),
);

// The optimistic reaction is kept (queued for replay on reconnect)
final current = channel.state!.messages.firstWhere(
(m) => m.id == message.id,
);
expect(current.ownReactions?.map((r) => r.type), contains(type));
},
);

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.

Major — the headline behaviour of this PR is untested: the enqueue wiring can be deleted and CI stays green.

This test (and its deleteReaction twin) asserts only that the optimistic reaction is kept. That outcome is produced entirely by the if (retriable) { ... } else { state?.replaceMessage(message) } branch — the enqueue call contributes nothing to it. And the group's setUpAll stubs when(() => client.chatPersistenceClient).thenReturn(null) (L244), so persistenceEnabled is false and the enqueue is memory-only, while PendingOperationsManager exposes no way to inspect its queue.

Net result: deleting await _client.pendingOperationsManager.enqueue(...) from channel.dart:1630 and :1670 fails zero tests. "Reactions get queued for replay" — the thing this PR is for — has no coverage at the point where it is triggered.

Related: the comment at L242-243 says "pending-operation tests install a MockPersistenceClient locally", but no test in this group does (grep MockPersistenceClient channel_test.dart only hits the unrelated "User messages deleted event" group), which also makes the tearDown re-stub at L260-261 dead code justified by tests that don't exist.

Fix: install a MockPersistenceClient in these two tests and assert the queued operation, including flag pass-through:

expect(persistence.storedPendingOperations, hasLength(1));
final op = persistence.storedPendingOperations.single;
expect(op.type, ReactionPendingOperation.addType);
expect(op.targetMessageId, message.id);
expect(op.payload[ReactionPendingOperation.enforceUniqueKey], isTrue);

Alternatively expose an @visibleForTesting queue view on PendingOperationsManager so the memory-only path (the one that is always on) can be asserted directly. Either works — but without one of them the PR's central wiring is uncovered.

Other gaps worth closing while you are here: add→delete of the same reaction (the common offline toggle, and the only ordering where FIFO changes the outcome); a nested-Map disk round-trip (the DAO test only ever stores flat primitives, yet payload['reaction'] as Map<String, dynamic> is load-bearing); clear() during an in-flight replay; memory-id reuse after clear(); and the connectUser → hydrate() wiring end to end (the new client_test.dart group seeds the queue after connectUser, so hydrate-on-connect is never exercised).

Comment on lines +10 to +51
class PendingOperation extends Equatable {
/// {@macro pendingOperation}
const PendingOperation({
required this.type,
required this.payload,
this.id,
this.targetMessageId,
});

/// The database autoincrement id, assigned when the operation is stored;
/// `null` until then.
final int? id;

/// The discriminator persisted in the `type` column, e.g. `reaction.add`.
final String type;

/// The id of the message the operation targets, if any.
final String? targetMessageId;

/// The operation-specific value fields, stored as JSON.
final Map<String, dynamic> payload;

/// Returns a copy of this operation with the given fields replaced.
PendingOperation copyWith({
int? id,
String? type,
String? targetMessageId,
Map<String, dynamic>? payload,
}) => PendingOperation(
id: id ?? this.id,
type: type ?? this.type,
targetMessageId: targetMessageId ?? this.targetMessageId,
payload: payload ?? this.payload,
);

@override
List<Object?> get props => [
type,
targetMessageId,
payload,
];
}

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.

Suggestion — make PendingOperation type-safe with a sealed hierarchy, so illegal states stop being representable.

As written this is a stringly-typed bag: type is a String, payload is an untyped Map<String, dynamic>, and targetMessageId is nullable even though every operation type that exists requires it. All of the validation therefore happens at replay time in PendingOperationsManager._replayCallFor, which is why that function needs two StateError throws, two unchecked casts, and two silent ?? false defaults — and why two of the eight manager tests exist only to prove an impossible state is detected.

The DB row should stay generic (type, payload) — that is what makes the table reusable. The in-memory model doesn't have to be. Parse once at the persistence boundary, and replay becomes exhaustive and cast-free.

The repo is on Dart ^3.11 and already uses sealed hierarchies for exactly this shape (MessageState, MessageDeleteScope), so this is idiomatic here:

/// Identity of a queued operation: either a persisted row or session-only.
sealed class PendingOperationId extends Equatable {
  const PendingOperationId();
}

/// An operation mirrored to persistence, identified by its row id.
final class PersistedOperationId extends PendingOperationId {
  const PersistedOperationId(this.value) : assert(value > 0, 'row ids are positive');
  final int value;
  @override
  List<Object?> get props => [value];
}

/// An operation that lives only in memory for this session.
final class SessionOperationId extends PendingOperationId {
  const SessionOperationId(this.value);
  final int value;
  @override
  List<Object?> get props => [value];
}

/// {@macro pendingOperation}
sealed class PendingOperation extends Equatable {
  const PendingOperation({this.id});

  /// Identity of this operation, `null` until it is queued.
  final PendingOperationId? id;

  /// The discriminator persisted in the `type` column.
  String get type;

  /// The message this operation targets.
  String get targetMessageId;

  /// The operation-specific value fields. Must be JSON-encodable.
  Map<String, dynamic> toPayload();

  /// Returns a copy of this operation with [id] assigned.
  PendingOperation withId(PendingOperationId id);

  /// Rebuilds a stored operation, or returns `null` when [type] is unknown to
  /// this version. Throws [FormatException] on a malformed payload.
  static PendingOperation? fromStored({
    required PendingOperationId id,
    required String type,
    required String? targetMessageId,
    required Map<String, dynamic> payload,
  }) => switch (type) {
    AddReactionOperation.opType => AddReactionOperation.fromPayload(id, payload),
    DeleteReactionOperation.opType => DeleteReactionOperation.fromPayload(id, payload),
    _ => null,
  };
}

/// A reaction added while offline, awaiting replay.
final class AddReactionOperation extends PendingOperation {
  const AddReactionOperation({
    required this.reaction,
    this.skipPush = false,
    this.enforceUnique = false,
    super.id,
  });

  /// The discriminator persisted for this operation type.
  static const opType = 'reaction.add';

  /// The reaction to send.
  final Reaction reaction;

  /// Whether to skip the push notification for the reaction.
  final bool skipPush;

  /// Whether the reaction replaces the user's existing one.
  final bool enforceUnique;

  @override
  String get type => opType;

  @override
  String get targetMessageId => reaction.messageId!;

  // ... toPayload / fromPayload / withId / props
}

_replayCallFor then collapses to an exhaustive switch with no default, no null return, and no casts:

Future<void> Function() _replayCallFor(PendingOperation operation) =>
    switch (operation) {
      AddReactionOperation(:final targetMessageId, :final reaction, :final skipPush, :final enforceUnique) =>
        () => _client.sendReaction(targetMessageId, reaction, skipPush: skipPush, enforceUnique: enforceUnique),
      DeleteReactionOperation(:final targetMessageId, :final reactionType) =>
        () => _client.deleteReaction(targetMessageId, reactionType),
    };

What this buys, concretely:

  • enforceUnique can no longer silently default to false — the malformed-payload case becomes a FormatException at the parse boundary, not a semantic change at send time.
  • No StateError for a missing targetMessageId — it is non-nullable by construction.
  • No unchecked as Map<String, dynamic> / as String.
  • The id sign hack disappears. _remove becomes switch (id) { PersistedOperationId(:final value) => deletePendingOperation(value), SessionOperationId() => null } instead of if (id < 0) return, which is what makes the _memorySeq collision above possible in the first place. The assert(value > 0) also documents and enforces the insertPendingOperation contract that is currently only implied.
  • Adding message.send or a channel operation becomes a compile error until handled, instead of a silent default: return null that drops the row.

Two smaller things on the current class while it is being touched: props (L46-50) excludes id, so op == op.copyWith(id: 7) — harmless today because removal matches on id, but a future List.remove(op) would delete the wrong entry. And the doc on L19-20 says "the database autoincrement id, null until then" while PendingOperationsManager also assigns negative session ids to this field, so the documented value domain and the real one disagree.

Fully understand if the sealed refactor is out of scope for this PR — in that case the minimum I would ask for is making the strictness in _replayCallFor consistent (throw on a missing enforce_unique exactly as for targetMessageId), plus documenting on insertPendingOperation that ids must be positive and unique and that payload must be JSON-encodable.

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.

Follow-up: React Native already ships exactly this shape, which I think settles the "is this over-engineering?" question.

RN's equivalent type is a discriminated union whose payload type is derived from the method being replayed (stream-chat/src/offline-support/types.ts):

export type PendingTask = {
  channelId: string;
  channelType: string;
  messageId: string;
  id?: number;
} & (
  | { type: 'send-reaction';   payload: Parameters<Channel['sendReaction']> }
  | { type: 'delete-reaction'; payload: Parameters<Channel['deleteReaction']> }
  | { type: 'delete-message';  payload: Parameters<StreamChat['deleteMessage']> }
);

and its executor narrows on the discriminant and spreads the payload, so there are no casts and no defaults to get wrong (offline_support_api.ts:1268-1320):

if (task.type === 'send-reaction')   return await channel._sendReaction(...task.payload);
if (task.type === 'delete-reaction') return await channel._deleteReaction(...task.payload);

Same idea as the sealed hierarchy above: the row stays generic (type, payload) on disk, the in-memory model is typed, and the payload can't drift from the signature it feeds. Dart's sealed classes + exhaustive switch expressions are the direct analogue of TS's discriminated union + narrowing, and this repo already uses that pattern (MessageState, MessageDeleteScope).

Two extra reasons this matters more here than it looks:

  • RN's queue already carries four operation families (reactions, send-message, delete-message/update-message, drafts). If Flutter follows, _replayCallFor's default: return null becomes the place every future operation type can be silently forgotten — whereas an exhaustive switch makes each addition a compile error until handled.
  • It closes the id hole from the other thread by construction. A sealed PendingOperationId turns _remove's if (id < 0) return into a pattern match, so the _memorySeq collision stops being expressible.

Worth noting RN also carries channelId/channelType/threadId on the task, because its executor resolves a Channel to replay against. This PR routes replay through _client.sendReaction by message id, so it genuinely doesn't need them — but that's also why nothing here can clean up queued operations per channel.

Comment on lines +1627 to +1628
final retriable = e is StreamChatNetworkError && e.isRetriable;
if (retriable) {

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.

Cross-SDK divergence — this predicate makes Flutter roll back exactly the errors React Native queues.

Raising this as its own thread (it was an aside in my PendingOperationsManager.replay() comment) because comparing against the RN/JS implementation turns it from a theoretical sharp edge into a behavioural inconsistency between Stream SDKs.

RN classifies with an explicit terminal allowlist (stream-chat/src/offline-support/offline_support_api.ts:1154):

private shouldSkipQueueingTask = (error: AxiosError<APIErrorResponse>) =>
  error?.response?.data?.code === 4 ||   // bad request data
  error?.response?.data?.code === 17;    // missing own_capabilities

Everything not on that list is queued and replayed. Flutter's isRetriable => data == null is the inverse: any response carrying a body is terminal. So for the same user action:

Failure RN This PR
Offline / connection error queued queued ✅
429 rate limited queued rolled back
5xx with the Stream error envelope queued rolled back
Bad request data (code 4) dropped dropped ✅
Missing capability (code 17) dropped rolled back ✅

429 and transient 5xx are the canonical "retry this later" responses, and they're the ones a real device hits under load — so the headline feature currently doesn't engage in the failure modes most likely to occur after plain offline. Worth deciding deliberately rather than inheriting it from data == null.

Two things RN does here that are worth borrowing:

  1. Enumerate the terminal cases instead of inferring them from body presence — an allowlist of codes/statuses fails safe (unknown error ⇒ keep the operation) where data == null fails destructive (unknown error with a body ⇒ discard the user's reaction).
  2. Pre-check the connection before spending a request. RN's queueTask (offline_support_api.ts:1127-1136) checks wsConnection?.isHealthy and throws an OfflineError without a network round-trip, so the common offline case never waits on a Dio timeout. Here every offline reaction pays a full timeout before being queued.

I'd leave isRetriable itself alone — it's pre-existing and shared with RetryQueue. The narrow fix is to not reuse it for this decision, since this is the first place it determines whether local state is kept or reverted rather than merely whether to retry a send.

@renefloor

Copy link
Copy Markdown
Contributor

Cross-check against the React Native SDK

Since RN has shipped offline queuing for reactions (plus messages and drafts) for a while, I compared this PR against it — stream-chat-react-native/package/src/store for the SQLite layer and stream-chat/src/offline-support for the executor. Posting the result because it cuts both ways, and I don't want the two new threads above read as "RN does it right, this doesn't".

How RN is structured, for context: pendingTasks is a SQLite table; queueTask() pre-checks wsConnection.isHealthy and throws without a network call, queuing on failure; OfflineDBSyncManager calls executePendingTasks() on connection.changed, which re-reads the queue from the DB on every pass — there is no in-memory queue at all.

Where RN corroborates the findings above

  • The table columns. RN's pendingTasks is id, type, payload, createdAt, channelId, channelType, messageId, threadId (store/schema.ts:206), and getPendingTasks explicitly orders by createdAt ASC (store/apis/getPendingTasks.ts). This PR's table has no timestamp and orders implicitly by autoincrement.
  • Cleanup on message deletion. RN has a dedicated dropPendingTasks({ messageId }) and runs it alongside its hard-delete-message queries (offline_support_api.ts:712) — the exact cleanup path missing here.
  • Error classification — see the new channel.dart thread.
  • Payload typing — see the reply on the pending_operation.dart thread.
  • Coalescing exists in RN, but only for drafts (store/apis/addPendingTask.ts deletes prior create-draft/delete-draft rows for the same entity before inserting). Not for reactions, so the dedup question is open in both SDKs.

Where RN is no better — these are shared gaps, not regressions in this PR

  • No fail-fast. RN's executePendingTasks also continues through the whole queue on retriable failures, so it burns the same N × timeout on a flaky reconnect.
  • No attempt counter. RN has createdAt but no attempts column, so a persistently-failing task loops forever there too. The attempts column I suggested goes beyond RN rather than toward parity.
  • No cancellation mid-drain. RN iterates a snapshot with no epoch/generation check either, so the mechanism behind the blocker exists in RN as well. I'd read that as an argument for fixing it here and reporting it upstream, not for downgrading it — RN just has less exposure because of how its queue is scoped.
  • Replay before sync, awaited. RN does the same ordering, but wraps each step in its own try/catch with a comment explaining that a failure in one must not block the other (referencing their issue feat(ui): Extract StreamMediaAttachmentBuilder widget #1816). Same design, more defensively implemented.

Where this PR is better than RN

  • The per-user database is a real advantage. Flutter uses db_$userId.sqlite, so the persisted queue cannot leak across users. RN uses a single static DB name and its pendingTasks table has no user column, even though other RN tables are scoped by currentUserId. This is exactly why the blocker is worth fixing rather than tolerating: the stale in-memory snapshot is the one path that defeats an isolation guarantee RN doesn't even have.
  • Memory-only replay works with persistence disabled. RN's queue is DB-only, so no offline DB means no queuing at all. The always-on in-memory layer here gives every client same-session replay across transient outages — the PR description's claim holds up and RN has no equivalent.
  • Unknown-type handling is better, and I want to walk back my framing. In my PendingOperations comment I described dropping forward-incompatible rows as "silently destroying recorded user intent". Comparing with RN, that's unfair: RN throws Error('Tried to execute invalid pending task type ...') inside its try, the catch then evaluates error?.response?.data?.code on a non-Axios error, gets undefined, and continues — so the row is kept forever. Dropping is the better of the two available options. With a createdAt column there's a third (skip-and-expire), which is the only reason I'd still raise it.

Net

Nothing in the RN comparison changes the blocker or the six majors. It does raise my confidence on the schema columns and the error classification, supplies a concrete precedent for the type-safety suggestion, and shows that three of the weaknesses are shared across SDKs and probably worth an upstream issue rather than just a fix here.

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.

3 participants