数据层
类型化的 Supabase 服务、TanStack Query 与订阅只读模型。
Electron 模板的数据访问保持小而可替换:类型化 Supabase 调用放在
app/services/,TanStack Query 负责 renderer cache 行为,zustand stores 保存跨
route 读取的 session 与 entitlement 状态。
栈
| 层级 | 文件 | 作用 |
|---|---|---|
| Supabase client | app/lib/supabase/client.ts | 带安全桌面 session storage 的 createClient<Database>()。 |
| Database types | app/lib/supabase/database.types.ts | 手工维护的 Supabase type map,供 service modules 使用。 |
| Query client | app/lib/query/client.ts | 全局 QueryClient,配置为 retry: 1、staleTime: 30000、refetchOnWindowFocus: false。 |
| Feature services | app/services/*.ts | Supabase tables、Storage、Edge Functions、analytics 与 license activation 的纯模块。 |
| Auth store | app/stores/auth-store.ts | Session、user、profile、auth-state subscription 与 sign-out。 |
| Subscription store | app/stores/subscription-store.ts | 启用 billing 后读取账号绑定的 entitlement 模型。 |
把 services 当作替换成你自己后端的边界。组件应通过 query、mutation 或 store
action 调用 service,不要把 supabase.from(...) 分散在 UI 代码中。
Todos 示例
Todos 是 Example feature,用来展示端到端模式:
| 部分 | 文件 | 说明 |
|---|---|---|
| Service | app/services/todos.ts | 从 public.todos list、create、toggle、delete 行。 |
| Query key | app/lib/query/client.ts 中的 todosQueryKey(userId) | 按用户拆分 cache entries。 |
| UI | app/routes/showcase/demos/TodosDemo.tsx | 要求已登录用户,使用 useQuery 和 useMutation,mutation 成功后更新 cache。 |
| RLS | Supabase Setup SQL | 用户只能 select、insert、update、delete user_id = auth.uid() 的行。 |
listTodos() 先按 sort_order 排序,再按最新 created_at 排序。Mutations
在 Supabase 报错时抛出异常,让 UI 显示本地化失败 toast。
Profile 与 avatar 示例
Profile 数据使用 app/services/profile.ts,avatar 上传使用
app/services/avatar.ts。
getProfile(userId)按 id select 一个profiles行。updateProfile(userId, patch)写入 profile 字段并更新updated_at。uploadAvatar()校验 PNG/JPEG/WebP 和 5 MB 上限,上传到avatars/<user-id>/avatar.<ext>,然后把路径写入profiles.avatar_url。getAvatarUrl()把保存的路径转换成 7 天有效的 signed Storage URL。removeAvatar()删除 Storage object 并清空avatar_url。
这些 services 依赖 Supabase Setup 中的内联 schema 与 Storage policies。
Subscription read model
计费使用 read model,而不是信任本地购买状态:
| 部分 | 文件 | 规则 |
|---|---|---|
| Table read | app/services/subscription.ts | 用 maybeSingle() 读取已登录用户的 subscriptions 行。 |
| Store | app/stores/subscription-store.ts | 推导 free、active、expired、unknown 或 disabled。 |
| Product mapping | shared/price-config.ts | 把 Creem product IDs 映射到 free、pro 或 lifetime。 |
| Writes | Supabase Edge Functions | 客户端永远不写 subscriptions 或 payments;RLS 只授予 SELECT。 |
VITE_BILLING_ENABLED=false 时,subscription store 进入 disabled 并且不读取
Supabase。Session 或网络读取失败时,它使用 unknown,而不是 free,避免临时
错误错误降级已付费用户或解锁付费功能。
subscriptions 和 payments 的形状与移动模板中 RevenueCat webhook 使用的账号绑定
entitlement read model 一致,所以同时拥有多个模板的团队可以共享同一套后端概念。
添加一个功能
添加 SQL 和 RLS
创建 table、indexes、triggers 和 owner-scoped policies。对于桌面客户端写入, 始终假设 renderer 是公开的,让 RLS 负责授权。
更新 database types
重新生成或手工更新 app/lib/supabase/database.types.ts,让 services 可以导入
Tables<'your_table'>、TablesInsert<'your_table'> 和
TablesUpdate<'your_table'>。
创建 service module
把 Supabase table、Storage 或 Edge Function 调用放到 app/services/。在这里规范化
错误,让组件代码专注 UI 状态。
添加 query 与 mutation 用法
共享 key 有价值时,在 app/lib/query/client.ts 添加稳定 query keys,然后从
useQuery、useMutation 或小型 store action 中调用 service。
渲染 route
添加 route、sidebar 或 command palette 入口、i18n strings 和 signed-out state。如果 功能需要 main process 能力,通过 Conveyor 暴露,不要在 renderer 里导入 Electron API。
Offline 与 network 行为
当前 query client 很简单:一次重试、30 秒 freshness,并且不在 window focus 时自动 refetch。它不会把 TanStack Query cache 持久化到磁盘。
app/hooks/use-online-status.ts 用 useSyncExternalStore 包装浏览器
online 和 offline 事件。它反映的是 OS 网络接口,不保证 Supabase 可达,更适合用作
提示信号。
对关键付费或账号数据,遵循 subscription store 的模式:把传输失败表示为 unknown,
这样 UI 能保持中性状态,而不是假装用户是 free 或 entitled。
