@lunora/replica 1.0.0-alpha.8 → 1.0.0-alpha.81

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 (58) hide show
  1. package/LICENSE.md +214 -0
  2. package/README.md +55 -10
  3. package/dist/adapters/better-sqlite3.d.mts +1 -1
  4. package/dist/adapters/better-sqlite3.d.ts +1 -1
  5. package/dist/adapters/better-sqlite3.mjs +1 -29
  6. package/dist/adapters/sqlite-wasm.d.mts +3 -9
  7. package/dist/adapters/sqlite-wasm.d.ts +3 -9
  8. package/dist/adapters/sqlite-wasm.mjs +1 -44
  9. package/dist/adapters/sqljs.d.mts +1 -1
  10. package/dist/adapters/sqljs.d.ts +1 -1
  11. package/dist/adapters/sqljs.mjs +1 -55
  12. package/dist/index.d.mts +156 -57
  13. package/dist/index.d.ts +156 -57
  14. package/dist/index.mjs +1 -20
  15. package/dist/packem_shared/EventEmitter-uo75adUL.mjs +1 -0
  16. package/dist/packem_shared/EventLog-B1-yhArT.mjs +1 -0
  17. package/dist/packem_shared/EventLogDO-BgUx2GGL.mjs +1 -0
  18. package/dist/packem_shared/EventLogDOClient-DWerZ3_n.mjs +1 -0
  19. package/dist/packem_shared/EventSource-BC0hKJSA.mjs +1 -0
  20. package/dist/packem_shared/EventsSync-B3wzXm-b.mjs +1 -0
  21. package/dist/packem_shared/InMemorySnapshotStore-C4taIG5K.mjs +1 -0
  22. package/dist/packem_shared/LocalMirror-DP6flo3R.mjs +4 -0
  23. package/dist/packem_shared/MaterializerRuntime-DdszLF-h.mjs +1 -0
  24. package/dist/packem_shared/SubscriptionManager-AhPw3lFc.mjs +1 -0
  25. package/dist/packem_shared/applyDiff-BaUgblCl.mjs +1 -0
  26. package/dist/packem_shared/applyDiffToDb-DiSnjR45.mjs +1 -0
  27. package/dist/packem_shared/classifyChanges-BBc0-770.mjs +1 -0
  28. package/dist/packem_shared/defineEvents-DHo-VK7G.mjs +1 -0
  29. package/dist/packem_shared/eventsContext-Dxow9Y7S.mjs +1 -0
  30. package/dist/packem_shared/fnv1a-BNN96GYb.mjs +1 -0
  31. package/dist/packem_shared/isClientSeq-D2Xm0_lj.mjs +1 -0
  32. package/dist/packem_shared/{local-mirror.d-DTavX_y0.d.mts → local-mirror.d-CHCqFAg9.d.mts} +31 -14
  33. package/dist/packem_shared/{local-mirror.d-CyGOpUES.d.ts → local-mirror.d-CvNV3_gk.d.ts} +31 -14
  34. package/dist/packem_shared/subscribeToMirror-Cv24WiWj.mjs +1 -0
  35. package/dist/packem_shared/{types.d-CkMkSwLJ.d.mts → types.d-BuLTPLaQ.d.mts} +0 -2
  36. package/dist/packem_shared/{types.d-CkMkSwLJ.d.ts → types.d-BuLTPLaQ.d.ts} +0 -2
  37. package/dist/packem_shared/wire-key-CU8KEXPo.mjs +1 -0
  38. package/dist/react.d.mts +39 -29
  39. package/dist/react.d.ts +39 -29
  40. package/dist/react.mjs +1 -15
  41. package/package.json +1 -1
  42. package/dist/packem_shared/EventEmitter-CMZfct03.mjs +0 -92
  43. package/dist/packem_shared/EventLog-CnK-3Wge.mjs +0 -264
  44. package/dist/packem_shared/EventLogDO-DqlsVx0H.mjs +0 -381
  45. package/dist/packem_shared/EventLogDOClient-F4FO8Si4.mjs +0 -92
  46. package/dist/packem_shared/EventSource-D5yO9_aI.mjs +0 -221
  47. package/dist/packem_shared/EventsSync-BP36tC9O.mjs +0 -123
  48. package/dist/packem_shared/InMemorySnapshotStore-BHVAD-Bp.mjs +0 -24
  49. package/dist/packem_shared/LocalMirror-a-5jEqFN.mjs +0 -219
  50. package/dist/packem_shared/MaterializerRuntime-BoIrsMYB.mjs +0 -224
  51. package/dist/packem_shared/SubscriptionManager-C5xbw0pg.mjs +0 -75
  52. package/dist/packem_shared/applyDiff-98tKzmiW.mjs +0 -67
  53. package/dist/packem_shared/applyDiffToDb-DQ1xZp5J.mjs +0 -58
  54. package/dist/packem_shared/classifyChanges-RcqLBpLs.mjs +0 -41
  55. package/dist/packem_shared/defineEvents-DiBkPTh_.mjs +0 -28
  56. package/dist/packem_shared/eventsContext-Bk_p48hj.mjs +0 -6
  57. package/dist/packem_shared/isClientSeq-C46BkzqJ.mjs +0 -5
  58. package/dist/packem_shared/subscribeToMirror-CiaM-nQ7.mjs +0 -45
