Skip to content

Commit 2223cd6

Browse files
kevin-dpautofix-ci[bot]claude
authored
Improve type of collection utils for Trailbase, PowerSync, and localOnly collections (#1236)
* Proprly type collection utils for Electric, Trailbase, PowerSync, and local collections. * Changeset * ci: apply automated fixes * Revert unnecessary change to Electric collection typing * Add type tests to check the type of the utils of the collections * ci: apply automated fixes * Fix types in powersync tests * Fix type in trailbase type test * ci: apply automated fixes * Changeset update * Add type test verifying utils retain concrete types after createCollection Adds a reproduction test for the issue where ElectricCollectionUtils type is widened to UtilsRecord after passing electricCollectionOptions through createCollection when handlers (onInsert, onUpdate, onDelete) are present. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix ElectricCollectionUtils type lost after createCollection Omit onInsert/onUpdate/onDelete from CollectionConfig in the return type of electricCollectionOptions overloads, then re-add them via Pick from ElectricCollectionConfig which carries the correct ElectricCollectionUtils<T>. This eliminates conflicting TUtils inference sites when the result is passed to createCollection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: apply automated fixes * Add electric-db-collection to changeset Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5e9c1af commit 2223cd6

13 files changed

Lines changed: 253 additions & 27 deletions

File tree

.changeset/tough-dragons-fold.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@tanstack/powersync-db-collection': patch
3+
'@tanstack/trailbase-db-collection': patch
4+
'@tanstack/electric-db-collection': patch
5+
'@tanstack/db': patch
6+
---
7+
8+
Make type of collection utils more precise for localOnly, PowerSync, Trailbase, and Electric collections

packages/db/src/local-only.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ type LocalOnlyCollectionOptionsResult<
6464
T extends object,
6565
TKey extends string | number,
6666
TSchema extends StandardSchemaV1 | never = never,
67-
> = CollectionConfig<T, TKey, TSchema> & {
67+
> = CollectionConfig<T, TKey, TSchema, LocalOnlyCollectionUtils> & {
6868
utils: LocalOnlyCollectionUtils
6969
}
7070

packages/db/tests/local-only.test-d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest'
22
import { z } from 'zod'
33
import { createCollection } from '../src/index'
44
import { localOnlyCollectionOptions } from '../src/local-only'
5+
import type { LocalOnlyCollectionUtils } from '../src/local-only'
56

67
interface TestItem extends Record<string, unknown> {
78
id: number
@@ -252,4 +253,18 @@ describe(`LocalOnly Collection Types`, () => {
252253
// Test that the collection has the correct inferred type from schema
253254
expectTypeOf(collection.toArray).toEqualTypeOf<Array<ExpectedType>>()
254255
})
256+
257+
it(`should type collection.utils as LocalOnlyCollectionUtils`, () => {
258+
const collection = createCollection(
259+
localOnlyCollectionOptions({
260+
id: `test-utils-typing`,
261+
getKey: (item: TestItem) => item.id,
262+
}),
263+
)
264+
265+
// Verify that collection.utils is typed as LocalOnlyCollectionUtils, not UtilsRecord
266+
const utils: LocalOnlyCollectionUtils = collection.utils
267+
expectTypeOf(utils.acceptMutations).toBeFunction()
268+
expectTypeOf(collection.utils.acceptMutations).toBeFunction()
269+
})
255270
})

packages/db/tests/local-storage.test-d.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createCollection } from '../src/index'
44
import { localStorageCollectionOptions } from '../src/local-storage'
55
import type {
66
LocalStorageCollectionConfig,
7+
LocalStorageCollectionUtils,
78
StorageApi,
89
StorageEventApi,
910
} from '../src/local-storage'
@@ -394,4 +395,23 @@ describe(`LocalStorage collection type resolution tests`, () => {
394395
expectTypeOf(draft).toEqualTypeOf<SelectUrlType>()
395396
})
396397
})
398+
399+
it(`should type collection.utils as LocalStorageCollectionUtils after createCollection`, () => {
400+
const collection = createCollection(
401+
localStorageCollectionOptions<ExplicitType>({
402+
storageKey: `test-utils-typing`,
403+
storage: mockStorage,
404+
storageEventApi: mockStorageEventApi,
405+
getKey: (item) => item.id,
406+
}),
407+
)
408+
409+
// Verify that collection.utils is typed as LocalStorageCollectionUtils, not UtilsRecord
410+
const utils: LocalStorageCollectionUtils = collection.utils
411+
expectTypeOf(utils.clearStorage).toBeFunction()
412+
expectTypeOf(utils.getStorageSize).toBeFunction()
413+
expectTypeOf(utils.acceptMutations).toBeFunction()
414+
expectTypeOf(collection.utils.clearStorage).toBeFunction()
415+
expectTypeOf(collection.utils.getStorageSize).toBeFunction()
416+
})
397417
})

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -511,22 +511,33 @@ export function electricCollectionOptions<T extends StandardSchemaV1>(
511511
config: ElectricCollectionConfig<InferSchemaOutput<T>, T> & {
512512
schema: T
513513
},
514-
): Omit<CollectionConfig<InferSchemaOutput<T>, string | number, T>, `utils`> & {
515-
id?: string
516-
utils: ElectricCollectionUtils<InferSchemaOutput<T>>
517-
schema: T
518-
}
514+
): Omit<
515+
CollectionConfig<InferSchemaOutput<T>, string | number, T>,
516+
`utils` | `onInsert` | `onUpdate` | `onDelete`
517+
> &
518+
Pick<
519+
ElectricCollectionConfig<InferSchemaOutput<T>, T>,
520+
`onInsert` | `onUpdate` | `onDelete`
521+
> & {
522+
id?: string
523+
utils: ElectricCollectionUtils<InferSchemaOutput<T>>
524+
schema: T
525+
}
519526

