Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8a7f711
Persist offline environment metadata
Jul 8, 2026
cec0ff7
Refine mobile cache schema effects
Jul 8, 2026
018c39f
Use schema JSON codecs for mobile storage
Jul 8, 2026
9960346
Handle offline cache refresh failures
Jul 9, 2026
a2a34c3
Avoid stale VCS cache fallback
Jul 9, 2026
c676a65
Fix live VCS refs test fixture
Jul 9, 2026
779b2bc
Fix VCS ref refresh behavior
Jul 9, 2026
1578199
Centralize persisted mobile preferences
juliusmarminge Jul 9, 2026
7ff0669
Harden mobile persistence state
juliusmarminge Jul 9, 2026
074171a
feat(mobile): manage client caches with sqlite
juliusmarminge Jul 9, 2026
5e9b960
Merge origin/main into offline environment cache
juliusmarminge Jul 9, 2026
88eb843
fix(client): address persistence review findings
juliusmarminge Jul 9, 2026
5168c71
fix(client): keep VCS polling after transient failures
juliusmarminge Jul 9, 2026
4e203b1
fix(mobile): prevent EAS install heap exhaustion
juliusmarminge Jul 9, 2026
efe64bf
fix(mobile): use EAS default Node toolchain
juliusmarminge Jul 9, 2026
9001555
chore(mobile): add preview dev-client command
juliusmarminge Jul 9, 2026
e7cb7f8
fix(mobile): refine client storage screen
juliusmarminge Jul 9, 2026
e481f1b
fix(mobile): combine cache size with clear action
juliusmarminge Jul 9, 2026
854bed8
fix(mobile): consolidate client cache total
juliusmarminge Jul 9, 2026
ec5bdc0
fix(mobile): preserve preferences when sqlite fails
juliusmarminge Jul 9, 2026
57f8909
fix(mobile): order preference persistence fallbacks
juliusmarminge Jul 9, 2026
5e69a03
fix(mobile): validate preference fallback candidates
juliusmarminge Jul 9, 2026
eb28d7d
refactor(mobile): model persistence as Effect services
juliusmarminge Jul 9, 2026
816bb1f
refactor(mobile): keep persistence in app runtime
juliusmarminge Jul 9, 2026
8353eb3
fix(mobile): sync live activity preference state
juliusmarminge Jul 9, 2026
8f7e4f2
fix(mobile): commit live activity preferences after sync
juliusmarminge Jul 9, 2026
f3c1cc8
fix(mobile): roll back partial preference sync
juliusmarminge Jul 9, 2026
43884b5
fix(mobile): make preference rollback observable
juliusmarminge Jul 9, 2026
9dd6245
fix(mobile): preserve actual live activity state
juliusmarminge Jul 9, 2026
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
9 changes: 9 additions & 0 deletions .macroscope/check-run-agents/effect-service-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ Review changed TypeScript and directly affected call sites for the conventions b
- Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`.
- `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports.

## Dependency acquisition and runtime boundaries

