diff --git a/docs/how-to/example-sync-cache.rst b/docs/how-to/example-sync-cache.rst index 8b811f1..6de985a 100644 --- a/docs/how-to/example-sync-cache.rst +++ b/docs/how-to/example-sync-cache.rst @@ -2,17 +2,16 @@ .. SPDX-License-Identifier: AGPL-3.0-or-later ================================ -Sync a calendar to a local cache +Sync an account to a local cache ================================ -A complete script that keeps a SQLite cache of one calendar's events in sync, using :doc:`authentication `, :doc:`incremental sync `, and :doc:`error handling ` together. +A complete script that keeps a SQLite cache of an account's events in sync, using :doc:`authentication ` and :doc:`incremental sync ` together. .. code-block:: python import sqlite3 from calendaring_jmap import get_jmap_client - from calendaring_jmap.error import JMAPMethodError DB_PATH = "events.db" @@ -27,70 +26,67 @@ A complete script that keeps a SQLite cache of one calendar's events in sync, us ) conn.execute( "CREATE TABLE IF NOT EXISTS sync_state (" - " calendar_id TEXT PRIMARY KEY," + " id INTEGER PRIMARY KEY CHECK (id = 0)," " token TEXT NOT NULL" ")" ) conn.commit() - def load_token(conn, calendar_id): - row = conn.execute( - "SELECT token FROM sync_state WHERE calendar_id = ?", (calendar_id,) - ).fetchone() + def load_token(conn): + row = conn.execute("SELECT token FROM sync_state WHERE id = 0").fetchone() return row[0] if row else None - def save_token(conn, calendar_id, token): + def save_token(conn, token): conn.execute( - "INSERT INTO sync_state (calendar_id, token) VALUES (?, ?) " - "ON CONFLICT(calendar_id) DO UPDATE SET token = excluded.token", - (calendar_id, token), + "INSERT INTO sync_state (id, token) VALUES (0, ?) " + "ON CONFLICT(id) DO UPDATE SET token = excluded.token", + (token,), ) conn.commit() - def sync_calendar(client, conn, calendar_id): - token = load_token(conn, calendar_id) + def sync_account(client, conn): + token = load_token(conn) if token is None: token = client.get_sync_token() - save_token(conn, calendar_id, token) + save_token(conn, token) return - try: - added, modified, deleted, token = client.get_objects_by_sync_token(token) - except JMAPMethodError as e: - if e.error_type == "serverPartialFail": - # The server dropped our baseline; start over from a fresh token. - token = client.get_sync_token() - save_token(conn, calendar_id, token) - return - raise - - for obj in list(added) + list(modified): - ical_str = obj.get_icalendar_instance().to_ical().decode() - conn.execute( - "INSERT INTO events (id, calendar_id, ical) VALUES (?, ?, ?) " - "ON CONFLICT(id) DO UPDATE SET ical = excluded.ical", - (obj.id, calendar_id, ical_str), + while True: + added, modified, deleted, old_state, new_state, has_more_changes = ( + client.get_objects_by_sync_token(token) ) - for event_id in deleted: - conn.execute("DELETE FROM events WHERE id = ?", (event_id,)) - save_token(conn, calendar_id, token) - conn.commit() + for obj in list(added) + list(modified): + ical_str = obj.get_icalendar_instance().to_ical().decode() + calendar_id = next(iter(obj.get_data()["calendarIds"])) + conn.execute( + "INSERT INTO events (id, calendar_id, ical) VALUES (?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET ical = excluded.ical", + (obj.id, calendar_id, ical_str), + ) + for event_id in deleted: + conn.execute("DELETE FROM events WHERE id = ?", (event_id,)) + save_token(conn, new_state) + conn.commit() - if __name__ == "__main__": - conn = sqlite3.connect(DB_PATH) - init_db(conn) + token = new_state + if not has_more_changes: + break - with get_jmap_client() as client: - for cal in client.get_calendars(): - sync_calendar(client, conn, cal.id) - conn.close() + if __name__ == "__main__": + with get_jmap_client() as client: + conn = sqlite3.connect(DB_PATH) + init_db(conn) + sync_account(client, conn) + conn.close() This reads configuration the same way every other example does, through :func:`~calendaring_jmap.get_jmap_client`'s explicit-argument/environment-variable/config-file chain; see :doc:`authenticate` for how to set that up. -The first run for a calendar has no stored token, so it only records a baseline and caches nothing; the next run starts fetching real deltas. :meth:`~calendaring_jmap.client.JMAPClient.get_objects_by_sync_token`'s :class:`~calendaring_jmap.error.JMAPMethodError` with ``error_type="serverPartialFail"`` means the server truncated the change list; the same :doc:`error-handling pattern ` applies here as anywhere else this error can occur. +JMAP state covers the whole account, not one calendar, so ``sync_state`` holds a single row for the whole account's token, not one per calendar: :meth:`~calendaring_jmap.client.JMAPClient.get_objects_by_sync_token` returns every changed event across every calendar in the account in one call, and ``obj.get_data()["calendarIds"]`` says which calendar each one actually belongs to. An event can belong to more than one calendar; this cache only has room for one row per event id, so it stores whichever calendar id comes first. + +The first run has no stored token, so it only records a baseline and caches nothing; the next run starts fetching real deltas. ``has_more_changes`` being ``True`` means the server truncated the page, not an error; the loop calls :meth:`~calendaring_jmap.client.JMAPClient.get_objects_by_sync_token` again with ``new_state`` until it's ``False``. See :doc:`errors` for the errors this can still raise, such as an account-wide capability or auth failure. diff --git a/docs/how-to/sync.rst b/docs/how-to/sync.rst index 51af70f..5e0424e 100644 --- a/docs/how-to/sync.rst +++ b/docs/how-to/sync.rst @@ -18,7 +18,9 @@ Fetch a token, then the delta # ... time passes, events are created, modified, or deleted ... # Fetch only the delta - added, modified, deleted, token = client.get_objects_by_sync_token(token) + added, modified, deleted, old_state, new_state, has_more_changes = ( + client.get_objects_by_sync_token(token) + ) for obj in added: print("New:", obj.get_icalendar_instance()) @@ -27,16 +29,31 @@ Fetch a token, then the delta for event_id in deleted: print("Deleted ID:", event_id) -``added`` and ``modified`` are lists of :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject`. ``deleted`` is a list of event IDs: those objects no longer exist on the server, so their data can't be fetched. The fourth element is the server's new sync token. Chaining straight from it avoids the race window a separate :meth:`~calendaring_jmap.client.JMAPClient.get_sync_token` round trip would open. +``added`` and ``modified`` are lists of :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject`. ``deleted`` is a list of event IDs: those objects no longer exist on the server, so their data can't be fetched. ``new_state`` is the server's new sync token; chaining straight from it avoids the race window a separate :meth:`~calendaring_jmap.client.JMAPClient.get_sync_token` round trip would open. ``old_state`` is the ``token`` argument echoed back, useful for detecting a server that silently accepted a stale or invalid token instead of reporting an error; see :meth:`~calendaring_jmap.client.JMAPClient.get_objects_by_sync_token`'s own docs for what both Cyrus and Stalwart actually do here. This example ignores ``has_more_changes``, fine for a small change set; see the next section for what it means and how to use it. Handle a truncated change list ============================== -:meth:`~calendaring_jmap.client.JMAPClient.get_objects_by_sync_token` raises :class:`~calendaring_jmap.error.JMAPMethodError` (``error_type="serverPartialFail"``) if the server truncated the change list (``hasMoreChanges: true``). If this happens, call :meth:`~calendaring_jmap.client.JMAPClient.get_sync_token` to establish a fresh baseline and re-sync from scratch. See :doc:`errors` for other errors this can raise. +If the server has more changes than it returned in one page, ``has_more_changes`` is ``True`` rather than :meth:`~calendaring_jmap.client.JMAPClient.get_objects_by_sync_token` raising an error. Call it again with ``new_state`` to fetch the next page, repeating until ``has_more_changes`` is ``False``: + +.. code-block:: python + + while True: + added, modified, deleted, old_state, new_state, has_more_changes = ( + client.get_objects_by_sync_token(token) + ) + # process added/modified/deleted here + token = new_state + if not has_more_changes: + break + +See :doc:`errors` for the errors this can still raise, such as an account-wide capability or auth failure. Persist the token between runs ============================== +This saves ``new_state`` once per run and doesn't loop on ``has_more_changes``, unlike the paging example above; fine for a change set small enough to fit in one page, otherwise loop the same way: + .. code-block:: python import json @@ -60,6 +77,8 @@ Persist the token between runs token = client.get_sync_token() save_token(token) else: - added, modified, deleted, token = client.get_objects_by_sync_token(token) + added, modified, deleted, old_state, new_state, has_more_changes = ( + client.get_objects_by_sync_token(token) + ) # process changes here - save_token(token) + save_token(new_state) diff --git a/news/+fix-sync-docs-six-tuple.documentation.rst b/news/+fix-sync-docs-six-tuple.documentation.rst new file mode 100644 index 0000000..13b3f15 --- /dev/null +++ b/news/+fix-sync-docs-six-tuple.documentation.rst @@ -0,0 +1 @@ +Fixed ``docs/how-to/sync.rst`` and ``docs/how-to/example-sync-cache.rst``, which still unpacked ``get_objects_by_sync_token``'s old 4-tuple and described the removed ``serverPartialFail`` exception. The sync-cache example also kept its sync token per calendar; fixed to key it per account, since JMAP state covers the whole account, not one calendar.