@livestore/livestore 0.4.0-dev.22 → 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.
- package/README.md +0 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/QueryCache.js +1 -1
- package/dist/QueryCache.js.map +1 -1
- package/dist/SqliteDbWrapper.d.ts +5 -5
- package/dist/SqliteDbWrapper.d.ts.map +1 -1
- package/dist/SqliteDbWrapper.js +8 -8
- package/dist/SqliteDbWrapper.js.map +1 -1
- package/dist/SqliteDbWrapper.test.js +2 -2
- package/dist/SqliteDbWrapper.test.js.map +1 -1
- package/dist/effect/LiveStore.d.ts +14 -7
- package/dist/effect/LiveStore.d.ts.map +1 -1
- package/dist/effect/LiveStore.js +0 -15
- package/dist/effect/LiveStore.js.map +1 -1
- package/dist/effect/LiveStore.test.d.ts +2 -0
- package/dist/effect/LiveStore.test.d.ts.map +1 -0
- package/dist/effect/LiveStore.test.js +42 -0
- package/dist/effect/LiveStore.test.js.map +1 -0
- package/dist/live-queries/base-class.d.ts +3 -3
- package/dist/live-queries/base-class.d.ts.map +1 -1
- package/dist/live-queries/base-class.js +2 -2
- package/dist/live-queries/base-class.js.map +1 -1
- package/dist/live-queries/client-document-get-query.d.ts +1 -1
- package/dist/live-queries/client-document-get-query.d.ts.map +1 -1
- package/dist/live-queries/client-document-get-query.js +1 -1
- package/dist/live-queries/client-document-get-query.js.map +1 -1
- package/dist/live-queries/computed.d.ts.map +1 -1
- package/dist/live-queries/computed.js +2 -2
- package/dist/live-queries/computed.js.map +1 -1
- package/dist/live-queries/db-query.js +14 -14
- package/dist/live-queries/db-query.js.map +1 -1
- package/dist/live-queries/db-query.test.js +2 -2
- package/dist/live-queries/db-query.test.js.map +1 -1
- package/dist/live-queries/signal.test.js +2 -2
- package/dist/live-queries/signal.test.js.map +1 -1
- package/dist/mod.d.ts +1 -1
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js.map +1 -1
- package/dist/reactive.d.ts +9 -9
- package/dist/reactive.d.ts.map +1 -1
- package/dist/reactive.js +9 -26
- package/dist/reactive.js.map +1 -1
- package/dist/reactive.test.js +2 -2
- package/dist/reactive.test.js.map +1 -1
- package/dist/store/StoreRegistry.d.ts +30 -5
- package/dist/store/StoreRegistry.d.ts.map +1 -1
- package/dist/store/StoreRegistry.js +54 -31
- package/dist/store/StoreRegistry.js.map +1 -1
- package/dist/store/StoreRegistry.test.js +251 -250
- package/dist/store/StoreRegistry.test.js.map +1 -1
- package/dist/store/create-store.d.ts +6 -2
- package/dist/store/create-store.d.ts.map +1 -1
- package/dist/store/create-store.js +13 -7
- package/dist/store/create-store.js.map +1 -1
- package/dist/store/devtools.d.ts +1 -1
- package/dist/store/devtools.d.ts.map +1 -1
- package/dist/store/devtools.js +3 -3
- package/dist/store/devtools.js.map +1 -1
- package/dist/store/store-eventstream.test.js +2 -2
- package/dist/store/store-eventstream.test.js.map +1 -1
- package/dist/store/store-types.d.ts +70 -5
- package/dist/store/store-types.d.ts.map +1 -1
- package/dist/store/store-types.js.map +1 -1
- package/dist/store/store-types.test.js +1 -1
- package/dist/store/store-types.test.js.map +1 -1
- package/dist/store/store.d.ts +81 -2
- package/dist/store/store.d.ts.map +1 -1
- package/dist/store/store.js +128 -45
- package/dist/store/store.js.map +1 -1
- package/dist/utils/dev.js.map +1 -1
- package/dist/utils/stack-info.js +2 -2
- package/dist/utils/stack-info.js.map +1 -1
- package/dist/utils/tests/fixture.d.ts +1 -1
- package/dist/utils/tests/fixture.d.ts.map +1 -1
- package/dist/utils/tests/fixture.js.map +1 -1
- package/dist/utils/tests/otel.d.ts.map +1 -1
- package/dist/utils/tests/otel.js +5 -5
- package/dist/utils/tests/otel.js.map +1 -1
- package/package.json +58 -17
- package/src/QueryCache.ts +1 -1
- package/src/SqliteDbWrapper.test.ts +4 -2
- package/src/SqliteDbWrapper.ts +12 -11
- package/src/ambient.d.ts +0 -7
- package/src/effect/LiveStore.test.ts +61 -0
- package/src/effect/LiveStore.ts +17 -26
- package/src/live-queries/__snapshots__/db-query.test.ts.snap +336 -231
- package/src/live-queries/base-class.ts +7 -6
- package/src/live-queries/client-document-get-query.ts +4 -2
- package/src/live-queries/computed.ts +3 -2
- package/src/live-queries/db-query.test.ts +3 -2
- package/src/live-queries/db-query.ts +15 -15
- package/src/live-queries/signal.test.ts +3 -2
- package/src/mod.ts +1 -0
- package/src/reactive.test.ts +3 -2
- package/src/reactive.ts +22 -23
- package/src/store/StoreRegistry.test.ts +317 -293
- package/src/store/StoreRegistry.ts +63 -38
- package/src/store/create-store.ts +26 -11
- package/src/store/devtools.ts +5 -6
- package/src/store/store-eventstream.test.ts +4 -2
- package/src/store/store-types.test.ts +3 -1
- package/src/store/store-types.ts +47 -8
- package/src/store/store.ts +172 -55
- package/src/utils/dev.ts +2 -2
- package/src/utils/stack-info.ts +2 -2
- package/src/utils/tests/fixture.ts +2 -1
- package/src/utils/tests/otel.ts +8 -7
- package/docs/api/index.md +0 -3
- package/docs/building-with-livestore/complex-ui-state/index.md +0 -3
- package/docs/building-with-livestore/crud/index.md +0 -3
- package/docs/building-with-livestore/data-modeling/index.md +0 -30
- package/docs/building-with-livestore/debugging/index.md +0 -17
- package/docs/building-with-livestore/devtools/index.md +0 -79
- package/docs/building-with-livestore/events/index.md +0 -355
- package/docs/building-with-livestore/examples/ai-agent/index.md +0 -5
- package/docs/building-with-livestore/examples/todo-workspaces/index.md +0 -885
- package/docs/building-with-livestore/examples/turnbased-game/index.md +0 -7
- package/docs/building-with-livestore/opentelemetry/index.md +0 -227
- package/docs/building-with-livestore/production-checklist/index.md +0 -5
- package/docs/building-with-livestore/reactivity-system/index.md +0 -202
- package/docs/building-with-livestore/rules-for-ai-agents/index.md +0 -9
- package/docs/building-with-livestore/state/materializers/index.md +0 -300
- package/docs/building-with-livestore/state/sql-queries/index.md +0 -94
- package/docs/building-with-livestore/state/sqlite/index.md +0 -45
- package/docs/building-with-livestore/state/sqlite-schema/index.md +0 -306
- package/docs/building-with-livestore/state/sqlite-schema-effect/index.md +0 -300
- package/docs/building-with-livestore/store/index.md +0 -625
- package/docs/building-with-livestore/syncing/index.md +0 -136
- package/docs/building-with-livestore/tools/cli/index.md +0 -177
- package/docs/building-with-livestore/tools/mcp/index.md +0 -187
- package/docs/examples/cloudflare-adapter/index.md +0 -44
- package/docs/examples/expo-adapter/index.md +0 -44
- package/docs/examples/index.md +0 -55
- package/docs/examples/node-adapter/index.md +0 -44
- package/docs/examples/web-adapter/index.md +0 -52
- package/docs/framework-integrations/custom-elements/index.md +0 -142
- package/docs/framework-integrations/react-integration/index.md +0 -937
- package/docs/framework-integrations/solid-integration/index.md +0 -293
- package/docs/framework-integrations/svelte-integration/index.md +0 -42
- package/docs/framework-integrations/vue-integration/index.md +0 -294
- package/docs/getting-started/expo/index.md +0 -882
- package/docs/getting-started/node/index.md +0 -115
- package/docs/getting-started/react-web/index.md +0 -626
- package/docs/getting-started/solid/index.md +0 -3
- package/docs/getting-started/vue/index.md +0 -471
- package/docs/index.md +0 -208
- package/docs/llms.txt +0 -146
- package/docs/misc/CODE_OF_CONDUCT/index.md +0 -133
- package/docs/misc/FAQ/index.md +0 -37
- package/docs/misc/community/index.md +0 -88
- package/docs/misc/credits/index.md +0 -14
- package/docs/misc/design-partners/index.md +0 -13
- package/docs/misc/package-management/index.md +0 -21
- package/docs/misc/performance/index.md +0 -25
- package/docs/misc/resources/index.md +0 -46
- package/docs/misc/state-of-the-project/index.md +0 -37
- package/docs/misc/troubleshooting/index.md +0 -82
- package/docs/overview/concepts/index.md +0 -78
- package/docs/overview/how-livestore-works/index.md +0 -56
- package/docs/overview/introduction/index.md +0 -413
- package/docs/overview/technology-comparison/index.md +0 -40
- package/docs/overview/when-livestore/index.md +0 -81
- package/docs/overview/why-livestore/index.md +0 -111
- package/docs/patterns/ai/index.md +0 -15
- package/docs/patterns/anonymous-user-transition/index.md +0 -10
- package/docs/patterns/app-evolution/index.md +0 -72
- package/docs/patterns/auth/index.md +0 -377
- package/docs/patterns/effect/index.md +0 -1505
- package/docs/patterns/encryption/index.md +0 -6
- package/docs/patterns/external-data/index.md +0 -5
- package/docs/patterns/file-management/index.md +0 -11
- package/docs/patterns/file-structure/index.md +0 -14
- package/docs/patterns/list-ordering/index.md +0 -369
- package/docs/patterns/offline/index.md +0 -32
- package/docs/patterns/orm/index.md +0 -18
- package/docs/patterns/presence/index.md +0 -11
- package/docs/patterns/rich-text-editing/index.md +0 -11
- package/docs/patterns/server-side-clients/index.md +0 -97
- package/docs/patterns/side-effects/index.md +0 -11
- package/docs/patterns/state-machines/index.md +0 -11
- package/docs/patterns/storybook/index.md +0 -209
- package/docs/patterns/undo-redo/index.md +0 -9
- package/docs/patterns/version-control/index.md +0 -8
- package/docs/platform-adapters/cloudflare-durable-object-adapter/index.md +0 -453
- package/docs/platform-adapters/electron-adapter/index.md +0 -15
- package/docs/platform-adapters/expo-adapter/index.md +0 -262
- package/docs/platform-adapters/node-adapter/index.md +0 -160
- package/docs/platform-adapters/tauri-adapter/index.md +0 -15
- package/docs/platform-adapters/web-adapter/index.md +0 -287
- package/docs/sustainable-open-source/contributing/docs/index.md +0 -94
- package/docs/sustainable-open-source/contributing/info/index.md +0 -63
- package/docs/sustainable-open-source/contributing/monorepo/index.md +0 -195
- package/docs/sustainable-open-source/sponsoring/index.md +0 -104
- package/docs/sync-providers/cloudflare/index.md +0 -773
- package/docs/sync-providers/custom/index.md +0 -65
- package/docs/sync-providers/electricsql/index.md +0 -159
- package/docs/sync-providers/s2/index.md +0 -230
- package/docs/tutorial/0-welcome/index.md +0 -48
- package/docs/tutorial/1-setup-starter-project/index.md +0 -105
- package/docs/tutorial/2-deploy-to-cloudflare/index.md +0 -195
- package/docs/tutorial/3-read-and-write-todos-via-livestore/index.md +0 -530
- package/docs/tutorial/4-sync-data-via-cloudflare/index.md +0 -210
- package/docs/tutorial/5-expand-business-logic/index.md +0 -174
- package/docs/tutorial/6-persist-ui-state/index.md +0 -453
- package/docs/tutorial/7-next-steps/index.md +0 -22
- package/docs/understanding-livestore/design-decisions/index.md +0 -33
- package/docs/understanding-livestore/event-sourcing/index.md +0 -40
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# Why LiveStore?
|
|
2
|
-
|
|
3
|
-
## The problem LiveStore solves
|
|
4
|
-
|
|
5
|
-
Building modern apps with great UX means handling a lot of complexity:
|
|
6
|
-
|
|
7
|
-
- **State management**: Keeping UI in sync with data
|
|
8
|
-
- **Persistence**: Surviving refreshes and app restarts
|
|
9
|
-
- **Offline support**: Working without a network connection
|
|
10
|
-
- **Real-time sync**: Reflecting changes across devices instantly
|
|
11
|
-
- **Conflict resolution**: Handling concurrent edits gracefully
|
|
12
|
-
|
|
13
|
-
Traditionally, each of these concerns requires separate solutions—a state library, local storage, a sync service, optimistic update logic, retry queues. These pieces don't naturally fit together, leading to complex, fragile code and subtle bugs.
|
|
14
|
-
|
|
15
|
-
LiveStore provides a _unified architecture_ that handles all of these concerns through one coherent model: event sourcing.
|
|
16
|
-
|
|
17
|
-
As a positive side-effect, the simplicity of LiveStore's event-driven abstractions also leads to a superior DX compared to the traditional ways of dealing with state.
|
|
18
|
-
|
|
19
|
-
## What makes LiveStore different
|
|
20
|
-
|
|
21
|
-
### A real database, not just a cache
|
|
22
|
-
|
|
23
|
-
Most state management tools treat local data as a _cache_ of server state. This introduces complexity that's hard to manage due to the inherent intricacies of caching.
|
|
24
|
-
|
|
25
|
-
LiveStore flips this: your local SQLite database is the primary data source, and the server is just a sync target to which data is distributed automatically.
|
|
26
|
-
|
|
27
|
-
<SourceOfTruthDiagram class="my-8" />
|
|
28
|
-
|
|
29
|
-
This means:
|
|
30
|
-
- **No loading states for reads**—queries execute synchronously against local SQLite
|
|
31
|
-
- **Full SQL power**—joins, aggregations, complex filters, all instant
|
|
32
|
-
- **Offline by default**—your app works the same whether online or not
|
|
33
|
-
|
|
34
|
-
### Events as the source of truth
|
|
35
|
-
|
|
36
|
-
Instead of syncing mutable state (which leads to "who wins?" conflicts), LiveStore syncs an append-only log of events. This is fundamentally easier to reason about:
|
|
37
|
-
|
|
38
|
-
- Events merge naturally—you're combining histories, not reconciling states
|
|
39
|
-
- Every change is auditable—you have a complete record of what happened
|
|
40
|
-
- State is always rebuildable—replay events to reconstruct any point in time
|
|
41
|
-
|
|
42
|
-
### Sync that actually works
|
|
43
|
-
|
|
44
|
-
LiveStore includes a battle-tested sync engine that handles the hard problems:
|
|
45
|
-
|
|
46
|
-
- **Optimistic updates**: Changes appear instantly, sync happens in the background
|
|
47
|
-
- **Automatic offline queue**: Events committed offline sync when connectivity returns
|
|
48
|
-
- **Deterministic conflict resolution**: Same events always produce the same state
|
|
49
|
-
- **Pluggable backends**: Use Cloudflare, ElectricSQL, or build your own
|
|
50
|
-
|
|
51
|
-
### Built for demanding apps
|
|
52
|
-
|
|
53
|
-
LiveStore was developed as the foundation for [Overtone](https://overtone.pro), a professional music application requiring 120fps performance with complex, real-time collaborative state. It's designed for apps where performance and reliability aren't negotiable.
|
|
54
|
-
|
|
55
|
-
### Open-source: By developers, for developers
|
|
56
|
-
|
|
57
|
-
LiveStore is entirely open-source. There is no company behind it and it's only possible through its generous [sponsors](/sustainable-open-source/sponsoring).
|
|
58
|
-
|
|
59
|
-
## LiveStore vs. the alternatives
|
|
60
|
-
|
|
61
|
-
### vs. State management libraries (Redux, Zustand, MobX)
|
|
62
|
-
|
|
63
|
-
These solve state management but not persistence or sync. You'll need to add:
|
|
64
|
-
- Local storage or IndexedDB for persistence
|
|
65
|
-
- A sync layer for real-time updates
|
|
66
|
-
- Optimistic update logic
|
|
67
|
-
- Offline queue management
|
|
68
|
-
|
|
69
|
-
LiveStore handles all of this out of the box.
|
|
70
|
-
|
|
71
|
-
### vs. Backend-as-a-Service (Firebase, Supabase)
|
|
72
|
-
|
|
73
|
-
These provide sync but treat the server as the source of truth. The consequences are:
|
|
74
|
-
- Reads require network round-trips (or complex caching)
|
|
75
|
-
- Offline support is limited or requires significant extra work
|
|
76
|
-
- You're locked into their data model and pricing
|
|
77
|
-
|
|
78
|
-
LiveStore is client-first, works fully offline, and syncs via any backend you choose.
|
|
79
|
-
|
|
80
|
-
### vs. Other local-first solutions (ElectricSQL, Zero, PowerSync)
|
|
81
|
-
|
|
82
|
-
These are excellent if you have an existing Postgres database you want to sync to clients. LiveStore is better when:
|
|
83
|
-
- You're building a new app without legacy data
|
|
84
|
-
- You want event sourcing semantics (not just row-level sync)
|
|
85
|
-
- You need the flexibility to materialize state in different ways
|
|
86
|
-
|
|
87
|
-
See the [local-first landscape](https://localfirst.fm/landscape) for a comprehensive comparison.
|
|
88
|
-
|
|
89
|
-
## A great developer experience (DX)
|
|
90
|
-
|
|
91
|
-
LiveStore is designed to make the right thing easy:
|
|
92
|
-
|
|
93
|
-
- **Type-safe schema**: Your events, tables, and queries are fully typed
|
|
94
|
-
- **Reactive by default**: UI updates automatically when data changes
|
|
95
|
-
- **Powerful devtools**: Inspect state, browse events, time-travel debug
|
|
96
|
-
- **Cross-platform**: Same mental model on web, mobile, desktop, and server
|
|
97
|
-
|
|
98
|
-
## When to choose LiveStore
|
|
99
|
-
|
|
100
|
-
**Choose LiveStore if:**
|
|
101
|
-
- You're building a new app that should work offline
|
|
102
|
-
- You want a unified solution for state, persistence, and sync
|
|
103
|
-
- Your app has complex local state that benefits from SQL queries
|
|
104
|
-
- You value auditability and the ability to replay/debug state changes
|
|
105
|
-
|
|
106
|
-
**Consider alternatives if:**
|
|
107
|
-
- You need to sync with an existing server database
|
|
108
|
-
- Your data won't fit in client memory
|
|
109
|
-
- You're building a traditional request/response app without offline needs
|
|
110
|
-
|
|
111
|
-
Still unsure? Try the [evaluation exercise](/overview/when-livestore#evaluation-exercise) to model your app's events and see if it feels natural.
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# AI
|
|
2
|
-
|
|
3
|
-
- LiveStore is a great fit for building AI applications.
|
|
4
|
-
- Scenarios:
|
|
5
|
-
- Local RAG (via sqlite-vec (see [feature request](https://github.com/livestorejs/livestore/issues/127)) + local LLM e.g. Gemini Nano embedded in Chrome)
|
|
6
|
-
- Agentic applications
|
|
7
|
-
|
|
8
|
-
- Event <> tool calls
|
|
9
|
-
- Nice mapping
|
|
10
|
-
|
|
11
|
-
## Example
|
|
12
|
-
|
|
13
|
-
```ts
|
|
14
|
-
// TODO (contribution welcome)
|
|
15
|
-
```
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Anonymous user transition
|
|
2
|
-
|
|
3
|
-
## Basic idea
|
|
4
|
-
|
|
5
|
-
- Locally choose a unique identifier for the user (e.g. via `crypto.randomUUID()`).
|
|
6
|
-
- You might want to handle the very unlikely case that the identifier is not unique (collision) on the sync backend.
|
|
7
|
-
- Persist this identifier locally (either via a separate LiveStore instance or via `localStorage`).
|
|
8
|
-
- Use this identifier in the `storeId` for the user-related LiveStore instance.
|
|
9
|
-
- Initially when the user is anonymous, the store won't be synced yet (i.e. no sync backend used in adapter).
|
|
10
|
-
- As part of the auth flow, the LiveStore instance is now synced with the same `storeId` to a sync backend which will sync all local events to the sync backend making sure the user keeps all their data.
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# App evolution
|
|
2
|
-
|
|
3
|
-
When building an app with LiveStore, you'll need to keep some things in mind when evolving your app.
|
|
4
|
-
|
|
5
|
-
## Schema changes
|
|
6
|
-
|
|
7
|
-
### State schema changes
|
|
8
|
-
|
|
9
|
-
Generally any kind of changes to your state schema (e.g. SQLite tables, ...) can be done at any time without any further considerations assuming the event materializer is updated to support the new schema.
|
|
10
|
-
|
|
11
|
-
### Event schema changes
|
|
12
|
-
|
|
13
|
-
Event schema changes require a bit more consideration. Changes to the event schema should generally be done in a backwards-compatible way. See [Event schema evolution](/building-with-livestore/events#schema-evolution) for more details.
|
|
14
|
-
|
|
15
|
-
## Parallel different app versions
|
|
16
|
-
|
|
17
|
-
In scenarios where you have multiple app versions rolled out in parallel (e.g. app version v3 with event schema v3 and app version v4 with event schema v4), you'll need to keep the following in mind:
|
|
18
|
-
|
|
19
|
-
App instances running version 4 might commit events that are not yet supported by version 3. Your app needs to decide how to handle this scenario in one of the following ways:
|
|
20
|
-
|
|
21
|
-
- Ignore unknown events
|
|
22
|
-
- Cause an error in the app for unknown events
|
|
23
|
-
- Handle events with a "catch all" event handler
|
|
24
|
-
- Let app render a "app update required" screen. App can still be used in read-only mode.
|
|
25
|
-
- ...
|
|
26
|
-
|
|
27
|
-
LiveStore exposes a dedicated `unknownEventHandling` configuration on `makeSchema` so you can codify the desired behaviour instead of sprinkling ad-hoc checks across your app. The default is `'warn'`, which logs every unknown event and keeps processing.
|
|
28
|
-
|
|
29
|
-
## `reference/events/unknown-event-handling.ts`
|
|
30
|
-
|
|
31
|
-
```ts filename="reference/events/unknown-event-handling.ts"
|
|
32
|
-
|
|
33
|
-
const tables = {
|
|
34
|
-
todos: State.SQLite.table({
|
|
35
|
-
name: 'todos',
|
|
36
|
-
columns: {
|
|
37
|
-
id: State.SQLite.text({ primaryKey: true }),
|
|
38
|
-
text: State.SQLite.text(),
|
|
39
|
-
},
|
|
40
|
-
}),
|
|
41
|
-
} as const
|
|
42
|
-
|
|
43
|
-
const events = {
|
|
44
|
-
todoCreated: Events.synced({
|
|
45
|
-
name: 'v1.TodoCreated',
|
|
46
|
-
schema: Schema.Struct({ id: Schema.String, text: Schema.String }),
|
|
47
|
-
}),
|
|
48
|
-
} as const
|
|
49
|
-
|
|
50
|
-
const materializers = State.SQLite.materializers(events, {
|
|
51
|
-
[events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text }) =>
|
|
52
|
-
tables.todos.insert({ id, text }),
|
|
53
|
-
),
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
const state = State.SQLite.makeState({ tables, materializers })
|
|
57
|
-
|
|
58
|
-
// ---cut---
|
|
59
|
-
|
|
60
|
-
const _schema = makeSchema({
|
|
61
|
-
events,
|
|
62
|
-
state,
|
|
63
|
-
unknownEventHandling: {
|
|
64
|
-
strategy: 'callback',
|
|
65
|
-
onUnknownEvent: (event, error) => {
|
|
66
|
-
console.warn('LiveStore saw an unknown event', { event, reason: error.reason })
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
})
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
Set the strategy to `'ignore'` to silently skip forward-only events, `'fail'` to stop immediately (useful during development), or `'callback'` to forward them to custom telemetry while continuing to replay the log.
|
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
# Auth
|
|
2
|
-
|
|
3
|
-
LiveStore doesn't include built-in authentication or authorization support, but you can implement it in your app's logic.
|
|
4
|
-
|
|
5
|
-
## Pass an auth payload to the sync backend
|
|
6
|
-
|
|
7
|
-
Use the `syncPayload` store option to send a custom payload to your sync backend.
|
|
8
|
-
|
|
9
|
-
### Example
|
|
10
|
-
|
|
11
|
-
The following example sends the authenticated user's JWT to the server.
|
|
12
|
-
|
|
13
|
-
## `patterns/auth/store-with-auth.tsx`
|
|
14
|
-
|
|
15
|
-
```tsx filename="patterns/auth/store-with-auth.tsx"
|
|
16
|
-
|
|
17
|
-
const schema = {} as LiveStoreSchema
|
|
18
|
-
const storeId = 'demo-store'
|
|
19
|
-
const user = { jwt: 'user-token' }
|
|
20
|
-
const adapter = makeInMemoryAdapter()
|
|
21
|
-
|
|
22
|
-
// ---cut---
|
|
23
|
-
const useAppStore = () =>
|
|
24
|
-
useStore({
|
|
25
|
-
storeId,
|
|
26
|
-
schema,
|
|
27
|
-
adapter,
|
|
28
|
-
batchUpdates,
|
|
29
|
-
syncPayload: {
|
|
30
|
-
authToken: user.jwt, // Using a JWT
|
|
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
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
On the sync server, validate the token and allow or reject the sync based on the result. See the following example:
|
|
53
|
-
|
|
54
|
-
## `patterns/auth/pass-auth-payload.ts`
|
|
55
|
-
|
|
56
|
-
```ts filename="patterns/auth/pass-auth-payload.ts"
|
|
57
|
-
|
|
58
|
-
const JWT_SECRET = 'a-string-secret-at-least-256-bits-long'
|
|
59
|
-
|
|
60
|
-
export class SyncBackendDO extends makeDurableObject({
|
|
61
|
-
onPush: async (message) => {
|
|
62
|
-
console.log('onPush', message.batch)
|
|
63
|
-
},
|
|
64
|
-
onPull: async (message) => {
|
|
65
|
-
console.log('onPull', message)
|
|
66
|
-
},
|
|
67
|
-
}) {}
|
|
68
|
-
|
|
69
|
-
export default makeWorker({
|
|
70
|
-
syncBackendBinding: 'SYNC_BACKEND_DO',
|
|
71
|
-
validatePayload: async (payload: any, context) => {
|
|
72
|
-
const { storeId } = context
|
|
73
|
-
const { authToken } = payload
|
|
74
|
-
|
|
75
|
-
if (!authToken) {
|
|
76
|
-
throw new Error('No auth token provided')
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const user = await getUserFromToken(authToken)
|
|
80
|
-
|
|
81
|
-
if (!user) {
|
|
82
|
-
throw new Error('Invalid auth token')
|
|
83
|
-
} else {
|
|
84
|
-
// User is authenticated!
|
|
85
|
-
console.log('Sync backend payload', JSON.stringify(user, null, 2))
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Check if token is expired
|
|
89
|
-
if (payload.exp && payload.exp < Date.now() / 1000) {
|
|
90
|
-
throw new Error('Token expired')
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
await checkUserAccess(user, storeId)
|
|
94
|
-
},
|
|
95
|
-
enableCORS: true,
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
async function getUserFromToken(token: string): Promise<jose.JWTPayload | undefined> {
|
|
99
|
-
try {
|
|
100
|
-
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(JWT_SECRET))
|
|
101
|
-
return payload
|
|
102
|
-
} catch (error) {
|
|
103
|
-
console.log('⚠️ Error verifying token', error)
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function checkUserAccess(payload: jose.JWTPayload, storeId: string): Promise<void> {
|
|
108
|
-
// Check if user is authorized to access the store
|
|
109
|
-
console.log('Checking access for store', storeId, 'with payload', payload)
|
|
110
|
-
}
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
The above example uses [`jose`](https://www.npmjs.com/package/jose), a popular JavaScript module that supports JWTs. It works across various runtimes, including Node.js, Cloudflare Workers, Deno, Bun, and others.
|
|
114
|
-
|
|
115
|
-
The `validatePayload` function receives the `authToken`, checks if the payload exists, and verifies that it's valid and hasn't expired. If all checks pass, sync continues as normal. If any check fails, the server rejects the sync.
|
|
116
|
-
|
|
117
|
-
The client app still works as expected, but saves data locally. If the user re-authenticates or refreshes the token later, LiveStore syncs any local changes made while the user was unauthenticated.
|
|
118
|
-
|
|
119
|
-
## Re-validate payload inside the Durable Object
|
|
120
|
-
|
|
121
|
-
When you rely on `syncPayload`, treat it as untrusted input. Decode the token inside `validatePayload` to gate the connection, and then repeat the same verification inside the Durable Object before trusting per-push metadata.
|
|
122
|
-
|
|
123
|
-
## `patterns/auth/keep-payload-canonical.ts`
|
|
124
|
-
|
|
125
|
-
```ts filename="patterns/auth/keep-payload-canonical.ts"
|
|
126
|
-
|
|
127
|
-
// ---cut---
|
|
128
|
-
type SyncPayload = { authToken?: string; userId?: string }
|
|
129
|
-
|
|
130
|
-
type AuthorizedSession = {
|
|
131
|
-
authToken: string
|
|
132
|
-
userId: string
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const ensureAuthorized = (payload: unknown): AuthorizedSession => {
|
|
136
|
-
if (payload === undefined || payload === null || typeof payload !== 'object') {
|
|
137
|
-
throw new Error('Missing auth payload')
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const { authToken, userId } = payload as SyncPayload
|
|
141
|
-
if (!authToken) {
|
|
142
|
-
throw new Error('Missing auth token')
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const claims = verifyJwt(authToken)
|
|
146
|
-
if (!claims.sub) {
|
|
147
|
-
throw new Error('Token missing subject claim')
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (userId !== undefined && userId !== claims.sub) {
|
|
151
|
-
throw new Error('Payload userId mismatch')
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return { authToken, userId: claims.sub }
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export default makeWorker({
|
|
158
|
-
syncBackendBinding: 'SYNC_BACKEND_DO',
|
|
159
|
-
validatePayload: (payload) => {
|
|
160
|
-
ensureAuthorized(payload)
|
|
161
|
-
},
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
export class SyncBackendDO extends makeDurableObject({
|
|
165
|
-
onPush: async (message: SyncMessage.PushRequest, { payload }) => {
|
|
166
|
-
const { userId } = ensureAuthorized(payload)
|
|
167
|
-
await ensureTenantAccess(userId, message.batch)
|
|
168
|
-
},
|
|
169
|
-
}) {}
|
|
170
|
-
|
|
171
|
-
const ensureTenantAccess = async (_userId: string, _batch: SyncMessage.PushRequest['batch']) => {
|
|
172
|
-
// Replace with your application-specific access checks.
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### `patterns/auth/verify-jwt.ts`
|
|
177
|
-
|
|
178
|
-
```ts filename="patterns/auth/verify-jwt.ts"
|
|
179
|
-
export type Claims = {
|
|
180
|
-
sub?: string
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export const verifyJwt = (token: string): Claims => {
|
|
184
|
-
if (token.length === 0) {
|
|
185
|
-
throw new Error('Missing token')
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Replace with real JWT verification (e.g. via `jose`)
|
|
189
|
-
return { sub: token }
|
|
190
|
-
}
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
- `validatePayload` runs once per connection and rejects mismatched tokens before LiveStore upgrades to WebSocket.
|
|
194
|
-
- `onPush` (and `onPull`, if you need it) must repeat the verification because the payload forwarded to the Durable Object is the original client input.
|
|
195
|
-
- The HTTP transport does not forward payloads today; embed the necessary authorization context directly in the events or move those clients to WebSocket/DO-RPC if you must rely on shared payload metadata.
|
|
196
|
-
|
|
197
|
-
You can extend `ensureAuthorized` to project additional claims, memoise verification per `authToken`, or enforce application-specific policies without changing LiveStore internals.
|
|
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
|
-
|
|
326
|
-
## Client identity vs user identity
|
|
327
|
-
|
|
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.
|
|
329
|
-
|
|
330
|
-
### Key points
|
|
331
|
-
- `clientId`: Automatically managed by LiveStore, identifies a client instance
|
|
332
|
-
- User identity: Managed by your application through events and syncPayload
|
|
333
|
-
|
|
334
|
-
### Using syncPayload for authentication
|
|
335
|
-
|
|
336
|
-
The `syncPayload` is primarily intended for authentication purposes:
|
|
337
|
-
|
|
338
|
-
## `patterns/auth/store-with-auth.tsx`
|
|
339
|
-
|
|
340
|
-
```tsx filename="patterns/auth/store-with-auth.tsx"
|
|
341
|
-
|
|
342
|
-
const schema = {} as LiveStoreSchema
|
|
343
|
-
const storeId = 'demo-store'
|
|
344
|
-
const user = { jwt: 'user-token' }
|
|
345
|
-
const adapter = makeInMemoryAdapter()
|
|
346
|
-
|
|
347
|
-
// ---cut---
|
|
348
|
-
const useAppStore = () =>
|
|
349
|
-
useStore({
|
|
350
|
-
storeId,
|
|
351
|
-
schema,
|
|
352
|
-
adapter,
|
|
353
|
-
batchUpdates,
|
|
354
|
-
syncPayload: {
|
|
355
|
-
authToken: user.jwt, // Using a JWT
|
|
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
|
-
}
|
|
375
|
-
```
|
|
376
|
-
|
|
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.
|