Skip to content

Read a series' rows by its id, and say how far they reach - #96

Merged
Timtam merged 2 commits into
mainfrom
fix/series-rows-by-id
Sep 25, 2026
Merged

Timtam merged 2 commits into
mainfrom
fix/series-rows-by-id

Conversation

@Timtam

@Timtam Timtam commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

First PR of the arc "exceptions when splitting a series" (decision 134), decisions 135 and 139.

Why

Splitting a series ("this and all following") needs every occurrence the provider keeps as a row of its own, {series}::rid::{slot}: a changed one stands in for its slot, and a cancelled one is an occurrence the user deleted, which the new series must not bring back. readSeriesRows read them through get_events over a month around the series' start and the cutoff.

  • A row further out was never seen. That matters as soon as the tail's exceptions come from the rows (a later PR of this arc).
  • On Google, the wide range was a coverage miss, and set off a full resync that the following write threw away.

The change

The read goes by id (135). The hosts return every cached {series}::rid:: row of the calendar, whatever its date, cancelled ones included. It is a range scan on the table's primary key (id >= 'S::rid::' AND id < 'S::rid:;'). It never warms, never repairs bindings (their repair judges "not in this batch", which a filtered batch would break) and never hides anything.

The host says how far its rows reach (139). SeriesReach is one of:

  • complete: Exchange and CalDAV (the cache holds the whole calendar), local and birthday calendars (no such rows at all);
  • window: the stretch the cache was filled for (Google, about now − 92 days to now + 366 days; the CalDAV fallback without a listing);
  • unknown: never read, or written to since the last refresh, because every write clears the window.

Rows and window are read in one transaction, so a refresh cannot land between them.

  • host-core: cache::series_rows routes like get_events (an external calendar without an adapter is an error, not an empty answer) and refuses an empty id or an occurrence's id. CacheStore::read_series_rows does the read. SeriesReach is generated for TypeScript.
  • Desktop: a get_series_rows command.
  • Phone: get_events_json takes an optional series_id rather than a method of its own, and its doc comment stays unchanged. UniFFI's checksum covers the doc comment, and a new method or a changed checksum would fail an older .so at load. An older .so ignores the field and answers with the stretch's events; seriesRowsFromHost reads that list as rows whose reach is unknown. The bindings are unchanged (check:bindings passes).
  • shared: readSeriesRows asks by series and returns { rows, reach }. A master without a rule is not read; the carry used to read for every single copy. The six callers (both editors, both carries, both delete helpers) take .rows.

Not yet used: the reach. The question before a split comes with the PR that puts the rows into the new series. Until then the tail's exceptions come from the master alone, and a question about the rows' reach would promise what the split does not yet do. A window vouches for rows by where they stand now, not by the slot they name (see the review round), and that question has to account for it.

Checks

  • cargo test --workspace --all-features: 2841 passed; fmt, clippy -D warnings clean; host-core alone clean; cargo xtask ts-types --check current (new SeriesReach.ts).
  • Desktop and mobile tsc, eslint clean; vitest 2171 passed, locally and under TZ=UTC; npm run check:bindings passes.
  • New tests:
    • host-core: every row of the series whatever its date, cancelled included, in shown order; ev-10 and ev-1x not taken for ev-1; another calendar's rows left alone; the reach for a window, a whole calendar, a calendar never read and one invalidated after a write (its rows still read); local and birthday calendars answer with none and complete; no adapter is NotFound; an occurrence id or an empty id is refused.
    • cal-ffi: get_events_json with a series_id answers with the object; unroutable is NotFound; an occurrence is InvalidField.
    • shared: the request carries the series and the old stretch; the reach passes through; an older host's events are narrowed to the series and their reach is unknown; a master without a rule is not read.
  • Red proofs, 12 of 12 red, tree restored byte for byte: no upper bound on the prefix; a date limit on the read; cancelled rows hidden; the whole calendar read as a window; an invalidated calendar reported complete; no adapter read as empty; an occurrence read as a series; the phone ignoring series_id; a master without a rule read; an older host's events not narrowed; an older host's answer taken as complete; the desktop asking get_events.
  • Not covered by a test: the phone call sites and the older-.so path end to end (no runner).

