mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(agent): own SQLite backend tests in storage package (#7626)
* fix(agent): own SQLite backend tests in storage package * fix(agent): use lexical disposal for SQLite tests
This commit is contained in:
parent
b0d382e25a
commit
79cc1ef00a
15 changed files with 545 additions and 623 deletions
144
package-lock.json
generated
144
package-lock.json
generated
|
|
@ -479,9 +479,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -489,9 +489,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -499,13 +499,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
|
|
@ -524,14 +524,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -2320,6 +2320,37 @@
|
|||
"@vitest-evals/core": "0.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
|
|
@ -2468,6 +2499,18 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
|
||||
"integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
|
@ -3856,6 +3899,18 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
|
||||
"integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
|
|
@ -5397,61 +5452,6 @@
|
|||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"packages/agent/node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/agent/node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
|
||||
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"packages/agent/node_modules/magicast": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
|
||||
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/types": "^7.29.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"packages/agent/node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
|
|
@ -5733,6 +5733,10 @@
|
|||
"@earendil-works/pi-agent-core": "^0.83.0",
|
||||
"@earendil-works/pi-ai": "^0.83.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -466,6 +466,7 @@ export function createSessionBackendConformance(
|
|||
await session.setName("Second");
|
||||
await session.setLabel("user", "keep");
|
||||
await session.setLabel("user", undefined);
|
||||
await rejectsWithCode(session.setLabel("missing", "checkpoint"), "not_found");
|
||||
|
||||
strictEqual(await session.getName(), "Second");
|
||||
strictEqual(await session.getLabel("user"), undefined);
|
||||
|
|
|
|||
|
|
@ -1,127 +1,7 @@
|
|||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
AgentMessage,
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
Session as CoreSession,
|
||||
Entry,
|
||||
MessageEntry,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { afterEach } from "vitest";
|
||||
import type { SqliteSessionMetadata } from "../../../storage/sqlite-node/src/index.ts";
|
||||
export function createUserMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAssistantMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export type SqliteTestSession = CoreSession<SqliteSessionMetadata>;
|
||||
export type SqliteTestMessage = MessageEntry["message"];
|
||||
|
||||
export async function appendSqliteCompaction(
|
||||
session: SqliteTestSession,
|
||||
summary: string,
|
||||
tokensBefore: number,
|
||||
details?: unknown,
|
||||
usage?: Usage,
|
||||
retainedTail: SqliteTestMessage[] = [],
|
||||
): Promise<string> {
|
||||
const provisioned = {
|
||||
type: "compaction",
|
||||
id: session.idGenerator.next(),
|
||||
summary,
|
||||
retainedTail,
|
||||
tokensBefore,
|
||||
...(details === undefined ? {} : { details }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
} satisfies Omit<CompactionEntry, "parentId" | "seq" | "timestamp">;
|
||||
const entry = await session.appendEntry(provisioned, "main");
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
export async function moveSqliteMainLane(
|
||||
session: SqliteTestSession,
|
||||
entryId: string | null,
|
||||
summary?: { summary: string; details?: unknown; usage?: Usage },
|
||||
): Promise<string | undefined> {
|
||||
await session.moveLane("main", entryId);
|
||||
if (!summary) return undefined;
|
||||
const provisioned = {
|
||||
type: "branch_summary",
|
||||
id: session.idGenerator.next(),
|
||||
fromId: entryId ?? "root",
|
||||
summary: summary.summary,
|
||||
...(summary.details === undefined ? {} : { details: summary.details }),
|
||||
...(summary.usage === undefined ? {} : { usage: summary.usage }),
|
||||
} satisfies Omit<BranchSummaryEntry, "parentId" | "seq" | "timestamp">;
|
||||
const entry = await session.appendEntry(provisioned, "main");
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
export async function getSqliteBranch(session: SqliteTestSession, fromId?: string | null): Promise<Entry[]> {
|
||||
const start = fromId === undefined ? await session.getLeafId() : fromId;
|
||||
if (start === null) return [];
|
||||
const newestWindow = await session.findEntriesOnBranch({ start, stopAtType: "compaction" });
|
||||
return newestWindow.reverse();
|
||||
}
|
||||
|
||||
export async function getSqliteEntries(
|
||||
session: SqliteTestSession,
|
||||
options?: { afterEntrySeq?: number; limit?: number },
|
||||
): Promise<Entry[]> {
|
||||
return session.findEntries({
|
||||
order: "oldestFirst",
|
||||
limit: options?.limit,
|
||||
cursor: options?.afterEntrySeq === undefined ? undefined : { afterSeq: options.afterEntrySeq },
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendSqliteSessionName(session: SqliteTestSession, name: string): Promise<void> {
|
||||
await session.setName(name.replace(/[\r\n]+/g, " ").trim());
|
||||
}
|
||||
|
||||
export async function appendSqliteLabel(
|
||||
session: SqliteTestSession,
|
||||
targetId: string,
|
||||
label: string | undefined,
|
||||
): Promise<void> {
|
||||
await session.setLabel(targetId, label);
|
||||
}
|
||||
|
||||
export async function buildSqliteContext(session: SqliteTestSession): Promise<{ messages: SqliteTestMessage[] }> {
|
||||
const entries = await getSqliteBranch(session);
|
||||
const messages = entries.flatMap((entry): SqliteTestMessage[] => {
|
||||
if (entry.type === "message") return [entry.message];
|
||||
if (entry.type === "compaction") return entry.retainedTail;
|
||||
return [];
|
||||
});
|
||||
return { messages };
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
|
@ -135,8 +15,6 @@ export function createTempDir(): string {
|
|||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()!;
|
||||
if (existsSync(dir)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
"scripts": {
|
||||
"clean": "node -e \"import('node:fs/promises').then(fs=>fs.rm('dist',{recursive:true,force:true}))\"",
|
||||
"build": "tsgo -p tsconfig.build.json && node ./scripts/prepare-dist.mjs copy-sqlite-migrations",
|
||||
"test": "vitest --run",
|
||||
"coverage": "vitest --run --coverage",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"author": "Earendil Works",
|
||||
|
|
@ -34,5 +36,9 @@
|
|||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.83.0",
|
||||
"@earendil-works/pi-agent-core": "^0.83.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -629,7 +629,9 @@ function metadataFromRow(row: SessionRow, path: string): SqliteSessionMetadata {
|
|||
}
|
||||
|
||||
export class SqliteSessionRepository
|
||||
implements SessionRepository<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions>
|
||||
implements
|
||||
SessionRepository<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions>,
|
||||
AsyncDisposable
|
||||
{
|
||||
private databasePath: string | undefined;
|
||||
private database: SqliteDatabase | undefined;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createNodeSqliteFactory } from "../../../storage/sqlite-node/src/index.ts";
|
||||
import { createNodeSqliteFactory } from "../src/index.ts";
|
||||
|
||||
describe("node:sqlite adapter", () => {
|
||||
it("runs transaction callbacks synchronously", async () => {
|
||||
it("commits a synchronous transaction and returns its result", async () => {
|
||||
const db = await createNodeSqliteFactory().open(":memory:");
|
||||
try {
|
||||
db.exec("CREATE TABLE values_table (value INTEGER NOT NULL)");
|
||||
|
|
@ -18,6 +18,27 @@ describe("node:sqlite adapter", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("forwards positional and named statement parameters", async () => {
|
||||
const db = await createNodeSqliteFactory().open(":memory:");
|
||||
try {
|
||||
db.exec("CREATE TABLE values_table (id INTEGER PRIMARY KEY, value TEXT NOT NULL)");
|
||||
expect(db.prepare("INSERT INTO values_table (value) VALUES (?)").run("positional")).toEqual({
|
||||
changes: 1,
|
||||
lastInsertRowid: 1,
|
||||
});
|
||||
expect(db.prepare("INSERT INTO values_table (value) VALUES (:value)").run({ value: "named" })).toEqual({
|
||||
changes: 1,
|
||||
lastInsertRowid: 2,
|
||||
});
|
||||
expect(db.prepare("SELECT value FROM values_table WHERE id = ?").get(1)).toEqual({ value: "positional" });
|
||||
expect(db.prepare("SELECT value FROM values_table WHERE id >= :id ORDER BY id").all({ id: 2 })).toEqual([
|
||||
{ value: "named" },
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects asynchronous transaction callbacks", async () => {
|
||||
const db = await createNodeSqliteFactory().open(":memory:");
|
||||
try {
|
||||
|
|
@ -1,28 +1,23 @@
|
|||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNodeSqliteFactory, SqliteSessionRepository } from "../../../storage/sqlite-node/src/index.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { createNodeSqliteFactory, SqliteSessionRepository } from "../src/index.ts";
|
||||
import {
|
||||
appendSqliteCompaction,
|
||||
createAssistantMessage,
|
||||
createTempDir,
|
||||
createUserMessage,
|
||||
getSqliteBranch,
|
||||
moveSqliteMainLane,
|
||||
} from "./session-test-utils.ts";
|
||||
|
||||
function createTempDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-branch-cache-"));
|
||||
}
|
||||
} from "./test-utils.ts";
|
||||
|
||||
describe("SQLite branch cache", () => {
|
||||
it("collections complete root paths for branches created after compaction", async () => {
|
||||
it("collects complete root paths for branches created after compaction", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
const keptId = await session.appendMessage(createUserMessage("kept"));
|
||||
|
|
@ -51,7 +46,7 @@ describe("SQLite branch cache", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const oldId = await session.appendMessage(createUserMessage("old"));
|
||||
await session.appendMessage(createUserMessage("kept"));
|
||||
|
|
@ -74,49 +69,26 @@ describe("SQLite branch cache", () => {
|
|||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
await session.appendMessage(createUserMessage("root"));
|
||||
const firstCompactionId = await appendSqliteCompaction(session, "first summary", 100, undefined, undefined, []);
|
||||
const middleId = await session.appendMessage(createUserMessage("middle"));
|
||||
await appendSqliteCompaction(session, "first summary", 100, undefined, undefined, []);
|
||||
await session.appendMessage(createUserMessage("middle"));
|
||||
const secondCompactionId = await appendSqliteCompaction(session, "second summary", 200);
|
||||
const leafId = await session.appendMessage(createAssistantMessage("new"));
|
||||
|
||||
expect(firstCompactionId).not.toBe(secondCompactionId);
|
||||
expect(middleId).not.toBe(leafId);
|
||||
expect((await getSqliteBranch(session)).map((entry) => entry.id)).toEqual([secondCompactionId, leafId]);
|
||||
});
|
||||
|
||||
it("fails loudly when the private branch cache is missing", async () => {
|
||||
it("rejects reads and writes without repairing a missing private branch cache", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
const childId = await session.appendMessage(createAssistantMessage("child"));
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
await db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run("session-1");
|
||||
await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run("session-1");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
expect(rootId).not.toBe(childId);
|
||||
await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" });
|
||||
});
|
||||
|
||||
it("does not repair the private branch cache during normal reads", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
await session.appendMessage(createUserMessage("root"));
|
||||
await session.appendMessage(createAssistantMessage("child"));
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
|
|
@ -127,6 +99,10 @@ describe("SQLite branch cache", () => {
|
|||
}
|
||||
|
||||
await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" });
|
||||
await expect(session.appendMessage(createAssistantMessage("later"))).rejects.toMatchObject({
|
||||
code: "invalid_entry",
|
||||
message: expect.stringContaining("has no branch containing parent entry"),
|
||||
});
|
||||
|
||||
const inspection = await sqlite.open(databasePath);
|
||||
try {
|
||||
|
|
@ -143,7 +119,7 @@ describe("SQLite branch cache", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
const childId = await session.appendMessage(createAssistantMessage("child"));
|
||||
|
|
@ -169,7 +145,7 @@ describe("SQLite branch cache", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const source = await repo.create({ cwd: root, id: "source" });
|
||||
const rootId = await source.appendMessage(createUserMessage("root"));
|
||||
const childId = await source.appendMessage(createAssistantMessage("child"));
|
||||
|
|
@ -198,7 +174,7 @@ describe("SQLite branch cache", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
const staleId = await session.appendMessage(createAssistantMessage("stale"));
|
||||
|
|
@ -224,7 +200,7 @@ describe("SQLite branch cache", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
await session.appendMessage(createUserMessage("root"));
|
||||
const metadata = await session.getMetadata();
|
||||
|
|
@ -1,17 +1,13 @@
|
|||
import { join } from "node:path";
|
||||
import type { SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
createNodeSqliteFactory,
|
||||
type SqliteSessionMetadata,
|
||||
SqliteSessionRepository,
|
||||
} from "../../../../storage/sqlite-node/src/index.ts";
|
||||
import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import {
|
||||
createSessionBackendConformance,
|
||||
type SessionBackendFixture,
|
||||
} from "../../../src/harness/session/testing/index.ts";
|
||||
import { createTempDir } from "../session-test-utils.ts";
|
||||
} from "@earendil-works/pi-agent-core/session/testing";
|
||||
import { describe, it } from "vitest";
|
||||
import { createNodeSqliteFactory, type SqliteSessionMetadata, SqliteSessionRepository } from "../src/index.ts";
|
||||
import { createTempDir } from "./test-utils.ts";
|
||||
|
||||
function requireSqliteMetadata(metadata: SessionMetadata): SqliteSessionMetadata {
|
||||
const cwd = "cwd" in metadata ? metadata.cwd : undefined;
|
||||
|
|
@ -1,14 +1,8 @@
|
|||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createNodeSqliteFactory,
|
||||
type SqliteSessionMetadata,
|
||||
SqliteSessionRepository,
|
||||
} from "../../../storage/sqlite-node/src/index.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { createTempDir, createUserMessage } from "./session-test-utils.ts";
|
||||
|
||||
const repositories: SqliteSessionRepository[] = [];
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createNodeSqliteFactory, type SqliteSessionMetadata, SqliteSessionRepository } from "../src/index.ts";
|
||||
import { createTempDir, createUserMessage } from "./test-utils.ts";
|
||||
|
||||
function createRepository(root: string, databasePath: string, lease?: { ttlMs: number; heartbeatIntervalMs: number }) {
|
||||
const repository = new SqliteSessionRepository({
|
||||
|
|
@ -17,21 +11,49 @@ function createRepository(root: string, databasePath: string, lease?: { ttlMs: n
|
|||
databasePath,
|
||||
writerLease: lease,
|
||||
});
|
||||
repositories.push(repository);
|
||||
return repository;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
for (const repository of repositories.splice(0)) await repository.close();
|
||||
});
|
||||
|
||||
describe("SQLite session writer leases", () => {
|
||||
it("shares one write queue across repeated opens in one repository", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
await using repository = createRepository(root, databasePath);
|
||||
const session = await repository.create({ cwd: root, id: "session" });
|
||||
const reopened = await repository.open(await session.getMetadata());
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
session.appendMessage(createUserMessage("first")),
|
||||
reopened.appendMessage(createUserMessage("second")),
|
||||
]);
|
||||
|
||||
expect((await session.findEntries({ order: "oldestFirst" })).map((entry) => entry.id)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ ttlMs: 0, heartbeatIntervalMs: 1 }, "writerLease.ttlMs must be positive"],
|
||||
[
|
||||
{ ttlMs: 100, heartbeatIntervalMs: 100 },
|
||||
"writerLease.heartbeatIntervalMs must be positive and less than ttlMs",
|
||||
],
|
||||
])("rejects invalid lease timing %o", (writerLease, message) => {
|
||||
const root = createTempDir();
|
||||
expect(
|
||||
() =>
|
||||
new SqliteSessionRepository({
|
||||
env: new NodeExecutionEnv({ cwd: root }),
|
||||
sqlite: createNodeSqliteFactory(),
|
||||
databasePath: join(root, "sessions.sqlite"),
|
||||
writerLease,
|
||||
}),
|
||||
).toThrow(message);
|
||||
});
|
||||
|
||||
it("rejects a second writer until the first session releases its claim", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const firstRepository = createRepository(root, databasePath);
|
||||
const secondRepository = createRepository(root, databasePath);
|
||||
await using firstRepository = createRepository(root, databasePath);
|
||||
await using secondRepository = createRepository(root, databasePath);
|
||||
const first = await firstRepository.create({ cwd: root, id: "session-1" });
|
||||
const metadata = await first.getMetadata();
|
||||
|
||||
|
|
@ -49,8 +71,8 @@ describe("SQLite session writer leases", () => {
|
|||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const lease = { ttlMs: 120_000, heartbeatIntervalMs: 60_000 };
|
||||
const firstRepository = createRepository(root, databasePath, lease);
|
||||
const secondRepository = createRepository(root, databasePath, lease);
|
||||
await using firstRepository = createRepository(root, databasePath, lease);
|
||||
await using secondRepository = createRepository(root, databasePath, lease);
|
||||
const first = await firstRepository.create({ cwd: root, id: "session-1" });
|
||||
const metadata = await first.getMetadata();
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
|
|
@ -94,7 +116,7 @@ describe("SQLite session writer leases", () => {
|
|||
it("serializes lease-checked writes for sessions sharing one database connection", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const repository = createRepository(root, databasePath);
|
||||
await using repository = createRepository(root, databasePath);
|
||||
const first = await repository.create({ cwd: root, id: "session-1" });
|
||||
const second = await repository.create({ cwd: root, id: "session-2" });
|
||||
|
||||
|
|
@ -111,7 +133,7 @@ describe("SQLite session writer leases", () => {
|
|||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const repository = createRepository(root, databasePath, { ttlMs: 30_000, heartbeatIntervalMs: 10_000 });
|
||||
await using repository = createRepository(root, databasePath, { ttlMs: 30_000, heartbeatIntervalMs: 10_000 });
|
||||
const session = await repository.create({ cwd: root, id: "session-1" });
|
||||
const metadata = (await session.getMetadata()) as SqliteSessionMetadata;
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
41
packages/storage/sqlite-node/test/migrations.test.ts
Normal file
41
packages/storage/sqlite-node/test/migrations.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMigrations, createNodeSqliteFactory } from "../src/index.ts";
|
||||
import { createTempDir } from "./test-utils.ts";
|
||||
|
||||
describe("SQLite migrations", () => {
|
||||
it("applies the current schema once and records its migration", async () => {
|
||||
const databasePath = join(createTempDir(), "sessions.sqlite");
|
||||
const db = await createNodeSqliteFactory().open(databasePath);
|
||||
try {
|
||||
await applyMigrations(db);
|
||||
await applyMigrations(db);
|
||||
|
||||
const rows = db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>();
|
||||
expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]);
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.all<{ name: string }>();
|
||||
expect(tables.map((row) => row.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"migrations",
|
||||
"sessions",
|
||||
"entries",
|
||||
"session_sequences",
|
||||
"session_stats",
|
||||
"branch_entries",
|
||||
"branch_tips",
|
||||
"lanes",
|
||||
"records",
|
||||
"lane_moves",
|
||||
"facts",
|
||||
"leases",
|
||||
]),
|
||||
);
|
||||
const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>();
|
||||
expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,31 +1,23 @@
|
|||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNodeSqliteFactory,
|
||||
type SqliteDatabase,
|
||||
type SqliteDatabaseFactory,
|
||||
type SqliteRunResult,
|
||||
type SqliteSessionMetadata,
|
||||
SqliteSessionRepository,
|
||||
type SqliteStatement,
|
||||
} from "../../../storage/sqlite-node/src/index.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
} from "../src/index.ts";
|
||||
import {
|
||||
appendSqliteCompaction,
|
||||
appendSqliteLabel,
|
||||
appendSqliteSessionName,
|
||||
buildSqliteContext,
|
||||
createAssistantMessage,
|
||||
createSetupFailureSqlite,
|
||||
createTempDir,
|
||||
createUserMessage,
|
||||
getSqliteEntries,
|
||||
moveSqliteMainLane,
|
||||
} from "./session-test-utils.ts";
|
||||
|
||||
function createTempDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-"));
|
||||
}
|
||||
} from "./test-utils.ts";
|
||||
|
||||
class ThrowingStatement implements SqliteStatement {
|
||||
private readonly onRun: () => SqliteRunResult;
|
||||
|
|
@ -96,66 +88,12 @@ function createCloseCountingSqliteFactory(): {
|
|||
};
|
||||
}
|
||||
|
||||
describe("SQLite migrations", () => {
|
||||
it("applies file-based migrations and records them", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await repo.create({ cwd: root, id: "session-1" });
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
const rows = await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>();
|
||||
expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]);
|
||||
const tables = await db
|
||||
.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.all<{ name: string; sql: string | null }>();
|
||||
expect(tables.map((row) => row.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"migrations",
|
||||
"sessions",
|
||||
"entries",
|
||||
"session_sequences",
|
||||
"branch_entries",
|
||||
"branch_tips",
|
||||
]),
|
||||
);
|
||||
const sessionColumns = await db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>();
|
||||
expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id");
|
||||
expect(tables.map((row) => row.name)).toEqual(
|
||||
expect.arrayContaining(["lanes", "records", "lane_moves", "facts", "leases", "session_stats"]),
|
||||
);
|
||||
const branchIndexes = await db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'branch_entries'")
|
||||
.all<{ name: string }>();
|
||||
expect(branchIndexes.map((index) => index.name)).toContain("idx_branch_entries_session_branch_seq");
|
||||
expect(branchIndexes.map((index) => index.name)).not.toContain("idx_branch_entries_session_branch");
|
||||
for (const tableName of [
|
||||
"sessions",
|
||||
"session_sequences",
|
||||
"session_stats",
|
||||
"branch_entries",
|
||||
"branch_tips",
|
||||
"lanes",
|
||||
"records",
|
||||
"lane_moves",
|
||||
"facts",
|
||||
]) {
|
||||
const table = tables.find((row) => row.name === tableName);
|
||||
expect(table?.sql).toContain("WITHOUT ROWID");
|
||||
}
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("SQLite session repository", () => {
|
||||
it("persists session metadata through create, list, open, and fork", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
const source = await repo.create({
|
||||
cwd: root,
|
||||
id: "session-1",
|
||||
|
|
@ -181,7 +119,7 @@ describe("SQLite migrations", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const source = await repo.create({ cwd: root, id: "source" });
|
||||
await source.appendMessage(createUserMessage("one"));
|
||||
await source.appendMessage(createAssistantMessage("two"));
|
||||
|
|
@ -215,94 +153,7 @@ END;
|
|||
}
|
||||
});
|
||||
|
||||
it("materializes the main lane leaf transactionally", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
const childId = await session.appendMessage(createAssistantMessage("child"));
|
||||
await moveSqliteMainLane(session, rootId);
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
const row = await db
|
||||
.prepare("SELECT leaf_id FROM lanes WHERE session_id = ? AND lane = ?")
|
||||
.get<{ leaf_id: string | null }>("session-1", "main");
|
||||
expect(row?.leaf_id).toBe(rootId);
|
||||
const latestLaneMove = await db
|
||||
.prepare("SELECT lane, leaf_id FROM lane_moves WHERE session_id = ? ORDER BY seq DESC LIMIT 1")
|
||||
.get<{ lane: string; leaf_id: string | null }>("session-1");
|
||||
expect(latestLaneMove).toEqual({ lane: "main", leaf_id: rootId });
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
const reopened = await repo.open(await session.getMetadata());
|
||||
expect(await reopened.getLeafId()).toBe(rootId);
|
||||
expect(childId).not.toBe(rootId);
|
||||
});
|
||||
|
||||
it("materializes a new branch when appending from a parent with an existing child", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
const firstChildId = await session.appendMessage(createAssistantMessage("first child"));
|
||||
await moveSqliteMainLane(session, rootId);
|
||||
const secondChildId = await session.appendMessage(createAssistantMessage("second child"));
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
const branchRows = await db
|
||||
.prepare(
|
||||
"SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY branch_id, entry_seq",
|
||||
)
|
||||
.all<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1");
|
||||
const branchIds = [...new Set(branchRows.map((row) => row.branch_id))];
|
||||
expect(branchIds).toHaveLength(2);
|
||||
expect(branchRows.filter((row) => row.entry_id === rootId)).toHaveLength(2);
|
||||
expect(branchRows.filter((row) => row.entry_id === firstChildId)).toHaveLength(1);
|
||||
expect(branchRows.filter((row) => row.entry_id === secondChildId)).toHaveLength(1);
|
||||
const tips = await db
|
||||
.prepare("SELECT branch_id, tip_id FROM branch_tips WHERE session_id = ? ORDER BY branch_id")
|
||||
.all<{ branch_id: string; tip_id: string }>("session-1");
|
||||
expect(tips.map((tip) => tip.branch_id)).toEqual(branchIds.sort());
|
||||
expect(new Set(tips.map((tip) => tip.tip_id)).size).toBe(tips.length);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reopens using branch materialization and session summary state", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
await session.appendMessage(createAssistantMessage("first child"));
|
||||
await appendSqliteSessionName(session, " Reopened Session ");
|
||||
await moveSqliteMainLane(session, rootId);
|
||||
await session.appendMessage(createAssistantMessage("branched child"));
|
||||
|
||||
const reopened = await repo.open(await session.getMetadata());
|
||||
expect(await reopened.getName()).toBe("Reopened Session");
|
||||
expect((await buildSqliteContext(reopened)).messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
expect((await buildSqliteContext(reopened)).messages.at(-1)).toMatchObject({
|
||||
content: [{ type: "text", text: "branched child" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the database when create fails after openDatabase succeeds", async () => {
|
||||
it("retains an opened database after a failed operation until disposal", async () => {
|
||||
const root = createTempDir();
|
||||
const db = new CountingDatabase((sql) => {
|
||||
if (sql.startsWith("INSERT INTO sessions")) {
|
||||
|
|
@ -316,7 +167,7 @@ END;
|
|||
open: async () => db,
|
||||
};
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
|
||||
|
||||
await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed");
|
||||
expect(db.closeCount).toBe(0);
|
||||
|
|
@ -324,31 +175,17 @@ END;
|
|||
expect(db.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("closes the database when open fails after openDatabase succeeds", async () => {
|
||||
it("closes the database when repository setup fails", async () => {
|
||||
const root = createTempDir();
|
||||
const db = new CountingDatabase((sql) => {
|
||||
if (sql.includes("FROM sessions WHERE id = ?")) {
|
||||
return new ThrowingStatement(() => ({ changes: 0 }));
|
||||
}
|
||||
return new ThrowingStatement(() => ({ changes: 1 }));
|
||||
const { sqlite, counts } = createSetupFailureSqlite();
|
||||
await using repository = new SqliteSessionRepository({
|
||||
env: new NodeExecutionEnv({ cwd: root }),
|
||||
sqlite,
|
||||
databasePath: join(root, "sessions.sqlite"),
|
||||
});
|
||||
const sqlite: SqliteDatabaseFactory = {
|
||||
open: async () => db,
|
||||
};
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
|
||||
const metadata: SqliteSessionMetadata = {
|
||||
id: "missing",
|
||||
createdAt: Date.now(),
|
||||
cwd: root,
|
||||
path: join(root, "sessions.sqlite"),
|
||||
};
|
||||
writeFileSync(metadata.path, "");
|
||||
|
||||
await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing");
|
||||
expect(db.closeCount).toBe(0);
|
||||
await repo[Symbol.asyncDispose]();
|
||||
expect(db.closeCount).toBe(1);
|
||||
await expect(repository.create({ cwd: root, id: "session-1" })).rejects.toThrow("setup failed");
|
||||
expect(counts.closes).toBe(1);
|
||||
});
|
||||
|
||||
it("retains one connection for repeated session operations", async () => {
|
||||
|
|
@ -356,7 +193,7 @@ END;
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const { sqlite, counts } = createCloseCountingSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
for (let i = 0; i < 10; i++) await session.appendMessage(createUserMessage(`message ${i}`));
|
||||
|
|
@ -368,27 +205,12 @@ END;
|
|||
expect(counts).toEqual({ opens: 1, closes: 1 });
|
||||
});
|
||||
|
||||
it("shares one connection across source and fork until the repository is disposed", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const { sqlite, counts } = createCloseCountingSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const source = await repo.create({ cwd: root, id: "session-1" });
|
||||
|
||||
const fork = await repo.fork(await source.getMetadata(), { cwd: root, id: "session-2" });
|
||||
await fork.appendMessage(createUserMessage("fork"));
|
||||
expect(counts).toEqual({ opens: 1, closes: 0 });
|
||||
await repo[Symbol.asyncDispose]();
|
||||
expect(counts).toEqual({ opens: 1, closes: 1 });
|
||||
});
|
||||
|
||||
it("rejects a missing lane leaf when listing lanes and opening", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const metadata = await session.getMetadata();
|
||||
|
||||
|
|
@ -411,12 +233,36 @@ END;
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["invalid JSON", "not json", "metadata is not valid JSON"],
|
||||
["a non-object value", "[]", "metadata must be an object"],
|
||||
])("rejects stored session metadata containing %s", async (_case, stored, message) => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await repo.create({ cwd: root, id: "session-1" });
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
await db.prepare("UPDATE sessions SET metadata = ? WHERE id = ?").run(stored, "session-1");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
await expect(repo.list()).rejects.toMatchObject({
|
||||
code: "storage",
|
||||
message: expect.stringContaining(message),
|
||||
});
|
||||
});
|
||||
|
||||
it("fails loudly when a stored entry is read and cannot be decoded", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const entryId = await session.appendMessage(createUserMessage("message"));
|
||||
const metadata = await session.getMetadata();
|
||||
|
|
@ -434,12 +280,42 @@ END;
|
|||
await expect(getSqliteEntries(reopened)).rejects.toMatchObject({ code: "invalid_entry" });
|
||||
});
|
||||
|
||||
it("fails loudly when a stored record cannot be decoded", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
await session.appendRecord({
|
||||
type: "operation_finished",
|
||||
id: "record-1",
|
||||
lane: "main",
|
||||
runId: "run-1",
|
||||
outcome: "completed",
|
||||
});
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
await db
|
||||
.prepare("UPDATE records SET payload = ? WHERE session_id = ? AND id = ?")
|
||||
.run("not json", "session-1", "record-1");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
await expect(session.findRecords()).rejects.toMatchObject({
|
||||
code: "storage",
|
||||
message: expect.stringContaining("failed to decode payload"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish connection state when an append transaction fails", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
|
|
@ -466,12 +342,12 @@ END;
|
|||
expect(await session.getStats()).toMatchObject({ messageCount: 1 });
|
||||
});
|
||||
|
||||
it("materializes session summary fields transactionally", async () => {
|
||||
it("accounts for assistant, compaction, and branch-summary usage", async () => {
|
||||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
await using repo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const userId = await session.appendMessage(createUserMessage("one"));
|
||||
const assistant = {
|
||||
|
|
@ -543,55 +419,12 @@ END;
|
|||
stopReason: "stop",
|
||||
usage: branchUsage,
|
||||
});
|
||||
await appendSqliteSessionName(session, " My Session ");
|
||||
await appendSqliteLabel(session, userId, "checkpoint");
|
||||
|
||||
expect(await session.getStats()).toMatchObject({
|
||||
expect(await session.getStats()).toEqual({
|
||||
messageCount: 2,
|
||||
cachedTokens: 50,
|
||||
uncachedTokens: 128,
|
||||
totalTokens: 211,
|
||||
costTotal: 0.73,
|
||||
});
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
expect(
|
||||
await db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_materialized'")
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await db
|
||||
.prepare("SELECT type, COUNT(*) AS count FROM records WHERE session_id = ? AND type = ? GROUP BY type")
|
||||
.get<{ type: string; count: number }>("session-1", "usage"),
|
||||
).toEqual({ type: "usage", count: 3 });
|
||||
expect(
|
||||
await db
|
||||
.prepare(
|
||||
`SELECT message_count, cached_tokens, uncached_tokens, total_tokens, cost_total
|
||||
FROM session_stats
|
||||
WHERE session_id = ?`,
|
||||
)
|
||||
.get("session-1"),
|
||||
).toEqual({
|
||||
message_count: 2,
|
||||
cached_tokens: 50,
|
||||
uncached_tokens: 128,
|
||||
total_tokens: 211,
|
||||
cost_total: 0.73,
|
||||
});
|
||||
const nameFact = await db
|
||||
.prepare("SELECT value FROM facts WHERE session_id = ? AND kind = 'name' ORDER BY seq DESC LIMIT 1")
|
||||
.get<{ value: string }>("session-1");
|
||||
expect(JSON.parse(nameFact?.value ?? "null")).toBe("My Session");
|
||||
const labelFact = await db
|
||||
.prepare("SELECT key, value FROM facts WHERE session_id = ? AND kind = 'label' ORDER BY seq DESC LIMIT 1")
|
||||
.get<{ key: string; value: string }>("session-1");
|
||||
expect(labelFact?.key).toBe(userId);
|
||||
expect(JSON.parse(labelFact?.value ?? "null")).toBe("checkpoint");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,73 +1,28 @@
|
|||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNodeSqliteFactory,
|
||||
createSqliteSessionSearch,
|
||||
type SqliteSessionMetadata,
|
||||
SqliteSessionRepository,
|
||||
} from "../../../storage/sqlite-node/src/index.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import type { SessionSearchOptions } from "../../src/harness/session/search.ts";
|
||||
import { createTempDir, createUserMessage, getSqliteEntries } from "./session-test-utils.ts";
|
||||
|
||||
const ownedRepositories: AsyncDisposable[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const repository of ownedRepositories.splice(0)) await repository[Symbol.asyncDispose]();
|
||||
});
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNodeSqliteFactory, createSqliteSessionSearch, SqliteSessionRepository } from "../src/index.ts";
|
||||
import { createSetupFailureSqlite, createTempDir, createUserMessage, getSqliteEntries } from "./test-utils.ts";
|
||||
|
||||
function createSqliteFixture(options: ConstructorParameters<typeof SqliteSessionRepository>[0]) {
|
||||
const repository = new SqliteSessionRepository(options);
|
||||
ownedRepositories.push(repository);
|
||||
return { repository, search: createSqliteSessionSearch(options) };
|
||||
return {
|
||||
repository,
|
||||
search: createSqliteSessionSearch(options),
|
||||
[Symbol.asyncDispose]: () => repository[Symbol.asyncDispose](),
|
||||
};
|
||||
}
|
||||
|
||||
describe("SqliteSessionRepository writer leases", () => {
|
||||
it("shares one storage queue for repeated opens in the same repository", async () => {
|
||||
describe("SQLite FTS5 session search", () => {
|
||||
it("matches trigrams within one cwd", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const { repository: repo } = createSqliteFixture({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session" });
|
||||
const reopened = await repo.open(await session.getMetadata());
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
session.appendMessage(createUserMessage("first")),
|
||||
reopened.appendMessage(createUserMessage("second")),
|
||||
]);
|
||||
|
||||
expect((await getSqliteEntries(session)).map((entry) => entry.id)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it("rejects a second repository while a session lease is active", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const { repository: firstRepo } = createSqliteFixture({ env, sqlite, databasePath });
|
||||
const session = await firstRepo.create({ cwd: root, id: "session" });
|
||||
const metadata = await session.getMetadata();
|
||||
const secondRepo = new SqliteSessionRepository({ env, sqlite, databasePath });
|
||||
ownedRepositories.push(secondRepo);
|
||||
|
||||
await expect(secondRepo.open(metadata)).rejects.toMatchObject({ code: "storage" });
|
||||
|
||||
await firstRepo.close();
|
||||
await expect(secondRepo.open(metadata)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => {
|
||||
it("uses SQLite FTS5 when composed with its search implementation", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const { repository: repo, search } = createSqliteFixture({ env, sqlite, databasePath });
|
||||
await using fixture = createSqliteFixture({ env, sqlite, databasePath });
|
||||
const { repository: repo, search } = fixture;
|
||||
const included = await repo.create({ cwd: root, id: "included" });
|
||||
const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" });
|
||||
const metadata = await included.getMetadata();
|
||||
const entryId = await included.appendMessage(createUserMessage("Find the auth defect"));
|
||||
await excluded.appendMessage(createUserMessage("Find the auth defect"));
|
||||
|
||||
|
|
@ -77,28 +32,47 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => {
|
|||
await expect(search.search({ text: "uth", cwd: root })).resolves.toEqual([
|
||||
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }),
|
||||
]);
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
const tables = await db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.all<{ name: string }>();
|
||||
expect(tables.map((row) => row.name)).toContain("session_search_fts");
|
||||
expect(tables.map((row) => row.name)).not.toContain("session_search_records");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
await repo.delete(metadata);
|
||||
await expect(search.search({ text: "auth", cwd: root })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("creates an empty canonical session without initializing FTS", async () => {
|
||||
it("handles quoted search text without exposing FTS syntax", async () => {
|
||||
const root = createTempDir();
|
||||
await using fixture = createSqliteFixture({
|
||||
env: new NodeExecutionEnv({ cwd: root }),
|
||||
sqlite: createNodeSqliteFactory(),
|
||||
databasePath: join(root, "sessions.sqlite"),
|
||||
});
|
||||
const { search } = fixture;
|
||||
|
||||
await expect(search.search({ text: 'missing "phrase"' })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("removes deleted session entries from the index", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
await using fixture = createSqliteFixture({
|
||||
env,
|
||||
sqlite: createNodeSqliteFactory(),
|
||||
databasePath,
|
||||
});
|
||||
const { repository, search } = fixture;
|
||||
const session = await repository.create({ cwd: root, id: "session-1" });
|
||||
await session.appendMessage(createUserMessage("Find the auth defect"));
|
||||
await expect(search.search({ text: "auth" })).resolves.toHaveLength(1);
|
||||
|
||||
await repository.delete(await session.getMetadata());
|
||||
|
||||
await expect(search.search({ text: "auth" })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("does not initialize FTS for canonical writes or blank searches", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const { repository: repo } = createSqliteFixture({ env, sqlite, databasePath });
|
||||
await using fixture = createSqliteFixture({ env, sqlite, databasePath });
|
||||
const { repository: repo, search } = fixture;
|
||||
await expect(search.search({ text: " " })).resolves.toEqual([]);
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
|
|
@ -118,7 +92,8 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => {
|
|||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const { repository: repo, search } = createSqliteFixture({ env, sqlite, databasePath });
|
||||
await using fixture = createSqliteFixture({ env, sqlite, databasePath });
|
||||
const { repository: repo, search } = fixture;
|
||||
await search.search({ text: "initialize" });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
|
||||
|
|
@ -138,7 +113,8 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => {
|
|||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const { repository: repo, search } = createSqliteFixture({ env, sqlite, databasePath });
|
||||
await using fixture = createSqliteFixture({ env, sqlite, databasePath });
|
||||
const { repository: repo, search } = fixture;
|
||||
await search.search({ text: "initialize" });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
await session.appendMessage(createUserMessage("must remain"));
|
||||
|
|
@ -156,14 +132,28 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => {
|
|||
await expect(getSqliteEntries(reopened)).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closes the database when search setup fails", async () => {
|
||||
const root = createTempDir();
|
||||
const { sqlite, counts } = createSetupFailureSqlite();
|
||||
const search = createSqliteSessionSearch({
|
||||
env: new NodeExecutionEnv({ cwd: root }),
|
||||
sqlite,
|
||||
databasePath: join(root, "sessions.sqlite"),
|
||||
});
|
||||
|
||||
await expect(search.search({ text: "auth" })).rejects.toThrow("setup failed");
|
||||
expect(counts.closes).toBe(1);
|
||||
});
|
||||
|
||||
it("initializes canonical storage when searched before the first session is created", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const { repository: repo, search } = createSqliteFixture({
|
||||
await using fixture = createSqliteFixture({
|
||||
env,
|
||||
sqlite: createNodeSqliteFactory(),
|
||||
databasePath: join(root, "sessions.sqlite"),
|
||||
});
|
||||
const { repository: repo, search } = fixture;
|
||||
|
||||
await expect(search.search({ text: "auth" })).resolves.toEqual([]);
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
|
|
@ -175,30 +165,3 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => {
|
|||
await expect(session.appendMessage(createUserMessage("Still writable"))).resolves.toBeTypeOf("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqliteSessionRepository with custom search", () => {
|
||||
it("uses an independently supplied search implementation", async () => {
|
||||
const root = createTempDir();
|
||||
const searches: SessionSearchOptions[] = [];
|
||||
const search: {
|
||||
search(options: SessionSearchOptions): Promise<{ metadata: SqliteSessionMetadata; entryId: string }[]>;
|
||||
} = {
|
||||
async search(options) {
|
||||
searches.push(options);
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepository({
|
||||
env,
|
||||
sqlite: createNodeSqliteFactory(),
|
||||
databasePath: join(root, "sessions.sqlite"),
|
||||
});
|
||||
ownedRepositories.push(repo);
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
await session.appendMessage(createUserMessage("stored canonically"));
|
||||
|
||||
await expect(search.search({ text: "custom query" })).resolves.toEqual([]);
|
||||
expect(searches).toEqual([{ text: "custom query" }]);
|
||||
});
|
||||
});
|
||||
144
packages/storage/sqlite-node/test/test-utils.ts
Normal file
144
packages/storage/sqlite-node/test/test-utils.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
AgentMessage,
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
Session as CoreSession,
|
||||
Entry,
|
||||
MessageEntry,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, vi } from "vitest";
|
||||
import type { SqliteDatabaseFactory, SqliteSessionMetadata } from "../src/index.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
export function createTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-storage-sqlite-node-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function createSetupFailureSqlite(): {
|
||||
sqlite: SqliteDatabaseFactory;
|
||||
counts: { closes: number };
|
||||
} {
|
||||
const counts = { closes: 0 };
|
||||
return {
|
||||
counts,
|
||||
sqlite: {
|
||||
async open() {
|
||||
return {
|
||||
exec() {
|
||||
throw new Error("setup failed");
|
||||
},
|
||||
prepare() {
|
||||
throw new Error("unexpected prepare");
|
||||
},
|
||||
transaction<T>(_fn: () => T): T {
|
||||
throw new Error("unexpected transaction");
|
||||
},
|
||||
close() {
|
||||
counts.closes += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUserMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAssistantMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export type SqliteTestSession = CoreSession<SqliteSessionMetadata>;
|
||||
export type SqliteTestMessage = MessageEntry["message"];
|
||||
|
||||
export async function appendSqliteCompaction(
|
||||
session: SqliteTestSession,
|
||||
summary: string,
|
||||
tokensBefore: number,
|
||||
details?: unknown,
|
||||
usage?: Usage,
|
||||
retainedTail: SqliteTestMessage[] = [],
|
||||
): Promise<string> {
|
||||
const provisioned = {
|
||||
type: "compaction",
|
||||
id: session.idGenerator.next(),
|
||||
summary,
|
||||
retainedTail,
|
||||
tokensBefore,
|
||||
...(details === undefined ? {} : { details }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
} satisfies Omit<CompactionEntry, "parentId" | "seq" | "timestamp">;
|
||||
const entry = await session.appendEntry(provisioned, "main");
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
export async function moveSqliteMainLane(
|
||||
session: SqliteTestSession,
|
||||
entryId: string | null,
|
||||
summary?: { summary: string; details?: unknown; usage?: Usage },
|
||||
): Promise<string | undefined> {
|
||||
await session.moveLane("main", entryId);
|
||||
if (!summary) return undefined;
|
||||
const provisioned = {
|
||||
type: "branch_summary",
|
||||
id: session.idGenerator.next(),
|
||||
fromId: entryId ?? "root",
|
||||
summary: summary.summary,
|
||||
...(summary.details === undefined ? {} : { details: summary.details }),
|
||||
...(summary.usage === undefined ? {} : { usage: summary.usage }),
|
||||
} satisfies Omit<BranchSummaryEntry, "parentId" | "seq" | "timestamp">;
|
||||
const entry = await session.appendEntry(provisioned, "main");
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
export async function getSqliteBranch(session: SqliteTestSession, fromId?: string | null): Promise<Entry[]> {
|
||||
const start = fromId === undefined ? await session.getLeafId() : fromId;
|
||||
if (start === null) return [];
|
||||
const newestWindow = await session.findEntriesOnBranch({ start, stopAtType: "compaction" });
|
||||
return newestWindow.reverse();
|
||||
}
|
||||
|
||||
export async function getSqliteEntries(
|
||||
session: SqliteTestSession,
|
||||
options?: { afterEntrySeq?: number; limit?: number },
|
||||
): Promise<Entry[]> {
|
||||
return session.findEntries({
|
||||
order: "oldestFirst",
|
||||
limit: options?.limit,
|
||||
cursor: options?.afterEntrySeq === undefined ? undefined : { afterSeq: options.afterEntrySeq },
|
||||
});
|
||||
}
|
||||
31
packages/storage/sqlite-node/vitest.config.ts
Normal file
31
packages/storage/sqlite-node/vitest.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url));
|
||||
const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url));
|
||||
const agentNode = fileURLToPath(new URL("../../agent/src/node.ts", import.meta.url));
|
||||
const agentSessionTesting = fileURLToPath(new URL("../../agent/src/harness/session/testing/index.ts", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
all: true,
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts"],
|
||||
reporter: ["text", "html", "lcov"],
|
||||
reportsDirectory: "coverage",
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting },
|
||||
{ find: /^@earendil-works\/pi-agent-core\/node$/, replacement: agentNode },
|
||||
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex },
|
||||
{ find: /^@earendil-works\/pi-ai$/, replacement: aiIndex },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
@ -11,8 +11,12 @@
|
|||
"@earendil-works/pi-ai/*": ["./packages/ai/src/*.ts", "./packages/ai/src/providers/*.ts"],
|
||||
"@earendil-works/pi-ai/dist/*": ["./packages/ai/src/*"],
|
||||
"@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"],
|
||||
"@earendil-works/pi-agent-core/node": ["./packages/agent/src/node.ts"],
|
||||
"@earendil-works/pi-agent-core/session/testing": [
|
||||
"./packages/agent/src/harness/session/testing/index.ts"
|
||||
],
|
||||
"@earendil-works/pi-agent-core/*": ["./packages/agent/src/*"],
|
||||
"@earendil-works/pi-agent-sqlite-node": ["./packages/storage/sqlite-node/src/index.ts"],
|
||||
"@earendil-works/pi-storage-sqlite-node": ["./packages/storage/sqlite-node/src/index.ts"],
|
||||
"@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"],
|
||||
"@earendil-works/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"],
|
||||
"@earendil-works/pi-coding-agent/*": ["./packages/coding-agent/src/*"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue