Skip to content

Commit 6404a53

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/fix-electric-proxy-queries-01C2vhzTAzg2myxC269rptUA
2 parents 917d7af + 4ff9b5d commit 6404a53

8 files changed

Lines changed: 278 additions & 10 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/query-db-collection': patch
3+
---
4+
5+
Fix writeInsert/writeUpsert throwing error when collection uses select option
6+
7+
When a Query Collection was configured with a `select` option to extract items from a wrapped API response (e.g., `{ data: [...], meta: {...} }`), calling `writeInsert()` or `writeUpsert()` would corrupt the query cache and trigger the error: "select() must return an array of objects".
8+
9+
The fix routes cache updates through a new `updateCacheData` function that preserves the wrapper structure by using the `select` function to identify which property contains the items array (via reference equality), then updates only that property while keeping metadata intact.

examples/react/paced-mutations-demo/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@tanstack/db": "^0.5.11",
13-
"@tanstack/react-db": "^0.1.55",
13+
"@tanstack/react-db": "^0.1.56",
1414
"mitt": "^3.0.1",
1515
"react": "^19.2.1",
1616
"react-dom": "^19.2.1"

examples/react/todo/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"dependencies": {
66
"@tanstack/electric-db-collection": "^0.2.12",
77
"@tanstack/query-core": "^5.90.12",
8-
"@tanstack/query-db-collection": "^1.0.6",
9-
"@tanstack/react-db": "^0.1.55",
8+
"@tanstack/query-db-collection": "^1.0.7",
9+
"@tanstack/react-db": "^0.1.56",
1010
"@tanstack/react-router": "^1.140.0",
1111
"@tanstack/react-start": "^1.140.0",
1212
"@tanstack/trailbase-db-collection": "^0.1.55",

examples/solid/todo/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"dependencies": {
66
"@tanstack/electric-db-collection": "^0.2.12",
77
"@tanstack/query-core": "^5.90.12",
8-
"@tanstack/query-db-collection": "^1.0.6",
8+
"@tanstack/query-db-collection": "^1.0.7",
99
"@tanstack/solid-db": "^0.1.54",
1010
"@tanstack/solid-router": "^1.140.0",
1111
"@tanstack/solid-start": "^1.140.0",

packages/query-db-collection/src/manual-sync.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export interface SyncContext<
3838
begin: () => void
3939
write: (message: Omit<ChangeMessage<TRow>, `key`>) => void
4040
commit: () => void
41+
/**
42+
* Optional function to update the query cache with the latest synced data.
43+
* Handles both direct array caches and wrapped response formats (when `select` is used).
44+
* If not provided, falls back to directly setting the cache with the raw array.
45+
*/
46+
updateCacheData?: (items: Array<TRow>) => void
4147
}
4248

4349
interface NormalizedOperation<
@@ -205,7 +211,12 @@ export function performWriteOperations<
205211

206212
// Update query cache after successful commit
207213
const updatedData = Array.from(ctx.collection._state.syncedData.values())
208-
ctx.queryClient.setQueryData(ctx.queryKey, updatedData)
214+
if (ctx.updateCacheData) {
215+
ctx.updateCacheData(updatedData)
216+
} else {
217+
// Fallback: directly set the cache with raw array (for non-Query Collection consumers)
218+
ctx.queryClient.setQueryData(ctx.queryKey, updatedData)
219+
}
209220
}
210221

211222
// Factory function to create write utils

packages/query-db-collection/src/query.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,73 @@ export function queryCollectionOptions(
11301130
await Promise.all(refetchPromises)
11311131
}
11321132

