@sanity/sdk 2.11.1 → 2.13.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 (60) hide show
  1. package/dist/_chunks-dts/utils.d.ts +175 -19
  2. package/dist/_chunks-es/_internal.js +41 -26
  3. package/dist/_chunks-es/_internal.js.map +1 -1
  4. package/dist/_chunks-es/createGroqSearchFilter.js +15 -4
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  6. package/dist/_chunks-es/telemetryManager.js +25 -19
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -1
  8. package/dist/_chunks-es/version.js +1 -1
  9. package/dist/_exports/_internal.d.ts +27 -11
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +465 -131
  12. package/dist/index.js.map +1 -1
  13. package/package.json +11 -11
  14. package/src/_exports/index.ts +23 -2
  15. package/src/config/sanityConfig.ts +12 -0
  16. package/src/document/actions.test.ts +112 -1
  17. package/src/document/actions.ts +148 -1
  18. package/src/document/applyDocumentActions.test.ts +24 -0
  19. package/src/document/applyDocumentActions.ts +17 -5
  20. package/src/document/documentConstants.ts +7 -0
  21. package/src/document/documentStore.test.ts +69 -0
  22. package/src/document/documentStore.ts +42 -10
  23. package/src/document/events.test.ts +57 -2
  24. package/src/document/events.ts +43 -24
  25. package/src/document/listen.ts +1 -1
  26. package/src/document/permissions.test.ts +79 -0
  27. package/src/document/permissions.ts +8 -7
  28. package/src/document/processActions/create.ts +7 -4
  29. package/src/document/processActions/delete.ts +4 -4
  30. package/src/document/processActions/discard.ts +2 -2
  31. package/src/document/processActions/edit.ts +13 -47
  32. package/src/document/processActions/processActions.ts +53 -3
  33. package/src/document/processActions/publish.ts +4 -4
  34. package/src/document/processActions/releaseArchive.ts +77 -0
  35. package/src/document/processActions/releaseCreate.ts +59 -0
  36. package/src/document/processActions/releaseDelete.ts +65 -0
  37. package/src/document/processActions/releaseEdit.ts +37 -0
  38. package/src/document/processActions/releasePublish.ts +45 -0
  39. package/src/document/processActions/releaseSchedule.ts +87 -0
  40. package/src/document/processActions/releaseUtil.ts +31 -0
  41. package/src/document/processActions/shared.ts +108 -4
  42. package/src/document/processActions/unpublish.ts +3 -3
  43. package/src/document/processActions.test.ts +423 -1
  44. package/src/document/reducers.ts +44 -8
  45. package/src/document/resourceRules.test.ts +178 -0
  46. package/src/document/resourceRules.ts +117 -0
  47. package/src/releases/getPerspectiveState.test.ts +1 -1
  48. package/src/releases/releasesStore.test.ts +50 -1
  49. package/src/releases/releasesStore.ts +41 -18
  50. package/src/releases/utils/sortReleases.test.ts +2 -2
  51. package/src/releases/utils/sortReleases.ts +1 -1
  52. package/src/telemetry/environment.test.ts +119 -0
  53. package/src/telemetry/environment.ts +92 -0
  54. package/src/telemetry/{__telemetry__/sdk.telemetry.ts → events.ts} +9 -9
  55. package/src/telemetry/initTelemetry.test.ts +240 -16
  56. package/src/telemetry/initTelemetry.ts +39 -16
  57. package/src/telemetry/telemetryManager.test.ts +129 -65
  58. package/src/telemetry/telemetryManager.ts +41 -29
  59. package/src/telemetry/devMode.test.ts +0 -60
  60. package/src/telemetry/devMode.ts +0 -41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "2.11.1",
3
+ "version": "2.13.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -57,31 +57,31 @@
57
57
  "@sanity/image-url": "^2.1.1",
58
58
  "@sanity/json-match": "^1.0.5",
59
59
  "@sanity/message-protocol": "^0.23.0",
