@lunora/replica 1.0.0-alpha.12 → 1.0.0-alpha.121

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 (52) hide show
  1. package/README.md +55 -10
  2. package/dist/adapters/better-sqlite3.d.mts +2 -1
  3. package/dist/adapters/better-sqlite3.d.ts +2 -1
  4. package/dist/adapters/better-sqlite3.mjs +1 -1
  5. package/dist/adapters/sqlite-wasm.d.mts +3 -9
  6. package/dist/adapters/sqlite-wasm.d.ts +3 -9
  7. package/dist/adapters/sqlite-wasm.mjs +1 -1
  8. package/dist/adapters/sqljs.d.mts +4 -2
  9. package/dist/adapters/sqljs.d.ts +4 -2
  10. package/dist/adapters/sqljs.mjs +1 -1
  11. package/dist/index.d.mts +171 -67
  12. package/dist/index.d.ts +171 -67
  13. package/dist/index.mjs +1 -1
  14. package/dist/packem_shared/{EventEmitter-ovTsLeAj.mjs → EventEmitter-uo75adUL.mjs} +1 -1
  15. package/dist/packem_shared/EventLog-SuC_BKwj.mjs +1 -0
  16. package/dist/packem_shared/EventLogDO-BgUx2GGL.mjs +1 -0
  17. package/dist/packem_shared/EventLogDOClient-DWerZ3_n.mjs +1 -0
  18. package/dist/packem_shared/EventSource-Bg6zNmRn.mjs +1 -0
  19. package/dist/packem_shared/EventsSync-B3wzXm-b.mjs +1 -0
  20. package/dist/packem_shared/LocalMirror-Bn22hyEB.mjs +4 -0
  21. package/dist/packem_shared/MaterializerRuntime-DFXi-aqd.mjs +1 -0
  22. package/dist/packem_shared/SubscriptionManager-AhPw3lFc.mjs +1 -0
  23. package/dist/packem_shared/applyDiff-CZAqAC8Y.mjs +1 -0
  24. package/dist/packem_shared/applyDiffToDb-CK-Dcy17.mjs +1 -0
  25. package/dist/packem_shared/classifyChanges-BBc0-770.mjs +1 -0
  26. package/dist/packem_shared/defineEvents-DHo-VK7G.mjs +1 -0
  27. package/dist/packem_shared/fnv1a-BNN96GYb.mjs +1 -0
  28. package/dist/packem_shared/int64-CCVxepl4.mjs +1 -0
  29. package/dist/packem_shared/{isClientSeq-DSXBJskD.mjs → isClientSeq-D2Xm0_lj.mjs} +1 -1
  30. package/dist/packem_shared/{local-mirror.d-DTavX_y0.d.mts → local-mirror.d-DzREvGNM.d.mts} +54 -22
  31. package/dist/packem_shared/{local-mirror.d-CyGOpUES.d.ts → local-mirror.d-wbGXkXaE.d.ts} +54 -22
  32. package/dist/packem_shared/subscribeToMirror-DSn8n1IR.mjs +1 -0
  33. package/dist/packem_shared/{types.d-CkMkSwLJ.d.mts → types.d-BuLTPLaQ.d.mts} +0 -2
  34. package/dist/packem_shared/{types.d-CkMkSwLJ.d.ts → types.d-BuLTPLaQ.d.ts} +0 -2
  35. package/dist/packem_shared/wire-key-DfMHAtqH.mjs +1 -0
  36. package/dist/react.d.mts +39 -29
  37. package/dist/react.d.ts +39 -29
  38. package/dist/react.mjs +1 -1
  39. package/package.json +1 -1
  40. package/dist/packem_shared/EventLog-DmlRY_4Z.mjs +0 -1
  41. package/dist/packem_shared/EventLogDO-Cb-7iN9w.mjs +0 -1
  42. package/dist/packem_shared/EventLogDOClient-C6gA3b3u.mjs +0 -1
  43. package/dist/packem_shared/EventSource-B5UTlkl9.mjs +0 -1
  44. package/dist/packem_shared/EventsSync-pK_hg9KP.mjs +0 -1
  45. package/dist/packem_shared/LocalMirror-Q7cPH_rX.mjs +0 -4
  46. package/dist/packem_shared/MaterializerRuntime-B-I9jKDe.mjs +0 -1
  47. package/dist/packem_shared/SubscriptionManager-CbSjG_GA.mjs +0 -1
  48. package/dist/packem_shared/applyDiff-BUzddc6r.mjs +0 -1
  49. package/dist/packem_shared/applyDiffToDb-1M2I3BHi.mjs +0 -1
  50. package/dist/packem_shared/classifyChanges-BnOMcDGj.mjs +0 -1
  51. package/dist/packem_shared/defineEvents-CJZV8Bgi.mjs +0 -1
  52. package/dist/packem_shared/subscribeToMirror-Dru_AYBu.mjs +0 -1
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-DzREvGNM.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-DzREvGNM.mjs";
7
22
  /**
8
23
  * Apply a single {@link TableDiff} to an in-memory row map and return
9
24
  * the updated map.
@@ -39,7 +54,7 @@ declare const applyDiffs: (current: ReadonlyMap<string, Record<string, unknown>>
39
54
  /**
40
55
  * Merge the row-level effect of a {@link TableDiff} into plain JSON
41
56
  * state keyed by table name, returning a new snapshot.
42
- * @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> }`.
43
58
  * @param diff Contains the target table name and the row-level changes to merge.
44
59
  * @returns A shallow copy of `snapshot` with `diff.table`'s map updated.
45
60
  * @experimental
@@ -90,23 +105,12 @@ type EventsDefinition<TDefinition extends Record<string, Record<string, unknown>
90
105
  /** Type-level map of event type → payload shape. Useful for generic code. */
