@livestore/solid 0.4.0-dev.21 → 0.4.0-dev.23

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 (94) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/StoreRegistryContext.d.ts +56 -0
  3. package/dist/StoreRegistryContext.d.ts.map +1 -0
  4. package/dist/StoreRegistryContext.jsx +60 -0
  5. package/dist/StoreRegistryContext.jsx.map +1 -0
  6. package/dist/__tests__/fixture.d.ts +14 -0
  7. package/dist/__tests__/fixture.d.ts.map +1 -0
  8. package/dist/__tests__/fixture.jsx +13 -0
  9. package/dist/__tests__/fixture.jsx.map +1 -0
  10. package/dist/experimental/components/LiveList.d.ts +24 -0
  11. package/dist/experimental/components/LiveList.d.ts.map +1 -0
  12. package/dist/experimental/components/LiveList.jsx +24 -0
  13. package/dist/experimental/components/LiveList.jsx.map +1 -0
  14. package/dist/experimental/mod.d.ts +2 -0
  15. package/dist/experimental/mod.d.ts.map +1 -0
  16. package/dist/experimental/mod.js +2 -0
  17. package/dist/experimental/mod.js.map +1 -0
  18. package/dist/mod.d.ts +6 -2
  19. package/dist/mod.d.ts.map +1 -1
  20. package/dist/mod.js +4 -2
  21. package/dist/mod.js.map +1 -1
  22. package/dist/useClientDocument.client.test.d.ts +2 -0
  23. package/dist/useClientDocument.client.test.d.ts.map +1 -0
  24. package/dist/useClientDocument.client.test.jsx +177 -0
  25. package/dist/useClientDocument.client.test.jsx.map +1 -0
  26. package/dist/useClientDocument.d.ts +71 -0
  27. package/dist/useClientDocument.d.ts.map +1 -0
  28. package/dist/useClientDocument.js +74 -0
  29. package/dist/useClientDocument.js.map +1 -0
  30. package/dist/useClientDocument.server.test.d.ts +6 -0
  31. package/dist/useClientDocument.server.test.d.ts.map +1 -0
  32. package/dist/useClientDocument.server.test.jsx +76 -0
  33. package/dist/useClientDocument.server.test.jsx.map +1 -0
  34. package/dist/useQuery.client.test.d.ts +2 -0
  35. package/dist/useQuery.client.test.d.ts.map +1 -0
  36. package/dist/useQuery.client.test.jsx +165 -0
  37. package/dist/useQuery.client.test.jsx.map +1 -0
  38. package/dist/useQuery.d.ts +32 -0
  39. package/dist/useQuery.d.ts.map +1 -0
  40. package/dist/useQuery.js +64 -0
  41. package/dist/useQuery.js.map +1 -0
  42. package/dist/useQuery.server.test.d.ts +6 -0
  43. package/dist/useQuery.server.test.d.ts.map +1 -0
  44. package/dist/useQuery.server.test.jsx +88 -0
  45. package/dist/useQuery.server.test.jsx.map +1 -0
  46. package/dist/useStore.client.test.d.ts +2 -0
  47. package/dist/useStore.client.test.d.ts.map +1 -0
  48. package/dist/useStore.client.test.jsx +438 -0
  49. package/dist/useStore.client.test.jsx.map +1 -0
  50. package/dist/useStore.d.ts +91 -0
  51. package/dist/useStore.d.ts.map +1 -0
  52. package/dist/useStore.js +94 -0
  53. package/dist/useStore.js.map +1 -0
  54. package/dist/useStore.server.test.d.ts +6 -0
  55. package/dist/useStore.server.test.d.ts.map +1 -0
  56. package/dist/useStore.server.test.jsx +56 -0
  57. package/dist/useStore.server.test.jsx.map +1 -0
  58. package/dist/utils.d.ts +4 -0
  59. package/dist/utils.d.ts.map +1 -0
  60. package/dist/utils.js +7 -0
  61. package/dist/utils.js.map +1 -0
  62. package/dist/whenever.d.ts +32 -0
  63. package/dist/whenever.d.ts.map +1 -0
  64. package/dist/whenever.js +51 -0
  65. package/dist/whenever.js.map +1 -0
  66. package/package.json +65 -17
  67. package/src/StoreRegistryContext.tsx +70 -0
  68. package/src/__snapshots__/useClientDocument.client.test.tsx.snap +570 -0
  69. package/src/__snapshots__/useQuery.client.test.tsx.snap +1550 -0
  70. package/src/__tests__/fixture.tsx +42 -0
  71. package/src/experimental/components/LiveList.tsx +54 -0
  72. package/src/experimental/mod.ts +1 -0
  73. package/src/mod.ts +6 -2
  74. package/src/useClientDocument.client.test.tsx +299 -0
  75. package/src/useClientDocument.server.test.tsx +107 -0
  76. package/src/useClientDocument.ts +146 -0
  77. package/src/useQuery.client.test.tsx +293 -0
  78. package/src/useQuery.server.test.tsx +128 -0
  79. package/src/useQuery.ts +115 -0
  80. package/src/useStore.client.test.tsx +632 -0
  81. package/src/useStore.server.test.tsx +70 -0
  82. package/src/useStore.ts +179 -0
  83. package/src/utils.ts +10 -0
  84. package/src/whenever.ts +80 -0
  85. package/dist/query.d.ts +0 -4
  86. package/dist/query.d.ts.map +0 -1
  87. package/dist/query.js +0 -15
  88. package/dist/query.js.map +0 -1
  89. package/dist/store.d.ts +0 -6
  90. package/dist/store.d.ts.map +0 -1
  91. package/dist/store.js +0 -99
  92. package/dist/store.js.map +0 -1
  93. package/src/query.ts +0 -22
  94. package/src/store.ts +0 -196
