@lunora/replica 1.0.0-alpha.78 → 1.0.0-alpha.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,7 +142,9 @@ machine, and pushes the resulting diffs to the mirror:
142
142
  import { EventsSync } from "@lunora/replica";
143
143
 
144
144
  const sync = new EventsSync({
145
- fetchEventsSince: (seq) => eventLogClient.getSince(seq),
145
+ // `getSince` answers ONE bounded page; EventsSync keeps calling with the
146
+ // advanced watermark until the log is exhausted.
147
+ fetchEventsSince: async (seq) => (await eventLogClient.getSince(seq)).entries,
146
148
  applyEvents: (events) => {
147
149
  /* feed events into your state machine */
148
150
  },
@@ -235,9 +237,10 @@ const client = new EventLogDOClient({
235
237
  const [entry] = await client.append([{ type: "order:placed", payload: { orderId: "123" } }]);
236
238
 
237
239
  // Read back by sequence number — the log is append-only and ordered, so
238
- // there is no filter-by-type query. `getSince(0)` is the whole log.
239
- const events = await client.getSince(entry.seq);
240
- const { entries, hasMore } = await client.getRange(0, 50);
240
+ // there is no filter-by-type query. Every read is ONE bounded page (500
241
+ // entries by default, 1000 max): walk `cursor` while `truncated` is true.
242
+ const { entries, truncated, cursor } = await client.getSince(entry.seq);
243
+ const page = await client.getSince(0, 50);
241
244
  const size = await client.getSize();
242
245
  ```
243
246
 
package/dist/index.d.mts CHANGED
@@ -17,8 +17,8 @@ createBetterSqlite3Adapter } from "./adapters/better-sqlite3.mjs";
17
17
  export { createSqliteWasmAdapter } from "./adapters/sqlite-wasm.mjs";
18
18
  export { createSqlJsAdapter } from "./adapters/sqljs.mjs";
19
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-N1dkxd9A.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-N1dkxd9A.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-CtQovAv_.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-CtQovAv_.mjs";
22
22
  /**
23
23
  * Apply a single {@link TableDiff} to an in-memory row map and return
24
24
  * the updated map.
@@ -194,19 +194,28 @@ declare class EventLogDOClient {
194
194
  batchId?: string;
195
195
  }): Promise<EventLogEntry[]>;
196
196
  /**
197
- * Fetch all entries with `seq >= sinceSeq`.
197
+ * Fetch ONE page of entries with `seq >= sinceSeq`.
198
198
  *
199
- * Pass `sinceSeq = 0` to fetch the entire log.
200
- */
201
- getSince(sinceSeq: number): Promise<EventLogEntry[]>;
202
- /**
203
- * Fetch a paginated range of entries.
204
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
205
- * 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`.
206
214
  */
207
- getRange(fromSeq: number, limit?: number): Promise<{
215
+ getSince(sinceSeq: number, limit?: number): Promise<{
216
+ cursor?: number;
208
217
  entries: EventLogEntry[];
209
- hasMore: boolean;
218
+ truncated: boolean;
210
219
  }>;
211
220
  /**
212
221
  * Return the total number of entries currently in the log.
@@ -603,12 +612,24 @@ declare class MaterializerRuntime {
603
612
  *
604
613
  * 1. Recover materialized state from snapshots (if a snapshotStore is
605
614
  * configured).
606
- * 2. Fetch all entries since the MINIMUM per-materializer watermark from
615
+ * 2. Fetch entries since the MINIMUM per-materializer watermark from
607
616
  * the DO — not the maximum — so a materializer with no snapshot (or a
608
617
  * lower one) still receives every event it hasn't seen (REPLICA-04).
609
618
  * 3. Apply them through the materializers; `applyEntries` skips each
610
619
  * entry for any materializer already past it, so nothing is double-applied.
611
620
  *
621
+ * The DO answers one BOUNDED page per request, so step 2/3 walk pages until
622
+ * the log is exhausted — applying each page as it arrives, rather than
623
+ * holding the whole backlog in memory. Taking only the first page (and
624
+ * dropping `truncated`) would silently leave every materializer short of
625
+ * the log's head whenever the backlog exceeds a page.
626
+ *
627
+ * The walk is bounded by {@link MAX_CATCHUP_PAGES}: against a log written
628
+ * faster than it is read, "until the log is exhausted" never arrives and
629
+ * startup would never finish. Hitting the budget returns what was applied
630
+ * with every materializer's watermark advanced, so a later `initialize()`
631
+ * (or the ordinary append path) picks up exactly where this left off.
632
+ *
612
633
  * Call this once on startup / after the DO binding is available.
613
634
  * @returns The number of entries applied during catch-up.
614
635
  */
@@ -620,7 +641,8 @@ declare class MaterializerRuntime {
620
641
  * This is a convenience over calling `doClient.append(...)` +
621
642
  * `runtime.applyEntries(...)` yourself — it persists the event
622
643
  * **then** applies the returned entry (with its assigned seq).
623
- * @returns The persisted entry with its DO-assigned `seq`.
644
+ * @returns The persisted entry with its DO-assigned `seq` — always, whether
645
+ * or not the entry could be applied to the materializers (see below).
624
646
  */
625
647
  appendEvent(input: AppendEventInput): Promise<EventLogEntry>;
626
648
  /**
@@ -714,20 +736,15 @@ interface EventsFacade {
714
736
  type: string;
715
737
  }[]) => Promise<EventLogEntry[]>;
716
738
  /**
717
- * Fetch a paginated range of entries.
718
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
719
- * page exists.
739
+ * Fetch ONE bounded page of entries with `seq >= sinceSeq`.
740
+ * @returns `{ entries, truncated, cursor }` — pass `cursor` back as
741
+ * `sinceSeq` while `truncated` is `true` to walk the whole log.
720
742
  */
721
- getRange: (fromSeq: number, limit?: number) => Promise<{
743
+ getSince: (sinceSeq: number, limit?: number) => Promise<{
744
+ cursor?: number;
722
745
  entries: EventLogEntry[];
723
- hasMore: boolean;
746
+ truncated: boolean;
724
747
  }>;
725
- /**
726
- * Fetch all entries with `seq >= sinceSeq`.
727
- *
728
- * Pass `sinceSeq = 0` to fetch the entire log.
729
- */
730
- getSince: (sinceSeq: number) => Promise<EventLogEntry[]>;
731
748
  /** Return the total number of entries currently in the log. */
732
749
  getSize: () => Promise<number>;
733
750
  /** Return the full log state — all entries plus the next seq number. */
@@ -748,7 +765,7 @@ interface EventsContextOutput {
748
765
  * Create a middleware that attaches a typed `ctx.events` facade backed by
749
766
  * the given {@link EventLogDOClient}.
750
767
  *
751
- * The facade surfaces `append`, `getSince`, `getRange`, `getSize`, and
768
+ * The facade surfaces `append`, `getSince`, `getSize`, and
752
769
  * `getState` — every method the DO client exposes — so handlers can read
753
770
  * and write the event log without reaching for the DO stub directly.
754
771
  *
@@ -790,12 +807,16 @@ interface SubscriptionClient {
790
807
  * is applied to the local SQLite store.
791
808
  *
792
809
  * Each frame from a Lunora live query is the FULL current result set, so the
793
- * callback treats it as a snapshot: it upserts every row present and emits a
794
- * `delete` for any id that was mirrored on a previous frame but is absent now
795
- * otherwise rows that drop out of the server result would linger stale in the
796
- * local mirror. Rows are keyed by their `id` field (the mirror's default primary
797
- * key); a row without an `id` can't be reconciled on removal, and — because the
798
- * mirror table's `id` column is `NOT NULL` — will fail the insert.
810
+ * callback diffs it against the previous frame: a row that is new or whose
811
+ * content changed is upserted, a row that dropped out is deleted, and an
812
+ * unchanged row produces nothing. A frame identical to the last one therefore
813
+ * applies no diff no event-log entry, no `version` bump, no re-query for the
814
+ * hooks subscribed to the mirror.
815
+ *
816
+ * Rows are keyed by the table's primary key (`id` unless the table was
817
+ * registered with another `primaryKey`); a row without one can't be diffed or
818
+ * reconciled on removal, and — because the mirror's key column is `NOT NULL` —
819
+ * will fail the insert.
799
820
  *
800
821
  * The mirror table name is derived from the function ref alone (not `args`), so
801
822
  * do NOT mirror two subscriptions to the same function with different `args`
@@ -895,15 +916,28 @@ interface EventsSyncOptions {
895
916
  * consumer should feed these events into their state machine
896
917
  * (e.g. an {@link import("@lunora/replica").EventSource | EventSource})
897
918
  * so that the machine's state reflects the latest log position.
919
+ *
920
+ * **Must be atomic across the batch: apply every event, or none.** There is
921
+ * no rollback here and none is possible — the state machine is the
922
+ * consumer's. A call that mutates derived state and then throws partway is
923
+ * re-delivered WHOLE on the next poll (the watermark only advances past a
924
+ * batch that fully succeeded, and a replay that threw is not recorded), so a
925
+ * non-atomic implementation applies the events before the throw twice. A
926
+ * call that RETURNS is never re-delivered: {@link EventsSync} tracks the
927
+ * highest applied `seq` separately from the watermark, so a batch whose
928
+ * replay succeeded and whose mirror fan-out then failed is not replayed.
898
929
  */
899
930
  applyEvents: (events: ReadonlyArray<EventLogEntry>) => void;
900
931
  /**
901
- * Fetch all events whose `seq >= sinceSeq`.
932
+ * Fetch the next batch of events whose `seq >= sinceSeq`.
902
933
  *
903
- * In a server-side context, this typically wraps
904
- * {@link import("@lunora/replica").EventLogDOClient.getSince |
905
- * EventLogDOClient.getSince()}.
906
- * In a client context it could call a Lunora action that proxies to the
934
+ * It does NOT have to return the whole backlog: a bounded batch is
935
+ * preferred and is what the DO-backed transport gives you
936
+ * ({@link import("@lunora/replica").EventLogDOClient.getSince |
937
+ * EventLogDOClient.getSince()} answers one page). {@link EventsSync} keeps
938
+ * calling with the advanced watermark until a call returns nothing, so the
939
+ * whole log is applied either way — one bounded atom at a time.
940
+ * In a client context this could call a Lunora action that proxies to the
907
941
  * event log, or read from an IndexedDB cache.
908
942
  *
909
943
  * Return an empty array when there are no new events.
package/dist/index.d.ts CHANGED
@@ -17,8 +17,8 @@ createBetterSqlite3Adapter } from "./adapters/better-sqlite3.js";
17
17
  export { createSqliteWasmAdapter } from "./adapters/sqlite-wasm.js";
18
18
  export { createSqlJsAdapter } from "./adapters/sqljs.js";
19
19
  import { S as SqliteAdapter } from "./packem_shared/types.d-BuLTPLaQ.js";
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-C1pwwvwS.js";
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-C1pwwvwS.js";
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-CErKffFW.js";
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-CErKffFW.js";
22
22
  /**
23
23
  * Apply a single {@link TableDiff} to an in-memory row map and return
24
24
  * the updated map.
@@ -194,19 +194,28 @@ declare class EventLogDOClient {
194
194
  batchId?: string;
195
195
  }): Promise<EventLogEntry[]>;
196
196
  /**
197
- * Fetch all entries with `seq >= sinceSeq`.
197
+ * Fetch ONE page of entries with `seq >= sinceSeq`.
198
198
  *
199
- * Pass `sinceSeq = 0` to fetch the entire log.
200
- */
201
- getSince(sinceSeq: number): Promise<EventLogEntry[]>;
202
- /**
203
- * Fetch a paginated range of entries.
204
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
205
- * 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`.
206
214
  */
207
- getRange(fromSeq: number, limit?: number): Promise<{
215
+ getSince(sinceSeq: number, limit?: number): Promise<{
216
+ cursor?: number;
208
217
  entries: EventLogEntry[];
209
- hasMore: boolean;
218
+ truncated: boolean;
210
219
  }>;
211
220
  /**
212
221
  * Return the total number of entries currently in the log.
@@ -603,12 +612,24 @@ declare class MaterializerRuntime {
603
612
  *
604
613
  * 1. Recover materialized state from snapshots (if a snapshotStore is
605
614
  * configured).
606
- * 2. Fetch all entries since the MINIMUM per-materializer watermark from
615
+ * 2. Fetch entries since the MINIMUM per-materializer watermark from
607
616
  * the DO — not the maximum — so a materializer with no snapshot (or a
608
617
  * lower one) still receives every event it hasn't seen (REPLICA-04).
609
618
  * 3. Apply them through the materializers; `applyEntries` skips each
610
619
  * entry for any materializer already past it, so nothing is double-applied.
611
620
  *
621
+ * The DO answers one BOUNDED page per request, so step 2/3 walk pages until
622
+ * the log is exhausted — applying each page as it arrives, rather than
623
+ * holding the whole backlog in memory. Taking only the first page (and
624
+ * dropping `truncated`) would silently leave every materializer short of
625
+ * the log's head whenever the backlog exceeds a page.
626
+ *
627
+ * The walk is bounded by {@link MAX_CATCHUP_PAGES}: against a log written
628
+ * faster than it is read, "until the log is exhausted" never arrives and
629
+ * startup would never finish. Hitting the budget returns what was applied
630
+ * with every materializer's watermark advanced, so a later `initialize()`
631
+ * (or the ordinary append path) picks up exactly where this left off.
632
+ *
612
633
  * Call this once on startup / after the DO binding is available.
613
634
  * @returns The number of entries applied during catch-up.
614
635
  */
@@ -620,7 +641,8 @@ declare class MaterializerRuntime {
620
641
  * This is a convenience over calling `doClient.append(...)` +
621
642
  * `runtime.applyEntries(...)` yourself — it persists the event
622
643
  * **then** applies the returned entry (with its assigned seq).
623
- * @returns The persisted entry with its DO-assigned `seq`.
644
+ * @returns The persisted entry with its DO-assigned `seq` — always, whether
645
+ * or not the entry could be applied to the materializers (see below).
624
646
  */
625
647
  appendEvent(input: AppendEventInput): Promise<EventLogEntry>;
626
648
  /**
@@ -714,20 +736,15 @@ interface EventsFacade {
714
736
  type: string;
715
737
  }[]) => Promise<EventLogEntry[]>;
716
738
  /**
717
- * Fetch a paginated range of entries.
718
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
719
- * page exists.
739
+ * Fetch ONE bounded page of entries with `seq >= sinceSeq`.
740
+ * @returns `{ entries, truncated, cursor }` — pass `cursor` back as
741
+ * `sinceSeq` while `truncated` is `true` to walk the whole log.
720
742
  */
721
- getRange: (fromSeq: number, limit?: number) => Promise<{
743
+ getSince: (sinceSeq: number, limit?: number) => Promise<{
744
+ cursor?: number;
722
745
  entries: EventLogEntry[];
723
- hasMore: boolean;
746
+ truncated: boolean;
724
747
  }>;
725
- /**
726
- * Fetch all entries with `seq >= sinceSeq`.
727
- *
728
- * Pass `sinceSeq = 0` to fetch the entire log.
729
- */
730
- getSince: (sinceSeq: number) => Promise<EventLogEntry[]>;
731
748
  /** Return the total number of entries currently in the log. */
732
749
  getSize: () => Promise<number>;
733
750
  /** Return the full log state — all entries plus the next seq number. */
@@ -748,7 +765,7 @@ interface EventsContextOutput {
748
765
  * Create a middleware that attaches a typed `ctx.events` facade backed by
749
766
  * the given {@link EventLogDOClient}.
750
767
  *
751
- * The facade surfaces `append`, `getSince`, `getRange`, `getSize`, and
768
+ * The facade surfaces `append`, `getSince`, `getSize`, and
752
769
  * `getState` — every method the DO client exposes — so handlers can read
753
770
  * and write the event log without reaching for the DO stub directly.
754
771
  *
@@ -790,12 +807,16 @@ interface SubscriptionClient {
790
807
  * is applied to the local SQLite store.
791
808
  *
792
809
  * Each frame from a Lunora live query is the FULL current result set, so the
793
- * callback treats it as a snapshot: it upserts every row present and emits a
794
- * `delete` for any id that was mirrored on a previous frame but is absent now
795
- * otherwise rows that drop out of the server result would linger stale in the
796
- * local mirror. Rows are keyed by their `id` field (the mirror's default primary
797
- * key); a row without an `id` can't be reconciled on removal, and — because the
798
- * mirror table's `id` column is `NOT NULL` — will fail the insert.
810
+ * callback diffs it against the previous frame: a row that is new or whose
811
+ * content changed is upserted, a row that dropped out is deleted, and an
812
+ * unchanged row produces nothing. A frame identical to the last one therefore
813
+ * applies no diff no event-log entry, no `version` bump, no re-query for the
814
+ * hooks subscribed to the mirror.
815
+ *
816
+ * Rows are keyed by the table's primary key (`id` unless the table was
817
+ * registered with another `primaryKey`); a row without one can't be diffed or
818
+ * reconciled on removal, and — because the mirror's key column is `NOT NULL` —
819
+ * will fail the insert.
799
820
  *
800
821
  * The mirror table name is derived from the function ref alone (not `args`), so
801
822
  * do NOT mirror two subscriptions to the same function with different `args`
@@ -895,15 +916,28 @@ interface EventsSyncOptions {
895
916
  * consumer should feed these events into their state machine
896
917
  * (e.g. an {@link import("@lunora/replica").EventSource | EventSource})
897
918
  * so that the machine's state reflects the latest log position.
919
+ *
920
+ * **Must be atomic across the batch: apply every event, or none.** There is
921
+ * no rollback here and none is possible — the state machine is the
922
+ * consumer's. A call that mutates derived state and then throws partway is
923
+ * re-delivered WHOLE on the next poll (the watermark only advances past a
924
+ * batch that fully succeeded, and a replay that threw is not recorded), so a
925
+ * non-atomic implementation applies the events before the throw twice. A
926
+ * call that RETURNS is never re-delivered: {@link EventsSync} tracks the
927
+ * highest applied `seq` separately from the watermark, so a batch whose
928
+ * replay succeeded and whose mirror fan-out then failed is not replayed.
898
929
  */
899
930
  applyEvents: (events: ReadonlyArray<EventLogEntry>) => void;
900
931
  /**
901
- * Fetch all events whose `seq >= sinceSeq`.
932
+ * Fetch the next batch of events whose `seq >= sinceSeq`.
902
933
  *
903
- * In a server-side context, this typically wraps
904
- * {@link import("@lunora/replica").EventLogDOClient.getSince |
905
- * EventLogDOClient.getSince()}.
906
- * In a client context it could call a Lunora action that proxies to the
934
+ * It does NOT have to return the whole backlog: a bounded batch is
935
+ * preferred and is what the DO-backed transport gives you
936
+ * ({@link import("@lunora/replica").EventLogDOClient.getSince |
937
+ * EventLogDOClient.getSince()} answers one page). {@link EventsSync} keeps
938
+ * calling with the advanced watermark until a call returns nothing, so the
939
+ * whole log is applied either way — one bounded atom at a time.
940
+ * In a client context this could call a Lunora action that proxies to the
907
941
  * event log, or read from an IndexedDB cache.
908
942
  *
909
943
  * Return an empty array when there are no new events.
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{createBetterSqlite3Adapter as o}from"./adapters/better-sqlite3.mjs";import{createSqliteWasmAdapter as f}from"./adapters/sqlite-wasm.mjs";import{createSqlJsAdapter as i}from"./adapters/sqljs.mjs";import{applyDiff as m,applyDiffToSnapshot as n,applyDiffs as x}from"./packem_shared/applyDiff-C6A5aCk4.mjs";import{defineEvents as l}from"./packem_shared/defineEvents-DHo-VK7G.mjs";import{MaterializerRuntime as S,defineMaterializer as E}from"./packem_shared/MaterializerRuntime-D0HOAe24.mjs";import{applyDiffToDb as v,applyDiffsToDb as y}from"./packem_shared/applyDiffToDb-C6ek5Elp.mjs";import{EventEmitter as d}from"./packem_shared/EventEmitter-uo75adUL.mjs";import{EventLog as M}from"./packem_shared/EventLog-B1-yhArT.mjs";import{EventLogDO as u}from"./packem_shared/EventLogDO-CaZvpgJN.mjs";import{EventLogDOClient as T}from"./packem_shared/EventLogDOClient-DGDMj5L5.mjs";import{EventSource as C,UNHANDLED as h}from"./packem_shared/EventSource-BC0hKJSA.mjs";import{eventsContext as I}from"./packem_shared/eventsContext-Dxow9Y7S.mjs";import{LocalMirror as O}from"./packem_shared/LocalMirror-BIURA-He.mjs";import{isClientSeq as G,isGlobalSeq as H,isInputEvent as J}from"./packem_shared/isClientSeq-D2Xm0_lj.mjs";import{InMemorySnapshotStore as U}from"./packem_shared/InMemorySnapshotStore-C4taIG5K.mjs";import{subscribeToMirror as j}from"./packem_shared/subscribeToMirror-Rq100MXf.mjs";import{SubscriptionManager as w}from"./packem_shared/SubscriptionManager-AhPw3lFc.mjs";import{EventsSync as K}from"./packem_shared/EventsSync-DWyGGZQZ.mjs";import{classifyChanges as Q,createTableDiff as V,diffSize as X,isDiffEmpty as Y,mergeDiffs as Z}from"./packem_shared/classifyChanges-BDEt9_BL.mjs";export{d as EventEmitter,M as EventLog,u as EventLogDO,T as EventLogDOClient,C as EventSource,K as EventsSync,U as InMemorySnapshotStore,O as LocalMirror,S as MaterializerRuntime,w as SubscriptionManager,h as UNHANDLED,m as applyDiff,v as applyDiffToDb,n as applyDiffToSnapshot,x as applyDiffs,y as applyDiffsToDb,Q as classifyChanges,o as createBetterSqlite3Adapter,i as createSqlJsAdapter,f as createSqliteWasmAdapter,V as createTableDiff,l as defineEvents,E as defineMaterializer,X as diffSize,I as eventsContext,G as isClientSeq,Y as isDiffEmpty,H as isGlobalSeq,J as isInputEvent,Z as mergeDiffs,j as subscribeToMirror};
1
+ import{createBetterSqlite3Adapter as o}from"./adapters/better-sqlite3.mjs";import{createSqliteWasmAdapter as f}from"./adapters/sqlite-wasm.mjs";import{createSqlJsAdapter as i}from"./adapters/sqljs.mjs";import{applyDiff as m,applyDiffToSnapshot as n,applyDiffs as x}from"./packem_shared/applyDiff-C6A5aCk4.mjs";import{defineEvents as l}from"./packem_shared/defineEvents-DHo-VK7G.mjs";import{MaterializerRuntime as S,defineMaterializer as E}from"./packem_shared/MaterializerRuntime-S-Knx6BM.mjs";import{applyDiffToDb as v,applyDiffsToDb as y}from"./packem_shared/applyDiffToDb-C6ek5Elp.mjs";import{EventEmitter as d}from"./packem_shared/EventEmitter-uo75adUL.mjs";import{EventLog as M}from"./packem_shared/EventLog-B1-yhArT.mjs";import{EventLogDO as u}from"./packem_shared/EventLogDO-BF9ZWc6C.mjs";import{EventLogDOClient as T}from"./packem_shared/EventLogDOClient-DWerZ3_n.mjs";import{EventSource as C,UNHANDLED as h}from"./packem_shared/EventSource-BC0hKJSA.mjs";import{eventsContext as I}from"./packem_shared/eventsContext-Dxow9Y7S.mjs";import{LocalMirror as O}from"./packem_shared/LocalMirror-ZmB8SJFe.mjs";import{isClientSeq as G,isGlobalSeq as H,isInputEvent as J}from"./packem_shared/isClientSeq-D2Xm0_lj.mjs";import{InMemorySnapshotStore as U}from"./packem_shared/InMemorySnapshotStore-C4taIG5K.mjs";import{subscribeToMirror as j}from"./packem_shared/subscribeToMirror-BT4oOBng.mjs";import{SubscriptionManager as w}from"./packem_shared/SubscriptionManager-AhPw3lFc.mjs";import{EventsSync as K}from"./packem_shared/EventsSync-B3wzXm-b.mjs";import{classifyChanges as Q,createTableDiff as V,diffSize as X,isDiffEmpty as Y,mergeDiffs as Z}from"./packem_shared/classifyChanges-BDEt9_BL.mjs";export{d as EventEmitter,M as EventLog,u as EventLogDO,T as EventLogDOClient,C as EventSource,K as EventsSync,U as InMemorySnapshotStore,O as LocalMirror,S as MaterializerRuntime,w as SubscriptionManager,h as UNHANDLED,m as applyDiff,v as applyDiffToDb,n as applyDiffToSnapshot,x as applyDiffs,y as applyDiffsToDb,Q as classifyChanges,o as createBetterSqlite3Adapter,i as createSqlJsAdapter,f as createSqliteWasmAdapter,V as createTableDiff,l as defineEvents,E as defineMaterializer,X as diffSize,I as eventsContext,G as isClientSeq,Y as isDiffEmpty,H as isGlobalSeq,J as isInputEvent,Z as mergeDiffs,j as subscribeToMirror};
@@ -0,0 +1 @@
1
+ const y=Symbol("lunora.replica.event-log-do.idempotency-conflict"),q=n=>{const t=new Error(n);return Object.defineProperty(t,y,{value:!0}),t},N=n=>n instanceof Error&&y in n,T=n=>{if(Array.isArray(n))return n.map(t=>T(t));if(n!==null&&typeof n=="object"){const t=n,e=Object.keys(t);e.sort();const s={};for(const r of e)s[r]=T(t[r]);return s}return n},I=async n=>{const t=JSON.stringify(T(n)),e=new TextEncoder().encode(t),s=await crypto.subtle.digest("SHA-256",e);return[...new Uint8Array(s)].map(r=>r.toString(16).padStart(2,"0")).join("")},g=500,A=1e3,f=(n,t=200)=>Response.json(n,{status:t,headers:{"content-type":"application/json"}}),d=(n,t,e)=>f({error:{code:t,message:e}},n),m=n=>typeof n.toArray=="function"?n.toArray():typeof n[Symbol.iterator]=="function"?[...n]:[],E=n=>m(n).map(e=>({seq:e.seq,type:e.type,payload:JSON.parse(e.payload),timestamp:e.timestamp,clientId:e.client_id??void 0,sessionId:e.session_id??void 0,parentSeqNum:e.parent_seq??void 0}));class p{state;env;#t=!1;constructor(t,e){this.state=t,this.env=e}async fetch(t){this.#c();const e=new URL(t.url);try{if(t.method==="POST"&&e.pathname==="/append")return await this.#e(t);if(t.method==="GET"&&e.pathname==="/since")return this.#i(e);if(t.method==="GET"&&e.pathname==="/size")return this.#o();if(t.method==="GET"&&e.pathname==="/state")return this.#a()}catch(s){return console.error("[event-log-do] request failed:",s),d(500,"INTERNAL_ERROR","internal error")}return d(404,"NOT_FOUND","unknown route")}async#e(t){let e;try{e=await t.json()}catch{return d(400,"BAD_REQUEST","invalid JSON body")}const s=p.#n(e);if(s)return d(400,"BAD_REQUEST",s);const{sql:r}=this.state.storage,{batchId:o}=e,a=()=>p.#s(r,e,o),{transaction:i}=this.state.storage;let c;try{c=typeof i=="function"?await i(a):await a()}catch(u){if(N(u))return d(409,"CONFLICT",u.message);throw u}return f({entries:c})}static async#s(t,e,s){let r;if(typeof s=="string"){r=await I(e.events);const i=p.#p(t,s);if(i){if(i.fingerprint!==r)throw q(`batchId "${s}" was already used for a different event batch`);return i.entries}}const o=Date.now(),a=[];for(const i of e.events){const l={seq:p.#d(t),type:i.type,payload:i.payload,timestamp:i.timestamp??o,clientId:i.clientId,sessionId:i.sessionId,parentSeqNum:i.parentSeqNum};p.#l(t,l),a.push(l)}if(typeof s=="string"&&r!==void 0){const i=a[0]?.seq,c=a.at(-1)?.seq;i!==void 0&&c!==void 0&&p.#u(t,s,i,c,r)}return a}static#n(t){if(!Array.isArray(t.events)||t.events.length===0)return"events[] with a non-empty string `type` required";if(t.batchId!==void 0&&(typeof t.batchId!="string"||t.batchId.length===0))return"batchId must be a non-empty string";for(const e of t.events){const s=p.#r(e);if(s!==void 0)return s}}static#r(t){if(typeof t.type!="string"||t.type.length===0)return"events[] with a non-empty string `type` required";if(t.timestamp!==void 0&&!Number.isFinite(t.timestamp))return"events[].timestamp must be a finite number";if(t.clientId!==void 0&&typeof t.clientId!="string")return"events[].clientId must be a string";if(t.sessionId!==void 0&&typeof t.sessionId!="string")return"events[].sessionId must be a string";if(t.parentSeqNum!==void 0&&(typeof t.parentSeqNum!="number"||!Number.isInteger(t.parentSeqNum)||t.parentSeqNum<0))return"events[].parentSeqNum must be a non-negative integer"}#i(t){const e=t.searchParams.get("seq"),s=e===null?0:Number(e),r=t.searchParams.get("limit"),o=r===null?g:Number(r);if(!Number.isSafeInteger(s)||s<0)return d(400,"BAD_REQUEST","invalid seq");if(!Number.isSafeInteger(o)||o<1||o>A)return d(400,"BAD_REQUEST","invalid limit");const{sql:a}=this.state.storage,i=a.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? ORDER BY seq ASC LIMIT ?",s,o+1),c=E(i),l=c.length>o,u=l?c.slice(0,o):c,h=u.at(-1),S=l&&h!==void 0?{entries:u,truncated:!0,cursor:h.seq+1}:{entries:u,truncated:!1};return f(S)}#o(){const{sql:t}=this.state.storage,e=t.exec("SELECT COUNT(*) AS count FROM events"),r=m(e)[0]?.count??0;return f({count:r})}#a(){const{sql:t}=this.state.storage,e=t.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events ORDER BY seq ASC"),s=E(e),r=(s.at(-1)?.seq??-1)+1;return f({entries:s,nextSeq:r})}#c(){if(this.#t)return;const{sql:t}=this.state.storage;t.exec("CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, payload TEXT NOT NULL, timestamp INTEGER NOT NULL, client_id TEXT, session_id TEXT, parent_seq INTEGER)"),t.exec("CREATE TABLE IF NOT EXISTS event_batches (batch_id TEXT PRIMARY KEY, first_seq INTEGER NOT NULL, last_seq INTEGER NOT NULL, fingerprint TEXT NOT NULL)"),this.#t=!0}static#p(t,e){const s=t.exec("SELECT first_seq, last_seq, fingerprint FROM event_batches WHERE batch_id = ?",e),o=m(s)[0];if(!o)return;const a=t.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? AND seq <= ? ORDER BY seq ASC",o.first_seq,o.last_seq);return{entries:E(a),fingerprint:o.fingerprint}}static#u(t,e,s,r,o){t.exec("INSERT INTO event_batches (batch_id, first_seq, last_seq, fingerprint) VALUES (?, ?, ?, ?)",e,s,r,o)}static#d(t){const e=t.exec("SELECT COALESCE(MAX(seq), -1) + 1 AS next_seq FROM events");return m(e)[0]?.next_seq??0}static#l(t,e){const s=typeof e.parentSeqNum=="number"?e.parentSeqNum:null;t.exec("INSERT INTO events (seq, type, payload, timestamp, client_id, session_id, parent_seq) VALUES (?, ?, ?, ?, ?, ?, ?)",e.seq,e.type,JSON.stringify(e.payload),e.timestamp,e.clientId??null,e.sessionId??null,s)}}export{p as EventLogDO};
@@ -0,0 +1 @@
1
+ class n{#t;constructor(t){this.#t=t.fetch}async append(t,a){const e=JSON.stringify({events:t,batchId:a?.batchId}),s=await this.#t(new Request("https://do/append",{method:"POST",headers:{"content-type":"application/json"},body:e}));if(!s.ok)throw await n.#e(s,"append");return(await s.json()).entries}async getSince(t,a){const e=a===void 0?"":`&limit=${String(a)}`;return this.#a(`/since?seq=${String(t)}${e}`,"getSince")}async getSize(){return(await this.#a("/size","getSize")).count}async getState(){return this.#a("/state","getState")}async#a(t,a){const e=await this.#t(new Request(`https://do${t}`));if(!e.ok)throw await n.#e(e,a);return await e.json()}static async#e(t,a){try{const s=(await t.json()).error?.message??t.statusText;return new Error(`EventLogDO.${a} failed (${String(t.status)}): ${s}`)}catch{return new Error(`EventLogDO.${a} failed (${String(t.status)}): ${t.statusText}`)}}}export{n as EventLogDOClient};
@@ -0,0 +1 @@
1
+ class c{#t;#r=0;#i=0;#s;#e;constructor(t){this.#t=t}get watermark(){return this.#r}start(){if(this.#s!==void 0)return;const t=this.#t.pollInterval??5e3;this.#s=setInterval(()=>{this.#n().catch(()=>{})},t)}stop(){this.#s!==void 0&&(clearInterval(this.#s),this.#s=void 0)}async sync(){return this.#n()}async#n(){if(this.#e)return this.#e;const t=this.#o().finally(()=>{this.#e=void 0});return this.#e=t,t}async#o(){let t=0;try{for(let r=0;r<1e3;r+=1){const s=await this.#t.fetchEventsSince(this.#r);if(s.length===0)return t;const e=s.filter(i=>i.seq>=this.#i);e.length>0&&(this.#t.applyEvents(e),this.#i=e[e.length-1].seq+1);const o=this.#t.getTableDiffs();for(const i of o)this.#t.mirror.applyDiff(i);t+=s.length;const n=s[s.length-1].seq+1;if(n<=this.#r)return t;this.#r=n}return t}catch(r){return(this.#t.onError??console.error)(r),t}}}export{c as EventsSync};
@@ -0,0 +1,4 @@
1
+ import{createSqlJsAdapter as y}from"../adapters/sqljs.mjs";import{applyDiffToDb as m,escapeIdentifier as a}from"./applyDiffToDb-C6ek5Elp.mjs";import{EventLog as b}from"./EventLog-B1-yhArT.mjs";const A=1e3,l="__lunora_mirror_meta",R=o=>{o.exec(`CREATE TABLE IF NOT EXISTS ${l} (
2
+ key TEXT PRIMARY KEY NOT NULL,
3
+ value TEXT NOT NULL
4
+ )`)},u="schema_version",f=3,h=o=>typeof o=="bigint"||typeof o=="boolean"?"INTEGER":typeof o=="number"?Number.isInteger(o)?"INTEGER":"REAL":"TEXT";class E{#e;#t;#s;#n=new Set;#i=0;static create(e,s){const t=y(e);return new E({db:t,tables:s?.tables})}constructor(e){this.#e=e.db,this.#t={...e.tables},this.#s=new b({maxEntries:e.maxEventLogEntries??A}),R(this.#e),this.#o()}onChange(e){return this.#n.add(e),()=>{this.#n.delete(e)}}get eventLog(){return this.#s}get db(){return this.#e}get version(){return this.#i}applyDiff(e){e.changes.length!==0&&(this.#l(e),m(this.#e,e,this.primaryKeyOf(e.table)),this.#s.append("table-diff",e,[e]),this.#r())}query(e,s){return this.#e.query(e,s)}clearData(){const e=this.#a();this.#e.transaction(()=>{for(const{name:s}of e)this.#e.exec(`DELETE FROM ${a(s)}`)}),this.#r()}#r(){this.#i+=1;for(const e of this.#n)try{e()}catch{}}close(){this.#e.close(),this.#s.clear(),this.#n.clear()}registerTable(e,s){this.#t[e]={...this.#t[e],...s}}primaryKeyOf(e){return this.#t[e]?.primaryKey??"id"}get mirroredTables(){return Object.keys(this.#t)}#a(){return this.#e.query(String.raw`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\_\_lunora\_%' ESCAPE '\' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'`)}#o(){if(this.#e.query(`SELECT key, value FROM ${l}`).find(n=>n.key===u)?.value===String(f))return;const t=this.#a();this.#e.transaction(()=>{for(const{name:n}of t)this.#e.exec(`DROP TABLE IF EXISTS ${a(n)}`);this.#e.exec(`INSERT OR REPLACE INTO ${l} (key, value) VALUES (?, ?)`,[u,String(f)])})}static#c(e,s){const t=new Set;for(const n of e.changes)if(n.type!=="delete")for(const c of Object.keys(n.data))c!==s&&t.add(c);return t}static#E(e,s,t){const n=new Map;for(const c of e.changes)if(!(c.type==="delete"||n.size===t.size))for(const r of t){if(r===s||n.has(r))continue;const i=c.data[r];i!=null&&n.set(r,h(i))}return n}static#h(e,s){for(const t of e.changes){const n=t.type==="delete"?t.id:t.data[s];if(n!=null)return h(n);if(t.type==="update")return h(t.id)}return"TEXT"}#l(e){const s=this.primaryKeyOf(e.table),t=E.#c(e,s),n=E.#E(e,s,t);if(this.#e.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?",[e.table]).length===0){const r=E.#h(e,s);let i=`${a(s)} ${r==="INTEGER"?"INT":r} PRIMARY KEY NOT NULL`;for(const T of t)i+=`, ${a(T)} ${n.get(T)??"TEXT"}`;this.#e.exec(`CREATE TABLE IF NOT EXISTS ${a(e.table)} (${i})`)}else if(t.size>0){const r=new Set(this.#e.query(`PRAGMA table_info(${a(e.table)})`).map(i=>i.name));for(const i of t)r.has(i)||this.#e.exec(`ALTER TABLE ${a(e.table)} ADD COLUMN ${a(i)} ${n.get(i)??"TEXT"}`)}}}export{E as LocalMirror};
@@ -0,0 +1 @@
1
+ const c=a=>{let e=a.initial();return{def:a,get state(){return Object.freeze(e)},setState(t){e=t},apply(t){e=a.handle(e,t)},reset(){e=a.initial()}}};class u{#e;#i;#n;#s;#t;constructor(e,t={}){this.#e=[...e],this.#t=this.#e.map(()=>0),this.#i=t.snapshotStore,this.#n=t.doClient,this.#s=t.unknownEventHandling??"warn"}get appliedSeq(){return this.#t.length>0?Math.min(...this.#t):0}applyEntries(e){let t=0;for(const i of e){let s=!1,n=!1;const r=[];for(const[o,p]of this.#e.entries()){const h=this.#t[o]??0;if(i.seq<h)continue;const l=p.state;p.apply(i),s=!0,p.state!==l&&(n=!0),r.push(o)}if(s){n||this.#a(i);for(const o of r)this.#t[o]=i.seq+1;t+=1}}return t}#a(e){const t=this.#s;if(typeof t=="function"){t(e);return}switch(t){case"ignore":return;case"fail":throw new Error(`MaterializerRuntime: unhandled event type "${e.type}" (seq ${String(e.seq)}). Configure \`unknownEventHandling\` to handle this event or change the strategy.`);default:console.warn(`[MaterializerRuntime] unhandled event type "${e.type}" (seq ${String(e.seq)}). The event was skipped. Configure \`unknownEventHandling\` if this is expected.`)}}async recoverFromSnapshots(){if(!this.#i)return 0;let e=0;for(const[t,i]of this.#e.entries()){const s=await this.#i.load(i.def.name);if(s!==null&&typeof s=="object"){const n=s;if(Number.isSafeInteger(n.appliedSeq)&&n.appliedSeq>=0&&n.state!==void 0){const r=n.appliedSeq;i.setState(n.state),this.#t[t]=r,r>e&&(e=r)}}}return e}async persistSnapshots(){if(this.#i)for(const[e,t]of this.#e.entries())await this.#i.save(t.def.name,{appliedSeq:this.#t[e]??0,state:t.state})}async initialize(){return this.#n?(await this.recoverFromSnapshots(),this.#r()):0}async#r(){const e=this.#n;if(!e)return 0;let t=this.appliedSeq,i=0;for(let s=0;s<1e3;s+=1){const n=await e.getSince(t);if(i+=this.applyEntries(n.entries),!n.truncated||n.cursor===void 0||n.cursor<=t)return i;t=n.cursor}return i}async appendEvent(e){if(!this.#n)throw new Error("MaterializerRuntime.appendEvent requires a doClient — pass one in the constructor options.");const i=(await this.#n.append([e]))[0];if(!i)throw new Error("MaterializerRuntime.appendEvent: DO returned empty result");return this.#e.length>0&&this.appliedSeq<i.seq&&(await this.#r(),this.appliedSeq<i.seq)||this.applyEntries([i]),i}reset(){for(const[e,t]of this.#e.entries())this.#t[e]=0,t.reset()}get materializers(){return this.#e}}export{u as MaterializerRuntime,c as defineMaterializer};
@@ -381,15 +381,18 @@ interface LocalMirrorOptions {
381
381
  /** Platform-specific SQLite adapter. */
382
382
  readonly db: SqliteAdapter;
383
383
  /**
384
- * Cap the mirror's internal {@link EventLog} to this many entries
385
- * (REPLICA-06). Every applied diff is recorded in the log with no cap,
386
- * a long-running client accumulates one entry per diff forever.
384
+ * Cap the mirror's internal {@link EventLog} to this many entries.
385
+ * Every applied diff is recorded in the log, so an uncapped log grows by
386
+ * one entry (holding every changed row) per diff for the life of the
387
+ * mirror — a leak by construction on a long-lived client.
387
388
  *
388
- * `undefined` (the default) preserves unbounded retention. Set this when
389
- * catch-up replication only ever needs a bounded recent window; older
390
- * entries are silently evicted (oldest-first) once the cap is exceeded.
391
- * See {@link EventLog#truncateBelow} for caller-driven truncation tied to
392
- * a snapshot instead.
389
+ * Defaults to {@link DEFAULT_MAX_EVENT_LOG_ENTRIES}. On overflow the
390
+ * OLDEST entries are dropped; nothing in the mirror replays its own log,
391
+ * so a drop loses nothing the mirror needs. A consumer that does replay
392
+ * it (`eventLog.getSince(watermark)` from another tab / service worker)
393
+ * detects a gap when the first returned entry's `seq` is above its
394
+ * watermark, and should re-seed from the mirror's rows (`query`) instead
395
+ * of applying the partial window.
393
396
  */
394
397
  readonly maxEventLogEntries?: number;
395
398
  /**
@@ -519,9 +522,14 @@ declare class LocalMirror {
519
522
  close(): void;
520
523
  /**
521
524
  * Register a table schema so the mirror can create the table on
522
- * first use.
525
+ * first use. Merges into any definition already registered for `name`
526
+ * (from the constructor's `tables` or an earlier call), so a helper that
527
+ * registers `{}` just to make the table known does not erase a
528
+ * user-supplied `primaryKey`.
523
529
  */
524
530
  registerTable(name: string, definition: MirrorTableDef): void;
531
+ /** The primary-key column of a mirrored table (`"id"` unless registered otherwise). */
532
+ primaryKeyOf(table: string): string;
525
533
  /**
526
534
  * Return the list of mirrored table names.
527
535
  */
@@ -381,15 +381,18 @@ interface LocalMirrorOptions {
381
381
  /** Platform-specific SQLite adapter. */
382
382
  readonly db: SqliteAdapter;
383
383
  /**
384
- * Cap the mirror's internal {@link EventLog} to this many entries
385
- * (REPLICA-06). Every applied diff is recorded in the log with no cap,
386
- * a long-running client accumulates one entry per diff forever.
384
+ * Cap the mirror's internal {@link EventLog} to this many entries.
385
+ * Every applied diff is recorded in the log, so an uncapped log grows by
386
+ * one entry (holding every changed row) per diff for the life of the
387
+ * mirror — a leak by construction on a long-lived client.
387
388
  *
388
- * `undefined` (the default) preserves unbounded retention. Set this when
389
- * catch-up replication only ever needs a bounded recent window; older
390
- * entries are silently evicted (oldest-first) once the cap is exceeded.
391
- * See {@link EventLog#truncateBelow} for caller-driven truncation tied to
392
- * a snapshot instead.
389
+ * Defaults to {@link DEFAULT_MAX_EVENT_LOG_ENTRIES}. On overflow the
390
+ * OLDEST entries are dropped; nothing in the mirror replays its own log,
391
+ * so a drop loses nothing the mirror needs. A consumer that does replay
392
+ * it (`eventLog.getSince(watermark)` from another tab / service worker)
393
+ * detects a gap when the first returned entry's `seq` is above its
394
+ * watermark, and should re-seed from the mirror's rows (`query`) instead
395
+ * of applying the partial window.
393
396
  */
394
397
  readonly maxEventLogEntries?: number;
395
398
  /**
@@ -519,9 +522,14 @@ declare class LocalMirror {
519
522
  close(): void;
520
523
  /**
521
524
  * Register a table schema so the mirror can create the table on
522
- * first use.
525
+ * first use. Merges into any definition already registered for `name`
526
+ * (from the constructor's `tables` or an earlier call), so a helper that
527
+ * registers `{}` just to make the table known does not erase a
528
+ * user-supplied `primaryKey`.
523
529
  */
524
530
  registerTable(name: string, definition: MirrorTableDef): void;
531
+ /** The primary-key column of a mirrored table (`"id"` unless registered otherwise). */
532
+ primaryKeyOf(table: string): string;
525
533
  /**
526
534
  * Return the list of mirrored table names.
527
535
  */
@@ -0,0 +1 @@
1
+ const k=/["\\\u0000-\u001F\uD800-\uDFFF]/,O=r=>k.test(r)?JSON.stringify(r):`"${r}"`,w=r=>{if(r===void 0)return"null";if(typeof r=="bigint")throw new TypeError("stableStringify: cannot use a bigint in a stable JSON cache key — pass it as a string, or use stableWireKey");if(typeof r=="number"){if(Number.isNaN(r))return"nan";if(r===1/0)return"inf";if(r===-1/0)return"-inf";if(Object.is(r,-0))return"-0"}if(typeof r=="string")return O(r);if(r===null||typeof r!="object")return JSON.stringify(r);if(Array.isArray(r)){let e="[";for(let o=0;o<r.length;o++)o>0&&(e+=","),e+=w(r[o]);return e+"]"}const n=Object.getPrototypeOf(r);if(n!==null&&n!==Object.prototype){const e=r.constructor?.name??"value";throw new TypeError(`stableStringify: cannot use a ${e} in a stable JSON cache key — only plain objects, arrays, and JSON primitives are supported (wire-typed values key via stableWireKey)`)}const f=r,s=Object.keys(f).sort();let c="{",t=!0;for(const e of s){const o=f[e];o!==void 0&&(t?t=!1:c+=",",c+=O(e),c+=":",c+=w(o))}return c+"}"},l=r=>{let n="";for(let s=0;s<r.length;s+=32768)n+=String.fromCharCode(...r.subarray(s,s+32768));return btoa(n)},i="$lunora.wire$";const j="__proto__",E=r=>{if(r===null||typeof r!="object")return!1;const n=Object.getPrototypeOf(r);return n===null||n===Object.prototype},y=(r,n=0)=>{if(n>64)throw new RangeError("wire-codec: value nesting exceeds the 64-level limit");if(r===void 0)return[i,"undefined"];if(r===null)return null;const f=typeof r;if(f==="bigint")return[i,"bigint",r.toString()];if(f==="number"){const t=r;return Number.isNaN(t)?[i,"nan"]:t===1/0?[i,"inf"]:t===-1/0?[i,"-inf"]:t}if(f!=="object")return r;if(r instanceof Date)return[i,"date",y(r.getTime(),n+1)];if(r instanceof Error){const t=r,e={};for(const b of Object.keys(t))t[b]!==void 0&&(e[b]=y(t[b],n+1));const o=[i,"error",t.name,t.message,e];return t.cause!==void 0&&o.push(y(t.cause,n+1)),o}if(r instanceof URL)return[i,"url",r.href];if(r instanceof Map)return[i,"map",[...r.entries()].map(([t,e])=>[y(t,n+1),y(e,n+1)])];if(r instanceof Set)return[i,"set",[...r].map(t=>y(t,n+1))];if(r instanceof ArrayBuffer)return[i,"bytes",l(new Uint8Array(r)),"ArrayBuffer"];if(ArrayBuffer.isView(r)){const t=r,e=t.constructor.name,o=new Uint8Array(t.buffer,t.byteOffset,t.byteLength);return e==="Uint8Array"?[i,"bytes",l(o)]:[i,"bytes",l(o),e]}if(Array.isArray(r)){const t=r.map(e=>y(e,n+1));return t.length>0&&t[0]===i?[i,"arr",t]:t}if(!E(r)){const t=r.constructor?.name??"value";throw new TypeError(`wire-codec: cannot encode a ${t} over the Lunora wire — only plain objects, arrays, and the supported built-ins (Date, Error, URL, Map, Set, ArrayBuffer/typed arrays, bigint) round-trip`)}const s=r,c={};for(const t of Object.keys(s)){const e=s[t];if(e===void 0)continue;const o=y(e,n+1);t===j?Object.defineProperty(c,t,{configurable:!0,enumerable:!0,value:o,writable:!0}):c[t]=o}return c},N=r=>w(y(r)),S=r=>`fn_${r.replaceAll(/[/:.]/g,"_")}`,_=r=>Array.isArray(r)?r:r!==null&&typeof r=="object"?[r]:[],T=(r,n,f,s,c)=>{const t=S(f.__lunoraRef);n.registerTable(t,{});const e=n.primaryKeyOf(t);let o=new Map;return r.subscribe(f,s,b=>{const d=new Map,m=new Map,p=[];for(const a of _(b)){const u=a,g=u[e];if(typeof g!="string"&&typeof g!="number"&&typeof g!="bigint"){p.push({type:"insert",data:u});continue}const A=String(g),h=N(u);d.set(A,h),m.set(A,u)}for(const[a,u]of d)o.get(a)!==u&&p.push({data:m.get(a),type:"insert"});for(const a of o.keys())d.has(a)||p.push({type:"delete",id:a});p.length>0&&n.applyDiff({table:t,changes:p,timestamp:Date.now()}),o=d},{shardKey:c})};export{T as subscribeToMirror};
package/dist/react.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { L as LocalMirror } from "./packem_shared/local-mirror.d-N1dkxd9A.mjs";
1
+ import { L as LocalMirror } from "./packem_shared/local-mirror.d-CtQovAv_.mjs";
2
2
  import "./packem_shared/types.d-BuLTPLaQ.mjs";
3
3
  /**
4
4
  * Result of {@link useLocalQuery} — a discriminated union so callers get a
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { L as LocalMirror } from "./packem_shared/local-mirror.d-C1pwwvwS.js";
1
+ import { L as LocalMirror } from "./packem_shared/local-mirror.d-CErKffFW.js";
2
2
  import "./packem_shared/types.d-BuLTPLaQ.js";
3
3
  /**
4
4
  * Result of {@link useLocalQuery} — a discriminated union so callers get a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/replica",
3
- "version": "1.0.0-alpha.78",
3
+ "version": "1.0.0-alpha.79",
4
4
  "description": "Local-first replica runtime + local SQLite mirror for Lunora",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1 +0,0 @@
1
- const T=Symbol("lunora.replica.event-log-do.idempotency-conflict"),q=n=>{const t=new Error(n);return Object.defineProperty(t,T,{value:!0}),t},N=n=>n instanceof Error&&T in n,h=n=>{if(Array.isArray(n))return n.map(t=>h(t));if(n!==null&&typeof n=="object"){const t=n,e=Object.keys(t);e.sort();const s={};for(const r of e)s[r]=h(t[r]);return s}return n},S=async n=>{const t=JSON.stringify(h(n)),e=new TextEncoder().encode(t),s=await crypto.subtle.digest("SHA-256",e);return[...new Uint8Array(s)].map(r=>r.toString(16).padStart(2,"0")).join("")},l=(n,t=200)=>Response.json(n,{status:t,headers:{"content-type":"application/json"}}),u=(n,t,e)=>l({error:{code:t,message:e}},n),E=n=>typeof n.toArray=="function"?n.toArray():typeof n[Symbol.iterator]=="function"?[...n]:[],f=n=>E(n).map(e=>({seq:e.seq,type:e.type,payload:JSON.parse(e.payload),timestamp:e.timestamp,clientId:e.client_id??void 0,sessionId:e.session_id??void 0,parentSeqNum:e.parent_seq??void 0}));class p{state;env;#t=!1;constructor(t,e){this.state=t,this.env=e}async fetch(t){this.#p();const e=new URL(t.url);try{if(t.method==="POST"&&e.pathname==="/append")return await this.#e(t);if(t.method==="GET"&&e.pathname==="/since")return this.#i(e);if(t.method==="GET"&&e.pathname==="/range")return this.#a(e);if(t.method==="GET"&&e.pathname==="/size")return this.#o();if(t.method==="GET"&&e.pathname==="/state")return this.#c()}catch(s){return console.error("[event-log-do] request failed:",s),u(500,"INTERNAL_ERROR","internal error")}return u(404,"NOT_FOUND","unknown route")}async#e(t){let e;try{e=await t.json()}catch{return u(400,"BAD_REQUEST","invalid JSON body")}const s=p.#n(e);if(s)return u(400,"BAD_REQUEST",s);const{sql:r}=this.state.storage,{batchId:i}=e,o=()=>p.#s(r,e,i),{transaction:a}=this.state.storage;let c;try{c=typeof a=="function"?await a(o):await o()}catch(m){if(N(m))return u(409,"CONFLICT",m.message);throw m}return l({entries:c})}static async#s(t,e,s){let r;if(typeof s=="string"){r=await S(e.events);const a=p.#u(t,s);if(a){if(a.fingerprint!==r)throw q(`batchId "${s}" was already used for a different event batch`);return a.entries}}const i=Date.now(),o=[];for(const a of e.events){const d={seq:p.#l(t),type:a.type,payload:a.payload,timestamp:a.timestamp??i,clientId:a.clientId,sessionId:a.sessionId,parentSeqNum:a.parentSeqNum};p.#m(t,d),o.push(d)}if(typeof s=="string"&&r!==void 0){const a=o[0]?.seq,c=o.at(-1)?.seq;a!==void 0&&c!==void 0&&p.#d(t,s,a,c,r)}return o}static#n(t){if(!Array.isArray(t.events)||t.events.length===0)return"events[] with a non-empty string `type` required";if(t.batchId!==void 0&&(typeof t.batchId!="string"||t.batchId.length===0))return"batchId must be a non-empty string";for(const e of t.events){const s=p.#r(e);if(s!==void 0)return s}}static#r(t){if(typeof t.type!="string"||t.type.length===0)return"events[] with a non-empty string `type` required";if(t.timestamp!==void 0&&!Number.isFinite(t.timestamp))return"events[].timestamp must be a finite number";if(t.clientId!==void 0&&typeof t.clientId!="string")return"events[].clientId must be a string";if(t.sessionId!==void 0&&typeof t.sessionId!="string")return"events[].sessionId must be a string";if(t.parentSeqNum!==void 0&&(typeof t.parentSeqNum!="number"||!Number.isInteger(t.parentSeqNum)||t.parentSeqNum<0))return"events[].parentSeqNum must be a non-negative integer"}#i(t){const e=t.searchParams.get("seq"),s=e===null?0:Number(e);if(!Number.isFinite(s)||s<0)return u(400,"BAD_REQUEST","invalid seq");const{sql:r}=this.state.storage,i=r.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? ORDER BY seq ASC",s);return l({entries:f(i)})}#a(t){const e=t.searchParams.get("from"),s=e===null?0:Number(e),r=t.searchParams.get("limit"),i=r===null?50:Number(r);if(!Number.isFinite(s)||s<0||!Number.isFinite(i)||i<1||i>1e3)return u(400,"BAD_REQUEST","invalid from/limit");const{sql:o}=this.state.storage,a=o.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? ORDER BY seq ASC LIMIT ?",s,i+1),c=f(a),d=c.length>i,y={entries:d?c.slice(0,i):c,hasMore:d};return l(y)}#o(){const{sql:t}=this.state.storage,e=t.exec("SELECT COUNT(*) AS count FROM events"),r=E(e)[0]?.count??0;return l({count:r})}#c(){const{sql:t}=this.state.storage,e=t.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events ORDER BY seq ASC"),s=f(e),r=(s.at(-1)?.seq??-1)+1;return l({entries:s,nextSeq:r})}#p(){if(this.#t)return;const{sql:t}=this.state.storage;t.exec("CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, payload TEXT NOT NULL, timestamp INTEGER NOT NULL, client_id TEXT, session_id TEXT, parent_seq INTEGER)"),t.exec("CREATE TABLE IF NOT EXISTS event_batches (batch_id TEXT PRIMARY KEY, first_seq INTEGER NOT NULL, last_seq INTEGER NOT NULL, fingerprint TEXT NOT NULL)"),this.#t=!0}static#u(t,e){const s=t.exec("SELECT first_seq, last_seq, fingerprint FROM event_batches WHERE batch_id = ?",e),i=E(s)[0];if(!i)return;const o=t.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? AND seq <= ? ORDER BY seq ASC",i.first_seq,i.last_seq);return{entries:f(o),fingerprint:i.fingerprint}}static#d(t,e,s,r,i){t.exec("INSERT INTO event_batches (batch_id, first_seq, last_seq, fingerprint) VALUES (?, ?, ?, ?)",e,s,r,i)}static#l(t){const e=t.exec("SELECT COALESCE(MAX(seq), -1) + 1 AS next_seq FROM events");return E(e)[0]?.next_seq??0}static#m(t,e){const s=typeof e.parentSeqNum=="number"?e.parentSeqNum:null;t.exec("INSERT INTO events (seq, type, payload, timestamp, client_id, session_id, parent_seq) VALUES (?, ?, ?, ?, ?, ?, ?)",e.seq,e.type,JSON.stringify(e.payload),e.timestamp,e.clientId??null,e.sessionId??null,s)}}export{p as EventLogDO};
@@ -1 +0,0 @@
1
- class s{#a;constructor(t){this.#a=t.fetch}async append(t,a){const e=JSON.stringify({events:t,batchId:a?.batchId}),n=await this.#a(new Request("https://do/append",{method:"POST",headers:{"content-type":"application/json"},body:e}));if(!n.ok)throw await s.#e(n,"append");return(await n.json()).entries}async getSince(t){return(await this.#t(`/since?seq=${String(t)}`,"getSince")).entries}async getRange(t,a=50){return this.#t(`/range?from=${String(t)}&limit=${String(a)}`,"getRange")}async getSize(){return(await this.#t("/size","getSize")).count}async getState(){return this.#t("/state","getState")}async#t(t,a){const e=await this.#a(new Request(`https://do${t}`));if(!e.ok)throw await s.#e(e,a);return await e.json()}static async#e(t,a){try{const n=(await t.json()).error?.message??t.statusText;return new Error(`EventLogDO.${a} failed (${String(t.status)}): ${n}`)}catch{return new Error(`EventLogDO.${a} failed (${String(t.status)}): ${t.statusText}`)}}}export{s as EventLogDOClient};
@@ -1 +0,0 @@
1
- class e{#t;#i=0;#s;#r;constructor(t){this.#t=t}get watermark(){return this.#i}start(){if(this.#s!==void 0)return;const t=this.#t.pollInterval??5e3;this.#s=setInterval(()=>{this.#n().catch(()=>{})},t)}stop(){this.#s!==void 0&&(clearInterval(this.#s),this.#s=void 0)}async sync(){return this.#n()}async#n(){if(this.#r)return this.#r;const t=this.#e().finally(()=>{this.#r=void 0});return this.#r=t,t}async#e(){try{const t=await this.#t.fetchEventsSince(this.#i);if(t.length===0)return 0;this.#t.applyEvents(t);const s=this.#t.getTableDiffs();for(const i of s)this.#t.mirror.applyDiff(i);const r=t[t.length-1];return this.#i=r.seq+1,t.length}catch(t){return(this.#t.onError??console.error)(t),0}}}export{e as EventsSync};
@@ -1,4 +0,0 @@
1
- import{createSqlJsAdapter as y}from"../adapters/sqljs.mjs";import{applyDiffToDb as m,escapeIdentifier as a}from"./applyDiffToDb-C6ek5Elp.mjs";import{EventLog as b}from"./EventLog-B1-yhArT.mjs";const h="__lunora_mirror_meta",g=o=>{o.exec(`CREATE TABLE IF NOT EXISTS ${h} (
2
- key TEXT PRIMARY KEY NOT NULL,
3
- value TEXT NOT NULL
4
- )`)},T="schema_version",f=3,l=o=>typeof o=="bigint"||typeof o=="boolean"?"INTEGER":typeof o=="number"?Number.isInteger(o)?"INTEGER":"REAL":"TEXT";class E{#e;#t;#n;#s=new Set;#i=0;static create(e,t){const n=y(e);return new E({db:n,tables:t?.tables})}constructor(e){this.#e=e.db,this.#t={...e.tables},this.#n=new b({maxEntries:e.maxEventLogEntries}),g(this.#e),this.#o()}onChange(e){return this.#s.add(e),()=>{this.#s.delete(e)}}get eventLog(){return this.#n}get db(){return this.#e}get version(){return this.#i}applyDiff(e){if(e.changes.length===0)return;const t=this.#t[e.table]?.primaryKey??"id";this.#h(e),m(this.#e,e,t),this.#n.append("table-diff",e,[e]),this.#r()}query(e,t){return this.#e.query(e,t)}clearData(){const e=this.#a();this.#e.transaction(()=>{for(const{name:t}of e)this.#e.exec(`DELETE FROM ${a(t)}`)}),this.#r()}#r(){this.#i+=1;for(const e of this.#s)try{e()}catch{}}close(){this.#e.close(),this.#n.clear(),this.#s.clear()}registerTable(e,t){this.#t[e]=t}get mirroredTables(){return Object.keys(this.#t)}#a(){return this.#e.query(String.raw`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\_\_lunora\_%' ESCAPE '\' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'`)}#o(){if(this.#e.query(`SELECT key, value FROM ${h}`).find(s=>s.key===T)?.value===String(f))return;const n=this.#a();this.#e.transaction(()=>{for(const{name:s}of n)this.#e.exec(`DROP TABLE IF EXISTS ${a(s)}`);this.#e.exec(`INSERT OR REPLACE INTO ${h} (key, value) VALUES (?, ?)`,[T,String(f)])})}static#c(e,t){const n=new Set;for(const s of e.changes)if(s.type!=="delete")for(const c of Object.keys(s.data))c!==t&&n.add(c);return n}static#E(e,t,n){const s=new Map;for(const c of e.changes)if(!(c.type==="delete"||s.size===n.size))for(const r of n){if(r===t||s.has(r))continue;const i=c.data[r];i!=null&&s.set(r,l(i))}return s}static#l(e,t){for(const n of e.changes){const s=n.type==="delete"?n.id:n.data[t];if(s!=null)return l(s);if(n.type==="update")return l(n.id)}return"TEXT"}#h(e){const t=this.#t[e.table]?.primaryKey??"id",n=E.#c(e,t),s=E.#E(e,t,n);if(this.#e.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?",[e.table]).length===0){const r=E.#l(e,t);let i=`${a(t)} ${r==="INTEGER"?"INT":r} PRIMARY KEY NOT NULL`;for(const u of n)i+=`, ${a(u)} ${s.get(u)??"TEXT"}`;this.#e.exec(`CREATE TABLE IF NOT EXISTS ${a(e.table)} (${i})`)}else if(n.size>0){const r=new Set(this.#e.query(`PRAGMA table_info(${a(e.table)})`).map(i=>i.name));for(const i of n)r.has(i)||this.#e.exec(`ALTER TABLE ${a(e.table)} ADD COLUMN ${a(i)} ${s.get(i)??"TEXT"}`)}}}export{E as LocalMirror};
@@ -1 +0,0 @@
1
- const c=a=>{let e=a.initial();return{def:a,get state(){return Object.freeze(e)},setState(t){e=t},apply(t){e=a.handle(e,t)},reset(){e=a.initial()}}};class u{#t;#n;#i;#s;#e;constructor(e,t={}){this.#t=[...e],this.#e=this.#t.map(()=>0),this.#n=t.snapshotStore,this.#i=t.doClient,this.#s=t.unknownEventHandling??"warn"}get appliedSeq(){return this.#e.length>0?Math.min(...this.#e):0}applyEntries(e){let t=0;for(const n of e){let s=!1,i=!1;const r=[];for(const[o,h]of this.#t.entries()){const l=this.#e[o]??0;if(n.seq<l)continue;const p=h.state;h.apply(n),s=!0,h.state!==p&&(i=!0),r.push(o)}if(s){i||this.#r(n);for(const o of r)this.#e[o]=n.seq+1;t+=1}}return t}#r(e){const t=this.#s;if(typeof t=="function"){t(e);return}switch(t){case"ignore":return;case"fail":throw new Error(`MaterializerRuntime: unhandled event type "${e.type}" (seq ${String(e.seq)}). Configure \`unknownEventHandling\` to handle this event or change the strategy.`);default:console.warn(`[MaterializerRuntime] unhandled event type "${e.type}" (seq ${String(e.seq)}). The event was skipped. Configure \`unknownEventHandling\` if this is expected.`)}}async recoverFromSnapshots(){if(!this.#n)return 0;let e=0;for(const[t,n]of this.#t.entries()){const s=await this.#n.load(n.def.name);if(s!==null&&typeof s=="object"){const i=s;if(Number.isSafeInteger(i.appliedSeq)&&i.appliedSeq>=0&&i.state!==void 0){const r=i.appliedSeq;n.setState(i.state),this.#e[t]=r,r>e&&(e=r)}}}return e}async persistSnapshots(){if(this.#n)for(const[e,t]of this.#t.entries())await this.#n.save(t.def.name,{appliedSeq:this.#e[e]??0,state:t.state})}async initialize(){if(!this.#i)return 0;await this.recoverFromSnapshots();const e=this.#e.length>0?Math.min(...this.#e):0,t=await this.#i.getSince(e);return t.length===0?0:this.applyEntries(t)}async appendEvent(e){if(!this.#i)throw new Error("MaterializerRuntime.appendEvent requires a doClient — pass one in the constructor options.");const n=(await this.#i.append([e]))[0];if(!n)throw new Error("MaterializerRuntime.appendEvent: DO returned empty result");return this.applyEntries([n]),n}reset(){for(const[e,t]of this.#t.entries())this.#e[e]=0,t.reset()}get materializers(){return this.#t}}export{u as MaterializerRuntime,c as defineMaterializer};
@@ -1 +0,0 @@
1
- const y=e=>`fn_${e.replaceAll(/[/:.]/g,"_")}`,w=e=>Array.isArray(e)?e:e!==null&&typeof e=="object"?[e]:[],d=(e,c,i,a,p)=>{const f=y(i.__lunoraRef);c.registerTable(f,{});let s=new Set;return e.subscribe(i,a,u=>{const b=w(u),r=new Set,t=[];for(const n of b){const l=n,o=l.id;(typeof o=="string"||typeof o=="number")&&r.add(String(o)),t.push({type:"insert",data:l})}for(const n of s)r.has(n)||t.push({type:"delete",id:n});if(t.length===0){s=r;return}c.applyDiff({table:f,changes:t,timestamp:Date.now()}),s=r},{shardKey:p})};export{d as subscribeToMirror};