60
- "@sanity/mutate": "^0.16.1",
60
+ "@sanity/mutate": "^0.18.0",
61
61
  "@sanity/telemetry": "^1.1.0",
62
- "@sanity/types": "^5.24.0",
62
+ "@sanity/types": "^5.26.0",
63
63
  "groq": "3.88.1-typegen-experimental.0",
64
- "groq-js": "^1.30.1",
64
+ "groq-js": "^1.30.2",
65
65
  "reselect": "^5.1.1",
66
66
  "rxjs": "^7.8.2",
67
67
  "zustand": "^5.0.13"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@sanity/browserslist-config": "^1.0.5",
71
- "@sanity/pkg-utils": "^8.1.29",
71
+ "@sanity/pkg-utils": "^9.2.3",
72
72
  "@sanity/prettier-config": "^1.0.6",
73
- "@types/node": "^24.12.3",
74
- "@vitest/coverage-v8": "4.1.5",
73
+ "@types/node": "^24.12.4",
74
+ "@vitest/coverage-v8": "^4.1.8",
75
75
  "eslint": "^9.39.4",
76
76
  "prettier": "^3.8.3",
77
77
  "rollup-plugin-visualizer": "^5.14.0",
78
78
  "typescript": "^5.9.3",
79
- "vite": "^7.3.3",
80
- "vitest": "^4.1.5",
81
- "@repo/config-eslint": "0.0.0",
82
- "@repo/package.bundle": "3.82.0",
79
+ "vite": "^7.3.5",
80
+ "vitest": "^4.1.8",
83
81
  "@repo/config-test": "0.0.1",
84
82
  "@repo/package.config": "0.0.1",
83
+ "@repo/config-eslint": "0.0.0",
84
+ "@repo/package.bundle": "3.82.0",
85
85
  "@repo/tsconfig": "0.0.1"
86
86
  },
