Skip to content

Commit 1fbb844

Browse files
authored
Collections must have a getId function & use an id for update/delete operators (#134)
1 parent d1cb4ba commit 1fbb844

20 files changed

Lines changed: 921 additions & 940 deletions

.changeset/fast-trees-find.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@tanstack/db-collections": patch
3+
"@tanstack/db-example-react-todo": patch
4+
"@tanstack/react-db": patch
5+
"@tanstack/vue-db": patch
6+
"@tanstack/db": patch
7+
---
8+
9+
Collections must have a getId function & use an id for update/delete operators

examples/react/todo/src/App.tsx

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ const createTodoCollection = (type: CollectionType) => {
149149
timestamptz: (date: string) => new Date(date),
150150
},
151151
},
152-
primaryKey: [`id`],
152+
getId: (item) => item.id,
153153
schema: updateTodoSchema,
154154
})
155155
} else {
@@ -198,7 +198,7 @@ const createConfigCollection = (type: CollectionType) => {
198198
},
199199
},
200200
},
201-
primaryKey: [`id`],
201+
getId: (item) => item.id,
202202
schema: updateConfigSchema,
203203
})
204204
} else {
@@ -220,7 +220,7 @@ const createConfigCollection = (type: CollectionType) => {
220220
: undefined,
221221
}))
222222
},
223-
getId: (item) => String(item.id),
223+
getId: (item) => item.id,
224224
schema: updateConfigSchema,
225225
queryClient,
226226
})
@@ -290,10 +290,19 @@ export default function App() {
290290

291291
const updateTodo = useOptimisticMutation({
292292
mutationFn: async ({ transaction }) => {
293-
const mutation = transaction.mutations[0] as PendingMutation<UpdateTodo>
294-
const { original, changes } = mutation
295-
const response = await api.todos.update(original.id, changes)
296-
await collectionSync(collectionType, mutation, response.txid)
293+
const txids = await Promise.all(
294+
transaction.mutations.map(async (mutation) => {
295+
const { original, changes } = mutation
296+
const response = await api.todos.update(original.id, changes)
297+
return response.txid
298+
})
299+
)
300+
await Promise.all(
301+
txids.map(async (txid, i) => {
302+
const mutation = transaction.mutations[i]
303+
return await collectionSync(collectionType, mutation, txid)
304+
})
305+
)
297306
},
298307
})
299308

@@ -349,12 +358,9 @@ export default function App() {
349358
for (const config of configData) {
350359
if (config.key === key) {
351360
updateConfig.mutate(() =>
352-
configCollection.update(
353-
Array.from(configCollection.state.values())[0]! as UpdateConfig,
354-
(draft) => {
355-
draft.value = value
356-
}
357-
)
361+
configCollection.update(config.id, (draft) => {
362+
draft.value = value
363+
})
358364
)
359365

360366
return
@@ -439,14 +445,9 @@ export default function App() {
439445

440446
const toggleTodo = (todo: UpdateTodo) => {
441447
updateTodo.mutate(() =>
442-
todoCollection.update(
443-
Array.from(todoCollection.state.values()).find(
444-
(t) => t.id === todo.id
445-
)!,
446-
(draft) => {
447-
draft.completed = !draft.completed
448-
}
449-
)
448+
todoCollection.update(todo.id, (draft) => {
449+
draft.completed = !draft.completed
450+
})
450451
)
451452
}
452453

@@ -544,9 +545,7 @@ export default function App() {
544545
todosToToggle.forEach((t) => togglingIds.add(t.id))
545546
updateTodo.mutate(() =>
546547
todoCollection.update(
547-
Array.from(todoCollection.state.values()).filter((t) =>
548-
togglingIds.has(t.id)
549-
),
548+
todosToToggle.map((todo) => todo.id),
550549
(drafts) => {
551550
drafts.forEach(
552551
(draft) => (draft.completed = !allCompleted)
@@ -596,11 +595,7 @@ export default function App() {
596595
<button
597596
onClick={() => {
598597
deleteTodo.mutate(() =>
599-
todoCollection.delete(
600-
Array.from(todoCollection.state.values()).find(
601-
(t) => t.id === todo.id
602-
)!
603-
)
598+
todoCollection.delete(todo.id)
604599
)
605600
}}
606601
className="hidden group-hover:block absolute right-[10px] w-[40px] h-[40px] my-auto top-0 bottom-0 text-[30px] text-[#cc9a9a] hover:text-[#af5b5e] transition-colors"
@@ -623,9 +618,7 @@ export default function App() {
623618
onClick={() => {
624619
deleteTodo.mutate(() =>
625620
todoCollection.delete(
626-
Array.from(todoCollection.state.values()).filter(
627-
(t) => completedTodos.some((ct) => ct.id === t.id)
628-
)
621+
completedTodos.map((todo) => todo.id)
629622
)
630623
)
631624
}}

packages/db-collections/src/electric.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,6 @@ export interface ElectricCollectionConfig<T extends Row<unknown>>
2222
* Configuration options for the ElectricSQL ShapeStream
2323
*/
2424
streamOptions: ShapeStreamOptions
25-
26-
/**
27-
* Array of column names that form the primary key of the shape
28-
*/
29-
primaryKey: Array<string>
3025
}
3126

3227
/**
@@ -40,7 +35,6 @@ export class ElectricCollection<
4035
constructor(config: ElectricCollectionConfig<T>) {
4136
const seenTxids = new Store<Set<number>>(new Set([Math.random()]))
4237
const sync = createElectricSync<T>(config.streamOptions, {
43-
primaryKey: config.primaryKey,
4438
seenTxids,
4539
})
4640

@@ -114,23 +108,24 @@ export function createElectricCollection<T extends Row<unknown>>(
114108
*/
115109
function createElectricSync<T extends Row<unknown>>(
116110
streamOptions: ShapeStreamOptions,
117-
options: { primaryKey: Array<string>; seenTxids: Store<Set<number>> }
111+
options: {
112+
seenTxids: Store<Set<number>>
113+
}
118114
): SyncConfig<T> {
119-
const { primaryKey, seenTxids } = options
115+
const { seenTxids } = options
120116

121117
// Store for the relation schema information
122118
const relationSchema = new Store<string | undefined>(undefined)
123119

124120
/**
125121
* Get the sync metadata for insert operations
126-
* @returns Record containing primaryKey and relation information
122+
* @returns Record containing relation information
127123
*/
128124
const getSyncMetadata = (): Record<string, unknown> => {
129125
// Use the stored schema if available, otherwise default to 'public'
130126
const schema = relationSchema.state || `public`
131127

132128
return {
133-
primaryKey,
134129
relation: streamOptions.params?.table
135130
? [schema, streamOptions.params.table]
136131
: undefined,
@@ -168,18 +163,16 @@ function createElectricSync<T extends Row<unknown>>(
168163
transactionStarted = true
169164
}
170165

171-
const key = message.key
166+
const value = message.value as unknown as T
172167

173168
// Include the primary key and relation info in the metadata
174169
const enhancedMetadata = {
175170
...message.headers,
176-
primaryKey,
177171
}
178172

179173
write({
180-
key,
181174
type: message.headers.operation,
182-
value: message.value as unknown as T,
175+
value,
183176
metadata: enhancedMetadata,
184177
})
185178
} else if (isUpToDateMessage(message)) {
@@ -204,13 +197,3 @@ function createElectricSync<T extends Row<unknown>>(
204197
getSyncMetadata,
205198
}
206199
}
207-
208-
/**
209-
* Configuration options for ElectricSync
210-
*/
211-
export interface ElectricSyncOptions {
212-
/**
213-
* Array of column names that form the primary key of the shape
214-
*/
215-
primaryKey: Array<string>
216-
}

packages/db-collections/src/query.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,21 +175,21 @@ export class QueryCollection<
175175
currentSyncedItems.forEach((oldItem, key) => {
176176
const newItem = newItemsMap.get(key)
177177
if (!newItem) {
178-
write({ type: `delete`, key, value: oldItem })
178+
write({ type: `delete`, value: oldItem })
179179
} else if (
180180
!shallowEqual(
181181
oldItem as Record<string, any>,
182182
newItem as Record<string, any>
183183
)
184184
) {
185185
// Only update if there are actual differences in the properties
186-
write({ type: `update`, key, value: newItem })
186+
write({ type: `update`, value: newItem })
187187
}
188188
})
189189

190190
newItemsMap.forEach((newItem, key) => {
191191
if (!currentSyncedItems.has(key)) {
192-
write({ type: `insert`, key, value: newItem })
192+
write({ type: `insert`, value: newItem })
193193
}
194194
})
195195

packages/db-collections/tests/electric.test.ts

Lines changed: 8 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe(`Electric Integration`, () => {
4141
table: `test_table`,
4242
},
4343
},
44-
primaryKey: [`id`], // Using 'id' as the primary key column
44+
getId: (item) => item.id,
4545
})
4646
})
4747

@@ -63,7 +63,7 @@ describe(`Electric Integration`, () => {
6363
])
6464

6565
expect(collection.state).toEqual(
66-
new Map([[`1`, { id: 1, name: `Test User` }]])
66+
new Map([[`KEY::${collection.id}/1`, { id: 1, name: `Test User` }]])
6767
)
6868
})
6969

@@ -95,8 +95,8 @@ describe(`Electric Integration`, () => {
9595

9696
expect(collection.state).toEqual(
9797
new Map([
98-
[`1`, { id: 1, name: `Test User` }],
99-
[`2`, { id: 2, name: `Another User` }],
98+
[`KEY::${collection.id}/1`, { id: 1, name: `Test User` }],
99+
[`KEY::${collection.id}/2`, { id: 2, name: `Another User` }],
100100
])
101101
)
102102
})
@@ -128,7 +128,7 @@ describe(`Electric Integration`, () => {
128128
])
129129

130130
expect(collection.state).toEqual(
131-
new Map([[`1`, { id: 1, name: `Updated User` }]])
131+
new Map([[`KEY::${collection.id}/1`, { id: 1, name: `Updated User` }]])
132132
)
133133
})
134134

@@ -149,7 +149,7 @@ describe(`Electric Integration`, () => {
149149
subscriber([
150150
{
151151
key: `1`,
152-
value: {},
152+
value: { id: `1` },
153153
headers: { operation: `delete` },
154154
},
155155
{
@@ -367,35 +367,8 @@ describe(`Electric Integration`, () => {
367367
// Check that the data was added to the collection
368368
// Note: In a real implementation, the collection would be updated by the sync process
369369
// This is just verifying our test setup worked correctly
370-
expect(fakeBackend.data.has(`item1`)).toBe(true)
371-
expect(collection.state.has(`item1`)).toBe(true)
370+
expect(fakeBackend.data.has(`KEY::${collection.id}/1`)).toBe(true)
371+
expect(collection.state.has(`KEY::${collection.id}/1`)).toBe(true)
372372
})
373373
})
374-
375-
it(`should include primaryKey in the metadata`, () => {
376-
// Simulate incoming insert message
377-
subscriber([
378-
{
379-
key: `1`,
380-
value: { id: 1, name: `Test User` },
381-
headers: { operation: `insert` },
382-
},
383-
])
384-
385-
// Send up-to-date control message to commit transaction
386-
subscriber([
387-
{
388-
headers: { control: `up-to-date` },
389-
},
390-
])
391-
392-
// Get the metadata for the inserted item
393-
const metadata = collection.syncedMetadata.state.get(`1`) as {
394-
primaryKey: string
395-
}
396-
397-
// Verify that the primaryKey is included in the metadata
398-
expect(metadata).toHaveProperty(`primaryKey`)
399-
expect(metadata.primaryKey).toEqual([`id`])
400-
})
401374
})

0 commit comments

Comments
 (0)