@sanity/sdk 3.0.0-rc.1 → 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 (120) 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-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 -2335
  29. package/dist/index.js +4048 -3874
  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 +13 -19
  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 +38 -86
  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/document/applyDocumentActions.test.ts +8 -12
  77. package/src/document/documentConstants.ts +8 -0
  78. package/src/document/documentStore.test.ts +104 -19
  79. package/src/document/documentStore.ts +36 -5
  80. package/src/document/patchOperations.test.ts +27 -0
  81. package/src/document/patchOperations.ts +10 -1
  82. package/src/document/permissions.test.ts +3 -1
  83. package/src/document/reducers.test.ts +133 -0
  84. package/src/document/reducers.ts +18 -3
  85. package/src/document/util.test.ts +46 -0
  86. package/src/document/util.ts +37 -1
  87. package/src/installations/installations.test-d.ts +5 -0
  88. package/src/installations/installations.ts +4 -0
  89. package/src/presence/bifurTransport.test.ts +155 -3
  90. package/src/presence/bifurTransport.ts +79 -12
  91. package/src/presence/paths.test.ts +70 -0
  92. package/src/presence/paths.ts +41 -0
  93. package/src/presence/presenceStore.test.ts +822 -2
  94. package/src/presence/presenceStore.ts +518 -102
  95. package/src/presence/types.ts +175 -3
  96. package/src/preview/types.ts +0 -25
  97. package/src/projection/projectionStore.test.ts +0 -8
  98. package/src/projection/types.ts +0 -8
  99. package/src/query/queryStore.ts +7 -6
  100. package/src/store/createActionBinder.test.ts +51 -29
  101. package/src/store/createActionBinder.ts +16 -62
  102. package/src/store/createSanityInstance.test.ts +0 -68
  103. package/src/store/createSanityInstance.ts +0 -63
  104. package/src/store/createStateSourceAction.ts +1 -1
  105. package/src/users/reducers.test.ts +0 -3
  106. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
  107. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  108. package/dist/_chunks-es/_internal.js +0 -134
  109. package/dist/_chunks-es/_internal.js.map +0 -1
  110. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
  111. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  112. package/dist/_chunks-es/telemetryManager.js +0 -93
  113. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  114. package/dist/index.d.ts.map +0 -1
  115. package/src/applications/userApplications.test.ts +0 -126
  116. package/src/applications/userApplications.ts +0 -261
  117. package/src/preview/getPreviewState.test.ts +0 -137
  118. package/src/preview/getPreviewState.ts +0 -69
  119. package/src/preview/resolvePreview.test.ts +0 -64
  120. package/src/preview/resolvePreview.ts +0 -43
@@ -73,14 +73,11 @@ export interface DatasetHandle<TDataset extends string = string, TProjectId exte
73
73
  dataset?: TDataset
74
74
  /**
75
75
  * @beta
76
- * Explicit resource object to use for this operation.
76
+ * Explicit resource to use. On an individual operation this overrides any default;
77
+ * on a `SanityConfig` it becomes the instance's default resource. Unlike
78
+ * `projectId`/`dataset`, this can also be a media library or canvas resource.
77
79
  */
78
80
  resource?: DocumentResource
79
- /**
80
- * @deprecated Use `resource` instead.
81
- * @beta
82
- */
83
- source?: DocumentResource
84
81
  }
85
82
 
86
83
  /**
@@ -148,27 +145,6 @@ export interface SanityConfig extends DatasetHandle, PerspectiveHandle {
148
145
  * `SDKStudioContext` provider. Can also be set explicitly for programmatic use.
149
146
  */
150
147
  studio?: StudioConfig
151
-
152
- /**
153
- * Studio mode configuration for use of the SDK in a Sanity Studio.
154
- * @remarks Controls whether studio mode features are enabled.
155
- * @deprecated Use `studio` instead, which provides richer integration
156
- * with the Studio's workspace (auth token sync, etc.).
157
- */
158
- studioMode?: {
159
- enabled: boolean
160
- }
161
-
162
- /**
163
- * @beta
164
- * A list of named resources to use for this instance.
165
- */
166
- resources?: Record<string, DocumentResource>
167
- /**
168
- * @deprecated Use `resources` instead.
169
- * @beta
170
- */
171
- sources?: Record<string, DocumentResource>
172
148
  }
