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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CollectionRequiresConfigError,
CollectionRequiresSyncConfigError,
} from '../errors'
import { validateCollectionConfig } from './validate-config'
import { currentStateAsChanges } from './change-events'

import { CollectionStateManager } from './state'
Expand Down Expand Up @@ -249,6 +250,16 @@ export function createCollection(
schema?: StandardSchemaV1
},
): Collection<any, string | number, UtilsRecord, any, any> {
// 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<any, string | number, any, any, any>(
options,
)
Expand Down
229 changes: 229 additions & 0 deletions packages/db/src/collection/validate-config.ts
Original file line number Diff line number Diff line change
@@ -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<number>> = 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<string, unknown>

// Check for unknown properties (typo detection)
// Skip properties starting with _ (internal/private convention)
const unknownKeys: Array<string> = []
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<string, unknown>
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),
)
}
}
}
96 changes: 96 additions & 0 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
suggestions: Array<{ unknown: string; suggestion: string }>,
) {
const parts: Array<string> = []
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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading