Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/early-ties-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@tanstack/electric-db-collection": patch
"@tanstack/query-db-collection": patch
"@tanstack/db": patch
---

Ensure that you can use optional properties in the `select` and `join` clauses of a query, and fix an issue where standard schemas were not properly carried through to live queries.
75 changes: 74 additions & 1 deletion packages/db/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,81 @@ export interface Collection<
* sync: { sync: () => {} }
* })
*
* // Note: You must provide either an explicit type or a schema, but not both.
* // Note: You can provide an explicit type, a schema, or both. When both are provided, the explicit type takes precedence.
*/

// Overload for when schema is provided - infers schema type
export function createCollection<
TSchema extends StandardSchemaV1,
TKey extends string | number = string | number,
TUtils extends UtilsRecord = {},
TFallback extends object = Record<string, unknown>,
>(
options: CollectionConfig<
ResolveType<unknown, TSchema, TFallback>,
TKey,
TSchema,
ResolveInsertInput<unknown, TSchema, TFallback>
> & {
schema: TSchema
utils?: TUtils
}
): Collection<
ResolveType<unknown, TSchema, TFallback>,
TKey,
TUtils,
TSchema,
ResolveInsertInput<unknown, TSchema, TFallback>
>

// Overload for when explicit type is provided with schema - explicit type takes precedence
export function createCollection<
TExplicit extends object,
TKey extends string | number = string | number,
TUtils extends UtilsRecord = {},
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TFallback extends object = Record<string, unknown>,
>(
options: CollectionConfig<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
> & {
schema: TSchema
utils?: TUtils
}
): Collection<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TUtils,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
>

// Overload for when explicit type is provided or no schema
export function createCollection<
TExplicit = unknown,
TKey extends string | number = string | number,
TUtils extends UtilsRecord = {},
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TFallback extends object = Record<string, unknown>,
>(
options: CollectionConfig<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
> & { utils?: TUtils }
): Collection<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TUtils,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
>

// Implementation
export function createCollection<
TExplicit = unknown,
TKey extends string | number = string | number,
Expand Down
38 changes: 24 additions & 14 deletions packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CollectionImpl } from "../../collection.js"
import type { Aggregate, BasicExpression } from "../ir.js"
import type { QueryBuilder } from "./index.js"
import type { ResolveType } from "../../types.js"

