@livestore/livestore 0.0.0-snapshot-8edfbb590201c0bff6943d89f2ebc3d5ba1a09b0 → 0.0.0-snapshot-6788a4cf00171a177a3a1e59cefc10f5bad71049

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.
@@ -1,5 +1,92 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
+ exports[`otel > QueryBuilder subscription - async iterator 1`] = `
4
+ {
5
+ "_name": "createStore",
6
+ "attributes": {
7
+ "debugInstanceId": "test",
8
+ "storeId": "default",
9
+ },
10
+ "children": [
11
+ {
12
+ "_name": "livestore.in-memory-db:execute",
13
+ "attributes": {
14
+ "sql.query": "
15
+ PRAGMA page_size=32768;
16
+ PRAGMA cache_size=10000;
17
+ PRAGMA synchronous='OFF';
18
+ PRAGMA temp_store='MEMORY';
19
+ PRAGMA foreign_keys='ON'; -- we want foreign key constraints to be enforced
20
+ ",
21
+ },
22
+ },
23
+ {
24
+ "_name": "@livestore/common:LeaderSyncProcessor:push",
25
+ "attributes": {
26
+ "batch": "undefined",
27
+ "batchSize": 1,
28
+ },
29
+ },
30
+ {
31
+ "_name": "client-session-sync-processor:pull",
32
+ "attributes": {
33
+ "code.stacktrace": "<STACKTRACE>",
34
+ "span.label": "⚠︎ Interrupted",
35
+ "status.interrupted": true,
36
+ },
37
+ },
38
+ {
39
+ "_name": "LiveStore:sync",
40
+ },
41
+ {
42
+ "_name": "LiveStore:commits",
43
+ },
44
+ {
45
+ "_name": "LiveStore:queries",
46
+ },
47
+ ],
48
+ }
49
+ `;
50
+
51
+ exports[`otel > QueryBuilder subscription - async iterator 2`] = `
52
+ [
53
+ {
54
+ "_name": "LiveStore:commit",
55
+ "attributes": {
56
+ "livestore.eventTags": "[
57
+ "todo.created"
58
+ ]",
59
+ "livestore.eventsCount": 1,
60
+ },
61
+ "children": [
62
+ {
63
+ "_name": "client-session-sync-processor:push",
64
+ "attributes": {
65
+ "batchSize": 1,
66
+ "eventCounts": "{
67
+ "todo.created": 1
68
+ }",
69
+ "mergeResultTag": "advance",
70
+ },
71
+ "children": [
72
+ {
73
+ "_name": "client-session-sync-processor:materialize-event",
74
+ "children": [
75
+ {
76
+ "_name": "livestore.in-memory-db:execute",
77
+ "attributes": {
78
+ "sql.query": "INSERT INTO 'todos' (id, text, completed) VALUES (?, ?, ?)",
79
+ },
80
+ },
81
+ ],
82
+ },
83
+ ],
84
+ },
85
+ ],
86
+ },
87
+ ]
88
+ `;
89
+
3
90
  exports[`otel > QueryBuilder subscription - basic functionality 1`] = `
4
91
  {
5
92
  "_name": "createStore",
@@ -289,6 +289,53 @@ 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)
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
+ expect(doneResult.done).toBe(true)
321
+ })
322
+
323
+ span.end()
324
+
325
+ return { exporter, provider }
326
+ }).pipe(
327
+ Effect.scoped,
328
+ Effect.tap(({ exporter, provider }) =>
329
+ Effect.promise(async () => {
330
+ await provider.forceFlush()
331
+ expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
332
+ expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
333
+ await provider.shutdown()
334
+ }),
335
+ ),
336
+ ),
337
+ )
338
+
292
339
  Vitest.scopedLive('QueryBuilder subscription - direct table subscription', () =>
293
340
  Effect.gen(function* () {
294
341
  const { store, exporter, span, provider } = yield* makeQuery
package/src/mod.ts CHANGED
@@ -37,7 +37,13 @@ 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
+ QueryDebugInfo,
43
+ RefreshReason,
44
+ SubscribeOptions,
45
+ Unsubscribe,
46
+ } from './store/store-types.ts'
41
47
  export {
42
48
  type LiveStoreContext,
43
49
  type LiveStoreContextRunning,
@@ -14,6 +14,7 @@ import type { Effect, Runtime, Scope } from '@livestore/utils/effect'
14
14
  import { Deferred } from '@livestore/utils/effect'
15
15
  import type * as otel from '@opentelemetry/api'
16
16
 
17
+ import type { LiveQuery } from '../live-queries/base-class.ts'
17
18
  import type { DebugRefreshReasonBase } from '../reactive.ts'
18
19
  import type { StackInfo } from '../utils/stack-info.ts'
19
20
  import type { Store } from './store.ts'
@@ -135,3 +136,12 @@ export type StoreEventsOptions<TSchema extends LiveStoreSchema> = {
135
136
  }
136
137
 
137
138
  export type Unsubscribe = () => void
139
+
140
+ export type SubscribeOptions<TResult> = {
141
+ onSubscribe?: (query$: LiveQuery<TResult>) => void
142
+ onUnsubsubscribe?: () => void
143
+ label?: string
144
+ skipInitialRun?: boolean
145
+ otelContext?: otel.Context
146
+ stackInfo?: StackInfo
147
+ }
@@ -52,16 +52,21 @@ import type { Ref } from '../reactive.ts'
52
52
  import { SqliteDbWrapper } from '../SqliteDbWrapper.ts'
53
53
  import { ReferenceCountedSet } from '../utils/data-structures.ts'
54
54
  import { downloadBlob, exposeDebugUtils } from '../utils/dev.ts'
55
- import type { StackInfo } from '../utils/stack-info.ts'
56
55
  import type {
57
56
  RefreshReason,
58
57
  StoreCommitOptions,
59
58
  StoreEventsOptions,
60
59
  StoreOptions,
61
60
  StoreOtel,
61
+ SubscribeOptions,
62
62
  Unsubscribe,
63
63
  } from './store-types.ts'
64
64
 
65
+ type SubscribeQuery<TResult> =
66
+ | LiveQueryDef<TResult, 'def' | 'signal-def'>
67
+ | LiveQuery<TResult>
68
+ | QueryBuilder<TResult, any, any>
69
+
65
70
  if (isDevEnv()) {
66
71
  exposeDebugUtils()
67
72
  }
@@ -349,98 +354,125 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
349
354
  }
350
355
 
351
356
  /**
352
- * Subscribe to the results of a query
353
- * Returns a function to cancel the subscription.
357
+ * Subscribe to the results of a query.
358
+ *
359
+ * - When providing an `onUpdate` callback it returns an {@link Unsubscribe} function.
360
+ * - Without a callback it returns an {@link AsyncIterable} that yields query results.
354
361
  *
355
362
  * @example
356
363
  * ```ts
