Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 39 additions & 43 deletions docs/how-to/example-sync-cache.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <authenticate>`, :doc:`incremental sync <sync>`, and :doc:`error handling <errors>` together.
A complete script that keeps a SQLite cache of an account's events in sync, using :doc:`authentication <authenticate>` and :doc:`incremental sync <sync>` together.

.. code-block:: python

import sqlite3

from calendaring_jmap import get_jmap_client
from calendaring_jmap.error import JMAPMethodError

DB_PATH = "events.db"

Expand All @@ -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 <errors>` 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.
29 changes: 24 additions & 5 deletions docs/how-to/sync.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand All @@ -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)
1 change: 1 addition & 0 deletions news/+fix-sync-docs-six-tuple.documentation.rst
Original file line number Diff line number Diff line change
@@ -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.
Loading