@lunora/replica 1.0.0-alpha.3 → 1.0.0-alpha.5

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.
@@ -0,0 +1,264 @@
1
+ class EventLog {
2
+ #entries = [];
3
+ /**
4
+ * Logical index of the first LIVE entry within `#entries` (REPLICA-06
5
+ * perf): entries at `[0, #headOffset)` have already been evicted by the
6
+ * cap but are not yet physically removed from the backing array. Bumping
7
+ * this offset is O(1), so a capped append never pays the O(n) cost of
8
+ * shifting the retained array — see `#enforceCap`.
9
+ */
10
+ #headOffset = 0;
11
+ #nextSeq = 0;
12
+ // eslint-disable-next-line unicorn/no-null -- public contract uses `null` for an empty log head
13
+ #headSeq = null;
14
+ #maxEntries;
15
+ constructor(options) {
16
+ this.#maxEntries = EventLog.#validateMaxEntries(options?.maxEntries);
17
+ }
18
+ /**
19
+ * Validate `maxEntries` as an invariant: `undefined` (uncapped) or a
20
+ * non-negative safe integer. Negative values would evict everything,
21
+ * fractional/NaN/Infinity values would leave the log over capacity or
22
+ * effectively disable the cap.
23
+ */
24
+ static #validateMaxEntries(maxEntries) {
25
+ if (maxEntries === void 0) {
26
+ return void 0;
27
+ }
28
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 0) {
29
+ throw new RangeError("maxEntries must be a non-negative safe integer");
30
+ }
31
+ return maxEntries;
32
+ }
33
+ /**
34
+ * Entries currently considered live (excludes the evicted-but-not-yet-
35
+ * compacted prefix). ALWAYS returns a fresh copy — never the internal
36
+ * `#entries` array by reference — so a caller iterating the result (e.g.
37
+ * `EventSource.replayFromLog` replaying a log into itself) can't observe
38
+ * later appends made to `#entries` while iterating.
39
+ */
40
+ #liveEntries() {
41
+ return this.#entries.slice(this.#headOffset);
42
+ }
43
+ // ── Mutators ──────────────────────────────────────────────────────
44
+ /**
45
+ * Evict the oldest entries so the LIVE entry count never exceeds
46
+ * `#maxEntries`. `headSeq`/`nextSeq` are untouched — they're independent
47
+ * counters, not derived from the entries array — so appends after an
48
+ * eviction continue the same sequence.
49
+ *
50
+ * Eviction itself is O(1) (bump `#headOffset`) rather than an O(n)
51
+ * `splice(0, n)` on every capped append. The dead prefix is compacted
52
+ * away in one pass once it reaches half the backing array, which bounds
53
+ * memory growth while keeping the amortized cost of both append and
54
+ * compaction O(1) per entry.
55
+ */
56
+ #enforceCap() {
57
+ if (this.#maxEntries === void 0) {
58
+ return;
59
+ }
60
+ const liveCount = this.#entries.length - this.#headOffset;
61
+ const overflow = liveCount - this.#maxEntries;
62
+ if (overflow > 0) {
63
+ this.#headOffset += overflow;
64
+ }
65
+ if (this.#headOffset > 0 && this.#headOffset >= this.#entries.length - this.#headOffset) {
66
+ this.#entries = this.#entries.slice(this.#headOffset);
67
+ this.#headOffset = 0;
68
+ }
69
+ }
70
+ append(typeOrEvent, payload, tableDiffs, options) {
71
+ const type = typeof typeOrEvent === "string" ? typeOrEvent : typeOrEvent.type;
72
+ const pl = typeof typeOrEvent === "string" ? payload : typeOrEvent.payload;
73
+ let diffs;
74
+ let resolvedOptions;
75
+ if (typeof typeOrEvent === "string") {
76
+ diffs = tableDiffs;
77
+ resolvedOptions = options;
78
+ } else {
79
+ diffs = void 0;
80
+ resolvedOptions = payload;
81
+ }
82
+ const parentSeqNumber = resolvedOptions?.parentSeqNum ?? this.#headSeq ?? void 0;
83
+ const seq = this.#nextSeq;
84
+ this.#nextSeq += 1;
85
+ const entry = {
86
+ seq,
87
+ type,
88
+ payload: pl,
89
+ timestamp: resolvedOptions?.timestamp ?? Date.now(),
90
+ tableDiffs: diffs,
91
+ clientId: resolvedOptions?.clientId,
92
+ sessionId: resolvedOptions?.sessionId,
93
+ parentSeqNum: parentSeqNumber
94
+ };
95
+ this.#entries.push(entry);
96
+ this.#headSeq = entry.seq;
97
+ this.#enforceCap();
98
+ return entry;
99
+ }
100
+ /**
101
+ * Atomically append multiple events to the log.
102
+ *
103
+ * All events are assigned sequential global sequence numbers and
104
+ * automatically wired as a causal chain (each event's `parentSeqNum`
105
+ * points to the previous event in the batch, or to the log head for
106
+ * the first event).
107
+ * @param events An array of events to commit atomically.
108
+ * @returns The newly created entries in order.
109
+ */
110
+ commitAll(events) {
111
+ if (events.length === 0) {
112
+ return [];
113
+ }
114
+ const entries = [];
115
+ for (const event of events) {
116
+ const { type } = event;
117
+ const payload = "payload" in event ? event.payload : void 0;
118
+ const ts = "timestamp" in event ? event.timestamp : Date.now();
119
+ const parentSeqNumber = entries.at(-1)?.seq ?? this.#headSeq ?? void 0;
120
+ const seq = this.#nextSeq;
121
+ this.#nextSeq += 1;
122
+ const entry = {
123
+ seq,
124
+ type,
125
+ payload,
126
+ timestamp: ts,
127
+ parentSeqNum: parentSeqNumber
128
+ };
129
+ this.#entries.push(entry);
130
+ entries.push(entry);
131
+ }
132
+ this.#headSeq = entries.at(-1)?.seq ?? this.#headSeq;
133
+ this.#enforceCap();
134
+ return entries;
135
+ }
136
+ /**
137
+ * Replace the log contents with a previously captured snapshot.
138
+ * This is the restore counterpart of {@link EventLog#snapshot}.
139
+ * Restores `headSeq` from the snapshot so auto-parenting continues
140
+ * after restore.
141
+ *
142
+ * Runs `#enforceCap()` after restoring so a snapshot captured under a
143
+ * different (or no) `maxEntries` can never leave this log over its
144
+ * configured capacity.
145
+ */
146
+ load(snapshot) {
147
+ this.#entries = [...snapshot.entries];
148
+ this.#headOffset = 0;
149
+ this.#nextSeq = snapshot.nextSeq;
150
+ this.#headSeq = snapshot.headSeq;
151
+ this.#enforceCap();
152
+ }
153
+ // ── Queries ───────────────────────────────────────────────────────
154
+ /**
155
+ * Return **all** entries whose `seq >= sinceSeq`.
156
+ * Useful for catch-up: "give me everything since my last watermark".
157
+ */
158
+ getSince(sinceSeq) {
159
+ const live = this.#liveEntries();
160
+ if (sinceSeq <= 0) {
161
+ return live;
162
+ }
163
+ const first = live.findIndex((entry) => entry.seq >= sinceSeq);
164
+ return first === -1 ? [] : live.slice(first);
165
+ }
166
+ /**
167
+ * Paginated read starting at `fromSeq`.
168
+ * @returns `{ entries, hasMore }` where `hasMore` is `true` when more
169
+ * entries exist beyond the requested page.
170
+ */
171
+ getFrom(fromSeq, limit = 50) {
172
+ const entries = this.#entries;
173
+ const start = this.#headOffset;
174
+ const first = entries.findIndex((entry, index) => index >= start && entry.seq >= fromSeq);
175
+ if (first === -1) {
176
+ return { entries: [], hasMore: false };
177
+ }
178
+ const end = Math.min(first + limit, entries.length);
179
+ return {
180
+ entries: entries.slice(first, end),
181
+ hasMore: end < entries.length
182
+ };
183
+ }
184
+ /**
185
+ * Return all entries as a snapshot suitable for serialisation.
186
+ */
187
+ snapshot() {
188
+ return {
189
+ entries: this.#liveEntries(),
190
+ nextSeq: this.#nextSeq,
191
+ headSeq: this.#headSeq
192
+ };
193
+ }
194
+ /** Number of entries currently in the log. */
195
+ get size() {
196
+ return this.#entries.length - this.#headOffset;
197
+ }
198
+ /** The next sequence number that will be assigned. */
199
+ get nextSeq() {
200
+ return this.#nextSeq;
201
+ }
202
+ /** Return `true` when there are no entries. */
203
+ get isEmpty() {
204
+ return this.size === 0;
205
+ }
206
+ /**
207
+ * The sequence number of the last (most recent) entry, or `null`
208
+ * when the log is empty. Used internally for auto-parenting and
209
+ * exposed for consumers that need the causal head.
210
+ */
211
+ get headSeq() {
212
+ return this.#headSeq;
213
+ }
214
+ /** Remove all entries (primarily for testing). */
215
+ clear() {
216
+ this.#entries = [];
217
+ this.#headOffset = 0;
218
+ this.#nextSeq = 0;
219
+ this.#headSeq = null;
220
+ }
221
+ /**
222
+ * Discard all entries with `seq < floorSeq` (REPLICA-06).
223
+ *
224
+ * `headSeq`/`nextSeq` are untouched (they're independent counters), so
225
+ * appends after a truncation continue the same sequence uninterrupted.
226
+ *
227
+ * **Caller-driven, not automatic**: only call this after the truncated
228
+ * range has already been durably captured elsewhere (a snapshot, a
229
+ * server-side `EventLogDO`) — truncating without such a floor makes any
230
+ * future `getSince`/`getFrom`/`EventSource.replayFromLog` call for a
231
+ * watermark below `floorSeq` silently miss the discarded entries. This is
232
+ * the hook the caller ties to snapshot persistence; the log itself has no
233
+ * concept of "already durably persisted".
234
+ *
235
+ * `floorSeq` must be a non-negative safe integer — `NaN` would make
236
+ * every comparison false and silently clear the entire log.
237
+ */
238
+ truncateBelow(floorSeq) {
239
+ if (!Number.isSafeInteger(floorSeq) || floorSeq < 0) {
240
+ throw new RangeError("floorSeq must be a non-negative safe integer");
241
+ }
242
+ const live = this.#liveEntries();
243
+ const cutoff = live.findIndex((entry) => entry.seq >= floorSeq);
244
+ this.#entries = cutoff === -1 ? [] : live.slice(cutoff);
245
+ this.#headOffset = 0;
246
+ }
247
+ /**
248
+ * Return an async generator that yields every entry starting from
249
+ * `fromSeq` (default `0` = all entries).
250
+ *
251
+ * Because `EventLog` is purely in-memory, the generator yields all
252
+ * matching entries synchronously on first iteration and then completes.
253
+ * For a streaming / push-based variant see {@link EventSource.events}.
254
+ */
255
+ // eslint-disable-next-line @typescript-eslint/require-await -- kept async so callers can uniformly `for await` over any event stream
256
+ async *events(fromSeq = 0) {
257
+ const entries = this.getSince(fromSeq);
258
+ for (const entry of entries) {
259
+ yield entry;
260
+ }
261
+ }
262
+ }
263
+
264
+ export { EventLog };
@@ -1,3 +1,31 @@
1
+ const IDEMPOTENCY_CONFLICT = /* @__PURE__ */ Symbol("lunora.replica.event-log-do.idempotency-conflict");
2
+ const createIdempotencyConflictError = (message) => {
3
+ const error = new Error(message);
4
+ Object.defineProperty(error, IDEMPOTENCY_CONFLICT, { value: true });
5
+ return error;
6
+ };
7
+ const isIdempotencyConflictError = (error) => error instanceof Error && IDEMPOTENCY_CONFLICT in error;
8
+ const canonicalizeForFingerprint = (value) => {
9
+ if (Array.isArray(value)) {
10
+ return value.map((item) => canonicalizeForFingerprint(item));
11
+ }
12
+ if (value !== null && typeof value === "object") {
13
+ const record = value;
14
+ const sortedKeys = Object.keys(record).toSorted((a, b) => a.localeCompare(b));
15
+ const result = {};
16
+ for (const key of sortedKeys) {
17
+ result[key] = canonicalizeForFingerprint(record[key]);
18
+ }
19
+ return result;
20
+ }
21
+ return value;
22
+ };
23
+ const fingerprintBatch = async (events) => {
24
+ const canonical = JSON.stringify(canonicalizeForFingerprint(events));
25
+ const bytes = new TextEncoder().encode(canonical);
26
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
27
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
28
+ };
1
29
  const toArray = (cursor) => {
2
30
  if (typeof cursor.toArray === "function") {
3
31
  return cursor.toArray();
@@ -85,34 +113,130 @@ class EventLogDO {
85
113
  }
86
114
  );
87
115
  }
