diff --git a/.changeset/tough-dragons-fold.md b/.changeset/tough-dragons-fold.md new file mode 100644 index 000000000..8a93ddc1b --- /dev/null +++ b/.changeset/tough-dragons-fold.md @@ -0,0 +1,8 @@ +--- +'@tanstack/powersync-db-collection': patch +'@tanstack/trailbase-db-collection': patch +'@tanstack/electric-db-collection': patch +'@tanstack/db': patch +--- + +Make type of collection utils more precise for localOnly, PowerSync, Trailbase, and Electric collections diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index 68168ae02..642e286d7 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -64,7 +64,7 @@ type LocalOnlyCollectionOptionsResult< T extends object, TKey extends string | number, TSchema extends StandardSchemaV1 | never = never, -> = CollectionConfig & { +> = CollectionConfig & { utils: LocalOnlyCollectionUtils } diff --git a/packages/db/tests/local-only.test-d.ts b/packages/db/tests/local-only.test-d.ts index 471a266b3..f8cfe0964 100644 --- a/packages/db/tests/local-only.test-d.ts +++ b/packages/db/tests/local-only.test-d.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { createCollection } from '../src/index' import { localOnlyCollectionOptions } from '../src/local-only' +import type { LocalOnlyCollectionUtils } from '../src/local-only' interface TestItem extends Record { id: number @@ -252,4 +253,18 @@ describe(`LocalOnly Collection Types`, () => { // Test that the collection has the correct inferred type from schema expectTypeOf(collection.toArray).toEqualTypeOf>() }) + + it(`should type collection.utils as LocalOnlyCollectionUtils`, () => { + const collection = createCollection( + localOnlyCollectionOptions({ + id: `test-utils-typing`, + getKey: (item: TestItem) => item.id, + }), + ) + + // Verify that collection.utils is typed as LocalOnlyCollectionUtils, not UtilsRecord + const utils: LocalOnlyCollectionUtils = collection.utils + expectTypeOf(utils.acceptMutations).toBeFunction() + expectTypeOf(collection.utils.acceptMutations).toBeFunction() + }) }) diff --git a/packages/db/tests/local-storage.test-d.ts b/packages/db/tests/local-storage.test-d.ts index 3321b2e66..fb008a1e1 100644 --- a/packages/db/tests/local-storage.test-d.ts +++ b/packages/db/tests/local-storage.test-d.ts @@ -4,6 +4,7 @@ import { createCollection } from '../src/index' import { localStorageCollectionOptions } from '../src/local-storage' import type { LocalStorageCollectionConfig, + LocalStorageCollectionUtils, StorageApi, StorageEventApi, } from '../src/local-storage' @@ -394,4 +395,23 @@ describe(`LocalStorage collection type resolution tests`, () => { expectTypeOf(draft).toEqualTypeOf() }) }) + + it(`should type collection.utils as LocalStorageCollectionUtils after createCollection`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `test-utils-typing`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item) => item.id, + }), + ) + + // Verify that collection.utils is typed as LocalStorageCollectionUtils, not UtilsRecord + const utils: LocalStorageCollectionUtils = collection.utils + expectTypeOf(utils.clearStorage).toBeFunction() + expectTypeOf(utils.getStorageSize).toBeFunction() + expectTypeOf(utils.acceptMutations).toBeFunction() + expectTypeOf(collection.utils.clearStorage).toBeFunction() + expectTypeOf(collection.utils.getStorageSize).toBeFunction() + }) }) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 25bf29e53..261069db9 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -511,22 +511,33 @@ export function electricCollectionOptions( config: ElectricCollectionConfig, T> & { schema: T }, -): Omit, string | number, T>, `utils`> & { - id?: string - utils: ElectricCollectionUtils> - schema: T -} +): Omit< + CollectionConfig, string | number, T>, + `utils` | `onInsert` | `onUpdate` | `onDelete` +> & + Pick< + ElectricCollectionConfig, T>, + `onInsert` | `onUpdate` | `onDelete` + > & { + id?: string + utils: ElectricCollectionUtils> + schema: T + } // Overload for when no schema is provided export function electricCollectionOptions>( config: ElectricCollectionConfig & { schema?: never // prohibit schema }, -): Omit, `utils`> & { - id?: string - utils: ElectricCollectionUtils - schema?: never // no schema in the result -} +): Omit< + CollectionConfig, + `utils` | `onInsert` | `onUpdate` | `onDelete` +> & + Pick, `onInsert` | `onUpdate` | `onDelete`> & { + id?: string + utils: ElectricCollectionUtils + schema?: never // no schema in the result + } export function electricCollectionOptions>( config: ElectricCollectionConfig, diff --git a/packages/electric-db-collection/tests/electric.test-d.ts b/packages/electric-db-collection/tests/electric.test-d.ts index 946e6496a..5075c63f5 100644 --- a/packages/electric-db-collection/tests/electric.test-d.ts +++ b/packages/electric-db-collection/tests/electric.test-d.ts @@ -154,7 +154,6 @@ describe(`Electric collection type resolution tests`, () => { // but ElectricCollectionUtils extends UtilsRecord which is Record (no number index signature). // This causes a constraint error instead of a type mismatch error. // Instead, we test via type assignment which will show a proper type error if the types don't match. - // Currently this shows that todosCollection.utils is typed as UtilsRecord, not ElectricCollectionUtils const testTodosUtils: ElectricCollectionUtils = todosCollection.utils @@ -165,6 +164,44 @@ describe(`Electric collection type resolution tests`, () => { expectTypeOf(todosCollection.utils.awaitMatch).toBeFunction }) + it(`should preserve ElectricCollectionUtils type on collection.utils after createCollection with handlers`, () => { + const todoSchema = z.object({ + id: z.string(), + title: z.string(), + completed: z.boolean(), + }) + + type TodoType = z.infer + + const options = electricCollectionOptions({ + shapeOptions: { + url: `/api/todos`, + }, + schema: todoSchema, + getKey: (item) => item.id, + onInsert: async () => { + return Promise.resolve({ txid: 1 }) + }, + onUpdate: async () => { + return Promise.resolve({ txid: 1 }) + }, + onDelete: async () => { + return Promise.resolve({ txid: 1 }) + }, + }) + + const todosCollection = createCollection(options) + + // After createCollection, utils should be typed as ElectricCollectionUtils + // and not widened to UtilsRecord + const testUtils: ElectricCollectionUtils = todosCollection.utils + + expectTypeOf(testUtils.awaitTxId).toBeFunction + expectTypeOf(testUtils.awaitMatch).toBeFunction + expectTypeOf(todosCollection.utils.awaitTxId).toBeFunction + expectTypeOf(todosCollection.utils.awaitMatch).toBeFunction + }) + it(`should properly type the onInsert, onUpdate, and onDelete handlers`, () => { const options = electricCollectionOptions({ shapeOptions: { @@ -195,17 +232,35 @@ describe(`Electric collection type resolution tests`, () => { }, }) - // Verify that the handlers are properly typed + // Verify that the handlers are properly typed with ElectricCollectionUtils expectTypeOf(options.onInsert).parameters.toEqualTypeOf< - [InsertMutationFnParams] + [ + InsertMutationFnParams< + ExplicitType, + string | number, + ElectricCollectionUtils + >, + ] >() expectTypeOf(options.onUpdate).parameters.toEqualTypeOf< - [UpdateMutationFnParams] + [ + UpdateMutationFnParams< + ExplicitType, + string | number, + ElectricCollectionUtils + >, + ] >() expectTypeOf(options.onDelete).parameters.toEqualTypeOf< - [DeleteMutationFnParams] + [ + DeleteMutationFnParams< + ExplicitType, + string | number, + ElectricCollectionUtils + >, + ] >() }) @@ -279,15 +334,33 @@ describe(`Electric collection type resolution tests`, () => { }, }) - // Verify that the handlers are properly typed + // Verify that the handlers are properly typed with ElectricCollectionUtils expectTypeOf(options.onDelete).parameters.toEqualTypeOf< - [DeleteMutationFnParams] + [ + DeleteMutationFnParams< + TodoType, + string | number, + ElectricCollectionUtils + >, + ] >() expectTypeOf(options.onInsert).parameters.toEqualTypeOf< - [InsertMutationFnParams] + [ + InsertMutationFnParams< + TodoType, + string | number, + ElectricCollectionUtils + >, + ] >() expectTypeOf(options.onUpdate).parameters.toEqualTypeOf< - [UpdateMutationFnParams] + [ + UpdateMutationFnParams< + TodoType, + string | number, + ElectricCollectionUtils + >, + ] >() }) diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 7a8bc7384..f48d2905e 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -528,7 +528,11 @@ describe(`Electric Integration`, () => { id: `test-transaction`, mutations: [], } as unknown as TransactionWithMutations - const mockParams: InsertMutationFnParams = { + const mockParams: InsertMutationFnParams< + Row, + string | number, + ElectricCollectionUtils + > = { transaction: mockTransaction, // @ts-expect-error not relevant to test collection: CollectionImpl, diff --git a/packages/powersync-db-collection/src/definitions.ts b/packages/powersync-db-collection/src/definitions.ts index c65fa850e..1a605d077 100644 --- a/packages/powersync-db-collection/src/definitions.ts +++ b/packages/powersync-db-collection/src/definitions.ts @@ -260,7 +260,12 @@ export type EnhancedPowerSyncCollectionConfig< TTable extends Table, OutputType extends Record = Record, TSchema extends StandardSchemaV1 = never, -> = CollectionConfig & { +> = CollectionConfig< + OutputType, + string, + TSchema, + PowerSyncCollectionUtils +> & { id?: string utils: PowerSyncCollectionUtils schema?: TSchema diff --git a/packages/powersync-db-collection/tests/powersync.test-d.ts b/packages/powersync-db-collection/tests/powersync.test-d.ts new file mode 100644 index 000000000..9694ec46f --- /dev/null +++ b/packages/powersync-db-collection/tests/powersync.test-d.ts @@ -0,0 +1,31 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { Schema, Table, column } from '@powersync/node' +import { createCollection } from '@tanstack/db' +import { powerSyncCollectionOptions } from '../src' +import type { PowerSyncCollectionUtils } from '../src' +import type { AbstractPowerSyncDatabase } from '@powersync/node' + +const APP_SCHEMA = new Schema({ + documents: new Table({ + name: column.text, + author: column.text, + }), +}) + +describe(`PowerSync collection type tests`, () => { + it(`should type collection.utils as PowerSyncCollectionUtils after createCollection`, () => { + const collection = createCollection( + powerSyncCollectionOptions({ + database: {} as AbstractPowerSyncDatabase, + table: APP_SCHEMA.props.documents, + }), + ) + + // Verify that collection.utils is typed as PowerSyncCollectionUtils, not UtilsRecord + const utils: PowerSyncCollectionUtils< + (typeof APP_SCHEMA.props)['documents'] + > = collection.utils + expectTypeOf(utils.getMeta).toBeFunction() + expectTypeOf(collection.utils.getMeta).toBeFunction() + }) +}) diff --git a/packages/powersync-db-collection/tests/powersync.test.ts b/packages/powersync-db-collection/tests/powersync.test.ts index 4a4a5a544..17920f5d8 100644 --- a/packages/powersync-db-collection/tests/powersync.test.ts +++ b/packages/powersync-db-collection/tests/powersync.test.ts @@ -203,7 +203,9 @@ describe(`PowerSync Integration`, () => { const _crudEntries = await db.getAll(` SELECT * FROM ps_crud ORDER BY id`) - const crudEntries = _crudEntries.map((r) => CrudEntry.fromRow(r)) + const crudEntries = _crudEntries.map((r) => + CrudEntry.fromRow(r as Parameters[0]), + ) expect(crudEntries.length).toBe(6) // We can only group transactions for similar operations @@ -250,7 +252,9 @@ describe(`PowerSync Integration`, () => { // There should be a crud entries for this const _crudEntries = await db.getAll(` SELECT * FROM ps_crud ORDER BY id`) - const crudEntries = _crudEntries.map((r) => CrudEntry.fromRow(r)) + const crudEntries = _crudEntries.map((r) => + CrudEntry.fromRow(r as Parameters[0]), + ) const lastTransactionId = crudEntries[crudEntries.length - 1]?.transactionId @@ -312,7 +316,9 @@ describe(`PowerSync Integration`, () => { // There should be a crud entries for this const _crudEntries = await db.getAll(` SELECT * FROM ps_crud ORDER BY id`) - const crudEntries = _crudEntries.map((r) => CrudEntry.fromRow(r)) + const crudEntries = _crudEntries.map((r) => + CrudEntry.fromRow(r as Parameters[0]), + ) const lastTransactionId = crudEntries[crudEntries.length - 1]?.transactionId @@ -464,7 +470,7 @@ describe(`PowerSync Integration`, () => { liveDocuments.subscribeChanges((changes) => { changes .map((change) => change.value.name) - .forEach((change) => bookNames.add(change)) + .forEach((change) => bookNames.add(change!)) }) await collection.insert({ diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 8481021eb..6dbd233e7 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -560,4 +560,26 @@ describe(`Query collection type resolution tests`, () => { createCollection(options) }) }) + + it(`should type collection.utils as QueryCollectionUtils after createCollection`, () => { + const collection = createCollection( + queryCollectionOptions({ + id: `test-utils-typing`, + queryClient, + queryKey: [`test-utils`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + }), + ) + + // Verify that collection.utils is typed as QueryCollectionUtils, not UtilsRecord + const utils: QueryCollectionUtils = collection.utils + expectTypeOf(utils.refetch).toBeFunction() + expectTypeOf(collection.utils.refetch).toBeFunction() + expectTypeOf(collection.utils.writeInsert).toBeFunction() + expectTypeOf(collection.utils.writeUpdate).toBeFunction() + expectTypeOf(collection.utils.writeDelete).toBeFunction() + expectTypeOf(collection.utils.isFetching).toBeBoolean() + expectTypeOf(collection.utils.isLoading).toBeBoolean() + }) }) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 32b6755c5..4d6ed078d 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -113,7 +113,9 @@ export function trailBaseCollectionOptions< TKey extends string | number = string | number, >( config: TrailBaseCollectionConfig, -): CollectionConfig & { utils: TrailBaseCollectionUtils } { +): CollectionConfig & { + utils: TrailBaseCollectionUtils +} { const getKey = config.getKey const parse = (record: TRecord) => diff --git a/packages/trailbase-db-collection/tests/trailbase.test-d.ts b/packages/trailbase-db-collection/tests/trailbase.test-d.ts new file mode 100644 index 000000000..49c26ad2f --- /dev/null +++ b/packages/trailbase-db-collection/tests/trailbase.test-d.ts @@ -0,0 +1,29 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection } from '@tanstack/db' +import { trailBaseCollectionOptions } from '../src/trailbase' +import type { TrailBaseCollectionUtils } from '../src/trailbase' +import type { RecordApi } from 'trailbase' + +type TestItem = { + id: string + title: string +} + +describe(`TrailBase collection type tests`, () => { + it(`should type collection.utils as TrailBaseCollectionUtils after createCollection`, () => { + const collection = createCollection( + trailBaseCollectionOptions({ + id: `todos`, + recordApi: {} as RecordApi, + getKey: (item) => item.id, + parse: {}, + serialize: {}, + }), + ) + + // Verify that collection.utils is typed as TrailBaseCollectionUtils, not UtilsRecord + const utils: TrailBaseCollectionUtils = collection.utils + expectTypeOf(utils.cancel).toBeFunction() + expectTypeOf(collection.utils.cancel).toBeFunction() + }) +})