@tanstack/db
Version:
A reactive client store for building super fast apps on sync
448 lines (447 loc) • 13.4 kB
JavaScript
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