fix(0.2.1): upload 契约对齐客户端 —— 外键列即父行 uuid (projectId/columnId/parentId/taskId); 附 Node 闭环验证脚本
This commit is contained in:
Generated
+64
@@ -32,9 +32,11 @@
|
|||||||
"ws": "^8.21.2"
|
"ws": "^8.21.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@powersync/node": "^1.2.0",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
@@ -1633,6 +1635,45 @@
|
|||||||
"js-logger": "^1.6.1"
|
"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": {
|
"node_modules/@powersync/react": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@powersync/react/-/react-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@powersync/react/-/react-1.10.0.tgz",
|
||||||
@@ -3212,6 +3253,19 @@
|
|||||||
"integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==",
|
"integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.6",
|
"version": "1.20.6",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||||
@@ -5393,6 +5447,16 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-cron": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||||
|
|||||||
@@ -54,9 +54,11 @@
|
|||||||
"ws": "^8.21.2"
|
"ws": "^8.21.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@powersync/node": "^1.2.0",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
+16
-15
@@ -31,7 +31,8 @@ router.post('/token', authRequired, async (req, res, next) => {
|
|||||||
// ---------- 上传队列 ----------
|
// ---------- 上传队列 ----------
|
||||||
// 契约: POST { ops: [{ id, table, op: 'PUT'|'DELETE', uuid, data }] }
|
// 契约: POST { ops: [{ id, table, op: 'PUT'|'DELETE', uuid, data }] }
|
||||||
// table ∈ Project | BoardColumn | ProjectMember | Task | TaskMember | TaskTag
|
// 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
|
// 返回 { results: [{ id, ok, skipped?, error? }] }, 单条失败不影响同批其它 op
|
||||||
const TABLES = ['Project', 'BoardColumn', 'ProjectMember', 'Task', 'TaskMember', 'TaskTag']
|
const TABLES = ['Project', 'BoardColumn', 'ProjectMember', 'Task', 'TaskMember', 'TaskTag']
|
||||||
const TABLE_RANK = Object.fromEntries(TABLES.map((t, i) => [t, i]))
|
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 })
|
if (Object.keys(data).length) await prisma.boardColumn.update({ where: { uuid }, data })
|
||||||
return {}
|
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('项目不存在或无权限')
|
if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限')
|
||||||
const name = String(d.name || '').trim()
|
const name = String(d.name || '').trim()
|
||||||
if (!name || name.length > 20) throw new Error('列名非法')
|
if (!name || name.length > 20) throw new Error('列名非法')
|
||||||
@@ -143,7 +144,7 @@ async function applyPut(op, ctx) {
|
|||||||
// 成员关系只增不改: 已存在即视为完成 (幂等)
|
// 成员关系只增不改: 已存在即视为完成 (幂等)
|
||||||
return { skipped: true }
|
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('项目不存在或无权限')
|
if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限')
|
||||||
const target = Number(d.userId)
|
const target = Number(d.userId)
|
||||||
if (!target) throw new Error('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.sortOrder !== undefined) data.sortOrder = Number(d.sortOrder) || 0
|
||||||
if (d.assigneeId !== undefined) data.assigneeId = d.assigneeId ? Number(d.assigneeId) : null
|
if (d.assigneeId !== undefined) data.assigneeId = d.assigneeId ? Number(d.assigneeId) : null
|
||||||
if (d.doneAt !== undefined) data.doneAt = isoTime(d.doneAt)
|
if (d.doneAt !== undefined) data.doneAt = isoTime(d.doneAt)
|
||||||
if (d.columnUuid !== undefined) {
|
if (d.columnId !== undefined) {
|
||||||
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnUuid } })
|
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } })
|
||||||
if (!col || col.projectId !== existing.projectId) throw new Error('看板列不存在')
|
if (!col || col.projectId !== existing.projectId) throw new Error('看板列不存在')
|
||||||
data.columnId = col.id
|
data.columnId = col.id
|
||||||
}
|
}
|
||||||
if (d.parentUuid !== undefined) {
|
if (d.parentId !== undefined) {
|
||||||
if (!d.parentUuid) {
|
if (!d.parentId) {
|
||||||
data.parentId = null
|
data.parentId = null
|
||||||
} else {
|
} 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 || parent.projectId !== existing.projectId) throw new Error('父任务不存在')
|
||||||
if (parent.parentId) throw new Error('仅支持一级子任务')
|
if (parent.parentId) throw new Error('仅支持一级子任务')
|
||||||
data.parentId = parent.id
|
data.parentId = parent.id
|
||||||
@@ -198,13 +199,13 @@ async function applyPut(op, ctx) {
|
|||||||
return {}
|
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('项目不存在或无权限')
|
if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限')
|
||||||
const title = String(d.title || '').trim()
|
const title = String(d.title || '').trim()
|
||||||
if (!title || title.length > 30) throw new Error('任务标题非法')
|
if (!title || title.length > 30) throw new Error('任务标题非法')
|
||||||
let columnId = null
|
let columnId = null
|
||||||
if (d.columnUuid) {
|
if (d.columnId) {
|
||||||
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnUuid } })
|
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } })
|
||||||
if (!col || col.projectId !== project.id) throw new Error('看板列不存在')
|
if (!col || col.projectId !== project.id) throw new Error('看板列不存在')
|
||||||
columnId = col.id
|
columnId = col.id
|
||||||
} else {
|
} else {
|
||||||
@@ -216,8 +217,8 @@ async function applyPut(op, ctx) {
|
|||||||
columnId = first.id
|
columnId = first.id
|
||||||
}
|
}
|
||||||
let parentId = null
|
let parentId = null
|
||||||
if (d.parentUuid) {
|
if (d.parentId) {
|
||||||
const parent = await prisma.task.findUnique({ where: { uuid: d.parentUuid } })
|
const parent = await prisma.task.findUnique({ where: { uuid: d.parentId } })
|
||||||
if (!parent || parent.projectId !== project.id) throw new Error('父任务不存在')
|
if (!parent || parent.projectId !== project.id) throw new Error('父任务不存在')
|
||||||
if (parent.parentId) throw new Error('仅支持一级子任务')
|
if (parent.parentId) throw new Error('仅支持一级子任务')
|
||||||
parentId = parent.id
|
parentId = parent.id
|
||||||
@@ -248,7 +249,7 @@ async function applyPut(op, ctx) {
|
|||||||
if (table === 'TaskMember') {
|
if (table === 'TaskMember') {
|
||||||
const existing = await prisma.taskMember.findUnique({ where: { uuid } })
|
const existing = await prisma.taskMember.findUnique({ where: { uuid } })
|
||||||
if (existing) return { skipped: true }
|
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('任务不存在或无权限')
|
if (!task || !(await assertMember(task.projectId))) throw new Error('任务不存在或无权限')
|
||||||
const uid = Number(d.userId)
|
const uid = Number(d.userId)
|
||||||
if (!uid) throw new Error('userId 非法')
|
if (!uid) throw new Error('userId 非法')
|
||||||
@@ -263,7 +264,7 @@ async function applyPut(op, ctx) {
|
|||||||
if (table === 'TaskTag') {
|
if (table === 'TaskTag') {
|
||||||
const existing = await prisma.taskTag.findUnique({ where: { uuid } })
|
const existing = await prisma.taskTag.findUnique({ where: { uuid } })
|
||||||
if (existing) return { skipped: true }
|
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('任务不存在或无权限')
|
if (!task || !(await assertMember(task.projectId))) throw new Error('任务不存在或无权限')
|
||||||
const name = String(d.name || '').trim().slice(0, 20)
|
const name = String(d.name || '').trim().slice(0, 20)
|
||||||
if (!name) throw new Error('标签名非法')
|
if (!name) throw new Error('标签名非法')
|
||||||
|
|||||||
Reference in New Issue
Block a user