@sanity/sdk 3.0.0-rc.0 → 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 +163 -0
- package/dist/_exports/_internal.d.ts.map +1 -0
- package/dist/_exports/_internal.js +332 -0
- package/dist/_exports/_internal.js.map +1 -0
- 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 -2332
- package/dist/index.js +4171 -4332
- 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 +47 -52
- package/src/_exports/_internal.ts +27 -0
- package/src/_exports/agent.ts +19 -0
- package/src/_exports/comlink.ts +16 -0
- package/src/_exports/index.ts +125 -64
- package/src/access/checkPermissions.test.ts +55 -0
- package/src/access/checkPermissions.ts +54 -0
- package/src/agent/agentActions.test.ts +16 -60
- package/src/agent/agentActions.ts +25 -38
- package/src/applications/applications.test-d.ts +88 -0
- package/src/applications/applications.test.ts +130 -0
- package/src/applications/applications.ts +367 -0
- package/src/auth/authLogger.ts +30 -0
- package/src/auth/authStore.test.ts +194 -13
- package/src/auth/authStore.ts +58 -26
- package/src/auth/getOrganizationVerificationState.test.ts +18 -17
- package/src/auth/getOrganizationVerificationState.ts +4 -4
- package/src/auth/handleAuthCallback.test.ts +35 -1
- package/src/auth/handleAuthCallback.ts +25 -6
- package/src/auth/logout.test.ts +75 -2
- package/src/auth/logout.ts +23 -4
- package/src/auth/refreshStampedToken.test.ts +219 -469
- package/src/auth/refreshStampedToken.ts +79 -195
- package/src/auth/studioAuth.ts +1 -1
- package/src/auth/studioModeAuth.ts +2 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
- package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
- package/src/client/clientStore.test.ts +62 -37
- package/src/client/clientStore.ts +45 -75
- package/src/client/liveEvents.test.ts +151 -0
- package/src/client/liveEvents.ts +107 -0
- package/src/comlink/controller/actions/destroyController.test.ts +4 -1
- package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
- package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
- package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
- package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
- package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
- package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
- package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
- package/src/comlink/node/actions/releaseNode.test.ts +7 -4
- package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
- package/src/comlink/node/getNodeState.test.ts +1 -1
- package/src/comlink/node/getNodeState.ts +2 -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/__tests__/handles.test.ts +18 -12
- package/src/config/handles.ts +25 -7
- package/src/config/sanityConfig.ts +40 -82
- package/src/datasets/datasets.test.ts +2 -2
- package/src/datasets/datasets.ts +10 -4
- package/src/document/actions.test.ts +115 -33
- package/src/document/actions.ts +212 -15
- package/src/document/applyDocumentActions.test.ts +35 -13
- package/src/document/applyDocumentActions.ts +30 -9
- package/src/document/documentConstants.ts +15 -0
- package/src/document/documentStore.concurrency.test.ts +1306 -0
- package/src/document/documentStore.test.ts +753 -273
- package/src/document/documentStore.ts +197 -75
- package/src/document/events.test.ts +57 -2
- package/src/document/events.ts +82 -26
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +74 -21
- package/src/document/listenerEventOperators.test.ts +311 -0
- package/src/document/listenerEventOperators.ts +217 -0
- package/src/document/patchOperations.test.ts +76 -2
- package/src/document/patchOperations.ts +60 -1
- package/src/document/permissions.test.ts +107 -60
- package/src/document/permissions.ts +44 -19
- package/src/document/processActions/create.ts +138 -0
- package/src/document/processActions/delete.ts +100 -0
- package/src/document/processActions/discard.ts +63 -0
- package/src/document/processActions/edit.ts +154 -0
- package/src/document/processActions/processActions.ts +218 -0
- package/src/document/processActions/publish.ts +120 -0
- package/src/document/processActions/releaseArchive.ts +77 -0
- package/src/document/processActions/releaseCreate.ts +59 -0
- package/src/document/processActions/releaseDelete.ts +65 -0
- package/src/document/processActions/releaseEdit.ts +37 -0
- package/src/document/processActions/releasePublish.ts +45 -0
- package/src/document/processActions/releaseSchedule.ts +87 -0
- package/src/document/processActions/releaseUtil.ts +31 -0
- package/src/document/processActions/shared.ts +308 -0
- package/src/document/processActions/unpublish.ts +85 -0
- package/src/document/processActions.test.ts +1129 -121
- package/src/document/reducers.test.ts +772 -64
- package/src/document/reducers.ts +205 -51
- package/src/document/resourceRules.test.ts +178 -0
- package/src/document/resourceRules.ts +117 -0
- package/src/document/sharedListener.test.ts +16 -14
- package/src/document/sharedListener.ts +24 -8
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +50 -0
- package/src/favorites/favorites.test.ts +5 -6
- package/src/installations/installations.test-d.ts +73 -0
- package/src/installations/installations.test.ts +62 -0
- package/src/installations/installations.ts +206 -0
- package/src/organization/organization.test-d.ts +36 -0
- package/src/organization/organization.test.ts +138 -0
- package/src/organization/organization.ts +138 -0
- package/src/organizations/organizations.test-d.ts +56 -0
- package/src/organizations/organizations.test.ts +150 -0
- package/src/organizations/organizations.ts +107 -0
- package/src/presence/bifurTransport.test.ts +201 -9
- package/src/presence/bifurTransport.ts +98 -14
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +915 -24
- package/src/presence/presenceStore.ts +576 -104
- package/src/presence/types.ts +175 -3
- package/src/preview/previewProjectionUtils.test.ts +9 -22
- package/src/preview/previewProjectionUtils.ts +7 -3
- package/src/preview/types.ts +0 -15
- package/src/project/project.test-d.ts +37 -0
- package/src/project/project.test.ts +157 -12
- package/src/project/project.ts +138 -25
- package/src/projection/getProjectionState.test.ts +18 -20
- package/src/projection/getProjectionState.ts +23 -3
- package/src/projection/projectionQuery.ts +5 -5
- package/src/projection/projectionStore.test.ts +1 -9
- package/src/projection/resolveProjection.test.ts +1 -2
- package/src/projection/resolveProjection.ts +16 -0
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
- package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
- package/src/projects/projects.test-d.ts +32 -0
- package/src/projects/projects.test.ts +108 -42
- package/src/projects/projects.ts +63 -27
- package/src/query/queryStore.test.ts +259 -123
- package/src/query/queryStore.ts +85 -69
- package/src/query/reducers.test.ts +0 -42
- package/src/query/reducers.ts +2 -18
- package/src/releases/getPerspectiveState.test.ts +17 -23
- package/src/releases/getPerspectiveState.ts +8 -20
- package/src/releases/observeReleases.test.ts +127 -0
- package/src/releases/observeReleases.ts +102 -0
- package/src/releases/releasesStore.test.ts +100 -53
- package/src/releases/releasesStore.ts +69 -44
- package/src/releases/utils/sortReleases.test.ts +38 -2
- package/src/releases/utils/sortReleases.ts +15 -10
- package/src/store/createActionBinder.test.ts +109 -90
- package/src/store/createActionBinder.ts +36 -28
- package/src/store/createSanityInstance.test.ts +15 -13
- package/src/store/createSanityInstance.ts +14 -2
- package/src/store/createStateSourceAction.test.ts +64 -2
- package/src/store/createStateSourceAction.ts +38 -43
- package/src/store/createStoreInstance.test.ts +4 -2
- package/src/store/fetcherStore.test-d.ts +121 -0
- package/src/store/fetcherStore.test.ts +299 -0
- package/src/store/fetcherStore.ts +801 -0
- package/src/telemetry/environment.test.ts +119 -0
- package/src/telemetry/environment.ts +92 -0
- package/src/telemetry/events.ts +42 -0
- package/src/telemetry/initTelemetry.test.ts +432 -0
- package/src/telemetry/initTelemetry.ts +218 -0
- package/src/telemetry/telemetryManager.test.ts +327 -0
- package/src/telemetry/telemetryManager.ts +199 -0
- package/src/users/reducers.test.ts +3 -1
- package/src/users/reducers.ts +5 -6
- package/src/users/types.ts +4 -4
- package/src/users/usersStore.test.ts +16 -12
- package/src/users/usersStore.ts +5 -1
- package/src/utils/buildQuery.test.ts +14 -0
- package/src/utils/buildQuery.ts +17 -0
- package/src/utils/createFetcherStore.test.ts +7 -5
- package/src/utils/createFetcherStore.ts +8 -5
- package/src/utils/getStagingApiHost.test.ts +21 -0
- package/src/utils/getStagingApiHost.ts +14 -0
- package/src/utils/ids.test.ts +1 -29
- package/src/utils/ids.ts +0 -10
- package/src/utils/includeShape.ts +36 -0
- package/src/utils/isImportError.test.ts +72 -0
- package/src/utils/isImportError.ts +34 -0
- package/src/utils/logger.test.ts +12 -0
- package/src/utils/logger.ts +8 -3
- package/src/utils/object.test.ts +95 -0
- package/src/utils/object.ts +142 -0
- package/src/utils/setCleanupTimeout.ts +37 -0
- package/src/document/processActions.ts +0 -707
- package/src/utils/defineIntent.test.ts +0 -477
- package/src/utils/defineIntent.ts +0 -244
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type BaseActionOptions,
|
|
3
|
+
type BaseMutationOptions,
|
|
4
|
+
ClientError,
|
|
3
5
|
type FilteredResponseQueryOptions,
|
|
4
6
|
type ListenEvent,
|
|
5
7
|
type MultipleActionResult,
|
|
8
|
+
type MultipleMutationResult,
|
|
6
9
|
type MutationEvent,
|
|
7
10
|
type RawQueryResponse,
|
|
8
11
|
type ResponseQueryOptions,
|
|
@@ -12,16 +15,27 @@ import {
|
|
|
12
15
|
type WelcomeEvent,
|
|
13
16
|
} from '@sanity/client'
|
|
14
17
|
import {diffValue} from '@sanity/diff-patch'
|
|
18
|
+
import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
|
|
15
19
|
import {type Mutation, type SanityDocument} from '@sanity/types'
|
|
16
20
|
import {evaluate, parse} from 'groq-js'
|
|
17
|
-
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'
|
|
18
33
|
import {afterEach, beforeEach, expect, it, vi} from 'vitest'
|
|
19
34
|
|
|
20
35
|
import {getClientState} from '../client/clientStore'
|
|
21
36
|
import {createDocumentHandle} from '../config/handles'
|
|
22
37
|
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
23
38
|
import {type StateSource} from '../store/createStateSourceAction'
|
|
24
|
-
import {getDraftId, getPublishedId} from '../utils/ids'
|
|
25
39
|
import {
|
|
26
40
|
createDocument,
|
|
27
41
|
deleteDocument,
|
|
@@ -39,7 +53,12 @@ import {
|
|
|
39
53
|
resolvePermissions,
|
|
40
54
|
subscribeDocumentEvents,
|
|
41
55
|
} from './documentStore'
|
|
42
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
type ActionErrorEvent,
|
|
58
|
+
type DocumentRemotePatchesEvent,
|
|
59
|
+
type TransactionRevertedEvent,
|
|
60
|
+
} from './events'
|
|
61
|
+
import {DEFAULT_MAX_BUFFER_SIZE} from './listen'
|
|
43
62
|
import {type DatasetAcl} from './permissions'
|
|
44
63
|
import {type DocumentSet, processMutations} from './processMutations'
|
|
45
64
|
import {type HttpAction} from './reducers'
|
|
@@ -51,23 +70,42 @@ interface TestDocument extends SanityDocument {
|
|
|
51
70
|
title?: string
|
|
52
71
|
}
|
|
53
72
|
|
|
73
|
+
// Scope the TestDocument type to the project/datasets used in tests
|
|
74
|
+
type AllTestSchemaTypes = TestDocument
|
|
75
|
+
|
|
76
|
+
// Augment the 'groq' module
|
|
77
|
+
declare module 'groq' {
|
|
78
|
+
interface SanitySchemas {
|
|
79
|
+
default: AllTestSchemaTypes
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
let instance: SanityInstance
|
|
84
|
+
let instance1: SanityInstance
|
|
85
|
+
let instance2: SanityInstance
|
|
55
86
|
|
|
56
|
-
// bindActionByResource should ensure docs from different resources are isolated
|
|
57
87
|
const resource = {projectId: 'p', dataset: 'd'}
|
|
58
|
-
const
|
|
59
|
-
const
|
|
88
|
+
const source1 = {projectId: 'p', dataset: 'd1'}
|
|
89
|
+
const source2 = {projectId: 'p', dataset: 'd2'}
|
|
60
90
|
|
|
61
91
|
beforeEach(() => {
|
|
62
|
-
instance = createSanityInstance()
|
|
92
|
+
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
93
|
+
// test uses two instances that share the same in-memory dataset, but separate
|
|
94
|
+
// store instances. in real scenarios, this would be separate machines but with
|
|
95
|
+
// the same project + dataset
|
|
96
|
+
instance1 = createSanityInstance({projectId: 'p', dataset: 'd1'})
|
|
97
|
+
instance2 = createSanityInstance({projectId: 'p', dataset: 'd2'})
|
|
63
98
|
})
|
|
64
99
|
|
|
65
100
|
afterEach(() => {
|
|
66
101
|
instance?.dispose()
|
|
102
|
+
instance1?.dispose()
|
|
103
|
+
instance2?.dispose()
|
|
67
104
|
})
|
|
68
105
|
|
|
69
106
|
it('creates, edits, and publishes a document', async () => {
|
|
70
|
-
const
|
|
107
|
+
const documentId = DocumentId('doc-single')
|
|
108
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
71
109
|
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
72
110
|
|
|
73
111
|
// Initially the document is undefined
|
|
@@ -80,10 +118,10 @@ it('creates, edits, and publishes a document', async () => {
|
|
|
80
118
|
actions: [createDocument(doc)],
|
|
81
119
|
resource,
|
|
82
120
|
})
|
|
83
|
-
expect(appeared).toContain(getDraftId(
|
|
121
|
+
expect(appeared).toContain(getDraftId(documentId))
|
|
84
122
|
|
|
85
123
|
let currentDoc = documentState.getCurrent()
|
|
86
|
-
expect(currentDoc?._id).toEqual(getDraftId(
|
|
124
|
+
expect(currentDoc?._id).toEqual(getDraftId(documentId))
|
|
87
125
|
|
|
88
126
|
// Edit the document – add a title
|
|
89
127
|
await applyDocumentActions(instance, {
|
|
@@ -101,16 +139,13 @@ it('creates, edits, and publishes a document', async () => {
|
|
|
101
139
|
await submitted()
|
|
102
140
|
currentDoc = documentState.getCurrent()
|
|
103
141
|
|
|
104
|
-
expect(currentDoc).toMatchObject({_id:
|
|
142
|
+
expect(currentDoc).toMatchObject({_id: documentId, _rev: transactionId})
|
|
105
143
|
unsubscribe()
|
|
106
144
|
})
|
|
107
145
|
|
|
108
146
|
it('creates a document with initial values', async () => {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
documentType: 'article',
|
|
112
|
-
resource,
|
|
113
|
-
})
|
|
147
|
+
const documentId = DocumentId('doc-with-initial')
|
|
148
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
114
149
|
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
115
150
|
|
|
116
151
|
expect(documentState.getCurrent()).toBeUndefined()
|
|
@@ -128,10 +163,10 @@ it('creates a document with initial values', async () => {
|
|
|
128
163
|
],
|
|
129
164
|
resource,
|
|
130
165
|
})
|
|
131
|
-
expect(appeared).toContain(getDraftId(
|
|
166
|
+
expect(appeared).toContain(getDraftId(documentId))
|
|
132
167
|
|
|
133
168
|
const currentDoc = documentState.getCurrent()
|
|
134
|
-
expect(currentDoc?._id).toEqual(getDraftId(
|
|
169
|
+
expect(currentDoc?._id).toEqual(getDraftId(documentId))
|
|
135
170
|
expect(currentDoc?.title).toEqual('Article with Initial Values')
|
|
136
171
|
expect(currentDoc?.['author']).toEqual('Jane Doe')
|
|
137
172
|
expect(currentDoc?.['count']).toEqual(42)
|
|
@@ -140,7 +175,8 @@ it('creates a document with initial values', async () => {
|
|
|
140
175
|
})
|
|
141
176
|
|
|
142
177
|
it('edits existing documents', async () => {
|
|
143
|
-
const
|
|
178
|
+
const documentId = DocumentId('existing-doc')
|
|
179
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
144
180
|
const state = getDocumentState<TestDocument>(instance, doc)
|
|
145
181
|
|
|
146
182
|
// not subscribed yet so the value is undefined
|
|
@@ -152,7 +188,7 @@ it('edits existing documents', async () => {
|
|
|
152
188
|
await firstValueFrom(state.observable.pipe(first((i) => !!i)))
|
|
153
189
|
|
|
154
190
|
expect(state.getCurrent()).toMatchObject({
|
|
155
|
-
_id: getDraftId(
|
|
191
|
+
_id: getDraftId(documentId),
|
|
156
192
|
title: 'existing doc',
|
|
157
193
|
})
|
|
158
194
|
|
|
@@ -161,7 +197,7 @@ it('edits existing documents', async () => {
|
|
|
161
197
|
resource,
|
|
162
198
|
})
|
|
163
199
|
expect(state.getCurrent()).toMatchObject({
|
|
164
|
-
_id: getDraftId(
|
|
200
|
+
_id: getDraftId(documentId),
|
|
165
201
|
title: 'updated title',
|
|
166
202
|
})
|
|
167
203
|
|
|
@@ -169,33 +205,31 @@ it('edits existing documents', async () => {
|
|
|
169
205
|
})
|
|
170
206
|
|
|
171
207
|
it('sets optimistic changes synchronously', async () => {
|
|
172
|
-
const doc1 =
|
|
173
|
-
documentId: 'optimistic',
|
|
208
|
+
const doc1 = {
|
|
209
|
+
documentId: DocumentId('optimistic'),
|
|
174
210
|
documentType: 'article',
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
documentId: 'optimistic',
|
|
211
|
+
}
|
|
212
|
+
const doc2 = {
|
|
213
|
+
documentId: DocumentId('optimistic'),
|
|
179
214
|
documentType: 'article',
|
|
180
|
-
|
|
181
|
-
})
|
|
215
|
+
}
|
|
182
216
|
|
|
183
|
-
const state1 = getDocumentState
|
|
184
|
-
const state2 = getDocumentState
|
|
217
|
+
const state1 = getDocumentState(instance1, doc1)
|
|
218
|
+
const state2 = getDocumentState(instance2, doc2)
|
|
185
219
|
|
|
186
220
|
const unsubscribe1 = state1.subscribe()
|
|
187
221
|
const unsubscribe2 = state2.subscribe()
|
|
188
222
|
|
|
189
223
|
// wait until the value is primed in the store
|
|
190
|
-
await resolveDocument(
|
|
224
|
+
await resolveDocument(instance1, doc1)
|
|
191
225
|
|
|
192
226
|
// then the actions are synchronous
|
|
193
227
|
expect(state1.getCurrent()).toBeNull()
|
|
194
|
-
applyDocumentActions(
|
|
228
|
+
applyDocumentActions(instance1, {actions: [createDocument(doc1)], resource: source1})
|
|
195
229
|
expect(state1.getCurrent()).toMatchObject({_id: getDraftId(doc1.documentId)})
|
|
196
|
-
const actionResult1Promise = applyDocumentActions(
|
|
230
|
+
const actionResult1Promise = applyDocumentActions(instance1, {
|
|
197
231
|
actions: [editDocument(doc1, {set: {title: 'initial title'}})],
|
|
198
|
-
resource:
|
|
232
|
+
resource: source1,
|
|
199
233
|
})
|
|
200
234
|
expect(state1.getCurrent()?.title).toBe('initial title')
|
|
201
235
|
|
|
@@ -214,9 +248,9 @@ it('sets optimistic changes synchronously', async () => {
|
|
|
214
248
|
expect(state2.getCurrent()?.title).toBe('initial title')
|
|
215
249
|
|
|
216
250
|
// synchronous for state 2
|
|
217
|
-
const actionResult2Promise = applyDocumentActions(
|
|
251
|
+
const actionResult2Promise = applyDocumentActions(instance2, {
|
|
218
252
|
actions: [editDocument(doc2, {set: {title: 'updated title'}})],
|
|
219
|
-
resource:
|
|
253
|
+
resource: source2,
|
|
220
254
|
})
|
|
221
255
|
expect(state2.getCurrent()?.title).toBe('updated title')
|
|
222
256
|
// async for state 1
|
|
@@ -231,37 +265,27 @@ it('sets optimistic changes synchronously', async () => {
|
|
|
231
265
|
})
|
|
232
266
|
|
|
233
267
|
it('propagates changes between two instances', async () => {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
resource: resource1,
|
|
238
|
-
})
|
|
239
|
-
const doc2 = createDocumentHandle({
|
|
240
|
-
documentId: 'doc-collab',
|
|
241
|
-
documentType: 'article',
|
|
242
|
-
resource: resource2,
|
|
243
|
-
})
|
|
244
|
-
const state1 = getDocumentState<TestDocument>(instance, doc1)
|
|
245
|
-
const state2 = getDocumentState<TestDocument>(instance, doc2)
|
|
268
|
+
const doc = createDocumentHandle({documentId: 'doc-collab', documentType: 'article'})
|
|
269
|
+
const state1 = getDocumentState(instance1, doc)
|
|
270
|
+
const state2 = getDocumentState(instance2, doc)
|
|
246
271
|
|
|
247
272
|
const state1Unsubscribe = state1.subscribe()
|
|
248
273
|
const state2Unsubscribe = state2.subscribe()
|
|
249
274
|
|
|
250
|
-
// Create the document from
|
|
251
|
-
await applyDocumentActions(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}).then((r) => r.submitted())
|
|
275
|
+
// Create the document from instance1.
|
|
276
|
+
await applyDocumentActions(instance1, {actions: [createDocument(doc)], resource: source1}).then(
|
|
277
|
+
(r) => r.submitted(),
|
|
278
|
+
)
|
|
255
279
|
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
expect(
|
|
259
|
-
expect(
|
|
280
|
+
const doc1 = state1.getCurrent()
|
|
281
|
+
const doc2 = state2.getCurrent()
|
|
282
|
+
expect(doc1?._id).toEqual(getDraftId(DocumentId(doc.documentId)))
|
|
283
|
+
expect(doc2?._id).toEqual(getDraftId(DocumentId(doc.documentId)))
|
|
260
284
|
|
|
261
|
-
// Now, edit the document from
|
|
262
|
-
await applyDocumentActions(
|
|
263
|
-
actions: [editDocument(
|
|
264
|
-
resource:
|
|
285
|
+
// Now, edit the document from instance2.
|
|
286
|
+
await applyDocumentActions(instance2, {
|
|
287
|
+
actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
|
|
288
|
+
resource: source2,
|
|
265
289
|
}).then((r) => r.submitted())
|
|
266
290
|
|
|
267
291
|
const updated1 = state1.getCurrent()
|
|
@@ -273,44 +297,95 @@ it('propagates changes between two instances', async () => {
|
|
|
273
297
|
state2Unsubscribe()
|
|
274
298
|
})
|
|
275
299
|
|
|
276
|
-
it('
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
300
|
+
it('emits remote-patches events distinguishing own and foreign transactions', async () => {
|
|
301
|
+
const doc = createDocumentHandle({documentId: 'doc-remote-patches', documentType: 'article'})
|
|
302
|
+
const state1 = getDocumentState(instance1, doc)
|
|
303
|
+
const state2 = getDocumentState(instance2, doc)
|
|
304
|
+
|
|
305
|
+
const state1Unsubscribe = state1.subscribe()
|
|
306
|
+
const state2Unsubscribe = state2.subscribe()
|
|
307
|
+
|
|
308
|
+
await applyDocumentActions(instance1, {actions: [createDocument(doc)], resource: source1}).then(
|
|
309
|
+
(r) => r.submitted(),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
const events1: DocumentRemotePatchesEvent[] = []
|
|
313
|
+
const events2: DocumentRemotePatchesEvent[] = []
|
|
314
|
+
const unsubscribeEvents1 = subscribeDocumentEvents(instance1, {
|
|
315
|
+
resource: source1,
|
|
316
|
+
eventHandler: (e) => {
|
|
317
|
+
if (e.type === 'remote-patches') events1.push(e)
|
|
318
|
+
},
|
|
281
319
|
})
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
320
|
+
const unsubscribeEvents2 = subscribeDocumentEvents(instance2, {
|
|
321
|
+
resource: source2,
|
|
322
|
+
eventHandler: (e) => {
|
|
323
|
+
if (e.type === 'remote-patches') events2.push(e)
|
|
324
|
+
},
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// edit from instance2: an own transaction for instance2, a foreign one for instance1
|
|
328
|
+
const result = await applyDocumentActions(instance2, {
|
|
329
|
+
actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
|
|
330
|
+
resource: source2,
|
|
331
|
+
}).then((r) => r.submitted())
|
|
332
|
+
|
|
333
|
+
const draftId = getDraftId(DocumentId(doc.documentId))
|
|
334
|
+
|
|
335
|
+
expect(events1).toHaveLength(1)
|
|
336
|
+
expect(events1[0]).toMatchObject({
|
|
337
|
+
type: 'remote-patches',
|
|
338
|
+
documentId: draftId,
|
|
339
|
+
transactionId: result.transactionId,
|
|
340
|
+
origin: 'remote',
|
|
286
341
|
})
|
|
287
|
-
|
|
288
|
-
|
|
342
|
+
expect(events1[0].patches).toEqual(
|
|
343
|
+
expect.arrayContaining([{set: expect.objectContaining({title: 'Hello world!'})}]),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
expect(events2).toHaveLength(1)
|
|
347
|
+
expect(events2[0]).toMatchObject({
|
|
348
|
+
type: 'remote-patches',
|
|
349
|
+
documentId: draftId,
|
|
350
|
+
transactionId: result.transactionId,
|
|
351
|
+
origin: 'local',
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
unsubscribeEvents1()
|
|
355
|
+
unsubscribeEvents2()
|
|
356
|
+
state1Unsubscribe()
|
|
357
|
+
state2Unsubscribe()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('handles concurrent edits and resolves conflicts', async () => {
|
|
361
|
+
const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
|
|
362
|
+
const state1 = getDocumentState(instance1, doc)
|
|
363
|
+
const state2 = getDocumentState(instance2, doc)
|
|
289
364
|
|
|
290
365
|
const state1Unsubscribe = state1.subscribe()
|
|
291
366
|
const state2Unsubscribe = state2.subscribe()
|
|
292
367
|
|
|
293
|
-
const oneOffInstance = createSanityInstance()
|
|
368
|
+
const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
294
369
|
|
|
295
370
|
// Create the initial document from a one-off instance.
|
|
296
371
|
await applyDocumentActions(oneOffInstance, {
|
|
297
372
|
actions: [
|
|
298
|
-
createDocument(
|
|
299
|
-
editDocument(
|
|
373
|
+
createDocument(doc),
|
|
374
|
+
editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy dog'}}),
|
|
300
375
|
],
|
|
301
376
|
resource,
|
|
302
377
|
}).then((res) => res.submitted())
|
|
303
378
|
|
|
304
|
-
// Both
|
|
305
|
-
const p1 = applyDocumentActions(
|
|
306
|
-
actions: [editDocument(
|
|
307
|
-
resource:
|
|
379
|
+
// Both instances now issue an edit simultaneously.
|
|
380
|
+
const p1 = applyDocumentActions(instance1, {
|
|
381
|
+
actions: [editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy cat'}})],
|
|
382
|
+
resource: source1,
|
|
308
383
|
}).then((r) => r.submitted())
|
|
309
|
-
const p2 = applyDocumentActions(
|
|
384
|
+
const p2 = applyDocumentActions(instance2, {
|
|
310
385
|
actions: [
|
|
311
|
-
editDocument(
|
|
386
|
+
editDocument(doc, {set: {title: 'The quick brown elephant jumps over the lazy dog'}}),
|
|
312
387
|
],
|
|
313
|
-
resource:
|
|
388
|
+
resource: source2,
|
|
314
389
|
}).then((r) => r.submitted())
|
|
315
390
|
|
|
316
391
|
// Wait for both actions to complete (or reject).
|
|
@@ -326,8 +401,76 @@ it('handles concurrent edits and resolves conflicts', async () => {
|
|
|
326
401
|
oneOffInstance.dispose()
|
|
327
402
|
})
|
|
328
403
|
|
|
404
|
+
it('interleaves concurrent keyed-array edits when operations are preserved', async () => {
|
|
405
|
+
const doc = createDocumentHandle({documentId: 'doc-preserve-ops', documentType: 'article'})
|
|
406
|
+
const state1 = getDocumentState(instance1, doc)
|
|
407
|
+
const state2 = getDocumentState(instance2, doc)
|
|
408
|
+
|
|
409
|
+
const state1Unsubscribe = state1.subscribe()
|
|
410
|
+
const state2Unsubscribe = state2.subscribe()
|
|
411
|
+
|
|
412
|
+
const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
413
|
+
await applyDocumentActions(oneOffInstance, {
|
|
414
|
+
actions: [
|
|
415
|
+
createDocument(doc),
|
|
416
|
+
editDocument(doc, {
|
|
417
|
+
set: {
|
|
418
|
+
items: [
|
|
419
|
+
{_key: 'a', value: 'first'},
|
|
420
|
+
{_key: 'b', value: 'second'},
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
}),
|
|
424
|
+
],
|
|
425
|
+
resource,
|
|
426
|
+
}).then((r) => r.submitted())
|
|
427
|
+
|
|
428
|
+
// both instances insert into the same keyed array simultaneously, each
|
|
429
|
+
// preserving their operational patches instead of re-diffing snapshots
|
|
430
|
+
const p1 = applyDocumentActions(instance1, {
|
|
431
|
+
actions: [
|
|
432
|
+
editDocument(
|
|
433
|
+
doc,
|
|
434
|
+
{insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'from instance1'}]}},
|
|
435
|
+
{preserveOperations: true},
|
|
436
|
+
),
|
|
437
|
+
],
|
|
438
|
+
resource: source1,
|
|
439
|
+
}).then((r) => r.submitted())
|
|
440
|
+
const p2 = applyDocumentActions(instance2, {
|
|
441
|
+
actions: [
|
|
442
|
+
editDocument(
|
|
443
|
+
doc,
|
|
444
|
+
{insert: {after: 'items[_key=="b"]', items: [{_key: 'd', value: 'from instance2'}]}},
|
|
445
|
+
{preserveOperations: true},
|
|
446
|
+
),
|
|
447
|
+
],
|
|
448
|
+
resource: source2,
|
|
449
|
+
}).then((r) => r.submitted())
|
|
450
|
+
|
|
451
|
+
await Promise.allSettled([p1, p2])
|
|
452
|
+
|
|
453
|
+
const finalDoc1 = state1.getCurrent()
|
|
454
|
+
const finalDoc2 = state2.getCurrent()
|
|
455
|
+
|
|
456
|
+
// neither edit overwrote the other: both keyed inserts landed and both
|
|
457
|
+
// instances converged on the same interleaved result
|
|
458
|
+
expect(finalDoc1?.['items']).toEqual(finalDoc2?.['items'])
|
|
459
|
+
expect(finalDoc1?.['items']).toEqual([
|
|
460
|
+
{_key: 'a', value: 'first'},
|
|
461
|
+
{_key: 'c', value: 'from instance1'},
|
|
462
|
+
{_key: 'b', value: 'second'},
|
|
463
|
+
{_key: 'd', value: 'from instance2'},
|
|
464
|
+
])
|
|
465
|
+
|
|
466
|
+
state1Unsubscribe()
|
|
467
|
+
state2Unsubscribe()
|
|
468
|
+
oneOffInstance.dispose()
|
|
469
|
+
})
|
|
470
|
+
|
|
329
471
|
it('unpublishes and discards a document', async () => {
|
|
330
|
-
const
|
|
472
|
+
const documentId = DocumentId('doc-pub-unpub')
|
|
473
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
331
474
|
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
332
475
|
const unsubscribe = documentState.subscribe()
|
|
333
476
|
|
|
@@ -339,7 +482,7 @@ it('unpublishes and discards a document', async () => {
|
|
|
339
482
|
})
|
|
340
483
|
const publishedDoc = documentState.getCurrent()
|
|
341
484
|
expect(publishedDoc).toMatchObject({
|
|
342
|
-
_id: getPublishedId(
|
|
485
|
+
_id: getPublishedId(documentId),
|
|
343
486
|
_rev: afterPublish.transactionId,
|
|
344
487
|
})
|
|
345
488
|
|
|
@@ -347,7 +490,7 @@ it('unpublishes and discards a document', async () => {
|
|
|
347
490
|
await applyDocumentActions(instance, {actions: [unpublishDocument(doc)], resource})
|
|
348
491
|
const afterUnpublish = documentState.getCurrent()
|
|
349
492
|
// In our mock implementation the _id remains the same but the published copy is removed.
|
|
350
|
-
expect(afterUnpublish?._id).toEqual(getDraftId(
|
|
493
|
+
expect(afterUnpublish?._id).toEqual(getDraftId(documentId))
|
|
351
494
|
|
|
352
495
|
// Discard the draft (which deletes the draft version).
|
|
353
496
|
await applyDocumentActions(instance, {actions: [discardDocument(doc)], resource})
|
|
@@ -358,9 +501,10 @@ it('unpublishes and discards a document', async () => {
|
|
|
358
501
|
})
|
|
359
502
|
|
|
360
503
|
it('deletes a document', async () => {
|
|
361
|
-
const
|
|
504
|
+
const documentId = DocumentId('doc-delete')
|
|
505
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
362
506
|
|
|
363
|
-
const documentState = getDocumentState
|
|
507
|
+
const documentState = getDocumentState(instance, doc)
|
|
364
508
|
const unsubscribe = documentState.subscribe()
|
|
365
509
|
|
|
366
510
|
await applyDocumentActions(instance, {
|
|
@@ -379,7 +523,8 @@ it('deletes a document', async () => {
|
|
|
379
523
|
})
|
|
380
524
|
|
|
381
525
|
it('cleans up document state when there are no subscribers', async () => {
|
|
382
|
-
const
|
|
526
|
+
const documentId = DocumentId('doc-cleanup')
|
|
527
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
383
528
|
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
384
529
|
|
|
385
530
|
// Subscribe to the document state.
|
|
@@ -397,14 +542,14 @@ it('cleans up document state when there are no subscribers', async () => {
|
|
|
397
542
|
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
398
543
|
|
|
399
544
|
// When a new subscriber is created, if the state was cleared it should return undefined.
|
|
400
|
-
const newDocumentState = getDocumentState
|
|
545
|
+
const newDocumentState = getDocumentState(instance, doc)
|
|
401
546
|
expect(newDocumentState.getCurrent()).toBeUndefined()
|
|
402
547
|
})
|
|
403
548
|
|
|
404
549
|
it('fetches documents if there are no active subscriptions for the actions applied', async () => {
|
|
405
|
-
const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'
|
|
550
|
+
const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
|
|
406
551
|
|
|
407
|
-
const {getCurrent} = getDocumentState
|
|
552
|
+
const {getCurrent} = getDocumentState(instance, doc)
|
|
408
553
|
expect(getCurrent()).toBeUndefined()
|
|
409
554
|
expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBeUndefined()
|
|
410
555
|
|
|
@@ -457,13 +602,9 @@ it('fetches documents if there are no active subscriptions for the actions appli
|
|
|
457
602
|
})
|
|
458
603
|
|
|
459
604
|
it('batches edit transaction into one outgoing transaction', async () => {
|
|
460
|
-
const doc = createDocumentHandle({
|
|
461
|
-
documentId: crypto.randomUUID(),
|
|
462
|
-
documentType: 'article',
|
|
463
|
-
resource,
|
|
464
|
-
})
|
|
605
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
465
606
|
|
|
466
|
-
const unsubscribe = getDocumentState
|
|
607
|
+
const unsubscribe = getDocumentState(instance, doc).subscribe()
|
|
467
608
|
|
|
468
609
|
// this creates its own transaction
|
|
469
610
|
applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
|
|
@@ -490,12 +631,54 @@ it('batches edit transaction into one outgoing transaction', async () => {
|
|
|
490
631
|
unsubscribe()
|
|
491
632
|
})
|
|
492
633
|
|
|
493
|
-
it('
|
|
494
|
-
const
|
|
495
|
-
documentId:
|
|
634
|
+
it('submits liveEdit document edits through observable.mutate', async () => {
|
|
635
|
+
const liveDoc = createDocumentHandle({
|
|
636
|
+
documentId: 'live-edit-test-doc',
|
|
496
637
|
documentType: 'article',
|
|
638
|
+
liveEdit: true,
|
|
639
|
+
})
|
|
640
|
+
const state = getDocumentState(instance, liveDoc)
|
|
641
|
+
const unsubscribe = state.subscribe()
|
|
642
|
+
|
|
643
|
+
await firstValueFrom(state.observable.pipe(first((doc) => doc !== undefined)))
|
|
644
|
+
expect(state.getCurrent()).toMatchObject({
|
|
645
|
+
_id: 'live-edit-test-doc',
|
|
646
|
+
title: 'live initial',
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
const callCountBefore = mutateSubmission.mock.calls.length
|
|
650
|
+
|
|
651
|
+
const result = await applyDocumentActions(instance, {
|
|
652
|
+
actions: [editDocument(liveDoc, {set: {title: 'patched via mutate'}})],
|
|
497
653
|
resource,
|
|
498
654
|
})
|
|
655
|
+
await result.submitted()
|
|
656
|
+
|
|
657
|
+
expect(mutateSubmission.mock.calls.length).toBe(callCountBefore + 1)
|
|
658
|
+
const [mutationList, mutateOptions] = mutateSubmission.mock.calls[callCountBefore]!
|
|
659
|
+
expect(mutateOptions).toMatchObject({
|
|
660
|
+
transactionId: result.transactionId,
|
|
661
|
+
visibility: 'async',
|
|
662
|
+
returnDocuments: false,
|
|
663
|
+
returnFirst: false,
|
|
664
|
+
tag: 'document.mutate',
|
|
665
|
+
skipCrossDatasetReferenceValidation: true,
|
|
666
|
+
})
|
|
667
|
+
expect(mutationList.length).toBeGreaterThan(0)
|
|
668
|
+
expect(
|
|
669
|
+
mutationList.every(
|
|
670
|
+
(m) => 'patch' in m && (m as {patch: {id: string}}).patch.id === liveDoc.documentId,
|
|
671
|
+
),
|
|
672
|
+
).toBe(true)
|
|
673
|
+
|
|
674
|
+
expect(state.getCurrent()?.title).toBe('patched via mutate')
|
|
675
|
+
expect(client.action).not.toHaveBeenCalled()
|
|
676
|
+
|
|
677
|
+
unsubscribe()
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('provides the consistency status via `getDocumentSyncStatus`', async () => {
|
|
681
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
499
682
|
|
|
500
683
|
const syncStatus = getDocumentSyncStatus(instance, doc)
|
|
501
684
|
expect(syncStatus.getCurrent()).toBeUndefined()
|
|
@@ -550,13 +733,10 @@ it('reverts failed outgoing transaction locally', async () => {
|
|
|
550
733
|
})
|
|
551
734
|
})
|
|
552
735
|
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
documentType: 'article',
|
|
556
|
-
resource,
|
|
557
|
-
})
|
|
736
|
+
const documentId = DocumentId(crypto.randomUUID())
|
|
737
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
558
738
|
|
|
559
|
-
const {getCurrent, subscribe} = getDocumentState
|
|
739
|
+
const {getCurrent, subscribe} = getDocumentState(instance, doc)
|
|
560
740
|
const unsubscribe = subscribe()
|
|
561
741
|
|
|
562
742
|
await applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
|
|
@@ -621,12 +801,8 @@ it('removes a queued transaction if it fails to apply', async () => {
|
|
|
621
801
|
})
|
|
622
802
|
})
|
|
623
803
|
|
|
624
|
-
const doc = createDocumentHandle({
|
|
625
|
-
|
|
626
|
-
documentType: 'article',
|
|
627
|
-
resource,
|
|
628
|
-
})
|
|
629
|
-
const state = getDocumentState<TestDocument>(instance, doc)
|
|
804
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
805
|
+
const state = getDocumentState(instance, doc)
|
|
630
806
|
const unsubscribe = state.subscribe()
|
|
631
807
|
|
|
632
808
|
await expect(
|
|
@@ -664,9 +840,8 @@ it('returns allowed true when no permission errors occur', async () => {
|
|
|
664
840
|
const doc = createDocumentHandle({
|
|
665
841
|
documentId: 'doc-perm-allowed',
|
|
666
842
|
documentType: 'article',
|
|
667
|
-
resource,
|
|
668
843
|
})
|
|
669
|
-
const state = getDocumentState
|
|
844
|
+
const state = getDocumentState(instance, doc)
|
|
670
845
|
const unsubscribe = state.subscribe()
|
|
671
846
|
await applyDocumentActions(instance, {actions: [createDocument(doc)], resource}).then((r) =>
|
|
672
847
|
r.submitted(),
|
|
@@ -674,7 +849,6 @@ it('returns allowed true when no permission errors occur', async () => {
|
|
|
674
849
|
|
|
675
850
|
// Use an action that includes a patch (so that update permission check is bypassed).
|
|
676
851
|
const permissionsState = getPermissionsState(instance, {
|
|
677
|
-
resource,
|
|
678
852
|
actions: [
|
|
679
853
|
{
|
|
680
854
|
...doc,
|
|
@@ -691,11 +865,7 @@ it('returns allowed true when no permission errors occur', async () => {
|
|
|
691
865
|
})
|
|
692
866
|
|
|
693
867
|
it("should reject applying the action if a precondition isn't met", async () => {
|
|
694
|
-
const doc = createDocumentHandle({
|
|
695
|
-
documentId: 'does-not-exist',
|
|
696
|
-
documentType: 'article',
|
|
697
|
-
resource,
|
|
698
|
-
})
|
|
868
|
+
const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'})
|
|
699
869
|
|
|
700
870
|
await expect(
|
|
701
871
|
applyDocumentActions(instance, {actions: [deleteDocument(doc)], resource}),
|
|
@@ -703,11 +873,7 @@ it("should reject applying the action if a precondition isn't met", async () =>
|
|
|
703
873
|
})
|
|
704
874
|
|
|
705
875
|
it("should reject applying the action if a permission isn't met", async () => {
|
|
706
|
-
const doc = createDocumentHandle({
|
|
707
|
-
documentId: 'does-not-exist',
|
|
708
|
-
documentType: 'article',
|
|
709
|
-
resource,
|
|
710
|
-
})
|
|
876
|
+
const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'})
|
|
711
877
|
|
|
712
878
|
const datasetAcl = [{filter: 'false', permissions: ['create']}]
|
|
713
879
|
vi.mocked(client.request).mockResolvedValue(datasetAcl)
|
|
@@ -721,12 +887,8 @@ it('returns allowed false with reasons when permission errors occur', async () =
|
|
|
721
887
|
const datasetAcl = [{filter: 'false', permissions: ['create']}]
|
|
722
888
|
vi.mocked(client.request).mockResolvedValue(datasetAcl)
|
|
723
889
|
|
|
724
|
-
const doc = createDocumentHandle({
|
|
725
|
-
|
|
726
|
-
documentType: 'article',
|
|
727
|
-
resource,
|
|
728
|
-
})
|
|
729
|
-
const result = await resolvePermissions(instance, {actions: [createDocument(doc)], resource})
|
|
890
|
+
const doc = createDocumentHandle({documentId: 'doc-perm-denied', documentType: 'article'})
|
|
891
|
+
const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
|
|
730
892
|
|
|
731
893
|
const message = 'You do not have permission to create a draft for document "doc-perm-denied".'
|
|
732
894
|
expect(result).toMatchObject({
|
|
@@ -744,39 +906,119 @@ it('fetches dataset ACL and updates grants in the document store state', async (
|
|
|
744
906
|
]
|
|
745
907
|
vi.mocked(client.request).mockResolvedValue(datasetAcl)
|
|
746
908
|
|
|
747
|
-
const book = createDocumentHandle({
|
|
748
|
-
|
|
749
|
-
documentType: 'book',
|
|
750
|
-
resource,
|
|
751
|
-
})
|
|
752
|
-
const author = createDocumentHandle({
|
|
753
|
-
documentId: crypto.randomUUID(),
|
|
754
|
-
documentType: 'author',
|
|
755
|
-
resource,
|
|
756
|
-
})
|
|
909
|
+
const book = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'book'})
|
|
910
|
+
const author = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'author'})
|
|
757
911
|
|
|
758
|
-
expect(await resolvePermissions(instance, {actions: [createDocument(book)]
|
|
912
|
+
expect(await resolvePermissions(instance, {actions: [createDocument(book)]})).toEqual({
|
|
759
913
|
allowed: true,
|
|
760
914
|
})
|
|
761
|
-
expect(
|
|
762
|
-
await resolvePermissions(instance, {actions: [createDocument(author)], resource}),
|
|
763
|
-
).toMatchObject({
|
|
915
|
+
expect(await resolvePermissions(instance, {actions: [createDocument(author)]})).toMatchObject({
|
|
764
916
|
allowed: false,
|
|
765
917
|
message: expect.stringContaining('You do not have permission to create a draft for document'),
|
|
766
918
|
})
|
|
767
919
|
})
|
|
768
920
|
|
|
921
|
+
it('does not send credentials with the dataset ACL request', async () => {
|
|
922
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
923
|
+
await resolvePermissions(instance, {actions: [createDocument(doc)]})
|
|
924
|
+
|
|
925
|
+
const aclCall = vi
|
|
926
|
+
.mocked(client.request)
|
|
927
|
+
.mock.calls.find(([options]) => options.uri?.endsWith('/acl'))
|
|
928
|
+
expect(aclCall).toBeDefined()
|
|
929
|
+
expect(aclCall![0]).not.toHaveProperty('withCredentials')
|
|
930
|
+
})
|
|
931
|
+
|
|
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
|
+
)
|
|
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
|
+
|
|
769
1015
|
it('fetches ACL for MediaLibraryResource', async () => {
|
|
770
1016
|
const mediaLibraryInstance = createSanityInstance()
|
|
771
1017
|
|
|
772
1018
|
const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
|
|
773
1019
|
vi.mocked(client.request).mockResolvedValue(datasetAcl)
|
|
774
1020
|
|
|
775
|
-
const doc = createDocumentHandle({
|
|
776
|
-
documentId: crypto.randomUUID(),
|
|
777
|
-
documentType: 'article',
|
|
778
|
-
resource,
|
|
779
|
-
})
|
|
1021
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
780
1022
|
const mediaLibraryResource = {mediaLibraryId: 'test-media-library'}
|
|
781
1023
|
|
|
782
1024
|
const result = await resolvePermissions(mediaLibraryInstance, {
|
|
@@ -794,11 +1036,7 @@ it('fetches ACL for CanvasResource', async () => {
|
|
|
794
1036
|
const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
|
|
795
1037
|
vi.mocked(client.request).mockResolvedValue(datasetAcl)
|
|
796
1038
|
|
|
797
|
-
const doc = createDocumentHandle({
|
|
798
|
-
documentId: crypto.randomUUID(),
|
|
799
|
-
documentType: 'article',
|
|
800
|
-
resource,
|
|
801
|
-
})
|
|
1039
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
802
1040
|
const canvasResource = {canvasId: 'test-canvas'}
|
|
803
1041
|
|
|
804
1042
|
const result = await resolvePermissions(canvasInstance, {
|
|
@@ -811,16 +1049,13 @@ it('fetches ACL for CanvasResource', async () => {
|
|
|
811
1049
|
})
|
|
812
1050
|
|
|
813
1051
|
it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => {
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
documentType: 'article',
|
|
817
|
-
resource,
|
|
818
|
-
})
|
|
1052
|
+
const documentId = DocumentId(crypto.randomUUID())
|
|
1053
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
819
1054
|
|
|
820
1055
|
expect(await resolveDocument(instance, doc)).toBe(null)
|
|
821
1056
|
|
|
822
1057
|
// use one-off instance to create the document in the mock backend
|
|
823
|
-
const oneOffInstance = createSanityInstance()
|
|
1058
|
+
const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
824
1059
|
const result = await applyDocumentActions(oneOffInstance, {
|
|
825
1060
|
actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial title'}})],
|
|
826
1061
|
resource,
|
|
@@ -828,7 +1063,7 @@ it('returns a promise that resolves when a document has been loaded in the store
|
|
|
828
1063
|
await result.submitted() // wait till submitted to server before resolving
|
|
829
1064
|
|
|
830
1065
|
await expect(resolveDocument(instance, doc)).resolves.toMatchObject({
|
|
831
|
-
_id: getDraftId(
|
|
1066
|
+
_id: getDraftId(documentId),
|
|
832
1067
|
_type: 'article',
|
|
833
1068
|
title: 'initial title',
|
|
834
1069
|
})
|
|
@@ -839,8 +1074,8 @@ it('emits an event for each action after an outgoing transaction has been accept
|
|
|
839
1074
|
const handler = vi.fn()
|
|
840
1075
|
const unsubscribe = subscribeDocumentEvents(instance, {resource, eventHandler: handler})
|
|
841
1076
|
|
|
842
|
-
const documentId = crypto.randomUUID()
|
|
843
|
-
const doc = createDocumentHandle({documentId, documentType: 'article'
|
|
1077
|
+
const documentId = DocumentId(crypto.randomUUID())
|
|
1078
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
844
1079
|
expect(handler).toHaveBeenCalledTimes(0)
|
|
845
1080
|
|
|
846
1081
|
const tnx1 = await applyDocumentActions(instance, {
|
|
@@ -881,6 +1116,188 @@ it('emits an event for each action after an outgoing transaction has been accept
|
|
|
881
1116
|
unsubscribe()
|
|
882
1117
|
})
|
|
883
1118
|
|
|
1119
|
+
it('creates and edits a version document with a release perspective', async () => {
|
|
1120
|
+
const documentId = DocumentId('doc-release')
|
|
1121
|
+
const releaseName = 'test-release'
|
|
1122
|
+
const doc = createDocumentHandle({
|
|
1123
|
+
documentId,
|
|
1124
|
+
documentType: 'article',
|
|
1125
|
+
perspective: {releaseName},
|
|
1126
|
+
})
|
|
1127
|
+
const versionId = `versions.${releaseName}.${documentId}`
|
|
1128
|
+
|
|
1129
|
+
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
1130
|
+
expect(documentState.getCurrent()).toBeUndefined()
|
|
1131
|
+
|
|
1132
|
+
const unsubscribe = documentState.subscribe()
|
|
1133
|
+
|
|
1134
|
+
// Create a version document for the release
|
|
1135
|
+
const {appeared} = await applyDocumentActions(instance, {
|
|
1136
|
+
actions: [createDocument(doc)],
|
|
1137
|
+
})
|
|
1138
|
+
expect(appeared).toContain(versionId)
|
|
1139
|
+
|
|
1140
|
+
let currentDoc = documentState.getCurrent()
|
|
1141
|
+
expect(currentDoc?._id).toEqual(versionId)
|
|
1142
|
+
expect(currentDoc?._type).toEqual('article')
|
|
1143
|
+
|
|
1144
|
+
// Edit the version document
|
|
1145
|
+
await applyDocumentActions(instance, {
|
|
1146
|
+
actions: [editDocument(doc, {set: {title: 'Release Version Title'}})],
|
|
1147
|
+
})
|
|
1148
|
+
currentDoc = documentState.getCurrent()
|
|
1149
|
+
expect(currentDoc?.title).toEqual('Release Version Title')
|
|
1150
|
+
expect(currentDoc?._id).toEqual(versionId)
|
|
1151
|
+
|
|
1152
|
+
unsubscribe()
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
it('creates a version document with initial values and then discards it', async () => {
|
|
1156
|
+
const documentId = DocumentId('doc-release-discard')
|
|
1157
|
+
const releaseName = 'test-release-discard'
|
|
1158
|
+
const doc = createDocumentHandle({
|
|
1159
|
+
documentId,
|
|
1160
|
+
documentType: 'article',
|
|
1161
|
+
perspective: {releaseName},
|
|
1162
|
+
})
|
|
1163
|
+
const versionId = `versions.${releaseName}.${documentId}`
|
|
1164
|
+
|
|
1165
|
+
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
1166
|
+
const unsubscribe = documentState.subscribe()
|
|
1167
|
+
|
|
1168
|
+
// Create a version document with initial values
|
|
1169
|
+
await applyDocumentActions(instance, {
|
|
1170
|
+
actions: [createDocument(doc, {title: 'Initial Release Title'})],
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
let currentDoc = documentState.getCurrent()
|
|
1174
|
+
expect(currentDoc?._id).toEqual(versionId)
|
|
1175
|
+
expect(currentDoc?.title).toEqual('Initial Release Title')
|
|
1176
|
+
|
|
1177
|
+
// Discard the version document
|
|
1178
|
+
const {disappeared} = await applyDocumentActions(instance, {
|
|
1179
|
+
actions: [discardDocument(doc)],
|
|
1180
|
+
})
|
|
1181
|
+
expect(disappeared).toContain(versionId)
|
|
1182
|
+
|
|
1183
|
+
currentDoc = documentState.getCurrent()
|
|
1184
|
+
expect(currentDoc).toBeNull()
|
|
1185
|
+
|
|
1186
|
+
unsubscribe()
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
it('version edits are isolated from draft state', async () => {
|
|
1190
|
+
const documentId = DocumentId('doc-version-isolation')
|
|
1191
|
+
const releaseName = 'isolation-release'
|
|
1192
|
+
const versionDoc = createDocumentHandle({
|
|
1193
|
+
documentId,
|
|
1194
|
+
documentType: 'article',
|
|
1195
|
+
perspective: {releaseName},
|
|
1196
|
+
})
|
|
1197
|
+
const draftDoc = createDocumentHandle({documentId, documentType: 'article'})
|
|
1198
|
+
const versionId = `versions.${releaseName}.${documentId}`
|
|
1199
|
+
|
|
1200
|
+
const versionState = getDocumentState<TestDocument>(instance, versionDoc)
|
|
1201
|
+
const draftState = getDocumentState<TestDocument>(instance, draftDoc)
|
|
1202
|
+
|
|
1203
|
+
const unsubscribeVersion = versionState.subscribe()
|
|
1204
|
+
const unsubscribeDraft = draftState.subscribe()
|
|
1205
|
+
|
|
1206
|
+
// Create draft and version documents
|
|
1207
|
+
await applyDocumentActions(instance, {
|
|
1208
|
+
actions: [createDocument(draftDoc)],
|
|
1209
|
+
})
|
|
1210
|
+
await applyDocumentActions(instance, {
|
|
1211
|
+
actions: [editDocument(draftDoc, {set: {title: 'Draft Title'}})],
|
|
1212
|
+
})
|
|
1213
|
+
await applyDocumentActions(instance, {
|
|
1214
|
+
actions: [createDocument(versionDoc, {title: 'Release Title'})],
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
// Version perspective shows the version doc
|
|
1218
|
+
expect(versionState.getCurrent()?._id).toEqual(versionId)
|
|
1219
|
+
expect(versionState.getCurrent()?.title).toEqual('Release Title')
|
|
1220
|
+
|
|
1221
|
+
// Draft state shows the draft doc
|
|
1222
|
+
expect(draftState.getCurrent()?.title).toEqual('Draft Title')
|
|
1223
|
+
|
|
1224
|
+
// Editing the version doc should not affect the draft
|
|
1225
|
+
await applyDocumentActions(instance, {
|
|
1226
|
+
actions: [editDocument(versionDoc, {set: {title: 'Updated Release Title'}})],
|
|
1227
|
+
})
|
|
1228
|
+
expect(versionState.getCurrent()?.title).toEqual('Updated Release Title')
|
|
1229
|
+
expect(draftState.getCurrent()?.title).toEqual('Draft Title')
|
|
1230
|
+
|
|
1231
|
+
unsubscribeVersion()
|
|
1232
|
+
unsubscribeDraft()
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
it('subscribeToSubscriptionsAndListenToDocuments recovers from an OutOfSyncError instead of failing the document store', async () => {
|
|
1236
|
+
const documentId = DocumentId('doc-out-of-sync-recovery')
|
|
1237
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
1238
|
+
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
1239
|
+
const unsubscribe = documentState.subscribe()
|
|
1240
|
+
const draftId = getDraftId(documentId)
|
|
1241
|
+
|
|
1242
|
+
// Establish a synced document so the listener has a base revision.
|
|
1243
|
+
const created = await applyDocumentActions(instance, {
|
|
1244
|
+
actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial'}})],
|
|
1245
|
+
})
|
|
1246
|
+
await created.submitted()
|
|
1247
|
+
expect(documentState.getCurrent()?.title).toBe('initial')
|
|
1248
|
+
|
|
1249
|
+
// Capture the fetchDocument spy so we can detect when the listener
|
|
1250
|
+
// re-subscribes. (every frest subscription re-runs fetchDocument)
|
|
1251
|
+
const fetchDocumentSpy = vi.mocked(createFetchDocument).mock.results.at(-1)?.value as ReturnType<
|
|
1252
|
+
typeof vi.fn
|
|
1253
|
+
>
|
|
1254
|
+
const fetchCallsBefore = fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length
|
|
1255
|
+
|
|
1256
|
+
// Inject DEFAULT_MAX_BUFFER_SIZE mutations whose previousRev doesn't chain
|
|
1257
|
+
// to the base revision (basically forcing the error)
|
|
1258
|
+
const sharedListener = (
|
|
1259
|
+
createSharedListener as unknown as () => {events: Subject<ListenEvent<SanityDocument>>}
|
|
1260
|
+
)()
|
|
1261
|
+
for (let i = 0; i < DEFAULT_MAX_BUFFER_SIZE; i++) {
|
|
1262
|
+
sharedListener.events.next({
|
|
1263
|
+
type: 'mutation',
|
|
1264
|
+
documentId: draftId,
|
|
1265
|
+
eventId: `bad-${i}`,
|
|
1266
|
+
identity: 'user',
|
|
1267
|
+
mutations: [],
|
|
1268
|
+
timestamp: new Date().toISOString(),
|
|
1269
|
+
transactionId: `bad-tx-${i}`,
|
|
1270
|
+
transactionCurrentEvent: 0,
|
|
1271
|
+
transactionTotalEvents: 1,
|
|
1272
|
+
previousRev: `dangling-rev-${i}`,
|
|
1273
|
+
resultRev: `bad-rev-${i}`,
|
|
1274
|
+
transition: 'update',
|
|
1275
|
+
visibility: 'query',
|
|
1276
|
+
})
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// The above should have triggered a retry and re-subscribed to the listener
|
|
1280
|
+
await vi.waitFor(() => {
|
|
1281
|
+
expect(fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length).toBeGreaterThan(
|
|
1282
|
+
fetchCallsBefore,
|
|
1283
|
+
)
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
// The store stayed healthy through the OutOfSyncError.
|
|
1287
|
+
expect(() => documentState.getCurrent()).not.toThrow()
|
|
1288
|
+
expect(() => getDocumentSyncStatus(instance, doc).getCurrent()).not.toThrow()
|
|
1289
|
+
|
|
1290
|
+
// A follow-up edit still propagates through the recovered listener.
|
|
1291
|
+
const edited = await applyDocumentActions(instance, {
|
|
1292
|
+
actions: [editDocument(doc, {set: {title: 'after-recovery'}})],
|
|
1293
|
+
resource,
|
|
1294
|
+
})
|
|
1295
|
+
await edited.submitted()
|
|
1296
|
+
expect(documentState.getCurrent()?.title).toBe('after-recovery')
|
|
1297
|
+
|
|
1298
|
+
unsubscribe()
|
|
1299
|
+
})
|
|
1300
|
+
|
|
884
1301
|
vi.mock('../client/clientStore.ts', () => ({
|
|
885
1302
|
getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
|
|
886
1303
|
}))
|
|
@@ -914,12 +1331,30 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
|
|
|
914
1331
|
...original,
|
|
915
1332
|
INITIAL_OUTGOING_THROTTLE_TIME: 0,
|
|
916
1333
|
DOCUMENT_STATE_CLEAR_DELAY: 25,
|
|
1334
|
+
OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
|
|
1335
|
+
OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
|
|
1336
|
+
ACL_RETRY_BASE_DELAY: 0,
|
|
1337
|
+
ACL_RETRY_MAX_DELAY: 0,
|
|
917
1338
|
}
|
|
918
1339
|
})
|
|
919
1340
|
|
|
920
1341
|
let client: SanityClient
|
|
921
1342
|
|
|
1343
|
+
/** Mock for `client.observable.mutate` (liveEdit submission path); implementation reset in `beforeEach`. */
|
|
1344
|
+
const mutateSubmission = vi.fn(
|
|
1345
|
+
async (
|
|
1346
|
+
_mutations: Mutation[],
|
|
1347
|
+
_options?: BaseMutationOptions,
|
|
1348
|
+
): Promise<MultipleMutationResult> => ({
|
|
1349
|
+
transactionId: '',
|
|
1350
|
+
documentIds: [],
|
|
1351
|
+
results: [],
|
|
1352
|
+
}),
|
|
1353
|
+
)
|
|
1354
|
+
|
|
922
1355
|
beforeEach(() => {
|
|
1356
|
+
mutateSubmission.mockReset()
|
|
1357
|
+
|
|
923
1358
|
const client$ = (getClientState as () => StateSource<SanityClient>)()
|
|
924
1359
|
.observable as ReplaySubject<SanityClient>
|
|
925
1360
|
const sharedListener = (
|
|
@@ -930,14 +1365,130 @@ beforeEach(() => {
|
|
|
930
1365
|
)()
|
|
931
1366
|
|
|
932
1367
|
let documents: DocumentSet = {
|
|
933
|
-
[getDraftId('existing-doc')]: {
|
|
934
|
-
_id: getDraftId('existing-doc'),
|
|
1368
|
+
[getDraftId(DocumentId('existing-doc'))]: {
|
|
1369
|
+
_id: getDraftId(DocumentId('existing-doc')),
|
|
935
1370
|
_createdAt: '2025-02-06T06:43:46.236Z',
|
|
936
1371
|
_updatedAt: '2025-02-06T06:43:46.236Z',
|
|
937
1372
|
_rev: 'initial-rev',
|
|
938
1373
|
_type: 'book',
|
|
939
1374
|
title: 'existing doc',
|
|
940
1375
|
},
|
|
1376
|
+
'live-edit-test-doc': {
|
|
1377
|
+
_id: 'live-edit-test-doc',
|
|
1378
|
+
_type: 'article',
|
|
1379
|
+
_createdAt: '2025-02-06T06:43:46.236Z',
|
|
1380
|
+
_updatedAt: '2025-02-06T06:43:46.236Z',
|
|
1381
|
+
_rev: 'rev-live-initial',
|
|
1382
|
+
title: 'live initial',
|
|
1383
|
+
},
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const emitDatasetChangeEvents = (
|
|
1387
|
+
prior: DocumentSet,
|
|
1388
|
+
next: DocumentSet,
|
|
1389
|
+
transactionId: string,
|
|
1390
|
+
timestamp: string,
|
|
1391
|
+
) => {
|
|
1392
|
+
const existingIds = new Set(
|
|
1393
|
+
Object.entries(prior)
|
|
1394
|
+
.filter(([, value]) => !!value)
|
|
1395
|
+
.map(([key]) => key),
|
|
1396
|
+
)
|
|
1397
|
+
const resultingIds = new Set(
|
|
1398
|
+
Object.entries(next)
|
|
1399
|
+
.filter(([, value]) => !!value)
|
|
1400
|
+
.map(([key]) => key),
|
|
1401
|
+
)
|
|
1402
|
+
const allKeys = new Set([...existingIds, ...resultingIds])
|
|
1403
|
+
|
|
1404
|
+
const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
|
|
1405
|
+
updated: string[]
|
|
1406
|
+
appeared: string[]
|
|
1407
|
+
disappeared: string[]
|
|
1408
|
+
}>(
|
|
1409
|
+
(acc, id) => {
|
|
1410
|
+
if (existingIds.has(id) && resultingIds.has(id)) {
|
|
1411
|
+
acc.updated.push(id)
|
|
1412
|
+
} else if (!existingIds.has(id) && resultingIds.has(id)) {
|
|
1413
|
+
acc.appeared.push(id)
|
|
1414
|
+
} else if (!resultingIds.has(id) && existingIds.has(id)) {
|
|
1415
|
+
acc.disappeared.push(id)
|
|
1416
|
+
}
|
|
1417
|
+
return acc
|
|
1418
|
+
},
|
|
1419
|
+
{updated: [], appeared: [], disappeared: []},
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
const transactionTotalEvents = appeared.length + disappeared.length + updated.length
|
|
1423
|
+
let transactionCurrentEvent = 0
|
|
1424
|
+
|
|
1425
|
+
const mutationEvents: MutationEvent[] = []
|
|
1426
|
+
|
|
1427
|
+
for (const id of appeared) {
|
|
1428
|
+
transactionCurrentEvent++
|
|
1429
|
+
const nextDoc = next[id]!
|
|
1430
|
+
mutationEvents.push({
|
|
1431
|
+
type: 'mutation',
|
|
1432
|
+
documentId: id,
|
|
1433
|
+
eventId: `${transactionId}#${id}`,
|
|
1434
|
+
identity: 'example-user',
|
|
1435
|
+
mutations: [{create: nextDoc}],
|
|
1436
|
+
timestamp,
|
|
1437
|
+
transactionId,
|
|
1438
|
+
transactionCurrentEvent,
|
|
1439
|
+
transactionTotalEvents,
|
|
1440
|
+
transition: 'appear',
|
|
1441
|
+
visibility: 'query',
|
|
1442
|
+
resultRev: transactionId,
|
|
1443
|
+
})
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
for (const id of updated) {
|
|
1447
|
+
transactionCurrentEvent++
|
|
1448
|
+
const prevDoc = prior[id]!
|
|
1449
|
+
const nextDoc = next[id]!
|
|
1450
|
+
|
|
1451
|
+
mutationEvents.push({
|
|
1452
|
+
type: 'mutation',
|
|
1453
|
+
documentId: id,
|
|
1454
|
+
eventId: `${transactionId}#${id}`,
|
|
1455
|
+
identity: 'example-user',
|
|
1456
|
+
mutations: diffValue(prevDoc, nextDoc).map((patch): Mutation => ({patch: {...patch, id}})),
|
|
1457
|
+
timestamp,
|
|
1458
|
+
transactionCurrentEvent,
|
|
1459
|
+
transactionTotalEvents,
|
|
1460
|
+
transactionId,
|
|
1461
|
+
transition: 'update',
|
|
1462
|
+
visibility: 'query',
|
|
1463
|
+
previousRev: prevDoc._rev,
|
|
1464
|
+
resultRev: transactionId,
|
|
1465
|
+
})
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
for (const id of disappeared) {
|
|
1469
|
+
transactionCurrentEvent++
|
|
1470
|
+
const prevDoc = prior[id]!
|
|
1471
|
+
mutationEvents.push({
|
|
1472
|
+
type: 'mutation',
|
|
1473
|
+
documentId: id,
|
|
1474
|
+
eventId: `${transactionId}#${id}`,
|
|
1475
|
+
identity: 'example-user',
|
|
1476
|
+
mutations: [{delete: {id}}],
|
|
1477
|
+
timestamp,
|
|
1478
|
+
transactionId,
|
|
1479
|
+
transactionCurrentEvent,
|
|
1480
|
+
transactionTotalEvents,
|
|
1481
|
+
transition: 'disappear',
|
|
1482
|
+
visibility: 'query',
|
|
1483
|
+
previousRev: prevDoc._rev,
|
|
1484
|
+
})
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
documents = next
|
|
1488
|
+
|
|
1489
|
+
for (const mutationEvent of mutationEvents) {
|
|
1490
|
+
sharedListener.events.next(mutationEvent)
|
|
1491
|
+
}
|
|
941
1492
|
}
|
|
942
1493
|
|
|
943
1494
|
vi.mocked(createFetchDocument).mockReturnValue(
|
|
@@ -949,6 +1500,33 @@ beforeEach(() => {
|
|
|
949
1500
|
),
|
|
950
1501
|
)
|
|
951
1502
|
|
|
1503
|
+
mutateSubmission.mockImplementation(
|
|
1504
|
+
async (
|
|
1505
|
+
mutations: Mutation[],
|
|
1506
|
+
options: BaseMutationOptions = {},
|
|
1507
|
+
): Promise<MultipleMutationResult> => {
|
|
1508
|
+
const transactionId = options.transactionId ?? crypto.randomUUID()
|
|
1509
|
+
const timestamp = new Date().toISOString()
|
|
1510
|
+
const prior = {...documents}
|
|
1511
|
+
let next = {...documents}
|
|
1512
|
+
next = processMutations({documents: next, mutations, transactionId, timestamp})
|
|
1513
|
+
|
|
1514
|
+
if (!options.dryRun) {
|
|
1515
|
+
emitDatasetChangeEvents(prior, next, transactionId, timestamp)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
1519
|
+
|
|
1520
|
+
return {
|
|
1521
|
+
transactionId,
|
|
1522
|
+
documentIds: Object.entries(next)
|
|
1523
|
+
.filter(([, v]) => !!v)
|
|
1524
|
+
.map(([k]) => k),
|
|
1525
|
+
results: [],
|
|
1526
|
+
}
|
|
1527
|
+
},
|
|
1528
|
+
)
|
|
1529
|
+
|
|
952
1530
|
const isNonNullable = <T>(t: T): t is NonNullable<T> => !!t
|
|
953
1531
|
|
|
954
1532
|
const fetch = vi.fn(
|
|
@@ -1029,7 +1607,7 @@ beforeEach(() => {
|
|
|
1029
1607
|
continue
|
|
1030
1608
|
}
|
|
1031
1609
|
case 'sanity.action.document.edit': {
|
|
1032
|
-
const documentSource = next[i.draftId] ?? next[i.publishedId]
|
|
1610
|
+
const documentSource = (i.draftId && next[i.draftId]) ?? next[i.publishedId]
|
|
1033
1611
|
if (!documentSource) {
|
|
1034
1612
|
throw new Error(
|
|
1035
1613
|
`Could not find a document to edit from \`draftId\` \`${i.draftId}\` or \`publishedId\` ${i.publishedId}`,
|
|
@@ -1039,8 +1617,8 @@ beforeEach(() => {
|
|
|
1039
1617
|
next = processMutations({
|
|
1040
1618
|
documents: next,
|
|
1041
1619
|
mutations: [
|
|
1042
|
-
{createIfNotExists: {...documentSource, _id: i.draftId}},
|
|
1043
|
-
{patch: {id: i.draftId
|
|
1620
|
+
{createIfNotExists: {...documentSource, _id: i.draftId!}},
|
|
1621
|
+
{patch: {id: i.draftId!, ...i.patch}},
|
|
1044
1622
|
],
|
|
1045
1623
|
transactionId,
|
|
1046
1624
|
timestamp,
|
|
@@ -1121,114 +1699,15 @@ beforeEach(() => {
|
|
|
1121
1699
|
continue
|
|
1122
1700
|
}
|
|
1123
1701
|
default: {
|
|
1124
|
-
throw new Error(
|
|
1702
|
+
throw new Error(
|
|
1703
|
+
`Unsupported action for mock backend: ${(i as {actionType: string}).actionType}`,
|
|
1704
|
+
)
|
|
1125
1705
|
}
|
|
1126
1706
|
}
|
|
1127
1707
|
}
|
|
1128
1708
|
|
|
1129
1709
|
if (!dryRun) {
|
|
1130
|
-
|
|
1131
|
-
Object.entries(documents)
|
|
1132
|
-
.filter(([, value]) => !!value)
|
|
1133
|
-
.map(([key]) => key),
|
|
1134
|
-
)
|
|
1135
|
-
const resultingIds = new Set(
|
|
1136
|
-
Object.entries(next)
|
|
1137
|
-
.filter(([, value]) => !!value)
|
|
1138
|
-
.map(([key]) => key),
|
|
1139
|
-
)
|
|
1140
|
-
const allKeys = new Set([...existingIds, ...resultingIds])
|
|
1141
|
-
|
|
1142
|
-
const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
|
|
1143
|
-
updated: string[]
|
|
1144
|
-
appeared: string[]
|
|
1145
|
-
disappeared: string[]
|
|
1146
|
-
}>(
|
|
1147
|
-
(acc, id) => {
|
|
1148
|
-
if (existingIds.has(id) && resultingIds.has(id)) {
|
|
1149
|
-
acc.updated.push(id)
|
|
1150
|
-
} else if (!existingIds.has(id) && resultingIds.has(id)) {
|
|
1151
|
-
acc.appeared.push(id)
|
|
1152
|
-
} else if (!resultingIds.has(id) && existingIds.has(id)) {
|
|
1153
|
-
acc.disappeared.push(id)
|
|
1154
|
-
}
|
|
1155
|
-
return acc
|
|
1156
|
-
},
|
|
1157
|
-
{updated: [], appeared: [], disappeared: []},
|
|
1158
|
-
)
|
|
1159
|
-
|
|
1160
|
-
const transactionTotalEvents = appeared.length + disappeared.length + updated.length
|
|
1161
|
-
let transactionCurrentEvent = 0
|
|
1162
|
-
|
|
1163
|
-
const mutationEvents: MutationEvent[] = []
|
|
1164
|
-
|
|
1165
|
-
for (const id of appeared) {
|
|
1166
|
-
transactionCurrentEvent++ // index seems to start at 1
|
|
1167
|
-
const nextDoc = next[id]!
|
|
1168
|
-
mutationEvents.push({
|
|
1169
|
-
type: 'mutation',
|
|
1170
|
-
documentId: id,
|
|
1171
|
-
eventId: `${transactionId}#${id}`,
|
|
1172
|
-
identity: 'example-user',
|
|
1173
|
-
mutations: [{create: nextDoc}],
|
|
1174
|
-
timestamp,
|
|
1175
|
-
transactionId,
|
|
1176
|
-
transactionCurrentEvent,
|
|
1177
|
-
transactionTotalEvents,
|
|
1178
|
-
transition: 'appear',
|
|
1179
|
-
visibility: 'query',
|
|
1180
|
-
resultRev: transactionId,
|
|
1181
|
-
})
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
for (const id of updated) {
|
|
1185
|
-
transactionCurrentEvent++
|
|
1186
|
-
const prevDoc = documents[id]!
|
|
1187
|
-
const nextDoc = next[id]!
|
|
1188
|
-
|
|
1189
|
-
mutationEvents.push({
|
|
1190
|
-
type: 'mutation',
|
|
1191
|
-
documentId: id,
|
|
1192
|
-
eventId: `${transactionId}#${id}`,
|
|
1193
|
-
identity: 'example-user',
|
|
1194
|
-
mutations: diffValue(prevDoc, nextDoc).map(
|
|
1195
|
-
(patch): Mutation => ({patch: {...patch, id}}),
|
|
1196
|
-
),
|
|
1197
|
-
timestamp,
|
|
1198
|
-
transactionCurrentEvent,
|
|
1199
|
-
transactionTotalEvents,
|
|
1200
|
-
transactionId,
|
|
1201
|
-
transition: 'update',
|
|
1202
|
-
visibility: 'query',
|
|
1203
|
-
previousRev: prevDoc._rev,
|
|
1204
|
-
resultRev: transactionId,
|
|
1205
|
-
})
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
for (const id of disappeared) {
|
|
1209
|
-
transactionCurrentEvent++
|
|
1210
|
-
const prevDoc = documents[id]!
|
|
1211
|
-
mutationEvents.push({
|
|
1212
|
-
type: 'mutation',
|
|
1213
|
-
documentId: id,
|
|
1214
|
-
eventId: `${transactionId}#${id}`,
|
|
1215
|
-
identity: 'example-user',
|
|
1216
|
-
mutations: [{delete: {id}}],
|
|
1217
|
-
timestamp,
|
|
1218
|
-
transactionId,
|
|
1219
|
-
transactionCurrentEvent,
|
|
1220
|
-
transactionTotalEvents,
|
|
1221
|
-
transition: 'disappear',
|
|
1222
|
-
visibility: 'query',
|
|
1223
|
-
previousRev: prevDoc._rev,
|
|
1224
|
-
})
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
documents = next
|
|
1228
|
-
|
|
1229
|
-
for (const mutationEvent of mutationEvents) {
|
|
1230
|
-
sharedListener.events.next(mutationEvent)
|
|
1231
|
-
}
|
|
1710
|
+
emitDatasetChangeEvents(documents, next, transactionId, timestamp)
|
|
1232
1711
|
}
|
|
1233
1712
|
|
|
1234
1713
|
// add a tick for realism
|
|
@@ -1255,6 +1734,7 @@ beforeEach(() => {
|
|
|
1255
1734
|
action: (...args: Parameters<typeof action>) => from(action(...args)),
|
|
1256
1735
|
fetch: (...args: Parameters<typeof fetch>) => from(fetch(...args)),
|
|
1257
1736
|
request: (...args: Parameters<typeof request>) => from(request(...args)),
|
|
1737
|
+
mutate: (...args: Parameters<typeof mutateSubmission>) => from(mutateSubmission(...args)),
|
|
1258
1738
|
},
|
|
1259
1739
|
} as SanityClient
|
|
1260
1740
|
client$.next(client)
|