@sanity/sdk 3.0.0-rc.1 → 3.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.
Files changed (128) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +71 -3
  3. package/dist/_exports/_internal.d.ts.map +1 -1
  4. package/dist/_exports/_internal.js +332 -24
  5. package/dist/_exports/_internal.js.map +1 -1
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-C0o1NOxW.d.ts +3165 -0
  15. package/dist/authStore-C0o1NOxW.d.ts.map +1 -0
  16. package/dist/authStore-CtE9bLHW.js +754 -0
  17. package/dist/authStore-CtE9bLHW.js.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2335
  29. package/dist/index.js +4048 -3878
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-C7yMHPUI.js +1346 -0
  36. package/dist/queryStore-C7yMHPUI.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-UIEiMtC0.js +127 -0
  41. package/dist/telemetryManager-UIEiMtC0.js.map +1 -0
  42. package/dist/version-D8RP_Q59.js +9 -0
  43. package/dist/version-D8RP_Q59.js.map +1 -0
  44. package/package.json +11 -17
  45. package/src/_exports/_internal.ts +1 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +45 -90
  49. package/src/applications/applications.test-d.ts +5 -0
  50. package/src/applications/applications.ts +5 -2
  51. package/src/auth/authMode.test.ts +0 -25
  52. package/src/auth/authMode.ts +3 -6
  53. package/src/auth/authStore.test.ts +1 -1
  54. package/src/auth/authStore.ts +0 -1
  55. package/src/comments/addonDatasetStore.test.ts +215 -0
  56. package/src/comments/addonDatasetStore.ts +249 -0
  57. package/src/comments/buildCommentThreads.test.ts +83 -0
  58. package/src/comments/buildCommentThreads.ts +54 -0
  59. package/src/comments/commentActions.test.ts +540 -0
  60. package/src/comments/commentActions.ts +555 -0
  61. package/src/comments/commentFieldPath.test.ts +40 -0
  62. package/src/comments/commentFieldPath.ts +44 -0
  63. package/src/comments/commentsConstants.ts +80 -0
  64. package/src/comments/commentsStore.test.ts +419 -0
  65. package/src/comments/commentsStore.ts +483 -0
  66. package/src/comments/normalizeComment.test.ts +116 -0
  67. package/src/comments/normalizeComment.ts +45 -0
  68. package/src/comments/observeComments.test.ts +193 -0
  69. package/src/comments/observeComments.ts +113 -0
  70. package/src/comments/reducers.test.ts +277 -0
  71. package/src/comments/reducers.ts +316 -0
  72. package/src/comments/types.ts +249 -0
  73. package/src/comments/weakenReferences.test.ts +47 -0
  74. package/src/comments/weakenReferences.ts +29 -0
  75. package/src/config/sanityConfig.ts +3 -75
  76. package/src/datasets/datasets.test.ts +44 -9
  77. package/src/datasets/datasets.ts +24 -23
  78. package/src/document/applyDocumentActions.test.ts +8 -12
  79. package/src/document/documentConstants.ts +17 -0
  80. package/src/document/documentStore.test.ts +173 -19
  81. package/src/document/documentStore.ts +36 -5
  82. package/src/document/patchOperations.test.ts +27 -0
  83. package/src/document/patchOperations.ts +10 -1
  84. package/src/document/permissions.test.ts +3 -1
  85. package/src/document/reducers.test.ts +357 -0
  86. package/src/document/reducers.ts +125 -27
  87. package/src/document/util.test.ts +46 -0
  88. package/src/document/util.ts +37 -1
  89. package/src/favorites/favorites.test.ts +64 -24
  90. package/src/favorites/favorites.ts +73 -22
  91. package/src/installations/installations.test-d.ts +5 -0
  92. package/src/installations/installations.ts +4 -0
  93. package/src/presence/bifurTransport.test.ts +155 -3
  94. package/src/presence/bifurTransport.ts +79 -12
  95. package/src/presence/paths.test.ts +70 -0
  96. package/src/presence/paths.ts +41 -0
  97. package/src/presence/presenceStore.test.ts +822 -2
  98. package/src/presence/presenceStore.ts +518 -102
  99. package/src/presence/types.ts +175 -3
  100. package/src/preview/types.ts +0 -25
  101. package/src/projection/projectionStore.test.ts +0 -8
  102. package/src/projection/types.ts +0 -8
  103. package/src/query/queryStore.ts +7 -6
  104. package/src/store/createActionBinder.test.ts +51 -29
  105. package/src/store/createActionBinder.ts +16 -62
  106. package/src/store/createSanityInstance.test.ts +0 -68
  107. package/src/store/createSanityInstance.ts +0 -63
  108. package/src/store/createStateSourceAction.ts +1 -1
  109. package/src/store/fetcherStore.test.ts +23 -1
  110. package/src/store/fetcherStore.ts +3 -3
  111. package/src/users/reducers.test.ts +0 -3
  112. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
  113. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  114. package/dist/_chunks-es/_internal.js +0 -134
  115. package/dist/_chunks-es/_internal.js.map +0 -1
  116. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
  117. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  118. package/dist/_chunks-es/telemetryManager.js +0 -93
  119. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  120. package/dist/index.d.ts.map +0 -1
  121. package/src/applications/userApplications.test.ts +0 -126
  122. package/src/applications/userApplications.ts +0 -261
  123. package/src/preview/getPreviewState.test.ts +0 -137
  124. package/src/preview/getPreviewState.ts +0 -69
  125. package/src/preview/resolvePreview.test.ts +0 -64
  126. package/src/preview/resolvePreview.ts +0 -43
  127. package/src/utils/createFetcherStore.test.ts +0 -171
  128. package/src/utils/createFetcherStore.ts +0 -284
@@ -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
+ })