diff --git a/packages/db/src/change-events.ts b/packages/db/src/change-events.ts index d06b8a933..2326d91b6 100644 --- a/packages/db/src/change-events.ts +++ b/packages/db/src/change-events.ts @@ -47,12 +47,12 @@ export function currentStateAsChanges< >( collection: CollectionLike, options: CurrentStateAsChangesOptions = {} -): Array> { +): Array> { // Helper function to collect filtered results const collectFilteredResults = ( filterFn?: (value: T) => boolean - ): Array> => { - const result: Array> = [] + ): Array> => { + const result: Array> = [] for (const [key, value] of collection.entries()) { // If no filter function is provided, include all items if (filterFn?.(value) ?? true) { @@ -100,7 +100,7 @@ export function currentStateAsChanges< if (optimizationResult.canOptimize) { // Use index optimization - const result: Array> = [] + const result: Array> = [] for (const key of optimizationResult.matchingKeys) { const value = collection.get(key) if (value !== undefined) { @@ -199,16 +199,19 @@ export function createFilterFunctionFromExpression( * @param options - The subscription options containing the where clause * @returns A filtered callback function */ -export function createFilteredCallback( - originalCallback: (changes: Array>) => void, +export function createFilteredCallback< + T extends object, + TKey extends string | number, +>( + originalCallback: (changes: Array>) => void, options: SubscribeChangesOptions -): (changes: Array>) => void { +): (changes: Array>) => void { const filterFn = options.whereExpression ? createFilterFunctionFromExpression(options.whereExpression) : createFilterFunction(options.where!) - return (changes: Array>) => { - const filteredChanges: Array> = [] + return (changes: Array>) => { + const filteredChanges: Array> = [] for (const change of changes) { if (change.type === `insert`) { diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index a895aa11f..141ffec43 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -39,7 +39,6 @@ import { } from "./errors" import { createFilteredCallback, currentStateAsChanges } from "./change-events" import type { Transaction } from "./transactions" -import type { StandardSchemaV1 } from "@standard-schema/spec" import type { SingleRowRefProxy } from "./query/builder/ref-proxy" import type { ChangeListener, @@ -47,12 +46,13 @@ import type { CollectionConfig, CollectionStatus, CurrentStateAsChangesOptions, + DerivedCollectionConfig, Fn, InsertConfig, OperationConfig, OptimisticChangeMessage, PendingMutation, - ResolveInsertInput, + ResolveInput, ResolveType, StandardSchema, SubscribeChangesOptions, @@ -76,26 +76,26 @@ interface PendingSyncedTransaction> { * @template T - The type of items in the collection * @template TKey - The type of the key for the collection * @template TUtils - The utilities record type - * @template TInsertInput - The type for insert operations (can be different from T for schemas with defaults) + * @template TSchema - The schema type for validation and type inference + * @template TInput - The type for insert and update operations (can be different from T for schemas with defaults) */ export interface Collection< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TInsertInput extends object = T, -> extends CollectionImpl { + TSchema = never, + TInput extends object = T, +> extends CollectionImpl { readonly utils: TUtils } /** * Creates a new Collection instance with the given configuration * - * @template TExplicit - The explicit type of items in the collection (highest priority) + * @template TExplicit - The explicit type of items in the collection (second priority) * @template TKey - The type of the key for the collection * @template TUtils - The utilities record type - * @template TSchema - The schema type for validation and type inference (second priority) - * @template TFallback - The fallback type if no explicit or schema type is provided + * @template TSchema - The schema type for validation and type inference (highest priority) * @param options - Collection options with optional utilities * @returns A new Collection with utilities exposed both at top level and under .utils * @@ -162,105 +162,66 @@ export interface Collection< * // 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, + TExplicit extends object = never, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, - TFallback extends object = Record, + TSchema = never, >( - options: CollectionConfig< - ResolveType, - TKey, - TSchema, - ResolveInsertInput - > & { - schema: TSchema - utils?: TUtils + options: CollectionConfig & { utils?: TUtils } & { + __derivedConfig?: never } ): Collection< - ResolveType, + ResolveType, TKey, TUtils, TSchema, - ResolveInsertInput + ResolveInput > -// Overload for when explicit type is provided with schema - explicit type takes precedence +// Overload for when the derived config is used, we infer the types from the config export function createCollection< - TExplicit extends object, - TKey extends string | number = string | number, + TConfig extends DerivedCollectionConfig, TUtils extends UtilsRecord = {}, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TFallback extends object = Record, >( - options: CollectionConfig< - ResolveType, - TKey, - TSchema, - ResolveInsertInput - > & { - schema: TSchema - utils?: TUtils - } -): Collection< - ResolveType, - TKey, - TUtils, - TSchema, - ResolveInsertInput + options: TConfig & { utils?: TUtils } +): TConfig extends DerivedCollectionConfig< + infer TExplicit, + infer TKey, + infer TSchema > + ? TConfig["__derivedType"] extends [never] + ? Collection< + ResolveType, + TKey, + TUtils, + TSchema, + ResolveInput + > + : Collection + : never -// Overload for when explicit type is provided or no schema export function createCollection< - TExplicit = unknown, + TExplicit extends object = never, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TFallback extends object = Record, + TSchema = never, >( - options: CollectionConfig< - ResolveType, - TKey, - TSchema, - ResolveInsertInput - > & { utils?: TUtils } + options: CollectionConfig & { utils?: TUtils } ): Collection< - ResolveType, + ResolveType, TKey, TUtils, TSchema, - ResolveInsertInput -> - -// Implementation -export function createCollection< - TExplicit = unknown, - TKey extends string | number = string | number, - TUtils extends UtilsRecord = {}, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TFallback extends object = Record, ->( - options: CollectionConfig< - ResolveType, - TKey, - TSchema, - ResolveInsertInput - > & { utils?: TUtils } -): Collection< - ResolveType, - TKey, - TUtils, - TSchema, - ResolveInsertInput + ResolveInput > { const collection = new CollectionImpl< - ResolveType, + ResolveType, TKey, TUtils, TSchema, - ResolveInsertInput - >(options) + ResolveInput + >(options as any) // Copy utils to both top level and .utils namespace if (options.utils) { @@ -270,22 +231,22 @@ export function createCollection< } return collection as Collection< - ResolveType, + ResolveType, TKey, TUtils, TSchema, - ResolveInsertInput + ResolveInput > } export class CollectionImpl< - T extends object = Record, + T extends object = never, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TInsertInput extends object = T, + TSchema = never, + TInput extends object = Record, > { - public config: CollectionConfig + public config: CollectionConfig // Core state - make public for testing public transactions: SortedMap> @@ -480,7 +441,7 @@ export class CollectionImpl< * @param config - Configuration object for the collection * @throws Error if sync config is missing */ - constructor(config: CollectionConfig) { + constructor(config: CollectionConfig) { // eslint-disable-next-line if (!config) { throw new CollectionRequiresConfigError() @@ -504,7 +465,7 @@ export class CollectionImpl< this.config = { ...config, autoIndex: config.autoIndex ?? `eager`, - } + } as unknown as CollectionConfig // Set up data storage with optional comparison function if (this.config.compare) { @@ -1774,9 +1735,14 @@ export class CollectionImpl< throw new SchemaValidationError(type, typedIssues) } - // Return the original update data, not the merged data - // We only used the merged data for validation - return data as T + // Extract only the modified keys from the validated result + const validatedMergedData = result.value as T + const modifiedKeys = Object.keys(data) + const extractedChanges = Object.fromEntries( + modifiedKeys.map((k) => [k, validatedMergedData[k as keyof T]]) + ) as T + + return extractedChanges } } @@ -1836,10 +1802,7 @@ export class CollectionImpl< * console.log('Insert failed:', error) * } */ - insert = ( - data: TInsertInput | Array, - config?: InsertConfig - ) => { + insert = (data: TInput | Array, config?: InsertConfig) => { this.validateCollectionUsable(`insert`) const ambientTransaction = getActiveTransaction() @@ -1868,14 +1831,15 @@ export class CollectionImpl< original: {}, modified: validatedData, // Pick the values from validatedData based on what's passed in - this is for cases - // where a schema has default values. The validated data has the extra default - // values but for changes, we just want to show the data that was actually passed in. + // where a schema has default values or transforms. The validated data has the extra + // default or transformed values but for changes, we just want to show the data that + // was actually passed in. changes: Object.fromEntries( Object.keys(item).map((k) => [ k, validatedData[k as keyof typeof validatedData], ]) - ) as TInsertInput, + ) as TInput, globalKey, key, metadata: config?.metadata as unknown, @@ -1907,7 +1871,7 @@ export class CollectionImpl< return await this.config.onInsert!({ transaction: params.transaction as unknown as TransactionWithMutations< - TInsertInput, + T, `insert` >, collection: this as unknown as Collection, @@ -1969,38 +1933,41 @@ export class CollectionImpl< */ // Overload 1: Update multiple items with a callback - update( - key: Array, - callback: (drafts: Array>) => void - ): TransactionType + update( + keys: Array, + callback: (drafts: Array>) => void + ): TransactionType // Overload 2: Update multiple items with config and a callback - update( + update( keys: Array, config: OperationConfig, - callback: (drafts: Array>) => void - ): TransactionType + callback: (drafts: Array>) => void + ): TransactionType // Overload 3: Update a single item with a callback - update( - id: TKey | unknown, - callback: (draft: WritableDeep) => void - ): TransactionType + update( + keys: TKey | unknown, + callback: (draft: WritableDeep) => void + ): TransactionType // Overload 4: Update a single item with config and a callback - update( - id: TKey | unknown, + update( + keys: TKey | unknown, config: OperationConfig, - callback: (draft: WritableDeep) => void - ): TransactionType + callback: (draft: WritableDeep) => void + ): TransactionType - update( + update( keys: (TKey | unknown) | Array, configOrCallback: - | ((draft: WritableDeep | Array>) => void) + | ((draft: WritableDeep) => void) + | ((drafts: Array>) => void) | OperationConfig, - maybeCallback?: (draft: TItem | Array) => void - ) { + maybeCallback?: + | ((draft: WritableDeep) => void) + | ((drafts: Array>) => void) + ): TransactionType { if (typeof keys === `undefined`) { throw new MissingUpdateArgumentError() } @@ -2034,19 +2001,19 @@ export class CollectionImpl< } return item - }) as unknown as Array + }) as unknown as Array let changesArray if (isArray) { // Use the proxy to track changes for all objects changesArray = withArrayChangeTracking( currentObjects, - callback as (draft: Array) => void + callback as (draft: Array) => void ) } else { const result = withChangeTracking( currentObjects[0]!, - callback as (draft: TItem) => void + callback as (draft: TInput) => void ) changesArray = [result] } @@ -2090,7 +2057,16 @@ export class CollectionImpl< mutationId: crypto.randomUUID(), original: originalItem, modified: modifiedItem, - changes: validatedUpdatePayload as Partial, + // Pick the values from modifiedItem based on what's passed in - this is for cases + // where a schema has default values or transforms. The modified data has the extra + // default or transformed values but for changes, we just want to show the data that + // was actually passed in. + changes: Object.fromEntries( + Object.keys(itemChanges).map((k) => [ + k, + modifiedItem[k as keyof typeof modifiedItem], + ]) + ) as TInput, globalKey, key, metadata: config.metadata as unknown, @@ -2109,7 +2085,7 @@ export class CollectionImpl< // If no changes were made, return an empty transaction early if (mutations.length === 0) { - const emptyTransaction = createTransaction({ + const emptyTransaction = createTransaction({ mutationFn: async () => {}, }) emptyTransaction.commit() @@ -2126,7 +2102,7 @@ export class CollectionImpl< this.scheduleTransactionCleanup(ambientTransaction) this.recomputeOptimisticState(true) - return ambientTransaction + return ambientTransaction as TransactionType } // No need to check for onUpdate handler here as we've already checked at the beginning @@ -2367,7 +2343,7 @@ export class CollectionImpl< */ public currentStateAsChanges( options: CurrentStateAsChangesOptions = {} - ): Array> { + ): Array> { return currentStateAsChanges(this, options) } @@ -2411,7 +2387,7 @@ export class CollectionImpl< * }) */ public subscribeChanges( - callback: (changes: Array>) => void, + callback: (changes: Array>) => void, options: SubscribeChangesOptions = {} ): () => void { // Start sync and track subscriber diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index d6590c610..e5a4d76f3 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -1,87 +1,35 @@ import type { + BaseCollectionConfig, CollectionConfig, + DerivedCollectionConfig, DeleteMutationFnParams, InsertMutationFnParams, OperationType, + ResolveInput, ResolveType, SyncConfig, UpdateMutationFnParams, UtilsRecord, } from "./types" -import type { StandardSchemaV1 } from "@standard-schema/spec" /** * Configuration interface for Local-only collection options - * @template TExplicit - The explicit type of items in the collection (highest priority) - * @template TSchema - The schema type for validation and type inference (second priority) - * @template TFallback - The fallback type if no explicit or schema type is provided + * @template TExplicit - The explicit type of items in the collection (second priority) + * @template TSchema - The schema type for validation and type inference (highest priority) * @template TKey - The type of the key returned by getKey - * - * @remarks - * Type resolution follows a priority order: - * 1. If you provide an explicit type via generic parameter, it will be used - * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred - * 3. If neither explicit type nor schema is provided, the fallback type will be used - * - * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict. */ export interface LocalOnlyCollectionConfig< - TExplicit = unknown, - TSchema extends StandardSchemaV1 = never, - TFallback extends Record = Record, - TKey extends string | number = string | number, -> { - /** - * Standard Collection configuration properties - */ - id?: string - schema?: TSchema - getKey: (item: ResolveType) => TKey - + TExplicit extends object = never, + TKey extends string | number = never, + TSchema = never, + TInput extends object = ResolveInput, + TResolvedType extends object = ResolveType, +> extends BaseCollectionConfig { /** * Optional initial data to populate the collection with on creation * This data will be applied during the initial sync process */ - initialData?: Array> - - /** - * Optional asynchronous handler function called after an insert operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to any value - */ - onInsert?: ( - params: InsertMutationFnParams< - ResolveType, - TKey, - LocalOnlyCollectionUtils - > - ) => Promise - - /** - * Optional asynchronous handler function called after an update operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to any value - */ - onUpdate?: ( - params: UpdateMutationFnParams< - ResolveType, - TKey, - LocalOnlyCollectionUtils - > - ) => Promise - - /** - * Optional asynchronous handler function called after a delete operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to any value - */ - onDelete?: ( - params: DeleteMutationFnParams< - ResolveType, - TKey, - LocalOnlyCollectionUtils - > - ) => Promise + initialData?: Array> } /** @@ -96,12 +44,11 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord {} * that immediately "syncs" all optimistic changes to the collection, making them permanent. * Perfect for local-only data that doesn't need persistence or external synchronization. * - * @template TExplicit - The explicit type of items in the collection (highest priority) - * @template TSchema - The schema type for validation and type inference (second priority) - * @template TFallback - The fallback type if no explicit or schema type is provided + * @template TExplicit - The explicit type of items in the collection (second priority) + * @template TSchema - The schema type for validation and type inference (highest priority) * @template TKey - The type of the key returned by getKey * @param config - Configuration options for the Local-only collection - * @returns Collection options with utilities (currently empty but follows the pattern) + * @returns Collection options with utilities * * @example * // Basic local-only collection @@ -135,17 +82,21 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord {} * }) * ) */ + export function localOnlyCollectionOptions< - TExplicit = unknown, - TSchema extends StandardSchemaV1 = never, - TFallback extends Record = Record, + TExplicit extends object = never, TKey extends string | number = string | number, + TSchema = never, >( - config: LocalOnlyCollectionConfig -): CollectionConfig, TKey> & { + config: LocalOnlyCollectionConfig +): DerivedCollectionConfig< + TExplicit, + TKey, + TSchema +> & { utils: LocalOnlyCollectionUtils } { - type ResolvedType = ResolveType + type ResolvedType = ResolveType const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config @@ -162,7 +113,7 @@ export function localOnlyCollectionOptions< // Call user handler first if provided let handlerResult if (onInsert) { - handlerResult = (await onInsert(params)) ?? {} + handlerResult = (await onInsert(params as any)) ?? {} } // Then synchronously confirm the transaction by looping through mutations @@ -180,7 +131,7 @@ export function localOnlyCollectionOptions< // Call user handler first if provided let handlerResult if (onUpdate) { - handlerResult = (await onUpdate(params)) ?? {} + handlerResult = (await onUpdate(params as any)) ?? {} } // Then synchronously confirm the transaction by looping through mutations @@ -198,7 +149,7 @@ export function localOnlyCollectionOptions< // Call user handler first if provided let handlerResult if (onDelete) { - handlerResult = (await onDelete(params)) ?? {} + handlerResult = (await onDelete(params as any)) ?? {} } // Then synchronously confirm the transaction by looping through mutations @@ -209,13 +160,15 @@ export function localOnlyCollectionOptions< return { ...restConfig, - sync: syncResult.sync, - onInsert: wrappedOnInsert, - onUpdate: wrappedOnUpdate, - onDelete: wrappedOnDelete, + sync: syncResult.sync as any, + onInsert: wrappedOnInsert as any, + onUpdate: wrappedOnUpdate as any, + onDelete: wrappedOnDelete as any, utils: {} as LocalOnlyCollectionUtils, startSync: true, gcTime: 0, + } as DerivedCollectionConfig & { + utils: LocalOnlyCollectionUtils } } diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 43dfc5afa..6b6e4a9bd 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -7,15 +7,16 @@ import { StorageKeyRequiredError, } from "./errors" import type { + BaseCollectionConfig, CollectionConfig, DeleteMutationFnParams, InsertMutationFnParams, + ResolveInput, ResolveType, SyncConfig, UpdateMutationFnParams, UtilsRecord, } from "./types" -import type { StandardSchemaV1 } from "@standard-schema/spec" /** * Storage API interface - subset of DOM Storage that we need @@ -46,9 +47,9 @@ interface StoredItem { /** * Configuration interface for localStorage collection options - * @template TExplicit - The explicit type of items in the collection (highest priority) - * @template TSchema - The schema type for validation and type inference (second priority) - * @template TFallback - The fallback type if no explicit or schema type is provided + * @template TExplicit - The explicit type of items in the collection (second priority) + * @template TSchema - The schema type for validation and type inference (highest priority) + * @template TKey - The type of the key returned by getKey * * @remarks * Type resolution follows a priority order: @@ -59,10 +60,10 @@ interface StoredItem { * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict. */ export interface LocalStorageCollectionConfig< - TExplicit = unknown, - TSchema extends StandardSchemaV1 = never, - TFallback extends object = Record, -> { + TExplicit extends object = Record, + TKey extends string | number = string | number, + TSchema = never, +> extends BaseCollectionConfig { /** * The key to use for storing the collection data in localStorage/sessionStorage */ @@ -79,41 +80,6 @@ export interface LocalStorageCollectionConfig< * Can be any object that implements addEventListener/removeEventListener for storage events */ storageEventApi?: StorageEventApi - - /** - * Collection identifier (defaults to "local-collection:{storageKey}" if not provided) - */ - id?: string - schema?: TSchema - getKey: CollectionConfig>[`getKey`] - sync?: CollectionConfig>[`sync`] - - /** - * Optional asynchronous handler function called before an insert operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to any value - */ - onInsert?: ( - params: InsertMutationFnParams> - ) => Promise - - /** - * Optional asynchronous handler function called before an update operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to any value - */ - onUpdate?: ( - params: UpdateMutationFnParams> - ) => Promise - - /** - * Optional asynchronous handler function called before a delete operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to any value - */ - onDelete?: ( - params: DeleteMutationFnParams> - ) => Promise } /** @@ -165,10 +131,10 @@ function generateUuid(): string { * This function creates a collection that persists data to localStorage/sessionStorage * and synchronizes changes across browser tabs using storage events. * - * @template TExplicit - The explicit type of items in the collection (highest priority) - * @template TSchema - The schema type for validation and type inference (second priority) - * @template TFallback - The fallback type if no explicit or schema type is provided - * @param config - Configuration options for the localStorage collection + * @template TExplicit - The explicit type of items in the collection (second priority) + * @template TSchema - The schema type for validation and type inference (highest priority) + * @template TKey - The type of the key returned by getKey + * @param config - Configuration options for the Local-storage collection * @returns Collection options with utilities including clearStorage and getStorageSize * * @example @@ -203,16 +169,25 @@ function generateUuid(): string { * ) */ export function localStorageCollectionOptions< - TExplicit = unknown, - TSchema extends StandardSchemaV1 = never, - TFallback extends object = Record, + TExplicit extends object = Record, + TKey extends string | number = string | number, + TSchema = never, >( - config: LocalStorageCollectionConfig -): Omit>, `id`> & { + config: LocalStorageCollectionConfig +): Omit< + CollectionConfig< + TExplicit, + TKey, + TSchema, + ResolveInput, + ResolveType + >, + `id` +> & { id: string utils: LocalStorageCollectionUtils } { - type ResolvedType = ResolveType + type TItem = ResolveType // Validate required parameters if (!config.storageKey) { @@ -237,14 +212,14 @@ export function localStorageCollectionOptions< } // Track the last known state to detect changes - const lastKnownData = new Map>() + const lastKnownData = new Map>() // Create the sync configuration - const sync = createLocalStorageSync( + const sync = createLocalStorageSync( config.storageKey, storage, storageEventApi, - config.getKey, + config.getKey as (item: TItem) => TKey, lastKnownData ) @@ -263,11 +238,11 @@ export function localStorageCollectionOptions< * @param dataMap - Map of items with version tracking to save to storage */ const saveToStorage = ( - dataMap: Map> + dataMap: Map> ): void => { try { // Convert Map to object format for storage - const objectData: Record> = {} + const objectData: Record> = {} dataMap.forEach((storedItem, key) => { objectData[String(key)] = storedItem }) @@ -303,7 +278,7 @@ export function localStorageCollectionOptions< * Wraps the user's onInsert handler to also save changes to localStorage */ const wrappedOnInsert = async ( - params: InsertMutationFnParams + params: InsertMutationFnParams ) => { // Validate that all values in the transaction can be JSON serialized params.transaction.mutations.forEach((mutation) => { @@ -313,20 +288,17 @@ export function localStorageCollectionOptions< // Call the user handler BEFORE persisting changes (if provided) let handlerResult: any = {} if (config.onInsert) { - handlerResult = (await config.onInsert(params)) ?? {} + handlerResult = (await config.onInsert(params as any)) ?? {} } // Always persist to storage // Load current data from storage - const currentData = loadFromStorage( - config.storageKey, - storage - ) + const currentData = loadFromStorage(config.storageKey, storage) // Add new items with version keys params.transaction.mutations.forEach((mutation) => { - const key = config.getKey(mutation.modified) - const storedItem: StoredItem = { + const key = (config.getKey as (item: TItem) => TKey)(mutation.modified) + const storedItem: StoredItem = { versionKey: generateUuid(), data: mutation.modified, } @@ -343,7 +315,7 @@ export function localStorageCollectionOptions< } const wrappedOnUpdate = async ( - params: UpdateMutationFnParams + params: UpdateMutationFnParams ) => { // Validate that all values in the transaction can be JSON serialized params.transaction.mutations.forEach((mutation) => { @@ -353,20 +325,17 @@ export function localStorageCollectionOptions< // Call the user handler BEFORE persisting changes (if provided) let handlerResult: any = {} if (config.onUpdate) { - handlerResult = (await config.onUpdate(params)) ?? {} + handlerResult = (await config.onUpdate(params as any)) ?? {} } // Always persist to storage // Load current data from storage - const currentData = loadFromStorage( - config.storageKey, - storage - ) + const currentData = loadFromStorage(config.storageKey, storage) // Update items with new version keys params.transaction.mutations.forEach((mutation) => { - const key = config.getKey(mutation.modified) - const storedItem: StoredItem = { + const key = (config.getKey as (item: TItem) => TKey)(mutation.modified) + const storedItem: StoredItem = { versionKey: generateUuid(), data: mutation.modified, } @@ -383,25 +352,24 @@ export function localStorageCollectionOptions< } const wrappedOnDelete = async ( - params: DeleteMutationFnParams + params: DeleteMutationFnParams ) => { // Call the user handler BEFORE persisting changes (if provided) let handlerResult: any = {} if (config.onDelete) { - handlerResult = (await config.onDelete(params)) ?? {} + handlerResult = (await config.onDelete(params as any)) ?? {} } // Always persist to storage // Load current data from storage - const currentData = loadFromStorage( - config.storageKey, - storage - ) + const currentData = loadFromStorage(config.storageKey, storage) // Remove items params.transaction.mutations.forEach((mutation) => { // For delete operations, mutation.original contains the full object - const key = config.getKey(mutation.original as ResolvedType) + const key = (config.getKey as (item: TItem) => TKey)( + mutation.original as TItem + ) currentData.delete(key) }) @@ -432,10 +400,10 @@ export function localStorageCollectionOptions< return { ...restConfig, id: collectionId, - sync, - onInsert: wrappedOnInsert, - onUpdate: wrappedOnUpdate, - onDelete: wrappedOnDelete, + sync: sync as any, + onInsert: wrappedOnInsert as any, + onUpdate: wrappedOnUpdate as any, + onDelete: wrappedOnDelete as any, utils: { clearStorage, getStorageSize, @@ -506,14 +474,17 @@ function loadFromStorage( * @param lastKnownData - Map tracking the last known state for change detection * @returns Sync configuration with manual trigger capability */ -function createLocalStorageSync( +function createLocalStorageSync< + T extends object, + TKey extends string | number = string | number, +>( storageKey: string, storage: StorageApi, storageEventApi: StorageEventApi, - _getKey: (item: T) => string | number, + _getKey: (item: T) => TKey, lastKnownData: Map> -): SyncConfig & { manualTrigger?: () => void } { - let syncParams: Parameters[`sync`]>[0] | null = null +): SyncConfig & { manualTrigger?: () => void } { + let syncParams: Parameters[`sync`]>[0] | null = null /** * Compare two Maps to find differences using version keys @@ -588,8 +559,8 @@ function createLocalStorageSync( } } - const syncConfig: SyncConfig & { manualTrigger?: () => void } = { - sync: (params: Parameters[`sync`]>[0]) => { + const syncConfig: SyncConfig & { manualTrigger?: () => void } = { + sync: (params: Parameters[`sync`]>[0]) => { const { begin, write, commit, markReady } = params // Store sync params for later use diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 763447d88..0a5b5526a 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -24,14 +24,16 @@ export interface Context { export type ContextSchema = Record export type Source = { - [alias: string]: CollectionImpl | QueryBuilder + [alias: string]: + | CollectionImpl + | QueryBuilder } // Helper type to infer collection type from CollectionImpl // This uses ResolveType directly to ensure consistency with collection creation logic export type InferCollectionType = T extends CollectionImpl - ? ResolveType + ? ResolveType : never // Helper type to create schema from source diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 73f151949..6a6a74e4f 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -52,8 +52,8 @@ export interface CompilationResult { export function compileQuery( rawQuery: QueryIR, inputs: Record, - collections: Record>, - callbacks: Record, + collections: Record>, + callbacks: Record>, lazyCollections: Set, optimizableOrderByCollections: Record, cache: QueryCache = new WeakMap(), @@ -320,7 +320,7 @@ export function compileQuery( function processFrom( from: CollectionRef | QueryRef, allInputs: Record, - collections: Record, + collections: Record>, callbacks: Record, lazyCollections: Set, optimizableOrderByCollections: Record, @@ -447,8 +447,11 @@ function getRefFromAlias( export function followRef( query: QueryIR, ref: PropRef, - collection: Collection -): { collection: Collection; path: Array } | void { + collection: Collection +): { + collection: Collection + path: Array +} | void { if (ref.path.length === 0) { return } diff --git a/packages/db/src/query/compiler/joins.ts b/packages/db/src/query/compiler/joins.ts index c8ab79e1c..ae25beae8 100644 --- a/packages/db/src/query/compiler/joins.ts +++ b/packages/db/src/query/compiler/joins.ts @@ -37,9 +37,11 @@ import type { import type { QueryCache, QueryMapping } from "./types.js" import type { BaseIndex } from "../../indexes/base-index.js" -export type LoadKeysFn = (key: Set) => void -export type LazyCollectionCallbacks = { - loadKeys: LoadKeysFn +export type LoadKeysFn = (key: Set) => void +export type LazyCollectionCallbacks< + TKey extends string | number = string | number, +> = { + loadKeys: LoadKeysFn loadInitialState: () => void } @@ -55,7 +57,7 @@ export function processJoins( allInputs: Record, cache: QueryCache, queryMapping: QueryMapping, - collections: Record, + collections: Record>, callbacks: Record, lazyCollections: Set, optimizableOrderByCollections: Record, @@ -96,7 +98,7 @@ function processJoin( allInputs: Record, cache: QueryCache, queryMapping: QueryMapping, - collections: Record, + collections: Record>, callbacks: Record, lazyCollections: Set, optimizableOrderByCollections: Record, @@ -367,7 +369,7 @@ function getTableAliasFromExpression(expr: BasicExpression): string | null { function processJoinSource( from: CollectionRef | QueryRef, allInputs: Record, - collections: Record, + collections: Record>, callbacks: Record, lazyCollections: Set, optimizableOrderByCollections: Record, diff --git a/packages/db/src/query/compiler/order-by.ts b/packages/db/src/query/compiler/order-by.ts index ddf4703ab..8d08fda4e 100644 --- a/packages/db/src/query/compiler/order-by.ts +++ b/packages/db/src/query/compiler/order-by.ts @@ -35,7 +35,7 @@ export function processOrderBy( pipeline: NamespacedAndKeyedStream, orderByClause: Array, selectClause: Select, - collection: Collection, + collection: Collection, optimizableOrderByCollections: Record, limit?: number, offset?: number diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index 863f3f554..9198718c5 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -71,7 +71,7 @@ abstract class BaseExpression { export class CollectionRef extends BaseExpression { public type = `collectionRef` as const constructor( - public collection: CollectionImpl, + public collection: CollectionImpl, public alias: string ) { super() diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 6ef7e7f65..7a9be8f01 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -33,12 +33,16 @@ import type { Context, GetResult } from "./builder/types.js" export function liveQueryCollectionOptions< TContext extends Context, TResult extends object = GetResult, + TKey extends string | number = string | number, + TSchema = never, >( - config: LiveQueryCollectionConfig -): CollectionConfig { + config: LiveQueryCollectionConfig +): CollectionConfig { const collectionConfigBuilder = new CollectionConfigBuilder< TContext, - TResult + TResult, + TKey, + TSchema >(config) return collectionConfigBuilder.getConfig() } @@ -89,38 +93,59 @@ export function createLiveQueryCollection< export function createLiveQueryCollection< TContext extends Context, TResult extends object = GetResult, + TKey extends string | number = string | number, + TSchema = never, TUtils extends UtilsRecord = {}, >( - config: LiveQueryCollectionConfig & { utils?: TUtils } + config: LiveQueryCollectionConfig & { + utils?: TUtils + } ): Collection // Implementation export function createLiveQueryCollection< TContext extends Context, TResult extends object = GetResult, + TKey extends string | number = string | number, + TSchema = never, TUtils extends UtilsRecord = {}, >( configOrQuery: - | (LiveQueryCollectionConfig & { utils?: TUtils }) + | (LiveQueryCollectionConfig & { + utils?: TUtils + }) | ((q: InitialQueryBuilder) => QueryBuilder) -): Collection { +): Collection { // Determine if the argument is a function (query) or a config object if (typeof configOrQuery === `function`) { // Simple query function case - const config: LiveQueryCollectionConfig = { - query: configOrQuery as ( - q: InitialQueryBuilder - ) => QueryBuilder, - } - const options = liveQueryCollectionOptions(config) + const config: LiveQueryCollectionConfig = + { + query: configOrQuery as ( + q: InitialQueryBuilder + ) => QueryBuilder, + } + const options = liveQueryCollectionOptions< + TContext, + TResult, + TKey, + TSchema + >(config) return bridgeToCreateCollection(options) } else { // Config object case const config = configOrQuery as LiveQueryCollectionConfig< TContext, - TResult + TResult, + TKey, + TSchema > & { utils?: TUtils } - const options = liveQueryCollectionOptions(config) + const options = liveQueryCollectionOptions< + TContext, + TResult, + TKey, + TSchema + >(config) return bridgeToCreateCollection({ ...options, utils: config.utils, @@ -134,14 +159,18 @@ export function createLiveQueryCollection< */ function bridgeToCreateCollection< TResult extends object, + TKey extends string | number = string | number, + TSchema = never, TUtils extends UtilsRecord = {}, >( - options: CollectionConfig & { utils?: TUtils } -): Collection { + options: CollectionConfig & { utils?: TUtils } +): Collection { // This is the only place we need a type assertion, hidden from user API return createCollection(options as any) as unknown as Collection< TResult, - string | number, - TUtils + TKey, + TUtils, + TSchema, + TResult > } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index ede264a7f..a6e181a9b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -8,6 +8,7 @@ import type { Collection } from "../../collection.js" import type { CollectionConfig, KeyedStream, + ResolveType, ResultStream, SyncConfig, } from "../../types.js" @@ -27,6 +28,8 @@ let liveQueryCollectionCounter = 0 export class CollectionConfigBuilder< TContext extends Context, TResult extends object = GetResult, + TKey extends string | number = string | number, + TSchema = never, > { private readonly id: string readonly query: QueryIR @@ -39,7 +42,10 @@ export class CollectionConfigBuilder< // WeakMap to store the orderBy index for each result private readonly orderByIndices = new WeakMap() - private readonly compare?: (val1: TResult, val2: TResult) => number + private readonly compare?: ( + val1: ResolveType, + val2: ResolveType + ) => number private graphCache: D2 | undefined private inputsCache: Record> | undefined @@ -49,14 +55,19 @@ export class CollectionConfigBuilder< | undefined // Map of collection IDs to functions that load keys for that lazy collection - lazyCollectionsCallbacks: Record = {} + lazyCollectionsCallbacks: Record> = {} // Set of collection IDs that are lazy collections readonly lazyCollections = new Set() // Set of collection IDs that include an optimizable ORDER BY clause optimizableOrderByCollections: Record = {} constructor( - private readonly config: LiveQueryCollectionConfig + private readonly config: LiveQueryCollectionConfig< + TContext, + TResult, + TKey, + TSchema + > ) { // Generate a unique ID if not provided this.id = config.id || `live-query-${++liveQueryCollectionCounter}` @@ -66,7 +77,9 @@ export class CollectionConfigBuilder< // Create compare function for ordering if the query has orderBy if (this.query.orderBy && this.query.orderBy.length > 0) { - this.compare = createOrderByComparator(this.orderByIndices) + this.compare = createOrderByComparator>( + this.orderByIndices + ) } // Compile the base pipeline once initially @@ -74,20 +87,18 @@ export class CollectionConfigBuilder< this.compileBasePipeline() } - getConfig(): CollectionConfig { + getConfig(): CollectionConfig { + const { getKey, ...rest } = this.config return { id: this.id, - getKey: - this.config.getKey || - ((item) => this.resultKeys.get(item) as string | number), - sync: this.getSyncConfig(), + getKey: getKey || ((item) => this.resultKeys.get(item) as TKey), + sync: this.getSyncConfig() as SyncConfig< + ResolveType, + TKey + >, compare: this.compare, - gcTime: this.config.gcTime || 5000, // 5 seconds by default for live queries - schema: this.config.schema, - onInsert: this.config.onInsert, - onUpdate: this.config.onUpdate, - onDelete: this.config.onDelete, - startSync: this.config.startSync, + gcTime: this.config.gcTime ?? 5000, // 5 seconds by default for live queries + ...rest, } } @@ -100,7 +111,7 @@ export class CollectionConfigBuilder< // So this callback would notice that it doesn't have enough rows and load some more. // The callback returns a boolean, when it's true it's done loading data and we can mark the collection as ready. maybeRunGraph( - config: Parameters[`sync`]>[0], + config: Parameters[`sync`]>[0], syncState: FullSyncState, callback?: () => boolean ) { @@ -126,14 +137,14 @@ export class CollectionConfigBuilder< } } - private getSyncConfig(): SyncConfig { + private getSyncConfig(): SyncConfig { return { rowUpdateMode: `full`, sync: this.syncFn.bind(this), } } - private syncFn(config: Parameters[`sync`]>[0]) { + private syncFn(config: Parameters[`sync`]>[0]) { const syncState: SyncState = { messagesCount: 0, subscribedToAllCollections: false, @@ -210,7 +221,7 @@ export class CollectionConfigBuilder< } private extendPipelineWithChangeProcessing( - config: Parameters[`sync`]>[0], + config: Parameters[`sync`]>[0], syncState: SyncState ): FullSyncState { const { begin, commit } = config @@ -243,7 +254,7 @@ export class CollectionConfigBuilder< } private applyChanges( - config: Parameters[`sync`]>[0], + config: Parameters[`sync`]>[0], changes: { deletes: number inserts: number @@ -275,7 +286,7 @@ export class CollectionConfigBuilder< inserts > deletes || // Just update(s) but the item is already in the collection (so // was inserted previously). - (inserts === deletes && collection.has(key as string | number)) + (inserts === deletes && collection.has(key as TKey)) ) { write({ value, @@ -308,17 +319,21 @@ export class CollectionConfigBuilder< } private subscribeToAllCollections( - config: Parameters[`sync`]>[0], + config: Parameters[`sync`]>[0], syncState: FullSyncState ) { const loaders = Object.entries(this.collections).map( ([collectionId, collection]) => { - const collectionSubscriber = new CollectionSubscriber( + const collectionSubscriber = new CollectionSubscriber< + TContext, + TResult, + TKey + >( collectionId, collection, config, syncState, - this + this as unknown as CollectionConfigBuilder ) collectionSubscriber.subscribe() @@ -341,8 +356,8 @@ export class CollectionConfigBuilder< } } -function buildQueryFromConfig( - config: LiveQueryCollectionConfig +function buildQueryFromConfig( + config: LiveQueryCollectionConfig ) { // Build the query using the provided query builder function or instance if (typeof config.query === `function`) { diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 38e5ceee0..2a69d458f 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -12,19 +12,24 @@ import type { CollectionConfigBuilder } from "./collection-config-builder.js" export class CollectionSubscriber< TContext extends Context, TResult extends object = GetResult, + TKey extends string | number = string | number, > { // Keep track of the keys we've sent (needed for join and orderBy optimizations) - private sentKeys = new Set() + private sentKeys = new Set() // Keep track of the biggest value we've sent so far (needed for orderBy optimization) private biggest: any = undefined constructor( private collectionId: string, - private collection: Collection, - private config: Parameters[`sync`]>[0], + private collection: Collection, + private config: Parameters[`sync`]>[0], private syncState: FullSyncState, - private collectionConfigBuilder: CollectionConfigBuilder + private collectionConfigBuilder: CollectionConfigBuilder< + TContext, + TResult, + TKey + > ) {} subscribe() { @@ -106,7 +111,7 @@ export class CollectionSubscriber< // for keys that have not been sent to the pipeline yet // and filter out deletes for keys that have not been sent private sendVisibleChangesToPipeline = ( - changes: Array>, + changes: Array>, loadedInitialState: boolean ) => { if (loadedInitialState) { @@ -134,10 +139,7 @@ export class CollectionSubscriber< return this.sendChangesToPipeline(newChanges) } - private loadKeys( - keys: Iterable, - filterFn: (item: object) => boolean - ) { + private loadKeys(keys: Iterable, filterFn: (item: object) => boolean) { for (const key of keys) { // Only load the key once if (this.sentKeys.has(key)) continue @@ -178,9 +180,7 @@ export class CollectionSubscriber< // Until that point we filter out all changes from subscription to the collection. let sendChanges = false - const sendVisibleChanges = ( - changes: Array> - ) => { + const sendVisibleChanges = (changes: Array>) => { // We filter out changes when sendChanges is false to ensure that we don't send // any changes from the live subscription until the join operator requests either // the initial state or its first key. This is needed otherwise it could receive @@ -201,7 +201,7 @@ export class CollectionSubscriber< const filterFn = whereExpression ? createFilterFunctionFromExpression(whereExpression) : () => true - const loadKs = (keys: Set) => { + const loadKs = (keys: Set) => { sendChanges = true return this.loadKeys(keys, filterFn) } @@ -239,7 +239,7 @@ export class CollectionSubscriber< this.loadNextItems(offset + limit) const sendChangesInRange = ( - changes: Iterable> + changes: Iterable> ) => { // Split live updates into a delete of the old value and an insert of the new value // and filter out changes that are bigger than the biggest value we've sent so far @@ -312,7 +312,7 @@ export class CollectionSubscriber< } private sendChangesToPipelineWithTracking( - changes: Iterable> + changes: Iterable> ) { const { comparator } = this.collectionConfigBuilder.optimizableOrderByCollections[ @@ -335,10 +335,15 @@ export class CollectionSubscriber< : biggestSentRow // Take the `n` items after the biggest sent value const nextOrderedKeys = index.take(n, biggestSentValue) - const nextInserts: Array> = - nextOrderedKeys.map((key) => { - return { type: `insert`, key, value: this.collection.get(key) } - }) + const nextInserts: Array> = nextOrderedKeys.map( + (key) => { + return { + type: `insert`, + key: key as TKey, + value: this.collection.get(key as TKey), + } + } + ) this.sendChangesToPipelineWithTracking(nextInserts) return nextInserts.length } @@ -355,7 +360,7 @@ export class CollectionSubscriber< } private *trackSentValues( - changes: Iterable>, + changes: Iterable>, comparator: (a: any, b: any) => number ) { for (const change of changes) { @@ -405,10 +410,10 @@ function findCollectionAlias( /** * Helper function to send changes to a D2 input stream */ -function sendChangesToInput( +function sendChangesToInput( input: RootStreamBuilder, - changes: Iterable, - getKey: (item: ChangeMessage[`value`]) => any + changes: Iterable>, + getKey: (item: ChangeMessage[`value`]) => TKey ): number { const multiSetArray: MultiSetArray = [] for (const change of changes) { diff --git a/packages/db/src/query/live/types.ts b/packages/db/src/query/live/types.ts index 995101aef..e5a22a2f7 100644 --- a/packages/db/src/query/live/types.ts +++ b/packages/db/src/query/live/types.ts @@ -1,5 +1,9 @@ import type { D2, RootStreamBuilder } from "@tanstack/db-ivm" -import type { CollectionConfig, ResultStream } from "../../types.js" +import type { + BaseCollectionConfig, + ResolveType, + ResultStream, +} from "../../types.js" import type { InitialQueryBuilder, QueryBuilder } from "../builder/index.js" import type { Context, GetResult } from "../builder/types.js" @@ -49,13 +53,12 @@ export type FullSyncState = Required export interface LiveQueryCollectionConfig< TContext extends Context, TResult extends object = GetResult & object, -> { - /** - * Unique identifier for the collection - * If not provided, defaults to `live-query-${number}` with auto-incrementing number - */ - id?: string - + TKey extends string | number = string | number, + TSchema = never, +> extends Omit< + BaseCollectionConfig, + `compare` | `getKey` + > { /** * Query builder function that defines the live query */ @@ -65,29 +68,6 @@ export interface LiveQueryCollectionConfig< /** * Function to extract the key from result items - * If not provided, defaults to using the key from the D2 stream - */ - getKey?: (item: TResult) => string | number - - /** - * Optional schema for validation - */ - schema?: CollectionConfig[`schema`] - - /** - * Optional mutation handlers - */ - onInsert?: CollectionConfig[`onInsert`] - onUpdate?: CollectionConfig[`onUpdate`] - onDelete?: CollectionConfig[`onDelete`] - - /** - * Start sync / the query immediately - */ - startSync?: boolean - - /** - * GC time for the collection */ - gcTime?: number + getKey?: (item: ResolveType) => TKey } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 752c6387b..a8d003b73 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -11,8 +11,11 @@ import type { BasicExpression } from "./query/ir.js" * * @internal This is used by the type resolution system */ -export type InferSchemaOutput = T extends StandardSchemaV1 - ? StandardSchemaV1.InferOutput extends object +export type InferSchemaOutput< + T, + TRestrict = object, +> = T extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput extends TRestrict ? StandardSchemaV1.InferOutput : Record : Record @@ -22,8 +25,8 @@ export type InferSchemaOutput = T extends StandardSchemaV1 * * @internal This is used for collection insert type inference */ -export type InferSchemaInput = T extends StandardSchemaV1 - ? StandardSchemaV1.InferInput extends object +export type InferSchemaInput = T extends StandardSchemaV1 + ? StandardSchemaV1.InferInput extends TRestrict ? StandardSchemaV1.InferInput : Record : Record @@ -39,11 +42,11 @@ export type InferSchemaInput = T extends StandardSchemaV1 * * @internal This is used for collection insert type inference */ -export type ResolveInsertInput< - TExplicit = unknown, - TSchema extends StandardSchemaV1 = never, +export type ResolveInput< + TExplicit = never, + TSchema = never, TFallback extends object = Record, -> = unknown extends TExplicit +> = [TExplicit] extends [never] ? [TSchema] extends [never] ? TFallback : InferSchemaInput @@ -62,10 +65,10 @@ export type ResolveInsertInput< * Users should not need to use this type directly, but understanding the priority order helps when defining collections. */ export type ResolveType< - TExplicit, - TSchema extends StandardSchemaV1 = never, + TExplicit = never, + TSchema = never, TFallback extends object = Record, -> = unknown extends TExplicit +> = [TExplicit] extends [never] ? [TSchema] extends [never] ? TFallback : InferSchemaOutput @@ -350,13 +353,14 @@ export type CollectionStatus = export interface CollectionConfig< T extends object = Record, TKey extends string | number = string | number, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TInsertInput extends object = T, + TSchema = never, + _TInput extends object = ResolveInput, + TResolvedType extends object = ResolveType, > { // If an id isn't passed in, a UUID will be // generated for it. id?: string - sync: SyncConfig + sync: SyncConfig schema?: TSchema /** * Function to extract the ID from an object @@ -367,7 +371,7 @@ export interface CollectionConfig< * // For a collection with a 'uuid' field as the primary key * getKey: (item) => item.uuid */ - getKey: (item: T) => TKey + getKey: (item: TResolvedType) => TKey /** * Time in milliseconds after which the collection will be garbage collected * when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -397,7 +401,7 @@ export interface CollectionConfig< * // For a collection with a 'createdAt' field * compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() */ - compare?: (x: T, y: T) => number + compare?: (x: TResolvedType, y: TResolvedType) => number /** * Optional asynchronous handler function called before an insert operation * @param params Object containing transaction and collection information @@ -439,7 +443,7 @@ export interface CollectionConfig< * }) * } */ - onInsert?: InsertMutationFn + onInsert?: InsertMutationFn /** * Optional asynchronous handler function called before an update operation @@ -483,7 +487,7 @@ export interface CollectionConfig< * } * } */ - onUpdate?: UpdateMutationFn + onUpdate?: UpdateMutationFn /** * Optional asynchronous handler function called before a delete operation * @param params Object containing transaction and collection information @@ -526,13 +530,32 @@ export interface CollectionConfig< * } * } */ - onDelete?: DeleteMutationFn + onDelete?: DeleteMutationFn } +export type BaseCollectionConfig< + T extends object = Record, + TKey extends string | number = string | number, + TSchema = never, + TInput extends object = ResolveInput, + TResolvedType extends object = ResolveType, +> = Omit, `sync`> + export type ChangesPayload> = Array< ChangeMessage > +export type DerivedCollectionConfig< + T extends object = any, + TKey extends string | number = any, + TSchema = any, + TInput extends object = ResolveInput, + TResolvedType extends object = ResolveType, +> = CollectionConfig & { + __derivedConfig: true + __derivedType: T +} + /** * An input row from a collection */ diff --git a/packages/db/tests/collection-getters.test.ts b/packages/db/tests/collection-getters.test.ts index a0b37ee7e..61b04d268 100644 --- a/packages/db/tests/collection-getters.test.ts +++ b/packages/db/tests/collection-getters.test.ts @@ -5,16 +5,18 @@ import type { CollectionImpl } from "../src/collection" import type { ChangeMessage, CollectionConfig } from "../src/types" describe(`Collection getters`, () => { - let collection: CollectionImpl + let collection: CollectionImpl let mockSync: { sync: (params: { - collection: CollectionImpl + collection: CollectionImpl begin: () => void - write: (message: ChangeMessage) => void + write: (message: Omit) => void commit: () => void + markReady: () => void + truncate: () => void }) => void } - let config: CollectionConfig + let config: CollectionConfig beforeEach(() => { mockSync = { diff --git a/packages/db/tests/collection-schema.test.ts b/packages/db/tests/collection-schema.test.ts new file mode 100644 index 000000000..c5b40e75f --- /dev/null +++ b/packages/db/tests/collection-schema.test.ts @@ -0,0 +1,1002 @@ +import { type } from "arktype" +import { describe, expect, expectTypeOf, it } from "vitest" +import { z } from "zod" +import { createCollection } from "../src/collection" +import { SchemaValidationError } from "../src/errors" +import { createTransaction } from "../src/transactions" +import type { + OperationType, + PendingMutation, + ResolveTransactionChanges, +} from "../src/types" + +describe(`Collection Schema Validation`, () => { + it(`should apply transformations for both insert and update operations`, () => { + // Create a schema with transformations + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + created_at: z.string().transform((val) => new Date(val)), + updated_at: z.string().transform((val) => new Date(val)), + }) + + const collection = createCollection({ + getKey: (item) => item.id, + schema: userSchema, + sync: { sync: () => {} }, + }) + + // Test insert validation + const insertData = { + id: `1`, + name: `John Doe`, + email: `john@example.com`, + created_at: `2023-01-01T00:00:00.000Z`, + updated_at: `2023-01-01T00:00:00.000Z`, + } + + const validatedInsert = collection.validateData(insertData, `insert`) + + // Verify that the inserted data has been transformed + expect(validatedInsert.created_at).toBeInstanceOf(Date) + expect(validatedInsert.updated_at).toBeInstanceOf(Date) + expect(validatedInsert.name).toBe(`John Doe`) + expect(validatedInsert.email).toBe(`john@example.com`) + + // Test update validation - use a schema that accepts both string and Date for existing data + const updateSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + created_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + updated_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + }) + + const updateCollection = createCollection({ + getKey: (item) => item.id, + schema: updateSchema, + sync: { sync: () => {} }, + }) + + // Add the validated insert data to the update collection + ;(updateCollection as any).syncedData.set(`1`, validatedInsert) + + const updateData = { + name: `Jane Doe`, + email: `jane@example.com`, + updated_at: `2023-01-02T00:00:00.000Z`, + } + + const validatedUpdate = updateCollection.validateData( + updateData, + `update`, + `1` + ) + + // Verify that the updated data has been transformed + expect(validatedUpdate.updated_at).toBeInstanceOf(Date) + expect(validatedUpdate.name).toBe(`Jane Doe`) + expect(validatedUpdate.email).toBe(`jane@example.com`) + }) + + it(`should extract only modified keys from validated update result`, () => { + // Create a schema with transformations that can handle both string and Date inputs + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + created_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + updated_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + }) + + const collection = createCollection({ + getKey: (item) => item.id, + schema: userSchema, + sync: { sync: () => {} }, + }) + + // First, we need to add an item to the collection for update validation + const insertData = { + id: `1`, + name: `John Doe`, + email: `john@example.com`, + created_at: `2023-01-01T00:00:00.000Z`, + updated_at: `2023-01-01T00:00:00.000Z`, + } + + const validatedInsert = collection.validateData(insertData, `insert`) + + // Manually add the item to the collection's synced data for testing + ;(collection as any).syncedData.set(`1`, validatedInsert) + + // Test update validation with only modified fields + const updateData = { + name: `Jane Doe`, + updated_at: `2023-01-02T00:00:00.000Z`, + } + + const validatedUpdate = collection.validateData(updateData, `update`, `1`) + + // Verify that only the modified fields are returned + expect(validatedUpdate).toHaveProperty(`name`) + expect(validatedUpdate).toHaveProperty(`updated_at`) + expect(validatedUpdate).not.toHaveProperty(`id`) + expect(validatedUpdate).not.toHaveProperty(`email`) + expect(validatedUpdate).not.toHaveProperty(`created_at`) + + // Verify the changes contain the transformed values + expect(validatedUpdate.name).toBe(`Jane Doe`) + expect(validatedUpdate.updated_at).toBeInstanceOf(Date) + }) + + it(`should handle schemas with default values correctly`, () => { + // Create a schema with default values that can handle both existing Date objects and new string inputs + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + created_at: z + .union([z.date(), z.string()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)) + .default(() => new Date()), + updated_at: z + .union([z.date(), z.string()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)) + .default(() => new Date()), + }) + + const collection = createCollection({ + getKey: (item) => item.id, + schema: userSchema, + sync: { sync: () => {} }, + }) + + // Test insert validation without providing defaulted fields + const insertData = { + id: `1`, + name: `John Doe`, + email: `john@example.com`, + } + + const validatedInsert = collection.validateData(insertData, `insert`) + + // Verify that default values are applied + expect(validatedInsert.created_at).toBeInstanceOf(Date) + expect(validatedInsert.updated_at).toBeInstanceOf(Date) + expect(validatedInsert.name).toBe(`John Doe`) + expect(validatedInsert.email).toBe(`john@example.com`) + + // Manually add the item to the collection's synced data for testing + ;(collection as any).syncedData.set(`1`, validatedInsert) + + // Test update validation without providing defaulted fields + const updateData = { + name: `Jane Doe`, + } + + const validatedUpdate = collection.validateData(updateData, `update`, `1`) + + // Verify that only the modified field is returned + expect(validatedUpdate).toHaveProperty(`name`) + expect(validatedUpdate).not.toHaveProperty(`updated_at`) + expect(validatedUpdate.name).toBe(`Jane Doe`) + + // Test update validation with explicit updated_at field + const updateDataWithTimestamp = { + name: `Jane Smith`, + updated_at: `2023-01-02T00:00:00.000Z`, + } + + const validatedUpdateWithTimestamp = collection.validateData( + updateDataWithTimestamp, + `update`, + `1` + ) + + // Verify that both modified fields are returned with transformations applied + expect(validatedUpdateWithTimestamp).toHaveProperty(`name`) + expect(validatedUpdateWithTimestamp).toHaveProperty(`updated_at`) + expect(validatedUpdateWithTimestamp.name).toBe(`Jane Smith`) + expect(validatedUpdateWithTimestamp.updated_at).toBeInstanceOf(Date) + }) + + it(`should validate schema input types for both insert and update`, () => { + // Create a schema with different input and output types that can handle both string and Date inputs + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + age: z.number().int().positive(), + created_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + updated_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + }) + + const collection = createCollection({ + getKey: (item) => item.id, + schema: userSchema, + sync: { sync: () => {} }, + }) + + // Test that insert validation accepts input type (with string dates) + const insertData = { + id: `1`, + name: `John Doe`, + email: `john@example.com`, + age: 30, + created_at: `2023-01-01T00:00:00.000Z`, + updated_at: `2023-01-01T00:00:00.000Z`, + } + + const validatedInsert = collection.validateData(insertData, `insert`) + + // Verify that the output type has Date objects + expect(validatedInsert.created_at).toBeInstanceOf(Date) + expect(validatedInsert.updated_at).toBeInstanceOf(Date) + expect(typeof validatedInsert.age).toBe(`number`) + + // Add to collection for update testing + ;(collection as any).syncedData.set(`1`, validatedInsert) + + // Test that update validation accepts input type for new fields + const updateData = { + name: `Jane Doe`, + age: 31, + updated_at: `2023-01-02T00:00:00.000Z`, + } + + const validatedUpdate = collection.validateData(updateData, `update`, `1`) + + // Verify that the output type has Date objects and only modified fields + expect(validatedUpdate).toHaveProperty(`name`) + expect(validatedUpdate).toHaveProperty(`age`) + expect(validatedUpdate).toHaveProperty(`updated_at`) + expect(validatedUpdate.updated_at).toBeInstanceOf(Date) + expect(typeof validatedUpdate.age).toBe(`number`) + expect(validatedUpdate.name).toBe(`Jane Doe`) + expect(validatedUpdate.age).toBe(31) + }) +}) + +describe(`Collection with schema validation`, () => { + it(`should validate data against arktype schema on insert`, () => { + // Create a Zod schema for a user + const userSchema = type({ + name: `string > 0`, + age: `number.integer > 0`, + "email?": `string.email`, + }) + + // Create a collection with the schema + const collection = createCollection({ + id: `test`, + getKey: (item) => item.name, + startSync: true, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + }, + }, + schema: userSchema, + }) + const mutationFn = async () => {} + + // Valid data should work + const validUser = { + name: `Alice`, + age: 30, + email: `alice@example.com`, + } + + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => collection.insert(validUser)) + + // Invalid data should throw SchemaValidationError + const invalidUser = { + name: ``, // Empty name (fails min length) + age: -5, // Negative age (fails positive) + email: `not-an-email`, // Invalid email + } + + try { + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => collection.insert(invalidUser)) + // Should not reach here + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(SchemaValidationError) + if (error instanceof SchemaValidationError) { + expect(error.type).toBe(`insert`) + expect(error.issues.length).toBeGreaterThan(0) + // Check that we have validation errors for each invalid field + expect(error.issues.some((issue) => issue.path?.includes(`name`))).toBe( + true + ) + expect(error.issues.some((issue) => issue.path?.includes(`age`))).toBe( + true + ) + expect( + error.issues.some((issue) => issue.path?.includes(`email`)) + ).toBe(true) + } + } + + // Partial updates should work with valid data + const tx3 = createTransaction({ mutationFn }) + tx3.mutate(() => + collection.update(`Alice`, (draft) => { + draft.age = 31 + }) + ) + + // Partial updates should fail with invalid data + try { + const tx4 = createTransaction({ mutationFn }) + tx4.mutate(() => + collection.update(`Alice`, (draft) => { + draft.age = -1 + }) + ) + // Should not reach here + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(SchemaValidationError) + if (error instanceof SchemaValidationError) { + expect(error.type).toBe(`update`) + expect(error.issues.length).toBeGreaterThan(0) + expect(error.issues.some((issue) => issue.path?.includes(`age`))).toBe( + true + ) + } + } + }) + + it(`should validate data against schema on insert`, () => { + // Create a Zod schema for a user + const userSchema = z.object({ + name: z.string().min(1), + age: z.number().int().positive(), + email: z.string().email().optional(), + }) + + // Create a collection with the schema + const collection = createCollection({ + id: `test`, + getKey: (item) => item.name, + startSync: true, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + }, + }, + schema: userSchema, + }) + const mutationFn = async () => {} + + // Valid data should work + const validUser = { + name: `Alice`, + age: 30, + email: `alice@example.com`, + } + + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => collection.insert(validUser)) + + // Invalid data should throw SchemaValidationError + const invalidUser = { + name: ``, // Empty name (fails min length) + age: -5, // Negative age (fails positive) + email: `not-an-email`, // Invalid email + } + + try { + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => collection.insert(invalidUser)) + // Should not reach here + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(SchemaValidationError) + if (error instanceof SchemaValidationError) { + expect(error.type).toBe(`insert`) + expect(error.issues.length).toBeGreaterThan(0) + // Check that we have validation errors for each invalid field + expect(error.issues.some((issue) => issue.path?.includes(`name`))).toBe( + true + ) + expect(error.issues.some((issue) => issue.path?.includes(`age`))).toBe( + true + ) + expect( + error.issues.some((issue) => issue.path?.includes(`email`)) + ).toBe(true) + } + } + + // Partial updates should work with valid data + const tx3 = createTransaction({ mutationFn }) + tx3.mutate(() => + collection.update(`Alice`, (draft) => { + draft.age = 31 + }) + ) + + // Partial updates should fail with invalid data + try { + const tx4 = createTransaction({ mutationFn }) + tx4.mutate(() => + collection.update(`Alice`, (draft) => { + draft.age = -1 + }) + ) + // Should not reach here + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(SchemaValidationError) + if (error instanceof SchemaValidationError) { + expect(error.type).toBe(`update`) + expect(error.issues.length).toBeGreaterThan(0) + expect(error.issues.some((issue) => issue.path?.includes(`age`))).toBe( + true + ) + } + } + }) + + it(`should apply schema defaults on insert`, () => { + const todoSchema = z.object({ + id: z + .string() + .default(() => `todo-${Math.random().toString(36).substr(2, 9)}`), + text: z.string(), + completed: z.boolean().default(false), + createdAt: z.coerce.date().default(() => new Date()), + updatedAt: z.coerce.date().default(() => new Date()), + }) + + // Define inferred types for clarity and use in assertions + type Todo = z.infer + type TodoInput = z.input + + // NOTE: `createCollection` breaks the schema type inference. + // We have to use only the schema, and not the type generic, like so: + const collection = createCollection({ + id: `defaults-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + }, + }, + schema: todoSchema, + }) + + // Type test: should allow inserting input type (with missing fields that have defaults) + // Important: Input type is different from the output type (which is inferred using z.infer) + // For more details, @see https://github.com/colinhacks/zod/issues/4179#issuecomment-2811669261 + type InsertParam = Parameters[0] + expectTypeOf().toEqualTypeOf>() + + const mutationFn = async () => {} + + // Minimal data + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => collection.insert({ text: `task-1` })) + + // Type assertions on the mutation structure + expect(tx1.mutations).toHaveLength(1) + const mutation = tx1.mutations[0]! + + // Test the mutation type structure + expectTypeOf(mutation).toExtend>() + expectTypeOf(mutation.type).toEqualTypeOf() + expectTypeOf(mutation.changes).toEqualTypeOf< + ResolveTransactionChanges + >() + expectTypeOf(mutation.modified).toEqualTypeOf() + + // Runtime assertions for actual values + expect(mutation.type).toBe(`insert`) + expect(mutation.changes).toEqual({ text: `task-1` }) + expect(mutation.modified.text).toBe(`task-1`) + expect(mutation.modified.completed).toBe(false) + expect(mutation.modified.id).toBeDefined() + expect(mutation.modified.createdAt).toBeInstanceOf(Date) + expect(mutation.modified.updatedAt).toBeInstanceOf(Date) + + let insertedItems = Array.from(collection.state.values()) + expect(insertedItems).toHaveLength(1) + const insertedItem = insertedItems[0]! + expect(insertedItem.text).toBe(`task-1`) + expect(insertedItem.completed).toBe(false) + expect(insertedItem.id).toBeDefined() + expect(typeof insertedItem.id).toBe(`string`) + expect(insertedItem.createdAt).toBeInstanceOf(Date) + expect(insertedItem.updatedAt).toBeInstanceOf(Date) + + // Partial data + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => collection.insert({ text: `task-2`, completed: true })) + + insertedItems = Array.from(collection.state.values()) + expect(insertedItems).toHaveLength(2) + + const secondItem = insertedItems.find((item) => item.text === `task-2`)! + expect(secondItem).toBeDefined() + expect(secondItem.text).toBe(`task-2`) + expect(secondItem.completed).toBe(true) + expect(secondItem.id).toBeDefined() + expect(typeof secondItem.id).toBe(`string`) + expect(secondItem.createdAt).toBeInstanceOf(Date) + expect(secondItem.updatedAt).toBeInstanceOf(Date) + + // All fields provided + const tx3 = createTransaction({ mutationFn }) + + tx3.mutate(() => + collection.insert({ + id: `task-id-3`, + text: `task-3`, + completed: true, + createdAt: new Date(`2023-01-01T00:00:00Z`), + updatedAt: new Date(`2023-01-01T00:00:00Z`), + }) + ) + insertedItems = Array.from(collection.state.values()) + expect(insertedItems).toHaveLength(3) + + // using insertedItems[2] was finding wrong item for some reason. + const thirdItem = insertedItems.find((item) => item.text === `task-3`) + expect(thirdItem).toBeDefined() + expect(thirdItem!.text).toBe(`task-3`) + expect(thirdItem!.completed).toBe(true) + expect(thirdItem!.createdAt).toEqual(new Date(`2023-01-01T00:00:00Z`)) + expect(thirdItem!.updatedAt).toEqual(new Date(`2023-01-01T00:00:00Z`)) + expect(thirdItem!.id).toBe(`task-id-3`) + }) + + it(`should apply schema transformations on insert operations`, () => { + // Create a schema with transformations + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + age: z.number().int().positive(), + created_at: z.string().transform((val) => new Date(val)), + updated_at: z.string().transform((val) => new Date(val)), + tags: z + .array(z.string()) + .transform((val) => val.map((tag) => tag.toLowerCase())), + metadata: z + .record(z.string()) + .transform((val) => ({ ...val, processed: true })), + }) + + const collection = createCollection({ + getKey: (item) => item.id, + schema: userSchema, + startSync: true, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + }, + }, + }) + + const mutationFn = async () => {} + + // Test insert with data that should be transformed + const insertData = { + id: `1`, + name: `John Doe`, + email: `john@example.com`, + age: 30, + created_at: `2023-01-01T00:00:00.000Z`, + updated_at: `2023-01-01T00:00:00.000Z`, + tags: [`IMPORTANT`, `USER`], + metadata: { source: `manual` } as any, + } + + const tx = createTransaction({ mutationFn }) + tx.mutate(() => collection.insert(insertData)) + + // Verify that transformations were applied + expect(tx.mutations).toHaveLength(1) + const mutation = tx.mutations[0]! + + expect(mutation.type).toBe(`insert`) + expect(mutation.modified.created_at).toBeInstanceOf(Date) + expect(mutation.modified.updated_at).toBeInstanceOf(Date) + expect(mutation.modified.tags).toEqual([`important`, `user`]) + expect(mutation.modified.metadata).toEqual({ + source: `manual`, + processed: true, + }) + expect(mutation.modified.name).toBe(`John Doe`) + expect(mutation.modified.age).toBe(30) + }) + + it(`should apply schema transformations on update operations`, async () => { + // Create a schema with transformations that can handle both input and existing data + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + age: z.number().int().positive(), + created_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + updated_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + tags: z + .union([ + z.array(z.string()), + z + .array(z.string()) + .transform((val) => val.map((tag) => tag.toLowerCase())), + ]) + .transform((val) => val.map((tag) => tag.toLowerCase())), + metadata: z + .union([ + z.record(z.string()), + z + .record(z.string()) + .transform((val) => ({ ...val, processed: true })), + ]) + .transform((val) => ({ ...val, processed: true })), + }) + + const collection = createCollection({ + getKey: (item) => item.id, + schema: userSchema, + startSync: true, + sync: { + sync: ({ begin, write, commit }) => { + begin() + // Insert initial data + write({ + type: `insert`, + value: { + id: `1`, + name: `John Doe`, + email: `john@example.com`, + age: 30, + created_at: new Date(`2023-01-01T00:00:00.000Z`), + updated_at: new Date(`2023-01-01T00:00:00.000Z`), + tags: [`user`], + metadata: { source: `manual` } as any, + }, + }) + commit() + }, + }, + }) + + await collection.stateWhenReady() + + const mutationFn = async () => {} + + // Test update with data that should be transformed + const tx = createTransaction({ mutationFn }) + tx.mutate(() => + collection.update(`1`, (draft) => { + draft.name = `Jane Doe` + draft.age = 31 + draft.updated_at = `2023-01-02T00:00:00.000Z` + draft.tags = [`IMPORTANT`, `ADMIN`] + draft.metadata = { role: `admin` } as Record + }) + ) + + // Verify that transformations were applied and only modified fields are returned + expect(tx.mutations).toHaveLength(1) + const mutation = tx.mutations[0]! + + expect(mutation.type).toBe(`update`) + expect(mutation.changes).toHaveProperty(`name`) + expect(mutation.changes).toHaveProperty(`age`) + expect(mutation.changes).toHaveProperty(`updated_at`) + expect(mutation.changes).toHaveProperty(`tags`) + expect(mutation.changes).toHaveProperty(`metadata`) + + // Verify transformations + expect(mutation.changes.updated_at).toBeInstanceOf(Date) + expect(mutation.changes.tags).toEqual([`important`, `admin`]) + expect(mutation.changes.metadata).toEqual({ + role: `admin`, + processed: true, + }) + expect(mutation.changes.name).toBe(`Jane Doe`) + expect(mutation.changes.age).toBe(31) + }) + + it(`should handle complex nested transformations on insert and update`, async () => { + // Create a schema with complex nested transformations + const addressSchema = z.object({ + street: z.string(), + city: z.string(), + country: z.string().transform((val) => val.toUpperCase()), + }) + + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + addresses: z + .array(addressSchema) + .transform((val) => val.map((addr) => ({ ...addr, normalized: true }))), + preferences: z + .object({ + theme: z.string().transform((val) => val.toLowerCase()), + notifications: z.boolean(), + }) + .transform((val) => ({ ...val, version: `1.0` })), + created_at: z.string().transform((val) => new Date(val)), + }) + + const collection = createCollection({ + getKey: (item) => item.id, + schema: userSchema, + startSync: true, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + }, + }, + }) + + const mutationFn = async () => {} + + // Test insert with complex nested data + const insertData = { + id: `1`, + name: `John Doe`, + email: `john@example.com`, + addresses: [ + { street: `123 Main St`, city: `New York`, country: `usa` }, + { street: `456 Oak Ave`, city: `Los Angeles`, country: `usa` }, + ], + preferences: { + theme: `DARK`, + notifications: true, + }, + created_at: `2023-01-01T00:00:00.000Z`, + } + + const insertTx = createTransaction({ mutationFn }) + insertTx.mutate(() => collection.insert(insertData)) + + // Verify complex transformations were applied + expect(insertTx.mutations).toHaveLength(1) + const insertMutation = insertTx.mutations[0]! + + expect(insertMutation.type).toBe(`insert`) + expect(insertMutation.modified.created_at).toBeInstanceOf(Date) + expect((insertMutation.modified as any).addresses).toHaveLength(2) + expect((insertMutation.modified as any).addresses[0].country).toBe(`USA`) + expect((insertMutation.modified as any).addresses[0].normalized).toBe(true) + expect((insertMutation.modified as any).addresses[1].country).toBe(`USA`) + expect((insertMutation.modified as any).addresses[1].normalized).toBe(true) + expect((insertMutation.modified as any).preferences.theme).toBe(`dark`) + expect((insertMutation.modified as any).preferences.version).toBe(`1.0`) + expect((insertMutation.modified as any).preferences.notifications).toBe( + true + ) + + // Now test update with the same schema that can handle existing transformed data + const updateSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + addresses: z + .array( + z.object({ + street: z.string(), + city: z.string(), + country: z.string().transform((val) => val.toUpperCase()), + normalized: z.boolean().optional(), + }) + ) + .transform((val) => val.map((addr) => ({ ...addr, normalized: true }))), + preferences: z + .object({ + theme: z.string().transform((val) => val.toLowerCase()), + notifications: z.boolean(), + version: z.string().optional(), + }) + .transform((val) => ({ ...val, version: `1.0` })), + created_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === `string` ? new Date(val) : val)), + }) + + const updateCollection = createCollection({ + getKey: (item) => item.id, + schema: updateSchema, + startSync: true, + sync: { + sync: ({ begin, write, commit }) => { + begin() + // Add the transformed insert data + write({ + type: `insert`, + value: insertMutation.modified as any, + }) + commit() + }, + }, + }) + + await updateCollection.stateWhenReady() + + // Test update with new nested data + const updateTx = createTransaction({ mutationFn }) + updateTx.mutate(() => + updateCollection.update(`1`, (draft) => { + draft.name = `Jane Doe` + draft.addresses = [ + { street: `789 Pine St`, city: `Chicago`, country: `usa` }, + ] + draft.preferences = { + theme: `LIGHT`, + notifications: false, + } + }) + ) + + // Verify update transformations + expect(updateTx.mutations).toHaveLength(1) + const updateMutation = updateTx.mutations[0]! + + expect(updateMutation.type).toBe(`update`) + expect(updateMutation.changes).toHaveProperty(`name`) + expect(updateMutation.changes).toHaveProperty(`addresses`) + expect(updateMutation.changes).toHaveProperty(`preferences`) + + expect(updateMutation.changes.name).toBe(`Jane Doe`) + expect((updateMutation.changes as any).addresses).toHaveLength(1) + expect((updateMutation.changes as any).addresses[0].country).toBe(`USA`) + expect((updateMutation.changes as any).addresses[0].normalized).toBe(true) + expect((updateMutation.changes as any).preferences.theme).toBe(`light`) + expect((updateMutation.changes as any).preferences.version).toBe(`1.0`) + expect((updateMutation.changes as any).preferences.notifications).toBe( + false + ) + }) +}) + +describe(`Collection schema callback type tests`, () => { + it(`should correctly type all callback parameters with schema`, () => { + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + age: z.number().int().positive(), + }) + + type ExpectedType = z.infer + type ExpectedInput = z.input + + createCollection({ + getKey: (item) => item.id, + schema: userSchema, + sync: { sync: () => {} }, + onInsert: (params) => { + expectTypeOf(params.transaction).toHaveProperty(`mutations`) + const mutation = params.transaction.mutations[0] + expectTypeOf(mutation).toHaveProperty(`modified`) + expectTypeOf(mutation.modified).toEqualTypeOf() + return Promise.resolve() + }, + onUpdate: (params) => { + expectTypeOf(params.transaction).toHaveProperty(`mutations`) + const mutation = params.transaction.mutations[0] + expectTypeOf(mutation).toHaveProperty(`modified`) + expectTypeOf(mutation.modified).toEqualTypeOf() + expectTypeOf(mutation).toHaveProperty(`changes`) + expectTypeOf(mutation.changes).toEqualTypeOf>() + return Promise.resolve() + }, + onDelete: (params) => { + expectTypeOf(params.transaction).toHaveProperty(`mutations`) + const mutation = params.transaction.mutations[0] + expectTypeOf(mutation).toHaveProperty(`original`) + expectTypeOf(mutation.original).toEqualTypeOf() + return Promise.resolve() + }, + }) + }) + + it(`should correctly type callbacks with schema transformations`, () => { + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + created_at: z.string().transform((val) => new Date(val)), + updated_at: z.string().transform((val) => new Date(val)), + }) + + type ExpectedType = z.infer + + createCollection({ + getKey: (item) => item.id, + schema: userSchema, + sync: { sync: () => {} }, + onInsert: (params) => { + const mutation = params.transaction.mutations[0] + // Modified should be the output type (with Date objects) + expectTypeOf(mutation.modified).toEqualTypeOf() + return Promise.resolve() + }, + onUpdate: (params) => { + const mutation = params.transaction.mutations[0] + // Modified should be the output type (with Date objects) + expectTypeOf(mutation.modified).toEqualTypeOf() + return Promise.resolve() + }, + onDelete: (params) => { + const mutation = params.transaction.mutations[0] + // Original should be the output type (with Date objects) + expectTypeOf(mutation.original).toEqualTypeOf() + return Promise.resolve() + }, + }) + }) + + it(`should correctly type callbacks with schema defaults`, () => { + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + created_at: z.date().default(() => new Date()), + updated_at: z.date().default(() => new Date()), + }) + + type ExpectedType = z.infer + type ExpectedInput = z.input + + createCollection({ + getKey: (item) => item.id, + schema: userSchema, + sync: { sync: () => {} }, + onInsert: (params) => { + const mutation = params.transaction.mutations[0] + // Modified should be the output type (with all fields including defaults) + expectTypeOf(mutation.modified).toEqualTypeOf() + return Promise.resolve() + }, + onUpdate: (params) => { + const mutation = params.transaction.mutations[0] + // Modified should be the output type (with all fields including defaults) + expectTypeOf(mutation.modified).toEqualTypeOf() + // Changes should be the input type (without defaulted fields) + expectTypeOf(mutation.changes).toEqualTypeOf>() + return Promise.resolve() + }, + onDelete: (params) => { + const mutation = params.transaction.mutations[0] + // Original should be the output type (with all fields including defaults) + expectTypeOf(mutation.original).toEqualTypeOf() + return Promise.resolve() + }, + }) + }) +}) diff --git a/packages/db/tests/collection.test-d.ts b/packages/db/tests/collection.test-d.ts index 0faa68346..cf7d6fbd7 100644 --- a/packages/db/tests/collection.test-d.ts +++ b/packages/db/tests/collection.test-d.ts @@ -1,14 +1,17 @@ import { assertType, describe, expectTypeOf, it } from "vitest" import { z } from "zod" import { createCollection } from "../src/collection" -import type { CollectionImpl } from "../src/collection" -import type { OperationConfig, ResolveType } from "../src/types" +import type { OperationConfig, ResolveInput, ResolveType } from "../src/types" import type { StandardSchemaV1 } from "@standard-schema/spec" describe(`Collection.update type tests`, () => { type TypeTestItem = { id: string; value: number; optional?: boolean } - const updateMethod: CollectionImpl[`update`] = (() => {}) as any // Dummy assignment for type checking + const testCollection = createCollection({ + getKey: (item) => item.id, + sync: { sync: () => {} }, + }) + const updateMethod = testCollection.update it(`should correctly type drafts for multi-item update with callback (Overload 1)`, () => { updateMethod([`id1`, `id2`], (drafts) => { @@ -48,7 +51,6 @@ describe(`Collection.update type tests`, () => { describe(`Collection type resolution tests`, () => { // Define test types type ExplicitType = { id: string; explicit: boolean } - type FallbackType = { id: string; fallback: boolean } const testSchema = z.object({ id: z.string(), @@ -59,86 +61,43 @@ describe(`Collection type resolution tests`, () => { type ItemOf = T extends Array ? U : T it(`should use explicit type when provided without schema`, () => { - const _collection = createCollection({ - getKey: (item) => item.id, + const _collection = createCollection({ + getKey: (item) => { + expectTypeOf(item).toEqualTypeOf() + return item.id + }, sync: { sync: () => {} }, }) - type Param = Parameters[0] - expectTypeOf>().toEqualTypeOf() - }) - - it(`should use schema type when explicit type is not provided`, () => { - const _collection = createCollection< - unknown, - string, - {}, - typeof testSchema - >({ - getKey: (item) => item.id, - sync: { sync: () => {} }, - schema: testSchema, - }) - - type ExpectedType = ResolveType - type Param = Parameters[0] - expectTypeOf>().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - }) - - it(`should use fallback type when neither explicit nor schema type is provided`, () => { - const _collection = createCollection< - unknown, - string, - {}, - never, - FallbackType - >({ - getKey: (item) => item.id, - sync: { sync: () => {} }, - }) + expectTypeOf(_collection.toArray).toEqualTypeOf>() - type ExpectedType = ResolveType - type Param = Parameters[0] - expectTypeOf>().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - }) - - it(`should correctly resolve type with all three types provided`, () => { - // Explicit type should win - const _collection = createCollection< - ExplicitType, - string, - {}, - typeof testSchema, - FallbackType - >({ - getKey: (item) => item.id, - sync: { sync: () => {} }, - schema: testSchema, - }) + type Key = Parameters[0] + expectTypeOf().toEqualTypeOf() - type ExpectedType = ResolveType< - ExplicitType, - typeof testSchema, - FallbackType - > type Param = Parameters[0] expectTypeOf>().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() }) - it(`should automatically infer type from schema without generic arguments`, () => { - // This is the key test case that was missing - no generic arguments at all + it(`should use schema type when explicit type is not provided`, () => { const _collection = createCollection({ - getKey: (item) => item.id, + getKey: (item) => { + expectTypeOf(item).toEqualTypeOf() + return item.id + }, sync: { sync: () => {} }, schema: testSchema, }) + expectTypeOf(_collection.toArray).toEqualTypeOf>() + + type Key = Parameters[0] + expectTypeOf().toEqualTypeOf() + type Param = Parameters[0] - // Should infer the schema type automatically expectTypeOf>().toEqualTypeOf() + + type ExpectedType = ResolveType + expectTypeOf().toEqualTypeOf() }) it(`should automatically infer type from Zod schema with optional fields`, () => { @@ -195,3 +154,235 @@ describe(`Collection type resolution tests`, () => { expectTypeOf>().toEqualTypeOf() }) }) + +describe(`Schema Input/Output Type Distinction`, () => { + // Define schema with different input/output types + const userSchemaWithDefaults = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + created_at: z.date().default(() => new Date()), + updated_at: z.date().default(() => new Date()), + }) + + // Define schema with transformations + const userSchemaTransform = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + created_at: z.string().transform((val) => new Date(val)), + updated_at: z.string().transform((val) => new Date(val)), + }) + + it(`should handle schema with default values correctly for insert`, () => { + type ExpectedOutputType = ResolveType + type ExpectedInputType = ResolveInput + + const collection = createCollection({ + getKey: (item) => { + expectTypeOf(item).toEqualTypeOf() + return item.id + }, + sync: { sync: () => {} }, + schema: userSchemaWithDefaults, + }) + + type InsertArg = Parameters[0] + + // Input type should not include defaulted fields + expectTypeOf().toEqualTypeOf<{ + id: string + name: string + email: string + created_at?: Date + updated_at?: Date + }>() + + // Output type should include all fields + expectTypeOf().toEqualTypeOf<{ + id: string + name: string + email: string + created_at: Date + updated_at: Date + }>() + + // Insert should accept ExpectedInputType or array thereof + expectTypeOf().toEqualTypeOf< + ExpectedInputType | Array + >() + + // Collection items should be ExpectedOutputType + expectTypeOf(collection.toArray).toEqualTypeOf>() + }) + + it(`should handle schema with transformations correctly for insert`, () => { + const collection = createCollection({ + getKey: (item) => item.id, + sync: { sync: () => {} }, + schema: userSchemaTransform, + }) + + type ExpectedInputType = ResolveInput + type ExpectedOutputType = ResolveType + type InsertArg = Parameters[0] + + // Input type should be the raw input (before transformation) + expectTypeOf().toEqualTypeOf<{ + id: string + name: string + email: string + created_at: string + updated_at: string + }>() + + // Output type should be the transformed output + expectTypeOf().toEqualTypeOf<{ + id: string + name: string + email: string + created_at: Date + updated_at: Date + }>() + + // Insert should accept ExpectedInputType or array thereof + expectTypeOf().toEqualTypeOf< + ExpectedInputType | Array + >() + + // Collection items should be ExpectedOutputType + expectTypeOf(collection.toArray).toEqualTypeOf>() + }) + + it(`should handle schema with default values correctly for update method`, () => { + const collection = createCollection({ + getKey: (item) => item.id, + sync: { sync: () => {} }, + schema: userSchemaWithDefaults, + }) + + type ExpectedOutputType = ResolveType + type ExpectedInputType = ResolveInput + + // Input type should not include defaulted fields + expectTypeOf().toEqualTypeOf<{ + id: string + name: string + email: string + created_at?: Date + updated_at?: Date + }>() + + // Output type should include all fields + expectTypeOf().toEqualTypeOf<{ + id: string + name: string + email: string + created_at: Date + updated_at: Date + }>() + + // Test update method with schema types + const updateMethod: typeof collection.update = (() => {}) as any + updateMethod(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) + + updateMethod([`test-id1`, `test-id2`], (drafts) => { + expectTypeOf(drafts).toEqualTypeOf>() + }) + + // Collection items should be ExpectedOutputType + expectTypeOf(collection.toArray).toEqualTypeOf>() + }) + + it(`should handle schema with transformations correctly for update method`, () => { + const collection = createCollection({ + getKey: (item) => item.id, + sync: { sync: () => {} }, + schema: userSchemaTransform, + }) + + type ExpectedInputType = ResolveInput + type ExpectedOutputType = ResolveType + + // Input type should be the raw input (before transformation) + expectTypeOf().toEqualTypeOf<{ + id: string + name: string + email: string + created_at: string + updated_at: string + }>() + + // Output type should be the transformed output + expectTypeOf().toEqualTypeOf<{ + id: string + name: string + email: string + created_at: Date + updated_at: Date + }>() + + // Test update method with schema types + const updateMethod: typeof collection.update = (() => {}) as any + updateMethod(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) + + updateMethod([`test-id1`, `test-id2`], (drafts) => { + expectTypeOf(drafts).toEqualTypeOf>() + }) + + // Collection items should be ExpectedOutputType + expectTypeOf(collection.toArray).toEqualTypeOf>() + }) +}) + +describe(`Collection callback type tests`, () => { + type TypeTestItem = { id: string; value: number; optional?: boolean } + + it(`should correctly type onInsert callback parameters`, () => { + createCollection({ + getKey: (item) => item.id, + sync: { sync: () => {} }, + onInsert: (params) => { + expectTypeOf(params.transaction).toHaveProperty(`mutations`) + const mutation = params.transaction.mutations[0] + expectTypeOf(mutation).toHaveProperty(`modified`) + expectTypeOf(mutation.modified).toEqualTypeOf() + return Promise.resolve() + }, + }) + }) + + it(`should correctly type onUpdate callback parameters`, () => { + createCollection({ + getKey: (item) => item.id, + sync: { sync: () => {} }, + onUpdate: (params) => { + expectTypeOf(params.transaction).toHaveProperty(`mutations`) + const mutation = params.transaction.mutations[0] + expectTypeOf(mutation).toHaveProperty(`modified`) + expectTypeOf(mutation.modified).toEqualTypeOf() + expectTypeOf(mutation).toHaveProperty(`changes`) + expectTypeOf(mutation.changes).toEqualTypeOf>() + return Promise.resolve() + }, + }) + }) + + it(`should correctly type onDelete callback parameters`, () => { + createCollection({ + getKey: (item) => item.id, + sync: { sync: () => {} }, + onDelete: (params) => { + expectTypeOf(params.transaction).toHaveProperty(`mutations`) + const mutation = params.transaction.mutations[0] + expectTypeOf(mutation).toHaveProperty(`original`) + expectTypeOf(mutation.original).toEqualTypeOf() + return Promise.resolve() + }, + }) + }) +}) diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index 56e0ca825..74cd66077 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -1,7 +1,5 @@ -import { type } from "arktype" import mitt from "mitt" -import { describe, expect, expectTypeOf, it, vi } from "vitest" -import { z } from "zod" +import { describe, expect, it, vi } from "vitest" import { createCollection } from "../src/collection" import { CollectionRequiresConfigError, @@ -10,7 +8,6 @@ import { MissingDeleteHandlerError, MissingInsertHandlerError, MissingUpdateHandlerError, - SchemaValidationError, } from "../src/errors" import { createTransaction } from "../src/transactions" import { @@ -18,13 +15,7 @@ import { mockSyncCollectionOptionsNoInitialState, withExpectedRejection, } from "./utils" -import type { - ChangeMessage, - MutationFn, - OperationType, - PendingMutation, - ResolveTransactionChanges, -} from "../src/types" +import type { ChangeMessage, MutationFn, PendingMutation } from "../src/types" describe(`Collection`, () => { it(`should throw if there's no sync config`, () => { @@ -946,307 +937,6 @@ describe(`Collection`, () => { await optimisticDeleteTx.isPersisted.promise }) -}) - -describe(`Collection with schema validation`, () => { - it(`should validate data against arktype schema on insert`, () => { - // Create a Zod schema for a user - const userSchema = type({ - name: `string > 0`, - age: `number.integer > 0`, - "email?": `string.email`, - }) - - // Create a collection with the schema - const collection = createCollection({ - id: `test`, - getKey: (item) => item.name, - startSync: true, - sync: { - sync: ({ begin, commit }) => { - begin() - commit() - }, - }, - schema: userSchema, - }) - const mutationFn = async () => {} - - // Valid data should work - const validUser = { - name: `Alice`, - age: 30, - email: `alice@example.com`, - } - - const tx1 = createTransaction({ mutationFn }) - tx1.mutate(() => collection.insert(validUser)) - - // Invalid data should throw SchemaValidationError - const invalidUser = { - name: ``, // Empty name (fails min length) - age: -5, // Negative age (fails positive) - email: `not-an-email`, // Invalid email - } - - try { - const tx2 = createTransaction({ mutationFn }) - tx2.mutate(() => collection.insert(invalidUser)) - // Should not reach here - expect(true).toBe(false) - } catch (error) { - expect(error).toBeInstanceOf(SchemaValidationError) - if (error instanceof SchemaValidationError) { - expect(error.type).toBe(`insert`) - expect(error.issues.length).toBeGreaterThan(0) - // Check that we have validation errors for each invalid field - expect(error.issues.some((issue) => issue.path?.includes(`name`))).toBe( - true - ) - expect(error.issues.some((issue) => issue.path?.includes(`age`))).toBe( - true - ) - expect( - error.issues.some((issue) => issue.path?.includes(`email`)) - ).toBe(true) - } - } - - // Partial updates should work with valid data - const tx3 = createTransaction({ mutationFn }) - tx3.mutate(() => - collection.update(`Alice`, (draft) => { - draft.age = 31 - }) - ) - - // Partial updates should fail with invalid data - try { - const tx4 = createTransaction({ mutationFn }) - tx4.mutate(() => - collection.update(`Alice`, (draft) => { - draft.age = -1 - }) - ) - // Should not reach here - expect(true).toBe(false) - } catch (error) { - expect(error).toBeInstanceOf(SchemaValidationError) - if (error instanceof SchemaValidationError) { - expect(error.type).toBe(`update`) - expect(error.issues.length).toBeGreaterThan(0) - expect(error.issues.some((issue) => issue.path?.includes(`age`))).toBe( - true - ) - } - } - }) - - it(`should validate data against schema on insert`, () => { - // Create a Zod schema for a user - const userSchema = z.object({ - name: z.string().min(1), - age: z.number().int().positive(), - email: z.string().email().optional(), - }) - - // Create a collection with the schema - const collection = createCollection>({ - id: `test`, - getKey: (item) => item.name, - startSync: true, - sync: { - sync: ({ begin, commit }) => { - begin() - commit() - }, - }, - schema: userSchema, - }) - const mutationFn = async () => {} - - // Valid data should work - const validUser = { - name: `Alice`, - age: 30, - email: `alice@example.com`, - } - - const tx1 = createTransaction({ mutationFn }) - tx1.mutate(() => collection.insert(validUser)) - - // Invalid data should throw SchemaValidationError - const invalidUser = { - name: ``, // Empty name (fails min length) - age: -5, // Negative age (fails positive) - email: `not-an-email`, // Invalid email - } - - try { - const tx2 = createTransaction({ mutationFn }) - tx2.mutate(() => collection.insert(invalidUser)) - // Should not reach here - expect(true).toBe(false) - } catch (error) { - expect(error).toBeInstanceOf(SchemaValidationError) - if (error instanceof SchemaValidationError) { - expect(error.type).toBe(`insert`) - expect(error.issues.length).toBeGreaterThan(0) - // Check that we have validation errors for each invalid field - expect(error.issues.some((issue) => issue.path?.includes(`name`))).toBe( - true - ) - expect(error.issues.some((issue) => issue.path?.includes(`age`))).toBe( - true - ) - expect( - error.issues.some((issue) => issue.path?.includes(`email`)) - ).toBe(true) - } - } - - // Partial updates should work with valid data - const tx3 = createTransaction({ mutationFn }) - tx3.mutate(() => - collection.update(`Alice`, (draft) => { - draft.age = 31 - }) - ) - - // Partial updates should fail with invalid data - try { - const tx4 = createTransaction({ mutationFn }) - tx4.mutate(() => - collection.update(`Alice`, (draft) => { - draft.age = -1 - }) - ) - // Should not reach here - expect(true).toBe(false) - } catch (error) { - expect(error).toBeInstanceOf(SchemaValidationError) - if (error instanceof SchemaValidationError) { - expect(error.type).toBe(`update`) - expect(error.issues.length).toBeGreaterThan(0) - expect(error.issues.some((issue) => issue.path?.includes(`age`))).toBe( - true - ) - } - } - }) - - it(`should apply schema defaults on insert`, () => { - const todoSchema = z.object({ - id: z - .string() - .default(() => `todo-${Math.random().toString(36).substr(2, 9)}`), - text: z.string(), - completed: z.boolean().default(false), - createdAt: z.coerce.date().default(() => new Date()), - updatedAt: z.coerce.date().default(() => new Date()), - }) - - // Define inferred types for clarity and use in assertions - type Todo = z.infer - type TodoInput = z.input - - // NOTE: `createCollection` breaks the schema type inference. - // We have to use only the schema, and not the type generic, like so: - const collection = createCollection({ - id: `defaults-test`, - getKey: (item) => item.id, - sync: { - sync: ({ begin, commit }) => { - begin() - commit() - }, - }, - schema: todoSchema, - }) - - // Type test: should allow inserting input type (with missing fields that have defaults) - // Important: Input type is different from the output type (which is inferred using z.infer) - // For more details, @see https://github.com/colinhacks/zod/issues/4179#issuecomment-2811669261 - type InsertParam = Parameters[0] - expectTypeOf().toEqualTypeOf>() - - const mutationFn = async () => {} - - // Minimal data - const tx1 = createTransaction({ mutationFn }) - tx1.mutate(() => collection.insert({ text: `task-1` })) - - // Type assertions on the mutation structure - expect(tx1.mutations).toHaveLength(1) - const mutation = tx1.mutations[0]! - - // Test the mutation type structure - expectTypeOf(mutation).toExtend>() - expectTypeOf(mutation.type).toEqualTypeOf() - expectTypeOf(mutation.changes).toEqualTypeOf< - ResolveTransactionChanges - >() - expectTypeOf(mutation.modified).toEqualTypeOf() - - // Runtime assertions for actual values - expect(mutation.type).toBe(`insert`) - expect(mutation.changes).toEqual({ text: `task-1` }) - expect(mutation.modified.text).toBe(`task-1`) - expect(mutation.modified.completed).toBe(false) - expect(mutation.modified.id).toBeDefined() - expect(mutation.modified.createdAt).toBeInstanceOf(Date) - expect(mutation.modified.updatedAt).toBeInstanceOf(Date) - - let insertedItems = Array.from(collection.state.values()) - expect(insertedItems).toHaveLength(1) - const insertedItem = insertedItems[0]! - expect(insertedItem.text).toBe(`task-1`) - expect(insertedItem.completed).toBe(false) - expect(insertedItem.id).toBeDefined() - expect(typeof insertedItem.id).toBe(`string`) - expect(insertedItem.createdAt).toBeInstanceOf(Date) - expect(insertedItem.updatedAt).toBeInstanceOf(Date) - - // Partial data - const tx2 = createTransaction({ mutationFn }) - tx2.mutate(() => collection.insert({ text: `task-2`, completed: true })) - - insertedItems = Array.from(collection.state.values()) - expect(insertedItems).toHaveLength(2) - - const secondItem = insertedItems.find((item) => item.text === `task-2`)! - expect(secondItem).toBeDefined() - expect(secondItem.text).toBe(`task-2`) - expect(secondItem.completed).toBe(true) - expect(secondItem.id).toBeDefined() - expect(typeof secondItem.id).toBe(`string`) - expect(secondItem.createdAt).toBeInstanceOf(Date) - expect(secondItem.updatedAt).toBeInstanceOf(Date) - - // All fields provided - const tx3 = createTransaction({ mutationFn }) - - tx3.mutate(() => - collection.insert({ - id: `task-id-3`, - text: `task-3`, - completed: true, - createdAt: new Date(`2023-01-01T00:00:00Z`), - updatedAt: new Date(`2023-01-01T00:00:00Z`), - }) - ) - insertedItems = Array.from(collection.state.values()) - expect(insertedItems).toHaveLength(3) - - // using insertedItems[2] was finding wrong item for some reason. - const thirdItem = insertedItems.find((item) => item.text === `task-3`) - expect(thirdItem).toBeDefined() - expect(thirdItem!.text).toBe(`task-3`) - expect(thirdItem!.completed).toBe(true) - expect(thirdItem!.createdAt).toEqual(new Date(`2023-01-01T00:00:00Z`)) - expect(thirdItem!.updatedAt).toEqual(new Date(`2023-01-01T00:00:00Z`)) - expect(thirdItem!.id).toBe(`task-id-3`) - }) it(`should not block user actions when keys are recently synced`, async () => { // This test reproduces the ACTUAL issue where rapid user actions get blocked diff --git a/packages/db/tests/local-only.test-d.ts b/packages/db/tests/local-only.test-d.ts index 0dc4f2ff1..a711c77e5 100644 --- a/packages/db/tests/local-only.test-d.ts +++ b/packages/db/tests/local-only.test-d.ts @@ -2,9 +2,6 @@ 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" -import type { Collection } from "../src/index" -import type { Query } from "../src/query/builder" interface TestItem extends Record { id: number @@ -12,19 +9,14 @@ interface TestItem extends Record { completed?: boolean } +type ItemOf = T extends Array ? U : T + describe(`LocalOnly Collection Types`, () => { it(`should have correct return type from localOnlyCollectionOptions`, () => { - const config = { + const options = localOnlyCollectionOptions({ id: `test-local-only`, - getKey: (item: TestItem) => item.id, - } - - const options = localOnlyCollectionOptions< - TestItem, - never, - TestItem, - number - >(config) + getKey: (item) => item.id, + }) // Test that options has the expected structure expectTypeOf(options).toHaveProperty(`sync`) @@ -35,32 +27,33 @@ describe(`LocalOnly Collection Types`, () => { expectTypeOf(options).toHaveProperty(`getKey`) // Test that getKey returns the correct type - expectTypeOf(options.getKey).toExtend<(item: TestItem) => number>() + expectTypeOf(options.getKey).parameter(0).toEqualTypeOf() + expectTypeOf(options.getKey).returns.toEqualTypeOf() }) it(`should be compatible with createCollection`, () => { - const config = { + const options = localOnlyCollectionOptions({ id: `test-local-only`, - getKey: (item: TestItem) => item.id, - } + getKey: (item) => item.id, + }) + + const collection = createCollection(options) - const options = localOnlyCollectionOptions< - TestItem, - never, - TestItem, - number - >(config) - - const collection = createCollection< - TestItem, - number, - LocalOnlyCollectionUtils - >(options) - - // Test that the collection has the expected type - expectTypeOf(collection).toExtend< - Collection - >() + // Test that the collection has the essential methods and properties + expectTypeOf(collection.insert).toBeFunction() + expectTypeOf(collection.update).toBeFunction() + expectTypeOf(collection.delete).toBeFunction() + expectTypeOf(collection.get).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf>() + + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() + + // Test update draft type + collection.update(1, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) it(`should work with custom callbacks`, () => { @@ -72,21 +65,24 @@ describe(`LocalOnly Collection Types`, () => { onDelete: () => Promise.resolve({}), } - const options = localOnlyCollectionOptions< - TestItem, - never, - TestItem, - number - >(configWithCallbacks) - const collection = createCollection< - TestItem, - number, - LocalOnlyCollectionUtils - >(options) - - expectTypeOf(collection).toExtend< - Collection - >() + const options = localOnlyCollectionOptions(configWithCallbacks) + const collection = createCollection(options) + + // Test that the collection has the essential methods and properties + expectTypeOf(collection.insert).toBeFunction() + expectTypeOf(collection.update).toBeFunction() + expectTypeOf(collection.delete).toBeFunction() + expectTypeOf(collection.get).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf>() + + // Test insert parameter type + type InsertParam2 = Parameters[0] + expectTypeOf>().toEqualTypeOf() + + // Test update draft type + collection.update(1, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) it(`should work with initial data`, () => { @@ -96,21 +92,15 @@ describe(`LocalOnly Collection Types`, () => { initialData: [{ id: 1, name: `Test` }] as Array, } - const options = localOnlyCollectionOptions< - TestItem, - never, - TestItem, - number - >(configWithInitialData) - const collection = createCollection< - TestItem, - number, - LocalOnlyCollectionUtils - >(options) - - expectTypeOf(collection).toExtend< - Collection - >() + const options = localOnlyCollectionOptions(configWithInitialData) + const collection = createCollection(options) + + // Test that the collection has the essential methods and properties + expectTypeOf(collection.insert).toBeFunction() + expectTypeOf(collection.update).toBeFunction() + expectTypeOf(collection.delete).toBeFunction() + expectTypeOf(collection.get).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf>() }) it(`should infer key type from getKey function`, () => { @@ -119,142 +109,128 @@ describe(`LocalOnly Collection Types`, () => { getKey: (item: TestItem) => `item-${item.id}`, } - const options = localOnlyCollectionOptions< - TestItem, - never, - TestItem, - string - >(config) - const collection = createCollection< - TestItem, - string, - LocalOnlyCollectionUtils - >(options) - - expectTypeOf(collection).toExtend< - Collection - >() - expectTypeOf(options.getKey).toExtend<(item: TestItem) => string>() + const options = localOnlyCollectionOptions(config) + const collection = createCollection(options) + + // Test that the collection has the essential methods and properties + expectTypeOf(collection.insert).toBeFunction() + expectTypeOf(collection.update).toBeFunction() + expectTypeOf(collection.delete).toBeFunction() + expectTypeOf(collection.get).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(options.getKey).toBeFunction() }) - it(`should work with schema and infer correct types`, () => { + it(`should work with schema and infer correct types when saved to a variable`, () => { const testSchema = z.object({ id: z.string(), entityId: z.string(), value: z.string(), + createdAt: z.date().optional().default(new Date()), }) - const config = { + // We can trust that zod infers the correct types for the schema + type ExpectedType = z.infer + type ExpectedInput = z.input + + const config = localOnlyCollectionOptions({ id: `test-with-schema`, - getKey: (item: any) => item.id, + getKey: (item) => item.id, schema: testSchema, - } + onInsert: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() + }, + onUpdate: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() + }, + onDelete: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() + }, + }) + const collection = createCollection(config) - const options = localOnlyCollectionOptions(config) - const collection = createCollection(options) + collection.insert({ + id: `1`, + entityId: `1`, + value: `1`, + }) + + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() + + // Check that the update method accepts the expected input type + collection.update(`1`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) // Test that the collection has the correct inferred type from schema - expectTypeOf(collection).toExtend< - Collection< - { - id: string - entityId: string - value: string - }, - string, - LocalOnlyCollectionUtils - > - >() + expectTypeOf(collection.toArray).toEqualTypeOf>() }) - it(`should work with schema and query builder type inference (bug report reproduction)`, () => { + it(`should work with schema and infer correct types when nested in createCollection`, () => { const testSchema = z.object({ id: z.string(), entityId: z.string(), value: z.string(), - createdAt: z.date(), + createdAt: z.date().optional().default(new Date()), }) - const config = { - id: `test-with-schema-query`, - getKey: (item: any) => item.id, - schema: testSchema, - } - - const options = localOnlyCollectionOptions(config) - const collection = createCollection(options) - - // This should work without type errors - the query builder should infer the correct type - const query = (q: InstanceType) => - q - .from({ bookmark: collection }) - .orderBy(({ bookmark }) => bookmark.createdAt, `desc`) + // We can trust that zod infers the correct types for the schema + type ExpectedType = z.infer + type ExpectedInput = z.input - // Test that the collection has the correct inferred type from schema - expectTypeOf(collection).toExtend< - Collection< - { - id: string - entityId: string - value: string - createdAt: Date + const collection = createCollection( + localOnlyCollectionOptions({ + id: `test-with-schema`, + getKey: (item) => item.id, + schema: testSchema, + onInsert: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() }, - string, - LocalOnlyCollectionUtils - > - >() - - // Test that the query builder can access the createdAt property - expectTypeOf(query).toBeFunction() - }) + onUpdate: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() + }, + onDelete: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() + }, + }) + ) - it(`should reproduce exact bug report scenario`, () => { - // This reproduces the exact scenario from the bug report - const selectUrlSchema = z.object({ - id: z.string(), - url: z.string(), - title: z.string(), - createdAt: z.date(), + collection.insert({ + id: `1`, + entityId: `1`, + value: `1`, }) - const initialData = [ - { - id: `1`, - url: `https://example.com`, - title: `Example`, - createdAt: new Date(), - }, - ] + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() - const bookmarkCollection = createCollection( - localOnlyCollectionOptions({ - initialData, - getKey: (url: any) => url.id, - schema: selectUrlSchema, - }) - ) - - // This should work without type errors - the query builder should infer the correct type - const query = (q: InstanceType) => - q - .from({ bookmark: bookmarkCollection }) - .orderBy(({ bookmark }) => bookmark.createdAt, `desc`) + // Check that the update method accepts the expected input type + collection.update(`1`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) // Test that the collection has the correct inferred type from schema - expectTypeOf(bookmarkCollection).toExtend< - Collection< - { - id: string - url: string - title: string - createdAt: Date - }, - string, - LocalOnlyCollectionUtils - > - >() - - // Test that the query builder can access the createdAt property - expectTypeOf(query).toBeFunction() + expectTypeOf(collection.toArray).toEqualTypeOf>() }) }) diff --git a/packages/db/tests/local-storage.test-d.ts b/packages/db/tests/local-storage.test-d.ts index 82be215da..fc64e3648 100644 --- a/packages/db/tests/local-storage.test-d.ts +++ b/packages/db/tests/local-storage.test-d.ts @@ -2,19 +2,14 @@ import { describe, expectTypeOf, it } from "vitest" import { z } from "zod" import { createCollection } from "../src/index" import { localStorageCollectionOptions } from "../src/local-storage" -import type { Query } from "../src/query/builder" import type { LocalStorageCollectionConfig, StorageApi, StorageEventApi, } from "../src/local-storage" -import type { - CollectionConfig, - DeleteMutationFnParams, - InsertMutationFnParams, - ResolveType, - UpdateMutationFnParams, -} from "../src/types" +import type { ResolveType } from "../src/types" + +type ItemOf = T extends Array ? U : T describe(`LocalStorage collection type resolution tests`, () => { // Define test types @@ -60,6 +55,15 @@ describe(`LocalStorage collection type resolution tests`, () => { expectTypeOf(collection.size).toBeNumber() expectTypeOf(collection.utils.clearStorage).toBeFunction() expectTypeOf(collection.utils.getStorageSize).toBeFunction() + + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() + + // Test update draft type + collection.update(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) it(`should prioritize explicit type in LocalStorageCollectionConfig`, () => { @@ -70,14 +74,22 @@ describe(`LocalStorage collection type resolution tests`, () => { getKey: (item) => item.id, }) - type ExpectedType = ResolveType< - ExplicitType, - never, - Record - > + type ExpectedType = ResolveType // The getKey function should have the resolved type expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() expectTypeOf().toEqualTypeOf() + + // Test that the collection works with the options + const collection = createCollection(options) + + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() + + // Test update draft type + collection.update(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) it(`should use schema type when explicit type is not provided`, () => { @@ -89,55 +101,76 @@ describe(`LocalStorage collection type resolution tests`, () => { getKey: (item) => item.id, }) - type ExpectedType = ResolveType< - unknown, - typeof testSchema, - Record - > + type ExpectedType = ResolveType // The getKey function should have the resolved type expectTypeOf(options.getKey).parameters.toEqualTypeOf<[SchemaType]>() expectTypeOf().toEqualTypeOf() + + // Test that the collection works with the options + const collection = createCollection(options) + + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() + + // Test update draft type + collection.update(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) - it(`should use fallback type when neither explicit nor schema type is provided`, () => { - const config: LocalStorageCollectionConfig = { + it(`should use explicit type when provided`, () => { + const config: LocalStorageCollectionConfig = { storageKey: `test`, storage: mockStorage, storageEventApi: mockStorageEventApi, getKey: (item) => item.id, } - const options = localStorageCollectionOptions( - config - ) + const options = localStorageCollectionOptions(config) - type ExpectedType = ResolveType + type ExpectedType = ResolveType // The getKey function should have the resolved type expectTypeOf(options.getKey).parameters.toEqualTypeOf<[FallbackType]>() expectTypeOf().toEqualTypeOf() + + // Test that the collection works with the options + const collection = createCollection(options) + + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() + + // Test update draft type + collection.update(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) - it(`should correctly resolve type with all three types provided`, () => { - const options = localStorageCollectionOptions< - ExplicitType, - typeof testSchema, - FallbackType - >({ + it(`should correctly resolve type with explicit type provided`, () => { + const options = localStorageCollectionOptions({ storageKey: `test`, storage: mockStorage, storageEventApi: mockStorageEventApi, - schema: testSchema, getKey: (item) => item.id, }) - type ExpectedType = ResolveType< - ExplicitType, - typeof testSchema, - FallbackType - > + type ExpectedType = ResolveType // The getKey function should have the resolved type (explicit type should win) expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() expectTypeOf().toEqualTypeOf() + + // Test that the collection works with the options + const collection = createCollection(options) + + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() + + // Test update draft type + collection.update(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) it(`should properly type the onInsert, onUpdate, and onDelete handlers`, () => { @@ -169,18 +202,17 @@ describe(`LocalStorage collection type resolution tests`, () => { }, }) - // Verify that the handlers are properly typed - expectTypeOf(options.onInsert).parameters.toEqualTypeOf< - [InsertMutationFnParams] - >() + // Test that the collection works with the options + const collection = createCollection(options) - expectTypeOf(options.onUpdate).parameters.toEqualTypeOf< - [UpdateMutationFnParams] - >() + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() - expectTypeOf(options.onDelete).parameters.toEqualTypeOf< - [DeleteMutationFnParams] - >() + // Test update draft type + collection.update(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) it(`should properly type localStorage-specific configuration options`, () => { @@ -228,9 +260,7 @@ describe(`LocalStorage collection type resolution tests`, () => { }) // Verify sync has the correct type and optional getSyncMetadata - expectTypeOf(options.sync).toExtend< - CollectionConfig[`sync`] - >() + expectTypeOf(options.sync).toBeObject() if (options.sync.getSyncMetadata) { expectTypeOf(options.sync.getSyncMetadata).toBeFunction() @@ -281,68 +311,96 @@ describe(`LocalStorage collection type resolution tests`, () => { expectTypeOf(windowEventApi).toExtend() }) - it(`should work with schema and query builder type inference (bug report reproduction)`, () => { - const queryTestSchema = z.object({ + it(`should work with schema and infer correct types`, () => { + const testSchemaWithSchema = z.object({ id: z.string(), entityId: z.string(), value: z.string(), - createdAt: z.date(), + createdAt: z.date().optional().default(new Date()), }) - const config = { - storageKey: `test-with-schema-query`, - storage: mockStorage, - storageEventApi: mockStorageEventApi, - getKey: (item: any) => item.id, - schema: queryTestSchema, - } + // We can trust that zod infers the correct types for the schema + type ExpectedType = z.infer + type ExpectedInput = z.input + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `test-with-schema`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item: any) => item.id, + schema: testSchemaWithSchema, + onInsert: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() + }, + onUpdate: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() + }, + onDelete: (params) => { + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve() + }, + }) + ) - const options = localStorageCollectionOptions(config) - const collection = createCollection(options) + collection.insert({ + id: `1`, + entityId: `1`, + value: `1`, + }) - // This should work without type errors - the query builder should infer the correct type - const query = (q: InstanceType) => - q - .from({ bookmark: collection }) - .orderBy(({ bookmark }) => bookmark.createdAt, `desc`) + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() - // Test that the collection has the correct inferred type from schema - expectTypeOf(collection).toExtend() // Using any here since we don't have the exact Collection type imported + // Check that the update method accepts the expected input type + collection.update(`1`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) - // Test that the query builder can access the createdAt property - expectTypeOf(query).toBeFunction() + // Test that the collection has the correct inferred type from schema + expectTypeOf(collection.toArray).toEqualTypeOf>() }) - it(`should reproduce exact bug report scenario with localStorage`, () => { - // This reproduces the exact scenario from the bug report but with localStorage - const selectUrlSchema = z.object({ - id: z.string(), - url: z.string(), - title: z.string(), - createdAt: z.date(), - }) + it(`should work with explicit type for URL scenario`, () => { + type SelectUrlType = { + id: string + url: string + title: string + createdAt: Date + } - const config = { - storageKey: `test-with-schema`, + const options = localStorageCollectionOptions({ + storageKey: `test-with-url-type`, storage: mockStorage, storageEventApi: mockStorageEventApi, - getKey: (url: any) => url.id, - schema: selectUrlSchema, - } + getKey: (url) => url.id, + }) - const options = localStorageCollectionOptions(config) const collection = createCollection(options) - // This should work without type errors - the query builder should infer the correct type - const query = (q: InstanceType) => - q - .from({ bookmark: collection }) - .orderBy(({ bookmark }) => bookmark.createdAt, `desc`) + // Test that the collection has the expected methods + expectTypeOf(collection.insert).toBeFunction() + expectTypeOf(collection.get).returns.toEqualTypeOf< + SelectUrlType | undefined + >() + expectTypeOf(collection.toArray).toEqualTypeOf>() - // Test that the collection has the correct inferred type from schema - expectTypeOf(collection).toExtend() // Using any here since we don't have the exact Collection type imported + // Test insert parameter type + type InsertParam = Parameters[0] + expectTypeOf>().toEqualTypeOf() - // Test that the query builder can access the createdAt property - expectTypeOf(query).toBeFunction() + // Test update draft type + collection.update(`test-id`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) }) }) diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index a871d059e..abf1e5669 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -738,10 +738,10 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { query: (q) => q .from({ vehicleDocuments: vehicleDocumentCollection }) - .groupBy((q) => q.vehicleDocuments.vin) - .orderBy((q) => q.vehicleDocuments.vin, `asc`) - .select((q) => ({ - vin: q.vehicleDocuments.vin, + .groupBy(({ vehicleDocuments }) => vehicleDocuments.vin) + .orderBy(({ vehicleDocuments }) => vehicleDocuments.vin, `asc`) + .select(({ vehicleDocuments }) => ({ + vin: vehicleDocuments.vin, })), startSync: true, }) @@ -794,11 +794,14 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { query: (q) => q .from({ vehicleDocuments: vehicleDocumentCollection }) - .groupBy((q) => q.vehicleDocuments.vin) - .orderBy((q) => max(q.vehicleDocuments.updatedAt), `desc`) - .select((q) => ({ - vin: q.vehicleDocuments.vin, - updatedAt: max(q.vehicleDocuments.updatedAt), + .groupBy(({ vehicleDocuments }) => vehicleDocuments.vin) + .orderBy( + ({ vehicleDocuments }) => max(vehicleDocuments.updatedAt), + `desc` + ) + .select(({ vehicleDocuments }) => ({ + vin: vehicleDocuments.vin, + updatedAt: max(vehicleDocuments.updatedAt), })) .offset(0) .limit(10), diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index c96433951..0660d9aad 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -218,7 +218,9 @@ export function mockSyncCollectionOptions< }, } - const options: CollectionConfig & { utils: typeof utils } = { + const options: CollectionConfig & { + utils: typeof utils + } = { sync: { sync: (params: Parameters[`sync`]>[0]) => { begin = params.begin @@ -307,7 +309,9 @@ export function mockSyncCollectionOptionsNoInitialState< }, } - const options: CollectionConfig & { utils: typeof utils } = { + const options: CollectionConfig & { + utils: typeof utils + } = { sync: { sync: (params: Parameters[`sync`]>[0]) => { begin = params.begin diff --git a/packages/electric-db-collection/package.json b/packages/electric-db-collection/package.json index 92d84a13e..eb1d1fcdb 100644 --- a/packages/electric-db-collection/package.json +++ b/packages/electric-db-collection/package.json @@ -4,7 +4,6 @@ "version": "0.1.12", "dependencies": { "@electric-sql/client": "1.0.9", - "@standard-schema/spec": "^1.0.0", "@tanstack/db": "workspace:*", "@tanstack/store": "^0.7.0", "debug": "^4.4.1" diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 6655020c4..5c5c14949 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -13,14 +13,17 @@ import { TimeoutWaitingForTxIdError, } from "./errors" import type { + BaseCollectionConfig, CollectionConfig, + DeleteMutationFn, DeleteMutationFnParams, InsertMutationFnParams, + ResolveInput, + ResolveType, SyncConfig, UpdateMutationFnParams, UtilsRecord, } from "@tanstack/db" -import type { StandardSchemaV1 } from "@standard-schema/spec" import type { ControlMessage, GetExtensions, @@ -36,216 +39,23 @@ const debug = DebugModule.debug(`ts/db:electric`) */ export type Txid = number -// The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package -// but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row` -// This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema -type InferSchemaOutput = T extends StandardSchemaV1 - ? StandardSchemaV1.InferOutput extends Row - ? StandardSchemaV1.InferOutput - : Record - : Record - -type ResolveType< - TExplicit extends Row = Row, - TSchema extends StandardSchemaV1 = never, - TFallback extends object = Record, -> = - unknown extends GetExtensions - ? [TSchema] extends [never] - ? TFallback - : InferSchemaOutput - : TExplicit - /** * Configuration interface for Electric collection options - * @template TExplicit - The explicit type of items in the collection (highest priority) - * @template TSchema - The schema type for validation and type inference (second priority) - * @template TFallback - The fallback type if no explicit or schema type is provided - * - * @remarks - * Type resolution follows a priority order: - * 1. If you provide an explicit type via generic parameter, it will be used - * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred - * 3. If neither explicit type nor schema is provided, the fallback type will be used - * - * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict. + * @template TExplicit - The explicit type of items in the collection (second priority) + * @template TSchema - The schema type for validation and type inference (highest priority) + * @template TKey - The type of the key returned by getKey */ export interface ElectricCollectionConfig< TExplicit extends Row = Row, - TSchema extends StandardSchemaV1 = never, - TFallback extends Row = Row, -> { + TKey extends string | number = string | number, + TSchema = never, +> extends BaseCollectionConfig { /** * Configuration options for the ElectricSQL ShapeStream */ shapeOptions: ShapeStreamOptions< - GetExtensions> + GetExtensions>> > - - /** - * All standard Collection configuration properties - */ - id?: string - schema?: TSchema - getKey: CollectionConfig>[`getKey`] - sync?: CollectionConfig>[`sync`] - - /** - * Optional asynchronous handler function called before an insert operation - * Must return an object containing a txid number or array of txids - * @param params Object containing transaction and collection information - * @returns Promise resolving to an object with txid or txids - * @example - * // Basic Electric insert handler - MUST return { txid: number } - * onInsert: async ({ transaction }) => { - * const newItem = transaction.mutations[0].modified - * const result = await api.todos.create({ - * data: newItem - * }) - * return { txid: result.txid } // Required for Electric sync matching - * } - * - * @example - * // Insert handler with multiple items - return array of txids - * onInsert: async ({ transaction }) => { - * const items = transaction.mutations.map(m => m.modified) - * const results = await Promise.all( - * items.map(item => api.todos.create({ data: item })) - * ) - * return { txid: results.map(r => r.txid) } // Array of txids - * } - * - * @example - * // Insert handler with error handling - * onInsert: async ({ transaction }) => { - * try { - * const newItem = transaction.mutations[0].modified - * const result = await api.createTodo(newItem) - * return { txid: result.txid } - * } catch (error) { - * console.error('Insert failed:', error) - * throw error // This will cause the transaction to fail - * } - * } - * - * @example - * // Insert handler with batch operation - single txid - * onInsert: async ({ transaction }) => { - * const items = transaction.mutations.map(m => m.modified) - * const result = await api.todos.createMany({ - * data: items - * }) - * return { txid: result.txid } // Single txid for batch operation - * } - */ - onInsert?: ( - params: InsertMutationFnParams> - ) => Promise<{ txid: Txid | Array }> - - /** - * Optional asynchronous handler function called before an update operation - * Must return an object containing a txid number or array of txids - * @param params Object containing transaction and collection information - * @returns Promise resolving to an object with txid or txids - * @example - * // Basic Electric update handler - MUST return { txid: number } - * onUpdate: async ({ transaction }) => { - * const { original, changes } = transaction.mutations[0] - * const result = await api.todos.update({ - * where: { id: original.id }, - * data: changes // Only the changed fields - * }) - * return { txid: result.txid } // Required for Electric sync matching - * } - * - * @example - * // Update handler with multiple items - return array of txids - * onUpdate: async ({ transaction }) => { - * const updates = await Promise.all( - * transaction.mutations.map(m => - * api.todos.update({ - * where: { id: m.original.id }, - * data: m.changes - * }) - * ) - * ) - * return { txid: updates.map(u => u.txid) } // Array of txids - * } - * - * @example - * // Update handler with optimistic rollback - * onUpdate: async ({ transaction }) => { - * const mutation = transaction.mutations[0] - * try { - * const result = await api.updateTodo(mutation.original.id, mutation.changes) - * return { txid: result.txid } - * } catch (error) { - * // Transaction will automatically rollback optimistic changes - * console.error('Update failed, rolling back:', error) - * throw error - * } - * } - */ - onUpdate?: ( - params: UpdateMutationFnParams> - ) => Promise<{ txid: Txid | Array }> - - /** - * Optional asynchronous handler function called before a delete operation - * Must return an object containing a txid number or array of txids - * @param params Object containing transaction and collection information - * @returns Promise resolving to an object with txid or txids - * @example - * // Basic Electric delete handler - MUST return { txid: number } - * onDelete: async ({ transaction }) => { - * const mutation = transaction.mutations[0] - * const result = await api.todos.delete({ - * id: mutation.original.id - * }) - * return { txid: result.txid } // Required for Electric sync matching - * } - * - * @example - * // Delete handler with multiple items - return array of txids - * onDelete: async ({ transaction }) => { - * const deletes = await Promise.all( - * transaction.mutations.map(m => - * api.todos.delete({ - * where: { id: m.key } - * }) - * ) - * ) - * return { txid: deletes.map(d => d.txid) } // Array of txids - * } - * - * @example - * // Delete handler with batch operation - single txid - * onDelete: async ({ transaction }) => { - * const idsToDelete = transaction.mutations.map(m => m.original.id) - * const result = await api.todos.deleteMany({ - * ids: idsToDelete - * }) - * return { txid: result.txid } // Single txid for batch operation - * } - * - * @example - * // Delete handler with optimistic rollback - * onDelete: async ({ transaction }) => { - * const mutation = transaction.mutations[0] - * try { - * const result = await api.deleteTodo(mutation.original.id) - * return { txid: result.txid } - * } catch (error) { - * // Transaction will automatically rollback optimistic changes - * console.error('Delete failed, rolling back:', error) - * throw error - * } - * } - * - */ - onDelete?: ( - params: DeleteMutationFnParams> - ) => Promise<{ txid: Txid | Array }> } function isUpToDateMessage>( @@ -282,24 +92,31 @@ export interface ElectricCollectionUtils extends UtilsRecord { /** * Creates Electric collection options for use with a standard Collection * - * @template TExplicit - The explicit type of items in the collection (highest priority) - * @template TSchema - The schema type for validation and type inference (second priority) - * @template TFallback - The fallback type if no explicit or schema type is provided + * @template TExplicit - The explicit type of items in the collection (second priority) + * @template TSchema - The schema type for validation and type inference (highest priority) + * @template TKey - The type of the key returned by getKey * @param config - Configuration options for the Electric collection * @returns Collection options with utilities */ export function electricCollectionOptions< TExplicit extends Row = Row, - TSchema extends StandardSchemaV1 = never, - TFallback extends Row = Row, ->(config: ElectricCollectionConfig) { + TKey extends string | number = string | number, + TSchema = never, +>( + config: ElectricCollectionConfig +): CollectionConfig< + ResolveType>, + TKey, + TSchema, + ResolveInput> +> & { utils: ElectricCollectionUtils } { const seenTxids = new Store>(new Set([])) - const sync = createElectricSync>( - config.shapeOptions, - { - seenTxids, - } - ) + const sync = createElectricSync< + ResolveType>, + TKey + >(config.shapeOptions, { + seenTxids, + }) /** * Wait for a specific transaction ID to be synced @@ -340,12 +157,12 @@ export function electricCollectionOptions< const wrappedOnInsert = config.onInsert ? async ( params: InsertMutationFnParams< - ResolveType + ResolveType> > ) => { // Runtime check (that doesn't follow type) - // eslint-disable-next-line - const handlerResult = (await config.onInsert!(params)) ?? {} + + const handlerResult = (await config.onInsert!(params as any)) ?? {} const txid = (handlerResult as { txid?: Txid | Array }).txid if (!txid) { @@ -366,12 +183,12 @@ export function electricCollectionOptions< const wrappedOnUpdate = config.onUpdate ? async ( params: UpdateMutationFnParams< - ResolveType + ResolveType> > ) => { // Runtime check (that doesn't follow type) - // eslint-disable-next-line - const handlerResult = (await config.onUpdate!(params)) ?? {} + + const handlerResult = (await config.onUpdate!(params as any)) ?? {} const txid = (handlerResult as { txid?: Txid | Array }).txid if (!txid) { @@ -389,22 +206,26 @@ export function electricCollectionOptions< } : undefined - const wrappedOnDelete = config.onDelete + const wrappedOnDelete: + | DeleteMutationFn>> + | undefined = config.onDelete ? async ( params: DeleteMutationFnParams< - ResolveType + ResolveType> > ) => { - const handlerResult = await config.onDelete!(params) - if (!handlerResult.txid) { + const handlerResult = await config.onDelete!(params as any) + const txid = (handlerResult as { txid?: Txid | Array }).txid + + if (!txid) { throw new ElectricDeleteHandlerMustReturnTxIdError() } // Handle both single txid and array of txids - if (Array.isArray(handlerResult.txid)) { - await Promise.all(handlerResult.txid.map((id) => awaitTxId(id))) + if (Array.isArray(txid)) { + await Promise.all(txid.map((id) => awaitTxId(id))) } else { - await awaitTxId(handlerResult.txid) + await awaitTxId(txid) } return handlerResult @@ -429,18 +250,26 @@ export function electricCollectionOptions< utils: { awaitTxId, }, - } + } as CollectionConfig< + ResolveType>, + TKey, + TSchema, + ResolveInput> + > & { utils: ElectricCollectionUtils } } /** * Internal function to create ElectricSQL sync configuration */ -function createElectricSync>( +function createElectricSync< + T extends Row, + TKey extends string | number, +>( shapeOptions: ShapeStreamOptions>, options: { seenTxids: Store> } -): SyncConfig { +): SyncConfig { const { seenTxids } = options // Store for the relation schema information @@ -475,7 +304,7 @@ function createElectricSync>( let unsubscribeStream: () => void return { - sync: (params: Parameters[`sync`]>[0]) => { + sync: (params: Parameters[`sync`]>[0]) => { const { begin, write, commit, markReady, truncate, collection } = params const stream = new ShapeStream({ ...shapeOptions, diff --git a/packages/electric-db-collection/tests/electric.test-d.ts b/packages/electric-db-collection/tests/electric.test-d.ts index df0f127ba..4d67c3ffe 100644 --- a/packages/electric-db-collection/tests/electric.test-d.ts +++ b/packages/electric-db-collection/tests/electric.test-d.ts @@ -7,19 +7,16 @@ import { gt, } from "@tanstack/db" import { electricCollectionOptions } from "../src/electric" -import type { ElectricCollectionConfig } from "../src/electric" import type { DeleteMutationFnParams, InsertMutationFnParams, ResolveType, UpdateMutationFnParams, } from "@tanstack/db" -import type { Row } from "@electric-sql/client" describe(`Electric collection type resolution tests`, () => { // Define test types type ExplicitType = { id: string; explicit: boolean } - type FallbackType = { id: string; fallback: boolean } // Define a schema const testSchema = z.object({ @@ -29,7 +26,7 @@ describe(`Electric collection type resolution tests`, () => { type SchemaType = z.infer - it(`should prioritize explicit type in ElectricCollectionConfig`, () => { + it(`should use explicit type when provided in ElectricCollectionConfig`, () => { const options = electricCollectionOptions({ shapeOptions: { url: `foo`, @@ -38,13 +35,13 @@ describe(`Electric collection type resolution tests`, () => { getKey: (item) => item.id, }) - type ExpectedType = ResolveType> + type ExpectedType = ResolveType // The getKey function should have the resolved type expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() expectTypeOf().toEqualTypeOf() }) - it(`should use schema type when explicit type is not provided`, () => { + it(`should use schema type when provided in ElectricCollectionConfig`, () => { const options = electricCollectionOptions({ shapeOptions: { url: `foo`, @@ -54,42 +51,17 @@ describe(`Electric collection type resolution tests`, () => { getKey: (item) => item.id, }) - type ExpectedType = ResolveType> + type ExpectedType = ResolveType // The getKey function should have the resolved type expectTypeOf(options.getKey).parameters.toEqualTypeOf<[SchemaType]>() expectTypeOf().toEqualTypeOf() }) - it(`should use fallback type when neither explicit nor schema type is provided`, () => { - const config: ElectricCollectionConfig< - Row, - never, - FallbackType - > = { - shapeOptions: { - url: `foo`, - params: { table: `test_table` }, - }, - getKey: (item) => item.id, - } - - const options = electricCollectionOptions< - Row, - never, - FallbackType - >(config) - - type ExpectedType = ResolveType - // The getKey function should have the resolved type - expectTypeOf(options.getKey).parameters.toEqualTypeOf<[FallbackType]>() - expectTypeOf().toEqualTypeOf() - }) - - it(`should correctly resolve type with all three types provided`, () => { + it(`should correctly resolve type when explicit and schema types are provided`, () => { const options = electricCollectionOptions< ExplicitType, - typeof testSchema, - FallbackType + string, + typeof testSchema >({ shapeOptions: { url: `test_shape`, @@ -99,14 +71,10 @@ describe(`Electric collection type resolution tests`, () => { getKey: (item) => item.id, }) - type ExpectedType = ResolveType< - ExplicitType, - typeof testSchema, - FallbackType - > + type ExpectedType = ResolveType // The getKey function should have the resolved type - expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() - expectTypeOf().toEqualTypeOf() + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[SchemaType]>() + expectTypeOf().toEqualTypeOf() }) it(`should properly type the onInsert, onUpdate, and onDelete handlers`, () => { diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index fdaf56f2b..6021c07f0 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -15,7 +15,6 @@ import type { TransactionWithMutations, } from "@tanstack/db" import type { Message, Row } from "@electric-sql/client" -import type { StandardSchemaV1 } from "@standard-schema/spec" // Mock the ShapeStream module const mockSubscribe = vi.fn() @@ -34,9 +33,9 @@ vi.mock(`@electric-sql/client`, async () => { describe(`Electric Integration`, () => { let collection: Collection< Row, - string | number, + number, ElectricCollectionUtils, - StandardSchemaV1, + undefined, Row > let subscriber: (messages: Array>) => void diff --git a/packages/query-db-collection/src/manual-sync.ts b/packages/query-db-collection/src/manual-sync.ts index f783eb119..7600afdde 100644 --- a/packages/query-db-collection/src/manual-sync.ts +++ b/packages/query-db-collection/src/manual-sync.ts @@ -20,9 +20,9 @@ const activeBatchContexts = new WeakMap< export type SyncOperation< TRow extends object, TKey extends string | number = string | number, - TInsertInput extends object = TRow, + TInput extends object = TRow, > = - | { type: `insert`; data: TInsertInput | Array } + | { type: `insert`; data: TInput | Array } | { type: `update`; data: Partial | Array> } | { type: `delete`; key: TKey | Array } | { type: `upsert`; data: Partial | Array> } @@ -53,11 +53,11 @@ interface NormalizedOperation< function normalizeOperations< TRow extends object, TKey extends string | number = string | number, - TInsertInput extends object = TRow, + TInput extends object = TRow, >( ops: - | SyncOperation - | Array>, + | SyncOperation + | Array>, ctx: SyncContext ): Array> { const operations = Array.isArray(ops) ? ops : [ops] @@ -126,11 +126,11 @@ function validateOperations< export function performWriteOperations< TRow extends object, TKey extends string | number = string | number, - TInsertInput extends object = TRow, + TInput extends object = TRow, >( operations: - | SyncOperation - | Array>, + | SyncOperation + | Array>, ctx: SyncContext ): void { const normalized = normalizeOperations(operations, ctx) @@ -206,7 +206,7 @@ export function performWriteOperations< export function createWriteUtils< TRow extends object, TKey extends string | number = string | number, - TInsertInput extends object = TRow, + TInput extends object = TRow, >(getContext: () => SyncContext | null) { function ensureContext(): SyncContext { const context = getContext() @@ -217,8 +217,8 @@ export function createWriteUtils< } return { - writeInsert(data: TInsertInput | Array) { - const operation: SyncOperation = { + writeInsert(data: TInput | Array) { + const operation: SyncOperation = { type: `insert`, data, } @@ -237,7 +237,7 @@ export function createWriteUtils< }, writeUpdate(data: Partial | Array>) { - const operation: SyncOperation = { + const operation: SyncOperation = { type: `update`, data, } @@ -254,7 +254,7 @@ export function createWriteUtils< }, writeDelete(key: TKey | Array) { - const operation: SyncOperation = { + const operation: SyncOperation = { type: `delete`, key, } @@ -271,7 +271,7 @@ export function createWriteUtils< }, writeUpsert(data: Partial | Array>) { - const operation: SyncOperation = { + const operation: SyncOperation = { type: `upsert`, data, } @@ -300,7 +300,7 @@ export function createWriteUtils< // Set up the batch context for this specific collection const batchContext = { - operations: [] as Array>, + operations: [] as Array>, isActive: true, } activeBatchContexts.set(ctx, batchContext) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 23b58c987..67aab1d4c 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -13,60 +13,49 @@ import type { QueryObserverOptions, } from "@tanstack/query-core" import type { + BaseCollectionConfig, ChangeMessage, CollectionConfig, - DeleteMutationFn, DeleteMutationFnParams, - InsertMutationFn, InsertMutationFnParams, + ResolveInput, + ResolveType, SyncConfig, - UpdateMutationFn, UpdateMutationFnParams, UtilsRecord, } from "@tanstack/db" -import type { StandardSchemaV1 } from "@standard-schema/spec" // Re-export for external use export type { SyncOperation } from "./manual-sync" -// Schema output type inference helper (matches electric.ts pattern) -type InferSchemaOutput = T extends StandardSchemaV1 - ? StandardSchemaV1.InferOutput extends object - ? StandardSchemaV1.InferOutput - : Record - : Record - -// QueryFn return type inference helper -type InferQueryFnOutput = TQueryFn extends ( +// Infer the explicit type from the queryFn return type +type InferExplicit = TQueryFn extends ( context: QueryFunctionContext ) => Promise> ? TItem extends object ? TItem - : Record - : Record - -// Type resolution system with priority order (matches electric.ts pattern) -type ResolveType< - TExplicit extends object | unknown = unknown, - TSchema extends StandardSchemaV1 = never, - TQueryFn = unknown, -> = unknown extends TExplicit - ? [TSchema] extends [never] - ? InferQueryFnOutput - : InferSchemaOutput + : TExplicit : TExplicit +// Resolve the item type from the queryFn return type +type ResolveItemType = ResolveType< + InferExplicit, + TSchema +> + /** * Configuration options for creating a Query Collection - * @template TExplicit - The explicit type of items stored in the collection (highest priority) - * @template TSchema - The schema type for validation and type inference (second priority) + * @template TExplicit - The explicit type of items in the collection (second priority) + * @template TSchema - The schema type for validation and type inference (highest priority) + * @template TKey - The type of the key returned by getKey * @template TQueryFn - The queryFn type for inferring return type (third priority) * @template TError - The type of errors that can occur during queries * @template TQueryKey - The type of the query key */ export interface QueryCollectionConfig< - TExplicit extends object = object, - TSchema extends StandardSchemaV1 = never, + TExplicit extends object = Record, + TKey extends string | number = string | number, + TSchema = never, TQueryFn extends ( context: QueryFunctionContext ) => Promise> = ( @@ -74,7 +63,7 @@ export interface QueryCollectionConfig< ) => Promise>, TError = unknown, TQueryKey extends QueryKey = QueryKey, -> { +> extends BaseCollectionConfig { /** The query key used by TanStack Query to identify this query */ queryKey: TQueryKey /** Function that fetches data from the server. Must return the complete collection state */ @@ -84,7 +73,7 @@ export interface QueryCollectionConfig< ? TQueryFn : ( context: QueryFunctionContext - ) => Promise>> + ) => Promise>> /** The TanStack Query client instance */ queryClient: QueryClient @@ -93,188 +82,34 @@ export interface QueryCollectionConfig< /** Whether the query should automatically run (default: true) */ enabled?: boolean refetchInterval?: QueryObserverOptions< - Array>, + Array>, TError, - Array>, - Array>, + Array>, + Array>, TQueryKey >[`refetchInterval`] retry?: QueryObserverOptions< - Array>, + Array>, TError, - Array>, - Array>, + Array>, + Array>, TQueryKey >[`retry`] retryDelay?: QueryObserverOptions< - Array>, + Array>, TError, - Array>, - Array>, + Array>, + Array>, TQueryKey >[`retryDelay`] staleTime?: QueryObserverOptions< - Array>, + Array>, TError, - Array>, - Array>, + Array>, + Array>, TQueryKey >[`staleTime`] - // Standard Collection configuration properties - /** Unique identifier for the collection */ - id?: string - /** Function to extract the unique key from an item */ - getKey: CollectionConfig>[`getKey`] - /** Schema for validating items */ - schema?: TSchema - sync?: CollectionConfig>[`sync`] - startSync?: CollectionConfig< - ResolveType - >[`startSync`] - - // Direct persistence handlers - /** - * Optional asynchronous handler function called before an insert operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to void or { refetch?: boolean } to control refetching - * @example - * // Basic query collection insert handler - * onInsert: async ({ transaction }) => { - * const newItem = transaction.mutations[0].modified - * await api.createTodo(newItem) - * // Automatically refetches query after insert - * } - * - * @example - * // Insert handler with refetch control - * onInsert: async ({ transaction }) => { - * const newItem = transaction.mutations[0].modified - * await api.createTodo(newItem) - * return { refetch: false } // Skip automatic refetch - * } - * - * @example - * // Insert handler with multiple items - * onInsert: async ({ transaction }) => { - * const items = transaction.mutations.map(m => m.modified) - * await api.createTodos(items) - * // Will refetch query to get updated data - * } - * - * @example - * // Insert handler with error handling - * onInsert: async ({ transaction }) => { - * try { - * const newItem = transaction.mutations[0].modified - * await api.createTodo(newItem) - * } catch (error) { - * console.error('Insert failed:', error) - * throw error // Transaction will rollback optimistic changes - * } - * } - */ - onInsert?: InsertMutationFn> - - /** - * Optional asynchronous handler function called before an update operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to void or { refetch?: boolean } to control refetching - * @example - * // Basic query collection update handler - * onUpdate: async ({ transaction }) => { - * const mutation = transaction.mutations[0] - * await api.updateTodo(mutation.original.id, mutation.changes) - * // Automatically refetches query after update - * } - * - * @example - * // Update handler with multiple items - * onUpdate: async ({ transaction }) => { - * const updates = transaction.mutations.map(m => ({ - * id: m.key, - * changes: m.changes - * })) - * await api.updateTodos(updates) - * // Will refetch query to get updated data - * } - * - * @example - * // Update handler with manual refetch - * onUpdate: async ({ transaction, collection }) => { - * const mutation = transaction.mutations[0] - * await api.updateTodo(mutation.original.id, mutation.changes) - * - * // Manually trigger refetch - * await collection.utils.refetch() - * - * return { refetch: false } // Skip automatic refetch - * } - * - * @example - * // Update handler with related collection refetch - * onUpdate: async ({ transaction, collection }) => { - * const mutation = transaction.mutations[0] - * await api.updateTodo(mutation.original.id, mutation.changes) - * - * // Refetch related collections when this item changes - * await Promise.all([ - * collection.utils.refetch(), // Refetch this collection - * usersCollection.utils.refetch(), // Refetch users - * tagsCollection.utils.refetch() // Refetch tags - * ]) - * - * return { refetch: false } // Skip automatic refetch since we handled it manually - * } - */ - onUpdate?: UpdateMutationFn> - - /** - * Optional asynchronous handler function called before a delete operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to void or { refetch?: boolean } to control refetching - * @example - * // Basic query collection delete handler - * onDelete: async ({ transaction }) => { - * const mutation = transaction.mutations[0] - * await api.deleteTodo(mutation.original.id) - * // Automatically refetches query after delete - * } - * - * @example - * // Delete handler with refetch control - * onDelete: async ({ transaction }) => { - * const mutation = transaction.mutations[0] - * await api.deleteTodo(mutation.original.id) - * return { refetch: false } // Skip automatic refetch - * } - * - * @example - * // Delete handler with multiple items - * onDelete: async ({ transaction }) => { - * const keysToDelete = transaction.mutations.map(m => m.key) - * await api.deleteTodos(keysToDelete) - * // Will refetch query to get updated data - * } - * - * @example - * // Delete handler with related collection refetch - * onDelete: async ({ transaction, collection }) => { - * const mutation = transaction.mutations[0] - * await api.deleteTodo(mutation.original.id) - * - * // Refetch related collections when this item is deleted - * await Promise.all([ - * collection.utils.refetch(), // Refetch this collection - * usersCollection.utils.refetch(), // Refetch users - * projectsCollection.utils.refetch() // Refetch projects - * ]) - * - * return { refetch: false } // Skip automatic refetch since we handled it manually - * } - */ - onDelete?: DeleteMutationFn> - /** * Metadata to pass to the query. * Available in queryFn via context.meta @@ -308,17 +143,17 @@ export type RefetchFn = () => Promise * Direct writes bypass the normal query/mutation flow and write directly to the synced data store. * @template TItem - The type of items stored in the collection * @template TKey - The type of the item keys - * @template TInsertInput - The type accepted for insert operations + * @template TInput - The type accepted for insert operations */ export interface QueryCollectionUtils< TItem extends object = Record, TKey extends string | number = string | number, - TInsertInput extends object = TItem, + TInput extends object = TItem, > extends UtilsRecord { /** Manually trigger a refetch of the query */ refetch: RefetchFn /** Insert one or more items directly into the synced data store without triggering a query refetch or optimistic update */ - writeInsert: (data: TInsertInput | Array) => void + writeInsert: (data: TInput | Array) => void /** Update one or more items directly in the synced data store without triggering a query refetch or optimistic update */ writeUpdate: (updates: Partial | Array>) => void /** Delete one or more items directly from the synced data store without triggering a query refetch or optimistic update */ @@ -345,7 +180,7 @@ export interface QueryCollectionUtils< * @template TError - The type of errors that can occur during queries * @template TQueryKey - The type of the query key * @template TKey - The type of the item keys - * @template TInsertInput - The type accepted for insert operations + * @template TInput - The type accepted for insert operations * @param config - Configuration options for the Query collection * @returns Collection options with utilities for direct writes and manual operations * @@ -407,8 +242,9 @@ export interface QueryCollectionUtils< * ) */ export function queryCollectionOptions< - TExplicit extends object = object, - TSchema extends StandardSchemaV1 = never, + TExplicit extends object = Record, + TKey extends string | number = string | number, + TSchema = never, TQueryFn extends ( context: QueryFunctionContext ) => Promise> = ( @@ -416,18 +252,32 @@ export function queryCollectionOptions< ) => Promise>, TError = unknown, TQueryKey extends QueryKey = QueryKey, - TKey extends string | number = string | number, - TInsertInput extends object = ResolveType, + TInput extends object = ResolveInput< + InferExplicit, + TSchema + >, >( - config: QueryCollectionConfig -): CollectionConfig> & { + config: QueryCollectionConfig< + TExplicit, + TKey, + TSchema, + TQueryFn, + TError, + TQueryKey + > +): CollectionConfig< + InferExplicit, + TKey, + TSchema, + TInput +> & { utils: QueryCollectionUtils< - ResolveType, + ResolveItemType, TKey, - TInsertInput + TInput > } { - type TItem = ResolveType + type TItem = ResolveItemType const { queryKey, @@ -467,7 +317,7 @@ export function queryCollectionOptions< throw new GetKeyRequiredError() } - const internalSync: SyncConfig[`sync`] = (params) => { + const internalSync: SyncConfig[`sync`] = (params) => { const { begin, write, commit, markReady, collection } = params const observerOptions: QueryObserverOptions< @@ -514,7 +364,7 @@ export function queryCollectionOptions< } const currentSyncedItems = new Map(collection.syncedData) - const newItemsMap = new Map() + const newItemsMap = new Map() newItemsArray.forEach((item) => { const key = getKey(item) newItemsMap.set(key, item) @@ -608,12 +458,12 @@ export function queryCollectionOptions< queryKey: Array getKey: (item: TItem) => TKey begin: () => void - write: (message: Omit, `key`>) => void + write: (message: Omit, `key`>) => void commit: () => void } | null = null // Enhanced internalSync that captures write functions for manual use - const enhancedInternalSync: SyncConfig[`sync`] = (params) => { + const enhancedInternalSync: SyncConfig[`sync`] = (params) => { const { begin, write, commit, collection } = params // Store references for manual write operations @@ -632,14 +482,12 @@ export function queryCollectionOptions< } // Create write utils using the manual-sync module - const writeUtils = createWriteUtils( - () => writeContext - ) + const writeUtils = createWriteUtils(() => writeContext) // Create wrapper handlers for direct persistence operations that handle refetching const wrappedOnInsert = onInsert - ? async (params: InsertMutationFnParams) => { - const handlerResult = (await onInsert(params)) ?? {} + ? async (params: InsertMutationFnParams) => { + const handlerResult = (await onInsert(params as any)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -652,8 +500,8 @@ export function queryCollectionOptions< : undefined const wrappedOnUpdate = onUpdate - ? async (params: UpdateMutationFnParams) => { - const handlerResult = (await onUpdate(params)) ?? {} + ? async (params: UpdateMutationFnParams) => { + const handlerResult = (await onUpdate(params as any)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -666,8 +514,8 @@ export function queryCollectionOptions< : undefined const wrappedOnDelete = onDelete - ? async (params: DeleteMutationFnParams) => { - const handlerResult = (await onDelete(params)) ?? {} + ? async (params: DeleteMutationFnParams) => { + const handlerResult = (await onDelete(params as any)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 5d639c28d..6db812f58 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -187,6 +187,36 @@ describe(`Query collection type resolution tests`, () => { expectTypeOf(queryOptions.getKey).parameters.toEqualTypeOf<[UserType]>() }) + it(`types getKey param as schema OUTPUT and onInsert as schema INPUT`, () => { + const schema = z.object({ + id: z.string(), + created_at: z.string().transform((s) => new Date(s)), + updated_at: z.string().transform((s) => new Date(s)), + }) + + type Output = z.output + type Input = z.input + + const testQueryClient = new QueryClient() + + const options = queryCollectionOptions({ + queryClient: testQueryClient, + queryKey: [`users`], + queryFn: () => Promise.resolve([] as Array), + schema, + getKey: (item) => item.id, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + }) + + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[Output]>() + // utils.writeInsert should accept schema INPUT + type WriteInsertParam0 = Parameters< + (typeof options.utils)[`writeInsert`] + >[0] + expectTypeOf().toEqualTypeOf>() + }) + describe(`QueryFn type inference`, () => { interface TodoType { id: string @@ -198,8 +228,8 @@ describe(`Query collection type resolution tests`, () => { const options = queryCollectionOptions({ queryClient, queryKey: [`queryfn-inference`], - queryFn: async (): Promise> => { - return [] as Array + queryFn: (): Promise> => { + return Promise.resolve([] as Array) }, getKey: (item) => item.id, }) @@ -217,8 +247,8 @@ describe(`Query collection type resolution tests`, () => { const options = queryCollectionOptions({ queryClient, queryKey: [`explicit-priority`], - queryFn: async (): Promise> => { - return [] as Array + queryFn: (): Promise> => { + return Promise.resolve([] as Array) }, getKey: (item) => item.id, }) @@ -237,8 +267,8 @@ describe(`Query collection type resolution tests`, () => { const options = queryCollectionOptions({ queryClient, queryKey: [`schema-priority`], - queryFn: async (): Promise>> => { - return [] as Array> + queryFn: (): Promise>> => { + return Promise.resolve([] as Array>) }, schema: userSchema, getKey: (item) => item.id, @@ -253,7 +283,7 @@ describe(`Query collection type resolution tests`, () => { const options = queryCollectionOptions({ queryClient, queryKey: [`backward-compat`], - queryFn: async () => [] as Array, + queryFn: () => Promise.resolve([] as Array), getKey: (item) => item.id, }) @@ -264,8 +294,8 @@ describe(`Query collection type resolution tests`, () => { const options = queryCollectionOptions({ queryClient, queryKey: [`collection-test`], - queryFn: async (): Promise> => { - return [] as Array + queryFn: (): Promise> => { + return Promise.resolve([] as Array) }, getKey: (item) => item.id, }) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 88519ec1c..a9c9f66e0 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -84,11 +84,11 @@ function convertPartial< * Configuration interface for Trailbase Collection */ export interface TrailBaseCollectionConfig< - TItem extends ShapeOf, - TRecord extends ShapeOf = TItem, + TExplicit extends ShapeOf, + TRecord extends ShapeOf = TExplicit, TKey extends string | number = string | number, > extends Omit< - CollectionConfig, + CollectionConfig, `sync` | `onInsert` | `onUpdate` | `onDelete` > { /** @@ -96,8 +96,8 @@ export interface TrailBaseCollectionConfig< */ recordApi: RecordApi - parse: Conversions - serialize: Conversions + parse: Conversions + serialize: Conversions } export type AwaitTxIdFn = (txId: string, timeout?: number) => Promise @@ -107,20 +107,20 @@ export interface TrailBaseCollectionUtils extends UtilsRecord { } export function trailBaseCollectionOptions< - TItem extends ShapeOf, - TRecord extends ShapeOf = TItem, + TExplicit extends ShapeOf, + TRecord extends ShapeOf = TExplicit, TKey extends string | number = string | number, >( - config: TrailBaseCollectionConfig -): CollectionConfig & { utils: TrailBaseCollectionUtils } { + config: TrailBaseCollectionConfig +): CollectionConfig & { utils: TrailBaseCollectionUtils } { const getKey = config.getKey const parse = (record: TRecord) => - convert(config.parse, record) - const serialUpd = (item: Partial) => - convertPartial(config.serialize, item) - const serialIns = (item: TItem) => - convert(config.serialize, item) + convert(config.parse, record) + const serialUpd = (item: Partial) => + convertPartial(config.serialize, item) + const serialIns = (item: TExplicit) => + convert(config.serialize, item) const seenIds = new Store(new Map()) @@ -159,7 +159,7 @@ export function trailBaseCollectionOptions< } } - type SyncParams = Parameters[`sync`]>[0] + type SyncParams = Parameters[`sync`]>[0] const sync = { sync: (params: SyncParams) => { const { begin, write, commit, markReady } = params @@ -216,7 +216,7 @@ export function trailBaseCollectionOptions< } begin() - let value: TItem | undefined + let value: TExplicit | undefined if (`Insert` in event) { value = parse(event.Insert as TRecord) write({ type: `insert`, value }) @@ -294,7 +294,7 @@ export function trailBaseCollectionOptions< sync, getKey, onInsert: async ( - params: InsertMutationFnParams + params: InsertMutationFnParams ): Promise> => { const ids = await config.recordApi.createBulk( params.transaction.mutations.map((tx) => { @@ -313,7 +313,7 @@ export function trailBaseCollectionOptions< return ids }, - onUpdate: async (params: UpdateMutationFnParams) => { + onUpdate: async (params: UpdateMutationFnParams) => { const ids: Array = await Promise.all( params.transaction.mutations.map(async (tx) => { const { type, changes, key } = tx @@ -332,7 +332,7 @@ export function trailBaseCollectionOptions< // DB by the subscription. await awaitIds(ids) }, - onDelete: async (params: DeleteMutationFnParams) => { + onDelete: async (params: DeleteMutationFnParams) => { const ids: Array = await Promise.all( params.transaction.mutations.map(async (tx) => { const { type, key } = tx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cb31dcbb..657be7221 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -520,9 +520,6 @@ importers: '@electric-sql/client': specifier: 1.0.9 version: 1.0.9 - '@standard-schema/spec': - specifier: ^1.0.0 - version: 1.0.0 '@tanstack/db': specifier: workspace:* version: link:../db