@livestore/livestore 0.0.32 → 0.0.35

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 (71) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/__tests__/react/fixture.d.ts +3 -1
  3. package/dist/__tests__/react/fixture.d.ts.map +1 -1
  4. package/dist/__tests__/react/fixture.js +6 -3
  5. package/dist/__tests__/react/fixture.js.map +1 -1
  6. package/dist/__tests__/react/useRow.test.js +10 -10
  7. package/dist/__tests__/react/useRow.test.js.map +1 -1
  8. package/dist/__tests__/reactive.test.js +13 -2
  9. package/dist/__tests__/reactive.test.js.map +1 -1
  10. package/dist/global-state.d.ts +1 -4
  11. package/dist/global-state.d.ts.map +1 -1
  12. package/dist/global-state.js +2 -6
  13. package/dist/global-state.js.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/react/LiveStoreProvider.d.ts.map +1 -1
  19. package/dist/react/LiveStoreProvider.js +9 -3
  20. package/dist/react/LiveStoreProvider.js.map +1 -1
  21. package/dist/react/useQuery.d.ts.map +1 -1
  22. package/dist/react/useQuery.js +1 -0
  23. package/dist/react/useQuery.js.map +1 -1
  24. package/dist/react/useRow.d.ts +7 -3
  25. package/dist/react/useRow.d.ts.map +1 -1
  26. package/dist/react/useRow.js +7 -5
  27. package/dist/react/useRow.js.map +1 -1
  28. package/dist/reactive.d.ts +21 -6
  29. package/dist/reactive.d.ts.map +1 -1
  30. package/dist/reactive.js +60 -12
  31. package/dist/reactive.js.map +1 -1
  32. package/dist/reactiveQueries/base-class.d.ts +5 -2
  33. package/dist/reactiveQueries/base-class.d.ts.map +1 -1
  34. package/dist/reactiveQueries/base-class.js +8 -3
  35. package/dist/reactiveQueries/base-class.js.map +1 -1
  36. package/dist/reactiveQueries/graphql.d.ts +6 -3
  37. package/dist/reactiveQueries/graphql.d.ts.map +1 -1
  38. package/dist/reactiveQueries/graphql.js +10 -7
  39. package/dist/reactiveQueries/graphql.js.map +1 -1
  40. package/dist/reactiveQueries/js.d.ts +6 -2
  41. package/dist/reactiveQueries/js.d.ts.map +1 -1
  42. package/dist/reactiveQueries/js.js +8 -5
  43. package/dist/reactiveQueries/js.js.map +1 -1
  44. package/dist/reactiveQueries/sql.d.ts +5 -2
  45. package/dist/reactiveQueries/sql.d.ts.map +1 -1
  46. package/dist/reactiveQueries/sql.js +11 -6
  47. package/dist/reactiveQueries/sql.js.map +1 -1
  48. package/dist/row-query.d.ts +3 -0
  49. package/dist/row-query.d.ts.map +1 -1
  50. package/dist/row-query.js +11 -6
  51. package/dist/row-query.js.map +1 -1
  52. package/dist/store.d.ts +6 -3
  53. package/dist/store.d.ts.map +1 -1
  54. package/dist/store.js +29 -15
  55. package/dist/store.js.map +1 -1
  56. package/package.json +4 -4
  57. package/src/__tests__/react/fixture.tsx +8 -2
  58. package/src/__tests__/react/useRow.test.tsx +10 -10
  59. package/src/__tests__/reactive.test.ts +20 -2
  60. package/src/global-state.ts +2 -9
  61. package/src/index.ts +3 -3
  62. package/src/react/LiveStoreProvider.tsx +13 -4
  63. package/src/react/useQuery.ts +2 -0
  64. package/src/react/useRow.ts +17 -8
  65. package/src/reactive.ts +96 -16
  66. package/src/reactiveQueries/base-class.ts +15 -4
  67. package/src/reactiveQueries/graphql.ts +15 -8
  68. package/src/reactiveQueries/js.ts +14 -6
  69. package/src/reactiveQueries/sql.ts +15 -6
  70. package/src/row-query.ts +20 -7
  71. package/src/store.ts +38 -17