package/dist/index.d.mts CHANGED
@@ -1,9 +1,24 @@
1
- export { createBetterSqlite3Adapter } from "./adapters/better-sqlite3.mjs";
1
+ export {
2
+ /**
3
+ * \@lunora/replica — Event sourcing runtime + local SQLite mirror for Lunora
4
+ *
5
+ * ## Modules
6
+ *
7
+ * - **EventEmitter** — type-safe event emitter with typed + wildcard listeners.
8
+ * - **EventSource** — event-sourcing runtime that derives state from an
9
+ * append-only log via a reducer.
10
+ * - **SubscriptionManager** — manages state and event-type subscriptions.
11
+ * - **SnapshotStore** — interface + in-memory store for persisting
12
+ * event-sourced state snapshots.
13
+ * - **LocalMirror** — local SQLite mirror that applies typed table diffs.
14
+ * - **EventLog** — append-only log for events and catch-up replication.
15
+ */
16
+ createBetterSqlite3Adapter } from "./adapters/better-sqlite3.mjs";
2
17
  export { createSqliteWasmAdapter } from "./adapters/sqlite-wasm.mjs";
3
18
  export { createSqlJsAdapter } from "./adapters/sqljs.mjs";
4
- import { S as SqliteAdapter } from "./packem_shared/types.d-CkMkSwLJ.mjs";
5
- import { T as TableDiff, I as InputEvent, S as Seq, E as EventLogEntry, a as EventLog, A as AppendOptions, L as LocalMirror } from "./packem_shared/local-mirror.d-DTavX_y0.mjs";
6
- export { type C as ClientSeq, type b as EventLogOptions, type c as EventLogSnapshot, type G as GlobalSeq, type d as LocalMirrorOptions, type M as MirrorTableDef, type R as RowChange, e as classifyChanges, f as createTableDiff, g as diffSize, i as isClientSeq, h as isDiffEmpty, j as isGlobalSeq, k as isInputEvent, m as mergeDiffs } from "./packem_shared/local-mirror.d-DTavX_y0.mjs";
19
+ import { S as SqliteAdapter } from "./packem_shared/types.d-BuLTPLaQ.mjs";
20
+ import { T as TableDiff, I as InputEvent, S as Seq, E as EventLogEntry, a as EventLog, A as AppendOptions, L as LocalMirror } from "./packem_shared/local-mirror.d-CHCqFAg9.mjs";
21
+ export { type C as ClientSeq, type b as EventLogOptions, type c as EventLogSnapshot, type G as GlobalSeq, type d as LocalMirrorOptions, type M as MirrorTableDef, type R as RowChange, e as classifyChanges, f as createTableDiff, g as diffSize, i as isClientSeq, h as isDiffEmpty, j as isGlobalSeq, k as isInputEvent, m as mergeDiffs } from "./packem_shared/local-mirror.d-CHCqFAg9.mjs";
7
22
  /**
8
23
  * Apply a single {@link TableDiff} to an in-memory row map and return
9
24
  * the updated map.
@@ -30,15 +45,16 @@ declare const applyDiff: (current: ReadonlyMap<string, Record<string, unknown>>,
30
45
  /**
31
46
  * Apply an array of diffs **in order**, returning the final row map.
32
47
  *
33
- * This is equivalent to calling {@link applyDiff} repeatedly but avoids
34
- * intermediate map copies.
48
+ * This is equivalent to calling {@link applyDiff} repeatedly but copies the
49
+ * input map exactly once rather than once per diff — catch-up replay of an
50
+ * N-diff backlog is a single copy, not N+1.
35
51
  * @experimental
36
52
  */
