@sanity/sdk 2.5.0 → 2.7.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 (46) hide show
  1. package/dist/index.d.ts +429 -27
  2. package/dist/index.js +657 -266
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -3
  5. package/src/_exports/index.ts +18 -3
  6. package/src/auth/authMode.test.ts +56 -0
  7. package/src/auth/authMode.ts +71 -0
  8. package/src/auth/authStore.test.ts +85 -4
  9. package/src/auth/authStore.ts +63 -125
  10. package/src/auth/authStrategy.ts +39 -0
  11. package/src/auth/dashboardAuth.ts +132 -0
  12. package/src/auth/standaloneAuth.ts +109 -0
  13. package/src/auth/studioAuth.ts +217 -0
  14. package/src/auth/studioModeAuth.test.ts +43 -1
  15. package/src/auth/studioModeAuth.ts +10 -1
  16. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +21 -6
  17. package/src/client/clientStore.test.ts +45 -43
  18. package/src/client/clientStore.ts +23 -9
  19. package/src/config/loggingConfig.ts +149 -0
  20. package/src/config/sanityConfig.ts +82 -22
  21. package/src/projection/getProjectionState.ts +6 -5
  22. package/src/projection/projectionQuery.test.ts +38 -55
  23. package/src/projection/projectionQuery.ts +27 -31
  24. package/src/projection/projectionStore.test.ts +4 -4
  25. package/src/projection/projectionStore.ts +3 -2
  26. package/src/projection/resolveProjection.ts +2 -2
  27. package/src/projection/statusQuery.test.ts +35 -0
  28. package/src/projection/statusQuery.ts +71 -0
  29. package/src/projection/subscribeToStateAndFetchBatches.test.ts +63 -50
  30. package/src/projection/subscribeToStateAndFetchBatches.ts +106 -27
  31. package/src/projection/types.ts +12 -0
  32. package/src/projection/util.ts +0 -1
  33. package/src/query/queryStore.test.ts +64 -0
  34. package/src/query/queryStore.ts +33 -11
  35. package/src/releases/getPerspectiveState.test.ts +17 -14
  36. package/src/releases/getPerspectiveState.ts +58 -38
  37. package/src/releases/releasesStore.test.ts +59 -61
  38. package/src/releases/releasesStore.ts +21 -35
  39. package/src/releases/utils/isReleasePerspective.ts +7 -0
  40. package/src/store/createActionBinder.test.ts +211 -1
  41. package/src/store/createActionBinder.ts +102 -13
  42. package/src/store/createSanityInstance.test.ts +85 -1
  43. package/src/store/createSanityInstance.ts +55 -4
  44. package/src/utils/logger-usage-example.md +141 -0
  45. package/src/utils/logger.test.ts +757 -0
  46. package/src/utils/logger.ts +537 -0
@@ -1,10 +1,11 @@
1
+ import {DocumentId} from '@sanity/id-utils'
1
2
  import {describe, expect, it} from 'vitest'
2
3
 
3
4
  import {createProjectionQuery, processProjectionQuery} from './projectionQuery'
4
5
 
