event-sourced-collection 0.0.1 → 0.0.2
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/ARCHITECTURE.md +479 -0
- package/README.md +284 -69
- package/dist/browser.d.mts +27 -2
- package/dist/browser.d.mts.map +1 -1
- package/dist/browser.mjs +54 -1
- package/dist/browser.mjs.map +1 -1
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -584
- package/dist/lazy-singleton-DFrAsYTV.mjs +629 -0
- package/dist/lazy-singleton-DFrAsYTV.mjs.map +1 -0
- package/dist/react-native.d.mts +26 -2
- package/dist/react-native.d.mts.map +1 -1
- package/dist/react-native.mjs +34 -1
- package/dist/react-native.mjs.map +1 -1
- package/package.json +8 -3
- package/dist/index.mjs.map +0 -1
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
# Architecture & Design Decisions
|
|
2
|
+
|
|
3
|
+
Technical reference for contributors and anyone evaluating this library's tradeoffs.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Core Philosophy
|
|
8
|
+
|
|
9
|
+
This library answers one question: **How do you sync local-first data without downloading entire tables?**
|
|
10
|
+
|
|
11
|
+
The answer: log every mutation as an event. Sync the event log. Replay events on both sides.
|
|
12
|
+
|
|
13
|
+
This is event sourcing applied to client-side state, scoped to a single user's data.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## System Overview
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
21
|
+
│ Client (Browser / React Native / Expo / Node) │
|
|
22
|
+
│ │
|
|
23
|
+
│ ┌───────────────────────────────┐ ┌───────────────────────────────┐ │
|
|
24
|
+
│ │ outbox (persisted collection)│ │ inbox (persisted collection) │ │
|
|
25
|
+
│ │ local mutations to push │ │ server events pulled down │ │
|
|
26
|
+
│ │ │ │ │ │
|
|
27
|
+
│ │ eventId | type | key | sync │ │ eventId | globalSeq | sync │ │
|
|
28
|
+
│ │ ──────────────────────────── │ │ ──────────────────────────── │ │
|
|
29
|
+
│ │ e1 | ins | t1 | false │ │ e9 | 50 | true │ │
|
|
30
|
+
│ │ e2 | upd | t1 | true │ │ e10 | 51 | false │ │
|
|
31
|
+
│ └───────────────────────────────┘ └───────────────────────────────┘ │
|
|
32
|
+
│ ▲ │ │
|
|
33
|
+
│ onInsert/onUpdate/onDelete acceptMutations │
|
|
34
|
+
│ hooks append to outbox replays inbox into │
|
|
35
|
+
│ │ state collections │
|
|
36
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
37
|
+
│ │ users │ │ todos │ │ settings │ │
|
|
38
|
+
│ │ (persisted) │ │ (persisted) │ │ (persisted) │ │
|
|
39
|
+
│ │ collection │ │ collection │ │ collection │ │
|
|
40
|
+
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
41
|
+
│ │
|
|
42
|
+
│ Pull cursor = max(synced globalSeq) across inbox (derived) │
|
|
43
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
44
|
+
│ push (outbox.sync = false) │ pull (since derived cursor)
|
|
45
|
+
▼ ▼
|
|
46
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
47
|
+
│ Server │
|
|
48
|
+
│ │
|
|
49
|
+
│ events table (BIGSERIAL global_seq, event_id UNIQUE, payload JSONB) │
|
|
50
|
+
│ │
|
|
51
|
+
│ POST /api/events → assigns global_seq, deduplicates by event_id │
|
|
52
|
+
│ GET /api/events?since=N → returns events after N, sorted ASC │
|
|
53
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Design Decisions
|
|
59
|
+
|
|
60
|
+
### 1. Separate Outbox and Inbox (Not One Mixed Table)
|
|
61
|
+
|
|
62
|
+
**Decision:** Outgoing local mutations live in an `outbox` collection; pulled server events live in an `inbox` collection. Both are ordinary persisted TanStack DB collections, exposed as `db.collections.outbox` and `db.collections.inbox`. There is no separate raw event table and no separate cursor table.
|
|
63
|
+
|
|
64
|
+
**Why:**
|
|
65
|
+
|
|
66
|
+
| Factor | One Mixed Table | Outbox + Inbox |
|
|
67
|
+
| -------------------- | -------------------------------------------------------- | ---------------------------------------------------------- |
|
|
68
|
+
| Mental model | A single row could be "ours, pending" or "theirs, synced" | Direction is explicit: outbox = ours, inbox = theirs |
|
|
69
|
+
| Per-row `sync` flag | Overloaded — means "pushed" or "applied" depending on row | Unambiguous — outbox `sync` = pushed, inbox `sync` = replayed |
|
|
70
|
+
| Visualization | Needs raw SQL access to inspect | Just `useLiveQuery(db.collections.outbox)` |
|
|
71
|
+
| Cursor | Dedicated `esdb_cursor` row to keep in step | Derived from `max(synced globalSeq)` across inbox |
|
|
72
|
+
| Storage layer | Hand-rolled SQLite table + indexes | Reuses TanStack DB persistence (reactive, multi-tab aware) |
|
|
73
|
+
|
|
74
|
+
**Per-row sync semantics:**
|
|
75
|
+
|
|
76
|
+
- **outbox** — `sync: false` means the mutation has not been pushed yet. `sync: true` means the server accepted it (no errors) and assigned a `globalSeq`.
|
|
77
|
+
- **outbox `syncStatus`** — `pending` means retryable, `synced` means confirmed, and `failed` means the server or custom push handler explicitly rejected that event. Failed rows also store `attemptCount`, `lastAttemptAt`, `lastError`, `lastErrorCode`, and `retryable`.
|
|
78
|
+
- **inbox** — `sync: false` means the event was pulled but not yet replayed. `sync: true` means it has been applied on top of the local data, or it originated locally and was already applied before push.
|
|
79
|
+
|
|
80
|
+
**FK ordering** is still preserved: the inbox replays server events sorted by `globalSeq`, so parents always precede children.
|
|
81
|
+
|
|
82
|
+
**Tradeoff:** The pull cursor is derived by scanning synced inbox state rather than read from a dedicated row. For the target scope (single-user, modest event counts) this is negligible.
|
|
83
|
+
|
|
84
|
+
**Constraint:** Because both are persisted collections, durability and multi-tab coordination are inherited from TanStack DB's persistence layer rather than implemented here.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### 2. Server Assigns Order (Not Client)
|
|
89
|
+
|
|
90
|
+
**Decision:** The server assigns `global_seq` via `BIGSERIAL`. The client never determines canonical order.
|
|
91
|
+
|
|
92
|
+
**Why:**
|
|
93
|
+
|
|
94
|
+
- Foreign keys require causal ordering (parent before child)
|
|
95
|
+
- The network delivers events in arbitrary order
|
|
96
|
+
- Multiple devices produce events independently
|
|
97
|
+
- Only a central authority can guarantee a consistent total order
|
|
98
|
+
|
|
99
|
+
**Tradeoff:** Requires a server. This library does not support peer-to-peer sync.
|
|
100
|
+
|
|
101
|
+
**Constraint:** If you need P2P, you need CRDTs or vector clocks — a fundamentally different architecture that adds significant complexity for marginal benefit in single-user scenarios.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### 3. Hooks Intercept Normal Collection API (No Special Dispatch)
|
|
106
|
+
|
|
107
|
+
**Decision:** Users call `collection.insert()` / `update()` / `delete()` normally. Injected `onInsert` / `onUpdate` / `onDelete` hooks log events transparently.
|
|
108
|
+
|
|
109
|
+
**Why:**
|
|
110
|
+
|
|
111
|
+
- Zero learning curve — same API as TanStack DB without this library
|
|
112
|
+
- `useLiveQuery` works unchanged
|
|
113
|
+
- No "dual write" where users must remember to call both `collection.insert()` and `events.log()`
|
|
114
|
+
- Impossible to forget to log an event
|
|
115
|
+
|
|
116
|
+
**Tradeoff:** You cannot log custom domain events (e.g., `"user:promoted"` as a distinct event type). Every event is either `insert`, `update`, or `delete`.
|
|
117
|
+
|
|
118
|
+
**Constraint:** If you need rich domain events, you'd extend this library's hook to derive a domain event type from the mutation data, or add a `dispatch()` API alongside the collection API.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### 4. Replay Uses `acceptMutations` (No Re-Logging Guard Needed)
|
|
123
|
+
|
|
124
|
+
**Decision:** Server events are replayed into state collections via the persistence layer's `acceptMutations` util, which bypasses the `onInsert` / `onUpdate` / `onDelete` write handlers entirely.
|
|
125
|
+
|
|
126
|
+
**Why:**
|
|
127
|
+
|
|
128
|
+
The mutation hooks (which append to the outbox) only fire on user-initiated writes. `acceptMutations` applies and persists rows without invoking those handlers, so replaying a pulled event never re-appends it to the outbox. This removes the need for the old global `isSyncing` boolean and its associated race window — there is no longer a guard that could drop a concurrent user mutation.
|
|
129
|
+
|
|
130
|
+
**Idempotent recovery:** Replay is keyed by the row's primary key, so re-applying the same event is a no-op upsert. On startup, any inbox row still marked `sync: false` (e.g. the process crashed between writing the inbox row and applying it) is simply replayed again. The inbox `sync` flag is the durable record of "applied or not".
|
|
131
|
+
|
|
132
|
+
**Tradeoff:** A user mutation made during an in-flight pull is appended to the outbox immediately and will be pushed on the next sync — there is no longer any window where it is silently skipped.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### 5. State Collections Are Source of Truth Locally
|
|
137
|
+
|
|
138
|
+
**Decision:** The local persisted collections (state) are the source of truth for the UI. The event log is an audit trail + sync mechanism. We don't rebuild state from events on startup.
|
|
139
|
+
|
|
140
|
+
**Why:**
|
|
141
|
+
|
|
142
|
+
- Instant hydration — state is already materialized in SQLite
|
|
143
|
+
- No replay cost on app start (could be seconds for large event logs)
|
|
144
|
+
- TanStack DB's persistence layer already handles this correctly
|
|
145
|
+
- The event log is only used for sync, not for state reconstruction
|
|
146
|
+
|
|
147
|
+
**Tradeoff:** If the event log and state diverge (bug, partial failure), the state wins locally. The server's event log is the ultimate arbiter — next sync will correct any drift.
|
|
148
|
+
|
|
149
|
+
**Constraint:** This means you cannot "replay from event 0" to rebuild state client-side. If you need that (time-travel debugging, undo/redo), extend the library to add a `rebuildFromEvents()` method.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
### 6. Platform Dependencies Are Injected (Not Imported)
|
|
154
|
+
|
|
155
|
+
**Decision:** The library never imports `@tanstack/browser-db-sqlite-persistence` or `@tanstack/react-native-db-sqlite-persistence` directly. Users pass them in via a `load()` callback on the platform helpers, or as explicit parameters to the low-level `createEventSourcedDB` API.
|
|
156
|
+
|
|
157
|
+
**Why:**
|
|
158
|
+
|
|
159
|
+
- One npm package works on all platforms
|
|
160
|
+
- No conditional imports or build-time platform detection
|
|
161
|
+
- No React Native metro bundler issues with browser-only code
|
|
162
|
+
- The library has zero bundled platform or framework dependencies
|
|
163
|
+
- Users choose `@tanstack/react-db` vs `@tanstack/db` vs `@tanstack/react-native-db` in their own `load()` — the package never decides
|
|
164
|
+
|
|
165
|
+
**Recommended setup (browser):**
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { createBrowserEventSourcedDB } from "event-sourced-collection/browser";
|
|
169
|
+
|
|
170
|
+
const { ensureDb, db } = createBrowserEventSourcedDB({
|
|
171
|
+
databaseName: "my-app.sqlite",
|
|
172
|
+
collections: { todos: { getKey: (t) => t.id } },
|
|
173
|
+
sync: { pushEvents, pullEvents },
|
|
174
|
+
load: async () => {
|
|
175
|
+
const { createCollection } = await import("@tanstack/react-db");
|
|
176
|
+
const platform = await import("@tanstack/browser-db-sqlite-persistence");
|
|
177
|
+
return { ...platform, createCollection };
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`createBrowserEventSourcedDB` / `createReactNativeEventSourcedDB` orchestrate platform setup, call `createEventSourcedDB`, and return `{ ensureDb, db, close }` backed by `createLazySingleton` (deduped init + proxy). The `load` callback keeps dynamic imports in userland so SSR bundles stay clean.
|
|
183
|
+
|
|
184
|
+
**Low-level alternative:** Pass `createCollection`, `persistedCollectionOptions`, and `persistence` directly to `createEventSourcedDB` + `createBrowserPlatform` when you need full control.
|
|
185
|
+
|
|
186
|
+
**Tradeoff:** Users write a small `load()` function. That is intentional — it is the price of zero coupling.
|
|
187
|
+
|
|
188
|
+
**Constraint:** If even `load()` feels verbose, the helpers already collapse ~80 lines of manual singleton/proxy/platform wiring into one call.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
### 7. Handler-First Sync With HTTP Fallback
|
|
193
|
+
|
|
194
|
+
**Decision:** Sync accepts typed `pushEvents` and `pullEvents` functions first, plus `pushUrl` and `pullUrl` as HTTP convenience fallbacks. The legacy `SyncTransport` shape is still accepted.
|
|
195
|
+
|
|
196
|
+
**Why:**
|
|
197
|
+
|
|
198
|
+
- HTTP is universally available (browser, RN, Node)
|
|
199
|
+
- RESTful endpoints are the easiest to implement server-side
|
|
200
|
+
- Type-safe RPC/server-function clients should not lose type safety by routing through raw URLs
|
|
201
|
+
- Function handlers let users call existing clients, queues, workers, or local persistence without the library knowing the transport
|
|
202
|
+
|
|
203
|
+
**Tradeoff:** HTTP is request/response — no real-time push from server. The client must poll or call `sync()` explicitly unless user-provided handlers integrate a realtime trigger.
|
|
204
|
+
|
|
205
|
+
**Future improvement:** Add a `subscribe()` option that uses SSE or WebSocket for real-time server push, falling back to polling.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
### 8. No CRDT, No Vector Clocks
|
|
210
|
+
|
|
211
|
+
**Decision:** Conflicts are resolved by server ordering. The server's `global_seq` is law.
|
|
212
|
+
|
|
213
|
+
**Why:**
|
|
214
|
+
|
|
215
|
+
- Target scope: single user, possibly multi-device
|
|
216
|
+
- Cross-user conflicts are extremely rare (data is scoped to userId)
|
|
217
|
+
- Same-user conflicts on multi-device are resolved by "last writer wins" (server decides sequence)
|
|
218
|
+
- CRDTs add: metadata overhead, merge complexity, tombstone management, convergence testing
|
|
219
|
+
- None of that is needed when one server is the authority
|
|
220
|
+
|
|
221
|
+
**Tradeoff:** Two simultaneous offline edits to the same field → server picks one order. No automatic merge. The "losing" edit is still in the event log (audit trail), but the state reflects the server's chosen order.
|
|
222
|
+
|
|
223
|
+
**Constraint:** If you need real-time collaborative editing (Google Docs style), this is the wrong architecture. Use Yjs, Automerge, or similar.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
### 9. `BIGSERIAL` for Server Ordering (Not UUIDv7)
|
|
228
|
+
|
|
229
|
+
**Decision:** Server uses PostgreSQL's `BIGSERIAL` for `global_seq`.
|
|
230
|
+
|
|
231
|
+
**Why:**
|
|
232
|
+
|
|
233
|
+
| Factor | BIGSERIAL | UUIDv7 |
|
|
234
|
+
| ------------------- | ------------------------- | --------------------------------- |
|
|
235
|
+
| Storage per event | 8 bytes | 16 bytes |
|
|
236
|
+
| Index performance | Faster integer comparison | Slower string comparison |
|
|
237
|
+
| Ordering guarantee | Database-level monotonic | Timestamp-based (clock skew risk) |
|
|
238
|
+
| Readability | `seq: 42` | `019abc12-...` |
|
|
239
|
+
| Distributed writers | No (single server) | Yes |
|
|
240
|
+
|
|
241
|
+
Since there's one server (not distributed), `BIGSERIAL` is strictly superior.
|
|
242
|
+
|
|
243
|
+
**Constraint:** If you ever need multiple independent write servers, you'd need to switch to UUIDv7 or a distributed sequence generator. For single-server (which covers 99% of apps), this is irrelevant.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
### 10. Delete Events Store the Full Object (Not Just the Key)
|
|
248
|
+
|
|
249
|
+
**Decision:** When a row is deleted, the event payload contains the entire deleted object, not just the key.
|
|
250
|
+
|
|
251
|
+
**Why:**
|
|
252
|
+
|
|
253
|
+
- Server can process the delete without looking up what was deleted
|
|
254
|
+
- Enables "undo delete" on the server side
|
|
255
|
+
- Audit trail shows what was deleted, not just that something was deleted
|
|
256
|
+
- Replay can restore the object if the delete is later reversed
|
|
257
|
+
|
|
258
|
+
**Tradeoff:** Slightly larger event payloads for deletes.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Pros
|
|
263
|
+
|
|
264
|
+
- Zero learning curve for TanStack DB users
|
|
265
|
+
- Offline-first by default — works without network
|
|
266
|
+
- Partial sync — never downloads entire tables
|
|
267
|
+
- Foreign key safety — server controls insertion order
|
|
268
|
+
- Audit trail — complete history of every mutation
|
|
269
|
+
- Multi-device — events from all devices converge
|
|
270
|
+
- Platform agnostic — browser, React Native, Expo, Node
|
|
271
|
+
- Type-safe — full inference from collection definitions
|
|
272
|
+
- Small — ~4KB, minimal dependencies
|
|
273
|
+
- No build plugins or code generation
|
|
274
|
+
|
|
275
|
+
## Cons
|
|
276
|
+
|
|
277
|
+
- Requires a server (not P2P)
|
|
278
|
+
- No real-time push (polling or explicit sync)
|
|
279
|
+
- Event log grows unbounded (need pruning strategy for very long-lived apps)
|
|
280
|
+
- No automatic conflict resolution beyond "server decides order"
|
|
281
|
+
- Cannot replay from event 0 to rebuild state (state is materialized separately)
|
|
282
|
+
- Update events store full modified object (not a minimal diff)
|
|
283
|
+
- Pull cursor is derived by scanning synced inbox state rather than stored as a single authoritative value
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## When NOT to Use This
|
|
288
|
+
|
|
289
|
+
| Scenario | Better Alternative |
|
|
290
|
+
| --------------------------------------------- | ------------------------------ |
|
|
291
|
+
| Real-time multiplayer / collaborative editing | CRDTs (Yjs, Automerge) |
|
|
292
|
+
| Server-authoritative with no offline | Direct API calls + React Query |
|
|
293
|
+
| Simple cache layer | TanStack Query |
|
|
294
|
+
| Multi-tenant with cross-user data | Electric SQL, PowerSync |
|
|
295
|
+
| Streaming large datasets | TanStack DB with Electric sync |
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## When to Use This
|
|
300
|
+
|
|
301
|
+
- Single-user app with multi-device sync
|
|
302
|
+
- Offline-first mobile app (React Native)
|
|
303
|
+
- Todo apps, note-taking, personal finance, habit trackers
|
|
304
|
+
- Any app where the user owns their data and edits offline
|
|
305
|
+
- Apps that need an audit trail of changes
|
|
306
|
+
- Apps where you control the backend and want simple sync
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Adjacent TanStack Libraries & How They Compose
|
|
311
|
+
|
|
312
|
+
TanStack DB 0.6 shipped several features that directly improve this library's capabilities. Here's how each one fits.
|
|
313
|
+
|
|
314
|
+
### `createEffect` — Reactive Sync Trigger
|
|
315
|
+
|
|
316
|
+
**What it is:** A database-trigger-like API that fires `onEnter` / `onUpdate` / `onExit` callbacks when rows enter/leave a live query result. Runs incrementally on deltas, not full result sets.
|
|
317
|
+
|
|
318
|
+
**How it helps us:**
|
|
319
|
+
|
|
320
|
+
| Without createEffect | With createEffect |
|
|
321
|
+
| ----------------------------------------- | ---------------------------------------------------------- |
|
|
322
|
+
| User must call `db.sync()` manually | Auto-syncs when pending events exist |
|
|
323
|
+
| Polling interval for connectivity | Reactive — fires the instant an event is logged |
|
|
324
|
+
| No way to react to server-applied changes | `onEnter` fires when a new server event materializes a row |
|
|
325
|
+
|
|
326
|
+
**Integration pattern:**
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
createEffect({
|
|
330
|
+
query: (q) => q.from({ e: db.collections.outbox }).where(({ e }) => eq(e.sync, false)),
|
|
331
|
+
skipInitial: false,
|
|
332
|
+
onEnter: async (_event, ctx) => {
|
|
333
|
+
await syncWithRetry({ signal: ctx.signal });
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
This replaces `setInterval(() => db.sync(), 30000)` with a reactive approach that fires immediately when data is dirty.
|
|
339
|
+
|
|
340
|
+
**Trade-off:** Adds a dependency on `createEffect` from `@tanstack/db`. Currently optional — users who prefer manual sync can ignore it.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
### `@tanstack/offline-transactions` — Production Sync Reliability
|
|
345
|
+
|
|
346
|
+
**What it is:** An `OfflineExecutor` that orchestrates:
|
|
347
|
+
|
|
348
|
+
- Persistent outbox (IndexedDB/localStorage)
|
|
349
|
+
- Leader election (WebLocks/BroadcastChannel)
|
|
350
|
+
- Retry with exponential backoff
|
|
351
|
+
- Connectivity detection
|
|
352
|
+
- Idempotency keys for at-least-once delivery
|
|
353
|
+
- Graceful degradation when storage is unavailable
|
|
354
|
+
|
|
355
|
+
**How it helps us:**
|
|
356
|
+
|
|
357
|
+
Our library's `sync()` function is a simple push+pull. In production, you need:
|
|
358
|
+
|
|
359
|
+
- Only one tab performing sync (leader election)
|
|
360
|
+
- Automatic retry when push fails (network blip)
|
|
361
|
+
- Backoff so you don't DDoS your own server
|
|
362
|
+
- Connectivity awareness (don't try to sync when offline)
|
|
363
|
+
|
|
364
|
+
`@tanstack/offline-transactions` provides all of this.
|
|
365
|
+
|
|
366
|
+
**Integration pattern:**
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import { createOfflineTransaction } from "@tanstack/offline-transactions";
|
|
370
|
+
|
|
371
|
+
const offlineSync = createOfflineTransaction({
|
|
372
|
+
mutationFn: async () => {
|
|
373
|
+
const result = await db.sync();
|
|
374
|
+
if (result.errors.length > 0) throw result.errors[0];
|
|
375
|
+
},
|
|
376
|
+
retryConfig: { maxRetries: 5, backoffMs: 1000 },
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Trade-off:** Optional dependency. Our library works without it (manual sync), but production apps should use it.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
### Virtual Props (`$synced`, `$origin`) — Sync State Visibility
|
|
385
|
+
|
|
386
|
+
**What they are:**
|
|
387
|
+
|
|
388
|
+
- `$synced: boolean` — whether the row is confirmed by sync or still optimistic/local
|
|
389
|
+
- `$origin: 'local' | 'remote'` — whether the last confirmed change came from this client or upstream
|
|
390
|
+
|
|
391
|
+
**How they help us:**
|
|
392
|
+
|
|
393
|
+
| Problem | Solution with Virtual Props |
|
|
394
|
+
| --------------------------------------- | -------------------------------------------------------- |
|
|
395
|
+
| "Which todos haven't synced yet?" | `where(({ todo }) => eq(todo.$synced, false))` |
|
|
396
|
+
| "Show a spinner on unsynced items" | Render based on `todo.$synced` in the UI |
|
|
397
|
+
| "Skip re-logging server-applied events" | Already handled — `acceptMutations` bypasses the write hooks |
|
|
398
|
+
|
|
399
|
+
**Note:** This library no longer needs a re-logging guard. Server replay goes through `acceptMutations`, which never invokes the outbox-logging hooks (see Design Decision #4). Virtual props remain useful for surfacing optimistic vs confirmed state in the UI.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
### `includes` — Hierarchical Queries
|
|
404
|
+
|
|
405
|
+
**What it is:** Nested subqueries that project normalized data into the hierarchical shape of your UI, without N+1 queries. Each included field is a child collection with independent reactivity.
|
|
406
|
+
|
|
407
|
+
**How it helps us:**
|
|
408
|
+
|
|
409
|
+
Event-sourced state collections are flat (users, todos, settings). But UIs are hierarchical (a project has issues, each issue has comments). With `includes`, users query across their event-sourced collections with full hierarchical projection:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
const { data: users } = useLiveQuery((q) =>
|
|
413
|
+
q.from({ u: db.collections.users }).select(({ u }) => ({
|
|
414
|
+
id: u.id,
|
|
415
|
+
name: u.name,
|
|
416
|
+
todos: q
|
|
417
|
+
.from({ t: db.collections.todos })
|
|
418
|
+
.where(({ t }) => eq(t.userId, u.id))
|
|
419
|
+
.select(({ t }) => ({ id: t.id, title: t.title })),
|
|
420
|
+
})),
|
|
421
|
+
);
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
This would be extremely painful to build from scratch (incremental nested query evaluation). TanStack DB handles it in one query graph.
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
### `queryOnce` — One-Shot Reads
|
|
429
|
+
|
|
430
|
+
**What it is:** Execute a query once and get the result as a promise, without subscribing to updates.
|
|
431
|
+
|
|
432
|
+
**How it helps us:**
|
|
433
|
+
|
|
434
|
+
Useful internally for sync operations:
|
|
435
|
+
|
|
436
|
+
- "Get all pending events" — one-shot, no subscription needed
|
|
437
|
+
- "Get current cursor state" — read once at sync start
|
|
438
|
+
- Useful for migration scripts, exports, debugging
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
const pending = await queryOnce((q) =>
|
|
442
|
+
q
|
|
443
|
+
.from({ e: db.collections.outbox })
|
|
444
|
+
.where(({ e }) => eq(e.sync, false))
|
|
445
|
+
.orderBy(({ e }) => e.localSeq, "asc"),
|
|
446
|
+
);
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
### Summary: What We Get for Free vs What We Build
|
|
452
|
+
|
|
453
|
+
| Layer | Source | Lines of Code |
|
|
454
|
+
| --------------------------------------------- | ------------------------------ | ------------- |
|
|
455
|
+
| SQLite persistence (8 platforms) | TanStack DB | ~2,800 |
|
|
456
|
+
| Reactive query engine (differential dataflow) | TanStack DB | ~8,000 |
|
|
457
|
+
| Multi-tab coordination | TanStack DB | ~1,200 |
|
|
458
|
+
| Optimistic mutations + rollback | TanStack DB | ~2,000 |
|
|
459
|
+
| Hierarchical includes | TanStack DB | ~1,500 |
|
|
460
|
+
| Reactive effects (createEffect) | TanStack DB | ~600 |
|
|
461
|
+
| Offline retry + leader election | @tanstack/offline-transactions | ~800 |
|
|
462
|
+
| **Event log + sync protocol + hook wiring** | **This library** | **~300** |
|
|
463
|
+
|
|
464
|
+
We build 300 lines. We get 17,000+ lines of battle-tested infrastructure for free.
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## Future Directions
|
|
469
|
+
|
|
470
|
+
1. **Event pruning** — compact old synced events after N days
|
|
471
|
+
2. **Real-time subscribe** — SSE/WebSocket for instant server push
|
|
472
|
+
3. **Selective sync** — sync only specific collections or subsets
|
|
473
|
+
4. **Batched writes** — group rapid mutations into single events
|
|
474
|
+
5. **Undo/redo** — leverage event log for time-travel
|
|
475
|
+
6. **Schema migrations** — event upcasters for evolving payload shapes
|
|
476
|
+
7. **Compression** — delta encoding for update events (store diff, not full object)
|
|
477
|
+
8. **Retry queue** — exponential backoff for failed pushes
|
|
478
|
+
9. **Multi-user awareness** — optional user_id scoping on the event table
|
|
479
|
+
10. **Rebuild from events** — optional cold-start replay for disaster recovery
|