37
53
  declare const applyDiffs: (current: ReadonlyMap<string, Record<string, unknown>>, diffs: ReadonlyArray<TableDiff>) => Map<string, Record<string, unknown>>;
38
54
  /**
39
55
  * Merge the row-level effect of a {@link TableDiff} into plain JSON
40
56
  * state keyed by table name, returning a new snapshot.
41
- * @param snapshot Current snapshot, e.g. `{ users: Map&lt;id, row>, posts: Map&lt;id, row> }`.
57
+ * @param snapshot Current snapshot, e.g. `{ users: Map<id, row>, posts: Map<id, row> }`.
42
58
  * @param diff Contains the target table name and the row-level changes to merge.
43
59
  * @returns A shallow copy of `snapshot` with `diff.table`'s map updated.
44
60
  * @experimental
@@ -105,7 +121,7 @@ interface DefineEventsOptions {
105
121
  /**
106
122
  * Optional version prefix for all event types.
107
123
  *
108
- * When set, every qualified event type is prefixed with `"v&lt;N>."`, enabling
124
+ * When set, every qualified event type is prefixed with `"v<N>."`, enabling
109
125
  * versioned event naming like `"v1.chat.messageSent"` or `"v2.chat.messageSent"`.
110
126
  * This allows materializers to evolve their handling logic based on the event
111
127
  * version without breaking backward compatibility.
@@ -178,19 +194,28 @@ declare class EventLogDOClient {
178
194
  batchId?: string;
179
195
  }): Promise<EventLogEntry[]>;
180
196
  /**
181
- * Fetch all entries with `seq >= sinceSeq`.
197
+ * Fetch ONE page of entries with `seq >= sinceSeq`.
182
198
  *
183
- * Pass `sinceSeq = 0` to fetch the entire log.
184
- */
185
- getSince(sinceSeq: number): Promise<EventLogEntry[]>;
186
- /**
187
- * Fetch a paginated range of entries.
188
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
189
- * page exists (i.e. the DO returned `limit + 1` rows).
199
+ * The DO bounds every page (500 entries unless `limit` says otherwise, 1000
200
+ * max), so `getSince(0)` is the START of the log, never all of it — a
201
+ * catch-up walks pages until `truncated` is `false`:
202
+ *
203
+ * ```ts
204
+ * let seq = 0;
205
+ * for (;;) {
206
+ * const page = await client.getSince(seq);
207
+ * apply(page.entries);
208
+ * if (!page.truncated || page.cursor === undefined) break;
209
+ * seq = page.cursor;
210
+ * }
211
+ * ```
212
+ * @returns `{ entries, truncated, cursor }` — `cursor` is the `sinceSeq`
213
+ * for the next page and is present exactly when `truncated` is `true`.
190
214
  */
