@livestore/livestore 0.4.0-dev.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +0 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/QueryCache.js +1 -1
  4. package/dist/QueryCache.js.map +1 -1
  5. package/dist/SqliteDbWrapper.d.ts +5 -5
  6. package/dist/SqliteDbWrapper.d.ts.map +1 -1
  7. package/dist/SqliteDbWrapper.js +8 -8
  8. package/dist/SqliteDbWrapper.js.map +1 -1
  9. package/dist/SqliteDbWrapper.test.js +4 -3
  10. package/dist/SqliteDbWrapper.test.js.map +1 -1
  11. package/dist/effect/LiveStore.d.ts +133 -5
  12. package/dist/effect/LiveStore.d.ts.map +1 -1
  13. package/dist/effect/LiveStore.js +187 -8
  14. package/dist/effect/LiveStore.js.map +1 -1
  15. package/dist/effect/LiveStore.test.d.ts +2 -0
  16. package/dist/effect/LiveStore.test.d.ts.map +1 -0
  17. package/dist/effect/LiveStore.test.js +42 -0
  18. package/dist/effect/LiveStore.test.js.map +1 -0
  19. package/dist/effect/mod.d.ts +1 -1
  20. package/dist/effect/mod.d.ts.map +1 -1
  21. package/dist/effect/mod.js +3 -1
  22. package/dist/effect/mod.js.map +1 -1
  23. package/dist/live-queries/base-class.d.ts +110 -7
  24. package/dist/live-queries/base-class.d.ts.map +1 -1
  25. package/dist/live-queries/base-class.js +2 -2
  26. package/dist/live-queries/base-class.js.map +1 -1
  27. package/dist/live-queries/client-document-get-query.d.ts +1 -1
  28. package/dist/live-queries/client-document-get-query.d.ts.map +1 -1
  29. package/dist/live-queries/client-document-get-query.js +4 -3
  30. package/dist/live-queries/client-document-get-query.js.map +1 -1
  31. package/dist/live-queries/computed.d.ts +56 -0
  32. package/dist/live-queries/computed.d.ts.map +1 -1
  33. package/dist/live-queries/computed.js +58 -2
  34. package/dist/live-queries/computed.js.map +1 -1
  35. package/dist/live-queries/db-query.d.ts.map +1 -1
  36. package/dist/live-queries/db-query.js +21 -19
  37. package/dist/live-queries/db-query.js.map +1 -1
  38. package/dist/live-queries/db-query.test.js +106 -23
  39. package/dist/live-queries/db-query.test.js.map +1 -1
  40. package/dist/live-queries/signal.d.ts +49 -0
  41. package/dist/live-queries/signal.d.ts.map +1 -1
  42. package/dist/live-queries/signal.js +49 -0
  43. package/dist/live-queries/signal.js.map +1 -1
  44. package/dist/live-queries/signal.test.js +2 -2
  45. package/dist/live-queries/signal.test.js.map +1 -1
  46. package/dist/mod.d.ts +3 -3
  47. package/dist/mod.d.ts.map +1 -1
  48. package/dist/mod.js +3 -2
  49. package/dist/mod.js.map +1 -1
  50. package/dist/reactive.d.ts +9 -9
  51. package/dist/reactive.d.ts.map +1 -1
  52. package/dist/reactive.js +9 -26
  53. package/dist/reactive.js.map +1 -1
  54. package/dist/reactive.test.js +2 -2
  55. package/dist/reactive.test.js.map +1 -1
  56. package/dist/store/StoreRegistry.d.ts +215 -0
  57. package/dist/store/StoreRegistry.d.ts.map +1 -0
  58. package/dist/store/StoreRegistry.js +267 -0
  59. package/dist/store/StoreRegistry.js.map +1 -0
  60. package/dist/store/StoreRegistry.test.d.ts +2 -0
  61. package/dist/store/StoreRegistry.test.d.ts.map +1 -0
  62. package/dist/store/StoreRegistry.test.js +381 -0
  63. package/dist/store/StoreRegistry.test.js.map +1 -0
  64. package/dist/store/create-store.d.ts +98 -18
  65. package/dist/store/create-store.d.ts.map +1 -1
  66. package/dist/store/create-store.js +49 -20
  67. package/dist/store/create-store.js.map +1 -1
  68. package/dist/store/devtools.d.ts +5 -16
  69. package/dist/store/devtools.d.ts.map +1 -1
  70. package/dist/store/devtools.js +59 -18
  71. package/dist/store/devtools.js.map +1 -1
  72. package/dist/store/store-eventstream.test.d.ts +2 -0
  73. package/dist/store/store-eventstream.test.d.ts.map +1 -0
  74. package/dist/store/store-eventstream.test.js +65 -0
  75. package/dist/store/store-eventstream.test.js.map +1 -0
  76. package/dist/store/store-types.d.ts +285 -27
  77. package/dist/store/store-types.d.ts.map +1 -1
  78. package/dist/store/store-types.js +77 -1
  79. package/dist/store/store-types.js.map +1 -1
  80. package/dist/store/store-types.test.d.ts +2 -0
  81. package/dist/store/store-types.test.d.ts.map +1 -0
  82. package/dist/store/store-types.test.js +39 -0
  83. package/dist/store/store-types.test.js.map +1 -0
  84. package/dist/store/store.d.ts +253 -66
  85. package/dist/store/store.d.ts.map +1 -1
  86. package/dist/store/store.js +442 -153
  87. package/dist/store/store.js.map +1 -1
  88. package/dist/utils/dev.d.ts.map +1 -1
  89. package/dist/utils/dev.js.map +1 -1
  90. package/dist/utils/stack-info.js +2 -2
  91. package/dist/utils/stack-info.js.map +1 -1
  92. package/dist/utils/tests/fixture.d.ts +20 -5
  93. package/dist/utils/tests/fixture.d.ts.map +1 -1
  94. package/dist/utils/tests/fixture.js +7 -0
  95. package/dist/utils/tests/fixture.js.map +1 -1
  96. package/dist/utils/tests/otel.d.ts.map +1 -1
  97. package/dist/utils/tests/otel.js +5 -5
  98. package/dist/utils/tests/otel.js.map +1 -1
  99. package/package.json +59 -17
  100. package/src/QueryCache.ts +1 -1
  101. package/src/SqliteDbWrapper.test.ts +5 -3
  102. package/src/SqliteDbWrapper.ts +12 -11
  103. package/src/ambient.d.ts +0 -7
  104. package/src/effect/LiveStore.test.ts +61 -0
  105. package/src/effect/LiveStore.ts +388 -13
  106. package/src/effect/mod.ts +13 -1
  107. package/src/live-queries/__snapshots__/db-query.test.ts.snap +604 -192
  108. package/src/live-queries/base-class.ts +126 -28
  109. package/src/live-queries/client-document-get-query.ts +6 -4
  110. package/src/live-queries/computed.ts +59 -2
  111. package/src/live-queries/db-query.test.ts +162 -24
  112. package/src/live-queries/db-query.ts +23 -20
  113. package/src/live-queries/signal.test.ts +3 -2
  114. package/src/live-queries/signal.ts +49 -0
  115. package/src/mod.ts +19 -2
  116. package/src/reactive.test.ts +3 -2
  117. package/src/reactive.ts +22 -23
  118. package/src/store/StoreRegistry.test.ts +540 -0
  119. package/src/store/StoreRegistry.ts +418 -0
  120. package/src/store/create-store.ts +158 -39
  121. package/src/store/devtools.ts +77 -33
  122. package/src/store/store-eventstream.test.ts +114 -0
  123. package/src/store/store-types.test.ts +52 -0
  124. package/src/store/store-types.ts +360 -40
  125. package/src/store/store.ts +571 -236
  126. package/src/utils/dev.ts +2 -3
  127. package/src/utils/stack-info.ts +2 -2
  128. package/src/utils/tests/fixture.ts +9 -1
  129. package/src/utils/tests/otel.ts +8 -7
