@sanity/sdk 3.0.0-rc.1 → 3.0.0-rc.2
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/README.md +12 -0
- package/dist/_exports/_internal.d.ts +71 -3
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +332 -24
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +65 -0
- package/dist/_exports/agent.d.ts.map +1 -0
- package/dist/_exports/agent.js +71 -0
- package/dist/_exports/agent.js.map +1 -0
- package/dist/_exports/comlink.d.ts +118 -0
- package/dist/_exports/comlink.d.ts.map +1 -0
- package/dist/_exports/comlink.js +69 -0
- package/dist/_exports/comlink.js.map +1 -0
- package/dist/authStore-CtE9bLHW.js +754 -0
- package/dist/authStore-CtE9bLHW.js.map +1 -0
- package/dist/authStore-DKFX8fn7.d.ts +3179 -0
- package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
- package/dist/clientStore-BtE2-q2i.js +90 -0
- package/dist/clientStore-BtE2-q2i.js.map +1 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
- package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
- package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
- package/dist/getEnv-D5RGkQLu.js +8 -0
- package/dist/getEnv-D5RGkQLu.js.map +1 -0
- package/dist/getNodeState-CR3__B2D.js +67 -0
- package/dist/getNodeState-CR3__B2D.js.map +1 -0
- package/dist/index.d.ts +3 -2335
- package/dist/index.js +4048 -3874
- package/dist/index.js.map +1 -1
- package/dist/logger-DAtJb5RQ.js +173 -0
- package/dist/logger-DAtJb5RQ.js.map +1 -0
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/queryStore-DPf9sN3V.js +1346 -0
- package/dist/queryStore-DPf9sN3V.js.map +1 -0
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
- package/dist/telemetryManager-Dq_qCkeI.js +127 -0
- package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
- package/dist/version-BChjsi4u.js +9 -0
- package/dist/version-BChjsi4u.js.map +1 -0
- package/package.json +13 -19
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/agent.ts +19 -0
- package/src/_exports/comlink.ts +16 -0
- package/src/_exports/index.ts +38 -86
- package/src/applications/applications.test-d.ts +5 -0
- package/src/applications/applications.ts +5 -2
- package/src/auth/authMode.test.ts +0 -25
- package/src/auth/authMode.ts +3 -6
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/authStore.ts +0 -1
- package/src/comments/addonDatasetStore.test.ts +215 -0
- package/src/comments/addonDatasetStore.ts +249 -0
- package/src/comments/buildCommentThreads.test.ts +83 -0
- package/src/comments/buildCommentThreads.ts +54 -0
- package/src/comments/commentActions.test.ts +540 -0
- package/src/comments/commentActions.ts +555 -0
- package/src/comments/commentFieldPath.test.ts +40 -0
- package/src/comments/commentFieldPath.ts +44 -0
- package/src/comments/commentsConstants.ts +80 -0
- package/src/comments/commentsStore.test.ts +419 -0
- package/src/comments/commentsStore.ts +483 -0
- package/src/comments/normalizeComment.test.ts +116 -0
- package/src/comments/normalizeComment.ts +45 -0
- package/src/comments/observeComments.test.ts +193 -0
- package/src/comments/observeComments.ts +113 -0
- package/src/comments/reducers.test.ts +277 -0
- package/src/comments/reducers.ts +316 -0
- package/src/comments/types.ts +249 -0
- package/src/comments/weakenReferences.test.ts +47 -0
- package/src/comments/weakenReferences.ts +29 -0
- package/src/config/sanityConfig.ts +3 -75
- package/src/document/applyDocumentActions.test.ts +8 -12
- package/src/document/documentConstants.ts +8 -0
- package/src/document/documentStore.test.ts +104 -19
- package/src/document/documentStore.ts +36 -5
- package/src/document/patchOperations.test.ts +27 -0
- package/src/document/patchOperations.ts +10 -1
- package/src/document/permissions.test.ts +3 -1
- package/src/document/reducers.test.ts +133 -0
- package/src/document/reducers.ts +18 -3
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/installations/installations.test-d.ts +5 -0
- package/src/installations/installations.ts +4 -0
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/src/preview/types.ts +0 -25
- package/src/projection/projectionStore.test.ts +0 -8
- package/src/projection/types.ts +0 -8
- package/src/query/queryStore.ts +7 -6
- package/src/store/createActionBinder.test.ts +51 -29
- package/src/store/createActionBinder.ts +16 -62
- package/src/store/createSanityInstance.test.ts +0 -68
- package/src/store/createSanityInstance.ts +0 -63
- package/src/store/createStateSourceAction.ts +1 -1
- package/src/users/reducers.test.ts +0 -3
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -134
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
- package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
- package/dist/_chunks-es/telemetryManager.js +0 -93
- package/dist/_chunks-es/telemetryManager.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/src/applications/userApplications.test.ts +0 -126
- package/src/applications/userApplications.ts +0 -261
- package/src/preview/getPreviewState.test.ts +0 -137
- package/src/preview/getPreviewState.ts +0 -69
- package/src/preview/resolvePreview.test.ts +0 -64
- package/src/preview/resolvePreview.ts +0 -43
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {type ListenEvent, type SanityClient} from '@sanity/client'
|
|
2
|
+
import {Subject} from 'rxjs'
|
|
3
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {type CommentsEvent, observeComments} from './observeComments'
|
|
6
|
+
import {type StoredComment} from './types'
|
|
7
|
+
|
|
8
|
+
function comment(overrides: Partial<StoredComment> & Pick<StoredComment, '_id'>) {
|
|
9
|
+
return {
|
|
10
|
+
_type: 'comment',
|
|
11
|
+
_createdAt: '2026-01-01T00:00:00Z',
|
|
12
|
+
_rev: 'rev',
|
|
13
|
+
authorId: 'user-1',
|
|
14
|
+
message: null,
|
|
15
|
+
threadId: 'thread-1',
|
|
16
|
+
status: 'open',
|
|
17
|
+
reactions: null,
|
|
18
|
+
target: {documentType: 'author', document: {_ref: 'doc-1', _type: 'reference', _weak: true}},
|
|
19
|
+
...overrides,
|
|
20
|
+
} satisfies StoredComment as StoredComment
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const WELCOME = {type: 'welcome'} as ListenEvent<StoredComment>
|
|
24
|
+
|
|
25
|
+
let listen$: Subject<ListenEvent<StoredComment>>
|
|
26
|
+
let fetches: Subject<StoredComment[]>[]
|
|
27
|
+
let client: SanityClient
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
listen$ = new Subject()
|
|
31
|
+
fetches = []
|
|
32
|
+
client = {
|
|
33
|
+
observable: {
|
|
34
|
+
listen: vi.fn(() => listen$),
|
|
35
|
+
fetch: vi.fn(() => {
|
|
36
|
+
const fetch$ = new Subject<StoredComment[]>()
|
|
37
|
+
fetches.push(fetch$)
|
|
38
|
+
return fetch$
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
} as unknown as SanityClient
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function collect(documentVersionId?: string) {
|
|
45
|
+
const events: CommentsEvent[] = []
|
|
46
|
+
const subscription = observeComments({client, documentId: 'doc-1', documentVersionId}).subscribe(
|
|
47
|
+
(event) => events.push(event),
|
|
48
|
+
)
|
|
49
|
+
return {events, subscription}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('observeComments', () => {
|
|
53
|
+
it('fetches a snapshot when the listener connects', () => {
|
|
54
|
+
const {events} = collect()
|
|
55
|
+
|
|
56
|
+
listen$.next(WELCOME)
|
|
57
|
+
fetches[0].next([comment({_id: 'a'})])
|
|
58
|
+
|
|
59
|
+
expect(events).toEqual([{type: 'snapshot', comments: [comment({_id: 'a'})]}])
|
|
60
|
+
expect(client.observable.fetch).toHaveBeenCalledWith(
|
|
61
|
+
expect.stringContaining('_type == "comment"'),
|
|
62
|
+
{documentId: 'doc-1'},
|
|
63
|
+
{tag: 'comments.list'},
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('passes the release id when reading a release', () => {
|
|
68
|
+
collect('summer')
|
|
69
|
+
|
|
70
|
+
expect(client.observable.listen).toHaveBeenCalledWith(
|
|
71
|
+
expect.stringContaining('target.documentVersionId == $documentVersionId'),
|
|
72
|
+
{documentId: 'doc-1', documentVersionId: 'summer'},
|
|
73
|
+
expect.objectContaining({tag: 'comments.listen'}),
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('maps appear, update, and disappear events', () => {
|
|
78
|
+
const {events} = collect()
|
|
79
|
+
listen$.next(WELCOME)
|
|
80
|
+
fetches[0].next([])
|
|
81
|
+
fetches[0].complete()
|
|
82
|
+
|
|
83
|
+
const created = comment({_id: 'a'})
|
|
84
|
+
const edited = comment({_id: 'a', status: 'resolved'})
|
|
85
|
+
|
|
86
|
+
listen$.next({
|
|
87
|
+
type: 'mutation',
|
|
88
|
+
transition: 'appear',
|
|
89
|
+
documentId: 'a',
|
|
90
|
+
result: created,
|
|
91
|
+
transactionId: 'tx-1',
|
|
92
|
+
} as ListenEvent<StoredComment>)
|
|
93
|
+
listen$.next({
|
|
94
|
+
type: 'mutation',
|
|
95
|
+
transition: 'update',
|
|
96
|
+
documentId: 'a',
|
|
97
|
+
result: edited,
|
|
98
|
+
transactionId: 'tx-2',
|
|
99
|
+
} as ListenEvent<StoredComment>)
|
|
100
|
+
listen$.next({
|
|
101
|
+
type: 'mutation',
|
|
102
|
+
transition: 'disappear',
|
|
103
|
+
documentId: 'a',
|
|
104
|
+
} as ListenEvent<StoredComment>)
|
|
105
|
+
|
|
106
|
+
expect(events.slice(1)).toEqual([
|
|
107
|
+
{type: 'appear', comment: created},
|
|
108
|
+
{type: 'update', comment: edited, transactionId: 'tx-2'},
|
|
109
|
+
{type: 'disappear', commentId: 'a'},
|
|
110
|
+
])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('ignores a mutation that carries no document', () => {
|
|
114
|
+
const {events} = collect()
|
|
115
|
+
listen$.next(WELCOME)
|
|
116
|
+
fetches[0].next([])
|
|
117
|
+
|
|
118
|
+
listen$.next({
|
|
119
|
+
type: 'mutation',
|
|
120
|
+
transition: 'update',
|
|
121
|
+
documentId: 'a',
|
|
122
|
+
transactionId: 'tx-1',
|
|
123
|
+
} as ListenEvent<StoredComment>)
|
|
124
|
+
|
|
125
|
+
expect(events).toHaveLength(1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps a mutation that arrives while the snapshot is still loading', () => {
|
|
129
|
+
// The Studio loses this one: it awaits the fetch inside the event handler,
|
|
130
|
+
// so the older snapshot overwrites the newer comment.
|
|
131
|
+
const {events} = collect()
|
|
132
|
+
listen$.next(WELCOME)
|
|
133
|
+
|
|
134
|
+
const created = comment({_id: 'late'})
|
|
135
|
+
listen$.next({
|
|
136
|
+
type: 'mutation',
|
|
137
|
+
transition: 'appear',
|
|
138
|
+
documentId: 'late',
|
|
139
|
+
result: created,
|
|
140
|
+
transactionId: 'tx-1',
|
|
141
|
+
} as ListenEvent<StoredComment>)
|
|
142
|
+
|
|
143
|
+
expect(events).toEqual([])
|
|
144
|
+
|
|
145
|
+
fetches[0].next([comment({_id: 'a'})])
|
|
146
|
+
|
|
147
|
+
expect(events).toEqual([
|
|
148
|
+
{type: 'snapshot', comments: [comment({_id: 'a'})]},
|
|
149
|
+
{type: 'appear', comment: created},
|
|
150
|
+
])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('abandons an in-flight fetch when the listener reconnects', () => {
|
|
154
|
+
const {events} = collect()
|
|
155
|
+
listen$.next(WELCOME)
|
|
156
|
+
expect(fetches).toHaveLength(1)
|
|
157
|
+
|
|
158
|
+
listen$.next(WELCOME)
|
|
159
|
+
expect(fetches).toHaveLength(2)
|
|
160
|
+
|
|
161
|
+
// The first connection's snapshot is stale by now and must not be applied.
|
|
162
|
+
expect(fetches[0].observed).toBe(false)
|
|
163
|
+
|
|
164
|
+
fetches[0].next([comment({_id: 'stale'})])
|
|
165
|
+
expect(events).toEqual([])
|
|
166
|
+
|
|
167
|
+
fetches[1].next([comment({_id: 'fresh'})])
|
|
168
|
+
expect(events).toEqual([{type: 'snapshot', comments: [comment({_id: 'fresh'})]}])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('reports a failed snapshot without killing the stream', () => {
|
|
172
|
+
const {events} = collect()
|
|
173
|
+
listen$.next(WELCOME)
|
|
174
|
+
|
|
175
|
+
const error = new Error('boom')
|
|
176
|
+
fetches[0].error(error)
|
|
177
|
+
|
|
178
|
+
expect(events).toEqual([{type: 'error', error}])
|
|
179
|
+
|
|
180
|
+
// A later reconnect still recovers.
|
|
181
|
+
listen$.next(WELCOME)
|
|
182
|
+
fetches[1].next([])
|
|
183
|
+
expect(events).toHaveLength(2)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('stops listening when unsubscribed', () => {
|
|
187
|
+
const {subscription} = collect()
|
|
188
|
+
listen$.next(WELCOME)
|
|
189
|
+
subscription.unsubscribe()
|
|
190
|
+
|
|
191
|
+
expect(listen$.observed).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {type ListenEvent, type SanityClient} from '@sanity/client'
|
|
2
|
+
import {filter, map, Observable, share, switchMap, take} from 'rxjs'
|
|
3
|
+
|
|
4
|
+
import {buildCommentsListenQuery, buildCommentsQuery, LISTEN_OPTIONS} from './commentsConstants'
|
|
5
|
+
import {type StoredComment} from './types'
|
|
6
|
+
|
|
7
|
+
/** What the listener saw, in terms the store can apply. */
|
|
8
|
+
export type CommentsEvent =
|
|
9
|
+
| {type: 'snapshot'; comments: StoredComment[]}
|
|
10
|
+
| {type: 'appear'; comment: StoredComment}
|
|
11
|
+
| {type: 'disappear'; commentId: string}
|
|
12
|
+
| {type: 'update'; comment: StoredComment; transactionId: string}
|
|
13
|
+
| {type: 'error'; error: unknown}
|
|
14
|
+
|
|
15
|
+
function toCommentsEvent(event: ListenEvent<StoredComment>): CommentsEvent | undefined {
|
|
16
|
+
if (event.type !== 'mutation') return undefined
|
|
17
|
+
|
|
18
|
+
if (event.transition === 'disappear') {
|
|
19
|
+
return {type: 'disappear', commentId: event.documentId}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// `includeResult` is on, so a create or update carries the whole comment. An
|
|
23
|
+
// event without one is nothing we can apply.
|
|
24
|
+
if (!event.result) return undefined
|
|
25
|
+
|
|
26
|
+
if (event.transition === 'appear') {
|
|
27
|
+
return {type: 'appear', comment: event.result}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {type: 'update', comment: event.result, transactionId: event.transactionId}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Watches one document's comments: a snapshot on connect, then live changes.
|
|
35
|
+
*
|
|
36
|
+
* The Studio awaits its snapshot inside the event handler, which loses any
|
|
37
|
+
* mutation that arrives while the fetch is in flight, because the older
|
|
38
|
+
* snapshot overwrites it. Here the mutations that arrive during a fetch are
|
|
39
|
+
* held and replayed straight after the snapshot, so nothing is dropped.
|
|
40
|
+
*
|
|
41
|
+
* A reconnect produces a fresh `welcome`, which cancels any fetch still running
|
|
42
|
+
* and starts a new one. Reconnects emit nothing themselves: dropping the list
|
|
43
|
+
* while reconnecting would only make readers suspend again for data we still
|
|
44
|
+
* have.
|
|
45
|
+
*
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export function observeComments(options: {
|
|
49
|
+
client: SanityClient
|
|
50
|
+
documentId: string
|
|
51
|
+
documentVersionId?: string
|
|
52
|
+
}): Observable<CommentsEvent> {
|
|
53
|
+
const {client, documentId, documentVersionId} = options
|
|
54
|
+
|
|
55
|
+
const params = {
|
|
56
|
+
documentId,
|
|
57
|
+
...(documentVersionId ? {documentVersionId} : {}),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const events$ = client.observable
|
|
61
|
+
.listen<StoredComment>(buildCommentsListenQuery(documentVersionId), params, LISTEN_OPTIONS)
|
|
62
|
+
.pipe(share())
|
|
63
|
+
|
|
64
|
+
const mutations$ = events$.pipe(
|
|
65
|
+
map(toCommentsEvent),
|
|
66
|
+
filter((event): event is CommentsEvent => event !== undefined),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return events$.pipe(
|
|
70
|
+
filter((event) => event.type === 'welcome'),
|
|
71
|
+
switchMap(
|
|
72
|
+
() =>
|
|
73
|
+
new Observable<CommentsEvent>((observer) => {
|
|
74
|
+
// Subscribe before the fetch starts so nothing between the two is missed.
|
|
75
|
+
// The array only exists while the snapshot is in flight. Once flushed,
|
|
76
|
+
// live mutations pass through without retaining their history.
|
|
77
|
+
const buffered: CommentsEvent[] = []
|
|
78
|
+
let snapshotReceived = false
|
|
79
|
+
const mutationSubscription = mutations$.subscribe({
|
|
80
|
+
next: (event) => {
|
|
81
|
+
if (snapshotReceived) observer.next(event)
|
|
82
|
+
else buffered.push(event)
|
|
83
|
+
},
|
|
84
|
+
error: (error: unknown) => observer.error(error),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const snapshotSubscription = client.observable
|
|
88
|
+
.fetch<StoredComment[]>(buildCommentsQuery(documentVersionId), params, {
|
|
89
|
+
tag: 'comments.list',
|
|
90
|
+
})
|
|
91
|
+
.pipe(take(1))
|
|
92
|
+
.subscribe({
|
|
93
|
+
next: (comments) => {
|
|
94
|
+
observer.next({type: 'snapshot', comments})
|
|
95
|
+
snapshotReceived = true
|
|
96
|
+
for (const event of buffered) observer.next(event)
|
|
97
|
+
buffered.length = 0
|
|
98
|
+
},
|
|
99
|
+
error: (error: unknown) => {
|
|
100
|
+
observer.next({type: 'error', error})
|
|
101
|
+
observer.complete()
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
mutationSubscription.unsubscribe()
|
|
107
|
+
snapshotSubscription.unsubscribe()
|
|
108
|
+
buffered.length = 0
|
|
109
|
+
}
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
addComment,
|
|
5
|
+
addSubscriber,
|
|
6
|
+
applyCommentUpdate,
|
|
7
|
+
clearPendingTransaction,
|
|
8
|
+
type CommentsStoreState,
|
|
9
|
+
getCommentsKey,
|
|
10
|
+
parseCommentsKey,
|
|
11
|
+
receiveComment,
|
|
12
|
+
removeCommentById,
|
|
13
|
+
removeSubscriber,
|
|
14
|
+
rollbackCommentUpdate,
|
|
15
|
+
setComments,
|
|
16
|
+
setCommentsError,
|
|
17
|
+
setPendingTransaction,
|
|
18
|
+
} from './reducers'
|
|
19
|
+
import {type StoredComment} from './types'
|
|
20
|
+
|
|
21
|
+
const KEY = getCommentsKey({documentId: 'doc-1'})
|
|
22
|
+
|
|
23
|
+
function comment(overrides: Partial<StoredComment> & Pick<StoredComment, '_id'>) {
|
|
24
|
+
return {
|
|
25
|
+
_type: 'comment',
|
|
26
|
+
_createdAt: '2026-01-01T00:00:00Z',
|
|
27
|
+
_rev: 'rev',
|
|
28
|
+
authorId: 'user-1',
|
|
29
|
+
message: null,
|
|
30
|
+
threadId: 'thread-1',
|
|
31
|
+
status: 'open',
|
|
32
|
+
reactions: null,
|
|
33
|
+
target: {documentType: 'author', document: {_ref: 'doc-1', _type: 'reference', _weak: true}},
|
|
34
|
+
...overrides,
|
|
35
|
+
} satisfies StoredComment as StoredComment
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stateWith(comments: StoredComment[]): CommentsStoreState {
|
|
39
|
+
const base: CommentsStoreState = {
|
|
40
|
+
entries: {[KEY]: {subscribers: ['sub-1']}},
|
|
41
|
+
pendingCreates: {},
|
|
42
|
+
pendingTransactions: {},
|
|
43
|
+
}
|
|
44
|
+
return setComments(KEY, comments)(base)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const emptyState = (): CommentsStoreState => ({
|
|
48
|
+
entries: {},
|
|
49
|
+
pendingCreates: {},
|
|
50
|
+
pendingTransactions: {},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('comment list keys', () => {
|
|
54
|
+
it('round-trips a document id', () => {
|
|
55
|
+
expect(parseCommentsKey(getCommentsKey({documentId: 'doc-1'}))).toEqual({documentId: 'doc-1'})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('round-trips a release version', () => {
|
|
59
|
+
const key = getCommentsKey({documentId: 'doc-1', documentVersionId: 'summer'})
|
|
60
|
+
expect(parseCommentsKey(key)).toEqual({documentId: 'doc-1', documentVersionId: 'summer'})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('separates a release from the default list', () => {
|
|
64
|
+
expect(getCommentsKey({documentId: 'doc-1'})).not.toBe(
|
|
65
|
+
getCommentsKey({documentId: 'doc-1', documentVersionId: 'summer'}),
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('subscribers', () => {
|
|
71
|
+
const empty = emptyState()
|
|
72
|
+
|
|
73
|
+
it('creates an entry for the first subscriber', () => {
|
|
74
|
+
expect(addSubscriber(KEY, 'sub-1')(empty).entries[KEY]).toEqual({subscribers: ['sub-1']})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('drops the entry when the last subscriber leaves', () => {
|
|
78
|
+
const one = addSubscriber(KEY, 'sub-1')(empty)
|
|
79
|
+
expect(removeSubscriber(KEY, 'sub-1')(one).entries).toEqual({})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keeps the entry while another subscriber remains', () => {
|
|
83
|
+
const two = addSubscriber(KEY, 'sub-2')(addSubscriber(KEY, 'sub-1')(empty))
|
|
84
|
+
expect(removeSubscriber(KEY, 'sub-1')(two).entries[KEY]).toEqual({subscribers: ['sub-2']})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('drops reconciliation state when the last listener leaves', () => {
|
|
88
|
+
const before = {
|
|
89
|
+
...stateWith([comment({_id: 'a'})]),
|
|
90
|
+
pendingCreates: {a: true as const},
|
|
91
|
+
pendingTransactions: {a: 'tx-1'},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const next = removeSubscriber(KEY, 'sub-1')(before)
|
|
95
|
+
|
|
96
|
+
expect(next.entries).toEqual({})
|
|
97
|
+
expect(next.pendingCreates).toEqual({})
|
|
98
|
+
expect(next.pendingTransactions).toEqual({})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('ignores removal for an unknown key', () => {
|
|
102
|
+
expect(removeSubscriber('missing', 'sub-1')(empty)).toBe(empty)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('setComments', () => {
|
|
107
|
+
it('keys the snapshot by comment id and clears any previous error', () => {
|
|
108
|
+
const withError = setCommentsError(
|
|
109
|
+
KEY,
|
|
110
|
+
new Error('boom'),
|
|
111
|
+
)({...emptyState(), entries: {[KEY]: {subscribers: ['sub-1']}}})
|
|
112
|
+
|
|
113
|
+
const next = setComments(KEY, [comment({_id: 'a'})])(withError)
|
|
114
|
+
|
|
115
|
+
expect(Object.keys(next.entries[KEY]!.comments!)).toEqual(['a'])
|
|
116
|
+
expect(next.entries[KEY]!.error).toBe(undefined)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('ignores a snapshot for an entry nobody is reading', () => {
|
|
120
|
+
const empty = emptyState()
|
|
121
|
+
expect(setComments(KEY, [comment({_id: 'a'})])(empty)).toBe(empty)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('keeps a failed local create that is absent from the snapshot', () => {
|
|
125
|
+
const failed = comment({
|
|
126
|
+
_id: 'failed',
|
|
127
|
+
message: [{_type: 'block', _key: 'block-1', children: [{_type: 'span', text: 'unsent'}]}],
|
|
128
|
+
_state: {type: 'createError', error: new Error('nope')},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const next = setComments(KEY, [])(stateWith([failed]))
|
|
132
|
+
|
|
133
|
+
expect(next.entries[KEY]!.comments!['failed']).toBe(failed)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('keeps an in-flight local create that is absent from the snapshot', () => {
|
|
137
|
+
const optimistic = addComment(KEY, {
|
|
138
|
+
_id: 'pending',
|
|
139
|
+
_type: 'comment',
|
|
140
|
+
authorId: 'user-1',
|
|
141
|
+
message: null,
|
|
142
|
+
threadId: 'thread-2',
|
|
143
|
+
status: 'open',
|
|
144
|
+
reactions: null,
|
|
145
|
+
target: {documentType: 'author', document: {_ref: 'doc-1', _type: 'reference', _weak: true}},
|
|
146
|
+
})(stateWith([]))
|
|
147
|
+
|
|
148
|
+
const next = setComments(KEY, [])(optimistic)
|
|
149
|
+
|
|
150
|
+
expect(next.entries[KEY]!.comments!['pending']).toBeDefined()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('stores comment ids such as __proto__ as ordinary keys', () => {
|
|
154
|
+
const special = comment({_id: '__proto__'})
|
|
155
|
+
const next = setComments(KEY, [special])(stateWith([]))
|
|
156
|
+
|
|
157
|
+
expect(Object.values(next.entries[KEY]!.comments!)).toEqual([special])
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('addComment', () => {
|
|
162
|
+
it('stands in a createdAt so an unconfirmed comment still sorts', () => {
|
|
163
|
+
const next = addComment(KEY, {
|
|
164
|
+
_id: 'new',
|
|
165
|
+
_type: 'comment',
|
|
166
|
+
authorId: 'user-1',
|
|
167
|
+
message: null,
|
|
168
|
+
threadId: 'thread-2',
|
|
169
|
+
status: 'open',
|
|
170
|
+
reactions: null,
|
|
171
|
+
target: {documentType: 'author', document: {_ref: 'doc-1', _type: 'reference', _weak: true}},
|
|
172
|
+
})(stateWith([]))
|
|
173
|
+
|
|
174
|
+
expect(next.entries[KEY]!.comments!['new']._createdAt).toEqual(expect.any(String))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('keeps the server createdAt once there is one', () => {
|
|
178
|
+
const existing = comment({_id: 'a', _createdAt: '2026-03-03T00:00:00Z'})
|
|
179
|
+
const next = addComment(KEY, {...existing})(stateWith([existing]))
|
|
180
|
+
|
|
181
|
+
expect(next.entries[KEY]!.comments!['a']._createdAt).toBe('2026-03-03T00:00:00Z')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('marks a failed comment as retrying when it is added again', () => {
|
|
185
|
+
const failed = comment({
|
|
186
|
+
_id: 'a',
|
|
187
|
+
_state: {type: 'createError', error: new Error('nope')},
|
|
188
|
+
})
|
|
189
|
+
const payload = {...failed}
|
|
190
|
+
delete payload._state
|
|
191
|
+
|
|
192
|
+
const next = addComment(KEY, payload)(stateWith([failed]))
|
|
193
|
+
|
|
194
|
+
expect(next.entries[KEY]!.comments!['a']._state).toEqual({type: 'createRetrying'})
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('applyCommentUpdate', () => {
|
|
199
|
+
it('merges a patch into the comment', () => {
|
|
200
|
+
const next = applyCommentUpdate('a', {status: 'resolved'})(stateWith([comment({_id: 'a'})]))
|
|
201
|
+
expect(next.entries[KEY]!.comments!['a'].status).toBe('resolved')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('leaves state untouched for an unknown comment', () => {
|
|
205
|
+
const before = stateWith([comment({_id: 'a'})])
|
|
206
|
+
expect(applyCommentUpdate('missing', {status: 'resolved'})(before)).toBe(before)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('removeCommentById', () => {
|
|
211
|
+
it('removes the comment and its replies', () => {
|
|
212
|
+
const before = stateWith([
|
|
213
|
+
comment({_id: 'a'}),
|
|
214
|
+
comment({_id: 'b', parentCommentId: 'a'}),
|
|
215
|
+
comment({_id: 'c'}),
|
|
216
|
+
])
|
|
217
|
+
|
|
218
|
+
expect(Object.keys(removeCommentById('a')(before).entries[KEY]!.comments!)).toEqual(['c'])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('removes a single reply without touching its siblings', () => {
|
|
222
|
+
const before = stateWith([
|
|
223
|
+
comment({_id: 'a'}),
|
|
224
|
+
comment({_id: 'b', parentCommentId: 'a'}),
|
|
225
|
+
comment({_id: 'c', parentCommentId: 'a'}),
|
|
226
|
+
])
|
|
227
|
+
|
|
228
|
+
expect(Object.keys(removeCommentById('b')(before).entries[KEY]!.comments!)).toEqual(['a', 'c'])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('leaves state untouched for an unknown comment', () => {
|
|
232
|
+
const before = stateWith([comment({_id: 'a'})])
|
|
233
|
+
expect(removeCommentById('missing')(before)).toBe(before)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('receiveComment', () => {
|
|
238
|
+
it('replaces what we held with the server copy', () => {
|
|
239
|
+
const before = stateWith([comment({_id: 'a', status: 'open'})])
|
|
240
|
+
const next = receiveComment(KEY, comment({_id: 'a', status: 'resolved'}))(before)
|
|
241
|
+
|
|
242
|
+
expect(next.entries[KEY]!.comments!['a'].status).toBe('resolved')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('clears create reconciliation after the entry has been released', () => {
|
|
246
|
+
const before: CommentsStoreState = {...emptyState(), pendingCreates: {a: true}}
|
|
247
|
+
|
|
248
|
+
expect(receiveComment(KEY, comment({_id: 'a'}))(before).pendingCreates).toEqual({})
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('pending transactions', () => {
|
|
253
|
+
it('records and clears the transaction for a comment', () => {
|
|
254
|
+
const set = setPendingTransaction('a', 'tx-1')(emptyState())
|
|
255
|
+
expect(set.pendingTransactions).toEqual({a: 'tx-1'})
|
|
256
|
+
expect(clearPendingTransaction('a')(set).pendingTransactions).toEqual({})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('rolls back an optimistic edit while its transaction is still current', () => {
|
|
260
|
+
const previous = comment({_id: 'a', status: 'open'})
|
|
261
|
+
const pending = setPendingTransaction('a', 'tx-1')(stateWith([previous]))
|
|
262
|
+
const optimistic = applyCommentUpdate('a', {status: 'resolved'})(pending)
|
|
263
|
+
|
|
264
|
+
const rolledBack = rollbackCommentUpdate('a', 'tx-1', previous)(optimistic)
|
|
265
|
+
|
|
266
|
+
expect(rolledBack.entries[KEY]!.comments!['a'].status).toBe('open')
|
|
267
|
+
expect(rolledBack.pendingTransactions).toEqual({})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('does not roll back once a newer transaction owns the comment', () => {
|
|
271
|
+
const previous = comment({_id: 'a', status: 'open'})
|
|
272
|
+
const pending = setPendingTransaction('a', 'tx-2')(stateWith([previous]))
|
|
273
|
+
const optimistic = applyCommentUpdate('a', {status: 'resolved'})(pending)
|
|
274
|
+
|
|
275
|
+
expect(rollbackCommentUpdate('a', 'tx-1', previous)(optimistic)).toBe(optimistic)
|
|
276
|
+
})
|
|
277
|
+
})
|