superlocalmemory 3.8.3 → 3.8.6
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.
- package/CHANGELOG.md +76 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com
|
|
4
|
+
|
|
5
|
+
"""Separate FULL-synchronous journal for durable remember admission.
|
|
6
|
+
|
|
7
|
+
The journal is intentionally not a memory store. Its only mutable state is a
|
|
8
|
+
small encrypted replay command and the canonical receipt associated with it.
|
|
9
|
+
Canonical facts, FTS, graph, vectors, and model work stay outside this module.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Generator, Protocol
|
|
28
|
+
|
|
29
|
+
from cryptography.exceptions import InvalidTag
|
|
30
|
+
|
|
31
|
+
_MAX_COMMAND_BYTES = 256 * 1024
|
|
32
|
+
_MAX_RECEIPT_BYTES = 16 * 1024
|
|
33
|
+
_MAX_METADATA_DEPTH = 8
|
|
34
|
+
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
|
|
35
|
+
_STATES = frozenset({"prepared", "dispatched", "committed", "rejected"})
|
|
36
|
+
|
|
37
|
+
_JOURNAL_DDL = """
|
|
38
|
+
CREATE TABLE IF NOT EXISTS admission_journal (
|
|
39
|
+
journal_id TEXT PRIMARY KEY,
|
|
40
|
+
idempotency_key TEXT NOT NULL,
|
|
41
|
+
request_hash TEXT NOT NULL,
|
|
42
|
+
profile_id TEXT NOT NULL,
|
|
43
|
+
command_json TEXT NOT NULL,
|
|
44
|
+
state TEXT NOT NULL CHECK (
|
|
45
|
+
state IN ('prepared','dispatched','committed','rejected')
|
|
46
|
+
),
|
|
47
|
+
canonical_operation_id TEXT,
|
|
48
|
+
canonical_commit_sequence INTEGER,
|
|
49
|
+
error_code TEXT,
|
|
50
|
+
receipt_json TEXT,
|
|
51
|
+
created_at_ms INTEGER NOT NULL,
|
|
52
|
+
updated_at_ms INTEGER NOT NULL,
|
|
53
|
+
UNIQUE(profile_id, idempotency_key)
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_admission_replay
|
|
56
|
+
ON admission_journal(state, updated_at_ms);
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
_JOURNAL_TABLE_DDL = _JOURNAL_DDL.split(";", 1)[0]
|
|
60
|
+
_JOURNAL_REPLAY_INDEX_DDL = (
|
|
61
|
+
"CREATE INDEX IF NOT EXISTS idx_admission_replay "
|
|
62
|
+
"ON admission_journal(state, updated_at_ms)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class IdempotencyConflict(ValueError):
|
|
67
|
+
"""The key belongs to a different immutable remember request."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AdmissionAuthorizationError(PermissionError):
|
|
71
|
+
"""The actor is not entitled to submit the requested profile and scope."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AdmissionPayloadError(ValueError):
|
|
75
|
+
"""The admission body is invalid, oversized, or cannot be encoded safely."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AdmissionJournalUnavailable(RuntimeError):
|
|
79
|
+
"""The durable admission journal could not mutate within its caller budget."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TerminalAdmissionError(RuntimeError):
|
|
83
|
+
"""A replayable command was deterministically rejected after journaling."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, error_code: str = "COMMAND_REJECTED") -> None:
|
|
86
|
+
if not error_code or len(error_code) > 128:
|
|
87
|
+
raise ValueError("error_code is required and bounded")
|
|
88
|
+
super().__init__(error_code)
|
|
89
|
+
self.error_code = error_code
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CommandCodec(Protocol):
|
|
93
|
+
"""Existing product encryption policy injected by the runtime.
|
|
94
|
+
|
|
95
|
+
The journal deliberately owns no key derivation or cryptographic primitive.
|
|
96
|
+
This prevents it from creating a second, incompatible encryption policy.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def encrypt(self, plaintext: bytes) -> bytes: ...
|
|
100
|
+
|
|
101
|
+
def decrypt(self, ciphertext: bytes) -> bytes: ...
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class Actor:
|
|
106
|
+
"""Bounded authorization context supplied by the authenticated boundary."""
|
|
107
|
+
|
|
108
|
+
principal_id: str
|
|
109
|
+
allowed_profiles: frozenset[str]
|
|
110
|
+
allowed_scopes: frozenset[str]
|
|
111
|
+
trusted: bool = True
|
|
112
|
+
|
|
113
|
+
def permits(self, profile_id: str, scope: str) -> bool:
|
|
114
|
+
return self.trusted and profile_id in self.allowed_profiles and scope in self.allowed_scopes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class RememberRequest:
|
|
119
|
+
"""Immutable canonical input for a single remember admission."""
|
|
120
|
+
|
|
121
|
+
content: str
|
|
122
|
+
profile_id: str
|
|
123
|
+
source_type: str
|
|
124
|
+
idempotency_key: str
|
|
125
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
126
|
+
scope: str = "personal"
|
|
127
|
+
shared_with: tuple[str, ...] = ()
|
|
128
|
+
trusted_actor_id: str = ""
|
|
129
|
+
session_id: str = ""
|
|
130
|
+
session_date: str = ""
|
|
131
|
+
speaker: str = ""
|
|
132
|
+
role: str = "user"
|
|
133
|
+
|
|
134
|
+
def __post_init__(self) -> None:
|
|
135
|
+
if not isinstance(self.content, str) or not self.content.strip():
|
|
136
|
+
raise AdmissionPayloadError("content is required")
|
|
137
|
+
for name in ("profile_id", "source_type"):
|
|
138
|
+
if not isinstance(getattr(self, name), str) or not getattr(self, name).strip():
|
|
139
|
+
raise AdmissionPayloadError(f"{name} is required")
|
|
140
|
+
if not isinstance(self.idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch(
|
|
141
|
+
self.idempotency_key
|
|
142
|
+
):
|
|
143
|
+
raise AdmissionPayloadError("idempotency_key must be 1-256 safe characters")
|
|
144
|
+
if self.scope not in {"personal", "project", "shared", "global"}:
|
|
145
|
+
raise AdmissionPayloadError(f"unsupported scope: {self.scope}")
|
|
146
|
+
if not isinstance(self.metadata, Mapping):
|
|
147
|
+
raise AdmissionPayloadError("metadata must be an object")
|
|
148
|
+
metadata = dict(self.metadata)
|
|
149
|
+
_validate_json(metadata, "metadata")
|
|
150
|
+
object.__setattr__(self, "metadata", metadata)
|
|
151
|
+
object.__setattr__(self, "shared_with", tuple(self.shared_with))
|
|
152
|
+
|
|
153
|
+
def canonical_payload(self) -> dict[str, Any]:
|
|
154
|
+
return {
|
|
155
|
+
"content": self.content,
|
|
156
|
+
"idempotency_key": self.idempotency_key,
|
|
157
|
+
"metadata": dict(self.metadata),
|
|
158
|
+
"profile_id": self.profile_id,
|
|
159
|
+
"role": self.role,
|
|
160
|
+
"scope": self.scope,
|
|
161
|
+
"session_date": self.session_date,
|
|
162
|
+
"session_id": self.session_id,
|
|
163
|
+
"shared_with": list(self.shared_with),
|
|
164
|
+
"source_type": self.source_type,
|
|
165
|
+
"speaker": self.speaker,
|
|
166
|
+
"trusted_actor_id": self.trusted_actor_id,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> RememberRequest:
|
|
171
|
+
return cls(
|
|
172
|
+
content=str(payload["content"]),
|
|
173
|
+
profile_id=str(payload["profile_id"]),
|
|
174
|
+
source_type=str(payload["source_type"]),
|
|
175
|
+
idempotency_key=str(payload["idempotency_key"]),
|
|
176
|
+
metadata=dict(payload.get("metadata") or {}),
|
|
177
|
+
scope=str(payload.get("scope") or "personal"),
|
|
178
|
+
shared_with=tuple(payload.get("shared_with") or ()),
|
|
179
|
+
trusted_actor_id=str(payload.get("trusted_actor_id") or ""),
|
|
180
|
+
session_id=str(payload.get("session_id") or ""),
|
|
181
|
+
session_date=str(payload.get("session_date") or ""),
|
|
182
|
+
speaker=str(payload.get("speaker") or ""),
|
|
183
|
+
role=str(payload.get("role") or "user"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class AdmissionEntry:
|
|
189
|
+
"""Content-free journal metadata safe for status and recovery decisions."""
|
|
190
|
+
|
|
191
|
+
journal_id: str
|
|
192
|
+
idempotency_key: str
|
|
193
|
+
request_hash: str
|
|
194
|
+
profile_id: str
|
|
195
|
+
state: str
|
|
196
|
+
canonical_operation_id: str | None
|
|
197
|
+
canonical_commit_sequence: int | None
|
|
198
|
+
error_code: str | None
|
|
199
|
+
created_at_ms: int
|
|
200
|
+
updated_at_ms: int
|
|
201
|
+
original_receipt: dict[str, Any] | None = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
PreparedAdmission = AdmissionEntry
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class AdmissionJournal:
|
|
208
|
+
"""Synchronous idempotency journal stored independently from ``memory.db``."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, path: str | Path, *, codec: CommandCodec) -> None:
|
|
211
|
+
self.path = Path(path)
|
|
212
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
self._codec = codec
|
|
214
|
+
# The daemon is the sole journal owner, but many HTTP/MCP request
|
|
215
|
+
# threads can prepare and transition entries concurrently. SQLite has
|
|
216
|
+
# one writer, so admit those tiny FULL-synchronous transactions through
|
|
217
|
+
# one process-local lock instead of letting BEGIN IMMEDIATE race and
|
|
218
|
+
# leak SQLITE_BUSY to remember callers.
|
|
219
|
+
self._write_lock = threading.RLock()
|
|
220
|
+
self._initialize()
|
|
221
|
+
|
|
222
|
+
def prepare(
|
|
223
|
+
self,
|
|
224
|
+
request: RememberRequest,
|
|
225
|
+
actor: Actor,
|
|
226
|
+
*,
|
|
227
|
+
deadline: float | None = None,
|
|
228
|
+
) -> PreparedAdmission:
|
|
229
|
+
"""Durably prepare one encrypted replay command before dispatch."""
|
|
230
|
+
if not actor.principal_id.strip() or not actor.permits(request.profile_id, request.scope):
|
|
231
|
+
raise AdmissionAuthorizationError(
|
|
232
|
+
"actor is not authorized for requested profile or scope"
|
|
233
|
+
)
|
|
234
|
+
if request.trusted_actor_id and request.trusted_actor_id != actor.principal_id:
|
|
235
|
+
raise AdmissionAuthorizationError(
|
|
236
|
+
"trusted actor does not match authenticated principal"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
payload = request.canonical_payload()
|
|
240
|
+
plaintext = _canonical_bytes(payload)
|
|
241
|
+
if len(plaintext) > _MAX_COMMAND_BYTES:
|
|
242
|
+
raise AdmissionPayloadError("remember command exceeds journal payload limit")
|
|
243
|
+
encrypted = self._codec.encrypt(plaintext)
|
|
244
|
+
if not isinstance(encrypted, bytes) or not encrypted:
|
|
245
|
+
raise AdmissionPayloadError("configured command codec returned no ciphertext")
|
|
246
|
+
command_json = json.dumps({"ciphertext_b64": base64.b64encode(encrypted).decode("ascii")})
|
|
247
|
+
request_hash = hashlib.sha256(plaintext).hexdigest()
|
|
248
|
+
now = _now_ms()
|
|
249
|
+
journal_id = uuid.uuid4().hex
|
|
250
|
+
|
|
251
|
+
with self._write_transaction(deadline=deadline) as conn:
|
|
252
|
+
existing = conn.execute(
|
|
253
|
+
"SELECT * FROM admission_journal "
|
|
254
|
+
"WHERE profile_id=? AND idempotency_key=?",
|
|
255
|
+
(request.profile_id, request.idempotency_key),
|
|
256
|
+
).fetchone()
|
|
257
|
+
if existing is not None:
|
|
258
|
+
entry = self._entry_from_row(existing)
|
|
259
|
+
if entry.request_hash != request_hash:
|
|
260
|
+
raise IdempotencyConflict(
|
|
261
|
+
"idempotency key belongs to a different immutable request"
|
|
262
|
+
)
|
|
263
|
+
return entry
|
|
264
|
+
conn.execute(
|
|
265
|
+
"INSERT INTO admission_journal "
|
|
266
|
+
"(journal_id, idempotency_key, request_hash, profile_id, command_json, state, "
|
|
267
|
+
"created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)",
|
|
268
|
+
(
|
|
269
|
+
journal_id,
|
|
270
|
+
request.idempotency_key,
|
|
271
|
+
request_hash,
|
|
272
|
+
request.profile_id,
|
|
273
|
+
command_json,
|
|
274
|
+
now,
|
|
275
|
+
now,
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
row = conn.execute(
|
|
279
|
+
"SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
|
|
280
|
+
).fetchone()
|
|
281
|
+
assert row is not None
|
|
282
|
+
return self._entry_from_row(row)
|
|
283
|
+
|
|
284
|
+
def request_for(
|
|
285
|
+
self,
|
|
286
|
+
entry: AdmissionEntry,
|
|
287
|
+
*,
|
|
288
|
+
deadline: float | None = None,
|
|
289
|
+
) -> RememberRequest:
|
|
290
|
+
"""Decrypt the minimal replay body only immediately before canonical work."""
|
|
291
|
+
with self._read_connection(deadline=deadline) as conn:
|
|
292
|
+
row = conn.execute(
|
|
293
|
+
"SELECT command_json FROM admission_journal WHERE journal_id=?", (entry.journal_id,)
|
|
294
|
+
).fetchone()
|
|
295
|
+
if row is None:
|
|
296
|
+
raise KeyError(entry.journal_id)
|
|
297
|
+
try:
|
|
298
|
+
encoded = json.loads(str(row["command_json"]))["ciphertext_b64"]
|
|
299
|
+
plaintext = self._codec.decrypt(base64.b64decode(encoded, validate=True))
|
|
300
|
+
payload = json.loads(plaintext.decode("utf-8"))
|
|
301
|
+
except (
|
|
302
|
+
KeyError,
|
|
303
|
+
TypeError,
|
|
304
|
+
UnicodeDecodeError,
|
|
305
|
+
ValueError,
|
|
306
|
+
binascii.Error,
|
|
307
|
+
json.JSONDecodeError,
|
|
308
|
+
InvalidTag,
|
|
309
|
+
) as exc:
|
|
310
|
+
raise AdmissionPayloadError(
|
|
311
|
+
"journal command cannot be decrypted by the configured policy"
|
|
312
|
+
) from exc
|
|
313
|
+
request = RememberRequest.from_payload(payload)
|
|
314
|
+
_remaining_seconds(deadline)
|
|
315
|
+
return request
|
|
316
|
+
|
|
317
|
+
def mark_dispatched(
|
|
318
|
+
self,
|
|
319
|
+
journal_id: str,
|
|
320
|
+
*,
|
|
321
|
+
deadline: float | None = None,
|
|
322
|
+
) -> AdmissionEntry:
|
|
323
|
+
# A concurrent retry may observe ``prepared`` and then lose the race to
|
|
324
|
+
# another caller that commits the same idempotent command. Treat that
|
|
325
|
+
# terminal state as a successful no-op so the retry can return the
|
|
326
|
+
# canonical receipt instead of surfacing a false transition failure.
|
|
327
|
+
return self._transition(
|
|
328
|
+
journal_id,
|
|
329
|
+
target="dispatched",
|
|
330
|
+
allowed={"prepared", "dispatched", "committed"},
|
|
331
|
+
deadline=deadline,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def mark_rejected(
|
|
335
|
+
self,
|
|
336
|
+
journal_id: str,
|
|
337
|
+
error_code: str,
|
|
338
|
+
*,
|
|
339
|
+
deadline: float | None = None,
|
|
340
|
+
) -> AdmissionEntry:
|
|
341
|
+
if not error_code or len(error_code) > 128:
|
|
342
|
+
raise ValueError("error_code is required and bounded")
|
|
343
|
+
return self._transition(
|
|
344
|
+
journal_id,
|
|
345
|
+
target="rejected",
|
|
346
|
+
allowed={"prepared", "dispatched", "rejected"},
|
|
347
|
+
error_code=error_code,
|
|
348
|
+
deadline=deadline,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def mark_committed(
|
|
352
|
+
self,
|
|
353
|
+
journal_id: str,
|
|
354
|
+
receipt: Mapping[str, Any],
|
|
355
|
+
*,
|
|
356
|
+
deadline: float | None = None,
|
|
357
|
+
) -> AdmissionEntry:
|
|
358
|
+
receipt_json = _receipt_json(receipt)
|
|
359
|
+
data = json.loads(receipt_json)
|
|
360
|
+
operation_id = data.get("operation_id")
|
|
361
|
+
commit_sequence = data.get("commit_sequence")
|
|
362
|
+
if operation_id is not None and not isinstance(operation_id, str):
|
|
363
|
+
raise ValueError("receipt operation_id must be a string")
|
|
364
|
+
if commit_sequence is not None and not isinstance(commit_sequence, int):
|
|
365
|
+
raise ValueError("receipt commit_sequence must be an integer")
|
|
366
|
+
return self._transition(
|
|
367
|
+
journal_id,
|
|
368
|
+
target="committed",
|
|
369
|
+
allowed={"prepared", "dispatched", "rejected", "committed"},
|
|
370
|
+
receipt_json=receipt_json,
|
|
371
|
+
operation_id=operation_id,
|
|
372
|
+
commit_sequence=commit_sequence,
|
|
373
|
+
deadline=deadline,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def get(self, journal_id: str) -> AdmissionEntry:
|
|
377
|
+
with self._read_connection() as conn:
|
|
378
|
+
row = conn.execute(
|
|
379
|
+
"SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
|
|
380
|
+
).fetchone()
|
|
381
|
+
if row is None:
|
|
382
|
+
raise KeyError(journal_id)
|
|
383
|
+
return self._entry_from_row(row)
|
|
384
|
+
|
|
385
|
+
def get_by_idempotency_key(
|
|
386
|
+
self, profile_id: str, idempotency_key: str
|
|
387
|
+
) -> AdmissionEntry | None:
|
|
388
|
+
"""Return a profile-scoped retry record, never a cross-profile match."""
|
|
389
|
+
with self._read_connection() as conn:
|
|
390
|
+
row = conn.execute(
|
|
391
|
+
"SELECT * FROM admission_journal WHERE profile_id=? AND idempotency_key=?",
|
|
392
|
+
(profile_id, idempotency_key),
|
|
393
|
+
).fetchone()
|
|
394
|
+
return self._entry_from_row(row) if row is not None else None
|
|
395
|
+
|
|
396
|
+
def count(self) -> int:
|
|
397
|
+
with self._read_connection() as conn:
|
|
398
|
+
return int(conn.execute("SELECT COUNT(*) FROM admission_journal").fetchone()[0])
|
|
399
|
+
|
|
400
|
+
def replay_pending(
|
|
401
|
+
self,
|
|
402
|
+
find_canonical_receipt: Callable[[AdmissionEntry], Mapping[str, Any] | None],
|
|
403
|
+
dispatch: Callable[[AdmissionEntry, RememberRequest], Mapping[str, Any]],
|
|
404
|
+
*,
|
|
405
|
+
profile_id: str | None = None,
|
|
406
|
+
) -> int:
|
|
407
|
+
"""Resolve crash-surviving entries without duplicate canonical writes.
|
|
408
|
+
|
|
409
|
+
A daemon runtime is bound to one active profile at a time. Pending
|
|
410
|
+
commands for other profiles remain durable until that profile is
|
|
411
|
+
rebound; dispatching them through the wrong profile writer would make
|
|
412
|
+
one abandoned command prevent the daemon from starting.
|
|
413
|
+
"""
|
|
414
|
+
with self._read_connection() as conn:
|
|
415
|
+
if profile_id is None:
|
|
416
|
+
rows = conn.execute(
|
|
417
|
+
"SELECT * FROM admission_journal "
|
|
418
|
+
"WHERE state IN ('prepared', 'dispatched') "
|
|
419
|
+
"ORDER BY created_at_ms, journal_id"
|
|
420
|
+
).fetchall()
|
|
421
|
+
else:
|
|
422
|
+
rows = conn.execute(
|
|
423
|
+
"SELECT * FROM admission_journal "
|
|
424
|
+
"WHERE state IN ('prepared', 'dispatched') AND profile_id=? "
|
|
425
|
+
"ORDER BY created_at_ms, journal_id",
|
|
426
|
+
(profile_id,),
|
|
427
|
+
).fetchall()
|
|
428
|
+
recovered = 0
|
|
429
|
+
for row in rows:
|
|
430
|
+
entry = self._entry_from_row(row)
|
|
431
|
+
try:
|
|
432
|
+
canonical = find_canonical_receipt(entry)
|
|
433
|
+
if canonical is None:
|
|
434
|
+
canonical = dispatch(entry, self.request_for(entry))
|
|
435
|
+
except TerminalAdmissionError as exc:
|
|
436
|
+
self.mark_rejected(entry.journal_id, exc.error_code)
|
|
437
|
+
recovered += 1
|
|
438
|
+
continue
|
|
439
|
+
self.mark_committed(entry.journal_id, canonical)
|
|
440
|
+
recovered += 1
|
|
441
|
+
return recovered
|
|
442
|
+
|
|
443
|
+
def _transition(
|
|
444
|
+
self,
|
|
445
|
+
journal_id: str,
|
|
446
|
+
*,
|
|
447
|
+
target: str,
|
|
448
|
+
allowed: set[str],
|
|
449
|
+
error_code: str | None = None,
|
|
450
|
+
receipt_json: str | None = None,
|
|
451
|
+
operation_id: str | None = None,
|
|
452
|
+
commit_sequence: int | None = None,
|
|
453
|
+
deadline: float | None = None,
|
|
454
|
+
) -> AdmissionEntry:
|
|
455
|
+
with self._write_transaction(deadline=deadline) as conn:
|
|
456
|
+
row = conn.execute(
|
|
457
|
+
"SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
|
|
458
|
+
).fetchone()
|
|
459
|
+
if row is None:
|
|
460
|
+
raise KeyError(journal_id)
|
|
461
|
+
previous = self._entry_from_row(row)
|
|
462
|
+
if previous.state not in allowed:
|
|
463
|
+
if previous.state == "committed":
|
|
464
|
+
raise ValueError("journal entry is already committed")
|
|
465
|
+
raise ValueError(f"illegal admission transition {previous.state} -> {target}")
|
|
466
|
+
if previous.state == "committed":
|
|
467
|
+
return previous
|
|
468
|
+
conn.execute(
|
|
469
|
+
"UPDATE admission_journal SET "
|
|
470
|
+
"state=?, "
|
|
471
|
+
"canonical_operation_id=COALESCE(?, canonical_operation_id), "
|
|
472
|
+
"canonical_commit_sequence=COALESCE(?, canonical_commit_sequence), "
|
|
473
|
+
"error_code=?, "
|
|
474
|
+
"receipt_json=COALESCE(?, receipt_json), "
|
|
475
|
+
"updated_at_ms=? WHERE journal_id=? AND state=?",
|
|
476
|
+
(
|
|
477
|
+
target,
|
|
478
|
+
operation_id,
|
|
479
|
+
commit_sequence,
|
|
480
|
+
error_code,
|
|
481
|
+
receipt_json,
|
|
482
|
+
_now_ms(),
|
|
483
|
+
journal_id,
|
|
484
|
+
previous.state,
|
|
485
|
+
),
|
|
486
|
+
)
|
|
487
|
+
updated = conn.execute(
|
|
488
|
+
"SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
|
|
489
|
+
).fetchone()
|
|
490
|
+
assert updated is not None
|
|
491
|
+
return self._entry_from_row(updated)
|
|
492
|
+
|
|
493
|
+
def _initialize(self) -> None:
|
|
494
|
+
with self._connection() as conn:
|
|
495
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
496
|
+
if self._has_legacy_global_idempotency_key(conn):
|
|
497
|
+
self._upgrade_legacy_schema(conn)
|
|
498
|
+
else:
|
|
499
|
+
_create_journal_schema(conn)
|
|
500
|
+
|
|
501
|
+
@contextmanager
|
|
502
|
+
def _write_transaction(
|
|
503
|
+
self,
|
|
504
|
+
*,
|
|
505
|
+
deadline: float | None = None,
|
|
506
|
+
) -> Generator[sqlite3.Connection, None, None]:
|
|
507
|
+
"""Serialize and atomically commit one deadline-bounded mutation."""
|
|
508
|
+
if deadline is None:
|
|
509
|
+
acquired = self._write_lock.acquire()
|
|
510
|
+
else:
|
|
511
|
+
acquired = self._write_lock.acquire(
|
|
512
|
+
timeout=max(0.0, deadline - time.monotonic())
|
|
513
|
+
)
|
|
514
|
+
if not acquired:
|
|
515
|
+
raise AdmissionJournalUnavailable(
|
|
516
|
+
"admission journal deadline expired waiting for its writer"
|
|
517
|
+
)
|
|
518
|
+
try:
|
|
519
|
+
remaining = _remaining_seconds(deadline)
|
|
520
|
+
with self._connection(timeout=remaining) as conn:
|
|
521
|
+
try:
|
|
522
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
523
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
524
|
+
raise AdmissionJournalUnavailable(
|
|
525
|
+
"admission journal deadline expired before its mutation"
|
|
526
|
+
)
|
|
527
|
+
yield conn
|
|
528
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
529
|
+
raise AdmissionJournalUnavailable(
|
|
530
|
+
"admission journal deadline expired during its mutation"
|
|
531
|
+
)
|
|
532
|
+
conn.commit()
|
|
533
|
+
except sqlite3.OperationalError as exc:
|
|
534
|
+
conn.rollback()
|
|
535
|
+
if _is_sqlite_busy(exc):
|
|
536
|
+
raise AdmissionJournalUnavailable(
|
|
537
|
+
"admission journal is busy beyond its caller deadline"
|
|
538
|
+
) from exc
|
|
539
|
+
raise
|
|
540
|
+
except BaseException:
|
|
541
|
+
conn.rollback()
|
|
542
|
+
raise
|
|
543
|
+
finally:
|
|
544
|
+
self._write_lock.release()
|
|
545
|
+
|
|
546
|
+
@contextmanager
|
|
547
|
+
def _read_connection(
|
|
548
|
+
self,
|
|
549
|
+
*,
|
|
550
|
+
deadline: float | None = None,
|
|
551
|
+
) -> Generator[sqlite3.Connection, None, None]:
|
|
552
|
+
"""Open a bounded journal reader without leaking SQLite lock errors."""
|
|
553
|
+
try:
|
|
554
|
+
with self._connection(timeout=_remaining_seconds(deadline)) as conn:
|
|
555
|
+
yield conn
|
|
556
|
+
except sqlite3.OperationalError as exc:
|
|
557
|
+
if _is_sqlite_busy(exc):
|
|
558
|
+
raise AdmissionJournalUnavailable(
|
|
559
|
+
"admission journal is busy beyond its caller deadline"
|
|
560
|
+
) from exc
|
|
561
|
+
raise
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _has_legacy_global_idempotency_key(conn: sqlite3.Connection) -> bool:
|
|
565
|
+
table = conn.execute(
|
|
566
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='admission_journal'"
|
|
567
|
+
).fetchone()
|
|
568
|
+
if table is None:
|
|
569
|
+
return False
|
|
570
|
+
return _has_unique_index(conn, "admission_journal", ("idempotency_key",))
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def _upgrade_legacy_schema(conn: sqlite3.Connection) -> None:
|
|
574
|
+
"""Replace only the provisional global-key table without losing journals."""
|
|
575
|
+
conn.execute("SAVEPOINT admission_journal_profile_key_upgrade")
|
|
576
|
+
try:
|
|
577
|
+
conn.execute("ALTER TABLE admission_journal RENAME TO admission_journal_legacy")
|
|
578
|
+
conn.execute("DROP INDEX IF EXISTS idx_admission_replay")
|
|
579
|
+
_create_journal_schema(conn)
|
|
580
|
+
conn.execute(
|
|
581
|
+
"INSERT INTO admission_journal("
|
|
582
|
+
"journal_id, idempotency_key, request_hash, profile_id, command_json, state, "
|
|
583
|
+
"canonical_operation_id, canonical_commit_sequence, error_code, receipt_json, "
|
|
584
|
+
"created_at_ms, updated_at_ms"
|
|
585
|
+
") SELECT journal_id, idempotency_key, request_hash, profile_id, command_json, "
|
|
586
|
+
"state, canonical_operation_id, canonical_commit_sequence, error_code, "
|
|
587
|
+
"receipt_json, "
|
|
588
|
+
"created_at_ms, updated_at_ms FROM admission_journal_legacy"
|
|
589
|
+
)
|
|
590
|
+
conn.execute("DROP TABLE admission_journal_legacy")
|
|
591
|
+
except BaseException:
|
|
592
|
+
conn.execute("ROLLBACK TO admission_journal_profile_key_upgrade")
|
|
593
|
+
conn.execute("RELEASE admission_journal_profile_key_upgrade")
|
|
594
|
+
raise
|
|
595
|
+
conn.execute("RELEASE admission_journal_profile_key_upgrade")
|
|
596
|
+
|
|
597
|
+
@contextmanager
|
|
598
|
+
def _connection(
|
|
599
|
+
self,
|
|
600
|
+
*,
|
|
601
|
+
timeout: float = 1.0,
|
|
602
|
+
) -> Generator[sqlite3.Connection, None, None]:
|
|
603
|
+
bounded_timeout = max(0.001, timeout)
|
|
604
|
+
busy_timeout_ms = max(1, int(bounded_timeout * 1_000))
|
|
605
|
+
conn = sqlite3.connect(str(self.path), timeout=bounded_timeout)
|
|
606
|
+
try:
|
|
607
|
+
conn.row_factory = sqlite3.Row
|
|
608
|
+
conn.execute("PRAGMA synchronous=FULL")
|
|
609
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
610
|
+
conn.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
|
|
611
|
+
yield conn
|
|
612
|
+
finally:
|
|
613
|
+
conn.close()
|
|
614
|
+
|
|
615
|
+
@staticmethod
|
|
616
|
+
def _entry_from_row(row: sqlite3.Row) -> AdmissionEntry:
|
|
617
|
+
receipt_raw = row["receipt_json"]
|
|
618
|
+
receipt = json.loads(receipt_raw) if receipt_raw else None
|
|
619
|
+
return AdmissionEntry(
|
|
620
|
+
journal_id=str(row["journal_id"]),
|
|
621
|
+
idempotency_key=str(row["idempotency_key"]),
|
|
622
|
+
request_hash=str(row["request_hash"]),
|
|
623
|
+
profile_id=str(row["profile_id"]),
|
|
624
|
+
state=str(row["state"]),
|
|
625
|
+
canonical_operation_id=row["canonical_operation_id"],
|
|
626
|
+
canonical_commit_sequence=row["canonical_commit_sequence"],
|
|
627
|
+
error_code=row["error_code"],
|
|
628
|
+
created_at_ms=int(row["created_at_ms"]),
|
|
629
|
+
updated_at_ms=int(row["updated_at_ms"]),
|
|
630
|
+
original_receipt=receipt,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _canonical_bytes(value: Mapping[str, Any]) -> bytes:
|
|
635
|
+
try:
|
|
636
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
637
|
+
"utf-8"
|
|
638
|
+
)
|
|
639
|
+
except (TypeError, ValueError) as exc:
|
|
640
|
+
raise AdmissionPayloadError("remember command must be JSON serializable") from exc
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _receipt_json(receipt: Mapping[str, Any]) -> str:
|
|
644
|
+
if not isinstance(receipt, Mapping):
|
|
645
|
+
raise ValueError("receipt must be an object")
|
|
646
|
+
_validate_json(dict(receipt), "receipt")
|
|
647
|
+
_reject_raw_content(receipt)
|
|
648
|
+
rendered = _canonical_bytes(dict(receipt))
|
|
649
|
+
if len(rendered) > _MAX_RECEIPT_BYTES:
|
|
650
|
+
raise ValueError("receipt exceeds journal receipt limit")
|
|
651
|
+
return rendered.decode("utf-8")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _validate_json(value: Any, label: str, depth: int = 0) -> None:
|
|
655
|
+
if depth > _MAX_METADATA_DEPTH:
|
|
656
|
+
raise AdmissionPayloadError(f"{label} nesting exceeds limit")
|
|
657
|
+
if isinstance(value, Mapping):
|
|
658
|
+
for key, child in value.items():
|
|
659
|
+
if not isinstance(key, str):
|
|
660
|
+
raise AdmissionPayloadError(f"{label} keys must be strings")
|
|
661
|
+
_validate_json(child, label, depth + 1)
|
|
662
|
+
elif isinstance(value, (list, tuple)):
|
|
663
|
+
for child in value:
|
|
664
|
+
_validate_json(child, label, depth + 1)
|
|
665
|
+
elif not isinstance(value, (str, int, float, bool, type(None))):
|
|
666
|
+
raise AdmissionPayloadError(f"{label} must be JSON serializable")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _reject_raw_content(value: Any) -> None:
|
|
670
|
+
if isinstance(value, Mapping):
|
|
671
|
+
for key, child in value.items():
|
|
672
|
+
if key.casefold() in {
|
|
673
|
+
"content",
|
|
674
|
+
"content_preview",
|
|
675
|
+
"raw_content",
|
|
676
|
+
"memory_content",
|
|
677
|
+
"source_content",
|
|
678
|
+
}:
|
|
679
|
+
raise ValueError("receipt must not include raw memory content")
|
|
680
|
+
_reject_raw_content(child)
|
|
681
|
+
elif isinstance(value, (list, tuple)):
|
|
682
|
+
for child in value:
|
|
683
|
+
_reject_raw_content(child)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _now_ms() -> int:
|
|
687
|
+
return int(time.time() * 1000)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _remaining_seconds(deadline: float | None) -> float:
|
|
691
|
+
if deadline is None:
|
|
692
|
+
return 1.0
|
|
693
|
+
remaining = deadline - time.monotonic()
|
|
694
|
+
if remaining <= 0:
|
|
695
|
+
raise AdmissionJournalUnavailable("admission journal deadline expired")
|
|
696
|
+
return remaining
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _is_sqlite_busy(error: sqlite3.OperationalError) -> bool:
|
|
700
|
+
"""Recognize primary and extended SQLite BUSY/LOCKED result codes."""
|
|
701
|
+
code = getattr(error, "sqlite_errorcode", None)
|
|
702
|
+
if isinstance(code, int) and (code & 0xFF) in {
|
|
703
|
+
sqlite3.SQLITE_BUSY,
|
|
704
|
+
sqlite3.SQLITE_LOCKED,
|
|
705
|
+
}:
|
|
706
|
+
return True
|
|
707
|
+
message = str(error).casefold()
|
|
708
|
+
return "locked" in message or "busy" in message
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _has_unique_index(
|
|
712
|
+
conn: sqlite3.Connection, table: str, columns: tuple[str, ...]
|
|
713
|
+
) -> bool:
|
|
714
|
+
"""Return whether SQLite enforces exactly these columns as a unique key."""
|
|
715
|
+
for index in conn.execute(f"PRAGMA index_list({table})").fetchall():
|
|
716
|
+
if not index[2]:
|
|
717
|
+
continue
|
|
718
|
+
names = tuple(
|
|
719
|
+
row[2] for row in conn.execute(f"PRAGMA index_info({index[1]})").fetchall()
|
|
720
|
+
)
|
|
721
|
+
if names == columns:
|
|
722
|
+
return True
|
|
723
|
+
return False
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _create_journal_schema(conn: sqlite3.Connection) -> None:
|
|
727
|
+
conn.execute(_JOURNAL_TABLE_DDL)
|
|
728
|
+
conn.execute(_JOURNAL_REPLAY_INDEX_DDL)
|