1import {
2 ORCHESTRATION_CACHE_SCHEMA_VERSION,
3 ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
4 COMPACT_THREAD_CACHE_SCHEMA_VERSION,
5 type ConnectionPersistenceError,
6 StoredOrchestrationThreadSnapshot,
7} from "@t3tools/client-runtime/platform";
8import {
9 CommandId,
10 EnvironmentId,
11 ProjectId,
12 ProviderInstanceId,
13 RunId,
14 ThreadId,
15 TurnItemId,
16 type OrchestrationV2ShellSnapshot,
17 type OrchestrationV2ThreadDetailSnapshot,
18 type ThreadPullRequestLink,
19} from "@t3tools/contracts";
20import { describe, expect, it } from "@effect/vitest";
21import * as DateTime from "effect/DateTime";
22import * as Effect from "effect/Effect";
23import * as Fiber from "effect/Fiber";
24import * as Logger from "effect/Logger";
25import * as Option from "effect/Option";
26import * as Schema from "effect/Schema";
27import * as TestClock from "effect/testing/TestClock";
28
29import * as MobileDatabase from "../persistence/mobile-database";
30import { make } from "./environment-cache-store";
31
32const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
33const PROJECT_ID = ProjectId.make("project-1");
34const THREAD_ID = ThreadId.make("thread-1");
35const PROVIDER_INSTANCE_ID = ProviderInstanceId.make("codex");
36const NOW = DateTime.makeUnsafe("2026-07-29T12:00:00.000Z");
37const encodeLegacyThreadSnapshot = Schema.encodeEffect(
38 Schema.fromJsonString(StoredOrchestrationThreadSnapshot),
39);
40const SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = {
41 schemaVersion: 1,
42 snapshotSequence: 3,
43 projects: [
44 {
45 id: PROJECT_ID,
46 title: "Project",
47 workspaceRoot: "/repo",
48 repositoryIdentity: null,
49 defaultModelSelection: null,
50 scripts: [],
51 createdAt: "2026-07-29T12:00:00.000Z",
52 updatedAt: "2026-07-29T12:00:00.000Z",
53 },
54 ],
55 threads: [
56 {
57 id: THREAD_ID,
58 projectId: PROJECT_ID,
59 title: "Thread",
60 providerInstanceId: PROVIDER_INSTANCE_ID,
61 modelSelection: {
62 instanceId: PROVIDER_INSTANCE_ID,
63 model: "gpt-5.4",
64 },
65 runtimeMode: "full-access",
66 interactionMode: "default",
67 branch: null,
68 worktreePath: null,
69 activeProviderThreadId: null,
70 lineage: {
71 rootThreadId: THREAD_ID,
72 parentThreadId: null,
73 relationshipToParent: null,
74 },
75 forkedFrom: null,
76 createdBy: "user",
77 creationSource: "mobile",
78 latestRunId: null,
79 activeRunId: null,
80 status: "idle",
81 pendingRuntimeRequest: null,
82 latestVisibleMessage: null,
83 latestUserMessageAt: NOW,
84 activityRunStartedAt: NOW,
85 activityRunStatus: "running",
86 hasActionableProposedPlan: false,
87 itemCount: 0,
88 visibleItemCount: 0,
89 createdAt: NOW,
90 updatedAt: NOW,
91 archivedAt: null,
92 settledOverride: null,
93 settledAt: NOW,
94 unsettledAt: NOW,
95 lastVisitedAt: null,
96 titleRegeneration: {
97 requestId: CommandId.make("title-regeneration-1"),
98 startedAt: NOW,
99 },
100 deletedAt: null,
101 },
102 ],
103 archivedThreads: [],
104};
105const THREAD_SNAPSHOT: OrchestrationV2ThreadDetailSnapshot = {
106 snapshotSequence: 4,
107 projection: {
108 thread: {
109 id: THREAD_ID,
110 projectId: PROJECT_ID,
111 title: "Thread",
112 providerInstanceId: PROVIDER_INSTANCE_ID,
113 modelSelection: {
114 instanceId: PROVIDER_INSTANCE_ID,
115 model: "gpt-5.4",
116 },
117 runtimeMode: "full-access",
118 interactionMode: "default",
119 branch: null,
120 worktreePath: null,
121 activeProviderThreadId: null,
122 lineage: {
123 rootThreadId: THREAD_ID,
124 parentThreadId: null,
125 relationshipToParent: null,
126 },
127 forkedFrom: null,
128 createdBy: "user",
129 creationSource: "mobile",
130 createdAt: NOW,
131 updatedAt: NOW,
132 archivedAt: null,
133 settledOverride: null,
134 settledAt: NOW,
135 lastVisitedAt: null,
136 deletedAt: null,
137 },
138 runs: [],
139 attempts: [],
140 nodes: [],
141 subagents: [],
142 providerSessions: [],
143 providerThreads: [],
144 providerTurns: [],
145 runtimeRequests: [],
146 messages: [],
147 plans: [],
148 turnItems: [],
149 checkpointScopes: [],
150 checkpoints: [],
151 contextHandoffs: [],
152 contextTransfers: [],
153 visibleTurnItems: [],
154 updatedAt: NOW,
155 },
156};
157
158function cacheId(
159 environmentId: EnvironmentId,
160 kind: MobileDatabase.ClientCacheKind,
161 cacheKey: string,
162) {
163 return `${environmentId}:${kind}:${cacheKey}`;
164}
165
166function threadSnapshotWithItems(): OrchestrationV2ThreadDetailSnapshot {
167 const local = {
168 id: TurnItemId.make("local-notice"),
169 threadId: THREAD_ID,
170 runId: null,
171 nodeId: null,
172 providerThreadId: null,
173 providerTurnId: null,
174 nativeItemRef: null,
175 parentItemId: null,
176 ordinal: 10,
177 status: "completed" as const,
178 title: null,
179 startedAt: NOW,
180 completedAt: NOW,
181 updatedAt: NOW,
182 type: "system_notice" as const,
183 message: "A settled local notice.",
184 };
185 const hidden = { ...local, id: TurnItemId.make("hidden-notice"), ordinal: 11 };
186 const inherited = {
187 ...local,
188 id: TurnItemId.make("inherited-notice"),
189 threadId: ThreadId.make("parent-thread"),
190 };
191 return {
192 ...THREAD_SNAPSHOT,
193 historyCursor: "older-page",
194 hasMoreHistory: true,
195 latestLocalTurnOrdinal: 11,
196 projection: {
197 ...THREAD_SNAPSHOT.projection,
198 turnItems: [local, hidden],
199 visibleTurnItems: [
200 {
201 position: 0,
202 visibility: "inherited",
203 sourceThreadId: inherited.threadId,
204 sourceItemId: inherited.id,
205 item: inherited,
206 },
207 {
208 position: 1,
209 visibility: "local",
210 sourceThreadId: THREAD_ID,
211 sourceItemId: local.id,
212 item: local,
213 },
214 ],
215 },
216 };
217}
218
219// More rows than one encoding chunk, so a cold save yields to the host mid-encode.
220function threadSnapshotWithManyItems(
221 snapshotSequence: number,
222 message = "A settled local notice.",
223): OrchestrationV2ThreadDetailSnapshot {
224 const base = threadSnapshotWithItems();
225 const turnItems = Array.from({ length: 40 }, (_, index) => ({
226 ...base.projection.turnItems[0]!,
227 id: TurnItemId.make(`notice-${snapshotSequence}-${index}`),
228 ordinal: index,
229 message,
230 }));
231 return {
232 ...THREAD_SNAPSHOT,
233 snapshotSequence,
234 projection: {
235 ...THREAD_SNAPSHOT.projection,
236 turnItems,
237 visibleTurnItems: turnItems.map((item, position) => ({
238 position,
239 visibility: "local" as const,
240 sourceThreadId: THREAD_ID,
241 sourceItemId: item.id,
242 item,
243 })),
244 },
245 };
246}
247
248function makeDatabase() {
249 const values = new Map<string, string>();
250 const schemaVersions = new Map<string, number>();
251 const removed: Array<string> = [];
252 const database = MobileDatabase.MobileDatabase.of({
253 loadCache: (environmentId, kind, cacheKey) =>
254 Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))),
255 listCache: (kind) =>
256 Effect.sync(() =>
257 [...values.entries()]
258 .filter(([key]) => key.split(":")[1] === kind)
259 .map(([, payload]) => payload),
260 ),
261 saveCache: (environmentId, kind, cacheKey, schemaVersion, payload) =>
262 Effect.sync(() => {
263 const id = cacheId(environmentId, kind, cacheKey);
264 values.set(id, payload);
265 schemaVersions.set(id, schemaVersion);
266 }),
267 removeCache: (environmentId, kind, cacheKey) =>
268 Effect.sync(() => {
269 const id = cacheId(environmentId, kind, cacheKey);
270 removed.push(id);
271 values.delete(id);
272 }),
273 clearCacheKind: (environmentId, kind) =>
274 Effect.sync(() => {
275 for (const key of values.keys()) {
276 if (key.startsWith(`${environmentId}:${kind}:`)) values.delete(key);
277 }
278 }),
279 clearEnvironmentCache: (environmentId) =>
280 Effect.sync(() => {
281 for (const key of values.keys()) {
282 if (key.startsWith(`${environmentId}:`)) values.delete(key);
283 }
284 }),
285 clearAllCaches: Effect.sync(() => values.clear()),
286 inspectCaches: Effect.succeed([]),
287 loadPreferencesJson: Effect.succeedNone,
288 savePreferencesJson: () => Effect.void,
289 });
290 return { database, removed, schemaVersions, values };
291}
292
293describe("mobile SQLite environment cache store", () => {
294 it.effect("loads a V5 record and migrates to V6 on the next save without losing items", () =>
295 Effect.gen(function* () {
296 const memory = makeDatabase();
297 const store = yield* make().pipe(
298 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
299 );
300 const snapshot = threadSnapshotWithItems();
301 const id = cacheId(ENVIRONMENT_ID, "thread", THREAD_ID);
302 const raw = yield* encodeLegacyThreadSnapshot({
303 schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
304 environmentId: ENVIRONMENT_ID,
305 threadId: THREAD_ID,
306 snapshot,
307 });
308 memory.values.set(id, raw);
309 const loaded = Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID));
310 expect(loaded).toEqual(snapshot);
311 expect(memory.values.get(id)).toBe(raw);
312 yield* store.saveThread(ENVIRONMENT_ID, loaded);
313 expect(memory.schemaVersions.get(id)).toBe(COMPACT_THREAD_CACHE_SCHEMA_VERSION);
314 expect(Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID))).toEqual(
315 snapshot,
316 );
317 expect(memory.removed).toEqual([]);
318 }),
319 );
320
321 it.effect("discards corrupt or partial thread records so they can be refetched", () =>
322 Effect.gen(function* () {
323 const memory = makeDatabase();
324 const store = yield* make().pipe(
325 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
326 );
327 const id = cacheId(ENVIRONMENT_ID, "thread", THREAD_ID);
328 yield* store.saveThread(ENVIRONMENT_ID, threadSnapshotWithItems());
329 const raw = memory.values.get(id)!;
330 const partial = JSON.parse(raw);
331 delete partial.snapshot.projection.visibleTurnItems;
332 const invalidItem = JSON.parse(raw);
333 delete invalidItem.snapshot.projection.visibleTurnItems[1].item.message;
334 const corruptions = [
335 partial,
336 invalidItem,
337 ...[
338 (value: typeof partial) => {
339 delete value.snapshot.turnItemCount;
340 },
341 (value: typeof partial) => {
342 delete value.snapshot.turnItemsOmitLocalVisible;
343 },
344 (value: typeof partial) => {
345 value.snapshot.turnItemsOmitLocalVisible = false;
346 },
347 (value: typeof partial) => {
348 value.snapshot.projection.visibleTurnItems.pop();
349 },
350 (value: typeof partial) => {
351 value.snapshot.projection.visibleTurnItems.shift();
352 },
353 (value: typeof partial) => {
354 value.snapshot.projection.turnItems.pop();
355 },
356 (value: typeof partial) => {
357 value.snapshot.turnItemCount = 0;
358 },
359 (value: typeof partial) => {
360 value.snapshot.projection.visibleTurnItems[1].sourceItemId = "wrong-item";
361 },
362 (value: typeof partial) => {
363 value.snapshot.projection.visibleTurnItems[1].visibility = "inherited";
364 },
365 (value: typeof partial) => {
366 value.snapshot.projection.visibleTurnItems[1].item.threadId = "wrong-thread";
367 },
368 (value: typeof partial) => {
369 value.snapshot.projection.thread.id = "wrong-thread";
370 },
371 (value: typeof partial) => {
372 value.snapshot.projection.turnItems.push(
373 value.snapshot.projection.visibleTurnItems[1].item,
374 );
375 },
376 (value: typeof partial) => {
377 value.snapshot.projection.visibleTurnItems.push(
378 value.snapshot.projection.visibleTurnItems[1],
379 );
380 },
381 ].map((corrupt) => {
382 const value = JSON.parse(raw);
383 corrupt(value);
384 return value;
385 }),
386 ];
387 const invalidRecords = ["{broken", ...corruptions.map((value) => JSON.stringify(value))];
388 for (const invalid of invalidRecords) {
389 memory.values.set(id, invalid);
390 expect(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID)).toEqual(Option.none());
391 expect(memory.values.has(id)).toBe(false);
392 }
393 expect(memory.removed).toEqual(invalidRecords.map(() => id));
394 yield* store.saveThread(ENVIRONMENT_ID, threadSnapshotWithItems());
395 expect(Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID))).toEqual(
396 threadSnapshotWithItems(),
397 );
398 }),
399 );
400
401 it.effect("round-trips V5 exactly and restores shared items before returning to a consumer", () =>
402 Effect.gen(function* () {
403 const memory = makeDatabase();
404 const store = yield* make().pipe(
405 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
406 );
407 const original = threadSnapshotWithItems();
408 const local = original.projection.visibleTurnItems[1]!;
409 const inherited = original.projection.visibleTurnItems[0]!;
410 const synthetic = { ...local.item, id: TurnItemId.make("synthetic-notice"), ordinal: 12 };
411 const fork = {
412 id: TurnItemId.make("fork-marker"),
413 threadId: THREAD_ID,
414 runId: null,
415 nodeId: null,
416 providerTurnId: null,
417 nativeItemRef: null,
418 parentItemId: null,
419 ordinal: 0,
420 status: "completed" as const,
421 title: null,
422 startedAt: null,
423 completedAt: NOW,
424 updatedAt: NOW,
425 type: "fork" as const,
426 source: {
427 type: "run" as const,
428 threadId: ThreadId.make("parent-thread"),
429 runId: RunId.make("parent-run"),
430 },
431 targetThreadId: THREAD_ID,
432 };
433 const snapshot = {
434 ...original,
435 projection: {
436 ...original.projection,
437 turnItems: [local.item, synthetic, original.projection.turnItems[1]!],
438 visibleTurnItems: [
439 { ...local, position: 0 },
440 {
441 ...inherited,
442 position: 1,
443 sourceItemId: local.sourceItemId,
444 item: { ...inherited.item, id: local.item.id },
445 },
446 {
447 ...local,
448 position: 2,
449 visibility: "synthetic" as const,
450 sourceItemId: synthetic.id,
451 item: synthetic,
452 },
453 {
454 position: 3,
455 visibility: "synthetic" as const,
456 sourceThreadId: fork.source.threadId,
457 sourceItemId: fork.id,
458 item: fork,
459 },
460 ],
461 },
462 };
463 yield* store.saveThread(ENVIRONMENT_ID, snapshot);
464 const id = cacheId(ENVIRONMENT_ID, "thread", THREAD_ID);
465 const encoded = JSON.parse(memory.values.get(id)!);
466 expect(encoded.schemaVersion).toBe(6);
467 expect(memory.schemaVersions.get(id)).toBe(6);
468 expect(encoded.snapshot.turnItemsOmitLocalVisible).toBe(true);
469 expect(encoded.snapshot.turnItemCount).toBe(3);
470 expect(encoded.snapshot.visibleTurnItemCount).toBe(4);
471 expect(encoded.snapshot.projection.turnItems.map((item: { id: string }) => item.id)).toEqual([
472 "hidden-notice",
473 ]);
474
475 const duplicate = JSON.parse(memory.values.get(id)!);
476 duplicate.snapshot.projection.visibleTurnItems.push({
477 ...duplicate.snapshot.projection.visibleTurnItems[1],
478 position: 4,
479 });
480 duplicate.snapshot.visibleTurnItemCount += 1;
481 memory.values.set(id, JSON.stringify(duplicate));
482 expect(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID)).toEqual(Option.none());
483 expect(memory.values.has(id)).toBe(false);
484 expect(memory.removed).toEqual([id]);
485 yield* store.saveThread(ENVIRONMENT_ID, snapshot);
486
487 const loaded = Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID));
488 expect(loaded).toEqual(snapshot);
489 expect(loaded).not.toHaveProperty("turnItemsOmitLocalVisible");
490 expect(loaded).not.toHaveProperty("turnItemCount");
491 expect(loaded).not.toHaveProperty("visibleTurnItemCount");
492 expect(loaded.projection.turnItems[0]).toBe(loaded.projection.visibleTurnItems[0]!.item);
493 expect(loaded.projection.turnItems[1]).toBe(loaded.projection.visibleTurnItems[2]!.item);
494 expect(snapshot.projection.turnItems).toHaveLength(3);
495 }),
496 );
497
498 it.effect("keeps a full projection when visible rows cannot reproduce the original items", () =>
499 Effect.gen(function* () {
500 const memory = makeDatabase();
501 const store = yield* make().pipe(
502 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
503 );
504 const original = threadSnapshotWithItems();
505 const local = original.projection.turnItems[0]!;
506 for (const turnItems of [
507 [{ ...local }, original.projection.turnItems[1]!],
508 [{ ...local, message: "Newer than the visible row." }, original.projection.turnItems[1]!],
509 original.projection.turnItems.toReversed(),
510 ]) {
511 const snapshot = { ...original, projection: { ...original.projection, turnItems } };
512 yield* store.saveThread(ENVIRONMENT_ID, snapshot);
513 const raw = memory.values.get(cacheId(ENVIRONMENT_ID, "thread", THREAD_ID))!;
514 const encoded = JSON.parse(raw);
515 expect(encoded.schemaVersion).toBe(6);
516 expect(encoded.snapshot.turnItemsOmitLocalVisible).toBe(false);
517 expect(encoded.snapshot.turnItemCount).toBe(2);
518 expect(encoded.snapshot.projection.turnItems).toHaveLength(2);
519 expect(Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID))).toEqual(
520 snapshot,
521 );
522 }
523 }),
524 );
525
526 it.effect("round-trips V2 shell and thread DateTime fields with the shared cache schema", () =>
527 Effect.gen(function* () {
528 const memory = makeDatabase();
529 const store = yield* make().pipe(
530 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
531 );
532
533 const origin = {
534 serverVersion: "1.0.0",
535 databaseMigration: 70,
536 omitIdleSubagents: true,
537 omitOlderSettled: false,
538 snapshotAt: 1_800_000_000_000,
539 };
540 yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT, origin);
541 yield* store.saveThread(ENVIRONMENT_ID, THREAD_SNAPSHOT);
542
543 const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
544 const thread = Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID));
545
546 expect(shell.origin).toEqual(origin);
547
548 expect(DateTime.formatIso(shell.threads[0]!.updatedAt)).toBe("2026-07-29T12:00:00.000Z");
549 expect(DateTime.formatIso(shell.threads[0]!.latestUserMessageAt!)).toBe(
550 "2026-07-29T12:00:00.000Z",
551 );
552 expect(DateTime.formatIso(shell.threads[0]!.titleRegeneration!.startedAt)).toBe(
553 "2026-07-29T12:00:00.000Z",
554 );
555 expect(DateTime.formatIso(shell.threads[0]!.activityRunStartedAt!)).toBe(
556 "2026-07-29T12:00:00.000Z",
557 );
558 expect(DateTime.formatIso(shell.threads[0]!.unsettledAt!)).toBe("2026-07-29T12:00:00.000Z");
559 expect(DateTime.formatIso(thread.projection.thread.updatedAt)).toBe(
560 "2026-07-29T12:00:00.000Z",
561 );
562 expect(memory.schemaVersions.get(cacheId(ENVIRONMENT_ID, "shell", "snapshot"))).toBe(
563 ORCHESTRATION_CACHE_SCHEMA_VERSION,
564 );
565 expect(memory.schemaVersions.get(cacheId(ENVIRONMENT_ID, "thread", THREAD_ID))).toBe(
566 COMPACT_THREAD_CACHE_SCHEMA_VERSION,
567 );
568 }),
569 );
570
571 it.effect("returns cached threads before their pull request links, which load after", () =>
572 Effect.gen(function* () {
573 const memory = makeDatabase();
574 const store = yield* make().pipe(
575 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
576 );
577 const link: ThreadPullRequestLink = {
578 host: "github.com",
579 repository: "pingdotgg/t3code",
580 number: 42,
581 url: "https://github.com/pingdotgg/t3code/pull/42",
582 source: "agent",
583 linkedAt: "2026-07-29T12:00:00.000Z",
584 snapshot: null,
585 stack: null,
586 };
587 const linked = { ...SHELL_SNAPSHOT.threads[0]!, pullRequests: [link] };
588 const unlinked = { ...SHELL_SNAPSHOT.threads[0]!, id: ThreadId.make("thread-2") };
589 yield* store.saveShell(ENVIRONMENT_ID, {
590 ...SHELL_SNAPSHOT,
591 threads: [linked, unlinked],
592 });
593
594 const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
595 expect(shell.threads.map((thread) => thread.pullRequests)).toEqual([undefined, undefined]);
596 const links = yield* Effect.forkChild(shell.loadPullRequests!);
597 yield* TestClock.adjust("1 millis");
598 expect([...(yield* Fiber.join(links))]).toEqual([[THREAD_ID, [link]]]);
599 }),
600 );
601
602 it.effect("keeps cached threads when their pull request links cannot be read", () => {
603 const messages: Array<unknown> = [];
604 const logger = Logger.make(({ message }) => {
605 messages.push(message);
606 });
607 return Effect.gen(function* () {
608 const memory = makeDatabase();
609 const store = yield* make().pipe(
610 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
611 );
612 const link: ThreadPullRequestLink = {
613 host: "github.com",
614 repository: "pingdotgg/t3code",
615 number: 5,
616 url: "https://github.com/pingdotgg/t3code/pull/5",
617 source: "agent",
618 linkedAt: "2026-07-29T12:00:00.000Z",
619 snapshot: null,
620 stack: null,
621 };
622 const readable = {
623 ...SHELL_SNAPSHOT.threads[0]!,
624 id: ThreadId.make("thread-2"),
625 pullRequests: [link],
626 };
627 yield* store.saveShell(ENVIRONMENT_ID, {
628 ...SHELL_SNAPSHOT,
629 threads: [SHELL_SNAPSHOT.threads[0]!, readable],
630 });
631 const id = cacheId(ENVIRONMENT_ID, "shell", "snapshot");
632 const payload = JSON.parse(memory.values.get(id)!);
633 payload.snapshot.threads[0].pullRequests = [{ number: -1 }];
634 memory.values.set(id, JSON.stringify(payload));
635
636 const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
637 expect(shell.threads.map((thread) => thread.id)).toEqual([THREAD_ID, readable.id]);
638 const links = yield* Effect.forkChild(shell.loadPullRequests!);
639 yield* TestClock.adjust("1 millis");
640 expect([...(yield* Fiber.join(links))]).toEqual([[readable.id, [link]]]);
641 expect(messages).toHaveLength(1);
642 expect(memory.removed).toEqual([]);
643 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
644 });
645
646 it.effect("does not write a save that a removal, clear, or newer save overtook", () =>
647 Effect.gen(function* () {
648 const id = cacheId(ENVIRONMENT_ID, "thread", THREAD_ID);
649 const overtake = Effect.fn("overtake")(function* (
650 later: (
651 store: Effect.Success<ReturnType<typeof make>>,
652 ) => Effect.Effect<void, ConnectionPersistenceError>,
653 ) {
654 const memory = makeDatabase();
655 const store = yield* make().pipe(
656 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
657 );
658 const older = yield* store
659 .saveThread(ENVIRONMENT_ID, threadSnapshotWithManyItems(1))
660 .pipe(Effect.forkChild({ startImmediately: true }));
661 yield* later(store);
662 yield* Fiber.join(older);
663 return { memory, store };
664 });
665
666 const removed = yield* overtake((store) => store.removeThread(ENVIRONMENT_ID, THREAD_ID));
667 expect(removed.memory.values.has(id)).toBe(false);
668 const cleared = yield* overtake((store) => store.clear(ENVIRONMENT_ID));
669 expect(cleared.memory.values.has(id)).toBe(false);
670 const replaced = yield* overtake((store) =>
671 store.saveThread(ENVIRONMENT_ID, { ...THREAD_SNAPSHOT, snapshotSequence: 2 }),
672 );
673 const kept = yield* replaced.store.loadThread(ENVIRONMENT_ID, THREAD_ID);
674 expect(Option.getOrThrow(kept).snapshotSequence).toBe(2);
675 }),
676 );
677
678 it.effect("removes the older copy of a thread that grew past the cache limit", () =>
679 Effect.gen(function* () {
680 const memory = makeDatabase();
681 const store = yield* make().pipe(
682 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
683 );
684 const id = cacheId(ENVIRONMENT_ID, "thread", THREAD_ID);
685 yield* store.saveThread(ENVIRONMENT_ID, threadSnapshotWithItems());
686 expect(memory.values.has(id)).toBe(true);
687 yield* store.saveThread(ENVIRONMENT_ID, threadSnapshotWithManyItems(2, "x".repeat(200_000)));
688 expect(memory.values.has(id)).toBe(false);
689 expect(memory.removed).toEqual([id]);
690 }),
691 );
692
693 it.effect("deletes a corrupt cache record and treats it as a miss", () =>
694 Effect.gen(function* () {
695 const memory = makeDatabase();
696 const store = yield* make().pipe(
697 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
698 );
699 const id = cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo");
700 memory.values.set(id, "{not-json");
701
702 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
703 expect(memory.removed).toEqual([id]);
704 }),
705 );
706});