import type * as otel from '@opentelemetry/api' import { isNotNil } from '@livestore/utils' import { Equal, Hash, Predicate } from '@livestore/utils/effect' import * as RG from '../reactive.ts' import type { QueryDebugInfo, RefreshReason } from '../store/store-types.ts' import type { Store } from '../store/store.ts' import type { StackInfo } from '../utils/stack-info.ts' export type ReactivityGraph = RG.ReactiveGraph export const makeReactivityGraph = (): ReactivityGraph => new RG.ReactiveGraph() export type ReactivityGraphContext = { store: Store /** Maps from the hash of the query definition to the RcRef of the query */ defRcMap: Map>> /** Back-reference to the reactivity graph for convenience */ reactivityGraph: WeakRef otelTracer: otel.Tracer rootOtelContext: otel.Context effectsWrapper: (run: () => void) => void } export type GetResult> = TQuery extends LiveQuery ? TResult : TQuery extends LiveQueryDef ? TResult : TQuery extends SignalDef ? TResult : unknown let queryIdCounter = 0 /** * A signal definition representing ephemeral, local-only reactive state. * * `SignalDef` is the type returned by {@link signal}. It's a blueprint for creating * signal instances—the actual instance is created when you use the definition with * a Store via `store.query()` or `store.setSignal()`. * * @typeParam T - The type of value the signal holds */ export interface SignalDef extends LiveQueryDef { _tag: 'signal-def' /** The initial value used when the signal is first created */ defaultValue: T /** Unique identifier for caching and deduplication */ hash: string /** Human-readable label for debugging and devtools */ label: string /** Creates a reference-counted signal instance bound to a Store's reactivity graph */ make: (ctx: ReactivityGraphContext) => RcRef> [Equal.symbol](that: SignalDef): boolean [Hash.symbol](): number } /** * Interface for a live signal instance. * * This represents an active signal bound to a Store's reactivity graph. * Use `store.setSignal()` to update values and `store.query()` to read them. * * @typeParam T - The type of value the signal holds */ export interface ISignal extends LiveQuery { _tag: 'signal' reactivityGraph: ReactivityGraph /** The underlying reactive reference in the graph */ ref: RG.Ref /** Sets the signal's value (prefer using `store.setSignal()` instead) */ set: (value: T) => void /** Gets the signal's current value (prefer using `store.query()` instead) */ get: () => T /** Removes the signal from the reactivity graph */ destroy: () => void } export const TypeId = Symbol.for('LiveQuery') export type TypeId = typeof TypeId /** * A reference-counted wrapper around a LiveQuery or Signal instance. * * LiveStore uses reference counting to manage query lifecycle. When multiple * components or subscriptions use the same query definition, they share a single * instance. The instance is destroyed when the last reference is released. * * You typically don't interact with `RcRef` directly—it's used internally by * hooks like `useQuery` and `useQueryRef`. */ export interface RcRef { /** Current reference count */ rc: number /** The wrapped query or signal instance */ value: T /** Decrements the reference count; destroys the instance when it reaches zero */ deref: () => void } /** * Dependency key used to identify queries on platforms where `fn.toString()` isn't reliable. * * On Expo/React Native, Hermes compiles functions to bytecode, so `fn.toString()` returns * `[native code]`. To uniquely identify contextual queries, you must provide explicit `deps`. * * @example * ```ts * // On Expo, this would fail without deps: * const filtered$ = queryDb( * (get) => tables.todos.where({ userId: get(userId$) }), * { deps: [userId] } // Required on Expo/React Native * ) * ``` */ export type DepKey = string | number | ReadonlyArray export const depsToString = (deps: DepKey): string => { if (typeof deps === 'string' || typeof deps === 'number') { return deps.toString() } return deps.filter(isNotNil).join(',') } /** * A query definition representing a blueprint for a reactive query. * * Query definitions are created by {@link queryDb}, {@link computed}, and {@link signal}. * They're lightweight and can be defined at module scope. The actual query instance * (which holds state) is created lazily when you use the definition with a Store. * * Multiple uses of the same definition share a single instance via reference counting. * * @typeParam TResult - The type of value the query returns * @typeParam TTag - Internal discriminator tag ('def' for queries, 'signal-def' for signals) */ // TODO we should refactor/clean up how LiveQueryDef / SignalDef / LiveQuery / ISignal are defined (particularly on the type-level) export interface LiveQueryDef { _tag: TTag /** Creates a reference-counted query instance bound to a Store's reactivity graph */ make: (ctx: ReactivityGraphContext, otelContext?: otel.Context) => RcRef | ISignal> /** Human-readable label for debugging and devtools */ label: string /** Unique identifier derived from the query string or explicit deps; used for caching */ hash: string [Equal.symbol](that: LiveQueryDef): boolean [Hash.symbol](): number } export namespace LiveQueryDef { export type Any = LiveQueryDef } /** * A live query instance bound to a specific Store. * * `LiveQuery` represents an active, stateful query in the reactivity graph. Unlike * query definitions (`LiveQueryDef`), instances maintain state like execution counts, * timing data, and active subscriptions. * * You typically don't work with `LiveQuery` directly—use `store.query()` for one-shot * reads or `store.subscribe()` for reactive subscriptions. The instance is managed * automatically via reference counting. * * @typeParam TResult - The type of value the query returns */ export interface LiveQuery { /** Unique identifier for this query instance */ id: number /** Discriminator for the query type */ _tag: 'computed' | 'db' | 'graphql' | 'signal' [TypeId]: TypeId /** Type-level only—extracts the result type from a LiveQuery */ '__result!': TResult /** The underlying reactive atom in the graph that holds the query result */ results$: RG.Atom /** Human-readable label for debugging and devtools */ label: string /** Executes the query and returns the result */ run: (args: { otelContext?: otel.Context; debugRefreshReason?: RefreshReason }) => TResult /** Removes the query from the reactivity graph */ destroy: () => void /** Whether this query instance has been destroyed */ isDestroyed: boolean /** Stack traces of active subscriptions (for debugging) */ activeSubscriptions: Set /** Number of times this query has been executed */ runs: number /** Execution times in milliseconds (for performance monitoring) */ executionTimes: number[] /** The definition that created this instance */ def: LiveQueryDef | SignalDef } export namespace LiveQuery { export type Any = LiveQuery } export abstract class LiveStoreQueryBase implements LiveQuery { '__result!'!: TResult id = queryIdCounter++; [TypeId]: TypeId = TypeId abstract _tag: 'computed' | 'db' | 'graphql' | 'signal' /** Human-readable label for the query for debugging */ abstract label: string abstract def: LiveQueryDef | SignalDef abstract results$: RG.Atom activeSubscriptions: Set = new Set() abstract readonly reactivityGraph: ReactivityGraph get runs() { if (this.results$._tag === 'thunk') { return this.results$.recomputations } return 0 } executionTimes: number[] = [] // TODO double check if this is needed isDestroyed = false abstract destroy: () => void run = (args: { otelContext?: otel.Context; debugRefreshReason?: RefreshReason }): TResult => { return this.results$.computeResult(args.otelContext, args.debugRefreshReason) } protected dependencyQueriesRef: DependencyQueriesRef = new Set() // subscribe = ( // onNewValue: (value: TResult) => void, // options?: { // label?: string // otelContext?: otel.Context // onUnsubsubscribe?: () => void // }, // ): (() => void) => // this.reactivityGraph.context?.store.subscribe(this, onNewValue, options) ?? // RG.throwContextNotSetError(this.reactivityGraph) } /** * Function signature for the `get` parameter in `computed()` and `queryDb()` callbacks. * * Call `get()` with a query definition, signal, or live query instance to: * 1. Read its current value * 2. Establish a reactive dependency (the caller re-runs when the dependency changes) * * @example * ```ts * const filtered$ = computed((get) => { * const todos = get(todos$) // Depends on todos$ * const filter = get(filterText$) // Depends on filterText$ * return todos.filter((t) => t.text.includes(filter)) * }) * ``` */ export type GetAtomResult = ( atom: RG.Atom | LiveQueryDef | LiveQuery | ISignal | SignalDef, otelContext?: otel.Context , debugRefreshReason?: RefreshReason , ) => T export type DependencyQueriesRef = Set>> export const makeGetAtomResult = ( get: RG.GetAtom, ctx: ReactivityGraphContext, otelContext: otel.Context, dependencyQueriesRef: DependencyQueriesRef, ) => { // NOTE we're using the `otelContext` from `makeGetAtomResult` here, not the `otelContext` from `getAtom` const getAtom: GetAtomResult = (atom, _otelContext, debugRefreshReason) => { // ReactivityGraph atoms case if (atom._tag === 'thunk' || atom._tag === 'ref') return get(atom, otelContext, debugRefreshReason) // def case if (atom._tag === 'def' || atom._tag === 'signal-def') { const query = atom.make(ctx) dependencyQueriesRef.add(query) // TODO deref the query on destroy return getAtom(query.value, _otelContext, debugRefreshReason) } // Signal case if (atom._tag === 'signal' && Predicate.hasProperty(atom, 'ref') === true) { return get(atom.ref, otelContext, debugRefreshReason) } // LiveQuery case return get(atom.results$, otelContext, debugRefreshReason) } return getAtom } export const withRCMap = >( id: string, make: (ctx: ReactivityGraphContext, otelContext?: otel.Context) => T, ): ((ctx: ReactivityGraphContext, otelContext?: otel.Context) => RcRef) => { return (ctx, otelContext) => { let item = ctx.defRcMap.get(id) if (item !== undefined) { item.rc++ return item as RcRef } const query$ = make(ctx, otelContext) item = { rc: 1, value: query$, deref: () => { item!.rc-- if (item!.rc === 0) { item!.value.destroy() ctx.defRcMap.delete(id) } }, } ctx.defRcMap.set(id, item) return item as RcRef } }