Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions apps/mobile/src/lib/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => {
const values = new Map<string, string>();
Expand Down Expand Up @@ -98,20 +98,27 @@ vi.mock("react-native", () => ({
},
}));

import { loadPreferences, savePreferencesPatch } from "../persistence/imperative";
let runtime: typeof import("./runtime").runtime;
let storage: typeof import("../persistence/imperative");

describe("mobile connection storage", () => {
beforeEach(() => {
// The preferences store caches per runtime, so each case gets a fresh one.
beforeEach(async () => {
mocks.clear();
vi.clearAllMocks();
vi.resetModules();
({ runtime } = await import("./runtime"));
storage = await import("../persistence/imperative");
});

afterEach(() => runtime.dispose());

it("persists tab order locally and retains it when another preference changes", async () => {
const navigationTabOrder = ["usage", "threads", "pullRequests"];
const hiddenNavigationTabs = ["issues", "home"];
await savePreferencesPatch({ navigationTabOrder, hiddenNavigationTabs });
await savePreferencesPatch({ baseFontSize: 19 });
await expect(loadPreferences()).resolves.toEqual({
await storage.savePreferencesPatch({ navigationTabOrder, hiddenNavigationTabs });
await storage.savePreferencesPatch({ baseFontSize: 19 });
await expect(storage.loadPreferences()).resolves.toEqual({
navigationTabOrder,
hiddenNavigationTabs,
baseFontSize: 19,
Expand All @@ -127,7 +134,7 @@ describe("mobile connection storage", () => {
}),
1,
);
await expect(loadPreferences()).resolves.toEqual({
await expect(storage.loadPreferences()).resolves.toEqual({
navigationTabOrder: ["usage", "threads"],
hiddenNavigationTabs: ["home"],
baseFontSize: 18,
Expand All @@ -138,12 +145,14 @@ describe("mobile connection storage", () => {
mocks.setDatabaseFailures(true, true);
await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 }));

await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
await expect(storage.loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
});

it("falls back to secure storage when SQLite cannot save preferences", async () => {
mocks.setDatabaseFailures(true, true);
await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
await expect(storage.savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({
baseFontSize: 19,
});
const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as {
readonly payload: string;
readonly updatedAt: number;
Expand All @@ -162,7 +171,7 @@ describe("mobile connection storage", () => {
}),
);

await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 });
await expect(storage.loadPreferences()).resolves.toEqual({ baseFontSize: 19 });
expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 });
expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
});
Expand All @@ -177,7 +186,7 @@ describe("mobile connection storage", () => {
}),
);

await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
await expect(storage.loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
});
Expand All @@ -186,7 +195,7 @@ describe("mobile connection storage", () => {
mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 }));

await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
await expect(storage.loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
});
});
183 changes: 183 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({}));

import * as MobileDatabase from "./mobile-database";
import * as MobilePreferences from "./mobile-preferences";
import * as MobileSecureStorage from "./mobile-secure-storage";

const FALLBACK_KEY = "t3code.preferences.fallback";

function makeStorage() {
let databaseLoads = 0;
let failDatabaseLoad = false;
let pendingSave:
| { started: PromiseWithResolvers<void>; finish: PromiseWithResolvers<void> }
| undefined;
let stored: Option.Option<MobileDatabase.StoredPreferencesJson> = Option.none();
const secure = new Map<string, string>();

const database = MobileDatabase.MobileDatabase.of({
loadCache: () => Effect.succeedNone,
listCache: () => Effect.succeed([]),
saveCache: () => Effect.void,
removeCache: () => Effect.void,
clearCacheKind: () => Effect.void,
clearEnvironmentCache: () => Effect.void,
clearAllCaches: Effect.void,
inspectCaches: Effect.succeed([]),
loadPreferencesJson: Effect.suspend(() => {
databaseLoads++;
return failDatabaseLoad
? Effect.fail(
new MobileDatabase.MobileDatabaseError({ operation: "load-preferences", cause: null }),
)
: Effect.succeed(stored);
}),
savePreferencesJson: (payload, updatedAt) =>
Effect.promise(async () => {
const pending = pendingSave;
pendingSave = undefined;
if (pending) {
pending.started.resolve();
await pending.finish.promise;
}
stored = Option.some({ payload, updatedAt });
}),
});
const secureStorage = MobileSecureStorage.MobileSecureStorage.of({
getItem: (key) => Effect.sync(() => secure.get(key) ?? null),
setItem: (key, value) => Effect.sync(() => void secure.set(key, value)),
removeItem: (key) => Effect.sync(() => void secure.delete(key)),
});

const layer = MobilePreferences.layer.pipe(
Layer.provide(
Layer.merge(
Layer.succeed(MobileDatabase.MobileDatabase, database),
Layer.succeed(MobileSecureStorage.MobileSecureStorage, secureStorage),
),
),
);
return {
secure,
layer,
databaseLoads: () => databaseLoads,
failDatabaseLoad: (fail: boolean) => {
failDatabaseLoad = fail;
},
delayNextSave: () => {
const started = Promise.withResolvers<void>();
const finish = Promise.withResolvers<void>();
pendingSave = { started, finish };
return { started: Effect.promise(() => started.promise), finish: () => finish.resolve() };
},
stored: () => Option.map(stored, ({ payload }) => JSON.parse(payload) as unknown),
setStored: (preferences: MobilePreferences.Preferences, updatedAt: number) => {
stored = Option.some({ payload: JSON.stringify(preferences), updatedAt });
},
};
}

describe("MobilePreferencesStore", () => {
it.effect("reads storage once across serialized updates", () => {
const storage = makeStorage();
return Effect.gen(function* () {
const store = yield* MobilePreferences.MobilePreferencesStore;
yield* Effect.all(
[
store.savePatch({ mathRenderingEnabled: true }),
store.update((current) => ({
collapsedProjectGroups: [...(current.collapsedProjectGroups ?? []), "a"],
})),
store.update((current) => ({
collapsedProjectGroups: [...(current.collapsedProjectGroups ?? []), "b"],
})),
],
{ concurrency: "unbounded" },
);

expect(storage.stored()).toEqual(
Option.some({ mathRenderingEnabled: true, collapsedProjectGroups: ["a", "b"] }),
);
expect(storage.databaseLoads()).toBe(1);
}).pipe(Effect.provide(storage.layer));
});

it.effect.each(["update", "fallback", "legacy"] as const)(
"waits for an interrupted %s write before the next update",
(path) => {
const storage = makeStorage();
return Effect.gen(function* () {
const store = yield* MobilePreferences.MobilePreferencesStore;
const preferences = { mathRenderingEnabled: true, workingShelfEnabled: true };
if (path === "update") {
yield* store.savePatch({ mathRenderingEnabled: true });
} else if (path === "fallback") {
storage.secure.set(
FALLBACK_KEY,
JSON.stringify({ payload: JSON.stringify(preferences), updatedAt: 100 }),
);
} else {
storage.secure.set("t3code.preferences", JSON.stringify(preferences));
}

const pending = storage.delayNextSave();
const order: string[] = [];
const write: Effect.Effect<
MobilePreferences.Preferences,
| MobilePreferences.MobilePreferencesSaveError
| MobilePreferences.MobilePreferencesLoadError
> = path === "update" ? store.savePatch({ workingShelfEnabled: true }) : store.load;
const interrupted = yield* Effect.forkChild(
write.pipe(Effect.onExit(() => Effect.sync(() => void order.push("write exited")))),
);
yield* pending.started;
const interruption = yield* Effect.forkChild(Fiber.interrupt(interrupted), {
startImmediately: true,
});
const next = yield* Effect.forkChild(
store
.savePatch({ codeWordBreak: true })
.pipe(Effect.onExit(() => Effect.sync(() => void order.push("next exited")))),
{ startImmediately: true },
);
order.push("native write released");
pending.finish();
yield* Fiber.join(interruption);
yield* Fiber.join(next);
yield* store.savePatch({ collapsedProjectGroups: ["a"] });

expect(storage.stored()).toEqual(
Option.some({ ...preferences, codeWordBreak: true, collapsedProjectGroups: ["a"] }),
);
expect(order).toEqual(["native write released", "write exited", "next exited"]);
}).pipe(Effect.provide(storage.layer));
},
);

it.effect("does not reuse a load that could not read the database", () => {
const storage = makeStorage();
storage.setStored({ mathRenderingEnabled: true }, 100);
storage.secure.set(
FALLBACK_KEY,
JSON.stringify({ payload: JSON.stringify({ mathRenderingEnabled: false }), updatedAt: 50 }),
);
return Effect.gen(function* () {
const store = yield* MobilePreferences.MobilePreferencesStore;
storage.failDatabaseLoad(true);
expect(yield* store.load).toEqual({ mathRenderingEnabled: false });

storage.failDatabaseLoad(false);
yield* store.savePatch({ codeWordBreak: true });
expect(storage.stored()).toEqual(
Option.some({ mathRenderingEnabled: true, codeWordBreak: true }),
);
}).pipe(Effect.provide(storage.layer));
});
});
14 changes: 10 additions & 4 deletions apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () {
const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
const lock = yield* Semaphore.make(1);
const lastUpdatedAt = yield* Ref.make(0);
// This store is the only writer, so a complete load or a save stays current until an update fails.
const cachedPreferences = yield* Ref.make<Preferences | undefined>(undefined);

const parsePayload = (raw: string | null): Preferences | null => {
if (raw === null || !raw.trim()) return null;
Expand Down Expand Up @@ -461,7 +463,7 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () {
),
),
);
});
}, Effect.uninterruptible);