357
364
  * const unsubscribe = store.subscribe(query$, (result) => console.log(result))
358
365
  * ```
366
+ *
367
+ * @example
368
+ * ```ts
369
+ * for await (const result of store.subscribe(query$)) {
370
+ * console.log(result)
371
+ * }
372
+ * ```
359
373
  */
360
- subscribe = <TResult>(
361
- query: LiveQueryDef<TResult, 'def' | 'signal-def'> | LiveQuery<TResult> | QueryBuilder<TResult, any, any>,
362
- /** Called when the query result has changed */
363
- 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
- },
378
- ): Unsubscribe => {
379
- this.checkShutdown('subscribe')
374
+ subscribe: {
375
+ <TResult>(
376
+ query: SubscribeQuery<TResult>,
377
+ onUpdate: (value: TResult) => void,
378
+ options?: SubscribeOptions<TResult>,
379
+ ): Unsubscribe
380
+ <TResult>(query: SubscribeQuery<TResult>, options?: SubscribeOptions<TResult>): AsyncIterableIterator<TResult>
381
+ } = <TResult>(
382
+ query: SubscribeQuery<TResult>,
383
+ onUpdateOrOptions?: ((value: TResult) => void) | SubscribeOptions<TResult>,
384
+ maybeOptions?: SubscribeOptions<TResult>,
385
+ ): Unsubscribe | AsyncIterableIterator<TResult> => {
386
+ if (typeof onUpdateOrOptions === 'function') {
387
+ const onUpdate = onUpdateOrOptions
388
+ const options = maybeOptions
389
+
390
+ this.checkShutdown('subscribe')
391
+
392
+ return this.otel.tracer.startActiveSpan(
393
+ `LiveStore.subscribe`,
394
+ { attributes: { label: options?.label, queryLabel: isQueryBuilder(query) ? query.toString() : query.label } },
395
+ options?.otelContext ?? this.otel.queriesSpanContext,
396
+ (span) => {
397
+ // console.debug('store sub', query$.id, query$.label)
398
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
399
+
400
+ const queryRcRef = isQueryBuilder(query)
401
+ ? queryDb(query).make(this.reactivityGraph.context!)
402
+ : query._tag === 'def' || query._tag === 'signal-def'
403
+ ? query.make(this.reactivityGraph.context!)
404
+ : {
405
+ value: query as LiveQuery<TResult>,
406
+ deref: () => {},
407
+ }
408
+ const query$ = queryRcRef.value
380
409
 
381
- return this.otel.tracer.startActiveSpan(
382
- `LiveStore.subscribe`,
383
- { attributes: { label: options?.label, queryLabel: isQueryBuilder(query) ? query.toString() : query.label } },
384
- options?.otelContext ?? this.otel.queriesSpanContext,
385
- (span) => {
386
- // console.debug('store sub', query$.id, query$.label)
387
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
410
+ const label = `subscribe:${options?.label}`
411
+ const effect = this.reactivityGraph.makeEffect(
412
+ (get, _otelContext, debugRefreshReason) => onUpdate(get(query$.results$, otelContext, debugRefreshReason)),
413
+ { label },
414
+ )
388
415
 
389
- const queryRcRef = isQueryBuilder(query)
390
- ? queryDb(query).make(this.reactivityGraph.context!)
391
- : query._tag === 'def' || query._tag === 'signal-def'
392
- ? query.make(this.reactivityGraph.context!)
393
- : {
394
- value: query as LiveQuery<TResult>,
395
- deref: () => {},
396
- }
397
- const query$ = queryRcRef.value
416
+ if (options?.stackInfo) {
417
+ query$.activeSubscriptions.add(options.stackInfo)
418
+ }
398
419
 
399
- const label = `subscribe:${options?.label}`
400
- const effect = this.reactivityGraph.makeEffect(
401
- (get, _otelContext, debugRefreshReason) => onUpdate(get(query$.results$, otelContext, debugRefreshReason)),
402
- { label },
403
- )
420
+ options?.onSubscribe?.(query$)
404
421
 
405
- if (options?.stackInfo) {
406
- query$.activeSubscriptions.add(options.stackInfo)
407
- }
422
+ this.activeQueries.add(query$ as LiveQuery<TResult>)
408
423
 
409
- options?.onSubscribe?.(query$)
424
+ // Running effect right away to get initial value (unless `skipInitialRun` is set)
425
+ if (options?.skipInitialRun !== true && !query$.isDestroyed) {
426
+ effect.doEffect(otelContext, {
427
+ _tag: 'subscribe.initial',
428
+ label: `subscribe-initial-run:${options?.label}`,
429
+ })
430
+ }
410
431
 
411
- this.activeQueries.add(query$ as LiveQuery<TResult>)
432
+ const unsubscribe = () => {
433
+ // console.debug('store unsub', query$.id, query$.label)
434
+ try {
435
+ this.reactivityGraph.destroyNode(effect)
436
+ this.activeQueries.remove(query$ as LiveQuery<TResult>)
412
437
 
413
- // Running effect right away to get initial value (unless `skipInitialRun` is set)
414
- if (options?.skipInitialRun !== true && !query$.isDestroyed) {
415
- effect.doEffect(otelContext, { _tag: 'subscribe.initial', label: `subscribe-initial-run:${options?.label}` })
416
- }
438
+ if (options?.stackInfo) {
439
+ query$.activeSubscriptions.delete(options.stackInfo)
440
+ }
417
441
 
418
- const unsubscribe = () => {
419
- // console.debug('store unsub', query$.id, query$.label)
420
- try {
421
- this.reactivityGraph.destroyNode(effect)
422
- this.activeQueries.remove(query$ as LiveQuery<TResult>)
442
+ queryRcRef.deref()
423
443
 
424
- if (options?.stackInfo) {
425
- query$.activeSubscriptions.delete(options.stackInfo)
444
+ options?.onUnsubsubscribe?.()
445
+ } finally {
446
+ span.end()
426
447
  }
448
+ }
449
+
450
+ return unsubscribe
451
+ },
452
+ )
453
+ }
427
454
 
428
- queryRcRef.deref()
455
+ return this.subscribeAsAsyncIterator(query, onUpdateOrOptions)
456
+ }
429
457
 
430
- options?.onUnsubsubscribe?.()
431
- } finally {
432
- span.end()
433
- }
434
- }
458
+ private subscribeAsAsyncIterator = <TResult>(
459
+ query: SubscribeQuery<TResult>,
460
+ options?: SubscribeOptions<TResult>,
461
+ ): AsyncIterableIterator<TResult> => {
462
+ this.checkShutdown('subscribe')
463
+
464
+ const iterator = Stream.toAsyncIterable(this.subscribeStream(query, options))[Symbol.asyncIterator]()
435
465
 
436
- return unsubscribe
466
+ return Object.assign(iterator, {
467
+ [Symbol.asyncIterator]() {
468
+ return this
437
469
  },
438
- )
470
+ })
439
471
  }
440
472
 
441
473
  subscribeStream = <TResult>(
442
- query$: LiveQueryDef<TResult>,
443
- options?: { label?: string; skipInitialRun?: boolean } | undefined,
474
+ query: SubscribeQuery<TResult>,
475
+ options?: SubscribeOptions<TResult>,
444
476
  ): Stream.Stream<TResult> =>
445
477
  Stream.asyncPush<TResult>((emit) =>
446
478
  Effect.gen(this, function* () {
@@ -451,11 +483,10 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
451
483
 
452
484
  yield* Effect.acquireRelease(
453
485
  Effect.sync(() =>
454
- this.subscribe(
455
- query$,
456
- (result) => emit.single(result),
457
- omitUndefineds({ otelContext, label: options?.label }),
458
- ),
486
+ this.subscribe(query, (result) => emit.single(result), {
487
+ ...(options ?? {}),
488
+ otelContext,
489
+ }),
459
490
  ),
460
491
  (unsub) => Effect.sync(() => unsub()),
461
492
  )