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