@livestore/livestore 0.0.0-snapshot-909cdd1ac2fd591945c2be2b0f53e14d87f3c9d4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +1 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/QueryCache.d.ts +20 -0
  4. package/dist/QueryCache.d.ts.map +1 -0
  5. package/dist/QueryCache.js +61 -0
  6. package/dist/QueryCache.js.map +1 -0
  7. package/dist/SynchronousDatabaseWrapper.d.ts +36 -0
  8. package/dist/SynchronousDatabaseWrapper.d.ts.map +1 -0
  9. package/dist/SynchronousDatabaseWrapper.js +176 -0
  10. package/dist/SynchronousDatabaseWrapper.js.map +1 -0
  11. package/dist/effect/LiveStore.d.ts +38 -0
  12. package/dist/effect/LiveStore.d.ts.map +1 -0
  13. package/dist/effect/LiveStore.js +38 -0
  14. package/dist/effect/LiveStore.js.map +1 -0
  15. package/dist/effect/index.d.ts +2 -0
  16. package/dist/effect/index.d.ts.map +1 -0
  17. package/dist/effect/index.js +2 -0
  18. package/dist/effect/index.js.map +1 -0
  19. package/dist/global-state.d.ts +14 -0
  20. package/dist/global-state.d.ts.map +1 -0
  21. package/dist/global-state.js +16 -0
  22. package/dist/global-state.js.map +1 -0
  23. package/dist/index.d.ts +19 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +15 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/reactive.d.ts +163 -0
  28. package/dist/reactive.d.ts.map +1 -0
  29. package/dist/reactive.js +382 -0
  30. package/dist/reactive.js.map +1 -0
  31. package/dist/reactive.test.d.ts +2 -0
  32. package/dist/reactive.test.d.ts.map +1 -0
  33. package/dist/reactive.test.js +345 -0
  34. package/dist/reactive.test.js.map +1 -0
  35. package/dist/reactiveQueries/base-class.d.ts +59 -0
  36. package/dist/reactiveQueries/base-class.d.ts.map +1 -0
  37. package/dist/reactiveQueries/base-class.js +29 -0
  38. package/dist/reactiveQueries/base-class.js.map +1 -0
  39. package/dist/reactiveQueries/graphql.d.ts +52 -0
  40. package/dist/reactiveQueries/graphql.d.ts.map +1 -0
  41. package/dist/reactiveQueries/graphql.js +136 -0
  42. package/dist/reactiveQueries/graphql.js.map +1 -0
  43. package/dist/reactiveQueries/js.d.ts +35 -0
  44. package/dist/reactiveQueries/js.d.ts.map +1 -0
  45. package/dist/reactiveQueries/js.js +57 -0
  46. package/dist/reactiveQueries/js.js.map +1 -0
  47. package/dist/reactiveQueries/sql.d.ts +49 -0
  48. package/dist/reactiveQueries/sql.d.ts.map +1 -0
  49. package/dist/reactiveQueries/sql.js +130 -0
  50. package/dist/reactiveQueries/sql.js.map +1 -0
  51. package/dist/reactiveQueries/sql.test.d.ts +2 -0
  52. package/dist/reactiveQueries/sql.test.d.ts.map +1 -0
  53. package/dist/reactiveQueries/sql.test.js +284 -0
  54. package/dist/reactiveQueries/sql.test.js.map +1 -0
  55. package/dist/row-query.d.ts +33 -0
  56. package/dist/row-query.d.ts.map +1 -0
  57. package/dist/row-query.js +84 -0
  58. package/dist/row-query.js.map +1 -0
  59. package/dist/store-context.d.ts +26 -0
  60. package/dist/store-context.d.ts.map +1 -0
  61. package/dist/store-context.js +6 -0
  62. package/dist/store-context.js.map +1 -0
  63. package/dist/store-devtools.d.ts +19 -0
  64. package/dist/store-devtools.d.ts.map +1 -0
  65. package/dist/store-devtools.js +141 -0
  66. package/dist/store-devtools.js.map +1 -0
  67. package/dist/store.d.ts +175 -0
  68. package/dist/store.d.ts.map +1 -0
  69. package/dist/store.js +507 -0
  70. package/dist/store.js.map +1 -0
  71. package/dist/utils/data-structures.d.ts +10 -0
  72. package/dist/utils/data-structures.d.ts.map +1 -0
  73. package/dist/utils/data-structures.js +32 -0
  74. package/dist/utils/data-structures.js.map +1 -0
  75. package/dist/utils/dev.d.ts +3 -0
  76. package/dist/utils/dev.d.ts.map +1 -0
  77. package/dist/utils/dev.js +17 -0
  78. package/dist/utils/dev.js.map +1 -0
  79. package/dist/utils/otel.d.ts +4 -0
  80. package/dist/utils/otel.d.ts.map +1 -0
  81. package/dist/utils/otel.js +6 -0
  82. package/dist/utils/otel.js.map +1 -0
  83. package/dist/utils/stack-info.d.ts +10 -0
  84. package/dist/utils/stack-info.d.ts.map +1 -0
  85. package/dist/utils/stack-info.js +41 -0
  86. package/dist/utils/stack-info.js.map +1 -0
  87. package/dist/utils/stack-info.test.d.ts +2 -0
  88. package/dist/utils/stack-info.test.d.ts.map +1 -0
  89. package/dist/utils/stack-info.test.js +75 -0
  90. package/dist/utils/stack-info.test.js.map +1 -0
  91. package/dist/utils/tests/fixture.d.ts +259 -0
  92. package/dist/utils/tests/fixture.d.ts.map +1 -0
  93. package/dist/utils/tests/fixture.js +33 -0
  94. package/dist/utils/tests/fixture.js.map +1 -0
  95. package/dist/utils/tests/mod.d.ts +3 -0
  96. package/dist/utils/tests/mod.d.ts.map +1 -0
  97. package/dist/utils/tests/mod.js +3 -0
  98. package/dist/utils/tests/mod.js.map +1 -0
  99. package/dist/utils/tests/otel.d.ts +10 -0
  100. package/dist/utils/tests/otel.d.ts.map +1 -0
  101. package/dist/utils/tests/otel.js +42 -0
  102. package/dist/utils/tests/otel.js.map +1 -0
  103. package/package.json +60 -0
  104. package/src/QueryCache.ts +81 -0
  105. package/src/SynchronousDatabaseWrapper.ts +256 -0
  106. package/src/ambient.d.ts +10 -0
  107. package/src/effect/LiveStore.ts +112 -0
  108. package/src/effect/index.ts +8 -0
  109. package/src/global-state.ts +20 -0
  110. package/src/index.ts +64 -0
  111. package/src/reactive.test.ts +426 -0
  112. package/src/reactive.ts +661 -0
  113. package/src/reactiveQueries/base-class.ts +115 -0
  114. package/src/reactiveQueries/graphql.ts +233 -0
  115. package/src/reactiveQueries/js.ts +108 -0
  116. package/src/reactiveQueries/sql.test.ts +308 -0
  117. package/src/reactiveQueries/sql.ts +226 -0
  118. package/src/row-query.ts +200 -0
  119. package/src/store-context.ts +23 -0
  120. package/src/store-devtools.ts +217 -0
  121. package/src/store.ts +920 -0
  122. package/src/utils/data-structures.ts +36 -0
  123. package/src/utils/dev.ts +24 -0
  124. package/src/utils/otel.ts +9 -0
  125. package/src/utils/stack-info.test.ts +79 -0
  126. package/src/utils/stack-info.ts +54 -0
  127. package/src/utils/tests/fixture.ts +77 -0
  128. package/src/utils/tests/mod.ts +2 -0
  129. package/src/utils/tests/otel.ts +61 -0
  130. package/tsconfig.json +18 -0
  131. package/vitest.config.js +9 -0