@@ -1,10 +1,11 @@
1
+ import type * as otel from '@opentelemetry/api'
2
+
1
3
  import { isNotNil } from '@livestore/utils'
2
4
  import { Equal, Hash, Predicate } from '@livestore/utils/effect'
3
- import type * as otel from '@opentelemetry/api'
4
5
 
5
6
  import * as RG from '../reactive.ts'
6
- import type { Store } from '../store/store.ts'
7
7
  import type { QueryDebugInfo, RefreshReason } from '../store/store-types.ts'
8
+ import type { Store } from '../store/store.ts'
8
9
  import type { StackInfo } from '../utils/stack-info.ts'
9
10
 
10
11
  export type ReactivityGraph = RG.ReactiveGraph<RefreshReason, QueryDebugInfo, ReactivityGraphContext>
@@ -23,46 +24,98 @@ export type ReactivityGraphContext = {
23
24
  effectsWrapper: (run: () => void) => void
24
25
  }
25
26
 
26
- export type GetResult<TQuery extends LiveQueryDef.Any | LiveQuery.Any | SignalDef<any>> = TQuery extends LiveQuery<
27
- infer TResult
28
- >
29
- ? TResult
30
- : TQuery extends LiveQueryDef<infer TResult>
27
+ export type GetResult<TQuery extends LiveQueryDef.Any | LiveQuery.Any | SignalDef<any>> =
28
+ TQuery extends LiveQuery<infer TResult>
31
29
  ? TResult
