@allbluecn/web-app 0.23.5 → 0.23.7

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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/components/desktop-agent/agent-chat.tsx +3 -2
  3. package/src/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
  4. package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
  5. package/src/components/shared-kit/auth/auth-client.ts +4 -0
  6. package/src/lib/__tests__/desktop-logout.test.ts +239 -0
  7. package/src/lib/__tests__/session.test.ts +85 -2
  8. package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
  9. package/src/lib/auth/anonymous-safe.ts +5 -0
  10. package/src/lib/auth/page-gate.ts +11 -1
  11. package/src/lib/changelog/sdk-versions.ts +1 -1
  12. package/src/lib/desktop-agent/__tests__/connection.test.ts +93 -6
  13. package/src/lib/desktop-agent/connection.ts +36 -15
  14. package/src/lib/desktop-bridge-types.ts +12 -1
  15. package/src/lib/desktop-env.ts +25 -0
  16. package/src/lib/desktop-logout.ts +117 -0
  17. package/src/lib/session.ts +70 -0
  18. package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
  19. package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
  20. package/src/routes/_login/login/route.tsx +18 -3
  21. package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
  22. package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
  23. package/src/routes/_nav/route.tsx +5 -1
  24. package/src/routes/_nav/settings/account/route.tsx +5 -0
  25. package/src/routes/_nav/settings/team/route.tsx +5 -0
  26. package/src/routes/api/auth/$.ts +87 -2
  27. package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
  28. package/src/routes/api/sync/__tests__/bootstrap.test.ts +67 -0
  29. package/src/routes/api/sync/__tests__/pull.test.ts +68 -0
  30. package/src/routes/api/sync/__tests__/push.test.ts +69 -0
  31. package/src/routes/api/sync/__tests__/snapshot.test.ts +66 -0
  32. package/src/routes/api/sync/bootstrap.ts +20 -15
  33. package/src/routes/api/sync/pull.ts +62 -57
  34. package/src/routes/api/sync/push.ts +63 -58
  35. package/src/routes/api/sync/snapshot.ts +71 -66
  36. package/src/server/desktop-guest.ts +5 -1
  37. package/src/server/serverFns/auth/session.ts +19 -10
  38. package/src/server/sync/http-errors.ts +43 -0
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // /api/sync/bootstrap 未登录态回归哨兵(sync 族状态码对齐):requireAuth 抛
6
+ // UnauthorizedError 必须映射 401 + { error: 'unauthorized' }(此前裸调直上抛
7
+ // → 500)。非鉴权异常必须原样上抛(不吞)。
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
9
+
10
+ const mocks = vi.hoisted(() => ({ requireAuth: vi.fn(), getPrisma: vi.fn() }))
11
+
12
+ vi.mock('@tanstack/react-start/server', () => ({
13
+ getRequest: () => ({ url: 'https://allblue.test/api/sync/bootstrap' }),
14
+ }))
15
+ vi.mock('@/server/auth-helpers', () => ({
16
+ requireAuth: mocks.requireAuth,
17
+ UnauthorizedError: class UnauthorizedError extends Error {
18
+ constructor(message = 'UNAUTHORIZED') {
19
+ super(message)
20
+ this.name = 'UnauthorizedError'
21
+ }
22
+ },
23
+ }))
24
+ vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: mocks.getPrisma }))
25
+
26
+ import { Route } from '../bootstrap'
27
+ import { UnauthorizedError } from '@/server/auth-helpers'
28
+
29
+ type Handler = () => Promise<Response>
30
+ const GET = (Route.options.server as { handlers?: { GET?: Handler } } | undefined)?.handlers?.GET
31
+ if (!GET) throw new Error('GET handler missing')
32
+
33
+ const call = () => GET()
34
+
35
+ beforeEach(() => {
36
+ vi.clearAllMocks()
37
+ mocks.requireAuth.mockResolvedValue({ user: { id: 'u1' } })
38
+ mocks.getPrisma.mockResolvedValue({})
39
+ })
40
+
41
+ describe('/api/sync/bootstrap', () => {
42
+ it('未登录(UnauthorizedError)→ 401 + { error: "unauthorized" }', async () => {
43
+ mocks.requireAuth.mockRejectedValue(new UnauthorizedError())
44
+
45
+ const res = await call()
46
+
47
+ expect(res.status).toBe(401)
48
+ expect(await res.json()).toEqual({ error: 'unauthorized' })
49
+ // 鉴权失败不得触达数据库
50
+ expect(mocks.getPrisma).not.toHaveBeenCalled()
51
+ })
52
+
53
+ it('未登录(裸 Error("UNAUTHORIZED"),跨实例兜底)→ 401', async () => {
54
+ mocks.requireAuth.mockRejectedValue(new Error('UNAUTHORIZED'))
55
+
56
+ const res = await call()
57
+
58
+ expect(res.status).toBe(401)
59
+ expect(await res.json()).toEqual({ error: 'unauthorized' })
60
+ })
61
+
62
+ it('非鉴权异常(取 Prisma 失败)→ 原样上抛,不吞成响应', async () => {
63
+ mocks.getPrisma.mockRejectedValue(new Error('db down'))
64
+
65
+ await expect(call()).rejects.toThrow('db down')
66
+ })
67
+ })
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // /api/sync/pull 未登录态回归哨兵(sync 族状态码对齐):requireAuth 抛
6
+ // UnauthorizedError 必须映射 401 + { error: 'unauthorized' }(此前裸调直上抛
7
+ // → 500)。非鉴权异常必须原样上抛(不吞)。
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
9
+
10
+ const mocks = vi.hoisted(() => ({ requireAuth: vi.fn() }))
11
+
12
+ vi.mock('@tanstack/react-start/server', () => ({
13
+ getRequest: () => ({ url: 'https://allblue.test/api/sync/pull' }),
14
+ }))
15
+ vi.mock('@/server/auth-helpers', () => ({
16
+ requireAuth: mocks.requireAuth,
17
+ UnauthorizedError: class UnauthorizedError extends Error {
18
+ constructor(message = 'UNAUTHORIZED') {
19
+ super(message)
20
+ this.name = 'UnauthorizedError'
21
+ }
22
+ },
23
+ }))
24
+ vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: async () => ({}) }))
25
+
26
+ import { Route } from '../pull'
27
+ import { UnauthorizedError } from '@/server/auth-helpers'
28
+
29
+ type Handler = (ctx: { request: Request }) => Promise<Response>
30
+ const POST = (Route.options.server as { handlers?: { POST?: Handler } } | undefined)?.handlers?.POST
31
+ if (!POST) throw new Error('POST handler missing')
32
+
33
+ const post = (body: unknown = { scopes: [], lastSeq: 0 }) =>
34
+ POST({
35
+ request: new Request('https://allblue.test/api/sync/pull', {
36
+ method: 'POST',
37
+ body: JSON.stringify(body),
38
+ }),
39
+ })
40
+
41
+ beforeEach(() => {
42
+ vi.clearAllMocks()
43
+ mocks.requireAuth.mockResolvedValue({ user: { id: 'u1' } })
44
+ })
45
+
46
+ describe('/api/sync/pull', () => {
47
+ it('未登录(UnauthorizedError)→ 401 + { error: "unauthorized" }', async () => {
48
+ mocks.requireAuth.mockRejectedValue(new UnauthorizedError())
49
+
50
+ const res = await post()
51
+
52
+ expect(res.status).toBe(401)
53
+ expect(await res.json()).toEqual({ error: 'unauthorized' })
54
+ })
55
+
56
+ it('未登录(裸 Error("UNAUTHORIZED"),跨实例兜底)→ 401', async () => {
57
+ mocks.requireAuth.mockRejectedValue(new Error('UNAUTHORIZED'))
58
+
59
+ const res = await post()
60
+
61
+ expect(res.status).toBe(401)
62
+ expect(await res.json()).toEqual({ error: 'unauthorized' })
63
+ })
64
+
65
+ it('非鉴权异常(scopes/lastSeq 缺失,schema 校验失败)→ 原样上抛,不吞成响应', async () => {
66
+ await expect(post({})).rejects.toMatchObject({ name: 'ZodError' })
67
+ })
68
+ })
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // /api/sync/push 未登录态回归哨兵(sync 族状态码对齐):requireAuth 抛
6
+ // UnauthorizedError 必须映射 401 + { error: 'unauthorized' }——此前 handler 内
7
+ // 裸调 requireAuth,异常直上抛由框架统一回 500,客户端把「会话过期」误判成
8
+ // 服务端故障并在同步循环里反复重试。非鉴权异常必须原样上抛(不吞)。
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ const mocks = vi.hoisted(() => ({ requireAuth: vi.fn() }))
12
+
13
+ vi.mock('@tanstack/react-start/server', () => ({
14
+ getRequest: () => ({ url: 'https://allblue.test/api/sync/push' }),
15
+ }))
16
+ vi.mock('@/server/auth-helpers', () => ({
17
+ requireAuth: mocks.requireAuth,
18
+ UnauthorizedError: class UnauthorizedError extends Error {
19
+ constructor(message = 'UNAUTHORIZED') {
20
+ super(message)
21
+ this.name = 'UnauthorizedError'
22
+ }
23
+ },
24
+ }))
25
+ vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: async () => ({}) }))
26
+
27
+ import { Route } from '../push'
28
+ import { UnauthorizedError } from '@/server/auth-helpers'
29
+
30
+ type Handler = (ctx: { request: Request }) => Promise<Response>
31
+ const POST = (Route.options.server as { handlers?: { POST?: Handler } } | undefined)?.handlers?.POST
32
+ if (!POST) throw new Error('POST handler missing')
33
+
34
+ const post = (body: unknown = { ops: [] }) =>
35
+ POST({
36
+ request: new Request('https://allblue.test/api/sync/push', {
37
+ method: 'POST',
38
+ body: JSON.stringify(body),
39
+ }),
40
+ })
41
+
42
+ beforeEach(() => {
43
+ vi.clearAllMocks()
44
+ mocks.requireAuth.mockResolvedValue({ user: { id: 'u1' } })
45
+ })
46
+
47
+ describe('/api/sync/push', () => {
48
+ it('未登录(UnauthorizedError)→ 401 + { error: "unauthorized" }', async () => {
49
+ mocks.requireAuth.mockRejectedValue(new UnauthorizedError())
50
+
51
+ const res = await post()
52
+
53
+ expect(res.status).toBe(401)
54
+ expect(await res.json()).toEqual({ error: 'unauthorized' })
55
+ })
56
+
57
+ it('未登录(裸 Error("UNAUTHORIZED"),跨实例兜底)→ 401', async () => {
58
+ mocks.requireAuth.mockRejectedValue(new Error('UNAUTHORIZED'))
59
+
60
+ const res = await post()
61
+
62
+ expect(res.status).toBe(401)
63
+ expect(await res.json()).toEqual({ error: 'unauthorized' })
64
+ })
65
+
66
+ it('非鉴权异常(ops 缺失,schema 校验失败)→ 原样上抛,不吞成响应', async () => {
67
+ await expect(post({})).rejects.toMatchObject({ name: 'ZodError' })
68
+ })
69
+ })
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // /api/sync/snapshot 未登录态回归哨兵(sync 族状态码对齐):requireAuth 抛
6
+ // UnauthorizedError 必须映射 401 + { error: 'unauthorized' }(此前裸调直上抛
7
+ // → 500)。非鉴权异常必须原样上抛(不吞)。
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
9
+
10
+ const mocks = vi.hoisted(() => ({ requireAuth: vi.fn() }))
11
+
12
+ vi.mock('@tanstack/react-start/server', () => ({
13
+ getRequest: () => ({ url: 'https://allblue.test/api/sync/snapshot' }),
14
+ }))
15
+ vi.mock('@/server/auth-helpers', () => ({
16
+ requireAuth: mocks.requireAuth,
17
+ UnauthorizedError: class UnauthorizedError extends Error {
18
+ constructor(message = 'UNAUTHORIZED') {
19
+ super(message)
20
+ this.name = 'UnauthorizedError'
21
+ }
22
+ },
23
+ }))
24
+ vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: async () => ({}) }))
25
+
26
+ import { Route } from '../snapshot'
27
+ import { UnauthorizedError } from '@/server/auth-helpers'
28
+
29
+ type Handler = (ctx: { request: Request }) => Promise<Response>
30
+ const GET = (Route.options.server as { handlers?: { GET?: Handler } } | undefined)?.handlers?.GET
31
+ if (!GET) throw new Error('GET handler missing')
32
+
33
+ /** table 合法时才会走到业务查询;未登录用例用合法 URL 确保失败点只在鉴权 */
34
+ const call = (table = 'projects') =>
35
+ GET({
36
+ request: new Request(`https://allblue.test/api/sync/snapshot?table=${table}`),
37
+ })
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks()
41
+ mocks.requireAuth.mockResolvedValue({ user: { id: 'u1' } })
42
+ })
43
+
44
+ describe('/api/sync/snapshot', () => {
45
+ it('未登录(UnauthorizedError)→ 401 + { error: "unauthorized" }', async () => {
46
+ mocks.requireAuth.mockRejectedValue(new UnauthorizedError())
47
+
48
+ const res = await call()
49
+
50
+ expect(res.status).toBe(401)
51
+ expect(await res.json()).toEqual({ error: 'unauthorized' })
52
+ })
53
+
54
+ it('未登录(裸 Error("UNAUTHORIZED"),跨实例兜底)→ 401', async () => {
55
+ mocks.requireAuth.mockRejectedValue(new Error('UNAUTHORIZED'))
56
+
57
+ const res = await call()
58
+
59
+ expect(res.status).toBe(401)
60
+ expect(await res.json()).toEqual({ error: 'unauthorized' })
61
+ })
62
+
63
+ it('非鉴权异常(缺少 table 参数,schema 校验失败)→ 原样上抛,不吞成响应', async () => {
64
+ await expect(call('')).rejects.toMatchObject({ name: 'ZodError' })
65
+ })
66
+ })
@@ -9,6 +9,7 @@ import { createFileRoute } from "@tanstack/react-router"
9
9
  import { getRequest } from "@tanstack/react-start/server"