@@ -11,13 +11,13 @@ describe('useRow', () => {
11
11
  it('should update the data based on component key', async () => {
12
12
  let renderCount = 0
13
13
 
14
- const { wrapper, AppComponentSchema, store } = await makeTodoMvc()
14
+ const { wrapper, AppComponentSchema, store, dbGraph } = await makeTodoMvc({ useGlobalDbGraph: false })
15
15
 
16
16
  const { result, rerender } = renderHook(
17
17
  (userId: string) => {
18
18
  renderCount++
19
19
 
20
- const [state, setState] = LiveStoreReact.useRow(AppComponentSchema, userId)
20
+ const [state, setState] = LiveStoreReact.useRow(AppComponentSchema, userId, { dbGraph })
21
21
  return { state, setState }
22
22
  },
23
23
  { wrapper, initialProps: 'u1' },
@@ -41,13 +41,13 @@ describe('useRow', () => {
41
41
  it('should update the data reactively - via setState', async () => {
42
42
  let renderCount = 0
43
43
 
44
- const { wrapper, AppComponentSchema } = await makeTodoMvc()
44
+ const { wrapper, AppComponentSchema, dbGraph } = await makeTodoMvc({ useGlobalDbGraph: false })
45
45
 
46
46
  const { result } = renderHook(
47
47
  (userId: string) => {
48
48
  renderCount++
49
49
 
50
- const [state, setState] = LiveStoreReact.useRow(AppComponentSchema, userId)
50
+ const [state, setState] = LiveStoreReact.useRow(AppComponentSchema, userId, { dbGraph })
51
51
  return { state, setState }
52
52
  },
53
53
  { wrapper, initialProps: 'u1' },
@@ -67,13 +67,13 @@ describe('useRow', () => {
67
67
  it('should update the data reactively - via raw store update', async () => {
68
68
  let renderCount = 0
69
69
 
70
- const { wrapper, AppComponentSchema, store } = await makeTodoMvc()
70
+ const { wrapper, AppComponentSchema, store, dbGraph } = await makeTodoMvc({ useGlobalDbGraph: false })
71
71
 
72
72
  const { result } = renderHook(
73
73
  (userId: string) => {
74
74
  renderCount++
75
75
 
76
- const [state, setState] = LiveStoreReact.useRow(AppComponentSchema, userId)
76
+ const [state, setState] = LiveStoreReact.useRow(AppComponentSchema, userId, { dbGraph })
77
77
  return { state, setState }
78
78
  },
79
79
  { wrapper, initialProps: 'u1' },
@@ -95,9 +95,9 @@ describe('useRow', () => {
95
95
  })
96
96
 
97
97
  it('should work for a larger app', async () => {
98
- const allTodos$ = LiveStore.querySQL<Todo>(`select * from todos`, { label: 'allTodos' })
98
+ const { wrapper, store, dbGraph } = await makeTodoMvc({ useGlobalDbGraph: false })
99
99
 
100
- const { wrapper, store } = await makeTodoMvc()
100
+ const allTodos$ = LiveStore.querySQL<Todo>(`select * from todos`, { label: 'allTodos', dbGraph })
101
101
 
102
102
  const AppRouterSchema = LiveStore.DbSchema.table(
103
103
  'AppRouter',
@@ -112,7 +112,7 @@ describe('useRow', () => {
112
112
  const AppRouter: React.FC = () => {
113
113
  appRouterRenderCount++
114
114
 
115
- const [state, setState] = LiveStoreReact.useRow(AppRouterSchema)
115
+ const [state, setState] = LiveStoreReact.useRow(AppRouterSchema, { dbGraph })
116
116
 
117
117
  globalSetState = setState
118
118
 
@@ -141,7 +141,7 @@ describe('useRow', () => {
141
141
 
142
142
  const TaskDetails: React.FC<{ id: string }> = ({ id }) => {
143
143
  const todo = LiveStoreReact.useTemporaryQuery(() =>
144
- LiveStore.querySQL<Todo>(`select * from todos where id = '${id}' limit 1`).getFirstRow(),
144
+ LiveStore.querySQL<Todo>(`select * from todos where id = '${id}' limit 1`, { dbGraph }).getFirstRow(),
145
145
  )
146
146
  return <div role="content">{JSON.stringify(todo)}</div>
147
147
  }
@@ -221,7 +221,7 @@ describe('a trivial graph', () => {
221
221
  expect(numberOfEffect2Runs).toBe(1)
222
222
  expect(numberOfRunsForC.runs).toBe(1)
223
223
 
224
- graph.destroy(effect1)
224
+ graph.destroyNode(effect1)
225
225
 
226
226
  graph.runDeferredEffects()
227
227
 
@@ -231,6 +231,24 @@ describe('a trivial graph', () => {
231
231
  })
232
232
  })
233
233
  })
234
+
235
+ describe('destroying nodes', () => {
236
+ it('marks super node as dirty when a sub node is destroyed', () => {
237
+ const { graph, b, c, d, e } = makeGraph()
238
+
239
+ e.computeResult()
240
+
241
+ graph.destroyNode(b)
242
+
243
+ expect(c.isDirty).toBe(true)
244
+ expect(d.isDirty).toBe(false)
245
+ expect(e.isDirty).toBe(true)
246
+
247
+ expect(() => c.computeResult()).toThrowErrorMatchingInlineSnapshot(
248
+ `[Error: This should never happen LiveStore Error: Attempted to compute destroyed atom]`,
249
+ )
250
+ })
251
+ })
234
252
  })
235
253
 
236
254
  describe('a dynamic graph', () => {
@@ -402,7 +420,7 @@ describe('error handling', () => {
402
420
  const a = graph.makeRef(1)
403
421
  const b = graph.makeThunk((get) => get(a) + 1)
404
422
  expect(() => b.computeResult()).toThrowErrorMatchingInlineSnapshot(
405
- `[Error: LiveStore Error: \`context\` not set on ReactiveGraph]`,
423
+ `[Error: LiveStore Error: \`context\` not set on ReactiveGraph (graph-19)]`,
406
424
  )
407
425
  })
408
426
  })
@@ -11,16 +11,9 @@
11
11
  *
12
12
  */
13
13
 
14
- import ReactDOM from 'react-dom'
15
-
16
- import { ReactiveGraph } from './reactive.js'
17
- import type { DbContext } from './reactiveQueries/base-class.js'
14
+ import { makeDbGraph } from './reactiveQueries/base-class.js'
18
15
  import type { TableDef } from './schema/table-def.js'
19
- import type { QueryDebugInfo, RefreshReason } from './store.js'
20
16
 
21
- export const dbGraph = new ReactiveGraph<RefreshReason, QueryDebugInfo, DbContext>({
22
- // TODO also find a better way to only use this effects wrapper when used in a React app
23
- effectsWrapper: (run) => ReactDOM.unstable_batchedUpdates(() => run()),
24
- })
17
+ export const globalDbGraph = makeDbGraph()
25
18
 
26
19
  export const dynamicallyRegisteredTables: Map<string, TableDef> = new Map()
package/src/index.ts CHANGED
@@ -7,13 +7,13 @@ export { InMemoryDatabase, type DebugInfo, emptyDebugInfo } from './inMemoryData
7
7
 
8
8
  export type { Storage, StorageType, StorageInit } from './storage/index.js'
9
9
 
10
- export type { GetAtom, AtomDebugInfo, RefreshDebugInfo, SerializedAtom, Atom } from './reactive.js'
10
+ export type { GetAtom, AtomDebugInfo, RefreshDebugInfo, SerializedAtom, Atom, Node, Ref, Effect } from './reactive.js'
11
11
  export { LiveStoreJSQuery, queryJS } from './reactiveQueries/js.js'
12
12
  export { LiveStoreSQLQuery, querySQL } from './reactiveQueries/sql.js'
13
13
  export { LiveStoreGraphQLQuery, queryGraphQL } from './reactiveQueries/graphql.js'
14
- export { type GetAtomResult } from './reactiveQueries/base-class.js'
14
+ export { type GetAtomResult, type DbGraph, makeDbGraph } from './reactiveQueries/base-class.js'
15
15
 
16
- export { dbGraph } from './global-state.js'
16
+ export { globalDbGraph } from './global-state.js'
17
17
 
18
18
  export { type RowResult, type RowResultEncoded, type RowQueryArgs, rowQuery } from './row-query.js'
19
19
 
@@ -1,3 +1,4 @@
1
+ import { shouldNeverHappen } from '@livestore/utils'
1
2
  import type * as otel from '@opentelemetry/api'
2
3
  import type { ReactElement, ReactNode } from 'react'
3
4
  import React from 'react'
@@ -8,7 +9,7 @@ import type { LiveStoreContext as StoreContext_, LiveStoreCreateStoreOptions } f
8
9
  import type { InMemoryDatabase } from '../inMemoryDatabase.js'
9
10
  import type { LiveStoreSchema } from '../schema/index.js'
10
11
  import type { StorageInit } from '../storage/index.js'
11
- import type { BaseGraphQLContext, GraphQLOptions } from '../store.js'
12
+ import type { BaseGraphQLContext, GraphQLOptions, Store } from '../store.js'
12
13
  import { createStore } from '../store.js'
13
14
  import { LiveStoreContext } from './LiveStoreContext.js'
14
15
 
@@ -68,10 +69,15 @@ const useCreateStore = <GraphQLContext extends BaseGraphQLContext>({
68
69
  const [ctxValue, setCtxValue] = React.useState<StoreContext_ | undefined>()
69
70
 
70
71
  React.useEffect(() => {
72
+ let store: Store | undefined
73
+
74
+ // resetting the store context while we're creating a new store
75
+ setCtxValue(undefined)
76
+
71
77
  void (async () => {
72
78
  try {
73
79
  const sqlite3 = await sqlite3Promise
74
- const store = await createStore({
80
+ store = await createStore({
75
81
  schema,
76
82
  loadStorage,
77
83
  graphQLOptions,
@@ -82,11 +88,14 @@ const useCreateStore = <GraphQLContext extends BaseGraphQLContext>({
82
88
  })
83
89
  setCtxValue({ store })
84
90
  } catch (e) {
85
- console.error(`Error creating LiveStore store:`, e)
86
- throw e
91
+ shouldNeverHappen(`Error creating LiveStore store: ${e}`)
87
92
  }
88
93
  })()
89
94
 
95
+ return () => {
96
+ store?.destroy()
97
+ }
98
+
90
99
  // TODO: do we need to return any cleanup function here?
91
100
  }, [schema, loadStorage, graphQLOptions, otelTracer, otelRootSpanContext, boot])
92
101
 
@@ -22,6 +22,8 @@ export const useQueryRef = <TResult>(
22
22
  ): React.MutableRefObject<TResult> => {
23
23
  const { store } = useStore()
24
24
 
25
+ React.useDebugValue(`LiveStore:useQuery:${query.id}:${query.label}`)
26
+
25
27
  const stackInfo = React.useMemo(() => {
26
28
  Error.stackTraceLimit = 10
27
29
  // eslint-disable-next-line unicorn/error-message
@@ -4,6 +4,7 @@ import type { SqliteDsl } from 'effect-db-schema'
4
4
  import { mapValues } from 'lodash-es'
5
5
  import React from 'react'
6
6
 
7
+ import type { DbGraph } from '../index.js'
7
8
  import type { LiveStoreJSQuery } from '../reactiveQueries/js.js'
8
9
  import type { RowQueryArgs, RowResult } from '../row-query.js'
9
10
  import { rowQuery } from '../row-query.js'
@@ -17,10 +18,14 @@ export type UseRowResult<TTableDef extends TableDef> = [
17
18
  query$: LiveStoreJSQuery<RowResult<TTableDef>>,
18
19
  ]
19
20
 
20
- export type UseRowOptions<TTableDef extends TableDef> = {
21
+ export type UseRowOptionsDefaulValues<TTableDef extends TableDef> = {
21
22
  defaultValues?: Partial<RowResult<TTableDef>>
22
23
  }
23
24
 
25
+ export type UseRowOptionsBase = {
26
+ dbGraph?: DbGraph
27
+ }
28
+
24
29
  /**
25
30
  * Similar to `React.useState` but returns a tuple of `[row, setRow, query$]` for a given table where ...
26
31
  *
@@ -33,20 +38,24 @@ export type UseRowOptions<TTableDef extends TableDef> = {
33
38
  export const useRow: {
34
39
  <TTableDef extends TableDef<DefaultSqliteTableDef, boolean, TableOptions & { isSingleton: true }>>(
35
40
  table: TTableDef,
41
+ options?: UseRowOptionsBase,
36
42
  ): UseRowResult<TTableDef>
37
43
  <TTableDef extends TableDef<DefaultSqliteTableDef, boolean, TableOptions & { isSingleton: false }>>(
38
44
  table: TTableDef,
39
45
  // TODO adjust so it works with arbitrary primary keys or unique constraints
40
46
  id: string,
41
- options?: UseRowOptions<TTableDef>,
47
+ options?: UseRowOptionsBase & UseRowOptionsDefaulValues<TTableDef>,
42
48
  ): UseRowResult<TTableDef>
43
49
  } = <TTableDef extends TableDef>(
44
50
  table: TTableDef,
45
- id?: string,
46
- options?: UseRowOptions<TTableDef>,
51
+ idOrOptions?: string | UseRowOptionsBase,
52
+ options_?: UseRowOptionsBase & UseRowOptionsDefaulValues<TTableDef>,
47
53
  ): UseRowResult<TTableDef> => {
48
54
  const sqliteTableDef = table.schema
49
- const { defaultValues } = options ?? {}
55
+ const id = typeof idOrOptions === 'string' ? idOrOptions : undefined
56
+ const options: (UseRowOptionsBase & UseRowOptionsDefaulValues<TTableDef>) | undefined =
57
+ typeof idOrOptions === 'string' ? options_ : idOrOptions
58
+ const { defaultValues, dbGraph } = options ?? {}
50
59
  type TComponentState = SqliteDsl.FromColumns.RowDecoded<TTableDef['schema']['columns']>
51
60
 
52
61
  const { store } = useStore()
@@ -74,13 +83,13 @@ export const useRow: {
74
83
  const otelContext = otel.trace.setSpan(otel.context.active(), span)
75
84
 
76
85
  const query$ = table.options.isSingleton
77
- ? rowQuery({ table, store, otelContext, defaultValues } as RowQueryArgs<TTableDef>)
78
- : rowQuery({ table, store, id, otelContext, defaultValues } as RowQueryArgs<TTableDef>)
86
+ ? rowQuery({ table, store, otelContext, defaultValues, dbGraph } as RowQueryArgs<TTableDef>)
87
+ : rowQuery({ table, store, id, otelContext, defaultValues, dbGraph } as RowQueryArgs<TTableDef>)
79
88
 
80
89
  rcCache.set(table, id ?? 'singleton', query$, reactId, otelContext, span)
81
90
 
82
91
  return { query$, otelContext }
83
- }, [table, id, reactId, store, defaultValues])
92
+ }, [table, id, reactId, store, defaultValues, dbGraph])
84
93
 
85
94
  React.useEffect(
86
95
  () => () => {
package/src/reactive.ts CHANGED
@@ -24,9 +24,9 @@
24
24
  /* eslint-disable prefer-arrow/prefer-arrow-functions */
25
25
 
26
26
  import type { PrettifyFlat } from '@livestore/utils'
27
- import { pick } from '@livestore/utils'
27
+ import { pick, shouldNeverHappen } from '@livestore/utils'
28
28
  import type * as otel from '@opentelemetry/api'
29
- import { isEqual, uniqueId } from 'lodash-es'
29
+ import { isEqual } from 'lodash-es'
30
30
 
31
31
  import { BoundArray } from './utils/bounded-collections.js'
32
32
  // import { getDurationMsFromSpan } from './otel.js'
@@ -40,24 +40,27 @@ export type Ref<T, TContext, TDebugRefreshReason extends DebugRefreshReason> = {
40
40
  _tag: 'ref'
41
41
  id: string
42
42
  isDirty: false
43
+ isDestroyed: boolean
43
44
  previousResult: T
44
45
  computeResult: () => T
45
46
  sub: Set<Atom<any, TContext, TDebugRefreshReason>> // always empty
46
- super: Set<Atom<any, TContext, TDebugRefreshReason> | Effect>
47
+ super: Set<Thunk<any, TContext, TDebugRefreshReason> | Effect>
47
48
  label?: string
48
49
  /** Container for meta information (e.g. the LiveStore Store) */
49
50
  meta?: any
50
51
  equal: (a: T, b: T) => boolean
52
+ refreshes: number
51
53
  }
52
54
 
53
55
  export type Thunk<TResult, TContext, TDebugRefreshReason extends DebugRefreshReason> = {
54
56
  _tag: 'thunk'
55
57
  id: string
56
58
  isDirty: boolean
59
+ isDestroyed: boolean
57
60
  computeResult: (otelContext?: otel.Context, debugRefreshReason?: TDebugRefreshReason) => TResult
58
61
  previousResult: TResult | NOT_REFRESHED_YET
59
62
  sub: Set<Atom<any, TContext, TDebugRefreshReason>>
60
- super: Set<Atom<any, TContext, TDebugRefreshReason> | Effect>
63
+ super: Set<Thunk<any, TContext, TDebugRefreshReason> | Effect>
61
64
  label?: string
62
65
  /** Container for meta information (e.g. the LiveStore Store) */
63
66
  meta?: any
@@ -74,11 +77,17 @@ export type Atom<T, TContext, TDebugRefreshReason extends DebugRefreshReason> =
74
77
  export type Effect = {
75
78
  _tag: 'effect'
76
79
  id: string
80
+ isDestroyed: boolean
77
81
  doEffect: (otelContext?: otel.Context) => void
78
82
  sub: Set<Atom<any, TODO, TODO>>
79
83
  label?: string
84
+ invocations: number
80
85
  }
81
86
 
87
+ export type Node<T, TContext, TDebugRefreshReason extends DebugRefreshReason> =
88
+ | Atom<T, TContext, TDebugRefreshReason>
89
+ | Effect
90
+
82
91
  export type DebugThunkInfo<T extends string = string> = {
83
92
  _tag: T
84
93
  durationMs: number
@@ -133,14 +142,25 @@ export type SerializedAtom = Readonly<
133
142
  >
134
143
  >
135
144
 
145
+ export type SerializedEffect = Readonly<
146
+ PrettifyFlat<
147
+ Pick<Effect, '_tag' | 'id' | 'label'> & {
148
+ sub: ReadonlyArray<string>
149
+ }
150
+ >
151
+ >
152
+
136
153
  type ReactiveGraphSnapshot = {
137
154
  readonly atoms: ReadonlyArray<SerializedAtom>
155
+ readonly effects: ReadonlyArray<SerializedEffect>
138
156
  /** IDs of deferred effects */
139
157
  readonly deferredEffects: ReadonlyArray<string>
140
158
  }
141
159
 
142
- const uniqueNodeId = () => uniqueId('node-')
143
- const uniqueRefreshInfoId = () => uniqueId('refresh-info-')
160
+ let nodeIdCounter = 0
161
+ const uniqueNodeId = () => `node-${++nodeIdCounter}`
162
+ let refreshInfoIdCounter = 0
163
+ const uniqueRefreshInfoId = () => `refresh-info-${++refreshInfoIdCounter}`
144
164
 
145
165
  const serializeAtom = (atom: Atom<any, unknown, any>): SerializedAtom => ({
146
166
  ...pick(atom, ['_tag', 'id', 'label', 'meta', 'isDirty']),
@@ -148,12 +168,23 @@ const serializeAtom = (atom: Atom<any, unknown, any>): SerializedAtom => ({
148
168
  super: Array.from(atom.super).map((a) => a.id),
149
169
  })
150
170
 
171
+ const serializeEffect = (effect: Effect): SerializedEffect => ({
172
+ ...pick(effect, ['_tag', 'id', 'label']),
173
+ sub: Array.from(effect.sub).map((a) => a.id),
174
+ })
175
+
176
+ let globalGraphIdCounter = 0
177
+ const uniqueGraphId = () => `graph-${++globalGraphIdCounter}`
178
+
151
179
  export class ReactiveGraph<
152
180
  TDebugRefreshReason extends DebugRefreshReason,
153
181
  TDebugThunkInfo extends DebugThunkInfo,
154
182
  TContext = {},
155
183
  > {
184
+ id = uniqueGraphId()
185
+
156
186
  readonly atoms: Set<Atom<any, TContext, TDebugRefreshReason>> = new Set()
187
+ readonly effects: Set<Effect> = new Set()
157
188
  effectsWrapper: (runEffects: () => void) => void
158
189
 
159
190
  context: TContext | undefined
@@ -166,6 +197,8 @@ export class ReactiveGraph<
166
197
 
167
198
  private deferredEffects: Map<Effect, Set<TDebugRefreshReason>> = new Map()
168
199
 
200
+ private refreshCallbacks: Set<() => void> = new Set()
201
+
169
202
  constructor(options: ReactiveGraphOptions) {
170
203
  this.effectsWrapper = options?.effectsWrapper ?? ((runEffects: () => void) => runEffects())
171
204
  }
@@ -178,6 +211,7 @@ export class ReactiveGraph<
178
211
  _tag: 'ref',
179
212
  id: uniqueNodeId(),
180
213
  isDirty: false,
214
+ isDestroyed: false,
181
215
  previousResult: val,
182
216
  computeResult: () => ref.previousResult,
183
217
  sub: new Set(),
@@ -185,6 +219,7 @@ export class ReactiveGraph<
185
219
  label: options?.label,
186
220
  meta: options?.meta,
187
221
  equal: options?.equal ?? isEqual,
222
+ refreshes: 0,
188
223
  }
189
224
 
190
225
  this.atoms.add(ref)
@@ -212,6 +247,7 @@ export class ReactiveGraph<
212
247
  id: uniqueNodeId(),
213
248
  previousResult: NOT_REFRESHED_YET,
214
249
  isDirty: true,
250
+ isDestroyed: false,
215
251
  computeResult: (otelContext, debugRefreshReason) => {
216
252
  if (thunk.isDirty) {
217
253
  const neededCurrentRefresh = this.currentDebugRefresh === undefined
@@ -235,7 +271,7 @@ export class ReactiveGraph<
235
271
  const result = getResult(
236
272
  getAtom as GetAtom,
237
273
  setDebugInfo,
238
- this.context ?? throwContextNotSetError(),
274
+ this.context ?? throwContextNotSetError(this),
239
275
  otelContext,
240
276
  )
241
277
 
@@ -288,24 +324,38 @@ export class ReactiveGraph<
288
324
  return thunk
289
325
  }
290
326
 
291
- destroy(node: Atom<any, TContext, TDebugRefreshReason> | Effect) {
327
+ destroyNode(node: Node<any, TContext, TDebugRefreshReason>) {
328
+ // console.debug(`destroying node (${node._tag})`, node.id, node.label)
329
+
292
330
  // Recursively destroy any supercomputations
293
331
  if (node._tag === 'ref' || node._tag === 'thunk') {
294
332
  for (const superComp of node.super) {
295
- this.destroy(superComp)
333
+ this.destroyNode(superComp)
296
334
  }
297
335
  }
298
336
 
299
337
  // Destroy this node
300
- for (const subComp of node.sub) {
301
- this.removeEdge(node, subComp)
338
+ if (node._tag !== 'ref') {
339
+ for (const subComp of node.sub) {
340
+ this.removeEdge(node, subComp)
341
+ }
302
342
  }
303
343
 
304
344
  if (node._tag === 'effect') {
305
345
  this.deferredEffects.delete(node)
346
+ this.effects.delete(node)
306
347
  } else {
307
348
  this.atoms.delete(node)
308
349
  }
350
+
351
+ node.isDestroyed = true
352
+ }
353
+
354
+ destroy() {
355
+ // NOTE we don't need to sort the atoms first, as `destroyNode` will recursively destroy all supercomputations
356
+ for (const node of this.atoms) {
357
+ this.destroyNode(node)
358
+ }
309
359
  }
310
360
 
311
361
  makeEffect(
@@ -315,7 +365,10 @@ export class ReactiveGraph<
315
365
  const effect: Effect = {
316
366
  _tag: 'effect',
317
367
  id: uniqueNodeId(),
368
+ isDestroyed: false,
318
369
  doEffect: (otelContext) => {
370
+ effect.invocations++
371
+
319
372
  // NOTE we're not tracking any debug refresh info for effects as they're tracked by the thunks they depend on
320
373
 
321
374
  // Reset previous subcomputations as we're about to re-add them as part of the `doEffect` call below
@@ -330,8 +383,11 @@ export class ReactiveGraph<
330
383
  },
331
384
  sub: new Set(),
332
385
  label: options?.label,
386
+ invocations: 0,
333
387
  }
334
388
 
389
+ this.effects.add(effect)
390
+
335
391
  return effect
336
392
  }
337
393
 
@@ -362,6 +418,7 @@ export class ReactiveGraph<
362
418
  const effectsToRefresh = new Set<Effect>()
363
419
  for (const [ref, val] of refs) {
364
420
  ref.previousResult = val
421
+ ref.refreshes++
365
422
 
366
423
  markSuperCompDirtyRec(ref, effectsToRefresh)
367
424
  }
@@ -412,6 +469,10 @@ export class ReactiveGraph<
412
469
  graphSnapshot: this.getSnapshot(),
413
470
  }
414
471
  this.debugRefreshInfos.push(refreshDebugInfo)
472
+
473
+ for (const cb of this.refreshCallbacks) {
474
+ cb()
475
+ }
415
476
  })
416
477
  }
417
478
 
@@ -432,7 +493,7 @@ export class ReactiveGraph<
432
493
  }
433
494
 
434
495
  addEdge(
435
- superComp: Atom<any, TContext, TDebugRefreshReason> | Effect,
496
+ superComp: Thunk<any, TContext, TDebugRefreshReason> | Effect,
436
497
  subComp: Atom<any, TContext, TDebugRefreshReason>,
437
498
  ) {
438
499
  superComp.sub.add(subComp)
@@ -440,21 +501,40 @@ export class ReactiveGraph<
440
501
  }
441
502
 
442
503
  removeEdge(
443
- superComp: Atom<any, TContext, TDebugRefreshReason> | Effect,
504
+ superComp: Thunk<any, TContext, TDebugRefreshReason> | Effect,
444
505
  subComp: Atom<any, TContext, TDebugRefreshReason>,
445
506
  ) {
446
507
  superComp.sub.delete(subComp)
508
+ const effectsToRefresh = new Set<Effect>()
509
+ markSuperCompDirtyRec(subComp, effectsToRefresh)
510
+
511
+ for (const effect of effectsToRefresh) {
512
+ this.deferredEffects.set(effect, new Set())
513
+ }
514
+
447
515
  subComp.super.delete(superComp)
448
516
  }
449
517
 
450
518
  getSnapshot = (): ReactiveGraphSnapshot => ({
451
519
  atoms: Array.from(this.atoms).map(serializeAtom),
520
+ effects: Array.from(this.effects).map(serializeEffect),
452
521
  deferredEffects: Array.from(this.deferredEffects.keys()).map((_) => _.id),
453
522
  })
523
+
524
+ subscribeToRefresh = (cb: () => void) => {
525
+ this.refreshCallbacks.add(cb)
526
+ return () => {
527
+ this.refreshCallbacks.delete(cb)
528
+ }
529
+ }
454
530
  }
455
531
 
456
532
  const compute = <T>(atom: Atom<T, unknown, any>, otelContext: otel.Context): T => {
457
533
  // const __getResult = atom._tag === 'thunk' ? atom.__getResult.toString() : ''
534
+ if (atom.isDestroyed) {
535
+ shouldNeverHappen(`LiveStore Error: Attempted to compute destroyed atom`)
536
+ }
537
+
458
538
  if (atom.isDirty) {
459
539
  // console.log('atom is dirty', atom.id, atom.label ?? '', atom._tag, __getResult)
460
540
  const result = atom.computeResult(otelContext)
@@ -469,7 +549,7 @@ const compute = <T>(atom: Atom<T, unknown, any>, otelContext: otel.Context): T =
469
549
 
470
550
  const markSuperCompDirtyRec = <T>(atom: Atom<T, unknown, any>, effectsToRefresh: Set<Effect>) => {
471
551
  for (const superComp of atom.super) {
472
- if (superComp._tag === 'thunk' || superComp._tag === 'ref') {
552
+ if (superComp._tag === 'thunk') {
473
553
  superComp.isDirty = true
474
554
  markSuperCompDirtyRec(superComp, effectsToRefresh)
475
555
  } else {
@@ -478,6 +558,6 @@ const markSuperCompDirtyRec = <T>(atom: Atom<T, unknown, any>, effectsToRefresh:
478
558
  }
479
559
  }
480
560
 
481
- export const throwContextNotSetError = (): never => {
482
- throw new Error(`LiveStore Error: \`context\` not set on ReactiveGraph`)
561
+ export const throwContextNotSetError = (graph: ReactiveGraph<any, any, any>): never => {
562
+ throw new Error(`LiveStore Error: \`context\` not set on ReactiveGraph (${graph.id})`)
483
563
  }
@@ -1,11 +1,19 @@
1
1
  import type * as otel from '@opentelemetry/api'
2
+ import ReactDOM from 'react-dom'
2
3
 
3
- import { dbGraph } from '../global-state.js'
4
4
  import type { StackInfo } from '../react/utils/stack-info.js'
5
- import { type Atom, type GetAtom, throwContextNotSetError, type Thunk } from '../reactive.js'
6
- import type { RefreshReason, Store } from '../store.js'
5
+ import { type Atom, type GetAtom, ReactiveGraph, throwContextNotSetError, type Thunk } from '../reactive.js'
6
+ import type { QueryDebugInfo, RefreshReason, Store } from '../store.js'
7
7
  import type { LiveStoreJSQuery } from './js.js'
8
8
 
9
+ export type DbGraph = ReactiveGraph<RefreshReason, QueryDebugInfo, DbContext>
10
+
11
+ export const makeDbGraph = (): DbGraph =>
12
+ new ReactiveGraph<RefreshReason, QueryDebugInfo, DbContext>({
13
+ // TODO also find a better way to only use this effects wrapper when used in a React app
14
+ effectsWrapper: (run) => ReactDOM.unstable_batchedUpdates(() => run()),
15
+ })
16
+
9
17
  export type DbContext = {
10
18
  store: Store
11
19
  otelTracer: otel.Tracer
@@ -41,6 +49,8 @@ export abstract class LiveStoreQueryBase<TResult> implements ILiveStoreQuery<TRe
41
49
 
42
50
  activeSubscriptions: Set<StackInfo> = new Set()
43
51
 
52
+ protected abstract dbGraph: DbGraph
53
+
44
54
  get runs() {
45
55
  return this.results$.recomputations
46
56
  }
@@ -61,7 +71,8 @@ export abstract class LiveStoreQueryBase<TResult> implements ILiveStoreQuery<TRe
61
71
  onUnsubsubscribe?: () => void,
62
72
  options?: { label?: string; otelContext?: otel.Context } | undefined,
63
73
  ): (() => void) =>
64
- dbGraph.context?.store.subscribe(this, onNewValue, onUnsubsubscribe, options) ?? throwContextNotSetError()
74
+ this.dbGraph.context?.store.subscribe(this, onNewValue, onUnsubsubscribe, options) ??
75
+ throwContextNotSetError(this.dbGraph)
65
76
  }
66
77
 
67
78
  export type GetAtomResult = <T>(atom: Atom<T, any, RefreshReason> | LiveStoreJSQuery<T>) => T