@livestore/livestore 0.0.0-snapshot-6c08ae981df3f97c859084351e00b463f8dc8fb5 → 0.0.0-snapshot-c81e633d08ce770cc8cad9586fb0eb6d70f39f0e

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.
@@ -2,7 +2,7 @@ import { Effect, ReadonlyRecord, Schema } from '@livestore/utils/effect'
2
2
  import { Vitest } from '@livestore/utils-dev/node-vitest'
3
3
  import * as otel from '@opentelemetry/api'
4
4
  import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
5
- import { expect } from 'vitest'
5
+ import { assert, expect } from 'vitest'
6
6
 
7
7
  import * as RG from '../reactive.ts'
8
8
  import { events, makeTodoMvc, tables } from '../utils/tests/fixture.ts'
@@ -289,6 +289,54 @@ Vitest.describe('otel', () => {
289
289
  ),
290
290
  )
291
291
 
292
+ Vitest.scopedLive('QueryBuilder subscription - async iterator', () =>
293
+ Effect.gen(function* () {
294
+ const { store, exporter, span, provider } = yield* makeQuery
295
+
296
+ const defaultTodo = { id: '', text: '', completed: false }
297
+
298
+ const queryBuilder = tables.todos
299
+ .where({ completed: false })
300
+ .first({ behaviour: 'fallback', fallback: () => defaultTodo })
301
+
302
+ yield* Effect.promise(async () => {
303
+ const iterator = store.subscribe(queryBuilder)[Symbol.asyncIterator]()
304
+
305
+ const initial = await iterator.next()
306
+ expect(initial.done).toBe(false)
307
+ expect(initial.value).toMatchObject(defaultTodo)
308
+
309
+ store.commit(events.todoCreated({ id: 't-async', text: 'write tests', completed: false }))
310
+
311
+ const update = await iterator.next()
312
+ expect(update.done).toBe(false)
313
+ expect(update.value).toMatchObject({
314
+ id: 't-async',
315
+ text: 'write tests',
316
+ completed: false,
317
+ })
318
+
319
+ const doneResult = await iterator.return?.()
320
+ assert(doneResult)
321
+ expect(doneResult.done).toBe(true)
322
+ })
323
+
324
+ span.end()
325
+
326
+ return { exporter, provider }
327
+ }).pipe(
328
+ Effect.scoped,
329
+ Effect.tap(({ exporter, provider }) =>
330
+ Effect.promise(async () => {
331
+ await provider.forceFlush()
332
+ expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
333
+ expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
334
+ await provider.shutdown()
335
+ }),
336
+ ),
337
+ ),
338
+ )
339
+
292
340
  Vitest.scopedLive('QueryBuilder subscription - direct table subscription', () =>
293
341
  Effect.gen(function* () {
294
342
  const { store, exporter, span, provider } = yield* makeQuery
package/src/mod.ts CHANGED
@@ -37,7 +37,14 @@ export {
37
37
  export { emptyDebugInfo, SqliteDbWrapper } from './SqliteDbWrapper.ts'
38
38
  export { type CreateStoreOptions, createStore, createStorePromise } from './store/create-store.ts'
39
39
  export { Store } from './store/store.ts'
40
- export type { OtelOptions, QueryDebugInfo, RefreshReason, Unsubscribe } from './store/store-types.ts'
40
+ export type {
41
+ OtelOptions,
42
+ Queryable,
43
+ QueryDebugInfo,
44
+ RefreshReason,
45
+ SubscribeOptions,
46
+ Unsubscribe,
47
+ } from './store/store-types.ts'
41
48
  export {
42
49
  type LiveStoreContext,
43
50
  type LiveStoreContextRunning,
@@ -5,6 +5,7 @@ import type {
5
5
  InvalidPullError,
6
6
  IsOfflineError,
7
7
  MaterializeError,
8
+ QueryBuilder,
8
9
  StoreInterrupted,
9
10
  SyncError,
10
11
  UnexpectedError,
@@ -14,6 +15,7 @@ import type { Effect, Runtime, Scope } from '@livestore/utils/effect'
14
15
  import { Deferred } from '@livestore/utils/effect'
15
16
  import type * as otel from '@opentelemetry/api'
16
17
 
18
+ import type { LiveQuery, LiveQueryDef, SignalDef } from '../live-queries/base-class.ts'
17
19
  import type { DebugRefreshReasonBase } from '../reactive.ts'
18
20
  import type { StackInfo } from '../utils/stack-info.ts'
19
21
  import type { Store } from './store.ts'
@@ -135,3 +137,19 @@ export type StoreEventsOptions<TSchema extends LiveStoreSchema> = {
135
137
  }
136
138
 
137
139
  export type Unsubscribe = () => void
140
+
141
+ export type SubscribeOptions<TResult> = {
142
+ onSubscribe?: (query$: LiveQuery<TResult>) => void
143
+ onUnsubsubscribe?: () => void
144
+ label?: string
145
+ skipInitialRun?: boolean
146
+ otelContext?: otel.Context
147
+ stackInfo?: StackInfo
148
+ }
149
+
150
+ /** All query definitions or instances the store can execute or subscribe to. */
151
+ export type Queryable<TResult> =
152
+ | LiveQueryDef<TResult>
153
+ | SignalDef<TResult>
154
+ | LiveQuery<TResult>
155
+ | QueryBuilder<TResult, any, any>
@@ -14,7 +14,6 @@ import {
14
14
  makeClientSessionSyncProcessor,
15
15
  type PreparedBindValues,
16
16
  prepareBindValues,
17
- type QueryBuilder,
18
17
  QueryBuilderAstSymbol,
19
18
  replaceSessionIdSymbol,
20
19
  UnexpectedError,
@@ -38,13 +37,7 @@ import {
38
37
  import { nanoid } from '@livestore/utils/nanoid'
39
38
  import * as otel from '@opentelemetry/api'
40
39
 
41
- import type {
42
- LiveQuery,
43
- LiveQueryDef,
44
- ReactivityGraph,
45
- ReactivityGraphContext,
46
- SignalDef,
47
- } from '../live-queries/base-class.ts'
40
+ import type { LiveQuery, ReactivityGraph, ReactivityGraphContext, SignalDef } from '../live-queries/base-class.ts'
48
41
  import { makeReactivityGraph } from '../live-queries/base-class.ts'
49
42
  import { makeExecBeforeFirstRun } from '../live-queries/client-document-get-query.ts'
50
43
  import { queryDb } from '../live-queries/db-query.ts'
@@ -52,16 +45,26 @@ import type { Ref } from '../reactive.ts'
52
45
  import { SqliteDbWrapper } from '../SqliteDbWrapper.ts'
53
46
  import { ReferenceCountedSet } from '../utils/data-structures.ts'
54
47
  import { downloadBlob, exposeDebugUtils } from '../utils/dev.ts'
55
- import type { StackInfo } from '../utils/stack-info.ts'
56
48
  import type {
49
+ Queryable,
57
50
  RefreshReason,
58
51
  StoreCommitOptions,
59
52
  StoreEventsOptions,
60
53
  StoreOptions,
61
54
  StoreOtel,
55
+ SubscribeOptions,
62
56
  Unsubscribe,
63
57
  } from './store-types.ts'
64
58
 
59
+ type SubscribeFn = {
60
+ <TResult>(
61
+ query: Queryable<TResult>,
62
+ onUpdate: (value: TResult) => void,
63
+ options?: SubscribeOptions<TResult>,
64
+ ): Unsubscribe
65
+ <TResult>(query: Queryable<TResult>, options?: SubscribeOptions<TResult>): AsyncIterable<TResult>
66
+ }
67
+
65
68
  if (isDevEnv()) {
66
69
  exposeDebugUtils()
67
70
  }
@@ -349,32 +352,39 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
349
352
  }
350
353
 
351
354
  /**
352
- * Subscribe to the results of a query
353
- * Returns a function to cancel the subscription.
355
+ * Subscribe to the results of a query.
356
+ *
357
+ * - When providing an `onUpdate` callback it returns an {@link Unsubscribe} function.
358
+ * - Without a callback it returns an {@link AsyncIterable} that yields query results.
354
359
  *
355
360
  * @example
356
361
  * ```ts
357
362
  * const unsubscribe = store.subscribe(query$, (result) => console.log(result))
358
363
  * ```
364
+ *
365
+ * @example
366
+ * ```ts
367
+ * for await (const result of store.subscribe(query$)) {
368
+ * console.log(result)
369
+ * }
370
+ * ```
359
371
  */
360
- subscribe = <TResult>(
361
- query: LiveQueryDef<TResult, 'def' | 'signal-def'> | LiveQuery<TResult> | QueryBuilder<TResult, any, any>,
362
- /** Called when the query result has changed */
372
+ subscribe = (<TResult>(
373
+ query: Queryable<TResult>,
374
+ onUpdateOrOptions?: ((value: TResult) => void) | SubscribeOptions<TResult>,
375
+ maybeOptions?: SubscribeOptions<TResult>,
376
+ ): Unsubscribe | AsyncIterable<TResult> => {
377
+ if (typeof onUpdateOrOptions === 'function') {
378
+ return this.subscribeWithCallback(query, onUpdateOrOptions, maybeOptions)
379
+ }
380
+
381
+ return this.subscribeAsAsyncIterable(query, onUpdateOrOptions)
382
+ }) as SubscribeFn
383
+
384
+ private subscribeWithCallback = <TResult>(
385
+ query: Queryable<TResult>,
363
386
  onUpdate: (value: TResult) => void,
364
- options?: {
365
- onSubscribe?: (query$: LiveQuery<TResult>) => void
366
- /** Gets called after the query subscription has been removed */
367
- onUnsubsubscribe?: () => void
368
- label?: string
369
- /**
370
- * Skips the initial `onUpdate` callback
371
- * @default false
372
- */
373
- skipInitialRun?: boolean
374
- otelContext?: otel.Context
375
- /** If provided, the stack info will be added to the `activeSubscriptions` set of the query */
376
- stackInfo?: StackInfo
377
- },
387
+ options?: SubscribeOptions<TResult>,
378
388
  ): Unsubscribe => {
379
389
  this.checkShutdown('subscribe')
380
390
 
@@ -383,7 +393,6 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
383
393
  { attributes: { label: options?.label, queryLabel: isQueryBuilder(query) ? query.toString() : query.label } },
384
394
  options?.otelContext ?? this.otel.queriesSpanContext,
385
395
  (span) => {
386
- // console.debug('store sub', query$.id, query$.label)
387
396
  const otelContext = otel.trace.setSpan(otel.context.active(), span)
388
397
 
389
398
  const queryRcRef = isQueryBuilder(query)
@@ -410,13 +419,14 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
410
419
 
411
420
  this.activeQueries.add(query$ as LiveQuery<TResult>)
412
421
 
413
- // Running effect right away to get initial value (unless `skipInitialRun` is set)
414
422
  if (options?.skipInitialRun !== true && !query$.isDestroyed) {
415
- effect.doEffect(otelContext, { _tag: 'subscribe.initial', label: `subscribe-initial-run:${options?.label}` })
423
+ effect.doEffect(otelContext, {
424
+ _tag: 'subscribe.initial',
425
+ label: `subscribe-initial-run:${options?.label}`,
426
+ })
416
427
  }
417
428
 
418
429
  const unsubscribe = () => {
419
- // console.debug('store unsub', query$.id, query$.label)
420
430
  try {
421
431
  this.reactivityGraph.destroyNode(effect)
422
432
  this.activeQueries.remove(query$ as LiveQuery<TResult>)
@@ -438,10 +448,16 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
438
448
  )
439
449
  }
440
450
 
441
- subscribeStream = <TResult>(
442
- query$: LiveQueryDef<TResult>,
443
- options?: { label?: string; skipInitialRun?: boolean } | undefined,
444
- ): Stream.Stream<TResult> =>
451
+ private subscribeAsAsyncIterable = <TResult>(
452
+ query: Queryable<TResult>,
453
+ options?: SubscribeOptions<TResult>,
454
+ ): AsyncIterable<TResult> => {
455
+ this.checkShutdown('subscribe')
456
+
457
+ return Stream.toAsyncIterable(this.subscribeStream(query, options))
458
+ }
459
+
460
+ subscribeStream = <TResult>(query: Queryable<TResult>, options?: SubscribeOptions<TResult>): Stream.Stream<TResult> =>
445
461
  Stream.asyncPush<TResult>((emit) =>
446
462
  Effect.gen(this, function* () {
447
463
  const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(
@@ -451,11 +467,10 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
451
467
 
452
468
  yield* Effect.acquireRelease(
453
469
  Effect.sync(() =>
454
- this.subscribe(
455
- query$,
456
- (result) => emit.single(result),
457
- omitUndefineds({ otelContext, label: options?.label }),
458
- ),
470
+ this.subscribe(query, (result) => emit.single(result), {
471
+ ...(options ?? {}),
472
+ otelContext,
473
+ }),
459
474
  ),
460
475
  (unsub) => Effect.sync(() => unsub()),
461
476
  )
@@ -477,12 +492,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
477
492
  * ```
478
493
  */
479
494
  query = <TResult>(
480
- query:
481
- | QueryBuilder<TResult, any, any>
482
- | LiveQuery<TResult>
483
- | LiveQueryDef<TResult>
484
- | SignalDef<TResult>
485
- | { query: string; bindValues: Bindable; schema?: Schema.Schema<TResult> },
495
+ query: Queryable<TResult> | { query: string; bindValues: Bindable; schema?: Schema.Schema<TResult> },
486
496
  options?: { otelContext?: otel.Context; debugRefreshReason?: RefreshReason },
487
497
  ): TResult => {
488
498
  this.checkShutdown('query')