@allbluecn/web-app 0.23.3 → 0.23.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
- package/src/components/desktop/__tests__/cloud-login-rollback.test.tsx +161 -0
- package/src/components/desktop/cloud-login.tsx +29 -26
- package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
- package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
- package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
- package/src/components/desktop-agent/agent-chat.tsx +221 -0
- package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
- package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
- package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
- package/src/config/sidebar-routes.tsx +14 -1
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
- package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
- package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
- package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
- package/src/lib/desktop-agent/availability.ts +39 -0
- package/src/lib/desktop-agent/connection.ts +72 -0
- package/src/lib/desktop-agent/errors.ts +21 -0
- package/src/lib/desktop-agent/tool-result.ts +170 -0
- package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
- package/src/lib/desktop-bridge-types.ts +22 -1
- package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
- package/src/lib/hotkeys/registry.ts +9 -1
- package/src/lib/queries/README.md +1 -0
- package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
- package/src/lib/queries/desktop-agent.ts +25 -0
- package/src/paraglide/messages/_index.d.ts +42 -0
- package/src/paraglide/messages/_index.js +42 -0
- package/src/paraglide/messages/agent_desktop_chatcwd1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_chatcwd1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatemptyhint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatemptyhint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatemptytitle2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatemptytitle2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatindependenthint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatindependenthint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatnoworkspace2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatnoworkspace2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatplaceholder1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatplaceholder1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatsend1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatsend1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatstop1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatstop1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatthinking1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatthinking1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorconnection1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorconnection1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorforbidden1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorforbidden1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorgeneric1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_errorgeneric1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorunauthorized1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorunauthorized1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorunavailable1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorunavailable1.js +30 -0
- package/src/paraglide/messages/agent_desktop_pagedescription1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_pagedescription1.js +30 -0
- package/src/paraglide/messages/agent_desktop_title.d.ts +16 -0
- package/src/paraglide/messages/agent_desktop_title.js +29 -0
- package/src/paraglide/messages/agent_desktop_toolcountfiles2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcountfiles2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolcounthits2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcounthits2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolcountlines2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcountlines2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolemptydir2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolemptydir2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolerror1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolerror1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolfind1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolfind1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolgrep1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolgrep1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolls1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolls1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolmore1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolmore1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolnomatches2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolnomatches2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolother1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolother1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolread1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolread1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolrunning1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolrunning1.js +30 -0
- package/src/paraglide/messages/agent_desktop_tooltruncated1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_tooltruncated1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceadd1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceadd1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspacedescription1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspacedescription1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceempty1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceempty1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceremove1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceremove1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspacetitle1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspacetitle1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceunsupported1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceunsupported1.js +30 -0
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +17 -0
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +30 -0
- package/src/paraglide/messages/sync_bind_rolled_back.d.ts +16 -0
- package/src/paraglide/messages/sync_bind_rolled_back.js +29 -0
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/-tests/agent-gate.test.ts +45 -0
- package/src/routes/_nav/agent/route.lazy.tsx +63 -0
- package/src/routes/_nav/agent/route.tsx +21 -0
- package/src/routes/api/sync/account-status.ts +4 -2
- package/src/routes/api/sync/push.ts +11 -3
- package/src/server/__tests__/desktop-cloud-identity.test.ts +331 -0
- package/src/server/__tests__/desktop-session-merge.test.ts +42 -0
- package/src/server/__tests__/exchange-remote-token.test.ts +92 -10
- package/src/server/__tests__/remote-auth.test.ts +39 -0
- package/src/server/desktop-bootstrap.ts +16 -5
- package/src/server/desktop-cloud-identity.ts +247 -0
- package/src/server/remote-auth.ts +33 -1
- package/src/server/serverFns/auth/session.ts +27 -2
- package/src/server/serverFns/sync/transfer.ts +28 -27
- package/src/server/serverFns/workspace-kb.ts +10 -0
- package/src/server/sync/__tests__/ownership-transfer.test.ts +261 -48
- package/src/server/sync/__tests__/sync-first-push.e2e.test.ts +349 -0
- package/src/server/sync/account-status-cloud.ts +41 -0
- package/src/server/sync/ownership-transfer.ts +88 -49
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 首次上云端到端(真实 schema):空账号 + 一批本地业务行 → 引擎出箱 → 服务端
|
|
6
|
+
// applyOp 逐 op 应用 → 全部 applied、零冲突、无 500(R3 收尾验收)。
|
|
7
|
+
//
|
|
8
|
+
// 装置:两个 **真实迁移全量** 的 PGlite 实例
|
|
9
|
+
// - client:packages/database/migrations 全量 + 客户端 overlay(出箱触发器)
|
|
10
|
+
// ——等价桌面端 PGlite 库的真实形态(迁移里的服务端 overlay 也会装,与生产一致)
|
|
11
|
+
// - cloud:同样的全量迁移(含 sync_trg_* 服务端触发器 + change log),空数据
|
|
12
|
+
// ——等价「刚绑定好的空云账号」
|
|
13
|
+
//
|
|
14
|
+
// 覆盖的真实 schema 外键(R3):stories.iterationId → iterations、
|
|
15
|
+
// test_cases.folderId → test_case_folders(本轮新注册)、knowledge_documents
|
|
16
|
+
// 自引用 parentId(三轮深度)、stories.projectId → projects、docs.kbId →
|
|
17
|
+
// knowledge_bases 等。数据播种刻意让**父行的最后一次修改晚于子行**(纯 lamport
|
|
18
|
+
// 序会反序),由 PUSH_TABLE_ORDER + 祖先链深度排序纠正。
|
|
19
|
+
// @vitest-environment node
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
21
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import { createPglite, type PgliteHandle } from '@allbluecn/database/pglite-init'
|
|
26
|
+
import {
|
|
27
|
+
SYNC_TABLES,
|
|
28
|
+
SYNC_TABLE_MAP,
|
|
29
|
+
admitScopeForOp,
|
|
30
|
+
applyOp,
|
|
31
|
+
createSyncEngine,
|
|
32
|
+
} from '@allbluecn/sync-core'
|
|
33
|
+
import { buildClientOverlayDdl } from '@allbluecn/sync-core/client/overlay'
|
|
34
|
+
import type { ApplyOpOutcome, SyncOp, SyncOpResult, SyncPushResponse } from '@allbluecn/sync-core'
|
|
35
|
+
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
37
|
+
// apps/web/src/server/sync/__tests__ → 仓根(6 层)
|
|
38
|
+
const MIGRATIONS_DIR = join(HERE, '../../../../../..', 'packages/database/migrations')
|
|
39
|
+
|
|
40
|
+
const OWNER = 'cloud-owner-e2e'
|
|
41
|
+
const GUEST_SESSION_SCOPES = [`user:${OWNER}`]
|
|
42
|
+
|
|
43
|
+
const tmpDirs: string[] = []
|
|
44
|
+
function makeDataDir(prefix: string): string {
|
|
45
|
+
const dir = mkdtempSync(join(tmpdir(), prefix))
|
|
46
|
+
tmpDirs.push(dir)
|
|
47
|
+
return dir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function openRealSchemaDb(prefix: string): Promise<PgliteHandle> {
|
|
51
|
+
return createPglite({ dataDir: makeDataDir(prefix), migrationsDir: MIGRATIONS_DIR })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function executorOf(handle: PgliteHandle) {
|
|
55
|
+
return {
|
|
56
|
+
query: async <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
57
|
+
(await handle.db.query<T>(sql, params as never[])).rows as T[],
|
|
58
|
+
execute: async (sql: string, params: unknown[] = []) => {
|
|
59
|
+
const r = await handle.db.query(sql, params as never[])
|
|
60
|
+
return (r as unknown as { rows?: unknown[] }).rows?.length ?? 0
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mapOutcome(opId: string, outcome: ApplyOpOutcome): SyncOpResult {
|
|
66
|
+
switch (outcome.kind) {
|
|
67
|
+
case 'applied':
|
|
68
|
+
return { opId, status: 'applied', syncVersion: outcome.syncVersion, row: outcome.row }
|
|
69
|
+
case 'duplicate':
|
|
70
|
+
return { opId, status: 'applied', syncVersion: outcome.syncVersion }
|
|
71
|
+
case 'conflict':
|
|
72
|
+
return {
|
|
73
|
+
opId,
|
|
74
|
+
status: 'conflict',
|
|
75
|
+
conflict: { reason: outcome.reason, local: outcome.local, remote: outcome.remote },
|
|
76
|
+
}
|
|
77
|
+
case 'forbidden':
|
|
78
|
+
return { opId, status: 'conflict', conflict: { reason: 'forbidden' } }
|
|
79
|
+
case 'unknownTable':
|
|
80
|
+
return { opId, status: 'conflict', conflict: { reason: 'unknownTable' } }
|
|
81
|
+
case 'invalidPayload':
|
|
82
|
+
return { opId, status: 'conflict', conflict: { reason: outcome.reason } }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 云侧 push 处理器(与 apps/web/src/routes/api/sync/push.ts 同构的最小实现):
|
|
88
|
+
* 整批单事务 + 逐 op 动态 scope 准入 + applyOp。任何未降级的异常都会直接抛出
|
|
89
|
+
* (= 生产里的 push 500),故本用例「全 applied」即等价「无 500」。
|
|
90
|
+
*/
|
|
91
|
+
function createCloudPushHandler(cloud: PgliteHandle) {
|
|
92
|
+
const ex = executorOf(cloud)
|
|
93
|
+
return async (ops: SyncOp[]): Promise<SyncPushResponse> => {
|
|
94
|
+
const admitted = new Set(GUEST_SESSION_SCOPES)
|
|
95
|
+
await ex.execute('BEGIN', [])
|
|
96
|
+
try {
|
|
97
|
+
const results: SyncOpResult[] = []
|
|
98
|
+
for (const op of ops) {
|
|
99
|
+
const reg = SYNC_TABLE_MAP.get(op.table)
|
|
100
|
+
if (!reg) {
|
|
101
|
+
results.push({ opId: op.opId, status: 'conflict', conflict: { reason: 'unknownTable' } })
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
await admitScopeForOp(ex, reg, { pk: op.pk, op: op.op, payload: op.payload }, OWNER, admitted)
|
|
105
|
+
const outcome = await applyOp(ex, reg, op, {
|
|
106
|
+
userId: OWNER,
|
|
107
|
+
scopes: [...admitted],
|
|
108
|
+
now: new Date('2026-11-01T00:00:00Z'),
|
|
109
|
+
})
|
|
110
|
+
results.push(mapOutcome(op.opId, outcome))
|
|
111
|
+
}
|
|
112
|
+
await ex.execute('COMMIT', [])
|
|
113
|
+
return { results }
|
|
114
|
+
} catch (e) {
|
|
115
|
+
await ex.execute('ROLLBACK', [])
|
|
116
|
+
throw e
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let client: PgliteHandle
|
|
122
|
+
let cloud: PgliteHandle
|
|
123
|
+
let pushToCloud: (ops: SyncOp[]) => Promise<SyncPushResponse>
|
|
124
|
+
|
|
125
|
+
beforeAll(async () => {
|
|
126
|
+
// 两个实例并行初始化:全量迁移在 PGlite 上各需数秒
|
|
127
|
+
;[client, cloud] = await Promise.all([openRealSchemaDb('sync-e2e-client-'), openRealSchemaDb('sync-e2e-cloud-')])
|
|
128
|
+
// 客户端 overlay(真实桌面端由宿主在 bootstrap 时装配):出箱 + row_state + 游标
|
|
129
|
+
await client.db.exec(buildClientOverlayDdl(SYNC_TABLES, { clientId: 'client-e2e', schemaVersion: 'sv-e2e' }))
|
|
130
|
+
// 云端「空账号」= 只有账号本身(users 行由 exchange 的 ensureCloudIdentity 建好),
|
|
131
|
+
// 无任何业务数据。users 行是 workspaces.ownerId / stories.createdById 等 FK 的目标,
|
|
132
|
+
// 缺了它首推会(正确地)撞 FK——真实链路里这一行必然存在。
|
|
133
|
+
await cloud.prisma.user.create({
|
|
134
|
+
data: { id: OWNER, email: 'owner-e2e@example.com', username: '云上用户', password: 'x' },
|
|
135
|
+
})
|
|
136
|
+
pushToCloud = createCloudPushHandler(cloud)
|
|
137
|
+
}, 300_000)
|
|
138
|
+
|
|
139
|
+
afterAll(async () => {
|
|
140
|
+
await client?.prisma.$disconnect()
|
|
141
|
+
await cloud?.prisma.$disconnect()
|
|
142
|
+
while (tmpDirs.length) {
|
|
143
|
+
const dir = tmpDirs.pop()
|
|
144
|
+
if (dir) rmSync(dir, { recursive: true, force: true })
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/** 本地播种:真实 schema 下的「一台设备上的全部数据」 */
|
|
149
|
+
async function seedLocalData(prisma: PgliteHandle['prisma']) {
|
|
150
|
+
await prisma.user.create({
|
|
151
|
+
data: { id: OWNER, email: 'owner-e2e@example.com', username: '云上用户', password: 'x' },
|
|
152
|
+
})
|
|
153
|
+
await prisma.workspace.create({ data: { id: 'ws1', name: '我的空间', ownerId: OWNER } })
|
|
154
|
+
await prisma.project.create({
|
|
155
|
+
data: { id: 'p1', displayId: 'PRJ', name: '我的项目', ownerId: OWNER, workspaceId: 'ws1' },
|
|
156
|
+
})
|
|
157
|
+
await prisma.workspaceMember.create({
|
|
158
|
+
data: { id: 'wm1', workspaceId: 'ws1', userId: OWNER, role: 'owner' },
|
|
159
|
+
})
|
|
160
|
+
await prisma.projectMember.create({
|
|
161
|
+
data: { id: 'pm1', projectId: 'p1', userId: OWNER, role: 'owner' },
|
|
162
|
+
})
|
|
163
|
+
// 迭代先建、随后改名(lamport 晚于 story)——旧兜底序会把 story 排在前面撞 FK
|
|
164
|
+
await prisma.iteration.create({
|
|
165
|
+
data: {
|
|
166
|
+
id: 'it1',
|
|
167
|
+
projectId: 'p1',
|
|
168
|
+
name: '迭代 1',
|
|
169
|
+
startDate: new Date('2026-11-01T00:00:00Z'),
|
|
170
|
+
endDate: new Date('2026-11-30T00:00:00Z'),
|
|
171
|
+
createdById: OWNER,
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
await prisma.story.create({
|
|
175
|
+
data: {
|
|
176
|
+
id: 's1',
|
|
177
|
+
sequenceId: 1,
|
|
178
|
+
identifier: 'PRJ-1',
|
|
179
|
+
projectId: 'p1',
|
|
180
|
+
name: '带迭代的需求',
|
|
181
|
+
iterationId: 'it1',
|
|
182
|
+
createdById: OWNER,
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
// 知识库 + 三层文档树
|
|
186
|
+
await prisma.knowledgeBase.create({ data: { id: 'kb1', name: '知识库', userId: OWNER, workspaceId: 'ws1' } })
|
|
187
|
+
for (const [id, parentId] of [
|
|
188
|
+
['doc-root', null],
|
|
189
|
+
['doc-child', 'doc-root'],
|
|
190
|
+
['doc-grand', 'doc-child'],
|
|
191
|
+
] as const) {
|
|
192
|
+
await prisma.knowledgeDocument.create({
|
|
193
|
+
data: { id, kbId: 'kb1', parentId, title: id, createdBy: OWNER, syncWorkspaceId: 'ws1' },
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
// 用例文件夹 + 带文件夹的用例(folder 随后改名 → lamport 晚于 test_case)
|
|
197
|
+
await prisma.testCaseFolder.create({ data: { id: 'f1', projectId: 'p1', name: '文件夹' } })
|
|
198
|
+
await prisma.testCase.create({
|
|
199
|
+
data: {
|
|
200
|
+
id: 'tc1',
|
|
201
|
+
sequenceId: 1,
|
|
202
|
+
identifier: 'PRJ-TC1',
|
|
203
|
+
projectId: 'p1',
|
|
204
|
+
folderId: 'f1',
|
|
205
|
+
title: '带文件夹的用例',
|
|
206
|
+
createdById: OWNER,
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// 刻意把「父行」的最后修改排到子行之后(纯 lamport 序会反序)
|
|
211
|
+
await prisma.iteration.update({ where: { id: 'it1' }, data: { name: '迭代 1(改名)' } })
|
|
212
|
+
await prisma.testCaseFolder.update({ where: { id: 'f1' }, data: { position: 3 } })
|
|
213
|
+
await prisma.knowledgeDocument.update({ where: { id: 'doc-root' }, data: { title: '根(改名)' } })
|
|
214
|
+
await prisma.knowledgeDocument.update({ where: { id: 'doc-child' }, data: { title: '子(改名)' } })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
describe('R3 首次上云:真实 schema 一批 op 全 applied(无 500)', () => {
|
|
218
|
+
it('workspace/project/member/iteration/story/kb/三层 doc/folder/test_case → 零冲突', async () => {
|
|
219
|
+
await seedLocalData(client.prisma)
|
|
220
|
+
const iface = executorOf(client)
|
|
221
|
+
|
|
222
|
+
// 出箱现状(诊断):12 行 pending
|
|
223
|
+
const pending = await iface.query<{ table_name: string; pk: string; base_version: number }>(
|
|
224
|
+
`SELECT table_name, pk, base_version FROM sync_outbox WHERE status = 'pending' ORDER BY lamport`,
|
|
225
|
+
)
|
|
226
|
+
expect(pending).toHaveLength(12)
|
|
227
|
+
// 首推全部 base_version=0(本地新建)
|
|
228
|
+
expect(pending.every((r) => Number(r.base_version) === 0)).toBe(true)
|
|
229
|
+
|
|
230
|
+
// 机制对照:纯 lamport 序下 story 排在 iteration 之前(会被旧兜底序推成 FK 失败)
|
|
231
|
+
const lamportOrder = pending.map((r) => `${r.table_name}:${r.pk}`)
|
|
232
|
+
expect(lamportOrder.indexOf('stories:s1')).toBeLessThan(lamportOrder.indexOf('iterations:it1'))
|
|
233
|
+
expect(lamportOrder.indexOf('test_cases:tc1')).toBeLessThan(lamportOrder.indexOf('test_case_folders:f1'))
|
|
234
|
+
|
|
235
|
+
const engine = createSyncEngine({
|
|
236
|
+
executor: iface,
|
|
237
|
+
fetchApi: async (path, init) => {
|
|
238
|
+
if (path === '/api/sync/push') {
|
|
239
|
+
const body = JSON.parse(init.body!) as { ops: SyncOp[] }
|
|
240
|
+
return { status: 200, json: await pushToCloud(body.ops) }
|
|
241
|
+
}
|
|
242
|
+
return { status: 200, json: { changes: [], nextSeq: 0, hasMore: false } }
|
|
243
|
+
},
|
|
244
|
+
registrations: SYNC_TABLES,
|
|
245
|
+
batchSize: 200,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const result = await engine.syncOnce()
|
|
249
|
+
expect(result).toEqual({ pushed: 12, conflicts: 0, pulled: 0 })
|
|
250
|
+
|
|
251
|
+
// 出箱应已清空(无 pending、无 conflict)
|
|
252
|
+
const left = await iface.query<{ status: string; c: number }>(
|
|
253
|
+
`SELECT status, COUNT(*)::int AS c FROM sync_outbox GROUP BY status`,
|
|
254
|
+
[],
|
|
255
|
+
)
|
|
256
|
+
expect(left).toEqual([])
|
|
257
|
+
|
|
258
|
+
// 云端落库的事实断言(含真实外键列)
|
|
259
|
+
const cloudEx = executorOf(cloud)
|
|
260
|
+
expect((await cloudEx.query(`SELECT COUNT(*)::int AS c FROM workspaces`, []))[0]).toEqual({ c: 1 })
|
|
261
|
+
expect((await cloudEx.query(`SELECT COUNT(*)::int AS c FROM projects`, []))[0]).toEqual({ c: 1 })
|
|
262
|
+
expect((await cloudEx.query(`SELECT COUNT(*)::int AS c FROM workspace_members`, []))[0]).toEqual({ c: 1 })
|
|
263
|
+
expect((await cloudEx.query(`SELECT COUNT(*)::int AS c FROM project_members`, []))[0]).toEqual({ c: 1 })
|
|
264
|
+
expect((await cloudEx.query(`SELECT COUNT(*)::int AS c FROM iterations`, []))[0]).toEqual({ c: 1 })
|
|
265
|
+
expect((await cloudEx.query(`SELECT COUNT(*)::int AS c FROM test_case_folders`, []))[0]).toEqual({ c: 1 })
|
|
266
|
+
|
|
267
|
+
const story = await cloudEx.query<{ iterationId: string | null; identifier: string }>(
|
|
268
|
+
`SELECT "iterationId", identifier FROM stories WHERE id = 's1'`,
|
|
269
|
+
[],
|
|
270
|
+
)
|
|
271
|
+
expect(story[0]).toEqual({ iterationId: 'it1', identifier: 'PRJ-1' })
|
|
272
|
+
|
|
273
|
+
const tc = await cloudEx.query<{ folderId: string | null }>(
|
|
274
|
+
`SELECT "folderId" FROM test_cases WHERE id = 'tc1'`,
|
|
275
|
+
[],
|
|
276
|
+
)
|
|
277
|
+
expect(tc[0]).toEqual({ folderId: 'f1' })
|
|
278
|
+
|
|
279
|
+
const docs = await cloudEx.query<{ id: string; parentId: string | null }>(
|
|
280
|
+
`SELECT id, "parentId" FROM knowledge_documents ORDER BY id`,
|
|
281
|
+
[],
|
|
282
|
+
)
|
|
283
|
+
expect(docs).toEqual([
|
|
284
|
+
{ id: 'doc-child', parentId: 'doc-root' },
|
|
285
|
+
{ id: 'doc-grand', parentId: 'doc-child' },
|
|
286
|
+
{ id: 'doc-root', parentId: null },
|
|
287
|
+
])
|
|
288
|
+
}, 300_000)
|
|
289
|
+
|
|
290
|
+
it('机制对照:子行先于父行推送 → FK 降级为 per-op 冲突(不再 500),父行到达后可重推成功', async () => {
|
|
291
|
+
const ex = executorOf(cloud)
|
|
292
|
+
const now = '2026-11-01T00:00:00.000Z'
|
|
293
|
+
const mk = (over: Partial<SyncOp>): SyncOp => ({
|
|
294
|
+
opId: `op-ctl-${Math.random().toString(36).slice(2, 10)}`,
|
|
295
|
+
table: 'stories',
|
|
296
|
+
pk: 's-ctl',
|
|
297
|
+
op: 'upsert',
|
|
298
|
+
payload: {
|
|
299
|
+
id: 's-ctl',
|
|
300
|
+
sequenceId: 9,
|
|
301
|
+
identifier: 'PRJ-9',
|
|
302
|
+
projectId: 'p1',
|
|
303
|
+
name: '顺序错误的需求',
|
|
304
|
+
createdById: OWNER,
|
|
305
|
+
createdAt: now,
|
|
306
|
+
updatedAt: now,
|
|
307
|
+
iterationId: 'it-ctl',
|
|
308
|
+
},
|
|
309
|
+
baseVersion: 0,
|
|
310
|
+
lamport: 1,
|
|
311
|
+
clientId: 'client-e2e',
|
|
312
|
+
schemaVersion: 'sv-e2e',
|
|
313
|
+
...over,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// ① story 先到(iteration 尚不存在)→ 冲突,不抛(旧实现此处整批 500)
|
|
317
|
+
const first = await pushToCloud([mk({})])
|
|
318
|
+
expect(first.results[0]).toMatchObject({ status: 'conflict', conflict: { reason: 'fk-violation' } })
|
|
319
|
+
expect((await ex.query(`SELECT id FROM stories WHERE id = 's-ctl'`, [])).length).toBe(0)
|
|
320
|
+
|
|
321
|
+
// ② 父行到达
|
|
322
|
+
const iter = await pushToCloud([
|
|
323
|
+
mk({
|
|
324
|
+
table: 'iterations',
|
|
325
|
+
pk: 'it-ctl',
|
|
326
|
+
payload: {
|
|
327
|
+
id: 'it-ctl',
|
|
328
|
+
projectId: 'p1',
|
|
329
|
+
name: '对照迭代',
|
|
330
|
+
startDate: now,
|
|
331
|
+
endDate: now,
|
|
332
|
+
createdById: OWNER,
|
|
333
|
+
createdAt: now,
|
|
334
|
+
updatedAt: now,
|
|
335
|
+
},
|
|
336
|
+
} as Partial<SyncOp>),
|
|
337
|
+
])
|
|
338
|
+
expect(iter.results[0]).toMatchObject({ status: 'applied' })
|
|
339
|
+
|
|
340
|
+
// ③ 子行重推(新 opId = 客户端未标记 applied 的重试形态)→ applied
|
|
341
|
+
const retry = await pushToCloud([mk({})])
|
|
342
|
+
expect(retry.results[0]).toMatchObject({ status: 'applied' })
|
|
343
|
+
const row = await ex.query<{ iterationId: string | null }>(
|
|
344
|
+
`SELECT "iterationId" FROM stories WHERE id = 's-ctl'`,
|
|
345
|
+
[],
|
|
346
|
+
)
|
|
347
|
+
expect(row[0]).toEqual({ iterationId: 'it-ctl' })
|
|
348
|
+
}, 120_000)
|
|
349
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 云账号空置状态的**云端权威**探测(桌面本地优先 P3 收尾 I2)。
|
|
6
|
+
//
|
|
7
|
+
// 背景:绑定转移的「目标必须空账号」复核原先用本地 PGlite 的 isAccountEmpty
|
|
8
|
+
// 按 cloudUserId 计数——转移前恒 0(真)、**转移后恒 >0(假)**,第二次进入
|
|
9
|
+
// bind 流程会被自己的本地数据判成「目标账号非空」,幂等重试直接失效;本地库
|
|
10
|
+
// 也根本看不到云端数据(非空账号的真实判定只有 SaaS 权威)。
|
|
11
|
+
//
|
|
12
|
+
// 口径(与 probeCloudAccountStatusFn 原实现逐字一致):以云会话 token 请求
|
|
13
|
+
// SaaS /api/sync/account-status;非 200 / 网络异常 / 形状漂移一律视为
|
|
14
|
+
// unreachable(宁可不转不可错转),调用方按「拒绝转移」处理。
|
|
15
|
+
import { DESKTOP_SAAS_URL } from './saas-profile'
|
|
16
|
+
|
|
17
|
+
export interface CloudAccountStatus {
|
|
18
|
+
empty: boolean
|
|
19
|
+
/** true = 无法确认(非空按 false 返回,调用方必须拒绝转移) */
|
|
20
|
+
unreachable: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 以云会话 token 探测云端账号是否空账号(SaaS 权威) */
|
|
24
|
+
export async function probeCloudAccountStatus(
|
|
25
|
+
sessionToken: string | undefined,
|
|
26
|
+
): Promise<CloudAccountStatus> {
|
|
27
|
+
if (!sessionToken) return { empty: false, unreachable: true }
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(`${DESKTOP_SAAS_URL}/api/sync/account-status`, {
|
|
30
|
+
headers: { Cookie: `auth.session-token=${sessionToken}` },
|
|
31
|
+
credentials: 'omit',
|
|
32
|
+
signal: AbortSignal.timeout(10_000),
|
|
33
|
+
})
|
|
34
|
+
if (!res.ok) return { empty: false, unreachable: true }
|
|
35
|
+
const json = (await res.json().catch(() => null)) as { empty?: unknown } | null
|
|
36
|
+
if (typeof json?.empty !== 'boolean') return { empty: false, unreachable: true }
|
|
37
|
+
return json.empty ? { empty: true, unreachable: false } : { empty: false, unreachable: false }
|
|
38
|
+
} catch {
|
|
39
|
+
return { empty: false, unreachable: true }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -10,16 +10,20 @@
|
|
|
10
10
|
//
|
|
11
11
|
// 属主列全集的派生口径(A8 预裁定,A5 实证):schema **全量 User-relation FK
|
|
12
12
|
// 标量列**(遍历 @relation 到 User 的关系取其 fields 标量列,共 79 处;含
|
|
13
|
-
// ownerId/userId/createdById/createdBy/assignee
|
|
14
|
-
// TRANSFER_EXCLUDED
|
|
15
|
-
// guest users 行,任何漏改的 User-FK 列要么触发
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// ownerId/userId/createdById/createdBy/assignee 类,**含 AiProviderConfig.userId**
|
|
14
|
+
// ——C3 裁定:AI 配置随身份翻转,见 TRANSFER_EXCLUDED 注释),排除 users 表自身。
|
|
15
|
+
// 为什么必须全量:转移末尾删除 guest users 行,任何漏改的 User-FK 列要么触发
|
|
16
|
+
// onDelete: Cascade **级联删掉业务数据**,要么留下指向已删用户的孤儿行 /
|
|
17
|
+
// Restrict 直接炸删除——全量翻转是规格 Q1②「防孤儿行」的唯一安全读法。
|
|
18
18
|
//
|
|
19
|
-
// ⚠
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// ⚠ sync_outbox 零触达;sync_row_state 只在正向转移末尾做一次**版本基线重置**
|
|
20
|
+
// (R2 换绑修复:base_version=0 + dirty=true,见函数内 ③)。属主 UPDATE 走常规
|
|
21
|
+
// Prisma 写入,已装触发的表自然产出 op='upsert' + base_version=0 的整行 change
|
|
22
|
+
// log,推送拓扑 A7 已修为 upsert 正向——转移面因此天然含全部注册表;基线重置是
|
|
23
|
+
// 唯一必需的例外(旧 base_version 指向上一个账号的云端版本,换绑后会全量
|
|
24
|
+
// remoteRowMissing 冲突)。
|
|
25
|
+
// ai_provider_configs **不在同步面**(SYNC_TABLES 仅 10 张注册表,无此表):
|
|
26
|
+
// 翻转只改本地行,不产出 outbox/change log,AI Key 不会随同步上云。
|
|
23
27
|
//
|
|
24
28
|
// 幂等语义:重复调用不抛错——UPDATE where fromUserId 匹配 0 行(已翻转)、
|
|
25
29
|
// 云身份行 upsert update 分支留空(no-op)、guest 行已删(deleteMany 计 0
|
|
@@ -27,21 +31,27 @@
|
|
|
27
31
|
// 清单对齐哨兵:__tests__/ownership-transfer.test.ts 内固定清单与本常量逐项
|
|
28
32
|
// 相等——schema 增删 User-relation FK 时该测试红,须同步重扫本清单。
|
|
29
33
|
|
|
30
|
-
import { randomUUID } from "node:crypto"
|
|
31
34
|
import type { PrismaClient } from "@allbluecn/database"
|
|
35
|
+
import { ensureCloudIdentity, readUserPreferences } from "@/server/desktop-cloud-identity"
|
|
32
36
|
import { ensureLocalShadowUserWith } from "@/server/desktop-bootstrap"
|
|
33
37
|
|
|
34
38
|
/**
|
|
35
|
-
* 配置类排除清单(规格 Q1
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
+
* 配置类排除清单(规格 Q1①)——**C3 裁定已清零**:原先把 `AiProviderConfig.userId`
|
|
40
|
+
* 排除在翻转之外,但转移末尾的 `user.deleteMany` 会经 `ON DELETE CASCADE` 把
|
|
41
|
+
* guest 名下全部 AI 配置行一起删掉(「不翻转」实际等于「静默丢失」)。现改为
|
|
42
|
+
* 随身份翻转:配置行在删行前已改属云身份,CASCADE 无从命中;反向转移同理把
|
|
43
|
+
* 云态配置带回 guest。用户级偏好列(aiApiKey/locale/timezone 等)另由
|
|
44
|
+
* ensureCloudIdentity / readUserPreferences 复制(见 desktop-cloud-identity)。
|
|
45
|
+
*
|
|
46
|
+
* 常量保留为「口径位」:新增真正不该翻转的 User-FK 列时在此登记(并在
|
|
47
|
+
* OWNERSHIP_COLUMNS 之外),当前为空。
|
|
39
48
|
*/
|
|
40
|
-
export const TRANSFER_EXCLUDED: readonly string[] = [
|
|
49
|
+
export const TRANSFER_EXCLUDED: readonly string[] = []
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
|
-
* 属主列全集:schema 全量 User-relation FK 标量列(79
|
|
44
|
-
*
|
|
52
|
+
* 属主列全集:schema 全量 User-relation FK 标量列(79 处,**含** AiProviderConfig.userId)
|
|
53
|
+
* 减 TRANSFER_EXCLUDED(当前为空)= 79 处「表.列」(table 为 Prisma 模型名,
|
|
54
|
+
* delegate 取 lowerFirst)。
|
|
45
55
|
* 派生方法(复核命令):
|
|
46
56
|
* grep -nE "^\s*\w+\s+User\??\s+@relation" packages/database/schema.prisma
|
|
47
57
|
* 取各行的 fields: [...] 标量列;与 User 模型反向关系数组(79 项)互为镜像。
|
|
@@ -99,6 +109,9 @@ export const OWNERSHIP_COLUMNS: ReadonlyArray<{ table: string; column: string }>
|
|
|
99
109
|
{ table: "StoryFilterView", column: "userId" },
|
|
100
110
|
{ table: "Subscription", column: "userId" },
|
|
101
111
|
{ table: "AiUsageLog", column: "userId" },
|
|
112
|
+
// C3:AI 配置随身份翻转(移出 TRANSFER_EXCLUDED——否则末尾删 guest 行会经
|
|
113
|
+
// ON DELETE CASCADE 把配置行一起删掉)。该表不在同步面(无触发器/无 outbox)。
|
|
114
|
+
{ table: "AiProviderConfig", column: "userId" },
|
|
102
115
|
{ table: "LicenseKey", column: "issuedById" },
|
|
103
116
|
{ table: "SsoMapping", column: "userId" },
|
|
104
117
|
{ table: "RoleAssignment", column: "userId" },
|
|
@@ -157,9 +170,10 @@ function delegateKey(model: string): string {
|
|
|
157
170
|
* OWNERSHIP_COLUMNS 长度);`rows`:Σ updateMany.count(实际翻转的属主行数,
|
|
158
171
|
* 幂等二跑为 0)。
|
|
159
172
|
*
|
|
160
|
-
* 步序(事务内):①
|
|
161
|
-
*
|
|
162
|
-
* desktop-bootstrap
|
|
173
|
+
* 步序(事务内):① ensureCloudIdentity 幂等建云身份行(必须先于属主翻转——
|
|
174
|
+
* 后续 FK 写入的合法目标;update 分支留空保证幂等不覆盖既有资料,password
|
|
175
|
+
* 随机占位与 desktop-bootstrap 同惯例:本地行凭据登录必失败;email/username
|
|
176
|
+
* 与本地既有用户冲突时降级占位,不 P2002 崩掉转移);② 全量属主列 UPDATE;
|
|
163
177
|
* ③ 删除 guest 行(Restrict 列已全部翻转,删除不被 FK 阻塞)。
|
|
164
178
|
*/
|
|
165
179
|
export async function transferOwnership(
|
|
@@ -175,23 +189,23 @@ export async function transferOwnership(
|
|
|
175
189
|
const columns = OWNERSHIP_COLUMNS
|
|
176
190
|
|
|
177
191
|
return prisma.$transaction(async (tx) => {
|
|
178
|
-
// ① 云身份行(先于属主翻转:FK
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
192
|
+
// ① 云身份行(先于属主翻转:FK 目标必须先存在)。统一走 ensureCloudIdentity
|
|
193
|
+
// (C2):幂等 upsert + email/username 唯一冲突降级——本地凭据用户已占用
|
|
194
|
+
// 云邮箱时不再 P2002 崩掉整个转移。
|
|
195
|
+
// C3:copyPreferencesFrom=guest——用户级配置列(AI key/模型/语言/时区等)
|
|
196
|
+
// 随身份搬到云行;否则 guest 行删除后这些配置直接丢失(AI Key 需要重填)。
|
|
197
|
+
await ensureCloudIdentity(
|
|
198
|
+
tx,
|
|
199
|
+
{
|
|
182
200
|
id: toUserId,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
bgColor: toUser.bgColor ?? undefined,
|
|
189
|
-
status: "ACTIVE",
|
|
201
|
+
name: toUser.name,
|
|
202
|
+
email: toUser.email,
|
|
203
|
+
avatarConfig: toUser.avatarConfig,
|
|
204
|
+
bgShape: toUser.bgShape,
|
|
205
|
+
bgColor: toUser.bgColor,
|
|
190
206
|
},
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
update: {},
|
|
194
|
-
})
|
|
207
|
+
{ copyPreferencesFrom: fromUserId },
|
|
208
|
+
)
|
|
195
209
|
|
|
196
210
|
// ② 全量属主列翻转(清单纯 server 侧数据;配置类 TRANSFER_EXCLUDED 不在内)
|
|
197
211
|
let rows = 0
|
|
@@ -211,7 +225,23 @@ export async function transferOwnership(
|
|
|
211
225
|
rows += result.count
|
|
212
226
|
}
|
|
213
227
|
|
|
214
|
-
// ③
|
|
228
|
+
// ③ 重置 sync_row_state 版本基线(R2,换绑账号):反向转移**保留** row_state
|
|
229
|
+
// (裁定 R2——维持同账号重登的版本连续性),但正向转移是「本机数据整体搬
|
|
230
|
+
// 去另一个(必为空的)云账号」:旧 base_version 是**上一个**账号的云端
|
|
231
|
+
// 版本号,留用会让首推撞 remoteRowMissing(云端 current=null 且
|
|
232
|
+
// baseVersion>0)——全量冲突且不可自愈(resolveApplyLocal 不重置
|
|
233
|
+
// base_version)。故在同事务内、属主翻转之后、删 guest 行之前清零基线:
|
|
234
|
+
// - base_version = 0:新账号云端无行,首推按 INSERT 应用;
|
|
235
|
+
// - dirty = true:翻转本身已为重同步面行产出待推 upsert(触发器语义
|
|
236
|
+
// 「本地有未推送变更」),且在新 op 落地前挡住 pull 覆盖本地行
|
|
237
|
+
// (applyRemoteChange 对 dirty 行留存 conflict_copies 并跳过)。
|
|
238
|
+
// 选择 UPDATE 而非 DELETE 行:删掉 row_state 会让「行存在但无 row_state
|
|
239
|
+
// 记录」被 pull 视为非 dirty → 直接覆盖本地行(本地数据丢失窗口);保留行
|
|
240
|
+
// 并把 dirty 置 true 才有防护。表名静态写死(非用户输入,无注入面),
|
|
241
|
+
// $executeRaw 只经 tx(与属主翻转同一事务闭包,无 tick 窗口)。
|
|
242
|
+
await tx.$executeRaw`UPDATE sync_row_state SET base_version = 0, dirty = true`
|
|
243
|
+
|
|
244
|
+
// ④ 删除 guest 行(翻转后无任何 FK 指向它:Cascade 无从级联、Restrict 不阻塞)。
|
|
215
245
|
// 必须用 deleteMany 而非 delete:delete 对已删行抛 P2025 并回滚整个二跑事务
|
|
216
246
|
// (幂等二跑变确定性失败,renderer 超时重试即误入 bind:fail)——deleteMany
|
|
217
247
|
// count=0 即 no-op,幂等三要素才全部为真(审查轮 1 修复)。
|
|
@@ -230,18 +260,21 @@ export async function transferOwnership(
|
|
|
230
260
|
* ① guest 影子行**重建**而非删除:正向末尾删了 guest 行(或首启尚未 seed),
|
|
231
261
|
* 反向翻转的 FK 目标必须先存在——事务内先调 ensureLocalShadowUserWith(tx)
|
|
232
262
|
* 重建(抽自 desktop-bootstrap,原无参包装直用模块级 prisma 单例会绕过 tx)。
|
|
233
|
-
* ② sync_outbox
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
263
|
+
* ② sync_outbox **同事务清空**,sync_row_state **保留**(正向两表零触达——正向的
|
|
264
|
+
* outbox 语义由触发器自然产出)。清 outbox 是必须的:属主翻转触发的 upsert ops
|
|
265
|
+
* 若留在队列,登出后引擎一旦带云会话 tick 就会把「cloud→guest 属主翻转」推上
|
|
266
|
+
* 云,污染云端数据(审查重点「防反向 op 推上云」)。row_state 不清(裁定 R2):
|
|
267
|
+
* 它存的是 base_version(该行上次同步到的云端 sync_version),清掉会让重登后的
|
|
268
|
+
* 首推 base_version 全 0 而云端 sync_version ≥ 1 → 全量 versionDiverged;保留即
|
|
269
|
+
* 维持版本连续性,且 row_state 不承载「待推 op」语义(推什么由 outbox 决定)。
|
|
270
|
+
* 两表都是 sync-core 客户端原生 SQL 表(无 Prisma delegate),用 $executeRaw 静态
|
|
271
|
+
* SQL(表名写死非用户输入,无注入面),且与属主 UPDATE 同一 $transaction 闭包
|
|
272
|
+
* (原子化,无 tick 窗口)。
|
|
240
273
|
* ③ 云身份行删除后不做快照/恢复编排:单事务自身原子,失败整体回滚即留在云态,
|
|
241
274
|
* 调用方(reverseTransferOwnershipFn + renderer 登出流程)解锁后可重试。
|
|
242
275
|
*
|
|
243
276
|
* 幂等语义与正向一致:二跑 UPDATE 匹配 0 行、guest 行 upsert update 留空、云身份
|
|
244
|
-
* 行 deleteMany 计 0 no-op、raw
|
|
277
|
+
* 行 deleteMany 计 0 no-op、raw 清 outbox 照常(DELETE 全表天然幂等)。
|
|
245
278
|
*/
|
|
246
279
|
export async function reverseTransferOwnership(
|
|
247
280
|
prisma: PrismaClient,
|
|
@@ -256,10 +289,13 @@ export async function reverseTransferOwnership(
|
|
|
256
289
|
|
|
257
290
|
return prisma.$transaction(async (tx) => {
|
|
258
291
|
// ① guest 影子行重建(先于属主翻转:FK 目标必须先存在;update 留空幂等,
|
|
259
|
-
//
|
|
260
|
-
|
|
292
|
+
// 不覆盖既有影子行资料)。C3:云身份行删除前把用户级配置列(AI key/
|
|
293
|
+
// 模型/语言/时区等)复制到重建的 guest 行——否则云态填的配置随云行
|
|
294
|
+
// 消失(只在 create 分支生效:已存在的影子行保留本机值)。
|
|
295
|
+
const cloudPreferences = await readUserPreferences(tx, fromUserId)
|
|
296
|
+
await ensureLocalShadowUserWith(tx, cloudPreferences)
|
|
261
297
|
|
|
262
|
-
// ② 全量属主列翻转 cloud → guest
|
|
298
|
+
// ② 全量属主列翻转 cloud → guest(与正向同清单反向,含 AiProviderConfig:
|
|
263
299
|
let rows = 0
|
|
264
300
|
const delegates = tx as unknown as Record<string, OwnerDelegate | undefined>
|
|
265
301
|
for (const { table, column } of columns) {
|
|
@@ -279,12 +315,15 @@ export async function reverseTransferOwnership(
|
|
|
279
315
|
// ③ 删除云身份行(翻转后无 FK 指向它;deleteMany 幂等——二跑已删计 0)
|
|
280
316
|
await tx.user.deleteMany({ where: { id: fromUserId } })
|
|
281
317
|
|
|
282
|
-
// ④
|
|
318
|
+
// ④ 同事务清空 outbox(防反向 op 推上云污染;表名静态写死无注入面)。
|
|
283
319
|
// 放在翻转之后:清空前的任何窗口内 outbox ops 都随事务回滚/覆盖,清空后
|
|
284
320
|
// 引擎 tick 无 op 可推。$executeRaw 仅经 tx(测试以「外层无此方法」钉死同
|
|
285
321
|
// 闭包语义)。
|
|
322
|
+
// ⚠ 不清 sync_row_state(裁定 R2):属主翻转产生的 upsert op 必须清(否则
|
|
323
|
+
// 反向流转被推上云),但 row_state 的 base_version 是「行上次同步到的云端
|
|
324
|
+
// 版本」,清掉会让重登后首推 base_version 全 0 vs 云端 sync_version ≥ 1 →
|
|
325
|
+
// 全量 versionDiverged。保留它维持版本连续性。
|
|
286
326
|
await tx.$executeRaw`DELETE FROM sync_outbox`
|
|
287
|
-
await tx.$executeRaw`DELETE FROM sync_row_state`
|
|
288
327
|
|
|
289
328
|
return { tables: columns.length, rows }
|
|
290
329
|
})
|