@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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {type Mutation} from '@sanity/types'
|
|
2
|
+
|
|
3
|
+
import {type DeleteReleaseAction} from '../actions'
|
|
4
|
+
import {processMutations} from '../processMutations'
|
|
5
|
+
import {getReleaseDocumentId} from './releaseUtil'
|
|
6
|
+
import {
|
|
7
|
+
ActionError,
|
|
8
|
+
type ActionHandlerContext,
|
|
9
|
+
type ActionHandlerResult,
|
|
10
|
+
checkGrant,
|
|
11
|
+
PermissionActionError,
|
|
12
|
+
} from './shared'
|
|
13
|
+
|
|
14
|
+
// you can only delete archived or published releases
|
|
15
|
+
// https://www.sanity.io/docs/content-lake/dispatch-actions#k22ab37420f3c
|
|
16
|
+
const DELETABLE_STATES = new Set(['archived', 'published'])
|
|
17
|
+
|
|
18
|
+
export function handleReleaseDelete(
|
|
19
|
+
action: DeleteReleaseAction,
|
|
20
|
+
ctx: ActionHandlerContext,
|
|
21
|
+
): ActionHandlerResult {
|
|
22
|
+
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations, identity} = ctx
|
|
23
|
+
let {base, working} = ctx
|
|
24
|
+
|
|
25
|
+
const releaseDocumentId = getReleaseDocumentId(action.releaseId)
|
|
26
|
+
const existing = working[releaseDocumentId] ?? base[releaseDocumentId]
|
|
27
|
+
|
|
28
|
+
if (!existing) {
|
|
29
|
+
throw new ActionError({
|
|
30
|
+
documentId: releaseDocumentId,
|
|
31
|
+
transactionId,
|
|
32
|
+
message: `Cannot delete release "${action.releaseId}" because it does not exist.`,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const state = existing['state']
|
|
37
|
+
if (state && typeof state === 'string' && !DELETABLE_STATES.has(state)) {
|
|
38
|
+
throw new ActionError({
|
|
39
|
+
documentId: releaseDocumentId,
|
|
40
|
+
transactionId,
|
|
41
|
+
message: `Cannot delete release "${action.releaseId}" while it is "${state}". Archive it first.`,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!checkGrant(grants.update, existing, identity)) {
|
|
46
|
+
throw new PermissionActionError({
|
|
47
|
+
documentId: releaseDocumentId,
|
|
48
|
+
transactionId,
|
|
49
|
+
message: `You do not have permission to delete release "${action.releaseId}".`,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const mutations: Mutation[] = [{delete: {id: releaseDocumentId}}]
|
|
54
|
+
|
|
55
|
+
base = processMutations({documents: base, transactionId, mutations, timestamp})
|
|
56
|
+
working = processMutations({documents: working, transactionId, mutations, timestamp})
|
|
57
|
+
|
|
58
|
+
outgoingMutations.push(...mutations)
|
|
59
|
+
outgoingActions.push({
|
|
60
|
+
actionType: 'sanity.action.release.delete',
|
|
61
|
+
releaseId: action.releaseId,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return {base, working}
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {type EditReleaseAction} from '../actions'
|
|
2
|
+
import {getReleaseDocumentId} from './releaseUtil'
|
|
3
|
+
import {type ActionHandlerContext, type ActionHandlerResult, applySingleDocPatch} from './shared'
|
|
4
|
+
|
|
5
|
+
export function handleReleaseEdit(
|
|
6
|
+
action: EditReleaseAction,
|
|
7
|
+
ctx: ActionHandlerContext,
|
|
8
|
+
): ActionHandlerResult {
|
|
9
|
+
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations, identity} = ctx
|
|
10
|
+
const {base, working} = ctx
|
|
11
|
+
|
|
12
|
+
const releaseDocumentId = getReleaseDocumentId(action.releaseId)
|
|
13
|
+
|
|
14
|
+
const result = applySingleDocPatch({
|
|
15
|
+
base,
|
|
16
|
+
working,
|
|
17
|
+
documentId: releaseDocumentId,
|
|
18
|
+
patches: [action.patch],
|
|
19
|
+
transactionId,
|
|
20
|
+
timestamp,
|
|
21
|
+
grants,
|
|
22
|
+
identity,
|
|
23
|
+
notFoundMessage: `Cannot edit release "${action.releaseId}" because it does not exist.`,
|
|
24
|
+
permissionMessage: `You do not have permission to edit release "${action.releaseId}".`,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
outgoingMutations.push(...result.workingMutations)
|
|
28
|
+
outgoingActions.push(
|
|
29
|
+
...result.diffedPatches.map((patch) => ({
|
|
30
|
+
actionType: 'sanity.action.release.edit' as const,
|
|
31
|
+
releaseId: action.releaseId,
|
|
32
|
+
patch,
|
|
33
|
+
})),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return {base: result.base, working: result.working}
|
|
37
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {type PublishReleaseAction} from '../actions'
|
|
2
|
+
import {getReleaseDocumentId} from './releaseUtil'
|
|
3
|
+
import {
|
|
4
|
+
ActionError,
|
|
5
|
+
type ActionHandlerContext,
|
|
6
|
+
type ActionHandlerResult,
|
|
7
|
+
checkGrant,
|
|
8
|
+
PermissionActionError,
|
|
9
|
+
} from './shared'
|
|
10
|
+
|
|
11
|
+
export function handleReleasePublish(
|
|
12
|
+
action: PublishReleaseAction,
|
|
13
|
+
ctx: ActionHandlerContext,
|
|
14
|
+
): ActionHandlerResult {
|
|
15
|
+
const {base, working, grants, outgoingActions, transactionId, identity} = ctx
|
|
16
|
+
|
|
17
|
+
const releaseDocumentId = getReleaseDocumentId(action.releaseId)
|
|
18
|
+
const existing = working[releaseDocumentId] ?? base[releaseDocumentId]
|
|
19
|
+
if (!existing) {
|
|
20
|
+
throw new ActionError({
|
|
21
|
+
documentId: releaseDocumentId,
|
|
22
|
+
transactionId,
|
|
23
|
+
message: `Cannot publish release "${action.releaseId}" because it does not exist.`,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!checkGrant(grants.update, existing, identity)) {
|
|
28
|
+
throw new PermissionActionError({
|
|
29
|
+
documentId: releaseDocumentId,
|
|
30
|
+
transactionId,
|
|
31
|
+
message: `You do not have permission to publish release "${action.releaseId}".`,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// a release publish cascades to every version document in the release
|
|
36
|
+
// although technically we could search through the document store for all related
|
|
37
|
+
// version documents, for now it's simpler to just let the server handle it.
|
|
38
|
+
// (Those local documents will be eventually updated by the listener.)
|
|
39
|
+
outgoingActions.push({
|
|
40
|
+
actionType: 'sanity.action.release.publish',
|
|
41
|
+
releaseId: action.releaseId,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return {base, working}
|
|
45
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {type ScheduleReleaseAction, type UnscheduleReleaseAction} from '../actions'
|
|
2
|
+
import {getReleaseDocumentId} from './releaseUtil'
|
|
3
|
+
import {
|
|
4
|
+
ActionError,
|
|
5
|
+
type ActionHandlerContext,
|
|
6
|
+
type ActionHandlerResult,
|
|
7
|
+
checkGrant,
|
|
8
|
+
PermissionActionError,
|
|
9
|
+
} from './shared'
|
|
10
|
+
|
|
11
|
+
export function handleReleaseSchedule(
|
|
12
|
+
action: ScheduleReleaseAction,
|
|
13
|
+
ctx: ActionHandlerContext,
|
|
14
|
+
): ActionHandlerResult {
|
|
15
|
+
const {base, working, grants, outgoingActions, transactionId, identity} = ctx
|
|
16
|
+
|
|
17
|
+
const releaseDocumentId = getReleaseDocumentId(action.releaseId)
|
|
18
|
+
|
|
19
|
+
if (Number.isNaN(Date.parse(action.publishAt))) {
|
|
20
|
+
throw new ActionError({
|
|
21
|
+
documentId: releaseDocumentId,
|
|
22
|
+
transactionId,
|
|
23
|
+
message: `Cannot schedule release "${action.releaseId}": "publishAt" must be a valid ISO 8601 timestamp (received "${action.publishAt}").`,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const existing = working[releaseDocumentId] ?? base[releaseDocumentId]
|
|
28
|
+
if (!existing) {
|
|
29
|
+
throw new ActionError({
|
|
30
|
+
documentId: releaseDocumentId,
|
|
31
|
+
transactionId,
|
|
32
|
+
message: `Cannot schedule release "${action.releaseId}" because it does not exist.`,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!checkGrant(grants.update, existing, identity)) {
|
|
37
|
+
throw new PermissionActionError({
|
|
38
|
+
documentId: releaseDocumentId,
|
|
39
|
+
transactionId,
|
|
40
|
+
message: `You do not have permission to schedule release "${action.releaseId}".`,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Scheduling flips `state` to 'scheduled' and locks version documents
|
|
45
|
+
// server-side. We don't model the lock locally yet — local edits to those
|
|
46
|
+
// version docs will be rejected at submit time. The listener will sync the
|
|
47
|
+
// updated release state.
|
|
48
|
+
outgoingActions.push({
|
|
49
|
+
actionType: 'sanity.action.release.schedule',
|
|
50
|
+
releaseId: action.releaseId,
|
|
51
|
+
publishAt: action.publishAt,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return {base, working}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function handleReleaseUnschedule(
|
|
58
|
+
action: UnscheduleReleaseAction,
|
|
59
|
+
ctx: ActionHandlerContext,
|
|
60
|
+
): ActionHandlerResult {
|
|
61
|
+
const {base, working, grants, outgoingActions, transactionId, identity} = ctx
|
|
62
|
+
|
|
63
|
+
const releaseDocumentId = getReleaseDocumentId(action.releaseId)
|
|
64
|
+
const existing = working[releaseDocumentId] ?? base[releaseDocumentId]
|
|
65
|
+
if (!existing) {
|
|
66
|
+
throw new ActionError({
|
|
67
|
+
documentId: releaseDocumentId,
|
|
68
|
+
transactionId,
|
|
69
|
+
message: `Cannot unschedule release "${action.releaseId}" because it does not exist.`,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!checkGrant(grants.update, existing, identity)) {
|
|
74
|
+
throw new PermissionActionError({
|
|
75
|
+
documentId: releaseDocumentId,
|
|
76
|
+
transactionId,
|
|
77
|
+
message: `You do not have permission to unschedule release "${action.releaseId}".`,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
outgoingActions.push({
|
|
82
|
+
actionType: 'sanity.action.release.unschedule',
|
|
83
|
+
releaseId: action.releaseId,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return {base, working}
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {type Action, type ReleaseAction} from '../actions'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Path prefix shared by every release document. Matches studio's
|
|
5
|
+
* `RELEASE_DOCUMENTS_PATH` constant.
|
|
6
|
+
*/
|
|
7
|
+
const RELEASE_DOCUMENTS_PATH = '_.releases'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the full release document ID for the given release name.
|
|
11
|
+
* e.g. `getReleaseDocumentId('my-release') === '_.releases.my-release'`
|
|
12
|
+
* @beta
|
|
13
|
+
*/
|
|
14
|
+
export function getReleaseDocumentId(releaseId: string): string {
|
|
15
|
+
return `${RELEASE_DOCUMENTS_PATH}.${releaseId}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const RELEASE_ACTION_TYPES = new Set([
|
|
19
|
+
'release.create',
|
|
20
|
+
'release.edit',
|
|
21
|
+
'release.publish',
|
|
22
|
+
'release.schedule',
|
|
23
|
+
'release.unschedule',
|
|
24
|
+
'release.archive',
|
|
25
|
+
'release.unarchive',
|
|
26
|
+
'release.delete',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
export function isReleaseAction(action: Action): action is ReleaseAction {
|
|
30
|
+
return RELEASE_ACTION_TYPES.has(action.type)
|
|
31
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import {diffValue} from '@sanity/diff-patch'
|
|
2
|
+
import {jsonMatch, stringifyPath} from '@sanity/json-match'
|
|
3
|
+
import {type Mutation, type PatchOperations, type SanityDocument} from '@sanity/types'
|
|
4
|
+
import {evaluateSync, type ExprNode} from 'groq-js'
|
|
5
|
+
|
|
6
|
+
import {type Grant} from '../permissions'
|
|
7
|
+
import {type DocumentSet, processMutations} from '../processMutations'
|
|
8
|
+
import {type HttpAction} from '../reducers'
|
|
9
|
+
|
|
10
|
+
export interface ActionHandlerContext {
|
|
11
|
+
base: DocumentSet
|
|
12
|
+
working: DocumentSet
|
|
13
|
+
transactionId: string
|
|
14
|
+
timestamp: string
|
|
15
|
+
grants: Record<Grant, ExprNode>
|
|
16
|
+
/**
|
|
17
|
+
* The current user's ID, used by GROQ's `identity()` when evaluating ACL
|
|
18
|
+
* filters. May be `undefined` before the user has loaded; in that case
|
|
19
|
+
* `identity()` evaluates to null.
|
|
20
|
+
*/
|
|
21
|
+
identity: string | undefined
|
|
22
|
+
outgoingActions: HttpAction[]
|
|
23
|
+
outgoingMutations: Mutation[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ActionHandlerResult {
|
|
27
|
+
base: DocumentSet
|
|
28
|
+
working: DocumentSet
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function checkGrant(
|
|
32
|
+
grantExpr: ExprNode,
|
|
33
|
+
document: SanityDocument,
|
|
34
|
+
identity: string | undefined,
|
|
35
|
+
): boolean {
|
|
36
|
+
const value = evaluateSync(grantExpr, {params: {document}, identity})
|
|
37
|
+
return value.type === 'boolean' && value.data
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ActionErrorOptions {
|
|
41
|
+
message: string
|
|
42
|
+
documentId: string
|
|
43
|
+
transactionId: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Thrown when a precondition for an action failed.
|
|
48
|
+
*/
|
|
49
|
+
export class ActionError extends Error implements ActionErrorOptions {
|
|
50
|
+
documentId!: string
|
|
51
|
+
transactionId!: string
|
|
52
|
+
|
|
53
|
+
constructor(options: ActionErrorOptions) {
|
|
54
|
+
super(options.message)
|
|
55
|
+
Object.assign(this, options)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class PermissionActionError extends ActionError {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Creates a `processMutations` wrapper that surfaces application failures as
|
|
63
|
+
* `ActionError`s when the caller asked to preserve their patch operations.
|
|
64
|
+
* With preserved operations, patches can legitimately fail to apply (e.g.
|
|
65
|
+
* re-applied onto a diverged document during a rebase), so wrapping lets a
|
|
66
|
+
* rebase skip the transaction instead of failing the store. Without
|
|
67
|
+
* `preserveOperations`, errors are rethrown untouched.
|
|
68
|
+
*/
|
|
69
|
+
export function createMutationApplier(options: {
|
|
70
|
+
documentId: string
|
|
71
|
+
transactionId: string
|
|
72
|
+
timestamp: string
|
|
73
|
+
preserveOperations: boolean | undefined
|
|
74
|
+
}): (
|
|
75
|
+
documents: DocumentSet,
|
|
76
|
+
mutations: Mutation[],
|
|
77
|
+
documentSetName: 'base' | 'working',
|
|
78
|
+
) => DocumentSet {
|
|
79
|
+
const {documentId, transactionId, timestamp, preserveOperations} = options
|
|
80
|
+
return (documents, mutations, documentSetName) => {
|
|
81
|
+
try {
|
|
82
|
+
return processMutations({documents, transactionId, mutations, timestamp})
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!preserveOperations) throw error
|
|
85
|
+
throw new ActionError({
|
|
86
|
+
documentId,
|
|
87
|
+
transactionId,
|
|
88
|
+
message: `Failed to apply patches to the ${documentSetName} document: ${
|
|
89
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
90
|
+
}`,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Re-applies the operational intent of user-supplied `inc`/`dec` patches to a
|
|
98
|
+
* set of snapshot-diffed patches.
|
|
99
|
+
*
|
|
100
|
+
* The outgoing patches sent to Content Lake are re-derived by diffing the
|
|
101
|
+
* base document before and after the user's patches were applied. That diff
|
|
102
|
+
* collapses an `inc` into a `set` of the resulting number, which turns
|
|
103
|
+
* concurrent increments from different clients into last-write-wins. This
|
|
104
|
+
* helper finds diffed `set` operations that are exactly explained by the
|
|
105
|
+
* user's `inc`/`dec` operations and swaps them back, so the server applies
|
|
106
|
+
* the increment against whatever value is current at commit time.
|
|
107
|
+
*
|
|
108
|
+
* A `set` is only swapped when its value equals the base value plus the
|
|
109
|
+
* accumulated user deltas for that exact path; anything else (e.g. an `inc`
|
|
110
|
+
* combined with a `set` of the same field) keeps the diffed `set`.
|
|
111
|
+
*
|
|
112
|
+
* Content Lake fails the whole transaction when `inc`/`dec` targets a missing
|
|
113
|
+
* path (unlike `set`, which always succeeds). To avoid turning a concurrent
|
|
114
|
+
* field removal into a transaction revert, the emitted patch seeds the target
|
|
115
|
+
* with a `setIfMissing` of the base value; the server executes `setIfMissing`
|
|
116
|
+
* before `inc`/`dec` within a single patch, so a concurrently removed field
|
|
117
|
+
* degrades to the same result the diffed `set` would have produced. A path
|
|
118
|
+
* into a concurrently removed keyed array item can still fail the
|
|
119
|
+
* transaction (`setIfMissing` cannot create array items); the transaction is
|
|
120
|
+
* then reverted and reported, which is preferable to silently resurrecting
|
|
121
|
+
* the item.
|
|
122
|
+
*/
|
|
123
|
+
export function preserveNumericOperations(
|
|
124
|
+
baseBefore: SanityDocument | null | undefined,
|
|
125
|
+
userPatches: PatchOperations[] | undefined,
|
|
126
|
+
diffedPatches: PatchOperations[],
|
|
127
|
+
): PatchOperations[] {
|
|
128
|
+
if (!baseBefore || !userPatches?.length) return diffedPatches
|
|
129
|
+
|
|
130
|
+
// accumulate the user's inc/dec deltas per concrete (stringified) path,
|
|
131
|
+
// tracking the expected final value by replaying the additions in order
|
|
132
|
+
const targets = new Map<string, {baseValue: number; expectedValue: number; delta: number}>()
|
|
133
|
+
for (const patch of userPatches) {
|
|
134
|
+
for (const [op, sign] of [
|
|
135
|
+
['inc', 1],
|
|
136
|
+
['dec', -1],
|
|
137
|
+
] as const) {
|
|
138
|
+
const record = patch[op]
|
|
139
|
+
if (!record) continue
|
|
140
|
+
for (const [pathExpression, amount] of Object.entries(record)) {
|
|
141
|
+
if (typeof amount !== 'number') continue
|
|
142
|
+
for (const match of jsonMatch(baseBefore, pathExpression)) {
|
|
143
|
+
const path = stringifyPath(match.path)
|
|
144
|
+
const existing = targets.get(path)
|
|
145
|
+
if (existing) {
|
|
146
|
+
existing.expectedValue += sign * amount
|
|
147
|
+
existing.delta += sign * amount
|
|
148
|
+
} else if (typeof match.value === 'number') {
|
|
149
|
+
targets.set(path, {
|
|
150
|
+
baseValue: match.value,
|
|
151
|
+
expectedValue: match.value + sign * amount,
|
|
152
|
+
delta: sign * amount,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!targets.size) return diffedPatches
|
|
160
|
+
|
|
161
|
+
const preserved = new Map<string, {delta: number; baseValue: number}>()
|
|
162
|
+
const next = diffedPatches.flatMap((patch) => {
|
|
163
|
+
if (!patch.set) return [patch]
|
|
164
|
+
|
|
165
|
+
const keptSet: Record<string, unknown> = {}
|
|
166
|
+
for (const [path, value] of Object.entries(patch.set)) {
|
|
167
|
+
const target = targets.get(path)
|
|
168
|
+
if (target && typeof value === 'number' && value === target.expectedValue) {
|
|
169
|
+
preserved.set(path, {delta: target.delta, baseValue: target.baseValue})
|
|
170
|
+
} else {
|
|
171
|
+
keptSet[path] = value
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (Object.keys(keptSet).length === Object.keys(patch.set).length) return [patch]
|
|
176
|
+
const {set: _set, ...rest} = patch
|
|
177
|
+
const withKept = Object.keys(keptSet).length ? {...rest, set: keptSet} : rest
|
|
178
|
+
return Object.keys(withKept).length ? [withKept] : []
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
if (!preserved.size) return next
|
|
182
|
+
|
|
183
|
+
const setIfMissing: Record<string, unknown> = {}
|
|
184
|
+
const inc: Record<string, number> = {}
|
|
185
|
+
const dec: Record<string, number> = {}
|
|
186
|
+
for (const [path, {delta, baseValue}] of preserved) {
|
|
187
|
+
setIfMissing[path] = baseValue
|
|
188
|
+
if (delta >= 0) inc[path] = delta
|
|
189
|
+
else dec[path] = -delta
|
|
190
|
+
}
|
|
191
|
+
return [
|
|
192
|
+
...next,
|
|
193
|
+
{
|
|
194
|
+
setIfMissing,
|
|
195
|
+
...(Object.keys(inc).length && {inc}),
|
|
196
|
+
...(Object.keys(dec).length && {dec}),
|
|
197
|
+
},
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface ApplySingleDocPatchOptions {
|
|
202
|
+
base: DocumentSet
|
|
203
|
+
working: DocumentSet
|
|
204
|
+
documentId: string
|
|
205
|
+
patches: PatchOperations[] | undefined
|
|
206
|
+
transactionId: string
|
|
207
|
+
timestamp: string
|
|
208
|
+
grants: Record<Grant, ExprNode>
|
|
209
|
+
identity: string | undefined
|
|
210
|
+
/**
|
|
211
|
+
* Error message thrown when the target document does not exist in either
|
|
212
|
+
* the base or working set.
|
|
213
|
+
*/
|
|
214
|
+
notFoundMessage?: string
|
|
215
|
+
/**
|
|
216
|
+
* Error message thrown when the working document fails the `update` grant.
|
|
217
|
+
*/
|
|
218
|
+
permissionMessage?: string
|
|
219
|
+
/**
|
|
220
|
+
* When `true`, the given patches are used verbatim instead of being
|
|
221
|
+
* re-derived by diffing the base document before and after application.
|
|
222
|
+
* Patch application failures are surfaced as `ActionError`s so a rebase
|
|
223
|
+
* can skip the transaction instead of failing the store.
|
|
224
|
+
*/
|
|
225
|
+
preserveOperations?: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface ApplySingleDocPatchResult {
|
|
229
|
+
base: DocumentSet
|
|
230
|
+
working: DocumentSet
|
|
231
|
+
/**
|
|
232
|
+
* Patch operations representing the minimal diff between base before and
|
|
233
|
+
* after the user's patches were applied. These are the patches that should
|
|
234
|
+
* be sent to the server (or applied to the working set as mutations).
|
|
235
|
+
*/
|
|
236
|
+
diffedPatches: PatchOperations[]
|
|
237
|
+
/**
|
|
238
|
+
* Mutation envelopes for `diffedPatches` already keyed to `documentId`.
|
|
239
|
+
* Useful for callers that want to push them to `outgoingMutations`.
|
|
240
|
+
*/
|
|
241
|
+
workingMutations: Mutation[]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Shared logic for applying user-provided patches to a single document that
|
|
246
|
+
* is identified by an exact ID (no draft/published wrapping). Used by the
|
|
247
|
+
* liveEdit branch of `document.edit` and by `release.edit`.
|
|
248
|
+
*
|
|
249
|
+
* Returns the updated base + working sets, plus the diffed patches in both
|
|
250
|
+
* raw and mutation form so the caller can decide what to send to the server.
|
|
251
|
+
*/
|
|
252
|
+
export function applySingleDocPatch({
|
|
253
|
+
base: initialBase,
|
|
254
|
+
working: initialWorking,
|
|
255
|
+
documentId,
|
|
256
|
+
patches,
|
|
257
|
+
transactionId,
|
|
258
|
+
timestamp,
|
|
259
|
+
grants,
|
|
260
|
+
identity,
|
|
261
|
+
notFoundMessage = 'Cannot edit document because it does not exist.',
|
|
262
|
+
permissionMessage = `You do not have permission to edit document "${documentId}".`,
|
|
263
|
+
preserveOperations,
|
|
264
|
+
}: ApplySingleDocPatchOptions): ApplySingleDocPatchResult {
|
|
265
|
+
let base = initialBase
|
|
266
|
+
let working = initialWorking
|
|
267
|
+
|
|
268
|
+
const userPatches = patches?.map((patch) => ({patch: {id: documentId, ...patch}}))
|
|
269
|
+
|
|
270
|
+
if (!userPatches?.length) {
|
|
271
|
+
return {base, working, diffedPatches: [], workingMutations: []}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!working[documentId] || !base[documentId]) {
|
|
275
|
+
throw new ActionError({documentId, transactionId, message: notFoundMessage})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const applyMutations = createMutationApplier({
|
|
279
|
+
documentId,
|
|
280
|
+
transactionId,
|
|
281
|
+
timestamp,
|
|
282
|
+
preserveOperations,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const baseBefore = base[documentId]
|
|
286
|
+
base = applyMutations(base, userPatches, 'base')
|
|
287
|
+
const baseAfter = base[documentId]
|
|
288
|
+
const diffedPatches = preserveOperations
|
|
289
|
+
? (patches as PatchOperations[])
|
|
290
|
+
: preserveNumericOperations(
|
|
291
|
+
baseBefore,
|
|
292
|
+
patches,
|
|
293
|
+
diffValue(baseBefore, baseAfter) as PatchOperations[],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const workingBefore = working[documentId] as SanityDocument
|
|
297
|
+
if (!checkGrant(grants.update, workingBefore, identity)) {
|
|
298
|
+
throw new PermissionActionError({documentId, transactionId, message: permissionMessage})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const workingMutations: Mutation[] = diffedPatches.map((patch) => ({
|
|
302
|
+
patch: {id: documentId, ...patch},
|
|
303
|
+
}))
|
|
304
|
+
|
|
305
|
+
working = applyMutations(working, workingMutations, 'working')
|
|
306
|
+
|
|
307
|
+
return {base, working, diffedPatches, workingMutations}
|
|
308
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
|
|
2
|
+
import {type Mutation, type SanityDocument} from '@sanity/types'
|
|
3
|
+
|
|
4
|
+
import {isReleasePerspective} from '../../releases/utils/isReleasePerspective'
|
|
5
|
+
import {type UnpublishDocumentAction} from '../actions'
|
|
6
|
+
import {getId, processMutations} from '../processMutations'
|
|
7
|
+
import {
|
|
8
|
+
ActionError,
|
|
9
|
+
type ActionHandlerContext,
|
|
10
|
+
type ActionHandlerResult,
|
|
11
|
+
checkGrant,
|
|
12
|
+
PermissionActionError,
|
|
13
|
+
} from './shared'
|
|
14
|
+
|
|
15
|
+
export function handleUnpublish(
|
|
16
|
+
action: UnpublishDocumentAction,
|
|
17
|
+
ctx: ActionHandlerContext,
|
|
18
|
+
): ActionHandlerResult {
|
|
19
|
+
const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
|
|
20
|
+
let {base, working} = ctx
|
|
21
|
+
|
|
22
|
+
const documentId = getId(action.documentId)
|
|
23
|
+
|
|
24
|
+
if (action.liveEdit || isReleasePerspective(action.perspective)) {
|
|
25
|
+
throw new ActionError({
|
|
26
|
+
documentId,
|
|
27
|
+
transactionId,
|
|
28
|
+
message: `Cannot unpublish this document. Unpublishing is not supported for liveEdit or version (release) documents.`,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Standard draft/published or version logic
|
|
33
|
+
const draftId = getDraftId(DocumentId(documentId))
|
|
34
|
+
const publishedId = getPublishedId(DocumentId(documentId))
|
|
35
|
+
|
|
36
|
+
if (!working[publishedId] && !base[publishedId]) {
|
|
37
|
+
throw new ActionError({
|
|
38
|
+
documentId,
|
|
39
|
+
transactionId,
|
|
40
|
+
message: `Cannot unpublish because the document "${documentId}" is not currently published.`,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sourceDoc = working[publishedId] ?? (base[publishedId] as SanityDocument)
|
|
45
|
+
const newDraftFromPublished = {...sourceDoc, _id: draftId}
|
|
46
|
+
const mutations: Mutation[] = [
|
|
47
|
+
{delete: {id: publishedId}},
|
|
48
|
+
{createIfNotExists: newDraftFromPublished},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
if (!checkGrant(grants.update, sourceDoc, identity)) {
|
|
52
|
+
throw new PermissionActionError({
|
|
53
|
+
documentId,
|
|
54
|
+
transactionId,
|
|
55
|
+
message: `You do not have permission to unpublish the document "${documentId}".`,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!working[draftId] && !checkGrant(grants.create, newDraftFromPublished, identity)) {
|
|
60
|
+
throw new PermissionActionError({
|
|
61
|
+
documentId,
|
|
62
|
+
transactionId,
|
|
63
|
+
message: `You do not have permission to create a draft from the published version of "${documentId}".`,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
base = processMutations({
|
|
68
|
+
documents: base,
|
|
69
|
+
transactionId,
|
|
70
|
+
mutations: [
|
|
71
|
+
{delete: {id: publishedId}},
|
|
72
|
+
{createIfNotExists: {...(base[publishedId] ?? sourceDoc), _id: draftId}},
|
|
73
|
+
],
|
|
74
|
+
timestamp,
|
|
75
|
+
})
|
|
76
|
+
working = processMutations({documents: working, transactionId, mutations, timestamp})
|
|
77
|
+
|
|
78
|
+
outgoingMutations.push(...mutations)
|
|
79
|
+
outgoingActions.push({
|
|
80
|
+
actionType: 'sanity.action.document.unpublish',
|
|
81
|
+
draftId,
|
|
82
|
+
publishedId,
|
|
83
|
+
})
|
|
84
|
+
return {base, working}
|
|
85
|
+
}
|