Skip to content

feat: portable event identity storage and capture (Phase 0) - #277

Merged
williscool merged 8 commits into
masterfrom
feat/event_identity_storage
Sep 21, 2026
Merged

williscool merged 8 commits into
masterfrom
feat/event_identity_storage

Conversation

@williscool

@williscool williscool commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

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 RoomEventIdentity 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. Identity is read only during a restore and joins by (eventId, instanceStartTime).
  • The sync layer targets the eventsV9 table 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.
  • No legacy predecessor and no migration — starts at version 1, begins empty. Simpler than the other three databases here.

_SYNC_ID, not UID_2445

The plan originally assumed UID_2445 (the iCalendar UID). The probe merged in #276 measured it on the target device:

total UID_2445 _SYNC_ID
com.google 4761 0 (0%) 4761 (100%, all unique)

So _SYNC_ID is primary and UID_2445 is captured opportunistically for non-Google providers. Building on UID_2445 would have failed completely and silently.

Notable design points

originalEventId duplicates the event row on purpose. It is the staleness check that distinguishes "never resolved" from "already resolved" — reKey moves eventId but deliberately leaves originalEventId alone, and that divergence is what drops a row out of getUnresolved(). 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 SQLException and return false rather than propagating: failing to record identity must never fail the event write it accompanies. Catches SQLException specifically, never broad Exception.

findEventIdBySyncId declines ambiguity. If two events match, it returns -1 rather 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 / recordResolutionAttempt UPDATEs. Most importantly unresolvedExcludesReKeyedRows and unresolvedExcludesRowsAtTheAttemptCap — if that WHERE clause 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.EventIdentityStorageTest

Also here

AGENTS.md gains 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 to master earlier in this work.

Next

Phase 0c writes identity rows from ApplicationController on 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

williscool and others added 3 commits September 21, 2026 01:21
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
@github-actions

Copy link
Copy Markdown

Build artifacts for PR #277 (commit 169db9d) are available:

You can download these artifacts from the "Artifacts" section of the workflow run.

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Code Coverage Report

Overall Project 34.68% -0.35% 🍏
Files changed 55.12% ❌

Module Coverage
app 42.08% -0.34% ❌
x8664Debug 27.43% -0.36% ❌
Files
Module File Coverage
app EventIdentityEntity.kt 80% -20% 🍏
EventIdentityStorage.kt 74.44% -22.42% 🍏
EventRecord.kt 57.19% -1.82% ❌
CalendarProvider.kt 54.97% -2.94% ❌
EventIdentityDatabase.kt 31.71% -64.63% ❌
CalendarProviderInterface.kt 0% -54.55% ❌
x8664Debug EventIdentityEntity.kt 82.86% -17.14% 🍏
EventIdentityStorage.kt 76.68% -20.18% 🍏
EventRecord.kt 56.13% -1.51% 🍏
CalendarProvider.kt 4.79% -3.34% ❌
EventIdentityDatabase.kt 0% -89.02% ❌
CalendarProviderInterface.kt 0% -54.55% ❌

@github-actions

Copy link
Copy Markdown

📊 Code Coverage Summary

Coverage Type Coverage
Overall 27.41
Changed Files 16.48

View detailed coverage report

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
@github-actions

Copy link
Copy Markdown

Build artifacts for PR #277 (commit bf7d82f) are available:

You can download these artifacts from the "Artifacts" section of the workflow run.

@github-actions

Copy link
Copy Markdown

📊 Code Coverage Summary

Coverage Type Coverage
Overall null
Changed Files 100

View detailed coverage report

@github-actions

Copy link
Copy Markdown

📊 Code Coverage Summary

Coverage Type Coverage
Overall 34.78
Changed Files 36.95

View detailed coverage report

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
@github-actions

Copy link
Copy Markdown

Build artifacts for PR #277 (commit 2127ad3) are available:

You can download these artifacts from the "Artifacts" section of the workflow run.

@github-actions

Copy link
Copy Markdown

📊 Code Coverage Summary

Coverage Type Coverage
Overall 27.41
Changed Files 16.48

View detailed coverage report

williscool and others added 3 commits September 21, 2026 03:28
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
@github-actions

Copy link
Copy Markdown

Build artifacts for PR #277 (commit a215f2a) are available:

You can download these artifacts from the "Artifacts" section of the workflow run.

@github-actions

Copy link
Copy Markdown

📊 Code Coverage Summary

Coverage Type Coverage
Overall 27.43
Changed Files 16.75

View detailed coverage report

Comment thread scripts/watch_test_progress.sh
@github-actions

Copy link
Copy Markdown

📊 Code Coverage Summary

Coverage Type Coverage
Overall 34.68
Changed Files 36.68

View detailed coverage report

@williscool
williscool merged commit 3993e06 into master Sep 21, 2026
30 of 32 checks passed
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
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