purra-sqlite 0.5.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lybrands
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # purra-sqlite · TypeScript
2
+
3
+ English | [简体中文](README.zh-CN.md)
4
+
5
+ SQLite persistence for PurrA Runs, events, operations, budgets, checkpoints, and
6
+ tool receipts. Requires Node.js 22.13+ for `node:sqlite`.
7
+
8
+ ## Install
9
+
10
+ Follow [source installation](../../README.md#source-installation), selecting
11
+ `integrations/sqlite/typescript`.
12
+
13
+ ## Configure
14
+
15
+ Given a configured model gateway `model`:
16
+
17
+ ```ts
18
+ import { Agent } from "purra";
19
+ import { SqliteAgentAdapters } from "purra-sqlite";
20
+
21
+ const storage = new SqliteAgentAdapters("agent.db", {
22
+ scope: "user-1/project-1",
23
+ });
24
+ const agent = new Agent({
25
+ model,
26
+ runRepository: storage.runs,
27
+ outputPublisher: storage.publisher,
28
+ idempotency: storage.idempotency,
29
+ });
30
+ ```
31
+
32
+ Select `scope` from the application's authenticated user/project binding.
33
+ The bundle also exposes `runTree`, `artifacts`, and `longTasks`.
34
+
35
+ ## Recovery
36
+
37
+ `agent.resume(runId, originalRequest)` restores an eligible checkpoint with its
38
+ existing budget and deadline. Restore the original model and tool configuration.
39
+ The repository supplies execution ownership to prevent concurrent resumes.
40
+
41
+ For uncertain external tool writes, call
42
+ `storage.reconcileTool(key, { result })` or
43
+ `storage.reconcileTool(key, { notExecuted: true })` only after confirming what
44
+ happened. For persisted questions and answers, use
45
+ [SqliteClarification](../../interaction/typescript/README.md).
46
+
47
+ ## Storage and shutdown
48
+
49
+ Canonical output events are appended as rows with Run and Root sequence indexes.
50
+ Event additions and the execution snapshot commit in one transaction.
51
+ `runs.listEvents()`, `runs.listRootEvents()` and subscription polling neither load
52
+ the execution snapshot nor acquire a writer lock. `runs.get()` remains read-only.
53
+ Tool receipts and lease renewal/release skip Run state and journal hydration;
54
+ Agent tree, Artifact and Long Task operations restore only their own repository.
55
+ Writes targeting an existing Run validate sequence counts in SQL, use indexed
56
+ source-key lookups and buffer only new events. Core planning, public-progress
57
+ and terminal-settlement rules read original Run evidence on demand. Persisted
58
+ counts and pending events jointly determine Run and Root sequences; sibling
59
+ budgets remain shared. Run reads restore the complete Root journal. Unloaded Roots reject access;
60
+ their checkpoints and event counts are preserved. Lease acquisition, public
61
+ `transaction()` and operations such as creating a Run still validate the full
62
+ scope. Execution snapshots retain checkpoints and receipts and
63
+ are still loaded and saved at scope granularity. This adapter therefore
64
+ still suits bounded local workloads.
65
+ Storage v3 rejects v1/v2 data without automatic migration; existing databases
66
+ cannot be resumed directly.
67
+ Python and TypeScript execution snapshots are not interchangeable.
68
+ The source-key index is local to each Root (Python keys are scope-wide).
69
+ Opening existing v3 data creates the index using SQLite `json_extract`.
70
+ Sequence checks scan a covering index for the selected Root and match each child
71
+ by Run id, without fetching event body rows. Root headers have a covering index
72
+ as well. Existing v3 databases build these indexes on opening, consuming time and
73
+ disk space; inserts maintain the extra indexes. Metadata remains scope-sized;
74
+ writes are not constant-cost. Event bodies are validated when read. Lease
75
+ acquisition and public transactions retain full journal hydration.
76
+
77
+ After building Core and this package, run the empty-poll and tail-pagination
78
+ benchmark from the repository root:
79
+
80
+ ```sh
81
+ node integrations/sqlite/typescript/scripts/benchmark-reads.mjs
82
+ ```
83
+
84
+ This temporary-database benchmark reports warm median read latency, not
85
+ concurrent throughput or real-model end-to-end performance.
86
+
87
+ Measure tool receipt writes, including claim and result-commit transactions:
88
+
89
+ ```sh
90
+ node integrations/sqlite/typescript/scripts/benchmark-writes.mjs
91
+ ```
92
+
93
+ The tool callback is local and has no external side effect; this excludes real
94
+ business-tool and model latency.
95
+
96
+ Measure active Run event writes beside a growing unrelated Root:
97
+
98
+ ```sh
99
+ node integrations/sqlite/typescript/scripts/benchmark-run-writes.mjs
100
+ ```
101
+
102
+ This measures isolation from other Roots, not scaling within a single growing Root.
103
+
104
+ Measure appends, invocation registration and checkpoint commits within the same
105
+ growing Root (two warmups and ten measured writes per operation):
106
+
107
+ ```sh
108
+ node integrations/sqlite/typescript/scripts/benchmark-execution-writes.mjs
109
+ ```
110
+
111
+ History consists of private diagnostic events. Results exclude planning evidence
112
+ replay, concurrent throughput and real Provider latency. Unlike Python's
113
+ reservation and checkpoint APIs, TypeScript emits an output event for each of
114
+ these operations, so compare each SDK against its own baseline.
115
+ Add `--profile` to report journal preparation, Run state import/export and the
116
+ remaining transaction time. The remainder includes outer snapshot handling,
117
+ other repositories and SQL writes. Phase medians are calculated independently
118
+ and need not sum to the total median.
119
+
120
+ The application owns database access, backups, and retention. Checkpoints contain
121
+ private model data. Wait for active executions to settle before `storage.close()`.
@@ -0,0 +1,104 @@
1
+ # purra-sqlite · TypeScript
2
+
3
+ [English](README.md) | 简体中文
4
+
5
+ 为 PurrA 的 Run、事件、操作、预算、检查点和工具回执提供 SQLite 持久化。
6
+ 依赖 `node:sqlite`,要求 Node.js 22.13+。
7
+
8
+ ## 安装
9
+
10
+ 按照[源码安装说明](../../README.zh-CN.md#从源码安装)操作,选择
11
+ `integrations/sqlite/typescript`。
12
+
13
+ ## 配置
14
+
15
+ 已有模型网关 `model` 时:
16
+
17
+ ```ts
18
+ import { Agent } from "purra";
19
+ import { SqliteAgentAdapters } from "purra-sqlite";
20
+
21
+ const storage = new SqliteAgentAdapters("agent.db", {
22
+ scope: "user-1/project-1",
23
+ });
24
+ const agent = new Agent({
25
+ model,
26
+ runRepository: storage.runs,
27
+ outputPublisher: storage.publisher,
28
+ idempotency: storage.idempotency,
29
+ });
30
+ ```
31
+
32
+ `scope` 应由应用根据已认证的用户与项目绑定确定。
33
+ 该组件还提供 `runTree`、`artifacts` 和 `longTasks`。
34
+
35
+ ## 恢复
36
+
37
+ `agent.resume(runId, originalRequest)` 恢复满足条件的检查点,并保留原有预算和截止时间。
38
+ 恢复时还原原有模型与工具配置。仓储提供执行所有权控制,阻止并发恢复。
39
+
40
+ 外部工具写入结果不确定时,先确认实际执行情况,再调用
41
+ `storage.reconcileTool(key, { result })` 或
42
+ `storage.reconcileTool(key, { notExecuted: true })`。
43
+ 需要保存提问和回答时,使用 [SqliteClarification](../../interaction/typescript/README.zh-CN.md)。
44
+
45
+ ## 存储与关闭
46
+
47
+ 规范输出事件按行增量保存,通过 Run 序号和 Root 序号索引分页读取。事件追加与执行状态
48
+ 快照在同一个事务中提交;`runs.listEvents()`、`runs.listRootEvents()` 和订阅轮询不加载
49
+ 执行快照,也不申请写锁。`runs.get()` 保持只读事务。工具回执、租约续期与释放跳过
50
+ Run 状态和输出历史的还原;Agent 树、产物与长任务操作只还原对应仓储。
51
+ 指向已有 Run 的写入先通过 SQL 校验序号和事件数量,按索引查询幂等键,并缓冲新增事件。
52
+ 规划、公开进度和终态收尾所需的原始证据仍由 Core 按需读取。已持久化的事件数量和
53
+ 待提交事件共同决定 Run 与 Root 的序号,兄弟任务仍共享预算。Run 读取完整还原所属
54
+ Root 日志;未加载的 Root 不能读取或写入,其检查点与事件计数保持不变。
55
+ 租约认领、公开 `transaction()` 和创建 Run 等无法定位既有 Root 的操作仍校验完整作用域。
56
+ 执行快照仍按作用域加载和保存,包含检查点和回执,适合数据量受控的本地场景。
57
+ 本次存储格式为 v3,明确拒绝 v1/v2 数据;没有自动迁移,已有数据库不能直接恢复。
58
+ Python 与 TypeScript 的执行快照不能互换。
59
+ 事件幂等键的索引范围是单个 Root(Python 则是整个作用域)。打开已有 v3 数据库时,
60
+ 通过 SQLite `json_extract` 建立该索引。序号校验使用选定 Root 的覆盖索引,并按 Run
61
+ 精确匹配子任务,无需读取事件正文所在的数据行。Root 与 Run 的映射也有覆盖索引。
62
+ 已有 v3 数据库在打开时补建索引,需要耗时和额外磁盘,新增事件也需要维护索引。
63
+ 元数据仍按作用域保存,
64
+ 因此写入成本并非常数。事件正文在读取时校验;租约认领和公开事务仍还原完整日志。
65
+
66
+ 构建 Core 和本包后,在仓库根目录运行空轮询与末尾分页基准:
67
+
68
+ ```sh
69
+ node integrations/sqlite/typescript/scripts/benchmark-reads.mjs
70
+ ```
71
+
72
+ 基准使用临时数据库,报告预热后的读取中位耗时,不代表并发吞吐量或真实模型端到端性能。
73
+
74
+ 工具回执写入基准计入认领和结果提交两个事务,使用本地无外部副作用的工具函数:
75
+
76
+ ```sh
77
+ node integrations/sqlite/typescript/scripts/benchmark-writes.mjs
78
+ ```
79
+
80
+ 它不计真实业务工具或模型调用耗时。
81
+
82
+ 另一个 Root 的历史数据增长时,活动 Run 的事件写入成本可用以下基准测量:
83
+
84
+ ```sh
85
+ node integrations/sqlite/typescript/scripts/benchmark-run-writes.mjs
86
+ ```
87
+
88
+ 它测量无关 Root 的隔离效果,不代表活动 Root 自身历史不断增长时的性能。
89
+
90
+ 同一 Root 内的事件追加、调用登记和检查点提交基准,每种操作预热两次后测量十次:
91
+
92
+ ```sh
93
+ node integrations/sqlite/typescript/scripts/benchmark-execution-writes.mjs
94
+ ```
95
+
96
+ 历史数据为私有诊断事件;结果不包含规划证据重放、并发吞吐量或真实 Provider 耗时。
97
+ 与 Python 的调用登记和检查点接口不同,TypeScript 的这两类操作都会追加输出事件,
98
+ 因此应分别与各 SDK 自身的基线比较。
99
+ 追加 `--profile` 可分别查看日志准备、Run 状态导入导出和事务其余部分的耗时。
100
+ 其余部分包含外层快照处理、其他仓储和 SQL 写入。各分段的中位数独立计算,
101
+ 不能直接相加得到总耗时中位数。
102
+
103
+ 应用负责数据库访问、备份和数据保留。检查点包含私有模型数据。
104
+ 等待活动执行结束后再调用 `storage.close()`。
@@ -0,0 +1,33 @@
1
+ import { InMemoryRunRepository, InMemoryRunTreeRepository, InMemoryArtifactStore, InMemoryLongTaskRepository, type OutputPublisher, type ToolHandlerResult, type ToolIdempotencyGateway } from "purra";
2
+ declare function stores(): {
3
+ runs: InMemoryRunRepository;
4
+ runTree: InMemoryRunTreeRepository;
5
+ artifacts: InMemoryArtifactStore;
6
+ longTasks: InMemoryLongTaskRepository;
7
+ };
8
+ type Stores = ReturnType<typeof stores>;
9
+ export declare class SqliteAgentAdapters {
10
+ #private;
11
+ readonly runs: InMemoryRunRepository;
12
+ readonly runTree: InMemoryRunTreeRepository;
13
+ readonly artifacts: InMemoryArtifactStore;
14
+ readonly artifactClaims: InMemoryArtifactStore;
15
+ readonly artifactMaintenance: InMemoryArtifactStore;
16
+ readonly longTasks: InMemoryLongTaskRepository;
17
+ readonly publisher: OutputPublisher;
18
+ readonly idempotency: ToolIdempotencyGateway;
19
+ constructor(path: string, options: {
20
+ scope: string;
21
+ });
22
+ transaction<T>(operation: (all: Stores, extra: {
23
+ tools: Record<string, any>;
24
+ [key: string]: any;
25
+ }) => Promise<T>): Promise<T>;
26
+ reconcileTool(key: string, proof: {
27
+ result: ToolHandlerResult;
28
+ } | {
29
+ notExecuted: true;
30
+ }): Promise<void>;
31
+ close(): void;
32
+ }
33
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,240 @@
1
+ import { OutputJournal, STORAGE_VERSION } from "./journal.js";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ import { openSync, closeSync } from "node:fs";
5
+ import { setTimeout as sleep } from "node:timers/promises";
6
+ import { InMemoryRunRepository, InMemoryRunTreeRepository, InMemoryArtifactStore, InMemoryLongTaskRepository, } from "purra";
7
+ function stores() {
8
+ const runTree = new InMemoryRunTreeRepository();
9
+ const runs = new InMemoryRunRepository({ leaseValidator: (id, claim) => runTree.requireRunClaim(id, claim) });
10
+ return { runs, runTree, artifacts: new InMemoryArtifactStore(), longTasks: new InMemoryLongTaskRepository() };
11
+ }
12
+ const RUN_READ_METHODS = new Set(["get", "listEvents", "listRootEvents"]);
13
+ const ROOT_BOUND_METHODS = new Set(["get", "openInvocation", "appendEvent", "appendBatch", "saveExecutionCheckpoint", "settleInvocation", "settleRun", "cancel"]);
14
+ export class SqliteAgentAdapters {
15
+ #db;
16
+ #scope;
17
+ #journal;
18
+ #tail = Promise.resolve();
19
+ #active = false;
20
+ #owner = new AsyncLocalStorage();
21
+ runs = this.#port("runs");
22
+ runTree = this.#port("runTree");
23
+ artifacts = this.#port("artifacts");
24
+ artifactClaims = this.artifacts;
25
+ artifactMaintenance = this.artifacts;
26
+ longTasks = this.#port("longTasks");
27
+ publisher = {
28
+ publishCommitted: async (event) => {
29
+ const rows = await this.runs.listEvents(event.runId, event.sequence - 1, 1);
30
+ if (JSON.stringify(rows[0]) !== JSON.stringify(event))
31
+ throw new Error("only persisted output can be published");
32
+ },
33
+ waitForSequence: async (id, after, signal) => {
34
+ while (!(await this.runs.listEvents(id, after, 1)).length)
35
+ await sleep(50, undefined, { signal });
36
+ },
37
+ };
38
+ idempotency = {
39
+ executeOnce: async (key, operation) => {
40
+ const saved = await this.#extraTransaction(async (extra) => {
41
+ if (extra.tools[key]) {
42
+ if (extra.tools[key].state === "claimed")
43
+ throw new Error("tool_effect_unknown");
44
+ return extra.tools[key].result;
45
+ }
46
+ extra.tools[key] = { state: "claimed" };
47
+ return undefined;
48
+ });
49
+ if (saved !== undefined)
50
+ return saved;
51
+ const result = await operation();
52
+ await this.#extraTransaction(async (extra) => { extra.tools[key] = { state: "complete", result }; });
53
+ return result;
54
+ },
55
+ };
56
+ constructor(path, options) {
57
+ if (!options.scope.trim())
58
+ throw new TypeError("scope is required");
59
+ this.#scope = options.scope;
60
+ if (path !== ":memory:") {
61
+ try {
62
+ closeSync(openSync(path, "ax", 0o600));
63
+ }
64
+ catch (error) {
65
+ if (error.code !== "EEXIST")
66
+ throw error;
67
+ }
68
+ }
69
+ this.#db = new DatabaseSync(path);
70
+ this.#db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=0; PRAGMA foreign_keys=ON;");
71
+ this.#db.exec("CREATE TABLE IF NOT EXISTS purra_state (scope TEXT NOT NULL, sdk TEXT NOT NULL, version INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(scope,sdk))");
72
+ this.#journal = new OutputJournal(this.#db, this.#scope);
73
+ }
74
+ #port(name) {
75
+ const methods = new Set(Object.getOwnPropertyNames(Object.getPrototypeOf(stores()[name])));
76
+ return new Proxy({}, { get: (_target, method) => {
77
+ if (name === "runs" && method === "executeOwned")
78
+ return this.#executeOwned.bind(this);
79
+ if (name === "runs" && (method === "listEvents" || method === "listRootEvents")) {
80
+ return (id, after, limit) => this.#withConnection(async () => this.#journal.read(id, after, limit, method === "listRootEvents"), true);
81
+ }
82
+ if (!methods.has(method) || method === "constructor")
83
+ return undefined;
84
+ return (...args) => this.#transaction(async (all, extra) => {
85
+ if (name === "runs" && !["get", "listEvents", "listRootEvents"].includes(method)) {
86
+ const lease = extra.leases[String(args[0])];
87
+ const owner = this.#owner.getStore();
88
+ if (owner !== undefined && lease && (lease.owner !== owner || lease.expires <= Date.now()))
89
+ throw new Error("run_lease_lost");
90
+ }
91
+ return all[name][method](...args);
92
+ }, name === "runs" && RUN_READ_METHODS.has(method), name === "runs" ? "all" : name, name === "runs" && ROOT_BOUND_METHODS.has(method) && typeof args[0] === "string" ? args[0] : undefined);
93
+ } });
94
+ }
95
+ async #executeOwned(runId, operation, checkpoint) {
96
+ const owner = globalThis.crypto.randomUUID();
97
+ await this.transaction(async (all, extra) => {
98
+ const saved = await all.runs.get(runId);
99
+ if (saved.status !== "running")
100
+ throw new Error("run_terminal");
101
+ const old = extra.leases[runId];
102
+ if (old && old.owner !== null && old.expires > Date.now())
103
+ throw new Error("run_lease_conflict");
104
+ if (checkpoint !== undefined && JSON.stringify(saved.executionCheckpoint) !== JSON.stringify(checkpoint))
105
+ throw new Error("checkpoint_conflict");
106
+ if (checkpoint !== undefined) {
107
+ const events = await all.runs.listEvents(runId, 0, Number.MAX_SAFE_INTEGER);
108
+ const lastCheckpoint = events.reduce((last, event, index) => event.kind === "agent.execution_checkpoint" ? index : last, -1);
109
+ if (events.slice(lastCheckpoint + 1).some(event => event.kind === "invocation.started"))
110
+ throw new Error("run_recovery_requires_reconciliation");
111
+ }
112
+ extra.leases[runId] = { owner, expires: Date.now() + 30000 };
113
+ });
114
+ let stopped = false;
115
+ const heartbeat = async () => {
116
+ while (!stopped) {
117
+ await sleep(1000, undefined, { signal: stop.signal }).catch(() => { });
118
+ if (stopped)
119
+ break;
120
+ await this.#extraTransaction(async (extra) => {
121
+ if (extra.leases[runId]?.owner !== owner)
122
+ throw new Error("run_lease_lost");
123
+ extra.leases[runId].expires = Date.now() + 30000;
124
+ });
125
+ }
126
+ };
127
+ const stop = new AbortController();
128
+ let heartbeatError;
129
+ const monitor = heartbeat().catch((error) => { heartbeatError = error; });
130
+ try {
131
+ const result = await this.#owner.run(owner, operation);
132
+ if (heartbeatError)
133
+ throw heartbeatError;
134
+ return result;
135
+ }
136
+ finally {
137
+ stopped = true;
138
+ stop.abort();
139
+ await monitor;
140
+ await this.#extraTransaction(async (extra) => {
141
+ if (extra.leases[runId]?.owner === owner)
142
+ extra.leases[runId] = { owner: null, expires: 0 };
143
+ });
144
+ }
145
+ }
146
+ async transaction(operation) {
147
+ return this.#transaction(operation);
148
+ }
149
+ async #withConnection(operation, readOnly = false) {
150
+ const previous = this.#tail;
151
+ let release;
152
+ this.#tail = new Promise((resolve) => { release = resolve; });
153
+ await previous;
154
+ this.#active = true;
155
+ let begun = false;
156
+ try {
157
+ const deadline = Date.now() + 5000;
158
+ for (;;) {
159
+ try {
160
+ this.#db.exec(readOnly ? "BEGIN" : "BEGIN IMMEDIATE");
161
+ begun = true;
162
+ break;
163
+ }
164
+ catch (error) {
165
+ if (!(error instanceof Error) || !error.message.includes("locked") || Date.now() >= deadline)
166
+ throw error;
167
+ await sleep(10);
168
+ }
169
+ }
170
+ const result = await operation();
171
+ this.#db.exec("COMMIT");
172
+ begun = false;
173
+ return result;
174
+ }
175
+ catch (error) {
176
+ if (begun)
177
+ this.#db.exec("ROLLBACK");
178
+ throw error;
179
+ }
180
+ finally {
181
+ this.#active = false;
182
+ release();
183
+ }
184
+ }
185
+ async #extraTransaction(operation) {
186
+ return this.#transaction(async (_all, extra) => operation(extra), false, "extra");
187
+ }
188
+ async #transaction(operation, readOnly = false, selection = "all", journalRunId) {
189
+ return this.#withConnection(async () => {
190
+ const all = stores();
191
+ const row = this.#db.prepare("SELECT version,body FROM purra_state WHERE scope=? AND sdk='typescript'").get(this.#scope);
192
+ if (row && row.version !== STORAGE_VERSION)
193
+ throw new Error("unsupported SQLite storage version");
194
+ const saved = row ? JSON.parse(String(row.body)) : { extra: { tools: Object.create(null) } };
195
+ saved.extra.tools = Object.assign(Object.create(null), saved.extra.tools);
196
+ saved.extra.leases = Object.assign(Object.create(null), saved.extra.leases);
197
+ const rootRunId = row && journalRunId !== undefined ? this.#journal.rootForRun(journalRunId) : undefined;
198
+ const deferredJournal = row && selection === "all" && !readOnly && rootRunId !== undefined ? this.#journal.deferred(rootRunId) : undefined;
199
+ const events = row && selection === "all" && deferredJournal === undefined ? this.#journal.restore(rootRunId) : [];
200
+ const prior = new Map(deferredJournal?.counts);
201
+ for (const event of events)
202
+ prior.set(event.runId, event.sequence);
203
+ if (saved.runs && selection === "all")
204
+ all.runs.importState(saved.runs, deferredJournal === undefined ? events : undefined, { ...(rootRunId === undefined ? {} : { rootRunId }), ...(deferredJournal === undefined ? {} : { deferredJournal }) });
205
+ for (const key of ["runTree", "artifacts", "longTasks"])
206
+ if (saved[key] && (selection === "all" || selection === key))
207
+ all[key].importState(saved[key]);
208
+ const result = await operation(all, saved.extra);
209
+ if (!readOnly) {
210
+ const checkpoint = selection === "all" ? all.runs.exportJournalState({ incremental: true }) : undefined;
211
+ if (checkpoint !== undefined)
212
+ saved.runs = checkpoint.state;
213
+ for (const key of ["runTree", "artifacts", "longTasks"]) {
214
+ if (selection === "all" || selection === key)
215
+ saved[key] = all[key].exportState();
216
+ }
217
+ const body = JSON.stringify(saved);
218
+ if (!row || row.body !== body)
219
+ this.#db.prepare("INSERT INTO purra_state VALUES(?, 'typescript', 3, ?) ON CONFLICT(scope,sdk) DO UPDATE SET version=excluded.version,body=excluded.body").run(this.#scope, body);
220
+ if (checkpoint !== undefined)
221
+ this.#journal.append(checkpoint.journals, prior);
222
+ }
223
+ return result;
224
+ }, readOnly);
225
+ }
226
+ async reconcileTool(key, proof) {
227
+ await this.#extraTransaction(async (extra) => {
228
+ if (extra.tools[key]?.state !== "claimed")
229
+ throw new Error("tool_claim_conflict");
230
+ if ("result" in proof)
231
+ extra.tools[key] = { state: "complete", result: proof.result };
232
+ else if (proof.notExecuted === true)
233
+ delete extra.tools[key];
234
+ else
235
+ throw new TypeError("invalid tool reconciliation proof");
236
+ });
237
+ }
238
+ close() { if (this.#active)
239
+ throw new Error("storage transaction is active"); this.#db.close(); }
240
+ }
@@ -0,0 +1,24 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import { type OutputEvent } from "purra";
3
+ export declare const STORAGE_VERSION = 3;
4
+ export declare class OutputJournal {
5
+ private readonly db;
6
+ private readonly scope;
7
+ constructor(db: DatabaseSync, scope: string);
8
+ rootForRun(runId: string): string | undefined;
9
+ restore(rootRunId?: string): readonly OutputEvent[];
10
+ deferred(rootRunId: string): {
11
+ counts: Map<string, number>;
12
+ readRun: (runId: string) => readonly OutputEvent[];
13
+ readRoot: () => readonly OutputEvent[];
14
+ findSource: (key: string) => OutputEvent | undefined;
15
+ };
16
+ restoreRun(runId: string): readonly OutputEvent[];
17
+ append(journals: readonly {
18
+ readonly runId: string;
19
+ readonly rootRunId: string;
20
+ readonly afterSequence?: number;
21
+ readonly events: readonly OutputEvent[];
22
+ }[], prior: ReadonlyMap<string, number>): void;
23
+ read(runId: string, after: number, limit?: number, root?: boolean): readonly OutputEvent[];
24
+ }
@@ -0,0 +1,124 @@
1
+ import { AgentError } from "purra";
2
+ export const STORAGE_VERSION = 3;
3
+ function decode(text) {
4
+ const freeze = (value) => {
5
+ if (value !== null && typeof value === "object") {
6
+ for (const child of Object.values(value))
7
+ freeze(child);
8
+ Object.freeze(value);
9
+ }
10
+ return value;
11
+ };
12
+ return freeze(JSON.parse(text));
13
+ }
14
+ function decodeRow(row) {
15
+ const event = decode(String(row.body));
16
+ if (event.runId !== row.run_id || event.rootRunId !== row.root_run_id
17
+ || event.sequence !== row.sequence || event.rootSequence !== row.root_sequence) {
18
+ throw new TypeError("invalid output journal identity or sequence");
19
+ }
20
+ return event;
21
+ }
22
+ export class OutputJournal {
23
+ db;
24
+ scope;
25
+ constructor(db, scope) {
26
+ this.db = db;
27
+ this.scope = scope;
28
+ db.exec(`CREATE TABLE IF NOT EXISTS purra_journal_runs (
29
+ scope TEXT NOT NULL, sdk TEXT NOT NULL, run_id TEXT NOT NULL,
30
+ root_run_id TEXT NOT NULL, PRIMARY KEY(scope,sdk,run_id),
31
+ FOREIGN KEY(scope,sdk) REFERENCES purra_state(scope,sdk) ON DELETE CASCADE);
32
+ CREATE TABLE IF NOT EXISTS purra_output_events (
33
+ scope TEXT NOT NULL, sdk TEXT NOT NULL, run_id TEXT NOT NULL,
34
+ sequence INTEGER NOT NULL, root_run_id TEXT NOT NULL,
35
+ root_sequence INTEGER NOT NULL, body TEXT NOT NULL,
36
+ PRIMARY KEY(scope,sdk,run_id,sequence),
37
+ UNIQUE(scope,sdk,root_run_id,root_sequence),
38
+ FOREIGN KEY(scope,sdk,run_id) REFERENCES purra_journal_runs(scope,sdk,run_id) ON DELETE CASCADE);
39
+ CREATE UNIQUE INDEX IF NOT EXISTS purra_typescript_output_source
40
+ ON purra_output_events(scope,root_run_id,json_extract(body,'$.sourceKey')) WHERE sdk='typescript';
41
+ CREATE INDEX IF NOT EXISTS purra_output_sequence_cover
42
+ ON purra_output_events(scope,sdk,root_run_id,run_id,sequence,root_sequence);
43
+ CREATE INDEX IF NOT EXISTS purra_journal_roots
44
+ ON purra_journal_runs(scope,sdk,root_run_id,run_id);`);
45
+ }
46
+ rootForRun(runId) {
47
+ const row = this.db.prepare("SELECT root_run_id FROM purra_journal_runs WHERE scope=? AND sdk='typescript' AND run_id=?").get(this.scope, runId);
48
+ return row === undefined ? undefined : String(row.root_run_id);
49
+ }
50
+ restore(rootRunId) {
51
+ const query = this.db.prepare("SELECT body FROM purra_output_events WHERE scope=? AND sdk='typescript'"
52
+ + (rootRunId === undefined ? "" : " AND root_run_id=?") + " ORDER BY root_run_id,root_sequence");
53
+ const rows = rootRunId === undefined ? query.all(this.scope) : query.all(this.scope, rootRunId);
54
+ return rows.map((row) => decode(String(row.body)));
55
+ }
56
+ deferred(rootRunId) {
57
+ const rows = this.db.prepare(`SELECT r.run_id,COUNT(e.sequence) AS count,
58
+ MIN(e.sequence) AS first,MAX(e.sequence) AS last,
59
+ MIN(e.root_sequence) AS root_first,MAX(e.root_sequence) AS root_last
60
+ FROM purra_journal_runs r LEFT JOIN purra_output_events e
61
+ ON e.scope=r.scope AND e.sdk=r.sdk AND e.run_id=r.run_id AND e.root_run_id=r.root_run_id
62
+ WHERE r.scope=? AND r.sdk='typescript' AND r.root_run_id=? GROUP BY r.run_id`).all(this.scope, rootRunId);
63
+ const counts = new Map();
64
+ let rootFirst = Infinity, rootLast = 0, total = 0;
65
+ for (const row of rows) {
66
+ const count = Number(row.count);
67
+ if (count && (row.first !== 1 || row.last !== count))
68
+ throw new TypeError("invalid output journal sequence");
69
+ counts.set(String(row.run_id), count);
70
+ total += count;
71
+ if (count) {
72
+ rootFirst = Math.min(rootFirst, Number(row.root_first));
73
+ rootLast = Math.max(rootLast, Number(row.root_last));
74
+ }
75
+ }
76
+ if (total && (rootFirst !== 1 || rootLast !== total))
77
+ throw new TypeError("incomplete output journal");
78
+ return {
79
+ counts,
80
+ readRun: (runId) => this.restoreRun(runId),
81
+ readRoot: () => this.restore(rootRunId),
82
+ findSource: (key) => {
83
+ const row = this.db.prepare("SELECT run_id,root_run_id,sequence,root_sequence,body FROM purra_output_events WHERE scope=? AND sdk='typescript' AND root_run_id=? AND json_extract(body,'$.sourceKey')=?").get(this.scope, rootRunId, key);
84
+ return row === undefined ? undefined : decodeRow(row);
85
+ },
86
+ };
87
+ }
88
+ restoreRun(runId) {
89
+ return this.db.prepare("SELECT run_id,root_run_id,sequence,root_sequence,body FROM purra_output_events WHERE scope=? AND sdk='typescript' AND run_id=? ORDER BY sequence")
90
+ .all(this.scope, runId).map(decodeRow);
91
+ }
92
+ append(journals, prior) {
93
+ const insertRun = this.db.prepare("INSERT INTO purra_journal_runs VALUES (?, 'typescript', ?, ?)");
94
+ const insertEvent = this.db.prepare("INSERT INTO purra_output_events VALUES (?, 'typescript', ?, ?, ?, ?, ?)");
95
+ for (const journal of journals) {
96
+ if (!prior.has(journal.runId))
97
+ insertRun.run(this.scope, journal.runId, journal.rootRunId);
98
+ const offset = journal.afterSequence ?? 0;
99
+ const count = prior.get(journal.runId) ?? 0;
100
+ if (offset > count)
101
+ throw new TypeError("output journal append has a gap");
102
+ for (const event of journal.events.slice(count - offset)) {
103
+ insertEvent.run(this.scope, event.runId, event.sequence, event.rootRunId, event.rootSequence, JSON.stringify(event));
104
+ }
105
+ }
106
+ }
107
+ read(runId, after, limit = 200, root = false) {
108
+ const row = this.db.prepare("SELECT version FROM purra_state WHERE scope=? AND sdk='typescript'").get(this.scope);
109
+ if (row && row.version !== STORAGE_VERSION)
110
+ throw new Error("unsupported SQLite storage version");
111
+ const run = row && this.db.prepare("SELECT root_run_id FROM purra_journal_runs WHERE scope=? AND sdk='typescript' AND run_id=?").get(this.scope, runId);
112
+ if (!run)
113
+ throw new AgentError("run_not_found", "Run does not exist");
114
+ if (root && run.root_run_id !== runId)
115
+ throw new AgentError("run_scope_conflict", "Root journal query requires a Root Run");
116
+ if (!Number.isSafeInteger(after) || after < 0)
117
+ throw new TypeError(`${root ? "afterRootSequence" : "afterSequence"} must be a non-negative integer`);
118
+ if (!Number.isSafeInteger(limit) || limit < 1)
119
+ throw new TypeError("limit must be positive");
120
+ const [identity, sequence] = root ? ["root_run_id", "root_sequence"] : ["run_id", "sequence"];
121
+ const rows = this.db.prepare(`SELECT body FROM purra_output_events WHERE scope=? AND sdk='typescript' AND ${identity}=? AND ${sequence}>? ORDER BY ${sequence} LIMIT ?`).all(this.scope, runId, after, limit);
122
+ return Object.freeze(rows.map((item) => decode(String(item.body))));
123
+ }
124
+ }
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "purra-sqlite", "version": "0.5.0", "type": "module", "license": "MIT",
3
+ "description": "SQLite persistence for PurrA",
4
+ "engines": { "node": ">=22.13.0" },
5
+ "files": ["dist", "README.md", "LICENSE"],
6
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
7
+ "peerDependencies": { "purra": "0.5.0" },
8
+ "devDependencies": { "@types/node": "22.20.1", "purra": "file:../../../typescript", "typescript": "7.0.2" },
9
+ "scripts": { "build": "tsc -p tsconfig.json", "test": "node --test test/*.test.mjs", "check": "npm run build && npm test", "prepack": "npm run build" }
10
+ }