export interface Context {
// The collections available in the base schema
Expand All @@ -27,13 +28,16 @@ export type Source = {
}

// Helper type to infer collection type from CollectionImpl
// This uses ResolveType directly to ensure consistency with collection creation logic
export type InferCollectionType<T> =
T extends CollectionImpl<infer U> ? U : never
T extends CollectionImpl<infer U, any, any, infer TSchema, any>
? ResolveType<U, TSchema, U>
: never

// Helper type to create schema from source
export type SchemaFromSource<T extends Source> = Prettify<{
[K in keyof T]: T[K] extends CollectionImpl<infer U>
? U
[K in keyof T]: T[K] extends CollectionImpl<any, any, any, any, any>
? InferCollectionType<T[K]>
: T[K] extends QueryBuilder<infer TContext>
? GetResult<TContext>
: never
Expand All @@ -58,16 +62,18 @@ export type SelectObject<
// Helper type to get the result type from a select object
export type ResultTypeFromSelect<TSelectObject> = {
[K in keyof TSelectObject]: TSelectObject[K] extends RefProxy<infer T>
? // For RefProxy, preserve the type as-is (including optionality from joins)
T
? T
: TSelectObject[K] extends BasicExpression<infer T>
? T
: TSelectObject[K] extends Aggregate<infer T>
? T
: TSelectObject[K] extends RefProxyFor<infer T>
? // For RefProxyFor, preserve the type as-is (including optionality from joins)
T
: never
? T
: TSelectObject[K] extends undefined
? undefined
: TSelectObject[K] extends { __type: infer U }
? U
: never
}

// Callback type for orderBy clauses
Expand Down Expand Up @@ -119,22 +125,26 @@ export type RefProxyFor<T> = OmitRefProxy<
? // T is optional (T | undefined) but not exactly undefined
NonUndefined<T> extends Record<string, any>
? {
// Properties are accessible and their types become optional
[K in keyof NonUndefined<T>]: NonUndefined<T>[K] extends Record<
[K in keyof NonUndefined<T>]-?: NonUndefined<T>[K] extends Record<
string,
any
>
? RefProxyFor<NonUndefined<T>[K] | undefined> &
? RefProxyFor<NonUndefined<T>[K]> &
RefProxy<NonUndefined<T>[K] | undefined>
: RefProxy<NonUndefined<T>[K] | undefined>
} & RefProxy<T>
: RefProxy<T>
: // T is not optional
T extends Record<string, any>
? {
[K in keyof T]: T[K] extends Record<string, any>
? RefProxyFor<T[K]> & RefProxy<T[K]>
: RefProxy<T[K]>
// Make all properties required, but for optional ones, include undefined in the RefProxy type
[K in keyof T]-?: undefined extends T[K]
? T[K] extends Record<string, any>
? RefProxyFor<T[K]> & RefProxy<T[K]>
: RefProxy<T[K]>
: T[K] extends Record<string, any>
? RefProxyFor<T[K]> & RefProxy<T[K]>
: RefProxy<T[K]>
} & RefProxy<T>
: RefProxy<T>
>
Expand Down
3 changes: 2 additions & 1 deletion packages/db/tests/collection-indexes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
or,
} from "../src/query/builder/functions"
import { expectIndexUsage, withIndexTracking } from "./utls"
import type { Collection } from "../src/collection"
import type { MutationFn, PendingMutation } from "../src/types"

interface TestItem {
Expand All @@ -25,7 +26,7 @@ interface TestItem {
createdAt: Date
}
describe(`Collection Indexes`, () => {
let collection: ReturnType<typeof createCollection<TestItem, string>>
let collection: Collection<TestItem, string>
let testData: Array<TestItem>
let mutationFn: MutationFn
let emitter: any
Expand Down
76 changes: 68 additions & 8 deletions packages/db/tests/collection.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,14 @@ describe(`Collection type resolution tests`, () => {
type SchemaType = StandardSchemaV1.InferOutput<typeof testSchema>
type ItemOf<T> = T extends Array<infer U> ? U : T

it(`should prioritize explicit type when provided`, () => {
it(`should use explicit type when provided without schema`, () => {
const _collection = createCollection<ExplicitType>({
getKey: (item) => item.id,
sync: { sync: () => {} },
schema: testSchema,
})

type ExpectedType = ResolveType<
ExplicitType,
typeof testSchema,
FallbackType
>
type Param = Parameters<typeof _collection.insert>[0]
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<ExplicitType>()
expectTypeOf<ExpectedType>().toEqualTypeOf<ExplicitType>()
})

it(`should use schema type when explicit type is not provided`, () => {
Expand Down Expand Up @@ -134,4 +127,71 @@ describe(`Collection type resolution tests`, () => {
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<ExplicitType>()
expectTypeOf<ExpectedType>().toEqualTypeOf<ExplicitType>()
})

it(`should automatically infer type from schema without generic arguments`, () => {
// This is the key test case that was missing - no generic arguments at all
const _collection = createCollection({
getKey: (item) => item.id,
sync: { sync: () => {} },
schema: testSchema,
})

type Param = Parameters<typeof _collection.insert>[0]
// Should infer the schema type automatically
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<SchemaType>()
})

it(`should automatically infer type from Zod schema with optional fields`, () => {
// Test with a Zod schema that has optional fields
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email().optional(),
created_at: z.date().optional(),
})

const _collection = createCollection({
getKey: (item) => item.id,
sync: { sync: () => {} },
schema: userSchema,
})

type Param = Parameters<typeof _collection.insert>[0]
type ExpectedType = {
id: number
name: string
email?: string
created_at?: Date
}

// Should automatically infer the complete Zod schema type
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<ExpectedType>()
})

it(`should automatically infer type from Zod schema with nullable fields`, () => {
// Test with nullable fields (different from optional)
const postSchema = z.object({
id: z.string(),
title: z.string(),
author_id: z.string().nullable(),
published_at: z.date().nullable(),
})

const _collection = createCollection({
getKey: (item) => item.id,
sync: { sync: () => {} },
schema: postSchema,
})

type Param = Parameters<typeof _collection.insert>[0]
type ExpectedType = {
id: string
title: string
author_id: string | null
published_at: Date | null
}

// Should automatically infer nullable types correctly
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<ExpectedType>()
})
})
Loading