UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

675 lines (674 loc) • 25 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const errors = require("./errors.cjs"); const liveQueryAdapter = require("./live-query-adapter.cjs"); const collectionRegistry = require("./query/live/collection-registry.cjs"); const persistedReadiness = require("./persisted-readiness.cjs"); const INITIAL_RENDER_PRELOADS = /* @__PURE__ */ new WeakSet(); function collectPersistedReadinessSources(root) { const seen = /* @__PURE__ */ new Set(); const sources = []; const visit = (collection) => { if (seen.has(collection)) return true; seen.add(collection); const config = collection.config; if (!config) return false; const builder = collectionRegistry.getBuilderFromConfig(config); if (builder) { return builder.getSourceCollections().every(visit); } const source = persistedReadiness.getPersistedReadinessSource(config); if (!source) return false; sources.push({ collection, readiness: source }); return true; }; return visit(root) && sources.length > 0 ? sources : void 0; } const DISABLED_SNAPSHOT = { state: void 0, data: void 0, collection: void 0, layoutRevision: 0, status: `disabled`, isLoading: false, isReady: true, persistedStatus: `unavailable`, isPersistedReady: false, persistedError: void 0, 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.initialRenderReady = false; this.disposed = false; this.collection = collection; this.wholesale = wholesale; this.client = client; this.queryHash = queryHash; this.persistedSources = collection ? collectPersistedReadinessSources(collection) : void 0; this.onPreload = onPreload; this.registerClientResource(); } getSnapshot() { const collection = this.collection; if (!collection) return DISABLED_SNAPSHOT; this.syncHydrationState(); if (!this.attached) this.refreshDetachedState(collection); const local = this.readPersistedReadiness(); const persistedStatus = this.hasHydrationSeed() && local.status === `ready` ? `loading` : local.status; if (this.cachedSnapshot.persistedStatus !== persistedStatus || this.cachedSnapshot.persistedError !== local.error) { this.snapshotDirty = true; } if (this.snapshotDirty) { const entries = this.getVisibleEntries(collection); const state = new Map(entries); const data = entries.map(([, value]) => value); const singleResult = liveQueryAdapter.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, ...liveQueryAdapter.getLiveQueryStatusFlags(status), persistedStatus, isPersistedReady: persistedStatus === `ready`, persistedError: local.error, 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; } readPersistedReadiness() { const sources = this.persistedSources; if (!sources) return { status: `unavailable`, error: void 0 }; let loading = false; let error; let failed = false; for (const source of sources) { const snapshot = source.readiness.getSnapshot(); if (snapshot.status === `error` && !failed) { error = snapshot.error; failed = true; } else if (snapshot.status === `loading`) { loading = true; } } return failed ? { status: `error`, error } : { status: loading ? `loading` : `ready`, error: void 0 }; } 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` && this.readPersistedReadiness().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 errors.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) ) : () => { }; const persistedUnsubs = this.persistedSources?.map( (source) => source.readiness.subscribe(() => { if (this.disposed || this.subscriptions.size === 0) return; this.snapshotDirty = true; if (this.hasHydrationSeed() && this.readPersistedReadiness().status === `ready`) { this.scheduleHydrationHandoff(); } else { this.emit(void 0); } }) ); 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(); persistedUnsubs?.forEach((unsubscribe) => unsubscribe()); 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.readPersistedReadiness().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 || !collectionRegistry.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; let failure; 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) break; try { subRecord.listener(publication.changes); } catch (error) { failure ??= { error }; } } } } } finally { this.dispatching = false; } if (failure) throw failure.error; } 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; } isInitialRenderReady() { if (this.initialRenderReady) return true; const collection = this.collection; const sources = this.persistedSources; if (!collection || !INITIAL_RENDER_PRELOADS.has(collection) || !sources || this.readPersistedReadiness().status !== `ready`) { return false; } const sourceFailed = sources.some( (source) => source.collection.status === `error` ); if (collection.status === `error`) return sourceFailed; const clientQuery = this.client && this.queryHash ? this.client._getLiveQuery(this.queryHash) : void 0; if (clientQuery?.status === `error`) return true; return sources.every( (source) => Date.now() >= source.readiness.getOrStartNetworkDeadline() ); } preloadForInitialRender() { if (this.disposed) return Promise.reject(new errors.LiveQueryObserverDisposedError()); const sources = this.persistedSources; if (!sources) return this.preload(); if (this.initialRenderReady) return Promise.resolve(); if (this.initialRenderPromise) return this.initialRenderPromise; const network = this.preload(); const clientQuery = this.client && this.queryHash ? this.client._getLiveQuery(this.queryHash) : void 0; const clientStream = clientQuery?.promise === network ? clientQuery : void 0; if (this.collection) INITIAL_RENDER_PRELOADS.add(this.collection); const unsubscribers = []; let cancel; let timer; const initialRender = new Promise((resolve, reject) => { let networkReady = false; let failedClientStream = clientQuery?.status === `error` ? clientQuery : void 0; let networkFailed = failedClientStream !== void 0; let networkError = failedClientStream?.error; let settled = false; const finish = (failure) => { if (settled) return; settled = true; if (failure) reject(failure.error); else { this.initialRenderReady = true; resolve(); } }; cancel = () => finish({ error: new errors.LiveQueryObserverDisposedError() }); this.cancelInitialRenderWait = cancel; const check = () => { if (settled) return; if (timer !== void 0) { clearTimeout(timer); timer = void 0; } if (networkReady) { finish(); return; } const persisted = this.readPersistedReadiness(); const networkDeadlineAt = Math.max( ...sources.map( (source) => source.readiness.getOrStartNetworkDeadline() ) ); const remainingNetworkMs = Math.max(0, networkDeadlineAt - Date.now()); const deadlinePassed = remainingNetworkMs === 0; const sourceFailed = sources.some( (source) => source.collection.status === `error` ); const clientStreamFailed = failedClientStream !== void 0 && this.client !== void 0 && this.queryHash !== void 0 && this.client._getLiveQuery(this.queryHash) === failedClientStream; if (networkFailed && !sourceFailed && (this.collection?.status === `error` || !clientStreamFailed)) { finish({ error: networkError }); } else if ((deadlinePassed || networkFailed) && persisted.status === `ready`) { finish(); } else if (networkFailed && persisted.status === `error`) { finish({ error: networkError ?? persisted.error }); } else if (!deadlinePassed && !networkFailed) { timer = setTimeout(check, remainingNetworkMs); } }; for (const source of sources) { unsubscribers.push(source.readiness.subscribe(check)); } void network.then( () => { networkReady = true; check(); }, (error) => { networkFailed = true; networkError = error; failedClientStream = clientStream?.status === `error` ? clientStream : void 0; check(); } ); check(); }); const promise = initialRender.finally(() => { if (this.cancelInitialRenderWait === cancel) { this.cancelInitialRenderWait = void 0; } unsubscribers.forEach((unsubscribe) => unsubscribe()); if (timer !== void 0) clearTimeout(timer); if (this.initialRenderPromise === promise) { this.initialRenderPromise = void 0; } }); this.initialRenderPromise = promise; return promise; } dispose() { if (this.disposed) return; this.disposed = true; this.cancelInitialRenderWait?.(); this.cancelInitialRenderWait = void 0; 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 ); } exports.createLiveQueryObserver = createLiveQueryObserver; //# sourceMappingURL=live-query-observer.cjs.map