@livestore/react 0.0.0-snapshot-2ef046b02334f52613d31dbe06af53487685edc0 → 0.0.0-snapshot-2c861249e50661661613204300b1fc0d902c2e46

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 (63) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreContext.d.ts +10 -6
  3. package/dist/LiveStoreContext.d.ts.map +1 -1
  4. package/dist/LiveStoreContext.js +0 -14
  5. package/dist/LiveStoreContext.js.map +1 -1
  6. package/dist/LiveStoreProvider.d.ts +2 -2
  7. package/dist/LiveStoreProvider.d.ts.map +1 -1
  8. package/dist/LiveStoreProvider.js +5 -1
  9. package/dist/LiveStoreProvider.js.map +1 -1
  10. package/dist/LiveStoreProvider.test.js +6 -5
  11. package/dist/LiveStoreProvider.test.js.map +1 -1
  12. package/dist/__tests__/fixture.d.ts +115 -546
  13. package/dist/__tests__/fixture.d.ts.map +1 -1
  14. package/dist/__tests__/fixture.js +64 -22
  15. package/dist/__tests__/fixture.js.map +1 -1
  16. package/dist/mod.d.ts +4 -4
  17. package/dist/mod.d.ts.map +1 -1
  18. package/dist/mod.js +4 -4
  19. package/dist/mod.js.map +1 -1
  20. package/dist/useClientDocument.d.ts +61 -0
  21. package/dist/useClientDocument.d.ts.map +1 -0
  22. package/dist/useClientDocument.js +73 -0
  23. package/dist/useClientDocument.js.map +1 -0
  24. package/dist/useClientDocument.test.d.ts +2 -0
  25. package/dist/useClientDocument.test.d.ts.map +1 -0
  26. package/dist/{useRow.test.js → useClientDocument.test.js} +38 -43
  27. package/dist/useClientDocument.test.js.map +1 -0
  28. package/dist/useQuery.d.ts +1 -3
  29. package/dist/useQuery.d.ts.map +1 -1
  30. package/dist/useQuery.js +6 -3
  31. package/dist/useQuery.js.map +1 -1
  32. package/dist/useQuery.test.js +16 -17
  33. package/dist/useQuery.test.js.map +1 -1
  34. package/dist/useStore.d.ts +9 -0
  35. package/dist/useStore.d.ts.map +1 -0
  36. package/dist/useStore.js +28 -0
  37. package/dist/useStore.js.map +1 -0
  38. package/package.json +5 -5
  39. package/src/LiveStoreContext.ts +10 -19
  40. package/src/LiveStoreProvider.test.tsx +6 -5
  41. package/src/LiveStoreProvider.tsx +7 -4
  42. package/src/__snapshots__/{useRow.test.tsx.snap → useClientDocument.test.tsx.snap} +50 -30
  43. package/src/__snapshots__/useQuery.test.tsx.snap +8 -8
  44. package/src/__tests__/fixture.tsx +69 -39
  45. package/src/mod.ts +5 -5
  46. package/src/{useRow.test.tsx → useClientDocument.test.tsx} +43 -49
  47. package/src/useClientDocument.ts +149 -0
  48. package/src/useQuery.test.tsx +18 -19
  49. package/src/useQuery.ts +9 -8
  50. package/src/useStore.ts +36 -0
  51. package/dist/useAtom.d.ts +0 -8
  52. package/dist/useAtom.d.ts.map +0 -1
  53. package/dist/useAtom.js +0 -42
  54. package/dist/useAtom.js.map +0 -1
  55. package/dist/useRow.d.ts +0 -64
  56. package/dist/useRow.d.ts.map +0 -1
  57. package/dist/useRow.js +0 -108
  58. package/dist/useRow.js.map +0 -1
  59. package/dist/useRow.test.d.ts +0 -2
  60. package/dist/useRow.test.d.ts.map +0 -1
  61. package/dist/useRow.test.js.map +0 -1
  62. package/src/useAtom.ts +0 -66
  63. package/src/useRow.ts +0 -210
