@livestore/livestore 0.0.46-dev.4 → 0.0.47-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.
Files changed (108) hide show
  1. package/README.md +10 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/react/fixture.d.ts +28 -12
  4. package/dist/__tests__/react/fixture.d.ts.map +1 -1
  5. package/dist/__tests__/react/fixture.js +27 -3
  6. package/dist/__tests__/react/fixture.js.map +1 -1
  7. package/dist/__tests__/react/utils/otel.d.ts +10 -0
  8. package/dist/__tests__/react/utils/otel.d.ts.map +1 -0
  9. package/dist/__tests__/react/utils/otel.js +42 -0
  10. package/dist/__tests__/react/utils/otel.js.map +1 -0
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/react/LiveStoreProvider.js +39 -6
  15. package/dist/react/LiveStoreProvider.js.map +1 -1
  16. package/dist/react/LiveStoreProvider.test.d.ts +2 -0
  17. package/dist/react/LiveStoreProvider.test.d.ts.map +1 -0
  18. package/dist/react/LiveStoreProvider.test.js +40 -0
  19. package/dist/react/LiveStoreProvider.test.js.map +1 -0
  20. package/dist/react/components/LiveList.d.ts +21 -0
  21. package/dist/react/components/LiveList.d.ts.map +1 -0
  22. package/dist/react/components/LiveList.js +31 -0
  23. package/dist/react/components/LiveList.js.map +1 -0
  24. package/dist/react/index.d.ts +1 -1
  25. package/dist/react/index.d.ts.map +1 -1
  26. package/dist/react/index.js +1 -1
  27. package/dist/react/index.js.map +1 -1
  28. package/dist/react/useAtom.d.ts +1 -1
  29. package/dist/react/useAtom.d.ts.map +1 -1
  30. package/dist/react/useAtom.js +6 -1
  31. package/dist/react/useAtom.js.map +1 -1
  32. package/dist/react/useQuery.d.ts +4 -1
  33. package/dist/react/useQuery.d.ts.map +1 -1
  34. package/dist/react/useQuery.js +24 -19
  35. package/dist/react/useQuery.js.map +1 -1
  36. package/dist/react/useQuery.test.js +11 -11
  37. package/dist/react/useQuery.test.js.map +1 -1
  38. package/dist/react/useRow.d.ts.map +1 -1
  39. package/dist/react/useRow.js +14 -69
  40. package/dist/react/useRow.js.map +1 -1
  41. package/dist/react/useRow.test.js +440 -28
  42. package/dist/react/useRow.test.js.map +1 -1
  43. package/dist/react/useTemporaryQuery.d.ts +15 -3
  44. package/dist/react/useTemporaryQuery.d.ts.map +1 -1
  45. package/dist/react/useTemporaryQuery.js +60 -27
  46. package/dist/react/useTemporaryQuery.js.map +1 -1
  47. package/dist/react/useTemporaryQuery.test.js +10 -9
  48. package/dist/react/useTemporaryQuery.test.js.map +1 -1
  49. package/dist/reactive.d.ts +23 -5
  50. package/dist/reactive.d.ts.map +1 -1
  51. package/dist/reactive.js +44 -11
  52. package/dist/reactive.js.map +1 -1
  53. package/dist/reactive.test.js +1 -1
  54. package/dist/reactive.test.js.map +1 -1
  55. package/dist/reactiveQueries/base-class.d.ts +1 -1
  56. package/dist/reactiveQueries/base-class.d.ts.map +1 -1
  57. package/dist/reactiveQueries/base-class.js.map +1 -1
  58. package/dist/reactiveQueries/graphql.d.ts +2 -2
  59. package/dist/reactiveQueries/graphql.d.ts.map +1 -1
  60. package/dist/reactiveQueries/graphql.js +21 -11
  61. package/dist/reactiveQueries/graphql.js.map +1 -1
  62. package/dist/reactiveQueries/sql.d.ts +1 -1
  63. package/dist/reactiveQueries/sql.d.ts.map +1 -1
  64. package/dist/reactiveQueries/sql.js +15 -11
  65. package/dist/reactiveQueries/sql.js.map +1 -1
  66. package/dist/reactiveQueries/sql.test.js +1 -40
  67. package/dist/reactiveQueries/sql.test.js.map +1 -1
  68. package/dist/row-query.d.ts.map +1 -1
  69. package/dist/row-query.js +3 -1
  70. package/dist/row-query.js.map +1 -1
  71. package/dist/store.d.ts +7 -5
  72. package/dist/store.d.ts.map +1 -1
  73. package/dist/store.js +50 -38
  74. package/dist/store.js.map +1 -1
  75. package/package.json +11 -13
  76. package/src/__tests__/react/fixture.tsx +35 -2
  77. package/src/__tests__/react/utils/otel.ts +61 -0
  78. package/src/index.ts +12 -1
  79. package/src/react/LiveStoreProvider.test.tsx +63 -0
  80. package/src/react/LiveStoreProvider.tsx +42 -7
  81. package/src/react/components/LiveList.tsx +84 -0
  82. package/src/react/index.ts +1 -1
  83. package/src/react/useAtom.ts +6 -2
  84. package/src/react/useQuery.test.tsx +11 -11
  85. package/src/react/useQuery.ts +29 -22
  86. package/src/react/useRow.test.tsx +502 -30
  87. package/src/react/useRow.ts +19 -107
  88. package/src/react/useTemporaryQuery.test.tsx +17 -16
  89. package/src/react/useTemporaryQuery.ts +96 -28
  90. package/src/reactive.test.ts +1 -1
  91. package/src/reactive.ts +76 -15
  92. package/src/reactiveQueries/base-class.ts +2 -1
  93. package/src/reactiveQueries/graphql.ts +26 -16
  94. package/src/reactiveQueries/sql.test.ts +1 -54
  95. package/src/reactiveQueries/sql.ts +20 -14
  96. package/src/row-query.ts +3 -1
  97. package/src/store.ts +71 -49
  98. package/tsconfig.json +0 -1
  99. package/dist/react/components/DiffableList.d.ts +0 -20
  100. package/dist/react/components/DiffableList.d.ts.map +0 -1
  101. package/dist/react/components/DiffableList.js +0 -113
  102. package/dist/react/components/DiffableList.js.map +0 -1
  103. package/dist/react/utils/useCleanup.d.ts +0 -7
  104. package/dist/react/utils/useCleanup.d.ts.map +0 -1
  105. package/dist/react/utils/useCleanup.js +0 -19
  106. package/dist/react/utils/useCleanup.js.map +0 -1
  107. package/src/react/components/DiffableList.tsx +0 -192
  108. package/src/react/utils/useCleanup.ts +0 -25
