@livestore/livestore 0.4.0-dev.9 → 0.5.0-dev.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 (132) 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 +15 -13
  8. package/dist/SqliteDbWrapper.js.map +1 -1
  9. package/dist/SqliteDbWrapper.test.js +24 -6
  10. package/dist/SqliteDbWrapper.test.js.map +1 -1
  11. package/dist/effect/LiveStore.d.ts +134 -6
  12. package/dist/effect/LiveStore.d.ts.map +1 -1
  13. package/dist/effect/LiveStore.js +190 -11
  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 +41 -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 +129 -9
  24. package/dist/live-queries/base-class.d.ts.map +1 -1
  25. package/dist/live-queries/base-class.js +30 -4
  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 +62 -6
  34. package/dist/live-queries/computed.js.map +1 -1
  35. package/dist/live-queries/db-query.d.ts +2 -2
  36. package/dist/live-queries/db-query.d.ts.map +1 -1
  37. package/dist/live-queries/db-query.js +41 -30
  38. package/dist/live-queries/db-query.js.map +1 -1
  39. package/dist/live-queries/db-query.test.js +112 -29
  40. package/dist/live-queries/db-query.test.js.map +1 -1
  41. package/dist/live-queries/signal.d.ts +49 -0
  42. package/dist/live-queries/signal.d.ts.map +1 -1
  43. package/dist/live-queries/signal.js +51 -2
  44. package/dist/live-queries/signal.js.map +1 -1
  45. package/dist/live-queries/signal.test.js +4 -4
  46. package/dist/live-queries/signal.test.js.map +1 -1
  47. package/dist/mod.d.ts +3 -3
  48. package/dist/mod.d.ts.map +1 -1
  49. package/dist/mod.js +3 -2
  50. package/dist/mod.js.map +1 -1
  51. package/dist/reactive.d.ts +10 -10
  52. package/dist/reactive.d.ts.map +1 -1
  53. package/dist/reactive.js +29 -27
  54. package/dist/reactive.js.map +1 -1
  55. package/dist/reactive.test.js +2 -2
  56. package/dist/reactive.test.js.map +1 -1
  57. package/dist/store/StoreRegistry.d.ts +215 -0
  58. package/dist/store/StoreRegistry.d.ts.map +1 -0
  59. package/dist/store/StoreRegistry.js +267 -0
  60. package/dist/store/StoreRegistry.js.map +1 -0
  61. package/dist/store/StoreRegistry.test.d.ts +2 -0
  62. package/dist/store/StoreRegistry.test.d.ts.map +1 -0
  63. package/dist/store/StoreRegistry.test.js +384 -0
  64. package/dist/store/StoreRegistry.test.js.map +1 -0
  65. package/dist/store/create-store.d.ts +99 -22
  66. package/dist/store/create-store.d.ts.map +1 -1
  67. package/dist/store/create-store.js +85 -37
  68. package/dist/store/create-store.js.map +1 -1
  69. package/dist/store/devtools.d.ts +6 -18
  70. package/dist/store/devtools.d.ts.map +1 -1
  71. package/dist/store/devtools.js +61 -19
  72. package/dist/store/devtools.js.map +1 -1
  73. package/dist/store/store-eventstream.test.d.ts +2 -0
  74. package/dist/store/store-eventstream.test.d.ts.map +1 -0
  75. package/dist/store/store-eventstream.test.js +65 -0
  76. package/dist/store/store-eventstream.test.js.map +1 -0
  77. package/dist/store/store-types.d.ts +271 -31
  78. package/dist/store/store-types.d.ts.map +1 -1
  79. package/dist/store/store-types.js +41 -1
  80. package/dist/store/store-types.js.map +1 -1
  81. package/dist/store/store-types.test.d.ts +2 -0
  82. package/dist/store/store-types.test.d.ts.map +1 -0
  83. package/dist/store/store-types.test.js +39 -0
  84. package/dist/store/store-types.test.js.map +1 -0
  85. package/dist/store/store.d.ts +255 -68
  86. package/dist/store/store.d.ts.map +1 -1
  87. package/dist/store/store.js +533 -186
  88. package/dist/store/store.js.map +1 -1
  89. package/dist/utils/dev.d.ts.map +1 -1
  90. package/dist/utils/dev.js +1 -1
  91. package/dist/utils/dev.js.map +1 -1
  92. package/dist/utils/stack-info.d.ts.map +1 -1
  93. package/dist/utils/stack-info.js +3 -2
  94. package/dist/utils/stack-info.js.map +1 -1
  95. package/dist/utils/tests/fixture.d.ts +46 -39
  96. package/dist/utils/tests/fixture.d.ts.map +1 -1
  97. package/dist/utils/tests/fixture.js +7 -0
  98. package/dist/utils/tests/fixture.js.map +1 -1
  99. package/dist/utils/tests/otel.d.ts.map +1 -1
  100. package/dist/utils/tests/otel.js +5 -5
  101. package/dist/utils/tests/otel.js.map +1 -1
  102. package/package.json +50 -18
  103. package/src/QueryCache.ts +1 -1
  104. package/src/SqliteDbWrapper.test.ts +30 -6
  105. package/src/SqliteDbWrapper.ts +21 -17
  106. package/src/ambient.d.ts +0 -7
  107. package/src/effect/LiveStore.test.ts +61 -0
  108. package/src/effect/LiveStore.ts +426 -22
  109. package/src/effect/mod.ts +13 -1
  110. package/src/live-queries/__snapshots__/db-query.test.ts.snap +814 -178
  111. package/src/live-queries/base-class.ts +162 -32
  112. package/src/live-queries/client-document-get-query.ts +6 -4
  113. package/src/live-queries/computed.ts +65 -8
  114. package/src/live-queries/db-query.test.ts +168 -30
  115. package/src/live-queries/db-query.ts +58 -43
  116. package/src/live-queries/signal.test.ts +5 -4
  117. package/src/live-queries/signal.ts +52 -3
  118. package/src/mod.ts +19 -2
  119. package/src/reactive.test.ts +3 -2
  120. package/src/reactive.ts +58 -49
  121. package/src/store/StoreRegistry.test.ts +543 -0
  122. package/src/store/StoreRegistry.ts +429 -0
  123. package/src/store/create-store.ts +227 -74
  124. package/src/store/devtools.ts +305 -261
  125. package/src/store/store-eventstream.test.ts +123 -0
  126. package/src/store/store-types.test.ts +52 -0
  127. package/src/store/store-types.ts +318 -44
  128. package/src/store/store.ts +695 -283
  129. package/src/utils/dev.ts +3 -4
  130. package/src/utils/stack-info.ts +5 -2
  131. package/src/utils/tests/fixture.ts +9 -1
  132. package/src/utils/tests/otel.ts +8 -7
@@ -1,66 +1,159 @@
1
- import { Devtools, getExecStatementsFromMaterializer, getResultSchema, hashMaterializerResults, IntentionalShutdownCause, isQueryBuilder, liveStoreVersion, MaterializeError, MaterializerHashMismatchError, makeClientSessionSyncProcessor, prepareBindValues, QueryBuilderAstSymbol, replaceSessionIdSymbol, UnexpectedError, } from '@livestore/common';
2
- import { getEventDef, LiveStoreEvent, SystemTables } from '@livestore/common/schema';
3
- import { assertNever, isDevEnv, notYetImplemented, omitUndefineds, shouldNeverHappen } from '@livestore/utils';
4
- import { Cause, Effect, Exit, Fiber, Inspectable, Option, OtelTracer, Runtime, Schema, Stream, } from '@livestore/utils/effect';
5
- import { nanoid } from '@livestore/utils/nanoid';
6
1
  import * as otel from '@opentelemetry/api';