const loadUnlocked = Effect.gen(function* () {
const databaseResult = yield* Effect.result(database.loadPreferencesJson);
Expand Down Expand Up @@ -535,7 +537,10 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () {
}
}

return parsed === null ? {} : sanitizePreferences(parsed);
const preferences = parsed === null ? {} : sanitizePreferences(parsed);
if (databaseAvailable && fallbackResult._tag === "Success")
yield* Ref.set(cachedPreferences, preferences);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return preferences;
});

const load = lock
Expand All @@ -547,16 +552,17 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () {
lock
.withPermits(1)(
Effect.gen(function* () {
const current = yield* loadUnlocked;
const current = (yield* Ref.get(cachedPreferences)) ?? (yield* loadUnlocked);
const patch = yield* Effect.try({
try: () => transform(current),
catch: (cause) => new MobilePreferencesSaveError({ cause }),
});
const next: Preferences = { ...current, ...patch };
const payload = yield* encode(PREFERENCES_KEY, next);
yield* saveJson(payload);
yield* Ref.set(cachedPreferences, sanitizePreferences(parsePayload(payload) ?? {}));
return next;
}),
}).pipe(Effect.onError(() => Ref.set(cachedPreferences, undefined))),
)
.pipe(
Effect.mapError((cause) =>
Expand Down
Loading