@sanity/sdk 3.0.0-rc.2 → 3.1.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 (102) hide show
  1. package/dist/_exports/_internal.d.ts +25 -3
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +127 -9
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/_exports/agent.d.ts +1 -1
  6. package/dist/_exports/agent.js +1 -1
  7. package/dist/_exports/comlink.d.ts +1 -1
  8. package/dist/_exports/comlink.js +2 -3
  9. package/dist/_exports/comlink.js.map +1 -1
  10. package/dist/{authStore-DKFX8fn7.d.ts → authStore-C-OEkGaM.d.ts} +30 -44
  11. package/dist/authStore-C-OEkGaM.d.ts.map +1 -0
  12. package/dist/{authStore-CtE9bLHW.js → clientStore-gUALQ0qW.js} +269 -11
  13. package/dist/{authStore-CtE9bLHW.js.map → clientStore-gUALQ0qW.js.map} +1 -1
  14. package/dist/{createSanityInstance-DUI1bGIg.d.ts → createSanityInstance-DCePpBYW.d.ts} +2 -2
  15. package/dist/{createSanityInstance-DUI1bGIg.d.ts.map → createSanityInstance-DCePpBYW.d.ts.map} +1 -1
  16. package/dist/{getNodeState-CR3__B2D.js → getNodeState-E0hjqFOo.js} +2 -3
  17. package/dist/{getNodeState-CR3__B2D.js.map → getNodeState-E0hjqFOo.js.map} +1 -1
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +168 -176
  20. package/dist/index.js.map +1 -1
  21. package/dist/{createStateSourceAction-BOKtqFNm.js → object-BwynGPRZ.js} +87 -3
  22. package/dist/object-BwynGPRZ.js.map +1 -0
  23. package/dist/{queryStore-DPf9sN3V.js → version-C90rNWaX.js} +48 -16
  24. package/dist/version-C90rNWaX.js.map +1 -0
  25. package/package.json +4 -4
  26. package/src/_exports/_internal.ts +1 -0
  27. package/src/_exports/index.ts +7 -4
  28. package/src/access/checkPermissions.test.ts +1 -1
  29. package/src/access/checkPermissions.ts +1 -1
  30. package/src/applications/applications.test.ts +4 -4
  31. package/src/applications/applications.ts +4 -4
  32. package/src/auth/handleAuthCallback.test.ts +2 -2
  33. package/src/auth/handleAuthCallback.ts +1 -1
  34. package/src/auth/logout.test.ts +1 -1
  35. package/src/auth/logout.ts +1 -1
  36. package/src/auth/refreshStampedToken.ts +1 -1
  37. package/src/auth/studioModeAuth.test.ts +1 -1
  38. package/src/auth/studioModeAuth.ts +1 -1
  39. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -2
  40. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -1
  41. package/src/comments/addonDatasetStore.test.ts +2 -2
  42. package/src/comments/addonDatasetStore.ts +2 -2
  43. package/src/comments/commentActions.ts +6 -5
  44. package/src/comments/commentsStore.ts +4 -4
  45. package/src/datasets/datasets.test.ts +44 -9
  46. package/src/datasets/datasets.ts +24 -23
  47. package/src/document/applyDocumentActions.ts +2 -6
  48. package/src/document/documentConstants.ts +9 -0
  49. package/src/document/documentStore.concurrency.test.ts +2 -1
  50. package/src/document/documentStore.test.ts +87 -17
  51. package/src/document/documentStore.ts +5 -5
  52. package/src/document/permissions.ts +2 -1
  53. package/src/document/processActions.test.ts +2 -1
  54. package/src/document/processMutations.ts +3 -2
  55. package/src/document/reducers.test.ts +224 -0
  56. package/src/document/reducers.ts +110 -27
  57. package/src/favorites/favorites.test.ts +64 -24
  58. package/src/favorites/favorites.ts +73 -22
  59. package/src/installations/installations.test.ts +2 -2
  60. package/src/installations/installations.ts +2 -2
  61. package/src/organization/organization.test.ts +2 -2
  62. package/src/organization/organization.ts +1 -1
  63. package/src/organizations/organizations.test.ts +2 -2
  64. package/src/organizations/organizations.ts +1 -1
  65. package/src/presence/presenceStore.test.ts +15 -22
  66. package/src/presence/presenceStore.ts +3 -2
  67. package/src/project/project.test.ts +3 -3
  68. package/src/project/project.ts +1 -1
  69. package/src/projection/getProjectionState.test.ts +6 -4
  70. package/src/projection/getProjectionState.ts +2 -2
  71. package/src/projects/projects.test.ts +2 -2
  72. package/src/projects/projects.ts +1 -1
  73. package/src/query/queryStore.ts +3 -3
  74. package/src/store/createSanityInstance.test.ts +1 -1
  75. package/src/store/createSanityInstance.ts +4 -4
  76. package/src/store/createStoreInstance.test.ts +0 -5
  77. package/src/store/fetcherStore.test.ts +23 -1
  78. package/src/store/fetcherStore.ts +3 -3
  79. package/src/telemetry/telemetryManager.test.ts +1 -1
  80. package/src/telemetry/telemetryManager.ts +2 -2
  81. package/src/users/usersStore.test.ts +1 -1
  82. package/src/users/usersStore.ts +5 -5
  83. package/src/utils/ids.test.ts +40 -15
  84. package/src/utils/ids.ts +47 -2
  85. package/dist/authStore-DKFX8fn7.d.ts.map +0 -1
  86. package/dist/clientStore-BtE2-q2i.js +0 -90
  87. package/dist/clientStore-BtE2-q2i.js.map +0 -1
  88. package/dist/createStateSourceAction-BOKtqFNm.js.map +0 -1
  89. package/dist/getEnv-D5RGkQLu.js +0 -8
  90. package/dist/getEnv-D5RGkQLu.js.map +0 -1
  91. package/dist/logger-DAtJb5RQ.js +0 -173
  92. package/dist/logger-DAtJb5RQ.js.map +0 -1
  93. package/dist/object-CxXlSVg7.js +0 -83
  94. package/dist/object-CxXlSVg7.js.map +0 -1
  95. package/dist/queryStore-DPf9sN3V.js.map +0 -1
  96. package/dist/rolldown-runtime-vyAXikos.js +0 -9
  97. package/dist/telemetryManager-Dq_qCkeI.js +0 -127
  98. package/dist/telemetryManager-Dq_qCkeI.js.map +0 -1
  99. package/dist/version-BChjsi4u.js +0 -9
  100. package/dist/version-BChjsi4u.js.map +0 -1
  101. package/src/utils/createFetcherStore.test.ts +0 -171
  102. package/src/utils/createFetcherStore.ts +0 -284
