librus-python-api 1.0.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.
Files changed (48) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_bootstrap.py +124 -0
  3. librus_python_api/_notification_codec.py +396 -0
  4. librus_python_api/_storage.py +403 -0
  5. librus_python_api/_windows_filesystem.py +390 -0
  6. librus_python_api/announcements.py +159 -0
  7. librus_python_api/attachment_routes.py +114 -0
  8. librus_python_api/attachments.py +297 -0
  9. librus_python_api/attendance.py +182 -0
  10. librus_python_api/attendance_frequency.py +112 -0
  11. librus_python_api/budget.py +79 -0
  12. librus_python_api/checkpoint.py +61 -0
  13. librus_python_api/completed_lessons.py +216 -0
  14. librus_python_api/config.py +1410 -0
  15. librus_python_api/detail_fields.py +50 -0
  16. librus_python_api/diagnostics.py +25 -0
  17. librus_python_api/exceptions.py +172 -0
  18. librus_python_api/files.py +242 -0
  19. librus_python_api/grade_parsers.py +169 -0
  20. librus_python_api/grade_records.py +454 -0
  21. librus_python_api/homework_range.py +41 -0
  22. librus_python_api/lifecycle.py +24 -0
  23. librus_python_api/markup.py +147 -0
  24. librus_python_api/message_content.py +230 -0
  25. librus_python_api/messages.py +288 -0
  26. librus_python_api/models.py +1160 -0
  27. librus_python_api/modern_body.py +75 -0
  28. librus_python_api/modern_mailbox.py +459 -0
  29. librus_python_api/modern_messages.py +276 -0
  30. librus_python_api/notification_models.py +145 -0
  31. librus_python_api/notification_persistence.py +1243 -0
  32. librus_python_api/notification_workflow.py +337 -0
  33. librus_python_api/notifications.py +216 -0
  34. librus_python_api/parsers.py +232 -0
  35. librus_python_api/parsing.py +49 -0
  36. librus_python_api/persistence.py +419 -0
  37. librus_python_api/py.typed +0 -0
  38. librus_python_api/recipients.py +271 -0
  39. librus_python_api/scheduler.py +287 -0
  40. librus_python_api/school_reads.py +400 -0
  41. librus_python_api/sending.py +125 -0
  42. librus_python_api/service.py +2285 -0
  43. librus_python_api/timetable.py +261 -0
  44. librus_python_api/transport.py +956 -0
  45. librus_python_api-1.0.0.dist-info/METADATA +262 -0
  46. librus_python_api-1.0.0.dist-info/RECORD +48 -0
  47. librus_python_api-1.0.0.dist-info/WHEEL +4 -0
  48. librus_python_api-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1243 @@
