@sanity/sdk 3.0.0-rc.1 → 3.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +71 -3
  3. package/dist/_exports/_internal.d.ts.map +1 -1
  4. package/dist/_exports/_internal.js +332 -24
  5. package/dist/_exports/_internal.js.map +1 -1
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2335
  29. package/dist/index.js +4048 -3874
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +13 -19
  45. package/src/_exports/_internal.ts +1 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +38 -86
  49. package/src/applications/applications.test-d.ts +5 -0
  50. package/src/applications/applications.ts +5 -2
  51. package/src/auth/authMode.test.ts +0 -25
  52. package/src/auth/authMode.ts +3 -6
  53. package/src/auth/authStore.test.ts +1 -1
  54. package/src/auth/authStore.ts +0 -1
  55. package/src/comments/addonDatasetStore.test.ts +215 -0
  56. package/src/comments/addonDatasetStore.ts +249 -0
  57. package/src/comments/buildCommentThreads.test.ts +83 -0
  58. package/src/comments/buildCommentThreads.ts +54 -0
  59. package/src/comments/commentActions.test.ts +540 -0
  60. package/src/comments/commentActions.ts +555 -0
  61. package/src/comments/commentFieldPath.test.ts +40 -0
  62. package/src/comments/commentFieldPath.ts +44 -0
  63. package/src/comments/commentsConstants.ts +80 -0
  64. package/src/comments/commentsStore.test.ts +419 -0
  65. package/src/comments/commentsStore.ts +483 -0
  66. package/src/comments/normalizeComment.test.ts +116 -0
  67. package/src/comments/normalizeComment.ts +45 -0
  68. package/src/comments/observeComments.test.ts +193 -0
  69. package/src/comments/observeComments.ts +113 -0
  70. package/src/comments/reducers.test.ts +277 -0
  71. package/src/comments/reducers.ts +316 -0
  72. package/src/comments/types.ts +249 -0
  73. package/src/comments/weakenReferences.test.ts +47 -0
  74. package/src/comments/weakenReferences.ts +29 -0
  75. package/src/config/sanityConfig.ts +3 -75
  76. package/src/document/applyDocumentActions.test.ts +8 -12
  77. package/src/document/documentConstants.ts +8 -0
  78. package/src/document/documentStore.test.ts +104 -19
  79. package/src/document/documentStore.ts +36 -5
  80. package/src/document/patchOperations.test.ts +27 -0
  81. package/src/document/patchOperations.ts +10 -1
  82. package/src/document/permissions.test.ts +3 -1
  83. package/src/document/reducers.test.ts +133 -0
  84. package/src/document/reducers.ts +18 -3
  85. package/src/document/util.test.ts +46 -0
  86. package/src/document/util.ts +37 -1
  87. package/src/installations/installations.test-d.ts +5 -0
  88. package/src/installations/installations.ts +4 -0
  89. package/src/presence/bifurTransport.test.ts +155 -3
  90. package/src/presence/bifurTransport.ts +79 -12
  91. package/src/presence/paths.test.ts +70 -0
  92. package/src/presence/paths.ts +41 -0
  93. package/src/presence/presenceStore.test.ts +822 -2
  94. package/src/presence/presenceStore.ts +518 -102
  95. package/src/presence/types.ts +175 -3
  96. package/src/preview/types.ts +0 -25
  97. package/src/projection/projectionStore.test.ts +0 -8
  98. package/src/projection/types.ts +0 -8
  99. package/src/query/queryStore.ts +7 -6
  100. package/src/store/createActionBinder.test.ts +51 -29
  101. package/src/store/createActionBinder.ts +16 -62
  102. package/src/store/createSanityInstance.test.ts +0 -68
  103. package/src/store/createSanityInstance.ts +0 -63
  104. package/src/store/createStateSourceAction.ts +1 -1
  105. package/src/users/reducers.test.ts +0 -3
  106. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
  107. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  108. package/dist/_chunks-es/_internal.js +0 -134
  109. package/dist/_chunks-es/_internal.js.map +0 -1
  110. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
  111. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  112. package/dist/_chunks-es/telemetryManager.js +0 -93
  113. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  114. package/dist/index.d.ts.map +0 -1
  115. package/src/applications/userApplications.test.ts +0 -126
  116. package/src/applications/userApplications.ts +0 -261
  117. package/src/preview/getPreviewState.test.ts +0 -137
  118. package/src/preview/getPreviewState.ts +0 -69
  119. package/src/preview/resolvePreview.test.ts +0 -64
  120. package/src/preview/resolvePreview.ts +0 -43
