| ... | @@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Path from "effect/Path"; | 8 | import * as Path from "effect/Path"; |
| 9 | import * as SqlClient from "effect/sql/SqlClient"; | 9 | import * as SqlClient from "effect/sql/SqlClient"; |
| | 10 | import { vi } from "vite-plus/test"; |
| 10 | | 11 | |
| 11 | import { readOpenCodeUsage } from "./usage.ts"; | 12 | import { readOpenCodeUsage } from "./usage.ts"; |
| 12 | | 13 | |
| ... | @@ -17,6 +18,75 @@ const openDatabase = (filename: string) => | ... | @@ -17,6 +18,75 @@ const openDatabase = (filename: string) => |
| 17 | ); | 18 | ); |
| 18 | | 19 | |
| 19 | describe("readOpenCodeUsage", () => { | 20 | describe("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 | |
| 20 | it.effect("counts migrated OpenCode messages once and sees subsequent WAL writes", () => | 90 | it.effect("counts migrated OpenCode messages once and sees subsequent WAL writes", () => |
| 21 | Effect.gen(function* () { | 91 | Effect.gen(function* () { |
| 22 | const fileSystem = yield* FileSystem.FileSystem; | 92 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | @@ -68,3 +138,4 @@ describe("readOpenCodeUsage", () => { | ... | @@ -68,3 +138,4 @@ describe("readOpenCodeUsage", () => { |
| 68 | }).pipe(Effect.provide(NodeServices.layer)), | 138 | }).pipe(Effect.provide(NodeServices.layer)), |
| 69 | ); | 139 | ); |
| 70 | }); | 140 | }); |
| | 141 | import * as NodeSqlite from "node:sqlite"; |