@@ -1,9 +1,9 @@
1
1
  import { makeInMemoryAdapter } from '@livestore/adapter-web'
2
2
  import { provideOtel } from '@livestore/common'
3
- import { DbSchema, makeSchema } from '@livestore/common/schema'
4
- import type { LiveStoreContextRunning } from '@livestore/livestore'
3
+ import { Events, makeSchema, State } from '@livestore/common/schema'
4
+ import type { Store } from '@livestore/livestore'
5
5
  import { createStore } from '@livestore/livestore'
6
- import { Effect } from '@livestore/utils/effect'
6
+ import { Effect, Schema } from '@livestore/utils/effect'
7
7
  import type * as otel from '@opentelemetry/api'
8
8
  import React from 'react'
9
9
 
@@ -22,45 +22,70 @@ export type AppState = {
22
22
  filter: Filter
23
23
  }
24
24
 
25
- export const todos = DbSchema.table(
26
- 'todos',
27
- {
28
- id: DbSchema.text({ primaryKey: true }),
29
- text: DbSchema.text({ default: '', nullable: false }),
30
- completed: DbSchema.boolean({ default: false, nullable: false }),
25
+ const todos = State.SQLite.table({
26
+ name: 'todos',
27
+ columns: {
28
+ id: State.SQLite.text({ primaryKey: true }),
29
+ text: State.SQLite.text({ default: '', nullable: false }),
30
+ completed: State.SQLite.boolean({ default: false, nullable: false }),
31
31
  },
32
- { deriveMutations: { clientOnly: true }, isSingleton: false },
33
- )
34
-
35
- export const app = DbSchema.table(
36
- 'app',
37
- {
38
- id: DbSchema.text({ primaryKey: true, default: 'static' }),
39
- newTodoText: DbSchema.text({ default: '', nullable: true }),
40
- filter: DbSchema.text({ default: 'all', nullable: false }),
32
+ })
33
+
34
+ const app = State.SQLite.table({
35
+ name: 'app',
36
+ columns: {
37
+ id: State.SQLite.text({ primaryKey: true, default: 'static' }),
38
+ newTodoText: State.SQLite.text({ default: '', nullable: true }),
39
+ filter: State.SQLite.text({ default: 'all', nullable: false }),
41
40
  },
42
- { isSingleton: true },
43
- )
44
-
45
- export const userInfo = DbSchema.table(
46
- 'UserInfo',
47
- {
48
- username: DbSchema.text({ default: '' }),
49
- text: DbSchema.text({ default: '' }),
41
+ })
42
+
43
+ const userInfo = State.SQLite.clientDocument({
44
+ name: 'UserInfo',
45
+ schema: Schema.Struct({
46
+ username: Schema.String,
47
+ text: Schema.String,
48
+ }),
49
+ default: { value: { username: '', text: '' } },
50
+ })
51
+
52
+ const AppRouterSchema = State.SQLite.clientDocument({
53
+ name: 'AppRouter',
54
+ schema: Schema.Struct({
55
+ currentTaskId: Schema.String.pipe(Schema.NullOr),
56
+ }),
57
+ default: {
58
+ value: { currentTaskId: null },
59
+ id: 'singleton',
50
60
  },
51
- { deriveMutations: { clientOnly: true } },
52
- )
61
+ })
62
+
63
+ export const events = {
64
+ todoCreated: Events.synced({
65
+ name: 'todoCreated',
66
+ schema: Schema.Struct({ id: Schema.String, text: Schema.String, completed: Schema.Boolean }),
67
+ }),
68
+ todoUpdated: Events.synced({
69
+ name: 'todoUpdated',
70
+ schema: Schema.Struct({
71
+ id: Schema.String,
72
+ text: Schema.String.pipe(Schema.optional),
73
+ completed: Schema.Boolean.pipe(Schema.optional),
74
+ }),
75
+ }),
76
+ AppRouterSet: AppRouterSchema.set,
77
+ UserInfoSet: userInfo.set,
78
+ }
53
79
 
54
- export const AppRouterSchema = DbSchema.table(
55
- 'AppRouter',
56
- {
57
- currentTaskId: DbSchema.text({ default: null, nullable: true }),
58
- },
59
- { isSingleton: true, deriveMutations: { clientOnly: true } },
60
- )
80
+ const materializers = State.SQLite.materializers(events, {
81
+ todoCreated: ({ id, text, completed }) => todos.insert({ id, text, completed }),
82
+ todoUpdated: ({ id, text, completed }) => todos.update({ completed, text }).where({ id }),
83
+ })
61
84
 
62
85
  export const tables = { todos, app, userInfo, AppRouterSchema }
63
- export const schema = makeSchema({ tables })
86
+
87
+ const state = State.SQLite.makeState({ tables, materializers })
88
+ export const schema = makeSchema({ state, events })
64
89
 
65
90
  export const makeTodoMvcReact = ({
66
91
  otelTracer,
@@ -87,15 +112,20 @@ export const makeTodoMvcReact = ({
87
112
  }
88
113
  }
89
114
 
90
- const store = yield* createStore({
115
+ const store: Store<any> = yield* createStore({
91
116
  schema,
92
117
  storeId: 'default',
93
118
  adapter: makeInMemoryAdapter(),
94
119
  debug: { instanceId: 'test' },
95
120
  })
96
121
 
122
+ const storeWithReactApi = LiveStoreReact.withReactApi(store)
123
+
97
124
  // TODO improve typing of `LiveStoreContext`
98
- const storeContext = { stage: 'running', store } as any as LiveStoreContextRunning
125
+ const storeContext = {
126
+ stage: 'running' as const,
127
+ store: storeWithReactApi,
128
+ }
99
129
 
100
130
  const MaybeStrictMode = strictMode ? React.StrictMode : React.Fragment
101
131
 
@@ -109,5 +139,5 @@ export const makeTodoMvcReact = ({
109
139
 
110
140
  const renderCount = makeRenderCount()
111
141
 
112
- return { wrapper, store, renderCount }
142
+ return { wrapper, store: storeWithReactApi, renderCount }
113
143
  }).pipe(provideOtel({ parentSpanContext: otelContext, otelTracer }))
package/src/mod.ts CHANGED
@@ -1,12 +1,12 @@
1
- export { LiveStoreContext, useStore } from './LiveStoreContext.js'
1
+ export { LiveStoreContext, type ReactApi } from './LiveStoreContext.js'
2
+ export { useStore, withReactApi } from './useStore.js'
2
3
  export { LiveStoreProvider } from './LiveStoreProvider.js'
3
- export { useQuery } from './useQuery.js'
4
4
  export { useStackInfo } from './utils/stack-info.js'
5
+ export { useQuery, useQueryRef } from './useQuery.js'
5
6
  export {
6
- useRow,
7
+ useClientDocument,
7
8
  type StateSetters,
8
9
  type SetStateAction,
9
10
  type Dispatch,
10
11
  type UseRowResult as UseStateResult,
11
- } from './useRow.js'
12
- export { useAtom } from './useAtom.js'
12
+ } from './useClientDocument.js'
@@ -8,14 +8,14 @@ import * as ReactTesting from '@testing-library/react'
8
8
  import React from 'react'
9
9
  import { beforeEach, expect, it } from 'vitest'
10
10
 
11
- import { AppRouterSchema, makeTodoMvcReact, tables, todos } from './__tests__/fixture.js'
12
- import * as LiveStoreReact from './mod.js'
11
+ import { events, makeTodoMvcReact, tables } from './__tests__/fixture.js'
12
+ import type * as LiveStoreReact from './mod.js'
13
13
  import { __resetUseRcResourceCache } from './useRcResource.js'
14
14
 
15
15
  // const strictMode = process.env.REACT_STRICT_MODE !== undefined
16
16
 
17
17
  // NOTE running tests concurrently doesn't work with the default global db graph
18
- Vitest.describe('useRow', () => {
18
+ Vitest.describe('useClientDocument', () => {
19
19
  beforeEach(() => {
20
20
  __resetUseRcResourceCache()
21
21
  })
@@ -28,22 +28,22 @@ Vitest.describe('useRow', () => {
28
28
  (userId: string) => {
29
29
  renderCount.inc()
30
30
 
31
- const [state, setState] = LiveStoreReact.useRow(tables.userInfo, userId)
32
- return { state, setState }
31
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
32
+ return { state, setState, id }
33
33
  },
34
34
  { wrapper, initialProps: 'u1' },
35
35
  )
36
36
 
37
- expect(result.current.state.id).toBe('u1')
37
+ expect(result.current.id).toBe('u1')
38
38
  expect(result.current.state.username).toBe('')
39
39
  expect(renderCount.val).toBe(1)
40
40
  expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
41
- store.commit(tables.userInfo.insert({ id: 'u2', username: 'username_u2' }))
41
+ store.commit(tables.userInfo.set({ username: 'username_u2' }, 'u2'))
42
42
 
43
43
  rerender('u2')
44
44
 
45
45
  expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
46
- expect(result.current.state.id).toBe('u2')
46
+ expect(result.current.id).toBe('u2')
47
47
  expect(result.current.state.username).toBe('username_u2')
48
48
  expect(renderCount.val).toBe(2)
49
49
  }),
@@ -53,31 +53,31 @@ Vitest.describe('useRow', () => {
53
53
 
54
54
  Vitest.scopedLive('should update the data reactively - via setState', () =>
55
55
  Effect.gen(function* () {
56
- const { wrapper, renderCount } = yield* makeTodoMvcReact({})
56
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({})
57
57
 
58
58
  const { result } = ReactTesting.renderHook(
59
59
  (userId: string) => {
60
60
  renderCount.inc()
61
61
 
62
- const [state, setState] = LiveStoreReact.useRow(tables.userInfo, userId)
63
- return { state, setState }
62
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
63
+ return { state, setState, id }
64
64
  },
65
65
  { wrapper, initialProps: 'u1' },
66
66
  )
67
67
 
68
- expect(result.current.state.id).toBe('u1')
68
+ expect(result.current.id).toBe('u1')
69
69
  expect(result.current.state.username).toBe('')
70
70
  expect(renderCount.val).toBe(1)
71
71
 
72
- ReactTesting.act(() => result.current.setState.username('username_u1_hello'))
72
+ ReactTesting.act(() => result.current.setState({ username: 'username_u1_hello' }))
73
73
 
74
- expect(result.current.state.id).toBe('u1')
74
+ expect(result.current.id).toBe('u1')
75
75
  expect(result.current.state.username).toBe('username_u1_hello')
76
76
  expect(renderCount.val).toBe(2)
77
77
  }),
78
78
  )
79
79
 
80
- Vitest.scopedLive('should update the data reactively - via raw store mutation', () =>
80
+ Vitest.scopedLive('should update the data reactively - via raw store commit', () =>
81
81
  Effect.gen(function* () {
82
82
  const { wrapper, store, renderCount } = yield* makeTodoMvcReact({})
83
83
 
@@ -85,21 +85,19 @@ Vitest.describe('useRow', () => {
85
85
  (userId: string) => {
86
86
  renderCount.inc()
87
87
 
88
- const [state, setState] = LiveStoreReact.useRow(tables.userInfo, userId)
89
- return { state, setState }
88
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
89
+ return { state, setState, id }
90
90
  },
91
91
  { wrapper, initialProps: 'u1' },
92
92
  )
93
93
 
94
- expect(result.current.state.id).toBe('u1')
94
+ expect(result.current.id).toBe('u1')
95
95
  expect(result.current.state.username).toBe('')
96
96
  expect(renderCount.val).toBe(1)
97
97
 
98
- ReactTesting.act(() =>
99
- store.commit(tables.userInfo.update({ where: { id: 'u1' }, values: { username: 'username_u1_hello' } })),
100
- )
98
+ ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u1_hello' }, 'u1')))
101
99
 
102
- expect(result.current.state.id).toBe('u1')
100
+ expect(result.current.id).toBe('u1')
103
101
  expect(result.current.state.username).toBe('username_u1_hello')
104
102
  expect(renderCount.val).toBe(2)
105
103
  }),
@@ -110,21 +108,21 @@ Vitest.describe('useRow', () => {
110
108
  const { wrapper, store, renderCount } = yield* makeTodoMvcReact({})
111
109
 
112
110
  const allTodos$ = LiveStore.queryDb(
113
- { query: `select * from todos`, schema: Schema.Array(tables.todos.schema) },
111
+ { query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) },
114
112
  { label: 'allTodos' },
115
113
  )
116
114
 
117
- let globalSetState: LiveStoreReact.StateSetters<typeof AppRouterSchema> | undefined
115
+ let globalSetState: LiveStoreReact.StateSetters<typeof tables.AppRouterSchema> | undefined
118
116
  const AppRouter: React.FC = () => {
119
117
  renderCount.inc()
120
118
 
121
- const [state, setState] = LiveStoreReact.useRow(AppRouterSchema)
119
+ const [state, setState] = store.useClientDocument(tables.AppRouterSchema, 'singleton')
122
120
 
123
121
  globalSetState = setState
124
122
 
125
123
  return (
126
124
  <div>
127
- <TasksList setTaskId={setState.currentTaskId} />
125
+ <TasksList setTaskId={(taskId) => setState({ currentTaskId: taskId })} />
128
126
  <div role="current-id">Current Task Id: {state.currentTaskId ?? '-'}</div>
129
127
  {state.currentTaskId ? <TaskDetails id={state.currentTaskId} /> : <div>Click on a task to see details</div>}
130
128
  </div>
@@ -132,7 +130,7 @@ Vitest.describe('useRow', () => {
132
130
  }
133
131
 
134
132
  const TasksList: React.FC<{ setTaskId: (_: string) => void }> = ({ setTaskId }) => {
135
- const allTodos = LiveStoreReact.useQuery(allTodos$)
133
+ const allTodos = store.useQuery(allTodos$)
136
134
 
137
135
  return (
138
136
  <div>
@@ -146,7 +144,7 @@ Vitest.describe('useRow', () => {
146
144
  }
147
145
 
148
146
  const TaskDetails: React.FC<{ id: string }> = ({ id }) => {
149
- const [todo] = LiveStoreReact.useRow(todos, id)
147
+ const todo = store.useQuery(LiveStore.queryDb(tables.todos.where({ id }).first(), { deps: id }))
150
148
  return <div role="content">{JSON.stringify(todo)}</div>
151
149
  }
152
150
 
@@ -156,7 +154,7 @@ Vitest.describe('useRow', () => {
156
154
 
157
155
  ReactTesting.act(() =>
158
156
  store.commit(
159
- LiveStore.rawSqlMutation({
157
+ LiveStore.rawSqlEvent({
160
158
  sql: LiveStore.sql`INSERT INTO todos (id, text, completed) VALUES ('t1', 'buy milk', 0)`,
161
159
  }),
162
160
  ),
@@ -165,7 +163,7 @@ Vitest.describe('useRow', () => {
165
163
  expect(renderCount.val).toBe(1)
166
164
  expect(renderResult.getByRole('current-id').innerHTML).toMatchInlineSnapshot('"Current Task Id: -"')
167
165
 
168
- ReactTesting.act(() => globalSetState!.currentTaskId('t1'))
166
+ ReactTesting.act(() => globalSetState!({ currentTaskId: 't1' }))
169
167
 
170
168
  expect(renderCount.val).toBe(2)
171
169
  expect(renderResult.getByRole('content').innerHTML).toMatchInlineSnapshot(
@@ -176,13 +174,9 @@ Vitest.describe('useRow', () => {
176
174
 
177
175
  ReactTesting.act(() =>
178
176
  store.commit(
179
- LiveStore.rawSqlMutation({
180
- sql: LiveStore.sql`INSERT INTO todos (id, text, completed) VALUES ('t2', 'buy eggs', 0)`,
181
- }),
182
- AppRouterSchema.update({ where: { id: 'singleton' }, values: { currentTaskId: 't2' } }),
183
- LiveStore.rawSqlMutation({
184
- sql: LiveStore.sql`INSERT INTO todos (id, text, completed) VALUES ('t3', 'buy bread', 0)`,
185
- }),
177
+ events.todoCreated({ id: 't2', text: 'buy eggs', completed: false }),
178
+ events.AppRouterSet({ currentTaskId: 't2' }),
179
+ events.todoCreated({ id: 't3', text: 'buy bread', completed: false }),
186
180
  ),
187
181
  )
188
182
 
@@ -191,23 +185,23 @@ Vitest.describe('useRow', () => {
191
185
  }),
192
186
  )
193
187
 
194
- Vitest.scopedLive('should work for a useRow query chained with a useTemporary query', () =>
188
+ Vitest.scopedLive('should work for a useClientDocument query chained with a useTemporary query', () =>
195
189
  Effect.gen(function* () {
196
190
  const { store, wrapper, renderCount } = yield* makeTodoMvcReact({})
197
191
 
198
192
  store.commit(
199
- todos.insert({ id: 't1', text: 'buy milk', completed: false }),
200
- todos.insert({ id: 't2', text: 'buy bread', completed: false }),
193
+ events.todoCreated({ id: 't1', text: 'buy milk', completed: false }),
194
+ events.todoCreated({ id: 't2', text: 'buy bread', completed: false }),
201
195
  )
202
196
 
203
197
  const { result, unmount, rerender } = ReactTesting.renderHook(
204
198
  (userId: string) => {
205
199
  renderCount.inc()
206
200
 
207
- const [_row, _setRow, rowState$] = LiveStoreReact.useRow(tables.userInfo, userId)
208
- const todos = LiveStoreReact.useQuery(
201
+ const [_row, _setRow, _id, rowState$] = store.useClientDocument(tables.userInfo, userId)
202
+ const todos = store.useQuery(
209
203
  LiveStore.queryDb(
210
- (get) => tables.todos.query.where('text', 'LIKE', `%${get(rowState$).text}%`),
204
+ (get) => tables.todos.where('text', 'LIKE', `%${get(rowState$).text}%`),
211
205
  // TODO find a way where explicit `userId` is not needed here
212
206
  // possibly by automatically understanding the `get(rowState$)` dependency
213
207
  { label: 'todosFiltered', deps: userId },
@@ -220,7 +214,7 @@ Vitest.describe('useRow', () => {
220
214
  { wrapper, initialProps: 'u1' },
221
215
  )
222
216
 
223
- ReactTesting.act(() => store.commit(tables.userInfo.insert({ id: 'u2', username: 'username_u2', text: 'milk' })))
217
+ ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u2', text: 'milk' }, 'u2')))
224
218
 
225
219
  expect(result.current.todos.length).toBe(2)
226
220
  expect(renderCount.val).toBe(1)
@@ -262,23 +256,23 @@ Vitest.describe('useRow', () => {
262
256
  (userId: string) => {
263
257
  renderCount.inc()
264
258
 
265
- const [state, setState] = LiveStoreReact.useRow(tables.userInfo, userId)
266
- return { state, setState }
259
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
260
+ return { state, setState, id }
267
261
  },
268
262
  { wrapper, initialProps: 'u1' },
269
263
  )
270
264
 
271
- expect(result.current.state.id).toBe('u1')
265
+ expect(result.current.id).toBe('u1')
272
266
  expect(result.current.state.username).toBe('')
273
267
  expect(renderCount.val).toBe(1)
274
268
 
275
269
  // For u2 we'll make sure that the row already exists,
276
270
  // so the lazy `insert` will be skipped
277
- ReactTesting.act(() => store.commit(tables.userInfo.insert({ id: 'u2', username: 'username_u2' })))
271
+ ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u2' }, 'u2')))
278
272
 
279
273
  rerender('u2')
280
274
 
281
- expect(result.current.state.id).toBe('u2')
275
+ expect(result.current.id).toBe('u2')
282
276
  expect(result.current.state.username).toBe('username_u2')
283
277
  expect(renderCount.val).toBe(2)
284
278
 
@@ -0,0 +1,149 @@
1
+ import type { RowQuery } from '@livestore/common'
2
+ import { SessionIdSymbol } from '@livestore/common'
3
+ import { State } from '@livestore/common/schema'
4
+ import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
5
+ import { queryDb } from '@livestore/livestore'
6
+ import { shouldNeverHappen } from '@livestore/utils'
7
+ import React from 'react'
8
+
9
+ import { LiveStoreContext } from './LiveStoreContext.js'
10
+ import { useQueryRef } from './useQuery.js'
11
+
12
+ export type UseRowResult<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = [
13
+ row: TTableDef['Value'],
14
+ setRow: StateSetters<TTableDef>,
15
+ id: string,
16
+ query$: LiveQuery<TTableDef['Value']>,
17
+ ]
18
+
19
+ /**
20
+ * Similar to `React.useState` but returns a tuple of `[state, setState, id, query$]` for a given table where ...
21
+ *
22
+ * - `state` is the current value of the row (fully decoded according to the table schema)
23
+ * - `setState` is a function that can be used to update the document
24
+ * - `id` is the id of the document
25
+ * - `query$` is a `LiveQuery` that e.g. can be used to subscribe to changes to the document
26
+ *
27
+ * `useClientDocument` only works for client-document tables:
28
+ *
29
+ * ```tsx
30
+ * const MyState = State.SQLite.clientDocument({
31
+ * name: 'MyState',
32
+ * schema: Schema.Struct({
33
+ * showSidebar: Schema.Boolean,
34
+ * }),
35
+ * default: { id: SessionIdSymbol, value: { showSidebar: true } },
36
+ * })
37
+ *
38
+ * const MyComponent = () => {
39
+ * const [{ showSidebar }, setState] = useClientDocument(MyState)
40
+ * return (
41
+ * <div onClick={() => setState({ showSidebar: !showSidebar })}>
42
+ * {showSidebar ? 'Sidebar is open' : 'Sidebar is closed'}
43
+ * </div>
44
+ * )
45
+ * }
46
+ * ```
47
+ *
48
+ * If the table has a default id, `useClientDocument` can be called without an `id` argument. Otherwise, the `id` argument is required.
49
+ */
50
+ export const useClientDocument: {
51
+ // case: with default id
52
+ <
53
+ TTableDef extends State.SQLite.ClientDocumentTableDef.Trait<
54
+ any,
55
+ any,
56
+ any,
57
+ { partialSet: boolean; default: { id: string | SessionIdSymbol; value: any } }
58
+ >,
59
+ >(
60
+ table: TTableDef,
61
+ id?: State.SQLite.ClientDocumentTableDef.DefaultIdType<TTableDef> | SessionIdSymbol,
62
+ options?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
63
+ ): UseRowResult<TTableDef>
64
+
65
+ // case: no default id → id arg is required
66
+ <
67
+ TTableDef extends State.SQLite.ClientDocumentTableDef.Trait<
68
+ any,
69
+ any,
70
+ any,
71
+ { partialSet: boolean; default: { id: string | SessionIdSymbol | undefined; value: any } }
72
+ >,
73
+ >(
74
+ table: TTableDef,
75
+ // TODO adjust so it works with arbitrary primary keys or unique constraints
76
+ id: State.SQLite.ClientDocumentTableDef.DefaultIdType<TTableDef> | string | SessionIdSymbol,
77
+ options?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
78
+ ): UseRowResult<TTableDef>
79
+ } = <TTableDef extends State.SQLite.ClientDocumentTableDef.Any>(
80
+ table: TTableDef,
81
+ idOrOptions?: string | SessionIdSymbol,
82
+ options_?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
83
+ storeArg?: { store?: Store },
84
+ ): UseRowResult<TTableDef> => {
85
+ const id =
86
+ typeof idOrOptions === 'string' || idOrOptions === SessionIdSymbol
87
+ ? idOrOptions
88
+ : table[State.SQLite.ClientDocumentTableDefSymbol].options.default.id
89
+
90
+ const options: Partial<RowQuery.GetOrCreateOptions<TTableDef>> | undefined =
91
+ typeof idOrOptions === 'string' || idOrOptions === SessionIdSymbol ? options_ : idOrOptions
92
+
93
+ const { default: defaultValues } = options ?? {}
94
+
95
+ React.useMemo(() => validateTableOptions(table), [table])
96
+
97
+ const tableName = table.sqliteDef.name
98
+
99
+ const store =
100
+ storeArg?.store ??
101
+ // eslint-disable-next-line react-hooks/rules-of-hooks
102
+ React.useContext(LiveStoreContext)?.store ??
103
+ shouldNeverHappen(`No store provided to useClientDocument`)
104
+
105
+ // console.debug('useClientDocument', tableName, id)
106
+
107
+ const idStr: string = id === SessionIdSymbol ? store.clientSession.sessionId : id
108
+
109
+ type QueryDef = LiveQueryDef<TTableDef['Value']>
110
+ const queryDef: QueryDef = React.useMemo(
111
+ () =>
112
+ queryDb(table.get(id!, { default: defaultValues! }), {
113
+ deps: [idStr!, table.sqliteDef.name, JSON.stringify(defaultValues)],
114
+ }),
115
+ [table, id, defaultValues, idStr],
116
+ )
117
+
118
+ const queryRef = useQueryRef(queryDef, {
119
+ otelSpanName: `LiveStore:useClientDocument:${tableName}:${idStr}`,
120
+ store: storeArg?.store,
121
+ })
122
+
123
+ const setState = React.useMemo<StateSetters<TTableDef>>(
124
+ () => (newValueOrFn: TTableDef['Value']) => {
125
+ const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(queryRef.valueRef.current) : newValueOrFn
126
+ if (queryRef.valueRef.current === newValue) return
127
+
128
+ store.commit(table.set(newValue, id as any))
129
+ },
130
+ [id, queryRef.valueRef, store, table],
131
+ )
132
+
133
+ return [queryRef.valueRef.current, setState, idStr, queryRef.queryRcRef.value]
134
+ }
135
+
136
+ export type Dispatch<A> = (action: A) => void
137
+ export type SetStateAction<S> = Partial<S> | ((previousValue: S) => Partial<S>)
138
+
139
+ export type StateSetters<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = Dispatch<
140
+ SetStateAction<TTableDef['Value']>
141
+ >
142
+
143
+ const validateTableOptions = (table: State.SQLite.TableDef<any, any>) => {
144
+ if (State.SQLite.tableIsClientDocumentTable(table) === false) {
145
+ return shouldNeverHappen(
146
+ `useClientDocument called on table "${table.sqliteDef.name}" which is not a client document table`,
147
+ )
148
+ }
149
+ }