@tanstack/db
Version:
A reactive client store for building super fast apps on sync
846 lines (845 loc) • 28.1 kB
JavaScript
import { Store } from "@tanstack/store";
import { withArrayChangeTracking, withChangeTracking } from "./proxy.js";
import { getActiveTransaction, Transaction } from "./transactions.js";
import { SortedMap } from "./SortedMap.js";
const collectionsStore = /* @__PURE__ */ new Map();
const loadingCollectionResolvers = /* @__PURE__ */ new Map();
function createCollection(options) {
const collection = new CollectionImpl(options);
if (options.utils) {
collection.utils = { ...options.utils };
} else {
collection.utils = {};
}
return collection;
}
function preloadCollection(config) {
if (!config.id) {
throw new Error(`The id property is required for preloadCollection`);
}
if (collectionsStore.has(config.id) && !loadingCollectionResolvers.has(config.id)) {
return Promise.resolve(
collectionsStore.get(config.id)
);
}
if (loadingCollectionResolvers.has(config.id)) {
return loadingCollectionResolvers.get(config.id).promise;
}
if (!collectionsStore.has(config.id)) {
collectionsStore.set(
config.id,
createCollection({
id: config.id,
getKey: config.getKey,
sync: config.sync,
schema: config.schema
})
);
}
const collection = collectionsStore.get(config.id);
let resolveFirstCommit;
const firstCommitPromise = new Promise((resolve) => {
resolveFirstCommit = resolve;
});
loadingCollectionResolvers.set(config.id, {
promise: firstCommitPromise,
resolve: resolveFirstCommit
});
collection.onFirstCommit(() => {
if (!config.id) {
throw new Error(`The id property is required for preloadCollection`);
}
if (loadingCollectionResolvers.has(config.id)) {
const resolver = loadingCollectionResolvers.get(config.id);
loadingCollectionResolvers.delete(config.id);
resolver.resolve(collection);
}
});
return firstCommitPromise;
}
class SchemaValidationError extends Error {
constructor(type, issues, message) {
const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues.map((issue) => issue.message).join(`, `)}`;
super(message || defaultMessage);
this.name = `SchemaValidationError`;
this.type = type;
this.issues = issues;
}
}
class CollectionImpl {
/**
* Creates a new Collection instance
*
* @param config - Configuration object for the collection
* @throws Error if sync config is missing
*/
constructor(config) {
this.syncedData = /* @__PURE__ */ new Map();
this.syncedMetadata = /* @__PURE__ */ new Map();
this.derivedUpserts = /* @__PURE__ */ new Map();
this.derivedDeletes = /* @__PURE__ */ new Set();
this._size = 0;
this.changeListeners = /* @__PURE__ */ new Set();
this.changeKeyListeners = /* @__PURE__ */ new Map();
this.utils = {};
this.pendingSyncedTransactions = [];
this.syncedKeys = /* @__PURE__ */ new Set();
this.hasReceivedFirstCommit = false;
this.onFirstCommitCallbacks = [];
this.id = ``;
this.commitPendingTransactions = () => {
if (!Array.from(this.transactions.values()).some(
({ state }) => state === `persisting`
)) {
const changedKeys = /* @__PURE__ */ new Set();
const events = [];
for (const transaction of this.pendingSyncedTransactions) {
for (const operation of transaction.operations) {
const key = operation.key;
changedKeys.add(key);
this.syncedKeys.add(key);
switch (operation.type) {
case `insert`:
this.syncedMetadata.set(key, operation.metadata);
break;
case `update`:
this.syncedMetadata.set(
key,
Object.assign(
{},
this.syncedMetadata.get(key),
operation.metadata
)
);
break;
case `delete`:
this.syncedMetadata.delete(key);
break;
}
const previousValue = this.syncedData.get(key);
switch (operation.type) {
case `insert`:
this.syncedData.set(key, operation.value);
if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
events.push({
type: `insert`,
key,
value: operation.value
});
}
break;
case `update`: {
const updatedValue = Object.assign(
{},
this.syncedData.get(key),
operation.value
);
this.syncedData.set(key, updatedValue);
if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
events.push({
type: `update`,
key,
value: updatedValue,
previousValue
});
}
break;
}
case `delete`:
this.syncedData.delete(key);
if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
if (previousValue) {
events.push({
type: `delete`,
key,
value: previousValue
});
}
}
break;
}
}
}
this._size = this.calculateSize();
this.emitEvents(events);
this.pendingSyncedTransactions = [];
if (!this.hasReceivedFirstCommit) {
this.hasReceivedFirstCommit = true;
const callbacks = [...this.onFirstCommitCallbacks];
this.onFirstCommitCallbacks = [];
callbacks.forEach((callback) => callback());
}
}
};
this.insert = (data, config2) => {
const ambientTransaction = getActiveTransaction();
if (!ambientTransaction && !this.config.onInsert) {
throw new Error(
`Collection.insert called directly (not within an explicit transaction) but no 'onInsert' handler is configured.`
);
}
const items = Array.isArray(data) ? data : [data];
const mutations = [];
items.forEach((item) => {
var _a, _b;
const validatedData = this.validateData(item, `insert`);
const key = this.getKeyFromItem(item);
if (this.has(key)) {
throw `Cannot insert document with ID "${key}" because it already exists in the collection`;
}
const globalKey = this.generateGlobalKey(key, item);
const mutation = {
mutationId: crypto.randomUUID(),
original: {},
modified: validatedData,
changes: validatedData,
globalKey,
key,
metadata: config2 == null ? void 0 : config2.metadata,
syncMetadata: ((_b = (_a = this.config.sync).getSyncMetadata) == null ? void 0 : _b.call(_a)) || {},
type: `insert`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date(),
collection: this
};
mutations.push(mutation);
});
if (ambientTransaction) {
ambientTransaction.applyMutations(mutations);
this.transactions.set(ambientTransaction.id, ambientTransaction);
this.recomputeOptimisticState();
return ambientTransaction;
} else {
const directOpTransaction = new Transaction({
mutationFn: async (params) => {
return this.config.onInsert(params);
}
});
directOpTransaction.applyMutations(mutations);
directOpTransaction.commit();
this.transactions.set(directOpTransaction.id, directOpTransaction);
this.recomputeOptimisticState();
return directOpTransaction;
}
};
this.delete = (keys, config2) => {
const ambientTransaction = getActiveTransaction();
if (!ambientTransaction && !this.config.onDelete) {
throw new Error(
`Collection.delete called directly (not within an explicit transaction) but no 'onDelete' handler is configured.`
);
}
if (Array.isArray(keys) && keys.length === 0) {
throw new Error(`No keys were passed to delete`);
}
const keysArray = Array.isArray(keys) ? keys : [keys];
const mutations = [];
for (const key of keysArray) {
const globalKey = this.generateGlobalKey(key, this.get(key));
const mutation = {
mutationId: crypto.randomUUID(),
original: this.get(key) || {},
modified: this.get(key),
changes: this.get(key) || {},
globalKey,
key,
metadata: config2 == null ? void 0 : config2.metadata,
syncMetadata: this.syncedMetadata.get(key) || {},
type: `delete`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date(),
collection: this
};
mutations.push(mutation);
}
if (ambientTransaction) {
ambientTransaction.applyMutations(mutations);
this.transactions.set(ambientTransaction.id, ambientTransaction);
this.recomputeOptimisticState();
return ambientTransaction;
}
const directOpTransaction = new Transaction({
autoCommit: true,
mutationFn: async (params) => {
return this.config.onDelete(params);
}
});
directOpTransaction.applyMutations(mutations);
directOpTransaction.commit();
this.transactions.set(directOpTransaction.id, directOpTransaction);
this.recomputeOptimisticState();
return directOpTransaction;
};
if (!config) {
throw new Error(`Collection requires a config`);
}
if (config.id) {
this.id = config.id;
} else {
this.id = crypto.randomUUID();
}
if (!config.sync) {
throw new Error(`Collection requires a sync config`);
}
this.transactions = new SortedMap(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
);
this.config = config;
config.sync.sync({
collection: this,
begin: () => {
this.pendingSyncedTransactions.push({
committed: false,
operations: []
});
},
write: (messageWithoutKey) => {
const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
if (!pendingTransaction) {
throw new Error(`No pending sync transaction to write to`);
}
if (pendingTransaction.committed) {
throw new Error(
`The pending sync transaction is already committed, you can't still write to it.`
);
}
const key = this.getKeyFromItem(messageWithoutKey.value);
if (messageWithoutKey.type === `insert`) {
if (this.syncedData.has(key) && !pendingTransaction.operations.some(
(op) => op.key === key && op.type === `delete`
)) {
throw new Error(
`Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
);
}
}
const message = {
...messageWithoutKey,
key
};
pendingTransaction.operations.push(message);
},
commit: () => {
const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
if (!pendingTransaction) {
throw new Error(`No pending sync transaction to commit`);
}
if (pendingTransaction.committed) {
throw new Error(
`The pending sync transaction is already committed, you can't commit it again.`
);
}
pendingTransaction.committed = true;
this.commitPendingTransactions();
}
});
}
/**
* Register a callback to be executed on the next commit
* Useful for preloading collections
* @param callback Function to call after the next commit
*/
onFirstCommit(callback) {
this.onFirstCommitCallbacks.push(callback);
}
/**
* Recompute optimistic state from active transactions
*/
recomputeOptimisticState() {
const previousState = new Map(this.derivedUpserts);
const previousDeletes = new Set(this.derivedDeletes);
this.derivedUpserts.clear();
this.derivedDeletes.clear();
const activeTransactions = Array.from(this.transactions.values());
for (const transaction of activeTransactions) {
if (![`completed`, `failed`].includes(transaction.state)) {
for (const mutation of transaction.mutations) {
if (mutation.collection === this) {
switch (mutation.type) {
case `insert`:
case `update`:
this.derivedUpserts.set(mutation.key, mutation.modified);
this.derivedDeletes.delete(mutation.key);
break;
case `delete`:
this.derivedUpserts.delete(mutation.key);
this.derivedDeletes.add(mutation.key);
break;
}
}
}
}
}
this._size = this.calculateSize();
const events = [];
this.collectOptimisticChanges(previousState, previousDeletes, events);
this.emitEvents(events);
}
/**
* Calculate the current size based on synced data and optimistic changes
*/
calculateSize() {
const syncedSize = this.syncedData.size;
const deletesFromSynced = Array.from(this.derivedDeletes).filter(
(key) => this.syncedData.has(key) && !this.derivedUpserts.has(key)
).length;
const upsertsNotInSynced = Array.from(this.derivedUpserts.keys()).filter(
(key) => !this.syncedData.has(key)
).length;
return syncedSize - deletesFromSynced + upsertsNotInSynced;
}
/**
* Collect events for optimistic changes
*/
collectOptimisticChanges(previousUpserts, previousDeletes, events) {
const allKeys = /* @__PURE__ */ new Set([
...previousUpserts.keys(),
...this.derivedUpserts.keys(),
...previousDeletes,
...this.derivedDeletes
]);
for (const key of allKeys) {
const currentValue = this.get(key);
const previousValue = this.getPreviousValue(
key,
previousUpserts,
previousDeletes
);
if (previousValue !== void 0 && currentValue === void 0) {
events.push({ type: `delete`, key, value: previousValue });
} else if (previousValue === void 0 && currentValue !== void 0) {
events.push({ type: `insert`, key, value: currentValue });
} else if (previousValue !== void 0 && currentValue !== void 0 && previousValue !== currentValue) {
events.push({
type: `update`,
key,
value: currentValue,
previousValue
});
}
}
}
/**
* Get the previous value for a key given previous optimistic state
*/
getPreviousValue(key, previousUpserts, previousDeletes) {
if (previousDeletes.has(key)) {
return void 0;
}
if (previousUpserts.has(key)) {
return previousUpserts.get(key);
}
return this.syncedData.get(key);
}
/**
* Emit multiple events at once to all listeners
*/
emitEvents(changes) {
if (changes.length > 0) {
for (const listener of this.changeListeners) {
listener(changes);
}
if (this.changeKeyListeners.size > 0) {
const changesByKey = /* @__PURE__ */ new Map();
for (const change of changes) {
if (this.changeKeyListeners.has(change.key)) {
if (!changesByKey.has(change.key)) {
changesByKey.set(change.key, []);
}
changesByKey.get(change.key).push(change);
}
}
for (const [key, keyChanges] of changesByKey) {
const keyListeners = this.changeKeyListeners.get(key);
for (const listener of keyListeners) {
listener(keyChanges);
}
}
}
}
}
/**
* Get the current value for a key (virtual derived state)
*/
get(key) {
if (this.derivedDeletes.has(key)) {
return void 0;
}
if (this.derivedUpserts.has(key)) {
return this.derivedUpserts.get(key);
}
return this.syncedData.get(key);
}
/**
* Check if a key exists in the collection (virtual derived state)
*/
has(key) {
if (this.derivedDeletes.has(key)) {
return false;
}
if (this.derivedUpserts.has(key)) {
return true;
}
return this.syncedData.has(key);
}
/**
* Get the current size of the collection (cached)
*/
get size() {
return this._size;
}
/**
* Get all keys (virtual derived state)
*/
*keys() {
for (const key of this.syncedData.keys()) {
if (!this.derivedDeletes.has(key)) {
yield key;
}
}
for (const key of this.derivedUpserts.keys()) {
if (!this.syncedData.has(key) && !this.derivedDeletes.has(key)) {
yield key;
}
}
}
/**
* Get all values (virtual derived state)
*/
*values() {
for (const key of this.keys()) {
const value = this.get(key);
if (value !== void 0) {
yield value;
}
}
}
/**
* Get all entries (virtual derived state)
*/
*entries() {
for (const key of this.keys()) {
const value = this.get(key);
if (value !== void 0) {
yield [key, value];
}
}
}
ensureStandardSchema(schema) {
if (schema && typeof schema === `object` && `~standard` in schema) {
return schema;
}
throw new Error(
`Schema must either implement the standard-schema interface or be a Zod schema`
);
}
getKeyFromItem(item) {
return this.config.getKey(item);
}
generateGlobalKey(key, item) {
if (typeof key === `undefined`) {
throw new Error(
`An object was created without a defined key: ${JSON.stringify(item)}`
);
}
return `KEY::${this.id}/${key}`;
}
validateData(data, type, key) {
if (!this.config.schema) return data;
const standardSchema = this.ensureStandardSchema(this.config.schema);
if (type === `update` && key) {
const existingData = this.get(key);
if (existingData && data && typeof data === `object` && typeof existingData === `object`) {
const mergedData = Object.assign({}, existingData, data);
const result2 = standardSchema[`~standard`].validate(mergedData);
if (result2 instanceof Promise) {
throw new TypeError(`Schema validation must be synchronous`);
}
if (`issues` in result2 && result2.issues) {
const typedIssues = result2.issues.map((issue) => {
var _a;
return {
message: issue.message,
path: (_a = issue.path) == null ? void 0 : _a.map((p) => String(p))
};
});
throw new SchemaValidationError(type, typedIssues);
}
return data;
}
}
const result = standardSchema[`~standard`].validate(data);
if (result instanceof Promise) {
throw new TypeError(`Schema validation must be synchronous`);
}
if (`issues` in result && result.issues) {
const typedIssues = result.issues.map((issue) => {
var _a;
return {
message: issue.message,
path: (_a = issue.path) == null ? void 0 : _a.map((p) => String(p))
};
});
throw new SchemaValidationError(type, typedIssues);
}
return result.value;
}
update(keys, configOrCallback, maybeCallback) {
if (typeof keys === `undefined`) {
throw new Error(`The first argument to update is missing`);
}
const ambientTransaction = getActiveTransaction();
if (!ambientTransaction && !this.config.onUpdate) {
throw new Error(
`Collection.update called directly (not within an explicit transaction) but no 'onUpdate' handler is configured.`
);
}
const isArray = Array.isArray(keys);
const keysArray = isArray ? keys : [keys];
if (isArray && keysArray.length === 0) {
throw new Error(`No keys were passed to update`);
}
const callback = typeof configOrCallback === `function` ? configOrCallback : maybeCallback;
const config = typeof configOrCallback === `function` ? {} : configOrCallback;
const currentObjects = keysArray.map((key) => {
const item = this.get(key);
if (!item) {
throw new Error(
`The key "${key}" was passed to update but an object for this key was not found in the collection`
);
}
return item;
});
let changesArray;
if (isArray) {
changesArray = withArrayChangeTracking(
currentObjects,
callback
);
} else {
const result = withChangeTracking(
currentObjects[0],
callback
);
changesArray = [result];
}
const mutations = keysArray.map((key, index) => {
const itemChanges = changesArray[index];
if (!itemChanges || Object.keys(itemChanges).length === 0) {
return null;
}
const originalItem = currentObjects[index];
const validatedUpdatePayload = this.validateData(
itemChanges,
`update`,
key
);
const modifiedItem = Object.assign(
{},
originalItem,
validatedUpdatePayload
);
const originalItemId = this.getKeyFromItem(originalItem);
const modifiedItemId = this.getKeyFromItem(modifiedItem);
if (originalItemId !== modifiedItemId) {
throw new Error(
`Updating the key of an item is not allowed. Original key: "${originalItemId}", Attempted new key: "${modifiedItemId}". Please delete the old item and create a new one if a key change is necessary.`
);
}
const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem);
return {
mutationId: crypto.randomUUID(),
original: originalItem,
modified: modifiedItem,
changes: validatedUpdatePayload,
globalKey,
key,
metadata: config.metadata,
syncMetadata: this.syncedMetadata.get(key) || {},
type: `update`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date(),
collection: this
};
}).filter(Boolean);
if (mutations.length === 0) {
const emptyTransaction = new Transaction({
mutationFn: async () => {
}
});
emptyTransaction.commit();
return emptyTransaction;
}
if (ambientTransaction) {
ambientTransaction.applyMutations(mutations);
this.transactions.set(ambientTransaction.id, ambientTransaction);
this.recomputeOptimisticState();
return ambientTransaction;
}
const directOpTransaction = new Transaction({
mutationFn: async (params) => {
return this.config.onUpdate(params);
}
});
directOpTransaction.applyMutations(mutations);
directOpTransaction.commit();
this.transactions.set(directOpTransaction.id, directOpTransaction);
this.recomputeOptimisticState();
return directOpTransaction;
}
/**
* Gets the current state of the collection as a Map
*
* @returns A Map containing all items in the collection, with keys as identifiers
*/
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.hasReceivedFirstCommit === true) {
return Promise.resolve(this.state);
}
return new Promise((resolve) => {
this.onFirstCommit(() => {
resolve(this.state);
});
});
}
/**
* Gets the current state of the collection as an Array
*
* @returns An Array containing all items in the collection
*/
get toArray() {
const array = Array.from(this.values());
if (array[0] && array[0]._orderByIndex) {
return array.sort(
(a, b) => a._orderByIndex - b._orderByIndex
);
}
return array;
}
/**
* 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.hasReceivedFirstCommit === true) {
return Promise.resolve(this.toArray);
}
return new Promise((resolve) => {
this.onFirstCommit(() => {
resolve(this.toArray);
});
});
}
/**
* Returns the current state of the collection as an array of changes
* @returns An array of changes
*/
currentStateAsChanges() {
return Array.from(this.entries()).map(([key, value]) => ({
type: `insert`,
key,
value
}));
}
/**
* Subscribe to changes in the collection
* @param callback - A function that will be called with the changes in the collection
* @returns A function that can be called to unsubscribe from the changes
*/
subscribeChanges(callback, { includeInitialState = false } = {}) {
if (includeInitialState) {
callback(this.currentStateAsChanges());
}
this.changeListeners.add(callback);
return () => {
this.changeListeners.delete(callback);
};
}
/**
* Subscribe to changes for a specific key
*/
subscribeChangesKey(key, listener, { includeInitialState = false } = {}) {
if (!this.changeKeyListeners.has(key)) {
this.changeKeyListeners.set(key, /* @__PURE__ */ new Set());
}
if (includeInitialState) {
listener([
{
type: `insert`,
key,
value: this.get(key)
}
]);
}
this.changeKeyListeners.get(key).add(listener);
return () => {
const listeners = this.changeKeyListeners.get(key);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.changeKeyListeners.delete(key);
}
}
};
}
/**
* Trigger a recomputation when transactions change
* This method should be called by the Transaction class when state changes
*/
onTransactionStateChange() {
this.recomputeOptimisticState();
}
/**
* Returns a Tanstack Store Map that is updated when the collection changes
* This is a temporary solution to enable the existing framework hooks to work
* with the new internals of Collection until they are rewritten.
* TODO: Remove this once the framework hooks are rewritten.
*/
asStoreMap() {
if (!this._storeMap) {
this._storeMap = new Store(new Map(this.entries()));
this.subscribeChanges(() => {
this._storeMap.setState(() => new Map(this.entries()));
});
}
return this._storeMap;
}
/**
* Returns a Tanstack Store Array that is updated when the collection changes
* This is a temporary solution to enable the existing framework hooks to work
* with the new internals of Collection until they are rewritten.
* TODO: Remove this once the framework hooks are rewritten.
*/
asStoreArray() {
if (!this._storeArray) {
this._storeArray = new Store(this.toArray);
this.subscribeChanges(() => {
this._storeArray.setState(() => this.toArray);
});
}
return this._storeArray;
}
}
export {
CollectionImpl,
SchemaValidationError,
collectionsStore,
createCollection,
preloadCollection
};
//# sourceMappingURL=collection.js.map