Review round

One adversarial round (2 lenses, every finding verified): 4 confirmed, all low after verification; 1 refuted (an unreadable stored window failing the read: only builds from 1 to 2 June 2026 wrote one, and those rows were reset since).

  • A window vouches for where rows stand now. Google fills its cache by the time a row is shown, not by the slot it names, on the full read and on every delta (list_events_full with timeMin/timeMax; list_events_incremental keeps a change only if cancelled or in the window). A cancelled row stands at its slot, so it is missing only when that slot lies outside the window. A changed occurrence moved out of the window is missing whatever its slot. The Window doc said the slot decided; it and the module doc are corrected, since the later question must not key on the series' end alone.
  • The routed read had no test: a new host-core test routes an external calendar to its account through a registered adapter that panics if asked, and reads that account's cache. Red proofs, 2 of 2: the routed read answering with none, and the calendar id passed as the account.
  • The phone bridge's getEventsJson declaration now names the second answer shape; it is not covered by UniFFI's checksum.
  • TODO.md: Google rows reach only as far as the cache; the phone half waits for a new .so (↻).

Checks for the round: host-core series tests 10 passed, clippy and fmt clean, ts-types --check current.

Phone

Nothing to regenerate. A new .so is needed for the series read itself; an older one keeps today's read.

🤖 Generated with Claude Code

Timtam and others added 2 commits September 25, 2026 22:22
Splitting a series ("this and all following") needs every occurrence the
provider keeps as a row of its own: a changed one stands in for its slot,
a cancelled one is an occurrence the user deleted. readSeriesRows read them
through get_events over a month around the series' start and the cutoff, so
a row further out was never seen, and on Google a long series set off a full
resync the following write threw away.

The hosts now read the rows by id (decision 135): every cached
`{series}::rid::` row of the calendar, whatever its date, cancelled ones
included, on the table's primary key. The read never warms, never repairs
bindings and never hides anything. With the rows the host says how far its
cache reaches (decision 139): complete (Exchange, CalDAV, local and birthday
calendars), a window (Google, about a year ahead), or unknown (never read,
or written to since the last refresh). Rows and window are read in one
transaction.

- host-core: `cache::series_rows` and `CacheStore::read_series_rows`, with
  `SeriesReach` (generated for TypeScript) and `SeriesRows`. An occurrence id
  or an empty id is refused; an external calendar without an adapter is an
  error, as for get_events.
- Desktop: a `get_series_rows` command.
- Phone: `get_events_json` takes an optional `series_id` instead of a method
  of its own, and its doc comment is left alone, so a native library built
  before this still loads (UniFFI's checksum covers the doc comment) and
  answers with the stretch's events. `seriesRowsFromHost` reads that list as
  rows whose reach is unknown.
- shared: `readSeriesRows` asks by series and returns `{ rows, reach }`; a
  master without a rule is not read (the carry used to read for every single
  copy). The six callers take `.rows`; the reach is for the question before a
  split, which comes with the rule that puts the rows into the new series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A windowed provider (Google) fills its cache by the time a row is
  shown, not by the slot it names, on the full read and on every delta. So
  a cancelled row, which stands at its slot, is missing only when the slot
  lies outside the window, but a changed occurrence moved out of it is
  missing whatever its slot. The `Window` doc and the module doc said the
  slot decided; the question before a split has to know the difference.
- A test for the path the read exists for: an external calendar with an
  adapter, routed to its account and read from that account's cache,
  without the adapter being asked.
- The phone bridge's `getEventsJson` declaration names the second answer
  shape. It is not covered by UniFFI's checksum.
- TODO.md: Google rows reach only as far as the cache; the phone half
  waits for a new `.so` (↻).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Timtam
Timtam merged commit 493ba41 into main Sep 25, 2026
13 checks passed
@Timtam
Timtam deleted the fix/series-rows-by-id branch September 25, 2026 21:17
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