@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,483 @@
1
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
+ import {type Path} from '@sanity/types'
3
+ import {
4
+ catchError,
5
+ distinctUntilChanged,
6
+ EMPTY,
7
+ first,
8
+ firstValueFrom,
9
+ groupBy,
10
+ map,
11
+ mergeMap,
12
+ NEVER,
13
+ Observable,
14
+ pairwise,
15
+ race,
16
+ startWith,
17
+ switchMap,
18
+ tap,
19
+ } from 'rxjs'
20
+
21
+ import {type DocumentHandle} from '../config/sanityConfig'
22
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
23
+ import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
24
+ import {type SanityInstance} from '../store/createSanityInstance'
25
+ import {
26
+ createStateSourceAction,
27
+ type SelectorContext,
28
+ type StateSource,
29
+ } from '../store/createStateSourceAction'
30
+ import {type StoreState} from '../store/createStoreState'
31
+ import {defineStore, type StoreContext} from '../store/defineStore'
32
+ import {insecureRandomId} from '../utils/ids'
33
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
34
+ import {observeAddonDatasetClient} from './addonDatasetStore'
35
+ import {buildCommentThreads} from './buildCommentThreads'
36
+ import {toCommentFieldPath} from './commentFieldPath'
37
+ import {COMMENTS_STATE_CLEAR_DELAY} from './commentsConstants'
38
+ import {normalizeComment} from './normalizeComment'
39
+ import {type CommentsEvent, observeComments} from './observeComments'
40
+ import {
41
+ addSubscriber,
42
+ clearPendingTransaction,
43
+ type CommentsKeyParts,
44
+ type CommentsStoreState,
45
+ getCommentsKey,
46
+ parseCommentsKey,
47
+ receiveComment,
48
+ removeCommentById,
49
+ removeSubscriber,
50
+ setComments,
51
+ setCommentsError,
52
+ } from './reducers'
53
+ import {type Comment, type CommentStatus, type CommentThread, type StoredComment} from './types'
54
+
55
+ /**
56
+ * Which comments to read.
57
+ * @beta
58
+ */
59
+ export interface CommentsOptions extends DocumentHandle {
60
+ /**
61
+ * Narrow to one field. Omit to get every comment on the document.
62
+ */
63
+ fieldPath?: string | Path
64
+ /** Narrow to open or resolved threads. Omit for both. */
65
+ status?: CommentStatus
66
+ }
67
+
68
+ /** @beta */
69
+ export interface ResolveCommentsOptions extends CommentsOptions {
70
+ signal?: AbortSignal
71
+ }
72
+
73
+ /**
74
+ * Fields on {@link CommentsOptions} that are deliberately left out of the key
75
+ * below, because neither changes which comments an option set addresses.
76
+ *
77
+ * `source` is the deprecated alias for `resource` and is folded into it before
78
+ * a key is ever built, so keying on both would give one list two keys.
79
+ * `liveEdit` describes the document rather than its comments, which hang off
80
+ * the published id whether or not drafts exist.
81
+ */
82
+ type CommentsKeyIrrelevantField = 'source' | 'liveEdit'
83
+
84
+ /**
85
+ * A stable string standing for one set of read options.
86
+ *
87
+ * Only React needs this: it holds one state source steady across renders and
88
+ * defers swapping to a new one while the previous list is still on screen.
89
+ * `fieldPath` is normalised on the way in, so a path array and the equivalent
90
+ * string address the same list.
91
+ *
92
+ * @internal
93
+ */
94
+ export function getCommentsOptionsKey(options: CommentsOptions): string {
95
+ return JSON.stringify({
96
+ documentId: options.documentId,
97
+ documentType: options.documentType,
98
+ projectId: options.projectId,
99
+ dataset: options.dataset,
100
+ resource: options.resource,
101
+ perspective: options.perspective,
102
+ fieldPath: options.fieldPath === undefined ? undefined : toCommentFieldPath(options.fieldPath),
103
+ status: options.status,
104
+ // The `Record` half makes a new field on `CommentsOptions` a compile
105
+ // error here unless it is listed above or named as irrelevant. Left to
106
+ // `satisfies CommentsOptions` alone, a forgotten field would just be
107
+ // absent from the key, and a reader would keep the list it had while the
108
+ // caller thought it had asked for a different one.
109
+ } satisfies CommentsOptions &
110
+ Record<Exclude<keyof CommentsOptions, CommentsKeyIrrelevantField>, unknown>)
111
+ }
112
+
113
+ /** @internal */
114
+ export function parseCommentsOptionsKey(key: string): CommentsOptions {
115
+ return JSON.parse(key) as CommentsOptions
116
+ }
117
+
118
+ /**
119
+ * Which comment list an option set addresses.
120
+ *
121
+ * Comments hang off the published id, so a draft and its published document
122
+ * share one list. A release keeps its own.
123
+ */
124
+ export function toCommentsKeyParts(
125
+ instance: SanityInstance,
126
+ options: CommentsOptions,
127
+ ): CommentsKeyParts {
128
+ const perspective = options.perspective ?? instance.config.perspective
129
+ return {
130
+ documentId: getPublishedId(DocumentId(options.documentId)),
131
+ ...(isReleasePerspective(perspective) ? {documentVersionId: perspective.releaseName} : {}),
132
+ }
133
+ }
134
+
135
+ function applyEvent(
136
+ state: StoreState<CommentsStoreState>,
137
+ key: string,
138
+ event: CommentsEvent,
139
+ ): void {
140
+ switch (event.type) {
141
+ case 'snapshot':
142
+ state.set('setComments', setComments(key, event.comments))
143
+ return
144
+ case 'appear':
145
+ state.set('receiveComment', receiveComment(key, event.comment))
146
+ return
147
+ case 'disappear':
148
+ state.set('removeComment', removeCommentById(event.commentId))
149
+ return
150
+ case 'error':
151
+ state.set('setCommentsError', setCommentsError(key, event.error))
152
+ return
153
+ case 'update': {
154
+ const pending = state.get().pendingTransactions[event.comment._id]
155
+
156
+ // Our own writes come back through the listener. When a later transaction
157
+ // is already in flight for this comment, an echo of an earlier one would
158
+ // undo it on screen, so drop it and wait for the one we are expecting.
159
+ if (pending && pending !== event.transactionId) return
160
+
161
+ state.set('receiveComment', receiveComment(key, event.comment))
162
+ if (pending) {
163
+ state.set('clearPendingTransaction', clearPendingTransaction(event.comment._id))
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ const watchSubscribedDocuments = ({
170
+ state,
171
+ instance,
172
+ key,
173
+ }: StoreContext<CommentsStoreState, BoundResourceKey>) => {
174
+ const client$ = observeAddonDatasetClient(instance, {resource: key.resource})
175
+
176
+ return state.observable
177
+ .pipe(
178
+ map((current) => new Set(Object.keys(current.entries))),
179
+ distinctUntilChanged(
180
+ (a, b) => a.size === b.size && Array.from(b).every((entry) => a.has(entry)),
181
+ ),
182
+ startWith(new Set<string>()),
183
+ pairwise(),
184
+ mergeMap(([previous, current]) => [
185
+ ...Array.from(current)
186
+ .filter((entry) => !previous.has(entry))
187
+ .map((entry) => ({key: entry, added: true})),
188
+ ...Array.from(previous)
189
+ .filter((entry) => !current.has(entry))
190
+ .map((entry) => ({key: entry, added: false})),
191
+ ]),
192
+ groupBy((event) => event.key),
193
+ mergeMap((group$) =>
194
+ group$.pipe(
195
+ switchMap((event) => {
196
+ if (!event.added) return EMPTY
197
+
198
+ const {documentId, documentVersionId} = parseCommentsKey(group$.key)
199
+
200
+ return client$.pipe(
201
+ switchMap((client) => {
202
+ if (!client) {
203
+ // A dataset that does not exist holds no comments. Settling on
204
+ // an empty list matters: leaving it unset would suspend
205
+ // readers forever on a project nobody has commented in.
206
+ state.set('setComments', setComments(group$.key, []))
207
+ return EMPTY
208
+ }
209
+
210
+ return observeComments({client, documentId, documentVersionId}).pipe(
211
+ tap((commentsEvent) => applyEvent(state, group$.key, commentsEvent)),
212
+ // Keep following the addon client after one listener fails.
213
+ // A token refresh or reconnect can then supply a new client.
214
+ catchError((error: unknown) => {
215
+ state.set('setCommentsError', setCommentsError(group$.key, error))
216
+ return EMPTY
217
+ }),
218
+ )
219
+ }),
220
+ )
221
+ }),
222
+ ),
223
+ ),
224
+ )
225
+ .subscribe({error: (error: unknown) => state.set('setError', {error})})
226
+ }
227
+
228
+ export const commentsStore = defineStore<CommentsStoreState, BoundResourceKey>({
229
+ name: 'Comments',
230
+ getInitialState: () => ({
231
+ entries: {},
232
+ pendingCreates: {},
233
+ pendingTransactions: {},
234
+ }),
235
+ initialize: (context) => {
236
+ const subscription = watchSubscribedDocuments(context)
237
+ return () => subscription.unsubscribe()
238
+ },
239
+ })
240
+
241
+ /**
242
+ * Filtered lists and their threads, cached against the comment map they came
243
+ * from.
244
+ *
245
+ * Selectors run on every store change, and a fresh array each time would make
246
+ * `useSyncExternalStore` re-render whenever anything anywhere in the store
247
+ * moved. Keying on the comment map means a change to some other document, or
248
+ * to an unrelated part of the state, hands back the identical array. Entries
249
+ * die with the map they belong to.
250
+ */
251
+ const normalizedCache = new WeakMap<object, Comment[]>()
252
+ const filteredCache = new WeakMap<object, Map<string, Comment[]>>()
253
+ const threadCache = new WeakMap<object, Map<string, CommentThread[]>>()
254
+
255
+ /**
256
+ * The stored map turned into what consumers see, newest first.
257
+ *
258
+ * Cached alongside the filters below rather than done per read, so the
259
+ * normalised objects keep their identity for as long as the stored map does.
260
+ */
261
+ function normalizeAll(commentsById: Record<string, StoredComment>): Comment[] {
262
+ const cached = normalizedCache.get(commentsById)
263
+ if (cached) return cached
264
+
265
+ // Newest first, matching the query's order. The store keys comments by id, so
266
+ // the order has to be reapplied here.
267
+ const normalized = Object.values(commentsById)
268
+ .map(normalizeComment)
269
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
270
+
271
+ normalizedCache.set(commentsById, normalized)
272
+ return normalized
273
+ }
274
+
275
+ function filterComments(
276
+ all: Comment[],
277
+ fieldPath: string | undefined,
278
+ status: CommentStatus | undefined,
279
+ ): Comment[] {
280
+ let byFilter = filteredCache.get(all)
281
+ if (!byFilter) {
282
+ byFilter = new Map()
283
+ filteredCache.set(all, byFilter)
284
+ }
285
+
286
+ // `\0` stands in for "no field filter", which is not the same as `''`.
287
+ const cacheKey = `${fieldPath ?? '\0'}|${status ?? ''}`
288
+ const cached = byFilter.get(cacheKey)
289
+ if (cached) return cached
290
+
291
+ const filtered = all.filter((comment) => {
292
+ if (status && comment.status !== status) return false
293
+ if (fieldPath === undefined) return true
294
+ return comment.fieldPath === fieldPath
295
+ })
296
+
297
+ byFilter.set(cacheKey, filtered)
298
+ return filtered
299
+ }
300
+
301
+ function selectComments(
302
+ {state, instance}: SelectorContext<CommentsStoreState>,
303
+ options: CommentsOptions,
304
+ ): Comment[] | undefined {
305
+ if (state.error) throw state.error
306
+
307
+ const entry = state.entries[getCommentsKey(toCommentsKeyParts(instance, options))]
308
+ if (entry?.error) throw entry.error
309
+ if (!entry?.comments) return undefined
310
+
311
+ return filterComments(
312
+ normalizeAll(entry.comments),
313
+ options.fieldPath === undefined ? undefined : toCommentFieldPath(options.fieldPath),
314
+ options.status,
315
+ )
316
+ }
317
+
318
+ function selectCommentThreads(
319
+ context: SelectorContext<CommentsStoreState>,
320
+ options: CommentsOptions,
321
+ ): CommentThread[] | undefined {
322
+ const comments = selectComments(context, {...options, fieldPath: undefined, status: undefined})
323
+ if (comments === undefined) return undefined
324
+
325
+ let byFilter = threadCache.get(comments)
326
+ if (!byFilter) {
327
+ byFilter = new Map()
328
+ threadCache.set(comments, byFilter)
329
+ }
330
+
331
+ const fieldPath =
332
+ options.fieldPath === undefined ? undefined : toCommentFieldPath(options.fieldPath)
333
+ const cacheKey = `${fieldPath ?? '\0'}|${options.status ?? ''}`
334
+ const cached = byFilter.get(cacheKey)
335
+ if (cached) return cached
336
+
337
+ const threads = buildCommentThreads(comments).filter((thread) => {
338
+ if (options.status && thread.parentComment.status !== options.status) return false
339
+ if (fieldPath === undefined) return true
340
+ return thread.fieldPath === fieldPath
341
+ })
342
+ byFilter.set(cacheKey, threads)
343
+ return threads
344
+ }
345
+
346
+ /**
347
+ * Every comment on a document, newest first.
348
+ *
349
+ * `undefined` until the first snapshot arrives. Replies are included; use
350
+ * {@link getCommentThreadsState} to read them grouped.
351
+ *
352
+ * @beta
353
+ */
354
+ export const getCommentsState: (
355
+ instance: SanityInstance,
356
+ options: CommentsOptions,
357
+ ) => StateSource<Comment[] | undefined> = bindActionByResource(
358
+ commentsStore,
359
+ createStateSourceAction({
360
+ selector: selectComments,
361
+ onSubscribe: ({state, instance}, options: CommentsOptions) => {
362
+ const key = getCommentsKey(toCommentsKeyParts(instance, options))
363
+ const subscriptionId = insecureRandomId()
364
+ state.set('addSubscriber', addSubscriber(key, subscriptionId))
365
+
366
+ return () => {
367
+ setCleanupTimeout(
368
+ () => state.set('removeSubscriber', removeSubscriber(key, subscriptionId)),
369
+ COMMENTS_STATE_CLEAR_DELAY,
370
+ )
371
+ }
372
+ },
373
+ }),
374
+ )
375
+
376
+ /**
377
+ * Threads on a document, newest thread first, each with its replies oldest first.
378
+ *
379
+ * A thread's `status` and `fieldPath` come from its first comment, so filtering
380
+ * by either selects whole threads rather than individual replies.
381
+ *
382
+ * @beta
383
+ */
384
+ export const getCommentThreadsState: (
385
+ instance: SanityInstance,
386
+ options: CommentsOptions,
387
+ ) => StateSource<CommentThread[] | undefined> = bindActionByResource(
388
+ commentsStore,
389
+ createStateSourceAction({
390
+ selector: selectCommentThreads,
391
+ onSubscribe: ({state, instance}, options: CommentsOptions) => {
392
+ const key = getCommentsKey(toCommentsKeyParts(instance, options))
393
+ const subscriptionId = insecureRandomId()
394
+ state.set('addSubscriber', addSubscriber(key, subscriptionId))
395
+
396
+ return () => {
397
+ setCleanupTimeout(
398
+ () => state.set('removeSubscriber', removeSubscriber(key, subscriptionId)),
399
+ COMMENTS_STATE_CLEAR_DELAY,
400
+ )
401
+ }
402
+ },
403
+ }),
404
+ )
405
+
406
+ /**
407
+ * Waits for a document's comments to load.
408
+ *
409
+ * Holds a subscriber only while resolving, so a component that suspends on this
410
+ * and then errors before mounting does not strand the list. Throw the promise
411
+ * for Suspense, then read through {@link getCommentsState}.
412
+ *
413
+ * @beta
414
+ */
415
+ export const resolveComments: (
416
+ instance: SanityInstance,
417
+ options: ResolveCommentsOptions,
418
+ ) => Promise<Comment[]> = bindActionByResource(
419
+ commentsStore,
420
+ (
421
+ {state, instance}: StoreContext<CommentsStoreState, BoundResourceKey>,
422
+ {signal, ...options}: ResolveCommentsOptions,
423
+ ) => resolveList(state, instance, options, signal, getCommentsState),
424
+ )
425
+
426
+ /**
427
+ * Waits for a document's comments to load, grouped into threads.
428
+ * @beta
429
+ */
430
+ export const resolveCommentThreads: (
431
+ instance: SanityInstance,
432
+ options: ResolveCommentsOptions,
433
+ ) => Promise<CommentThread[]> = bindActionByResource(
434
+ commentsStore,
435
+ (
436
+ {state, instance}: StoreContext<CommentsStoreState, BoundResourceKey>,
437
+ {signal, ...options}: ResolveCommentsOptions,
438
+ ) => resolveList(state, instance, options, signal, getCommentThreadsState),
439
+ )
440
+
441
+ function resolveList<T>(
442
+ state: StoreState<CommentsStoreState>,
443
+ instance: SanityInstance,
444
+ options: CommentsOptions,
445
+ signal: AbortSignal | undefined,
446
+ getState: (i: SanityInstance, o: CommentsOptions) => StateSource<T | undefined>,
447
+ ): Promise<T> {
448
+ const key = getCommentsKey(toCommentsKeyParts(instance, options))
449
+ const {getCurrent} = getState(instance, options)
450
+
451
+ // Loading is driven by subscribers, so without one here nothing would ever
452
+ // fetch and this promise would never settle. Holding it only for the duration
453
+ // of the resolve also means a component that suspends and then errors before
454
+ // mounting does not leave a subscriber-less entry behind holding its error.
455
+ const subscriptionId = insecureRandomId()
456
+ state.set('addSubscriber', addSubscriber(key, subscriptionId))
457
+
458
+ const release = () => state.set('removeSubscriber', removeSubscriber(key, subscriptionId))
459
+
460
+ const aborted$ = signal
461
+ ? new Observable<never>((observer) => {
462
+ const listener = () => {
463
+ // Release now rather than after the delay: when this was the only
464
+ // reader, dropping the key tears down the listener immediately, which
465
+ // is the point of aborting.
466
+ release()
467
+ observer.error(new DOMException('The operation was aborted.', 'AbortError'))
468
+ }
469
+ signal.addEventListener('abort', listener)
470
+ return () => signal.removeEventListener('abort', listener)
471
+ })
472
+ : NEVER
473
+
474
+ const resolved$ = state.observable.pipe(
475
+ map(() => getCurrent()),
476
+ first((value): value is T => value !== undefined),
477
+ )
478
+
479
+ const promise = firstValueFrom(race([resolved$, aborted$]))
480
+ const releaseLater = () => setCleanupTimeout(release, COMMENTS_STATE_CLEAR_DELAY)
481
+ promise.then(releaseLater, releaseLater)
482
+ return promise
483
+ }
@@ -0,0 +1,116 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {normalizeComment} from './normalizeComment'
4
+ import {type StoredComment} from './types'
5
+
6
+ function stored(overrides: Partial<StoredComment> = {}): StoredComment {
7
+ return {
8
+ _type: 'comment',
9
+ _id: 'comment-1',
10
+ _createdAt: '2026-01-01T00:00:00Z',
11
+ _rev: 'rev-1',
12
+ authorId: 'user-1',
13
+ message: null,
14
+ threadId: 'thread-1',
15
+ status: 'open',
16
+ reactions: null,
17
+ target: {
18
+ documentType: 'author',
19
+ document: {
20
+ _dataset: 'production',
21
+ _projectId: 'p',
22
+ _ref: 'doc-1',
23
+ _type: 'crossDatasetReference',
24
+ _weak: true,
25
+ },
26
+ },
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ describe('normalizeComment', () => {
32
+ it('maps a stored comment to the public shape', () => {
33
+ // Asserted in full. This mapping is the seam that lets storage change
34
+ // without the public shape moving, so every field is deliberate.
35
+ expect(normalizeComment(stored())).toEqual({
36
+ id: 'comment-1',
37
+ createdAt: '2026-01-01T00:00:00Z',
38
+ authorId: 'user-1',
39
+ message: null,
40
+ threadId: 'thread-1',
41
+ status: 'open',
42
+ documentId: 'doc-1',
43
+ documentType: 'author',
44
+ fieldPath: '',
45
+ reactions: [],
46
+ })
47
+ })
48
+
49
+ it('leaves out what the stored comment does not have', () => {
50
+ const normalized = normalizeComment(stored())
51
+
52
+ // Absent rather than present-and-undefined, so a deep equality check in a
53
+ // consumer's test does not have to know about keys it never set.
54
+ expect('parentCommentId' in normalized).toBe(false)
55
+ expect('lastEditedAt' in normalized).toBe(false)
56
+ expect('selection' in normalized).toBe(false)
57
+ expect('contentSnapshot' in normalized).toBe(false)
58
+ expect('state' in normalized).toBe(false)
59
+ })
60
+
61
+ it('lifts the field path out of the target', () => {
62
+ const comment = stored({
63
+ target: {
64
+ documentType: 'author',
65
+ path: {field: 'body[_key=="intro"].content'},
66
+ document: {_ref: 'doc-1', _type: 'reference', _weak: true},
67
+ },
68
+ })
69
+
70
+ expect(normalizeComment(comment).fieldPath).toBe('body[_key=="intro"].content')
71
+ })
72
+
73
+ it('lifts a text selection out of the target', () => {
74
+ const selection = {type: 'text' as const, value: [{_key: 'b1', text: 'marked'}]}
75
+ const comment = stored({
76
+ target: {
77
+ documentType: 'author',
78
+ path: {field: 'body', selection},
79
+ document: {_ref: 'doc-1', _type: 'reference', _weak: true},
80
+ },
81
+ })
82
+
83
+ expect(normalizeComment(comment).selection).toEqual(selection)
84
+ })
85
+
86
+ it('drops the array key from reactions', () => {
87
+ const comment = stored({
88
+ reactions: [{_key: 'user-2-:+1:', shortName: ':+1:', userId: 'user-2', addedAt: 'then'}],
89
+ })
90
+
91
+ expect(normalizeComment(comment).reactions).toEqual([
92
+ {shortName: ':+1:', userId: 'user-2', addedAt: 'then'},
93
+ ])
94
+ })
95
+
96
+ it('leaves the author out when the document does not carry one', () => {
97
+ // The organization store records identity server-side rather than on the
98
+ // document, and types it as doubly optional, so there are comments with no
99
+ // author to map. Emitting `''` would look like a user id.
100
+ const comment = stored()
101
+ delete (comment as {authorId?: string}).authorId
102
+
103
+ expect('authorId' in normalizeComment(comment)).toBe(false)
104
+ })
105
+
106
+ it('carries the local create state through', () => {
107
+ const error = new Error('nope')
108
+ const comment = stored({_state: {type: 'createError', error}})
109
+
110
+ expect(normalizeComment(comment).state).toEqual({type: 'createError', error})
111
+ })
112
+
113
+ it('keeps replies pointed at their parent', () => {
114
+ expect(normalizeComment(stored({parentCommentId: 'parent-1'})).parentCommentId).toBe('parent-1')
115
+ })
116
+ })
@@ -0,0 +1,45 @@
1
+ import {type Comment, type StoredComment} from './types'
2
+
3
+ /**
4
+ * Turns a stored comment into the shape consumers see.
5
+ *
6
+ * This is the whole seam between the storage format and the public API. When
7
+ * comments move to the organization-level store, the three fields that differ
8
+ * are handled here and nothing downstream changes:
9
+ *
10
+ * - the author moves from `authorId` to `_system.createdBy`
11
+ * - `target.document` becomes a global document reference, so `documentId` is
12
+ * parsed out of a `resourceType:resourceId:documentId` string rather than
13
+ * read off `_ref`
14
+ * - `_type` goes from `comment` to `sanity.comment`, and stops being exposed
15
+ *
16
+ * @internal
17
+ */
18
+ export function normalizeComment(stored: StoredComment): Comment {
19
+ const {target} = stored
20
+
21
+ return {
22
+ id: stored._id,
23
+ createdAt: stored._createdAt,
24
+ // Omitted rather than emptied when absent, so a consumer can tell "no
25
+ // author recorded" from a user whose id happens to be falsy.
26
+ ...(stored.authorId ? {authorId: stored.authorId} : {}),
27
+ message: stored.message,
28
+ threadId: stored.threadId,
29
+ ...(stored.parentCommentId ? {parentCommentId: stored.parentCommentId} : {}),
30
+ status: stored.status,
31
+ ...(stored.lastEditedAt ? {lastEditedAt: stored.lastEditedAt} : {}),
32
+ documentId: target.document._ref,
33
+ documentType: target.documentType,
34
+ fieldPath: target.path?.field ?? '',
35
+ ...(target.path?.selection ? {selection: target.path.selection} : {}),
36
+ ...(stored.contentSnapshot === undefined ? {} : {contentSnapshot: stored.contentSnapshot}),
37
+ // Dropped: the stored `_key`, which only exists to address the array item.
38
+ reactions: (stored.reactions ?? []).map(({shortName, userId, addedAt}) => ({
39
+ shortName,
40
+ userId,
41
+ addedAt,
42
+ })),
43
+ ...(stored._state ? {state: stored._state} : {}),
44
+ }
45
+ }