1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as SqlClient from "effect/sql/SqlClient";
10import { vi } from "vite-plus/test";
11
12import { readOpenCodeUsage } from "./usage.ts";
13
14/** A writable connection that stays open until the test's scope closes. */
15const openDatabase = (filename: string) =>
16 Layer.build(NodeSqliteClient.layer({ filename })).pipe(
17 Effect.map(Context.get(SqlClient.SqlClient)),
18 );
19
20describe("readOpenCodeUsage", () => {
21 it.effect("bounds raw history allocations while counting every eligible message once", () =>
22 Effect.gen(function* () {
23 const fileSystem = yield* FileSystem.FileSystem;
24 const path = yield* Path.Path;
25 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-large-test-" });
26 const db = yield* openDatabase(path.join(dir, "opencode.db"));
27 yield* db.unsafe(
28 "CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)",
29 );
30 const sinceMs = 1780000000000;
31 const message = {
32 role: "assistant",
33 modelID: "claude-sonnet-4-5",
34 time: { created: sinceMs },
35 tokens: { input: 100, output: 20 },
36 // Transcript bodies are irrelevant to usage, but can be very large.
37 body: "x".repeat(64 * 1024),
38 };
39 const data = JSON.stringify(message);
40 const insert = (rowId: number, id: string, payload: string, created = sinceMs) =>
41 db.unsafe(
42 "INSERT INTO message (rowid, id, session_id, data, time_created) VALUES (?, ?, ?, ?, ?)",
43 [rowId, id, "session-1", payload, created],
44 );
45 // Sparse IDs, including negative rowids, must not skip records at page boundaries.
46 for (let index = 0; index < 130; index++) {
47 yield* insert(index * 2 - 10, `msg-${index}`, data);
48 }
49 yield* insert(300, "old", data, sinceMs - 1);
50 yield* insert(301, "user", JSON.stringify({ ...message, role: "user" }));
51 yield* insert(302, "invalid", "{invalid");
52 yield* insert(303, "msg-0", data);
53
54 let largestRawResult = 0;
55 const originalAll = NodeSqlite.StatementSync.prototype.all;
56 yield* Effect.acquireRelease(
57 Effect.sync(() =>
58 vi.spyOn(NodeSqlite.StatementSync.prototype, "all").mockImplementation(function (
59 this: NodeSqlite.StatementSync,
60 ...params
61 ) {
62 const rows = originalAll.apply(this, params);
63 const rawSize = rows.reduce(
64 (size, row) => size + (typeof row.data === "string" ? row.data.length : 0),
65 0,
66 );
67 largestRawResult = Math.max(largestRawResult, rawSize);
68 return rows;
69 }),
70 ),
71 (spy) => Effect.sync(() => spy.mockRestore()),
72 );
73 const result = yield* readOpenCodeUsage(dir, sinceMs);
74 assert.isFalse(result.error);
75 const records = result.files.flatMap((file) => file.records);
76 assert.deepStrictEqual(
77 records.map((record) => record.dedupeKey),
78 Array.from({ length: 130 }, (_, index) => `opencode:msg-${index}`),
79 );
80 assert.strictEqual(
81 records.reduce((total, record) => total + record.totals.uncachedInputTokens, 0),
82 13000,
83 );
84 // The fixture exceeds 8 MiB. No query may retain its complete bodies.
85 assert.isAbove(largestRawResult, 0);
86 assert.isBelow(largestRawResult, 6 * 1024 * 1024);
87 }).pipe(Effect.provide(NodeServices.layer)),
88 );
89
90 it.effect("reads at most 64 OpenCode rowids per statement however much history is older", () =>
91 Effect.gen(function* () {
92 const fileSystem = yield* FileSystem.FileSystem;
93 const path = yield* Path.Path;
94 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
95 const db = yield* openDatabase(path.join(dir, "opencode.db"));
96 yield* db.unsafe(
97 "CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)",
98 );
99 const sinceMs = 1780000000000;
100 const data = JSON.stringify({
101 role: "assistant",
102 modelID: "claude-sonnet-4-5",
103 tokens: { input: 100, output: 20 },
104 });
105 // OpenCode appends, so the history before the window comes first.
106 yield* db.unsafe("BEGIN");
107 for (let index = 0; index < 305; index++) {
108 yield* db.unsafe("INSERT INTO message VALUES (?, ?, ?, ?)", [
109 `msg-${index}`,
110 "session-1",
111 data,
112 sinceMs + index - 300,
113 ]);
114 }
115 yield* db.unsafe("COMMIT");
116
117 const spans: number[] = [];
118 const originalAll = NodeSqlite.StatementSync.prototype.all;
119 yield* Effect.acquireRelease(
120 Effect.sync(() =>
121 vi.spyOn(NodeSqlite.StatementSync.prototype, "all").mockImplementation(function (
122 this: NodeSqlite.StatementSync,
123 ...params
124 ) {
125 const rows = originalAll.apply(this, params);
126 if (this.sourceSQL.includes("FROM message WHERE")) {
127 const range = /rowid > (-?[\d.]+) AND rowid <= (-?[\d.]+)/.exec(this.expandedSQL);
128 spans.push(range ? Number(range[2]) - Number(range[1]) : Infinity);
129 }
130 return rows;
131 }),
132 ),
133 (spy) => Effect.sync(() => spy.mockRestore()),
134 );
135 const result = yield* readOpenCodeUsage(dir, sinceMs);
136 assert.isFalse(result.error);
137 assert.deepStrictEqual(
138 result.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
139 Array.from({ length: 5 }, (_, index) => `opencode:msg-${index + 300}`),
140 );
141 assert.isNotEmpty(spans);
142 assert.isAtMost(Math.max(...spans), 64);
143 }).pipe(Effect.provide(NodeServices.layer)),
144 );
145
146 it.effect("counts migrated OpenCode messages once and sees subsequent WAL writes", () =>
147 Effect.gen(function* () {
148 const fileSystem = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
151 const db = yield* openDatabase(path.join(dir, "opencode.db"));
152 yield* db.unsafe("PRAGMA journal_mode = WAL");
153 yield* db.unsafe("PRAGMA wal_autocheckpoint = 0");
154 yield* db.unsafe("CREATE TABLE message (id TEXT, session_id TEXT, data TEXT)");
155 const message = {
156 id: "msg-1",
157 sessionID: "session-1",
158 role: "assistant",
159 modelID: "claude-sonnet-4-5",
160 time: { created: 1780000000000 },
161 cost: 0.25,
162 tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } },
163 };
164 const insert = (id: string, sessionId: string, data: string) =>
165 db.unsafe("INSERT INTO message VALUES (?, ?, ?)", [id, sessionId, data]);
166 yield* insert(message.id, message.sessionID, JSON.stringify(message));
167 const legacy = path.join(dir, "storage", "message", message.sessionID);
168 yield* fileSystem.makeDirectory(legacy, { recursive: true });
169 yield* fileSystem.writeFileString(path.join(legacy, "msg-1.json"), JSON.stringify(message));
170 const first = yield* readOpenCodeUsage(dir, 0);
171 assert.isFalse(first.error);
172 const records = first.files.flatMap((file) => file.records);
173 assert.strictEqual(records.length, 1);
174 assert.deepStrictEqual(records[0]?.totals, {
175 uncachedInputTokens: 100,
176 cachedInputTokens: 30,
177 cacheCreationTokens: 10,
178 outputTokens: 25,
179 reasoningTokens: 5,
180 });
181 assert.strictEqual(records[0]?.reportedCostUsd, 0.25);
182 yield* insert(
183 "msg-2",
184 message.sessionID,
185 JSON.stringify({ ...message, id: "msg-2", time: { created: 1780000001000 } }),
186 );
187 const next = yield* readOpenCodeUsage(dir, 1780000001000);
188 assert.isFalse(next.error);
189 assert.deepStrictEqual(
190 next.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
191 ["opencode:msg-2"],
192 );
193 assert.isAbove(Number((yield* fileSystem.stat(path.join(dir, "opencode.db-wal"))).size), 0);
194 }).pipe(Effect.provide(NodeServices.layer)),
195 );
196
197 it.effect("reads only legacy OpenCode messages written since the window opened", () =>
198 Effect.gen(function* () {
199 const fileSystem = yield* FileSystem.FileSystem;
200 const path = yield* Path.Path;
201 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
202 const sinceMs = 1780000000000;
203 const legacy = path.join(dir, "storage", "message", "session-1");
204 yield* fileSystem.makeDirectory(legacy, { recursive: true });
205 const write = Effect.fn(function* (id: string, mtimeMs: number) {
206 const file = path.join(legacy, `${id}.json`);
207 yield* fileSystem.writeFileString(
208 file,
209 JSON.stringify({
210 id,
211 sessionID: "session-1",
212 role: "assistant",
213 modelID: "claude-sonnet-4-5",
214 // In range, so an old file that was read would produce a record.
215 time: { created: sinceMs + 1000 },
216 tokens: { input: 100, output: 20 },
217 }),
218 );
219 yield* fileSystem.utimes(file, mtimeMs / 1000, mtimeMs / 1000);
220 return file;
221 });
222 yield* write("msg-old", sinceMs - 60_000);
223
224 const stale = yield* readOpenCodeUsage(dir, sinceMs);
225 assert.deepStrictEqual(stale, { files: [], missing: false, error: false });
226
227 const recent = yield* write("msg-new", sinceMs + 60_000);
228 const result = yield* readOpenCodeUsage(dir, sinceMs);
229 assert.isFalse(result.missing);
230 assert.isFalse(result.error);
231 assert.deepStrictEqual(
232 result.files.map((file) => file.path),
233 [recent],
234 );
235 assert.deepStrictEqual(
236 result.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
237 ["opencode:msg-new"],
238 );
239 }).pipe(Effect.provide(NodeServices.layer)),
240 );
241
242 it.effect("never follows symlinks in the legacy OpenCode store", () =>
243 Effect.gen(function* () {
244 const fileSystem = yield* FileSystem.FileSystem;
245 const path = yield* Path.Path;
246 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
247 const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
248 const sinceMs = 1780000000000;
249 const store = path.join(dir, "storage", "message");
250 const session = path.join(store, "session-1");
251 yield* fileSystem.makeDirectory(session, { recursive: true });
252 const write = Effect.fn(function* (directory: string, id: string, mtimeMs: number) {
253 const file = path.join(directory, `${id}.json`);
254 yield* fileSystem.writeFileString(
255 file,
256 JSON.stringify({
257 id,
258 sessionID: "session-1",
259 role: "assistant",
260 modelID: "claude-sonnet-4-5",
261 time: { created: sinceMs + 1000 },
262 tokens: { input: 100, output: 20 },
263 }),
264 );
265 yield* fileSystem.utimes(file, mtimeMs / 1000, mtimeMs / 1000);
266 return file;
267 });
268 const target = yield* write(outside, "msg-linked", sinceMs + 60_000);
269 const stale = yield* write(outside, "msg-stale", sinceMs - 60_000);
270 yield* fileSystem.symlink(target, path.join(session, "msg-linked.json"));
271 yield* fileSystem.symlink(stale, path.join(session, "msg-stale.json"));
272 yield* fileSystem.symlink(outside, path.join(store, "session-linked"));
273 yield* fileSystem.symlink(store, path.join(session, "cycle"));
274 yield* fileSystem.symlink(path.join(outside, "gone.json"), path.join(session, "gone.json"));
275 yield* fileSystem.symlink(path.join(session, "loop.json"), path.join(session, "loop.json"));
276
277 // Links alone are not a store, even when one points at a stale message.
278 const linksOnly = yield* readOpenCodeUsage(dir, sinceMs);
279 assert.deepStrictEqual(linksOnly, { files: [], missing: true, error: false });
280
281 const real = yield* write(session, "msg-real", sinceMs + 60_000);
282 const result = yield* readOpenCodeUsage(dir, sinceMs);
283 assert.isFalse(result.missing);
284 assert.isFalse(result.error);
285 assert.deepStrictEqual(
286 result.files.map((file) => file.path),
287 [real],
288 );
289 assert.deepStrictEqual(
290 result.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
291 ["opencode:msg-real"],
292 );
293 }).pipe(Effect.provide(NodeServices.layer)),
294 );
295
296 it.effect("reads a large legacy OpenCode session in directory order", () =>
297 Effect.gen(function* () {
298 const fileSystem = yield* FileSystem.FileSystem;
299 const path = yield* Path.Path;
300 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
301 const session = path.join(dir, "storage", "message", "session-1");
302 yield* fileSystem.makeDirectory(session, { recursive: true });
303 // More entries than the walk stats at once.
304 yield* Effect.forEach(
305 Array.from({ length: 600 }, (_, index) => `msg-${index}`),
306 (id) =>
307 fileSystem.writeFileString(
308 path.join(session, `${id}.json`),
309 JSON.stringify({
310 id,
311 sessionID: "session-1",
312 role: "assistant",
313 modelID: "claude-sonnet-4-5",
314 time: { created: 1780000000000 },
315 tokens: { input: 100, output: 20 },
316 }),
317 ),
318 { concurrency: 16, discard: true },
319 );
320 const names = yield* fileSystem.readDirectory(session);
321
322 const result = yield* readOpenCodeUsage(dir, 0);
323 assert.isFalse(result.error);
324 assert.deepStrictEqual(
325 result.files.map((file) => file.path),
326 names.map((name) => path.join(session, name)),
327 );
328 assert.strictEqual(result.files.flatMap((file) => file.records).length, 600);
329 }).pipe(Effect.provide(NodeServices.layer)),
330 );
331});
332import * as NodeSqlite from "node:sqlite";