173
149
 
174
150
  /**
@@ -216,51 +192,3 @@ export function isMediaLibraryResource(
216
192
  export function isCanvasResource(resource: DocumentResource): resource is CanvasResource {
217
193
  return 'canvasId' in resource
218
194
  }
219
-
220
- /**
221
- * @deprecated Use `DocumentResource` instead.
222
- * @beta
223
- */
224
- export type DocumentSource = DocumentResource
225
-
226
- /**
227
- * @deprecated Use `DatasetResource` instead.
228
- * @beta
229
- */
230
- export type DatasetSource = DatasetResource
231
-
232
- /**
233
- * @deprecated Use `MediaLibraryResource` instead.
234
- * @beta
235
- */
236
- export type MediaLibrarySource = MediaLibraryResource
237
-
238
- /**
239
- * @deprecated Use `CanvasResource` instead.
240
- * @beta
241
- */
242
- export type CanvasSource = CanvasResource
243
-
244
- /**
245
- * @deprecated Use `isDatasetResource` instead.
246
- * @beta
247
- */
248
- export function isDatasetSource(source: DocumentSource): source is DatasetSource {
249
- return isDatasetResource(source)
250
- }
251
-
252
- /**
253
- * @deprecated Use `isMediaLibraryResource` instead.
254
- * @beta
255
- */
256
- export function isMediaLibrarySource(source: DocumentSource): source is MediaLibrarySource {
257
- return isMediaLibraryResource(source)
258
- }
259
-
260
- /**
261
- * @deprecated Use `isCanvasResource` instead.
262
- * @beta
263
- */
264
- export function isCanvasSource(source: DocumentSource): source is CanvasSource {
265
- return isCanvasResource(source)
266
- }
@@ -146,13 +146,10 @@ describe('applyDocumentActions', () => {
146
146
  await expect(applyPromise).rejects.toThrow('Simulated error')
147
147
  })
148
148
 