88
- if (!Array.isArray(body.events) || body.events.length === 0 || body.events.some((eventRecord) => typeof eventRecord.type !== "string" || eventRecord.type.length === 0)) {
116
+ const validationError = EventLogDO.#validateAppendRequest(body);
117
+ if (validationError) {
89
118
  return Response.json(
90
- { error: { code: "BAD_REQUEST", message: "events[] with a non-empty string `type` required" } },
119
+ { error: { code: "BAD_REQUEST", message: validationError } },
91
120
  {
92
121
  status: 400,
93
122
  headers: { "content-type": "application/json" }
94
123
  }
95
124
  );
96
125
  }
97
- const entries = [];
98
126
  const { sql } = this.state.storage;
127
+ const { batchId } = body;
128
+ const runBatch = () => EventLogDO.#runAppendBatch(sql, body, batchId);
129
+ const { transaction } = this.state.storage;
130
+ let entries;
131
+ try {
132
+ entries = typeof transaction === "function" ? await transaction(runBatch) : await runBatch();
133
+ } catch (error) {
134
+ if (isIdempotencyConflictError(error)) {
135
+ return Response.json({ error: { code: "CONFLICT", message: error.message } }, { status: 409, headers: { "content-type": "application/json" } });
136
+ }
137
+ throw error;
138
+ }
139
+ const response = { entries };
140
+ return Response.json(response, {
141
+ status: 200,
142
+ headers: { "content-type": "application/json" }
143
+ });
144
+ }
145
+ /**
146
+ * Execute one `/append` batch: idempotent lookup (fingerprint-checked) or
147
+ * insert + record. Extracted out of `#handleAppend` (which only wires up
148
+ * the HTTP request/response and the transaction wrapper) to keep that
149
+ * method's cognitive complexity within budget.
150
+ *
151
+ * Idempotent replay: a batch already persisted under `batchId` returns
152
+ * the originally-persisted entries instead of inserting a duplicate copy
153
+ * — but ONLY when the incoming request's fingerprint matches what was
154
+ * persisted under that key. A `batchId` reused for a genuinely different
155
+ * event batch throws an idempotency-conflict error instead of silently
156
+ * dropping the new events and returning unrelated entries.
157
+ */
158
+ static async #runAppendBatch(sql, body, batchId) {
159
+ let fingerprint;
160
+ if (typeof batchId === "string") {
161
+ fingerprint = await fingerprintBatch(body.events);
162
+ const existing = EventLogDO.#findBatch(sql, batchId);
163
+ if (existing) {
164
+ if (existing.fingerprint !== fingerprint) {
165
+ throw createIdempotencyConflictError(`batchId "${batchId}" was already used for a different event batch`);
166
+ }
167
+ return existing.entries;
168
+ }
169
+ }
99
170
  const now = Date.now();
