diff --git a/DEVTOOLS_SPEC.md b/DEVTOOLS_SPEC.md index ee4ab004..5443db09 100644 --- a/DEVTOOLS_SPEC.md +++ b/DEVTOOLS_SPEC.md @@ -14,21 +14,81 @@ connection: - selects the newest live Figbird instance; - subscribes to `figbird.events`; -- exposes current `inspect()`, `inspectRelational()`, and `mutating` snapshots; -- buffers at most 1,000 events between panel polls; and +- exposes current `inspect()`, `inspectCache()`, and `inspectRelational()` snapshots; +- buffers at most 5,000 events between panel polls; and - expires after five seconds without a poll, removing every event subscription. The bridge serializes values before they cross the browser DevTools evaluation boundary. Errors retain their name and message, bigint values become strings, and circular values -are marked instead of breaking the panel. +are marked instead of breaking the panel. Event payloads and current query, cache, and +relational values are depth-, item-, string-, and node-bounded before serialization. + +## Connection diagnostics + +Adapters may expose transport lifecycle events through Figbird's adapter-neutral +connection observer. The Feathers adapter maps Socket.IO `connect`, `disconnect`, +connection failure, and Manager reconnection state into this observer. A successful +reconnection remains the single trigger for Figbird's active-query sweep. + +The panel retains these lifecycle events in the bounded event log and renders a +**Connection** timeline lane. Red spans show detected offline intervals; the reconnect +marker carries the final attempt count and transport so the refetch activity immediately +after it can be correlated visually. Individual retry attempts are deliberately +coalesced instead of filling the event buffer. Authentication payloads and tokens are +never collected. + +## Causal traces + +Runtime events carry stable, session-local identifiers where one operation causes +another. Realtime items and reconnections are trace roots. Cache updates retain the +root identifier, reconciliation decisions record whether work started, coalesced, +deferred while hidden, or became pending while inactive, and fetch attempts carry +their reason, retry attempt, and causes. Fetch end/error events share a fetch ID with +their start event. + +The event details pane assembles those events into one causal chain. Timeline fetch, +realtime, and connection marks link into the same chain. This metadata is emitted only +while something subscribes to `figbird.events`, and the extension continues to bound +all retained history. + +The Events tab defaults to an **Activity** projection: one summary row per causal trace, +mutation, or action. Supporting cache, reconciliation, retry, and sweep events remain in +the row's details. **All events** exposes the complete raw instrumentation stream when a +forensic view is needed. + +The Queries tab separates **Inactive queries** from the live view. These are still +present in Figbird's cache but currently have no subscribers, so they remain inspectable +until normal garbage collection removes them. **All queries** also includes bounded +DevTools history for queries that are no longer present in Figbird. + +Read-only JSON values use a shared syntax-highlighted browser. The root is expanded so +top-level properties are immediately visible, nested containers are individually +collapsible, and **Expand all** opens the complete value. **Raw** switches to highlighted +formatted JSON. The cache editor remains a JSON textarea because it accepts replacement +input rather than only inspecting a value. + +## Cache inspection and editing + +The page bridge exposes a serialized projection of each service's normalized entities, +their current query memberships, and complete-set materialization marker. The collector +adds session-local provenance from cache-update events (fetch, realtime, mutation, +optimistic projection, or devtools edit). + +An attached extension session may replace one existing entity in memory. The edited +JSON must retain the same entity ID. Figbird reapplies locally decidable query results +and replaces the value in query results that already contain the entity; it never sends +a service mutation or server request. The panel labels this behavior explicitly and +offers a one-step undo. Later fetches or realtime events may overwrite the edit. ## Extension architecture ``` lib/core/devtoolsBridge.ts weak instance registry and inspected-page session lib/devtools/collector.ts bounded query, event, timeline, and write history +lib/devtools/timelineTraceStore.ts Timeline-owned causal summaries independent of Events +lib/devtools/historicalValue.ts retained-or-evicted diagnostic value contract lib/devtools/Devtools.tsx shared React panel used only by the extension -extensions/src/remote.ts polling transport exposed as a collector-compatible source +extensions/src/remote.ts polling transport that publishes decoded collector frames extensions/src/protocol.ts versioned snapshot envelope and wire-to-panel decoding extensions/src/inspection.ts extension-side picker lifecycle and state extensions/src/inspectionPage.ts injected element picker and React query-area scanner @@ -61,6 +121,6 @@ to expire. ## Scope -The panel is read-only apart from clearing its local history and starting or stopping the -element picker. It does not persist history across reloads, edit application state, or -provide time travel. +The panel is read-only apart from clearing local history, controlling the element picker, +and explicitly replacing an existing cached entity in memory. It does not persist history +across reloads, write cache edits to the server, or provide time travel. diff --git a/demo/src/components/DemoControls.tsx b/demo/src/components/DemoControls.tsx index 4bc2560d..9d585675 100644 --- a/demo/src/components/DemoControls.tsx +++ b/demo/src/components/DemoControls.tsx @@ -25,6 +25,28 @@ export function DemoControls() { } }, [open]) + useEffect(() => { + if (!demoState?.chaosArmed) return + let cancelled = false + let timer: ReturnType | null = null + const refresh = async () => { + try { + const state = await demoControl.getState() + if (cancelled) return + setDemoState(state) + if (!state.chaosArmed) return + } catch { + if (cancelled) return + } + timer = setTimeout(() => void refresh(), 500) + } + timer = setTimeout(() => void refresh(), 500) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [demoState?.chaosArmed]) + const applyDemoPatch = async (patch: Partial) => { if (!demoState) return const previous = demoState diff --git a/demo/src/pages/IssueDetail/screen.tsx b/demo/src/pages/IssueDetail/screen.tsx index 6a2ef28a..937b6f6e 100644 --- a/demo/src/pages/IssueDetail/screen.tsx +++ b/demo/src/pages/IssueDetail/screen.tsx @@ -190,23 +190,23 @@ queries: ({ params }) => [
- - - - - diff --git a/extensions/README.md b/extensions/README.md index 09e6a022..082f9db5 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -65,7 +65,12 @@ The shared browser extension version lives in `extensions/version.json`. See ## QA checklist - The Figbird panel reports **Connected** after the page creates a Figbird instance. -- Queries, relational details, events, the fetch timeline, and writes update live. +- Queries, relational details, causal events, the fetch timeline, normalized cache, and writes update live. +- The Queries visibility menu separates live, inactive cached, skipped, and historical queries. +- Events opens in grouped Activity mode; All events exposes the bounded raw stream. +- JSON details open as a collapsible highlighted tree, with Expand all and Raw views. +- Selecting a linked timeline or cache-provenance mark opens its causal event chain. +- Cache JSON edits rerender affected subscribers without issuing a server request and can be undone. - **Inspect** highlights the page and filters to queries owned by the selected area. - Reloading or navigating the inspected tab reconnects the panel. - Closing the panel for five seconds ends the page-side debug session. diff --git a/extensions/src/panel.tsx b/extensions/src/panel.tsx index 26f26eaa..f47b0260 100644 --- a/extensions/src/panel.tsx +++ b/extensions/src/panel.tsx @@ -1,34 +1,72 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { createRoot } from 'react-dom/client' import { FigbirdDevtoolsPanel } from '../../lib/devtools/Devtools.js' -import { createCollector } from '../../lib/devtools/collector.js' +import { createRemoteCollector } from '../../lib/devtools/collector.js' import { PANEL_VISIBILITY_CALLBACK, type DevtoolsPanelWindow } from './panelVisibility.js' import { ExtensionSession } from './remote.js' +interface DevtoolsNavigationEvent { + addListener(listener: (url: string) => void): void + removeListener(listener: (url: string) => void): void +} + +declare const chrome: { devtools: { network: { onNavigated: DevtoolsNavigationEvent } } } + function Panel() { const session = useMemo(() => new ExtensionSession(), []) - const collector = useMemo(() => createCollector(session.figbird, { heartbeatMs: 0 }), [session]) + const collector = useMemo(() => createRemoteCollector(), []) + const cacheEditor = useMemo(() => ({ update: session.editCacheEntity }), [session]) const status = useSyncExternalStore(session.subscribeStatus, session.getStatus, session.getStatus) useEffect(() => { const panelWindow = window as DevtoolsPanelWindow - const setVisible = (visible: boolean) => { - if (visible) session.start() - else session.stop() + let documentVisible = document.visibilityState !== 'hidden' + let hostVisible = true + let running = false + const applyVisibility = () => { + const visible = documentVisible && hostVisible + if (visible === running) return + running = visible + if (visible) { + session.start() + } else { + session.stop() + } + } + const updateFromDocument = () => { + documentVisible = document.visibilityState !== 'hidden' + applyVisibility() } - const updateFromDocument = () => setVisible(document.visibilityState !== 'hidden') - panelWindow[PANEL_VISIBILITY_CALLBACK] = setVisible - updateFromDocument() + panelWindow[PANEL_VISIBILITY_CALLBACK] = visible => { + hostVisible = visible + applyVisibility() + } + applyVisibility() document.addEventListener('visibilitychange', updateFromDocument) return () => { document.removeEventListener('visibilitychange', updateFromDocument) delete panelWindow[PANEL_VISIBILITY_CALLBACK] session.stop() + collector.reset() } + }, [collector, session]) + + useEffect(() => { + const resetForNavigation = () => session.resetForNavigation() + chrome.devtools.network.onNavigated.addListener(resetForNavigation) + return () => chrome.devtools.network.onNavigated.removeListener(resetForNavigation) }, [session]) + useEffect(() => session.subscribeReset(() => collector.reset()), [collector, session]) + useEffect(() => session.subscribeRead(frame => collector.ingest(frame)), [collector, session]) + return ( - + ) } diff --git a/extensions/src/protocol.ts b/extensions/src/protocol.ts index fb14196d..07e51337 100644 --- a/extensions/src/protocol.ts +++ b/extensions/src/protocol.ts @@ -1,4 +1,5 @@ import type { FigbirdEvent } from '../../lib/core/events.js' +import { errorFromDetails } from '../../lib/core/errors.js' import type { DevtoolsBridgeConnection, DevtoolsWireEvent, @@ -6,13 +7,14 @@ import type { } from '../../lib/core/devtoolsBridge.js' interface WireEnvelopeShape { - protocol: 2 + protocol: 2 | 3 version: number read: { + cache?: unknown[] events: unknown[] - inFlightMutations: unknown[] - queries: unknown[] - relational: unknown[] + inFlightMutations?: unknown[] + queries?: unknown[] + relational?: unknown[] } | null } @@ -25,7 +27,7 @@ export function parseConnection(value: unknown): DevtoolsBridgeConnection | null if (value === null || value === undefined) return null if ( !isRecord(value) || - value.protocol !== 2 || + (value.protocol !== 2 && value.protocol !== 3) || typeof value.instanceCount !== 'number' || typeof value.instanceId !== 'number' || typeof value.sessionId !== 'string' @@ -47,10 +49,10 @@ export function parseWireRead(value: unknown): ParsedWireRead | null { const envelope: unknown = JSON.parse(value) if (!isWireEnvelope(envelope)) throw new Error('Figbird returned an invalid devtools snapshot') - // Protocol 2 defines the collection item shapes. The envelope check guards the + // The bridge protocol defines the collection item shapes. The envelope check guards the // transport boundary without duplicating every domain type in the extension. return { - read: envelope.read as unknown as DevtoolsWireRead | null, + read: envelope.read ? (envelope.read as unknown as DevtoolsWireRead) : null, version: envelope.version, } } @@ -59,9 +61,14 @@ export function decodeEvent(event: DevtoolsWireEvent): FigbirdEvent { switch (event.kind) { case 'fetch:error': case 'mutate:error': - case 'action:error': { - const error = new Error(event.error.message) - error.name = event.error.name + case 'action:error': + case 'connection:error': { + const error = errorFromDetails(event.error.details, event.error) + return { ...event, error } + } + case 'connection:reconnect-failed': { + if (!event.error) return event + const error = errorFromDetails(event.error.details, event.error) return { ...event, error } } default: @@ -70,15 +77,22 @@ export function decodeEvent(event: DevtoolsWireEvent): FigbirdEvent { } function isWireEnvelope(value: unknown): value is WireEnvelopeShape { - if (!isRecord(value) || value.protocol !== 2 || typeof value.version !== 'number') return false + if ( + !isRecord(value) || + (value.protocol !== 2 && value.protocol !== 3) || + typeof value.version !== 'number' + ) { + return false + } if (value.read === null) return true if (!isRecord(value.read)) return false const read = value.read return ( Array.isArray(read.events) && - Array.isArray(read.inFlightMutations) && - Array.isArray(read.queries) && - Array.isArray(read.relational) + (read.cache === undefined || Array.isArray(read.cache)) && + (read.inFlightMutations === undefined || Array.isArray(read.inFlightMutations)) && + (read.queries === undefined || Array.isArray(read.queries)) && + (read.relational === undefined || Array.isArray(read.relational)) ) } diff --git a/extensions/src/remote.ts b/extensions/src/remote.ts index 4c003112..fe46d93c 100644 --- a/extensions/src/remote.ts +++ b/extensions/src/remote.ts @@ -1,13 +1,10 @@ -import type { FigbirdEvent, FigbirdEvents } from '../../lib/core/events.js' -import type { InspectedQuery } from '../../lib/core/figbird.js' -import type { InFlightMutation, MutationActivity } from '../../lib/core/mutationTracker.js' -import type { InspectedRelationalQuery } from '../../lib/core/relationalQuery.js' import type { DevtoolsBridgeConnection, DevtoolsWireRead } from '../../lib/core/devtoolsBridge.js' -import type { FigbirdLikeForDevtools } from '../../lib/devtools/collector.js' +import type { RemoteCollectorFrame } from '../../lib/devtools/collector.js' import { ExtensionInspectionSession } from './inspection.js' import { decodeEvent, parseConnection, parseWireRead } from './protocol.js' -const POLL_INTERVAL_MS = 250 +const ACTIVE_POLL_INTERVAL_MS = 250 +const IDLE_POLL_INTERVAL_MS = 1_000 const BRIDGE_EXPRESSION = 'globalThis["__FIGBIRD_DEVTOOLS__"]' type Evaluate = (expression: string) => Promise @@ -21,107 +18,21 @@ interface InspectedWindowApi { declare const chrome: { devtools: { inspectedWindow: InspectedWindowApi } } -class RemoteFigbird implements FigbirdLikeForDevtools { - #eventListeners = new Set<(event: FigbirdEvent) => void>() - #mutatingListeners = new Set<() => void>() - #mutations: readonly InFlightMutation[] = [] - #pending: DevtoolsWireRead | null = null - #queries: InspectedQuery[] = [] - #relational: InspectedRelationalQuery[] = [] - #renderFrame: number | null = null - #stateListeners = new Set<(state: unknown) => void>() - #taskVersion = 0 - - readonly events: FigbirdEvents = { - subscribe: listener => { - this.#eventListeners.add(listener) - return () => this.#eventListeners.delete(listener) - }, - } - - readonly mutating: MutationActivity = { - getSnapshot: () => this.#mutations, - subscribe: listener => { - this.#mutatingListeners.add(listener) - return () => this.#mutatingListeners.delete(listener) - }, - } - - inspect(): InspectedQuery[] { - return this.#queries - } - - inspectRelational(): InspectedRelationalQuery[] { - return this.#relational - } - - subscribeToStateChanges(listener: (state: unknown) => void): () => void { - this.#stateListeners.add(listener) - return () => this.#stateListeners.delete(listener) - } - - update(read: DevtoolsWireRead): void { - this.#pending = this.#pending - ? { - ...read, - events: [...this.#pending.events, ...read.events], - } - : read - if (this.#renderFrame !== null) return - if (typeof requestAnimationFrame === 'function') { - this.#renderFrame = requestAnimationFrame(() => { - this.#renderFrame = null - this.#flush() - }) - return - } - const taskVersion = ++this.#taskVersion - queueMicrotask(() => { - if (taskVersion === this.#taskVersion) this.#flush() - }) - } - - cancelPending(): void { - this.#taskVersion++ - this.#pending = null - if (this.#renderFrame !== null && typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(this.#renderFrame) - } - this.#renderFrame = null - } - - #flush(): void { - const read = this.#pending - this.#pending = null - if (!read) return - this.#queries = read.queries.map(query => ({ - ...query, - fetchedAt: query.fetchedAt, - query: query.query, - })) - this.#relational = read.relational - this.#mutations = read.inFlightMutations - for (const event of read.events) { - const decoded = decodeEvent(event) - for (const listener of this.#eventListeners) listener(decoded) - } - for (const listener of this.#stateListeners) listener(undefined) - for (const listener of this.#mutatingListeners) listener() - } -} - export class ExtensionSession { - readonly figbird = new RemoteFigbird() readonly inspection: ExtensionInspectionSession #connection: DevtoolsBridgeConnection | null = null #evaluate: Evaluate #generation = 0 + #instanceId: number | null = null #polling = false + #running = false #status = 'Waiting for Figbird' #statusListeners = new Set<() => void>() #timer: ReturnType | null = null #version: number | null = null + #resetListeners = new Set<() => void>() + #readListeners = new Set<(frame: RemoteCollectorFrame) => void>() constructor(evaluate: Evaluate = evaluateInspectedWindow) { this.#evaluate = evaluate @@ -135,28 +46,74 @@ export class ExtensionSession { return () => this.#statusListeners.delete(listener) } + subscribeReset = (listener: () => void): (() => void) => { + this.#resetListeners.add(listener) + return () => this.#resetListeners.delete(listener) + } + + subscribeRead = (listener: (frame: RemoteCollectorFrame) => void): (() => void) => { + this.#readListeners.add(listener) + return () => this.#readListeners.delete(listener) + } + + editCacheEntity = async ( + serviceName: string, + itemId: string | number, + item: unknown, + ): Promise<{ ok: boolean; error?: string; traceId?: number }> => { + try { + const sessionId = this.#connection?.sessionId + if (!sessionId) return { ok: false, error: 'Figbird is not connected' } + const expression = `${BRIDGE_EXPRESSION}?.editCacheEntityJson(${JSON.stringify(sessionId)},${JSON.stringify(serviceName)},${JSON.stringify(JSON.stringify(itemId))},${JSON.stringify(JSON.stringify(item))})` + const value = await this.#evaluate(expression) + if (typeof value !== 'string') return { ok: false, error: 'Invalid cache edit response' } + const parsed: unknown = JSON.parse(value) + if (!isCacheEditResult(parsed)) return { ok: false, error: 'Invalid cache edit response' } + return parsed + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + } + start(): void { - if (this.#timer) return + if (this.#running) return + this.#running = true const generation = ++this.#generation + if (this.#polling) { + this.#schedulePoll(generation, ACTIVE_POLL_INTERVAL_MS) + return + } void this.#poll(generation) - this.#timer = setInterval(() => void this.#poll(generation), POLL_INTERVAL_MS) } stop(): void { + this.#running = false this.#generation++ - if (this.#timer) clearInterval(this.#timer) + if (this.#timer) clearTimeout(this.#timer) this.#timer = null this.#version = null - this.figbird.cancelPending() this.inspection.stop() const sessionId = this.#connection?.sessionId this.#connection = null if (sessionId) void this.#disconnect(sessionId) } + resetForNavigation(): void { + const restart = this.#running + this.stop() + this.#instanceId = null + this.inspection.reset() + for (const listener of this.#resetListeners) listener() + if (restart) this.start() + } + async #poll(generation: number): Promise { - if (this.#polling) return + if (this.#polling) { + if (generation === this.#generation) this.#schedulePoll(generation, ACTIVE_POLL_INTERVAL_MS) + return + } this.#polling = true + let nextDelay = IDLE_POLL_INTERVAL_MS try { if (!this.#connection) { const connection = parseConnection(await this.#evaluate(`${BRIDGE_EXPRESSION}?.connect()`)) @@ -168,12 +125,13 @@ export class ExtensionSession { this.#setStatus('Waiting for Figbird') return } + if (this.#instanceId !== null && connection.instanceId !== this.#instanceId) { + this.inspection.reset() + for (const listener of this.#resetListeners) listener() + } + this.#instanceId = connection.instanceId this.#connection = connection - this.#setStatus( - connection.instanceCount > 1 - ? `Connected · instance ${connection.instanceId} of ${connection.instanceCount}` - : 'Connected', - ) + this.#setConnectedStatus(connection) } const sessionId = JSON.stringify(this.#connection.sessionId) @@ -182,26 +140,48 @@ export class ExtensionSession { ) if (generation !== this.#generation) return if (!poll) { - this.#connection = null - this.#version = null + this.#dropConnection() this.inspection.reset() this.#setStatus('Reconnecting') return } this.#version = poll.version - if (poll.read) this.figbird.update(poll.read) + this.#setConnectedStatus(this.#connection) + if (poll.read) { + nextDelay = ACTIVE_POLL_INTERVAL_MS + this.#publishRead(poll.read) + } await this.inspection.refresh() } catch { if (generation !== this.#generation) return - this.#connection = null - this.#version = null this.inspection.reset() this.#setStatus('Cannot inspect this page') } finally { this.#polling = false + if (generation === this.#generation) this.#schedulePoll(generation, nextDelay) } } + #schedulePoll(generation: number, delay: number): void { + this.#timer = setTimeout(() => { + this.#timer = null + void this.#poll(generation) + }, delay) + } + + #dropConnection(): void { + this.#connection = null + this.#version = null + } + + #setConnectedStatus(connection: DevtoolsBridgeConnection): void { + this.#setStatus( + connection.instanceCount > 1 + ? `Connected · instance ${connection.instanceId} of ${connection.instanceCount}` + : 'Connected', + ) + } + async #disconnect(sessionId: string): Promise { await this.#evaluate(`${BRIDGE_EXPRESSION}?.disconnect(${JSON.stringify(sessionId)})`).catch( () => {}, @@ -213,6 +193,33 @@ export class ExtensionSession { this.#status = status for (const listener of this.#statusListeners) listener() } + + #publishRead(read: DevtoolsWireRead): void { + const frame: RemoteCollectorFrame = { + events: read.events.map(decodeEvent), + ...(read.queries + ? { + queries: read.queries.map(query => ({ + ...query, + fetchedAt: query.fetchedAt, + query: query.query ?? {}, + })), + } + : {}), + ...(read.cache ? { cache: read.cache } : {}), + ...(read.relational ? { relational: read.relational } : {}), + } + for (const listener of this.#readListeners) listener(frame) + } +} + +function isCacheEditResult( + value: unknown, +): value is { ok: boolean; error?: string; traceId?: number } { + if (typeof value !== 'object' || value === null || !('ok' in value)) return false + if (typeof value.ok !== 'boolean') return false + if ('error' in value && value.error !== undefined && typeof value.error !== 'string') return false + return !('traceId' in value) || value.traceId === undefined || typeof value.traceId === 'number' } function evaluateInspectedWindow(expression: string): Promise { diff --git a/lib/adapters/adapter.ts b/lib/adapters/adapter.ts index b84e3548..9a92c1f1 100644 --- a/lib/adapters/adapter.ts +++ b/lib/adapters/adapter.ts @@ -61,6 +61,23 @@ export interface EventHandlers { removed: (item: unknown) => void } +/** Transport lifecycle facts an adapter can expose for observability. */ +export type AdapterConnectionEvent = + | { type: 'connected'; transport?: string; connectionId?: string } + | { + type: 'disconnected' + reason?: string + reconnecting: boolean + } + | { + type: 'reconnected' + attempt?: number + transport?: string + connectionId?: string + } + | { type: 'error'; phase: 'connect' | 'reconnect'; error: Error } + | { type: 'reconnect-failed'; error?: Error } + /** Service context supplied when the adapter evaluates a query locally. */ export interface MatcherContext { serviceName: string @@ -102,6 +119,9 @@ export interface Adapter< // reconnects after a period where realtime events may have been missed. subscribeToReconnect?(handler: () => void): () => void + /** Optional detailed transport lifecycle for devtools and diagnostics. */ + subscribeToConnectionEvents?(handler: (event: AdapterConnectionEvent) => void): () => void + /** * Read an item's id, or `undefined` when absent. Pure extraction — whether a * missing id is noteworthy is the store's call (it warns on event/fetch paths diff --git a/lib/adapters/feathers.ts b/lib/adapters/feathers.ts index bc7b6045..88ab77d6 100644 --- a/lib/adapters/feathers.ts +++ b/lib/adapters/feathers.ts @@ -1,5 +1,6 @@ import type { Adapter, + AdapterConnectionEvent, EventHandlers, MatcherContext, PageCursor, @@ -228,6 +229,23 @@ interface ReconnectEventSource { removeListener?: (event: string, listener: () => void) => void } +type ConnectionEventListener = (...args: unknown[]) => void + +interface ConnectionEventSource { + on(event: string, listener: ConnectionEventListener): void + off?: (event: string, listener: ConnectionEventListener) => void + removeListener?: (event: string, listener: ConnectionEventListener) => void +} + +interface SocketIoConnectionSource extends ConnectionEventSource { + active?: boolean + connected?: boolean + id?: string + io?: ConnectionEventSource & { + engine?: { transport?: { name?: string } } + } +} + /** * Typed Feathers service for a specific service in the schema. * Provides full type safety for CRUD methods and custom methods. @@ -620,19 +638,129 @@ export class FeathersAdapter> implements Adapte } subscribeToReconnect(handler: () => void): () => void { - const source = this.#getReconnectEventSource() - if (!source) return () => {} + return this.subscribeToConnectionEvents(event => { + if (event.type === 'reconnected') handler() + }) + } - source.on('reconnect', handler) - return () => { - if (source.off) { - source.off('reconnect', handler) + subscribeToConnectionEvents(handler: (event: AdapterConnectionEvent) => void): () => void { + const socket = this.#getSocketIoConnectionSource() + if (!socket) { + const source = this.#getReconnectEventSource() + if (!source) return () => {} + const onReconnect = () => handler({ type: 'reconnected' }) + source.on('reconnect', onReconnect) + return () => { + if (source.off) source.off('reconnect', onReconnect) + else source.removeListener?.('reconnect', onReconnect) + } + } + + const manager = socket.io + let disconnected = false + let reconnectAttempt: number | undefined + let lastReconnectError: Error | undefined + const listeners: Array<{ + source: ConnectionEventSource + event: string + listener: ConnectionEventListener + }> = [] + const listen = ( + source: ConnectionEventSource | undefined, + event: string, + listener: ConnectionEventListener, + ) => { + if (!source) return + source.on(event, listener) + listeners.push({ source, event, listener }) + } + const connectionDetails = () => ({ + ...(manager?.engine?.transport?.name ? { transport: manager.engine.transport.name } : {}), + ...(socket.id ? { connectionId: socket.id } : {}), + }) + + listen(socket, 'connect', () => { + if (disconnected || reconnectAttempt !== undefined) { + handler({ + type: 'reconnected', + ...(reconnectAttempt === undefined ? {} : { attempt: reconnectAttempt }), + ...connectionDetails(), + }) + } else { + handler({ type: 'connected', ...connectionDetails() }) + } + disconnected = false + reconnectAttempt = undefined + lastReconnectError = undefined + }) + listen(socket, 'disconnect', (reason: unknown) => { + disconnected = true + handler({ + type: 'disconnected', + ...(typeof reason === 'string' ? { reason } : {}), + reconnecting: socket.active === true, + }) + }) + listen(socket, 'connect_error', (error: unknown) => { + const captured = connectionError(error) + if (disconnected || reconnectAttempt !== undefined || socket.active === true) { + lastReconnectError = captured } else { - source.removeListener?.('reconnect', handler) + handler({ type: 'error', phase: 'connect', error: captured }) + } + }) + listen(manager, 'reconnect_attempt', (attempt: unknown) => { + if (typeof attempt !== 'number') return + reconnectAttempt = attempt + }) + listen(manager, 'reconnect_error', (error: unknown) => { + lastReconnectError = connectionError(error) + }) + listen(manager, 'reconnect_failed', () => + handler({ + type: 'reconnect-failed', + ...(lastReconnectError ? { error: lastReconnectError } : {}), + }), + ) + if (!manager) { + listen(socket, 'reconnect', (attempt: unknown) => { + handler({ + type: 'reconnected', + ...(typeof attempt === 'number' ? { attempt } : {}), + ...connectionDetails(), + }) + disconnected = false + reconnectAttempt = undefined + lastReconnectError = undefined + }) + } + + return () => { + for (const { source, event, listener } of listeners) { + if (source.off) source.off(event, listener) + else source.removeListener?.(event, listener) } } } + #getSocketIoConnectionSource(): SocketIoConnectionSource | null { + const candidates = [ + (this.feathers as { io?: unknown }).io, + (this.feathers as { socket?: unknown }).socket, + ] + for (const candidate of candidates) { + if ( + candidate && + typeof candidate === 'object' && + 'on' in candidate && + typeof candidate.on === 'function' + ) { + return candidate as SocketIoConnectionSource + } + } + return null + } + #getReconnectEventSource(): ReconnectEventSource | null { const io = (this.feathers as { io?: { io?: unknown } }).io const candidates = [ @@ -751,3 +879,11 @@ export class FeathersAdapter> implements Adapte return window } } + +function connectionError(value: unknown): Error { + if (value instanceof Error) return value + if (typeof value === 'object' && value !== null && 'message' in value) { + return new Error(String(value.message)) + } + return new Error(String(value)) +} diff --git a/lib/core/cappedBuffer.ts b/lib/core/cappedBuffer.ts index 3c7a2bd3..50f5fbb0 100644 --- a/lib/core/cappedBuffer.ts +++ b/lib/core/cappedBuffer.ts @@ -13,15 +13,30 @@ export class CappedBuffer { return this.#length } - push(item: T): void { - if (this.#capacity === 0) return + push(item: T): T | undefined { + if (this.#capacity === 0) return item const index = (this.#start + this.#length) % this.#capacity + const evicted = this.#length === this.#capacity ? this.#items[index] : undefined this.#items[index] = item if (this.#length < this.#capacity) { this.#length++ } else { this.#start = (this.#start + 1) % this.#capacity } + return evicted + } + + first(): T | undefined { + return this.#length === 0 ? undefined : this.#items[this.#start] + } + + shift(): T | undefined { + if (this.#length === 0) return undefined + const item = this.#items[this.#start] + this.#items[this.#start] = undefined + this.#start = (this.#start + 1) % this.#capacity + this.#length-- + return item } clear(): void { diff --git a/lib/core/devtoolsBridge.ts b/lib/core/devtoolsBridge.ts index 4ffd99dd..00b2a993 100644 --- a/lib/core/devtoolsBridge.ts +++ b/lib/core/devtoolsBridge.ts @@ -1,29 +1,49 @@ import type { FigbirdEvent, FigbirdEvents } from './events.js' -import type { InspectedQuery } from './figbird.js' +import type { InspectedCacheService, InspectedQuery } from './figbird.js' import type { InspectedRelationalQuery } from './relationalQuery.js' -import type { InFlightMutation, MutationActivity } from './mutationTracker.js' +import type { InFlightMutation } from './mutationTracker.js' import { CappedBuffer } from './cappedBuffer.js' +import { errorDetails } from './errors.js' const BRIDGE_KEY = '__FIGBIRD_DEVTOOLS__' const SESSION_TIMEOUT_MS = 5_000 -const EVENT_LIMIT = 1_000 +const EVENT_LIMIT = 5_000 +const STARTUP_CAPTURE_KEY = '__FIGBIRD_DEVTOOLS_CAPTURE_UNTIL__' +const STARTUP_CAPTURE_TTL_MS = 10_000 +const PAYLOAD_MAX_DEPTH = 8 +const PAYLOAD_MAX_ARRAY_ITEMS = 200 +const PAYLOAD_MAX_OBJECT_PROPERTIES = 100 +const PAYLOAD_MAX_NODES = 2_000 +const PAYLOAD_MAX_STRING_CHARACTERS = 100_000 +const FRAME_MAX_NODES = 50_000 +const FRAME_MAX_STRING_CHARACTERS = 2_000_000 +let cachedStartupCaptureUntil = 0 interface DevtoolsSource { events: FigbirdEvents - mutating: MutationActivity inspect(): InspectedQuery[] + inspectCache(): InspectedCacheService[] inspectRelational(): InspectedRelationalQuery[] + editCacheEntity( + serviceName: string, + itemId: string | number, + item: unknown, + ): { ok: boolean; error?: string; traceId?: number } subscribeToStateChanges(fn: (state: unknown) => void): () => void } export interface DevtoolsWireError { message: string name: string + details?: unknown } -type ToWireEvent = E extends { error: Error } - ? Omit & { error: DevtoolsWireError } - : E +type ToWireEvent = E extends unknown + ? 'error' extends keyof E + ? Omit & + (E extends { error: Error } ? { error: DevtoolsWireError } : { error?: DevtoolsWireError }) + : E + : never export type DevtoolsWireEvent = ToWireEvent @@ -35,35 +55,51 @@ export type DevtoolsWireQuery = Omit & { export interface DevtoolsBridgeConnection { instanceCount: number instanceId: number - protocol: 2 + protocol: 2 | 3 sessionId: string } export interface DevtoolsWireRead { + cache?: InspectedCacheService[] events: DevtoolsWireEvent[] - inFlightMutations: readonly InFlightMutation[] - queries: DevtoolsWireQuery[] - relational: InspectedRelationalQuery[] + inFlightMutations?: readonly InFlightMutation[] + queries?: DevtoolsWireQuery[] + relational?: InspectedRelationalQuery[] } export interface DevtoolsWireEnvelope { - protocol: 2 + protocol: 3 version: number read: DevtoolsWireRead | null } interface DevtoolsBridgeSession { + cacheDirty: boolean events: CappedBuffer expires: ReturnType | null + queriesDirty: boolean + relationalDirty: boolean source: DevtoolsSource unsubscribe: () => void version: number } +interface StartupCapture { + events: CappedBuffer + expires: ReturnType + unsubscribe: () => void +} + interface DevtoolsPageBridge { - protocol: 2 + protocol: 2 | 3 connect(instanceId?: number): DevtoolsBridgeConnection | null disconnect(sessionId: string): void + editCacheEntityJson( + sessionId: string, + serviceName: string, + itemIdJson: string, + itemJson: string, + ): string readJson(sessionId: string, version: number | null): string | null register(source: DevtoolsSource): void } @@ -100,28 +136,40 @@ function isPageBridge(value: unknown): value is DevtoolsPageBridge { typeof value === 'object' && value !== null && 'protocol' in value && - value.protocol === 2 && + (value.protocol === 2 || value.protocol === 3) && 'register' in value && typeof value.register === 'function' && 'connect' in value && typeof value.connect === 'function' && 'readJson' in value && - typeof value.readJson === 'function' + typeof value.readJson === 'function' && + 'editCacheEntityJson' in value && + typeof value.editCacheEntityJson === 'function' ) } function createPageBridge(): DevtoolsPageBridge { const instances = new Map>() const sessions = new Map() + const startupCaptures = new Map() let nextInstanceId = 1 let nextSessionId = 1 - const closeSession = (sessionId: string) => { + const closeSession = (sessionId: string): boolean => { const session = sessions.get(sessionId) - if (!session) return + if (!session) return false if (session.expires) clearTimeout(session.expires) session.unsubscribe() sessions.delete(sessionId) + return true + } + + const closeStartupCapture = (instanceId: number) => { + const capture = startupCaptures.get(instanceId) + if (!capture) return + clearTimeout(capture.expires) + capture.unsubscribe() + startupCaptures.delete(instanceId) } const refreshExpiry = (sessionId: string, session: DevtoolsBridgeSession) => { @@ -130,21 +178,35 @@ function createPageBridge(): DevtoolsPageBridge { } return { - protocol: 2, + protocol: 3, register(source) { - instances.set(nextInstanceId++, new WeakRef(source)) + const instanceId = nextInstanceId++ + instances.set(instanceId, new WeakRef(source)) + const captureUntil = startupCaptureUntil() + if (captureUntil <= Date.now()) return + const events = new CappedBuffer(EVENT_LIMIT) + const unsubscribe = source.events.subscribe(event => events.push(event)) + const expires = setTimeout( + () => closeStartupCapture(instanceId), + Math.max(0, captureUntil - Date.now()), + ) + startupCaptures.set(instanceId, { events, expires, unsubscribe }) }, connect(instanceId) { - removeCollectedInstances(instances) + refreshStartupCaptureMarker() + removeCollectedInstances(instances, closeStartupCapture) const instance = resolveInstance(instances, instanceId) if (!instance) return null const [resolvedId, source] = instance const sessionId = `${Date.now().toString(36)}-${nextSessionId++}` const session: DevtoolsBridgeSession = { + cacheDirty: true, events: new CappedBuffer(EVENT_LIMIT), expires: null, + queriesDirty: true, + relationalDirty: true, source, unsubscribe: () => {}, version: 1, @@ -152,45 +214,98 @@ function createPageBridge(): DevtoolsPageBridge { const unsubscribers = [ source.events.subscribe(event => { session.events.push(event) + if (event.kind === 'cache:updated') session.cacheDirty = true + session.version++ + }), + source.subscribeToStateChanges(() => { + session.queriesDirty = true + session.relationalDirty = true session.version++ }), - source.mutating.subscribe(() => session.version++), - source.subscribeToStateChanges(() => session.version++), ] session.unsubscribe = () => { for (const unsubscribe of unsubscribers) unsubscribe() } + const startupCapture = startupCaptures.get(resolvedId) + if (startupCapture) { + for (const event of startupCapture.events.toArray()) session.events.push(event) + closeStartupCapture(resolvedId) + } sessions.set(sessionId, session) refreshExpiry(sessionId, session) return { instanceCount: instances.size, instanceId: resolvedId, - protocol: 2, + protocol: 3, sessionId, } }, disconnect(sessionId) { - closeSession(sessionId) + const closed = closeSession(sessionId) + if (closed && sessions.size === 0) { + clearStartupCaptureMarker() + for (const instanceId of startupCaptures.keys()) closeStartupCapture(instanceId) + } + }, + + editCacheEntityJson(sessionId, serviceName, itemIdJson, itemJson) { + const session = sessions.get(sessionId) + if (!session) return '{"ok":false,"error":"Devtools session expired"}' + refreshExpiry(sessionId, session) + try { + const itemId = JSON.parse(itemIdJson) as unknown + if (typeof itemId !== 'string' && typeof itemId !== 'number') { + return '{"ok":false,"error":"Entity ID must be a string or number"}' + } + const result = session.source.editCacheEntity(serviceName, itemId, JSON.parse(itemJson)) + session.cacheDirty = true + session.queriesDirty = true + session.relationalDirty = true + session.version++ + return JSON.stringify(result) + } catch (error) { + return JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } }, readJson(sessionId, version) { const session = sessions.get(sessionId) if (!session) return null + refreshStartupCaptureMarker() refreshExpiry(sessionId, session) if (version === session.version) { return `{"protocol":2,"version":${session.version},"read":null}` } - return serializeWireEnvelope({ - protocol: 2, + const pendingEvents = session.events.toArray() + const frameBudget: PayloadBudget = { + nodes: FRAME_MAX_NODES, + stringCharacters: FRAME_MAX_STRING_CHARACTERS, + } + const serialized = serializeWireEnvelope({ + protocol: 3, version: session.version, read: { - events: session.events.drain().map(toWireEvent), - inFlightMutations: session.source.mutating.getSnapshot(), - queries: session.source.inspect(), - relational: session.source.inspectRelational(), + events: pendingEvents.map(event => toWireEvent(event, frameBudget)), + ...(session.cacheDirty + ? { cache: sanitizeCache(session.source.inspectCache(), frameBudget) } + : {}), + ...(session.queriesDirty + ? { queries: sanitizeQueries(session.source.inspect(), frameBudget) } + : {}), + ...(session.relationalDirty + ? { relational: sanitizeRelational(session.source.inspectRelational(), frameBudget) } + : {}), }, }) + session.events.clear() + session.cacheDirty = false + session.queriesDirty = false + session.relationalDirty = false + return serialized }, } } @@ -211,26 +326,338 @@ function resolveInstance( return null } -function removeCollectedInstances(instances: Map>): void { +function removeCollectedInstances( + instances: Map>, + onRemove: (instanceId: number) => void, +): void { for (const [id, reference] of instances) { - if (!reference.deref()) instances.delete(id) + if (reference.deref()) continue + instances.delete(id) + onRemove(id) + } +} + +function startupCaptureUntil(): number { + if (cachedStartupCaptureUntil > Date.now()) return cachedStartupCaptureUntil + try { + if (typeof sessionStorage === 'undefined') return 0 + const value = Number(sessionStorage.getItem(STARTUP_CAPTURE_KEY)) + cachedStartupCaptureUntil = Number.isFinite(value) ? value : 0 + return cachedStartupCaptureUntil + } catch { + return 0 } } -function toWireEvent(event: FigbirdEvent): DevtoolsWireEvent { +function refreshStartupCaptureMarker(): void { + const currentTime = Date.now() + if (cachedStartupCaptureUntil - currentTime > STARTUP_CAPTURE_TTL_MS / 2) return + try { + if (typeof sessionStorage === 'undefined') return + cachedStartupCaptureUntil = currentTime + STARTUP_CAPTURE_TTL_MS + sessionStorage.setItem(STARTUP_CAPTURE_KEY, String(cachedStartupCaptureUntil)) + } catch { + // Storage can be unavailable in sandboxed frames. Live collection still works. + } +} + +function clearStartupCaptureMarker(): void { + cachedStartupCaptureUntil = 0 + try { + if (typeof sessionStorage === 'undefined') return + sessionStorage.removeItem(STARTUP_CAPTURE_KEY) + } catch { + // Storage can be unavailable in sandboxed frames. + } +} + +function toWireEvent(event: FigbirdEvent, frameBudget?: PayloadBudget): DevtoolsWireEvent { switch (event.kind) { + case 'fetch:start': + return event.params === undefined + ? event + : { ...event, params: sanitizeDevtoolsPayload(event.params, frameBudget) } + case 'realtime': + return event.item === undefined + ? event + : { ...event, item: sanitizeDevtoolsPayload(event.item, frameBudget) } + case 'cache:updated': + return { + ...event, + item: sanitizeDevtoolsPayload(event.item, frameBudget), + previousItem: + event.previousItem === null + ? null + : sanitizeDevtoolsPayload(event.previousItem, frameBudget), + } + case 'mutate:start': + case 'mutate:update': + case 'action:start': + return event.args === undefined + ? event + : { ...event, args: sanitizeDevtoolsArgs(event.args, frameBudget) } case 'fetch:error': case 'mutate:error': case 'action:error': + case 'connection:error': return { ...event, - error: { message: event.error.message, name: event.error.name }, + error: { + message: event.error.message, + name: event.error.name, + details: sanitizeDevtoolsPayload(errorDetails(event.error), frameBudget), + }, } + case 'connection:reconnect-failed': + return event.error + ? { + ...event, + error: { + message: event.error.message, + name: event.error.name, + details: sanitizeDevtoolsPayload(errorDetails(event.error), frameBudget), + }, + } + : event default: return event } } +function sanitizeQueries( + queries: readonly InspectedQuery[], + frameBudget: PayloadBudget, +): DevtoolsWireQuery[] { + return queries.map(query => ({ + ...query, + query: sanitizeRecord(query.query ?? {}, frameBudget), + ...(query.data === undefined ? {} : { data: sanitizeDevtoolsPayload(query.data, frameBudget) }), + })) +} + +function sanitizeCache( + cache: readonly InspectedCacheService[], + frameBudget: PayloadBudget, +): InspectedCacheService[] { + return cache.map(service => ({ + ...service, + entities: service.entities.map(entity => ({ + ...entity, + value: sanitizeDevtoolsPayload(entity.value, frameBudget), + })), + })) +} + +function sanitizeRelational( + relational: readonly InspectedRelationalQuery[], + frameBudget: PayloadBudget, +): InspectedRelationalQuery[] { + return relational.map(query => ({ + ...query, + ...(query.data === undefined ? {} : { data: sanitizeDevtoolsPayload(query.data, frameBudget) }), + })) +} + +function sanitizeRecord( + value: Record, + frameBudget: PayloadBudget, +): Record { + const sanitized = sanitizeDevtoolsPayload(value, frameBudget) + return typeof sanitized === 'object' && sanitized !== null && !Array.isArray(sanitized) + ? (sanitized as Record) + : {} +} + +interface PayloadBudget { + nodes: number + stringCharacters: number +} + +function sanitizeDevtoolsArgs( + args: readonly unknown[], + frameBudget?: PayloadBudget, +): readonly unknown[] { + const sanitized = sanitizeDevtoolsPayload(args, frameBudget) + return Array.isArray(sanitized) ? sanitized : ['[Payload truncated]'] +} + +function sanitizeDevtoolsPayload(value: unknown, frameBudget?: PayloadBudget): unknown { + const budget = { + nodes: Math.min(PAYLOAD_MAX_NODES, frameBudget?.nodes ?? PAYLOAD_MAX_NODES), + stringCharacters: Math.min( + PAYLOAD_MAX_STRING_CHARACTERS, + frameBudget?.stringCharacters ?? PAYLOAD_MAX_STRING_CHARACTERS, + ), + } + const initialNodes = budget.nodes + const initialStringCharacters = budget.stringCharacters + const sanitized = sanitizePayloadValue(value, 0, new Set(), budget) + if (frameBudget) { + frameBudget.nodes = Math.max(0, frameBudget.nodes - (initialNodes - budget.nodes)) + frameBudget.stringCharacters = Math.max( + 0, + frameBudget.stringCharacters - (initialStringCharacters - budget.stringCharacters), + ) + } + return sanitized +} + +function sanitizePayloadValue( + value: unknown, + depth: number, + ancestors: Set, + budget: PayloadBudget, +): unknown { + if (typeof value === 'string') return boundedString(value, budget) + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'undefined' + ) { + return value + } + if (typeof value === 'bigint') return String(value) + if (typeof value === 'symbol') + return value.description ? `[Symbol ${value.description}]` : '[Symbol]' + if (typeof value === 'function') return value.name ? `[Function ${value.name}]` : '[Function]' + if (depth >= PAYLOAD_MAX_DEPTH) return '[Max depth]' + if (budget.nodes-- <= 0) return '[Payload truncated]' + + const object = value as object + const hostDescription = describeHostObject(object) + if (hostDescription) return hostDescription + if (object instanceof Error) { + return { + message: boundedString(object.message, budget), + name: boundedString(object.name, budget), + } + } + if (object instanceof Date) + return Number.isNaN(object.valueOf()) ? '[Invalid Date]' : object.toISOString() + if (object instanceof RegExp) return String(object) + if (ancestors.has(object)) return '[Circular]' + + ancestors.add(object) + try { + if (Array.isArray(object)) { + const itemCount = Math.min(object.length, PAYLOAD_MAX_ARRAY_ITEMS) + const result: unknown[] = [] + for (let index = 0; index < itemCount; index++) { + result.push(sanitizePayloadValue(object[index], depth + 1, ancestors, budget)) + } + if (object.length > itemCount) result.push(`[${object.length - itemCount} more items]`) + return result + } + + const keys = safeEnumerableKeys(object) + const propertyCount = Math.min(keys.length, PAYLOAD_MAX_OBJECT_PROPERTIES) + const result: Record = {} + for (let index = 0; index < propertyCount; index++) { + const key = keys[index]! + result[key] = sanitizePayloadValue( + safePropertyValue(object, key), + depth + 1, + ancestors, + budget, + ) + } + if (keys.length > propertyCount) { + result['[truncated]'] = `${keys.length - propertyCount} more properties` + } + return result + } finally { + ancestors.delete(object) + } +} + +function boundedString(value: string, budget: PayloadBudget): string { + const retained = Math.min(value.length, Math.max(0, budget.stringCharacters)) + budget.stringCharacters -= retained + if (retained === value.length) return value + return `${value.slice(0, retained)}… [${value.length - retained} characters omitted]` +} + +function describeHostObject(value: object): string | null { + const syntheticType = ownDataProperty(value, 'type') + if (typeof syntheticType === 'string' && ownDataProperty(value, 'nativeEvent') !== undefined) { + return `[SyntheticEvent ${syntheticType}]` + } + + let tag: string + try { + tag = Object.prototype.toString.call(value).slice(8, -1) + } catch { + return '[Uninspectable object]' + } + try { + if (typeof Node !== 'undefined' && value instanceof Node) return `[${tag}]` + if (typeof Event !== 'undefined' && value instanceof Event) { + const type = safePropertyValue(value, 'type') + return typeof type === 'string' ? `[${tag} ${type}]` : `[${tag}]` + } + } catch { + return '[Uninspectable host object]' + } + if (tag === 'Window' || tag === 'Document' || tag === 'Node' || tag === 'Text') { + return `[${tag}]` + } + if (tag.endsWith('Element')) return `[${tag}]` + if (tag.endsWith('Event')) { + const type = safePropertyValue(value, 'type') + return typeof type === 'string' ? `[${tag} ${type}]` : `[${tag}]` + } + if ( + tag === 'ArrayBuffer' || + tag === 'SharedArrayBuffer' || + (tag !== 'Array' && tag.endsWith('Array')) + ) { + const byteLength = safePropertyValue(value, 'byteLength') + return typeof byteLength === 'number' ? `[${tag} ${byteLength} bytes]` : `[${tag}]` + } + if (tag === 'Blob' || tag === 'File') { + const size = safePropertyValue(value, 'size') + const name = tag === 'File' ? safePropertyValue(value, 'name') : undefined + return `[${tag}${typeof name === 'string' ? ` ${name}` : ''}${typeof size === 'number' ? ` ${size} bytes` : ''}]` + } + if ( + tag === 'Map' || + tag === 'Set' || + tag === 'WeakMap' || + tag === 'WeakSet' || + tag === 'Promise' + ) { + const size = safePropertyValue(value, 'size') + return `[${tag}${typeof size === 'number' ? `(${size})` : ''}]` + } + return null +} + +function safeEnumerableKeys(value: object): string[] { + try { + return Object.keys(value) + } catch { + return [] + } +} + +function safePropertyValue(value: object, key: string): unknown { + try { + return (value as Record)[key] + } catch (error) { + return `[Property threw: ${error instanceof Error ? error.message : String(error)}]` + } +} + +function ownDataProperty(value: object, key: string): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + return descriptor && 'value' in descriptor ? descriptor.value : undefined + } catch { + return undefined + } +} + function serializeWireEnvelope(envelope: DevtoolsWireEnvelope): string { const ancestors: object[] = [] const serialized = JSON.stringify(envelope, function (_key, value: unknown): unknown { diff --git a/lib/core/errors.ts b/lib/core/errors.ts index 89181c96..2d1ba38f 100644 --- a/lib/core/errors.ts +++ b/lib/core/errors.ts @@ -1,5 +1,98 @@ type ItemId = string | number +export interface ErrorDetails { + name: string + message: string + [property: string]: unknown +} + +/** Preserve structured server failures while keeping the public error contract. */ +export function normalizeError(value: unknown): Error { + if (value instanceof Error) return value + + const details = errorDetails(value) + const error = new Error(details.message) + error.name = details.name + applyErrorDetails(error, details) + return error +} + +/** Convert an Error (including Feathers custom fields) into inspectable data. */ +export function errorDetails(value: unknown): ErrorDetails { + if (value instanceof Error) { + const details: ErrorDetails = { + name: value.name || 'Error', + message: value.message || String(value), + } + for (const key of errorPropertyNames(value)) { + if (key === 'name' || key === 'message' || key === 'stack') continue + details[key] = safeErrorProperty(value, key) + } + return details + } + + if (isErrorRecord(value)) { + const name = typeof value.name === 'string' ? value.name : 'Error' + const message = + typeof value.message === 'string' && value.message + ? value.message + : typeof value.error === 'string' && value.error + ? value.error + : 'Request failed' + return { ...value, name, message } + } + + if (typeof value === 'string') return { name: 'Error', message: value } + return { name: 'Error', message: 'Request failed', response: value } +} + +/** Rebuild an Error after structured details have crossed the extension boundary. */ +export function errorFromDetails( + details: unknown, + fallback: { name: string; message: string }, +): Error { + const normalized = isErrorRecord(details) + ? errorDetails(details) + : { name: fallback.name, message: fallback.message } + const error = new Error(normalized.message) + error.name = normalized.name + applyErrorDetails(error, normalized) + return error +} + +function applyErrorDetails(error: Error, details: ErrorDetails): void { + const target = error as Error & Record + for (const [key, value] of Object.entries(details)) { + if (key === 'name' || key === 'message' || key === 'stack') continue + try { + target[key] = value + } catch { + // A custom Error subclass may expose a read-only property. Its core + // name/message still survive even when that one field cannot be restored. + } + } +} + +function errorPropertyNames(error: Error): string[] { + try { + return [...new Set([...Object.getOwnPropertyNames(error), ...Object.keys(error)])] + } catch { + return [] + } +} + +function safeErrorProperty(error: Error, key: string): unknown { + try { + return (error as Error & Record)[key] + } catch (propertyError) { + return `[Property threw: ${propertyError instanceof Error ? propertyError.message : String(propertyError)}]` + } +} + +function isErrorRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + /** A fetched item was removed by realtime while a consumer was viewing it. */ export class ItemRemovedError extends Error { readonly itemId: ItemId diff --git a/lib/core/events.ts b/lib/core/events.ts index 0055160b..b25fd668 100644 --- a/lib/core/events.ts +++ b/lib/core/events.ts @@ -1,4 +1,5 @@ -import type { EventType } from './queryTypes.js' +import type { EventType, QueryGraphRef, TraceCause } from './queryTypes.js' +export type { QueryGraphRef, TraceCause } from './queryTypes.js' /** * Mutation method names (subset usable in observability events). @@ -13,21 +14,34 @@ export type MutationMethod = 'create' | 'update' | 'patch' | 'remove' // oxlint-disable-next-line @typescript-eslint/no-empty-object-type export type MutationEventMethod = MutationMethod | (string & {}) +export type FetchReason = 'subscription' | 'manual' | 'reconcile' | 'retry' | 'follow-up' + +export interface CacheQueryEffect { + queryId: string + outcome: 'merged' | 'reconcile' +} + /** * Observability events emitted by a Figbird instance — the same signal a dev tool * panel or trace logger would want to subscribe to. * - * Events are intentionally lightweight. Mutation and action starts carry their - * original arguments so an attached devtool can inspect the write; no result or - * cache diffs are emitted, and emit() drops everything when nothing is listening. + * Events are intentionally bounded by consumers. Realtime events and mutation/action + * starts carry their original payloads so an attached devtool can inspect what + * happened. Cache transitions may include before/after values for attached devtools, + * and emit() drops everything when nothing is listening. */ -export type FigbirdEvent = +export type FigbirdEvent = ( | { kind: 'fetch:start' serviceName: string method: 'find' | 'get' queryId: string generation: number + fetchId?: number + reason?: FetchReason + attempt?: number + causes?: readonly TraceCause[] + graph?: readonly QueryGraphRef[] resourceId?: string | number params?: unknown } @@ -37,8 +51,10 @@ export type FigbirdEvent = method: 'find' | 'get' queryId: string generation: number + fetchId?: number durationMs: number itemCount: number + graph?: readonly QueryGraphRef[] } | { kind: 'fetch:error' @@ -46,24 +62,81 @@ export type FigbirdEvent = method: 'find' | 'get' queryId: string generation: number + fetchId?: number durationMs: number error: Error + graph?: readonly QueryGraphRef[] } | { kind: 'reconcile:started' queryId: string serviceName: string + causes?: readonly TraceCause[] + } + | { + kind: 'reconcile:decision' + queryId: string + serviceName: string + decision: 'fetch-now' | 'coalesced' | 'deferred-hidden' | 'inactive' + causes?: readonly TraceCause[] + } + | { + kind: 'reconnect:sweep' + traceId?: number + phase: 'scheduled' | 'started' + delayMs: number + queryCount?: number } | { kind: 'realtime' + traceId?: number serviceName: string type: EventType itemId: string | number | undefined + item?: unknown + } + | { + kind: 'cache:updated' + traceId?: number + source: 'realtime' | 'mutation' | 'fetch' | 'optimistic' | 'devtools' + serviceName: string + type: EventType + itemId: string | number + item: unknown + previousItem: unknown | null + queryEffects: readonly CacheQueryEffect[] } + | { + kind: 'connection:connected' + traceId?: number + transport?: string + connectionId?: string + } + | { + kind: 'connection:disconnected' + traceId?: number + reason?: string + reconnecting: boolean + } + | { + kind: 'connection:reconnected' + traceId?: number + attempt?: number + transport?: string + connectionId?: string + } + | { + kind: 'connection:error' + traceId?: number + phase: 'connect' | 'reconnect' + error: Error + } + | { kind: 'connection:reconnect-failed'; traceId?: number; error?: Error } | { kind: 'mutate:start' /** Correlates the start/end/error/rollback events of one mutation. */ mutationId: number + traceId?: number serviceName: string method: MutationEventMethod id?: string | number @@ -74,6 +147,7 @@ export type FigbirdEvent = /** An unsent queued mutation was coalesced with newer arguments. */ kind: 'mutate:update' mutationId: number + traceId?: number serviceName: string method: MutationEventMethod id?: string | number @@ -83,6 +157,7 @@ export type FigbirdEvent = | { kind: 'mutate:end' mutationId: number + traceId?: number serviceName: string method: MutationEventMethod durationMs: number @@ -92,6 +167,7 @@ export type FigbirdEvent = | { kind: 'mutate:error' mutationId: number + traceId?: number serviceName: string method: MutationEventMethod durationMs: number @@ -102,6 +178,7 @@ export type FigbirdEvent = | { kind: 'mutate:rollback' mutationId: number + traceId?: number serviceName: string method: MutationEventMethod id?: string | number @@ -128,6 +205,10 @@ export type FigbirdEvent = /** The captured failure — also available on the hook's `error` slot. */ error: Error } +) & { + /** Epoch timestamp captured at the event source, before deferred or bridged delivery. */ + timestamp?: number +} /** * Public surface for subscribing to Figbird's observability events. @@ -144,6 +225,10 @@ export class FigbirdEventEmitter implements FigbirdEvents { }> = [] #flushScheduled = false + get hasListeners(): boolean { + return this.#listeners.size > 0 + } + /** * Emission is deferred to a microtask (batched, order-preserving). Some emits * happen synchronously inside a React render (subscribing to a query can start a @@ -155,7 +240,8 @@ export class FigbirdEventEmitter implements FigbirdEvents { */ emit(event: FigbirdEvent): void { if (this.#listeners.size === 0) return - this.#queue.push({ event, recipients: [...this.#listeners] }) + const timedEvent = event.timestamp === undefined ? { ...event, timestamp: Date.now() } : event + this.#queue.push({ event: timedEvent, recipients: [...this.#listeners] }) if (this.#flushScheduled) return this.#flushScheduled = true queueMicrotask(() => { diff --git a/lib/core/fetchRebase.ts b/lib/core/fetchRebase.ts index 693f40f1..60adb9e3 100644 --- a/lib/core/fetchRebase.ts +++ b/lib/core/fetchRebase.ts @@ -1,9 +1,4 @@ -import { - entityKey, - type EntityKey, - type ItemId, - type ProcessedRealtimeEvent, -} from './queryTypes.js' +import { entityKey, type EntityKey, type ItemId, type ProcessedCacheEvent } from './queryTypes.js' /** Maximum retained events per service while one or more fetches are in flight. */ export const MAX_FETCH_JOURNAL_EVENTS = 1024 @@ -14,7 +9,7 @@ interface ActiveFetch { } interface ServiceJournal { - events: ProcessedRealtimeEvent[] + events: ProcessedCacheEvent[] activeFetches: Map } @@ -24,7 +19,7 @@ export interface FetchJournalCursor { } export interface FetchJournalSnapshot { - readonly events: readonly ProcessedRealtimeEvent[] + readonly events: readonly ProcessedCacheEvent[] readonly overflowed: boolean } @@ -88,7 +83,7 @@ export class FetchEventJournal { } } - record(events: readonly ProcessedRealtimeEvent[]): void { + record(events: readonly ProcessedCacheEvent[]): void { for (const event of events) { const journal = this.#services.get(event.serviceName) if (!journal) continue @@ -126,8 +121,8 @@ export class FetchEventJournal { } export interface FetchRebasePlan { - readonly events: readonly ProcessedRealtimeEvent[] - readonly latestEventById: ReadonlyMap + readonly events: readonly ProcessedCacheEvent[] + readonly latestEventById: ReadonlyMap readonly itemIds: ReadonlySet } @@ -139,7 +134,7 @@ export function planFetchRebase({ isItemStale, }: { responseItems: readonly unknown[] - journalEvents: readonly ProcessedRealtimeEvent[] + journalEvents: readonly ProcessedCacheEvent[] getId: (item: unknown) => ItemId | undefined isItemStale: (current: unknown, next: unknown) => boolean }): FetchRebasePlan { @@ -149,7 +144,7 @@ export function planFetchRebase({ if (itemId !== undefined) responseItemsById.set(entityKey(itemId), item) } - const latestJournalEventById = new Map() + const latestJournalEventById = new Map() for (const event of journalEvents) { latestJournalEventById.set(event.itemId, event) } @@ -166,7 +161,7 @@ export function planFetchRebase({ } const events = journalEvents.filter(event => !supersededItemIds.has(event.itemId)) - const latestEventById = new Map() + const latestEventById = new Map() for (const event of events) { latestEventById.set(event.itemId, event) } @@ -184,7 +179,7 @@ export type FetchResponseMode = 'entity' | 'projection' | 'snapshot' function overlayProjectionItem( responseItem: unknown, - event: ProcessedRealtimeEvent, + event: ProcessedCacheEvent, ): unknown | undefined { if (event.type === 'removed') return undefined if ( @@ -226,7 +221,7 @@ export function rebaseResponseData({ }: { data: unknown mode: FetchResponseMode - latestEventById: ReadonlyMap + latestEventById: ReadonlyMap entities: ReadonlyMap getId: (item: unknown) => ItemId | undefined isItemStale: (current: unknown, next: unknown) => boolean diff --git a/lib/core/figbird.ts b/lib/core/figbird.ts index d595f7e2..907f499e 100644 --- a/lib/core/figbird.ts +++ b/lib/core/figbird.ts @@ -29,17 +29,31 @@ import { type QueryBuilderResult, } from './queryBuilder.js' import { resolveQueryInput, type PreparedQuery, type QueryInput } from './queryDefinition.js' -import { explainQuery, type ExplainReport, type QueryNodeClass } from './queryClassification.js' +import { + explainQuery, + explainQueryNode, + isServerMaintained, + type ClassificationReason, + type ExplainReport, + type QueryNodeClass, +} from './queryClassification.js' export type { ExplainNode, ExplainReport } from './queryClassification.js' import { QueryRef } from './queryRef.js' import { QueryStore, + type DevtoolsCacheEditResult, + type QueryFetchHistoryEntry, type ReconnectJitter, type RetryDelay, type VisibilitySource, } from './queryStore.js' -export type { ReconnectJitter, RetryDelay, VisibilitySource } from './queryStore.js' +export type { + QueryFetchHistoryEntry, + ReconnectJitter, + RetryDelay, + VisibilitySource, +} from './queryStore.js' import { normalizeQueryConfig, queryOfParams, @@ -287,10 +301,10 @@ export class Figbird< } /** - * Returns the entire internal state map keyed by service name — including the - * cached entities themselves, which `inspect()` deliberately omits. Debug-grade: - * internal shapes may change between versions; prefer `inspect()` for anything - * built to last. + * Returns the entire internal state map keyed by service name — including cached + * entities that are not part of a current query result. Debug-grade: internal + * shapes may change between versions; prefer `inspect()` for anything built to + * last. */ getState(): Map>> { return this.queryStore.getState() @@ -507,7 +521,7 @@ export class Figbird< // While pinned, subsequent useQuery subscribers join the same ref. When everyone has // released and unsubscribed, RelationalQueryRef cleans up and evicts the cache entry. // A staleTime skips the SWR revalidation when the data is already fresh enough. - const unsub = ref.subscribe(() => {}, options ?? {}) + const unsub = ref.subscribe(() => {}, { ...options, source: 'prepare' }) return { key: ref.hash(), promise: ref.suspensePromise(), @@ -558,7 +572,7 @@ export class Figbird< // The pin also carries the staleTime so a warm-in-store read within the window // skips the SWR revalidation instead of re-fetching. - const release = ref.subscribe(() => {}, { staleTime }) + const release = ref.subscribe(() => {}, { staleTime, source: 'prefetch' }) const timer = setTimeout(() => { this.#prefetches.delete(hash) release() @@ -927,6 +941,15 @@ export class Figbird< const stats = this.queryStore.getQueryStats(query.queryId) const generation = this.queryStore.getQueryGeneration(query.queryId) if (generation === undefined) continue + const explanation = + query.desc.method === 'find' + ? explainQueryNode(q, { + server: query.config.server, + allPages: 'allPages' in query.config && query.config.allPages === true, + localOperators: locallySupportedOperators(this.adapter, serviceName), + snapshot: query.config.realtime === 'disabled', + }) + : null rows.push({ queryId: query.queryId, generation, @@ -943,8 +966,17 @@ export class Figbird< } : {}), classification: query.classification, + classificationReasons: explanation?.reasons ?? [], + realtimeStrategy: + query.config.realtime === 'disabled' + ? 'manual' + : query.config.realtime === 'refetch' || isServerMaintained(query.classification) + ? 'refetch' + : 'merge', + skipped: query.config.skip === true, status: query.state.status, isFetching: query.state.isFetching, + data: query.state.data, itemCount: Array.isArray(query.state.data) ? query.state.data.length : query.state.data @@ -956,12 +988,35 @@ export class Figbird< errorCount: stats?.errorCount ?? 0, ...(stats?.lastDurationMs !== undefined ? { lastDurationMs: stats.lastDurationMs } : {}), totalDurationMs: stats?.totalDurationMs ?? 0, + fetchHistory: stats?.history ?? [], }) } } return rows } + /** Read-only normalized entity-cache projection for attached devtools. */ + inspectCache(): InspectedCacheService[] { + return [...this.queryStore.getState()].map(([serviceName, service]) => ({ + serviceName, + ...(service.materialized ? { materialized: service.materialized } : {}), + entities: [...service.entities].map(([id, value]) => ({ + id, + value, + queryIds: [...(service.itemQueryIndex.get(id) ?? [])], + })), + })) + } + + /** @internal Browser-devtools command; changes only the in-memory cache. */ + editCacheEntity( + serviceName: string, + itemId: string | number, + item: unknown, + ): DevtoolsCacheEditResult { + return this.queryStore.editCacheEntity(serviceName, itemId, item) + } + /** * Read-only grouping of active relational query refs and the store-level queries * each one currently owns. Entries exist while the interned ref is alive. @@ -990,8 +1045,14 @@ export interface InspectedQuery { /** Native adapter page details. Offset pages remain visible in `query` as `$skip`/`$limit`. */ page?: { request: PageRequest; info?: PageInfo } classification: QueryNodeClass | 'get' + classificationReasons?: ClassificationReason[] + realtimeStrategy?: 'merge' | 'refetch' | 'manual' + /** True when this entry was materialized with `skip: true`. */ + skipped?: boolean status: 'loading' | 'success' | 'error' isFetching: boolean + /** Current result for this query. Debug-only and safe to inspect, not mutate. */ + data?: unknown itemCount: number fetchedAt: number | undefined subscriberCount: number @@ -999,4 +1060,18 @@ export interface InspectedQuery { errorCount: number lastDurationMs?: number totalDurationMs: number + /** Bounded, payload-free latency history used by attached developer tools. */ + fetchHistory?: readonly QueryFetchHistoryEntry[] +} + +export interface InspectedCacheEntity { + id: string + value: unknown + queryIds: string[] +} + +export interface InspectedCacheService { + serviceName: string + materialized?: { queryId: string; fetchedAt: number } + entities: InspectedCacheEntity[] } diff --git a/lib/core/mutationLanes.ts b/lib/core/mutationLanes.ts index 8e385a33..992a8203 100644 --- a/lib/core/mutationLanes.ts +++ b/lib/core/mutationLanes.ts @@ -4,7 +4,8 @@ import { type ItemId, type MutationDescriptor, type ProcessedProjectionEvent, - type ProcessedRealtimeEvent, + type ProcessedServerEvent, + type TraceCause, } from './queryTypes.js' export const ABSENT = Symbol('figbird.absent') @@ -13,6 +14,7 @@ export type ProjectedEntity = unknown | typeof ABSENT export interface MutationLaneEntry { desc: MutationDescriptor optimistic: boolean + cause?: TraceCause } /** Opaque identity for a lane; mutable lane state stays inside MutationLanes. */ @@ -40,7 +42,7 @@ export interface ProjectionChange { export interface AuthoritativeTransition { projection: ProjectionChange - event: ProcessedRealtimeEvent + event: ProcessedServerEvent } export type MutationOutcome = { ok: true; item: unknown } | { ok: false; error: Error } @@ -48,7 +50,7 @@ export type MutationOutcome = { ok: true; item: unknown } | { ok: false; error: export interface LaneSettlement { projection: ProjectionChange cancelled: TEntry[] - authoritativeEvent: ProcessedRealtimeEvent | null + authoritativeEvent: ProcessedServerEvent | null } export interface ReleasedLaneEffects { @@ -160,7 +162,7 @@ export class MutationLanes { const end = nextCreate === -1 ? state.entries.length : nextCreate cancelled = state.entries.splice(0, end) } - let authoritativeEvent: ProcessedRealtimeEvent | null = null + let authoritativeEvent: ProcessedServerEvent | null = null if (outcome.ok) { const type = MUTATION_EVENT_TYPE[entry.desc.method] @@ -170,12 +172,14 @@ export class MutationLanes { this.#setBase(state, type, outcome.item) if (eventItem !== null && eventItem !== undefined) { authoritativeEvent = { - origin: 'authoritative', + mode: 'server', + source: 'mutation', serviceName: state.serviceName, type, item: eventItem, previousItem, itemId: entityKey(state.id), + ...(entry.cause === undefined ? {} : { cause: entry.cause }), } } } @@ -215,7 +219,8 @@ export class MutationLanes { return { projection: this.#reproject(state), event: { - origin: 'authoritative', + mode: 'server', + source: 'realtime', serviceName: state.serviceName, type, item, @@ -268,26 +273,29 @@ export class MutationLanes { if (lane.serviceName !== serviceName || !lane.entries.some(entry => entry.optimistic)) continue const previousItem = lane.base === ABSENT ? null : lane.base + const cause = [...lane.entries].reverse().find(entry => entry.optimistic)?.cause if (lane.visible === ABSENT) { if (!lane.lastPresent) continue events.push({ - origin: 'projection', + mode: 'optimistic', serviceName, type: 'removed', item: lane.lastPresent, previousItem, itemId: entityKey(lane.id), mutationLaneKey: lane.key, + ...(cause === undefined ? {} : { cause }), }) } else { events.push({ - origin: 'projection', + mode: 'optimistic', serviceName, type: lane.base === ABSENT ? 'created' : 'patched', item: lane.visible, previousItem, itemId: entityKey(lane.id), mutationLaneKey: lane.key, + ...(cause === undefined ? {} : { cause }), }) } } diff --git a/lib/core/queryRef.ts b/lib/core/queryRef.ts index 060ec9f1..c23359d7 100644 --- a/lib/core/queryRef.ts +++ b/lib/core/queryRef.ts @@ -2,9 +2,10 @@ import { createQueryId } from './queryIdentity.js' import type { AnySchema, Schema } from './schema.js' import type { QueryStore } from './queryStore.js' import type { - ProcessedRealtimeEvent, + ProcessedCacheEvent, QueryConfig, QueryDescriptor, + QueryExecutionOptions, QueryState, } from './queryTypes.js' @@ -64,7 +65,7 @@ export class QueryRef< */ subscribe( fn: (state: QueryState) => void, - options?: { staleTime?: number | undefined }, + options?: QueryExecutionOptions, ): () => void { this.#queryStore.materialize(this) return this.#queryStore.subscribe(this.#queryId, fn, options) @@ -74,7 +75,7 @@ export class QueryRef< * Re-run the store's subscribe-time freshness check without adding a listener. * Relational refs use this when a stricter subscriber joins an already-live tree. */ - ensureFresh(options?: { staleTime?: number | undefined }): void { + ensureFresh(options?: QueryExecutionOptions): void { this.#queryStore.materialize(this) this.#queryStore.ensureFresh(this.#queryId, options) } @@ -86,9 +87,9 @@ export class QueryRef< } /** Triggers a refetch for this query. */ - refetch(): void { + refetch(options?: Omit): void { this.#queryStore.materialize(this) - return this.#queryStore.refetch(this.#queryId) + return this.#queryStore.refetch(this.#queryId, undefined, options) } /** Route an event-driven refetch through the store's reconciliation gate. @internal */ @@ -98,7 +99,7 @@ export class QueryRef< } /** Apply a value-only update to an already-visible row. @internal */ - applyVisibleEvent(event: ProcessedRealtimeEvent): void { + applyVisibleEvent(event: ProcessedCacheEvent): void { this.#queryStore.materialize(this) this.#queryStore.applyVisibleEvent(this.#queryId, event) } diff --git a/lib/core/queryRoots.ts b/lib/core/queryRoots.ts index 5f021876..278cb581 100644 --- a/lib/core/queryRoots.ts +++ b/lib/core/queryRoots.ts @@ -1,6 +1,6 @@ import type { PageCursor, PageInfo } from '../adapters/adapter.js' import type { QueryRef } from './queryRef.js' -import type { ProcessedRealtimeEvent, QueryState } from './queryTypes.js' +import type { ProcessedCacheEvent, QueryGraphRef, QueryState } from './queryTypes.js' import type { AnySchema, Schema } from './schema.js' /** The relational engine's adapter-neutral view of its root rows. */ @@ -27,8 +27,8 @@ export interface RootSource { snapshot(): RootSnapshot metadata(): RootMetadata setStaleTime(staleTime: number): void - ensureFresh(staleTime?: number): void - refetch(): void + ensureFresh(staleTime?: number, graph?: QueryGraphRef): void + refetch(graph?: QueryGraphRef): void teardown(): void queryIds(): string[] } @@ -56,7 +56,7 @@ export interface InspectedPagination { } export interface PaginatedRootSource extends RootSource { - loadMore(): void + loadMore(graph?: QueryGraphRef): void pagination(): RelationalPaginationState inspectPagination(): InspectedPagination } @@ -84,13 +84,14 @@ export function subscribeAndSeed< onSuccess: (data: unknown[]) => void, onChange: () => void, staleTime = 0, + graph?: QueryGraphRef, ): () => void { const unsub = queryRef.subscribe( state => { if (state.status === 'success') onSuccess(state.data as unknown[]) onChange() }, - { staleTime }, + { staleTime, graph }, ) const initial = queryRef.getSnapshot() if (initial?.status === 'success') onSuccess(initial.data as unknown[]) @@ -116,12 +117,14 @@ export class SingleQueryRoot< onRows, onChange, staleTime = 0, + graph, }: { queryRef: QueryRef isGet: boolean onRows: (rows: unknown[]) => void onChange: () => void staleTime?: number + graph?: QueryGraphRef }) { this.#queryRef = queryRef this.#isGet = isGet @@ -130,6 +133,7 @@ export class SingleQueryRoot< data => onRows(this.#asRows(data)), onChange, staleTime, + graph, ) } @@ -172,14 +176,14 @@ export class SingleQueryRoot< } } - ensureFresh(staleTime?: number): void { - this.#queryRef.ensureFresh({ staleTime }) + ensureFresh(staleTime?: number, graph?: QueryGraphRef): void { + this.#queryRef.ensureFresh({ staleTime, graph }) } setStaleTime(_staleTime: number): void {} - refetch(): void { - this.#queryRef.refetch() + refetch(graph?: QueryGraphRef): void { + this.#queryRef.refetch({ graph }) } teardown(): void { @@ -253,6 +257,7 @@ export class PagedQueryRoot< cursorRealtime, realtime, staleTime = 0, + graph, }: { pageSize: number includeTotal: boolean @@ -264,11 +269,12 @@ export class PagedQueryRoot< onRows: (rows: unknown[]) => void onChange: () => void cursorRealtime?: { - subscribe(fn: (event: ProcessedRealtimeEvent) => void): () => void - canKeepPrefix(event: ProcessedRealtimeEvent): boolean + subscribe(fn: (event: ProcessedCacheEvent) => void): () => void + canKeepPrefix(event: ProcessedCacheEvent): boolean } realtime: InspectedPagination['realtime'] staleTime?: number + graph?: QueryGraphRef }) { this.#pageSize = pageSize this.#includeTotal = includeTotal @@ -278,7 +284,7 @@ export class PagedQueryRoot< this.#onRows = onRows this.#onChange = onChange this.#staleTime = staleTime - this.#setupPage(0) + this.#setupPage(0, undefined, undefined, graph) if (cursorRealtime) { this.#cursorReconnectUnsub = this.#pageRefs[0]?.registerReconnectReconciliation() ?? null this.#cursorEventUnsub = cursorRealtime.subscribe(event => { @@ -295,6 +301,7 @@ export class PagedQueryRoot< pageIndex: number, settle?: { onError: (error: Error) => void }, after?: PageCursor, + graph?: QueryGraphRef, ): void { const queryRef = this.#makePageRef(pageIndex, after) this.#pageRefs.push(queryRef) @@ -357,13 +364,13 @@ export class PagedQueryRoot< onState(state) this.#onChange() }, - { staleTime: reconcile.phase === 'running' ? 0 : this.#staleTime }, + { staleTime: reconcile.phase === 'running' ? 0 : this.#staleTime, graph }, ) this.#pageUnsubs.push(unsub) onState(queryRef.getSnapshot()) } - loadMore(): void { + loadMore(graph?: QueryGraphRef): void { if (this.#reconcile.phase !== 'idle') return if (this.#isLoadingMore || !this.#hasMoreSticky || this.#pageRefs.length === 0) return @@ -390,6 +397,7 @@ export class PagedQueryRoot< }, }, after, + graph, ) this.#onChange() } @@ -427,12 +435,12 @@ export class PagedQueryRoot< } } - ensureFresh(staleTime?: number): void { + ensureFresh(staleTime?: number, graph?: QueryGraphRef): void { if (this.#sequential) { - this.#pageRefs[0]?.ensureFresh({ staleTime }) + this.#pageRefs[0]?.ensureFresh({ staleTime, graph }) return } - for (const ref of this.#pageRefs) ref.ensureFresh({ staleTime }) + for (const ref of this.#pageRefs) ref.ensureFresh({ staleTime, graph }) } setStaleTime(staleTime: number): void { @@ -474,13 +482,13 @@ export class PagedQueryRoot< } /** Manual refetch deliberately resets the cursor chain to page zero. */ - refetch(): void { + refetch(graph?: QueryGraphRef): void { this.#reconcile = { phase: 'idle' } this.#dropFollowupPages() this.#hasMoreSticky = true this.#isLoadingMore = false this.#loadMoreError = null - this.#pageRefs[0]?.refetch() + this.#pageRefs[0]?.refetch({ graph }) this.#onChange() } diff --git a/lib/core/queryStore.ts b/lib/core/queryStore.ts index 724b7e9e..176853a3 100644 --- a/lib/core/queryStore.ts +++ b/lib/core/queryStore.ts @@ -7,7 +7,8 @@ import { import type { AnySchema, Schema } from './schema.js' import type { QueryRef } from './queryRef.js' import { isEphemeralQuery } from './queryIdentity.js' -import { FigbirdEventEmitter } from './events.js' +import { type FigbirdEventEmitter, type FetchReason, type TraceCause } from './events.js' +import { QueryTelemetry } from './queryTelemetry.js' import { MutationTracker } from './mutationTracker.js' import { GatedMutationAttempt } from './gatedMutationAttempt.js' import { @@ -62,15 +63,18 @@ import { type ItemMatcher, type MutationDescriptor, type ProcessedProjectionEvent, - type ProcessedRealtimeEvent, + type ProcessedCacheEvent, type Query, type QueryConfig, type QueryDescriptor, + type QueryExecutionOptions, + type QueryGraphRef, type QueryState, type QueuedEvent, type ServiceState, } from './queryTypes.js' import { defaultRetryDelay, resolveRetryDelay } from './retryDelay.js' +import { normalizeError } from './errors.js' /** * Where the store learns whether the tab is visible. Injectable for tests and @@ -96,6 +100,13 @@ const DEFAULT_RETRIES = 3 type StoreResponse = QueryResponse | PageResponse +type MutationTraceCause = Extract & { mutationId: number } + +interface MutationTrackingContext { + mutationId: number + cause?: MutationTraceCause +} + function resolveCreateOptimisticItem(desc: CreateMutationDescriptor): unknown { const { optimistic } = desc return optimistic == null || typeof optimistic === 'boolean' ? desc.data : optimistic @@ -110,12 +121,13 @@ interface MutationTrackingEntry { } interface MutationTrackingHooks { - onSuccess?: (result: T) => void - onError?: (error: Error, mutationId: number) => void + onSuccess?: (result: T, context: MutationTrackingContext) => void + onError?: (error: Error, context: MutationTrackingContext) => void } interface TrackedMutation { mutationId: number + cause?: MutationTraceCause promise: Promise } @@ -124,16 +136,25 @@ interface QueuedMutation { args: unknown[] optimistic: boolean attempt: GatedMutationAttempt + cause?: MutationTraceCause } interface AppliedEventEffect { - event: ProcessedRealtimeEvent + event: ProcessedCacheEvent reconcileQueryIds: Set + queryEffects?: Map + observabilityOnly?: boolean } interface PublishedEventEffects { reconcileQueryIds: Set - refetchService: boolean + reconcileCauses: Map +} + +interface FetchContext { + reason: Exclude + causes?: TraceCause[] + graph?: QueryGraphRef[] } type LaneAuthoritativeAcceptance = @@ -144,6 +165,23 @@ export interface QueryFetchStats { errorCount: number lastDurationMs?: number totalDurationMs: number + history: QueryFetchHistoryEntry[] +} + +export interface QueryFetchHistoryEntry { + fetchId: number + startedAt: number + durationMs: number + ok: boolean + reason: FetchReason +} + +export const QUERY_FETCH_HISTORY_LIMIT = 50 + +export interface DevtoolsCacheEditResult { + ok: boolean + error?: string + traceId?: number } function documentVisibility(): VisibilitySource { @@ -167,13 +205,13 @@ export class QueryStore< TQuery = Record, > { #adapter: Adapter - #events: FigbirdEventEmitter + #telemetry: QueryTelemetry #mutations: MutationTracker #realtime: Set = new Set() #listeners: Map) => void>> = new Map() #globalListeners: Set<(state: Map>) => void> = new Set() - #processedEventListeners: Set<(event: ProcessedRealtimeEvent) => void> = new Set() + #processedEventListeners: Set<(event: ProcessedCacheEvent) => void> = new Set() #projectionSettlementListeners: Set<(event: ProcessedProjectionEvent) => void> = new Set() #state: Map> = new Map() @@ -181,6 +219,7 @@ export class QueryStore< #queryGenerations: Map = new Map() #queryStats: Map = new Map() #nextQueryGeneration = 1 + #followupFetchContexts = new Map() #fetchEventJournal = new FetchEventJournal() #mutationLanes: MutationLanes @@ -191,9 +230,9 @@ export class QueryStore< #visibility: VisibilitySource #reconcileWindows: Map< string, - { lastAt: number; trailing: ReturnType | null } + { lastAt: number; trailing: ReturnType | null; causes?: TraceCause[] } > = new Map() - #deferredWhileHidden: Set = new Set() + #deferredWhileHidden: Map = new Map() #defaultSort: Record | undefined #retry: number | false @@ -251,7 +290,7 @@ export class QueryStore< this.#mutationLanes = new MutationLanes(item => this.#peekId(item)) this.#defaultSort = defaultSort this.#eventBatchInterval = eventBatchInterval - this.#events = new FigbirdEventEmitter() + this.#telemetry = new QueryTelemetry() this.#mutations = new MutationTracker() this.#reconcileCooldown = reconcileCooldown this.#retry = this.#normalizeRetry(retry) @@ -259,13 +298,64 @@ export class QueryStore< this.#reconnectJitter = this.#normalizeReconnectJitter(reconnectJitter) this.#visibility = visibility ?? documentVisibility() this.#visibility.onChange(() => this.#drainDeferredReconciles()) - this.#adapter.subscribeToReconnect?.(() => this.#scheduleReconnectSweep()) + if (this.#adapter.subscribeToConnectionEvents) { + this.#adapter.subscribeToConnectionEvents(event => { + const traceId = this.#telemetry.nextTraceId() + switch (event.type) { + case 'connected': + this.#telemetry.emit({ + kind: 'connection:connected', + ...(traceId === undefined ? {} : { traceId }), + ...(event.transport ? { transport: event.transport } : {}), + ...(event.connectionId ? { connectionId: event.connectionId } : {}), + }) + break + case 'disconnected': + this.#telemetry.emit({ + kind: 'connection:disconnected', + ...(traceId === undefined ? {} : { traceId }), + ...(event.reason ? { reason: event.reason } : {}), + reconnecting: event.reconnecting, + }) + break + case 'reconnected': + this.#telemetry.emit({ + kind: 'connection:reconnected', + ...(traceId === undefined ? {} : { traceId }), + ...(event.attempt === undefined ? {} : { attempt: event.attempt }), + ...(event.transport ? { transport: event.transport } : {}), + ...(event.connectionId ? { connectionId: event.connectionId } : {}), + }) + this.#scheduleReconnectSweep(traceId) + break + case 'error': + this.#telemetry.emit({ + kind: 'connection:error', + ...(traceId === undefined ? {} : { traceId }), + phase: event.phase, + error: event.error, + }) + break + case 'reconnect-failed': + this.#telemetry.emit({ + kind: 'connection:reconnect-failed', + ...(traceId === undefined ? {} : { traceId }), + ...(event.error ? { error: event.error } : {}), + }) + break + } + }) + } else { + this.#adapter.subscribeToReconnect?.(() => + this.#scheduleReconnectSweep(this.#telemetry.nextTraceId()), + ) + } } // Public store API /** The instance's observability event emitter — the store is its single owner. */ get events(): FigbirdEventEmitter { - return this.#events + return this.#telemetry.events } /** The instance's active mutation tracker — the store is its single owner. */ @@ -273,6 +363,11 @@ export class QueryStore< return this.#mutations } + /** Whether an observability consumer is currently attached. @internal */ + isObservabilityActive(): boolean { + return this.#telemetry.active + } + /** Returns the entire store state map keyed by service name. */ getState(): Map> { return this.#state @@ -283,6 +378,84 @@ export class QueryStore< return this.#state.get(serviceName) } + /** Apply an in-memory entity edit from an attached devtool without touching the server. */ + editCacheEntity(serviceName: string, itemId: ItemId, item: unknown): DevtoolsCacheEditResult { + const service = this.#state.get(serviceName) + const key = entityKey(itemId) + const previousItem = service?.entities.get(key) + if (!service || previousItem === undefined) { + return { ok: false, error: `Entity ${serviceName} #${String(itemId)} is not cached` } + } + const nextId = this.#peekId(item) + if (nextId === undefined || entityKey(nextId) !== key) { + return { ok: false, error: 'Edited JSON must retain the entity ID' } + } + + const cause = this.#telemetry.cause('manual') + const traceId = cause?.traceId + const queryEffects = this.#telemetry.active + ? new Map() + : undefined + const event: ProcessedCacheEvent = { + mode: 'local', + ...(cause === undefined ? {} : { cause }), + serviceName, + type: 'patched', + item, + previousItem, + itemId: key, + } + const touched = this.#transactOverServiceByName(serviceName, (current, touch) => { + current.entities.set(key, item) + const getId = this.#getIdReader(serviceName) + for (const queryId of current.queries.keys()) { + const result = reapplyQueryFromEntities({ + service: current, + queryId, + touch, + getId, + itemAdded: meta => this.#adapter.itemAdded(meta), + itemRemoved: meta => this.#adapter.itemRemoved(meta), + defaultSort: this.#defaultSort, + }) + if (result === 'applied') { + queryEffects?.set(queryId, 'merged') + continue + } + if ( + applyVisibleEventToQuery({ + service: current, + queryId, + event, + touch, + getId, + itemRemoved: meta => this.#adapter.itemRemoved(meta), + }) + ) { + queryEffects?.set(queryId, 'merged') + } + } + }) + if (touched.size > 0) this.#notify(touched) + else this.#invokeGlobalListeners() + // Let relational queries recompute filters and assembled values from the edit. + // Consumers distinguish this source from authoritative server events, so this + // remains a purely local operation and never schedules a reconciliation fetch. + this.#emitProcessedEvent(event) + this.#telemetry.emit({ + kind: 'cache:updated', + ...(traceId === undefined ? {} : { traceId }), + source: 'devtools', + serviceName, + type: 'patched', + itemId, + item, + previousItem, + queryEffects: [...(queryEffects ?? [])].map(([queryId, outcome]) => ({ queryId, outcome })), + }) + return { ok: true, ...(traceId === undefined ? {} : { traceId }) } + } + /** Returns the current state for a query by id, if present. */ getQueryState(queryId: string): QueryState | undefined { return this.#getQuery(queryId)?.state as QueryState | undefined @@ -291,7 +464,7 @@ export class QueryStore< getQueryStats(queryId: string): QueryFetchStats | undefined { const stats = this.#queryStats.get(queryId) if (!stats) return undefined - return { ...stats } + return { ...stats, history: [...stats.history] } } getQueryGeneration(queryId: string): number | undefined { @@ -347,7 +520,7 @@ export class QueryStore< subscribe( queryId: string, fn: (state: QueryState) => void, - options: { staleTime?: number | undefined } = {}, + options: QueryExecutionOptions = {}, ): () => void { const q = this.#getQuery(queryId) if (!q) return () => {} @@ -377,7 +550,7 @@ export class QueryStore< * Used internally for relational-filter invalidation; each event carries the * previous entity so listeners can detect which fields changed. */ - subscribeToProcessedEvents(fn: (event: ProcessedRealtimeEvent) => void): () => void { + subscribeToProcessedEvents(fn: (event: ProcessedCacheEvent) => void): () => void { this.#processedEventListeners.add(fn) return () => { this.#processedEventListeners.delete(fn) @@ -438,10 +611,14 @@ export class QueryStore< * `staleTime` is not part of query identity: a later, stricter subscriber must be * able to revalidate an already-live query without rebuilding its subscription. */ - ensureFresh(queryId: string, options: { staleTime?: number | undefined } = {}): void { + ensureFresh(queryId: string, options: QueryExecutionOptions = {}): void { const q = this.#getQuery(queryId) if (!q) return + if (q.state.isFetching && options.graph) { + this.#telemetry.attachGraph(queryId, options.graph) + } + const staleTime = options.staleTime ?? 0 const isFresh = staleTime > 0 && q.fetchedAt !== undefined && Date.now() - q.fetchedAt < staleTime @@ -453,17 +630,30 @@ export class QueryStore< !isFresh) || (q.state.status === 'error' && !q.state.isFetching) ) { - this.#queue(queryId) + this.#queue(queryId, { + reason: 'subscription', + ...this.#causeContext('subscription'), + ...(options.graph ? { graph: [options.graph] } : {}), + }) } } /** Refetch a specific query by id. */ - refetch(queryId: string): void { + refetch( + queryId: string, + context?: FetchContext, + options: Omit = {}, + ): void { const q = this.#getQuery(queryId) if (!q) return + const fetchContext = context ?? { + reason: 'manual' as const, + ...this.#causeContext('manual'), + ...(options.graph ? { graph: [options.graph] } : {}), + } if (!q.state.isFetching) { - this.#queue(queryId) + this.#queue(queryId, fetchContext) } else { // Mark as dirty to refetch after current fetch completes this.#transactOverService(queryId, (service, query) => { @@ -472,6 +662,7 @@ export class QueryStore< dirty: true, }) }) + this.#followupFetchContexts.set(queryId, fetchContext) } } @@ -481,7 +672,7 @@ export class QueryStore< } /** Replace/remove a row already visible in one query without changing membership. @internal */ - applyVisibleEvent(queryId: string, event: ProcessedRealtimeEvent): void { + applyVisibleEvent(queryId: string, event: ProcessedCacheEvent): void { const serviceName = this.#serviceNamesByQueryId.get(queryId) if (!serviceName) return const getId = this.#getIdReader(serviceName) @@ -529,8 +720,8 @@ export class QueryStore< control: undefined, run: () => this.#adapter.mutate(serviceName, method, [...args]), hooks: { - onSuccess: item => - this.#processEvent(serviceName, { type: MUTATION_EVENT_TYPE[method], item }), + onSuccess: (item, { cause }) => + this.#processEvent(serviceName, { type: MUTATION_EVENT_TYPE[method], item }, cause), }, }) return registration.promise as Promise> @@ -599,21 +790,27 @@ export class QueryStore< control, ...(optimistic ? { - project: () => - this.#processEvent(desc.serviceName, { type: 'created', item: optimisticItem }), + project: (cause?: MutationTraceCause) => + this.#processEvent( + desc.serviceName, + { type: 'created', item: optimisticItem }, + cause, + ), } : {}), run: () => this.#adapter.mutate(desc.serviceName, desc.method, [...args]), hooks: { // Apply the cache update before ending the tracker entry, so by the time a // `useMutating` subscriber sees "not busy" the data is already in the cache. - onSuccess: item => this.#processEvent(desc.serviceName, { type: 'created', item }), - onError: (_error, mutationId) => { + onSuccess: (item, { cause }) => + this.#processEvent(desc.serviceName, { type: 'created', item }, cause), + onError: (_error, { mutationId, cause }) => { if (!optimistic) return - this.#processEvent(desc.serviceName, { type: 'removed', item: optimisticItem }) - this.#events.emit({ + this.#processEvent(desc.serviceName, { type: 'removed', item: optimisticItem }, cause) + this.#telemetry.emit({ kind: 'mutate:rollback', mutationId, + ...(cause ? { traceId: cause.traceId } : {}), serviceName: desc.serviceName, method: desc.method, }) @@ -638,11 +835,15 @@ export class QueryStore< control, run: () => this.#adapter.mutate(desc.serviceName, desc.method, [...args]), hooks: { - onSuccess: item => - this.#processEvent(desc.serviceName, { - type: MUTATION_EVENT_TYPE[desc.method], - item, - }), + onSuccess: (item, { cause }) => + this.#processEvent( + desc.serviceName, + { + type: MUTATION_EVENT_TYPE[desc.method], + item, + }, + cause, + ), }, }) } @@ -678,13 +879,15 @@ export class QueryStore< }, () => entry.attempt.promise, { - onSuccess: item => this.#settleQueuedMutation(lane, entry, { ok: true, item }), - onError: (error, mutationId) => { - this.#settleQueuedMutation(lane, entry, { ok: false, error }) + onSuccess: (item, { cause }) => + this.#settleQueuedMutation(lane, entry, { ok: true, item }, cause), + onError: (error, { mutationId, cause }) => { + this.#settleQueuedMutation(lane, entry, { ok: false, error }, cause) if (optimistic) { - this.#events.emit({ + this.#telemetry.emit({ kind: 'mutate:rollback', mutationId, + ...(cause ? { traceId: cause.traceId } : {}), serviceName: desc.serviceName, method: desc.method, id, @@ -694,7 +897,8 @@ export class QueryStore< }, ) - this.#applyProjection(this.#mutationLanes.enqueue(lane, entry), true) + if (tracked.cause) entry.cause = tracked.cause + this.#applyProjection(this.#mutationLanes.enqueue(lane, entry), true, tracked.cause) entry.attempt.whenReady(() => { this.#expediteMutationPredecessors(lane, entry) this.#drainMutationLane(lane) @@ -707,10 +911,11 @@ export class QueryStore< const projection = this.#mutationLanes.replaceTail(lane, entry, next) if (!projection) return false entry.args = this.#buildMutationArgs(next) - this.#applyProjection(projection, true) - this.#events.emit({ + this.#applyProjection(projection, true, tracked.cause) + this.#telemetry.emit({ kind: 'mutate:update', mutationId: tracked.mutationId, + ...(tracked.cause ? { traceId: tracked.cause.traceId } : {}), serviceName: next.serviceName, method: next.method, id, @@ -719,7 +924,7 @@ export class QueryStore< }) return true }, - cancel: error => this.#cancelQueuedMutation(lane, entry, error), + cancel: error => this.#cancelQueuedMutation(lane, entry, error, tracked.cause), } } @@ -757,7 +962,7 @@ export class QueryStore< try { return await run() } catch (error) { - const normalized = error instanceof Error ? error : new Error(String(error)) + const normalized = normalizeError(error) if (!control || (await control.onAttemptFailure(normalized, attempt)) === 'discard') { throw normalized } @@ -765,10 +970,15 @@ export class QueryStore< } } - #cancelQueuedMutation(lane: MutationLane, entry: QueuedMutation, error: Error): void { + #cancelQueuedMutation( + lane: MutationLane, + entry: QueuedMutation, + error: Error, + cause?: TraceCause, + ): void { if (!entry.attempt.cancel(error)) return const projection = this.#mutationLanes.cancel(lane, entry) - if (projection) this.#applyProjection(projection, true) + if (projection) this.#applyProjection(projection, true, cause) this.#drainMutationLane(lane) } @@ -776,6 +986,7 @@ export class QueryStore< lane: MutationLane, entry: QueuedMutation, outcome: { ok: true; item: unknown } | { ok: false; error: Error }, + cause?: TraceCause, ): void { const settlement = this.#mutationLanes.settle(lane, entry, outcome) if (!settlement) return @@ -787,7 +998,7 @@ export class QueryStore< this.#fetchEventJournal.record([settlement.authoritativeEvent]) } - const projected = this.#applyProjection(settlement.projection, true) + const projected = this.#applyProjection(settlement.projection, true, cause) if (!projected && settlement.authoritativeEvent && !this.#mutationLanes.peekNext(lane)) { this.#publishAppliedEvent(settlement.authoritativeEvent) } @@ -810,8 +1021,8 @@ export class QueryStore< this.#drainMutationLane(lane) } - #applyProjection(change: ProjectionChange, immediate: boolean): boolean { - const event = this.#queuedProjectionEvent(change) + #applyProjection(change: ProjectionChange, immediate: boolean, cause?: TraceCause): boolean { + const event = this.#queuedProjectionEvent(change, cause) if (!event) return false this.#eventQueue.push(event) if (immediate) this.#processQueuedEvents() @@ -876,15 +1087,15 @@ export class QueryStore< }: { tracking: MutationTrackingEntry control: ScheduledMutationControl | undefined - project?: () => void + project?: (cause?: MutationTraceCause) => void run: () => Promise hooks?: MutationTrackingHooks }): RegisteredMutation { const attempt = new GatedMutationAttempt(control) const tracked = this.#trackMutation( tracking, - () => { - project?.() + ({ cause }) => { + project?.(cause) return attempt.promise }, hooks, @@ -915,29 +1126,33 @@ export class QueryStore< */ #trackMutation( entry: MutationTrackingEntry, - run: () => Promise, + run: (context: MutationTrackingContext) => Promise, hooks?: MutationTrackingHooks, ): TrackedMutation { const { serviceName, method, id, optimistic, args } = entry const idField = id !== undefined ? { id } : {} const startedAt = Date.now() const mutationId = this.#mutations.start({ serviceName, method, ...idField }) - this.#events.emit({ + const cause = this.#telemetry.mutationCause(mutationId) as MutationTraceCause | undefined + const context: MutationTrackingContext = { mutationId, ...(cause ? { cause } : {}) } + this.#telemetry.emit({ kind: 'mutate:start', mutationId, + ...(cause ? { traceId: cause.traceId } : {}), serviceName, method, ...idField, optimistic, args, }) - const promise = run().then( + const promise = run(context).then( result => { - hooks?.onSuccess?.(result) + hooks?.onSuccess?.(result, context) this.#mutations.end(mutationId) - this.#events.emit({ + this.#telemetry.emit({ kind: 'mutate:end', mutationId, + ...(cause ? { traceId: cause.traceId } : {}), serviceName, method, durationMs: Date.now() - startedAt, @@ -947,12 +1162,13 @@ export class QueryStore< return result }, (err: unknown) => { - const error = err instanceof Error ? err : new Error(String(err)) - hooks?.onError?.(error, mutationId) + const error = normalizeError(err) + hooks?.onError?.(error, context) this.#mutations.end(mutationId) - this.#events.emit({ + this.#telemetry.emit({ kind: 'mutate:error', mutationId, + ...(cause ? { traceId: cause.traceId } : {}), serviceName, method, durationMs: Date.now() - startedAt, @@ -963,53 +1179,80 @@ export class QueryStore< throw error }, ) - return { mutationId, promise } + return { mutationId, ...(cause ? { cause } : {}), promise } } // Query lifecycle - async #queue(queryId: string): Promise { + async #queue(queryId: string, context?: FetchContext): Promise { + const fetchContext = context ?? { + reason: 'subscription' as const, + ...this.#causeContext('subscription'), + } + const graph = this.#telemetry.beginGraph(queryId, fetchContext.graph) this.#fetching({ queryId }) const generation = this.#queryGenerations.get(queryId) - if (generation === undefined) return + if (generation === undefined) { + this.#telemetry.finishGraph(queryId, graph) + return + } - let retryAttempt = 0 - while (true) { - const outcome = await this.#runFetchAttempt(queryId, generation) - if (outcome.kind !== 'failed') return - - const query = this.#getQuery(queryId) - if ( - !query || - this.#queryGenerations.get(queryId) !== generation || - !this.#hasRetryOwner(queryId) || - !this.#shouldRetry(query, retryAttempt, outcome.error) - ) { - if (query && this.#queryGenerations.get(queryId) === generation) { - this.#fetchFailed({ queryId, error: outcome.error }) + try { + let retryAttempt = 0 + while (true) { + const outcome = await this.#runFetchAttempt( + queryId, + generation, + { + reason: retryAttempt === 0 ? fetchContext.reason : 'retry', + attempt: retryAttempt, + ...(fetchContext.causes ? { causes: fetchContext.causes } : {}), + }, + graph, + ) + if (outcome.kind !== 'failed') return + + const query = this.#getQuery(queryId) + if ( + !query || + this.#queryGenerations.get(queryId) !== generation || + !this.#hasRetryOwner(queryId) || + !this.#shouldRetry(query, retryAttempt, outcome.error) + ) { + if (query && this.#queryGenerations.get(queryId) === generation) { + this.#fetchFailed({ queryId, error: outcome.error }) + } + return } - return - } - retryAttempt++ - const configuredDelay = query.config.retryDelay ?? this.#retryDelay - const delay = this.#resolveRetryDelay(configuredDelay, retryAttempt, outcome.error) - await new Promise(resolve => setTimeout(resolve, delay)) + retryAttempt++ + const configuredDelay = query.config.retryDelay ?? this.#retryDelay + const delay = this.#resolveRetryDelay(configuredDelay, retryAttempt, outcome.error) + await new Promise(resolve => setTimeout(resolve, delay)) - if (this.#queryGenerations.get(queryId) !== generation) return - if (!this.#hasRetryOwner(queryId)) { - this.#fetchFailed({ queryId, error: outcome.error }) - return + if (this.#queryGenerations.get(queryId) !== generation) return + if (!this.#hasRetryOwner(queryId)) { + this.#fetchFailed({ queryId, error: outcome.error }) + return + } } + } finally { + this.#telemetry.finishGraph(queryId, graph) } } - async #runFetchAttempt(queryId: string, generation: number): Promise { + async #runFetchAttempt( + queryId: string, + generation: number, + context: { reason: FetchReason; attempt: number; causes?: TraceCause[] }, + graphRefs: ReadonlyMap, + ): Promise { const query = this.#getQuery(queryId) if (!query || this.#queryGenerations.get(queryId) !== generation) { return { kind: 'stale' } } const startedAt = Date.now() + const fetchId = this.#telemetry.nextFetchId() const trace = { generation, serviceName: query.desc.serviceName, @@ -1018,59 +1261,92 @@ export class QueryStore< params: query.desc.params, } const journalCursor = this.#fetchEventJournal.begin(trace.serviceName) - this.#events.emit({ + const graph = [...graphRefs.values()] + this.#telemetry.emit({ kind: 'fetch:start', + timestamp: startedAt, serviceName: trace.serviceName, method: trace.method, queryId, generation, + fetchId, + reason: context.reason, + attempt: context.attempt, + ...(context.causes ? { causes: context.causes } : {}), + ...(graph.length > 0 ? { graph } : {}), ...('resourceId' in trace ? { resourceId: trace.resourceId } : {}), params: trace.params, }) try { const result = await this.#fetch(queryId) - const durationMs = Date.now() - startedAt + const endedAt = Date.now() + const durationMs = endedAt - startedAt const current = this.#getQuery(queryId) if (current && this.#queryGenerations.get(queryId) === generation) { const journal = this.#fetchEventJournal.read(journalCursor) if (journal.overflowed) { this.#discardFetchedResponse(queryId) } else { - this.#fetched({ queryId, result, journalEvents: journal.events }) + const cacheCause = context.causes?.[0] + this.#fetched({ + queryId, + result, + journalEvents: journal.events, + ...(cacheCause === undefined ? {} : { cause: cacheCause }), + }) } - this.#recordFetchStats(queryId, { ok: true, durationMs }) + this.#recordFetchStats(queryId, { + fetchId, + startedAt, + durationMs, + ok: true, + reason: context.reason, + }) } const data = result.data const itemCount = Array.isArray(data) ? data.length : data ? 1 : 0 - this.#events.emit({ + this.#telemetry.emit({ kind: 'fetch:end', + timestamp: endedAt, serviceName: trace.serviceName, method: trace.method, queryId, generation, + fetchId, durationMs, itemCount, + ...(graphRefs.size > 0 ? { graph: [...graphRefs.values()] } : {}), }) return { kind: 'completed' } } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)) - const durationMs = Date.now() - startedAt + const error = normalizeError(err) + const endedAt = Date.now() + const durationMs = endedAt - startedAt const current = this.#getQuery(queryId) const isCurrent = Boolean(current && this.#queryGenerations.get(queryId) === generation) if (isCurrent) { - this.#recordFetchStats(queryId, { ok: false, durationMs }) + this.#recordFetchStats(queryId, { + fetchId, + startedAt, + durationMs, + ok: false, + reason: context.reason, + }) } - this.#events.emit({ + this.#telemetry.emit({ kind: 'fetch:error', + timestamp: endedAt, serviceName: trace.serviceName, method: trace.method, queryId, generation, + fetchId, durationMs, error, + ...(graphRefs.size > 0 ? { graph: [...graphRefs.values()] } : {}), }) return isCurrent ? { kind: 'failed', error } : { kind: 'stale' } } finally { @@ -1098,6 +1374,15 @@ export class QueryStore< ) } + #takeFollowupFetchContext(queryId: string): FetchContext { + const context = this.#followupFetchContexts.get(queryId) ?? { + reason: 'follow-up' as const, + ...this.#causeContext('fetch-rebase'), + } + this.#followupFetchContexts.delete(queryId) + return context + } + #resolveRetryDelay(delay: RetryDelay, attempt: number, error: Error): number { return resolveRetryDelay( () => (typeof delay === 'function' ? delay(attempt, error) : delay), @@ -1261,10 +1546,12 @@ export class QueryStore< queryId, result, journalEvents, + cause, }: { queryId: string result: StoreResponse - journalEvents: readonly ProcessedRealtimeEvent[] + journalEvents: readonly ProcessedCacheEvent[] + cause?: TraceCause }): void { let shouldRefetch = false let hadEffectiveJournalEvents = false @@ -1293,9 +1580,15 @@ export class QueryStore< for (const item of responseItems) { const itemId = getId(item) if (itemId === undefined || rebasePlan.itemIds.has(entityKey(itemId))) continue - const accepted = this.#acceptLaneAuthoritative(query.desc.serviceName, 'updated', item) + const accepted = this.#acceptLaneAuthoritative( + query.desc.serviceName, + 'updated', + item, + 'fetch', + cause, + ) if (!accepted.handled || !accepted.projection) continue - const projectionEvent = this.#queuedProjectionEvent(accepted.projection) + const projectionEvent = this.#queuedProjectionEvent(accepted.projection, cause) if (projectionEvent) fetchedProjectionEvents.push(projectionEvent) } } @@ -1372,6 +1665,23 @@ export class QueryStore< const currentItem = service.entities.get(key) if (!currentItem || !this.#adapter.isItemStale(currentItem, item)) { service.entities.set(key, item) + if (!isCompleteSet && currentItem !== item && this.#telemetry.active) { + eventEffects.push({ + event: { + mode: 'server', + source: 'fetch', + serviceName: query.desc.serviceName, + type: currentItem === undefined ? 'created' : 'updated', + item, + previousItem: currentItem ?? null, + itemId: key, + ...(cause === undefined ? {} : { cause }), + }, + reconcileQueryIds: new Set(), + queryEffects: new Map([[queryId, 'merged']]), + observabilityOnly: true, + }) + } } } addQueryToItemIndex(service, itemId, queryId) @@ -1430,7 +1740,9 @@ export class QueryStore< ...this.#updateQueriesForEvents({ service, serviceName: query.desc.serviceName, - processedEvents: diffEvents, + processedEvents: diffEvents.map(event => + cause === undefined ? event : { ...event, cause }, + ), touch, excludeQueryId: queryId, }), @@ -1468,7 +1780,7 @@ export class QueryStore< if (shouldRefetch && shouldRunFollowup) { serverMaintainedQueriesToRefetch.delete(queryId) - this.#queue(queryId) + this.#queue(queryId, this.#takeFollowupFetchContext(queryId)) } else if (hadEffectiveJournalEvents && query?.config.realtime !== 'disabled') { // Even exact replay cannot prove every server-maintained membership/order // edge. One gated trailing reconciliation guarantees convergence. @@ -1478,6 +1790,9 @@ export class QueryStore< for (const id of serverMaintainedQueriesToRefetch) { this.#requestReconcile(id, { force: id === service?.materialized?.queryId, + ...(publishedEffects.reconcileCauses.has(id) + ? { causes: publishedEffects.reconcileCauses.get(id)! } + : {}), }) } } @@ -1507,7 +1822,7 @@ export class QueryStore< const isMaterializedRoot = serviceName !== undefined && this.#state.get(serviceName)?.materialized?.queryId === queryId if (shouldRefetch && (this.#listenerCount(queryId) > 0 || isMaterializedRoot)) { - this.#queue(queryId) + this.#queue(queryId, this.#takeFollowupFetchContext(queryId)) } } @@ -1524,26 +1839,29 @@ export class QueryStore< const serviceName = this.#serviceNamesByQueryId.get(queryId) const isMaterializedRoot = serviceName !== undefined && this.#state.get(serviceName)?.materialized?.queryId === queryId - this.#requestReconcile(queryId, { force: isMaterializedRoot }) + this.#requestReconcile(queryId, { + force: isMaterializedRoot, + ...this.#causeContext('fetch-rebase'), + }) // An immediate reconciliation has already restored isFetching=true; hidden // or inactive queries expose the settled state and remain pending instead. this.#notify(touched) } - #recordFetchStats( - queryId: string, - { ok, durationMs }: { ok: boolean; durationMs: number }, - ): void { + #recordFetchStats(queryId: string, entry: QueryFetchHistoryEntry): void { + const { ok, durationMs } = entry const current = this.#queryStats.get(queryId) ?? { fetchCount: 0, errorCount: 0, totalDurationMs: 0, + history: [], } this.#queryStats.set(queryId, { fetchCount: current.fetchCount + 1, errorCount: current.errorCount + (ok ? 0 : 1), totalDurationMs: current.totalDurationMs + durationMs, lastDurationMs: durationMs, + history: [...current.history.slice(-(QUERY_FETCH_HISTORY_LIMIT - 1)), entry], }) } @@ -1574,65 +1892,102 @@ export class QueryStore< this.#realtime.add(serviceName) } - #emitRealtimeForItems(serviceName: string, type: Event['type'], items: unknown[]): void { - for (const item of items) { - this.#events.emit({ - kind: 'realtime', - serviceName, - type, - itemId: this.#getIdWarn(serviceName, item), - }) - } + #emitRealtime(serviceName: string, type: Event['type'], item: unknown): TraceCause | undefined { + if (!this.#telemetry.active) return undefined + const cause = this.#telemetry.cause('realtime') + this.#telemetry.emit({ + kind: 'realtime', + ...(cause ? { traceId: cause.traceId } : {}), + serviceName, + type, + itemId: this.#getIdWarn(serviceName, item), + item, + }) + return cause } /** Push an authoritative event onto the atomic queue. */ - #enqueueAuthoritativeEvent(serviceName: string, event: Event): void { - const items = Array.isArray(event.item) ? event.item : [event.item] + #enqueueAuthoritativeEvent( + serviceName: string, + event: Event, + source: 'realtime' | 'mutation', + cause?: TraceCause, + ): void { this.#eventQueue.push({ - origin: 'authoritative', + mode: 'server', + source, serviceName, type: event.type, - items, + item: event.item, + ...(cause ? { cause } : {}), }) } - #queuedProjectionEvent(change: ProjectionChange): QueuedEvent | null { + #queuedProjectionEvent(change: ProjectionChange, cause?: TraceCause): QueuedEvent | null { const event = this.#projectionEvent(change) if (!event) return null return { - origin: 'projection', + mode: 'optimistic', serviceName: change.lane.serviceName, type: event.type, - items: Array.isArray(event.item) ? event.item : [event.item], + item: event.item, mutationLaneKey: change.lane.key, + ...(cause === undefined ? {} : { cause }), } } /** Apply an event immediately — used for mutation results and optimistic writes. */ - #processEvent(serviceName: string, event: Event): void { - this.#ingestAuthoritativeEvent(serviceName, event, true) + #processEvent(serviceName: string, event: Event, cause?: TraceCause): void { + this.#ingestAuthoritativeEvent(serviceName, event, { + immediate: true, + source: 'mutation', + ...(cause ? { cause } : {}), + }) } /** Queue a realtime event for batched processing. */ #queueEvent(serviceName: string, event: Event): void { - this.#ingestAuthoritativeEvent(serviceName, event, false) + this.#ingestAuthoritativeEvent(serviceName, event, { + immediate: false, + source: 'realtime', + }) } - #ingestAuthoritativeEvent(serviceName: string, event: Event, immediate: boolean): void { + #ingestAuthoritativeEvent( + serviceName: string, + event: Event, + context: + | { immediate: false; source: 'realtime' } + | { immediate: true; source: 'mutation'; cause?: TraceCause }, + ): void { const items = Array.isArray(event.item) ? event.item : [event.item] - this.#emitRealtimeForItems(serviceName, event.type, items) for (const item of items) { - const accepted = this.#acceptLaneAuthoritative(serviceName, event.type, item) + const cause = + context.source === 'realtime' + ? this.#emitRealtime(serviceName, event.type, item) + : context.cause + const accepted = this.#acceptLaneAuthoritative( + serviceName, + event.type, + item, + context.source, + cause, + ) if (!accepted.handled) { - this.#enqueueAuthoritativeEvent(serviceName, { type: event.type, item }) + this.#enqueueAuthoritativeEvent( + serviceName, + { type: event.type, item }, + context.source, + cause, + ) continue } - if (accepted.projection) this.#applyProjection(accepted.projection, false) + if (accepted.projection) this.#applyProjection(accepted.projection, false, cause) } if (this.#eventQueue.length === 0) return - if (immediate) { + if (context.immediate) { this.#processQueuedEvents() return } @@ -1655,6 +2010,8 @@ export class QueryStore< serviceName: string, type: Event['type'], item: unknown, + source: 'realtime' | 'mutation' | 'fetch', + cause?: TraceCause, ): LaneAuthoritativeAcceptance { const id = this.#peekId(item) const lane = id === undefined ? undefined : this.#mutationLanes.get(serviceName, id) @@ -1663,7 +2020,13 @@ export class QueryStore< this.#adapter.isItemStale(current, next), ) if (!transition) return { handled: true, projection: null } - this.#fetchEventJournal.record([transition.event]) + this.#fetchEventJournal.record([ + { + ...transition.event, + source, + ...(cause === undefined ? {} : { cause }), + }, + ]) return { handled: true, projection: transition.projection } } @@ -1681,7 +2044,7 @@ export class QueryStore< excludeQueryId?: string }): AppliedEventEffect[] { const getId = this.#getIdReader(serviceName) - const processedEvents: ProcessedRealtimeEvent[] = [] + const processedEvents: ProcessedCacheEvent[] = [] applyEventsToService({ service, serviceName, @@ -1708,13 +2071,16 @@ export class QueryStore< }: { service: ServiceState serviceName: string - processedEvents: readonly ProcessedRealtimeEvent[] + processedEvents: readonly ProcessedCacheEvent[] touch: (queryId: string) => void excludeQueryId?: string }): AppliedEventEffect[] { const getId = this.#getIdReader(serviceName) return processedEvents.map(event => { const reconcileQueryIds = new Set() + const queryEffects = this.#telemetry.active + ? new Map() + : undefined updateQueriesFromEvents({ service, appliedItems: [event], @@ -1723,10 +2089,16 @@ export class QueryStore< itemAdded: meta => this.#adapter.itemAdded(meta), itemRemoved: meta => this.#adapter.itemRemoved(meta), serverMaintainedQueriesToRefetch: reconcileQueryIds, + ...(queryEffects + ? { + onEffect: (queryId: string, effect: 'merged' | 'reconcile') => + queryEffects.set(queryId, effect), + } + : {}), ...(excludeQueryId ? { excludeQueryId } : {}), defaultSort: this.#defaultSort, }) - return { event, reconcileQueryIds } + return { event, reconcileQueryIds, ...(queryEffects ? { queryEffects } : {}) } }) } @@ -1736,46 +2108,102 @@ export class QueryStore< refetchPolicy: 'realtime' | 'none', ): PublishedEventEffects { const immediateReconciles = new Set() - for (const { event, reconcileQueryIds } of effects) { + const reconcileCauses = new Map() + const fallbackCauses = this.#telemetry.active + ? new Map() + : undefined + const causeFor = (event: ProcessedCacheEvent): TraceCause | undefined => { + if (event.cause) return event.cause + if (!fallbackCauses) return undefined + if (!fallbackCauses.has(event)) { + fallbackCauses.set(event, this.#telemetry.fallbackCause(event)) + } + return fallbackCauses.get(event) + } + const hasAuthoritative = + refetchPolicy === 'realtime' && effects.some(effect => effect.event.mode === 'server') + const addReconcile = (queryId: string, cause?: TraceCause) => { + immediateReconciles.add(queryId) + if (!cause) return + const merged = this.#telemetry.merge(reconcileCauses.get(queryId), [cause]) + if (merged) reconcileCauses.set(queryId, merged) + } + + for (const { event, reconcileQueryIds, queryEffects, observabilityOnly } of effects) { + if (observabilityOnly) continue + const cause = causeFor(event) const deferred = - event.origin === 'projection' && + event.mode === 'optimistic' && this.#mutationLanes.deferQueryIds(event.mutationLaneKey, reconcileQueryIds) if (!deferred) { - for (const queryId of reconcileQueryIds) immediateReconciles.add(queryId) + for (const queryId of reconcileQueryIds) addReconcile(queryId, cause) } // Relational filters need projected dependency changes immediately so // they can recompute locally. Their listener distinguishes projections // from authoritative events and only the latter may trigger a refetch. const projectionSettled = - event.origin === 'projection' && + event.mode === 'optimistic' && !this.#mutationLanes.deferProjection(event.mutationLaneKey, event) this.#emitProcessedEvent(event) if (projectionSettled) this.#emitProjectionSettlement(event) - } - const refetchService = - refetchPolicy === 'realtime' && effects.some(({ event }) => event.origin === 'authoritative') - if (refetchPolicy === 'realtime' && effects.length > 0 && !refetchService) { - const refetchableQueryIds = this.#refetchableQueryIds(serviceName) - for (const { event } of effects) { - if (event.origin === 'projection') { + if (refetchPolicy === 'realtime') { + const refetchableQueryIds = this.#refetchableQueryIds(serviceName) + if (event.mode === 'server') { + for (const queryId of refetchableQueryIds) { + queryEffects?.set(queryId, 'reconcile') + addReconcile(queryId, cause) + } + } else if (event.mode === 'optimistic' && !hasAuthoritative) { const deferred = this.#mutationLanes.deferQueryIds( event.mutationLaneKey, refetchableQueryIds, ) if (!deferred) { - for (const queryId of refetchableQueryIds) immediateReconciles.add(queryId) + for (const queryId of refetchableQueryIds) { + queryEffects?.set(queryId, 'reconcile') + addReconcile(queryId, cause) + } } } } } - return { reconcileQueryIds: immediateReconciles, refetchService } + for (const { event, queryEffects } of effects) { + if (!this.#telemetry.active) break + const cause = causeFor(event) + this.#telemetry.emit({ + kind: 'cache:updated', + ...(cause ? { traceId: cause.traceId } : {}), + source: this.#cacheEventSource(event), + serviceName: event.serviceName, + type: event.type, + itemId: event.itemId, + item: event.item, + previousItem: event.previousItem, + queryEffects: [...(queryEffects ?? [])].map(([queryId, outcome]) => ({ queryId, outcome })), + }) + } + + return { reconcileQueryIds: immediateReconciles, reconcileCauses } + } + + #cacheEventSource( + event: ProcessedCacheEvent, + ): 'realtime' | 'mutation' | 'fetch' | 'optimistic' | 'devtools' { + if (event.mode === 'optimistic') return 'optimistic' + if (event.mode === 'local') return 'devtools' + return event.source + } + + #causeContext(kind: TraceCause['kind']): { causes?: TraceCause[] } { + const cause = this.#telemetry.cause(kind) + return cause ? { causes: [cause] } : {} } /** Publish an authoritative transition whose entity-cache effect already happened. */ - #publishAppliedEvent(event: ProcessedRealtimeEvent): void { + #publishAppliedEvent(event: ProcessedCacheEvent): void { let effects: AppliedEventEffect[] = [] const touched = this.#transactOverServiceByName(event.serviceName, (service, touch) => { effects = this.#updateQueriesForEvents({ @@ -1787,8 +2215,13 @@ export class QueryStore< }) this.#notify(touched) const published = this.#publishServiceEventEffects(event.serviceName, effects, 'realtime') - for (const queryId of published.reconcileQueryIds) this.#requestReconcile(queryId) - if (published.refetchService) this.#refetchRefetchableQueries(event.serviceName) + for (const queryId of published.reconcileQueryIds) { + this.#requestReconcile(queryId, { + ...(published.reconcileCauses.has(queryId) + ? { causes: published.reconcileCauses.get(queryId)! } + : {}), + }) + } } #processQueuedEvents(): void { @@ -1853,9 +2286,12 @@ export class QueryStore< // ones through the gate (cooldown + hidden-tab deferral); the gate marks // inactive cached ones pending so their next subscription reconciles. for (const queryId of publishedEffects.reconcileQueryIds) { - this.#requestReconcile(queryId) + this.#requestReconcile(queryId, { + ...(publishedEffects.reconcileCauses.has(queryId) + ? { causes: publishedEffects.reconcileCauses.get(queryId)! } + : {}), + }) } - if (publishedEffects.refetchService) this.#refetchRefetchableQueries(serviceName) } } } finally { @@ -1863,7 +2299,7 @@ export class QueryStore< } } - #emitProcessedEvent(event: ProcessedRealtimeEvent): void { + #emitProcessedEvent(event: ProcessedCacheEvent): void { for (const listener of this.#processedEventListeners) { try { listener(event) @@ -1883,10 +2319,6 @@ export class QueryStore< } } - #refetchRefetchableQueries(serviceName: string): void { - for (const queryId of this.#refetchableQueryIds(serviceName)) this.#requestReconcile(queryId) - } - #refetchableQueryIds(serviceName: string): string[] { const service = this.getState().get(serviceName) if (!service) return [] @@ -1914,21 +2346,34 @@ export class QueryStore< * edge — isolated changes stay as fast as today); further events within * `reconcileCooldown` coalesce into one guaranteed trailing refetch. */ - #requestReconcile(queryId: string, { force = false }: { force?: boolean } = {}): void { + #requestReconcile( + queryId: string, + { force = false, causes }: { force?: boolean; causes?: readonly TraceCause[] } = {}, + ): void { + const mergedCauses = this.#telemetry.merge(undefined, causes) if (!force && this.#listenerCount(queryId) === 0) { + this.#emitReconcileDecision(queryId, 'inactive', mergedCauses) this.#markQueryPending(queryId) return } if (this.#visibility.isHidden()) { - this.#deferredWhileHidden.add(queryId) + this.#deferredWhileHidden.set( + queryId, + this.#telemetry.merge(this.#deferredWhileHidden.get(queryId), mergedCauses), + ) + this.#emitReconcileDecision(queryId, 'deferred-hidden', mergedCauses) this.#markQueryPending(queryId) return } if (this.#reconcileCooldown <= 0) { - this.#emitReconcileStarted(queryId) - this.refetch(queryId) + this.#emitReconcileDecision(queryId, 'fetch-now', mergedCauses) + this.#emitReconcileStarted(queryId, mergedCauses) + this.refetch(queryId, { + reason: 'reconcile', + ...(mergedCauses ? { causes: mergedCauses } : {}), + }) return } @@ -1936,25 +2381,39 @@ export class QueryStore< const window = this.#reconcileWindows.get(queryId) if (!window || now - window.lastAt >= this.#reconcileCooldown) { - this.#reconcileWindows.set(queryId, { lastAt: now, trailing: window?.trailing ?? null }) - this.#emitReconcileStarted(queryId) - this.refetch(queryId) + this.#reconcileWindows.set(queryId, { + lastAt: now, + trailing: window?.trailing ?? null, + }) + this.#emitReconcileDecision(queryId, 'fetch-now', mergedCauses) + this.#emitReconcileStarted(queryId, mergedCauses) + this.refetch(queryId, { + reason: 'reconcile', + ...(mergedCauses ? { causes: mergedCauses } : {}), + }) return } + const coalescedCauses = this.#telemetry.merge(window.causes, mergedCauses) + if (coalescedCauses) window.causes = coalescedCauses + this.#emitReconcileDecision(queryId, 'coalesced', mergedCauses) if (window.trailing) return // the pending trailing refetch already covers this const timer = setTimeout( () => { const current = this.#reconcileWindows.get(queryId) - if (current) current.trailing = null + const trailingCauses = current?.causes + if (current) { + current.trailing = null + delete current.causes + } if (!this.#getQuery(queryId)) { this.#reconcileWindows.delete(queryId) return } // Re-enter the gate after the window expires unless the tab went hidden or // the last subscriber left in the meantime. - this.#requestReconcile(queryId) + this.#requestReconcile(queryId, trailingCauses ? { causes: trailingCauses } : {}) }, window.lastAt + this.#reconcileCooldown - now, ) @@ -1963,10 +2422,32 @@ export class QueryStore< window.trailing = timer } - #emitReconcileStarted(queryId: string): void { + #emitReconcileDecision( + queryId: string, + decision: 'fetch-now' | 'coalesced' | 'deferred-hidden' | 'inactive', + causes: readonly TraceCause[] | undefined, + ): void { const serviceName = this.#serviceNamesByQueryId.get(queryId) if (serviceName !== undefined) { - this.#events.emit({ kind: 'reconcile:started', queryId, serviceName }) + this.#telemetry.emit({ + kind: 'reconcile:decision', + queryId, + serviceName, + decision, + ...(causes ? { causes } : {}), + }) + } + } + + #emitReconcileStarted(queryId: string, causes: readonly TraceCause[] | undefined): void { + const serviceName = this.#serviceNamesByQueryId.get(queryId) + if (serviceName !== undefined) { + this.#telemetry.emit({ + kind: 'reconcile:started', + queryId, + serviceName, + ...(causes ? { causes } : {}), + }) } } @@ -1975,9 +2456,9 @@ export class QueryStore< if (this.#visibility.isHidden() || this.#deferredWhileHidden.size === 0) return const deferred = Array.from(this.#deferredWhileHidden) this.#deferredWhileHidden.clear() - for (const queryId of deferred) { + for (const [queryId, causes] of deferred) { if (!this.#getQuery(queryId)) continue - this.#requestReconcile(queryId) + this.#requestReconcile(queryId, causes ? { causes } : {}) } } @@ -2017,12 +2498,13 @@ export class QueryStore< } } - #refetchActiveQueries(): void { + #activeReconnectQueries(): Array<{ queryId: string; force: boolean }> { + const queryIds = new Map() for (const service of this.getState().values()) { // Materialization roots reconcile even with no subscribers — every local read // depends on their completeness, and events may have been missed while offline. if (service.materialized) { - this.#requestReconcile(service.materialized.queryId, { force: true }) + queryIds.set(service.materialized.queryId, true) } for (const query of service.queries.values()) { if (query.queryId === service.materialized?.queryId) continue @@ -2031,24 +2513,60 @@ export class QueryStore< (query.config.realtime !== 'disabled' || this.#reconnectQueryIds.has(query.queryId)) && this.#listenerCount(query.queryId) > 0 ) { - this.#requestReconcile(query.queryId) + queryIds.set(query.queryId, false) } } } + return [...queryIds].map(([queryId, force]) => ({ queryId, force })) } - #scheduleReconnectSweep(): void { + #refetchActiveQueries( + traceId: number | undefined, + queries: readonly { queryId: string; force: boolean }[], + ): void { + for (const query of queries) { + this.#requestReconcile(query.queryId, { + force: query.force, + ...(traceId === undefined ? {} : { causes: [{ kind: 'reconnect' as const, traceId }] }), + }) + } + } + + #scheduleReconnectSweep(traceId: number | undefined): void { if (this.#reconnectSweepTimer) return const [min, max] = this.#reconnectJitter const delay = min === max ? min : min + Math.floor(Math.random() * (max - min + 1)) if (delay === 0) { - this.#refetchActiveQueries() + const queries = this.#activeReconnectQueries() + this.#telemetry.emit({ + kind: 'reconnect:sweep', + ...(traceId === undefined ? {} : { traceId }), + phase: 'started', + delayMs: 0, + queryCount: queries.length, + }) + this.#refetchActiveQueries(traceId, queries) return } + this.#telemetry.emit({ + kind: 'reconnect:sweep', + ...(traceId === undefined ? {} : { traceId }), + phase: 'scheduled', + delayMs: delay, + }) + const timer = setTimeout(() => { this.#reconnectSweepTimer = null - this.#refetchActiveQueries() + const queries = this.#activeReconnectQueries() + this.#telemetry.emit({ + kind: 'reconnect:sweep', + ...(traceId === undefined ? {} : { traceId }), + phase: 'started', + delayMs: delay, + queryCount: queries.length, + }) + this.#refetchActiveQueries(traceId, queries) }, delay) ;(timer as { unref?: () => void }).unref?.() this.#reconnectSweepTimer = timer diff --git a/lib/core/queryTelemetry.ts b/lib/core/queryTelemetry.ts new file mode 100644 index 00000000..38927474 --- /dev/null +++ b/lib/core/queryTelemetry.ts @@ -0,0 +1,102 @@ +import { FigbirdEventEmitter, type FigbirdEvent, type TraceCause } from './events.js' +import type { ProcessedCacheEvent, QueryGraphRef } from './queryTypes.js' + +type TraceKind = TraceCause['kind'] + +const EMPTY_GRAPH = new Map() + +function graphRefKey(ref: QueryGraphRef): string { + return `${ref.operationId}\u0000${ref.runId}\u0000${ref.path}\u0000${ref.role ?? ''}` +} + +/** + * Owns the IDs and public event schema used for optional query observability. + * Cache and query state machines ask for optional causes only at lifecycle seams; + * when nobody is listening, no trace objects are retained by those machines. + */ +export class QueryTelemetry { + readonly #events = new FigbirdEventEmitter() + #nextTraceId = 1 + #nextFetchId = 1 + readonly #activeFetchGraphs = new Map>() + + get events(): FigbirdEventEmitter { + return this.#events + } + + get active(): boolean { + return this.#events.hasListeners + } + + emit(event: FigbirdEvent): void { + this.#events.emit(event) + } + + nextFetchId(): number { + return this.#nextFetchId++ + } + + nextTraceId(): number | undefined { + return this.active ? this.#nextTraceId++ : undefined + } + + beginGraph( + queryId: string, + refs: readonly QueryGraphRef[] | undefined, + ): ReadonlyMap { + if (!this.active) return EMPTY_GRAPH + const graph = new Map((refs ?? []).map(ref => [graphRefKey(ref), ref])) + this.#activeFetchGraphs.set(queryId, graph) + return graph + } + + attachGraph(queryId: string, ref: QueryGraphRef): void { + this.#activeFetchGraphs.get(queryId)?.set(graphRefKey(ref), ref) + } + + finishGraph(queryId: string, graph: ReadonlyMap): void { + if (this.#activeFetchGraphs.get(queryId) === graph) this.#activeFetchGraphs.delete(queryId) + } + + cause(kind: TraceKind): TraceCause | undefined { + if (!this.active) return undefined + const traceId = this.#nextTraceId++ + switch (kind) { + case 'realtime': + return { kind, traceId } + case 'reconnect': + return { kind, traceId } + case 'mutation': + return { kind, traceId } + case 'fetch-rebase': + return { kind, traceId } + case 'manual': + return { kind, traceId } + case 'subscription': + return { kind, traceId } + } + } + + mutationCause(mutationId: number): Extract | undefined { + if (!this.active) return undefined + return { kind: 'mutation', traceId: this.#nextTraceId++, mutationId } + } + + merge( + current: readonly TraceCause[] | undefined, + next: readonly TraceCause[] | undefined, + ): TraceCause[] | undefined { + if (!this.active) return undefined + const keyed = new Map() + for (const cause of [...(current ?? []), ...(next ?? [])]) keyed.set(cause.traceId, cause) + return keyed.size > 0 ? [...keyed.values()] : undefined + } + + fallbackCause(event: ProcessedCacheEvent): TraceCause | undefined { + if (event.mode === 'optimistic') return this.cause('mutation') + if (event.mode === 'local') return this.cause('manual') + if (event.source === 'realtime') return this.cause('realtime') + if (event.source === 'mutation') return this.cause('mutation') + return this.cause('fetch-rebase') + } +} diff --git a/lib/core/queryTypes.ts b/lib/core/queryTypes.ts index 6bf4f8b9..b93c3443 100644 --- a/lib/core/queryTypes.ts +++ b/lib/core/queryTypes.ts @@ -23,19 +23,44 @@ export interface Event { item: unknown } +export type TraceCause = + | { kind: 'realtime'; traceId: number } + | { kind: 'reconnect'; traceId: number } + | { kind: 'mutation'; traceId: number; mutationId?: number } + | { kind: 'fetch-rebase'; traceId: number } + | { kind: 'manual'; traceId: number } + | { kind: 'subscription'; traceId: number } + +/** + * Identifies the relational operation run and structural node that owns a fetch. + * This is execution metadata only: it must never participate in query identity. + */ +export interface QueryGraphRef { + operationId: string + runId: string + path: string + role?: 'junction' +} + +export interface QueryExecutionOptions { + staleTime?: number | undefined + graph?: QueryGraphRef | undefined +} + /** * Queued event for batch processing */ interface QueuedEventBase { serviceName: string type: EventType - items: unknown[] + item: unknown + cause?: TraceCause } /** Internal entity changes waiting at the store's atomic event boundary. */ export type QueuedEvent = - | (QueuedEventBase & { origin: 'authoritative' }) - | (QueuedEventBase & { origin: 'projection'; mutationLaneKey: string }) + | (QueuedEventBase & { mode: 'server'; source: 'realtime' | 'mutation' }) + | (QueuedEventBase & { mode: 'optimistic'; mutationLaneKey: string }) /** * A realtime event after it has been applied to the entity cache. Carries the @@ -49,17 +74,23 @@ interface ProcessedEventBase { previousItem: unknown | null /** Always defined — events whose item has no resolvable id are never applied. */ itemId: EntityKey + cause?: TraceCause } /** An optimistic entity change after cache application. */ export type ProcessedProjectionEvent = ProcessedEventBase & { - origin: 'projection' + mode: 'optimistic' mutationLaneKey: string } -/** An authoritative or optimistic entity change after cache application. */ -export type ProcessedRealtimeEvent = - (ProcessedEventBase & { origin: 'authoritative' }) | ProcessedProjectionEvent +/** A server, optimistic, or explicitly local entity change after cache application. */ +export type ProcessedServerEvent = ProcessedEventBase & { + mode: 'server' + source: 'realtime' | 'mutation' | 'fetch' +} + +export type ProcessedCacheEvent = + ProcessedServerEvent | ProcessedProjectionEvent | (ProcessedEventBase & { mode: 'local' }) export type QueryStatus = 'loading' | 'success' | 'error' diff --git a/lib/core/relationalFilters.ts b/lib/core/relationalFilters.ts index b8782614..983c4d27 100644 --- a/lib/core/relationalFilters.ts +++ b/lib/core/relationalFilters.ts @@ -1,7 +1,7 @@ import type { QueryAST } from './queryBuilder.js' import type { RelationshipDef, Schema } from './schema.js' import { resolveServicePath } from './schema.js' -import { entityKey, type ProcessedRealtimeEvent, type ServiceState } from './queryTypes.js' +import { entityKey, type ProcessedCacheEvent, type ServiceState } from './queryTypes.js' /** * Relational filters — dotted-path predicates over related entities, e.g. @@ -142,7 +142,7 @@ export function shouldRefetchRelationalFilterQuery item.serviceName === event.serviceName) if (!dep) return false diff --git a/lib/core/relationalQuery.ts b/lib/core/relationalQuery.ts index 89894ff6..204631de 100644 --- a/lib/core/relationalQuery.ts +++ b/lib/core/relationalQuery.ts @@ -7,9 +7,10 @@ import type { QueryRef } from './queryRef.js' import type { QueryLifecycleConfig } from './queryIdentity.js' import type { ProcessedProjectionEvent, - ProcessedRealtimeEvent, + ProcessedCacheEvent, QueryConfig, QueryDescriptor, + QueryGraphRef, ServiceState, } from './queryTypes.js' import { @@ -66,7 +67,8 @@ export interface RelationalQueryHost | undefined } queryStore: { - subscribeToProcessedEvents(fn: (event: ProcessedRealtimeEvent) => void): () => void + isObservabilityActive(): boolean + subscribeToProcessedEvents(fn: (event: ProcessedCacheEvent) => void): () => void subscribeToProjectionSettlements(fn: (event: ProcessedProjectionEvent) => void): () => void ensureRealtimeSubscription(serviceName: string): void reapplyQuery(queryId: string, mutationLaneKeys: ReadonlySet): void @@ -167,6 +169,12 @@ export interface InspectedRelationalQuery { service: string ast: QueryAST pagination?: InspectedPagination + /** Current assembled result when the relational query has settled successfully. */ + data?: unknown + /** Mounted consumers, excluding internal prepare/prefetch pins. */ + subscriberCount?: number + prefetchCount?: number + prepareCount?: number nodes: Array<{ path: string role?: 'junction' @@ -199,6 +207,9 @@ export class RelationalQueryRef< #ast: QueryAST #schema: S #queryId: string + #nextGraphRun = 1 + #graphRunId: string | null = null + #graphCompletionScheduled = false // The root data source — a single find/get query, or a page accumulator for // `.paginate()` builders. `#pagedRoot` aliases the same object when paginated so @@ -210,8 +221,10 @@ export class RelationalQueryRef< // "comments.reactions"). A relation is "synced" once its entry exists here — even a // kind:'empty' entry counts, so loading detection doesn't hang on empty relations. #relationSubs: Map> = new Map() - #listeners: Set<(state: RelationalQueryState) => void> = new Set() - #listenerStaleTimes: Map<(state: RelationalQueryState) => void, number> = new Map() + #listeners: Map< + (state: RelationalQueryState) => void, + { staleTime: number; source: 'subscriber' | 'prepare' | 'prefetch' } + > = new Map() #processedEventUnsub: (() => void) | null = null #relationalFilterRefetchQueued = false // Strictest active subscriber freshness tolerance — applied to newly-created @@ -251,6 +264,10 @@ export class RelationalQueryRef< #resolveSuspense: (() => void) | null = null #rejectSuspense: ((error: Error) => void) | null = null #suspenseSettled = false + // A Suspense read materializes and fetches the graph before React can commit its + // subscription. The first committed listener claims that fetch instead of treating + // the just-resolved data as stale and immediately repeating the whole graph. + #coldStartAwaitingSubscriber = false // Relation keys that already produced a fan-out warning — warn once per relation, // not on every sync pass. @@ -317,12 +334,18 @@ export class RelationalQueryRef< break } } + const snapshot = this.getSnapshot() + const listenerMetadata = [...this.#listeners.values()] return { key: this.#queryId, ...(this.#name ? { name: this.#name } : {}), service: this.#ast.service, ast: this.#ast, ...(this.#pagedRoot ? { pagination: this.#pagedRoot.inspectPagination() } : {}), + ...(snapshot.status === 'success' ? { data: snapshot.data } : {}), + subscriberCount: listenerMetadata.filter(({ source }) => source === 'subscriber').length, + prefetchCount: listenerMetadata.filter(({ source }) => source === 'prefetch').length, + prepareCount: listenerMetadata.filter(({ source }) => source === 'prepare').length, nodes, } } @@ -371,25 +394,37 @@ export class RelationalQueryRef< */ subscribe( fn: (state: RelationalQueryState) => void, - options?: { staleTime?: number | undefined }, + options?: { + staleTime?: number | undefined + /** @internal Identifies non-UI pins in devtools. */ + source?: 'subscriber' | 'prepare' | 'prefetch' + }, ): () => void { const staleTime = options?.staleTime ?? 0 - this.#listeners.add(fn) - this.#listenerStaleTimes.set(fn, staleTime) + const claimsColdStart = this.#coldStartAwaitingSubscriber + this.#listeners.set(fn, { staleTime, source: options?.source ?? 'subscriber' }) this.#staleTime = this.#currentStaleTime() if (!this.#root) { this.#setupRoot() } else { this.#root.setStaleTime(this.#staleTime) - this.#ensureFresh(staleTime) + if (claimsColdStart) { + // React StrictMode may subscribe, unsubscribe, and resubscribe in one turn. + // Keep the claim window open through that commit so neither subscription + // mistakes the Suspense fetch for stale data. + queueMicrotask(() => { + this.#coldStartAwaitingSubscriber = false + }) + } else { + this.#ensureFresh(staleTime) + } } // Don't call fn synchronously - useSyncExternalStore will call getSnapshot() instead return () => { this.#listeners.delete(fn) - this.#listenerStaleTimes.delete(fn) this.#staleTime = this.#currentStaleTime() this.#root?.setStaleTime(this.#staleTime) @@ -405,19 +440,41 @@ export class RelationalQueryRef< } #currentStaleTime(): number { - if (this.#listenerStaleTimes.size === 0) return 0 + if (this.#listeners.size === 0) return 0 let staleTime = Infinity - for (const value of this.#listenerStaleTimes.values()) { - staleTime = Math.min(staleTime, value) + for (const listener of this.#listeners.values()) { + staleTime = Math.min(staleTime, listener.staleTime) } return staleTime } + #beginGraphRun(): string | null { + if (!this.#host.queryStore.isObservabilityActive()) { + this.#graphRunId = null + return null + } + const runId = `${this.#queryId}:${this.#nextGraphRun++}` + this.#graphRunId = runId + return runId + } + + #graph(path: string, role?: QueryGraphRef['role']): QueryGraphRef | undefined { + if (!this.#graphRunId) return undefined + return { + operationId: this.#queryId, + runId: this.#graphRunId, + path, + ...(role ? { role } : {}), + } + } + #ensureFresh(staleTime: number): void { - this.#root?.ensureFresh(staleTime) + if (!this.#graphRunId) this.#beginGraphRun() + this.#root?.ensureFresh(staleTime, this.#graph('(root)')) for (const sub of this.#relationSubs.values()) { this.#ensureRelationSubFresh(sub, staleTime) } + this.#scheduleGraphRunCompletion(this.getSnapshot()) } #ensureRelationSubFresh(sub: RelationSub, staleTime: number): void { @@ -425,16 +482,28 @@ export class RelationalQueryRef< case 'empty': return case 'fanIn': + sub.queryRef.ensureFresh({ staleTime, graph: this.#graph(this.#pathForSub(sub)) }) + return case 'junction': - sub.queryRef.ensureFresh({ staleTime }) + sub.queryRef.ensureFresh({ + staleTime, + graph: this.#graph(this.#pathForSub(sub), 'junction'), + }) return case 'perParent': for (const child of sub.children.values()) { - child.queryRef.ensureFresh({ staleTime }) + child.queryRef.ensureFresh({ staleTime, graph: this.#graph(this.#pathForSub(sub)) }) } } } + #pathForSub(target: RelationSub): string { + for (const [path, sub] of this.#relationSubs) { + if (sub === target) return path.endsWith('#dest') ? path.slice(0, -'#dest'.length) : path + } + return '(unknown)' + } + #scheduleCleanup(): void { if (this.#cleanupScheduled) return this.#cleanupScheduled = true @@ -795,36 +864,48 @@ export class RelationalQueryRef< * server changes invisible (especially for snapshot queries). */ refetch(): void { - this.#root?.refetch() + this.#beginGraphRun() + this.#root?.refetch(this.#graph('(root)')) const seen = new Set>() for (const sub of this.#relationSubs.values()) { switch (sub.kind) { case 'empty': break case 'fanIn': + if (!seen.has(sub.queryRef)) { + seen.add(sub.queryRef) + sub.queryRef.refetch({ graph: this.#graph(this.#pathForSub(sub)) }) + } + break case 'junction': if (!seen.has(sub.queryRef)) { seen.add(sub.queryRef) - sub.queryRef.refetch() + sub.queryRef.refetch({ + graph: this.#graph(this.#pathForSub(sub), 'junction'), + }) } break case 'perParent': for (const child of sub.children.values()) { if (seen.has(child.queryRef)) continue seen.add(child.queryRef) - child.queryRef.refetch() + child.queryRef.refetch({ graph: this.#graph(this.#pathForSub(sub)) }) } break } } + this.#scheduleGraphRunCompletion(this.getSnapshot()) } /** Append the next page (paginated queries only; no-op otherwise). */ loadMore(): void { - this.#pagedRoot?.loadMore() + this.#beginGraphRun() + this.#pagedRoot?.loadMore(this.#graph('(root)')) + this.#scheduleGraphRunCompletion(this.getSnapshot()) } #setupRoot(): void { + this.#beginGraphRun() this.#subscribeToRelationalFilterInvalidations() const serviceName = resolveServicePath(this.#schema, this.#ast.service) @@ -852,18 +933,19 @@ export class RelationalQueryRef< !this.#ast.snapshot && cursorQueryCanKeepPrefix(this.#ast.query) ? { - subscribe: (fn: (event: ProcessedRealtimeEvent) => void) => + subscribe: (fn: (event: ProcessedCacheEvent) => void) => this.#host.queryStore.subscribeToProcessedEvents(event => { if (event.serviceName === serviceName) fn(event) }), - canKeepPrefix: (event: ProcessedRealtimeEvent) => + canKeepPrefix: (event: ProcessedCacheEvent) => !this.#ast.server && (event.type === 'patched' || event.type === 'updated') && event.previousItem !== null && cursorQueryInputsUnchanged(this.#ast.query, event.previousItem, event.item), } : undefined - this.#pagedRoot = new PagedQueryRoot({ + const rootGraph = this.#graph('(root)') + this.#pagedRoot = new PagedQueryRoot({ pageSize, includeTotal: Boolean(this.#ast.includeTotal), sequential, @@ -875,6 +957,7 @@ export class RelationalQueryRef< ? 'reconcile' : 'merge-or-reconcile', staleTime: this.#staleTime, + ...(rootGraph ? { graph: rootGraph } : {}), makePageRef: (pageIndex, after) => this.#query( sequential @@ -917,6 +1000,7 @@ export class RelationalQueryRef< ...(cursorRealtime ? { cursorRealtime } : {}), }) this.#root = this.#pagedRoot + this.#scheduleGraphRunCompletion(this.getSnapshot()) return } @@ -935,7 +1019,8 @@ export class RelationalQueryRef< } : { serviceName, method: 'find', params: { query: this.#ast.query } } - this.#root = new SingleQueryRoot({ + const rootGraph = this.#graph('(root)') + this.#root = new SingleQueryRoot({ queryRef: this.#query(rootDesc, { realtime: this.#realtimeMode, fetchPolicy: 'swr', @@ -950,7 +1035,9 @@ export class RelationalQueryRef< onRows, onChange, staleTime: this.#staleTime, + ...(rootGraph ? { graph: rootGraph } : {}), }) + this.#scheduleGraphRunCompletion(this.getSnapshot()) } /** @@ -1074,6 +1161,7 @@ export class RelationalQueryRef< data => this.#syncNested(data, relAST, nestedKey), () => this.#notifyListeners(), this.#staleTime, + this.#graph(nestedKey), ) this.#relationSubs.set(subKey, { kind: 'fanIn', sourceKey, queryRef, unsub }) @@ -1138,7 +1226,7 @@ export class RelationalQueryRef< } this.#notifyListeners() }, - { staleTime: this.#staleTime }, + { staleTime: this.#staleTime, graph: this.#graph(key) }, ) entry.children.set(sourceValueKey(sourceValue), { queryRef, unsub, sourceValue }) @@ -1226,6 +1314,7 @@ export class RelationalQueryRef< junctionItems => this.#syncFanInRelation(junctionItems, relDef, relAST, `${key}#dest`, key), () => this.#notifyListeners(), this.#staleTime, + this.#graph(key, 'junction'), ) this.#relationSubs.set(key, { kind: 'junction', @@ -1381,7 +1470,7 @@ export class RelationalQueryRef< this.#host.queryStore.ensureRealtimeSubscription(dependency.serviceName) } - const affectsFilter = (event: ProcessedRealtimeEvent) => + const affectsFilter = (event: ProcessedCacheEvent) => shouldRefetchRelationalFilterQuery( this.#schema, this.#host.getState(), @@ -1393,8 +1482,9 @@ export class RelationalQueryRef< const unsubscribeEvents = this.#host.queryStore.subscribeToProcessedEvents(event => { if (!affectsFilter(event)) return - if (event.origin === 'projection') { - const laneKeys = new Set([event.mutationLaneKey]) + if (event.mode !== 'server') { + const laneKeys = + event.mode === 'optimistic' ? new Set([event.mutationLaneKey]) : new Set() for (const queryId of this.#root?.queryIds() ?? []) { this.#host.queryStore.reapplyQuery(queryId, laneKeys) } @@ -1446,9 +1536,22 @@ export class RelationalQueryRef< const snapshot = this.getSnapshot() this.#settleSuspense(snapshot) // Notify all listeners with the cached snapshot - for (const listener of this.#listeners) { + for (const listener of this.#listeners.keys()) { listener(snapshot) } + this.#scheduleGraphRunCompletion(snapshot) + } + + #scheduleGraphRunCompletion(snapshot: RelationalQueryState): void { + if (!this.#graphRunId || snapshot.isFetching || this.#graphCompletionScheduled) return + const runId = this.#graphRunId + this.#graphCompletionScheduled = true + queueMicrotask(() => { + this.#graphCompletionScheduled = false + if (this.#graphRunId !== runId) return + const current = this.getSnapshot() + if (!current.isFetching) this.#graphRunId = null + }) } /** @@ -1498,6 +1601,7 @@ export class RelationalQueryRef< // Ensure the underlying queries are materialised — callers may reach this method via // the hook before subscribe() runs in some orderings. if (!this.#root) { + this.#coldStartAwaitingSubscriber = this.#listeners.size === 0 this.#setupRoot() } // If we've already reached a terminal state synchronously, settle immediately. @@ -1524,7 +1628,6 @@ export class RelationalQueryRef< this.#lastRelationData.clear() this.#lastRelationAssembly = null this.#lastGatherWasPartial = false - this.#listenerStaleTimes.clear() this.#staleTime = 0 // Evict from the figbird-level cache so a subsequent query rebuilds a fresh ref. // Reset the suspense promise state too — a fresh cold-start will need a fresh promise. @@ -1532,6 +1635,7 @@ export class RelationalQueryRef< this.#resolveSuspense = null this.#rejectSuspense = null this.#suspenseSettled = false + this.#coldStartAwaitingSubscriber = false this.#onEvict?.() } } diff --git a/lib/core/windowMaintenance.ts b/lib/core/windowMaintenance.ts index 9b43be10..2015124a 100644 --- a/lib/core/windowMaintenance.ts +++ b/lib/core/windowMaintenance.ts @@ -18,7 +18,7 @@ import { type ItemId, queryOfParams, type EventType, - type ProcessedRealtimeEvent, + type ProcessedCacheEvent, type Query, type QueryState, type QueuedEvent, @@ -164,65 +164,66 @@ export function applyEventsToService({ events: QueuedEvent[] getId: (item: unknown) => ItemId | undefined isItemStale: (curr: unknown, next: unknown) => boolean - processedEvents: ProcessedRealtimeEvent[] + processedEvents: ProcessedCacheEvent[] }): void { for (const event of events) { - const { type, items } = event - for (const item of items) { - if (type === 'created') { - const incomingId = getId(item) - if (incomingId !== undefined) { - const itemId = entityKey(incomingId) - const previousItem = service.entities.get(itemId) ?? null + const { type, item, cause } = event + if (type === 'created') { + const incomingId = getId(item) + if (incomingId !== undefined) { + const itemId = entityKey(incomingId) + const previousItem = service.entities.get(itemId) ?? null + service.entities.set(itemId, item) + processedEvents.push({ + serviceName, + type, + item, + previousItem, + itemId, + ...(cause === undefined ? {} : { cause }), + ...(event.mode === 'optimistic' + ? { mode: event.mode, mutationLaneKey: event.mutationLaneKey } + : { mode: event.mode, source: event.source }), + }) + } + } else if (type === 'updated' || type === 'patched') { + const incomingId = getId(item) + if (incomingId !== undefined) { + const itemId = entityKey(incomingId) + const currItem = service.entities.get(itemId) + if (event.mode !== 'server' || !currItem || !isItemStale(currItem, item)) { service.entities.set(itemId, item) processedEvents.push({ serviceName, type, item, - previousItem, - itemId, - ...(event.origin === 'projection' - ? { origin: event.origin, mutationLaneKey: event.mutationLaneKey } - : { origin: event.origin }), - }) - } - } else if (type === 'updated' || type === 'patched') { - const incomingId = getId(item) - if (incomingId !== undefined) { - const itemId = entityKey(incomingId) - const currItem = service.entities.get(itemId) - if (event.origin === 'projection' || !currItem || !isItemStale(currItem, item)) { - service.entities.set(itemId, item) - processedEvents.push({ - serviceName, - type, - item, - previousItem: currItem ?? null, - itemId, - ...(event.origin === 'projection' - ? { origin: event.origin, mutationLaneKey: event.mutationLaneKey } - : { origin: event.origin }), - }) - } - } - } else if (type === 'removed') { - const incomingId = getId(item) - if (incomingId !== undefined) { - const itemId = entityKey(incomingId) - const previousItem = service.entities.get(itemId) ?? null - service.entities.delete(itemId) - processedEvents.push({ - serviceName, - type, - item, - previousItem, + previousItem: currItem ?? null, itemId, - ...(event.origin === 'projection' - ? { origin: event.origin, mutationLaneKey: event.mutationLaneKey } - : { origin: event.origin }), + ...(cause === undefined ? {} : { cause }), + ...(event.mode === 'optimistic' + ? { mode: event.mode, mutationLaneKey: event.mutationLaneKey } + : { mode: event.mode, source: event.source }), }) } } + } else if (type === 'removed') { + const incomingId = getId(item) + if (incomingId !== undefined) { + const itemId = entityKey(incomingId) + const previousItem = service.entities.get(itemId) ?? null + service.entities.delete(itemId) + processedEvents.push({ + serviceName, + type, + item, + previousItem, + itemId, + ...(cause === undefined ? {} : { cause }), + ...(event.mode === 'optimistic' + ? { mode: event.mode, mutationLaneKey: event.mutationLaneKey } + : { mode: event.mode, source: event.source }), + }) + } } } } @@ -247,19 +248,20 @@ export function diffCompleteSet({ nextItemIds: Set /** Items changed by events during the fetch; those events already own their diff. */ ignoredItemIds?: ReadonlySet -}): ProcessedRealtimeEvent[] { - const events: ProcessedRealtimeEvent[] = [] +}): ProcessedCacheEvent[] { + const events: ProcessedCacheEvent[] = [] for (const [itemId, previousItem] of previousEntities) { if (ignoredItemIds?.has(itemId)) continue if (!nextItemIds.has(itemId)) { service.entities.delete(itemId) events.push({ - origin: 'authoritative', + mode: 'server', serviceName, type: 'removed', item: previousItem, previousItem, itemId, + source: 'fetch', }) } } @@ -269,21 +271,23 @@ export function diffCompleteSet({ const previousItem = previousEntities.get(itemId) if (!previousItem) { events.push({ - origin: 'authoritative', + mode: 'server', serviceName, type: 'created', item, previousItem: null, itemId, + source: 'fetch', }) } else if (previousItem !== item) { events.push({ - origin: 'authoritative', + mode: 'server', serviceName, type: 'updated', item, previousItem, itemId, + source: 'fetch', }) } } @@ -304,7 +308,7 @@ type QueryEventApplication = 'applied' | 'reconcile' | 'ignored' function applyVisibleEventEffect( context: QueryEventContext, queryId: string, - event: ProcessedRealtimeEvent, + event: ProcessedCacheEvent, effect: 'remove' | 'replace', ): boolean { const { service, touch, getId, itemRemoved } = context @@ -385,7 +389,7 @@ export function applyVisibleEventToQuery({ }: { service: ServiceState queryId: string - event: ProcessedRealtimeEvent + event: ProcessedCacheEvent touch: (queryId: string) => void getId: (item: unknown) => ItemId | undefined itemRemoved: (meta: TMeta) => TMeta @@ -408,7 +412,7 @@ export function applyVisibleEventToQuery({ function applyMergeEventToQuery( context: QueryEventContext, queryId: string, - event: ProcessedRealtimeEvent, + event: ProcessedCacheEvent, ): QueryEventApplication { const { service, touch, getId, itemAdded, itemRemoved, defaultSort } = context const query = service.queries.get(queryId) @@ -499,16 +503,18 @@ export function updateQueriesFromEvents({ itemAdded, itemRemoved, serverMaintainedQueriesToRefetch, + onEffect, excludeQueryId, defaultSort, }: { service: ServiceState - appliedItems: readonly ProcessedRealtimeEvent[] + appliedItems: readonly ProcessedCacheEvent[] touch: (queryId: string) => void getId: (item: unknown) => ItemId | undefined itemAdded: (meta: TMeta) => TMeta itemRemoved: (meta: TMeta) => TMeta serverMaintainedQueriesToRefetch: Set + onEffect?: (queryId: string, effect: 'merged' | 'reconcile') => void /** A query whose state already reflects the applied items (e.g. the fetch they came from). */ excludeQueryId?: string /** The backend's implicit order for queries without `$sort` — see QueryStore options. */ @@ -526,8 +532,12 @@ export function updateQueriesFromEvents({ for (const [queryId, query] of service.queries) { if (queryId === excludeQueryId || query.config.realtime !== 'merge') continue if (query.desc.method === 'find' && query.config.fetchPolicy === 'network-only') continue - if (applyMergeEventToQuery(context, queryId, event) === 'reconcile') { + const result = applyMergeEventToQuery(context, queryId, event) + if (result === 'reconcile') { serverMaintainedQueriesToRefetch.add(queryId) + onEffect?.(queryId, 'reconcile') + } else if (result === 'applied') { + onEffect?.(queryId, 'merged') } } } @@ -634,7 +644,7 @@ export function replayFetchedQueryFromEvents({ }: { service: ServiceState queryId: string - events: readonly ProcessedRealtimeEvent[] + events: readonly ProcessedCacheEvent[] touch: (queryId: string) => void getId: (item: unknown) => ItemId | undefined itemAdded: (meta: TMeta) => TMeta diff --git a/lib/devtools/CacheTab.tsx b/lib/devtools/CacheTab.tsx new file mode 100644 index 00000000..c6087e53 --- /dev/null +++ b/lib/devtools/CacheTab.tsx @@ -0,0 +1,834 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type MouseEvent as ReactMouseEvent, + type RefObject, +} from 'react' +import { + compactJson, + estimateSerializedBytes, + formatAge, + formatBytes, + formatClock, + prettyJson, +} from './format.js' +import type { DevtoolsCacheEntity, DevtoolsCacheService } from './collector.js' +import { JsonViewer } from './JsonViewer.js' +import type { DevtoolsModel } from './model.js' +import { + Badge, + ColumnResizeHandle, + DetailBlock, + DetailSection, + DetailStat, + DetailStats, + DetailsPane, + buttonStyle, + useDetailsPaneWidth, + useResizableColumns, + useDevtoolsTheme, + type DevtoolsColors, +} from './ui.js' + +const CACHE_COLUMNS = [ + { label: 'service', width: 140, minWidth: 95 }, + { label: 'entity', width: 150, minWidth: 90 }, + { label: 'value', width: 310, minWidth: 160 }, + { label: 'est. size', width: 90, minWidth: 72 }, + { label: 'memberships', width: 110, minWidth: 76 }, + { label: 'last changed', width: 150, minWidth: 110 }, +] as const + +const CACHE_SIZE_DESCRIPTION = + 'Estimated UTF-8 size of the JSON-serialized cache value. This is not JavaScript heap usage.' +const CACHE_ROW_HEIGHT = 29 +const CACHE_HEADER_HEIGHT = 30 +const CACHE_ROW_OVERSCAN = 16 +const CACHE_SERVICE_SIDEBAR_WIDTH = 280 +const CACHE_SERVICE_GRID = 'minmax(0, 1fr) 32px 50px 14px' + +export interface DevtoolsCacheEditor { + update( + serviceName: string, + itemId: string | number, + item: unknown, + ): Promise<{ ok: boolean; error?: string; traceId?: number }> +} + +export function CacheTab({ + services, + model, + filter, + editor, + onViewTrace, + onViewQuery, + requestedEntity, + onRequestedEntityHandled, +}: { + services: DevtoolsCacheService[] + model: DevtoolsModel + filter: string + editor?: DevtoolsCacheEditor + onViewTrace?: (traceId: number) => void + onViewQuery?: (queryId: string) => void + requestedEntity?: { serviceName: string; itemId: string | number } | null + onRequestedEntityHandled?: () => void +}) { + const { colors, styles } = useDevtoolsTheme() + const orderedServices = useMemo( + () => [...services].sort((a, b) => a.serviceName.localeCompare(b.serviceName)), + [services], + ) + const [serviceName, setServiceName] = useState(null) + const [selectedKey, setSelectedKey] = useState(null) + const cacheScrollRef = useRef(null) + const [columnWidths, onColumnResizeStart] = useResizableColumns(CACHE_COLUMNS) + const [detailsWidth, onDetailsResizeStart] = useDetailsPaneWidth() + const tableWidth = columnWidths.reduce((sum, width) => sum + width, 0) + const cacheRows = useMemo( + () => + orderedServices.flatMap(service => + service.entities.map(entity => ({ + service, + entity, + preview: compactJson(entity.value), + estimatedSize: estimateSerializedBytes(entity.value), + })), + ), + [orderedServices], + ) + const serviceSizes = useMemo( + () => + cacheRows.reduce((sizes, row) => { + sizes.set( + row.service.serviceName, + (sizes.get(row.service.serviceName) ?? 0) + (row.estimatedSize ?? 0), + ) + return sizes + }, new Map()), + [cacheRows], + ) + const totalSize = [...serviceSizes.values()].reduce((total, size) => total + size, 0) + const totalEntities = orderedServices.reduce((count, item) => count + item.entities.length, 0) + + useEffect(() => { + if (!serviceName || orderedServices.some(service => service.serviceName === serviceName)) return + setServiceName(null) + setSelectedKey(null) + }, [orderedServices, serviceName]) + + useEffect(() => { + if (!requestedEntity) return + const service = orderedServices.find(item => item.serviceName === requestedEntity.serviceName) + if (!service) { + onRequestedEntityHandled?.() + return + } + const entity = service.entities.find(item => item.id === String(requestedEntity.itemId)) + setServiceName(service.serviceName) + setSelectedKey(entity ? cacheEntityKey(service.serviceName, entity.id) : null) + onRequestedEntityHandled?.() + }, [onRequestedEntityHandled, orderedServices, requestedEntity]) + + const normalizedFilter = filter.trim().toLowerCase() + const entries = useMemo( + () => + cacheRows + .filter(row => serviceName === null || row.service.serviceName === serviceName) + .filter(({ service, entity, preview }) => { + if (!normalizedFilter) return true + return [ + service.serviceName, + entity.id, + preview, + entity.queryIds.join(' '), + entity.lastChange?.source ?? 'initial snapshot', + entity.lastChange?.type ?? '', + ] + .join(' ') + .toLowerCase() + .includes(normalizedFilter) + }) + .sort( + (a, b) => + a.service.serviceName.localeCompare(b.service.serviceName) || + a.entity.id.localeCompare(b.entity.id, undefined, { numeric: true }), + ), + [cacheRows, normalizedFilter, serviceName], + ) + const virtualRows = useVirtualCacheRows( + entries.length, + cacheScrollRef, + `${serviceName ?? '*'}\u0000${normalizedFilter}`, + ) + const visibleEntries = entries.slice(virtualRows.start, virtualRows.end) + const selected = entries.find( + ({ service, entity }) => cacheEntityKey(service.serviceName, entity.id) === selectedKey, + ) + + useEffect(() => { + if (!selectedKey) return + const index = entries.findIndex( + ({ service, entity }) => cacheEntityKey(service.serviceName, entity.id) === selectedKey, + ) + const scroll = cacheScrollRef.current + if (index < 0 || !scroll || scroll.clientHeight === 0) return + const top = CACHE_HEADER_HEIGHT + index * CACHE_ROW_HEIGHT + const bottom = top + CACHE_ROW_HEIGHT + if (top < scroll.scrollTop + CACHE_HEADER_HEIGHT) { + scroll.scrollTop = Math.max(0, top - CACHE_HEADER_HEIGHT) + } else if (bottom > scroll.scrollTop + scroll.clientHeight) { + scroll.scrollTop = bottom - scroll.clientHeight + } + }, [entries, selectedKey]) + + return ( +
+ +
+ {orderedServices.length === 0 ? ( +
No entities cached yet.
+ ) : ( + + + {CACHE_COLUMNS.map((column, index) => ( + + ))} + + + + {CACHE_COLUMNS.map((column, index) => ( + + ))} + + + + {entries.length === 0 ? ( + + + + ) : null} + {virtualRows.paddingTop > 0 ? ( + + ) : null} + {visibleEntries.map(({ service, entity, preview, estimatedSize }) => { + const key = cacheEntityKey(service.serviceName, entity.id) + const isSelected = key === selectedKey + return ( + setSelectedKey(key)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setSelectedKey(key) + } + }} + style={{ + cursor: 'pointer', + outline: 'none', + height: CACHE_ROW_HEIGHT, + background: isSelected ? colors.activeButtonBg : undefined, + boxShadow: isSelected ? `inset 3px 0 ${colors.blue}` : undefined, + }} + > + + + + + + + + ) + })} + {virtualRows.paddingBottom > 0 ? ( + + ) : null} + +
+ {column.label} + onColumnResizeStart(index, event)} + /> +
+ No matching cached entities. +
+ {service.serviceName} + + #{entity.id} + + + {preview} + + + + {estimatedSize === null ? '—' : formatBytes(estimatedSize)} + + + {entity.queryIds.length === 0 ? ( + unreferenced + ) : ( + `${entity.queryIds.length} ${entity.queryIds.length === 1 ? 'query' : 'queries'}` + )} + + {entity.lastChange ? ( + + + {entity.lastChange.source} + + + {formatAge(Date.now() - entity.lastChange.wallAt)} ago + + + ) : ( + initial snapshot + )} +
+ )} +
+ {selected ? ( + setSelectedKey(null)} + {...(editor ? { editor } : {})} + {...(onViewTrace ? { onViewTrace } : {})} + {...(onViewQuery ? { onViewQuery } : {})} + /> + ) : null} +
+ ) +} + +function useVirtualCacheRows( + count: number, + scrollRef: RefObject, + resetKey: string, +): { start: number; end: number; paddingTop: number; paddingBottom: number } { + const [range, setRange] = useState({ start: 0, end: Math.min(count, 64) }) + + useLayoutEffect(() => { + const scroll = scrollRef.current + if (scroll) scroll.scrollTop = 0 + }, [resetKey, scrollRef]) + + useLayoutEffect(() => { + const scroll = scrollRef.current + if (!scroll) { + setRange({ start: 0, end: Math.min(count, 64) }) + return + } + const ownerWindow = scroll.ownerDocument.defaultView + let frame: number | null = null + const measure = () => { + frame = null + const viewportHeight = scroll.clientHeight || CACHE_ROW_HEIGHT * 48 + const bodyScrollTop = Math.max(0, scroll.scrollTop - CACHE_HEADER_HEIGHT) + const start = Math.max(0, Math.floor(bodyScrollTop / CACHE_ROW_HEIGHT) - CACHE_ROW_OVERSCAN) + const end = Math.min( + count, + Math.ceil((bodyScrollTop + viewportHeight) / CACHE_ROW_HEIGHT) + CACHE_ROW_OVERSCAN, + ) + setRange(current => + current.start === start && current.end === end ? current : { start, end }, + ) + } + const scheduleMeasure = () => { + if (frame !== null) return + if (ownerWindow?.requestAnimationFrame) { + frame = ownerWindow.requestAnimationFrame(measure) + } else { + measure() + } + } + + measure() + scroll.addEventListener('scroll', scheduleMeasure, { passive: true }) + const observer = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleMeasure) + observer?.observe(scroll) + return () => { + scroll.removeEventListener('scroll', scheduleMeasure) + observer?.disconnect() + if (frame !== null) ownerWindow?.cancelAnimationFrame(frame) + } + }, [count, resetKey, scrollRef]) + + return { + ...range, + paddingTop: range.start * CACHE_ROW_HEIGHT, + paddingBottom: Math.max(0, count - range.end) * CACHE_ROW_HEIGHT, + } +} + +function CacheTableSpacer({ height }: { height: number }) { + return ( + + + + ) +} + +function CacheEntityDetails({ + service, + entity, + model, + width, + onResizeStart, + onClose, + editor, + onViewTrace, + onViewQuery, +}: { + service: DevtoolsCacheService + entity: DevtoolsCacheEntity + model: DevtoolsModel + width: number + onResizeStart: (event: ReactMouseEvent) => void + onClose: () => void + editor?: DevtoolsCacheEditor + onViewTrace?: (traceId: number) => void + onViewQuery?: (queryId: string) => void +}) { + const { colors, styles } = useDevtoolsTheme() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(() => prettyJson(entity.value)) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ tone: 'green' | 'red'; text: string } | null>(null) + const [undoValue, setUndoValue] = useState(null) + const queryLabels = entity.queryIds.map(queryId => ({ + queryId, + ...queryMembership(model, queryId), + })) + + const update = async (value: unknown, rememberUndo: boolean) => { + if (!editor) return + setSaving(true) + setMessage(null) + try { + const result = await editor.update(service.serviceName, entity.id, value) + if (!result.ok) { + setMessage({ tone: 'red', text: result.error ?? 'Cache edit failed' }) + return + } + if (rememberUndo) setUndoValue(entity.value) + else setUndoValue(null) + setEditing(false) + setMessage({ tone: 'green', text: 'Applied in memory. No server request was sent.' }) + } catch (error) { + setMessage({ + tone: 'red', + text: error instanceof Error ? error.message : 'Cache edit failed', + }) + } finally { + setSaving(false) + } + } + + return ( + + + + + +
+ {entity.lastChange ? {entity.lastChange.type} : null} + + {entity.queryIds.length} query{' '} + {entity.queryIds.length === 1 ? 'membership' : 'memberships'} + + + {formatEstimatedSize(entity.value)} + + + {editor && !editing ? ( + + ) : null} +
+ + {message ? ( +
+ {message.text} + {undoValue !== null && editor ? ( + + ) : null} +
+ ) : null} + + {editing ? ( + +