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.
Files changed (36) hide show
  1. package/ARCHITECTURE.md +571 -37
  2. package/README.md +589 -37
  3. package/dist/browser.d.mts +16 -1
  4. package/dist/browser.d.mts.map +1 -1
  5. package/dist/browser.mjs +15 -2
  6. package/dist/browser.mjs.map +1 -1
  7. package/dist/index.d.mts +62 -3
  8. package/dist/index.d.mts.map +1 -1
  9. package/dist/index.mjs +129 -2
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/lazy-singleton-BuBmSQb3.mjs +1627 -0
  12. package/dist/lazy-singleton-BuBmSQb3.mjs.map +1 -0
  13. package/dist/react-native.d.mts +13 -1
  14. package/dist/react-native.d.mts.map +1 -1
  15. package/dist/react-native.mjs +14 -2
  16. package/dist/react-native.mjs.map +1 -1
  17. package/dist/react.d.mts +69 -0
  18. package/dist/react.d.mts.map +1 -0
  19. package/dist/react.mjs +88 -0
  20. package/dist/react.mjs.map +1 -0
  21. package/dist/types-CIzhepuW.d.mts +436 -0
  22. package/dist/types-CIzhepuW.d.mts.map +1 -0
  23. package/dist/web-locks-C4279VL2.mjs +40 -0
  24. package/dist/web-locks-C4279VL2.mjs.map +1 -0
  25. package/examples/postgres-sync-server/README.md +50 -0
  26. package/examples/postgres-sync-server/handlers.ts +289 -0
  27. package/examples/postgres-sync-server/schema.sql +30 -0
  28. package/examples/web-worker-sync/README.md +17 -0
  29. package/examples/web-worker-sync/collections.snippet.ts +57 -0
  30. package/examples/web-worker-sync/create-worker-sync-transport.ts +82 -0
  31. package/examples/web-worker-sync/sync.worker.ts +66 -0
  32. package/package.json +17 -2
  33. package/dist/lazy-singleton-BG02GHLj.mjs +0 -664
  34. package/dist/lazy-singleton-BG02GHLj.mjs.map +0 -1
  35. package/dist/types-BX8dtGOu.d.mts +0 -162
  36. 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
- onInsert/onUpdate/onDelete acceptMutations
34
- hooks append to outbox replays inbox into
35
- │ │ state collections
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
- Pull cursor = max(synced globalSeq) across inbox (derived)
48
+ syncmeta: { pullCursor, backendId, lastSyncAt, lastError }
49
+ │ rowversions: last event per row (only when conflictDetection is on) │
43
50
  └──────────────────────────────────────────────────────────────────────┘
44
- │ push (outbox.sync = false) │ pull (since derived cursor)
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
- GET /api/events?since=N returns events after N, sorted ASC
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 | Derived from `max(synced globalSeq)` across inbox |
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 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.
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
- **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.
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 both are persisted collections, durability and multi-tab coordination are inherited from TanStack DB's persistence layer rather than implemented here.
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 | 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.
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:** 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.
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
- - Event log grows unbounded (need pruning strategy for very long-lived apps)
280
- - No automatic conflict resolution beyond "server decides order"
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
- - Pull cursor is derived by scanning synced inbox state rather than stored as a single authoritative value
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
- 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
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. **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
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