event-sourced-collection 0.0.5 → 0.0.7
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 +571 -37
- package/README.md +589 -37
- package/dist/browser.d.mts +16 -1
- package/dist/browser.d.mts.map +1 -1
- package/dist/browser.mjs +15 -2
- package/dist/browser.mjs.map +1 -1
- package/dist/index.d.mts +62 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +129 -2
- package/dist/index.mjs.map +1 -0
- package/dist/lazy-singleton-BuBmSQb3.mjs +1627 -0
- package/dist/lazy-singleton-BuBmSQb3.mjs.map +1 -0
- package/dist/react-native.d.mts +13 -1
- package/dist/react-native.d.mts.map +1 -1
- package/dist/react-native.mjs +14 -2
- package/dist/react-native.mjs.map +1 -1
- package/dist/react.d.mts +69 -0
- package/dist/react.d.mts.map +1 -0
- package/dist/react.mjs +88 -0
- package/dist/react.mjs.map +1 -0
- package/dist/types-CIzhepuW.d.mts +436 -0
- package/dist/types-CIzhepuW.d.mts.map +1 -0
- package/dist/web-locks-C4279VL2.mjs +40 -0
- package/dist/web-locks-C4279VL2.mjs.map +1 -0
- package/examples/postgres-sync-server/README.md +50 -0
- package/examples/postgres-sync-server/handlers.ts +289 -0
- package/examples/postgres-sync-server/schema.sql +30 -0
- package/examples/web-worker-sync/README.md +17 -0
- package/examples/web-worker-sync/collections.snippet.ts +57 -0
- package/examples/web-worker-sync/create-worker-sync-transport.ts +82 -0
- package/examples/web-worker-sync/sync.worker.ts +66 -0
- package/package.json +17 -2
- package/dist/lazy-singleton-BG02GHLj.mjs +0 -664
- package/dist/lazy-singleton-BG02GHLj.mjs.map +0 -1
- package/dist/types-BX8dtGOu.d.mts +0 -162
- package/dist/types-BX8dtGOu.d.mts.map +0 -1
package/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Architecture & Design Decisions
|
|
2
2
|
|
|
3
3
|
Technical reference for contributors and anyone evaluating this library's tradeoffs.
|
|
4
|
+
Usage and API: [README.md](./README.md).
|
|
4
5
|
|
|
5
6
|
---
|
|
6
7
|
|
|
@@ -29,32 +30,126 @@ This is event sourcing applied to client-side state, scoped to a single user's d
|
|
|
29
30
|
│ │ e1 | ins | t1 | false │ │ e9 | 50 | true │ │
|
|
30
31
|
│ │ e2 | upd | t1 | true │ │ e10 | 51 | false │ │
|
|
31
32
|
│ └───────────────────────────────┘ └───────────────────────────────┘ │
|
|
32
|
-
│ ▲
|
|
33
|
-
│
|
|
34
|
-
│
|
|
35
|
-
│ │
|
|
33
|
+
│ ▲ │ │ │
|
|
34
|
+
│ │ exhausted retries acceptMutations │
|
|
35
|
+
│ │ or hard rejection replays inbox into │
|
|
36
|
+
│ │ ▼ state collections │
|
|
37
|
+
│ onInsert/ ┌──────────────┐ │ │
|
|
38
|
+
│ onUpdate/ │ deadletter │ │ │
|
|
39
|
+
│ onDelete │ terminal │ │ │
|
|
40
|
+
│ hooks └──────────────┘ │ │
|
|
41
|
+
│ │ ▼ │
|
|
36
42
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
37
43
|
│ │ users │ │ todos │ │ settings │ │
|
|
38
44
|
│ │ (persisted) │ │ (persisted) │ │ (persisted) │ │
|
|
39
45
|
│ │ collection │ │ collection │ │ collection │ │
|
|
40
46
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
41
47
|
│ │
|
|
42
|
-
│
|
|
48
|
+
│ syncmeta: { pullCursor, backendId, lastSyncAt, lastError } │
|
|
49
|
+
│ rowversions: last event per row (only when conflictDetection is on) │
|
|
43
50
|
└──────────────────────────────────────────────────────────────────────┘
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
│ push (batched, backoff) │ pull (since syncmeta.pullCursor)
|
|
52
|
+
▼ ▼
|
|
46
53
|
┌──────────────────────────────────────────────────────────────────────┐
|
|
47
54
|
│ Server │
|
|
48
55
|
│ │
|
|
49
56
|
│ events table (BIGSERIAL global_seq, event_id UNIQUE, payload JSONB) │
|
|
57
|
+
│ sync_backend table (backend_id — changes when the store is recreated)│
|
|
50
58
|
│ │
|
|
51
|
-
│ POST /api/events → assigns global_seq, deduplicates by event_id │
|
|
52
|
-
│
|
|
59
|
+
│ POST /api/events → assigns global_seq, deduplicates by event_id, │
|
|
60
|
+
│ rejects stale writes when base_version is sent │
|
|
61
|
+
│ GET /api/events?since=N → events after N sorted ASC, plus backendId │
|
|
53
62
|
└──────────────────────────────────────────────────────────────────────┘
|
|
54
63
|
```
|
|
55
64
|
|
|
56
65
|
---
|
|
57
66
|
|
|
67
|
+
## Source map
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
src/
|
|
71
|
+
├── create-event-sourced-db.ts # orchestration: wire collections, sync, status, APIs
|
|
72
|
+
├── types.ts # public wire + config types
|
|
73
|
+
├── sync.ts # transport normalization (handlers / URLs / SyncTransport)
|
|
74
|
+
├── mock-sync-backend.ts # in-memory server for tests
|
|
75
|
+
├── internal/
|
|
76
|
+
│ ├── constants.ts # reserved ids, defaults, CONFLICT code
|
|
77
|
+
│ ├── types.ts # replay / meta types shared by internals
|
|
78
|
+
│ ├── hooks.ts # fire-and-forget lifecycle emitter
|
|
79
|
+
│ ├── serial-queue.ts # in-process sync serialization
|
|
80
|
+
│ ├── sync-meta.ts # syncmeta row: cursor, backendId, lastSync*
|
|
81
|
+
│ ├── push.ts # due set, backoff, batching, dead-letter
|
|
82
|
+
│ ├── pull.ts # pagination, origin skip, backend identity
|
|
83
|
+
│ └── replay.ts # upcast, acceptMutations, rowversions
|
|
84
|
+
└── platforms/
|
|
85
|
+
├── web-locks.ts # browser SyncLock (ifAvailable)
|
|
86
|
+
├── browser-event-sourced-db.ts
|
|
87
|
+
└── react-native-event-sourced-db.ts
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`create-event-sourced-db.ts` should stay thin. Push, pull, and replay each have
|
|
91
|
+
enough edge cases (backoff, tx-aware batching, backend reset, upcast asymmetry)
|
|
92
|
+
that they were split out so a change in one path does not force a re-read of the
|
|
93
|
+
whole engine.
|
|
94
|
+
|
|
95
|
+
Platform packages only inject persistence + an optional lock. The core never
|
|
96
|
+
imports Web Locks or SQLite drivers directly — that keeps Node tests and RN
|
|
97
|
+
bundles free of browser APIs.
|
|
98
|
+
|
|
99
|
+
### Reserved collections
|
|
100
|
+
|
|
101
|
+
| Id | Role |
|
|
102
|
+
| -- | ---- |
|
|
103
|
+
| `outbox` | Local mutations; push due set |
|
|
104
|
+
| `inbox` | Pulled events; replay / skip markers |
|
|
105
|
+
| `deadletter` | Terminal push failures and unapplicable pulled events |
|
|
106
|
+
| `syncmeta` | Singleton: cursor, backendId, clientId, lastSyncAt, lastError |
|
|
107
|
+
| `rowversions` | Last applied event id per row (conflict detection only) |
|
|
108
|
+
|
|
109
|
+
User collection ids must not collide with these.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Sync pipeline (one `sync()` call)
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
sync() / manualSync()
|
|
117
|
+
└─ serial queue (this context)
|
|
118
|
+
└─ lock.tryRun (cross-tab, optional)
|
|
119
|
+
└─ onSyncStart
|
|
120
|
+
├─ pushOutbox
|
|
121
|
+
│ due = !sync && (not failed OR retryable & nextAttemptAt ≤ now)
|
|
122
|
+
│ batch by txId (never split a transaction)
|
|
123
|
+
│ for each batch:
|
|
124
|
+
│ stamp attemptCount / lastAttemptAt
|
|
125
|
+
│ transport.push
|
|
126
|
+
│ confirmed → sync=true, onEventPushed
|
|
127
|
+
│ failed retryable → nextAttemptAt = now + backoff
|
|
128
|
+
│ failed hard / maxAttempts → deadletter, onDeadLetter
|
|
129
|
+
│ transport throw → backoff whole batch, stop with counts kept
|
|
130
|
+
└─ pullInbox
|
|
131
|
+
since = max(0, syncmeta.pullCursor - pullOverlap)
|
|
132
|
+
for each page:
|
|
133
|
+
reconcile backendId
|
|
134
|
+
→ resetCursor also requeues the synced outbox
|
|
135
|
+
skip local origin (outbox.has || clientId match)
|
|
136
|
+
insert inbox row → replayEvent → resolve
|
|
137
|
+
replay threw → bump inbox attemptCount
|
|
138
|
+
→ under budget: halt, retry next sync
|
|
139
|
+
→ budget spent: inbound deadletter, cursor advances
|
|
140
|
+
halt leaves cursor unmoved
|
|
141
|
+
writePullCursor after each successful page
|
|
142
|
+
├─ pushOutbox again, only if a backend reset requeued events
|
|
143
|
+
└─ writeSyncOutcome(lastSyncAt, lastError)
|
|
144
|
+
└─ onSyncComplete
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`manualSync()` then additionally drains unresolved inbox rows (same replay
|
|
148
|
+
path). Deferred lock acquisition returns `{ deferred: true }` without firing
|
|
149
|
+
`onSyncStart` — another tab is already doing the work.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
58
153
|
## Design Decisions
|
|
59
154
|
|
|
60
155
|
### 1. Separate Outbox and Inbox (Not One Mixed Table)
|
|
@@ -68,20 +163,26 @@ This is event sourcing applied to client-side state, scoped to a single user's d
|
|
|
68
163
|
| Mental model | A single row could be "ours, pending" or "theirs, synced" | Direction is explicit: outbox = ours, inbox = theirs |
|
|
69
164
|
| Per-row `sync` flag | Overloaded — means "pushed" or "applied" depending on row | Unambiguous — outbox `sync` = pushed, inbox `sync` = replayed |
|
|
70
165
|
| Visualization | Needs raw SQL access to inspect | Just `useLiveQuery(db.collections.outbox)` |
|
|
71
|
-
| Cursor | Dedicated `esdb_cursor` row to keep in step |
|
|
166
|
+
| Cursor | Dedicated `esdb_cursor` row to keep in step | A single `syncmeta` row, seeded from inbox state on upgrade |
|
|
72
167
|
| Storage layer | Hand-rolled SQLite table + indexes | Reuses TanStack DB persistence (reactive, multi-tab aware) |
|
|
73
168
|
|
|
169
|
+
Three more reserved collections support the sync loop: `deadletter` (events that
|
|
170
|
+
will never be retried, in either direction), `syncmeta` (one row holding the pull
|
|
171
|
+
cursor, the backend identity, and this device's own identity), and `rowversions`
|
|
172
|
+
(per-row version index, only written when `conflictDetection` is enabled).
|
|
173
|
+
|
|
74
174
|
**Per-row sync semantics:**
|
|
75
175
|
|
|
76
176
|
- **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
|
|
78
|
-
- **inbox** — `sync: false` means the event was pulled but not yet
|
|
177
|
+
- **outbox `syncStatus`** — `pending` means eligible to send, `synced` means confirmed, and `failed` means the server rejected it but marked it retryable; `nextAttemptAt` records when the backoff window opens. A rejection that is *not* retryable never lingers as `failed` — it moves straight to `deadletter`.
|
|
178
|
+
- **inbox** — `sync: false` means the event was pulled but not yet resolved. `sync: true` means resolved: applied on top of the local data, originated locally and already applied before push, or deliberately skipped. Skipped rows carry `skipped: true` and a `skipReason`, and still allow the cursor to advance.
|
|
179
|
+
- **deadletter** — terminal. Rows carry a `direction` (`outbound` / `inbound`) and a `reason` (`rejected`, `maxAttemptsExceeded`, `conflict`, `replayFailed`, `manual`), and only move via `retryDeadLetter()` or `discardDeadLetter()`. `retryDeadLetter()` returns each event to the queue it came from: the outbox for outbound, the inbox for inbound.
|
|
79
180
|
|
|
80
181
|
**FK ordering** is still preserved: the inbox replays server events sorted by `globalSeq`, so parents always precede children.
|
|
81
182
|
|
|
82
|
-
**
|
|
183
|
+
**Why the cursor moved out of the inbox:** deriving it from `max(synced globalSeq)` meant pruning the inbox silently rewound the client. Storing it in `syncmeta` decouples log retention from sync position; the derived value is still read as a floor so existing databases upgrade without re-pulling.
|
|
83
184
|
|
|
84
|
-
**Constraint:** Because
|
|
185
|
+
**Constraint:** Because these are persisted collections, durability and multi-tab coordination are inherited from TanStack DB's persistence layer rather than implemented here.
|
|
85
186
|
|
|
86
187
|
---
|
|
87
188
|
|
|
@@ -226,21 +327,61 @@ const { ensureDb, db } = createBrowserEventSourcedDB({
|
|
|
226
327
|
|
|
227
328
|
### 9. `BIGSERIAL` for Server Ordering (Not UUIDv7)
|
|
228
329
|
|
|
229
|
-
**Decision:** Server uses PostgreSQL's `BIGSERIAL` for `global_seq`.
|
|
330
|
+
**Decision:** Server uses PostgreSQL's `BIGSERIAL` for `global_seq`. Client
|
|
331
|
+
`eventId` remains UUIDv7 for uniqueness and dedup — those are different jobs.
|
|
230
332
|
|
|
231
333
|
**Why:**
|
|
232
334
|
|
|
233
|
-
| Factor
|
|
234
|
-
|
|
|
235
|
-
| Storage per event
|
|
236
|
-
| Index
|
|
237
|
-
|
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
|
|
241
|
-
|
|
335
|
+
| Factor | BIGSERIAL (`global_seq`) | UUIDv7 |
|
|
336
|
+
| ------ | ------------------------ | ------ |
|
|
337
|
+
| Storage per event | 8 bytes | 16 bytes |
|
|
338
|
+
| Index / cursor | Dense integer compare | Lexicographic string |
|
|
339
|
+
| Who assigns order | One server (authority) | Whoever mints the UUID |
|
|
340
|
+
| Multi-device → one API → one DB | Correct | Unnecessary for order |
|
|
341
|
+
| Multiple independent write primaries | Needs a distributed sequencer | Helps generate unique sortable IDs without a central SERIAL |
|
|
342
|
+
|
|
343
|
+
**“Distributed writers” means multiple databases minting order, not multiple
|
|
344
|
+
phones.** Several devices pushing into one Postgres is the intended design.
|
|
345
|
+
|
|
346
|
+
**Critical caveat — sequence values are allocated before commit.** Two concurrent
|
|
347
|
+
inserts can take `global_seq` 50 and 51, and 51 can commit first. A client that
|
|
348
|
+
pulls during that window sees 51, advances its cursor to 51, and **never sees
|
|
349
|
+
50**. Multi-device traffic increases concurrency and thus this race; it does not
|
|
350
|
+
require switching to UUIDv7. Server-minted UUIDv7 before commit has the same gap.
|
|
351
|
+
|
|
352
|
+
How to handle it (pick at least one server-side approach):
|
|
353
|
+
|
|
354
|
+
| Approach | Where | Notes |
|
|
355
|
+
| -------- | ----- | ----- |
|
|
356
|
+
| `pg_advisory_xact_lock` around insert | Server push | One writer assigns+commits at a time; simplest correct fix |
|
|
357
|
+
| Serve only `global_seq < pg_snapshot_xmin(...)` | Server pull | Never returns in-flight sequences |
|
|
358
|
+
| `pullOverlap` | Client | Fallback; narrows the window, does not close it |
|
|
359
|
+
|
|
360
|
+
Annotated handlers (plain comments, not a checklist):
|
|
361
|
+
[`examples/postgres-sync-server/`](./examples/postgres-sync-server/).
|
|
362
|
+
|
|
363
|
+
Sketch:
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
// PUSH — one DB transaction per client txId group
|
|
367
|
+
await client.query("BEGIN");
|
|
368
|
+
// Keep assign+commit ordered so pullers don't skip a late-committing row
|
|
369
|
+
await client.query("SELECT pg_advisory_xact_lock($1)", [EVENTS_SEQ_LOCK]);
|
|
370
|
+
// Same eventId again → return the existing global_seq
|
|
371
|
+
// baseVersion mismatch → CONFLICT, retryable: false
|
|
372
|
+
// Sibling failure → ROLLBACK the whole group
|
|
373
|
+
await client.query("COMMIT");
|
|
374
|
+
|
|
375
|
+
// PULL
|
|
376
|
+
// backendId change → client resets cursor after a DB wipe
|
|
377
|
+
// xmin ceiling → don't serve sequences still in flight
|
|
378
|
+
WHERE global_seq > $since
|
|
379
|
+
AND global_seq < pg_snapshot_xmin(pg_current_snapshot())
|
|
380
|
+
```
|
|
242
381
|
|
|
243
|
-
**Constraint:**
|
|
382
|
+
**Constraint:** Only if you run multiple independent write primaries would you
|
|
383
|
+
replace central `BIGSERIAL` with a distributed id scheme — and you would still
|
|
384
|
+
need a total-order story (or CRDTs), not “UUIDv7 as pull cursor” alone.
|
|
244
385
|
|
|
245
386
|
---
|
|
246
387
|
|
|
@@ -257,6 +398,272 @@ Since there's one server (not distributed), `BIGSERIAL` is strictly superior.
|
|
|
257
398
|
|
|
258
399
|
**Tradeoff:** Slightly larger event payloads for deletes.
|
|
259
400
|
|
|
401
|
+
Update and delete events additionally carry `previous`, the row state before the
|
|
402
|
+
mutation. That makes every event invertible, which is the prerequisite for
|
|
403
|
+
rollback, undo, and any future rebase support. Inserts have `previous: null`.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
### 11. Unknown Events Are Recorded, Not Retried Forever
|
|
408
|
+
|
|
409
|
+
**Decision:** A pulled event targeting a collection this client does not know
|
|
410
|
+
about is written to the inbox, marked resolved with a `skipReason`, and the
|
|
411
|
+
cursor moves past it. Configurable via `unknownEventHandling: "skip" | "fail"`,
|
|
412
|
+
defaulting to `"skip"`.
|
|
413
|
+
|
|
414
|
+
**Why:**
|
|
415
|
+
|
|
416
|
+
Halting on an unrecognized event means the cursor never advances, so every
|
|
417
|
+
subsequent sync re-fetches the same blocked event and **no later event ever
|
|
418
|
+
lands**. That turns a single forward-compatibility mismatch into total sync
|
|
419
|
+
failure — which is the normal state of affairs during any staged rollout where
|
|
420
|
+
an old client meets events from a new one.
|
|
421
|
+
|
|
422
|
+
Skipping keeps the pipeline draining. The event is still durably recorded, so
|
|
423
|
+
nothing is lost and it stays visible for debugging.
|
|
424
|
+
|
|
425
|
+
**Tradeoff:** A skipped event is not automatically re-applied after the client
|
|
426
|
+
learns the collection. Query the inbox for `skipped: true` rows and replay them
|
|
427
|
+
if you need that.
|
|
428
|
+
|
|
429
|
+
**Constraint:** Use `"fail"` when you would rather stall than diverge — the
|
|
430
|
+
event stays unresolved and is retried on every sync.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
### 12. Conflicts Are Detected, Not Resolved (Opt-In)
|
|
435
|
+
|
|
436
|
+
**Decision:** With `conflictDetection: true`, each mutation records the version
|
|
437
|
+
of the row it was authored against (`baseVersion`, the `eventId` of the last
|
|
438
|
+
event applied to that row) and sends it with the event. The server rejects the
|
|
439
|
+
push with code `CONFLICT` when the row has moved on. Off by default.
|
|
440
|
+
|
|
441
|
+
**Why not always on:** it costs a write to the `rowversions` index on every
|
|
442
|
+
mutation *and* every replay. Single-device apps get nothing for that.
|
|
443
|
+
|
|
444
|
+
**What it does not do:** there is no rebase. A losing event goes to the
|
|
445
|
+
dead-letter queue with `reason: "conflict"` and stops there. Resolution is your
|
|
446
|
+
application's job — show the user both versions, re-apply on top of the new
|
|
447
|
+
state, or discard.
|
|
448
|
+
|
|
449
|
+
**Without it,** the semantics are not "last writer wins" but **"stale writer
|
|
450
|
+
wins"**: a pending local write pushed *after* a conflicting server event was
|
|
451
|
+
pulled lands at a higher `global_seq` and overwrites it, with neither side able
|
|
452
|
+
to detect that it happened. For single-user single-device this never comes up;
|
|
453
|
+
for multi-device it is real, silent data loss on concurrently edited rows.
|
|
454
|
+
|
|
455
|
+
Full parent links plus automatic rebase, as LiveStore does, remains out of
|
|
456
|
+
scope.
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
### 13. Failed Pushes Retry With Backoff, Then Dead-Letter
|
|
461
|
+
|
|
462
|
+
**Decision:** A rejection the server marks `retryable: true` is rescheduled with
|
|
463
|
+
exponential backoff (`retry.baseDelayMs` doubling to `retry.maxDelayMs`).
|
|
464
|
+
Anything else — or an event that exhausts `retry.maxAttempts` — is moved out of
|
|
465
|
+
the outbox into `deadletter`.
|
|
466
|
+
|
|
467
|
+
**Why the queue:** the outbox is ordered by `localSeq` and processed in order. A
|
|
468
|
+
permanently rejected event left in place is re-examined on every sync forever
|
|
469
|
+
and, worse, invites the reader to treat the head of the queue as "stuck". Moving
|
|
470
|
+
it out keeps the outbox meaning exactly one thing: work that will still be
|
|
471
|
+
attempted.
|
|
472
|
+
|
|
473
|
+
**Why not retry forever:** an event the server will never accept (schema
|
|
474
|
+
mismatch, revoked permission, validation failure) is not a transient fault.
|
|
475
|
+
Retrying it indefinitely burns battery and hides the failure from the user.
|
|
476
|
+
|
|
477
|
+
**Tradeoff:** dead-lettered events are user work that will be lost unless
|
|
478
|
+
something surfaces them. `retryDeadLetter()` and `discardDeadLetter()` exist, but
|
|
479
|
+
a UI that never reads `db.collections.deadletter` is silently dropping data.
|
|
480
|
+
|
|
481
|
+
**History:** earlier, `syncStatus === "failed"` permanently filtered events out
|
|
482
|
+
of the due set, so `retryable` / `attemptCount` metadata was write-only. The
|
|
483
|
+
dead-letter path is what makes that metadata meaningful.
|
|
484
|
+
|
|
485
|
+
**Backoff is deliberately jitter-free** so retry scheduling is reproducible in
|
|
486
|
+
tests. Add jitter in your push handler if many clients share one server.
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
### 13b. Inbound Events Dead-Letter Too
|
|
491
|
+
|
|
492
|
+
**Decision:** A pulled event whose `acceptMutations` call throws is not
|
|
493
|
+
propagated as a pull error. The failure is recorded on the inbox row
|
|
494
|
+
(`attemptCount`, `lastError`) and the page halts so the event is retried on the
|
|
495
|
+
next sync. Once it has failed `retry.maxAttempts` times it is copied to
|
|
496
|
+
`deadletter` with `direction: "inbound"` and `reason: "replayFailed"`, its inbox
|
|
497
|
+
row is resolved as skipped, and the cursor advances past it.
|
|
498
|
+
|
|
499
|
+
**Why:** replay used to rethrow. Because the cursor only advances past resolved
|
|
500
|
+
events, one event the local schema could not accept — a constraint violation, a
|
|
501
|
+
row referencing something this build does not have — stopped that client pulling
|
|
502
|
+
*anything* ever again. Every later event queued behind it silently, and the only
|
|
503
|
+
symptom was a repeating error in `lastError`. Outbound events had a full
|
|
504
|
+
retry-then-park pipeline; inbound events had none, despite being the direction
|
|
505
|
+
the client has no control over.
|
|
506
|
+
|
|
507
|
+
**Why retry before parking:** replay failures are often ordering artefacts that
|
|
508
|
+
resolve once an earlier event lands, so giving up on the first throw would park
|
|
509
|
+
recoverable events. Halting the page preserves `globalSeq` ordering while the
|
|
510
|
+
budget lasts.
|
|
511
|
+
|
|
512
|
+
**Tradeoff:** advancing past a parked event means the client is knowingly
|
|
513
|
+
inconsistent with the server for that row. That is strictly better than being
|
|
514
|
+
consistent-but-frozen for every row, but it does mean `deadletter` must be
|
|
515
|
+
surfaced for inbound rows too, not just outbound ones. `retryDeadLetter()` sends
|
|
516
|
+
them back to the inbox and replays immediately rather than pushing them, which
|
|
517
|
+
would otherwise re-send another device's event as if this one had authored it.
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
### 13c. Client Identity Is Persisted
|
|
522
|
+
|
|
523
|
+
**Decision:** `clientId` defaults to a generated id written into the `syncmeta`
|
|
524
|
+
row on first run and reused on every subsequent one, rather than a fresh value
|
|
525
|
+
per process.
|
|
526
|
+
|
|
527
|
+
**Why:** `isLocalOrigin` decides whether a pulled event is this device's own echo
|
|
528
|
+
by checking `outbox.has(eventId) || event.clientId === clientId`. The `clientId`
|
|
529
|
+
half exists precisely so that origin detection keeps working after the outbox has
|
|
530
|
+
been pruned — but a per-process id makes that half useless the moment the page
|
|
531
|
+
reloads. A client that had pruned its outbox and then rewound its cursor (via
|
|
532
|
+
`pullOverlap`, or via a `resetCursor` backend mismatch) would re-apply its *own*
|
|
533
|
+
history as if it were remote. Replaying an old insert is harmless; replaying an
|
|
534
|
+
old delete silently destroys whatever now lives at that key.
|
|
535
|
+
|
|
536
|
+
**Tradeoff:** the identity is scoped to the database file, so copying a SQLite
|
|
537
|
+
file to a second device gives both the same identity and each will treat the
|
|
538
|
+
other's events as its own echoes. Pass an explicit `clientId` if you clone
|
|
539
|
+
databases.
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
### 14. Pushes Are Batched, With Progress Persisted Per Batch
|
|
544
|
+
|
|
545
|
+
**Decision:** The outbox is sent in chunks of `pushBatchSize` (default 100), and
|
|
546
|
+
each batch's confirmations are persisted before the next request goes out.
|
|
547
|
+
|
|
548
|
+
**Why:** a device offline for a week comes back with thousands of pending
|
|
549
|
+
events. One unbounded POST either times out or exceeds a body limit, and on
|
|
550
|
+
failure *nothing* is durable — the next attempt does exactly the same thing and
|
|
551
|
+
fails the same way. Batching makes the work resumable.
|
|
552
|
+
|
|
553
|
+
A transport-level failure stops the loop and is reported alongside the counts
|
|
554
|
+
from batches that already succeeded, rather than thrown, so partial progress is
|
|
555
|
+
visible to the caller.
|
|
556
|
+
|
|
557
|
+
**Batches never split a `txId`.** The server contract asks for each transaction
|
|
558
|
+
to commit atomically, which is impossible if half its events arrive in a later
|
|
559
|
+
request — and if that later request never succeeds, the server has permanently
|
|
560
|
+
applied a partial transaction. Batching therefore packs whole transaction groups
|
|
561
|
+
and lets a batch exceed `pushBatchSize` rather than split one.
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
### 15. Backend Identity Guards Against a Reset Server
|
|
566
|
+
|
|
567
|
+
**Decision:** The pull response may carry a `backendId`. The client stores the
|
|
568
|
+
first one it sees and compares on every subsequent sync. On mismatch the default
|
|
569
|
+
policy `resetCursor` clears the inbox, re-pulls from zero, *and* marks every
|
|
570
|
+
retained outbox event pending again so local history is re-uploaded.
|
|
571
|
+
|
|
572
|
+
**Why:** the cursor is just an integer. Wiping or swapping the server restarts
|
|
573
|
+
`global_seq` at 1 while the client keeps asking for events after 500. The server
|
|
574
|
+
truthfully returns nothing, forever, with no error anywhere. Before this, that
|
|
575
|
+
failure mode was undetectable from the client.
|
|
576
|
+
|
|
577
|
+
Re-pulling from zero is safe because replay is an idempotent upsert.
|
|
578
|
+
|
|
579
|
+
Requeuing the outbox matters just as much as resetting the cursor. Rows the
|
|
580
|
+
client already flipped to `sync: true` describe data the replacement backend has
|
|
581
|
+
never seen; without the requeue the client keeps them locally, believes they are
|
|
582
|
+
safely synced, and never uploads them again — so a restored-from-backup server
|
|
583
|
+
quietly loses every write made since the backup. Push is idempotent by `eventId`,
|
|
584
|
+
so re-uploading to a *restored* backend is a no-op rather than a duplicate.
|
|
585
|
+
|
|
586
|
+
The reset is only discovered during pull, after this sync's push has run, so the
|
|
587
|
+
requeued events would otherwise sit unsent until the next sync. `pushPull` runs a
|
|
588
|
+
second push pass when a reset requeued anything, making one `sync()` enough to
|
|
589
|
+
recover. `baseVersion` is cleared on requeue because it names event ids from the
|
|
590
|
+
old backend's history.
|
|
591
|
+
|
|
592
|
+
**Tradeoff:** events already removed by `pruneSyncedEvents` cannot be re-uploaded
|
|
593
|
+
— pruning trades recoverability for space, and this is where that bill arrives.
|
|
594
|
+
Servers that do not send `backendId` keep the old behaviour: the check is skipped
|
|
595
|
+
entirely rather than guessed at.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
### 16. Events Carry a Schema Version
|
|
600
|
+
|
|
601
|
+
**Decision:** Every authored event is stamped with `eventSchemaVersion`. On
|
|
602
|
+
replay, an event whose version differs is passed through the optional
|
|
603
|
+
`upcastEvent` hook.
|
|
604
|
+
|
|
605
|
+
**Why:** payload shapes change. A device that was offline across a deploy
|
|
606
|
+
replays events written by the old shape into code expecting the new one. Without
|
|
607
|
+
a version there is nothing to branch on and the mismatch surfaces as corrupt
|
|
608
|
+
data rather than an error.
|
|
609
|
+
|
|
610
|
+
**Asymmetric default:** with no upcaster, *older* events are applied as-is with
|
|
611
|
+
a warning (usually additive changes, usually fine), but *newer* events are
|
|
612
|
+
refused via `unknownEventHandling`. This build cannot know what a future field
|
|
613
|
+
means, and guessing is worse than skipping.
|
|
614
|
+
|
|
615
|
+
**Upgrade note:** outbox rows written before `schemaVersion` / `baseVersion`
|
|
616
|
+
existed can still sit in SQLite. `toOutboundEvent` and dead-letter construction
|
|
617
|
+
default `schemaVersion` to `1` and `baseVersion` to `null` so a push after
|
|
618
|
+
upgrade does not send `undefined` on the wire.
|
|
619
|
+
|
|
620
|
+
---
|
|
621
|
+
|
|
622
|
+
### 17. Leader Election Is Injected, Not Imported
|
|
623
|
+
|
|
624
|
+
**Decision:** `createEventSourcedDB` accepts an optional `lock` implementing
|
|
625
|
+
`tryRun(name, fn)`. The browser helper defaults to a Web Locks implementation;
|
|
626
|
+
the core imports nothing platform-specific.
|
|
627
|
+
|
|
628
|
+
**Why `tryRun` and not a queue:** if five tabs each queue a sync behind the
|
|
629
|
+
lock, you get five sequential syncs where one would do. `tryRun` returns
|
|
630
|
+
`{ acquired: false }` immediately when another context holds the lock, and the
|
|
631
|
+
caller gets `{ deferred: true }` back from `sync()`.
|
|
632
|
+
|
|
633
|
+
**Tradeoff:** this only elects a leader per sync call, not for the process
|
|
634
|
+
lifetime as LiveStore's shared worker does. Writes still happen in every tab —
|
|
635
|
+
it is TanStack DB's persistence layer, not this library, that coordinates those.
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
639
|
+
### 18. Lifecycle Hooks Observe, They Do Not Intercept
|
|
640
|
+
|
|
641
|
+
**Decision:** `hooks` exposes fire-and-forget callbacks (`onMutation`,
|
|
642
|
+
`onSyncStart`, `onDeadLetter`, and so on). A hook that throws is logged and
|
|
643
|
+
swallowed. Hooks cannot cancel, delay, or rewrite an event.
|
|
644
|
+
|
|
645
|
+
**Why:** the alternative — awaited, failable, mutating middleware — makes every
|
|
646
|
+
hook part of the sync loop's correctness. One slow analytics call would stall
|
|
647
|
+
sync; one thrown error would abort a push mid-batch. Observation is the common
|
|
648
|
+
need; the rare cases that genuinely want to transform data already have
|
|
649
|
+
`upcastEvent` (replay) and a custom transport (push/pull).
|
|
650
|
+
|
|
651
|
+
**Tradeoff:** you cannot veto a mutation from a hook. Validate before calling
|
|
652
|
+
`insert`/`update`/`delete` instead.
|
|
653
|
+
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
## Status and pruning
|
|
657
|
+
|
|
658
|
+
`getSyncStatus()` scans the outbox for pending/failed counts and reads
|
|
659
|
+
`syncmeta`. It is O(n) in outbox size and runs on every outbox / deadletter /
|
|
660
|
+
syncmeta change when subscribers exist — fine for modest backlogs, worth
|
|
661
|
+
caching if you keep tens of thousands of pending rows.
|
|
662
|
+
|
|
663
|
+
`pruneSyncedEvents` only deletes `sync: true` rows. It writes the cursor first
|
|
664
|
+
so pruning cannot rewind pull position. It does **not** prune `rowversions` or
|
|
665
|
+
`deadletter` (see open issues).
|
|
666
|
+
|
|
260
667
|
---
|
|
261
668
|
|
|
262
669
|
## Pros
|
|
@@ -276,11 +683,16 @@ Since there's one server (not distributed), `BIGSERIAL` is strictly superior.
|
|
|
276
683
|
|
|
277
684
|
- Requires a server (not P2P)
|
|
278
685
|
- No real-time push (polling or explicit sync)
|
|
279
|
-
-
|
|
280
|
-
-
|
|
686
|
+
- No rollback or rebase — conflicts are detected and parked, never merged (decision #12)
|
|
687
|
+
- Conflict detection is opt-in and costs an index write per mutation and per replay
|
|
688
|
+
- Dead-lettered events are lost unless your UI surfaces them, in both directions (decisions #13, #13b)
|
|
689
|
+
- Two devices editing the same row between syncs do not converge (see Open issues)
|
|
690
|
+
- Leader election is per sync call, not a long-lived leader owning all writes (decision #17)
|
|
691
|
+
- Log pruning is manual — call `pruneSyncedEvents()` yourself on a schedule
|
|
692
|
+
- Offline transport failures still consume retry budget (see Open issues)
|
|
281
693
|
- Cannot replay from event 0 to rebuild state (state is materialized separately)
|
|
282
694
|
- Update events store full modified object (not a minimal diff)
|
|
283
|
-
-
|
|
695
|
+
- Materialization is a plain upsert, so there are no custom materializers or derived read models
|
|
284
696
|
|
|
285
697
|
---
|
|
286
698
|
|
|
@@ -465,15 +877,137 @@ We build 300 lines. We get 17,000+ lines of battle-tested infrastructure for fre
|
|
|
465
877
|
|
|
466
878
|
---
|
|
467
879
|
|
|
880
|
+
## Open issues (ordered by production risk)
|
|
881
|
+
|
|
882
|
+
These are known gaps, not forgotten TODOs. They are listed so contributors do
|
|
883
|
+
not "fix" them without the surrounding context.
|
|
884
|
+
|
|
885
|
+
### 0. Concurrent edits to one row do not converge
|
|
886
|
+
|
|
887
|
+
Two devices that edit the same row between syncs end up disagreeing, and neither
|
|
888
|
+
notices. Say `a` and `b` both edit row `t1`; `a`'s event lands at `globalSeq` 2
|
|
889
|
+
and `b`'s at 3, so the server's answer is unambiguously `b`. `a` pulls both,
|
|
890
|
+
skips its own event 2, applies 3, and is correct. `b` pulls both, applies `a`'s
|
|
891
|
+
event 2, then reaches its *own* event 3 and skips it as local origin — so `b`
|
|
892
|
+
finishes holding `a`'s value while everyone else holds `b`'s.
|
|
893
|
+
|
|
894
|
+
The origin skip is not incidental: it is what stops a client re-applying its own
|
|
895
|
+
pruned history (decision #13c). Dropping it would trade this bug for a worse one.
|
|
896
|
+
|
|
897
|
+
**Direction:** replace the origin check with a per-row applied-sequence
|
|
898
|
+
watermark. Record the highest `globalSeq` applied to each `(collectionId, key)`,
|
|
899
|
+
updating it both on replay and on push confirmation, and apply an event only when
|
|
900
|
+
its `globalSeq` exceeds it. That makes replay order-independent and idempotent,
|
|
901
|
+
fixes this case, and subsumes the origin check. The cost is a watermark write per
|
|
902
|
+
mutation and per replay — which is what `rowversions` already does under
|
|
903
|
+
`conflictDetection`, currently opt-in precisely because of that cost. Turning it
|
|
904
|
+
on unconditionally is the decision this needs.
|
|
905
|
+
|
|
906
|
+
**Meanwhile:** `conflictDetection: true` turns the silent divergence into a
|
|
907
|
+
visible `CONFLICT` dead-letter, which is the safer failure. Partitioning writes by
|
|
908
|
+
owner avoids it entirely. Pinned by the "does not yet converge when two devices
|
|
909
|
+
edit the same row concurrently" test.
|
|
910
|
+
|
|
911
|
+
### 1. Connectivity vs retry budget
|
|
912
|
+
|
|
913
|
+
Sync runs whether or not the device is online. Offline transport failures still
|
|
914
|
+
increment `attemptCount` and schedule backoff. A long offline window can exhaust
|
|
915
|
+
`maxAttempts` and dead-letter events the server never saw.
|
|
916
|
+
|
|
917
|
+
**Direction:** only charge the retry budget when a server response was received
|
|
918
|
+
(or gate push on an injectable `isOnline` / `navigator.onLine`).
|
|
919
|
+
|
|
920
|
+
### 2. Outbox durability vs state durability
|
|
921
|
+
|
|
922
|
+
`persistedCollectionOptions` wraps our mutation hook: it awaits the hook (the
|
|
923
|
+
outbox append) and *then* calls `persistAndConfirmCollectionMutations` to write
|
|
924
|
+
collection state. So the previously documented hazard — state persisting with no
|
|
925
|
+
event — does not occur: if the outbox append rejects, state is never written and
|
|
926
|
+
TanStack rolls the optimistic mutation back. That direction is pinned by the
|
|
927
|
+
"rolls the row back when the outbox append fails" test.
|
|
928
|
+
|
|
929
|
+
The remaining exposure is the mirror image. The outbox append has already
|
|
930
|
+
committed by the time the state write runs, so a state write that fails leaves an
|
|
931
|
+
event with no local row. The mutation rejects and the optimistic value is rolled
|
|
932
|
+
back, but the event survives, gets pushed, and materialises on every *other*
|
|
933
|
+
device. The authoring client then skips it on pull as its own echo and never
|
|
934
|
+
materialises it — the one device that originated the row is the only one without
|
|
935
|
+
it.
|
|
936
|
+
|
|
937
|
+
The two writes want to be one transaction. LiveStore co-commits eventlog and
|
|
938
|
+
state; the hook model cannot, since the two collections are persisted
|
|
939
|
+
independently. Closing it properly means either a shared transaction across the
|
|
940
|
+
outbox and the target collection, or reconciling the outbox against collection
|
|
941
|
+
state at startup.
|
|
942
|
+
|
|
943
|
+
### 3. `rowversions` growth
|
|
944
|
+
|
|
945
|
+
With `conflictDetection: true`, one index row per touched key forever, including
|
|
946
|
+
deletes. `pruneSyncedEvents` ignores it.
|
|
947
|
+
|
|
948
|
+
### 4. Byte-size batching
|
|
949
|
+
|
|
950
|
+
`pushBatchSize` counts events, not bytes. Large payloads (or one huge
|
|
951
|
+
transaction) can still blow body limits. LiveStore-style ~900KB caps would
|
|
952
|
+
complement tx-aware batching.
|
|
953
|
+
|
|
954
|
+
### 5. Per-call leader only
|
|
955
|
+
|
|
956
|
+
`localSeq` allocation has a TOCTOU window across tabs; `eventId` (uuidv7) breaks
|
|
957
|
+
ties so order stays deterministic. Cross-tab write leadership is still TanStack's
|
|
958
|
+
job, not ours.
|
|
959
|
+
|
|
960
|
+
### 6. No `AbortSignal`
|
|
961
|
+
|
|
962
|
+
A sync cannot be cancelled. bfcache / background tabs can hold the Web Lock
|
|
963
|
+
until the browser times it out.
|
|
964
|
+
|
|
965
|
+
### 7. Unbounded pull pages
|
|
966
|
+
|
|
967
|
+
Non-advancing cursors stop the loop; a server that always advances with
|
|
968
|
+
`hasMore: true` does not. A `maxPullPages` safety valve is missing.
|
|
969
|
+
|
|
970
|
+
### 8. Status scan cost
|
|
971
|
+
|
|
972
|
+
Full outbox scan on every mutation when a sync-status subscriber is attached.
|
|
973
|
+
|
|
974
|
+
### Scope limits (not bugs)
|
|
975
|
+
|
|
976
|
+
No rebase, no realtime subscribe, manual pruning only, plain upsert
|
|
977
|
+
materialization, full-object updates (not diffs), no cold-start rebuild from
|
|
978
|
+
event 0.
|
|
979
|
+
|
|
980
|
+
### Example server notes
|
|
981
|
+
|
|
982
|
+
The demo API does per-event conflict/dedup queries (fine for demos) and has no
|
|
983
|
+
auth or user scoping — do not treat it as a production sync backend.
|
|
984
|
+
|
|
985
|
+
---
|
|
986
|
+
|
|
987
|
+
## Testing notes
|
|
988
|
+
|
|
989
|
+
- Prefer `createMockSyncBackend()` over hand-rolled transports: it assigns
|
|
990
|
+
`globalSeq`, dedupes by `eventId`, paginates, and can inject outages /
|
|
991
|
+
rejections / backend resets.
|
|
992
|
+
- Assert `pushBatchSizes` when testing batching; assert deadletter reasons for
|
|
993
|
+
conflict vs rejection vs max attempts.
|
|
994
|
+
- Hook tests should prove a throwing hook does **not** fail insert/sync.
|
|
995
|
+
- Repo vitest via `vite-plus` may lack native bindings in some environments;
|
|
996
|
+
a plain vitest config with `unstubGlobals: true` is enough for this package's
|
|
997
|
+
unit tests.
|
|
998
|
+
|
|
999
|
+
---
|
|
1000
|
+
|
|
468
1001
|
## Future Directions
|
|
469
1002
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1003
|
+
Highest-impact production gaps are detailed under [Open issues](#open-issues-ordered-by-production-risk). Product directions after those:
|
|
1004
|
+
|
|
1005
|
+
1. **Real-time subscribe** — SSE/WebSocket for instant server push, replacing polling
|
|
1006
|
+
2. **Automatic rebase** — roll back divergent local events, apply upstream, re-apply on top. Events are invertible (`previous` is recorded), so the raw material exists
|
|
1007
|
+
3. **Long-lived leader** — a shared worker owning all writes, rather than per-call election
|
|
1008
|
+
4. **Automatic pruning** — a retention policy rather than a manual `pruneSyncedEvents()` call
|
|
1009
|
+
5. **Selective sync** — sync only specific collections or subsets
|
|
1010
|
+
6. **Undo/redo** — leverage the invertible event log for time-travel
|
|
476
1011
|
7. **Compression** — delta encoding for update events (store diff, not full object)
|
|
477
|
-
8. **
|
|
478
|
-
9. **
|
|
479
|
-
10. **Rebuild from events** — optional cold-start replay for disaster recovery
|
|
1012
|
+
8. **Multi-user awareness** — optional user_id scoping on the event table
|
|
1013
|
+
9. **Rebuild from events** — optional cold-start replay for disaster recovery
|