@livestore/livestore 0.4.0-dev.1 → 0.4.0-dev.11

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 (61) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/effect/LiveStore.d.ts.map +1 -1
  3. package/dist/effect/LiveStore.js +2 -4
  4. package/dist/effect/LiveStore.js.map +1 -1
  5. package/dist/live-queries/base-class.d.ts.map +1 -1
  6. package/dist/live-queries/base-class.js.map +1 -1
  7. package/dist/live-queries/db-query.d.ts.map +1 -1
  8. package/dist/live-queries/db-query.js +7 -4
  9. package/dist/live-queries/db-query.js.map +1 -1
  10. package/dist/live-queries/db-query.test.js +53 -24
  11. package/dist/live-queries/db-query.test.js.map +1 -1
  12. package/dist/mod.d.ts +2 -2
  13. package/dist/mod.d.ts.map +1 -1
  14. package/dist/mod.js +1 -1
  15. package/dist/mod.js.map +1 -1
  16. package/dist/reactive.d.ts +10 -10
  17. package/dist/reactive.d.ts.map +1 -1
  18. package/dist/reactive.js +36 -27
  19. package/dist/reactive.js.map +1 -1
  20. package/dist/reactive.test.js +115 -0
  21. package/dist/reactive.test.js.map +1 -1
  22. package/dist/store/create-store.d.ts.map +1 -1
  23. package/dist/store/create-store.js +3 -3
  24. package/dist/store/create-store.js.map +1 -1
  25. package/dist/store/store-types.d.ts +13 -2
  26. package/dist/store/store-types.d.ts.map +1 -1
  27. package/dist/store/store-types.js.map +1 -1
  28. package/dist/store/store.d.ts +45 -29
  29. package/dist/store/store.d.ts.map +1 -1
  30. package/dist/store/store.js +165 -100
  31. package/dist/store/store.js.map +1 -1
  32. package/dist/utils/dev.d.ts +3 -0
  33. package/dist/utils/dev.d.ts.map +1 -1
  34. package/dist/utils/dev.js.map +1 -1
  35. package/dist/utils/tests/fixture.d.ts.map +1 -1
  36. package/dist/utils/tests/fixture.js +2 -1
  37. package/dist/utils/tests/fixture.js.map +1 -1
  38. package/dist/utils/tests/otel.d.ts +15 -14
  39. package/dist/utils/tests/otel.d.ts.map +1 -1
  40. package/dist/utils/tests/otel.js +20 -15
  41. package/dist/utils/tests/otel.js.map +1 -1
  42. package/package.json +7 -7
  43. package/src/ambient.d.ts +3 -3
  44. package/src/effect/LiveStore.ts +2 -4
  45. package/src/live-queries/__snapshots__/db-query.test.ts.snap +354 -130
  46. package/src/live-queries/base-class.ts +6 -3
  47. package/src/live-queries/db-query.test.ts +70 -24
  48. package/src/live-queries/db-query.ts +7 -4
  49. package/src/mod.ts +10 -1
  50. package/src/reactive.test.ts +150 -1
  51. package/src/reactive.ts +47 -39
  52. package/src/store/create-store.ts +12 -4
  53. package/src/store/store-types.ts +23 -2
  54. package/src/store/store.ts +262 -193
  55. package/src/utils/dev.ts +5 -0
  56. package/src/utils/tests/fixture.ts +2 -1
  57. package/src/utils/tests/otel.ts +31 -20
  58. package/dist/store/store-shutdown.test.d.ts +0 -2
  59. package/dist/store/store-shutdown.test.d.ts.map +0 -1
  60. package/dist/store/store-shutdown.test.js +0 -103
  61. package/dist/store/store-shutdown.test.js.map +0 -1
