@sanity/sdk 3.1.0 → 3.3.0-rc.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.
Files changed (86) hide show
  1. package/dist/_exports/_internal.d.ts +52 -21
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +10 -9
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/_exports/agent.d.ts +7 -7
  6. package/dist/_exports/agent.d.ts.map +1 -1
  7. package/dist/_exports/agent.js +1 -1
  8. package/dist/_exports/comlink.d.ts +9 -9
  9. package/dist/_exports/comlink.d.ts.map +1 -1
  10. package/dist/_exports/comlink.js +3 -2
  11. package/dist/_exports/comlink.js.map +1 -1
  12. package/dist/_exports/dashboard.d.ts +2 -0
  13. package/dist/_exports/dashboard.js +2 -0
  14. package/dist/{authStore-C-OEkGaM.d.ts → authStore-BGoEGxOq.d.ts} +531 -63
  15. package/dist/authStore-BGoEGxOq.d.ts.map +1 -0
  16. package/dist/{clientStore-gUALQ0qW.js → clientStore-QDrJNTM4.js} +128 -7
  17. package/dist/clientStore-QDrJNTM4.js.map +1 -0
  18. package/dist/{createSanityInstance-DCePpBYW.d.ts → createSanityInstance-BQIqyGLX.d.ts} +16 -1
  19. package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
  20. package/dist/{object-BwynGPRZ.js → createStateSourceAction-DdAhEfnZ.js} +2 -82
  21. package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
  22. package/dist/defineProperty-Bho9Ngsi.js +31 -0
  23. package/dist/{getNodeState-E0hjqFOo.js → getNodeState-B4nG0sjm.js} +3 -2
  24. package/dist/{getNodeState-E0hjqFOo.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.js +371 -54
  27. package/dist/index.js.map +1 -1
  28. package/dist/object-CxXlSVg7.js +83 -0
  29. package/dist/object-CxXlSVg7.js.map +1 -0
  30. package/dist/topicStore-Ct7IFNtY.js +565 -0
  31. package/dist/topicStore-Ct7IFNtY.js.map +1 -0
  32. package/dist/topicStore-DXvuH9h4.d.ts +435 -0
  33. package/dist/topicStore-DXvuH9h4.d.ts.map +1 -0
  34. package/dist/{version-C90rNWaX.js → version-CIhX5ppJ.js} +6 -6
  35. package/dist/{version-C90rNWaX.js.map → version-CIhX5ppJ.js.map} +1 -1
  36. package/package.json +20 -19
  37. package/src/_exports/_internal.ts +12 -0
  38. package/src/_exports/dashboard.ts +36 -0
  39. package/src/_exports/index.ts +17 -1
  40. package/src/applications/applications.test-d.ts +28 -4
  41. package/src/applications/applications.ts +39 -14
  42. package/src/auth/authMode.test.ts +24 -0
  43. package/src/auth/authMode.ts +8 -3
  44. package/src/auth/authStore.test.ts +18 -0
  45. package/src/auth/authStore.ts +15 -0
  46. package/src/auth/authStrategy.ts +2 -0
  47. package/src/auth/handleAuthCallback.test.ts +33 -0
  48. package/src/auth/handleAuthCallback.ts +11 -1
  49. package/src/auth/logout.test.ts +53 -0
  50. package/src/auth/logout.ts +19 -1
  51. package/src/auth/oauth/oauthActions.test.ts +593 -0
  52. package/src/auth/oauth/oauthActions.ts +438 -0
  53. package/src/auth/oauth/oauthAuth.test.ts +229 -0
  54. package/src/auth/oauth/oauthAuth.ts +178 -0
  55. package/src/auth/oauth/pkce.test.ts +44 -0
  56. package/src/auth/oauth/pkce.ts +60 -0
  57. package/src/auth/oauth/types.ts +12 -0
  58. package/src/config/authConfig.ts +18 -0
  59. package/src/dashboard/messageBus/__fixtures__/test-topics.ts +18 -0
  60. package/src/dashboard/messageBus/bus.test.ts +1033 -0
  61. package/src/dashboard/messageBus/bus.ts +1246 -0
  62. package/src/dashboard/messageBus/store.test.ts +142 -0
  63. package/src/dashboard/messageBus/store.ts +74 -0
  64. package/src/dashboard/messageBus/topicStore.test.ts +172 -0
  65. package/src/dashboard/messageBus/topicStore.ts +184 -0
  66. package/src/dashboard/messageBus/topics.test.ts +71 -0
  67. package/src/dashboard/messageBus/topics.ts +260 -0
  68. package/src/document/actions.ts +3 -3
  69. package/src/document/applyDocumentActions.ts +3 -3
  70. package/src/document/documentStore.ts +12 -12
  71. package/src/document/processMutations.ts +58 -22
  72. package/src/installations/installations.test-d.ts +3 -3
  73. package/src/installations/installations.ts +2 -19
  74. package/src/projection/getProjectionState.ts +2 -2
  75. package/src/projection/resolveProjection.ts +2 -2
  76. package/src/query/queryStore.ts +3 -3
  77. package/src/typegen/defineProjection.test.ts +17 -0
  78. package/src/typegen/defineProjection.ts +19 -0
  79. package/src/typegen/groqCompat.ts +122 -0
  80. package/src/typegen/resolve.ts +37 -0
  81. package/src/utils/createGroqSearchFilter.test.ts +8 -0
  82. package/src/utils/createGroqSearchFilter.ts +6 -3
  83. package/dist/authStore-C-OEkGaM.d.ts.map +0 -1
  84. package/dist/clientStore-gUALQ0qW.js.map +0 -1
  85. package/dist/createSanityInstance-DCePpBYW.d.ts.map +0 -1
  86. 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
+ })