pi-durable-python 1.3.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 (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1130 @@
1
+ """Portable SQLite implementation of the storage contract, translated from v1.3.0.
2
+
3
+ One SQL transaction is one Session commit. Table records stay in their record tables as JSON, while
4
+ indexed identities are duplicated into columns so scans, ancestry walks, and document history use
5
+ indexes instead of decoding every row.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from collections.abc import Awaitable, Callable, Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Any, NotRequired, TypedDict, TypeVar, cast, overload
15
+
16
+ from ...context import Context
17
+ from ...delta import apply
18
+ from ...errors import StorageRequestError
19
+ from ...ids import (
20
+ ConversationId,
21
+ DocumentId,
22
+ EntryId,
23
+ Seq,
24
+ SubmissionId,
25
+ TaskId,
26
+ id_from_number,
27
+ seq_from_number,
28
+ )
29
+ from ...json import JsonObject
30
+ from ...types import (
31
+ ConversationQuery,
32
+ ConversationRecord,
33
+ Cursor,
34
+ DocumentAddress,
35
+ DocumentContent,
36
+ DocumentCreate,
37
+ DocumentPoint,
38
+ DocumentQuery,
39
+ DocumentRecord,
40
+ EntryLookup,
41
+ EntryQuery,
42
+ EntryRecord,
43
+ EntryWithHead,
44
+ Page,
45
+ ScanOrder,
46
+ StorageWrite,
47
+ StoredDocument,
48
+ SubmissionQuery,
49
+ SubmissionRecord,
50
+ TaskQuery,
51
+ TaskRecord,
52
+ )
53
+ from ..scan import ScanStart, next_cursor, scan_start
54
+ from .database import SqliteDatabase, SqliteExecutor, SqliteValue
55
+ from .migrations import apply_sqlite_migrations_for_schema
56
+ from .schema import SqliteSchema, create_sqlite_schema
57
+
58
+ T = TypeVar("T")
59
+
60
+ _MAX_SAFE_INTEGER = 9_007_199_254_740_991
61
+
62
+
63
+ class SqliteStorageOptions(TypedDict):
64
+ """Options for opening SQLite storage.
65
+
66
+ ``prefix`` namespaces every durable table and index. Existing data is not migrated when it
67
+ changes. Connection tuning belongs to the host adapter, not the portable core.
68
+ """
69
+
70
+ prefix: NotRequired[str]
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class _ScopeColumns:
75
+ scope_kind: str
76
+ owner_id: int
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class _AddressParts:
81
+ kind: str
82
+ scope_kind: str
83
+ owner_id: int
84
+ family: int
85
+ key_value: str
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class _DocumentAction:
90
+ create: DocumentCreate | None = None
91
+ copy: dict[str, Any] | None = None
92
+ content: DocumentContent | None = None
93
+ retire: bool = False
94
+
95
+
96
+ def _is_safe_int(value: object) -> bool:
97
+ return (
98
+ isinstance(value, int)
99
+ and not isinstance(value, bool)
100
+ and abs(value) <= _MAX_SAFE_INTEGER
101
+ )
102
+
103
+
104
+ def _parse_json(value: str) -> Any:
105
+ return json.loads(value)
106
+
107
+
108
+ def _encode_json(value: object) -> str:
109
+ return json.dumps(value, ensure_ascii=True, separators=(",", ":"), allow_nan=False)
110
+
111
+
112
+ def _encode_indexed_string(value: str) -> str:
113
+ """stdlib ``sqlite3`` cannot bind lone surrogates; JSON encoding keeps identities lossless."""
114
+
115
+ return json.dumps(value)
116
+
117
+
118
+ def _cursor_id(cursor: Cursor | None) -> DocumentId | None:
119
+ if cursor is None:
120
+ return None
121
+ after = cursor.get("after")
122
+ if not _is_safe_int(after):
123
+ raise StorageRequestError("Invalid storage cursor")
124
+ return id_from_number(cast(int, after))
125
+
126
+
127
+ def _page(
128
+ values: Sequence[Any],
129
+ limit: int,
130
+ order: ScanOrder = "ascending",
131
+ ) -> Page[Any, Cursor]:
132
+ items = list(values[:limit])
133
+ if len(values) <= limit:
134
+ return {"items": items}
135
+ return {"items": items, "next": next_cursor(items[-1]["id"], order)}
136
+
137
+
138
+ def _scan_sql(start: ScanStart) -> tuple[str, int, str]:
139
+ if start.order == "ascending":
140
+ return "id > ?", -1 if start.after is None else start.after, "ASC"
141
+ return "id < ?", _MAX_SAFE_INTEGER if start.after is None else start.after, "DESC"
142
+
143
+
144
+ def _scope_columns(scope: Any) -> _ScopeColumns:
145
+ kind = scope["kind"]
146
+ if kind == "session":
147
+ return _ScopeColumns("session", 0)
148
+ if kind == "conversation":
149
+ return _ScopeColumns("conversation", scope["conversationId"])
150
+ return _ScopeColumns("task", scope["taskId"])
151
+
152
+
153
+ def _address_parts(address: Any) -> _AddressParts:
154
+ scope = _scope_columns(address["scope"])
155
+ key = address.get("key")
156
+ return _AddressParts(
157
+ kind=_encode_indexed_string(address["kind"]),
158
+ scope_kind=scope.scope_kind,
159
+ owner_id=scope.owner_id,
160
+ family=0 if key is None else 1,
161
+ key_value=_encode_indexed_string("" if key is None else key),
162
+ )
163
+
164
+
165
+ def _address_key(address: Any) -> str:
166
+ parts = _address_parts(address)
167
+ return json.dumps(
168
+ [parts.kind, parts.scope_kind, parts.owner_id, parts.family, parts.key_value],
169
+ separators=(",", ":"),
170
+ )
171
+
172
+
173
+ def _is_alive_at(record: DocumentRecord, at: DocumentPoint) -> bool:
174
+ if at == "current":
175
+ return "retiredAt" not in record
176
+ return record["createdAt"] <= at and (
177
+ "retiredAt" not in record or at < record["retiredAt"]
178
+ )
179
+
180
+
181
+ def _is_current_only(record: Any) -> bool:
182
+ return record["scope"]["kind"] != "conversation" or record.get("history") == "latest"
183
+
184
+
185
+ def _write_id(write: StorageWrite) -> int | None:
186
+ item = cast(dict[str, Any], write)
187
+ kind = item["type"]
188
+ if kind in ("conversation", "entry", "task", "submission"):
189
+ return cast(int, item["value"]["id"])
190
+ if kind in ("document.create", "document.copy"):
191
+ return cast(int, item["record"]["id"])
192
+ return None
193
+
194
+
195
+ class SqliteStorage:
196
+ """Portable SQLite implementation of the storage contract."""
197
+
198
+ def __init__(
199
+ self,
200
+ database: SqliteDatabase,
201
+ schema: SqliteSchema,
202
+ next_id: int,
203
+ ) -> None:
204
+ self._db = database
205
+ self._schema = schema
206
+ self._next_id = next_id
207
+ self._closed = False
208
+ self._closing: asyncio.Future[None] | None = None
209
+ self._admitted_reads = 0
210
+ self._reads_drained: asyncio.Future[None] | None = None
211
+
212
+ @staticmethod
213
+ async def open(
214
+ database: SqliteDatabase,
215
+ options: SqliteStorageOptions | None = None,
216
+ ) -> SqliteStorage:
217
+ """Initialize storage over an owned SQLite database facade."""
218
+
219
+ prefix = None if options is None else options.get("prefix")
220
+ try:
221
+ schema = create_sqlite_schema(prefix)
222
+ await apply_sqlite_migrations_for_schema(database, schema)
223
+ metadata = await database.get(
224
+ f"SELECT next_id, next_seq FROM {schema.tables['durableMetadata']} "
225
+ "WHERE singleton = 1"
226
+ )
227
+ if metadata is None:
228
+ raise RuntimeError("Durable SQLite metadata is missing")
229
+ return SqliteStorage(database, schema, int(metadata["next_id"]))
230
+ except BaseException:
231
+ try:
232
+ await database.close()
233
+ except Exception:
234
+ pass
235
+ raise
236
+
237
+ # ── Commits ─────────────────────────────────────────────────────────────
238
+
239
+ async def commit(self, writes: Sequence[StorageWrite], context: Context) -> Seq:
240
+ self._assert_open()
241
+ document_actions = self._prepare_document_actions(writes)
242
+ candidate_next_id = self._candidate_next_id(writes)
243
+ metadata_table = self._schema.tables["durableMetadata"]
244
+
245
+ async def run_transaction(transaction: SqliteExecutor) -> Seq:
246
+ metadata = await transaction.get(
247
+ f"SELECT next_id, next_seq FROM {metadata_table} WHERE singleton = 1"
248
+ )
249
+ if metadata is None:
250
+ raise RuntimeError("Durable SQLite metadata is missing")
251
+ committed_seq = seq_from_number(metadata["next_seq"])
252
+ await self._check_global_ids(transaction, writes)
253
+ await self._check_document_actions(transaction, document_actions)
254
+ for write in writes:
255
+ await self._apply_table_write(transaction, write, committed_seq)
256
+ await self._apply_document_actions(transaction, document_actions, committed_seq)
257
+ await transaction.run(
258
+ f"UPDATE {metadata_table} SET next_id = ?, next_seq = ? WHERE singleton = 1",
259
+ str(max(int(metadata["next_id"]), candidate_next_id)),
260
+ committed_seq + 1,
261
+ )
262
+ return committed_seq
263
+
264
+ seq = await self._db.transaction(run_transaction)
265
+ self._next_id = max(self._next_id, candidate_next_id)
266
+ return seq
267
+
268
+ async def mint_id(self) -> int:
269
+ self._assert_open()
270
+ if not _is_safe_int(self._next_id):
271
+ raise RuntimeError("ID space is exhausted")
272
+ value = self._next_id
273
+ self._next_id += 1
274
+ return value
275
+
276
+ # ── Table reads ─────────────────────────────────────────────────────────
277
+
278
+ async def conversation(self, id: ConversationId, context: Context) -> ConversationRecord | None:
279
+ self._assert_open()
280
+ row = await self._db.get(
281
+ f"SELECT record FROM {self._schema.tables['conversations']} WHERE id = ?", id
282
+ )
283
+ return None if row is None else cast(ConversationRecord, _parse_json(row["record"]))
284
+
285
+ async def scan_conversations(
286
+ self,
287
+ query: ConversationQuery,
288
+ limit: int,
289
+ cursor: Cursor | None,
290
+ context: Context,
291
+ ) -> Page[ConversationRecord, Cursor]:
292
+ self._assert_open()
293
+ start = scan_start(query.get("order"), cursor, "ascending")
294
+ clause, param, direction = _scan_sql(start)
295
+ clauses = [clause]
296
+ params: list[SqliteValue] = [param]
297
+ if "ownerConversationId" in query:
298
+ clauses.append("owner_conversation_id = ?")
299
+ params.append(query["ownerConversationId"])
300
+ if "ownerTaskId" in query:
301
+ clauses.append("owner_task_id = ?")
302
+ params.append(query["ownerTaskId"])
303
+ params.append(limit + 1)
304
+ rows = await self._db.all(
305
+ f"SELECT record FROM {self._schema.tables['conversations']} "
306
+ f"WHERE {' AND '.join(clauses)} ORDER BY id {direction} LIMIT ?",
307
+ *params,
308
+ )
309
+ return _page(
310
+ [_parse_json(row["record"]) for row in rows], limit, start.order
311
+ )
312
+
313
+ @overload
314
+ async def entry(self, id: EntryId, context: Context, /) -> EntryLookup | None: ...
315
+
316
+ @overload
317
+ async def entry(
318
+ self,
319
+ conversation_id: ConversationId,
320
+ id: EntryId,
321
+ context: Context,
322
+ /,
323
+ ) -> EntryLookup | None: ...
324
+
325
+ async def entry(
326
+ self,
327
+ id_or_conversation_id: EntryId | ConversationId,
328
+ id_or_context: EntryId | Context | None = None,
329
+ context: Context | None = None,
330
+ ) -> EntryLookup | None:
331
+ return await self._admit_read(
332
+ lambda: self._read_entry(id_or_conversation_id, id_or_context, context)
333
+ )
334
+
335
+ async def _read_entry(
336
+ self,
337
+ id_or_conversation_id: EntryId | ConversationId,
338
+ id_or_context: EntryId | Context | None,
339
+ context: Context | None,
340
+ ) -> EntryLookup | None:
341
+ # Public call shapes are ``entry(id, context)`` and ``entry(conversation_id, id, context)``.
342
+ id: EntryId
343
+ conversation_id: ConversationId | None
344
+ if context is None:
345
+ id = id_from_number(id_or_conversation_id)
346
+ conversation_id = None
347
+ else:
348
+ if not _is_safe_int(id_or_context):
349
+ raise StorageRequestError("Storage.entry() requires an entry ID")
350
+ id = id_from_number(cast(int, id_or_context))
351
+ conversation_id = id_from_number(id_or_conversation_id)
352
+ conversation: ConversationRecord | None = None
353
+ if conversation_id is not None:
354
+ conversation = await self._read_conversation(conversation_id)
355
+ if conversation is None:
356
+ raise StorageRequestError(f"Unknown conversation: {conversation_id}")
357
+ row = await self._db.get(
358
+ f"SELECT record, commit_seq FROM {self._schema.tables['entries']} WHERE id = ?", id
359
+ )
360
+ if row is None:
361
+ return None
362
+ entry = cast(EntryRecord, _parse_json(row["record"]))
363
+ if conversation is not None:
364
+ upper_entry_id = _MAX_SAFE_INTEGER
365
+ while conversation["id"] != entry["conversationId"]:
366
+ parent = conversation.get("parent")
367
+ if parent is None:
368
+ return None
369
+ upper_entry_id = min(upper_entry_id, parent["at"])
370
+ parent_conversation = await self._read_conversation(parent["conversationId"])
371
+ if parent_conversation is None:
372
+ return None
373
+ conversation = parent_conversation
374
+ if entry["id"] > upper_entry_id:
375
+ return None
376
+ return {"entry": entry, "commitSeq": seq_from_number(row["commit_seq"])}
377
+
378
+ async def find_latest_head_marker(
379
+ self,
380
+ conversation_id: ConversationId,
381
+ at_or_before_entry_id: EntryId | None,
382
+ context: Context,
383
+ ) -> EntryWithHead | None:
384
+ return await self._admit_read(
385
+ lambda: self._read_latest_head_marker(conversation_id, at_or_before_entry_id)
386
+ )
387
+
388
+ async def _read_latest_head_marker(
389
+ self,
390
+ conversation_id: ConversationId,
391
+ at_or_before_entry_id: EntryId | None,
392
+ ) -> EntryWithHead | None:
393
+ conversation = await self._read_conversation(conversation_id)
394
+ if conversation is None:
395
+ raise StorageRequestError(f"Unknown conversation: {conversation_id}")
396
+ entries = self._schema.tables["entries"]
397
+ upper = at_or_before_entry_id
398
+ while True:
399
+ if upper is None:
400
+ row = await self._db.get(
401
+ f"SELECT record FROM {entries} WHERE conversation_id = ? "
402
+ "AND head IS NOT NULL ORDER BY id DESC LIMIT 1",
403
+ conversation["id"],
404
+ )
405
+ else:
406
+ row = await self._db.get(
407
+ f"SELECT record FROM {entries} WHERE conversation_id = ? "
408
+ "AND head IS NOT NULL AND id <= ? ORDER BY id DESC LIMIT 1",
409
+ conversation["id"],
410
+ upper,
411
+ )
412
+ if row is not None:
413
+ return cast(EntryWithHead, _parse_json(row["record"]))
414
+ parent = conversation.get("parent")
415
+ if parent is None:
416
+ return None
417
+ upper = parent["at"] if upper is None else min(upper, parent["at"])
418
+ parent_conversation = await self._read_conversation(parent["conversationId"])
419
+ if parent_conversation is None:
420
+ return None
421
+ conversation = parent_conversation
422
+
423
+ async def scan_entries(
424
+ self,
425
+ query: EntryQuery,
426
+ limit: int,
427
+ cursor: Cursor | None,
428
+ context: Context,
429
+ ) -> Page[EntryRecord, Cursor]:
430
+ return await self._admit_read(lambda: self._read_entries(query, limit, cursor))
431
+
432
+ async def _read_entries(
433
+ self,
434
+ query: EntryQuery,
435
+ limit: int,
436
+ cursor: Cursor | None,
437
+ ) -> Page[EntryRecord, Cursor]:
438
+ start = scan_start(query.get("order"), cursor, "descending")
439
+ if start.order == "ascending":
440
+ return await self._read_entries_ascending(query, limit, start.after)
441
+ conversation = await self._read_conversation(query["conversationId"])
442
+ if conversation is None:
443
+ raise StorageRequestError(f"Unknown conversation: {query['conversationId']}")
444
+ upper: int | None = query.get("maxEntryId")
445
+ if start.after is not None:
446
+ upper = min(
447
+ _MAX_SAFE_INTEGER if upper is None else upper, start.after - 1
448
+ )
449
+ values: list[EntryRecord] = []
450
+ entries_table = self._schema.tables["entries"]
451
+ while True:
452
+ clauses = ["conversation_id = ?"]
453
+ params: list[SqliteValue] = [conversation["id"]]
454
+ if "minEntryId" in query:
455
+ clauses.append("id >= ?")
456
+ params.append(query["minEntryId"])
457
+ if upper is not None:
458
+ clauses.append("id <= ?")
459
+ params.append(upper)
460
+ params.append(limit + 1 - len(values))
461
+ rows = await self._db.all(
462
+ f"SELECT record FROM {entries_table} WHERE {' AND '.join(clauses)} "
463
+ "ORDER BY id DESC LIMIT ?",
464
+ *params,
465
+ )
466
+ values.extend(cast(EntryRecord, _parse_json(row["record"])) for row in rows)
467
+ parent = conversation.get("parent")
468
+ if len(values) > limit or parent is None:
469
+ break
470
+ upper = parent["at"] if upper is None else min(upper, parent["at"])
471
+ if "minEntryId" in query and upper < query["minEntryId"]:
472
+ break
473
+ parent_conversation = await self._read_conversation(parent["conversationId"])
474
+ if parent_conversation is None:
475
+ break
476
+ conversation = parent_conversation
477
+ return _page(values, limit, "descending")
478
+
479
+ async def _read_entries_ascending(
480
+ self,
481
+ query: EntryQuery,
482
+ limit: int,
483
+ after: int | None,
484
+ ) -> Page[EntryRecord, Cursor]:
485
+ """Oldest first: the fork chain's segments from the root conversation forward."""
486
+
487
+ segments: list[tuple[ConversationId, int | None]] = []
488
+ conversation = await self._read_conversation(query["conversationId"])
489
+ if conversation is None:
490
+ raise StorageRequestError(f"Unknown conversation: {query['conversationId']}")
491
+ upper: int | None = query.get("maxEntryId")
492
+ while True:
493
+ segments.append((id_from_number(conversation["id"]), upper))
494
+ parent = conversation.get("parent")
495
+ if parent is None:
496
+ break
497
+ upper = parent["at"] if upper is None else min(upper, parent["at"])
498
+ if "minEntryId" in query and upper < query["minEntryId"]:
499
+ break
500
+ parent_conversation = await self._read_conversation(parent["conversationId"])
501
+ if parent_conversation is None:
502
+ break
503
+ conversation = parent_conversation
504
+ lower: int | None = query.get("minEntryId")
505
+ if after is not None:
506
+ lower = max(after + 1 if lower is None else lower, after + 1)
507
+ values: list[EntryRecord] = []
508
+ entries_table = self._schema.tables["entries"]
509
+ for conversation_id, segment_upper in reversed(segments):
510
+ clauses = ["conversation_id = ?"]
511
+ params: list[SqliteValue] = [conversation_id]
512
+ if lower is not None:
513
+ clauses.append("id >= ?")
514
+ params.append(lower)
515
+ if segment_upper is not None:
516
+ clauses.append("id <= ?")
517
+ params.append(segment_upper)
518
+ params.append(limit + 1 - len(values))
519
+ rows = await self._db.all(
520
+ f"SELECT record FROM {entries_table} WHERE {' AND '.join(clauses)} "
521
+ "ORDER BY id ASC LIMIT ?",
522
+ *params,
523
+ )
524
+ values.extend(cast(EntryRecord, _parse_json(row["record"])) for row in rows)
525
+ if len(values) > limit:
526
+ break
527
+ return _page(values, limit, "ascending")
528
+
529
+ async def task(self, id: TaskId, context: Context) -> TaskRecord | None:
530
+ self._assert_open()
531
+ row = await self._db.get(
532
+ f"SELECT record FROM {self._schema.tables['tasks']} WHERE id = ?", id
533
+ )
534
+ return None if row is None else cast(TaskRecord, _parse_json(row["record"]))
535
+
536
+ async def scan_tasks(
537
+ self,
538
+ query: TaskQuery,
539
+ limit: int,
540
+ cursor: Cursor | None,
541
+ context: Context,
542
+ ) -> Page[TaskRecord, Cursor]:
543
+ self._assert_open()
544
+ start = scan_start(query.get("order"), cursor, "ascending")
545
+ clause, param, direction = _scan_sql(start)
546
+ clauses = [clause]
547
+ params: list[SqliteValue] = [param]
548
+ if "conversationId" in query:
549
+ clauses.append("conversation_id = ?")
550
+ params.append(query["conversationId"])
551
+ if "kind" in query:
552
+ clauses.append("kind = ?")
553
+ params.append(_encode_indexed_string(query["kind"]))
554
+ if "status" in query:
555
+ clauses.append("status = ?")
556
+ params.append(query["status"])
557
+ if "abortRequested" in query:
558
+ clauses.append("abort_requested = ?")
559
+ params.append(1 if query["abortRequested"] else 0)
560
+ if "background" in query:
561
+ clauses.append("background = ?")
562
+ params.append(1 if query["background"] else 0)
563
+ params.append(limit + 1)
564
+ rows = await self._db.all(
565
+ f"SELECT record FROM {self._schema.tables['tasks']} "
566
+ f"WHERE {' AND '.join(clauses)} ORDER BY id {direction} LIMIT ?",
567
+ *params,
568
+ )
569
+ return _page([_parse_json(row["record"]) for row in rows], limit, start.order)
570
+
571
+ async def submission(self, id: SubmissionId, context: Context) -> SubmissionRecord | None:
572
+ self._assert_open()
573
+ row = await self._db.get(
574
+ f"SELECT record FROM {self._schema.tables['submissions']} WHERE id = ?", id
575
+ )
576
+ return None if row is None else cast(SubmissionRecord, _parse_json(row["record"]))
577
+
578
+ async def scan_submissions(
579
+ self,
580
+ query: SubmissionQuery,
581
+ limit: int,
582
+ cursor: Cursor | None,
583
+ context: Context,
584
+ ) -> Page[SubmissionRecord, Cursor]:
585
+ self._assert_open()
586
+ start = scan_start(query.get("order"), cursor, "ascending")
587
+ clause, param, direction = _scan_sql(start)
588
+ clauses = [clause]
589
+ params: list[SqliteValue] = [param]
590
+ if "conversationId" in query:
591
+ clauses.append("conversation_id = ?")
592
+ params.append(query["conversationId"])
593
+ if "status" in query:
594
+ clauses.append("status = ?")
595
+ params.append(query["status"])
596
+ params.append(limit + 1)
597
+ rows = await self._db.all(
598
+ f"SELECT record FROM {self._schema.tables['submissions']} "
599
+ f"WHERE {' AND '.join(clauses)} ORDER BY id {direction} LIMIT ?",
600
+ *params,
601
+ )
602
+ return _page([_parse_json(row["record"]) for row in rows], limit, start.order)
603
+
604
+ async def submission_by_request(
605
+ self,
606
+ conversation_id: ConversationId,
607
+ request_id: str,
608
+ context: Context,
609
+ ) -> SubmissionRecord | None:
610
+ self._assert_open()
611
+ row = await self._db.get(
612
+ f"SELECT record FROM {self._schema.tables['submissions']} "
613
+ "WHERE conversation_id = ? AND request_id = ?",
614
+ conversation_id,
615
+ _encode_indexed_string(request_id),
616
+ )
617
+ return None if row is None else cast(SubmissionRecord, _parse_json(row["record"]))
618
+
619
+ # ── Document reads ──────────────────────────────────────────────────────
620
+
621
+ async def find_document(
622
+ self,
623
+ address: DocumentAddress,
624
+ at: DocumentPoint,
625
+ context: Context,
626
+ ) -> DocumentRecord | None:
627
+ self._assert_open()
628
+ parts = _address_parts(address)
629
+ documents = self._schema.tables["documents"]
630
+ if at == "current":
631
+ sql = (
632
+ f"SELECT record FROM {documents} WHERE kind = ? AND scope_kind = ? "
633
+ "AND owner_id = ? AND family = ? AND key_value = ? AND retired_at IS NULL "
634
+ "ORDER BY created_at DESC LIMIT 1"
635
+ )
636
+ params: list[SqliteValue] = [
637
+ parts.kind,
638
+ parts.scope_kind,
639
+ parts.owner_id,
640
+ parts.family,
641
+ parts.key_value,
642
+ ]
643
+ else:
644
+ sql = (
645
+ f"SELECT record FROM {documents} WHERE kind = ? AND scope_kind = ? "
646
+ "AND owner_id = ? AND family = ? AND key_value = ? AND created_at <= ? "
647
+ "AND (retired_at IS NULL OR retired_at > ?) ORDER BY created_at DESC LIMIT 1"
648
+ )
649
+ params = [
650
+ parts.kind,
651
+ parts.scope_kind,
652
+ parts.owner_id,
653
+ parts.family,
654
+ parts.key_value,
655
+ at,
656
+ at,
657
+ ]
658
+ row = await self._db.get(sql, *params)
659
+ return None if row is None else cast(DocumentRecord, _parse_json(row["record"]))
660
+
661
+ async def document(
662
+ self,
663
+ id: DocumentId,
664
+ at: DocumentPoint,
665
+ context: Context,
666
+ ) -> StoredDocument | None:
667
+ self._assert_open()
668
+ # The record and revision queries must observe one committed state; a commit between them
669
+ # can replace the base.
670
+ return await self._db.transaction(
671
+ lambda transaction: self._materialize_document(transaction, id, at)
672
+ )
673
+
674
+ async def scan_documents(
675
+ self,
676
+ query: DocumentQuery,
677
+ limit: int,
678
+ cursor: Cursor | None,
679
+ context: Context,
680
+ ) -> Page[DocumentRecord, Cursor]:
681
+ self._assert_open()
682
+ scope = _scope_columns(query["scope"])
683
+ after = _cursor_id(cursor)
684
+ clauses = ["scope_kind = ?", "owner_id = ?", "id > ?"]
685
+ params: list[SqliteValue] = [
686
+ scope.scope_kind,
687
+ scope.owner_id,
688
+ -1 if after is None else cast(int, after),
689
+ ]
690
+ if "kind" in query:
691
+ clauses.append("kind = ?")
692
+ params.append(_encode_indexed_string(query["kind"]))
693
+ if query["at"] == "current":
694
+ clauses.append("retired_at IS NULL")
695
+ else:
696
+ clauses.append("created_at <= ?")
697
+ clauses.append("(retired_at IS NULL OR retired_at > ?)")
698
+ params.append(cast(int, query["at"]))
699
+ params.append(cast(int, query["at"]))
700
+ params.append(limit + 1)
701
+ rows = await self._db.all(
702
+ f"SELECT record FROM {self._schema.tables['documents']} "
703
+ f"WHERE {' AND '.join(clauses)} ORDER BY id LIMIT ?",
704
+ *params,
705
+ )
706
+ return _page([_parse_json(row["record"]) for row in rows], limit)
707
+
708
+ def close(self, context: Context) -> asyncio.Future[None]:
709
+ if self._closing is None:
710
+ self._closed = True
711
+ self._closing = asyncio.ensure_future(self._close_database())
712
+ return self._closing
713
+
714
+ # ── Reads and close helpers ─────────────────────────────────────────────
715
+
716
+ async def _close_database(self) -> None:
717
+ if self._admitted_reads > 0:
718
+ self._reads_drained = asyncio.get_running_loop().create_future()
719
+ await self._reads_drained
720
+ await self._db.close()
721
+
722
+ async def _admit_read(self, read: Callable[[], Awaitable[T]]) -> T:
723
+ """Run a grouped read; close waits for admitted reads so none reaches a closed database."""
724
+
725
+ self._assert_open()
726
+ self._admitted_reads += 1
727
+ try:
728
+ return await read()
729
+ finally:
730
+ self._admitted_reads -= 1
731
+ if (
732
+ self._admitted_reads == 0
733
+ and self._reads_drained is not None
734
+ and not self._reads_drained.done()
735
+ ):
736
+ self._reads_drained.set_result(None)
737
+
738
+ async def _read_conversation(self, id: ConversationId) -> ConversationRecord | None:
739
+ row = await self._db.get(
740
+ f"SELECT record FROM {self._schema.tables['conversations']} WHERE id = ?", id
741
+ )
742
+ return None if row is None else cast(ConversationRecord, _parse_json(row["record"]))
743
+
744
+ async def _materialize_document(
745
+ self,
746
+ executor: SqliteExecutor,
747
+ id: DocumentId,
748
+ at: DocumentPoint,
749
+ ) -> StoredDocument | None:
750
+ documents = self._schema.tables["documents"]
751
+ revisions = self._schema.tables["documentRevisions"]
752
+ row = await executor.get(f"SELECT record FROM {documents} WHERE id = ?", id)
753
+ if row is None:
754
+ return None
755
+ record = cast(DocumentRecord, _parse_json(row["record"]))
756
+ if at != "current" and _is_current_only(record):
757
+ raise StorageRequestError(f"Document {id} does not retain historical content")
758
+ if not _is_alive_at(record, at):
759
+ return None
760
+ upper = _MAX_SAFE_INTEGER if at == "current" else at
761
+ base = await executor.get(
762
+ f"SELECT seq, kind, version, content FROM {revisions} WHERE document_id = ? "
763
+ "AND kind = 'base' AND seq <= ? ORDER BY seq DESC LIMIT 1",
764
+ id,
765
+ upper,
766
+ )
767
+ if base is None:
768
+ raise RuntimeError(f"Document {id} is missing a required base")
769
+ value: JsonObject = _parse_json(base["content"])
770
+ tail = await executor.all(
771
+ f"SELECT seq, kind, version, content FROM {revisions} WHERE document_id = ? "
772
+ "AND seq > ? AND seq <= ? ORDER BY seq",
773
+ id,
774
+ base["seq"],
775
+ upper,
776
+ )
777
+ for revision in tail:
778
+ if revision["kind"] != "delta" or revision["version"] != base["version"]:
779
+ raise RuntimeError(
780
+ f"Document {id} crosses a stored version boundary without a base"
781
+ )
782
+ value = cast(JsonObject, apply(value, _parse_json(revision["content"])))
783
+ return {
784
+ "record": record,
785
+ "version": base["version"],
786
+ "value": value,
787
+ "deltasSinceBase": len(tail),
788
+ }
789
+
790
+ # ── Commit validation and application ───────────────────────────────────
791
+
792
+ def _candidate_next_id(self, writes: Sequence[StorageWrite]) -> int:
793
+ next_id = self._next_id
794
+ for write in writes:
795
+ id = _write_id(write)
796
+ if id is not None:
797
+ next_id = max(next_id, id + 1)
798
+ return next_id
799
+
800
+ async def _check_global_ids(
801
+ self,
802
+ executor: SqliteExecutor,
803
+ writes: Sequence[StorageWrite],
804
+ ) -> None:
805
+ record_ids = self._schema.tables["recordIds"]
806
+ claimed: dict[int, str] = {}
807
+ for write in writes:
808
+ kind = write["type"]
809
+ if kind in ("document.change", "document.retire"):
810
+ continue
811
+ is_document = kind in ("document.create", "document.copy")
812
+ table = "document" if is_document else kind
813
+ id = _write_id(write)
814
+ assert id is not None
815
+ row = await executor.get(
816
+ f"SELECT record_type FROM {record_ids} WHERE id = ?", id
817
+ )
818
+ existing = None if row is None else row["record_type"]
819
+ earlier = claimed.get(id)
820
+ if table in ("conversation", "entry", "document"):
821
+ if existing is not None:
822
+ raise RuntimeError(f"ID {id} already belongs to {existing}")
823
+ if earlier is not None:
824
+ raise RuntimeError(f"ID {id} is written more than once")
825
+ else:
826
+ if existing is not None and existing != table:
827
+ raise RuntimeError(f"ID {id} already belongs to {existing}")
828
+ if earlier is not None and earlier != table:
829
+ raise RuntimeError(f"ID {id} is written as two record types")
830
+ claimed[id] = table
831
+
832
+ def _prepare_document_actions(
833
+ self,
834
+ writes: Sequence[StorageWrite],
835
+ ) -> dict[DocumentId, _DocumentAction]:
836
+ actions: dict[DocumentId, _DocumentAction] = {}
837
+ for write in writes:
838
+ item = cast(dict[str, Any], write)
839
+ kind = item["type"]
840
+ if kind not in (
841
+ "document.create",
842
+ "document.copy",
843
+ "document.change",
844
+ "document.retire",
845
+ ):
846
+ continue
847
+ id = item["record"]["id"] if kind in ("document.create", "document.copy") else item["id"]
848
+ action = actions.get(id)
849
+ if action is None:
850
+ action = _DocumentAction()
851
+ actions[id] = action
852
+ if kind in ("document.create", "document.copy"):
853
+ if (
854
+ action.create is not None
855
+ or action.content is not None
856
+ or action.copy is not None
857
+ ):
858
+ raise RuntimeError(f"Document {id} has more than one content command")
859
+ action.create = item["record"]
860
+ if kind == "document.create":
861
+ action.content = item["content"]
862
+ else:
863
+ action.copy = dict(item["source"])
864
+ elif kind == "document.change":
865
+ if action.content is not None or action.copy is not None:
866
+ raise RuntimeError(f"Document {id} has more than one content command")
867
+ action.content = item["content"]
868
+ else:
869
+ if action.retire:
870
+ raise RuntimeError(f"Document {id} is retired more than once")
871
+ action.retire = True
872
+ return actions
873
+
874
+ async def _check_document_actions(
875
+ self,
876
+ executor: SqliteExecutor,
877
+ actions: dict[DocumentId, _DocumentAction],
878
+ ) -> None:
879
+ documents = self._schema.tables["documents"]
880
+ revisions = self._schema.tables["documentRevisions"]
881
+ live_counts: dict[str, int] = {}
882
+ for id, action in actions.items():
883
+ if action.copy is not None and action.copy["id"] in actions:
884
+ raise RuntimeError(f"Document copy {id} source is changed in the copy batch")
885
+ row = await executor.get(f"SELECT record FROM {documents} WHERE id = ?", id)
886
+ existing = None if row is None else cast(DocumentRecord, _parse_json(row["record"]))
887
+ if action.create is None and existing is None:
888
+ raise RuntimeError(f"Unknown document: {id}")
889
+ if action.create is not None and existing is not None:
890
+ raise RuntimeError(f"Document {id} already exists")
891
+ if existing is not None and "retiredAt" in existing:
892
+ raise RuntimeError(f"Document {id} is retired")
893
+ if action.content is not None and action.content["kind"] == "delta":
894
+ previous = await executor.get(
895
+ f"SELECT version FROM {revisions} WHERE document_id = ? "
896
+ "ORDER BY seq DESC LIMIT 1",
897
+ id,
898
+ )
899
+ if previous is None:
900
+ raise RuntimeError(f"Document {id} delta has no base")
901
+ if previous["version"] != action.content["version"]:
902
+ raise RuntimeError(f"Document {id} version transition requires a base")
903
+ record = action.create if action.create is not None else existing
904
+ assert record is not None
905
+ key = _address_key(record)
906
+ live = live_counts.get(key)
907
+ if live is None:
908
+ current = await self._current_document_id(executor, record)
909
+ live = 0 if current is None else 1
910
+ if action.retire and existing is not None:
911
+ live -= 1
912
+ if action.create is not None and not action.retire:
913
+ live += 1
914
+ live_counts[key] = live
915
+ for live in live_counts.values():
916
+ if live > 1:
917
+ raise RuntimeError("Document address already has a current incarnation")
918
+
919
+ async def _current_document_id(
920
+ self,
921
+ executor: SqliteExecutor,
922
+ address: DocumentAddress | DocumentCreate | DocumentRecord,
923
+ ) -> DocumentId | None:
924
+ parts = _address_parts(address)
925
+ row = await executor.get(
926
+ f"SELECT id FROM {self._schema.tables['documents']} WHERE kind = ? "
927
+ "AND scope_kind = ? AND owner_id = ? AND family = ? AND key_value = ? "
928
+ "AND retired_at IS NULL LIMIT 1",
929
+ parts.kind,
930
+ parts.scope_kind,
931
+ parts.owner_id,
932
+ parts.family,
933
+ parts.key_value,
934
+ )
935
+ return None if row is None else id_from_number(row["id"])
936
+
937
+ async def _apply_table_write(
938
+ self,
939
+ executor: SqliteExecutor,
940
+ write: StorageWrite,
941
+ seq: Seq,
942
+ ) -> None:
943
+ tables = self._schema.tables
944
+ item = cast(dict[str, Any], write)
945
+ kind = item["type"]
946
+ if kind == "conversation":
947
+ value = item["value"]
948
+ await self._claim_id(executor, value["id"], "conversation")
949
+ owner = value.get("owner")
950
+ await executor.run(
951
+ f"INSERT INTO {tables['conversations']} "
952
+ "(id, owner_conversation_id, owner_task_id, record) VALUES (?, ?, ?, ?)",
953
+ value["id"],
954
+ None if owner is None else owner["conversationId"],
955
+ None if owner is None else owner["taskId"],
956
+ _encode_json(value),
957
+ )
958
+ elif kind == "entry":
959
+ value = item["value"]
960
+ await self._claim_id(executor, value["id"], "entry")
961
+ await executor.run(
962
+ f"INSERT INTO {tables['entries']} "
963
+ "(id, conversation_id, head, commit_seq, record) VALUES (?, ?, ?, ?, ?)",
964
+ value["id"],
965
+ value["conversationId"],
966
+ value.get("head"),
967
+ seq,
968
+ _encode_json(value),
969
+ )
970
+ elif kind == "task":
971
+ value = item["value"]
972
+ await self._claim_id(executor, value["id"], "task")
973
+ await executor.run(
974
+ f"INSERT INTO {tables['tasks']} "
975
+ "(id, conversation_id, kind, status, abort_requested, background, record) "
976
+ "VALUES (?, ?, ?, ?, ?, ?, ?) "
977
+ "ON CONFLICT(id) DO UPDATE SET conversation_id = excluded.conversation_id, "
978
+ "kind = excluded.kind, status = excluded.status, "
979
+ "abort_requested = excluded.abort_requested, "
980
+ "background = excluded.background, record = excluded.record",
981
+ value["id"],
982
+ value["conversationId"],
983
+ _encode_indexed_string(value["kind"]),
984
+ value["state"]["status"],
985
+ 1 if value["abortRequested"] else 0,
986
+ 1 if value["background"] else 0,
987
+ _encode_json(value),
988
+ )
989
+ elif kind == "submission":
990
+ value = item["value"]
991
+ await self._claim_id(executor, value["id"], "submission")
992
+ request_id = value.get("requestId")
993
+ await executor.run(
994
+ f"INSERT INTO {tables['submissions']} "
995
+ "(id, conversation_id, request_id, status, record) VALUES (?, ?, ?, ?, ?) "
996
+ "ON CONFLICT(id) DO UPDATE SET conversation_id = excluded.conversation_id, "
997
+ "request_id = excluded.request_id, status = excluded.status, "
998
+ "record = excluded.record",
999
+ value["id"],
1000
+ value["conversationId"],
1001
+ None if request_id is None else _encode_indexed_string(request_id),
1002
+ value["status"],
1003
+ _encode_json(value),
1004
+ )
1005
+
1006
+ async def _claim_id(
1007
+ self,
1008
+ executor: SqliteExecutor,
1009
+ id: int,
1010
+ table: str,
1011
+ ) -> None:
1012
+ await executor.run(
1013
+ f"INSERT OR IGNORE INTO {self._schema.tables['recordIds']} "
1014
+ "(id, record_type) VALUES (?, ?)",
1015
+ id,
1016
+ table,
1017
+ )
1018
+
1019
+ async def _apply_document_actions(
1020
+ self,
1021
+ executor: SqliteExecutor,
1022
+ actions: dict[DocumentId, _DocumentAction],
1023
+ seq: Seq,
1024
+ ) -> None:
1025
+ tables = self._schema.tables
1026
+ documents = tables["documents"]
1027
+ revisions = tables["documentRevisions"]
1028
+ for id, action in actions.items():
1029
+ content = action.content
1030
+ if action.copy is not None:
1031
+ try:
1032
+ stored = await self._materialize_document(
1033
+ executor,
1034
+ action.copy["id"],
1035
+ action.copy["at"],
1036
+ )
1037
+ if stored is None:
1038
+ raise RuntimeError(
1039
+ f"Fork source document {action.copy['id']} cannot be read"
1040
+ )
1041
+ create = action.create
1042
+ assert create is not None
1043
+ source_record = stored["record"]
1044
+ if (
1045
+ source_record["scope"]["kind"] != "conversation"
1046
+ or create["scope"]["kind"] != "conversation"
1047
+ or source_record["kind"] != create["kind"]
1048
+ or source_record.get("key") != create.get("key")
1049
+ or source_record.get("history") != create.get("history")
1050
+ or source_record.get("fork") != create.get("fork")
1051
+ ):
1052
+ raise RuntimeError(
1053
+ f"Fork source document {action.copy['id']} "
1054
+ "does not match the copied record"
1055
+ )
1056
+ content = {
1057
+ "kind": "base",
1058
+ "version": stored["version"],
1059
+ "value": stored["value"],
1060
+ }
1061
+ except Exception as error:
1062
+ raise RuntimeError(f"Document copy {id} was rejected") from error
1063
+ if action.create is not None:
1064
+ record = cast(
1065
+ DocumentRecord,
1066
+ {
1067
+ **action.create,
1068
+ "createdAt": seq,
1069
+ **({"retiredAt": seq} if action.retire else {}),
1070
+ },
1071
+ )
1072
+ parts = _address_parts(record)
1073
+ await self._claim_id(executor, id, "document")
1074
+ await executor.run(
1075
+ f"INSERT INTO {documents} (id, kind, family, key_value, scope_kind, "
1076
+ "owner_id, created_at, retired_at, record) "
1077
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1078
+ id,
1079
+ parts.kind,
1080
+ parts.family,
1081
+ parts.key_value,
1082
+ parts.scope_kind,
1083
+ parts.owner_id,
1084
+ seq,
1085
+ seq if action.retire else None,
1086
+ _encode_json(record),
1087
+ )
1088
+ else:
1089
+ row = await executor.get(f"SELECT record FROM {documents} WHERE id = ?", id)
1090
+ assert row is not None
1091
+ record = cast(DocumentRecord, _parse_json(row["record"]))
1092
+ if content is not None:
1093
+ if content["kind"] == "base" and _is_current_only(record):
1094
+ await executor.run(
1095
+ f"DELETE FROM {revisions} WHERE document_id = ?", id
1096
+ )
1097
+ encoded = (
1098
+ _encode_json(content["value"])
1099
+ if content["kind"] == "base"
1100
+ else _encode_json(content["ops"])
1101
+ )
1102
+ await executor.run(
1103
+ f"INSERT INTO {revisions} (document_id, seq, kind, version, content) "
1104
+ "VALUES (?, ?, ?, ?, ?)",
1105
+ id,
1106
+ seq,
1107
+ content["kind"],
1108
+ content["version"],
1109
+ encoded,
1110
+ )
1111
+ if action.retire:
1112
+ if action.create is None:
1113
+ record = cast(DocumentRecord, {**record, "retiredAt": seq})
1114
+ await executor.run(
1115
+ f"UPDATE {documents} SET retired_at = ?, record = ? WHERE id = ?",
1116
+ seq,
1117
+ _encode_json(record),
1118
+ id,
1119
+ )
1120
+ if _is_current_only(record):
1121
+ await executor.run(
1122
+ f"DELETE FROM {revisions} WHERE document_id = ?", id
1123
+ )
1124
+
1125
+ def _assert_open(self) -> None:
1126
+ if self._closed:
1127
+ raise RuntimeError("SqliteStorage is closed")
1128
+
1129
+
1130
+ __all__ = ["SqliteStorage", "SqliteStorageOptions"]