Skip to content
Merged
261 changes: 0 additions & 261 deletions apps/mobile/src/connection/environment-cache-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
ORCHESTRATION_CACHE_SCHEMA_VERSION,
ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
StoredOrchestrationShellSnapshot,
} from "@t3tools/client-runtime/platform";
import {
CommandId,
Expand All @@ -17,16 +16,11 @@ import {
import { describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Logger from "effect/Logger";
import * as Schema from "effect/Schema";
import { vi } from "vite-plus/test";
import * as Deferred from "effect/Deferred";
import * as Option from "effect/Option";

import * as MobileDatabase from "../persistence/mobile-database";
import { make } from "./environment-cache-store";
import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding";

const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
const PROJECT_ID = ProjectId.make("project-1");
Expand Down Expand Up @@ -408,258 +402,3 @@ describe("mobile SQLite environment cache store", () => {
}),
);
});

describe("cooperative shell cache encoding", () => {
// A fresh encoder per call keeps these cases cold; the cache has its own cases below.
const encodeStoredShellSnapshot = (stored: typeof StoredOrchestrationShellSnapshot.Type) =>
makeStoredShellSnapshotEncoder()(stored);
const encodeOriginal = Schema.encodeEffect(
Schema.fromJsonString(StoredOrchestrationShellSnapshot),
);
const stored = {
schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId: ENVIRONMENT_ID,
snapshot: {
...SHELL_SNAPSHOT,
projects: SHELL_SNAPSHOT.projects.map((project) => ({
...project,
title: " Project ",
unexpected: "drop this field",
})),
threads: Array.from({ length: 64 }, (_, index) => ({
...SHELL_SNAPSHOT.threads[0]!,
id: ThreadId.make(`thread-${index}`),
branch: " main ",
activityRunStartedAt: index % 2 === 0 ? NOW : null,
unsettledAt: index % 2 === 0 ? NOW : null,
modelSelection: {
instanceId: PROVIDER_INSTANCE_ID,
model: " gpt-5.4 ",
unexpected: "drop this field",
},
unexpected: "drop this field",
})),
archivedThreads: [{ ...SHELL_SNAPSHOT.threads[0]!, archivedAt: NOW }],
unexpected: "drop this field",
},
unexpected: "drop this field",
};

it.effect("preserves exact JSON, transforms, nulls, dates, and unknown-field stripping", () =>
Effect.gen(function* () {
const expected = yield* encodeOriginal(stored);
const actual = yield* encodeStoredShellSnapshot(stored);
expect(actual).toBe(expected);
const parsed = JSON.parse(actual);
expect(parsed).not.toHaveProperty("unexpected");
expect(parsed.snapshot).not.toHaveProperty("unexpected");
expect(parsed.snapshot.projects[0]).not.toHaveProperty("unexpected");
expect(parsed.snapshot.projects[0].title).toBe("Project");
expect(parsed.snapshot.threads[0]).not.toHaveProperty("unexpected");
expect(parsed.snapshot.threads[0].branch).toBe("main");
expect(parsed.snapshot.threads[0].modelSelection).toEqual({
instanceId: "codex",
model: "gpt-5.4",
});
expect(parsed.snapshot.threads[0].activityRunStartedAt).toBe(DateTime.formatIso(NOW));
expect(parsed.snapshot.threads[1].activityRunStartedAt).toBeNull();
expect(parsed.snapshot.archivedThreads[0].archivedAt).toBe(DateTime.formatIso(NOW));
}),
);

it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () =>
Effect.gen(function* () {
const base = SHELL_SNAPSHOT.threads[0]!;
const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base;
const row = (index: number) =>
index % 3 === 0
? { ...withoutOptional, id: ThreadId.make(`row-${index}`) }
: {
...base,
id: ThreadId.make(`row-${index}`),
snoozedUntil: DateTime.add(NOW, { minutes: index }),
pinnedAt: index % 2 === 0 ? null : NOW,
};
const mixed = {
...stored,
snapshot: {
...stored.snapshot,
threads: Array.from({ length: 65 }, (_, index) => row(index)),
archivedThreads: Array.from({ length: 33 }, (_, index) => ({
...row(index + 100),
archivedAt: DateTime.add(NOW, { hours: index }),
})),
},
};
const actual = yield* encodeStoredShellSnapshot(mixed);
expect(actual).toBe(yield* encodeOriginal(mixed));
const parsed = JSON.parse(actual);
expect(parsed.snapshot.threads).toHaveLength(65);
expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration");
expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt");
expect(parsed.snapshot.threads[64].snoozedUntil).toBe(
DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })),
);
expect(parsed.snapshot.archivedThreads).toHaveLength(33);
expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe(
DateTime.formatIso(DateTime.add(NOW, { hours: 32 })),
);
}),
);