520527
// Overload for when no schema is provided
521528
export function electricCollectionOptions<T extends Row<unknown>>(
522529
config: ElectricCollectionConfig<T> & {
523530
schema?: never // prohibit schema
524531
},
525-
): Omit<CollectionConfig<T, string | number>, `utils`> & {
526-
id?: string
527-
utils: ElectricCollectionUtils<T>
528-
schema?: never // no schema in the result
529-
}
532+
): Omit<
533+
CollectionConfig<T, string | number>,
534+
`utils` | `onInsert` | `onUpdate` | `onDelete`
535+
> &
536+
Pick<ElectricCollectionConfig<T>, `onInsert` | `onUpdate` | `onDelete`> & {
537+
id?: string
538+
utils: ElectricCollectionUtils<T>
539+
schema?: never // no schema in the result
540+
}
530541

531542
export function electricCollectionOptions<T extends Row<unknown>>(
532543
config: ElectricCollectionConfig<T, any>,

packages/electric-db-collection/tests/electric.test-d.ts

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,6 @@ describe(`Electric collection type resolution tests`, () => {
154154
// but ElectricCollectionUtils extends UtilsRecord which is Record<string, any> (no number index signature).
155155
// This causes a constraint error instead of a type mismatch error.
156156
// Instead, we test via type assignment which will show a proper type error if the types don't match.
157-
// Currently this shows that todosCollection.utils is typed as UtilsRecord, not ElectricCollectionUtils<TodoType>
158157
const testTodosUtils: ElectricCollectionUtils<TodoType> =
159158
todosCollection.utils
160159

@@ -165,6 +164,44 @@ describe(`Electric collection type resolution tests`, () => {
165164
expectTypeOf(todosCollection.utils.awaitMatch).toBeFunction
166165
})
167166

167+
it(`should preserve ElectricCollectionUtils type on collection.utils after createCollection with handlers`, () => {
168+
const todoSchema = z.object({
169+
id: z.string(),
170+
title: z.string(),
171+
completed: z.boolean(),
172+
})
173+
174+
type TodoType = z.infer<typeof todoSchema>
175+
176+
const options = electricCollectionOptions({
177+
shapeOptions: {
178+
url: `/api/todos`,
179+
},
180+
schema: todoSchema,
181+
getKey: (item) => item.id,
182+
onInsert: async () => {
183+
return Promise.resolve({ txid: 1 })
184+
},
185+
onUpdate: async () => {
186+
return Promise.resolve({ txid: 1 })
187+
},
188+
onDelete: async () => {
189+
return Promise.resolve({ txid: 1 })
190+
},
191+
})
192+
193+
const todosCollection = createCollection(options)
194+
195+
// After createCollection, utils should be typed as ElectricCollectionUtils<TodoType>
196+
// and not widened to UtilsRecord
197+
const testUtils: ElectricCollectionUtils<TodoType> = todosCollection.utils
198+
199+
expectTypeOf(testUtils.awaitTxId).toBeFunction
200+
expectTypeOf(testUtils.awaitMatch).toBeFunction
201+
expectTypeOf(todosCollection.utils.awaitTxId).toBeFunction
202+
expectTypeOf(todosCollection.utils.awaitMatch).toBeFunction
203+
})
204+
168205
it(`should properly type the onInsert, onUpdate, and onDelete handlers`, () => {
169206
const options = electricCollectionOptions<ExplicitType>({
170207
shapeOptions: {
@@ -195,17 +232,35 @@ describe(`Electric collection type resolution tests`, () => {
195232
},
196233
})
197234

198-
// Verify that the handlers are properly typed
235+
// Verify that the handlers are properly typed with ElectricCollectionUtils
199236
expectTypeOf(options.onInsert).parameters.toEqualTypeOf<
200-
[InsertMutationFnParams<ExplicitType>]
237+
[
238+
InsertMutationFnParams<
239+
ExplicitType,
240+
string | number,
241+
ElectricCollectionUtils<ExplicitType>
242+
>,
243+
]
201244
>()
202245

203246
expectTypeOf(options.onUpdate).parameters.toEqualTypeOf<
204-
[UpdateMutationFnParams<ExplicitType>]
247+
[
248+
UpdateMutationFnParams<
249+
ExplicitType,
250+
string | number,
251+
ElectricCollectionUtils<ExplicitType>
252+
>,
253+
]
205254
>()
206255

207256
expectTypeOf(options.onDelete).parameters.toEqualTypeOf<
208-
[DeleteMutationFnParams<ExplicitType>]
257+
[
258+
DeleteMutationFnParams<
259+
ExplicitType,
260+
string | number,
261+
ElectricCollectionUtils<ExplicitType>
262+
>,
263+
]
209264
>()
210265
})
211266

@@ -279,15 +334,33 @@ describe(`Electric collection type resolution tests`, () => {
279334
},
280335
})
281336

282-
// Verify that the handlers are properly typed
337+
// Verify that the handlers are properly typed with ElectricCollectionUtils
283338
expectTypeOf(options.onDelete).parameters.toEqualTypeOf<
284-
[DeleteMutationFnParams<TodoType>]
339+
[
340+
DeleteMutationFnParams<
341+
TodoType,
342+
string | number,
343+
ElectricCollectionUtils<TodoType>
344+
>,
345+
]
285346
>()
286347
expectTypeOf(options.onInsert).parameters.toEqualTypeOf<
287-
[InsertMutationFnParams<TodoType>]
348+
[
349+
InsertMutationFnParams<
350+
TodoType,
351+
string | number,
352+
ElectricCollectionUtils<TodoType>
353+
>,
354+
]
288355
>()
289356
expectTypeOf(options.onUpdate).parameters.toEqualTypeOf<
290-
[UpdateMutationFnParams<TodoType>]
357+
[
358+
UpdateMutationFnParams<
359+
TodoType,
360+
string | number,
361+
ElectricCollectionUtils<TodoType>
362+
>,
363+
]
291364
>()
292365
})
293366

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,11 @@ describe(`Electric Integration`, () => {
528528
id: `test-transaction`,
529529
mutations: [],
530530
} as unknown as TransactionWithMutations<Row, `insert`>
531-
const mockParams: InsertMutationFnParams<Row> = {
531+
const mockParams: InsertMutationFnParams<
532+
Row,
533+
string | number,
534+
ElectricCollectionUtils<Row>
535+
> = {
532536
transaction: mockTransaction,
533537
// @ts-expect-error not relevant to test
534538
collection: CollectionImpl,

packages/powersync-db-collection/src/definitions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,12 @@ export type EnhancedPowerSyncCollectionConfig<
260260
TTable extends Table,
261261
OutputType extends Record<string, unknown> = Record<string, unknown>,
262262
TSchema extends StandardSchemaV1 = never,
263-
> = CollectionConfig<OutputType, string, TSchema> & {
263+
> = CollectionConfig<
264+
OutputType,
265+
string,
266+
TSchema,
267+
PowerSyncCollectionUtils<TTable>
268+
> & {
264269
id?: string
265270
utils: PowerSyncCollectionUtils<TTable>
266271
schema?: TSchema
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import { Schema, Table, column } from '@powersync/node'
3+
import { createCollection } from '@tanstack/db'
4+
import { powerSyncCollectionOptions } from '../src'
5+
import type { PowerSyncCollectionUtils } from '../src'
6+
import type { AbstractPowerSyncDatabase } from '@powersync/node'
7+
8+
const APP_SCHEMA = new Schema({
9+
documents: new Table({
10+
name: column.text,
11+
author: column.text,
12+
}),
13+
})
14+
15+
describe(`PowerSync collection type tests`, () => {
16+
it(`should type collection.utils as PowerSyncCollectionUtils after createCollection`, () => {
17+
const collection = createCollection(
18+
powerSyncCollectionOptions({
19+
database: {} as AbstractPowerSyncDatabase,
20+
table: APP_SCHEMA.props.documents,
21+
}),
22+
)
23+
24+
// Verify that collection.utils is typed as PowerSyncCollectionUtils, not UtilsRecord
25+
const utils: PowerSyncCollectionUtils<
26+
(typeof APP_SCHEMA.props)['documents']
27+
> = collection.utils
28+
expectTypeOf(utils.getMeta).toBeFunction()
29+
expectTypeOf(collection.utils.getMeta).toBeFunction()
30+
})
31+
})

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ describe(`PowerSync Integration`, () => {
203203
const _crudEntries = await db.getAll(`
204204
SELECT * FROM ps_crud ORDER BY id`)
205205

206-
const crudEntries = _crudEntries.map((r) => CrudEntry.fromRow(r))
206+
const crudEntries = _crudEntries.map((r) =>
207+
CrudEntry.fromRow(r as Parameters<typeof CrudEntry.fromRow>[0]),
208+
)
207209

208210
expect(crudEntries.length).toBe(6)
209211
// We can only group transactions for similar operations
@@ -250,7 +252,9 @@ describe(`PowerSync Integration`, () => {
250252
// There should be a crud entries for this
251253
const _crudEntries = await db.getAll(`
252254
SELECT * FROM ps_crud ORDER BY id`)
253-
const crudEntries = _crudEntries.map((r) => CrudEntry.fromRow(r))
255+
const crudEntries = _crudEntries.map((r) =>
256+
CrudEntry.fromRow(r as Parameters<typeof CrudEntry.fromRow>[0]),
257+
)
254258

255259
const lastTransactionId =
256260
crudEntries[crudEntries.length - 1]?.transactionId
@@ -312,7 +316,9 @@ describe(`PowerSync Integration`, () => {
312316
// There should be a crud entries for this
313317
const _crudEntries = await db.getAll(`
314318
SELECT * FROM ps_crud ORDER BY id`)
315-
const crudEntries = _crudEntries.map((r) => CrudEntry.fromRow(r))
319+
const crudEntries = _crudEntries.map((r) =>
320+
CrudEntry.fromRow(r as Parameters<typeof CrudEntry.fromRow>[0]),
321+
)
316322

317323
const lastTransactionId =
318324
crudEntries[crudEntries.length - 1]?.transactionId
@@ -464,7 +470,7 @@ describe(`PowerSync Integration`, () => {
464470
liveDocuments.subscribeChanges((changes) => {
465471
changes
466472
.map((change) => change.value.name)
467-
.forEach((change) => bookNames.add(change))
473+
.forEach((change) => bookNames.add(change!))
468474
})
469475

470476
await collection.insert({

0 commit comments

Comments
 (0)