@sanity/sdk 3.4.0-rc.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_exports/_internal.d.ts +3 -3
- package/dist/_exports/_internal.js +5 -5
- package/dist/_exports/agent.d.ts +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +1 -1
- package/dist/_exports/dashboard.d.ts +2 -2
- package/dist/_exports/dashboard.js +2 -2
- package/dist/{applications-903KQTrD.d.ts → applications-Bb-0Kgkc.d.ts} +6 -20
- package/dist/applications-Bb-0Kgkc.d.ts.map +1 -0
- package/dist/{clientStore-QDrJNTM4.js → clientStore-CD-USpIU.js} +11 -15
- package/dist/clientStore-CD-USpIU.js.map +1 -0
- package/dist/{createSanityInstance--Lrldto8.d.ts → createSanityInstance-BQIqyGLX.d.ts} +3 -6
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +204 -211
- package/dist/index.js.map +1 -1
- package/dist/{topicStore-BJ54_X6H.js → topicStore-CPhGph6z.js} +53 -13
- package/dist/topicStore-CPhGph6z.js.map +1 -0
- package/dist/{topicStore-VQILEI1R.d.ts → topicStore-fPK2CfqH.d.ts} +91 -4
- package/dist/topicStore-fPK2CfqH.d.ts.map +1 -0
- package/dist/{version-CYqZgJX1.js → version-C7m7OAy9.js} +3 -3
- package/dist/{version-CYqZgJX1.js.map → version-C7m7OAy9.js.map} +1 -1
- package/package.json +6 -6
- package/src/_exports/dashboard.ts +6 -0
- package/src/auth/authStore.test.ts +0 -18
- package/src/auth/authStore.ts +1 -4
- package/src/auth/handleAuthCallback.test.ts +0 -33
- package/src/auth/handleAuthCallback.ts +1 -11
- package/src/auth/logout.test.ts +1 -9
- package/src/auth/logout.ts +1 -18
- package/src/auth/oauth/oauthActions.test.ts +1 -178
- package/src/auth/oauth/oauthActions.ts +46 -111
- package/src/auth/oauth/oauthAuth.test.ts +0 -23
- package/src/auth/oauth/oauthAuth.ts +8 -18
- package/src/config/authConfig.ts +2 -5
- package/src/dashboard/messageBus/bus.test.ts +118 -0
- package/src/dashboard/messageBus/bus.ts +81 -7
- package/src/dashboard/messageBus/topics.test.ts +20 -0
- package/src/dashboard/messageBus/topics.ts +74 -0
- package/src/document/documentConstants.ts +5 -2
- package/src/document/documentStore.test.ts +194 -0
- package/src/document/documentStore.ts +79 -39
- package/src/document/reducers.ts +2 -2
- package/dist/applications-903KQTrD.d.ts.map +0 -1
- package/dist/clientStore-QDrJNTM4.js.map +0 -1
- package/dist/createSanityInstance--Lrldto8.d.ts.map +0 -1
- package/dist/topicStore-BJ54_X6H.js.map +0 -1
- package/dist/topicStore-VQILEI1R.d.ts.map +0 -1
|
@@ -42,6 +42,7 @@ export type MessageBusErrorCode =
|
|
|
42
42
|
| 'PROTOCOL_MISMATCH'
|
|
43
43
|
| 'OWNERSHIP_MISMATCH'
|
|
44
44
|
| 'MISSING_APP_ID'
|
|
45
|
+
| 'REFUSED'
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* An error raised by the message bus protocol.
|
|
@@ -89,6 +90,12 @@ export interface MessageBusMessage<T, R = never, K extends PropertyKey = TopicNa
|
|
|
89
90
|
meta: MessageBusMeta
|
|
90
91
|
/** Replies to an awaiting sender. */
|
|
91
92
|
reply(value: R): void
|
|
93
|
+
/**
|
|
94
|
+
* Refuses the message: the sender's awaited `emit()` rejects with a
|
|
95
|
+
* {@link MessageBusError} whose `code` is `'REFUSED'`. Ignored, with a warning,
|
|
96
|
+
* once the reply has already settled.
|
|
97
|
+
*/
|
|
98
|
+
reject(message?: string): void
|
|
92
99
|
/** Aborts when the sender stops waiting for a reply. */
|
|
93
100
|
readonly signal: AbortSignal
|
|
94
101
|
}
|
|
@@ -186,6 +193,8 @@ export interface MessageBus<TTopics = MessageBusTopics> {
|
|
|
186
193
|
* @public
|
|
187
194
|
*/
|
|
188
195
|
export interface MessageBusConnection<TTopics = MessageBusTopics> extends MessageBus<TTopics> {
|
|
196
|
+
/** The application this connection belongs to. */
|
|
197
|
+
readonly appId: Application['id']
|
|
189
198
|
/**
|
|
190
199
|
* Tears down this connection: pending requests reject `ABORTED`, subscriptions
|
|
191
200
|
* complete, and later `emit`, `query`, and `subscribe` calls fail with `ABORTED`.
|
|
@@ -207,6 +216,19 @@ export interface MessageBusClient {
|
|
|
207
216
|
readonly closed: AbortSignal
|
|
208
217
|
/** Writes a state value to this connection only. Ignored once the connection has closed. */
|
|
209
218
|
emit<K extends StateTopic>(type: K, value: ValueOf<K>): void
|
|
219
|
+
/**
|
|
220
|
+
* Rejects a state topic for this connection: its pending and later {@link MessageBus.query}
|
|
221
|
+
* calls reject with a {@link MessageBusError} whose `code` is `'REFUSED'`, until the host next
|
|
222
|
+
* writes a value with {@link MessageBusClient.emit}, which clears the rejection.
|
|
223
|
+
*
|
|
224
|
+
* @remarks
|
|
225
|
+
* A rejected promise cannot be un-rejected and an errored subject cannot resume, so the
|
|
226
|
+
* rejection is tracked per connection alongside the topic's subject rather than by erroring
|
|
227
|
+
* it: `query` consults it before awaiting, and {@link MessageBus.subscribe} and the topic's
|
|
228
|
+
* {@link MessageBusStateSource} stay silent (they never error) until a value is written.
|
|
229
|
+
* Ignored once the connection has closed, the same as {@link MessageBusClient.emit}.
|
|
230
|
+
*/
|
|
231
|
+
reject<K extends StateTopic>(type: K, message?: string): void
|
|
210
232
|
}
|
|
211
233
|
|
|
212
234
|
/**
|
|
@@ -260,6 +282,10 @@ interface ConnectionRecord {
|
|
|
260
282
|
readonly appId: string
|
|
261
283
|
readonly moduleId: string
|
|
262
284
|
readonly stateSubjects: Map<string, BehaviorSubject<unknown>>
|
|
285
|
+
// Topics the host has rejected for this connection, with the reason, until the next write.
|
|
286
|
+
readonly rejections: Map<string, string | undefined>
|
|
287
|
+
// Fires the rejected topic name so pending queries on that topic can reject at once.
|
|
288
|
+
readonly rejected$: Subject<string>
|
|
263
289
|
readonly abort: AbortController
|
|
264
290
|
}
|
|
265
291
|
|
|
@@ -384,6 +410,15 @@ function createEventMessage(
|
|
|
384
410
|
}
|
|
385
411
|
settleReply(pendingReply, {ok: true, value})
|
|
386
412
|
},
|
|
413
|
+
reject: (reason) => {
|
|
414
|
+
if (pendingReply.settled) {
|
|
415
|
+
console.warn(
|
|
416
|
+
`[sanity-sdk:message-bus] reject ignored for "${type}": no waiting caller or already replied`,
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
settleReply(pendingReply, {ok: false, error: new MessageBusError('REFUSED', reason)})
|
|
421
|
+
},
|
|
387
422
|
get signal() {
|
|
388
423
|
return pendingReply.responderSignal
|
|
389
424
|
},
|
|
@@ -516,6 +551,8 @@ function emitState(
|
|
|
516
551
|
type: string,
|
|
517
552
|
value: unknown,
|
|
518
553
|
): void {
|
|
554
|
+
// A written value clears any rejection so later queries resolve normally.
|
|
555
|
+
connection.rejections.delete(type)
|
|
519
556
|
const subject = resolveStateSubject(registry, connection, type)
|
|
520
557
|
if (!Object.is(subject.getValue(), value)) subject.next(value)
|
|
521
558
|
}
|
|
@@ -526,7 +563,11 @@ function query(
|
|
|
526
563
|
options: MessageBusQueryOptions | undefined,
|
|
527
564
|
connectionSignal: AbortSignal,
|
|
528
565
|
resetSignal: AbortSignal,
|
|
566
|
+
connection: ConnectionRecord,
|
|
529
567
|
): Promise<unknown> {
|
|
568
|
+
const rejection = () => new MessageBusError('REFUSED', connection.rejections.get(type))
|
|
569
|
+
// A rejection outranks any earlier value: it is cleared only when the host writes a new one.
|
|
570
|
+
if (connection.rejections.has(type)) return Promise.reject(rejection())
|
|
530
571
|
const current = source.getCurrent()
|
|
531
572
|
if (current !== undefined) return Promise.resolve(current)
|
|
532
573
|
|
|
@@ -537,12 +578,21 @@ function query(
|
|
|
537
578
|
const timer =
|
|
538
579
|
timeoutMs === null
|
|
539
580
|
? undefined
|
|
540
|
-
: setTimeout(
|
|
541
|
-
()
|
|
542
|
-
|
|
543
|
-
|
|
581
|
+
: setTimeout(() => {
|
|
582
|
+
clear()
|
|
583
|
+
signal?.removeEventListener('abort', onAbort)
|
|
584
|
+
reject(new MessageBusError('TIMEOUT', `query("${type}") timed out`))
|
|
585
|
+
}, timeoutMs)
|
|
586
|
+
// A rejection that arrives while waiting rejects the pending query at once.
|
|
587
|
+
const rejectedSub = connection.rejected$.subscribe((rejectedType) => {
|
|
588
|
+
if (rejectedType !== type) return
|
|
589
|
+
clear()
|
|
590
|
+
signal?.removeEventListener('abort', onAbort)
|
|
591
|
+
reject(rejection())
|
|
592
|
+
})
|
|
544
593
|
const clear = () => {
|
|
545
594
|
if (timer !== undefined) clearTimeout(timer)
|
|
595
|
+
rejectedSub.unsubscribe()
|
|
546
596
|
}
|
|
547
597
|
const onAbort = () => {
|
|
548
598
|
clear()
|
|
@@ -824,6 +874,7 @@ function migrateEventMessage(
|
|
|
824
874
|
payload: payload(message.payload),
|
|
825
875
|
meta: message.meta,
|
|
826
876
|
reply: (value) => message.reply(reply(value)),
|
|
877
|
+
reject: (reason) => message.reject(reason),
|
|
827
878
|
get signal() {
|
|
828
879
|
return message.signal
|
|
829
880
|
},
|
|
@@ -838,11 +889,15 @@ function migrateEventReply(
|
|
|
838
889
|
return createLazyReply(() => (projected ??= Promise.resolve(result).then(project)))
|
|
839
890
|
}
|
|
840
891
|
|
|
841
|
-
function createRejectedConnection(
|
|
892
|
+
function createRejectedConnection(
|
|
893
|
+
appId: string,
|
|
894
|
+
connectionError: () => unknown,
|
|
895
|
+
): MessageBusConnection {
|
|
842
896
|
const throwConnectionError = (): never => {
|
|
843
897
|
throw connectionError()
|
|
844
898
|
}
|
|
845
899
|
return {
|
|
900
|
+
appId,
|
|
846
901
|
emit: throwConnectionError,
|
|
847
902
|
// `query` is typed as a Promise, so it must reject rather than throw synchronously.
|
|
848
903
|
query: () => Promise.reject(connectionError()),
|
|
@@ -883,6 +938,7 @@ export function connectApplicationToMessageBus(
|
|
|
883
938
|
`[sanity-sdk:message-bus] protocol mismatch for "${appId}": installed ${String(installedProtocol)}, this copy speaks ${MESSAGE_BUS_PROTOCOL}`,
|
|
884
939
|
)
|
|
885
940
|
return createRejectedConnection(
|
|
941
|
+
appId,
|
|
886
942
|
() =>
|
|
887
943
|
new MessageBusError(
|
|
888
944
|
'PROTOCOL_MISMATCH',
|
|
@@ -895,6 +951,7 @@ export function connectApplicationToMessageBus(
|
|
|
895
951
|
if (!registry) {
|
|
896
952
|
console.error(`[sanity-sdk:message-bus] incompatible message bus for "${appId}"`)
|
|
897
953
|
return createRejectedConnection(
|
|
954
|
+
appId,
|
|
898
955
|
() =>
|
|
899
956
|
new MessageBusError(
|
|
900
957
|
'PROTOCOL_MISMATCH',
|
|
@@ -907,7 +964,7 @@ export function connectApplicationToMessageBus(
|
|
|
907
964
|
mergeTopicManifest(registry, DASHBOARD_TOPIC_MANIFEST)
|
|
908
965
|
} catch (error) {
|
|
909
966
|
console.error(`[sanity-sdk:message-bus] topic manifest conflict for "${appId}"`, {error})
|
|
910
|
-
return createRejectedConnection(() => error)
|
|
967
|
+
return createRejectedConnection(appId, () => error)
|
|
911
968
|
}
|
|
912
969
|
|
|
913
970
|
return createConnection(registry, config) as MessageBusConnection<Topics>
|
|
@@ -932,6 +989,11 @@ function createClient(
|
|
|
932
989
|
}
|
|
933
990
|
emitState(registry, record, type, compatibility.toInstalledEmission(type, value))
|
|
934
991
|
},
|
|
992
|
+
reject: (type: string, message?: string) => {
|
|
993
|
+
if (record.abort.signal.aborted) return
|
|
994
|
+
record.rejections.set(type, message)
|
|
995
|
+
record.rejected$.next(type)
|
|
996
|
+
},
|
|
935
997
|
} as MessageBusClient
|
|
936
998
|
}
|
|
937
999
|
|
|
@@ -978,6 +1040,8 @@ function createConnection(
|
|
|
978
1040
|
appId,
|
|
979
1041
|
moduleId,
|
|
980
1042
|
stateSubjects: new Map(),
|
|
1043
|
+
rejections: new Map(),
|
|
1044
|
+
rejected$: new Subject<string>(),
|
|
981
1045
|
abort: connectionAbort,
|
|
982
1046
|
}
|
|
983
1047
|
|
|
@@ -1028,6 +1092,7 @@ function createConnection(
|
|
|
1028
1092
|
}
|
|
1029
1093
|
|
|
1030
1094
|
const connection = {
|
|
1095
|
+
appId,
|
|
1031
1096
|
emit: (type: string, payload: unknown, options?: MessageBusEmitOptions) => {
|
|
1032
1097
|
throwIfDisconnected()
|
|
1033
1098
|
return migrateEventReply(
|
|
@@ -1045,7 +1110,14 @@ function createConnection(
|
|
|
1045
1110
|
},
|
|
1046
1111
|
query: (type: string, options?: MessageBusQueryOptions) => {
|
|
1047
1112
|
if (connectionSignal.aborted) return Promise.reject(new MessageBusError('ABORTED'))
|
|
1048
|
-
return query(
|
|
1113
|
+
return query(
|
|
1114
|
+
stateSource(type),
|
|
1115
|
+
type,
|
|
1116
|
+
options,
|
|
1117
|
+
connectionSignal,
|
|
1118
|
+
registry.resetAbort.signal,
|
|
1119
|
+
record,
|
|
1120
|
+
)
|
|
1049
1121
|
},
|
|
1050
1122
|
subscribe: (
|
|
1051
1123
|
type: string,
|
|
@@ -1085,6 +1157,7 @@ function createConnection(
|
|
|
1085
1157
|
connectionAbort.abort()
|
|
1086
1158
|
for (const subject of record.stateSubjects.values()) subject.complete()
|
|
1087
1159
|
record.stateSubjects.clear()
|
|
1160
|
+
record.rejected$.complete()
|
|
1088
1161
|
registry.connections.delete(record)
|
|
1089
1162
|
},
|
|
1090
1163
|
}
|
|
@@ -1110,6 +1183,7 @@ function reset(registry: MessageBusRegistry): void {
|
|
|
1110
1183
|
for (const connection of registry.connections) {
|
|
1111
1184
|
for (const subject of connection.stateSubjects.values()) subject.complete()
|
|
1112
1185
|
connection.stateSubjects.clear()
|
|
1186
|
+
connection.rejections.clear()
|
|
1113
1187
|
}
|
|
1114
1188
|
for (const subject of registry.eventSubjects.values()) subject.complete()
|
|
1115
1189
|
|
|
@@ -3,9 +3,12 @@ import {describe, expect, expectTypeOf, it} from 'vitest'
|
|
|
3
3
|
import {type Application, type ApplicationInclude} from '../../applications/applications'
|
|
4
4
|
import {type MessageBus} from './bus'
|
|
5
5
|
import {
|
|
6
|
+
type ApplicationActivity,
|
|
6
7
|
type ApplicationConfig,
|
|
7
8
|
type ApplicationConfigAppType,
|
|
9
|
+
type ApplicationContext,
|
|
8
10
|
type ApplicationStatusUpdate,
|
|
11
|
+
type CapabilityRecord,
|
|
9
12
|
DASHBOARD_TOPIC_MANIFEST,
|
|
10
13
|
type DashboardTopics,
|
|
11
14
|
type EventTopic,
|
|
@@ -53,6 +56,7 @@ describe('dashboard topic types', () => {
|
|
|
53
56
|
expectTypeOf<ValueOf<'applications.foreground', DashboardTopics>>().toEqualTypeOf<
|
|
54
57
|
Application['id'] | null
|
|
55
58
|
>()
|
|
59
|
+
expectTypeOf<ValueOf<'applications.capabilities'>>().toEqualTypeOf<CapabilityRecord>()
|
|
56
60
|
expectTypeOf<ValueOf<'applications.config'>>().toEqualTypeOf<ApplicationConfig[] | null>()
|
|
57
61
|
expectTypeOf<ValueOf<'applications.list'>>().toEqualTypeOf<TopicResult<
|
|
58
62
|
Application<ApplicationInclude>[]
|
|
@@ -73,6 +77,17 @@ describe('dashboard topic types', () => {
|
|
|
73
77
|
expectTypeOf<ApplicationConfigAppType>().not.toEqualTypeOf<string>()
|
|
74
78
|
})
|
|
75
79
|
|
|
80
|
+
it('pairs the application context state with its update event', () => {
|
|
81
|
+
// State carries the host-stamped sender; the update payload does not, since the host reads
|
|
82
|
+
// the sender from `message.meta.appId`.
|
|
83
|
+
expectTypeOf<ValueOf<'applications.context'>>().toEqualTypeOf<
|
|
84
|
+
(ApplicationContext & {readonly appId: Application['id']}) | null
|
|
85
|
+
>()
|
|
86
|
+
expectTypeOf<
|
|
87
|
+
PayloadOf<'applications.context.update'>
|
|
88
|
+
>().toEqualTypeOf<ApplicationContext | null>()
|
|
89
|
+
})
|
|
90
|
+
|
|
76
91
|
it('exposes event payload and reply values', () => {
|
|
77
92
|
expectTypeOf<PayloadOf<'navigation.location.update'>>().toEqualTypeOf<{
|
|
78
93
|
url: string
|
|
@@ -83,6 +98,11 @@ describe('dashboard topic types', () => {
|
|
|
83
98
|
>()
|
|
84
99
|
})
|
|
85
100
|
|
|
101
|
+
it('reports application activity as a fire-and-forget event', () => {
|
|
102
|
+
expectTypeOf<PayloadOf<'applications.activity'>>().toEqualTypeOf<ApplicationActivity>()
|
|
103
|
+
expectTypeOf<ReplyOf<'applications.activity'>>().toBeNever()
|
|
104
|
+
})
|
|
105
|
+
|
|
86
106
|
it('requires payloads only for events that declare one', () => {
|
|
87
107
|
const messageBus = {
|
|
88
108
|
emit: () => undefined,
|
|
@@ -31,6 +31,52 @@ export interface ApplicationConfig extends RemoteModuleRef {
|
|
|
31
31
|
readonly appType: ApplicationConfigAppType
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The capabilities a host can provide.
|
|
36
|
+
*
|
|
37
|
+
* Reuses the Studio's rendering-context identifiers so an application can hide its own
|
|
38
|
+
* implementation of anything the host provides.
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export const capabilities = ['globalUserMenu', 'favorites', 'history'] as const
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A capability a host can provide.
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
export type Capability = (typeof capabilities)[number]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The capabilities the host provides. A missing key means the host does not provide it.
|
|
51
|
+
* @public
|
|
52
|
+
*/
|
|
53
|
+
export type CapabilityRecord = Partial<Record<Capability, boolean>>
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A document a user viewed, edited, created, or deleted in an application.
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
export interface DocumentActivity {
|
|
60
|
+
readonly kind: 'document'
|
|
61
|
+
readonly eventType: 'viewed' | 'edited' | 'created' | 'deleted'
|
|
62
|
+
readonly document: {
|
|
63
|
+
readonly id: string
|
|
64
|
+
readonly type: string
|
|
65
|
+
readonly resource: {
|
|
66
|
+
readonly id: string
|
|
67
|
+
readonly type: 'dataset' | 'media-library' | 'canvas'
|
|
68
|
+
/** Schema collection the document belongs to; the workspace name for studios. */
|
|
69
|
+
readonly schemaName?: string
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Activity an application reports to the host. Discriminated on `kind`.
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
78
|
+
export type ApplicationActivity = DocumentActivity
|
|
79
|
+
|
|
34
80
|
/**
|
|
35
81
|
* A label rendered for an application interface; `null` clears it.
|
|
36
82
|
* @internal
|
|
@@ -48,6 +94,20 @@ export type ApplicationStatusUpdate = {
|
|
|
48
94
|
value: ApplicationStatus
|
|
49
95
|
}
|
|
50
96
|
|
|
97
|
+
/**
|
|
98
|
+
* What an application is currently showing: the Comlink agent resource context, made generic.
|
|
99
|
+
* @public
|
|
100
|
+
*/
|
|
101
|
+
export interface ApplicationContext {
|
|
102
|
+
/** The resource the application is working in. Types follow `@sanity/client`'s resource config. */
|
|
103
|
+
readonly resource: {
|
|
104
|
+
readonly id: string
|
|
105
|
+
readonly type: 'dataset' | 'media-library' | 'canvas'
|
|
106
|
+
}
|
|
107
|
+
/** The open document, or `null` when none is open. */
|
|
108
|
+
readonly document: {readonly id: string} | null
|
|
109
|
+
}
|
|
110
|
+
|
|
51
111
|
/**
|
|
52
112
|
* Declares a topic that stores and replays its current value.
|
|
53
113
|
* @public
|
|
@@ -100,14 +160,24 @@ export type NavigationLocation = NavigationTarget & {
|
|
|
100
160
|
* @public
|
|
101
161
|
*/
|
|
102
162
|
export interface DashboardTopics {
|
|
163
|
+
/** Reports user activity in an application, e.g. for the recents feed. Fire-and-forget. */
|
|
164
|
+
'applications.activity': EventTopicDef<ApplicationActivity>
|
|
103
165
|
/**
|
|
104
166
|
* The base path for an application.
|
|
105
167
|
*
|
|
106
168
|
* An unknown application returns `{ok: false}`.
|
|
107
169
|
*/
|
|
108
170
|
'applications.base-path': StateTopicDef<TopicResult<string>>
|
|
171
|
+
/** The capabilities the host provides. A missing key means the host does not provide it. */
|
|
172
|
+
'applications.capabilities': StateTopicDef<CapabilityRecord>
|
|
109
173
|
/** The available application configuration modules. */
|
|
110
174
|
'applications.config': StateTopicDef<ApplicationConfig[] | null>
|
|
175
|
+
/** The foreground application's context, or `null` when it has published none. */
|
|
176
|
+
'applications.context': StateTopicDef<
|
|
177
|
+
(ApplicationContext & {readonly appId: Application['id']}) | null
|
|
178
|
+
>
|
|
179
|
+
/** Publishes the sending application's context. `null` clears it. Fire-and-forget. */
|
|
180
|
+
'applications.context.update': EventTopicDef<ApplicationContext | null>
|
|
111
181
|
/** The foreground application ID, or `null` on dashboard-level routes. */
|
|
112
182
|
'applications.foreground': StateTopicDef<Application['id'] | null>
|
|
113
183
|
/** The dashboard applications available to the current user. */
|
|
@@ -208,8 +278,12 @@ type DashboardTopicManifest = {
|
|
|
208
278
|
* @internal
|
|
209
279
|
*/
|
|
210
280
|
export const DASHBOARD_TOPIC_MANIFEST: DashboardTopicManifest = {
|
|
281
|
+
'applications.activity': dashboardEvent,
|
|
211
282
|
'applications.base-path': stateTopic(undefined),
|
|
283
|
+
'applications.capabilities': stateTopic(undefined),
|
|
212
284
|
'applications.config': stateTopic(undefined),
|
|
285
|
+
'applications.context': stateTopic(undefined),
|
|
286
|
+
'applications.context.update': dashboardEvent,
|
|
213
287
|
'applications.foreground': stateTopic(undefined),
|
|
214
288
|
'applications.list': stateTopic(undefined),
|
|
215
289
|
'applications.status.update': dashboardEvent,
|
|
@@ -19,11 +19,14 @@ export const API_VERSION = 'v2025-05-06'
|
|
|
19
19
|
export const UNVERIFIED_REVISION_RETENTION_TIME = 30_000
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Base delay (ms) before retrying a document listener after
|
|
23
|
-
*
|
|
22
|
+
* Base delay (ms) before retrying a document listener after a transient failure
|
|
23
|
+
* (an `OutOfSyncError`, a 5xx response, 408 or 429). Backoff doubles on each
|
|
24
|
+
* successive retry, capped at {@link OUT_OF_SYNC_RETRY_MAX_DELAY}, and gives up
|
|
25
|
+
* after {@link OUT_OF_SYNC_RETRY_COUNT} retries so a lasting failure surfaces.
|
|
24
26
|
*/
|
|
25
27
|
export const OUT_OF_SYNC_RETRY_BASE_DELAY = 500
|
|
26
28
|
export const OUT_OF_SYNC_RETRY_MAX_DELAY = 10_000
|
|
29
|
+
export const OUT_OF_SYNC_RETRY_COUNT = 5
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* Base delay (ms) before retrying the dataset ACL fetch after a transient
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type RawQueryResponse,
|
|
11
11
|
type ResponseQueryOptions,
|
|
12
12
|
type SanityClient,
|
|
13
|
+
ServerError,
|
|
13
14
|
type SingleActionResult,
|
|
14
15
|
type UnfilteredResponseQueryOptions,
|
|
15
16
|
type WelcomeEvent,
|
|
@@ -43,9 +44,11 @@ import {
|
|
|
43
44
|
discardDocument,
|
|
44
45
|
editDocument,
|
|
45
46
|
publishDocument,
|
|
47
|
+
publishRelease,
|
|
46
48
|
unpublishDocument,
|
|
47
49
|
} from './actions'
|
|
48
50
|
import {applyDocumentActions} from './applyDocumentActions'
|
|
51
|
+
import {OUT_OF_SYNC_RETRY_COUNT} from './documentConstants'
|
|
49
52
|
import {
|
|
50
53
|
getDocumentState,
|
|
51
54
|
getDocumentSyncStatus,
|
|
@@ -1403,6 +1406,7 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
|
|
|
1403
1406
|
DOCUMENT_STATE_CLEAR_DELAY: 25,
|
|
1404
1407
|
OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
|
|
1405
1408
|
OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
|
|
1409
|
+
OUT_OF_SYNC_RETRY_COUNT: 2,
|
|
1406
1410
|
ACL_RETRY_BASE_DELAY: 0,
|
|
1407
1411
|
ACL_RETRY_MAX_DELAY: 0,
|
|
1408
1412
|
}
|
|
@@ -1809,3 +1813,193 @@ beforeEach(() => {
|
|
|
1809
1813
|
} as SanityClient
|
|
1810
1814
|
client$.next(client)
|
|
1811
1815
|
})
|
|
1816
|
+
|
|
1817
|
+
it('resolves a readable document while another in the same dataset is inaccessible due to permissions', async () => {
|
|
1818
|
+
const readableId = DocumentId('doc-readable')
|
|
1819
|
+
const blockedId = DocumentId('doc-blocked')
|
|
1820
|
+
|
|
1821
|
+
vi.mocked(createFetchDocument).mockReturnValue(
|
|
1822
|
+
vi.fn((id) =>
|
|
1823
|
+
(id === blockedId
|
|
1824
|
+
? throwError(
|
|
1825
|
+
() =>
|
|
1826
|
+
new Error(`Document with ID \`${blockedId}\` is inaccessible due to permissions.`),
|
|
1827
|
+
)
|
|
1828
|
+
: of({_id: id, _type: 'article', _rev: 'rev-1'} as SanityDocument)
|
|
1829
|
+
).pipe(delay(0)),
|
|
1830
|
+
),
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
const readable = getDocumentState<TestDocument>(instance, {
|
|
1834
|
+
documentId: readableId,
|
|
1835
|
+
documentType: 'article',
|
|
1836
|
+
liveEdit: true,
|
|
1837
|
+
})
|
|
1838
|
+
const blocked = getDocumentState<TestDocument>(instance, {
|
|
1839
|
+
documentId: blockedId,
|
|
1840
|
+
documentType: 'article',
|
|
1841
|
+
liveEdit: true,
|
|
1842
|
+
})
|
|
1843
|
+
|
|
1844
|
+
const unsubscribeReadable = readable.subscribe()
|
|
1845
|
+
const unsubscribeBlocked = blocked.subscribe()
|
|
1846
|
+
|
|
1847
|
+
await vi.waitFor(() => expect(readable.getCurrent()).toMatchObject({_id: readableId}))
|
|
1848
|
+
await expect(firstValueFrom(blocked.observable)).rejects.toThrow(
|
|
1849
|
+
'Document with ID `doc-blocked` is inaccessible due to permissions.',
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
unsubscribeReadable()
|
|
1853
|
+
unsubscribeBlocked()
|
|
1854
|
+
})
|
|
1855
|
+
|
|
1856
|
+
it('reports an inaccessible draft to the document that reads it', async () => {
|
|
1857
|
+
const documentId = DocumentId('doc-with-blocked-draft')
|
|
1858
|
+
const draftId = getDraftId(documentId)
|
|
1859
|
+
const message = `Document with ID \`${draftId}\` is inaccessible due to permissions.`
|
|
1860
|
+
|
|
1861
|
+
vi.mocked(createFetchDocument).mockReturnValue(
|
|
1862
|
+
vi.fn((id) =>
|
|
1863
|
+
(id === draftId
|
|
1864
|
+
? throwError(() => new Error(message))
|
|
1865
|
+
: of({_id: id, _type: 'article', _rev: 'rev-1'} as SanityDocument)
|
|
1866
|
+
).pipe(delay(0)),
|
|
1867
|
+
),
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
1871
|
+
|
|
1872
|
+
await expect(resolveDocument(instance, doc)).rejects.toThrow(message)
|
|
1873
|
+
await expect(
|
|
1874
|
+
firstValueFrom(
|
|
1875
|
+
getDocumentSyncStatus(instance, doc).observable.pipe(first((value) => value !== undefined)),
|
|
1876
|
+
),
|
|
1877
|
+
).rejects.toThrow(message)
|
|
1878
|
+
})
|
|
1879
|
+
|
|
1880
|
+
it('fails a write to a document it cannot read instead of waiting for it', async () => {
|
|
1881
|
+
const documentId = DocumentId('doc-with-blocked-draft-write')
|
|
1882
|
+
const draftId = getDraftId(documentId)
|
|
1883
|
+
const message = `Document with ID \`${draftId}\` is inaccessible due to permissions.`
|
|
1884
|
+
|
|
1885
|
+
vi.mocked(createFetchDocument).mockReturnValue(
|
|
1886
|
+
vi.fn((id) =>
|
|
1887
|
+
(id === draftId
|
|
1888
|
+
? throwError(() => new Error(message))
|
|
1889
|
+
: of({_id: id, _type: 'article', _rev: 'rev-1'} as SanityDocument)
|
|
1890
|
+
).pipe(delay(0)),
|
|
1891
|
+
),
|
|
1892
|
+
)
|
|
1893
|
+
|
|
1894
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
1895
|
+
|
|
1896
|
+
await expect(
|
|
1897
|
+
applyDocumentActions(instance, {
|
|
1898
|
+
actions: [editDocument(doc, {set: {title: 'Edited'}})],
|
|
1899
|
+
resource,
|
|
1900
|
+
}),
|
|
1901
|
+
).rejects.toThrow(message)
|
|
1902
|
+
})
|
|
1903
|
+
|
|
1904
|
+
it('reads a document again after the server fails once', async () => {
|
|
1905
|
+
const documentId = DocumentId('doc-with-flaky-read')
|
|
1906
|
+
const draftId = getDraftId(documentId)
|
|
1907
|
+
let thrown = 0
|
|
1908
|
+
|
|
1909
|
+
vi.mocked(createFetchDocument).mockReturnValue(
|
|
1910
|
+
vi.fn((id) =>
|
|
1911
|
+
defer(() => {
|
|
1912
|
+
if (id === draftId && thrown === 0) {
|
|
1913
|
+
thrown++
|
|
1914
|
+
return throwError(() => new ServerError({statusCode: 503, headers: {}, body: {}}))
|
|
1915
|
+
}
|
|
1916
|
+
return of({_id: id, _type: 'article', _rev: 'rev-1'} as SanityDocument)
|
|
1917
|
+
}).pipe(delay(0)),
|
|
1918
|
+
),
|
|
1919
|
+
)
|
|
1920
|
+
|
|
1921
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
1922
|
+
|
|
1923
|
+
await expect(resolveDocument(instance, doc)).resolves.toMatchObject({_id: draftId})
|
|
1924
|
+
expect(thrown).toBe(1)
|
|
1925
|
+
})
|
|
1926
|
+
|
|
1927
|
+
it('fails a release action whose release document cannot be read', async () => {
|
|
1928
|
+
const releaseId = 'blocked-release'
|
|
1929
|
+
const releaseDocumentId = `_.releases.${releaseId}`
|
|
1930
|
+
const message = `Document with ID \`${releaseDocumentId}\` is inaccessible due to permissions.`
|
|
1931
|
+
|
|
1932
|
+
vi.mocked(createFetchDocument).mockReturnValue(
|
|
1933
|
+
vi.fn((id) =>
|
|
1934
|
+
(id === releaseDocumentId
|
|
1935
|
+
? throwError(() => new Error(message))
|
|
1936
|
+
: of({_id: id, _type: 'article', _rev: 'rev-1'} as SanityDocument)
|
|
1937
|
+
).pipe(delay(0)),
|
|
1938
|
+
),
|
|
1939
|
+
)
|
|
1940
|
+
|
|
1941
|
+
await expect(
|
|
1942
|
+
applyDocumentActions(instance, {
|
|
1943
|
+
actions: [publishRelease({releaseId})],
|
|
1944
|
+
resource,
|
|
1945
|
+
}),
|
|
1946
|
+
).rejects.toThrow(message)
|
|
1947
|
+
})
|
|
1948
|
+
|
|
1949
|
+
it('gives up on a read that keeps failing rather than loading forever', async () => {
|
|
1950
|
+
const documentId = DocumentId('doc-with-failing-read')
|
|
1951
|
+
const draftId = getDraftId(documentId)
|
|
1952
|
+
let attempts = 0
|
|
1953
|
+
|
|
1954
|
+
vi.mocked(createFetchDocument).mockReturnValue(
|
|
1955
|
+
vi.fn((id) =>
|
|
1956
|
+
defer(() => {
|
|
1957
|
+
if (id !== draftId) return of({_id: id, _type: 'article', _rev: 'rev-1'} as SanityDocument)
|
|
1958
|
+
attempts++
|
|
1959
|
+
return throwError(() => new ServerError({statusCode: 503, headers: {}, body: {}}))
|
|
1960
|
+
}).pipe(delay(0)),
|
|
1961
|
+
),
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
1965
|
+
|
|
1966
|
+
await expect(resolveDocument(instance, doc)).rejects.toThrow(ServerError)
|
|
1967
|
+
expect(attempts).toBe(3)
|
|
1968
|
+
})
|
|
1969
|
+
|
|
1970
|
+
it('keeps its retry budget per failure, not per listener', async () => {
|
|
1971
|
+
const documentId = DocumentId('doc-with-flaky-listener')
|
|
1972
|
+
const draftId = getDraftId(documentId)
|
|
1973
|
+
let calls = 0
|
|
1974
|
+
|
|
1975
|
+
vi.mocked(createFetchDocument).mockReturnValue(
|
|
1976
|
+
vi.fn((id) =>
|
|
1977
|
+
defer(() => {
|
|
1978
|
+
if (id !== draftId) return of({_id: id, _type: 'article', _rev: 'rev-1'} as SanityDocument)
|
|
1979
|
+
calls++
|
|
1980
|
+
// every read fails once before it succeeds
|
|
1981
|
+
return calls % 2 === 1
|
|
1982
|
+
? throwError(() => new ServerError({statusCode: 503, headers: {}, body: {}}))
|
|
1983
|
+
: of({_id: id, _type: 'article', _rev: `rev-${calls}`} as SanityDocument)
|
|
1984
|
+
}).pipe(delay(0)),
|
|
1985
|
+
),
|
|
1986
|
+
)
|
|
1987
|
+
|
|
1988
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
1989
|
+
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
1990
|
+
const unsubscribe = documentState.subscribe()
|
|
1991
|
+
await vi.waitFor(() => expect(documentState.getCurrent()).toMatchObject({_id: draftId}))
|
|
1992
|
+
|
|
1993
|
+
const sharedListener = (
|
|
1994
|
+
createSharedListener as unknown as () => {events: Subject<ListenEvent<SanityDocument>>}
|
|
1995
|
+
)()
|
|
1996
|
+
// more failure streaks than the budget, each one recovering in between
|
|
1997
|
+
for (let attempt = 0; attempt < OUT_OF_SYNC_RETRY_COUNT + 2; attempt++) {
|
|
1998
|
+
const seen = calls
|
|
1999
|
+
sharedListener.events.next({type: 'reset'})
|
|
2000
|
+
await vi.waitFor(() => expect(calls).toBeGreaterThan(seen + 1))
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
expect(() => documentState.getCurrent()).not.toThrow()
|
|
2004
|
+
unsubscribe()
|
|
2005
|
+
})
|