Skip to content

Commit b4602a0

Browse files
refactor collection for fine grained reactivity (#155)
Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com>
1 parent c50cd51 commit b4602a0

File tree

21 files changed

+1363
-816
lines changed

21 files changed

+1363
-816
lines changed

.changeset/icy-moles-study.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@tanstack/db-collections": patch
3+
"@tanstack/react-db": patch
4+
"@tanstack/vue-db": patch
5+
"@tanstack/db": patch
6+
---
7+
8+
A large refactor of the core `Collection` with:
9+
10+
- a change to not use Store internally and emit fine grade changes with `subscribeChanges` and `subscribeKeyChanges` methods.
11+
- changes to the `Collection` api to be more `Map` like for reads, with `get`, `has`, `size`, `entries`, `keys`, and `values`.
12+
- renames `config.getId` to `config.getKey` for consistency with the `Map` like api.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { createQueryCollection } from "@tanstack/db-collections"
5353
const todoCollection = createQueryCollection<Todo>({
5454
queryKey: ["todos"],
5555
queryFn: async () => fetch("/api/todos"),
56-
getId: (item) => item.id,
56+
getKey: (item) => item.id,
5757
schema: todoSchema, // any standard schema
5858
})
5959
```

docs/overview.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ If provided, this should be a [Standard Schema](https://standardschema.dev) comp
173173
const todoCollection = createCollection<Todo>(queryCollectionOptions({
174174
queryKey: ['todoItems'],
175175
queryFn: async () => fetch('/api/todos'),
176-
getId: (item) => item.id,
176+
getKey: (item) => item.id,
177177
schema: todoSchema // any standard schema
178178
}))
179179
```
@@ -198,7 +198,7 @@ export const todoCollection = createCollection<Todo>(electricCollectionOptions({
198198
table: 'todos'
199199
}
200200
},
201-
getId: (item) => item.id,
201+
getKey: (item) => item.id,
202202
schema: todoSchema
203203
}))
204204
```
@@ -208,7 +208,7 @@ The Electric collection requires two Electric-specific options:
208208
- `shapeOptions` &mdash; the Electric [ShapeStreamOptions](https://electric-sql.com/docs/api/clients/typescript#options) that define the [Shape](https://electric-sql.com/docs/guides/shapes) to sync into the collection; this includes the
209209
- `url` to your sync engine; and
210210
- `params` to specify the `table` to sync and any optional `where` clauses, etc.
211-
- `getId` &mdash; identifies the id for the rows being synced into the collection
211+
- `getKey` &mdash; identifies the id for the rows being synced into the collection
212212

213213
When you create the collection, sync starts automatically.
214214

@@ -228,7 +228,7 @@ export const myPendingTodos = createCollection<Todo>(electricCollectionOptions({
228228
`
229229
}
230230
},
231-
getId: (item) => item.id,
231+
getKey: (item) => item.id,
232232
schema: todoSchema
233233
}))
234234
```
@@ -515,7 +515,7 @@ import { queryCollectionOptions } from "@tanstack/db-collections"
515515
const todoCollection = createCollection<Todo>(queryCollectionOptions({
516516
queryKey: ["todos"],
517517
queryFn: async () => fetch("/api/todos"),
518-
getId: (item) => item.id,
518+
getKey: (item) => item.id,
519519
schema: todoSchema, // any standard schema
520520
onInsert: ({ transaction }) => {
521521
const { changes: newTodo } = transaction.mutations[0]
@@ -528,7 +528,7 @@ const todoCollection = createCollection<Todo>(queryCollectionOptions({
528528
const listCollection = createCollection<TodoList>(queryCollectionOptions({
529529
queryKey: ["todo-lists"],
530530
queryFn: async () => fetch("/api/todo-lists"),
531-
getId: (item) => item.id,
531+
getKey: (item) => item.id,
532532
schema: todoListSchema
533533
onInsert: ({ transaction }) => {
534534
const { changes: newTodo } = transaction.mutations[0]
@@ -586,7 +586,7 @@ export const todoCollection = createCollection(electricCollectionOptions<Todo>({
586586
table: 'todos'
587587
}
588588
},
589-
getId: (item) => item.id,
589+
getKey: (item) => item.id,
590590
schema: todoSchema
591591
}))
592592

examples/react/todo/src/App.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ const createTodoCollection = (type: CollectionType) => {
147147
timestamptz: (date: string) => new Date(date),
148148
},
149149
},
150-
getId: (item) => item.id,
150+
getKey: (item) => item.id,
151151
schema: updateTodoSchema,
152152
onInsert: async ({ transaction }) => {
153153
const modified = transaction.mutations[0].modified
@@ -201,7 +201,7 @@ const createTodoCollection = (type: CollectionType) => {
201201
: undefined,
202202
}))
203203
},
204-
getId: (item: UpdateTodo) => String(item.id),
204+
getKey: (item: UpdateTodo) => String(item.id),
205205
schema: updateTodoSchema,
206206
queryClient,
207207
onInsert: async ({ transaction }) => {
@@ -254,7 +254,7 @@ const createConfigCollection = (type: CollectionType) => {
254254
},
255255
},
256256
},
257-
getId: (item: UpdateConfig) => item.id,
257+
getKey: (item: UpdateConfig) => item.id,
258258
schema: updateConfigSchema,
259259
onInsert: async ({ transaction }) => {
260260
const modified = transaction.mutations[0].modified
@@ -297,7 +297,7 @@ const createConfigCollection = (type: CollectionType) => {
297297
: undefined,
298298
}))
299299
},
300-
getId: (item: UpdateConfig) => item.id,
300+
getKey: (item: UpdateConfig) => item.id,
301301
schema: updateConfigSchema,
302302
queryClient,
303303
onInsert: async ({ transaction }) => {

packages/db-collections/src/electric.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface ElectricCollectionConfig<T extends Row<unknown>> {
3131
*/
3232
id?: string
3333
schema?: CollectionConfig<T>[`schema`]
34-
getId: CollectionConfig<T>[`getId`]
34+
getKey: CollectionConfig<T>[`getKey`]
3535
sync?: CollectionConfig<T>[`sync`]
3636

3737
/**

packages/db-collections/src/query.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export interface QueryCollectionConfig<
5454

5555
// Standard Collection configuration properties
5656
id?: string
57-
getId: CollectionConfig<TItem>[`getId`]
57+
getKey: CollectionConfig<TItem>[`getKey`]
5858
schema?: CollectionConfig<TItem>[`schema`]
5959
sync?: CollectionConfig<TItem>[`sync`]
6060

@@ -115,7 +115,7 @@ export function queryCollectionOptions<
115115
retry,
116116
retryDelay,
117117
staleTime,
118-
getId,
118+
getKey,
119119
onInsert,
120120
onUpdate,
121121
onDelete,
@@ -136,8 +136,8 @@ export function queryCollectionOptions<
136136
throw new Error(`[QueryCollection] queryClient must be provided.`)
137137
}
138138
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
139-
if (!getId) {
140-
throw new Error(`[QueryCollection] getId must be provided.`)
139+
if (!getKey) {
140+
throw new Error(`[QueryCollection] getKey must be provided.`)
141141
}
142142

143143
const internalSync: SyncConfig<TItem>[`sync`] = (params) => {
@@ -184,11 +184,11 @@ export function queryCollectionOptions<
184184
return
185185
}
186186

187-
const currentSyncedItems = new Map(collection.syncedData.state)
188-
const newItemsMap = new Map<string, TItem>()
187+
const currentSyncedItems = new Map(collection.syncedData)
188+
const newItemsMap = new Map<string | number, TItem>()
189189
newItemsArray.forEach((item) => {
190190
try {
191-
const key = getId(item)
191+
const key = getKey(item)
192192
newItemsMap.set(key, item)
193193
} catch (e) {
194194
console.error(
@@ -327,7 +327,7 @@ export function queryCollectionOptions<
327327

328328
return {
329329
...baseCollectionConfig,
330-
getId,
330+
getKey,
331331
sync: { sync: internalSync },
332332
onInsert: wrappedOnInsert,
333333
onUpdate: wrappedOnUpdate,

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

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe(`Electric Integration`, () => {
4747
table: `test_table`,
4848
},
4949
},
50-
getId: (item: Row) => item.id,
50+
getKey: (item: Row) => item.id as number,
5151
}
5252

5353
// Get the options with utilities
@@ -75,7 +75,7 @@ describe(`Electric Integration`, () => {
7575
])
7676

7777
expect(collection.state).toEqual(
78-
new Map([[`KEY::${collection.id}/1`, { id: 1, name: `Test User` }]])
78+
new Map([[1, { id: 1, name: `Test User` }]])
7979
)
8080
})
8181

@@ -107,8 +107,8 @@ describe(`Electric Integration`, () => {
107107

108108
expect(collection.state).toEqual(
109109
new Map([
110-
[`KEY::${collection.id}/1`, { id: 1, name: `Test User` }],
111-
[`KEY::${collection.id}/2`, { id: 2, name: `Another User` }],
110+
[1, { id: 1, name: `Test User` }],
111+
[2, { id: 2, name: `Another User` }],
112112
])
113113
)
114114
})
@@ -140,7 +140,7 @@ describe(`Electric Integration`, () => {
140140
])
141141

142142
expect(collection.state).toEqual(
143-
new Map([[`KEY::${collection.id}/1`, { id: 1, name: `Updated User` }]])
143+
new Map([[1, { id: 1, name: `Updated User` }]])
144144
)
145145
})
146146

@@ -161,7 +161,7 @@ describe(`Electric Integration`, () => {
161161
subscriber([
162162
{
163163
key: `1`,
164-
value: { id: `1` },
164+
value: { id: 1 },
165165
headers: { operation: `delete` },
166166
},
167167
{
@@ -293,7 +293,7 @@ describe(`Electric Integration`, () => {
293293
it(`should simulate the complete flow`, async () => {
294294
// Create a fake backend store to simulate server-side storage
295295
const fakeBackend = {
296-
data: new Map<string, { txid: string; value: unknown }>(),
296+
data: new Map<number, { txid: string; value: unknown }>(),
297297
// Simulates persisting data to a backend and returning a txid
298298
persist: (mutations: Array<PendingMutation>): Promise<string> => {
299299
const txid = String(Date.now())
@@ -316,7 +316,7 @@ describe(`Electric Integration`, () => {
316316
fakeBackend.data.forEach((value, key) => {
317317
if (value.txid === txid) {
318318
messages.push({
319-
key,
319+
key: key.toString(),
320320
value: value.value as Row,
321321
headers: {
322322
operation: `insert`,
@@ -371,16 +371,16 @@ describe(`Electric Integration`, () => {
371371

372372
await transaction.isPersisted.promise
373373

374-
transaction = collection.transactions.state.get(transaction.id)!
374+
transaction = collection.transactions.get(transaction.id)!
375375

376376
// Verify the mutation function was called correctly
377377
expect(testMutationFn).toHaveBeenCalledTimes(1)
378378

379379
// Check that the data was added to the collection
380380
// Note: In a real implementation, the collection would be updated by the sync process
381381
// This is just verifying our test setup worked correctly
382-
expect(fakeBackend.data.has(`KEY::${collection.id}/1`)).toBe(true)
383-
expect(collection.state.has(`KEY::${collection.id}/1`)).toBe(true)
382+
expect(fakeBackend.data.has(1)).toBe(true)
383+
expect(collection.has(1)).toBe(true)
384384
})
385385
})
386386

@@ -400,7 +400,7 @@ describe(`Electric Integration`, () => {
400400
table: `test_table`,
401401
},
402402
},
403-
getId: (item: Row) => item.id,
403+
getKey: (item: Row) => item.id as number,
404404
onInsert,
405405
onUpdate,
406406
onDelete,
@@ -433,7 +433,7 @@ describe(`Electric Integration`, () => {
433433
table: `test_table`,
434434
},
435435
},
436-
getId: (item: Row) => item.id,
436+
getKey: (item: Row) => item.id as number,
437437
onInsert,
438438
}
439439

@@ -517,7 +517,7 @@ describe(`Electric Integration`, () => {
517517
table: `test_table`,
518518
},
519519
},
520-
getId: (item: Row) => item.id,
520+
getKey: (item: Row) => item.id as number,
521521
onInsert,
522522
}
523523

@@ -530,28 +530,20 @@ describe(`Electric Integration`, () => {
530530
})
531531

532532
// If awaitTxId wasn't called automatically, this wouldn't be true.
533-
expect(testCollection.syncedData.state.size).toEqual(0)
534-
expect(
535-
testCollection.optimisticOperations.state.filter((o) => o.isActive)
536-
.length
537-
).toEqual(1)
533+
expect(testCollection.syncedData.size).toEqual(0)
538534

539535
// Verify that our onInsert handler was called
540536
expect(onInsert).toHaveBeenCalled()
541537

542538
await tx.isPersisted.promise
543539

544540
// Verify that the data was added to the collection via the sync process
545-
expect(testCollection.state.has(`KEY::${testCollection.id}/1`)).toBe(true)
546-
expect(testCollection.state.get(`KEY::${testCollection.id}/1`)).toEqual({
541+
expect(testCollection.has(1)).toBe(true)
542+
expect(testCollection.get(1)).toEqual({
547543
id: 1,
548544
name: `Direct Persistence User`,
549545
})
550-
expect(testCollection.syncedData.state.size).toEqual(1)
551-
expect(
552-
testCollection.optimisticOperations.state.filter((o) => o.isActive)
553-
.length
554-
).toEqual(0)
546+
expect(testCollection.syncedData.size).toEqual(1)
555547
})
556548
})
557549
})

0 commit comments

Comments
 (0)