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