From a295338bb0c87c355d8e83972c1ecb34d6b3ba69 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 23:08:30 +0000 Subject: [PATCH 1/3] feat(db): add runtime config validation to createCollection for readable error messages TypeScript errors from createCollection's 8 overloads with deeply nested generics are extremely hard to read. This adds comprehensive runtime validation that catches common misconfigurations with clear, actionable error messages including examples and typo suggestions. Validations added: - Missing or wrong-type getKey (must be a function) - Missing or wrong-type sync config (must be object with sync function) - Wrong-type callback options (onInsert, onUpdate, onDelete, compare) - Wrong-type scalar options (id, gcTime, startSync, autoIndex, syncMode, utils) - Unknown config properties with Levenshtein-based "did you mean?" suggestions Also fixes: - localStorageCollectionOptions leaking `parser` property into CollectionConfig - Test utility mockSyncCollectionOptions leaking `initialData` into CollectionConfig https://claude.ai/code/session_01SoqPVFMvsgwf2ciuSU1wRF --- packages/db/src/collection/index.ts | 7 + packages/db/src/collection/validate-config.ts | 226 ++++++++++ packages/db/src/errors.ts | 96 +++++ packages/db/src/local-storage.ts | 2 + .../collection-config-validation.test.ts | 400 ++++++++++++++++++ packages/db/tests/utils.ts | 8 +- 6 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/collection/validate-config.ts create mode 100644 packages/db/tests/collection-config-validation.test.ts diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 39f59ed73..a49791e72 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,12 @@ export function createCollection( schema?: StandardSchemaV1 }, ): Collection { + // Validate config at runtime to produce clear error messages. + // 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. + 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..3fe5242c4 --- /dev/null +++ b/packages/db/src/collection/validate-config.ts @@ -0,0 +1,226 @@ +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..a95fa79ba 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..2cecfad02 --- /dev/null +++ b/packages/db/tests/collection-config-validation.test.ts @@ -0,0 +1,400 @@ +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..d80fc10df 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -241,6 +241,12 @@ 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 +266,7 @@ export function mockSyncCollectionOptions< await awaitSync() }, utils, - ...config, + ...collectionConfig, autoIndex: config.autoIndex, } From 79e705e1515872374390fa9ebb71bcb075dfac67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 23:33:02 +0000 Subject: [PATCH 2/3] Make config validation dev-only to avoid shipping to production Wrap validateCollectionConfig() in a process.env.NODE_ENV check so bundlers can tree-shake the validation code (including Levenshtein distance, error classes, etc.) in production builds. https://claude.ai/code/session_01SoqPVFMvsgwf2ciuSU1wRF --- packages/db/src/collection/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index a49791e72..d774b19a6 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -250,11 +250,18 @@ export function createCollection( schema?: StandardSchemaV1 }, ): Collection { - // Validate config at runtime to produce clear error messages. + // 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. - validateCollectionConfig(options) + // 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, From ce5038b19dfdec7be1d8c31d22a1912e120d25c3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:43:01 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- packages/db/src/collection/index.ts | 5 +---- packages/db/src/collection/validate-config.ts | 15 +++++++++------ packages/db/src/errors.ts | 8 ++++---- .../db/tests/collection-config-validation.test.ts | 4 +--- packages/db/tests/utils.ts | 5 +---- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index d774b19a6..be037e05e 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -256,10 +256,7 @@ export function createCollection( // 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` - ) { + if (typeof process !== `undefined` && process.env.NODE_ENV !== `production`) { validateCollectionConfig(options) } diff --git a/packages/db/src/collection/validate-config.ts b/packages/db/src/collection/validate-config.ts index 3fe5242c4..e1bc01888 100644 --- a/packages/db/src/collection/validate-config.ts +++ b/packages/db/src/collection/validate-config.ts @@ -62,7 +62,10 @@ 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()) + const distance = levenshtein( + unknownKey.toLowerCase(), + validKey.toLowerCase(), + ) if (distance < bestDistance) { bestDistance = distance bestMatch = validKey @@ -164,7 +167,10 @@ export function validateCollectionConfig(config: unknown): void { // Validate gcTime if (`gcTime` in configObj && configObj.gcTime !== undefined) { - if (typeof configObj.gcTime !== `number` || Number.isNaN(configObj.gcTime)) { + if ( + typeof configObj.gcTime !== `number` || + Number.isNaN(configObj.gcTime) + ) { throw new InvalidOptionTypeError( `gcTime`, `a number`, @@ -197,10 +203,7 @@ export function validateCollectionConfig(config: unknown): void { // Validate syncMode if (`syncMode` in configObj && configObj.syncMode !== undefined) { - if ( - configObj.syncMode !== `eager` && - configObj.syncMode !== `on-demand` - ) { + if (configObj.syncMode !== `eager` && configObj.syncMode !== `on-demand`) { throw new InvalidOptionTypeError( `syncMode`, `"eager" or "on-demand"`, diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index a95fa79ba..01fbbb87e 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -159,9 +159,7 @@ export class InvalidSyncFunctionError extends CollectionConfigurationError { export class InvalidCallbackOptionError extends CollectionConfigurationError { constructor(optionName: string, actualType: string) { - super( - `"${optionName}" must be a function, but received ${actualType}.`, - ) + super(`"${optionName}" must be a function, but received ${actualType}.`) } } @@ -185,7 +183,9 @@ export class UnknownCollectionConfigError extends CollectionConfigurationError { if (suggestions.length > 0) { parts.push( `\n\nDid you mean?\n` + - suggestions.map((s) => ` "${s.unknown}" → "${s.suggestion}"`).join(`\n`), + suggestions + .map((s) => ` "${s.unknown}" → "${s.suggestion}"`) + .join(`\n`), ) } parts.push( diff --git a/packages/db/tests/collection-config-validation.test.ts b/packages/db/tests/collection-config-validation.test.ts index 2cecfad02..38b91fce4 100644 --- a/packages/db/tests/collection-config-validation.test.ts +++ b/packages/db/tests/collection-config-validation.test.ts @@ -37,9 +37,7 @@ describe(`createCollection runtime config validation`, () => { it(`should throw CollectionRequiresConfigError when an array is passed`, () => { // @ts-expect-error testing runtime behavior - expect(() => createCollection([])).toThrow( - CollectionRequiresConfigError, - ) + expect(() => createCollection([])).toThrow(CollectionRequiresConfigError) }) }) diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index d80fc10df..7c68a1178 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -242,10 +242,7 @@ export function mockSyncCollectionOptions< } // Destructure to avoid passing test-only properties (like initialData) to createCollection - const { - initialData: _initialData, - ...collectionConfig - } = config + const { initialData: _initialData, ...collectionConfig } = config const options: CollectionConfig & { utils: typeof utils