@sanity/sdk 3.0.0-rc.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-C0o1NOxW.d.ts +3165 -0
- package/dist/authStore-C0o1NOxW.d.ts.map +1 -0
- package/dist/authStore-CtE9bLHW.js +754 -0
- package/dist/authStore-CtE9bLHW.js.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 -3878
- 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-C7yMHPUI.js +1346 -0
- package/dist/queryStore-C7yMHPUI.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-UIEiMtC0.js +127 -0
- package/dist/telemetryManager-UIEiMtC0.js.map +1 -0
- package/dist/version-D8RP_Q59.js +9 -0
- package/dist/version-D8RP_Q59.js.map +1 -0
- package/package.json +11 -17
- 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 +45 -90
- 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/datasets/datasets.test.ts +44 -9
- package/src/datasets/datasets.ts +24 -23
- package/src/document/applyDocumentActions.test.ts +8 -12
- package/src/document/documentConstants.ts +17 -0
- package/src/document/documentStore.test.ts +173 -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 +357 -0
- package/src/document/reducers.ts +125 -27
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/favorites/favorites.test.ts +64 -24
- package/src/favorites/favorites.ts +73 -22
- 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/store/fetcherStore.test.ts +23 -1
- package/src/store/fetcherStore.ts +3 -3
- 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
- package/src/utils/createFetcherStore.test.ts +0 -171
- package/src/utils/createFetcherStore.ts +0 -284
|
@@ -73,14 +73,11 @@ export interface DatasetHandle<TDataset extends string = string, TProjectId exte
|
|
|
73
73
|
dataset?: TDataset
|
|
74
74
|
/**
|
|
75
75
|
* @beta
|
|
76
|
-
* Explicit resource
|
|
76
|
+
* Explicit resource to use. On an individual operation this overrides any default;
|
|
77
|
+
* on a `SanityConfig` it becomes the instance's default resource. Unlike
|
|
78
|
+
* `projectId`/`dataset`, this can also be a media library or canvas resource.
|
|
77
79
|
*/
|
|
78
80
|
resource?: DocumentResource
|
|
79
|
-
/**
|
|
80
|
-
* @deprecated Use `resource` instead.
|
|
81
|
-
* @beta
|
|
82
|
-
*/
|
|
83
|
-
source?: DocumentResource
|
|
84
81
|
}
|
|
85
82
|
|
|
86
83
|
/**
|
|
@@ -148,27 +145,6 @@ export interface SanityConfig extends DatasetHandle, PerspectiveHandle {
|
|
|
148
145
|
* `SDKStudioContext` provider. Can also be set explicitly for programmatic use.
|
|
149
146
|
*/
|
|
150
147
|
studio?: StudioConfig
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Studio mode configuration for use of the SDK in a Sanity Studio.
|
|
154
|
-
* @remarks Controls whether studio mode features are enabled.
|
|
155
|
-
* @deprecated Use `studio` instead, which provides richer integration
|
|
156
|
-
* with the Studio's workspace (auth token sync, etc.).
|
|
157
|
-
*/
|
|
158
|
-
studioMode?: {
|
|
159
|
-
enabled: boolean
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* @beta
|
|
164
|
-
* A list of named resources to use for this instance.
|
|
165
|
-
*/
|
|
166
|
-
resources?: Record<string, DocumentResource>
|
|
167
|
-
/**
|
|
168
|
-
* @deprecated Use `resources` instead.
|
|
169
|
-
* @beta
|
|
170
|
-
*/
|
|
171
|
-
sources?: Record<string, DocumentResource>
|
|
172
148
|
}
|
|
173
149
|
|
|
174
150
|
/**
|
|
@@ -216,51 +192,3 @@ export function isMediaLibraryResource(
|
|
|
216
192
|
export function isCanvasResource(resource: DocumentResource): resource is CanvasResource {
|
|
217
193
|
return 'canvasId' in resource
|
|
218
194
|
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* @deprecated Use `DocumentResource` instead.
|
|
222
|
-
* @beta
|
|
223
|
-
*/
|
|
224
|
-
export type DocumentSource = DocumentResource
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* @deprecated Use `DatasetResource` instead.
|
|
228
|
-
* @beta
|
|
229
|
-
*/
|
|
230
|
-
export type DatasetSource = DatasetResource
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* @deprecated Use `MediaLibraryResource` instead.
|
|
234
|
-
* @beta
|
|
235
|
-
*/
|
|
236
|
-
export type MediaLibrarySource = MediaLibraryResource
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* @deprecated Use `CanvasResource` instead.
|
|
240
|
-
* @beta
|
|
241
|
-
*/
|
|
242
|
-
export type CanvasSource = CanvasResource
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* @deprecated Use `isDatasetResource` instead.
|
|
246
|
-
* @beta
|
|
247
|
-
*/
|
|
248
|
-
export function isDatasetSource(source: DocumentSource): source is DatasetSource {
|
|
249
|
-
return isDatasetResource(source)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* @deprecated Use `isMediaLibraryResource` instead.
|
|
254
|
-
* @beta
|
|
255
|
-
*/
|
|
256
|
-
export function isMediaLibrarySource(source: DocumentSource): source is MediaLibrarySource {
|
|
257
|
-
return isMediaLibraryResource(source)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* @deprecated Use `isCanvasResource` instead.
|
|
262
|
-
* @beta
|
|
263
|
-
*/
|
|
264
|
-
export function isCanvasSource(source: DocumentSource): source is CanvasSource {
|
|
265
|
-
return isCanvasResource(source)
|
|
266
|
-
}
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import {type SanityClient} from '@sanity/client'
|
|
2
2
|
import {of} from 'rxjs'
|
|
3
|
-
import {describe, it} from 'vitest'
|
|
3
|
+
import {afterEach, beforeEach, describe, it} from 'vitest'
|
|
4
4
|
|
|
5
5
|
import {getClientState} from '../client/clientStore'
|
|
6
|
-
import {createSanityInstance} from '../store/createSanityInstance'
|
|
6
|
+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
7
7
|
import {type StateSource} from '../store/createStateSourceAction'
|
|
8
|
-
import {
|
|
8
|
+
import {datasets} from './datasets'
|
|
9
9
|
|
|
10
10
|
vi.mock('../client/clientStore')
|
|
11
11
|
|
|
12
12
|
describe('datasets', () => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
29
|
-
expect(result).toEqual(
|
|
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
|
})
|
package/src/datasets/datasets.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
projectId: (options?.projectId ?? instance.config.projectId)!,
|
|
31
|
+
projectId: resolveProjectId(instance, options),
|
|
24
32
|
useProjectHostname: true,
|
|
25
|
-
}).observable.pipe(switchMap((client) => client.observable.datasets.list()))
|
|
26
|
-
},
|
|
33
|
+
}).observable.pipe(switchMap((client) => client.observable.datasets.list())),
|
|
27
34
|
})
|
|
28
|
-
|
|
29
|
-
/** @public */
|
|
30
|
-
export const getDatasetsState = datasets.getState
|
|
31
|
-
|
|
32
|
-
/** @public */
|
|
33
|
-
export const resolveDatasets = datasets.resolveState
|
|
@@ -146,13 +146,10 @@ describe('applyDocumentActions', () => {
|
|
|
146
146
|
await expect(applyPromise).rejects.toThrow('Simulated error')
|
|
147
147
|
})
|
|
148
148
|
|
|
149
|
-
it('
|
|
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 () => {
|
|
@@ -9,9 +9,26 @@ export const DOCUMENT_STATE_CLEAR_DELAY = 1000
|
|
|
9
9
|
export const INITIAL_OUTGOING_THROTTLE_TIME = 1000
|
|
10
10
|
export const API_VERSION = 'v2025-05-06'
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* How long a submitted transaction's revision is kept around waiting to be
|
|
14
|
+
* verified by its listener echo. After this, the transaction is no longer
|
|
15
|
+
* treated as in flight and a document held onto only for that verification can
|
|
16
|
+
* be evicted. Sized to match the listener's own chaining deadline, after which
|
|
17
|
+
* an echo that has not arrived is not going to.
|
|
18
|
+
*/
|
|
19
|
+
export const UNVERIFIED_REVISION_RETENTION_TIME = 30_000
|
|
20
|
+
|
|
12
21
|
/**
|
|
13
22
|
* Base delay (ms) before retrying a document listener after an `OutOfSyncError`.
|
|
14
23
|
* Backoff doubles on each successive retry, capped at {@link OUT_OF_SYNC_RETRY_MAX_DELAY}.
|
|
15
24
|
*/
|
|
16
25
|
export const OUT_OF_SYNC_RETRY_BASE_DELAY = 500
|
|
17
26
|
export const OUT_OF_SYNC_RETRY_MAX_DELAY = 10_000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Base delay (ms) before retrying the dataset ACL fetch after a transient
|
|
30
|
+
* failure (a network error or 5xx response). Backoff doubles on each
|
|
31
|
+
* successive retry, capped at {@link ACL_RETRY_MAX_DELAY}.
|
|
32
|
+
*/
|
|
33
|
+
export const ACL_RETRY_BASE_DELAY = 500
|
|
34
|
+
export const ACL_RETRY_MAX_DELAY = 10_000
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type BaseActionOptions,
|
|
3
3
|
type BaseMutationOptions,
|
|
4
|
+
ClientError,
|
|
4
5
|
type FilteredResponseQueryOptions,
|
|
5
6
|
type ListenEvent,
|
|
6
7
|
type MultipleActionResult,
|
|
@@ -17,7 +18,18 @@ import {diffValue} from '@sanity/diff-patch'
|
|
|
17
18
|
import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
|
|
18
19
|
import {type Mutation, type SanityDocument} from '@sanity/types'
|
|
19
20
|
import {evaluate, parse} from 'groq-js'
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
defer,
|
|
23
|
+
delay,
|
|
24
|
+
first,
|
|
25
|
+
firstValueFrom,
|
|
26
|
+
from,
|
|
27
|
+
Observable,
|
|
28
|
+
of,
|
|
29
|
+
ReplaySubject,
|
|
30
|
+
Subject,
|
|
31
|
+
throwError,
|
|
32
|
+
} from 'rxjs'
|
|
21
33
|
import {afterEach, beforeEach, expect, it, vi} from 'vitest'
|
|
22
34
|
|
|
23
35
|
import {getClientState} from '../client/clientStore'
|
|
@@ -345,6 +357,75 @@ it('emits remote-patches events distinguishing own and foreign transactions', as
|
|
|
345
357
|
state2Unsubscribe()
|
|
346
358
|
})
|
|
347
359
|
|
|
360
|
+
it('labels an echo that arrives after the last subscriber left as its own', async () => {
|
|
361
|
+
const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
|
|
362
|
+
const draftId = getDraftId(DocumentId(doc.documentId))
|
|
363
|
+
const state = getDocumentState(instance, doc)
|
|
364
|
+
const unsubscribe = state.subscribe()
|
|
365
|
+
await vi.waitUntil(() => state.getCurrent() !== undefined)
|
|
366
|
+
const previousRev = state.getCurrent()?._rev
|
|
367
|
+
|
|
368
|
+
// ack without emitting the listener echo, so the echo can be delivered later
|
|
369
|
+
// and out of band, which is the ordering the real API produces
|
|
370
|
+
const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
|
|
371
|
+
vi.mocked(client.action).mockImplementation(
|
|
372
|
+
async (_input, {transactionId = crypto.randomUUID()} = {}) => ({transactionId}),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
const patchEvents: DocumentRemotePatchesEvent[] = []
|
|
376
|
+
const unsubscribeEvents = subscribeDocumentEvents(instance, {
|
|
377
|
+
resource,
|
|
378
|
+
eventHandler: (e) => {
|
|
379
|
+
if (e.type === 'remote-patches') patchEvents.push(e)
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
const edited = await applyDocumentActions(instance, {
|
|
384
|
+
actions: [editDocument(doc, {set: {title: 'edited while leaving'}})],
|
|
385
|
+
resource,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// the last subscriber leaves before the ack comes back, which used to delete
|
|
389
|
+
// the document state (and with it the memory of our own transaction) as soon
|
|
390
|
+
// as the ack released the transaction's subscription
|
|
391
|
+
unsubscribe()
|
|
392
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
393
|
+
await edited.submitted()
|
|
394
|
+
|
|
395
|
+
const {events: listenerEvents} = (
|
|
396
|
+
createSharedListener as () => {
|
|
397
|
+
dispose: () => void
|
|
398
|
+
events: Subject<ListenEvent<SanityDocument>>
|
|
399
|
+
}
|
|
400
|
+
)()
|
|
401
|
+
listenerEvents.next({
|
|
402
|
+
type: 'mutation',
|
|
403
|
+
documentId: draftId,
|
|
404
|
+
eventId: `${edited.transactionId}#${draftId}`,
|
|
405
|
+
identity: 'example-user',
|
|
406
|
+
mutations: [{patch: {id: draftId, set: {title: 'edited while leaving'}}}],
|
|
407
|
+
timestamp: new Date().toISOString(),
|
|
408
|
+
transactionId: edited.transactionId,
|
|
409
|
+
transactionCurrentEvent: 1,
|
|
410
|
+
transactionTotalEvents: 1,
|
|
411
|
+
transition: 'update',
|
|
412
|
+
visibility: 'query',
|
|
413
|
+
previousRev,
|
|
414
|
+
resultRev: edited.transactionId,
|
|
415
|
+
} satisfies MutationEvent)
|
|
416
|
+
await vi.waitUntil(() => patchEvents.length > 0)
|
|
417
|
+
|
|
418
|
+
expect(patchEvents).toHaveLength(1)
|
|
419
|
+
expect(patchEvents[0]).toMatchObject({
|
|
420
|
+
documentId: draftId,
|
|
421
|
+
transactionId: edited.transactionId,
|
|
422
|
+
origin: 'local',
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
unsubscribeEvents()
|
|
426
|
+
vi.mocked(client.action).mockImplementation(clientActionMockImplementation)
|
|
427
|
+
})
|
|
428
|
+
|
|
348
429
|
it('handles concurrent edits and resolves conflicts', async () => {
|
|
349
430
|
const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
|
|
350
431
|
const state1 = getDocumentState(instance1, doc)
|
|
@@ -917,24 +998,101 @@ it('does not send credentials with the dataset ACL request', async () => {
|
|
|
917
998
|
expect(aclCall![0]).not.toHaveProperty('withCredentials')
|
|
918
999
|
})
|
|
919
1000
|
|
|
920
|
-
it('
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1001
|
+
it('retries transient dataset ACL fetch failures instead of failing fatally', async () => {
|
|
1002
|
+
const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
|
|
1003
|
+
|
|
1004
|
+
// the first attempt fails like a dropped connection; subsequent attempts succeed
|
|
1005
|
+
let aclAttempts = 0
|
|
1006
|
+
client.observable.request = vi.fn().mockReturnValue(
|
|
1007
|
+
defer(() => {
|
|
1008
|
+
aclAttempts++
|
|
1009
|
+
if (aclAttempts === 1) return throwError(() => new Error('socket hang up'))
|
|
1010
|
+
return of(datasetAcl)
|
|
1011
|
+
}),
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
1015
|
+
const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
|
|
1016
|
+
|
|
1017
|
+
expect(result).toEqual({allowed: true})
|
|
1018
|
+
expect(aclAttempts).toBe(2)
|
|
1019
|
+
|
|
1020
|
+
// the store never entered the fatal error state
|
|
1021
|
+
const documentState = getDocumentState(instance, doc)
|
|
1022
|
+
expect(() => documentState.getCurrent()).not.toThrow()
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
it('does not retry dataset ACL fetch failures caused by 4xx client errors', async () => {
|
|
1026
|
+
const forbidden = new ClientError({
|
|
1027
|
+
statusCode: 403,
|
|
1028
|
+
headers: {},
|
|
1029
|
+
body: {error: 'Forbidden', message: 'Session does not have access to this dataset'},
|
|
927
1030
|
})
|
|
928
1031
|
|
|
1032
|
+
let aclAttempts = 0
|
|
1033
|
+
client.observable.request = vi.fn().mockReturnValue(
|
|
1034
|
+
defer(() => {
|
|
1035
|
+
aclAttempts++
|
|
1036
|
+
return throwError(() => forbidden)
|
|
1037
|
+
}),
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
1041
|
+
const documentState = getDocumentState(instance, doc)
|
|
1042
|
+
const unsubscribe = documentState.subscribe()
|
|
1043
|
+
|
|
1044
|
+
// the error is fatal: it surfaces through the store instead of being retried
|
|
1045
|
+
await vi.waitFor(() => {
|
|
1046
|
+
expect(() => documentState.getCurrent()).toThrow(
|
|
1047
|
+
'Forbidden - Session does not have access to this dataset',
|
|
1048
|
+
)
|
|
1049
|
+
})
|
|
1050
|
+
expect(aclAttempts).toBe(1)
|
|
1051
|
+
|
|
1052
|
+
unsubscribe()
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
it('retries dataset ACL fetch failures caused by 429 rate-limit errors', async () => {
|
|
1056
|
+
const rateLimited = new ClientError({
|
|
1057
|
+
statusCode: 429,
|
|
1058
|
+
headers: {},
|
|
1059
|
+
body: {error: 'Too Many Requests', message: 'Rate limit exceeded'},
|
|
1060
|
+
})
|
|
1061
|
+
const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
|
|
1062
|
+
|
|
1063
|
+
// the first attempt is rate limited; subsequent attempts succeed
|
|
1064
|
+
let aclAttempts = 0
|
|
1065
|
+
client.observable.request = vi.fn().mockReturnValue(
|
|
1066
|
+
defer(() => {
|
|
1067
|
+
aclAttempts++
|
|
1068
|
+
if (aclAttempts === 1) return throwError(() => rateLimited)
|
|
1069
|
+
return of(datasetAcl)
|
|
1070
|
+
}),
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
1074
|
+
const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
|
|
1075
|
+
|
|
1076
|
+
expect(result).toEqual({allowed: true})
|
|
1077
|
+
expect(aclAttempts).toBe(2)
|
|
1078
|
+
|
|
1079
|
+
// the store never entered the fatal error state
|
|
1080
|
+
const documentState = getDocumentState(instance, doc)
|
|
1081
|
+
expect(() => documentState.getCurrent()).not.toThrow()
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
it('fetches ACL for MediaLibraryResource', async () => {
|
|
1085
|
+
const mediaLibraryInstance = createSanityInstance()
|
|
1086
|
+
|
|
929
1087
|
const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
|
|
930
1088
|
vi.mocked(client.request).mockResolvedValue(datasetAcl)
|
|
931
1089
|
|
|
932
1090
|
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
933
|
-
const
|
|
1091
|
+
const mediaLibraryResource = {mediaLibraryId: 'test-media-library'}
|
|
934
1092
|
|
|
935
1093
|
const result = await resolvePermissions(mediaLibraryInstance, {
|
|
936
1094
|
actions: [createDocument(doc)],
|
|
937
|
-
resource:
|
|
1095
|
+
resource: mediaLibraryResource,
|
|
938
1096
|
})
|
|
939
1097
|
|
|
940
1098
|
expect(result).toEqual({allowed: true})
|
|
@@ -942,23 +1100,17 @@ it('fetches ACL for MediaLibraryResource', async () => {
|
|
|
942
1100
|
})
|
|
943
1101
|
|
|
944
1102
|
it('fetches ACL for CanvasResource', async () => {
|
|
945
|
-
const canvasInstance = createSanityInstance(
|
|
946
|
-
projectId: 'p',
|
|
947
|
-
dataset: 'd',
|
|
948
|
-
resources: {
|
|
949
|
-
canvas: {canvasId: 'test-canvas'},
|
|
950
|
-
},
|
|
951
|
-
})
|
|
1103
|
+
const canvasInstance = createSanityInstance()
|
|
952
1104
|
|
|
953
1105
|
const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
|
|
954
1106
|
vi.mocked(client.request).mockResolvedValue(datasetAcl)
|
|
955
1107
|
|
|
956
1108
|
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
957
|
-
const
|
|
1109
|
+
const canvasResource = {canvasId: 'test-canvas'}
|
|
958
1110
|
|
|
959
1111
|
const result = await resolvePermissions(canvasInstance, {
|
|
960
1112
|
actions: [createDocument(doc)],
|
|
961
|
-
resource:
|
|
1113
|
+
resource: canvasResource,
|
|
962
1114
|
})
|
|
963
1115
|
|
|
964
1116
|
expect(result).toEqual({allowed: true})
|
|
@@ -1250,6 +1402,8 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
|
|
|
1250
1402
|
DOCUMENT_STATE_CLEAR_DELAY: 25,
|
|
1251
1403
|
OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
|
|
1252
1404
|
OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
|
|
1405
|
+
ACL_RETRY_BASE_DELAY: 0,
|
|
1406
|
+
ACL_RETRY_MAX_DELAY: 0,
|
|
1253
1407
|
}
|
|
1254
1408
|
})
|
|
1255
1409
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type Action, type Mutation} from '@sanity/client'
|
|
1
|
+
import {type Action, ClientError, CorsOriginError, type Mutation} from '@sanity/client'
|
|
2
2
|
import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
|
|
3
3
|
import {jsonMatch} from '@sanity/json-match'
|
|
4
4
|
import {type SanityDocument} from 'groq'
|
|
@@ -48,6 +48,8 @@ import {createStateSourceAction, type StateSource} from '../store/createStateSou
|
|
|
48
48
|
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
49
49
|
import {type DocumentAction} from './actions'
|
|
50
50
|
import {
|
|
51
|
+
ACL_RETRY_BASE_DELAY,
|
|
52
|
+
ACL_RETRY_MAX_DELAY,
|
|
51
53
|
API_VERSION,
|
|
52
54
|
INITIAL_OUTGOING_THROTTLE_TIME,
|
|
53
55
|
OUT_OF_SYNC_RETRY_BASE_DELAY,
|
|
@@ -562,10 +564,39 @@ const subscribeToClientAndFetchDatasetAcl = ({
|
|
|
562
564
|
return getClientState(instance, clientOptions)
|
|
563
565
|
.observable.pipe(
|
|
564
566
|
switchMap((client) =>
|
|
565
|
-
client.observable
|
|
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 = {
|