32
- : TQuery extends SignalDef<infer TResult>
30
+ : TQuery extends LiveQueryDef<infer TResult>
33
31
  ? TResult
34
- : unknown
32
+ : TQuery extends SignalDef<infer TResult>
33
+ ? TResult
34
+ : unknown
35
35
 
36
36
  let queryIdCounter = 0
37
37
 
38
+ /**
39
+ * A signal definition representing ephemeral, local-only reactive state.
40
+ *
41
+ * `SignalDef` is the type returned by {@link signal}. It's a blueprint for creating
42
+ * signal instances—the actual instance is created when you use the definition with
43
+ * a Store via `store.query()` or `store.setSignal()`.
44
+ *
45
+ * @typeParam T - The type of value the signal holds
46
+ */
38
47
  export interface SignalDef<T> extends LiveQueryDef<T, 'signal-def'> {
39
48
  _tag: 'signal-def'
49
+ /** The initial value used when the signal is first created */
40
50
  defaultValue: T
51
+ /** Unique identifier for caching and deduplication */
41
52
  hash: string
53
+ /** Human-readable label for debugging and devtools */
42
54
  label: string
55
+ /** Creates a reference-counted signal instance bound to a Store's reactivity graph */
43
56
  make: (ctx: ReactivityGraphContext) => RcRef<ISignal<T>>
44
57
  [Equal.symbol](that: SignalDef<T>): boolean
45
58
  [Hash.symbol](): number
46
59
  }
47
60
 
61
+ /**
62
+ * Interface for a live signal instance.
63
+ *
64
+ * This represents an active signal bound to a Store's reactivity graph.
65
+ * Use `store.setSignal()` to update values and `store.query()` to read them.
66
+ *
67
+ * @typeParam T - The type of value the signal holds
68
+ */
48
69
  export interface ISignal<T> extends LiveQuery<T> {
49
70
  _tag: 'signal'
50
71
  reactivityGraph: ReactivityGraph
72
+ /** The underlying reactive reference in the graph */
51
73
  ref: RG.Ref<T, ReactivityGraphContext, RefreshReason>
74
+ /** Sets the signal's value (prefer using `store.setSignal()` instead) */
52
75
  set: (value: T) => void
76
+ /** Gets the signal's current value (prefer using `store.query()` instead) */
53
77
  get: () => T
78
+ /** Removes the signal from the reactivity graph */
54
79
  destroy: () => void
55
80
  }
56
81
 
57
82
  export const TypeId = Symbol.for('LiveQuery')
58
83
  export type TypeId = typeof TypeId
59
84
 
85
+ /**
86
+ * A reference-counted wrapper around a LiveQuery or Signal instance.
87
+ *
88
+ * LiveStore uses reference counting to manage query lifecycle. When multiple
89
+ * components or subscriptions use the same query definition, they share a single
90
+ * instance. The instance is destroyed when the last reference is released.
91
+ *
92
+ * You typically don't interact with `RcRef` directly—it's used internally by
93
+ * hooks like `useQuery` and `useQueryRef`.
94
+ */
60
95
  export interface RcRef<T> {
96
+ /** Current reference count */
61
97
  rc: number
98
+ /** The wrapped query or signal instance */
62
99
  value: T
100
+ /** Decrements the reference count; destroys the instance when it reaches zero */
63
101
  deref: () => void
64
102
  }
65
103
 
104
+ /**
105
+ * Dependency key used to identify queries on platforms where `fn.toString()` isn't reliable.
106
+ *
107
+ * On Expo/React Native, Hermes compiles functions to bytecode, so `fn.toString()` returns
108
+ * `[native code]`. To uniquely identify contextual queries, you must provide explicit `deps`.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * // On Expo, this would fail without deps:
113
+ * const filtered$ = queryDb(
114
+ * (get) => tables.todos.where({ userId: get(userId$) }),
115
+ * { deps: [userId] } // Required on Expo/React Native
116
+ * )
117
+ * ```
118
+ */
66
119
  export type DepKey = string | number | ReadonlyArray<string | number | undefined | null>
67
120
 
