@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
@@ -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
- }
@@ -1,20 +1,26 @@
1
1
  import {type SanityClient} from '@sanity/client'
2
2
  import {of} from 'rxjs'
3
- import {describe, it} from 'vitest'
3
+ import {afterEach, beforeEach, describe, it} from 'vitest'
4
4
 
5
5
  import {getClientState} from '../client/clientStore'
6
- import {createSanityInstance} from '../store/createSanityInstance'
6
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
7
  import {type StateSource} from '../store/createStateSourceAction'
8
- import {resolveDatasets} from './datasets'
8
+ import {datasets} from './datasets'
9
9
 
10
10
  vi.mock('../client/clientStore')
11
11
 
12
12
  describe('datasets', () => {
13
- it('calls the `client.observable.datasets.list` method on the client and returns the result', async () => {
14
- const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
15
- const datasets = [{id: 'a'}, {id: 'b'}]
16
- const list = vi.fn().mockReturnValue(of(datasets))
13
+ let instance: SanityInstance
14
+
15
+ beforeEach(() => {
16
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
17
+ })
17
18
 
19
+ afterEach(() => {
20
+ instance.dispose()
21
+ })
22
+
23
+ const mockClientWithList = (list: ReturnType<typeof vi.fn>) => {
18
24
  const mockClient = {
19
25
  observable: {
20
26
  datasets: {list} as unknown as SanityClient['observable']['datasets'],
@@ -24,9 +30,38 @@ describe('datasets', () => {
24
30
  vi.mocked(getClientState).mockReturnValue({
25
31
  observable: of(mockClient),
26
32
  } as StateSource<SanityClient>)
33
+ }
34
+
35
+ it('calls the `client.observable.datasets.list` method on the client and returns the result', async () => {
36
+ const list = vi.fn().mockReturnValue(of([{id: 'a'}, {id: 'b'}]))
37
+ mockClientWithList(list)
27
38
 
28
- const result = await resolveDatasets(instance)
29
- expect(result).toEqual(datasets)
39
+ const result = await datasets.resolveState(instance)
40
+ expect(result).toEqual([{id: 'a'}, {id: 'b'}])
30
41
  expect(list).toHaveBeenCalled()
42
+ expect(getClientState).toHaveBeenCalledWith(
43
+ instance,
44
+ expect.objectContaining({projectId: 'p', useProjectHostname: true}),
45
+ )
46
+ })
47
+
48
+ it('reads datasets for an explicit projectId over the instance config', async () => {
49
+ const list = vi.fn().mockReturnValue(of([]))
50
+ mockClientWithList(list)
51
+
52
+ await datasets.resolveState(instance, {projectId: 'other'})
53
+ expect(getClientState).toHaveBeenCalledWith(
54
+ instance,
55
+ expect.objectContaining({projectId: 'other'}),
56
+ )
57
+ })
58
+
59
+ it('rejects when no projectId can be resolved', async () => {
60
+ const bare = createSanityInstance({})
61
+ // async wrapper: the missing-projectId error is thrown synchronously from getKey
62
+ await expect(async () => datasets.resolveState(bare)).rejects.toThrow(
63
+ 'A projectId is required to use the datasets API.',
64
+ )
65
+ bare.dispose()
31
66
  })
32
67
  })
@@ -1,33 +1,34 @@
1
+ import {type DatasetsResponse} from '@sanity/client'
1
2
  import {switchMap} from 'rxjs'
2
3
 
3
4
  import {getClientState} from '../client/clientStore'
4
5
  import {type ProjectHandle} from '../config/sanityConfig'
5
- import {createFetcherStore} from '../utils/createFetcherStore'
6
+ import {type SanityInstance} from '../store/createSanityInstance'
7
+ import {defineFetcher} from '../store/fetcherStore'
6
8
 
7
9
  const API_VERSION = 'v2025-02-19'
8
10
 
9
- /** @public */
10
- export const datasets = createFetcherStore({
11
- name: 'Datasets',
12
- getKey: (instance, options?: ProjectHandle) => {
13
- const projectId = options?.projectId ?? instance.config.projectId
14
- if (!projectId) {
15
- throw new Error('A projectId is required to use the project API.')
16
- }
17
- return projectId
18
- },
19
- fetcher: (instance) => (options?: ProjectHandle) => {
20
- return getClientState(instance, {
11
+ function resolveProjectId(instance: SanityInstance, options?: ProjectHandle): string {
12
+ const projectId = options?.projectId ?? instance.config.projectId
13
+ if (!projectId) {
14
+ throw new Error('A projectId is required to use the datasets API.')
15
+ }
16
+ return projectId
17
+ }
18
+
19
+ /**
20
+ * Fetcher for a project's datasets (`GET /datasets`), on the shared fetcher
21
+ * cache.
22
+ *
23
+ * @internal
24
+ */
25
+ export const datasets = defineFetcher<[options?: ProjectHandle], DatasetsResponse>({
26
+ name: 'datasets',
27
+ getKey: resolveProjectId,
28
+ fetch: (instance) => (options) =>
29
+ getClientState(instance, {
21
30
  apiVersion: API_VERSION,
22
- // non-null assertion is fine because we check above
23
- projectId: (options?.projectId ?? instance.config.projectId)!,
31
+ projectId: resolveProjectId(instance, options),
24
32
  useProjectHostname: true,
25
- }).observable.pipe(switchMap((client) => client.observable.datasets.list()))
26
- },
33
+ }).observable.pipe(switchMap((client) => client.observable.datasets.list())),
27
34
  })
28
-
29
- /** @public */
30
- export const getDatasetsState = datasets.getState
31
-
32
- /** @public */
33
- export const resolveDatasets = datasets.resolveState
@@ -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 () => {
@@ -9,9 +9,26 @@ export const DOCUMENT_STATE_CLEAR_DELAY = 1000
9
9
  export const INITIAL_OUTGOING_THROTTLE_TIME = 1000
10
10
  export const API_VERSION = 'v2025-05-06'
11
11
 
12
+ /**
13
+ * How long a submitted transaction's revision is kept around waiting to be
14
+ * verified by its listener echo. After this, the transaction is no longer
15
+ * treated as in flight and a document held onto only for that verification can
16
+ * be evicted. Sized to match the listener's own chaining deadline, after which
17
+ * an echo that has not arrived is not going to.
18
+ */
19
+ export const UNVERIFIED_REVISION_RETENTION_TIME = 30_000
20
+
12
21
  /**
13
22
  * Base delay (ms) before retrying a document listener after an `OutOfSyncError`.
14
23
  * Backoff doubles on each successive retry, capped at {@link OUT_OF_SYNC_RETRY_MAX_DELAY}.
15
24
  */
16
25
  export const OUT_OF_SYNC_RETRY_BASE_DELAY = 500
17
26
  export const OUT_OF_SYNC_RETRY_MAX_DELAY = 10_000
27
+
28
+ /**
29
+ * Base delay (ms) before retrying the dataset ACL fetch after a transient
30
+ * failure (a network error or 5xx response). Backoff doubles on each
31
+ * successive retry, capped at {@link ACL_RETRY_MAX_DELAY}.
32
+ */
33
+ export const ACL_RETRY_BASE_DELAY = 500
34
+ 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'
@@ -345,6 +357,75 @@ it('emits remote-patches events distinguishing own and foreign transactions', as
345
357
  state2Unsubscribe()
346
358
  })
347
359
 
360
+ it('labels an echo that arrives after the last subscriber left as its own', async () => {
361
+ const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
362
+ const draftId = getDraftId(DocumentId(doc.documentId))
363
+ const state = getDocumentState(instance, doc)
364
+ const unsubscribe = state.subscribe()
365
+ await vi.waitUntil(() => state.getCurrent() !== undefined)
366
+ const previousRev = state.getCurrent()?._rev
367
+
368
+ // ack without emitting the listener echo, so the echo can be delivered later
369
+ // and out of band, which is the ordering the real API produces
370
+ const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
371
+ vi.mocked(client.action).mockImplementation(
372
+ async (_input, {transactionId = crypto.randomUUID()} = {}) => ({transactionId}),
373
+ )
374
+
375
+ const patchEvents: DocumentRemotePatchesEvent[] = []
376
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
377
+ resource,
378
+ eventHandler: (e) => {
379
+ if (e.type === 'remote-patches') patchEvents.push(e)
380
+ },
381
+ })
382
+
383
+ const edited = await applyDocumentActions(instance, {
384
+ actions: [editDocument(doc, {set: {title: 'edited while leaving'}})],
385
+ resource,
386
+ })
387
+
388
+ // the last subscriber leaves before the ack comes back, which used to delete
389
+ // the document state (and with it the memory of our own transaction) as soon
390
+ // as the ack released the transaction's subscription
391
+ unsubscribe()
392
+ await new Promise((resolve) => setTimeout(resolve, 50))
393
+ await edited.submitted()
394
+
395
+ const {events: listenerEvents} = (
396
+ createSharedListener as () => {
397
+ dispose: () => void
398
+ events: Subject<ListenEvent<SanityDocument>>
399
+ }
400
+ )()
401
+ listenerEvents.next({
402
+ type: 'mutation',
403
+ documentId: draftId,
404
+ eventId: `${edited.transactionId}#${draftId}`,
405
+ identity: 'example-user',
406
+ mutations: [{patch: {id: draftId, set: {title: 'edited while leaving'}}}],
407
+ timestamp: new Date().toISOString(),
408
+ transactionId: edited.transactionId,
409
+ transactionCurrentEvent: 1,
410
+ transactionTotalEvents: 1,
411
+ transition: 'update',
412
+ visibility: 'query',
413
+ previousRev,
414
+ resultRev: edited.transactionId,
415
+ } satisfies MutationEvent)
416
+ await vi.waitUntil(() => patchEvents.length > 0)
417
+
418
+ expect(patchEvents).toHaveLength(1)
419
+ expect(patchEvents[0]).toMatchObject({
420
+ documentId: draftId,
421
+ transactionId: edited.transactionId,
422
+ origin: 'local',
423
+ })
424
+
425
+ unsubscribeEvents()
426
+ vi.mocked(client.action).mockImplementation(clientActionMockImplementation)
427
+ })
428
+
348
429
  it('handles concurrent edits and resolves conflicts', async () => {
349
430
  const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
350
431
  const state1 = getDocumentState(instance1, doc)
@@ -917,24 +998,101 @@ it('does not send credentials with the dataset ACL request', async () => {
917
998
  expect(aclCall![0]).not.toHaveProperty('withCredentials')
918
999
  })
919
1000
 
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
- },
1001
+ it('retries transient dataset ACL fetch failures instead of failing fatally', async () => {
1002
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
1003
+
1004
+ // the first attempt fails like a dropped connection; subsequent attempts succeed
1005
+ let aclAttempts = 0
1006
+ client.observable.request = vi.fn().mockReturnValue(
1007
+ defer(() => {
1008
+ aclAttempts++
1009
+ if (aclAttempts === 1) return throwError(() => new Error('socket hang up'))
1010
+ return of(datasetAcl)
1011
+ }),
1012
+ )
1013
+
1014
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1015
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
1016
+
1017
+ expect(result).toEqual({allowed: true})
1018
+ expect(aclAttempts).toBe(2)
1019
+
1020
+ // the store never entered the fatal error state
1021
+ const documentState = getDocumentState(instance, doc)
1022
+ expect(() => documentState.getCurrent()).not.toThrow()
1023
+ })
1024
+
1025
+ it('does not retry dataset ACL fetch failures caused by 4xx client errors', async () => {
1026
+ const forbidden = new ClientError({
1027
+ statusCode: 403,
1028
+ headers: {},
1029
+ body: {error: 'Forbidden', message: 'Session does not have access to this dataset'},
927
1030
  })
928
1031
 
1032
+ let aclAttempts = 0
1033
+ client.observable.request = vi.fn().mockReturnValue(
1034
+ defer(() => {
1035
+ aclAttempts++
1036
+ return throwError(() => forbidden)
1037
+ }),
1038
+ )
1039
+
1040
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1041
+ const documentState = getDocumentState(instance, doc)
1042
+ const unsubscribe = documentState.subscribe()
1043
+
1044
+ // the error is fatal: it surfaces through the store instead of being retried
1045
+ await vi.waitFor(() => {
1046
+ expect(() => documentState.getCurrent()).toThrow(
1047
+ 'Forbidden - Session does not have access to this dataset',
1048
+ )
1049
+ })
1050
+ expect(aclAttempts).toBe(1)
1051
+
1052
+ unsubscribe()
1053
+ })
1054
+
1055
+ it('retries dataset ACL fetch failures caused by 429 rate-limit errors', async () => {
1056
+ const rateLimited = new ClientError({
1057
+ statusCode: 429,
1058
+ headers: {},
1059
+ body: {error: 'Too Many Requests', message: 'Rate limit exceeded'},
1060
+ })
1061
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
1062
+
1063
+ // the first attempt is rate limited; subsequent attempts succeed
1064
+ let aclAttempts = 0
1065
+ client.observable.request = vi.fn().mockReturnValue(
1066
+ defer(() => {
1067
+ aclAttempts++
1068
+ if (aclAttempts === 1) return throwError(() => rateLimited)
1069
+ return of(datasetAcl)
1070
+ }),
1071
+ )
1072
+
1073
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1074
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
1075
+
1076
+ expect(result).toEqual({allowed: true})
1077
+ expect(aclAttempts).toBe(2)
1078
+
1079
+ // the store never entered the fatal error state
1080
+ const documentState = getDocumentState(instance, doc)
1081
+ expect(() => documentState.getCurrent()).not.toThrow()
1082
+ })
1083
+
1084
+ it('fetches ACL for MediaLibraryResource', async () => {
1085
+ const mediaLibraryInstance = createSanityInstance()
1086
+
929
1087
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
930
1088
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
931
1089
 
932
1090
  const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
933
- const mediaLibrarySource = {mediaLibraryId: 'test-media-library'}
1091
+ const mediaLibraryResource = {mediaLibraryId: 'test-media-library'}
934
1092
 
935
1093
  const result = await resolvePermissions(mediaLibraryInstance, {
936
1094
  actions: [createDocument(doc)],
937
- resource: mediaLibrarySource,
1095
+ resource: mediaLibraryResource,
938
1096
  })
939
1097
 
940
1098
  expect(result).toEqual({allowed: true})
@@ -942,23 +1100,17 @@ it('fetches ACL for MediaLibraryResource', async () => {
942
1100
  })
943
1101
 
944
1102
  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
- })
1103
+ const canvasInstance = createSanityInstance()
952
1104
 
953
1105
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
954
1106
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
955
1107
 
956
1108
  const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
957
- const canvasSource = {canvasId: 'test-canvas'}
1109
+ const canvasResource = {canvasId: 'test-canvas'}
958
1110
 
959
1111
  const result = await resolvePermissions(canvasInstance, {
960
1112
  actions: [createDocument(doc)],
961
- resource: canvasSource,
1113
+ resource: canvasResource,
962
1114
  })
963
1115
 
964
1116
  expect(result).toEqual({allowed: true})
@@ -1250,6 +1402,8 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
1250
1402
  DOCUMENT_STATE_CLEAR_DELAY: 25,
1251
1403
  OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
1252
1404
  OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
1405
+ ACL_RETRY_BASE_DELAY: 0,
1406
+ ACL_RETRY_MAX_DELAY: 0,
1253
1407
  }
1254
1408
  })
1255
1409
 
@@ -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 = {