@livestore/livestore 0.0.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 (205) hide show
  1. package/README.md +108 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/LiveRiffleStore.d.ts +42 -0
  4. package/dist/LiveRiffleStore.d.ts.map +1 -0
  5. package/dist/LiveRiffleStore.js +36 -0
  6. package/dist/LiveRiffleStore.js.map +1 -0
  7. package/dist/QueryCache.d.ts +20 -0
  8. package/dist/QueryCache.d.ts.map +1 -0
  9. package/dist/QueryCache.js +71 -0
  10. package/dist/QueryCache.js.map +1 -0
  11. package/dist/__tests__/react/fixture.d.ts +141 -0
  12. package/dist/__tests__/react/fixture.d.ts.map +1 -0
  13. package/dist/__tests__/react/fixture.js +72 -0
  14. package/dist/__tests__/react/fixture.js.map +1 -0
  15. package/dist/__tests__/react/useLiveStoreComponent.test.d.ts +2 -0
  16. package/dist/__tests__/react/useLiveStoreComponent.test.d.ts.map +1 -0
  17. package/dist/__tests__/react/useLiveStoreComponent.test.js +78 -0
  18. package/dist/__tests__/react/useLiveStoreComponent.test.js.map +1 -0
  19. package/dist/__tests__/react/useRiffleComponent.test.d.ts +2 -0
  20. package/dist/__tests__/react/useRiffleComponent.test.d.ts.map +1 -0
  21. package/dist/__tests__/react/useRiffleComponent.test.js +78 -0
  22. package/dist/__tests__/react/useRiffleComponent.test.js.map +1 -0
  23. package/dist/__tests__/reactive.test.d.ts +2 -0
  24. package/dist/__tests__/reactive.test.d.ts.map +1 -0
  25. package/dist/__tests__/reactive.test.js +167 -0
  26. package/dist/__tests__/reactive.test.js.map +1 -0
  27. package/dist/backends/base.d.ts +13 -0
  28. package/dist/backends/base.d.ts.map +1 -0
  29. package/dist/backends/base.js +53 -0
  30. package/dist/backends/base.js.map +1 -0
  31. package/dist/backends/index.d.ts +41 -0
  32. package/dist/backends/index.d.ts.map +1 -0
  33. package/dist/backends/index.js +38 -0
  34. package/dist/backends/index.js.map +1 -0
  35. package/dist/backends/noop.d.ts +18 -0
  36. package/dist/backends/noop.d.ts.map +1 -0
  37. package/dist/backends/noop.js +21 -0
  38. package/dist/backends/noop.js.map +1 -0
  39. package/dist/backends/tauri.d.ts +24 -0
  40. package/dist/backends/tauri.d.ts.map +1 -0
  41. package/dist/backends/tauri.js +48 -0
  42. package/dist/backends/tauri.js.map +1 -0
  43. package/dist/backends/utils/idb.d.ts +10 -0
  44. package/dist/backends/utils/idb.d.ts.map +1 -0
  45. package/dist/backends/utils/idb.js +58 -0
  46. package/dist/backends/utils/idb.js.map +1 -0
  47. package/dist/backends/web-in-memory.d.ts +24 -0
  48. package/dist/backends/web-in-memory.d.ts.map +1 -0
  49. package/dist/backends/web-in-memory.js +46 -0
  50. package/dist/backends/web-in-memory.js.map +1 -0
  51. package/dist/backends/web-worker.d.ts +17 -0
  52. package/dist/backends/web-worker.d.ts.map +1 -0
  53. package/dist/backends/web-worker.js +139 -0
  54. package/dist/backends/web-worker.js.map +1 -0
  55. package/dist/backends/web.d.ts +28 -0
  56. package/dist/backends/web.d.ts.map +1 -0
  57. package/dist/backends/web.js +64 -0
  58. package/dist/backends/web.js.map +1 -0
  59. package/dist/bounded-collections.d.ts +34 -0
  60. package/dist/bounded-collections.d.ts.map +1 -0
  61. package/dist/bounded-collections.js +103 -0
  62. package/dist/bounded-collections.js.map +1 -0
  63. package/dist/componentKey.d.ts +20 -0
  64. package/dist/componentKey.d.ts.map +1 -0
  65. package/dist/componentKey.js +3 -0
  66. package/dist/componentKey.js.map +1 -0
  67. package/dist/effect/LiveStore.d.ts +42 -0
  68. package/dist/effect/LiveStore.d.ts.map +1 -0
  69. package/dist/effect/LiveStore.js +36 -0
  70. package/dist/effect/LiveStore.js.map +1 -0
  71. package/dist/effect/index.d.ts +2 -0
  72. package/dist/effect/index.d.ts.map +1 -0
  73. package/dist/effect/index.js +2 -0
  74. package/dist/effect/index.js.map +1 -0
  75. package/dist/events.d.ts +7 -0
  76. package/dist/events.d.ts.map +1 -0
  77. package/dist/events.js +2 -0
  78. package/dist/events.js.map +1 -0
  79. package/dist/inMemoryDatabase.d.ts +65 -0
  80. package/dist/inMemoryDatabase.d.ts.map +1 -0
  81. package/dist/inMemoryDatabase.js +241 -0
  82. package/dist/inMemoryDatabase.js.map +1 -0
  83. package/dist/index.d.ts +20 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +10 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/otel.d.ts +5 -0
  88. package/dist/otel.d.ts.map +1 -0
  89. package/dist/otel.js +17 -0
  90. package/dist/otel.js.map +1 -0
  91. package/dist/react/LiveStoreContext.d.ts +11 -0
  92. package/dist/react/LiveStoreContext.d.ts.map +1 -0
  93. package/dist/react/LiveStoreContext.js +10 -0
  94. package/dist/react/LiveStoreContext.js.map +1 -0
  95. package/dist/react/LiveStoreProvider.d.ts +21 -0
  96. package/dist/react/LiveStoreProvider.d.ts.map +1 -0
  97. package/dist/react/LiveStoreProvider.js +48 -0
  98. package/dist/react/LiveStoreProvider.js.map +1 -0
  99. package/dist/react/RiffleProvider.d.ts +21 -0
  100. package/dist/react/RiffleProvider.d.ts.map +1 -0
  101. package/dist/react/RiffleProvider.js +48 -0
  102. package/dist/react/RiffleProvider.js.map +1 -0
  103. package/dist/react/StoreContext.d.ts +11 -0
  104. package/dist/react/StoreContext.d.ts.map +1 -0
  105. package/dist/react/StoreContext.js +10 -0
  106. package/dist/react/StoreContext.js.map +1 -0
  107. package/dist/react/index.d.ts +7 -0
  108. package/dist/react/index.d.ts.map +1 -0
  109. package/dist/react/index.js +6 -0
  110. package/dist/react/index.js.map +1 -0
  111. package/dist/react/useGlobalQuery.d.ts +3 -0
  112. package/dist/react/useGlobalQuery.d.ts.map +1 -0
  113. package/dist/react/useGlobalQuery.js +25 -0
  114. package/dist/react/useGlobalQuery.js.map +1 -0
  115. package/dist/react/useGraphQL.d.ts +11 -0
  116. package/dist/react/useGraphQL.d.ts.map +1 -0
  117. package/dist/react/useGraphQL.js +68 -0
  118. package/dist/react/useGraphQL.js.map +1 -0
  119. package/dist/react/useLiveStoreComponent.d.ts +70 -0
  120. package/dist/react/useLiveStoreComponent.d.ts.map +1 -0
  121. package/dist/react/useLiveStoreComponent.js +261 -0
  122. package/dist/react/useLiveStoreComponent.js.map +1 -0
  123. package/dist/react/useRiffleComponent.d.ts +70 -0
  124. package/dist/react/useRiffleComponent.d.ts.map +1 -0
  125. package/dist/react/useRiffleComponent.js +261 -0
  126. package/dist/react/useRiffleComponent.js.map +1 -0
  127. package/dist/react/useRiffleJsonHook.d.ts +4 -0
  128. package/dist/react/useRiffleJsonHook.d.ts.map +1 -0
  129. package/dist/react/useRiffleJsonHook.js +21 -0
  130. package/dist/react/useRiffleJsonHook.js.map +1 -0
  131. package/dist/react/utils/useStateRefWithReactiveInput.d.ts +13 -0
  132. package/dist/react/utils/useStateRefWithReactiveInput.d.ts.map +1 -0
  133. package/dist/react/utils/useStateRefWithReactiveInput.js +38 -0
  134. package/dist/react/utils/useStateRefWithReactiveInput.js.map +1 -0
  135. package/dist/reactive.d.ts +140 -0
  136. package/dist/reactive.d.ts.map +1 -0
  137. package/dist/reactive.js +301 -0
  138. package/dist/reactive.js.map +1 -0
  139. package/dist/reactiveQueries/base-class.d.ts +24 -0
  140. package/dist/reactiveQueries/base-class.d.ts.map +1 -0
  141. package/dist/reactiveQueries/base-class.js +22 -0
  142. package/dist/reactiveQueries/base-class.js.map +1 -0
  143. package/dist/reactiveQueries/graphql.d.ts +25 -0
  144. package/dist/reactiveQueries/graphql.d.ts.map +1 -0
  145. package/dist/reactiveQueries/graphql.js +14 -0
  146. package/dist/reactiveQueries/graphql.js.map +1 -0
  147. package/dist/reactiveQueries/js.d.ts +19 -0
  148. package/dist/reactiveQueries/js.d.ts.map +1 -0
  149. package/dist/reactiveQueries/js.js +13 -0
  150. package/dist/reactiveQueries/js.js.map +1 -0
  151. package/dist/reactiveQueries/sql.d.ts +31 -0
  152. package/dist/reactiveQueries/sql.d.ts.map +1 -0
  153. package/dist/reactiveQueries/sql.js +28 -0
  154. package/dist/reactiveQueries/sql.js.map +1 -0
  155. package/dist/schema.d.ts +163 -0
  156. package/dist/schema.d.ts.map +1 -0
  157. package/dist/schema.js +92 -0
  158. package/dist/schema.js.map +1 -0
  159. package/dist/store.d.ts +175 -0
  160. package/dist/store.d.ts.map +1 -0
  161. package/dist/store.js +546 -0
  162. package/dist/store.js.map +1 -0
  163. package/dist/util.d.ts +24 -0
  164. package/dist/util.d.ts.map +1 -0
  165. package/dist/util.js +51 -0
  166. package/dist/util.js.map +1 -0
  167. package/package.json +52 -0
  168. package/src/QueryCache.ts +81 -0
  169. package/src/__tests__/react/fixture.tsx +106 -0
  170. package/src/__tests__/react/useLiveStoreComponent.test.tsx +111 -0
  171. package/src/__tests__/reactive.test.ts +227 -0
  172. package/src/ambient.d.ts +7 -0
  173. package/src/backends/base.ts +67 -0
  174. package/src/backends/index.ts +94 -0
  175. package/src/backends/noop.ts +32 -0
  176. package/src/backends/tauri.ts +74 -0
  177. package/src/backends/utils/idb.ts +71 -0
  178. package/src/backends/web-in-memory.ts +65 -0
  179. package/src/backends/web-worker.ts +176 -0
  180. package/src/backends/web.ts +96 -0
  181. package/src/bounded-collections.ts +112 -0
  182. package/src/componentKey.ts +9 -0
  183. package/src/effect/LiveStore.ts +123 -0
  184. package/src/effect/index.ts +7 -0
  185. package/src/events.ts +8 -0
  186. package/src/inMemoryDatabase.ts +347 -0
  187. package/src/index.ts +47 -0
  188. package/src/otel.ts +20 -0
  189. package/src/react/LiveStoreContext.ts +23 -0
  190. package/src/react/LiveStoreProvider.tsx +93 -0
  191. package/src/react/index.ts +11 -0
  192. package/src/react/useGlobalQuery.ts +40 -0
  193. package/src/react/useGraphQL.ts +113 -0
  194. package/src/react/useLiveStoreComponent.ts +493 -0
  195. package/src/react/utils/useStateRefWithReactiveInput.ts +51 -0
  196. package/src/reactive.ts +538 -0
  197. package/src/reactiveQueries/base-class.ts +49 -0
  198. package/src/reactiveQueries/graphql.ts +52 -0
  199. package/src/reactiveQueries/js.ts +38 -0
  200. package/src/reactiveQueries/sql.ts +65 -0
  201. package/src/schema.ts +219 -0
  202. package/src/store.ts +889 -0
  203. package/src/util.ts +59 -0
  204. package/tsconfig.json +15 -0
  205. package/vitest.config.js +13 -0