@@ -1,5 +1,4 @@
1
1
  import { DbSchema } from '@livestore/common/schema'
2
- import * as otel from '@opentelemetry/api'
3
2
  import type { SqliteDsl } from 'effect-db-schema'
4
3
  import { mapValues } from 'lodash-es'
5
4
  import React from 'react'
@@ -11,7 +10,7 @@ import type { RowResult } from '../row-query.js'
11
10
  import { rowQuery } from '../row-query.js'
12
11
  import { useStore } from './LiveStoreContext.js'
13
12
  import { useQueryRef } from './useQuery.js'
14
- import { useCleanup } from './utils/useCleanup.js'
13
+ import { useMakeTemporaryQuery } from './useTemporaryQuery.js'
15
14
 
16
15
  export type UseRowResult<TTableDef extends DbSchema.TableDef> = [
17
16
  row: RowResult<TTableDef>,
@@ -73,52 +72,24 @@ export const useRow: {
73
72
 
74
73
  const { store } = useStore()
75
74
 
76
- const reactId = React.useId()
77
-
78
- const { query$, otelContext } = React.useMemo(() => {
79
- const cachedItem = rcCache.get(table, id ?? 'singleton')
80
- if (cachedItem !== undefined) {
81
- cachedItem.reactIds.add(reactId)
82
- cachedItem.span.addEvent('new-subscriber', { reactId })
83
-
84
- return {
85
- query$: cachedItem.query$ as LiveQuery<RowResult<TTableDef>, QueryInfo>,
86
- otelContext: cachedItem.otelContext,
87
- }
88
- }
89
-
90
- const span = store.otel.tracer.startSpan(
91
- `LiveStore:useState:${table.sqliteDef.name}${id === undefined ? '' : `:${id}`}`,
92
- { attributes: { id } },
93
- store.otel.queriesSpanContext,
94
- )
95
-
96
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
97
-
98
- const query$ = DbSchema.tableIsSingleton(table)
99
- ? (rowQuery(table, { otelContext, dbGraph }) as LiveQuery<RowResult<TTableDef>, QueryInfo>)
100
- : (rowQuery(table as TTableDef & { options: { isSingleton: false } }, id!, {
101
- otelContext,
102
- defaultValues: defaultValues!,
103
- dbGraph,
104
- }) as any as LiveQuery<RowResult<TTableDef>, QueryInfo>)
105
-
106
- rcCache.set(table, id ?? 'singleton', query$, reactId, otelContext, span)
107
-
108
- return { query$, otelContext }
109
- }, [table, id, reactId, store, defaultValues, dbGraph])
110
-
111
- useCleanup(
112
- React.useCallback(() => {
113
- const cachedItem = rcCache.get(table, id ?? 'singleton')!
114
-
115
- cachedItem.reactIds.delete(reactId)
116
- if (cachedItem.reactIds.size === 0) {
117
- rcCache.delete(cachedItem.query$)
118
- cachedItem.query$.destroy()
119
- cachedItem.span.end()
120
- }
121
- }, [table, id, reactId]),
75
+ // console.debug('useRow', table.sqliteDef.name, id)
76
+
77
+ const { query$, otelContext } = useMakeTemporaryQuery(
78
+ (otelContext) =>
79
+ DbSchema.tableIsSingleton(table)
80
+ ? (rowQuery(table, { otelContext, dbGraph }) as LiveQuery<RowResult<TTableDef>, QueryInfo>)
81
+ : (rowQuery(table as TTableDef & { options: { isSingleton: false } }, id!, {
82
+ otelContext,
83
+ defaultValues: defaultValues!,
84
+ dbGraph,
85
+ }) as any as LiveQuery<RowResult<TTableDef>, QueryInfo>),
86
+ [id!, table.sqliteDef.name],
87
+ {
88
+ otel: {
89
+ spanName: `LiveStore:useRow:${table.sqliteDef.name}${id === undefined ? '' : `:${id}`}`,
90
+ attributes: { id },
91
+ },
92
+ },
122
93
  )
123
94
 
124
95
  const query$Ref = useQueryRef(query$, otelContext) as React.MutableRefObject<RowResult<TTableDef>>
@@ -179,62 +150,3 @@ export type StateSetters<TTableDef extends DbSchema.TableDef> = TTableDef['isSin
179
150
  } & {
180
151
  setMany: Dispatch<SetStateAction<Partial<RowResult<TTableDef>>>>
181
152
  }
182
-
183
- /** Reference counted cache for `query$` and otel context */
184
- class RCCache {
185
- private readonly cache = new Map<
186
- DbSchema.TableDef,
187
- Map<
188
- string,
189
- {
190
- reactIds: Set<string>
191
- span: otel.Span
192
- otelContext: otel.Context
193
- query$: LiveQuery<any, any>
194
- }
195
- >
196
- >()
197
- private reverseCache = new Map<LiveQuery<any, any>, [DbSchema.TableDef, string]>()
198
-
199
- get = (table: DbSchema.TableDef, id: string) => {
200
- const queries = this.cache.get(table)
201
- if (queries === undefined) return undefined
202
- return queries.get(id)
203
- }
204
-
205
- set = (
206
- table: DbSchema.TableDef,
207
- id: string,
208
- query$: LiveQuery<any, any>,
209
- reactId: string,
210
- otelContext: otel.Context,
211
- span: otel.Span,
212
- ) => {
213
- let queries = this.cache.get(table)
214
- if (queries === undefined) {
215
- queries = new Map()
216
- this.cache.set(table, queries)
217
- }
218
- queries.set(id, { query$, otelContext, span, reactIds: new Set([reactId]) })
219
- this.reverseCache.set(query$, [table, id])
220
- }
221
-
222
- delete = (query$: LiveQuery<any, any>) => {
223
- const item = this.reverseCache.get(query$)
224
- if (item === undefined) return
225
-
226
- const [table, id] = item
227
- const queries = this.cache.get(table)
228
- if (queries === undefined) return
229
-
230
- queries.delete(id)
231
-
232
- if (queries.size === 0) {
233
- this.cache.delete(table)
234
- }
235
-
236
- this.reverseCache.delete(query$)
237
- }
238
- }
239
-
240
- const rcCache = new RCCache()
@@ -1,5 +1,4 @@
1
1
  import { renderHook } from '@testing-library/react'
2
- import React from 'react'
3
2
  import { describe, expect, it } from 'vitest'
4
3
 
5
4
  import { makeTodoMvc, parseTodos } from '../__tests__/react/fixture.js'
@@ -9,43 +8,45 @@ import * as LiveStoreReact from './index.js'
9
8
 
10
9
  describe('useTemporaryQuery', () => {
11
10
  it('simple', async () => {
12
- let renderCount = 0
11
+ const { wrapper, store, cud, makeRenderCount } = await makeTodoMvc()
13
12
 
14
- const { wrapper, store, cud } = await makeTodoMvc()
13
+ const renderCount = makeRenderCount()
15
14
 
16
15
  store.mutate(
17
16
  cud.todos.insert({ id: 't1', text: 'buy milk', completed: false }),
18
17
  cud.todos.insert({ id: 't2', text: 'buy bread', completed: false }),
19
18
  )
20
19
 
21
- const queryMap = new Map<string, LiveStore.LiveQuery<any, any>>()
20
+ const queryMap = new Map<string, LiveStore.LiveQuery<any>>()
22
21
 
23
- const { rerender, result } = renderHook(
22
+ const { rerender, result, unmount } = renderHook(
24
23
  (id: string) => {
25
- renderCount++
26
-
27
- return LiveStoreReact.useTemporaryQuery(
28
- React.useCallback(() => {
29
- const query$ = querySQL(`select * from todos where id = '${id}'`, { map: parseTodos })
30
- queryMap.set(id, query$)
31
- return query$
32
- }, [id]),
33
- )
24
+ renderCount.inc()
25
+
26
+ return LiveStoreReact.useTemporaryQuery(() => {
27
+ const query$ = querySQL(`select * from todos where id = '${id}'`, { map: parseTodos })
28
+ queryMap.set(id, query$)
29
+ return query$
30
+ }, id)
34
31
  },
35
32
  { wrapper, initialProps: 't1' },
36
33
  )
37
34
 
38
35
  expect(result.current.length).toBe(1)
39
36
  expect(result.current[0]!.text).toBe('buy milk')
40
- expect(renderCount).toBe(1)
37
+ expect(renderCount.val).toBe(1)
41
38
  expect(queryMap.get('t1')!.runs).toBe(1)
42
39
 
43
40
  rerender('t2')
44
41
 
45
42
  expect(result.current.length).toBe(1)
46
43
  expect(result.current[0]!.text).toBe('buy bread')
47
- expect(renderCount).toBe(2)
44
+ expect(renderCount.val).toBe(2)
48
45
  expect(queryMap.get('t1')!.runs).toBe(1)
49
46
  expect(queryMap.get('t2')!.runs).toBe(1)
47
+
48
+ unmount()
49
+
50
+ expect(queryMap.get('t2')!.runs).toBe(1)
50
51
  })
51
52
  })
@@ -1,53 +1,121 @@
1
+ import * as otel from '@opentelemetry/api'
1
2
  import React from 'react'
2
3
 
4
+ import type { QueryInfo } from '../query-info.js'
3
5
  import type { LiveQuery } from '../reactiveQueries/base-class.js'
6
+ import { useStore } from './LiveStoreContext.js'
4
7
  import { useQueryRef } from './useQuery.js'
5
- import { useCleanup } from './utils/useCleanup.js'
6
8
 
7
- /**
8
- * This is needed because the `React.useMemo` call below, can sometimes be called multiple times 🤷.
9
- * The map entry is being removed again in the `React.useEffect` call below.
10
- */
11
- const queryCache = new Map<() => LiveQuery<any>, { reactIds: Set<string>; query$: LiveQuery<any> }>()
9
+ // NOTE Given `useMemo` will be called multiple times (e.g. when using React Strict mode or Fast Refresh),
10
+ // we are using this cache to avoid starting multiple queries/spans for the same component.
11
+ // This is somewhat against some recommended React best practices, but it should be fine in our case below.
12
+ // Please definitely open an issue if you see or run into any problems with this approach!
13
+ const cache = new Map<
14
+ string,
15
+ {
16
+ rc: number
17
+ query$: LiveQuery<any, any>
18
+ span: otel.Span
19
+ otelContext: otel.Context
20
+ }
21
+ >()
22
+
23
+ export type DepKey = string | number | ReadonlyArray<string | number>
12
24
 
13
25
  /**
14
26
  * Creates a query, subscribes and destroys it when the component unmounts.
15
27
  *
16
- * Make sure `makeQuery` is a memoized function.
28
+ * The `key` is used to determine whether the a new query should be created or if the existing one should be reused.
17
29
  */
18
- export const useTemporaryQuery = <TResult>(makeQuery: () => LiveQuery<TResult>): TResult =>
19
- useTemporaryQueryRef(makeQuery).current
30
+ export const useTemporaryQuery = <TResult>(makeQuery: () => LiveQuery<TResult>, key: DepKey): TResult =>
31
+ useTemporaryQueryRef(makeQuery, key).current
32
+
33
+ export const useTemporaryQueryRef = <TResult>(
34
+ makeQuery: () => LiveQuery<TResult>,
35
+ key: DepKey,
36
+ ): React.MutableRefObject<TResult> => {
37
+ const { query$ } = useMakeTemporaryQuery(makeQuery, key)
38
+
39
+ return useQueryRef(query$)
40
+ }
41
+
42
+ export const useMakeTemporaryQuery = <TResult, TQueryInfo extends QueryInfo>(
43
+ makeQuery: (otelContext: otel.Context) => LiveQuery<TResult, TQueryInfo>,
44
+ key: DepKey,
45
+ options?: {
46
+ otel?: {
47
+ spanName?: string
48
+ attributes?: otel.Attributes
49
+ }
50
+ },
51
+ ): { query$: LiveQuery<TResult, TQueryInfo>; otelContext: otel.Context } => {
52
+ const { store } = useStore()
53
+ const fullKey = React.useMemo(
54
+ // NOTE We're using the `makeQuery` function body string to make sure the key is unique across the app
55
+ // TODO we should figure out whether this could cause some problems and/or if there's a better way to do this
56
+ () => (Array.isArray(key) ? key.join('-') : key) + '-' + store.graph.id + '-' + makeQuery.toString(),
57
+ [key, makeQuery, store.graph.id],
58
+ )
59
+ const fullKeyRef = React.useRef<string>()
60
+
61
+ const { query$, otelContext } = React.useMemo(() => {
62
+ if (fullKeyRef.current !== undefined && fullKeyRef.current !== fullKey) {
63
+ // console.debug('fullKey changed, destroying previous', fullKeyRef.current.split('-')[0]!, fullKey.split('-')[0]!)
20
64
 
21
- export const useTemporaryQueryRef = <TResult>(makeQuery: () => LiveQuery<TResult>): React.MutableRefObject<TResult> => {
22
- const reactId = React.useId()
65
+ const cachedItem = cache.get(fullKeyRef.current)
66
+ if (cachedItem !== undefined) {
67
+ cachedItem.rc--
23
68
 
24
- const query$ = React.useMemo(() => {
25
- const cachedItem = queryCache.get(makeQuery)
69
+ if (cachedItem.rc === 0) {
70
+ cachedItem.query$.destroy()
71
+ cachedItem.span.end()
72
+ cache.delete(fullKeyRef.current)
73
+ }
74
+ }
75
+ }
76
+
77
+ const cachedItem = cache.get(fullKey)
26
78
  if (cachedItem !== undefined) {
27
- cachedItem.reactIds.add(reactId)
79
+ cachedItem.rc++
28
80
 
29
- return cachedItem.query$
81
+ return cachedItem
30
82
  }
31
83
 
32
- const query$ = makeQuery()
84
+ const spanName = options?.otel?.spanName ?? `LiveStore:useTemporaryQuery:${key}`
85
+
86
+ const span = store.otel.tracer.startSpan(
87
+ spanName,
88
+ { attributes: options?.otel?.attributes },
89
+ store.otel.queriesSpanContext,
90
+ )
91
+
92
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
33
93
 
34
- queryCache.set(makeQuery, { reactIds: new Set([reactId]), query$ })
94
+ const query$ = makeQuery(otelContext)
35
95
 
36
- return query$
37
- }, [reactId, makeQuery])
96
+ cache.set(fullKey, { rc: 1, query$, span, otelContext })
38
97
 
39
- useCleanup(
40
- React.useCallback(() => {
41
- const cachedItem = queryCache.get(makeQuery)!
98
+ return { query$, otelContext }
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ }, [fullKey])
42
101
 
43
- cachedItem.reactIds.delete(reactId)
102
+ fullKeyRef.current = fullKey
44
103
 
45
- if (cachedItem.reactIds.size === 0) {
104
+ React.useEffect(() => {
105
+ return () => {
106
+ const cachedItem = cache.get(fullKey)
107
+ // NOTE in case the fullKey changed then the query was already destroyed in the useMemo above
108
+ if (cachedItem === undefined) return
109
+
110
+ cachedItem.rc--
111
+
112
+ if (cachedItem.rc === 0) {
46
113
  cachedItem.query$.destroy()
47
- queryCache.delete(makeQuery)
114
+ cachedItem.span.end()
115
+ cache.delete(fullKey)
48
116
  }
49
- }, [makeQuery, reactId]),
50
- )
117
+ }
118
+ }, [fullKey])
51
119
 
52
- return useQueryRef(query$)
120
+ return { query$, otelContext }
53
121
  }
@@ -245,7 +245,7 @@ describe('a trivial graph', () => {
245
245
  expect(e.isDirty).toBe(true)
246
246
 
247
247
  expect(() => c.computeResult()).toThrowErrorMatchingInlineSnapshot(
248
- `[Error: This should never happen LiveStore Error: Attempted to compute destroyed atom]`,
248
+ `[Error: This should never happen LiveStore Error: Attempted to compute destroyed ref (node-58): b]`,
249
249
  )
250
250
  })
251
251
  })
package/src/reactive.ts CHANGED
@@ -88,6 +88,12 @@ export type Node<T, TContext, TDebugRefreshReason extends DebugRefreshReason> =
88
88
  | Atom<T, TContext, TDebugRefreshReason>
89
89
  | Effect
90
90
 
91
+ export const isThunk = <T, TContext, TDebugRefreshReason extends DebugRefreshReason>(
92
+ obj: unknown,
93
+ ): obj is Thunk<T, TContext, TDebugRefreshReason> => {
94
+ return typeof obj === 'object' && obj !== null && '_tag' in obj && (obj as any)._tag === 'thunk'
95
+ }
96
+
91
97
  export type DebugThunkInfo<T extends string = string> = {
92
98
  _tag: T
93
99
  durationMs: number
@@ -133,9 +139,31 @@ const unknownRefreshReason = () => {
133
139
  return { _tag: 'unknown' as const }
134
140
  }
135
141
 
136
- export type SerializedAtom = Readonly<
142
+ export type EncodedOption<A> = { _tag: 'Some'; value?: A } | { _tag: 'None' }
143
+ const encodedOptionSome = <A>(value: A): EncodedOption<A> => ({ _tag: 'Some', value })
144
+ const encodedOptionNone = <A>(): EncodedOption<A> => ({ _tag: 'None' })
145
+
146
+ export type SerializedAtom = SerializedRef | SerializedThunk
147
+
148
+ export type SerializedRef = Readonly<
149
+ PrettifyFlat<
150
+ Pick<Ref<unknown, unknown, any>, '_tag' | 'id' | 'label' | 'meta' | 'isDirty' | 'isDestroyed' | 'refreshes'> & {
151
+ /** Is `None` if `getSnapshot` was called with `includeResults: false` which is the default */
152
+ previousResult: EncodedOption<string>
153
+ sub: ReadonlyArray<string>
154
+ super: ReadonlyArray<string>
155
+ }
156
+ >
157
+ >
158
+
159
+ export type SerializedThunk = Readonly<
137
160
  PrettifyFlat<
138
- Pick<Atom<unknown, unknown, any>, '_tag' | 'id' | 'label' | 'meta' | 'isDirty'> & {
161
+ Pick<
162
+ Thunk<unknown, unknown, any>,
163
+ '_tag' | 'id' | 'label' | 'meta' | 'isDirty' | 'isDestroyed' | 'recomputations'
164
+ > & {
165
+ /** Is `None` if `getSnapshot` was called with `includeResults: false` which is the default */
166
+ previousResult: EncodedOption<string>
139
167
  sub: ReadonlyArray<string>
140
168
  super: ReadonlyArray<string>
141
169
  }
@@ -144,13 +172,13 @@ export type SerializedAtom = Readonly<
144
172
 
145
173
  export type SerializedEffect = Readonly<
146
174
  PrettifyFlat<
147
- Pick<Effect, '_tag' | 'id' | 'label'> & {
175
+ Pick<Effect, '_tag' | 'id' | 'label' | 'invocations' | 'isDestroyed'> & {
148
176
  sub: ReadonlyArray<string>
149
177
  }
150
178
  >
151
179
  >
152
180
 
153
- type ReactiveGraphSnapshot = {
181
+ export type ReactiveGraphSnapshot = {
154
182
  readonly atoms: ReadonlyArray<SerializedAtom>
155
183
  readonly effects: ReadonlyArray<SerializedEffect>
156
184
  /** IDs of deferred effects */
@@ -267,7 +295,7 @@ export class ReactiveGraph<
267
295
  const resultChanged = thunk.equal(thunk.previousResult as T, result) === false
268
296
 
269
297
  const debugInfoForAtom = {
270
- atom: serializeAtom(thunk),
298
+ atom: serializeAtom(thunk, false),
271
299
  resultChanged,
272
300
  debugInfo: debugInfo ?? (unknownRefreshReason() as TDebugThunkInfo),
273
301
  } satisfies AtomDebugInfo<TDebugThunkInfo>
@@ -290,7 +318,7 @@ export class ReactiveGraph<
290
318
  refreshedAtoms,
291
319
  durationMs,
292
320
  completedTimestamp: Date.now(),
293
- graphSnapshot: this.getSnapshot(),
321
+ graphSnapshot: this.getSnapshot({ includeResults: false }),
294
322
  })
295
323
  }
296
324
 
@@ -455,13 +483,11 @@ export class ReactiveGraph<
455
483
  refreshedAtoms,
456
484
  durationMs,
457
485
  completedTimestamp: Date.now(),
458
- graphSnapshot: this.getSnapshot(),
486
+ graphSnapshot: this.getSnapshot({ includeResults: false }),
459
487
  }
460
488
  this.debugRefreshInfos.push(refreshDebugInfo)
461
489
 
462
- for (const cb of this.refreshCallbacks) {
463
- cb()
464
- }
490
+ this.runRefreshCallbacks()
465
491
  })
466
492
  }
467
493
 
@@ -481,12 +507,20 @@ export class ReactiveGraph<
481
507
  }
482
508
  }
