Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
550069f
Adopt Effect SQL-backed SQLite adapter
cursoragent Feb 20, 2026
e691b4a
Type state event payloads across contracts and store
cursoragent Feb 20, 2026
13dea39
Run migrations and ingest persistence via Effect runtime
cursoragent Feb 20, 2026
a976a82
Use Effect SQL repository for state event persistence
cursoragent Feb 20, 2026
56066ce
Extract metadata persistence to Effect SQL repository
cursoragent Feb 20, 2026
5d75a58
Extract initial schema migration into persistence module
cursoragent Feb 20, 2026
d6e0cd8
Extract document persistence queries into Effect repository
cursoragent Feb 20, 2026
bce0df0
Move provider event SQL access into Effect repository
cursoragent Feb 20, 2026
34d456b
Document Effect-based persistence architecture
cursoragent Feb 20, 2026
85af69c
Route thread message and summary queries through Effect repo
cursoragent Feb 20, 2026
9112818
Move remaining document queries behind Effect repositories
cursoragent Feb 20, 2026
aa2d6e7
Initialize persistence service directly from Effect SQLite adapter
cursoragent Feb 20, 2026
1db852a
Remove legacy StateDb wrapper and obsolete tests
cursoragent Feb 20, 2026
40d49f1
Extract persistence domain helpers and add turn summary tests
cursoragent Feb 20, 2026
2cf729e
Add Effect persistence runtime modules and wire service bootstrap
cursoragent Feb 20, 2026
ee2211b
Replace legacy sqlite adapter tests with persistence module tests
cursoragent Feb 20, 2026
ba0a08f
Remove legacy SQL fallback paths from persistence service
cursoragent Feb 20, 2026
5ba2218
Drop non-Effect migration fallback path
cursoragent Feb 20, 2026
10d6321
Bridge state event publication through Effect queue worker
cursoragent Feb 20, 2026
cdea401
Add migration reliability tests for Effect-only adapter requirements
cursoragent Feb 20, 2026
72c33f7
Move sqlite adapter and migrator fully into persistence module
cursoragent Feb 20, 2026
40f9885
Validate persistence repository rows with Effect Schema decoders
cursoragent Feb 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/persistence/config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions apps/server/src/persistence/domain/appSettings.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
39 changes: 39 additions & 0 deletions apps/server/src/persistence/domain/messages.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
24 changes: 24 additions & 0 deletions apps/server/src/persistence/domain/projects.ts
Original file line number Diff line number Diff line change
@@ -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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

domain/projects.ts:5 On POSIX, directory names can contain leading/trailing whitespace (e.g., "repo "), so .trim() may corrupt valid paths. If this is intentional input sanitization, consider documenting that assumption; otherwise, consider removing .trim().

Suggested change
const resolved = path.resolve(rawCwd.trim());
const resolved = path.resolve(rawCwd);

🚀 Want me to fix this? Reply ex: "fix it for me".

🤖 Prompt for AI
In file apps/server/src/persistence/domain/projects.ts around line 5:

On POSIX, directory names can contain leading/trailing whitespace (e.g., `"repo "`), so `.trim()` may corrupt valid paths. If this is intentional input sanitization, consider documenting that assumption; otherwise, consider removing `.trim()`.

Evidence trail:
apps/server/src/persistence/domain/projects.ts line 5 at commit 40f9885dceed14d5af286fb33d23b48e7fd3e974 shows: `const resolved = path.resolve(rawCwd.trim());` - confirming `.trim()` is called on the path input. POSIX filesystem specification allows whitespace characters in filenames/directory names.

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";
}
Comment on lines +21 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

domain/projects.ts:21 On Windows, path.basename returns empty for drive roots like D:\ or E:\, so all root paths get the same name "project". Consider extracting the drive letter (e.g., "D-root") to avoid collisions when multiple drive roots are registered.

Suggested change
export function inferProjectName(cwd: string): string {
const name = path.basename(cwd);
return name.length > 0 ? name : "project";
}
export function inferProjectName(cwd: string): string {
const name = path.basename(cwd);
if (name.length > 0) {
return name;
}
// Handle Windows drive roots (e.g., "C:\" -> "C-root")
if (process.platform === "win32") {
const drive = path.parse(cwd).root.replace(/[:\\]/g, "").toUpperCase();
if (drive) {
return `${drive}-root`;
}
}
return "project";
}

🚀 Want me to fix this? Reply ex: "fix it for me".

🤖 Prompt for AI
In file apps/server/src/persistence/domain/projects.ts around lines 21-24:

On Windows, `path.basename` returns empty for drive roots like `D:\` or `E:\`, so all root paths get the same name `"project"`. Consider extracting the drive letter (e.g., `"D-root"`) to avoid collisions when multiple drive roots are registered.

Evidence trail:
apps/server/src/persistence/domain/projects.ts lines 21-24 at commit 40f9885dceed14d5af286fb33d23b48e7fd3e974. The `inferProjectName` function uses `path.basename(cwd)` and falls back to `"project"` when the result is empty. Node.js `path.basename` behavior for Windows drive roots like `D:\` returns empty string (documented behavior in Node.js path module).

42 changes: 42 additions & 0 deletions apps/server/src/persistence/domain/providerProjection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ProviderEvent } from "@t3tools/contracts";

export function asObject(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
return value as Record<string, unknown>;
}

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;
}
41 changes: 41 additions & 0 deletions apps/server/src/persistence/domain/stateSync.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
117 changes: 117 additions & 0 deletions apps/server/src/persistence/domain/threads.ts
Original file line number Diff line number Diff line change
@@ -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))
Comment on lines +25 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

domain/threads.ts:25 Deduplication via Set happens before trim(), so "a" and " a" both survive deduplication and become duplicate "a" entries after trimming. Consider trimming before deduplicating, similar to normalizeTerminalIds.

Suggested change
return [...new Set(runningTerminalIds)]
.map((id) => id.trim())
.filter((id) => id.length > 0 && validTerminalIds.has(id))
return [...new Set(runningTerminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]
.filter((id) => validTerminalIds.has(id))

🚀 Want me to fix this? Reply ex: "fix it for me".

🤖 Prompt for AI
In file apps/server/src/persistence/domain/threads.ts around lines 25-27:

Deduplication via `Set` happens before `trim()`, so `"a"` and `" a"` both survive deduplication and become duplicate `"a"` entries after trimming. Consider trimming before deduplicating, similar to `normalizeTerminalIds`.

Evidence trail:
apps/server/src/persistence/domain/threads.ts lines 24-28 (commit 40f9885): `return [...new Set(runningTerminalIds)].map((id) => id.trim())...` shows Set deduplication before trim().

apps/server/src/persistence/domain/threads.ts lines 7-8 (commit 40f9885): `...new Set(ids.map((id) => id.trim()).filter...)` shows normalizeTerminalIds correctly trims before deduplicating.

.slice(0, MAX_TERMINAL_COUNT);
}

export function fallbackGroupId(terminalId: string): string {
return `group-${terminalId}`;
}

function assignUniqueGroupId(groupId: string, usedGroupIds: Set<string>): 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<string>();
const usedGroupIds = new Set<string>();
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,
};
}
46 changes: 46 additions & 0 deletions apps/server/src/persistence/domain/turnSummaries.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
]);
});
});
Loading