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