@@ -1,27 +1,199 @@
1
1
  import {type SanityClient} from '@sanity/client'
2
+ import {type Path} from '@sanity/types'
2
3
  import {type Observable} from 'rxjs'
3
4
 
5
+ import {type PerspectiveHandle} from '../config/sanityConfig'
4
6
  import {type SanityUser} from '../users/types'
5
7
 
6
- /** @public */
8
+ /**
9
+ * One end of a text selection: a Portable Text path plus a character offset
10
+ * within the span it addresses.
11
+ * @public
12
+ */
13
+ export interface PresenceSelectionPoint {
14
+ path: Path
15
+ offset: number
16
+ }
17
+
18
+ /**
19
+ * A Portable Text caret or selected range, as exchanged with the Studio.
20
+ *
21
+ * Structurally identical to `EditorSelection` from `@portabletext/editor`, and
22
+ * declared separately so that `@sanity/sdk` stays free of React dependencies. A
23
+ * value from the editor can be passed straight through, and vice versa.
24
+ *
25
+ * `offset` is a character offset within the span that `path` addresses.
26
+ * @public
27
+ */
28
+ export type PresenceSelection = {
29
+ anchor: PresenceSelectionPoint
30
+ focus: PresenceSelectionPoint
31
+ backward?: boolean
32
+ } | null
33
+
34
+ /**
35
+ * Somewhere a participant is, as it arrives over the wire.
36
+ *
37
+ * Prefer {@link DocumentPresence} from `getDocumentPresence`, which is scoped to one
38
+ * document, flattened for rendering, and types `path` accurately.
39
+ * @public
40
+ */
7
41
  export interface PresenceLocation {
8
42
  type: 'document'
43
+ /**
44
+ * The specific document the participant is in, so a draft, published, or version
45
+ * id rather than a bare document id.
46
+ */
9
47
  documentId: string
48
+ /**
49
+ * The focused field path, empty when they are in the document but not a field.
50
+ *
51
+ * Declared as `string[]`, but segments can also be numbers or `{_key: string}`
52
+ * objects when the focus is inside an array or a Portable Text field, because
53
+ * that is what other clients send. Guard for those rather than assuming strings.
54
+ * Widening the declared type is a breaking change, so it waits for the next major;
55
+ * {@link DocumentPresence.path} is typed correctly today.
56
+ */
10
57
  path: string[]
58
+ /**
59
+ * When the participant said they were last active, on *their* clock.
60
+ *
61
+ * Fine for display, unreliable for deciding whether someone is still here: it is
62
+ * subject to clock skew between machines. Presence already drops participants that
63
+ * stop announcing, so a session appearing here is one the SDK still considers
64
+ * live.
65
+ */
11
66
  lastActiveAt: string
67
+ /** A Portable Text caret, when the focused field is a Portable Text field. */
68
+ selection?: PresenceSelection
12
69
  }
13
70
 
