@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.
- package/README.md +0 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/QueryCache.js +1 -1
- package/dist/QueryCache.js.map +1 -1
- package/dist/SqliteDbWrapper.d.ts +5 -5
- package/dist/SqliteDbWrapper.d.ts.map +1 -1
- package/dist/SqliteDbWrapper.js +8 -8
- package/dist/SqliteDbWrapper.js.map +1 -1
- package/dist/SqliteDbWrapper.test.js +4 -3
- package/dist/SqliteDbWrapper.test.js.map +1 -1
- package/dist/effect/LiveStore.d.ts +133 -5
- package/dist/effect/LiveStore.d.ts.map +1 -1
- package/dist/effect/LiveStore.js +187 -8
- package/dist/effect/LiveStore.js.map +1 -1
- package/dist/effect/LiveStore.test.d.ts +2 -0
- package/dist/effect/LiveStore.test.d.ts.map +1 -0
- package/dist/effect/LiveStore.test.js +42 -0
- package/dist/effect/LiveStore.test.js.map +1 -0
- package/dist/effect/mod.d.ts +1 -1
- package/dist/effect/mod.d.ts.map +1 -1
- package/dist/effect/mod.js +3 -1
- package/dist/effect/mod.js.map +1 -1
- package/dist/live-queries/base-class.d.ts +110 -7
- package/dist/live-queries/base-class.d.ts.map +1 -1
- package/dist/live-queries/base-class.js +2 -2
- package/dist/live-queries/base-class.js.map +1 -1
- package/dist/live-queries/client-document-get-query.d.ts +1 -1
- package/dist/live-queries/client-document-get-query.d.ts.map +1 -1
- package/dist/live-queries/client-document-get-query.js +4 -3
- package/dist/live-queries/client-document-get-query.js.map +1 -1
- package/dist/live-queries/computed.d.ts +56 -0
- package/dist/live-queries/computed.d.ts.map +1 -1
- package/dist/live-queries/computed.js +58 -2
- package/dist/live-queries/computed.js.map +1 -1
- package/dist/live-queries/db-query.d.ts.map +1 -1
- package/dist/live-queries/db-query.js +21 -19
- package/dist/live-queries/db-query.js.map +1 -1
- package/dist/live-queries/db-query.test.js +106 -23
- package/dist/live-queries/db-query.test.js.map +1 -1
- package/dist/live-queries/signal.d.ts +49 -0
- package/dist/live-queries/signal.d.ts.map +1 -1
- package/dist/live-queries/signal.js +49 -0
- package/dist/live-queries/signal.js.map +1 -1
- package/dist/live-queries/signal.test.js +2 -2
- package/dist/live-queries/signal.test.js.map +1 -1
- package/dist/mod.d.ts +3 -3
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +3 -2
- package/dist/mod.js.map +1 -1
- package/dist/reactive.d.ts +9 -9
- package/dist/reactive.d.ts.map +1 -1
- package/dist/reactive.js +9 -26
- package/dist/reactive.js.map +1 -1
- package/dist/reactive.test.js +2 -2
- package/dist/reactive.test.js.map +1 -1
- package/dist/store/StoreRegistry.d.ts +215 -0
- package/dist/store/StoreRegistry.d.ts.map +1 -0
- package/dist/store/StoreRegistry.js +267 -0
- package/dist/store/StoreRegistry.js.map +1 -0
- package/dist/store/StoreRegistry.test.d.ts +2 -0
- package/dist/store/StoreRegistry.test.d.ts.map +1 -0
- package/dist/store/StoreRegistry.test.js +381 -0
- package/dist/store/StoreRegistry.test.js.map +1 -0
- package/dist/store/create-store.d.ts +98 -18
- package/dist/store/create-store.d.ts.map +1 -1
- package/dist/store/create-store.js +49 -20
- package/dist/store/create-store.js.map +1 -1
- package/dist/store/devtools.d.ts +5 -16
- package/dist/store/devtools.d.ts.map +1 -1
- package/dist/store/devtools.js +59 -18
- package/dist/store/devtools.js.map +1 -1
- package/dist/store/store-eventstream.test.d.ts +2 -0
- package/dist/store/store-eventstream.test.d.ts.map +1 -0
- package/dist/store/store-eventstream.test.js +65 -0
- package/dist/store/store-eventstream.test.js.map +1 -0
- package/dist/store/store-types.d.ts +285 -27
- package/dist/store/store-types.d.ts.map +1 -1
- package/dist/store/store-types.js +77 -1
- package/dist/store/store-types.js.map +1 -1
- package/dist/store/store-types.test.d.ts +2 -0
- package/dist/store/store-types.test.d.ts.map +1 -0
- package/dist/store/store-types.test.js +39 -0
- package/dist/store/store-types.test.js.map +1 -0
- package/dist/store/store.d.ts +253 -66
- package/dist/store/store.d.ts.map +1 -1
- package/dist/store/store.js +442 -153
- package/dist/store/store.js.map +1 -1
- package/dist/utils/dev.d.ts.map +1 -1
- package/dist/utils/dev.js.map +1 -1
- package/dist/utils/stack-info.js +2 -2
- package/dist/utils/stack-info.js.map +1 -1
- package/dist/utils/tests/fixture.d.ts +20 -5
- package/dist/utils/tests/fixture.d.ts.map +1 -1
- package/dist/utils/tests/fixture.js +7 -0
- package/dist/utils/tests/fixture.js.map +1 -1
- package/dist/utils/tests/otel.d.ts.map +1 -1
- package/dist/utils/tests/otel.js +5 -5
- package/dist/utils/tests/otel.js.map +1 -1
- package/package.json +59 -17
- package/src/QueryCache.ts +1 -1
- package/src/SqliteDbWrapper.test.ts +5 -3
- package/src/SqliteDbWrapper.ts +12 -11
- package/src/ambient.d.ts +0 -7
- package/src/effect/LiveStore.test.ts +61 -0
- package/src/effect/LiveStore.ts +388 -13
- package/src/effect/mod.ts +13 -1
- package/src/live-queries/__snapshots__/db-query.test.ts.snap +604 -192
- package/src/live-queries/base-class.ts +126 -28
- package/src/live-queries/client-document-get-query.ts +6 -4
- package/src/live-queries/computed.ts +59 -2
- package/src/live-queries/db-query.test.ts +162 -24
- package/src/live-queries/db-query.ts +23 -20
- package/src/live-queries/signal.test.ts +3 -2
- package/src/live-queries/signal.ts +49 -0
- package/src/mod.ts +19 -2
- package/src/reactive.test.ts +3 -2
- package/src/reactive.ts +22 -23
- package/src/store/StoreRegistry.test.ts +540 -0
- package/src/store/StoreRegistry.ts +418 -0
- package/src/store/create-store.ts +158 -39
- package/src/store/devtools.ts +77 -33
- package/src/store/store-eventstream.test.ts +114 -0
- package/src/store/store-types.test.ts +52 -0
- package/src/store/store-types.ts +360 -40
- package/src/store/store.ts +571 -236
- package/src/utils/dev.ts +2 -3
- package/src/utils/stack-info.ts +2 -2
- package/src/utils/tests/fixture.ts +9 -1
- package/src/utils/tests/otel.ts +8 -7
package/dist/store/store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Devtools, getExecStatementsFromMaterializer, getResultSchema, hashMaterializerResults, IntentionalShutdownCause, isQueryBuilder, liveStoreVersion, MaterializeError, MaterializerHashMismatchError, makeClientSessionSyncProcessor, prepareBindValues, QueryBuilderAstSymbol, replaceSessionIdSymbol,
|
|
2
|
-
import {
|
|
3
|
-
import { assertNever, isDevEnv,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
/** Options provided to the Store constructor. */
|
|
78
|
+
params;
|
|
24
79
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
124
|
+
this.params = params;
|
|
125
|
+
this.networkStatus = clientSession.leaderThread.networkStatus;
|
|
126
|
+
this.storageMode = clientSession.leaderThread.initialState.storageMode;
|
|
47
127
|
const reactivityGraph = makeReactivityGraph();
|
|
48
|
-
const
|
|
49
|
-
this.syncProcessor = makeClientSessionSyncProcessor({
|
|
128
|
+
const syncProcessor = makeClientSessionSyncProcessor({
|
|
50
129
|
schema,
|
|
51
130
|
clientSession,
|
|
52
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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, {
|
|
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
|
|
177
|
+
throw UnknownError.make({
|
|
84
178
|
cause,
|
|
85
|
-
note: `Error executing materializer for event "${
|
|
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 =
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
252
|
+
tableRefs[tableName] =
|
|
162
253
|
existingTableRefs.get(tableName) ??
|
|
163
|
-
|
|
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
|
-
|
|
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(
|
|
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*
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
333
|
+
* for await (const result of store.subscribe(query$)) {
|
|
334
|
+
* console.log(result)
|
|
335
|
+
* }
|
|
207
336
|
* ```
|
|
208
337
|
*/
|
|
209
|
-
subscribe = (query,
|
|
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`, {
|
|
212
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
586
|
+
const encodedEvents = yield* this[StoreInternalsSymbol].syncProcessor.encodeEvents(events);
|
|
587
|
+
const { writeTables } = yield* Effect.try({
|
|
425
588
|
try: () => {
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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) =>
|
|
594
|
+
catch: (cause) => UnknownError.make({ cause }),
|
|
437
595
|
});
|
|
438
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* //
|
|
487
|
-
* for await (const event of store.events({
|
|
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 = (
|
|
493
|
-
this.
|
|
494
|
-
return
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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'
|
|
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.
|
|
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.
|
|
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.
|
|
904
|
+
// event.args.id = this.sessionId
|
|
616
905
|
// }
|
|
617
906
|
// }
|
|
618
907
|
return { events, options };
|