@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,7 +1,6 @@
1
1
  import {
2
2
  type Bindable,
3
3
  type ClientSession,
4
- type ClientSessionSyncProcessor,
5
4
  Devtools,
6
5
  getExecStatementsFromMaterializer,
7
6
  getResultSchema,
@@ -14,14 +13,16 @@ import {
14
13
  makeClientSessionSyncProcessor,
15
14
  type PreparedBindValues,
16
15
  prepareBindValues,
17
- type QueryBuilder,
18
16
  QueryBuilderAstSymbol,
19
17
  replaceSessionIdSymbol,
20
- UnexpectedError,
18
+ type StorageMode,
19
+ type SyncState,
20
+ UnknownError,
21
21
  } from '@livestore/common'
22
+ import type { StreamEventsOptions } from '@livestore/common/leader-thread'
22
23
  import type { LiveStoreSchema } from '@livestore/common/schema'
23
- import { getEventDef, LiveStoreEvent, SystemTables } from '@livestore/common/schema'
24
- import { assertNever, isDevEnv, notYetImplemented, omitUndefineds, shouldNeverHappen } from '@livestore/utils'
24
+ import { EventSequenceNumber, LiveStoreEvent, resolveEventDef, SystemTables } from '@livestore/common/schema'
25
+ import { assertNever, isDevEnv, objectToString, omitUndefineds, shouldNeverHappen } from '@livestore/utils'
25
26
  import type { Scope } from '@livestore/utils/effect'
26
27
  import {
27
28
  Cause,
@@ -38,13 +39,7 @@ import {
38
39
  import { nanoid } from '@livestore/utils/nanoid'
39
40
  import * as otel from '@opentelemetry/api'
40
41
 
41
- import type {
42
- LiveQuery,
43
- LiveQueryDef,
44
- ReactivityGraph,
45
- ReactivityGraphContext,
46
- SignalDef,
47
- } from '../live-queries/base-class.ts'
42
+ import type { LiveQuery, ReactivityGraphContext, SignalDef } from '../live-queries/base-class.ts'
48
43
  import { makeReactivityGraph } from '../live-queries/base-class.ts'
49
44
  import { makeExecBeforeFirstRun } from '../live-queries/client-document-get-query.ts'
50
45
  import { queryDb } from '../live-queries/db-query.ts'
@@ -52,52 +47,143 @@ import type { Ref } from '../reactive.ts'
52
47
  import { SqliteDbWrapper } from '../SqliteDbWrapper.ts'
53
48
  import { ReferenceCountedSet } from '../utils/data-structures.ts'
54
49
  import { downloadBlob, exposeDebugUtils } from '../utils/dev.ts'
55
- import type { StackInfo } from '../utils/stack-info.ts'
56
- import type {
57
- RefreshReason,
58
- StoreCommitOptions,
59
- StoreEventsOptions,
60
- StoreOptions,
61
- StoreOtel,
62
- Unsubscribe,
50
+ import {
51
+ type Queryable,
52
+ type RefreshReason,
53
+ type StoreCommitOptions,
54
+ type StoreConstructorParams,
55
+ type StoreEventsOptions,
56
+ type StoreInternals,
57
+ StoreInternalsSymbol,
58
+ type StoreOtel,
59
+ type SubscribeOptions,
60
+ type SyncStatus,
61
+ type Unsubscribe,
63
62
  } from './store-types.ts'
64
63
 
65
- if (isDevEnv()) {
64
+ export type SubscribeFn = {
65
+ <TResult>(
66
+ query: Queryable<TResult>,
67
+ onUpdate: (value: TResult) => void,
68
+ options?: SubscribeOptions<TResult>,
69
+ ): Unsubscribe
70
+ <TResult>(query: Queryable<TResult>, options?: SubscribeOptions<TResult>): AsyncIterable<TResult>
71
+ }
72
+
73
+ if (isDevEnv() === true) {
66
74
  exposeDebugUtils()
67
75
  }
68
76
 
77
+ /**
78
+ * Default parameters for the Store. Also used in `create-store.ts`
79
+ */
80
+ export const STORE_DEFAULT_PARAMS = {
81
+ leaderPushBatchSize: 100,
82
+ eventQueryBatchSize: 100,
83
+ }
84
+
85
+ //
86
+ /**
87
+ * Central interface to a LiveStore database providing reactive queries, event commits, and sync.
88
+ *
89
+ * A `Store` instance wraps a local SQLite database that is kept in sync with other clients via
90
+ * an event log. Instead of mutating state directly, you commit events that get materialized
91
+ * into database rows. Queries automatically re-run when their underlying tables change.
92
+ *
93
+ * ## Creating a Store
94
+ *
95
+ * Use `createStore` (Effect-based) or `createStorePromise` to obtain a Store instance.
96
+ * In React applications, use `StoreRegistry` with `<StoreRegistryProvider>` and the `useStore()` hook
97
+ * which manages the Store lifecycle.
98
+ *
99
+ * ## Querying Data
100
+ *
101
+ * Use {@link Store.query} for one-shot reads or {@link Store.subscribe} for reactive subscriptions.
102
+ * Both accept query builders (e.g. `tables.todo.where({ complete: true })`) or custom `LiveQueryDef`s.
103
+ *
104
+ * ## Committing Events
105
+ *
106
+ * Use {@link Store.commit} to persist events. Events are immediately materialized locally and
107
+ * asynchronously synced to other clients. Multiple events can be committed atomically.
108
+ *
109
+ * ## Lifecycle
110
+ *
111
+ * The Store must be shut down when no longer needed via {@link Store.shutdown} or
112
+ * {@link Store.shutdownPromise}. Framework integrations (React, Effect) handle this automatically.
113
+ *
114
+ * @typeParam TSchema - The LiveStore schema defining tables and events
115
+ * @typeParam TContext - Optional user-defined context attached to the Store (e.g. for dependency injection)
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * // Query data
120
+ * const todos = store.query(tables.todo.where({ complete: false }))
121
+ *
122
+ * // Subscribe to changes
123
+ * const unsubscribe = store.subscribe(tables.todo.all(), (todos) => {
124
+ * console.log('Todos updated:', todos)
125
+ * })
126
+ *
127
+ * // Commit an event
128
+ * store.commit(events.todoCreated({ id: nanoid(), text: 'Buy milk' }))
129
+ * ```
130
+ */
69
131
  export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TContext = {}> extends Inspectable.Class {
132
+ /** Unique identifier for this Store instance, stable for its lifetime. */
70
133
  readonly storeId: string
71
- reactivityGraph: ReactivityGraph
72
- sqliteDbWrapper: SqliteDbWrapper
73
- clientSession: ClientSession
74
- schema: LiveStoreSchema
75
- context: TContext
76
- otel: StoreOtel
77
- /**
78
- * Note we're using `Ref<null>` here as we don't care about the value but only about *that* something has changed.
79
- * This only works in combination with `equal: () => false` which will always trigger a refresh.
80
- */
81
- tableRefs: { [key: string]: Ref<null, ReactivityGraphContext, RefreshReason> }
82
134
 
83
- /** Tracks whether the store has been shut down */
84
- private isShutdown = false
135
+ /** The LiveStore schema defining tables, events, and materializers. */
136
+ readonly schema: LiveStoreSchema
85
137
 
86
- private effectContext: {
87
- runtime: Runtime.Runtime<Scope.Scope>
88
- lifetimeScope: Scope.Scope
89
- }
138
+ /** User-defined context attached to this Store (e.g. for dependency injection). */
139
+ readonly context: TContext
140
+
141
+ /** Options provided to the Store constructor. */
142
+ readonly params: StoreConstructorParams<TSchema, TContext>['params']
90
143
 
91
- /** RC-based set to see which queries are currently subscribed to */
92
- activeQueries: ReferenceCountedSet<LiveQuery<any>>
144
+ /**
145
+ * Reactive connectivity updates emitted by the backing sync backend.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * import { Effect, Stream } from 'effect'
150
+ *
151
+ * const status = await store.networkStatus.pipe(Effect.runPromise)
152
+ *
153
+ * await store.networkStatus.changes.pipe(
154
+ * Stream.tap((next) => console.log('network status update', next)),
155
+ * Stream.runDrain,
156
+ * Effect.scoped,
157
+ * Effect.runPromise,
158
+ * )
159
+ * ```
160
+ */
161
+ readonly networkStatus: ClientSession['leaderThread']['networkStatus']
93
162
 
94
- // NOTE this is currently exposed for the Devtools databrowser to commit events
95
- readonly __eventSchema
96
- readonly syncProcessor: ClientSessionSyncProcessor
163
+ /**
164
+ * Indicates how data is being stored.
165
+ *
166
+ * - `persisted`: Data is persisted to disk (e.g., via OPFS on web, SQLite file on native)
167
+ * - `in-memory`: Data is only stored in memory and will be lost on page refresh
168
+ *
169
+ * The store operates in `in-memory` mode when persistent storage is unavailable,
170
+ * such as in Safari/Firefox private browsing mode where OPFS is restricted.
171
+ *
172
+ * @example
173
+ * ```tsx
174
+ * if (store.storageMode === 'in-memory') {
175
+ * showWarning('Data will not be persisted in private browsing mode')
176
+ * }
177
+ * ```
178
+ */
179
+ readonly storageMode: StorageMode
97
180
 
98
- readonly boot: Effect.Effect<void, UnexpectedError, Scope.Scope>
181
+ /**
182
+ * Store internals. Not part of the public API — shapes and semantics may change without notice.
183
+ */
184
+ readonly [StoreInternalsSymbol]: StoreInternals
99
185
 
100
- // #region constructor
186
+ //#region constructor
101
187
  constructor({
102
188
  clientSession,
103
189
  schema,
@@ -109,40 +195,51 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
109
195
  params,
110
196
  confirmUnsavedChanges,
111
197
  __runningInDevtools,
112
- }: StoreOptions<TSchema, TContext>) {
198
+ }: StoreConstructorParams<TSchema, TContext>) {
113
199
  super()
114
200
 
115
201
  this.storeId = storeId
116
-
117
- this.sqliteDbWrapper = new SqliteDbWrapper({ otel: otelOptions, db: clientSession.sqliteDb })
118
- this.clientSession = clientSession
119
202
  this.schema = schema
120
203
  this.context = context
121
-
122
- this.effectContext = effectContext
204
+ this.params = params
205
+ this.networkStatus = clientSession.leaderThread.networkStatus
206
+ this.storageMode = clientSession.leaderThread.initialState.storageMode
123
207
 
124
208
  const reactivityGraph = makeReactivityGraph()
125
209
 
126
- const syncSpan = otelOptions.tracer.startSpan('LiveStore:sync', {}, otelOptions.rootSpanContext)
127
-
128
- this.syncProcessor = makeClientSessionSyncProcessor({
210
+ const syncProcessor = makeClientSessionSyncProcessor({
129
211
  schema,
130
212
  clientSession,
131
- runtime: effectContext.runtime,
132
213
  materializeEvent: Effect.fn('client-session-sync-processor:materialize-event')(
133
- (eventDecoded, { withChangeset, materializerHashLeader }) =>
214
+ (eventEncoded, { withChangeset, materializerHashLeader }) =>
134
215
  // We need to use `Effect.gen` (even though we're using `Effect.fn`) so that we can pass `this` to the function
135
216
  Effect.gen(this, function* () {
136
- const { eventDef, materializer } = getEventDef(schema, eventDecoded.name)
217
+ const resolution = yield* resolveEventDef(schema, {
218
+ operation: '@livestore/livestore:store:materializeEvent',
219
+ event: eventEncoded,
220
+ })
221
+
222
+ if (resolution._tag === 'unknown') {
223
+ // Runtime schema doesn't know this event yet; skip materialization but
224
+ // keep the log entry so upgraded clients can replay it later.
225
+ return {
226
+ writeTables: new Set<string>(),
227
+ sessionChangeset: { _tag: 'no-op' as const },
228
+ materializerHash: Option.none(),
229
+ }
230
+ }
231
+
232
+ const { eventDef, materializer } = resolution
137
233
 
138
234
  const execArgsArr = getExecStatementsFromMaterializer({
139
235
  eventDef,
140
236
  materializer,
141
- dbState: this.sqliteDbWrapper,
142
- event: { decoded: eventDecoded, encoded: undefined },
237
+ dbState: this[StoreInternalsSymbol].sqliteDbWrapper,
238
+ event: { decoded: undefined, encoded: eventEncoded },
143
239
  })
144
240
 
145
- const materializerHash = isDevEnv() ? Option.some(hashMaterializerResults(execArgsArr)) : Option.none()
241
+ const materializerHash =
242
+ isDevEnv() === true ? Option.some(hashMaterializerResults(execArgsArr)) : Option.none()
146
243
 
147
244
  // Hash mismatch detection only occurs during the pull path (when receiving events from the leader).
148
245
  // During push path (local commits), materializerHashLeader is always Option.none(), so this condition
@@ -153,7 +250,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
153
250
  materializerHash._tag === 'Some' &&
154
251
  materializerHashLeader.value !== materializerHash.value
155
252
  ) {
156
- return yield* MaterializerHashMismatchError.make({ eventName: eventDecoded.name })
253
+ return yield* MaterializerHashMismatchError.make({ eventName: eventEncoded.name })
157
254
  }
158
255
 
159
256
  const span = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie)
@@ -165,15 +262,18 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
165
262
  for (const {
166
263
  statementSql,
167
264
  bindValues,
168
- writeTables = this.sqliteDbWrapper.getTablesUsed(statementSql),
265
+ writeTables = this[StoreInternalsSymbol].sqliteDbWrapper.getTablesUsed(statementSql),
169
266
  } of execArgsArr) {
170
267
  try {
171
- this.sqliteDbWrapper.cachedExecute(statementSql, bindValues, { otelContext, writeTables })
268
+ this[StoreInternalsSymbol].sqliteDbWrapper.cachedExecute(statementSql, bindValues, {
269
+ otelContext,
270
+ writeTables,
271
+ })
172
272
  } catch (cause) {
173
273
  // TOOD refactor with `SqliteError`
174
- throw UnexpectedError.make({
274
+ throw UnknownError.make({
175
275
  cause,
176
- note: `Error executing materializer for event "${eventDecoded.name}".\nStatement: ${statementSql}\nBind values: ${JSON.stringify(bindValues)}`,
276
+ note: `Error executing materializer for event "${eventEncoded.name}".\nStatement: ${statementSql}\nBind values: ${JSON.stringify(bindValues)}`,
177
277
  })
178
278
  }
179
279
 
@@ -182,7 +282,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
182
282
  writeTablesForEvent.add(table)
183
283
  }
184
284
 
185
- this.sqliteDbWrapper.debug.head = eventDecoded.seqNum
285
+ this[StoreInternalsSymbol].sqliteDbWrapper.debug.head = eventEncoded.seqNum
186
286
  }
187
287
  }
188
288
 
@@ -191,7 +291,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
191
291
  | { _tag: 'no-op' }
192
292
  | { _tag: 'unset' } = { _tag: 'unset' }
193
293
  if (withChangeset === true) {
194
- sessionChangeset = this.sqliteDbWrapper.withChangeset(exec).changeset
294
+ sessionChangeset = this[StoreInternalsSymbol].sqliteDbWrapper.withChangeset(exec).changeset
195
295
  } else {
196
296
  exec()
197
297
  }
@@ -200,18 +300,17 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
200
300
  }).pipe(Effect.mapError((cause) => MaterializeError.make({ cause }))),
201
301
  ),
202
302
  rollback: (changeset) => {
203
- this.sqliteDbWrapper.rollback(changeset)
303
+ this[StoreInternalsSymbol].sqliteDbWrapper.rollback(changeset)
204
304
  },
205
305
  refreshTables: (tables) => {
206
306
  const tablesToUpdate = [] as [Ref<null, ReactivityGraphContext, RefreshReason>, null][]
207
307
  for (const tableName of tables) {
208
- const tableRef = this.tableRefs[tableName]
308
+ const tableRef = this[StoreInternalsSymbol].tableRefs[tableName]
209
309
  assertNever(tableRef !== undefined, `No table ref found for ${tableName}`)
210
310
  tablesToUpdate.push([tableRef!, null])
211
311
  }
212
312
  reactivityGraph.setRefs(tablesToUpdate)
213
313
  },
214
- span: syncSpan,
215
314
  params: {
216
315
  ...omitUndefineds({
217
316
  leaderPushBatchSize: params.leaderPushBatchSize,
@@ -221,13 +320,11 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
221
320
  : {}),
222
321
  },
223
322
  confirmUnsavedChanges,
224
- })
225
-
226
- this.__eventSchema = LiveStoreEvent.makeEventDefSchemaMemo(schema)
323
+ }).pipe(Runtime.runSync(effectContext.runtime))
227
324
 
228
325
  // TODO generalize the `tableRefs` concept to allow finer-grained refs
229
- this.tableRefs = {}
230
- this.activeQueries = new ReferenceCountedSet()
326
+ const tableRefs: { [key: string]: Ref<null, ReactivityGraphContext, RefreshReason> } = {}
327
+ const activeQueries = new ReferenceCountedSet<LiveQuery<any>>()
231
328
 
232
329
  const commitsSpan = otelOptions.tracer.startSpan('LiveStore:commits', {}, otelOptions.rootSpanContext)
233
330
  const otelMuationsSpanContext = otel.trace.setSpan(otel.context.active(), commitsSpan)
@@ -235,8 +332,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
235
332
  const queriesSpan = otelOptions.tracer.startSpan('LiveStore:queries', {}, otelOptions.rootSpanContext)
236
333
  const otelQueriesSpanContext = otel.trace.setSpan(otel.context.active(), queriesSpan)
237
334
 
238
- this.reactivityGraph = reactivityGraph
239
- this.reactivityGraph.context = {
335
+ reactivityGraph.context = {
240
336
  store: this as unknown as Store<LiveStoreSchema>,
241
337
  defRcMap: new Map(),
242
338
  reactivityGraph: new WeakRef(reactivityGraph),
@@ -244,8 +340,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
244
340
  rootOtelContext: otelQueriesSpanContext,
245
341
  effectsWrapper: batchUpdates,
246
342
  }
247
-
248
- this.otel = {
343
+ const otelObj: StoreOtel = {
249
344
  tracer: otelOptions.tracer,
250
345
  rootSpanContext: otelOptions.rootSpanContext,
251
346
  commitsSpanContext: otelMuationsSpanContext,
@@ -256,58 +351,93 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
256
351
  const allTableNames = new Set(
257
352
  // NOTE we're excluding the LiveStore schema and events tables as they are not user-facing
258
353
  // unless LiveStore is running in the devtools
259
- __runningInDevtools
354
+ __runningInDevtools === true
260
355
  ? this.schema.state.sqlite.tables.keys()
261
356
  : Array.from(this.schema.state.sqlite.tables.keys()).filter((_) => !SystemTables.isStateSystemTable(_)),
262
357
  )
263
358
  const existingTableRefs = new Map(
264
- Array.from(this.reactivityGraph.atoms.values())
359
+ Array.from(reactivityGraph.atoms.values())
265
360
  .filter((_): _ is Ref<any, any, any> => _._tag === 'ref' && _.label?.startsWith('tableRef:') === true)
266
361
  .map((_) => [_.label!.slice('tableRef:'.length), _] as const),
267
362
  )
268
363
  for (const tableName of allTableNames) {
269
- this.tableRefs[tableName] =
364
+ tableRefs[tableName] =
270
365
  existingTableRefs.get(tableName) ??
271
- this.reactivityGraph.makeRef(null, {
366
+ reactivityGraph.makeRef(null, {
272
367
  equal: () => false,
273
- label: `tableRef:${tableName}`,
368
+ label: `tableRef:${String(tableName)}`,
274
369
  meta: { liveStoreRefType: 'table' },
275
370
  })
276
371
  }
277
372
 
278
- this.boot = Effect.gen(this, function* () {
373
+ const boot = Effect.gen(this, function* () {
279
374
  yield* Effect.addFinalizer(() =>
280
375
  Effect.sync(() => {
281
376
  // Remove all table refs from the reactivity graph
282
- for (const tableRef of Object.values(this.tableRefs)) {
377
+ for (const tableRef of Object.values(tableRefs)) {
283
378
  for (const superComp of tableRef.super) {
284
- this.reactivityGraph.removeEdge(superComp, tableRef)
379
+ this[StoreInternalsSymbol].reactivityGraph.removeEdge(superComp, tableRef)
285
380
  }
286
381
  }
287
382
 
288
383
  // End the otel spans
289
- syncSpan.end()
290
384
  commitsSpan.end()
291
385
  queriesSpan.end()
292
386
  }),
293
387
  )
294
388
 
295
- yield* this.syncProcessor.boot
389
+ yield* syncProcessor.boot
296
390
  })
391
+
392
+ // Build Sqlite wrapper last to avoid using getters before internals are set
393
+ const sqliteDbWrapper = new SqliteDbWrapper({ otel: otelOptions, db: clientSession.sqliteDb })
394
+
395
+ // Initialize internals bag
396
+ this[StoreInternalsSymbol] = {
397
+ eventSchema: LiveStoreEvent.Client.makeSchemaMemo(schema) as Schema.Schema<
398
+ LiveStoreEvent.Client.Decoded,
399
+ LiveStoreEvent.Client.Encoded
400
+ >,
401
+ clientSession,
402
+ sqliteDbWrapper,
403
+ effectContext,
404
+ otel: otelObj,
405
+ reactivityGraph,
406
+ tableRefs,
407
+ activeQueries,
408
+ syncProcessor,
409
+ boot,
410
+ isShutdown: false,
411
+ }
412
+
413
+ // Initialize stable network status property from client session
414
+ this.networkStatus = clientSession.leaderThread.networkStatus
297
415
  }
298
- // #endregion constructor
416
+ //#endregion constructor
299
417
 
418
+ /**
419
+ * Current session identifier for this Store instance.
420
+ *
421
+ * - Stable for the lifetime of the Store
422
+ * - Useful for correlating events or scoping per-session data
423
+ */
300
424
  get sessionId(): string {
301
- return this.clientSession.sessionId
425
+ return this[StoreInternalsSymbol].clientSession.sessionId
302
426
  }
303
427
 
428
+ /**
429
+ * Stable client identifier for the process/device using this Store.
430
+ *
431
+ * - Shared across Store instances created by the same client
432
+ * - Useful for diagnostics and multi-client correlation
433
+ */
304
434
  get clientId(): string {
305
- return this.clientSession.clientId
435
+ return this[StoreInternalsSymbol].clientSession.clientId
306
436
  }
307
437
 
308
438
  private checkShutdown = (operation: string): void => {
309
- if (this.isShutdown) {
310
- throw new UnexpectedError({
439
+ if (this[StoreInternalsSymbol].isShutdown === true) {
440
+ throw new UnknownError({
311
441
  cause: `Store has been shut down (while performing "${operation}").`,
312
442
  note: `You cannot perform this operation after the store has been shut down.`,
313
443
  })
@@ -315,80 +445,109 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
315
445
  }
316
446
 
317
447
  /**
318
- * Subscribe to the results of a query
319
- * Returns a function to cancel the subscription.
448
+ * Subscribe to the results of a query.
449
+ *
450
+ * - When providing an `onUpdate` callback it returns an {@link Unsubscribe} function.
451
+ * - Without a callback it returns an {@link AsyncIterable} that yields query results.
452
+ *
453
+ * @example
454
+ * ```ts
455
+ * const unsubscribe = store.subscribe(query$, (result) => console.log(result))
456
+ * ```
320
457
  *
321
458
  * @example
322
459
  * ```ts
323
- * const unsubscribe = store.subscribe(query$, { onUpdate: (result) => console.log(result) })
460
+ * for await (const result of store.subscribe(query$)) {
461
+ * console.log(result)
462
+ * }
324
463
  * ```
325
464
  */
326
- subscribe = <TResult>(
327
- query: LiveQueryDef<TResult, 'def' | 'signal-def'> | LiveQuery<TResult> | QueryBuilder<TResult, any, any>,
328
- options: {
329
- /** Called when the query result has changed */
330
- onUpdate: (value: TResult) => void
331
- onSubscribe?: (query$: LiveQuery<TResult>) => void
332
- /** Gets called after the query subscription has been removed */
333
- onUnsubsubscribe?: () => void
334
- label?: string
335
- /**
336
- * Skips the initial `onUpdate` callback
337
- * @default false
338
- */
339
- skipInitialRun?: boolean
340
- otelContext?: otel.Context
341
- /** If provided, the stack info will be added to the `activeSubscriptions` set of the query */
342
- stackInfo?: StackInfo
343
- },
465
+ subscribe = (<TResult>(
466
+ query: Queryable<TResult>,
467
+ onUpdateOrOptions?: ((value: TResult) => void) | SubscribeOptions<TResult>,
468
+ maybeOptions?: SubscribeOptions<TResult>,
469
+ ): Unsubscribe | AsyncIterable<TResult> => {
470
+ if (typeof onUpdateOrOptions === 'function') {
471
+ return this.subscribeWithCallback(query, onUpdateOrOptions, maybeOptions)
472
+ }
473
+
474
+ return this.subscribeAsAsyncIterable(query, onUpdateOrOptions)
475
+ }) as SubscribeFn
476
+
477
+ private subscribeWithCallback = <TResult>(
478
+ query: Queryable<TResult>,
479
+ onUpdate: (value: TResult) => void,
480
+ options?: SubscribeOptions<TResult>,
344
481
  ): Unsubscribe => {
345
482
  this.checkShutdown('subscribe')
346
483
 
347
- return this.otel.tracer.startActiveSpan(
484
+ return this[StoreInternalsSymbol].otel.tracer.startActiveSpan(
348
485
  `LiveStore.subscribe`,
349
- { attributes: { label: options?.label, queryLabel: isQueryBuilder(query) ? query.toString() : query.label } },
350
- options?.otelContext ?? this.otel.queriesSpanContext,
486
+ {
487
+ attributes: {
488
+ label: options?.label,
489
+ queryLabel: isQueryBuilder(query) === true ? query.toString() : query.label,
490
+ },
491
+ },
492
+ options?.otelContext ?? this[StoreInternalsSymbol].otel.queriesSpanContext,
351
493
  (span) => {
352
- // console.debug('store sub', query$.id, query$.label)
353
494
  const otelContext = otel.trace.setSpan(otel.context.active(), span)
354
495
 
355
- const queryRcRef = isQueryBuilder(query)
356
- ? queryDb(query).make(this.reactivityGraph.context!)
357
- : query._tag === 'def' || query._tag === 'signal-def'
358
- ? query.make(this.reactivityGraph.context!)
359
- : {
360
- value: query as LiveQuery<TResult>,
361
- deref: () => {},
362
- }
496
+ const queryRcRef =
497
+ isQueryBuilder(query) === true
498
+ ? queryDb(query).make(this[StoreInternalsSymbol].reactivityGraph.context!)
499
+ : query._tag === 'def' || query._tag === 'signal-def'
500
+ ? query.make(this[StoreInternalsSymbol].reactivityGraph.context!)
501
+ : {
502
+ value: query,
503
+ deref: () => {},
504
+ }
363
505
  const query$ = queryRcRef.value
364
506
 
365
507
  const label = `subscribe:${options?.label}`
366
- const effect = this.reactivityGraph.makeEffect(
367
- (get, _otelContext, debugRefreshReason) =>
368
- options.onUpdate(get(query$.results$, otelContext, debugRefreshReason)),
508
+ let suppressCallback = options?.skipInitialRun === true
509
+ const effect = this[StoreInternalsSymbol].reactivityGraph.makeEffect(
510
+ (get, _otelContext, debugRefreshReason) => {
511
+ const result = get(query$.results$, otelContext, debugRefreshReason)
512
+ if (suppressCallback === true) {
513
+ return
514
+ }
515
+ onUpdate(result)
516
+ },
369
517
  { label },
370
518
  )
519
+ const runInitialEffect = () => {
520
+ effect.doEffect(otelContext, {
521
+ _tag: 'subscribe.initial',
522
+ label: `subscribe-initial-run:${options?.label}`,
523
+ })
524
+ }
371
525
 
372
- if (options?.stackInfo) {
526
+ if (options?.stackInfo !== undefined) {
373
527
  query$.activeSubscriptions.add(options.stackInfo)
374
528
  }
375
529
 
376
530
  options?.onSubscribe?.(query$)
377
531
 
378
- this.activeQueries.add(query$ as LiveQuery<TResult>)
532
+ this[StoreInternalsSymbol].activeQueries.add(query$ as LiveQuery<TResult>)
379
533
 
380
- // Running effect right away to get initial value (unless `skipInitialRun` is set)
381
- if (options?.skipInitialRun !== true && !query$.isDestroyed) {
382
- effect.doEffect(otelContext, { _tag: 'subscribe.initial', label: `subscribe-initial-run:${options?.label}` })
534
+ if (query$.isDestroyed === false) {
535
+ if (suppressCallback === true) {
536
+ // We still run once to register dependencies in the reactive graph, but suppress the initial callback so the
537
+ // caller truly skips the first emission; subsequent runs (after commits) will call the callback.
538
+ runInitialEffect()
539
+ suppressCallback = false
540
+ } else {
541
+ runInitialEffect()
542
+ }
383
543
  }
384
544
 
385
545
  const unsubscribe = () => {
386
- // console.debug('store unsub', query$.id, query$.label)
387
546
  try {
388
- this.reactivityGraph.destroyNode(effect)
389
- this.activeQueries.remove(query$ as LiveQuery<TResult>)
547
+ this[StoreInternalsSymbol].reactivityGraph.destroyNode(effect)
548
+ this[StoreInternalsSymbol].activeQueries.remove(query$ as LiveQuery<TResult>)
390
549
 
391
- if (options?.stackInfo) {
550
+ if (options?.stackInfo !== undefined) {
392
551
  query$.activeSubscriptions.delete(options.stackInfo)
393
552
  }
394
553
 
@@ -405,22 +564,29 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
405
564
  )
406
565
  }
407
566
 
408
- subscribeStream = <TResult>(
409
- query$: LiveQueryDef<TResult>,
410
- options?: { label?: string; skipInitialRun?: boolean } | undefined,
411
- ): Stream.Stream<TResult> =>
567
+ private subscribeAsAsyncIterable = <TResult>(
568
+ query: Queryable<TResult>,
569
+ options?: SubscribeOptions<TResult>,
570
+ ): AsyncIterable<TResult> => {
571
+ this.checkShutdown('subscribe')
572
+
573
+ return Stream.toAsyncIterable(this.subscribeStream(query, options))
574
+ }
575
+
576
+ subscribeStream = <TResult>(query: Queryable<TResult>, options?: SubscribeOptions<TResult>): Stream.Stream<TResult> =>
412
577
  Stream.asyncPush<TResult>((emit) =>
413
578
  Effect.gen(this, function* () {
414
579
  const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(
415
580
  Effect.catchTag('NoSuchElementException', () => Effect.succeed(undefined)),
416
581
  )
417
- const otelContext = otelSpan ? otel.trace.setSpan(otel.context.active(), otelSpan) : otel.context.active()
582
+ const otelContext =
583
+ otelSpan !== undefined ? otel.trace.setSpan(otel.context.active(), otelSpan) : otel.context.active()
418
584
 
419
585
  yield* Effect.acquireRelease(
420
586
  Effect.sync(() =>
421
- this.subscribe(query$, {
422
- onUpdate: (result) => emit.single(result),
423
- ...omitUndefineds({ otelContext, label: options?.label }),
587
+ this.subscribe(query, (result) => emit.single(result), {
588
+ ...options,
589
+ otelContext,
424
590
  }),
425
591
  ),
426
592
  (unsub) => Effect.sync(() => unsub()),
@@ -443,25 +609,24 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
443
609
  * ```
444
610
  */
445
611
  query = <TResult>(
446
- query:
447
- | QueryBuilder<TResult, any, any>
448
- | LiveQuery<TResult>
449
- | LiveQueryDef<TResult>
450
- | SignalDef<TResult>
451
- | { query: string; bindValues: Bindable; schema?: Schema.Schema<TResult> },
612
+ query: Queryable<TResult> | { query: string; bindValues: Bindable; schema?: Schema.Schema<TResult> },
452
613
  options?: { otelContext?: otel.Context; debugRefreshReason?: RefreshReason },
453
614
  ): TResult => {
454
615
  this.checkShutdown('query')
455
616
 
456
617
  if (typeof query === 'object' && 'query' in query && 'bindValues' in query) {
457
- const res = this.sqliteDbWrapper.cachedSelect(query.query, prepareBindValues(query.bindValues, query.query), {
458
- ...omitUndefineds({ otelContext: options?.otelContext }),
459
- }) as any
460
- if (query.schema) {
618
+ const res = this[StoreInternalsSymbol].sqliteDbWrapper.cachedSelect(
619
+ query.query,
620
+ prepareBindValues(query.bindValues, query.query),
621
+ {
622
+ ...omitUndefineds({ otelContext: options?.otelContext }),
623
+ },
624
+ ) as any
625
+ if (query.schema !== undefined) {
461
626
  return Schema.decodeSync(query.schema)(res)
462
627
  }
463
628
  return res
464
- } else if (isQueryBuilder(query)) {
629
+ } else if (isQueryBuilder(query) === true) {
465
630
  const ast = query[QueryBuilderAstSymbol]
466
631
  if (ast._tag === 'RowQuery') {
467
632
  makeExecBeforeFirstRun({
@@ -469,29 +634,33 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
469
634
  id: ast.id,
470
635
  explicitDefaultValues: ast.explicitDefaultValues,
471
636
  otelContext: options?.otelContext,
472
- })(this.reactivityGraph.context!)
637
+ })(this[StoreInternalsSymbol].reactivityGraph.context!)
473
638
  }
474
639
 
475
640
  const sqlRes = query.asSql()
476
641
  const schema = getResultSchema(query)
477
642
 
478
643
  // Replace SessionIdSymbol in bind values before executing the query
479
- if (sqlRes.bindValues) {
480
- replaceSessionIdSymbol(sqlRes.bindValues, this.clientSession.sessionId)
644
+ if (sqlRes.bindValues !== undefined) {
645
+ replaceSessionIdSymbol(sqlRes.bindValues, this[StoreInternalsSymbol].clientSession.sessionId)
481
646
  }
482
647
 
483
- const rawRes = this.sqliteDbWrapper.cachedSelect(sqlRes.query, sqlRes.bindValues as any as PreparedBindValues, {
484
- ...omitUndefineds({ otelContext: options?.otelContext }),
485
- queriedTables: new Set([query[QueryBuilderAstSymbol].tableDef.sqliteDef.name]),
486
- })
648
+ const rawRes = this[StoreInternalsSymbol].sqliteDbWrapper.cachedSelect(
649
+ sqlRes.query,
650
+ sqlRes.bindValues as any as PreparedBindValues,
651
+ {
652
+ ...omitUndefineds({ otelContext: options?.otelContext }),
653
+ queriedTables: new Set([query[QueryBuilderAstSymbol].tableDef.sqliteDef.name]),
654
+ },
655
+ )
487
656
 
488
657
  const decodeResult = Schema.decodeEither(schema)(rawRes)
489
658
  if (decodeResult._tag === 'Right') {
490
659
  return decodeResult.right
491
660
  } else {
492
661
  return shouldNeverHappen(
493
- `Failed to decode query result with for schema:`,
494
- schema.toString(),
662
+ 'Failed to decode query result with for schema:',
663
+ objectToString(schema),
495
664
  'raw result:',
496
665
  rawRes,
497
666
  'decode error:',
@@ -499,12 +668,12 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
499
668
  )
500
669
  }
501
670
  } else if (query._tag === 'def') {
502
- const query$ = query.make(this.reactivityGraph.context!)
671
+ const query$ = query.make(this[StoreInternalsSymbol].reactivityGraph.context!)
503
672
  const result = this.query(query$.value, options)
504
673
  query$.deref()
505
674
  return result
506
675
  } else if (query._tag === 'signal-def') {
507
- const signal$ = query.make(this.reactivityGraph.context!)
676
+ const signal$ = query.make(this[StoreInternalsSymbol].reactivityGraph.context!)
508
677
  return signal$.value.get()
509
678
  } else {
510
679
  return query.run({
@@ -531,7 +700,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
531
700
  setSignal = <T>(signalDef: SignalDef<T>, value: T | ((prev: T) => T)): void => {
532
701
  this.checkShutdown('setSignal')
533
702
 
534
- const signalRef = signalDef.make(this.reactivityGraph.context!)
703
+ const signalRef = signalDef.make(this[StoreInternalsSymbol].reactivityGraph.context!)
535
704
  const newValue: T = typeof value === 'function' ? (value as any)(signalRef.value.get()) : value
536
705
  signalRef.value.set(newValue)
537
706
 
@@ -545,7 +714,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
545
714
  }
546
715
  }
547
716
 
548
- // #region commit
717
+ //#region commit
549
718
  /**
550
719
  * Commit a list of events to the store which will immediately update the local database
551
720
  * and sync the events across other clients (similar to a `git commit`).
@@ -598,19 +767,19 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
598
767
  * ```
599
768
  */
600
769
  commit: {
601
- <const TCommitArg extends ReadonlyArray<LiveStoreEvent.PartialForSchema<TSchema>>>(...list: TCommitArg): void
770
+ <const TCommitArg extends ReadonlyArray<LiveStoreEvent.Input.ForSchema<TSchema>>>(...list: TCommitArg): void
602
771
  (
603
- txn: <const TCommitArg extends ReadonlyArray<LiveStoreEvent.PartialForSchema<TSchema>>>(
772
+ txn: <const TCommitArg extends ReadonlyArray<LiveStoreEvent.Input.ForSchema<TSchema>>>(
604
773
  ...list: TCommitArg
605
774
  ) => void,
606
775
  ): void
607
- <const TCommitArg extends ReadonlyArray<LiveStoreEvent.PartialForSchema<TSchema>>>(
776
+ <const TCommitArg extends ReadonlyArray<LiveStoreEvent.Input.ForSchema<TSchema>>>(
608
777
  options: StoreCommitOptions,
609
778
  ...list: TCommitArg
610
779
  ): void
611
780
  (
612
781
  options: StoreCommitOptions,
613
- txn: <const TCommitArg extends ReadonlyArray<LiveStoreEvent.PartialForSchema<TSchema>>>(
782
+ txn: <const TCommitArg extends ReadonlyArray<LiveStoreEvent.Input.ForSchema<TSchema>>>(
614
783
  ...list: TCommitArg
615
784
  ) => void,
616
785
  ): void
@@ -620,40 +789,37 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
620
789
  const { events, options } = this.getCommitArgs(firstEventOrTxnFnOrOptions, restEvents)
621
790
 
622
791
  Effect.gen(this, function* () {
623
- const commitsSpan = otel.trace.getSpan(this.otel.commitsSpanContext)
792
+ const commitsSpan = otel.trace.getSpan(this[StoreInternalsSymbol].otel.commitsSpanContext)
624
793
  commitsSpan?.addEvent('commit')
625
794
  const currentSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie)
626
795
  commitsSpan?.addLink({ context: currentSpan.spanContext() })
627
796
 
628
797
  for (const event of events) {
629
- replaceSessionIdSymbol(event.args, this.clientSession.sessionId)
798
+ replaceSessionIdSymbol(event.args, this[StoreInternalsSymbol].clientSession.sessionId)
630
799
  }
631
800
 
632
801
  if (events.length === 0) return
633
802
 
634
803
  const localRuntime = yield* Effect.runtime()
635
804
 
636
- const materializeEventsTx = Effect.try({
637
- try: () => {
638
- const runMaterializeEvents = () => {
639
- return this.syncProcessor.push(events).pipe(Runtime.runSync(localRuntime))
640
- }
805
+ const encodedEvents = yield* this[StoreInternalsSymbol].syncProcessor.encodeEvents(events)
641
806
 
642
- if (events.length > 1) {
643
- return this.sqliteDbWrapper.txn(runMaterializeEvents)
644
- } else {
645
- return runMaterializeEvents()
646
- }
807
+ const { writeTables } = yield* Effect.try({
808
+ try: () => {
809
+ const materialize = () =>
810
+ this[StoreInternalsSymbol].syncProcessor.materializeEvents(encodedEvents).pipe(Runtime.runSync(localRuntime))
811
+ return events.length > 1
812
+ ? this[StoreInternalsSymbol].sqliteDbWrapper.txn(materialize)
813
+ : materialize()
647
814
  },
648
- catch: (cause) => UnexpectedError.make({ cause }),
815
+ catch: (cause) => UnknownError.make({ cause }),
649
816
  })
650
817
 
651
- // Materialize events to state
652
- const { writeTables } = yield* materializeEventsTx
818
+ yield* this[StoreInternalsSymbol].syncProcessor.push(encodedEvents)
653
819
 
654
820
  const tablesToUpdate: [Ref<null, ReactivityGraphContext, RefreshReason>, null][] = []
655
821
  for (const tableName of writeTables) {
656
- const tableRef = this.tableRefs[tableName]
822
+ const tableRef = this[StoreInternalsSymbol].tableRefs[tableName]
657
823
  assertNever(tableRef !== undefined, `No table ref found for ${tableName}`)
658
824
  tablesToUpdate.push([tableRef!, null])
659
825
  }
@@ -666,7 +832,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
666
832
  const skipRefresh = options?.skipRefresh ?? false
667
833
 
668
834
  // Update all table refs together in a batch, to only trigger one reactive update
669
- this.reactivityGraph.setRefs(tablesToUpdate, {
835
+ this[StoreInternalsSymbol].reactivityGraph.setRefs(tablesToUpdate, {
670
836
  debugRefreshReason,
671
837
  skipRefresh,
672
838
  otelContext: otel.trace.setSpan(otel.context.active(), currentSpan),
@@ -681,46 +847,204 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
681
847
  },
682
848
  links: [
683
849
  // Span link to LiveStore:commits
684
- OtelTracer.makeSpanLink({ context: otel.trace.getSpanContext(this.otel.commitsSpanContext)! }),
850
+ OtelTracer.makeSpanLink({
851
+ context: otel.trace.getSpanContext(this[StoreInternalsSymbol].otel.commitsSpanContext)!,
852
+ }),
685
853
  // User-provided span links
686
854
  ...(options?.spanLinks?.map(OtelTracer.makeSpanLink) ?? []),
687
855
  ],
688
856
  }),
689
857
  Effect.tapErrorCause(Effect.logError),
690
858
  Effect.catchAllCause((cause) => Effect.fork(this.shutdown(cause))),
691
- Runtime.runSync(this.effectContext.runtime),
859
+ Runtime.runSync(this[StoreInternalsSymbol].effectContext.runtime),
692
860
  )
693
861
  }
694
- // #endregion commit
862
+ //#endregion commit
695
863
 
696
864
  /**
697
- * Returns an async iterable of events.
865
+ * Returns an async iterable of events from the eventlog.
866
+ * Currently only events confirmed by the sync backend is supported.
867
+ *
868
+ * Defaults to tracking upstreamHead as it advances. If an `until` event is
869
+ * supplied the stream finalizes upon reaching it.
870
+ *
871
+ * To start streaming from a specific point in the eventlog
872
+ * you can provide a `since` event.
873
+ *
874
+ * Allows filtering by:
875
+ * - `filter`: event types
876
+ * - `clientIds`: client identifiers
877
+ * - `sessionIds`: session identifiers
878
+ *
879
+ * The batchSize option controls the maximum amount of events that are fetched
880
+ * from the eventlog in each query. Defaults to 100 and has a max allowed
881
+ * value of 1000.
882
+ *
883
+ * TODO:
884
+ * - Support streaming unconfirmed events
885
+ * - Leader level
886
+ * - Session level
887
+ * - Support streaming client-only events
698
888
  *
699
889
  * @example
700
890
  * ```ts
701
- * for await (const event of store.events()) {
891
+ * // Stream todoCompleted events from the start
892
+ * for await (const event of store.events(filter: ['todoCompleted'])) {
702
893
  * console.log(event)
703
894
  * }
704
895
  * ```
705
896
  *
706
897
  * @example
707
898
  * ```ts
708
- * // Get all events from the beginning of time
709
- * for await (const event of store.events({ cursor: EventSequenceNumber.ROOT })) {
899
+ * // Start streaming from a specific event
900
+ * for await (const event of store.events({ since: EventSequenceNumber.Client.fromString('e3') })) {
710
901
  * console.log(event)
711
902
  * }
712
903
  * ```
713
904
  */
714
- events = (_options?: StoreEventsOptions<TSchema>): AsyncIterable<LiveStoreEvent.ForSchema<TSchema>> => {
715
- this.checkShutdown('events')
905
+ events = (options?: StoreEventsOptions<TSchema>): AsyncIterable<LiveStoreEvent.Client.ForSchema<TSchema>> => {
906
+ const stream = this.eventsStream(options)
907
+ return {
908
+ async *[Symbol.asyncIterator]() {
909
+ const iterator = Stream.toAsyncIterable(stream)
910
+ for await (const event of iterator) {
911
+ yield event
912
+ }
913
+ },
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Returns an Effect Stream of events from the eventlog.
919
+ * See `store.events` for details on options and behaviour.
920
+ */
921
+ eventsStream = (
922
+ options?: StoreEventsOptions<TSchema>,
923
+ ): Stream.Stream<LiveStoreEvent.Client.ForSchema<TSchema>, UnknownError> => {
924
+ const { clientSession } = this[StoreInternalsSymbol]
925
+ const eventSchema = LiveStoreEvent.Client.makeSchema(this.schema)
926
+
927
+ const preferredBatchSize =
928
+ options?.batchSize ?? this.params.eventQueryBatchSize ?? STORE_DEFAULT_PARAMS.eventQueryBatchSize
929
+
930
+ const baseOptions: StreamEventsOptions = {
931
+ ...options,
932
+ filter: options?.filter as readonly string[],
933
+ batchSize: preferredBatchSize,
934
+ }
935
+
936
+ return clientSession.leaderThread.events.stream(baseOptions).pipe(
937
+ Stream.mapChunksEffect(Schema.decode(Schema.ChunkFromSelf(eventSchema))),
938
+ Stream.catchTag('ParseError', (cause) => Stream.fail(UnknownError.make({ cause }))),
939
+ Stream.tapError((error) => Effect.logError('Error in eventsStream', error)),
940
+ )
941
+ }
942
+
943
+ /**
944
+ * Returns the current synchronization status of the store.
945
+ *
946
+ * This is a synchronous operation that returns the sync state between the
947
+ * client session and the leader thread. Use this to display sync indicators
948
+ * or check if local changes have been pushed to the leader.
949
+ *
950
+ * @example
951
+ * ```ts
952
+ * const status = store.syncStatus()
953
+ * console.log(status.isSynced ? 'Synced' : `${status.pendingCount} pending`)
954
+ * ```
955
+ *
956
+ * @example
957
+ * ```ts
958
+ * // Health check for backend connectivity
959
+ * const status = store.syncStatus()
960
+ * if (!status.isSynced && status.pendingCount > 100) {
961
+ * console.warn('Large backlog of unsynced events')
962
+ * }
963
+ * ```
964
+ */
965
+ syncStatus = (): SyncStatus => {
966
+ this.checkShutdown('syncStatus')
967
+
968
+ const syncState = this[StoreInternalsSymbol].syncProcessor.syncState.pipe(Effect.runSync)
969
+ const pendingCount = syncState.pending.length
970
+
971
+ return {
972
+ localHead: EventSequenceNumber.Client.toString(syncState.localHead),
973
+ upstreamHead: EventSequenceNumber.Client.toString(syncState.upstreamHead),
974
+ pendingCount,
975
+ isSynced: pendingCount === 0,
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Returns an Effect Stream of sync status updates.
981
+ *
982
+ * Emits the current status immediately and then whenever the sync state changes.
983
+ * Use this for Effect-based workflows or when you need more control over the stream.
984
+ *
985
+ * @example
986
+ * ```ts
987
+ * store.syncStatusStream().pipe(
988
+ * Stream.tap((status) => Effect.log(`Sync status: ${status.isSynced}`)),
989
+ * Stream.runDrain,
990
+ * )
991
+ * ```
992
+ */
993
+ syncStatusStream = (): Stream.Stream<SyncStatus> => {
994
+ const syncStateSubscribable = this[StoreInternalsSymbol].syncProcessor.syncState
716
995
 
717
- return notYetImplemented(`store.events() is not yet implemented but planned soon`)
996
+ return Stream.concat(
997
+ Stream.fromEffect(syncStateSubscribable.pipe(Effect.map(this.makeSyncStatus))),
998
+ syncStateSubscribable.changes.pipe(Stream.map(this.makeSyncStatus)),
999
+ )
718
1000
  }
719
1001
 
720
- eventsStream = (_options?: StoreEventsOptions<TSchema>): Stream.Stream<LiveStoreEvent.ForSchema<TSchema>> => {
721
- this.checkShutdown('eventsStream')
1002
+ /**
1003
+ * Subscribes to sync status changes.
1004
+ *
1005
+ * The callback is invoked immediately with the current status and then
1006
+ * whenever the sync state changes (e.g., when events are pushed or confirmed).
1007
+ *
1008
+ * @param onUpdate - Callback invoked with the current sync status
1009
+ * @returns Unsubscribe function to stop receiving updates
1010
+ *
1011
+ * @example
1012
+ * ```ts
1013
+ * const unsubscribe = store.subscribeSyncStatus((status) => {
1014
+ * updateUI(status.isSynced ? 'Synced' : 'Syncing...')
1015
+ * })
1016
+ *
1017
+ * // Later, stop listening
1018
+ * unsubscribe()
1019
+ * ```
1020
+ */
1021
+ subscribeSyncStatus = (onUpdate: (status: SyncStatus) => void): Unsubscribe => {
1022
+ this.checkShutdown('subscribeSyncStatus')
1023
+
1024
+ const fiber = this.syncStatusStream().pipe(
1025
+ Stream.tap((status) => Effect.sync(() => onUpdate(status))),
1026
+ Stream.runDrain,
1027
+ this.runEffectFork,
1028
+ )
722
1029
 
723
- return notYetImplemented(`store.eventsStream() is not yet implemented but planned soon`)
1030
+ return () => {
1031
+ Fiber.interrupt(fiber).pipe(Runtime.runFork(this[StoreInternalsSymbol].effectContext.runtime))
1032
+ }
1033
+ }
1034
+
1035
+ private makeSyncStatus = (syncState: {
1036
+ localHead: EventSequenceNumber.Client.Composite
1037
+ upstreamHead: EventSequenceNumber.Client.Composite
1038
+ pending: readonly any[]
1039
+ }): SyncStatus => {
1040
+ const pendingCount = syncState.pending.length
1041
+
1042
+ return {
1043
+ localHead: EventSequenceNumber.Client.toString(syncState.localHead),
1044
+ upstreamHead: EventSequenceNumber.Client.toString(syncState.upstreamHead),
1045
+ pendingCount,
1046
+ isSynced: pendingCount === 0,
1047
+ }
724
1048
  }
725
1049
 
726
1050
  /**
@@ -731,13 +1055,13 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
731
1055
  this.checkShutdown('manualRefresh')
732
1056
 
733
1057
  const { label } = options ?? {}
734
- this.otel.tracer.startActiveSpan(
1058
+ this[StoreInternalsSymbol].otel.tracer.startActiveSpan(
735
1059
  'LiveStore:manualRefresh',
736
1060
  { attributes: { 'livestore.manualRefreshLabel': label } },
737
- this.otel.commitsSpanContext,
1061
+ this[StoreInternalsSymbol].otel.commitsSpanContext,
738
1062
  (span) => {
739
1063
  const otelContext = otel.trace.setSpan(otel.context.active(), span)
740
- this.reactivityGraph.runDeferredEffects({ otelContext })
1064
+ this[StoreInternalsSymbol].reactivityGraph.runDeferredEffects({ otelContext })
741
1065
  span.end()
742
1066
  },
743
1067
  )
@@ -748,11 +1072,15 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
748
1072
  *
749
1073
  * This is called automatically when the store was created using the React or Effect API.
750
1074
  */
751
- shutdownPromise = async (cause?: UnexpectedError) => {
1075
+ shutdownPromise = async (cause?: UnknownError) => {
752
1076
  this.checkShutdown('shutdownPromise')
753
1077
 
754
- this.isShutdown = true
755
- await this.shutdown(cause ? Cause.fail(cause) : undefined).pipe(this.runEffectFork, Fiber.join, Effect.runPromise)
1078
+ this[StoreInternalsSymbol].isShutdown = true
1079
+ await this.shutdown(cause !== undefined ? Cause.fail(cause) : undefined).pipe(
1080
+ this.runEffectFork,
1081
+ Fiber.join,
1082
+ Effect.runPromise,
1083
+ )
756
1084
  }
757
1085
 
758
1086
  /**
@@ -760,10 +1088,10 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
760
1088
  *
761
1089
  * This is called automatically when the store was created using the React or Effect API.
762
1090
  */
763
- shutdown = (cause?: Cause.Cause<UnexpectedError | MaterializeError>): Effect.Effect<void> => {
764
- this.isShutdown = true
765
- return this.clientSession.shutdown(
766
- cause ? Exit.failCause(cause) : Exit.succeed(IntentionalShutdownCause.make({ reason: 'manual' })),
1091
+ shutdown = (cause?: Cause.Cause<UnknownError | MaterializeError>): Effect.Effect<void> => {
1092
+ this[StoreInternalsSymbol].isShutdown = true
1093
+ return this[StoreInternalsSymbol].clientSession.shutdown(
1094
+ cause !== undefined ? Exit.failCause(cause) : Exit.succeed(IntentionalShutdownCause.make({ reason: 'manual' })),
767
1095
  )
768
1096
  }
769
1097
 
@@ -775,30 +1103,33 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
775
1103
  _dev = {
776
1104
  downloadDb: (source: 'local' | 'leader' = 'local') => {
777
1105
  Effect.gen(this, function* () {
778
- const data = source === 'local' ? this.sqliteDbWrapper.export() : yield* this.clientSession.leaderThread.export
1106
+ const data =
1107
+ source === 'local'
1108
+ ? this[StoreInternalsSymbol].sqliteDbWrapper.export()
1109
+ : yield* this[StoreInternalsSymbol].clientSession.leaderThread.export
779
1110
  downloadBlob(data, `livestore-${Date.now()}.db`)
780
1111
  }).pipe(this.runEffectFork)
781
1112
  },
782
1113
 
783
1114
  downloadEventlogDb: () => {
784
1115
  Effect.gen(this, function* () {
785
- const data = yield* this.clientSession.leaderThread.getEventlogData
1116
+ const data = yield* this[StoreInternalsSymbol].clientSession.leaderThread.getEventlogData
786
1117
  downloadBlob(data, `livestore-eventlog-${Date.now()}.db`)
787
1118
  }).pipe(this.runEffectFork)
788
1119
  },
789
1120
 
790
1121
  hardReset: (mode: 'all-data' | 'only-app-db' = 'all-data') => {
791
1122
  Effect.gen(this, function* () {
792
- const clientId = this.clientSession.clientId
793
- yield* this.clientSession.leaderThread.sendDevtoolsMessage(
1123
+ const clientId = this[StoreInternalsSymbol].clientSession.clientId
1124
+ yield* this[StoreInternalsSymbol].clientSession.leaderThread.sendDevtoolsMessage(
794
1125
  Devtools.Leader.ResetAllData.Request.make({ liveStoreVersion, mode, requestId: nanoid(), clientId }),
795
1126
  )
796
1127
  }).pipe(this.runEffectFork)
797
1128
  },
798
1129
 
799
1130
  overrideNetworkStatus: (status: 'online' | 'offline') => {
800
- const clientId = this.clientSession.clientId
801
- this.clientSession.leaderThread
1131
+ const clientId = this[StoreInternalsSymbol].clientSession.clientId
1132
+ this[StoreInternalsSymbol].clientSession.leaderThread
802
1133
  .sendDevtoolsMessage(
803
1134
  Devtools.Leader.SetSyncLatch.Request.make({
804
1135
  clientId,
@@ -810,56 +1141,60 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
810
1141
  .pipe(this.runEffectFork)
811
1142
  },
812
1143
 
813
- syncStates: () =>
1144
+ // NOTE: Explicit return type needed to avoid TS2742 (inferred type references internal path)
1145
+ syncStates: (): Promise<{ session: SyncState.SyncState; leader: SyncState.SyncState }> =>
814
1146
  Effect.gen(this, function* () {
815
- const session = yield* this.syncProcessor.syncState
816
- const leader = yield* this.clientSession.leaderThread.getSyncState
1147
+ const session = yield* this[StoreInternalsSymbol].syncProcessor.syncState
1148
+ const leader = yield* this[StoreInternalsSymbol].clientSession.leaderThread.syncState
817
1149
  return { session, leader }
818
1150
  }).pipe(this.runEffectPromise),
819
1151
 
820
1152
  printSyncStates: () => {
821
1153
  Effect.gen(this, function* () {
822
- const session = yield* this.syncProcessor.syncState
1154
+ const session = yield* this[StoreInternalsSymbol].syncProcessor.syncState
823
1155
  yield* Effect.log(
824
- `Session sync state: ${session.localHead} (upstream: ${session.upstreamHead})`,
1156
+ `Session sync state: ${objectToString(session.localHead)} (upstream: ${objectToString(session.upstreamHead)})`,
825
1157
  session.toJSON(),
826
1158
  )
827
- const leader = yield* this.clientSession.leaderThread.getSyncState
828
- yield* Effect.log(`Leader sync state: ${leader.localHead} (upstream: ${leader.upstreamHead})`, leader.toJSON())
1159
+ const leader = yield* this[StoreInternalsSymbol].clientSession.leaderThread.syncState
1160
+ yield* Effect.log(
1161
+ `Leader sync state: ${objectToString(leader.localHead)} (upstream: ${objectToString(leader.upstreamHead)})`,
1162
+ leader.toJSON(),
1163
+ )
829
1164
  }).pipe(this.runEffectFork)
830
1165
  },
831
1166
 
832
1167
  version: liveStoreVersion,
833
1168
 
834
1169
  otel: {
835
- rootSpanContext: () => otel.trace.getSpan(this.otel.rootSpanContext)?.spanContext(),
1170
+ rootSpanContext: () => otel.trace.getSpan(this[StoreInternalsSymbol].otel.rootSpanContext)?.spanContext(),
836
1171
  },
837
1172
  }
838
1173
 
839
1174
  // NOTE This is needed because when booting a Store via Effect it seems to call `toJSON` in the error path
840
1175
  toJSON = () => ({
841
1176
  _tag: 'livestore.Store',
842
- reactivityGraph: this.reactivityGraph.getSnapshot({ includeResults: true }),
1177
+ reactivityGraph: this[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true }),
843
1178
  })
844
1179
 
845
1180
  private runEffectFork = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
846
1181
  effect.pipe(
847
- Effect.forkIn(this.effectContext.lifetimeScope),
1182
+ Effect.forkIn(this[StoreInternalsSymbol].effectContext.lifetimeScope),
848
1183
  Effect.tapCauseLogPretty,
849
- Runtime.runFork(this.effectContext.runtime),
1184
+ Runtime.runFork(this[StoreInternalsSymbol].effectContext.runtime),
850
1185
  )
851
1186
 
852
1187
  private runEffectPromise = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
853
- effect.pipe(Effect.tapCauseLogPretty, Runtime.runPromise(this.effectContext.runtime))
1188
+ effect.pipe(Effect.tapCauseLogPretty, Runtime.runPromise(this[StoreInternalsSymbol].effectContext.runtime))
854
1189
 
855
1190
  private getCommitArgs = (
856
1191
  firstEventOrTxnFnOrOptions: any,
857
1192
  restEvents: any[],
858
1193
  ): {
859
- events: LiveStoreEvent.PartialForSchema<TSchema>[]
1194
+ events: LiveStoreEvent.Input.ForSchema<TSchema>[]
860
1195
  options: StoreCommitOptions | undefined
861
1196
  } => {
862
- let events: LiveStoreEvent.PartialForSchema<TSchema>[]
1197
+ let events: LiveStoreEvent.Input.ForSchema<TSchema>[]
863
1198
  let options: StoreCommitOptions | undefined
864
1199
 
865
1200
  if (typeof firstEventOrTxnFnOrOptions === 'function') {
@@ -882,7 +1217,7 @@ export class Store<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TConte
882
1217
 
883
1218
  // for (const event of events) {
884
1219
  // if (event.args.id === SessionIdSymbol) {
885
- // event.args.id = this.clientSession.sessionId
1220
+ // event.args.id = this.sessionId
886
1221
  // }
887
1222
  // }
888
1223