87
87
  "publishConfig": {
@@ -105,6 +105,7 @@ export {
105
105
  type MediaLibrarySource,
106
106
  type PerspectiveHandle,
107
107
  type ProjectHandle,
108
+ type ReleaseHandle,
108
109
  type ReleasePerspective,
109
110
  type SanityConfig,
110
111
  type StudioConfig,
@@ -112,19 +113,37 @@ export {
112
113
  } from '../config/sanityConfig'
113
114
  export {getDatasetsState, resolveDatasets} from '../datasets/datasets'
114
115
  export {
116
+ type Action,
117
+ archiveRelease,
118
+ type ArchiveReleaseAction,
115
119
  createDocument,
116
120
  type CreateDocumentAction,
121
+ createRelease,
122
+ type CreateReleaseAction,
117
123
  deleteDocument,
118
124
  type DeleteDocumentAction,
125
+ deleteRelease,
126
+ type DeleteReleaseAction,
119
127
  discardDocument,
120
128
  type DiscardDocumentAction,
121
129
  type DocumentAction,
122
130
  editDocument,
123
131
  type EditDocumentAction,
132
+ editRelease,
133
+ type EditReleaseAction,
124
134
  publishDocument,
125
135
  type PublishDocumentAction,
136
+ publishRelease,
137
+ type PublishReleaseAction,
138
+ type ReleaseAction,
139
+ scheduleRelease,
140
+ type ScheduleReleaseAction,
141
+ unarchiveRelease,
142
+ type UnarchiveReleaseAction,
126
143
  unpublishDocument,
127
144
  type UnpublishDocumentAction,
145
+ unscheduleRelease,
146
+ type UnscheduleReleaseAction,
128
147
  } from '../document/actions'
129
148
  export {
130
149
  type ActionsResult,
@@ -155,6 +174,7 @@ export {
155
174
  } from '../document/events'
156
175
  export {type JsonMatch} from '../document/patchOperations'
157
176
  export {type DocumentPermissionsResult, type PermissionDeniedReason} from '../document/permissions'
177
+ export {getReleaseDocumentId} from '../document/processActions/releaseUtil'
158
178
  export type {FavoriteStatusResponse} from '../favorites/favorites'
159
179
  export {getFavoritesState, resolveFavoritesState} from '../favorites/favorites'
160
180
  export {
@@ -214,8 +234,8 @@ export {
214
234
  resolveQuery,
215
235
  } from '../query/queryStore'
216
236
  export {getPerspectiveState} from '../releases/getPerspectiveState'
217
- export type {ReleaseDocument} from '../releases/releasesStore'
218
- export {getActiveReleasesState} from '../releases/releasesStore'
237
+ export type {ReleaseState} from '../releases/releasesStore'
238
+ export {getActiveReleasesState, getAllReleasesState} from '../releases/releasesStore'
219
239
  export {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
220
240
  export {type Selector, type StateSource} from '../store/createStateSourceAction'
221
241
  export {getUsersKey, parseUsersKey} from '../users/reducers'
@@ -244,6 +264,7 @@ export {defineIntent, type Intent, type IntentFilter} from '../utils/defineInten
244
264
  export {getCorsErrorProjectId} from '../utils/getCorsErrorProjectId'
245
265
  export {isImportError} from '../utils/isImportError'
246
266
  export {CORE_SDK_VERSION} from '../version'
267
+ export type {ReleaseDocument} from '@sanity/client'
247
268
  export {
248
269
  getIndexForKey,
249
270
  getPathDepth,
@@ -118,6 +118,18 @@ export interface DocumentHandle<
118
118
  documentId: string
119
119
  }
120
120
 
121
+ /**
122
+ * Identifies a release within a Sanity dataset and project. `releaseId` is the
123
+ * `name` parameter on the release document (e.g. `{name: 'r41035a4'}`).
124
+ * The underlying release document ID is `_.releases.<releaseId>`.
125
+ * It's also the `id` parameter sent to the Actions API.
126
+ * (This type doesn't need to have ProjectId / Dataset generics since it's always the same shape)
127
+ * @beta
128
+ */
129
+ export interface ReleaseHandle extends DatasetHandle {
130
+ releaseId: string
131
+ }
132
+
121
133
  /**
122
134
  * Represents the complete configuration for a Sanity SDK instance
123
135
  * @public
@@ -1,15 +1,24 @@
1
+ import {type ReleaseDocument} from '@sanity/client'
1
2
  import {at, patch, set, setIfMissing} from '@sanity/mutate'
2
3
  import {type PatchOperations} from '@sanity/types'
3
4
  import {describe, expect, it} from 'vitest'
4
5
 
5
- import {type DocumentHandle} from '../config/sanityConfig'
6
+ import {type DocumentHandle, type ReleaseHandle} from '../config/sanityConfig'
6
7
  import {
8
+ archiveRelease,
7
9
  createDocument,
10
+ createRelease,
8
11
  deleteDocument,
12
+ deleteRelease,
9
13
  discardDocument,
10
14
  editDocument,
15
+ editRelease,
11
16
  publishDocument,
17
+ publishRelease,
18
+ scheduleRelease,
19
+ unarchiveRelease,
12
20
  unpublishDocument,
21
+ unscheduleRelease,
13
22
  } from '../document/actions'
14
23
 
15
24
  const dummyPatch: PatchOperations = {
@@ -19,6 +28,9 @@ const dummyPatch: PatchOperations = {
19
28
  const dummyDocHandle: DocumentHandle = {documentId: 'drafts.abc123', documentType: 'testType'}
20
29
  const dummyDocString = {documentId: 'drafts.abc123', documentType: 'testType'}
21
30
 
31
+ const dummyReleaseHandle: ReleaseHandle = {releaseId: 'my-release'}
32
+ const dummyReleasePatch: PatchOperations = {set: {'metadata.title': 'Updated title'}}
33
+
22
34
  describe('document actions', () => {
23
35
  describe('createDocument', () => {
24
36
  it('creates a document action from a document handle', () => {
@@ -207,3 +219,102 @@ describe('document actions', () => {
207
219
  })
208
220
  })
209
221
  })
222
+
223
+ describe('release actions', () => {
224
+ describe('createRelease', () => {
225
+ it('creates a release action from a release handle', () => {
226
+ const action = createRelease(dummyReleaseHandle)
227
+ expect(action).toEqual({
228
+ type: 'release.create',
229
+ releaseId: 'my-release',
230
+ metadata: {releaseType: 'undecided'},
231
+ })
232
+ })
233
+
234
+ it('creates a release action with metadata', () => {
235
+ const metadata: ReleaseDocument['metadata'] = {
236
+ title: 'My release',
237
+ description: 'Some description',
238
+ releaseType: 'scheduled',
239
+ intendedPublishAt: '2026-01-01T00:00:00.000Z',
240
+ }
241
+ const action = createRelease(dummyReleaseHandle, metadata)
242
+ expect(action).toEqual({
243
+ type: 'release.create',
244
+ releaseId: 'my-release',
245
+ metadata,
246
+ })
247
+ })
248
+
249
+ it('preserves resource fields from the handle', () => {
250
+ const action = createRelease({
251
+ releaseId: 'my-release',
252
+ resource: {dataset: 'production', projectId: 'abc123'},
253
+ })
254
+ expect(action).toEqual({
255
+ type: 'release.create',
256
+ releaseId: 'my-release',
257
+ resource: {dataset: 'production', projectId: 'abc123'},
258
+ metadata: {releaseType: 'undecided'},
259
+ })
260
+ })
261
+ })
262
+
263
+ describe('editRelease', () => {
264
+ it('creates a release.edit action with a patch', () => {
265
+ const action = editRelease(dummyReleaseHandle, dummyReleasePatch)
266
+ expect(action).toEqual({
267
+ type: 'release.edit',
268
+ releaseId: 'my-release',
269
+ patch: dummyReleasePatch,
270
+ })
271
+ })
272
+ })
273
+
274
+ describe('publishRelease', () => {
275
+ it('creates a release.publish action from a release handle', () => {
276
+ const action = publishRelease(dummyReleaseHandle)
277
+ expect(action).toEqual({type: 'release.publish', releaseId: 'my-release'})
278
+ })
279
+ })
280
+
281
+ describe('scheduleRelease', () => {
282
+ it('creates a release.schedule action with publishAt', () => {
283
+ const publishAt = '2026-01-01T00:00:00.000Z'
284
+ const action = scheduleRelease(dummyReleaseHandle, publishAt)
285
+ expect(action).toEqual({
286
+ type: 'release.schedule',
287
+ releaseId: 'my-release',
288
+ publishAt,
289
+ })
290
+ })
291
+ })
292
+
293
+ describe('unscheduleRelease', () => {
294
+ it('creates a release.unschedule action from a release handle', () => {
295
+ const action = unscheduleRelease(dummyReleaseHandle)
296
+ expect(action).toEqual({type: 'release.unschedule', releaseId: 'my-release'})
297
+ })
298
+ })
299
+
300
+ describe('archiveRelease', () => {
301
+ it('creates a release.archive action from a release handle', () => {
302
+ const action = archiveRelease(dummyReleaseHandle)
303
+ expect(action).toEqual({type: 'release.archive', releaseId: 'my-release'})
304
+ })
305
+ })
306
+
307
+ describe('unarchiveRelease', () => {
308
+ it('creates a release.unarchive action from a release handle', () => {
309
+ const action = unarchiveRelease(dummyReleaseHandle)
310
+ expect(action).toEqual({type: 'release.unarchive', releaseId: 'my-release'})
311
+ })
312
+ })
313
+
314
+ describe('deleteRelease', () => {
315
+ it('creates a release.delete action from a release handle', () => {
316
+ const action = deleteRelease(dummyReleaseHandle)
317
+ expect(action).toEqual({type: 'release.delete', releaseId: 'my-release'})
318
+ })
319
+ })
320
+ })
@@ -1,9 +1,14 @@
1
+ import {type ReleaseDocument} from '@sanity/client'
1
2
  import {SanityEncoder} from '@sanity/mutate'
2
3
  import {type PatchMutation as SanityMutatePatchMutation} from '@sanity/mutate/_unstable_store'
3
4
  import {type PatchMutation, type PatchOperations} from '@sanity/types'
4
5
  import {type SanityDocument} from 'groq'
5
6
 
6
- import {type DocumentHandle, type DocumentTypeHandle} from '../config/sanityConfig'
7
+ import {
8
+ type DocumentHandle,
9
+ type DocumentTypeHandle,
10
+ type ReleaseHandle,
11
+ } from '../config/sanityConfig'
7
12
  import {getEffectiveDocumentId} from './util'
8
13
 
9
14
  const isSanityMutatePatch = (value: unknown): value is SanityMutatePatchMutation => {
@@ -120,6 +125,17 @@ export type DocumentAction<
120
125
  | UnpublishDocumentAction<TDocumentType, TDataset, TProjectId>
121
126
  | DiscardDocumentAction<TDocumentType, TDataset, TProjectId>
122
127
 
128
+ /**
129
+ * Union of every action accepted by `applyDocumentActions` — both document-
130
+ * level actions and release-lifecycle actions.
131
+ * @beta
132
+ */
133
+ export type Action<
134
+ TDocumentType extends string = string,
135
+ TDataset extends string = string,
136
+ TProjectId extends string = string,
137
+ > = DocumentAction<TDocumentType, TDataset, TProjectId> | ReleaseAction
138
+
123
139
  /**
124
140
  * Creates a `CreateDocumentAction` object.
125
141
  * @param doc - A handle identifying the document type, dataset, and project. An optional `documentId` can be provided.
@@ -316,3 +332,134 @@ export function discardDocument<
316
332
  documentId: effectiveDocumentId,
317
333
  }
318
334
  }
335
+
336
+ /**
337
+ * Creates a new release. The `releaseId` must be unique within the current
338
+ * retention period.
339
+ * @beta
340
+ */
341
+ export interface CreateReleaseAction extends ReleaseHandle {
342
+ type: 'release.create'
343
+ metadata: ReleaseDocument['metadata']
344
+ }
345
+
346
+ /**
347
+ * Patches the metadata of an existing release.
348
+ * @beta
349
+ */
350
+ export interface EditReleaseAction extends ReleaseHandle {
351
+ type: 'release.edit'
352
+ patch: PatchOperations
353
+ }
354
+
355
+ /**
356
+ * Publishes all version documents in a release.
357
+ * @beta
358
+ */
359
+ export interface PublishReleaseAction extends ReleaseHandle {
360
+ type: 'release.publish'
361
+ }
362
+
363
+ /**
364
+ * Schedules a release to be published at the given UTC time. Locks the
365
+ * version documents server-side until the release is unscheduled or published.
366
+ * @beta
367
+ */
368
+ export interface ScheduleReleaseAction extends ReleaseHandle {
369
+ type: 'release.schedule'
370
+ publishAt: string
371
+ }
372
+
373
+ /**
374
+ * Unschedules a release that was previously scheduled, returning it to the
375
+ * active editable state.
376
+ * @beta
377
+ */
378
+ export interface UnscheduleReleaseAction extends ReleaseHandle {
379
+ type: 'release.unschedule'
380
+ }
381
+
382
+ /**
383
+ * Archives an active release. Version documents within the release are
384
+ * removed and no longer queryable, though still recoverable through history
385
+ * during the retention period.
386
+ * @beta
387
+ */
388
+ export interface ArchiveReleaseAction extends ReleaseHandle {
389
+ type: 'release.archive'
390
+ }
391
+
392
+ /**
393
+ * Restores an archived release. Only possible during the retention period.
394
+ * @beta
395
+ */
396
+ export interface UnarchiveReleaseAction extends ReleaseHandle {
397
+ type: 'release.unarchive'
398
+ }
399
+
400
+ /**
401
+ * Permanently deletes an archived or published release. To remove an active
402
+ * release, use the archive action first.
403
+ * @beta
404
+ */
405
+ export interface DeleteReleaseAction extends ReleaseHandle {
406
+ type: 'release.delete'
407
+ }
408
+
409
+ /**
410
+ * Union of all release actions that can be dispatched alongside document
411
+ * actions through `applyDocumentActions`.
412
+ * @beta
413
+ */
414
+ export type ReleaseAction =
415
+ | CreateReleaseAction
416
+ | EditReleaseAction
417
+ | PublishReleaseAction
418
+ | ScheduleReleaseAction
419
+ | UnscheduleReleaseAction
420
+ | ArchiveReleaseAction
421
+ | UnarchiveReleaseAction
422
+ | DeleteReleaseAction
423
+
424
+ /** @beta */
425
+ export function createRelease(
426
+ handle: ReleaseHandle,
427
+ metadata: ReleaseDocument['metadata'] = {releaseType: 'undecided'},
428
+ ): CreateReleaseAction {
429
+ return {type: 'release.create', ...handle, metadata}
430
+ }
431
+
432
+ /** @beta */
433
+ export function editRelease(handle: ReleaseHandle, patch: PatchOperations): EditReleaseAction {
434
+ return {type: 'release.edit', ...handle, patch}
435
+ }
436
+
437
+ /** @beta */
438
+ export function publishRelease(handle: ReleaseHandle): PublishReleaseAction {
439
+ return {type: 'release.publish', ...handle}
440
+ }
441
+
442
+ /** @beta */
443
+ export function scheduleRelease(handle: ReleaseHandle, publishAt: string): ScheduleReleaseAction {
444
+ return {type: 'release.schedule', ...handle, publishAt}
445
+ }
446
+
447
+ /** @beta */
448
+ export function unscheduleRelease(handle: ReleaseHandle): UnscheduleReleaseAction {
449
+ return {type: 'release.unschedule', ...handle}
450
+ }
451
+
452
+ /** @beta */
453
+ export function archiveRelease(handle: ReleaseHandle): ArchiveReleaseAction {
454
+ return {type: 'release.archive', ...handle}
455
+ }
456
+
457
+ /** @beta */
458
+ export function unarchiveRelease(handle: ReleaseHandle): UnarchiveReleaseAction {
459
+ return {type: 'release.unarchive', ...handle}
460
+ }
461
+
462
+ /** @beta */
463
+ export function deleteRelease(handle: ReleaseHandle): DeleteReleaseAction {
464
+ return {type: 'release.delete', ...handle}
465
+ }
@@ -202,4 +202,28 @@ describe('applyDocumentActions', () => {
202
202
  childInstance.dispose()
203
203
  parentInstance.dispose()
204
204
  })
205
+
206
+ it('normalizes actions for the bound resource before queueing', async () => {
207
+ // A plain edit action with no `liveEdit` flag. Canvas forces liveEdit, so
208
+ // the queued action should come back with `liveEdit: true` — proving that
209
+ // `applyDocumentActions` runs `normalizeActionsForResource`.
210
+ const action: DocumentAction = {
211
+ type: 'document.edit',
212
+ documentId: 'doc1',
213
+ documentType: 'sanity.canvas.document',
214
+ patches: [{set: {foo: 'bar'}}],
215
+ }
216
+
217
+ // Don't await — we only need to inspect the synchronously-queued transaction.
218
+ applyDocumentActions(instance, {
219
+ actions: [action],
220
+ transactionId: 'txn-normalize',
221
+ resource: {canvasId: 'c'},
222
+ })
223
+
224
+ const queued = state.get().queued.find((t) => t.transactionId === 'txn-normalize')
225
+ expect(queued).toBeDefined()
226
+ const [queuedAction] = queued!.actions
227
+ expect(queuedAction).toMatchObject({type: 'document.edit', liveEdit: true})
228
+ })
205
229
  })
@@ -5,11 +5,12 @@ import {type DocumentResource} from '../config/sanityConfig'
5
5
  import {bindActionByResource} from '../store/createActionBinder'
6
6
  import {type SanityInstance} from '../store/createSanityInstance'
7
7
  import {type StoreContext} from '../store/defineStore'
8
- import {type DocumentAction} from './actions'
8
+ import {type Action} from './actions'
9
9
  import {documentStore, type DocumentStoreState} from './documentStore'
10
10
  import {type DocumentTransactionSubmissionResult} from './events'
11
11
  import {type DocumentSet} from './processMutations'
12
12
  import {type AppliedTransaction, type QueuedTransaction, queueTransaction} from './reducers'
13
+ import {normalizeActionsForResource} from './resourceRules'
13
14
 
14
15
  /** @beta */
15
16
  export interface ActionsResult<TDocument extends SanityDocument = SanityDocument> {
@@ -26,9 +27,10 @@ export interface ActionsResult<TDocument extends SanityDocument = SanityDocument
26
27
  /** @beta */
27
28
  export interface ApplyDocumentActionsOptions {
28
29
  /**
29
- * List of actions to apply.
30
+ * List of actions to apply. Accepts both document actions and release
31
+ * lifecycle actions because they share the same transaction pipeline.
30
32
  */
31
- actions: DocumentAction[]
33
+ actions: Action[]
32
34
 
33
35
  /**
34
36
  * The resource to which the documents being acted on belong.
@@ -72,13 +74,23 @@ const boundApplyDocumentActions = bindActionByResource(documentStore, _applyDocu
72
74
  /** @internal */
73
75
  async function _applyDocumentActions(
74
76
  {state}: StoreContext<DocumentStoreState>,
75
- {actions, transactionId = crypto.randomUUID(), disableBatching}: ApplyDocumentActionsOptions,
77
+ {
78
+ actions,
79
+ resource,
80
+ transactionId = crypto.randomUUID(),
81
+ disableBatching,
82
+ }: ApplyDocumentActionsOptions,
76
83
  ): Promise<ActionsResult> {
77
84
  const {events} = state.get()
78
85
 
86
+ // Rewrite edit actions to match the bound resource's editing model (e.g.
87
+ // forcing liveEdit for Canvas, stripping unsupported release perspectives).
88
+ // Non-edit and release actions pass through unchanged.
89
+ const normalizedActions = normalizeActionsForResource(actions, resource)
90
+
79
91
  const transaction: QueuedTransaction = {
80
92
  transactionId,
81
- actions,
93
+ actions: normalizedActions,
82
94
  ...(disableBatching && {disableBatching}),
83
95
  }
84
96
 
@@ -8,3 +8,10 @@
8
8
  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
+
12
+ /**
13
+ * Base delay (ms) before retrying a document listener after an `OutOfSyncError`.
14
+ * Backoff doubles on each successive retry, capped at {@link OUT_OF_SYNC_RETRY_MAX_DELAY}.
15
+ */
16
+ export const OUT_OF_SYNC_RETRY_BASE_DELAY = 500
17
+ export const OUT_OF_SYNC_RETRY_MAX_DELAY = 10_000
@@ -42,6 +42,7 @@ import {
42
42
  subscribeDocumentEvents,
43
43
  } from './documentStore'
44
44
  import {type ActionErrorEvent, type TransactionRevertedEvent} from './events'
45
+ import {DEFAULT_MAX_BUFFER_SIZE} from './listen'
45
46
  import {type DatasetAcl} from './permissions'
46
47
  import {type DocumentSet, processMutations} from './processMutations'
47
48
  import {type HttpAction} from './reducers'
@@ -1006,6 +1007,72 @@ it('version edits are isolated from draft state', async () => {
1006
1007
  unsubscribeDraft()
1007
1008
  })
1008
1009
 
1010
+ it('subscribeToSubscriptionsAndListenToDocuments recovers from an OutOfSyncError instead of failing the document store', async () => {
1011
+ const documentId = DocumentId('doc-out-of-sync-recovery')
1012
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
1013
+ const documentState = getDocumentState<TestDocument>(instance, doc)
1014
+ const unsubscribe = documentState.subscribe()
1015
+ const draftId = getDraftId(documentId)
1016
+
1017
+ // Establish a synced document so the listener has a base revision.
1018
+ const created = await applyDocumentActions(instance, {
1019
+ actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial'}})],
1020
+ })
1021
+ await created.submitted()
1022
+ expect(documentState.getCurrent()?.title).toBe('initial')
1023
+
1024
+ // Capture the fetchDocument spy so we can detect when the listener
1025
+ // re-subscribes. (every frest subscription re-runs fetchDocument)
1026
+ const fetchDocumentSpy = vi.mocked(createFetchDocument).mock.results.at(-1)?.value as ReturnType<
1027
+ typeof vi.fn
1028
+ >
1029
+ const fetchCallsBefore = fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length
1030
+
1031
+ // Inject DEFAULT_MAX_BUFFER_SIZE mutations whose previousRev doesn't chain
1032
+ // to the base revision (basically forcing the error)
1033
+ const sharedListener = (
1034
+ createSharedListener as unknown as () => {events: Subject<ListenEvent<SanityDocument>>}
1035
+ )()
1036
+ for (let i = 0; i < DEFAULT_MAX_BUFFER_SIZE; i++) {
1037
+ sharedListener.events.next({
1038
+ type: 'mutation',
1039
+ documentId: draftId,
1040
+ eventId: `bad-${i}`,
1041
+ identity: 'user',
1042
+ mutations: [],
1043
+ timestamp: new Date().toISOString(),
1044
+ transactionId: `bad-tx-${i}`,
1045
+ transactionCurrentEvent: 0,
1046
+ transactionTotalEvents: 1,
1047
+ previousRev: `dangling-rev-${i}`,
1048
+ resultRev: `bad-rev-${i}`,
1049
+ transition: 'update',
1050
+ visibility: 'query',
1051
+ })
1052
+ }
1053
+
1054
+ // The above should have triggered a retry and re-subscribed to the listener
1055
+ await vi.waitFor(() => {
1056
+ expect(fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length).toBeGreaterThan(
1057
+ fetchCallsBefore,
1058
+ )
1059
+ })
1060
+
1061
+ // The store stayed healthy through the OutOfSyncError.
1062
+ expect(() => documentState.getCurrent()).not.toThrow()
1063
+ expect(() => getDocumentSyncStatus(instance, doc).getCurrent()).not.toThrow()
1064
+
1065
+ // A follow-up edit still propagates through the recovered listener.
1066
+ const edited = await applyDocumentActions(instance, {
1067
+ actions: [editDocument(doc, {set: {title: 'after-recovery'}})],
1068
+ resource,
1069
+ })
1070
+ await edited.submitted()
1071
+ expect(documentState.getCurrent()?.title).toBe('after-recovery')
1072
+
1073
+ unsubscribe()
1074
+ })
1075
+
1009
1076
  vi.mock('../client/clientStore.ts', () => ({
1010
1077
  getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
1011
1078
  }))
@@ -1039,6 +1106,8 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
1039
1106
  ...original,
1040
1107
  INITIAL_OUTGOING_THROTTLE_TIME: 0,
1041
1108
  DOCUMENT_STATE_CLEAR_DELAY: 25,
1109
+ OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
1110
+ OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
1042
1111
  }
1043
1112
  })
1044
1113