171
+ const entries = [];
100
172
  for (const eventRecord of body.events) {
101
173
  const seq = EventLogDO.#nextSeq(sql);
102
174
  const entry = {
103
175
  seq,
104
176
  type: eventRecord.type,
105
177
  payload: eventRecord.payload,
106
- timestamp: eventRecord.timestamp ?? now
178
+ timestamp: eventRecord.timestamp ?? now,
179
+ clientId: eventRecord.clientId,
180
+ sessionId: eventRecord.sessionId,
181
+ parentSeqNum: eventRecord.parentSeqNum
107
182
  };
108
183
  EventLogDO.#insertEvent(sql, entry);
109
184
  entries.push(entry);
110
185
  }
111
- const response = { entries };
112
- return Response.json(response, {
113
- status: 200,
114
- headers: { "content-type": "application/json" }
115
- });
186
+ if (typeof batchId === "string" && fingerprint !== void 0) {
187
+ const firstSeq = entries[0]?.seq;
188
+ const lastSeq = entries.at(-1)?.seq;
189
+ if (firstSeq !== void 0 && lastSeq !== void 0) {
190
+ EventLogDO.#recordBatch(sql, batchId, firstSeq, lastSeq, fingerprint);
191
+ }
192
+ }
193
+ return entries;
194
+ }
195
+ /**
196
+ * Validate an `/append` request body up front so malformed input surfaces
197
+ * as a clean 400 instead of a 500 from the generic catch handler (e.g. a
198
+ * non-string `type` or a non-finite `timestamp` landing in a NOT NULL /
199
+ * INTEGER column).
200
+ * @returns An error message, or `undefined` when the body is valid.
201
+ */
202
+ static #validateAppendRequest(body) {
203
+ if (!Array.isArray(body.events) || body.events.length === 0) {
204
+ return "events[] with a non-empty string `type` required";
205
+ }
206
+ if (body.batchId !== void 0 && (typeof body.batchId !== "string" || body.batchId.length === 0)) {
207
+ return "batchId must be a non-empty string";
208
+ }
209
+ for (const eventRecord of body.events) {
210
+ const error = EventLogDO.#validateEventRecord(eventRecord);
211
+ if (error !== void 0) {
212
+ return error;
213
+ }
214
+ }
215
+ return void 0;
216
+ }
217
+ /**
218
+ * Validate a single `/append` event record — split out of
219
+ * {@link EventLogDO.#validateAppendRequest} to keep each function's
220
+ * cognitive complexity down.
221
+ * @returns An error message, or `undefined` when the record is valid.
222
+ */
223
+ static #validateEventRecord(eventRecord) {
224
+ if (typeof eventRecord.type !== "string" || eventRecord.type.length === 0) {
225
+ return "events[] with a non-empty string `type` required";
226
+ }
227
+ if (eventRecord.timestamp !== void 0 && !Number.isFinite(eventRecord.timestamp)) {
228
+ return "events[].timestamp must be a finite number";
229
+ }
230
+ if (eventRecord.clientId !== void 0 && typeof eventRecord.clientId !== "string") {
231
+ return "events[].clientId must be a string";
232
+ }
233
+ if (eventRecord.sessionId !== void 0 && typeof eventRecord.sessionId !== "string") {
234
+ return "events[].sessionId must be a string";
235
+ }
236
+ if (eventRecord.parentSeqNum !== void 0 && (typeof eventRecord.parentSeqNum !== "number" || !Number.isInteger(eventRecord.parentSeqNum) || eventRecord.parentSeqNum < 0)) {
237
+ return "events[].parentSeqNum must be a non-negative integer";
238
+ }
239
+ return void 0;
116
240
  }
