@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.
Files changed (48) hide show
  1. package/dist/_exports/_internal.d.ts +3 -3
  2. package/dist/_exports/_internal.js +5 -5
  3. package/dist/_exports/agent.d.ts +1 -1
  4. package/dist/_exports/agent.js +1 -1
  5. package/dist/_exports/comlink.d.ts +1 -1
  6. package/dist/_exports/dashboard.d.ts +2 -2
  7. package/dist/_exports/dashboard.js +2 -2
  8. package/dist/{applications-903KQTrD.d.ts → applications-Bb-0Kgkc.d.ts} +6 -20
  9. package/dist/applications-Bb-0Kgkc.d.ts.map +1 -0
  10. package/dist/{clientStore-QDrJNTM4.js → clientStore-CD-USpIU.js} +11 -15
  11. package/dist/clientStore-CD-USpIU.js.map +1 -0
  12. package/dist/{createSanityInstance--Lrldto8.d.ts → createSanityInstance-BQIqyGLX.d.ts} +3 -6
  13. package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.js +204 -211
  16. package/dist/index.js.map +1 -1
  17. package/dist/{topicStore-BJ54_X6H.js → topicStore-CPhGph6z.js} +53 -13
  18. package/dist/topicStore-CPhGph6z.js.map +1 -0
  19. package/dist/{topicStore-VQILEI1R.d.ts → topicStore-fPK2CfqH.d.ts} +91 -4
  20. package/dist/topicStore-fPK2CfqH.d.ts.map +1 -0
  21. package/dist/{version-CYqZgJX1.js → version-C7m7OAy9.js} +3 -3
  22. package/dist/{version-CYqZgJX1.js.map → version-C7m7OAy9.js.map} +1 -1
  23. package/package.json +6 -6
  24. package/src/_exports/dashboard.ts +6 -0
  25. package/src/auth/authStore.test.ts +0 -18
  26. package/src/auth/authStore.ts +1 -4
  27. package/src/auth/handleAuthCallback.test.ts +0 -33
  28. package/src/auth/handleAuthCallback.ts +1 -11
  29. package/src/auth/logout.test.ts +1 -9
  30. package/src/auth/logout.ts +1 -18
  31. package/src/auth/oauth/oauthActions.test.ts +1 -178
  32. package/src/auth/oauth/oauthActions.ts +46 -111
  33. package/src/auth/oauth/oauthAuth.test.ts +0 -23
  34. package/src/auth/oauth/oauthAuth.ts +8 -18
  35. package/src/config/authConfig.ts +2 -5
  36. package/src/dashboard/messageBus/bus.test.ts +118 -0
  37. package/src/dashboard/messageBus/bus.ts +81 -7
  38. package/src/dashboard/messageBus/topics.test.ts +20 -0
  39. package/src/dashboard/messageBus/topics.ts +74 -0
  40. package/src/document/documentConstants.ts +5 -2
  41. package/src/document/documentStore.test.ts +194 -0
  42. package/src/document/documentStore.ts +79 -39
  43. package/src/document/reducers.ts +2 -2
  44. package/dist/applications-903KQTrD.d.ts.map +0 -1
  45. package/dist/clientStore-QDrJNTM4.js.map +0 -1
  46. package/dist/createSanityInstance--Lrldto8.d.ts.map +0 -1
  47. package/dist/topicStore-BJ54_X6H.js.map +0 -1
  48. 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
- () => reject(new MessageBusError('TIMEOUT', `query("${type}") timed out`)),
542
- timeoutMs,
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(connectionError: () => unknown): MessageBusConnection {
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(stateSource(type), type, options, connectionSignal, registry.resetAbort.signal)
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 an `OutOfSyncError`.
23
- * Backoff doubles on each successive retry, capped at {@link OUT_OF_SYNC_RETRY_MAX_DELAY}.
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
+ })