218 lines
10 KiB
JavaScript
218 lines
10 KiB
JavaScript
// 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, uuid, 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 视角: REST 仍按 int id 寻址, 通过列表接口按 uuid 匹配)
|
|
const plist = await api('/projects', { token: login.token })
|
|
const pj = plist.items.find((p) => p.uuid === target.uuid)
|
|
if (!pj) throw new Error('服务端确认失败: 项目列表中找不到目标项目')
|
|
const tlist = await api(`/projects/${pj.id}/tasks`, { token: login.token })
|
|
const hit = tlist.items.find((t) => t.uuid === newId)
|
|
if (!hit || hit.number == null) throw new Error('服务端确认失败: 任务不存在或未定号')
|
|
console.log(`[verify] 6. 服务端确认: number=${hit.number}, uuid=${hit.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(`/projects/${pj.id}/tasks`, { token: login.token })
|
|
if (!gone.items.some((t) => t.uuid === newId)) console.log('[verify] 7. 验证任务已从服务端清除')
|
|
else throw new Error('验证任务删除未同步到服务端')
|
|
|
|
db.close()
|
|
try {
|
|
fs.rmSync(dbFile, { force: true })
|
|
fs.rmSync(dbFile + '-journal', { force: true })
|
|
} catch { /* Windows 下可能被占用, 留在临时目录无害 */ }
|
|
console.log('[verify] 全部通过: 下行同步 / 离线写入 / 上行幂等 / 服务端编号 / 回填 / 删除同步')
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('[verify] 失败:', e.message)
|
|
process.exit(1)
|
|
})
|