@sanity/sdk 2.19.0 → 2.20.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.
Files changed (62) hide show
  1. package/dist/_exports/_internal.d.ts +2 -2
  2. package/dist/_exports/_internal.js +196 -22
  3. package/dist/_exports/_internal.js.map +1 -1
  4. package/dist/authStore-CWTAuieY.js +1072 -0
  5. package/dist/authStore-CWTAuieY.js.map +1 -0
  6. package/dist/clientStore-CMzJ7XhZ.js +168 -0
  7. package/dist/clientStore-CMzJ7XhZ.js.map +1 -0
  8. package/dist/createGroqSearchFilter-B0NdA-EP.js +1222 -0
  9. package/dist/createGroqSearchFilter-B0NdA-EP.js.map +1 -0
  10. package/dist/getEnv-76AjZd_f.js +178 -0
  11. package/dist/getEnv-76AjZd_f.js.map +1 -0
  12. package/dist/index.d.ts +2 -2105
  13. package/dist/index.js +4140 -3870
  14. package/dist/index.js.map +1 -1
  15. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  16. package/dist/telemetryManager-Bd3O8z2S.js +127 -0
  17. package/dist/telemetryManager-Bd3O8z2S.js.map +1 -0
  18. package/dist/utils-BMwA8Kg9.d.ts +3334 -0
  19. package/dist/utils-BMwA8Kg9.d.ts.map +1 -0
  20. package/dist/version-DL2l07HG.js +9 -0
  21. package/dist/version-DL2l07HG.js.map +1 -0
  22. package/package.json +12 -20
  23. package/src/_exports/_internal.ts +1 -0
  24. package/src/_exports/index.ts +30 -0
  25. package/src/comments/addonDatasetStore.test.ts +215 -0
  26. package/src/comments/addonDatasetStore.ts +249 -0
  27. package/src/comments/buildCommentThreads.test.ts +83 -0
  28. package/src/comments/buildCommentThreads.ts +54 -0
  29. package/src/comments/commentActions.test.ts +540 -0
  30. package/src/comments/commentActions.ts +555 -0
  31. package/src/comments/commentFieldPath.test.ts +40 -0
  32. package/src/comments/commentFieldPath.ts +44 -0
  33. package/src/comments/commentsConstants.ts +80 -0
  34. package/src/comments/commentsStore.test.ts +419 -0
  35. package/src/comments/commentsStore.ts +483 -0
  36. package/src/comments/normalizeComment.test.ts +116 -0
  37. package/src/comments/normalizeComment.ts +45 -0
  38. package/src/comments/observeComments.test.ts +193 -0
  39. package/src/comments/observeComments.ts +113 -0
  40. package/src/comments/reducers.test.ts +277 -0
  41. package/src/comments/reducers.ts +316 -0
  42. package/src/comments/types.ts +249 -0
  43. package/src/comments/weakenReferences.test.ts +47 -0
  44. package/src/comments/weakenReferences.ts +29 -0
  45. package/src/document/documentConstants.ts +8 -0
  46. package/src/document/documentStore.test.ts +98 -1
  47. package/src/document/documentStore.ts +36 -5
  48. package/src/document/patchOperations.test.ts +27 -0
  49. package/src/document/patchOperations.ts +10 -1
  50. package/src/document/permissions.test.ts +3 -1
  51. package/src/document/reducers.test.ts +133 -0
  52. package/src/document/reducers.ts +18 -3
  53. package/src/projection/projectionStore.test.ts +0 -8
  54. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -923
  55. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  56. package/dist/_chunks-es/_internal.js +0 -134
  57. package/dist/_chunks-es/_internal.js.map +0 -1
  58. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1590
  59. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  60. package/dist/_chunks-es/telemetryManager.js +0 -93
  61. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  62. package/dist/index.d.ts.map +0 -1
