From 9caaa303e5c2c19d710d377bf32cbb763a6f2085 Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Mon, 26 Oct 2026 01:14:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(0.2.1):=20upload=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=AE=A2=E6=88=B7=E7=AB=AF=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=A4=96=E9=94=AE=E5=88=97=E5=8D=B3=E7=88=B6?= =?UTF-8?q?=E8=A1=8C=20uuid=20(projectId/columnId/parentId/taskId);=20?= =?UTF-8?q?=E9=99=84=20Node=20=E9=97=AD=E7=8E=AF=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 64 +++++++++ package.json | 2 + scripts/verify-powersync-client.mjs | 210 ++++++++++++++++++++++++++++ server/routes/powersync.js | 31 ++-- 4 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 scripts/verify-powersync-client.mjs diff --git a/package-lock.json b/package-lock.json index 2d9f350..bc288eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,11 @@ "ws": "^8.21.2" }, "devDependencies": { + "@powersync/node": "^1.2.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/cli": "^2.0.0", "@vitejs/plugin-react": "^4.3.1", + "better-sqlite3": "^13.0.3", "cheerio": "^1.2.0", "concurrently": "^9.0.0", "csv-parser": "^3.2.0", @@ -1633,6 +1635,45 @@ "js-logger": "^1.6.1" } }, + "node_modules/@powersync/node": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@powersync/node/-/node-1.2.0.tgz", + "integrity": "sha512-wBPIMHxdOV7NYSQP2ozLkCku7ezC+SnMvveyetnZQ0LpI3jZCQ08hXDc+lyZFngkJaLMfj/BOkr/QF261lBcAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@powersync/common": "2.4.0", + "@powersync/shared-internals": "1.4.0", + "comlink": "^4.4.2", + "undici": "^7.11.0" + }, + "peerDependencies": { + "@powersync/common": "^2.4.0", + "better-sqlite3": "12.x || 13.x" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + } + } + }, + "node_modules/@powersync/node/node_modules/@powersync/common": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@powersync/common/-/common-2.4.0.tgz", + "integrity": "sha512-ngVogy+B8NQeI5l+0Es8qMxnGozUie8Q6R4+0/AHA3oBAhDG5UmeYw/LquTzZJSOPnCmvGgFSqovblV+JKzxKQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@powersync/node/node_modules/@powersync/shared-internals": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@powersync/shared-internals/-/shared-internals-1.4.0.tgz", + "integrity": "sha512-OiqqDuW6tePW4Ed7C5IWutBKrtcqKuTMlVMQNwUZtKtqr7ZrqKlb+YP5l4niuz8mpqDYo3/HNU0dpc+3mneSBQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@powersync/common": "^2.4.0" + } + }, "node_modules/@powersync/react": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@powersync/react/-/react-1.10.0.tgz", @@ -3212,6 +3253,19 @@ "integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==", "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/body-parser": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", @@ -5393,6 +5447,16 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-cron": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", diff --git a/package.json b/package.json index ed2d713..6c5615e 100644 --- a/package.json +++ b/package.json @@ -54,9 +54,11 @@ "ws": "^8.21.2" }, "devDependencies": { + "@powersync/node": "^1.2.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/cli": "^2.0.0", "@vitejs/plugin-react": "^4.3.1", + "better-sqlite3": "^13.0.3", "cheerio": "^1.2.0", "concurrently": "^9.0.0", "csv-parser": "^3.2.0", diff --git a/scripts/verify-powersync-client.mjs b/scripts/verify-powersync-client.mjs new file mode 100644 index 0000000..836ab8f --- /dev/null +++ b/scripts/verify-powersync-client.mjs @@ -0,0 +1,210 @@ +// 0.2.1 闭环验证脚本 (Node 客户端): 同步下行 -> 本地离线写入 -> 上行 -> 服务端编号 -> 同步回填 +// 用法: node scripts/verify-powersync-client.mjs [BASE_URL] [EMAIL] [PASSWORD] +// BASE_URL 默认 http://bh.vps.honor3.com:13001 +// 流程: +// 1. REST 登录 -> /api/powersync/token +// 2. Node SDK 连接 PowerSync, 等待首次同步, 校验 ETL 数据 (项目/任务/编号) +// 3. 本地新建一条验证任务 (number 为空, 模拟离线写入) +// 4. 等待 upload 完成并同步回填 -> 本地行应获得服务端分配的 number +// 5. 清理: 本地删除该任务 (上行级联删除), 恢复数据原状 +import 'dotenv/config' +import crypto from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs' +import { column, Schema, Table, PowerSyncDatabase, createConsoleLogger, LogLevels } from '@powersync/node' + +const BASE = (process.argv[2] || 'http://bh.vps.honor3.com:13001').replace(/\/+$/, '') +const EMAIL = process.argv[3] || 'admin@iboard.local' +const PASSWORD = process.argv[4] || 'admin123' + +const uuid = () => crypto.randomUUID() +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +// 与 src/lib/powersync/schema.js 同构的最小 schema (验证只用到 Project/Task/TaskTag) +const AppSchema = new Schema({ + 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, createdAt: column.text, updatedAt: column.text, + }), + 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, + createdAt: column.text, updatedAt: column.text, + }), + TaskTag: new Table({ uuid: column.text, taskId: column.text, name: column.text }), + TaskMember: new Table({ uuid: column.text, taskId: column.text, userId: column.text }), + ProjectMember: new Table({ uuid: column.text, projectId: column.text, userId: column.text, permission: column.text, createdAt: column.text }), + 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, + }), + User: new Table({ name: column.text, email: column.text }), +}) + +async function api(path, { method = 'GET', token, body } = {}) { + const res = await fetch(BASE + '/api' + path, { + method, + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(`${path} -> ${res.status}: ${data.error || '请求失败'}`) + return data +} + +class Connector { + constructor(appToken, endpoint) { + // appToken: 应用 JWT (调业务 API 上行用); endpoint + sync JWT 由 fetchCredentials 提供 + this.appToken = appToken + this.endpoint = endpoint + } + async fetchCredentials() { + const cred = await api('/powersync/token', { method: 'POST', token: this.appToken }) + return { endpoint: cred.endpoint, token: cred.token } + } + async uploadData(database) { + const batch = await database.getCrudBatch() + if (!batch) return + const ops = [] + for (let i = 0; i < batch.crud.length; i++) { + const op = batch.crud[i] + let data = op.opData || {} + if (op.op !== 'DELETE') { + const row = await database.getOptional(`SELECT * FROM "${op.table}" WHERE id = ?`, [op.id]) + if (row) data = row + } + ops.push({ id: String(i), table: op.table, op: op.op === 'DELETE' ? 'DELETE' : 'PUT', uuid: op.id, data }) + } + const res = await fetch(BASE + '/api/powersync/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + this.appToken }, + body: JSON.stringify({ ops }), + }) + const out = await res.json().catch(() => ({})) + if (!res.ok) throw new Error('upload failed: ' + JSON.stringify(out)) + const failed = (out.results || []).filter((r) => !r.ok) + if (failed.length) console.log('[verify] upload 部分失败:', JSON.stringify(failed)) + await batch.complete() + } +} + +async function main() { + console.log(`[verify] BASE = ${BASE}`) + const login = await api('/auth/login', { method: 'POST', body: { email: EMAIL, password: PASSWORD } }) + console.log('[verify] 1. 登录 OK, user:', login.user.email) + const cred = await api('/powersync/token', { method: 'POST', token: login.token }) + if (!cred.endpoint) throw new Error('POWERSYNC_URL 未配置 (服务端 /powersync/token 返回空 endpoint)') + console.log('[verify] 2. 同步凭证 OK, endpoint:', cred.endpoint) + + const dbFile = path.join(os.tmpdir(), `iboard-verify-${Date.now()}.db`) + const logger = createConsoleLogger({ minLevel: LogLevels.debug }) + const db = new PowerSyncDatabase({ + schema: AppSchema, + database: { dbFilename: dbFile, databaseWorkerLogLevel: LogLevels.debug }, + sync: { logLevel: LogLevels.debug }, + logger, + }) + db.registerListener({ + statusChanged: (s) => { + const c = s?.dataFlowStatus || {} + console.log(`[verify] sync status: connected=${s?.connected} connecting=${s?.connecting} err=${c.downloadError?.message || c.uploadError?.message || '-'}`) + }, + }) + try { + await db.connect(new Connector(login.token, cred.endpoint)) + console.log('[verify] connect() 已建立') + } catch (e) { + console.error('[verify] connect 失败:', e?.message) + throw e + } + await Promise.race([ + db.waitForFirstSync(), + sleep(45000).then(() => { throw new Error('waitForFirstSync 超时 (45s)') }), + ]).catch(async (e) => { + console.log('[verify] waitForFirstSync 未完成, 轮询内部状态...') + for (let i = 0; i < 10; i++) { + await sleep(3000) + const st = db.currentStatus + const counts = { + projects: (await db.getOptional('SELECT COUNT(*) AS n FROM Project'))?.n, + tasks: (await db.getOptional('SELECT COUNT(*) AS n FROM Task'))?.n, + } + console.log(`[verify] poll#${i}:`, JSON.stringify({ + connected: st?.connected, connecting: st?.connecting, hasSynced: st?.hasSynced, + lastSyncedAt: st?.lastSyncedAt, dl: st?.dataFlowStatus?.downloadError?.message, + ul: st?.dataFlowStatus?.uploadError?.message, ...counts, + })) + if (st?.hasSynced && counts.projects > 0) { + console.log('[verify] 轮询确认同步已完成') + return + } + } + throw e + }) + console.log('[verify] 3. 首次同步完成') + + const projects = await db.getAll('SELECT id, code, name FROM Project ORDER BY id') + console.log(`[verify] 项目 ${projects.length} 个:`, projects.map((p) => `${p.code || '?'}(${p.name})`).join(', ')) + const tasks = await db.getAll('SELECT COUNT(*) AS n FROM Task') + const numbered = await db.getAll('SELECT COUNT(*) AS n FROM Task WHERE number IS NOT NULL') + console.log(`[verify] 任务 ${tasks[0].n} 条 (已有编号 ${numbered[0].n} 条)`) + if (!projects.length || tasks[0].n === 0) throw new Error('同步数据为空, ETL 未生效?') + + // 本地新建验证任务 (模拟离线写入: number 为空, 等服务端分配) + const target = projects.find((p) => p.code) || projects[0] + const firstCol = await db.get(`SELECT id, boundStatus FROM BoardColumn WHERE projectId = ? ORDER BY sortOrder, id LIMIT 1`, [target.id]) + const marker = `离线闭环验证-${Date.now()}` + const newId = uuid() + const ts = new Date().toISOString() + await db.writeTransaction(async (tx) => { + await tx.execute( + `INSERT INTO Task (id, uuid, projectId, columnId, parentId, title, description, kind, markDate, status, doneAt, assigneeId, creatorId, extras, sortOrder, number, createdAt, updatedAt) + VALUES (?, ?, ?, ?, NULL, ?, '', NULL, NULL, ?, NULL, NULL, ?, '[]', 0, NULL, ?, ?)`, + [newId, newId, target.id, firstCol.id, marker, firstCol.boundStatus || 'todo', String(login.user.id), ts, ts], + ) + }) + console.log(`[verify] 4. 本地写入任务 "${marker}" (number 为空), 等待上行 + 服务端定号 + 同步回填...`) + + let syncedBack = null + for (let i = 0; i < 30; i++) { + await sleep(2000) + const row = await db.getOptional('SELECT number, updatedAt FROM Task WHERE id = ?', [newId]) + const localCheck = await db.getOptional(`SELECT 1 AS x FROM ps_crud LIMIT 1`) + if (!localCheck) { + // 队列已清空 (上行完成); 等待下行回填 number + if (row && row.number != null) { syncedBack = row; break } + if (i > 10 && row && row.number == null) continue + } + } + if (!syncedBack) throw new Error('超时: 未观测到服务端编号回填') + console.log(`[verify] 5. 闭环 OK! 服务端分配编号: ${target.code}-${syncedBack.number} (项目 ${target.code})`) + + // 直接从服务端核对 (REST 视角) + const serverCheck = await api(`/tasks/${newId}`, { token: login.token }) + console.log(`[verify] 6. 服务端确认: 任务存在, number=${serverCheck.item.number}, uuid=${serverCheck.item.uuid.slice(0, 13)}...`) + + // 清理验证数据 (本地删除 -> 上行删除) + await db.execute('DELETE FROM Task WHERE id = ?', [newId]) + for (let i = 0; i < 15; i++) { + await sleep(2000) + const left = await db.getOptional('SELECT 1 AS x FROM ps_crud LIMIT 1') + if (!left) break + } + const gone = await api(`/tasks/${newId}`, { token: login.token }).catch(() => null) + if (!gone) console.log('[verify] 7. 验证任务已从服务端清除') + else throw new Error('验证任务删除未同步到服务端') + + db.close() + fs.rmSync(dbFile, { force: true }) + fs.rmSync(dbFile + '-journal', { force: true }) + console.log('[verify] 全部通过: 下行同步 / 离线写入 / 上行幂等 / 服务端编号 / 回填 / 删除同步') +} + +main().catch((e) => { + console.error('[verify] 失败:', e.message) + process.exit(1) +}) diff --git a/server/routes/powersync.js b/server/routes/powersync.js index 4a7cdd4..fdb4faa 100644 --- a/server/routes/powersync.js +++ b/server/routes/powersync.js @@ -31,7 +31,8 @@ router.post('/token', authRequired, async (req, res, next) => { // ---------- 上传队列 ---------- // 契约: POST { ops: [{ id, table, op: 'PUT'|'DELETE', uuid, data }] } // table ∈ Project | BoardColumn | ProjectMember | Task | TaskMember | TaskTag -// data 为客户端本地全量行; 父引用走 *Uuid 字段 (projectUuid / columnUuid / parentUuid / taskUuid) +// data 为客户端本地全量行 (列名与客户端 schema 一致); 外键列 (projectId/columnId/parentId/taskId) +// 的值即父行的本地 id = uuid, 服务端据此解析为内部自增 id // 返回 { results: [{ id, ok, skipped?, error? }] }, 单条失败不影响同批其它 op const TABLES = ['Project', 'BoardColumn', 'ProjectMember', 'Task', 'TaskMember', 'TaskTag'] const TABLE_RANK = Object.fromEntries(TABLES.map((t, i) => [t, i])) @@ -120,7 +121,7 @@ async function applyPut(op, ctx) { if (Object.keys(data).length) await prisma.boardColumn.update({ where: { uuid }, data }) return {} } - const project = await prisma.project.findUnique({ where: { uuid: d.projectUuid } }) + const project = await prisma.project.findUnique({ where: { uuid: d.projectId } }) if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限') const name = String(d.name || '').trim() if (!name || name.length > 20) throw new Error('列名非法') @@ -143,7 +144,7 @@ async function applyPut(op, ctx) { // 成员关系只增不改: 已存在即视为完成 (幂等) return { skipped: true } } - const project = await prisma.project.findUnique({ where: { uuid: d.projectUuid } }) + const project = await prisma.project.findUnique({ where: { uuid: d.projectId } }) if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限') const target = Number(d.userId) if (!target) throw new Error('userId 非法') @@ -179,16 +180,16 @@ async function applyPut(op, ctx) { if (d.sortOrder !== undefined) data.sortOrder = Number(d.sortOrder) || 0 if (d.assigneeId !== undefined) data.assigneeId = d.assigneeId ? Number(d.assigneeId) : null if (d.doneAt !== undefined) data.doneAt = isoTime(d.doneAt) - if (d.columnUuid !== undefined) { - const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnUuid } }) + if (d.columnId !== undefined) { + const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } }) if (!col || col.projectId !== existing.projectId) throw new Error('看板列不存在') data.columnId = col.id } - if (d.parentUuid !== undefined) { - if (!d.parentUuid) { + if (d.parentId !== undefined) { + if (!d.parentId) { data.parentId = null } else { - const parent = await prisma.task.findUnique({ where: { uuid: d.parentUuid } }) + const parent = await prisma.task.findUnique({ where: { uuid: d.parentId } }) if (!parent || parent.projectId !== existing.projectId) throw new Error('父任务不存在') if (parent.parentId) throw new Error('仅支持一级子任务') data.parentId = parent.id @@ -198,13 +199,13 @@ async function applyPut(op, ctx) { return {} } // 离线新建: 服务端解析父引用并分配项目内编号 - const project = await prisma.project.findUnique({ where: { uuid: d.projectUuid } }) + const project = await prisma.project.findUnique({ where: { uuid: d.projectId } }) if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限') const title = String(d.title || '').trim() if (!title || title.length > 30) throw new Error('任务标题非法') let columnId = null - if (d.columnUuid) { - const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnUuid } }) + if (d.columnId) { + const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } }) if (!col || col.projectId !== project.id) throw new Error('看板列不存在') columnId = col.id } else { @@ -216,8 +217,8 @@ async function applyPut(op, ctx) { columnId = first.id } let parentId = null - if (d.parentUuid) { - const parent = await prisma.task.findUnique({ where: { uuid: d.parentUuid } }) + if (d.parentId) { + const parent = await prisma.task.findUnique({ where: { uuid: d.parentId } }) if (!parent || parent.projectId !== project.id) throw new Error('父任务不存在') if (parent.parentId) throw new Error('仅支持一级子任务') parentId = parent.id @@ -248,7 +249,7 @@ async function applyPut(op, ctx) { if (table === 'TaskMember') { const existing = await prisma.taskMember.findUnique({ where: { uuid } }) if (existing) return { skipped: true } - const task = await prisma.task.findUnique({ where: { uuid: d.taskUuid } }) + const task = await prisma.task.findUnique({ where: { uuid: d.taskId } }) if (!task || !(await assertMember(task.projectId))) throw new Error('任务不存在或无权限') const uid = Number(d.userId) if (!uid) throw new Error('userId 非法') @@ -263,7 +264,7 @@ async function applyPut(op, ctx) { if (table === 'TaskTag') { const existing = await prisma.taskTag.findUnique({ where: { uuid } }) if (existing) return { skipped: true } - const task = await prisma.task.findUnique({ where: { uuid: d.taskUuid } }) + const task = await prisma.task.findUnique({ where: { uuid: d.taskId } }) if (!task || !(await assertMember(task.projectId))) throw new Error('任务不存在或无权限') const name = String(d.name || '').trim().slice(0, 20) if (!name) throw new Error('标签名非法')