14
- /** @public */
71
+ /**
72
+ * A location as it travels over the wire. Identical in shape to
73
+ * {@link PresenceLocation}, except `path` is a full `Path`, which is what is
74
+ * really exchanged. See the note on {@link PresenceLocation.path}.
75
+ *
76
+ * Internal: nothing in the public API accepts or returns this. Apps describe
77
+ * where they are with {@link ReportPresenceOptions} and read presence as
78
+ * {@link PresenceLocation}.
79
+ * @internal
80
+ */
81
+ export interface WirePresenceLocation extends Omit<PresenceLocation, 'path'> {
82
+ path: Path
83
+ }
84
+
85
+ /**
86
+ * Which specific document a presence id resolves to.
87
+ *
88
+ * Presence compares exact document ids, so a client in a draft and a client in the
89
+ * published document are in different places even for the same `documentId`. Pass
90
+ * the perspective you are editing under and the SDK resolves it; the React hooks
91
+ * take it from `ResourceProvider` when you do not pass one.
92
+ * @public
93
+ */
94
+ export interface PresencePerspectiveOptions extends PerspectiveHandle {
95
+ /** Live-edit documents have no draft, so presence resolves to the published id. */
96
+ liveEdit?: boolean
97
+ }
98
+
99
+ /**
100
+ * What an app reports about where the current user is.
101
+ *
102
+ * Omit `path` for document-level presence, or pass one for field-level presence.
103
+ * @public
104
+ */
105
+ export interface ReportPresenceOptions extends PresencePerspectiveOptions {
106
+ /**
107
+ * The document the user is in.
108
+ *
109
+ * Pass the plain document id; the perspective decides which specific document that
110
+ * resolves to. An already-specific id is accepted and re-resolved, so passing
111
+ * `drafts.abc` while the perspective is `published` reports the published document.
112
+ */
113
+ documentId: string
114
+ /**
115
+ * The focused field path. Supports keyed and numeric segments, so array items
116
+ * and Portable Text spans can be addressed.
117
+ */
118
+ path?: Path
119
+ /** A Portable Text caret, when the focused field is a Portable Text field. */
120
+ selection?: PresenceSelection
121
+ }
122
+
123
+ /**
124
+ * One participant's session and everywhere it currently is.
125
+ *
126
+ * Keyed by session, not by user: the same person in two tabs is two entries with
127
+ * the same `user`.
128
+ * @public
129
+ */
15
130
  export interface UserPresence {
16
131
  user: SanityUser
17
132
  locations: PresenceLocation[]
133
+ /** Identifies one tab. Stable while that tab is open, and not reused. */
134
+ sessionId: string
135
+ }
136
+
137
+ /**
138
+ * One participant at one location within a single document, flattened so it can
139
+ * be rendered directly against a field.
140
+ *
141
+ * Unlike {@link PresenceLocation.path}, `path` here is typed as a full `Path`, so
142
+ * keyed and numeric segments are described rather than cast away.
143
+ * @beta
144
+ */
145
+ export interface DocumentPresence {
146
+ user: SanityUser
147
+ /** Identifies one tab, so the same person in two tabs appears twice. */
18
148
  sessionId: string
149
+ /**
150
+ * The specific document id this participant is in: a draft, published, or
151
+ * version id, depending on which perspective they are editing.
152
+ */
153
+ documentId: string
154
+ /** The focused field path. Empty when they are in the document but no field. */
155
+ path: Path
156
+ /**
157
+ * When they said they were last active, on *their* clock. Fine for display,
158
+ * unreliable for liveness. See {@link PresenceLocation.lastActiveAt}.
159
+ */
160
+ lastActiveAt: string
161
+ /** The Portable Text caret, when they are focused in a Portable Text field. */
162
+ selection?: PresenceSelection
163
+ }
164
+
165
+ /** @beta */
166
+ export interface DocumentPresenceOptions extends PresencePerspectiveOptions {
167
+ /**
168
+ * The document to look at. Resolved through the perspective exactly as
169
+ * {@link ReportPresenceOptions.documentId} is, so reads match writes.
170
+ */
171
+ documentId: string
172
+ /**
173
+ * Narrows to participants at or below this field path. Omit it for everyone in
174
+ * the document.
175
+ */
176
+ path?: Path
177
+ /**
178
+ * By default a draft, its published version, and any release versions are
179
+ * treated as the same document, which is what document lists want. Set this to
180
+ * compare ids exactly, which is what field-level indicators want so that a
181
+ * draft and a release version do not bleed into each other.
182
+ */
183
+ excludeVersions?: boolean
19
184
  }