it.effect("yields to a host timer between every 32-row chunk", () =>
Effect.gen(function* () {
const setTimer = vi.spyOn(globalThis, "setTimeout");
try {
yield* encodeStoredShellSnapshot({
...stored,
snapshot: {
...stored.snapshot,
threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })),
archivedThreads: Array.from({ length: 33 }, () => ({
...stored.snapshot.archivedThreads[0]!,
})),
},
});
// Five row chunks (3 active + 2 archived): four yields between them, one before the envelope.
expect(setTimer).toHaveBeenCalledTimes(5);
} finally {
vi.restoreAllMocks();
}
}),
);

it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () =>
Effect.gen(function* () {
const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 };
const invalidActive = {
...stored,
snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] },
};
const invalidArchived = {
...stored,
snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] },
};
const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } };
expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true);
expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true);
expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true);
expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true);
}),
);

it.effect("keeps concurrent saves independent", () =>
Effect.gen(function* () {
const other = {
...stored,
environmentId: EnvironmentId.make("environment-2"),
snapshot: {
...stored.snapshot,
threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({
...thread,
title: `Other ${thread.id}`,
})),
archivedThreads: [],
},
};
const [first, second] = yield* Effect.all(
[encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)],
{ concurrency: "unbounded" },
);
expect(first).toBe(yield* encodeOriginal(stored));
expect(second).toBe(yield* encodeOriginal(other));
}),
);

it.effect("cancels the pending host timer when encoding is interrupted", () =>
Effect.gen(function* () {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const fakeSetTimeout = globalThis.setTimeout;
const scheduled = yield* Deferred.make<ReturnType<typeof setTimeout>>();
vi.spyOn(globalThis, "setTimeout").mockImplementation((...args) => {
const timer = fakeSetTimeout(...args);
Deferred.doneUnsafe(scheduled, Effect.succeed(timer));
return timer;
});
const clearTimer = vi.spyOn(globalThis, "clearTimeout");
const fiber = yield* Effect.forkScoped(
Effect.suspend(() => encodeStoredShellSnapshot(stored)),
);
try {
const pendingTimer = yield* Deferred.await(scheduled);
expect(vi.getTimerCount()).toBe(1);
yield* Fiber.interrupt(fiber);
expect(clearTimer).toHaveBeenCalledWith(pendingTimer);
expect(vi.getTimerCount()).toBe(0);
} finally {
yield* Fiber.interrupt(fiber);
vi.restoreAllMocks();
vi.useRealTimers();
}
}),
);

it.effect("reuses encoded rows across saves without changing the payload", () =>
Effect.gen(function* () {
const encode = makeStoredShellSnapshotEncoder();
expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored));

const setTimer = vi.spyOn(globalThis, "setTimeout");
try {
// Same row references, new envelope: no row work, so no host yields.
const resaved = {
...stored,
snapshot: { ...stored.snapshot, snapshotSequence: 4 },
};
expect(yield* encode(resaved)).toBe(yield* encodeOriginal(resaved));
expect(setTimer).not.toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
}),
);

