Skip to content

fix(sync): store what Firestore returns in a form Hive can hold - #555

Open
MOHITKOURAV01 wants to merge 2 commits into
ishita2740:mainfrom
MOHITKOURAV01:fix/issue-550-firestore-hive-timestamps
Open

fix(sync): store what Firestore returns in a form Hive can hold#555
MOHITKOURAV01 wants to merge 2 commits into
ishita2740:mainfrom
MOHITKOURAV01:fix/issue-550-firestore-hive-timestamps

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Closes #550.

Hive encodes int, double, bool, String, List, Map, DateTime
and Uint8List, plus anything with a registered TypeAdapter — and this
app registers none:

$ grep -rn "registerAdapter\|TypeAdapter" rhythma_flutter/lib/
(no matches)

Every synced document carries a Timestamp under synced_at, and every
path in FirestoreService hands documents straight to
LocalStorageService. So each write-back threw on the first document it
touched:

HiveError: Cannot write, unknown type: Timestamp.

That one throw is why cloud sync never completed:

  • syncCycleLogs committed its batch and then blew up reading the
    result back. The catch marked the sync failed and re-queued the whole
    history — data that was already on the server. flushPendingQueue
    pushed it again and deleted the queue keys; the next syncCycleLogs
    re-queued all of it. On every launch, every login, and every
    connectivity change, with the indicator never leaving error/pending.
  • pullCycleLogs had no per-document guard, so the first document
    aborted the loop and nothing remote was ever merged.
  • syncProfile / pullProfile the same, via saveProfile.

It also silently disabled the conflict resolution the class is built on.
localTime was read back out of Hive, where a Timestamp had never been
storable, so it was always null, the localTime == null branch was the
only reachable one, and the server won unconditionally. Last-write-wins
had no local write to compare against.

What changed

toStorable rewrites a document into something Hive can encode:
Timestamp and DateTime become ISO-8601 strings, DocumentReference
its path, GeoPoint a plain map, and nested maps and lists are walked.
ISO strings are what the pending queue already stores under queued_at
and what start_date is, so local documents stay one shape rather than
two.

syncedAt reads a Timestamp and the ISO string it becomes as the
same instant, so serverWins compares two real times. Ties still go to
the server: a document written and immediately read back has the same
stamp on both sides, and treating that as a conflict would strand the
local copy on its pre-resolution value.

The write-back moved into its own try. The batch has committed by
that point, so a failure there is a local caching problem — re-queuing
the whole history for it is what made the queue refill itself from the
very sync that had just emptied it. _isSyncing moved into a finally
while I was in there, so no path can leave the flag set and turn every
later sync into a silent no-op.

pullCycleLogs catches per document, so one unreadable log costs one
log rather than the whole merge.

device_id was LocalStorageService.currentUserId in all four write
paths — the same value on every device an account is signed in to, so two
devices were indistinguishable and the field carried no information for
the conflict resolution it exists for. It is now a random per-install id
created on first use and stored unscoped, because it identifies the
handset rather than the account. Deliberately not a hardware identifier:
a locally generated random value answers "was this the same install?",
which is the only question the sync asks.

Tests

rhythma_flutter/test/services/firestore_hive_round_trip_test.dart — 20
cases.

The first one is the bug, asserted against a real Hive box rather
than a mock: putting an unconverted Firestore document in throws, and the
same document goes in once converted. A mocked box that happily accepted
a Timestamp would have made every other test here pass while production
kept throwing, so it is worth reading first. It catches by hand rather
than with throwsA because Hive serialises inside the Future put
returns, and whether the error surfaces synchronously is an
implementation detail the test should not depend on.

The last-write-wins group covers the case that could not previously
happen at all — a newer local write surviving — plus the tie, the empty
local, and an unstamped server document.

Verified the changed files parse cleanly. I don't have a Flutter SDK on
this machine, so flutter analyze and flutter test will get their
first real run in CI.

Hive encodes a fixed set of types plus anything with a registered
TypeAdapter, and this app registers none. Every synced document carries
a Timestamp under `synced_at`, so every write-back from Firestore into
Hive threw on the first document it touched:

    HiveError: Cannot write, unknown type: Timestamp.

That single throw is why cloud sync never completed. `syncCycleLogs`
committed its batch, blew up reading the result back, caught its own
exception and re-queued the entire history — which the next flush pushed
again and the next sync re-queued again, on every launch. `pullCycleLogs`
aborted on its first document, so nothing remote was ever merged.

It also silently disabled the conflict resolution the class is built on:
the local half of every `synced_at` comparison came back out of Hive,
where a Timestamp had never been storable, so `localTime` was always null
and the server unconditionally won.

Everything crossing the boundary now goes through `toStorable`, and both
sides of the comparison through `syncedAt`, which reads a Timestamp and
the ISO string it becomes as the same instant.

Two structural fixes alongside. The write-back is in its own try, because
the batch has already committed by then and re-queuing for a local
caching failure is what made the queue self-refilling. And `pullCycleLogs`
catches per document, so one unreadable log costs one log.

`device_id` was `currentUserId`, the same value on every device an
account is signed in to, which carried no information for the conflict
resolution it was added for. It is now a random per-install id created on
first use — not a hardware identifier; it only has to answer "was this
the same install?".

Closes ishita2740#550
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

@MOHITKOURAV01 is attempting to deploy a commit to the ishita2740's projects Team on Vercel.

A member of the Team first needs to authorize it.

`setUpLocalStorage` installs a mock method-call handler for
flutter_secure_storage, which needs the binding. Matches how
firestore_service_test.dart and profile_provider_test.dart do it.
@MOHITKOURAV01

Copy link
Copy Markdown
Contributor Author

A note on the red Flutter check, so it is not read as this branch's doing.

Analyze, format, and test fails at the Analyze step and skips Run tests. It does the same on main — run 32812803373 at 78995ba9 shows Analyze: failure, Run tests: skipped — and every Flutter run on main since 14 August has failed the same way.

All 80 analyzer errors are undefined_getter / undefined_method on AppLocalizations, and none of them are in a file this branch touches. They come from lib/l10n/app_localizations.dart, where two blocks of abstract getters sit outside the class they belong to: four inside _AppLocalizationsDelegate (line 2042), and four after the throw FlutterError(...) in lookupAppLocalizations (line 2093), which is a syntax error. Everything else follows from AppLocalizations failing to resolve. This is the same breakage #492 described; it was closed, but this part of it is still on main.

Two consequences worth stating plainly:

  • The red here is inherited. Comparing error counts, this branch and main produce the same 80.
  • Because Analyze fails, flutter test never runs, so the tests added in this PR have not been executed by CI. I do not have a Flutter SDK on this machine either — I verified the changed files parse, and reviewed the types by hand, but that is weaker than a green run and I would rather say so than imply otherwise.

Happy to open a separate PR restoring app_localizations.dart if that would help — it would unblock flutter test for every Flutter PR, not just this one.

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

Labels

None yet

Projects

None yet

1 participant