91
106
  readonly _types: EventTypeMap<TDefinition>;
92
107
  };
93
- /**
94
- * Declare typed event types for event sourcing.
95
- *
96
- * Each key under a namespace becomes a factory function that produces
97
- * an {@link InputEvent} — an optimistic / command event that the event
98
- * log will assign a sequence number to on append.
99
- * @param definition A nested object where the outer keys are namespaces
100
- * and the inner keys are event names mapped to their
101
- * payload schemas (or simple type-descriptor objects).
102
- * @returns An object with the same nesting structure, where each leaf is
103
- * a factory function plus a `.type` property.
104
- */
108
+ /** Options accepted by {@link defineEvents}. */
105
109
  interface DefineEventsOptions {
106
110
  /**
107
111
  * Optional version prefix for all event types.
108
112
  *
109
- * When set, every qualified event type is prefixed with `"v&lt;N>."`, enabling
113
+ * When set, every qualified event type is prefixed with `"v<N>."`, enabling
110
114
  * versioned event naming like `"v1.chat.messageSent"` or `"v2.chat.messageSent"`.
111
115
  * This allows materializers to evolve their handling logic based on the event
112
116
  * version without breaking backward compatibility.
@@ -115,7 +119,19 @@ interface DefineEventsOptions {
115
119
  readonly version?: string;
116
120
  }
117
121
  /**
122
+ * Declare typed event types for event sourcing.
123
+ *
124
+ * Each key under a namespace becomes a factory function that produces
125
+ * an {@link InputEvent} — an optimistic / command event that the event
126
+ * log will assign a sequence number to on append.
127
+ *
118
128
  * `defineEvents` is part of the experimental `@lunora/replica` API and may change without a major version bump.
129
+ * @param definition A nested object where the outer keys are namespaces
130
+ * and the inner keys are event names mapped to their
131
+ * payload schemas (or simple type-descriptor objects).
132
+ * @param options Optional {@link DefineEventsOptions} — currently a `version` prefix.
133
+ * @returns An object with the same nesting structure, where each leaf is
134
+ * a factory function plus a `.type` property.
119
135
  * @experimental
120
136
  */
121
137
  declare const defineEvents: <TDefinition extends Record<string, Record<string, unknown>>>(definition: TDefinition, options?: DefineEventsOptions) => EventsDefinition<TDefinition>;
@@ -179,19 +195,28 @@ declare class EventLogDOClient {
179
195
  batchId?: string;
180
196
  }): Promise<EventLogEntry[]>;