68
121
  export const depsToString = (deps: DepKey): string => {
@@ -72,12 +125,26 @@ export const depsToString = (deps: DepKey): string => {
72
125
  return deps.filter(isNotNil).join(',')
73
126
  }
74
127
 
128
+ /**
129
+ * A query definition representing a blueprint for a reactive query.
130
+ *
131
+ * Query definitions are created by {@link queryDb}, {@link computed}, and {@link signal}.
132
+ * They're lightweight and can be defined at module scope. The actual query instance
133
+ * (which holds state) is created lazily when you use the definition with a Store.
134
+ *
135
+ * Multiple uses of the same definition share a single instance via reference counting.
136
+ *
137
+ * @typeParam TResult - The type of value the query returns
138
+ * @typeParam TTag - Internal discriminator tag ('def' for queries, 'signal-def' for signals)
139
+ */
75
140
  // TODO we should refactor/clean up how LiveQueryDef / SignalDef / LiveQuery / ISignal are defined (particularly on the type-level)
76
141
  export interface LiveQueryDef<TResult, TTag extends string = 'def'> {
77
142
  _tag: TTag
78
- /** Creates a new LiveQuery instance bound to a specific store/reactivityGraph */
143
+ /** Creates a reference-counted query instance bound to a Store's reactivity graph */
79
144
  make: (ctx: ReactivityGraphContext, otelContext?: otel.Context) => RcRef<LiveQuery<TResult> | ISignal<TResult>>
145
+ /** Human-readable label for debugging and devtools */
80
146
  label: string
147
+ /** Unique identifier derived from the query string or explicit deps; used for caching */
81
148
  hash: string
82
149
  [Equal.symbol](that: LiveQueryDef<TResult, TTag>): boolean
83
150
  [Hash.symbol](): number
@@ -88,39 +155,51 @@ export namespace LiveQueryDef {
88
155
  }
89
156
 
90
157
  /**
91
- * A LiveQuery is stateful
158
+ * A live query instance bound to a specific Store.
159
+ *
160
+ * `LiveQuery` represents an active, stateful query in the reactivity graph. Unlike
161
+ * query definitions (`LiveQueryDef`), instances maintain state like execution counts,
162
+ * timing data, and active subscriptions.
163
+ *
164
+ * You typically don't work with `LiveQuery` directly—use `store.query()` for one-shot
165
+ * reads or `store.subscribe()` for reactive subscriptions. The instance is managed
166
+ * automatically via reference counting.
167
+ *
168
+ * @typeParam TResult - The type of value the query returns
92
169
  */
93
170
  export interface LiveQuery<TResult> {
171
+ /** Unique identifier for this query instance */
94
172
  id: number
173
+ /** Discriminator for the query type */
95
174
  _tag: 'computed' | 'db' | 'graphql' | 'signal'
96
175
  [TypeId]: TypeId
97
176
 
98
- // reactivityGraph: ReactivityGraph
99
-
100
- /** This should only be used on a type-level and doesn't hold any value during runtime */
177
+ /** Type-level only—extracts the result type from a LiveQuery */
101
178
  '__result!': TResult
102
179
 
103
- /** A reactive thunk representing the query results */
180
+ /** The underlying reactive atom in the graph that holds the query result */
104
181
  results$: RG.Atom<TResult, ReactivityGraphContext, RefreshReason>
105
182
 
183
+ /** Human-readable label for debugging and devtools */
106
184
  label: string
107
185
 
186
+ /** Executes the query and returns the result */
108
187
  run: (args: { otelContext?: otel.Context; debugRefreshReason?: RefreshReason }) => TResult
109
188
 
189
+ /** Removes the query from the reactivity graph */
110
190
  destroy: () => void
191
+ /** Whether this query instance has been destroyed */
111
192
  isDestroyed: boolean
112
193
 
113
- // subscribe(
114
- // onNewValue: (value: TResult) => void,
115
- // onUnsubsubscribe?: () => void,
116
- // options?: { label?: string; otelContext?: otel.Context },
117
- // ): () => void
118
-
194
+ /** Stack traces of active subscriptions (for debugging) */
119
195
  activeSubscriptions: Set<StackInfo>
120
196
 
197
+ /** Number of times this query has been executed */
121
198
  runs: number
122
199
 
200
+ /** Execution times in milliseconds (for performance monitoring) */
123
201
  executionTimes: number[]
202
+ /** The definition that created this instance */
124
203
  def: LiveQueryDef<TResult> | SignalDef<TResult>
125
204
  }
126
205
 
@@ -166,17 +245,36 @@ export abstract class LiveStoreQueryBase<TResult> implements LiveQuery<TResult>
166
245
 
167
246
  // subscribe = (
168
247
  // onNewValue: (value: TResult) => void,
169
- // onUnsubsubscribe?: () => void,
170
- // options?: { label?: string; otelContext?: otel.Context } | undefined,
248
+ // options?: {
249
+ // label?: string
250
+ // otelContext?: otel.Context
251
+ // onUnsubsubscribe?: () => void
252
+ // },
171
253
  // ): (() => void) =>
172
- // this.reactivityGraph.context?.store.subscribe(this, onNewValue, onUnsubsubscribe, options) ??
254
+ // this.reactivityGraph.context?.store.subscribe(this, onNewValue, options) ??
173
255
  // RG.throwContextNotSetError(this.reactivityGraph)
174
256
  }
175
257
 
258
+ /**
259
+ * Function signature for the `get` parameter in `computed()` and `queryDb()` callbacks.
260
+ *
261
+ * Call `get()` with a query definition, signal, or live query instance to:
262
+ * 1. Read its current value
263
+ * 2. Establish a reactive dependency (the caller re-runs when the dependency changes)
264
+ *
265
+ * @example
266
+ * ```ts
267
+ * const filtered$ = computed((get) => {
268
+ * const todos = get(todos$) // Depends on todos$
269
+ * const filter = get(filterText$) // Depends on filterText$
270
+ * return todos.filter((t) => t.text.includes(filter))
271
+ * })
272
+ * ```
273
+ */
176
274
  export type GetAtomResult = <T>(
177
275
  atom: RG.Atom<T, any, RefreshReason> | LiveQueryDef<T> | LiveQuery<T> | ISignal<T> | SignalDef<T>,
178
- otelContext?: otel.Context | undefined,
179
- debugRefreshReason?: RefreshReason | undefined,
276
+ otelContext?: otel.Context ,
277
+ debugRefreshReason?: RefreshReason ,
180
278
  ) => T
181
279
 
182
280
  export type DependencyQueriesRef = Set<RcRef<LiveQuery.Any | ISignal<any>>>
@@ -201,7 +299,7 @@ export const makeGetAtomResult = (
201
299
  }
202
300
 
203
301
  // Signal case
204
- if (atom._tag === 'signal' && Predicate.hasProperty(atom, 'ref')) {
302
+ if (atom._tag === 'signal' && Predicate.hasProperty(atom, 'ref') === true) {
205
303
  return get(atom.ref, otelContext, debugRefreshReason)
206
304
  }
207
305
 
@@ -218,7 +316,7 @@ export const withRCMap = <T extends LiveQuery.Any | ISignal<any>>(
218
316
  ): ((ctx: ReactivityGraphContext, otelContext?: otel.Context) => RcRef<T>) => {
219
317
  return (ctx, otelContext) => {
220
318
  let item = ctx.defRcMap.get(id)
221
- if (item) {
319
+ if (item !== undefined) {
222
320
  item.rc++
223
321
  return item as RcRef<T>
224
322
  }
@@ -1,9 +1,11 @@
1
+ import type * as otel from '@opentelemetry/api'
2
+
1
3
  import type { PreparedBindValues } from '@livestore/common'
2
4
  import { SessionIdSymbol } from '@livestore/common'
3
5
  import { State } from '@livestore/common/schema'
4
6
  import { shouldNeverHappen } from '@livestore/utils'
5
- import type * as otel from '@opentelemetry/api'
6
7
 
8
+ import { StoreInternalsSymbol } from '../store/store-types.ts'
7
9
  import type { ReactivityGraphContext } from './base-class.ts'
8
10
 
9
11
  export const rowQueryLabel = (
@@ -30,17 +32,17 @@ export const makeExecBeforeFirstRun =
30
32
  )
31
33
  }
32
34
 
33
- const otelContext = otelContext_ ?? store.otel.queriesSpanContext
35
+ const otelContext = otelContext_ ?? store[StoreInternalsSymbol].otel.queriesSpanContext
34
36
 
35
37
  const idVal = id === SessionIdSymbol ? store.sessionId : id!
36
38
  const rowExists =
37
- store.sqliteDbWrapper.cachedSelect(
39
+ store[StoreInternalsSymbol].sqliteDbWrapper.cachedSelect(
38
40
  `SELECT 1 FROM '${table.sqliteDef.name}' WHERE id = ?`,
39
41
  [idVal] as any as PreparedBindValues,
40
42
  { otelContext },
41
43
  ).length === 1
42
44
 
43
- if (rowExists) return
45
+ if (rowExists === true) return
44
46
 
45
47
  // It's important that we only commit and don't refresh here, as this function might be called during a render
46
48
  // and otherwise we might end up in a "reactive loop"
@@ -1,6 +1,7 @@
1
+ import * as otel from '@opentelemetry/api'
2
+
1
3
  import { getDurationMsFromSpan } from '@livestore/common'
2
4
  import { Equal, Hash } from '@livestore/utils/effect'
3
- import * as otel from '@opentelemetry/api'
4
5
 
5
6
  import type { Thunk } from '../reactive.ts'
6
7
  import type { RefreshReason } from '../store/store-types.ts'
@@ -8,6 +9,55 @@ import { isValidFunctionString } from '../utils/function-string.ts'
8
9
  import type { DepKey, GetAtomResult, LiveQueryDef, ReactivityGraph, ReactivityGraphContext } from './base-class.ts'
9
10
  import { depsToString, LiveStoreQueryBase, makeGetAtomResult, withRCMap } from './base-class.ts'
10
11
 
12
+ /**
13
+ * Creates a derived query that computes a value from other queries or signals.
14
+ *
15
+ * Computed queries are memoized—they only re-evaluate when their dependencies change,
16
+ * and if the new result equals the previous result, downstream dependents won't re-run.
17
+ * Use them for expensive calculations, aggregations, or transformations.
18
+ *
19
+ * The `get` function inside `computed` establishes reactive dependencies automatically.
20
+ * When any dependency updates, the computed re-evaluates.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // Derive a count from a database query
25
+ * const todos$ = queryDb(tables.todos.all())
26
+ * const todoCount$ = computed((get) => get(todos$).length, { label: 'todoCount' })
27
+ *
28
+ * // Use in a component
29
+ * const count = store.query(todoCount$) // 5
30
+ * ```
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * // Combine multiple queries into derived stats
35
+ * const stats$ = computed((get) => {
36
+ * const todos = get(todos$)
37
+ * const completed = todos.filter((t) => t.completed).length
38
+ * return {
39
+ * total: todos.length,
40
+ * completed,
41
+ * remaining: todos.length - completed,
42
+ * percentComplete: todos.length > 0 ? (completed / todos.length) * 100 : 0,
43
+ * }
44
+ * }, { label: 'todoStats' })
45
+ * ```
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * // Chain computed queries
50
+ * const hasCompletedTodos$ = computed(
51
+ * (get) => get(stats$).completed > 0,
52
+ * { label: 'hasCompletedTodos' }
53
+ * )
54
+ * ```
55
+ *
56
+ * @param fn - Pure function that computes the result. Use `get()` to read dependencies.
57
+ * @param options.label - Human-readable label for debugging and devtools
58
+ * @param options.deps - Explicit dependency keys (required on Expo/React Native where `fn.toString()` returns `[native code]`)
59
+ * @returns A query definition usable with `store.query()`, `store.subscribe()`, and as a dependency in other queries
60
+ */
11
61
  export const computed = <TResult>(
12
62
  fn: (get: GetAtomResult) => TResult,
13
63
  options?: {
@@ -15,7 +65,7 @@ export const computed = <TResult>(
15
65
  deps?: DepKey
16
66
  },
17
67
  ): LiveQueryDef<TResult> => {
18
- const hash = options?.deps ? depsToString(options.deps) : fn.toString()
68
+ const hash = options?.deps !== undefined ? depsToString(options.deps) : fn.toString()
19
69
  if (isValidFunctionString(hash)._tag === 'invalid') {
20
70
  throw new Error(`On Expo/React Native, computed queries must provide a \`deps\` option`)
21
71
  }
@@ -47,6 +97,13 @@ export const computed = <TResult>(
47
97
  return def
48
98
  }
49
99
 
100
+ /**
101
+ * A live computed query instance bound to a specific Store.
102
+ *
103
+ * Computed query instances are created internally when you use a `LiveQueryDef` (from {@link computed})
104
+ * with the Store. You typically don't construct these directly—use `computed()` to create definitions
105
+ * and `store.query()` / `store.subscribe()` to interact with them.
106
+ */
50
107
  export class LiveStoreComputedQuery<TResult> extends LiveStoreQueryBase<TResult> {
51
108
  _tag = 'computed' as const
52
109
 
@@ -1,11 +1,13 @@
1
- import { Effect, ReadonlyRecord, Schema } from '@livestore/utils/effect'
2
- import { Vitest } from '@livestore/utils-dev/node-vitest'
3
1
  import * as otel from '@opentelemetry/api'
4
2
  import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
5
- import { expect } from 'vitest'
3
+ import { assert, expect } from 'vitest'
4
+
5
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
6
+ import { Effect, ReadonlyRecord, Schema } from '@livestore/utils/effect'
6
7
 
7
8
  import * as RG from '../reactive.ts'
8
- import { events, makeTodoMvc, tables } from '../utils/tests/fixture.ts'
9
+ import { StoreInternalsSymbol } from '../store/store-types.ts'
10
+ import { events, makeTodoMvc, type Todo, tables } from '../utils/tests/fixture.ts'
9
11
  import { getAllSimplifiedRootSpans, getSimplifiedRootSpan } from '../utils/tests/otel.ts'
10
12
  import { computed } from './computed.ts'
11
13
  import { queryDb } from './db-query.ts'
@@ -106,7 +108,7 @@ Vitest.describe('otel', () => {
106
108
  { label: 'all todos' },
107
109
  )
108
110
 
109
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
111
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
110
112
 
111
113
  expect(store.query(query$)).toMatchInlineSnapshot(`
112
114
  {
@@ -116,11 +118,11 @@ Vitest.describe('otel', () => {
116
118
  }
117
119
  `)
118
120
 
119
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
121
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
120
122
 
121
123
  store.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false }))
122
124
 
123
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
125
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
124
126
 
125
127
  expect(store.query(query$)).toMatchInlineSnapshot(`
126
128
  {
@@ -130,7 +132,7 @@ Vitest.describe('otel', () => {
130
132
  }
131
133
  `)
132
134
 
133
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
135
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
134
136
 
135
137
  span.end()
136
138
 
@@ -204,10 +206,8 @@ Vitest.describe('otel', () => {
204
206
  .where({ completed: false })
205
207
  .first({ behaviour: 'fallback', fallback: () => defaultTodo })
206
208
 
207
- const unsubscribe = store.subscribe(queryBuilder, {
208
- onUpdate: (result) => {
209
- callbackResults.push(result)
210
- },
209
+ const unsubscribe = store.subscribe(queryBuilder, (result) => {
210
+ callbackResults.push(result)
211
211
  })
212
212
 
213
213
  expect(callbackResults).toHaveLength(1)
@@ -239,6 +239,53 @@ Vitest.describe('otel', () => {
239
239
  ),
240
240
  )
241
241
 
242
+ Vitest.scopedLive('QueryBuilder subscription - skipInitialRun', () =>
243
+ Effect.gen(function* () {
244
+ const { store, exporter, span, provider } = yield* makeQuery
245
+
246
+ const callbackResults: Todo[] = []
247
+ const defaultTodo: Todo = { id: '', text: '', completed: false }
248
+
249
+ const queryBuilder = tables.todos
250
+ .where({ completed: false })
251
+ .first({ behaviour: 'fallback', fallback: () => defaultTodo })
252
+
253
+ const unsubscribe = store.subscribe(
254
+ queryBuilder,
255
+ (result) => {
256
+ callbackResults.push(result)
257
+ },
258
+ { skipInitialRun: true },
259
+ )
260
+
261
+ expect(callbackResults).toHaveLength(0)
262
+
263
+ store.commit(events.todoCreated({ id: 't-skip', text: 'skip initial', completed: false }))
264
+
265
+ expect(callbackResults).toHaveLength(1)
266
+ expect(callbackResults[0]).toMatchObject({
267
+ id: 't-skip',
268
+ text: 'skip initial',
269
+ completed: false,
270
+ })
271
+
272
+ unsubscribe()
273
+ span.end()
274
+
275
+ return { exporter, provider }
276
+ }).pipe(
277
+ Effect.scoped,
278
+ Effect.tap(({ exporter, provider }) =>
279
+ Effect.promise(async () => {
280
+ await provider.forceFlush()
281
+ expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
282
+ expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
283
+ await provider.shutdown()
284
+ }),
285
+ ),
286
+ ),
287
+ )
288
+
242
289
  Vitest.scopedLive('QueryBuilder subscription - unsubscribe functionality', () =>
243
290
  Effect.gen(function* () {
244
291
  const { store, exporter, span, provider } = yield* makeQuery
@@ -251,16 +298,12 @@ Vitest.describe('otel', () => {
251
298
  .where({ completed: false })
252
299
  .first({ behaviour: 'fallback', fallback: () => defaultTodo })
253
300
 
254
- const unsubscribe1 = store.subscribe(queryBuilder, {
255
- onUpdate: (result) => {
256
- callbackResults1.push(result)
257
- },
301
+ const unsubscribe1 = store.subscribe(queryBuilder, (result) => {
302
+ callbackResults1.push(result)
258
303
  })
259
304
 
260
- const unsubscribe2 = store.subscribe(queryBuilder, {
261
- onUpdate: (result) => {
262
- callbackResults2.push(result)
263
- },
305
+ const unsubscribe2 = store.subscribe(queryBuilder, (result) => {
306
+ callbackResults2.push(result)
264
307
  })
265
308
 
266
309
  expect(callbackResults1).toHaveLength(1)
@@ -295,16 +338,111 @@ Vitest.describe('otel', () => {
295
338
  ),
296
339
  )
297
340
 
341
+ Vitest.scopedLive('QueryBuilder subscription - async iterator', () =>
342
+ Effect.gen(function* () {
343
+ const { store, exporter, span, provider } = yield* makeQuery
344
+
345
+ const defaultTodo: Todo = { id: '', text: '', completed: false }
346
+
347
+ const queryBuilder = tables.todos
348
+ .where({ completed: false })
349
+ .first({ behaviour: 'fallback', fallback: () => defaultTodo })
350
+
351
+ yield* Effect.promise(async () => {
352
+ const iterator = store.subscribe(queryBuilder)[Symbol.asyncIterator]()
353
+
354
+ const initial = await iterator.next()
355
+ expect(initial.done).toBe(false)
356
+ expect(initial.value).toMatchObject(defaultTodo)
357
+
358
+ store.commit(events.todoCreated({ id: 't-async', text: 'write tests', completed: false }))
359
+
360
+ const update = await iterator.next()
361
+ expect(update.done).toBe(false)
362
+ expect(update.value).toMatchObject({
363
+ id: 't-async',
364
+ text: 'write tests',
365
+ completed: false,
366
+ })
367
+
368
+ const doneResult = await iterator.return?.()
369
+ assert(doneResult)
370
+ expect(doneResult.done).toBe(true)
371
+ })
372
+
373
+ span.end()
374
+
375
+ return { exporter, provider }
376
+ }).pipe(
377
+ Effect.scoped,
378
+ Effect.tap(({ exporter, provider }) =>
379
+ Effect.promise(async () => {
380
+ await provider.forceFlush()
381
+ expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
382
+ expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
383
+ await provider.shutdown()
384
+ }),
385
+ ),
386
+ ),
387
+ )
388
+
389
+ Vitest.scopedLive('QueryBuilder subscription - async iterator with skipInitialRun', () =>
390
+ Effect.gen(function* () {
391
+ const { store, exporter, span, provider } = yield* makeQuery
392
+
393
+ const defaultTodo: Todo = { id: '', text: '', completed: false }
394
+
395
+ const queryBuilder = tables.todos
396
+ .where({ completed: false })
397
+ .first({ behaviour: 'fallback', fallback: () => defaultTodo })
398
+
399
+ yield* Effect.promise(async () => {
400
+ const iterator = store.subscribe(queryBuilder, { skipInitialRun: true })[Symbol.asyncIterator]()
401
+
402
+ const pending = Symbol('pending')
403
+ const nextPromise = iterator.next()
404
+ const raceResult = await Promise.race([nextPromise, Promise.resolve(pending)])
405
+ expect(raceResult).toBe(pending)
406
+
407
+ store.commit(events.todoCreated({ id: 't-async-skip', text: 'write tests later', completed: false }))
408
+
409
+ const update = await nextPromise
410
+ expect(update.done).toBe(false)
411
+ expect(update.value).toMatchObject({
412
+ id: 't-async-skip',
413
+ text: 'write tests later',
414
+ completed: false,
415
+ })
416
+
417
+ const doneResult = await iterator.return?.()
418
+ assert(doneResult)
419
+ expect(doneResult.done).toBe(true)
420
+ })
421
+
422
+ span.end()
423
+
424
+ return { exporter, provider }
425
+ }).pipe(
426
+ Effect.scoped,
427
+ Effect.tap(({ exporter, provider }) =>
428
+ Effect.promise(async () => {
429
+ await provider.forceFlush()
430
+ expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
431
+ expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
432
+ await provider.shutdown()
433
+ }),
434
+ ),
435
+ ),
436
+ )
437
+
298
438
  Vitest.scopedLive('QueryBuilder subscription - direct table subscription', () =>
299
439
  Effect.gen(function* () {
300
440
  const { store, exporter, span, provider } = yield* makeQuery
301
441
 
302
442
  const callbackResults: any[] = []
303
443
 
304
- const unsubscribe = store.subscribe(tables.todos, {
305
- onUpdate: (result) => {
306
- callbackResults.push(result)
307
- },
444
+ const unsubscribe = store.subscribe(tables.todos, (result) => {
445
+ callbackResults.push(result)
308
446
  })
309
447
 
310
448
  expect(callbackResults).toHaveLength(1)