feat: portable event identity storage and capture (Phase 0) - #277
Merged
Merged
Conversation
First code for portable event identity (#273). Adds a dedicated Room database holding durable, provider-independent identity for each stored event, so a database restored onto a new phone can re-resolve itself to the right calendar and event rows. Separate database rather than eventsV9's reserved s2 column: the reserved columns are a scarce one-shot resource better spent on data that must live in the event row, and keeping identity out of eventsV9 keeps account emails out of the Supabase sync payload by construction rather than by remembering to filter them. No legacy predecessor and no migration -- it starts at version 1 and begins empty. _SYNC_ID is the primary identifier, per the probe measurement: UID_2445 was null for all 4761 events on the target device while _SYNC_ID was populated and unique for 100%. UID_2445 is still captured opportunistically, since non-Google providers may populate it. originalCalendarId/originalEventId duplicate the event row on purpose -- they are the staleness check that distinguishes "never resolved" from "already resolved" across retry passes. Capture is best-effort: writes swallow SQLException and report false rather than propagating, since failing to record identity must never fail the event write it accompanies. Catches SQLException specifically, never broad Exception. 16 Robolectric tests against a fake DAO. Real SQLite cannot run under Robolectric here (cr-sqlite is native), so DAO-level query coverage will come from an instrumentation test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
Branches are long-lived and named after the plan they implement, not per-commit. Work accumulates; the PR merges at a coherent stopping point and a new branch starts the next stage. Sizing signal is reviewability in the GitHub UI, not a line or commit count. Also documents never pushing to master, with the specific trap that caused it here: `git checkout -b <new> origin/master` sets the new branch's upstream to master, so `git push -u origin <new-branch>` resolves to the tracked ref and lands on master. Branch protection reported failing checks but the push still went through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
Adds the provider half of portable event identity (#273). CalendarProvider.getEvent() now reads Events._SYNC_ID and Events.UID_2445, exposed as nullable fields on EventRecord. They live on EventRecord rather than CalendarEventDetails because they are provider identity, not user-visible event content. Adds findEventIdBySyncId(), which maps a stored sync id back to this device's local event id -- the event half of restore re-association, and the query the resolver will be built on. Three deliberate choices: - scoped to a single calendar, since the same event can appear in another calendar the user subscribes to - _SYNC_ID tried first, UID_2445 second, matching what was measured (UID_2445 null for all 4761 events, _SYNC_ID unique for 100%) - an ambiguous match returns -1 rather than picking one, because re-keying onto the wrong event is worse than staying unresolved Adds the instrumentation test for the DAO. The Robolectric test fakes the DAO out of necessity (cr-sqlite is native and will not load under Robolectric), which left the actual Room queries unverified -- this covers the schema, the composite primary key, and the hand-written reKey/recordResolutionAttempt UPDATEs. It compiles but has NOT been executed: no device attached. The 16 Robolectric tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
Code Coverage Report
Files
|
📊 Code Coverage Summary
|
All four instrumentation shards and the connected-test verification job were failing in Common Setup, before any test ran: Warning: Failed to find package 'tools' Error: sdkmanager failed with exit code 1 Google removed the legacy 'tools' package from the SDK repository (superseded by cmdline-tools), so sdkmanager exits non-zero and takes the whole setup step with it. Downstream that surfaces as "No test result XML files found! Tests may have crashed", which points at the tests rather than at the setup that never finished -- the shards were failing in ~20s, far too fast to have run anything. Nothing here uses the old tools/ binaries: avdmanager and emulator come from cmdline-tools and the emulator package, and reactivecircus/android-emulator-runner installs what else it needs. Added a comment so the package does not get added back. Pre-existing breakage, not caused by the identity storage work -- it was invisible on the last two PRs because they were docs and standalone scripts, so the emulator jobs had nothing to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
williscool
commented
Sep 21, 2026
📊 Code Coverage Summary
|
📊 Code Coverage Summary
|
The Merge Integration Test Coverage job was failing at "Generate Allure HTML Report" with: xargs is not available cp: cannot stat './android/app/build/outputs/allure-report/.': No such file v1.13's image installs only tar, wget and gzip, so xargs is missing and the action's own script cannot build the report. v1.15 exists solely to fix this -- its single change is "add findutils to dockerfile" (simple-elf/allure-report-action#78). Allure results themselves were produced correctly by all four shards, and the JaCoCo coverage merge in the same job succeeded; only the HTML report generation failed. Pre-existing, like the 'tools' package problem -- this job has failed on every recent run including the docs-only probe branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
📊 Code Coverage Summary
|
Coverage from the last good CI run showed EventIdentityStorage line 57 completely unexecuted by unit tests (ci=0 mi=13): putAll's only Robolectric test passed an empty list, which short-circuits at the early return before ever reaching the DAO. getAll was likewise only exercised through its failure path. Adds putAllWritesEveryRow, putAllReportsFailure and getAllReturnsEveryStoredRow. 19 tests pass, up from 16. Note the inline runCatchingWrite/runCatchingRead helpers still read as uncovered in JaCoCo: it attributes inlined bodies to the call sites, which are covered including branches (cb=2 mb=0). Same for the entity's constructor default-value initializers, which only execute when a parameter is omitted -- create() always passes them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
Gradle's Test task logs nothing per-test by default, so a working build is indistinguishable from a hung one -- --console=plain prints the task name and then goes silent for minutes. Both of us misread a healthy run as stalled. Three options, documented in the WSL/build doc: - scripts/watch_test_progress.sh reads the JUnit XMLs as each class finishes. Needs no config change and works on a build that is ALREADY running, which is the case where you most want it. - --info at launch prints each test, at the cost of logging everything else Gradle does. - Summing java process CPU twice ~20s apart answers "hung or just slow" without any log at all. Also notes that configuring testLogging on the Test task would make the first option unnecessary, but that is not set up today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
Gradle's Test task prints nothing per-test by default, so a 15-minute
unit run looks identical to a hung one -- just the task name and then
silence. Android Studio hides this behind its own test UI; plain Gradle
and CI do not, and we both misread a healthy run as stalled.
Adds to the existing unitTests.all block:
- testLogging with exceptionFormat 'full' -- a CI failure now prints its
whole stack trace instead of a single "AssertionError at Foo.kt:327"
line that requires downloading artifacts to diagnose.
- afterSuite printing one line per test CLASS plus a run summary.
Only failures are logged per-test: at ~930 tests, logging passes too
would bury the failures worth reading.
Output goes from silence to:
SUCCESS CalendarIntentsRobolectricTest (9 tests)
SUCCESS EventIdentityStorageRobolectricTest (19 tests)
Test result: SUCCESS — 28 tests, 28 passed, 0 failed, 0 skipped
Note the afterSuite class check keys on desc.className rather than
desc.parent.parent == null: the Gradle worker adds a nesting level, so
the latter silently matches nothing (which is what the first attempt
did).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
📊 Code Coverage Summary
|
williscool
commented
Sep 21, 2026
📊 Code Coverage Summary
|
williscool
added a commit
that referenced
this pull request
Sep 21, 2026
Per review on #277: the bash version embedded a Python heredoc, adding a third scripting language when this repo already has first-class JS support. Rewritten as node, matching the existing scripts/*.js conventions (shebang, JSDoc header, CommonJS) and wired up as `yarn watch:tests`. Parses the JUnit XML attributes with a regex rather than adding a parser dependency -- fast-xml-parser resolves here but only transitively, so depending on it could break silently if the dep tree shifts. Only a few root-element attributes are needed. Verified against the bash version on the same results directory: identical output. Also checked the missing-directory path (exits 1), repeated polling, and failure aggregation (failures + errors). One bug caught while comparing: the first JS attempt read the leading `<?xml ...?>` declaration instead of the `<testsuite` element and reported 0 classes where bash reported 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
williscool
added a commit
that referenced
this pull request
Sep 21, 2026
* chore: rewrite test progress watcher in JavaScript Per review on #277: the bash version embedded a Python heredoc, adding a third scripting language when this repo already has first-class JS support. Rewritten as node, matching the existing scripts/*.js conventions (shebang, JSDoc header, CommonJS) and wired up as `yarn watch:tests`. Parses the JUnit XML attributes with a regex rather than adding a parser dependency -- fast-xml-parser resolves here but only transitively, so depending on it could break silently if the dep tree shifts. Only a few root-element attributes are needed. Verified against the bash version on the same results directory: identical output. Also checked the missing-directory path (exits 1), repeated polling, and failure aggregation (failures + errors). One bug caught while comparing: the first JS attempt read the leading `<?xml ...?>` declaration instead of the `<testsuite` element and reported 0 classes where bash reported 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z * chore: add fast-xml-parser as a dev dep and use it in the watcher Per review: rather than working around the missing dependency with a regex, declare it. fast-xml-parser was already resolving here, but only transitively via @react-native-community/cli -- depending on that would have worked today and could break silently if the dep tree shifted. Now an explicit devDependency (it is only used by a dev script, never shipped), and readSuite parses properly instead of string-matching attributes off the root element. Verified output is unchanged against the same results directory, that failures and errors still aggregate, and that a half-written file is skipped rather than crashing the poll -- Gradle writes these while the build runs, so partial reads are normal rather than exceptional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
williscool
added a commit
that referenced
this pull request
Sep 26, 2026
The dev_todo plan has grown to nearly 800 lines of design history --
what was measured, what was tried and reverted, why. Useful when you
want to know why something is shaped the way it is, wrong shape when you
want to know what runs today.
New file at docs/architecture/portable_event_identity.md as the "how it
works right now" reference:
- What lives in the identitystorage/ package and what each file owns
- The two entry points on ApplicationController (captureEventIdentities,
resolveEventCalendars) and what they do
- The full rescan flow, showing which reads/writes happen when
- Self-check as a standalone rule with the verdict table
- Capture in detail: both stores, dedup, per-verdict outcomes, the
fully-captured skip list and why it matters
- Resolution in detail: how the three account fields identify a calendar,
why ownerAccount is the field doing the work, how the plan splits
events into buckets, how the applier turns a plan into edits
- Explicit in-scope / out-of-scope, so future readers don't wonder where
the event-id re-key or backfill went
- A per-PR table showing which layer landed where
The dev_todo plan gets a pointer at the top explaining what each doc is
for, so anyone landing on it knows the shipped state lives elsewhere.
Also flips database_schema_reference.md's entry for RoomEventIdentity
from "(planned)" to shipped -- capture has been in master since #277.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 0 of portable event identity — part of #273.
Storage and capture for durable, provider-independent event identity, so a database restored onto a new phone can re-resolve itself to the right calendar and event rows. No behavior change yet — nothing writes identity rows until Phase 0c.
Why a separate database
Identity lives in a new
RoomEventIdentitydatabase rather thaneventsV9's reserveds2column:(eventId, instanceStartTime).eventsV9table by name, so keeping identity out of that table keeps account emails out of the Supabase payload by construction rather than by remembering to filter._SYNC_ID, notUID_2445The plan originally assumed
UID_2445(the iCalendar UID). The probe merged in #276 measured it on the target device:UID_2445_SYNC_IDSo
_SYNC_IDis primary andUID_2445is captured opportunistically for non-Google providers. Building onUID_2445would have failed completely and silently.Notable design points
originalEventIdduplicates the event row on purpose. It is the staleness check that distinguishes "never resolved" from "already resolved" —reKeymoveseventIdbut deliberately leavesoriginalEventIdalone, and that divergence is what drops a row out ofgetUnresolved(). Getting this backwards would make the retry pass loop forever, so there are tests for it in both suites.Capture is best-effort. Writes swallow
SQLExceptionand returnfalserather than propagating: failing to record identity must never fail the event write it accompanies. CatchesSQLExceptionspecifically, never broadException.findEventIdBySyncIddeclines ambiguity. If two events match, it returns-1rather than picking one — re-keying onto the wrong event is worse than staying unresolved and retrying. Scoped to a single calendar, since the same event can appear in another calendar the user subscribes to.Testing
16 Robolectric tests pass (verified in the XML results, not just
BUILD SUCCESSFUL). They use a fake DAO because cr-sqlite is a native library that cannot load under Robolectric — see sqlite-mocking-robolectric.md.13 instrumentation tests pass against real SQLite on an API 34 x86_64 emulator — verified from the XML results (
tests=13 failures=0 errors=0 skipped=0).These cover what Robolectric structurally cannot: the Room schema building, the composite primary key allowing two occurrences of one event to coexist, and the hand-written
reKey/recordResolutionAttemptUPDATEs. Most importantlyunresolvedExcludesReKeyedRowsandunresolvedExcludesRowsAtTheAttemptCap— if thatWHEREclause were wrong the retry pass would either spin forever or never run, and a faked DAO would agree with either..\gradlew.bat :app:connectedX8664DebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.github.quarck.calnotify.identitystorage.EventIdentityStorageTestAlso here
AGENTS.mdgains the branching/PR workflow — long-lived branches named after the plan, merged at natural stopping points, plus the explicit-refspec rule after a tracked upstream sent a commit straight tomasterearlier in this work.Next
Phase 0c writes identity rows from
ApplicationControlleron event add/update. That touches live app behavior, so it belongs in its own PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z