191
- getRange(fromSeq: number, limit?: number): Promise<{
215
+ getSince(sinceSeq: number, limit?: number): Promise<{
216
+ cursor?: number;
192
217
  entries: EventLogEntry[];
193
- hasMore: boolean;
218
+ truncated: boolean;
194
219
  }>;
195
220
  /**
196
221
  * Return the total number of entries currently in the log.
@@ -198,6 +223,12 @@ declare class EventLogDOClient {
198
223
  getSize(): Promise<number>;
199
224
  /**
200
225
  * Return the full log state — all entries plus the next seq number.
226
+ *
227
+ * Only for a log small enough to answer as one body: the DO refuses with a
228
+ * 413 past its page ceiling, since serialising an unbounded log into one
229
+ * response is what {@link EventLogDOClient.getSince} was bounded to avoid.
230
+ * A catch-up walks `getSince` instead.
231
+ * @throws Error when the log is too large to return in one body
201
232
  */
202
233
  getState(): Promise<{
203
234
  entries: EventLogEntry[];
@@ -271,8 +302,8 @@ type UnknownEventHandling = "warn" | "ignore" | "fail" | ((entry: EventLogEntry)
271
302
  * Events emitted by the {@link EventSource} runtime.
272
303
  *
273
304
  * A `type` (not `interface`) so it satisfies `EventEmitter`'s
274
- * `Record&lt;string, unknown>` constraint — interfaces have no implicit index
275
- * signature and aren't assignable to `Record&lt;string, unknown>`.
305
+ * `Record<string, unknown>` constraint — interfaces have no implicit index
306
+ * signature and aren't assignable to `Record<string, unknown>`.
276
307
  * @experimental
277
308
  */
278
309
  type EventSourceEvents = {
@@ -408,8 +439,10 @@ declare class EventSource<S extends Record<string, unknown> = Record<string, unk
408
439
  * starting from the events currently in the log and continuing with
409
440
  * every future `applyEvent` / `replayFromLog` call.
410
441
  *
411
- * The generator runs indefinitely — it never returns. Callers should
412
- * break out of the `for await` loop or use an `AbortSignal` to stop.
442
+ * The generator runs indefinitely unless given a `signal` — callers
443
+ * should break out of the `for await` loop or pass an `AbortSignal` to
444
+ * stop it (an abort settles the generator, `done: true`, on its next
445
+ * iteration step; it does not throw).
413
446
  * @example
414
447
  * ```ts
415
448
  * for await (const entry of source.events()) {
@@ -460,9 +493,16 @@ declare class InMemorySnapshotStore implements SnapshotStore {
460
493
  *
461
494
  * Pure functions are strongly encouraged: given the same event and state,
462
495
  * they must produce the same next state for deterministic replay.
496
+ *
497
+ * Return {@link UNHANDLED} for an event `type` the reducer does not recognise —
498
+ * that, and only that, is what {@link MaterializerRuntimeOptions.unknownEventHandling}
499
+ * reacts to. Returning the current `state` is a legitimate, idempotent no-op for
500
+ * a type the reducer DOES handle; reference equality cannot tell the two apart
501
+ * (REPLICA-07), and reading it as "unhandled" warned about — or, under `"fail"`,
502
+ * threw on — an event type the reducer explicitly recognised.
463
503
  * @experimental
464
504
  */
465
- type MaterializerReducer<S> = (state: S, entry: EventLogEntry) => S;
505
+ type MaterializerReducer<S> = (state: S, entry: EventLogEntry) => S | typeof UNHANDLED;
466
506
  /**
467
507
  * Options for defining a single materializer.
468
508
  * @experimental
@@ -471,7 +511,8 @@ interface MaterializerDef<S> {
471
511
  /**
472
512
  * Reducer invoked for every event in the log.
473
513
  *
474
- * Return the current state unchanged to skip the event.
514
+ * Return the current state unchanged for a recognised event with nothing to
515
+ * do; return {@link UNHANDLED} for a `type` this reducer does not process.
475
516
  */
476
517
  handle: MaterializerReducer<S>;
477
518
  /** Factory for the initial (empty) state. */
@@ -484,8 +525,12 @@ interface MaterializerDef<S> {
484
525
  * @experimental
485
526
  */
486
527
  interface Materializer<S> {
487
- /** Apply a single event entry through the reducer. */
488
- apply: (entry: EventLogEntry) => void;
528
+ /**
529
+ * Apply a single event entry through the reducer.
530
+ * @returns `false` when the reducer returned {@link UNHANDLED} (state left
531
+ * untouched), `true` otherwise.
532
+ */
533
+ apply: (entry: EventLogEntry) => boolean;
489
534
  readonly def: MaterializerDef<S>;
490
535
  /** Reset to the initial state. */
491
536
  reset: () => void;
@@ -506,9 +551,9 @@ declare const defineMaterializer: <S>(definition: MaterializerDef<S>) => Materia
506
551
  * A materializer of any state shape. The {@link MaterializerRuntime} holds a
507
552
  * heterogeneous collection and only ever calls `apply(entry)` / `setState(...)`
508
553
  * (with cast values) / reads `def.name` — it never needs the concrete state
509
- * type. `Materializer&lt;unknown>` won't do: `setState(state: S)` makes
510
- * `Materializer&lt;S>` invariant in `S`, so `Materializer&lt;number>` isn't assignable
511
- * to `Materializer&lt;unknown>`. Erasing the type param is the idiomatic fix.
554
+ * type. `Materializer<unknown>` won't do: `setState(state: S)` makes
555
+ * `Materializer<S>` invariant in `S`, so `Materializer<number>` isn't assignable
556
+ * to `Materializer<unknown>`. Erasing the type param is the idiomatic fix.
512
557
  */
513
558
  type AnyMaterializer = Materializer<any>;
514
559
  /**
@@ -527,7 +572,21 @@ interface MaterializerRuntimeOptions {
527
572
  /** Optional snapshot store for persisting/recovering materialized state. */
528
573
  snapshotStore?: SnapshotStore;
529
574
  /**
530
- * How to handle events whose type no materializer handles.
575
+ * How to handle an event that every materializer explicitly DECLINED — one
576
+ * for which each reducer returned {@link UNHANDLED}.
577
+ *
578
+ * A reducer that instead falls through to `return state` for a type it does
579
+ * not recognise has, as far as the runtime can tell, handled the event: it
580
+ * changed nothing, but it did not decline. `"fail"` and `"warn"` are inert
581
+ * for such a reducer, and no option here can make them otherwise — write the
582
+ * reducer's default branch as `return UNHANDLED` if you want to hear about
583
+ * unknown types.
584
+ *
585
+ * A materializer whose own watermark is already past the entry does not run
586
+ * for it, and does not count as declining it: an entry that was already
587
+ * applied has already been classified, so a catch-up replaying it for a
588
+ * LAGGING materializer alone never re-reports it. Without that, `"fail"`
589
+ * aborted a catch-up on events a snapshot-recovered sibling had processed.
531
590
  * @default "warn"
532
591
  */
533
592
  unknownEventHandling?: UnknownEventHandling;
@@ -585,12 +644,24 @@ declare class MaterializerRuntime {
585
644
  *
586
645
  * 1. Recover materialized state from snapshots (if a snapshotStore is
587
646
  * configured).
588
- * 2. Fetch all entries since the MINIMUM per-materializer watermark from
647
+ * 2. Fetch entries since the MINIMUM per-materializer watermark from
589
648
  * the DO — not the maximum — so a materializer with no snapshot (or a
590
649
  * lower one) still receives every event it hasn't seen (REPLICA-04).
591
650
  * 3. Apply them through the materializers; `applyEntries` skips each
592
651
  * entry for any materializer already past it, so nothing is double-applied.
593
652
  *
653
+ * The DO answers one BOUNDED page per request, so step 2/3 walk pages until
654
+ * the log is exhausted — applying each page as it arrives, rather than
655
+ * holding the whole backlog in memory. Taking only the first page (and
656
+ * dropping `truncated`) would silently leave every materializer short of
657
+ * the log's head whenever the backlog exceeds a page.
658
+ *
659
+ * The walk is bounded by {@link MAX_CATCHUP_PAGES}: against a log written
660
+ * faster than it is read, "until the log is exhausted" never arrives and
661
+ * startup would never finish. Hitting the budget returns what was applied
662
+ * with every materializer's watermark advanced, so a later `initialize()`
663
+ * (or the ordinary append path) picks up exactly where this left off.
664
+ *
594
665
  * Call this once on startup / after the DO binding is available.
595
666
  * @returns The number of entries applied during catch-up.
596
667
  */
@@ -602,7 +673,8 @@ declare class MaterializerRuntime {
602
673
  * This is a convenience over calling `doClient.append(...)` +
603
674
  * `runtime.applyEntries(...)` yourself — it persists the event
604
675
  * **then** applies the returned entry (with its assigned seq).
605
- * @returns The persisted entry with its DO-assigned `seq`.
676
+ * @returns The persisted entry with its DO-assigned `seq` — always, whether
677
+ * or not the entry could be applied to the materializers (see below).
606
678
  */
607
679
  appendEvent(input: AppendEventInput): Promise<EventLogEntry>;
608
680
  /**
@@ -696,20 +768,15 @@ interface EventsFacade {
696
768
  type: string;
697
769
  }[]) => Promise<EventLogEntry[]>;
698
770
  /**
699
- * Fetch a paginated range of entries.
700
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
701
- * page exists.
771
+ * Fetch ONE bounded page of entries with `seq >= sinceSeq`.
772
+ * @returns `{ entries, truncated, cursor }` — pass `cursor` back as
773
+ * `sinceSeq` while `truncated` is `true` to walk the whole log.
702
774
  */
703
- getRange: (fromSeq: number, limit?: number) => Promise<{
775
+ getSince: (sinceSeq: number, limit?: number) => Promise<{
776
+ cursor?: number;
704
777
  entries: EventLogEntry[];
705
- hasMore: boolean;
778
+ truncated: boolean;
706
779
  }>;
707
- /**
708
- * Fetch all entries with `seq >= sinceSeq`.
709
- *
710
- * Pass `sinceSeq = 0` to fetch the entire log.
711
- */
712
- getSince: (sinceSeq: number) => Promise<EventLogEntry[]>;
713
780
  /** Return the total number of entries currently in the log. */
714
781
  getSize: () => Promise<number>;
715
782
  /** Return the full log state — all entries plus the next seq number. */
@@ -730,7 +797,7 @@ interface EventsContextOutput {
730
797
  * Create a middleware that attaches a typed `ctx.events` facade backed by
731
798
  * the given {@link EventLogDOClient}.
732
799
  *
733
- * The facade surfaces `append`, `getSince`, `getRange`, `getSize`, and
800
+ * The facade surfaces `append`, `getSince`, `getSize`, and
734
801
  * `getState` — every method the DO client exposes — so handlers can read
735
802
  * and write the event log without reaching for the DO stub directly.
736
803
  *
@@ -772,12 +839,22 @@ interface SubscriptionClient {
772
839
  * is applied to the local SQLite store.
773
840
  *
774
841
  * Each frame from a Lunora live query is the FULL current result set, so the
775
- * callback treats it as a snapshot: it upserts every row present and emits a
776
- * `delete` for any id that was mirrored on a previous frame but is absent now —
777
- * otherwise rows that drop out of the server result would linger stale in the
778
- * local mirror. Rows are keyed by their `id` field (the mirror's default primary
779
- * key); a row without an `id` can't be reconciled on removal, and — because the
780
- * mirror table's `id` column is `NOT NULL` — will fail the insert.
842
+ * callback diffs it against the previous frame: a row that is new or whose
843
+ * content changed is upserted, a row that dropped out is deleted, and an
844
+ * unchanged row produces nothing. A frame identical to the last one therefore
845
+ * applies no diff — no event-log entry, no `version` bump, no re-query for the
846
+ * hooks subscribed to the mirror.
847
+ *
848
+ * Rows are keyed by the table's primary key (`id` unless the table was
849
+ * registered with another `primaryKey`). A row without one still lands — the
850
+ * apply path derives a key from the ROW's own content — but it cannot be
851
+ * diffed: it is re-emitted on every frame, and it is never reconciled on
852
+ * removal because only keyed rows are recorded for the delete pass. Repeating an
853
+ * identical frame is therefore a no-op upsert, but each distinct content the
854
+ * un-keyed row ever holds leaves a row behind for the life of the mirror.
855
+ * **Mirror a query that selects the primary key.** An un-keyed shape (an
856
+ * aggregate, or a projection that drops `id`) is supported only so that one such
857
+ * row cannot take the rest of the frame down with it.
781
858
  *
782
859
  * The mirror table name is derived from the function ref alone (not `args`), so
783
860
  * do NOT mirror two subscriptions to the same function with different `args`
@@ -877,15 +954,28 @@ interface EventsSyncOptions {
877
954
  * consumer should feed these events into their state machine
878
955
  * (e.g. an {@link import("@lunora/replica").EventSource | EventSource})
879
956
  * so that the machine's state reflects the latest log position.
957
+ *
958
+ * **Must be atomic across the batch: apply every event, or none.** There is
959
+ * no rollback here and none is possible — the state machine is the
960
+ * consumer's. A call that mutates derived state and then throws partway is
961
+ * re-delivered WHOLE on the next poll (the watermark only advances past a
962
+ * batch that fully succeeded, and a replay that threw is not recorded), so a
963
+ * non-atomic implementation applies the events before the throw twice. A
964
+ * call that RETURNS is never re-delivered: {@link EventsSync} tracks the
965
+ * highest applied `seq` separately from the watermark, so a batch whose
966
+ * replay succeeded and whose mirror fan-out then failed is not replayed.
880
967
  */
881
968
  applyEvents: (events: ReadonlyArray<EventLogEntry>) => void;
882
969
  /**
883
- * Fetch all events whose `seq >= sinceSeq`.
970
+ * Fetch the next batch of events whose `seq >= sinceSeq`.
884
971
  *
885
- * In a server-side context, this typically wraps
886
- * {@link import("@lunora/replica").EventLogDOClient.getSince |
887
- * EventLogDOClient.getSince()}.
888
- * In a client context it could call a Lunora action that proxies to the
972
+ * It does NOT have to return the whole backlog: a bounded batch is
973
+ * preferred and is what the DO-backed transport gives you
974
+ * ({@link import("@lunora/replica").EventLogDOClient.getSince |
975
+ * EventLogDOClient.getSince()} answers one page). {@link EventsSync} keeps
976
+ * calling with the advanced watermark until a call returns nothing, so the
977
+ * whole log is applied either way — one bounded atom at a time.
978
+ * In a client context this could call a Lunora action that proxies to the
889
979
  * event log, or read from an IndexedDB cache.
890
980
  *
891
981
  * Return an empty array when there are no new events.
@@ -895,8 +985,17 @@ interface EventsSyncOptions {
895
985
  * Produce {@link TableDiff | TableDiffs} from the current derived state.
896
986
  *
897
987
  * Called after every batch of events has been applied. The consumer
898
- * compares the state _before_ and _after_ the batch and returns the
899
- * diffs needed to bring the LocalMirror up to date.
988
+ * **recomputes a full diff from the current mirror-vs-source state** and
989
+ * returns the diffs needed to bring the LocalMirror up to date.
990
+ *
991
+ * **MUST be idempotent** — it must NOT advance a one-shot cursor as a side
992
+ * effect. A batch that fails partway (a `mirror.applyDiff` throws) is
993
+ * retried on the next poll from the same watermark; if this call consumed a
994
+ * cursor on the first attempt it would return `[]` on the retry and the
995
+ * un-mirrored diffs would be lost forever. Recompute-from-current-state has
996
+ * no such hazard: calling it again with no new events returns the same
997
+ * diffs, and calling it after a partial mirror write returns exactly the
998
+ * diffs still missing from the mirror.
900
999
  *
901
1000
  * Return an empty array when there are no changes to push to the mirror.
902
1001
  */