jotabase 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jotabase/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Python client for jotaBase.
2
+
3
+ Planned in docs/PYTHON-CLIENT.md.
4
+ """
5
+
6
+ # Set by scripts/set-version.mjs together with the npm packages. Never edit by hand.
7
+ __version__ = "0.7.0"
8
+
9
+ from .client import DEFAULT_URL, Database, connect # noqa: E402
10
+ from .errors import AuthError, JotabaseError # noqa: E402
11
+
12
+ __all__ = ["__version__", "connect", "Database", "DEFAULT_URL", "JotabaseError", "AuthError"]
jotabase/client.py ADDED
@@ -0,0 +1,362 @@
1
+ """Connecting to one database, and signing in to it.
2
+
3
+ Three ways in, the same as the server's data plane (CLAUDE.md, "Control plane vs
4
+ data plane"; docs/PYTHON-CLIENT.md, "What it does" 1):
5
+
6
+ - a **secret key** (``sk_``): servers and scripts, bypasses access rules;
7
+ - a **publishable key** (``pk_``), optionally with an app user signed in through
8
+ ``sign_in``: subject to access rules;
9
+ - **no key**: a public ``@handle/name`` database, read-only.
10
+
11
+ The credential sent is the app user's token if one is signed in, else the key,
12
+ else nothing -- and with nothing, **no Authorization header at all**, never
13
+ ``Bearer None``, which the server would refuse as a bad credential instead of
14
+ reading as an anonymous request. That is the JS client's ``credential()`` rule.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import deque
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from . import __version__
25
+ from .errors import AuthError, JotabaseError
26
+
27
+ DEFAULT_URL = "https://api.jotabase.com"
28
+ DEFAULT_TIMEOUT = 30.0
29
+
30
+ # "Use the database's current credential" -- distinct from None, which sends none.
31
+ _CURRENT = object()
32
+
33
+
34
+ class Database:
35
+ """One jotaBase database. Create it with :func:`jotabase.connect`."""
36
+
37
+ def __init__(
38
+ self,
39
+ name: str,
40
+ *,
41
+ key: str | None = None,
42
+ token: str | None = None,
43
+ url: str = DEFAULT_URL,
44
+ timeout: float = DEFAULT_TIMEOUT,
45
+ transport: httpx.BaseTransport | None = None,
46
+ ):
47
+ name = name.strip().strip("/")
48
+ if not name:
49
+ raise ValueError("a database name is required")
50
+ if name.startswith("@") and name.count("/") != 1:
51
+ raise ValueError("a public database is addressed as '@handle/name'")
52
+ if not name.startswith("@") and "/" in name:
53
+ raise ValueError(
54
+ f"{name!r} is not a database name; a public one is written '@handle/name'"
55
+ )
56
+ self.name = name
57
+ self.url = url.rstrip("/")
58
+ self.key = key
59
+ self.token = token
60
+ self.user: dict[str, Any] | None = None
61
+ # Seqs of this Database's own writes, so a listener can leave them out.
62
+ self._own_seqs: set[int] = set()
63
+ self._own_order: deque[int] = deque()
64
+ self._listeners: list[Any] = []
65
+ self._http = httpx.Client(
66
+ base_url=self.url,
67
+ timeout=timeout,
68
+ transport=transport,
69
+ headers={"User-Agent": f"jotabase-python/{__version__}"},
70
+ )
71
+
72
+ # -- lifecycle -----------------------------------------------------------
73
+
74
+ def close(self) -> None:
75
+ for listener in list(self._listeners):
76
+ listener.stop()
77
+ for engine in self.__dict__.get("_sql", {}).values():
78
+ engine.close()
79
+ self._http.close()
80
+
81
+ def __enter__(self) -> "Database":
82
+ return self
83
+
84
+ def __exit__(self, *exc: object) -> None:
85
+ self.close()
86
+
87
+ def __repr__(self) -> str:
88
+ return f"<jotabase.Database {self.name!r} at {self.url} ({self.credential_kind})>"
89
+
90
+ # -- credentials ---------------------------------------------------------
91
+
92
+ @property
93
+ def is_public(self) -> bool:
94
+ """Addressed as ``@handle/name``: someone's published database."""
95
+ return self.name.startswith("@")
96
+
97
+ @property
98
+ def credential(self) -> str | None:
99
+ """What is sent as ``Authorization: Bearer``: token, else key, else nothing."""
100
+ return self.token or self.key or None
101
+
102
+ @property
103
+ def credential_kind(self) -> str:
104
+ """``app_user``, ``secret``, ``publishable`` or ``anonymous``, from what is held.
105
+
106
+ A local reading of the credential's shape; the server decides what it
107
+ really is. Useful for messages, not for access decisions.
108
+ """
109
+ if self.token:
110
+ return "app_user"
111
+ if self.key and self.key.startswith("sk_"):
112
+ return "secret"
113
+ if self.key and self.key.startswith("pk_"):
114
+ return "publishable"
115
+ if self.key:
116
+ return "unknown"
117
+ return "anonymous"
118
+
119
+ # -- app users (/_auth/*) ------------------------------------------------
120
+
121
+ def sign_up(self, email: str, password: str) -> dict[str, Any]:
122
+ """Create an app user (role ``member``) and, when a token comes back, sign in.
123
+
124
+ A database with confirmed email required returns
125
+ ``verification_required: True`` and no token: the user must open the
126
+ emailed link, then ``sign_in``.
127
+ """
128
+ body = self._auth_post("/_auth/signup", email, password)
129
+ self._take_session(body)
130
+ return body
131
+
132
+ def sign_in(self, email: str, password: str) -> dict[str, Any]:
133
+ """Sign an app user in; later requests carry their token and their roles.
134
+
135
+ Raises :class:`AuthError` -- ``code='email_not_verified'`` (403) when the
136
+ database requires a confirmed address and this one is not.
137
+ """
138
+ body = self._auth_post("/_auth/login", email, password)
139
+ self._take_session(body)
140
+ return body
141
+
142
+ def sign_out(self) -> None:
143
+ """Forget the app user's token; requests go back to using the key."""
144
+ self.token = None
145
+ self.user = None
146
+
147
+ def me(self) -> dict[str, Any]:
148
+ """The signed-in app user's id, email, roles and whether they are verified."""
149
+ if not self.token:
150
+ raise AuthError("no app user is signed in", status=None)
151
+ try:
152
+ return self._request("GET", "/_auth/me").json()
153
+ except JotabaseError as e:
154
+ raise AuthError(str(e), e.status, e.code) from None
155
+
156
+ def _auth_post(self, path: str, email: str, password: str) -> dict[str, Any]:
157
+ if not self.key:
158
+ raise AuthError("signing app users in needs the database's publishable key")
159
+ # The key, never a previous user's token: /_auth answers for the database.
160
+ try:
161
+ res = self._request(
162
+ "POST", path, json={"email": email, "password": password}, credential=self.key
163
+ )
164
+ except JotabaseError as e:
165
+ raise AuthError(str(e), e.status, e.code) from None
166
+ return res.json()
167
+
168
+ def _take_session(self, body: dict[str, Any]) -> None:
169
+ if body.get("token"):
170
+ self.token = body["token"]
171
+ self.user = body.get("user")
172
+
173
+ # -- reading -------------------------------------------------------------
174
+
175
+ def fetch_changes(self, since: int = 0, on_progress=None):
176
+ """Everything past ``since`` as documents, in apply order; stores nothing.
177
+
178
+ Returns :class:`jotabase.sync.Changes`: ``.items``, ``.checkpoint`` (pass
179
+ it as ``since`` next time) and ``.latest()`` (newest version per id,
180
+ deleted ones left out). The bring-your-own-store path, like the JS
181
+ client's ``fetchChanges``.
182
+ """
183
+ from .sync import fetch_changes
184
+
185
+ return fetch_changes(self, since, on_progress)
186
+
187
+ def list_changes(self, since: int = 0, files_since: int | None = None):
188
+ """One ``/_sync`` without fetching any file: segments, tail and stats."""
189
+ from .sync import list_changes
190
+
191
+ return list_changes(self, since, files_since)
192
+
193
+ def local(self, folder=None):
194
+ """The local copy on disk (default ``./.jotabase/<db>/``); see :class:`LocalStore`."""
195
+ from .local import LocalStore
196
+
197
+ if folder is None and getattr(self, "_local", None) is not None:
198
+ return self._local
199
+ store = LocalStore(self, folder)
200
+ if folder is None:
201
+ self._local = store
202
+ return store
203
+
204
+ def pull(self, folder=None, on_progress=None):
205
+ """Bring the local copy up to date; only what changed is downloaded."""
206
+ return self.local(folder).pull(on_progress)
207
+
208
+ def sql_engine(self, folder=None):
209
+ """The DuckDB engine over the local copy; see :class:`jotabase.sql.LocalSql`."""
210
+ from .sql import LocalSql
211
+
212
+ store = self.local(folder)
213
+ engines = self.__dict__.setdefault("_sql", {})
214
+ key = str(store.folder.resolve())
215
+ if key not in engines:
216
+ engines[key] = LocalSql(store)
217
+ return engines[key]
218
+
219
+ def sql(self, query: str, params=None, folder=None):
220
+ """SQL over the local copy: one view per collection. Call :meth:`pull` first.
221
+
222
+ >>> db.pull()
223
+ >>> db.sql("SELECT status, count(*) FROM tasks GROUP BY status").df()
224
+ """
225
+ return self.sql_engine(folder).sql(query, params)
226
+
227
+ # -- live changes --------------------------------------------------------
228
+
229
+ def on_change(self, handler=None, *, since=None, pull=False, include_own=False, on_error=None):
230
+ """Call ``handler(change)`` for every change, from a background thread.
231
+
232
+ ``change.docs`` are the new versions (tombstones included, ``deleted``
233
+ set), ``change.last_seq`` the server's seq. Starts from now, or from
234
+ ``since``. ``pull=True`` also brings the local copy (and ``db.sql``) up to
235
+ date before each call. Usable as a decorator::
236
+
237
+ @db.on_change
238
+ def changed(change):
239
+ for doc in change.docs: ...
240
+
241
+ Returns a :class:`jotabase.live.Listener`; ``.stop()`` ends it, and so
242
+ does ``db.close()``. Needs the ``websockets`` package.
243
+ """
244
+ from .live import Listener
245
+
246
+ def start(h):
247
+ listener = Listener(self, h, since=since, pull=pull, include_own=include_own,
248
+ on_error=on_error)
249
+ self._listeners.append(listener)
250
+ return listener
251
+
252
+ return start if handler is None else start(handler)
253
+
254
+ def _note_own_writes(self, seqs) -> None:
255
+ for s in seqs:
256
+ if s and s not in self._own_seqs:
257
+ self._own_seqs.add(s)
258
+ self._own_order.append(s)
259
+ while len(self._own_order) > 100_000:
260
+ self._own_seqs.discard(self._own_order.popleft())
261
+
262
+ # -- writing -------------------------------------------------------------
263
+
264
+ def put(self, collection: str | None, data: dict[str, Any], id: str | None = None):
265
+ """Create or replace one document; returns its :class:`WriteResult`.
266
+
267
+ The id is ``id``, else ``data["id"]``, else a new UUID. A document an
268
+ access rule refuses comes back with ``ok=False`` and the reason in
269
+ ``error`` -- check it; it is not raised.
270
+ """
271
+ from .push import make_doc, push
272
+
273
+ return push(self, [make_doc(collection, data, id)])[0]
274
+
275
+ def put_many(
276
+ self,
277
+ collection: str | None,
278
+ docs,
279
+ silent: bool = False,
280
+ on_progress=None,
281
+ ):
282
+ """Create or replace many documents, in as few requests as fit under 1 MB.
283
+
284
+ Returns :class:`WriteResults` (a list, in input order) with ``.ok`` and
285
+ ``.failed``. ``silent=True`` writes without notifying live listeners per
286
+ batch -- for bulk loads.
287
+ """
288
+ from .push import make_doc, push_many
289
+
290
+ return push_many(
291
+ self, (make_doc(collection, d) for d in docs), silent=silent, on_progress=on_progress
292
+ )
293
+
294
+ def delete(self, collection: str | None, id: str):
295
+ """Delete one document (a tombstone); returns its :class:`WriteResult`."""
296
+ from .push import make_doc, push
297
+
298
+ return push(self, [make_doc(collection, {}, id, deleted=True)])[0]
299
+
300
+ def push(self, docs: list[dict[str, Any]], silent: bool = False):
301
+ """Raw ``/_push`` of ready-made entries ``{id, data, deleted?, collection?}``, batched."""
302
+ from .push import push_many
303
+
304
+ return push_many(self, docs, silent=silent)
305
+
306
+ # -- transport -----------------------------------------------------------
307
+
308
+ def _request(
309
+ self,
310
+ method: str,
311
+ path: str,
312
+ *,
313
+ credential: Any = _CURRENT,
314
+ **kwargs: Any,
315
+ ) -> httpx.Response:
316
+ """One data-plane request to ``/{db}{path}``; raises on anything but 2xx."""
317
+ cred = self.credential if credential is _CURRENT else credential
318
+ headers = dict(kwargs.pop("headers", None) or {})
319
+ if cred:
320
+ headers["Authorization"] = f"Bearer {cred}"
321
+ target = path if path.startswith("/" + self.name + "/") else f"/{self.name}{path}"
322
+ try:
323
+ res = self._http.request(method, target, headers=headers, **kwargs)
324
+ except httpx.HTTPError as e:
325
+ raise JotabaseError(f"{method} {target} failed: {e}") from e
326
+ if res.is_success:
327
+ return res
328
+ raise _error_from(res)
329
+
330
+
331
+ def _error_from(res: httpx.Response) -> JotabaseError:
332
+ try:
333
+ data = res.json()
334
+ except ValueError:
335
+ data = {}
336
+ if not isinstance(data, dict):
337
+ data = {}
338
+ message = data.get("reason") or data.get("message") or data.get("error") or res.reason_phrase
339
+ code = data.get("error") if isinstance(data.get("error"), str) else None
340
+ return JotabaseError(f"{res.status_code}: {message}", res.status_code, code)
341
+
342
+
343
+ def connect(
344
+ name: str,
345
+ *,
346
+ key: str | None = None,
347
+ token: str | None = None,
348
+ url: str = DEFAULT_URL,
349
+ timeout: float = DEFAULT_TIMEOUT,
350
+ transport: httpx.BaseTransport | None = None,
351
+ ) -> Database:
352
+ """Open one database.
353
+
354
+ >>> db = jotabase.connect("my-app", key="sk_live_...") # a server or script
355
+ >>> db = jotabase.connect("my-app", key="pk_live_...") # an app, rules apply
356
+ >>> db = jotabase.connect("@jotabase/trips") # a public database
357
+
358
+ Nothing is sent until the first call that needs the server. ``url`` points at
359
+ another server (``http://localhost:8787`` for ``wrangler dev``); ``transport``
360
+ is for tests.
361
+ """
362
+ return Database(name, key=key, token=token, url=url, timeout=timeout, transport=transport)
jotabase/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ """Errors raised by the client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class JotabaseError(Exception):
7
+ """A request the server refused, or one that never reached it.
8
+
9
+ ``status`` is the HTTP status (``None`` when no response arrived), so a caller
10
+ can tell an expired or wrong credential (401/403) from a transient failure --
11
+ the same distinction the JS client's ``SyncError`` carries. ``code`` is the
12
+ server's ``error`` field when it sent one (``'email_not_verified'``, ...).
13
+ """
14
+
15
+ def __init__(self, message: str, status: int | None = None, code: str | None = None):
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.code = code
19
+
20
+
21
+ class AuthError(JotabaseError):
22
+ """Sign-up, sign-in or identity failed (``/_auth/*``)."""
jotabase/live.py ADDED
@@ -0,0 +1,244 @@
1
+ """Live changes over ``/_ws``.
2
+
3
+ docs/PYTHON-CLIENT.md, "What it does" 6. What the server sends, and what this
4
+ does with it:
5
+
6
+ - ``{type: 'change', last_seq}`` (strategies ``live`` and ``batch``) means
7
+ "there is something to read" -- sent **after** the publish lands. The
8
+ listener reads it with ``fetch_changes(since=checkpoint)`` and hands the new
9
+ documents to the handler.
10
+ - ``{type: 'change', last_seq, docs}`` (strategy ``push``) carries the documents
11
+ themselves, already filtered for this socket's roles, so nothing is read.
12
+ - ``{type: 'log', ...}`` frames are the dashboard's server log; ignored.
13
+
14
+ On every (re)connect the listener first catches up from its checkpoint, so a
15
+ dropped connection loses nothing. The credential goes in the
16
+ ``Authorization`` header, never in the URL (decision 8). A public database has
17
+ no anonymous socket, so it cannot be listened to.
18
+
19
+ A handler runs on the listener's thread, one change at a time. Changes caused
20
+ by this ``Database``'s own writes are left out unless ``include_own=True`` --
21
+ a script that writes in response to changes would otherwise feed itself, the
22
+ same reason the JS client skips its self-writes.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import threading
30
+ from dataclasses import dataclass, field
31
+ from typing import TYPE_CHECKING, Any, Callable
32
+
33
+ from .errors import JotabaseError
34
+
35
+ if TYPE_CHECKING:
36
+ from .client import Database
37
+
38
+ log = logging.getLogger("jotabase.live")
39
+
40
+ INITIAL_BACKOFF = 1.0
41
+ MAX_BACKOFF = 30.0
42
+
43
+
44
+ @dataclass
45
+ class Change:
46
+ """What one notification brought: documents with ``seq`` past the last one seen."""
47
+
48
+ docs: list[dict[str, Any]]
49
+ last_seq: int
50
+ #: True when the frame carried the documents (``push``); False when they were read.
51
+ pushed: bool = False
52
+ #: True for the catch-up after a (re)connect rather than a notification.
53
+ catch_up: bool = False
54
+
55
+
56
+ @dataclass
57
+ class _State:
58
+ checkpoint: int
59
+ error: BaseException | None = None
60
+ connected: threading.Event = field(default_factory=threading.Event)
61
+
62
+
63
+ def ws_url(db: "Database") -> str:
64
+ base = db.url
65
+ if base.startswith("https://"):
66
+ base = "wss://" + base[len("https://"):]
67
+ elif base.startswith("http://"):
68
+ base = "ws://" + base[len("http://"):]
69
+ return f"{base}/{db.name}/_ws"
70
+
71
+
72
+ class Listener:
73
+ """A background connection calling ``handler(change)`` for each change.
74
+
75
+ Get one from :meth:`Database.on_change`. ``stop()`` ends it; it is also a
76
+ context manager. ``wait()`` blocks until it stops (or ``timeout``).
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ db: "Database",
82
+ handler: Callable[[Change], Any],
83
+ since: int | None = None,
84
+ pull: bool = False,
85
+ include_own: bool = False,
86
+ on_error: Callable[[BaseException], Any] | None = None,
87
+ ):
88
+ if db.is_public or not db.credential:
89
+ raise JotabaseError("live changes need a key or a signed-in user; "
90
+ "a public database has no anonymous socket")
91
+ self.db = db
92
+ self.handler = handler
93
+ self.pull = pull
94
+ self.include_own = include_own
95
+ self.on_error = on_error
96
+ self._stop = threading.Event()
97
+ self._ws = None
98
+ self._lock = threading.Lock()
99
+ self._state = _State(checkpoint=since if since is not None else -1)
100
+ self._thread = threading.Thread(target=self._run, name=f"jotabase-live-{db.name}", daemon=True)
101
+ self._thread.start()
102
+
103
+ # -- public --------------------------------------------------------------
104
+
105
+ @property
106
+ def checkpoint(self) -> int:
107
+ """The highest seq delivered (or skipped as nothing new)."""
108
+ return self._state.checkpoint
109
+
110
+ @property
111
+ def connected(self) -> bool:
112
+ return self._state.connected.is_set()
113
+
114
+ @property
115
+ def error(self) -> BaseException | None:
116
+ """Set when the listener stopped because the server refused it (401/403)."""
117
+ return self._state.error
118
+
119
+ def wait_connected(self, timeout: float | None = None) -> bool:
120
+ return self._state.connected.wait(timeout)
121
+
122
+ def stop(self) -> None:
123
+ self._stop.set()
124
+ with self._lock:
125
+ ws = self._ws
126
+ if ws is not None:
127
+ try:
128
+ ws.close()
129
+ except Exception: # noqa: BLE001 - closing a dead socket
130
+ pass
131
+ if threading.current_thread() is not self._thread:
132
+ self._thread.join(timeout=10)
133
+
134
+ def wait(self, timeout: float | None = None) -> bool:
135
+ """Block until the listener stops; True if it did within ``timeout``."""
136
+ self._thread.join(timeout)
137
+ return not self._thread.is_alive()
138
+
139
+ def __enter__(self) -> "Listener":
140
+ return self
141
+
142
+ def __exit__(self, *exc: object) -> None:
143
+ self.stop()
144
+
145
+ # -- loop ----------------------------------------------------------------
146
+
147
+ def _run(self) -> None:
148
+ from websockets.exceptions import InvalidStatus
149
+ from websockets.sync.client import connect
150
+
151
+ backoff = INITIAL_BACKOFF
152
+ while not self._stop.is_set():
153
+ try:
154
+ headers = {"Authorization": f"Bearer {self.db.credential}",
155
+ "User-Agent": self.db._http.headers.get("User-Agent", "jotabase-python")}
156
+ with connect(ws_url(self.db), additional_headers=headers, open_timeout=15,
157
+ close_timeout=2, max_size=None) as ws:
158
+ with self._lock:
159
+ self._ws = ws
160
+ if self._stop.is_set():
161
+ return
162
+ self._state.connected.set()
163
+ backoff = INITIAL_BACKOFF
164
+ self._catch_up()
165
+ for message in ws:
166
+ if self._stop.is_set():
167
+ return
168
+ self._on_frame(message)
169
+ except InvalidStatus as e:
170
+ status = getattr(getattr(e, "response", None), "status_code", None)
171
+ if status in (401, 403):
172
+ self._state.error = JotabaseError(f"live connection refused: {status}", status)
173
+ self._report(self._state.error)
174
+ return
175
+ self._report(e)
176
+ except Exception as e: # noqa: BLE001 - reconnect on anything else
177
+ if self._stop.is_set():
178
+ return
179
+ self._report(e)
180
+ finally:
181
+ self._state.connected.clear()
182
+ with self._lock:
183
+ self._ws = None
184
+ if self._stop.wait(backoff):
185
+ return
186
+ backoff = min(backoff * 2, MAX_BACKOFF)
187
+
188
+ def _report(self, e: BaseException) -> None:
189
+ if self.on_error is not None:
190
+ try:
191
+ self.on_error(e)
192
+ except Exception: # noqa: BLE001
193
+ log.exception("on_error raised")
194
+ else:
195
+ log.warning("jotabase live (%s): %s", self.db.name, e)
196
+
197
+ def _catch_up(self) -> None:
198
+ """At (re)connect: read what was missed. A fresh listener starts at now."""
199
+ if self._state.checkpoint < 0:
200
+ self._state.checkpoint = self.db.list_changes(since=2**62).checkpoint
201
+ return
202
+ self._read(catch_up=True)
203
+
204
+ def _on_frame(self, message: Any) -> None:
205
+ try:
206
+ msg = json.loads(message)
207
+ except (TypeError, ValueError):
208
+ return
209
+ if not isinstance(msg, dict) or msg.get("type") != "change":
210
+ return
211
+ last_seq = msg.get("last_seq")
212
+ if not isinstance(last_seq, int) or last_seq <= self._state.checkpoint:
213
+ return
214
+ if isinstance(msg.get("docs"), list):
215
+ docs = [d for d in msg["docs"] if isinstance(d, dict) and d.get("seq", 0) > self._state.checkpoint]
216
+ self._state.checkpoint = last_seq
217
+ self._deliver(Change(docs, last_seq, pushed=True))
218
+ else:
219
+ self._read()
220
+
221
+ def _read(self, catch_up: bool = False) -> None:
222
+ since = self._state.checkpoint
223
+ changes = self.db.fetch_changes(since=since)
224
+ # A listed segment can hold versions at or before `since`.
225
+ docs = [d for d in changes.items if d["seq"] > since]
226
+ self._state.checkpoint = max(since, changes.checkpoint)
227
+ if docs or not catch_up:
228
+ self._deliver(Change(docs, self._state.checkpoint, catch_up=catch_up))
229
+
230
+ def _deliver(self, change: Change) -> None:
231
+ if not self.include_own:
232
+ own = self.db._own_seqs
233
+ change.docs = [d for d in change.docs if d.get("seq") not in own]
234
+ if self.pull:
235
+ try:
236
+ self.db.pull()
237
+ except Exception as e: # noqa: BLE001
238
+ self._report(e)
239
+ if not change.docs:
240
+ return
241
+ try:
242
+ self.handler(change)
243
+ except Exception: # noqa: BLE001 - a handler's bug must not end the listener
244
+ log.exception("jotabase live handler raised")