@@ -35,7 +35,7 @@ describe('checkForCookieAuth', () => {
35
35
 
36
36
  expect(result).toBe(true)
37
37
  expect(mockClient.request).toHaveBeenCalledWith({
38
- uri: '/users/me',
38
+ url: '/users/me',
39
39
  withCredentials: true,
40
40
  tag: 'users.get-current',
41
41
  })
@@ -30,7 +30,7 @@ export async function checkForCookieAuth(
30
30
  timeout: COOKIE_AUTH_TIMEOUT_MS,
31
31
  })
32
32
  const user = await client.request({
33
- uri: '/users/me',
33
+ url: '/users/me',
34
34
  withCredentials: true,
35
35
  tag: 'users.get-current',
36
36
  })
@@ -57,7 +57,7 @@ describe('subscribeToStateAndFetchCurrentUser', () => {
57
57
  })
58
58
  expect(mockRequest).toHaveBeenCalledWith({
59
59
  method: 'GET',
60
- uri: '/users/me',
60
+ url: '/users/me',
61
61
  tag: 'users.get-current',
62
62
  })
63
63
 
@@ -97,7 +97,7 @@ describe('subscribeToStateAndFetchCurrentUser', () => {
97
97
  })
98
98
  expect(mockRequest).toHaveBeenCalledWith({
99
99
  method: 'GET',
100
- uri: '/users/me',
100
+ url: '/users/me',
101
101
  tag: 'users.get-current',
102
102
  })
103
103
 
@@ -73,7 +73,7 @@ export const subscribeToStateAndFetchCurrentUser = (
73
73
  switchMap((client) =>
74
74
  client.observable
75
75
  .request<CurrentUser>({
76
- uri: '/users/me',
76
+ url: '/users/me',
77
77
  method: 'GET',
78
78
  tag: 'users.get-current',
79
79
  })
@@ -59,7 +59,7 @@ describe('getAddonDatasetState', () => {
59
59
  )
60
60
 
61
61
  expect(observableRequest).toHaveBeenCalledWith({
62
- uri: '/projects/p/datasets?datasetProfile=comments&addonFor=d',
62
+ url: '/projects/p/datasets?datasetProfile=comments&addonFor=d',
63
63
  tag: 'comments.addon-dataset.list',
64
64
  })
65
65
  })
@@ -139,7 +139,7 @@ describe('provisionAddonDataset', () => {
139
139
 
140
140
  await expect(provisionAddonDataset(instance, {})).resolves.toBe('d-comments')
141
141
  expect(request).toHaveBeenCalledWith({
142
- uri: '/comments/d/setup',
142
+ url: '/comments/d/setup',
143
143
  method: 'POST',
144
144
  tag: 'comments.addon-dataset.setup',
145
145
  })
@@ -67,7 +67,7 @@ function requestAddonDataset(
67
67
  ): Observable<string | undefined> {
68
68
  return client.observable
69
69
  .request<{name: string}[] | undefined>({
70
- uri: `/projects/${projectId}/datasets?datasetProfile=${ADDON_DATASET_PROFILE}&addonFor=${dataset}`,
70
+ url: `/projects/${projectId}/datasets?datasetProfile=${ADDON_DATASET_PROFILE}&addonFor=${dataset}`,
71
71
  tag: 'comments.addon-dataset.list',
72
72
  })
73
73
  .pipe(map((datasets) => datasets?.[0]?.name))
@@ -182,7 +182,7 @@ export const provisionAddonDataset: (
182
182
  })
183
183
 
184
184
  const response = await client.request<{datasetName?: string} | undefined>({
185
- uri: `/comments/${resource.dataset}/setup`,
185
+ url: `/comments/${resource.dataset}/setup`,
186
186
  method: 'POST',
187
187
  tag: 'comments.addon-dataset.setup',
188
188
  })
@@ -16,6 +16,7 @@ import {bindActionByResource, type BoundResourceKey} from '../store/createAction
16
16
  import {type SanityInstance} from '../store/createSanityInstance'
17
17
  import {type StoreState} from '../store/createStoreState'
18
18
  import {type StoreContext} from '../store/defineStore'
19
+ import {randomUuid} from '../utils/ids'
19
20
  import {
20
21
  assertDatasetResource,
21
22
  getAddonDatasetState,
@@ -326,7 +327,7 @@ export const createComment: (
326
327
  options,
327
328
  buildCommentPayload({
328
329
  authorId: requireCurrentUserId(instance),
329
- commentId: options.commentId ?? crypto.randomUUID(),
330
+ commentId: options.commentId ?? randomUuid(),
330
331
  contentSnapshot: options.contentSnapshot,
331
332
  context: options.context,
332
333
  documentRevisionId: options.documentRevisionId,
@@ -336,7 +337,7 @@ export const createComment: (
336
337
  fieldPath,
337
338
  selection: options.selection,
338
339
  status: options.status ?? 'open',
339
- threadId: options.threadId ?? crypto.randomUUID(),
340
+ threadId: options.threadId ?? randomUuid(),
340
341
  ...(isReleasePerspective(perspective) ? {documentVersionId: perspective.releaseName} : {}),
341
342
  }),
342
343
  )
@@ -386,7 +387,7 @@ export const replyToComment: (
386
387
  options,
387
388
  buildCommentPayload({
388
389
  authorId: requireCurrentUserId(instance),
389
- commentId: options.commentId ?? crypto.randomUUID(),
390
+ commentId: options.commentId ?? randomUuid(),
390
391
  handle: options,
391
392
  message: options.message,
392
393
  // Replies to a reply belong to the thread's first comment, matching how
@@ -420,7 +421,7 @@ export const updateComment: (
420
421
  {commentId, message}: UpdateCommentOptions,
421
422
  ) => {
422
423
  const lastEditedAt = new Date().toISOString()
423
- const transactionId = crypto.randomUUID()
424
+ const transactionId = randomUuid()
424
425
  const previous = findCommentById(state, commentId)
425
426
 
426
427
  if (previous) {
@@ -468,7 +469,7 @@ export const setCommentStatus: (
468
469
  {state, instance, key}: StoreContext<CommentsStoreState, BoundResourceKey>,
469
470
  {commentId, status}: SetCommentStatusOptions,
470
471
  ) => {
471
- const transactionId = crypto.randomUUID()
472
+ const transactionId = randomUuid()
472
473
  const previousComments: StoredComment[] = []
473
474
 
474
475
  for (const entry of Object.values(state.get().entries)) {
@@ -29,7 +29,7 @@ import {
29
29
  } from '../store/createStateSourceAction'
30
30
  import {type StoreState} from '../store/createStoreState'
31
31
  import {defineStore, type StoreContext} from '../store/defineStore'
32
- import {insecureRandomId} from '../utils/ids'
32
+ import {randomId} from '../utils/ids'
33
33
  import {setCleanupTimeout} from '../utils/setCleanupTimeout'
34
34
  import {observeAddonDatasetClient} from './addonDatasetStore'
35
35
  import {buildCommentThreads} from './buildCommentThreads'
@@ -360,7 +360,7 @@ export const getCommentsState: (
360
360
  selector: selectComments,
361
361
  onSubscribe: ({state, instance}, options: CommentsOptions) => {
362
362
  const key = getCommentsKey(toCommentsKeyParts(instance, options))
363
- const subscriptionId = insecureRandomId()
363
+ const subscriptionId = randomId(16)
364
364
  state.set('addSubscriber', addSubscriber(key, subscriptionId))
365
365
 
366
366
  return () => {
@@ -390,7 +390,7 @@ export const getCommentThreadsState: (
390
390
  selector: selectCommentThreads,
391
391
  onSubscribe: ({state, instance}, options: CommentsOptions) => {
392
392
  const key = getCommentsKey(toCommentsKeyParts(instance, options))
393
- const subscriptionId = insecureRandomId()
393
+ const subscriptionId = randomId(16)
394
394
  state.set('addSubscriber', addSubscriber(key, subscriptionId))
395
395
 
396
396
  return () => {
@@ -452,7 +452,7 @@ function resolveList<T>(
452
452
  // fetch and this promise would never settle. Holding it only for the duration
453
453
  // of the resolve also means a component that suspends and then errors before
454
454
  // mounting does not leave a subscriber-less entry behind holding its error.
455
- const subscriptionId = insecureRandomId()
455
+ const subscriptionId = randomId(16)
456
456
  state.set('addSubscriber', addSubscriber(key, subscriptionId))
457
457
 
458
458
  const release = () => state.set('removeSubscriber', removeSubscriber(key, subscriptionId))
@@ -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
@@ -5,6 +5,7 @@ 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 {randomUuid} from '../utils/ids'
8
9
  import {type Action} from './actions'
9
10
  import {documentStore, type DocumentStoreState} from './documentStore'
10
11
  import {type DocumentTransactionSubmissionResult} from './events'
@@ -74,12 +75,7 @@ const boundApplyDocumentActions = bindActionByResource(documentStore, _applyDocu
74
75
  /** @internal */
75
76
  async function _applyDocumentActions(
76
77
  {state}: StoreContext<DocumentStoreState>,
77
- {
78
- actions,
79
- resource,
80
- transactionId = crypto.randomUUID(),
81
- disableBatching,
82
- }: ApplyDocumentActionsOptions,
78
+ {actions, resource, transactionId = randomUuid(), disableBatching}: ApplyDocumentActionsOptions,
83
79
  ): Promise<ActionsResult> {
84
80
  const {events} = state.get()
85
81
 
@@ -9,6 +9,15 @@ 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}.
@@ -44,6 +44,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
44
44
  import {getClientState} from '../client/clientStore'
45
45
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
46
46
  import {type StateSource} from '../store/createStateSourceAction'
47
+ import {randomUuid} from '../utils/ids'
47
48
  import {editDocument, publishDocument} from './actions'
48
49
  import {applyDocumentActions} from './applyDocumentActions'
49
50
  import {getDocumentState, getDocumentSyncStatus} from './documentStore'
@@ -273,7 +274,7 @@ function createMockClient(server: MockContentLake): SanityClient {
273
274
 
274
275
  const action = vi.fn(async (input: HttpAction | HttpAction[], options?: BaseActionOptions) => {
275
276
  const actions = Array.isArray(input) ? input : [input]
276
- return server.submitActions(actions, options?.transactionId ?? crypto.randomUUID())
277
+ return server.submitActions(actions, options?.transactionId ?? randomUuid())
277
278
  })
278
279
 
279
280
  const request = vi.fn(async () => {
@@ -36,6 +36,7 @@ import {getClientState} from '../client/clientStore'
36
36
  import {createDocumentHandle} from '../config/handles'
37
37
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
38
38
  import {type StateSource} from '../store/createStateSourceAction'
39
+ import {randomUuid} from '../utils/ids'
39
40
  import {
40
41
  createDocument,
41
42
  deleteDocument,
@@ -357,6 +358,75 @@ it('emits remote-patches events distinguishing own and foreign transactions', as
357
358
  state2Unsubscribe()
358
359
  })
359
360
 
361
+ it('labels an echo that arrives after the last subscriber left as its own', async () => {
362
+ const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
363
+ const draftId = getDraftId(DocumentId(doc.documentId))
364
+ const state = getDocumentState(instance, doc)
365
+ const unsubscribe = state.subscribe()
366
+ await vi.waitUntil(() => state.getCurrent() !== undefined)
367
+ const previousRev = state.getCurrent()?._rev
368
+
369
+ // ack without emitting the listener echo, so the echo can be delivered later
370
+ // and out of band, which is the ordering the real API produces
371
+ const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
372
+ vi.mocked(client.action).mockImplementation(
373
+ async (_input, {transactionId = randomUuid()} = {}) => ({transactionId}),
374
+ )
375
+
376
+ const patchEvents: DocumentRemotePatchesEvent[] = []
377
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
378
+ resource,
379
+ eventHandler: (e) => {
380
+ if (e.type === 'remote-patches') patchEvents.push(e)
381
+ },
382
+ })
383
+
384
+ const edited = await applyDocumentActions(instance, {
385
+ actions: [editDocument(doc, {set: {title: 'edited while leaving'}})],
386
+ resource,
387
+ })
388
+
389
+ // the last subscriber leaves before the ack comes back, which used to delete
390
+ // the document state (and with it the memory of our own transaction) as soon
391
+ // as the ack released the transaction's subscription
392
+ unsubscribe()
393
+ await new Promise((resolve) => setTimeout(resolve, 50))
394
+ await edited.submitted()
395
+
396
+ const {events: listenerEvents} = (
397
+ createSharedListener as () => {
398
+ dispose: () => void
399
+ events: Subject<ListenEvent<SanityDocument>>
400
+ }
401
+ )()
402
+ listenerEvents.next({
403
+ type: 'mutation',
404
+ documentId: draftId,
405
+ eventId: `${edited.transactionId}#${draftId}`,
406
+ identity: 'example-user',
407
+ mutations: [{patch: {id: draftId, set: {title: 'edited while leaving'}}}],
408
+ timestamp: new Date().toISOString(),
409
+ transactionId: edited.transactionId,
410
+ transactionCurrentEvent: 1,
411
+ transactionTotalEvents: 1,
412
+ transition: 'update',
413
+ visibility: 'query',
414
+ previousRev,
415
+ resultRev: edited.transactionId,
416
+ } satisfies MutationEvent)
417
+ await vi.waitUntil(() => patchEvents.length > 0)
418
+
419
+ expect(patchEvents).toHaveLength(1)
420
+ expect(patchEvents[0]).toMatchObject({
421
+ documentId: draftId,
422
+ transactionId: edited.transactionId,
423
+ origin: 'local',
424
+ })
425
+
426
+ unsubscribeEvents()
427
+ vi.mocked(client.action).mockImplementation(clientActionMockImplementation)
428
+ })
429
+
360
430
  it('handles concurrent edits and resolves conflicts', async () => {
361
431
  const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
362
432
  const state1 = getDocumentState(instance1, doc)
@@ -602,7 +672,7 @@ it('fetches documents if there are no active subscriptions for the actions appli
602
672
  })
603
673
 
604
674
  it('batches edit transaction into one outgoing transaction', async () => {
605
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
675
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
606
676
 
607
677
  const unsubscribe = getDocumentState(instance, doc).subscribe()
608
678
 
@@ -678,7 +748,7 @@ it('submits liveEdit document edits through observable.mutate', async () => {
678
748
  })
679
749
 
680
750
  it('provides the consistency status via `getDocumentSyncStatus`', async () => {
681
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
751
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
682
752
 
683
753
  const syncStatus = getDocumentSyncStatus(instance, doc)
684
754
  expect(syncStatus.getCurrent()).toBeUndefined()
@@ -733,7 +803,7 @@ it('reverts failed outgoing transaction locally', async () => {
733
803
  })
734
804
  })
735
805
 
736
- const documentId = DocumentId(crypto.randomUUID())
806
+ const documentId = DocumentId(randomUuid())
737
807
  const doc = createDocumentHandle({documentId, documentType: 'article'})
738
808
 
739
809
  const {getCurrent, subscribe} = getDocumentState(instance, doc)
@@ -801,7 +871,7 @@ it('removes a queued transaction if it fails to apply', async () => {
801
871
  })
802
872
  })
803
873
 
804
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
874
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
805
875
  const state = getDocumentState(instance, doc)
806
876
  const unsubscribe = state.subscribe()
807
877
 
@@ -906,8 +976,8 @@ it('fetches dataset ACL and updates grants in the document store state', async (
906
976
  ]
907
977
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
908
978
 
909
- const book = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'book'})
910
- const author = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'author'})
979
+ const book = createDocumentHandle({documentId: randomUuid(), documentType: 'book'})
980
+ const author = createDocumentHandle({documentId: randomUuid(), documentType: 'author'})
911
981
 
912
982
  expect(await resolvePermissions(instance, {actions: [createDocument(book)]})).toEqual({
913
983
  allowed: true,
@@ -919,12 +989,12 @@ it('fetches dataset ACL and updates grants in the document store state', async (
919
989
  })
920
990
 
921
991
  it('does not send credentials with the dataset ACL request', async () => {
922
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
992
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
923
993
  await resolvePermissions(instance, {actions: [createDocument(doc)]})
924
994
 
925
995
  const aclCall = vi
926
996
  .mocked(client.request)
927
- .mock.calls.find(([options]) => options.uri?.endsWith('/acl'))
997
+ .mock.calls.find(([options]) => options.url?.endsWith('/acl'))
928
998
  expect(aclCall).toBeDefined()
929
999
  expect(aclCall![0]).not.toHaveProperty('withCredentials')
930
1000
  })
@@ -942,7 +1012,7 @@ it('retries transient dataset ACL fetch failures instead of failing fatally', as
942
1012
  }),
943
1013
  )
944
1014
 
945
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1015
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
946
1016
  const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
947
1017
 
948
1018
  expect(result).toEqual({allowed: true})
@@ -968,7 +1038,7 @@ it('does not retry dataset ACL fetch failures caused by 4xx client errors', asyn
968
1038
  }),
969
1039
  )
970
1040
 
971
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1041
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
972
1042
  const documentState = getDocumentState(instance, doc)
973
1043
  const unsubscribe = documentState.subscribe()
974
1044
 
@@ -1001,7 +1071,7 @@ it('retries dataset ACL fetch failures caused by 429 rate-limit errors', async (
1001
1071
  }),
1002
1072
  )
1003
1073
 
1004
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1074
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
1005
1075
  const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
1006
1076
 
1007
1077
  expect(result).toEqual({allowed: true})
@@ -1018,7 +1088,7 @@ it('fetches ACL for MediaLibraryResource', async () => {
1018
1088
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
1019
1089
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
1020
1090
 
1021
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1091
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
1022
1092
  const mediaLibraryResource = {mediaLibraryId: 'test-media-library'}
1023
1093
 
1024
1094
  const result = await resolvePermissions(mediaLibraryInstance, {
@@ -1036,7 +1106,7 @@ it('fetches ACL for CanvasResource', async () => {
1036
1106
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
1037
1107
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
1038
1108
 
1039
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1109
+ const doc = createDocumentHandle({documentId: randomUuid(), documentType: 'article'})
1040
1110
  const canvasResource = {canvasId: 'test-canvas'}
1041
1111
 
1042
1112
  const result = await resolvePermissions(canvasInstance, {
@@ -1049,7 +1119,7 @@ it('fetches ACL for CanvasResource', async () => {
1049
1119
  })
1050
1120
 
1051
1121
  it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => {
1052
- const documentId = DocumentId(crypto.randomUUID())
1122
+ const documentId = DocumentId(randomUuid())
1053
1123
  const doc = createDocumentHandle({documentId, documentType: 'article'})
1054
1124
 
1055
1125
  expect(await resolveDocument(instance, doc)).toBe(null)
@@ -1074,7 +1144,7 @@ it('emits an event for each action after an outgoing transaction has been accept
1074
1144
  const handler = vi.fn()
1075
1145
  const unsubscribe = subscribeDocumentEvents(instance, {resource, eventHandler: handler})
1076
1146
 
1077
- const documentId = DocumentId(crypto.randomUUID())
1147
+ const documentId = DocumentId(randomUuid())
1078
1148
  const doc = createDocumentHandle({documentId, documentType: 'article'})
1079
1149
  expect(handler).toHaveBeenCalledTimes(0)
1080
1150
 
@@ -1505,7 +1575,7 @@ beforeEach(() => {
1505
1575
  mutations: Mutation[],
1506
1576
  options: BaseMutationOptions = {},
1507
1577
  ): Promise<MultipleMutationResult> => {
1508
- const transactionId = options.transactionId ?? crypto.randomUUID()
1578
+ const transactionId = options.transactionId ?? randomUuid()
1509
1579
  const timestamp = new Date().toISOString()
1510
1580
  const prior = {...documents}
1511
1581
  let next = {...documents}
@@ -1578,7 +1648,7 @@ beforeEach(() => {
1578
1648
  const action = vi.fn(
1579
1649
  async (
1580
1650
  input: HttpAction | HttpAction[],
1581
- {transactionId = crypto.randomUUID(), dryRun}: BaseActionOptions,
1651
+ {transactionId = randomUuid(), dryRun}: BaseActionOptions,
1582
1652
  ): Promise<SingleActionResult | MultipleActionResult> => {
1583
1653
  const actions = Array.isArray(input) ? input : [input]
1584
1654
  let next: DocumentSet = {...documents}
@@ -550,13 +550,13 @@ const subscribeToClientAndFetchDatasetAcl = ({
550
550
  }: StoreContext<DocumentStoreState, BoundResourceKey>) => {
551
551
  const clientOptions: ClientOptions = {apiVersion: API_VERSION, resource}
552
552
 
553
- let uri: string
553
+ let url: string
554
554
  if (resource && isDatasetResource(resource)) {
555
- uri = `/projects/${resource.projectId}/datasets/${resource.dataset}/acl`
555
+ url = `/projects/${resource.projectId}/datasets/${resource.dataset}/acl`
556
556
  } else if (resource && isMediaLibraryResource(resource)) {
557
- uri = `/media-libraries/${resource.mediaLibraryId}/acl`
557
+ url = `/media-libraries/${resource.mediaLibraryId}/acl`
558
558
  } else if (resource && isCanvasResource(resource)) {
559
- uri = `/canvases/${resource.canvasId}/acl`
559
+ url = `/canvases/${resource.canvasId}/acl`
560
560
  } else {
561
561
  throw new Error(`Received invalid resource: ${JSON.stringify(resource)}`)
562
562
  }
@@ -566,7 +566,7 @@ const subscribeToClientAndFetchDatasetAcl = ({
566
566
  switchMap((client) =>
567
567
  client.observable
568
568
  .request<DatasetAcl>({
569
- uri,
569
+ url,
570
570
  tag: 'acl.get',
571
571
  })
572
572
  .pipe(
@@ -5,6 +5,7 @@ import {createSelector} from 'reselect'
5
5
 
6
6
  import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
7
7
  import {type SelectorContext} from '../store/createStateSourceAction'
8
+ import {randomId} from '../utils/ids'
8
9
  import {MultiKeyWeakMap} from '../utils/MultiKeyWeakMap'
9
10
  import {type DocumentAction} from './actions'
10
11
  import {ActionError, PermissionActionError, processActions} from './processActions/processActions'
@@ -190,7 +191,7 @@ const _calculatePermissions = createSelector(
190
191
  try {
191
192
  processActions({
192
193
  actions,
193
- transactionId: crypto.randomUUID(),
194
+ transactionId: randomId(16),
194
195
  working: documents,
195
196
  base: documents,
196
197
  timestamp,
@@ -2,6 +2,7 @@ import {type CreateMutation, type Reference, type SanityDocument} from '@sanity/
2
2
  import {parse} from 'groq-js'
3
3
  import {describe, expect, it} from 'vitest'
4
4
 
5
+ import {randomUuid} from '../utils/ids'
5
6
  import {type Action, type DocumentAction} from './actions'
6
7
  import {ActionError, processActions} from './processActions/processActions'
7
8
  import {type DocumentSet, processMutations} from './processMutations'
@@ -973,7 +974,7 @@ describe('processActions', () => {
973
974
  })
974
975
 
975
976
  it('should strengthen `_strengthenOnPublish` references', () => {
976
- const _ref = crypto.randomUUID()
977
+ const _ref = randomUuid()
977
978
  const referenceToStrength: Reference = {
978
979
  _ref,
979
980
  _type: 'reference',