483
509
 
510
+ runRefreshCallbacks = () => {
511
+ for (const cb of this.refreshCallbacks) {
512
+ cb()
513
+ }
514
+ }
515
+
484
516
  addEdge(
485
517
  superComp: Thunk<any, TContext, TDebugRefreshReason> | Effect,
486
518
  subComp: Atom<any, TContext, TDebugRefreshReason>,
487
519
  ) {
488
520
  superComp.sub.add(subComp)
489
521
  subComp.super.add(superComp)
522
+
523
+ this.runRefreshCallbacks()
490
524
  }
491
525
 
492
526
  removeEdge(
@@ -502,13 +536,16 @@ export class ReactiveGraph<
502
536
  }
503
537
 
504
538
  subComp.super.delete(superComp)
539
+
540
+ this.runRefreshCallbacks()
505
541
  }
506
542
 
507
543
  // NOTE This function is performance-optimized (i.e. not using `Array.from`)
508
- getSnapshot = (): ReactiveGraphSnapshot => {
544
+ getSnapshot = (opts?: { includeResults: boolean }): ReactiveGraphSnapshot => {
545
+ const { includeResults = false } = opts ?? {}
509
546
  const atoms: SerializedAtom[] = []
510
547
  for (const atom of this.atoms) {
511
- atoms.push(serializeAtom(atom))
548
+ atoms.push(serializeAtom(atom, includeResults))
512
549
  }
513
550
 
514
551
  const effects: SerializedEffect[] = []
@@ -535,7 +572,7 @@ export class ReactiveGraph<
535
572
  const compute = <T>(atom: Atom<T, unknown, any>, otelContext: otel.Context): T => {
536
573
  // const __getResult = atom._tag === 'thunk' ? atom.__getResult.toString() : ''
537
574
  if (atom.isDestroyed) {
538
- shouldNeverHappen(`LiveStore Error: Attempted to compute destroyed atom`)
575
+ shouldNeverHappen(`LiveStore Error: Attempted to compute destroyed ${atom._tag} (${atom.id}): ${atom.label ?? ''}`)
539
576
  }
540
577
 
541
578
  if (atom.isDirty) {
@@ -566,7 +603,7 @@ export const throwContextNotSetError = (graph: ReactiveGraph<any, any, any>): ne
566
603
  }
567
604
 
568
605
  // NOTE This function is performance-optimized (i.e. not using `pick` and `Array.from`)
569
- const serializeAtom = (atom: Atom<any, unknown, any>): SerializedAtom => {
606
+ const serializeAtom = (atom: Atom<any, unknown, any>, includeResult: boolean): SerializedAtom => {
570
607
  const sub: string[] = []
571
608
  for (const a of atom.sub) {
572
609
  sub.push(a.id)
@@ -577,14 +614,36 @@ const serializeAtom = (atom: Atom<any, unknown, any>): SerializedAtom => {
577
614
  super_.push(a.id)
578
615
  }
579
616
 
617
+ const previousResult: EncodedOption<string> = includeResult
618
+ ? encodedOptionSome(JSON.stringify(atom.previousResult))
619
+ : encodedOptionNone()
620
+
621
+ if (atom._tag === 'ref') {
622
+ return {
623
+ _tag: atom._tag,
624
+ id: atom.id,
625
+ label: atom.label,
626
+ meta: atom.meta,
627
+ isDirty: atom.isDirty,
628
+ sub,
629
+ super: super_,
630
+ isDestroyed: atom.isDestroyed,
631
+ refreshes: atom.refreshes,
632
+ previousResult,
633
+ }
634
+ }
635
+
580
636
  return {
581
- _tag: atom._tag,
637
+ _tag: 'thunk',
582
638
  id: atom.id,
583
639
  label: atom.label,
584
640
  meta: atom.meta,
585
641
  isDirty: atom.isDirty,
586
642
  sub,
587
643
  super: super_,
644
+ isDestroyed: atom.isDestroyed,
645
+ recomputations: atom.recomputations,
646
+ previousResult,
588
647
  }
589
648
  }
590
649
 
@@ -600,5 +659,7 @@ const serializeEffect = (effect: Effect): SerializedEffect => {
600
659
  id: effect.id,
601
660
  label: effect.label,
602
661
  sub,
662
+ invocations: effect.invocations,
663
+ isDestroyed: effect.isDestroyed,
603
664
  }
604
665
  }
@@ -22,7 +22,8 @@ export type DbContext = {
22
22
 
23
23
  export type UnsubscribeQuery = () => void
24
24
 
25
- export type GetResult<TQuery extends LiveQueryAny> = TQuery extends LiveQuery<infer TResult> ? TResult : unknown
25
+ export type GetResult<TQuery extends LiveQueryAny> =
26
+ TQuery extends LiveQuery<infer TResult, infer _1> ? TResult : unknown
26
27
 
27
28
  let queryIdCounter = 0
28
29
 
@@ -6,7 +6,7 @@ import * as graphql from 'graphql'
6
6
 
7
7
  import { globalDbGraph } from '../global-state.js'
8
8
  import type { QueryInfoNone } from '../query-info.js'
9
- import type { Thunk } from '../reactive.js'
9
+ import { isThunk, type Thunk } from '../reactive.js'
10
10
  import type { BaseGraphQLContext, RefreshReason, Store } from '../store.js'
11
11
  import { getDurationMsFromSpan } from '../utils/otel.js'
12
12
  import type { DbContext, DbGraph, GetAtomResult, LiveQuery } from './base-class.js'
@@ -39,7 +39,7 @@ export class LiveStoreGraphQLQuery<
39
39
  /** A reactive thunk representing the query results */
40
40
  results$: Thunk<TResultMapped, DbContext, RefreshReason>
41
41
 
42
- variableValues$: Thunk<TVariableValues, DbContext, RefreshReason>
42
+ variableValues$: Thunk<TVariableValues, DbContext, RefreshReason> | undefined
43
43
 
44
44
  label: string
45
45
 
@@ -89,23 +89,26 @@ export class LiveStoreGraphQLQuery<
89
89
  : shouldNeverHappen(`Invalid map function ${map}`)
90
90
 
91
91
  // TODO don't even create a thunk if variables are static
92
- const variableValues$ = this.dbGraph.makeThunk(
93
- (get, _setDebugInfo, { rootOtelContext }, otelContext) => {
94
- if (typeof genVariableValues === 'function') {
95
- return genVariableValues(makeGetAtomResult(get, otelContext ?? rootOtelContext))
96
- } else {
97
- return genVariableValues
98
- }
99
- },
100
- { label: `${labelWithDefault}:variableValues`, meta: { liveStoreThunkType: 'graphqlVariableValues' } },
101
- )
92
+ let variableValues$OrvariableValues
102
93
 
103
- this.variableValues$ = variableValues$
94
+ if (typeof genVariableValues === 'function') {
95
+ variableValues$OrvariableValues = this.dbGraph.makeThunk(
96
+ (get, _setDebugInfo, { rootOtelContext }, otelContext) => {
97
+ return genVariableValues(makeGetAtomResult(get, otelContext ?? rootOtelContext))
98
+ },
99
+ { label: `${labelWithDefault}:variableValues`, meta: { liveStoreThunkType: 'graphqlVariableValues' } },
100
+ )
101
+ this.variableValues$ = variableValues$OrvariableValues
102
+ } else {
103
+ variableValues$OrvariableValues = genVariableValues
104
+ }
104
105
 
105
106
  const resultsLabel = `${labelWithDefault}:results`
106
107
  this.results$ = this.dbGraph.makeThunk<TResultMapped>(
107
108
  (get, setDebugInfo, { store, otelTracer, rootOtelContext }, otelContext) => {
108
- const variableValues = get(variableValues$)
109
+ const variableValues = isThunk(variableValues$OrvariableValues)
110
+ ? (get(variableValues$OrvariableValues) as TVariableValues)
111
+ : (variableValues$OrvariableValues as TVariableValues)
109
112
  const { result, queriedTables, durationMs } = this.queryOnce({
110
113
  document,
111
114
  variableValues,
@@ -188,7 +191,11 @@ export class LiveStoreGraphQLQuery<
188
191
  span.setStatus({ code: otel.SpanStatusCode.ERROR, message: 'GraphQL error' })
189
192
  span.setAttribute('graphql.error', res.errors.join('\n'))
190
193
  span.setAttribute('graphql.error-detail', JSON.stringify(res.errors))
191
- console.error(`graphql error (${operationName})`, res.errors)
194
+ console.error(`graphql error (${operationName}) - ${res.errors.length} errors`)
195
+ for (const error of res.errors) {
196
+ console.error(error)
197
+ }
198
+ debugger
192
199
  }
193
200
 
194
201
  span.end()
@@ -208,7 +215,10 @@ export class LiveStoreGraphQLQuery<
208
215
  }
209
216
 
210
217
  destroy = () => {
211
- this.dbGraph.destroyNode(this.variableValues$)
218
+ if (this.variableValues$ !== undefined) {
219
+ this.dbGraph.destroyNode(this.variableValues$)
220
+ }
221
+
212
222
  this.dbGraph.destroyNode(this.results$)
213
223
  }
214
224
  }