- Production service construction must acquire Effect service dependencies from the environment with `yield* Foo.Foo`, and its `make`/`layer` types must expose those requirements. Flag factories or constructors that accept `Foo["Service"]` (or a plain object whose methods return `Effect`) when that value is an implementation dependency owned by the service. Passing service instances explicitly is acceptable in tests and integration harnesses; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection.
- Do not hide dependencies in module globals, closures over singleton services, or `Layer.succeed` implementations that call runtime-backed or imperative APIs. Trace helpers used by a supposedly synchronous layer far enough to verify that asynchronous services are represented in the Effect environment.
- `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at explicit application/framework boundaries such as React, native callback, CLI, or HTTP adapters. Flag their use in domain services, repositories, persistence implementations, and service constructors. A clearly named imperative adapter may bridge an Effect service into a Promise API, but it must not become a dependency of another Effect service.
- Do not create per-feature managed runtimes or Atom runtimes to smuggle the same owned resource into multiple consumers. Compose the resource once in an application-owned layer/runtime and provide its context to integration runtimes.
- When acquisition can fail but a caller must retain fallback behavior, keep the failure typed in Effect rather than bypassing the layer through an imperative runtime. Model unavailability in service operations or with an explicit optional-service layer so downstream recovery remains visible and testable.
- During review, search touched code and affected call sites for service-instance parameters, `Layer.succeed`, `ManagedRuntime.make`, and `.runPromise`/`.runPromiseExit`. Verify that each occurrence is a legitimate test seam, pure value injection, or application boundary—not fake dependency injection or a hidden runtime.

## Errors and predicates

- Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data.
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const config: ExpoConfig = {
plugins: [
"expo-font",
"expo-secure-store",
"expo-sqlite",
["@clerk/expo", { theme: "./clerk-theme.json" }],
"expo-web-browser",
[
Expand Down
12 changes: 8 additions & 4 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"development": {
"corepack": true,
"env": {
"APP_VARIANT": "development"
"APP_VARIANT": "development",
"NODE_OPTIONS": "--max-old-space-size=4096"
},
"channel": "development",
"developmentClient": true,
Expand All @@ -17,7 +18,8 @@
"preview": {
"corepack": true,
"env": {
"APP_VARIANT": "preview"
"APP_VARIANT": "preview",
"NODE_OPTIONS": "--max-old-space-size=4096"
},
"channel": "preview",
"environment": "preview",
Expand All @@ -27,7 +29,8 @@
"corepack": true,
"env": {
"APP_VARIANT": "preview",
"MOBILE_VERSION_POLICY": "fingerprint"
"MOBILE_VERSION_POLICY": "fingerprint",
"NODE_OPTIONS": "--max-old-space-size=4096"
},
"channel": "preview",
"environment": "preview",
Expand All @@ -40,7 +43,8 @@
"production": {
"corepack": true,
"env": {
"APP_VARIANT": "production"
"APP_VARIANT": "production",
"NODE_OPTIONS": "--max-old-space-size=4096"
},
"channel": "production",
"environment": "production",
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "expo start --clear",
"dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear",
"dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear'",
"start": "expo start",
"start:dev": "APP_VARIANT=development expo start",
"start:preview": "APP_VARIANT=preview expo start",
Expand Down Expand Up @@ -86,6 +87,7 @@
"expo-paste-input": "^0.1.15",
"expo-secure-store": "~56.0.4",
"expo-splash-screen": "~56.0.10",
"expo-sqlite": "~56.0.5",
"expo-symbols": "~56.0.6",
"expo-updates": "~56.0.19",
"expo-web-browser": "~56.0.5",
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr
import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
Expand Down Expand Up @@ -147,6 +148,13 @@ const SettingsSheetStack = createNativeStackNavigator({
title: "Appearance",
},
}),
SettingsClientStorage: createNativeStackScreen({
screen: SettingsClientStorageRouteScreen,
linking: "client-storage",
options: {
title: "Client Storage",
},
}),
SettingsAuth: createNativeStackScreen({
screen: SettingsAuthRouteScreen,
linking: "auth",
Expand Down
51 changes: 25 additions & 26 deletions apps/mobile/src/connection/catalog-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";

import * as MobileSecureStorage from "../persistence/mobile-secure-storage";
import { migrateLegacyConnectionCatalog } from "./migration";

export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1";
Expand All @@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) {
});
}

const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson);
const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);

const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) {
const parsed = yield* Effect.try({
try: () => JSON.parse(raw) as unknown,
catch: (cause) => catalogError("decode", cause),
});
return yield* Effect.fromResult(
Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed),
).pipe(Effect.mapError((cause) => catalogError("decode", cause)));
return yield* decodeConnectionCatalogDocument(raw).pipe(
Effect.mapError((cause) => catalogError("decode", cause)),
);
});

const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* (
catalog: ConnectionCatalogDocumentType,
) {
const encoded = yield* Effect.fromResult(
Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog),
).pipe(Effect.mapError((cause) => catalogError("encode", cause)));
return JSON.stringify(encoded);
return yield* encodeConnectionCatalogDocument(catalog).pipe(
Effect.mapError((cause) => catalogError("encode", cause)),
);
});

interface CatalogStore {
Expand All @@ -48,20 +48,19 @@ interface CatalogStore {
) => Effect.Effect<void, ConnectionTransientError>;
}

export interface SecureCatalogStorage {
readonly getItem: (key: string) => Effect.Effect<string | null, ConnectionTransientError>;
readonly setItem: (key: string, value: string) => Effect.Effect<void, ConnectionTransientError>;
readonly deleteItem: (key: string) => Effect.Effect<void, ConnectionTransientError>;
}

export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* (
storage: SecureCatalogStorage,
) {
export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () {
const storage = yield* MobileSecureStorage.MobileSecureStorage;
const getItem = (key: string) =>
storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause)));
const setItem = (key: string, value: string) =>
storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause)));
const deleteItem = (key: string) =>
storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause)));
const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none());
const lock = yield* Semaphore.make(1);

const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () {
const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY);
const legacyRaw = yield* getItem(LEGACY_CONNECTIONS_KEY);
const catalog =
legacyRaw === null || legacyRaw.trim() === ""
? EMPTY_CONNECTION_CATALOG_DOCUMENT
Expand All @@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
);
if (legacyRaw !== null && legacyRaw.trim() !== "") {
const encoded = yield* encodeCatalog(catalog);
yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded);
yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY);
yield* setItem(CONNECTION_CATALOG_KEY, encoded);
yield* deleteItem(LEGACY_CONNECTIONS_KEY);
}
return catalog;
});
Expand All @@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
if (Option.isSome(cached)) {
return cached.value;
}
const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY);
const raw = yield* getItem(CONNECTION_CATALOG_KEY);
let catalog: ConnectionCatalogDocumentType;
if (raw !== null && raw.trim() !== "") {
catalog = yield* decodeCatalog(raw).pipe(
Effect.catch((error) =>
Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe(
Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)),
Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)),
Effect.andThen(loadLegacyCatalog()),
),
),
Expand All @@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
Effect.gen(function* () {
const next = transform(yield* loadUnlocked());
const encoded = yield* encodeCatalog(next);
yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded);
yield* setItem(CONNECTION_CATALOG_KEY, encoded);
yield* Ref.set(state, Option.some(next));
}),
);
Expand Down
97 changes: 97 additions & 0 deletions apps/mobile/src/connection/environment-cache-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";

import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";
import { make } from "./environment-cache-store";

const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
const REFS: VcsListRefsResult = {
refs: [
{
name: "main",
current: true,
isDefault: true,
worktreePath: "/repo",
},
],
isRepo: true,
hasPrimaryRemote: true,
nextCursor: null,
totalCount: 1,
};

function cacheId(environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string) {
return `${environmentId}:${kind}:${cacheKey}`;
}

function makeDatabase() {
const values = new Map<string, string>();
const removed: Array<string> = [];
const database = MobileDatabase.of({
loadCache: (environmentId, kind, cacheKey) =>
Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))),
saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) =>
Effect.sync(() => {
values.set(cacheId(environmentId, kind, cacheKey), payload);
}),
removeCache: (environmentId, kind, cacheKey) =>
Effect.sync(() => {
const id = cacheId(environmentId, kind, cacheKey);
removed.push(id);
values.delete(id);
}),
clearEnvironmentCache: (environmentId) =>
Effect.sync(() => {
for (const key of values.keys()) {
if (key.startsWith(`${environmentId}:`)) values.delete(key);
}
}),
clearAllCaches: Effect.sync(() => values.clear()),
inspectCaches: Effect.succeed([]),
loadPreferencesJson: Effect.succeed(Option.none()),
savePreferencesJson: () => Effect.void,
});
return { database, removed, values };
}

describe("mobile SQLite environment cache store", () => {
it.effect("round-trips schema-validated VCS refs", () =>
Effect.gen(function* () {
const memory = makeDatabase();
const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));

yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);

expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.some(REFS));
}),
);

it.effect("deletes a corrupt cache record and treats it as a miss", () =>
Effect.gen(function* () {
const memory = makeDatabase();
const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
const id = cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo");
memory.values.set(id, "{not-json");

expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
expect(memory.removed).toEqual([id]);
}),
);

it.effect("clears one environment without touching another", () =>
Effect.gen(function* () {
const memory = makeDatabase();
const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
const otherEnvironmentId = EnvironmentId.make("environment-2");
yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);
yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS);

yield* store.clear(ENVIRONMENT_ID);

expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS));
}),
);
});
Loading
Loading