@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,6 +1,6 @@
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';
1
+ import { Devtools, getExecStatementsFromMaterializer, getResultSchema, hashMaterializerResults, IntentionalShutdownCause, isQueryBuilder, liveStoreVersion, MaterializeError, MaterializerHashMismatchError, makeClientSessionSyncProcessor, prepareBindValues, QueryBuilderAstSymbol, replaceSessionIdSymbol, UnknownError, } from '@livestore/common';
2
+ import { EventSequenceNumber, LiveStoreEvent, resolveEventDef, SystemTables } from '@livestore/common/schema';
3
+ import { assertNever, isDevEnv, objectToString, omitUndefineds, shouldNeverHappen } from '@livestore/utils';
4
4
  import { Cause, Effect, Exit, Fiber, Inspectable, Option, OtelTracer, Runtime, Schema, Stream, } from '@livestore/utils/effect';
5
5
  import { nanoid } from '@livestore/utils/nanoid';
6
6
  import * as otel from '@opentelemetry/api';
@@ -10,57 +10,148 @@ 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;
24
79
  /**
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.
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
+ * ```
27
95
  */
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
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;
114
+ /**
115
+ * Store internals. Not part of the public API — shapes and semantics may change without notice.
116
+ */
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 syncProcessor = makeClientSessionSyncProcessor({
50
129
  schema,
51
130
  clientSession,
52
- runtime: effectContext.runtime,
53
- materializeEvent: Effect.fn('client-session-sync-processor:materialize-event')((eventDecoded, { withChangeset, materializerHashLeader }) =>
131
+ materializeEvent: Effect.fn('client-session-sync-processor:materialize-event')((eventEncoded, { withChangeset, materializerHashLeader }) =>
54
132
  // We need to use `Effect.gen` (even though we're using `Effect.fn`) so that we can pass `this` to the function
55
133
  Effect.gen(this, function* () {
56
- const { eventDef, materializer } = getEventDef(schema, eventDecoded.name);
134
+ const resolution = yield* resolveEventDef(schema, {
135
+ operation: '@livestore/livestore:store:materializeEvent',
136
+ event: eventEncoded,
137
+ });
138
+ if (resolution._tag === 'unknown') {
139
+ // Runtime schema doesn't know this event yet; skip materialization but
140
+ // keep the log entry so upgraded clients can replay it later.
141
+ return {
142
+ writeTables: new Set(),
143
+ sessionChangeset: { _tag: 'no-op' },
144
+ materializerHash: Option.none(),
145
+ };
146
+ }
147
+ const { eventDef, materializer } = resolution;
57
148
  const execArgsArr = getExecStatementsFromMaterializer({
58
149
  eventDef,
59
150
  materializer,
60
- dbState: this.sqliteDbWrapper,
61
- event: { decoded: eventDecoded, encoded: undefined },
151
+ dbState: this[StoreInternalsSymbol].sqliteDbWrapper,
152
+ event: { decoded: undefined, encoded: eventEncoded },
62
153
  });
63
- const materializerHash = isDevEnv() ? Option.some(hashMaterializerResults(execArgsArr)) : Option.none();
154
+ const materializerHash = isDevEnv() === true ? Option.some(hashMaterializerResults(execArgsArr)) : Option.none();
64
155
  // Hash mismatch detection only occurs during the pull path (when receiving events from the leader).
65
156
  // During push path (local commits), materializerHashLeader is always Option.none(), so this condition
66
157
  // will never be met. The check happens when the same event comes back from the leader during sync,
@@ -68,33 +159,36 @@ export class Store extends Inspectable.Class {
68
159
  if (materializerHashLeader._tag === 'Some' &&
69
160
  materializerHash._tag === 'Some' &&
70
161
  materializerHashLeader.value !== materializerHash.value) {
71
- return yield* MaterializerHashMismatchError.make({ eventName: eventDecoded.name });
162
+ return yield* MaterializerHashMismatchError.make({ eventName: eventEncoded.name });
72
163
  }
73
164
  const span = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie);
74
165
  const otelContext = otel.trace.setSpan(otel.context.active(), span);
75
166
  const writeTablesForEvent = new Set();
76
167
  const exec = () => {
77
- for (const { statementSql, bindValues, writeTables = this.sqliteDbWrapper.getTablesUsed(statementSql), } of execArgsArr) {
168
+ for (const { statementSql, bindValues, writeTables = this[StoreInternalsSymbol].sqliteDbWrapper.getTablesUsed(statementSql), } of execArgsArr) {
78
169
  try {
79
- this.sqliteDbWrapper.cachedExecute(statementSql, bindValues, { otelContext, writeTables });
170
+ this[StoreInternalsSymbol].sqliteDbWrapper.cachedExecute(statementSql, bindValues, {
171
+ otelContext,
172
+ writeTables,
173
+ });
80
174
  }
81
175
  catch (cause) {
82
176
  // TOOD refactor with `SqliteError`
83
- throw UnexpectedError.make({
177
+ throw UnknownError.make({
84
178
  cause,
85
- note: `Error executing materializer for event "${eventDecoded.name}".\nStatement: ${statementSql}\nBind values: ${JSON.stringify(bindValues)}`,
179
+ note: `Error executing materializer for event "${eventEncoded.name}".\nStatement: ${statementSql}\nBind values: ${JSON.stringify(bindValues)}`,
86
180
  });
87
181
  }
88
182
  // durationMsTotal += durationMs
89
183
  for (const table of writeTables) {
90
184
  writeTablesForEvent.add(table);
91
185
  }
92
- this.sqliteDbWrapper.debug.head = eventDecoded.seqNum;
186
+ this[StoreInternalsSymbol].sqliteDbWrapper.debug.head = eventEncoded.seqNum;
93
187
  }
94
188
  };
95
189
  let sessionChangeset = { _tag: 'unset' };
96
190
  if (withChangeset === true) {
97
- sessionChangeset = this.sqliteDbWrapper.withChangeset(exec).changeset;
191
+ sessionChangeset = this[StoreInternalsSymbol].sqliteDbWrapper.withChangeset(exec).changeset;
98
192
  }
99
193
  else {
100
194
  exec();
@@ -102,18 +196,17 @@ export class Store extends Inspectable.Class {
102
196
  return { writeTables: writeTablesForEvent, sessionChangeset, materializerHash };
103
197
  }).pipe(Effect.mapError((cause) => MaterializeError.make({ cause })))),
104
198
  rollback: (changeset) => {
105
- this.sqliteDbWrapper.rollback(changeset);
199
+ this[StoreInternalsSymbol].sqliteDbWrapper.rollback(changeset);
106
200
  },
107
201
  refreshTables: (tables) => {
108
202
  const tablesToUpdate = [];
109
203
  for (const tableName of tables) {
110
- const tableRef = this.tableRefs[tableName];
204
+ const tableRef = this[StoreInternalsSymbol].tableRefs[tableName];
111
205
  assertNever(tableRef !== undefined, `No table ref found for ${tableName}`);
112
206
  tablesToUpdate.push([tableRef, null]);
113
207
  }
114
208
  reactivityGraph.setRefs(tablesToUpdate);
115
209
  },
116
- span: syncSpan,
117
210
  params: {
118
211
  ...omitUndefineds({
119
212
  leaderPushBatchSize: params.leaderPushBatchSize,
@@ -123,17 +216,15 @@ export class Store extends Inspectable.Class {
123
216
  : {}),
124
217
  },
125
218
  confirmUnsavedChanges,
126
- });
127
- this.__eventSchema = LiveStoreEvent.makeEventDefSchemaMemo(schema);
219
+ }).pipe(Runtime.runSync(effectContext.runtime));
128
220
  // TODO generalize the `tableRefs` concept to allow finer-grained refs
129
- this.tableRefs = {};
130
- this.activeQueries = new ReferenceCountedSet();
221
+ const tableRefs = {};
222
+ const activeQueries = new ReferenceCountedSet();
131
223
  const commitsSpan = otelOptions.tracer.startSpan('LiveStore:commits', {}, otelOptions.rootSpanContext);
132
224
  const otelMuationsSpanContext = otel.trace.setSpan(otel.context.active(), commitsSpan);
133
225
  const queriesSpan = otelOptions.tracer.startSpan('LiveStore:queries', {}, otelOptions.rootSpanContext);
134
226
  const otelQueriesSpanContext = otel.trace.setSpan(otel.context.active(), queriesSpan);
135
- this.reactivityGraph = reactivityGraph;
136
- this.reactivityGraph.context = {
227
+ reactivityGraph.context = {
137
228
  store: this,
138
229
  defRcMap: new Map(),
139
230
  reactivityGraph: new WeakRef(reactivityGraph),
@@ -141,7 +232,7 @@ export class Store extends Inspectable.Class {
141
232
  rootOtelContext: otelQueriesSpanContext,
142
233
  effectsWrapper: batchUpdates,
143
234
  };
144
- this.otel = {
235
+ const otelObj = {
145
236
  tracer: otelOptions.tracer,
146
237
  rootSpanContext: otelOptions.rootSpanContext,
147
238
  commitsSpanContext: otelMuationsSpanContext,
@@ -151,92 +242,159 @@ export class Store extends Inspectable.Class {
151
242
  const allTableNames = new Set(
152
243
  // NOTE we're excluding the LiveStore schema and events tables as they are not user-facing
153
244
  // unless LiveStore is running in the devtools
154
- __runningInDevtools
245
+ __runningInDevtools === true
155
246
  ? this.schema.state.sqlite.tables.keys()
156
247
  : Array.from(this.schema.state.sqlite.tables.keys()).filter((_) => !SystemTables.isStateSystemTable(_)));
157
- const existingTableRefs = new Map(Array.from(this.reactivityGraph.atoms.values())
248
+ const existingTableRefs = new Map(Array.from(reactivityGraph.atoms.values())
158
249
  .filter((_) => _._tag === 'ref' && _.label?.startsWith('tableRef:') === true)
159
250
  .map((_) => [_.label.slice('tableRef:'.length), _]));
160
251
  for (const tableName of allTableNames) {
161
- this.tableRefs[tableName] =
252
+ tableRefs[tableName] =
162
253
  existingTableRefs.get(tableName) ??
163
- this.reactivityGraph.makeRef(null, {
254
+ reactivityGraph.makeRef(null, {
164
255
  equal: () => false,
165
- label: `tableRef:${tableName}`,
256
+ label: `tableRef:${String(tableName)}`,
166
257
  meta: { liveStoreRefType: 'table' },
167
258
  });
168
259
  }
169
- this.boot = Effect.gen(this, function* () {
260
+ const boot = Effect.gen(this, function* () {
170
261
  yield* Effect.addFinalizer(() => Effect.sync(() => {
171
262
  // Remove all table refs from the reactivity graph
172
- for (const tableRef of Object.values(this.tableRefs)) {
263
+ for (const tableRef of Object.values(tableRefs)) {
173
264
  for (const superComp of tableRef.super) {
174
- this.reactivityGraph.removeEdge(superComp, tableRef);
265
+ this[StoreInternalsSymbol].reactivityGraph.removeEdge(superComp, tableRef);
175
266
  }
176
267
  }
177
268
  // End the otel spans
178
- syncSpan.end();
179
269
  commitsSpan.end();
180
270
  queriesSpan.end();
181
271
  }));
182
- yield* this.syncProcessor.boot;
272
+ yield* syncProcessor.boot;
183
273
  });
274
+ // Build Sqlite wrapper last to avoid using getters before internals are set
275
+ const sqliteDbWrapper = new SqliteDbWrapper({ otel: otelOptions, db: clientSession.sqliteDb });
276
+ // Initialize internals bag
277
+ this[StoreInternalsSymbol] = {
278
+ eventSchema: LiveStoreEvent.Client.makeSchemaMemo(schema),
279
+ clientSession,
280
+ sqliteDbWrapper,
281
+ effectContext,
282
+ otel: otelObj,
283
+ reactivityGraph,
284
+ tableRefs,
285
+ activeQueries,
286
+ syncProcessor,
287
+ boot,
288
+ isShutdown: false,
289
+ };
290
+ // Initialize stable network status property from client session
291
+ this.networkStatus = clientSession.leaderThread.networkStatus;
184
292
  }
185
- // #endregion constructor
293
+ //#endregion constructor
294
+ /**
295
+ * Current session identifier for this Store instance.
296
+ *
297
+ * - Stable for the lifetime of the Store
298
+ * - Useful for correlating events or scoping per-session data
299
+ */
186
300
  get sessionId() {
187
- return this.clientSession.sessionId;
301
+ return this[StoreInternalsSymbol].clientSession.sessionId;
188
302
  }
303
+ /**
304
+ * Stable client identifier for the process/device using this Store.
305
+ *
306
+ * - Shared across Store instances created by the same client
307
+ * - Useful for diagnostics and multi-client correlation
308
+ */
189
309
  get clientId() {
190
- return this.clientSession.clientId;
310
+ return this[StoreInternalsSymbol].clientSession.clientId;
191
311
  }
192
312
  checkShutdown = (operation) => {
193
- if (this.isShutdown) {
194
- throw new UnexpectedError({
313
+ if (this[StoreInternalsSymbol].isShutdown === true) {
314
+ throw new UnknownError({
195
315
  cause: `Store has been shut down (while performing "${operation}").`,
196
316
  note: `You cannot perform this operation after the store has been shut down.`,
197
317
  });
198
318
  }
199
319
  };
200
320
  /**
201
- * Subscribe to the results of a query
202
- * Returns a function to cancel the subscription.
321
+ * Subscribe to the results of a query.
322
+ *
323
+ * - When providing an `onUpdate` callback it returns an {@link Unsubscribe} function.
324
+ * - Without a callback it returns an {@link AsyncIterable} that yields query results.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * const unsubscribe = store.subscribe(query$, (result) => console.log(result))
329
+ * ```
203
330
  *
204
331
  * @example
205
332
  * ```ts
206
- * const unsubscribe = store.subscribe(query$, { onUpdate: (result) => console.log(result) })
333
+ * for await (const result of store.subscribe(query$)) {
334
+ * console.log(result)
335
+ * }
207
336
  * ```
208
337
  */
209
- subscribe = (query, options) => {
338
+ subscribe = ((query, onUpdateOrOptions, maybeOptions) => {
339
+ if (typeof onUpdateOrOptions === 'function') {
340
+ return this.subscribeWithCallback(query, onUpdateOrOptions, maybeOptions);
341
+ }
342
+ return this.subscribeAsAsyncIterable(query, onUpdateOrOptions);
343
+ });
344
+ subscribeWithCallback = (query, onUpdate, options) => {
210
345
  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)
346
+ return this[StoreInternalsSymbol].otel.tracer.startActiveSpan(`LiveStore.subscribe`, {
347
+ attributes: {
348
+ label: options?.label,
349
+ queryLabel: isQueryBuilder(query) === true ? query.toString() : query.label,
350
+ },
351
+ }, options?.otelContext ?? this[StoreInternalsSymbol].otel.queriesSpanContext, (span) => {
213
352
  const otelContext = otel.trace.setSpan(otel.context.active(), span);
214
- const queryRcRef = isQueryBuilder(query)
215
- ? queryDb(query).make(this.reactivityGraph.context)
353
+ const queryRcRef = isQueryBuilder(query) === true
354
+ ? queryDb(query).make(this[StoreInternalsSymbol].reactivityGraph.context)
216
355
  : query._tag === 'def' || query._tag === 'signal-def'
217
- ? query.make(this.reactivityGraph.context)
356
+ ? query.make(this[StoreInternalsSymbol].reactivityGraph.context)
218
357
  : {
219
358
  value: query,
220
359
  deref: () => { },
221
360
  };
222
361
  const query$ = queryRcRef.value;
223
362
  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) {
363
+ let suppressCallback = options?.skipInitialRun === true;
364
+ const effect = this[StoreInternalsSymbol].reactivityGraph.makeEffect((get, _otelContext, debugRefreshReason) => {
365
+ const result = get(query$.results$, otelContext, debugRefreshReason);
366
+ if (suppressCallback === true) {
367
+ return;
368
+ }
369
+ onUpdate(result);
370
+ }, { label });
371
+ const runInitialEffect = () => {
372
+ effect.doEffect(otelContext, {
373
+ _tag: 'subscribe.initial',
374
+ label: `subscribe-initial-run:${options?.label}`,
375
+ });
376
+ };
377
+ if (options?.stackInfo !== undefined) {
226
378
  query$.activeSubscriptions.add(options.stackInfo);
227
379
  }
228
380
  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}` });
381
+ this[StoreInternalsSymbol].activeQueries.add(query$);
382
+ if (query$.isDestroyed === false) {
383
+ if (suppressCallback === true) {
384
+ // We still run once to register dependencies in the reactive graph, but suppress the initial callback so the
385
+ // caller truly skips the first emission; subsequent runs (after commits) will call the callback.
386
+ runInitialEffect();
387
+ suppressCallback = false;
388
+ }
389
+ else {
390
+ runInitialEffect();
391
+ }
233
392
  }
234
393
  const unsubscribe = () => {
235
- // console.debug('store unsub', query$.id, query$.label)
236
394
  try {
237
- this.reactivityGraph.destroyNode(effect);
238
- this.activeQueries.remove(query$);
239
- if (options?.stackInfo) {
395
+ this[StoreInternalsSymbol].reactivityGraph.destroyNode(effect);
396
+ this[StoreInternalsSymbol].activeQueries.remove(query$);
397
+ if (options?.stackInfo !== undefined) {
240
398
  query$.activeSubscriptions.delete(options.stackInfo);
241
399
  }
242
400
  queryRcRef.deref();
@@ -249,12 +407,16 @@ export class Store extends Inspectable.Class {
249
407
  return unsubscribe;
250
408
  });
251
409
  };
252
- subscribeStream = (query$, options) => Stream.asyncPush((emit) => Effect.gen(this, function* () {
410
+ subscribeAsAsyncIterable = (query, options) => {
411
+ this.checkShutdown('subscribe');
412
+ return Stream.toAsyncIterable(this.subscribeStream(query, options));
413
+ };
414
+ subscribeStream = (query, options) => Stream.asyncPush((emit) => Effect.gen(this, function* () {
253
415
  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 }),
416
+ const otelContext = otelSpan !== undefined ? otel.trace.setSpan(otel.context.active(), otelSpan) : otel.context.active();
417
+ yield* Effect.acquireRelease(Effect.sync(() => this.subscribe(query, (result) => emit.single(result), {
418
+ ...options,
419
+ otelContext,
258
420
  })), (unsub) => Effect.sync(() => unsub()));
259
421
  }));
260
422
  /**
@@ -274,15 +436,15 @@ export class Store extends Inspectable.Class {
274
436
  query = (query, options) => {
275
437
  this.checkShutdown('query');
276
438
  if (typeof query === 'object' && 'query' in query && 'bindValues' in query) {
277
- const res = this.sqliteDbWrapper.cachedSelect(query.query, prepareBindValues(query.bindValues, query.query), {
439
+ const res = this[StoreInternalsSymbol].sqliteDbWrapper.cachedSelect(query.query, prepareBindValues(query.bindValues, query.query), {
278
440
  ...omitUndefineds({ otelContext: options?.otelContext }),
279
441
  });
280
- if (query.schema) {
442
+ if (query.schema !== undefined) {
281
443
  return Schema.decodeSync(query.schema)(res);
282
444
  }
283
445
  return res;
284
446
  }
285
- else if (isQueryBuilder(query)) {
447
+ else if (isQueryBuilder(query) === true) {
286
448
  const ast = query[QueryBuilderAstSymbol];
287
449
  if (ast._tag === 'RowQuery') {
288
450
  makeExecBeforeFirstRun({
@@ -290,15 +452,15 @@ export class Store extends Inspectable.Class {
290
452
  id: ast.id,
291
453
  explicitDefaultValues: ast.explicitDefaultValues,
292
454
  otelContext: options?.otelContext,
293
- })(this.reactivityGraph.context);
455
+ })(this[StoreInternalsSymbol].reactivityGraph.context);
294
456
  }
295
457
  const sqlRes = query.asSql();
296
458
  const schema = getResultSchema(query);
297
459
  // Replace SessionIdSymbol in bind values before executing the query
298
- if (sqlRes.bindValues) {
299
- replaceSessionIdSymbol(sqlRes.bindValues, this.clientSession.sessionId);
460
+ if (sqlRes.bindValues !== undefined) {
461
+ replaceSessionIdSymbol(sqlRes.bindValues, this[StoreInternalsSymbol].clientSession.sessionId);
300
462
  }
301
- const rawRes = this.sqliteDbWrapper.cachedSelect(sqlRes.query, sqlRes.bindValues, {
463
+ const rawRes = this[StoreInternalsSymbol].sqliteDbWrapper.cachedSelect(sqlRes.query, sqlRes.bindValues, {
302
464
  ...omitUndefineds({ otelContext: options?.otelContext }),
303
465
  queriedTables: new Set([query[QueryBuilderAstSymbol].tableDef.sqliteDef.name]),
304
466
  });
@@ -307,17 +469,17 @@ export class Store extends Inspectable.Class {
307
469
  return decodeResult.right;
308
470
  }
309
471
  else {
310
- return shouldNeverHappen(`Failed to decode query result with for schema:`, schema.toString(), 'raw result:', rawRes, 'decode error:', decodeResult.left);
472
+ return shouldNeverHappen('Failed to decode query result with for schema:', objectToString(schema), 'raw result:', rawRes, 'decode error:', decodeResult.left);
311
473
  }
312
474
  }
313
475
  else if (query._tag === 'def') {
314
- const query$ = query.make(this.reactivityGraph.context);
476
+ const query$ = query.make(this[StoreInternalsSymbol].reactivityGraph.context);
315
477
  const result = this.query(query$.value, options);
316
478
  query$.deref();
317
479
  return result;
318
480
  }
319
481
  else if (query._tag === 'signal-def') {
320
- const signal$ = query.make(this.reactivityGraph.context);
482
+ const signal$ = query.make(this[StoreInternalsSymbol].reactivityGraph.context);
321
483
  return signal$.value.get();
322
484
  }
323
485
  else {
@@ -343,7 +505,7 @@ export class Store extends Inspectable.Class {
343
505
  */
344
506
  setSignal = (signalDef, value) => {
345
507
  this.checkShutdown('setSignal');
346
- const signalRef = signalDef.make(this.reactivityGraph.context);
508
+ const signalRef = signalDef.make(this[StoreInternalsSymbol].reactivityGraph.context);
347
509
  const newValue = typeof value === 'function' ? value(signalRef.value.get()) : value;
348
510
  signalRef.value.set(newValue);
349
511
  // The current implementation of signals i.e. the separation into `signal-def` and `signal`
@@ -355,7 +517,7 @@ export class Store extends Inspectable.Class {
355
517
  signalRef.deref();
356
518
  }
357
519
  };
358
- // #region commit
520
+ //#region commit
359
521
  /**
360
522
  * Commit a list of events to the store which will immediately update the local database
361
523
  * and sync the events across other clients (similar to a `git commit`).
@@ -411,35 +573,30 @@ export class Store extends Inspectable.Class {
411
573
  this.checkShutdown('commit');
412
574
  const { events, options } = this.getCommitArgs(firstEventOrTxnFnOrOptions, restEvents);
413
575
  Effect.gen(this, function* () {
414
- const commitsSpan = otel.trace.getSpan(this.otel.commitsSpanContext);
576
+ const commitsSpan = otel.trace.getSpan(this[StoreInternalsSymbol].otel.commitsSpanContext);
415
577
  commitsSpan?.addEvent('commit');
416
578
  const currentSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie);
417
579
  commitsSpan?.addLink({ context: currentSpan.spanContext() });
418
580
  for (const event of events) {
419
- replaceSessionIdSymbol(event.args, this.clientSession.sessionId);
581
+ replaceSessionIdSymbol(event.args, this[StoreInternalsSymbol].clientSession.sessionId);
420
582
  }
421
583
  if (events.length === 0)
422
584
  return;
423
585
  const localRuntime = yield* Effect.runtime();
424
- const materializeEventsTx = Effect.try({
586
+ const encodedEvents = yield* this[StoreInternalsSymbol].syncProcessor.encodeEvents(events);
587
+ const { writeTables } = yield* Effect.try({
425
588
  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
- }
589
+ const materialize = () => this[StoreInternalsSymbol].syncProcessor.materializeEvents(encodedEvents).pipe(Runtime.runSync(localRuntime));
590
+ return events.length > 1
591
+ ? this[StoreInternalsSymbol].sqliteDbWrapper.txn(materialize)
592
+ : materialize();
435
593
  },
436
- catch: (cause) => UnexpectedError.make({ cause }),
594
+ catch: (cause) => UnknownError.make({ cause }),
437
595
  });
438
- // Materialize events to state
439
- const { writeTables } = yield* materializeEventsTx;
596
+ yield* this[StoreInternalsSymbol].syncProcessor.push(encodedEvents);
440
597
  const tablesToUpdate = [];
441
598
  for (const tableName of writeTables) {
442
- const tableRef = this.tableRefs[tableName];
599
+ const tableRef = this[StoreInternalsSymbol].tableRefs[tableName];
443
600
  assertNever(tableRef !== undefined, `No table ref found for ${tableName}`);
444
601
  tablesToUpdate.push([tableRef, null]);
445
602
  }
@@ -450,7 +607,7 @@ export class Store extends Inspectable.Class {
450
607
  };
451
608
  const skipRefresh = options?.skipRefresh ?? false;
452
609
  // Update all table refs together in a batch, to only trigger one reactive update
453
- this.reactivityGraph.setRefs(tablesToUpdate, {
610
+ this[StoreInternalsSymbol].reactivityGraph.setRefs(tablesToUpdate, {
454
611
  debugRefreshReason,
455
612
  skipRefresh,
456
613
  otelContext: otel.trace.setSpan(otel.context.active(), currentSpan),
@@ -464,38 +621,167 @@ export class Store extends Inspectable.Class {
464
621
  },
465
622
  links: [
466
623
  // Span link to LiveStore:commits
467
- OtelTracer.makeSpanLink({ context: otel.trace.getSpanContext(this.otel.commitsSpanContext) }),
624
+ OtelTracer.makeSpanLink({
625
+ context: otel.trace.getSpanContext(this[StoreInternalsSymbol].otel.commitsSpanContext),
626
+ }),
468
627
  // User-provided span links
469
628
  ...(options?.spanLinks?.map(OtelTracer.makeSpanLink) ?? []),
470
629
  ],
471
- }), Effect.tapErrorCause(Effect.logError), Effect.catchAllCause((cause) => Effect.fork(this.shutdown(cause))), Runtime.runSync(this.effectContext.runtime));
630
+ }), Effect.tapErrorCause(Effect.logError), Effect.catchAllCause((cause) => Effect.fork(this.shutdown(cause))), Runtime.runSync(this[StoreInternalsSymbol].effectContext.runtime));
472
631
  };
473
- // #endregion commit
632
+ //#endregion commit
474
633
  /**
475
- * Returns an async iterable of events.
634
+ * Returns an async iterable of events from the eventlog.
635
+ * Currently only events confirmed by the sync backend is supported.
636
+ *
637
+ * Defaults to tracking upstreamHead as it advances. If an `until` event is
638
+ * supplied the stream finalizes upon reaching it.
639
+ *
640
+ * To start streaming from a specific point in the eventlog
641
+ * you can provide a `since` event.
642
+ *
643
+ * Allows filtering by:
644
+ * - `filter`: event types
645
+ * - `clientIds`: client identifiers
646
+ * - `sessionIds`: session identifiers
647
+ *
648
+ * The batchSize option controls the maximum amount of events that are fetched
649
+ * from the eventlog in each query. Defaults to 100 and has a max allowed
650
+ * value of 1000.
651
+ *
652
+ * TODO:
653
+ * - Support streaming unconfirmed events
654
+ * - Leader level
655
+ * - Session level
656
+ * - Support streaming client-only events
476
657
  *
477
658
  * @example
478
659
  * ```ts
479
- * for await (const event of store.events()) {
660
+ * // Stream todoCompleted events from the start
661
+ * for await (const event of store.events(filter: ['todoCompleted'])) {
480
662
  * console.log(event)
481
663
  * }
482
664
  * ```
483
665
  *
484
666
  * @example
485
667
  * ```ts
486
- * // Get all events from the beginning of time
487
- * for await (const event of store.events({ cursor: EventSequenceNumber.ROOT })) {
668
+ * // Start streaming from a specific event
669
+ * for await (const event of store.events({ since: EventSequenceNumber.Client.fromString('e3') })) {
488
670
  * console.log(event)
489
671
  * }
490
672
  * ```
491
673
  */
492
- events = (_options) => {
493
- this.checkShutdown('events');
494
- return notYetImplemented(`store.events() is not yet implemented but planned soon`);
674
+ events = (options) => {
675
+ const stream = this.eventsStream(options);
676
+ return {
677
+ async *[Symbol.asyncIterator]() {
678
+ const iterator = Stream.toAsyncIterable(stream);
679
+ for await (const event of iterator) {
680
+ yield event;
681
+ }
682
+ },
683
+ };
495
684
  };
496
- eventsStream = (_options) => {
497
- this.checkShutdown('eventsStream');
498
- return notYetImplemented(`store.eventsStream() is not yet implemented but planned soon`);
685
+ /**
686
+ * Returns an Effect Stream of events from the eventlog.
687
+ * See `store.events` for details on options and behaviour.
688
+ */
689
+ eventsStream = (options) => {
690
+ const { clientSession } = this[StoreInternalsSymbol];
691
+ const eventSchema = LiveStoreEvent.Client.makeSchema(this.schema);
692
+ const preferredBatchSize = options?.batchSize ?? this.params.eventQueryBatchSize ?? STORE_DEFAULT_PARAMS.eventQueryBatchSize;
693
+ const baseOptions = {
694
+ ...options,
695
+ filter: options?.filter,
696
+ batchSize: preferredBatchSize,
697
+ };
698
+ return clientSession.leaderThread.events.stream(baseOptions).pipe(Stream.mapChunksEffect(Schema.decode(Schema.ChunkFromSelf(eventSchema))), Stream.catchTag('ParseError', (cause) => Stream.fail(UnknownError.make({ cause }))), Stream.tapError((error) => Effect.logError('Error in eventsStream', error)));
699
+ };
700
+ /**
701
+ * Returns the current synchronization status of the store.
702
+ *
703
+ * This is a synchronous operation that returns the sync state between the
704
+ * client session and the leader thread. Use this to display sync indicators
705
+ * or check if local changes have been pushed to the leader.
706
+ *
707
+ * @example
708
+ * ```ts
709
+ * const status = store.syncStatus()
710
+ * console.log(status.isSynced ? 'Synced' : `${status.pendingCount} pending`)
711
+ * ```
712
+ *
713
+ * @example
714
+ * ```ts
715
+ * // Health check for backend connectivity
716
+ * const status = store.syncStatus()
717
+ * if (!status.isSynced && status.pendingCount > 100) {
718
+ * console.warn('Large backlog of unsynced events')
719
+ * }
720
+ * ```
721
+ */
722
+ syncStatus = () => {
723
+ this.checkShutdown('syncStatus');
724
+ const syncState = this[StoreInternalsSymbol].syncProcessor.syncState.pipe(Effect.runSync);
725
+ const pendingCount = syncState.pending.length;
726
+ return {
727
+ localHead: EventSequenceNumber.Client.toString(syncState.localHead),
728
+ upstreamHead: EventSequenceNumber.Client.toString(syncState.upstreamHead),
729
+ pendingCount,
730
+ isSynced: pendingCount === 0,
731
+ };
732
+ };
733
+ /**
734
+ * Returns an Effect Stream of sync status updates.
735
+ *
736
+ * Emits the current status immediately and then whenever the sync state changes.
737
+ * Use this for Effect-based workflows or when you need more control over the stream.
738
+ *
739
+ * @example
740
+ * ```ts
741
+ * store.syncStatusStream().pipe(
742
+ * Stream.tap((status) => Effect.log(`Sync status: ${status.isSynced}`)),
743
+ * Stream.runDrain,
744
+ * )
745
+ * ```
746
+ */
747
+ syncStatusStream = () => {
748
+ const syncStateSubscribable = this[StoreInternalsSymbol].syncProcessor.syncState;
749
+ return Stream.concat(Stream.fromEffect(syncStateSubscribable.pipe(Effect.map(this.makeSyncStatus))), syncStateSubscribable.changes.pipe(Stream.map(this.makeSyncStatus)));
750
+ };
751
+ /**
752
+ * Subscribes to sync status changes.
753
+ *
754
+ * The callback is invoked immediately with the current status and then
755
+ * whenever the sync state changes (e.g., when events are pushed or confirmed).
756
+ *
757
+ * @param onUpdate - Callback invoked with the current sync status
758
+ * @returns Unsubscribe function to stop receiving updates
759
+ *
760
+ * @example
761
+ * ```ts
762
+ * const unsubscribe = store.subscribeSyncStatus((status) => {
763
+ * updateUI(status.isSynced ? 'Synced' : 'Syncing...')
764
+ * })
765
+ *
766
+ * // Later, stop listening
767
+ * unsubscribe()
768
+ * ```
769
+ */
770
+ subscribeSyncStatus = (onUpdate) => {
771
+ this.checkShutdown('subscribeSyncStatus');
772
+ const fiber = this.syncStatusStream().pipe(Stream.tap((status) => Effect.sync(() => onUpdate(status))), Stream.runDrain, this.runEffectFork);
773
+ return () => {
774
+ Fiber.interrupt(fiber).pipe(Runtime.runFork(this[StoreInternalsSymbol].effectContext.runtime));
775
+ };
776
+ };
777
+ makeSyncStatus = (syncState) => {
778
+ const pendingCount = syncState.pending.length;
779
+ return {
780
+ localHead: EventSequenceNumber.Client.toString(syncState.localHead),
781
+ upstreamHead: EventSequenceNumber.Client.toString(syncState.upstreamHead),
782
+ pendingCount,
783
+ isSynced: pendingCount === 0,
784
+ };
499
785
  };
500
786
  /**
501
787
  * This can be used in combination with `skipRefresh` when committing events.
@@ -504,9 +790,9 @@ export class Store extends Inspectable.Class {
504
790
  manualRefresh = (options) => {
505
791
  this.checkShutdown('manualRefresh');
506
792
  const { label } = options ?? {};
507
- this.otel.tracer.startActiveSpan('LiveStore:manualRefresh', { attributes: { 'livestore.manualRefreshLabel': label } }, this.otel.commitsSpanContext, (span) => {
793
+ this[StoreInternalsSymbol].otel.tracer.startActiveSpan('LiveStore:manualRefresh', { attributes: { 'livestore.manualRefreshLabel': label } }, this[StoreInternalsSymbol].otel.commitsSpanContext, (span) => {
508
794
  const otelContext = otel.trace.setSpan(otel.context.active(), span);
509
- this.reactivityGraph.runDeferredEffects({ otelContext });
795
+ this[StoreInternalsSymbol].reactivityGraph.runDeferredEffects({ otelContext });
510
796
  span.end();
511
797
  });
512
798
  };
@@ -517,8 +803,8 @@ export class Store extends Inspectable.Class {
517
803
  */
518
804
  shutdownPromise = async (cause) => {
519
805
  this.checkShutdown('shutdownPromise');
520
- this.isShutdown = true;
521
- await this.shutdown(cause ? Cause.fail(cause) : undefined).pipe(this.runEffectFork, Fiber.join, Effect.runPromise);
806
+ this[StoreInternalsSymbol].isShutdown = true;
807
+ await this.shutdown(cause !== undefined ? Cause.fail(cause) : undefined).pipe(this.runEffectFork, Fiber.join, Effect.runPromise);
522
808
  };
523
809
  /**
524
810
  * Shuts down the store and closes the client session.
@@ -526,8 +812,8 @@ export class Store extends Inspectable.Class {
526
812
  * This is called automatically when the store was created using the React or Effect API.
527
813
  */
528
814
  shutdown = (cause) => {
529
- this.isShutdown = true;
530
- return this.clientSession.shutdown(cause ? Exit.failCause(cause) : Exit.succeed(IntentionalShutdownCause.make({ reason: 'manual' })));
815
+ this[StoreInternalsSymbol].isShutdown = true;
816
+ return this[StoreInternalsSymbol].clientSession.shutdown(cause !== undefined ? Exit.failCause(cause) : Exit.succeed(IntentionalShutdownCause.make({ reason: 'manual' })));
531
817
  };
532
818
  /**
533
819
  * Helper methods useful during development
@@ -537,25 +823,27 @@ export class Store extends Inspectable.Class {
537
823
  _dev = {
538
824
  downloadDb: (source = 'local') => {
539
825
  Effect.gen(this, function* () {
540
- const data = source === 'local' ? this.sqliteDbWrapper.export() : yield* this.clientSession.leaderThread.export;
826
+ const data = source === 'local'
827
+ ? this[StoreInternalsSymbol].sqliteDbWrapper.export()
828
+ : yield* this[StoreInternalsSymbol].clientSession.leaderThread.export;
541
829
  downloadBlob(data, `livestore-${Date.now()}.db`);
542
830
  }).pipe(this.runEffectFork);
543
831
  },
544
832
  downloadEventlogDb: () => {
545
833
  Effect.gen(this, function* () {
546
- const data = yield* this.clientSession.leaderThread.getEventlogData;
834
+ const data = yield* this[StoreInternalsSymbol].clientSession.leaderThread.getEventlogData;
547
835
  downloadBlob(data, `livestore-eventlog-${Date.now()}.db`);
548
836
  }).pipe(this.runEffectFork);
549
837
  },
550
838
  hardReset: (mode = 'all-data') => {
551
839
  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 }));
840
+ const clientId = this[StoreInternalsSymbol].clientSession.clientId;
841
+ yield* this[StoreInternalsSymbol].clientSession.leaderThread.sendDevtoolsMessage(Devtools.Leader.ResetAllData.Request.make({ liveStoreVersion, mode, requestId: nanoid(), clientId }));
554
842
  }).pipe(this.runEffectFork);
555
843
  },
556
844
  overrideNetworkStatus: (status) => {
557
- const clientId = this.clientSession.clientId;
558
- this.clientSession.leaderThread
845
+ const clientId = this[StoreInternalsSymbol].clientSession.clientId;
846
+ this[StoreInternalsSymbol].clientSession.leaderThread
559
847
  .sendDevtoolsMessage(Devtools.Leader.SetSyncLatch.Request.make({
560
848
  clientId,
561
849
  closeLatch: status === 'offline',
@@ -564,31 +852,32 @@ export class Store extends Inspectable.Class {
564
852
  }))
565
853
  .pipe(this.runEffectFork);
566
854
  },
855
+ // NOTE: Explicit return type needed to avoid TS2742 (inferred type references internal path)
567
856
  syncStates: () => Effect.gen(this, function* () {
568
- const session = yield* this.syncProcessor.syncState;
569
- const leader = yield* this.clientSession.leaderThread.getSyncState;
857
+ const session = yield* this[StoreInternalsSymbol].syncProcessor.syncState;
858
+ const leader = yield* this[StoreInternalsSymbol].clientSession.leaderThread.syncState;
570
859
  return { session, leader };
571
860
  }).pipe(this.runEffectPromise),
572
861
  printSyncStates: () => {
573
862
  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());
863
+ const session = yield* this[StoreInternalsSymbol].syncProcessor.syncState;
864
+ yield* Effect.log(`Session sync state: ${objectToString(session.localHead)} (upstream: ${objectToString(session.upstreamHead)})`, session.toJSON());
865
+ const leader = yield* this[StoreInternalsSymbol].clientSession.leaderThread.syncState;
866
+ yield* Effect.log(`Leader sync state: ${objectToString(leader.localHead)} (upstream: ${objectToString(leader.upstreamHead)})`, leader.toJSON());
578
867
  }).pipe(this.runEffectFork);
579
868
  },
580
869
  version: liveStoreVersion,
581
870
  otel: {
582
- rootSpanContext: () => otel.trace.getSpan(this.otel.rootSpanContext)?.spanContext(),
871
+ rootSpanContext: () => otel.trace.getSpan(this[StoreInternalsSymbol].otel.rootSpanContext)?.spanContext(),
583
872
  },
584
873
  };
585
874
  // NOTE This is needed because when booting a Store via Effect it seems to call `toJSON` in the error path
586
875
  toJSON = () => ({
587
876
  _tag: 'livestore.Store',
588
- reactivityGraph: this.reactivityGraph.getSnapshot({ includeResults: true }),
877
+ reactivityGraph: this[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true }),
589
878
  });
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));
879
+ runEffectFork = (effect) => effect.pipe(Effect.forkIn(this[StoreInternalsSymbol].effectContext.lifetimeScope), Effect.tapCauseLogPretty, Runtime.runFork(this[StoreInternalsSymbol].effectContext.runtime));
880
+ runEffectPromise = (effect) => effect.pipe(Effect.tapCauseLogPretty, Runtime.runPromise(this[StoreInternalsSymbol].effectContext.runtime));
592
881
  getCommitArgs = (firstEventOrTxnFnOrOptions, restEvents) => {
593
882
  let events;
594
883
  let options;
@@ -612,7 +901,7 @@ export class Store extends Inspectable.Class {
612
901
  }
613
902
  // for (const event of events) {
614
903
  // if (event.args.id === SessionIdSymbol) {
615
- // event.args.id = this.clientSession.sessionId
904
+ // event.args.id = this.sessionId
616
905
  // }
617
906
  // }
618
907
  return { events, options };