refactor(data): move Result utility to utils and simplify API

Relocate the Result type from `@/data/result` to `@/utils/result` and
replace the discriminated-union API (`kind`/`value`) with a simpler
boolean-based API (`success`/`data`) across all repositories and
storage classes for improved readability and consistency.
This commit is contained in:
2026-06-09 10:34:40 +08:00
parent c767322db6
commit 904cb3638a
16 changed files with 81 additions and 122 deletions
+8 -8
View File
@@ -26,7 +26,7 @@ import { RefreshTokenResponse } from "@/data/dto/auth/refresh_token_response";
import { RegisterRequest } from "@/data/dto/auth/register_request";
import { SendCodeRequest } from "@/data/dto/auth/send_code_request";
import { User } from "@/data/dto/user/user";
import { Result } from "@/data/result";
import { Result } from "@/utils/result";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import type { IAuthStorage } from "@/data/storage/auth/iauth_storage";
import { UserStorage } from "@/data/storage/user/user_storage";
@@ -204,10 +204,10 @@ export class AuthRepository {
*/
async refreshToken(): Promise<Result<RefreshTokenResponse>> {
const existing = await this.storage.getRefreshToken();
if (existing.kind !== "success" || !existing.value) {
if (!existing.success || !existing.data) {
return Result.err(new Error("No refresh token available"));
}
const refreshToken = existing.value;
const refreshToken = existing.data;
return Result.wrap(async () => {
const response = await this.api.refreshToken(
RefreshTokenRequest.from({ refreshToken }),
@@ -228,7 +228,7 @@ export class AuthRepository {
return Result.wrap(async () => {
const user = await this.api.getCurrentUser();
const writeResult = await this.userStorage.setUser(user.toJson());
if (writeResult.kind === "failure") {
if (!writeResult.success) {
console.warn(
"[AuthRepository] failed to cache current user",
writeResult.error,
@@ -261,21 +261,21 @@ export class AuthRepository {
*/
private async _saveLoginData(data: LoginResponse): Promise<void> {
const r1 = await this.storage.setLoginToken(data.token);
if (r1.kind === "failure") {
if (!r1.success) {
console.warn("[AuthRepository] setLoginToken failed", r1.error);
}
if (data.refreshToken) {
const r2 = await this.storage.setRefreshToken(data.refreshToken);
if (r2.kind === "failure") {
if (!r2.success) {
console.warn("[AuthRepository] setRefreshToken failed", r2.error);
}
}
const r3 = await this.userStorage.setUser(data.user.toJson());
if (r3.kind === "failure") {
if (!r3.success) {
console.warn("[AuthRepository] setUser failed", r3.error);
}
const r4 = await this.userStorage.setUserId(data.user.id);
if (r4.kind === "failure") {
if (!r4.success) {
console.warn("[AuthRepository] setUserId failed", r4.error);
}
}
+9 -15
View File
@@ -20,7 +20,7 @@ import { ChatHistoryResponse } from "@/data/dto/chat/chat_history_response";
import { ChatMessage } from "@/data/dto/chat/chat_message";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { SendMessageRequest } from "@/data/dto/chat/send_message_request";
import { Result } from "@/data/result";
import { Result } from "@/utils/result";
import { LocalChatStorage } from "@/data/storage/chat/local_chat_storage";
import { LocalMessage } from "@/data/storage/chat/local_message";
@@ -72,7 +72,7 @@ export class ChatRepository {
messages: readonly ChatMessage[],
): Promise<Result<void>> {
const cleared = await this.localStorage.clearAll();
if (cleared.kind === "failure") {
if (!cleared.success) {
return Result.err(cleared.error);
}
return this._mapLocalStorageResult(
@@ -83,10 +83,10 @@ export class ChatRepository {
/** 读取所有本地消息,按 dbId 升序。 */
async getLocalMessages(): Promise<Result<ChatMessage[]>> {
const result = await this.localStorage.getAllMessages();
if (result.kind === "failure") {
if (!result.success) {
return Result.err(result.error);
}
return Result.ok(result.value.map((lm) => this._localToChat(lm)));
return Result.ok(result.data.map((lm) => this._localToChat(lm)));
}
/** 清空本地消息。 */
@@ -126,20 +126,14 @@ export class ChatRepository {
}
/**
* 把 storage 层 `{kind, value/error}` 形态的 `Result<T>` 桥接到仓库层
* `{success, data/error}` 形态。错误统一用 `Error` 包装
* 把 storage 层 `Result<T>` 透传:仓库与 storage 现在共享同一全局
* `Result<T>`(来自 `@/utils/result`),不需要任何形状转换
* 该方法保留仅为未来万一又出现差异化时方便快速插入适配。
*/
private async _mapLocalStorageResult<T>(
promise: Promise<
| { readonly kind: "success"; readonly value: T }
| { readonly kind: "failure"; readonly error: unknown }
>,
promise: Promise<Result<T>>,
): Promise<Result<T>> {
const r = await promise;
if (r.kind === "success") {
return Result.ok(r.value);
}
return Result.err(r.error);
return await promise;
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
import { MetricsApi, metricsApi } from "@/data/api";
import { AppEvent } from "@/data/dto/metrics/app_event";
import { PwaEvent } from "@/data/dto/metrics/pwa_event";
import { Result } from "@/data/result";
import { Result } from "@/utils/result";
export class MetricsRepository {
constructor(private readonly api: MetricsApi) {}
+1 -1
View File
@@ -14,7 +14,7 @@ import { CreditsHistoryData } from "@/data/dto/user/credits_history_data";
import { UpdateProfileRequest } from "@/data/dto/user/update_profile_request";
import { User } from "@/data/dto/user/user";
import { UserStatsResponse } from "@/data/dto/user/user_stats_response";
import { Result } from "@/data/result";
import { Result } from "@/utils/result";
export class UserRepository {
constructor(private readonly api: UserApi) {}
-76
View File
@@ -1,76 +0,0 @@
/**
* 仓库层统一 Result<T>
*
* 与 `src/data/storage/result.ts` 的 `Result<T>``{kind, value}` 形态)并存:
* - 本类型供仓库层(`src/data/repositories/*`)使用,`{success, data}` 形态更贴近
* 原 Dart `Result.success` / `Result.failure` 命名与 API 端 envelope 风格。
* - 存储层继续使用 `{kind, value}`,因为它的消费者(localStorage、Dexie)已经在
* 大量代码中 pattern-match `r.kind`。
* - 两个类型路径不同(`@/data/result` vs `@/data/storage/result`),不冲突;
* 如需在同模块中混用,可用 `import { Result as StorageResult } from
* "@/data/storage/result"` 别名。
*
* `error` 固定为 `Error`(非 `unknown`):HTTP 拦截器与共享 unwrap 都抛 `ApiError`
* `ApiError extends Error`),仓库 `Result.wrap` 捕获后用 `toError` 规范化,
* 调用方能可靠地 `instanceof ApiError` / `error.message` 访问错误信息。
*/
export type Result<T> =
| { readonly success: true; readonly data: T }
| { readonly success: false; readonly error: Error };
export const Result = {
/** 构造成功结果。 */
ok<T>(data: T): Result<T> {
return { success: true, data };
},
/**
* 构造失败结果。`error` 接受任意 thrown 值,内部用 `toError` 归一化为 `Error`。
*/
err<T = never>(error: unknown): Result<T> {
return { success: false, error: toError(error) };
},
/** 类型守卫:true 分支。 */
isOk<T>(r: Result<T>): r is { readonly success: true; readonly data: T } {
return r.success;
},
/** 类型守卫:false 分支。 */
isErr<T>(r: Result<T>): r is { readonly success: false; readonly error: Error } {
return !r.success;
},
/** 不可变的链式 map:失败时原样传递。 */
map<T, U>(r: Result<T>, fn: (v: T) => U): Result<U> {
return r.success ? Result.ok(fn(r.data)) : r;
},
/**
* 捕获异步 thunk 的异常并包装成 `Result<T>`。仓库内主要用这个把
* `*Api` 调用从「throw 风格」桥接到「Result 风格」。
*/
async wrap<T>(thunk: () => Promise<T>): Promise<Result<T>> {
try {
return Result.ok(await thunk());
} catch (e) {
return Result.err(e);
}
},
} as const;
/**
* 把任意 thrown 值规范化为 `Error`
* - 已是 `Error` 子类 → 原样返回(保留 `.code` / `.status` 等自定义字段)
* - string → `new Error(str)`
* - 其他 → `new Error(JSON.stringify(value))`,失败则 `new Error(String(value))`
*/
export function toError(value: unknown): Error {
if (value instanceof Error) return value;
if (typeof value === "string") return new Error(value);
try {
return new Error(JSON.stringify(value));
} catch {
return new Error(String(value));
}
}
+3 -3
View File
@@ -17,7 +17,7 @@
*/
import { LocalStorage } from "../local_storage";
import { Result, type Result as ResultT } from "../result";
import { Result, type Result as ResultT } from "@/utils/result";
import { StorageKeys } from "../storage_keys";
export class AppStorage {
@@ -78,7 +78,7 @@ export class AppStorage {
todayString: string,
): Promise<ResultT<boolean>> {
const r = await AppStorage.ls.getString(key);
if (r.kind === "failure") return r;
return Result.success(r.value === null || r.value !== todayString);
if (!r.success) return r;
return Result.ok(r.data === null || r.data !== todayString);
}
}
+7 -7
View File
@@ -15,7 +15,7 @@
*/
import { LocalStorage } from "../local_storage";
import { Result, type Result as ResultT } from "../result";
import { Result, type Result as ResultT } from "@/utils/result";
import { StorageKeys } from "../storage_keys";
import type { IAuthStorage } from "./iauth_storage";
@@ -47,8 +47,8 @@ export class AuthStorage implements IAuthStorage {
}
async hasLoginToken(): Promise<ResultT<boolean>> {
const r = await this.getLoginToken();
if (r.kind === "failure") return Result.failure(r.error);
return Result.success(r.value !== null && r.value.length > 0);
if (!r.success) return Result.err(r.error);
return Result.ok(r.data !== null && r.data.length > 0);
}
clearLoginToken(): Promise<ResultT<void>> {
return this.ls.remove(StorageKeys.loginToken);
@@ -64,8 +64,8 @@ export class AuthStorage implements IAuthStorage {
}
async hasGuestToken(): Promise<ResultT<boolean>> {
const r = await this.getGuestToken();
if (r.kind === "failure") return Result.failure(r.error);
return Result.success(r.value !== null && r.value.length > 0);
if (!r.success) return Result.err(r.error);
return Result.ok(r.data !== null && r.data.length > 0);
}
clearGuestToken(): Promise<ResultT<void>> {
return this.ls.remove(StorageKeys.guestToken);
@@ -107,9 +107,9 @@ export class AuthStorage implements IAuthStorage {
async clearAuthData(): Promise<ResultT<void>> {
const r1 = await this.clearLoginToken();
if (r1.kind === "failure") return r1;
if (!r1.success) return r1;
const r2 = await this.clearGuestToken();
if (r2.kind === "failure") return r2;
if (!r2.success) return r2;
return this.clearRefreshToken();
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
* - 所有方法返回 `Promise<Result<T>>`,与 Dart `Future<Result<T>>` 对齐
*/
import type { Result } from "../result";
import type { Result } from "@/utils/result";
export interface IAuthStorage {
getLoginToken(): Promise<Result<string | null>>;
+1 -1
View File
@@ -22,7 +22,7 @@ import {
import { z } from "zod";
import { LocalStorage } from "../local_storage";
import { type Result as ResultT } from "../result";
import { type Result as ResultT } from "@/utils/result";
import { StorageKeys } from "../storage_keys";
import type { IChatStorage } from "./ichat_storage";
+1 -1
View File
@@ -11,7 +11,7 @@
* 跨天重置逻辑(`needsReset`)保留在 `GuestChatQuota` 类上,存储层只做读写。
*/
import type { Result } from "../result";
import type { Result } from "@/utils/result";
import type { GuestChatQuotaData } from "@/data/schemas/chat/guest_chat_quota";
export interface IChatStorage {
+21 -21
View File
@@ -15,7 +15,7 @@
* 数据量大时考虑升级 schema 加索引。
*/
import { Result, type Result as ResultT } from "../result";
import { Result, type Result as ResultT } from "@/utils/result";
import { LocalChatDB } from "./local_chat_db";
import { LocalMessage } from "./local_message";
@@ -47,9 +47,9 @@ export class LocalChatStorage {
async init(): Promise<ResultT<void>> {
try {
await this.db.open();
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
@@ -58,9 +58,9 @@ export class LocalChatStorage {
async saveMessage(message: LocalMessage): Promise<ResultT<void>> {
try {
await this.db.messages.add(message.toRow());
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
@@ -75,9 +75,9 @@ export class LocalChatStorage {
}
},
);
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
@@ -88,17 +88,17 @@ export class LocalChatStorage {
const rows = await this.db.messages.toArray();
// 按 dbId 升序(与 Dart `box.values.toList()` 插入序语义一致)
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
return Result.success(rows.map((r) => LocalMessage.fromRow(r)));
return Result.ok(rows.map((r) => LocalMessage.fromRow(r)));
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
async getMessageCount(): Promise<ResultT<number>> {
try {
return Result.success(await this.db.messages.count());
return Result.ok(await this.db.messages.count());
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
@@ -110,9 +110,9 @@ export class LocalChatStorage {
.filter((r) => r.sessionId === sessionId)
.toArray();
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
return Result.success(rows.map((r) => LocalMessage.fromRow(r)));
return Result.ok(rows.map((r) => LocalMessage.fromRow(r)));
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
@@ -121,9 +121,9 @@ export class LocalChatStorage {
async clearAll(): Promise<ResultT<void>> {
try {
await this.db.messages.clear();
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
@@ -134,30 +134,30 @@ export class LocalChatStorage {
async deleteMessage(index: number): Promise<ResultT<void>> {
try {
if (!Number.isInteger(index) || index < 0) {
return Result.failure(
return Result.err(
new RangeError(`deleteMessage: index ${index} out of range`),
);
}
const rows = await this.db.messages.toArray();
if (index >= rows.length) {
return Result.failure(
return Result.err(
new RangeError(`deleteMessage: index ${index} out of range`),
);
}
const target = rows[index]!;
await this.db.messages.delete(target.dbId!);
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
async close(): Promise<ResultT<void>> {
try {
this.db.close();
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
export * from "./auth_storage";
export * from "./local_storage";
export * from "./result";
export * from "@/utils/result";
export * from "./storage_keys";
export * from "./app/app_storage";
export * from "./auth/auth_storage";
+23 -23
View File
@@ -16,7 +16,7 @@
*/
import { type ZodType } from "zod";
import { Result, type Result as ResultT } from "./result";
import { Result, type Result as ResultT } from "@/utils/result";
const SSR_ERROR_MSG =
"localStorage is not available in this environment (SSR or non-browser)";
@@ -52,38 +52,38 @@ export class LocalStorage {
}
async getString(key: string): Promise<ResultT<string | null>> {
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG));
try {
const v = this.storage!.getItem(key);
if (v === null || v === "") return Result.success(null);
return Result.success(v);
if (v === null || v === "") return Result.ok(null);
return Result.ok(v);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
async setString(key: string, value: string): Promise<ResultT<void>> {
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG));
try {
this.storage!.setItem(key, value);
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
async getJson<T>(key: string, schema: ZodType<T>): Promise<ResultT<T | null>> {
const r = await this.getString(key);
if (r.kind === "failure") return Result.failure(r.error);
if (r.value === null) {
if (!r.success) return Result.err(r.error);
if (r.data === null) {
const value: T | null = null;
return Result.success(value);
return Result.ok(value);
}
try {
const parsed = schema.parse(JSON.parse(r.value));
return Result.success(parsed);
const parsed = schema.parse(JSON.parse(r.data));
return Result.ok(parsed);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
@@ -92,32 +92,32 @@ export class LocalStorage {
value: T,
schema?: ZodType<T>,
): Promise<ResultT<void>> {
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG));
try {
const validated = schema ? schema.parse(value) : value;
this.storage!.setItem(key, JSON.stringify(validated));
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
async remove(key: string): Promise<ResultT<void>> {
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG));
try {
this.storage!.removeItem(key);
return Result.success(undefined);
return Result.ok(undefined);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
async has(key: string): Promise<ResultT<boolean>> {
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG));
try {
return Result.success(this.storage!.getItem(key) !== null);
return Result.ok(this.storage!.getItem(key) !== null);
} catch (e) {
return Result.failure(e);
return Result.err(e);
}
}
}
-35
View File
@@ -1,35 +0,0 @@
/**
* 通用 Result<T> 类型
*
* 对齐 Dart 项目中的 `sealed class Result<T>` / `Success<T>` / `Failure<T>` 模式。
* TypeScript 没有 sealed class,用判别联合(discriminated union)实现等价效果:
* 调用方在 `switch (result.kind)` 上做穷尽匹配,编译器在 strict 模式下能保证完整性。
*
* `error` 字段保持 `unknown`(不强制 `Error`)以匹配 Dart 端可抛任意对象的能力。
*/
export type Result<T> =
| { readonly kind: "success"; readonly value: T }
| { readonly kind: "failure"; readonly error: unknown };
export const Result = {
success<T>(value: T): Result<T> {
return { kind: "success", value };
},
failure<T = never>(error: unknown): Result<T> {
return { kind: "failure", error };
},
isSuccess<T>(r: Result<T>): r is { kind: "success"; value: T } {
return r.kind === "success";
},
isFailure<T>(r: Result<T>): r is { kind: "failure"; error: unknown } {
return r.kind === "failure";
},
map<T, U>(r: Result<T>, fn: (v: T) => U): Result<U> {
return r.kind === "success" ? Result.success(fn(r.value)) : r;
},
} as const;
+1 -1
View File
@@ -11,7 +11,7 @@
* 具体序列化由实现层通过 Zod schema 校验后转换为 `User` 类实例。
*/
import type { Result } from "../result";
import type { Result } from "@/utils/result";
import type { UserData } from "@/data/schemas/user/user";
export interface IUserStorage {
+3 -3
View File
@@ -17,7 +17,7 @@
import { UserSchema, type UserData } from "@/data/schemas/user/user";
import { LocalStorage } from "../local_storage";
import { type Result as ResultT } from "../result";
import { type Result as ResultT } from "@/utils/result";
import { StorageKeys } from "../storage_keys";
import type { IUserStorage } from "./iuser_storage";
@@ -85,9 +85,9 @@ export class UserStorage implements IUserStorage {
async clearUserData(): Promise<ResultT<void>> {
const r1 = await this.clearUser();
if (r1.kind === "failure") return r1;
if (!r1.success) return r1;
const r2 = await this.clearUserId();
if (r2.kind === "failure") return r2;
if (!r2.success) return r2;
return this.clearAvatarUrl();
}
}