181
197
  /**
182
- * Fetch all entries with `seq >= sinceSeq`.
198
+ * Fetch ONE page of entries with `seq >= sinceSeq`.
183
199
  *
184
- * Pass `sinceSeq = 0` to fetch the entire log.
185
- */
186
- getSince(sinceSeq: number): Promise<EventLogEntry[]>;
187
- /**
188
- * Fetch a paginated range of entries.
189
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
190
- * page exists (i.e. the DO returned `limit + 1` rows).
200
+ * The DO bounds every page (500 entries unless `limit` says otherwise, 1000
201
+ * max), so `getSince(0)` is the START of the log, never all of it — a
202
+ * catch-up walks pages until `truncated` is `false`:
203
+ *
204
+ * ```ts
205
+ * let seq = 0;
206
+ * for (;;) {
207
+ * const page = await client.getSince(seq);
208
+ * apply(page.entries);
209
+ * if (!page.truncated || page.cursor === undefined) break;
210
+ * seq = page.cursor;
211
+ * }
212
+ * ```
213
+ * @returns `{ entries, truncated, cursor }` — `cursor` is the `sinceSeq`
214
+ * for the next page and is present exactly when `truncated` is `true`.
191
215
  */
192
- getRange(fromSeq: number, limit?: number): Promise<{
216
+ getSince(sinceSeq: number, limit?: number): Promise<{
217
+ cursor?: number;
193
218
  entries: EventLogEntry[];
194
- hasMore: boolean;
219
+ truncated: boolean;
195
220
  }>;
196
221
  /**
197
222
  * Return the total number of entries currently in the log.
@@ -199,6 +224,12 @@ declare class EventLogDOClient {
199
224
  getSize(): Promise<number>;
200
225
  /**
201
226
  * Return the full log state — all entries plus the next seq number.
227
+ *
228
+ * Only for a log small enough to answer as one body: the DO refuses with a
229
+ * 413 past its page ceiling, since serialising an unbounded log into one
230
+ * response is what {@link EventLogDOClient.getSince} was bounded to avoid.
231
+ * A catch-up walks `getSince` instead.
232
+ * @throws Error when the log is too large to return in one body
202
233
  */
203
234
  getState(): Promise<{
204
235
  entries: EventLogEntry[];
@@ -272,8 +303,8 @@ type UnknownEventHandling = "warn" | "ignore" | "fail" | ((entry: EventLogEntry)
272
303
  * Events emitted by the {@link EventSource} runtime.
273
304
  *
274
305
  * A `type` (not `interface`) so it satisfies `EventEmitter`'s
275
- * `Record&lt;string, unknown>` constraint — interfaces have no implicit index
276
- * signature and aren't assignable to `Record&lt;string, unknown>`.
306
+ * `Record<string, unknown>` constraint — interfaces have no implicit index
307
+ * signature and aren't assignable to `Record<string, unknown>`.
277
308
  * @experimental
278
309
  */
279
310
  type EventSourceEvents = {
@@ -409,8 +440,10 @@ declare class EventSource<S extends Record<string, unknown> = Record<string, unk
409
440
  * starting from the events currently in the log and continuing with
410
441
  * every future `applyEvent` / `replayFromLog` call.
411
442
  *
412
- * The generator runs indefinitely — it never returns. Callers should
413
- * break out of the `for await` loop or use an `AbortSignal` to stop.
443
+ * The generator runs indefinitely unless given a `signal` — callers
444
+ * should break out of the `for await` loop or pass an `AbortSignal` to
445
+ * stop it (an abort settles the generator, `done: true`, on its next
446
+ * iteration step; it does not throw).
414
447
  * @example
415
448
  * ```ts
416
449
  * for await (const entry of source.events()) {
@@ -461,9 +494,16 @@ declare class InMemorySnapshotStore implements SnapshotStore {
461
494
  *
462
495
  * Pure functions are strongly encouraged: given the same event and state,
463
496
  * they must produce the same next state for deterministic replay.
497
+ *
498
+ * Return {@link UNHANDLED} for an event `type` the reducer does not recognise —
499
+ * that, and only that, is what {@link MaterializerRuntimeOptions.unknownEventHandling}
500
+ * reacts to. Returning the current `state` is a legitimate, idempotent no-op for
501
+ * a type the reducer DOES handle; reference equality cannot tell the two apart
502
+ * (REPLICA-07), and reading it as "unhandled" warned about — or, under `"fail"`,
503
+ * threw on — an event type the reducer explicitly recognised.
464
504
  * @experimental
465
505
  */
466
- type MaterializerReducer<S> = (state: S, entry: EventLogEntry) => S;
506
+ type MaterializerReducer<S> = (state: S, entry: EventLogEntry) => S | typeof UNHANDLED;
467
507
  /**
468
508
  * Options for defining a single materializer.
469
509
  * @experimental
@@ -472,7 +512,8 @@ interface MaterializerDef<S> {
472
512
  /**
473
513
  * Reducer invoked for every event in the log.
474
514
  *
475
- * Return the current state unchanged to skip the event.
515
+ * Return the current state unchanged for a recognised event with nothing to
516
+ * do; return {@link UNHANDLED} for a `type` this reducer does not process.
476
517
  */
477
518
  handle: MaterializerReducer<S>;
478
519
  /** Factory for the initial (empty) state. */
@@ -485,8 +526,12 @@ interface MaterializerDef<S> {
485
526
  * @experimental
486
527
  */
487
528
  interface Materializer<S> {
488
- /** Apply a single event entry through the reducer. */
489
- apply: (entry: EventLogEntry) => void;
529
+ /**
530
+ * Apply a single event entry through the reducer.
531
+ * @returns `false` when the reducer returned {@link UNHANDLED} (state left
532
+ * untouched), `true` otherwise.
533
+ */
534
+ apply: (entry: EventLogEntry) => boolean;
490
535
  readonly def: MaterializerDef<S>;
491
536
  /** Reset to the initial state. */
492
537
  reset: () => void;
@@ -507,9 +552,9 @@ declare const defineMaterializer: <S>(definition: MaterializerDef<S>) => Materia
507
552
  * A materializer of any state shape. The {@link MaterializerRuntime} holds a
508
553
  * heterogeneous collection and only ever calls `apply(entry)` / `setState(...)`
509
554
  * (with cast values) / reads `def.name` — it never needs the concrete state
510
- * type. `Materializer&lt;unknown>` won't do: `setState(state: S)` makes
511
- * `Materializer&lt;S>` invariant in `S`, so `Materializer&lt;number>` isn't assignable
512
- * to `Materializer&lt;unknown>`. Erasing the type param is the idiomatic fix.
555
+ * type. `Materializer<unknown>` won't do: `setState(state: S)` makes
556
+ * `Materializer<S>` invariant in `S`, so `Materializer<number>` isn't assignable
557
+ * to `Materializer<unknown>`. Erasing the type param is the idiomatic fix.
513
558
  */
514
559
  type AnyMaterializer = Materializer<any>;
515
560
  /**
@@ -528,7 +573,21 @@ interface MaterializerRuntimeOptions {
528
573
  /** Optional snapshot store for persisting/recovering materialized state. */
529
574
  snapshotStore?: SnapshotStore;
530
575
  /**
531
- * How to handle events whose type no materializer handles.
576
+ * How to handle an event that every materializer explicitly DECLINED — one
577
+ * for which each reducer returned {@link UNHANDLED}.
578
+ *
579
+ * A reducer that instead falls through to `return state` for a type it does
580
+ * not recognise has, as far as the runtime can tell, handled the event: it
581
+ * changed nothing, but it did not decline. `"fail"` and `"warn"` are inert
582
+ * for such a reducer, and no option here can make them otherwise — write the
583
+ * reducer's default branch as `return UNHANDLED` if you want to hear about
584
+ * unknown types.
585
+ *
586
+ * A materializer whose own watermark is already past the entry does not run
587
+ * for it, and does not count as declining it: an entry that was already
588
+ * applied has already been classified, so a catch-up replaying it for a
589
+ * LAGGING materializer alone never re-reports it. Without that, `"fail"`
590
+ * aborted a catch-up on events a snapshot-recovered sibling had processed.
532
591
  * @default "warn"
533
592
  */
534
593
  unknownEventHandling?: UnknownEventHandling;
@@ -586,12 +645,24 @@ declare class MaterializerRuntime {
586
645
  *
587
646
  * 1. Recover materialized state from snapshots (if a snapshotStore is
588
647
  * configured).
589
- * 2. Fetch all entries since the MINIMUM per-materializer watermark from
648
+ * 2. Fetch entries since the MINIMUM per-materializer watermark from
590
649
  * the DO — not the maximum — so a materializer with no snapshot (or a
591
650
  * lower one) still receives every event it hasn't seen (REPLICA-04).
592
651
  * 3. Apply them through the materializers; `applyEntries` skips each
593
652
  * entry for any materializer already past it, so nothing is double-applied.
594
653
  *
654
+ * The DO answers one BOUNDED page per request, so step 2/3 walk pages until
655
+ * the log is exhausted — applying each page as it arrives, rather than
656
+ * holding the whole backlog in memory. Taking only the first page (and
657
+ * dropping `truncated`) would silently leave every materializer short of
658
+ * the log's head whenever the backlog exceeds a page.
659
+ *
660
+ * The walk is bounded by {@link MAX_CATCHUP_PAGES}: against a log written
661
+ * faster than it is read, "until the log is exhausted" never arrives and
662
+ * startup would never finish. Hitting the budget returns what was applied
663
+ * with every materializer's watermark advanced, so a later `initialize()`
664
+ * (or the ordinary append path) picks up exactly where this left off.
665
+ *
595
666
  * Call this once on startup / after the DO binding is available.
596
667
  * @returns The number of entries applied during catch-up.
597
668
  */
@@ -603,7 +674,8 @@ declare class MaterializerRuntime {
603
674
  * This is a convenience over calling `doClient.append(...)` +
604
675
  * `runtime.applyEntries(...)` yourself — it persists the event
605
676
  * **then** applies the returned entry (with its assigned seq).
606
- * @returns The persisted entry with its DO-assigned `seq`.
677
+ * @returns The persisted entry with its DO-assigned `seq` — always, whether
678
+ * or not the entry could be applied to the materializers (see below).
607
679
  */
608
680
  appendEvent(input: AppendEventInput): Promise<EventLogEntry>;
609
681
  /**
@@ -697,20 +769,15 @@ interface EventsFacade {
697
769
  type: string;
698
770
  }[]) => Promise<EventLogEntry[]>;
699
771
  /**
700
- * Fetch a paginated range of entries.
701
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
702
- * page exists.
772
+ * Fetch ONE bounded page of entries with `seq >= sinceSeq`.
773
+ * @returns `{ entries, truncated, cursor }` — pass `cursor` back as
774
+ * `sinceSeq` while `truncated` is `true` to walk the whole log.
703
775
  */
704
- getRange: (fromSeq: number, limit?: number) => Promise<{
776
+ getSince: (sinceSeq: number, limit?: number) => Promise<{
777
+ cursor?: number;
705
778
  entries: EventLogEntry[];
706
- hasMore: boolean;
779
+ truncated: boolean;
707
780
  }>;
708
- /**
709
- * Fetch all entries with `seq >= sinceSeq`.
710
- *
711
- * Pass `sinceSeq = 0` to fetch the entire log.
712
- */
713
- getSince: (sinceSeq: number) => Promise<EventLogEntry[]>;
714
781
  /** Return the total number of entries currently in the log. */
715
782
  getSize: () => Promise<number>;
716
783
  /** Return the full log state — all entries plus the next seq number. */
@@ -731,7 +798,7 @@ interface EventsContextOutput {
731
798
  * Create a middleware that attaches a typed `ctx.events` facade backed by
732
799
  * the given {@link EventLogDOClient}.
733
800
  *
734
- * The facade surfaces `append`, `getSince`, `getRange`, `getSize`, and
801
+ * The facade surfaces `append`, `getSince`, `getSize`, and
735
802
  * `getState` — every method the DO client exposes — so handlers can read
736
803
  * and write the event log without reaching for the DO stub directly.
737
804
  *
@@ -773,18 +840,33 @@ interface SubscriptionClient {
773
840
  * is applied to the local SQLite store.
774
841
  *
775
842
  * Each frame from a Lunora live query is the FULL current result set, so the
776
- * callback treats it as a snapshot: it upserts every row present and emits a
777
- * `delete` for any id that was mirrored on a previous frame but is absent now —
778
- * otherwise rows that drop out of the server result would linger stale in the
779
- * local mirror. Rows are keyed by their `id` field (the mirror's default primary
780
- * key); a row without an `id` can't be reconciled on removal, and — because the
781
- * mirror table's `id` column is `NOT NULL` — will fail the insert.
843
+ * callback diffs it against the previous frame: a row that is new or whose
844
+ * content changed is upserted, a row that dropped out is deleted, and an
845
+ * unchanged row produces nothing. A frame identical to the last one therefore
846
+ * applies no diff — no event-log entry, no `version` bump, no re-query for the
847
+ * hooks subscribed to the mirror.
848
+ *
849
+ * Rows are keyed by the table's primary key (`id` unless the table was
850
+ * registered with another `primaryKey`). A row without one still lands — the
851
+ * apply path derives a key from the ROW's own content — but it cannot be
852
+ * diffed: it is re-emitted on every frame, and it is never reconciled on
853
+ * removal because only keyed rows are recorded for the delete pass. Repeating an
854
+ * identical frame is therefore a no-op upsert, but each distinct content the
855
+ * un-keyed row ever holds leaves a row behind for the life of the mirror.
856
+ * **Mirror a query that selects the primary key.** An un-keyed shape (an
857
+ * aggregate, or a projection that drops `id`) is supported only so that one such
858
+ * row cannot take the rest of the frame down with it.
782
859
  *
783
860
  * The mirror table name is derived from the function ref alone (not `args`), so
784
861
  * do NOT mirror two subscriptions to the same function with different `args`
785
862
  * into the same mirror: they'd share one table and the snapshot-delete pass of
786
863
  * one could remove rows still live in the other.
787
864
  *
865
+ * `mirror.clearData()` resets the remembered frame. Without that reset the next
866
+ * identical frame diffed clean against a mirror that no longer held the rows —
867
+ * no changes, no `applyDiff` — and the table stayed empty until some row's
868
+ * content happened to change or the page reloaded.
869
+ *
788
870
  * Call the returned unsubscribe function to tear down both the client
789
871
  * subscription and future mirror writes.
790
872
  * @example
@@ -878,15 +960,28 @@ interface EventsSyncOptions {
878
960
  * consumer should feed these events into their state machine
879
961
  * (e.g. an {@link import("@lunora/replica").EventSource | EventSource})
880
962
  * so that the machine's state reflects the latest log position.
963
+ *
964
+ * **Must be atomic across the batch: apply every event, or none.** There is
965
+ * no rollback here and none is possible — the state machine is the
966
+ * consumer's. A call that mutates derived state and then throws partway is
967
+ * re-delivered WHOLE on the next poll (the watermark only advances past a
968
+ * batch that fully succeeded, and a replay that threw is not recorded), so a
969
+ * non-atomic implementation applies the events before the throw twice. A
970
+ * call that RETURNS is never re-delivered: {@link EventsSync} tracks the
971
+ * highest applied `seq` separately from the watermark, so a batch whose
972
+ * replay succeeded and whose mirror fan-out then failed is not replayed.
881
973
  */
882
974
  applyEvents: (events: ReadonlyArray<EventLogEntry>) => void;
883
975
  /**
884
- * Fetch all events whose `seq >= sinceSeq`.
976
+ * Fetch the next batch of events whose `seq >= sinceSeq`.
885
977
  *
886
- * In a server-side context, this typically wraps
887
- * {@link import("@lunora/replica").EventLogDOClient.getSince |
888
- * EventLogDOClient.getSince()}.
889
- * In a client context it could call a Lunora action that proxies to the
978
+ * It does NOT have to return the whole backlog: a bounded batch is
979
+ * preferred and is what the DO-backed transport gives you
980
+ * ({@link import("@lunora/replica").EventLogDOClient.getSince |
981
+ * EventLogDOClient.getSince()} answers one page). {@link EventsSync} keeps
982
+ * calling with the advanced watermark until a call returns nothing, so the
983
+ * whole log is applied either way — one bounded atom at a time.
984
+ * In a client context this could call a Lunora action that proxies to the
890
985
  * event log, or read from an IndexedDB cache.
891
986
  *
892
987
  * Return an empty array when there are no new events.
@@ -896,8 +991,17 @@ interface EventsSyncOptions {
896
991
  * Produce {@link TableDiff | TableDiffs} from the current derived state.
897
992
  *
898
993
  * Called after every batch of events has been applied. The consumer
899
- * compares the state _before_ and _after_ the batch and returns the
900
- * diffs needed to bring the LocalMirror up to date.
994
+ * **recomputes a full diff from the current mirror-vs-source state** and
995
+ * returns the diffs needed to bring the LocalMirror up to date.
996
+ *
997
+ * **MUST be idempotent** — it must NOT advance a one-shot cursor as a side
998
+ * effect. A batch that fails partway (a `mirror.applyDiff` throws) is
999
+ * retried on the next poll from the same watermark; if this call consumed a
1000
+ * cursor on the first attempt it would return `[]` on the retry and the
1001
+ * un-mirrored diffs would be lost forever. Recompute-from-current-state has
1002
+ * no such hazard: calling it again with no new events returns the same
1003
+ * diffs, and calling it after a partial mirror write returns exactly the
1004
+ * diffs still missing from the mirror.
901
1005
  *
902
1006
  * Return an empty array when there are no changes to push to the mirror.
903
1007
  */