@tanstack/db
Version:
A reactive client store for building super fast apps on sync
492 lines (491 loc) • 17.6 kB
JavaScript
import { LiveQueryObserverDisposedError } from "./errors.js";
import { isSingleResultCollection, getLiveQueryStatusFlags } from "./live-query-adapter.js";
import { getBuilderFromConfig } from "./query/live/collection-registry.js";
const DISABLED_SNAPSHOT = {
state: void 0,
data: void 0,
collection: void 0,
layoutRevision: 0,
status: `disabled`,
isLoading: false,
isReady: true,
isIdle: false,
isError: false,
isCleanedUp: false,
isEnabled: false
};
class LiveQueryObserverImpl {
// Sync activation belongs to the first subscription (attach), so building
// an observer cannot activate collection resources on its own. Server
// request clients still record ownership here because React may render an
// observer without ever subscribing to it.
constructor(collection, wholesale, client, queryHash, onPreload) {
this.snapshotDirty = true;
this.cachedSnapshot = DISABLED_SNAPSHOT;
this.layoutRevision = 0;
this.subscriptions = /* @__PURE__ */ new Set();
this.publicationQueue = [];
this.dispatching = false;
this.blockDelivery = false;
this.attached = false;
this.collectionUnsub = null;
this.hasHydrationError = false;
this.liveResultIsAuthoritative = false;
this.handoffScheduled = false;
this.disposed = false;
this.collection = collection;
this.wholesale = wholesale;
this.client = client;
this.queryHash = queryHash;
this.onPreload = onPreload;
this.registerClientResource();
}
getSnapshot() {
const collection = this.collection;
if (!collection) return DISABLED_SNAPSHOT;
this.syncHydrationState();
if (!this.attached) this.refreshDetachedState(collection);
if (this.snapshotDirty) {
const entries = this.getVisibleEntries(collection);
const state = new Map(entries);
const data = entries.map(([, value]) => value);
const singleResult = isSingleResultCollection(collection);
const liveStatus = this.visibleStatus ?? collection.status;
const status = this.hasHydrationError || liveStatus === `error` ? `error` : this.hasHydrationSeed() ? `ready` : liveStatus;
const prevKeys = this.lastLayoutKeys;
let layoutChanged = prevKeys === void 0 || prevKeys.length !== entries.length;
if (!layoutChanged) {
for (let i = 0; i < entries.length; i++) {
if (prevKeys[i] !== entries[i][0]) {
layoutChanged = true;
break;
}
}
}
if (layoutChanged) {
this.lastLayoutKeys = entries.map(([key]) => key);
this.layoutRevision++;
}
this.cachedSnapshot = {
state,
data: singleResult ? data[0] : data,
collection,
layoutRevision: this.layoutRevision,
status,
...getLiveQueryStatusFlags(status),
isEnabled: true
};
this.snapshotDirty = false;
}
return this.cachedSnapshot;
}
getServerSnapshot() {
return this.getSnapshot();
}
getError() {
this.syncHydrationState();
return this.hasHydrationError ? this.hydrationError : void 0;
}
dehydrate() {
const collection = this.collection;
if (!collection) return { rows: [] };
const entries = this.hasHydrationSeed() ? this.hydrationSeed.entries : this.readEntries(collection).entries;
return {
rows: entries.map(([key, value]) => ({
key,
value
}))
};
}
hasHydrationSeed() {
return this.hydrationSeed !== void 0 && !this.liveResultIsAuthoritative;
}
getVisibleEntries(collection) {
if (this.hasHydrationSeed()) return this.hydrationSeed.entries;
return this.cachedEntries ?? this.captureEntries(collection).entries;
}
syncHydrationState() {
if (!this.client || !this.queryHash || this.liveResultIsAuthoritative) {
return false;
}
const query = this.client._getLiveQuery(this.queryHash);
if (!query) return false;
if (this.attached && !this.hydrationSeed && this.collection?.status === `ready` && !this.collection.isLoadingSubset) {
return this.markLiveResultAuthoritative(query.dehydratedAt);
}
if (query.status === `error`) {
const changed = !this.hasHydrationError || this.hydrationError !== query.error;
this.hydrationError = query.error;
this.hasHydrationError = true;
if (changed) this.snapshotDirty = true;
return changed;
}
if (query.status !== `success` || !query.snapshot || this.hydrationSeed && this.hydrationSeed.dehydratedAt >= query.dehydratedAt) {
return false;
}
this.hydrationSeed = {
dehydratedAt: query.dehydratedAt,
entries: query.snapshot.rows.map((row) => [
row.key,
row.value
])
};
this.hydrationError = void 0;
this.hasHydrationError = false;
this.snapshotDirty = true;
return true;
}
diffEntries(previous, next) {
const previousByKey = new Map(previous);
const nextByKey = new Map(next);
const changes = [];
for (const [key, value] of previous) {
if (!nextByKey.has(key)) changes.push({ type: `delete`, key, value });
}
for (const [key, value] of next) {
const previousValue = previousByKey.get(key);
if (previousValue === void 0) {
changes.push({ type: `insert`, key, value });
} else if (previousValue !== value) {
changes.push({
type: `update`,
key,
value,
previousValue
});
}
}
return changes;
}
handoffHydrationSeed(collection) {
const previous = this.hydrationSeed?.entries ?? [];
const dehydratedAt = this.hydrationSeed?.dehydratedAt;
const { entries, revision } = this.readEntries(collection);
this.hydrationSeed = void 0;
this.markLiveResultAuthoritative(dehydratedAt);
this.updateCachedEntries(entries, revision);
this.snapshotDirty = true;
return {
changes: this.diffEntries(previous, entries),
entries,
revision
};
}
markLiveResultAuthoritative(dehydratedAt) {
const changed = this.hasHydrationError;
this.hydrationError = void 0;
this.hasHydrationError = false;
this.liveResultIsAuthoritative = true;
if (dehydratedAt !== void 0 && this.queryHash) {
this.client?._consumeLiveQueryResult(this.queryHash, dehydratedAt);
}
if (changed) this.snapshotDirty = true;
return changed;
}
scheduleHydrationHandoff() {
if (this.handoffScheduled) return;
this.handoffScheduled = true;
queueMicrotask(() => {
this.handoffScheduled = false;
const collection = this.collection;
if (this.disposed || !this.attached || !collection || !this.hasHydrationSeed() || collection.status !== `ready` || collection.isLoadingSubset) {
return;
}
const handoff = this.handoffHydrationSeed(collection);
this.emit(
this.wholesale ? void 0 : handoff.changes,
void 0,
handoff.entries,
collection.status,
handoff.revision,
this.getCollectionLayoutRevision(collection),
true
);
});
}
getCollectionRevision(collection) {
const revision = collection._stateRevision;
return typeof revision === `number` ? revision : void 0;
}
getCollectionLayoutRevision(collection) {
const revision = collection._layoutRevision;
return typeof revision === `number` ? revision : void 0;
}
readEntries(collection) {
const entries = Array.from(collection.entries());
const revision = this.getCollectionRevision(collection);
return { entries, revision };
}
captureEntries(collection) {
const { entries, revision } = this.readEntries(collection);
this.updateCachedEntries(entries, revision);
return { entries, revision };
}
updateCachedEntries(entries, revision) {
const changed = revision !== void 0 ? this.cachedEntries === void 0 || revision !== this.cachedCollectionRevision : !this.entriesEqual(this.cachedEntries, entries);
this.cachedEntries = entries;
this.cachedCollectionRevision = revision;
if (changed) this.snapshotDirty = true;
}
entriesEqual(left, right) {
if (!left || left.length !== right.length) return false;
return left.every(
([key, value], index) => right[index][0] === key && right[index][1] === value
);
}
/**
* While detached there is no delivered-publication clock, so fall back to
* the collection revision. Compatible cross-copy collections that predate
* `_stateRevision` are compared structurally instead.
*/
refreshDetachedState(collection) {
const status = collection.status;
const revision = this.getCollectionRevision(collection);
const layoutRevision = this.getCollectionLayoutRevision(collection);
if (revision !== void 0) {
if (this.cachedEntries === void 0 || revision !== this.cachedCollectionRevision || layoutRevision !== this.cachedCollectionLayoutRevision) {
this.captureEntries(collection);
this.cachedCollectionLayoutRevision = layoutRevision;
this.snapshotDirty = true;
}
} else {
const entries = Array.from(collection.entries());
this.updateCachedEntries(entries, void 0);
}
if (this.visibleStatus !== status) {
this.visibleStatus = status;
this.snapshotDirty = true;
}
}
subscribe(listener) {
if (this.disposed) throw new LiveQueryObserverDisposedError();
const record = { listener, active: true };
this.subscriptions.add(record);
if (this.subscriptions.size === 1) {
this.attach();
} else {
if (!this.wholesale) this.seed(record);
}
return () => {
if (!record.active) return;
record.active = false;
this.subscriptions.delete(record);
if (this.subscriptions.size === 0) this.detach();
};
}
/** Deliver the collection's current rows to one late subscription as inserts. */
seed(record) {
const collection = this.collection;
if (!collection) return;
const seedChanges = [];
for (const [key, value] of this.getVisibleEntries(collection)) {
seedChanges.push({ type: `insert`, key, value });
}
if (seedChanges.length === 0) return;
this.emit(seedChanges, [record]);
}
attach() {
const collection = this.collection;
if (!collection || this.disposed) return;
this.registerClientResource();
this.syncHydrationState();
this.refreshDetachedState(collection);
this.attached = true;
this.visibleStatus ??= collection.status;
this.deliveredLayoutRevision = this.getCollectionLayoutRevision(collection);
const attachedWithHydrationSeed = this.hasHydrationSeed();
this.blockDelivery = this.wholesale || attachedWithHydrationSeed;
const notify = (changes, status = collection.status, explicitLayoutChange = false) => {
if (this.disposed || this.subscriptions.size === 0) return;
if (this.hasHydrationSeed()) {
if (status === `ready`) this.scheduleHydrationHandoff();
if (status !== `error`) return;
}
if (status === `ready` && !collection.isLoadingSubset && !this.liveResultIsAuthoritative && this.client && this.queryHash) {
const query = this.client._getLiveQuery(this.queryHash);
this.markLiveResultAuthoritative(query?.dehydratedAt);
}
const layoutRevision = this.getCollectionLayoutRevision(collection);
let layoutChanged = explicitLayoutChange;
if (!explicitLayoutChange && changes !== void 0 && changes.length === 0) {
if (layoutRevision === void 0 || layoutRevision === this.deliveredLayoutRevision) {
return;
}
layoutChanged = true;
}
if (changes !== void 0 && layoutRevision !== void 0) {
this.deliveredLayoutRevision = layoutRevision;
}
const captured = changes !== void 0 ? this.readEntries(collection) : status === `cleaned-up` ? this.readEntries(collection) : void 0;
this.emit(
changes,
void 0,
captured?.entries,
status,
captured?.revision,
layoutRevision,
layoutChanged
);
};
const statusUnsub = collection.on(
`status:change`,
({ status }) => notify(void 0, status)
);
const subscribeLayoutChanges = collection._subscribeLayoutChanges;
const layoutUnsub = typeof subscribeLayoutChanges === `function` ? subscribeLayoutChanges.call(
collection,
() => notify([], collection.status, true)
) : () => {
};
let subscription = null;
const clientUnsub = this.client && this.queryHash ? this.client.subscribe((event) => {
if (event.type === `liveQueryStreamError` || event.query.queryHash !== this.queryHash) {
return;
}
const previousEntries = this.getVisibleEntries(collection);
if (!this.syncHydrationState()) return;
const nextEntries = this.getVisibleEntries(collection);
this.emit(
this.wholesale ? void 0 : this.diffEntries(previousEntries, nextEntries)
);
}) : () => {
};
const release = () => {
clientUnsub();
statusUnsub();
layoutUnsub();
subscription?.unsubscribe();
};
this.collectionUnsub = release;
subscription = collection.subscribeChanges(
(changes) => notify(changes),
{ includeInitialState: !this.wholesale && !attachedWithHydrationSeed }
);
this.blockDelivery = false;
if (this.collectionUnsub !== release) {
subscription.unsubscribe();
return;
}
if (this.wholesale || attachedWithHydrationSeed) {
this.flushPublications(!this.wholesale);
const { entries, revision } = this.readEntries(collection);
this.updateCachedEntries(entries, revision);
}
if (this.hasHydrationSeed()) {
if (!this.wholesale) this.seed(Array.from(this.subscriptions)[0]);
if (collection.status === `ready`) this.scheduleHydrationHandoff();
}
}
detach() {
this.collectionUnsub?.();
this.collectionUnsub = null;
this.attached = false;
this.blockDelivery = false;
this.publicationQueue.length = 0;
this.unregisterClientResource?.();
this.unregisterClientResource = void 0;
}
registerClientResource() {
if (this.unregisterClientResource || !this.client?._isSsrServerCleanupEnabled() || !this.collection || !getBuilderFromConfig(this.collection.config)) {
return;
}
this.unregisterClientResource = this.client._registerLiveQueryResource(
this,
async () => {
const collection = this.collection;
this.dispose();
await collection?.cleanup();
}
);
}
emit(changes, targets = Array.from(this.subscriptions), entries, status = this.collection?.status ?? `cleaned-up`, collectionRevision, collectionLayoutRevision, layoutChanged = false) {
this.publicationQueue.push({
changes,
targets,
entries,
status,
collectionRevision,
collectionLayoutRevision,
layoutChanged
});
if (this.dispatching || this.blockDelivery) return;
this.flushPublications();
}
flushPublications(deliver = true) {
if (this.dispatching) return;
this.dispatching = true;
try {
while (this.publicationQueue.length > 0) {
const publication = this.publicationQueue.shift();
if (publication.entries) {
this.updateCachedEntries(
publication.entries,
publication.collectionRevision
);
}
if (publication.collectionLayoutRevision !== void 0) {
this.cachedCollectionLayoutRevision = publication.collectionLayoutRevision;
}
if (publication.layoutChanged) {
this.snapshotDirty = true;
}
if (this.visibleStatus !== publication.status) {
this.visibleStatus = publication.status;
this.snapshotDirty = true;
}
if (deliver) {
for (const subRecord of publication.targets) {
if (this.disposed) return;
subRecord.listener(publication.changes);
}
}
}
} finally {
this.dispatching = false;
}
}
preload() {
if (this.preloadPromise) return this.preloadPromise;
if (this.client && this.queryHash) {
const query = this.client._getLiveQuery(this.queryHash);
if (query?.status === `pending`) return query.promise;
if (query?.status === `success`) return Promise.resolve();
}
this.registerClientResource();
this.onPreload?.();
const collectionPromise = this.collection?.preload() ?? Promise.resolve();
const preloadPromise = this.client?._isSsrStreamingEnabled() && this.queryHash ? this.client._registerLiveQuery(
this.queryHash,
collectionPromise.then(() => this.dehydrate())
) : collectionPromise;
this.preloadPromise = preloadPromise;
const clearPreload = () => {
if (this.preloadPromise === preloadPromise) {
this.preloadPromise = void 0;
}
};
void preloadPromise.then(clearPreload, clearPreload);
return preloadPromise;
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.detach();
for (const subRecord of this.subscriptions) subRecord.active = false;
this.subscriptions.clear();
this.publicationQueue.length = 0;
}
}
function createLiveQueryObserver(collection, options = {}) {
return new LiveQueryObserverImpl(
collection ?? null,
options.mode === `wholesale`,
options.client,
options.queryHash,
options.onPreload
);
}
export {
createLiveQueryObserver
};
//# sourceMappingURL=live-query-observer.js.map