10
10
  import { getPrisma } from "@allbluecn/database/get-prisma"
11
11
  import { requireAuth } from "@/server/auth-helpers"
12
+ import { mapSyncAuthError } from "@/server/sync/http-errors"
12
13
  import { resolveSyncScopes } from "@/server/sync/scopes"
13
14
  import { SYNC_TABLES } from "@allbluecn/sync-core/registrations"
14
15
 
@@ -16,22 +17,26 @@ export const Route = createFileRoute("/api/sync/bootstrap")({
16
17
  server: {
17
18
  handlers: {
18
19
  GET: async () => {
19
- const session = await requireAuth(getRequest())
20
- const prisma = await getPrisma()
20
+ try {
21
+ const session = await requireAuth(getRequest())
22
+ const prisma = await getPrisma()
21
23
 
22
- const [{ seq }] = await prisma.$queryRawUnsafe<Array<{ seq: bigint }>>(
23
- `SELECT COALESCE(MAX(seq), 0) AS seq FROM sync_change_log`,
24
- )
25
- const counts = await Promise.all(
26
- SYNC_TABLES.map(async (reg) => {
27
- const [r] = await prisma.$queryRawUnsafe<Array<{ c: bigint }>>(
28
- `SELECT COUNT(*)::bigint AS c FROM ${reg.table}`,
29
- )
30
- return { name: reg.table, count: Number(r.c) }
31
- }),
32
- )
33
- void resolveSyncScopes // scopes 解析在快照分页端点逐页执行(此处仅鉴权)
34
- return Response.json({ watermarkSeq: Number(seq), tables: counts })
24
+ const [{ seq }] = await prisma.$queryRawUnsafe<Array<{ seq: bigint }>>(
25
+ `SELECT COALESCE(MAX(seq), 0) AS seq FROM sync_change_log`,
26
+ )
27
+ const counts = await Promise.all(
28
+ SYNC_TABLES.map(async (reg) => {
29
+ const [r] = await prisma.$queryRawUnsafe<Array<{ c: bigint }>>(
30
+ `SELECT COUNT(*)::bigint AS c FROM ${reg.table}`,
31
+ )
32
+ return { name: reg.table, count: Number(r.c) }
33
+ }),
34
+ )
35
+ void resolveSyncScopes // scopes 解析在快照分页端点逐页执行(此处仅鉴权)
36
+ return Response.json({ watermarkSeq: Number(seq), tables: counts })
37
+ } catch (e) {
38
+ return mapSyncAuthError(e)
39
+ }
35
40
  },
36
41
  },
37
42
  },
@@ -9,6 +9,7 @@ import { getRequest } from "@tanstack/react-start/server"
9
9
  import { z } from "zod"
10
10
  import { getPrisma } from "@allbluecn/database/get-prisma"
11
11
  import { requireAuth } from "@/server/auth-helpers"
12
+ import { mapSyncAuthError } from "@/server/sync/http-errors"
12
13
  import { resolveSyncScopes } from "@/server/sync/scopes"
13
14
 
14
15
  const pullSchema = z.object({
@@ -23,73 +24,77 @@ export const Route = createFileRoute("/api/sync/pull")({
23
24
  server: {
24
25
  handlers: {
25
26
  POST: async ({ request }) => {
26
- const session = await requireAuth(getRequest())
27
- const userId = session.user.id
28
- const body = pullSchema.parse(await request.json())
27
+ try {
28
+ const session = await requireAuth(getRequest())
29
+ const userId = session.user.id
30
+ const body = pullSchema.parse(await request.json())
29
31
 
30
- const prisma = await getPrisma()
31
- const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
32
- // scopes 服务端权威解析(客户端回显值仅作对账提示,不参与过滤)
33
- const scopes = await resolveSyncScopes(prisma, userId)
34
- const limit = Math.min(body.limit ?? PAGE_LIMIT, PAGE_LIMIT)
32
+ const prisma = await getPrisma()
33
+ const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
34
+ // scopes 服务端权威解析(客户端回显值仅作对账提示,不参与过滤)
35
+ const scopes = await resolveSyncScopes(prisma, userId)
36
+ const limit = Math.min(body.limit ?? PAGE_LIMIT, PAGE_LIMIT)
35
37
 
36
- const logRows = await prisma.$queryRawUnsafe<
37
- Array<{ seq: bigint; table_name: string; pk: string; op: string }>
38
- >(
39
- `SELECT seq, table_name, pk, op FROM sync_change_log
38
+ const logRows = await prisma.$queryRawUnsafe<
39
+ Array<{ seq: bigint; table_name: string; pk: string; op: string }>
40
+ >(
41
+ `SELECT seq, table_name, pk, op FROM sync_change_log
40
42
  WHERE seq > $1 AND scope = ANY($2)
41
43
  ORDER BY seq ASC
42
44
  LIMIT $3`,
43
- body.lastSeq,
44
- scopes,
45
- limit + 1,
46
- )
47
- const hasMore = logRows.length > limit
48
- const page = hasMore ? logRows.slice(0, limit) : logRows
45
+ body.lastSeq,
46
+ scopes,
47
+ limit + 1,
48
+ )
49
+ const hasMore = logRows.length > limit
50
+ const page = hasMore ? logRows.slice(0, limit) : logRows
49
51
 
50
- const changes: Array<Record<string, unknown>> = []
51
- for (const row of page) {
52
- const reg = SYNC_TABLE_MAP.get(row.table_name)
53
- if (!reg) continue // 未注册表(历史注册被移除)跳过
54
- const change: Record<string, unknown> = {
55
- table: row.table_name,
56
- pk: row.pk,
57
- op: row.op,
58
- }
59
- if (row.op === "upsert") {
60
- // pk 来自 change_log(客户端 push 时写入)——键必须经注册表 pkColumns
61
- // 白名单过滤(注入面收口),值参数化
62
- const params: unknown[] = []
63
- let where: string
64
- if (reg.pkColumns.length === 1) {
65
- params.push(row.pk)
66
- where = `"${reg.pkColumns[0]}" = $1`
52
+ const changes: Array<Record<string, unknown>> = []
53
+ for (const row of page) {
54
+ const reg = SYNC_TABLE_MAP.get(row.table_name)
55
+ if (!reg) continue // 未注册表(历史注册被移除)跳过
56
+ const change: Record<string, unknown> = {
57
+ table: row.table_name,
58
+ pk: row.pk,
59
+ op: row.op,
60
+ }
61
+ if (row.op === "upsert") {
62
+ // pk 来自 change_log(客户端 push 时写入)——键必须经注册表 pkColumns
63
+ // 白名单过滤(注入面收口),值参数化
64
+ const params: unknown[] = []
65
+ let where: string
66
+ if (reg.pkColumns.length === 1) {
67
+ params.push(row.pk)
68
+ where = `"${reg.pkColumns[0]}" = $1`
69
+ } else {
70
+ const parsed = JSON.parse(row.pk) as Record<string, string>
71
+ where = reg.pkColumns
72
+ .map((k) => {
73
+ params.push(parsed[k])
74
+ return `"${k}" = $${params.length}`
75
+ })
76
+ .join(" AND ")
77
+ }
78
+ const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
79
+ `SELECT ${reg.columns.map((c) => `"${c}"`).join(", ")}, sync_version FROM ${reg.table} WHERE ${where}`,
80
+ ...params,
81
+ )
82
+ if (rows.length === 0) continue // 行已删(change_log 略滞后),下轮 delete 事件收敛
83
+ const payload: Record<string, unknown> = {}
84
+ for (const c of reg.columns) payload[c] = rows[0][c] ?? null
85
+ change.payload = payload
86
+ change.syncVersion = Number(rows[0].sync_version ?? 0)
67
87
  } else {
68
- const parsed = JSON.parse(row.pk) as Record<string, string>
69
- where = reg.pkColumns
70
- .map((k) => {
71
- params.push(parsed[k])
72
- return `"${k}" = $${params.length}`
73
- })
74
- .join(" AND ")
88
+ change.syncVersion = 0
75
89
  }
76
- const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
77
- `SELECT ${reg.columns.map((c) => `"${c}"`).join(", ")}, sync_version FROM ${reg.table} WHERE ${where}`,
78
- ...params,
79
- )
80
- if (rows.length === 0) continue // 行已删(change_log 略滞后),下轮 delete 事件收敛
81
- const payload: Record<string, unknown> = {}
82
- for (const c of reg.columns) payload[c] = rows[0][c] ?? null
83
- change.payload = payload
84
- change.syncVersion = Number(rows[0].sync_version ?? 0)
85
- } else {
86
- change.syncVersion = 0
90
+ changes.push(change)
87
91
  }
88
- changes.push(change)
89
- }
90
92
 
91
- const nextSeq = page.length ? Number(page[page.length - 1].seq) : body.lastSeq
92
- return Response.json({ changes, nextSeq, hasMore })
93
+ const nextSeq = page.length ? Number(page[page.length - 1].seq) : body.lastSeq
94
+ return Response.json({ changes, nextSeq, hasMore })
95
+ } catch (e) {
96
+ return mapSyncAuthError(e)
97
+ }
93
98
  },
94
99
  },
95
100
  },
@@ -15,6 +15,7 @@ import { getRequest } from "@tanstack/react-start/server"
15
15
  import { z } from "zod"
16
16
  import { getPrisma } from "@allbluecn/database/get-prisma"
17
17
  import { requireAuth } from "@/server/auth-helpers"
18
+ import { mapSyncAuthError } from "@/server/sync/http-errors"
18
19
  import { resolveSyncScopes } from "@/server/sync/scopes"
19
20
 
20
21
  const pushSchema = z.object({
@@ -82,72 +83,76 @@ export const Route = createFileRoute("/api/sync/push")({
82
83
  server: {
83
84
  handlers: {
84
85
  POST: async ({ request }) => {
85
- const session = await requireAuth(getRequest())
86
- const userId = session.user.id
87
- const body = pushSchema.parse(await request.json())
86
+ try {
87
+ const session = await requireAuth(getRequest())
88
+ const userId = session.user.id
89
+ const body = pushSchema.parse(await request.json())
88
90
 
89
- const prisma = await getPrisma()
90
- const { applyOp, admitScopeForOp } = await import("@allbluecn/sync-core")
91
- const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
92
- const scopes = await resolveSyncScopes(prisma, userId)
93
- // 首次上云的 scope 动态准入(C1 / 裁定 R5):空云账号的 scopes 只有
94
- // user:<id>,而 outbox 的容器行 scope = 自身 id、子行 scope = 父容器 id。
95
- // 逐 op 按「属主自证」累积准入集合(同一 Set 穿过整批 op,依赖 outbox
96
- // 的父先子后拓扑序),再交给 applyOp 做行级校验——准入只覆盖自证行,
97
- // 不放宽到整表(边界见 @allbluecn/sync-core 的 scope-admission 头注释)。
98
- const admittedScopes = new Set(scopes)
91
+ const prisma = await getPrisma()
92
+ const { applyOp, admitScopeForOp } = await import("@allbluecn/sync-core")
93
+ const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
94
+ const scopes = await resolveSyncScopes(prisma, userId)
95
+ // 首次上云的 scope 动态准入(C1 / 裁定 R5):空云账号的 scopes 只有
96
+ // user:<id>,而 outbox 的容器行 scope = 自身 id、子行 scope = 父容器 id。
97
+ // 逐 op 按「属主自证」累积准入集合(同一 Set 穿过整批 op,依赖 outbox
98
+ // 的父先子后拓扑序),再交给 applyOp 做行级校验——准入只覆盖自证行,
99
+ // 不放宽到整表(边界见 @allbluecn/sync-core 的 scope-admission 头注释)。
100
+ const admittedScopes = new Set(scopes)
99
101
 
100
- // 整批单事务:逐 op 应用(依赖拓扑由客户端 outbox 顺序保证)
101
- const results = await prisma.$transaction(async (tx) => {
102
- const executor = {
103
- query: <T = Record<string, unknown>>(sql: string, params: unknown[]) =>
104
- tx.$queryRawUnsafe<T[]>(sql, ...params),
105
- execute: (sql: string, params: unknown[]) =>
106
- tx.$executeRawUnsafe(sql, ...params),
107
- }
108
- const out: Array<Record<string, unknown>> = []
109
- for (const op of body.ops) {
110
- const reg = SYNC_TABLE_MAP.get(op.table)
111
- if (!reg) {
112
- out.push({ opId: op.opId, status: "conflict", conflict: { reason: "unknownTable" } })
113
- continue
102
+ // 整批单事务:逐 op 应用(依赖拓扑由客户端 outbox 顺序保证)
103
+ const results = await prisma.$transaction(async (tx) => {
104
+ const executor = {
105
+ query: <T = Record<string, unknown>>(sql: string, params: unknown[]) =>
106
+ tx.$queryRawUnsafe<T[]>(sql, ...params),
107
+ execute: (sql: string, params: unknown[]) =>
108
+ tx.$executeRawUnsafe(sql, ...params),
114
109
  }
115
- const payload = op.payload as SyncPayload | undefined
116
- await admitScopeForOp(executor, reg, { pk: op.pk, op: op.op, payload }, userId, admittedScopes)
117
- const ctxScopes = [...admittedScopes]
118
- let outcome = await applyOp(executor, reg, { ...op, payload }, {
119
- userId,
120
- scopes: ctxScopes,
121
- now: new Date(),
122
- })
123
- // 编号重发号特例:stories/bugs 撞 identifier 唯一键 → 服务端重发号重试
124
- if (outcome.kind === "conflict" && outcome.reason === "uniqueViolation" && (op.table === "stories" || op.table === "bugs") && payload) {
125
- const oldIdentifier = payload.identifier as string | undefined
126
- const newIdentifier = await reissueIdentifier(tx, op.table, payload)
127
- if (newIdentifier && newIdentifier !== oldIdentifier) {
128
- outcome = await applyOp(executor, reg, { ...op, payload }, {
129
- userId,
130
- scopes: ctxScopes,
131
- now: new Date(),
132
- })
133
- if (outcome.kind === "applied") {
134
- out.push({
135
- opId: op.opId,
136
- status: "applied",
137
- syncVersion: outcome.syncVersion,
138
- row: outcome.row,
139
- renamed: { from: oldIdentifier ?? "", to: newIdentifier },
110
+ const out: Array<Record<string, unknown>> = []
111
+ for (const op of body.ops) {
112
+ const reg = SYNC_TABLE_MAP.get(op.table)
113
+ if (!reg) {
114
+ out.push({ opId: op.opId, status: "conflict", conflict: { reason: "unknownTable" } })
115
+ continue
116
+ }
117
+ const payload = op.payload as SyncPayload | undefined
118
+ await admitScopeForOp(executor, reg, { pk: op.pk, op: op.op, payload }, userId, admittedScopes)
119
+ const ctxScopes = [...admittedScopes]
120
+ let outcome = await applyOp(executor, reg, { ...op, payload }, {
121
+ userId,
122
+ scopes: ctxScopes,
123
+ now: new Date(),
124
+ })
125
+ // 编号重发号特例:stories/bugs 撞 identifier 唯一键 → 服务端重发号重试
126
+ if (outcome.kind === "conflict" && outcome.reason === "uniqueViolation" && (op.table === "stories" || op.table === "bugs") && payload) {
127
+ const oldIdentifier = payload.identifier as string | undefined
128
+ const newIdentifier = await reissueIdentifier(tx, op.table, payload)
129
+ if (newIdentifier && newIdentifier !== oldIdentifier) {
130
+ outcome = await applyOp(executor, reg, { ...op, payload }, {
131
+ userId,
132
+ scopes: ctxScopes,
133
+ now: new Date(),
140
134
  })
141
- continue
135
+ if (outcome.kind === "applied") {
136
+ out.push({
137
+ opId: op.opId,
138
+ status: "applied",
139
+ syncVersion: outcome.syncVersion,
140
+ row: outcome.row,
141
+ renamed: { from: oldIdentifier ?? "", to: newIdentifier },
142
+ })
143
+ continue
144
+ }
142
145
  }
143
146
  }
147
+ out.push(mapOutcome(op.opId, outcome))
144
148
  }
145
- out.push(mapOutcome(op.opId, outcome))
146
- }
147
- return out
148
- })
149
+ return out
150
+ })
149
151
 
150
- return Response.json({ results })
152
+ return Response.json({ results })
153
+ } catch (e) {
154
+ return mapSyncAuthError(e)
155
+ }
151
156
  },
152
157
  },
153
158
  },