UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

448 lines (447 loc) 13.4 kB
import { CollectionRequiresConfigError, CollectionRequiresSyncConfigError } from "../errors.js"; import { currentStateAsChanges } from "./change-events.js"; import { CollectionStateManager } from "./state.js"; import { CollectionChangesManager } from "./changes.js"; import { CollectionLifecycleManager } from "./lifecycle.js"; import { CollectionSyncManager } from "./sync.js"; import { CollectionIndexesManager } from "./indexes.js"; import { CollectionMutationsManager } from "./mutations.js"; import { CollectionEventsManager } from "./events.js"; function createCollection(options) { const collection = new CollectionImpl( options ); if (options.utils) { collection.utils = options.utils; } else { collection.utils = {}; } return collection; } class CollectionImpl { /** * Creates a new Collection instance * * @param config - Configuration object for the collection * @throws Error if sync config is missing */ constructor(config) { this.utils = {}; this.insert = (data, config2) => { return this._mutations.insert(data, config2); }; this.delete = (keys, config2) => { return this._mutations.delete(keys, config2); }; if (!config) { throw new CollectionRequiresConfigError(); } if (!config.sync) { throw new CollectionRequiresSyncConfigError(); } if (config.id) { this.id = config.id; } else { this.id = crypto.randomUUID(); } this.config = { ...config, autoIndex: config.autoIndex ?? `eager` }; this._changes = new CollectionChangesManager(); this._events = new CollectionEventsManager(); this._indexes = new CollectionIndexesManager(); this._lifecycle = new CollectionLifecycleManager(config, this.id); this._mutations = new CollectionMutationsManager(config, this.id); this._state = new CollectionStateManager(config); this._sync = new CollectionSyncManager(config, this.id); this.comparisonOpts = buildCompareOptionsFromConfig(config); this._changes.setDeps({ collection: this, // Required for passing to CollectionSubscription lifecycle: this._lifecycle, sync: this._sync, events: this._events }); this._events.setDeps({ collection: this // Required for adding to emitted events }); this._indexes.setDeps({ state: this._state, lifecycle: this._lifecycle }); this._lifecycle.setDeps({ changes: this._changes, events: this._events, indexes: this._indexes, state: this._state, sync: this._sync }); this._mutations.setDeps({ collection: this, // Required for passing to config.onInsert/onUpdate/onDelete and annotating mutations lifecycle: this._lifecycle, state: this._state }); this._state.setDeps({ collection: this, // Required for filtering events to only include this collection lifecycle: this._lifecycle, changes: this._changes, indexes: this._indexes, events: this._events }); this._sync.setDeps({ collection: this, // Required for passing to config.sync callback state: this._state, lifecycle: this._lifecycle, events: this._events }); if (config.startSync === true) { this._sync.startSync(); } } /** * Gets the current status of the collection */ get status() { return this._lifecycle.status; } /** * Get the number of subscribers to the collection */ get subscriberCount() { return this._changes.activeSubscribersCount; } /** * Register a callback to be executed when the collection first becomes ready * Useful for preloading collections * @param callback Function to call when the collection first becomes ready * @example * collection.onFirstReady(() => { * console.log('Collection is ready for the first time') * // Safe to access collection.state now * }) */ onFirstReady(callback) { return this._lifecycle.onFirstReady(callback); } /** * Check if the collection is ready for use * Returns true if the collection has been marked as ready by its sync implementation * @returns true if the collection is ready, false otherwise * @example * if (collection.isReady()) { * console.log('Collection is ready, data is available') * // Safe to access collection.state * } else { * console.log('Collection is still loading') * } */ isReady() { return this._lifecycle.status === `ready`; } /** * Check if the collection is currently loading more data * @returns true if the collection has pending load more operations, false otherwise */ get isLoadingSubset() { return this._sync.isLoadingSubset; } /** * Start sync immediately - internal method for compiled queries * This bypasses lazy loading for special cases like live query results */ startSyncImmediate() { this._sync.startSync(); } /** * Preload the collection data by starting sync if not already started * Multiple concurrent calls will share the same promise */ preload() { return this._sync.preload(); } /** * Get the current value for a key (virtual derived state) */ get(key) { return this._state.get(key); } /** * Check if a key exists in the collection (virtual derived state) */ has(key) { return this._state.has(key); } /** * Get the current size of the collection (cached) */ get size() { return this._state.size; } /** * Get all keys (virtual derived state) */ *keys() { yield* this._state.keys(); } /** * Get all values (virtual derived state) */ *values() { yield* this._state.values(); } /** * Get all entries (virtual derived state) */ *entries() { yield* this._state.entries(); } /** * Get all entries (virtual derived state) */ *[Symbol.iterator]() { yield* this._state[Symbol.iterator](); } /** * Execute a callback for each entry in the collection */ forEach(callbackfn) { return this._state.forEach(callbackfn); } /** * Create a new array with the results of calling a function for each entry in the collection */ map(callbackfn) { return this._state.map(callbackfn); } getKeyFromItem(item) { return this.config.getKey(item); } /** * Creates an index on a collection for faster queries. * Indexes significantly improve query performance by allowing constant time lookups * and logarithmic time range queries instead of full scans. * * @template TResolver - The type of the index resolver (constructor or async loader) * @param indexCallback - Function that extracts the indexed value from each item * @param config - Configuration including index type and type-specific options * @returns An index proxy that provides access to the index when ready * * @example * // Create a default B+ tree index * const ageIndex = collection.createIndex((row) => row.age) * * // Create a ordered index with custom options * const ageIndex = collection.createIndex((row) => row.age, { * indexType: BTreeIndex, * options: { * compareFn: customComparator, * compareOptions: { direction: 'asc', nulls: 'first', stringSort: 'lexical' } * }, * name: 'age_btree' * }) * * // Create an async-loaded index * const textIndex = collection.createIndex((row) => row.content, { * indexType: async () => { * const { FullTextIndex } = await import('./indexes/fulltext.js') * return FullTextIndex * }, * options: { language: 'en' } * }) */ createIndex(indexCallback, config = {}) { return this._indexes.createIndex(indexCallback, config); } /** * Get resolved indexes for query optimization */ get indexes() { return this._indexes.indexes; } /** * Validates the data against the schema */ validateData(data, type, key) { return this._mutations.validateData(data, type, key); } get compareOptions() { return { ...this.comparisonOpts }; } update(keys, configOrCallback, maybeCallback) { return this._mutations.update(keys, configOrCallback, maybeCallback); } /** * Gets the current state of the collection as a Map * @returns Map containing all items in the collection, with keys as identifiers * @example * const itemsMap = collection.state * console.log(`Collection has ${itemsMap.size} items`) * * for (const [key, item] of itemsMap) { * console.log(`${key}: ${item.title}`) * } * * // Check if specific item exists * if (itemsMap.has("todo-1")) { * console.log("Todo 1 exists:", itemsMap.get("todo-1")) * } */ get state() { const result = /* @__PURE__ */ new Map(); for (const [key, value] of this.entries()) { result.set(key, value); } return result; } /** * Gets the current state of the collection as a Map, but only resolves when data is available * Waits for the first sync commit to complete before resolving * * @returns Promise that resolves to a Map containing all items in the collection */ stateWhenReady() { if (this.size > 0 || this.isReady()) { return Promise.resolve(this.state); } return this.preload().then(() => this.state); } /** * Gets the current state of the collection as an Array * * @returns An Array containing all items in the collection */ get toArray() { return Array.from(this.values()); } /** * Gets the current state of the collection as an Array, but only resolves when data is available * Waits for the first sync commit to complete before resolving * * @returns Promise that resolves to an Array containing all items in the collection */ toArrayWhenReady() { if (this.size > 0 || this.isReady()) { return Promise.resolve(this.toArray); } return this.preload().then(() => this.toArray); } /** * Returns the current state of the collection as an array of changes * @param options - Options including optional where filter * @returns An array of changes * @example * // Get all items as changes * const allChanges = collection.currentStateAsChanges() * * // Get only items matching a condition * const activeChanges = collection.currentStateAsChanges({ * where: (row) => row.status === 'active' * }) * * // Get only items using a pre-compiled expression * const activeChanges = collection.currentStateAsChanges({ * whereExpression: eq(row.status, 'active') * }) */ currentStateAsChanges(options = {}) { return currentStateAsChanges(this, options); } /** * Subscribe to changes in the collection * @param callback - Function called when items change * @param options - Subscription options including includeInitialState and where filter * @returns Unsubscribe function - Call this to stop listening for changes * @example * // Basic subscription * const subscription = collection.subscribeChanges((changes) => { * changes.forEach(change => { * console.log(`${change.type}: ${change.key}`, change.value) * }) * }) * * // Later: subscription.unsubscribe() * * @example * // Include current state immediately * const subscription = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { includeInitialState: true }) * * @example * // Subscribe only to changes matching a condition * const subscription = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { * includeInitialState: true, * where: (row) => row.status === 'active' * }) * * @example * // Subscribe using a pre-compiled expression * const subscription = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { * includeInitialState: true, * whereExpression: eq(row.status, 'active') * }) */ subscribeChanges(callback, options = {}) { return this._changes.subscribeChanges(callback, options); } /** * Subscribe to a collection event */ on(event, callback) { return this._events.on(event, callback); } /** * Subscribe to a collection event once */ once(event, callback) { return this._events.once(event, callback); } /** * Unsubscribe from a collection event */ off(event, callback) { this._events.off(event, callback); } /** * Wait for a collection event */ waitFor(event, timeout) { return this._events.waitFor(event, timeout); } /** * Clean up the collection by stopping sync and clearing data * This can be called manually or automatically by garbage collection */ async cleanup() { this._lifecycle.cleanup(); return Promise.resolve(); } } function buildCompareOptionsFromConfig(config) { if (config.defaultStringCollation) { const options = config.defaultStringCollation; return { stringSort: options.stringSort ?? `locale`, locale: options.stringSort === `locale` ? options.locale : void 0, localeOptions: options.stringSort === `locale` ? options.localeOptions : void 0 }; } else { return { stringSort: `locale` }; } } export { CollectionImpl, createCollection }; //# sourceMappingURL=index.js.map