5
6
  describe('createProjectionQuery', () => {
6
7
  it('creates a query and params for given ids and projections', () => {
7
- const ids = new Set(['doc1', 'doc2'])
8
+ const ids = new Set(['doc1', 'doc2'].map(DocumentId))
8
9
  const projectionHash = '{title, description}'
9
10
  const documentProjections = {
10
11
  doc1: {[projectionHash]: projectionHash},
@@ -15,11 +16,11 @@ describe('createProjectionQuery', () => {
15
16
  expect(query).toMatch(/.*_id in \$__ids_.*/)
16
17
  expect(Object.keys(params)).toHaveLength(1)
17
18
  expect(params[`__ids_${projectionHash}`]).toBeDefined()
18
- expect(params[`__ids_${projectionHash}`]).toHaveLength(4)
19
+ expect(params[`__ids_${projectionHash}`]).toHaveLength(2)
19
20
  })
20
21
 
21
22
  it('handles multiple different projections', () => {
22
- const ids = new Set(['doc1', 'doc2'])
23
+ const ids = new Set(['doc1', 'doc2'].map(DocumentId))
23
24
  const projectionHash1 = '{title, description}'
24
25
  const projectionHash2 = '{name, age}'
25
26
  const documentProjections = {
@@ -31,13 +32,13 @@ describe('createProjectionQuery', () => {
31
32
  expect(query).toMatch(/.*_id in \$__ids_.*/)
32
33
  expect(Object.keys(params)).toHaveLength(2)
33
34
  expect(params[`__ids_${projectionHash1}`]).toBeDefined()
34
- expect(params[`__ids_${projectionHash1}`]).toHaveLength(2)
35
+ expect(params[`__ids_${projectionHash1}`]).toHaveLength(1)
35
36
  expect(params[`__ids_${projectionHash2}`]).toBeDefined()
36
- expect(params[`__ids_${projectionHash2}`]).toHaveLength(2)
37
+ expect(params[`__ids_${projectionHash2}`]).toHaveLength(1)
37
38
  })
38
39
 
39
40
  it('filters out ids without projections', () => {
40
- const ids = new Set(['doc1', 'doc2', 'doc3'])
41
+ const ids = new Set(['doc1', 'doc2', 'doc3'].map(DocumentId))
41
42
  const projectionHash1 = '{title}'
42
43
  // projectionHash2 missing intentionally
43
44
  const projectionHash3 = '{name}'
@@ -51,9 +52,9 @@ describe('createProjectionQuery', () => {
51
52
  expect(query).toMatch(/.*_id in \$__ids_.*/)
52
53
  expect(Object.keys(params)).toHaveLength(2)
53
54
  expect(params[`__ids_${projectionHash1}`]).toBeDefined()
54
- expect(params[`__ids_${projectionHash1}`]).toHaveLength(2)
55
+ expect(params[`__ids_${projectionHash1}`]).toHaveLength(1)
55
56
  expect(params[`__ids_${projectionHash3}`]).toBeDefined()
56
- expect(params[`__ids_${projectionHash3}`]).toHaveLength(2)
57
+ expect(params[`__ids_${projectionHash3}`]).toHaveLength(1)
57
58
  })
58
59
  })
59
60
 
@@ -63,10 +64,9 @@ describe('processProjectionQuery', () => {
63
64
  it('returns structure with empty object if no results found', () => {
64
65
  const ids = new Set(['doc1'])
65
66
  const result = processProjectionQuery({
66
- projectId: 'p',
67
- dataset: 'd',
68
67
  ids,
69
68
  results: [], // no results
69
+ perspective: 'published',
70
70
  })
71
71
 
72
72
  expect(result['doc1']).toEqual({})
@@ -85,10 +85,14 @@ describe('processProjectionQuery', () => {
85
85
  ]
86
86
 
87
87
  const processed = processProjectionQuery({
88
- projectId: 'p',
89
- dataset: 'd',
90
88
  ids,
91
89
  results,
90
+ perspective: 'published',
91
+ documentStatuses: {
92
+ doc1: {
93
+ lastEditedPublishedAt: '2021-01-01',
94
+ },
95
+ },
92
96
  })
93
97
 
94
98
  expect(processed['doc1']?.[testProjectionHash]).toEqual({
@@ -116,10 +120,14 @@ describe('processProjectionQuery', () => {
116
120
  ]
117
121
 
118
122
  const processed = processProjectionQuery({
119
- projectId: 'p',
120
- dataset: 'd',
121
123
  ids: new Set(['doc1']),
122
124
  results,
125
+ perspective: 'published',
126
+ documentStatuses: {
127
+ doc1: {
128
+ lastEditedPublishedAt: '2021-01-01',
129
+ },
130
+ },
123
131
  })
124
132
 
125
133
  expect(processed['doc1']?.[testProjectionHash]).toEqual({
@@ -134,66 +142,37 @@ describe('processProjectionQuery', () => {
134
142
  })
135
143
  })
136
144
 
137
- it('handles both draft and published documents', () => {
145
+ it('handles release perspective with all three status fields', () => {
138
146
  const results = [
139
- {
140
- _id: 'drafts.doc1',
141
- _type: 'document',
142
- _updatedAt: '2021-01-02',
143
- result: {title: 'Draft'},
144
- __projectionHash: testProjectionHash,
145
- },
146
147
  {
147
148
  _id: 'doc1',
148
149
  _type: 'document',
149
- _updatedAt: '2021-01-01',
150
- result: {title: 'Published'},
150
+ _updatedAt: '2021-01-03',
151
+ result: {title: 'Version'},
151
152
  __projectionHash: testProjectionHash,
152
153
  },
153
154
  ]
154
155
 
155
156
  const processed = processProjectionQuery({
156
- projectId: 'p',
157
- dataset: 'd',
158
157
  ids: new Set(['doc1']),
159
158
  results,
160
- })
161
-
162
- expect(processed['doc1']?.[testProjectionHash]).toEqual({
163
- data: {
164
- title: 'Draft',
165
- _status: {
159
+ perspective: {releaseName: 'release1'},
160
+ documentStatuses: {
161
+ doc1: {
166
162
  lastEditedDraftAt: '2021-01-02',
167
163
  lastEditedPublishedAt: '2021-01-01',
164
+ lastEditedVersionAt: '2021-01-03',
168
165
  },
169
166
  },
170
- isPending: false,
171
- })
172
- })
173
-
174
- it('uses published result when no draft exists', () => {
175
- const results = [
176
- {
177
- _id: 'doc1',
178
- _type: 'document',
179
- _updatedAt: '2021-01-01',
180
- result: {title: 'Published'},
181
- __projectionHash: testProjectionHash,
182
- },
183
- ]
184
-
185
- const processed = processProjectionQuery({
186
- projectId: 'p',
187
- dataset: 'd',
188
- ids: new Set(['doc1']),
189
- results,
190
167
  })
191
168
 
192
169
  expect(processed['doc1']?.[testProjectionHash]).toEqual({
193
170
  data: {
194
- title: 'Published',
171
+ title: 'Version',
195
172
  _status: {
173
+ lastEditedDraftAt: '2021-01-02',
196
174
  lastEditedPublishedAt: '2021-01-01',
175
+ lastEditedVersionAt: '2021-01-03',
197
176
  },
198
177
  },
199
178
  isPending: false,
@@ -221,10 +200,14 @@ describe('processProjectionQuery', () => {
221
200
  ]
222
201
 
223
202
  const processed = processProjectionQuery({
224
- projectId: 'p',
225
- dataset: 'd',
226
203
  ids: new Set(['doc1']),
227
204
  results,
205
+ perspective: 'published',
206
+ documentStatuses: {
207
+ doc1: {
208
+ lastEditedPublishedAt: '2021-01-01',
209
+ },
210
+ },
228
211
  })
229
212
 
230
213
  expect(processed['doc1']?.[hash1]).toEqual({
@@ -1,5 +1,13 @@
1
- import {getDraftId, getPublishedId} from '../utils/ids'
2
- import {type DocumentProjections, type DocumentProjectionValues} from './types'
1
+ import {type ClientPerspective} from '@sanity/client'
2
+ import {DocumentId} from '@sanity/id-utils'
3
+
4
+ import {type ReleasePerspective} from '../config/sanityConfig'
5
+ import {getPublishedId} from '../utils/ids'
6
+ import {
7
+ type DocumentProjections,
8
+ type DocumentProjectionValues,
9
+ type ProjectionStoreState,
10
+ } from './types'
3
11
  import {validateProjection} from './util'
4
12
 
5
13
  export type ProjectionQueryResult = {
@@ -18,8 +26,8 @@ interface CreateProjectionQueryResult {
18
26
  type ProjectionMap = Record<string, {projection: string; documentIds: Set<string>}>
19
27
 
20
28
  export function createProjectionQuery(
21
- documentIds: Set<string>,
22
- documentProjections: {[TDocumentId in string]?: DocumentProjections},
29
+ documentIds: Set<DocumentId>,
30
+ documentProjections: {[TDocumentId in DocumentId]?: DocumentProjections},
23
31
  ): CreateProjectionQueryResult {
24
32
  const projections = Array.from(documentIds)
25
33
  .flatMap((id) => {
@@ -48,11 +56,7 @@ export function createProjectionQuery(
48
56
 
49
57
  const params = Object.fromEntries(
50
58
  Object.entries(projections).map(([projectionHash, value]) => {
51
- const idsInProjection = Array.from(value.documentIds).flatMap((id) => [
52
- getPublishedId(id),
53
- getDraftId(id),
54
- ])
55
-
59
+ const idsInProjection = Array.from(value.documentIds).flatMap((id) => DocumentId(id))
56
60
  return [`__ids_${projectionHash}`, Array.from(idsInProjection)]
57
61
  }),
58
62
  )
@@ -61,28 +65,28 @@ export function createProjectionQuery(
61
65
  }
62
66
 
63
67
  interface ProcessProjectionQueryOptions {
64
- projectId: string
65
- dataset: string
66
68
  ids: Set<string>
67
69
  results: ProjectionQueryResult[]
70
+ documentStatuses?: ProjectionStoreState['documentStatuses']
71
+ perspective: ClientPerspective | ReleasePerspective
68
72
  }
69
73
 
70
- export function processProjectionQuery({ids, results}: ProcessProjectionQueryOptions): {
74
+ export function processProjectionQuery({
75
+ ids,
76
+ results,
77
+ documentStatuses,
78
+ }: ProcessProjectionQueryOptions): {
71
79
  [TDocumentId in string]?: DocumentProjectionValues<Record<string, unknown>>
72
80
  } {
73
81
  const groupedResults: {
74
82
  [docId: string]: {
75
- [hash: string]: {
76
- draft?: ProjectionQueryResult
77
- published?: ProjectionQueryResult
78
- }
83
+ [hash: string]: ProjectionQueryResult | undefined
79
84
  }
80
85
  } = {}
81
86
 
82
87
  for (const result of results) {
83
88
  const originalId = getPublishedId(result._id)
84
89
  const hash = result.__projectionHash
85
- const isDraft = result._id.startsWith('drafts.')
86
90
 
87
91
  if (!ids.has(originalId)) continue
88
92
 
@@ -90,14 +94,10 @@ export function processProjectionQuery({ids, results}: ProcessProjectionQueryOpt
90
94
  groupedResults[originalId] = {}
91
95
  }
92
96
  if (!groupedResults[originalId][hash]) {
93
- groupedResults[originalId][hash] = {}
97
+ groupedResults[originalId][hash] = undefined
94
98
  }
95
99
 
96
- if (isDraft) {
97
- groupedResults[originalId][hash].draft = result
98
- } else {
99
- groupedResults[originalId][hash].published = result
100
- }
100
+ groupedResults[originalId][hash] = result
101
101
  }
102
102
 
103
103
  const finalValues: {
@@ -111,22 +111,18 @@ export function processProjectionQuery({ids, results}: ProcessProjectionQueryOpt
111
111
  if (!projectionsForDoc) continue
112
112
 
113
113
  for (const hash in projectionsForDoc) {
114
- const {draft, published} = projectionsForDoc[hash]
115
-
116
- const projectionResultData = draft?.result ?? published?.result
114
+ const projectionResult = projectionsForDoc[hash]
115
+ const projectionResultData = projectionResult?.result
117
116
 
118
117
  if (!projectionResultData) {
119
118
  finalValues[originalId][hash] = {data: null, isPending: false}
120
119
  continue
121
120
  }
122
121
 
123
- const _status = {
124
- ...(draft?._updatedAt && {lastEditedDraftAt: draft._updatedAt}),
125
- ...(published?._updatedAt && {lastEditedPublishedAt: published._updatedAt}),
126
- }
122
+ const statusFromStore = documentStatuses?.[originalId]
127
123
 
128
124
  finalValues[originalId][hash] = {
129
- data: {...projectionResultData, _status},
125
+ data: {...projectionResultData, _status: statusFromStore},
130
126
  isPending: false,
131
127
  }
132
128
  }
@@ -30,8 +30,8 @@ describe('projectionStore', () => {
30
30
  instance,
31
31
  {
32
32
  name: 'p.d',
33
- projectId: 'p',
34
- dataset: 'd',
33
+ source: {projectId: 'p', dataset: 'd'},
34
+ perspective: 'drafts',
35
35
  },
36
36
  projectionStore,
37
37
  )
@@ -42,8 +42,8 @@ describe('projectionStore', () => {
42
42
  state,
43
43
  key: {
44
44
  name: 'p.d',
45
- projectId: 'p',
46
- dataset: 'd',
45
+ source: {projectId: 'p', dataset: 'd'},
46
+ perspective: 'drafts',
47
47
  },
48
48
  })
49
49
 
@@ -1,15 +1,16 @@
1
- import {type BoundDatasetKey} from '../store/createActionBinder'
1
+ import {type BoundPerspectiveKey} from '../store/createActionBinder'
2
2
  import {defineStore} from '../store/defineStore'
3
3
  import {subscribeToStateAndFetchBatches} from './subscribeToStateAndFetchBatches'
4
4
  import {type ProjectionStoreState} from './types'
5
5
 
6
- export const projectionStore = defineStore<ProjectionStoreState, BoundDatasetKey>({
6
+ export const projectionStore = defineStore<ProjectionStoreState, BoundPerspectiveKey>({
7
7
  name: 'Projection',
8
8
  getInitialState() {
9
9
  return {
10
10
  values: {},
11
11
  documentProjections: {},
12
12
  subscriptions: {},
13
+ documentStatuses: {},
13
14
  }
14
15
  },
15
16
  initialize(context) {
@@ -1,7 +1,7 @@
1
1
  import {type SanityProjectionResult} from 'groq'
2
2
  import {filter, firstValueFrom} from 'rxjs'
3
3
 
4
- import {bindActionByDataset} from '../store/createActionBinder'
4
+ import {bindActionBySourceAndPerspective} from '../store/createActionBinder'
5
5
  import {type SanityInstance} from '../store/createSanityInstance'
6
6
  import {getProjectionState, type ProjectionOptions} from './getProjectionState'
7
7
  import {projectionStore} from './projectionStore'
@@ -38,7 +38,7 @@ export function resolveProjection(
38
38
  /**
39
39
  * @beta
40
40
  */
41
- const _resolveProjection = bindActionByDataset(
41
+ const _resolveProjection = bindActionBySourceAndPerspective(
42
42
  projectionStore,
43
43
  (
44
44
  {instance}: {instance: SanityInstance},
@@ -0,0 +1,35 @@
1
+ import {DocumentId, getVersionId} from '@sanity/id-utils'
2
+ import {describe, expect, it} from 'vitest'
3
+
4
+ import {buildStatusQueryIds, processStatusQueryResults} from './statusQuery'
5
+
6
+ describe('buildStatusQueryIds', () => {
7
+ it('includes draft and published ids only when perspective is not a release', () => {
8
+ const ids = new Set(['doc1'].map(DocumentId))
9
+ const result = buildStatusQueryIds(ids, 'published')
10
+ expect(result).toContain('drafts.doc1')
11
+ expect(result).toContain('doc1')
12
+ expect(result).toHaveLength(2)
13
+ })
14
+
15
+ it('includes version ids when perspective is a release', () => {
16
+ const ids = new Set(['doc1'].map(DocumentId))
17
+ const perspective = {releaseName: 'myRelease'}
18
+ const result = buildStatusQueryIds(ids, perspective)
19
+ expect(result).toContain('drafts.doc1')
20
+ expect(result).toContain('doc1')
21
+ expect(result).toContain(getVersionId(DocumentId('doc1'), 'myRelease'))
22
+ expect(result).toHaveLength(3)
23
+ })
24
+ })
25
+
26
+ describe('processStatusQueryResults', () => {
27
+ it('sets lastEditedVersionAt when result _id is a version id', () => {
28
+ const versionId = getVersionId(DocumentId('doc1'), 'myRelease')
29
+ const results = [{_id: versionId, _updatedAt: '2025-01-01T12:00:00Z'}]
30
+ const documentStatuses = processStatusQueryResults(results)
31
+ expect(documentStatuses['doc1']).toEqual({
32
+ lastEditedVersionAt: '2025-01-01T12:00:00Z',
33
+ })
34
+ })
35
+ })
@@ -0,0 +1,71 @@
1
+ import {
2
+ DocumentId,
3
+ getDraftId,
4
+ getPublishedId,
5
+ getVersionId,
6
+ isDraftId,
7
+ isPublishedId,
8
+ isVersionId,
9
+ } from '@sanity/id-utils'
10
+
11
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
12
+ import {type BoundPerspectiveKey} from '../store/createActionBinder'
13
+ import {type ProjectionStoreState} from './types'
14
+
15
+ interface StatusQueryResult {
16
+ _id: string
17
+ _updatedAt: string
18
+ }
19
+
20
+ /**
21
+ * Builds an array of document IDs to query for status information using the "raw" perspective.
22
+ * Includes draft, published, and version IDs as needed.
23
+ */
24
+ export function buildStatusQueryIds(
25
+ documentIds: Set<string>,
26
+ perspective: BoundPerspectiveKey['perspective'],
27
+ ): string[] {
28
+ const ids: string[] = []
29
+ const releaseName = isReleasePerspective(perspective) ? perspective.releaseName : null
30
+
31
+ for (const id of documentIds) {
32
+ const publishedId = getPublishedId(DocumentId(id))
33
+ const draftId = getDraftId(publishedId)
34
+
35
+ // Always query for draft and published versions
36
+ ids.push(draftId, publishedId)
37
+
38
+ // If it's a release perspective, also query for the version
39
+ if (releaseName) {
40
+ ids.push(getVersionId(publishedId, releaseName))
41
+ }
42
+ }
43
+
44
+ return ids
45
+ }
46
+
47
+ /**
48
+ * Processes status query results into documentStatuses (same shape as _status returned to users).
49
+ */
50
+ export function processStatusQueryResults(
51
+ results: StatusQueryResult[],
52
+ ): ProjectionStoreState['documentStatuses'] {
53
+ const documentStatuses: ProjectionStoreState['documentStatuses'] = {}
54
+
55
+ for (const result of results) {
56
+ const id = DocumentId(result._id)
57
+ const updatedAt = result._updatedAt
58
+ const publishedId = getPublishedId(id)
59
+ const statusData = documentStatuses[publishedId] ?? {}
60
+ if (isDraftId(id)) {
61
+ statusData.lastEditedDraftAt = updatedAt
62
+ } else if (isVersionId(id)) {
63
+ statusData.lastEditedVersionAt = updatedAt
64
+ } else if (isPublishedId(id)) {
65
+ statusData.lastEditedPublishedAt = updatedAt
66
+ }
67
+ documentStatuses[publishedId] = statusData
68
+ }
69
+
70
+ return documentStatuses
71
+ }
@@ -15,13 +15,18 @@ vi.mock('../query/queryStore')
15
15
  describe('subscribeToStateAndFetchBatches', () => {
16
16
  let instance: SanityInstance
17
17
  let state: StoreState<ProjectionStoreState>
18
- const key = {name: 'test.test', projectId: 'test', dataset: 'test'}
18
+ const key = {
19
+ name: 'test.test:drafts',
20
+ source: {projectId: 'test', dataset: 'test'},
21
+ perspective: 'drafts' as const,
22
+ }
19
23
 
20
24
  beforeEach(() => {
21
25
  vi.clearAllMocks()
22
26
  instance = createSanityInstance({projectId: 'test', dataset: 'test'})
23
27
  state = createStoreState<ProjectionStoreState>({
24
28
  documentProjections: {},
29
+ documentStatuses: {},
25
30
  subscriptions: {},
26
31
  values: {},
27
32
  })
@@ -65,19 +70,14 @@ describe('subscribeToStateAndFetchBatches', () => {
65
70
  // Wait for debounce
66
71
  await new Promise((resolve) => setTimeout(resolve, 100))
67
72
 
68
- // Should still be 1 call because projections are identical
69
- expect(getQueryState).toHaveBeenCalledTimes(1)
73
+ // only 2 calls (one for projection, one for status) even though we added 2 subscriptions
74
+ expect(getQueryState).toHaveBeenCalledTimes(2)
70
75
  expect(getQueryState).toHaveBeenCalledWith(
71
76
  instance,
72
77
  expect.objectContaining({
73
- query: expect.any(String),
78
+ perspective: 'drafts',
74
79
  params: {
75
- [`__ids_${projectionHash}`]: expect.arrayContaining([
76
- 'doc1',
77
- 'drafts.doc1',
78
- 'doc2',
79
- 'drafts.doc2',
80
- ]),
80
+ [`__ids_${projectionHash}`]: expect.arrayContaining(['doc1', 'doc2']),
81
81
  },
82
82
  }),
83
83
  )
@@ -87,47 +87,43 @@ describe('subscribeToStateAndFetchBatches', () => {
87
87
 
88
88
  it('processes query results and updates state with resolved values', async () => {
89
89
  const teardown = vi.fn()
90
- const subscriber = vi
91
- .fn<(observer: Observer<ProjectionQueryResult[] | undefined>) => () => void>()
92
- .mockReturnValue(teardown)
93
-
94
- vi.mocked(getQueryState).mockReturnValue({
95
- getCurrent: () => undefined,
96
- observable: new Observable(subscriber),
97
- } as StateSource<ProjectionQueryResult[] | undefined>)
90
+ const projectionObservers: Observer<ProjectionQueryResult[] | undefined>[] = []
91
+ const statusObservers: Observer<{_id: string; _updatedAt: string}[] | undefined>[] = []
92
+
93
+ vi.mocked(getQueryState).mockImplementation((_, options) => {
94
+ const isStatusQuery = options.perspective === 'raw'
95
+ const observers = isStatusQuery ? statusObservers : projectionObservers
96
+ const observable = new Observable<unknown>((observer) => {
97
+ observers.push(observer as Observer<ProjectionQueryResult[] | undefined>)
98
+ return teardown
99
+ })
100
+ return {
101
+ getCurrent: () => undefined,
102
+ observable,
103
+ } as StateSource<ProjectionQueryResult[] | undefined>
104
+ })
98
105
 
99
106
  const subscription = subscribeToStateAndFetchBatches({instance, state, key})
100
107
  const projection = '{title}'
101
108
  const projectionHash = hashString(projection)
102
109
 
103
- expect(subscriber).not.toHaveBeenCalled()
104
-
105
110
  // Add a subscription
106
111
  state.set('addSubscription', {
107
112
  documentProjections: {doc1: {[projectionHash]: projection}},
108
113
  subscriptions: {doc1: {[projectionHash]: {sub1: true}}},
109
114
  })
110
115
 
111
- expect(subscriber).not.toHaveBeenCalled()
112
-
113
- // Wait for debounce
116
+ // Wait for debounce (combineLatest subscribes to both projection and status)
114
117
  await new Promise((resolve) => setTimeout(resolve, 100))
115
118
 
116
- expect(subscriber).toHaveBeenCalled()
119
+ expect(projectionObservers.length).toBe(1)
120
+ expect(statusObservers.length).toBe(1)
117
121
  expect(teardown).not.toHaveBeenCalled()
118
122
 
119
- const [observer] = subscriber.mock.lastCall!
120
-
121
123
  const timestamp = new Date().toISOString()
122
124
 
123
- observer.next([
124
- {
125
- _id: 'doc1',
126
- _type: 'doc',
127
- _updatedAt: timestamp,
128
- result: {title: 'resolved'},
129
- __projectionHash: projectionHash,
130
- },
125
+ // Emit projection results first
126
+ projectionObservers[0]!.next([
131
127
  {
132
128
  _id: 'drafts.doc1',
133
129
  _type: 'doc',
@@ -137,6 +133,12 @@ describe('subscribeToStateAndFetchBatches', () => {
137
133
  },
138
134
  ])
139
135
 
136
+ // Emit status results (raw query returns _id, _updatedAt per document variant)
137
+ statusObservers[0]!.next([
138
+ {_id: 'doc1', _updatedAt: timestamp},
139
+ {_id: 'drafts.doc1', _updatedAt: timestamp},
140
+ ])
141
+
140
142
  const {values} = state.get()
141
143
  expect(values['doc1']?.[projectionHash]).toEqual({
142
144
  isPending: false,
@@ -247,11 +249,9 @@ describe('subscribeToStateAndFetchBatches', () => {
247
249
 
248
250
  await new Promise((resolve) => setTimeout(resolve, 100))
249
251
 
250
- // Expected calls:
251
- // 1. Initial fetch (doc1, hash1)
252
- // 2. Adding doc2 subscription (optimistic update, no fetch)
253
- // 3. Debounced fetch for (doc1, hash1) AND (doc2, hash2)
254
- expect(getQueryState).toHaveBeenCalledTimes(initialQueryCallCount + 1)
252
+ // Expected calls: initial batch has 2 (projection + status). After adding doc2,
253
+ // debounced fetch triggers 2 more (projection + status for new batch).
254
+ expect(getQueryState).toHaveBeenCalledTimes(initialQueryCallCount + 2)
255
255
  // Abort should have been called because the required projections changed
256
256
  expect(abortSpy).toHaveBeenCalled()
257
257
 
@@ -259,13 +259,21 @@ describe('subscribeToStateAndFetchBatches', () => {
259
259
  })
260
260
 
261
261
  it('processes and applies fetch results correctly', async () => {
262
- const subscriber =
263
- vi.fn<(observer: Observer<ProjectionQueryResult[] | undefined>) => () => void>()
264
-
265
- vi.mocked(getQueryState).mockReturnValue({
266
- getCurrent: () => undefined,
267
- observable: new Observable(subscriber),
268
- } as StateSource<ProjectionQueryResult[] | undefined>)
262
+ const projectionObservers: Observer<ProjectionQueryResult[] | undefined>[] = []
263
+ const statusObservers: Observer<{_id: string; _updatedAt: string}[] | undefined>[] = []
264
+
265
+ vi.mocked(getQueryState).mockImplementation((_, options) => {
266
+ const isStatusQuery = options.perspective === 'raw'
267
+ const observers = isStatusQuery ? statusObservers : projectionObservers
268
+ const observable = new Observable<unknown>((observer) => {
269
+ observers.push(observer as Observer<ProjectionQueryResult[] | undefined>)
270
+ return () => {}
271
+ })
272
+ return {
273
+ getCurrent: () => undefined,
274
+ observable,
275
+ } as StateSource<ProjectionQueryResult[] | undefined>
276
+ })
269
277
 
270
278
  const subscription = subscribeToStateAndFetchBatches({instance, state, key})
271
279
  const projection = '{title, description}'
@@ -280,12 +288,12 @@ describe('subscribeToStateAndFetchBatches', () => {
280
288
 
281
289
  await new Promise((resolve) => setTimeout(resolve, 100))
282
290
 
283
- expect(subscriber).toHaveBeenCalled()
284
- const [observer] = subscriber.mock.lastCall!
291
+ expect(projectionObservers.length).toBe(1)
292
+ expect(statusObservers.length).toBe(1)
285
293
 
286
294
  // Emit fetch results
287
295
  const timestamp = '2024-01-01T00:00:00Z'
288
- observer.next([
296
+ projectionObservers[0]!.next([
289
297
  {
290
298
  _id: 'doc1',
291
299
  _type: 'test',
@@ -294,13 +302,18 @@ describe('subscribeToStateAndFetchBatches', () => {
294
302
  __projectionHash: projectionHash,
295
303
  },
296
304
  ])
305
+ statusObservers[0]!.next([
306
+ {_id: 'doc1', _updatedAt: timestamp},
307
+ {_id: 'drafts.doc1', _updatedAt: timestamp},
308
+ ])
297
309
 
298
- // Check that the state was updated
310
+ // Check that the state was updated (status query provides both draft and published _updatedAt)
299
311
  expect(state.get().values['doc1']?.[projectionHash]).toEqual({
300
312
  data: expect.objectContaining({
301
313
  title: 'Test Document',
302
314
  description: 'Test Description',
303
315
  _status: {
316
+ lastEditedDraftAt: timestamp,
304
317
  lastEditedPublishedAt: timestamp,
305
318
  },
306
319
  }),