UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

380 lines (333 loc) 11.2 kB
import { createSingleRowRefProxy, toExpression, } from '../query/builder/ref-proxy' import { CollectionConfigurationError } from '../errors' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { BaseIndex, IndexConstructor } from '../indexes/base-index' import type { ChangeMessage } from '../types' import type { IndexOptions } from '../indexes/index-options' import type { SingleRowRefProxy } from '../query/builder/ref-proxy' import type { CollectionLifecycleManager } from './lifecycle' import type { CollectionStateManager } from './state' import type { BasicExpression } from '../query/ir' import type { CollectionEventsManager, CollectionIndexMetadata, CollectionIndexResolverMetadata, CollectionIndexSerializableValue, } from './events' const INDEX_SIGNATURE_VERSION = 1 as const function compareStringsCodePoint(left: string, right: string): number { if (left === right) { return 0 } return left < right ? -1 : 1 } function resolveResolverMetadata<TKey extends string | number>( resolver: IndexConstructor<TKey>, ): CollectionIndexResolverMetadata { return { kind: `constructor`, ...(resolver.name ? { name: resolver.name } : {}), } } function toSerializableIndexValue( value: unknown, ): CollectionIndexSerializableValue | undefined { if (value == null) { return value } switch (typeof value) { case `string`: case `boolean`: return value case `number`: return Number.isFinite(value) ? value : null case `bigint`: return { __type: `bigint`, value: value.toString() } case `function`: case `symbol`: // Function and symbol identity are process-local and not stable across runtimes. // Dropping them keeps signatures deterministic; we may skip index reuse, which is acceptable. return undefined case `undefined`: return undefined } if (Array.isArray(value)) { return value.map((entry) => toSerializableIndexValue(entry) ?? null) } if (value instanceof Date) { return { __type: `date`, value: value.toISOString(), } } if (value instanceof Set) { const serializedValues = Array.from(value) .map((entry) => toSerializableIndexValue(entry) ?? null) .sort((a, b) => compareStringsCodePoint( stableStringifyCollectionIndexValue(a), stableStringifyCollectionIndexValue(b), ), ) return { __type: `set`, values: serializedValues, } } if (value instanceof Map) { const serializedEntries = Array.from(value.entries()) .map(([mapKey, mapValue]) => ({ key: toSerializableIndexValue(mapKey) ?? null, value: toSerializableIndexValue(mapValue) ?? null, })) .sort((a, b) => compareStringsCodePoint( stableStringifyCollectionIndexValue(a.key), stableStringifyCollectionIndexValue(b.key), ), ) return { __type: `map`, entries: serializedEntries, } } if (value instanceof RegExp) { return { __type: `regexp`, value: value.toString(), } } const serializedObject: Record<string, CollectionIndexSerializableValue> = {} const entries = Object.entries(value as Record<string, unknown>).sort( ([leftKey], [rightKey]) => compareStringsCodePoint(leftKey, rightKey), ) for (const [key, entryValue] of entries) { const serializedEntry = toSerializableIndexValue(entryValue) if (serializedEntry !== undefined) { serializedObject[key] = serializedEntry } } return serializedObject } function stableStringifyCollectionIndexValue( value: CollectionIndexSerializableValue, ): string { if (value === null) { return `null` } if (Array.isArray(value)) { return `[${value.map(stableStringifyCollectionIndexValue).join(`,`)}]` } if (typeof value !== `object`) { return JSON.stringify(value) } const sortedKeys = Object.keys(value).sort((left, right) => compareStringsCodePoint(left, right), ) const serializedEntries = sortedKeys.map( (key) => `${JSON.stringify(key)}:${stableStringifyCollectionIndexValue(value[key]!)}`, ) return `{${serializedEntries.join(`,`)}}` } function createCollectionIndexMetadata<TKey extends string | number>( indexId: number, expression: BasicExpression, name: string | undefined, resolver: IndexConstructor<TKey>, options: unknown, ): CollectionIndexMetadata { const resolverMetadata = resolveResolverMetadata(resolver) const serializedExpression = toSerializableIndexValue(expression) ?? null const serializedOptions = toSerializableIndexValue(options) const signatureInput = toSerializableIndexValue({ signatureVersion: INDEX_SIGNATURE_VERSION, expression: serializedExpression, options: serializedOptions ?? null, }) const normalizedSignatureInput = signatureInput ?? null const signature = stableStringifyCollectionIndexValue( normalizedSignatureInput, ) return { signatureVersion: INDEX_SIGNATURE_VERSION, signature, indexId, name, expression, resolver: resolverMetadata, ...(serializedOptions === undefined ? {} : { options: serializedOptions }), } } function cloneSerializableIndexValue( value: CollectionIndexSerializableValue, ): CollectionIndexSerializableValue { if (value === null || typeof value !== `object`) { return value } if (Array.isArray(value)) { return value.map((entry) => cloneSerializableIndexValue(entry)) } const cloned: Record<string, CollectionIndexSerializableValue> = {} for (const [key, entryValue] of Object.entries(value)) { cloned[key] = cloneSerializableIndexValue(entryValue) } return cloned } function cloneExpression(expression: BasicExpression): BasicExpression { return JSON.parse(JSON.stringify(expression)) as BasicExpression } export class CollectionIndexesManager< TOutput extends object = Record<string, unknown>, TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = StandardSchemaV1, TInput extends object = TOutput, > { private lifecycle!: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput> private state!: CollectionStateManager<TOutput, TKey, TSchema, TInput> private defaultIndexType: IndexConstructor<TKey> | undefined private events!: CollectionEventsManager public indexes = new Map<number, BaseIndex<TKey>>() public indexMetadata = new Map<number, CollectionIndexMetadata>() public indexCounter = 0 constructor() {} setDeps(deps: { state: CollectionStateManager<TOutput, TKey, TSchema, TInput> lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput> defaultIndexType?: IndexConstructor<TKey> events: CollectionEventsManager }) { this.state = deps.state this.lifecycle = deps.lifecycle this.defaultIndexType = deps.defaultIndexType this.events = deps.events } /** * Creates an index on a collection for faster queries. * * @example * ```ts * // With explicit index type (recommended for tree-shaking) * import { BasicIndex } from '@tanstack/db' * collection.createIndex((row) => row.userId, { indexType: BasicIndex }) * * // With collection's default index type * collection.createIndex((row) => row.userId) * ``` */ public createIndex<TIndexType extends IndexConstructor<TKey>>( indexCallback: (row: SingleRowRefProxy<TOutput>) => any, config: IndexOptions<TIndexType> = {}, ): BaseIndex<TKey> { this.lifecycle.validateCollectionUsable(`createIndex`) const indexId = ++this.indexCounter const singleRowRefProxy = createSingleRowRefProxy<TOutput>() const indexExpression = indexCallback(singleRowRefProxy) const expression = toExpression(indexExpression) // Use provided index type, or fall back to collection's default const IndexType = config.indexType ?? this.defaultIndexType if (!IndexType) { throw new CollectionConfigurationError( `No index type specified and no defaultIndexType set on collection. ` + `Either pass indexType in config, or set defaultIndexType on the collection:\n` + ` import { BasicIndex } from '@tanstack/db'\n` + ` createCollection({ defaultIndexType: BasicIndex, ... })`, ) } // Create index synchronously const index = new IndexType( indexId, expression, config.name, config.options, ) // Build with current data index.build(this.state.entries()) this.indexes.set(indexId, index) // Track metadata and emit event const metadata = createCollectionIndexMetadata( indexId, expression, config.name, IndexType, config.options, ) this.indexMetadata.set(indexId, metadata) this.events.emitIndexAdded(metadata) return index } /** * Removes an index from this collection. * Returns true when an index existed and was removed, false otherwise. */ public removeIndex(indexOrId: BaseIndex<TKey> | number): boolean { this.lifecycle.validateCollectionUsable(`removeIndex`) const indexId = typeof indexOrId === `number` ? indexOrId : indexOrId.id const index = this.indexes.get(indexId) if (!index) { return false } if (typeof indexOrId !== `number` && index !== indexOrId) { // Passed a different index instance with the same id — do not remove. return false } this.indexes.delete(indexId) const metadata = this.indexMetadata.get(indexId) this.indexMetadata.delete(indexId) if (metadata) { this.events.emitIndexRemoved(metadata) } return true } /** * Returns a sorted snapshot of index metadata. * This allows persisted wrappers to bootstrap from indexes that were created * before they attached lifecycle listeners. */ public getIndexMetadataSnapshot(): Array<CollectionIndexMetadata> { return Array.from(this.indexMetadata.values()) .sort((left, right) => left.indexId - right.indexId) .map((metadata) => ({ ...metadata, expression: cloneExpression(metadata.expression), resolver: { ...metadata.resolver }, ...(metadata.options === undefined ? {} : { options: cloneSerializableIndexValue(metadata.options) }), })) } /** * Updates all indexes when the collection changes */ public updateIndexes(changes: Array<ChangeMessage<TOutput, TKey>>): void { for (const index of this.indexes.values()) { for (const change of changes) { switch (change.type) { case `insert`: index.add(change.key, change.value) break case `update`: if (change.previousValue) { index.update(change.key, change.previousValue, change.value) } else { index.add(change.key, change.value) } break case `delete`: index.remove(change.key, change.value) break } } } } /** * Clean up indexes */ public cleanup(): void { this.indexes.clear() this.indexMetadata.clear() } }