@sanity/sdk 2.19.0 → 2.20.1

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-w-AMXtmz.js +127 -0
  17. package/dist/telemetryManager-w-AMXtmz.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-ByqNiWrK.js +9 -0
  21. package/dist/version-ByqNiWrK.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,555 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
3
+ import {type Path} from '@sanity/types'
4
+ import {filter, firstValueFrom, take} from 'rxjs'
5
+
6
+ import {getCurrentUserState} from '../auth/authStore'
7
+ import {getClient} from '../client/clientStore'
8
+ import {
9
+ type DatasetHandle,
10
+ type DatasetResource,
11
+ type DocumentHandle,
12
+ type DocumentResource,
13
+ } from '../config/sanityConfig'
14
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
15
+ import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
16
+ import {type SanityInstance} from '../store/createSanityInstance'
17
+ import {type StoreState} from '../store/createStoreState'
18
+ import {type StoreContext} from '../store/defineStore'
19
+ import {
20
+ assertDatasetResource,
21
+ getAddonDatasetState,
22
+ provisionAddonDataset,
23
+ } from './addonDatasetStore'
24
+ import {toCommentFieldPath} from './commentFieldPath'
25
+ import {COMMENTS_API_VERSION} from './commentsConstants'
26
+ import {type CommentsOptions, commentsStore, toCommentsKeyParts} from './commentsStore'
27
+ import {normalizeComment} from './normalizeComment'
28
+ import {
29
+ addComment,
30
+ applyCommentUpdate,
31
+ clearPendingTransaction,
32
+ type CommentsStoreState,
33
+ getCommentsKey,
34
+ receiveComment,
35
+ removeCommentById,
36
+ restoreComments,
37
+ rollbackCommentUpdate,
38
+ setCommentCreateError,
39
+ setPendingTransaction,
40
+ } from './reducers'
41
+ import {
42
+ type Comment,
43
+ type CommentMessage,
44
+ type CommentPostPayload,
45
+ type CommentStatus,
46
+ type CommentTextSelection,
47
+ type StoredComment,
48
+ } from './types'
49
+ import {weakenReferencesInContentSnapshot} from './weakenReferences'
50
+
51
+ /** @beta */
52
+ export interface CreateCommentOptions extends DocumentHandle {
53
+ message: CommentMessage
54
+ /**
55
+ * Which field the thread hangs off, for example `title` or
56
+ * `body[_key=="intro"].content`.
57
+ *
58
+ * Required, and it has to resolve to a real path. There is no such thing as a
59
+ * comment on a document as a whole: the Studio only ever creates field
60
+ * comments, and its comment inspector throws on an empty path rather than
61
+ * ignoring it, so a pathless comment takes the inspector down for everyone
62
+ * looking at that document.
63
+ */
64
+ fieldPath: string | Path
65
+ /**
66
+ * Anchors the comment to a run of text inside a Portable Text field. Building
67
+ * one needs a live editor, so this comes from
68
+ * `@portabletext/plugin-sdk-value` rather than being constructed by hand.
69
+ */
70
+ selection?: CommentTextSelection
71
+ /** Reuse the id of a failed comment to retry it. Defaults to a new id. */
72
+ commentId?: string
73
+ /** Defaults to a new id, which starts a new thread. */
74
+ threadId?: string
75
+ /** @defaultValue `'open'` */
76
+ status?: CommentStatus
77
+ /**
78
+ * The `_rev` of the document when the comment was written.
79
+ *
80
+ * Not filled in automatically: the SDK's local document revision is replaced
81
+ * by a transaction id while an edit is in flight, so it can name a revision
82
+ * that never reached the server. Nothing in the Studio reads this today.
83
+ */
84
+ documentRevisionId?: string
85
+ /**
86
+ * Ambient information stored alongside the comment, merged over the defaults.
87
+ *
88
+ * The Studio writes `tool`, `payload`, `intent`, and `notification` here for
89
+ * its notification backend. The SDK has no source for any of them, so
90
+ * mentions in comments created this way do not send email unless you supply
91
+ * `notification` yourself. Deliberately loose: the organization-level store
92
+ * treats this as free-form, so no shape is worth promising.
93
+ */
94
+ context?: Record<string, unknown>
95
+ /** A copy of the content being discussed. References in it are weakened. */
96
+ contentSnapshot?: unknown
97
+ }
98
+
99
+ /** @beta */
100
+ export interface ReplyToCommentOptions extends DocumentHandle {
101
+ /** The comment being replied to. Replies to a reply join the same thread. */
102
+ parentCommentId: string
103
+ message: CommentMessage
104
+ commentId?: string
105
+ /** Only needed when the parent is not loaded, such as following a deep link. */
106
+ threadId?: string
107
+ /** Only needed when the parent is not loaded. */
108
+ fieldPath?: string | Path
109
+ /** Required when the parent is not loaded. */
110
+ status?: CommentStatus
111
+ }
112
+
113
+ /** @beta */
114
+ export interface UpdateCommentOptions extends DatasetHandle {
115
+ commentId: string
116
+ message: CommentMessage
117
+ }
118
+
119
+ /** @beta */
120
+ export interface SetCommentStatusOptions extends DatasetHandle {
121
+ /** The thread's first comment. Replies follow their parent. */
122
+ commentId: string
123
+ status: CommentStatus
124
+ }
125
+
126
+ /** @beta */
127
+ export interface RemoveCommentOptions extends DatasetHandle {
128
+ commentId: string
129
+ }
130
+
131
+ /**
132
+ * Refuses to write a comment that points at nothing.
133
+ *
134
+ * The type already requires `fieldPath`, but an empty string or an empty path
135
+ * array both survive that and normalise to `''`. Such a comment is not merely
136
+ * useless: the Studio's comment inspector calls `fromString` on the stored
137
+ * path, which throws on `''`, so the inspector crashes for everyone viewing
138
+ * that document until the comment is deleted. Cheaper to refuse the write.
139
+ */
140
+ function requireFieldPath(fieldPath: string | Path): string {
141
+ const normalized = toCommentFieldPath(fieldPath)
142
+ if (!normalized) {
143
+ throw new Error(
144
+ 'A comment needs a field path. Comments attach to a field, not to a document as a whole.',
145
+ )
146
+ }
147
+ return normalized
148
+ }
149
+
150
+ function requireCurrentUserId(instance: SanityInstance): string {
151
+ const userId = getCurrentUserState(instance).getCurrent()?.id
152
+ if (!userId) {
153
+ throw new Error('Writing a comment requires a logged in user.')
154
+ }
155
+ return userId
156
+ }
157
+
158
+ /** Waits out discovery, then insists the addon dataset exists. */
159
+ async function requireAddonDataset(
160
+ instance: SanityInstance,
161
+ resource: DocumentResource,
162
+ ): Promise<string> {
163
+ const datasetName = await firstValueFrom(
164
+ getAddonDatasetState(instance, {resource}).observable.pipe(
165
+ filter((value) => value !== undefined),
166
+ take(1),
167
+ ),
168
+ )
169
+
170
+ if (!datasetName) {
171
+ throw new Error('This project has no comments dataset, so there is no comment to change.')
172
+ }
173
+
174
+ return datasetName
175
+ }
176
+
177
+ async function getWritableClient(
178
+ instance: SanityInstance,
179
+ resource: DocumentResource,
180
+ {createIfMissing}: {createIfMissing: boolean},
181
+ ): Promise<SanityClient> {
182
+ const {projectId} = assertDatasetResource(resource)
183
+
184
+ // Provisioning is a project-wide side effect, so only the create path may
185
+ // trigger it. Editing or deleting a comment implies the dataset is there.
186
+ const dataset = createIfMissing
187
+ ? await provisionAddonDataset(instance, {resource})
188
+ : await requireAddonDataset(instance, resource)
189
+
190
+ return getClient(instance, {apiVersion: COMMENTS_API_VERSION, projectId, dataset})
191
+ }
192
+
193
+ function buildCommentPayload(options: {
194
+ authorId: string
195
+ commentId: string
196
+ contentSnapshot?: unknown
197
+ context?: Record<string, unknown>
198
+ documentRevisionId?: string
199
+ handle: DocumentHandle
200
+ message: CommentMessage
201
+ parentCommentId?: string
202
+ resource: DatasetResource
203
+ fieldPath: string
204
+ selection?: CommentTextSelection
205
+ status: CommentStatus
206
+ threadId: string
207
+ documentVersionId?: string
208
+ }): CommentPostPayload {
209
+ const {handle, resource} = options
210
+
211
+ return {
212
+ _id: options.commentId,
213
+ _type: 'comment',
214
+ authorId: options.authorId,
215
+ message: options.message,
216
+ threadId: options.threadId,
217
+ ...(options.parentCommentId ? {parentCommentId: options.parentCommentId} : {}),
218
+ status: options.status,
219
+ reactions: null,
220
+ // The Studio writes an empty tool name when no tool is active, and reads
221
+ // this nowhere in its UI.
222
+ context: {tool: '', ...options.context},
223
+ ...(options.contentSnapshot === undefined
224
+ ? {}
225
+ : {contentSnapshot: weakenReferencesInContentSnapshot(options.contentSnapshot)}),
226
+ target: {
227
+ documentRevisionId: options.documentRevisionId ?? '',
228
+ path: {
229
+ field: options.fieldPath,
230
+ ...(options.selection ? {selection: options.selection} : {}),
231
+ },
232
+ // The comment lives in the addon dataset, so this points across to the
233
+ // dataset holding the document. Weak, or Content Lake would refuse to
234
+ // delete a commented document.
235
+ document: {
236
+ _dataset: resource.dataset,
237
+ _projectId: resource.projectId,
238
+ _ref: getPublishedId(DocumentId(handle.documentId)),
239
+ _type: 'crossDatasetReference',
240
+ _weak: true,
241
+ },
242
+ documentType: handle.documentType,
243
+ ...(options.documentVersionId ? {documentVersionId: options.documentVersionId} : {}),
244
+ },
245
+ }
246
+ }
247
+
248
+ function findComment(
249
+ state: StoreState<CommentsStoreState>,
250
+ commentsKey: string,
251
+ commentId: string,
252
+ ): StoredComment | undefined {
253
+ const comments = state.get().entries[commentsKey]?.comments
254
+ return comments && Object.hasOwn(comments, commentId) ? comments[commentId] : undefined
255
+ }
256
+
257
+ function findCommentById(
258
+ state: StoreState<CommentsStoreState>,
259
+ commentId: string,
260
+ ): StoredComment | undefined {
261
+ for (const entry of Object.values(state.get().entries)) {
262
+ const comments = entry?.comments
263
+ const comment = comments && Object.hasOwn(comments, commentId) ? comments[commentId] : undefined
264
+ if (comment) return comment
265
+ }
266
+ return undefined
267
+ }
268
+
269
+ async function postComment(
270
+ context: StoreContext<CommentsStoreState, BoundResourceKey>,
271
+ handle: DocumentHandle & CommentsOptions,
272
+ payload: CommentPostPayload,
273
+ ): Promise<Comment> {
274
+ const {state, instance, key} = context
275
+ const commentsKey = getCommentsKey(toCommentsKeyParts(instance, handle))
276
+
277
+ // Show it before the round trip, so a thread feels immediate.
278
+ state.set('addComment', addComment(commentsKey, payload))
279
+
280
+ try {
281
+ const client = await getWritableClient(instance, key.resource, {createIfMissing: true})
282
+ // `createIfNotExists` makes duplicate writes of the same id harmless, which
283
+ // covers both a retry after a lost response and two concurrent calls with
284
+ // the same comment id.
285
+ const created = await client.createIfNotExists(payload, {tag: 'comments.create'})
286
+ state.set('receiveComment', receiveComment(commentsKey, created))
287
+ return normalizeComment(created)
288
+ } catch (error) {
289
+ // Keep it on screen carrying the failure, so an app can offer a retry with
290
+ // the same id rather than silently losing what someone typed.
291
+ state.set(
292
+ 'setCommentCreateError',
293
+ setCommentCreateError(payload._id, error instanceof Error ? error : new Error(String(error))),
294
+ )
295
+ throw error
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Starts a thread on a document, or on one of its fields.
301
+ *
302
+ * Creates the project's comments dataset if this is the first comment anywhere
303
+ * in it. The comment appears locally before the server confirms it; if the
304
+ * write fails it stays, carrying `_state.createError`, and retrying with the
305
+ * same `commentId` replaces it.
306
+ *
307
+ * @beta
308
+ */
309
+ export const createComment: (
310
+ instance: SanityInstance,
311
+ options: CreateCommentOptions,
312
+ ) => Promise<Comment> = bindActionByResource(
313
+ commentsStore,
314
+ // `async` so a validation failure rejects rather than throwing at the call
315
+ // site, which would slip past a `.catch()`.
316
+ async (
317
+ context: StoreContext<CommentsStoreState, BoundResourceKey>,
318
+ options: CreateCommentOptions,
319
+ ) => {
320
+ const {instance, key} = context
321
+ const perspective = options.perspective ?? instance.config.perspective
322
+ const fieldPath = requireFieldPath(options.fieldPath)
323
+
324
+ return postComment(
325
+ context,
326
+ options,
327
+ buildCommentPayload({
328
+ authorId: requireCurrentUserId(instance),
329
+ commentId: options.commentId ?? crypto.randomUUID(),
330
+ contentSnapshot: options.contentSnapshot,
331
+ context: options.context,
332
+ documentRevisionId: options.documentRevisionId,
333
+ handle: options,
334
+ message: options.message,
335
+ resource: assertDatasetResource(key.resource),
336
+ fieldPath,
337
+ selection: options.selection,
338
+ status: options.status ?? 'open',
339
+ threadId: options.threadId ?? crypto.randomUUID(),
340
+ ...(isReleasePerspective(perspective) ? {documentVersionId: perspective.releaseName} : {}),
341
+ }),
342
+ )
343
+ },
344
+ )
345
+
346
+ /**
347
+ * Adds a reply to an existing thread.
348
+ *
349
+ * The thread and field are taken from the parent comment, so a reply always
350
+ * matches the same list as the comment it answers. Supply them only when the
351
+ * parent is not loaded.
352
+ *
353
+ * @beta
354
+ */
355
+ export const replyToComment: (
356
+ instance: SanityInstance,
357
+ options: ReplyToCommentOptions,
358
+ ) => Promise<Comment> = bindActionByResource(
359
+ commentsStore,
360
+ async (
361
+ context: StoreContext<CommentsStoreState, BoundResourceKey>,
362
+ options: ReplyToCommentOptions,
363
+ ) => {
364
+ const {instance, key, state} = context
365
+ const commentsKey = getCommentsKey(toCommentsKeyParts(instance, options))
366
+ const parent = findComment(state, commentsKey, options.parentCommentId)
367
+
368
+ const threadId = options.threadId ?? parent?.threadId
369
+ if (!threadId) {
370
+ throw new Error(
371
+ `Cannot reply to "${options.parentCommentId}": it is not loaded, so pass its threadId.`,
372
+ )
373
+ }
374
+
375
+ const status = parent?.status ?? options.status
376
+ if (!status) {
377
+ throw new Error(
378
+ `Cannot reply to "${options.parentCommentId}": it is not loaded, so pass its status.`,
379
+ )
380
+ }
381
+
382
+ const perspective = options.perspective ?? instance.config.perspective
383
+
384
+ return postComment(
385
+ context,
386
+ options,
387
+ buildCommentPayload({
388
+ authorId: requireCurrentUserId(instance),
389
+ commentId: options.commentId ?? crypto.randomUUID(),
390
+ handle: options,
391
+ message: options.message,
392
+ // Replies to a reply belong to the thread's first comment, matching how
393
+ // the Studio flattens threads.
394
+ parentCommentId: parent?.parentCommentId ?? options.parentCommentId,
395
+ resource: assertDatasetResource(key.resource),
396
+ // Inherited from the parent unless the caller names one, and checked
397
+ // either way: a reply with no path crashes the Studio inspector exactly
398
+ // as a pathless thread would.
399
+ fieldPath: requireFieldPath(options.fieldPath ?? parent?.target.path?.field ?? ''),
400
+ // A reply into a resolved thread stays consistent with its parent.
401
+ status,
402
+ threadId,
403
+ ...(isReleasePerspective(perspective) ? {documentVersionId: perspective.releaseName} : {}),
404
+ }),
405
+ )
406
+ },
407
+ )
408
+
409
+ /**
410
+ * Rewrites a comment's message and stamps `lastEditedAt`.
411
+ * @beta
412
+ */
413
+ export const updateComment: (
414
+ instance: SanityInstance,
415
+ options: UpdateCommentOptions,
416
+ ) => Promise<void> = bindActionByResource(
417
+ commentsStore,
418
+ async (
419
+ {state, instance, key}: StoreContext<CommentsStoreState, BoundResourceKey>,
420
+ {commentId, message}: UpdateCommentOptions,
421
+ ) => {
422
+ const lastEditedAt = new Date().toISOString()
423
+ const transactionId = crypto.randomUUID()
424
+ const previous = findCommentById(state, commentId)
425
+
426
+ if (previous) {
427
+ state.set('setPendingTransaction', setPendingTransaction(commentId, transactionId))
428
+ state.set('updateComment', applyCommentUpdate(commentId, {message, lastEditedAt}))
429
+ }
430
+
431
+ try {
432
+ const client = await getWritableClient(instance, key.resource, {createIfMissing: false})
433
+ await client
434
+ .transaction()
435
+ .transactionId(transactionId)
436
+ .patch(client.patch(commentId).set({message, lastEditedAt}))
437
+ .commit({tag: 'comments.update'})
438
+ if (previous) {
439
+ state.set('clearPendingTransaction', clearPendingTransaction(commentId))
440
+ }
441
+ } catch (error) {
442
+ if (previous) {
443
+ state.set(
444
+ 'rollbackCommentUpdate',
445
+ rollbackCommentUpdate(commentId, transactionId, previous),
446
+ )
447
+ }
448
+ throw error
449
+ }
450
+ },
451
+ )
452
+
453
+ /**
454
+ * Resolves or reopens a thread.
455
+ *
456
+ * Pass the thread's first comment: replies follow it, patched server-side in
457
+ * one query so a reply created by someone else at the same moment is caught
458
+ * too.
459
+ *
460
+ * @beta
461
+ */
462
+ export const setCommentStatus: (
463
+ instance: SanityInstance,
464
+ options: SetCommentStatusOptions,
465
+ ) => Promise<void> = bindActionByResource(
466
+ commentsStore,
467
+ async (
468
+ {state, instance, key}: StoreContext<CommentsStoreState, BoundResourceKey>,
469
+ {commentId, status}: SetCommentStatusOptions,
470
+ ) => {
471
+ const transactionId = crypto.randomUUID()
472
+ const previousComments: StoredComment[] = []
473
+
474
+ for (const entry of Object.values(state.get().entries)) {
475
+ for (const candidate of Object.values(entry?.comments ?? {})) {
476
+ if (candidate._id !== commentId && candidate.parentCommentId !== commentId) continue
477
+ previousComments.push(candidate)
478
+ state.set('setPendingTransaction', setPendingTransaction(candidate._id, transactionId))
479
+ state.set('updateCommentStatus', applyCommentUpdate(candidate._id, {status}))
480
+ }
481
+ }
482
+
483
+ try {
484
+ const client = await getWritableClient(instance, key.resource, {createIfMissing: false})
485
+
486
+ await client.mutate(
487
+ [
488
+ {patch: {id: commentId, set: {status}}},
489
+ {
490
+ patch: {
491
+ query: '*[_type == "comment" && parentCommentId == $commentId]',
492
+ params: {commentId},
493
+ set: {status},
494
+ },
495
+ },
496
+ ],
497
+ {transactionId, tag: 'comments.set-status'},
498
+ )
499
+ for (const previous of previousComments) {
500
+ state.set('clearPendingTransaction', clearPendingTransaction(previous._id))
501
+ }
502
+ } catch (error) {
503
+ for (const previous of previousComments) {
504
+ state.set(
505
+ 'rollbackCommentUpdate',
506
+ rollbackCommentUpdate(previous._id, transactionId, previous),
507
+ )
508
+ }
509
+ throw error
510
+ }
511
+ },
512
+ )
513
+
514
+ /**
515
+ * Deletes a comment, and its replies when it starts a thread.
516
+ * @beta
517
+ */
518
+ export const removeComment: (
519
+ instance: SanityInstance,
520
+ options: RemoveCommentOptions,
521
+ ) => Promise<void> = bindActionByResource(
522
+ commentsStore,
523
+ async (
524
+ {state, instance, key}: StoreContext<CommentsStoreState, BoundResourceKey>,
525
+ {commentId}: RemoveCommentOptions,
526
+ ) => {
527
+ const removed = Object.entries(state.get().entries).flatMap(([entryKey, entry]) => {
528
+ const comments = Object.values(entry?.comments ?? {}).filter(
529
+ (comment) => comment._id === commentId || comment.parentCommentId === commentId,
530
+ )
531
+ return comments.length ? [{key: entryKey, comments}] : []
532
+ })
533
+
534
+ state.set('removeComment', removeCommentById(commentId))
535
+
536
+ try {
537
+ const client = await getWritableClient(instance, key.resource, {createIfMissing: false})
538
+ await client.mutate(
539
+ [
540
+ {
541
+ delete: {
542
+ query: '*[_type == "comment" && parentCommentId == $commentId]',
543
+ params: {commentId},
544
+ },
545
+ },
546
+ {delete: {id: commentId}},
547
+ ],
548
+ {tag: 'comments.remove'},
549
+ )
550
+ } catch (error) {
551
+ state.set('restoreComments', restoreComments(removed))
552
+ throw error
553
+ }
554
+ },
555
+ )
@@ -0,0 +1,40 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {toCommentFieldPath} from './commentFieldPath'
4
+
5
+ describe('toCommentFieldPath', () => {
6
+ it('produces the strings the Studio writes', () => {
7
+ // Golden values. These are the interop contract: a comment the SDK writes is
8
+ // only visible in the Studio if `target.path.field` matches what the Studio
9
+ // would have written for the same field.
10
+ expect(toCommentFieldPath(['title'])).toBe('title')
11
+ expect(toCommentFieldPath(['body', {_key: 'intro'}, 'content'])).toBe(
12
+ 'body[_key=="intro"].content',
13
+ )
14
+ expect(toCommentFieldPath(['arr', 0, 'field'])).toBe('arr[0].field')
15
+ expect(toCommentFieldPath(['a', {_key: 'b'}, 'c', 0, 'd'])).toBe('a[_key=="b"].c[0].d')
16
+ })
17
+
18
+ it('treats a missing or empty path as document level', () => {
19
+ expect(toCommentFieldPath()).toBe('')
20
+ expect(toCommentFieldPath([])).toBe('')
21
+ expect(toCommentFieldPath('')).toBe('')
22
+ })
23
+
24
+ it('canonicalises a string that is already a path', () => {
25
+ expect(toCommentFieldPath('body[_key=="intro"].content')).toBe('body[_key=="intro"].content')
26
+ })
27
+
28
+ it('quotes nested field names that GROQ treats as values', () => {
29
+ expect(toCommentFieldPath(['object', 'true'])).toBe('object["true"]')
30
+ expect(toCommentFieldPath(['object', 'false'])).toBe('object["false"]')
31
+ expect(toCommentFieldPath(['object', 'null'])).toBe('object["null"]')
32
+ expect(toCommentFieldPath('object.true')).toBe('object["true"]')
33
+ })
34
+
35
+ it('does not rewrite reserved words inside keyed segments', () => {
36
+ expect(toCommentFieldPath(['body', {_key: 'section.true.child'}, 'null'])).toBe(
37
+ 'body[_key=="section.true.child"]["null"]',
38
+ )
39
+ })
40
+ })
@@ -0,0 +1,44 @@
1
+ import {stringifyPath} from '@sanity/json-match'
2
+ import {type Path} from '@sanity/types'
3
+
4
+ /** A dotted segment GROQ would read as a literal rather than a field name. */
5
+ const RESERVED_SEGMENT = /\.(true|false|null)(?=$|[.[])/g
6
+
7
+ /**
8
+ * One quoted string, or one run of text containing no quote at all. Splitting on
9
+ * this is what keeps the rewrite below out of `[_key=="..."]` values.
10
+ */
11
+ const QUOTED_OR_PLAIN = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^"']+/g
12
+
13
+ function quoteReservedFieldSegments(path: string): string {
14
+ return path.replace(QUOTED_OR_PLAIN, (chunk) =>
15
+ chunk.startsWith('"') || chunk.startsWith("'")
16
+ ? chunk
17
+ : chunk.replace(RESERVED_SEGMENT, '["$1"]'),
18
+ )
19
+ }
20
+
21
+ /**
22
+ * Normalises a field path to the string form comments are stored in.
23
+ *
24
+ * Comments keep `target.path.field` as a string rather than a path array,
25
+ * because that is what the Studio writes and reading it back has to match
26
+ * exactly. A string given here is parsed and re-stringified, so both a path
27
+ * array and a hand-written string come out in the same canonical form.
28
+ *
29
+ * `undefined` and `[]` both produce `''`. Callers writing a comment reject that
30
+ * rather than passing it on, since a comment has to point at a field.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * toCommentFieldPath(['body', {_key: 'intro'}, 'content'])
35
+ * // 'body[_key=="intro"].content'
36
+ * toCommentFieldPath(undefined)
37
+ * // ''
38
+ * ```
39
+ *
40
+ * @internal
41
+ */
42
+ export function toCommentFieldPath(path?: string | Path): string {
43
+ return quoteReservedFieldSegments(stringifyPath(path))
44
+ }