149
- it('matches parent instance via child when action projectId and dataset do not match child config', async () => {
150
- // Create a parent instance
151
- const parentInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
152
- // Create a child instance with different config
153
- const childInstance = parentInstance.createChild({projectId: 'child-p', dataset: 'child-d'})
154
- // Use the child instance in context
155
- // Create an action that refers to the parent's configuration
149
+ it('uses the explicit resource when it does not match the instance config', async () => {
150
+ // The instance is configured for one resource...
151
+ const scopedInstance = createSanityInstance({projectId: 'child-p', dataset: 'child-d'})
152
+ // ...while the action refers to a different one
156
153
  const action: DocumentAction = {
157
154
  type: 'document.edit',
158
155
  documentId: 'doc1',
@@ -161,14 +158,14 @@ describe('applyDocumentActions', () => {
161
158
  projectId: 'p',
162
159
  dataset: 'd',
163
160
  }
164
- // Call applyDocumentActions with the context using childInstance, but with action requiring parent's config
165
- const applyPromise = applyDocumentActions(childInstance, {
161
+ // The explicit `resource` option should win over the instance's own config
162
+ const applyPromise = applyDocumentActions(scopedInstance, {
166
163
  actions: [action],
167
164
  transactionId: 'txn-child-match',
168
165
  resource: {projectId: 'p', dataset: 'd'},
169
166
  })
170
167
 
171
- // Simulate an applied transaction on the parent's instance
168
+ // Simulate an applied transaction for the explicitly-passed resource
172
169
  const appliedTx: AppliedTransaction = {
173
170
  transactionId: 'txn-child-match',
174
171
  actions: [action],
@@ -199,8 +196,7 @@ describe('applyDocumentActions', () => {
199
196
  const submittedResult = await result.submitted()
200
197
  expect(submittedResult).toEqual(acceptedResult)
201
198
 
202
- childInstance.dispose()
203
- parentInstance.dispose()
199
+ scopedInstance.dispose()
204
200
  })
205
201
 
206
202
  it('normalizes actions for the bound resource before queueing', async () => {
@@ -15,3 +15,11 @@ export const API_VERSION = 'v2025-05-06'
15
15
  */
16
16
  export const OUT_OF_SYNC_RETRY_BASE_DELAY = 500
17
17
  export const OUT_OF_SYNC_RETRY_MAX_DELAY = 10_000
18
+
19
+ /**
20
+ * Base delay (ms) before retrying the dataset ACL fetch after a transient
21
+ * failure (a network error or 5xx response). Backoff doubles on each
22
+ * successive retry, capped at {@link ACL_RETRY_MAX_DELAY}.
23
+ */
24
+ export const ACL_RETRY_BASE_DELAY = 500
25
+ export const ACL_RETRY_MAX_DELAY = 10_000
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type BaseActionOptions,
3
3
  type BaseMutationOptions,
4
+ ClientError,
4
5
  type FilteredResponseQueryOptions,
5
6
  type ListenEvent,
6
7
  type MultipleActionResult,
@@ -17,7 +18,18 @@ import {diffValue} from '@sanity/diff-patch'
17
18
  import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
18
19
  import {type Mutation, type SanityDocument} from '@sanity/types'
19
20
  import {evaluate, parse} from 'groq-js'
20
- import {delay, first, firstValueFrom, from, Observable, of, ReplaySubject, Subject} from 'rxjs'
21
+ import {
22
+ defer,
23
+ delay,
24
+ first,
25
+ firstValueFrom,
26
+ from,
27
+ Observable,
28
+ of,
29
+ ReplaySubject,
30
+ Subject,
31
+ throwError,
32
+ } from 'rxjs'
21
33
  import {afterEach, beforeEach, expect, it, vi} from 'vitest'
22
34
 
23
35
  import {getClientState} from '../client/clientStore'
@@ -917,24 +929,101 @@ it('does not send credentials with the dataset ACL request', async () => {
917
929
  expect(aclCall![0]).not.toHaveProperty('withCredentials')
918
930
  })
919
931
 
920
- it('fetches ACL for MediaLibraryResource', async () => {
921
- const mediaLibraryInstance = createSanityInstance({
922
- projectId: 'p',
923
- dataset: 'd',
924
- resources: {
925
- 'media-library': {mediaLibraryId: 'test-media-library'},
926
- },
932
+ it('retries transient dataset ACL fetch failures instead of failing fatally', async () => {
933
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
934
+
935
+ // the first attempt fails like a dropped connection; subsequent attempts succeed
936
+ let aclAttempts = 0
937
+ client.observable.request = vi.fn().mockReturnValue(
938
+ defer(() => {
939
+ aclAttempts++
940
+ if (aclAttempts === 1) return throwError(() => new Error('socket hang up'))
941
+ return of(datasetAcl)
942
+ }),
943
+ )
944
+
945
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
946
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
947
+
948
+ expect(result).toEqual({allowed: true})
949
+ expect(aclAttempts).toBe(2)
950
+
951
+ // the store never entered the fatal error state
952
+ const documentState = getDocumentState(instance, doc)
953
+ expect(() => documentState.getCurrent()).not.toThrow()
954
+ })
955
+
956
+ it('does not retry dataset ACL fetch failures caused by 4xx client errors', async () => {
957
+ const forbidden = new ClientError({
958
+ statusCode: 403,
959
+ headers: {},
960
+ body: {error: 'Forbidden', message: 'Session does not have access to this dataset'},
961
+ })
962
+
963
+ let aclAttempts = 0
964
+ client.observable.request = vi.fn().mockReturnValue(
965
+ defer(() => {
966
+ aclAttempts++
967
+ return throwError(() => forbidden)
968
+ }),
969
+ )
970
+
971
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
972
+ const documentState = getDocumentState(instance, doc)
973
+ const unsubscribe = documentState.subscribe()
974
+
975
+ // the error is fatal: it surfaces through the store instead of being retried
976
+ await vi.waitFor(() => {
977
+ expect(() => documentState.getCurrent()).toThrow(
978
+ 'Forbidden - Session does not have access to this dataset',
979
+ )
927
980
  })
981
+ expect(aclAttempts).toBe(1)
982
+
983
+ unsubscribe()
984
+ })
985
+
986
+ it('retries dataset ACL fetch failures caused by 429 rate-limit errors', async () => {
987
+ const rateLimited = new ClientError({
988
+ statusCode: 429,
989
+ headers: {},
990
+ body: {error: 'Too Many Requests', message: 'Rate limit exceeded'},
991
+ })
992
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
993
+
994
+ // the first attempt is rate limited; subsequent attempts succeed
995
+ let aclAttempts = 0
996
+ client.observable.request = vi.fn().mockReturnValue(
997
+ defer(() => {
998
+ aclAttempts++
999
+ if (aclAttempts === 1) return throwError(() => rateLimited)
1000
+ return of(datasetAcl)
1001
+ }),
1002
+ )
1003
+
1004
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1005
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
1006
+
1007
+ expect(result).toEqual({allowed: true})
1008
+ expect(aclAttempts).toBe(2)
1009
+
1010
+ // the store never entered the fatal error state
1011
+ const documentState = getDocumentState(instance, doc)
1012
+ expect(() => documentState.getCurrent()).not.toThrow()
1013
+ })
1014
+
1015
+ it('fetches ACL for MediaLibraryResource', async () => {
1016
+ const mediaLibraryInstance = createSanityInstance()
928
1017
 
929
1018
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
930
1019
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
931
1020
 
932
1021
  const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
933
- const mediaLibrarySource = {mediaLibraryId: 'test-media-library'}
1022
+ const mediaLibraryResource = {mediaLibraryId: 'test-media-library'}
934
1023
 
935
1024
  const result = await resolvePermissions(mediaLibraryInstance, {
936
1025
  actions: [createDocument(doc)],
937
- resource: mediaLibrarySource,
1026
+ resource: mediaLibraryResource,
938
1027
  })
939
1028
 
940
1029
  expect(result).toEqual({allowed: true})
@@ -942,23 +1031,17 @@ it('fetches ACL for MediaLibraryResource', async () => {
942
1031
  })
943
1032
 
944
1033
  it('fetches ACL for CanvasResource', async () => {
945
- const canvasInstance = createSanityInstance({
946
- projectId: 'p',
947
- dataset: 'd',
948
- resources: {
949
- canvas: {canvasId: 'test-canvas'},
950
- },
951
- })
1034
+ const canvasInstance = createSanityInstance()
952
1035
 
953
1036
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
954
1037
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
955
1038
 
956
1039
  const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
957
- const canvasSource = {canvasId: 'test-canvas'}
1040
+ const canvasResource = {canvasId: 'test-canvas'}
958
1041
 
959
1042
  const result = await resolvePermissions(canvasInstance, {
960
1043
  actions: [createDocument(doc)],
961
- resource: canvasSource,
1044
+ resource: canvasResource,
962
1045
  })
963
1046
 
964
1047
  expect(result).toEqual({allowed: true})
@@ -1250,6 +1333,8 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
1250
1333
  DOCUMENT_STATE_CLEAR_DELAY: 25,
1251
1334
  OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
1252
1335
  OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
1336
+ ACL_RETRY_BASE_DELAY: 0,
1337
+ ACL_RETRY_MAX_DELAY: 0,
1253
1338
  }
1254
1339
  })
1255
1340
 
@@ -1,4 +1,4 @@
1
- import {type Action, type Mutation} from '@sanity/client'
1
+ import {type Action, ClientError, CorsOriginError, type Mutation} from '@sanity/client'
2
2
  import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
3
3
  import {jsonMatch} from '@sanity/json-match'
4
4
  import {type SanityDocument} from 'groq'
@@ -48,6 +48,8 @@ import {createStateSourceAction, type StateSource} from '../store/createStateSou
48
48
  import {defineStore, type StoreContext} from '../store/defineStore'
49
49
  import {type DocumentAction} from './actions'
50
50
  import {
51
+ ACL_RETRY_BASE_DELAY,
52
+ ACL_RETRY_MAX_DELAY,
51
53
  API_VERSION,
52
54
  INITIAL_OUTGOING_THROTTLE_TIME,
53
55
  OUT_OF_SYNC_RETRY_BASE_DELAY,
@@ -562,10 +564,39 @@ const subscribeToClientAndFetchDatasetAcl = ({
562
564
  return getClientState(instance, clientOptions)
563
565
  .observable.pipe(
564
566
  switchMap((client) =>
565
- client.observable.request<DatasetAcl>({
566
- uri,
567
- tag: 'acl.get',
568
- }),
567
+ client.observable
568
+ .request<DatasetAcl>({
569
+ uri,
570
+ tag: 'acl.get',
571
+ })
572
+ .pipe(
573
+ retry({
574
+ delay: (error, retryCount) => {
575
+ // 4xx responses and CORS misconfigurations are not transient —
576
+ // the server will keep rejecting the request, so rethrow to
577
+ // surface them as a fatal store error. 408 (request timeout)
578
+ // and 429 (rate limit) are the exceptions: they resolve on
579
+ // their own, so they are retried like network errors
580
+ const isTransientClientError =
581
+ error instanceof ClientError &&
582
+ (error.statusCode === 408 || error.statusCode === 429)
583
+ if (
584
+ (error instanceof ClientError && !isTransientClientError) ||
585
+ error instanceof CorsOriginError
586
+ ) {
587
+ return throwError(() => error)
588
+ }
589
+ // network errors (no status code), 408/429 responses, and 5xx
590
+ // responses are retried with exponential backoff so a transient
591
+ // failure during startup doesn't permanently brick the store
592
+ const backoff = Math.min(
593
+ ACL_RETRY_BASE_DELAY * 2 ** (retryCount - 1),
594
+ ACL_RETRY_MAX_DELAY,
595
+ )
596
+ return timer(backoff)
597
+ },
598
+ }),
599
+ ),
569
600
  ),
570
601
  tap((datasetAcl) => state.set('setGrants', {grants: createGrantsLookup(datasetAcl)})),
571
602
  )
@@ -565,6 +565,33 @@ describe('diffMatchPatch', () => {
565
565
  expect(output).toEqual(input)
566
566
  })
567
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
+
568
595
  it('applies a diff-match-patch that makes no changes', () => {
569
596
  const input = {foo: 'unchanged'}
570
597
  const patch = '@@ -1,9 +1,9 @@\n unchanged\n'
@@ -617,7 +617,16 @@ export function diffMatchPatch(
617
617
  )
618
618
  }
619
619
 
620
- 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
+ })
621
630
  return {path, value: nextValue}
622
631
  })
623
632
  .reduce((acc, {path, value}) => setDeep(acc, path, value), input)
@@ -266,7 +266,9 @@ describe('calculatePermissions', () => {
266
266
  _createdAt: '2025-01-01T00:00:00.000Z',
267
267
  _updatedAt: '2025-01-01T00:00:00.000Z',
268
268
  _rev: 'rev-1',
269
- _system: {createdBy},
269
+ // Content Lake sets `_system.createdBy` on canvas documents; the public
270
+ // `DocumentSystem` type doesn't declare it, so the literal needs a cast.
271
+ _system: {createdBy} as SanityDocument['_system'],
270
272
  })
271
273
 
272
274
  const editAction: DocumentAction = {
@@ -632,6 +632,60 @@ describe('revertOutgoingTransaction', () => {
632
632
  const docState = newState.documentStates[draftId]
633
633
  expect(docState?.unverifiedRevisions && docState.unverifiedRevisions['txnOut']).toBeUndefined()
634
634
  })
635
+
636
+ it('unregisters the reverted batch subscription ids, making documents with no other subscribers evictable', () => {
637
+ const draftId = getDraftId(DocumentId('doc1'))
638
+ const pubId = getPublishedId(DocumentId('doc1'))
639
+ const state: SyncTransactionState = {
640
+ queued: [],
641
+ applied: [],
642
+ outgoing: {
643
+ transactionId: 'txnRevert',
644
+ actions: [
645
+ {
646
+ type: 'document.edit',
647
+ documentId: 'doc1',
648
+ documentType: 'book',
649
+ patches: [{set: {foo: 'changed'}}],
650
+ },
651
+ ],
652
+ disableBatching: false,
653
+ batchedTransactionIds: ['txnRevert'],
654
+ outgoingActions: [],
655
+ outgoingMutations: [],
656
+ base: {},
657
+ working: {},
658
+ previous: {},
659
+ previousRevs: {},
660
+ timestamp: '2025-02-06T00:04:30.000Z',
661
+ },
662
+ grants,
663
+ documentStates: {
664
+ // only subscriber is the reverted transaction itself
665
+ [draftId]: {
666
+ id: draftId,
667
+ subscriptions: ['txnRevert'],
668
+ local: {...exampleDoc, _id: draftId, foo: 'changed', _rev: 'rev2'},
669
+ remote: {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'},
670
+ },
671
+ // also has a UI subscriber, so it should survive with only the
672
+ // transaction subscription removed
673
+ [pubId]: {
674
+ id: pubId,
675
+ subscriptions: ['txnRevert', 'sub-ui'],
676
+ local: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
677
+ remote: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
678
+ },
679
+ },
680
+ }
681
+ const newState = revertOutgoingTransaction(state)
682
+ expect(newState.outgoing).toBeUndefined()
683
+ // no subscribers remain, so the document state is evicted entirely
684
+ expect(newState.documentStates[draftId]).toBeUndefined()
685
+ // the UI subscription keeps the other document alive, but the reverted
686
+ // transaction id is gone from its subscriptions
687
+ expect(newState.documentStates[pubId]?.subscriptions).toEqual(['sub-ui'])
688
+ })
635
689
  })
636
690
 
637
691
  describe('applyRemoteDocument', () => {
@@ -1124,6 +1178,85 @@ describe('applyRemoteDocument', () => {
1124
1178
  })
1125
1179
  })
1126
1180
 
1181
+ describe('rebase over multi-byte text', () => {
1182
+ it('does not kill the pipeline when a plain edit rebases onto multi-byte text', () => {
1183
+ // The reported production failure: a byte-offset throw from
1184
+ // `@sanity/diff-match-patch` is a plain `Error`, and `createMutationApplier`
1185
+ // only converts to `ActionError` when `preserveOperations` is set. So for an
1186
+ // ordinary `editDocument` it escapes this rebase loop, reaches the rxjs
1187
+ // subscription, and the outgoing-actions pipeline for the whole instance
1188
+ // dies. Every later write is then dropped silently, for every document.
1189
+ const docId = getDraftId(DocumentId('doc1'))
1190
+ const originalDoc: SanityDocument = {
1191
+ ...exampleDoc,
1192
+ _id: docId,
1193
+ _rev: 'rev1',
1194
+ title: 'abcdefg\u00f8',
1195
+ }
1196
+ const locallyEditedDoc: SanityDocument = {
1197
+ ...originalDoc,
1198
+ title: 'abcdefg\u00f8!',
1199
+ _rev: 'txnLocal',
1200
+ }
1201
+
1202
+ const appliedTransaction: AppliedTransaction = {
1203
+ transactionId: 'txnLocal',
1204
+ actions: [
1205
+ {
1206
+ type: 'document.edit',
1207
+ documentId: 'doc1',
1208
+ documentType: 'author',
1209
+ patches: [{diffMatchPatch: {title: '@@ -9,1 +9,2 @@\n \u00f8\n+!\n'}}],
1210
+ },
1211
+ ],
1212
+ previous: {[docId]: originalDoc},
1213
+ base: {[docId]: originalDoc},
1214
+ working: {[docId]: locallyEditedDoc},
1215
+ previousRevs: {[docId]: 'rev1'},
1216
+ timestamp: '2025-02-06T00:16:00.000Z',
1217
+ outgoingActions: [],
1218
+ outgoingMutations: [],
1219
+ }
1220
+
1221
+ const initialState: SyncTransactionState = {
1222
+ queued: [],
1223
+ applied: [appliedTransaction],
1224
+ outgoing: undefined,
1225
+ grants,
1226
+ documentStates: {
1227
+ [docId]: {
1228
+ id: docId,
1229
+ subscriptions: ['sub1'],
1230
+ local: locallyEditedDoc,
1231
+ remote: originalDoc,
1232
+ unverifiedRevisions: {},
1233
+ },
1234
+ },
1235
+ }
1236
+
1237
+ // someone else changed the same field, so the local patch is rebased onto
1238
+ // a base its byte offsets were not computed against
1239
+ const remoteDoc: SanityDocument = {
1240
+ ...originalDoc,
1241
+ title: '\u00f8\u00f8\u00f8\u00f8\u00f8\u00f8',
1242
+ _rev: 'txnForeign',
1243
+ }
1244
+ const remote: RemoteDocument = {
1245
+ type: 'mutation',
1246
+ documentId: docId,
1247
+ document: remoteDoc,
1248
+ revision: 'txnForeign',
1249
+ previousRev: 'rev1',
1250
+ timestamp: '2025-02-06T00:17:00.000Z',
1251
+ mutations: [{patch: {id: docId, set: {title: '\u00f8\u00f8\u00f8\u00f8\u00f8\u00f8'}}}],
1252
+ }
1253
+
1254
+ const events = new Subject<DocumentEvent>()
1255
+
1256
+ expect(() => applyRemoteDocument(initialState, remote, events)).not.toThrow()
1257
+ })
1258
+ })
1259
+
1127
1260
  describe('rebase with preserved operations', () => {
1128
1261
  it('skips a preserved-operations transaction that fails to re-apply and emits rebase-error', () => {
1129
1262
  const docId = getDraftId(DocumentId('doc1'))
@@ -406,8 +406,23 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
406
406
 
407
407
  export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTransactionState {
408
408
  if (!prev.grants) return prev
409
+
410
+ // unregister the reverted batch's transaction-held subscriptions, symmetric
411
+ // with `cleanupOutgoingTransaction` on ack and `removeQueuedTransaction` on
412
+ // apply failure. without this, a reverted document's subscription IDs are
413
+ // never released and it can never be evicted
414
+ let base = prev
415
+ if (prev.outgoing) {
416
+ const ids = getDocumentIdsFromHandleLikes(prev.outgoing.actions)
417
+ for (const transactionId of prev.outgoing.batchedTransactionIds) {
418
+ for (const documentId of ids) {
419
+ base = removeSubscriptionIdFromDocument(base, documentId, transactionId)
420
+ }
421
+ }
422
+ }
423
+
409
424
  let working = Object.fromEntries(
410
- Object.entries(prev.documentStates).map(([documentId, documentState]) => [
425
+ Object.entries(base.documentStates).map(([documentId, documentState]) => [
411
426
  documentId,
412
427
  documentState?.remote,
413
428
  ]),
@@ -428,11 +443,11 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
428
443
  }
429
444
 
430
445
  return {
431
- ...prev,
446
+ ...base,
432
447
  applied: nextApplied,
433
448
  outgoing: undefined,
434
449
  documentStates: Object.fromEntries(
435
- Object.entries(prev.documentStates)
450
+ Object.entries(base.documentStates)
436
451
  .filter((e): e is [string, DocumentState] => !!e[1])
437
452
  .map(([documentId, {unverifiedRevisions = {}, local, ...documentState}]) => {
438
453
  const next: DocumentState = {