Merge commit '67b361f0e99135c578d5764c915b413f457dd67a' as 'third_party/pi'
This commit is contained in:
50
third_party/pi/packages/session-backends/sqlite-node/CHANGELOG.md
vendored
Normal file
50
third_party/pi/packages/session-backends/sqlite-node/CHANGELOG.md
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## [0.85.1] - 2026-09-05
|
||||
|
||||
## [0.85.0] - 2026-09-04
|
||||
|
||||
## [0.84.4] - 2026-08-28
|
||||
|
||||
## [0.84.3] - 2026-08-24
|
||||
|
||||
## [0.84.2] - 2026-08-14
|
||||
|
||||
## [0.84.1] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added the composable, parameterized `sql` template tag for SQLite queries.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed SQLite branch queries to apply filters, cursors, and limits in SQL; bounded log reads; and added covering indexes for session, record, branch, and fact queries ([#7727](https://github.com/earendil-works/pi/pull/7727) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||
|
||||
## [0.84.0] - 2026-08-06
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed the package from `@earendil-works/pi-storage-sqlite-node` to `@earendil-works/pi-session-backend-sqlite-node`.
|
||||
- Replaced the legacy SQLite session schema and repository with the v4 lane-based `SessionRepo` contract. Existing work-in-progress databases are not migrated.
|
||||
|
||||
### Added
|
||||
|
||||
- Added bounded active-branch queries, durable operation records, global facts, shared sequence allocation, session statistics, and fenced writer leases to the SQLite backend.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed SQLite session listings to avoid acquiring writer claims and include current session names, allowing inventory reads while sessions have active writers ([#7655](https://github.com/earendil-works/pi/pull/7655)).
|
||||
|
||||
## [0.83.0] - 2026-07-29
|
||||
|
||||
## [0.82.1] - 2026-07-25
|
||||
|
||||
## [0.82.0] - 2026-07-24
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
|
||||
## [0.81.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added a Node.js SQLite storage backend for agent harness sessions, including migrations and materialized session views ([#6594](https://github.com/earendil-works/pi/pull/6594) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||
33
third_party/pi/packages/session-backends/sqlite-node/README.md
vendored
Normal file
33
third_party/pi/packages/session-backends/sqlite-node/README.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# @earendil-works/pi-session-backend-sqlite-node
|
||||
|
||||
Node `node:sqlite` Session backend for `@earendil-works/pi-agent-core`.
|
||||
|
||||
```ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
createNodeSqliteFactory,
|
||||
SqliteSessionRepo,
|
||||
} from "@earendil-works/pi-session-backend-sqlite-node";
|
||||
|
||||
const repository = new SqliteSessionRepo({
|
||||
directory: "/var/lib/pi/sessions",
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
});
|
||||
|
||||
const session = await repository.create({}, BACKGROUND_CONTEXT);
|
||||
const main = await session.createBranch("main", null, BACKGROUND_CONTEXT);
|
||||
await main.appendMessage(
|
||||
{ role: "user", content: "hello", timestamp: Date.now() },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
await repository.close(BACKGROUND_CONTEXT);
|
||||
```
|
||||
|
||||
The default layout creates one file per Session under `directory`. IDs containing only ASCII letters, digits, `_`, and `-` retain `{sessionId}.sqlite`; every other ID uses a `~`-prefixed base64url encoding of its UTF-16 code units. The durable ID is unchanged, and returned/listed metadata contains the canonical physical path. Pass `databasePath` to place multiple Sessions in one supported shared container; its parent is created when needed.
|
||||
|
||||
The database factory distinguishes intentional creation, no-create read-write open, and no-create read-only open. Session `open()` and deletion reject metadata outside the configured repository and never create a missing database. Listing is read-only and best-effort. Forking deliberately permits a foreign source metadata path: it reads that exact existing container read-only and never substitutes an active local Session with the same ID.
|
||||
|
||||
The host lifecycle, not this backend, guarantees one writable owner per Session. Directly opening the same Session for writes in another process is unsupported. The repository rejects overlapping local create/open/fork/delete ownership for one ID, but implements no cross-process lease, lock, fence, heartbeat, or takeover. The host must close a worker before deletion.
|
||||
|
||||
A fork of a source open in the same repository queues its snapshot on that source's commit queue. Any other source, including one held open by a live Session worker, uses an independent read-only connection and one deferred WAL transaction; later worker commits may complete while that snapshot remains open. Shared-container deletion removes only the selected Session's rows. Repository close waits for every open Session cleanup attempt before reporting errors. The package does not export a search service or FTS index; search is the separate S3 projection.
|
||||
33
third_party/pi/packages/session-backends/sqlite-node/benchmark/session/session-repo-targets.ts
vendored
Normal file
33
third_party/pi/packages/session-backends/sqlite-node/benchmark/session/session-repo-targets.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { BACKGROUND_CONTEXT, type SessionRepo } from "@earendil-works/pi-agent-core";
|
||||
import { createNodeSqliteFactory, SqliteSessionRepo } from "../../src/index.ts";
|
||||
import type { BenchmarkTarget } from "../../../../agent/benchmark/session/benchmark.ts";
|
||||
|
||||
export interface SessionRepoBenchmarkFixture extends AsyncDisposable {
|
||||
readonly repo: SessionRepo;
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
export const sessionRepoBenchmarkTargets = [
|
||||
{
|
||||
name: "sqlite",
|
||||
async createFixture() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-repo-benchmark-"));
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => NOW,
|
||||
});
|
||||
return {
|
||||
repo,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await repo.close(BACKGROUND_CONTEXT);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
] satisfies readonly BenchmarkTarget<SessionRepoBenchmarkFixture>[];
|
||||
62
third_party/pi/packages/session-backends/sqlite-node/benchmark/session/session-repo.bench.ts
vendored
Normal file
62
third_party/pi/packages/session-backends/sqlite-node/benchmark/session/session-repo.bench.ts
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
SESSION_REPO_CATALOG_BENCHMARK_DATASETS,
|
||||
SESSION_REPO_CATALOG_READ_BENCHMARK_SCENARIOS,
|
||||
SESSION_REPO_CATALOG_WRITE_BENCHMARK_SCENARIOS,
|
||||
SESSION_REPO_FORK_BENCHMARK_DATASETS,
|
||||
SESSION_REPO_FORK_WRITE_BENCHMARK_SCENARIOS,
|
||||
seedSessionRepoCatalogBenchmark,
|
||||
seedSessionRepoForkBenchmark,
|
||||
} from "@earendil-works/pi-agent-core/harness/session/testing";
|
||||
import { registerReadBenchmarks, registerWriteBenchmarks } from "../../../../agent/benchmark/session/benchmark.ts";
|
||||
import { sessionRepoBenchmarkTargets } from "./session-repo-targets.ts";
|
||||
|
||||
await registerReadBenchmarks({
|
||||
datasets: SESSION_REPO_CATALOG_BENCHMARK_DATASETS,
|
||||
targets: sessionRepoBenchmarkTargets,
|
||||
scenarios: SESSION_REPO_CATALOG_READ_BENCHMARK_SCENARIOS,
|
||||
async prepare(fixture, dataset) {
|
||||
await seedSessionRepoCatalogBenchmark(fixture.repo, dataset);
|
||||
},
|
||||
getSubject(fixture) {
|
||||
return fixture.repo;
|
||||
},
|
||||
});
|
||||
|
||||
await registerWriteBenchmarks({
|
||||
targets: sessionRepoBenchmarkTargets,
|
||||
scenarios: SESSION_REPO_CATALOG_WRITE_BENCHMARK_SCENARIOS,
|
||||
prepare(fixture, scenario) {
|
||||
return scenario.prepare(fixture.repo);
|
||||
},
|
||||
expectedResult(scenario) {
|
||||
return scenario.expectedResult;
|
||||
},
|
||||
run(operation) {
|
||||
return operation.run();
|
||||
},
|
||||
});
|
||||
|
||||
const forkBenchmarks = SESSION_REPO_FORK_BENCHMARK_DATASETS.flatMap((dataset) =>
|
||||
SESSION_REPO_FORK_WRITE_BENCHMARK_SCENARIOS.map((scenario) => ({
|
||||
name: `${scenario.name} (${dataset.name})`,
|
||||
dataset,
|
||||
scenario,
|
||||
})),
|
||||
);
|
||||
|
||||
await registerWriteBenchmarks({
|
||||
targets: sessionRepoBenchmarkTargets,
|
||||
scenarios: forkBenchmarks,
|
||||
async prepare(fixture, benchmark) {
|
||||
return {
|
||||
repo: fixture.repo,
|
||||
source: await seedSessionRepoForkBenchmark(fixture.repo, benchmark.dataset),
|
||||
};
|
||||
},
|
||||
expectedResult(benchmark) {
|
||||
return benchmark.scenario.expectedResult(benchmark.dataset);
|
||||
},
|
||||
run(subject, benchmark) {
|
||||
return benchmark.scenario.run(subject.repo, subject.source, benchmark.dataset);
|
||||
},
|
||||
});
|
||||
48
third_party/pi/packages/session-backends/sqlite-node/benchmark/session/storage-targets.ts
vendored
Normal file
48
third_party/pi/packages/session-backends/sqlite-node/benchmark/session/storage-targets.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
|
||||
import type { StorageFixture } from "@earendil-works/pi-agent-core/harness/session/testing";
|
||||
import { createNodeSqliteFactory, SQLITE_STORAGE_VERSION, SqliteStorage, sql } from "../../src/index.ts";
|
||||
import { applyInitialSchema } from "../../src/sqlite/migrations.ts";
|
||||
import type { BenchmarkTarget } from "../../../../agent/benchmark/session/benchmark.ts";
|
||||
|
||||
const SESSION_ID = "session";
|
||||
const NOW = 1_700_000_000_000;
|
||||
const EMPTY_USAGE = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
export const storageBenchmarkTargets = [
|
||||
{
|
||||
name: "sqlite",
|
||||
async createFixture() {
|
||||
const db = await createNodeSqliteFactory().open(":memory:");
|
||||
try {
|
||||
db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
|
||||
await applyInitialSchema(db);
|
||||
sql`INSERT INTO sessions
|
||||
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
|
||||
VALUES (${SESSION_ID}, ${NOW}, ${null}, ${SQLITE_STORAGE_VERSION}, ${null}, ${0}, ${JSON.stringify(EMPTY_USAGE)}, ${1})`.run(
|
||||
db,
|
||||
);
|
||||
const storage = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => NOW });
|
||||
return {
|
||||
storage,
|
||||
async [Symbol.asyncDispose]() {
|
||||
try {
|
||||
await storage.close(BACKGROUND_CONTEXT);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
} satisfies StorageFixture;
|
||||
} catch (error) {
|
||||
db.close();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
] satisfies readonly BenchmarkTarget<StorageFixture>[];
|
||||
35
third_party/pi/packages/session-backends/sqlite-node/benchmark/session/storage.bench.ts
vendored
Normal file
35
third_party/pi/packages/session-backends/sqlite-node/benchmark/session/storage.bench.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
STORAGE_BENCHMARK_DATASETS,
|
||||
STORAGE_READ_BENCHMARK_SCENARIOS,
|
||||
STORAGE_WRITE_BENCHMARK_SCENARIOS,
|
||||
seedStorageBenchmark,
|
||||
} from "@earendil-works/pi-agent-core/harness/session/testing";
|
||||
import { registerReadBenchmarks, registerWriteBenchmarks } from "../../../../agent/benchmark/session/benchmark.ts";
|
||||
import { storageBenchmarkTargets } from "./storage-targets.ts";
|
||||
|
||||
await registerReadBenchmarks({
|
||||
datasets: STORAGE_BENCHMARK_DATASETS,
|
||||
targets: storageBenchmarkTargets,
|
||||
scenarios: STORAGE_READ_BENCHMARK_SCENARIOS,
|
||||
prepare(fixture, dataset) {
|
||||
return seedStorageBenchmark(fixture.storage, dataset);
|
||||
},
|
||||
getSubject(fixture) {
|
||||
return fixture.storage;
|
||||
},
|
||||
});
|
||||
|
||||
await registerWriteBenchmarks({
|
||||
targets: storageBenchmarkTargets,
|
||||
scenarios: STORAGE_WRITE_BENCHMARK_SCENARIOS,
|
||||
async prepare(fixture, scenario) {
|
||||
await scenario.prepare?.(fixture.storage);
|
||||
return fixture.storage;
|
||||
},
|
||||
expectedResult(scenario) {
|
||||
return scenario.writeCount;
|
||||
},
|
||||
run(storage, scenario) {
|
||||
return scenario.run(storage);
|
||||
},
|
||||
});
|
||||
45
third_party/pi/packages/session-backends/sqlite-node/package.json
vendored
Normal file
45
third_party/pi/packages/session-backends/sqlite-node/package.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-session-backend-sqlite-node",
|
||||
"version": "0.85.1",
|
||||
"description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"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/copy-migrations.mjs",
|
||||
"bench:session:timing": "vitest bench --config vitest.benchmark.config.ts",
|
||||
"test": "vitest --run",
|
||||
"coverage": "vitest --run --coverage",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"author": "Earendil Works",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/session-backends/sqlite-node"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.85.1",
|
||||
"@earendil-works/pi-agent-core": "^0.85.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
7
third_party/pi/packages/session-backends/sqlite-node/scripts/copy-migrations.mjs
vendored
Normal file
7
third_party/pi/packages/session-backends/sqlite-node/scripts/copy-migrations.mjs
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cp, mkdir } from "node:fs/promises";
|
||||
|
||||
const source = new URL("../src/sqlite/migrations/", import.meta.url);
|
||||
const target = new URL("../dist/sqlite/migrations/", import.meta.url);
|
||||
|
||||
await mkdir(target, { recursive: true });
|
||||
await cp(source, target, { recursive: true });
|
||||
123
third_party/pi/packages/session-backends/sqlite-node/src/index.ts
vendored
Normal file
123
third_party/pi/packages/session-backends/sqlite-node/src/index.ts
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { SQLInputValue } from "node:sqlite";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { sql } from "./sqlite/sql.ts";
|
||||
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteRunResult, SqliteStatement } from "./sqlite/types.ts";
|
||||
|
||||
function isNamedParameters(value: unknown): value is Record<string, SQLInputValue> {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
if (Array.isArray(value) || ArrayBuffer.isView(value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isAsyncResult(value: unknown): boolean {
|
||||
return value !== null && (typeof value === "object" || typeof value === "function") && "then" in value;
|
||||
}
|
||||
|
||||
class NodeSqliteStatement implements SqliteStatement {
|
||||
private readonly statement: ReturnType<DatabaseSync["prepare"]>;
|
||||
|
||||
constructor(statement: ReturnType<DatabaseSync["prepare"]>) {
|
||||
this.statement = statement;
|
||||
}
|
||||
|
||||
run(...params: unknown[]): SqliteRunResult {
|
||||
const [first, ...rest] = params;
|
||||
const result = isNamedParameters(first)
|
||||
? this.statement.run(first, ...(rest as SQLInputValue[]))
|
||||
: this.statement.run(...(params as SQLInputValue[]));
|
||||
return {
|
||||
changes: Number(result.changes),
|
||||
lastInsertRowid: result.lastInsertRowid === undefined ? undefined : Number(result.lastInsertRowid),
|
||||
};
|
||||
}
|
||||
|
||||
get<TRow extends object>(...params: unknown[]): TRow | undefined {
|
||||
const [first, ...rest] = params;
|
||||
return (
|
||||
isNamedParameters(first)
|
||||
? this.statement.get(first, ...(rest as SQLInputValue[]))
|
||||
: this.statement.get(...(params as SQLInputValue[]))
|
||||
) as TRow | undefined;
|
||||
}
|
||||
|
||||
all<TRow extends object>(...params: unknown[]): TRow[] {
|
||||
const [first, ...rest] = params;
|
||||
return (
|
||||
isNamedParameters(first)
|
||||
? this.statement.all(first, ...(rest as SQLInputValue[]))
|
||||
: this.statement.all(...(params as SQLInputValue[]))
|
||||
) as TRow[];
|
||||
}
|
||||
|
||||
iterate<TRow extends object>(...params: unknown[]): Iterable<TRow> {
|
||||
const [first, ...rest] = params;
|
||||
return (
|
||||
isNamedParameters(first)
|
||||
? this.statement.iterate(first, ...(rest as SQLInputValue[]))
|
||||
: this.statement.iterate(...(params as SQLInputValue[]))
|
||||
) as Iterable<TRow>;
|
||||
}
|
||||
}
|
||||
|
||||
class NodeSqliteDatabase implements SqliteDatabase {
|
||||
private readonly db: DatabaseSync;
|
||||
|
||||
constructor(db: DatabaseSync) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
exec(sql: string): void {
|
||||
this.db.exec(sql);
|
||||
}
|
||||
|
||||
prepare(sql: string): SqliteStatement {
|
||||
return new NodeSqliteStatement(this.db.prepare(sql));
|
||||
}
|
||||
|
||||
transaction<T>(fn: () => T): T {
|
||||
sql`BEGIN IMMEDIATE`.exec(this);
|
||||
try {
|
||||
const result = fn();
|
||||
if (isAsyncResult(result)) {
|
||||
throw new TypeError("SQLite transaction callbacks must be synchronous");
|
||||
}
|
||||
sql`COMMIT`.exec(this);
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
sql`ROLLBACK`.exec(this);
|
||||
} catch {
|
||||
// Ignore rollback errors to rethrow original error.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapNodeSqliteDatabase(db: DatabaseSync): SqliteDatabase {
|
||||
return new NodeSqliteDatabase(db);
|
||||
}
|
||||
|
||||
export function createNodeSqliteFactory(): SqliteDatabaseFactory {
|
||||
return {
|
||||
async open(path: string): Promise<SqliteDatabase> {
|
||||
return new NodeSqliteDatabase(new DatabaseSync(path));
|
||||
},
|
||||
async openExisting(path: string): Promise<SqliteDatabase> {
|
||||
const url = pathToFileURL(path);
|
||||
url.searchParams.set("mode", "rw");
|
||||
return new NodeSqliteDatabase(new DatabaseSync(url));
|
||||
},
|
||||
async openReadOnly(path: string): Promise<SqliteDatabase> {
|
||||
return new NodeSqliteDatabase(new DatabaseSync(path, { readOnly: true }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export the SQLite session backend and types so this package is a complete node-sqlite backend.
|
||||
export * from "./sqlite/index.ts";
|
||||
9
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/index.ts
vendored
Normal file
9
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/index.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./repo.ts";
|
||||
export * from "./sql.ts";
|
||||
export * from "./storage.ts";
|
||||
export type {
|
||||
SqliteDatabase,
|
||||
SqliteDatabaseFactory,
|
||||
SqliteRunResult,
|
||||
SqliteStatement,
|
||||
} from "./types.ts";
|
||||
8
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/migrations.ts
vendored
Normal file
8
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/migrations.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { SqliteDatabase } from "./types.ts";
|
||||
|
||||
export async function applyInitialSchema(db: SqliteDatabase): Promise<void> {
|
||||
const migration = await readFile(fileURLToPath(new URL("./migrations/001_initial.sql", import.meta.url)), "utf8");
|
||||
db.exec(migration);
|
||||
}
|
||||
121
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql
vendored
Normal file
121
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
-- AgentHarness storage format 4 / storageVersion 1.
|
||||
-- A SQLite database file is a session container. The current default repo
|
||||
-- placement still creates one file per session, but the schema supports any
|
||||
-- number of sessions per file by scoping every durable row with session_id.
|
||||
-- Authoritative durable state is entries + scalar_values + list_values +
|
||||
-- usage_ledger; branch_* and stats columns on sessions are maintained
|
||||
-- projections/caches.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
parent_session_id TEXT,
|
||||
storage_version INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
message_count INTEGER NOT NULL,
|
||||
usage_payload TEXT NOT NULL,
|
||||
next_seq INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
session_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
custom_type TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_entry_parent ON entries(session_id, parent_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_entry_seq ON entries(session_id, seq, type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scalar_values (
|
||||
session_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, namespace, key)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_values (
|
||||
session_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, namespace, key, seq)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_ledger (
|
||||
session_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
entry_id TEXT,
|
||||
adjustment INTEGER NOT NULL,
|
||||
usage TEXT NOT NULL,
|
||||
details TEXT,
|
||||
PRIMARY KEY (session_id, id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_usage_seq ON usage_ledger(session_id, seq);
|
||||
|
||||
-- Storage-level integrity that spans rows/tables. Primary keys enforce
|
||||
-- same-table duplicate ids; these triggers enforce the shared entry/usage id
|
||||
-- namespace and ordered parent insertion without a TypeScript preflight pass.
|
||||
CREATE TRIGGER IF NOT EXISTS trg_entries_validate
|
||||
BEFORE INSERT ON entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'missing parent entry')
|
||||
WHERE NEW.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM entries WHERE session_id = NEW.session_id AND id = NEW.parent_id
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'duplicate entry or usage id')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM usage_ledger WHERE session_id = NEW.session_id AND id = NEW.id
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_usage_ledger_validate
|
||||
BEFORE INSERT ON usage_ledger
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'duplicate entry or usage id')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM entries WHERE session_id = NEW.session_id AND id = NEW.id
|
||||
);
|
||||
END;
|
||||
|
||||
-- Private branch index. Not values/lists; no equivalent in the other backends.
|
||||
CREATE TABLE IF NOT EXISTS branch_entries (
|
||||
session_id TEXT NOT NULL,
|
||||
branch_id TEXT NOT NULL,
|
||||
entry_id TEXT NOT NULL,
|
||||
entry_seq INTEGER NOT NULL,
|
||||
entry_type TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, branch_id, entry_id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Ordered scans. entry_seq must follow session_id, branch_id directly or ORDER
|
||||
-- BY needs a temp b-tree; entry_id and entry_type trail so the index covers
|
||||
-- id-only reads.
|
||||
CREATE INDEX IF NOT EXISTS ix_be_seq ON branch_entries(session_id, branch_id, entry_seq, entry_id, entry_type);
|
||||
-- Type-filtered scans.
|
||||
CREATE INDEX IF NOT EXISTS ix_be_type ON branch_entries(session_id, branch_id, entry_type, entry_seq, entry_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_be_entry ON branch_entries(session_id, entry_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS branch_meta (
|
||||
session_id TEXT NOT NULL,
|
||||
branch_id TEXT NOT NULL,
|
||||
tip_entry_id TEXT NOT NULL,
|
||||
tip_seq INTEGER NOT NULL,
|
||||
base_branch_id TEXT,
|
||||
base_seq INTEGER,
|
||||
PRIMARY KEY (session_id, branch_id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_bm_tip ON branch_meta(session_id, tip_entry_id);
|
||||
453
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/repo.ts
vendored
Normal file
453
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/repo.ts
vendored
Normal file
@@ -0,0 +1,453 @@
|
||||
import { mkdir, open as openFile, readdir, realpath, rm } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { Context, Entry, ForkOptions, SessionCreateOptions, StoredValue } from "@earendil-works/pi-agent-core";
|
||||
import { branchTip, createForkSnapshot, StorageBackedSession } from "@earendil-works/pi-agent-core";
|
||||
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||
import { applyInitialSchema } from "./migrations.ts";
|
||||
import { appendEntryToBranchIndex, scanBranchEntries } from "./session/branch-entries.ts";
|
||||
import { decodeEntryRow, type EntryRow, EntryRowWriter } from "./session/entries.ts";
|
||||
import {
|
||||
deleteSessionRows,
|
||||
hasSessionRow,
|
||||
insertSessionRow,
|
||||
metadataFromSessionRow,
|
||||
readAllSessionRows,
|
||||
readSessionRow,
|
||||
type SqliteSessionMetadata,
|
||||
} from "./session/session-row.ts";
|
||||
import { readAllScalarValueRows, setScalarValueRow } from "./session/values.ts";
|
||||
import { SqliteOpenSession } from "./session.ts";
|
||||
import { sql } from "./sql.ts";
|
||||
import { SqliteStorage, type SqliteStorageSnapshot } from "./storage.ts";
|
||||
import type { SqliteDatabase, SqliteDatabaseFactory } from "./types.ts";
|
||||
|
||||
export const SQLITE_STORAGE_VERSION = 1;
|
||||
export const SQLITE_SESSION_EXTENSION = ".sqlite";
|
||||
|
||||
const FIRST_AVAILABLE_COMMIT_SEQ = 1;
|
||||
const SAFE_SESSION_FILE_ID = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export type SqliteSessionCreateOptions = SessionCreateOptions;
|
||||
|
||||
export interface SqliteSessionRepoOptions {
|
||||
directory: string;
|
||||
/** Optional single container path. Defaults to one encoded `${id}.sqlite` file per session under directory. */
|
||||
databasePath?: string;
|
||||
databaseFactory: SqliteDatabaseFactory;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function sessionFileName(id: string): string {
|
||||
if (SAFE_SESSION_FILE_ID.test(id)) return `${id}${SQLITE_SESSION_EXTENSION}`;
|
||||
const encoded = Buffer.from(id, "utf16le").toString("base64url");
|
||||
return `~${encoded}${SQLITE_SESSION_EXTENSION}`;
|
||||
}
|
||||
|
||||
function sessionPath(directory: string, id: string): string {
|
||||
return join(directory, sessionFileName(id));
|
||||
}
|
||||
|
||||
function storageIdentity(path: string, sessionId: string): string {
|
||||
return JSON.stringify([path, sessionId]);
|
||||
}
|
||||
|
||||
function isErrorWithCode(error: unknown, code: string): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
async function removeSessionFiles(path: string, options: { force: boolean }): Promise<void> {
|
||||
await rm(path, { force: options.force });
|
||||
await rm(`${path}-wal`, { force: true });
|
||||
await rm(`${path}-shm`, { force: true });
|
||||
}
|
||||
|
||||
function configureWritableConnection(db: SqliteDatabase): void {
|
||||
db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
|
||||
}
|
||||
|
||||
function configureReadOnlyConnection(db: SqliteDatabase): void {
|
||||
db.exec("PRAGMA busy_timeout = 5000;");
|
||||
}
|
||||
|
||||
interface ForkSnapshot {
|
||||
entries: Entry[];
|
||||
scalarValues: StoredValue<unknown>[];
|
||||
messageCount: number;
|
||||
nextSeq: number;
|
||||
}
|
||||
|
||||
function readSourceEntries(db: SqliteDatabase, sessionId: string): Entry[] {
|
||||
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
|
||||
FROM entries WHERE session_id = ${sessionId} ORDER BY seq ASC`
|
||||
.all<EntryRow>(db)
|
||||
.map(decodeEntryRow);
|
||||
}
|
||||
|
||||
function buildForkSnapshot(source: SqliteStorageSnapshot, options: ForkOptions): ForkSnapshot {
|
||||
const snapshot = createForkSnapshot(
|
||||
{
|
||||
entries: source.entries,
|
||||
scalarValues: source.scalarValues,
|
||||
entriesComplete: source.entriesComplete,
|
||||
},
|
||||
options,
|
||||
);
|
||||
const entries = [...snapshot.entries.values()].sort((left, right) => left.seq - right.seq);
|
||||
return {
|
||||
entries,
|
||||
scalarValues: snapshot.scalarValues,
|
||||
messageCount: entries.filter((entry) => entry.type === "message").length,
|
||||
nextSeq: snapshot.nextSeq,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(WP08): Remove this snapshot path when SQLite forks use streaming staging.
|
||||
function readForkSourceEntries(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
scalarValues: readonly StoredValue<unknown>[],
|
||||
options: ForkOptions,
|
||||
): Entry[] {
|
||||
if (options.scope === "tree") return readSourceEntries(db, sessionId);
|
||||
const sourceAddress = branchTip(options.branch);
|
||||
const sourceTip = scalarValues.find(
|
||||
(stored) => stored.address.namespace === sourceAddress.namespace && stored.address.key === sourceAddress.key,
|
||||
) as StoredValue<string | null> | undefined;
|
||||
if (sourceTip === undefined) throw new Error(`Unknown source branch: ${options.branch}`);
|
||||
return sourceTip.value === null
|
||||
? []
|
||||
: scanBranchEntries(db, sessionId, { start: sourceTip.value, order: "oldestFirst" });
|
||||
}
|
||||
|
||||
function createSqliteForkSnapshot(
|
||||
sourceDb: SqliteDatabase,
|
||||
source: SqliteSessionMetadata,
|
||||
options: ForkOptions,
|
||||
): ForkSnapshot {
|
||||
sourceDb.exec("BEGIN");
|
||||
let committed = false;
|
||||
try {
|
||||
metadataFromSessionRow(source.path, readSessionRow(sourceDb, source.id), SQLITE_STORAGE_VERSION);
|
||||
const scalarValues = readAllScalarValueRows(sourceDb, source.id);
|
||||
const snapshot = buildForkSnapshot(
|
||||
{
|
||||
entries: readForkSourceEntries(sourceDb, source.id, scalarValues, options),
|
||||
scalarValues,
|
||||
entriesComplete: options.scope === "tree",
|
||||
},
|
||||
options,
|
||||
);
|
||||
sourceDb.exec("COMMIT");
|
||||
committed = true;
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (!committed) sourceDb.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function insertForkValue(db: SqliteDatabase, sessionId: string, stored: StoredValue<unknown>): void {
|
||||
setScalarValueRow(db, sessionId, stored.address.namespace, stored.address.key, stored.seq, stored.value);
|
||||
}
|
||||
|
||||
function updateForkSessionStats(db: SqliteDatabase, sessionId: string, messageCount: number): void {
|
||||
sql`UPDATE sessions SET message_count = ${messageCount} WHERE id = ${sessionId}`.run(db);
|
||||
}
|
||||
|
||||
export class SqliteSessionRepo {
|
||||
private readonly directory: string;
|
||||
private readonly databasePath: string | undefined;
|
||||
private readonly databaseFactory: SqliteDatabaseFactory;
|
||||
private readonly now: () => number;
|
||||
private readonly pendingIds = new Set<string>();
|
||||
private readonly openStorages = new Map<string, SqliteStorage>();
|
||||
private readonly openSessions = new Set<SqliteOpenSession>();
|
||||
private closed = false;
|
||||
private closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: SqliteSessionRepoOptions) {
|
||||
this.directory = options.directory;
|
||||
this.databasePath = options.databasePath;
|
||||
this.databaseFactory = options.databaseFactory;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async create(options: SqliteSessionCreateOptions | undefined, _context: Context): Promise<SqliteOpenSession> {
|
||||
this.assertOpen();
|
||||
options ??= {};
|
||||
const createdAt = this.now();
|
||||
const id = options.id ?? uuidv7(createdAt);
|
||||
this.reserveId(id);
|
||||
const path = this.pathForSession(id);
|
||||
let db: SqliteDatabase | undefined;
|
||||
let reservedFile = false;
|
||||
let initialized = false;
|
||||
let session: SqliteOpenSession | undefined;
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
if (!this.usesSharedDatabase()) {
|
||||
const file = await openFile(path, "wx");
|
||||
await file.close();
|
||||
reservedFile = true;
|
||||
}
|
||||
const activeDb = await this.databaseFactory.open(path);
|
||||
db = activeDb;
|
||||
configureWritableConnection(activeDb);
|
||||
await applyInitialSchema(activeDb);
|
||||
const canonicalPath = await realpath(path);
|
||||
const metadata: SqliteSessionMetadata = {
|
||||
id,
|
||||
createdAt,
|
||||
storageVersion: SQLITE_STORAGE_VERSION,
|
||||
...(options.parentSessionId === undefined ? {} : { parentSessionId: options.parentSessionId }),
|
||||
path: canonicalPath,
|
||||
};
|
||||
activeDb.transaction(() => {
|
||||
if (hasSessionRow(activeDb, id)) throw new Error(`SQLite session already exists: ${id}`);
|
||||
insertSessionRow(activeDb, metadata, SQLITE_STORAGE_VERSION, FIRST_AVAILABLE_COMMIT_SEQ);
|
||||
});
|
||||
initialized = true;
|
||||
session = this.openStorageBackedSession(metadata, activeDb);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (reservedFile && !initialized) await removeSessionFiles(path, { force: true });
|
||||
throw error;
|
||||
} finally {
|
||||
if (session === undefined) {
|
||||
try {
|
||||
db?.close();
|
||||
} finally {
|
||||
this.pendingIds.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async open(metadata: SqliteSessionMetadata, _context: Context): Promise<SqliteOpenSession> {
|
||||
this.assertOpen();
|
||||
this.reserveId(metadata.id);
|
||||
let db: SqliteDatabase | undefined;
|
||||
let session: SqliteOpenSession | undefined;
|
||||
try {
|
||||
const path = await this.repositoryPathForMetadata(metadata);
|
||||
const activeDb = await this.databaseFactory.openExisting(path);
|
||||
db = activeDb;
|
||||
configureWritableConnection(activeDb);
|
||||
const stored = metadataFromSessionRow(path, readSessionRow(activeDb, metadata.id), SQLITE_STORAGE_VERSION);
|
||||
session = this.openStorageBackedSession(stored, activeDb);
|
||||
return session;
|
||||
} finally {
|
||||
if (session === undefined) {
|
||||
try {
|
||||
db?.close();
|
||||
} finally {
|
||||
this.pendingIds.delete(metadata.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async list(_options: undefined, _context: Context): Promise<SqliteSessionMetadata[]> {
|
||||
this.assertOpen();
|
||||
let paths: string[];
|
||||
if (this.usesSharedDatabase()) {
|
||||
paths = [this.databasePath!];
|
||||
} else {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.directory);
|
||||
} catch (error) {
|
||||
if (isErrorWithCode(error, "ENOENT")) return [];
|
||||
throw error;
|
||||
}
|
||||
paths = names
|
||||
.filter((name) => name.endsWith(SQLITE_SESSION_EXTENSION))
|
||||
.map((name) => join(this.directory, name));
|
||||
}
|
||||
const sessions: SqliteSessionMetadata[] = [];
|
||||
for (const path of paths) {
|
||||
let db: SqliteDatabase | undefined;
|
||||
try {
|
||||
const canonicalPath = await realpath(path);
|
||||
db = await this.databaseFactory.openReadOnly(canonicalPath);
|
||||
configureReadOnlyConnection(db);
|
||||
for (const row of readAllSessionRows(db)) {
|
||||
sessions.push(metadataFromSessionRow(canonicalPath, row, SQLITE_STORAGE_VERSION));
|
||||
}
|
||||
} catch {
|
||||
// Discovery is best-effort: corrupt files, incompatible versions, and
|
||||
// unrelated *.sqlite files are reported when explicitly opened.
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
return sessions.sort((left, right) => right.createdAt - left.createdAt);
|
||||
}
|
||||
|
||||
async delete(metadata: SqliteSessionMetadata, _context: Context): Promise<void> {
|
||||
this.assertOpen();
|
||||
this.reserveId(metadata.id);
|
||||
try {
|
||||
const path = await this.repositoryPathForMetadata(metadata);
|
||||
const db = await this.databaseFactory.openExisting(path);
|
||||
try {
|
||||
configureWritableConnection(db);
|
||||
if (this.usesSharedDatabase()) {
|
||||
db.transaction(() => {
|
||||
metadataFromSessionRow(path, readSessionRow(db, metadata.id), SQLITE_STORAGE_VERSION);
|
||||
deleteSessionRows(db, metadata.id);
|
||||
});
|
||||
} else {
|
||||
metadataFromSessionRow(path, readSessionRow(db, metadata.id), SQLITE_STORAGE_VERSION);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
if (!this.usesSharedDatabase()) await removeSessionFiles(path, { force: false });
|
||||
} finally {
|
||||
this.pendingIds.delete(metadata.id);
|
||||
}
|
||||
}
|
||||
|
||||
async fork(source: SqliteSessionMetadata, options: ForkOptions, context: Context): Promise<SqliteOpenSession> {
|
||||
this.assertOpen();
|
||||
const createdAt = this.now();
|
||||
const id = options.id ?? uuidv7(createdAt);
|
||||
this.reserveId(id);
|
||||
const sourceStorage = this.openStorages.get(storageIdentity(source.path, source.id));
|
||||
const activeSourceSnapshot = sourceStorage?.snapshot(options, context);
|
||||
void activeSourceSnapshot?.catch(() => undefined);
|
||||
const path = this.pathForSession(id);
|
||||
let db: SqliteDatabase | undefined;
|
||||
let reservedFile = false;
|
||||
let initialized = false;
|
||||
let session: SqliteOpenSession | undefined;
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
if (!this.usesSharedDatabase()) {
|
||||
const file = await openFile(path, "wx");
|
||||
await file.close();
|
||||
reservedFile = true;
|
||||
}
|
||||
|
||||
const snapshot =
|
||||
activeSourceSnapshot === undefined
|
||||
? await this.createForkSnapshotFromExternalSource(source, options)
|
||||
: buildForkSnapshot(await activeSourceSnapshot, options);
|
||||
|
||||
const activeDb = await this.databaseFactory.open(path);
|
||||
db = activeDb;
|
||||
configureWritableConnection(activeDb);
|
||||
await applyInitialSchema(activeDb);
|
||||
const canonicalPath = await realpath(path);
|
||||
const metadata: SqliteSessionMetadata = {
|
||||
id,
|
||||
createdAt,
|
||||
storageVersion: SQLITE_STORAGE_VERSION,
|
||||
parentSessionId: source.id,
|
||||
path: canonicalPath,
|
||||
};
|
||||
activeDb.transaction(() => {
|
||||
if (hasSessionRow(activeDb, id)) throw new Error(`SQLite session already exists: ${id}`);
|
||||
insertSessionRow(activeDb, metadata, SQLITE_STORAGE_VERSION, snapshot.nextSeq);
|
||||
const entryWriter = new EntryRowWriter(activeDb, id);
|
||||
for (const entry of snapshot.entries) {
|
||||
entryWriter.insert(entry);
|
||||
appendEntryToBranchIndex(activeDb, id, entry);
|
||||
}
|
||||
for (const stored of snapshot.scalarValues) insertForkValue(activeDb, id, stored);
|
||||
updateForkSessionStats(activeDb, id, snapshot.messageCount);
|
||||
});
|
||||
initialized = true;
|
||||
session = this.openStorageBackedSession(metadata, activeDb);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (reservedFile && !initialized) await removeSessionFiles(path, { force: true });
|
||||
throw error;
|
||||
} finally {
|
||||
if (session === undefined) {
|
||||
try {
|
||||
db?.close();
|
||||
} finally {
|
||||
this.pendingIds.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(context: Context): Promise<void> {
|
||||
if (this.closePromise !== undefined) return this.closePromise;
|
||||
this.closed = true;
|
||||
this.closePromise = this.closeOpenSessions(context);
|
||||
return this.closePromise;
|
||||
}
|
||||
|
||||
private async createForkSnapshotFromExternalSource(
|
||||
source: SqliteSessionMetadata,
|
||||
options: ForkOptions,
|
||||
): Promise<ForkSnapshot> {
|
||||
const path = await realpath(source.path);
|
||||
const sourceDb = await this.databaseFactory.openReadOnly(path);
|
||||
try {
|
||||
configureReadOnlyConnection(sourceDb);
|
||||
return createSqliteForkSnapshot(sourceDb, { ...source, path }, options);
|
||||
} finally {
|
||||
sourceDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOpenSessions(context: Context): Promise<void> {
|
||||
const results = await Promise.allSettled([...this.openSessions].map((session) => session.close(context)));
|
||||
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to close SQLite Sessions");
|
||||
}
|
||||
|
||||
private openStorageBackedSession(metadata: SqliteSessionMetadata, db: SqliteDatabase): SqliteOpenSession {
|
||||
const key = storageIdentity(metadata.path, metadata.id);
|
||||
const storage = new SqliteStorage(db, { sessionId: metadata.id, now: this.now });
|
||||
this.openStorages.set(key, storage);
|
||||
const session = new StorageBackedSession(metadata, storage);
|
||||
const openSession = new SqliteOpenSession(session, {
|
||||
onClose: () => {
|
||||
try {
|
||||
db.close();
|
||||
} finally {
|
||||
if (this.openStorages.get(key) === storage) this.openStorages.delete(key);
|
||||
this.openSessions.delete(openSession);
|
||||
this.pendingIds.delete(metadata.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.openSessions.add(openSession);
|
||||
return openSession;
|
||||
}
|
||||
|
||||
private async repositoryPathForMetadata(metadata: SqliteSessionMetadata): Promise<string> {
|
||||
const [expected, actual] = await Promise.all([
|
||||
realpath(this.pathForSession(metadata.id)),
|
||||
realpath(metadata.path),
|
||||
]);
|
||||
if (expected !== actual) {
|
||||
throw new Error(`SQLite session metadata path is outside this repository: ${metadata.path}`);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
private reserveId(id: string): void {
|
||||
if (this.pendingIds.has(id)) throw new Error(`Session is already open: ${id}`);
|
||||
this.pendingIds.add(id);
|
||||
}
|
||||
|
||||
private pathForSession(id: string): string {
|
||||
return this.databasePath ?? sessionPath(this.directory, id);
|
||||
}
|
||||
|
||||
private usesSharedDatabase(): boolean {
|
||||
return this.databasePath !== undefined;
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.closed) throw new Error("SqliteSessionRepo is closed");
|
||||
}
|
||||
}
|
||||
213
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session.ts
vendored
Normal file
213
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session.ts
vendored
Normal file
@@ -0,0 +1,213 @@
|
||||
import type {
|
||||
Branch,
|
||||
Context,
|
||||
Entry,
|
||||
EntryQuery,
|
||||
ListElement,
|
||||
ListReadOptions,
|
||||
Session,
|
||||
SessionMutation,
|
||||
SessionMutationCallback,
|
||||
SessionStats,
|
||||
StorageBranchScan,
|
||||
StoredValue,
|
||||
Value,
|
||||
ValueList,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type { SqliteSessionMetadata } from "./session/session-row.ts";
|
||||
|
||||
export interface SqliteOpenSessionOptions {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** SQLite-specific open-session lifecycle wrapper. */
|
||||
export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
|
||||
readonly metadata: SqliteSessionMetadata;
|
||||
readonly idGenerator: Session<SqliteSessionMetadata>["idGenerator"];
|
||||
private readonly session: Session<SqliteSessionMetadata>;
|
||||
private readonly onClose: () => void;
|
||||
private readonly admitted = new Set<Promise<unknown>>();
|
||||
private readonly closedError = new Error("Session is closed");
|
||||
private state: "open" | "closing" | "closed" = "open";
|
||||
private closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(session: Session<SqliteSessionMetadata>, options: SqliteOpenSessionOptions) {
|
||||
this.session = session;
|
||||
this.metadata = session.metadata;
|
||||
this.idGenerator = session.idGenerator;
|
||||
this.onClose = options.onClose;
|
||||
}
|
||||
|
||||
async beginMutation(context: Context): Promise<SessionMutation> {
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
});
|
||||
this.admitted.add(finished);
|
||||
let source: SessionMutation;
|
||||
try {
|
||||
source = await this.admit(() => this.session.beginMutation(context));
|
||||
} catch (error) {
|
||||
this.admitted.delete(finished);
|
||||
resolveFinished();
|
||||
throw error;
|
||||
}
|
||||
if (this.state !== "open") {
|
||||
await source.end(context);
|
||||
this.admitted.delete(finished);
|
||||
resolveFinished();
|
||||
throw this.closedError;
|
||||
}
|
||||
let ended = false;
|
||||
return {
|
||||
commit: (writes, commitContext) => source.commit(writes, commitContext),
|
||||
end: async (endContext) => {
|
||||
try {
|
||||
await source.end(endContext);
|
||||
} finally {
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
this.admitted.delete(finished);
|
||||
resolveFinished();
|
||||
}
|
||||
}
|
||||
},
|
||||
getEntries: (ids, readContext) => source.getEntries(ids, readContext),
|
||||
getStats: (readContext) => source.getStats(readContext),
|
||||
getValue: (address, readContext) => source.getValue(address, readContext),
|
||||
scanValues: (prefix, readContext) => source.scanValues(prefix, readContext),
|
||||
readList: (address, options, readContext) => source.readList(address, options, readContext),
|
||||
scanBranch: (query, readContext) => source.scanBranch(query, readContext),
|
||||
};
|
||||
}
|
||||
|
||||
mutate<T>(mutation: SessionMutationCallback<T>, context: Context): Promise<T> {
|
||||
return this.admit(() =>
|
||||
this.session.mutate((mutator, mutationContext) => {
|
||||
if (this.state !== "open") throw this.closedError;
|
||||
return mutation(mutator, mutationContext);
|
||||
}, context),
|
||||
);
|
||||
}
|
||||
|
||||
getEntries(ids: string[], context: Context): Promise<Map<string, Entry>> {
|
||||
return this.admit(() => this.session.getEntries(ids, context));
|
||||
}
|
||||
|
||||
getEntry(id: string, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.getEntry(id, context));
|
||||
}
|
||||
|
||||
getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | undefined> {
|
||||
return this.admit(() => this.session.getValue(address, context));
|
||||
}
|
||||
|
||||
scanValues<T>(prefix: Value<T>, context: Context): Promise<StoredValue<T>[]> {
|
||||
return this.admit(() => this.session.scanValues(prefix, context));
|
||||
}
|
||||
|
||||
readList<T>(
|
||||
address: ValueList<T>,
|
||||
options: ListReadOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<ListElement<T>[]> {
|
||||
return this.admit(() => this.session.readList(address, options, context));
|
||||
}
|
||||
|
||||
scanBranch(query: StorageBranchScan, context: Context): Promise<Entry[]> {
|
||||
return this.admit(() => this.session.scanBranch(query, context));
|
||||
}
|
||||
|
||||
getStats(context: Context): Promise<SessionStats> {
|
||||
return this.admit(() => this.session.getStats(context));
|
||||
}
|
||||
|
||||
getName(context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getName(context));
|
||||
}
|
||||
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getLabel(targetId, context));
|
||||
}
|
||||
|
||||
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
|
||||
return this.admit(() => this.session.findEntries(query, context));
|
||||
}
|
||||
|
||||
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.findEntry(query, context));
|
||||
}
|
||||
|
||||
async branch(name: string, context: Context): Promise<Branch | undefined> {
|
||||
const branch = await this.admit(() => this.session.branch(name, context));
|
||||
return branch === undefined ? undefined : this.wrapBranch(branch);
|
||||
}
|
||||
|
||||
async createBranch(name: string, at: string | null, context: Context): Promise<Branch> {
|
||||
return this.wrapBranch(await this.admit(() => this.session.createBranch(name, at, context)));
|
||||
}
|
||||
|
||||
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.setValue(address, next, context));
|
||||
}
|
||||
|
||||
deleteValue<T>(address: Value<T>, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.deleteValue(address, context));
|
||||
}
|
||||
|
||||
appendList<T>(address: ValueList<T>, element: NoInfer<T>, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.appendList(address, element, context));
|
||||
}
|
||||
|
||||
deleteList<T>(address: ValueList<T>, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.deleteList(address, context));
|
||||
}
|
||||
|
||||
setName(name: string | undefined, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.setName(name, context));
|
||||
}
|
||||
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.setLabel(targetId, label, context));
|
||||
}
|
||||
|
||||
close(context: Context): Promise<void> {
|
||||
if (this.closePromise !== undefined) return this.closePromise;
|
||||
this.state = "closing";
|
||||
this.closePromise = Promise.allSettled([...this.admitted])
|
||||
.then(() => this.session.close(context))
|
||||
.finally(() => {
|
||||
this.state = "closed";
|
||||
this.onClose();
|
||||
});
|
||||
return this.closePromise;
|
||||
}
|
||||
|
||||
private wrapBranch(branch: Branch): Branch {
|
||||
return {
|
||||
name: branch.name,
|
||||
getTipId: (context) => this.admit(() => branch.getTipId(context)),
|
||||
findEntries: (query, context) => this.admit(() => branch.findEntries(query, context)),
|
||||
findEntry: (query, context) => this.admit(() => branch.findEntry(query, context)),
|
||||
appendMessage: (message, context) => this.admit(() => branch.appendMessage(message, context)),
|
||||
appendCustomEntry: (customType, data, context) =>
|
||||
this.admit(() => branch.appendCustomEntry(customType, data, context)),
|
||||
};
|
||||
}
|
||||
|
||||
private admit<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (this.state !== "open") return Promise.reject(this.closedError);
|
||||
let result: Promise<T>;
|
||||
try {
|
||||
result = operation();
|
||||
} catch (error) {
|
||||
result = Promise.reject(error);
|
||||
}
|
||||
this.admitted.add(result);
|
||||
void result.then(
|
||||
() => this.admitted.delete(result),
|
||||
() => this.admitted.delete(result),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
324
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/branch-entries.ts
vendored
Normal file
324
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/branch-entries.ts
vendored
Normal file
@@ -0,0 +1,324 @@
|
||||
import type { Entry, EntryStructure, StorageBranchScan } from "@earendil-works/pi-agent-core";
|
||||
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
import { decodeEntryRow, type EntryRow } from "./entries.ts";
|
||||
|
||||
interface BranchMembershipRow {
|
||||
branch_id: string;
|
||||
entry_seq: number;
|
||||
}
|
||||
|
||||
interface BranchMetaRow {
|
||||
branch_id: string;
|
||||
tip_entry_id: string;
|
||||
tip_seq: number;
|
||||
base_branch_id: string | null;
|
||||
base_seq: number | null;
|
||||
}
|
||||
|
||||
interface BranchSegment {
|
||||
branchId: string;
|
||||
lowerSeq: number;
|
||||
upperSeq: number;
|
||||
}
|
||||
|
||||
interface StopSeqRow {
|
||||
stop_seq: number | null;
|
||||
}
|
||||
|
||||
interface EntryStructureRow {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
seq: number;
|
||||
type: Entry["type"];
|
||||
custom_type: string | null;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface BranchTipRow {
|
||||
branch_id: string;
|
||||
}
|
||||
|
||||
interface CompactionBoundary {
|
||||
branchId: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
interface CompactionBoundaryRow {
|
||||
entry_seq: number | null;
|
||||
}
|
||||
|
||||
function readBranchMembership(db: SqliteDatabase, sessionId: string, entryId: string): BranchMembershipRow {
|
||||
const row = sql`SELECT b.branch_id, b.entry_seq
|
||||
FROM branch_entries b
|
||||
JOIN branch_meta m ON m.session_id = b.session_id AND m.branch_id = b.branch_id
|
||||
WHERE b.session_id = ${sessionId}
|
||||
AND b.entry_id = ${entryId}
|
||||
AND ((m.base_seq IS NULL AND b.entry_seq > 0) OR (m.base_seq IS NOT NULL AND b.entry_seq > m.base_seq))
|
||||
AND b.entry_seq <= m.tip_seq
|
||||
ORDER BY m.tip_seq DESC, b.branch_id
|
||||
LIMIT 1`.get<BranchMembershipRow>(db);
|
||||
if (row === undefined) throw new Error(`Branch cache missing entry ${entryId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function readBranchMeta(db: SqliteDatabase, sessionId: string, branchId: string): BranchMetaRow {
|
||||
const row = sql`SELECT branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq
|
||||
FROM branch_meta
|
||||
WHERE session_id = ${sessionId} AND branch_id = ${branchId}`.get<BranchMetaRow>(db);
|
||||
if (row === undefined) throw new Error(`Branch metadata missing for branch ${branchId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function insertBranchEntry(db: SqliteDatabase, sessionId: string, branchId: string, entry: Entry): void {
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
VALUES (${sessionId}, ${branchId}, ${entry.id}, ${entry.seq}, ${entry.type})`.run(db);
|
||||
}
|
||||
|
||||
function readBranchTipForParent(db: SqliteDatabase, sessionId: string, parentId: string): BranchTipRow | undefined {
|
||||
return sql`SELECT branch_id
|
||||
FROM branch_meta
|
||||
WHERE session_id = ${sessionId} AND tip_entry_id = ${parentId}`.get<BranchTipRow>(db);
|
||||
}
|
||||
|
||||
function createRootBranchForEntry(db: SqliteDatabase, sessionId: string, entry: Entry): void {
|
||||
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
|
||||
VALUES (${sessionId}, ${entry.id}, ${entry.id}, ${entry.seq}, ${null}, ${null})`.run(db);
|
||||
insertBranchEntry(db, sessionId, entry.id, entry);
|
||||
}
|
||||
|
||||
function appendEntryToExistingBranch(db: SqliteDatabase, sessionId: string, branchId: string, entry: Entry): void {
|
||||
insertBranchEntry(db, sessionId, branchId, entry);
|
||||
const result = sql`UPDATE branch_meta
|
||||
SET tip_entry_id = ${entry.id}, tip_seq = ${entry.seq}
|
||||
WHERE session_id = ${sessionId} AND branch_id = ${branchId}`.run(db);
|
||||
if (result.changes !== 1) throw new Error(`Expected to update branch ${branchId}, updated ${result.changes}`);
|
||||
}
|
||||
|
||||
function readBranchSegmentsNewestFirst(db: SqliteDatabase, sessionId: string, start: string): BranchSegment[] {
|
||||
let { branch_id: branchId, entry_seq: upperSeq } = readBranchMembership(db, sessionId, start);
|
||||
const segments: BranchSegment[] = [];
|
||||
while (true) {
|
||||
const meta = readBranchMeta(db, sessionId, branchId);
|
||||
const lowerSeq = meta.base_seq ?? 0;
|
||||
segments.push({ branchId, lowerSeq, upperSeq });
|
||||
if (meta.base_branch_id === null) break;
|
||||
if (meta.base_seq === null) throw new Error(`Branch ${branchId} has base branch without base_seq`);
|
||||
branchId = meta.base_branch_id;
|
||||
upperSeq = meta.base_seq;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function readNewestCompactionBoundary(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segmentsNewestFirst: readonly BranchSegment[],
|
||||
): CompactionBoundary | undefined {
|
||||
for (const segment of segmentsNewestFirst) {
|
||||
const row = sql`SELECT MAX(entry_seq) AS entry_seq
|
||||
FROM branch_entries
|
||||
WHERE session_id = ${sessionId}
|
||||
AND branch_id = ${segment.branchId}
|
||||
AND entry_seq > ${segment.lowerSeq}
|
||||
AND entry_seq <= ${segment.upperSeq}
|
||||
AND entry_type = ${"compaction"}`.get<CompactionBoundaryRow>(db);
|
||||
if (row?.entry_seq !== null && row?.entry_seq !== undefined)
|
||||
return { branchId: segment.branchId, seq: row.entry_seq };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function copyBranchEntriesAfterSeqThroughParent(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
targetBranchId: string,
|
||||
segmentsNewestFirst: readonly BranchSegment[],
|
||||
afterSeq: number,
|
||||
): void {
|
||||
for (const segment of [...segmentsNewestFirst].reverse()) {
|
||||
const lowerSeq = Math.max(segment.lowerSeq, afterSeq);
|
||||
if (segment.upperSeq <= lowerSeq) continue;
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
SELECT ${sessionId}, ${targetBranchId}, entry_id, entry_seq, entry_type
|
||||
FROM branch_entries
|
||||
WHERE session_id = ${sessionId}
|
||||
AND branch_id = ${segment.branchId}
|
||||
AND entry_seq > ${lowerSeq}
|
||||
AND entry_seq <= ${segment.upperSeq}`.run(db);
|
||||
}
|
||||
}
|
||||
|
||||
function createDivergentBranchForEntry(db: SqliteDatabase, sessionId: string, entry: Entry): void {
|
||||
if (entry.parentId === null) throw new Error("Root entries do not create divergent branches");
|
||||
const segmentsNewestFirst = readBranchSegmentsNewestFirst(db, sessionId, entry.parentId);
|
||||
const compaction = readNewestCompactionBoundary(db, sessionId, segmentsNewestFirst);
|
||||
const branchId = entry.id;
|
||||
// A null base means this segment stores its own root-through-parent prefix.
|
||||
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
|
||||
VALUES (${sessionId}, ${branchId}, ${entry.id}, ${entry.seq}, ${compaction?.branchId ?? null}, ${compaction?.seq ?? null})`.run(
|
||||
db,
|
||||
);
|
||||
copyBranchEntriesAfterSeqThroughParent(db, sessionId, branchId, segmentsNewestFirst, compaction?.seq ?? 0);
|
||||
insertBranchEntry(db, sessionId, branchId, entry);
|
||||
}
|
||||
|
||||
export function appendEntryToBranchIndex(db: SqliteDatabase, sessionId: string, entry: Entry): void {
|
||||
if (entry.parentId === null) {
|
||||
createRootBranchForEntry(db, sessionId, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = readBranchTipForParent(db, sessionId, entry.parentId);
|
||||
if (branch === undefined) {
|
||||
createDivergentBranchForEntry(db, sessionId, entry);
|
||||
return;
|
||||
}
|
||||
appendEntryToExistingBranch(db, sessionId, branch.branch_id, entry);
|
||||
}
|
||||
|
||||
function stopPredicates(query: StorageBranchScan): SqlQuery[] {
|
||||
const predicates: SqlQuery[] = [];
|
||||
if (query.stopAtType !== undefined) predicates.push(sql`b.entry_type = ${query.stopAtType}`);
|
||||
if (query.stopAtId !== undefined) predicates.push(sql`b.entry_id = ${query.stopAtId}`);
|
||||
return predicates;
|
||||
}
|
||||
|
||||
function readStopSeq(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
): number | undefined {
|
||||
const stop = stopPredicates(query);
|
||||
if (stop.length === 0) return undefined;
|
||||
const aggregate = oldestFirst ? sql`MIN(b.entry_seq)` : sql`MAX(b.entry_seq)`;
|
||||
const row = sql`SELECT ${aggregate} AS stop_seq
|
||||
FROM branch_entries b
|
||||
WHERE b.session_id = ${sessionId}
|
||||
AND b.branch_id = ${segment.branchId}
|
||||
AND b.entry_seq > ${segment.lowerSeq}
|
||||
AND b.entry_seq <= ${segment.upperSeq}
|
||||
AND (${joinSqlFragments(stop, " OR ")})`.get<StopSeqRow>(db);
|
||||
return row?.stop_seq ?? undefined;
|
||||
}
|
||||
|
||||
function branchScanPredicates(
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
stopSeq: number | undefined,
|
||||
): SqlQuery[] {
|
||||
const predicates: SqlQuery[] = [
|
||||
sql`b.session_id = ${sessionId}`,
|
||||
sql`b.branch_id = ${segment.branchId}`,
|
||||
sql`b.entry_seq > ${segment.lowerSeq}`,
|
||||
sql`b.entry_seq <= ${segment.upperSeq}`,
|
||||
sql`e.session_id = b.session_id`,
|
||||
];
|
||||
if (stopSeq !== undefined)
|
||||
predicates.push(oldestFirst ? sql`b.entry_seq <= ${stopSeq}` : sql`b.entry_seq >= ${stopSeq}`);
|
||||
if (query.type !== undefined) predicates.push(sql`b.entry_type = ${query.type}`);
|
||||
if (query.customType !== undefined) predicates.push(sql`e.custom_type = ${query.customType}`);
|
||||
if (query.cursor !== undefined) {
|
||||
predicates.push(oldestFirst ? sql`b.entry_seq > ${query.cursor.seq}` : sql`b.entry_seq < ${query.cursor.seq}`);
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
|
||||
function limitSql(limit: number | undefined): SqlQuery {
|
||||
return limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, limit)}`;
|
||||
}
|
||||
|
||||
function scanEntrySegmentRows(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
stopSeq: number | undefined,
|
||||
limit: number | undefined,
|
||||
): EntryRow[] {
|
||||
const predicates = branchScanPredicates(sessionId, segment, query, oldestFirst, stopSeq);
|
||||
const order = oldestFirst ? sql`ASC` : sql`DESC`;
|
||||
return sql`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload
|
||||
FROM branch_entries b
|
||||
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
|
||||
WHERE ${joinSqlFragments(predicates, " AND ")}
|
||||
ORDER BY b.entry_seq ${order} ${limitSql(limit)}`.all<EntryRow>(db);
|
||||
}
|
||||
|
||||
function decodeEntryStructureRow(row: EntryStructureRow): EntryStructure {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
seq: row.seq,
|
||||
timestamp: row.timestamp,
|
||||
type: row.type,
|
||||
...(row.custom_type === null ? {} : { customType: row.custom_type }),
|
||||
};
|
||||
}
|
||||
|
||||
function scanStructureSegmentRows(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
stopSeq: number | undefined,
|
||||
limit: number | undefined,
|
||||
): EntryStructure[] {
|
||||
const predicates = branchScanPredicates(sessionId, segment, query, oldestFirst, stopSeq);
|
||||
const order = oldestFirst ? sql`ASC` : sql`DESC`;
|
||||
const rows = sql`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp
|
||||
FROM branch_entries b
|
||||
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
|
||||
WHERE ${joinSqlFragments(predicates, " AND ")}
|
||||
ORDER BY b.entry_seq ${order} ${limitSql(limit)}`.all<EntryStructureRow>(db);
|
||||
return rows.map(decodeEntryStructureRow);
|
||||
}
|
||||
|
||||
function scanBranchSegments<T>(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
query: StorageBranchScan,
|
||||
readSegment: (
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
stopSeq: number | undefined,
|
||||
limit: number | undefined,
|
||||
) => T[],
|
||||
): T[] {
|
||||
const oldestFirst = query.order === "oldestFirst";
|
||||
const segmentsNewestFirst = readBranchSegmentsNewestFirst(db, sessionId, query.start);
|
||||
const segments = oldestFirst ? [...segmentsNewestFirst].reverse() : segmentsNewestFirst;
|
||||
const limit = query.limit === undefined ? undefined : Math.max(0, query.limit);
|
||||
if (limit === 0) return [];
|
||||
|
||||
const rows: T[] = [];
|
||||
for (const segment of segments) {
|
||||
const remaining = limit === undefined ? undefined : limit - rows.length;
|
||||
if (remaining !== undefined && remaining <= 0) break;
|
||||
const stopSeq = readStopSeq(db, sessionId, segment, query, oldestFirst);
|
||||
rows.push(...readSegment(db, sessionId, segment, query, oldestFirst, stopSeq, remaining));
|
||||
if (stopSeq !== undefined) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function scanBranchEntries(db: SqliteDatabase, sessionId: string, query: StorageBranchScan): Entry[] {
|
||||
return scanBranchSegments(db, sessionId, query, scanEntrySegmentRows).map(decodeEntryRow);
|
||||
}
|
||||
|
||||
export function scanBranchEntryStructures(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
query: StorageBranchScan,
|
||||
): EntryStructure[] {
|
||||
return scanBranchSegments(db, sessionId, query, scanStructureSegmentRows);
|
||||
}
|
||||
161
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/entries.ts
vendored
Normal file
161
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/entries.ts
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
CustomEntry,
|
||||
Entry,
|
||||
EntryScan,
|
||||
EntryStructure,
|
||||
MessageEntry,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
|
||||
import type { SqliteDatabase, SqliteStatement } from "../types.ts";
|
||||
|
||||
export interface EntryRow {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
seq: number;
|
||||
type: Entry["type"];
|
||||
custom_type: string | null;
|
||||
timestamp: number;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
type StoredEntryPayload<TEntry extends Entry> = Omit<
|
||||
TEntry,
|
||||
"id" | "parentId" | "seq" | "timestamp" | "type" | "customType"
|
||||
>;
|
||||
|
||||
function entryPayload(entry: Entry): StoredEntryPayload<Entry> {
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const payload: StoredEntryPayload<MessageEntry> = {
|
||||
message: entry.message,
|
||||
...(entry.terminate === undefined ? {} : { terminate: entry.terminate }),
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
case "compaction": {
|
||||
const payload: StoredEntryPayload<CompactionEntry> = {
|
||||
summary: entry.summary,
|
||||
retainedTail: entry.retainedTail,
|
||||
tokensBefore: entry.tokensBefore,
|
||||
...(entry.details === undefined ? {} : { details: entry.details }),
|
||||
...(entry.usage === undefined ? {} : { usage: entry.usage }),
|
||||
fromHook: entry.fromHook,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
case "branch_summary": {
|
||||
const payload: StoredEntryPayload<BranchSummaryEntry> = {
|
||||
fromId: entry.fromId,
|
||||
summary: entry.summary,
|
||||
...(entry.details === undefined ? {} : { details: entry.details }),
|
||||
...(entry.usage === undefined ? {} : { usage: entry.usage }),
|
||||
fromHook: entry.fromHook,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
case "custom": {
|
||||
const payload: StoredEntryPayload<CustomEntry> = entry.data === undefined ? {} : { data: entry.data };
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parsePayload<TEntry extends Entry>(row: EntryRow): StoredEntryPayload<TEntry> {
|
||||
return JSON.parse(row.payload) as StoredEntryPayload<TEntry>;
|
||||
}
|
||||
|
||||
const INSERT_ENTRY_SQL = `INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
function entryRowParams(sessionId: string, entry: Entry): unknown[] {
|
||||
return [
|
||||
sessionId,
|
||||
entry.id,
|
||||
entry.parentId,
|
||||
entry.seq,
|
||||
entry.type,
|
||||
entry.type === "custom" ? entry.customType : null,
|
||||
entry.timestamp,
|
||||
JSON.stringify(entryPayload(entry)),
|
||||
];
|
||||
}
|
||||
|
||||
export class EntryRowWriter {
|
||||
private readonly insertStatement: SqliteStatement;
|
||||
private readonly sessionId: string;
|
||||
|
||||
constructor(db: SqliteDatabase, sessionId: string) {
|
||||
this.insertStatement = db.prepare(INSERT_ENTRY_SQL);
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
insert(entry: Entry): void {
|
||||
this.insertStatement.run(...entryRowParams(this.sessionId, entry));
|
||||
}
|
||||
}
|
||||
|
||||
export function insertEntryRow(db: SqliteDatabase, sessionId: string, entry: Entry): void {
|
||||
db.prepare(INSERT_ENTRY_SQL).run(...entryRowParams(sessionId, entry));
|
||||
}
|
||||
|
||||
export function decodeEntryRow(row: EntryRow): Entry {
|
||||
const base = {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
seq: row.seq,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
switch (row.type) {
|
||||
case "message":
|
||||
return { ...base, type: "message", ...parsePayload<MessageEntry>(row) };
|
||||
case "compaction":
|
||||
return { ...base, type: "compaction", ...parsePayload<CompactionEntry>(row) };
|
||||
case "branch_summary":
|
||||
return { ...base, type: "branch_summary", ...parsePayload<BranchSummaryEntry>(row) };
|
||||
case "custom":
|
||||
if (row.custom_type === null) throw new Error(`Custom entry ${row.id} is missing custom_type`);
|
||||
return { ...base, type: "custom", customType: row.custom_type, ...parsePayload<CustomEntry>(row) };
|
||||
}
|
||||
}
|
||||
|
||||
export function entryStructureFromRow(row: EntryRow): EntryStructure {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
seq: row.seq,
|
||||
timestamp: row.timestamp,
|
||||
type: row.type,
|
||||
...(row.custom_type === null ? {} : { customType: row.custom_type }),
|
||||
};
|
||||
}
|
||||
|
||||
export function readEntryRows(db: SqliteDatabase, sessionId: string, ids: readonly string[]): EntryRow[] {
|
||||
if (ids.length === 0) return [];
|
||||
const placeholders = joinSqlFragments(
|
||||
ids.map((id) => sql`${id}`),
|
||||
", ",
|
||||
);
|
||||
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
|
||||
FROM entries
|
||||
WHERE session_id = ${sessionId} AND id IN (${placeholders})`.all<EntryRow>(db);
|
||||
}
|
||||
|
||||
export function readAllEntryRows(db: SqliteDatabase, sessionId: string): EntryRow[] {
|
||||
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
|
||||
FROM entries WHERE session_id = ${sessionId} ORDER BY seq ASC`.all<EntryRow>(db);
|
||||
}
|
||||
|
||||
export function scanEntryRows(db: SqliteDatabase, sessionId: string, query: EntryScan): EntryRow[] {
|
||||
const filters: SqlQuery[] = [sql`session_id = ${sessionId}`];
|
||||
if (query.type !== undefined) filters.push(sql`type = ${query.type}`);
|
||||
if (query.customType !== undefined) filters.push(sql`custom_type = ${query.customType}`);
|
||||
if (query.fromSeq !== undefined) filters.push(sql`seq >= ${query.fromSeq}`);
|
||||
if (query.toSeq !== undefined) filters.push(sql`seq <= ${query.toSeq}`);
|
||||
|
||||
const order = query.order === "desc" ? sql`ORDER BY seq DESC` : sql`ORDER BY seq ASC`;
|
||||
const limit = query.limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, query.limit)}`;
|
||||
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
|
||||
FROM entries WHERE ${joinSqlFragments(filters, " AND ")} ${order} ${limit}`.all<EntryRow>(db);
|
||||
}
|
||||
102
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-row.ts
vendored
Normal file
102
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-row.ts
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
created_at: number;
|
||||
parent_session_id: string | null;
|
||||
storage_version: number;
|
||||
metadata: string | null;
|
||||
message_count: number;
|
||||
usage_payload: string;
|
||||
next_seq: number;
|
||||
}
|
||||
|
||||
export interface SqliteSessionMetadata extends SessionMetadata {
|
||||
/** SQLite container/shard path containing this session. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
function zeroUsage(): Usage {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function readSessionRow(db: SqliteDatabase, sessionId: string): SessionRow {
|
||||
const row = sql`SELECT id, created_at, parent_session_id, storage_version, metadata,
|
||||
message_count, usage_payload, next_seq
|
||||
FROM sessions
|
||||
WHERE id = ${sessionId}`.get<SessionRow>(db);
|
||||
if (row === undefined) throw new Error(`Unknown SQLite session: ${sessionId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function readAllSessionRows(db: SqliteDatabase): SessionRow[] {
|
||||
return sql`SELECT id, created_at, parent_session_id, storage_version, metadata,
|
||||
message_count, usage_payload, next_seq
|
||||
FROM sessions`.all<SessionRow>(db);
|
||||
}
|
||||
|
||||
export function hasSessionRow(db: SqliteDatabase, sessionId: string): boolean {
|
||||
return sql`SELECT id FROM sessions WHERE id = ${sessionId}`.get<{ id: string }>(db) !== undefined;
|
||||
}
|
||||
|
||||
export function metadataFromSessionRow(
|
||||
path: string,
|
||||
row: SessionRow,
|
||||
currentStorageVersion: number,
|
||||
): SqliteSessionMetadata {
|
||||
if (row.storage_version > currentStorageVersion) {
|
||||
throw new Error(`SQLite session storage version ${row.storage_version} is newer than ${currentStorageVersion}`);
|
||||
}
|
||||
if (row.storage_version < currentStorageVersion) {
|
||||
throw new Error(`SQLite session storage version ${row.storage_version} requires migrations`);
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: row.created_at,
|
||||
storageVersion: row.storage_version,
|
||||
...(row.parent_session_id === null ? {} : { parentSessionId: row.parent_session_id }),
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertSessionRow(
|
||||
db: SqliteDatabase,
|
||||
metadata: SqliteSessionMetadata,
|
||||
storageVersion: number,
|
||||
nextSeq: number,
|
||||
): void {
|
||||
sql`INSERT INTO sessions
|
||||
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
|
||||
VALUES (
|
||||
${metadata.id},
|
||||
${metadata.createdAt},
|
||||
${metadata.parentSessionId ?? null},
|
||||
${storageVersion},
|
||||
${null},
|
||||
${0},
|
||||
${JSON.stringify(zeroUsage())},
|
||||
${nextSeq}
|
||||
)`.run(db);
|
||||
}
|
||||
|
||||
export function deleteSessionRows(db: SqliteDatabase, sessionId: string): void {
|
||||
sql`DELETE FROM entries WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM scalar_values WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM list_values WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM usage_ledger WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM branch_entries WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM branch_meta WHERE session_id = ${sessionId}`.run(db);
|
||||
const result = sql`DELETE FROM sessions WHERE id = ${sessionId}`.run(db);
|
||||
if (result.changes !== 1)
|
||||
throw new Error(`Expected to delete one SQLite session ${sessionId}, deleted ${result.changes}`);
|
||||
}
|
||||
14
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-sequences.ts
vendored
Normal file
14
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-sequences.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import { sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
|
||||
export function readNextSeq(db: SqliteDatabase, sessionId: string): number {
|
||||
const row = sql`SELECT next_seq FROM sessions WHERE id = ${sessionId}`.get<{ next_seq: number }>(db);
|
||||
if (row === undefined) throw new Error(`Unknown SQLite session: ${sessionId}`);
|
||||
return row.next_seq;
|
||||
}
|
||||
|
||||
export function advanceNextSeq(db: SqliteDatabase, sessionId: string, nextSeq: number): void {
|
||||
const result = sql`UPDATE sessions SET next_seq = ${nextSeq} WHERE id = ${sessionId}`.run(db);
|
||||
if (result.changes !== 1)
|
||||
throw new Error(`Expected to update one SQLite session ${sessionId}, updated ${result.changes}`);
|
||||
}
|
||||
45
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-stats.ts
vendored
Normal file
45
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-stats.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SessionStats, UsageRow } from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
import { readSessionRow } from "./session-row.ts";
|
||||
|
||||
function addUsage(left: Usage, right: Usage): Usage {
|
||||
return {
|
||||
input: left.input + right.input,
|
||||
output: left.output + right.output,
|
||||
cacheRead: left.cacheRead + right.cacheRead,
|
||||
cacheWrite: left.cacheWrite + right.cacheWrite,
|
||||
...(left.cacheWrite1h === undefined && right.cacheWrite1h === undefined
|
||||
? {}
|
||||
: { cacheWrite1h: (left.cacheWrite1h ?? 0) + (right.cacheWrite1h ?? 0) }),
|
||||
...(left.reasoning === undefined && right.reasoning === undefined
|
||||
? {}
|
||||
: { reasoning: (left.reasoning ?? 0) + (right.reasoning ?? 0) }),
|
||||
totalTokens: left.totalTokens + right.totalTokens,
|
||||
cost: {
|
||||
input: left.cost.input + right.cost.input,
|
||||
output: left.cost.output + right.cost.output,
|
||||
cacheRead: left.cost.cacheRead + right.cost.cacheRead,
|
||||
cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
|
||||
total: left.cost.total + right.cost.total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readSessionStats(db: SqliteDatabase, sessionId: string): SessionStats {
|
||||
const row = readSessionRow(db, sessionId);
|
||||
return {
|
||||
messageCount: row.message_count,
|
||||
usage: JSON.parse(row.usage_payload) as Usage,
|
||||
};
|
||||
}
|
||||
|
||||
export function incrementMessageCount(db: SqliteDatabase, sessionId: string): void {
|
||||
sql`UPDATE sessions SET message_count = message_count + 1 WHERE id = ${sessionId}`.run(db);
|
||||
}
|
||||
|
||||
export function addUsageToSessionStats(db: SqliteDatabase, sessionId: string, usage: UsageRow["usage"]): void {
|
||||
const current = readSessionStats(db, sessionId).usage;
|
||||
sql`UPDATE sessions SET usage_payload = ${JSON.stringify(addUsage(current, usage))} WHERE id = ${sessionId}`.run(db);
|
||||
}
|
||||
67
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/usage-ledger.ts
vendored
Normal file
67
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/usage-ledger.ts
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { UsageRow, UsageScan } from "@earendil-works/pi-agent-core";
|
||||
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
|
||||
import type { SqliteDatabase, SqliteStatement } from "../types.ts";
|
||||
|
||||
export interface UsageLedgerRow {
|
||||
id: string;
|
||||
seq: number;
|
||||
entry_id: string | null;
|
||||
adjustment: number;
|
||||
usage: string;
|
||||
details: string | null;
|
||||
}
|
||||
|
||||
const INSERT_USAGE_LEDGER_SQL = `INSERT INTO usage_ledger (session_id, id, seq, entry_id, adjustment, usage, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
function usageLedgerRowParams(sessionId: string, row: UsageRow): unknown[] {
|
||||
return [
|
||||
sessionId,
|
||||
row.id,
|
||||
row.seq,
|
||||
row.entryId ?? null,
|
||||
row.adjustment ? 1 : 0,
|
||||
JSON.stringify(row.usage),
|
||||
row.details === undefined ? null : JSON.stringify(row.details),
|
||||
];
|
||||
}
|
||||
|
||||
export class UsageLedgerRowWriter {
|
||||
private readonly insertStatement: SqliteStatement;
|
||||
private readonly sessionId: string;
|
||||
|
||||
constructor(db: SqliteDatabase, sessionId: string) {
|
||||
this.insertStatement = db.prepare(INSERT_USAGE_LEDGER_SQL);
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
insert(row: UsageRow): void {
|
||||
this.insertStatement.run(...usageLedgerRowParams(this.sessionId, row));
|
||||
}
|
||||
}
|
||||
|
||||
export function insertUsageLedgerRow(db: SqliteDatabase, sessionId: string, row: UsageRow): void {
|
||||
db.prepare(INSERT_USAGE_LEDGER_SQL).run(...usageLedgerRowParams(sessionId, row));
|
||||
}
|
||||
|
||||
export function decodeUsageLedgerRow(row: UsageLedgerRow): UsageRow {
|
||||
return {
|
||||
id: row.id,
|
||||
seq: row.seq,
|
||||
usage: JSON.parse(row.usage) as UsageRow["usage"],
|
||||
...(row.entry_id === null ? {} : { entryId: row.entry_id }),
|
||||
adjustment: row.adjustment !== 0,
|
||||
...(row.details === null ? {} : { details: JSON.parse(row.details) as UsageRow["details"] }),
|
||||
};
|
||||
}
|
||||
|
||||
export function scanUsageLedgerRows(db: SqliteDatabase, sessionId: string, query: UsageScan): UsageLedgerRow[] {
|
||||
const filters: SqlQuery[] = [sql`session_id = ${sessionId}`];
|
||||
if (query.fromSeq !== undefined) filters.push(sql`seq >= ${query.fromSeq}`);
|
||||
if (query.toSeq !== undefined) filters.push(sql`seq <= ${query.toSeq}`);
|
||||
|
||||
const order = query.order === "desc" ? sql`ORDER BY seq DESC` : sql`ORDER BY seq ASC`;
|
||||
const limit = query.limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, query.limit)}`;
|
||||
return sql`SELECT id, seq, entry_id, adjustment, usage, details
|
||||
FROM usage_ledger WHERE ${joinSqlFragments(filters, " AND ")} ${order} ${limit}`.all<UsageLedgerRow>(db);
|
||||
}
|
||||
146
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/values.ts
vendored
Normal file
146
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/values.ts
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
type ListElement,
|
||||
type ListReadOptions,
|
||||
resolveListReadOptions,
|
||||
type StoredValue,
|
||||
type Value,
|
||||
type ValueList,
|
||||
value,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { type SqlQuery, sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
|
||||
export interface ScalarValueRow {
|
||||
namespace: string;
|
||||
key: string;
|
||||
seq: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ListValueRow {
|
||||
seq: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function setScalarValueRow(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
namespace: string,
|
||||
key: string,
|
||||
seq: number,
|
||||
storedValue: unknown,
|
||||
): void {
|
||||
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
|
||||
VALUES (${sessionId}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(storedValue)})
|
||||
ON CONFLICT(session_id, namespace, key) DO UPDATE SET seq = excluded.seq, value = excluded.value`.run(db);
|
||||
}
|
||||
|
||||
export function deleteScalarValueRow(db: SqliteDatabase, sessionId: string, namespace: string, key: string): void {
|
||||
sql`DELETE FROM scalar_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${namespace} AND key = ${key}`.run(db);
|
||||
}
|
||||
|
||||
export function appendListValueRow(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
namespace: string,
|
||||
key: string,
|
||||
seq: number,
|
||||
element: unknown,
|
||||
): void {
|
||||
sql`INSERT INTO list_values (session_id, namespace, key, seq, value)
|
||||
VALUES (${sessionId}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(element)})`.run(db);
|
||||
}
|
||||
|
||||
export function deleteListValueRows(db: SqliteDatabase, sessionId: string, namespace: string, key: string): void {
|
||||
sql`DELETE FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${namespace} AND key = ${key}`.run(db);
|
||||
}
|
||||
|
||||
function decodeScalarValueRow<T>(address: Value<T>, row: ScalarValueRow): StoredValue<T> {
|
||||
if (row.namespace !== address.namespace || row.key !== address.key) {
|
||||
throw new Error(`Expected value ${address.namespace}:${address.key}, found ${row.namespace}:${row.key}`);
|
||||
}
|
||||
return { address, seq: row.seq, value: JSON.parse(row.value) as T };
|
||||
}
|
||||
|
||||
export function readScalarValueRow<T>(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
address: Value<T>,
|
||||
): StoredValue<T> | undefined {
|
||||
const row = sql`SELECT namespace, key, seq, value FROM scalar_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}`.get<ScalarValueRow>(
|
||||
db,
|
||||
);
|
||||
return row === undefined ? undefined : decodeScalarValueRow(address, row);
|
||||
}
|
||||
|
||||
export function readAllScalarValueRows(db: SqliteDatabase, sessionId: string): StoredValue<unknown>[] {
|
||||
return sql`SELECT namespace, key, seq, value FROM scalar_values
|
||||
WHERE session_id = ${sessionId} ORDER BY seq ASC`
|
||||
.all<ScalarValueRow>(db)
|
||||
.map((row) => ({
|
||||
address: value<unknown>(row.namespace, row.key),
|
||||
seq: row.seq,
|
||||
value: JSON.parse(row.value),
|
||||
}));
|
||||
}
|
||||
|
||||
function nextPrefixBoundary(prefix: string): string | undefined {
|
||||
if (prefix === "") return undefined;
|
||||
const codePoints = Array.from(prefix);
|
||||
for (let index = codePoints.length - 1; index >= 0; index--) {
|
||||
const codePoint = codePoints[index]?.codePointAt(0);
|
||||
if (codePoint === undefined) throw new Error("Invalid value key prefix");
|
||||
if (codePoint < 0x10ffff) {
|
||||
const nextCodePoint = codePoint >= 0xd7ff && codePoint < 0xe000 ? 0xe000 : codePoint + 1;
|
||||
return `${codePoints.slice(0, index).join("")}${String.fromCodePoint(nextCodePoint)}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function scanScalarValueRows<T>(db: SqliteDatabase, sessionId: string, prefix: Value<T>): StoredValue<T>[] {
|
||||
const upperBound = nextPrefixBoundary(prefix.key);
|
||||
const rows =
|
||||
upperBound === undefined
|
||||
? sql`SELECT namespace, key, seq, value FROM scalar_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${prefix.namespace} AND key >= ${prefix.key}
|
||||
ORDER BY key ASC`.all<ScalarValueRow>(db)
|
||||
: sql`SELECT namespace, key, seq, value FROM scalar_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${prefix.namespace} AND key >= ${prefix.key} AND key < ${upperBound}
|
||||
ORDER BY key ASC`.all<ScalarValueRow>(db);
|
||||
return rows.map((row) => decodeScalarValueRow(value<T>(row.namespace, row.key), row));
|
||||
}
|
||||
|
||||
export function listValueReadQuery<T>(sessionId: string, address: ValueList<T>, options?: ListReadOptions): SqlQuery {
|
||||
const resolved = resolveListReadOptions(options);
|
||||
if (resolved.order === "asc") {
|
||||
return resolved.cursor === undefined
|
||||
? sql`SELECT seq, value FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}
|
||||
ORDER BY seq ASC LIMIT ${resolved.limit}`
|
||||
: sql`SELECT seq, value FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key} AND seq > ${resolved.cursor.seq}
|
||||
ORDER BY seq ASC LIMIT ${resolved.limit}`;
|
||||
}
|
||||
return resolved.cursor === undefined
|
||||
? sql`SELECT seq, value FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}
|
||||
ORDER BY seq DESC LIMIT ${resolved.limit}`
|
||||
: sql`SELECT seq, value FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key} AND seq < ${resolved.cursor.seq}
|
||||
ORDER BY seq DESC LIMIT ${resolved.limit}`;
|
||||
}
|
||||
|
||||
export function readListValueRows<T>(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
address: ValueList<T>,
|
||||
options?: ListReadOptions,
|
||||
): ListElement<T>[] {
|
||||
return listValueReadQuery(sessionId, address, options)
|
||||
.all<ListValueRow>(db)
|
||||
.map((row) => ({ seq: row.seq, value: JSON.parse(row.value) as T }));
|
||||
}
|
||||
66
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/sql.ts
vendored
Normal file
66
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/sql.ts
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { SqliteDatabase, SqliteRunResult } from "./types.ts";
|
||||
|
||||
type SqlTemplateValue = unknown | SqlQuery;
|
||||
|
||||
/** A parameterized SQLite query produced by {@link sql}. */
|
||||
export class SqlQuery {
|
||||
readonly queryText: string;
|
||||
readonly params: readonly unknown[];
|
||||
|
||||
constructor(queryText: string, params: readonly unknown[] = []) {
|
||||
this.queryText = queryText;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
exec(db: SqliteDatabase): void {
|
||||
if (this.params.length > 0) throw new TypeError("SQLite exec queries cannot have parameters");
|
||||
db.exec(this.queryText);
|
||||
}
|
||||
|
||||
run(db: SqliteDatabase): SqliteRunResult {
|
||||
return db.prepare(this.queryText).run(...this.params);
|
||||
}
|
||||
|
||||
get<TRow extends object>(db: SqliteDatabase): TRow | undefined {
|
||||
return db.prepare(this.queryText).get<TRow>(...this.params);
|
||||
}
|
||||
|
||||
all<TRow extends object>(db: SqliteDatabase): TRow[] {
|
||||
return db.prepare(this.queryText).all<TRow>(...this.params);
|
||||
}
|
||||
|
||||
iterate<TRow extends object>(db: SqliteDatabase): Iterable<TRow> {
|
||||
return db.prepare(this.queryText).iterate<TRow>(...this.params);
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a parameterized query. Nested queries are inlined; other interpolations become `?` parameters. */
|
||||
export function sql(strings: TemplateStringsArray, ...values: SqlTemplateValue[]): SqlQuery {
|
||||
let queryText = strings[0] ?? "";
|
||||
const params: unknown[] = [];
|
||||
for (let index = 0; index < values.length; index++) {
|
||||
const value = values[index];
|
||||
if (value instanceof SqlQuery) {
|
||||
queryText += value.queryText;
|
||||
params.push(...value.params);
|
||||
} else {
|
||||
queryText += "?";
|
||||
params.push(value);
|
||||
}
|
||||
queryText += strings[index + 1] ?? "";
|
||||
}
|
||||
return new SqlQuery(queryText, params);
|
||||
}
|
||||
|
||||
/** Joins trusted query fragments while preserving their parameter order. */
|
||||
export function joinSqlFragments(fragments: readonly SqlQuery[], separator: string): SqlQuery {
|
||||
let queryText = "";
|
||||
const params: unknown[] = [];
|
||||
for (let index = 0; index < fragments.length; index++) {
|
||||
if (index > 0) queryText += separator;
|
||||
const fragment = fragments[index]!;
|
||||
queryText += fragment.queryText;
|
||||
params.push(...fragment.params);
|
||||
}
|
||||
return new SqlQuery(queryText, params);
|
||||
}
|
||||
211
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/storage.ts
vendored
Normal file
211
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/storage.ts
vendored
Normal file
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
CommitResult,
|
||||
Context,
|
||||
Entry,
|
||||
EntryScan,
|
||||
EntryStructure,
|
||||
ForkOptions,
|
||||
ListElement,
|
||||
ListReadOptions,
|
||||
SessionStats,
|
||||
Storage,
|
||||
StorageBranchScan,
|
||||
StoredValue,
|
||||
UsageRow,
|
||||
UsageScan,
|
||||
Value,
|
||||
ValueList,
|
||||
Write,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { branchTip, prepareStorageCommit } from "@earendil-works/pi-agent-core";
|
||||
import { appendEntryToBranchIndex, scanBranchEntries, scanBranchEntryStructures } from "./session/branch-entries.ts";
|
||||
import { decodeEntryRow, EntryRowWriter, readAllEntryRows, readEntryRows, scanEntryRows } from "./session/entries.ts";
|
||||
import { advanceNextSeq, readNextSeq } from "./session/session-sequences.ts";
|
||||
import { addUsageToSessionStats, incrementMessageCount, readSessionStats } from "./session/session-stats.ts";
|
||||
import { decodeUsageLedgerRow, scanUsageLedgerRows, UsageLedgerRowWriter } from "./session/usage-ledger.ts";
|
||||
import {
|
||||
appendListValueRow,
|
||||
deleteListValueRows,
|
||||
deleteScalarValueRow,
|
||||
readAllScalarValueRows,
|
||||
readListValueRows,
|
||||
readScalarValueRow,
|
||||
scanScalarValueRows,
|
||||
setScalarValueRow,
|
||||
} from "./session/values.ts";
|
||||
import type { SqliteDatabase } from "./types.ts";
|
||||
|
||||
export interface SqliteStorageOptions {
|
||||
sessionId: string;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface SqliteStorageSnapshot {
|
||||
entries: Entry[];
|
||||
scalarValues: StoredValue<unknown>[];
|
||||
entriesComplete: boolean;
|
||||
}
|
||||
|
||||
export class SqliteStorage implements Storage {
|
||||
private readonly db: SqliteDatabase;
|
||||
private readonly sessionId: string;
|
||||
private readonly now: () => number;
|
||||
private readonly entryWriter: EntryRowWriter;
|
||||
private readonly usageWriter: UsageLedgerRowWriter;
|
||||
private commitQueue: Promise<void> = Promise.resolve();
|
||||
private state: "open" | "closing" | "closed" = "open";
|
||||
private closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(db: SqliteDatabase, options: SqliteStorageOptions) {
|
||||
this.db = db;
|
||||
this.sessionId = options.sessionId;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.entryWriter = new EntryRowWriter(db, this.sessionId);
|
||||
this.usageWriter = new UsageLedgerRowWriter(db, this.sessionId);
|
||||
}
|
||||
|
||||
async commit(writes: Write[], _context: Context): Promise<CommitResult> {
|
||||
if (this.state !== "open") throw new Error("SqliteStorage is closed");
|
||||
const result = this.commitQueue.then(() => this.applyCommit(writes));
|
||||
this.commitQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
getEntries(ids: string[], _context: Context): Promise<Map<string, Entry>> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
const rowsById = new Map(readEntryRows(this.db, this.sessionId, ids).map((row) => [row.id, row]));
|
||||
const entries = new Map<string, Entry>();
|
||||
for (const id of ids) {
|
||||
const row = rowsById.get(id);
|
||||
if (row !== undefined) entries.set(id, decodeEntryRow(row));
|
||||
}
|
||||
return Promise.resolve(entries);
|
||||
}
|
||||
|
||||
getValue<T>(address: Value<T>, _context: Context): Promise<StoredValue<T> | undefined> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
return Promise.resolve(readScalarValueRow(this.db, this.sessionId, address));
|
||||
}
|
||||
|
||||
scanValues<T>(prefix: Value<T>, _context: Context): Promise<StoredValue<T>[]> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
return Promise.resolve(scanScalarValueRows(this.db, this.sessionId, prefix));
|
||||
}
|
||||
|
||||
async readList<T>(
|
||||
address: ValueList<T>,
|
||||
options: ListReadOptions | undefined,
|
||||
_context: Context,
|
||||
): Promise<ListElement<T>[]> {
|
||||
if (this.state !== "open") throw new Error("SqliteStorage is closed");
|
||||
return readListValueRows(this.db, this.sessionId, address, options);
|
||||
}
|
||||
|
||||
scanBranch(query: StorageBranchScan, _context: Context): Promise<Entry[]> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
return Promise.resolve().then(() => scanBranchEntries(this.db, this.sessionId, query));
|
||||
}
|
||||
|
||||
scanBranchStructure(query: StorageBranchScan, _context: Context): Promise<EntryStructure[]> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
return Promise.resolve().then(() => scanBranchEntryStructures(this.db, this.sessionId, query));
|
||||
}
|
||||
|
||||
scanEntries(query: EntryScan, _context: Context): Promise<Entry[]> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
return Promise.resolve(scanEntryRows(this.db, this.sessionId, query).map(decodeEntryRow));
|
||||
}
|
||||
|
||||
scanUsage(query: UsageScan, _context: Context): Promise<UsageRow[]> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
return Promise.resolve(scanUsageLedgerRows(this.db, this.sessionId, query).map(decodeUsageLedgerRow));
|
||||
}
|
||||
|
||||
getStats(_context: Context): Promise<SessionStats> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
return Promise.resolve(readSessionStats(this.db, this.sessionId));
|
||||
}
|
||||
|
||||
snapshot(options: ForkOptions, _context: Context): Promise<SqliteStorageSnapshot> {
|
||||
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
|
||||
const result = this.commitQueue.then(() => this.readSnapshot(options));
|
||||
this.commitQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private readSnapshot(options: ForkOptions): SqliteStorageSnapshot {
|
||||
const scalarValues = readAllScalarValueRows(this.db, this.sessionId);
|
||||
return {
|
||||
entries: this.readSnapshotEntries(options, scalarValues),
|
||||
scalarValues,
|
||||
entriesComplete: options.scope === "tree",
|
||||
};
|
||||
}
|
||||
|
||||
private readSnapshotEntries(options: ForkOptions, scalarValues: readonly StoredValue<unknown>[]): Entry[] {
|
||||
if (options.scope === "tree") return readAllEntryRows(this.db, this.sessionId).map(decodeEntryRow);
|
||||
const sourceAddress = branchTip(options.branch);
|
||||
const sourceTip = scalarValues.find(
|
||||
(stored) => stored.address.namespace === sourceAddress.namespace && stored.address.key === sourceAddress.key,
|
||||
) as StoredValue<string | null> | undefined;
|
||||
if (sourceTip === undefined) throw new Error(`Unknown source branch: ${options.branch}`);
|
||||
return sourceTip.value === null
|
||||
? []
|
||||
: scanBranchEntries(this.db, this.sessionId, { start: sourceTip.value, order: "oldestFirst" });
|
||||
}
|
||||
|
||||
private applyCommit(writes: Write[]): CommitResult {
|
||||
return this.db.transaction(() => {
|
||||
const firstSeq = readNextSeq(this.db, this.sessionId);
|
||||
const prepared = prepareStorageCommit(writes, firstSeq, this.now());
|
||||
for (const write of prepared.writes) {
|
||||
switch (write.kind) {
|
||||
case "entry": {
|
||||
const { kind: _kind, ...entry } = write;
|
||||
this.entryWriter.insert(entry);
|
||||
appendEntryToBranchIndex(this.db, this.sessionId, entry);
|
||||
if (entry.type === "message") incrementMessageCount(this.db, this.sessionId);
|
||||
break;
|
||||
}
|
||||
case "usage": {
|
||||
const { kind: _kind, ...row } = write;
|
||||
this.usageWriter.insert(row);
|
||||
addUsageToSessionStats(this.db, this.sessionId, row.usage);
|
||||
break;
|
||||
}
|
||||
case "value":
|
||||
if (write.op === "delete") {
|
||||
deleteScalarValueRow(this.db, this.sessionId, write.namespace, write.key);
|
||||
} else {
|
||||
setScalarValueRow(this.db, this.sessionId, write.namespace, write.key, write.seq, write.value);
|
||||
}
|
||||
break;
|
||||
case "list":
|
||||
if (write.op === "delete") {
|
||||
deleteListValueRows(this.db, this.sessionId, write.namespace, write.key);
|
||||
} else {
|
||||
appendListValueRow(this.db, this.sessionId, write.namespace, write.key, write.seq, write.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
advanceNextSeq(this.db, this.sessionId, firstSeq + prepared.writes.length);
|
||||
return { ...prepared.result, stats: readSessionStats(this.db, this.sessionId) };
|
||||
});
|
||||
}
|
||||
|
||||
close(_context: Context): Promise<void> {
|
||||
if (this.closePromise !== undefined) return this.closePromise;
|
||||
this.state = "closing";
|
||||
this.closePromise = this.commitQueue.then(() => {
|
||||
this.state = "closed";
|
||||
});
|
||||
return this.closePromise;
|
||||
}
|
||||
}
|
||||
31
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/types.ts
vendored
Normal file
31
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/types.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
/** Result of a prepared SQLite statement execution. */
|
||||
export interface SqliteRunResult {
|
||||
changes: number;
|
||||
lastInsertRowid?: number;
|
||||
}
|
||||
|
||||
/** Prepared SQLite statement capability used by the SQLite session backend. */
|
||||
export interface SqliteStatement {
|
||||
run(...params: unknown[]): SqliteRunResult;
|
||||
get<TRow extends object>(...params: unknown[]): TRow | undefined;
|
||||
all<TRow extends object>(...params: unknown[]): TRow[];
|
||||
iterate<TRow extends object>(...params: unknown[]): Iterable<TRow>;
|
||||
}
|
||||
|
||||
/** SQLite database capability used by the SQLite session backend. */
|
||||
export interface SqliteDatabase {
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): SqliteStatement;
|
||||
/** Runs a synchronous write transaction. The callback must not return a promise. */
|
||||
transaction<T>(callback: () => T): T;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface SqliteDatabaseFactory {
|
||||
/** Open a writable database, creating it when absent. */
|
||||
open(path: string): Promise<SqliteDatabase>;
|
||||
/** Open a writable database without creating it. */
|
||||
openExisting(path: string): Promise<SqliteDatabase>;
|
||||
/** Open an existing database read-only. */
|
||||
openReadOnly(path: string): Promise<SqliteDatabase>;
|
||||
}
|
||||
102
third_party/pi/packages/session-backends/sqlite-node/test/adapter.test.ts
vendored
Normal file
102
third_party/pi/packages/session-backends/sqlite-node/test/adapter.test.ts
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
import { access, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNodeSqliteFactory } from "../src/index.ts";
|
||||
|
||||
async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-sqlite-adapter-"));
|
||||
try {
|
||||
return await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("node:sqlite adapter", () => {
|
||||
it("does not create files for existing or read-only opens", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const path = join(directory, "missing % #.sqlite");
|
||||
const factory = createNodeSqliteFactory();
|
||||
|
||||
await expect(factory.openExisting(path)).rejects.toThrow();
|
||||
await expect(access(path)).rejects.toThrow();
|
||||
await expect(factory.openReadOnly(path)).rejects.toThrow();
|
||||
await expect(access(path)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens an existing database read-write or read-only without changing its mode", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const path = join(directory, "existing % #.sqlite");
|
||||
const factory = createNodeSqliteFactory();
|
||||
const created = await factory.open(path);
|
||||
created.exec("CREATE TABLE values_table (value INTEGER NOT NULL)");
|
||||
created.close();
|
||||
|
||||
const writable = await factory.openExisting(path);
|
||||
writable.exec("INSERT INTO values_table (value) VALUES (1)");
|
||||
writable.close();
|
||||
const readOnly = await factory.openReadOnly(path);
|
||||
try {
|
||||
expect(readOnly.prepare("SELECT value FROM values_table").all()).toEqual([{ value: 1 }]);
|
||||
expect(() => readOnly.exec("INSERT INTO values_table (value) VALUES (2)")).toThrow();
|
||||
} finally {
|
||||
readOnly.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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)");
|
||||
const result = db.transaction(() => {
|
||||
db.prepare("INSERT INTO values_table (value) VALUES (?)").run(42);
|
||||
return "committed";
|
||||
});
|
||||
|
||||
expect(result).toBe("committed");
|
||||
expect(db.prepare("SELECT value FROM values_table").get()).toEqual({ value: 42 });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
db.exec("CREATE TABLE values_table (value INTEGER NOT NULL)");
|
||||
const asynchronous = async () => {
|
||||
db.prepare("INSERT INTO values_table (value) VALUES (?)").run(42);
|
||||
await Promise.resolve();
|
||||
};
|
||||
expect(() => db.transaction(asynchronous)).toThrow("SQLite transaction callbacks must be synchronous");
|
||||
await Promise.resolve();
|
||||
expect(db.prepare("SELECT value FROM values_table").all()).toEqual([]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
67
third_party/pi/packages/session-backends/sqlite-node/test/repo-conformance.test.ts
vendored
Normal file
67
third_party/pi/packages/session-backends/sqlite-node/test/repo-conformance.test.ts
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type ConformanceCase,
|
||||
createSessionRepoConformance,
|
||||
} from "@earendil-works/pi-agent-core/harness/session/testing";
|
||||
import { describe, it } from "vitest";
|
||||
import { createNodeSqliteFactory, SqliteSessionRepo } from "../src/index.ts";
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function registerConformance(name: string, cases: readonly ConformanceCase[]): void {
|
||||
describe(name, () => {
|
||||
for (const group of new Set(cases.map((testCase) => testCase.group))) {
|
||||
describe(group, () => {
|
||||
for (const testCase of cases.filter((candidate) => candidate.group === group)) {
|
||||
it(testCase.name, () => testCase.run());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let currentDirectory: string | undefined;
|
||||
let currentSharedDirectory: string | undefined;
|
||||
|
||||
async function createConformanceRepo() {
|
||||
currentDirectory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-repo-conformance-"));
|
||||
return new SqliteSessionRepo({
|
||||
directory: currentDirectory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => NOW,
|
||||
});
|
||||
}
|
||||
|
||||
async function createSharedContainerConformanceRepo() {
|
||||
currentSharedDirectory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-repo-shared-conformance-"));
|
||||
return new SqliteSessionRepo({
|
||||
directory: currentSharedDirectory,
|
||||
databasePath: join(currentSharedDirectory, "sessions.sqlite"),
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => NOW,
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupConformanceRepo() {
|
||||
if (currentDirectory === undefined) return;
|
||||
await rm(currentDirectory, { recursive: true, force: true });
|
||||
currentDirectory = undefined;
|
||||
}
|
||||
|
||||
async function cleanupSharedContainerConformanceRepo() {
|
||||
if (currentSharedDirectory === undefined) return;
|
||||
await rm(currentSharedDirectory, { recursive: true, force: true });
|
||||
currentSharedDirectory = undefined;
|
||||
}
|
||||
|
||||
registerConformance(
|
||||
"SqliteSessionRepo conformance",
|
||||
createSessionRepoConformance(createConformanceRepo, cleanupConformanceRepo),
|
||||
);
|
||||
|
||||
registerConformance(
|
||||
"SqliteSessionRepo shared-container conformance",
|
||||
createSessionRepoConformance(createSharedContainerConformanceRepo, cleanupSharedContainerConformanceRepo),
|
||||
);
|
||||
944
third_party/pi/packages/session-backends/sqlite-node/test/repo.test.ts
vendored
Normal file
944
third_party/pi/packages/session-backends/sqlite-node/test/repo.test.ts
vendored
Normal file
@@ -0,0 +1,944 @@
|
||||
import { access, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative } from "node:path";
|
||||
import * as storedValues from "@earendil-works/pi-agent-core";
|
||||
import * as sessionWrites from "@earendil-works/pi-agent-core";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteStatement } from "../src/index.ts";
|
||||
import { createNodeSqliteFactory, SqliteSessionRepo, sql } from "../src/index.ts";
|
||||
|
||||
const TEST_LANE_CONFIGURATION = {
|
||||
model: { provider: "test", modelId: "test" },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
} satisfies storedValues.LaneConfiguration;
|
||||
const IDLE_LANE_STATE = {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
} satisfies storedValues.LaneState;
|
||||
|
||||
async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-"));
|
||||
try {
|
||||
return await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function withDb<T>(path: string, run: (db: SqliteDatabase) => Promise<T> | T): Promise<T> {
|
||||
const db = await createNodeSqliteFactory().open(path);
|
||||
try {
|
||||
return await run(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
return access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
class ForwardingDatabase implements SqliteDatabase {
|
||||
readonly source: SqliteDatabase;
|
||||
|
||||
constructor(source: SqliteDatabase) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
exec(query: string): void {
|
||||
this.source.exec(query);
|
||||
}
|
||||
|
||||
prepare(query: string): SqliteStatement {
|
||||
return this.source.prepare(query);
|
||||
}
|
||||
|
||||
transaction<T>(callback: () => T): T {
|
||||
return this.source.transaction(callback);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.source.close();
|
||||
}
|
||||
}
|
||||
|
||||
class SnapshotBoundaryStatement implements SqliteStatement {
|
||||
readonly source: SqliteStatement;
|
||||
readonly afterGet: () => void;
|
||||
private called = false;
|
||||
|
||||
constructor(source: SqliteStatement, afterGet: () => void) {
|
||||
this.source = source;
|
||||
this.afterGet = afterGet;
|
||||
}
|
||||
|
||||
run(...params: unknown[]) {
|
||||
return this.source.run(...params);
|
||||
}
|
||||
|
||||
get<TRow extends object>(...params: unknown[]): TRow | undefined {
|
||||
const row = this.source.get<TRow>(...params);
|
||||
if (!this.called) {
|
||||
this.called = true;
|
||||
this.afterGet();
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
all<TRow extends object>(...params: unknown[]): TRow[] {
|
||||
return this.source.all<TRow>(...params);
|
||||
}
|
||||
|
||||
iterate<TRow extends object>(...params: unknown[]): Iterable<TRow> {
|
||||
return this.source.iterate<TRow>(...params);
|
||||
}
|
||||
}
|
||||
|
||||
class SnapshotBoundaryDatabase extends ForwardingDatabase {
|
||||
readonly afterSnapshotEstablished: () => void;
|
||||
|
||||
constructor(source: SqliteDatabase, afterSnapshotEstablished: () => void) {
|
||||
super(source);
|
||||
this.afterSnapshotEstablished = afterSnapshotEstablished;
|
||||
}
|
||||
|
||||
override prepare(query: string): SqliteStatement {
|
||||
const statement = this.source.prepare(query);
|
||||
return query.includes("FROM sessions")
|
||||
? new SnapshotBoundaryStatement(statement, this.afterSnapshotEstablished)
|
||||
: statement;
|
||||
}
|
||||
}
|
||||
|
||||
class SnapshotBoundaryFactory implements SqliteDatabaseFactory {
|
||||
readonly source = createNodeSqliteFactory();
|
||||
readonly afterSnapshotEstablished: () => void;
|
||||
readOnlyOpenCount = 0;
|
||||
|
||||
constructor(afterSnapshotEstablished: () => void) {
|
||||
this.afterSnapshotEstablished = afterSnapshotEstablished;
|
||||
}
|
||||
|
||||
open(path: string): Promise<SqliteDatabase> {
|
||||
return this.source.open(path);
|
||||
}
|
||||
|
||||
openExisting(path: string): Promise<SqliteDatabase> {
|
||||
return this.source.openExisting(path);
|
||||
}
|
||||
|
||||
async openReadOnly(path: string): Promise<SqliteDatabase> {
|
||||
this.readOnlyOpenCount++;
|
||||
return new SnapshotBoundaryDatabase(await this.source.openReadOnly(path), this.afterSnapshotEstablished);
|
||||
}
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
class GatedOpenExistingFactory implements SqliteDatabaseFactory {
|
||||
readonly source = createNodeSqliteFactory();
|
||||
readonly entered = deferred();
|
||||
readonly release = deferred();
|
||||
private gated = false;
|
||||
|
||||
arm(): void {
|
||||
this.gated = true;
|
||||
}
|
||||
|
||||
open(path: string): Promise<SqliteDatabase> {
|
||||
return this.source.open(path);
|
||||
}
|
||||
|
||||
async openExisting(path: string): Promise<SqliteDatabase> {
|
||||
if (this.gated) {
|
||||
this.gated = false;
|
||||
this.entered.resolve();
|
||||
await this.release.promise;
|
||||
}
|
||||
return this.source.openExisting(path);
|
||||
}
|
||||
|
||||
openReadOnly(path: string): Promise<SqliteDatabase> {
|
||||
return this.source.openReadOnly(path);
|
||||
}
|
||||
}
|
||||
|
||||
class CloseTrackingDatabase extends ForwardingDatabase {
|
||||
closeAttempts = 0;
|
||||
closeError: Error | undefined;
|
||||
|
||||
override close(): void {
|
||||
this.closeAttempts++;
|
||||
this.source.close();
|
||||
if (this.closeError !== undefined) throw this.closeError;
|
||||
}
|
||||
}
|
||||
|
||||
class CloseTrackingFactory implements SqliteDatabaseFactory {
|
||||
readonly source = createNodeSqliteFactory();
|
||||
readonly writableConnections: CloseTrackingDatabase[] = [];
|
||||
|
||||
async open(path: string): Promise<SqliteDatabase> {
|
||||
const db = new CloseTrackingDatabase(await this.source.open(path));
|
||||
this.writableConnections.push(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
async openExisting(path: string): Promise<SqliteDatabase> {
|
||||
const db = new CloseTrackingDatabase(await this.source.openExisting(path));
|
||||
this.writableConnections.push(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
openReadOnly(path: string): Promise<SqliteDatabase> {
|
||||
return this.source.openReadOnly(path);
|
||||
}
|
||||
}
|
||||
|
||||
function commitLaterSourceState(db: SqliteDatabase): void {
|
||||
const tip = storedValues.branchTip("main");
|
||||
const name = storedValues.sessionName;
|
||||
const label = storedValues.entryLabel("root");
|
||||
db.transaction(() => {
|
||||
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES (${"source"}, ${"child"}, ${"root"}, ${7}, ${"message"}, ${null}, ${2}, ${JSON.stringify({ message: { role: "user", content: "after", timestamp: 2 } })})`.run(
|
||||
db,
|
||||
);
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
VALUES (${"source"}, ${"root"}, ${"child"}, ${7}, ${"message"})`.run(db);
|
||||
sql`UPDATE branch_meta SET tip_entry_id = ${"child"}, tip_seq = ${7}
|
||||
WHERE session_id = ${"source"} AND branch_id = ${"root"}`.run(db);
|
||||
for (const [namespace, key, seq, value] of [
|
||||
[tip.namespace, tip.key, 8, "child"],
|
||||
[name.namespace, name.key, 9, "after"],
|
||||
[label.namespace, label.key, 10, "after-label"],
|
||||
] as const) {
|
||||
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
|
||||
VALUES (${"source"}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(value)})
|
||||
ON CONFLICT(session_id, namespace, key) DO UPDATE SET seq = excluded.seq, value = excluded.value`.run(db);
|
||||
}
|
||||
sql`UPDATE sessions SET message_count = ${1}, next_seq = ${11} WHERE id = ${"source"}`.run(db);
|
||||
});
|
||||
}
|
||||
|
||||
describe("SqliteSessionRepo", () => {
|
||||
it("creates one branchless initialized session file", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
const metadata = session.metadata;
|
||||
expect(metadata).toMatchObject({
|
||||
id: "session",
|
||||
createdAt: 1_700_000_000_000,
|
||||
storageVersion: 1,
|
||||
});
|
||||
expect(metadata.path).toBe(await realpath(join(directory, "session.sqlite")));
|
||||
|
||||
await withDb(metadata.path, (db) => {
|
||||
expect(sql`SELECT COUNT(*) AS count FROM sessions`.get<{ count: number }>(db)).toEqual({ count: 1 });
|
||||
expect(
|
||||
sql`SELECT message_count, usage_payload, next_seq FROM sessions WHERE id = ${"session"}`.get(db),
|
||||
).toEqual({
|
||||
message_count: 0,
|
||||
usage_payload: JSON.stringify({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
}),
|
||||
next_seq: 1,
|
||||
});
|
||||
expect(
|
||||
sql`SELECT namespace, key, seq, value FROM scalar_values WHERE session_id = ${"session"} ORDER BY seq`.all(
|
||||
db,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(sql`SELECT COUNT(*) AS count FROM list_values WHERE session_id = ${"session"}`.get(db)).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes explicit branch scans through the open-session facade", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
|
||||
sessionWrites.insertEntry({ id: "child", parentId: "root", type: "custom", customType: "child" }),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(await session.scanBranch({ start: "child", order: "oldestFirst" }, BACKGROUND_CONTEXT)).toMatchObject([
|
||||
{ id: "root" },
|
||||
{ id: "child" },
|
||||
]);
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
await expect(session.scanBranch({ start: "child" }, BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed");
|
||||
});
|
||||
});
|
||||
|
||||
it("commits an explicit mutation through the open-session facade", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
const mutation = await session.beginMutation(BACKGROUND_CONTEXT);
|
||||
const result = await mutation.commit(
|
||||
[storedValues.setValue(storedValues.sessionName, "explicit")],
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(result.seqs).toHaveLength(1);
|
||||
expect(await mutation.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
value: "explicit",
|
||||
});
|
||||
await mutation.end(BACKGROUND_CONTEXT);
|
||||
expect(await session.getName(BACKGROUND_CONTEXT)).toBe("explicit");
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate create without deleting the existing database", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
const { metadata } = session;
|
||||
|
||||
await expect(repo.create({ id: "session" }, BACKGROUND_CONTEXT)).rejects.toThrow();
|
||||
await withDb(metadata.path, (db) => {
|
||||
expect(sql`SELECT COUNT(*) AS count FROM sessions`.get<{ count: number }>(db)).toEqual({ count: 1 });
|
||||
});
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("lists an open session without storage-layer ownership state", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
const { metadata } = session;
|
||||
|
||||
await expect(repo.list(undefined, BACKGROUND_CONTEXT)).resolves.toMatchObject([
|
||||
{ id: "session", path: metadata.path },
|
||||
]);
|
||||
await withDb(metadata.path, (db) => {
|
||||
expect(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = ${"table"} AND name = ${"writer_lease"}`.get(db),
|
||||
).toBeUndefined();
|
||||
});
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a stale writer_lease table from a pre-WP07 WIP database", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const created = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
await created.close(BACKGROUND_CONTEXT);
|
||||
await withDb(created.metadata.path, (db) => {
|
||||
db.exec(
|
||||
"CREATE TABLE writer_lease (session_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, fence INTEGER NOT NULL, expires_at_ms INTEGER NOT NULL) WITHOUT ROWID",
|
||||
);
|
||||
sql`INSERT INTO writer_lease (session_id, owner_id, fence, expires_at_ms)
|
||||
VALUES (${"session"}, ${"stale"}, ${7}, ${999})`.run(db);
|
||||
});
|
||||
|
||||
const reopened = await repo.open(created.metadata, BACKGROUND_CONTEXT);
|
||||
await reopened.setName("works", BACKGROUND_CONTEXT);
|
||||
await reopened.close(BACKGROUND_CONTEXT);
|
||||
await withDb(created.metadata.path, (db) => {
|
||||
expect(sql`SELECT owner_id, fence FROM writer_lease WHERE session_id = ${"session"}`.get(db)).toEqual({
|
||||
owner_id: "stale",
|
||||
fence: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("skips corrupt and incompatible files during list discovery", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
await writeFile(join(directory, "corrupt.sqlite"), "not a sqlite database");
|
||||
await withDb(session.metadata.path, (db) => {
|
||||
sql`UPDATE sessions SET storage_version = ${999} WHERE id = ${"session"}`.run(db);
|
||||
});
|
||||
|
||||
expect(await repo.list(undefined, BACKGROUND_CONTEXT)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not remove a pre-existing non-database file when create fails", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const path = join(directory, "session.sqlite");
|
||||
await writeFile(path, "not a sqlite database");
|
||||
|
||||
await expect(repo.create({ id: "session" }, BACKGROUND_CONTEXT)).rejects.toThrow();
|
||||
await expect(access(path)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects delete for missing files and deletes a closed session", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
const { metadata } = session;
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(
|
||||
repo.delete({ ...metadata, path: join(directory, "missing.sqlite") }, BACKGROUND_CONTEXT),
|
||||
).rejects.toThrow();
|
||||
await repo.delete(metadata, BACKGROUND_CONTEXT);
|
||||
await expect(access(metadata.path)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes open sessions through repo close and rejects later operations", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
|
||||
await repo.close(BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(session.getStats(BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed");
|
||||
await expect(repo.list(undefined, BACKGROUND_CONTEXT)).rejects.toThrow("SqliteSessionRepo is closed");
|
||||
await expect(repo.create({ id: "other" }, BACKGROUND_CONTEXT)).rejects.toThrow("SqliteSessionRepo is closed");
|
||||
await expect(repo.close(BACKGROUND_CONTEXT)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a session through the version gate and rejects a duplicate local handle", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const created = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
const { metadata } = created;
|
||||
await created.close(BACKGROUND_CONTEXT);
|
||||
|
||||
const opened = await repo.open(metadata, BACKGROUND_CONTEXT);
|
||||
expect(opened.metadata).toMatchObject({ id: "session", path: metadata.path });
|
||||
await expect(repo.open(metadata, BACKGROUND_CONTEXT)).rejects.toThrow("already open");
|
||||
await opened.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates sessions stored in one shared SQLite container", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const databasePath = join(directory, "sessions.sqlite");
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databasePath,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
const left = await repo.create({ id: "left" }, BACKGROUND_CONTEXT);
|
||||
const right = await repo.create({ id: "right" }, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(left.metadata.path).toBe(await realpath(databasePath));
|
||||
expect(right.metadata.path).toBe(await realpath(databasePath));
|
||||
await left.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({ id: "left-root", parentId: null, type: "custom", customType: "left" }),
|
||||
storedValues.setValue(storedValues.sessionName, "left-name"),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await right.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({
|
||||
id: "right-root",
|
||||
parentId: null,
|
||||
type: "custom",
|
||||
customType: "right",
|
||||
}),
|
||||
storedValues.setValue(storedValues.sessionName, "right-name"),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect((await repo.list(undefined, BACKGROUND_CONTEXT)).map((metadata) => metadata.id).sort()).toEqual([
|
||||
"left",
|
||||
"right",
|
||||
]);
|
||||
expect(await left.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
value: "left-name",
|
||||
});
|
||||
expect(await right.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
value: "right-name",
|
||||
});
|
||||
expect((await left.getEntries(["left-root", "right-root"], BACKGROUND_CONTEXT)).has("right-root")).toBe(false);
|
||||
expect((await right.getEntries(["left-root", "right-root"], BACKGROUND_CONTEXT)).has("left-root")).toBe(false);
|
||||
|
||||
await Promise.all([left.close(BACKGROUND_CONTEXT), right.close(BACKGROUND_CONTEXT)]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not copy usage ledger rows when forking", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT);
|
||||
await source.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
|
||||
sessionWrites.insertEntry({
|
||||
id: "child",
|
||||
parentId: "root",
|
||||
type: "message",
|
||||
message: { role: "user", content: "child", timestamp: 1 },
|
||||
}),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "child"),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
|
||||
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
|
||||
sessionWrites.insertUsage({
|
||||
id: "usage",
|
||||
adjustment: false,
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
await source.close(BACKGROUND_CONTEXT);
|
||||
const fork = await repo.fork(
|
||||
source.metadata,
|
||||
{ id: "fork", scope: "branch", branch: "main", entryId: "child" },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
await withDb(fork.metadata.path, (db) => {
|
||||
expect(
|
||||
sql`SELECT COUNT(*) AS count FROM usage_ledger WHERE session_id = ${"fork"}`.get<{ count: number }>(db),
|
||||
).toEqual({ count: 0 });
|
||||
});
|
||||
await Promise.all([source.close(BACKGROUND_CONTEXT), fork.close(BACKGROUND_CONTEXT)]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create databases for missing open, list, fork, or delete targets", async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const directory = join(root, "missing-directory");
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const missing = {
|
||||
id: "missing",
|
||||
createdAt: 1,
|
||||
storageVersion: 1,
|
||||
path: join(directory, "missing.sqlite"),
|
||||
};
|
||||
|
||||
await expect(repo.list(undefined, BACKGROUND_CONTEXT)).resolves.toEqual([]);
|
||||
expect(await pathExists(directory)).toBe(false);
|
||||
await expect(repo.open(missing, BACKGROUND_CONTEXT)).rejects.toThrow();
|
||||
await expect(repo.delete(missing, BACKGROUND_CONTEXT)).rejects.toThrow();
|
||||
await expect(repo.fork(missing, { id: "fork", scope: "tree" }, BACKGROUND_CONTEXT)).rejects.toThrow();
|
||||
expect(await pathExists(missing.path)).toBe(false);
|
||||
expect(await pathExists(join(directory, "fork.sqlite"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves deletion against local create, open, and fork destinations", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const databaseFactory = new GatedOpenExistingFactory();
|
||||
const repo = new SqliteSessionRepo({ directory, databaseFactory, now: () => 1 });
|
||||
const target = await repo.create({ id: "target" }, BACKGROUND_CONTEXT);
|
||||
const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT);
|
||||
await expect(repo.delete(target.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("already open");
|
||||
// The host closes a worker before deletion; only same-repository exclusion is promised here.
|
||||
await target.close(BACKGROUND_CONTEXT);
|
||||
databaseFactory.arm();
|
||||
|
||||
const deleting = repo.delete(target.metadata, BACKGROUND_CONTEXT);
|
||||
await databaseFactory.entered.promise;
|
||||
await expect(repo.create({ id: "target" }, BACKGROUND_CONTEXT)).rejects.toThrow("already open");
|
||||
await expect(repo.open(target.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("already open");
|
||||
await expect(repo.fork(source.metadata, { id: "target", scope: "tree" }, BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
"already open",
|
||||
);
|
||||
databaseFactory.release.resolve();
|
||||
await deleting;
|
||||
await source.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes only the selected Session from a shared container", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const databasePath = join(directory, "shared.sqlite");
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databasePath,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const removed = await repo.create({ id: "removed" }, BACKGROUND_CONTEXT);
|
||||
const retained = await repo.create({ id: "retained" }, BACKGROUND_CONTEXT);
|
||||
await removed.setName("removed", BACKGROUND_CONTEXT);
|
||||
await retained.setName("retained", BACKGROUND_CONTEXT);
|
||||
await removed.close(BACKGROUND_CONTEXT);
|
||||
|
||||
await repo.delete(removed.metadata, BACKGROUND_CONTEXT);
|
||||
|
||||
expect((await repo.list(undefined, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual(["retained"]);
|
||||
expect(await retained.getName(BACKGROUND_CONTEXT)).toBe("retained");
|
||||
await expect(repo.open(removed.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("Unknown SQLite session");
|
||||
await retained.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes per-file WAL and SHM sidecars", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
await writeFile(`${session.metadata.path}-wal`, "");
|
||||
await writeFile(`${session.metadata.path}-shm`, "");
|
||||
|
||||
await repo.delete(session.metadata, BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(access(session.metadata.path)).rejects.toThrow();
|
||||
await expect(access(`${session.metadata.path}-wal`)).rejects.toThrow();
|
||||
await expect(access(`${session.metadata.path}-shm`)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the parent of a custom shared-container path", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const databasePath = join(directory, "nested", "containers", "sessions.sqlite");
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory: join(directory, "unrelated"),
|
||||
databasePath,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
|
||||
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(session.metadata.path).toBe(await realpath(databasePath));
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes unsafe explicit IDs inside the repository directory and round-trips them", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repo = new SqliteSessionRepo({
|
||||
directory,
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const ids = ["../escape", "slash/id", "back\\slash", "percent%id", "..", "dots...id", "ユニコード"];
|
||||
const metadata = [];
|
||||
for (const id of ids) {
|
||||
const session = await repo.create({ id }, BACKGROUND_CONTEXT);
|
||||
metadata.push(session.metadata);
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
}
|
||||
const canonicalDirectory = await realpath(directory);
|
||||
for (const stored of metadata) {
|
||||
const fromDirectory = relative(canonicalDirectory, stored.path);
|
||||
expect(isAbsolute(fromDirectory)).toBe(false);
|
||||
expect(fromDirectory.startsWith(".."), stored.id).toBe(false);
|
||||
expect(dirname(stored.path)).toBe(canonicalDirectory);
|
||||
}
|
||||
expect((await repo.list(undefined, BACKGROUND_CONTEXT)).map(({ id }) => id).sort()).toEqual([...ids].sort());
|
||||
for (const stored of metadata) {
|
||||
const reopened = await repo.open(stored, BACKGROUND_CONTEXT);
|
||||
expect(reopened.metadata.id).toBe(stored.id);
|
||||
await reopened.close(BACKGROUND_CONTEXT);
|
||||
}
|
||||
const fork = await repo.fork(metadata[0]!, { id: "fork/../%", scope: "tree" }, BACKGROUND_CONTEXT);
|
||||
expect(fork.metadata).toMatchObject({ id: "fork/../%", parentSessionId: "../escape" });
|
||||
expect(dirname(fork.metadata.path)).toBe(canonicalDirectory);
|
||||
await fork.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses exact physical identity for foreign fork sources and rejects foreign writable metadata", async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const leftRepo = new SqliteSessionRepo({
|
||||
directory: join(root, "left"),
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const rightRepo = new SqliteSessionRepo({
|
||||
directory: join(root, "right"),
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const left = await leftRepo.create({ id: "same" }, BACKGROUND_CONTEXT);
|
||||
const right = await rightRepo.create({ id: "same" }, BACKGROUND_CONTEXT);
|
||||
await left.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({ id: "left-root", parentId: null, type: "custom", customType: "left" }),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "left-root"),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
|
||||
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
|
||||
storedValues.setValue(storedValues.sessionName, "left"),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await right.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({
|
||||
id: "right-root",
|
||||
parentId: null,
|
||||
type: "custom",
|
||||
customType: "right",
|
||||
}),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "right-root"),
|
||||
storedValues.setValue(storedValues.sessionName, "right"),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const fork = await rightRepo.fork(
|
||||
left.metadata,
|
||||
{ id: "fork-left", scope: "branch", branch: "main" },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect((await fork.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual([
|
||||
"left-root",
|
||||
]);
|
||||
expect(await fork.getName(BACKGROUND_CONTEXT)).toBe("left");
|
||||
await right.close(BACKGROUND_CONTEXT);
|
||||
await expect(rightRepo.open(left.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("outside this repository");
|
||||
await expect(rightRepo.delete(left.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("outside this repository");
|
||||
await Promise.all([left.close(BACKGROUND_CONTEXT), fork.close(BACKGROUND_CONTEXT)]);
|
||||
});
|
||||
});
|
||||
|
||||
for (const layout of ["per-file", "shared"] as const) {
|
||||
it(`forks a live ${layout} source through one coherent read-only WAL snapshot`, async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const databasePath = layout === "shared" ? join(directory, "shared.sqlite") : undefined;
|
||||
const workerRepo = new SqliteSessionRepo({
|
||||
directory,
|
||||
...(databasePath === undefined ? {} : { databasePath }),
|
||||
databaseFactory: createNodeSqliteFactory(),
|
||||
now: () => 1,
|
||||
});
|
||||
const source = await workerRepo.create({ id: "source" }, BACKGROUND_CONTEXT);
|
||||
await source.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "root"),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
|
||||
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
|
||||
storedValues.setValue(storedValues.sessionName, "before"),
|
||||
storedValues.setValue(storedValues.entryLabel("root"), "before-label"),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const writerDb = await createNodeSqliteFactory().openExisting(source.metadata.path);
|
||||
writerDb.exec("PRAGMA busy_timeout = 5000;");
|
||||
let laterCommitCompleted = false;
|
||||
const databaseFactory = new SnapshotBoundaryFactory(() => {
|
||||
if (laterCommitCompleted) return;
|
||||
commitLaterSourceState(writerDb);
|
||||
laterCommitCompleted = true;
|
||||
});
|
||||
const serverRepo = new SqliteSessionRepo({
|
||||
directory,
|
||||
...(databasePath === undefined ? {} : { databasePath }),
|
||||
databaseFactory,
|
||||
now: () => 2,
|
||||
});
|
||||
|
||||
const first = await serverRepo.fork(
|
||||
source.metadata,
|
||||
{ id: "first-fork", scope: "branch", branch: "main" },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(laterCommitCompleted).toBe(true);
|
||||
expect(databaseFactory.readOnlyOpenCount).toBe(1);
|
||||
expect((await first.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual([
|
||||
"root",
|
||||
]);
|
||||
expect(await first.getName(BACKGROUND_CONTEXT)).toBe("before");
|
||||
expect(await first.getLabel("root", BACKGROUND_CONTEXT)).toBe("before-label");
|
||||
await expect((await first.branch("main", BACKGROUND_CONTEXT))?.getTipId(BACKGROUND_CONTEXT)).resolves.toBe(
|
||||
"root",
|
||||
);
|
||||
expect((await first.getStats(BACKGROUND_CONTEXT)).messageCount).toBe(0);
|
||||
|
||||
const second = await serverRepo.fork(
|
||||
source.metadata,
|
||||
{ id: "second-fork", scope: "branch", branch: "main" },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(databaseFactory.readOnlyOpenCount).toBe(2);
|
||||
expect((await second.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual([
|
||||
"root",
|
||||
"child",
|
||||
]);
|
||||
expect(await second.getName(BACKGROUND_CONTEXT)).toBe("after");
|
||||
expect(await second.getLabel("root", BACKGROUND_CONTEXT)).toBe("after-label");
|
||||
await expect((await second.branch("main", BACKGROUND_CONTEXT))?.getTipId(BACKGROUND_CONTEXT)).resolves.toBe(
|
||||
"child",
|
||||
);
|
||||
expect((await second.getStats(BACKGROUND_CONTEXT)).messageCount).toBe(1);
|
||||
|
||||
writerDb.close();
|
||||
await Promise.all([
|
||||
source.close(BACKGROUND_CONTEXT),
|
||||
first.close(BACKGROUND_CONTEXT),
|
||||
second.close(BACKGROUND_CONTEXT),
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("waits for every Session close and reports one or multiple failures after settlement", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const multipleFactory = new CloseTrackingFactory();
|
||||
const multipleRepo = new SqliteSessionRepo({
|
||||
directory: join(directory, "multiple"),
|
||||
databaseFactory: multipleFactory,
|
||||
now: () => 1,
|
||||
});
|
||||
await multipleRepo.create({ id: "first" }, BACKGROUND_CONTEXT);
|
||||
await multipleRepo.create({ id: "second" }, BACKGROUND_CONTEXT);
|
||||
await multipleRepo.create({ id: "third" }, BACKGROUND_CONTEXT);
|
||||
const firstError = new Error("first close failed");
|
||||
const secondError = new Error("second close failed");
|
||||
multipleFactory.writableConnections[0]!.closeError = firstError;
|
||||
multipleFactory.writableConnections[1]!.closeError = secondError;
|
||||
|
||||
const multipleClose = multipleRepo.close(BACKGROUND_CONTEXT);
|
||||
expect(multipleRepo.close(BACKGROUND_CONTEXT)).toBe(multipleClose);
|
||||
let multipleFailure: unknown;
|
||||
try {
|
||||
await multipleClose;
|
||||
} catch (error) {
|
||||
multipleFailure = error;
|
||||
}
|
||||
expect(multipleFailure).toBeInstanceOf(AggregateError);
|
||||
if (!(multipleFailure instanceof AggregateError)) throw new Error("Expected AggregateError");
|
||||
expect(multipleFailure.errors).toEqual([firstError, secondError]);
|
||||
expect(multipleFactory.writableConnections.map(({ closeAttempts }) => closeAttempts)).toEqual([1, 1, 1]);
|
||||
|
||||
const singleFactory = new CloseTrackingFactory();
|
||||
const singleRepo = new SqliteSessionRepo({
|
||||
directory: join(directory, "single"),
|
||||
databaseFactory: singleFactory,
|
||||
now: () => 1,
|
||||
});
|
||||
await singleRepo.create({ id: "first" }, BACKGROUND_CONTEXT);
|
||||
await singleRepo.create({ id: "second" }, BACKGROUND_CONTEXT);
|
||||
const singleError = new Error("single close failed");
|
||||
singleFactory.writableConnections[0]!.closeError = singleError;
|
||||
|
||||
await expect(singleRepo.close(BACKGROUND_CONTEXT)).rejects.toBe(singleError);
|
||||
expect(singleFactory.writableConnections.map(({ closeAttempts }) => closeAttempts)).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
39
third_party/pi/packages/session-backends/sqlite-node/test/sql.test.ts
vendored
Normal file
39
third_party/pi/packages/session-backends/sqlite-node/test/sql.test.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNodeSqliteFactory, joinSqlFragments, sql } from "../src/index.ts";
|
||||
|
||||
describe("sql", () => {
|
||||
it("composes SQLite queries without renumbering parameters", async () => {
|
||||
const db = await createNodeSqliteFactory().open(":memory:");
|
||||
try {
|
||||
sql`CREATE TABLE entries (id TEXT PRIMARY KEY, kind TEXT NOT NULL, active INTEGER NOT NULL)`.exec(db);
|
||||
sql`INSERT INTO entries (id, kind, active) VALUES (${"one"}, ${"message"}, ${1})`.run(db);
|
||||
sql`INSERT INTO entries (id, kind, active) VALUES (${"two"}, ${"message"}, ${0})`.run(db);
|
||||
const filters = joinSqlFragments([sql`kind = ${"message"}`, sql`active = ${1}`], " AND ");
|
||||
|
||||
expect(sql`SELECT id FROM entries WHERE ${filters} LIMIT ${10}`.all<{ id: string }>(db)).toEqual([
|
||||
{ id: "one" },
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("executes parameterized queries", async () => {
|
||||
const db = await createNodeSqliteFactory().open(":memory:");
|
||||
try {
|
||||
sql`CREATE TABLE values_table (id INTEGER PRIMARY KEY, value TEXT NOT NULL)`.exec(db);
|
||||
sql`INSERT INTO values_table (id, value) VALUES (${1}, ${"one"})`.run(db);
|
||||
sql`INSERT INTO values_table (id, value) VALUES (${2}, ${"two"})`.run(db);
|
||||
|
||||
expect(sql`SELECT value FROM values_table WHERE id = ${1}`.get<{ value: string }>(db)).toEqual({
|
||||
value: "one",
|
||||
});
|
||||
expect(sql`SELECT value FROM values_table ORDER BY id`.all<{ value: string }>(db)).toEqual([
|
||||
{ value: "one" },
|
||||
{ value: "two" },
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
61
third_party/pi/packages/session-backends/sqlite-node/test/storage-conformance.test.ts
vendored
Normal file
61
third_party/pi/packages/session-backends/sqlite-node/test/storage-conformance.test.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
type ConformanceCase,
|
||||
createStorageConformance,
|
||||
type StorageFixture,
|
||||
} from "@earendil-works/pi-agent-core/harness/session/testing";
|
||||
import { describe, it } from "vitest";
|
||||
import { createNodeSqliteFactory, SQLITE_STORAGE_VERSION, SqliteStorage, sql } from "../src/index.ts";
|
||||
import { applyInitialSchema } from "../src/sqlite/migrations.ts";
|
||||
|
||||
const SESSION_ID = "session";
|
||||
const NOW = 1_700_000_000_000;
|
||||
const EMPTY_USAGE = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
function registerConformance(name: string, cases: readonly ConformanceCase[]): void {
|
||||
describe(name, () => {
|
||||
for (const group of new Set(cases.map((testCase) => testCase.group))) {
|
||||
describe(group, () => {
|
||||
for (const testCase of cases.filter((candidate) => candidate.group === group)) {
|
||||
it(testCase.name, () => testCase.run());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerConformance(
|
||||
"SqliteStorage conformance",
|
||||
createStorageConformance(async () => {
|
||||
const db = await createNodeSqliteFactory().open(":memory:");
|
||||
try {
|
||||
await applyInitialSchema(db);
|
||||
sql`INSERT INTO sessions
|
||||
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
|
||||
VALUES (${SESSION_ID}, ${NOW}, ${null}, ${SQLITE_STORAGE_VERSION}, ${null}, ${0}, ${JSON.stringify(EMPTY_USAGE)}, ${1})`.run(
|
||||
db,
|
||||
);
|
||||
const storage = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => NOW });
|
||||
return {
|
||||
storage,
|
||||
async [Symbol.asyncDispose]() {
|
||||
try {
|
||||
await storage.close(BACKGROUND_CONTEXT);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
} satisfies StorageFixture;
|
||||
} catch (error) {
|
||||
db.close();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
696
third_party/pi/packages/session-backends/sqlite-node/test/storage.test.ts
vendored
Normal file
696
third_party/pi/packages/session-backends/sqlite-node/test/storage.test.ts
vendored
Normal file
@@ -0,0 +1,696 @@
|
||||
import * as storedValues from "@earendil-works/pi-agent-core";
|
||||
import * as sessionWrites from "@earendil-works/pi-agent-core";
|
||||
import { BACKGROUND_CONTEXT, prepareStorageCommit } from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNodeSqliteFactory,
|
||||
type SqliteDatabase,
|
||||
type SqliteStatement,
|
||||
SqliteStorage,
|
||||
sql,
|
||||
} from "../src/index.ts";
|
||||
import { applyInitialSchema } from "../src/sqlite/migrations.ts";
|
||||
import { advanceNextSeq, readNextSeq } from "../src/sqlite/session/session-sequences.ts";
|
||||
import { listValueReadQuery } from "../src/sqlite/session/values.ts";
|
||||
|
||||
const SESSION_ID = "session";
|
||||
|
||||
class TransactionCountingDatabase implements SqliteDatabase {
|
||||
readonly source: SqliteDatabase;
|
||||
transactionCount = 0;
|
||||
|
||||
constructor(source: SqliteDatabase) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
exec(query: string): void {
|
||||
this.source.exec(query);
|
||||
}
|
||||
|
||||
prepare(query: string): SqliteStatement {
|
||||
return this.source.prepare(query);
|
||||
}
|
||||
|
||||
transaction<T>(callback: () => T): T {
|
||||
this.transactionCount++;
|
||||
return this.source.transaction(callback);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.source.close();
|
||||
}
|
||||
}
|
||||
|
||||
const ZERO_USAGE = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
async function withStorage<T>(run: (storage: SqliteStorage, db: SqliteDatabase) => Promise<T>): Promise<T> {
|
||||
const db = await createNodeSqliteFactory().open(":memory:");
|
||||
try {
|
||||
await applyInitialSchema(db);
|
||||
const storage = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => 1_700_000_000_000 });
|
||||
return await run(storage, db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function explainQueryPlan(db: SqliteDatabase, query: string, ...params: unknown[]): string[] {
|
||||
return db
|
||||
.prepare(`EXPLAIN QUERY PLAN ${query}`)
|
||||
.all<{ detail: string }>(...params)
|
||||
.map((row) => row.detail);
|
||||
}
|
||||
|
||||
function expectBranchPlan(plan: string[]): void {
|
||||
expect(plan.some((detail) => detail.includes("SEARCH b USING COVERING INDEX ix_be_seq"))).toBe(true);
|
||||
expect(plan.some((detail) => detail.includes("SEARCH e USING PRIMARY KEY"))).toBe(true);
|
||||
expect(plan.some((detail) => detail.includes("USE TEMP B-TREE"))).toBe(false);
|
||||
expect(plan.some((detail) => detail.includes("SCAN e"))).toBe(false);
|
||||
}
|
||||
|
||||
function insertCommitSessionRow(db: SqliteDatabase, nextSeq = 1): void {
|
||||
sql`INSERT INTO sessions
|
||||
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
|
||||
VALUES (
|
||||
${SESSION_ID},
|
||||
${1},
|
||||
${null},
|
||||
${1},
|
||||
${null},
|
||||
${0},
|
||||
${JSON.stringify(ZERO_USAGE)},
|
||||
${nextSeq}
|
||||
)`.run(db);
|
||||
}
|
||||
|
||||
describe("SqliteStorage", () => {
|
||||
it("uses one write transaction for an ordinary commit", async () => {
|
||||
const db = new TransactionCountingDatabase(await createNodeSqliteFactory().open(":memory:"));
|
||||
try {
|
||||
await applyInitialSchema(db);
|
||||
insertCommitSessionRow(db);
|
||||
const storage = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => 1_700_000_000_000 });
|
||||
|
||||
await storage.commit([storedValues.setValue(storedValues.sessionName, "name")], BACKGROUND_CONTEXT);
|
||||
|
||||
expect(db.transactionCount).toBe(1);
|
||||
await storage.close(BACKGROUND_CONTEXT);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("commits root entries and append-to-tip entries into the branch index", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
insertCommitSessionRow(db);
|
||||
|
||||
expect(
|
||||
await storage.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({
|
||||
id: "root",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: { role: "user", content: "root", timestamp: 10 },
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual({
|
||||
firstSeq: 1,
|
||||
seqs: [1],
|
||||
timestamp: 1_700_000_000_000,
|
||||
stats: { messageCount: 1, usage: ZERO_USAGE },
|
||||
});
|
||||
expect(
|
||||
await storage.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({
|
||||
id: "child",
|
||||
parentId: "root",
|
||||
type: "message",
|
||||
message: { role: "user", content: "child", timestamp: 11 },
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual({
|
||||
firstSeq: 2,
|
||||
seqs: [2],
|
||||
timestamp: 1_700_000_000_000,
|
||||
stats: { messageCount: 2, usage: ZERO_USAGE },
|
||||
});
|
||||
|
||||
expect(sql`SELECT branch_id, tip_entry_id, tip_seq FROM branch_meta`.all(db)).toEqual([
|
||||
{ branch_id: "root", tip_entry_id: "child", tip_seq: 2 },
|
||||
]);
|
||||
expect(
|
||||
sql`SELECT branch_id, entry_id, entry_seq, entry_type FROM branch_entries ORDER BY entry_seq`.all(db),
|
||||
).toEqual([
|
||||
{ branch_id: "root", entry_id: "root", entry_seq: 1, entry_type: "message" },
|
||||
{ branch_id: "root", entry_id: "child", entry_seq: 2, entry_type: "message" },
|
||||
]);
|
||||
expect((await storage.scanBranch({ start: "child" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
|
||||
"child",
|
||||
"root",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("commits divergent branch entries by materializing a new segment", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
insertCommitSessionRow(db);
|
||||
await storage.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({
|
||||
id: "root",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: { role: "user", content: "root", timestamp: 10 },
|
||||
}),
|
||||
sessionWrites.insertEntry({
|
||||
id: "left",
|
||||
parentId: "root",
|
||||
type: "message",
|
||||
message: { role: "user", content: "left", timestamp: 11 },
|
||||
}),
|
||||
sessionWrites.insertEntry({
|
||||
id: "right",
|
||||
parentId: "root",
|
||||
type: "message",
|
||||
message: { role: "user", content: "right", timestamp: 12 },
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(
|
||||
sql`SELECT branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq FROM branch_meta ORDER BY branch_id`.all(
|
||||
db,
|
||||
),
|
||||
).toEqual([
|
||||
{ branch_id: "right", tip_entry_id: "right", tip_seq: 3, base_branch_id: null, base_seq: null },
|
||||
{ branch_id: "root", tip_entry_id: "left", tip_seq: 2, base_branch_id: null, base_seq: null },
|
||||
]);
|
||||
expect(
|
||||
sql`SELECT branch_id, entry_id, entry_seq, entry_type FROM branch_entries ORDER BY branch_id, entry_seq`.all(
|
||||
db,
|
||||
),
|
||||
).toEqual([
|
||||
{ branch_id: "right", entry_id: "root", entry_seq: 1, entry_type: "message" },
|
||||
{ branch_id: "right", entry_id: "right", entry_seq: 3, entry_type: "message" },
|
||||
{ branch_id: "root", entry_id: "root", entry_seq: 1, entry_type: "message" },
|
||||
{ branch_id: "root", entry_id: "left", entry_seq: 2, entry_type: "message" },
|
||||
]);
|
||||
expect((await storage.scanBranch({ start: "right" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
|
||||
"right",
|
||||
"root",
|
||||
]);
|
||||
expect((await storage.scanBranch({ start: "left" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
|
||||
"left",
|
||||
"root",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("bases divergent branch segments at the newest compaction", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
insertCommitSessionRow(db);
|
||||
await storage.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({
|
||||
id: "root",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: { role: "user", content: "root", timestamp: 10 },
|
||||
}),
|
||||
sessionWrites.insertEntry({
|
||||
id: "compact",
|
||||
parentId: "root",
|
||||
type: "compaction",
|
||||
summary: "summary",
|
||||
retainedTail: [],
|
||||
tokensBefore: 1,
|
||||
fromHook: false,
|
||||
}),
|
||||
sessionWrites.insertEntry({
|
||||
id: "left",
|
||||
parentId: "compact",
|
||||
type: "message",
|
||||
message: { role: "user", content: "left", timestamp: 11 },
|
||||
}),
|
||||
sessionWrites.insertEntry({
|
||||
id: "leaf",
|
||||
parentId: "left",
|
||||
type: "message",
|
||||
message: { role: "user", content: "leaf", timestamp: 12 },
|
||||
}),
|
||||
sessionWrites.insertEntry({
|
||||
id: "right",
|
||||
parentId: "left",
|
||||
type: "message",
|
||||
message: { role: "user", content: "right", timestamp: 13 },
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(
|
||||
sql`SELECT branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq FROM branch_meta WHERE branch_id = ${"right"}`.get(
|
||||
db,
|
||||
),
|
||||
).toEqual({
|
||||
branch_id: "right",
|
||||
tip_entry_id: "right",
|
||||
tip_seq: 5,
|
||||
base_branch_id: "root",
|
||||
base_seq: 2,
|
||||
});
|
||||
expect(
|
||||
sql`SELECT branch_id, entry_id, entry_seq, entry_type FROM branch_entries WHERE branch_id = ${"right"} ORDER BY entry_seq`.all(
|
||||
db,
|
||||
),
|
||||
).toEqual([
|
||||
{ branch_id: "right", entry_id: "left", entry_seq: 3, entry_type: "message" },
|
||||
{ branch_id: "right", entry_id: "right", entry_seq: 5, entry_type: "message" },
|
||||
]);
|
||||
expect((await storage.scanBranch({ start: "right" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
|
||||
"right",
|
||||
"left",
|
||||
"compact",
|
||||
"root",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("gets entries by requested id order", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"first"}, ${null}, ${1}, ${"custom"}, ${"note"}, ${10}, ${JSON.stringify({ data: { value: 1 } })}),
|
||||
(${SESSION_ID}, ${"second"}, ${"first"}, ${2}, ${"message"}, ${null}, ${11}, ${JSON.stringify({ message: { role: "user", content: "hi", timestamp: 11 } })})`.run(
|
||||
db,
|
||||
);
|
||||
|
||||
const entries = await storage.getEntries(["second", "missing", "first"], BACKGROUND_CONTEXT);
|
||||
|
||||
expect([...entries.keys()]).toEqual(["second", "first"]);
|
||||
expect(entries.get("second")).toMatchObject({ id: "second", type: "message", parentId: "first" });
|
||||
expect(entries.get("first")).toMatchObject({ id: "first", type: "custom", customType: "note" });
|
||||
});
|
||||
});
|
||||
|
||||
it("scans decoded entries with filters and sequence bounds", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"one"}, ${null}, ${1}, ${"custom"}, ${"note"}, ${10}, ${JSON.stringify({ data: 1 })}),
|
||||
(${SESSION_ID}, ${"two"}, ${"one"}, ${2}, ${"message"}, ${null}, ${11}, ${JSON.stringify({ message: { role: "user", content: "two", timestamp: 11 } })}),
|
||||
(${SESSION_ID}, ${"three"}, ${"two"}, ${3}, ${"custom"}, ${"note"}, ${12}, ${JSON.stringify({ data: 3 })})`.run(
|
||||
db,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await storage.scanEntries({ order: "desc", type: "custom", fromSeq: 2 }, BACKGROUND_CONTEXT)).map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
).toEqual(["three"]);
|
||||
expect(
|
||||
(await storage.scanEntries({ order: "asc", customType: "note", limit: 2 }, BACKGROUND_CONTEXT)).map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
).toEqual(["one", "three"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("scans branch entries through materialized branch segments", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"root"}, ${null}, ${1}, ${"message"}, ${null}, ${10}, ${JSON.stringify({ message: { role: "user", content: "root", timestamp: 10 } })}),
|
||||
(${SESSION_ID}, ${"compact"}, ${"root"}, ${2}, ${"compaction"}, ${null}, ${11}, ${JSON.stringify({ summary: "s", retainedTail: [], tokensBefore: 1, fromHook: false })}),
|
||||
(${SESSION_ID}, ${"old"}, ${"compact"}, ${3}, ${"message"}, ${null}, ${12}, ${JSON.stringify({ message: { role: "assistant", content: "old", timestamp: 12 } })}),
|
||||
(${SESSION_ID}, ${"custom"}, ${"old"}, ${4}, ${"custom"}, ${"note"}, ${13}, ${JSON.stringify({ data: 4 })}),
|
||||
(${SESSION_ID}, ${"leaf"}, ${"custom"}, ${5}, ${"message"}, ${null}, ${14}, ${JSON.stringify({ message: { role: "user", content: "leaf", timestamp: 14 } })})`.run(
|
||||
db,
|
||||
);
|
||||
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"base"}, ${"compact"}, ${2}, ${null}, ${null}),
|
||||
(${SESSION_ID}, ${"new"}, ${"leaf"}, ${5}, ${"base"}, ${2})`.run(db);
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"base"}, ${"root"}, ${1}, ${"message"}),
|
||||
(${SESSION_ID}, ${"base"}, ${"compact"}, ${2}, ${"compaction"}),
|
||||
(${SESSION_ID}, ${"new"}, ${"old"}, ${3}, ${"message"}),
|
||||
(${SESSION_ID}, ${"new"}, ${"custom"}, ${4}, ${"custom"}),
|
||||
(${SESSION_ID}, ${"new"}, ${"leaf"}, ${5}, ${"message"})`.run(db);
|
||||
|
||||
expect(
|
||||
(await storage.scanBranch({ start: "leaf", stopAtType: "compaction", limit: 2 }, BACKGROUND_CONTEXT)).map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
).toEqual(["leaf", "custom"]);
|
||||
expect(
|
||||
(
|
||||
await storage.scanBranch(
|
||||
{ start: "leaf", order: "oldestFirst", type: "message", cursor: { seq: 1 } },
|
||||
BACKGROUND_CONTEXT,
|
||||
)
|
||||
).map((entry) => entry.id),
|
||||
).toEqual(["old", "leaf"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a branch segment that physically contains the start entry", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"root"}, ${null}, ${1}, ${"message"}, ${null}, ${10}, ${JSON.stringify({ message: { role: "user", content: "root", timestamp: 10 } })}),
|
||||
(${SESSION_ID}, ${"base-tip"}, ${"root"}, ${2}, ${"message"}, ${null}, ${11}, ${JSON.stringify({ message: { role: "assistant", content: "base", timestamp: 11 } })}),
|
||||
(${SESSION_ID}, ${"new-tip"}, ${"base-tip"}, ${3}, ${"message"}, ${null}, ${12}, ${JSON.stringify({ message: { role: "user", content: "new", timestamp: 12 } })})`.run(
|
||||
db,
|
||||
);
|
||||
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"aaa-new"}, ${"new-tip"}, ${3}, ${"zzz-base"}, ${2}),
|
||||
(${SESSION_ID}, ${"zzz-base"}, ${"base-tip"}, ${2}, ${null}, ${null})`.run(db);
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"aaa-new"}, ${"new-tip"}, ${3}, ${"message"}),
|
||||
(${SESSION_ID}, ${"zzz-base"}, ${"root"}, ${1}, ${"message"}),
|
||||
(${SESSION_ID}, ${"zzz-base"}, ${"base-tip"}, ${2}, ${"message"})`.run(db);
|
||||
|
||||
expect(
|
||||
(await storage.scanBranch({ start: "base-tip", order: "oldestFirst" }, BACKGROUND_CONTEXT)).map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
).toEqual(["root", "base-tip"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies branch stop boundaries across base segments before filtering", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"root"}, ${null}, ${1}, ${"message"}, ${null}, ${10}, ${JSON.stringify({ message: { role: "user", content: "root", timestamp: 10 } })}),
|
||||
(${SESSION_ID}, ${"compact"}, ${"root"}, ${2}, ${"compaction"}, ${null}, ${11}, ${JSON.stringify({ summary: "s", retainedTail: [], tokensBefore: 1, fromHook: false })}),
|
||||
(${SESSION_ID}, ${"after"}, ${"compact"}, ${3}, ${"message"}, ${null}, ${12}, ${JSON.stringify({ message: { role: "assistant", content: "after", timestamp: 12 } })}),
|
||||
(${SESSION_ID}, ${"leaf"}, ${"after"}, ${4}, ${"custom"}, ${"note"}, ${13}, ${JSON.stringify({ data: 4 })})`.run(
|
||||
db,
|
||||
);
|
||||
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"base"}, ${"compact"}, ${2}, ${null}, ${null}),
|
||||
(${SESSION_ID}, ${"new"}, ${"leaf"}, ${4}, ${"base"}, ${2})`.run(db);
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"base"}, ${"root"}, ${1}, ${"message"}),
|
||||
(${SESSION_ID}, ${"base"}, ${"compact"}, ${2}, ${"compaction"}),
|
||||
(${SESSION_ID}, ${"new"}, ${"after"}, ${3}, ${"message"}),
|
||||
(${SESSION_ID}, ${"new"}, ${"leaf"}, ${4}, ${"custom"})`.run(db);
|
||||
|
||||
expect(
|
||||
(await storage.scanBranch({ start: "leaf", stopAtType: "compaction" }, BACKGROUND_CONTEXT)).map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
).toEqual(["leaf", "after", "compact"]);
|
||||
expect(
|
||||
(
|
||||
await storage.scanBranch(
|
||||
{ start: "leaf", stopAtType: "compaction", type: "message" },
|
||||
BACKGROUND_CONTEXT,
|
||||
)
|
||||
).map((entry) => entry.id),
|
||||
).toEqual(["after"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses branch_entries as the outer scan for branch payload queries", async () => {
|
||||
await withStorage(async (_storage, db) => {
|
||||
const plan = explainQueryPlan(
|
||||
db,
|
||||
`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload
|
||||
FROM branch_entries b
|
||||
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
|
||||
WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ?
|
||||
ORDER BY b.entry_seq DESC LIMIT ?`,
|
||||
SESSION_ID,
|
||||
"main",
|
||||
0,
|
||||
10,
|
||||
2,
|
||||
);
|
||||
|
||||
expectBranchPlan(plan);
|
||||
});
|
||||
});
|
||||
|
||||
it("scans branch structure without payloads", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"root"}, ${null}, ${1}, ${"message"}, ${null}, ${10}, ${JSON.stringify({ message: { role: "user", content: "root", timestamp: 10 } })}),
|
||||
(${SESSION_ID}, ${"custom"}, ${"root"}, ${2}, ${"custom"}, ${"note"}, ${11}, ${JSON.stringify({ data: 2 })})`.run(
|
||||
db,
|
||||
);
|
||||
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
|
||||
VALUES (${SESSION_ID}, ${"main"}, ${"custom"}, ${2}, ${null}, ${null})`.run(db);
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"main"}, ${"root"}, ${1}, ${"message"}),
|
||||
(${SESSION_ID}, ${"main"}, ${"custom"}, ${2}, ${"custom"})`.run(db);
|
||||
|
||||
expect(await storage.scanBranchStructure({ start: "custom", customType: "note" }, BACKGROUND_CONTEXT)).toEqual(
|
||||
[
|
||||
{
|
||||
id: "custom",
|
||||
parentId: "root",
|
||||
seq: 2,
|
||||
timestamp: 11,
|
||||
type: "custom",
|
||||
customType: "note",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses branch_entries as the outer scan for branch structure queries", async () => {
|
||||
await withStorage(async (_storage, db) => {
|
||||
const plan = explainQueryPlan(
|
||||
db,
|
||||
`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp
|
||||
FROM branch_entries b
|
||||
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
|
||||
WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ?
|
||||
ORDER BY b.entry_seq ASC LIMIT ?`,
|
||||
SESSION_ID,
|
||||
"main",
|
||||
0,
|
||||
10,
|
||||
2,
|
||||
);
|
||||
|
||||
expectBranchPlan(plan);
|
||||
});
|
||||
});
|
||||
|
||||
it("scans decoded usage rows with sequence bounds", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
const usage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
totalTokens: 10,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
sql`INSERT INTO usage_ledger (session_id, id, seq, entry_id, adjustment, usage, details)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"u1"}, ${1}, ${"e1"}, ${0}, ${JSON.stringify(usage)}, ${null}),
|
||||
(${SESSION_ID}, ${"u2"}, ${2}, ${null}, ${1}, ${JSON.stringify(usage)}, ${JSON.stringify({ reason: "adjust" })}),
|
||||
(${SESSION_ID}, ${"u3"}, ${3}, ${"e3"}, ${0}, ${JSON.stringify(usage)}, ${null})`.run(db);
|
||||
|
||||
expect(await storage.scanUsage({ fromSeq: 2, order: "asc", limit: 1 }, BACKGROUND_CONTEXT)).toEqual([
|
||||
{ id: "u2", seq: 2, usage, adjustment: true, details: { reason: "adjust" } },
|
||||
]);
|
||||
expect(
|
||||
(await storage.scanUsage({ toSeq: 2, order: "desc" }, BACKGROUND_CONTEXT)).map((row) => row.id),
|
||||
).toEqual(["u2", "u1"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("prepares committed writes with assigned sequences and timestamp", () => {
|
||||
const prepared = prepareStorageCommit(
|
||||
[
|
||||
sessionWrites.insertEntry({
|
||||
id: "entry",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: { role: "user", content: "hi", timestamp: 1 },
|
||||
}),
|
||||
sessionWrites.insertUsage({
|
||||
id: "usage",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
adjustment: false,
|
||||
}),
|
||||
storedValues.setValue(storedValues.sessionName, "name"),
|
||||
storedValues.deleteValue(storedValues.entryLabel("entry")),
|
||||
],
|
||||
7,
|
||||
1_700_000_000_000,
|
||||
);
|
||||
|
||||
expect(prepared.result).toEqual({ firstSeq: 7, seqs: [7, 8, 9, 10], timestamp: 1_700_000_000_000 });
|
||||
expect(prepared.writes).toMatchObject([
|
||||
{ kind: "entry", id: "entry", seq: 7, timestamp: 1_700_000_000_000 },
|
||||
{ kind: "usage", id: "usage", seq: 8 },
|
||||
storedValues.setValue(storedValues.sessionName, "name"),
|
||||
storedValues.deleteValue(storedValues.entryLabel("entry")),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads and advances the next commit sequence", async () => {
|
||||
await withStorage(async (_storage, db) => {
|
||||
sql`INSERT INTO sessions
|
||||
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
|
||||
VALUES (${SESSION_ID}, ${1}, ${null}, ${1}, ${null}, ${0}, ${JSON.stringify({})}, ${7})`.run(db);
|
||||
|
||||
expect(readNextSeq(db, SESSION_ID)).toBe(7);
|
||||
advanceNextSeq(db, SESSION_ID, 10);
|
||||
expect(readNextSeq(db, SESSION_ID)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
it("gets maintained session stats", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
const usage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
totalTokens: 10,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
sql`INSERT INTO sessions
|
||||
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
|
||||
VALUES (${SESSION_ID}, ${1}, ${null}, ${1}, ${null}, ${2}, ${JSON.stringify(usage)}, ${3})`.run(db);
|
||||
|
||||
expect(await storage.getStats(BACKGROUND_CONTEXT)).toEqual({ messageCount: 2, usage });
|
||||
const next = await storage.commit(
|
||||
[storedValues.setValue(storedValues.sessionName, "after-history")],
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(next.stats).toEqual({ messageCount: 2, usage });
|
||||
});
|
||||
});
|
||||
|
||||
it("includes historical totals in the first commit after storage reopen", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
insertCommitSessionRow(db);
|
||||
const usage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
await storage.commit(
|
||||
[
|
||||
sessionWrites.insertEntry({
|
||||
id: "history",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: { role: "user", content: "history", timestamp: 1 },
|
||||
}),
|
||||
sessionWrites.insertUsage({ id: "usage", usage, adjustment: false }),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await storage.close(BACKGROUND_CONTEXT);
|
||||
|
||||
const reopened = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => 1_700_000_000_000 });
|
||||
const result = await reopened.commit(
|
||||
[storedValues.setValue(storedValues.sessionName, "reopened")],
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(result.stats).toEqual({ messageCount: 1, usage });
|
||||
expect(result.stats).toEqual(await reopened.getStats(BACKGROUND_CONTEXT));
|
||||
await reopened.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
it("gets a decoded scalar value by bound address", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
const address = storedValues.value<{ ready: boolean }>("test.value", "state");
|
||||
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
|
||||
VALUES (${SESSION_ID}, ${address.namespace}, ${address.key}, ${1}, ${JSON.stringify({ ready: true })})`.run(
|
||||
db,
|
||||
);
|
||||
|
||||
expect(await storage.getValue(address, BACKGROUND_CONTEXT)).toEqual({
|
||||
address,
|
||||
seq: 1,
|
||||
value: { ready: true },
|
||||
});
|
||||
expect(
|
||||
await storage.getValue(storedValues.value<unknown>("test.value", "missing"), BACKGROUND_CONTEXT),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("scans decoded scalar values by namespace and key prefix", async () => {
|
||||
await withStorage(async (storage, db) => {
|
||||
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
|
||||
VALUES
|
||||
(${SESSION_ID}, ${"test.value"}, ${"app:one"}, ${1}, ${JSON.stringify(1)}),
|
||||
(${SESSION_ID}, ${"test.value"}, ${"app:two"}, ${2}, ${JSON.stringify(2)}),
|
||||
(${SESSION_ID}, ${"test.value"}, ${"app:\ufffftail"}, ${3}, ${JSON.stringify(3)}),
|
||||
(${SESSION_ID}, ${"test.value"}, ${"app;other"}, ${4}, ${JSON.stringify(4)}),
|
||||
(${SESSION_ID}, ${"test.value"}, ${"other"}, ${5}, ${JSON.stringify(5)}),
|
||||
(${SESSION_ID}, ${"other.value"}, ${""}, ${6}, ${JSON.stringify("name")})`.run(db);
|
||||
|
||||
expect(
|
||||
await storage.scanValues(storedValues.value<unknown>("test.value", "app:"), BACKGROUND_CONTEXT),
|
||||
).toEqual([
|
||||
{ address: storedValues.value<unknown>("test.value", "app:one"), seq: 1, value: 1 },
|
||||
{ address: storedValues.value<unknown>("test.value", "app:two"), seq: 2, value: 2 },
|
||||
{ address: storedValues.value<unknown>("test.value", "app:\ufffftail"), seq: 3, value: 3 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the list primary key for ascending and descending cursor pages", async () => {
|
||||
await withStorage(async (_storage, db) => {
|
||||
const address = storedValues.list<unknown>("test.list", "events");
|
||||
for (const options of [
|
||||
{ order: "asc" as const },
|
||||
{ order: "asc" as const, cursor: { seq: 4 } },
|
||||
{ order: "desc" as const },
|
||||
{ order: "desc" as const, cursor: { seq: 4 } },
|
||||
]) {
|
||||
const query = listValueReadQuery(SESSION_ID, address, options);
|
||||
const plan = explainQueryPlan(db, query.queryText, ...query.params);
|
||||
expect(plan.some((detail) => detail.includes("USING PRIMARY KEY"))).toBe(true);
|
||||
expect(plan.some((detail) => detail.includes("SCAN list_values"))).toBe(false);
|
||||
expect(plan.some((detail) => detail.includes("USE TEMP B-TREE"))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
11
third_party/pi/packages/session-backends/sqlite-node/tsconfig.build.json
vendored
Normal file
11
third_party/pi/packages/session-backends/sqlite-node/tsconfig.build.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
29
third_party/pi/packages/session-backends/sqlite-node/vitest.benchmark.config.ts
vendored
Normal file
29
third_party/pi/packages/session-backends/sqlite-node/vitest.benchmark.config.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url));
|
||||
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 agentSessionTesting = fileURLToPath(
|
||||
new URL("../../agent/src/harness/session/testing/index.ts", import.meta.url),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
benchmark: {
|
||||
include: ["benchmark/session/**/*.bench.ts"],
|
||||
reporters: ["verbose"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
conditions: ["source"],
|
||||
alias: [
|
||||
{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex },
|
||||
{ find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting },
|
||||
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex },
|
||||
{ find: /^@earendil-works\/pi-ai$/, replacement: aiIndex },
|
||||
],
|
||||
},
|
||||
ssr: { resolve: { conditions: ["source"] } },
|
||||
});
|
||||
37
third_party/pi/packages/session-backends/sqlite-node/vitest.config.ts
vendored
Normal file
37
third_party/pi/packages/session-backends/sqlite-node/vitest.config.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url));
|
||||
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: {
|
||||
conditions: ["source"],
|
||||
alias: [
|
||||
{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex },
|
||||
{ find: /^@earendil-works\/pi-agent-core\/node$/, replacement: agentNode },
|
||||
{ find: /^@earendil-works\/pi-agent-core\/harness\/session\/testing$/, replacement: agentSessionTesting },
|
||||
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex },
|
||||
{ find: /^@earendil-works\/pi-ai$/, replacement: aiIndex },
|
||||
],
|
||||
},
|
||||
ssr: { resolve: { conditions: ["source"] } },
|
||||
});
|
||||
Reference in New Issue
Block a user