@livestore/livestore 0.4.0-dev.21 → 0.4.0-dev.22
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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/effect/LiveStore.d.ts +123 -2
- package/dist/effect/LiveStore.d.ts.map +1 -1
- package/dist/effect/LiveStore.js +195 -1
- package/dist/effect/LiveStore.js.map +1 -1
- package/dist/effect/mod.d.ts +1 -1
- package/dist/effect/mod.d.ts.map +1 -1
- package/dist/effect/mod.js +3 -1
- package/dist/effect/mod.js.map +1 -1
- package/dist/mod.d.ts +1 -0
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +1 -0
- package/dist/mod.js.map +1 -1
- package/dist/store/StoreRegistry.d.ts +190 -0
- package/dist/store/StoreRegistry.d.ts.map +1 -0
- package/dist/store/StoreRegistry.js +244 -0
- package/dist/store/StoreRegistry.js.map +1 -0
- package/dist/store/StoreRegistry.test.d.ts +2 -0
- package/dist/store/StoreRegistry.test.d.ts.map +1 -0
- package/dist/store/StoreRegistry.test.js +380 -0
- package/dist/store/StoreRegistry.test.js.map +1 -0
- package/dist/store/create-store.d.ts +50 -4
- package/dist/store/create-store.d.ts.map +1 -1
- package/dist/store/create-store.js +19 -0
- package/dist/store/create-store.js.map +1 -1
- package/dist/store/devtools.d.ts.map +1 -1
- package/dist/store/devtools.js +13 -0
- package/dist/store/devtools.js.map +1 -1
- package/dist/store/store-types.d.ts +10 -25
- package/dist/store/store-types.d.ts.map +1 -1
- package/dist/store/store-types.js.map +1 -1
- package/dist/store/store.d.ts +23 -6
- package/dist/store/store.d.ts.map +1 -1
- package/dist/store/store.js +20 -2
- package/dist/store/store.js.map +1 -1
- package/docs/building-with-livestore/complex-ui-state/index.md +0 -2
- package/docs/building-with-livestore/crud/index.md +0 -2
- package/docs/building-with-livestore/data-modeling/index.md +29 -0
- package/docs/building-with-livestore/examples/todo-workspaces/index.md +0 -6
- package/docs/building-with-livestore/opentelemetry/index.md +25 -6
- package/docs/building-with-livestore/rules-for-ai-agents/index.md +2 -2
- package/docs/building-with-livestore/state/sql-queries/index.md +22 -0
- package/docs/building-with-livestore/state/sqlite-schema/index.md +2 -2
- package/docs/building-with-livestore/store/index.md +344 -0
- package/docs/framework-integrations/react-integration/index.md +380 -361
- package/docs/framework-integrations/vue-integration/index.md +2 -2
- package/docs/getting-started/expo/index.md +189 -43
- package/docs/getting-started/react-web/index.md +77 -24
- package/docs/getting-started/vue/index.md +3 -3
- package/docs/index.md +1 -2
- package/docs/llms.txt +0 -1
- package/docs/misc/troubleshooting/index.md +3 -3
- package/docs/overview/how-livestore-works/index.md +1 -1
- package/docs/overview/introduction/index.md +409 -1
- package/docs/overview/why-livestore/index.md +108 -2
- package/docs/patterns/auth/index.md +185 -34
- package/docs/patterns/effect/index.md +11 -1
- package/docs/patterns/storybook/index.md +43 -26
- package/docs/platform-adapters/expo-adapter/index.md +36 -19
- package/docs/platform-adapters/web-adapter/index.md +71 -2
- package/docs/tutorial/1-setup-starter-project/index.md +5 -5
- package/docs/tutorial/3-read-and-write-todos-via-livestore/index.md +54 -35
- package/docs/tutorial/5-expand-business-logic/index.md +1 -1
- package/docs/tutorial/6-persist-ui-state/index.md +12 -12
- package/package.json +6 -6
- package/src/effect/LiveStore.ts +385 -3
- package/src/effect/mod.ts +13 -1
- package/src/mod.ts +1 -0
- package/src/store/StoreRegistry.test.ts +516 -0
- package/src/store/StoreRegistry.ts +393 -0
- package/src/store/create-store.ts +50 -4
- package/src/store/devtools.ts +15 -0
- package/src/store/store-types.ts +17 -5
- package/src/store/store.ts +25 -5
- package/docs/building-with-livestore/examples/index.md +0 -30
|
@@ -10,31 +10,43 @@ Use the `syncPayload` store option to send a custom payload to your sync backend
|
|
|
10
10
|
|
|
11
11
|
The following example sends the authenticated user's JWT to the server.
|
|
12
12
|
|
|
13
|
-
## `patterns/auth/
|
|
13
|
+
## `patterns/auth/store-with-auth.tsx`
|
|
14
14
|
|
|
15
|
-
```tsx filename="patterns/auth/
|
|
15
|
+
```tsx filename="patterns/auth/store-with-auth.tsx"
|
|
16
16
|
|
|
17
|
-
const schema = {} as
|
|
17
|
+
const schema = {} as LiveStoreSchema
|
|
18
18
|
const storeId = 'demo-store'
|
|
19
19
|
const user = { jwt: 'user-token' }
|
|
20
|
-
const children: ReactNode = null
|
|
21
20
|
const adapter = makeInMemoryAdapter()
|
|
22
21
|
|
|
23
22
|
// ---cut---
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
adapter
|
|
29
|
-
batchUpdates
|
|
30
|
-
syncPayload
|
|
23
|
+
const useAppStore = () =>
|
|
24
|
+
useStore({
|
|
25
|
+
storeId,
|
|
26
|
+
schema,
|
|
27
|
+
adapter,
|
|
28
|
+
batchUpdates,
|
|
29
|
+
syncPayload: {
|
|
31
30
|
authToken: user.jwt, // Using a JWT
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const App = () => {
|
|
35
|
+
const [storeRegistry] = useState(() => new StoreRegistry())
|
|
36
|
+
return (
|
|
37
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
38
|
+
<StoreRegistryProvider storeRegistry={storeRegistry}>
|
|
39
|
+
<AppContent />
|
|
40
|
+
</StoreRegistryProvider>
|
|
41
|
+
</Suspense>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const AppContent = () => {
|
|
46
|
+
const _store = useAppStore()
|
|
47
|
+
// Use the store in your components
|
|
48
|
+
return <div>{/* Your app content */}</div>
|
|
49
|
+
}
|
|
38
50
|
```
|
|
39
51
|
|
|
40
52
|
On the sync server, validate the token and allow or reject the sync based on the result. See the following example:
|
|
@@ -184,6 +196,133 @@ export const verifyJwt = (token: string): Claims => {
|
|
|
184
196
|
|
|
185
197
|
You can extend `ensureAuthorized` to project additional claims, memoise verification per `authToken`, or enforce application-specific policies without changing LiveStore internals.
|
|
186
198
|
|
|
199
|
+
## Cookie-based authentication
|
|
200
|
+
|
|
201
|
+
If you prefer cookie-based authentication (e.g., with [better-auth](https://www.better-auth.com/)), you can forward HTTP headers to your `onPush` and `onPull` callbacks using the `forwardHeaders` option.
|
|
202
|
+
|
|
203
|
+
### Why forward headers?
|
|
204
|
+
|
|
205
|
+
Passing tokens in URL parameters (`syncPayload`) exposes them in browser history, server logs, and referrer headers. Cookie-based auth avoids these issues since cookies are sent automatically with each request and aren't logged in URLs.
|
|
206
|
+
|
|
207
|
+
### Example
|
|
208
|
+
|
|
209
|
+
The following example forwards `Cookie` and `Authorization` headers to the Durable Object callbacks:
|
|
210
|
+
|
|
211
|
+
## `patterns/auth/cookie-auth.ts`
|
|
212
|
+
|
|
213
|
+
```ts filename="patterns/auth/cookie-auth.ts"
|
|
214
|
+
|
|
215
|
+
export class SyncBackendDO extends makeDurableObject({
|
|
216
|
+
// Forward Cookie and Authorization headers to onPush/onPull callbacks
|
|
217
|
+
forwardHeaders: ['Cookie', 'Authorization'],
|
|
218
|
+
|
|
219
|
+
onPush: async (message, context) => {
|
|
220
|
+
const { storeId, headers } = context
|
|
221
|
+
|
|
222
|
+
// Access forwarded headers in callbacks
|
|
223
|
+
const cookie = headers?.get('cookie')
|
|
224
|
+
const _authorization = headers?.get('authorization')
|
|
225
|
+
|
|
226
|
+
if (cookie) {
|
|
227
|
+
// Parse session from cookie (example with better-auth)
|
|
228
|
+
const sessionToken = parseCookie(cookie, 'session_token')
|
|
229
|
+
const session = await getSessionFromToken(sessionToken)
|
|
230
|
+
|
|
231
|
+
if (!session) {
|
|
232
|
+
throw new Error('Invalid session')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log('Push from user:', session.userId, 'store:', storeId)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.log('onPush', message.batch)
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
onPull: async (message, context) => {
|
|
242
|
+
const { storeId, headers } = context
|
|
243
|
+
|
|
244
|
+
// Same header access in onPull
|
|
245
|
+
const cookie = headers?.get('cookie')
|
|
246
|
+
|
|
247
|
+
if (cookie) {
|
|
248
|
+
const sessionToken = parseCookie(cookie, 'session_token')
|
|
249
|
+
const session = await getSessionFromToken(sessionToken)
|
|
250
|
+
|
|
251
|
+
if (!session) {
|
|
252
|
+
throw new Error('Invalid session')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
console.log('Pull from user:', session.userId, 'store:', storeId)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log('onPull', message)
|
|
259
|
+
},
|
|
260
|
+
}) {}
|
|
261
|
+
|
|
262
|
+
export default makeWorker({
|
|
263
|
+
syncBackendBinding: 'SYNC_BACKEND_DO',
|
|
264
|
+
// Optional: validate at worker level using headers
|
|
265
|
+
validatePayload: async (_payload, context) => {
|
|
266
|
+
const { headers } = context
|
|
267
|
+
const cookie = headers.get('cookie')
|
|
268
|
+
|
|
269
|
+
if (cookie) {
|
|
270
|
+
const sessionToken = parseCookie(cookie, 'session_token')
|
|
271
|
+
const session = await getSessionFromToken(sessionToken)
|
|
272
|
+
|
|
273
|
+
if (!session) {
|
|
274
|
+
throw new Error('Unauthorized: Invalid session')
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
enableCORS: true,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// --- Helper functions (implement based on your auth library) ---
|
|
282
|
+
|
|
283
|
+
function parseCookie(cookieHeader: string, name: string): string | undefined {
|
|
284
|
+
const cookies = cookieHeader.split(';').map((c) => c.trim())
|
|
285
|
+
for (const cookie of cookies) {
|
|
286
|
+
const [key, value] = cookie.split('=')
|
|
287
|
+
if (key === name) return value
|
|
288
|
+
}
|
|
289
|
+
return undefined
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface Session {
|
|
293
|
+
userId: string
|
|
294
|
+
email: string
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function getSessionFromToken(_token: string | undefined): Promise<Session | null> {
|
|
298
|
+
// Implement session lookup using your auth library
|
|
299
|
+
// Example with better-auth:
|
|
300
|
+
// return await auth.api.getSession({ headers: { cookie: `session_token=${token}` } })
|
|
301
|
+
return { userId: 'user-123', email: 'user@example.com' }
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### How it works
|
|
306
|
+
|
|
307
|
+
1. **Configure `forwardHeaders`** in `makeDurableObject()` to specify which headers to forward.
|
|
308
|
+
2. **Headers are stored** in the WebSocket attachment during connection upgrade, surviving hibernation.
|
|
309
|
+
3. **Access headers** via `context.headers` in `onPush` and `onPull` callbacks.
|
|
310
|
+
4. **Worker-level validation** can also access headers via `context.headers` in `validatePayload`.
|
|
311
|
+
|
|
312
|
+
### Custom header extraction
|
|
313
|
+
|
|
314
|
+
For more control, pass a function to `forwardHeaders`:
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
export class SyncBackendDO extends makeDurableObject({
|
|
318
|
+
forwardHeaders: (request) => ({
|
|
319
|
+
'x-user-id': request.headers.get('x-user-id') ?? '',
|
|
320
|
+
'x-session': request.headers.get('cookie')?.split('session=')[1]?.split(';')[0] ?? '',
|
|
321
|
+
}),
|
|
322
|
+
// ...
|
|
323
|
+
}) {}
|
|
324
|
+
```
|
|
325
|
+
|
|
187
326
|
## Client identity vs user identity
|
|
188
327
|
|
|
189
328
|
LiveStore's `clientId` identifies a client instance, while user identity is an application-level concern that must be modeled through your application's events and logic.
|
|
@@ -196,31 +335,43 @@ LiveStore's `clientId` identifies a client instance, while user identity is an a
|
|
|
196
335
|
|
|
197
336
|
The `syncPayload` is primarily intended for authentication purposes:
|
|
198
337
|
|
|
199
|
-
## `patterns/auth/
|
|
338
|
+
## `patterns/auth/store-with-auth.tsx`
|
|
200
339
|
|
|
201
|
-
```tsx filename="patterns/auth/
|
|
340
|
+
```tsx filename="patterns/auth/store-with-auth.tsx"
|
|
202
341
|
|
|
203
|
-
const schema = {} as
|
|
342
|
+
const schema = {} as LiveStoreSchema
|
|
204
343
|
const storeId = 'demo-store'
|
|
205
344
|
const user = { jwt: 'user-token' }
|
|
206
|
-
const children: ReactNode = null
|
|
207
345
|
const adapter = makeInMemoryAdapter()
|
|
208
346
|
|
|
209
347
|
// ---cut---
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
adapter
|
|
215
|
-
batchUpdates
|
|
216
|
-
syncPayload
|
|
348
|
+
const useAppStore = () =>
|
|
349
|
+
useStore({
|
|
350
|
+
storeId,
|
|
351
|
+
schema,
|
|
352
|
+
adapter,
|
|
353
|
+
batchUpdates,
|
|
354
|
+
syncPayload: {
|
|
217
355
|
authToken: user.jwt, // Using a JWT
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
export const App = () => {
|
|
360
|
+
const [storeRegistry] = useState(() => new StoreRegistry())
|
|
361
|
+
return (
|
|
362
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
363
|
+
<StoreRegistryProvider storeRegistry={storeRegistry}>
|
|
364
|
+
<AppContent />
|
|
365
|
+
</StoreRegistryProvider>
|
|
366
|
+
</Suspense>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const AppContent = () => {
|
|
371
|
+
const _store = useAppStore()
|
|
372
|
+
// Use the store in your components
|
|
373
|
+
return <div>{/* Your app content */}</div>
|
|
374
|
+
}
|
|
224
375
|
```
|
|
225
376
|
|
|
226
377
|
User identification and semantic data (like user IDs) should typically be handled through your event payloads and application state rather than relying solely on the sync payload.
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
LiveStore itself is built on top of [Effect](https://effect.website) which is a powerful library to write production-grade TypeScript code. It's also possible (and recommended) to use Effect directly in your application code.
|
|
4
4
|
|
|
5
|
+
## Store context for Effect
|
|
6
|
+
|
|
7
|
+
For applications built with Effect, LiveStore provides `makeStoreContext()` - a factory that creates typed store contexts for the Effect layer system. This preserves your schema types through Effect's dependency injection and supports multiple stores.
|
|
8
|
+
|
|
9
|
+
See the [Store Effect integration](/building-with-livestore/store#effect-integration) section for details on:
|
|
10
|
+
- Creating typed store contexts
|
|
11
|
+
- Using stores in Effect services
|
|
12
|
+
- Layer composition patterns
|
|
13
|
+
- Multiple stores in the same app
|
|
14
|
+
|
|
5
15
|
## Schema
|
|
6
16
|
|
|
7
17
|
LiveStore uses the [Effect Schema](https://effect.website/docs/schema/introduction/) library to define schemas for the following:
|
|
@@ -430,7 +440,7 @@ export const pendingUsersAtom = Atom.make<PendingUser[]>([])
|
|
|
430
440
|
|
|
431
441
|
### Using queries in React components
|
|
432
442
|
|
|
433
|
-
Access query results in React components with the `useAtomValue` hook. When using `StoreTag.makeQuery` (non-unsafe API), the result is wrapped in a Result type for proper loading and error handling:
|
|
443
|
+
Access query results in React components with the `useAtomValue()` hook. When using `StoreTag.makeQuery` (non-unsafe API), the result is wrapped in a Result type for proper loading and error handling:
|
|
434
444
|
|
|
435
445
|
## `patterns/effect/store-setup/user-list.tsx`
|
|
436
446
|
|
|
@@ -20,7 +20,7 @@ export const WithInitialText: Story = {
|
|
|
20
20
|
])
|
|
21
21
|
],
|
|
22
22
|
}`,
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
storybookPreview: `import React from 'react'
|
|
25
25
|
|
|
26
26
|
// Default decorator with no seed data
|
|
@@ -28,40 +28,57 @@ const LiveStoreDecorator = createLiveStoreDecorator()
|
|
|
28
28
|
|
|
29
29
|
export const decorators = [LiveStoreDecorator]`,
|
|
30
30
|
|
|
31
|
-
decorator: `import React from 'react'
|
|
31
|
+
decorator: `import React, { Suspense, useState } from 'react'
|
|
32
|
+
|
|
33
|
+
const adapter = makeInMemoryAdapter()
|
|
32
34
|
|
|
33
35
|
// Create LiveStore decorator with optional seeding
|
|
34
36
|
export const createLiveStoreDecorator = (seedEvents = []) => (Story) => {
|
|
35
|
-
const
|
|
36
|
-
// Seed data through events during boot
|
|
37
|
-
if (seedEvents.length > 0) {
|
|
38
|
-
store.commit(...seedEvents)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
37
|
+
const [storeRegistry] = useState(() => new StoreRegistry())
|
|
41
38
|
|
|
42
39
|
return (
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
renderLoading={(status) => <div>Loading LiveStore ({status.stage})...</div>}
|
|
49
|
-
>
|
|
50
|
-
<Story />
|
|
51
|
-
</LiveStoreProvider>
|
|
40
|
+
<Suspense fallback={<div>Loading LiveStore...</div>}>
|
|
41
|
+
<StoreRegistryProvider storeRegistry={storeRegistry}>
|
|
42
|
+
<StoryWithStore Story={Story} seedEvents={seedEvents} />
|
|
43
|
+
</StoreRegistryProvider>
|
|
44
|
+
</Suspense>
|
|
52
45
|
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const StoryWithStore = ({ Story, seedEvents }) => {
|
|
49
|
+
const store = useStore({
|
|
50
|
+
storeId: 'storybook',
|
|
51
|
+
schema,
|
|
52
|
+
adapter,
|
|
53
|
+
batchUpdates,
|
|
54
|
+
boot: (store) => {
|
|
55
|
+
if (seedEvents.length > 0) {
|
|
56
|
+
store.commit(...seedEvents)
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
return <Story />
|
|
53
61
|
}`,
|
|
54
62
|
|
|
55
|
-
todoInput: `import
|
|
63
|
+
todoInput: `import { useStore } from '@livestore/react'
|
|
64
|
+
|
|
65
|
+
const adapter = makeInMemoryAdapter()
|
|
56
66
|
|
|
57
67
|
// Define queries (like in TodoMVC)
|
|
58
68
|
const uiState$ = queryDb(tables.uiState.get(), { label: 'uiState' })
|
|
59
69
|
|
|
70
|
+
const useAppStore = () => useStore({
|
|
71
|
+
storeId: 'todo-app',
|
|
72
|
+
schema,
|
|
73
|
+
adapter,
|
|
74
|
+
batchUpdates,
|
|
75
|
+
})
|
|
76
|
+
|
|
60
77
|
export const TodoInput = () => {
|
|
61
|
-
const
|
|
78
|
+
const store = useAppStore()
|
|
62
79
|
const { newTodoText } = store.useQuery(uiState$)
|
|
63
80
|
|
|
64
|
-
const updateNewTodoText = (text: string) =>
|
|
81
|
+
const updateNewTodoText = (text: string) =>
|
|
65
82
|
store.commit(events.uiStateSet({ newTodoText: text }))
|
|
66
83
|
|
|
67
84
|
const createTodo = () => {
|
|
@@ -110,13 +127,13 @@ export const tables = {
|
|
|
110
127
|
// Client document for UI state
|
|
111
128
|
uiState: State.SQLite.clientDocument({
|
|
112
129
|
name: 'uiState',
|
|
113
|
-
schema: Schema.Struct({
|
|
114
|
-
newTodoText: Schema.String,
|
|
115
|
-
filter: Schema.Literal('all', 'active', 'completed')
|
|
130
|
+
schema: Schema.Struct({
|
|
131
|
+
newTodoText: Schema.String,
|
|
132
|
+
filter: Schema.Literal('all', 'active', 'completed')
|
|
116
133
|
}),
|
|
117
|
-
default: {
|
|
118
|
-
id: SessionIdSymbol,
|
|
119
|
-
value: { newTodoText: '', filter: 'all' }
|
|
134
|
+
default: {
|
|
135
|
+
id: SessionIdSymbol,
|
|
136
|
+
value: { newTodoText: '', filter: 'all' }
|
|
120
137
|
},
|
|
121
138
|
}),
|
|
122
139
|
}
|
|
@@ -31,7 +31,7 @@ For a complete setup including sync and devtools, see the [Expo getting started
|
|
|
31
31
|
|
|
32
32
|
## Basic Usage
|
|
33
33
|
|
|
34
|
-
Create an adapter and
|
|
34
|
+
Create an adapter and a custom `useAppStore()` hook, then set up a `StoreRegistry` with `<StoreRegistryProvider>`:
|
|
35
35
|
|
|
36
36
|
## `reference/platform-adapters/expo-adapter/usage.tsx`
|
|
37
37
|
|
|
@@ -39,27 +39,39 @@ Create an adapter and wrap your app with the `LiveStoreProvider`:
|
|
|
39
39
|
|
|
40
40
|
const adapter = makePersistedAdapter()
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
const useAppStore = () =>
|
|
43
|
+
useStore({
|
|
44
|
+
storeId: 'my-app',
|
|
45
|
+
schema,
|
|
46
|
+
adapter,
|
|
47
|
+
batchUpdates,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export const App = () => {
|
|
51
|
+
const [storeRegistry] = useState(() => new StoreRegistry())
|
|
52
|
+
return (
|
|
53
|
+
<SafeAreaView style={{ flex: 1 }}>
|
|
54
|
+
<Suspense fallback={<Text>Loading...</Text>}>
|
|
55
|
+
<StoreRegistryProvider storeRegistry={storeRegistry}>
|
|
56
|
+
<TodoList />
|
|
57
|
+
</StoreRegistryProvider>
|
|
58
|
+
</Suspense>
|
|
59
|
+
</SafeAreaView>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const TodoList = () => {
|
|
64
|
+
const store = useAppStore()
|
|
65
|
+
const todos = store.useQuery(queryDb(tables.todos.select()))
|
|
66
|
+
return <Text>{todos.length} todos</Text>
|
|
67
|
+
}
|
|
56
68
|
```
|
|
57
69
|
|
|
58
70
|
### `reference/platform-adapters/expo-adapter/schema.ts`
|
|
59
71
|
|
|
60
72
|
```ts filename="reference/platform-adapters/expo-adapter/schema.ts"
|
|
61
73
|
|
|
62
|
-
const tables = {
|
|
74
|
+
export const tables = {
|
|
63
75
|
todos: State.SQLite.table({
|
|
64
76
|
name: 'todos',
|
|
65
77
|
columns: {
|
|
@@ -88,7 +100,7 @@ const state = State.SQLite.makeState({ tables, materializers })
|
|
|
88
100
|
export const schema = makeSchema({ events, state })
|
|
89
101
|
```
|
|
90
102
|
|
|
91
|
-
|
|
103
|
+
For more details on the registry, and hooks, see the [React integration guide](/framework-integrations/react-integration).
|
|
92
104
|
|
|
93
105
|
## Configuration Options
|
|
94
106
|
|
|
@@ -99,7 +111,11 @@ The adapter requires minimal configuration. For more details on the provider pro
|
|
|
99
111
|
// ---cut---
|
|
100
112
|
|
|
101
113
|
const adapter = makePersistedAdapter({
|
|
102
|
-
storage: {
|
|
114
|
+
storage: {
|
|
115
|
+
// Optional: custom base directory (defaults to expo-sqlite's default)
|
|
116
|
+
// directory: '/custom/path/to/databases',
|
|
117
|
+
subDirectory: 'my-app',
|
|
118
|
+
},
|
|
103
119
|
})
|
|
104
120
|
```
|
|
105
121
|
|
|
@@ -107,7 +123,8 @@ const adapter = makePersistedAdapter({
|
|
|
107
123
|
|
|
108
124
|
| Option | Type | Description |
|
|
109
125
|
|--------|------|-------------|
|
|
110
|
-
| `storage.
|
|
126
|
+
| `storage.directory` | `string` | Base directory for database files (defaults to `expo-sqlite`'s default directory) |
|
|
127
|
+
| `storage.subDirectory` | `string` | Subdirectory relative to `directory` for organizing databases |
|
|
111
128
|
| `sync` | `SyncOptions` | Sync backend configuration (see [Syncing](/building-with-livestore/syncing)) |
|
|
112
129
|
| `clientId` | `string` | Custom client identifier (defaults to device ID) |
|
|
113
130
|
| `sessionId` | `string` | Session identifier (defaults to `'static'`) |
|
|
@@ -149,6 +149,43 @@ During development (`NODE_ENV !== 'production'`), LiveStore automatically copies
|
|
|
149
149
|
|
|
150
150
|
LiveStore also uses `window.sessionStorage` to retain the identity of a client session (e.g. tab/window) across reloads.
|
|
151
151
|
|
|
152
|
+
### Private browsing mode
|
|
153
|
+
|
|
154
|
+
In Safari and Firefox private browsing mode, OPFS is not available due to browser restrictions. When this happens, LiveStore automatically falls back to in-memory storage. This means:
|
|
155
|
+
|
|
156
|
+
- The app will continue to work normally during the session
|
|
157
|
+
- Data will not persist across page reloads or tab closures
|
|
158
|
+
- Sync functionality (if configured) will still work
|
|
159
|
+
|
|
160
|
+
You can detect when the store is running in-memory mode using `store.storageMode`:
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
if (store.storageMode === 'in-memory') {
|
|
164
|
+
// Show a warning to the user
|
|
165
|
+
showToast('Data will not be saved in private browsing mode')
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The `storageMode` property returns:
|
|
170
|
+
- `'persisted'`: Data is being persisted to disk (e.g., via OPFS)
|
|
171
|
+
- `'in-memory'`: Data is only stored in memory and will be lost on page refresh
|
|
172
|
+
|
|
173
|
+
You can also listen for boot status events including warnings using the `onBootStatus` callback in your store options:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const useAppStore = () => useStore({
|
|
177
|
+
storeId: 'app',
|
|
178
|
+
schema,
|
|
179
|
+
adapter,
|
|
180
|
+
batchUpdates: ReactDOM.unstable_batchedUpdates,
|
|
181
|
+
onBootStatus: (status) => {
|
|
182
|
+
if (status.stage === 'warning') {
|
|
183
|
+
console.warn(`Storage warning (${status.reason}): ${status.message}`)
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
152
189
|
### Resetting local persistence
|
|
153
190
|
|
|
154
191
|
Resetting local persistence only clears data stored in the browser and does not affect any connected sync backend.
|
|
@@ -201,8 +238,40 @@ Assuming the web adapter in a multi-client, multi-tab browser application, a dia
|
|
|
201
238
|
|
|
202
239
|
## Browser support
|
|
203
240
|
|
|
204
|
-
- Notable required browser APIs: OPFS,
|
|
205
|
-
-
|
|
241
|
+
- Notable required browser APIs: OPFS, `navigator.locks`, WASM
|
|
242
|
+
- SharedWorker is used for multi-tab synchronization but is not strictly required
|
|
243
|
+
|
|
244
|
+
### Android Chrome (Single-tab mode)
|
|
245
|
+
|
|
246
|
+
Android Chrome does not support the `SharedWorker` API ([Chromium bug #40290702](https://issues.chromium.org/issues/40290702)). When running on Android Chrome, LiveStore automatically falls back to **single-tab mode**:
|
|
247
|
+
|
|
248
|
+
- Each browser tab runs independently with its own leader worker
|
|
249
|
+
- Data is still persisted to OPFS (same as full mode)
|
|
250
|
+
- Multi-tab synchronization is not available
|
|
251
|
+
- Devtools are not supported in single-tab mode
|
|
252
|
+
- A warning is logged to the console when this fallback occurs
|
|
253
|
+
|
|
254
|
+
You can also explicitly use single-tab mode if you don't need multi-tab support:
|
|
255
|
+
|
|
256
|
+
## `reference/platform-adapters/web-adapter/single-tab.ts`
|
|
257
|
+
|
|
258
|
+
```ts filename="reference/platform-adapters/web-adapter/single-tab.ts"
|
|
259
|
+
/** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet keeps inline adapter */
|
|
260
|
+
// ---cut---
|
|
261
|
+
|
|
262
|
+
// Use this only if you specifically need single-tab mode.
|
|
263
|
+
// Prefer makePersistedAdapter which auto-detects SharedWorker support.
|
|
264
|
+
const adapter = makeSingleTabAdapter({
|
|
265
|
+
worker: LiveStoreWorker,
|
|
266
|
+
storage: { type: 'opfs' },
|
|
267
|
+
})
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
:::note
|
|
271
|
+
The single-tab adapter is intended as a fallback for browsers without SharedWorker support.
|
|
272
|
+
We plan to remove it once SharedWorker is supported in Android Chrome.
|
|
273
|
+
Track progress: [LiveStore #321](https://github.com/livestorejs/livestore/issues/321)
|
|
274
|
+
:::
|
|
206
275
|
|
|
207
276
|
## Best practices
|
|
208
277
|
|
|
@@ -25,7 +25,7 @@ Once you've downloaded the project, you can navigate to the project directory an
|
|
|
25
25
|
The project currently is set up as follows:
|
|
26
26
|
- Minimal project created via [`vite create`](https://vite.dev/guide/#scaffolding-your-first-vite-project) using React and TypeScript.
|
|
27
27
|
- Using [Tailwind CSS](https://tailwindcss.com/) for styling.
|
|
28
|
-
- Has basic functionality for adding and deleting todos via local [`React.useState`](https://react.dev/learn/state-a-components-memory).
|
|
28
|
+
- Has basic functionality for adding and deleting todos via local [`React.useState()`](https://react.dev/learn/state-a-components-memory).
|
|
29
29
|
|
|
30
30
|
## Understand the current project state
|
|
31
31
|
|
|
@@ -80,8 +80,8 @@ function App() {
|
|
|
80
80
|
|
|
81
81
|
return (
|
|
82
82
|
// Render input text field and todo list ...
|
|
83
|
-
// ... and invoke `addTodo` and `deleteTodo`
|
|
84
|
-
// ... when the buttons are clicked.
|
|
83
|
+
// ... and invoke `addTodo` and `deleteTodo`
|
|
84
|
+
// ... when the buttons are clicked.
|
|
85
85
|
)
|
|
86
86
|
}
|
|
87
87
|
```
|
|
@@ -98,8 +98,8 @@ The "problem" with this code is that the todo items are not _persisted_, meaning
|
|
|
98
98
|
- the page is refreshed in the browser.
|
|
99
99
|
- the development server is restarted.
|
|
100
100
|
|
|
101
|
-
In the next chapters, you'll learn how to persist the todos in the list, so that they'll "survive" both actions.
|
|
101
|
+
In the next chapters, you'll learn how to persist the todos in the list, so that they'll "survive" both actions.
|
|
102
102
|
|
|
103
|
-
Even more: They will not only persist, they will automatically sync across multiple browsers tabs/windows, and even across devices—without you needing to think about the syncing logic and managing remote state.
|
|
103
|
+
Even more: They will not only persist, they will automatically sync across multiple browsers tabs/windows, and even across devices—without you needing to think about the syncing logic and managing remote state.
|
|
104
104
|
|
|
105
105
|
That's the power of LiveStore!
|