package/src/store.ts ADDED
@@ -0,0 +1,920 @@
1
+ import type {
2
+ Adapter,
3
+ BootDb,
4
+ BootStatus,
5
+ ClientSession,
6
+ EventId,
7
+ IntentionalShutdownCause,
8
+ ParamsObject,
9
+ PreparedBindValues,
10
+ StoreDevtoolsChannel,
11
+ } from '@livestore/common'
12
+ import { getExecArgsFromMutation, prepareBindValues, UnexpectedError } from '@livestore/common'
13
+ import type { LiveStoreSchema, MutationEvent } from '@livestore/common/schema'
14
+ import {
15
+ isPartialMutationEvent,
16
+ makeMutationEventSchemaMemo,
17
+ SCHEMA_META_TABLE,
18
+ SCHEMA_MUTATIONS_META_TABLE,
19
+ SESSION_CHANGESET_META_TABLE,
20
+ } from '@livestore/common/schema'
21
+ import { assertNever, makeNoopTracer, shouldNeverHappen } from '@livestore/utils'
22
+ import {
23
+ Cause,
24
+ Data,
25
+ Deferred,
26
+ Duration,
27
+ Effect,
28
+ Exit,
29
+ FiberSet,
30
+ Inspectable,
31
+ Layer,
32
+ Logger,
33
+ LogLevel,
34
+ MutableHashMap,
35
+ OtelTracer,
36
+ Queue,
37
+ Runtime,
38
+ Schema,
39
+ Scope,
40
+ Stream,
41
+ } from '@livestore/utils/effect'
42
+ import * as otel from '@opentelemetry/api'
43
+ import type { GraphQLSchema } from 'graphql'
44
+
45
+ import { globalReactivityGraph } from './global-state.js'
46
+ import type { DebugRefreshReasonBase, Ref } from './reactive.js'
47
+ import type { LiveQuery, QueryContext, ReactivityGraph } from './reactiveQueries/base-class.js'
48
+ import { connectDevtoolsToStore } from './store-devtools.js'
49
+ import { SynchronousDatabaseWrapper } from './SynchronousDatabaseWrapper.js'
50
+ import { ReferenceCountedSet } from './utils/data-structures.js'
51
+ import { downloadBlob } from './utils/dev.js'
52
+ import { getDurationMsFromSpan } from './utils/otel.js'
53
+ import type { StackInfo } from './utils/stack-info.js'
54
+
55
+ export type BaseGraphQLContext = {
56
+ queriedTables: Set<string>
57
+ /** Needed by Pothos Otel plugin for resolver tracing to work */
58
+ otelContext?: otel.Context
59
+ }
60
+
61
+ export type GraphQLOptions<TContext> = {
62
+ schema: GraphQLSchema
63
+ makeContext: (db: SynchronousDatabaseWrapper, tracer: otel.Tracer, sessionId: string) => TContext
64
+ }
65
+
66
+ export type OtelOptions = {
67
+ tracer: otel.Tracer
68
+ rootSpanContext: otel.Context
69
+ }
70
+
71
+ export type StoreOptions<
72
+ TGraphQLContext extends BaseGraphQLContext,
73
+ TSchema extends LiveStoreSchema = LiveStoreSchema,
74
+ > = {
75
+ clientSession: ClientSession
76
+ schema: TSchema
77
+ storeId: string
78
+ // TODO remove graphql-related stuff from store and move to GraphQL query directly
79
+ graphQLOptions?: GraphQLOptions<TGraphQLContext>
80
+ otelOptions: OtelOptions
81
+ reactivityGraph: ReactivityGraph
82
+ disableDevtools?: boolean
83
+ fiberSet: FiberSet.FiberSet
84
+ runtime: Runtime.Runtime<Scope.Scope>
85
+ batchUpdates: (runUpdates: () => void) => void
86
+ currentMutationEventIdRef: { current: EventId }
87
+ unsyncedMutationEvents: MutableHashMap.MutableHashMap<EventId, MutationEvent.ForSchema<TSchema>>
88
+ }
89
+
90
+ export type RefreshReason =
91
+ | DebugRefreshReasonBase
92
+ | {
93
+ _tag: 'mutate'
94
+ /** The mutations that were applied */
95
+ mutations: ReadonlyArray<MutationEvent.Any>
96
+
97
+ /** The tables that were written to by the event */
98
+ writeTables: ReadonlyArray<string>
99
+ }
100
+ | {
101
+ // TODO rename to a more appropriate name which is framework-agnostic
102
+ _tag: 'react'
103
+ api: string
104
+ label?: string
105
+ stackInfo?: StackInfo
106
+ }
107
+ | { _tag: 'manual'; label?: string }
108
+
109
+ export type QueryDebugInfo = {
110
+ _tag: 'graphql' | 'sql' | 'js' | 'unknown'
111
+ label: string
112
+ query: string
113
+ durationMs: number
114
+ }
115
+
116
+ export type StoreOtel = {
117
+ tracer: otel.Tracer
118
+ mutationsSpanContext: otel.Context
119
+ queriesSpanContext: otel.Context
120
+ }
121
+
122
+ export type StoreMutateOptions = {
123
+ label?: string
124
+ skipRefresh?: boolean
125
+ wasSyncMessage?: boolean
126
+ /**
127
+ * When set to `false` the mutation won't be persisted in the mutation log and sync server (but still synced).
128
+ * This can be useful e.g. for fine-granular update events (e.g. position updates during drag & drop)
129
+ *
130
+ * @default true
131
+ */
132
+ persisted?: boolean
133
+ }
134
+
135
+ // eslint-disable-next-line unicorn/prefer-global-this
136
+ if (import.meta.env.DEV && typeof window !== 'undefined') {
137
+ // eslint-disable-next-line unicorn/prefer-global-this
138
+ window.__debugDownloadBlob = downloadBlob
139
+ }
140
+
141
+ export class Store<
142
+ TGraphQLContext extends BaseGraphQLContext = BaseGraphQLContext,
143
+ TSchema extends LiveStoreSchema = LiveStoreSchema,
144
+ > extends Inspectable.Class {
145
+ readonly storeId: string
146
+ reactivityGraph: ReactivityGraph
147
+ syncDbWrapper: SynchronousDatabaseWrapper
148
+ clientSession: ClientSession
149
+ schema: LiveStoreSchema
150
+ graphQLSchema?: GraphQLSchema
151
+ graphQLContext?: TGraphQLContext
152
+ otel: StoreOtel
153
+ /**
154
+ * Note we're using `Ref<null>` here as we don't care about the value but only about *that* something has changed.
155
+ * This only works in combination with `equal: () => false` which will always trigger a refresh.
156
+ */
157
+ tableRefs: { [key: string]: Ref<null, QueryContext, RefreshReason> }
158
+
159
+ private fiberSet: FiberSet.FiberSet
160
+ private runtime: Runtime.Runtime<Scope.Scope>
161
+
162
+ /** RC-based set to see which queries are currently subscribed to */
163
+ activeQueries: ReferenceCountedSet<LiveQuery<any>>
164
+
165
+ // NOTE this is currently exposed for the Devtools databrowser to emit mutation events
166
+ readonly __mutationEventSchema
167
+
168
+ private currentMutationEventIdRef
169
+ private unsyncedMutationEvents
170
+
171
+ // #region constructor
172
+ private constructor({
173
+ clientSession,
174
+ schema,
175
+ graphQLOptions,
176
+ reactivityGraph,
177
+ otelOptions,
178
+ disableDevtools,
179
+ batchUpdates,
180
+ currentMutationEventIdRef,
181
+ unsyncedMutationEvents,
182
+ storeId,
183
+ fiberSet,
184
+ runtime,
185
+ }: StoreOptions<TGraphQLContext, TSchema>) {
186
+ super()
187
+
188
+ this.storeId = storeId
189
+
190
+ this.currentMutationEventIdRef = currentMutationEventIdRef
191
+ this.unsyncedMutationEvents = unsyncedMutationEvents
192
+
193
+ this.syncDbWrapper = new SynchronousDatabaseWrapper({ otel: otelOptions, db: clientSession.syncDb })
194
+ this.clientSession = clientSession
195
+ this.schema = schema
196
+
197
+ this.fiberSet = fiberSet
198
+ this.runtime = runtime
199
+
200
+ // TODO refactor
201
+ this.__mutationEventSchema = makeMutationEventSchemaMemo(schema)
202
+
203
+ // TODO generalize the `tableRefs` concept to allow finer-grained refs
204
+ this.tableRefs = {}
205
+ this.activeQueries = new ReferenceCountedSet()
206
+
207
+ const mutationsSpan = otelOptions.tracer.startSpan('LiveStore:mutations', {}, otelOptions.rootSpanContext)
208
+ const otelMuationsSpanContext = otel.trace.setSpan(otel.context.active(), mutationsSpan)
209
+
210
+ const queriesSpan = otelOptions.tracer.startSpan('LiveStore:queries', {}, otelOptions.rootSpanContext)
211
+ const otelQueriesSpanContext = otel.trace.setSpan(otel.context.active(), queriesSpan)
212
+
213
+ this.reactivityGraph = reactivityGraph
214
+ this.reactivityGraph.context = {
215
+ store: this as unknown as Store<BaseGraphQLContext, LiveStoreSchema>,
216
+ otelTracer: otelOptions.tracer,
217
+ rootOtelContext: otelQueriesSpanContext,
218
+ effectsWrapper: batchUpdates,
219
+ }
220
+
221
+ this.otel = {
222
+ tracer: otelOptions.tracer,
223
+ mutationsSpanContext: otelMuationsSpanContext,
224
+ queriesSpanContext: otelQueriesSpanContext,
225
+ }
226
+
227
+ // TODO find a better way to detect if we're running LiveStore in the LiveStore devtools
228
+ // But for now this is a good enough approximation with little downsides
229
+ const isRunningInDevtools = disableDevtools === true
230
+
231
+ // Need a set here since `schema.tables` might contain duplicates and some componentStateTables
232
+ const allTableNames = new Set(
233
+ // NOTE we're excluding the LiveStore schema and mutations tables as they are not user-facing
234
+ // unless LiveStore is running in the devtools
235
+ isRunningInDevtools
236
+ ? this.schema.tables.keys()
237
+ : Array.from(this.schema.tables.keys()).filter(
238
+ (_) => _ !== SCHEMA_META_TABLE && _ !== SCHEMA_MUTATIONS_META_TABLE && _ !== SESSION_CHANGESET_META_TABLE,
239
+ ),
240
+ )
241
+ const existingTableRefs = new Map(
242
+ Array.from(this.reactivityGraph.atoms.values())
243
+ .filter((_): _ is Ref<any, any, any> => _._tag === 'ref' && _.label?.startsWith('tableRef:') === true)
244
+ .map((_) => [_.label!.slice('tableRef:'.length), _] as const),
245
+ )
246
+ for (const tableName of allTableNames) {
247
+ this.tableRefs[tableName] = existingTableRefs.get(tableName) ?? this.makeTableRef(tableName)
248
+ }
249
+
250
+ if (graphQLOptions) {
251
+ this.graphQLSchema = graphQLOptions.schema
252
+ this.graphQLContext = graphQLOptions.makeContext(
253
+ this.syncDbWrapper,
254
+ this.otel.tracer,
255
+ clientSession.coordinator.sessionId,
256
+ )
257
+ }
258
+
259
+ Effect.gen(this, function* () {
260
+ yield* this.clientSession.coordinator.syncMutations.pipe(
261
+ Stream.tapChunk((mutationsEventsDecodedChunk) =>
262
+ Effect.sync(() => {
263
+ this.mutate({ wasSyncMessage: true }, ...mutationsEventsDecodedChunk)
264
+ }),
265
+ ),
266
+ Stream.runDrain,
267
+ Effect.interruptible,
268
+ Effect.withSpan('LiveStore:syncMutations'),
269
+ Effect.forkScoped,
270
+ )
271
+
272
+ yield* Effect.addFinalizer(() =>
273
+ Effect.sync(() => {
274
+ for (const tableRef of Object.values(this.tableRefs)) {
275
+ for (const superComp of tableRef.super) {
276
+ this.reactivityGraph.removeEdge(superComp, tableRef)
277
+ }
278
+ }
279
+
280
+ otel.trace.getSpan(this.otel.mutationsSpanContext)!.end()
281
+ otel.trace.getSpan(this.otel.queriesSpanContext)!.end()
282
+ }),
283
+ )
284
+
285
+ yield* Effect.never
286
+ }).pipe(Effect.scoped, Effect.withSpan('LiveStore:constructor'), this.runEffectFork)
287
+ }
288
+ // #endregion constructor
289
+
290
+ static createStore = <TGraphQLContext extends BaseGraphQLContext, TSchema extends LiveStoreSchema = LiveStoreSchema>(
291
+ storeOptions: StoreOptions<TGraphQLContext, TSchema>,
292
+ parentSpan: otel.Span,
293
+ ): Store<TGraphQLContext, TSchema> => {
294
+ const ctx = otel.trace.setSpan(otel.context.active(), parentSpan)
295
+ return storeOptions.otelOptions.tracer.startActiveSpan('LiveStore:createStore', {}, ctx, (span) => {
296
+ try {
297
+ return new Store(storeOptions)
298
+ } finally {
299
+ span.end()
300
+ }
301
+ })
302
+ }
303
+
304
+ get sessionId(): string {
305
+ return this.clientSession.coordinator.sessionId
306
+ }
307
+
308
+ /**
309
+ * Subscribe to the results of a query
310
+ * Returns a function to cancel the subscription.
311
+ */
312
+ subscribe = <TResult>(
313
+ query$: LiveQuery<TResult, any>,
314
+ onNewValue: (value: TResult) => void,
315
+ onUnsubsubscribe?: () => void,
316
+ options?: { label?: string; otelContext?: otel.Context; skipInitialRun?: boolean } | undefined,
317
+ ): (() => void) =>
318
+ this.otel.tracer.startActiveSpan(
319
+ `LiveStore.subscribe`,
320
+ { attributes: { label: options?.label, queryLabel: query$.label } },
321
+ options?.otelContext ?? this.otel.queriesSpanContext,
322
+ (span) => {
323
+ // console.debug('store sub', query$.id, query$.label)
324
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
325
+
326
+ const label = `subscribe:${options?.label}`
327
+ const effect = this.reactivityGraph.makeEffect((get) => onNewValue(get(query$.results$)), { label })
328
+
329
+ this.activeQueries.add(query$ as LiveQuery<TResult>)
330
+
331
+ // Running effect right away to get initial value (unless `skipInitialRun` is set)
332
+ if (options?.skipInitialRun !== true) {
333
+ effect.doEffect(otelContext)
334
+ }
335
+
336
+ const unsubscribe = () => {
337
+ // console.debug('store unsub', query$.id, query$.label)
338
+ try {
339
+ this.reactivityGraph.destroyNode(effect)
340
+ this.activeQueries.remove(query$ as LiveQuery<TResult>)
341
+ onUnsubsubscribe?.()
342
+ } finally {
343
+ span.end()
344
+ }
345
+ }
346
+
347
+ return unsubscribe
348
+ },
349
+ )
350
+
351
+ // #region mutate
352
+ mutate: {
353
+ <const TMutationArg extends ReadonlyArray<MutationEvent.PartialForSchema<TSchema>>>(...list: TMutationArg): void
354
+ (
355
+ txn: <const TMutationArg extends ReadonlyArray<MutationEvent.PartialForSchema<TSchema>>>(
356
+ ...list: TMutationArg
357
+ ) => void,
358
+ ): void
359
+ <const TMutationArg extends ReadonlyArray<MutationEvent.PartialForSchema<TSchema>>>(
360
+ options: StoreMutateOptions,
361
+ ...list: TMutationArg
362
+ ): void
363
+ (
364
+ options: StoreMutateOptions,
365
+ txn: <const TMutationArg extends ReadonlyArray<MutationEvent.PartialForSchema<TSchema>>>(
366
+ ...list: TMutationArg
367
+ ) => void,
368
+ ): void
369
+ } = (firstMutationOrTxnFnOrOptions: any, ...restMutations: any[]) => {
370
+ let mutationsEvents: MutationEvent.ForSchema<TSchema>[]
371
+ let options: StoreMutateOptions | undefined
372
+
373
+ if (typeof firstMutationOrTxnFnOrOptions === 'function') {
374
+ // TODO ensure that function is synchronous and isn't called in a async way (also write tests for this)
375
+ mutationsEvents = firstMutationOrTxnFnOrOptions((arg: any) => mutationsEvents.push(arg))
376
+ } else if (
377
+ firstMutationOrTxnFnOrOptions?.label !== undefined ||
378
+ firstMutationOrTxnFnOrOptions?.skipRefresh !== undefined ||
379
+ firstMutationOrTxnFnOrOptions?.wasSyncMessage !== undefined ||
380
+ firstMutationOrTxnFnOrOptions?.persisted !== undefined
381
+ ) {
382
+ options = firstMutationOrTxnFnOrOptions
383
+ mutationsEvents = restMutations
384
+ } else if (firstMutationOrTxnFnOrOptions === undefined) {
385
+ // When `mutate` is called with no arguments (which sometimes happens when dynamically filtering mutations)
386
+ mutationsEvents = []
387
+ } else {
388
+ mutationsEvents = [firstMutationOrTxnFnOrOptions, ...restMutations]
389
+ }
390
+
391
+ mutationsEvents = mutationsEvents.filter(
392
+ (_) => _.id === undefined || !MutableHashMap.has(this.unsyncedMutationEvents, Data.struct(_.id)),
393
+ )
394
+
395
+ if (mutationsEvents.length === 0) {
396
+ return
397
+ }
398
+
399
+ const label = options?.label ?? 'mutate'
400
+ const skipRefresh = options?.skipRefresh ?? false
401
+ const wasSyncMessage = options?.wasSyncMessage ?? false
402
+ const persisted = options?.persisted ?? true
403
+
404
+ const mutationsSpan = otel.trace.getSpan(this.otel.mutationsSpanContext)!
405
+ mutationsSpan.addEvent('mutate')
406
+
407
+ // console.group('LiveStore.mutate', { skipRefresh, wasSyncMessage, label })
408
+ // mutationsEvents.forEach((_) => console.debug(_.mutation, _.id, _.args))
409
+ // console.groupEnd()
410
+
411
+ let durationMs: number
412
+
413
+ const res = this.otel.tracer.startActiveSpan(
414
+ 'LiveStore:mutate',
415
+ { attributes: { 'livestore.mutateLabel': label } },
416
+ this.otel.mutationsSpanContext,
417
+ (span) => {
418
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
419
+
420
+ try {
421
+ const writeTables: Set<string> = new Set()
422
+
423
+ this.otel.tracer.startActiveSpan(
424
+ 'LiveStore:processWrites',
425
+ { attributes: { 'livestore.mutateLabel': label } },
426
+ otel.trace.setSpan(otel.context.active(), span),
427
+ (span) => {
428
+ try {
429
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
430
+
431
+ const applyMutations = () => {
432
+ for (const mutationEvent of mutationsEvents) {
433
+ try {
434
+ const { writeTables: writeTablesForEvent } = this.mutateWithoutRefresh(mutationEvent, {
435
+ otelContext,
436
+ // NOTE if it was a sync message, it's already coming from the coordinator, so we can skip the coordinator
437
+ coordinatorMode: wasSyncMessage ? 'skip-coordinator' : persisted ? 'default' : 'skip-persist',
438
+ })
439
+ for (const tableName of writeTablesForEvent) {
440
+ writeTables.add(tableName)
441
+ }
442
+ } catch (e: any) {
443
+ console.error(e, mutationEvent)
444
+ throw e
445
+ }
446
+ }
447
+ }
448
+
449
+ if (mutationsEvents.length > 1) {
450
+ // TODO: what to do about coordinator transaction here?
451
+ this.syncDbWrapper.txn(applyMutations)
452
+ } else {
453
+ applyMutations()
454
+ }
455
+ } catch (e: any) {
456
+ console.error(e)
457
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: e.toString() })
458
+ throw e
459
+ } finally {
460
+ span.end()
461
+ }
462
+ },
463
+ )
464
+
465
+ const tablesToUpdate = [] as [Ref<null, QueryContext, RefreshReason>, null][]
466
+ for (const tableName of writeTables) {
467
+ const tableRef = this.tableRefs[tableName]
468
+ assertNever(tableRef !== undefined, `No table ref found for ${tableName}`)
469
+ tablesToUpdate.push([tableRef!, null])
470
+ }
471
+
472
+ const debugRefreshReason = {
473
+ _tag: 'mutate' as const,
474
+ mutations: mutationsEvents,
475
+ writeTables: Array.from(writeTables),
476
+ }
477
+
478
+ // Update all table refs together in a batch, to only trigger one reactive update
479
+ this.reactivityGraph.setRefs(tablesToUpdate, { debugRefreshReason, otelContext, skipRefresh })
480
+ } catch (e: any) {
481
+ console.error(e)
482
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: e.toString() })
483
+ throw e
484
+ } finally {
485
+ span.end()
486
+
487
+ durationMs = getDurationMsFromSpan(span)
488
+ }
489
+
490
+ return { durationMs }
491
+ },
492
+ )
493
+
494
+ // NOTE we need to add the mutation events to the unsynced mutation events map only after running the code above
495
+ // so the short-circuiting in `mutateWithoutRefresh` doesn't kick in for those events
496
+ for (const mutationEvent of mutationsEvents) {
497
+ if (mutationEvent.id !== undefined) {
498
+ MutableHashMap.set(this.unsyncedMutationEvents, Data.struct(mutationEvent.id), mutationEvent)
499
+ }
500
+ }
501
+
502
+ return res
503
+ }
504
+
505
+ /**
506
+ * This can be used in combination with `skipRefresh` when applying mutations.
507
+ * We might need a better solution for this. Let's see.
508
+ */
509
+ manualRefresh = (options?: { label?: string }) => {
510
+ const { label } = options ?? {}
511
+ this.otel.tracer.startActiveSpan(
512
+ 'LiveStore:manualRefresh',
513
+ { attributes: { 'livestore.manualRefreshLabel': label } },
514
+ this.otel.mutationsSpanContext,
515
+ (span) => {
516
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
517
+ this.reactivityGraph.runDeferredEffects({ otelContext })
518
+ span.end()
519
+ },
520
+ )
521
+ }
522
+
523
+ /**
524
+ * Apply a mutation to the store.
525
+ * Returns the tables that were affected by the event.
526
+ * This is an internal method that doesn't trigger a refresh;
527
+ * the caller must refresh queries after calling this method.
528
+ */
529
+ mutateWithoutRefresh = (
530
+ mutationEventDecoded_: MutationEvent.ForSchema<TSchema> | MutationEvent.PartialForSchema<TSchema>,
531
+ options: {
532
+ otelContext: otel.Context
533
+ // TODO adjust `skip-persist` with new rebase sync strategy
534
+ coordinatorMode: 'default' | 'skip-coordinator' | 'skip-persist'
535
+ },
536
+ ): { writeTables: ReadonlySet<string>; durationMs: number } => {
537
+ const mutationDef =
538
+ this.schema.mutations.get(mutationEventDecoded_.mutation) ??
539
+ shouldNeverHappen(`Unknown mutation type: ${mutationEventDecoded_.mutation}`)
540
+
541
+ // Needs to happen only for partial mutation events (thus a function)
542
+ const nextMutationEventId = () => {
543
+ const { id, parentId } = this.clientSession.coordinator
544
+ .nextMutationEventIdPair({ localOnly: mutationDef.options.localOnly })
545
+ .pipe(Effect.runSync)
546
+
547
+ this.currentMutationEventIdRef.current = id
548
+
549
+ return { id, parentId }
550
+ }
551
+
552
+ const mutationEventDecoded: MutationEvent.ForSchema<TSchema> = isPartialMutationEvent(mutationEventDecoded_)
553
+ ? { ...mutationEventDecoded_, ...nextMutationEventId() }
554
+ : mutationEventDecoded_
555
+
556
+ // NOTE we also need this temporary workaround here since some code-paths use `mutateWithoutRefresh` directly
557
+ // e.g. the row-query functionality
558
+ if (MutableHashMap.has(this.unsyncedMutationEvents, Data.struct(mutationEventDecoded.id))) {
559
+ // NOTE this data should never be used
560
+ return { writeTables: new Set(), durationMs: 0 }
561
+ } else {
562
+ MutableHashMap.set(this.unsyncedMutationEvents, Data.struct(mutationEventDecoded.id), mutationEventDecoded)
563
+ }
564
+
565
+ const { otelContext, coordinatorMode = 'default' } = options
566
+
567
+ return this.otel.tracer.startActiveSpan(
568
+ 'LiveStore:mutateWithoutRefresh',
569
+ {
570
+ attributes: {
571
+ 'livestore.mutation': mutationEventDecoded.mutation,
572
+ 'livestore.args': JSON.stringify(mutationEventDecoded.args, null, 2),
573
+ },
574
+ },
575
+ otelContext,
576
+ (span) => {
577
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
578
+
579
+ const allWriteTables = new Set<string>()
580
+ let durationMsTotal = 0
581
+
582
+ const execArgsArr = getExecArgsFromMutation({ mutationDef, mutationEventDecoded })
583
+
584
+ for (const {
585
+ statementSql,
586
+ bindValues,
587
+ writeTables = this.syncDbWrapper.getTablesUsed(statementSql),
588
+ } of execArgsArr) {
589
+ // TODO when the store doesn't have the lock, we need wait for the coordinator to confirm the mutation
590
+ // before executing the mutation on the main db
591
+ const { durationMs } = this.syncDbWrapper.execute(statementSql, bindValues, writeTables, { otelContext })
592
+
593
+ durationMsTotal += durationMs
594
+ writeTables.forEach((table) => allWriteTables.add(table))
595
+ }
596
+
597
+ const mutationEventEncoded = Schema.encodeUnknownSync(this.__mutationEventSchema)(mutationEventDecoded)
598
+
599
+ if (coordinatorMode !== 'skip-coordinator') {
600
+ // Asynchronously apply mutation to a persistent storage (we're not awaiting this promise here)
601
+ this.clientSession.coordinator
602
+ .mutate(mutationEventEncoded as MutationEvent.AnyEncoded, { persisted: coordinatorMode !== 'skip-persist' })
603
+ .pipe(this.runEffectFork)
604
+ }
605
+
606
+ // Uncomment to print a list of queries currently registered on the store
607
+ // console.debug(JSON.parse(JSON.stringify([...this.queries].map((q) => `${labelForKey(q.componentKey)}/${q.label}`))))
608
+
609
+ span.end()
610
+
611
+ return { writeTables: allWriteTables, durationMs: durationMsTotal }
612
+ },
613
+ )
614
+ }
615
+ // #endregion mutate
616
+
617
+ /**
618
+ * Directly execute a SQL query on the Store.
619
+ * This should only be used for framework-internal purposes;
620
+ * all app writes should go through mutate.
621
+ */
622
+ __execute = (
623
+ query: string,
624
+ params: ParamsObject = {},
625
+ writeTables?: ReadonlySet<string>,
626
+ otelContext?: otel.Context,
627
+ ) => {
628
+ this.syncDbWrapper.execute(query, prepareBindValues(params, query), writeTables, { otelContext })
629
+
630
+ this.clientSession.coordinator.execute(query, prepareBindValues(params, query)).pipe(this.runEffectFork)
631
+ }
632
+
633
+ __select = (query: string, params: ParamsObject = {}) => {
634
+ return this.syncDbWrapper.select(query, { bindValues: prepareBindValues(params, query) })
635
+ }
636
+
637
+ private makeTableRef = (tableName: string) =>
638
+ this.reactivityGraph.makeRef(null, {
639
+ equal: () => false,
640
+ label: `tableRef:${tableName}`,
641
+ meta: { liveStoreRefType: 'table' },
642
+ })
643
+
644
+ __devDownloadDb = () => {
645
+ const data = this.syncDbWrapper.export()
646
+ downloadBlob(data, `livestore-${Date.now()}.db`)
647
+ }
648
+
649
+ __devDownloadMutationLogDb = () =>
650
+ Effect.gen(this, function* () {
651
+ const data = yield* this.clientSession.coordinator.getMutationLogData
652
+ downloadBlob(data, `livestore-mutationlog-${Date.now()}.db`)
653
+ }).pipe(this.runEffectFork)
654
+
655
+ // NOTE This is needed because when booting a Store via Effect it seems to call `toJSON` in the error path
656
+ toJSON = () => {
657
+ return {
658
+ _tag: 'Store',
659
+ reactivityGraph: this.reactivityGraph.getSnapshot({ includeResults: true }),
660
+ }
661
+ }
662
+
663
+ private runEffectFork = <A, E>(effect: Effect.Effect<A, E, never>) =>
664
+ effect.pipe(Effect.tapCauseLogPretty, FiberSet.run(this.fiberSet), Runtime.runFork(this.runtime))
665
+ }
666
+
667
+ export type CreateStoreOptions<TGraphQLContext extends BaseGraphQLContext, TSchema extends LiveStoreSchema> = {
668
+ schema: TSchema
669
+ adapter: Adapter
670
+ storeId: string
671
+ reactivityGraph?: ReactivityGraph
672
+ graphQLOptions?: GraphQLOptions<TGraphQLContext>
673
+ otelOptions?: Partial<OtelOptions>
674
+ boot?: (db: BootDb, parentSpan: otel.Span) => void | Promise<void> | Effect.Effect<void, unknown, otel.Tracer>
675
+ batchUpdates?: (run: () => void) => void
676
+ disableDevtools?: boolean
677
+ onBootStatus?: (status: BootStatus) => void
678
+ }
679
+
680
+ /** Create a new LiveStore Store */
681
+ export const createStorePromise = async <
682
+ TGraphQLContext extends BaseGraphQLContext,
683
+ TSchema extends LiveStoreSchema = LiveStoreSchema,
684
+ >({
685
+ signal,
686
+ ...options
687
+ }: CreateStoreOptions<TGraphQLContext, TSchema> & { signal?: AbortSignal }): Promise<Store<TGraphQLContext, TSchema>> =>
688
+ Effect.gen(function* () {
689
+ const scope = yield* Scope.make()
690
+ const runtime = yield* Effect.runtime()
691
+
692
+ if (signal !== undefined) {
693
+ signal.addEventListener('abort', () => {
694
+ Scope.close(scope, Exit.void).pipe(Effect.tapCauseLogPretty, Runtime.runFork(runtime))
695
+ })
696
+ }
697
+
698
+ return yield* FiberSet.make().pipe(
699
+ Effect.andThen((fiberSet) => createStore({ ...options, fiberSet })),
700
+ Scope.extend(scope),
701
+ )
702
+ }).pipe(
703
+ Effect.withSpan('createStore'),
704
+ Effect.tapCauseLogPretty,
705
+ Effect.annotateLogs({ thread: 'window' }),
706
+ Effect.provide(Logger.pretty),
707
+ Logger.withMinimumLogLevel(LogLevel.Debug),
708
+ Effect.runPromise,
709
+ )
710
+
711
+ // #region createStore
712
+ export const createStore = <
713
+ TGraphQLContext extends BaseGraphQLContext,
714
+ TSchema extends LiveStoreSchema = LiveStoreSchema,
715
+ >({
716
+ schema,
717
+ adapter,
718
+ storeId,
719
+ graphQLOptions,
720
+ otelOptions,
721
+ boot,
722
+ reactivityGraph = globalReactivityGraph,
723
+ batchUpdates,
724
+ disableDevtools,
725
+ onBootStatus,
726
+ fiberSet,
727
+ }: CreateStoreOptions<TGraphQLContext, TSchema> & { fiberSet: FiberSet.FiberSet }): Effect.Effect<
728
+ Store<TGraphQLContext, TSchema>,
729
+ UnexpectedError,
730
+ Scope.Scope
731
+ > => {
732
+ const otelTracer = otelOptions?.tracer ?? makeNoopTracer()
733
+ const otelRootSpanContext = otelOptions?.rootSpanContext ?? otel.context.active()
734
+
735
+ const TracingLive = Layer.unwrapEffect(Effect.map(OtelTracer.make, Layer.setTracer)).pipe(
736
+ Layer.provide(Layer.sync(OtelTracer.Tracer, () => otelTracer)),
737
+ )
738
+
739
+ return Effect.gen(function* () {
740
+ const span = yield* OtelTracer.currentOtelSpan.pipe(Effect.orDie)
741
+
742
+ const bootStatusQueue = yield* Queue.unbounded<BootStatus>().pipe(Effect.acquireRelease(Queue.shutdown))
743
+
744
+ yield* Queue.take(bootStatusQueue).pipe(
745
+ Effect.tapSync((status) => onBootStatus?.(status)),
746
+ Effect.tap((status) => (status.stage === 'done' ? Queue.shutdown(bootStatusQueue) : Effect.void)),
747
+ Effect.forever,
748
+ Effect.tapCauseLogPretty,
749
+ Effect.forkScoped,
750
+ )
751
+
752
+ const storeDeferred = yield* Deferred.make<Store>()
753
+
754
+ const connectDevtoolsToStore_ = (storeDevtoolsChannel: StoreDevtoolsChannel) =>
755
+ Effect.gen(function* () {
756
+ const store = yield* Deferred.await(storeDeferred)
757
+ yield* connectDevtoolsToStore({ storeDevtoolsChannel, store })
758
+ })
759
+
760
+ const runtime = yield* Effect.runtime<Scope.Scope>()
761
+
762
+ const runEffectFork = (effect: Effect.Effect<any, any, never>) =>
763
+ effect.pipe(Effect.tapCauseLogPretty, FiberSet.run(fiberSet), Runtime.runFork(runtime))
764
+
765
+ // TODO close parent scope? (Needs refactor with Mike A)
766
+ const shutdown = (cause: Cause.Cause<UnexpectedError | IntentionalShutdownCause>) =>
767
+ Effect.gen(function* () {
768
+ // NOTE we're calling `cause.toString()` here to avoid triggering a `console.error` in the grouped log
769
+ const logCause =
770
+ Cause.isFailType(cause) && cause.error._tag === 'LiveStore.IntentionalShutdownCause'
771
+ ? cause.toString()
772
+ : cause
773
+ yield* Effect.logDebug(`Shutting down LiveStore`, logCause)
774
+
775
+ FiberSet.clear(fiberSet).pipe(
776
+ Effect.andThen(() => FiberSet.run(fiberSet, Effect.failCause(cause))),
777
+ Effect.timeout(Duration.seconds(1)),
778
+ Effect.logWarnIfTakesLongerThan({ label: '@livestore/livestore:shutdown:clear-fiber-set', duration: 500 }),
779
+ Effect.catchTag('TimeoutException', (err) =>
780
+ Effect.logError('Store shutdown timed out. Forcing shutdown.', err).pipe(
781
+ Effect.andThen(FiberSet.run(fiberSet, Effect.failCause(cause))),
782
+ ),
783
+ ),
784
+ Runtime.runFork(runtime), // NOTE we need to fork this separately otherwise it will also be interrupted
785
+ )
786
+ }).pipe(Effect.withSpan('livestore:shutdown'))
787
+
788
+ const clientSession: ClientSession = yield* adapter({
789
+ schema,
790
+ storeId,
791
+ devtoolsEnabled: disableDevtools !== true,
792
+ bootStatusQueue,
793
+ shutdown,
794
+ connectDevtoolsToStore: connectDevtoolsToStore_,
795
+ }).pipe(Effect.withPerformanceMeasure('livestore:makeAdapter'), Effect.withSpan('createStore:makeAdapter'))
796
+
797
+ const mutationEventSchema = makeMutationEventSchemaMemo(schema)
798
+
799
+ // TODO get rid of this
800
+ // const __processedMutationIds = new Set<number>()
801
+
802
+ const currentMutationEventIdRef = { current: yield* clientSession.coordinator.getCurrentMutationEventId }
803
+
804
+ // TODO fill up with unsynced mutation events from the coordinator
805
+ const unsyncedMutationEvents = MutableHashMap.empty<EventId, MutationEvent.ForSchema<TSchema>>()
806
+
807
+ // TODO consider moving booting into the storage backend
808
+ if (boot !== undefined) {
809
+ let isInTxn = false
810
+ let txnExecuteStmnts: [string, PreparedBindValues | undefined][] = []
811
+
812
+ const bootDbImpl: BootDb = {
813
+ _tag: 'BootDb',
814
+ execute: (queryStr, bindValues) => {
815
+ const stmt = clientSession.syncDb.prepare(queryStr)
816
+ stmt.execute(bindValues)
817
+
818
+ if (isInTxn === true) {
819
+ txnExecuteStmnts.push([queryStr, bindValues])
820
+ } else {
821
+ clientSession.coordinator.execute(queryStr, bindValues).pipe(runEffectFork)
822
+ }
823
+ },
824
+ mutate: (...list) => {
825
+ for (const mutationEventDecoded_ of list) {
826
+ const mutationDef =
827
+ schema.mutations.get(mutationEventDecoded_.mutation) ??
828
+ shouldNeverHappen(`Unknown mutation type: ${mutationEventDecoded_.mutation}`)
829
+
830
+ const { id, parentId } = clientSession.coordinator
831
+ .nextMutationEventIdPair({ localOnly: mutationDef.options.localOnly })
832
+ .pipe(Effect.runSync)
833
+
834
+ currentMutationEventIdRef.current = id
835
+
836
+ const mutationEventDecoded = { ...mutationEventDecoded_, id, parentId }
837
+
838
+ MutableHashMap.set(unsyncedMutationEvents, Data.struct(mutationEventDecoded.id), mutationEventDecoded)
839
+
840
+ // __processedMutationIds.add(mutationEventDecoded.id.global)
841
+
842
+ const execArgsArr = getExecArgsFromMutation({ mutationDef, mutationEventDecoded })
843
+ // const { bindValues, statementSql } = getExecArgsFromMutation({ mutationDef, mutationEventDecoded })
844
+
845
+ for (const { statementSql, bindValues } of execArgsArr) {
846
+ clientSession.syncDb.execute(statementSql, bindValues)
847
+ }
848
+
849
+ const mutationEventEncoded = Schema.encodeUnknownSync(mutationEventSchema)(mutationEventDecoded)
850
+
851
+ clientSession.coordinator
852
+ .mutate(mutationEventEncoded as MutationEvent.AnyEncoded, { persisted: true })
853
+ .pipe(runEffectFork)
854
+ }
855
+ },
856
+ select: (queryStr, bindValues) => {
857
+ const stmt = clientSession.syncDb.prepare(queryStr)
858
+ return stmt.select(bindValues)
859
+ },
860
+ txn: (callback) => {
861
+ try {
862
+ isInTxn = true
863
+ // clientSession.syncDb.execute('BEGIN TRANSACTION', undefined)
864
+
865
+ callback()
866
+
867
+ // clientSession.syncDb.execute('COMMIT', undefined)
868
+
869
+ // clientSession.coordinator.execute('BEGIN', undefined, undefined)
870
+ for (const [queryStr, bindValues] of txnExecuteStmnts) {
871
+ clientSession.coordinator.execute(queryStr, bindValues).pipe(runEffectFork)
872
+ }
873
+ // clientSession.coordinator.execute('COMMIT', undefined, undefined)
874
+ } catch (e: any) {
875
+ // clientSession.syncDb.execute('ROLLBACK', undefined)
876
+ throw e
877
+ } finally {
878
+ isInTxn = false
879
+ txnExecuteStmnts = []
880
+ }
881
+ },
882
+ }
883
+
884
+ yield* Effect.tryAll(() => boot(bootDbImpl, span)).pipe(
885
+ UnexpectedError.mapToUnexpectedError,
886
+ Effect.withSpan('createStore:boot'),
887
+ )
888
+ }
889
+
890
+ const store = Store.createStore<TGraphQLContext, TSchema>(
891
+ {
892
+ clientSession,
893
+ schema,
894
+ graphQLOptions,
895
+ otelOptions: { tracer: otelTracer, rootSpanContext: otelRootSpanContext },
896
+ reactivityGraph,
897
+ disableDevtools,
898
+ currentMutationEventIdRef,
899
+ unsyncedMutationEvents,
900
+ fiberSet,
901
+ runtime,
902
+ batchUpdates: batchUpdates ?? ((run) => run()),
903
+ storeId,
904
+ },
905
+ span,
906
+ )
907
+
908
+ yield* Deferred.succeed(storeDeferred, store as any as Store)
909
+
910
+ return store
911
+ }).pipe(
912
+ Effect.withSpan('createStore', {
913
+ parent: otelOptions?.rootSpanContext
914
+ ? OtelTracer.makeExternalSpan(otel.trace.getSpanContext(otelOptions.rootSpanContext)!)
915
+ : undefined,
916
+ }),
917
+ Effect.provide(TracingLive),
918
+ )
919
+ }
920
+ // #endregion createStore