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 @@...@@ -1,5 +1,7 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Logger from "effect/Logger";
4import * as SecureStore from "expo-secure-store";
3import { vi } from "vite-plus/test";5import { vi } from "vite-plus/test";
46
5const secureStore = vi.hoisted(() => new Map<string, string>());7const secureStore = vi.hoisted(() => new Map<string, string>());
...@@ -16,7 +18,10 @@ vi.mock("expo-secure-store", () => ({...@@ -16,7 +18,10 @@ vi.mock("expo-secure-store", () => ({
16 }),18 }),
17}));19}));
1820
19import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";21import {
22 ManagedRelayTokenStoreError,
23 managedRelayAccessTokenStore,
24} from "./managedRelayTokenStore";
2025
21it.effect("round-trips and clears persisted managed relay access tokens", () =>26it.effect("round-trips and clears persisted managed relay access tokens", () =>
22 Effect.gen(function* () {27 Effect.gen(function* () {
...@@ -49,3 +54,32 @@ it.effect("falls back to an empty cache when persisted data is invalid", () =>...@@ -49,3 +54,32 @@ it.effect("falls back to an empty cache when persisted data is invalid", () =>
49 expect(yield* managedRelayAccessTokenStore.load).toEqual([]);54 expect(yield* managedRelayAccessTokenStore.load).toEqual([]);
50 }),55 }),
51);56);
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 @@...@@ -1,5 +1,4 @@
1import { ManagedRelay } from "@t3tools/client-runtime/relay";1import { ManagedRelay } from "@t3tools/client-runtime/relay";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
5import * as SecureStore from "expo-secure-store";4import * as SecureStore from "expo-secure-store";
...@@ -31,36 +30,50 @@ const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect(...@@ -31,36 +30,50 @@ const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect(
31);30);
32const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema);31const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema);
3332
34export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{33export class ManagedRelayTokenStoreError extends Schema.TaggedErrorClass<ManagedRelayTokenStoreError>()(
35 readonly message: string;34 "ManagedRelayTokenStoreError",
36 readonly cause: unknown;35 {
37}> {}36 operation: Schema.Literals(["read", "decode", "encode", "write", "clear"]),
3837 storageKey: Schema.String,
39const storeError =38 cause: Schema.Defect(),
40 (message: string) =>39 },
41 (cause: unknown): ManagedRelayTokenStoreError =>40) {
42 new ManagedRelayTokenStoreError({ message, cause });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) {46function logStoreFailure(error: ManagedRelayTokenStoreError) {
45 return (error: ManagedRelayTokenStoreError) =>47 return Effect.logWarning("Managed relay token store operation failed.", {
46 Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe(48 errorTag: error._tag,
47 Effect.annotateLogs({49 operation: error.operation,
48 errorTag: error._tag,50 storageKey: error.storageKey,
49 message: error.message,51 cause: error,
50 }),52 });
51 );
52}53}
5354
54const loadManagedRelayAccessTokens = Effect.tryPromise({55const loadManagedRelayAccessTokens = Effect.tryPromise({
55 try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),56 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 }),
57}).pipe(63}).pipe(
58 Effect.flatMap((encoded) =>64 Effect.flatMap((encoded) =>
59 encoded === null65 encoded === null
60 ? Effect.succeed<ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry>>([])66 ? Effect.succeed<ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry>>([])
61 : decodeManagedRelayAccessTokenCache(encoded).pipe(67 : decodeManagedRelayAccessTokenCache(encoded).pipe(
62 Effect.map((cache) => cache.entries),68 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 ),
64 ),77 ),
65 ),78 ),
66);79);
...@@ -72,34 +85,48 @@ const saveManagedRelayAccessTokens = (...@@ -72,34 +85,48 @@ const saveManagedRelayAccessTokens = (
72 version: MANAGED_RELAY_TOKEN_CACHE_VERSION,85 version: MANAGED_RELAY_TOKEN_CACHE_VERSION,
73 entries,86 entries,
74 }).pipe(87 }).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 ),
76 Effect.flatMap((encoded) =>96 Effect.flatMap((encoded) =>
77 Effect.tryPromise({97 Effect.tryPromise({
78 try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded),98 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 }),
80 }),105 }),
81 ),106 ),
82 );107 );
83108
84const clearManagedRelayAccessTokens = Effect.tryPromise({109const clearManagedRelayAccessTokens = Effect.tryPromise({
85 try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),110 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 }),
87});117});
88118
89export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {119export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
90 load: loadManagedRelayAccessTokens.pipe(120 load: loadManagedRelayAccessTokens.pipe(
91 Effect.tapError(logStoreFailure("load")),121 Effect.tapError(logStoreFailure),
92 Effect.orElseSucceed(() => []),122 Effect.orElseSucceed(() => []),
93 Effect.withSpan("mobile.managedRelayTokenStore.load"),123 Effect.withSpan("mobile.managedRelayTokenStore.load"),
94 ),124 ),
95 save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) =>125 save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) =>
96 saveManagedRelayAccessTokens(entries).pipe(126 saveManagedRelayAccessTokens(entries).pipe(Effect.tapError(logStoreFailure), Effect.ignore),
97 Effect.tapError(logStoreFailure("save")),
98 Effect.ignore,
99 ),
100 ),127 ),
101 clear: clearManagedRelayAccessTokens.pipe(128 clear: clearManagedRelayAccessTokens.pipe(
102 Effect.tapError(logStoreFailure("clear")),129 Effect.tapError(logStoreFailure),
103 Effect.ignore,130 Effect.ignore,
104 Effect.withSpan("mobile.managedRelayTokenStore.clear"),131 Effect.withSpan("mobile.managedRelayTokenStore.clear"),
105 ),132 ),