@tanstack/db
Version:
A reactive client store for building super fast apps on sync
723 lines (722 loc) • 26.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const index = require("./collection/index.cjs");
const collectionOptions$1 = require("./collection-options.cjs");
const transactions = require("./transactions.cjs");
const collectionRegistry = require("./query/live/collection-registry.cjs");
const liveQueryCollection = require("./query/live-query-collection.cjs");
const liveQueryObserver = require("./live-query-observer.cjs");
const liveQueryObserverClient = require("./live-query-observer-client.cjs");
const deferred = require("./deferred.cjs");
const liveQueryOptions = require("./live-query-options.cjs");
const errorMessage = require("./error-message.cjs");
const state = require("./collection/state.cjs");
const collectionOptionsBrand = require("./collection-options-brand.cjs");
const collectionConfigFactory = /* @__PURE__ */ Symbol.for(
`/db.collectionConfig.factory`
);
function sameSourcesAtEachPosition(left, right) {
return left.length === right.length && left.every((source, index2) => source === right[index2]);
}
function withCollectionConfigFactory(config, factory, claim) {
Object.defineProperty(config, collectionConfigFactory, {
value: factory,
enumerable: false
});
if (claim) {
Object.defineProperty(config, collectionOptions$1.collectionOptionsClaim, {
value: claim,
enumerable: true
});
}
return config;
}
function collectionOptions(optionsOrId, explicitFactory) {
const config = typeof optionsOrId === `string` ? void 0 : optionsOrId;
const id = typeof optionsOrId === `string` ? optionsOrId : optionsOrId.id;
if (!id) {
throw new Error(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `collectionOptions requires a non-empty explicit id so the descriptor is stable across DbClient instances and SSR boundaries.` : errorMessage.codedMessage(88)
);
}
if (typeof optionsOrId === `string` && !explicitFactory) {
throw new Error(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `collectionOptions("${id}") requires a factory as its second argument.` : errorMessage.codedMessage(89, { id })
);
}
const reusableFactory = config ? config[collectionConfigFactory] : explicitFactory;
let owner;
const materialize = (client) => {
let materialized;
if (reusableFactory) {
materialized = reusableFactory(client);
} else {
if (owner && owner !== client) {
throw new Error(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `Collection descriptor "${id}" was created from a concrete config that cannot be safely reused across DbClient instances. Use collectionOptions("${id}", (client) => adapterCollectionOptions(...)) or an adapter options creator that supports DbClient materialization.` : errorMessage.codedMessage(90, { id })
);
}
owner = client;
materialized = config;
}
if (materialized.id !== void 0 && materialized.id !== id) {
throw new Error(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `Collection descriptor "${id}" materialized a config with id "${materialized.id}". Descriptor and collection ids must match.` : errorMessage.codedMessage(91, { id, materializedId: materialized.id })
);
}
return materialized.id === id ? materialized : { ...materialized, id };
};
const descriptor = {
id,
...config?.singleResult === true ? { singleResult: true } : {}
};
Object.defineProperties(descriptor, {
[collectionOptionsBrand.collectionOptionsBrand]: {
value: true,
enumerable: false
},
[collectionOptions$1.collectionOptionsFactory]: {
value: materialize,
enumerable: false
}
});
return Object.freeze(descriptor);
}
function isCollectionOptions(value) {
return collectionOptions$1.hasCollectionOptionsBrand(value);
}
function createAdapterAuthorityLookups(state$1) {
const rowKeys = /* @__PURE__ */ new Set();
const metadataKeys = /* @__PURE__ */ new Set();
let truncate = state$1.hasAppliedAdapterTruncate ? {
truncate: true,
preserveHydrationSeedKeys: state$1.adapterTruncateKeptKeys,
sourceTruncate: true
} : void 0;
for (const transaction of [
...state$1.pendingSyncedTransactions.filter((pending) => pending.committed),
...state$1.getStagedSyncTransactions()
]) {
if (transaction.truncate && (!transaction.preserveHydrationSeedKeys || transaction.sourceTruncate))
truncate = transaction;
for (const operation of transaction.operations)
if (!transaction.preserveHydrationSeedKeys?.has(operation.key))
rowKeys.add(operation.key);
for (const key of transaction.rowMetadataWrites.keys())
if (!transaction.preserveHydrationSeedKeys?.has(key))
metadataKeys.add(key);
}
return {
rows: (key) => truncate !== void 0 && state.truncatesSourceKey(truncate, key) || rowKeys.has(key) || state$1.appliedAdapterKeys?.get(key) === true || state$1.syncedData.has(key) && !state$1.hydrationSeedKeys.has(key),
// A key whose row an applied adapter write deleted is in the map too.
metadata: (key) => truncate !== void 0 && state.truncatesSourceKey(truncate, key) || metadataKeys.has(key) || state$1.appliedAdapterKeys?.has(key) === true
};
}
class DbClient {
constructor(options = {}) {
this.options = options;
this.bv = /* @__PURE__ */ new Map();
this.cd = /* @__PURE__ */ new Map();
this.liveQueries = /* @__PURE__ */ new Map();
this.gW = /* @__PURE__ */ new Map();
this.bL = /* @__PURE__ */ new Map();
this.bz = /* @__PURE__ */ new Map();
this.bK = /* @__PURE__ */ new Map();
this.listeners = /* @__PURE__ */ new Set();
this.c$ = false;
this.cp = false;
this.aS = 0;
this.fR = new transactions.TransactionScope();
}
getDependency(key) {
return this.options[key];
}
requireDependency(key) {
const dependency = this.getDependency(key);
if (dependency === void 0) {
throw new Error(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `DbClient is missing the required "${key}" dependency. Pass it explicitly when constructing the client: new DbClient({ ${key} }).` : errorMessage.codedMessage(92, { key })
);
}
return dependency;
}
get activeTransaction() {
return this.fR.getActiveTransaction();
}
createTransaction(config) {
return this.fR.createTransaction(config);
}
preloadLiveQuery(options) {
const deferredCollections = /* @__PURE__ */ new Set();
let started;
let primaryFailure;
try {
started = this.gw(options, deferredCollections);
} catch (error) {
primaryFailure = { error };
}
let resumeFailure;
try {
liveQueryOptions.resumeDeferredLiveQueryCollections(deferredCollections, true);
} catch (error) {
resumeFailure = { error };
}
if (primaryFailure) throw primaryFailure.error;
if (resumeFailure) {
const registration = started?.registration;
if (registration && this.liveQueries.get(registration.queryHash) === registration.record) {
registration.record.fail(resumeFailure.error);
this.fU(registration.queryHash);
}
void started?.result.catch(() => {
});
throw resumeFailure.error;
}
return started.result;
}
fU(queryHash) {
const preloaded = this.bz.get(queryHash);
if (!preloaded) return;
preloaded.observer.dispose();
void preloaded.collection.cleanup({ dependents: true }).catch(() => {
});
this.bz.delete(queryHash);
}
gw(options, deferredCollections) {
const prepared = liveQueryOptions.prepareLiveQueryValue(options, this, deferredCollections);
const queryHash = liveQueryOptions.getLiveQueryHash(prepared, options.queryKey);
const sources = liveQueryOptions.getPreparedLiveQuerySources(prepared);
const existing = this.liveQueries.get(queryHash);
const rowOptions = liveQueryOptions.getLiveQueryRowOptions(prepared);
this._assertLiveQuerySources(queryHash, sources, rowOptions);
if (existing && (existing.status === `pending` || existing.status === `success` && existing.snapshot?.status === void 0)) {
return { result: existing.promise };
}
this.fU(queryHash);
const collection = liveQueryCollection.createLiveQueryCollection({
...prepared,
startSync: true
});
const observer = liveQueryObserver.createLiveQueryObserver(collection, {
client: this,
queryHash,
mode: `wholesale`
});
this.bz.set(queryHash, {
collection,
observer,
sources
});
const result = this._registerLiveQuery(
queryHash,
collection.preload().then(() => observer.dehydrate()),
sources,
rowOptions
);
return {
result,
registration: { queryHash, record: this.liveQueries.get(queryHash) }
};
}
collection(options, materializeOptions) {
return this.dS(options, materializeOptions, false);
}
/** @internal */
_materializeCollectionForRender(options) {
return this.dS(options, void 0, true);
}
dS(options, materializeOptions, deferSyncStart) {
const existing = this.bv.get(options.id);
if (existing) {
return this.cR(existing, deferSyncStart);
}
const config = options[collectionOptions$1.collectionOptionsFactory](this);
const materializedDuringFactory = this.bv.get(options.id);
if (materializedDuringFactory) {
return this.cR(
materializedDuringFactory,
deferSyncStart
);
}
const shouldStartSync = config.startSync === true;
const collection = index.createCollection({
...config,
startSync: false
});
collection._setTransactionScope(this.fR);
collection._state.enableHydrationAuthorityTracking();
if (deferSyncStart) {
collection._deferSyncStart();
}
this.bv.set(collection.id, {
collection,
shouldDehydrate: !deferSyncStart,
hydrated: this.cd.has(collection.id)
});
if (materializeOptions?.initialData?.length) {
this.fd(
collection,
{
collectionId: collection.id,
rows: materializeOptions.initialData.map((value) => ({ value }))
},
`initialData`
);
}
const pendingChunks = this.cd.get(collection.id);
if (pendingChunks) {
for (const chunk of pendingChunks) {
this.fd(collection, chunk, `hydration`);
}
this.cd.delete(collection.id);
}
if (shouldStartSync) {
collection.startSyncImmediate();
}
return collection;
}
cR(record, deferSyncStart) {
if (deferSyncStart) {
record.collection._deferSyncStart();
} else {
record.shouldDehydrate = true;
}
return record.collection;
}
dehydrate(options = {}) {
const collections = [];
for (const {
collection,
shouldDehydrate
} of this.bv.values()) {
const collectionDecision = options.shouldDehydrateCollection?.(collection);
if (collectionRegistry.getBuilderFromConfig(collection.config) || collectionDecision === false || !shouldDehydrate && collectionDecision !== true) {
continue;
}
const rows = Array.from(collection._state.syncedData.entries()).map(
([key, value]) => {
const metadata = collection._state.syncedMetadata.get(key);
return {
key,
value,
...metadata === void 0 ? {} : { metadata }
};
}
);
collections.push({
collectionId: collection.id,
rows,
syncMeta: collection.config.sync.exportSyncMeta?.()
});
}
const liveQueries = Array.from(this.liveQueries.values()).flatMap(
(query) => {
const decision = options.shouldDehydrateLiveQuery?.(query);
const shouldDehydrate = decision ?? (query.status === `success` || query.snapshot !== void 0);
if (!shouldDehydrate || query.status === `error`) {
return [];
}
return [
{
queryHash: query.queryHash,
dehydratedAt: query.dehydratedAt,
...query.initialSnapshot && query.initialSnapshot !== query.snapshot ? { initialSnapshot: query.initialSnapshot } : {},
...query.snapshot ? { snapshot: query.snapshot } : {},
...query.status === `pending` && (!query.snapshot || this.c$ || decision === true) ? { promise: query.resultPromise } : {}
}
];
}
);
return {
collections,
...liveQueries.length > 0 ? { liveQueries } : {}
};
}
hydrate(state2) {
for (const chunk of state2.collections) {
const record = this.bv.get(chunk.collectionId);
if (record) {
record.hydrated = true;
this.fd(record.collection, chunk, `hydration`);
continue;
}
const pendingChunks = this.cd.get(chunk.collectionId) ?? [];
pendingChunks.push(chunk);
this.cd.set(chunk.collectionId, pendingChunks);
}
for (const dehydratedQuery of state2.liveQueries ?? []) {
this.ft(dehydratedQuery);
}
}
applyCollectionChunk(chunk) {
this.hydrate({ collections: [chunk] });
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/** @internal Supply transport behavior without coupling ordinary observers to SSR. */
_bindLiveQueryObserver(collection, queryHash, suspendUntilReady, host) {
return new liveQueryObserverClient.LiveQueryObserverClient(
this,
collection,
queryHash,
suspendUntilReady,
host
);
}
/** @internal Normalized source transport carries rows, not readiness. */
_hasSsrCollectionSnapshot(collection) {
const sources = collectionRegistry.getBuilderFromConfig(
collection.config
)?.getSourceCollections();
if (sources)
return sources.every((source) => this._hasSsrCollectionSnapshot(source));
const record = this.bv.get(collection.id);
return record?.collection === collection && (record.shouldDehydrate || record.hydrated);
}
/** @internal */
_setSsrStreamingEnabled(enabled) {
if (enabled && !this.c$) {
this.bB = void 0;
this.bL.clear();
} else if (!enabled && this.c$ && !this.bB)
this.cA(
new Error(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `The DB stream ended before the server-rendered query snapshot arrived.` : errorMessage.codedMessage(253)
)
);
this.c$ = enabled;
}
/** @internal Wait for transport without claiming or starting a local source. */
_waitForSsrLiveQuery(queryHash, forHydration = false) {
let waiter = this.bL.get(queryHash);
if (waiter && `cause` in waiter) throw waiter.cause;
if (!forHydration || this.gW.has(queryHash)) return;
if (this.bB) {
this.bL.set(queryHash, this.bB);
throw this.bB.cause;
}
if (!this.c$) return;
if (!waiter) {
waiter = deferred.createDeferred();
waiter.promise.catch(() => {
});
this.bL.set(queryHash, waiter);
}
return waiter.promise;
}
cA(cause) {
this.bB ??= { cause };
for (const [queryHash, waiter] of this.bL) {
this.bL.set(queryHash, this.bB);
if (`promise` in waiter) waiter.reject(this.bB.cause);
}
}
/** @internal */
_isSsrStreamingEnabled() {
return this.c$;
}
/** @internal */
_setSsrServerCleanupEnabled(enabled) {
this.cp = enabled;
}
/** @internal */
_isSsrServerCleanupEnabled() {
return this.cp;
}
/** @internal */
_getLiveQuery(queryHash, forServerSnapshot = false) {
return forServerSnapshot && this.gW.get(queryHash) || this.liveQueries.get(queryHash);
}
/** @internal Reject incompatible local sources or row behavior for a reusable stream. */
_assertLiveQuerySources(queryHash, sources, rowOptions) {
const record = this.liveQueries.get(queryHash);
if (!record || record.status === `error`) return;
const priorSources = record.sources ?? this.bz.get(queryHash)?.sources;
if (priorSources && !sameSourcesAtEachPosition(priorSources, sources) || rowOptions && record.rowOptions && rowOptions.some(
(option, index2) => option !== record.rowOptions[index2]
)) {
throw new Error(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `DbClient cannot reuse live query "${queryHash}" with different source Collections or getKey/schema options. Use the same Collection objects and options, or a distinct query key.` : errorMessage.codedMessage(234, { queryHash })
);
}
record.rowOptions ??= rowOptions;
if (!record.sources) record.sources = [...sources];
}
/** @internal */
_consumeLiveQueryResult(queryHash, dehydratedAt) {
const record = this.liveQueries.get(queryHash);
if (record?.dehydratedAt === dehydratedAt) {
this.liveQueries.delete(queryHash);
}
}
/** @internal Capture the first server view without starting acquisition. */
_captureServerQuery(queryHash, snapshot, sources, rowOptions) {
const existing = this.liveQueries.get(queryHash);
if (existing) {
if (sources) this._assertLiveQuerySources(queryHash, sources, rowOptions);
if (existing.snapshot && (snapshot.status === void 0 || existing.initialSnapshot))
return;
if (snapshot.status !== void 0) existing.initialSnapshot ??= snapshot;
existing.snapshot ??= snapshot;
existing.dehydratedAt = this.eL();
this.emit({ type: `liveQueryUpdated`, query: existing });
return;
}
const record = this.dQ(
queryHash,
this.eL()
);
record.rowOptions = rowOptions;
record.sources = sources;
if (snapshot.status !== void 0) record.initialSnapshot = snapshot;
this.liveQueries.set(queryHash, record);
this.emit({ type: `liveQueryAdded`, query: record });
record.succeed(snapshot);
}
/** @internal */
_registerLiveQuery(queryHash, promise, sources, rowOptions) {
void Promise.resolve(promise).catch(() => {
});
if (sources) this._assertLiveQuerySources(queryHash, sources, rowOptions);
const existing = this.liveQueries.get(queryHash);
if (existing && (existing.status === `pending` || existing.status === `success` && existing.snapshot?.status === void 0)) {
return existing.promise;
}
const record = this.dQ(
queryHash,
this.eL()
);
record.rowOptions = rowOptions;
record.sources = sources;
record.initialSnapshot = existing?.initialSnapshot;
this.liveQueries.set(queryHash, record);
this.emit({ type: `liveQueryAdded`, query: record });
Promise.resolve(promise).then(record.succeed, record.fail);
return record.promise;
}
/** @internal */
_registerLiveQueryResource(owner, cleanup) {
this.bK.set(owner, cleanup);
return () => {
if (this.bK.get(owner) === cleanup) {
this.bK.delete(owner);
}
};
}
/** @internal */
_failPendingLiveQueries(error) {
this.cA(error);
for (const record of this.liveQueries.values()) {
if (record.status === `pending`) record.fail(error);
}
this.emit({ type: `liveQueryStreamError`, error });
}
async cleanup() {
try {
const materializedCollections = Array.from(
this.bv.values(),
({ collection }) => collection
);
const preloadedQueries = Array.from(this.bz.values());
const liveQueryCollections = /* @__PURE__ */ new Set([
...preloadedQueries.map(({ collection }) => collection),
...materializedCollections.filter(
(collection) => collectionRegistry.getBuilderFromConfig(collection.config)
)
]);
for (const { observer } of preloadedQueries) observer.dispose();
const cleanupResults = [
...await Promise.allSettled(
Array.from(this.bK.values(), (cleanup) => cleanup())
),
...await Promise.allSettled(
Array.from(
liveQueryCollections,
(collection) => collection.cleanup({ dependents: true })
)
),
...await Promise.allSettled(
materializedCollections.filter((collection) => !liveQueryCollections.has(collection)).map((collection) => collection.cleanup({ dependents: true }))
)
];
const failure = cleanupResults.find(
(result) => result.status === `rejected`
);
if (failure) throw failure.reason;
} finally {
this._setSsrStreamingEnabled(false);
this.fR.clear();
this.bv.clear();
this.cd.clear();
this.liveQueries.clear();
this.gW.clear();
this.bz.clear();
this.bK.clear();
this.listeners.clear();
this.cp = false;
this.aS = 0;
}
}
ft(dehydratedQuery) {
const existing = this.liveQueries.get(dehydratedQuery.queryHash);
if (existing && existing === this.gW.get(dehydratedQuery.queryHash) && existing.dehydratedAt >= dehydratedQuery.dehydratedAt) {
return;
}
const record = this.dQ(
dehydratedQuery.queryHash,
dehydratedQuery.dehydratedAt
);
if (existing?.status === `error`) {
this.fU(dehydratedQuery.queryHash);
} else {
record.sources = existing?.sources;
record.rowOptions = existing?.rowOptions;
}
this.liveQueries.set(record.queryHash, record);
if (existing?.status === `pending`) {
void record.resultPromise.then(existing.succeed, existing.fail);
}
record.initialSnapshot = dehydratedQuery.initialSnapshot ?? dehydratedQuery.snapshot;
this.gW.set(record.queryHash, record);
const waiter = this.bL.get(record.queryHash);
if (waiter && `promise` in waiter) waiter.resolve();
this.bL.delete(record.queryHash);
if (dehydratedQuery.promise) record.snapshot = dehydratedQuery.snapshot;
this.emit({ type: `liveQueryAdded`, query: record });
if (dehydratedQuery.snapshot && !dehydratedQuery.promise) {
record.succeed(dehydratedQuery.snapshot);
} else if (dehydratedQuery.promise) {
Promise.resolve(dehydratedQuery.promise).then(record.succeed, record.fail);
} else {
record.fail(
new Error(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `Dehydrated live query "${dehydratedQuery.queryHash}" has neither a snapshot nor a promise.` : errorMessage.codedMessage(93, { queryHash: dehydratedQuery.queryHash })
)
);
}
}
eL() {
this.aS = Math.max(
Date.now(),
this.aS + 1
);
return this.aS;
}
dQ(queryHash, dehydratedAt) {
this.aS = Math.max(
this.aS,
dehydratedAt
);
let resolveResult;
let rejectResult;
let settled = false;
const resultPromise = new Promise(
(resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
}
);
const promise = resultPromise.then(() => void 0);
resultPromise.catch(() => {
});
promise.catch(() => {
});
const record = {
queryHash,
dehydratedAt,
status: `pending`,
promise,
resultPromise,
succeed: (snapshot) => {
if (settled) return;
settled = true;
record.status = `success`;
record.snapshot = snapshot;
resolveResult(snapshot);
if (this.liveQueries.get(queryHash) === record) {
this.emit({ type: `liveQueryUpdated`, query: record });
}
},
fail: (error) => {
if (settled) return;
settled = true;
record.status = `error`;
record.error = error;
rejectResult(error);
if (this.liveQueries.get(queryHash) === record) {
this.emit({ type: `liveQueryUpdated`, query: record });
}
}
};
return record;
}
emit(event) {
for (const listener of this.listeners) {
listener(event);
}
}
fd(collection, chunk, seedKind) {
const hydrating = seedKind === `hydration`;
const authority = createAdapterAuthorityLookups(collection._state);
const seeds = chunk.rows.map((row) => {
const value = collection.validateData(row.value, `insert`);
return { ...row, key: collection.config.getKey(value), value };
});
const rows = seeds.filter((row) => !hydrating || !authority.rows(row.key));
const rowMetadataWrites = /* @__PURE__ */ new Map();
for (const row of seeds) {
if (hydrating && authority.metadata(row.key)) continue;
if (row.metadata !== void 0)
rowMetadataWrites.set(row.key, { type: `set`, value: row.metadata });
else if (hydrating) rowMetadataWrites.set(row.key, { type: `delete` });
}
if (seedKind) {
for (const row of rows) {
collection._state.hydrationSeedKeys.add(row.key);
if (seedKind === `hydration`) {
collection._state.hydratedKeys.add(row.key);
}
}
}
if (rows.length > 0 || rowMetadataWrites.size > 0) {
const applied = deferred.createDeferred();
void applied.promise.catch(() => void 0);
collection._state.acceptSyncTransaction({
committed: true,
applicationStarted: false,
layoutChanged: false,
// A seed replaces any earlier seed of its row whole.
operations: rows.map((row) => ({
type: `insert`,
key: row.key,
value: row.value
})),
rowMetadataWrites,
// An accepted transaction's metadata writes are never rebuilt.
explicitRowMetadataWrites: /* @__PURE__ */ new Map(),
collectionMetadataWrites: /* @__PURE__ */ new Map(),
applied,
// A seed chunk keeps its keys' seed status.
preserveHydrationSeedKeys: seedKind ? /* @__PURE__ */ new Set([
...rows.map((row) => row.key),
...rowMetadataWrites.keys()
]) : void 0
});
}
if (chunk.syncMeta !== void 0) {
const currentMeta = collection.config.sync.exportSyncMeta?.();
const mergedMeta = currentMeta === void 0 ? chunk.syncMeta : collection.config.sync.mergeSyncMeta?.(
currentMeta,
chunk.syncMeta
) ?? chunk.syncMeta;
collection.config.sync.importSyncMeta?.(mergedMeta);
}
}
}
exports.DbClient = DbClient;
exports.collectionOptions = collectionOptions;
exports.isCollectionOptions = isCollectionOptions;
exports.withCollectionConfigFactory = withCollectionConfigFactory;
//# sourceMappingURL=client.cjs.map