20
185
 
21
186
  /** @public */
22
187
  export type PresenceTransport = [
23
188
  incomingEvents$: Observable<TransportEvent>,
24
189
  dispatchMessage: (message: TransportMessage) => Observable<void>,
190
+ /**
191
+ * Emits an incrementing generation number each time the connection becomes
192
+ * live. Reconnects with backoff, and subscribing keeps the connection alive.
193
+ */
194
+ connections$: Observable<number>,
195
+ /** Emits when the page is going away, so a disconnect can be announced. */
196
+ unload$: Observable<void>,
25
197
  ]
26
198
 
27
199
  /** @public */
@@ -54,7 +226,7 @@ export interface DisconnectEvent {
54
226
  /** @public */
55
227
  export type TransportMessage =
56
228
  | {type: 'rollCall'}
57
- | {type: 'state'; locations: PresenceLocation[]}
229
+ | {type: 'state'; locations: WirePresenceLocation[]}
58
230
  | {type: 'disconnect'}
59
231
 
60
232
  /** @public */
@@ -59,28 +59,3 @@ export interface PreviewValue {
59
59
  lastEditedDraftAt?: string
60
60
  }
61
61
  }
62
-
63
- /**
64
- * Represents the current state of a preview value along with a flag indicating whether
65
- * the preview data is still being fetched or is fully resolved.
66
- *
67
- * The tuple contains a preview value or null, and a boolean indicating if the data is
68
- * pending. A `true` value means a fetch is ongoing; `false` indicates that the
69
- * currently provided preview value is up-to-date.
70
- *
71
- * @public
72
- */
73
- export type ValuePending<T> = {
74
- data: T | null
75
- isPending: boolean
76
- }
77
-
78
- /**
79
- * @public
80
- * @deprecated This interface is kept for backwards compatibility but is no longer used internally.
81
- * Preview state is now stored in the projection store.
82
- */
83
- export interface PreviewStoreState {
84
- values: {[TDocumentId in string]?: ValuePending<PreviewValue>}
85
- subscriptions: {[TDocumentId in string]?: {[TSubscriptionId in string]?: true}}
86
- }
@@ -6,14 +6,6 @@ import {createStoreInstance} from '../store/createStoreInstance'
6
6
  import {projectionStore} from './projectionStore'
7
7
  import {subscribeToStateAndFetchBatches} from './subscribeToStateAndFetchBatches'
8
8
 
9
- // Mock the module with a factory function
10
- vi.mock('../common/createLiveEventSubscriber', () => {
11
- const mockLiveSubscriber = vi.fn()
12
- return {
13
- createLiveEventSubscriber: vi.fn(() => mockLiveSubscriber),
14
- }
15
- })
16
-
17
9
  vi.mock('./subscribeToStateAndFetchBatches')
18
10
 