@@ -3,24 +3,24 @@ import {
3
3
  type ClientSession,
4
4
  type ClientSessionSyncProcessor,
5
5
  Devtools,
6
- getDurationMsFromSpan,
7
6
  getExecStatementsFromMaterializer,
8
7
  getResultSchema,
9
8
  hashMaterializerResults,
10
9
  IntentionalShutdownCause,
11
10
  isQueryBuilder,
12
11
  liveStoreVersion,
12
+ MaterializeError,
13
+ MaterializerHashMismatchError,
13
14
  makeClientSessionSyncProcessor,
14
15
  type PreparedBindValues,
15
16
  prepareBindValues,
16
- type QueryBuilder,
17
17
  QueryBuilderAstSymbol,
18
18
  replaceSessionIdSymbol,
19
19
  UnexpectedError,
20
20
  } from '@livestore/common'
21
21
  import type { LiveStoreSchema } from '@livestore/common/schema'
22
- import { getEventDef, LiveStoreEvent, SystemTables } from '@livestore/common/schema'
23
- import { assertNever, isDevEnv, notYetImplemented } from '@livestore/utils'
22
+ import { LiveStoreEvent, resolveEventDef, SystemTables } from '@livestore/common/schema'
23
+ import { assertNever, isDevEnv, notYetImplemented, omitUndefineds, shouldNeverHappen } from '@livestore/utils'
24
24
  import type { Scope } from '@livestore/utils/effect'
