diff --git a/README.md b/README.md index 425a70e40410..a12ac23c8ae9 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,45 @@ T3 Code has a global runtime mode switch in the chat toolbar: - **Full access** (default): starts sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. - **Supervised**: starts sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. +## Sync engine migration modes + +The server supports feature-flagged sync-engine modes while migrating from the legacy in-house state sync pipeline to LiveStore. + +- `T3CODE_SYNC_ENGINE_MODE=livestore` (default) + - Uses the LiveStore-backed read path and disables delegate fallback (strict mirror-read mode). + - Mirror state is pre-bootstrapped from persisted catch-up history during server startup. +- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` + - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. + - Automatically falls back to legacy reads if the mirror is unavailable or errors. +- `T3CODE_SYNC_ENGINE_MODE=legacy` + - Uses the existing `PersistenceService`-backed state sync engine for reads and writes. +- `T3CODE_SYNC_ENGINE_MODE=shadow` + - Keeps legacy state as canonical, but mirrors committed `state.event` traffic into a LiveStore shadow store for parity validation. + +- `T3CODE_LIVESTORE_ENFORCE_MODE=1` + - Disallows `legacy` and `shadow` sync modes at startup. + - Intended for post-cutover environments to prevent accidental rollback to pre-LiveStore modes. + +Optional diagnostics: + +- `T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK=1` + - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.bootstrap` snapshots and logs drift diagnostics. +- `T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK=1` + - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.catchUp` responses and logs drift diagnostics. +- `T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK=1` + - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.listMessages` responses and logs drift diagnostics. +- `T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK=1` + - In `shadow` mode, compares mirror vs delegate `state.bootstrap` snapshots and logs drift diagnostics. +- `T3CODE_LIVESTORE_SHADOW_CATCHUP_PARITY_CHECK=1` + - In `shadow` mode, compares mirror vs delegate `state.catchUp` responses and logs drift diagnostics. +- `T3CODE_LIVESTORE_SHADOW_LIST_MESSAGES_PARITY_CHECK=1` + - In `shadow` mode, compares mirror vs delegate `state.listMessages` responses and logs drift diagnostics. +- `T3CODE_LIVESTORE_DISABLE_READ_FALLBACK=1` + - In `livestore-read-pilot` mode, disables delegate read fallback and fails requests when mirror reads fail (strict canary mode). + - Read-source/fallback counters are emitted on shutdown via `livestore read pilot metrics` logs. + +Web clients now always consume the server-authoritative `api.state.*` stream, and the server mode fully controls LiveStore vs fallback behavior. + ## Provider architecture The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md new file mode 100644 index 000000000000..89f11b189b28 --- /dev/null +++ b/SYNC_ENGINE_MIGRATION.md @@ -0,0 +1,80 @@ +# Sync Engine Migration Runbook + +This runbook describes how to operate and verify the LiveStore migration path safely. + +## Runtime modes + +Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: + +- `livestore` (default): strict LiveStore read path (delegate read fallback disabled). +- `livestore-read-pilot`: write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. +- `legacy`: legacy persistence service is canonical for reads/writes. +- `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. + +Startup behavior: + +- when running `shadow`, `livestore-read-pilot`, or `livestore`, the mirror is bootstrapped from persisted catch-up history before serving requests. + +Optional safety gate: + +- `T3CODE_LIVESTORE_ENFORCE_MODE=1` + - prevents startup in `legacy` or `shadow` mode. + - use in post-cutover environments to block accidental rollback. + +## Diagnostics flags + +- `T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK=1` + - In read-pilot mode, compares `state.bootstrap` mirror output against delegate output. +- `T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK=1` + - In read-pilot mode, compares `state.catchUp` mirror output against delegate output. +- `T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK=1` + - In read-pilot mode, compares `state.listMessages` mirror output against delegate output. +- `T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK=1` + - In shadow mode, compares `state.bootstrap` mirror output against delegate output. +- `T3CODE_LIVESTORE_SHADOW_CATCHUP_PARITY_CHECK=1` + - In shadow mode, compares `state.catchUp` mirror output against delegate output. +- `T3CODE_LIVESTORE_SHADOW_LIST_MESSAGES_PARITY_CHECK=1` + - In shadow mode, compares `state.listMessages` mirror output against delegate output. +- `T3CODE_LIVESTORE_DISABLE_READ_FALLBACK=1` + - In read-pilot mode, disables delegate read fallback for strict mirror-read canary validation. +- Web clients consume the server-authoritative `api.state.*` stream directly; server mode now controls fallback behavior. + +## Suggested rollout sequence + +1. **Baseline** + - Run in `legacy`. + - Ensure no outstanding state-sync regressions in CI. +2. **Shadow validation window** + - Enable `T3CODE_SYNC_ENGINE_MODE=shadow`. + - Watch logs for mirror commit failures. + - Validate parity fixtures and targeted tests. +3. **Read pilot** + - Enable `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot`. + - Start with parity flags enabled in staging. + - Verify bootstrap/catch-up/list-message fallback behavior under induced mirror failures. +4. **Confidence window** + - Keep parity flags enabled until drift warnings are consistently absent. + - Track fallback frequency (should trend toward zero in healthy conditions). + - Review `livestore read pilot metrics` shutdown logs for route-level source and fallback counts. + +## Phase-5 cleanup readiness checklist + +Before removing legacy-only sync plumbing: + +- [ ] Read-pilot mode has stable production/staging behavior for a full confidence window. +- [ ] Bootstrap and catch-up parity checks show no unexplained drift. +- [ ] Fallback-to-delegate behavior has been exercised and observed as safe. +- [ ] Web client state-source mode seam is present and default behavior remains backwards-compatible. +- [ ] Parity and integration tests cover: + - [ ] project/thread/message lifecycle parity + - [ ] checkpoint revert parity + - [ ] websocket bootstrap/catch-up ordering in read-pilot + - [ ] websocket read fallback under mirror failures + +## Final cutover notes + +When promoting LiveStore path to default: + +- keep legacy fallback for at least one release window; +- remove fallback only after confirming operational metrics and parity diagnostics remain healthy; +- remove legacy-only state sync glue in a dedicated cleanup PR to keep risk isolated and reviewable. diff --git a/apps/server/package.json b/apps/server/package.json index 59f41aa67800..dac66a4388a9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,8 @@ "test": "vitest run" }, "dependencies": { + "@livestore/adapter-node": "^0.3.1", + "@livestore/livestore": "^0.3.1", "@pierre/diffs": "^1.1.0-beta.16", "node-pty": "^1.1.0", "open": "^10.1.0", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index a8c8e5371bcc..de25ade5f56b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,7 +6,13 @@ import { fileURLToPath } from "node:url"; import { fixPath } from "./fixPath"; import { createLogger } from "./logger"; +import { bootstrapMirrorFromCatchUp } from "./livestore/mirrorBootstrap"; +import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; import { PersistenceService } from "./persistenceService"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; +import { assertSyncEngineModeAllowed, resolveSyncEngineMode } from "./syncEngineMode"; import { createServer } from "./wsServer"; fixPath(); @@ -110,10 +116,69 @@ async function main() { requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT)); const legacyStateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); const stateDbPath = resolveStateDbPath(process.env.T3CODE_STATE_DB_PATH); + const syncEngineMode = resolveSyncEngineMode(process.env.T3CODE_SYNC_ENGINE_MODE); + const enforceLiveStoreOnly = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_ENFORCE_MODE) ?? false; + assertSyncEngineModeAllowed(syncEngineMode, { + enforceLiveStoreOnly, + }); + const enableLiveStoreBootstrapParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK) ?? false; + const enableLiveStoreCatchUpParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK) ?? false; + const enableLiveStoreListMessagesParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK) ?? false; + const enableLiveStoreShadowBootstrapParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK) ?? false; + const enableLiveStoreShadowCatchUpParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_SHADOW_CATCHUP_PARITY_CHECK) ?? false; + const enableLiveStoreShadowListMessagesParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_SHADOW_LIST_MESSAGES_PARITY_CHECK) ?? false; + const disableLiveStoreDelegateReadFallback = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_DISABLE_READ_FALLBACK) ?? false; const persistenceService = new PersistenceService({ dbPath: stateDbPath, legacyProjectsJsonPath: path.join(legacyStateDir, "projects.json"), }); + const legacyStateSyncEngine = new LegacyStateSyncEngine({ persistenceService }); + const liveStoreMirror = new LiveStoreStateMirror({ + storeId: `t3-shadow-${mode}`, + }); + const isReadPilotMode = syncEngineMode === "livestore-read-pilot" || syncEngineMode === "livestore"; + if (syncEngineMode === "shadow" || isReadPilotMode) { + const bootstrapResult = await bootstrapMirrorFromCatchUp({ + source: legacyStateSyncEngine, + mirror: liveStoreMirror, + logger, + failOnError: syncEngineMode === "livestore", + }); + logger.info("bootstrapped livestore mirror from persisted catch-up history", { + mirroredCount: bootstrapResult.mirroredCount, + lastStateSeq: bootstrapResult.lastStateSeq, + complete: bootstrapResult.complete, + syncEngineMode, + }); + } + const stateSyncEngine = + syncEngineMode === "shadow" + ? new ShadowStateSyncEngine({ + delegate: legacyStateSyncEngine, + mirror: liveStoreMirror, + enableBootstrapParityCheck: enableLiveStoreShadowBootstrapParityCheck, + enableCatchUpParityCheck: enableLiveStoreShadowCatchUpParityCheck, + enableListMessagesParityCheck: enableLiveStoreShadowListMessagesParityCheck, + }) + : isReadPilotMode + ? new LiveStoreReadPilotStateSyncEngine({ + delegate: legacyStateSyncEngine, + mirror: liveStoreMirror, + enableBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, + enableCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, + enableListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, + disableDelegateReadFallback: + syncEngineMode === "livestore" ? true : disableLiveStoreDelegateReadFallback, + }) + : legacyStateSyncEngine; const devUrl = process.env.VITE_DEV_SERVER_URL; const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop"; const authToken = process.env.T3CODE_AUTH_TOKEN; @@ -132,6 +197,8 @@ async function main() { staticDir, devUrl, persistenceService, + stateSyncEngine, + syncEngineMode, authToken, }); await server.start(); @@ -143,6 +210,15 @@ async function main() { mode, stateDbPath, legacyStateDir, + syncEngineMode, + enforceLiveStoreOnly, + liveStoreBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, + liveStoreCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, + liveStoreListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, + liveStoreShadowBootstrapParityCheck: enableLiveStoreShadowBootstrapParityCheck, + liveStoreShadowCatchUpParityCheck: enableLiveStoreShadowCatchUpParityCheck, + liveStoreShadowListMessagesParityCheck: enableLiveStoreShadowListMessagesParityCheck, + liveStoreDisableReadFallback: disableLiveStoreDelegateReadFallback, authEnabled: Boolean(authToken), }); diff --git a/apps/server/src/livestore/liveStoreEngine.test.ts b/apps/server/src/livestore/liveStoreEngine.test.ts new file mode 100644 index 000000000000..f484647cab66 --- /dev/null +++ b/apps/server/src/livestore/liveStoreEngine.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import type { StateBootstrapResult, StateEvent } from "@t3tools/contracts"; +import { diffStateSnapshots, isStateSnapshotInParity } from "./parity"; +import { LiveStoreStateMirror } from "./liveStoreEngine"; + +function makeEvent(input: StateEvent): StateEvent { + return input; +} + +function makeExpectedSnapshot(): StateBootstrapResult { + return { + projects: [ + { + id: "project-1", + cwd: "/workspace/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:01.000Z", + }, + ], + threads: [ + { + id: "thread-1", + codexThreadId: null, + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:02.000Z", + branch: null, + worktreePath: null, + turnDiffSummaries: [ + { + turnId: "turn-1", + completedAt: "2026-02-20T00:00:04.000Z", + files: [], + }, + ], + messages: [ + { + id: "message-1", + threadId: "thread-1", + role: "user", + text: "hello", + createdAt: "2026-02-20T00:00:03.000Z", + updatedAt: "2026-02-20T00:00:03.000Z", + streaming: false, + }, + ], + }, + ], + lastStateSeq: 4, + }; +} + +describe("LiveStoreStateMirror", () => { + it("projects mirrored state events into a parity snapshot", async () => { + const mirror = new LiveStoreStateMirror({ storeId: "shadow-parity-test" }); + const events: StateEvent[] = [ + makeEvent({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + createdAt: "2026-02-20T00:00:01.000Z", + payload: { + project: { + id: "project-1", + cwd: "/workspace/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:01.000Z", + }, + }, + }), + makeEvent({ + seq: 2, + eventType: "thread.upsert", + entityId: "thread-1", + createdAt: "2026-02-20T00:00:02.000Z", + payload: { + thread: { + id: "thread-1", + codexThreadId: null, + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:02.000Z", + branch: null, + worktreePath: null, + }, + }, + }), + makeEvent({ + seq: 3, + eventType: "message.upsert", + entityId: "thread-1:message-1", + createdAt: "2026-02-20T00:00:03.000Z", + payload: { + threadId: "thread-1", + message: { + id: "message-1", + threadId: "thread-1", + role: "user", + text: "hello", + createdAt: "2026-02-20T00:00:03.000Z", + updatedAt: "2026-02-20T00:00:03.000Z", + streaming: false, + }, + }, + }), + makeEvent({ + seq: 4, + eventType: "turn_summary.upsert", + entityId: "thread-1:turn-1", + createdAt: "2026-02-20T00:00:04.000Z", + payload: { + threadId: "thread-1", + turnSummary: { + turnId: "turn-1", + completedAt: "2026-02-20T00:00:04.000Z", + files: [], + }, + }, + }), + ]; + + try { + for (const event of events) { + await mirror.mirrorStateEvent(event); + } + + const expected = makeExpectedSnapshot(); + const actual = mirror.debugReadSnapshot(); + expect(diffStateSnapshots(expected, actual)).toEqual([]); + expect(isStateSnapshotInParity(expected, actual)).toBe(true); + } finally { + await mirror.dispose(); + } + }); + + it("handles delete events in mirrored projections", async () => { + const mirror = new LiveStoreStateMirror({ storeId: "shadow-delete-test" }); + try { + await mirror.mirrorStateEvent( + makeEvent({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + createdAt: "2026-02-20T00:00:00.000Z", + payload: { + project: { + id: "project-1", + cwd: "/workspace/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + }, + }), + ); + await mirror.mirrorStateEvent( + makeEvent({ + seq: 2, + eventType: "project.delete", + entityId: "project-1", + createdAt: "2026-02-20T00:00:01.000Z", + payload: { projectId: "project-1" }, + }), + ); + + const snapshot = mirror.debugReadSnapshot(); + expect(snapshot.projects).toEqual([]); + expect(snapshot.threads).toEqual([]); + expect(snapshot.lastStateSeq).toBe(2); + } finally { + await mirror.dispose(); + } + }); +}); diff --git a/apps/server/src/livestore/liveStoreEngine.ts b/apps/server/src/livestore/liveStoreEngine.ts new file mode 100644 index 000000000000..9d22e38daa5e --- /dev/null +++ b/apps/server/src/livestore/liveStoreEngine.ts @@ -0,0 +1,323 @@ +import { makeAdapter } from "@livestore/adapter-node"; +import { createStorePromise } from "@livestore/livestore"; +import { + stateBootstrapResultSchema, + stateCatchUpResultSchema, + stateListMessagesResultSchema, + stateMessageSchema, + stateProjectSchema, + stateThreadSchema, + stateTurnSummarySchema, + type StateBootstrapResult, + type StateCatchUpResult, + type StateEvent, + type StateListMessagesInput, + type StateListMessagesResult, +} from "@t3tools/contracts"; +import { createLogger } from "../logger"; +import type { StateEventMirror } from "../stateSyncEngineShadow"; +import { liveStoreShadowSchema } from "./materializers"; +import { liveStoreShadowEvents } from "./schema"; + +export interface LiveStoreStateMirrorOptions { + enabled?: boolean; + storeId?: string; +} + +interface LiveStoreShadowStore { + commit: (event: unknown) => unknown; + shutdown?: () => unknown; + shutdownPromise?: () => Promise; +} + +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; +} + +export class LiveStoreStateMirror implements StateEventMirror { + private readonly enabled: boolean; + private readonly storeId: string; + private readonly logger = createLogger("livestore-shadow"); + private store: LiveStoreShadowStore | null = null; + private storePromise: Promise | null = null; + private lastMirroredSeq = 0; + private disposed = false; + private readonly projectsById = new Map(); + private readonly threadsById = new Map(); + private readonly threadMessagesByThreadId = new Map< + string, + Map + >(); + private readonly turnSummariesByThreadId = new Map< + string, + Map + >(); + private readonly mirroredEventsBySeq = new Map(); + + constructor(options: LiveStoreStateMirrorOptions = {}) { + this.enabled = options.enabled ?? true; + this.storeId = options.storeId ?? "t3-shadow-sync"; + } + + async mirrorStateEvent(event: StateEvent): Promise { + if (!this.enabled || this.disposed) { + return false; + } + if (event.seq <= this.lastMirroredSeq) { + return true; + } + + const store = await this.getStore(); + if (!store) { + return false; + } + + try { + store.commit( + liveStoreShadowEvents.stateEventMirrored({ + seq: event.seq, + eventType: event.eventType, + entityId: event.entityId, + payloadJson: JSON.stringify(event.payload), + createdAt: event.createdAt, + }, "shadow"), + ); + this.applyProjection(event); + this.lastMirroredSeq = event.seq; + return true; + } catch (error) { + this.logger.warn("failed to commit mirrored state event", { + error, + seq: event.seq, + eventType: event.eventType, + }); + return false; + } + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + const store = this.store; + this.store = null; + this.storePromise = null; + this.lastMirroredSeq = 0; + this.projectsById.clear(); + this.threadsById.clear(); + this.threadMessagesByThreadId.clear(); + this.turnSummariesByThreadId.clear(); + this.mirroredEventsBySeq.clear(); + if (!store) { + return; + } + try { + if (typeof store.shutdownPromise === "function") { + await store.shutdownPromise(); + } else if (typeof store.shutdown === "function") { + const maybeResult = store.shutdown(); + if (maybeResult && typeof (maybeResult as PromiseLike).then === "function") { + await maybeResult; + } + } + } catch (error) { + this.logger.warn("failed to shutdown livestore shadow store", { error }); + } + } + + private async getStore(): Promise { + if (!this.enabled || this.disposed) { + return null; + } + + if (!this.storePromise) { + const adapter = makeAdapter({ + // Shadow mode is intended to validate event parity first. + // Persisted storage can be enabled later during cutover. + storage: { type: "in-memory" }, + }); + this.storePromise = createStorePromise({ + adapter, + schema: liveStoreShadowSchema, + storeId: this.storeId, + }) + .then((store) => { + const castStore = store as unknown as LiveStoreShadowStore; + this.store = castStore; + this.logger.info("initialized livestore shadow store", { + storeId: this.storeId, + }); + return castStore; + }) + .catch((error) => { + this.logger.warn("failed to initialize livestore shadow store", { + error, + storeId: this.storeId, + }); + this.storePromise = null; + return Promise.reject(error); + }); + } + + try { + return await this.storePromise; + } catch { + return null; + } + } + + debugReadSnapshot(): StateBootstrapResult { + const projects = Array.from(this.projectsById.values()); + const threads = Array.from(this.threadsById.values()).map((thread) => { + const messagesById = this.threadMessagesByThreadId.get(thread.id); + const turnSummariesByTurnId = this.turnSummariesByThreadId.get(thread.id); + return { + ...thread, + messages: messagesById + ? Array.from(messagesById.values()).toSorted((a, b) => { + if (a.createdAt === b.createdAt) { + return a.id.localeCompare(b.id); + } + return a.createdAt.localeCompare(b.createdAt); + }) + : [], + turnDiffSummaries: turnSummariesByTurnId + ? Array.from(turnSummariesByTurnId.values()).toSorted((a, b) => + b.completedAt.localeCompare(a.completedAt), + ) + : [], + }; + }); + + return stateBootstrapResultSchema.parse({ + projects, + threads, + lastStateSeq: this.lastMirroredSeq, + }); + } + + debugCatchUp(afterSeq: number): StateCatchUpResult { + const events = Array.from(this.mirroredEventsBySeq.entries()) + .filter(([seq]) => seq > afterSeq) + .sort(([a], [b]) => a - b) + .map(([, event]) => event); + return stateCatchUpResultSchema.parse({ + events, + lastStateSeq: this.lastMirroredSeq, + }); + } + + debugListMessages(raw: StateListMessagesInput): StateListMessagesResult { + const snapshot = this.debugReadSnapshot(); + const thread = snapshot.threads.find((candidate) => candidate.id === raw.threadId); + const messages = thread?.messages ?? []; + const offset = Math.max(0, Math.floor(raw.offset ?? 0)); + const limit = Math.max(1, Math.floor(raw.limit ?? 200)); + const paged = messages.slice(offset, offset + limit); + const nextOffset = offset + paged.length; + return stateListMessagesResultSchema.parse({ + messages: paged, + total: messages.length, + nextOffset: nextOffset < messages.length ? nextOffset : null, + }); + } + + private applyProjection(event: StateEvent): void { + this.mirroredEventsBySeq.set(event.seq, event); + const payload = asObject(event.payload); + + if (event.eventType === "project.upsert") { + const candidate = stateProjectSchema.safeParse(payload?.project); + if (candidate.success) { + this.projectsById.set(candidate.data.id, candidate.data); + } + return; + } + + if (event.eventType === "project.delete") { + const projectId = asString(payload?.projectId) ?? event.entityId; + this.projectsById.delete(projectId); + for (const [threadId, thread] of this.threadsById.entries()) { + if (thread.projectId !== projectId) { + continue; + } + this.threadsById.delete(threadId); + this.threadMessagesByThreadId.delete(threadId); + this.turnSummariesByThreadId.delete(threadId); + } + return; + } + + if (event.eventType === "thread.upsert") { + const candidate = stateThreadSchema.safeParse(payload?.thread); + if (!candidate.success) { + return; + } + this.threadsById.set(candidate.data.id, { + ...candidate.data, + messages: [], + turnDiffSummaries: [], + }); + return; + } + + if (event.eventType === "thread.delete") { + const threadId = asString(payload?.threadId) ?? event.entityId; + this.threadsById.delete(threadId); + this.threadMessagesByThreadId.delete(threadId); + this.turnSummariesByThreadId.delete(threadId); + return; + } + + if (event.eventType === "message.upsert") { + const threadId = asString(payload?.threadId); + const messageCandidate = stateMessageSchema.safeParse(payload?.message); + if (!threadId || !messageCandidate.success) { + return; + } + const byId = this.threadMessagesByThreadId.get(threadId) ?? new Map(); + byId.set(messageCandidate.data.id, messageCandidate.data); + this.threadMessagesByThreadId.set(threadId, byId); + return; + } + + if (event.eventType === "message.delete") { + const threadId = asString(payload?.threadId); + const messageId = asString(payload?.messageId); + if (!threadId || !messageId) { + return; + } + this.threadMessagesByThreadId.get(threadId)?.delete(messageId); + return; + } + + if (event.eventType === "turn_summary.upsert") { + const threadId = asString(payload?.threadId); + const summaryCandidate = stateTurnSummarySchema.safeParse(payload?.turnSummary); + if (!threadId || !summaryCandidate.success) { + return; + } + const byTurnId = this.turnSummariesByThreadId.get(threadId) ?? new Map(); + byTurnId.set(summaryCandidate.data.turnId, summaryCandidate.data); + this.turnSummariesByThreadId.set(threadId, byTurnId); + return; + } + + if (event.eventType === "turn_summary.delete") { + const threadId = asString(payload?.threadId); + const turnId = asString(payload?.turnId); + if (!threadId || !turnId) { + return; + } + this.turnSummariesByThreadId.get(threadId)?.delete(turnId); + } + } +} diff --git a/apps/server/src/livestore/materializers.ts b/apps/server/src/livestore/materializers.ts new file mode 100644 index 000000000000..b58447594198 --- /dev/null +++ b/apps/server/src/livestore/materializers.ts @@ -0,0 +1,16 @@ +import { makeSchema, State } from "@livestore/livestore"; +import { liveStoreShadowEvents, shadowMirrorStatus } from "./schema"; + +export const liveStoreShadowMaterializers = {}; + +export const liveStoreShadowState = State.SQLite.makeState({ + tables: { + shadowMirrorStatus, + }, + materializers: liveStoreShadowMaterializers, +}); + +export const liveStoreShadowSchema = makeSchema({ + events: liveStoreShadowEvents, + state: liveStoreShadowState, +}); diff --git a/apps/server/src/livestore/mirrorBootstrap.test.ts b/apps/server/src/livestore/mirrorBootstrap.test.ts new file mode 100644 index 000000000000..1a8769802922 --- /dev/null +++ b/apps/server/src/livestore/mirrorBootstrap.test.ts @@ -0,0 +1,208 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLogger } from "../logger"; +import { PersistenceService } from "../persistenceService"; +import { LegacyStateSyncEngine } from "../stateSyncEngineLegacy"; +import { bootstrapMirrorFromCatchUp } from "./mirrorBootstrap"; +import { LiveStoreStateMirror } from "./liveStoreEngine"; +import { diffStateSnapshots } from "./parity"; + +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 }); + } +}); + +describe("bootstrapMirrorFromCatchUp", () => { + it("mirrors catch-up history into the mirror sink", async () => { + const mirrorStateEvent = vi.fn(); + const source = { + catchUp: vi.fn().mockReturnValue({ + events: [ + { + seq: 1, + entityId: "project-1", + eventType: "project.upsert", + payload: { id: "project-1", name: "Demo", path: "/demo" }, + createdAt: new Date().toISOString(), + }, + { + seq: 2, + entityId: "thread-1", + eventType: "thread.upsert", + payload: { id: "thread-1", title: "Thread" }, + createdAt: new Date().toISOString(), + }, + ], + lastStateSeq: 2, + }), + }; + + const result = await bootstrapMirrorFromCatchUp({ + source, + mirror: { mirrorStateEvent }, + logger: createLogger("mirror-bootstrap-test"), + }); + + expect(result).toEqual({ + mirroredCount: 2, + lastStateSeq: 2, + complete: true, + }); + expect(mirrorStateEvent).toHaveBeenCalledTimes(2); + }); + + it("returns partial progress when non-fatal mirroring errors occur", async () => { + const mirrorStateEvent = vi + .fn() + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => { + throw new Error("mirror write failed"); + }); + const source = { + catchUp: vi.fn().mockReturnValue({ + events: [ + { + seq: 1, + entityId: "project-1", + eventType: "project.upsert", + payload: { id: "project-1", name: "Demo", path: "/demo" }, + createdAt: new Date().toISOString(), + }, + { + seq: 2, + entityId: "thread-1", + eventType: "thread.upsert", + payload: { id: "thread-1", title: "Thread" }, + createdAt: new Date().toISOString(), + }, + ], + lastStateSeq: 2, + }), + }; + + const result = await bootstrapMirrorFromCatchUp({ + source, + mirror: { mirrorStateEvent }, + logger: createLogger("mirror-bootstrap-test"), + }); + + expect(result).toEqual({ + mirroredCount: 1, + lastStateSeq: 2, + complete: false, + }); + expect(mirrorStateEvent).toHaveBeenCalledTimes(2); + }); + + it("throws when failOnError is enabled", async () => { + const mirrorStateEvent = vi.fn().mockImplementation(() => { + throw new Error("mirror write failed"); + }); + const source = { + catchUp: vi.fn().mockReturnValue({ + events: [ + { + seq: 1, + entityId: "project-1", + eventType: "project.upsert", + payload: { id: "project-1", name: "Demo", path: "/demo" }, + createdAt: new Date().toISOString(), + }, + ], + lastStateSeq: 1, + }), + }; + + await expect( + bootstrapMirrorFromCatchUp({ + source, + mirror: { mirrorStateEvent }, + logger: createLogger("mirror-bootstrap-test"), + failOnError: true, + }), + ).rejects.toThrow(/failed to bootstrap livestore mirror/i); + }); + + it("treats mirrorStateEvent false return as a bootstrap failure", async () => { + const source = { + catchUp: vi.fn().mockReturnValue({ + events: [ + { + seq: 1, + entityId: "project-1", + eventType: "project.upsert", + payload: { id: "project-1", name: "Demo", path: "/demo" }, + createdAt: new Date().toISOString(), + }, + ], + lastStateSeq: 1, + }), + }; + + const result = await bootstrapMirrorFromCatchUp({ + source, + mirror: { mirrorStateEvent: vi.fn().mockResolvedValue(false) }, + logger: createLogger("mirror-bootstrap-test"), + }); + + expect(result).toEqual({ + mirroredCount: 0, + lastStateSeq: 1, + complete: false, + }); + }); + + it("replays persisted legacy catch-up history into a real LiveStore mirror", async () => { + const stateDir = makeTempDir("t3code-bootstrap-replay-state-"); + const projectDir = makeTempDir("t3code-bootstrap-replay-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new LiveStoreStateMirror({ storeId: "bootstrap-replay-parity-test" }); + + try { + const project = legacy.addProject({ cwd: projectDir }).project; + const thread = legacy.createThread({ + projectId: project.id, + title: "Bootstrap replay parity thread", + }).thread; + service.bindSessionToThread("bootstrap-replay-session", thread.id, "runtime-thread-bootstrap"); + service.persistUserMessageForTurn({ + sessionId: "bootstrap-replay-session", + clientMessageId: "bootstrap-message-1", + clientMessageText: "bootstrap replay parity", + input: "bootstrap replay parity", + attachments: [], + }); + + const expectedSnapshot = legacy.loadSnapshot(); + const bootstrapResult = await bootstrapMirrorFromCatchUp({ + source: legacy, + mirror, + logger: createLogger("mirror-bootstrap-test"), + failOnError: true, + }); + + expect(bootstrapResult.complete).toBe(true); + expect(bootstrapResult.lastStateSeq).toBe(expectedSnapshot.lastStateSeq); + expect(bootstrapResult.mirroredCount).toBeGreaterThan(0); + expect(diffStateSnapshots(expectedSnapshot, mirror.debugReadSnapshot())).toEqual([]); + } finally { + await mirror.dispose(); + service.close(); + } + }); +}); diff --git a/apps/server/src/livestore/mirrorBootstrap.ts b/apps/server/src/livestore/mirrorBootstrap.ts new file mode 100644 index 000000000000..8ac6e418c33d --- /dev/null +++ b/apps/server/src/livestore/mirrorBootstrap.ts @@ -0,0 +1,60 @@ +import type { StateCatchUpResult, StateEvent } from "@t3tools/contracts"; + +interface CatchUpSource { + catchUp(input: { afterSeq: number }): StateCatchUpResult; +} + +interface MirrorSink { + mirrorStateEvent(event: StateEvent): void | boolean | Promise; +} + +interface LoggerLike { + warn(message: string, context?: Record): void; +} + +export interface MirrorBootstrapResult { + mirroredCount: number; + lastStateSeq: number; + complete: boolean; +} + +export async function bootstrapMirrorFromCatchUp(options: { + source: CatchUpSource; + mirror: MirrorSink; + logger: LoggerLike; + failOnError?: boolean; +}): Promise { + const { source, mirror, logger, failOnError = false } = options; + const catchUpResult = source.catchUp({ afterSeq: 0 }); + let mirroredCount = 0; + for (const event of catchUpResult.events) { + try { + const mirrorResult = await mirror.mirrorStateEvent(event); + if (mirrorResult === false) { + throw new Error("mirrorStateEvent returned false"); + } + mirroredCount += 1; + } catch (error) { + if (failOnError) { + throw new Error("failed to bootstrap livestore mirror from catch-up history", { + cause: error, + }); + } + logger.warn("failed to bootstrap livestore mirror event; continuing with partial mirror state", { + error, + eventSeq: event.seq, + mirroredCount, + }); + return { + mirroredCount, + lastStateSeq: catchUpResult.lastStateSeq, + complete: false, + }; + } + } + return { + mirroredCount, + lastStateSeq: catchUpResult.lastStateSeq, + complete: true, + }; +} diff --git a/apps/server/src/livestore/parity.test.ts b/apps/server/src/livestore/parity.test.ts new file mode 100644 index 000000000000..fea291be1c94 --- /dev/null +++ b/apps/server/src/livestore/parity.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import type { StateBootstrapResult } from "@t3tools/contracts"; +import { + diffCatchUpResults, + diffListMessagesResults, + diffStateSnapshots, + isStateSnapshotInParity, +} from "./parity"; + +function makeSnapshot(overrides: Partial = {}): StateBootstrapResult { + return { + projects: [], + threads: [], + lastStateSeq: 0, + ...overrides, + }; +} + +describe("diffStateSnapshots", () => { + it("treats snapshots with different ordering as parity-equal", () => { + const projectA = { + id: "project-a", + cwd: "/workspace/a", + name: "A", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }; + const projectB = { + id: "project-b", + cwd: "/workspace/b", + name: "B", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }; + const threadA = { + id: "thread-a", + codexThreadId: null, + projectId: "project-a", + title: "Thread A", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + branch: null, + worktreePath: null, + turnDiffSummaries: [ + { + turnId: "turn-2", + completedAt: "2026-02-20T00:00:02.000Z", + files: [], + }, + { + turnId: "turn-1", + completedAt: "2026-02-20T00:00:01.000Z", + files: [], + }, + ], + messages: [ + { + id: "msg-2", + threadId: "thread-a", + role: "assistant" as const, + text: "hello", + createdAt: "2026-02-20T00:00:02.000Z", + updatedAt: "2026-02-20T00:00:02.000Z", + streaming: false, + }, + { + id: "msg-1", + threadId: "thread-a", + role: "user" as const, + text: "hi", + createdAt: "2026-02-20T00:00:01.000Z", + updatedAt: "2026-02-20T00:00:01.000Z", + streaming: false, + }, + ], + }; + + const expected = makeSnapshot({ + projects: [projectA, projectB], + threads: [threadA], + lastStateSeq: 10, + }); + const actual = makeSnapshot({ + projects: [projectB, projectA], + threads: [ + { + ...threadA, + turnDiffSummaries: [...threadA.turnDiffSummaries].toReversed(), + messages: [...threadA.messages].toReversed(), + }, + ], + lastStateSeq: 10, + }); + + expect(diffStateSnapshots(expected, actual)).toEqual([]); + expect(isStateSnapshotInParity(expected, actual)).toBe(true); + }); + + it("reports path-qualified diffs when snapshots diverge", () => { + const expected = makeSnapshot({ + projects: [ + { + id: "project-a", + cwd: "/workspace/a", + name: "Project A", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 5, + }); + const actual = makeSnapshot({ + projects: [ + { + id: "project-a", + cwd: "/workspace/a", + name: "Project A renamed", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 7, + }); + + const diffs = diffStateSnapshots(expected, actual); + expect(diffs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "$.lastStateSeq", + expected: 5, + actual: 7, + }), + expect.objectContaining({ + path: "$.projects[0].name", + expected: "Project A", + actual: "Project A renamed", + }), + ]), + ); + expect(isStateSnapshotInParity(expected, actual)).toBe(false); + }); +}); + +describe("diffCatchUpResults", () => { + it("returns empty diff when catch-up payloads are equal", () => { + const catchUp = { + events: [ + { + seq: 1, + eventType: "project.upsert" as const, + entityId: "project-1", + payload: { project: { id: "project-1", name: "Demo" } }, + createdAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 1, + }; + + expect(diffCatchUpResults(catchUp, catchUp)).toEqual([]); + }); + + it("returns targeted diffs for catch-up payload drift", () => { + const expected = { + events: [ + { + seq: 1, + eventType: "project.upsert" as const, + entityId: "project-1", + payload: { project: { id: "project-1", name: "Demo" } }, + createdAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 1, + }; + const actual = { + events: [ + { + seq: 2, + eventType: "project.delete" as const, + entityId: "project-2", + payload: { projectId: "project-2" }, + createdAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 2, + }; + + expect(diffCatchUpResults(expected, actual)).toEqual( + expect.arrayContaining([ + "lastStateSeq mismatch: expected=1 actual=2", + "events[0].seq mismatch: expected=1 actual=2", + "events[0].eventType mismatch: expected=project.upsert actual=project.delete", + "events[0].entityId mismatch: expected=project-1 actual=project-2", + "events[0].payload mismatch", + ]), + ); + }); +}); + +describe("diffListMessagesResults", () => { + it("returns empty diff for equivalent list-messages responses", () => { + const result = { + messages: [ + { + id: "message-1", + threadId: "thread-1", + role: "user" as const, + text: "hello", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + streaming: false, + }, + ], + total: 1, + nextOffset: null, + }; + expect(diffListMessagesResults(result, result)).toEqual([]); + }); + + it("returns targeted diffs for list-messages drift", () => { + const expected = { + messages: [], + total: 1, + nextOffset: 1, + }; + const actual = { + messages: [ + { + id: "message-2", + threadId: "thread-1", + role: "assistant" as const, + text: "hi", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + streaming: false, + }, + ], + total: 2, + nextOffset: null, + }; + + expect(diffListMessagesResults(expected, actual)).toEqual( + expect.arrayContaining([ + "total mismatch: expected=1 actual=2", + "nextOffset mismatch: expected=1 actual=null", + "messages.length mismatch: expected=0 actual=1", + ]), + ); + }); +}); diff --git a/apps/server/src/livestore/parity.ts b/apps/server/src/livestore/parity.ts new file mode 100644 index 000000000000..64af1375a65c --- /dev/null +++ b/apps/server/src/livestore/parity.ts @@ -0,0 +1,180 @@ +import type { + StateBootstrapResult, + StateCatchUpResult, + StateListMessagesResult, +} from "@t3tools/contracts"; + +export interface SnapshotParityDiff { + path: string; + expected: unknown; + actual: unknown; +} + +function sortById(items: readonly T[]): T[] { + return [...items].sort((a, b) => a.id.localeCompare(b.id)); +} + +function normalizeSnapshot(snapshot: StateBootstrapResult): StateBootstrapResult { + return { + ...snapshot, + projects: sortById(snapshot.projects), + threads: sortById(snapshot.threads).map((thread) => ({ + ...thread, + messages: [...thread.messages].sort((a, b) => { + if (a.id === b.id) { + return a.createdAt.localeCompare(b.createdAt); + } + return a.id.localeCompare(b.id); + }), + turnDiffSummaries: [...thread.turnDiffSummaries].sort((a, b) => + a.turnId.localeCompare(b.turnId), + ), + })), + }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function collectDiffs( + expected: unknown, + actual: unknown, + path: string, + diffs: SnapshotParityDiff[], +): void { + if (Object.is(expected, actual)) { + return; + } + + if (Array.isArray(expected) || Array.isArray(actual)) { + if (!Array.isArray(expected) || !Array.isArray(actual)) { + diffs.push({ path, expected, actual }); + return; + } + if (expected.length !== actual.length) { + diffs.push({ + path: `${path}.length`, + expected: expected.length, + actual: actual.length, + }); + return; + } + for (let index = 0; index < expected.length; index += 1) { + collectDiffs(expected[index], actual[index], `${path}[${index}]`, diffs); + } + return; + } + + if (isRecord(expected) || isRecord(actual)) { + if (!isRecord(expected) || !isRecord(actual)) { + diffs.push({ path, expected, actual }); + return; + } + const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]); + for (const key of [...keys].sort((a, b) => a.localeCompare(b))) { + collectDiffs(expected[key], actual[key], `${path}.${key}`, diffs); + } + return; + } + + diffs.push({ path, expected, actual }); +} + +export function diffStateSnapshots( + expectedSnapshot: StateBootstrapResult, + actualSnapshot: StateBootstrapResult, +): SnapshotParityDiff[] { + const expected = normalizeSnapshot(expectedSnapshot); + const actual = normalizeSnapshot(actualSnapshot); + const diffs: SnapshotParityDiff[] = []; + collectDiffs(expected, actual, "$", diffs); + return diffs; +} + +export function isStateSnapshotInParity( + expectedSnapshot: StateBootstrapResult, + actualSnapshot: StateBootstrapResult, +): boolean { + return diffStateSnapshots(expectedSnapshot, actualSnapshot).length === 0; +} + +export function diffCatchUpResults(expected: StateCatchUpResult, actual: StateCatchUpResult): string[] { + const diffs: string[] = []; + + if (expected.lastStateSeq !== actual.lastStateSeq) { + diffs.push( + `lastStateSeq mismatch: expected=${expected.lastStateSeq} actual=${actual.lastStateSeq}`, + ); + } + + if (expected.events.length !== actual.events.length) { + diffs.push(`events.length mismatch: expected=${expected.events.length} actual=${actual.events.length}`); + } + + const minLength = Math.min(expected.events.length, actual.events.length); + for (let index = 0; index < minLength; index += 1) { + const expectedEvent = expected.events[index]; + const actualEvent = actual.events[index]; + if (!expectedEvent || !actualEvent) { + continue; + } + if (expectedEvent.seq !== actualEvent.seq) { + diffs.push(`events[${index}].seq mismatch: expected=${expectedEvent.seq} actual=${actualEvent.seq}`); + } + if (expectedEvent.eventType !== actualEvent.eventType) { + diffs.push( + `events[${index}].eventType mismatch: expected=${expectedEvent.eventType} actual=${actualEvent.eventType}`, + ); + } + if (expectedEvent.entityId !== actualEvent.entityId) { + diffs.push( + `events[${index}].entityId mismatch: expected=${expectedEvent.entityId} actual=${actualEvent.entityId}`, + ); + } + const expectedPayload = JSON.stringify(expectedEvent.payload); + const actualPayload = JSON.stringify(actualEvent.payload); + if (expectedPayload !== actualPayload) { + diffs.push(`events[${index}].payload mismatch`); + } + } + + return diffs; +} + +export function diffListMessagesResults( + expected: StateListMessagesResult, + actual: StateListMessagesResult, +): string[] { + const diffs: string[] = []; + + if (expected.total !== actual.total) { + diffs.push(`total mismatch: expected=${expected.total} actual=${actual.total}`); + } + if (expected.nextOffset !== actual.nextOffset) { + diffs.push( + `nextOffset mismatch: expected=${String(expected.nextOffset)} actual=${String(actual.nextOffset)}`, + ); + } + if (expected.messages.length !== actual.messages.length) { + diffs.push( + `messages.length mismatch: expected=${expected.messages.length} actual=${actual.messages.length}`, + ); + } + + const minLength = Math.min(expected.messages.length, actual.messages.length); + for (let index = 0; index < minLength; index += 1) { + const expectedMessage = expected.messages[index]; + const actualMessage = actual.messages[index]; + if (!expectedMessage || !actualMessage) { + continue; + } + const expectedSerialized = JSON.stringify(expectedMessage); + const actualSerialized = JSON.stringify(actualMessage); + if (expectedSerialized !== actualSerialized) { + diffs.push(`messages[${index}] mismatch`); + } + } + + return diffs; +} diff --git a/apps/server/src/livestore/schema.ts b/apps/server/src/livestore/schema.ts new file mode 100644 index 000000000000..6583825a630a --- /dev/null +++ b/apps/server/src/livestore/schema.ts @@ -0,0 +1,25 @@ +import { Schema, State } from "@livestore/livestore"; + +export const shadowMirrorStatus = State.SQLite.clientDocument({ + name: "shadowMirrorStatus", + schema: Schema.Struct({ + seq: Schema.Number, + eventType: Schema.String, + entityId: Schema.String, + payloadJson: Schema.String, + createdAt: Schema.String, + }), + default: { + value: { + seq: 0, + eventType: "", + entityId: "", + payloadJson: "{}", + createdAt: new Date(0).toISOString(), + }, + }, +}); + +export const liveStoreShadowEvents = { + stateEventMirrored: shadowMirrorStatus.set, +} as const; diff --git a/apps/server/src/persistenceService.test.ts b/apps/server/src/persistenceService.test.ts index 88fd74444a4c..bee57a7e78a5 100644 --- a/apps/server/src/persistenceService.test.ts +++ b/apps/server/src/persistenceService.test.ts @@ -5,7 +5,11 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { ProviderEvent } from "@t3tools/contracts"; +import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import { diffStateSnapshots } from "./livestore/parity"; import { PersistenceService } from "./persistenceService"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; const tempDirs: string[] = []; const ISO_BASE_MS = Date.parse("2026-02-19T00:00:00.000Z"); @@ -20,6 +24,20 @@ function iso(offsetMs = 0): string { return new Date(ISO_BASE_MS + offsetMs).toISOString(); } +async function waitForMirrorParity( + readExpected: () => ReturnType, + readMirror: () => ReturnType, +): Promise { + const maxAttempts = 40; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (diffStateSnapshots(readExpected(), readMirror()).length === 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 15)); + } + expect(diffStateSnapshots(readExpected(), readMirror())).toEqual([]); +} + afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -446,4 +464,78 @@ describe("PersistenceService", () => { } }); + it("maintains shadow mirror parity across checkpoint revert effects", async () => { + const stateDir = makeTempDir("t3code-persistence-shadow-parity-state-"); + const projectDir = makeTempDir("t3code-persistence-shadow-parity-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new LiveStoreStateMirror({ storeId: "persistence-shadow-parity-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + const project = shadow.addProject({ cwd: projectDir }).project; + const thread = shadow.createThread({ + projectId: project.id, + title: "checkpoint parity thread", + model: "gpt-5.3-codex", + }).thread; + service.bindSessionToThread("sess-shadow", thread.id, "runtime-thread-shadow"); + + service.persistUserMessageForTurn({ + sessionId: "sess-shadow", + clientMessageId: "msg-1", + clientMessageText: "first", + input: "first", + attachments: [], + }); + service.persistUserMessageForTurn({ + sessionId: "sess-shadow", + clientMessageId: "msg-2", + clientMessageText: "second", + input: "second", + attachments: [], + }); + + service.persistTurnDiffSummaryFromCheckpoint({ + sessionId: "sess-shadow", + runtimeThreadId: "runtime-thread-shadow", + turnId: "turn-1", + checkpointTurnCount: 1, + completedAt: iso(100), + diff: "", + }); + service.persistTurnDiffSummaryFromCheckpoint({ + sessionId: "sess-shadow", + runtimeThreadId: "runtime-thread-shadow", + turnId: "turn-2", + checkpointTurnCount: 2, + completedAt: iso(200), + diff: "", + }); + + shadow.applyCheckpointRevert({ + sessionId: "sess-shadow", + runtimeThreadId: "runtime-thread-shadow", + turnCount: 1, + messageCount: 1, + }); + + await waitForMirrorParity(() => legacy.loadSnapshot(), () => mirror.debugReadSnapshot()); + + const mirrored = mirror.debugReadSnapshot(); + expect(mirrored.threads[0]?.messages.map((message) => message.id)).toEqual(["msg-1"]); + expect(mirrored.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ + "turn-1", + ]); + expect(diffStateSnapshots(legacy.loadSnapshot(), mirrored)).toEqual([]); + } finally { + shadow.close(); + } + }); + }); diff --git a/apps/server/src/stateSyncEngine.ts b/apps/server/src/stateSyncEngine.ts new file mode 100644 index 000000000000..89d3db865361 --- /dev/null +++ b/apps/server/src/stateSyncEngine.ts @@ -0,0 +1,53 @@ +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; + +export interface ApplyCheckpointRevertInput { + sessionId: string; + runtimeThreadId: string; + turnCount: number; + messageCount: number; +} + +export interface StateSyncEngine { + onStateEvent(listener: (event: StateEvent) => void): () => void; + loadSnapshot(): StateBootstrapResult; + listMessages(raw: StateListMessagesInput): StateListMessagesResult; + catchUp(raw: StateCatchUpInput): StateCatchUpResult; + getAppSettings(): AppSettings; + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings; + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult; + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult; + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult; + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult; + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult; + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult; + deleteThread(raw: ThreadsDeleteInput): void; + listProjects(): ProjectListResult; + addProject(raw: ProjectAddInput): ProjectAddResult; + removeProject(raw: ProjectRemoveInput): void; + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult; + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void; + close(): void; +} diff --git a/apps/server/src/stateSyncEngineLegacy.ts b/apps/server/src/stateSyncEngineLegacy.ts new file mode 100644 index 000000000000..26c42b54e3de --- /dev/null +++ b/apps/server/src/stateSyncEngineLegacy.ts @@ -0,0 +1,117 @@ +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; +import type { PersistenceService } from "./persistenceService"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; + +export interface LegacyStateSyncEngineOptions { + persistenceService: PersistenceService; +} + +export class LegacyStateSyncEngine implements StateSyncEngine { + private readonly persistenceService: PersistenceService; + + constructor(options: LegacyStateSyncEngineOptions) { + this.persistenceService = options.persistenceService; + } + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.persistenceService.on("stateEvent", listener); + return () => { + this.persistenceService.off("stateEvent", listener); + }; + } + + loadSnapshot(): StateBootstrapResult { + return this.persistenceService.loadSnapshot(); + } + + listMessages(raw: StateListMessagesInput): StateListMessagesResult { + return this.persistenceService.listMessages(raw); + } + + catchUp(raw: StateCatchUpInput): StateCatchUpResult { + return this.persistenceService.catchUp(raw); + } + + getAppSettings(): AppSettings { + return this.persistenceService.getAppSettings(); + } + + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { + return this.persistenceService.updateAppSettings(raw); + } + + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult { + return this.persistenceService.createThread(raw); + } + + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult { + return this.persistenceService.updateThreadTerminalState(raw); + } + + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult { + return this.persistenceService.updateThreadModel(raw); + } + + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult { + return this.persistenceService.updateThreadTitle(raw); + } + + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult { + return this.persistenceService.updateThreadBranch(raw); + } + + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult { + return this.persistenceService.markThreadVisited(raw); + } + + deleteThread(raw: ThreadsDeleteInput): void { + this.persistenceService.deleteThread(raw); + } + + listProjects(): ProjectListResult { + return this.persistenceService.listProjects(); + } + + addProject(raw: ProjectAddInput): ProjectAddResult { + return this.persistenceService.addProject(raw); + } + + removeProject(raw: ProjectRemoveInput): void { + this.persistenceService.removeProject(raw); + } + + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + return this.persistenceService.updateProjectScripts(raw); + } + + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.persistenceService.applyCheckpointRevert(input); + } + + close(): void { + // Persistence service lifecycle is owned by server bootstrap. + } +} diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts new file mode 100644 index 000000000000..faa2710e7a76 --- /dev/null +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -0,0 +1,645 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; +import type { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; +import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; + +class MockDelegateStateSyncEngine extends EventEmitter<{ stateEvent: [event: StateEvent] }> + implements StateSyncEngine +{ + readonly loadSnapshotMock = vi.fn<() => StateBootstrapResult>(() => ({ + projects: [], + threads: [], + lastStateSeq: 1, + })); + readonly listMessagesMock = vi.fn<(raw: StateListMessagesInput) => StateListMessagesResult>(() => ({ + messages: [], + total: 0, + nextOffset: null, + })); + readonly catchUpMock = vi.fn<(raw: StateCatchUpInput) => StateCatchUpResult>(() => ({ + events: [], + lastStateSeq: 1, + })); + readonly getAppSettingsMock = vi.fn<() => AppSettings>(() => ({ + codexBinaryPath: "", + codexHomePath: "", + })); + readonly updateAppSettingsMock = vi.fn<(raw: AppSettingsUpdateInput) => AppSettings>(() => ({ + codexBinaryPath: "", + codexHomePath: "", + })); + readonly createThreadMock = vi.fn<(raw: ThreadsCreateInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly updateThreadTerminalStateMock = vi.fn< + (raw: ThreadsUpdateTerminalStateInput) => ThreadsUpdateResult + >(() => { + throw new Error("not implemented in test"); + }); + readonly updateThreadModelMock = vi.fn<(raw: ThreadsUpdateModelInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly updateThreadTitleMock = vi.fn<(raw: ThreadsUpdateTitleInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly updateThreadBranchMock = vi.fn<(raw: ThreadsUpdateBranchInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly markThreadVisitedMock = vi.fn<(raw: ThreadsMarkVisitedInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly deleteThreadMock = vi.fn<(raw: ThreadsDeleteInput) => void>(() => {}); + readonly listProjectsMock = vi.fn<() => ProjectListResult>(() => []); + readonly addProjectMock = vi.fn<(raw: ProjectAddInput) => ProjectAddResult>(() => { + throw new Error("not implemented in test"); + }); + readonly removeProjectMock = vi.fn<(raw: ProjectRemoveInput) => void>(() => {}); + readonly updateProjectScriptsMock = vi.fn< + (raw: ProjectUpdateScriptsInput) => ProjectUpdateScriptsResult + >(() => { + throw new Error("not implemented in test"); + }); + readonly applyCheckpointRevertMock = vi.fn<(input: ApplyCheckpointRevertInput) => void>(() => {}); + readonly closeMock = vi.fn(); + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.on("stateEvent", listener); + return () => { + this.off("stateEvent", listener); + }; + } + + emitStateEvent(event: StateEvent): void { + this.emit("stateEvent", event); + } + + loadSnapshot(): StateBootstrapResult { + return this.loadSnapshotMock(); + } + listMessages(raw: StateListMessagesInput): StateListMessagesResult { + return this.listMessagesMock(raw); + } + catchUp(raw: StateCatchUpInput): StateCatchUpResult { + return this.catchUpMock(raw); + } + getAppSettings(): AppSettings { + return this.getAppSettingsMock(); + } + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { + return this.updateAppSettingsMock(raw); + } + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult { + return this.createThreadMock(raw); + } + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult { + return this.updateThreadTerminalStateMock(raw); + } + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult { + return this.updateThreadModelMock(raw); + } + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult { + return this.updateThreadTitleMock(raw); + } + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult { + return this.updateThreadBranchMock(raw); + } + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult { + return this.markThreadVisitedMock(raw); + } + deleteThread(raw: ThreadsDeleteInput): void { + this.deleteThreadMock(raw); + } + listProjects(): ProjectListResult { + return this.listProjectsMock(); + } + addProject(raw: ProjectAddInput): ProjectAddResult { + return this.addProjectMock(raw); + } + removeProject(raw: ProjectRemoveInput): void { + this.removeProjectMock(raw); + } + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + return this.updateProjectScriptsMock(raw); + } + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.applyCheckpointRevertMock(input); + } + close(): void { + this.closeMock(); + } +} + +function makeMirrorStub(input: { + snapshot: StateBootstrapResult; + catchUp: StateCatchUpResult; + listMessages: StateListMessagesResult; +}): LiveStoreStateMirror { + return { + mirrorStateEvent: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + debugReadSnapshot: vi.fn(() => input.snapshot), + debugCatchUp: vi.fn(() => input.catchUp), + debugListMessages: vi.fn(() => input.listMessages), + } as unknown as LiveStoreStateMirror; +} + +describe("LiveStoreReadPilotStateSyncEngine", () => { + it("serves bootstrap/catchUp/listMessages from mirror when available", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirrorSnapshot: StateBootstrapResult = { + projects: [], + threads: [], + lastStateSeq: 5, + }; + const mirrorCatchUp: StateCatchUpResult = { + events: [], + lastStateSeq: 5, + }; + const mirrorList: StateListMessagesResult = { + messages: [], + total: 0, + nextOffset: null, + }; + const mirror = makeMirrorStub({ + snapshot: mirrorSnapshot, + catchUp: mirrorCatchUp, + listMessages: mirrorList, + }); + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + }); + + try { + expect(engine.loadSnapshot()).toEqual(mirrorSnapshot); + expect(engine.catchUp({ afterSeq: 0 })).toEqual(mirrorCatchUp); + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual(mirrorList); + expect(delegate.loadSnapshotMock).not.toHaveBeenCalled(); + expect(delegate.catchUpMock).not.toHaveBeenCalled(); + expect(delegate.listMessagesMock).not.toHaveBeenCalled(); + expect(engine.debugReadMetrics()).toEqual({ + routeReadCounts: { + "state.bootstrap": { livestore: 1, delegate: 0 }, + "state.catchUp": { livestore: 1, delegate: 0 }, + "state.listMessages": { livestore: 1, delegate: 0 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 0, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 0, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 0, "empty-mirror": 0 }, + }, + }); + } finally { + engine.close(); + } + }); + + it("falls back to delegate reads when mirror throws", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 2, + }, + catchUp: { + events: [], + lastStateSeq: 2, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + vi.mocked(mirror.debugReadSnapshot).mockImplementationOnce(() => { + throw new Error("mirror bootstrap failure"); + }); + vi.mocked(mirror.debugCatchUp).mockImplementationOnce(() => { + throw new Error("mirror catch-up failure"); + }); + vi.mocked(mirror.debugListMessages).mockImplementationOnce(() => { + throw new Error("mirror list failure"); + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + }); + + try { + expect(engine.loadSnapshot()).toEqual({ + projects: [], + threads: [], + lastStateSeq: 1, + }); + expect(engine.catchUp({ afterSeq: 0 })).toEqual({ + events: [], + lastStateSeq: 1, + }); + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual({ + messages: [], + total: 0, + nextOffset: null, + }); + expect(delegate.loadSnapshotMock).toHaveBeenCalledTimes(1); + expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); + expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + expect(engine.debugReadMetrics()).toEqual({ + routeReadCounts: { + "state.bootstrap": { livestore: 0, delegate: 1 }, + "state.catchUp": { livestore: 0, delegate: 1 }, + "state.listMessages": { livestore: 0, delegate: 1 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 1, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 1, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 1, "empty-mirror": 0 }, + }, + }); + } finally { + engine.close(); + } + }); + + it("logs a warning when mirror reports unsuccessful event writes", async () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 1, + }, + catchUp: { + events: [], + lastStateSeq: 1, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + vi.mocked(mirror.mirrorStateEvent).mockResolvedValue(false); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + }); + + try { + delegate.emitStateEvent({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + payload: {}, + createdAt: "2026-02-20T00:00:00.000Z", + }); + await Promise.resolve(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("mirror state event in read pilot reported unsuccessful write"), + ); + } finally { + warnSpy.mockRestore(); + engine.close(); + } + }); + + it("runs bootstrap parity check against delegate when enabled", () => { + const delegate = new MockDelegateStateSyncEngine(); + delegate.loadSnapshotMock.mockReturnValue({ + projects: [], + threads: [], + lastStateSeq: 9, + }); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 5, + }, + catchUp: { + events: [], + lastStateSeq: 5, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + enableBootstrapParityCheck: true, + }); + + try { + expect(engine.loadSnapshot()).toEqual({ + projects: [], + threads: [], + lastStateSeq: 5, + }); + // One call from the parity check path while mirror remains source. + expect(delegate.loadSnapshotMock).toHaveBeenCalledTimes(1); + } finally { + engine.close(); + } + }); + + it("runs catch-up parity check against delegate when enabled", () => { + const delegate = new MockDelegateStateSyncEngine(); + delegate.catchUpMock.mockReturnValue({ + events: [], + lastStateSeq: 11, + }); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 5, + }, + catchUp: { + events: [], + lastStateSeq: 7, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + enableCatchUpParityCheck: true, + }); + + try { + expect(engine.catchUp({ afterSeq: 3 })).toEqual({ + events: [], + lastStateSeq: 7, + }); + // One call from parity-check comparison path while mirror remains source. + expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); + expect(delegate.catchUpMock).toHaveBeenCalledWith({ afterSeq: 3 }); + } finally { + engine.close(); + } + }); + + it("runs list-messages parity check against delegate when enabled", () => { + const delegate = new MockDelegateStateSyncEngine(); + delegate.listMessagesMock.mockReturnValue({ + messages: [], + total: 2, + nextOffset: 2, + }); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 5, + }, + catchUp: { + events: [], + lastStateSeq: 5, + }, + listMessages: { + messages: [], + total: 1, + nextOffset: null, + }, + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + enableListMessagesParityCheck: true, + }); + + try { + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual({ + messages: [], + total: 1, + nextOffset: null, + }); + // One call from parity-check comparison path while mirror remains source. + expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + expect(delegate.listMessagesMock).toHaveBeenCalledWith({ + threadId: "thread-1", + offset: 0, + limit: 10, + }); + } finally { + engine.close(); + } + }); + + it("can disable delegate read fallback for strict mirror reads", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 0, + }, + catchUp: { + events: [], + lastStateSeq: 0, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + vi.mocked(mirror.debugReadSnapshot).mockImplementationOnce(() => { + throw new Error("strict bootstrap failure"); + }); + vi.mocked(mirror.debugCatchUp).mockImplementationOnce(() => { + throw new Error("strict catch-up failure"); + }); + vi.mocked(mirror.debugListMessages).mockImplementationOnce(() => { + throw new Error("strict list failure"); + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + disableDelegateReadFallback: true, + }); + + try { + expect(() => engine.loadSnapshot()).toThrow(/strict bootstrap failure/); + expect(() => engine.catchUp({ afterSeq: 0 })).toThrow(/strict catch-up failure/); + expect(() => engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toThrow( + /strict list failure/, + ); + expect(delegate.loadSnapshotMock).not.toHaveBeenCalled(); + expect(delegate.catchUpMock).not.toHaveBeenCalled(); + expect(delegate.listMessagesMock).not.toHaveBeenCalled(); + } finally { + engine.close(); + } + }); + + it("uses mirror bootstrap even when seq is zero if fallback disabled", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 0, + }, + catchUp: { + events: [], + lastStateSeq: 0, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + disableDelegateReadFallback: true, + }); + + try { + expect(engine.loadSnapshot()).toEqual({ + projects: [], + threads: [], + lastStateSeq: 0, + }); + expect(delegate.loadSnapshotMock).not.toHaveBeenCalled(); + expect(engine.debugReadMetrics()).toEqual({ + routeReadCounts: { + "state.bootstrap": { livestore: 1, delegate: 0 }, + "state.catchUp": { livestore: 0, delegate: 0 }, + "state.listMessages": { livestore: 0, delegate: 0 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 0, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 0, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 0, "empty-mirror": 0 }, + }, + }); + } finally { + engine.close(); + } + }); + + it("returns to mirror reads after transient fallback errors", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 5, + }, + catchUp: { + events: [], + lastStateSeq: 5, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + vi.mocked(mirror.debugCatchUp) + .mockImplementationOnce(() => { + throw new Error("mirror catch-up failure"); + }) + .mockImplementation(() => ({ + events: [], + lastStateSeq: 5, + })); + vi.mocked(mirror.debugListMessages) + .mockImplementationOnce(() => { + throw new Error("mirror list failure"); + }) + .mockImplementation(() => ({ + messages: [], + total: 0, + nextOffset: null, + })); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + }); + + try { + expect(engine.catchUp({ afterSeq: 0 })).toEqual({ + events: [], + lastStateSeq: 1, + }); + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual({ + messages: [], + total: 0, + nextOffset: null, + }); + expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); + expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + + expect(engine.catchUp({ afterSeq: 0 })).toEqual({ + events: [], + lastStateSeq: 5, + }); + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual({ + messages: [], + total: 0, + nextOffset: null, + }); + expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); + expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + expect(engine.debugReadMetrics()).toEqual({ + routeReadCounts: { + "state.bootstrap": { livestore: 0, delegate: 0 }, + "state.catchUp": { livestore: 1, delegate: 1 }, + "state.listMessages": { livestore: 1, delegate: 1 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 0, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 1, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 1, "empty-mirror": 0 }, + }, + }); + } finally { + engine.close(); + } + }); +}); diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts new file mode 100644 index 000000000000..45f3a2d5567d --- /dev/null +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -0,0 +1,439 @@ +import { EventEmitter } from "node:events"; +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; +import { createLogger } from "./logger"; +import type { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import { diffCatchUpResults, diffListMessagesResults, diffStateSnapshots } from "./livestore/parity"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; + +interface LiveStoreReadPilotEvents { + stateEvent: [event: StateEvent]; +} + +type LiveStoreReadSource = "delegate" | "livestore"; +type LiveStoreReadRoute = "state.bootstrap" | "state.catchUp" | "state.listMessages"; +type LiveStoreFallbackReason = "mirror-error" | "empty-mirror"; + +export interface LiveStoreReadPilotMetrics { + routeReadCounts: Record; + fallbackCounts: Record; +} + +export interface LiveStoreReadPilotStateSyncEngineOptions { + delegate: StateSyncEngine; + mirror: LiveStoreStateMirror; + enableBootstrapParityCheck?: boolean; + enableCatchUpParityCheck?: boolean; + enableListMessagesParityCheck?: boolean; + disableDelegateReadFallback?: boolean; +} + +export class LiveStoreReadPilotStateSyncEngine + extends EventEmitter + implements StateSyncEngine +{ + private readonly delegate: StateSyncEngine; + private readonly mirror: LiveStoreStateMirror; + private readonly logger = createLogger("livestore-read-pilot"); + private readonly unsubscribeDelegate: () => void; + private readonly enableBootstrapParityCheck: boolean; + private readonly enableCatchUpParityCheck: boolean; + private readonly enableListMessagesParityCheck: boolean; + private readonly disableDelegateReadFallback: boolean; + private closed = false; + private bootstrapSource: LiveStoreReadSource = "delegate"; + private catchUpSource: LiveStoreReadSource = "delegate"; + private listMessagesSource: LiveStoreReadSource = "delegate"; + private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private catchUpParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private listMessagesParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private readonly metrics: LiveStoreReadPilotMetrics = { + routeReadCounts: { + "state.bootstrap": { livestore: 0, delegate: 0 }, + "state.catchUp": { livestore: 0, delegate: 0 }, + "state.listMessages": { livestore: 0, delegate: 0 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 0, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 0, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 0, "empty-mirror": 0 }, + }, + }; + + constructor(options: LiveStoreReadPilotStateSyncEngineOptions) { + super(); + this.delegate = options.delegate; + this.mirror = options.mirror; + this.enableBootstrapParityCheck = options.enableBootstrapParityCheck ?? false; + this.enableCatchUpParityCheck = options.enableCatchUpParityCheck ?? false; + this.enableListMessagesParityCheck = options.enableListMessagesParityCheck ?? false; + this.disableDelegateReadFallback = options.disableDelegateReadFallback ?? false; + this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { + this.emit("stateEvent", event); + void this.mirror + .mirrorStateEvent(event) + .then((mirrored) => { + if (mirrored === false) { + this.logger.warn("mirror state event in read pilot reported unsuccessful write", { + seq: event.seq, + }); + } + }) + .catch((error) => { + this.logger.warn("failed to mirror state event in read pilot", { + error, + seq: event.seq, + }); + }); + }); + } + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.on("stateEvent", listener); + return () => { + this.off("stateEvent", listener); + }; + } + + loadSnapshot(): StateBootstrapResult { + try { + const snapshot = this.mirror.debugReadSnapshot(); + if (this.enableBootstrapParityCheck) { + this.checkBootstrapParity(snapshot); + } + if (snapshot.lastStateSeq === 0 && !this.disableDelegateReadFallback) { + const delegateSnapshot = this.delegate.loadSnapshot(); + this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "delegate", { + lastStateSeq: delegateSnapshot.lastStateSeq, + }); + this.recordReadRoute("state.bootstrap", "delegate"); + this.recordFallback("state.bootstrap", "empty-mirror"); + this.bootstrapSource = "delegate"; + return delegateSnapshot; + } + this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "livestore", { + lastStateSeq: snapshot.lastStateSeq, + }); + this.recordReadRoute("state.bootstrap", "livestore"); + this.bootstrapSource = "livestore"; + return snapshot; + } catch (error) { + if (this.disableDelegateReadFallback) { + this.logger.error("failed to read bootstrap from livestore mirror with fallback disabled", { + error, + }); + throw error; + } + this.logger.warn("failed to read bootstrap from livestore mirror; using delegate", { error }); + this.recordFallback("state.bootstrap", "mirror-error"); + } + const snapshot = this.delegate.loadSnapshot(); + this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "delegate", { + lastStateSeq: snapshot.lastStateSeq, + }); + this.recordReadRoute("state.bootstrap", "delegate"); + this.bootstrapSource = "delegate"; + return snapshot; + } + + private checkBootstrapParity(mirrorSnapshot: StateBootstrapResult): void { + let delegateSnapshot: StateBootstrapResult; + try { + delegateSnapshot = this.delegate.loadSnapshot(); + } catch (error) { + this.logger.warn("bootstrap parity check failed to read delegate snapshot", { error }); + return; + } + + const diffs = diffStateSnapshots(delegateSnapshot, mirrorSnapshot); + if (diffs.length === 0) { + if (this.bootstrapParityState !== "in-parity") { + this.bootstrapParityState = "in-parity"; + this.logger.info("livestore read pilot bootstrap parity check passed", { + lastStateSeq: mirrorSnapshot.lastStateSeq, + }); + } + return; + } + + this.bootstrapParityState = "drift"; + this.logger.warn("livestore read pilot bootstrap parity drift detected", { + diffCount: diffs.length, + samplePaths: diffs.slice(0, 5).map((diff) => diff.path), + delegateLastStateSeq: delegateSnapshot.lastStateSeq, + mirrorLastStateSeq: mirrorSnapshot.lastStateSeq, + }); + } + + listMessages(raw: StateListMessagesInput): StateListMessagesResult { + try { + const result = this.mirror.debugListMessages(raw); + if (this.enableListMessagesParityCheck) { + this.checkListMessagesParity(raw, result); + } + this.logReadSourceChange("state.listMessages", this.listMessagesSource, "livestore", { + threadId: raw.threadId, + }); + this.recordReadRoute("state.listMessages", "livestore"); + this.listMessagesSource = "livestore"; + return result; + } catch (error) { + if (this.disableDelegateReadFallback) { + this.logger.error( + "failed to list messages from livestore mirror with fallback disabled", + { + error, + threadId: raw.threadId, + }, + ); + throw error; + } + this.recordFallback("state.listMessages", "mirror-error"); + this.logger.warn("failed to list messages from livestore mirror; using delegate", { + error, + threadId: raw.threadId, + }); + const result = this.delegate.listMessages(raw); + this.logReadSourceChange("state.listMessages", this.listMessagesSource, "delegate", { + threadId: raw.threadId, + }); + this.recordReadRoute("state.listMessages", "delegate"); + this.listMessagesSource = "delegate"; + return result; + } + } + + private checkListMessagesParity( + raw: StateListMessagesInput, + mirrorResult: StateListMessagesResult, + ): void { + let delegateResult: StateListMessagesResult; + try { + delegateResult = this.delegate.listMessages(raw); + } catch (error) { + this.logger.warn("list-messages parity check failed to read delegate listMessages", { error }); + return; + } + + const diffs = diffListMessagesResults(delegateResult, mirrorResult); + if (diffs.length === 0) { + if (this.listMessagesParityState !== "in-parity") { + this.listMessagesParityState = "in-parity"; + this.logger.info("livestore read pilot list-messages parity check passed", { + threadId: raw.threadId, + offset: raw.offset ?? 0, + limit: raw.limit ?? 200, + }); + } + return; + } + + this.listMessagesParityState = "drift"; + this.logger.warn("livestore read pilot list-messages parity drift detected", { + threadId: raw.threadId, + offset: raw.offset ?? 0, + limit: raw.limit ?? 200, + diffCount: diffs.length, + sampleDiffs: diffs.slice(0, 5), + }); + } + + catchUp(raw: StateCatchUpInput): StateCatchUpResult { + const afterSeq = raw.afterSeq ?? 0; + try { + const result = this.mirror.debugCatchUp(afterSeq); + if (this.enableCatchUpParityCheck) { + this.checkCatchUpParity(raw, result); + } + this.logReadSourceChange("state.catchUp", this.catchUpSource, "livestore", { + afterSeq, + }); + this.recordReadRoute("state.catchUp", "livestore"); + this.catchUpSource = "livestore"; + return result; + } catch (error) { + if (this.disableDelegateReadFallback) { + this.logger.error("failed to catch up from livestore mirror with fallback disabled", { + error, + afterSeq, + }); + throw error; + } + this.recordFallback("state.catchUp", "mirror-error"); + this.logger.warn("failed to catch up from livestore mirror; using delegate", { + error, + afterSeq, + }); + const result = this.delegate.catchUp(raw); + this.logReadSourceChange("state.catchUp", this.catchUpSource, "delegate", { + afterSeq, + }); + this.recordReadRoute("state.catchUp", "delegate"); + this.catchUpSource = "delegate"; + return result; + } + } + + debugReadMetrics(): LiveStoreReadPilotMetrics { + return { + routeReadCounts: { + "state.bootstrap": { ...this.metrics.routeReadCounts["state.bootstrap"] }, + "state.catchUp": { ...this.metrics.routeReadCounts["state.catchUp"] }, + "state.listMessages": { ...this.metrics.routeReadCounts["state.listMessages"] }, + }, + fallbackCounts: { + "state.bootstrap": { ...this.metrics.fallbackCounts["state.bootstrap"] }, + "state.catchUp": { ...this.metrics.fallbackCounts["state.catchUp"] }, + "state.listMessages": { ...this.metrics.fallbackCounts["state.listMessages"] }, + }, + }; + } + + isReadFallbackDisabled(): boolean { + return this.disableDelegateReadFallback; + } + + private checkCatchUpParity(raw: StateCatchUpInput, mirrorResult: StateCatchUpResult): void { + let delegateResult: StateCatchUpResult; + try { + delegateResult = this.delegate.catchUp(raw); + } catch (error) { + this.logger.warn("catch-up parity check failed to read delegate catch-up", { error }); + return; + } + + const diffs = diffCatchUpResults(delegateResult, mirrorResult); + if (diffs.length === 0) { + if (this.catchUpParityState !== "in-parity") { + this.catchUpParityState = "in-parity"; + this.logger.info("livestore read pilot catch-up parity check passed", { + afterSeq: raw.afterSeq ?? 0, + lastStateSeq: mirrorResult.lastStateSeq, + }); + } + return; + } + + this.catchUpParityState = "drift"; + this.logger.warn("livestore read pilot catch-up parity drift detected", { + afterSeq: raw.afterSeq ?? 0, + diffCount: diffs.length, + sampleDiffs: diffs.slice(0, 5), + delegateLastStateSeq: delegateResult.lastStateSeq, + mirrorLastStateSeq: mirrorResult.lastStateSeq, + }); + } + + private logReadSourceChange( + route: LiveStoreReadRoute, + previous: LiveStoreReadSource, + next: LiveStoreReadSource, + metadata: Record, + ): void { + if (previous === next) { + return; + } + this.logger.info(`serving ${route} from ${next}`, { + source: next, + previousSource: previous, + ...metadata, + }); + } + + private recordReadRoute(route: LiveStoreReadRoute, source: LiveStoreReadSource): void { + this.metrics.routeReadCounts[route][source] += 1; + } + + private recordFallback(route: LiveStoreReadRoute, reason: LiveStoreFallbackReason): void { + this.metrics.fallbackCounts[route][reason] += 1; + } + + getAppSettings(): AppSettings { + return this.delegate.getAppSettings(); + } + + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { + return this.delegate.updateAppSettings(raw); + } + + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult { + return this.delegate.createThread(raw); + } + + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult { + return this.delegate.updateThreadTerminalState(raw); + } + + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult { + return this.delegate.updateThreadModel(raw); + } + + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult { + return this.delegate.updateThreadTitle(raw); + } + + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult { + return this.delegate.updateThreadBranch(raw); + } + + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult { + return this.delegate.markThreadVisited(raw); + } + + deleteThread(raw: ThreadsDeleteInput): void { + this.delegate.deleteThread(raw); + } + + listProjects(): ProjectListResult { + return this.delegate.listProjects(); + } + + addProject(raw: ProjectAddInput): ProjectAddResult { + return this.delegate.addProject(raw); + } + + removeProject(raw: ProjectRemoveInput): void { + this.delegate.removeProject(raw); + } + + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + return this.delegate.updateProjectScripts(raw); + } + + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.delegate.applyCheckpointRevert(input); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.logger.info("livestore read pilot metrics", { metrics: this.metrics }); + this.unsubscribeDelegate(); + this.removeAllListeners(); + void this.mirror.dispose(); + } +} diff --git a/apps/server/src/stateSyncEngineShadow.test.ts b/apps/server/src/stateSyncEngineShadow.test.ts new file mode 100644 index 000000000000..756dbff9a3c3 --- /dev/null +++ b/apps/server/src/stateSyncEngineShadow.test.ts @@ -0,0 +1,303 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { StateEvent } from "@t3tools/contracts"; +import { diffStateSnapshots } from "./livestore/parity"; +import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import { PersistenceService } from "./persistenceService"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import type { StateEventMirror } from "./stateSyncEngineShadow"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function waitForParity( + getExpected: () => ReturnType, + getMirrored: () => ReturnType, +): Promise { + const maxAttempts = 30; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (diffStateSnapshots(getExpected(), getMirrored()).length === 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 15)); + } + + expect(diffStateSnapshots(getExpected(), getMirrored())).toEqual([]); +} + +class CapturingMirror implements StateEventMirror { + readonly events: StateEvent[] = []; + disposeCalls = 0; + + async mirrorStateEvent(event: StateEvent): Promise { + this.events.push(event); + } + + async dispose(): Promise { + this.disposeCalls += 1; + } +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ShadowStateSyncEngine", () => { + it("delegates durable APIs and mirrors emitted state events", async () => { + const stateDir = makeTempDir("t3code-shadow-state-"); + const projectDir = makeTempDir("t3code-shadow-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new CapturingMirror(); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + const observedEvents: StateEvent[] = []; + const unsubscribe = shadow.onStateEvent((event) => { + observedEvents.push(event); + }); + + const project = shadow.addProject({ cwd: projectDir }).project; + const thread = shadow.createThread({ + projectId: project.id, + title: "Shadow thread", + }).thread; + shadow.updateThreadTitle({ + threadId: thread.id, + title: "Shadow thread updated", + }); + service.bindSessionToThread("shadow-session", thread.id, "runtime-thread-1"); + service.persistUserMessageForTurn({ + sessionId: "shadow-session", + clientMessageId: "msg-1", + clientMessageText: "hello from shadow", + input: "hello from shadow", + attachments: [], + }); + + await Promise.resolve(); + + const snapshot = shadow.loadSnapshot(); + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.threads).toHaveLength(1); + expect(snapshot.threads[0]?.title).toBe("Shadow thread updated"); + + const catchUp = shadow.catchUp({ afterSeq: 0 }); + expect(catchUp.events.length).toBeGreaterThan(0); + expect(catchUp.events.map((event) => event.eventType)).toEqual( + expect.arrayContaining(["project.upsert", "thread.upsert"]), + ); + + expect(mirror.events.map((event) => event.seq)).toEqual( + observedEvents.map((event) => event.seq), + ); + expect(mirror.events.some((event) => event.eventType === "message.upsert")).toBe(true); + expect(observedEvents.some((event) => event.eventType === "message.upsert")).toBe(true); + + unsubscribe(); + } finally { + shadow.close(); + service.close(); + } + }); + + it("stops mirroring and disposes once on close", async () => { + const stateDir = makeTempDir("t3code-shadow-close-state-"); + const projectDir = makeTempDir("t3code-shadow-close-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new CapturingMirror(); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + shadow.close(); + shadow.close(); + + legacy.addProject({ cwd: projectDir }); + await Promise.resolve(); + expect(mirror.events).toHaveLength(0); + expect(mirror.disposeCalls).toBe(1); + } finally { + service.close(); + } + }); + + it("logs a warning when mirror reports unsuccessful writes", async () => { + const stateDir = makeTempDir("t3code-shadow-unsuccessful-write-state-"); + const projectDir = makeTempDir("t3code-shadow-unsuccessful-write-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mirror: StateEventMirror = { + mirrorStateEvent: async () => false, + dispose: async () => undefined, + }; + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + shadow.addProject({ cwd: projectDir }); + await Promise.resolve(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("livestore shadow mirror reported unsuccessful write"), + ); + } finally { + warnSpy.mockRestore(); + shadow.close(); + service.close(); + } + }); + + it("keeps LiveStore mirror snapshot in parity with delegate writes", async () => { + const stateDir = makeTempDir("t3code-shadow-parity-state-"); + const projectDir = makeTempDir("t3code-shadow-parity-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new LiveStoreStateMirror({ storeId: "shadow-parity-engine-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + const project = shadow.addProject({ cwd: projectDir }).project; + const thread = shadow.createThread({ + projectId: project.id, + title: "Parity thread", + }).thread; + service.bindSessionToThread("parity-session", thread.id, "runtime-thread-parity"); + service.persistUserMessageForTurn({ + sessionId: "parity-session", + clientMessageId: "parity-message-1", + clientMessageText: "hello parity", + input: "hello parity", + attachments: [], + }); + + await waitForParity(() => legacy.loadSnapshot(), () => mirror.debugReadSnapshot()); + } finally { + shadow.close(); + service.close(); + } + }); + + it("can run optional bootstrap parity diagnostics without changing read results", () => { + const stateDir = makeTempDir("t3code-shadow-parity-diagnostics-state-"); + const projectDir = makeTempDir("t3code-shadow-parity-diagnostics-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const debugReadSnapshot = vi.fn(() => ({ + projects: [], + threads: [], + lastStateSeq: 0, + })); + const mirror: StateEventMirror = { + mirrorStateEvent: async () => undefined, + debugReadSnapshot, + dispose: async () => undefined, + }; + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + enableBootstrapParityCheck: true, + }); + + try { + legacy.addProject({ cwd: projectDir }); + const snapshot = shadow.loadSnapshot(); + expect(snapshot.projects).toHaveLength(1); + expect(debugReadSnapshot).toHaveBeenCalledTimes(1); + } finally { + shadow.close(); + service.close(); + } + }); + + it("can run optional catch-up and list-messages parity diagnostics", () => { + const stateDir = makeTempDir("t3code-shadow-parity-diagnostics-reads-state-"); + const projectDir = makeTempDir("t3code-shadow-parity-diagnostics-reads-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const debugCatchUp = vi.fn((afterSeq: number) => ({ + events: [], + lastStateSeq: afterSeq, + })); + const debugListMessages = vi.fn(() => ({ + messages: [], + total: 0, + nextOffset: null, + })); + const mirror: StateEventMirror = { + mirrorStateEvent: async () => undefined, + debugCatchUp, + debugListMessages, + dispose: async () => undefined, + }; + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + enableCatchUpParityCheck: true, + enableListMessagesParityCheck: true, + }); + + try { + const project = shadow.addProject({ cwd: projectDir }).project; + const thread = shadow.createThread({ + projectId: project.id, + title: "Shadow parity read diagnostics thread", + }).thread; + + const catchUp = shadow.catchUp({ afterSeq: 0 }); + expect(catchUp.events.length).toBeGreaterThan(0); + const listed = shadow.listMessages({ threadId: thread.id, offset: 0, limit: 10 }); + expect(listed).toEqual({ + messages: [], + total: 0, + nextOffset: null, + }); + + expect(debugCatchUp).toHaveBeenCalledTimes(1); + expect(debugCatchUp).toHaveBeenCalledWith(0); + expect(debugListMessages).toHaveBeenCalledTimes(1); + expect(debugListMessages).toHaveBeenCalledWith({ + threadId: thread.id, + offset: 0, + limit: 10, + }); + } finally { + shadow.close(); + service.close(); + } + }); +}); diff --git a/apps/server/src/stateSyncEngineShadow.ts b/apps/server/src/stateSyncEngineShadow.ts new file mode 100644 index 000000000000..aff390139967 --- /dev/null +++ b/apps/server/src/stateSyncEngineShadow.ts @@ -0,0 +1,296 @@ +import { EventEmitter } from "node:events"; +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; +import { createLogger } from "./logger"; +import { diffCatchUpResults, diffListMessagesResults, diffStateSnapshots } from "./livestore/parity"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; + +interface StateSyncEngineShadowEvents { + stateEvent: [event: StateEvent]; +} + +export interface StateEventMirror { + mirrorStateEvent(event: StateEvent): Promise; + debugReadSnapshot?(): StateBootstrapResult; + debugCatchUp?(afterSeq: number): StateCatchUpResult; + debugListMessages?(raw: StateListMessagesInput): StateListMessagesResult; + dispose(): Promise | void; +} + +export interface ShadowStateSyncEngineOptions { + delegate: StateSyncEngine; + mirror: StateEventMirror; + enableBootstrapParityCheck?: boolean; + enableCatchUpParityCheck?: boolean; + enableListMessagesParityCheck?: boolean; +} + +export class ShadowStateSyncEngine + extends EventEmitter + implements StateSyncEngine +{ + private readonly delegate: StateSyncEngine; + private readonly mirror: StateEventMirror; + private readonly enableBootstrapParityCheck: boolean; + private readonly enableCatchUpParityCheck: boolean; + private readonly enableListMessagesParityCheck: boolean; + private readonly logger = createLogger("shadow-sync-engine"); + private readonly unsubscribeDelegate: () => void; + private closed = false; + private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private catchUpParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private listMessagesParityState: "unknown" | "in-parity" | "drift" = "unknown"; + + constructor(options: ShadowStateSyncEngineOptions) { + super(); + this.delegate = options.delegate; + this.mirror = options.mirror; + this.enableBootstrapParityCheck = options.enableBootstrapParityCheck ?? false; + this.enableCatchUpParityCheck = options.enableCatchUpParityCheck ?? false; + this.enableListMessagesParityCheck = options.enableListMessagesParityCheck ?? false; + this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { + this.emit("stateEvent", event); + void this.mirror + .mirrorStateEvent(event) + .then((mirrored) => { + if (mirrored === false) { + this.logger.warn("livestore shadow mirror reported unsuccessful write", { + seq: event.seq, + }); + } + }) + .catch((error) => { + this.logger.warn("livestore shadow mirror failed", { error, seq: event.seq }); + }); + }); + } + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.on("stateEvent", listener); + return () => { + this.off("stateEvent", listener); + }; + } + + loadSnapshot(): StateBootstrapResult { + const snapshot = this.delegate.loadSnapshot(); + if (this.enableBootstrapParityCheck) { + this.checkBootstrapParity(snapshot); + } + return snapshot; + } + + private checkBootstrapParity(delegateSnapshot: StateBootstrapResult): void { + if (typeof this.mirror.debugReadSnapshot !== "function") { + return; + } + + let mirrorSnapshot: StateBootstrapResult; + try { + mirrorSnapshot = this.mirror.debugReadSnapshot(); + } catch (error) { + this.logger.warn("shadow bootstrap parity check failed to read mirror snapshot", { error }); + return; + } + + const diffs = diffStateSnapshots(delegateSnapshot, mirrorSnapshot); + if (diffs.length === 0) { + if (this.bootstrapParityState !== "in-parity") { + this.bootstrapParityState = "in-parity"; + this.logger.info("shadow bootstrap parity check passed", { + lastStateSeq: delegateSnapshot.lastStateSeq, + }); + } + return; + } + + this.bootstrapParityState = "drift"; + this.logger.warn("shadow bootstrap parity drift detected", { + diffCount: diffs.length, + samplePaths: diffs.slice(0, 5).map((diff) => diff.path), + delegateLastStateSeq: delegateSnapshot.lastStateSeq, + mirrorLastStateSeq: mirrorSnapshot.lastStateSeq, + }); + } + + listMessages(raw: StateListMessagesInput): StateListMessagesResult { + const result = this.delegate.listMessages(raw); + if (this.enableListMessagesParityCheck) { + this.checkListMessagesParity(raw, result); + } + return result; + } + + catchUp(raw: StateCatchUpInput): StateCatchUpResult { + const result = this.delegate.catchUp(raw); + if (this.enableCatchUpParityCheck) { + this.checkCatchUpParity(raw, result); + } + return result; + } + + private checkCatchUpParity(raw: StateCatchUpInput, delegateResult: StateCatchUpResult): void { + if (typeof this.mirror.debugCatchUp !== "function") { + return; + } + + const afterSeq = raw.afterSeq ?? 0; + let mirrorResult: StateCatchUpResult; + try { + mirrorResult = this.mirror.debugCatchUp(afterSeq); + } catch (error) { + this.logger.warn("shadow catch-up parity check failed to read mirror catch-up", { error }); + return; + } + + const diffs = diffCatchUpResults(delegateResult, mirrorResult); + if (diffs.length === 0) { + if (this.catchUpParityState !== "in-parity") { + this.catchUpParityState = "in-parity"; + this.logger.info("shadow catch-up parity check passed", { + afterSeq, + lastStateSeq: delegateResult.lastStateSeq, + }); + } + return; + } + + this.catchUpParityState = "drift"; + this.logger.warn("shadow catch-up parity drift detected", { + afterSeq, + diffCount: diffs.length, + sampleDiffs: diffs.slice(0, 5), + delegateLastStateSeq: delegateResult.lastStateSeq, + mirrorLastStateSeq: mirrorResult.lastStateSeq, + }); + } + + private checkListMessagesParity( + raw: StateListMessagesInput, + delegateResult: StateListMessagesResult, + ): void { + if (typeof this.mirror.debugListMessages !== "function") { + return; + } + + let mirrorResult: StateListMessagesResult; + try { + mirrorResult = this.mirror.debugListMessages(raw); + } catch (error) { + this.logger.warn("shadow list-messages parity check failed to read mirror listMessages", { + error, + }); + return; + } + + const diffs = diffListMessagesResults(delegateResult, mirrorResult); + if (diffs.length === 0) { + if (this.listMessagesParityState !== "in-parity") { + this.listMessagesParityState = "in-parity"; + this.logger.info("shadow list-messages parity check passed", { + threadId: raw.threadId, + offset: raw.offset ?? 0, + limit: raw.limit ?? 200, + }); + } + return; + } + + this.listMessagesParityState = "drift"; + this.logger.warn("shadow list-messages parity drift detected", { + threadId: raw.threadId, + offset: raw.offset ?? 0, + limit: raw.limit ?? 200, + diffCount: diffs.length, + sampleDiffs: diffs.slice(0, 5), + }); + } + + getAppSettings(): AppSettings { + return this.delegate.getAppSettings(); + } + + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { + return this.delegate.updateAppSettings(raw); + } + + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult { + return this.delegate.createThread(raw); + } + + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult { + return this.delegate.updateThreadTerminalState(raw); + } + + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult { + return this.delegate.updateThreadModel(raw); + } + + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult { + return this.delegate.updateThreadTitle(raw); + } + + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult { + return this.delegate.updateThreadBranch(raw); + } + + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult { + return this.delegate.markThreadVisited(raw); + } + + deleteThread(raw: ThreadsDeleteInput): void { + this.delegate.deleteThread(raw); + } + + listProjects(): ProjectListResult { + return this.delegate.listProjects(); + } + + addProject(raw: ProjectAddInput): ProjectAddResult { + return this.delegate.addProject(raw); + } + + removeProject(raw: ProjectRemoveInput): void { + this.delegate.removeProject(raw); + } + + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + return this.delegate.updateProjectScripts(raw); + } + + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.delegate.applyCheckpointRevert(input); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.unsubscribeDelegate(); + this.removeAllListeners(); + void this.mirror.dispose(); + } +} diff --git a/apps/server/src/syncEngineMode.test.ts b/apps/server/src/syncEngineMode.test.ts new file mode 100644 index 000000000000..961aea7bd42e --- /dev/null +++ b/apps/server/src/syncEngineMode.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { assertSyncEngineModeAllowed, resolveSyncEngineMode } from "./syncEngineMode"; + +describe("resolveSyncEngineMode", () => { + it("defaults to livestore when env var is missing", () => { + expect(resolveSyncEngineMode(undefined)).toBe("livestore"); + expect(resolveSyncEngineMode("")).toBe("livestore"); + expect(resolveSyncEngineMode(" ")).toBe("livestore"); + }); + + it("accepts supported mode values case-insensitively", () => { + expect(resolveSyncEngineMode("legacy")).toBe("legacy"); + expect(resolveSyncEngineMode("SHADOW")).toBe("shadow"); + expect(resolveSyncEngineMode("livestore-read-pilot")).toBe("livestore-read-pilot"); + expect(resolveSyncEngineMode("LIVESTORE")).toBe("livestore"); + }); + + it("throws for unsupported values", () => { + expect(() => resolveSyncEngineMode("something-else")).toThrow( + /Invalid T3CODE_SYNC_ENGINE_MODE/i, + ); + }); +}); + +describe("assertSyncEngineModeAllowed", () => { + it("allows any mode when enforcement is disabled", () => { + expect(() => assertSyncEngineModeAllowed("legacy")).not.toThrow(); + expect(() => assertSyncEngineModeAllowed("shadow")).not.toThrow(); + expect(() => assertSyncEngineModeAllowed("livestore-read-pilot")).not.toThrow(); + expect(() => assertSyncEngineModeAllowed("livestore")).not.toThrow(); + }); + + it("blocks legacy and shadow when enforceLiveStoreOnly is enabled", () => { + expect(() => + assertSyncEngineModeAllowed("legacy", { enforceLiveStoreOnly: true }), + ).toThrow(/disabled/i); + expect(() => + assertSyncEngineModeAllowed("shadow", { enforceLiveStoreOnly: true }), + ).toThrow(/disabled/i); + }); + + it("allows livestore modes when enforceLiveStoreOnly is enabled", () => { + expect(() => + assertSyncEngineModeAllowed("livestore-read-pilot", { enforceLiveStoreOnly: true }), + ).not.toThrow(); + expect(() => + assertSyncEngineModeAllowed("livestore", { enforceLiveStoreOnly: true }), + ).not.toThrow(); + }); +}); diff --git a/apps/server/src/syncEngineMode.ts b/apps/server/src/syncEngineMode.ts new file mode 100644 index 000000000000..b318dbe79e53 --- /dev/null +++ b/apps/server/src/syncEngineMode.ts @@ -0,0 +1,35 @@ +export type SyncEngineMode = "legacy" | "shadow" | "livestore-read-pilot" | "livestore"; + +const VALID_SYNC_ENGINE_MODES: ReadonlySet = new Set([ + "legacy", + "shadow", + "livestore-read-pilot", + "livestore", +]); + +export function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { + if (!raw || raw.trim().length === 0) { + return "livestore"; + } + const normalized = raw.trim().toLowerCase(); + if (VALID_SYNC_ENGINE_MODES.has(normalized as SyncEngineMode)) { + return normalized as SyncEngineMode; + } + throw new Error( + `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy", "shadow", "livestore-read-pilot", or "livestore".`, + ); +} + +export function assertSyncEngineModeAllowed( + syncEngineMode: SyncEngineMode, + options: { enforceLiveStoreOnly?: boolean } = {}, +): void { + if (!options.enforceLiveStoreOnly) { + return; + } + if (syncEngineMode === "legacy" || syncEngineMode === "shadow") { + throw new Error( + `Sync engine mode "${syncEngineMode}" is disabled because T3CODE_LIVESTORE_ENFORCE_MODE is enabled.`, + ); + } +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 51f086a090e0..1a2418158626 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -11,6 +11,19 @@ import { DEFAULT_TERMINAL_ID, WS_CHANNELS, WS_METHODS, + type AppSettingsUpdateInput, + type ProjectAddInput, + type ProjectRemoveInput, + type ProjectUpdateScriptsInput, + type StateCatchUpInput, + type StateListMessagesInput, + type ThreadsCreateInput, + type ThreadsDeleteInput, + type ThreadsMarkVisitedInput, + type ThreadsUpdateBranchInput, + type ThreadsUpdateModelInput, + type ThreadsUpdateTerminalStateInput, + type ThreadsUpdateTitleInput, type KeybindingsConfig, type ResolvedKeybindingsConfig, type StateEvent, @@ -30,6 +43,12 @@ import type { import type { TerminalManager } from "./terminalManager"; import { PersistenceService } from "./persistenceService"; import { ProviderManager } from "./providerManager"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; +import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import { diffStateSnapshots } from "./livestore/parity"; interface PendingMessages { queue: unknown[]; @@ -145,6 +164,157 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } dispose(): void {} } +class MockStateSyncEngine extends EventEmitter<{ stateEvent: [event: StateEvent] }> + implements StateSyncEngine +{ + readonly loadSnapshotMock = vi.fn< + () => { + projects: []; + threads: []; + lastStateSeq: number; + } + >(() => ({ + projects: [], + threads: [], + lastStateSeq: 42, + })); + readonly listMessagesMock = vi.fn(() => ({ + messages: [], + total: 0, + nextOffset: null, + })); + readonly catchUpMock = vi.fn(() => ({ + events: [], + lastStateSeq: 42, + })); + readonly getAppSettingsMock = vi.fn(() => ({ + codexBinaryPath: "", + codexHomePath: "", + })); + readonly updateAppSettingsMock = vi.fn(() => ({ + codexBinaryPath: "", + codexHomePath: "", + })); + readonly createThreadMock = vi.fn(() => ({ + thread: { + id: "thread-1", + codexThreadId: null, + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + branch: null, + worktreePath: null, + turnDiffSummaries: [], + }, + })); + readonly updateThreadTerminalStateMock = vi.fn(() => this.createThreadMock()); + readonly updateThreadModelMock = vi.fn(() => this.createThreadMock()); + readonly updateThreadTitleMock = vi.fn(() => this.createThreadMock()); + readonly updateThreadBranchMock = vi.fn(() => this.createThreadMock()); + readonly markThreadVisitedMock = vi.fn(() => this.createThreadMock()); + readonly deleteThreadMock = vi.fn(); + readonly listProjectsMock = vi.fn(() => []); + readonly addProjectMock = vi.fn(() => ({ + project: { + id: "project-1", + cwd: "/tmp/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + created: true, + })); + readonly removeProjectMock = vi.fn(); + readonly updateProjectScriptsMock = vi.fn(() => ({ + project: { + id: "project-1", + cwd: "/tmp/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + })); + readonly applyCheckpointRevertMock = vi.fn(); + readonly closeMock = vi.fn(); + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.on("stateEvent", listener); + return () => { + this.off("stateEvent", listener); + }; + } + + emitStateEvent(event: StateEvent): void { + this.emit("stateEvent", event); + } + + loadSnapshot() { + return this.loadSnapshotMock(); + } + listMessages(_raw: StateListMessagesInput) { + return this.listMessagesMock(); + } + catchUp(_raw: StateCatchUpInput) { + return this.catchUpMock(); + } + getAppSettings() { + return this.getAppSettingsMock(); + } + updateAppSettings(_raw: AppSettingsUpdateInput) { + return this.updateAppSettingsMock(); + } + createThread(_raw: ThreadsCreateInput) { + return this.createThreadMock(); + } + updateThreadTerminalState(_raw: ThreadsUpdateTerminalStateInput) { + return this.updateThreadTerminalStateMock(); + } + updateThreadModel(_raw: ThreadsUpdateModelInput) { + return this.updateThreadModelMock(); + } + updateThreadTitle(_raw: ThreadsUpdateTitleInput) { + return this.updateThreadTitleMock(); + } + updateThreadBranch(_raw: ThreadsUpdateBranchInput) { + return this.updateThreadBranchMock(); + } + markThreadVisited(_raw: ThreadsMarkVisitedInput) { + return this.markThreadVisitedMock(); + } + deleteThread(_raw: ThreadsDeleteInput): void { + this.deleteThreadMock(); + } + listProjects() { + return this.listProjectsMock(); + } + addProject(_raw: ProjectAddInput) { + return this.addProjectMock(); + } + removeProject(_raw: ProjectRemoveInput): void { + this.removeProjectMock(); + } + updateProjectScripts(_raw: ProjectUpdateScriptsInput) { + return this.updateProjectScriptsMock(); + } + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.applyCheckpointRevertMock(input); + } + close(): void { + this.closeMock(); + } +} + function connectWs(port: number, token?: string): Promise { return new Promise((resolve, reject) => { const query = token ? `?token=${encodeURIComponent(token)}` : ""; @@ -218,6 +388,22 @@ async function waitForPush(ws: WebSocket, channel: string): Promise { } } +async function waitForCondition( + predicate: () => boolean, + options: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 1500; + const intervalMs = options.intervalMs ?? 20; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + expect(predicate()).toBe(true); +} + const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig { @@ -256,6 +442,8 @@ describe("WebSocket Server", () => { }; terminalManager?: TerminalManager; persistenceService?: PersistenceService; + stateSyncEngine?: StateSyncEngine; + syncEngineMode?: "legacy" | "shadow" | "livestore-read-pilot" | "livestore"; } = {}, ): ReturnType { const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); @@ -272,6 +460,8 @@ describe("WebSocket Server", () => { ...(options.devUrl ? { devUrl: options.devUrl } : {}), ...(options.authToken ? { authToken: options.authToken } : {}), persistenceService, + ...(options.stateSyncEngine ? { stateSyncEngine: options.stateSyncEngine } : {}), + ...(options.syncEngineMode ? { syncEngineMode: options.syncEngineMode } : {}), ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), }); @@ -363,10 +553,147 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + }); + }); + + it("includes configured sync engine mode in server.getConfig", async () => { + const fakeHome = makeTempDir("t3code-home-"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + server = createTestServer({ + cwd: "/my/workspace", + syncEngineMode: "livestore", + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + syncEngineMode: "livestore", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); }); + it("infers shadow sync engine mode in server.getConfig when mode option omitted", async () => { + const stateDir = makeTempDir("t3code-ws-config-shadow-infer-state-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-config-shadow-infer-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + server = createTestServer({ + cwd: "/my/workspace", + stateSyncEngine: shadow, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + syncEngineMode: "shadow", + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + }); + } finally { + shadow.close(); + } + }); + + it("infers read-pilot sync engine mode in server.getConfig when mode option omitted", async () => { + const stateDir = makeTempDir("t3code-ws-config-readpilot-infer-state-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-config-readpilot-infer-test" }); + const readPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + server = createTestServer({ + cwd: "/my/workspace", + stateSyncEngine: readPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + syncEngineMode: "livestore-read-pilot", + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + }); + } finally { + readPilot.close(); + } + }); + + it("infers strict livestore mode in server.getConfig when fallback is disabled", async () => { + const stateDir = makeTempDir("t3code-ws-config-livestore-infer-state-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-config-livestore-infer-test" }); + const strictReadPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: legacy, + mirror, + disableDelegateReadFallback: true, + }); + + try { + server = createTestServer({ + cwd: "/my/workspace", + stateSyncEngine: strictReadPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + syncEngineMode: "livestore", + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + }); + } finally { + strictReadPilot.close(); + } + }); + it("reads keybindings from ~/.t3/keybindings.json", async () => { const fakeHome = makeTempDir("t3code-home-"); const configDir = path.join(fakeHome, ".t3"); @@ -396,6 +723,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: mergeWithDefaultsForTest([ { key: "cmd+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, @@ -433,6 +761,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: mergeWithDefaultsForTest([{ key: "mod+j", command: "terminal.toggle" }]), }); expect( @@ -468,6 +797,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); expect( @@ -502,6 +832,7 @@ describe("WebSocket Server", () => { expect(firstResponse.error).toBeUndefined(); expect(firstResponse.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]), }); @@ -562,6 +893,7 @@ describe("WebSocket Server", () => { expect(configResponse.error).toBeUndefined(); expect(configResponse.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: mergeWithDefaultsForTest([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, @@ -595,6 +927,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); expect( @@ -626,6 +959,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); expect( @@ -763,6 +1097,355 @@ describe("WebSocket Server", () => { expect(events.some((event) => event.eventType === "thread.upsert")).toBe(true); }); + it("uses the injected state sync engine for state routes and pushes", async () => { + const shadowEngine = new MockStateSyncEngine(); + server = createTestServer({ + cwd: "/test", + stateSyncEngine: shadowEngine, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const bootstrap = await sendRequest(ws, WS_METHODS.stateBootstrap); + expect(bootstrap.error).toBeUndefined(); + expect(bootstrap.result).toEqual({ + projects: [], + threads: [], + lastStateSeq: 42, + }); + expect(shadowEngine.loadSnapshotMock).toHaveBeenCalledTimes(1); + + const projects = await sendRequest(ws, WS_METHODS.projectsList); + expect(projects.error).toBeUndefined(); + expect(projects.result).toEqual([]); + expect(shadowEngine.listProjectsMock).toHaveBeenCalledTimes(1); + + const mirroredStateEvent: StateEvent = { + seq: 43, + eventType: "thread.upsert", + entityId: "thread-1", + payload: { threadId: "thread-1" }, + createdAt: "2026-02-20T00:00:01.000Z", + }; + shadowEngine.emitStateEvent(mirroredStateEvent); + const pushed = await waitForPush(ws, WS_CHANNELS.stateEvent); + expect(pushed.data).toEqual(mirroredStateEvent); + }); + + it("keeps a shadow sync engine mirror in parity for websocket mutations", async () => { + const stateDir = makeTempDir("t3code-ws-shadow-engine-state-"); + const projectCwd = makeTempDir("t3code-ws-shadow-engine-project-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + legacyProjectsJsonPath: path.join(stateDir, "projects.json"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-shadow-engine-parity-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + server = createTestServer({ + cwd: "/test", + stateSyncEngine: shadow, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const addedProject = await sendRequest(ws, WS_METHODS.projectsAdd, { cwd: projectCwd }); + expect(addedProject.error).toBeUndefined(); + const projectId = (addedProject.result as { project: { id: string } }).project.id; + + const createdThread = await sendRequest(ws, WS_METHODS.threadsCreate, { + projectId, + title: "Shadow parity thread", + model: "gpt-5.3-codex", + }); + expect(createdThread.error).toBeUndefined(); + const threadId = (createdThread.result as { thread: { id: string } }).thread.id; + + const updatedThread = await sendRequest(ws, WS_METHODS.threadsUpdateTitle, { + threadId, + title: "Shadow parity thread updated", + }); + expect(updatedThread.error).toBeUndefined(); + + await waitForCondition(() => { + const expected = legacy.loadSnapshot(); + const mirrored = mirror.debugReadSnapshot(); + return diffStateSnapshots(expected, mirrored).length === 0; + }); + + expect(diffStateSnapshots(legacy.loadSnapshot(), mirror.debugReadSnapshot())).toEqual([]); + } finally { + shadow.close(); + } + }); + + it("serves ordered bootstrap and catch-up through the read-pilot sync engine", async () => { + const stateDir = makeTempDir("t3code-ws-read-pilot-state-"); + const projectCwd = makeTempDir("t3code-ws-read-pilot-project-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + legacyProjectsJsonPath: path.join(stateDir, "projects.json"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-read-pilot-parity-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + const readPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: shadow, + mirror, + }); + + try { + server = createTestServer({ + cwd: "/test", + stateSyncEngine: readPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const addedProject = await sendRequest(ws, WS_METHODS.projectsAdd, { cwd: projectCwd }); + expect(addedProject.error).toBeUndefined(); + const projectId = (addedProject.result as { project: { id: string } }).project.id; + + const createdThread = await sendRequest(ws, WS_METHODS.threadsCreate, { + projectId, + title: "Read pilot thread", + model: "gpt-5.3-codex", + }); + expect(createdThread.error).toBeUndefined(); + const threadId = (createdThread.result as { thread: { id: string } }).thread.id; + + await sendRequest(ws, WS_METHODS.threadsUpdateTitle, { + threadId, + title: "Read pilot thread updated", + }); + + await waitForCondition(() => { + const diffs = diffStateSnapshots(legacy.loadSnapshot(), mirror.debugReadSnapshot()); + return diffs.length === 0; + }); + + const bootstrap = await sendRequest(ws, WS_METHODS.stateBootstrap); + expect(bootstrap.error).toBeUndefined(); + const snapshot = bootstrap.result as { + projects: Array<{ id: string }>; + threads: Array<{ id: string; title: string }>; + lastStateSeq: number; + }; + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.threads).toHaveLength(1); + expect(snapshot.threads[0]?.title).toBe("Read pilot thread updated"); + expect(snapshot.lastStateSeq).toBeGreaterThan(0); + + const catchUp = await sendRequest(ws, WS_METHODS.stateCatchUp, { afterSeq: 0 }); + expect(catchUp.error).toBeUndefined(); + const events = (catchUp.result as { events: StateEvent[] }).events; + expect(events.length).toBeGreaterThan(0); + for (let index = 1; index < events.length; index += 1) { + const previous = events[index - 1]; + const current = events[index]; + expect(previous).toBeDefined(); + expect(current).toBeDefined(); + if (!previous || !current) continue; + expect(current.seq).toBeGreaterThan(previous.seq); + } + expect(events.some((event) => event.eventType === "project.upsert")).toBe(true); + expect(events.some((event) => event.eventType === "thread.upsert")).toBe(true); + } finally { + readPilot.close(); + } + }); + + it("falls back to legacy reads when the read-pilot mirror fails", async () => { + const stateDir = makeTempDir("t3code-ws-read-pilot-fallback-state-"); + const projectCwd = makeTempDir("t3code-ws-read-pilot-fallback-project-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + legacyProjectsJsonPath: path.join(stateDir, "projects.json"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const failingMirror = { + mirrorStateEvent: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + debugReadSnapshot: vi.fn(() => { + throw new Error("mirror bootstrap failed"); + }), + debugCatchUp: vi.fn(() => { + throw new Error("mirror catch-up failed"); + }), + debugListMessages: vi.fn(() => { + throw new Error("mirror listMessages failed"); + }), + } as unknown as LiveStoreStateMirror; + const readPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: legacy, + mirror: failingMirror, + }); + + try { + server = createTestServer({ + cwd: "/test", + stateSyncEngine: readPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const addedProject = await sendRequest(ws, WS_METHODS.projectsAdd, { cwd: projectCwd }); + expect(addedProject.error).toBeUndefined(); + const projectId = (addedProject.result as { project: { id: string } }).project.id; + + const createdThread = await sendRequest(ws, WS_METHODS.threadsCreate, { + projectId, + title: "Read pilot fallback thread", + model: "gpt-5.3-codex", + }); + expect(createdThread.error).toBeUndefined(); + const threadId = (createdThread.result as { thread: { id: string } }).thread.id; + + persistenceService.bindSessionToThread( + "read-pilot-fallback-session", + threadId, + "runtime-thread-fallback", + ); + persistenceService.persistUserMessageForTurn({ + sessionId: "read-pilot-fallback-session", + clientMessageId: "msg-fallback-1", + clientMessageText: "fallback message", + input: "fallback message", + attachments: [], + }); + + const bootstrap = await sendRequest(ws, WS_METHODS.stateBootstrap); + expect(bootstrap.error).toBeUndefined(); + const snapshot = bootstrap.result as { + projects: Array<{ id: string }>; + threads: Array<{ id: string; title: string }>; + lastStateSeq: number; + }; + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.threads).toHaveLength(1); + expect(snapshot.threads[0]?.title).toBe("Read pilot fallback thread"); + expect(snapshot.lastStateSeq).toBeGreaterThan(0); + + const catchUp = await sendRequest(ws, WS_METHODS.stateCatchUp, { afterSeq: 0 }); + expect(catchUp.error).toBeUndefined(); + const events = (catchUp.result as { events: StateEvent[] }).events; + expect(events.length).toBeGreaterThan(0); + expect(events.some((event) => event.eventType === "project.upsert")).toBe(true); + expect(events.some((event) => event.eventType === "thread.upsert")).toBe(true); + + const listMessages = await sendRequest(ws, WS_METHODS.stateListMessages, { + threadId, + offset: 0, + limit: 10, + }); + expect(listMessages.error).toBeUndefined(); + const messages = (listMessages.result as { messages: Array<{ id: string }> }).messages; + expect(messages.map((message) => message.id)).toEqual(["msg-fallback-1"]); + } finally { + readPilot.close(); + } + }); + + it("returns errors when strict read-pilot mode disables delegate fallback", async () => { + const stateDir = makeTempDir("t3code-ws-read-pilot-strict-state-"); + const projectCwd = makeTempDir("t3code-ws-read-pilot-strict-project-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + legacyProjectsJsonPath: path.join(stateDir, "projects.json"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const failingMirror = { + mirrorStateEvent: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + debugReadSnapshot: vi.fn(() => { + throw new Error("strict mirror bootstrap failed"); + }), + debugCatchUp: vi.fn(() => { + throw new Error("strict mirror catch-up failed"); + }), + debugListMessages: vi.fn(() => { + throw new Error("strict mirror listMessages failed"); + }), + } as unknown as LiveStoreStateMirror; + const readPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: legacy, + mirror: failingMirror, + disableDelegateReadFallback: true, + }); + + try { + server = createTestServer({ + cwd: "/test", + stateSyncEngine: readPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const addedProject = await sendRequest(ws, WS_METHODS.projectsAdd, { cwd: projectCwd }); + expect(addedProject.error).toBeUndefined(); + const projectId = (addedProject.result as { project: { id: string } }).project.id; + + const createdThread = await sendRequest(ws, WS_METHODS.threadsCreate, { + projectId, + title: "Read pilot strict thread", + model: "gpt-5.3-codex", + }); + expect(createdThread.error).toBeUndefined(); + const threadId = (createdThread.result as { thread: { id: string } }).thread.id; + + const bootstrap = await sendRequest(ws, WS_METHODS.stateBootstrap); + expect(bootstrap.result).toBeUndefined(); + expect(bootstrap.error?.message).toContain("strict mirror bootstrap failed"); + + const catchUp = await sendRequest(ws, WS_METHODS.stateCatchUp, { afterSeq: 0 }); + expect(catchUp.result).toBeUndefined(); + expect(catchUp.error?.message).toContain("strict mirror catch-up failed"); + + const listMessages = await sendRequest(ws, WS_METHODS.stateListMessages, { + threadId, + offset: 0, + limit: 10, + }); + expect(listMessages.result).toBeUndefined(); + expect(listMessages.error?.message).toContain("strict mirror listMessages failed"); + } finally { + readPilot.close(); + } + }); + it("broadcasts ordered state.event pushes", async () => { const stateDir = makeTempDir("t3code-ws-state-events-"); const projectCwd = makeTempDir("t3code-ws-state-events-project-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 9e92c76e69bc..73b3f13e06ba 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -33,6 +33,11 @@ import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { PersistenceService } from "./persistenceService"; +import type { StateSyncEngine } from "./stateSyncEngine"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; +import type { SyncEngineMode } from "./syncEngineMode"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -57,10 +62,12 @@ export interface ServerOptions { devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; persistenceService?: PersistenceService | undefined; + stateSyncEngine?: StateSyncEngine | undefined; stateDbPath?: string | undefined; gitManager?: GitManager | undefined; terminalManager?: TerminalManager | undefined; authToken?: string | undefined; + syncEngineMode?: SyncEngineMode | undefined; } function parseBooleanEnv(value: string | undefined): boolean | undefined { @@ -80,10 +87,12 @@ export function createServer(options: ServerOptions) { devUrl, logWebSocketEvents: explicitLogWsEvents, persistenceService: providedPersistenceService, + stateSyncEngine: providedStateSyncEngine, stateDbPath, gitManager: providedGitManager, terminalManager: providedTerminalManager, authToken, + syncEngineMode: providedSyncEngineMode, } = options; const persistenceService = providedPersistenceService ?? @@ -91,6 +100,10 @@ export function createServer(options: ServerOptions) { dbPath: stateDbPath ?? path.join(os.homedir(), ".t3", "state.sqlite"), }); const ownsPersistenceService = providedPersistenceService === undefined; + const stateSyncEngine = + providedStateSyncEngine ?? new LegacyStateSyncEngine({ persistenceService }); + const syncEngineMode = providedSyncEngineMode ?? inferSyncEngineMode(stateSyncEngine); + const ownsStateSyncEngine = providedStateSyncEngine === undefined; const providerManager = new ProviderManager({ persistenceService }); const terminalManager = providedTerminalManager ?? new TerminalManager(); const gitManager = providedGitManager ?? new GitManager(); @@ -143,7 +156,7 @@ export function createServer(options: ServerOptions) { } logOutgoingPush(push, recipients); }; - persistenceService.on("stateEvent", onStateEvent); + const unsubscribeStateEvents = stateSyncEngine.onStateEvent(onStateEvent); const onTerminalEvent = (event: TerminalEvent) => { const push: WsPush = { @@ -348,7 +361,7 @@ export function createServer(options: ServerOptions) { }; const result = await providerManager.revertToCheckpoint(params as never); try { - persistenceService.applyCheckpointRevert({ + stateSyncEngine.applyCheckpointRevert({ sessionId: params.sessionId, runtimeThreadId: result.threadId, turnCount: result.turnCount, @@ -365,22 +378,22 @@ export function createServer(options: ServerOptions) { } case WS_METHODS.stateBootstrap: - return persistenceService.loadSnapshot(); + return stateSyncEngine.loadSnapshot(); case WS_METHODS.stateListMessages: - return persistenceService.listMessages(request.params as never); + return stateSyncEngine.listMessages(request.params as never); case WS_METHODS.stateCatchUp: - return persistenceService.catchUp(request.params as never); + return stateSyncEngine.catchUp(request.params as never); case WS_METHODS.appSettingsGet: - return persistenceService.getAppSettings(); + return stateSyncEngine.getAppSettings(); case WS_METHODS.appSettingsUpdate: - return persistenceService.updateAppSettings((request.params ?? {}) as never); + return stateSyncEngine.updateAppSettings((request.params ?? {}) as never); case WS_METHODS.threadsCreate: - return persistenceService.createThread(request.params as never); + return stateSyncEngine.createThread(request.params as never); case "threads.update": throw new Error( @@ -388,38 +401,38 @@ export function createServer(options: ServerOptions) { ); case WS_METHODS.threadsUpdateTerminalState: - return persistenceService.updateThreadTerminalState(request.params as never); + return stateSyncEngine.updateThreadTerminalState(request.params as never); case WS_METHODS.threadsDelete: - persistenceService.deleteThread(request.params as never); + stateSyncEngine.deleteThread(request.params as never); return undefined; case WS_METHODS.threadsMarkVisited: - return persistenceService.markThreadVisited(request.params as never); + return stateSyncEngine.markThreadVisited(request.params as never); case WS_METHODS.threadsUpdateModel: - return persistenceService.updateThreadModel(request.params as never); + return stateSyncEngine.updateThreadModel(request.params as never); case WS_METHODS.threadsUpdateTitle: - return persistenceService.updateThreadTitle(request.params as never); + return stateSyncEngine.updateThreadTitle(request.params as never); case WS_METHODS.threadsUpdateBranch: - return persistenceService.updateThreadBranch(request.params as never); + return stateSyncEngine.updateThreadBranch(request.params as never); case WS_METHODS.projectsList: - return persistenceService.listProjects(); + return stateSyncEngine.listProjects(); case WS_METHODS.projectsAdd: - return persistenceService.addProject(request.params as never); + return stateSyncEngine.addProject(request.params as never); case WS_METHODS.projectsRemove: - persistenceService.removeProject(request.params as never); + stateSyncEngine.removeProject(request.params as never); return undefined; case WS_METHODS.projectsSearchEntries: return searchWorkspaceEntries(request.params as never); case WS_METHODS.projectsUpdateScripts: - return persistenceService.updateProjectScripts(request.params as never); + return stateSyncEngine.updateProjectScripts(request.params as never); case WS_METHODS.shellOpenInEditor: { const params = request.params as { @@ -516,6 +529,7 @@ export function createServer(options: ServerOptions) { case WS_METHODS.serverGetConfig: return { cwd, + syncEngineMode, keybindings: keybindingsConfig, }; @@ -551,10 +565,13 @@ export function createServer(options: ServerOptions) { async function stop(): Promise { terminalManager.off("event", onTerminalEvent); - persistenceService.off("stateEvent", onStateEvent); + unsubscribeStateEvents(); providerManager.stopAll(); providerManager.dispose(); terminalManager.dispose(); + if (ownsStateSyncEngine) { + stateSyncEngine.close(); + } if (ownsPersistenceService) { persistenceService.close(); } @@ -598,3 +615,16 @@ export function createServer(options: ServerOptions) { return { start, stop, httpServer }; } + +function inferSyncEngineMode(stateSyncEngine: StateSyncEngine): SyncEngineMode { + if (stateSyncEngine instanceof LiveStoreReadPilotStateSyncEngine) { + return stateSyncEngine.isReadFallbackDisabled() ? "livestore" : "livestore-read-pilot"; + } + if (stateSyncEngine instanceof ShadowStateSyncEngine) { + return "shadow"; + } + if (stateSyncEngine instanceof LegacyStateSyncEngine) { + return "legacy"; + } + return "legacy"; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7032c327188c..2e62415ac81c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -4,7 +4,7 @@ import { type ErrorComponentProps, useParams, } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; @@ -13,6 +13,7 @@ import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; +import { createStateSource } from "../stateSource"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; @@ -131,16 +132,17 @@ function StateSyncRouter() { }); const lastStateSeqRef = useRef(0); const stateQueueRef = useRef(Promise.resolve()); + const stateSource = useMemo(() => (api ? createStateSource(api) : null), [api]); useEffect(() => { - if (!api) return; + if (!api || !stateSource) return; let disposed = false; let retryDelayMs = 500; let retryTimer: ReturnType | null = null; const bootstrap = async () => { try { - const snapshot = await api.state.bootstrap(); + const snapshot = await stateSource.bootstrap(); if (disposed) return; dispatch({ type: "HYDRATE_FROM_SERVER", @@ -165,11 +167,11 @@ function StateSyncRouter() { clearTimeout(retryTimer); } }; - }, [api, dispatch]); + }, [api, dispatch, stateSource]); useEffect(() => { - if (!api) return; - return api.state.onEvent((event) => { + if (!api || !stateSource) return; + return stateSource.onEvent((event) => { stateQueueRef.current = stateQueueRef.current .then(async () => { if (event.seq <= lastStateSeqRef.current) { @@ -177,7 +179,7 @@ function StateSyncRouter() { } if (event.seq > lastStateSeqRef.current + 1) { - const catchUp = await api.state.catchUp({ afterSeq: lastStateSeqRef.current }); + const catchUp = await stateSource.catchUp({ afterSeq: lastStateSeqRef.current }); for (const missingEvent of catchUp.events) { if (missingEvent.seq <= lastStateSeqRef.current) continue; dispatch({ @@ -198,7 +200,7 @@ function StateSyncRouter() { }) .catch(() => undefined); }); - }, [api, dispatch]); + }, [api, dispatch, stateSource]); useEffect(() => { if (!api) return; diff --git a/apps/web/src/stateSource.test.ts b/apps/web/src/stateSource.test.ts new file mode 100644 index 000000000000..055d78972f48 --- /dev/null +++ b/apps/web/src/stateSource.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NativeApi, StateEvent } from "@t3tools/contracts"; +import { createStateSource } from "./stateSource"; + +function makeApi(): NativeApi { + const onStateEvent = vi.fn<(callback: (event: StateEvent) => void) => () => void>((callback) => { + callback({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + payload: {}, + createdAt: "2026-02-20T00:00:00.000Z", + }); + return () => undefined; + }); + + return { + todos: { + list: async () => [], + add: async () => [], + toggle: async () => [], + remove: async () => [], + }, + dialogs: { + pickFolder: async () => null, + confirm: async () => true, + }, + terminal: { + open: vi.fn(async () => { + throw new Error("not implemented"); + }), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + restart: vi.fn(async () => { + throw new Error("not implemented"); + }), + close: vi.fn(async () => undefined), + onEvent: vi.fn(() => () => undefined), + }, + agent: { + spawn: vi.fn(async () => ""), + kill: vi.fn(async () => undefined), + write: vi.fn(async () => undefined), + onOutput: vi.fn(() => () => undefined), + onExit: vi.fn(() => () => undefined), + }, + providers: { + startSession: vi.fn(async () => { + throw new Error("not implemented"); + }), + sendTurn: vi.fn(async () => { + throw new Error("not implemented"); + }), + interruptTurn: vi.fn(async () => undefined), + respondToRequest: vi.fn(async () => undefined), + stopSession: vi.fn(async () => undefined), + listSessions: vi.fn(async () => []), + listCheckpoints: vi.fn(async () => { + throw new Error("not implemented"); + }), + getCheckpointDiff: vi.fn(async () => { + throw new Error("not implemented"); + }), + revertToCheckpoint: vi.fn(async () => { + throw new Error("not implemented"); + }), + onEvent: vi.fn(() => () => undefined), + }, + state: { + bootstrap: vi.fn(async () => ({ + projects: [], + threads: [], + lastStateSeq: 3, + })), + listMessages: vi.fn(async () => ({ + messages: [], + total: 0, + nextOffset: null, + })), + catchUp: vi.fn(async () => ({ + events: [], + lastStateSeq: 3, + })), + onEvent: onStateEvent, + }, + appSettings: { + get: vi.fn(async () => ({ codexBinaryPath: "", codexHomePath: "" })), + update: vi.fn(async () => ({ codexBinaryPath: "", codexHomePath: "" })), + }, + threads: { + create: vi.fn(async () => { + throw new Error("not implemented"); + }), + delete: vi.fn(async () => undefined), + markVisited: vi.fn(async () => { + throw new Error("not implemented"); + }), + updateTerminalState: vi.fn(async () => { + throw new Error("not implemented"); + }), + updateModel: vi.fn(async () => { + throw new Error("not implemented"); + }), + updateTitle: vi.fn(async () => { + throw new Error("not implemented"); + }), + updateBranch: vi.fn(async () => { + throw new Error("not implemented"); + }), + }, + projects: { + list: vi.fn(async () => []), + add: vi.fn(async () => { + throw new Error("not implemented"); + }), + remove: vi.fn(async () => undefined), + searchEntries: vi.fn(async () => ({ entries: [], truncated: false })), + updateScripts: vi.fn(async () => { + throw new Error("not implemented"); + }), + }, + shell: { + openInEditor: vi.fn(async () => undefined), + openExternal: vi.fn(async () => undefined), + }, + git: { + pull: vi.fn(async () => { + throw new Error("not implemented"); + }), + status: vi.fn(async () => { + throw new Error("not implemented"); + }), + runStackedAction: vi.fn(async () => { + throw new Error("not implemented"); + }), + listBranches: vi.fn(async () => { + throw new Error("not implemented"); + }), + createWorktree: vi.fn(async () => { + throw new Error("not implemented"); + }), + removeWorktree: vi.fn(async () => undefined), + createBranch: vi.fn(async () => undefined), + checkout: vi.fn(async () => undefined), + init: vi.fn(async () => undefined), + }, + contextMenu: { + show: vi.fn(async () => null), + }, + server: { + getConfig: vi.fn(async () => ({ + cwd: "/workspace", + syncEngineMode: "legacy" as const, + keybindings: [], + })), + upsertKeybinding: vi.fn(async () => ({ + keybindings: [], + })), + }, + }; +} + +describe("createStateSource", () => { + it("delegates bootstrap/catchUp/onEvent to api.state", async () => { + const api = makeApi(); + const source = createStateSource(api); + + await expect(source.bootstrap()).resolves.toEqual({ + projects: [], + threads: [], + lastStateSeq: 3, + }); + await expect(source.catchUp({ afterSeq: 1 })).resolves.toEqual({ + events: [], + lastStateSeq: 3, + }); + + const received: StateEvent[] = []; + const unsubscribe = source.onEvent((event) => { + received.push(event); + }); + expect(received).toEqual([ + { + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + payload: {}, + createdAt: "2026-02-20T00:00:00.000Z", + }, + ]); + unsubscribe(); + + expect(api.state.bootstrap).toHaveBeenCalledTimes(1); + expect(api.state.catchUp).toHaveBeenCalledWith({ afterSeq: 1 }); + expect(api.state.onEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/stateSource.ts b/apps/web/src/stateSource.ts new file mode 100644 index 000000000000..ed541d74a635 --- /dev/null +++ b/apps/web/src/stateSource.ts @@ -0,0 +1,21 @@ +import type { + NativeApi, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, +} from "@t3tools/contracts"; + +export interface StateSource { + bootstrap(): Promise; + catchUp(input: StateCatchUpInput): Promise; + onEvent(listener: (event: StateEvent) => void): () => void; +} + +export function createStateSource(api: NativeApi): StateSource { + return { + bootstrap: () => api.state.bootstrap(), + catchUp: (input) => api.state.catchUp(input), + onEvent: (listener) => api.state.onEvent(listener), + }; +} diff --git a/bun.lock b/bun.lock index 313c4c8faee6..db14ae2ffe73 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,8 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@livestore/adapter-node": "^0.3.1", + "@livestore/livestore": "^0.3.1", "@pierre/diffs": "^1.1.0-beta.16", "node-pty": "^1.1.0", "open": "^10.1.0", @@ -145,6 +147,34 @@ "@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/cli": ["@effect/cli@0.61.8", "", { "dependencies": { "ini": "^4.1.3", "toml": "^3.0.0", "yaml": "^2.5.0" }, "peerDependencies": { "@effect/platform": "^0.82.8", "@effect/printer": "^0.43.4", "@effect/printer-ansi": "^0.43.4", "effect": "^3.15.4" } }, "sha512-CRdzBUW3SkjsL1Og5bnh7nt1VN0JwNLyfnjlzTWwtIm07WFFdcZXUXquFJfmU++eTP5Kfp8JbZYvsHwmzg/q1w=="], + + "@effect/cluster": ["@effect/cluster@0.34.5", "", { "peerDependencies": { "@effect/platform": "^0.82.8", "@effect/rpc": "^0.59.9", "@effect/sql": "^0.35.8", "effect": "^3.15.4" } }, "sha512-QlXfP8sTTijyw0XQdMFmjIVywqhrbrIuQ64RIazuNdiHkBMLGqkXPerFXdQwaeDeCb8iWy4vAUZSPqThCeHIdw=="], + + "@effect/experimental": ["@effect/experimental@0.46.8", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.82.8", "effect": "^3.15.4", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-2XasqFLQZFy2PGybFdBjgflXFfPuorc/6DMxogeTDHknZa0RH4KoJQQlWaCngbKMsZrZcv0jV+Zi4aDZsVGl7g=="], + + "@effect/opentelemetry": ["@effect/opentelemetry@0.48.8", "", { "peerDependencies": { "@effect/platform": "^0.82.8", "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^3.15.4" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-ZULA3TSLVx3ylcWcmwg5wHiDIMc+LWPxUQVEqE5I+PgqNcPuhk11DjLO+ZYPWbQ2nF7NsawMactNOVCZLMQa7Q=="], + + "@effect/platform": ["@effect/platform@0.82.8", "", { "dependencies": { "find-my-way-ts": "^0.1.5", "msgpackr": "^1.11.4", "multipasta": "^0.2.5" }, "peerDependencies": { "effect": "^3.15.4" } }, "sha512-JEnIACWM2CfyKbf31dPzC903iF11SfiAk74Xs43Ez+n1ieQz2Ew2S/99KBbuesIKkDScLtn8PI23EBFMQiL0Ow=="], + + "@effect/platform-browser": ["@effect/platform-browser@0.62.8", "", { "dependencies": { "multipasta": "^0.2.5" }, "peerDependencies": { "@effect/platform": "^0.82.8", "effect": "^3.15.4" } }, "sha512-xMLpEOrQcZnqZH/DIBjZdYo6W2gtFPgbrt8uxKhDg/GrEXPLJW/yxuKh7JCO/yuQWWZf6o/qhgEVHB406ppwYg=="], + + "@effect/platform-bun": ["@effect/platform-bun@0.65.5", "", { "dependencies": { "@effect/platform-node-shared": "^0.35.5", "multipasta": "^0.2.5" }, "peerDependencies": { "@effect/cluster": "^0.34.5", "@effect/platform": "^0.82.8", "@effect/rpc": "^0.59.9", "@effect/sql": "^0.35.8", "effect": "^3.15.4" } }, "sha512-XAbcJ9UbyCHzEWrUg2kGrZBAnQgVR6MRva1NWf7oU9+Q7SgDuMriNhvIOm9OTjS5KWrwTlzYa8Lr0gmCNEkewQ=="], + + "@effect/platform-node": ["@effect/platform-node@0.81.5", "", { "dependencies": { "@effect/platform-node-shared": "^0.35.5", "mime": "^3.0.0", "undici": "^7.10.0", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.34.5", "@effect/platform": "^0.82.8", "@effect/rpc": "^0.59.9", "@effect/sql": "^0.35.8", "effect": "^3.15.4" } }, "sha512-iRJolgh/DWjs6LjhDY38XtWwxI42sXfdKYD25syl2I1sUm4j/+NBaMOA4OPyf6DNKHIYU82nVyXEGNtrBlFWww=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@0.35.5", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.5", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.34.5", "@effect/platform": "^0.82.8", "@effect/rpc": "^0.59.9", "@effect/sql": "^0.35.8", "effect": "^3.15.4" } }, "sha512-F0Hh+aueRxOw2vqo9LNqWh31ch5WBhz0Hp0vYQ8jntjLOsvN5oX/0JbGO4E5BNsMZV5HCraP6I4ACjP3KUnvCQ=="], + + "@effect/printer": ["@effect/printer@0.43.5", "", { "peerDependencies": { "@effect/typeclass": "^0.34.5", "effect": "^3.15.5" } }, "sha512-0e+GlRzP1EMFlkkNozjvlNGq2T5VgstO6BDffY+MSqic6OZyd3nyM83CCHOCOT0+0legzr7qaHtY4WdCb0GdLA=="], + + "@effect/printer-ansi": ["@effect/printer-ansi@0.43.5", "", { "dependencies": { "@effect/printer": "^0.43.5" }, "peerDependencies": { "@effect/typeclass": "^0.34.5", "effect": "^3.15.5" } }, "sha512-EQXEKevlBIZPoVGhUi1gybkMrJc9zBKr8SiCN+xVvf2qW6K0I/oOGw58lqWXA3gKfughZBX8MkCVfqWDJlrpmw=="], + + "@effect/rpc": ["@effect/rpc@0.59.9", "", { "peerDependencies": { "@effect/platform": "^0.82.8", "effect": "^3.15.4" } }, "sha512-9Nw6YRBbTgsCyVb7/AMCTML7cE6v38cN0I7tP05XAlvKdmCroifaNopDCO7w1C3EPeNBG42nYuYnwU6L0Atnhg=="], + + "@effect/sql": ["@effect/sql@0.35.8", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.33.0", "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.46.8", "@effect/platform": "^0.82.8", "effect": "^3.15.4" } }, "sha512-M1x6XMWkIHMY7iHbZgHVpq1jhhX+oFBN5LcL7Oc7cy3h2rtWf9VH2WvC2ivc2gscoKSpITlxEhGNHDFy9YrCSw=="], + + "@effect/typeclass": ["@effect/typeclass@0.34.5", "", { "peerDependencies": { "effect": "^3.15.5" } }, "sha512-nC00DQoD2Tx7EMC1B1UvgPLr7QsG5Ck2nHn94LZl5ddteDNQLPAVkAWSKM+BE5ILyE1eMTblQ+EcomPW2rtUdg=="], + "@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 +275,38 @@ "@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=="], + "@livestore/adapter-node": ["@livestore/adapter-node@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/devtools-vite": "0.3.1", "@livestore/sqlite-wasm": "0.3.1", "@livestore/utils": "0.3.1", "@livestore/webmesh": "0.3.1", "@opentelemetry/api": "1.9.0", "vite": "6.3.4" } }, "sha512-qP6nGY8du3r+4aS4yJ2YgaDVp/KzE/qBzOzleFTBR9DEM0faeLI9EXzwVq9A/k8pbxm5heF78l0ConExxxl42Q=="], + + "@livestore/adapter-web": ["@livestore/adapter-web@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/devtools-web-common": "0.3.1", "@livestore/sqlite-wasm": "0.3.1", "@livestore/utils": "0.3.1", "@livestore/webmesh": "0.3.1", "@opentelemetry/api": "1.9.0" } }, "sha512-0ZKTam+C3KeG5qiiTLFOgoMhdUBCENmuaMwxoj7WOtA2c4eKyqIy+P/1zga+az0XqaMJoG5FVszWhWMUMzNjtQ=="], + + "@livestore/common": ["@livestore/common@0.3.1", "", { "dependencies": { "@livestore/utils": "0.3.1", "@livestore/webmesh": "0.3.1", "@opentelemetry/api": "1.9.0", "graphology": "0.26.0-alpha1", "graphology-dag": "0.4.1", "graphology-types": "0.24.8" } }, "sha512-D44ia+zVH8EF0TWGUp0GQnCVvCsGKgKD/ryFl+kx607/l7GDUSagNnWkRmMwhhshJSJblQwouhA9fwjDNTBB2g=="], + + "@livestore/devtools-vite": ["@livestore/devtools-vite@0.3.1", "", { "dependencies": { "@livestore/adapter-web": "0.3.1", "@livestore/utils": "0.3.1", "vite": "*" } }, "sha512-7d4dxI1jkNtMRNxfnj7WVFIg+z5+M1FxTCUw4XrtHYeJGd7qy3GOJ/75D0lU12OQ7EbY/qSVnoU7++SkSEjMFA=="], + + "@livestore/devtools-web-common": ["@livestore/devtools-web-common@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/utils": "0.3.1", "@livestore/webmesh": "0.3.1" } }, "sha512-jU7IvebelTtvSTQ2zKNzSm5qZodwUiPsNsDURsY5Yw0DXpafcfpRobs/WYd+Wd4Q1E4oFaK05hPfWZGbVFdw7Q=="], + + "@livestore/livestore": ["@livestore/livestore@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/utils": "0.3.1", "@opentelemetry/api": "1.9.0" } }, "sha512-imw1JK5i82yTmSC4PRoZ1gUiG39jasR49c5TPQI/MCIFSbgqcKS0x1IPoB8js6tlPoVm+iH7p4JTAQKYC2q3Pg=="], + + "@livestore/sqlite-wasm": ["@livestore/sqlite-wasm@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/utils": "0.3.1", "@livestore/wa-sqlite": "1.0.5-dev.2" } }, "sha512-+JLIwpd2N214LmwlUX6arYnJ5IhvSi4Fn3Jk6E6fNfuUU4tczcL1PtgfIX2IoiPhn9QDG0YJVdJBT6KYi1uMyg=="], + + "@livestore/utils": ["@livestore/utils@0.3.1", "", { "dependencies": { "@standard-schema/spec": "1.0.0", "msgpackr": "1.11.2", "nanoid": "5.1.3", "pretty-bytes": "6.1.1" }, "peerDependencies": { "@effect/cli": "~0.61.4", "@effect/cluster": "~0.34.1", "@effect/experimental": "~0.46.4", "@effect/opentelemetry": "~0.48.4", "@effect/platform": "~0.82.4", "@effect/platform-browser": "~0.62.4", "@effect/platform-bun": "~0.65.1", "@effect/platform-node": "~0.81.1", "@effect/printer": "~0.43.2", "@effect/printer-ansi": "~0.43.2", "@effect/rpc": "~0.59.5", "@effect/sql": "~0.35.4", "@effect/typeclass": "~0.34.2", "@opentelemetry/api": "~1.9.0", "@opentelemetry/resources": "~2.0.0", "effect": "~3.15.2" } }, "sha512-DFgrKpVIu/Nn9GE0QG2uitrFLF63aIiV5rencAk3kigNPzq4iBao05wvT/JVqgRxxiM7F1SuAZYogwM4QdpcDA=="], + + "@livestore/wa-sqlite": ["@livestore/wa-sqlite@1.0.5-dev.2", "", {}, "sha512-i7tKFEp0m3+4tGLV9Z5eAr/3RZPuNOyuf4NaQ/ygF2KwzlHc3TnqouPuE2sMH06r3soXIilWYND/x0eT8x6D6Q=="], + + "@livestore/webmesh": ["@livestore/webmesh@0.3.1", "", { "dependencies": { "@livestore/utils": "0.3.1" } }, "sha512-JLYFgiOf1r7xqN7SkwdHNZdZ+geQlEdC9rg81DeC839vqqshA5TEhh045dhEkeyVJZqeg44vX5adUX6FOkhTfg=="], + + "@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=="], @@ -255,6 +317,14 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.112.0", "", {}, "sha512-4vYtWXMnXM6EaweCxbJ6bISAhkNHeN33SihvuX3wrpqaSJA4ZEoW35i9mSvE74+GDf1yTeVE+aEHA+WBpjDk/g=="], "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], @@ -313,6 +383,34 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="], + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -687,6 +785,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@3.15.5", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-9IA4xu9niyDIuWkS/whroDj3u2nbccw+9fQ9sKCzaEtuwTV5SSoNvp8XOozeSF/0Nwnh+mx2+ShYYgSueOTmMw=="], + "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,18 +827,24 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "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@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "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=="], "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=="], @@ -773,6 +879,14 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphology": ["graphology@0.26.0-alpha1", "", { "dependencies": { "events": "^3.3.0", "obliterator": "^2.0.2" }, "peerDependencies": { "graphology-types": ">=0.24.0" } }, "sha512-XQ3b+P3Vvljq/mF+O15R5PlR8kVpRL8K8vhJWW9Enh2+iGNn5c3WZNpXP2OaAacfNV3a9gV3Ia6+Qz6do2JZ8w=="], + + "graphology-dag": ["graphology-dag@0.4.1", "", { "dependencies": { "graphology-utils": "^2.4.1", "mnemonist": "^0.39.0" }, "peerDependencies": { "graphology-types": ">=0.19.0" } }, "sha512-3ch9oOAnHZDoT043vyg7ukmSkKJ505nFzaHaYOn0IF2PgGo5VtIavyVK4UpbIa4tli3hhGm1ZTdBsubTmaxu/w=="], + + "graphology-types": ["graphology-types@0.24.8", "", {}, "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q=="], + + "graphology-utils": ["graphology-utils@2.5.2", "", { "peerDependencies": { "graphology-types": ">=0.23.0" } }, "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ=="], + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -813,6 +927,8 @@ "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -999,6 +1115,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -1007,16 +1125,26 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mnemonist": ["mnemonist@0.39.8", "", { "dependencies": { "obliterator": "^2.0.1" } }, "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@1.11.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g=="], + + "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=="], "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=="], @@ -1027,6 +1155,8 @@ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1063,6 +1193,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -1071,6 +1203,8 @@ "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@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], @@ -1213,6 +1347,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=="], @@ -1247,6 +1383,8 @@ "unconfig-core": ["unconfig-core@7.4.2", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg=="], + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1277,6 +1415,8 @@ "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=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "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 +1445,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=="], @@ -1341,8 +1483,18 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@effect/platform/msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], + "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@livestore/adapter-node/vite": ["vite@6.3.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw=="], + + "@livestore/devtools-vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "@livestore/utils/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + + "@livestore/utils/nanoid": ["nanoid@5.1.3", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-zAbEOEr7u2CbxwoMRlz/pNSpRP0FdAU4pRaYunCdEezWohXFs+a0Xw7RfkKaezMsmSM1vttcLthJtwRnVtOfHQ=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -1401,6 +1553,8 @@ "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + "@livestore/adapter-node/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], @@ -1426,5 +1580,57 @@ "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], } } diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts new file mode 100644 index 000000000000..9476cd22fb73 --- /dev/null +++ b/packages/contracts/src/server.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { serverConfigSchema } from "./server"; + +describe("serverConfigSchema", () => { + it("defaults syncEngineMode to livestore when omitted", () => { + expect( + serverConfigSchema.parse({ + cwd: "/workspace", + keybindings: [], + }), + ).toEqual({ + cwd: "/workspace", + syncEngineMode: "livestore", + keybindings: [], + }); + }); + + it("accepts supported sync engine modes", () => { + expect( + serverConfigSchema.parse({ + cwd: "/workspace", + syncEngineMode: "livestore-read-pilot", + keybindings: [], + }).syncEngineMode, + ).toBe("livestore-read-pilot"); + + expect( + serverConfigSchema.parse({ + cwd: "/workspace", + syncEngineMode: "shadow", + keybindings: [], + }).syncEngineMode, + ).toBe("shadow"); + + expect( + serverConfigSchema.parse({ + cwd: "/workspace", + syncEngineMode: "livestore", + keybindings: [], + }).syncEngineMode, + ).toBe("livestore"); + }); + + it("rejects unsupported sync engine mode values", () => { + expect(() => + serverConfigSchema.parse({ + cwd: "/workspace", + syncEngineMode: "something-else", + keybindings: [], + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 6303d9cfca97..b65376960010 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,6 +3,9 @@ import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybind export const serverConfigSchema = z.object({ cwd: z.string().min(1), + syncEngineMode: z + .enum(["legacy", "shadow", "livestore-read-pilot", "livestore"]) + .default("livestore"), keybindings: resolvedKeybindingsConfigSchema.default([]), });