From 04f38f4b88b080b4d15495576c1cf1d8be3a38b9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 20 Nov 2025 15:39:33 +0100 Subject: [PATCH 1/3] Fix type of utils in collection options --- packages/db/src/collection/index.ts | 87 ++++++++++++++++--- .../electric-db-collection/src/electric.ts | 10 +-- .../tests/electric.test-d.ts | 58 ++++++++++++- 3 files changed, 137 insertions(+), 18 deletions(-) diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 5616a8ceb..31d9e14e5 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -127,32 +127,85 @@ export interface Collection< * */ -// Overload for when schema is provided +// Overload for when schema is provided and utils is required (not optional) +// We can't infer the Utils type from the CollectionConfig because it will always be optional +// So we omit it from that type and instead infer it from the extension `& { utils: TUtils }` +// such that we have the real, non-optional Utils type export function createCollection< T extends StandardSchemaV1, - TKey extends string | number = string | number, - TUtils extends UtilsRecord = UtilsRecord, + TKey extends string | number, + TUtils extends UtilsRecord, >( - options: CollectionConfig, TKey, T, TUtils> & { + options: Omit< + CollectionConfig, TKey, T, TUtils>, + `utils` + > & { schema: T - utils?: TUtils + utils: TUtils // Required utils } & NonSingleResult ): Collection, TKey, TUtils, T, InferSchemaInput> & NonSingleResult +// Overload for when schema is provided and utils is optional +// In this case we can simply infer the Utils type from the CollectionConfig type +export function createCollection< + T extends StandardSchemaV1, + TKey extends string | number, + TUtils extends UtilsRecord, +>( + options: CollectionConfig, TKey, T, TUtils> & { + schema: T + } & NonSingleResult +): Collection< + InferSchemaOutput, + TKey, + Exclude, + T, + InferSchemaInput +> & + NonSingleResult + +// Overload for when schema is provided, singleResult is true, and utils is required +export function createCollection< + T extends StandardSchemaV1, + TKey extends string | number, + TUtils extends UtilsRecord, +>( + options: Omit< + CollectionConfig, TKey, T, TUtils>, + `utils` + > & { + schema: T + utils: TUtils // Required utils + } & SingleResult +): Collection, TKey, TUtils, T, InferSchemaInput> & + SingleResult + // Overload for when schema is provided and singleResult is true export function createCollection< T extends StandardSchemaV1, - TKey extends string | number = string | number, - TUtils extends UtilsRecord = UtilsRecord, + TKey extends string | number, + TUtils extends UtilsRecord, >( options: CollectionConfig, TKey, T, TUtils> & { schema: T - utils?: TUtils } & SingleResult ): Collection, TKey, TUtils, T, InferSchemaInput> & SingleResult +// Overload for when no schema is provided and utils is required +// the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config +export function createCollection< + T extends object, + TKey extends string | number, + TUtils extends UtilsRecord, +>( + options: Omit, `utils`> & { + schema?: never // prohibit schema if an explicit type is provided + utils: TUtils // Required utils + } & NonSingleResult +): Collection & NonSingleResult + // Overload for when no schema is provided // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config export function createCollection< @@ -162,10 +215,22 @@ export function createCollection< >( options: CollectionConfig & { schema?: never // prohibit schema if an explicit type is provided - utils?: TUtils } & NonSingleResult ): Collection & NonSingleResult +// Overload for when no schema is provided, singleResult is true, and utils is required +// the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config +export function createCollection< + T extends object, + TKey extends string | number = string | number, + TUtils extends UtilsRecord = UtilsRecord, +>( + options: Omit, `utils`> & { + schema?: never // prohibit schema if an explicit type is provided + utils: TUtils // Required utils + } & SingleResult +): Collection & SingleResult + // Overload for when no schema is provided and singleResult is true // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config export function createCollection< @@ -175,15 +240,13 @@ export function createCollection< >( options: CollectionConfig & { schema?: never // prohibit schema if an explicit type is provided - utils?: TUtils } & SingleResult ): Collection & SingleResult // Implementation export function createCollection( - options: CollectionConfig & { + options: CollectionConfig & { schema?: StandardSchemaV1 - utils?: UtilsRecord } ): Collection { const collection = new CollectionImpl( diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 0b377f672..308254b48 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -294,9 +294,9 @@ export function electricCollectionOptions( config: ElectricCollectionConfig, T> & { schema: T } -): CollectionConfig, string | number, T> & { +): Omit, string | number, T>, `utils`> & { id?: string - utils: ElectricCollectionUtils + utils: ElectricCollectionUtils> schema: T } @@ -305,15 +305,15 @@ export function electricCollectionOptions>( config: ElectricCollectionConfig & { schema?: never // prohibit schema } -): CollectionConfig & { +): Omit, `utils`> & { id?: string - utils: ElectricCollectionUtils + utils: ElectricCollectionUtils schema?: never // no schema in the result } export function electricCollectionOptions( config: ElectricCollectionConfig -): CollectionConfig & { +): Omit, `utils`> & { id?: string utils: ElectricCollectionUtils schema?: any diff --git a/packages/electric-db-collection/tests/electric.test-d.ts b/packages/electric-db-collection/tests/electric.test-d.ts index 67226bc4d..8e43d1bcc 100644 --- a/packages/electric-db-collection/tests/electric.test-d.ts +++ b/packages/electric-db-collection/tests/electric.test-d.ts @@ -8,7 +8,10 @@ import { gt, } from "@tanstack/db" import { electricCollectionOptions } from "../src/electric" -import type { ElectricCollectionConfig } from "../src/electric" +import type { + ElectricCollectionConfig, + ElectricCollectionUtils, +} from "../src/electric" import type { DeleteMutationFnParams, InsertMutationFnParams, @@ -97,6 +100,59 @@ describe(`Electric collection type resolution tests`, () => { expectTypeOf(options.getKey).parameters.toEqualTypeOf<[FallbackType]>() }) + it(`should type collection.utils as ElectricCollectionUtils`, () => { + const todoSchema = z.object({ + id: z.string(), + title: z.string(), + completed: z.boolean(), + }) + + type TodoType = z.infer + + const options = electricCollectionOptions({ + id: `todos`, + getKey: (item) => item.id, + shapeOptions: { + url: `/api/todos`, + params: { table: `todos` }, + }, + schema: todoSchema, + /* + onInsert: async ({ collection }) => { + const testCollectionUtils: ElectricCollectionUtils = + collection.utils + expectTypeOf(testCollectionUtils.awaitTxId).toBeFunction + expectTypeOf(collection.utils.awaitTxId).toBeFunction + return Promise.resolve({ txid: 1 }) + }, + */ + }) + + // ✅ Test that options.utils is typed as ElectricCollectionUtils + // The options object should have the correct type from electricCollectionOptions + const testOptionsUtils: ElectricCollectionUtils = options.utils + + expectTypeOf(testOptionsUtils.awaitTxId).toBeFunction + + const todosCollection = createCollection(options) + + // Test that todosCollection.utils is ElectricCollectionUtils + // Note: We can't use expectTypeOf(...).toEqualTypeOf> because + // expectTypeOf's toEqualTypeOf has a constraint that requires { [x: string]: any; [x: number]: never; }, + // 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 + + expectTypeOf(testTodosUtils.awaitTxId).toBeFunction + + // Verify the specific properties that define ElectricCollectionUtils exist and are functions + expectTypeOf(todosCollection.utils.awaitTxId).toBeFunction + expectTypeOf(todosCollection.utils.awaitMatch).toBeFunction + }) + it(`should properly type the onInsert, onUpdate, and onDelete handlers`, () => { const options = electricCollectionOptions({ shapeOptions: { From f8ecc5f3c2612137438a08edbb22a46b564a4890 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 20 Nov 2025 16:28:40 +0100 Subject: [PATCH 2/3] Fix type of utils in onInsert/onUpdate/onDelete and added a unit test --- .../electric-db-collection/src/electric.ts | 32 ++++++++++++++++--- .../tests/electric.test-d.ts | 2 -- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 308254b48..1d57a8661 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -109,7 +109,13 @@ export interface ElectricCollectionConfig< T extends Row = Row, TSchema extends StandardSchemaV1 = never, > extends Omit< - BaseCollectionConfig, + BaseCollectionConfig< + T, + string | number, + TSchema, + ElectricCollectionUtils, + any + >, `onInsert` | `onUpdate` | `onDelete` | `syncMode` > { /** @@ -164,7 +170,13 @@ export interface ElectricCollectionConfig< * ) * } */ - onInsert?: (params: InsertMutationFnParams) => Promise + onInsert?: ( + params: InsertMutationFnParams< + T, + string | number, + ElectricCollectionUtils + > + ) => Promise /** * Optional asynchronous handler function called before an update operation @@ -193,7 +205,13 @@ export interface ElectricCollectionConfig< * ) * } */ - onUpdate?: (params: UpdateMutationFnParams) => Promise + onUpdate?: ( + params: UpdateMutationFnParams< + T, + string | number, + ElectricCollectionUtils + > + ) => Promise /** * Optional asynchronous handler function called before a delete operation @@ -221,7 +239,13 @@ export interface ElectricCollectionConfig< * ) * } */ - onDelete?: (params: DeleteMutationFnParams) => Promise + onDelete?: ( + params: DeleteMutationFnParams< + T, + string | number, + ElectricCollectionUtils + > + ) => Promise } function isUpToDateMessage>( diff --git a/packages/electric-db-collection/tests/electric.test-d.ts b/packages/electric-db-collection/tests/electric.test-d.ts index 8e43d1bcc..208b6ee3f 100644 --- a/packages/electric-db-collection/tests/electric.test-d.ts +++ b/packages/electric-db-collection/tests/electric.test-d.ts @@ -117,7 +117,6 @@ describe(`Electric collection type resolution tests`, () => { params: { table: `todos` }, }, schema: todoSchema, - /* onInsert: async ({ collection }) => { const testCollectionUtils: ElectricCollectionUtils = collection.utils @@ -125,7 +124,6 @@ describe(`Electric collection type resolution tests`, () => { expectTypeOf(collection.utils.awaitTxId).toBeFunction return Promise.resolve({ txid: 1 }) }, - */ }) // ✅ Test that options.utils is typed as ElectricCollectionUtils From f95e52ec4e7ec8d252e264384910f6e309649164 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 20 Nov 2025 16:39:34 +0100 Subject: [PATCH 3/3] Fix types of params in onInsert/onUpdate/onDelete --- .../electric-db-collection/src/electric.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 1d57a8661..95a2ffcc3 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -335,11 +335,11 @@ export function electricCollectionOptions>( schema?: never // no schema in the result } -export function electricCollectionOptions( +export function electricCollectionOptions>( config: ElectricCollectionConfig ): Omit, `utils`> & { id?: string - utils: ElectricCollectionUtils + utils: ElectricCollectionUtils schema?: any } { const seenTxids = new Store>(new Set([])) @@ -584,7 +584,13 @@ export function electricCollectionOptions( // Create wrapper handlers for direct persistence operations that handle different matching strategies const wrappedOnInsert = config.onInsert - ? async (params: InsertMutationFnParams) => { + ? async ( + params: InsertMutationFnParams< + any, + string | number, + ElectricCollectionUtils> + > + ) => { const handlerResult = await config.onInsert!(params) await processMatchingStrategy(handlerResult) return handlerResult @@ -592,7 +598,13 @@ export function electricCollectionOptions( : undefined const wrappedOnUpdate = config.onUpdate - ? async (params: UpdateMutationFnParams) => { + ? async ( + params: UpdateMutationFnParams< + any, + string | number, + ElectricCollectionUtils> + > + ) => { const handlerResult = await config.onUpdate!(params) await processMatchingStrategy(handlerResult) return handlerResult @@ -600,7 +612,13 @@ export function electricCollectionOptions( : undefined const wrappedOnDelete = config.onDelete - ? async (params: DeleteMutationFnParams) => { + ? async ( + params: DeleteMutationFnParams< + any, + string | number, + ElectricCollectionUtils> + > + ) => { const handlerResult = await config.onDelete!(params) await processMatchingStrategy(handlerResult) return handlerResult