@byline/host-tanstack-start 3.20.4 → 4.0.0
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/dist/admin-shell/admin-activity/list.js +15 -5
- package/dist/admin-shell/admin-roles/delete.js +2 -1
- package/dist/admin-shell/admin-roles/list.js +2 -1
- package/dist/admin-shell/admin-users/delete.js +2 -1
- package/dist/admin-shell/admin-users/list.js +4 -7
- package/dist/admin-shell/chrome/admin-app-bar.js +2 -1
- package/dist/admin-shell/chrome/admin-menu-drawer.js +9 -8
- package/dist/admin-shell/chrome/admin-menu-provider.js +2 -1
- package/dist/admin-shell/chrome/dashboard.js +4 -3
- package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -2
- package/dist/admin-shell/chrome/sign-in-page.js +2 -2
- package/dist/admin-shell/collections/create.js +4 -3
- package/dist/admin-shell/collections/document-history.js +6 -1
- package/dist/admin-shell/collections/edit.js +18 -14
- package/dist/admin-shell/collections/history.js +5 -4
- package/dist/admin-shell/collections/list.js +8 -7
- package/dist/admin-shell/collections/restore-version-modal.js +2 -1
- package/dist/admin-shell/collections/tree-list.js +53 -17
- package/dist/admin-shell/collections/view-menu.js +9 -8
- package/dist/auth/auth-context.d.ts +13 -0
- package/dist/auth/auth-context.js +4 -0
- package/dist/auth/request-scope.d.ts +16 -0
- package/dist/auth/request-scope.js +24 -0
- package/dist/integrations/abilities.js +8 -7
- package/dist/integrations/byline-public-client.d.ts +38 -0
- package/dist/integrations/byline-public-client.js +8 -4
- package/dist/integrations/byline-viewer-client.d.ts +6 -0
- package/dist/integrations/byline-viewer-client.js +22 -17
- package/dist/integrations/start-errors.js +2 -1
- package/dist/routes/admin-path.d.ts +22 -0
- package/dist/routes/admin-path.js +35 -0
- package/dist/routes/create-admin-account-route.js +3 -2
- package/dist/routes/create-admin-activity-route.js +3 -2
- package/dist/routes/create-admin-dashboard-route.js +2 -1
- package/dist/routes/create-admin-layout-route.d.ts +1 -1
- package/dist/routes/create-admin-layout-route.js +3 -1
- package/dist/routes/create-admin-permissions-route.js +3 -2
- package/dist/routes/create-admin-role-edit-route.js +4 -3
- package/dist/routes/create-admin-roles-list-route.js +3 -2
- package/dist/routes/create-admin-user-edit-route.js +4 -3
- package/dist/routes/create-admin-users-list-route.js +3 -2
- package/dist/routes/create-collection-api-route.d.ts +1 -7
- package/dist/routes/create-collection-api-route.js +12 -8
- package/dist/routes/create-collection-create-route.js +4 -3
- package/dist/routes/create-collection-edit-route.d.ts +1 -7
- package/dist/routes/create-collection-edit-route.js +9 -6
- package/dist/routes/create-collection-history-route.d.ts +1 -7
- package/dist/routes/create-collection-history-route.js +10 -7
- package/dist/routes/create-collection-list-route.js +3 -2
- package/dist/routes/create-sign-in-route.js +3 -2
- package/dist/routes/get-content-locale-route-config.d.ts +12 -0
- package/dist/routes/get-content-locale-route-config.js +16 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/sign-in-path.d.ts +13 -0
- package/dist/routes/sign-in-path.js +19 -0
- package/dist/server-fns/collections/delete-outcome.d.ts +30 -0
- package/dist/server-fns/collections/delete-outcome.js +34 -0
- package/dist/server-fns/collections/delete.d.ts +1 -4
- package/dist/server-fns/collections/delete.js +2 -4
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/tree-mutation.d.ts +46 -0
- package/dist/server-fns/collections/tree-mutation.js +109 -0
- package/dist/server-fns/collections/tree-read.d.ts +6 -0
- package/dist/server-fns/collections/tree-read.js +15 -0
- package/dist/server-fns/collections/tree.d.ts +5 -4
- package/dist/server-fns/collections/tree.js +15 -12
- package/dist/server-fns/collections/update.js +2 -1
- package/dist/vite/client-hook-boundary.d.ts +19 -0
- package/dist/vite/client-hook-boundary.js +20 -0
- package/dist/vite/index.d.ts +8 -0
- package/dist/vite/index.js +1 -0
- package/package.json +15 -9
- package/src/admin-shell/admin-activity/list.tsx +13 -3
- package/src/admin-shell/admin-roles/delete.tsx +2 -1
- package/src/admin-shell/admin-roles/list.tsx +2 -1
- package/src/admin-shell/admin-users/delete.tsx +3 -2
- package/src/admin-shell/admin-users/list.tsx +9 -8
- package/src/admin-shell/chrome/admin-app-bar.tsx +2 -1
- package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -8
- package/src/admin-shell/chrome/admin-menu-provider.tsx +2 -1
- package/src/admin-shell/chrome/dashboard.tsx +4 -3
- package/src/admin-shell/chrome/sign-in-page.tsx +3 -3
- package/src/admin-shell/collections/create.tsx +4 -3
- package/src/admin-shell/collections/document-history.tsx +5 -0
- package/src/admin-shell/collections/edit.tsx +20 -13
- package/src/admin-shell/collections/history.tsx +6 -5
- package/src/admin-shell/collections/list.tsx +8 -7
- package/src/admin-shell/collections/restore-version-modal.tsx +2 -1
- package/src/admin-shell/collections/tree-list.tsx +78 -22
- package/src/admin-shell/collections/view-menu.tsx +9 -8
- package/src/auth/auth-context.test.node.ts +87 -0
- package/src/auth/auth-context.ts +18 -0
- package/src/auth/request-scope.test.node.ts +93 -0
- package/src/auth/request-scope.ts +62 -0
- package/src/integrations/abilities.tsx +14 -4
- package/src/integrations/byline-client.ts +6 -4
- package/src/integrations/byline-public-client.ts +18 -4
- package/src/integrations/byline-viewer-client.ts +30 -18
- package/src/integrations/request-context-contract.test.node.ts +131 -0
- package/src/integrations/request-context-factories.test.node.ts +140 -0
- package/src/integrations/start-errors.test.node.ts +22 -0
- package/src/integrations/start-errors.ts +1 -0
- package/src/routes/admin-path.test.node.ts +129 -0
- package/src/routes/admin-path.ts +66 -0
- package/src/routes/auth-routes.test.node.ts +39 -0
- package/src/routes/create-admin-account-route.tsx +3 -2
- package/src/routes/create-admin-activity-route.tsx +3 -2
- package/src/routes/create-admin-dashboard-route.tsx +2 -1
- package/src/routes/create-admin-layout-route.tsx +4 -4
- package/src/routes/create-admin-permissions-route.tsx +3 -2
- package/src/routes/create-admin-role-edit-route.tsx +7 -3
- package/src/routes/create-admin-roles-list-route.tsx +3 -2
- package/src/routes/create-admin-user-edit-route.tsx +7 -3
- package/src/routes/create-admin-users-list-route.tsx +3 -2
- package/src/routes/create-collection-api-route.tsx +15 -14
- package/src/routes/create-collection-create-route.tsx +7 -3
- package/src/routes/create-collection-edit-route.tsx +12 -12
- package/src/routes/create-collection-history-route.tsx +13 -13
- package/src/routes/create-collection-list-route.tsx +3 -2
- package/src/routes/create-sign-in-route.tsx +9 -3
- package/src/routes/get-content-locale-route-config.ts +26 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/sign-in-path.test.node.ts +79 -0
- package/src/routes/sign-in-path.ts +36 -0
- package/src/server-fns/collections/delete-outcome.test.node.ts +72 -0
- package/src/server-fns/collections/delete-outcome.ts +80 -0
- package/src/server-fns/collections/delete.ts +2 -1
- package/src/server-fns/collections/get.ts +2 -0
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/tree-mutation.test.node.ts +225 -0
- package/src/server-fns/collections/tree-mutation.ts +165 -0
- package/src/server-fns/collections/tree-read.test.node.ts +46 -0
- package/src/server-fns/collections/tree-read.ts +26 -0
- package/src/server-fns/collections/tree.ts +26 -10
- package/src/server-fns/collections/update.ts +3 -0
- package/src/vite/client-hook-boundary.test.node.ts +50 -0
- package/src/vite/client-hook-boundary.ts +45 -0
- package/src/vite/index.ts +9 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DeleteDocumentResult, DeleteDocumentSideEffectPhase } from '@byline/core'
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { hasDeleteSideEffectFailures, toDeleteDocumentResponse } from './delete-outcome.js'
|
|
13
|
+
|
|
14
|
+
describe('delete outcome transport and admin interpretation', () => {
|
|
15
|
+
it('returns only allowlisted phase/code data for a committed warning', () => {
|
|
16
|
+
// Simulate malformed runtime data from an older or compromised source. The
|
|
17
|
+
// core type rejects this shape, but the transport still sanitizes it.
|
|
18
|
+
const unsafeResult = {
|
|
19
|
+
deletedVersionCount: 2,
|
|
20
|
+
outcome: 'committed-with-side-effect-failures',
|
|
21
|
+
sideEffectFailures: [
|
|
22
|
+
{ phase: 'afterDelete', message: 'secret hook details', code: 'ERR_SEARCH' },
|
|
23
|
+
{ phase: 'storageCleanup', message: 'private/path.pdf', code: 'ERR_STORAGE' },
|
|
24
|
+
{
|
|
25
|
+
phase: 'internalOnly' as DeleteDocumentSideEffectPhase,
|
|
26
|
+
message: 'internal phase details',
|
|
27
|
+
code: 'ERR_INTERNAL',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
} as unknown as DeleteDocumentResult
|
|
31
|
+
const response = toDeleteDocumentResponse(unsafeResult)
|
|
32
|
+
|
|
33
|
+
expect(response).toEqual({
|
|
34
|
+
status: 'ok',
|
|
35
|
+
deletedVersionCount: 2,
|
|
36
|
+
outcome: 'committed-with-side-effect-failures',
|
|
37
|
+
sideEffectFailures: [
|
|
38
|
+
{ phase: 'afterDelete', code: 'ERR_UNHANDLED' },
|
|
39
|
+
{ phase: 'storageCleanup', code: 'ERR_STORAGE' },
|
|
40
|
+
{ phase: 'unknown', code: 'ERR_UNHANDLED' },
|
|
41
|
+
],
|
|
42
|
+
})
|
|
43
|
+
expect(JSON.parse(JSON.stringify(response))).toEqual(response)
|
|
44
|
+
expect(JSON.stringify(response)).not.toContain('secret hook details')
|
|
45
|
+
expect(JSON.stringify(response)).not.toContain('private/path.pdf')
|
|
46
|
+
expect(JSON.stringify(response)).not.toContain('internal phase details')
|
|
47
|
+
expect(hasDeleteSideEffectFailures(response)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('preserves the committed success discriminant and empty failures', () => {
|
|
51
|
+
const response = toDeleteDocumentResponse({
|
|
52
|
+
deletedVersionCount: 2,
|
|
53
|
+
outcome: 'committed',
|
|
54
|
+
sideEffectFailures: [],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(response).toEqual({
|
|
58
|
+
status: 'ok',
|
|
59
|
+
deletedVersionCount: 2,
|
|
60
|
+
outcome: 'committed',
|
|
61
|
+
sideEffectFailures: [],
|
|
62
|
+
})
|
|
63
|
+
expect(hasDeleteSideEffectFailures(response)).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('classifies only committed side-effect failures as an admin warning', () => {
|
|
67
|
+
expect(hasDeleteSideEffectFailures({ outcome: 'committed' })).toBe(false)
|
|
68
|
+
expect(hasDeleteSideEffectFailures({ outcome: 'committed-with-side-effect-failures' })).toBe(
|
|
69
|
+
true
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
DeleteDocumentResult,
|
|
11
|
+
DeleteDocumentSideEffectCode,
|
|
12
|
+
DeleteDocumentSideEffectPhase,
|
|
13
|
+
} from '@byline/core'
|
|
14
|
+
import { ErrorCodes } from '@byline/core'
|
|
15
|
+
|
|
16
|
+
export type DeleteDocumentPublicSideEffectPhase = DeleteDocumentSideEffectPhase | 'unknown'
|
|
17
|
+
export type DeleteDocumentPublicSideEffectCode = DeleteDocumentSideEffectCode
|
|
18
|
+
|
|
19
|
+
export interface DeleteDocumentPublicSideEffectFailure {
|
|
20
|
+
phase: DeleteDocumentPublicSideEffectPhase
|
|
21
|
+
code: DeleteDocumentPublicSideEffectCode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type DeleteDocumentResponse =
|
|
25
|
+
| {
|
|
26
|
+
status: 'ok'
|
|
27
|
+
deletedVersionCount: number
|
|
28
|
+
outcome: 'committed'
|
|
29
|
+
sideEffectFailures: []
|
|
30
|
+
}
|
|
31
|
+
| {
|
|
32
|
+
status: 'ok'
|
|
33
|
+
deletedVersionCount: number
|
|
34
|
+
outcome: 'committed-with-side-effect-failures'
|
|
35
|
+
sideEffectFailures: [
|
|
36
|
+
DeleteDocumentPublicSideEffectFailure,
|
|
37
|
+
...DeleteDocumentPublicSideEffectFailure[],
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sanitizePhase(phase: DeleteDocumentSideEffectPhase): DeleteDocumentPublicSideEffectPhase {
|
|
42
|
+
if (phase === 'storageCleanup' || phase === 'afterTreeChange' || phase === 'afterDelete') {
|
|
43
|
+
return phase
|
|
44
|
+
}
|
|
45
|
+
return 'unknown'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitizeCode(code: string): DeleteDocumentPublicSideEffectCode {
|
|
49
|
+
return code === ErrorCodes.STORAGE ? ErrorCodes.STORAGE : ErrorCodes.UNHANDLED
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sanitizeFailure({
|
|
53
|
+
phase,
|
|
54
|
+
code,
|
|
55
|
+
}: {
|
|
56
|
+
phase: DeleteDocumentSideEffectPhase
|
|
57
|
+
code: string
|
|
58
|
+
}): DeleteDocumentPublicSideEffectFailure {
|
|
59
|
+
return { phase: sanitizePhase(phase), code: sanitizeCode(code) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function toDeleteDocumentResponse(result: DeleteDocumentResult): DeleteDocumentResponse {
|
|
63
|
+
if (result.outcome === 'committed') {
|
|
64
|
+
return { status: 'ok', ...result }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const [firstFailure, ...remainingFailures] = result.sideEffectFailures
|
|
68
|
+
return {
|
|
69
|
+
status: 'ok',
|
|
70
|
+
deletedVersionCount: result.deletedVersionCount,
|
|
71
|
+
outcome: result.outcome,
|
|
72
|
+
sideEffectFailures: [sanitizeFailure(firstFailure), ...remainingFailures.map(sanitizeFailure)],
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function hasDeleteSideEffectFailures(
|
|
77
|
+
result: Pick<DeleteDocumentResult, 'outcome'>
|
|
78
|
+
): boolean {
|
|
79
|
+
return result.outcome === 'committed-with-side-effect-failures'
|
|
80
|
+
}
|
|
@@ -14,6 +14,7 @@ import { deleteDocument as deleteDocumentService } from '@byline/core/services'
|
|
|
14
14
|
|
|
15
15
|
import { getAdminRequestContext } from '../../auth/auth-context.js'
|
|
16
16
|
import { ensureCollection } from '../../integrations/api-utils.js'
|
|
17
|
+
import { toDeleteDocumentResponse } from './delete-outcome.js'
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Delete document (soft-delete — marks all versions as deleted)
|
|
@@ -58,5 +59,5 @@ export const deleteDocument = createServerFn({ method: 'POST' })
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
const result = await deleteDocumentService(ctx, { documentId: id })
|
|
61
|
-
return
|
|
62
|
+
return toDeleteDocumentResponse(result)
|
|
62
63
|
})
|
|
@@ -197,6 +197,8 @@ const getDocumentByVersionFn = createServerFn({ method: 'GET' })
|
|
|
197
197
|
}).log(logger)
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
// The client binds the historical row to `path` and applies its beforeRead
|
|
201
|
+
// predicate; unknown and cross-collection version ids both resolve null.
|
|
200
202
|
const document = await getAdminBylineClient()
|
|
201
203
|
.collection(path)
|
|
202
204
|
.findByVersion(versionId, { locale: resolvedLocale })
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER } from '@byline/core'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createAdminTreeMoveController,
|
|
6
|
+
executeAdminTreeMove,
|
|
7
|
+
getAdminTreeMoveUiEffects,
|
|
8
|
+
isCommittedTreeHookFailure,
|
|
9
|
+
isTreeMutationConflict,
|
|
10
|
+
placeAdminTreeNode,
|
|
11
|
+
removeAdminTreeNode,
|
|
12
|
+
shouldSyncAdminTreeRows,
|
|
13
|
+
} from './tree-mutation.js'
|
|
14
|
+
|
|
15
|
+
describe('admin tree mutations', () => {
|
|
16
|
+
it('recognises a coded stale-tree conflict', () => {
|
|
17
|
+
expect(isTreeMutationConflict({ code: 'ERR_CONFLICT' })).toBe(true)
|
|
18
|
+
expect(isTreeMutationConflict(new Error('failed'))).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('recognises a code-less stale-tree conflict by its stable marker', () => {
|
|
22
|
+
expect(
|
|
23
|
+
isTreeMutationConflict(
|
|
24
|
+
new Error(`${TREE_PLACEMENT_STALE_MARKER} completely reworded diagnostic`)
|
|
25
|
+
)
|
|
26
|
+
).toBe(true)
|
|
27
|
+
expect(isTreeMutationConflict(new Error('tree placement is stale: neighbours moved'))).toBe(
|
|
28
|
+
false
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('recognises a coded committed tree-hook rejection', () => {
|
|
33
|
+
expect(isCommittedTreeHookFailure({ code: 'ERR_TREE_HOOK_COMMITTED' })).toBe(true)
|
|
34
|
+
expect(isCommittedTreeHookFailure({ code: 'ERR_CONFLICT' })).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('recognises a code-less committed tree-hook rejection by its stable marker', () => {
|
|
38
|
+
expect(
|
|
39
|
+
isCommittedTreeHookFailure(
|
|
40
|
+
new Error(`${TREE_HOOK_COMMITTED_MARKER} completely reworded diagnostic`)
|
|
41
|
+
)
|
|
42
|
+
).toBe(true)
|
|
43
|
+
expect(isCommittedTreeHookFailure(new Error('tree mutation committed; hook failed'))).toBe(
|
|
44
|
+
false
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('refreshes canonical tree data after a rejected mutation', async () => {
|
|
49
|
+
const mutationError = new Error(`${TREE_PLACEMENT_STALE_MARKER} neighbours moved`)
|
|
50
|
+
const refresh = vi.fn().mockResolvedValue(undefined)
|
|
51
|
+
|
|
52
|
+
await expect(
|
|
53
|
+
executeAdminTreeMove(vi.fn().mockRejectedValue(mutationError), refresh)
|
|
54
|
+
).resolves.toEqual({ status: 'mutation-failed', error: mutationError })
|
|
55
|
+
expect(refresh).toHaveBeenCalledOnce()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reports a refresh failure separately after a successful mutation', async () => {
|
|
59
|
+
const refreshError = new Error('router unavailable')
|
|
60
|
+
|
|
61
|
+
await expect(
|
|
62
|
+
executeAdminTreeMove(
|
|
63
|
+
vi.fn().mockResolvedValue(undefined),
|
|
64
|
+
vi.fn().mockRejectedValue(refreshError)
|
|
65
|
+
)
|
|
66
|
+
).resolves.toEqual({ status: 'refresh-failed', error: refreshError })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('keeps a committed hook rejection separate from mutation failure', async () => {
|
|
70
|
+
const hookError = { code: 'ERR_TREE_HOOK_COMMITTED', message: 'hook failed' }
|
|
71
|
+
const refresh = vi.fn().mockResolvedValue(undefined)
|
|
72
|
+
|
|
73
|
+
const outcome = await executeAdminTreeMove(vi.fn().mockRejectedValue(hookError), refresh)
|
|
74
|
+
|
|
75
|
+
expect(outcome).toEqual({ status: 'committed-hook-failed', error: hookError })
|
|
76
|
+
expect(getAdminTreeMoveUiEffects(outcome)).toEqual({
|
|
77
|
+
rollback: false,
|
|
78
|
+
preserveOptimistic: false,
|
|
79
|
+
structuralFailure: null,
|
|
80
|
+
committedHookWarning: true,
|
|
81
|
+
refreshWarning: false,
|
|
82
|
+
})
|
|
83
|
+
expect(refresh).toHaveBeenCalledOnce()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('preserves optimistic UI when committed-hook refresh also fails', async () => {
|
|
87
|
+
const hookError = { code: 'ERR_TREE_HOOK_COMMITTED', message: 'hook failed' }
|
|
88
|
+
const refreshError = new Error('refresh failed')
|
|
89
|
+
const outcome = await executeAdminTreeMove(
|
|
90
|
+
vi.fn().mockRejectedValue(hookError),
|
|
91
|
+
vi.fn().mockRejectedValue(refreshError)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(outcome).toEqual({
|
|
95
|
+
status: 'committed-hook-failed',
|
|
96
|
+
error: hookError,
|
|
97
|
+
refreshError,
|
|
98
|
+
})
|
|
99
|
+
expect(getAdminTreeMoveUiEffects(outcome)).toEqual({
|
|
100
|
+
rollback: false,
|
|
101
|
+
preserveOptimistic: true,
|
|
102
|
+
structuralFailure: null,
|
|
103
|
+
committedHookWarning: true,
|
|
104
|
+
refreshWarning: true,
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('keeps ordinary mutation failure rollback policy', () => {
|
|
109
|
+
expect(
|
|
110
|
+
getAdminTreeMoveUiEffects({
|
|
111
|
+
status: 'mutation-failed',
|
|
112
|
+
error: { code: 'ERR_CONFLICT' },
|
|
113
|
+
})
|
|
114
|
+
).toEqual({
|
|
115
|
+
rollback: true,
|
|
116
|
+
preserveOptimistic: false,
|
|
117
|
+
structuralFailure: 'conflict',
|
|
118
|
+
committedHookWarning: false,
|
|
119
|
+
refreshWarning: false,
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('preserves optimistic rows after refresh failure until canonical rows change', () => {
|
|
124
|
+
const staleRows = [{ id: 'old' }]
|
|
125
|
+
expect(shouldSyncAdminTreeRows(staleRows, staleRows, false)).toBe(false)
|
|
126
|
+
expect(shouldSyncAdminTreeRows([{ id: 'canonical' }], staleRows, false)).toBe(true)
|
|
127
|
+
expect(shouldSyncAdminTreeRows([{ id: 'canonical' }], null, true)).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('suppresses a second move while the first move is in flight', async () => {
|
|
131
|
+
const controller = createAdminTreeMoveController()
|
|
132
|
+
let resolveMutation: (() => void) | undefined
|
|
133
|
+
const mutate = vi.fn(
|
|
134
|
+
() =>
|
|
135
|
+
new Promise<void>((resolve) => {
|
|
136
|
+
resolveMutation = resolve
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
const refresh = vi.fn().mockResolvedValue(undefined)
|
|
140
|
+
|
|
141
|
+
const first = controller.execute(mutate, refresh)
|
|
142
|
+
expect(controller.isMoving()).toBe(true)
|
|
143
|
+
await expect(controller.execute(mutate, refresh)).resolves.toEqual({ status: 'suppressed' })
|
|
144
|
+
expect(mutate).toHaveBeenCalledOnce()
|
|
145
|
+
|
|
146
|
+
resolveMutation?.()
|
|
147
|
+
await expect(first).resolves.toEqual({ status: 'ok' })
|
|
148
|
+
expect(refresh).toHaveBeenCalledOnce()
|
|
149
|
+
expect(controller.isMoving()).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('refreshes and unlocks after a rejected mutation', async () => {
|
|
153
|
+
const controller = createAdminTreeMoveController()
|
|
154
|
+
const mutationError = new Error('rejected')
|
|
155
|
+
const refresh = vi.fn().mockResolvedValue(undefined)
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
controller.execute(vi.fn().mockRejectedValue(mutationError), refresh)
|
|
159
|
+
).resolves.toEqual({ status: 'mutation-failed', error: mutationError })
|
|
160
|
+
expect(refresh).toHaveBeenCalledOnce()
|
|
161
|
+
expect(controller.isMoving()).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('unlocks and reports only refresh failure after a successful commit', async () => {
|
|
165
|
+
const controller = createAdminTreeMoveController()
|
|
166
|
+
const refreshError = new Error('refresh failed')
|
|
167
|
+
|
|
168
|
+
const outcome = await controller.execute(
|
|
169
|
+
vi.fn().mockResolvedValue(undefined),
|
|
170
|
+
vi.fn().mockRejectedValue(refreshError)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
expect(outcome).toEqual({ status: 'refresh-failed', error: refreshError })
|
|
174
|
+
expect(outcome.status).not.toBe('mutation-failed')
|
|
175
|
+
expect(controller.isMoving()).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('opts ordinary placement retries into lifecycle reconciliation', async () => {
|
|
179
|
+
const placeTreeNode = vi
|
|
180
|
+
.fn()
|
|
181
|
+
.mockRejectedValueOnce(new Error('post-commit hook failed'))
|
|
182
|
+
.mockResolvedValue({ orderKey: 'key-1' })
|
|
183
|
+
|
|
184
|
+
const retry = () =>
|
|
185
|
+
placeAdminTreeNode({ placeTreeNode } as any, 'doc-1', {
|
|
186
|
+
parentDocumentId: 'parent-1',
|
|
187
|
+
beforeDocumentId: 'left-1',
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
await expect(retry()).rejects.toThrow('post-commit hook failed')
|
|
191
|
+
await expect(retry()).resolves.toEqual({ orderKey: 'key-1' })
|
|
192
|
+
|
|
193
|
+
expect(placeTreeNode).toHaveBeenCalledTimes(2)
|
|
194
|
+
expect(placeTreeNode).toHaveBeenNthCalledWith(
|
|
195
|
+
2,
|
|
196
|
+
'doc-1',
|
|
197
|
+
expect.objectContaining({ reconcile: true })
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('opts ordinary removal retries into lifecycle reconciliation', async () => {
|
|
202
|
+
const removeFromTree = vi.fn().mockResolvedValue(undefined)
|
|
203
|
+
|
|
204
|
+
await removeAdminTreeNode({ removeFromTree } as any, 'doc-1')
|
|
205
|
+
|
|
206
|
+
expect(removeFromTree).toHaveBeenCalledWith('doc-1', { reconcile: true })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('preserves an explicit reconciliation override', async () => {
|
|
210
|
+
const placeTreeNode = vi.fn().mockResolvedValue({ orderKey: 'key-1' })
|
|
211
|
+
const removeFromTree = vi.fn().mockResolvedValue(undefined)
|
|
212
|
+
|
|
213
|
+
await placeAdminTreeNode({ placeTreeNode } as any, 'doc-1', {
|
|
214
|
+
parentDocumentId: null,
|
|
215
|
+
reconcile: false,
|
|
216
|
+
})
|
|
217
|
+
await removeAdminTreeNode({ removeFromTree } as any, 'doc-1', { reconcile: false })
|
|
218
|
+
|
|
219
|
+
expect(placeTreeNode).toHaveBeenCalledWith('doc-1', {
|
|
220
|
+
parentDocumentId: null,
|
|
221
|
+
reconcile: false,
|
|
222
|
+
})
|
|
223
|
+
expect(removeFromTree).toHaveBeenCalledWith('doc-1', { reconcile: false })
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { CollectionHandle, PlaceTreeNodeOptions, RemoveFromTreeOptions } from '@byline/client'
|
|
2
|
+
import { ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER } from '@byline/core'
|
|
3
|
+
|
|
4
|
+
function hasErrorCode(error: unknown, code: string): boolean {
|
|
5
|
+
return (
|
|
6
|
+
typeof error === 'object' &&
|
|
7
|
+
error != null &&
|
|
8
|
+
'code' in error &&
|
|
9
|
+
(error as { code?: unknown }).code === code
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hasMessageMarker(error: unknown, marker: string): boolean {
|
|
14
|
+
return error instanceof Error && error.message.startsWith(marker)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Prefer the code; the marker survives transports that reconstruct a code-less Error. */
|
|
18
|
+
export function isTreeMutationConflict(error: unknown): boolean {
|
|
19
|
+
if (hasErrorCode(error, ErrorCodes.CONFLICT)) return true
|
|
20
|
+
return hasMessageMarker(error, TREE_PLACEMENT_STALE_MARKER)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Detect when tree/audit committed even if transport reconstruction dropped the code. */
|
|
24
|
+
export function isCommittedTreeHookFailure(error: unknown): boolean {
|
|
25
|
+
if (hasErrorCode(error, ErrorCodes.TREE_HOOK_COMMITTED)) return true
|
|
26
|
+
return hasMessageMarker(error, TREE_HOOK_COMMITTED_MARKER)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type AdminTreeMoveOutcome =
|
|
30
|
+
| { status: 'ok' }
|
|
31
|
+
| { status: 'suppressed' }
|
|
32
|
+
| { status: 'mutation-failed'; error: unknown; refreshError?: unknown }
|
|
33
|
+
| { status: 'committed-hook-failed'; error: unknown; refreshError?: unknown }
|
|
34
|
+
| { status: 'refresh-failed'; error: unknown }
|
|
35
|
+
|
|
36
|
+
export interface AdminTreeMoveUiEffects {
|
|
37
|
+
rollback: boolean
|
|
38
|
+
preserveOptimistic: boolean
|
|
39
|
+
structuralFailure: 'conflict' | 'generic' | null
|
|
40
|
+
committedHookWarning: boolean
|
|
41
|
+
refreshWarning: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pure UI policy for every mutation/refresh outcome. */
|
|
45
|
+
export function getAdminTreeMoveUiEffects(outcome: AdminTreeMoveOutcome): AdminTreeMoveUiEffects {
|
|
46
|
+
if (outcome.status === 'mutation-failed') {
|
|
47
|
+
return {
|
|
48
|
+
rollback: true,
|
|
49
|
+
preserveOptimistic: false,
|
|
50
|
+
structuralFailure: isTreeMutationConflict(outcome.error) ? 'conflict' : 'generic',
|
|
51
|
+
committedHookWarning: false,
|
|
52
|
+
refreshWarning: outcome.refreshError != null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (outcome.status === 'committed-hook-failed') {
|
|
56
|
+
return {
|
|
57
|
+
rollback: false,
|
|
58
|
+
preserveOptimistic: outcome.refreshError != null,
|
|
59
|
+
structuralFailure: null,
|
|
60
|
+
committedHookWarning: true,
|
|
61
|
+
refreshWarning: outcome.refreshError != null,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (outcome.status === 'refresh-failed') {
|
|
65
|
+
return {
|
|
66
|
+
rollback: false,
|
|
67
|
+
preserveOptimistic: true,
|
|
68
|
+
structuralFailure: null,
|
|
69
|
+
committedHookWarning: false,
|
|
70
|
+
refreshWarning: true,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
rollback: outcome.status === 'suppressed',
|
|
75
|
+
preserveOptimistic: false,
|
|
76
|
+
structuralFailure: null,
|
|
77
|
+
committedHookWarning: false,
|
|
78
|
+
refreshWarning: false,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Preserve a committed optimistic move until a later loader returns new row data. */
|
|
83
|
+
export function shouldSyncAdminTreeRows(
|
|
84
|
+
rows: unknown,
|
|
85
|
+
preservedRows: unknown,
|
|
86
|
+
isMoving: boolean
|
|
87
|
+
): boolean {
|
|
88
|
+
return !isMoving && rows !== preservedRows
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Keep mutation and canonical-refresh failures distinct for optimistic tree UIs. */
|
|
92
|
+
export async function executeAdminTreeMove(
|
|
93
|
+
mutate: () => Promise<unknown>,
|
|
94
|
+
refresh: () => Promise<unknown>
|
|
95
|
+
): Promise<AdminTreeMoveOutcome> {
|
|
96
|
+
try {
|
|
97
|
+
await mutate()
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const status = isCommittedTreeHookFailure(error)
|
|
100
|
+
? ('committed-hook-failed' as const)
|
|
101
|
+
: ('mutation-failed' as const)
|
|
102
|
+
try {
|
|
103
|
+
await refresh()
|
|
104
|
+
return { status, error }
|
|
105
|
+
} catch (refreshError) {
|
|
106
|
+
return { status, error, refreshError }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await refresh()
|
|
112
|
+
return { status: 'ok' }
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { status: 'refresh-failed', error }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface AdminTreeMoveController {
|
|
119
|
+
isMoving(): boolean
|
|
120
|
+
execute(
|
|
121
|
+
mutate: () => Promise<unknown>,
|
|
122
|
+
refresh: () => Promise<unknown>
|
|
123
|
+
): Promise<AdminTreeMoveOutcome>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Single-flight controller shared by pointer and keyboard tree moves. */
|
|
127
|
+
export function createAdminTreeMoveController(): AdminTreeMoveController {
|
|
128
|
+
let moving = false
|
|
129
|
+
return {
|
|
130
|
+
isMoving: () => moving,
|
|
131
|
+
execute: async (mutate, refresh) => {
|
|
132
|
+
if (moving) return { status: 'suppressed' }
|
|
133
|
+
moving = true
|
|
134
|
+
try {
|
|
135
|
+
return await executeAdminTreeMove(mutate, refresh)
|
|
136
|
+
} finally {
|
|
137
|
+
moving = false
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Admin retries reconcile post-commit tree hooks unless explicitly disabled. */
|
|
144
|
+
export function placeAdminTreeNode(
|
|
145
|
+
handle: Pick<CollectionHandle, 'placeTreeNode'>,
|
|
146
|
+
documentId: string,
|
|
147
|
+
options: PlaceTreeNodeOptions
|
|
148
|
+
): Promise<{ orderKey: string }> {
|
|
149
|
+
return handle.placeTreeNode(documentId, {
|
|
150
|
+
...options,
|
|
151
|
+
reconcile: options.reconcile ?? true,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Admin retries reconcile post-commit tree hooks unless explicitly disabled. */
|
|
156
|
+
export function removeAdminTreeNode(
|
|
157
|
+
handle: Pick<CollectionHandle, 'removeFromTree'>,
|
|
158
|
+
documentId: string,
|
|
159
|
+
options: RemoveFromTreeOptions = {}
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
return handle.removeFromTree(documentId, {
|
|
162
|
+
...options,
|
|
163
|
+
reconcile: options.reconcile ?? true,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createReadContext } from '@byline/core'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { getAdminTreeParent, getAdminUnplacedTreeDocuments } from './tree-read.js'
|
|
5
|
+
|
|
6
|
+
describe('getAdminTreeParent', () => {
|
|
7
|
+
it('explicitly selects status:any', async () => {
|
|
8
|
+
const getTreeParent = vi.fn().mockResolvedValue({
|
|
9
|
+
placed: false,
|
|
10
|
+
parentDocumentId: null,
|
|
11
|
+
parentVisibility: 'none',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
await getAdminTreeParent({ getTreeParent } as any, 'doc-1')
|
|
15
|
+
|
|
16
|
+
expect(getTreeParent).toHaveBeenCalledWith('doc-1', { status: 'any' })
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('getAdminUnplacedTreeDocuments', () => {
|
|
21
|
+
it('uses scoped any-mode reads so hidden unplaced nodes cannot reappear', async () => {
|
|
22
|
+
const visible = { id: 'visible-unplaced' }
|
|
23
|
+
const hidden = { id: 'hidden-unplaced' }
|
|
24
|
+
const readContext = createReadContext()
|
|
25
|
+
const find = vi.fn(async (options: Record<string, unknown>) => ({
|
|
26
|
+
docs: options._bypassBeforeRead === true ? [visible, hidden] : [visible],
|
|
27
|
+
meta: { total: 1, page: 1, pageSize: 1000, totalPages: 1 },
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
const result = await getAdminUnplacedTreeDocuments(
|
|
31
|
+
{ find } as any,
|
|
32
|
+
new Set(['visible-placed']),
|
|
33
|
+
readContext,
|
|
34
|
+
'en'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
expect(find).toHaveBeenCalledWith({
|
|
38
|
+
status: 'any',
|
|
39
|
+
locale: 'en',
|
|
40
|
+
pageSize: 1000,
|
|
41
|
+
_readContext: readContext,
|
|
42
|
+
})
|
|
43
|
+
expect(result.map((doc) => doc.id)).toEqual(['visible-unplaced'])
|
|
44
|
+
expect(result).not.toContain(hidden)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ClientDocument, CollectionHandle, TreeParentResult } from '@byline/client'
|
|
2
|
+
import type { ReadContext } from '@byline/core'
|
|
3
|
+
|
|
4
|
+
/** Admin tree placement reads must include drafts. */
|
|
5
|
+
export function getAdminTreeParent(
|
|
6
|
+
handle: Pick<CollectionHandle, 'getTreeParent'>,
|
|
7
|
+
documentId: string
|
|
8
|
+
): Promise<TreeParentResult> {
|
|
9
|
+
return handle.getTreeParent(documentId, { status: 'any' })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Find unplaced nodes through the same row scope as the structural tree read. */
|
|
13
|
+
export async function getAdminUnplacedTreeDocuments(
|
|
14
|
+
handle: Pick<CollectionHandle, 'find'>,
|
|
15
|
+
placed: ReadonlySet<string>,
|
|
16
|
+
readContext: ReadContext,
|
|
17
|
+
locale?: string
|
|
18
|
+
): Promise<ClientDocument[]> {
|
|
19
|
+
const all = await handle.find({
|
|
20
|
+
status: 'any',
|
|
21
|
+
locale,
|
|
22
|
+
pageSize: 1000,
|
|
23
|
+
_readContext: readContext,
|
|
24
|
+
})
|
|
25
|
+
return all.docs.filter((doc) => !placed.has(doc.id))
|
|
26
|
+
}
|