authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:31:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:31:33-07:00
logf2cb14e77486174f3e09092e8023b632488006ab
treeb27cb9a921781a0abc3266079cc7ffac56fd3696
parente042c25f568f297402ddfa76477e29aecddaa85f
signature Signed by PGP key B5690EEEBB952194

[codex] Structure mobile relay token-store failures (#3309)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 91 insertions(+), 30 deletions(-)

apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts+35-1
......@@ -1,5 +1,7 @@
11import { expect, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as Logger from "effect/Logger";
4import * as SecureStore from "expo-secure-store";
35import { vi } from "vite-plus/test";
46
57const secureStore = vi.hoisted(() => new Map<string, string>());
......@@ -16,7 +18,10 @@ vi.mock("expo-secure-store", () => ({
1618 }),
1719}));
1820
19import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";
21import {
22 ManagedRelayTokenStoreError,
23 managedRelayAccessTokenStore,
24} from "./managedRelayTokenStore";
2025
2126it.effect("round-trips and clears persisted managed relay access tokens", () =>
2227 Effect.gen(function* () {
......@@ -49,3 +54,32 @@ it.effect("falls back to an empty cache when persisted data is invalid", () =>
4954 expect(yield* managedRelayAccessTokenStore.load).toEqual([]);
5055 }),
5156);
57
58it.effect("logs structured storage failures before falling back to an empty cache", () => {
59 const messages: Array<unknown> = [];
60 const logger = Logger.make(({ message }) => {
61 messages.push(message);
62 });
63 const cause = new Error("secure store unavailable");
64 vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(cause);
65
66 return Effect.gen(function* () {
67 expect(yield* managedRelayAccessTokenStore.load).toEqual([]);
68
69 const message = messages.find(
70 (candidate) =>
71 Array.isArray(candidate) && candidate[0] === "Managed relay token store operation failed.",
72 );
73 expect(message).toBeDefined();
74 const context = (message as ReadonlyArray<unknown>)[1] as {
75 readonly cause: ManagedRelayTokenStoreError;
76 };
77 expect(context.cause).toBeInstanceOf(ManagedRelayTokenStoreError);
78 expect(context.cause).toMatchObject({
79 operation: "read",
80 storageKey: "t3code.cloud.relay-access-tokens",
81 cause,
82 });
83 expect(context.cause.message).not.toContain(cause.message);
84 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
85});
apps/mobile/src/features/cloud/managedRelayTokenStore.ts+56-29
......@@ -1,5 +1,4 @@
11import { ManagedRelay } from "@t3tools/client-runtime/relay";
2import * as Data from "effect/Data";
32import * as Effect from "effect/Effect";
43import * as Schema from "effect/Schema";
54import * as SecureStore from "expo-secure-store";
......@@ -31,36 +30,50 @@ const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect(
3130);
3231const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema);
3332
34export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{
35 readonly message: string;
36 readonly cause: unknown;
37}> {}
38
39const storeError =
40 (message: string) =>
41 (cause: unknown): ManagedRelayTokenStoreError =>
42 new ManagedRelayTokenStoreError({ message, cause });
33export class ManagedRelayTokenStoreError extends Schema.TaggedErrorClass<ManagedRelayTokenStoreError>()(
34 "ManagedRelayTokenStoreError",
35 {
36 operation: Schema.Literals(["read", "decode", "encode", "write", "clear"]),
37 storageKey: Schema.String,
38 cause: Schema.Defect(),
39 },
40) {
41 override get message(): string {
42 return `Managed relay token store operation "${this.operation}" failed for key "${this.storageKey}".`;
43 }
44}
4345
44function logStoreFailure(operation: string) {
45 return (error: ManagedRelayTokenStoreError) =>
46 Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe(
47 Effect.annotateLogs({
48 errorTag: error._tag,
49 message: error.message,
50 }),
51 );
46function logStoreFailure(error: ManagedRelayTokenStoreError) {
47 return Effect.logWarning("Managed relay token store operation failed.", {
48 errorTag: error._tag,
49 operation: error.operation,
50 storageKey: error.storageKey,
51 cause: error,
52 });
5253}
5354
5455const loadManagedRelayAccessTokens = Effect.tryPromise({
5556 try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),
56 catch: storeError("Could not read persisted relay access tokens."),
57 catch: (cause) =>
58 new ManagedRelayTokenStoreError({
59 operation: "read",
60 storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY,
61 cause,
62 }),
5763}).pipe(
5864 Effect.flatMap((encoded) =>
5965 encoded === null
6066 ? Effect.succeed<ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry>>([])
6167 : decodeManagedRelayAccessTokenCache(encoded).pipe(
6268 Effect.map((cache) => cache.entries),
63 Effect.mapError(storeError("Persisted relay access tokens are invalid.")),
69 Effect.mapError(
70 (cause) =>
71 new ManagedRelayTokenStoreError({
72 operation: "decode",
73 storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY,
74 cause,
75 }),
76 ),
6477 ),
6578 ),
6679);
......@@ -72,34 +85,48 @@ const saveManagedRelayAccessTokens = (
7285 version: MANAGED_RELAY_TOKEN_CACHE_VERSION,
7386 entries,
7487 }).pipe(
75 Effect.mapError(storeError("Could not encode relay access tokens.")),
88 Effect.mapError(
89 (cause) =>
90 new ManagedRelayTokenStoreError({
91 operation: "encode",
92 storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY,
93 cause,
94 }),
95 ),
7696 Effect.flatMap((encoded) =>
7797 Effect.tryPromise({
7898 try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded),
79 catch: storeError("Could not persist relay access tokens."),
99 catch: (cause) =>
100 new ManagedRelayTokenStoreError({
101 operation: "write",
102 storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY,
103 cause,
104 }),
80105 }),
81106 ),
82107 );
83108
84109const clearManagedRelayAccessTokens = Effect.tryPromise({
85110 try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),
86 catch: storeError("Could not clear persisted relay access tokens."),
111 catch: (cause) =>
112 new ManagedRelayTokenStoreError({
113 operation: "clear",
114 storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY,
115 cause,
116 }),
87117});
88118
89119export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
90120 load: loadManagedRelayAccessTokens.pipe(
91 Effect.tapError(logStoreFailure("load")),
121 Effect.tapError(logStoreFailure),
92122 Effect.orElseSucceed(() => []),
93123 Effect.withSpan("mobile.managedRelayTokenStore.load"),
94124 ),
95125 save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) =>
96 saveManagedRelayAccessTokens(entries).pipe(
97 Effect.tapError(logStoreFailure("save")),
98 Effect.ignore,
99 ),
126 saveManagedRelayAccessTokens(entries).pipe(Effect.tapError(logStoreFailure), Effect.ignore),
100127 ),
101128 clear: clearManagedRelayAccessTokens.pipe(
102 Effect.tapError(logStoreFailure("clear")),
129 Effect.tapError(logStoreFailure),
103130 Effect.ignore,
104131 Effect.withSpan("mobile.managedRelayTokenStore.clear"),
105132 ),