117
241
  /** GET /since?seq=N — return entries with seq >= N. */
118
242
  #handleSince(url) {
@@ -203,8 +327,30 @@ class EventLogDO {
203
327
  sql.exec(
204
328
  "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)"
205
329
  );
330
+ sql.exec(
331
+ "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)"
332
+ );
206
333
  this.#initialized = true;
207
334
  }
335
+ /** Look up a previously-persisted batch by its idempotency key. */
336
+ static #findBatch(sql, batchId) {
337
+ const batchCursor = sql.exec("SELECT first_seq, last_seq, fingerprint FROM event_batches WHERE batch_id = ?", batchId);
338
+ const batchRows = toArray(batchCursor);
339
+ const batchRow = batchRows[0];
340
+ if (!batchRow) {
341
+ return void 0;
342
+ }
343
+ const cursor = sql.exec(
344
+ "SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? AND seq <= ? ORDER BY seq ASC",
345
+ batchRow.first_seq,
346
+ batchRow.last_seq
347
+ );
348
+ return { entries: rowsToEntries(cursor), fingerprint: batchRow.fingerprint };
349
+ }
350
+ /** Record a persisted batch's seq range and request fingerprint under its idempotency key. */
351
+ static #recordBatch(sql, batchId, firstSeq, lastSeq, fingerprint) {
352
+ sql.exec("INSERT INTO event_batches (batch_id, first_seq, last_seq, fingerprint) VALUES (?, ?, ?, ?)", batchId, firstSeq, lastSeq, fingerprint);
353
+ }
208
354
  /** Get the next available sequence number. */
