Skip to content
Source
Sign inSign up
Open History to see the last commit

mobile-database.ts

356 lines12.6 KB

1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import type { SQLiteDatabase } from "expo-sqlite";
9​
10const DATABASE_NAME = "t3code-client.db";
11const DATABASE_SCHEMA_VERSION = 1;
12​
13export const ClientCacheKind = Schema.Literals([
14 "shell",
15 "thread",
16 "server-config",
17 "vcs-refs",
18 "project-favicon",
19 "pull-request",
20]);
21export type ClientCacheKind = typeof ClientCacheKind.Type;
22​
23export interface ClientCacheSummaryRow {
24 readonly environmentId: EnvironmentId;
25 readonly kind: ClientCacheKind;
26 readonly recordCount: number;
27 readonly payloadBytes: number;
29​
30export interface StoredPreferencesJson {
31 readonly payload: string;
32 readonly updatedAt: number;
34​
35const ClientCacheSummaryRows = Schema.Array(
36 Schema.Struct({
37 environmentId: Schema.String,
38 kind: ClientCacheKind,
39 recordCount: Schema.Number,
40 payloadBytes: Schema.Number,
41 }),
42);
43​
44const MobileDatabaseOperation = Schema.Literals([
45 "open",
46 "migrate",
47 "load-cache",
48 "list-cache",
49 "save-cache",
50 "remove-cache",
51 "clear-cache-kind",
52 "clear-environment-cache",
53 "clear-all-caches",
54 "inspect-caches",
55 "load-preferences",
56 "save-preferences",
57]);
58​
59export class MobileDatabaseError extends Schema.TaggedError<MobileDatabaseError>()(
60 "MobileDatabaseError",
61 {
62 operation: MobileDatabaseOperation,
63 cause: Schema.Defect(),
64 },
65) {
66 override get message(): string {
67 return `Mobile database operation failed: ${this.operation}.`;
68 }
70​
71function databaseError(operation: typeof MobileDatabaseOperation.Type) {
72 return (cause: unknown) => new MobileDatabaseError({ operation, cause });
74​
75export class MobileDatabase extends Context.Service<
76 MobileDatabase,
77 {
78 readonly loadCache: (
79 environmentId: EnvironmentId,
80 kind: ClientCacheKind,
81 cacheKey: string,
82 ) => Effect.Effect<Option.Option<string>, MobileDatabaseError>;
83 readonly listCache: (
84 kind: ClientCacheKind,
85 ) => Effect.Effect<ReadonlyArray<string>, MobileDatabaseError>;
86 readonly saveCache: (
87 environmentId: EnvironmentId,
88 kind: ClientCacheKind,
89 cacheKey: string,
90 schemaVersion: number,
91 payload: string,
92 ) => Effect.Effect<void, MobileDatabaseError>;
93 readonly removeCache: (
94 environmentId: EnvironmentId,
95 kind: ClientCacheKind,
96 cacheKey: string,
97 ) => Effect.Effect<void, MobileDatabaseError>;
98 readonly clearCacheKind: (
99 environmentId: EnvironmentId,
100 kind: ClientCacheKind,
101 ) => Effect.Effect<void, MobileDatabaseError>;
102 readonly clearEnvironmentCache: (
103 environmentId: EnvironmentId,
104 ) => Effect.Effect<void, MobileDatabaseError>;
105 readonly clearAllCaches: Effect.Effect<void, MobileDatabaseError>;
106 readonly inspectCaches: Effect.Effect<
107 ReadonlyArray<ClientCacheSummaryRow>,
108 MobileDatabaseError
109 >;
110 readonly loadPreferencesJson: Effect.Effect<
111 Option.Option<StoredPreferencesJson>,
112 MobileDatabaseError
113 >;
114 readonly savePreferencesJson: (
115 payload: string,
116 updatedAt: number,
117 ) => Effect.Effect<void, MobileDatabaseError>;
118 }
119>()("@t3tools/mobile/persistence/mobile-database/MobileDatabase") {}
120​
121const makeAvailable = Effect.gen(function* () {
122 const database = yield* Effect.acquireRelease(
123 Effect.tryPromise({
124 try: async () => {
125 const SQLite = await import("expo-sqlite");
126 return SQLite.openDatabaseAsync(DATABASE_NAME);
127 },
128 catch: databaseError("open"),
129 }),
130 (openDatabase) => Effect.promise(() => openDatabase.closeAsync()).pipe(Effect.ignore),
131 );
132​
133 yield* Effect.tryPromise({
134 try: async () => {
135 await database.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
136 const schema = await database.getFirstAsync<{ readonly user_version: number }>(
137 "PRAGMA user_version",
138 );
139 await database.withExclusiveTransactionAsync(async (transaction) => {
140 await transaction.execAsync(`
141 CREATE TABLE IF NOT EXISTS client_cache (
142 environment_id TEXT NOT NULL,
143 kind TEXT NOT NULL,
144 cache_key TEXT NOT NULL,
145 schema_version INTEGER NOT NULL,
146 payload TEXT NOT NULL,
147 updated_at INTEGER NOT NULL,
148 PRIMARY KEY (environment_id, kind, cache_key)
149 ) WITHOUT ROWID;
150​
151 CREATE INDEX IF NOT EXISTS client_cache_environment_updated
152 ON client_cache (environment_id, updated_at DESC);
153​
154 CREATE TABLE IF NOT EXISTS client_preferences (
155 singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1),
156 payload TEXT NOT NULL,
157 updated_at INTEGER NOT NULL
158 );
159 `);
160 });
161 if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) {
162 await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`);
163 }
164 },
165 catch: databaseError("migrate"),
166 });
167​
168 return MobileDatabase.of({
169 loadCache: Effect.fn("MobileDatabase.loadCache")(function* (environmentId, kind, cacheKey) {
170 const now = yield* Clock.currentTimeMillis;
171 const row = yield* Effect.tryPromise({
172 try: () =>
173 database.getFirstAsync<{ readonly payload: string }>(
174 `SELECT payload
175 FROM client_cache
176 WHERE environment_id = ? AND kind = ? AND cache_key = ?
177 AND (kind != 'pull-request' OR updated_at >= ?)`,
178 environmentId,
179 kind,
180 cacheKey,
181 now - 30 * 24 * 60 * 60_000,
182 ),
183 catch: databaseError("load-cache"),
184 });
185 return Option.fromNullishOr(row?.payload);
186 }),
187 listCache: Effect.fn("MobileDatabase.listCache")((kind) =>
188 Effect.tryPromise({
189 try: () =>
190 database.getAllAsync<{ readonly payload: string }>(
191 "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at",
192 kind,
193 ),
194 catch: databaseError("list-cache"),
195 }).pipe(Effect.map((rows) => rows.map((row) => row.payload))),
196 ),
197 saveCache: Effect.fn("MobileDatabase.saveCache")(
198 (environmentId, kind, cacheKey, schemaVersion, payload) =>
199 Effect.tryPromise({
200 try: async () => {
201 // Bound offline PR caches to 32 MiB and 256 responses per environment; reject huge diffs that would evict everything.
202 if (
203 kind === "pull-request" &&
204 new TextEncoder().encode(payload).length > 4 * 1024 * 1024
205 )
206 return;
207 const save = async (transaction: SQLiteDatabase) => {
208 await transaction.runAsync(
209 `INSERT INTO client_cache
210 (environment_id, kind, cache_key, schema_version, payload, updated_at)
211 VALUES (?, ?, ?, ?, ?, ?)
212 ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET
213 schema_version = excluded.schema_version,
214 payload = excluded.payload,
215 updated_at = excluded.updated_at`,
216 environmentId,
217 kind,
218 cacheKey,
219 schemaVersion,
220 payload,
221 Date.now(),
222 );
223 if (kind === "pull-request") {
224 await transaction.runAsync(
225 `DELETE FROM client_cache WHERE environment_id = ? AND kind = 'pull-request'
226 AND cache_key IN (
227 SELECT cache_key FROM (
228 SELECT cache_key, updated_at,
229 ROW_NUMBER() OVER (ORDER BY updated_at DESC, cache_key) AS position,
230 SUM(LENGTH(CAST(payload AS BLOB))) OVER (
231 ORDER BY updated_at DESC, cache_key
232 ) AS total_bytes
233 FROM client_cache WHERE environment_id = ? AND kind = 'pull-request'
234 ) WHERE position > 256 OR total_bytes > 33554432 OR updated_at < ?
235 )`,
236 environmentId,
237 environmentId,
238 Date.now() - 30 * 24 * 60 * 60_000,
239 );
240 }
241 };
242 await save(database);
243 },
244 catch: databaseError("save-cache"),
245 }).pipe(Effect.asVoid),
246 ),
247 removeCache: Effect.fn("MobileDatabase.removeCache")((environmentId, kind, cacheKey) =>
248 Effect.tryPromise({
249 try: () =>
250 database.runAsync(
251 `DELETE FROM client_cache
252 WHERE environment_id = ? AND kind = ? AND cache_key = ?`,
253 environmentId,
254 kind,
255 cacheKey,
256 ),
257 catch: databaseError("remove-cache"),
258 }).pipe(Effect.asVoid),
259 ),
260 clearCacheKind: Effect.fn("MobileDatabase.clearCacheKind")((environmentId, kind) =>
261 Effect.tryPromise({
262 try: () =>
263 database.runAsync(
264 "DELETE FROM client_cache WHERE environment_id = ? AND kind = ?",
265 environmentId,
266 kind,
267 ),
268 catch: databaseError("clear-cache-kind"),
269 }).pipe(Effect.asVoid),
270 ),
271 clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")((environmentId) =>
272 Effect.tryPromise({
273 try: () =>
274 database.runAsync("DELETE FROM client_cache WHERE environment_id = ?", environmentId),
275 catch: databaseError("clear-environment-cache"),
276 }).pipe(Effect.asVoid),
277 ),
278 clearAllCaches: Effect.tryPromise({
279 try: () => database.runAsync("DELETE FROM client_cache"),
280 catch: databaseError("clear-all-caches"),
281 }).pipe(Effect.asVoid),
282 inspectCaches: Effect.tryPromise({
283 try: () =>
284 database.getAllAsync<unknown>(`
285 SELECT
286 environment_id AS environmentId,
287 kind,
288 COUNT(*) AS recordCount,
289 COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS payloadBytes
290 FROM client_cache
291 GROUP BY environment_id, kind
292 ORDER BY environment_id, kind
293 `),
294 catch: databaseError("inspect-caches"),
295 }).pipe(
296 Effect.flatMap(Schema.decodeUnknownEffect(ClientCacheSummaryRows)),
297 Effect.mapError(databaseError("inspect-caches")),
298 Effect.map((rows): ReadonlyArray<ClientCacheSummaryRow> =>
299 rows.map((row) => ({
300 environmentId: row.environmentId as EnvironmentId,
301 kind: row.kind,
302 recordCount: row.recordCount,
303 payloadBytes: row.payloadBytes,
304 })),
305 ),
306 ),
307 loadPreferencesJson: Effect.tryPromise({
308 try: () =>
309 database.getFirstAsync<StoredPreferencesJson>(
310 `SELECT payload, updated_at AS updatedAt
311 FROM client_preferences
312 WHERE singleton = 1`,
313 ),
314 catch: databaseError("load-preferences"),
315 }).pipe(Effect.map(Option.fromNullishOr)),
316 savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload, updatedAt) =>
317 Effect.tryPromise({
318 try: () =>
319 database.runAsync(
320 `INSERT INTO client_preferences (singleton, payload, updated_at)
321 VALUES (1, ?, ?)
322 ON CONFLICT (singleton) DO UPDATE SET
323 payload = excluded.payload,
324 updated_at = excluded.updated_at`,
325 payload,
326 updatedAt,
327 ),
328 catch: databaseError("save-preferences"),
329 }).pipe(Effect.asVoid),
330 ),
331 });
332});
333​
334function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] {
335 const fail = Effect.fail(error);
336 return MobileDatabase.of({
337 loadCache: Effect.fn("MobileDatabase.loadCache")(() => fail),
338 listCache: Effect.fn("MobileDatabase.listCache")(() => fail),
339 saveCache: Effect.fn("MobileDatabase.saveCache")(() => fail),
340 removeCache: Effect.fn("MobileDatabase.removeCache")(() => fail),
341 clearCacheKind: Effect.fn("MobileDatabase.clearCacheKind")(() => fail),
342 clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")(() => fail),
343 clearAllCaches: fail,
344 inspectCaches: fail,
345 loadPreferencesJson: fail,
346 savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")(() => fail),
347 });
349​
350export const make = Effect.result(makeAvailable).pipe(
351 Effect.map((result) =>
352 result._tag === "Success" ? result.success : makeUnavailable(result.failure),
353 ),
354);
355​
356export const layer = Layer.effect(MobileDatabase, make);