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.
@@ -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