1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import { vi } from "vite-plus/test";
5
6const openDatabaseAsync = vi.hoisted(() => vi.fn());
7
8vi.mock("expo-sqlite", () => ({ openDatabaseAsync }));
9
10import * as MobileDatabase from "./mobile-database";
11
12describe("mobile database legacy cache migration", () => {
13 it.effect("keeps acquisition failures typed on database operations", () =>
14 Effect.scoped(
15 Effect.gen(function* () {
16 openDatabaseAsync.mockRejectedValueOnce(new Error("SQLite unavailable"));
17
18 const database = yield* MobileDatabase.make;
19 const result = yield* Effect.result(database.loadPreferencesJson);
20
21 expect(result).toMatchObject({
22 _tag: "Failure",
23 failure: { _tag: "MobileDatabaseError", operation: "open" },
24 });
25 }),
26 ),
27 );
28
29 it("maps legacy thread records to their SQLite identity", () => {
30 const payload = JSON.stringify({
31 schemaVersion: 2,
32 environmentId: "environment-1",
33 threadId: "thread-1",
34 snapshot: {},
35 });
36
37 expect(MobileDatabase.decodeLegacyCacheRecord("connection-thread-snapshots", payload)).toEqual({
38 environmentId: "environment-1",
39 kind: "thread",
40 cacheKey: "thread-1",
41 schemaVersion: 2,
42 payload,
43 });
44 });
45
46 it("preserves the old shell payload for schema decoding after migration", () => {
47 const payload = JSON.stringify({
48 schemaVersion: 1,
49 environmentId: "environment-1",
50 snapshotReceivedAt: "2026-07-01T00:00:00.000Z",
51 snapshot: {},
52 });
53
54 expect(MobileDatabase.decodeLegacyCacheRecord("shell-snapshots", payload)).toEqual({
55 environmentId: "environment-1",
56 kind: "shell",
57 cacheKey: "snapshot",
58 schemaVersion: 1,
59 payload,
60 });
61 });
62
63 it("skips malformed legacy records", () => {
64 expect(MobileDatabase.decodeLegacyCacheRecord("connection-vcs-refs", "{not-json")).toBeNull();
65 expect(
66 MobileDatabase.decodeLegacyCacheRecord(
67 "connection-vcs-refs",
68 JSON.stringify({ schemaVersion: 1, environmentId: "environment-1" }),
69 ),
70 ).toBeNull();
71 });
72});
73
74// Exercise the production SQL against SQLite, including the eviction window functions.
75describe("offline pull request cache", () => {
76 const environmentId = "cached-environment" as import("@t3tools/contracts").EnvironmentId;
77 const otherEnvironment = "other-environment" as import("@t3tools/contracts").EnvironmentId;
78
79 async function sqliteDatabase() {
80 const { DatabaseSync } = await import("node:sqlite");
81 const sqlite = new DatabaseSync(":memory:");
82 sqlite.exec("PRAGMA user_version = 1");
83 const database = {
84 execAsync: async (sql: string) => {
85 sqlite.exec(sql);
86 },
87 runAsync: async (sql: string, ...args: Array<string | number>) =>
88 sqlite.prepare(sql).run(...args),
89 getFirstAsync: async (sql: string, ...args: Array<string | number>) =>
90 sqlite.prepare(sql).get(...args),
91 getAllAsync: async (sql: string, ...args: Array<string | number>) =>
92 sqlite.prepare(sql).all(...args),
93 withExclusiveTransactionAsync: async (run: (transaction: unknown) => Promise<void>) => {
94 await run(database);
95 },
96 closeAsync: async () => {
97 sqlite.close();
98 },
99 };
100 openDatabaseAsync.mockResolvedValueOnce(database);
101 return sqlite;
102 }
103
104 it.effect("restores saved records and clears them with the environment", () =>
105 Effect.scoped(
106 Effect.gen(function* () {
107 yield* Effect.promise(sqliteDatabase);
108 const database = yield* MobileDatabase.make;
109 yield* database.saveCache(environmentId, "pull-request", "v1:list", 1, '{"entries":[]}');
110 yield* database.saveCache(otherEnvironment, "pull-request", "v1:list", 1, "other");
111 expect(yield* database.loadCache(environmentId, "pull-request", "v1:list")).toEqual(
112 Option.some('{"entries":[]}'),
113 );
114 yield* database.clearEnvironmentCache(environmentId);
115 expect((yield* database.loadCache(environmentId, "pull-request", "v1:list"))._tag).toBe(
116 "None",
117 );
118 expect((yield* database.loadCache(otherEnvironment, "pull-request", "v1:list"))._tag).toBe(
119 "Some",
120 );
121 }),
122 ),
123 );
124
125 it.effect(
126 "expires old records and bounds count and payload size without evicting other cache kinds",
127 () =>
128 Effect.scoped(
129 Effect.gen(function* () {
130 const sqlite = yield* Effect.promise(sqliteDatabase);
131 const database = yield* MobileDatabase.make;
132 yield* database.saveCache(environmentId, "thread", "thread", 1, "thread");
133 yield* database.saveCache(environmentId, "pull-request", "old", 1, "old");
134 sqlite.prepare("UPDATE client_cache SET updated_at = 0 WHERE cache_key = 'old'").run();
135 expect((yield* database.loadCache(environmentId, "pull-request", "old"))._tag).toBe(
136 "None",
137 );
138 for (let i = 0; i < 260; i++)
139 yield* database.saveCache(environmentId, "pull-request", `page-${i}`, 1, "page");
140 expect((yield* database.listCache("pull-request")).length).toBe(256);
141 const large = "x".repeat(4 * 1024 * 1024);
142 for (let i = 0; i < 10; i++)
143 yield* database.saveCache(environmentId, "pull-request", `diff-${i}`, 1, large);
144 const summary = yield* database.inspectCaches;
145 const prs = summary.find((row) => row.kind === "pull-request");
146 expect(prs?.payloadBytes).toBeLessThanOrEqual(32 * 1024 * 1024);
147 expect(prs?.recordCount).toBeLessThanOrEqual(256);
148 expect((yield* database.loadCache(environmentId, "thread", "thread"))._tag).toBe("Some");
149 yield* database.saveCache(environmentId, "pull-request", "oversized", 1, large + "x");
150 expect((yield* database.loadCache(environmentId, "pull-request", "oversized"))._tag).toBe(
151 "None",
152 );
153 }),
154 ),
155 );
156});