@livestore/livestore 0.4.0-dev.10 → 0.4.0-dev.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livestore/livestore",
3
- "version": "0.4.0-dev.10",
3
+ "version": "0.4.0-dev.12",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -11,8 +11,8 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@opentelemetry/api": "1.9.0",
14
- "@livestore/utils": "0.4.0-dev.10",
15
- "@livestore/common": "0.4.0-dev.10"
14
+ "@livestore/common": "0.4.0-dev.12",
15
+ "@livestore/utils": "0.4.0-dev.12"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@opentelemetry/sdk-trace-base": "^2.0.1",
@@ -20,8 +20,8 @@
20
20
  "typescript": "5.9.2",
21
21
  "vite": "7.1.7",
22
22
  "vitest": "3.2.4",
23
- "@livestore/adapter-web": "0.4.0-dev.10",
24
- "@livestore/utils-dev": "0.4.0-dev.10"
23
+ "@livestore/utils-dev": "0.4.0-dev.12",
24
+ "@livestore/adapter-web": "0.4.0-dev.12"
25
25
  },
26
26
  "files": [
27
27
  "package.json",
@@ -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",
@@ -166,10 +166,13 @@ export abstract class LiveStoreQueryBase<TResult> implements LiveQuery<TResult>
166
166
 
167
167
  // subscribe = (
168
168
  // onNewValue: (value: TResult) => void,
169
- // onUnsubsubscribe?: () => void,
170
- // options?: { label?: string; otelContext?: otel.Context } | undefined,
169
+ // options?: {
170
+ // label?: string
171
+ // otelContext?: otel.Context
172
+ // onUnsubsubscribe?: () => void
173
+ // },
171
174
  // ): (() => void) =>
172
- // this.reactivityGraph.context?.store.subscribe(this, onNewValue, onUnsubsubscribe, options) ??
175
+ // this.reactivityGraph.context?.store.subscribe(this, onNewValue, options) ??
173
176
  // RG.throwContextNotSetError(this.reactivityGraph)
174
177
  }
175
178
 