package/src/store.ts ADDED
@@ -0,0 +1,889 @@
1
+ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'
2
+ import { assertNever, makeNoopTracer, shouldNeverHappen } from '@livestore/utils'
3
+ import * as otel from '@opentelemetry/api'
4
+ import type { GraphQLSchema } from 'graphql'
5
+ import * as graphql from 'graphql'
6
+ import { uniqueId } from 'lodash-es'
7
+ import ReactDOM from 'react-dom'
8
+ import { v4 as uuid } from 'uuid'
9
+
10
+ import type { Backend, BackendOptions } from './backends/index.js'
11
+ import { createBackend } from './backends/index.js'
12
+ import type { ComponentKey } from './componentKey.js'
13
+ import { tableNameForComponentKey } from './componentKey.js'
14
+ import type { LiveStoreEvent } from './events.js'
15
+ import { InMemoryDatabase } from './inMemoryDatabase.js'
16
+ import { getDurationMsFromSpan } from './otel.js'
17
+ import type { GetAtom, Ref } from './reactive.js'
18
+ import { ReactiveGraph } from './reactive.js'
19
+ import { LiveStoreGraphQLQuery } from './reactiveQueries/graphql.js'
20
+ import { LiveStoreJSQuery } from './reactiveQueries/js.js'
21
+ import { LiveStoreSQLQuery } from './reactiveQueries/sql.js'
22
+ import type { ActionDefinition, GetActionArgs, Schema } from './schema.js'
23
+ import { componentStateTables, loadSchema } from './schema.js'
24
+ import type { Bindable, ParamsObject } from './util.js'
25
+ import { sql } from './util.js'
26
+
27
+ export type LiveStoreQuery<TResult extends Record<string, any> = any> =
28
+ | LiveStoreSQLQuery<TResult>
29
+ | LiveStoreJSQuery<TResult>
30
+ | LiveStoreGraphQLQuery<TResult, any, any>
31
+
32
+ export type BaseGraphQLContext = {
33
+ queriedTables: Set<string>
34
+ /** Needed by Pothos Otel plugin for resolver tracing to work */
35
+ parentSpanContext?: otel.Context
36
+ }
37
+
38
+ export const RESET_DB_LOCAL_STORAGE_KEY = 'livestore-reset'
39
+
40
+ export type QueryResult<TQuery> = TQuery extends LiveStoreSQLQuery<infer R>
41
+ ? ReadonlyArray<Readonly<R>>
42
+ : TQuery extends LiveStoreJSQuery<infer S>
43
+ ? Readonly<S>
44
+ : TQuery extends LiveStoreGraphQLQuery<infer Result, any, any>
45
+ ? Readonly<Result>
46
+ : never
47
+
48
+ const globalComponentKey: ComponentKey = { _tag: 'singleton', componentName: '__global', id: 'singleton' }
49
+
50
+ export type GraphQLOptions<TContext> = {
51
+ schema: GraphQLSchema
52
+ makeContext: (db: InMemoryDatabase, tracer: otel.Tracer) => TContext
53
+ }
54
+
55
+ export type StoreOptions<TGraphQLContext extends BaseGraphQLContext> = {
56
+ db: InMemoryDatabase
57
+ schema: Schema
58
+ backend?: Backend
59
+ graphQLOptions?: GraphQLOptions<TGraphQLContext>
60
+ otelTracer: otel.Tracer
61
+ otelRootSpanContext: otel.Context
62
+ }
63
+
64
+ export type RefreshReason =
65
+ | {
66
+ _tag: 'applyEvent'
67
+ /** The event that was applied */
68
+ // note: we omit ID because it's annoying to read it given where it gets generated,
69
+ // but it would be useful to have in the debugger
70
+ event: Omit<LiveStoreEvent, 'id'>
71
+
72
+ /** The tables that were written to by the event */
73
+ writeTables: string[]
74
+ }
75
+ | {
76
+ _tag: 'applyEvents'
77
+ /** The events that was applied */
78
+ // note: we omit ID because it's annoying to read it given where it gets generated,
79
+ // but it would be useful to have in the debugger
80
+ events: Omit<LiveStoreEvent, 'id'>[]
81
+
82
+ /** The tables that were written to by the event */
83
+ writeTables: string[]
84
+ }
85
+ /** Usually in response to some `applyEvent`/`applyEvents` with `skipRefresh: true` */
86
+ | { _tag: 'manualRefresh' }
87
+ | {
88
+ _tag: 'makeThunk'
89
+ label?: string
90
+ }
91
+ | { _tag: 'unknown' }
92
+
93
+ export type QueryDebugInfo = { _tag: 'graphql' | 'sql' | 'js' | 'unknown'; label: string; query: string }
94
+
95
+ export type StoreOtel = {
96
+ tracer: otel.Tracer
97
+ applyEventsSpanContext: otel.Context
98
+ queriesSpanContext: otel.Context
99
+ }
100
+
101
+ export class Store<TGraphQLContext extends BaseGraphQLContext> {
102
+ graph: ReactiveGraph<RefreshReason, QueryDebugInfo>
103
+ inMemoryDB: InMemoryDatabase
104
+ schema: Schema
105
+ graphQLSchema?: GraphQLSchema
106
+ graphQLContext?: TGraphQLContext
107
+ otel: StoreOtel
108
+ /**
109
+ * Note we're using `Ref<null>` here as we don't care about the value but only about *that* something has changed.
110
+ * This only works in combination with `equal: () => false` which will always trigger a refresh.
111
+ */
112
+ tableRefs: { [key: string]: Ref<null> }
113
+ activeQueries: Set<LiveStoreQuery>
114
+ backend?: Backend
115
+ temporaryQueries: Set<LiveStoreQuery> | undefined
116
+
117
+ private constructor({
118
+ db,
119
+ schema,
120
+ backend,
121
+ graphQLOptions,
122
+ otelTracer,
123
+ otelRootSpanContext,
124
+ }: StoreOptions<TGraphQLContext>) {
125
+ this.inMemoryDB = db
126
+ this.graph = new ReactiveGraph({
127
+ // Do all our updates inside a single React setState batch to avoid multiple UI re-renders
128
+ effectsWrapper: (run) => ReactDOM.unstable_batchedUpdates(() => run()),
129
+ otelTracer,
130
+ })
131
+ this.schema = schema
132
+ // TODO generalize the `tableRefs` concept to allow finer-grained refs
133
+ this.tableRefs = {}
134
+ this.activeQueries = new Set()
135
+ this.backend = backend
136
+
137
+ const applyEventsSpan = otelTracer.startSpan('LiveStore:applyEvents', {}, otelRootSpanContext)
138
+ const otelApplyEventsSpanContext = otel.trace.setSpan(otel.context.active(), applyEventsSpan)
139
+
140
+ const queriesSpan = otelTracer.startSpan('LiveStore:queries', {}, otelRootSpanContext)
141
+ const otelQueriesSpanContext = otel.trace.setSpan(otel.context.active(), queriesSpan)
142
+
143
+ this.otel = {
144
+ tracer: otelTracer,
145
+ applyEventsSpanContext: otelApplyEventsSpanContext,
146
+ queriesSpanContext: otelQueriesSpanContext,
147
+ }
148
+
149
+ const allTableNames = [
150
+ ...Object.keys(this.schema.tables),
151
+ ...Object.keys(this.schema.materializedViews),
152
+ ...Object.keys(componentStateTables),
153
+ ]
154
+ for (const tableName of allTableNames) {
155
+ this.tableRefs[tableName] = this.graph.makeRef(null, {
156
+ equal: () => false,
157
+ label: tableName,
158
+ meta: { liveStoreRefType: 'table' },
159
+ })
160
+ }
161
+
162
+ if (graphQLOptions) {
163
+ this.graphQLSchema = graphQLOptions.schema
164
+ this.graphQLContext = graphQLOptions.makeContext(db, this.otel.tracer)
165
+ }
166
+ }
167
+
168
+ static createStore = <TGraphQLContext extends BaseGraphQLContext>(
169
+ storeOptions: StoreOptions<TGraphQLContext>,
170
+ parentSpan: otel.Span,
171
+ ): Store<TGraphQLContext> => {
172
+ const ctx = otel.trace.setSpan(otel.context.active(), parentSpan)
173
+ return storeOptions.otelTracer.startActiveSpan('LiveStore:store-constructor', {}, ctx, (span) => {
174
+ try {
175
+ return new Store(storeOptions)
176
+ } finally {
177
+ span.end()
178
+ }
179
+ })
180
+ }
181
+
182
+ /**
183
+ * Creates a reactive LiveStore SQL query
184
+ *
185
+ * NOTE The query is actually running (even if no one has subscribed to it yet) and will be kept up to date.
186
+ */
187
+ querySQL = <TResult>(
188
+ genQueryString: (get: GetAtom) => string,
189
+ /**
190
+ * List of tables that are queried in this query;
191
+ * used to determine reactive dependencies.
192
+ * In the future we want to auto-generate this via parsing the query
193
+ */
194
+ queriedTables: string[],
195
+ bindValues: Bindable | undefined,
196
+ componentKey: ComponentKey | undefined,
197
+ label: string | undefined,
198
+ parentSpanContext: otel.Context,
199
+ ): LiveStoreSQLQuery<TResult> =>
200
+ this.otel.tracer.startActiveSpan(
201
+ 'querySQL', // NOTE span name will be overridden further down
202
+ { attributes: { label } },
203
+ parentSpanContext,
204
+ (span) => {
205
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
206
+
207
+ const queryString$ = this.graph.makeThunk(
208
+ (get, addDebugInfo) => {
209
+ const queryString = genQueryString(get)
210
+ addDebugInfo({ _tag: 'js', label: `${label}:queryString`, query: queryString })
211
+ return queryString
212
+ },
213
+ { label: `${label}:queryString`, meta: { liveStoreThunkType: 'sqlQueryString' } },
214
+ otelContext,
215
+ )
216
+
217
+ label = label ?? queryString$.result
218
+ span.updateName(`querySQL:${label}`)
219
+
220
+ const queryLabel = `${label}:results` + (this.temporaryQueries ? ':temp' : '')
221
+
222
+ const results$ = this.graph.makeThunk<TResult[]>(
223
+ (get, addDebugInfo) =>
224
+ this.otel.tracer.startActiveSpan(
225
+ 'sql', // NOTE span name will be overridden further down
226
+ {},
227
+ otelContext,
228
+ (span) => {
229
+ try {
230
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
231
+
232
+ // Establish a reactive dependency on the tables used in the query
233
+ for (const tableName of queriedTables) {
234
+ const tableRef =
235
+ this.tableRefs[tableName] ?? shouldNeverHappen(`No table ref found for ${tableName}`)
236
+ get(tableRef)
237
+ }
238
+ const sqlString = get(queryString$)
239
+
240
+ span.setAttribute('sql.query', sqlString)
241
+ span.updateName(`sql:${sqlString.slice(0, 50)}`)
242
+
243
+ const results = this.inMemoryDB.select(sqlString, {
244
+ queriedTables,
245
+ bindValues,
246
+ parentSpanContext: otelContext,
247
+ })
248
+
249
+ span.setAttribute('sql.rowsCount', results.length)
250
+ addDebugInfo({ _tag: 'sql', label: label ?? '', query: sqlString })
251
+
252
+ return results as unknown as TResult[]
253
+ } finally {
254
+ span.end()
255
+ }
256
+ },
257
+ ),
258
+ { label: queryLabel },
259
+ otelContext,
260
+ )
261
+
262
+ const query = new LiveStoreSQLQuery<TResult>({
263
+ label,
264
+ queryString$,
265
+ results$,
266
+ componentKey: componentKey ?? globalComponentKey,
267
+ store: this,
268
+ otelContext,
269
+ })
270
+
271
+ this.activeQueries.add(query)
272
+
273
+ // TODO get rid of temporary query workaround
274
+ if (this.temporaryQueries !== undefined) {
275
+ this.temporaryQueries.add(query)
276
+ }
277
+
278
+ // NOTE we are not ending the span here but in the query `destroy` method
279
+ return query
280
+ },
281
+ )
282
+
283
+ queryJS = <TResult>(
284
+ genResults: (get: GetAtom) => TResult,
285
+ componentKey: ComponentKey,
286
+ label = `js${uniqueId()}`,
287
+ parentSpanContext: otel.Context,
288
+ ): LiveStoreJSQuery<TResult> =>
289
+ this.otel.tracer.startActiveSpan(`queryJS:${label}`, { attributes: { label } }, parentSpanContext, (span) => {
290
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
291
+ const queryLabel = `${label}:results` + (this.temporaryQueries ? ':temp' : '')
292
+ const results$ = this.graph.makeThunk(
293
+ (get, addDebugInfo) => {
294
+ addDebugInfo({ _tag: 'js', label, query: genResults.toString() })
295
+ return genResults(get)
296
+ },
297
+ { label: queryLabel, meta: { liveStoreThunkType: 'jsResults' } },
298
+ otelContext,
299
+ )
300
+
301
+ const query = new LiveStoreJSQuery<TResult>({
302
+ label,
303
+ results$,
304
+ componentKey,
305
+ store: this,
306
+ otelContext,
307
+ })
308
+
309
+ this.activeQueries.add(query)
310
+
311
+ // TODO get rid of temporary query workaround
312
+ if (this.temporaryQueries !== undefined) {
313
+ this.temporaryQueries.add(query)
314
+ }
315
+
316
+ // NOTE we are not ending the span here but in the query `destroy` method
317
+ return query
318
+ })
319
+
320
+ queryGraphQL = <TResult extends Record<string, any>, TVariableValues extends Record<string, any>>(
321
+ document: DocumentNode<TResult, TVariableValues>,
322
+ genVariableValues: (get: GetAtom) => TVariableValues,
323
+ { componentKey, label }: { componentKey: ComponentKey; label?: string },
324
+ parentSpanContext: otel.Context,
325
+ ): LiveStoreGraphQLQuery<TResult, TVariableValues, TGraphQLContext> =>
326
+ this.otel.tracer.startActiveSpan(
327
+ `queryGraphQL`, // NOTE span name will be overridden further down
328
+ {},
329
+ parentSpanContext,
330
+ (span) => {
331
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
332
+
333
+ if (this.graphQLContext === undefined) {
334
+ return shouldNeverHappen("Can't run a GraphQL query on a store without GraphQL context")
335
+ }
336
+
337
+ const labelWithDefault = label ?? graphql.getOperationAST(document)?.name?.value ?? 'graphql'
338
+
339
+ span.updateName(`queryGraphQL:${labelWithDefault}`)
340
+
341
+ const variableValues$ = this.graph.makeThunk(
342
+ genVariableValues,
343
+ { label: `${labelWithDefault}:variableValues`, meta: { liveStoreThunkType: 'graphqlVariableValues' } },
344
+ otelContext,
345
+ )
346
+
347
+ const resultsLabel = `${labelWithDefault}:results` + (this.temporaryQueries ? ':temp' : '')
348
+ const results$ = this.graph.makeThunk<TResult>(
349
+ (get, addDebugInfo) => {
350
+ const variableValues = get(variableValues$)
351
+ const { result, queriedTables } = this.queryGraphQLOnce(document, variableValues, otelContext)
352
+
353
+ // Add dependencies on any tables that were used
354
+ for (const tableName of queriedTables) {
355
+ const tableRef = this.tableRefs[tableName]
356
+ assertNever(tableRef !== undefined, `No table ref found for ${tableName}`)
357
+ get(tableRef!)
358
+ }
359
+
360
+ addDebugInfo({ _tag: 'graphql', label: resultsLabel, query: graphql.print(document) })
361
+
362
+ return result
363
+ },
364
+ { label: resultsLabel, meta: { liveStoreThunkType: 'graphqlResults' } },
365
+ otelContext,
366
+ )
367
+
368
+ const query = new LiveStoreGraphQLQuery({
369
+ document,
370
+ context: this.graphQLContext,
371
+ results$,
372
+ componentKey,
373
+ label: labelWithDefault,
374
+ store: this,
375
+ otelContext,
376
+ })
377
+
378
+ this.activeQueries.add(query)
379
+
380
+ // TODO get rid of temporary query workaround
381
+ if (this.temporaryQueries !== undefined) {
382
+ this.temporaryQueries.add(query)
383
+ }
384
+
385
+ // NOTE we are not ending the span here but in the query `destroy` method
386
+ return query
387
+ },
388
+ )
389
+
390
+ queryGraphQLOnce = <TResult extends Record<string, any>, TVariableValues extends Record<string, any>>(
391
+ document: DocumentNode<TResult, TVariableValues>,
392
+ variableValues: TVariableValues,
393
+ parentSpanContext?: otel.Context,
394
+ ): { result: TResult; queriedTables: string[] } => {
395
+ const schema =
396
+ this.graphQLSchema ?? shouldNeverHappen("Can't run a GraphQL query on a store without GraphQL schema")
397
+ const context =
398
+ this.graphQLContext ?? shouldNeverHappen("Can't run a GraphQL query on a store without GraphQL context")
399
+ const tracer = this.otel.tracer
400
+ const spanContext = parentSpanContext ?? this.otel.queriesSpanContext
401
+
402
+ const operationName = graphql.getOperationAST(document)?.name?.value
403
+
404
+ return tracer.startActiveSpan(`executeGraphQLQuery: ${operationName}`, {}, spanContext, (span) => {
405
+ try {
406
+ span.setAttribute('graphql.variables', JSON.stringify(variableValues))
407
+ span.setAttribute('graphql.query', graphql.print(document))
408
+
409
+ context.queriedTables.clear()
410
+
411
+ context.parentSpanContext = otel.trace.setSpan(otel.context.active(), span)
412
+
413
+ const res = graphql.executeSync({
414
+ document,
415
+ contextValue: context,
416
+ schema: schema,
417
+ variableValues,
418
+ })
419
+
420
+ // TODO track number of nested SQL queries via Otel + debug info
421
+
422
+ if (res.errors) {
423
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: 'GraphQL error' })
424
+ span.setAttribute('graphql.error', res.errors.join('\n'))
425
+ span.setAttribute('graphql.error-detail', JSON.stringify(res.errors))
426
+ console.error(`graphql error (${operationName})`, res.errors)
427
+ }
428
+
429
+ return { result: res.data as unknown as TResult, queriedTables: Array.from(context.queriedTables.values()) }
430
+ } finally {
431
+ span.end()
432
+ }
433
+ })
434
+ }
435
+
436
+ /**
437
+ * Subscribe to the results of a query
438
+ * Returns a function to cancel the subscription.
439
+ */
440
+ subscribe = <TQuery extends LiveStoreQuery>(
441
+ query: TQuery,
442
+ onNewValue: (value: QueryResult<TQuery>) => void,
443
+ onSubsubscribe?: () => void,
444
+ options?: { label?: string } | undefined,
445
+ ): (() => void) =>
446
+ this.otel.tracer.startActiveSpan(
447
+ `LiveStore.subscribe`,
448
+ { attributes: { label: options?.label } },
449
+ query.otelContext,
450
+ (span) => {
451
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
452
+
453
+ const effect = this.graph.makeEffect(
454
+ (get) => {
455
+ const result = get(query.results$) as QueryResult<TQuery>
456
+ onNewValue(result)
457
+ },
458
+ { label: `subscribe:${options?.label}` },
459
+ otelContext,
460
+ )
461
+
462
+ const subscriptionKey = uuid()
463
+
464
+ const unsubscribe = () => {
465
+ try {
466
+ this.graph.destroy(effect)
467
+ query.activeSubscriptions.delete(subscriptionKey)
468
+ onSubsubscribe?.()
469
+ } finally {
470
+ span.end()
471
+ }
472
+ }
473
+
474
+ query.activeSubscriptions.set(subscriptionKey, unsubscribe)
475
+
476
+ return unsubscribe
477
+ },
478
+ )
479
+
480
+ /**
481
+ * Any queries created in the callback will be destroyed when the callback is complete.
482
+ * Useful for temporarily creating reactive queries, which is an idempotent operation
483
+ * that can be safely called inside a React useMemo hook.
484
+ */
485
+ inTempQueryContext = <TResult>(callback: () => TResult): TResult => {
486
+ this.temporaryQueries = new Set()
487
+ // TODO: consider errors / try/finally here?
488
+ const result = callback()
489
+ for (const query of this.temporaryQueries) {
490
+ this.destroyQuery(query)
491
+ }
492
+ this.temporaryQueries = undefined
493
+ return result
494
+ }
495
+
496
+ /**
497
+ * Destroys the entire store, including all queries and subscriptions.
498
+ *
499
+ * Currently only used when shutting down the app for debugging purposes (e.g. to close Otel spans).
500
+ */
501
+ destroy = () => {
502
+ for (const query of this.activeQueries) {
503
+ this.destroyQuery(query)
504
+ }
505
+
506
+ Object.values(this.tableRefs).forEach((tableRef) => this.graph.destroy(tableRef))
507
+
508
+ const applyEventsSpan = otel.trace.getSpan(this.otel.applyEventsSpanContext)!
509
+ applyEventsSpan.end()
510
+
511
+ const queriesSpan = otel.trace.getSpan(this.otel.queriesSpanContext)!
512
+ queriesSpan.end()
513
+
514
+ // TODO destroy active subscriptions
515
+ }
516
+
517
+ private destroyQuery = (query: LiveStoreQuery) => {
518
+ if (query._tag === 'sql') {
519
+ // results are downstream of query string, so will automatically be destroyed together
520
+ this.graph.destroy(query.queryString$)
521
+ } else {
522
+ this.graph.destroy(query.results$)
523
+ }
524
+ this.activeQueries.delete(query)
525
+ query.destroy()
526
+ }
527
+
528
+ /**
529
+ * Clean up queries and downstream subscriptions associated with a component.
530
+ * This is critical to avoid memory leaks.
531
+ */
532
+ unmountComponent = (componentKey: ComponentKey) => {
533
+ for (const query of this.activeQueries) {
534
+ if (query.componentKey === componentKey) {
535
+ this.destroyQuery(query)
536
+ }
537
+ }
538
+ }
539
+
540
+ /* Apply a single write event to the store, and refresh all queries in response */
541
+ applyEvent = <TEventType extends string & keyof LiveStoreActionDefinitionsTypes>(
542
+ eventType: TEventType,
543
+ args: GetActionArgs<LiveStoreActionDefinitionsTypes[TEventType]> = {},
544
+ options?: { skipRefresh?: boolean },
545
+ ): { durationMs: number } => {
546
+ const skipRefresh = options?.skipRefresh ?? false
547
+ // console.log('applyEvent', { eventType, args, skipRefresh })
548
+
549
+ const applyEventsSpan = otel.trace.getSpan(this.otel.applyEventsSpanContext)!
550
+ applyEventsSpan.addEvent('applyEvent')
551
+
552
+ return this.otel.tracer.startActiveSpan(
553
+ 'LiveStore:applyEvent',
554
+ { attributes: {} },
555
+ this.otel.applyEventsSpanContext,
556
+ (span) => {
557
+ try {
558
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
559
+ const writeTables = this.applyEventWithoutRefresh(eventType, args, otelContext).writeTables
560
+
561
+ const tablesToUpdate = [] as [Ref<null>, null][]
562
+ for (const tableName of writeTables) {
563
+ const tableRef = this.tableRefs[tableName]
564
+ assertNever(tableRef !== undefined, `No table ref found for ${tableName}`)
565
+ tablesToUpdate.push([tableRef!, null])
566
+ }
567
+
568
+ // Update all table refs together in a batch, to only trigger one reactive update
569
+ this.graph.setRefs(
570
+ tablesToUpdate,
571
+ {
572
+ otelHint: 'applyEvents',
573
+ skipRefresh,
574
+ debugRefreshReason: {
575
+ _tag: 'applyEvent',
576
+ event: { type: eventType, args },
577
+ writeTables: [...writeTables],
578
+ },
579
+ },
580
+ otelContext,
581
+ )
582
+ } catch (e: any) {
583
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: e.toString() })
584
+
585
+ console.error(e)
586
+ shouldNeverHappen(`Error applying event (${eventType}): ${e.toString()}`)
587
+ } finally {
588
+ span.end()
589
+
590
+ return { durationMs: getDurationMsFromSpan(span) }
591
+ }
592
+ },
593
+ )
594
+ }
595
+
596
+ /**
597
+ * Apply multiple write events to the store, and refresh all queries in response.
598
+ * This is faster than calling applyEvent many times in quick succession because
599
+ * we can do a single refresh after all the events.
600
+ */
601
+ applyEvents = (
602
+ // TODO make args type-safe in polymorphic array case
603
+ events: Iterable<{ eventType: string; args: any }>,
604
+ options?: { label?: string; skipRefresh?: boolean },
605
+ ): { durationMs: number } => {
606
+ const label = options?.label ?? 'applyEvents'
607
+ const skipRefresh = options?.skipRefresh ?? false
608
+
609
+ const applyEventsSpan = otel.trace.getSpan(this.otel.applyEventsSpanContext)!
610
+ applyEventsSpan.addEvent('applyEvents')
611
+
612
+ // console.log('applyEvents', { skipRefresh, events: [...events] })
613
+ return this.otel.tracer.startActiveSpan(
614
+ 'LiveStore:applyEvents',
615
+ { attributes: { 'livestore.applyEventsLabel': label } },
616
+ this.otel.applyEventsSpanContext,
617
+ (span) => {
618
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
619
+
620
+ try {
621
+ const writeTables: Set<string> = new Set()
622
+
623
+ this.otel.tracer.startActiveSpan(
624
+ 'LiveStore:processWrites',
625
+ { attributes: { 'livestore.applyEventsLabel': label } },
626
+ otel.trace.setSpan(otel.context.active(), span),
627
+ (span) => {
628
+ try {
629
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
630
+
631
+ // TODO: what to do about backend transaction here?
632
+ this.inMemoryDB.txn(() => {
633
+ for (const event of events) {
634
+ try {
635
+ const { writeTables: writeTablesForEvent } = this.applyEventWithoutRefresh(
636
+ event.eventType,
637
+ event.args,
638
+ otelContext,
639
+ )
640
+ for (const tableName of writeTablesForEvent) {
641
+ writeTables.add(tableName)
642
+ }
643
+ } catch (e: any) {
644
+ console.error(e, event)
645
+ }
646
+ }
647
+ })
648
+ } catch (e: any) {
649
+ console.error(e)
650
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: e.toString() })
651
+ } finally {
652
+ span.end()
653
+ }
654
+ },
655
+ )
656
+
657
+ const tablesToUpdate = [] as [Ref<null>, null][]
658
+ for (const tableName of writeTables) {
659
+ const tableRef = this.tableRefs[tableName]
660
+ assertNever(tableRef !== undefined, `No table ref found for ${tableName}`)
661
+ tablesToUpdate.push([tableRef!, null])
662
+ }
663
+
664
+ // Update all table refs together in a batch, to only trigger one reactive update
665
+ this.graph.setRefs(
666
+ tablesToUpdate,
667
+ {
668
+ otelHint: 'applyEvents',
669
+ skipRefresh,
670
+ debugRefreshReason: {
671
+ _tag: 'applyEvents',
672
+ events: [...events].map((e) => ({ type: e.eventType, args: e.args })),
673
+ writeTables: [...writeTables],
674
+ },
675
+ },
676
+ otelContext,
677
+ )
678
+ } catch (e: any) {
679
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: e.toString() })
680
+ } finally {
681
+ span.end()
682
+
683
+ return { durationMs: getDurationMsFromSpan(span) }
684
+ }
685
+ },
686
+ )
687
+ }
688
+
689
+ /**
690
+ * This can be used in combination with `skipRefresh` when applying events.
691
+ * We might need a better solution for this. Let's see.
692
+ */
693
+ manualRefresh = (options?: { label?: string }) => {
694
+ const { label } = options ?? {}
695
+ this.otel.tracer.startActiveSpan(
696
+ 'LiveStore:manualRefresh',
697
+ { attributes: { 'livestore.manualRefreshLabel': label } },
698
+ this.otel.applyEventsSpanContext,
699
+ (span) => {
700
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
701
+ this.graph.refresh({ otelHint: 'manualRefresh', debugRefreshReason: { _tag: 'manualRefresh' } }, otelContext)
702
+ span.end()
703
+ },
704
+ )
705
+ }
706
+
707
+ /**
708
+ * Apply an event to the store.
709
+ * Returns the tables that were affected by the event.
710
+ * This is an internal method that doesn't trigger a refresh;
711
+ * the caller must refresh queries after calling this method.
712
+ */
713
+ private applyEventWithoutRefresh = (
714
+ eventType: string,
715
+ args: any = {},
716
+ parentSpanContext: otel.Context,
717
+ ): { writeTables: string[]; durationMs: number } => {
718
+ return this.otel.tracer.startActiveSpan(
719
+ 'LiveStore:applyEventWithoutRefresh',
720
+ {
721
+ attributes: {
722
+ 'livestore.actionType': eventType,
723
+ 'livestore.args': JSON.stringify(args, null, 2),
724
+ },
725
+ },
726
+ parentSpanContext,
727
+ (span) => {
728
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
729
+
730
+ const actionDefinitions: { [key: string]: ActionDefinition } = {
731
+ ...this.schema.actions,
732
+
733
+ // Special LiveStore:defined actions
734
+ updateComponentState: {
735
+ statement: ({ componentKey, columnNames }: { componentKey: ComponentKey; columnNames: string[] }) => {
736
+ const whereClause = componentKey._tag === 'singleton' ? '' : `where id = '${componentKey.id}'`
737
+ const updateClause = columnNames.map((columnName) => `${columnName} = $${columnName}`).join(', ')
738
+ const stmt = sql`update ${tableNameForComponentKey(componentKey)} set ${updateClause} ${whereClause}`
739
+
740
+ return {
741
+ sql: stmt,
742
+ writeTables: [tableNameForComponentKey(componentKey)],
743
+ }
744
+ },
745
+ },
746
+ }
747
+
748
+ const actionDefinition = actionDefinitions[eventType] ?? shouldNeverHappen(`Unknown event type: ${eventType}`)
749
+
750
+ // Generate a fresh ID for the event
751
+ const eventWithId: LiveStoreEvent = { id: uuid(), type: eventType, args }
752
+
753
+ // Synchronously apply the event to the in-memory database
754
+ const { durationMs } = this.inMemoryDB.applyEvent(eventWithId, actionDefinition, otelContext)
755
+
756
+ // Asynchronously apply the event to a persistent backend (we're not awaiting this promise here)
757
+ if (this.backend !== undefined) {
758
+ this.backend.applyEvent(eventWithId, actionDefinition, span)
759
+ }
760
+
761
+ // Uncomment to print a list of queries currently registered on the store
762
+ // console.log(JSON.parse(JSON.stringify([...this.queries].map((q) => `${labelForKey(q.componentKey)}/${q.label}`))))
763
+
764
+ const statement =
765
+ typeof actionDefinition.statement === 'function'
766
+ ? actionDefinition.statement(args)
767
+ : actionDefinition.statement
768
+
769
+ span.end()
770
+
771
+ return { writeTables: statement.writeTables, durationMs }
772
+ },
773
+ )
774
+ }
775
+
776
+ /**
777
+ * Directly execute a SQL query on the Store.
778
+ * This should only be used for framework-internal purposes;
779
+ * all app writes should go through applyEvent.
780
+ */
781
+ execute = async (query: string, params: ParamsObject = {}, writeTables?: string[]) => {
782
+ this.inMemoryDB.execute(query, params, writeTables)
783
+
784
+ if (this.backend !== undefined) {
785
+ const parentSpan = otel.trace.getSpan(otel.context.active())
786
+ this.backend.execute(query, params, parentSpan)
787
+ }
788
+ }
789
+ }
790
+
791
+ /** Create a new LiveStore Store */
792
+ export const createStore = async <TGraphQLContext extends BaseGraphQLContext>({
793
+ schema,
794
+ backendOptions,
795
+ graphQLOptions,
796
+ otelTracer = makeNoopTracer(),
797
+ otelRootSpanContext = otel.context.active(),
798
+ boot,
799
+ }: {
800
+ schema: Schema
801
+ backendOptions: BackendOptions
802
+ graphQLOptions?: GraphQLOptions<TGraphQLContext>
803
+ otelTracer?: otel.Tracer
804
+ otelRootSpanContext?: otel.Context
805
+ boot?: (backend: Backend, parentSpan: otel.Span) => Promise<void>
806
+ }): Promise<Store<TGraphQLContext>> => {
807
+ return otelTracer.startActiveSpan('createStore', {}, otelRootSpanContext, async (span) => {
808
+ try {
809
+ let persistedData: Uint8Array | undefined
810
+ const backend = await createBackend(backendOptions)
811
+ // if we're resetting the database, run boot here.
812
+
813
+ let shouldResetDB = false
814
+ // Uncomment this line if you want to reset the database contents.
815
+ // let shouldResetDB = true
816
+
817
+ const existingTablesRaw = await backend.select(
818
+ sql`SELECT * FROM sqlite_master WHERE type='table';`,
819
+ undefined,
820
+ span,
821
+ )
822
+ const existingTables = existingTablesRaw.results.map((t: { name: string }) => t.name)
823
+ const missingTables = Object.keys(schema.tables).filter((tableName) => !existingTables.includes(tableName))
824
+ if (existingTables.length === 0) {
825
+ console.log('No existing tables found, loading from schema')
826
+ shouldResetDB = true
827
+ } else if (
828
+ missingTables.length > 0 &&
829
+ window.confirm(
830
+ `Existing DB is missing ${missingTables.length} tables: ${missingTables.join(
831
+ ', ',
832
+ )}\n\nReset DB? This will reset all of the following tables to empty: ${Object.keys(schema).join(', ')}`,
833
+ )
834
+ ) {
835
+ shouldResetDB = true
836
+ }
837
+
838
+ if (localStorage.getItem(RESET_DB_LOCAL_STORAGE_KEY) !== null) {
839
+ shouldResetDB = true
840
+ }
841
+
842
+ if (shouldResetDB) {
843
+ await loadSchema(backend, schema)
844
+ localStorage.removeItem(RESET_DB_LOCAL_STORAGE_KEY)
845
+ }
846
+
847
+ if (boot) {
848
+ await boot(backend!, span)
849
+ }
850
+
851
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
852
+ await otelTracer.startActiveSpan('backend-getPersistedData', {}, otelContext, async (span) => {
853
+ try {
854
+ persistedData = await backend!.getPersistedData(span)
855
+ } finally {
856
+ span.end()
857
+ }
858
+ })
859
+
860
+ const db: InMemoryDatabase = await InMemoryDatabase.load(persistedData, otelTracer, otelRootSpanContext)
861
+ configureSQLite(db)
862
+
863
+ // TODO: we can't apply the schema at this point, we've already loaded persisted data!
864
+ // Think about what to do about this case.
865
+ // await applySchema(db, schema)
866
+ return Store.createStore<TGraphQLContext>(
867
+ { db, schema, backend, graphQLOptions, otelTracer, otelRootSpanContext },
868
+ span,
869
+ )
870
+ } finally {
871
+ span.end()
872
+ }
873
+ })
874
+ }
875
+
876
+ /** Set up SQLite performance; hasn't been super carefully optimized yet. */
877
+ const configureSQLite = (db: InMemoryDatabase) => {
878
+ db.execute(
879
+ // TODO: revisit these tuning parameters for max performance
880
+ sql`
881
+ PRAGMA page_size=32768;
882
+ PRAGMA cache_size=10000;
883
+ PRAGMA journal_mode='MEMORY'; -- we don't flush to disk before committing a write
884
+ PRAGMA synchronous='OFF';
885
+ PRAGMA temp_store='MEMORY';
886
+ PRAGMA foreign_keys='ON'; -- we want foreign key constraints to be enforced
887
+ `,
888
+ )
889
+ }