diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 39f59ed73..be037e05e 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -2,6 +2,7 @@ import { CollectionRequiresConfigError, CollectionRequiresSyncConfigError, } from '../errors' +import { validateCollectionConfig } from './validate-config' import { currentStateAsChanges } from './change-events' import { CollectionStateManager } from './state' @@ -249,6 +250,16 @@ export function createCollection( schema?: StandardSchemaV1 }, ): Collection { + // Validate config at runtime to produce clear error messages (dev only). + // TypeScript's type errors for createCollection overloads can be extremely + // hard to read due to deeply nested generics. This catches common mistakes + // early with actionable messages. + // Bundlers replace process.env.NODE_ENV with "production" in prod builds, + // making this entire block (and the imported validate-config module) tree-shakeable. + if (typeof process !== `undefined` && process.env.NODE_ENV !== `production`) { + validateCollectionConfig(options) + } + const collection = new CollectionImpl( options, ) diff --git a/packages/db/src/collection/validate-config.ts b/packages/db/src/collection/validate-config.ts new file mode 100644 index 000000000..e1bc01888 --- /dev/null +++ b/packages/db/src/collection/validate-config.ts @@ -0,0 +1,229 @@ +import { + CollectionRequiresConfigError, + CollectionRequiresGetKeyError, + CollectionRequiresSyncConfigError, + InvalidCallbackOptionError, + InvalidGetKeyError, + InvalidOptionTypeError, + InvalidSyncConfigError, + InvalidSyncFunctionError, + UnknownCollectionConfigError, +} from '../errors' + +/** + * All valid top-level config properties for createCollection. + * Used for unknown-property detection. + */ +const VALID_CONFIG_KEYS = new Set([ + `id`, + `schema`, + `getKey`, + `sync`, + `gcTime`, + `startSync`, + `autoIndex`, + `compare`, + `syncMode`, + `defaultStringCollation`, + `onInsert`, + `onUpdate`, + `onDelete`, + `utils`, + `singleResult`, +]) + +/** + * Compute Levenshtein distance between two strings for typo detection. + */ +function levenshtein(a: string, b: string): number { + const m = a.length + const n = b.length + const dp: Array> = Array.from({ length: m + 1 }, () => + Array.from({ length: n + 1 }, () => 0), + ) + for (let i = 0; i <= m; i++) dp[i]![0] = i + for (let j = 0; j <= n; j++) dp[0]![j] = j + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i]![j] = + a[i - 1] === b[j - 1] + ? dp[i - 1]![j - 1]! + : 1 + Math.min(dp[i - 1]![j]!, dp[i]![j - 1]!, dp[i - 1]![j - 1]!) + } + } + return dp[m]![n]! +} + +/** + * Find the closest matching valid config key for an unknown key. + * Returns the suggestion if within edit distance 3, otherwise undefined. + */ +function findClosestKey(unknownKey: string): string | undefined { + let bestMatch: string | undefined + let bestDistance = Infinity + for (const validKey of VALID_CONFIG_KEYS) { + const distance = levenshtein( + unknownKey.toLowerCase(), + validKey.toLowerCase(), + ) + if (distance < bestDistance) { + bestDistance = distance + bestMatch = validKey + } + } + return bestDistance <= 3 ? bestMatch : undefined +} + +function describeType(value: unknown): string { + if (value === null) return `null` + if (value === undefined) return `undefined` + if (Array.isArray(value)) return `an array` + return typeof value +} + +/** + * Validates the collection config at runtime, providing clear error messages + * for common misconfiguration mistakes that would otherwise surface as + * unreadable TypeScript errors. + * + * This runs before the config is passed to CollectionImpl, catching issues + * early with actionable error messages. + */ +export function validateCollectionConfig(config: unknown): void { + // Check config exists and is an object + if (!config || typeof config !== `object` || Array.isArray(config)) { + throw new CollectionRequiresConfigError() + } + + const configObj = config as Record + + // Check for unknown properties (typo detection) + // Skip properties starting with _ (internal/private convention) + const unknownKeys: Array = [] + const suggestions: Array<{ unknown: string; suggestion: string }> = [] + for (const key of Object.keys(configObj)) { + if (!VALID_CONFIG_KEYS.has(key) && !key.startsWith(`_`)) { + unknownKeys.push(key) + const suggestion = findClosestKey(key) + if (suggestion) { + suggestions.push({ unknown: key, suggestion }) + } + } + } + if (unknownKeys.length > 0) { + throw new UnknownCollectionConfigError(unknownKeys, suggestions) + } + + // Validate getKey + if (!(`getKey` in configObj) || configObj.getKey === undefined) { + throw new CollectionRequiresGetKeyError() + } + if (typeof configObj.getKey !== `function`) { + throw new InvalidGetKeyError(describeType(configObj.getKey)) + } + + // Validate sync + if (!configObj.sync) { + throw new CollectionRequiresSyncConfigError() + } + if (typeof configObj.sync !== `object` || Array.isArray(configObj.sync)) { + throw new InvalidSyncConfigError(describeType(configObj.sync)) + } + const syncObj = configObj.sync as Record + if (typeof syncObj.sync !== `function`) { + throw new InvalidSyncFunctionError(describeType(syncObj.sync)) + } + + // Validate callback options + const callbackOptions = [ + `onInsert`, + `onUpdate`, + `onDelete`, + `compare`, + ] as const + for (const optionName of callbackOptions) { + if ( + optionName in configObj && + configObj[optionName] !== undefined && + typeof configObj[optionName] !== `function` + ) { + throw new InvalidCallbackOptionError( + optionName, + describeType(configObj[optionName]), + ) + } + } + + // Validate id + if (`id` in configObj && configObj.id !== undefined) { + if (typeof configObj.id !== `string`) { + throw new InvalidOptionTypeError( + `id`, + `a string`, + describeType(configObj.id), + ) + } + } + + // Validate gcTime + if (`gcTime` in configObj && configObj.gcTime !== undefined) { + if ( + typeof configObj.gcTime !== `number` || + Number.isNaN(configObj.gcTime) + ) { + throw new InvalidOptionTypeError( + `gcTime`, + `a number`, + describeType(configObj.gcTime), + ) + } + } + + // Validate startSync + if (`startSync` in configObj && configObj.startSync !== undefined) { + if (typeof configObj.startSync !== `boolean`) { + throw new InvalidOptionTypeError( + `startSync`, + `a boolean`, + describeType(configObj.startSync), + ) + } + } + + // Validate autoIndex + if (`autoIndex` in configObj && configObj.autoIndex !== undefined) { + if (configObj.autoIndex !== `off` && configObj.autoIndex !== `eager`) { + throw new InvalidOptionTypeError( + `autoIndex`, + `"off" or "eager"`, + String(configObj.autoIndex), + ) + } + } + + // Validate syncMode + if (`syncMode` in configObj && configObj.syncMode !== undefined) { + if (configObj.syncMode !== `eager` && configObj.syncMode !== `on-demand`) { + throw new InvalidOptionTypeError( + `syncMode`, + `"eager" or "on-demand"`, + String(configObj.syncMode), + ) + } + } + + // Validate utils + if (`utils` in configObj && configObj.utils !== undefined) { + if ( + typeof configObj.utils !== `object` || + configObj.utils === null || + Array.isArray(configObj.utils) + ) { + throw new InvalidOptionTypeError( + `utils`, + `an object`, + describeType(configObj.utils), + ) + } + } +} diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index dc1c7b900..01fbbb87e 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -99,6 +99,102 @@ export class SchemaMustBeSynchronousError extends CollectionConfigurationError { } } +export class CollectionRequiresGetKeyError extends CollectionConfigurationError { + constructor() { + super( + `Collection requires a "getKey" function in the config.\n\n` + + `The getKey function extracts a unique identifier from each item.\n\n` + + `Example:\n` + + ` createCollection({\n` + + ` getKey: (item) => item.id,\n` + + ` sync: { sync: () => {} },\n` + + ` })`, + ) + } +} + +export class InvalidGetKeyError extends CollectionConfigurationError { + constructor(actualType: string) { + super( + `"getKey" must be a function, but received ${actualType}.\n\n` + + `Example:\n` + + ` createCollection({\n` + + ` getKey: (item) => item.id,\n` + + ` sync: { sync: () => {} },\n` + + ` })`, + ) + } +} + +export class InvalidSyncConfigError extends CollectionConfigurationError { + constructor(actualType: string) { + super( + `"sync" must be an object with a "sync" function, but received ${actualType}.\n\n` + + `Example:\n` + + ` createCollection({\n` + + ` getKey: (item) => item.id,\n` + + ` sync: {\n` + + ` sync: ({ begin, write, commit, markReady }) => {\n` + + ` // your sync logic\n` + + ` },\n` + + ` },\n` + + ` })`, + ) + } +} + +export class InvalidSyncFunctionError extends CollectionConfigurationError { + constructor(actualType: string) { + super( + `"sync.sync" must be a function, but received ${actualType}.\n\n` + + `The sync property should be an object containing a sync function:\n` + + ` sync: {\n` + + ` sync: ({ begin, write, commit, markReady }) => {\n` + + ` // your sync logic\n` + + ` },\n` + + ` }`, + ) + } +} + +export class InvalidCallbackOptionError extends CollectionConfigurationError { + constructor(optionName: string, actualType: string) { + super(`"${optionName}" must be a function, but received ${actualType}.`) + } +} + +export class InvalidOptionTypeError extends CollectionConfigurationError { + constructor(optionName: string, expectedType: string, actualType: string) { + super( + `"${optionName}" must be ${expectedType}, but received ${actualType}.`, + ) + } +} + +export class UnknownCollectionConfigError extends CollectionConfigurationError { + constructor( + unknownKeys: Array, + suggestions: Array<{ unknown: string; suggestion: string }>, + ) { + const parts: Array = [] + parts.push( + `Unknown config ${unknownKeys.length === 1 ? `property` : `properties`}: ${unknownKeys.map((k) => `"${k}"`).join(`, `)}.`, + ) + if (suggestions.length > 0) { + parts.push( + `\n\nDid you mean?\n` + + suggestions + .map((s) => ` "${s.unknown}" → "${s.suggestion}"`) + .join(`\n`), + ) + } + parts.push( + `\n\nValid config properties: id, schema, getKey, sync, gcTime, startSync, autoIndex, compare, syncMode, defaultStringCollation, onInsert, onUpdate, onDelete, utils, singleResult.`, + ) + super(parts.join(``)) + } +} + // Collection State Errors export class CollectionStateError extends TanStackDBError { constructor(message: string) { diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 3060b7ec6..3632e145f 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -529,10 +529,12 @@ export function localStorageCollectionOptions( } // Extract standard Collection config properties + // Remove localStorage-specific properties so they don't leak into the CollectionConfig const { storageKey: _storageKey, storage: _storage, storageEventApi: _storageEventApi, + parser: _parser, onInsert: _onInsert, onUpdate: _onUpdate, onDelete: _onDelete, diff --git a/packages/db/tests/collection-config-validation.test.ts b/packages/db/tests/collection-config-validation.test.ts new file mode 100644 index 000000000..38b91fce4 --- /dev/null +++ b/packages/db/tests/collection-config-validation.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { + CollectionRequiresConfigError, + CollectionRequiresGetKeyError, + CollectionRequiresSyncConfigError, + InvalidCallbackOptionError, + InvalidGetKeyError, + InvalidOptionTypeError, + InvalidSyncConfigError, + InvalidSyncFunctionError, + UnknownCollectionConfigError, +} from '../src/errors' + +describe(`createCollection runtime config validation`, () => { + const validSync = { sync: () => {} } + + describe(`missing or invalid config`, () => { + it(`should throw CollectionRequiresConfigError when no config is passed`, () => { + // @ts-expect-error testing runtime behavior + expect(() => createCollection()).toThrow(CollectionRequiresConfigError) + }) + + it(`should throw CollectionRequiresConfigError when null is passed`, () => { + // @ts-expect-error testing runtime behavior + expect(() => createCollection(null)).toThrow( + CollectionRequiresConfigError, + ) + }) + + it(`should throw CollectionRequiresConfigError when a string is passed`, () => { + // @ts-expect-error testing runtime behavior + expect(() => createCollection(`not a config`)).toThrow( + CollectionRequiresConfigError, + ) + }) + + it(`should throw CollectionRequiresConfigError when an array is passed`, () => { + // @ts-expect-error testing runtime behavior + expect(() => createCollection([])).toThrow(CollectionRequiresConfigError) + }) + }) + + describe(`getKey validation`, () => { + it(`should throw CollectionRequiresGetKeyError when getKey is missing`, () => { + // @ts-expect-error testing runtime behavior + expect(() => createCollection({ sync: validSync })).toThrow( + CollectionRequiresGetKeyError, + ) + }) + + it(`should throw InvalidGetKeyError when getKey is a string`, () => { + expect(() => + // @ts-expect-error testing runtime behavior + createCollection({ getKey: `id`, sync: validSync }), + ).toThrow(InvalidGetKeyError) + }) + + it(`should throw InvalidGetKeyError when getKey is an object`, () => { + expect(() => + // @ts-expect-error testing runtime behavior + createCollection({ getKey: { field: `id` }, sync: validSync }), + ).toThrow(InvalidGetKeyError) + }) + + it(`should include the actual type in the error message`, () => { + try { + // @ts-expect-error testing runtime behavior + createCollection({ getKey: 42, sync: validSync }) + expect.unreachable() + } catch (e: any) { + expect(e).toBeInstanceOf(InvalidGetKeyError) + expect(e.message).toContain(`number`) + } + }) + }) + + describe(`sync validation`, () => { + it(`should throw CollectionRequiresSyncConfigError when sync is missing`, () => { + expect(() => + // @ts-expect-error testing runtime behavior + createCollection({ getKey: (item: any) => item.id }), + ).toThrow(CollectionRequiresSyncConfigError) + }) + + it(`should throw InvalidSyncConfigError when sync is a string`, () => { + expect(() => + // @ts-expect-error testing runtime behavior + createCollection({ getKey: (item: any) => item.id, sync: `sync` }), + ).toThrow(InvalidSyncConfigError) + }) + + it(`should throw InvalidSyncConfigError when sync is a function`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + // @ts-expect-error testing runtime behavior + sync: () => {}, + }), + ).toThrow(InvalidSyncConfigError) + }) + + it(`should throw InvalidSyncFunctionError when sync.sync is missing`, () => { + expect(() => + // @ts-expect-error testing runtime behavior + createCollection({ getKey: (item: any) => item.id, sync: {} }), + ).toThrow(InvalidSyncFunctionError) + }) + + it(`should throw InvalidSyncFunctionError when sync.sync is a string`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + // @ts-expect-error testing runtime behavior + sync: { sync: `not a function` }, + }), + ).toThrow(InvalidSyncFunctionError) + }) + }) + + describe(`callback option validation`, () => { + it(`should throw InvalidCallbackOptionError when onInsert is not a function`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + onInsert: `not a function`, + }), + ).toThrow(InvalidCallbackOptionError) + }) + + it(`should throw InvalidCallbackOptionError when onUpdate is not a function`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + onUpdate: 42, + }), + ).toThrow(InvalidCallbackOptionError) + }) + + it(`should throw InvalidCallbackOptionError when onDelete is not a function`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + onDelete: true, + }), + ).toThrow(InvalidCallbackOptionError) + }) + + it(`should throw InvalidCallbackOptionError when compare is not a function`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + compare: `ascending`, + }), + ).toThrow(InvalidCallbackOptionError) + }) + + it(`should include the option name in the error message`, () => { + try { + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + onInsert: 42, + }) + expect.unreachable() + } catch (e: any) { + expect(e).toBeInstanceOf(InvalidCallbackOptionError) + expect(e.message).toContain(`onInsert`) + expect(e.message).toContain(`number`) + } + }) + }) + + describe(`option type validation`, () => { + it(`should throw InvalidOptionTypeError when id is not a string`, () => { + expect(() => + createCollection({ + // @ts-expect-error testing runtime behavior + id: 42, + getKey: (item: any) => item.id, + sync: validSync, + }), + ).toThrow(InvalidOptionTypeError) + }) + + it(`should throw InvalidOptionTypeError when gcTime is not a number`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + gcTime: `5000`, + }), + ).toThrow(InvalidOptionTypeError) + }) + + it(`should throw InvalidOptionTypeError when gcTime is NaN`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + gcTime: NaN, + }), + ).toThrow(InvalidOptionTypeError) + }) + + it(`should accept gcTime as Infinity`, () => { + const collection = createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + gcTime: Infinity, + }) + expect(collection).toBeDefined() + }) + + it(`should throw InvalidOptionTypeError when startSync is not a boolean`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + startSync: `true`, + }), + ).toThrow(InvalidOptionTypeError) + }) + + it(`should throw InvalidOptionTypeError when autoIndex is an invalid value`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + autoIndex: `lazy`, + }), + ).toThrow(InvalidOptionTypeError) + }) + + it(`should throw InvalidOptionTypeError when syncMode is an invalid value`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + syncMode: `lazy`, + }), + ).toThrow(InvalidOptionTypeError) + }) + + it(`should throw InvalidOptionTypeError when utils is not an object`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + utils: `not an object`, + }), + ).toThrow(InvalidOptionTypeError) + }) + + it(`should throw InvalidOptionTypeError when utils is an array`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + utils: [() => {}], + }), + ).toThrow(InvalidOptionTypeError) + }) + }) + + describe(`unknown property detection`, () => { + it(`should throw UnknownCollectionConfigError for unknown properties`, () => { + expect(() => + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + unknownProp: true, + }), + ).toThrow(UnknownCollectionConfigError) + }) + + it(`should suggest close matches for typos`, () => { + try { + createCollection({ + // @ts-expect-error testing runtime behavior + getkey: (item: any) => item.id, + sync: validSync, + }) + expect.unreachable() + } catch (e: any) { + expect(e).toBeInstanceOf(UnknownCollectionConfigError) + expect(e.message).toContain(`getkey`) + expect(e.message).toContain(`getKey`) + } + }) + + it(`should suggest "onInsert" for "oninsert"`, () => { + try { + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + oninsert: async () => {}, + }) + expect.unreachable() + } catch (e: any) { + expect(e).toBeInstanceOf(UnknownCollectionConfigError) + expect(e.message).toContain(`onInsert`) + } + }) + + it(`should list all valid properties in the error message`, () => { + try { + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + foo: true, + }) + expect.unreachable() + } catch (e: any) { + expect(e).toBeInstanceOf(UnknownCollectionConfigError) + expect(e.message).toContain(`Valid config properties`) + expect(e.message).toContain(`getKey`) + expect(e.message).toContain(`sync`) + } + }) + + it(`should detect multiple unknown properties at once`, () => { + try { + createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + // @ts-expect-error testing runtime behavior + foo: true, + bar: false, + }) + expect.unreachable() + } catch (e: any) { + expect(e).toBeInstanceOf(UnknownCollectionConfigError) + expect(e.message).toContain(`foo`) + expect(e.message).toContain(`bar`) + } + }) + }) + + describe(`valid configs should pass validation`, () => { + it(`should accept a minimal valid config`, () => { + const collection = createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + }) + expect(collection).toBeDefined() + }) + + it(`should accept a config with all optional properties`, () => { + const collection = createCollection({ + id: `test`, + getKey: (item: any) => item.id, + sync: validSync, + gcTime: 5000, + startSync: false, + autoIndex: `eager`, + compare: (a: any, b: any) => a.id - b.id, + syncMode: `eager`, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + utils: { helper: () => {} }, + }) + expect(collection).toBeDefined() + }) + + it(`should accept undefined optional properties`, () => { + const collection = createCollection({ + getKey: (item: any) => item.id, + sync: validSync, + id: undefined, + gcTime: undefined, + startSync: undefined, + onInsert: undefined, + onUpdate: undefined, + onDelete: undefined, + }) + expect(collection).toBeDefined() + }) + }) +}) diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index 49b48db95..7c68a1178 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -241,6 +241,9 @@ export function mockSyncCollectionOptions< }, } + // Destructure to avoid passing test-only properties (like initialData) to createCollection + const { initialData: _initialData, ...collectionConfig } = config + const options: CollectionConfig & { utils: typeof utils } = { @@ -260,7 +263,7 @@ export function mockSyncCollectionOptions< await awaitSync() }, utils, - ...config, + ...collectionConfig, autoIndex: config.autoIndex, }