2
+ import { Devtools, getExecStatementsFromMaterializer, getResultSchema, hashMaterializerResults, IntentionalShutdownCause, isQueryBuilder, liveStoreVersion, MaterializeError, MaterializerHashMismatchError, makeClientSessionSyncProcessor, prepareBindValues, QueryBuilderAstSymbol, resolveSessionIdSymbolInBindValues, SqliteDbHelper, StateHead, UnknownError, } from '@livestore/common';
3
+ import { EventSequenceNumber, LiveStoreEvent, resolveEventDef, SystemTables } from '@livestore/common/schema';
4
+ import { assertNever, isDevEnv, objectToString, omitUndefineds, shouldNeverHappen } from '@livestore/utils';
5
+ import { Cause, Effect, Exit, Fiber, Inspectable, Option, OtelTracer, Queue, Result, Schema, Stream, } from '@livestore/utils/effect';
6
+ import { nanoid } from '@livestore/utils/nanoid';
7
7
  import { makeReactivityGraph } from "../live-queries/base-class.js";
8
8
  import { makeExecBeforeFirstRun } from "../live-queries/client-document-get-query.js";
9
9
  import { queryDb } from "../live-queries/db-query.js";
10
10
  import { SqliteDbWrapper } from "../SqliteDbWrapper.js";
11
11
  import { ReferenceCountedSet } from "../utils/data-structures.js";
12
12
  import { downloadBlob, exposeDebugUtils } from "../utils/dev.js";
