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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
"""Detached in-memory reference storage.
|
|
2
|
+
|
|
3
|
+
Reads and retained writes are copied intentionally, matching the ownership boundary of
|
|
4
|
+
serialization-backed stores. This is backend conformance, not defensive validation: storage still
|
|
5
|
+
trusts Session-supplied semantics such as ancestry and ownership.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, cast, overload
|
|
13
|
+
|
|
14
|
+
from ..context import Context
|
|
15
|
+
from ..delta import Op, apply_immutable_batches
|
|
16
|
+
from ..documents import address_id
|
|
17
|
+
from ..errors import StorageRequestError
|
|
18
|
+
from ..ids import (
|
|
19
|
+
ConversationId,
|
|
20
|
+
DocumentId,
|
|
21
|
+
EntryId,
|
|
22
|
+
Seq,
|
|
23
|
+
SubmissionId,
|
|
24
|
+
TaskId,
|
|
25
|
+
id_from_number,
|
|
26
|
+
seq_from_number,
|
|
27
|
+
)
|
|
28
|
+
from ..json import JsonObject, JsonValue, copy_json, freeze_json
|
|
29
|
+
from ..types import (
|
|
30
|
+
ConversationQuery,
|
|
31
|
+
ConversationRecord,
|
|
32
|
+
Cursor,
|
|
33
|
+
DocumentAddress,
|
|
34
|
+
DocumentContent,
|
|
35
|
+
DocumentCreate,
|
|
36
|
+
DocumentPoint,
|
|
37
|
+
DocumentQuery,
|
|
38
|
+
DocumentRecord,
|
|
39
|
+
EntryLookup,
|
|
40
|
+
EntryQuery,
|
|
41
|
+
EntryRecord,
|
|
42
|
+
EntryWithHead,
|
|
43
|
+
Page,
|
|
44
|
+
ScanOrder,
|
|
45
|
+
StorageWrite,
|
|
46
|
+
StoredDocument,
|
|
47
|
+
SubmissionQuery,
|
|
48
|
+
SubmissionRecord,
|
|
49
|
+
TaskQuery,
|
|
50
|
+
TaskRecord,
|
|
51
|
+
)
|
|
52
|
+
from .scan import next_cursor, scan_start
|
|
53
|
+
|
|
54
|
+
_MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
55
|
+
_TASK_STATUSES = ("pending", "running", "waiting", "completing", "terminal")
|
|
56
|
+
_SUBMISSION_STATUSES = ("queued", "placed", "done", "unanswered")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class DocumentRevision:
|
|
61
|
+
seq: Seq
|
|
62
|
+
content: DocumentContent
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True)
|
|
66
|
+
class StoredDocumentState:
|
|
67
|
+
record: DocumentRecord
|
|
68
|
+
revisions: list[DocumentRevision]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True)
|
|
72
|
+
class DocumentAction:
|
|
73
|
+
create: DocumentCreate | None = None
|
|
74
|
+
content: DocumentContent | None = None
|
|
75
|
+
retire: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class DocumentAddressIndex:
|
|
80
|
+
ids: list[DocumentId] = field(default_factory=list)
|
|
81
|
+
current_id: DocumentId | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_safe_int(value: object) -> bool:
|
|
85
|
+
return isinstance(value, int) and not isinstance(value, bool) and abs(value) <= _MAX_SAFE_INTEGER
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cursor_id(cursor: Cursor | None) -> DocumentId | None:
|
|
89
|
+
if cursor is None:
|
|
90
|
+
return None
|
|
91
|
+
after = cursor.get("after")
|
|
92
|
+
if not _is_safe_int(after):
|
|
93
|
+
raise StorageRequestError("Invalid storage cursor")
|
|
94
|
+
return id_from_number(cast(int, after))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _lower_bound(ids: Sequence[int], target: int) -> int:
|
|
98
|
+
low, high = 0, len(ids)
|
|
99
|
+
while low < high:
|
|
100
|
+
middle = (low + high) // 2
|
|
101
|
+
if ids[middle] < target:
|
|
102
|
+
low = middle + 1
|
|
103
|
+
else:
|
|
104
|
+
high = middle
|
|
105
|
+
return low
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _upper_bound(ids: Sequence[int], target: int) -> int:
|
|
109
|
+
low, high = 0, len(ids)
|
|
110
|
+
while low < high:
|
|
111
|
+
middle = (low + high) // 2
|
|
112
|
+
if ids[middle] <= target:
|
|
113
|
+
low = middle + 1
|
|
114
|
+
else:
|
|
115
|
+
high = middle
|
|
116
|
+
return low
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _insert_sorted(ids: list[Any], id: Any) -> None:
|
|
120
|
+
if not ids or ids[-1] < id:
|
|
121
|
+
ids.append(id)
|
|
122
|
+
else:
|
|
123
|
+
ids.insert(_lower_bound(ids, id), id)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _remove_sorted(ids: list[Any], id: Any) -> None:
|
|
127
|
+
index = _lower_bound(ids, id)
|
|
128
|
+
if index < len(ids) and ids[index] == id:
|
|
129
|
+
ids.pop(index)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _insert_map_id(index: dict[Any, list[Any]], key: Any, id: Any) -> None:
|
|
133
|
+
ids = index.setdefault(key, [])
|
|
134
|
+
_insert_sorted(ids, id)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _scope_key(scope: dict[str, Any]) -> str:
|
|
138
|
+
if scope["kind"] == "session":
|
|
139
|
+
return '["session"]'
|
|
140
|
+
if scope["kind"] == "conversation":
|
|
141
|
+
return f'["conversation",{scope["conversationId"]}]'
|
|
142
|
+
return f'["task",{scope["taskId"]}]'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _address_key(address: DocumentAddress) -> str:
|
|
146
|
+
return address_id(address)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _record_address_key(record: DocumentCreate | DocumentRecord) -> str:
|
|
150
|
+
key = {"key": record["key"]} if "key" in record else {}
|
|
151
|
+
return _address_key(cast(DocumentAddress, {"kind": record["kind"], "scope": record["scope"], **key}))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_alive_at(record: DocumentRecord, at: DocumentPoint) -> bool:
|
|
155
|
+
if at == "current":
|
|
156
|
+
return "retiredAt" not in record
|
|
157
|
+
return record["createdAt"] <= at and ("retiredAt" not in record or at < record["retiredAt"])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_current_only(record: DocumentRecord) -> bool:
|
|
161
|
+
return record["scope"]["kind"] != "conversation" or record.get("history") == "latest"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _scan_indexes(ids: Sequence[int], order: ScanOrder, after: int | None) -> Iterable[int]:
|
|
165
|
+
if order == "ascending":
|
|
166
|
+
first = 0 if after is None else _upper_bound(ids, after)
|
|
167
|
+
yield from range(first, len(ids))
|
|
168
|
+
else:
|
|
169
|
+
end = len(ids) if after is None else _lower_bound(ids, after)
|
|
170
|
+
yield from range(end - 1, -1, -1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _page(values: Sequence[Any], limit: int, order: ScanOrder) -> Page[Any, Cursor]:
|
|
174
|
+
if not values:
|
|
175
|
+
return {"items": []}
|
|
176
|
+
items = [copy_json(item) for item in values[:limit]]
|
|
177
|
+
if len(values) <= limit:
|
|
178
|
+
return {"items": items}
|
|
179
|
+
return {"items": items, "next": next_cursor(items[-1]["id"], order)}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(slots=True)
|
|
183
|
+
class _State:
|
|
184
|
+
record_types: dict[int, str] = field(default_factory=dict)
|
|
185
|
+
conversations: dict[ConversationId, ConversationRecord] = field(default_factory=dict)
|
|
186
|
+
conversation_ids: list[ConversationId] = field(default_factory=list)
|
|
187
|
+
conversation_ids_by_owner_conversation: dict[ConversationId, list[ConversationId]] = field(
|
|
188
|
+
default_factory=dict
|
|
189
|
+
)
|
|
190
|
+
conversation_ids_by_owner_task: dict[TaskId, list[ConversationId]] = field(default_factory=dict)
|
|
191
|
+
entries: dict[EntryId, EntryRecord] = field(default_factory=dict)
|
|
192
|
+
entry_ids: dict[ConversationId, list[EntryId]] = field(default_factory=dict)
|
|
193
|
+
head_entry_ids: dict[ConversationId, list[EntryId]] = field(default_factory=dict)
|
|
194
|
+
entry_commit_seqs: dict[EntryId, Seq] = field(default_factory=dict)
|
|
195
|
+
tasks: dict[TaskId, TaskRecord] = field(default_factory=dict)
|
|
196
|
+
task_ids: list[TaskId] = field(default_factory=list)
|
|
197
|
+
task_ids_by_status: dict[str, list[TaskId]] = field(
|
|
198
|
+
default_factory=lambda: {status: [] for status in _TASK_STATUSES}
|
|
199
|
+
)
|
|
200
|
+
submissions: dict[SubmissionId, SubmissionRecord] = field(default_factory=dict)
|
|
201
|
+
submission_ids: list[SubmissionId] = field(default_factory=list)
|
|
202
|
+
submission_ids_by_status: dict[str, list[SubmissionId]] = field(
|
|
203
|
+
default_factory=lambda: {status: [] for status in _SUBMISSION_STATUSES}
|
|
204
|
+
)
|
|
205
|
+
submission_ids_by_request: dict[ConversationId, dict[str, SubmissionId]] = field(default_factory=dict)
|
|
206
|
+
documents: dict[DocumentId, StoredDocumentState] = field(default_factory=dict)
|
|
207
|
+
document_addresses: dict[str, DocumentAddressIndex] = field(default_factory=dict)
|
|
208
|
+
document_ids_by_scope: dict[str, list[DocumentId]] = field(default_factory=dict)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PreparedMemoryCommit:
|
|
212
|
+
"""A fully validated, detached state mutation whose application performs no fallible preparation."""
|
|
213
|
+
|
|
214
|
+
__slots__ = ("_apply", "_applied", "seq", "writes")
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
seq: Seq,
|
|
219
|
+
writes: Sequence[StorageWrite],
|
|
220
|
+
apply: Any,
|
|
221
|
+
) -> None:
|
|
222
|
+
self.seq = seq
|
|
223
|
+
self.writes = writes
|
|
224
|
+
self._apply = apply
|
|
225
|
+
self._applied = False
|
|
226
|
+
|
|
227
|
+
def apply(self) -> Seq:
|
|
228
|
+
if not self._applied:
|
|
229
|
+
self._applied = True
|
|
230
|
+
self._apply()
|
|
231
|
+
return self.seq
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class MemoryStorage:
|
|
235
|
+
"""Detached in-memory reference implementation of :class:`~pi_durable.types.Storage`."""
|
|
236
|
+
|
|
237
|
+
def __init__(self) -> None:
|
|
238
|
+
self._state = _State()
|
|
239
|
+
self._next_id = 2
|
|
240
|
+
self._next_seq = 1
|
|
241
|
+
self._closed = False
|
|
242
|
+
|
|
243
|
+
async def commit(self, writes: Sequence[StorageWrite], context: Context) -> Seq:
|
|
244
|
+
return self.prepare_commit(writes).apply()
|
|
245
|
+
|
|
246
|
+
def prepare_commit(
|
|
247
|
+
self,
|
|
248
|
+
writes: Sequence[StorageWrite],
|
|
249
|
+
seq: Seq | None = None,
|
|
250
|
+
) -> PreparedMemoryCommit:
|
|
251
|
+
"""Validate and detach one commit without changing observable state."""
|
|
252
|
+
|
|
253
|
+
self._assert_open()
|
|
254
|
+
assigned = seq_from_number(self._next_seq) if seq is None else seq
|
|
255
|
+
if not _is_safe_int(assigned) or assigned < self._next_seq:
|
|
256
|
+
raise ValueError(f"Commit sequence {assigned} does not strictly increase")
|
|
257
|
+
detached = cast(
|
|
258
|
+
list[StorageWrite],
|
|
259
|
+
copy_json(
|
|
260
|
+
self._resolve_document_copies(
|
|
261
|
+
[cast(StorageWrite, copy_json(write)) for write in writes]
|
|
262
|
+
)
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
frozen = cast(
|
|
266
|
+
list[StorageWrite],
|
|
267
|
+
freeze_json(detached),
|
|
268
|
+
)
|
|
269
|
+
self._check_global_ids(frozen)
|
|
270
|
+
document_actions = self._prepare_document_actions(frozen)
|
|
271
|
+
self._check_document_actions(document_actions)
|
|
272
|
+
|
|
273
|
+
def apply() -> None:
|
|
274
|
+
self._apply_prepared_commit(frozen, document_actions, assigned)
|
|
275
|
+
|
|
276
|
+
return PreparedMemoryCommit(assigned, frozen, apply)
|
|
277
|
+
|
|
278
|
+
# ── Validation and preparation ─────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def _resolve_document_copies(self, writes: list[StorageWrite]) -> list[StorageWrite]:
|
|
281
|
+
if not any(write["type"] == "document.copy" for write in writes):
|
|
282
|
+
return writes
|
|
283
|
+
changed_ids: set[int] = set()
|
|
284
|
+
for write in writes:
|
|
285
|
+
if write["type"] in ("document.create", "document.copy"):
|
|
286
|
+
changed_ids.add(write["record"]["id"])
|
|
287
|
+
elif write["type"] in ("document.change", "document.retire"):
|
|
288
|
+
changed_ids.add(write["id"])
|
|
289
|
+
resolved: list[StorageWrite] = []
|
|
290
|
+
for write in writes:
|
|
291
|
+
if write["type"] != "document.copy":
|
|
292
|
+
resolved.append(write)
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
source = write["source"]
|
|
296
|
+
if source["id"] in changed_ids:
|
|
297
|
+
raise ValueError(f"Fork source document {source['id']} is changed in the copy batch")
|
|
298
|
+
stored = self._materialize_document(source["id"], source["at"])
|
|
299
|
+
if stored is None:
|
|
300
|
+
raise ValueError(f"Fork source document {source['id']} cannot be read")
|
|
301
|
+
source_record = stored["record"]
|
|
302
|
+
record = write["record"]
|
|
303
|
+
if (
|
|
304
|
+
source_record["scope"]["kind"] != "conversation"
|
|
305
|
+
or record["scope"]["kind"] != "conversation"
|
|
306
|
+
or source_record["kind"] != record["kind"]
|
|
307
|
+
or source_record.get("key") != record.get("key")
|
|
308
|
+
or source_record.get("history") != record.get("history")
|
|
309
|
+
or source_record.get("fork") != record.get("fork")
|
|
310
|
+
):
|
|
311
|
+
raise ValueError(f"Fork source document {source['id']} does not match the copied record")
|
|
312
|
+
resolved.append(
|
|
313
|
+
cast(
|
|
314
|
+
StorageWrite,
|
|
315
|
+
{
|
|
316
|
+
"type": "document.create",
|
|
317
|
+
"record": record,
|
|
318
|
+
"content": {
|
|
319
|
+
"kind": "base",
|
|
320
|
+
"version": stored["version"],
|
|
321
|
+
"value": stored["value"],
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
except Exception as error:
|
|
327
|
+
raise ValueError(f"Document copy {write['record']['id']} was rejected") from error
|
|
328
|
+
return resolved
|
|
329
|
+
|
|
330
|
+
def _check_global_ids(self, writes: Sequence[StorageWrite]) -> None:
|
|
331
|
+
claimed: dict[int, str] = {}
|
|
332
|
+
for write in writes:
|
|
333
|
+
if write["type"] in ("document.change", "document.retire"):
|
|
334
|
+
continue
|
|
335
|
+
if write["type"] in ("document.create", "document.copy"):
|
|
336
|
+
table = "document"
|
|
337
|
+
id = write["record"]["id"]
|
|
338
|
+
else:
|
|
339
|
+
table = write["type"]
|
|
340
|
+
id = write["value"]["id"]
|
|
341
|
+
existing = self._state.record_types.get(id)
|
|
342
|
+
earlier = claimed.get(id)
|
|
343
|
+
if table in ("conversation", "entry", "document"):
|
|
344
|
+
if existing is not None:
|
|
345
|
+
raise ValueError(f"ID {id} already belongs to {existing}")
|
|
346
|
+
if earlier is not None:
|
|
347
|
+
raise ValueError(f"ID {id} is written more than once")
|
|
348
|
+
else:
|
|
349
|
+
if existing is not None and existing != table:
|
|
350
|
+
raise ValueError(f"ID {id} already belongs to {existing}")
|
|
351
|
+
if earlier is not None and earlier != table:
|
|
352
|
+
raise ValueError(f"ID {id} is written as two record types")
|
|
353
|
+
claimed[id] = table
|
|
354
|
+
|
|
355
|
+
def _prepare_document_actions(self, writes: Sequence[StorageWrite]) -> dict[DocumentId, DocumentAction]:
|
|
356
|
+
actions: dict[DocumentId, DocumentAction] = {}
|
|
357
|
+
for write in writes:
|
|
358
|
+
if write["type"] not in ("document.create", "document.change", "document.retire"):
|
|
359
|
+
continue
|
|
360
|
+
id = write["record"]["id"] if write["type"] == "document.create" else write["id"]
|
|
361
|
+
action = actions.setdefault(id, DocumentAction())
|
|
362
|
+
if write["type"] == "document.create":
|
|
363
|
+
if action.create is not None or action.content is not None:
|
|
364
|
+
raise ValueError(f"Document {id} has more than one content command")
|
|
365
|
+
action.create = write["record"]
|
|
366
|
+
action.content = write["content"]
|
|
367
|
+
elif write["type"] == "document.change":
|
|
368
|
+
if action.content is not None:
|
|
369
|
+
raise ValueError(f"Document {id} has more than one content command")
|
|
370
|
+
action.content = write["content"]
|
|
371
|
+
else:
|
|
372
|
+
if action.retire:
|
|
373
|
+
raise ValueError(f"Document {id} is retired more than once")
|
|
374
|
+
action.retire = True
|
|
375
|
+
return actions
|
|
376
|
+
|
|
377
|
+
def _check_document_actions(self, actions: dict[DocumentId, DocumentAction]) -> None:
|
|
378
|
+
live_counts: dict[str, int] = {}
|
|
379
|
+
for id, action in actions.items():
|
|
380
|
+
existing = self._state.documents.get(id)
|
|
381
|
+
if action.create is None and existing is None:
|
|
382
|
+
raise ValueError(f"Unknown document: {id}")
|
|
383
|
+
if action.create is not None and existing is not None:
|
|
384
|
+
raise ValueError(f"Document {id} already exists")
|
|
385
|
+
if existing is not None and "retiredAt" in existing.record:
|
|
386
|
+
raise ValueError(f"Document {id} is retired")
|
|
387
|
+
previous = existing.revisions[-1] if existing is not None and existing.revisions else None
|
|
388
|
+
if action.content is not None and action.content["kind"] == "delta":
|
|
389
|
+
if previous is None:
|
|
390
|
+
raise ValueError(f"Document {id} delta has no base")
|
|
391
|
+
if previous.content.get("version") != action.content["version"]:
|
|
392
|
+
raise ValueError(f"Document {id} version transition requires a base")
|
|
393
|
+
key = _record_address_key(action.create if action.create is not None else existing.record)
|
|
394
|
+
current_id = self._state.document_addresses.get(key, DocumentAddressIndex()).current_id
|
|
395
|
+
live = live_counts.get(key)
|
|
396
|
+
if live is None:
|
|
397
|
+
live = 0 if current_id is None else 1
|
|
398
|
+
if action.retire and current_id == id:
|
|
399
|
+
live -= 1
|
|
400
|
+
if action.create is not None and not action.retire:
|
|
401
|
+
live += 1
|
|
402
|
+
live_counts[key] = live
|
|
403
|
+
for live in live_counts.values():
|
|
404
|
+
if live > 1:
|
|
405
|
+
raise ValueError("Document address already has a current incarnation")
|
|
406
|
+
|
|
407
|
+
# ── Application ─────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
def _apply_prepared_commit(
|
|
410
|
+
self,
|
|
411
|
+
writes: Sequence[StorageWrite],
|
|
412
|
+
document_actions: dict[DocumentId, DocumentAction],
|
|
413
|
+
seq: Seq,
|
|
414
|
+
) -> None:
|
|
415
|
+
state = self._state
|
|
416
|
+
for write in writes:
|
|
417
|
+
item = cast(dict[str, Any], write)
|
|
418
|
+
kind = item["type"]
|
|
419
|
+
if kind == "conversation":
|
|
420
|
+
value = item["value"]
|
|
421
|
+
state.record_types[value["id"]] = "conversation"
|
|
422
|
+
state.conversations[value["id"]] = value
|
|
423
|
+
_insert_sorted(state.conversation_ids, value["id"])
|
|
424
|
+
owner = value.get("owner")
|
|
425
|
+
if owner is not None:
|
|
426
|
+
_insert_map_id(
|
|
427
|
+
state.conversation_ids_by_owner_conversation,
|
|
428
|
+
owner["conversationId"],
|
|
429
|
+
value["id"],
|
|
430
|
+
)
|
|
431
|
+
_insert_map_id(state.conversation_ids_by_owner_task, owner["taskId"], value["id"])
|
|
432
|
+
self._next_id = max(self._next_id, value["id"] + 1)
|
|
433
|
+
elif kind == "entry":
|
|
434
|
+
value = item["value"]
|
|
435
|
+
state.record_types[value["id"]] = "entry"
|
|
436
|
+
state.entries[value["id"]] = value
|
|
437
|
+
state.entry_commit_seqs[value["id"]] = seq
|
|
438
|
+
ids = state.entry_ids.setdefault(value["conversationId"], [])
|
|
439
|
+
_insert_sorted(ids, value["id"])
|
|
440
|
+
if "head" in value:
|
|
441
|
+
heads = state.head_entry_ids.setdefault(value["conversationId"], [])
|
|
442
|
+
_insert_sorted(heads, value["id"])
|
|
443
|
+
self._next_id = max(self._next_id, value["id"] + 1)
|
|
444
|
+
elif kind == "task":
|
|
445
|
+
value = item["value"]
|
|
446
|
+
state.record_types[value["id"]] = "task"
|
|
447
|
+
previous = state.tasks.get(value["id"])
|
|
448
|
+
if previous is None:
|
|
449
|
+
_insert_sorted(state.task_ids, value["id"])
|
|
450
|
+
_insert_sorted(state.task_ids_by_status[value["state"]["status"]], value["id"])
|
|
451
|
+
elif previous["state"]["status"] != value["state"]["status"]:
|
|
452
|
+
_remove_sorted(state.task_ids_by_status[previous["state"]["status"]], value["id"])
|
|
453
|
+
_insert_sorted(state.task_ids_by_status[value["state"]["status"]], value["id"])
|
|
454
|
+
state.tasks[value["id"]] = value
|
|
455
|
+
self._next_id = max(self._next_id, value["id"] + 1)
|
|
456
|
+
elif kind == "submission":
|
|
457
|
+
value = item["value"]
|
|
458
|
+
state.record_types[value["id"]] = "submission"
|
|
459
|
+
previous = state.submissions.get(value["id"])
|
|
460
|
+
if previous is None:
|
|
461
|
+
_insert_sorted(state.submission_ids, value["id"])
|
|
462
|
+
_insert_sorted(state.submission_ids_by_status[value["status"]], value["id"])
|
|
463
|
+
elif previous["status"] != value["status"]:
|
|
464
|
+
_remove_sorted(state.submission_ids_by_status[previous["status"]], value["id"])
|
|
465
|
+
_insert_sorted(state.submission_ids_by_status[value["status"]], value["id"])
|
|
466
|
+
previous_request = previous.get("requestId") if previous is not None else None
|
|
467
|
+
if previous_request is not None:
|
|
468
|
+
requests = state.submission_ids_by_request.get(previous["conversationId"])
|
|
469
|
+
if requests is not None and requests.get(previous_request) == value["id"]:
|
|
470
|
+
requests.pop(previous_request, None)
|
|
471
|
+
if not requests:
|
|
472
|
+
state.submission_ids_by_request.pop(previous["conversationId"], None)
|
|
473
|
+
state.submissions[value["id"]] = value
|
|
474
|
+
request = value.get("requestId")
|
|
475
|
+
if request is not None:
|
|
476
|
+
requests = state.submission_ids_by_request.setdefault(value["conversationId"], {})
|
|
477
|
+
requests[request] = value["id"]
|
|
478
|
+
self._next_id = max(self._next_id, value["id"] + 1)
|
|
479
|
+
elif kind == "document.copy":
|
|
480
|
+
raise ValueError("Prepared document copy was not resolved")
|
|
481
|
+
self._apply_document_actions(document_actions, seq)
|
|
482
|
+
self._next_seq = seq + 1
|
|
483
|
+
|
|
484
|
+
def _apply_document_actions(self, actions: dict[DocumentId, DocumentAction], seq: Seq) -> None:
|
|
485
|
+
state = self._state
|
|
486
|
+
for id, action in actions.items():
|
|
487
|
+
stored = state.documents.get(id)
|
|
488
|
+
if action.create is not None:
|
|
489
|
+
record: DocumentRecord = cast(
|
|
490
|
+
DocumentRecord,
|
|
491
|
+
{
|
|
492
|
+
**action.create,
|
|
493
|
+
"createdAt": seq,
|
|
494
|
+
**({"retiredAt": seq} if action.retire else {}),
|
|
495
|
+
},
|
|
496
|
+
)
|
|
497
|
+
assert action.content is not None
|
|
498
|
+
stored = StoredDocumentState(record, [DocumentRevision(seq, action.content)])
|
|
499
|
+
state.record_types[id] = "document"
|
|
500
|
+
state.documents[id] = stored
|
|
501
|
+
key = _record_address_key(record)
|
|
502
|
+
address = state.document_addresses.setdefault(key, DocumentAddressIndex())
|
|
503
|
+
_insert_sorted(address.ids, id)
|
|
504
|
+
scope_ids = state.document_ids_by_scope.setdefault(_scope_key(record["scope"]), [])
|
|
505
|
+
_insert_sorted(scope_ids, id)
|
|
506
|
+
self._next_id = max(self._next_id, id + 1)
|
|
507
|
+
elif action.content is not None and stored is not None:
|
|
508
|
+
revision = DocumentRevision(seq, action.content)
|
|
509
|
+
if action.content["kind"] == "base" and _is_current_only(stored.record):
|
|
510
|
+
stored.revisions = [revision]
|
|
511
|
+
else:
|
|
512
|
+
stored.revisions.append(revision)
|
|
513
|
+
if action.retire and action.create is None and stored is not None:
|
|
514
|
+
stored.record = cast(DocumentRecord, {**stored.record, "retiredAt": seq})
|
|
515
|
+
if action.retire and stored is not None and _is_current_only(stored.record):
|
|
516
|
+
stored.revisions = []
|
|
517
|
+
if stored is not None and (action.create is not None or action.retire):
|
|
518
|
+
address = state.document_addresses[_record_address_key(stored.record)]
|
|
519
|
+
if action.retire and address.current_id == id:
|
|
520
|
+
address.current_id = None
|
|
521
|
+
if action.create is not None and not action.retire:
|
|
522
|
+
address.current_id = id
|
|
523
|
+
|
|
524
|
+
# ── Reads ───────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
async def mint_id(self) -> int:
|
|
527
|
+
self._assert_open()
|
|
528
|
+
if not _is_safe_int(self._next_id):
|
|
529
|
+
raise ValueError("ID space is exhausted")
|
|
530
|
+
value = self._next_id
|
|
531
|
+
self._next_id += 1
|
|
532
|
+
return value
|
|
533
|
+
|
|
534
|
+
async def conversation(self, id: ConversationId, context: Context) -> ConversationRecord | None:
|
|
535
|
+
self._assert_open()
|
|
536
|
+
value = self._state.conversations.get(id)
|
|
537
|
+
return None if value is None else cast(ConversationRecord, copy_json(value))
|
|
538
|
+
|
|
539
|
+
async def scan_conversations(
|
|
540
|
+
self,
|
|
541
|
+
query: ConversationQuery,
|
|
542
|
+
limit: int,
|
|
543
|
+
cursor: Cursor | None,
|
|
544
|
+
context: Context,
|
|
545
|
+
) -> Page[ConversationRecord, Cursor]:
|
|
546
|
+
self._assert_open()
|
|
547
|
+
owner_task = query.get("ownerTaskId")
|
|
548
|
+
owner_conversation = query.get("ownerConversationId")
|
|
549
|
+
if owner_task is not None:
|
|
550
|
+
ids: Sequence[ConversationId] = self._state.conversation_ids_by_owner_task.get(owner_task, [])
|
|
551
|
+
elif owner_conversation is not None:
|
|
552
|
+
ids = self._state.conversation_ids_by_owner_conversation.get(owner_conversation, [])
|
|
553
|
+
else:
|
|
554
|
+
ids = self._state.conversation_ids
|
|
555
|
+
start = scan_start(query.get("order"), cursor, "ascending")
|
|
556
|
+
values: list[ConversationRecord] = []
|
|
557
|
+
for index in _scan_indexes(ids, start.order, start.after):
|
|
558
|
+
if len(values) > limit:
|
|
559
|
+
break
|
|
560
|
+
value = self._state.conversations[ids[index]]
|
|
561
|
+
if owner_conversation is not None:
|
|
562
|
+
owner = value.get("owner")
|
|
563
|
+
if owner is None or owner["conversationId"] != owner_conversation:
|
|
564
|
+
continue
|
|
565
|
+
values.append(value)
|
|
566
|
+
return _page(values, limit, start.order)
|
|
567
|
+
|
|
568
|
+
@overload
|
|
569
|
+
async def entry(self, id: EntryId, context: Context, /) -> EntryLookup | None: ...
|
|
570
|
+
|
|
571
|
+
@overload
|
|
572
|
+
async def entry(
|
|
573
|
+
self,
|
|
574
|
+
conversation_id: ConversationId,
|
|
575
|
+
id: EntryId,
|
|
576
|
+
context: Context,
|
|
577
|
+
/,
|
|
578
|
+
) -> EntryLookup | None: ...
|
|
579
|
+
|
|
580
|
+
async def entry(
|
|
581
|
+
self,
|
|
582
|
+
id_or_conversation_id: EntryId | ConversationId | int,
|
|
583
|
+
id_or_context: EntryId | Context | None = None,
|
|
584
|
+
context: Context | None = None,
|
|
585
|
+
) -> EntryLookup | None:
|
|
586
|
+
self._assert_open()
|
|
587
|
+
if context is None:
|
|
588
|
+
id = id_from_number(id_or_conversation_id)
|
|
589
|
+
entry = self._state.entries.get(id)
|
|
590
|
+
if entry is None:
|
|
591
|
+
return None
|
|
592
|
+
return {
|
|
593
|
+
"entry": cast(EntryRecord, copy_json(entry)),
|
|
594
|
+
"commitSeq": self._state.entry_commit_seqs[id],
|
|
595
|
+
}
|
|
596
|
+
if not _is_safe_int(id_or_context):
|
|
597
|
+
raise StorageRequestError("Storage.entry() requires an entry ID")
|
|
598
|
+
conversation_id = id_from_number(id_or_conversation_id)
|
|
599
|
+
id = id_from_number(id_or_context)
|
|
600
|
+
entry = next(self._visible_entries(conversation_id, id, id), None)
|
|
601
|
+
if entry is None:
|
|
602
|
+
return None
|
|
603
|
+
return {"entry": cast(EntryRecord, copy_json(entry)), "commitSeq": self._state.entry_commit_seqs[id]}
|
|
604
|
+
|
|
605
|
+
async def find_latest_head_marker(
|
|
606
|
+
self,
|
|
607
|
+
conversation_id: ConversationId,
|
|
608
|
+
at_or_before_entry_id: EntryId | None,
|
|
609
|
+
context: Context,
|
|
610
|
+
) -> EntryWithHead | None:
|
|
611
|
+
self._assert_open()
|
|
612
|
+
if conversation_id not in self._state.conversations:
|
|
613
|
+
raise StorageRequestError(f"Unknown conversation: {conversation_id}")
|
|
614
|
+
current_id = conversation_id
|
|
615
|
+
upper = _MAX_SAFE_INTEGER if at_or_before_entry_id is None else at_or_before_entry_id
|
|
616
|
+
while True:
|
|
617
|
+
ids = self._state.head_entry_ids.get(current_id, [])
|
|
618
|
+
index = _upper_bound(ids, upper) - 1
|
|
619
|
+
if index >= 0:
|
|
620
|
+
entry = self._state.entries[ids[index]]
|
|
621
|
+
value = dict(entry)
|
|
622
|
+
value["head"] = entry["head"]
|
|
623
|
+
return cast(EntryWithHead, copy_json(value))
|
|
624
|
+
conversation = self._state.conversations[current_id]
|
|
625
|
+
parent = conversation.get("parent")
|
|
626
|
+
if parent is None:
|
|
627
|
+
return None
|
|
628
|
+
upper = min(upper, parent["at"])
|
|
629
|
+
current_id = parent["conversationId"]
|
|
630
|
+
|
|
631
|
+
async def scan_entries(
|
|
632
|
+
self,
|
|
633
|
+
query: EntryQuery,
|
|
634
|
+
limit: int,
|
|
635
|
+
cursor: Cursor | None,
|
|
636
|
+
context: Context,
|
|
637
|
+
) -> Page[EntryRecord, Cursor]:
|
|
638
|
+
self._assert_open()
|
|
639
|
+
start = scan_start(query.get("order"), cursor, "descending")
|
|
640
|
+
min_entry_id: int | None = query.get("minEntryId")
|
|
641
|
+
max_entry_id: int | None = query.get("maxEntryId")
|
|
642
|
+
if start.after is not None and start.order == "descending":
|
|
643
|
+
max_entry_id = min(max_entry_id if max_entry_id is not None else start.after, start.after - 1)
|
|
644
|
+
if start.after is not None and start.order == "ascending":
|
|
645
|
+
min_entry_id = max(min_entry_id if min_entry_id is not None else start.after, start.after + 1)
|
|
646
|
+
visible: list[EntryRecord] = []
|
|
647
|
+
entries = (
|
|
648
|
+
self._visible_entries(query["conversationId"], min_entry_id, max_entry_id)
|
|
649
|
+
if start.order == "descending"
|
|
650
|
+
else self._visible_entries_ascending(query["conversationId"], min_entry_id, max_entry_id)
|
|
651
|
+
)
|
|
652
|
+
for entry in entries:
|
|
653
|
+
visible.append(entry)
|
|
654
|
+
if len(visible) > limit:
|
|
655
|
+
break
|
|
656
|
+
return _page(visible, limit, start.order)
|
|
657
|
+
|
|
658
|
+
async def task(self, id: TaskId, context: Context) -> TaskRecord | None:
|
|
659
|
+
self._assert_open()
|
|
660
|
+
value = self._state.tasks.get(id)
|
|
661
|
+
return None if value is None else cast(TaskRecord, copy_json(value))
|
|
662
|
+
|
|
663
|
+
async def scan_tasks(
|
|
664
|
+
self,
|
|
665
|
+
query: TaskQuery,
|
|
666
|
+
limit: int,
|
|
667
|
+
cursor: Cursor | None,
|
|
668
|
+
context: Context,
|
|
669
|
+
) -> Page[TaskRecord, Cursor]:
|
|
670
|
+
self._assert_open()
|
|
671
|
+
start = scan_start(query.get("order"), cursor, "ascending")
|
|
672
|
+
status = query.get("status")
|
|
673
|
+
ids: Sequence[TaskId] = (
|
|
674
|
+
self._state.task_ids if status is None else self._state.task_ids_by_status[status]
|
|
675
|
+
)
|
|
676
|
+
values: list[TaskRecord] = []
|
|
677
|
+
for index in _scan_indexes(ids, start.order, start.after):
|
|
678
|
+
if len(values) > limit:
|
|
679
|
+
break
|
|
680
|
+
value = self._state.tasks[ids[index]]
|
|
681
|
+
if "conversationId" in query and value["conversationId"] != query["conversationId"]:
|
|
682
|
+
continue
|
|
683
|
+
if "kind" in query and value["kind"] != query["kind"]:
|
|
684
|
+
continue
|
|
685
|
+
if "abortRequested" in query and value["abortRequested"] != query["abortRequested"]:
|
|
686
|
+
continue
|
|
687
|
+
if "background" in query and value["background"] != query["background"]:
|
|
688
|
+
continue
|
|
689
|
+
values.append(value)
|
|
690
|
+
return _page(values, limit, start.order)
|
|
691
|
+
|
|
692
|
+
async def submission(self, id: SubmissionId, context: Context) -> SubmissionRecord | None:
|
|
693
|
+
self._assert_open()
|
|
694
|
+
value = self._state.submissions.get(id)
|
|
695
|
+
return None if value is None else cast(SubmissionRecord, copy_json(value))
|
|
696
|
+
|
|
697
|
+
async def scan_submissions(
|
|
698
|
+
self,
|
|
699
|
+
query: SubmissionQuery,
|
|
700
|
+
limit: int,
|
|
701
|
+
cursor: Cursor | None,
|
|
702
|
+
context: Context,
|
|
703
|
+
) -> Page[SubmissionRecord, Cursor]:
|
|
704
|
+
self._assert_open()
|
|
705
|
+
start = scan_start(query.get("order"), cursor, "ascending")
|
|
706
|
+
status = query.get("status")
|
|
707
|
+
ids: Sequence[SubmissionId] = (
|
|
708
|
+
self._state.submission_ids
|
|
709
|
+
if status is None
|
|
710
|
+
else self._state.submission_ids_by_status[status]
|
|
711
|
+
)
|
|
712
|
+
values: list[SubmissionRecord] = []
|
|
713
|
+
for index in _scan_indexes(ids, start.order, start.after):
|
|
714
|
+
if len(values) > limit:
|
|
715
|
+
break
|
|
716
|
+
value = self._state.submissions[ids[index]]
|
|
717
|
+
if "conversationId" in query and value["conversationId"] != query["conversationId"]:
|
|
718
|
+
continue
|
|
719
|
+
values.append(value)
|
|
720
|
+
return _page(values, limit, start.order)
|
|
721
|
+
|
|
722
|
+
async def submission_by_request(
|
|
723
|
+
self,
|
|
724
|
+
conversation_id: ConversationId,
|
|
725
|
+
request_id: str,
|
|
726
|
+
context: Context,
|
|
727
|
+
) -> SubmissionRecord | None:
|
|
728
|
+
self._assert_open()
|
|
729
|
+
id = self._state.submission_ids_by_request.get(conversation_id, {}).get(request_id)
|
|
730
|
+
if id is None:
|
|
731
|
+
return None
|
|
732
|
+
return cast(SubmissionRecord, copy_json(self._state.submissions[id]))
|
|
733
|
+
|
|
734
|
+
async def find_document(
|
|
735
|
+
self,
|
|
736
|
+
address: DocumentAddress,
|
|
737
|
+
at: DocumentPoint,
|
|
738
|
+
context: Context,
|
|
739
|
+
) -> DocumentRecord | None:
|
|
740
|
+
self._assert_open()
|
|
741
|
+
index = self._state.document_addresses.get(_address_key(address))
|
|
742
|
+
if at == "current":
|
|
743
|
+
if index is None or index.current_id is None:
|
|
744
|
+
return None
|
|
745
|
+
return cast(DocumentRecord, copy_json(self._state.documents[index.current_id].record))
|
|
746
|
+
ids = index.ids if index is not None else []
|
|
747
|
+
for id in ids:
|
|
748
|
+
record = self._state.documents[id].record
|
|
749
|
+
if _is_alive_at(record, at):
|
|
750
|
+
return cast(DocumentRecord, copy_json(record))
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
async def document(self, id: DocumentId, at: DocumentPoint, context: Context) -> StoredDocument | None:
|
|
754
|
+
self._assert_open()
|
|
755
|
+
stored = self._materialize_document(id, at)
|
|
756
|
+
if stored is None:
|
|
757
|
+
return None
|
|
758
|
+
return {
|
|
759
|
+
"record": cast(DocumentRecord, copy_json(stored["record"])),
|
|
760
|
+
"version": stored["version"],
|
|
761
|
+
"value": cast(JsonObject, copy_json(stored["value"])),
|
|
762
|
+
"deltasSinceBase": stored["deltasSinceBase"],
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async def scan_documents(
|
|
766
|
+
self,
|
|
767
|
+
query: DocumentQuery,
|
|
768
|
+
limit: int,
|
|
769
|
+
cursor: Cursor | None,
|
|
770
|
+
context: Context,
|
|
771
|
+
) -> Page[DocumentRecord, Cursor]:
|
|
772
|
+
self._assert_open()
|
|
773
|
+
ids = self._state.document_ids_by_scope.get(_scope_key(query["scope"]), [])
|
|
774
|
+
after = _cursor_id(cursor)
|
|
775
|
+
start = 0 if after is None else _upper_bound(ids, after)
|
|
776
|
+
values: list[DocumentRecord] = []
|
|
777
|
+
for index in range(start, len(ids)):
|
|
778
|
+
if len(values) > limit:
|
|
779
|
+
break
|
|
780
|
+
record = self._state.documents[ids[index]].record
|
|
781
|
+
if "kind" in query and record["kind"] != query["kind"]:
|
|
782
|
+
continue
|
|
783
|
+
if _is_alive_at(record, query["at"]):
|
|
784
|
+
values.append(record)
|
|
785
|
+
return _page(values, limit, "ascending")
|
|
786
|
+
|
|
787
|
+
async def close(self, context: Context) -> None:
|
|
788
|
+
self._closed = True
|
|
789
|
+
|
|
790
|
+
# ── Internals ───────────────────────────────────────────────────────────
|
|
791
|
+
|
|
792
|
+
def _materialize_document(self, id: DocumentId, at: DocumentPoint) -> StoredDocument | None:
|
|
793
|
+
stored = self._state.documents.get(id)
|
|
794
|
+
if stored is None:
|
|
795
|
+
return None
|
|
796
|
+
if at != "current" and _is_current_only(stored.record):
|
|
797
|
+
raise StorageRequestError(f"Document {id} does not retain historical content")
|
|
798
|
+
if not _is_alive_at(stored.record, at):
|
|
799
|
+
return None
|
|
800
|
+
revisions = (
|
|
801
|
+
stored.revisions
|
|
802
|
+
if at == "current"
|
|
803
|
+
else [revision for revision in stored.revisions if revision.seq <= at]
|
|
804
|
+
)
|
|
805
|
+
base_index = len(revisions) - 1
|
|
806
|
+
while base_index >= 0 and revisions[base_index].content["kind"] != "base":
|
|
807
|
+
base_index -= 1
|
|
808
|
+
if base_index < 0:
|
|
809
|
+
raise ValueError(f"Document {id} is missing a required base")
|
|
810
|
+
base = revisions[base_index].content
|
|
811
|
+
assert base["kind"] == "base"
|
|
812
|
+
version = base["version"]
|
|
813
|
+
batches: list[Sequence[Op]] = []
|
|
814
|
+
for revision in revisions[base_index + 1:]:
|
|
815
|
+
content = revision.content
|
|
816
|
+
if content["kind"] != "delta" or content["version"] != version:
|
|
817
|
+
raise ValueError(f"Document {id} crosses a stored version boundary without a base")
|
|
818
|
+
batches.append(content["ops"])
|
|
819
|
+
value = apply_immutable_batches(cast(JsonValue, base["value"]), batches)
|
|
820
|
+
return {
|
|
821
|
+
"record": stored.record,
|
|
822
|
+
"version": version,
|
|
823
|
+
"value": cast(JsonObject, value),
|
|
824
|
+
"deltasSinceBase": len(revisions) - base_index - 1,
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
def _visible_entries(
|
|
828
|
+
self,
|
|
829
|
+
conversation_id: ConversationId,
|
|
830
|
+
min_entry_id: int | None = None,
|
|
831
|
+
max_entry_id: int | None = None,
|
|
832
|
+
) -> Iterable[EntryRecord]:
|
|
833
|
+
if conversation_id not in self._state.conversations:
|
|
834
|
+
raise StorageRequestError(f"Unknown conversation: {conversation_id}")
|
|
835
|
+
minimum = -(2**63) if min_entry_id is None else min_entry_id
|
|
836
|
+
upper = 2**63 - 1 if max_entry_id is None else max_entry_id
|
|
837
|
+
current_id = conversation_id
|
|
838
|
+
while True:
|
|
839
|
+
ids = self._state.entry_ids.get(current_id, [])
|
|
840
|
+
for index in range(_upper_bound(ids, upper) - 1, -1, -1):
|
|
841
|
+
id = ids[index]
|
|
842
|
+
if id < minimum:
|
|
843
|
+
break
|
|
844
|
+
yield self._state.entries[id]
|
|
845
|
+
conversation = self._state.conversations[current_id]
|
|
846
|
+
parent = conversation.get("parent")
|
|
847
|
+
if parent is None:
|
|
848
|
+
break
|
|
849
|
+
upper = min(upper, parent["at"])
|
|
850
|
+
if upper < minimum:
|
|
851
|
+
break
|
|
852
|
+
current_id = parent["conversationId"]
|
|
853
|
+
|
|
854
|
+
def _visible_entries_ascending(
|
|
855
|
+
self,
|
|
856
|
+
conversation_id: ConversationId,
|
|
857
|
+
min_entry_id: int | None = None,
|
|
858
|
+
max_entry_id: int | None = None,
|
|
859
|
+
) -> Iterable[EntryRecord]:
|
|
860
|
+
if conversation_id not in self._state.conversations:
|
|
861
|
+
raise StorageRequestError(f"Unknown conversation: {conversation_id}")
|
|
862
|
+
minimum = -(2**63) if min_entry_id is None else min_entry_id
|
|
863
|
+
upper = 2**63 - 1 if max_entry_id is None else max_entry_id
|
|
864
|
+
segments: list[tuple[ConversationId, int]] = []
|
|
865
|
+
current_id = conversation_id
|
|
866
|
+
while True:
|
|
867
|
+
segments.append((current_id, upper))
|
|
868
|
+
conversation = self._state.conversations[current_id]
|
|
869
|
+
parent = conversation.get("parent")
|
|
870
|
+
if parent is None:
|
|
871
|
+
break
|
|
872
|
+
upper = min(upper, parent["at"])
|
|
873
|
+
if upper < minimum:
|
|
874
|
+
break
|
|
875
|
+
current_id = parent["conversationId"]
|
|
876
|
+
for segment_id, segment_upper in reversed(segments):
|
|
877
|
+
ids = self._state.entry_ids.get(segment_id, [])
|
|
878
|
+
for index in range(_lower_bound(ids, minimum), len(ids)):
|
|
879
|
+
id = ids[index]
|
|
880
|
+
if id > segment_upper:
|
|
881
|
+
break
|
|
882
|
+
yield self._state.entries[id]
|
|
883
|
+
|
|
884
|
+
def _assert_open(self) -> None:
|
|
885
|
+
if self._closed:
|
|
886
|
+
raise RuntimeError("MemoryStorage is closed")
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
__all__ = ["MemoryStorage", "PreparedMemoryCommit"]
|