@tanstack/optimistic
Version:
Core optimistic updates library
587 lines (586 loc) • 20.4 kB
JavaScript
;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const store = require("@tanstack/store");
const proxy = require("./proxy.cjs");
const transactions = require("./transactions.cjs");
const SortedMap = require("./SortedMap.cjs");
const collectionsStore = new store.Store(/* @__PURE__ */ new Map());
const loadingCollections = /* @__PURE__ */ new Map();
function preloadCollection(config) {
if (collectionsStore.state.has(config.id) && !loadingCollections.has(config.id)) {
return Promise.resolve(
collectionsStore.state.get(config.id)
);
}
if (loadingCollections.has(config.id)) {
return loadingCollections.get(config.id);
}
if (!collectionsStore.state.has(config.id)) {
collectionsStore.setState((prev) => {
const next = new Map(prev);
next.set(
config.id,
new Collection({
id: config.id,
sync: config.sync,
schema: config.schema
})
);
return next;
});
}
const collection = collectionsStore.state.get(config.id);
let resolveFirstCommit;
const firstCommitPromise = new Promise((resolve) => {
resolveFirstCommit = () => {
resolve(collection);
};
});
collection.onFirstCommit(() => {
if (loadingCollections.has(config.id)) {
loadingCollections.delete(config.id);
resolveFirstCommit();
}
});
loadingCollections.set(
config.id,
firstCommitPromise
);
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 Collection {
/**
* Creates a new Collection instance
*
* @param config - Configuration object for the collection
* @throws Error if sync config is missing
*/
constructor(config) {
this.syncedData = new store.Store(/* @__PURE__ */ new Map());
this.syncedMetadata = new store.Store(/* @__PURE__ */ new Map());
this.pendingSyncedTransactions = [];
this.syncedKeys = /* @__PURE__ */ new Set();
this.hasReceivedFirstCommit = false;
this.objectKeyMap = /* @__PURE__ */ new WeakMap();
this.onFirstCommitCallbacks = [];
this.id = crypto.randomUUID();
this.commitPendingTransactions = () => {
if (!Array.from(this.transactions.state.values()).some(
({ state }) => state === `persisting`
)) {
const keys = /* @__PURE__ */ new Set();
store.batch(() => {
for (const transaction of this.pendingSyncedTransactions) {
for (const operation of transaction.operations) {
keys.add(operation.key);
this.syncedKeys.add(operation.key);
this.syncedMetadata.setState((prevData) => {
switch (operation.type) {
case `insert`:
prevData.set(operation.key, operation.metadata);
break;
case `update`:
prevData.set(operation.key, {
...prevData.get(operation.key),
...operation.metadata
});
break;
case `delete`:
prevData.delete(operation.key);
break;
}
return prevData;
});
this.syncedData.setState((prevData) => {
switch (operation.type) {
case `insert`:
prevData.set(operation.key, operation.value);
break;
case `update`:
prevData.set(operation.key, {
...prevData.get(operation.key),
...operation.value
});
break;
case `delete`:
prevData.delete(operation.key);
break;
}
return prevData;
});
}
}
});
keys.forEach((key) => {
const curValue = this.state.get(key);
if (curValue) {
this.objectKeyMap.set(curValue, key);
}
});
this.pendingSyncedTransactions = [];
if (!this.hasReceivedFirstCommit) {
this.hasReceivedFirstCommit = true;
const callbacks = [...this.onFirstCommitCallbacks];
this.onFirstCommitCallbacks = [];
callbacks.forEach((callback) => callback());
}
}
};
this.insert = (data, config2) => {
const transaction = transactions.getActiveTransaction();
if (typeof transaction === `undefined`) {
throw `no transaction found when calling collection.insert`;
}
const items = Array.isArray(data) ? data : [data];
const mutations = [];
let keys;
if (config2 == null ? void 0 : config2.key) {
const configKeys = Array.isArray(config2.key) ? config2.key : [config2.key];
if (Array.isArray(config2.key) && configKeys.length > items.length) {
throw new Error(`More keys provided than items to insert`);
}
keys = items.map((_, i) => configKeys[i] ?? this.generateKey(items[i]));
} else {
keys = items.map((item) => this.generateKey(item));
}
items.forEach((item, index) => {
var _a, _b;
const validatedData = this.validateData(item, `insert`);
const key = keys[index];
const mutation = {
mutationId: crypto.randomUUID(),
original: {},
modified: validatedData,
changes: validatedData,
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);
});
transaction.applyMutations(mutations);
this.transactions.setState((sortedMap) => {
sortedMap.set(transaction.id, transaction);
return sortedMap;
});
return transaction;
};
this.delete = (items, config2) => {
const transaction = transactions.getActiveTransaction();
if (typeof transaction === `undefined`) {
throw `no transaction found when calling collection.delete`;
}
const itemsArray = Array.isArray(items) ? items : [items];
const mutations = [];
for (const item of itemsArray) {
let key;
if (typeof item === `object` && item !== null) {
const objectKey = this.objectKeyMap.get(item);
if (objectKey === void 0) {
throw new Error(
`Object not found in collection: ${JSON.stringify(item)}`
);
}
key = objectKey;
} else if (typeof item === `string`) {
key = item;
} else {
throw new Error(
`Invalid item type for delete - must be an object or string key`
);
}
const mutation = {
mutationId: crypto.randomUUID(),
original: this.state.get(key) || {},
modified: { _deleted: true },
changes: { _deleted: true },
key,
metadata: config2 == null ? void 0 : config2.metadata,
syncMetadata: this.syncedMetadata.state.get(key) || {},
type: `delete`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date(),
collection: this
};
mutations.push(mutation);
}
mutations.forEach((mutation) => {
const curValue = this.state.get(mutation.key);
if (curValue) {
this.objectKeyMap.delete(curValue);
}
});
transaction.applyMutations(mutations);
this.transactions.setState((sortedMap) => {
sortedMap.set(transaction.id, transaction);
return sortedMap;
});
return transaction;
};
if (!(config == null ? void 0 : config.sync)) {
throw new Error(`Collection requires a sync config`);
}
this.transactions = new store.Store(
new SortedMap.SortedMap(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
)
);
this.optimisticOperations = new store.Derived({
fn: ({ currDepVals: [transactions2] }) => {
const result = Array.from(transactions2.values()).map((transaction) => {
const isActive = ![`completed`, `failed`].includes(
transaction.state
);
return transaction.mutations.map((mutation) => {
const message = {
type: mutation.type,
key: mutation.key,
value: mutation.modified,
isActive
};
if (mutation.metadata !== void 0 && mutation.metadata !== null) {
message.metadata = mutation.metadata;
}
return message;
});
}).flat();
return result;
},
deps: [this.transactions]
});
this.optimisticOperations.mount();
this.derivedState = new store.Derived({
fn: ({ currDepVals: [syncedData, operations] }) => {
const combined = new Map(syncedData);
const optimisticKeys = /* @__PURE__ */ new Set();
for (const operation of operations) {
optimisticKeys.add(operation.key);
if (operation.isActive) {
switch (operation.type) {
case `insert`:
combined.set(operation.key, operation.value);
break;
case `update`:
combined.set(operation.key, operation.value);
break;
case `delete`:
combined.delete(operation.key);
break;
}
}
}
optimisticKeys.forEach((key) => {
if (combined.has(key)) {
this.objectKeyMap.set(combined.get(key), key);
}
});
return combined;
},
deps: [this.syncedData, this.optimisticOperations]
});
this.derivedArray = new store.Derived({
fn: ({ currDepVals: [stateMap] }) => {
return Array.from(stateMap.values());
},
deps: [this.derivedState]
});
this.derivedArray.mount();
this.derivedChanges = new store.Derived({
fn: ({
currDepVals: [derivedState, optimisticOperations],
prevDepVals
}) => {
const prevDerivedState = (prevDepVals == null ? void 0 : prevDepVals[0]) ?? /* @__PURE__ */ new Map();
const changedKeys = new Set(this.syncedKeys);
optimisticOperations.flat().filter((op) => op.isActive).forEach((op) => changedKeys.add(op.key));
if (changedKeys.size === 0) {
return [];
}
const changes = [];
for (const key of changedKeys) {
if (prevDerivedState.has(key) && !derivedState.has(key)) {
changes.push({
type: `delete`,
key,
value: prevDerivedState.get(key)
});
} else if (!prevDerivedState.has(key) && derivedState.has(key)) {
changes.push({ type: `insert`, key, value: derivedState.get(key) });
} else if (prevDerivedState.has(key) && derivedState.has(key)) {
changes.push({
type: `update`,
key,
value: derivedState.get(key),
previousValue: prevDerivedState.get(key)
});
}
}
this.syncedKeys.clear();
return changes;
},
deps: [this.derivedState, this.optimisticOperations]
});
this.derivedChanges.mount();
this.config = config;
this.derivedState.mount();
config.sync.sync({
collection: this,
begin: () => {
this.pendingSyncedTransactions.push({
committed: false,
operations: []
});
},
write: (message) => {
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.`
);
}
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);
}
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`
);
}
validateData(data, type, key) {
if (!this.config.schema) return data;
const standardSchema = this.ensureStandardSchema(this.config.schema);
if (type === `update` && key) {
const existingData = this.state.get(key);
if (existingData && data && typeof data === `object` && typeof existingData === `object`) {
const mergedData = { ...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;
}
generateKey(data) {
const str = JSON.stringify(data);
let h = 0;
for (let i = 0; i < str.length; i++) {
h = Math.imul(31, h) + str.charCodeAt(i) | 0;
}
return `${this.id}/${Math.abs(h).toString(36)}`;
}
update(items, configOrCallback, maybeCallback) {
if (typeof items === `undefined`) {
throw new Error(`The first argument to update is missing`);
}
const transaction = transactions.getActiveTransaction();
if (typeof transaction === `undefined`) {
throw `no transaction found when calling collection.update`;
}
const isArray = Array.isArray(items);
const itemsArray = Array.isArray(items) ? items : [items];
const callback = typeof configOrCallback === `function` ? configOrCallback : maybeCallback;
const config = typeof configOrCallback === `function` ? {} : configOrCallback;
const keys = itemsArray.map((item) => {
if (typeof item === `object` && item !== null) {
const key = this.objectKeyMap.get(item);
if (key === void 0) {
throw new Error(`Object not found in collection`);
}
return key;
}
throw new Error(`Invalid item type for update - must be an object`);
});
const currentObjects = keys.map((key) => ({
...this.state.get(key) || {}
}));
let changesArray;
if (isArray) {
changesArray = proxy.withArrayChangeTracking(
currentObjects,
callback
);
} else {
const result = proxy.withChangeTracking(
currentObjects[0],
callback
);
changesArray = [result];
}
const mutations = keys.map((key, index) => {
const changes = changesArray[index];
if (!changes || Object.keys(changes).length === 0) {
return null;
}
const validatedData = this.validateData(changes, `update`, key);
return {
mutationId: crypto.randomUUID(),
original: this.state.get(key) || {},
modified: {
...this.state.get(key) || {},
...validatedData
},
changes: validatedData,
key,
metadata: config.metadata,
syncMetadata: this.syncedMetadata.state.get(key) || {},
type: `update`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date(),
collection: this
};
}).filter(Boolean);
if (mutations.length === 0) {
throw new Error(`No changes were made to any of the objects`);
}
transaction.applyMutations(mutations);
this.transactions.setState((sortedMap) => {
sortedMap.set(transaction.id, transaction);
return sortedMap;
});
return transaction;
}
/**
* 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() {
return this.derivedState.state;
}
/**
* 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.state.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() {
return this.derivedArray.state;
}
/**
* 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.toArray.length > 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 [...this.state.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) {
callback(this.currentStateAsChanges());
return this.derivedChanges.subscribe((changes) => {
if (changes.currentVal.length > 0) {
callback(changes.currentVal);
}
});
}
}
exports.Collection = Collection;
exports.SchemaValidationError = SchemaValidationError;
exports.collectionsStore = collectionsStore;
exports.preloadCollection = preloadCollection;
//# sourceMappingURL=collection.cjs.map