@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.2

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 (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  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-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.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 -2332
  29. package/dist/index.js +4171 -4332
  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-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.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-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -0,0 +1,217 @@
1
+ import {type ListenEvent, type MutationEvent} from '@sanity/client'
2
+ import {type SanityDocument} from '@sanity/types'
3
+ import {defer, filter, type MonoTypeOperatorFunction, Observable} from 'rxjs'
4
+
5
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
6
+
7
+ const DEDUPE_TTL = 120_000
8
+ const DEDUPE_MAX_ENTRIES = 1_000
9
+
10
+ /**
11
+ * How long to hold an incomplete multi-document transaction (and the events
12
+ * held behind it) before releasing everything anyway. Only reached when part
13
+ * of a transaction is lost.
14
+ */
15
+ const GROUP_FLUSH_DEADLINE = 30_000
16
+
17
+ /**
18
+ * How many intervening events to hold behind an incomplete multi-document
19
+ * transaction before giving up on grouping and releasing everything in
20
+ * arrival order.
21
+ */
22
+ const GROUP_MAX_HELD_EVENTS = 100
23
+
24
+ interface DedupeOptions {
25
+ ttl?: number
26
+ maxEntries?: number
27
+ }
28
+
29
+ /**
30
+ * Drops mutation events that have already been observed, keyed by
31
+ * `transactionId#documentId`. With `enableResume`, the listener replays
32
+ * missed events after a reconnect on an at-least-once basis, so the same
33
+ * event can be delivered twice. A duplicate would never chain onto the
34
+ * current base revision and would clog the sequencing buffer until it
35
+ * overflows into an `OutOfSyncError`.
36
+ *
37
+ * Seen keys expire after `ttl` milliseconds and the cache is capped at
38
+ * `maxEntries`, evicting the oldest entries first.
39
+ *
40
+ * @internal
41
+ */
42
+ export function dedupeListenerEvents(
43
+ options?: DedupeOptions,
44
+ ): MonoTypeOperatorFunction<ListenEvent<SanityDocument>> {
45
+ const {ttl = DEDUPE_TTL, maxEntries = DEDUPE_MAX_ENTRIES} = options ?? {}
46
+
47
+ return (source) =>
48
+ defer(() => {
49
+ // insertion-ordered map of dedupe key -> expiry timestamp
50
+ const seen = new Map<string, number>()
51
+
52
+ return source.pipe(
53
+ filter((event) => {
54
+ if (event.type !== 'mutation') return true
55
+
56
+ const key = `${event.transactionId}#${event.documentId}`
57
+ const now = Date.now()
58
+
59
+ const expiry = seen.get(key)
60
+ if (expiry !== undefined && expiry > now) return false
61
+
62
+ seen.delete(key)
63
+ seen.set(key, now + ttl)
64
+
65
+ for (const [existingKey, existingExpiry] of seen) {
66
+ if (seen.size <= maxEntries && existingExpiry > now) break
67
+ seen.delete(existingKey)
68
+ }
69
+
70
+ return true
71
+ }),
72
+ )
73
+ })
74
+ }
75
+
76
+ interface GroupTransactionEventsOptions {
77
+ flushDeadline?: number
78
+ maxHeldEvents?: number
79
+ }
80
+
81
+ /**
82
+ * Buffers the mutation events of multi-document transactions (e.g. a publish
83
+ * touching both the draft and the published document) until every event of
84
+ * the transaction has arrived, then emits them contiguously. Without this,
85
+ * a rebase can run between the two halves of a transaction and observe the
86
+ * dataset in a state that never existed on the server (draft deleted, but
87
+ * published not yet updated).
88
+ *
89
+ * The backend's streaming listener pipeline delivers a transaction's events
90
+ * contiguously and in order, but the legacy pipeline can interleave
91
+ * concurrent transactions. So while a multi-document transaction is
92
+ * incomplete, mutation events from other transactions are held (in arrival
93
+ * order) rather than emitted, and released right after the transaction
94
+ * completes; released events are re-processed so a held multi-document
95
+ * transaction is grouped too. Per-document `previousRev` sequencing
96
+ * downstream tolerates the reordering this introduces.
97
+ *
98
+ * Escape hatches so a lost event degrades to ungrouped delivery instead of
99
+ * stalling the stream: everything is released in arrival order when a
100
+ * non-mutation event (e.g. reconnect/welcome) arrives, when more than
101
+ * `maxHeldEvents` are held, or after `flushDeadline` milliseconds.
102
+ * Single-event transactions pass through untouched when nothing is buffered.
103
+ *
104
+ * @internal
105
+ */
106
+ export function groupTransactionEvents(
107
+ options?: GroupTransactionEventsOptions,
108
+ ): MonoTypeOperatorFunction<ListenEvent<SanityDocument>> {
109
+ const {flushDeadline = GROUP_FLUSH_DEADLINE, maxHeldEvents = GROUP_MAX_HELD_EVENTS} =
110
+ options ?? {}
111
+
112
+ return (source) =>
113
+ new Observable<ListenEvent<SanityDocument>>((subscriber) => {
114
+ interface OpenTransaction {
115
+ transactionId: string
116
+ events: MutationEvent[]
117
+ total: number
118
+ timer: ReturnType<typeof setTimeout>
119
+ }
120
+
121
+ let open: OpenTransaction | null = null
122
+ let held: MutationEvent[] = []
123
+
124
+ // emits the open transaction's events (complete or not) without
125
+ // releasing the held queue; callers decide what happens to `held`
126
+ const closeOpen = () => {
127
+ if (!open) return
128
+ clearTimeout(open.timer)
129
+ const {events} = open
130
+ open = null
131
+ for (const event of events) subscriber.next(event)
132
+ }
133
+
134
+ // re-processes the held queue so a held multi-document transaction
135
+ // gets grouped in turn. processing may open a new transaction and top
136
+ // up a new held queue; each drain only touches its own snapshot, so
137
+ // this terminates
138
+ const releaseHeld = () => {
139
+ const queue = held
140
+ held = []
141
+ for (const event of queue) processEvent(event)
142
+ }
143
+
144
+ const releaseEverything = () => {
145
+ closeOpen()
146
+ const queue = held
147
+ held = []
148
+ for (const event of queue) subscriber.next(event)
149
+ }
150
+
151
+ const processEvent = (event: ListenEvent<SanityDocument>) => {
152
+ if (event.type !== 'mutation') {
153
+ // connection-level events (welcome/welcomeback/reset/reconnect)
154
+ // reset the world downstream; release everything before them
155
+ releaseEverything()
156
+ subscriber.next(event)
157
+ return
158
+ }
159
+
160
+ const isMultiDocument = (event.transactionTotalEvents ?? 0) > 1
161
+
162
+ if (!open) {
163
+ if (!isMultiDocument) {
164
+ subscriber.next(event)
165
+ return
166
+ }
167
+ // isMultiDocument guarantees total >= 2, so a freshly opened
168
+ // transaction is always incomplete; completion is checked when
169
+ // subsequent events of the same transaction arrive below
170
+ open = {
171
+ transactionId: event.transactionId,
172
+ events: [event],
173
+ total: event.transactionTotalEvents,
174
+ timer: setCleanupTimeout(() => {
175
+ closeOpen()
176
+ releaseHeld()
177
+ }, flushDeadline),
178
+ }
179
+ return
180
+ }
181
+
182
+ if (event.transactionId === open.transactionId) {
183
+ open.events.push(event)
184
+ if (open.events.length >= open.total) {
185
+ closeOpen()
186
+ releaseHeld()
187
+ }
188
+ return
189
+ }
190
+
191
+ // an event from another transaction while one is incomplete: hold it
192
+ // (legacy listener pipeline interleaving) instead of giving up on the
193
+ // open transaction
194
+ held.push(event)
195
+ if (held.length > maxHeldEvents) releaseEverything()
196
+ }
197
+
198
+ const subscription = source.subscribe({
199
+ next: processEvent,
200
+ error: (error) => {
201
+ releaseEverything()
202
+ subscriber.error(error)
203
+ },
204
+ complete: () => {
205
+ releaseEverything()
206
+ subscriber.complete()
207
+ },
208
+ })
209
+
210
+ return () => {
211
+ if (open) clearTimeout(open.timer)
212
+ open = null
213
+ held = []
214
+ subscription.unsubscribe()
215
+ }
216
+ })
217
+ }
@@ -274,10 +274,51 @@ describe('set', () => {
274
274
  expect(output).toEqual({a: 1, nonexistent: {path: 999}})
275
275
  })
276
276
 
277
- it('creates an item from a key constraint if the key is not present', () => {
277
+ // Content Lake never creates array items when setting: a keyed or indexed
278
+ // segment that does not resolve is a silent no-op server-side. The applier
279
+ // used to append a degenerate item (without the key, even), which
280
+ // fabricated array items locally that would never exist on the server.
281
+ it('does not create an item from a key constraint if the key is not present', () => {
278
282
  const input = {items: [{_key: 'item1'}]}
279
283
  const output = set(input, {'items[_key=="item2"]': {_key: 'item2'}})
280
- expect(output).toEqual({items: [{_key: 'item1'}, {_key: 'item2'}]})
284
+ expect(output).toEqual({items: [{_key: 'item1'}]})
285
+ })
286
+
287
+ it('does not create an item when setting a property under an unresolvable key constraint', () => {
288
+ const input = {
289
+ blocks: [
290
+ {
291
+ _key: 'b1',
292
+ children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}],
293
+ },
294
+ ],
295
+ }
296
+ const output = set(input, {'blocks[_key=="b1"].children[_key=="missing"].marks': ['mark1']})
297
+ expect(output).toEqual(input)
298
+ })
299
+
300
+ it('does not create an item when setting through an out-of-bounds index', () => {
301
+ const input = {items: [{_key: 'item1', value: 1}]}
302
+ const output = set(input, {'items[3].value': 99})
303
+ expect(output).toEqual(input)
304
+ })
305
+
306
+ it('still sets a missing property on an existing keyed item', () => {
307
+ const input = {items: [{_key: 'item1'}]}
308
+ const output = set(input, {'items[_key=="item1"].value': 42})
309
+ expect(output).toEqual({items: [{_key: 'item1', value: 42}]})
310
+ })
311
+
312
+ it('does not create anything when an array-addressing segment targets a non-array', () => {
313
+ const input = {items: {nested: true}}
314
+ const output = set(input, {'items[_key=="item1"].value': 42})
315
+ expect(output).toEqual(input)
316
+ })
317
+
318
+ it('does not create an item when setting through a negative out-of-bounds index', () => {
319
+ const input = {items: [{_key: 'item1', value: 1}]}
320
+ const output = set(input, {'items[-5].value': 99})
321
+ expect(output).toEqual(input)
281
322
  })
282
323
  })
