diff --git a/.changeset/big-news-happen.md b/.changeset/big-news-happen.md new file mode 100644 index 000000000..c249f36b4 --- /dev/null +++ b/.changeset/big-news-happen.md @@ -0,0 +1,7 @@ +--- +"@tanstack/electric-db-collection": patch +"@tanstack/query-db-collection": patch +"@tanstack/db": patch +--- + +Refactor of the types of collection config factories for better type inference. diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index ab9c57c97..dc4cc9f2a 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -48,12 +48,12 @@ import type { CollectionStatus, CurrentStateAsChangesOptions, Fn, + InferSchemaInput, + InferSchemaOutput, InsertConfig, OperationConfig, OptimisticChangeMessage, PendingMutation, - ResolveInsertInput, - ResolveType, StandardSchema, SubscribeChangesOptions, Transaction as TransactionType, @@ -91,11 +91,9 @@ export interface Collection< /** * Creates a new Collection instance with the given configuration * - * @template TExplicit - The explicit type of items in the collection (highest priority) + * @template T - The schema type if a schema is provided, otherwise the type of items in the collection * @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 * @param options - Collection options with optional utilities * @returns A new Collection with utilities exposed both at top level and under .utils * @@ -159,142 +157,72 @@ export interface Collection< * sync: { sync: () => {} } * }) * - * // 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 +// Overload for when schema is provided export function createCollection< - TSchema extends StandardSchemaV1, + T extends StandardSchemaV1, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, - TFallback extends object = Record, >( - options: CollectionConfig< - ResolveType, - TKey, - TSchema, - ResolveInsertInput - > & { - schema: TSchema + options: CollectionConfig, TKey, T> & { + schema: T utils?: TUtils } -): Collection< - ResolveType, - TKey, - TUtils, - TSchema, - ResolveInsertInput -> - -// Overload for when explicit type is provided with schema - explicit type takes precedence +): Collection, TKey, TUtils, T, InferSchemaInput> + +// Overload for when no schema is provided +// the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config export function createCollection< - TExplicit extends object, + T extends object, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TFallback extends object = Record, >( - options: CollectionConfig< - ResolveType, - TKey, - TSchema, - ResolveInsertInput - > & { - schema: TSchema + options: CollectionConfig & { + schema?: never // prohibit schema if an explicit type is provided utils?: TUtils } -): Collection< - ResolveType, - TKey, - TUtils, - TSchema, - ResolveInsertInput -> - -// Overload for when explicit type is provided or no schema -export function createCollection< - TExplicit = unknown, - TKey extends string | number = string | number, - TUtils extends UtilsRecord = {}, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TFallback extends object = Record, ->( - options: CollectionConfig< - ResolveType, - TKey, - TSchema, - ResolveInsertInput - > & { utils?: TUtils } -): Collection< - ResolveType, - TKey, - TUtils, - TSchema, - ResolveInsertInput -> +): Collection // 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 -> { - const collection = new CollectionImpl< - ResolveType, - TKey, - TUtils, - TSchema, - ResolveInsertInput - >(options) +export function createCollection( + options: CollectionConfig & { + schema?: StandardSchemaV1 + utils?: UtilsRecord + } +): Collection { + const collection = new CollectionImpl( + options + ) // Copy utils to both top level and .utils namespace if (options.utils) { collection.utils = { ...options.utils } } else { - collection.utils = {} as TUtils + collection.utils = {} } - return collection as Collection< - ResolveType, - TKey, - TUtils, - TSchema, - ResolveInsertInput - > + return collection } export class CollectionImpl< - T extends object = Record, + TOutput extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, TSchema extends StandardSchemaV1 = StandardSchemaV1, - TInsertInput extends object = T, + TInput extends object = TOutput, > { - public config: CollectionConfig + public config: CollectionConfig // Core state - make public for testing public transactions: SortedMap> - public pendingSyncedTransactions: Array> = [] - public syncedData: Map | SortedMap + public pendingSyncedTransactions: Array> = + [] + public syncedData: Map | SortedMap public syncedMetadata = new Map() // Optimistic state tracking - make public for testing - public optimisticUpserts = new Map() + public optimisticUpserts = new Map() public optimisticDeletes = new Set() // Cached size for performance @@ -307,8 +235,11 @@ export class CollectionImpl< private indexCounter = 0 // Event system - private changeListeners = new Set>() - private changeKeyListeners = new Map>>() + private changeListeners = new Set>() + private changeKeyListeners = new Map< + TKey, + Set> + >() // Utilities namespace // This is populated by createCollection @@ -316,7 +247,7 @@ export class CollectionImpl< // State used for computing the change events private syncedKeys = new Set() - private preSyncVisibleState = new Map() + private preSyncVisibleState = new Map() private recentlySyncedKeys = new Set() private hasReceivedFirstCommit = false private isCommittingSyncTransactions = false @@ -326,7 +257,7 @@ export class CollectionImpl< private hasBeenReady = false // Event batching for preventing duplicate emissions during transaction flows - private batchedEvents: Array> = [] + private batchedEvents: Array> = [] private shouldBatchEvents = false // Lifecycle management @@ -481,7 +412,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() @@ -509,9 +440,9 @@ export class CollectionImpl< // Set up data storage with optional comparison function if (this.config.compare) { - this.syncedData = new SortedMap(this.config.compare) + this.syncedData = new SortedMap(this.config.compare) } else { - this.syncedData = new Map() + this.syncedData = new Map() } // Only start sync immediately if explicitly enabled @@ -549,7 +480,7 @@ export class CollectionImpl< deletedKeys: new Set(), }) }, - write: (messageWithoutKey: Omit, `key`>) => { + write: (messageWithoutKey: Omit, `key`>) => { const pendingTransaction = this.pendingSyncedTransactions[ this.pendingSyncedTransactions.length - 1 @@ -578,7 +509,7 @@ export class CollectionImpl< } } - const message: ChangeMessage = { + const message: ChangeMessage = { ...messageWithoutKey, key, } @@ -831,7 +762,10 @@ export class CollectionImpl< switch (mutation.type) { case `insert`: case `update`: - this.optimisticUpserts.set(mutation.key, mutation.modified as T) + this.optimisticUpserts.set( + mutation.key, + mutation.modified as TOutput + ) this.optimisticDeletes.delete(mutation.key) break case `delete`: @@ -847,7 +781,7 @@ export class CollectionImpl< this._size = this.calculateSize() // Collect events for changes - const events: Array> = [] + const events: Array> = [] this.collectOptimisticChanges(previousState, previousDeletes, events) // Filter out events for recently synced keys to prevent duplicates @@ -934,9 +868,9 @@ export class CollectionImpl< * Collect events for optimistic changes */ private collectOptimisticChanges( - previousUpserts: Map, + previousUpserts: Map, previousDeletes: Set, - events: Array> + events: Array> ): void { const allKeys = new Set([ ...previousUpserts.keys(), @@ -977,9 +911,9 @@ export class CollectionImpl< */ private getPreviousValue( key: TKey, - previousUpserts: Map, + previousUpserts: Map, previousDeletes: Set - ): T | undefined { + ): TOutput | undefined { if (previousDeletes.has(key)) { return undefined } @@ -1010,7 +944,7 @@ export class CollectionImpl< * Emit events either immediately or batch them for later emission */ private emitEvents( - changes: Array>, + changes: Array>, forceEmit = false ): void { // Skip batching for user actions (forceEmit=true) to keep UI responsive @@ -1040,7 +974,7 @@ export class CollectionImpl< // Emit to key-specific listeners if (this.changeKeyListeners.size > 0) { // Group changes by key, but only for keys that have listeners - const changesByKey = new Map>>() + const changesByKey = new Map>>() for (const change of eventsToEmit) { if (this.changeKeyListeners.has(change.key)) { if (!changesByKey.has(change.key)) { @@ -1063,7 +997,7 @@ export class CollectionImpl< /** * Get the current value for a key (virtual derived state) */ - public get(key: TKey): T | undefined { + public get(key: TKey): TOutput | undefined { // Check if optimistically deleted if (this.optimisticDeletes.has(key)) { return undefined @@ -1126,7 +1060,7 @@ export class CollectionImpl< /** * Get all values (virtual derived state) */ - public *values(): IterableIterator { + public *values(): IterableIterator { for (const key of this.keys()) { const value = this.get(key) if (value !== undefined) { @@ -1138,7 +1072,7 @@ export class CollectionImpl< /** * Get all entries (virtual derived state) */ - public *entries(): IterableIterator<[TKey, T]> { + public *entries(): IterableIterator<[TKey, TOutput]> { for (const key of this.keys()) { const value = this.get(key) if (value !== undefined) { @@ -1150,7 +1084,7 @@ export class CollectionImpl< /** * Get all entries (virtual derived state) */ - public *[Symbol.iterator](): IterableIterator<[TKey, T]> { + public *[Symbol.iterator](): IterableIterator<[TKey, TOutput]> { for (const [key, value] of this.entries()) { yield [key, value] } @@ -1160,7 +1094,7 @@ export class CollectionImpl< * Execute a callback for each entry in the collection */ public forEach( - callbackfn: (value: T, key: TKey, index: number) => void + callbackfn: (value: TOutput, key: TKey, index: number) => void ): void { let index = 0 for (const [key, value] of this.entries()) { @@ -1172,7 +1106,7 @@ export class CollectionImpl< * Create a new array with the results of calling a function for each entry in the collection */ public map( - callbackfn: (value: T, key: TKey, index: number) => U + callbackfn: (value: TOutput, key: TKey, index: number) => U ): Array { const result: Array = [] let index = 0 @@ -1215,8 +1149,12 @@ export class CollectionImpl< return acc }, { - committedSyncedTransactions: [] as Array>, - uncommittedSyncedTransactions: [] as Array>, + committedSyncedTransactions: [] as Array< + PendingSyncedTransaction + >, + uncommittedSyncedTransactions: [] as Array< + PendingSyncedTransaction + >, hasTruncateSync: false, } ) @@ -1238,7 +1176,7 @@ export class CollectionImpl< let currentVisibleState = this.preSyncVisibleState if (currentVisibleState.size === 0) { // No pre-captured state, capture it now for pure sync operations - currentVisibleState = new Map() + currentVisibleState = new Map() for (const key of changedKeys) { const currentValue = this.get(key) if (currentValue !== undefined) { @@ -1247,7 +1185,7 @@ export class CollectionImpl< } } - const events: Array> = [] + const events: Array> = [] const rowUpdateMode = this.config.sync.rowUpdateMode || `partial` for (const transaction of committedSyncedTransactions) { @@ -1346,7 +1284,7 @@ export class CollectionImpl< // Build re-apply sets from ACTIVE optimistic transactions against the new synced base // We do not copy maps; we compute intent directly from transactions to avoid drift. - const reapplyUpserts = new Map() + const reapplyUpserts = new Map() const reapplyDeletes = new Set() for (const tx of this.transactions.values()) { @@ -1356,14 +1294,14 @@ export class CollectionImpl< const key = mutation.key as TKey switch (mutation.type) { case `insert`: - reapplyUpserts.set(key, mutation.modified as T) + reapplyUpserts.set(key, mutation.modified as TOutput) reapplyDeletes.delete(key) break case `update`: { const base = this.syncedData.get(key) const next = base - ? (Object.assign({}, base, mutation.changes) as T) - : (mutation.modified as T) + ? (Object.assign({}, base, mutation.changes) as TOutput) + : (mutation.modified as TOutput) reapplyUpserts.set(key, next) reapplyDeletes.delete(key) break @@ -1401,7 +1339,7 @@ export class CollectionImpl< // Finally, ensure we do NOT insert keys that have an outstanding optimistic delete. if (events.length > 0 && reapplyDeletes.size > 0) { - const filtered: Array> = [] + const filtered: Array> = [] for (const evt of events) { if (evt.type === `insert` && reapplyDeletes.has(evt.key)) { continue @@ -1435,7 +1373,7 @@ export class CollectionImpl< case `update`: this.optimisticUpserts.set( mutation.key, - mutation.modified as T + mutation.modified as TOutput ) this.optimisticDeletes.delete(mutation.key) break @@ -1566,16 +1504,16 @@ export class CollectionImpl< }) } - private ensureStandardSchema(schema: unknown): StandardSchema { + private ensureStandardSchema(schema: unknown): StandardSchema { // If the schema already implements the standard-schema interface, return it if (schema && `~standard` in (schema as {})) { - return schema as StandardSchema + return schema as StandardSchema } throw new InvalidSchemaError() } - public getKeyFromItem(item: T): TKey { + public getKeyFromItem(item: TOutput): TKey { return this.config.getKey(item) } @@ -1618,13 +1556,13 @@ export class CollectionImpl< * }) */ public createIndex = typeof BTreeIndex>( - indexCallback: (row: SingleRowRefProxy) => any, + indexCallback: (row: SingleRowRefProxy) => any, config: IndexOptions = {} ): IndexProxy { this.validateCollectionUsable(`createIndex`) const indexId = ++this.indexCounter - const singleRowRefProxy = createSingleRowRefProxy() + const singleRowRefProxy = createSingleRowRefProxy() const indexExpression = indexCallback(singleRowRefProxy) const expression = toExpression(indexExpression) @@ -1720,7 +1658,7 @@ export class CollectionImpl< * Updates all indexes when the collection changes * @private */ - private updateIndexes(changes: Array>): void { + private updateIndexes(changes: Array>): void { for (const index of this.resolvedIndexes.values()) { for (const change of changes) { switch (change.type) { @@ -1746,8 +1684,8 @@ export class CollectionImpl< data: unknown, type: `insert` | `update`, key?: TKey - ): T | never { - if (!this.config.schema) return data as T + ): TOutput | never { + if (!this.config.schema) return data as TOutput const standardSchema = this.ensureStandardSchema(this.config.schema) @@ -1782,9 +1720,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 TOutput + const modifiedKeys = Object.keys(data) + const extractedChanges = Object.fromEntries( + modifiedKeys.map((k) => [k, validatedMergedData[k as keyof TOutput]]) + ) as TOutput + + return extractedChanges } } @@ -1805,7 +1748,7 @@ export class CollectionImpl< throw new SchemaValidationError(type, typedIssues) } - return result.value as T + return result.value as TOutput } /** @@ -1844,10 +1787,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() @@ -1857,7 +1797,7 @@ export class CollectionImpl< } const items = Array.isArray(data) ? data : [data] - const mutations: Array> = [] + const mutations: Array> = [] // Create mutations for each item items.forEach((item) => { @@ -1871,7 +1811,7 @@ export class CollectionImpl< } const globalKey = this.generateGlobalKey(key, item) - const mutation: PendingMutation = { + const mutation: PendingMutation = { mutationId: crypto.randomUUID(), original: {}, modified: validatedData, @@ -1883,7 +1823,7 @@ export class CollectionImpl< k, validatedData[k as keyof typeof validatedData], ]) - ) as TInsertInput, + ) as TInput, globalKey, key, metadata: config?.metadata as unknown, @@ -1909,16 +1849,16 @@ export class CollectionImpl< return ambientTransaction } else { // Create a new transaction with a mutation function that calls the onInsert handler - const directOpTransaction = createTransaction({ + const directOpTransaction = createTransaction({ mutationFn: async (params) => { // Call the onInsert handler with the transaction and collection return await this.config.onInsert!({ transaction: params.transaction as unknown as TransactionWithMutations< - TInsertInput, + TOutput, `insert` >, - collection: this as unknown as Collection, + collection: this as unknown as Collection, }) }, }) @@ -1977,37 +1917,40 @@ export class CollectionImpl< */ // Overload 1: Update multiple items with a callback - update( + update( key: Array, - callback: (drafts: Array>) => void + 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 + callback: (drafts: Array>) => void ): TransactionType // Overload 3: Update a single item with a callback - update( + update( id: TKey | unknown, - callback: (draft: WritableDeep) => void + callback: (draft: WritableDeep) => void ): TransactionType // Overload 4: Update a single item with config and a callback - update( + update( id: TKey | unknown, config: OperationConfig, - callback: (draft: WritableDeep) => void + 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) ) { if (typeof keys === `undefined`) { throw new MissingUpdateArgumentError() @@ -2042,25 +1985,25 @@ 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] } // Create mutations for each object that has changes - const mutations: Array> = keysArray + const mutations: Array> = keysArray .map((key, index) => { const itemChanges = changesArray[index] // User-provided changes for this specific item @@ -2069,7 +2012,7 @@ export class CollectionImpl< return null } - const originalItem = currentObjects[index] as unknown as T + const originalItem = currentObjects[index] as unknown as TOutput // Validate the user-provided changes for this item const validatedUpdatePayload = this.validateData( itemChanges, @@ -2098,7 +2041,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, @@ -2113,7 +2065,7 @@ export class CollectionImpl< collection: this, } }) - .filter(Boolean) as Array> + .filter(Boolean) as Array> // If no changes were made, return an empty transaction early if (mutations.length === 0) { @@ -2140,16 +2092,16 @@ export class CollectionImpl< // No need to check for onUpdate handler here as we've already checked at the beginning // Create a new transaction with a mutation function that calls the onUpdate handler - const directOpTransaction = createTransaction({ + const directOpTransaction = createTransaction({ mutationFn: async (params) => { // Call the onUpdate handler with the transaction and collection return this.config.onUpdate!({ transaction: params.transaction as unknown as TransactionWithMutations< - T, + TOutput, `update` >, - collection: this as unknown as Collection, + collection: this as unknown as Collection, }) }, }) @@ -2215,14 +2167,14 @@ export class CollectionImpl< } const keysArray = Array.isArray(keys) ? keys : [keys] - const mutations: Array> = [] + const mutations: Array> = [] for (const key of keysArray) { if (!this.has(key)) { throw new DeleteKeyNotFoundError(key) } const globalKey = this.generateGlobalKey(key, this.get(key)!) - const mutation: PendingMutation = { + const mutation: PendingMutation = { mutationId: crypto.randomUUID(), original: this.get(key)!, modified: this.get(key)!, @@ -2256,17 +2208,17 @@ export class CollectionImpl< } // Create a new transaction with a mutation function that calls the onDelete handler - const directOpTransaction = createTransaction({ + const directOpTransaction = createTransaction({ autoCommit: true, mutationFn: async (params) => { // Call the onDelete handler with the transaction and collection return this.config.onDelete!({ transaction: params.transaction as unknown as TransactionWithMutations< - T, + TOutput, `delete` >, - collection: this as unknown as Collection, + collection: this as unknown as Collection, }) }, }) @@ -2299,7 +2251,7 @@ export class CollectionImpl< * } */ get state() { - const result = new Map() + const result = new Map() for (const [key, value] of this.entries()) { result.set(key, value) } @@ -2312,14 +2264,14 @@ export class CollectionImpl< * * @returns Promise that resolves to a Map containing all items in the collection */ - stateWhenReady(): Promise> { + stateWhenReady(): Promise> { // If we already have data or collection is ready, resolve immediately if (this.size > 0 || this.isReady()) { return Promise.resolve(this.state) } // Otherwise, wait for the collection to be ready - return new Promise>((resolve) => { + return new Promise>((resolve) => { this.onFirstReady(() => { resolve(this.state) }) @@ -2341,14 +2293,14 @@ export class CollectionImpl< * * @returns Promise that resolves to an Array containing all items in the collection */ - toArrayWhenReady(): Promise> { + toArrayWhenReady(): Promise> { // If we already have data or collection is ready, resolve immediately if (this.size > 0 || this.isReady()) { return Promise.resolve(this.toArray) } // Otherwise, wait for the collection to be ready - return new Promise>((resolve) => { + return new Promise>((resolve) => { this.onFirstReady(() => { resolve(this.toArray) }) @@ -2374,8 +2326,8 @@ export class CollectionImpl< * }) */ public currentStateAsChanges( - options: CurrentStateAsChangesOptions = {} - ): Array> { + options: CurrentStateAsChangesOptions = {} + ): Array> { return currentStateAsChanges(this, options) } @@ -2419,8 +2371,8 @@ export class CollectionImpl< * }) */ public subscribeChanges( - callback: (changes: Array>) => void, - options: SubscribeChangesOptions = {} + callback: (changes: Array>) => void, + options: SubscribeChangesOptions = {} ): () => void { // Start sync and track subscriber this.addSubscriber() @@ -2459,7 +2411,7 @@ export class CollectionImpl< */ public subscribeChangesKey( key: TKey, - listener: ChangeListener, + listener: ChangeListener, { includeInitialState = false }: { includeInitialState?: boolean } = {} ): () => void { // Start sync and track subscriber diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index d6590c610..c5b3fdbcd 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -1,9 +1,9 @@ import type { CollectionConfig, DeleteMutationFnParams, + InferSchemaOutput, InsertMutationFnParams, OperationType, - ResolveType, SyncConfig, UpdateMutationFnParams, UtilsRecord, @@ -12,23 +12,13 @@ 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 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. + * @template T - The type of items in the collection + * @template TSchema - The schema type for validation + * @template TKey - The type of the key returned by `getKey` */ export interface LocalOnlyCollectionConfig< - TExplicit = unknown, + T extends object = object, TSchema extends StandardSchemaV1 = never, - TFallback extends Record = Record, TKey extends string | number = string | number, > { /** @@ -36,13 +26,13 @@ export interface LocalOnlyCollectionConfig< */ id?: string schema?: TSchema - getKey: (item: ResolveType) => TKey + getKey: (item: T) => TKey /** * Optional initial data to populate the collection with on creation * This data will be applied during the initial sync process */ - initialData?: Array> + initialData?: Array /** * Optional asynchronous handler function called after an insert operation @@ -50,11 +40,7 @@ export interface LocalOnlyCollectionConfig< * @returns Promise resolving to any value */ onInsert?: ( - params: InsertMutationFnParams< - ResolveType, - TKey, - LocalOnlyCollectionUtils - > + params: InsertMutationFnParams ) => Promise /** @@ -63,11 +49,7 @@ export interface LocalOnlyCollectionConfig< * @returns Promise resolving to any value */ onUpdate?: ( - params: UpdateMutationFnParams< - ResolveType, - TKey, - LocalOnlyCollectionUtils - > + params: UpdateMutationFnParams ) => Promise /** @@ -76,11 +58,7 @@ export interface LocalOnlyCollectionConfig< * @returns Promise resolving to any value */ onDelete?: ( - params: DeleteMutationFnParams< - ResolveType, - TKey, - LocalOnlyCollectionUtils - > + params: DeleteMutationFnParams ) => Promise } @@ -96,9 +74,7 @@ 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 T - The schema type if a schema is provided, otherwise the type of items in the collection * @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) @@ -135,29 +111,55 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord {} * }) * ) */ + +// Overload for when schema is provided export function localOnlyCollectionOptions< - TExplicit = unknown, - TSchema extends StandardSchemaV1 = never, - TFallback extends Record = Record, + T extends StandardSchemaV1, TKey extends string | number = string | number, >( - config: LocalOnlyCollectionConfig -): CollectionConfig, TKey> & { + config: LocalOnlyCollectionConfig, T, TKey> & { + schema: T + } +): CollectionConfig, TKey, T> & { utils: LocalOnlyCollectionUtils -} { - type ResolvedType = ResolveType + schema: T +} +// Overload for when no schema is provided +// the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config +export function localOnlyCollectionOptions< + T extends object, + TKey extends string | number = string | number, +>( + config: LocalOnlyCollectionConfig & { + schema?: never // prohibit schema + } +): CollectionConfig & { + utils: LocalOnlyCollectionUtils + schema?: never // no schema in the result +} + +export function localOnlyCollectionOptions( + config: LocalOnlyCollectionConfig +): CollectionConfig & { + utils: LocalOnlyCollectionUtils + schema?: StandardSchemaV1 +} { const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config // Create the sync configuration with transaction confirmation capability - const syncResult = createLocalOnlySync(initialData) + const syncResult = createLocalOnlySync(initialData) /** * Create wrapper handlers that call user handlers first, then confirm transactions * Wraps the user's onInsert handler to also confirm the transaction immediately */ const wrappedOnInsert = async ( - params: InsertMutationFnParams + params: InsertMutationFnParams< + any, + string | number, + LocalOnlyCollectionUtils + > ) => { // Call user handler first if provided let handlerResult @@ -175,7 +177,11 @@ export function localOnlyCollectionOptions< * Wrapper for onUpdate handler that also confirms the transaction immediately */ const wrappedOnUpdate = async ( - params: UpdateMutationFnParams + params: UpdateMutationFnParams< + any, + string | number, + LocalOnlyCollectionUtils + > ) => { // Call user handler first if provided let handlerResult @@ -193,7 +199,11 @@ export function localOnlyCollectionOptions< * Wrapper for onDelete handler that also confirms the transaction immediately */ const wrappedOnDelete = async ( - params: DeleteMutationFnParams + params: DeleteMutationFnParams< + any, + string | number, + LocalOnlyCollectionUtils + > ) => { // Call user handler first if provided let handlerResult diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 43dfc5afa..45f41bb9f 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -9,8 +9,8 @@ import { import type { CollectionConfig, DeleteMutationFnParams, + InferSchemaOutput, InsertMutationFnParams, - ResolveType, SyncConfig, UpdateMutationFnParams, UtilsRecord, @@ -46,22 +46,14 @@ 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 - * - * @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 T - The type of items in the collection + * @template TSchema - The schema type for validation + * @template TKey - The type of the key returned by `getKey` */ export interface LocalStorageCollectionConfig< - TExplicit = unknown, + T extends object = object, TSchema extends StandardSchemaV1 = never, - TFallback extends object = Record, + TKey extends string | number = string | number, > { /** * The key to use for storing the collection data in localStorage/sessionStorage @@ -85,35 +77,29 @@ export interface LocalStorageCollectionConfig< */ id?: string schema?: TSchema - getKey: CollectionConfig>[`getKey`] - sync?: CollectionConfig>[`sync`] + 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 + 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 + 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 + onDelete?: (params: DeleteMutationFnParams) => Promise } /** @@ -202,18 +188,43 @@ function generateUuid(): string { * }) * ) */ + +// Overload for when schema is provided export function localStorageCollectionOptions< - TExplicit = unknown, - TSchema extends StandardSchemaV1 = never, - TFallback extends object = Record, + T extends StandardSchemaV1, + TKey extends string | number = string | number, >( - config: LocalStorageCollectionConfig -): Omit>, `id`> & { + config: LocalStorageCollectionConfig, T, TKey> & { + schema: T + } +): CollectionConfig, TKey, T> & { id: string utils: LocalStorageCollectionUtils -} { - type ResolvedType = ResolveType + schema: T +} +// Overload for when no schema is provided +// the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config +export function localStorageCollectionOptions< + T extends object, + TKey extends string | number = string | number, +>( + config: LocalStorageCollectionConfig & { + schema?: never // prohibit schema + } +): CollectionConfig & { + id: string + utils: LocalStorageCollectionUtils + schema?: never // no schema in the result +} + +export function localStorageCollectionOptions( + config: LocalStorageCollectionConfig +): Omit, `id`> & { + id: string + utils: LocalStorageCollectionUtils + schema?: StandardSchemaV1 +} { // Validate required parameters if (!config.storageKey) { throw new StorageKeyRequiredError() @@ -237,10 +248,10 @@ 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, @@ -263,11 +274,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 }) @@ -302,9 +313,7 @@ export function localStorageCollectionOptions< * Create wrapper handlers for direct persistence operations that perform actual storage operations * Wraps the user's onInsert handler to also save changes to localStorage */ - const wrappedOnInsert = async ( - params: InsertMutationFnParams - ) => { + const wrappedOnInsert = async (params: InsertMutationFnParams) => { // Validate that all values in the transaction can be JSON serialized params.transaction.mutations.forEach((mutation) => { validateJsonSerializable(mutation.modified, `insert`) @@ -318,15 +327,12 @@ export function localStorageCollectionOptions< // 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 storedItem: StoredItem = { versionKey: generateUuid(), data: mutation.modified, } @@ -342,9 +348,7 @@ export function localStorageCollectionOptions< return handlerResult } - const wrappedOnUpdate = async ( - params: UpdateMutationFnParams - ) => { + const wrappedOnUpdate = async (params: UpdateMutationFnParams) => { // Validate that all values in the transaction can be JSON serialized params.transaction.mutations.forEach((mutation) => { validateJsonSerializable(mutation.modified, `update`) @@ -358,15 +362,12 @@ export function localStorageCollectionOptions< // 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 storedItem: StoredItem = { versionKey: generateUuid(), data: mutation.modified, } @@ -382,9 +383,7 @@ export function localStorageCollectionOptions< return handlerResult } - const wrappedOnDelete = async ( - params: DeleteMutationFnParams - ) => { + const wrappedOnDelete = async (params: DeleteMutationFnParams) => { // Call the user handler BEFORE persisting changes (if provided) let handlerResult: any = {} if (config.onDelete) { @@ -393,15 +392,12 @@ export function localStorageCollectionOptions< // 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(mutation.original) currentData.delete(key) }) diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index f55d80e85..ca0dc97a4 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -8,7 +8,6 @@ import type { Value, } from "../ir.js" import type { QueryBuilder } from "./index.js" -import type { ResolveType } from "../../types.js" /** * Context - The central state container for query builder operations @@ -77,19 +76,11 @@ export type Source = { /** * InferCollectionType - Extracts the TypeScript type from a CollectionImpl * - * This helper ensures we get the same type that would be used when creating - * the collection itself. It uses the internal `ResolveType` logic to maintain - * consistency between collection creation and query type inference. - * - * The complex generic parameters extract: - * - U: The base document type - * - TSchema: The schema definition - * - The resolved type combines these with any transforms + * This helper ensures we get the same type that was used when creating the collection itself. + * This can be an explicit type passed by the user or the schema output type. */ export type InferCollectionType = - T extends CollectionImpl - ? ResolveType - : never + T extends CollectionImpl ? TOutput : never /** * SchemaFromSource - Converts a Source definition into a ContextSchema diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 6ef7e7f65..b3fc5d793 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -130,7 +130,7 @@ export function createLiveQueryCollection< /** * Bridge function that handles the type compatibility between query2's TResult - * and core collection's ResolveType without exposing ugly type assertions to users + * and core collection's output type without exposing ugly type assertions to users */ function bridgeToCreateCollection< TResult extends object, diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 752c6387b..38ae18a9b 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -28,51 +28,6 @@ export type InferSchemaInput = T extends StandardSchemaV1 : Record : Record -/** - * Helper type to determine the insert input type - * This takes the raw generics (TExplicit, TSchema, TFallback) instead of the resolved T. - * - * Priority: - * 1. Explicit generic TExplicit (if not 'unknown') - * 2. Schema input type (if schema provided) - * 3. Fallback type TFallback - * - * @internal This is used for collection insert type inference - */ -export type ResolveInsertInput< - TExplicit = unknown, - TSchema extends StandardSchemaV1 = never, - TFallback extends object = Record, -> = unknown extends TExplicit - ? [TSchema] extends [never] - ? TFallback - : InferSchemaInput - : TExplicit extends object - ? TExplicit - : Record - -/** - * Helper type to determine the final type based on priority: - * 1. Explicit generic TExplicit (if not 'unknown') - * 2. Schema output type (if schema provided) - * 3. Fallback type TFallback - * - * @remarks - * This type is used internally to resolve the collection item type based on the provided generics and schema. - * 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, - TFallback extends object = Record, -> = unknown extends TExplicit - ? [TSchema] extends [never] - ? TFallback - : InferSchemaOutput - : TExplicit extends object - ? TExplicit - : Record - export type TransactionState = `pending` | `persisting` | `completed` | `failed` /** @@ -350,8 +305,11 @@ export type CollectionStatus = export interface CollectionConfig< T extends object = Record, TKey extends string | number = string | number, - TSchema extends StandardSchemaV1 = StandardSchemaV1, - TInsertInput extends object = T, + // Let TSchema default to `never` such that if a user provides T explicitly and no schema + // then TSchema will be `never` otherwise if it would default to StandardSchemaV1 + // then it would conflict with the overloads of createCollection which + // requires either T to be provided or a schema to be provided but not both! + TSchema extends StandardSchemaV1 = never, > { // If an id isn't passed in, a UUID will be // generated for it. @@ -439,7 +397,7 @@ export interface CollectionConfig< * }) * } */ - onInsert?: InsertMutationFn + onInsert?: InsertMutationFn /** * Optional asynchronous handler function called before an update operation diff --git a/packages/db/tests/collection-getters.test.ts b/packages/db/tests/collection-getters.test.ts index a0b37ee7e..fa3c5682c 100644 --- a/packages/db/tests/collection-getters.test.ts +++ b/packages/db/tests/collection-getters.test.ts @@ -2,19 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { createTransaction } from "../src/transactions" import { createCollection } from "../src/collection" import type { CollectionImpl } from "../src/collection" -import type { ChangeMessage, CollectionConfig } from "../src/types" +import type { SyncConfig } from "../src/types" + +type Item = { id: string; name: string } describe(`Collection getters`, () => { - let collection: CollectionImpl - let mockSync: { - sync: (params: { - collection: CollectionImpl - begin: () => void - write: (message: ChangeMessage) => void - commit: () => void - }) => void - } - let config: CollectionConfig + let collection: CollectionImpl + let mockSync: SyncConfig beforeEach(() => { mockSync = { @@ -33,9 +27,9 @@ describe(`Collection getters`, () => { }), } - config = { + const config = { id: `test-collection`, - getKey: (val) => val.id as string, + getKey: (val: Item) => val.id, sync: mockSync, startSync: true, } @@ -68,7 +62,7 @@ describe(`Collection getters`, () => { // Create a createCollection with no initial data const emptyCollection = createCollection({ id: `empty-collection`, - getKey: (val) => val.id as string, + getKey: (val: Item) => val.id, sync: { sync: ({ begin, commit }) => { begin() @@ -440,7 +434,7 @@ describe(`Collection getters`, () => { const delayedCollection = createCollection({ id: `delayed-collection`, - getKey: (val) => val.id as string, + getKey: (val: Item) => val.id, startSync: true, sync: delayedSyncMock, }) @@ -501,7 +495,7 @@ describe(`Collection getters`, () => { const delayedCollection = createCollection({ id: `delayed-collection`, - getKey: (val) => val.id as string, + getKey: (val: Item) => val.id, startSync: true, sync: delayedSyncMock, }) diff --git a/packages/db/tests/collection-schema.test.ts b/packages/db/tests/collection-schema.test.ts new file mode 100644 index 000000000..3dc5e7293 --- /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.date().or(z.string().transform((val) => new Date(val))), + updated_at: z.date().or(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..b513ff750 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 } 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,85 +61,39 @@ 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, - 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, + const _collection = createCollection({ + getKey: (item) => { + expectTypeOf(item).toEqualTypeOf() + return 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() }) @@ -195,3 +151,251 @@ 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 = StandardSchemaV1.InferOutput< + typeof userSchemaWithDefaults + > + type ExpectedInputType = StandardSchemaV1.InferInput< + typeof userSchemaWithDefaults + > + + 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 = StandardSchemaV1.InferInput< + typeof userSchemaTransform + > + type ExpectedOutputType = StandardSchemaV1.InferOutput< + typeof userSchemaTransform + > + 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 = StandardSchemaV1.InferOutput< + typeof userSchemaWithDefaults + > + type ExpectedInputType = StandardSchemaV1.InferInput< + typeof userSchemaWithDefaults + > + + // 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 = StandardSchemaV1.InferInput< + typeof userSchemaTransform + > + type ExpectedOutputType = StandardSchemaV1.InferOutput< + typeof userSchemaTransform + > + + // 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..512903713 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,33 @@ 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 = { + it(`should have correct return type from localOnlyCollectionOptions with explicit type`, () => { + const options = localOnlyCollectionOptions({ id: `test-local-only`, - getKey: (item: TestItem) => item.id, - } + getKey: (item) => item.id, + }) - const options = localOnlyCollectionOptions< - TestItem, - never, - TestItem, - number - >(config) + // Test that options has the expected structure + expectTypeOf(options).toHaveProperty(`sync`) + expectTypeOf(options).toHaveProperty(`onInsert`) + expectTypeOf(options).toHaveProperty(`onUpdate`) + expectTypeOf(options).toHaveProperty(`onDelete`) + expectTypeOf(options).toHaveProperty(`utils`) + expectTypeOf(options).toHaveProperty(`getKey`) + + // Test that getKey returns the correct type + expectTypeOf(options.getKey).parameter(0).toEqualTypeOf() + expectTypeOf(options.getKey).returns.toEqualTypeOf() + }) + + it(`should have correct return type from localOnlyCollectionOptions with type inferred from getKey`, () => { + const options = localOnlyCollectionOptions({ + id: `test-local-only`, + getKey: (item: TestItem) => item.id, + }) // Test that options has the expected structure expectTypeOf(options).toHaveProperty(`sync`) @@ -35,32 +46,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, - } + }) + + 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 +84,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 +111,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 +128,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) + // We can trust that zod infers the correct types for the schema + type ExpectedType = z.infer + type ExpectedInput = z.input - // 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 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..aaf48049b 100644 --- a/packages/db/tests/local-storage.test-d.ts +++ b/packages/db/tests/local-storage.test-d.ts @@ -2,19 +2,13 @@ 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" + +type ItemOf = T extends Array ? U : T describe(`LocalStorage collection type resolution tests`, () => { // Define test types @@ -60,6 +54,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 +73,20 @@ describe(`LocalStorage collection type resolution tests`, () => { getKey: (item) => item.id, }) - type ExpectedType = ResolveType< - ExplicitType, - never, - Record - > // 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 +98,70 @@ describe(`LocalStorage collection type resolution tests`, () => { getKey: (item) => item.id, }) - type ExpectedType = ResolveType< - unknown, - typeof testSchema, - Record - > // 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 // 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 - > // 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 +193,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 +251,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 +302,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/utils.ts b/packages/db/tests/utils.ts index c96433951..66c301464 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/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 18f96e03a..70321a5fd 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -45,50 +45,27 @@ type InferSchemaOutput = T extends StandardSchemaV1 : 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 T - The type of items in the collection + * @template TSchema - The schema type for validation */ export interface ElectricCollectionConfig< - TExplicit extends Row = Row, + T extends Row = Row, TSchema extends StandardSchemaV1 = never, - TFallback extends Row = Row, > { /** * Configuration options for the ElectricSQL ShapeStream */ - shapeOptions: ShapeStreamOptions< - GetExtensions> - > + shapeOptions: ShapeStreamOptions> /** * All standard Collection configuration properties */ id?: string schema?: TSchema - getKey: CollectionConfig>[`getKey`] - sync?: CollectionConfig>[`sync`] + getKey: CollectionConfig[`getKey`] + sync?: CollectionConfig[`sync`] /** * Optional asynchronous handler function called before an insert operation @@ -139,7 +116,7 @@ export interface ElectricCollectionConfig< * } */ onInsert?: ( - params: InsertMutationFnParams> + params: InsertMutationFnParams ) => Promise<{ txid: Txid | Array }> /** @@ -187,7 +164,7 @@ export interface ElectricCollectionConfig< * } */ onUpdate?: ( - params: UpdateMutationFnParams> + params: UpdateMutationFnParams ) => Promise<{ txid: Txid | Array }> /** @@ -244,7 +221,7 @@ export interface ElectricCollectionConfig< * */ onDelete?: ( - params: DeleteMutationFnParams> + params: DeleteMutationFnParams ) => Promise<{ txid: Txid | Array }> } @@ -282,24 +259,46 @@ 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 T - 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 Electric collection * @returns Collection options with utilities */ -export function electricCollectionOptions< - TExplicit extends Row = Row, - TSchema extends StandardSchemaV1 = never, - TFallback extends Row = Row, ->(config: ElectricCollectionConfig) { + +// Overload for when schema is provided +export function electricCollectionOptions( + config: ElectricCollectionConfig, T> & { + schema: T + } +): CollectionConfig, string | number, T> & { + id?: string + utils: ElectricCollectionUtils + schema: T +} + +// Overload for when no schema is provided +export function electricCollectionOptions>( + config: ElectricCollectionConfig & { + schema?: never // prohibit schema + } +): CollectionConfig & { + id?: string + utils: ElectricCollectionUtils + schema?: never // no schema in the result +} + +export function electricCollectionOptions( + config: ElectricCollectionConfig +): CollectionConfig & { + id?: string + utils: ElectricCollectionUtils + schema?: any +} { const seenTxids = new Store>(new Set([])) - const sync = createElectricSync>( - config.shapeOptions, - { - seenTxids, - } - ) + const sync = createElectricSync(config.shapeOptions, { + seenTxids, + }) /** * Wait for a specific transaction ID to be synced @@ -338,11 +337,7 @@ export function electricCollectionOptions< // Create wrapper handlers for direct persistence operations that handle txid awaiting const wrappedOnInsert = config.onInsert - ? async ( - params: InsertMutationFnParams< - ResolveType - > - ) => { + ? async (params: InsertMutationFnParams) => { // Runtime check (that doesn't follow type) // eslint-disable-next-line const handlerResult = (await config.onInsert!(params)) ?? {} @@ -364,11 +359,7 @@ export function electricCollectionOptions< : undefined const wrappedOnUpdate = config.onUpdate - ? async ( - params: UpdateMutationFnParams< - ResolveType - > - ) => { + ? async (params: UpdateMutationFnParams) => { // Runtime check (that doesn't follow type) // eslint-disable-next-line const handlerResult = (await config.onUpdate!(params)) ?? {} @@ -390,11 +381,7 @@ export function electricCollectionOptions< : undefined const wrappedOnDelete = config.onDelete - ? async ( - params: DeleteMutationFnParams< - ResolveType - > - ) => { + ? async (params: DeleteMutationFnParams) => { const handlerResult = await config.onDelete!(params) if (!handlerResult.txid) { throw new ElectricDeleteHandlerMustReturnTxIdError() diff --git a/packages/electric-db-collection/tests/electric.test-d.ts b/packages/electric-db-collection/tests/electric.test-d.ts index df0f127ba..649e4296f 100644 --- a/packages/electric-db-collection/tests/electric.test-d.ts +++ b/packages/electric-db-collection/tests/electric.test-d.ts @@ -11,10 +11,8 @@ 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 @@ -38,13 +36,11 @@ describe(`Electric collection type resolution tests`, () => { getKey: (item) => item.id, }) - 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 a schema is provided`, () => { const options = electricCollectionOptions({ shapeOptions: { url: `foo`, @@ -54,18 +50,24 @@ describe(`Electric collection type resolution tests`, () => { getKey: (item) => item.id, }) - 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 - > = { + it(`should throw a type error when both a schema and an explicit type (that is not the type of the schema) are provided`, () => { + electricCollectionOptions({ + shapeOptions: { + url: `foo`, + params: { table: `test_table` }, + }, + // @ts-expect-error – schema should be `undefined` because we passed an explicit type + schema: testSchema, + getKey: (item) => item.id, + }) + }) + + it(`should use explicit type when no schema is provided`, () => { + const config: ElectricCollectionConfig = { shapeOptions: { url: `foo`, params: { table: `test_table` }, @@ -73,40 +75,25 @@ describe(`Electric collection type resolution tests`, () => { getKey: (item) => item.id, } - const options = electricCollectionOptions< - Row, - never, - FallbackType - >(config) + const options = electricCollectionOptions(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`, () => { - const options = electricCollectionOptions< - ExplicitType, - typeof testSchema, - FallbackType - >({ + it(`should use getKey type when no schema and no explicit type is provided`, () => { + const config = { shapeOptions: { - url: `test_shape`, + url: `foo`, params: { table: `test_table` }, }, - schema: testSchema, - getKey: (item) => item.id, - }) + getKey: (item: FallbackType) => item.id, + } + + const options = electricCollectionOptions(config) - type ExpectedType = ResolveType< - ExplicitType, - typeof testSchema, - FallbackType - > // The getKey function should have the resolved type - expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() - expectTypeOf().toEqualTypeOf() + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[FallbackType]>() }) it(`should properly type the onInsert, onUpdate, and onDelete handlers`, () => { diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 73e71b4f4..93da05f8d 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -36,37 +36,24 @@ type InferSchemaOutput = T extends StandardSchemaV1 : Record : Record -// QueryFn return type inference helper -type InferQueryFnOutput = TQueryFn extends ( - context: QueryFunctionContext -) => Promise> - ? TItem extends object - ? TItem +// Schema input type inference helper (matches electric.ts pattern) +type InferSchemaInput = T extends StandardSchemaV1 + ? StandardSchemaV1.InferInput extends object + ? StandardSchemaV1.InferInput : 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 - /** * 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 TQueryFn - The queryFn type for inferring return type (third priority) + * @template T - The explicit type of items stored in the collection + * @template TQueryFn - The queryFn type * @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 TSchema - The schema type for validation */ export interface QueryCollectionConfig< - TExplicit extends object = object, - TSchema extends StandardSchemaV1 = never, + T extends object = object, TQueryFn extends ( context: QueryFunctionContext ) => Promise> = ( @@ -74,6 +61,8 @@ export interface QueryCollectionConfig< ) => Promise>, TError = unknown, TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, > { /** The query key used by TanStack Query to identify this query */ queryKey: TQueryKey @@ -82,9 +71,7 @@ export interface QueryCollectionConfig< context: QueryFunctionContext ) => Promise> ? TQueryFn - : ( - context: QueryFunctionContext - ) => Promise>> + : (context: QueryFunctionContext) => Promise> /** The TanStack Query client instance */ queryClient: QueryClient @@ -93,31 +80,31 @@ 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`] @@ -125,13 +112,11 @@ export interface QueryCollectionConfig< /** Unique identifier for the collection */ id?: string /** Function to extract the unique key from an item */ - getKey: CollectionConfig>[`getKey`] + getKey: CollectionConfig[`getKey`] /** Schema for validating items */ schema?: TSchema - sync?: CollectionConfig>[`sync`] - startSync?: CollectionConfig< - ResolveType - >[`startSync`] + sync?: CollectionConfig[`sync`] + startSync?: CollectionConfig[`startSync`] // Direct persistence handlers /** @@ -174,7 +159,7 @@ export interface QueryCollectionConfig< * } * } */ - onInsert?: InsertMutationFn> + onInsert?: InsertMutationFn /** * Optional asynchronous handler function called before an update operation @@ -227,7 +212,7 @@ export interface QueryCollectionConfig< * return { refetch: false } // Skip automatic refetch since we handled it manually * } */ - onUpdate?: UpdateMutationFn> + onUpdate?: UpdateMutationFn /** * Optional asynchronous handler function called before a delete operation @@ -273,7 +258,7 @@ export interface QueryCollectionConfig< * return { refetch: false } // Skip automatic refetch since we handled it manually * } */ - onDelete?: DeleteMutationFn> + onDelete?: DeleteMutationFn /** * Metadata to pass to the query. @@ -351,18 +336,13 @@ export interface QueryCollectionUtils< * This integrates TanStack Query with TanStack DB for automatic synchronization. * * Supports automatic type inference following the priority order: - * 1. Explicit type (highest priority) - * 2. Schema inference (second priority) - * 3. QueryFn return type inference (third priority) - * 4. Fallback to Record + * 1. Schema inference (highest priority) + * 2. QueryFn return type inference (second priority) * - * @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 TQueryFn - The queryFn type for inferring return type (third priority) + * @template T - Type of the schema if a schema is provided otherwise it is the type of the values returned by the queryFn * @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 * @param config - Configuration options for the Query collection * @returns Collection options with utilities for direct writes and manual operations * @@ -381,7 +361,7 @@ export interface QueryCollectionUtils< * ) * * @example - * // Explicit type (highest priority) + * // Explicit type * const todosCollection = createCollection( * queryCollectionOptions({ * queryKey: ['todos'], @@ -392,7 +372,7 @@ export interface QueryCollectionUtils< * ) * * @example - * // Schema inference (second priority) + * // Schema inference * const todosCollection = createCollection( * queryCollectionOptions({ * queryKey: ['todos'], @@ -423,30 +403,62 @@ export interface QueryCollectionUtils< * }) * ) */ + +// Overload for when schema is provided export function queryCollectionOptions< - TExplicit extends object = object, - TSchema extends StandardSchemaV1 = never, - TQueryFn extends ( - context: QueryFunctionContext - ) => Promise> = ( - context: QueryFunctionContext - ) => Promise>, + T extends StandardSchemaV1, TError = unknown, TQueryKey extends QueryKey = QueryKey, TKey extends string | number = string | number, - TInsertInput extends object = ResolveType, >( - config: QueryCollectionConfig -): CollectionConfig> & { + config: QueryCollectionConfig< + InferSchemaOutput, + ( + context: QueryFunctionContext + ) => Promise>>, + TError, + TQueryKey, + TKey, + T + > & { + schema: T + } +): CollectionConfig, TKey, T> & { + schema: T utils: QueryCollectionUtils< - ResolveType, + InferSchemaOutput, TKey, - TInsertInput, + InferSchemaInput, TError > -} { - type TItem = ResolveType +} + +// Overload for when no schema is provided +export function queryCollectionOptions< + T extends object, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, +>( + config: QueryCollectionConfig< + T, + (context: QueryFunctionContext) => Promise>, + TError, + TQueryKey, + TKey + > & { + schema?: never // prohibit schema + } +): CollectionConfig & { + schema?: never // no schema in the result + utils: QueryCollectionUtils +} +export function queryCollectionOptions( + config: QueryCollectionConfig> +): CollectionConfig & { + utils: QueryCollectionUtils +} { const { queryKey, queryFn, @@ -486,21 +498,21 @@ export function queryCollectionOptions< } /** The last error encountered by the query */ - let lastError: TError | undefined + let lastError: any /** The number of consecutive sync failures */ let errorCount = 0 /** The timestamp for when the query most recently returned the status as "error" */ let lastErrorUpdatedAt = 0 - const internalSync: SyncConfig[`sync`] = (params) => { + const internalSync: SyncConfig[`sync`] = (params) => { const { begin, write, commit, markReady, collection } = params const observerOptions: QueryObserverOptions< - Array, - TError, - Array, - Array, - TQueryKey + Array, + any, + Array, + Array, + any > = { queryKey: queryKey, queryFn: queryFn, @@ -515,11 +527,11 @@ export function queryCollectionOptions< } const localObserver = new QueryObserver< - Array, - TError, - Array, - Array, - TQueryKey + Array, + any, + Array, + Array, + any >(queryClient, observerOptions) type UpdateHandler = Parameters[0] @@ -543,7 +555,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) @@ -646,14 +658,14 @@ export function queryCollectionOptions< collection: any queryClient: QueryClient queryKey: Array - getKey: (item: TItem) => TKey + getKey: (item: any) => string | number 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 @@ -661,7 +673,7 @@ export function queryCollectionOptions< collection, queryClient, queryKey: queryKey as unknown as Array, - getKey: getKey as (item: TItem) => TKey, + getKey: getKey as (item: any) => string | number, begin, write, commit, @@ -672,13 +684,13 @@ export function queryCollectionOptions< } // Create write utils using the manual-sync module - const writeUtils = createWriteUtils( + const writeUtils = createWriteUtils( () => writeContext ) // Create wrapper handlers for direct persistence operations that handle refetching const wrappedOnInsert = onInsert - ? async (params: InsertMutationFnParams) => { + ? async (params: InsertMutationFnParams) => { const handlerResult = (await onInsert(params)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -692,7 +704,7 @@ export function queryCollectionOptions< : undefined const wrappedOnUpdate = onUpdate - ? async (params: UpdateMutationFnParams) => { + ? async (params: UpdateMutationFnParams) => { const handlerResult = (await onUpdate(params)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -706,7 +718,7 @@ export function queryCollectionOptions< : undefined const wrappedOnDelete = onDelete - ? async (params: DeleteMutationFnParams) => { + ? async (params: DeleteMutationFnParams) => { const handlerResult = (await onDelete(params)) ?? {} 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..747095435 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -208,23 +208,21 @@ describe(`Query collection type resolution tests`, () => { expectTypeOf(options.getKey).parameters.toEqualTypeOf<[TodoType]>() }) - it(`should prioritize explicit type over queryFn`, () => { + it(`should throw a type error if explicit type does not match the inferred type from the queryFn`, () => { interface UserType { id: string name: string } - const options = queryCollectionOptions({ + queryCollectionOptions({ queryClient, queryKey: [`explicit-priority`], + // @ts-expect-error – queryFn doesn't match the explicit type queryFn: async (): Promise> => { return [] as Array }, getKey: (item) => item.id, }) - - // Should use explicit UserType, not TodoType from queryFn - expectTypeOf(options.getKey).parameters.toEqualTypeOf<[UserType]>() }) it(`should prioritize schema over queryFn`, () => { @@ -249,6 +247,34 @@ describe(`Query collection type resolution tests`, () => { expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExpectedType]>() }) + it(`should throw an error if schema type doesn't match the queryFn type`, () => { + interface UserType { + id: string + name: string + } + + const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + + const options = queryCollectionOptions({ + queryClient, + queryKey: [`schema-priority`], + // @ts-expect-error – queryFn doesn't match the schema type + queryFn: async () => { + return [] as Array + }, + schema: userSchema, + getKey: (item) => item.id, + }) + + // Should use schema type, not TodoType from queryFn + type ExpectedType = z.infer + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExpectedType]>() + }) + it(`should maintain backward compatibility with explicit types`, () => { const options = queryCollectionOptions({ queryClient, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 47a14a706..b14fa591a 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { QueryClient } from "@tanstack/query-core" import { createCollection } from "@tanstack/db" import { queryCollectionOptions } from "../src/query" +import type { QueryFunctionContext } from "@tanstack/query-core" import type { CollectionImpl, DeleteMutationFnParams, @@ -177,7 +178,9 @@ describe(`QueryCollection`, () => { .spyOn(console, `error`) .mockImplementation(() => {}) - const queryFn = vi + const queryFn: ( + context: QueryFunctionContext + ) => Promise> = vi .fn() .mockResolvedValueOnce([initialItem]) .mockRejectedValueOnce(testError) @@ -229,7 +232,11 @@ describe(`QueryCollection`, () => { .mockImplementation(() => {}) // Mock queryFn to return invalid data (not an array of objects) - const queryFn = vi.fn().mockResolvedValue(`not an array` as any) + const queryFn: ( + context: QueryFunctionContext + ) => Promise> = vi + .fn() + .mockResolvedValue(`not an array` as any) const options = queryCollectionOptions({ id: `test`, @@ -271,7 +278,9 @@ describe(`QueryCollection`, () => { // First query returns the initial item // Second query returns a new object with the same properties (different reference) // Third query returns an object with an actual change - const queryFn = vi + const queryFn: ( + context: QueryFunctionContext + ) => Promise> = vi .fn() .mockResolvedValueOnce([initialItem]) .mockResolvedValueOnce([{ ...initialItem }]) // Same data, different object reference @@ -1611,7 +1620,9 @@ describe(`QueryCollection`, () => { { id: `1`, name: `Cached Item 1` }, { id: `2`, name: `Cached Item 2` }, ] - const queryFn = vi.fn().mockReturnValue(initialItems) + const queryFn: ( + context: QueryFunctionContext + ) => Promise> = vi.fn().mockReturnValue(initialItems) await queryClient.prefetchQuery({ queryKey, queryFn }) // The collection should immediately be ready @@ -1825,7 +1836,6 @@ describe(`QueryCollection`, () => { const config: QueryCollectionConfig< TestItem, - never, typeof queryFn, CustomError > = { diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index 82383428d..f0b93e288 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -14,7 +14,11 @@ import type { RxDocumentData, } from "rxdb/plugins/core" -import type { CollectionConfig, ResolveType, SyncConfig } from "@tanstack/db" +import type { + CollectionConfig, + InferSchemaOutput, + SyncConfig, +} from "@tanstack/db" import type { StandardSchemaV1 } from "@standard-schema/spec" const debug = DebugModule.debug(`ts/db:rxdb`) @@ -26,7 +30,7 @@ export const OPEN_RXDB_SUBSCRIPTIONS = new WeakMap>() /** * Configuration interface for RxDB collection options - * @template TExplicit - The explicit type of items in the collection (highest priority). Use the document type of your RxCollection here. + * @template T - The explicit type of items in the collection (highest priority). Use the document type of your RxCollection here. * @template TSchema - The schema type for validation and type inference (second priority) * * @remarks @@ -38,16 +42,16 @@ export const OPEN_RXDB_SUBSCRIPTIONS = new WeakMap>() * Notice that primary keys in RxDB must always be a string. */ export type RxDBCollectionConfig< - TExplicit extends object = Record, + T extends object = Record, TSchema extends StandardSchemaV1 = never, > = Omit< - CollectionConfig, string, TSchema>, + CollectionConfig, `insert` | `update` | `delete` | `getKey` | `sync` > & { /** * The RxCollection from a RxDB Database instance. */ - rxCollection: RxCollection + rxCollection: RxCollection /** * The maximum number of documents to read from the RxDB collection @@ -75,18 +79,32 @@ export type RxDBCollectionConfig< * @param config - Configuration options for the RxDB collection * @returns Collection options with utilities */ -export function rxdbCollectionOptions< - TExplicit extends object = Record, - TSchema extends StandardSchemaV1 = never, ->(config: RxDBCollectionConfig) { - type Row = ResolveType + +// Overload for when schema is provided +export function rxdbCollectionOptions( + config: RxDBCollectionConfig, T> +): CollectionConfig, string, T> & { + schema: T +} + +// Overload for when no schema is provided +export function rxdbCollectionOptions( + config: RxDBCollectionConfig & { + schema?: never // prohibit schema + } +): CollectionConfig & { + schema?: never // no schema in the result +} + +export function rxdbCollectionOptions(config: RxDBCollectionConfig) { + type Row = Record type Key = string // because RxDB primary keys must be strings const { ...restConfig } = config const rxCollection = config.rxCollection // "getKey" - const primaryPath = rxCollection.schema.primaryPath as string + const primaryPath = rxCollection.schema.primaryPath function getKey(item: any): string { const key: string = item[primaryPath] as string return key @@ -98,7 +116,7 @@ export function rxdbCollectionOptions< * and the in-memory tanstack-db collection. * It is not about sync between a client and a server! */ - type SyncParams = Parameters[`sync`]>[0] + type SyncParams = Parameters[`sync`]>[0] const sync: SyncConfig = { sync: (params: SyncParams) => { const { begin, write, commit, markReady } = params @@ -110,12 +128,12 @@ export function rxdbCollectionOptions< * which can be used to "sort" document writes, * so for initial sync we iterate over that. */ - let cursor: RxDocumentData | undefined = undefined + let cursor: RxDocumentData | undefined = undefined const syncBatchSize = config.syncBatchSize ? config.syncBatchSize : 1000 begin() while (!ready) { - let query: FilledMangoQuery + let query: FilledMangoQuery if (cursor) { query = { selector: { @@ -148,7 +166,7 @@ export function rxdbCollectionOptions< * RxCollection document cache because it likely wont be used anyway * since most queries will run directly on the tanstack-db side. */ - const preparedQuery = prepareQuery( + const preparedQuery = prepareQuery( rxCollection.storageInstance.schema, query ) @@ -187,9 +205,7 @@ export function rxdbCollectionOptions< function startOngoingFetch() { // Subscribe early and buffer live changes during initial load and ongoing sub = rxCollection.$.subscribe((ev) => { - const cur: ResolveType = stripRxdbFields( - clone(ev.documentData as Row) - ) + const cur: Row = stripRxdbFields(clone(ev.documentData as Row)) switch (ev.operation) { case `INSERT`: queue({ type: `insert`, value: cur }) @@ -241,7 +257,7 @@ export function rxdbCollectionOptions< getSyncMetadata: undefined, } - const collectionConfig: CollectionConfig> = { + const collectionConfig: CollectionConfig = { ...restConfig, getKey: getKey as any, sync,