25
25
  import {
26
26
  Cause,
@@ -37,13 +37,7 @@ import {
37
37
  import { nanoid } from '@livestore/utils/nanoid'
38
38
  import * as otel from '@opentelemetry/api'
39
39
 
40
- import type {
41
- LiveQuery,
42
- LiveQueryDef,
43
- ReactivityGraph,
44
- ReactivityGraphContext,
45
- SignalDef,
46
- } from '../live-queries/base-class.ts'
40
+ import type { LiveQuery, ReactivityGraph, ReactivityGraphContext, SignalDef } from '../live-queries/base-class.ts'
47
41
  import { makeReactivityGraph } from '../live-queries/base-class.ts'
48
42
  import { makeExecBeforeFirstRun } from '../live-queries/client-document-get-query.ts'
49
43
  import { queryDb } from '../live-queries/db-query.ts'
@@ -51,16 +45,26 @@ import type { Ref } from '../reactive.ts'
51
45
  import { SqliteDbWrapper } from '../SqliteDbWrapper.ts'
52
46
  import { ReferenceCountedSet } from '../utils/data-structures.ts'
53
47
  import { downloadBlob, exposeDebugUtils } from '../utils/dev.ts'
54
- import type { StackInfo } from '../utils/stack-info.ts'
55
48
  import type {
49
+ Queryable,
56
50
  RefreshReason,
57
51
  StoreCommitOptions,
58
52
  StoreEventsOptions,
59
53
  StoreOptions,
60
54
  StoreOtel,
55
+ SubscribeOptions,
61
56
  Unsubscribe,
62
57
  } from './store-types.ts'
63
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
+
64
68
  if (isDevEnv()) {
65
69
  exposeDebugUtils()
66
70
  }
@@ -73,6 +77,24 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
73
77
  schema: LiveStoreSchema
74
78
  context: TContext
75
79
  otel: StoreOtel
80
+ /**
81
+ * Reactive connectivity updates emitted by the backing sync backend.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import { Effect, Stream } from 'effect'
86
+ *
87
+ * const status = await store.networkStatus.pipe(Effect.runPromise)
88
+ *
89
+ * await store.networkStatus.changes.pipe(
90
+ * Stream.tap((next) => console.log('network status update', next)),
91
+ * Stream.runDrain,
92
+ * Effect.scoped,
93
+ * Effect.runPromise,
94
+ * )
95
+ * ```
96
+ */
97
+ readonly networkStatus: ClientSession['leaderThread']['networkStatus']
76
98
  /**
77
99
  * Note we're using `Ref<null>` here as we don't care about the value but only about *that* something has changed.
78
100
  * This only works in combination with `equal: () => false` which will always trigger a refresh.
@@ -117,6 +139,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
117
139
  this.clientSession = clientSession
118
140
  this.schema = schema
119
141
  this.context = context
142
+ this.networkStatus = clientSession.leaderThread.networkStatus
120
143
 
121
144
  this.effectContext = effectContext
122
145
 
@@ -128,72 +151,91 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
128
151
  schema,
129
152
  clientSession,
130
153
  runtime: effectContext.runtime,
131
- materializeEvent: (eventDecoded, { otelContext, withChangeset, materializerHashLeader }) => {
132
- const { eventDef, materializer } = getEventDef(schema, eventDecoded.name)
133
-
134
- const execArgsArr = getExecStatementsFromMaterializer({
135
- eventDef,
136
- materializer,
137
- dbState: this.sqliteDbWrapper,
138
- event: { decoded: eventDecoded, encoded: undefined },
139
- })
140
-
141
- const materializerHash = isDevEnv() ? Option.some(hashMaterializerResults(execArgsArr)) : Option.none()
142
-
143
- if (
144
- materializerHashLeader._tag === 'Some' &&
145
- materializerHash._tag === 'Some' &&
146
- materializerHashLeader.value !== materializerHash.value
147
- ) {
148
- void this.shutdown(
149
- Cause.fail(
150
- UnexpectedError.make({
151
- cause: `Materializer hash mismatch detected for event "${eventDecoded.name}".`,
152
- note: `Please make sure your event materializer is a pure function without side effects.`,
153
- }),
154
- ),
155
- )
156
- }
157
-
158
- const writeTablesForEvent = new Set<string>()
159
-
160
- const exec = () => {
161
- for (const {
162
- statementSql,
163
- bindValues,
164
- writeTables = this.sqliteDbWrapper.getTablesUsed(statementSql),
165
- } of execArgsArr) {
166
- try {
167
- this.sqliteDbWrapper.cachedExecute(statementSql, bindValues, { otelContext, writeTables })
168
- } catch (cause) {
169
- throw UnexpectedError.make({
170
- cause,
171
- note: `Error executing materializer for event "${eventDecoded.name}".\nStatement: ${statementSql}\nBind values: ${JSON.stringify(bindValues)}`,
172
- })
154
+ materializeEvent: Effect.fn('client-session-sync-processor:materialize-event')(
155
+ (eventEncoded, { withChangeset, materializerHashLeader }) =>
156
+ // We need to use `Effect.gen` (even though we're using `Effect.fn`) so that we can pass `this` to the function
157
+ Effect.gen(this, function* () {
158
+ const resolution = yield* resolveEventDef(schema, {
159
+ operation: '@livestore/livestore:store:materializeEvent',
160
+ event: eventEncoded,
161
+ })
162
+
163
+ if (resolution._tag === 'unknown') {
164
+ // Runtime schema doesn't know this event yet; skip materialization but
165
+ // keep the log entry so upgraded clients can replay it later.
166
+ return {
167
+ writeTables: new Set<string>(),
168
+ sessionChangeset: { _tag: 'no-op' as const },
169
+ materializerHash: Option.none(),
170
+ }
173
171
  }
174
172
 
175
- // durationMsTotal += durationMs
176
- for (const table of writeTables) {
177
- writeTablesForEvent.add(table)
173
+ const { eventDef, materializer } = resolution
174
+
175
+ const execArgsArr = getExecStatementsFromMaterializer({
176
+ eventDef,
177
+ materializer,
178
+ dbState: this.sqliteDbWrapper,
179
+ event: { decoded: undefined, encoded: eventEncoded },
180
+ })
181
+
182
+ const materializerHash = isDevEnv() ? Option.some(hashMaterializerResults(execArgsArr)) : Option.none()
183
+
184
+ // Hash mismatch detection only occurs during the pull path (when receiving events from the leader).
185
+ // During push path (local commits), materializerHashLeader is always Option.none(), so this condition
186
+ // will never be met. The check happens when the same event comes back from the leader during sync,
187
+ // allowing us to compare the leader's computed hash with our local re-materialization hash.
188
+ if (
189
+ materializerHashLeader._tag === 'Some' &&
190
+ materializerHash._tag === 'Some' &&
191
+ materializerHashLeader.value !== materializerHash.value
192
+ ) {
193
+ return yield* MaterializerHashMismatchError.make({ eventName: eventEncoded.name })
178
194
  }
179
195
 
180
- this.sqliteDbWrapper.debug.head = eventDecoded.seqNum
181
- }
182
- }
183
-
184
- let sessionChangeset:
185
- | { _tag: 'sessionChangeset'; data: Uint8Array<ArrayBuffer>; debug: any }
186
- | { _tag: 'no-op' }
187
- | { _tag: 'unset' } = { _tag: 'unset' }
196
+ const span = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie)
197
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
198
+
199
+ const writeTablesForEvent = new Set<string>()
200
+
201
+ const exec = () => {
202
+ for (const {
203
+ statementSql,
204
+ bindValues,
205
+ writeTables = this.sqliteDbWrapper.getTablesUsed(statementSql),
206
+ } of execArgsArr) {
207
+ try {
208
+ this.sqliteDbWrapper.cachedExecute(statementSql, bindValues, { otelContext, writeTables })
209
+ } catch (cause) {
210
+ // TOOD refactor with `SqliteError`
211
+ throw UnexpectedError.make({
212
+ cause,
213
+ note: `Error executing materializer for event "${eventEncoded.name}".\nStatement: ${statementSql}\nBind values: ${JSON.stringify(bindValues)}`,
214
+ })
215
+ }
216
+
217
+ // durationMsTotal += durationMs
218
+ for (const table of writeTables) {
219
+ writeTablesForEvent.add(table)
220
+ }
221
+
222
+ this.sqliteDbWrapper.debug.head = eventEncoded.seqNum
223
+ }
224
+ }
188
225
 
189
- if (withChangeset === true) {
190
- sessionChangeset = this.sqliteDbWrapper.withChangeset(exec).changeset
191
- } else {
192
- exec()
193
- }
226
+ let sessionChangeset:
227
+ | { _tag: 'sessionChangeset'; data: Uint8Array<ArrayBuffer>; debug: any }
228
+ | { _tag: 'no-op' }
229
+ | { _tag: 'unset' } = { _tag: 'unset' }
230
+ if (withChangeset === true) {
231
+ sessionChangeset = this.sqliteDbWrapper.withChangeset(exec).changeset
232
+ } else {
233
+ exec()
234
+ }
194
235
 
195
- return { writeTables: writeTablesForEvent, sessionChangeset, materializerHash }
196
- },
236
+ return { writeTables: writeTablesForEvent, sessionChangeset, materializerHash }
237
+ }).pipe(Effect.mapError((cause) => MaterializeError.make({ cause }))),
238
+ ),
197
239
  rollback: (changeset) => {
198
240
  this.sqliteDbWrapper.rollback(changeset)
199
241
  },
@@ -208,8 +250,12 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
208
250
  },
209
251
  span: syncSpan,
210
252
  params: {
211
- leaderPushBatchSize: params.leaderPushBatchSize,
212
- simulation: params.simulation?.clientSessionSyncProcessor,
253
+ ...omitUndefineds({
254
+ leaderPushBatchSize: params.leaderPushBatchSize,
255
+ }),
256
+ ...(params.simulation?.clientSessionSyncProcessor !== undefined
257
+ ? { simulation: params.simulation.clientSessionSyncProcessor }
258
+ : {}),
213
259
  },
214
260
  confirmUnsavedChanges,
215
261
  })
@@ -306,32 +352,39 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
306
352
  }
307
353
 
308
354
  /**
309
- * Subscribe to the results of a query
310
- * 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.
311
359
  *
312
360
  * @example
313
361
  * ```ts
314
- * 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
+ * }
315
370
  * ```
316
371
  */
317
- subscribe = <TResult>(
318
- query: LiveQueryDef<TResult, 'def' | 'signal-def'> | LiveQuery<TResult> | QueryBuilder<TResult, any, any>,
319
- options: {
320
- /** Called when the query result has changed */
321
- onUpdate: (value: TResult) => void
322
- onSubscribe?: (query$: LiveQuery<TResult>) => void
323
- /** Gets called after the query subscription has been removed */
324
- onUnsubsubscribe?: () => void
325
- label?: string
326
- /**
327
- * Skips the initial `onUpdate` callback
328
- * @default false
329
- */
330
- skipInitialRun?: boolean
331
- otelContext?: otel.Context
332
- /** If provided, the stack info will be added to the `activeSubscriptions` set of the query */
333
- stackInfo?: StackInfo
334
- },
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>,
335
388
  ): Unsubscribe => {
336
389
  this.checkShutdown('subscribe')
337
390
 
@@ -340,7 +393,6 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
340
393
  { attributes: { label: options?.label, queryLabel: isQueryBuilder(query) ? query.toString() : query.label } },
341
394
  options?.otelContext ?? this.otel.queriesSpanContext,
342
395
  (span) => {
343
- // console.debug('store sub', query$.id, query$.label)
344
396
  const otelContext = otel.trace.setSpan(otel.context.active(), span)
345
397
 
346
398
  const queryRcRef = isQueryBuilder(query)
@@ -355,8 +407,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
355
407
 
356
408
  const label = `subscribe:${options?.label}`
357
409
  const effect = this.reactivityGraph.makeEffect(
358
- (get, _otelContext, debugRefreshReason) =>
359
- options.onUpdate(get(query$.results$, otelContext, debugRefreshReason)),
410
+ (get, _otelContext, debugRefreshReason) => onUpdate(get(query$.results$, otelContext, debugRefreshReason)),
360
411
  { label },
361
412
  )
362
413
 
@@ -368,13 +419,14 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
368
419
 
369
420
  this.activeQueries.add(query$ as LiveQuery<TResult>)
370
421
 
371
- // Running effect right away to get initial value (unless `skipInitialRun` is set)
372
422
  if (options?.skipInitialRun !== true && !query$.isDestroyed) {
373
- 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
+ })
374
427
  }
375
428
 
376
429
  const unsubscribe = () => {
377
- // console.debug('store unsub', query$.id, query$.label)
378
430
  try {
379
431
  this.reactivityGraph.destroyNode(effect)
380
432
  this.activeQueries.remove(query$ as LiveQuery<TResult>)
@@ -396,10 +448,16 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
396
448
  )
397
449
  }
398
450
 
399
- subscribeStream = <TResult>(
400
- query$: LiveQueryDef<TResult>,
401
- options?: { label?: string; skipInitialRun?: boolean } | undefined,
402
- ): 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> =>
403
461
  Stream.asyncPush<TResult>((emit) =>
404
462
  Effect.gen(this, function* () {
405
463
  const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(
@@ -409,10 +467,9 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
409
467
 
410
468
  yield* Effect.acquireRelease(
411
469
  Effect.sync(() =>
412
- this.subscribe(query$, {
413
- onUpdate: (result) => emit.single(result),
470
+ this.subscribe(query, (result) => emit.single(result), {
471
+ ...(options ?? {}),
414
472
  otelContext,
415
- label: options?.label,
416
473
  }),
417
474
  ),
418
475
  (unsub) => Effect.sync(() => unsub()),
@@ -435,19 +492,14 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
435
492
  * ```
436
493
  */
437
494
  query = <TResult>(
438
- query:
439
- | QueryBuilder<TResult, any, any>
440
- | LiveQuery<TResult>
441
- | LiveQueryDef<TResult>
442
- | SignalDef<TResult>
443
- | { query: string; bindValues: Bindable; schema?: Schema.Schema<TResult> },
495
+ query: Queryable<TResult> | { query: string; bindValues: Bindable; schema?: Schema.Schema<TResult> },
444
496
  options?: { otelContext?: otel.Context; debugRefreshReason?: RefreshReason },
445
497
  ): TResult => {
446
498
  this.checkShutdown('query')
447
499
 
448
500
  if (typeof query === 'object' && 'query' in query && 'bindValues' in query) {
449
501
  const res = this.sqliteDbWrapper.cachedSelect(query.query, prepareBindValues(query.bindValues, query.query), {
450
- otelContext: options?.otelContext,
502
+ ...omitUndefineds({ otelContext: options?.otelContext }),
451
503
  }) as any
452
504
  if (query.schema) {
453
505
  return Schema.decodeSync(query.schema)(res)
@@ -473,11 +525,23 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
473
525
  }
474
526
 
475
527
  const rawRes = this.sqliteDbWrapper.cachedSelect(sqlRes.query, sqlRes.bindValues as any as PreparedBindValues, {
476
- otelContext: options?.otelContext,
528
+ ...omitUndefineds({ otelContext: options?.otelContext }),
477
529
  queriedTables: new Set([query[QueryBuilderAstSymbol].tableDef.sqliteDef.name]),
478
530
  })
479
531
 
480
- return Schema.decodeSync(schema)(rawRes)
532
+ const decodeResult = Schema.decodeEither(schema)(rawRes)
533
+ if (decodeResult._tag === 'Right') {
534
+ return decodeResult.right
535
+ } else {
536
+ return shouldNeverHappen(
537
+ `Failed to decode query result with for schema:`,
538
+ schema.toString(),
539
+ 'raw result:',
540
+ rawRes,
541
+ 'decode error:',
542
+ decodeResult.left,
543
+ )
544
+ }
481
545
  } else if (query._tag === 'def') {
482
546
  const query$ = query.make(this.reactivityGraph.context!)
483
547
  const result = this.query(query$.value, options)
@@ -487,7 +551,9 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
487
551
  const signal$ = query.make(this.reactivityGraph.context!)
488
552
  return signal$.value.get()
489
553
  } else {
490
- return query.run({ otelContext: options?.otelContext, debugRefreshReason: options?.debugRefreshReason })
554
+ return query.run({
555
+ ...omitUndefineds({ otelContext: options?.otelContext, debugRefreshReason: options?.debugRefreshReason }),
556
+ })
491
557
  }
492
558
  }
493
559
 
@@ -597,84 +663,76 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
597
663
 
598
664
  const { events, options } = this.getCommitArgs(firstEventOrTxnFnOrOptions, restEvents)
599
665
 
600
- for (const event of events) {
601
- replaceSessionIdSymbol(event.args, this.clientSession.sessionId)
602
- }
603
-
604
- if (events.length === 0) return
666
+ Effect.gen(this, function* () {
667
+ const commitsSpan = otel.trace.getSpan(this.otel.commitsSpanContext)
668
+ commitsSpan?.addEvent('commit')
669
+ const currentSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie)
670
+ commitsSpan?.addLink({ context: currentSpan.spanContext() })
605
671
 
606
- const skipRefresh = options?.skipRefresh ?? false
607
-
608
- const commitsSpan = otel.trace.getSpan(this.otel.commitsSpanContext)!
609
- commitsSpan.addEvent('commit')
610
-
611
- // console.group('LiveStore.commit', { skipRefresh })
612
- // events.forEach((_) => console.debug(_.name, _.args))
613
- // console.groupEnd()
614
-
615
- let durationMs: number
616
-
617
- return this.otel.tracer.startActiveSpan(
618
- 'LiveStore:commit',
619
- {
620
- attributes: {
621
- 'livestore.eventsCount': events.length,
622
- 'livestore.eventTags': events.map((_) => _.name),
623
- 'livestore.commitLabel': options?.label,
624
- },
625
- links: options?.spanLinks,
626
- },
627
- options?.otelContext ?? this.otel.commitsSpanContext,
628
- (span) => {
629
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
672
+ for (const event of events) {
673
+ replaceSessionIdSymbol(event.args, this.clientSession.sessionId)
674
+ }
630
675
 
631
- try {
632
- // Materialize events to state
633
- const { writeTables } = (() => {
634
- try {
635
- const materializeEvents = () => this.syncProcessor.push(events, { otelContext })
676
+ if (events.length === 0) return
636
677
 
637
- if (events.length > 1) {
638
- return this.sqliteDbWrapper.txn(materializeEvents)
639
- } else {
640
- return materializeEvents()
641
- }
642
- } catch (e: any) {
643
- console.error(e)
644
- span.setStatus({ code: otel.SpanStatusCode.ERROR, message: e.toString() })
645
- throw e
646
- } finally {
647
- span.end()
648
- }
649
- })()
678
+ const localRuntime = yield* Effect.runtime()
650
679
 
651
- const tablesToUpdate = [] as [Ref<null, ReactivityGraphContext, RefreshReason>, null][]
652
- for (const tableName of writeTables) {
653
- const tableRef = this.tableRefs[tableName]
654
- assertNever(tableRef !== undefined, `No table ref found for ${tableName}`)
655
- tablesToUpdate.push([tableRef!, null])
680
+ const materializeEventsTx = Effect.try({
681
+ try: () => {
682
+ const runMaterializeEvents = () => {
683
+ return this.syncProcessor.push(events).pipe(Runtime.runSync(localRuntime))
656
684
  }
657
685
 
658
- const debugRefreshReason = {
659
- _tag: 'commit' as const,
660
- events,
661
- writeTables: Array.from(writeTables),
686
+ if (events.length > 1) {
687
+ return this.sqliteDbWrapper.txn(runMaterializeEvents)
688
+ } else {
689
+ return runMaterializeEvents()
662
690
  }
691
+ },
692
+ catch: (cause) => UnexpectedError.make({ cause }),
693
+ })
663
694
 
664
- // Update all table refs together in a batch, to only trigger one reactive update
665
- this.reactivityGraph.setRefs(tablesToUpdate, { debugRefreshReason, otelContext, skipRefresh })
666
- } catch (e: any) {
667
- console.error(e)
668
- span.setStatus({ code: otel.SpanStatusCode.ERROR, message: e.toString() })
669
- throw e
670
- } finally {
671
- span.end()
695
+ // Materialize events to state
696
+ const { writeTables } = yield* materializeEventsTx
672
697
 
673
- durationMs = getDurationMsFromSpan(span)
674
- }
698
+ const tablesToUpdate: [Ref<null, ReactivityGraphContext, RefreshReason>, null][] = []
699
+ for (const tableName of writeTables) {
700
+ const tableRef = this.tableRefs[tableName]
701
+ assertNever(tableRef !== undefined, `No table ref found for ${tableName}`)
702
+ tablesToUpdate.push([tableRef!, null])
703
+ }
675
704
 
676
- return { durationMs }
677
- },
705
+ const debugRefreshReason: RefreshReason = {
706
+ _tag: 'commit',
707
+ events,
708
+ writeTables: Array.from(writeTables),
709
+ }
710
+ const skipRefresh = options?.skipRefresh ?? false
711
+
712
+ // Update all table refs together in a batch, to only trigger one reactive update
713
+ this.reactivityGraph.setRefs(tablesToUpdate, {
714
+ debugRefreshReason,
715
+ skipRefresh,
716
+ otelContext: otel.trace.setSpan(otel.context.active(), currentSpan),
717
+ })
718
+ }).pipe(
719
+ Effect.withSpan('LiveStore:commit', {
720
+ root: true,
721
+ attributes: {
722
+ 'livestore.eventsCount': events.length,
723
+ 'livestore.eventTags': events.map((_) => _.name),
724
+ ...(options?.label && { 'livestore.commitLabel': options.label }),
725
+ },
726
+ links: [
727
+ // Span link to LiveStore:commits
728
+ OtelTracer.makeSpanLink({ context: otel.trace.getSpanContext(this.otel.commitsSpanContext)! }),
729
+ // User-provided span links
730
+ ...(options?.spanLinks?.map(OtelTracer.makeSpanLink) ?? []),
731
+ ],
732
+ }),
733
+ Effect.tapErrorCause(Effect.logError),
734
+ Effect.catchAllCause((cause) => Effect.fork(this.shutdown(cause))),
735
+ Runtime.runSync(this.effectContext.runtime),
678
736
  )
679
737
  }
680
738
  // #endregion commit
@@ -746,9 +804,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
746
804
  *
747
805
  * This is called automatically when the store was created using the React or Effect API.
748
806
  */
749
- shutdown = (cause?: Cause.Cause<UnexpectedError>): Effect.Effect<void> => {
750
- this.checkShutdown('shutdown')
751
-
807
+ shutdown = (cause?: Cause.Cause<UnexpectedError | MaterializeError>): Effect.Effect<void> => {
752
808
  this.isShutdown = true
753
809
  return this.clientSession.shutdown(
754
810
  cause ? Exit.failCause(cause) : Exit.succeed(IntentionalShutdownCause.make({ reason: 'manual' })),
@@ -798,12 +854,22 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
798
854
  .pipe(this.runEffectFork)
799
855
  },
800
856
 
801
- syncStates: () => {
857
+ syncStates: () =>
802
858
  Effect.gen(this, function* () {
803
859
  const session = yield* this.syncProcessor.syncState
804
- console.log('Session sync state:', session.toJSON())
805
- const leader = yield* this.clientSession.leaderThread.getSyncState
806
- console.log('Leader sync state:', leader.toJSON())
860
+ const leader = yield* this.clientSession.leaderThread.syncState
861
+ return { session, leader }
862
+ }).pipe(this.runEffectPromise),
863
+
864
+ printSyncStates: () => {
865
+ Effect.gen(this, function* () {
866
+ const session = yield* this.syncProcessor.syncState
867
+ yield* Effect.log(
868
+ `Session sync state: ${session.localHead} (upstream: ${session.upstreamHead})`,
869
+ session.toJSON(),
870
+ )
871
+ const leader = yield* this.clientSession.leaderThread.syncState
872
+ yield* Effect.log(`Leader sync state: ${leader.localHead} (upstream: ${leader.upstreamHead})`, leader.toJSON())
807
873
  }).pipe(this.runEffectFork)
808
874
  },
809
875
 
@@ -827,6 +893,9 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
827
893
  Runtime.runFork(this.effectContext.runtime),
828
894
  )
829
895
 
896
+ private runEffectPromise = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
897
+ effect.pipe(Effect.tapCauseLogPretty, Runtime.runPromise(this.effectContext.runtime))
898
+
830
899
  private getCommitArgs = (
831
900
  firstEventOrTxnFnOrOptions: any,
832
901
  restEvents: any[],
package/src/utils/dev.ts CHANGED
@@ -2,6 +2,11 @@ import type { SqliteDb } from '@livestore/common'
2
2
  import { prettyBytes } from '@livestore/utils'
3
3
  import { Effect } from '@livestore/utils/effect'
4
4
 
5
+ declare global {
6
+ // declaring a global *value* is the least fussy when augmenting inline
7
+ var __debugLiveStoreUtils: any
8
+ }
9
+
5
10
  export const downloadBlob = (
6
11
  data: Uint8Array<ArrayBuffer> | Blob | string,
7
12
  fileName: string,
@@ -1,6 +1,7 @@
1
1
  import { makeInMemoryAdapter } from '@livestore/adapter-web'
2
2
  import { provideOtel } from '@livestore/common'
3
3
  import { createStore, Events, makeSchema, State } from '@livestore/livestore'
4
+ import { omitUndefineds } from '@livestore/utils'
4
5
  import { Effect, Schema } from '@livestore/utils/effect'
5
6
  import type * as otel from '@opentelemetry/api'
6
7
 
@@ -71,4 +72,4 @@ export const makeTodoMvc = ({
71
72
  })
72
73
 
73
74
  return store
74
- }).pipe(provideOtel({ parentSpanContext: otelContext, otelTracer: otelTracer }))
75
+ }).pipe(provideOtel(omitUndefineds({ parentSpanContext: otelContext, otelTracer: otelTracer })))