UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

492 lines (491 loc) 17.6 kB
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