@@ -0,0 +1,83 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {buildCommentThreads} from './buildCommentThreads'
4
+ import {type Comment} from './types'
5
+
6
+ function comment(overrides: Partial<Comment> & Pick<Comment, 'id'>): Comment {
7
+ return {
8
+ createdAt: '2026-01-01T00:00:00Z',
9
+ authorId: 'user-1',
10
+ message: null,
11
+ threadId: 'thread-1',
12
+ status: 'open',
13
+ reactions: [],
14
+ documentId: 'doc-1',
15
+ documentType: 'author',
16
+ fieldPath: '',
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('buildCommentThreads', () => {
22
+ it('returns nothing for an empty list', () => {
23
+ expect(buildCommentThreads([])).toEqual([])
24
+ })
25
+
26
+ it('groups a parent with its replies, oldest reply first', () => {
27
+ const parent = comment({id: 'a', createdAt: '2026-01-01T00:00:00Z'})
28
+ const second = comment({id: 'c', createdAt: '2026-01-03T00:00:00Z', parentCommentId: 'a'})
29
+ const first = comment({id: 'b', createdAt: '2026-01-02T00:00:00Z', parentCommentId: 'a'})
30
+
31
+ expect(buildCommentThreads([second, first, parent])).toEqual([
32
+ {
33
+ threadId: 'thread-1',
34
+ fieldPath: '',
35
+ parentComment: parent,
36
+ replies: [first, second],
37
+ commentsCount: 3,
38
+ status: 'open',
39
+ lastActivityAt: '2026-01-03T00:00:00Z',
40
+ },
41
+ ])
42
+ })
43
+
44
+ it('takes fieldPath from the parent', () => {
45
+ const parent = comment({id: 'a', fieldPath: 'body[_key=="intro"].content'})
46
+
47
+ expect(buildCommentThreads([parent])[0].fieldPath).toBe('body[_key=="intro"].content')
48
+ })
49
+
50
+ it('falls back to the parent createdAt when there are no replies', () => {
51
+ const parent = comment({id: 'a', createdAt: '2026-02-02T00:00:00Z'})
52
+
53
+ expect(buildCommentThreads([parent])[0]).toMatchObject({
54
+ commentsCount: 1,
55
+ lastActivityAt: '2026-02-02T00:00:00Z',
56
+ replies: [],
57
+ })
58
+ })
59
+
60
+ it('drops replies whose parent is missing', () => {
61
+ // Happens between a parent being deleted and its replies disappearing.
62
+ const orphan = comment({id: 'b', parentCommentId: 'gone'})
63
+
64
+ expect(buildCommentThreads([orphan])).toEqual([])
65
+ })
66
+
67
+ it('keeps threads in the order their parents arrive', () => {
68
+ const newer = comment({id: 'a', threadId: 'thread-a', createdAt: '2026-01-05T00:00:00Z'})
69
+ const older = comment({id: 'b', threadId: 'thread-b', createdAt: '2026-01-01T00:00:00Z'})
70
+
71
+ expect(buildCommentThreads([newer, older]).map((thread) => thread.threadId)).toEqual([
72
+ 'thread-a',
73
+ 'thread-b',
74
+ ])
75
+ })
76
+
77
+ it('reports the parent status for the thread', () => {
78
+ const parent = comment({id: 'a', status: 'resolved'})
79
+ const reply = comment({id: 'b', parentCommentId: 'a', status: 'resolved'})
80
+
81
+ expect(buildCommentThreads([parent, reply])[0].status).toBe('resolved')
82
+ })
83
+ })
@@ -0,0 +1,54 @@
1
+ import {type Comment, type CommentThread} from './types'
2
+
3
+ /**
4
+ * Groups a flat comment list into threads.
5
+ *
6
+ * A thread's parent is the comment with no `parentCommentId`; replies point at
7
+ * the parent's `id`. Replies whose parent is missing from the input are
8
+ * dropped, which is what happens when a parent is deleted but its replies have
9
+ * not disappeared yet.
10
+ *
11
+ * Threads come back in the order their parents appear in `comments`, so passing
12
+ * the store's newest-first list gives newest-thread-first. Replies within a
13
+ * thread are always oldest first, since that is reading order.
14
+ *
15
+ * @internal
16
+ */
17
+ export function buildCommentThreads(comments: Comment[]): CommentThread[] {
18
+ const repliesByParent = new Map<string, Comment[]>()
19
+
20
+ for (const comment of comments) {
21
+ if (!comment.parentCommentId) continue
22
+ const existing = repliesByParent.get(comment.parentCommentId)
23
+ if (existing) {
24
+ existing.push(comment)
25
+ } else {
26
+ repliesByParent.set(comment.parentCommentId, [comment])
27
+ }
28
+ }
29
+
30
+ const threads: CommentThread[] = []
31
+
32
+ for (const parentComment of comments) {
33
+ if (parentComment.parentCommentId) continue
34
+
35
+ // The array is built above and owned here, so sorting in place is safe.
36
+ const replies = (repliesByParent.get(parentComment.id) ?? []).sort((a, b) =>
37
+ a.createdAt.localeCompare(b.createdAt),
38
+ )
39
+
40
+ const lastReply = replies[replies.length - 1]
41
+
42
+ threads.push({
43
+ threadId: parentComment.threadId,
44
+ fieldPath: parentComment.fieldPath,
45
+ parentComment,
46
+ replies,
47
+ commentsCount: replies.length + 1,
48
+ status: parentComment.status,
49
+ lastActivityAt: lastReply ? lastReply.createdAt : parentComment.createdAt,
50
+ })
51
+ }
52
+
53
+ return threads
54
+ }
@@ -0,0 +1,540 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {type CurrentUser} from '@sanity/types'
3
+ import {of} from 'rxjs'
4
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {getCurrentUserState} from '../auth/authStore'
7
+ import {getClient} from '../client/clientStore'
8
+ import {type DocumentResource} from '../config/sanityConfig'
9
+ import {bindActionByResource} from '../store/createActionBinder'
10
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
11
+ import {type StateSource} from '../store/createStateSourceAction'
12
+ import {getAddonDatasetState, provisionAddonDataset} from './addonDatasetStore'
13
+ import {
14
+ createComment,
15
+ removeComment,
16
+ replyToComment,
17
+ setCommentStatus,
18
+ updateComment,
19
+ } from './commentActions'
20
+ import {commentsStore, getCommentsState} from './commentsStore'
21
+ import {addSubscriber, getCommentsKey, setComments} from './reducers'
22
+ import {type StoredComment} from './types'
23
+
24
+ vi.mock('../auth/authStore', () => ({getCurrentUserState: vi.fn()}))
25
+ vi.mock('../client/clientStore', () => ({getClient: vi.fn(), getClientState: vi.fn()}))
26
+ vi.mock('./addonDatasetStore', async (importOriginal) => ({
27
+ ...(await importOriginal<typeof import('./addonDatasetStore')>()),
28
+ getAddonDatasetState: vi.fn(),
29
+ provisionAddonDataset: vi.fn(),
30
+ observeAddonDatasetClient: vi.fn(() => of(null)),
31
+ }))
32
+
33
+ const HANDLE = {documentId: 'doc-1', documentType: 'author'}
34
+
35
+ /** Creates always name a field, since a pathless comment is refused. */
36
+ const CREATE = {...HANDLE, fieldPath: 'name'}
37
+
38
+ function comment(overrides: Partial<StoredComment> & Pick<StoredComment, '_id'>) {
39
+ return {
40
+ _type: 'comment',
41
+ _createdAt: '2026-01-01T00:00:00Z',
42
+ _rev: 'rev',
43
+ authorId: 'user-1',
44
+ message: null,
45
+ threadId: 'thread-1',
46
+ status: 'open',
47
+ reactions: null,
48
+ target: {
49
+ documentType: 'author',
50
+ // Every comment points at a field, so replies inherit a real path.
51
+ path: {field: 'name'},
52
+ document: {_ref: 'doc-1', _type: 'reference', _weak: true},
53
+ },
54
+ ...overrides,
55
+ } satisfies StoredComment as StoredComment
56
+ }
57
+
58
+ /** Puts comments into the store without going through the listener. */
59
+ const seedComments = bindActionByResource(
60
+ commentsStore,
61
+ (
62
+ {state},
63
+ options: {resource?: DocumentResource; documentId?: string; comments: StoredComment[]},
64
+ ) => {
65
+ const key = getCommentsKey({documentId: options.documentId ?? 'doc-1'})
66
+ state.set('addSubscriber', addSubscriber(key, 'seed'))
67
+ state.set('setComments', setComments(key, options.comments))
68
+ },
69
+ )
70
+
71
+ const getCommentsStoreState = bindActionByResource(
72
+ commentsStore,
73
+ ({state}, _options: {resource?: DocumentResource}) => state.get(),
74
+ )
75
+
76
+ let instance: SanityInstance
77
+ let client: {
78
+ create: ReturnType<typeof vi.fn>
79
+ createIfNotExists: ReturnType<typeof vi.fn>
80
+ patch: ReturnType<typeof vi.fn>
81
+ delete: ReturnType<typeof vi.fn>
82
+ mutate: ReturnType<typeof vi.fn>
83
+ transaction: ReturnType<typeof vi.fn>
84
+ }
85
+ let patchCommit: ReturnType<typeof vi.fn>
86
+ let transactionCommit: ReturnType<typeof vi.fn>
87
+ let patchSet: ReturnType<typeof vi.fn>
88
+
89
+ beforeEach(() => {
90
+ vi.resetAllMocks()
91
+
92
+ patchCommit = vi.fn().mockResolvedValue({})
93
+ transactionCommit = vi.fn().mockResolvedValue({})
94
+ patchSet = vi.fn(() => ({commit: patchCommit, set: patchSet}))
95
+
96
+ const patch = vi.fn(() => ({set: patchSet, commit: patchCommit}))
97
+ const transaction = vi.fn(() => {
98
+ const tx = {
99
+ transactionId: vi.fn(() => tx),
100
+ patch: vi.fn(() => tx),
101
+ commit: transactionCommit,
102
+ }
103
+ return tx
104
+ })
105
+
106
+ client = {
107
+ create: vi.fn(async (doc) => ({...doc, _createdAt: '2026-06-01T00:00:00Z', _rev: 'rev-1'})),
108
+ createIfNotExists: vi.fn(async (doc) => ({
109
+ ...doc,
110
+ _createdAt: '2026-06-01T00:00:00Z',
111
+ _rev: 'rev-1',
112
+ })),
113
+ patch,
114
+ delete: vi.fn().mockResolvedValue({}),
115
+ mutate: vi.fn().mockResolvedValue({}),
116
+ transaction,
117
+ }
118
+
119
+ vi.mocked(getClient).mockReturnValue(client as unknown as SanityClient)
120
+ vi.mocked(provisionAddonDataset).mockResolvedValue('d-comments')
121
+ vi.mocked(getAddonDatasetState).mockReturnValue({
122
+ observable: of('d-comments'),
123
+ getCurrent: () => 'd-comments',
124
+ subscribe: () => () => {},
125
+ } as unknown as StateSource<string | null | undefined>)
126
+ vi.mocked(getCurrentUserState).mockReturnValue({
127
+ observable: of({id: 'user-1'}),
128
+ getCurrent: () => ({id: 'user-1'}) as CurrentUser,
129
+ subscribe: () => () => {},
130
+ } as unknown as StateSource<CurrentUser | null>)
131
+
132
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
133
+ })
134
+
135
+ afterEach(() => {
136
+ instance.dispose()
137
+ })
138
+
139
+ describe('createComment', () => {
140
+ it('writes the document shape the Studio reads', async () => {
141
+ // Golden test. This object is the interop contract: the Studio filters on
142
+ // `target.document._ref` and groups on `target.path.field`, so a change
143
+ // here makes SDK comments invisible in the Studio without failing anything
144
+ // else.
145
+ await createComment(instance, {
146
+ ...HANDLE,
147
+ commentId: 'comment-1',
148
+ threadId: 'thread-1',
149
+ message: [{_type: 'block', _key: 'b1', children: [{_type: 'span', text: 'hi'}]}],
150
+ fieldPath: ['body', {_key: 'intro'}, 'content'],
151
+ })
152
+
153
+ expect(client.createIfNotExists).toHaveBeenCalledWith(
154
+ {
155
+ _id: 'comment-1',
156
+ _type: 'comment',
157
+ authorId: 'user-1',
158
+ message: [{_type: 'block', _key: 'b1', children: [{_type: 'span', text: 'hi'}]}],
159
+ threadId: 'thread-1',
160
+ status: 'open',
161
+ reactions: null,
162
+ context: {tool: ''},
163
+ target: {
164
+ documentRevisionId: '',
165
+ path: {field: 'body[_key=="intro"].content'},
166
+ document: {
167
+ _dataset: 'd',
168
+ _projectId: 'p',
169
+ _ref: 'doc-1',
170
+ _type: 'crossDatasetReference',
171
+ _weak: true,
172
+ },
173
+ documentType: 'author',
174
+ },
175
+ },
176
+ {tag: 'comments.create'},
177
+ )
178
+ })
179
+
180
+ it('targets the published document from a draft id', async () => {
181
+ await createComment(instance, {...CREATE, documentId: 'drafts.doc-1', message: null})
182
+
183
+ expect(client.createIfNotExists.mock.calls[0][0].target.document._ref).toBe('doc-1')
184
+ })
185
+
186
+ it('records the release when commenting on one', async () => {
187
+ await createComment(instance, {
188
+ ...CREATE,
189
+ perspective: {releaseName: 'summer'},
190
+ message: null,
191
+ })
192
+
193
+ expect(client.createIfNotExists.mock.calls[0][0].target.documentVersionId).toBe('summer')
194
+ })
195
+
196
+ it('carries a text selection through untouched', async () => {
197
+ const selection = {type: 'text' as const, value: [{_key: 'b1', text: 'marked'}]}
198
+
199
+ await createComment(instance, {...CREATE, message: null, fieldPath: 'body', selection})
200
+
201
+ expect(client.createIfNotExists.mock.calls[0][0].target.path).toEqual({
202
+ field: 'body',
203
+ selection,
204
+ })
205
+ })
206
+
207
+ it('weakens references in a content snapshot', async () => {
208
+ await createComment(instance, {
209
+ ...CREATE,
210
+ message: null,
211
+ contentSnapshot: {author: {_ref: 'other-doc', _type: 'reference'}},
212
+ })
213
+
214
+ expect(client.createIfNotExists.mock.calls[0][0].contentSnapshot).toEqual({
215
+ author: {_ref: 'other-doc', _type: 'reference', _weak: true},
216
+ })
217
+ })
218
+
219
+ it('provisions the addon dataset on the way', async () => {
220
+ await createComment(instance, {...CREATE, message: null})
221
+
222
+ expect(provisionAddonDataset).toHaveBeenCalled()
223
+ expect(getClient).toHaveBeenCalledWith(instance, {
224
+ apiVersion: 'v2025-05-06',
225
+ projectId: 'p',
226
+ dataset: 'd-comments',
227
+ })
228
+ })
229
+
230
+ it('shows the comment before the server confirms it', async () => {
231
+ const source = getCommentsState(instance, HANDLE)
232
+ source.subscribe()
233
+
234
+ let resolveCreate: (value: unknown) => void = () => {}
235
+ client.createIfNotExists.mockReturnValue(new Promise((resolve) => (resolveCreate = resolve)))
236
+
237
+ const pending = createComment(instance, {...CREATE, commentId: 'c1', message: null})
238
+
239
+ expect(source.getCurrent()!.map((c) => c.id)).toEqual(['c1'])
240
+
241
+ resolveCreate({...comment({_id: 'c1'})})
242
+ await pending
243
+ })
244
+
245
+ it('leaves a failed comment in place carrying the error', async () => {
246
+ const source = getCommentsState(instance, HANDLE)
247
+ source.subscribe()
248
+
249
+ client.create.mockRejectedValue(new Error('nope'))
250
+ client.createIfNotExists.mockRejectedValue(new Error('nope'))
251
+
252
+ await expect(
253
+ createComment(instance, {...CREATE, commentId: 'c1', message: null}),
254
+ ).rejects.toThrow('nope')
255
+
256
+ expect(source.getCurrent()![0].state).toEqual({type: 'createError', error: expect.any(Error)})
257
+ })
258
+
259
+ it('marks a failed comment as retrying while the retry is in flight', async () => {
260
+ const source = getCommentsState(instance, HANDLE)
261
+ source.subscribe()
262
+ client.create.mockRejectedValueOnce(new Error('nope'))
263
+ client.createIfNotExists.mockRejectedValueOnce(new Error('nope'))
264
+
265
+ await expect(
266
+ createComment(instance, {...CREATE, commentId: 'c1', message: null}),
267
+ ).rejects.toThrow('nope')
268
+
269
+ let resolveRetry: (value: unknown) => void = () => {}
270
+ client.create.mockReturnValue(new Promise((resolve) => (resolveRetry = resolve)))
271
+ client.createIfNotExists.mockReturnValue(new Promise((resolve) => (resolveRetry = resolve)))
272
+
273
+ const retry = createComment(instance, {...CREATE, commentId: 'c1', message: null})
274
+
275
+ expect(source.getCurrent()![0].state).toEqual({type: 'createRetrying'})
276
+
277
+ resolveRetry(comment({_id: 'c1'}))
278
+ await retry
279
+ })
280
+
281
+ it('refuses to write without a logged in user', async () => {
282
+ vi.mocked(getCurrentUserState).mockReturnValue({
283
+ getCurrent: () => null,
284
+ observable: of(null),
285
+ subscribe: () => () => {},
286
+ } as unknown as StateSource<CurrentUser | null>)
287
+
288
+ await expect(createComment(instance, {...CREATE, message: null})).rejects.toThrow(
289
+ /requires a logged in user/,
290
+ )
291
+ })
292
+
293
+ it('refuses a comment that points at no field', async () => {
294
+ // Not pedantry. The Studio's inspector calls `fromString` on the stored
295
+ // path and throws on `''`, so a pathless comment crashes the inspector for
296
+ // everyone viewing that document until someone deletes it.
297
+ await expect(
298
+ createComment(instance, {...HANDLE, fieldPath: '', message: null}),
299
+ ).rejects.toThrow(/needs a field path/)
300
+
301
+ await expect(
302
+ createComment(instance, {...HANDLE, fieldPath: [], message: null}),
303
+ ).rejects.toThrow(/needs a field path/)
304
+
305
+ expect(client.createIfNotExists).not.toHaveBeenCalled()
306
+ })
307
+ })
308
+
309
+ describe('replyToComment', () => {
310
+ it('inherits thread, field, and status from the parent', async () => {
311
+ seedComments(instance, {
312
+ comments: [
313
+ comment({
314
+ _id: 'parent',
315
+ threadId: 'thread-9',
316
+ status: 'resolved',
317
+ target: {
318
+ documentType: 'author',
319
+ path: {field: 'title'},
320
+ document: {_ref: 'doc-1', _type: 'reference', _weak: true},
321
+ },
322
+ }),
323
+ ],
324
+ })
325
+
326
+ await replyToComment(instance, {
327
+ ...HANDLE,
328
+ parentCommentId: 'parent',
329
+ commentId: 'reply-1',
330
+ message: null,
331
+ })
332
+
333
+ expect(client.createIfNotExists.mock.calls[0][0]).toMatchObject({
334
+ parentCommentId: 'parent',
335
+ threadId: 'thread-9',
336
+ status: 'resolved',
337
+ target: {path: {field: 'title'}},
338
+ })
339
+ })
340
+
341
+ it('attaches a reply to a reply to the thread parent', async () => {
342
+ // The Studio's threads are one level deep; a nested reply would be orphaned.
343
+ seedComments(instance, {
344
+ comments: [comment({_id: 'parent'}), comment({_id: 'reply-1', parentCommentId: 'parent'})],
345
+ })
346
+
347
+ await replyToComment(instance, {...HANDLE, parentCommentId: 'reply-1', message: null})
348
+
349
+ expect(client.createIfNotExists.mock.calls[0][0].parentCommentId).toBe('parent')
350
+ })
351
+
352
+ it('explains itself when the parent is not loaded', async () => {
353
+ await expect(
354
+ replyToComment(instance, {...HANDLE, parentCommentId: 'unknown', message: null}),
355
+ ).rejects.toThrow(/pass its threadId/)
356
+ })
357
+
358
+ it('requires the status when the parent is not loaded', async () => {
359
+ await expect(
360
+ replyToComment(instance, {
361
+ ...HANDLE,
362
+ parentCommentId: 'unknown',
363
+ threadId: 'thread-3',
364
+ message: null,
365
+ }),
366
+ ).rejects.toThrow(/pass its status/)
367
+ })
368
+
369
+ it('accepts explicit thread details when the parent is not loaded', async () => {
370
+ await replyToComment(instance, {
371
+ ...HANDLE,
372
+ parentCommentId: 'unknown',
373
+ threadId: 'thread-3',
374
+ status: 'resolved',
375
+ // With no parent to inherit from, the field has to be named too.
376
+ fieldPath: 'name',
377
+ message: null,
378
+ })
379
+
380
+ expect(client.createIfNotExists.mock.calls[0][0]).toMatchObject({
381
+ threadId: 'thread-3',
382
+ status: 'resolved',
383
+ })
384
+ })
385
+
386
+ it('does not reuse a parent loaded for another document', async () => {
387
+ seedComments(instance, {comments: [comment({_id: 'parent'})]})
388
+
389
+ await expect(
390
+ replyToComment(instance, {
391
+ ...HANDLE,
392
+ documentId: 'doc-2',
393
+ parentCommentId: 'parent',
394
+ message: null,
395
+ }),
396
+ ).rejects.toThrow(/not loaded/)
397
+ })
398
+ })
399
+
400
+ describe('updateComment', () => {
401
+ it('patches the message and stamps lastEditedAt in one transaction', async () => {
402
+ await updateComment(instance, {commentId: 'c1', message: null})
403
+
404
+ expect(client.transaction).toHaveBeenCalled()
405
+ expect(patchSet).toHaveBeenCalledWith({message: null, lastEditedAt: expect.any(String)})
406
+ expect(transactionCommit).toHaveBeenCalledWith({tag: 'comments.update'})
407
+ })
408
+
409
+ it('does not create a dataset just to edit a comment', async () => {
410
+ await updateComment(instance, {commentId: 'c1', message: null})
411
+
412
+ expect(provisionAddonDataset).not.toHaveBeenCalled()
413
+ })
414
+
415
+ it('does not retain a pending transaction when no comment is loaded', async () => {
416
+ await updateComment(instance, {commentId: 'c1', message: null})
417
+
418
+ expect(getCommentsStoreState(instance, {}).pendingTransactions).toEqual({})
419
+ })
420
+
421
+ it('restores the previous message when the write fails', async () => {
422
+ const source = getCommentsState(instance, HANDLE)
423
+ source.subscribe()
424
+ seedComments(instance, {comments: [comment({_id: 'c1', message: null})]})
425
+ transactionCommit.mockRejectedValue(new Error('nope'))
426
+ const message: StoredComment['message'] = [
427
+ {_type: 'block', _key: 'block-1', children: [{_type: 'span', text: 'changed'}]},
428
+ ]
429
+
430
+ await expect(updateComment(instance, {commentId: 'c1', message})).rejects.toThrow('nope')
431
+
432
+ expect(source.getCurrent()![0].message).toBe(null)
433
+ })
434
+ })
435
+
436
+ describe('setCommentStatus', () => {
437
+ it('patches the parent and replies in one mutation', async () => {
438
+ await setCommentStatus(instance, {commentId: 'c1', status: 'resolved'})
439
+
440
+ expect(client.mutate).toHaveBeenCalledWith(
441
+ [
442
+ {patch: {id: 'c1', set: {status: 'resolved'}}},
443
+ {
444
+ patch: {
445
+ query: '*[_type == "comment" && parentCommentId == $commentId]',
446
+ params: {commentId: 'c1'},
447
+ set: {status: 'resolved'},
448
+ },
449
+ },
450
+ ],
451
+ {transactionId: expect.any(String), tag: 'comments.set-status'},
452
+ )
453
+ })
454
+
455
+ it('moves known replies immediately rather than waiting for the echo', async () => {
456
+ const source = getCommentsState(instance, HANDLE)
457
+ source.subscribe()
458
+ seedComments(instance, {
459
+ comments: [comment({_id: 'c1'}), comment({_id: 'r1', parentCommentId: 'c1'})],
460
+ })
461
+
462
+ await setCommentStatus(instance, {commentId: 'c1', status: 'resolved'})
463
+
464
+ expect(source.getCurrent()!.every((c) => c.status === 'resolved')).toBe(true)
465
+ })
466
+
467
+ it('restores the parent and replies when the write fails', async () => {
468
+ const source = getCommentsState(instance, HANDLE)
469
+ source.subscribe()
470
+ seedComments(instance, {
471
+ comments: [comment({_id: 'c1'}), comment({_id: 'r1', parentCommentId: 'c1'})],
472
+ })
473
+ client.mutate.mockRejectedValue(new Error('nope'))
474
+ transactionCommit.mockRejectedValue(new Error('nope'))
475
+
476
+ await expect(setCommentStatus(instance, {commentId: 'c1', status: 'resolved'})).rejects.toThrow(
477
+ 'nope',
478
+ )
479
+
480
+ expect(source.getCurrent()!.every((c) => c.status === 'open')).toBe(true)
481
+ })
482
+ })
483
+
484
+ describe('removeComment', () => {
485
+ it('deletes the comment and its replies in one mutation', async () => {
486
+ await removeComment(instance, {commentId: 'c1'})
487
+
488
+ expect(client.mutate).toHaveBeenCalledWith(
489
+ [
490
+ {
491
+ delete: {
492
+ query: '*[_type == "comment" && parentCommentId == $commentId]',
493
+ params: {commentId: 'c1'},
494
+ },
495
+ },
496
+ {delete: {id: 'c1'}},
497
+ ],
498
+ {tag: 'comments.remove'},
499
+ )
500
+ })
501
+
502
+ it('drops the comment and its replies locally right away', async () => {
503
+ const source = getCommentsState(instance, HANDLE)
504
+ source.subscribe()
505
+ seedComments(instance, {
506
+ comments: [
507
+ comment({_id: 'c1'}),
508
+ comment({_id: 'r1', parentCommentId: 'c1'}),
509
+ comment({_id: 'other'}),
510
+ ],
511
+ })
512
+
513
+ await removeComment(instance, {commentId: 'c1'})
514
+
515
+ expect(source.getCurrent()!.map((c) => c.id)).toEqual(['other'])
516
+ })
517
+
518
+ it('restores the comment and replies when the write fails', async () => {
519
+ const source = getCommentsState(instance, HANDLE)
520
+ source.subscribe()
521
+ seedComments(instance, {
522
+ comments: [
523
+ comment({_id: 'c1'}),
524
+ comment({_id: 'r1', parentCommentId: 'c1'}),
525
+ comment({_id: 'other'}),
526
+ ],
527
+ })
528
+ client.mutate.mockRejectedValue(new Error('nope'))
529
+ client.delete.mockRejectedValue(new Error('nope'))
530
+
531
+ await expect(removeComment(instance, {commentId: 'c1'})).rejects.toThrow('nope')
532
+
533
+ expect(
534
+ source
535
+ .getCurrent()!
536
+ .map((c) => c.id)
537
+ .sort(),
538
+ ).toEqual(['c1', 'other', 'r1'])
539
+ })
540
+ })