13
- if (isDevEnv()) {
13
+ import { StoreInternalsSymbol, } from "./store-types.js";
14
+ if (isDevEnv() === true) {
14
15
  exposeDebugUtils();
15
16
  }
17
+ /**
18
+ * Default parameters for the Store. Also used in `create-store.ts`
19
+ */
20
+ export const STORE_DEFAULT_PARAMS = {
21
+ leaderPushBatchSize: 100,
22
+ eventQueryBatchSize: 100,
23
+ };
24
+ //
25
+ /**
26
+ * Central interface to a LiveStore database providing reactive queries, event commits, and sync.
27
+ *
28
+ * A `Store` instance wraps a local SQLite database that is kept in sync with other clients via
29
+ * an event log. Instead of mutating state directly, you commit events that get materialized
30
+ * into database rows. Queries automatically re-run when their underlying tables change.
31
+ *
32
+ * ## Creating a Store
33
+ *
34
+ * Use `createStore` (Effect-based) or `createStorePromise` to obtain a Store instance.
35
+ * In React applications, use `StoreRegistry` with `<StoreRegistryProvider>` and the `useStore()` hook
36
+ * which manages the Store lifecycle.
37
+ *
38
+ * ## Querying Data
39
+ *
40
+ * Use {@link Store.query} for one-shot reads or {@link Store.subscribe} for reactive subscriptions.
41
+ * Both accept query builders (e.g. `tables.todo.where({ complete: true })`) or custom `LiveQueryDef`s.
42
+ *
43
+ * ## Committing Events
44
+ *
45
+ * Use {@link Store.commit} to persist events. Events are immediately materialized locally and
46
+ * asynchronously synced to other clients. Multiple events can be committed atomically.
47
+ *
48
+ * ## Lifecycle
49
+ *
50
+ * The Store must be shut down when no longer needed via {@link Store.shutdown} or
51
+ * {@link Store.shutdownPromise}. Framework integrations (React, Effect) handle this automatically.
52
+ *
53
+ * @typeParam TSchema - The LiveStore schema defining tables and events
54
+ * @typeParam TContext - Optional user-defined context attached to the Store (e.g. for dependency injection)
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * // Query data
59
+ * const todos = store.query(tables.todo.where({ complete: false }))
60
+ *
61
+ * // Subscribe to changes
62
+ * const unsubscribe = store.subscribe(tables.todo.all(), (todos) => {
63
+ * console.log('Todos updated:', todos)
64
+ * })
65
+ *
66
+ * // Commit an event
67
+ * store.commit(events.todoCreated({ id: nanoid(), text: 'Buy milk' }))
68
+ * ```
69
+ */
16
70
  export class Store extends Inspectable.Class {
71
+ /** Unique identifier for this Store instance, stable for its lifetime. */
17
72
  storeId;
18
- reactivityGraph;
19
- sqliteDbWrapper;
20
- clientSession;
73
+ /** The LiveStore schema defining tables, events, and materializers. */
21
74
  schema;
75
+ /** User-defined context attached to this Store (e.g. for dependency injection). */
22
76
  context;
23
- otel;
77
+ /** Options provided to the Store constructor. */
78
+ params;
79
+ /**
80
+ * Reactive connectivity updates emitted by the backing sync backend.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * import { Effect, Stream } from 'effect'
85
+ *
86
+ * const status = await store.networkStatus.pipe(Effect.runPromise)
87
+ *
88
+ * await store.networkStatus.changes.pipe(
89
+ * Stream.tap((next) => console.log('network status update', next)),
90
+ * Stream.runDrain,
91
+ * Effect.scoped,
92
+ * Effect.runPromise,
93
+ * )
94
+ * ```
95
+ */
96
+ networkStatus;
97
+ /**
98
+ * Indicates how data is being stored.
99
+ *
100
+ * - `persisted`: Data is persisted to disk (e.g., via OPFS on web, SQLite file on native)
101
+ * - `in-memory`: Data is only stored in memory and will be lost on page refresh
102
+ *
103
+ * The store operates in `in-memory` mode when persistent storage is unavailable,
104
+ * such as in Safari/Firefox private browsing mode where OPFS is restricted.
105
+ *
106
+ * @example
107
+ * ```tsx
108
+ * if (store.storageMode === 'in-memory') {
109
+ * showWarning('Data will not be persisted in private browsing mode')
110
+ * }
111
+ * ```
112
+ */
113
+ storageMode;
24
114
  /**
25
- * Note we're using `Ref<null>` here as we don't care about the value but only about *that* something has changed.
26
- * This only works in combination with `equal: () => false` which will always trigger a refresh.
115
+ * Store internals. Not part of the public API shapes and semantics may change without notice.
27
116
  */
28
- tableRefs;
29
- /** Tracks whether the store has been shut down */
30
- isShutdown = false;
31
- effectContext;
32
- /** RC-based set to see which queries are currently subscribed to */
33
- activeQueries;
34
- // NOTE this is currently exposed for the Devtools databrowser to commit events
35
- __eventSchema;
36
- syncProcessor;
37
- boot;
38
- // #region constructor
117
+ [StoreInternalsSymbol];
118
+ //#region constructor
39
119
  constructor({ clientSession, schema, otelOptions, context, batchUpdates, storeId, effectContext, params, confirmUnsavedChanges, __runningInDevtools, }) {
40
120
  super();
41
121
  this.storeId = storeId;
42
- this.sqliteDbWrapper = new SqliteDbWrapper({ otel: otelOptions, db: clientSession.sqliteDb });
43
- this.clientSession = clientSession;
44
122
  this.schema = schema;
45
123
  this.context = context;
46
- this.effectContext = effectContext;
124
+ this.params = params;
125
+ this.networkStatus = clientSession.leaderThread.networkStatus;
126
+ this.storageMode = clientSession.leaderThread.initialState.storageMode;
47
127
  const reactivityGraph = makeReactivityGraph();
48
- const syncSpan = otelOptions.tracer.startSpan('LiveStore:sync', {}, otelOptions.rootSpanContext);
49
- this.syncProcessor = makeClientSessionSyncProcessor({
128
+ const stateHead = StateHead.make({ dbState: clientSession.sqliteDb });
129
+ const syncProcessor = makeClientSessionSyncProcessor({
50
130
  schema,
51
131
  clientSession,
52
- runtime: effectContext.runtime,
53
- materializeEvent: Effect.fn('client-session-sync-processor:materialize-event')((eventDecoded, { withChangeset, materializerHashLeader }) =>
132
+ materializeEvent: Effect.fn('client-session-sync-processor:materialize-event')((eventEncoded, { materializerHashLeader }) =>
54
133
  // We need to use `Effect.gen` (even though we're using `Effect.fn`) so that we can pass `this` to the function
55
- Effect.gen(this, function* () {
56
- const { eventDef, materializer } = getEventDef(schema, eventDecoded.name);
134
+ Effect.gen({ self: this }, function* () {
135
+ const resolution = yield* resolveEventDef(schema, {
136
+ operation: '@livestore/livestore:store:materializeEvent',
137
+ event: eventEncoded,
138
+ });
139
+ if (resolution._tag === 'unknown') {
140
+ // Runtime schema doesn't know this event yet; skip materialization but
141
+ // keep the log entry so upgraded clients can replay it later.
142
+ yield* stateHead.set(eventEncoded.seqNum);
143
+ return {
144
+ writeTables: new Set(),
145
+ sessionChangeset: { _tag: 'no-op' },
146
+ materializerHash: Option.none(),
147
+ };
148
+ }
149
+ const { eventDef, materializer } = resolution;
57
150
  const execArgsArr = getExecStatementsFromMaterializer({
58
151
  eventDef,
59
152
  materializer,
60
- dbState: this.sqliteDbWrapper,
61
- event: { decoded: eventDecoded, encoded: undefined },
153
+ dbState: this[StoreInternalsSymbol].sqliteDbWrapper,
154
+ event: { decoded: undefined, encoded: eventEncoded },
62
155
  });
63
- const materializerHash = isDevEnv() ? Option.some(hashMaterializerResults(execArgsArr)) : Option.none();
156
+ const materializerHash = isDevEnv() === true ? Option.some(hashMaterializerResults(execArgsArr)) : Option.none();
64
157
  // Hash mismatch detection only occurs during the pull path (when receiving events from the leader).
65
158
  // During push path (local commits), materializerHashLeader is always Option.none(), so this condition
66
159
  // will never be met. The check happens when the same event comes back from the leader during sync,
@@ -68,72 +161,64 @@ export class Store extends Inspectable.Class {
68
161
  if (materializerHashLeader._tag === 'Some' &&
69
162
  materializerHash._tag === 'Some' &&
70
163
  materializerHashLeader.value !== materializerHash.value) {
71
- return yield* MaterializerHashMismatchError.make({ eventName: eventDecoded.name });
164
+ return yield* MaterializerHashMismatchError.make({ eventName: eventEncoded.name });
72
165
  }
73
166
  const span = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie);
74
167
  const otelContext = otel.trace.setSpan(otel.context.active(), span);
75
168
  const writeTablesForEvent = new Set();
76
169
  const exec = () => {
77
- for (const { statementSql, bindValues, writeTables = this.sqliteDbWrapper.getTablesUsed(statementSql), } of execArgsArr) {
170
+ for (const { statementSql, bindValues, writeTables = this[StoreInternalsSymbol].sqliteDbWrapper.getTablesUsed(statementSql), } of execArgsArr) {
78
171
  try {
79
- this.sqliteDbWrapper.cachedExecute(statementSql, bindValues, { otelContext, writeTables });
172
+ this[StoreInternalsSymbol].sqliteDbWrapper.cachedExecute(statementSql, bindValues, {
173
+ otelContext,
174
+ writeTables,
175
+ });
80
176
  }
81
177
  catch (cause) {
82
178
  // TOOD refactor with `SqliteError`
83
- throw UnexpectedError.make({
179
+ throw UnknownError.make({
84
180
  cause,
85
- note: `Error executing materializer for event "${eventDecoded.name}".\nStatement: ${statementSql}\nBind values: ${JSON.stringify(bindValues)}`,
181
+ note: `Error executing materializer for event "${eventEncoded.name}".\nStatement: ${statementSql}\nBind values: ${JSON.stringify(bindValues)}`,
86
182
  });
87
183
  }
88
184
  // durationMsTotal += durationMs
89
185
  for (const table of writeTables) {
90
186
  writeTablesForEvent.add(table);
91
187
  }
92
- this.sqliteDbWrapper.debug.head = eventDecoded.seqNum;
188
+ this[StoreInternalsSymbol].sqliteDbWrapper.debug.head = eventEncoded.seqNum;
93
189
  }
94
190
  };
95
- let sessionChangeset = { _tag: 'unset' };
96
- if (withChangeset === true) {
97
- sessionChangeset = this.sqliteDbWrapper.withChangeset(exec).changeset;
98
- }
99
- else {
100
- exec();
101
- }
191
+ const sessionChangeset = this[StoreInternalsSymbol].sqliteDbWrapper.withChangeset(exec).changeset;
192
+ yield* stateHead.set(eventEncoded.seqNum);
102
193
  return { writeTables: writeTablesForEvent, sessionChangeset, materializerHash };
103
- }).pipe(Effect.mapError((cause) => MaterializeError.make({ cause })))),
194
+ }).pipe(SqliteDbHelper.withSavepoint(clientSession.sqliteDb), Effect.mapError((cause) => MaterializeError.make({ cause })))),
104
195
  rollback: (changeset) => {
105
- this.sqliteDbWrapper.rollback(changeset);
196
+ this[StoreInternalsSymbol].sqliteDbWrapper.rollback(changeset);
106
197
  },
107
198
  refreshTables: (tables) => {
108
199
  const tablesToUpdate = [];
109
200
  for (const tableName of tables) {
110
- const tableRef = this.tableRefs[tableName];
201
+ const tableRef = this[StoreInternalsSymbol].tableRefs[tableName];
111
202
  assertNever(tableRef !== undefined, `No table ref found for ${tableName}`);
112
203
  tablesToUpdate.push([tableRef, null]);
113
204
  }
114
205
  reactivityGraph.setRefs(tablesToUpdate);
115
206
  },
116
- span: syncSpan,
117
207
  params: {
118
208
  ...omitUndefineds({
119
209
  leaderPushBatchSize: params.leaderPushBatchSize,
120
210
  }),
121
- ...(params.simulation?.clientSessionSyncProcessor !== undefined
122
- ? { simulation: params.simulation.clientSessionSyncProcessor }
123
- : {}),
124
211
  },
125
212
  confirmUnsavedChanges,
126
- });
127
- this.__eventSchema = LiveStoreEvent.makeEventDefSchemaMemo(schema);
213
+ }).pipe(Effect.provideService(StateHead.StateHead, stateHead), Effect.runSyncWith(effectContext.services));
128
214
  // TODO generalize the `tableRefs` concept to allow finer-grained refs
129
- this.tableRefs = {};
130
- this.activeQueries = new ReferenceCountedSet();
215
+ const tableRefs = {};
216
+ const activeQueries = new ReferenceCountedSet();
131
217
  const commitsSpan = otelOptions.tracer.startSpan('LiveStore:commits', {}, otelOptions.rootSpanContext);
132
218
  const otelMuationsSpanContext = otel.trace.setSpan(otel.context.active(), commitsSpan);
133
219
  const queriesSpan = otelOptions.tracer.startSpan('LiveStore:queries', {}, otelOptions.rootSpanContext);
134
220
  const otelQueriesSpanContext = otel.trace.setSpan(otel.context.active(), queriesSpan);
135
- this.reactivityGraph = reactivityGraph;
136
- this.reactivityGraph.context = {
221
+ reactivityGraph.context = {
137
222
  store: this,
138
223
  defRcMap: new Map(),
139
224
  reactivityGraph: new WeakRef(reactivityGraph),
@@ -141,7 +226,7 @@ export class Store extends Inspectable.Class {
141
226
  rootOtelContext: otelQueriesSpanContext,
142
227
  effectsWrapper: batchUpdates,
143
228
  };
144
- this.otel = {
229
+ const otelObj = {
145
230
  tracer: otelOptions.tracer,
146
231
  rootSpanContext: otelOptions.rootSpanContext,
147
232
  commitsSpanContext: otelMuationsSpanContext,
@@ -151,92 +236,159 @@ export class Store extends Inspectable.Class {
151
236
  const allTableNames = new Set(
152
237
  // NOTE we're excluding the LiveStore schema and events tables as they are not user-facing
153
238
  // unless LiveStore is running in the devtools
154
- __runningInDevtools
239
+ __runningInDevtools === true
155
240
  ? this.schema.state.sqlite.tables.keys()
156
241
  : Array.from(this.schema.state.sqlite.tables.keys()).filter((_) => !SystemTables.isStateSystemTable(_)));
157
- const existingTableRefs = new Map(Array.from(this.reactivityGraph.atoms.values())
242
+ const existingTableRefs = new Map(Array.from(reactivityGraph.atoms.values())
158
243
  .filter((_) => _._tag === 'ref' && _.label?.startsWith('tableRef:') === true)
159
244
  .map((_) => [_.label.slice('tableRef:'.length), _]));
160
245
  for (const tableName of allTableNames) {
161
- this.tableRefs[tableName] =
246
+ tableRefs[tableName] =
162
247
  existingTableRefs.get(tableName) ??
163
- this.reactivityGraph.makeRef(null, {
248
+ reactivityGraph.makeRef(null, {
164
249
  equal: () => false,
165
- label: `tableRef:${tableName}`,
250
+ label: `tableRef:${String(tableName)}`,
166
251
  meta: { liveStoreRefType: 'table' },
167
252
  });
168
253
  }
169
- this.boot = Effect.gen(this, function* () {
254
+ const boot = Effect.gen({ self: this }, function* () {
170
255
  yield* Effect.addFinalizer(() => Effect.sync(() => {
171
256
  // Remove all table refs from the reactivity graph
172
- for (const tableRef of Object.values(this.tableRefs)) {
257
+ for (const tableRef of Object.values(tableRefs)) {
173
258
  for (const superComp of tableRef.super) {
174
- this.reactivityGraph.removeEdge(superComp, tableRef);
259
+ this[StoreInternalsSymbol].reactivityGraph.removeEdge(superComp, tableRef);
175
260
  }
176
261
  }
177
262
  // End the otel spans
178
- syncSpan.end();
179
263
  commitsSpan.end();
180
264
  queriesSpan.end();
181
265
  }));
182
- yield* this.syncProcessor.boot;
266
+ yield* syncProcessor.boot;
183
267
  });
268
+ // Build Sqlite wrapper last to avoid using getters before internals are set
269
+ const sqliteDbWrapper = new SqliteDbWrapper({ otel: otelOptions, db: clientSession.sqliteDb });
270
+ // Initialize internals bag
271
+ this[StoreInternalsSymbol] = {
272
+ eventSchema: LiveStoreEvent.Client.makeSchemaMemo(schema),
273
+ clientSession,
274
+ sqliteDbWrapper,
275
+ effectContext,
276
+ otel: otelObj,
277
+ reactivityGraph,
278
+ tableRefs,
279
+ activeQueries,
280
+ syncProcessor,
281
+ boot,
282
+ isShutdown: false,
283
+ };
284
+ // Initialize stable network status property from client session
285
+ this.networkStatus = clientSession.leaderThread.networkStatus;
184
286
  }
185
- // #endregion constructor
287
+ //#endregion constructor
288
+ /**
289
+ * Current session identifier for this Store instance.
290
+ *
291
+ * - Stable for the lifetime of the Store
292
+ * - Useful for correlating events or scoping per-session data
293
+ */
186
294
  get sessionId() {
187
- return this.clientSession.sessionId;
295
+ return this[StoreInternalsSymbol].clientSession.sessionId;
188
296
  }
297
+ /**
298
+ * Stable client identifier for the process/device using this Store.
299
+ *
300
+ * - Shared across Store instances created by the same client
301
+ * - Useful for diagnostics and multi-client correlation
302
+ */
189
303
  get clientId() {
190
- return this.clientSession.clientId;
304
+ return this[StoreInternalsSymbol].clientSession.clientId;
191
305
  }
192
306
  checkShutdown = (operation) => {
193
- if (this.isShutdown) {
194
- throw new UnexpectedError({
307
+ if (this[StoreInternalsSymbol].isShutdown === true) {
308
+ throw new UnknownError({
195
309
  cause: `Store has been shut down (while performing "${operation}").`,
196
310
  note: `You cannot perform this operation after the store has been shut down.`,
197
311
  });
198
312
  }
199
313
  };
200
314
  /**
201
- * Subscribe to the results of a query
202
- * Returns a function to cancel the subscription.
315
+ * Subscribe to the results of a query.
316
+ *
317
+ * - When providing an `onUpdate` callback it returns an {@link Unsubscribe} function.
318
+ * - Without a callback it returns an {@link AsyncIterable} that yields query results.
203
319
  *
204
320
  * @example
205
321
  * ```ts
206
- * const unsubscribe = store.subscribe(query$, { onUpdate: (result) => console.log(result) })
322
+ * const unsubscribe = store.subscribe(query$, (result) => console.log(result))
323
+ * ```
324
+ *
325
+ * @example
326
+ * ```ts
327
+ * for await (const result of store.subscribe(query$)) {
328
+ * console.log(result)
329
+ * }
207
330
  * ```
208
331
  */
209
- subscribe = (query, options) => {
332
+ subscribe = ((query, onUpdateOrOptions, maybeOptions) => {
333
+ if (typeof onUpdateOrOptions === 'function') {
334
+ return this.subscribeWithCallback(query, onUpdateOrOptions, maybeOptions);
335
+ }
336
+ return this.subscribeAsAsyncIterable(query, onUpdateOrOptions);
337
+ });
338
+ subscribeWithCallback = (query, onUpdate, options) => {
210
339
  this.checkShutdown('subscribe');
211
- return this.otel.tracer.startActiveSpan(`LiveStore.subscribe`, { attributes: { label: options?.label, queryLabel: isQueryBuilder(query) ? query.toString() : query.label } }, options?.otelContext ?? this.otel.queriesSpanContext, (span) => {
212
- // console.debug('store sub', query$.id, query$.label)
340
+ return this[StoreInternalsSymbol].otel.tracer.startActiveSpan(`LiveStore.subscribe`, {
341
+ attributes: {
342
+ label: options?.label,
343
+ queryLabel: isQueryBuilder(query) === true ? query.toString() : query.label,
344
+ },
345
+ }, options?.otelContext ?? this[StoreInternalsSymbol].otel.queriesSpanContext, (span) => {
213
346
  const otelContext = otel.trace.setSpan(otel.context.active(), span);
214
- const queryRcRef = isQueryBuilder(query)
215
- ? queryDb(query).make(this.reactivityGraph.context)
347
+ const queryRcRef = isQueryBuilder(query) === true
348
+ ? queryDb(query).make(this[StoreInternalsSymbol].reactivityGraph.context)
216
349
  : query._tag === 'def' || query._tag === 'signal-def'
217
- ? query.make(this.reactivityGraph.context)
350
+ ? query.make(this[StoreInternalsSymbol].reactivityGraph.context)
218
351
  : {
219
352
  value: query,
220
353
  deref: () => { },
221
354
  };
222
355
  const query$ = queryRcRef.value;
223
356
  const label = `subscribe:${options?.label}`;
224
- const effect = this.reactivityGraph.makeEffect((get, _otelContext, debugRefreshReason) => options.onUpdate(get(query$.results$, otelContext, debugRefreshReason)), { label });
225
- if (options?.stackInfo) {
357
+ let suppressCallback = options?.skipInitialRun === true;
358
+ const effect = this[StoreInternalsSymbol].reactivityGraph.makeEffect((get, _otelContext, debugRefreshReason) => {
359
+ const result = get(query$.results$, otelContext, debugRefreshReason);
360
+ if (suppressCallback === true) {
361
+ return;
362
+ }
363
+ onUpdate(result);
364
+ }, { label });
365
+ const runInitialEffect = () => {
366
+ effect.doEffect(otelContext, {
367
+ _tag: 'subscribe.initial',
368
+ label: `subscribe-initial-run:${options?.label}`,
369
+ });
370
+ };
371
+ if (options?.stackInfo !== undefined) {
226
372
  query$.activeSubscriptions.add(options.stackInfo);
227
373
  }
228
374
  options?.onSubscribe?.(query$);
229
- this.activeQueries.add(query$);
230
- // Running effect right away to get initial value (unless `skipInitialRun` is set)
231
- if (options?.skipInitialRun !== true && !query$.isDestroyed) {
232
- effect.doEffect(otelContext, { _tag: 'subscribe.initial', label: `subscribe-initial-run:${options?.label}` });
375
+ this[StoreInternalsSymbol].activeQueries.add(query$);
376
+ if (query$.isDestroyed === false) {
377
+ if (suppressCallback === true) {
378
+ // We still run once to register dependencies in the reactive graph, but suppress the initial callback so the
379
+ // caller truly skips the first emission; subsequent runs (after commits) will call the callback.
380
+ runInitialEffect();
381
+ suppressCallback = false;
382
+ }
383
+ else {
384
+ runInitialEffect();
385
+ }
233
386
  }
234
387
  const unsubscribe = () => {
235
- // console.debug('store unsub', query$.id, query$.label)
236
388
  try {
237
- this.reactivityGraph.destroyNode(effect);
238
- this.activeQueries.remove(query$);
239
- if (options?.stackInfo) {
389
+ this[StoreInternalsSymbol].reactivityGraph.destroyNode(effect);
390
+ this[StoreInternalsSymbol].activeQueries.remove(query$);
391
+ if (options?.stackInfo !== undefined) {
240
392
  query$.activeSubscriptions.delete(options.stackInfo);
241
393
  }
242
394
  queryRcRef.deref();
@@ -249,12 +401,87 @@ export class Store extends Inspectable.Class {
249
401
  return unsubscribe;
250
402
  });
251
403
  };
252
- subscribeStream = (query$, options) => Stream.asyncPush((emit) => Effect.gen(this, function* () {
253
- const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchTag('NoSuchElementException', () => Effect.succeed(undefined)));
254
- const otelContext = otelSpan ? otel.trace.setSpan(otel.context.active(), otelSpan) : otel.context.active();
255
- yield* Effect.acquireRelease(Effect.sync(() => this.subscribe(query$, {
256
- onUpdate: (result) => emit.single(result),
257
- ...omitUndefineds({ otelContext, label: options?.label }),
404
+ subscribeAsAsyncIterable = (query, options) => {
405
+ this.checkShutdown('subscribe');
406
+ // This used to delegate to `Stream.toAsyncIterable(this.subscribeStream(...))`.
407
+ // With Effect v4, the callback-backed stream producer is forked during stream startup, so a caller could request
408
+ // `next()`, commit an update, and race the actual subscription registration. Register directly here so the first
409
+ // pending `next()` observes post-subscription updates. In the future, this should be refactored back toward a shared
410
+ // stream/async-iterable bridge if Effect exposes a primitive with that synchronous registration guarantee.
411
+ return {
412
+ [Symbol.asyncIterator]: () => {
413
+ const services = this[StoreInternalsSymbol].effectContext.services;
414
+ const runSync = Effect.runSyncWith(services);
415
+ const runPromiseExit = Effect.runPromiseExitWith(services);
416
+ let queue;
417
+ let isDone = false;
418
+ let unsubscribe;
419
+ const done = () => ({ done: true, value: undefined });
420
+ const isClosed = () => isDone;
421
+ const isQueueDone = (cause) => {
422
+ const error = Cause.findError(cause);
423
+ return Result.isSuccess(error) && Cause.isDone(error.success);
424
+ };
425
+ const ensureQueue = () => {
426
+ // Lazily allocate the queue so creating an async iterator without consuming it has no subscription-side work.
427
+ queue ??= Queue.unbounded().pipe(runSync);
428
+ return queue;
429
+ };
430
+ const emit = (value) => {
431
+ if (isDone === true)
432
+ return;
433
+ Queue.offerUnsafe(ensureQueue(), value);
434
+ };
435
+ const ensureSubscribed = () => {
436
+ // Subscribe from the first `next()` call so callers that request a value before
437
+ // committing updates don't race the asynchronous Stream-to-iterator startup path.
438
+ unsubscribe ??= this.subscribeWithCallback(query, emit, options);
439
+ };
440
+ const close = () => {
441
+ if (isDone === false) {
442
+ isDone = true;
443
+ unsubscribe?.();
444
+ unsubscribe = undefined;
445
+ if (queue !== undefined) {
446
+ // Wake any pending `next()` call and prevent later callback emissions from being buffered.
447
+ Queue.shutdown(queue).pipe(runSync);
448
+ }
449
+ }
450
+ return done();
451
+ };
452
+ return {
453
+ next: async () => {
454
+ if (isDone === true) {
455
+ return done();
456
+ }
457
+ ensureSubscribed();
458
+ // Delegate buffering and pending-consumer wakeups to Effect's queue implementation.
459
+ const exit = await runPromiseExit(Queue.take(ensureQueue()));
460
+ if (Exit.isSuccess(exit) === true) {
461
+ return { done: false, value: exit.value };
462
+ }
463
+ if (isClosed() === true || isQueueDone(exit.cause) === true) {
464
+ return done();
465
+ }
466
+ throw Cause.squash(exit.cause);
467
+ },
468
+ return: () => Promise.resolve(close()),
469
+ throw: (cause) => {
470
+ close();
471
+ return Promise.reject(cause);
472
+ },
473
+ };
474
+ },
475
+ };
476
+ };
477
+ subscribeStream = (query, options) => Stream.callback((emit) => Effect.gen({ self: this }, function* () {
478
+ const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchTag('NoSuchElementError', () => Effect.void));
479
+ const otelContext = otelSpan !== undefined ? otel.trace.setSpan(otel.context.active(), otelSpan) : otel.context.active();
480
+ yield* Effect.acquireRelease(Effect.sync(() => this.subscribe(query, (result) => {
481
+ Queue.offerUnsafe(emit, result);
482
+ }, {
483
+ ...options,
484
+ otelContext,
258
485
  })), (unsub) => Effect.sync(() => unsub()));
259
486
  }));
260
487
  /**
@@ -274,15 +501,15 @@ export class Store extends Inspectable.Class {
274
501
  query = (query, options) => {
275
502
  this.checkShutdown('query');
276
503
  if (typeof query === 'object' && 'query' in query && 'bindValues' in query) {
277
- const res = this.sqliteDbWrapper.cachedSelect(query.query, prepareBindValues(query.bindValues, query.query), {
504
+ const res = this[StoreInternalsSymbol].sqliteDbWrapper.cachedSelect(query.query, prepareBindValues(query.bindValues, query.query), {
278
505
  ...omitUndefineds({ otelContext: options?.otelContext }),
279
506
  });
280
- if (query.schema) {
281
- return Schema.decodeSync(query.schema)(res);
507
+ if (query.schema !== undefined) {
508
+ return Schema.decodeUnknownSync(query.schema)(res);
282
509
  }
283
510
  return res;
284
511
  }
285
- else if (isQueryBuilder(query)) {
512
+ else if (isQueryBuilder(query) === true) {
286
513
  const ast = query[QueryBuilderAstSymbol];
287
514
  if (ast._tag === 'RowQuery') {
288
515
  makeExecBeforeFirstRun({
@@ -290,34 +517,35 @@ export class Store extends Inspectable.Class {
290
517
  id: ast.id,
291
518
  explicitDefaultValues: ast.explicitDefaultValues,
292
519
  otelContext: options?.otelContext,
293
- })(this.reactivityGraph.context);
520
+ })(this[StoreInternalsSymbol].reactivityGraph.context);
294
521
  }
295
522
  const sqlRes = query.asSql();
296
523
  const schema = getResultSchema(query);
297
- // Replace SessionIdSymbol in bind values before executing the query
298
- if (sqlRes.bindValues) {
299
- replaceSessionIdSymbol(sqlRes.bindValues, this.clientSession.sessionId);
300
- }
301
- const rawRes = this.sqliteDbWrapper.cachedSelect(sqlRes.query, sqlRes.bindValues, {
524
+ // Query builders preserve SessionIdSymbol so client-document queries can be reused across sessions.
525
+ // SQLite bind values must be concrete primitives, so resolve the symbol only at execution time.
526
+ const resolvedBindValues = sqlRes.bindValues === undefined
527
+ ? undefined
528
+ : resolveSessionIdSymbolInBindValues(sqlRes.bindValues, this[StoreInternalsSymbol].clientSession.sessionId);
529
+ const rawRes = this[StoreInternalsSymbol].sqliteDbWrapper.cachedSelect(sqlRes.query, resolvedBindValues === undefined ? undefined : prepareBindValues(resolvedBindValues, sqlRes.query), {
302
530
  ...omitUndefineds({ otelContext: options?.otelContext }),
303
531
  queriedTables: new Set([query[QueryBuilderAstSymbol].tableDef.sqliteDef.name]),
304
532
  });
305
- const decodeResult = Schema.decodeEither(schema)(rawRes);
306
- if (decodeResult._tag === 'Right') {
307
- return decodeResult.right;
533
+ const decodeResult = Schema.decodeResult(schema)(rawRes);
534
+ if (Result.isSuccess(decodeResult) === true) {
535
+ return decodeResult.success;
308
536
  }
309
537
  else {
310
- return shouldNeverHappen(`Failed to decode query result with for schema:`, schema.toString(), 'raw result:', rawRes, 'decode error:', decodeResult.left);
538
+ return shouldNeverHappen('Failed to decode query result with for schema:', objectToString(schema), 'raw result:', rawRes, 'decode error:', decodeResult.failure);
311
539
  }
312
540
  }
313
541
  else if (query._tag === 'def') {
314
- const query$ = query.make(this.reactivityGraph.context);
542
+ const query$ = query.make(this[StoreInternalsSymbol].reactivityGraph.context);
315
543
  const result = this.query(query$.value, options);
316
544
  query$.deref();
317
545
  return result;
318
546
  }
319
547
  else if (query._tag === 'signal-def') {
320
- const signal$ = query.make(this.reactivityGraph.context);
548
+ const signal$ = query.make(this[StoreInternalsSymbol].reactivityGraph.context);
321
549
  return signal$.value.get();
322
550
  }
323
551
  else {
@@ -343,7 +571,7 @@ export class Store extends Inspectable.Class {
343
571
  */
344
572
  setSignal = (signalDef, value) => {
345
573
  this.checkShutdown('setSignal');
346
- const signalRef = signalDef.make(this.reactivityGraph.context);
574
+ const signalRef = signalDef.make(this[StoreInternalsSymbol].reactivityGraph.context);
347
575
  const newValue = typeof value === 'function' ? value(signalRef.value.get()) : value;
348
576
  signalRef.value.set(newValue);
349
577
  // The current implementation of signals i.e. the separation into `signal-def` and `signal`
@@ -355,7 +583,7 @@ export class Store extends Inspectable.Class {
355
583
  signalRef.deref();
356
584
  }
357
585
  };
358
- // #region commit
586
+ //#region commit
359
587
  /**
360
588
  * Commit a list of events to the store which will immediately update the local database
361
589
  * and sync the events across other clients (similar to a `git commit`).
@@ -410,36 +638,28 @@ export class Store extends Inspectable.Class {
410
638
  commit = (firstEventOrTxnFnOrOptions, ...restEvents) => {
411
639
  this.checkShutdown('commit');
412
640
  const { events, options } = this.getCommitArgs(firstEventOrTxnFnOrOptions, restEvents);
413
- Effect.gen(this, function* () {
414
- const commitsSpan = otel.trace.getSpan(this.otel.commitsSpanContext);
641
+ Effect.gen({ self: this }, function* () {
642
+ const commitsSpan = otel.trace.getSpan(this[StoreInternalsSymbol].otel.commitsSpanContext);
415
643
  commitsSpan?.addEvent('commit');
416
644
  const currentSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie);
417
645
  commitsSpan?.addLink({ context: currentSpan.spanContext() });
418
- for (const event of events) {
419
- replaceSessionIdSymbol(event.args, this.clientSession.sessionId);
420
- }
421
646
  if (events.length === 0)
422
647
  return;
423
- const localRuntime = yield* Effect.runtime();
424
- const materializeEventsTx = Effect.try({
648
+ const localServices = yield* Effect.context();
649
+ const encodedEvents = yield* this[StoreInternalsSymbol].syncProcessor.encodeEvents(events);
650
+ const { writeTables } = yield* Effect.try({
425
651
  try: () => {
426
- const runMaterializeEvents = () => {
427
- return this.syncProcessor.push(events).pipe(Runtime.runSync(localRuntime));
428
- };
429
- if (events.length > 1) {
430
- return this.sqliteDbWrapper.txn(runMaterializeEvents);
431
- }
432
- else {
433
- return runMaterializeEvents();
434
- }
652
+ const materialize = () => this[StoreInternalsSymbol].syncProcessor
653
+ .materializeEvents(encodedEvents)
654
+ .pipe(Effect.runSyncWith(localServices));
655
+ return events.length > 1 ? this[StoreInternalsSymbol].sqliteDbWrapper.txn(materialize) : materialize();
435
656
  },
436
- catch: (cause) => UnexpectedError.make({ cause }),
657
+ catch: (cause) => UnknownError.make({ cause }),
437
658
  });
438
- // Materialize events to state
439
- const { writeTables } = yield* materializeEventsTx;
659
+ yield* this[StoreInternalsSymbol].syncProcessor.push(encodedEvents);
440
660
  const tablesToUpdate = [];
441
661
  for (const tableName of writeTables) {
442
- const tableRef = this.tableRefs[tableName];
662
+ const tableRef = this[StoreInternalsSymbol].tableRefs[tableName];
443
663
  assertNever(tableRef !== undefined, `No table ref found for ${tableName}`);
444
664
  tablesToUpdate.push([tableRef, null]);
445
665
  }
@@ -450,7 +670,7 @@ export class Store extends Inspectable.Class {
450
670
  };
451
671
  const skipRefresh = options?.skipRefresh ?? false;
452
672
  // Update all table refs together in a batch, to only trigger one reactive update
453
- this.reactivityGraph.setRefs(tablesToUpdate, {
673
+ this[StoreInternalsSymbol].reactivityGraph.setRefs(tablesToUpdate, {
454
674
  debugRefreshReason,
455
675
  skipRefresh,
456
676
  otelContext: otel.trace.setSpan(otel.context.active(), currentSpan),
@@ -464,38 +684,167 @@ export class Store extends Inspectable.Class {
464
684
  },
465
685
  links: [
466
686
  // Span link to LiveStore:commits
467
- OtelTracer.makeSpanLink({ context: otel.trace.getSpanContext(this.otel.commitsSpanContext) }),
687
+ OtelTracer.makeSpanLink({
688
+ context: otel.trace.getSpanContext(this[StoreInternalsSymbol].otel.commitsSpanContext),
689
+ }),
468
690
  // User-provided span links
469
691
  ...(options?.spanLinks?.map(OtelTracer.makeSpanLink) ?? []),
470
692
  ],
471
- }), Effect.tapErrorCause(Effect.logError), Effect.catchAllCause((cause) => Effect.fork(this.shutdown(cause))), Runtime.runSync(this.effectContext.runtime));
693
+ }), Effect.tapCause(Effect.logError), Effect.catchCause((cause) => Effect.forkChild(this.shutdown(cause))), Effect.runSyncWith(this[StoreInternalsSymbol].effectContext.services));
472
694
  };
473
- // #endregion commit
695
+ //#endregion commit
474
696
  /**
475
- * Returns an async iterable of events.
697
+ * Returns an async iterable of events from the eventlog.
698
+ * Currently only events confirmed by the sync backend is supported.
699
+ *
700
+ * Defaults to tracking upstreamHead as it advances. If an `until` event is
701
+ * supplied the stream finalizes upon reaching it.
702
+ *
703
+ * To start streaming from a specific point in the eventlog
704
+ * you can provide a `since` event.
705
+ *
706
+ * Allows filtering by:
707
+ * - `filter`: event types
708
+ * - `clientIds`: client identifiers
709
+ * - `sessionIds`: session identifiers
710
+ *
711
+ * The batchSize option controls the maximum amount of events that are fetched
712
+ * from the eventlog in each query. Defaults to 100 and has a max allowed
713
+ * value of 1000.
714
+ *
715
+ * TODO:
716
+ * - Support streaming unconfirmed events
717
+ * - Leader level
718
+ * - Session level
719
+ * - Support streaming client-only events
476
720
  *
477
721
  * @example
478
722
  * ```ts
479
- * for await (const event of store.events()) {
723
+ * // Stream todoCompleted events from the start
724
+ * for await (const event of store.events(filter: ['todoCompleted'])) {
480
725
  * console.log(event)
481
726
  * }
482
727
  * ```
483
728
  *
484
729
  * @example
485
730
  * ```ts
486
- * // Get all events from the beginning of time
487
- * for await (const event of store.events({ cursor: EventSequenceNumber.ROOT })) {
731
+ * // Start streaming from a specific event
732
+ * for await (const event of store.events({ since: EventSequenceNumber.Client.fromString('e3') })) {
488
733
  * console.log(event)
489
734
  * }
490
735
  * ```
491
736
  */
492
- events = (_options) => {
493
- this.checkShutdown('events');
494
- return notYetImplemented(`store.events() is not yet implemented but planned soon`);
737
+ events = (options) => {
738
+ const stream = this.eventsStream(options);
739
+ return {
740
+ async *[Symbol.asyncIterator]() {
741
+ const iterator = Stream.toAsyncIterable(stream);
742
+ for await (const event of iterator) {
743
+ yield event;
744
+ }
745
+ },
746
+ };
495
747
  };
496
- eventsStream = (_options) => {
497
- this.checkShutdown('eventsStream');
498
- return notYetImplemented(`store.eventsStream() is not yet implemented but planned soon`);
748
+ /**
749
+ * Returns an Effect Stream of events from the eventlog.
750
+ * See `store.events` for details on options and behaviour.
751
+ */
752
+ eventsStream = (options) => {
753
+ const { clientSession } = this[StoreInternalsSymbol];
754
+ const eventSchema = LiveStoreEvent.Client.makeSchema(this.schema);
755
+ const preferredBatchSize = options?.batchSize ?? this.params.eventQueryBatchSize ?? STORE_DEFAULT_PARAMS.eventQueryBatchSize;
756
+ const baseOptions = {
757
+ ...options,
758
+ filter: options?.filter,
759
+ batchSize: preferredBatchSize,
760
+ };
761
+ return clientSession.leaderThread.events.stream(baseOptions).pipe(Stream.mapEffect((event) => Schema.decodeEffect(eventSchema)(event)), Stream.catchTag('SchemaError', (cause) => Stream.fail(UnknownError.make({ cause }))), Stream.tapError((error) => Effect.logError('Error in eventsStream', error)));
762
+ };
763
+ /**
764
+ * Returns the current synchronization status of the store.
765
+ *
766
+ * This is a synchronous operation that returns the sync state between the
767
+ * client session and the leader thread. Use this to display sync indicators
768
+ * or check if local changes have been pushed to the leader.
769
+ *
770
+ * @example
771
+ * ```ts
772
+ * const status = store.syncStatus()
773
+ * console.log(status.isSynced ? 'Synced' : `${status.pendingCount} pending`)
774
+ * ```
775
+ *
776
+ * @example
777
+ * ```ts
778
+ * // Health check for backend connectivity
779
+ * const status = store.syncStatus()
780
+ * if (!status.isSynced && status.pendingCount > 100) {
781
+ * console.warn('Large backlog of unsynced events')
782
+ * }
783
+ * ```
784
+ */
785
+ syncStatus = () => {
786
+ this.checkShutdown('syncStatus');
787
+ const syncState = this[StoreInternalsSymbol].syncProcessor.syncState.pipe(Effect.runSync);
788
+ const pendingCount = syncState.pending.length;
789
+ return {
790
+ localHead: EventSequenceNumber.Client.toString(syncState.localHead),
791
+ upstreamHead: EventSequenceNumber.Client.toString(syncState.upstreamHead),
792
+ pendingCount,
793
+ isSynced: pendingCount === 0,
794
+ };
795
+ };
796
+ /**
797
+ * Returns an Effect Stream of sync status updates.
798
+ *
799
+ * Emits the current status immediately and then whenever the sync state changes.
800
+ * Use this for Effect-based workflows or when you need more control over the stream.
801
+ *
802
+ * @example
803
+ * ```ts
804
+ * store.syncStatusStream().pipe(
805
+ * Stream.tap((status) => Effect.log(`Sync status: ${status.isSynced}`)),
806
+ * Stream.runDrain,
807
+ * )
808
+ * ```
809
+ */
810
+ syncStatusStream = () => {
811
+ const syncStateSubscribable = this[StoreInternalsSymbol].syncProcessor.syncState;
812
+ return Stream.concat(Stream.fromEffect(syncStateSubscribable.pipe(Effect.map(this.makeSyncStatus))), syncStateSubscribable.changes.pipe(Stream.map(this.makeSyncStatus)));
813
+ };
814
+ /**
815
+ * Subscribes to sync status changes.
816
+ *
817
+ * The callback is invoked immediately with the current status and then
818
+ * whenever the sync state changes (e.g., when events are pushed or confirmed).
819
+ *
820
+ * @param onUpdate - Callback invoked with the current sync status
821
+ * @returns Unsubscribe function to stop receiving updates
822
+ *
823
+ * @example
824
+ * ```ts
825
+ * const unsubscribe = store.subscribeSyncStatus((status) => {
826
+ * updateUI(status.isSynced ? 'Synced' : 'Syncing...')
827
+ * })
828
+ *
829
+ * // Later, stop listening
830
+ * unsubscribe()
831
+ * ```
832
+ */
833
+ subscribeSyncStatus = (onUpdate) => {
834
+ this.checkShutdown('subscribeSyncStatus');
835
+ const fiber = this.syncStatusStream().pipe(Stream.tap((status) => Effect.sync(() => onUpdate(status))), Stream.runDrain, this.runEffectFork);
836
+ return () => {
837
+ Fiber.interrupt(fiber).pipe(Effect.runForkWith(this[StoreInternalsSymbol].effectContext.services));
838
+ };
839
+ };
840
+ makeSyncStatus = (syncState) => {
841
+ const pendingCount = syncState.pending.length;
842
+ return {
843
+ localHead: EventSequenceNumber.Client.toString(syncState.localHead),
844
+ upstreamHead: EventSequenceNumber.Client.toString(syncState.upstreamHead),
845
+ pendingCount,
846
+ isSynced: pendingCount === 0,
847
+ };
499
848
  };
500
849
  /**
501
850
  * This can be used in combination with `skipRefresh` when committing events.
@@ -504,9 +853,9 @@ export class Store extends Inspectable.Class {
504
853
  manualRefresh = (options) => {
505
854
  this.checkShutdown('manualRefresh');
506
855
  const { label } = options ?? {};
507
- this.otel.tracer.startActiveSpan('LiveStore:manualRefresh', { attributes: { 'livestore.manualRefreshLabel': label } }, this.otel.commitsSpanContext, (span) => {
856
+ this[StoreInternalsSymbol].otel.tracer.startActiveSpan('LiveStore:manualRefresh', { attributes: { 'livestore.manualRefreshLabel': label } }, this[StoreInternalsSymbol].otel.commitsSpanContext, (span) => {
508
857
  const otelContext = otel.trace.setSpan(otel.context.active(), span);
509
- this.reactivityGraph.runDeferredEffects({ otelContext });
858
+ this[StoreInternalsSymbol].reactivityGraph.runDeferredEffects({ otelContext });
510
859
  span.end();
511
860
  });
512
861
  };
@@ -517,8 +866,8 @@ export class Store extends Inspectable.Class {
517
866
  */
518
867
  shutdownPromise = async (cause) => {
519
868
  this.checkShutdown('shutdownPromise');
520
- this.isShutdown = true;
521
- await this.shutdown(cause ? Cause.fail(cause) : undefined).pipe(this.runEffectFork, Fiber.join, Effect.runPromise);
869
+ this[StoreInternalsSymbol].isShutdown = true;
870
+ await this.shutdown(cause !== undefined ? Cause.fail(cause) : undefined).pipe(this.runEffectFork, Fiber.join, Effect.runPromise);
522
871
  };
523
872
  /**
524
873
  * Shuts down the store and closes the client session.
@@ -526,8 +875,8 @@ export class Store extends Inspectable.Class {
526
875
  * This is called automatically when the store was created using the React or Effect API.
527
876
  */
528
877
  shutdown = (cause) => {
529
- this.isShutdown = true;
530
- return this.clientSession.shutdown(cause ? Exit.failCause(cause) : Exit.succeed(IntentionalShutdownCause.make({ reason: 'manual' })));
878
+ this[StoreInternalsSymbol].isShutdown = true;
879
+ return this[StoreInternalsSymbol].clientSession.shutdown(cause !== undefined ? Exit.failCause(cause) : Exit.succeed(IntentionalShutdownCause.make({ reason: 'manual' })));
531
880
  };
532
881
  /**
533
882
  * Helper methods useful during development
@@ -536,26 +885,28 @@ export class Store extends Inspectable.Class {
536
885
  */
537
886
  _dev = {
538
887
  downloadDb: (source = 'local') => {
539
- Effect.gen(this, function* () {
540
- const data = source === 'local' ? this.sqliteDbWrapper.export() : yield* this.clientSession.leaderThread.export;
888
+ Effect.gen({ self: this }, function* () {
889
+ const data = source === 'local'
890
+ ? this[StoreInternalsSymbol].sqliteDbWrapper.export()
891
+ : yield* this[StoreInternalsSymbol].clientSession.leaderThread.export;
541
892
  downloadBlob(data, `livestore-${Date.now()}.db`);
542
893
  }).pipe(this.runEffectFork);
543
894
  },
544
895
  downloadEventlogDb: () => {
545
- Effect.gen(this, function* () {
546
- const data = yield* this.clientSession.leaderThread.getEventlogData;
896
+ Effect.gen({ self: this }, function* () {
897
+ const data = yield* this[StoreInternalsSymbol].clientSession.leaderThread.getEventlogData;
547
898
  downloadBlob(data, `livestore-eventlog-${Date.now()}.db`);
548
899
  }).pipe(this.runEffectFork);
549
900
  },
550
901
  hardReset: (mode = 'all-data') => {
551
- Effect.gen(this, function* () {
552
- const clientId = this.clientSession.clientId;
553
- yield* this.clientSession.leaderThread.sendDevtoolsMessage(Devtools.Leader.ResetAllData.Request.make({ liveStoreVersion, mode, requestId: nanoid(), clientId }));
902
+ Effect.gen({ self: this }, function* () {
903
+ const clientId = this[StoreInternalsSymbol].clientSession.clientId;
904
+ yield* this[StoreInternalsSymbol].clientSession.leaderThread.sendDevtoolsMessage(Devtools.Leader.ResetAllData.Request.make({ liveStoreVersion, mode, requestId: nanoid(), clientId }));
554
905
  }).pipe(this.runEffectFork);
555
906
  },
556
907
  overrideNetworkStatus: (status) => {
557
- const clientId = this.clientSession.clientId;
558
- this.clientSession.leaderThread
908
+ const clientId = this[StoreInternalsSymbol].clientSession.clientId;
909
+ this[StoreInternalsSymbol].clientSession.leaderThread
559
910
  .sendDevtoolsMessage(Devtools.Leader.SetSyncLatch.Request.make({
560
911
  clientId,
561
912
  closeLatch: status === 'offline',
@@ -564,31 +915,32 @@ export class Store extends Inspectable.Class {
564
915
  }))
565
916
  .pipe(this.runEffectFork);
566
917
  },
567
- syncStates: () => Effect.gen(this, function* () {
568
- const session = yield* this.syncProcessor.syncState;
569
- const leader = yield* this.clientSession.leaderThread.getSyncState;
918
+ // NOTE: Explicit return type needed to avoid TS2742 (inferred type references internal path)
919
+ syncStates: () => Effect.gen({ self: this }, function* () {
920
+ const session = yield* this[StoreInternalsSymbol].syncProcessor.syncState;
921
+ const leader = yield* this[StoreInternalsSymbol].clientSession.leaderThread.syncState;
570
922
  return { session, leader };
571
923
  }).pipe(this.runEffectPromise),
572
924
  printSyncStates: () => {
573
- Effect.gen(this, function* () {
574
- const session = yield* this.syncProcessor.syncState;
575
- yield* Effect.log(`Session sync state: ${session.localHead} (upstream: ${session.upstreamHead})`, session.toJSON());
576
- const leader = yield* this.clientSession.leaderThread.getSyncState;
577
- yield* Effect.log(`Leader sync state: ${leader.localHead} (upstream: ${leader.upstreamHead})`, leader.toJSON());
925
+ Effect.gen({ self: this }, function* () {
926
+ const session = yield* this[StoreInternalsSymbol].syncProcessor.syncState;
927
+ yield* Effect.log(`Session sync state: ${objectToString(session.localHead)} (upstream: ${objectToString(session.upstreamHead)})`, session.toJSON());
928
+ const leader = yield* this[StoreInternalsSymbol].clientSession.leaderThread.syncState;
929
+ yield* Effect.log(`Leader sync state: ${objectToString(leader.localHead)} (upstream: ${objectToString(leader.upstreamHead)})`, leader.toJSON());
578
930
  }).pipe(this.runEffectFork);
579
931
  },
580
932
  version: liveStoreVersion,
581
933
  otel: {
582
- rootSpanContext: () => otel.trace.getSpan(this.otel.rootSpanContext)?.spanContext(),
934
+ rootSpanContext: () => otel.trace.getSpan(this[StoreInternalsSymbol].otel.rootSpanContext)?.spanContext(),
583
935
  },
584
936
  };
585
937
  // NOTE This is needed because when booting a Store via Effect it seems to call `toJSON` in the error path
586
938
  toJSON = () => ({
587
939
  _tag: 'livestore.Store',
588
- reactivityGraph: this.reactivityGraph.getSnapshot({ includeResults: true }),
940
+ reactivityGraph: this[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true }),
589
941
  });
590
- runEffectFork = (effect) => effect.pipe(Effect.forkIn(this.effectContext.lifetimeScope), Effect.tapCauseLogPretty, Runtime.runFork(this.effectContext.runtime));
591
- runEffectPromise = (effect) => effect.pipe(Effect.tapCauseLogPretty, Runtime.runPromise(this.effectContext.runtime));
942
+ runEffectFork = (effect) => effect.pipe(Effect.forkIn(this[StoreInternalsSymbol].effectContext.lifetimeScope), Effect.tapCauseLogPretty, Effect.runForkWith(this[StoreInternalsSymbol].effectContext.services));
943
+ runEffectPromise = (effect) => effect.pipe(Effect.tapCauseLogPretty, Effect.runPromiseWith(this[StoreInternalsSymbol].effectContext.services));
592
944
  getCommitArgs = (firstEventOrTxnFnOrOptions, restEvents) => {
593
945
  let events;
594
946
  let options;
@@ -610,11 +962,6 @@ export class Store extends Inspectable.Class {
610
962
  else {
611
963
  events = [firstEventOrTxnFnOrOptions, ...restEvents];
612
964
  }
613
- // for (const event of events) {
614
- // if (event.args.id === SessionIdSymbol) {
615
- // event.args.id = this.clientSession.sessionId
616
- // }
617
- // }
618
965
  return { events, options };
619
966
  };
620
967
  }