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,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"]
|