diff --git a/README.md b/README.md index 425a70e40410..e9d4d686e4cf 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS - `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared web app. - `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. +## Persistence architecture + +Server persistence now runs on the Effect v4 beta SQL stack with SQLite: + +- Effect SQL drivers are selected by runtime (Bun in development, Node in production). +- Schema bootstrapping is managed through the Effect SQL migrator. +- Persistence reads/writes are organized into Effect-backed repositories under `apps/server/src/persistence/`. +- State synchronization (`state.bootstrap`, ordered `state.event`, `state.catchUp`) is still exposed through the existing WebSocket API surface, with typed state-event payloads shared through `@t3tools/contracts`. + ## Codex prerequisites - Install Codex CLI so `codex` is on your PATH. diff --git a/apps/server/package.json b/apps/server/package.json index 59f41aa67800..a84cb9f08f05 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,7 +17,10 @@ "test": "vitest run" }, "dependencies": { + "@effect/sql-sqlite-bun": "^4.0.0-beta.6", + "@effect/sql-sqlite-node": "^4.0.0-beta.6", "@pierre/diffs": "^1.1.0-beta.16", + "effect": "^4.0.0-beta.6", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0" diff --git a/apps/server/src/persistence/config.ts b/apps/server/src/persistence/config.ts new file mode 100644 index 000000000000..0b7a5275aa00 --- /dev/null +++ b/apps/server/src/persistence/config.ts @@ -0,0 +1,16 @@ +import path from "node:path"; + +export interface PersistenceConfig { + dbPath: string; + legacyProjectsJsonPath?: string; +} + +export function resolvePersistenceConfig(input: PersistenceConfig): PersistenceConfig { + const resolved: PersistenceConfig = { + dbPath: path.resolve(input.dbPath), + }; + if (input.legacyProjectsJsonPath) { + resolved.legacyProjectsJsonPath = path.resolve(input.legacyProjectsJsonPath); + } + return resolved; +} diff --git a/apps/server/src/persistence/domain/appSettings.ts b/apps/server/src/persistence/domain/appSettings.ts new file mode 100644 index 000000000000..ee3470acf895 --- /dev/null +++ b/apps/server/src/persistence/domain/appSettings.ts @@ -0,0 +1,25 @@ +import { + type AppSettings, + type AppSettingsUpdateInput, + appSettingsSchema, + appSettingsUpdateInputSchema, +} from "@t3tools/contracts"; + +export function resolveAppSettings(metadataValue: unknown): AppSettings { + const parsed = appSettingsSchema.safeParse(metadataValue); + if (parsed.success) { + return parsed.data; + } + return appSettingsSchema.parse({}); +} + +export function buildUpdatedAppSettings( + current: AppSettings, + rawPatch: AppSettingsUpdateInput, +): AppSettings { + const patch = appSettingsUpdateInputSchema.parse(rawPatch); + return appSettingsSchema.parse({ + ...current, + ...patch, + }); +} diff --git a/apps/server/src/persistence/domain/messages.ts b/apps/server/src/persistence/domain/messages.ts new file mode 100644 index 000000000000..28dc6161e411 --- /dev/null +++ b/apps/server/src/persistence/domain/messages.ts @@ -0,0 +1,39 @@ +import { + type ProviderSendTurnInput, + type StateMessage, + stateMessageSchema, +} from "@t3tools/contracts"; + +export function messageDocId(threadId: string, messageId: string): string { + return `message:${threadId}:${messageId}`; +} + +export function buildUserTurnMessage(input: { + turn: ProviderSendTurnInput; + threadId: string; + messageId: string; + createdAt: string; +}): StateMessage { + const text = input.turn.clientMessageText ?? input.turn.input ?? ""; + const inputAttachments = input.turn.attachments ?? []; + const attachments = + inputAttachments.length > 0 + ? inputAttachments.map((attachment, index) => ({ + type: "image" as const, + id: `${input.messageId}:image:${index + 1}`, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + })) + : undefined; + return stateMessageSchema.parse({ + id: input.messageId, + threadId: input.threadId, + role: "user", + text, + ...(attachments ? { attachments } : {}), + createdAt: input.createdAt, + updatedAt: input.createdAt, + streaming: false, + }); +} diff --git a/apps/server/src/persistence/domain/projects.ts b/apps/server/src/persistence/domain/projects.ts new file mode 100644 index 000000000000..09b94f00a0c8 --- /dev/null +++ b/apps/server/src/persistence/domain/projects.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function normalizeCwd(rawCwd: string): string { + const resolved = path.resolve(rawCwd.trim()); + const normalized = path.normalize(resolved); + if (process.platform === "win32") { + return normalized.toLowerCase(); + } + return normalized; +} + +export function isDirectory(cwd: string): boolean { + try { + return fs.statSync(cwd).isDirectory(); + } catch { + return false; + } +} + +export function inferProjectName(cwd: string): string { + const name = path.basename(cwd); + return name.length > 0 ? name : "project"; +} diff --git a/apps/server/src/persistence/domain/providerProjection.ts b/apps/server/src/persistence/domain/providerProjection.ts new file mode 100644 index 000000000000..db8bdb62a3e4 --- /dev/null +++ b/apps/server/src/persistence/domain/providerProjection.ts @@ -0,0 +1,42 @@ +import type { ProviderEvent } from "@t3tools/contracts"; + +export function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function normalizeProviderItemType(value: string | undefined): string | undefined { + if (!value) return undefined; + const normalized = value.trim(); + if (normalized.length === 0) return undefined; + return normalized.replace(/[_\-\s]+/g, "").toLowerCase(); +} + +export function parseThreadIdFromEventPayload(payload: unknown): string | null { + const record = asObject(payload); + const threadId = asString(record?.threadId) ?? asString(record?.thread_id); + if (threadId) return threadId; + const thread = asObject(record?.thread); + return asString(thread?.id) ?? null; +} + +export function parseTurnIdFromEvent(event: ProviderEvent): string | null { + if (event.turnId) return event.turnId; + const payload = asObject(event.payload); + const turn = asObject(payload?.turn); + return asString(turn?.id) ?? null; +} + +export function parseAssistantItemId(event: ProviderEvent): string | null { + const payload = asObject(event.payload); + const item = asObject(payload?.item); + const itemType = asString(item?.type); + if (itemType !== "agentMessage") return null; + return asString(item?.id) ?? event.itemId ?? null; +} diff --git a/apps/server/src/persistence/domain/stateSync.ts b/apps/server/src/persistence/domain/stateSync.ts new file mode 100644 index 000000000000..7bd6e870fd85 --- /dev/null +++ b/apps/server/src/persistence/domain/stateSync.ts @@ -0,0 +1,41 @@ +import { + type StateBootstrapResult, + type StateBootstrapThread, + type StateCatchUpResult, + type StateEvent, + type StateListMessagesResult, + type StateMessage, + type StateProject, + stateBootstrapResultSchema, + stateCatchUpResultSchema, + stateListMessagesResultSchema, +} from "@t3tools/contracts"; + +export function buildStateBootstrapResult(input: { + projects: StateProject[]; + threads: StateBootstrapThread[]; + lastStateSeq: number; +}): StateBootstrapResult { + return stateBootstrapResultSchema.parse(input); +} + +export function buildStateCatchUpResult(input: { + events: StateEvent[]; + lastStateSeq: number; +}): StateCatchUpResult { + return stateCatchUpResultSchema.parse(input); +} + +export function buildStateListMessagesResult(input: { + messages: StateMessage[]; + total: number; + offset: number; + pageSize: number; +}): StateListMessagesResult { + const nextOffset = input.offset + input.pageSize; + return stateListMessagesResultSchema.parse({ + messages: input.messages, + total: input.total, + nextOffset: nextOffset < input.total ? nextOffset : null, + }); +} diff --git a/apps/server/src/persistence/domain/threads.ts b/apps/server/src/persistence/domain/threads.ts new file mode 100644 index 000000000000..4c8f6a75fa21 --- /dev/null +++ b/apps/server/src/persistence/domain/threads.ts @@ -0,0 +1,117 @@ +import type { StateThread } from "@t3tools/contracts"; + +const MAX_TERMINAL_COUNT = 4; +const DEFAULT_TERMINAL_ID = "default"; + +export function normalizeTerminalIds(ids: readonly string[]): string[] { + const normalized = [ + ...new Set(ids.map((id) => id.trim()).filter((id) => id.length > 0)), + ].slice(0, MAX_TERMINAL_COUNT); + if (normalized.length > 0) { + return normalized; + } + return [DEFAULT_TERMINAL_ID]; +} + +function normalizeRunningTerminalIds( + runningTerminalIds: readonly string[], + terminalIds: readonly string[], +): string[] { + if (runningTerminalIds.length === 0) { + return []; + } + + const validTerminalIds = new Set(terminalIds); + return [...new Set(runningTerminalIds)] + .map((id) => id.trim()) + .filter((id) => id.length > 0 && validTerminalIds.has(id)) + .slice(0, MAX_TERMINAL_COUNT); +} + +export function fallbackGroupId(terminalId: string): string { + return `group-${terminalId}`; +} + +function assignUniqueGroupId(groupId: string, usedGroupIds: Set): string { + if (!usedGroupIds.has(groupId)) { + usedGroupIds.add(groupId); + return groupId; + } + + let suffix = 2; + while (usedGroupIds.has(`${groupId}-${suffix}`)) { + suffix += 1; + } + const uniqueGroupId = `${groupId}-${suffix}`; + usedGroupIds.add(uniqueGroupId); + return uniqueGroupId; +} + +function normalizeTerminalGroups( + groups: StateThread["terminalGroups"], + terminalIds: readonly string[], +): StateThread["terminalGroups"] { + const validTerminalIds = new Set(terminalIds); + const assignedTerminalIds = new Set(); + const usedGroupIds = new Set(); + const normalizedGroups: StateThread["terminalGroups"] = []; + + for (const group of groups) { + const groupTerminalIds = [ + ...new Set(group.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), + ].filter((terminalId) => { + if (!validTerminalIds.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (groupTerminalIds.length === 0) continue; + for (const terminalId of groupTerminalIds) { + assignedTerminalIds.add(terminalId); + } + const baseGroupId = + group.id.trim().length > 0 + ? group.id.trim() + : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_TERMINAL_ID); + normalizedGroups.push({ + id: assignUniqueGroupId(baseGroupId, usedGroupIds), + terminalIds: groupTerminalIds, + }); + } + + for (const terminalId of terminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + normalizedGroups.push({ + id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), + terminalIds: [terminalId], + }); + } + + if (normalizedGroups.length > 0) { + return normalizedGroups; + } + + return [{ id: fallbackGroupId(DEFAULT_TERMINAL_ID), terminalIds: [DEFAULT_TERMINAL_ID] }]; +} + +export function normalizeThread(thread: StateThread): StateThread { + const terminalIds = normalizeTerminalIds(thread.terminalIds); + const runningTerminalIds = normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds); + const activeTerminalId = terminalIds.includes(thread.activeTerminalId) + ? thread.activeTerminalId + : (terminalIds[0] ?? DEFAULT_TERMINAL_ID); + const terminalGroups = normalizeTerminalGroups(thread.terminalGroups, terminalIds); + const activeGroupId = + terminalGroups.find((group) => group.id === thread.activeTerminalGroupId)?.id ?? + terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? + terminalGroups[0]?.id ?? + fallbackGroupId(activeTerminalId); + + return { + ...thread, + terminalIds, + runningTerminalIds, + activeTerminalId, + terminalGroups, + activeTerminalGroupId: activeGroupId, + }; +} diff --git a/apps/server/src/persistence/domain/turnSummaries.test.ts b/apps/server/src/persistence/domain/turnSummaries.test.ts new file mode 100644 index 000000000000..a115913cb645 --- /dev/null +++ b/apps/server/src/persistence/domain/turnSummaries.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; + +import { mergeTurnSummaryFiles, summarizeUnifiedDiff } from "./turnSummaries"; + +describe("turnSummaries domain helpers", () => { + test("summarizeUnifiedDiff returns per-file diff stats", () => { + const diff = [ + "diff --git a/src/example.ts b/src/example.ts", + "index 1111111..2222222 100644", + "--- a/src/example.ts", + "+++ b/src/example.ts", + "@@ -1,2 +1,3 @@", + " line1", + "-line2", + "+line2-updated", + "+line3", + "", + ].join("\n"); + + expect(summarizeUnifiedDiff(diff)).toEqual([ + { + path: "src/example.ts", + kind: "change", + additions: 2, + deletions: 1, + }, + ]); + }); + + test("mergeTurnSummaryFiles merges by path while preserving prior fields", () => { + const existing = [ + { path: "a.ts", kind: "modified" as const, additions: 1, deletions: 2 }, + { path: "b.ts", kind: "deleted" as const, additions: 0, deletions: 4 }, + ]; + const incoming = [ + { path: "a.ts", additions: 3, deletions: 5 }, + { path: "c.ts", kind: "added" as const, additions: 7, deletions: 0 }, + ]; + + expect(mergeTurnSummaryFiles(existing, incoming)).toEqual([ + { path: "a.ts", kind: "modified", additions: 3, deletions: 5 }, + { path: "b.ts", kind: "deleted", additions: 0, deletions: 4 }, + { path: "c.ts", kind: "added", additions: 7, deletions: 0 }, + ]); + }); +}); diff --git a/apps/server/src/persistence/domain/turnSummaries.ts b/apps/server/src/persistence/domain/turnSummaries.ts new file mode 100644 index 000000000000..ea6a6a052d5a --- /dev/null +++ b/apps/server/src/persistence/domain/turnSummaries.ts @@ -0,0 +1,115 @@ +import { parsePatchFiles } from "@pierre/diffs"; +import type { StateTurnDiffFileChange } from "@t3tools/contracts"; + +function parsePathFromDiff(diff: string): string | null { + const normalized = diff.replace(/\r\n/g, "\n"); + const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); + if (bPath?.[1]) return bPath[1]; + const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); + if (gitHeader?.[1]) return gitHeader[1]; + const direct = normalized.match(/^\+\+\+ (.+)$/m); + if (!direct?.[1] || direct[1] === "/dev/null") { + return null; + } + return direct[1]; +} + +function splitUnifiedDiffByFile(diff: string): Map { + const normalized = diff.replace(/\r\n/g, "\n"); + const byPath = new Map(); + const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; + + if (headerMatches.length === 0) { + const pathFromDiff = parsePathFromDiff(normalized); + if (pathFromDiff) { + byPath.set(pathFromDiff, normalized.trim()); + } + return byPath; + } + + for (let index = 0; index < headerMatches.length; index += 1) { + const match = headerMatches[index]; + if (!match) continue; + const start = match.index ?? 0; + const end = headerMatches[index + 1]?.index ?? normalized.length; + const segment = normalized.slice(start, end).trim(); + const pathFromDiff = parsePathFromDiff(segment); + if (!pathFromDiff || segment.length === 0) continue; + byPath.set(pathFromDiff, segment); + } + + return byPath; +} + +function countDiffStat(patch: string): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + for (const line of patch.replace(/\r\n/g, "\n").split("\n")) { + if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; + if (line.startsWith("+")) { + additions += 1; + continue; + } + if (line.startsWith("-")) { + deletions += 1; + } + } + return { additions, deletions }; +} + +export function summarizeUnifiedDiff(diff: string): StateTurnDiffFileChange[] { + try { + const parsedPatches = parsePatchFiles(diff, "state-turn-summary", false); + const files: StateTurnDiffFileChange[] = []; + for (const patch of parsedPatches) { + for (const file of patch.files) { + const additions = file.hunks.reduce((sum, hunk) => sum + hunk.additionLines, 0); + const deletions = file.hunks.reduce((sum, hunk) => sum + hunk.deletionLines, 0); + files.push({ + path: file.name, + kind: file.type, + additions, + deletions, + }); + } + } + if (files.length > 0) { + return files.toSorted((a, b) => a.path.localeCompare(b.path)); + } + } catch { + // Fallback below. + } + + const fileDiffsByPath = splitUnifiedDiffByFile(diff); + const fallback: StateTurnDiffFileChange[] = []; + for (const [filePath, fileDiff] of fileDiffsByPath) { + const stat = countDiffStat(fileDiff); + fallback.push({ + path: filePath, + additions: stat.additions, + deletions: stat.deletions, + }); + } + return fallback.toSorted((a, b) => a.path.localeCompare(b.path)); +} + +export function mergeTurnSummaryFiles( + existing: StateTurnDiffFileChange[], + incoming: StateTurnDiffFileChange[], +): StateTurnDiffFileChange[] { + const byPath = new Map(existing.map((file) => [file.path, { ...file }] as const)); + for (const file of incoming) { + const previous = byPath.get(file.path); + if (!previous) { + byPath.set(file.path, { ...file }); + continue; + } + byPath.set(file.path, { + ...previous, + ...(file.kind !== undefined ? { kind: file.kind } : {}), + ...(file.additions !== undefined ? { additions: file.additions } : {}), + ...(file.deletions !== undefined ? { deletions: file.deletions } : {}), + }); + } + return Array.from(byPath.values()).toSorted((a, b) => a.path.localeCompare(b.path)); +} diff --git a/apps/server/src/persistence/errors.ts b/apps/server/src/persistence/errors.ts new file mode 100644 index 000000000000..c6b06cebfe12 --- /dev/null +++ b/apps/server/src/persistence/errors.ts @@ -0,0 +1,6 @@ +export class PersistenceInitializationError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "PersistenceInitializationError"; + } +} diff --git a/apps/server/src/persistence/migrations/0001_initial.sql b/apps/server/src/persistence/migrations/0001_initial.sql new file mode 100644 index 000000000000..e8c66a1a3a8c --- /dev/null +++ b/apps/server/src/persistence/migrations/0001_initial.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + project_id TEXT NULL, + thread_id TEXT NULL, + sort_key INTEGER NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + data_json TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS provider_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + provider TEXT NOT NULL, + kind TEXT NOT NULL, + method TEXT NOT NULL, + thread_id TEXT NULL, + turn_id TEXT NULL, + item_id TEXT NULL, + request_id TEXT NULL, + request_kind TEXT NULL, + text_delta TEXT NULL, + message TEXT NULL, + payload_json TEXT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS state_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_documents_kind ON documents(kind); +CREATE INDEX IF NOT EXISTS idx_documents_project_kind ON documents(project_id, kind); +CREATE INDEX IF NOT EXISTS idx_documents_thread_kind_sort ON documents(thread_id, kind, sort_key); +CREATE INDEX IF NOT EXISTS idx_documents_kind_updated ON documents(kind, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_provider_events_session_seq ON provider_events(session_id, seq); +CREATE INDEX IF NOT EXISTS idx_provider_events_thread_seq ON provider_events(thread_id, seq); +CREATE INDEX IF NOT EXISTS idx_state_events_seq ON state_events(seq); diff --git a/apps/server/src/persistence/migrations/index.ts b/apps/server/src/persistence/migrations/index.ts new file mode 100644 index 000000000000..8b6c3edec295 --- /dev/null +++ b/apps/server/src/persistence/migrations/index.ts @@ -0,0 +1,53 @@ +export const MIGRATION_V1_SQL = ` +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + project_id TEXT NULL, + thread_id TEXT NULL, + sort_key INTEGER NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + data_json TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS provider_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + provider TEXT NOT NULL, + kind TEXT NOT NULL, + method TEXT NOT NULL, + thread_id TEXT NULL, + turn_id TEXT NULL, + item_id TEXT NULL, + request_id TEXT NULL, + request_kind TEXT NULL, + text_delta TEXT NULL, + message TEXT NULL, + payload_json TEXT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS state_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_documents_kind ON documents(kind); +CREATE INDEX IF NOT EXISTS idx_documents_project_kind ON documents(project_id, kind); +CREATE INDEX IF NOT EXISTS idx_documents_thread_kind_sort ON documents(thread_id, kind, sort_key); +CREATE INDEX IF NOT EXISTS idx_documents_kind_updated ON documents(kind, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_provider_events_session_seq ON provider_events(session_id, seq); +CREATE INDEX IF NOT EXISTS idx_provider_events_thread_seq ON provider_events(thread_id, seq); +CREATE INDEX IF NOT EXISTS idx_state_events_seq ON state_events(seq); +`; diff --git a/apps/server/src/persistence/migrator.test.ts b/apps/server/src/persistence/migrator.test.ts new file mode 100644 index 000000000000..70825b2e6894 --- /dev/null +++ b/apps/server/src/persistence/migrator.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; + +import { PersistenceInitializationError } from "./errors"; +import { applyStateDbPragmas, runPersistenceMigrations } from "./migrator"; +import type { SqliteDatabase, SqliteStatement } from "./sqliteAdapter"; + +class MockStatement implements SqliteStatement { + run(): unknown { + return undefined; + } + get(): unknown { + return undefined; + } + all(): unknown[] { + return []; + } +} + +class MockSqliteDatabase implements SqliteDatabase { + readonly statements: string[] = []; + + exec(sql: string): void { + this.statements.push(sql); + } + + prepare(): SqliteStatement { + return new MockStatement(); + } + + close(): void { + // no-op for tests + } +} + +describe("persistence migrator", () => { + test("applies sqlite pragmas in order", () => { + const db = new MockSqliteDatabase(); + applyStateDbPragmas(db); + expect(db.statements).toEqual([ + "PRAGMA journal_mode=WAL;", + "PRAGMA synchronous=FULL;", + "PRAGMA busy_timeout=5000;", + "PRAGMA foreign_keys=ON;", + ]); + }); + + test("throws when database adapter is not Effect-backed", () => { + const db = new MockSqliteDatabase(); + expect(() => runPersistenceMigrations(db)).toThrow(PersistenceInitializationError); + expect(db.statements).toEqual([ + "PRAGMA journal_mode=WAL;", + "PRAGMA synchronous=FULL;", + "PRAGMA busy_timeout=5000;", + "PRAGMA foreign_keys=ON;", + ]); + }); +}); diff --git a/apps/server/src/persistence/migrator.ts b/apps/server/src/persistence/migrator.ts new file mode 100644 index 000000000000..fa36f6391eeb --- /dev/null +++ b/apps/server/src/persistence/migrator.ts @@ -0,0 +1,96 @@ +import * as Effect from "effect/Effect"; +import * as Migrator from "effect/unstable/sql/Migrator"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { PersistenceInitializationError } from "./errors"; +import { MIGRATION_V1_SQL } from "./migrations"; +import type { EffectSqliteDatabaseAdapter, SqliteDatabase } from "./sqliteAdapter"; + +export const STATE_DB_SCHEMA_VERSION = 1; + +export function applyStateDbPragmas(db: SqliteDatabase): void { + db.exec("PRAGMA journal_mode=WAL;"); + db.exec("PRAGMA synchronous=FULL;"); + db.exec("PRAGMA busy_timeout=5000;"); + db.exec("PRAGMA foreign_keys=ON;"); +} + +function normalizeStatementBatch(sql: string): string[] { + const normalized = sql.replace(/\r\n/g, "\n"); + const statements: string[] = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + + for (const char of normalized) { + if (char === "'" && !inDoubleQuote && !inBacktick) { + inSingleQuote = !inSingleQuote; + current += char; + continue; + } + if (char === '"' && !inSingleQuote && !inBacktick) { + inDoubleQuote = !inDoubleQuote; + current += char; + continue; + } + if (char === "`" && !inSingleQuote && !inDoubleQuote) { + inBacktick = !inBacktick; + current += char; + continue; + } + if (char === ";" && !inSingleQuote && !inDoubleQuote && !inBacktick) { + const statement = current.trim(); + if (statement.length > 0) { + statements.push(statement); + } + current = ""; + continue; + } + current += char; + } + + const trailing = current.trim(); + if (trailing.length > 0) { + statements.push(trailing); + } + return statements; +} + +function isEffectSqliteDatabase(db: SqliteDatabase): db is EffectSqliteDatabaseAdapter { + return "runWithSqlClient" in db && typeof db.runWithSqlClient === "function"; +} + +function runEffectMigrations(db: EffectSqliteDatabaseAdapter): void { + const migrationV1Effect = Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + for (const statement of normalizeStatementBatch(MIGRATION_V1_SQL)) { + yield* sql.unsafe(statement).raw; + } + yield* sql.unsafe(`PRAGMA user_version=${STATE_DB_SCHEMA_VERSION};`).raw; + }); + + const migrationLoader = Migrator.fromRecord({ + "0001_initial_schema": migrationV1Effect, + }); + const runMigrations = Migrator.make({}); + + db.runWithSqlClient( + runMigrations({ loader: migrationLoader }).pipe( + Effect.mapError((error) => { + const message = + error instanceof Error + ? error.message + : "Failed to run Effect SQL migrations for state database."; + return new Error(message, { cause: error }); + }), + ), + ); +} + +export function runPersistenceMigrations(db: SqliteDatabase): void { + applyStateDbPragmas(db); + if (!isEffectSqliteDatabase(db)) { + throw new PersistenceInitializationError("Expected Effect-backed sqlite adapter for migrations"); + } + runEffectMigrations(db); +} diff --git a/apps/server/src/persistence/repos/documentsRepo.ts b/apps/server/src/persistence/repos/documentsRepo.ts new file mode 100644 index 000000000000..c64c7fe6cf42 --- /dev/null +++ b/apps/server/src/persistence/repos/documentsRepo.ts @@ -0,0 +1,272 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + DataJsonRowSchema, + DocumentRowSchema, + TotalCountRowSchema, +} from "../schema"; + +export interface DocumentRow { + id: string; + kind: string; + project_id: string | null; + thread_id: string | null; + sort_key: number | null; + created_at: string; + updated_at: string; + data_json: string; +} + +interface DataJsonRow { + data_json: string; +} + +export interface PaginatedMessagePayloadsRow { + data_json: string; +} + +export interface TotalCountRow { + total?: number | bigint; +} + +function toSafeInteger(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === "bigint") { + return Number(value); + } + return fallback; +} + +const decodeDocumentRows = Schema.decodeUnknownSync(Schema.Array(DocumentRowSchema)); +const decodeDataJsonRows = Schema.decodeUnknownSync(Schema.Array(DataJsonRowSchema)); +const decodeTotalCountRows = Schema.decodeUnknownSync(Schema.Array(TotalCountRowSchema)); + +export const getDocumentRowById = ( + id: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + "SELECT id, kind, project_id, thread_id, sort_key, created_at, updated_at, data_json FROM documents WHERE id = ? LIMIT 1;", + [id], + ) + .unprepared) as DocumentRow[]; + const parsedRows = decodeDocumentRows(rows); + return parsedRows[0] ?? null; + }); + +export const upsertDocument = (input: { + id: string; + kind: "project" | "thread" | "message" | "turn_summary"; + projectId: string | null; + threadId: string | null; + sortKey: number | null; + createdAt: string; + updatedAt: string; + dataJson: string; +}): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + yield* sql + .unsafe( + `INSERT INTO documents ( + id, + kind, + project_id, + thread_id, + sort_key, + schema_version, + created_at, + updated_at, + data_json + ) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + kind = excluded.kind, + project_id = excluded.project_id, + thread_id = excluded.thread_id, + sort_key = excluded.sort_key, + schema_version = excluded.schema_version, + updated_at = excluded.updated_at, + data_json = excluded.data_json;`, + [ + input.id, + input.kind, + input.projectId, + input.threadId, + input.sortKey, + input.createdAt, + input.updatedAt, + input.dataJson, + ], + ) + .raw; + }).pipe(Effect.asVoid); + +export const readNextSortKey = ( + kind: "message" | "turn_summary", + threadId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe<{ next_sort_key?: number }>( + "SELECT COALESCE(MAX(sort_key), 0) + 1 AS next_sort_key FROM documents WHERE kind = ? AND thread_id = ?;", + [kind, threadId], + ) + .unprepared) as Array<{ next_sort_key?: number }>; + return rows[0]?.next_sort_key ?? 1; + }); + +export const listProjectPayloads = (): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + "SELECT data_json FROM documents WHERE kind = 'project' ORDER BY updated_at DESC, created_at DESC;", + ) + .unprepared) as DataJsonRow[]; + return decodeDataJsonRows(rows).map((row) => row.data_json); + }); + +export const listThreadPayloads = (): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + "SELECT data_json FROM documents WHERE kind = 'thread' ORDER BY updated_at DESC, created_at DESC;", + ) + .unprepared) as DataJsonRow[]; + return decodeDataJsonRows(rows).map((row) => row.data_json); + }); + +export const listThreadPayloadsByProject = ( + projectId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe("SELECT data_json FROM documents WHERE kind = 'thread' AND project_id = ?;", [ + projectId, + ]) + .unprepared) as DataJsonRow[]; + return decodeDataJsonRows(rows).map((row) => row.data_json); + }); + +export const findThreadPayloadByRuntimeThreadId = ( + runtimeThreadId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + "SELECT data_json FROM documents WHERE kind = 'thread' AND json_extract(data_json, '$.codexThreadId') = ? LIMIT 1;", + [runtimeThreadId], + ) + .unprepared) as DataJsonRow[]; + const parsedRows = decodeDataJsonRows(rows); + return parsedRows[0]?.data_json ?? null; + }); + +export const deleteDocumentsByProjectId = ( + projectId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe("DELETE FROM documents WHERE project_id = ?;", [projectId]).raw; + }).pipe(Effect.asVoid); + +export const deleteDocumentsByThreadId = ( + threadId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe("DELETE FROM documents WHERE thread_id = ?;", [threadId]).raw; + }).pipe(Effect.asVoid); + +export const deleteDocumentByIdAndKind = ( + id: string, + kind: "thread" | "message" | "turn_summary", +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe("DELETE FROM documents WHERE id = ? AND kind = ?;", [id, kind]).raw; + }).pipe(Effect.asVoid); + +export const listMessagePayloadsForThread = ( + threadId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe<{ data_json: string }>( + "SELECT data_json FROM documents WHERE kind = 'message' AND thread_id = ? ORDER BY sort_key ASC;", + [threadId], + ) + .unprepared) as Array<{ data_json: string }>; + return decodeDataJsonRows(rows).map((row) => row.data_json); + }); + +export const listPaginatedMessagePayloadsForThread = (input: { + threadId: string; + limit: number; + offset: number; +}): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + "SELECT data_json FROM documents WHERE kind = 'message' AND thread_id = ? ORDER BY sort_key ASC LIMIT ? OFFSET ?;", + [input.threadId, input.limit, input.offset], + ) + .unprepared) as PaginatedMessagePayloadsRow[]; + return decodeDataJsonRows(rows).map((row) => row.data_json); + }); + +export const countMessagesForThread = ( + threadId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe("SELECT COUNT(1) AS total FROM documents WHERE kind = 'message' AND thread_id = ?;", [ + threadId, + ]) + .unprepared) as TotalCountRow[]; + const parsedRows = decodeTotalCountRows(rows); + return toSafeInteger(parsedRows[0]?.total, 0); + }); + +export const listMessagePayloadsForThreadDesc = ( + threadId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe<{ data_json: string }>( + `SELECT data_json + FROM documents + WHERE kind = 'message' AND thread_id = ? + ORDER BY sort_key DESC, updated_at DESC;`, + [threadId], + ) + .unprepared) as Array<{ data_json: string }>; + return decodeDataJsonRows(rows).map((row) => row.data_json); + }); + +export const listTurnSummaryPayloadsForThread = ( + threadId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe<{ data_json: string }>( + "SELECT data_json FROM documents WHERE kind = 'turn_summary' AND thread_id = ? ORDER BY sort_key DESC, updated_at DESC;", + [threadId], + ) + .unprepared) as Array<{ data_json: string }>; + return decodeDataJsonRows(rows).map((row) => row.data_json); + }); diff --git a/apps/server/src/persistence/repos/metadataRepo.ts b/apps/server/src/persistence/repos/metadataRepo.ts new file mode 100644 index 000000000000..de5b99bafaa3 --- /dev/null +++ b/apps/server/src/persistence/repos/metadataRepo.ts @@ -0,0 +1,43 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { MetadataRowSchema } from "../schema"; + +function tryParseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +const decodeMetadataRows = Schema.decodeUnknownSync(Schema.Array(MetadataRowSchema)); + +export const readMetadataValue = ( + key: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe<{ value_json: string }>("SELECT value_json FROM metadata WHERE key = ? LIMIT 1;", [key]) + .unprepared) as Array<{ value_json: string }>; + const row = decodeMetadataRows(rows)[0]; + if (!row) { + return null; + } + return tryParseJson(row.value_json); + }); + +export const writeMetadataValue = ( + key: string, + value: unknown, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + yield* sql + .unsafe( + "INSERT INTO metadata (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json;", + [key, JSON.stringify(value)], + ) + .raw; + }).pipe(Effect.asVoid); diff --git a/apps/server/src/persistence/repos/providerEventsRepo.ts b/apps/server/src/persistence/repos/providerEventsRepo.ts new file mode 100644 index 000000000000..48c6393e8352 --- /dev/null +++ b/apps/server/src/persistence/repos/providerEventsRepo.ts @@ -0,0 +1,147 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + CompletedProviderItemRowSchema, + ProviderEventInsertStatsSchema, +} from "../schema"; + +function toSafeInteger(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === "bigint") { + return Number(value); + } + return fallback; +} + +const decodeProviderEventInsertStats = Schema.decodeUnknownSync(ProviderEventInsertStatsSchema); +const decodeCompletedProviderItemRows = Schema.decodeUnknownSync( + Schema.Array(CompletedProviderItemRowSchema), +); + +export interface CompletedProviderItemRow { + item_id: string | null; + payload_json: string | null; +} + +export const insertProviderEvent = (input: { + id: string; + sessionId: string; + provider: string; + kind: string; + method: string; + runtimeThreadId: string | null; + turnId: string | null; + itemId: string | null; + requestId: string | null; + requestKind: string | null; + textDelta: string | null; + message: string | null; + payloadJson: string | null; + createdAt: string; +}): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rawResult = (yield* sql + .unsafe<{ changes?: number | bigint }>( + `INSERT OR IGNORE INTO provider_events ( + id, + session_id, + provider, + kind, + method, + thread_id, + turn_id, + item_id, + request_id, + request_kind, + text_delta, + message, + payload_json, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, + [ + input.id, + input.sessionId, + input.provider, + input.kind, + input.method, + input.runtimeThreadId, + input.turnId, + input.itemId, + input.requestId, + input.requestKind, + input.textDelta, + input.message, + input.payloadJson, + input.createdAt, + ], + ) + .raw) as { changes?: number | bigint }; + const stats = decodeProviderEventInsertStats(rawResult); + return toSafeInteger(stats.changes, 0); + }); + +export const listCompletedItemEventsBySessionTurn = (input: { + sessionId: string; + turnId: string; +}): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + `SELECT item_id, payload_json + FROM provider_events + WHERE session_id = ? AND turn_id = ? AND method = 'item/completed' + ORDER BY created_at DESC;`, + [input.sessionId, input.turnId], + ) + .unprepared) as CompletedProviderItemRow[]; + return decodeCompletedProviderItemRows(rows).map((row) => ({ + item_id: row.item_id, + payload_json: row.payload_json, + })); + }); + +export const listCompletedItemEventsByThreadTurn = (input: { + runtimeThreadId: string; + turnId: string; +}): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + `SELECT item_id, payload_json + FROM provider_events + WHERE thread_id = ? AND turn_id = ? AND method = 'item/completed' + ORDER BY created_at DESC;`, + [input.runtimeThreadId, input.turnId], + ) + .unprepared) as CompletedProviderItemRow[]; + return decodeCompletedProviderItemRows(rows).map((row) => ({ + item_id: row.item_id, + payload_json: row.payload_json, + })); + }); + +export const listCompletedItemEventsByTurn = ( + turnId: string, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + `SELECT item_id, payload_json + FROM provider_events + WHERE turn_id = ? AND method = 'item/completed' + ORDER BY created_at DESC;`, + [turnId], + ) + .unprepared) as CompletedProviderItemRow[]; + return decodeCompletedProviderItemRows(rows).map((row) => ({ + item_id: row.item_id, + payload_json: row.payload_json, + })); + }); diff --git a/apps/server/src/persistence/repos/stateEventsRepo.test.ts b/apps/server/src/persistence/repos/stateEventsRepo.test.ts new file mode 100644 index 000000000000..11670912ef74 --- /dev/null +++ b/apps/server/src/persistence/repos/stateEventsRepo.test.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { runPersistenceMigrations } from "../migrator"; +import { runWithSqlClient } from "../runtime"; +import { openPersistenceSqliteDatabase } from "../sqliteLayer"; +import { appendStateEvent, listStateEventsAfterSeq, readLastStateSeq } from "./stateEventsRepo"; + +describe("stateEventsRepo", () => { + test("appends and lists ordered state events", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-persistence-state-events-repo-")); + const dbPath = path.join(dir, "state.sqlite"); + try { + const db = openPersistenceSqliteDatabase(dbPath); + try { + runPersistenceMigrations(db); + const createdAt = new Date().toISOString(); + const first = runWithSqlClient( + db, + appendStateEvent({ + eventType: "project.upsert", + entityId: "project-1", + payload: { + project: { + id: "project-1", + name: "Project One", + cwd: "/tmp/project-one", + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }, + createdAt, + }), + ); + const second = runWithSqlClient( + db, + appendStateEvent({ + eventType: "project.delete", + entityId: "project-1", + payload: { projectId: "project-1" }, + createdAt, + }), + ); + + const lastSeq = runWithSqlClient(db, readLastStateSeq); + const events = runWithSqlClient(db, listStateEventsAfterSeq(first.seq - 1)); + + expect(first.seq).toBeGreaterThan(0); + expect(second.seq).toBeGreaterThan(first.seq); + expect(lastSeq).toBe(second.seq); + expect(events.map((event) => event.eventType)).toEqual(["project.upsert", "project.delete"]); + } finally { + db.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/persistence/repos/stateEventsRepo.ts b/apps/server/src/persistence/repos/stateEventsRepo.ts new file mode 100644 index 000000000000..8e8b207306ae --- /dev/null +++ b/apps/server/src/persistence/repos/stateEventsRepo.ts @@ -0,0 +1,102 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { stateEventSchema, type StateEvent } from "@t3tools/contracts"; +import { + StateEventRowSchema, + StateSeqRowSchema, + StateEventInsertStatsSchema, +} from "../schema"; + +interface StateEventRow { + seq: number; + event_type: string; + entity_id: string; + payload_json: string; + created_at: string; +} + +function toSafeInteger(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === "bigint") { + return Number(value); + } + return fallback; +} + +function tryParseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +const decodeStateEventRows = Schema.decodeUnknownSync(Schema.Array(StateEventRowSchema)); +const decodeStateSeqRows = Schema.decodeUnknownSync(Schema.Array(StateSeqRowSchema)); +const decodeStateEventInsertStats = Schema.decodeUnknownSync(StateEventInsertStatsSchema); + +export const appendStateEvent = (input: { + eventType: string; + entityId: string; + payload: unknown; + createdAt: string; +}): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const result = (yield* sql + .unsafe<{ changes?: number | bigint; lastInsertRowid?: number | bigint }>( + "INSERT INTO state_events (event_type, entity_id, payload_json, created_at) VALUES (?, ?, ?, ?);", + [input.eventType, input.entityId, JSON.stringify(input.payload), input.createdAt], + ) + .raw) as { + changes?: number | bigint; + lastInsertRowid?: number | bigint; + }; + const stats = decodeStateEventInsertStats(result); + + return stateEventSchema.parse({ + seq: toSafeInteger(stats.lastInsertRowid, 0), + eventType: input.eventType, + entityId: input.entityId, + payload: input.payload, + createdAt: input.createdAt, + }); + }); + +export const readLastStateSeq: Effect.Effect = Effect.gen( + function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe<{ seq?: number | bigint }>( + "SELECT COALESCE(MAX(seq), 0) AS seq FROM state_events;", + ) + .unprepared) as Array<{ seq?: number | bigint }>; + const parsedRows = decodeStateSeqRows(rows); + return toSafeInteger(parsedRows[0]?.seq, 0); + }, +); + +export const listStateEventsAfterSeq = ( + afterSeq: number, +): Effect.Effect => + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient; + const rows = (yield* sql + .unsafe( + "SELECT seq, event_type, entity_id, payload_json, created_at FROM state_events WHERE seq > ? ORDER BY seq ASC;", + [afterSeq], + ) + .unprepared) as StateEventRow[]; + return decodeStateEventRows(rows).map((row) => + stateEventSchema.parse({ + seq: row.seq, + eventType: row.event_type, + entityId: row.entity_id, + payload: tryParseJson(row.payload_json), + createdAt: row.created_at, + }), + ); + }); diff --git a/apps/server/src/persistence/runtime.ts b/apps/server/src/persistence/runtime.ts new file mode 100644 index 000000000000..3cbd6744bbda --- /dev/null +++ b/apps/server/src/persistence/runtime.ts @@ -0,0 +1,19 @@ +import type * as Effect from "effect/Effect"; +import type * as SqlClient from "effect/unstable/sql/SqlClient"; + +import type { EffectSqliteDatabaseAdapter, SqliteDatabase } from "./sqliteAdapter"; +import { PersistenceInitializationError } from "./errors"; + +function isEffectSqliteDatabase(db: SqliteDatabase): db is EffectSqliteDatabaseAdapter { + return "runWithSqlClient" in db && typeof db.runWithSqlClient === "function"; +} + +export function runWithSqlClient( + db: SqliteDatabase, + effect: Effect.Effect, +): A { + if (isEffectSqliteDatabase(db)) { + return db.runWithSqlClient(effect); + } + throw new PersistenceInitializationError("Expected Effect-backed sqlite adapter"); +} diff --git a/apps/server/src/persistence/schema.ts b/apps/server/src/persistence/schema.ts new file mode 100644 index 000000000000..dc90905f669d --- /dev/null +++ b/apps/server/src/persistence/schema.ts @@ -0,0 +1,52 @@ +import * as Schema from "effect/Schema"; + +export const DocumentRowSchema = Schema.Struct({ + id: Schema.String, + kind: Schema.String, + project_id: Schema.NullOr(Schema.String), + thread_id: Schema.NullOr(Schema.String), + sort_key: Schema.NullOr(Schema.Number), + created_at: Schema.String, + updated_at: Schema.String, + data_json: Schema.String, +}); + +export const DataJsonRowSchema = Schema.Struct({ + data_json: Schema.String, +}); + +const NumberOrBigIntSchema = Schema.Union([Schema.Number, Schema.BigInt]); + +export const ProviderEventInsertStatsSchema = Schema.Struct({ + changes: Schema.optional(Schema.NullOr(NumberOrBigIntSchema)), +}); + +export const CompletedProviderItemRowSchema = Schema.Struct({ + item_id: Schema.NullOr(Schema.String), + payload_json: Schema.NullOr(Schema.String), +}); + +export const StateEventInsertStatsSchema = Schema.Struct({ + changes: Schema.optional(Schema.NullOr(NumberOrBigIntSchema)), + lastInsertRowid: Schema.optional(Schema.NullOr(NumberOrBigIntSchema)), +}); + +export const StateEventRowSchema = Schema.Struct({ + seq: Schema.Number, + event_type: Schema.String, + entity_id: Schema.String, + payload_json: Schema.String, + created_at: Schema.String, +}); + +export const StateSeqRowSchema = Schema.Struct({ + seq: Schema.optional(Schema.NullOr(NumberOrBigIntSchema)), +}); + +export const TotalCountRowSchema = Schema.Struct({ + total: Schema.optional(Schema.NullOr(NumberOrBigIntSchema)), +}); + +export const MetadataRowSchema = Schema.Struct({ + value_json: Schema.String, +}); diff --git a/apps/server/src/persistence/sqliteAdapter.ts b/apps/server/src/persistence/sqliteAdapter.ts new file mode 100644 index 000000000000..f549fd7d5a9d --- /dev/null +++ b/apps/server/src/persistence/sqliteAdapter.ts @@ -0,0 +1,229 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ServiceMap from "effect/ServiceMap"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export interface SqliteStatement { + run: (...params: unknown[]) => unknown; + get: (...params: unknown[]) => unknown; + all: (...params: unknown[]) => unknown[]; +} + +export interface SqliteDatabase { + exec: (sql: string) => void; + prepare: (sql: string) => SqliteStatement; + close: () => void; +} + +export interface EffectSqliteDatabaseAdapter extends SqliteDatabase { + runWithSqlClient: (effect: Effect.Effect) => A; +} + +interface SqliteDriverModule { + SqliteClient?: { + layer: (config: { filename: string }) => Layer.Layer; + }; +} + +function isNodeSqliteUnavailableError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + if (code === "ERR_UNKNOWN_BUILTIN_MODULE" || code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") { + return true; + } + return ( + error.message.includes("node:sqlite") || + error.message.includes("@effect/sql-sqlite-node") || + error.message.includes("Only URLs with a scheme in: file, data, and node") + ); +} + +function normalizeStatementBatch(sql: string): string[] { + const normalized = sql.replace(/\r\n/g, "\n"); + const statements: string[] = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + + for (const char of normalized) { + if (char === "'" && !inDoubleQuote && !inBacktick) { + inSingleQuote = !inSingleQuote; + current += char; + continue; + } + if (char === '"' && !inSingleQuote && !inBacktick) { + inDoubleQuote = !inDoubleQuote; + current += char; + continue; + } + if (char === "`" && !inSingleQuote && !inDoubleQuote) { + inBacktick = !inBacktick; + current += char; + continue; + } + + if (char === ";" && !inSingleQuote && !inDoubleQuote && !inBacktick) { + const statement = current.trim(); + if (statement.length > 0) { + statements.push(statement); + } + current = ""; + continue; + } + + current += char; + } + + const trailing = current.trim(); + if (trailing.length > 0) { + statements.push(trailing); + } + + return statements; +} + +function toSafeInteger(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === "bigint") { + return Number(value); + } + return fallback; +} + +class EffectSqliteDatabase implements SqliteDatabase { + private readonly scope: Scope.Closeable; + private readonly services: ServiceMap.ServiceMap; + private readonly sql: SqlClient.SqlClient; + private closed = false; + + constructor(layer: Layer.Layer) { + this.scope = Effect.runSync(Scope.make()); + try { + this.services = Effect.runSync(Layer.buildWithScope(layer, this.scope)); + this.sql = Effect.runSync( + Effect.provideServices(Effect.service(SqlClient.SqlClient), this.services), + ); + } catch (error) { + try { + Effect.runSync(Scope.close(this.scope, Exit.void)); + } catch { + // Best effort cleanup on failed adapter construction. + } + throw error; + } + } + + exec(sql: string): void { + for (const statement of normalizeStatementBatch(sql)) { + this.runEffect(this.sql.unsafe(statement).raw); + } + } + + prepare(sql: string): SqliteStatement { + return { + run: (...params: unknown[]) => this.runStatement(sql, params), + get: (...params: unknown[]) => { + const rows = this.queryStatement(sql, params); + return rows[0]; + }, + all: (...params: unknown[]) => this.queryStatement(sql, params), + }; + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + Effect.runSync(Scope.close(this.scope, Exit.void)); + } + + runWithSqlClient(effect: Effect.Effect): A { + return this.runEffect(effect); + } + + private runStatement(sql: string, params: ReadonlyArray): unknown { + this.runEffect(this.sql.unsafe(sql, params).raw); + const stats = this.runEffect( + this.sql + .unsafe<{ changes?: number | bigint; lastInsertRowid?: number | bigint }>( + "SELECT changes() AS changes, last_insert_rowid() AS lastInsertRowid;", + ) + .unprepared, + )[0]; + return { + changes: toSafeInteger(stats?.changes, 0), + lastInsertRowid: toSafeInteger(stats?.lastInsertRowid, 0), + }; + } + + private queryStatement(sql: string, params: ReadonlyArray): unknown[] { + return this.runEffect(this.sql.unsafe(sql, params).unprepared) as unknown[]; + } + + private runEffect(effect: Effect.Effect): A { + const provided = Effect.provideServices(effect, this.services) as Effect.Effect; + return Effect.runSync(provided); + } +} + +function openDriverDatabase( + requireFn: ReturnType, + moduleId: string, + dbPath: string, +): SqliteDatabase { + const sqliteDriver = requireFn(moduleId) as SqliteDriverModule; + const layer = sqliteDriver.SqliteClient?.layer; + if (typeof layer !== "function") { + throw new Error(`${moduleId} was loaded but SqliteClient.layer is missing.`); + } + return new EffectSqliteDatabase(layer({ filename: dbPath })) satisfies EffectSqliteDatabaseAdapter; +} + +function openNodeSqliteDatabase( + requireFn: ReturnType, + dbPath: string, +): SqliteDatabase { + try { + return openDriverDatabase(requireFn, "@effect/sql-sqlite-node", dbPath); + } catch (error) { + if (isNodeSqliteUnavailableError(error)) { + throw new Error( + "@effect/sql-sqlite-node is unavailable in this runtime. Ensure dependencies are installed and use Node.js 22+ (or run the server with Bun).", + { cause: error }, + ); + } + throw error; + } +} + +function openBunSqliteDatabase( + requireFn: ReturnType, + dbPath: string, +): SqliteDatabase { + return openDriverDatabase(requireFn, "@effect/sql-sqlite-bun", dbPath); +} + +export function openSqliteDatabase( + dbPath: string, + requireFn: ReturnType = createRequire( + path.join(process.cwd(), "t3code-sqlite-adapter.cjs"), + ), + runtimeIsBun = Boolean(process.versions.bun), +): SqliteDatabase { + if (runtimeIsBun) { + // Development mode runs in Bun, so use Bun's SQLite adapter + return openBunSqliteDatabase(requireFn, dbPath); + } + + return openNodeSqliteDatabase(requireFn, dbPath); +} diff --git a/apps/server/src/persistence/sqliteLayer.test.ts b/apps/server/src/persistence/sqliteLayer.test.ts new file mode 100644 index 000000000000..35f6e9a946db --- /dev/null +++ b/apps/server/src/persistence/sqliteLayer.test.ts @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { runPersistenceMigrations } from "./migrator"; +import { openPersistenceSqliteDatabase } from "./sqliteLayer"; + +describe("persistence sqlite layer", () => { + test("opens sqlite database and runs migrations idempotently", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-persistence-sqlite-layer-")); + const dbPath = path.join(dir, "state.sqlite"); + try { + const db = openPersistenceSqliteDatabase(dbPath); + try { + runPersistenceMigrations(db); + runPersistenceMigrations(db); + + db.prepare( + "INSERT INTO metadata (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json;", + ).run("app.settings.v1", JSON.stringify({ theme: "dark" })); + + const row = db.prepare("SELECT value_json FROM metadata WHERE key = ? LIMIT 1;").get( + "app.settings.v1", + ) as { value_json?: string } | undefined; + expect(row?.value_json).toBe(JSON.stringify({ theme: "dark" })); + } finally { + db.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/persistence/sqliteLayer.ts b/apps/server/src/persistence/sqliteLayer.ts new file mode 100644 index 000000000000..fced185c38f2 --- /dev/null +++ b/apps/server/src/persistence/sqliteLayer.ts @@ -0,0 +1,10 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { openSqliteDatabase, type SqliteDatabase } from "./sqliteAdapter"; + +export function openPersistenceSqliteDatabase(dbPath: string): SqliteDatabase { + const resolvedPath = path.resolve(dbPath); + fs.mkdirSync(path.dirname(resolvedPath), { recursive: true }); + return openSqliteDatabase(resolvedPath); +} diff --git a/apps/server/src/persistenceService.ts b/apps/server/src/persistenceService.ts index 1a2e444b441f..c1eb7acc2f98 100644 --- a/apps/server/src/persistenceService.ts +++ b/apps/server/src/persistenceService.ts @@ -2,14 +2,15 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { parsePatchFiles } from "@pierre/diffs"; import { DEFAULT_MODEL, type AppSettings, type AppSettingsUpdateInput, - appSettingsSchema, - appSettingsUpdateInputSchema, type ProjectAddInput, type ProjectAddResult, type ProjectListResult, @@ -44,12 +45,8 @@ import { projectRemoveInputSchema, projectScriptsSchema, projectUpdateScriptsInputSchema, - stateBootstrapResultSchema, stateCatchUpInputSchema, - stateCatchUpResultSchema, - stateEventSchema, stateListMessagesInputSchema, - stateListMessagesResultSchema, stateMessageSchema, stateProjectSchema, stateThreadSchema, @@ -64,33 +61,77 @@ import { threadsUpdateResultSchema, } from "@t3tools/contracts"; -import { StateDb } from "./stateDb"; +import { + countMessagesForThread as countMessagesForThreadEffect, + deleteDocumentByIdAndKind as deleteDocumentByIdAndKindEffect, + deleteDocumentsByProjectId as deleteDocumentsByProjectIdEffect, + deleteDocumentsByThreadId as deleteDocumentsByThreadIdEffect, + findThreadPayloadByRuntimeThreadId as findThreadPayloadByRuntimeThreadIdEffect, + getDocumentRowById as getDocumentRowByIdEffect, + listPaginatedMessagePayloadsForThread as listPaginatedMessagePayloadsForThreadEffect, + listProjectPayloads as listProjectPayloadsEffect, + listMessagePayloadsForThread as listMessagePayloadsForThreadEffect, + listMessagePayloadsForThreadDesc as listMessagePayloadsForThreadDescEffect, + listThreadPayloads as listThreadPayloadsEffect, + listThreadPayloadsByProject as listThreadPayloadsByProjectEffect, + listTurnSummaryPayloadsForThread as listTurnSummaryPayloadsForThreadEffect, + readNextSortKey as readNextSortKeyEffect, + upsertDocument as upsertDocumentEffect, + type DocumentRow, +} from "./persistence/repos/documentsRepo"; +import { + insertProviderEvent as insertProviderEventEffect, + listCompletedItemEventsBySessionTurn as listCompletedItemEventsBySessionTurnEffect, + listCompletedItemEventsByThreadTurn as listCompletedItemEventsByThreadTurnEffect, + listCompletedItemEventsByTurn as listCompletedItemEventsByTurnEffect, + type CompletedProviderItemRow, +} from "./persistence/repos/providerEventsRepo"; +import { + appendStateEvent as appendStateEventEffect, + listStateEventsAfterSeq as listStateEventsAfterSeqEffect, + readLastStateSeq as readLastStateSeqEffect, +} from "./persistence/repos/stateEventsRepo"; +import { + readMetadataValue as readMetadataValueEffect, + writeMetadataValue as writeMetadataValueEffect, +} from "./persistence/repos/metadataRepo"; +import { + buildUpdatedAppSettings, + resolveAppSettings, +} from "./persistence/domain/appSettings"; +import { buildUserTurnMessage, messageDocId } from "./persistence/domain/messages"; +import { + inferProjectName, + isDirectory, + normalizeCwd, +} from "./persistence/domain/projects"; +import { + asObject, + asString, + normalizeProviderItemType, + parseAssistantItemId, + parseThreadIdFromEventPayload, + parseTurnIdFromEvent, +} from "./persistence/domain/providerProjection"; +import { + buildStateBootstrapResult, + buildStateCatchUpResult, + buildStateListMessagesResult, +} from "./persistence/domain/stateSync"; +import { fallbackGroupId, normalizeTerminalIds, normalizeThread } from "./persistence/domain/threads"; +import { mergeTurnSummaryFiles, summarizeUnifiedDiff } from "./persistence/domain/turnSummaries"; +import { resolvePersistenceConfig } from "./persistence/config"; +import { PersistenceInitializationError } from "./persistence/errors"; +import { runPersistenceMigrations } from "./persistence/migrator"; +import { runWithSqlClient } from "./persistence/runtime"; +import { openPersistenceSqliteDatabase } from "./persistence/sqliteLayer"; +import type { SqliteDatabase } from "./persistence/sqliteAdapter"; const METADATA_KEY_PROJECTS_JSON_IMPORTED = "migration.projects_json_imported"; const METADATA_KEY_APP_SETTINGS = "app.settings.v1"; -const MAX_TERMINAL_COUNT = 4; const DEFAULT_TERMINAL_ID = "default"; const DEFAULT_TERMINAL_HEIGHT = 280; -interface DocumentRow { - id: string; - kind: string; - project_id: string | null; - thread_id: string | null; - sort_key: number | null; - created_at: string; - updated_at: string; - data_json: string; -} - -interface StateEventRow { - seq: number; - event_type: string; - entity_id: string; - payload_json: string; - created_at: string; -} - interface ProviderEventInsertResult { inserted: boolean; runtimeThreadId: string | null; @@ -134,182 +175,6 @@ function toSafeInteger(value: unknown, fallback = 0): number { return fallback; } -function normalizeCwd(rawCwd: string): string { - const resolved = path.resolve(rawCwd.trim()); - const normalized = path.normalize(resolved); - if (process.platform === "win32") { - return normalized.toLowerCase(); - } - return normalized; -} - -function isDirectory(cwd: string): boolean { - try { - return fs.statSync(cwd).isDirectory(); - } catch { - return false; - } -} - -function inferProjectName(cwd: string): string { - const name = path.basename(cwd); - return name.length > 0 ? name : "project"; -} - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function normalizeProviderItemType(value: string | undefined): string | undefined { - if (!value) return undefined; - const normalized = value.trim(); - if (normalized.length === 0) return undefined; - return normalized.replace(/[_\-\s]+/g, "").toLowerCase(); -} - -function parseThreadIdFromEventPayload(payload: unknown): string | null { - const record = asObject(payload); - const threadId = asString(record?.threadId) ?? asString(record?.thread_id); - if (threadId) return threadId; - const thread = asObject(record?.thread); - return asString(thread?.id) ?? null; -} - -function parseTurnIdFromEvent(event: ProviderEvent): string | null { - if (event.turnId) return event.turnId; - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return asString(turn?.id) ?? null; -} - -function parseAssistantItemId(event: ProviderEvent): string | null { - const payload = asObject(event.payload); - const item = asObject(payload?.item); - const itemType = asString(item?.type); - if (itemType !== "agentMessage") return null; - return asString(item?.id) ?? event.itemId ?? null; -} - -function normalizeTerminalIds(ids: readonly string[]): string[] { - const normalized = [ - ...new Set(ids.map((id) => id.trim()).filter((id) => id.length > 0)), - ].slice(0, MAX_TERMINAL_COUNT); - if (normalized.length > 0) { - return normalized; - } - return [DEFAULT_TERMINAL_ID]; -} - -function normalizeRunningTerminalIds( - runningTerminalIds: readonly string[], - terminalIds: readonly string[], -): string[] { - if (runningTerminalIds.length === 0) { - return []; - } - - const validTerminalIds = new Set(terminalIds); - return [...new Set(runningTerminalIds)] - .map((id) => id.trim()) - .filter((id) => id.length > 0 && validTerminalIds.has(id)) - .slice(0, MAX_TERMINAL_COUNT); -} - -function fallbackGroupId(terminalId: string): string { - return `group-${terminalId}`; -} - -function assignUniqueGroupId(groupId: string, usedGroupIds: Set): string { - if (!usedGroupIds.has(groupId)) { - usedGroupIds.add(groupId); - return groupId; - } - - let suffix = 2; - while (usedGroupIds.has(`${groupId}-${suffix}`)) { - suffix += 1; - } - const uniqueGroupId = `${groupId}-${suffix}`; - usedGroupIds.add(uniqueGroupId); - return uniqueGroupId; -} - -function normalizeTerminalGroups( - groups: StateThread["terminalGroups"], - terminalIds: readonly string[], -): StateThread["terminalGroups"] { - const validTerminalIds = new Set(terminalIds); - const assignedTerminalIds = new Set(); - const usedGroupIds = new Set(); - const normalizedGroups: StateThread["terminalGroups"] = []; - - for (const group of groups) { - const groupTerminalIds = [ - ...new Set(group.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), - ].filter((terminalId) => { - if (!validTerminalIds.has(terminalId)) return false; - if (assignedTerminalIds.has(terminalId)) return false; - return true; - }); - if (groupTerminalIds.length === 0) continue; - for (const terminalId of groupTerminalIds) { - assignedTerminalIds.add(terminalId); - } - const baseGroupId = - group.id.trim().length > 0 - ? group.id.trim() - : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_TERMINAL_ID); - normalizedGroups.push({ - id: assignUniqueGroupId(baseGroupId, usedGroupIds), - terminalIds: groupTerminalIds, - }); - } - - for (const terminalId of terminalIds) { - if (assignedTerminalIds.has(terminalId)) continue; - normalizedGroups.push({ - id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), - terminalIds: [terminalId], - }); - } - - if (normalizedGroups.length > 0) { - return normalizedGroups; - } - - return [{ id: fallbackGroupId(DEFAULT_TERMINAL_ID), terminalIds: [DEFAULT_TERMINAL_ID] }]; -} - -function normalizeThread(thread: StateThread): StateThread { - const terminalIds = normalizeTerminalIds(thread.terminalIds); - const runningTerminalIds = normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds); - const activeTerminalId = terminalIds.includes(thread.activeTerminalId) - ? thread.activeTerminalId - : (terminalIds[0] ?? DEFAULT_TERMINAL_ID); - const terminalGroups = normalizeTerminalGroups(thread.terminalGroups, terminalIds); - const activeGroupId = - terminalGroups.find((group) => group.id === thread.activeTerminalGroupId)?.id ?? - terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? - terminalGroups[0]?.id ?? - fallbackGroupId(activeTerminalId); - - return { - ...thread, - terminalIds, - runningTerminalIds, - activeTerminalId, - terminalGroups, - activeTerminalGroupId: activeGroupId, - }; -} - function projectDocId(projectId: string): string { return `project:${projectId}`; } @@ -318,175 +183,73 @@ function threadDocId(threadId: string): string { return `thread:${threadId}`; } -function messageDocId(threadId: string, messageId: string): string { - return `message:${threadId}:${messageId}`; -} - function turnSummaryDocId(threadId: string, turnId: string): string { return `turn_summary:${threadId}:${turnId}`; } -function parsePathFromDiff(diff: string): string | null { - const normalized = diff.replace(/\r\n/g, "\n"); - const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); - if (bPath?.[1]) return bPath[1]; - const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); - if (gitHeader?.[1]) return gitHeader[1]; - const direct = normalized.match(/^\+\+\+ (.+)$/m); - if (!direct?.[1] || direct[1] === "/dev/null") { - return null; - } - return direct[1]; -} - -function splitUnifiedDiffByFile(diff: string): Map { - const normalized = diff.replace(/\r\n/g, "\n"); - const byPath = new Map(); - const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; - - if (headerMatches.length === 0) { - const pathFromDiff = parsePathFromDiff(normalized); - if (pathFromDiff) { - byPath.set(pathFromDiff, normalized.trim()); - } - return byPath; - } - - for (let index = 0; index < headerMatches.length; index += 1) { - const match = headerMatches[index]; - if (!match) continue; - const start = match.index ?? 0; - const end = headerMatches[index + 1]?.index ?? normalized.length; - const segment = normalized.slice(start, end).trim(); - const pathFromDiff = parsePathFromDiff(segment); - if (!pathFromDiff || segment.length === 0) continue; - byPath.set(pathFromDiff, segment); - } - - return byPath; -} - -function countDiffStat(patch: string): { additions: number; deletions: number } { - let additions = 0; - let deletions = 0; - for (const line of patch.replace(/\r\n/g, "\n").split("\n")) { - if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; - if (line.startsWith("+")) { - additions += 1; - continue; - } - if (line.startsWith("-")) { - deletions += 1; - } - } - return { additions, deletions }; -} - -function summarizeUnifiedDiff(diff: string): StateTurnDiffFileChange[] { - try { - const parsedPatches = parsePatchFiles(diff, "state-turn-summary", false); - const files: StateTurnDiffFileChange[] = []; - for (const patch of parsedPatches) { - for (const file of patch.files) { - const additions = file.hunks.reduce((sum, hunk) => sum + hunk.additionLines, 0); - const deletions = file.hunks.reduce((sum, hunk) => sum + hunk.deletionLines, 0); - files.push({ - path: file.name, - kind: file.type, - additions, - deletions, - }); - } - } - if (files.length > 0) { - return files.toSorted((a, b) => a.path.localeCompare(b.path)); - } - } catch { - // Fallback below. - } - - const fileDiffsByPath = splitUnifiedDiffByFile(diff); - const fallback: StateTurnDiffFileChange[] = []; - for (const [filePath, fileDiff] of fileDiffsByPath) { - const stat = countDiffStat(fileDiff); - fallback.push({ - path: filePath, - additions: stat.additions, - deletions: stat.deletions, - }); - } - return fallback.toSorted((a, b) => a.path.localeCompare(b.path)); -} - -function mergeTurnSummaryFiles( - existing: StateTurnDiffFileChange[], - incoming: StateTurnDiffFileChange[], -): StateTurnDiffFileChange[] { - const byPath = new Map(existing.map((file) => [file.path, { ...file }] as const)); - for (const file of incoming) { - const previous = byPath.get(file.path); - if (!previous) { - byPath.set(file.path, { ...file }); - continue; - } - byPath.set(file.path, { - ...previous, - ...(file.kind !== undefined ? { kind: file.kind } : {}), - ...(file.additions !== undefined ? { additions: file.additions } : {}), - ...(file.deletions !== undefined ? { deletions: file.deletions } : {}), - }); - } - return Array.from(byPath.values()).toSorted((a, b) => a.path.localeCompare(b.path)); -} - export class PersistenceService extends EventEmitter { - private readonly stateDb: StateDb; - private readonly db: StateDb["db"]; + private readonly db: SqliteDatabase; private readonly sessionThreadIds = new Map(); private readonly runtimeThreadIds = new Map(); + private readonly stateEventsQueue = Effect.runSync(Queue.unbounded()); + private readonly stateEventsBridge = Effect.runFork(this.runStateEventsBridge()); + private closed = false; constructor(options: PersistenceServiceOptions) { super(); - this.stateDb = new StateDb({ dbPath: options.dbPath }); - this.db = this.stateDb.db; - if (options.legacyProjectsJsonPath) { - this.importProjectsJsonIfNeeded(options.legacyProjectsJsonPath); + const config = resolvePersistenceConfig(options); + this.db = openPersistenceSqliteDatabase(config.dbPath); + try { + runPersistenceMigrations(this.db); + } catch (error) { + try { + this.db.close(); + } catch { + // Best effort close on failed initialization. + } + throw new PersistenceInitializationError("Failed to initialize persistence database", { + cause: error, + }); + } + if (config.legacyProjectsJsonPath) { + this.importProjectsJsonIfNeeded(config.legacyProjectsJsonPath); } } close(): void { - this.stateDb.close(); + if (this.closed) { + return; + } + this.closed = true; + try { + Effect.runSync(Queue.shutdown(this.stateEventsQueue)); + } catch { + // Best effort shutdown. + } + try { + Effect.runSync(Fiber.interrupt(this.stateEventsBridge)); + } catch { + // Ignore bridge interruption failures during shutdown. + } + this.db.close(); } getAppSettings(): AppSettings { - const metadataValue = this.readMetadata(METADATA_KEY_APP_SETTINGS); - const parsed = appSettingsSchema.safeParse(metadataValue); - if (parsed.success) { - return parsed.data; - } - return appSettingsSchema.parse({}); + return resolveAppSettings(this.readMetadata(METADATA_KEY_APP_SETTINGS)); } updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { - const patch = appSettingsUpdateInputSchema.parse(raw); - const next = appSettingsSchema.parse({ - ...this.getAppSettings(), - ...patch, - }); + const next = buildUpdatedAppSettings(this.getAppSettings(), raw); this.writeMetadata(METADATA_KEY_APP_SETTINGS, next); return next; } listProjects(): ProjectListResult { - const rows = this.db - .prepare( - "SELECT data_json FROM documents WHERE kind = 'project' ORDER BY updated_at DESC, created_at DESC;", - ) - .all() as Array<{ data_json: string }>; + const payloads = this.runWithEffectSql(listProjectPayloadsEffect()); const projects: StateProject[] = []; - for (const row of rows) { - const parsed = this.parseJson(row.data_json, stateProjectSchema); + for (const payload of payloads) { + const parsed = this.parseJson(payload, stateProjectSchema); if (parsed) { projects.push(parsed); } @@ -532,18 +295,16 @@ export class PersistenceService extends EventEmitter { } this.withTransaction((pendingEvents) => { - const threadRows = this.db - .prepare("SELECT data_json FROM documents WHERE kind = 'thread' AND project_id = ?;") - .all(input.id) as Array<{ data_json: string }>; + const threadPayloads = this.runWithEffectSql(listThreadPayloadsByProjectEffect(input.id)); const threadIds: string[] = []; - for (const row of threadRows) { - const parsed = this.parseJson(row.data_json, stateThreadSchema); + for (const payload of threadPayloads) { + const parsed = this.parseJson(payload, stateThreadSchema); if (parsed) { threadIds.push(parsed.id); } } - this.db.prepare("DELETE FROM documents WHERE project_id = ?;").run(input.id); + this.runWithEffectSql(deleteDocumentsByProjectIdEffect(input.id)); const eventTime = nowIso(); for (const threadId of threadIds) { @@ -692,25 +453,19 @@ export class PersistenceService extends EventEmitter { } this.withTransaction((pendingEvents) => { - this.db.prepare("DELETE FROM documents WHERE thread_id = ?;").run(thread.id); - this.db - .prepare("DELETE FROM documents WHERE id = ? AND kind = 'thread';") - .run(threadDocId(thread.id)); + this.runWithEffectSql(deleteDocumentsByThreadIdEffect(thread.id)); + this.runWithEffectSql(deleteDocumentByIdAndKindEffect(threadDocId(thread.id), "thread")); this.appendStateEvent(pendingEvents, "thread.delete", thread.id, { threadId: thread.id }, nowIso()); }); } loadSnapshot(): StateBootstrapResult { const projects = this.listProjects(); - const threadRows = this.db - .prepare( - "SELECT data_json FROM documents WHERE kind = 'thread' ORDER BY updated_at DESC, created_at DESC;", - ) - .all() as Array<{ data_json: string }>; + const threadPayloads = this.runWithEffectSql(listThreadPayloadsEffect()); const threads: StateBootstrapThread[] = []; - for (const row of threadRows) { - const parsedThread = this.parseJson(row.data_json, stateThreadSchema); + for (const payload of threadPayloads) { + const parsedThread = this.parseJson(payload, stateThreadSchema); if (!parsedThread) continue; const messages = this.listMessagesForThread(parsedThread.id); const turnDiffSummaries = this.listTurnSummariesForThread(parsedThread.id).map((summary) => { @@ -737,7 +492,7 @@ export class PersistenceService extends EventEmitter { } const lastStateSeq = this.readLastStateSeq(); - return stateBootstrapResultSchema.parse({ + return buildStateBootstrapResult({ projects, threads, lastStateSeq, @@ -746,27 +501,9 @@ export class PersistenceService extends EventEmitter { catchUp(raw: StateCatchUpInput): StateCatchUpResult { const input = stateCatchUpInputSchema.parse(raw); - const rows = this.db - .prepare( - "SELECT seq, event_type, entity_id, payload_json, created_at FROM state_events WHERE seq > ? ORDER BY seq ASC;", - ) - .all(input.afterSeq) as unknown as StateEventRow[]; - - const events: StateEvent[] = []; - for (const row of rows) { - const payload = this.tryParseJson(row.payload_json); - events.push( - stateEventSchema.parse({ - seq: row.seq, - eventType: row.event_type, - entityId: row.entity_id, - payload, - createdAt: row.created_at, - }), - ); - } + const events = this.runWithEffectSql(listStateEventsAfterSeqEffect(input.afterSeq)); - return stateCatchUpResultSchema.parse({ + return buildStateCatchUpResult({ events, lastStateSeq: this.readLastStateSeq(), }); @@ -774,29 +511,28 @@ export class PersistenceService extends EventEmitter { listMessages(raw: StateListMessagesInput): StateListMessagesResult { const input = stateListMessagesInputSchema.parse(raw); - const rows = this.db - .prepare( - "SELECT data_json FROM documents WHERE kind = 'message' AND thread_id = ? ORDER BY sort_key ASC LIMIT ? OFFSET ?;", - ) - .all(input.threadId, input.limit, input.offset) as Array<{ data_json: string }>; - const totalRow = this.db - .prepare("SELECT COUNT(1) AS total FROM documents WHERE kind = 'message' AND thread_id = ?;") - .get(input.threadId) as { total: number } | undefined; - const total = totalRow?.total ?? 0; + const payloads = this.runWithEffectSql( + listPaginatedMessagePayloadsForThreadEffect({ + threadId: input.threadId, + limit: input.limit, + offset: input.offset, + }), + ); + const total = this.runWithEffectSql(countMessagesForThreadEffect(input.threadId)); const messages: StateMessage[] = []; - for (const row of rows) { - const parsed = this.parseJson(row.data_json, stateMessageSchema); + for (const payload of payloads) { + const parsed = this.parseJson(payload, stateMessageSchema); if (parsed) { messages.push(parsed); } } - const nextOffset = input.offset + rows.length; - return stateListMessagesResultSchema.parse({ + return buildStateListMessagesResult({ messages, total, - nextOffset: nextOffset < total ? nextOffset : null, + offset: input.offset, + pageSize: payloads.length, }); } @@ -831,28 +567,12 @@ export class PersistenceService extends EventEmitter { } const messageId = input.clientMessageId ?? randomUUID(); - const text = input.clientMessageText ?? input.input ?? ""; const createdAt = nowIso(); - const inputAttachments = input.attachments ?? []; - const attachments = - inputAttachments.length > 0 - ? inputAttachments.map((attachment, index) => ({ - type: "image" as const, - id: `${messageId}:image:${index + 1}`, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - })) - : undefined; - const message = stateMessageSchema.parse({ - id: messageId, + const message = buildUserTurnMessage({ + turn: input, threadId, - role: "user", - text, - ...(attachments ? { attachments } : {}), + messageId, createdAt, - updatedAt: createdAt, - streaming: false, }); this.withTransaction((pendingEvents) => { @@ -1219,9 +939,9 @@ export class PersistenceService extends EventEmitter { for (let index = input.messageCount; index < messages.length; index += 1) { const message = messages[index]; if (!message) continue; - this.db - .prepare("DELETE FROM documents WHERE id = ? AND kind = 'message';") - .run(messageDocId(thread.id, message.id)); + this.runWithEffectSql( + deleteDocumentByIdAndKindEffect(messageDocId(thread.id, message.id), "message"), + ); this.appendStateEvent( pendingEvents, "message.delete", @@ -1238,9 +958,9 @@ export class PersistenceService extends EventEmitter { typeof summary.checkpointTurnCount === "number" && summary.checkpointTurnCount > input.turnCount ) { - this.db - .prepare("DELETE FROM documents WHERE id = ? AND kind = 'turn_summary';") - .run(turnSummaryDocId(thread.id, summary.turnId)); + this.runWithEffectSql( + deleteDocumentByIdAndKindEffect(turnSummaryDocId(thread.id, summary.turnId), "turn_summary"), + ); this.appendStateEvent( pendingEvents, "turn_summary.delete", @@ -1310,7 +1030,7 @@ export class PersistenceService extends EventEmitter { } let importedCount = 0; - this.stateDb.transaction(() => { + this.runDbTransaction(() => { try { const raw = fs.readFileSync(normalizedPath, "utf8"); const payload = JSON.parse(raw) as { projects?: unknown }; @@ -1354,49 +1074,50 @@ export class PersistenceService extends EventEmitter { } private readLastStateSeq(): number { - const row = this.db - .prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM state_events;") - .get() as { seq: number } | undefined; - return row?.seq ?? 0; + return this.runWithEffectSql(readLastStateSeqEffect); } - private withTransaction(fn: (pendingEvents: StateEvent[]) => T): T { - const pendingEvents: StateEvent[] = []; - const result = this.stateDb.transaction(() => fn(pendingEvents)); - for (const event of pendingEvents) { + private runDbTransaction(fn: () => T): T { + this.db.exec("BEGIN IMMEDIATE;"); + try { + const result = fn(); + this.db.exec("COMMIT;"); + return result; + } catch (error) { try { - this.emit("stateEvent", event); + this.db.exec("ROLLBACK;"); } catch { - // Listener failures should not break already-committed writes. + // Preserve the original transactional failure. } + throw error; + } + } + + private withTransaction(fn: (pendingEvents: StateEvent[]) => T): T { + const pendingEvents: StateEvent[] = []; + const result = this.runDbTransaction(() => fn(pendingEvents)); + for (const event of pendingEvents) { + this.publishStateEvent(event); } return result; } private appendStateEvent( pendingEvents: StateEvent[], - eventType: string, + eventType: StateEvent["eventType"], entityId: string, - payload: unknown, + payload: StateEvent["payload"], createdAt: string, ): void { - const result = this.db - .prepare( - "INSERT INTO state_events (event_type, entity_id, payload_json, created_at) VALUES (?, ?, ?, ?);", - ) - .run(eventType, entityId, JSON.stringify(payload), createdAt) as { - lastInsertRowid?: number | bigint; - }; - const seq = toSafeInteger(result.lastInsertRowid, 0); - pendingEvents.push( - stateEventSchema.parse({ - seq, + const nextEvent = this.runWithEffectSql( + appendStateEventEffect({ eventType, entityId, payload, createdAt, }), ); + pendingEvents.push(nextEvent); } private upsertProjectDocument(project: StateProject): void { @@ -1519,13 +1240,9 @@ export class PersistenceService extends EventEmitter { } private findThreadByRuntimeThreadId(runtimeThreadId: string): StateThread | null { - const row = this.db - .prepare( - "SELECT data_json FROM documents WHERE kind = 'thread' AND json_extract(data_json, '$.codexThreadId') = ? LIMIT 1;", - ) - .get(runtimeThreadId) as { data_json: string } | undefined; - if (!row) return null; - const parsed = this.parseJson(row.data_json, stateThreadSchema); + const payload = this.runWithEffectSql(findThreadPayloadByRuntimeThreadIdEffect(runtimeThreadId)); + if (!payload) return null; + const parsed = this.parseJson(payload, stateThreadSchema); return parsed ? normalizeThread(parsed) : null; } @@ -1546,38 +1263,32 @@ export class PersistenceService extends EventEmitter { sessionId?: string; runtimeThreadId?: string | null; }): string | undefined { - const queries: Array<{ sql: string; params: unknown[] }> = []; + const rowGroups: CompletedProviderItemRow[][] = []; if (input.sessionId) { - queries.push({ - sql: `SELECT item_id, payload_json - FROM provider_events - WHERE session_id = ? AND turn_id = ? AND method = 'item/completed' - ORDER BY created_at DESC;`, - params: [input.sessionId, input.turnId], - }); + rowGroups.push( + this.runWithEffectSql( + listCompletedItemEventsBySessionTurnEffect({ + sessionId: input.sessionId, + turnId: input.turnId, + }), + ), + ); } if (input.runtimeThreadId) { - queries.push({ - sql: `SELECT item_id, payload_json - FROM provider_events - WHERE thread_id = ? AND turn_id = ? AND method = 'item/completed' - ORDER BY created_at DESC;`, - params: [input.runtimeThreadId, input.turnId], - }); + rowGroups.push( + this.runWithEffectSql( + listCompletedItemEventsByThreadTurnEffect({ + runtimeThreadId: input.runtimeThreadId, + turnId: input.turnId, + }), + ), + ); } - queries.push({ - sql: `SELECT item_id, payload_json - FROM provider_events - WHERE turn_id = ? AND method = 'item/completed' - ORDER BY created_at DESC;`, - params: [input.turnId], - }); + rowGroups.push( + this.runWithEffectSql(listCompletedItemEventsByTurnEffect(input.turnId)), + ); - for (const query of queries) { - const rows = this.db.prepare(query.sql).all(...query.params) as Array<{ - item_id: string | null; - payload_json: string | null; - }>; + for (const rows of rowGroups) { for (const row of rows) { const payload = row.payload_json ? this.tryParseJson(row.payload_json) : null; const item = asObject(asObject(payload)?.item); @@ -1596,17 +1307,10 @@ export class PersistenceService extends EventEmitter { } private findLatestAssistantMessageIdForThread(threadId: string): string | undefined { - const rows = this.db - .prepare( - `SELECT data_json - FROM documents - WHERE kind = 'message' AND thread_id = ? - ORDER BY sort_key DESC, updated_at DESC;`, - ) - .all(threadId) as Array<{ data_json: string }>; - - for (const row of rows) { - const message = this.parseJson(row.data_json, stateMessageSchema); + const payloads = this.runWithEffectSql(listMessagePayloadsForThreadDescEffect(threadId)); + + for (const payload of payloads) { + const message = this.parseJson(payload, stateMessageSchema); if (!message) { continue; } @@ -1619,14 +1323,10 @@ export class PersistenceService extends EventEmitter { } private listMessagesForThread(threadId: string): StateMessage[] { - const rows = this.db - .prepare( - "SELECT data_json FROM documents WHERE kind = 'message' AND thread_id = ? ORDER BY sort_key ASC;", - ) - .all(threadId) as Array<{ data_json: string }>; + const payloads = this.runWithEffectSql(listMessagePayloadsForThreadEffect(threadId)); const messages: StateMessage[] = []; - for (const row of rows) { - const parsed = this.parseJson(row.data_json, stateMessageSchema); + for (const payload of payloads) { + const parsed = this.parseJson(payload, stateMessageSchema); if (parsed) { messages.push(parsed); } @@ -1635,14 +1335,10 @@ export class PersistenceService extends EventEmitter { } private listTurnSummariesForThread(threadId: string): StateTurnSummary[] { - const rows = this.db - .prepare( - "SELECT data_json FROM documents WHERE kind = 'turn_summary' AND thread_id = ? ORDER BY sort_key DESC, updated_at DESC;", - ) - .all(threadId) as Array<{ data_json: string }>; + const payloads = this.runWithEffectSql(listTurnSummaryPayloadsForThreadEffect(threadId)); const summaries: StateTurnSummary[] = []; - for (const row of rows) { - const parsed = this.parseJson(row.data_json, stateTurnSummarySchema); + for (const payload of payloads) { + const parsed = this.parseJson(payload, stateTurnSummarySchema); if (parsed) { summaries.push(parsed); } @@ -1651,56 +1347,26 @@ export class PersistenceService extends EventEmitter { } private upsertDocument(input: UpsertDocumentInput): void { - this.db - .prepare( - `INSERT INTO documents ( - id, - kind, - project_id, - thread_id, - sort_key, - schema_version, - created_at, - updated_at, - data_json - ) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - kind = excluded.kind, - project_id = excluded.project_id, - thread_id = excluded.thread_id, - sort_key = excluded.sort_key, - schema_version = excluded.schema_version, - updated_at = excluded.updated_at, - data_json = excluded.data_json;`, - ) - .run( - input.id, - input.kind, - input.projectId, - input.threadId, - input.sortKey, - input.createdAt, - input.updatedAt, - JSON.stringify(input.data), - ); + this.runWithEffectSql( + upsertDocumentEffect({ + id: input.id, + kind: input.kind, + projectId: input.projectId, + threadId: input.threadId, + sortKey: input.sortKey, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + dataJson: JSON.stringify(input.data), + }), + ); } private getDocumentRowById(id: string): DocumentRow | null { - const row = this.db - .prepare( - "SELECT id, kind, project_id, thread_id, sort_key, created_at, updated_at, data_json FROM documents WHERE id = ? LIMIT 1;", - ) - .get(id) as DocumentRow | undefined; - return row ?? null; + return this.runWithEffectSql(getDocumentRowByIdEffect(id)); } private readNextSortKey(kind: "message" | "turn_summary", threadId: string): number { - const row = this.db - .prepare( - "SELECT COALESCE(MAX(sort_key), 0) + 1 AS next_sort_key FROM documents WHERE kind = ? AND thread_id = ?;", - ) - .get(kind, threadId) as { next_sort_key: number } | undefined; - return row?.next_sort_key ?? 1; + return this.runWithEffectSql(readNextSortKeyEffect(kind, threadId)); } private resolveThreadIdForEvent(event: ProviderEvent, runtimeThreadId: string | null): string | null { @@ -1732,43 +1398,26 @@ export class PersistenceService extends EventEmitter { private insertProviderEvent(event: ProviderEvent): ProviderEventInsertResult { const runtimeThreadId = event.threadId ?? parseThreadIdFromEventPayload(event.payload); const payloadJson = event.payload === undefined ? null : JSON.stringify(event.payload); - const result = this.db - .prepare( - `INSERT OR IGNORE INTO provider_events ( - id, - session_id, - provider, - kind, - method, - thread_id, - turn_id, - item_id, - request_id, - request_kind, - text_delta, - message, - payload_json, - created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, - ) - .run( - event.id, - event.sessionId, - event.provider, - event.kind, - event.method, - runtimeThreadId, - event.turnId ?? null, - event.itemId ?? null, - event.requestId ?? null, - event.requestKind ?? null, - event.textDelta ?? null, - event.message ?? null, + const changes = this.runWithEffectSql( + insertProviderEventEffect({ + id: event.id, + sessionId: event.sessionId, + provider: event.provider, + kind: event.kind, + method: event.method, + runtimeThreadId: runtimeThreadId ?? null, + turnId: event.turnId ?? null, + itemId: event.itemId ?? null, + requestId: event.requestId ?? null, + requestKind: event.requestKind ?? null, + textDelta: event.textDelta ?? null, + message: event.message ?? null, payloadJson, - event.createdAt, - ) as { changes?: number | bigint }; + createdAt: event.createdAt, + }), + ); return { - inserted: toSafeInteger(result.changes, 0) > 0, + inserted: changes > 0, runtimeThreadId: runtimeThreadId ?? null, }; } @@ -1810,27 +1459,45 @@ export class PersistenceService extends EventEmitter { } private readMetadata(key: string): unknown { - const row = this.db - .prepare("SELECT value_json FROM metadata WHERE key = ? LIMIT 1;") - .get(key) as { value_json: string } | undefined; - if (!row) { - return null; - } - return this.tryParseJson(row.value_json); + return this.runWithEffectSql(readMetadataValueEffect(key)); } private writeMetadata(key: string, value: unknown, inTransaction = false): void { const write = () => { - this.db - .prepare( - "INSERT INTO metadata (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json;", - ) - .run(key, JSON.stringify(value)); + this.runWithEffectSql(writeMetadataValueEffect(key, value)); }; if (inTransaction) { write(); return; } - this.stateDb.transaction(write); + this.runDbTransaction(write); + } + + private runWithEffectSql( + effect: Effect.Effect, + ): A { + return runWithSqlClient(this.db, effect); + } + + private publishStateEvent(event: StateEvent): void { + try { + Effect.runSync(Queue.offer(this.stateEventsQueue, event)); + } catch { + // Best-effort state event delivery; persistence writes are already committed. + } + } + + private runStateEventsBridge(): Effect.Effect { + return Effect.forever( + Effect.flatMap(Queue.take(this.stateEventsQueue), (event) => + Effect.sync(() => { + try { + this.emit("stateEvent", event); + } catch { + // Listener failures should not break already-committed writes. + } + }), + ), + ); } } diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index 8ce55ea7112c..fbad06afc478 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -2,6 +2,9 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; import { type ProviderCheckpoint, @@ -175,6 +178,8 @@ export class ProviderManager extends EventEmitter { private readonly pendingEventsBySession = new Map(); private readonly sessionCheckpointCwds = new Map(); private readonly filesystemLocks = new Map>(); + private readonly persistenceQueue = Effect.runSync(Queue.unbounded()); + private readonly persistenceWorker = Effect.runFork(this.runPersistenceWorker()); private disposed = false; private readonly onCodexEvent = (event: ProviderEvent) => { if (this.disposed) { @@ -182,11 +187,7 @@ export class ProviderManager extends EventEmitter { } this.routeEventToThreadLog(event); - try { - this.persistenceService?.ingestProviderEvent(event); - } catch { - // Persistence failures should not break provider streaming. - } + this.enqueuePersistenceEvent(event); if (event.method === "session/closed" || event.method === "session/exited") { this.persistenceService?.unbindSession(event.sessionId); } @@ -415,6 +416,7 @@ export class ProviderManager extends EventEmitter { } this.disposed = true; + this.shutdownPersistenceWorker(); this.codex.off("event", this.onCodexEvent); for (const stream of this.threadLogStreams.values()) { stream.end(); @@ -426,6 +428,41 @@ export class ProviderManager extends EventEmitter { this.filesystemLocks.clear(); } + private runPersistenceWorker(): Effect.Effect { + return Effect.forever( + Effect.flatMap(Queue.take(this.persistenceQueue), (event) => + Effect.sync(() => { + try { + this.persistenceService?.ingestProviderEvent(event); + } catch { + // Persistence failures should not break provider streaming. + } + }), + ), + ); + } + + private enqueuePersistenceEvent(event: ProviderEvent): void { + try { + Effect.runSync(Queue.offer(this.persistenceQueue, event)); + } catch { + // Best effort ingest queue; runtime event stream should continue. + } + } + + private shutdownPersistenceWorker(): void { + try { + Effect.runSync(Queue.shutdown(this.persistenceQueue)); + } catch { + // Ignore queue shutdown failures during dispose. + } + try { + Effect.runSync(Fiber.interrupt(this.persistenceWorker)); + } catch { + // Ignore worker interrupt failures during dispose. + } + } + private async initializeFilesystemCheckpointing( session: ProviderSession, preferredCwd?: string, diff --git a/apps/server/src/sqliteAdapter.test.ts b/apps/server/src/sqliteAdapter.test.ts deleted file mode 100644 index f54a20c0b468..000000000000 --- a/apps/server/src/sqliteAdapter.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { openSqliteDatabase } from "./sqliteAdapter"; - -describe("sqliteAdapter", () => { - it("opens a database and runs basic statements", () => { - const db = openSqliteDatabase(":memory:"); - db.exec("CREATE TABLE sample (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL);"); - - const insertResult = db.prepare("INSERT INTO sample (value) VALUES (?);").run("hello") as { - changes?: number; - lastInsertRowid?: number; - }; - expect(insertResult.changes).toBe(1); - - const row = db.prepare("SELECT value FROM sample WHERE id = ?;").get(1) as - | { value?: string } - | undefined; - expect(row?.value).toBe("hello"); - - const rows = db.prepare("SELECT value FROM sample ORDER BY id ASC;").all() as Array<{ - value?: string; - }>; - expect(rows.map((entry) => entry.value)).toEqual(["hello"]); - - db.close(); - }); - - it("throws a helpful error when node:sqlite is unavailable", () => { - const missingModuleError = new Error("No such built-in module: node:sqlite"); - (missingModuleError as NodeJS.ErrnoException).code = "ERR_UNKNOWN_BUILTIN_MODULE"; - const requireFn = vi.fn((specifier: string) => { - if (specifier === "node:sqlite") { - throw missingModuleError; - } - throw new Error(`Unexpected module request: ${specifier}`); - }); - - expect(() => openSqliteDatabase(":memory:", requireFn as never, false)).toThrow( - "node:sqlite is unavailable", - ); - }); -}); diff --git a/apps/server/src/sqliteAdapter.ts b/apps/server/src/sqliteAdapter.ts deleted file mode 100644 index 2adbf1e14286..000000000000 --- a/apps/server/src/sqliteAdapter.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { createRequire } from "node:module"; -import path from "node:path"; - -export interface SqliteStatement { - run: (...params: unknown[]) => unknown; - get: (...params: unknown[]) => unknown; - all: (...params: unknown[]) => unknown[]; -} - -export interface SqliteDatabase { - exec: (sql: string) => void; - prepare: (sql: string) => SqliteStatement; - close: () => void; -} - -interface NodeSqliteModule { - DatabaseSync: new (filename: string) => { - exec: (sql: string) => void; - prepare: (sql: string) => SqliteStatement; - close: () => void; - }; -} - -interface BunSqliteStatement { - run: (...params: unknown[]) => unknown; - get: (...params: unknown[]) => unknown; - all: (...params: unknown[]) => unknown[]; -} - -interface BunSqliteModule { - Database: new (filename: string) => { - exec: (sql: string) => unknown; - query: (sql: string) => BunSqliteStatement; - close: () => void; - }; -} - -function isNodeSqliteUnavailableError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - const code = (error as NodeJS.ErrnoException).code; - if (code === "ERR_UNKNOWN_BUILTIN_MODULE" || code === "MODULE_NOT_FOUND") { - return true; - } - return error.message.includes("node:sqlite"); -} - -function openNodeSqliteDatabase( - requireFn: ReturnType, - dbPath: string, -): SqliteDatabase { - let nodeSqlite: NodeSqliteModule; - try { - nodeSqlite = requireFn("node:sqlite") as NodeSqliteModule; - } catch (error) { - if (isNodeSqliteUnavailableError(error)) { - throw new Error( - "node:sqlite is unavailable in this runtime. Use Node.js 22+ (or run the server with Bun).", - { cause: error }, - ); - } - throw error; - } - if (typeof nodeSqlite.DatabaseSync !== "function") { - throw new Error( - "node:sqlite was loaded but DatabaseSync is missing. Upgrade Node.js or run the server with Bun.", - ); - } - const db = new nodeSqlite.DatabaseSync(dbPath); - return { - exec: (sql) => { - db.exec(sql); - }, - prepare: (sql) => db.prepare(sql), - close: () => { - db.close(); - }, - }; -} - -function openBunSqliteDatabase( - requireFn: ReturnType, - dbPath: string, -): SqliteDatabase { - const bunSqlite = requireFn("bun:sqlite") as BunSqliteModule; - const db = new bunSqlite.Database(dbPath); - return { - exec: (sql) => { - db.exec(sql); - }, - prepare: (sql) => { - const statement = db.query(sql); - return { - run: (...params: unknown[]) => statement.run(...params), - get: (...params: unknown[]) => statement.get(...params), - all: (...params: unknown[]) => statement.all(...params), - }; - }, - close: () => { - db.close(); - }, - }; -} - -export function openSqliteDatabase( - dbPath: string, - requireFn: ReturnType = createRequire( - path.join(process.cwd(), "t3code-sqlite-adapter.cjs"), - ), - runtimeIsBun = Boolean(process.versions.bun), -): SqliteDatabase { - if (runtimeIsBun) { - // Development mode runs in Bun, so use Bun's SQLite adapter - return openBunSqliteDatabase(requireFn, dbPath); - } - - return openNodeSqliteDatabase(requireFn, dbPath); -} diff --git a/apps/server/src/stateDb.test.ts b/apps/server/src/stateDb.test.ts deleted file mode 100644 index 7446edf6aaec..000000000000 --- a/apps/server/src/stateDb.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { StateDb } from "./stateDb"; -import * as sqliteAdapter from "./sqliteAdapter"; -import { STATE_DB_SCHEMA_VERSION } from "./stateMigrations"; -import * as stateMigrations from "./stateMigrations"; - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - vi.restoreAllMocks(); -}); - -describe("StateDb", () => { - it("creates the SQLite schema and applies migrations", () => { - const tempDir = makeTempDir("t3code-state-db-"); - const dbPath = path.join(tempDir, "state.sqlite"); - const stateDb = new StateDb({ dbPath }); - - const tables = stateDb.db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table';") - .all() as Array<{ name: string }>; - const tableNames = new Set(tables.map((table) => table.name)); - expect(tableNames.has("documents")).toBe(true); - expect(tableNames.has("provider_events")).toBe(true); - expect(tableNames.has("state_events")).toBe(true); - expect(tableNames.has("metadata")).toBe(true); - - const userVersion = stateDb.db - .prepare("PRAGMA user_version;") - .get() as { user_version: number } | undefined; - expect(userVersion?.user_version).toBe(STATE_DB_SCHEMA_VERSION); - - stateDb.close(); - }); - - it("closes the database when migrations fail during construction", () => { - const tempDir = makeTempDir("t3code-state-db-migration-fail-"); - const dbPath = path.join(tempDir, "state.sqlite"); - const close = vi.fn(); - const fakeDb = { - exec: vi.fn(), - prepare: vi.fn(), - close, - }; - vi.spyOn(sqliteAdapter, "openSqliteDatabase").mockReturnValue( - fakeDb as unknown as sqliteAdapter.SqliteDatabase, - ); - vi.spyOn(stateMigrations, "runStateMigrations").mockImplementation(() => { - throw new Error("migration failed"); - }); - - expect(() => new StateDb({ dbPath })).toThrow("migration failed"); - expect(close).toHaveBeenCalledTimes(1); - }); - - it("preserves the original transaction error when rollback fails", () => { - const tempDir = makeTempDir("t3code-state-db-rollback-fail-"); - const dbPath = path.join(tempDir, "state.sqlite"); - const rollbackError = new Error("rollback failed"); - const exec = vi.fn((sql: string) => { - if (sql === "ROLLBACK;") { - throw rollbackError; - } - }); - const fakeDb = { - exec, - prepare: vi.fn(), - close: vi.fn(), - }; - vi.spyOn(sqliteAdapter, "openSqliteDatabase").mockReturnValue( - fakeDb as unknown as sqliteAdapter.SqliteDatabase, - ); - vi.spyOn(stateMigrations, "runStateMigrations").mockImplementation(() => {}); - - const stateDb = new StateDb({ dbPath }); - const originalError = new Error("operation failed"); - - let thrown: unknown; - try { - stateDb.transaction(() => { - throw originalError; - }); - } catch (error) { - thrown = error; - } - - expect(thrown).toBe(originalError); - expect(exec).toHaveBeenCalledWith("ROLLBACK;"); - stateDb.close(); - }); -}); diff --git a/apps/server/src/stateDb.ts b/apps/server/src/stateDb.ts deleted file mode 100644 index 7f2782d53a3a..000000000000 --- a/apps/server/src/stateDb.ts +++ /dev/null @@ -1,50 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import { openSqliteDatabase, type SqliteDatabase } from "./sqliteAdapter"; -import { runStateMigrations } from "./stateMigrations"; - -export interface StateDbOptions { - dbPath: string; -} - -export class StateDb { - readonly dbPath: string; - readonly db: SqliteDatabase; - - constructor(options: StateDbOptions) { - this.dbPath = path.resolve(options.dbPath); - fs.mkdirSync(path.dirname(this.dbPath), { recursive: true }); - this.db = openSqliteDatabase(this.dbPath); - try { - runStateMigrations(this.db); - } catch (error) { - try { - this.db.close(); - } catch { - // Best effort close on failed initialization. - } - throw error; - } - } - - close(): void { - this.db.close(); - } - - transaction(fn: () => T): T { - this.db.exec("BEGIN IMMEDIATE;"); - try { - const result = fn(); - this.db.exec("COMMIT;"); - return result; - } catch (error) { - try { - this.db.exec("ROLLBACK;"); - } catch { - // Preserve the original transactional failure. - } - throw error; - } - } -} diff --git a/apps/server/src/stateMigrations.ts b/apps/server/src/stateMigrations.ts deleted file mode 100644 index 9a2e11b8d065..000000000000 --- a/apps/server/src/stateMigrations.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { SqliteDatabase } from "./sqliteAdapter"; - -export const STATE_DB_SCHEMA_VERSION = 1; - -export function applyStateDbPragmas(db: SqliteDatabase): void { - db.exec("PRAGMA journal_mode=WAL;"); - db.exec("PRAGMA synchronous=FULL;"); - db.exec("PRAGMA busy_timeout=5000;"); - db.exec("PRAGMA foreign_keys=ON;"); -} - -function readUserVersion(db: SqliteDatabase): number { - const row = db.prepare("PRAGMA user_version;").get() as { user_version?: number } | undefined; - const value = row?.user_version; - if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { - return 0; - } - return value; -} - -function migrationV1(db: SqliteDatabase): void { - db.exec(` - CREATE TABLE IF NOT EXISTS documents ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - project_id TEXT NULL, - thread_id TEXT NULL, - sort_key INTEGER NULL, - schema_version INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - data_json TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS provider_events ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE, - session_id TEXT NOT NULL, - provider TEXT NOT NULL, - kind TEXT NOT NULL, - method TEXT NOT NULL, - thread_id TEXT NULL, - turn_id TEXT NULL, - item_id TEXT NULL, - request_id TEXT NULL, - request_kind TEXT NULL, - text_delta TEXT NULL, - message TEXT NULL, - payload_json TEXT NULL, - created_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS state_events ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - event_type TEXT NOT NULL, - entity_id TEXT NOT NULL, - payload_json TEXT NOT NULL, - created_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS metadata ( - key TEXT PRIMARY KEY, - value_json TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_documents_kind ON documents(kind); - CREATE INDEX IF NOT EXISTS idx_documents_project_kind ON documents(project_id, kind); - CREATE INDEX IF NOT EXISTS idx_documents_thread_kind_sort ON documents(thread_id, kind, sort_key); - CREATE INDEX IF NOT EXISTS idx_documents_kind_updated ON documents(kind, updated_at DESC); - - CREATE INDEX IF NOT EXISTS idx_provider_events_session_seq ON provider_events(session_id, seq); - CREATE INDEX IF NOT EXISTS idx_provider_events_thread_seq ON provider_events(thread_id, seq); - CREATE INDEX IF NOT EXISTS idx_state_events_seq ON state_events(seq); - `); -} - -export function runStateMigrations(db: SqliteDatabase): void { - applyStateDbPragmas(db); - - const userVersion = readUserVersion(db); - if (userVersion >= STATE_DB_SCHEMA_VERSION) { - return; - } - - db.exec("BEGIN IMMEDIATE;"); - try { - if (userVersion < 1) { - migrationV1(db); - } - db.exec(`PRAGMA user_version=${STATE_DB_SCHEMA_VERSION};`); - db.exec("COMMIT;"); - } catch (error) { - db.exec("ROLLBACK;"); - throw error; - } -} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 71e99e01dba7..f22081a893b7 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,4 +1,4 @@ -import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; +import type { ProviderEvent, ProviderSession, StateEvent, TerminalEvent } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { type AppState, reducer } from "./store"; @@ -566,7 +566,7 @@ describe("store reducer thread continuity", () => { const next = reducer(state, { type: "APPLY_STATE_EVENT", - event: { + event: ({ seq: 1, eventType: "thread.upsert", entityId: "thread-local-1", @@ -598,7 +598,7 @@ describe("store reducer thread continuity", () => { turnDiffSummaries: [], }, }, - }, + } as unknown as StateEvent), }); expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); @@ -626,33 +626,35 @@ describe("store reducer thread continuity", () => { }), ); - const next = reducer(state, { - type: "APPLY_STATE_EVENT", - event: { - seq: 1, - eventType: "thread.upsert", - entityId: "thread-local-1", - createdAt: "2026-02-09T00:00:06.000Z", - payload: { - thread: { - id: "thread-local-1", - codexThreadId: "thr-1", - projectId: "project-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - activeTerminalId: "term-2", - activeTerminalGroupId: "group-term-2", - createdAt: "2026-02-09T00:00:00.000Z", - updatedAt: "2026-02-09T00:00:06.000Z", - lastVisitedAt: "2026-02-09T00:00:06.000Z", - branch: null, - worktreePath: null, - turnDiffSummaries: [], - }, + const malformedStateEvent = ({ + seq: 1, + eventType: "thread.upsert", + entityId: "thread-local-1", + createdAt: "2026-02-09T00:00:06.000Z", + payload: { + thread: { + id: "thread-local-1", + codexThreadId: "thr-1", + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: true, + terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + activeTerminalId: "term-2", + activeTerminalGroupId: "group-term-2", + createdAt: "2026-02-09T00:00:00.000Z", + updatedAt: "2026-02-09T00:00:06.000Z", + lastVisitedAt: "2026-02-09T00:00:06.000Z", + branch: null, + worktreePath: null, + turnDiffSummaries: [], }, }, + } as unknown) as StateEvent; + + const next = reducer(state, { + type: "APPLY_STATE_EVENT", + event: malformedStateEvent, }); expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); @@ -674,7 +676,7 @@ describe("store reducer thread continuity", () => { const next = reducer(state, { type: "APPLY_STATE_EVENT", - event: { + event: ({ seq: 1, eventType: "thread.upsert", entityId: "thread-local-2", @@ -696,7 +698,7 @@ describe("store reducer thread continuity", () => { turnDiffSummaries: [], }, }, - }, + } as unknown as StateEvent), }); const createdThread = next.threads.find((thread) => thread.id === "thread-local-2"); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 1ade7eeec997..e32ce8e6b916 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -575,209 +575,158 @@ export function reducer(state: AppState, action: Action): AppState { } case "APPLY_STATE_EVENT": { - const payload = asObject(action.event.payload); - const eventType = action.event.eventType; - - if (eventType === "project.upsert") { - const project = asObject(payload?.project); - const id = asString(project?.id); - const name = asString(project?.name); - const cwd = asString(project?.cwd); - const scripts = Array.isArray(project?.scripts) ? (project.scripts as ProjectScript[]) : []; - if (!id || !name || !cwd) { - return state; + switch (action.event.eventType) { + case "project.upsert": { + const project = action.event.payload.project; + const previous = state.projects.find((entry) => entry.id === project.id); + const nextProject: Project = { + id: project.id, + name: project.name, + cwd: project.cwd, + model: resolveModelSlug(previous?.model ?? DEFAULT_MODEL), + expanded: previous?.expanded ?? true, + scripts: normalizeProjectScripts(project.scripts), + }; + const nextProjects = previous + ? state.projects.map((entry) => (entry.id === project.id ? nextProject : entry)) + : [...state.projects, nextProject]; + return { + ...state, + projects: nextProjects, + }; } - const previous = state.projects.find((entry) => entry.id === id); - const nextProject: Project = { - id, - name, - cwd, - model: resolveModelSlug(previous?.model ?? DEFAULT_MODEL), - expanded: previous?.expanded ?? true, - scripts: normalizeProjectScripts(scripts), - }; - const nextProjects = previous - ? state.projects.map((entry) => (entry.id === id ? nextProject : entry)) - : [...state.projects, nextProject]; - return { - ...state, - projects: nextProjects, - }; - } - - if (eventType === "project.delete") { - const projectId = asString(payload?.projectId) ?? action.event.entityId; - const projects = state.projects.filter((project) => project.id !== projectId); - const threads = state.threads.filter((thread) => thread.projectId !== projectId); - const diffState = resetDiffTargetIfMissing(state, threads); - return { - ...state, - projects, - threads, - ...diffState, - }; - } + case "project.delete": { + const projectId = action.event.payload.projectId; + const projects = state.projects.filter((project) => project.id !== projectId); + const threads = state.threads.filter((thread) => thread.projectId !== projectId); + const diffState = resetDiffTargetIfMissing(state, threads); + return { + ...state, + projects, + threads, + ...diffState, + }; + } + case "thread.upsert": { + const threadPayload = action.event.payload.thread as Partial & + Pick< + StateBootstrapThread, + "id" | "projectId" | "title" | "model" | "createdAt" | "updatedAt" + >; + const threadId = threadPayload.id; + const projectId = threadPayload.projectId; + if (!state.projects.some((project) => project.id === projectId)) { + return state; + } - if (eventType === "thread.upsert") { - const threadPayload = asObject(payload?.thread); - const threadId = asString(threadPayload?.id); - const projectId = asString(threadPayload?.projectId); - if (!threadId || !projectId) { - return state; + const existing = state.threads.find((thread) => thread.id === threadId); + const existingStateMessages = + existing?.messages.map((message) => ({ + id: message.id, + threadId, + role: message.role, + text: message.text, + ...(message.attachments + ? { + attachments: message.attachments.map((attachment) => ({ ...attachment })), + } + : {}), + createdAt: message.createdAt, + updatedAt: message.createdAt, + streaming: message.streaming, + })) ?? []; + const payloadTurnDiffSummaries = Array.isArray(threadPayload.turnDiffSummaries) + ? (threadPayload.turnDiffSummaries as Thread["turnDiffSummaries"]) + : undefined; + const payloadTerminalIds = Array.isArray(threadPayload.terminalIds) + ? (threadPayload.terminalIds as string[]) + : (existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID]); + const payloadRunningTerminalIds = Array.isArray(threadPayload.runningTerminalIds) + ? (threadPayload.runningTerminalIds as string[]) + : (existing?.runningTerminalIds ?? []); + const payloadTerminalGroups = Array.isArray(threadPayload.terminalGroups) + ? (threadPayload.terminalGroups as ThreadTerminalGroup[]) + : (existing?.terminalGroups ?? []); + const bootstrapThread: StateBootstrapThread = { + ...(threadPayload as unknown as Omit), + terminalIds: payloadTerminalIds, + runningTerminalIds: payloadRunningTerminalIds, + terminalGroups: payloadTerminalGroups, + messages: existingStateMessages, + turnDiffSummaries: payloadTurnDiffSummaries + ? mergeTurnDiffSummaries(existing?.turnDiffSummaries ?? [], payloadTurnDiffSummaries) + : existing?.turnDiffSummaries ?? [], + }; + const nextThread = hydrateThreadFromBootstrap(bootstrapThread, existing); + const nextThreads = existing + ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) + : [...state.threads, nextThread]; + return { + ...state, + threads: nextThreads, + }; } - if (!state.projects.some((project) => project.id === projectId)) { - return state; + case "thread.delete": { + const threadId = action.event.payload.threadId; + const nextThreads = state.threads.filter((thread) => thread.id !== threadId); + const diffState = resetDiffTargetIfMissing(state, nextThreads); + return { + ...state, + threads: nextThreads, + ...diffState, + }; } - - const existing = state.threads.find((thread) => thread.id === threadId); - const existingStateMessages = - existing?.messages.map((message) => ({ - id: message.id, - threadId, - role: message.role, - text: message.text, - ...(message.attachments - ? { - attachments: message.attachments.map((attachment) => ({ ...attachment })), - } - : {}), - createdAt: message.createdAt, - updatedAt: message.createdAt, - streaming: message.streaming, - })) ?? []; - const payloadTurnDiffSummaries = Array.isArray(threadPayload?.turnDiffSummaries) - ? (threadPayload.turnDiffSummaries as Thread["turnDiffSummaries"]) - : undefined; - const payloadTerminalIds = Array.isArray(threadPayload?.terminalIds) - ? (threadPayload.terminalIds as string[]) - : (existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID]); - const payloadRunningTerminalIds = Array.isArray(threadPayload?.runningTerminalIds) - ? (threadPayload.runningTerminalIds as string[]) - : (existing?.runningTerminalIds ?? []); - const payloadTerminalGroups = Array.isArray(threadPayload?.terminalGroups) - ? (threadPayload.terminalGroups as ThreadTerminalGroup[]) - : (existing?.terminalGroups ?? []); - const bootstrapThread: StateBootstrapThread = { - ...(threadPayload as unknown as Omit), - terminalIds: payloadTerminalIds, - runningTerminalIds: payloadRunningTerminalIds, - terminalGroups: payloadTerminalGroups, - messages: existingStateMessages, - turnDiffSummaries: payloadTurnDiffSummaries - ? mergeTurnDiffSummaries(existing?.turnDiffSummaries ?? [], payloadTurnDiffSummaries) - : existing?.turnDiffSummaries ?? [], - }; - const nextThread = hydrateThreadFromBootstrap(bootstrapThread, existing); - const nextThreads = existing - ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) - : [...state.threads, nextThread]; - return { - ...state, - threads: nextThreads, - }; - } - - if (eventType === "thread.delete") { - const threadId = asString(payload?.threadId) ?? action.event.entityId; - const nextThreads = state.threads.filter((thread) => thread.id !== threadId); - const diffState = resetDiffTargetIfMissing(state, nextThreads); - return { - ...state, - threads: nextThreads, - ...diffState, - }; - } - - if (eventType === "message.upsert") { - const threadId = asString(payload?.threadId); - const messagePayload = asObject(payload?.message); - const messageId = asString(messagePayload?.id); - const role = messagePayload?.role === "assistant" ? "assistant" : "user"; - const text = typeof messagePayload?.text === "string" ? messagePayload.text : ""; - const createdAt = asString(messagePayload?.createdAt); - if (!threadId || !messageId || !createdAt) { - return state; + case "message.upsert": { + const { threadId, message: messagePayload } = action.event.payload; + const attachments = messagePayload.attachments as + | Thread["messages"][number]["attachments"] + | undefined; + return { + ...state, + threads: updateThread(state.threads, threadId, (thread) => ({ + ...thread, + messages: upsertThreadMessage(thread.messages, { + id: messagePayload.id, + role: messagePayload.role, + text: messagePayload.text, + ...(attachments ? { attachments } : {}), + createdAt: messagePayload.createdAt, + streaming: messagePayload.streaming, + }), + })), + }; } - const attachments = Array.isArray(messagePayload?.attachments) - ? (messagePayload.attachments as Thread["messages"][number]["attachments"]) - : undefined; - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => ({ - ...thread, - messages: upsertThreadMessage(thread.messages, { - id: messageId, - role, - text, - ...(attachments ? { attachments } : {}), - createdAt, - streaming: messagePayload?.streaming === true, - }), - })), - }; - } - - if (eventType === "message.delete") { - const threadId = asString(payload?.threadId); - const messageId = asString(payload?.messageId); - if (!threadId || !messageId) { - return state; + case "message.delete": { + const { threadId, messageId } = action.event.payload; + return { + ...state, + threads: updateThread(state.threads, threadId, (thread) => ({ + ...thread, + messages: thread.messages.filter((message) => message.id !== messageId), + })), + }; } - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => ({ - ...thread, - messages: thread.messages.filter((message) => message.id !== messageId), - })), - }; - } - - if (eventType === "turn_summary.upsert") { - const threadId = asString(payload?.threadId); - const summaryPayload = asObject(payload?.turnSummary); - const turnId = asString(summaryPayload?.turnId); - const completedAt = asString(summaryPayload?.completedAt); - if (!threadId || !turnId || !completedAt) { - return state; + case "turn_summary.upsert": { + const { threadId, turnSummary } = action.event.payload; + return { + ...state, + threads: updateThread(state.threads, threadId, (thread) => ({ + ...thread, + turnDiffSummaries: mergeTurnDiffSummaries(thread.turnDiffSummaries, [turnSummary]), + })), + }; } - const summary: Thread["turnDiffSummaries"][number] = { - turnId, - completedAt, - status: asString(summaryPayload?.status), - files: Array.isArray(summaryPayload?.files) - ? (summaryPayload.files as Thread["turnDiffSummaries"][number]["files"]) - : [], - assistantMessageId: asString(summaryPayload?.assistantMessageId), - checkpointTurnCount: - typeof summaryPayload?.checkpointTurnCount === "number" - ? summaryPayload.checkpointTurnCount - : undefined, - }; - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => ({ - ...thread, - turnDiffSummaries: mergeTurnDiffSummaries(thread.turnDiffSummaries, [summary]), - })), - }; - } - - if (eventType === "turn_summary.delete") { - const threadId = asString(payload?.threadId); - const turnId = asString(payload?.turnId); - if (!threadId || !turnId) { - return state; + case "turn_summary.delete": { + const { threadId, turnId } = action.event.payload; + return { + ...state, + threads: updateThread(state.threads, threadId, (thread) => ({ + ...thread, + turnDiffSummaries: thread.turnDiffSummaries.filter((summary) => summary.turnId !== turnId), + })), + }; } - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => ({ - ...thread, - turnDiffSummaries: thread.turnDiffSummaries.filter((summary) => summary.turnId !== turnId), - })), - }; } - - return state; } case "ADD_PROJECT": diff --git a/bun.lock b/bun.lock index 313c4c8faee6..58e44f17633c 100644 --- a/bun.lock +++ b/bun.lock @@ -34,8 +34,11 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@effect/sql-sqlite-bun": "^4.0.0-beta.6", + "@effect/sql-sqlite-node": "^4.0.0-beta.6", "@pierre/diffs": "^1.1.0-beta.16", - "node-pty": "^1.1.0", + "effect": "^4.0.0-beta.6", + "node-pty": "1.1.0", "open": "^10.1.0", "ws": "^8.18.0", }, @@ -145,6 +148,10 @@ "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.6", "", { "peerDependencies": { "effect": "^4.0.0-beta.6" } }, "sha512-OYojvXs3X8TRMG8tQwxAbnR2LlOb/iM3bNUudU5bWBi2LJoLqQVmxLxDue5SIZbIMuxMfcBES9bPuKbPDd7Hgg=="], + + "@effect/sql-sqlite-node": ["@effect/sql-sqlite-node@4.0.0-beta.6", "", { "dependencies": { "better-sqlite3": "^12.6.2" }, "peerDependencies": { "effect": "^4.0.0-beta.6" } }, "sha512-lmV5h1U1VrfNWOlDEQBYidnXlMIWbLapKzwcNPcWuyGZZanHeg939Sve6v6I3AE8r874iIwCdj3e6boxkym89g=="], + "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -245,6 +252,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], @@ -583,18 +602,28 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -625,6 +654,8 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -657,6 +688,8 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -687,6 +720,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "effect": ["effect@4.0.0-beta.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-2xpzTi1f8eRYByMYso/vBiZGCvK74dVOMHOnXbwgzZQg6t5JaZ6ov7i5AU/vKPtb2y9FJbZwhf8owXBZq+LY0g=="], + "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], @@ -727,22 +762,32 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -761,6 +806,8 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], @@ -809,10 +856,16 @@ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -863,6 +916,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], @@ -1007,16 +1062,30 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -1061,6 +1130,8 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1071,10 +1142,14 @@ "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -1083,6 +1158,8 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -1133,6 +1210,8 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -1151,6 +1230,10 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1169,10 +1252,14 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -1193,6 +1280,10 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], @@ -1213,6 +1304,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -1227,6 +1320,8 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "turbo": ["turbo@2.8.7", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.7", "turbo-darwin-arm64": "2.8.7", "turbo-linux-64": "2.8.7", "turbo-linux-arm64": "2.8.7", "turbo-windows-64": "2.8.7", "turbo-windows-arm64": "2.8.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA=="], "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="], @@ -1277,6 +1372,10 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -1305,6 +1404,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1385,6 +1486,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/packages/contracts/src/state.test.ts b/packages/contracts/src/state.test.ts index d9311fb3eaf3..72e08a64608f 100644 --- a/packages/contracts/src/state.test.ts +++ b/packages/contracts/src/state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { stateBootstrapResultSchema, stateCatchUpInputSchema, + stateEventSchema, stateListMessagesInputSchema, stateListMessagesResultSchema, stateMessageSchema, @@ -117,4 +118,28 @@ describe("state schemas", () => { const catchUp = stateCatchUpInputSchema.parse({}); expect(catchUp.afterSeq).toBe(0); }); + + it("validates typed state event payload variants", () => { + const event = stateEventSchema.parse({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + payload: { + project: { + id: "project-1", + cwd: "/repo", + name: "repo", + scripts: [], + createdAt: "2026-02-19T00:00:00.000Z", + updatedAt: "2026-02-19T00:00:00.000Z", + }, + }, + createdAt: "2026-02-19T00:00:00.000Z", + }); + + expect(event.eventType).toBe("project.upsert"); + if (event.eventType === "project.upsert") { + expect(event.payload.project.id).toBe("project-1"); + } + }); }); diff --git a/packages/contracts/src/state.ts b/packages/contracts/src/state.ts index 0cef6ad044f0..700764b4646c 100644 --- a/packages/contracts/src/state.ts +++ b/packages/contracts/src/state.ts @@ -103,14 +103,83 @@ export const stateCatchUpInputSchema = z.object({ afterSeq: z.number().int().min(0).default(0), }); -export const stateEventSchema = z.object({ +const stateEventBaseSchema = z.object({ seq: z.number().int().positive(), - eventType: z.string().min(1), entityId: z.string().min(1), - payload: z.unknown(), createdAt: z.string().datetime(), }); +export const stateProjectUpsertPayloadSchema = z.object({ + project: stateProjectSchema, +}); + +export const stateProjectDeletePayloadSchema = z.object({ + projectId: z.string().min(1), +}); + +export const stateThreadUpsertPayloadSchema = z.object({ + thread: stateThreadSchema, +}); + +export const stateThreadDeletePayloadSchema = z.object({ + threadId: z.string().min(1), +}); + +export const stateMessageUpsertPayloadSchema = z.object({ + threadId: z.string().min(1), + message: stateMessageSchema, +}); + +export const stateMessageDeletePayloadSchema = z.object({ + threadId: z.string().min(1), + messageId: z.string().min(1), +}); + +export const stateTurnSummaryUpsertPayloadSchema = z.object({ + threadId: z.string().min(1), + turnSummary: stateTurnSummarySchema, +}); + +export const stateTurnSummaryDeletePayloadSchema = z.object({ + threadId: z.string().min(1), + turnId: z.string().min(1), +}); + +export const stateEventSchema = z.discriminatedUnion("eventType", [ + stateEventBaseSchema.extend({ + eventType: z.literal("project.upsert"), + payload: stateProjectUpsertPayloadSchema, + }), + stateEventBaseSchema.extend({ + eventType: z.literal("project.delete"), + payload: stateProjectDeletePayloadSchema, + }), + stateEventBaseSchema.extend({ + eventType: z.literal("thread.upsert"), + payload: stateThreadUpsertPayloadSchema, + }), + stateEventBaseSchema.extend({ + eventType: z.literal("thread.delete"), + payload: stateThreadDeletePayloadSchema, + }), + stateEventBaseSchema.extend({ + eventType: z.literal("message.upsert"), + payload: stateMessageUpsertPayloadSchema, + }), + stateEventBaseSchema.extend({ + eventType: z.literal("message.delete"), + payload: stateMessageDeletePayloadSchema, + }), + stateEventBaseSchema.extend({ + eventType: z.literal("turn_summary.upsert"), + payload: stateTurnSummaryUpsertPayloadSchema, + }), + stateEventBaseSchema.extend({ + eventType: z.literal("turn_summary.delete"), + payload: stateTurnSummaryDeletePayloadSchema, + }), +]); + export const stateCatchUpResultSchema = z.object({ events: z.array(stateEventSchema), lastStateSeq: z.number().int().min(0), @@ -204,6 +273,14 @@ export type StateThread = z.infer; export type StateBootstrapThread = z.infer; export type StateBootstrapResult = z.infer; export type StateCatchUpInput = z.input; +export type StateProjectUpsertPayload = z.infer; +export type StateProjectDeletePayload = z.infer; +export type StateThreadUpsertPayload = z.infer; +export type StateThreadDeletePayload = z.infer; +export type StateMessageUpsertPayload = z.infer; +export type StateMessageDeletePayload = z.infer; +export type StateTurnSummaryUpsertPayload = z.infer; +export type StateTurnSummaryDeletePayload = z.infer; export type StateEvent = z.infer; export type StateCatchUpResult = z.infer; export type StateListMessagesInput = z.input;