Merge branch 'dev-0.2.1' into dev-0.2
# Conflicts: # generated/prisma/internal/class.ts # generated/prisma/internal/prismaNamespace.ts # generated/prisma/internal/prismaNamespaceBrowser.ts # generated/prisma/models/BoardColumn.ts # generated/prisma/models/Project.ts # generated/prisma/models/ProjectMember.ts # generated/prisma/models/Task.ts # generated/prisma/models/TaskMember.ts # generated/prisma/models/TaskTag.ts # generated/prisma/models/User.ts # server/routes/projects.js # server/routes/tasks.js # src/api/task.js
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// PowerSync 后端连接器: 凭证获取 + 上传队列落地 (0.2.1)
|
||||
// 契约见 server/routes/powersync.js: POST /api/powersync/upload { ops: [{ id, table, op, uuid, data }] }
|
||||
// Web SDK 无需基类, 实现 fetchCredentials / uploadData 两个方法即可
|
||||
import { api, getToken } from '../../api/client.js'
|
||||
|
||||
// 上次成功的同步凭证: 离线时复用, 避免 SDK 因"凭证不可用"清空本地库
|
||||
const CRED_KEY = 'iboard_ps_cred'
|
||||
|
||||
export class IBoardConnector {
|
||||
async fetchCredentials() {
|
||||
// 未登录: 返回 null 让 SDK 保持断开 (登录后由页面触发 connect)
|
||||
if (!getToken()) return null
|
||||
try {
|
||||
const { token, endpoint } = await api('/powersync/token', { method: 'POST' })
|
||||
// VITE_POWERSYNC_URL: 打包端 (Tauri 外壳等) 可覆盖服务端下发的同步地址
|
||||
const ep = import.meta.env.VITE_POWERSYNC_URL || endpoint || ''
|
||||
if (ep && token) {
|
||||
try { localStorage.setItem(CRED_KEY, JSON.stringify({ endpoint: ep, token, ts: Date.now() })) } catch { /* ignore */ }
|
||||
}
|
||||
return { endpoint: ep, token }
|
||||
} catch {
|
||||
// 离线 / 服务不可达: 复用上次凭证 (本地库数据继续可读, 联网后自动恢复同步)
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(CRED_KEY) || 'null')
|
||||
if (cached?.endpoint && cached?.token) return { endpoint: cached.endpoint, token: cached.token }
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateCredentials() {
|
||||
// 交给 SDK: 下次 fetchCredentials 会重新尝试取新凭证 (离线时回退缓存)
|
||||
}
|
||||
|
||||
async uploadData(database) {
|
||||
const batch = await database.getCrudBatch()
|
||||
if (!batch) return
|
||||
|
||||
// 本地行的 id 即为 uuid (客户端创建时生成), 服务端按 uuid 幂等 upsert。
|
||||
// PATCH 的 opData 只含变更列, 这里统一回读全行按 PUT 上行, 保证服务端整行 upsert 语义。
|
||||
const ops = []
|
||||
for (let i = 0; i < batch.crud.length; i++) {
|
||||
const op = batch.crud[i]
|
||||
let data = op.opData || {}
|
||||
if (op.op !== 'DELETE') {
|
||||
try {
|
||||
const row = await database.get(`SELECT * FROM "${op.table}" WHERE id = ?`, [op.id])
|
||||
if (row) data = row
|
||||
} catch { /* 回读失败则用 opData 兜底 */ }
|
||||
}
|
||||
ops.push({
|
||||
id: String(i),
|
||||
table: op.table,
|
||||
op: op.op === 'DELETE' ? 'DELETE' : 'PUT',
|
||||
uuid: op.id,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await api('/powersync/upload', { method: 'POST', body: { ops } })
|
||||
} catch (e) {
|
||||
// 网络失败/401: 抛出让 SDK 稍后重试本批
|
||||
throw e
|
||||
}
|
||||
const failed = (res?.results || []).filter((r) => !r.ok)
|
||||
if (failed.length) {
|
||||
// 单条业务错误: 记录并放行 (重试也无法成功, 避免队列卡死); 服务端已尽力落地其余 op
|
||||
console.error('[powersync] upload 部分失败:', failed)
|
||||
}
|
||||
await batch.complete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// PowerSync 本地库单例 (0.2.1 离线编辑)
|
||||
// 页面用法: usePowerSyncQuery 等见 src/lib/powersync/react.jsx; 写入用 db.execute/writeTransaction
|
||||
import { PowerSyncDatabase } from '@powersync/web'
|
||||
import { AppSchema } from './schema.js'
|
||||
import { IBoardConnector } from './connector.js'
|
||||
|
||||
let dbInstance = null
|
||||
|
||||
export function getPowersyncDb() {
|
||||
if (!dbInstance) {
|
||||
dbInstance = new PowerSyncDatabase({
|
||||
schema: AppSchema,
|
||||
database: {
|
||||
// 默认 IDBBatchAtomicVFS (IndexedDB), 兼容性最广; WebView2 (Chromium) 后续可切 OPFS 加速
|
||||
dbFilename: 'iboard-powersync.db',
|
||||
},
|
||||
})
|
||||
}
|
||||
return dbInstance
|
||||
}
|
||||
|
||||
/** 登录后调用: 建立同步连接 (幂等) */
|
||||
export async function connectPowersync() {
|
||||
const db = getPowersyncDb()
|
||||
db.connect(new IBoardConnector())
|
||||
return db
|
||||
}
|
||||
|
||||
/** 退出登录时调用: 断开同步并清空本地库 */
|
||||
export async function disconnectAndClearPowersync() {
|
||||
if (!dbInstance) return
|
||||
try {
|
||||
await dbInstance.disconnectAndClear()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// PowerSync React 接入 (0.2.1 离线编辑)
|
||||
// 用法: 在 App 外层包 <PowerSyncProvider>; 任务模块内用 re-export 的 useQuery/useStatus/usePowerSync
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { PowerSyncContext } from '@powersync/react'
|
||||
import { getPowersyncDb, connectPowersync, disconnectAndClearPowersync } from './db.js'
|
||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||
|
||||
/**
|
||||
* 登录后建立同步连接; 登出后断开并清空本地库 (避免下一用户读到残留数据)
|
||||
*/
|
||||
export function PowerSyncProvider({ children }) {
|
||||
const { user } = useAuth()
|
||||
const db = useMemo(() => getPowersyncDb(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
connectPowersync().catch((e) => console.error('[powersync] connect 失败:', e))
|
||||
} else {
|
||||
disconnectAndClearPowersync()
|
||||
}
|
||||
}, [user])
|
||||
|
||||
return <PowerSyncContext.Provider value={db}>{children}</PowerSyncContext.Provider>
|
||||
}
|
||||
|
||||
export { useQuery, useStatus, useSuspenseQuery, usePowerSync } from '@powersync/react'
|
||||
@@ -0,0 +1,98 @@
|
||||
// PowerSync 客户端 schema (0.2.1 离线编辑)
|
||||
// 表名/列名与 deploy/0.2.1/powersync/sync-config.yaml 的输出严格一致 (PascalCase + text 型 id 由 SDK 自建)
|
||||
// 见 docs/V0.2/离线编辑模块/0.2.1实施方案.md
|
||||
import { column, Schema, Table } from '@powersync/web'
|
||||
|
||||
// 时间/JSON 列在同步流里以文本落地, 本地按字符串读写 (JSON 列为 JSON 字符串)
|
||||
const project = new Table(
|
||||
{
|
||||
uuid: column.text,
|
||||
code: column.text,
|
||||
name: column.text,
|
||||
ownerId: column.text,
|
||||
isPersonal: column.integer,
|
||||
archived: column.integer,
|
||||
notes: column.text,
|
||||
taskKinds: column.text,
|
||||
icon: column.text,
|
||||
autoArchive: column.text,
|
||||
createdAt: column.text,
|
||||
updatedAt: column.text,
|
||||
},
|
||||
{ indexes: { code: ['code'] } },
|
||||
)
|
||||
|
||||
const projectMember = new Table({
|
||||
uuid: column.text,
|
||||
projectId: column.text,
|
||||
userId: column.text,
|
||||
permission: column.text,
|
||||
createdAt: column.text,
|
||||
})
|
||||
|
||||
const boardColumn = new Table(
|
||||
{
|
||||
uuid: column.text,
|
||||
projectId: column.text,
|
||||
name: column.text,
|
||||
sortOrder: column.integer,
|
||||
boundStatus: column.text,
|
||||
retention: column.text,
|
||||
createdAt: column.text,
|
||||
updatedAt: column.text,
|
||||
},
|
||||
{ indexes: { project: ['projectId'] } },
|
||||
)
|
||||
|
||||
const task = new Table(
|
||||
{
|
||||
uuid: column.text,
|
||||
projectId: column.text,
|
||||
columnId: column.text,
|
||||
parentId: column.text,
|
||||
title: column.text,
|
||||
description: column.text,
|
||||
kind: column.text,
|
||||
markDate: column.text,
|
||||
status: column.text,
|
||||
doneAt: column.text,
|
||||
assigneeId: column.text,
|
||||
creatorId: column.text,
|
||||
extras: column.text,
|
||||
sortOrder: column.integer,
|
||||
number: column.integer,
|
||||
archived: column.integer,
|
||||
archivedAt: column.text,
|
||||
archiveSource: column.text,
|
||||
createdAt: column.text,
|
||||
updatedAt: column.text,
|
||||
},
|
||||
{ indexes: { project: ['projectId'], column: ['columnId'], number: ['projectId', 'number'] } },
|
||||
)
|
||||
|
||||
const taskMember = new Table({
|
||||
uuid: column.text,
|
||||
taskId: column.text,
|
||||
userId: column.text,
|
||||
})
|
||||
|
||||
const taskTag = new Table({
|
||||
uuid: column.text,
|
||||
taskId: column.text,
|
||||
name: column.text,
|
||||
})
|
||||
|
||||
const user = new Table({
|
||||
name: column.text,
|
||||
email: column.text,
|
||||
})
|
||||
|
||||
export const AppSchema = new Schema({
|
||||
Project: project,
|
||||
ProjectMember: projectMember,
|
||||
BoardColumn: boardColumn,
|
||||
Task: task,
|
||||
TaskMember: taskMember,
|
||||
TaskTag: taskTag,
|
||||
User: user,
|
||||
})
|
||||
Reference in New Issue
Block a user