19
11
  describe('projectionStore', () => {
@@ -11,14 +11,6 @@ export interface DocumentProjectionValues<TValue extends object = object> {
11
11
  [projectionHash: string]: ProjectionValuePending<TValue>
12
12
  }
13
13
 
14
- /**
15
- * @public
16
- * @deprecated
17
- * Template literals are a bit too limited, so this type is deprecated.
18
- * Use `string` instead. Projection strings are validated at runtime.
19
- */
20
- export type ValidProjection = string
21
-
22
14
  export interface DocumentProjections {
23
15
  [projectionHash: string]: string
24
16
  }
@@ -80,9 +80,10 @@ export interface ResolveQueryOptions<
80
80
 
81
81
  const EMPTY_ARRAY: never[] = []
82
82
 
83
- /** @beta */
84
- export const getQueryKey = (options: QueryOptions): string => JSON.stringify(options)
85
- /** @beta */
83
+ /** @internal */
84
+ export const getQueryKey = (instance: SanityInstance, options: QueryOptions): string =>
85
+ JSON.stringify(normalizeOptionsWithPerspective(instance, options))
86
+ /** @internal */
86
87
  export const parseQueryKey = (key: string): QueryOptions => JSON.parse(key)
87
88
 
88
89
  /**
@@ -302,14 +303,14 @@ const _getQueryState = bindActionByResource(
302
303
  createStateSourceAction({
303
304
  selector: ({state, instance}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
304
305
  if (state.error) throw state.error
305
- const key = getQueryKey(normalizeOptionsWithPerspective(instance, options))
306
+ const key = getQueryKey(instance, options)
306
307
  const queryState = state.queries[key]
307
308
  if (queryState?.error) throw queryState.error
308
309
  return queryState?.result
309
310
  },
310
311
  onSubscribe: ({state, instance}, options: QueryOptions) => {
311
312
  const subscriptionId = insecureRandomId()
312
- const key = getQueryKey(normalizeOptionsWithPerspective(instance, options))
313
+ const key = getQueryKey(instance, options)
313
314
 
314
315
  state.set('addSubscriber', addSubscriber(key, subscriptionId))
315
316
 
@@ -362,7 +363,7 @@ const _resolveQuery = bindActionByResource(
362
363
  ({state, instance}, {signal, ...options}: ResolveQueryOptions) => {
363
364
  const normalized = normalizeOptionsWithPerspective(instance, options)
364
365
  const {getCurrent} = getQueryState(instance, normalized)
365
- const key = getQueryKey(normalized)
366
+ const key = getQueryKey(instance, normalized)
366
367
 
367
368
  // Hold a temporary subscription for the lifetime of this resolution so the
368
369
  // key participates in normal cleanup. Without one, a fetch that errors
@@ -2,7 +2,6 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'
2
2
 
3
3
  import {type DocumentResource} from '../config/sanityConfig'
4
4
  import {
5
- bindActionByDataset,
6
5
  bindActionByResource,
7
6
  bindActionByResourceAndPerspective,
8
7
  bindActionGlobally,
@@ -96,34 +95,6 @@ describe('createActionBinder', () => {
96
95
  })
97
96
  })
98
97
 
99
- describe('bindActionByDataset', () => {
100
- it('should work correctly when projectId and dataset are provided', () => {
101
- const storeDefinition = {
102
- name: 'DSStore',
103
- getInitialState: () => ({counter: 0}),
104
- }
105
- const action = vi.fn((_context, {value}: {value: string}) => value)
106
- const boundAction = bindActionByDataset(storeDefinition, action)
107
- const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
108
- const result = boundAction(instance, {value: 'hello'})
109
- expect(result).toBe('hello')
110
- })
111
-
112
- it('should throw an error if projectId or dataset is missing', () => {
113
- const storeDefinition = {
114
- name: 'DSStore',
115
- getInitialState: () => ({counter: 0}),
116
- }
117
- const action = vi.fn((_context, _?) => 'fail')
118
- const boundAction = bindActionByDataset(storeDefinition, action)
119
- // Instance with missing dataset
120
- const instance = createSanityInstance({projectId: 'proj1', dataset: ''})
121
- expect(() => boundAction(instance)).toThrow(
122
- 'This API requires a project ID and dataset configured.',
123
- )
124
- })
125
- })
126
-
127
98
  describe('bindActionGlobally', () => {
128
99
  it('should work correctly ignoring config in key generation', () => {
129
100
  const storeDefinition = {
@@ -206,6 +177,57 @@ describe('bindActionByResource', () => {
206
177
  })
207
178
  })
208
179
 
180
+ describe('instance default resource', () => {
181
+ const storeDefinition = {
182
+ name: 'DefaultResourceStore',
183
+ getInitialState: () => ({counter: 0}),
184
+ }
185
+ const whichResource = bindActionByResource(storeDefinition, ({key}) => key)
186
+
187
+ it('uses config.resource when no resource is passed', () => {
188
+ const instance = createSanityInstance({resource: {projectId: 'p', dataset: 'd'}})
189
+ expect(whichResource(instance, {})).toMatchObject({
190
+ name: 'p.d',
191
+ resource: {projectId: 'p', dataset: 'd'},
192
+ })
193
+ instance.dispose()
194
+ })
195
+
196
+ it('supports a media library as the instance default', () => {
197
+ const instance = createSanityInstance({resource: {mediaLibraryId: 'ml-123'}})
198
+ expect(whichResource(instance, {})).toMatchObject({
199
+ name: 'media-library:ml-123',
200
+ resource: {mediaLibraryId: 'ml-123'},
201
+ })
202
+ instance.dispose()
203
+ })
204
+
205
+ it('supports a canvas as the instance default', () => {
206
+ const instance = createSanityInstance({resource: {canvasId: 'c-1'}})
207
+ expect(whichResource(instance, {})).toMatchObject({
208
+ name: 'canvas:c-1',
209
+ resource: {canvasId: 'c-1'},
210
+ })
211
+ instance.dispose()
212
+ })
213
+
214
+ it('lets a per-call resource override the instance default', () => {
215
+ const instance = createSanityInstance({resource: {mediaLibraryId: 'ml-123'}})
216
+ expect(whichResource(instance, {resource: {projectId: 'x', dataset: 'y'}})).toMatchObject({
217
+ resource: {projectId: 'x', dataset: 'y'},
218
+ })
219
+ instance.dispose()
220
+ })
221
+
222
+ it('still accepts the projectId/dataset shorthand', () => {
223
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
224
+ expect(whichResource(instance, {})).toMatchObject({
225
+ resource: {projectId: 'p', dataset: 'd'},
226
+ })
227
+ instance.dispose()
228
+ })
229
+ })
230
+
209
231
  describe('bindActionByResourceAndPerspective', () => {
210
232
  it('should throw an error when provided an invalid resource', () => {
211
233
  const storeDefinition = {
@@ -21,12 +21,6 @@ export interface BoundResourceKey {
21
21
  export interface BoundPerspectiveKey extends BoundResourceKey {
22
22
  perspective: ClientPerspective | ReleasePerspective
23
23
  }
24
- interface BoundDatasetKey {
25
- name: string
26
- projectId: string
27
- dataset: string
28
- }
29
-
30
24
  /**
31
25
  * Defines a store action that operates on a specific state type
32
26
  */
@@ -123,70 +117,29 @@ export function createActionBinder<
123
117
  }
124
118
  }