@@ -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'
@@ -204,10 +204,8 @@ Vitest.describe('otel', () => {
204
204
  .where({ completed: false })
205
205
  .first({ behaviour: 'fallback', fallback: () => defaultTodo })
206
206
 
207
- const unsubscribe = store.subscribe(queryBuilder, {
208
- onUpdate: (result) => {
209
- callbackResults.push(result)
210
- },
207
+ const unsubscribe = store.subscribe(queryBuilder, (result) => {
208
+ callbackResults.push(result)
211
209
  })
212
210
 
213
211
  expect(callbackResults).toHaveLength(1)
@@ -251,16 +249,12 @@ Vitest.describe('otel', () => {
251
249
  .where({ completed: false })
252
250
  .first({ behaviour: 'fallback', fallback: () => defaultTodo })
253
251
 
254
- const unsubscribe1 = store.subscribe(queryBuilder, {
255
- onUpdate: (result) => {
256
- callbackResults1.push(result)
257
- },
252
+ const unsubscribe1 = store.subscribe(queryBuilder, (result) => {
253
+ callbackResults1.push(result)
258
254
  })
259
255
 
260
- const unsubscribe2 = store.subscribe(queryBuilder, {
261
- onUpdate: (result) => {
262
- callbackResults2.push(result)
263
- },
256
+ const unsubscribe2 = store.subscribe(queryBuilder, (result) => {
257
+ callbackResults2.push(result)
264
258
  })
265
259
 
266
260
  expect(callbackResults1).toHaveLength(1)
@@ -295,16 +289,62 @@ Vitest.describe('otel', () => {
295
289
  ),
296
290
  )
297
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
+
298
340
  Vitest.scopedLive('QueryBuilder subscription - direct table subscription', () =>
299
341
  Effect.gen(function* () {
300
342
  const { store, exporter, span, provider } = yield* makeQuery
301
343
 
302
344
  const callbackResults: any[] = []
303
345
 
304
- const unsubscribe = store.subscribe(tables.todos, {
305
- onUpdate: (result) => {
306
- callbackResults.push(result)
307
- },
346
+ const unsubscribe = store.subscribe(tables.todos, (result) => {
347
+ callbackResults.push(result)
308
348
  })
309
349
 
310
350
  expect(callbackResults).toHaveLength(1)
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
- * const unsubscribe = store.subscribe(query$, { onUpdate: (result) => console.log(result) })
362
+ * const unsubscribe = store.subscribe(query$, (result) => console.log(result))
363
+ * ```
364
+ *
365
+ * @example
366
+ * ```ts
367
+ * for await (const result of store.subscribe(query$)) {
368
+ * console.log(result)
369
+ * }
358
370
  * ```
359
371
  */
360
- subscribe = <TResult>(
361
- query: LiveQueryDef<TResult, 'def' | 'signal-def'> | LiveQuery<TResult> | QueryBuilder<TResult, any, any>,
362
- options: {
363
- /** Called when the query result has changed */
364
- onUpdate: (value: TResult) => void
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
- },
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>,
386
+ onUpdate: (value: TResult) => void,
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)
@@ -398,8 +407,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
398
407
 
399
408
  const label = `subscribe:${options?.label}`
400
409
  const effect = this.reactivityGraph.makeEffect(
401
- (get, _otelContext, debugRefreshReason) =>
402
- options.onUpdate(get(query$.results$, otelContext, debugRefreshReason)),
410
+ (get, _otelContext, debugRefreshReason) => onUpdate(get(query$.results$, otelContext, debugRefreshReason)),
403
411
  { label },
404
412
  )
405
413
 
@@ -411,13 +419,14 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
411
419
 
412
420
  this.activeQueries.add(query$ as LiveQuery<TResult>)
413
421
 
414
- // Running effect right away to get initial value (unless `skipInitialRun` is set)
415
422
  if (options?.skipInitialRun !== true && !query$.isDestroyed) {
416
- 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
+ })
417
427
  }
418
428
 
419
429
  const unsubscribe = () => {
420
- // console.debug('store unsub', query$.id, query$.label)
421
430
  try {
422
431
  this.reactivityGraph.destroyNode(effect)
423
432
  this.activeQueries.remove(query$ as LiveQuery<TResult>)
@@ -439,10 +448,16 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
439
448
  )
440
449
  }
441
450
 
442
- subscribeStream = <TResult>(
443
- query$: LiveQueryDef<TResult>,
444
- options?: { label?: string; skipInitialRun?: boolean } | undefined,
445
- ): 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> =>
446
461
  Stream.asyncPush<TResult>((emit) =>
447
462
  Effect.gen(this, function* () {
448
463
  const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(
@@ -452,9 +467,9 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
452
467
 
453
468
  yield* Effect.acquireRelease(
454
469
  Effect.sync(() =>
455
- this.subscribe(query$, {
456
- onUpdate: (result) => emit.single(result),
457
- ...omitUndefineds({ otelContext, label: options?.label }),
470
+ this.subscribe(query, (result) => emit.single(result), {
471
+ ...(options ?? {}),
472
+ otelContext,
458
473
  }),
459
474
  ),
460
475
  (unsub) => Effect.sync(() => unsub()),
@@ -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')
@@ -847,7 +857,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
847
857
  syncStates: () =>
848
858
  Effect.gen(this, function* () {
849
859
  const session = yield* this.syncProcessor.syncState
850
- const leader = yield* this.clientSession.leaderThread.getSyncState
860
+ const leader = yield* this.clientSession.leaderThread.syncState
851
861
  return { session, leader }
852
862
  }).pipe(this.runEffectPromise),
853
863
 
@@ -858,7 +868,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
858
868
  `Session sync state: ${session.localHead} (upstream: ${session.upstreamHead})`,
859
869
  session.toJSON(),
860
870
  )
861
- const leader = yield* this.clientSession.leaderThread.getSyncState
871
+ const leader = yield* this.clientSession.leaderThread.syncState
862
872
  yield* Effect.log(`Leader sync state: ${leader.localHead} (upstream: ${leader.upstreamHead})`, leader.toJSON())
863
873
  }).pipe(this.runEffectFork)
864
874
  },