@@ -0,0 +1,42 @@
1
+ import type * as Solid from 'solid-js'
2
+
3
+ import type { UnknownError } from '@livestore/common'
4
+ import {
5
+ type AppState,
6
+ type CreateTodoMvcStoreOptions,
7
+ createTodoMvcStore,
8
+ events,
9
+ type Filter,
10
+ schema,
11
+ type Todo,
12
+ tables,
13
+ } from '@livestore/framework-toolkit/testing'
14
+ import type { Store } from '@livestore/livestore'
15
+ import { StoreInternalsSymbol } from '@livestore/livestore'
16
+ import { Effect, type Scope } from '@livestore/utils/effect'
17
+
18
+ import * as LiveStoreSolid from '../mod.ts'
19
+
20
+ // Re-export shared types, schema, and StoreInternalsSymbol for tests
21
+ export { events, schema, StoreInternalsSymbol, tables }
22
+ export type { AppState, Filter, Todo }
23
+
24
+ export const makeTodoMvcSolid = (
25
+ opts: CreateTodoMvcStoreOptions = {},
26
+ ): Effect.Effect<
27
+ {
28
+ wrapper: ({ children }: any) => Solid.JSX.Element
29
+ store: Store<typeof schema> & LiveStoreSolid.SolidApi
30
+ },
31
+ UnknownError,
32
+ Scope.Scope
33
+ > =>
34
+ Effect.gen(function* () {
35
+ const store = yield* createTodoMvcStore(opts)
36
+
37
+ const storeWithSolidApi = LiveStoreSolid.withSolidApi(store)
38
+
39
+ const wrapper = (props: Solid.ParentProps) => <>{props.children}</>
40
+
41
+ return { wrapper, store: storeWithSolidApi }
42
+ })
@@ -0,0 +1,54 @@
1
+ import type { Accessor, JSX } from 'solid-js'
2
+ import * as Solid from 'solid-js'
3
+
4
+ import type { LiveQueryDef, Store } from '@livestore/livestore'
5
+ import { computed } from '@livestore/livestore'
6
+
7
+ import { useQuery } from '../../useQuery.ts'
8
+
9
+ /*
10
+ TODO:
11
+ - [ ] Bring back incremental rendering (see https://github.com/livestorejs/livestore/pull/55)
12
+ - [ ] Enable exit animations
13
+ */
14
+
15
+ export type LiveListProps<TItem> = {
16
+ items$: LiveQueryDef<ReadonlyArray<TItem>>
17
+ // TODO refactor render-flag to allow for transition animations on add/remove
18
+ renderItem: (item: Accessor<TItem>, index: Accessor<number>) => JSX.Element
19
+ /** Needs to be unique across all list items */
20
+ getKey: (item: TItem, index: number) => string | number
21
+ /** The store instance to use for queries */
22
+ store: Store<any, any>
23
+ }
24
+
25
+ /**
26
+ * This component is a helper component for rendering a list of items for a LiveQuery of an array of items.
27
+ * The idea is that instead of letting Solid handle the rendering of the items array directly,
28
+ * we derive a item LiveQuery for each item which moves the reactivity to the item level when a single item changes.
29
+ *
30
+ * In the future we want to make this component even more efficient by using incremental rendering (https://github.com/livestorejs/livestore/pull/55)
31
+ * e.g. when an item is added/removed/moved to only re-render the affected DOM nodes.
32
+ */
33
+ export const LiveList = <TItem,>(props: LiveListProps<TItem>): JSX.Element => {
34
+ const [config] = Solid.splitProps(props, ['store'])
35
+ const keys = useQuery(
36
+ () => computed((get) => get(props.items$).map((item, index) => props.getKey(item, index))),
37
+ config,
38
+ )
39
+ return <Solid.For each={keys()}>{(key, index) => <ItemWrapper {...props} key={key} index={index} />}</Solid.For>
40
+ }
41
+
42
+ export const ItemWrapper = <TItem,>(
43
+ props: { key: string | number; index: Accessor<number> } & LiveListProps<TItem>,
44
+ ) => {
45
+ const [config] = Solid.splitProps(props, ['store'])
46
+ const item = useQuery(
47
+ () =>
48
+ computed((get) => get(props.items$).find((item, index) => props.getKey(item, index) === props.key)!, {
49
+ deps: [props.key],
50
+ }),
51
+ config,
52
+ )
53
+ return <>{props.renderItem(item, props.index)}</>
54
+ }
@@ -0,0 +1 @@
1
+ export { LiveList, type LiveListProps } from './components/LiveList.tsx'
package/src/mod.ts CHANGED
@@ -1,2 +1,6 @@
1
- export { query } from './query.ts'
2
- export { getStore } from './store.ts'
1
+ export type { Dispatch, SetStateAction, SetStateActionPartial, StateSetters } from '@livestore/framework-toolkit'
2
+ export { StoreRegistry, storeOptions } from '@livestore/livestore'
3
+ export { LiveList, type LiveListProps } from './experimental/components/LiveList.tsx'
4
+ export * from './StoreRegistryContext.tsx'
5
+ export type { UseClientDocumentResult } from './useClientDocument.ts'
6
+ export { type SolidApi, useStore, withSolidApi } from './useStore.ts'
@@ -0,0 +1,299 @@
1
+ /** biome-ignore-all lint/a11y/useValidAriaRole: not needed for testing */
2
+ /** biome-ignore-all lint/a11y/noStaticElementInteractions: not needed for testing */
3
+ import * as LiveStore from '@livestore/livestore'
4
+ import { getAllSimplifiedRootSpans, getSimplifiedRootSpan } from '@livestore/livestore/internal/testing-utils'
5
+ import { Effect, ReadonlyRecord, Schema } from '@livestore/utils/effect'
6
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
7
+ import * as otel from '@opentelemetry/api'
8
+ import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
9
+ import * as SolidTesting from '@solidjs/testing-library'
10
+ import * as Solid from 'solid-js'
11
+
12
+ import { events, makeTodoMvcSolid, StoreInternalsSymbol, tables } from './__tests__/fixture.tsx'
13
+ import type * as LiveStoreSolid from './mod.ts'
14
+
15
+ // const strictMode = process.env.REACT_STRICT_MODE !== undefined
16
+
17
+ // NOTE running tests concurrently doesn't work with the default global db graph
18
+ Vitest.describe('useClientDocument', () => {
19
+ Vitest.scopedLive('should update the data based on component key', () =>
20
+ Effect.gen(function* () {
21
+ const { wrapper, store } = yield* makeTodoMvcSolid({})
22
+
23
+ const [userId, setUserId] = Solid.createSignal('u1')
24
+
25
+ const { result } = SolidTesting.renderHook(
26
+ () => {
27
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
28
+ return { state, setState, id }
29
+ },
30
+ { wrapper },
31
+ )
32
+
33
+ Vitest.expect(result.id()).toBe('u1')
34
+ Vitest.expect(result.state().username).toBe('')
35
+ Vitest.expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
36
+ store.commit(tables.userInfo.set({ username: 'username_u2' }, 'u2'))
37
+
38
+ setUserId('u2')
39
+
40
+ Vitest.expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
41
+ Vitest.expect(result.id()).toBe('u2')
42
+ Vitest.expect(result.state().username).toBe('username_u2')
43
+ }),
44
+ )
45
+
46
+ // TODO add a test that makes sure Solid doesn't re-render when a setter is used to set the same value
47
+
48
+ Vitest.scopedLive('should update the data reactively - via setState', () =>
49
+ Effect.gen(function* () {
50
+ const { wrapper, store } = yield* makeTodoMvcSolid({})
51
+
52
+ const { result } = SolidTesting.renderHook(
53
+ () => {
54
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, 'u1')
55
+ return { state, setState, id }
56
+ },
57
+ { wrapper },
58
+ )
59
+
60
+ Vitest.expect(result.id()).toBe('u1')
61
+ Vitest.expect(result.state().username).toBe('')
62
+
63
+ result.setState({ username: 'username_u1_hello' })
64
+
65
+ Vitest.expect(result.id()).toBe('u1')
66
+ Vitest.expect(result.state().username).toBe('username_u1_hello')
67
+ }),
68
+ )
69
+
70
+ Vitest.scopedLive('should update the data reactively - via raw store commit', () =>
71
+ Effect.gen(function* () {
72
+ const { wrapper, store } = yield* makeTodoMvcSolid({})
73
+
74
+ const { result } = SolidTesting.renderHook(
75
+ () => {
76
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, 'u1')
77
+ return { state, setState, id }
78
+ },
79
+ { wrapper },
80
+ )
81
+
82
+ Vitest.expect(result.id()).toBe('u1')
83
+ Vitest.expect(result.state().username).toBe('')
84
+
85
+ store.commit(events.UserInfoSet({ username: 'username_u1_hello' }, 'u1'))
86
+
87
+ Vitest.expect(result.id()).toBe('u1')
88
+ Vitest.expect(result.state().username).toBe('username_u1_hello')
89
+ }),
90
+ )
91
+
92
+ Vitest.scopedLive('should work for a larger app', () =>
93
+ Effect.gen(function* () {
94
+ const { wrapper, store } = yield* makeTodoMvcSolid({})
95
+
96
+ const allTodos$ = LiveStore.queryDb(
97
+ { query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) },
98
+ { label: 'allTodos' },
99
+ )
100
+
101
+ let globalSetState: LiveStoreSolid.StateSetters<typeof tables.AppRouterSchema> | undefined
102
+ const AppRouter = () => {
103
+ const [state, setState] = store.useClientDocument(
104
+ () => tables.AppRouterSchema,
105
+ () => 'singleton',
106
+ )
107
+
108
+ globalSetState = setState
109
+
110
+ return (
111
+ <div>
112
+ <TasksList />
113
+ <div role={'current-id' as any}>Current Task Id: {state().currentTaskId ?? '-'}</div>
114
+ <Solid.Show when={state().currentTaskId} fallback={'Click on a task to see details'}>
115
+ {(id: Solid.Accessor<string>) => <TaskDetails id={id()} />}
116
+ </Solid.Show>
117
+ </div>
118
+ )
119
+ }
120
+
121
+ const TasksList = () => {
122
+ const allTodos = store.useQuery(() => allTodos$)
123
+
124
+ return (
125
+ <div>
126
+ <Solid.For each={allTodos()}>{(todo) => <div>{todo.id}</div>}</Solid.For>
127
+ </div>
128
+ )
129
+ }
130
+
131
+ const TaskDetails = (props: { id: string }) => {
132
+ const todo = store.useQuery(() =>
133
+ LiveStore.queryDb(tables.todos.where({ id: props.id }).first(), { deps: props.id }),
134
+ )
135
+
136
+ return <div role={'content' as any}>{JSON.stringify(todo())}</div>
137
+ }
138
+
139
+ const { getByRole } = SolidTesting.render(() => <AppRouter />, { wrapper })
140
+
141
+ store.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false }))
142
+
143
+ Vitest.expect(getByRole('current-id').innerHTML).toMatchInlineSnapshot('"Current Task Id: -"')
144
+
145
+ globalSetState!({ currentTaskId: 't1' })
146
+
147
+ Vitest.expect(getByRole('content').innerHTML).toMatchInlineSnapshot(
148
+ `"{"id":"t1","text":"buy milk","completed":false}"`,
149
+ )
150
+
151
+ Vitest.expect(getByRole('current-id').innerHTML).toMatchInlineSnapshot('"Current Task Id: t1"')
152
+
153
+ store.commit(
154
+ events.todoCreated({ id: 't2', text: 'buy eggs', completed: false }),
155
+ events.AppRouterSet({ currentTaskId: 't2' }),
156
+ events.todoCreated({ id: 't3', text: 'buy bread', completed: false }),
157
+ )
158
+
159
+ Vitest.expect(getByRole('current-id').innerHTML).toMatchInlineSnapshot('"Current Task Id: t2"')
160
+ }),
161
+ )
162
+
163
+ Vitest.scopedLive('should work for a useClientDocument query chained with a useTemporary query', () =>
164
+ Effect.gen(function* () {
165
+ const { store, wrapper } = yield* makeTodoMvcSolid({})
166
+
167
+ store.commit(
168
+ events.todoCreated({ id: 't1', text: 'buy milk', completed: false }),
169
+ events.todoCreated({ id: 't2', text: 'buy bread', completed: false }),
170
+ )
171
+
172
+ const [userId, setUserId] = Solid.createSignal('u1')
173
+
174
+ const { result } = SolidTesting.renderHook(
175
+ () => {
176
+ const [_row, _setRow, _id, rowState$] = store.useClientDocument(tables.userInfo, userId)
177
+ const todos = store.useQuery(
178
+ () =>
179
+ LiveStore.queryDb(
180
+ (get) => tables.todos.where('text', 'LIKE', `%${get(rowState$()).text}%`),
181
+ // TODO find a way where explicit `userId` is not needed here
182
+ // possibly by automatically understanding the `get(rowState$)` dependency
183
+ { label: 'todosFiltered', deps: userId() },
184
+ ),
185
+ // TODO introduce a `deps` array which is only needed when a query is parametric
186
+ )
187
+
188
+ return { todos }
189
+ },
190
+ { wrapper },
191
+ )
192
+
193
+ Vitest.expect(result.todos()?.length).toBe(2)
194
+
195
+ // Set text filter for u2 and test with second user
196
+ store.commit(events.UserInfoSet({ username: 'username_u2', text: 'milk' }, 'u2'))
197
+
198
+ setUserId('u2')
199
+
200
+ Vitest.expect(result.todos()?.length).toBe(1)
201
+ }),
202
+ )
203
+
204
+ Vitest.scopedLive('kv client document overwrites value (Schema.Any, no partial merge)', () =>
205
+ Effect.gen(function* () {
206
+ const { wrapper, store } = yield* makeTodoMvcSolid({})
207
+
208
+ const { result } = SolidTesting.renderHook(
209
+ () => {
210
+ const [state, setState] = store.useClientDocument(tables.kv, 'k1')
211
+ return { state, setState }
212
+ },
213
+ { wrapper },
214
+ )
215
+
216
+ Vitest.expect(result.state()).toBe(null)
217
+
218
+ result.setState(1)
219
+ Vitest.expect(result.state()).toEqual(1)
220
+
221
+ result.setState({ b: 2 })
222
+ Vitest.expect(result.state()).toEqual({ b: 2 })
223
+ }),
224
+ )
225
+
226
+ Vitest.describe('otel', () => {
227
+ Vitest.it('should update the data based on component key', async () => {
228
+ const exporter = new InMemorySpanExporter()
229
+
230
+ const provider = new BasicTracerProvider({
231
+ spanProcessors: [new SimpleSpanProcessor(exporter)],
232
+ })
233
+
234
+ const otelTracer = provider.getTracer(`testing-solid`)
235
+
236
+ const span = otelTracer.startSpan('test-root')
237
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
238
+
239
+ await Effect.gen(function* () {
240
+ const { wrapper, store } = yield* makeTodoMvcSolid({
241
+ otelContext,
242
+ otelTracer,
243
+ })
244
+
245
+ const [userId, setUserId] = Solid.createSignal('u1')
246
+
247
+ // Test with first user
248
+ const { result } = SolidTesting.renderHook(
249
+ () => {
250
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
251
+ return { state, setState, id }
252
+ },
253
+ { wrapper },
254
+ )
255
+
256
+ Vitest.expect(result.id()).toBe('u1')
257
+ Vitest.expect(result.state().username).toBe('')
258
+
259
+ // For u2 we'll make sure that the row already exists,
260
+ // so the lazy `insert` will be skipped
261
+ store.commit(events.UserInfoSet({ username: 'username_u2' }, 'u2'))
262
+
263
+ // Test with second user (new hook instance)
264
+ setUserId('u2')
265
+
266
+ Vitest.expect(result.id()).toBe('u2')
267
+ Vitest.expect(result.state().username).toBe('username_u2')
268
+
269
+ span.end()
270
+ }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise)
271
+
272
+ await provider.forceFlush()
273
+
274
+ const mapAttributes = (attributes: otel.Attributes) => {
275
+ return ReadonlyRecord.map(attributes, (val, key) => {
276
+ if (key === 'code.stacktrace') {
277
+ return '<STACKTRACE>'
278
+ } else if (key === 'firstStackInfo') {
279
+ const stackInfo = JSON.parse(val as string) as LiveStore.StackInfo
280
+ // stackInfo.frames.shift() // Removes `renderHook.wrapper` from the stack
281
+ stackInfo.frames.forEach((_) => {
282
+ if (_.name.includes('renderHook.wrapper') === true) {
283
+ _.name = 'renderHook.wrapper'
284
+ }
285
+ _.filePath = '__REPLACED_FOR_SNAPSHOT__'
286
+ })
287
+ return JSON.stringify(stackInfo)
288
+ }
289
+ return val
290
+ })
291
+ }
292
+
293
+ Vitest.expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
294
+ Vitest.expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
295
+
296
+ await provider.shutdown()
297
+ })
298
+ })
299
+ })
@@ -0,0 +1,107 @@
1
+ /**
2
+ * SSR tests for useClientDocument
3
+ * These tests run in node environment with SSR JSX transform using renderToString.
4
+ */
5
+
6
+ import { isServer, renderToString } from 'solid-js/web'
7
+ import { describe, expect, it } from 'vitest'
8
+
9
+ import { provideOtel } from '@livestore/common'
10
+ import * as LiveStore from '@livestore/livestore'
11
+ import { Effect, Schema } from '@livestore/utils/effect'
12
+
13
+ import { events, makeTodoMvcSolid, tables } from './__tests__/fixture.tsx'
14
+
15
+ describe('environment', () => {
16
+ it('runs on server', () => {
17
+ // Use 'window' in globalThis to avoid TypeScript error without DOM lib
18
+ expect('window' in globalThis).toBe(false)
19
+ expect(isServer).toBe(true)
20
+ })
21
+ })
22
+
23
+ describe('useClientDocument SSR', () => {
24
+ it('renders client document with default value to string', async () => {
25
+ await Effect.gen(function* () {
26
+ const { store } = yield* makeTodoMvcSolid({})
27
+
28
+ const UserDisplay = () => {
29
+ const [state] = store.useClientDocument(tables.userInfo, 'u1')
30
+ return <div>Username: {state().username || 'anonymous'}</div>
31
+ }
32
+
33
+ const html = renderToString(() => <UserDisplay />)
34
+
35
+ expect(html).toContain('Username:')
36
+ }).pipe(provideOtel({}), Effect.scoped, Effect.runPromise)
37
+ })
38
+
39
+ it('renders client document with committed value to string', async () => {
40
+ await Effect.gen(function* () {
41
+ const { store } = yield* makeTodoMvcSolid({})
42
+
43
+ store.commit(events.UserInfoSet({ username: 'ssr-user' }, 'u1'))
44
+
45
+ const UserDisplay = () => {
46
+ const [state] = store.useClientDocument(tables.userInfo, 'u1')
47
+ return <div>Username: {state().username}</div>
48
+ }
49
+
50
+ const html = renderToString(() => <UserDisplay />)
51
+
52
+ expect(html).toContain('Username:')
53
+ expect(html).toContain('ssr-user')
54
+ }).pipe(provideOtel({}), Effect.scoped, Effect.runPromise)
55
+ })
56
+
57
+ it('renders larger app with useClientDocument and useQuery to string', async () => {
58
+ await Effect.gen(function* () {
59
+ const { store } = yield* makeTodoMvcSolid({})
60
+
61
+ const allTodos$ = LiveStore.queryDb(
62
+ { query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) },
63
+ { label: 'allTodos' },
64
+ )
65
+
66
+ store.commit(
67
+ events.todoCreated({ id: 't1', text: 'buy milk', completed: false }),
68
+ events.todoCreated({ id: 't2', text: 'buy eggs', completed: true }),
69
+ )
70
+
71
+ const App = () => {
72
+ const [routerState] = store.useClientDocument(tables.AppRouterSchema, 'singleton')
73
+ const allTodos = store.useQuery(allTodos$)
74
+
75
+ return (
76
+ <div>
77
+ <div>Current Task: {routerState().currentTaskId ?? 'none'}</div>
78
+ <div>Total Tasks: {allTodos()?.length}</div>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ const html = renderToString(() => <App />)
84
+
85
+ expect(html).toContain('Current Task:')
86
+ expect(html).toContain('none')
87
+ expect(html).toContain('Total Tasks:')
88
+ expect(html).toContain('2')
89
+ }).pipe(provideOtel({}), Effect.scoped, Effect.runPromise)
90
+ })
91
+
92
+ it('renders kv client document to string', async () => {
93
+ await Effect.gen(function* () {
94
+ const { store } = yield* makeTodoMvcSolid({})
95
+
96
+ const KVDisplay = () => {
97
+ const [state] = store.useClientDocument(tables.kv, 'k1')
98
+ return <div>Value: {JSON.stringify(state())}</div>
99
+ }
100
+
101
+ const html = renderToString(() => <KVDisplay />)
102
+
103
+ expect(html).toContain('Value:')
104
+ expect(html).toContain('null')
105
+ }).pipe(provideOtel({}), Effect.scoped, Effect.runPromise)
106
+ })
107
+ })
@@ -0,0 +1,146 @@
1
+ import * as Solid from 'solid-js'
2
+
3
+ import type { RowQuery } from '@livestore/common'
4
+ import { SessionIdSymbol } from '@livestore/common'
5
+ import { State } from '@livestore/common/schema'
6
+ import { removeUndefinedValues, type StateSetters, validateTableOptions } from '@livestore/framework-toolkit'
7
+ import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
8
+ import { queryDb } from '@livestore/livestore'
9
+
10
+ import { useQueryRef } from './useQuery.ts'
11
+ import { type AccessorMaybe, resolve } from './utils.ts'
12
+
13
+ export type UseClientDocumentResult<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = [
14
+ row: Solid.Accessor<TTableDef['Value']>,
15
+ setRow: StateSetters<TTableDef>,
16
+ id: Solid.Accessor<string>,
17
+ query$: Solid.Accessor<LiveQuery<TTableDef['Value']>>,
18
+ ]
19
+
20
+ /**
21
+ * Type for useClientDocument that enforces id requirement based on table definition.
22
+ * If table has a default id → id parameter is optional.
23
+ * If table has no default id → id parameter is required.
24
+ */
25
+ export interface UseClientDocument {
26
+ // case: table has default id → id is optional
27
+ <
28
+ TTableDef extends State.SQLite.ClientDocumentTableDef.Trait<
29
+ any,
30
+ any,
31
+ any,
32
+ {
33
+ partialSet: boolean
34
+ default: { id: string | SessionIdSymbol; value: any }
35
+ }
36
+ >,
37
+ >(
38
+ table: AccessorMaybe<TTableDef>,
39
+ id: AccessorMaybe<State.SQLite.ClientDocumentTableDef.DefaultIdType<TTableDef> | SessionIdSymbol> | undefined,
40
+ options: Partial<RowQuery.GetOrCreateOptions<TTableDef>> | undefined,
41
+ config: { store: Store<any, any> },
42
+ ): UseClientDocumentResult<TTableDef>
43
+
44
+ // case: table has no default id → id is required
45
+ <
46
+ TTableDef extends State.SQLite.ClientDocumentTableDef.Trait<
47
+ any,
48
+ any,
49
+ any,
50
+ { partialSet: boolean; default: { id: undefined; value: any } }
51
+ >,
52
+ >(
53
+ table: AccessorMaybe<TTableDef>,
54
+ id: AccessorMaybe<string | SessionIdSymbol>,
55
+ options: Partial<RowQuery.GetOrCreateOptions<TTableDef>> | undefined,
56
+ config: { store: Store<any, any> },
57
+ ): UseClientDocumentResult<TTableDef>
58
+ }
59
+
60
+ /**
61
+ * Similar to `Solid.createSignal` but returns a tuple of `[state, setState, id, query$]` for a given table where ...
62
+ *
63
+ * - `state` is the current value of the row (fully decoded according to the table schema)
64
+ * - `setState` is a function that can be used to update the document
65
+ * - `id` is the id of the document
66
+ * - `query$` is a `LiveQuery` that e.g. can be used to subscribe to changes to the document
67
+ *
68
+ * `useClientDocument` only works for client-document tables:
69
+ *
70
+ * ```tsx
71
+ * const MyState = State.SQLite.clientDocument({
72
+ * name: 'MyState',
73
+ * schema: Schema.Struct({
74
+ * showSidebar: Schema.Boolean,
75
+ * }),
76
+ * default: { id: SessionIdSymbol, value: { showSidebar: true } },
77
+ * })
78
+ *
79
+ * const MyComponent = () => {
80
+ * const [{ showSidebar }, setState] = useClientDocument(MyState)
81
+ * return (
82
+ * <div onClick={() => setState({ showSidebar: !showSidebar })}>
83
+ * {showSidebar ? 'Sidebar is open' : 'Sidebar is closed'}
84
+ * </div>
85
+ * )
86
+ * }
87
+ * ```
88
+ *
89
+ * If the table has a default id, `useClientDocument` can be called without an `id` argument. Otherwise, the `id` argument is required.
90
+ */
91
+ export const useClientDocument: UseClientDocument = <TTableDef extends State.SQLite.ClientDocumentTableDef.Any>(
92
+ table: AccessorMaybe<TTableDef>,
93
+ _id: AccessorMaybe<string | SessionIdSymbol> | undefined,
94
+ options: Partial<RowQuery.GetOrCreateOptions<TTableDef>> | undefined,
95
+ config: { store: Store<any, any> },
96
+ ): UseClientDocumentResult<TTableDef> => {
97
+ const id = (): string | SessionIdSymbol => {
98
+ const id = resolve(_id)
99
+ return typeof id === 'string' || id === SessionIdSymbol
100
+ ? id
101
+ : resolve(table)[State.SQLite.ClientDocumentTableDefSymbol].options.default.id
102
+ }
103
+
104
+ const serializedId = () => {
105
+ const _id = id()
106
+ return typeof _id === 'string' ? _id : config.store.sessionId
107
+ }
108
+
109
+ Solid.createComputed(() => validateTableOptions(resolve(table)))
110
+
111
+ type QueryDef = LiveQueryDef<TTableDef['Value']>
112
+ const queryDef = Solid.createMemo<QueryDef>(() =>
113
+ queryDb(
114
+ resolve(table).get(
115
+ id(),
116
+ options?.default !== undefined
117
+ ? {
118
+ default: options.default,
119
+ }
120
+ : undefined,
121
+ ),
122
+ {
123
+ deps: [serializedId(), resolve(table).sqliteDef.name, JSON.stringify(options?.default)],
124
+ },
125
+ ),
126
+ )
127
+
128
+ const queryRef = useQueryRef(queryDef, {
129
+ get otelSpanName() {
130
+ return `LiveStore:useClientDocument:${resolve(table).sqliteDef.name}:${serializedId()}`
131
+ },
132
+ get store() {
133
+ return config.store
134
+ },
135
+ })
136
+
137
+ const setState = (newValueOrFn: TTableDef['Value']) => {
138
+ const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(queryRef.valueRef()) : newValueOrFn
139
+
140
+ if (queryRef.valueRef() === newValue) return
141
+
142
+ config.store.commit(resolve(table).set(removeUndefinedValues(newValue), id()))
143
+ }
144
+
145
+ return [queryRef.valueRef, setState, serializedId, () => queryRef.queryRcRef().value]
146
+ }