@tanstack/db
Version:
A reactive client store for building super fast apps on sync
675 lines (674 loc) • 21.8 kB
JavaScript
import { LiveQueryObserverDisposedError } from "./errors.js";
import { isSingleResultCollection, getLiveQueryStatusFlags } from "./live-query-adapter.js";
import { getBuilderFromConfig } from "./query/live/collection-registry.js";
import { getPersistedReadinessSource } from "./persisted-readiness.js";
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 = getBuilderFromConfig(config);
if (builder) {
return builder.getSourceCollections().every(visit);
}
const source = 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.aD = true;
this.bp = DISABLED_SNAPSHOT;
this.layoutRevision = 0;
this.subscriptions = /* @__PURE__ */ new Set();
this.aE = [];
this.b4 = false;
this.bC = false;
this.fV = false;
this.cu = null;
this.a9 = false;
this.bs = false;
this.dO = false;
this.dk = false;
this.disposed = false;
this.collection = collection;
this.wholesale = wholesale;
this.client = client;
this.queryHash = queryHash;
this.ce = collection ? collectPersistedReadinessSources(collection) : void 0;
this.onPreload = onPreload;
this.cE();
}
getSnapshot() {
const collection = this.collection;
if (!collection) return DISABLED_SNAPSHOT;
this.cA();
if (!this.fV) this.d6(collection);
const local = this.aU();
const persistedStatus = this.aO() && local.status === `ready` ? `loading` : local.status;
if (this.bp.persistedStatus !== persistedStatus || this.bp.persistedError !== local.error) {
this.aD = true;
}
if (this.aD) {
const entries = this.cH(collection);
const state = new Map(entries);
const data = entries.map(([, value]) => value);
const singleResult = isSingleResultCollection(collection);
const liveStatus = this.cs ?? collection.status;
const status = this.a9 || liveStatus === `error` ? `error` : this.aO() ? `ready` : liveStatus;
const prevKeys = this.eU;
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.eU = entries.map(([key]) => key);
this.layoutRevision++;
}
this.bp = {
state,
data: singleResult ? data[0] : data,
collection,
layoutRevision: this.layoutRevision,
status,
...getLiveQueryStatusFlags(status),
persistedStatus,
isPersistedReady: persistedStatus === `ready`,
persistedError: local.error,
isEnabled: true
};
this.aD = false;
}
return this.bp;
}
getServerSnapshot() {
return this.getSnapshot();
}
getError() {
this.cA();
return this.a9 ? this.cL : void 0;
}
dehydrate() {
const collection = this.collection;
if (!collection) return { rows: [] };
const entries = this.aO() ? this.a$.entries : this.cZ(collection).entries;
return {
rows: entries.map(([key, value]) => ({
key,
value
}))
};
}
aO() {
return this.a$ !== void 0 && !this.bs;
}
aU() {
const sources = this.ce;
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 };
}
cH(collection) {
if (this.aO()) return this.a$.entries;
return this.cT ?? this.eQ(collection).entries;
}
cA() {
if (!this.client || !this.queryHash || this.bs) {
return false;
}
const query = this.client._getLiveQuery(this.queryHash);
if (!query) return false;
if (this.fV && !this.a$ && this.collection?.status === `ready` && !this.collection.isLoadingSubset) {
return this.bL(query.dehydratedAt);
}
if (query.status === `error`) {
const changed = !this.a9 || this.cL !== query.error;
this.cL = query.error;
this.a9 = true;
if (changed) this.aD = true;
return changed;
}
if (query.status !== `success` || !query.snapshot || this.a$ && this.a$.dehydratedAt >= query.dehydratedAt) {
return false;
}
this.a$ = {
dehydratedAt: query.dehydratedAt,
entries: query.snapshot.rows.map((row) => [
row.key,
row.value
])
};
this.cL = void 0;
this.a9 = false;
this.aD = true;
return true;
}
fo(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;
}
eY(collection) {
const previous = this.a$?.entries ?? [];
const dehydratedAt = this.a$?.dehydratedAt;
const { entries, revision } = this.cZ(collection);
this.a$ = void 0;
this.bL(dehydratedAt);
this.bF(entries, revision);
this.aD = true;
return {
changes: this.fo(previous, entries),
entries,
revision
};
}
bL(dehydratedAt) {
const changed = this.a9;
this.cL = void 0;
this.a9 = false;
this.bs = true;
if (dehydratedAt !== void 0 && this.queryHash) {
this.client?._consumeLiveQueryResult(this.queryHash, dehydratedAt);
}
if (changed) this.aD = true;
return changed;
}
ch() {
if (this.dO) return;
this.dO = true;
queueMicrotask(() => {
this.dO = false;
const collection = this.collection;
if (this.disposed || !this.fV || !collection || !this.aO() || collection.status !== `ready` && this.aU().status !== `ready` || collection.isLoadingSubset) {
return;
}
const handoff = this.eY(collection);
this.emit(
this.wholesale ? void 0 : handoff.changes,
void 0,
handoff.entries,
collection.status,
handoff.revision,
this.bc(collection),
true
);
});
}
dR(collection) {
const revision = collection._stateRevision;
return typeof revision === `number` ? revision : void 0;
}
bc(collection) {
const revision = collection._layoutRevision;
return typeof revision === `number` ? revision : void 0;
}
cZ(collection) {
const entries = Array.from(collection.entries());
const revision = this.dR(collection);
return { entries, revision };
}
eQ(collection) {
const { entries, revision } = this.cZ(collection);
this.bF(entries, revision);
return { entries, revision };
}
bF(entries, revision) {
const changed = revision !== void 0 ? this.cT === void 0 || revision !== this.ca : !this.fH(this.cT, entries);
this.cT = entries;
this.ca = revision;
if (changed) this.aD = true;
}
fH(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.
*/
d6(collection) {
const status = collection.status;
const revision = this.dR(collection);
const layoutRevision = this.bc(collection);
if (revision !== void 0) {
if (this.cT === void 0 || revision !== this.ca || layoutRevision !== this.bu) {
this.eQ(collection);
this.bu = layoutRevision;
this.aD = true;
}
} else {
const entries = Array.from(collection.entries());
this.bF(entries, void 0);
}
if (this.cs !== status) {
this.cs = status;
this.aD = true;
}
}
subscribe(listener) {
if (this.disposed) throw new LiveQueryObserverDisposedError();
const record = { listener, active: true };
this.subscriptions.add(record);
if (this.subscriptions.size === 1) {
this.f2();
} 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.dH();
};
}
/** 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.cH(collection)) {
seedChanges.push({ type: `insert`, key, value });
}
if (seedChanges.length === 0) return;
this.emit(seedChanges, [record]);
}
f2() {
const collection = this.collection;
if (!collection || this.disposed) return;
this.cE();
this.cA();
this.d6(collection);
this.fV = true;
this.cs ??= collection.status;
this.cm = this.bc(collection);
const attachedWithHydrationSeed = this.aO();
this.bC = this.wholesale || attachedWithHydrationSeed;
const notify = (changes, status = collection.status, explicitLayoutChange = false) => {
if (this.disposed || this.subscriptions.size === 0) return;
if (this.aO()) {
if (status === `ready`) this.ch();
if (status !== `error`) return;
}
if (status === `ready` && !collection.isLoadingSubset && !this.bs && this.client && this.queryHash) {
const query = this.client._getLiveQuery(this.queryHash);
this.bL(query?.dehydratedAt);
}
const layoutRevision = this.bc(collection);
let layoutChanged = explicitLayoutChange;
if (!explicitLayoutChange && changes !== void 0 && changes.length === 0) {
if (layoutRevision === void 0 || layoutRevision === this.cm) {
return;
}
layoutChanged = true;
}
if (changes !== void 0 && layoutRevision !== void 0) {
this.cm = layoutRevision;
}
const captured = changes !== void 0 ? this.cZ(collection) : status === `cleaned-up` ? this.cZ(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.ce?.map(
(source) => source.readiness.subscribe(() => {
if (this.disposed || this.subscriptions.size === 0) return;
this.aD = true;
if (this.aO() && this.aU().status === `ready`) {
this.ch();
} 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.cH(collection);
if (!this.cA()) return;
const nextEntries = this.cH(collection);
this.emit(
this.wholesale ? void 0 : this.fo(previousEntries, nextEntries)
);
}) : () => {
};
const release = () => {
clientUnsub();
statusUnsub();
layoutUnsub();
persistedUnsubs?.forEach((unsubscribe) => unsubscribe());
subscription?.unsubscribe();
};
this.cu = release;
subscription = collection.subscribeChanges(
(changes) => notify(changes),
{ includeInitialState: !this.wholesale && !attachedWithHydrationSeed }
);
this.bC = false;
if (this.cu !== release) {
subscription.unsubscribe();
return;
}
if (this.wholesale || attachedWithHydrationSeed) {
this.er(!this.wholesale);
const { entries, revision } = this.cZ(collection);
this.bF(entries, revision);
}
if (this.aO()) {
if (!this.wholesale) this.seed(Array.from(this.subscriptions)[0]);
if (collection.status === `ready` || this.aU().status === `ready`) {
this.ch();
}
}
}
dH() {
this.cu?.();
this.cu = null;
this.fV = false;
this.bC = false;
this.aE.length = 0;
this.bB?.();
this.bB = void 0;
}
cE() {
if (this.bB || !this.client?._isSsrServerCleanupEnabled() || !this.collection || !getBuilderFromConfig(this.collection.config)) {
return;
}
this.bB = 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.aE.push({
changes,
targets,
entries,
status,
collectionRevision,
collectionLayoutRevision,
layoutChanged
});
if (this.b4 || this.bC) return;
this.er();
}
er(deliver = true) {
if (this.b4) return;
let failure;
this.b4 = true;
try {
while (this.aE.length > 0) {
const publication = this.aE.shift();
if (publication.entries) {
this.bF(
publication.entries,
publication.collectionRevision
);
}
if (publication.collectionLayoutRevision !== void 0) {
this.bu = publication.collectionLayoutRevision;
}
if (publication.layoutChanged) {
this.aD = true;
}
if (this.cs !== publication.status) {
this.cs = publication.status;
this.aD = true;
}
if (deliver) {
for (const subRecord of publication.targets) {
if (this.disposed) break;
try {
subRecord.listener(publication.changes);
} catch (error) {
failure ??= { error };
}
}
}
}
} finally {
this.b4 = 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.cE();
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.dk) return true;
const collection = this.collection;
const sources = this.ce;
if (!collection || !INITIAL_RENDER_PRELOADS.has(collection) || !sources || this.aU().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 LiveQueryObserverDisposedError());
const sources = this.ce;
if (!sources) return this.preload();
if (this.dk) return Promise.resolve();
if (this.bw) return this.bw;
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.dk = true;
resolve();
}
};
cancel = () => finish({ error: new LiveQueryObserverDisposedError() });
this.a6 = cancel;
const check = () => {
if (settled) return;
if (timer !== void 0) {
clearTimeout(timer);
timer = void 0;
}
if (networkReady) {
finish();
return;
}
const persisted = this.aU();
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.a6 === cancel) {
this.a6 = void 0;
}
unsubscribers.forEach((unsubscribe) => unsubscribe());
if (timer !== void 0) clearTimeout(timer);
if (this.bw === promise) {
this.bw = void 0;
}
});
this.bw = promise;
return promise;
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.a6?.();
this.a6 = void 0;
this.dH();
for (const subRecord of this.subscriptions) subRecord.active = false;
this.subscriptions.clear();
this.aE.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