1
+ """Explicit optional notification state, raw checkpoints and two-phase delivery."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import os
7
+ import secrets
8
+ import sqlite3
9
+ import stat
10
+ from collections.abc import AsyncIterator, Callable, Coroutine
11
+ from contextlib import asynccontextmanager
12
+ from dataclasses import asdict, replace
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pydantic import Field
17
+
18
+ from librus_python_api._notification_bootstrap import prepare_bootstrap
19
+ from librus_python_api._notification_codec import (
20
+ ARCHIVE_BYTES,
21
+ HEX,
22
+ META_BYTES,
23
+ WIRE_BYTES,
24
+ canonical_notification_id,
25
+ dump,
26
+ empty_state,
27
+ encode_batch,
28
+ encode_envelope,
29
+ is_historical_batch,
30
+ load,
31
+ restore_batch,
32
+ restore_envelope,
33
+ restore_state,
34
+ state_bytes,
35
+ )
36
+ from librus_python_api._storage import _SQLiteStore, _StorageLimits
37
+ from librus_python_api.exceptions import ErrorKind, LibrusError
38
+ from librus_python_api.models import (
39
+ AccountContext,
40
+ NotificationCategory,
41
+ ScheduleEventResponse,
42
+ )
43
+ from librus_python_api.notification_models import (
44
+ NotificationArchive,
45
+ NotificationBatch,
46
+ NotificationBootstrap,
47
+ NotificationBootstrapResult,
48
+ NotificationItem,
49
+ NotificationPendingDelivery,
50
+ NotificationRawCheckpointStatus,
51
+ NotificationRecoveryStatus,
52
+ NotificationSeen,
53
+ NotificationState,
54
+ )
55
+ from librus_python_api.notifications import decode_payload, parse_schedule_events
56
+
57
+ try:
58
+ import fcntl
59
+ except ImportError:
60
+ fcntl = None # type: ignore[assignment]
61
+
62
+ _CONTEXT_SCHEMA = """CREATE TABLE notification_contexts (
63
+ context TEXT PRIMARY KEY CHECK(length(context)=64)
64
+ )"""
65
+ _STATE_SCHEMA = """CREATE TABLE notification_state (
66
+ context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
67
+ payload BLOB NOT NULL,
68
+ last_receipt TEXT CHECK(last_receipt IS NULL OR length(last_receipt)=64)
69
+ )"""
70
+ _RAW_SCHEMA = """CREATE TABLE notification_raw (
71
+ context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
72
+ identifier TEXT NOT NULL CHECK(length(identifier)=64),
73
+ metadata BLOB NOT NULL,
74
+ body BLOB NOT NULL,
75
+ cursor INTEGER NOT NULL CHECK(cursor>=0 AND cursor<=1024),
76
+ total INTEGER CHECK(total IS NULL OR (total>=cursor AND total<=1024))
77
+ )"""
78
+ _RESERVATION_SCHEMA = """CREATE TABLE notification_reservations (
79
+ context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
80
+ bytes INTEGER NOT NULL CHECK(bytes>0 AND bytes<=8388608)
81
+ )"""
82
+ _DELIVERY_SCHEMA = """CREATE TABLE notification_deliveries (
83
+ context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
84
+ receipt TEXT NOT NULL CHECK(length(receipt)=64),
85
+ payload BLOB NOT NULL,
86
+ state_after BLOB NOT NULL,
87
+ raw_identifier TEXT CHECK(raw_identifier IS NULL OR length(raw_identifier)=64),
88
+ cursor_after INTEGER CHECK(cursor_after IS NULL OR (
89
+ cursor_after>=0 AND cursor_after<=1024))
90
+ )"""
91
+
92
+
93
+ class NotificationLimits(_StorageLimits):
94
+ contexts: int = Field(default=16, ge=1, le=64)
95
+ checkpoint_records: int = Field(default=32, ge=1, le=256)
96
+ checkpoint_bytes: int = Field(default=16 * 1024 * 1024, ge=1, le=32 * 1024 * 1024)
97
+ seen_ids_per_category: int = Field(default=4096, ge=1, le=4096)
98
+ state_bytes: int = Field(default=4 * 1024 * 1024, ge=1, le=4 * 1024 * 1024)
99
+ batch_items: int = Field(default=500, ge=1, le=4096)
100
+ batch_bytes: int = Field(default=1024 * 1024, ge=1, le=4 * 1024 * 1024)
101
+ replay_events: int = Field(default=500, ge=1, le=1024)
102
+ replay_bytes: int = Field(default=128 * 1024, ge=1, le=1024 * 1024)
103
+
104
+
105
+ def _context_id(context: AccountContext) -> str:
106
+ if (
107
+ not isinstance(context, AccountContext)
108
+ or type(context.identifier) is not str
109
+ or HEX.fullmatch(context.identifier) is None
110
+ or type(context.alias) is not str
111
+ or not 1 <= len(context.alias) <= 80
112
+ ):
113
+ raise LibrusError(ErrorKind.INVALID_INPUT)
114
+ return context.identifier
115
+
116
+
117
+ def _raw_id(context: str, metadata: bytes, body: bytes) -> str:
118
+ digest = hashlib.sha256(context.encode("ascii"))
119
+ digest.update(len(metadata).to_bytes(8, "big"))
120
+ digest.update(metadata)
121
+ digest.update(body)
122
+ return digest.hexdigest()
123
+
124
+
125
+ def _seen_after(
126
+ state: NotificationState, items: tuple[NotificationItem, ...], limit: int
127
+ ) -> NotificationState:
128
+ seen = {entry.category: list(entry.identifiers) for entry in state.seen}
129
+ for item in items:
130
+ identifiers = seen[item.category]
131
+ if item.identifier not in identifiers:
132
+ if len(identifiers) >= limit:
133
+ raise LibrusError(ErrorKind.LIMIT)
134
+ identifiers.append(item.identifier)
135
+ return NotificationState(
136
+ True,
137
+ tuple(
138
+ NotificationSeen(entry.category, tuple(seen[entry.category]))
139
+ for entry in state.seen
140
+ ),
141
+ )
142
+
143
+
144
+ class NotificationStore(_SQLiteStore):
145
+ """Private native recovery store. No MCP files, auto migration or live access.
146
+
147
+ Context locks require POSIX flock. Unsupported platforms fail explicitly.
148
+ Raw checkpoints contain private account/content metadata, never HTTP cookies.
149
+ """
150
+
151
+ _filename = "notifications.sqlite3"
152
+ _database_bytes = 64 * 1024 * 1024
153
+ _schema = (
154
+ _CONTEXT_SCHEMA,
155
+ _STATE_SCHEMA,
156
+ _RAW_SCHEMA,
157
+ _RESERVATION_SCHEMA,
158
+ _DELIVERY_SCHEMA,
159
+ )
160
+
161
+ def __init__(
162
+ self, directory: Path, *, limits: NotificationLimits | None = None
163
+ ) -> None:
164
+ self._notification_limits = (
165
+ limits if limits is not None else NotificationLimits()
166
+ )
167
+ if not isinstance(self.limits, NotificationLimits):
168
+ raise LibrusError(ErrorKind.INVALID_INPUT)
169
+ super().__init__(directory, limits=self.limits)
170
+
171
+ @property
172
+ def limits(self) -> NotificationLimits:
173
+ return self._notification_limits
174
+
175
+ def _validate_contents(self, connection: sqlite3.Connection) -> None:
176
+ contexts = connection.execute(
177
+ "SELECT context FROM notification_contexts LIMIT ?",
178
+ (self.limits.contexts + 1,),
179
+ ).fetchall()
180
+ if len(contexts) > self.limits.contexts:
181
+ raise LibrusError(ErrorKind.LIMIT)
182
+ if any(
183
+ type(row[0]) is not str or HEX.fullmatch(row[0]) is None for row in contexts
184
+ ):
185
+ raise LibrusError(ErrorKind.PARSE)
186
+ if connection.execute("PRAGMA foreign_key_check").fetchone() is not None:
187
+ raise LibrusError(ErrorKind.PARSE)
188
+ records, used = connection.execute(
189
+ "SELECT count(*), coalesce(sum(length(metadata)+length(body)),0) "
190
+ "FROM notification_raw"
191
+ ).fetchone()
192
+ reservations, reserved = connection.execute(
193
+ "SELECT count(*), coalesce(sum(bytes),0) FROM notification_reservations"
194
+ ).fetchone()
195
+ if (
196
+ records + reservations > self.limits.checkpoint_records
197
+ or used + reserved > self.limits.checkpoint_bytes
198
+ ):
199
+ raise LibrusError(ErrorKind.LIMIT)
200
+ state_size = connection.execute(
201
+ "SELECT coalesce(sum(length(payload)),0) FROM notification_state"
202
+ ).fetchone()[0]
203
+ delivery_size = connection.execute(
204
+ "SELECT coalesce(sum(length(payload)+length(state_after)),0) "
205
+ "FROM notification_deliveries"
206
+ ).fetchone()[0]
207
+ if state_size > self.limits.state_bytes or delivery_size > 16 * 1024 * 1024:
208
+ raise LibrusError(ErrorKind.LIMIT)
209
+
210
+ def _register(self, context: str) -> None:
211
+ context = self._context_key(context)
212
+ with self._connection() as connection:
213
+ self._validate_contents(connection)
214
+ self._register_context(connection, context)
215
+
216
+ def _register_context(self, connection: sqlite3.Connection, key: str) -> None:
217
+ if (
218
+ connection.execute(
219
+ "SELECT 1 FROM notification_contexts WHERE context=?", (key,)
220
+ ).fetchone()
221
+ is not None
222
+ ):
223
+ return
224
+ count = connection.execute(
225
+ "SELECT count(*) FROM notification_contexts"
226
+ ).fetchone()[0]
227
+ if count >= self.limits.contexts:
228
+ raise LibrusError(ErrorKind.LIMIT)
229
+ connection.execute("INSERT INTO notification_contexts VALUES (?)", (key,))
230
+
231
+ def _lock_context(self, context: str, held: list[int]) -> None:
232
+ context = self._context_key(context)
233
+ if self._windows_directory is not None:
234
+ from librus_python_api._windows_filesystem import acquire_lock
235
+
236
+ acquire_lock(self._windows_directory, f"notification-{context}.lock", held)
237
+ return
238
+ if fcntl is None:
239
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
240
+ path = self._directory / f"notification-{context}.lock"
241
+ if path.is_symlink():
242
+ raise LibrusError(ErrorKind.STORAGE)
243
+ descriptor = os.open(
244
+ path, os.O_CREAT | os.O_RDWR | os.O_NONBLOCK | os.O_NOFOLLOW, 0o600
245
+ )
246
+ try:
247
+ status = os.fstat(descriptor)
248
+ if (
249
+ not stat.S_ISREG(status.st_mode)
250
+ or status.st_nlink != 1
251
+ or status.st_size != 0
252
+ or status.st_uid != os.geteuid()
253
+ or stat.S_IMODE(status.st_mode) & 0o077
254
+ ):
255
+ raise LibrusError(ErrorKind.STORAGE)
256
+ try:
257
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
258
+ except BlockingIOError:
259
+ raise LibrusError(ErrorKind.LIMIT) from None
260
+ held.append(descriptor)
261
+ descriptor = -1
262
+ finally:
263
+ if descriptor >= 0:
264
+ os.close(descriptor)
265
+
266
+ @asynccontextmanager
267
+ async def _context(
268
+ self, context: AccountContext, *, register: bool = True
269
+ ) -> AsyncIterator[None]:
270
+ identifier = _context_id(context)
271
+ held: list[int] = []
272
+ try:
273
+ if register:
274
+ await self._io(lambda: self._register(identifier))
275
+ await self._io(lambda: self._lock_context(identifier, held))
276
+ yield
277
+ finally:
278
+ # A resource-release syscall, not a new queued storage operation.
279
+ # The acquisition worker is joined before this point, even on cancel.
280
+ if held:
281
+ if self._windows_directory is not None:
282
+ from librus_python_api._windows_filesystem import release_lock
283
+
284
+ try:
285
+ release_lock(held.pop())
286
+ except OSError:
287
+ raise LibrusError(ErrorKind.STORAGE) from None
288
+ else:
289
+ os.close(held.pop())
290
+
291
+ async def _transaction[T](
292
+ self,
293
+ context: AccountContext,
294
+ operation: Callable[[], Coroutine[Any, Any, T]],
295
+ *,
296
+ register: bool = True,
297
+ ) -> T:
298
+ async def run() -> T:
299
+ async with self._context(context, register=register):
300
+ return await operation()
301
+
302
+ return await self._owned(run)
303
+
304
+ def _state(
305
+ self, connection: sqlite3.Connection, context: str
306
+ ) -> tuple[NotificationState, str | None]:
307
+ row = connection.execute(
308
+ "SELECT payload,last_receipt FROM notification_state WHERE context=?",
309
+ (self._context_key(context),),
310
+ ).fetchone()
311
+ if row is None:
312
+ return empty_state(), None
313
+ if type(row[0]) is not bytes or (
314
+ row[1] is not None
315
+ and (type(row[1]) is not str or HEX.fullmatch(row[1]) is None)
316
+ ):
317
+ raise LibrusError(ErrorKind.PARSE)
318
+ return restore_state(
319
+ row[0], self.limits.seen_ids_per_category, self.limits.state_bytes
320
+ ), row[1]
321
+
322
+ async def state(self, *, context: AccountContext) -> NotificationState:
323
+ async def read() -> NotificationState:
324
+ return await self._io(lambda: self._read_state(context.identifier))
325
+
326
+ return await self._transaction(context, read)
327
+
328
+ async def recovery_status(
329
+ self, *, context: AccountContext
330
+ ) -> NotificationRecoveryStatus:
331
+ """Compact, offline snapshot; never resolves or advances pending work."""
332
+ _context_id(context)
333
+
334
+ async def read() -> NotificationRecoveryStatus:
335
+ return await self._io(lambda: self._recovery(context)[0])
336
+
337
+ # The SQLite transaction supplies a consistent snapshot. A read does not
338
+ # need workflow exclusion or a new per-context lock file/registration.
339
+ return await self._owned(read)
340
+
341
+ async def pending_batch(
342
+ self, *, context: AccountContext
343
+ ) -> NotificationBatch | None:
344
+ """Return the original staged batch offline, without a remembered selection.
345
+
346
+ Raw-only and uncertain work are reported by recovery_status, not parsed,
347
+ staged or cleared here. Explicit acknowledgement remains required.
348
+ """
349
+ _context_id(context)
350
+
351
+ async def read() -> NotificationBatch | None:
352
+ return await self._io(lambda: self._recovery(context)[1])
353
+
354
+ return await self._owned(read)
355
+
356
+ def _recovery(
357
+ self, context: AccountContext
358
+ ) -> tuple[NotificationRecoveryStatus, NotificationBatch | None]:
359
+ with self._connection() as connection:
360
+ self._validate_contents(connection)
361
+ state, last = self._state(connection, context.identifier)
362
+ delivery = self._delivery(connection, context)
363
+ raw = self._raw(connection, context)
364
+ uncertain = (
365
+ connection.execute(
366
+ "SELECT 1 FROM notification_reservations WHERE context=?",
367
+ (self._context_key(context.identifier),),
368
+ ).fetchone()
369
+ is not None
370
+ )
371
+ if uncertain and raw is not None:
372
+ raise LibrusError(ErrorKind.PARSE)
373
+ batch = delivery[0] if delivery is not None else None
374
+ pending = (
375
+ NotificationPendingDelivery(
376
+ batch.receipt,
377
+ batch.categories,
378
+ batch.messages_backend,
379
+ batch.first_run,
380
+ len(batch.items),
381
+ batch.has_more_schedule,
382
+ )
383
+ if batch is not None
384
+ else None
385
+ )
386
+ raw_status = (
387
+ NotificationRawCheckpointStatus(
388
+ raw[0], raw[2], raw[3], len(raw[1].wire.body)
389
+ )
390
+ if raw is not None
391
+ else None
392
+ )
393
+ return NotificationRecoveryStatus(
394
+ context, state.initialized, last, pending, raw_status, uncertain
395
+ ), batch
396
+
397
+ def _read_state(self, context: str) -> NotificationState:
398
+ with self._connection() as connection:
399
+ self._validate_contents(connection)
400
+ return self._state(connection, context)[0]
401
+
402
+ async def prune_seen(
403
+ self,
404
+ *,
405
+ context: AccountContext,
406
+ category: NotificationCategory,
407
+ identifiers: tuple[str, ...],
408
+ ) -> int:
409
+ """Explicit retention; forgotten IDs may be notified again.
410
+
411
+ Staged delivery and uncertain reservations must be resolved first. Raw
412
+ checkpoints/progress are retained unchanged, including at seen saturation.
413
+ No age-based expiry, background pruning or first-run reset is performed.
414
+ """
415
+ if (
416
+ not isinstance(category, NotificationCategory)
417
+ or type(identifiers) is not tuple
418
+ or not 1 <= len(identifiers) <= self.limits.seen_ids_per_category
419
+ or any(
420
+ type(value) is not str or HEX.fullmatch(value) is None
421
+ for value in identifiers
422
+ )
423
+ or len(set(identifiers)) != len(identifiers)
424
+ ):
425
+ raise LibrusError(ErrorKind.INVALID_INPUT)
426
+
427
+ async def prune() -> int:
428
+ return await self._io(
429
+ lambda: self._prune_seen(context, category, identifiers)
430
+ )
431
+
432
+ return await self._transaction(context, prune)
433
+
434
+ def _prune_seen(
435
+ self,
436
+ context: AccountContext,
437
+ category: NotificationCategory,
438
+ identifiers: tuple[str, ...],
439
+ ) -> int:
440
+ with self._connection() as connection:
441
+ self._validate_contents(connection)
442
+ key = self._context_key(context.identifier)
443
+ for table in (
444
+ "notification_deliveries",
445
+ "notification_reservations",
446
+ ):
447
+ if connection.execute(
448
+ f"SELECT 1 FROM {table} WHERE context=?", (key,)
449
+ ).fetchone():
450
+ raise LibrusError(ErrorKind.INVALID_INPUT)
451
+ state, last = self._state(connection, context.identifier)
452
+ removed = set(identifiers)
453
+ current = next(entry for entry in state.seen if entry.category is category)
454
+ if not removed.issubset(current.identifiers):
455
+ raise LibrusError(ErrorKind.INVALID_INPUT)
456
+ raw = self._raw(connection, context)
457
+ if category is NotificationCategory.AGENDA and raw is not None and raw[2]:
458
+ response = raw[1]
459
+ events = parse_schedule_events(
460
+ decode_payload(response.wire.body, response.wire, WIRE_BYTES)
461
+ )
462
+ protected = {
463
+ canonical_notification_id(NotificationCategory.AGENDA, value)
464
+ for value in events[: raw[2]]
465
+ }
466
+ if removed & protected:
467
+ # Prefix membership proves acknowledged cursor progress during
468
+ # archive import. Retain it until the raw receipt is drained.
469
+ raise LibrusError(ErrorKind.INVALID_INPUT)
470
+ after = replace(
471
+ state,
472
+ seen=tuple(
473
+ replace(
474
+ entry,
475
+ identifiers=tuple(
476
+ value for value in entry.identifiers if value not in removed
477
+ ),
478
+ )
479
+ if entry.category is category
480
+ else entry
481
+ for entry in state.seen
482
+ ),
483
+ )
484
+ connection.execute(
485
+ "UPDATE notification_state SET payload=? WHERE context=?",
486
+ (
487
+ state_bytes(
488
+ after,
489
+ self.limits.seen_ids_per_category,
490
+ self.limits.state_bytes,
491
+ ),
492
+ key,
493
+ ),
494
+ )
495
+ assert self._state(connection, context.identifier) == (after, last)
496
+ return len(identifiers)
497
+
498
+ def _raw(
499
+ self, connection: sqlite3.Connection, context: AccountContext
500
+ ) -> tuple[str, ScheduleEventResponse, int, int | None] | None:
501
+ row = connection.execute(
502
+ "SELECT identifier,metadata,body,cursor,total FROM notification_raw "
503
+ "WHERE context=?",
504
+ (self._context_key(context.identifier),),
505
+ ).fetchone()
506
+ if row is None:
507
+ return None
508
+ identifier, metadata, body, cursor, total = row
509
+ if (
510
+ type(metadata) is not bytes
511
+ or type(body) is not bytes
512
+ or type(cursor) is not int
513
+ or not 0 <= cursor <= 1024
514
+ or (
515
+ total is not None
516
+ and (type(total) is not int or not cursor <= total <= 1024)
517
+ )
518
+ or identifier
519
+ != _raw_id(self._context_key(context.identifier), metadata, body)
520
+ ):
521
+ raise LibrusError(ErrorKind.PARSE)
522
+ return (
523
+ identifier,
524
+ restore_envelope(metadata, body, context.alias),
525
+ cursor,
526
+ total,
527
+ )
528
+
529
+ def _pending(
530
+ self, context: AccountContext
531
+ ) -> tuple[
532
+ NotificationBatch | None,
533
+ tuple[str, ScheduleEventResponse, int, int | None] | None,
534
+ bool,
535
+ ]:
536
+ with self._connection() as connection:
537
+ self._validate_contents(connection)
538
+ delivery = self._delivery(connection, context)
539
+ raw = self._raw(connection, context)
540
+ reservation = (
541
+ connection.execute(
542
+ "SELECT 1 FROM notification_reservations WHERE context=?",
543
+ (self._context_key(context.identifier),),
544
+ ).fetchone()
545
+ is not None
546
+ )
547
+ if reservation and raw is not None:
548
+ raise LibrusError(ErrorKind.PARSE)
549
+ return delivery[0] if delivery else None, raw, reservation
550
+
551
+ def _reserve(self, context: str, body_bytes: int) -> None:
552
+ context = self._context_key(context)
553
+ if type(body_bytes) is not int or not 1 <= body_bytes <= WIRE_BYTES:
554
+ raise LibrusError(ErrorKind.LIMIT)
555
+ with self._connection() as connection:
556
+ self._validate_contents(connection)
557
+ if (
558
+ connection.execute(
559
+ "SELECT 1 FROM notification_raw WHERE context=?", (context,)
560
+ ).fetchone()
561
+ or connection.execute(
562
+ "SELECT 1 FROM notification_reservations WHERE context=?",
563
+ (context,),
564
+ ).fetchone()
565
+ ):
566
+ raise LibrusError(ErrorKind.CHECKPOINT)
567
+ connection.execute(
568
+ "INSERT INTO notification_reservations VALUES (?,?)",
569
+ (context, body_bytes + META_BYTES),
570
+ )
571
+ self._validate_contents(connection)
572
+
573
+ def _checkpoint(
574
+ self, context: AccountContext, response: ScheduleEventResponse
575
+ ) -> None:
576
+ metadata, body = encode_envelope(response, context.alias)
577
+ key = self._context_key(context.identifier)
578
+ with self._connection() as connection:
579
+ self._validate_contents(connection)
580
+ row = connection.execute(
581
+ "SELECT bytes FROM notification_reservations WHERE context=?",
582
+ (key,),
583
+ ).fetchone()
584
+ if row is None or len(metadata) + len(body) > row[0]:
585
+ raise LibrusError(ErrorKind.CHECKPOINT)
586
+ identifier = _raw_id(key, metadata, body)
587
+ connection.execute(
588
+ "INSERT INTO notification_raw VALUES (?,?,?,?,0,NULL)",
589
+ (key, identifier, metadata, body),
590
+ )
591
+ connection.execute(
592
+ "DELETE FROM notification_reservations WHERE context=?",
593
+ (key,),
594
+ )
595
+ self._validate_contents(connection)
596
+
597
+ def _stage(
598
+ self,
599
+ batch: NotificationBatch,
600
+ raw_identifier: str | None,
601
+ cursor_after: int | None,
602
+ total: int | None,
603
+ ) -> None:
604
+ stored_context = replace(
605
+ batch.context, identifier=self._context_key(batch.context.identifier)
606
+ )
607
+ payload = encode_batch(
608
+ replace(batch, context=stored_context), self.limits.batch_bytes
609
+ )
610
+ if len(batch.items) > self.limits.batch_items:
611
+ raise LibrusError(ErrorKind.LIMIT)
612
+ with self._connection() as connection:
613
+ self._validate_contents(connection)
614
+ state, _ = self._state(connection, batch.context.identifier)
615
+ after = state_bytes(
616
+ _seen_after(state, batch.items, self.limits.seen_ids_per_category),
617
+ self.limits.seen_ids_per_category,
618
+ self.limits.state_bytes,
619
+ )
620
+ if raw_identifier is not None:
621
+ raw = self._raw(connection, batch.context)
622
+ if (
623
+ raw is None
624
+ or raw[0] != raw_identifier
625
+ or cursor_after is None
626
+ or total is None
627
+ or not raw[2] <= cursor_after <= total <= 1024
628
+ ):
629
+ raise LibrusError(ErrorKind.PARSE)
630
+ connection.execute(
631
+ "UPDATE notification_raw SET total=? WHERE context=?",
632
+ (total, stored_context.identifier),
633
+ )
634
+ connection.execute(
635
+ "INSERT INTO notification_deliveries VALUES (?,?,?,?,?,?)",
636
+ (
637
+ stored_context.identifier,
638
+ batch.receipt,
639
+ payload,
640
+ after,
641
+ raw_identifier,
642
+ cursor_after,
643
+ ),
644
+ )
645
+ self._validate_contents(connection)
646
+ self._delivery(connection, batch.context)
647
+
648
+ def _delivery(
649
+ self, connection: sqlite3.Connection, context: AccountContext
650
+ ) -> tuple[NotificationBatch, NotificationState, str | None, int | None] | None:
651
+ row = connection.execute(
652
+ "SELECT receipt,payload,state_after,raw_identifier,cursor_after "
653
+ "FROM notification_deliveries WHERE context=?",
654
+ (self._context_key(context.identifier),),
655
+ ).fetchone()
656
+ if row is None:
657
+ return None
658
+ if type(row[1]) is not bytes or type(row[2]) is not bytes:
659
+ raise LibrusError(ErrorKind.PARSE)
660
+ batch = restore_batch(row[1], self.limits.batch_bytes)
661
+ if batch.context != replace(
662
+ context, identifier=self._context_key(context.identifier)
663
+ ):
664
+ raise LibrusError(ErrorKind.PARSE)
665
+ batch = replace(batch, context=context)
666
+ after = restore_state(
667
+ row[2], self.limits.seen_ids_per_category, self.limits.state_bytes
668
+ )
669
+ before, _ = self._state(connection, context.identifier)
670
+ seen = {entry.category: set(entry.identifiers) for entry in before.seen}
671
+ keys = [(item.category, item.identifier) for item in batch.items]
672
+ if (
673
+ batch.context != context
674
+ or batch.receipt != row[0]
675
+ or batch.first_run == before.initialized
676
+ or len(batch.items) > self.limits.batch_items
677
+ or len(set(keys)) != len(keys)
678
+ or any(identifier in seen[category] for category, identifier in keys)
679
+ or after
680
+ != _seen_after(before, batch.items, self.limits.seen_ids_per_category)
681
+ ):
682
+ raise LibrusError(ErrorKind.PARSE)
683
+ raw = self._raw(connection, context)
684
+ historical = is_historical_batch(batch)
685
+ if historical and (
686
+ raw is not None
687
+ or row[3] is not None
688
+ or connection.execute(
689
+ "SELECT 1 FROM notification_reservations WHERE context=?",
690
+ (self._context_key(context.identifier),),
691
+ ).fetchone()
692
+ is not None
693
+ ):
694
+ raise LibrusError(ErrorKind.PARSE)
695
+ if row[3] is None:
696
+ if (
697
+ row[4] is not None
698
+ or batch.has_more_schedule
699
+ or (NotificationCategory.AGENDA in batch.categories and not historical)
700
+ ):
701
+ raise LibrusError(ErrorKind.PARSE)
702
+ elif (
703
+ raw is None
704
+ or raw[0] != row[3]
705
+ or type(row[4]) is not int
706
+ or raw[3] is None
707
+ or not raw[2] <= row[4] <= raw[3]
708
+ or batch.has_more_schedule != (row[4] < raw[3])
709
+ ):
710
+ raise LibrusError(ErrorKind.PARSE)
711
+ return batch, after, row[3], row[4]
712
+
713
+ async def acknowledge(self, receipt: str, *, context: AccountContext) -> None:
714
+ if type(receipt) is not str or HEX.fullmatch(receipt) is None:
715
+ raise LibrusError(ErrorKind.INVALID_INPUT)
716
+
717
+ async def commit() -> None:
718
+ await self._io(lambda: self._ack(context, receipt), finishing=True)
719
+
720
+ await self._transaction(context, commit)
721
+
722
+ def _ack(self, context: AccountContext, receipt: str) -> None:
723
+ with self._connection() as connection:
724
+ self._validate_contents(connection)
725
+ delivery = self._delivery(connection, context)
726
+ if self._state(connection, context.identifier)[1] == receipt:
727
+ # A late acknowledgement retry must leave a newer batch intact.
728
+ return
729
+ if delivery is None:
730
+ raise LibrusError(ErrorKind.INVALID_INPUT)
731
+ batch, after, raw_identifier, cursor_after = delivery
732
+ if batch.receipt != receipt:
733
+ raise LibrusError(ErrorKind.INVALID_INPUT)
734
+ payload = state_bytes(
735
+ after, self.limits.seen_ids_per_category, self.limits.state_bytes
736
+ )
737
+ connection.execute(
738
+ "INSERT OR REPLACE INTO notification_state VALUES (?,?,?)",
739
+ (self._context_key(context.identifier), payload, receipt),
740
+ )
741
+ if raw_identifier is not None:
742
+ raw = self._raw(connection, context)
743
+ assert raw is not None
744
+ if cursor_after == raw[3]:
745
+ connection.execute(
746
+ "DELETE FROM notification_raw WHERE context=?",
747
+ (self._context_key(context.identifier),),
748
+ )
749
+ else:
750
+ connection.execute(
751
+ "UPDATE notification_raw SET cursor=? WHERE context=?",
752
+ (cursor_after, self._context_key(context.identifier)),
753
+ )
754
+ connection.execute(
755
+ "DELETE FROM notification_deliveries WHERE context=?",
756
+ (self._context_key(context.identifier),),
757
+ )
758
+ self._validate_contents(connection)
759
+
760
+ async def resolve_uncertain_consume(
761
+ self, *, context: AccountContext, accept_possible_loss: bool = False
762
+ ) -> None:
763
+ if type(accept_possible_loss) is not bool or not accept_possible_loss:
764
+ raise LibrusError(ErrorKind.INVALID_INPUT)
765
+
766
+ async def resolve() -> None:
767
+ await self._io(lambda: self._resolve(context))
768
+
769
+ await self._transaction(context, resolve)
770
+
771
+ def _resolve(self, context: AccountContext) -> None:
772
+ with self._connection() as connection:
773
+ self._validate_contents(connection)
774
+ if (
775
+ self._raw(connection, context) is not None
776
+ or self._delivery(connection, context) is not None
777
+ ):
778
+ raise LibrusError(ErrorKind.INVALID_INPUT)
779
+ if (
780
+ connection.execute(
781
+ "DELETE FROM notification_reservations WHERE context=?",
782
+ (self._context_key(context.identifier),),
783
+ ).rowcount
784
+ != 1
785
+ ):
786
+ raise LibrusError(ErrorKind.INVALID_INPUT)
787
+
788
+ async def export_archive(self, *, context: AccountContext) -> NotificationArchive:
789
+ async def export() -> NotificationArchive:
790
+ payload = await self._io(lambda: self._export(context))
791
+ return NotificationArchive(3, context, payload)
792
+
793
+ return await self._transaction(context, export)
794
+
795
+ async def bootstrap(
796
+ self, plan: NotificationBootstrap, *, context: AccountContext
797
+ ) -> NotificationBootstrapResult:
798
+ """Atomically import an externally mapped baseline and consumed history.
799
+
800
+ Unmapped IDs return imported=False without writes. A populated target
801
+ context rejects; callers retain their original files for rollback.
802
+ """
803
+ _context_id(context)
804
+ if type(plan) is not NotificationBootstrap or plan.context != context:
805
+ raise LibrusError(ErrorKind.INVALID_INPUT)
806
+ state, items, unmapped = await self._io(
807
+ lambda: prepare_bootstrap(
808
+ plan,
809
+ self.limits.seen_ids_per_category,
810
+ self.limits.batch_items,
811
+ self.limits.batch_bytes,
812
+ )
813
+ )
814
+ if unmapped:
815
+ return NotificationBootstrapResult(False, unmapped, None)
816
+
817
+ async def restore() -> NotificationBootstrapResult:
818
+ pending = await self._io(lambda: self._bootstrap(context, state, items))
819
+ return NotificationBootstrapResult(True, (), pending)
820
+
821
+ # Registration belongs to the import commit, not the lock acquisition.
822
+ # A failed import must not consume a context slot or create seen state.
823
+ return await self._transaction(context, restore, register=False)
824
+
825
+ def _bootstrap(
826
+ self,
827
+ context: AccountContext,
828
+ state: NotificationState,
829
+ items: tuple[NotificationItem, ...],
830
+ ) -> NotificationBatch | None:
831
+ key = self._context_key(context.identifier)
832
+ batch = (
833
+ NotificationBatch(
834
+ secrets.token_hex(32),
835
+ context,
836
+ False,
837
+ (NotificationCategory.AGENDA,),
838
+ items,
839
+ False,
840
+ )
841
+ if items
842
+ else None
843
+ )
844
+ with self._connection() as connection:
845
+ self._validate_contents(connection)
846
+ self._require_empty(connection, key)
847
+ self._register_context(connection, key)
848
+ connection.execute(
849
+ "INSERT INTO notification_state VALUES (?,?,NULL)",
850
+ (
851
+ key,
852
+ state_bytes(
853
+ state,
854
+ self.limits.seen_ids_per_category,
855
+ self.limits.state_bytes,
856
+ ),
857
+ ),
858
+ )
859
+ if batch is not None:
860
+ payload = encode_batch(
861
+ replace(batch, context=replace(context, identifier=key)),
862
+ self.limits.batch_bytes,
863
+ )
864
+ after = state_bytes(
865
+ _seen_after(state, items, self.limits.seen_ids_per_category),
866
+ self.limits.seen_ids_per_category,
867
+ self.limits.state_bytes,
868
+ )
869
+ connection.execute(
870
+ "INSERT INTO notification_deliveries VALUES (?,?,?,?,NULL,NULL)",
871
+ (key, batch.receipt, payload, after),
872
+ )
873
+ self._delivery(connection, context)
874
+ self._validate_contents(connection)
875
+ return batch
876
+
877
+ @staticmethod
878
+ def _require_empty(connection: sqlite3.Connection, key: str) -> None:
879
+ for table in (
880
+ "notification_state",
881
+ "notification_raw",
882
+ "notification_deliveries",
883
+ "notification_reservations",
884
+ ):
885
+ if connection.execute(
886
+ f"SELECT 1 FROM {table} WHERE context=?", (key,)
887
+ ).fetchone():
888
+ raise LibrusError(ErrorKind.INVALID_INPUT)
889
+
890
+ def _export(self, context: AccountContext) -> bytes:
891
+ with self._connection() as connection:
892
+ self._validate_contents(connection)
893
+ state, last = self._state(connection, context.identifier)
894
+ raw = self._raw(connection, context)
895
+ delivery = self._delivery(connection, context)
896
+ reservation = connection.execute(
897
+ "SELECT bytes FROM notification_reservations WHERE context=?",
898
+ (self._context_key(context.identifier),),
899
+ ).fetchone()
900
+ raw_record = None
901
+ if raw:
902
+ metadata, body = encode_envelope(raw[1], context.alias)
903
+ raw_record = {
904
+ "identifier": raw[0],
905
+ "metadata": load(metadata, META_BYTES),
906
+ "body": base64.b64encode(body).decode("ascii"),
907
+ "cursor": raw[2],
908
+ "total": raw[3],
909
+ }
910
+ delivery_record = None
911
+ if delivery:
912
+ delivery_record = {
913
+ "batch": load(
914
+ encode_batch(
915
+ replace(
916
+ delivery[0],
917
+ context=replace(
918
+ context,
919
+ identifier=self._context_key(context.identifier),
920
+ ),
921
+ ),
922
+ self.limits.batch_bytes,
923
+ ),
924
+ self.limits.batch_bytes,
925
+ ),
926
+ "state_after": asdict(delivery[1]),
927
+ "raw_identifier": delivery[2],
928
+ "cursor_after": delivery[3],
929
+ }
930
+ return dump(
931
+ {
932
+ "state": asdict(state),
933
+ "version": 3,
934
+ "context": asdict(
935
+ replace(
936
+ context, identifier=self._context_key(context.identifier)
937
+ )
938
+ ),
939
+ "context_salt": self._context_salt.hex()
940
+ if self._context_salt
941
+ else None,
942
+ "last_receipt": last,
943
+ "raw": raw_record,
944
+ "delivery": delivery_record,
945
+ "reservation": reservation[0] if reservation else None,
946
+ },
947
+ ARCHIVE_BYTES,
948
+ )
949
+
950
+ async def import_archive(self, archive: NotificationArchive) -> None:
951
+ if (
952
+ not isinstance(archive, NotificationArchive)
953
+ or type(archive.version) is not int
954
+ or archive.version != 3
955
+ or type(archive.payload) is not bytes
956
+ ):
957
+ raise LibrusError(ErrorKind.INVALID_INPUT)
958
+
959
+ async def restore() -> None:
960
+ await self._io(lambda: self._import(archive))
961
+
962
+ await self._transaction(archive.context, restore)
963
+
964
+ def _rebind_archive(self, record: dict[str, Any], context: AccountContext) -> None:
965
+ """Validate source namespace before rebinding to this empty target store."""
966
+ salt = record["context_salt"]
967
+ if type(salt) is not str or HEX.fullmatch(salt) is None:
968
+ raise LibrusError(ErrorKind.PARSE)
969
+ source_key = hmac.new(
970
+ bytes.fromhex(salt), bytes.fromhex(context.identifier), hashlib.sha256
971
+ ).hexdigest()
972
+ source_context = replace(context, identifier=source_key)
973
+ if record["context"] != asdict(source_context):
974
+ raise LibrusError(ErrorKind.PARSE)
975
+ target_context = replace(
976
+ context, identifier=self._context_key(context.identifier)
977
+ )
978
+ record["context"] = asdict(target_context)
979
+ raw, delivery = record["raw"], record["delivery"]
980
+ source_raw: str | None = None
981
+ target_raw: str | None = None
982
+ if raw is not None:
983
+ if (
984
+ type(raw) is not dict
985
+ or set(raw) != {"identifier", "metadata", "body", "cursor", "total"}
986
+ or type(raw["body"]) is not str
987
+ ):
988
+ raise LibrusError(ErrorKind.PARSE)
989
+ try:
990
+ body = base64.b64decode(raw["body"], validate=True)
991
+ except (ValueError, UnicodeError):
992
+ raise LibrusError(ErrorKind.PARSE) from None
993
+ metadata = dump(raw["metadata"], META_BYTES)
994
+ source_raw = _raw_id(source_key, metadata, body)
995
+ if raw["identifier"] != source_raw:
996
+ raise LibrusError(ErrorKind.PARSE)
997
+ target_raw = _raw_id(target_context.identifier, metadata, body)
998
+ raw["identifier"] = target_raw
999
+ if delivery is not None:
1000
+ if type(delivery) is not dict or set(delivery) != {
1001
+ "batch",
1002
+ "state_after",
1003
+ "raw_identifier",
1004
+ "cursor_after",
1005
+ }:
1006
+ raise LibrusError(ErrorKind.PARSE)
1007
+ batch = restore_batch(
1008
+ dump(delivery["batch"], self.limits.batch_bytes),
1009
+ self.limits.batch_bytes,
1010
+ )
1011
+ if (
1012
+ batch.context != source_context
1013
+ or delivery["raw_identifier"] != source_raw
1014
+ ):
1015
+ # Ordinary-only batches may coexist with pending raw recovery.
1016
+ if (
1017
+ batch.context != source_context
1018
+ or delivery["raw_identifier"] is not None
1019
+ ):
1020
+ raise LibrusError(ErrorKind.PARSE)
1021
+ if delivery["raw_identifier"] is not None:
1022
+ delivery["raw_identifier"] = target_raw
1023
+ delivery["batch"] = load(
1024
+ encode_batch(
1025
+ replace(batch, context=target_context), self.limits.batch_bytes
1026
+ ),
1027
+ self.limits.batch_bytes,
1028
+ )
1029
+
1030
+ def _import(self, archive: NotificationArchive) -> None:
1031
+ record = load(archive.payload, ARCHIVE_BYTES)
1032
+ if type(record) is not dict or set(record) != {
1033
+ "state",
1034
+ "last_receipt",
1035
+ "raw",
1036
+ "delivery",
1037
+ "reservation",
1038
+ "version",
1039
+ "context",
1040
+ "context_salt",
1041
+ }:
1042
+ raise LibrusError(ErrorKind.PARSE)
1043
+ context = archive.context
1044
+ if type(record["version"]) is not int or record["version"] != 3:
1045
+ raise LibrusError(ErrorKind.PARSE)
1046
+ self._rebind_archive(record, context)
1047
+ with self._connection() as connection:
1048
+ self._validate_contents(connection)
1049
+ # Import is empty-target only, never a silent history reset.
1050
+ self._require_empty(connection, self._context_key(context.identifier))
1051
+ state = restore_state(
1052
+ dump(record["state"], self.limits.state_bytes),
1053
+ self.limits.seen_ids_per_category,
1054
+ self.limits.state_bytes,
1055
+ )
1056
+ last = record["last_receipt"]
1057
+ if last is not None and (
1058
+ type(last) is not str
1059
+ or HEX.fullmatch(last) is None
1060
+ or not state.initialized
1061
+ ):
1062
+ raise LibrusError(ErrorKind.PARSE)
1063
+ if state.initialized or last is not None:
1064
+ connection.execute(
1065
+ "INSERT INTO notification_state VALUES (?,?,?)",
1066
+ (
1067
+ self._context_key(context.identifier),
1068
+ state_bytes(
1069
+ state,
1070
+ self.limits.seen_ids_per_category,
1071
+ self.limits.state_bytes,
1072
+ ),
1073
+ last,
1074
+ ),
1075
+ )
1076
+ raw = record["raw"]
1077
+ if raw is not None:
1078
+ self._import_raw(connection, context, raw)
1079
+ reservation = record["reservation"]
1080
+ if reservation is not None:
1081
+ if (
1082
+ raw is not None
1083
+ or type(reservation) is not int
1084
+ or not 1 <= reservation <= WIRE_BYTES + META_BYTES
1085
+ ):
1086
+ raise LibrusError(ErrorKind.PARSE)
1087
+ connection.execute(
1088
+ "INSERT INTO notification_reservations VALUES (?,?)",
1089
+ (self._context_key(context.identifier), reservation),
1090
+ )
1091
+ delivery = record["delivery"]
1092
+ if delivery is not None:
1093
+ if type(delivery) is not dict or set(delivery) != {
1094
+ "batch",
1095
+ "state_after",
1096
+ "raw_identifier",
1097
+ "cursor_after",
1098
+ }:
1099
+ raise LibrusError(ErrorKind.PARSE)
1100
+ batch = restore_batch(
1101
+ dump(delivery["batch"], self.limits.batch_bytes),
1102
+ self.limits.batch_bytes,
1103
+ )
1104
+ if batch.context != replace(
1105
+ context, identifier=self._context_key(context.identifier)
1106
+ ):
1107
+ raise LibrusError(ErrorKind.PARSE)
1108
+ public_batch = replace(batch, context=context)
1109
+ after = restore_state(
1110
+ dump(delivery["state_after"], self.limits.state_bytes),
1111
+ self.limits.seen_ids_per_category,
1112
+ self.limits.state_bytes,
1113
+ )
1114
+ connection.execute(
1115
+ "INSERT INTO notification_deliveries VALUES (?,?,?,?,?,?)",
1116
+ (
1117
+ self._context_key(context.identifier),
1118
+ batch.receipt,
1119
+ encode_batch(batch, self.limits.batch_bytes),
1120
+ state_bytes(
1121
+ after,
1122
+ self.limits.seen_ids_per_category,
1123
+ self.limits.state_bytes,
1124
+ ),
1125
+ delivery["raw_identifier"],
1126
+ delivery["cursor_after"],
1127
+ ),
1128
+ )
1129
+ self._delivery(connection, context)
1130
+ self._validate_import_delivery(connection, context, public_batch)
1131
+ self._validate_contents(connection)
1132
+
1133
+ def _validate_import_delivery(
1134
+ self,
1135
+ connection: sqlite3.Connection,
1136
+ context: AccountContext,
1137
+ batch: NotificationBatch,
1138
+ ) -> None:
1139
+ """Prove imported cursor progress does not discard an undelivered event."""
1140
+ raw = self._raw(connection, context)
1141
+ if is_historical_batch(batch):
1142
+ if raw is not None:
1143
+ raise LibrusError(ErrorKind.PARSE)
1144
+ return
1145
+ if NotificationCategory.AGENDA not in batch.categories:
1146
+ return
1147
+ if raw is None:
1148
+ raise LibrusError(ErrorKind.PARSE)
1149
+ delivery = self._delivery(connection, context)
1150
+ assert delivery is not None and delivery[3] is not None
1151
+ response = raw[1]
1152
+ events = parse_schedule_events(
1153
+ decode_payload(response.wire.body, response.wire, WIRE_BYTES)
1154
+ )
1155
+ before, _ = self._state(connection, context.identifier)
1156
+ seen = {
1157
+ identifier
1158
+ for entry in before.seen
1159
+ if entry.category is NotificationCategory.AGENDA
1160
+ for identifier in entry.identifiers
1161
+ }
1162
+ expected = []
1163
+ for value in events[raw[2] : delivery[3]]:
1164
+ identifier = canonical_notification_id(NotificationCategory.AGENDA, value)
1165
+ if identifier not in seen:
1166
+ expected.append(
1167
+ NotificationItem(
1168
+ NotificationCategory.AGENDA,
1169
+ identifier,
1170
+ value,
1171
+ response.identity,
1172
+ response.observation,
1173
+ )
1174
+ )
1175
+ seen.add(identifier)
1176
+ actual = [
1177
+ item for item in batch.items if item.category is NotificationCategory.AGENDA
1178
+ ]
1179
+ if actual != expected:
1180
+ raise LibrusError(ErrorKind.PARSE)
1181
+
1182
+ def _import_raw(
1183
+ self, connection: sqlite3.Connection, context: AccountContext, raw: Any
1184
+ ) -> None:
1185
+ if (
1186
+ type(raw) is not dict
1187
+ or set(raw) != {"identifier", "metadata", "body", "cursor", "total"}
1188
+ or type(raw["body"]) is not str
1189
+ ):
1190
+ raise LibrusError(ErrorKind.PARSE)
1191
+ failed = False
1192
+ body = b""
1193
+ try:
1194
+ body = base64.b64decode(raw["body"], validate=True)
1195
+ except (ValueError, UnicodeError):
1196
+ failed = True
1197
+ if (
1198
+ failed
1199
+ or type(raw["cursor"]) is not int
1200
+ or (raw["total"] is not None and type(raw["total"]) is not int)
1201
+ ):
1202
+ raise LibrusError(ErrorKind.PARSE)
1203
+ metadata = dump(raw["metadata"], META_BYTES)
1204
+ response = restore_envelope(metadata, body, context.alias)
1205
+ if raw["total"] is None and raw["cursor"] != 0:
1206
+ raise LibrusError(ErrorKind.PARSE)
1207
+ if raw["total"] is not None:
1208
+ events = parse_schedule_events(
1209
+ decode_payload(body, response.wire, WIRE_BYTES)
1210
+ )
1211
+ if raw["total"] != len(events):
1212
+ raise LibrusError(ErrorKind.PARSE)
1213
+ # Acknowledged progress marks every event before the cursor seen; an
1214
+ # unseen one there would be skipped without ever being delivered.
1215
+ state, _ = self._state(connection, context.identifier)
1216
+ seen = {
1217
+ identifier
1218
+ for entry in state.seen
1219
+ if entry.category is NotificationCategory.AGENDA
1220
+ for identifier in entry.identifiers
1221
+ }
1222
+ if any(
1223
+ canonical_notification_id(NotificationCategory.AGENDA, value)
1224
+ not in seen
1225
+ for value in events[: raw["cursor"]]
1226
+ ):
1227
+ raise LibrusError(ErrorKind.PARSE)
1228
+ if raw["identifier"] != _raw_id(
1229
+ self._context_key(context.identifier), metadata, body
1230
+ ):
1231
+ raise LibrusError(ErrorKind.PARSE)
1232
+ connection.execute(
1233
+ "INSERT INTO notification_raw VALUES (?,?,?,?,?,?)",
1234
+ (
1235
+ self._context_key(context.identifier),
1236
+ raw["identifier"],
1237
+ metadata,
1238
+ body,
1239
+ raw["cursor"],
1240
+ raw["total"],
1241
+ ),
1242
+ )
1243
+ self._raw(connection, context)