it.effect("encodes replaced, added, and moved-to-archive rows on a warm encoder", () =>
Effect.gen(function* () {
const encode = makeStoredShellSnapshotEncoder();
yield* encode(stored);
const [first, second, ...rest] = stored.snapshot.threads;
const next = {
...stored,
snapshot: {
...stored.snapshot,
threads: [
{ ...first!, title: "Renamed", itemCount: 9 },
...rest,
{ ...first!, id: ThreadId.make("thread-new"), branch: " feature " },
],
archivedThreads: [
...stored.snapshot.archivedThreads,
{ ...second!, archivedAt: DateTime.add(NOW, { hours: 1 }) },
],
},
};
const actual = yield* encode(next);
expect(actual).toBe(yield* encodeOriginal(next));
const parsed = JSON.parse(actual);
expect(parsed.snapshot.threads[0].title).toBe("Renamed");
expect(parsed.snapshot.threads.at(-1).branch).toBe("feature");
expect(parsed.snapshot.archivedThreads).toHaveLength(2);
}),
);

it.effect("does not cache rows from a failed encode", () =>
Effect.gen(function* () {
const encode = makeStoredShellSnapshotEncoder();
const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 };
const invalid = {
...stored,
snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] },
};
expect(yield* Effect.isFailure(encode(invalid))).toBe(true);
expect(yield* Effect.isFailure(encode(invalid))).toBe(true);
// Valid rows from the failed save still encode correctly afterwards.
expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored));
}),
);
});
2 changes: 1 addition & 1 deletion apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
StoredOrchestrationThreadSnapshot,
Persistence,
makeStoredShellSnapshotEncoder,
} from "@t3tools/client-runtime/platform";
import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
Expand All @@ -11,7 +12,6 @@ import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as MobileDatabase from "../persistence/mobile-database";
import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding";
import {
attachProjectFaviconDatabase,
projectFaviconDatabaseCache,
Expand Down
25 changes: 24 additions & 1 deletion apps/web/src/connection/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
setRoutesInCatalog,
replaceCatalogValue,
Persistence,
makeStoredShellSnapshotEncoder,
} from "@t3tools/client-runtime/platform";
import { TokenStore } from "@t3tools/client-runtime/authorization";
import {
Expand Down Expand Up @@ -68,7 +69,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc
const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson);
const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson);
const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);
const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson);
Expand Down Expand Up @@ -601,6 +601,25 @@ export function makeBrowserGitHubRoutingPermissions(
});
}

// A posted message resumes in a fresh task, so input and rendering get a turn between
// encoding chunks. A zero-delay timer would not do: browsers clamp nested timers to
// 4 ms and throttle them to once a second or less in a background tab.
const yieldToBrowserTask = Effect.callback<void>((resume) => {
const { port1, port2 } = new MessageChannel();
port1.addEventListener(
"message",
() => {
port1.close();
resume(Effect.void);
},
{ once: true },
);
port1.start();
port2.postMessage(null);
// A closed port delivers nothing, so interruption needs no listener bookkeeping.
return Effect.sync(() => port1.close());
});

export const layer = Layer.effectContext(
Effect.gen(function* () {
const { handle: database } = yield* Effect.acquireRelease(
Expand All @@ -609,6 +628,10 @@ export const layer = Layer.effectContext(
);
const catalog = yield* makeCatalogStore(makeCatalogBackend(database));
const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions();
// Remembers each thread row's encoding, so a save only encodes rows that changed.
const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder({
yieldToHost: yieldToBrowserTask,
});

const targetStore = Persistence.ConnectionTargetStore.of({
list: catalog.read.pipe(
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * as Persistence from "./persistence.ts";
// Flat so consumers' inferred types can name it.
export { ConnectionPersistenceError } from "./persistence.ts";
export * from "./orchestrationCache.ts";
export * from "./shellCacheEncoding.ts";
export * as PlatformConnectionSource from "./source.ts";
export * from "./storageDocument.ts";
Loading
Loading