@sanity/sdk 3.1.0 → 3.2.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 +52 -21
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +10 -9
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +7 -7
- package/dist/_exports/agent.d.ts.map +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +9 -9
- package/dist/_exports/comlink.d.ts.map +1 -1
- package/dist/_exports/comlink.js +3 -2
- package/dist/_exports/comlink.js.map +1 -1
- package/dist/_exports/dashboard.d.ts +2 -0
- package/dist/_exports/dashboard.js +2 -0
- package/dist/{authStore-C-OEkGaM.d.ts → authStore-BO2vch3I.d.ts} +516 -63
- package/dist/authStore-BO2vch3I.d.ts.map +1 -0
- package/dist/{clientStore-gUALQ0qW.js → clientStore-BI4D8HhJ.js} +124 -7
- package/dist/clientStore-BI4D8HhJ.js.map +1 -0
- package/dist/{createSanityInstance-DCePpBYW.d.ts → createSanityInstance-BQIqyGLX.d.ts} +16 -1
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/{object-BwynGPRZ.js → createStateSourceAction-DdAhEfnZ.js} +2 -82
- package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
- package/dist/defineProperty-Bho9Ngsi.js +31 -0
- package/dist/{getNodeState-E0hjqFOo.js → getNodeState-B4nG0sjm.js} +3 -2
- package/dist/{getNodeState-E0hjqFOo.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +335 -51
- package/dist/index.js.map +1 -1
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/topicStore-Ct7IFNtY.js +565 -0
- package/dist/topicStore-Ct7IFNtY.js.map +1 -0
- package/dist/topicStore-D0WqhQGF.d.ts +435 -0
- package/dist/topicStore-D0WqhQGF.d.ts.map +1 -0
- package/dist/{version-C90rNWaX.js → version-BcRmCiYi.js} +6 -6
- package/dist/{version-C90rNWaX.js.map → version-BcRmCiYi.js.map} +1 -1
- package/package.json +19 -18
- package/src/_exports/_internal.ts +12 -0
- package/src/_exports/dashboard.ts +36 -0
- package/src/_exports/index.ts +17 -1
- package/src/applications/applications.test-d.ts +28 -4
- package/src/applications/applications.ts +39 -14
- package/src/auth/authMode.test.ts +24 -0
- package/src/auth/authMode.ts +8 -3
- package/src/auth/authStore.ts +12 -0
- package/src/auth/authStrategy.ts +2 -0
- package/src/auth/logout.test.ts +45 -0
- package/src/auth/logout.ts +1 -0
- package/src/auth/oauth/oauthActions.test.ts +457 -0
- package/src/auth/oauth/oauthActions.ts +389 -0
- package/src/auth/oauth/oauthAuth.test.ts +206 -0
- package/src/auth/oauth/oauthAuth.ts +168 -0
- package/src/auth/oauth/pkce.test.ts +44 -0
- package/src/auth/oauth/pkce.ts +60 -0
- package/src/auth/oauth/types.ts +12 -0
- package/src/config/authConfig.ts +18 -0
- package/src/dashboard/messageBus/__fixtures__/test-topics.ts +18 -0
- package/src/dashboard/messageBus/bus.test.ts +1033 -0
- package/src/dashboard/messageBus/bus.ts +1246 -0
- package/src/dashboard/messageBus/store.test.ts +142 -0
- package/src/dashboard/messageBus/store.ts +74 -0
- package/src/dashboard/messageBus/topicStore.test.ts +172 -0
- package/src/dashboard/messageBus/topicStore.ts +184 -0
- package/src/dashboard/messageBus/topics.test.ts +71 -0
- package/src/dashboard/messageBus/topics.ts +260 -0
- package/src/document/actions.ts +3 -3
- package/src/document/applyDocumentActions.ts +3 -3
- package/src/document/documentStore.ts +12 -12
- package/src/document/processMutations.ts +58 -22
- package/src/installations/installations.test-d.ts +3 -3
- package/src/installations/installations.ts +2 -19
- package/src/projection/getProjectionState.ts +2 -2
- package/src/projection/resolveProjection.ts +2 -2
- package/src/query/queryStore.ts +3 -3
- package/src/typegen/defineProjection.test.ts +17 -0
- package/src/typegen/defineProjection.ts +19 -0
- package/src/typegen/groqCompat.ts +122 -0
- package/src/typegen/resolve.ts +37 -0
- package/src/utils/createGroqSearchFilter.test.ts +8 -0
- package/src/utils/createGroqSearchFilter.ts +6 -3
- package/dist/authStore-C-OEkGaM.d.ts.map +0 -1
- package/dist/clientStore-gUALQ0qW.js.map +0 -1
- package/dist/createSanityInstance-DCePpBYW.d.ts.map +0 -1
- package/dist/object-BwynGPRZ.js.map +0 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {createSanityInstance} from '../../store/createSanityInstance'
|
|
4
|
+
import {type MessageBusConnection} from './bus'
|
|
5
|
+
import {getDashboardMessageBus, isDashboardEnvironment} from './store'
|
|
6
|
+
|
|
7
|
+
const bus = vi.hoisted(() => ({
|
|
8
|
+
connect: vi.fn(),
|
|
9
|
+
installed: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
vi.mock('./bus', () => ({
|
|
13
|
+
connectMessageBus: bus.connect,
|
|
14
|
+
isMessageBusInstalled: bus.installed,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
interface FakeConnection extends MessageBusConnection {
|
|
18
|
+
moduleId?: string
|
|
19
|
+
disconnected: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const createFakeConnection = (moduleId?: string): FakeConnection => {
|
|
23
|
+
const connection = {
|
|
24
|
+
moduleId,
|
|
25
|
+
disconnected: false,
|
|
26
|
+
disconnect: () => {
|
|
27
|
+
connection.disconnected = true
|
|
28
|
+
},
|
|
29
|
+
} as FakeConnection
|
|
30
|
+
return connection
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const config = {projectId: 'p', dataset: 'd'}
|
|
34
|
+
|
|
35
|
+
describe('dashboard message bus store', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
bus.connect.mockReset()
|
|
38
|
+
bus.installed.mockReset()
|
|
39
|
+
bus.installed.mockReturnValue(true)
|
|
40
|
+
bus.connect.mockImplementation((options?: {moduleId?: string}) =>
|
|
41
|
+
createFakeConnection(options?.moduleId),
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.clearAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('connects one bus per instance with distinct module identity', () => {
|
|
50
|
+
const a = createSanityInstance(config)
|
|
51
|
+
const b = createSanityInstance(config)
|
|
52
|
+
|
|
53
|
+
const busA = getDashboardMessageBus(a, 'favorites/views/list/main') as FakeConnection
|
|
54
|
+
const busB = getDashboardMessageBus(b, 'favorites/views/list/panel') as FakeConnection
|
|
55
|
+
|
|
56
|
+
expect(busA).not.toBe(busB)
|
|
57
|
+
expect(busA.moduleId).toBe('favorites/views/list/main')
|
|
58
|
+
expect(busB.moduleId).toBe('favorites/views/list/panel')
|
|
59
|
+
expect(bus.connect).toHaveBeenCalledTimes(2)
|
|
60
|
+
|
|
61
|
+
a.dispose()
|
|
62
|
+
b.dispose()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('reuses the cached connection for the same instance', () => {
|
|
66
|
+
const instance = createSanityInstance(config)
|
|
67
|
+
|
|
68
|
+
const first = getDashboardMessageBus(instance)
|
|
69
|
+
const second = getDashboardMessageBus(instance)
|
|
70
|
+
|
|
71
|
+
expect(first).toBe(second)
|
|
72
|
+
expect(bus.connect).toHaveBeenCalledOnce()
|
|
73
|
+
|
|
74
|
+
instance.dispose()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('forwards an absent module id so the bus applies its app id fallback', () => {
|
|
78
|
+
const instance = createSanityInstance(config)
|
|
79
|
+
|
|
80
|
+
getDashboardMessageBus(instance)
|
|
81
|
+
|
|
82
|
+
// The fallback itself lives in connectApplicationToMessageBus (covered in bus.test.ts);
|
|
83
|
+
// the store's job is only to pass the option through unchanged.
|
|
84
|
+
expect(bus.connect).toHaveBeenCalledWith({moduleId: undefined})
|
|
85
|
+
|
|
86
|
+
instance.dispose()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not cache a failed connection', () => {
|
|
90
|
+
const instance = createSanityInstance(config)
|
|
91
|
+
bus.connect.mockReturnValue(undefined)
|
|
92
|
+
|
|
93
|
+
expect(getDashboardMessageBus(instance)).toBeUndefined()
|
|
94
|
+
|
|
95
|
+
// A failed connect is not cached, so a later successful connect still lands.
|
|
96
|
+
bus.connect.mockImplementation((options?: {moduleId?: string}) =>
|
|
97
|
+
createFakeConnection(options?.moduleId),
|
|
98
|
+
)
|
|
99
|
+
expect(getDashboardMessageBus(instance)).toBeDefined()
|
|
100
|
+
|
|
101
|
+
instance.dispose()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('disconnects only the disposed instance', () => {
|
|
105
|
+
const a = createSanityInstance(config)
|
|
106
|
+
const b = createSanityInstance(config)
|
|
107
|
+
|
|
108
|
+
const busA = getDashboardMessageBus(a) as FakeConnection
|
|
109
|
+
const busB = getDashboardMessageBus(b) as FakeConnection
|
|
110
|
+
|
|
111
|
+
a.dispose()
|
|
112
|
+
|
|
113
|
+
expect(busA.disconnected).toBe(true)
|
|
114
|
+
expect(busB.disconnected).toBe(false)
|
|
115
|
+
|
|
116
|
+
b.dispose()
|
|
117
|
+
expect(busB.disconnected).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('returns undefined without caching when no bus is installed, then connects later', () => {
|
|
121
|
+
const instance = createSanityInstance(config)
|
|
122
|
+
bus.installed.mockReturnValue(false)
|
|
123
|
+
|
|
124
|
+
expect(getDashboardMessageBus(instance)).toBeUndefined()
|
|
125
|
+
expect(bus.connect).not.toHaveBeenCalled()
|
|
126
|
+
|
|
127
|
+
bus.installed.mockReturnValue(true)
|
|
128
|
+
expect(getDashboardMessageBus(instance)).toBeDefined()
|
|
129
|
+
expect(bus.connect).toHaveBeenCalledOnce()
|
|
130
|
+
|
|
131
|
+
instance.dispose()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('reports the dashboard environment as soon as a host bus is installed', () => {
|
|
135
|
+
bus.installed.mockReturnValue(false)
|
|
136
|
+
expect(isDashboardEnvironment()).toBe(false)
|
|
137
|
+
|
|
138
|
+
// Before any instance connects: the host owns the session regardless.
|
|
139
|
+
bus.installed.mockReturnValue(true)
|
|
140
|
+
expect(isDashboardEnvironment()).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {createActionBinder} from '../../store/createActionBinder'
|
|
2
|
+
import {type SanityInstance} from '../../store/createSanityInstance'
|
|
3
|
+
import {defineStore} from '../../store/defineStore'
|
|
4
|
+
import {connectMessageBus, isMessageBusInstalled, type MessageBusConnection} from './bus'
|
|
5
|
+
|
|
6
|
+
interface DashboardMessageBusState {
|
|
7
|
+
connection?: MessageBusConnection
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const bindActionByInstance = createActionBinder<{name: string}, [moduleId?: string]>(
|
|
11
|
+
(instance) => ({name: instance.instanceId}),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const dashboardMessageBusStore = defineStore<DashboardMessageBusState>({
|
|
15
|
+
name: 'dashboardMessageBus',
|
|
16
|
+
getInitialState: () => ({}),
|
|
17
|
+
// Runs when the instance is disposed, tearing down this window's connection.
|
|
18
|
+
initialize:
|
|
19
|
+
({state}) =>
|
|
20
|
+
() =>
|
|
21
|
+
state.get().connection?.disconnect(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the message bus connection for this instance, connecting on first call.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* One connection is made per {@link SanityInstance}, carrying that window's
|
|
29
|
+
* module identity. The first caller wins; later callers get the cached
|
|
30
|
+
* connection. When no host bus is installed it returns `undefined` without
|
|
31
|
+
* caching, so a later call retries once a host installs the bus.
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export const getDashboardMessageBus = bindActionByInstance(
|
|
35
|
+
dashboardMessageBusStore,
|
|
36
|
+
({state}, moduleId?: string): MessageBusConnection | undefined => {
|
|
37
|
+
const current = state.get().connection
|
|
38
|
+
if (current) return current
|
|
39
|
+
if (!isMessageBusInstalled()) return undefined
|
|
40
|
+
const connection = connectMessageBus({moduleId})
|
|
41
|
+
if (!connection) return undefined
|
|
42
|
+
state.set('connect', {connection})
|
|
43
|
+
return connection
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the instance's message bus connection, or throws when no dashboard host has
|
|
49
|
+
* installed a bus. `action` names what the caller was about to do, e.g. `read topic "x"`.
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
export function requireDashboardMessageBus(
|
|
53
|
+
instance: SanityInstance,
|
|
54
|
+
action: string,
|
|
55
|
+
): MessageBusConnection {
|
|
56
|
+
const messageBus = getDashboardMessageBus(instance)
|
|
57
|
+
if (!messageBus) {
|
|
58
|
+
throw new Error(`Cannot ${action} without an installed dashboard message bus`)
|
|
59
|
+
}
|
|
60
|
+
return messageBus
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns whether a dashboard host has installed a message bus.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Answers "does a host own this session", not "has this instance connected yet". Callers
|
|
68
|
+
* such as the login redirect run before {@link getDashboardMessageBus} has been called for
|
|
69
|
+
* the instance, so this must not depend on the connection state.
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
export function isDashboardEnvironment(): boolean {
|
|
73
|
+
return isMessageBusInstalled()
|
|
74
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {createSanityInstance, type SanityInstance} from '../../store/createSanityInstance'
|
|
4
|
+
import {
|
|
5
|
+
installMessageBus,
|
|
6
|
+
type MessageBus,
|
|
7
|
+
MessageBusError,
|
|
8
|
+
type MessageBusHost,
|
|
9
|
+
resetMessageBus,
|
|
10
|
+
} from './bus'
|
|
11
|
+
import {getDashboardMessageBus} from './store'
|
|
12
|
+
import {getTopicState, resolveTopic, TopicError} from './topicStore'
|
|
13
|
+
|
|
14
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
15
|
+
|
|
16
|
+
let host: MessageBusHost
|
|
17
|
+
let instance: SanityInstance
|
|
18
|
+
|
|
19
|
+
const advance = (ms: number) => vi.advanceTimersByTimeAsync(ms)
|
|
20
|
+
|
|
21
|
+
describe('dashboard topic store', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.useFakeTimers()
|
|
24
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
25
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
26
|
+
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
instance.dispose()
|
|
31
|
+
resetMessageBus()
|
|
32
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
33
|
+
vi.unstubAllGlobals()
|
|
34
|
+
vi.useRealTimers()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('reads the current value from the bus and unwraps topic results', () => {
|
|
38
|
+
expect(getTopicState(instance, 'applications.foreground').getCurrent()).toBeUndefined()
|
|
39
|
+
host.connections.subscribe((client) => client.emit('applications.foreground', null))
|
|
40
|
+
expect(getTopicState(instance, 'applications.foreground').getCurrent()).toBeNull()
|
|
41
|
+
|
|
42
|
+
host.connections.subscribe((client) => client.emit('applications.list', {ok: true, value: []}))
|
|
43
|
+
expect(getTopicState(instance, 'applications.list').getCurrent()).toEqual([])
|
|
44
|
+
|
|
45
|
+
host.connections.subscribe((client) => client.emit('applications.list', {ok: false}))
|
|
46
|
+
expect(() => getTopicState(instance, 'applications.list').getCurrent()).toThrow(TopicError)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('notifies subscribers when the topic publishes', () => {
|
|
50
|
+
const onChange = vi.fn()
|
|
51
|
+
const unsubscribe = getTopicState(instance, 'auth.token').subscribe(onChange)
|
|
52
|
+
|
|
53
|
+
host.connections.subscribe((client) => client.emit('auth.token', 'token'))
|
|
54
|
+
|
|
55
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
56
|
+
expect(getTopicState(instance, 'auth.token').getCurrent()).toBe('token')
|
|
57
|
+
unsubscribe()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('shares one query across pending reads of the same topic', () => {
|
|
61
|
+
const query = vi.spyOn(getDashboardMessageBus(instance) as MessageBus, 'query')
|
|
62
|
+
|
|
63
|
+
const first = resolveTopic(instance, 'auth.token')
|
|
64
|
+
const second = resolveTopic(instance, 'auth.token')
|
|
65
|
+
|
|
66
|
+
expect(second).toBe(first)
|
|
67
|
+
expect(query).toHaveBeenCalledTimes(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('records a query failure, then drops it with its last reader', async () => {
|
|
71
|
+
const pending = resolveTopic(instance, 'auth.token')
|
|
72
|
+
await advance(5000)
|
|
73
|
+
await pending
|
|
74
|
+
|
|
75
|
+
expect(() => getTopicState(instance, 'auth.token').getCurrent()).toThrow(
|
|
76
|
+
expect.objectContaining({code: 'TIMEOUT'}) as MessageBusError,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// The temporary reader is released after the grace period; nothing else holds the entry.
|
|
80
|
+
await advance(1000)
|
|
81
|
+
|
|
82
|
+
expect(getTopicState(instance, 'auth.token').getCurrent()).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns a value published inside the grace window instead of the recorded failure', async () => {
|
|
86
|
+
const pending = resolveTopic(instance, 'auth.token')
|
|
87
|
+
await advance(5000)
|
|
88
|
+
await pending
|
|
89
|
+
expect(() => getTopicState(instance, 'auth.token').getCurrent()).toThrow(MessageBusError)
|
|
90
|
+
|
|
91
|
+
// The temporary reader still holds the entry; a publish must win over its stale failure.
|
|
92
|
+
host.connections.subscribe((client) => client.emit('auth.token', 'token'))
|
|
93
|
+
|
|
94
|
+
expect(getTopicState(instance, 'auth.token').getCurrent()).toBe('token')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('keeps a pending query alive after its readers leave, so its failure lands on its own entry', async () => {
|
|
98
|
+
const unsubscribe = getTopicState(instance, 'auth.token').subscribe()
|
|
99
|
+
const first = resolveTopic(instance, 'auth.token')
|
|
100
|
+
unsubscribe()
|
|
101
|
+
await advance(1000)
|
|
102
|
+
|
|
103
|
+
// Still pending: a new reader joins the same query rather than starting another.
|
|
104
|
+
expect(resolveTopic(instance, 'auth.token')).toBe(first)
|
|
105
|
+
|
|
106
|
+
await advance(4000)
|
|
107
|
+
await first
|
|
108
|
+
expect(() => getTopicState(instance, 'auth.token').getCurrent()).toThrow(
|
|
109
|
+
expect.objectContaining({code: 'TIMEOUT'}) as MessageBusError,
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('keeps the entry while any reader remains', async () => {
|
|
114
|
+
const leaveA = getTopicState(instance, 'auth.token').subscribe()
|
|
115
|
+
const leaveB = getTopicState(instance, 'auth.token').subscribe()
|
|
116
|
+
const pending = resolveTopic(instance, 'auth.token')
|
|
117
|
+
|
|
118
|
+
leaveA()
|
|
119
|
+
await advance(1000)
|
|
120
|
+
expect(resolveTopic(instance, 'auth.token')).toBe(pending)
|
|
121
|
+
|
|
122
|
+
leaveB()
|
|
123
|
+
await advance(1000)
|
|
124
|
+
// Only the query's own temporary reader is left, and that is enough.
|
|
125
|
+
expect(resolveTopic(instance, 'auth.token')).toBe(pending)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('surfaces a query failure to subscribers and ends their subscription', async () => {
|
|
129
|
+
const onChange = vi.fn()
|
|
130
|
+
getTopicState(instance, 'auth.token').subscribe(onChange)
|
|
131
|
+
const pending = resolveTopic(instance, 'auth.token')
|
|
132
|
+
await advance(5000)
|
|
133
|
+
await pending
|
|
134
|
+
|
|
135
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
136
|
+
expect(() => getTopicState(instance, 'auth.token').getCurrent()).toThrow(MessageBusError)
|
|
137
|
+
|
|
138
|
+
// The failed subscriber is gone: a later publish no longer reaches it, and once the
|
|
139
|
+
// temporary reader is released a fresh read sees the value cleanly.
|
|
140
|
+
await advance(1000)
|
|
141
|
+
host.connections.subscribe((client) => client.emit('auth.token', 'token'))
|
|
142
|
+
|
|
143
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
144
|
+
expect(getTopicState(instance, 'auth.token').getCurrent()).toBe('token')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('reads the value written to this instance connection only', () => {
|
|
148
|
+
const other = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
149
|
+
// Connect both instances (module ids come from the first store call) before writing.
|
|
150
|
+
getDashboardMessageBus(instance, 'module-a')
|
|
151
|
+
getDashboardMessageBus(other, 'module-b')
|
|
152
|
+
host.connections.subscribe((client) => {
|
|
153
|
+
if (client.moduleId === 'module-a') client.emit('auth.token', 'tok-a')
|
|
154
|
+
if (client.moduleId === 'module-b') client.emit('auth.token', 'tok-b')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(getTopicState(instance, 'auth.token').getCurrent()).toBe('tok-a')
|
|
158
|
+
expect(getTopicState(other, 'auth.token').getCurrent()).toBe('tok-b')
|
|
159
|
+
other.dispose()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('throws without an installed message bus', () => {
|
|
163
|
+
resetMessageBus()
|
|
164
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
165
|
+
const other = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
166
|
+
|
|
167
|
+
expect(() => getTopicState(other, 'auth.token').getCurrent()).toThrow(
|
|
168
|
+
'Cannot read topic "auth.token" without an installed dashboard message bus',
|
|
169
|
+
)
|
|
170
|
+
other.dispose()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {createActionBinder} from '../../store/createActionBinder'
|
|
2
|
+
import {type SanityInstance} from '../../store/createSanityInstance'
|
|
3
|
+
import {createStateSourceAction} from '../../store/createStateSourceAction'
|
|
4
|
+
import {defineStore} from '../../store/defineStore'
|
|
5
|
+
import {setCleanupTimeout} from '../../utils/setCleanupTimeout'
|
|
6
|
+
import {type MessageBus, type MessageBusStateSource} from './bus'
|
|
7
|
+
import {requireDashboardMessageBus} from './store'
|
|
8
|
+
import {type StateTopic, type ValueOf} from './topics'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An error raised when a dashboard state topic reports failure.
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
export class TopicError extends Error {
|
|
15
|
+
/** The topic that failed. */
|
|
16
|
+
readonly topic: StateTopic
|
|
17
|
+
|
|
18
|
+
/** Creates an error for a failed topic. */
|
|
19
|
+
constructor(topic: StateTopic) {
|
|
20
|
+
super(`Topic "${topic}" failed`)
|
|
21
|
+
this.name = 'TopicError'
|
|
22
|
+
this.topic = topic
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The value of a state topic, with `TopicResult` wrappers unwrapped to their success value.
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
export type TopicData<K extends StateTopic> =
|
|
31
|
+
ValueOf<K> extends infer Value
|
|
32
|
+
? Value extends {ok: true; value: infer Success}
|
|
33
|
+
? Success
|
|
34
|
+
: Value extends {ok: false}
|
|
35
|
+
? never
|
|
36
|
+
: Value
|
|
37
|
+
: never
|
|
38
|
+
|
|
39
|
+
// Mirrors the `TopicResult` shape declared in the topic manifest; no plain-value topic has an `ok` field.
|
|
40
|
+
function isTopicResult(value: unknown): value is {ok: boolean; value?: unknown} {
|
|
41
|
+
return (
|
|
42
|
+
typeof value === 'object' && value !== null && 'ok' in value && typeof value.ok === 'boolean'
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function unwrapTopicResult(topic: StateTopic, value: unknown): unknown {
|
|
47
|
+
if (!isTopicResult(value)) return value
|
|
48
|
+
if (!value.ok) throw new TopicError(topic)
|
|
49
|
+
return value.value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface TopicEntry {
|
|
53
|
+
/** Why the first-value query failed. Dropped with the entry when its last subscriber leaves. */
|
|
54
|
+
error?: unknown
|
|
55
|
+
/** The in-flight first-value query, shared by every suspended render of the topic. */
|
|
56
|
+
pending?: Promise<void>
|
|
57
|
+
subscribers: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface DashboardTopicsState {
|
|
61
|
+
topics: {[topic: string]: TopicEntry}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Matches the query store: a suspended component re-renders after its promise settles before
|
|
65
|
+
// the temporary subscriber is released, so it still sees the recorded failure.
|
|
66
|
+
const TOPIC_STATE_CLEAR_DELAY = 1000
|
|
67
|
+
|
|
68
|
+
const bindActionByInstance = createActionBinder<{name: string}, [topic: StateTopic]>(
|
|
69
|
+
(instance) => ({name: instance.instanceId}),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// The bus is the source of truth for topic values (it already keeps one replaying source per
|
|
73
|
+
// topic). This store only tracks what the bus cannot: read failures and who is still reading.
|
|
74
|
+
const dashboardTopicsStore = defineStore<DashboardTopicsState>({
|
|
75
|
+
name: 'dashboardTopics',
|
|
76
|
+
getInitialState: () => ({topics: {}}),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const getMessageBus = (instance: SanityInstance, topic: StateTopic): MessageBus =>
|
|
80
|
+
requireDashboardMessageBus(instance, `read topic "${topic}"`)
|
|
81
|
+
|
|
82
|
+
function getSource(instance: SanityInstance, topic: StateTopic): MessageBusStateSource<unknown> {
|
|
83
|
+
return getMessageBus(instance, topic).subscribe(topic)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// An entry lives as long as it has subscribers. Dropping it with the last one is what gives a
|
|
87
|
+
// later mount a fresh deadline instead of a stale failure.
|
|
88
|
+
const updateEntry =
|
|
89
|
+
(topic: string, update: (entry: TopicEntry) => TopicEntry) =>
|
|
90
|
+
(state: DashboardTopicsState): DashboardTopicsState => {
|
|
91
|
+
const {[topic]: entry = {subscribers: 0}, ...rest} = state.topics
|
|
92
|
+
const next = update(entry)
|
|
93
|
+
return {topics: next.subscribers > 0 ? {...rest, [topic]: next} : rest}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const addSubscriber = (topic: string) =>
|
|
97
|
+
updateEntry(topic, (entry) => ({...entry, subscribers: entry.subscribers + 1}))
|
|
98
|
+
|
|
99
|
+
const removeSubscriber = (topic: string) =>
|
|
100
|
+
updateEntry(topic, (entry) => ({...entry, subscribers: entry.subscribers - 1}))
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A state source for a dashboard state topic, with `TopicResult` wrappers unwrapped.
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* Returns `undefined` until the topic publishes; use {@link resolveTopic} to wait for that
|
|
107
|
+
* first value. While the topic is still unpublished, reading throws the failure that
|
|
108
|
+
* {@link resolveTopic} recorded. A published value takes precedence over a recorded failure,
|
|
109
|
+
* and a {@link TopicError} is thrown when the topic itself reports failure. A thrown failure
|
|
110
|
+
* ends the subscription, so a reader recovers by subscribing again.
|
|
111
|
+
* @internal
|
|
112
|
+
*/
|
|
113
|
+
export const getTopicState = bindActionByInstance(
|
|
114
|
+
dashboardTopicsStore,
|
|
115
|
+
createStateSourceAction({
|
|
116
|
+
selector: ({state, instance}, topic: StateTopic) => {
|
|
117
|
+
const current = getSource(instance, topic).getCurrent()
|
|
118
|
+
// A recorded failure only describes the wait for a first value; a published value wins.
|
|
119
|
+
const entry = state.topics[topic]
|
|
120
|
+
if (current === undefined && entry?.error) throw entry.error
|
|
121
|
+
return unwrapTopicResult(topic, current)
|
|
122
|
+
},
|
|
123
|
+
onSubscribe: ({state, instance}, topic: StateTopic) => {
|
|
124
|
+
state.set('addSubscriber', addSubscriber(topic))
|
|
125
|
+
// Each publish writes a fresh state object so the selector re-runs against the bus.
|
|
126
|
+
const subscription = getSource(instance, topic).subscribe(() =>
|
|
127
|
+
state.set(
|
|
128
|
+
'publish',
|
|
129
|
+
updateEntry(topic, (entry) => ({...entry})),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
return () => {
|
|
133
|
+
subscription.unsubscribe()
|
|
134
|
+
setCleanupTimeout(
|
|
135
|
+
() => state.set('removeSubscriber', removeSubscriber(topic)),
|
|
136
|
+
TOPIC_STATE_CLEAR_DELAY,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolves once a dashboard state topic has published, or records the failure for
|
|
145
|
+
* {@link getTopicState} to throw. Repeated calls for a pending topic share one query.
|
|
146
|
+
*
|
|
147
|
+
* @remarks
|
|
148
|
+
* Holds a temporary subscriber while the query is in flight so a failure recorded after the
|
|
149
|
+
* reading component has gone is dropped with it, rather than thrown at the next mount.
|
|
150
|
+
* @internal
|
|
151
|
+
*/
|
|
152
|
+
export const resolveTopic = bindActionByInstance(
|
|
153
|
+
dashboardTopicsStore,
|
|
154
|
+
({state, instance}, topic: StateTopic): Promise<void> => {
|
|
155
|
+
const existing = state.get().topics[topic]?.pending
|
|
156
|
+
if (existing) return existing
|
|
157
|
+
|
|
158
|
+
const release = () =>
|
|
159
|
+
setCleanupTimeout(
|
|
160
|
+
() => state.set('removeSubscriber', removeSubscriber(topic)),
|
|
161
|
+
TOPIC_STATE_CLEAR_DELAY,
|
|
162
|
+
)
|
|
163
|
+
const pending = getMessageBus(instance, topic)
|
|
164
|
+
.query(topic)
|
|
165
|
+
.then(
|
|
166
|
+
() =>
|
|
167
|
+
state.set(
|
|
168
|
+
'resolved',
|
|
169
|
+
updateEntry(topic, ({pending: _pending, ...entry}) => entry),
|
|
170
|
+
),
|
|
171
|
+
(error: unknown) =>
|
|
172
|
+
state.set(
|
|
173
|
+
'failed',
|
|
174
|
+
updateEntry(topic, ({pending: _pending, ...entry}) => ({...entry, error})),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
pending.then(release, release)
|
|
178
|
+
state.set(
|
|
179
|
+
'query',
|
|
180
|
+
updateEntry(topic, (entry) => ({...entry, pending, subscribers: entry.subscribers + 1})),
|
|
181
|
+
)
|
|
182
|
+
return pending
|
|
183
|
+
},
|
|
184
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {describe, expectTypeOf, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {type Application, type ApplicationInclude} from '../../applications/applications'
|
|
4
|
+
import {type MessageBus} from './bus'
|
|
5
|
+
import {
|
|
6
|
+
type ApplicationConfig,
|
|
7
|
+
type ApplicationConfigAppType,
|
|
8
|
+
type PayloadOf,
|
|
9
|
+
type ReplyOf,
|
|
10
|
+
type StateTopic,
|
|
11
|
+
type TopicResult,
|
|
12
|
+
type ValueOf,
|
|
13
|
+
} from './topics'
|
|
14
|
+
|
|
15
|
+
type IsStateTopic<Topic> = Topic extends StateTopic ? true : false
|
|
16
|
+
type ListedApplication = Extract<
|
|
17
|
+
NonNullable<ValueOf<'applications.list'>>,
|
|
18
|
+
{ok: true}
|
|
19
|
+
>['value'][number]
|
|
20
|
+
type ListedInterface = NonNullable<
|
|
21
|
+
NonNullable<ListedApplication['activeDeployment']>['interfaces']
|
|
22
|
+
>[number]
|
|
23
|
+
|
|
24
|
+
describe('dashboard topic types', () => {
|
|
25
|
+
it('distinguishes state and event topics', () => {
|
|
26
|
+
expectTypeOf<IsStateTopic<'applications.foreground'>>().toEqualTypeOf<true>()
|
|
27
|
+
expectTypeOf<IsStateTopic<'auth.token.refresh'>>().toEqualTypeOf<false>()
|
|
28
|
+
expectTypeOf<Parameters<MessageBus['query']>[0]>().toEqualTypeOf<StateTopic>()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('exposes application state values', () => {
|
|
32
|
+
expectTypeOf<ValueOf<'applications.config'>>().toEqualTypeOf<ApplicationConfig[] | null>()
|
|
33
|
+
expectTypeOf<ValueOf<'applications.list'>>().toEqualTypeOf<TopicResult<
|
|
34
|
+
Application<ApplicationInclude>[]
|
|
35
|
+
> | null>()
|
|
36
|
+
expectTypeOf<Extract<ListedInterface, {type: 'tile'}>['metadata']>().toEqualTypeOf<{
|
|
37
|
+
order?: number
|
|
38
|
+
size: 'small' | 'large' | 'banner'
|
|
39
|
+
}>()
|
|
40
|
+
expectTypeOf<Extract<ListedInterface, {type: 'panel'}>['metadata']>().toEqualTypeOf<{
|
|
41
|
+
dock?: {group?: string; order?: number}
|
|
42
|
+
} | null>()
|
|
43
|
+
expectTypeOf<ValueOf<'applications.foreground'>>().toEqualTypeOf<Application['id'] | null>()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('keeps appType open for forward compatibility without collapsing to string', () => {
|
|
47
|
+
expectTypeOf<'media-library'>().toMatchTypeOf<ApplicationConfigAppType>()
|
|
48
|
+
// Guards the `& {}` trick: a plain `string` here would drop known-value autocomplete.
|
|
49
|
+
expectTypeOf<ApplicationConfigAppType>().not.toEqualTypeOf<string>()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('exposes event payload and reply values', () => {
|
|
53
|
+
expectTypeOf<PayloadOf<'navigation.location.update'>>().toEqualTypeOf<{
|
|
54
|
+
url: string
|
|
55
|
+
history?: 'push' | 'replace'
|
|
56
|
+
}>()
|
|
57
|
+
expectTypeOf<ReplyOf<'navigation.location.update'>>().toEqualTypeOf<
|
|
58
|
+
{ok: true} | {ok: false; reason: 'not-navigable' | 'interrupted' | 'failed'}
|
|
59
|
+
>()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('requires payloads only for events that declare one', () => {
|
|
63
|
+
const messageBus = {emit: () => undefined} as unknown as MessageBus
|
|
64
|
+
|
|
65
|
+
messageBus.emit('auth.token.refresh')
|
|
66
|
+
messageBus.emit('auth.token.refresh', undefined, {timeout: null})
|
|
67
|
+
messageBus.emit('navigation.location.update', {url: '/'})
|
|
68
|
+
// @ts-expect-error navigation.location.update requires a payload
|
|
69
|
+
messageBus.emit('navigation.location.update')
|
|
70
|
+
})
|
|
71
|
+
})
|