283
324
 
@@ -299,6 +340,12 @@ describe('setIfMissing', () => {
299
340
  const output = setIfMissing(input, {'a.b': 42})
300
341
  expect(output).toEqual({a: {b: 1}})
301
342
  })
343
+
344
+ it('does not create an item under an unresolvable key constraint', () => {
345
+ const input = {items: [{_key: 'item1'}]}
346
+ const output = setIfMissing(input, {'items[_key=="missing"].value': 1})
347
+ expect(output).toEqual(input)
348
+ })
302
349
  })
303
350
 
304
351
  describe('unset', () => {
@@ -518,6 +565,33 @@ describe('diffMatchPatch', () => {
518
565
  expect(output).toEqual(input)
519
566
  })
520
567
 
568
+ it('applies the patch when its offsets miss a multi-byte boundary', () => {
569
+ // `\u00f8` is two bytes, so this patch's offsets land inside it and the byte
570
+ // walk overshoots. The default is to throw, which escapes the rebase loop in
571
+ // `reducers.ts` and kills the whole outgoing-actions pipeline: the instance
572
+ // then silently stops writing every document. The patch itself is fine and
573
+ // still applies.
574
+ const input = {foo: 'abcdefg\u00f8'}
575
+ const patch = '@@ -9,1 +9,2 @@\n \u00f8\n+!\n'
576
+
577
+ expect(() => diffMatchPatch(input, {foo: patch})).not.toThrow()
578
+ expect(diffMatchPatch(input, {foo: patch})).toEqual({foo: 'abcdefg\u00f8!'})
579
+ })
580
+
581
+ it('still patches the other properties in the same mutation', () => {
582
+ const input = {multibyte: 'abcdefg\u00f8', ascii: 'the quick brown fox'}
583
+
584
+ const output = diffMatchPatch(input, {
585
+ multibyte: '@@ -9,1 +9,2 @@\n \u00f8\n+!\n',
586
+ ascii: '@@ -13,7 +13,7 @@\n own \n-fox\n+cat\n',
587
+ })
588
+
589
+ expect(output).toEqual({
590
+ multibyte: 'abcdefg\u00f8!',
591
+ ascii: 'the quick brown cat',
592
+ })
593
+ })
594
+
521
595
  it('applies a diff-match-patch that makes no changes', () => {
522
596
  const input = {foo: 'unchanged'}
523
597
  const patch = '@@ -1,9 +1,9 @@\n unchanged\n'
@@ -175,6 +175,54 @@ export const ensureArrayKeysDeep = memoize(<R>(input: R): R => {
175
175
  return Object.fromEntries(entries) as R
176
176
  })
177
177
 
178
+ /**
179
+ * Whether every array-addressing segment (keyed `[_key=="…"]` or numeric
180
+ * index) in the given match path resolves to an existing array item.
181
+ *
182
+ * This mirrors Content Lake, which auto-creates missing *object* properties
183
+ * when setting but never creates *array items*:
184
+ *
185
+ * ```js
186
+ * // object properties are created, even nested ones:
187
+ * set({}, {'meta.author.name': 'Ada'})
188
+ * // -> {meta: {author: {name: 'Ada'}}} (applies)
189
+ *
190
+ * // array items are not:
191
+ * set({items: [{_key: 'a'}]}, {'items[_key=="b"].title': 'x'})
192
+ * // -> {items: [{_key: 'a'}]} (no-op)
193
+ * ```
194
+ *
195
+ * Without this filter, the second example fabricated a degenerate item
196
+ * (`{title: 'x'}`, no `_key`) that would never exist on the server, which
197
+ * is how concurrent Portable Text edits grew phantom span-less children.
198
+ */
199
+ function resolvesArraySegments(input: unknown, path: SingleValuePath): boolean {
200
+ let current: unknown = input
201
+ for (const segment of path) {
202
+ if (typeof segment === 'string') {
203
+ // missing object properties (nested ones included) are created
204
+ // server-side, so a string segment may descend into undefined
205
+ current =
206
+ typeof current === 'object' && current !== null && !Array.isArray(current)
207
+ ? (current as Record<string, unknown>)[segment]
208
+ : undefined
209
+ continue
210
+ }
211
+ // keyed and numeric segments address array items, which are never created
212
+ if (!Array.isArray(current)) return false
213
+ if (isKeySegment(segment)) {
214
+ const index = getIndexForKey(current, segment._key)
215
+ if (index === undefined) return false
216
+ current = current[index]
217
+ } else {
218
+ const index = segment < 0 ? current.length + segment : segment
219
+ if (!(index in current)) return false
220
+ current = current[index]
221
+ }
222
+ }
223
+ return true
224
+ }
225
+
178
226
  /**
179
227
  * Given an input object and a record of path expressions to values, this
180
228
  * function will set each match with the given value.
@@ -198,6 +246,7 @@ export function set(input: unknown, pathExpressionValues: Record<string, unknown
198
246
  replacementValue,
199
247
  })),
200
248
  )
249
+ .filter(({path}) => resolvesArraySegments(input, path))
201
250
  .reduce((acc, {path, replacementValue}) => setDeep(acc, path, replacementValue), input)
202
251
 
203
252
  return ensureArrayKeysDeep(result)
@@ -231,6 +280,7 @@ export function setIfMissing(
231
280
  }))
232
281
  })
233
282
  .filter((matchEntry) => matchEntry.value === null || matchEntry.value === undefined)
283
+ .filter(({path}) => resolvesArraySegments(input, path))
234
284
  .reduce((acc, {path, replacementValue}) => setDeep(acc, path, replacementValue), input)
235
285
 
236
286
  return ensureArrayKeysDeep(result)
@@ -567,7 +617,16 @@ export function diffMatchPatch(
567
617
  )
568
618
  }
569
619
 
570
- const [nextValue] = applyPatches(parsePatch(dmp), value)
620
+ // Offsets arrive as UTF-8 byte positions and can overshoot when they
621
+ // land inside a multi-byte character, which happens once the string a
622
+ // patch was computed against has diverged. The default is to throw, and
623
+ // that throw escapes the rebase loop in `reducers.ts`, kills the
624
+ // outgoing-actions pipeline, and leaves the instance silently dropping
625
+ // every write. Clamping to the nearest index applies the patch instead,
626
+ // which is what the Studio and the Portable Text editor already do.
627
+ const [nextValue] = applyPatches(parsePatch(dmp), value, {
628
+ allowExceedingIndices: true,
629
+ })
571
630
  return {path, value: nextValue}
572
631
  })
573
632
  .reduce((acc, {path, value}) => setDeep(acc, path, value), input)