209
355
  static #nextSeq(sql) {
210
356
  const cursor = sql.exec("SELECT COALESCE(MAX(seq), -1) + 1 AS next_seq FROM events");
@@ -6,10 +6,16 @@ class EventLogDOClient {
6
6
  // ── Append ─────────────────────────────────────────────────────────
7
7
  /**
8
8
  * Append one or more events to the log.
9
+ * @param events The events to append.
10
+ * @param options Idempotency controls for the batch.
11
+ * @param options.batchId Optional idempotency key for the whole batch — a
12
+ * retried `append` call with the same `batchId` (e.g. after a network
13
+ * timeout that hid a successful response) returns the originally-persisted
14
+ * entries instead of inserting duplicates.
9
15
  * @returns The persisted entries with their assigned `seq` numbers.
10
16
  */
11
- async append(events) {
12
- const body = JSON.stringify({ events });
17
+ async append(events, options) {
18
+ const body = JSON.stringify({ events, batchId: options?.batchId });
13
19
  const response = await this.#fetch(
14
20
  new Request("https://do/append", {
15
21
  method: "POST",
@@ -1,10 +1,11 @@
1
1
  import { EventEmitter } from './EventEmitter-CMZfct03.mjs';
2
- import { EventLog } from './EventLog-zMy7AYP4.mjs';
2
+ import { EventLog } from './EventLog-CnK-3Wge.mjs';
3
3
 
4
+ const UNHANDLED = /* @__PURE__ */ Symbol("lunora.replica.event-source.unhandled");
4
5
  class EventSource {
5
6
  // eslint-disable-next-line unicorn/prefer-event-target -- EventEmitter is the library's typed public API
6
7
  emitter = new EventEmitter();
7
- log = new EventLog();
8
+ log;
8
9
  #state;
9
10
  #reducer;
10
11
  #replayed = false;
@@ -22,6 +23,7 @@ class EventSource {
22
23
  this.#state = { ...initialState };
23
24
  this.#reducer = reducer;
24
25
  this.#unknownEventHandling = options?.unknownEventHandling ?? "warn";
26
+ this.log = new EventLog({ maxEntries: options?.maxLogEntries });
25
27
  }
26
28
  // ── Public API ────────────────────────────────────────────────────
27
29
  /**
@@ -49,8 +51,39 @@ class EventSource {
49
51
  pl = typeOrEvent.payload;
50
52
  resolvedOptions = payload;
51
53
  }
52
- const entry = this.log.append(type, pl, void 0, resolvedOptions);
53
- this.#applyEntry(entry);
54
+ const candidate = {
55
+ seq: this.log.nextSeq,
56
+ type,
57
+ payload: pl,
58
+ timestamp: Date.now(),
59
+ clientId: resolvedOptions?.clientId,
60
+ sessionId: resolvedOptions?.sessionId,
61
+ parentSeqNum: resolvedOptions?.parentSeqNum ?? this.log.headSeq ?? void 0
62
+ };
63
+ let reduced;
64
+ try {
65
+ reduced = this.#reducer(this.#state, candidate);
66
+ } catch (error) {
67
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
68
+ this.emitter.emit("replay-error", {
69
+ entry: candidate,
70
+ error: normalizedError
71
+ });
72
+ throw normalizedError;
73
+ }
74
+ let handledByCallback = true;
75
+ if (reduced === UNHANDLED) {
76
+ handledByCallback = this.#handleUnknown(candidate);
77
+ }
78
+ const entry = this.log.append(type, pl, void 0, { ...resolvedOptions, timestamp: candidate.timestamp });
79
+ if (reduced === UNHANDLED) {
80
+ if (handledByCallback) {
81
+ this.emitter.emit("state-changed", { state: this.#state, entry });
82
+ }
83
+ return entry;
84
+ }
85
+ this.#state = reduced;
86
+ this.emitter.emit("state-changed", { state: this.#state, entry });
54
87
  return entry;
55
88
  }
56
89
  /**
@@ -65,7 +98,10 @@ class EventSource {
65
98
  const entries = log.getSince(this.#lastAppliedSeq + 1);
66
99
  for (const entry of entries) {
67
100
  try {
68
- this.#state = this.#reducer(this.#state, entry);
101
+ const reduced = this.#reducer(this.#state, entry);
102
+ if (reduced !== UNHANDLED) {
103
+ this.#state = reduced;
104
+ }
69
105
  this.log.append(entry.type, entry.payload, entry.tableDiffs, {
70
106
  clientId: entry.clientId,
71
107
  sessionId: entry.sessionId,
@@ -152,22 +188,12 @@ class EventSource {
152
188
  }
153
189
  }
154
190
  // ── Internal ──────────────────────────────────────────────────────
155
- #applyEntry(entry) {
156
- const stateBefore = this.#state;
157
- try {
158
- this.#state = this.#reducer(this.#state, entry);
159
- } catch (error) {
160
- this.emitter.emit("replay-error", {
161
- entry,
162
- error: error instanceof Error ? error : new Error(String(error))
163
- });
164
- return;
165
- }
166
- if (this.#state === stateBefore && !this.#handleUnknown(entry)) {
167
- return;
168
- }
169
- this.emitter.emit("state-changed", { state: this.#state, entry });
170
- }
191
+ /**
192
+ * Explicit-sentinel detection (not `state === stateBefore` reference
193
+ * equality) is what lets a reducer legitimately return `state` unchanged
194
+ * for a type it DOES recognise without being misclassified as unhandled
195
+ * (REPLICA-07).
196
+ */
171
197
  #handleUnknown(entry) {
172
198
  const strategy = this.#unknownEventHandling;
173
199
  if (typeof strategy === "function") {
@@ -192,4 +218,4 @@ class EventSource {
192
218
  }
193
219
  }
194
220
 
195
- export { EventSource };
221
+ export { EventSource, UNHANDLED };
@@ -3,8 +3,13 @@ class EventsSync {
3
3
  /** The highest `seq + 1` that has been applied. Starts at `0`. */
4
4
  #watermark = 0;
5
5
  #timer;
6
- /** Guards against overlapping poll cycles (e.g. slow fetch). */
7
- #running = false;
6
+ /**
7
+ * The in-flight poll cycle, or `undefined` when idle. A concurrent
8
+ * `sync()`/timer tick AWAITS this instead of no-op'ing (REPLICA-08) —
9
+ * previously a concurrent call returned `0` immediately without waiting
10
+ * for the in-progress cycle to actually finish.
11
+ */
12
+ #inFlight;
8
13
  // ── Constructor ─────────────────────────────────────────────────────
9
14
  constructor(options) {
10
15
  this.#options = options;
@@ -56,34 +61,61 @@ class EventsSync {
56
61
  }
57
62
  // ── Internal ────────────────────────────────────────────────────────
58
63
  /**
59
- * One poll cycle: fetch → apply → diff → mirror.
64
+ * Entry point for a poll cycle. A cycle already in flight is AWAITED
65
+ * (not restarted, not no-op'd) so a `sync()` racing a timer tick — or two
66
+ * concurrent `sync()` calls — observes the real outcome of the one cycle
67
+ * that actually runs (REPLICA-08).
60
68
  */
61
69
  async #poll() {
62
- if (this.#running) {
63
- return 0;
70
+ if (this.#inFlight) {
71
+ return this.#inFlight;
64
72
  }
65
- this.#running = true;
73
+ const promise = this.#pollOnce().finally(() => {
74
+ this.#inFlight = void 0;
75
+ });
76
+ this.#inFlight = promise;
77
+ return promise;
78
+ }
79
+ /**
80
+ * One poll cycle: fetch → (apply → diff → mirror) per event.
81
+ *
82
+ * Each event is driven through the FULL pipeline — `applyEvents`,
83
+ * `getTableDiffs`, and `mirror.applyDiff` — atomically before the
84
+ * watermark advances past it (REPLICA-08). Advancing the watermark any
85
+ * earlier (e.g. right after `applyEvents`) would let a later throw from
86
+ * `getTableDiffs`/`mirror.applyDiff` skip mirror delivery for that event
87
+ * PERMANENTLY, since the next poll would never re-fetch it. Keeping the
88
+ * watermark pinned to the last event whose entire pipeline succeeded
89
+ * means the next poll re-fetches exactly the unapplied remainder — never
90
+ * re-applying a fully-succeeded event, never silently dropping one that
91
+ * partially failed.
92
+ */
93
+ async #pollOnce() {
66
94
  try {
67
95
  const events = await this.#options.fetchEventsSince(this.#watermark);
68
96
  if (events.length === 0) {
69
97
  return 0;
70
98
  }
71
- this.#options.applyEvents(events);
72
- const lastEvent = events[events.length - 1];
73
- if (lastEvent) {
74
- this.#watermark = lastEvent.seq + 1;
75
- }
76
- const diffs = this.#options.getTableDiffs();
77
- for (const diff of diffs) {
78
- this.#options.mirror.applyDiff(diff);
99
+ let appliedCount = 0;
100
+ try {
101
+ for (const event of events) {
102
+ this.#options.applyEvents([event]);
103
+ const diffs = this.#options.getTableDiffs();
104
+ for (const diff of diffs) {
105
+ this.#options.mirror.applyDiff(diff);
106
+ }
107
+ this.#watermark = event.seq + 1;
108
+ appliedCount += 1;
109
+ }
110
+ } catch (error) {
111
+ const onError = this.#options.onError ?? console.error;
112
+ onError(error);
79
113
  }
80
- return events.length;
114
+ return appliedCount;
81
115
  } catch (error) {
82
116
  const onError = this.#options.onError ?? console.error;
83
117
  onError(error);
84
118
  return 0;
85
- } finally {
86
- this.#running = false;
87
119
  }
88
120
  }
89
121
  }