125
119
 
126
- /**
127
- * Binds an action to a store that's scoped to a specific project and dataset
128
- *
129
- * @remarks
130
- * This creates actions that operate on state isolated to a specific projectId and dataset.
131
- * Different project/dataset combinations will have separate states.
132
- *
133
- * @throws Error if projectId or dataset is missing from the Sanity instance config
134
- *
135
- * @example
136
- * ```ts
137
- * // Define a store
138
- * const documentStore = defineStore<DocumentState>({
139
- * name: 'Document',
140
- * getInitialState: () => ({ documents: {} }),
141
- * // ...
142
- * })
143
- *
144
- * // Create dataset-specific actions
145
- * export const fetchDocument = bindActionByDataset(
146
- * documentStore,
147
- * ({instance, state}, documentId) => {
148
- * // This state is isolated to the specific project/dataset
149
- * // ...fetch logic...
150
- * }
151
- * )
152
- *
153
- * // Usage
154
- * fetchDocument(sanityInstance, 'doc123')
155
- * ```
156
- */
157
- export const bindActionByDataset = createActionBinder<
158
- BoundDatasetKey,
159
- [(object & {projectId?: string; dataset?: string})?, ...unknown[]]
160
- >((instance, options) => {
161
- const projectId = options?.projectId ?? instance.config.projectId
162
- const dataset = options?.dataset ?? instance.config.dataset
163
- if (!projectId || !dataset) {
164
- throw new Error('This API requires a project ID and dataset configured.')
165
- }
166
- return {name: `${projectId}.${dataset}`, projectId, dataset}
167
- })
168
-
169
120
  const createResourceKey = (
170
121
  instance: SanityInstance,
171
122
  resource?: DocumentResource,
172
123
  ): BoundResourceKey => {
173
124
  let name: string | undefined
174
125
  let resourceForKey: DocumentResource | undefined
175
- if (resource) {
176
- resourceForKey = resource
177
- if (isDatasetResource(resource)) {
178
- name = `${resource.projectId}.${resource.dataset}`
179
- } else if (isMediaLibraryResource(resource)) {
180
- name = `media-library:${resource.mediaLibraryId}`
181
- } else if (isCanvasResource(resource)) {
182
- name = `canvas:${resource.canvasId}`
126
+ // A per-call resource wins; otherwise use the instance's default resource.
127
+ const effectiveResource = resource ?? instance.config.resource
128
+ if (effectiveResource) {
129
+ resourceForKey = effectiveResource
130
+ if (isDatasetResource(effectiveResource)) {
131
+ name = `${effectiveResource.projectId}.${effectiveResource.dataset}`
132
+ } else if (isMediaLibraryResource(effectiveResource)) {
133
+ name = `media-library:${effectiveResource.mediaLibraryId}`
134
+ } else if (isCanvasResource(effectiveResource)) {
135
+ name = `canvas:${effectiveResource.canvasId}`
183
136
  } else {
184
- throw new Error(`Received invalid resource: ${JSON.stringify(resource)}`)
137
+ throw new Error(`Received invalid resource: ${JSON.stringify(effectiveResource)}`)
185
138
  }
186
139
  return {name, resource: resourceForKey}
187
140
  }
188
141
 
189
- // TODO: remove reference to instance.config when we get to v3
142
+ // `projectId`/`dataset` on the config are shorthand for a dataset resource.
190
143
  const {projectId, dataset} = instance.config
191
144
  if (!projectId || !dataset) {
192
145
  throw new Error('This API requires a project ID and dataset configured.')
@@ -199,8 +152,8 @@ const createResourceKey = (
199
152
  **/
200
153
  export const bindActionByResource = createActionBinder<
201
154
  BoundResourceKey,
202
- // this implies resources is optional to keep backwards compatibility
203
- // but in reality, we'll always pass a resource (since we'll defer to the instance until v3)
155
+ // `resource` is optional because it falls back to the instance config in
156
+ // `createResourceKey` — see the note there on why that fallback still exists.
204
157
  [{resource?: DocumentResource}, ...unknown[]]
205
158
  >((instance, {resource}) => {
206
159
  return createResourceKey(instance, resource)
@@ -245,7 +198,8 @@ export const bindActionByResourceAndPerspective = createActionBinder<
245
198
  [DatasetHandle, ...unknown[]]
246
199
  >((instance, options): BoundPerspectiveKey => {
247
200
  const {resource, perspective} = options
248
- // TODO: remove reference to instance.config.perspective when we get to v3
201
+ // Same trade-off as the resource fallback in `createResourceKey`: configured once on the
202
+ // instance rather than passed to every call. Kept for the same reason.
249
203
  const utilizedPerspective = perspective ?? instance.config.perspective ?? 'drafts'
250
204
  let perspectiveKey: string
251
205
  if (isReleasePerspective(utilizedPerspective)) {
@@ -29,60 +29,6 @@ describe('createSanityInstance', () => {
29
29
  expect(callback).toHaveBeenCalledTimes(1)
30
30
  })
31
31
 
32
- it('should create a child instance with merged config and correct parent', () => {
33
- const parent = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
34
- const child = parent.createChild({dataset: 'ds2'})
35
- expect(child.config).toEqual({projectId: 'proj1', dataset: 'ds2'})
36
- expect(child.getParent()).toBe(parent)
37
- })
38
-
39
- it('should match an instance in the hierarchy using match', () => {
40
- // three-level hierarchy
41
- const grandparent = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
42
- const parent = grandparent.createChild({projectId: 'proj2'})
43
- const child = parent.createChild({dataset: 'ds2'})
44
-
45
- expect(child.config).toEqual({projectId: 'proj2', dataset: 'ds2'})
46
- expect(parent.config).toEqual({projectId: 'proj2', dataset: 'ds1'})
47
-
48
- expect(child.match({dataset: 'ds2'})).toBe(child)
49
- expect(child.match({projectId: 'proj2'})).toBe(child)
50
- expect(child.match({projectId: 'proj1'})).toBe(grandparent)
51
- expect(parent.match({projectId: 'proj1'})).toBe(grandparent)
52
- expect(grandparent.match({projectId: 'proj1'})).toBe(grandparent)
53
- })
54
-
55
- it('should match `undefined` when the desired resource ID should not be set on an instance', () => {
56
- const noProjectOrDataset = createSanityInstance()
57
- const noDataset = noProjectOrDataset.createChild({projectId: 'proj1'})
58
- const leaf = noDataset.createChild({dataset: 'ds1'})
59
-
60
- // no keys means anything (in this case, self) will match
61
- expect(leaf.match({})).toBe(leaf)
62
-
63
- // `[resourceId]: undefined` means match an instance with no dataset set
64
- expect(leaf.match({dataset: undefined})).toBe(noDataset)
65
- expect(noDataset.match({dataset: undefined})).toBe(noDataset)
66
- expect(leaf.match({projectId: undefined})).toBe(noProjectOrDataset)
67
- expect(noDataset.match({projectId: undefined})).toBe(noProjectOrDataset)
68
- expect(noProjectOrDataset.match({projectId: undefined})).toBe(noProjectOrDataset)
69
- })
70
-
71
- it('should return undefined when no match is found', () => {
72
- const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
73
- expect(instance.match({dataset: 'non-existent'})).toBeUndefined()
74
- })
75
-
76
- it('should inherit and merge auth config', () => {
77
- const parent = createSanityInstance({
78
- projectId: 'proj1',
79
- dataset: 'ds1',
80
- auth: {apiHost: 'api.sanity.work'},
81
- })
82
- const child = parent.createChild({auth: {token: 'my-token'}})
83
- expect(child.config.auth).toEqual({apiHost: 'api.sanity.work', token: 'my-token'})
84
- })
85
-
86
32
  describe('logging', () => {
87
33
  const mockHandler: LogHandler = {
88
34
  error: vi.fn(),
@@ -141,20 +87,6 @@ describe('createSanityInstance', () => {
141
87
  )
142
88
  })
143
89
 
144
- it('should log child instance creation at debug level', () => {
145
- const parent = createSanityInstance({projectId: 'parent-proj'})
146
- vi.clearAllMocks() // Clear parent creation logs
147
-
148
- parent.createChild({dataset: 'child-ds'})
149
-
150
- expect(mockHandler.debug).toHaveBeenCalledWith(
151
- expect.stringContaining('Creating child instance'),
152
- expect.objectContaining({
153
- overridingDataset: true,
154
- }),
155
- )
156
- })
157
-
158
90
  it('should include instance context in logs', () => {
159
91
  createSanityInstance({projectId: 'my-project', dataset: 'my-dataset'})
160
92