@livestore/livestore 0.0.55-dev.1 → 0.0.55-dev.3

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/src/store.ts CHANGED
@@ -1,25 +1,20 @@
1
1
  import type {
2
2
  BootDb,
3
3
  BootStatus,
4
- DebugInfo,
5
4
  ParamsObject,
6
5
  PreparedBindValues,
7
6
  ResetMode,
8
7
  StoreAdapter,
9
8
  StoreAdapterFactory,
10
9
  } from '@livestore/common'
11
- import {
12
- Devtools,
13
- getExecArgsFromMutation,
14
- liveStoreVersion,
15
- prepareBindValues,
16
- UnexpectedError,
17
- } from '@livestore/common'
10
+ import { getExecArgsFromMutation, prepareBindValues, UnexpectedError } from '@livestore/common'
18
11
  import type { LiveStoreSchema, MutationEvent } from '@livestore/common/schema'
19
- import { makeMutationEventSchemaMemo } from '@livestore/common/schema'
20
- import { assertNever, makeNoopTracer, shouldNeverHappen, throttle } from '@livestore/utils'
21
- import { cuid } from '@livestore/utils/cuid'
12
+ import { makeMutationEventSchemaMemo, SCHEMA_META_TABLE, SCHEMA_MUTATIONS_META_TABLE } from '@livestore/common/schema'
13
+ import { assertNever, makeNoopTracer, shouldNeverHappen } from '@livestore/utils'
14
+ import type { Cause } from '@livestore/utils/effect'
22
15
  import {
16
+ Deferred,
17
+ Duration,
23
18
  Effect,
24
19
  Exit,
25
20
  FiberSet,
@@ -38,11 +33,12 @@ import * as otel from '@opentelemetry/api'
38
33
  import type { GraphQLSchema } from 'graphql'
39
34
 
40
35
  import { globalReactivityGraph } from './global-state.js'
41
- import { emptyDebugInfo as makeEmptyDebugInfo, MainDatabaseWrapper } from './MainDatabaseWrapper.js'
36
+ import { MainDatabaseWrapper } from './MainDatabaseWrapper.js'
42
37
  import type { StackInfo } from './react/utils/stack-info.js'
43
38
  import type { DebugRefreshReasonBase, Ref } from './reactive.js'
44
- import { NOT_REFRESHED_YET } from './reactive.js'
45
39
  import type { LiveQuery, QueryContext, ReactivityGraph } from './reactiveQueries/base-class.js'
40
+ import { connectDevtoolsToStore } from './store-devtools.js'
41
+ import { ReferenceCountedSet } from './utils/data-structures.js'
46
42
  import { downloadBlob } from './utils/dev.js'
47
43
  import { getDurationMsFromSpan } from './utils/otel.js'
48
44
 
@@ -62,6 +58,9 @@ export type OtelOptions = {
62
58
  rootSpanContext: otel.Context
63
59
  }
64
60
 
61
+ export class ForceStoreShutdown extends Schema.TaggedError<ForceStoreShutdown>()('LiveStore.ForceStoreShutdown', {}) {}
62
+ export class StoreShutdown extends Schema.TaggedError<StoreShutdown>()('LiveStore.StoreShutdown', {}) {}
63
+
65
64
  export type StoreOptions<
66
65
  TGraphQLContext extends BaseGraphQLContext,
67
66
  TSchema extends LiveStoreSchema = LiveStoreSchema,
@@ -130,7 +129,6 @@ export class Store<
130
129
  TSchema extends LiveStoreSchema = LiveStoreSchema,
131
130
  > extends Inspectable.Class {
132
131
  id = uniqueStoreId()
133
- readonly devtoolsConnectionId = cuid()
134
132
  private fiberSet: FiberSet.FiberSet
135
133
  reactivityGraph: ReactivityGraph
136
134
  mainDbWrapper: MainDatabaseWrapper
@@ -154,6 +152,7 @@ export class Store<
154
152
 
155
153
  readonly __mutationEventSchema
156
154
 
155
+ // #region constructor
157
156
  private constructor({
158
157
  adapter,
159
158
  schema,
@@ -201,8 +200,20 @@ export class Store<
201
200
  queriesSpanContext: otelQueriesSpanContext,
202
201
  }
203
202
 
203
+ // TODO find a better way to detect if we're running LiveStore in the LiveStore devtools
204
+ // But for now this is a good enough approximation with little downsides
205
+ const isRunningInDevtools = disableDevtools === true
206
+
204
207
  // Need a set here since `schema.tables` might contain duplicates and some componentStateTables
205
- const allTableNames = new Set(this.schema.tables.keys())
208
+ const allTableNames = new Set(
209
+ // NOTE we're excluding the LiveStore schema and mutations tables as they are not user-facing
210
+ // unless LiveStore is running in the devtools
211
+ isRunningInDevtools
212
+ ? this.schema.tables.keys()
213
+ : Array.from(this.schema.tables.keys()).filter(
214
+ (_) => _ !== SCHEMA_META_TABLE && _ !== SCHEMA_MUTATIONS_META_TABLE,
215
+ ),
216
+ )
206
217
  const existingTableRefs = new Map(
207
218
  Array.from(this.reactivityGraph.atoms.values())
208
219
  .filter((_): _ is Ref<any, any, any> => _._tag === 'ref' && _.label?.startsWith('tableRef:') === true)
@@ -223,14 +234,11 @@ export class Store<
223
234
  this.mutate({ wasSyncMessage: true }, mutationEventDecoded)
224
235
  }),
225
236
  Stream.runDrain,
237
+ Effect.interruptible,
226
238
  Effect.withSpan('LiveStore:syncMutations'),
227
239
  Effect.forkScoped,
228
240
  )
229
241
 
230
- if (disableDevtools !== true) {
231
- yield* this.bootDevtools().pipe(Effect.forkScoped)
232
- }
233
-
234
242
  yield* Effect.addFinalizer(() =>
235
243
  Effect.sync(() => {
236
244
  for (const tableRef of Object.values(this.tableRefs)) {
@@ -247,6 +255,7 @@ export class Store<
247
255
  yield* Effect.never
248
256
  }).pipe(Effect.scoped, Effect.withSpan('LiveStore:store-constructor'), FiberSet.run(fiberSet), runEffectFork)
249
257
  }
258
+ // #endregion constructor
250
259
 
251
260
  static createStore = <TGraphQLContext extends BaseGraphQLContext, TSchema extends LiveStoreSchema = LiveStoreSchema>(
252
261
  storeOptions: StoreOptions<TGraphQLContext, TSchema>,
@@ -314,6 +323,7 @@ export class Store<
314
323
  await FiberSet.clear(this.fiberSet).pipe(Effect.withSpan('Store:destroy'), runEffectPromise)
315
324
  }
316
325
 
326
+ // #region mutate
317
327
  mutate: {
318
328
  <const TMutationArg extends ReadonlyArray<MutationEvent.ForSchema<TSchema>>>(...list: TMutationArg): void
319
329
  (
@@ -549,6 +559,7 @@ export class Store<
549
559
  },
550
560
  )
551
561
  }
562
+ // #endregion mutate
552
563
 
553
564
  /**
554
565
  * Directly execute a SQL query on the Store.
@@ -577,226 +588,6 @@ export class Store<
577
588
  meta: { liveStoreRefType: 'table' },
578
589
  })
579
590
 
580
- // #region devtools
581
- // TODO shutdown behaviour
582
- private bootDevtools = () =>
583
- Effect.gen(this, function* () {
584
- const sendToDevtoolsContentscript = (
585
- message: typeof Devtools.DevtoolsWindowMessage.MessageForContentscript.Type,
586
- ) => {
587
- window.postMessage(Schema.encodeSync(Devtools.DevtoolsWindowMessage.MessageForContentscript)(message), '*')
588
- }
589
-
590
- sendToDevtoolsContentscript(Devtools.DevtoolsWindowMessage.LoadIframe.make({}))
591
-
592
- const channelId = this.adapter.coordinator.devtools.channelId
593
-
594
- const runtime = yield* Effect.runtime()
595
-
596
- window.addEventListener('message', (event) => {
597
- const decodedMessageRes = Schema.decodeOption(Devtools.DevtoolsWindowMessage.MessageForStore)(event.data)
598
- if (decodedMessageRes._tag === 'None') return
599
-
600
- const message = decodedMessageRes.value
601
-
602
- if (message._tag === 'LSD.WindowMessage.ContentscriptListening') {
603
- sendToDevtoolsContentscript(Devtools.DevtoolsWindowMessage.StoreReady.make({ channelId }))
604
- return
605
- }
606
-
607
- if (message.channelId !== channelId) return
608
-
609
- if (message._tag === 'LSD.WindowMessage.MessagePortForStore') {
610
- type Unsub = () => void
611
- type RequestId = string
612
-
613
- const reactivityGraphSubcriptions = new Map<RequestId, Unsub>()
614
- const liveQueriesSubscriptions = new Map<RequestId, Unsub>()
615
- const debugInfoHistorySubscriptions = new Map<RequestId, Unsub>()
616
-
617
- this.adapter.coordinator.devtools
618
- .connect({ port: message.port, connectionId: this.devtoolsConnectionId })
619
- .pipe(
620
- Effect.tapSync(({ storeMessagePort }) => {
621
- // console.log('storeMessagePort', storeMessagePort)
622
- storeMessagePort.addEventListener('message', (event) => {
623
- const decodedMessage = Schema.decodeUnknownSync(Devtools.MessageToAppHostStore)(event.data)
624
- // console.log('storeMessagePort message', decodedMessage)
625
-
626
- if (decodedMessage.channelId !== this.adapter.coordinator.devtools.channelId) {
627
- // console.log(`Unknown message`, event)
628
- return
629
- }
630
-
631
- const requestId = decodedMessage.requestId
632
- const sendToDevtools = (message: Devtools.MessageFromAppHostStore) =>
633
- storeMessagePort.postMessage(Schema.encodeSync(Devtools.MessageFromAppHostStore)(message))
634
-
635
- const requestIdleCallback = window.requestIdleCallback ?? ((cb: Function) => cb())
636
-
637
- switch (decodedMessage._tag) {
638
- case 'LSD.ReactivityGraphSubscribe': {
639
- const includeResults = decodedMessage.includeResults
640
-
641
- const send = () =>
642
- // In order to not add more work to the current tick, we use requestIdleCallback
643
- // to send the reactivity graph updates to the devtools
644
- requestIdleCallback(
645
- () =>
646
- sendToDevtools(
647
- Devtools.ReactivityGraphRes.make({
648
- reactivityGraph: this.reactivityGraph.getSnapshot({ includeResults }),
649
- requestId,
650
- channelId,
651
- liveStoreVersion,
652
- }),
653
- ),
654
- { timeout: 500 },
655
- )
656
-
657
- send()
658
-
659
- // In some cases, there can be A LOT of reactivity graph updates in a short period of time
660
- // so we throttle the updates to avoid sending too much data
661
- // This might need to be tweaked further and possibly be exposed to the user in some way.
662
- const throttledSend = throttle(send, 20)
663
-
664
- reactivityGraphSubcriptions.set(requestId, this.reactivityGraph.subscribeToRefresh(throttledSend))
665
-
666
- break
667
- }
668
- case 'LSD.DebugInfoReq': {
669
- sendToDevtools(
670
- Devtools.DebugInfoRes.make({
671
- debugInfo: this.mainDbWrapper.debugInfo,
672
- requestId,
673
- channelId,
674
- liveStoreVersion,
675
- }),
676
- )
677
- break
678
- }
679
- case 'LSD.DebugInfoHistorySubscribe': {
680
- const buffer: DebugInfo[] = []
681
- let hasStopped = false
682
- let rafHandle: number | undefined
683
-
684
- const tick = () => {
685
- buffer.push(this.mainDbWrapper.debugInfo)
686
-
687
- // NOTE this resets the debug info, so all other "readers" e.g. in other `requestAnimationFrame` loops,
688
- // will get the empty debug info
689
- // TODO We need to come up with a more graceful way to do this. Probably via a single global
690
- // `requestAnimationFrame` loop that is passed in somehow.
691
- this.mainDbWrapper.debugInfo = makeEmptyDebugInfo()
692
-
693
- if (buffer.length > 10) {
694
- sendToDevtools(
695
- Devtools.DebugInfoHistoryRes.make({
696
- debugInfoHistory: buffer,
697
- requestId,
698
- channelId,
699
- liveStoreVersion,
700
- }),
701
- )
702
- buffer.length = 0
703
- }
704
-
705
- if (hasStopped === false) {
706
- rafHandle = requestAnimationFrame(tick)
707
- }
708
- }
709
-
710
- rafHandle = requestAnimationFrame(tick)
711
-
712
- const unsub = () => {
713
- hasStopped = true
714
- if (rafHandle !== undefined) {
715
- cancelAnimationFrame(rafHandle)
716
- }
717
- }
718
-
719
- debugInfoHistorySubscriptions.set(requestId, unsub)
720
-
721
- break
722
- }
723
- case 'LSD.DebugInfoHistoryUnsubscribe': {
724
- debugInfoHistorySubscriptions.get(requestId)!()
725
- debugInfoHistorySubscriptions.delete(requestId)
726
- break
727
- }
728
- case 'LSD.DebugInfoResetReq': {
729
- this.mainDbWrapper.debugInfo.slowQueries.clear()
730
- sendToDevtools(Devtools.DebugInfoResetRes.make({ requestId, channelId, liveStoreVersion }))
731
- break
732
- }
733
- case 'LSD.DebugInfoRerunQueryReq': {
734
- const { queryStr, bindValues, queriedTables } = decodedMessage
735
- this.mainDbWrapper.select(queryStr, { bindValues, queriedTables, skipCache: true })
736
- sendToDevtools(Devtools.DebugInfoRerunQueryRes.make({ requestId, channelId, liveStoreVersion }))
737
- break
738
- }
739
- case 'LSD.ReactivityGraphUnsubscribe': {
740
- reactivityGraphSubcriptions.get(requestId)!()
741
- break
742
- }
743
- case 'LSD.LiveQueriesSubscribe': {
744
- const send = () =>
745
- requestIdleCallback(
746
- () =>
747
- sendToDevtools(
748
- Devtools.LiveQueriesRes.make({
749
- liveQueries: [...this.activeQueries].map((q) => ({
750
- _tag: q._tag,
751
- id: q.id,
752
- label: q.label,
753
- runs: q.runs,
754
- executionTimes: q.executionTimes.map((_) => Number(_.toString().slice(0, 5))),
755
- lastestResult:
756
- q.results$.previousResult === NOT_REFRESHED_YET
757
- ? 'SYMBOL_NOT_REFRESHED_YET'
758
- : q.results$.previousResult,
759
- activeSubscriptions: Array.from(q.activeSubscriptions),
760
- })),
761
- requestId,
762
- liveStoreVersion,
763
- channelId,
764
- }),
765
- ),
766
- { timeout: 500 },
767
- )
768
-
769
- send()
770
-
771
- // Same as in the reactivity graph subscription case above, we need to throttle the updates
772
- const throttledSend = throttle(send, 20)
773
-
774
- liveQueriesSubscriptions.set(requestId, this.reactivityGraph.subscribeToRefresh(throttledSend))
775
-
776
- break
777
- }
778
- case 'LSD.LiveQueriesUnsubscribe': {
779
- liveQueriesSubscriptions.get(requestId)!()
780
- liveQueriesSubscriptions.delete(requestId)
781
- break
782
- }
783
- // No default
784
- }
785
- })
786
-
787
- storeMessagePort.start()
788
- }),
789
- Runtime.runFork(runtime),
790
- )
791
-
792
- return
793
- }
794
- })
795
-
796
- sendToDevtoolsContentscript(Devtools.DevtoolsWindowMessage.StoreReady.make({ channelId }))
797
- })
798
- // #endregion devtools
799
-
800
591
  __devDownloadDb = () => {
801
592
  const data = this.mainDbWrapper.export()
802
593
  downloadBlob(data, `livestore-${Date.now()}.db`)
@@ -810,6 +601,7 @@ export class Store<
810
601
  // TODO allow for graceful store reset without requiring a full page reload (which should also call .boot)
811
602
  dangerouslyResetStorage = (mode: ResetMode) => this.adapter.coordinator.dangerouslyReset(mode).pipe(runEffectPromise)
812
603
 
604
+ // NOTE This is needed because when booting a Store via Effect it seems to call `toJSON` in the error path
813
605
  toJSON = () => {
814
606
  return {
815
607
  _tag: 'Store',
@@ -888,21 +680,44 @@ export const createStore = <
888
680
 
889
681
  yield* Queue.take(bootStatusQueue).pipe(
890
682
  Effect.tapSync((status) => onBootStatus?.(status)),
683
+ Effect.tap((status) => (status.stage === 'done' ? Queue.shutdown(bootStatusQueue) : Effect.void)),
891
684
  Effect.forever,
892
685
  Effect.tapCauseLogPretty,
893
686
  Effect.forkScoped,
894
687
  )
895
688
 
689
+ const storeDeferred = yield* Deferred.make<Store>()
690
+
691
+ const connectDevtoolsToStore_ = ({ storeMessagePort }: { storeMessagePort: MessagePort }) =>
692
+ Effect.gen(function* () {
693
+ const store = yield* Deferred.await(storeDeferred)
694
+ yield* connectDevtoolsToStore({ storeMessagePort, store })
695
+ })
696
+
697
+ // TODO close parent scope? (Needs refactor with Mike A)
698
+ const shutdown = (cause: Cause.Cause<unknown>) =>
699
+ Effect.gen(function* () {
700
+ yield* Effect.logWarning(`Shutting down LiveStore`, cause)
701
+
702
+ FiberSet.clear(fiberSet).pipe(
703
+ Effect.andThen(() => FiberSet.run(fiberSet, Effect.fail(StoreShutdown.make()))),
704
+ Effect.timeout(Duration.seconds(1)),
705
+ Effect.logWarnIfTakesLongerThan({ label: '@livestore/livestore:shutdown:clear-fiber-set', duration: 500 }),
706
+ Effect.catchTag('TimeoutException', () =>
707
+ Effect.logWarning('Store shutdown timed out. Forcing shutdown.').pipe(
708
+ Effect.andThen(FiberSet.run(fiberSet, Effect.fail(ForceStoreShutdown.make()))),
709
+ ),
710
+ ),
711
+ runEffectFork, // NOTE we need to fork this separately otherwise it will also be interrupted
712
+ )
713
+ }).pipe(Effect.withSpan('livestore:shutdown'))
714
+
896
715
  const adapter: StoreAdapter = yield* adapterFactory({
897
716
  schema,
898
717
  devtoolsEnabled: disableDevtools !== true,
899
718
  bootStatusQueue,
900
- shutdown: (cause) =>
901
- Effect.gen(function* () {
902
- yield* Effect.logWarning(`Shutting down LiveStore`, cause)
903
- // TODO close parent scope? (Needs refactor with Mike A)
904
- yield* FiberSet.clear(fiberSet)
905
- }).pipe(Effect.withSpan('livestore:shutdown')),
719
+ shutdown,
720
+ connectDevtoolsToStore: connectDevtoolsToStore_,
906
721
  }).pipe(Effect.withPerformanceMeasure('livestore:makeAdapter'), Effect.withSpan('createStore:makeAdapter'))
907
722
 
908
723
  if (batchUpdates !== undefined) {
@@ -986,7 +801,7 @@ export const createStore = <
986
801
  )
987
802
  }
988
803
 
989
- return Store.createStore<TGraphQLContext, TSchema>(
804
+ const store = Store.createStore<TGraphQLContext, TSchema>(
990
805
  {
991
806
  adapter,
992
807
  schema,
@@ -999,6 +814,10 @@ export const createStore = <
999
814
  },
1000
815
  span,
1001
816
  )
817
+
818
+ yield* Deferred.succeed(storeDeferred, store as any as Store)
819
+
820
+ return store
1002
821
  }).pipe(
1003
822
  Effect.withSpan('createStore', {
1004
823
  parent: otelOptions?.rootSpanContext
@@ -1010,43 +829,7 @@ export const createStore = <
1010
829
  }
1011
830
  // #endregion createStore
1012
831
 
1013
- // TODO consider replacing with Effect's RC data structures
1014
- class ReferenceCountedSet<T> {
1015
- private map: Map<T, number>
1016
-
1017
- constructor() {
1018
- this.map = new Map<T, number>()
1019
- }
1020
-
1021
- add = (key: T) => {
1022
- const count = this.map.get(key) ?? 0
1023
- this.map.set(key, count + 1)
1024
- }
1025
-
1026
- remove = (key: T) => {
1027
- const count = this.map.get(key) ?? 0
1028
- if (count === 1) {
1029
- this.map.delete(key)
1030
- } else {
1031
- this.map.set(key, count - 1)
1032
- }
1033
- }
1034
-
1035
- has = (key: T) => {
1036
- return this.map.has(key)
1037
- }
1038
-
1039
- get size() {
1040
- return this.map.size
1041
- }
1042
-
1043
- *[Symbol.iterator]() {
1044
- for (const key of this.map.keys()) {
1045
- yield key
1046
- }
1047
- }
1048
- }
1049
-
832
+ // TODO propagate runtime
1050
833
  const runEffectFork = <A, E>(effect: Effect.Effect<A, E, never>) =>
1051
834
  effect.pipe(
1052
835
  Effect.tapCauseLogPretty,
@@ -1056,6 +839,7 @@ const runEffectFork = <A, E>(effect: Effect.Effect<A, E, never>) =>
1056
839
  Effect.runFork,
1057
840
  )
1058
841
 
842
+ // TODO propagate runtime
1059
843
  const runEffectPromise = <A, E>(effect: Effect.Effect<A, E, never>) =>
1060
844
  effect.pipe(
1061
845
  Effect.tapCauseLogPretty,
@@ -0,0 +1,36 @@
1
+ // TODO consider replacing with Effect's RC data structures
2
+ export class ReferenceCountedSet<T> {
3
+ private map: Map<T, number>
4
+
5
+ constructor() {
6
+ this.map = new Map<T, number>()
7
+ }
8
+
9
+ add = (key: T) => {
10
+ const count = this.map.get(key) ?? 0
11
+ this.map.set(key, count + 1)
12
+ }
13
+
14
+ remove = (key: T) => {
15
+ const count = this.map.get(key) ?? 0
16
+ if (count === 1) {
17
+ this.map.delete(key)
18
+ } else {
19
+ this.map.set(key, count - 1)
20
+ }
21
+ }
22
+
23
+ has = (key: T) => {
24
+ return this.map.has(key)
25
+ }
26
+
27
+ get size() {
28
+ return this.map.size
29
+ }
30
+
31
+ *[Symbol.iterator]() {
32
+ for (const key of this.map.keys()) {
33
+ yield key
34
+ }
35
+ }
36
+ }