1133+
/**
1134+
* Updates the query cache with new items, handling both direct arrays
1135+
* and wrapped response formats (when `select` is used).
1136+
*/
1137+
const updateCacheData = (items: Array<any>): void => {
1138+
// Get the base query key (handle both static and function-based keys)
1139+
const key =
1140+
typeof queryKey === `function`
1141+
? queryKey({})
1142+
: (queryKey as unknown as QueryKey)
1143+
1144+
if (select) {
1145+
// When `select` is used, the cache contains a wrapped response (e.g., { data: [...], meta: {...} })
1146+
// We need to update the cache while preserving the wrapper structure
1147+
queryClient.setQueryData(key, (oldData: any) => {
1148+
if (!oldData || typeof oldData !== `object`) {
1149+
// No existing cache or not an object - don't corrupt the cache
1150+
return oldData
1151+
}
1152+
1153+
if (Array.isArray(oldData)) {
1154+
// Cache is already a raw array (shouldn't happen with select, but handle it)
1155+
return items
1156+
}
1157+
1158+
// Use the select function to identify which property contains the items array.
1159+
// This is more robust than guessing based on property order.
1160+
const selectedArray = select(oldData)
1161+
1162+
if (Array.isArray(selectedArray)) {
1163+
// Find the property that matches the selected array by reference equality
1164+
for (const propKey of Object.keys(oldData)) {
1165+
if (oldData[propKey] === selectedArray) {
1166+
// Found the exact property - create a shallow copy with updated items
1167+
return { ...oldData, [propKey]: items }
1168+
}
1169+
}
1170+
}
1171+
1172+
// Fallback: check common property names used for data arrays
1173+
if (Array.isArray(oldData.data)) {
1174+
return { ...oldData, data: items }
1175+
}
1176+
if (Array.isArray(oldData.items)) {
1177+
return { ...oldData, items: items }
1178+
}
1179+
if (Array.isArray(oldData.results)) {
1180+
return { ...oldData, results: items }
1181+
}
1182+
1183+
// Last resort: find first array property
1184+
for (const propKey of Object.keys(oldData)) {
1185+
if (Array.isArray(oldData[propKey])) {
1186+
return { ...oldData, [propKey]: items }
1187+
}
1188+
}
1189+
1190+
// Couldn't safely identify the array property - don't corrupt the cache
1191+
// Return oldData unchanged to avoid breaking select
1192+
return oldData
1193+
})
1194+
} else {
1195+
// No select - cache contains raw array, just set it directly
1196+
queryClient.setQueryData(key, items)
1197+
}
1198+
}
1199+
11331200
// Create write context for manual write operations
11341201
let writeContext: {
11351202
collection: any
@@ -1139,21 +1206,29 @@ export function queryCollectionOptions(
11391206
begin: () => void
11401207
write: (message: Omit<ChangeMessage<any>, `key`>) => void
11411208
commit: () => void
1209+
updateCacheData?: (items: Array<any>) => void
11421210
} | null = null
11431211

11441212
// Enhanced internalSync that captures write functions for manual use
11451213
const enhancedInternalSync: SyncConfig<any>[`sync`] = (params) => {
11461214
const { begin, write, commit, collection } = params
11471215

1216+
// Get the base query key for the context (handle both static and function-based keys)
1217+
const contextQueryKey =
1218+
typeof queryKey === `function`
1219+
? (queryKey({}) as unknown as Array<unknown>)
1220+
: (queryKey as unknown as Array<unknown>)
1221+
11481222
// Store references for manual write operations
11491223
writeContext = {
11501224
collection,
11511225
queryClient,
1152-
queryKey: queryKey as unknown as Array<unknown>,
1226+
queryKey: contextQueryKey,
11531227
getKey: getKey as (item: any) => string | number,
11541228
begin,
11551229
write,
11561230
commit,
1231+
updateCacheData,
11571232
}
11581233

11591234
// Call the original internalSync logic

packages/query-db-collection/tests/query.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,179 @@ describe(`QueryCollection`, () => {
692692
) as MetaDataType<TestItem>
693693
expect(initialCache).toEqual(initialMetaData)
694694
})
695+
696+
it(`should not throw error when using writeInsert with select option`, async () => {
697+
const queryKey = [`select-writeInsert-test`]
698+
const consoleErrorSpy = vi
699+
.spyOn(console, `error`)
700+
.mockImplementation(() => {})
701+
702+
const queryFn = vi.fn().mockResolvedValue(initialMetaData)
703+
const select = vi.fn((data: MetaDataType<TestItem>) => data.data)
704+
705+
const options = queryCollectionOptions({
706+
id: `select-writeInsert-test`,
707+
queryClient,
708+
queryKey,
709+
queryFn,
710+
select,
711+
getKey,
712+
startSync: true,
713+
})
714+
const collection = createCollection(options)
715+
716+
// Wait for collection to be ready
717+
await vi.waitFor(() => {
718+
expect(collection.status).toBe(`ready`)
719+
expect(collection.size).toBe(2)
720+
})
721+
722+
// This should NOT cause an error - but with the bug it does
723+
const newItem: TestItem = { id: `3`, name: `New Item` }
724+
collection.utils.writeInsert(newItem)
725+
726+
// Verify the item was inserted
727+
expect(collection.size).toBe(3)
728+
expect(collection.get(`3`)).toEqual(newItem)
729+
730+
// Wait a tick to allow any async error handlers to run
731+
await flushPromises()
732+
733+
// Verify no error was logged about select returning non-array
734+
const errorCallArgs = consoleErrorSpy.mock.calls.find((call) =>
735+
call[0]?.includes?.(
736+
`@tanstack/query-db-collection: select() must return an array of objects`,
737+
),
738+
)
739+
expect(errorCallArgs).toBeUndefined()
740+
741+
consoleErrorSpy.mockRestore()
742+
})
743+
744+
it(`should not throw error when using writeUpsert with select option`, async () => {
745+
const queryKey = [`select-writeUpsert-test`]
746+
const consoleErrorSpy = vi
747+
.spyOn(console, `error`)
748+
.mockImplementation(() => {})
749+
750+
const queryFn = vi.fn().mockResolvedValue(initialMetaData)
751+
const select = vi.fn((data: MetaDataType<TestItem>) => data.data)
752+
753+
const options = queryCollectionOptions({
754+
id: `select-writeUpsert-test`,
755+
queryClient,
756+
queryKey,
757+
queryFn,
758+
select,
759+
getKey,
760+
startSync: true,
761+
})
762+
const collection = createCollection(options)
763+
764+
// Wait for collection to be ready
765+
await vi.waitFor(() => {
766+
expect(collection.status).toBe(`ready`)
767+
expect(collection.size).toBe(2)
768+
})
769+
770+
// This should NOT cause an error - but with the bug it does
771+
// Test upsert for new item
772+
const newItem: TestItem = { id: `3`, name: `Upserted New Item` }
773+
collection.utils.writeUpsert(newItem)
774+
775+
// Verify the item was inserted
776+
expect(collection.size).toBe(3)
777+
expect(collection.get(`3`)).toEqual(newItem)
778+
779+
// Test upsert for existing item
780+
collection.utils.writeUpsert({ id: `1`, name: `Updated First Item` })
781+
782+
// Verify the item was updated
783+
expect(collection.get(`1`)?.name).toBe(`Updated First Item`)
784+
785+
// Wait a tick to allow any async error handlers to run
786+
await flushPromises()
787+
788+
// Verify no error was logged about select returning non-array
789+
const errorCallArgs = consoleErrorSpy.mock.calls.find((call) =>
790+
call[0]?.includes?.(
791+
`@tanstack/query-db-collection: select() must return an array of objects`,
792+
),
793+
)
794+
expect(errorCallArgs).toBeUndefined()
795+
796+
consoleErrorSpy.mockRestore()
797+
})
798+
799+
it(`should update query cache with wrapped format preserved when using writeInsert with select option`, async () => {
800+
const queryKey = [`select-cache-update-test`]
801+
802+
const queryFn = vi.fn().mockResolvedValue(initialMetaData)
803+
const select = vi.fn((data: MetaDataType<TestItem>) => data.data)
804+
805+
const options = queryCollectionOptions({
806+
id: `select-cache-update-test`,
807+
queryClient,
808+
queryKey,
809+
queryFn,
810+
select,
811+
getKey,
812+
startSync: true,
813+
})
814+
const collection = createCollection(options)
815+
816+
// Wait for collection to be ready
817+
await vi.waitFor(() => {
818+
expect(collection.status).toBe(`ready`)
819+
expect(collection.size).toBe(2)
820+
})
821+
822+
// Verify initial cache has wrapped format
823+
const initialCache = queryClient.getQueryData(
824+
queryKey,
825+
) as MetaDataType<TestItem>
826+
expect(initialCache.metaDataOne).toBe(`example metadata`)
827+
expect(initialCache.metaDataTwo).toBe(`example metadata`)
828+
expect(initialCache.data).toHaveLength(2)
829+
830+
// Insert a new item
831+
const newItem: TestItem = { id: `3`, name: `New Item` }
832+
collection.utils.writeInsert(newItem)
833+
834+
// Verify the cache still has wrapped format with metadata preserved
835+
const cacheAfterInsert = queryClient.getQueryData(
836+
queryKey,
837+
) as MetaDataType<TestItem>
838+
expect(cacheAfterInsert.metaDataOne).toBe(`example metadata`)
839+
expect(cacheAfterInsert.metaDataTwo).toBe(`example metadata`)
840+
expect(cacheAfterInsert.data).toHaveLength(3)
841+
expect(cacheAfterInsert.data).toContainEqual(newItem)
842+
843+
// Update an existing item
844+
collection.utils.writeUpdate({ id: `1`, name: `Updated First Item` })
845+
846+
// Verify the cache still has wrapped format
847+
const cacheAfterUpdate = queryClient.getQueryData(
848+
queryKey,
849+
) as MetaDataType<TestItem>
850+
expect(cacheAfterUpdate.metaDataOne).toBe(`example metadata`)
851+
expect(cacheAfterUpdate.data).toHaveLength(3)
852+
const updatedItem = cacheAfterUpdate.data.find((item) => item.id === `1`)
853+
expect(updatedItem?.name).toBe(`Updated First Item`)
854+
855+
// Delete an item
856+
collection.utils.writeDelete(`2`)
857+
858+
// Verify the cache still has wrapped format
859+
const cacheAfterDelete = queryClient.getQueryData(
860+
queryKey,
861+
) as MetaDataType<TestItem>
862+
expect(cacheAfterDelete.metaDataOne).toBe(`example metadata`)
863+
expect(cacheAfterDelete.data).toHaveLength(2)
864+
expect(cacheAfterDelete.data).not.toContainEqual(
865+
expect.objectContaining({ id: `2` }),
866+
)
867+
})
695868
})
696869
describe(`Direct persistence handlers`, () => {
697870
it(`should pass through direct persistence handlers to collection options`, () => {

pnpm-lock.yaml

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)