@powerhousedao/reactor 6.2.3-dev.4 → 6.2.3-dev.41

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 (42) hide show
  1. package/dist/{build-worker-executor-DnU3ZU4H.js → build-worker-executor-Bonu2jUB.js} +30 -8
  2. package/dist/build-worker-executor-Bonu2jUB.js.map +1 -0
  3. package/dist/{document-indexer-C5Gsa1B3.js → document-indexer-B3yKal-y.js} +981 -104
  4. package/dist/document-indexer-B3yKal-y.js.map +1 -0
  5. package/dist/entry.d.ts +2 -1
  6. package/dist/entry.js +13 -6
  7. package/dist/entry.js.map +1 -1
  8. package/dist/{errors-D3S6Eysd.js → errors-BM_ULVWd.js} +9 -1
  9. package/dist/{errors-D3S6Eysd.js.map → errors-BM_ULVWd.js.map} +1 -1
  10. package/dist/{drive-container-types-yZrksiJR.js → forwarding-logger-BKDP4JnD.js} +2650 -367
  11. package/dist/forwarding-logger-BKDP4JnD.js.map +1 -0
  12. package/dist/index.d.ts +4819 -3185
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4146 -892
  15. package/dist/index.js.map +1 -1
  16. package/dist/projection-entry.d.ts +2 -1
  17. package/dist/projection-entry.js +99 -21
  18. package/dist/projection-entry.js.map +1 -1
  19. package/dist/{projection-shard-manager-CPQc5XHL.js → projection-shard-manager-BZPCQK-4.js} +24 -4
  20. package/dist/projection-shard-manager-BZPCQK-4.js.map +1 -0
  21. package/dist/{projection-worker-wI4PwcV2.js → projection-worker-D8uNFdwX.js} +1 -1
  22. package/dist/{projection-worker-wI4PwcV2.js.map → projection-worker-D8uNFdwX.js.map} +1 -1
  23. package/dist/{transport-CuogVKN_.js → transport-B3G50KVd.js} +1 -1
  24. package/dist/{transport-CuogVKN_.js.map → transport-B3G50KVd.js.map} +1 -1
  25. package/dist/{transport-ByGviWdZ.js → transport-CZDHzfvy.js} +1 -1
  26. package/dist/{transport-ByGviWdZ.js.map → transport-CZDHzfvy.js.map} +1 -1
  27. package/dist/{worker-handle-CrERzl8s.js → worker-handle-CefOwokd.js} +30 -17
  28. package/dist/worker-handle-CefOwokd.js.map +1 -0
  29. package/package.json +20 -10
  30. package/dist/build-worker-executor-DnU3ZU4H.js.map +0 -1
  31. package/dist/document-indexer-C5Gsa1B3.js.map +0 -1
  32. package/dist/drive-container-types-yZrksiJR.js.map +0 -1
  33. package/dist/error-info-Cpu4OY3o.js +0 -62
  34. package/dist/error-info-Cpu4OY3o.js.map +0 -1
  35. package/dist/forwarding-logger-BBkMSxuJ.js +0 -85
  36. package/dist/forwarding-logger-BBkMSxuJ.js.map +0 -1
  37. package/dist/projection-shard-manager-CPQc5XHL.js.map +0 -1
  38. package/dist/types-DMKLa0Ok.js +0 -33
  39. package/dist/types-DMKLa0Ok.js.map +0 -1
  40. package/dist/worker-HPysBfhx.js +0 -22
  41. package/dist/worker-HPysBfhx.js.map +0 -1
  42. package/dist/worker-handle-CrERzl8s.js.map +0 -1
@@ -1,17 +1,379 @@
1
- import { L as DocumentExistence } from "./drive-container-types-yZrksiJR.js";
2
- import { n as ReactorEventTypes } from "./types-DMKLa0Ok.js";
3
- import { createAuthState, isDenied } from "@powerhousedao/shared/document-model";
1
+ import { At as ReactorEventTypes, D as acquirePurgeLocks, M as createKyselyWatermarkProbe, O as findPurged, Pt as yieldToMain, _t as DocumentNotFoundError, j as SettledWatermark } from "./forwarding-logger-BKDP4JnD.js";
2
+ import { createAuthState, isDenied, isPurgeMarker } from "@powerhousedao/shared/document-model";
4
3
  import { v4 } from "uuid";
5
4
  import { childLogger } from "document-model";
5
+ //#region src/catch-up/scheduler.ts
6
+ const noHooks = {
7
+ onSwept: () => {},
8
+ describeSessions: () => Promise.resolve([])
9
+ };
10
+ function isCatchUpConsumer(value) {
11
+ if (typeof value !== "object" || value === null) return false;
12
+ const candidate = value;
13
+ return typeof candidate.sweep === "function" && typeof candidate.consumerId === "string" && typeof candidate.appliedThrough === "number";
14
+ }
15
+ /** Periodically sweeps every consumer up to the settled watermark. */
16
+ var CatchUpScheduler = class {
17
+ watermark;
18
+ operationIndex;
19
+ config;
20
+ logger;
21
+ hooks;
22
+ fixed = [];
23
+ sources = [];
24
+ sourced = /* @__PURE__ */ new Map();
25
+ timer;
26
+ running;
27
+ started = false;
28
+ stopped = false;
29
+ warnedStallSince;
30
+ statusSources = [];
31
+ constructor(watermark, operationIndex, config, logger, hooks = noHooks) {
32
+ this.watermark = watermark;
33
+ this.operationIndex = operationIndex;
34
+ this.config = config;
35
+ this.logger = logger;
36
+ this.hooks = hooks;
37
+ }
38
+ get settledWatermark() {
39
+ return this.watermark;
40
+ }
41
+ addConsumer(consumer, thread) {
42
+ if (this.fixed.some((entry) => entry.consumer === consumer)) return;
43
+ this.fixed.push({
44
+ consumer,
45
+ thread,
46
+ lastAdvanceUtcMs: Date.now(),
47
+ warnedHeld: false
48
+ });
49
+ this.ensureTimer();
50
+ }
51
+ /** Consumers read on every tick, so models registered later are swept too. */
52
+ addSource(consumers, thread) {
53
+ this.sources.push({
54
+ consumers,
55
+ thread
56
+ });
57
+ this.ensureTimer();
58
+ }
59
+ /** Consumers another thread sweeps, reported in status(). */
60
+ addStatusSource(source) {
61
+ this.statusSources.push(source);
62
+ }
63
+ start() {
64
+ this.started = true;
65
+ this.ensureTimer();
66
+ }
67
+ async stop() {
68
+ this.stopped = true;
69
+ if (this.timer !== void 0) {
70
+ clearInterval(this.timer);
71
+ this.timer = void 0;
72
+ }
73
+ if (this.running !== void 0) await this.running.catch(() => []);
74
+ }
75
+ async sweepNow() {
76
+ if (this.running !== void 0) await this.running.catch(() => []);
77
+ return this.run();
78
+ }
79
+ status() {
80
+ return {
81
+ watermark: this.watermark.status(),
82
+ consumers: this.entries().map((entry) => ({
83
+ consumerId: entry.consumer.consumerId,
84
+ thread: entry.thread,
85
+ appliedThrough: entry.consumer.appliedThrough,
86
+ trackedAbove: entry.consumer.trackedAbove,
87
+ ...entry.blockedAt !== void 0 ? { blockedAt: entry.blockedAt } : {},
88
+ lastAdvanceUtcMs: entry.lastAdvanceUtcMs
89
+ })).concat(this.statusSources.flatMap((source) => source()))
90
+ };
91
+ }
92
+ ensureTimer() {
93
+ if (!this.started || this.stopped || this.timer !== void 0) return;
94
+ if (this.fixed.length === 0 && this.sources.length === 0) return;
95
+ const timer = setInterval(() => {
96
+ if (this.running !== void 0) return;
97
+ this.run().catch((error) => {
98
+ this.logger.error("catch-up tick failed: @error", error);
99
+ });
100
+ }, this.config.intervalMs);
101
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
102
+ this.timer = timer;
103
+ }
104
+ run() {
105
+ const run = this.tick().finally(() => {
106
+ if (this.running === run) this.running = void 0;
107
+ });
108
+ this.running = run;
109
+ return run;
110
+ }
111
+ async tick() {
112
+ const settled = await this.watermark.refresh();
113
+ await this.warnIfWatermarkHeld();
114
+ const entries = this.entries();
115
+ if (entries.length === 0) return [];
116
+ let lowest = Number.POSITIVE_INFINITY;
117
+ for (const entry of entries) lowest = Math.min(lowest, entry.consumer.appliedThrough);
118
+ const shared = await this.readPage(lowest, settled);
119
+ const results = [];
120
+ for (const entry of entries) {
121
+ if (this.stopped) break;
122
+ const result = await this.sweepOne(entry, shared, settled);
123
+ if (result !== void 0) results.push(result);
124
+ }
125
+ return results;
126
+ }
127
+ entries() {
128
+ const entries = [...this.fixed];
129
+ const seen = new Set(entries.map((entry) => entry.consumer));
130
+ for (const source of this.sources) for (const consumer of source.consumers()) {
131
+ if (seen.has(consumer)) continue;
132
+ seen.add(consumer);
133
+ let entry = this.sourced.get(consumer);
134
+ if (entry === void 0) {
135
+ entry = {
136
+ consumer,
137
+ thread: source.thread,
138
+ lastAdvanceUtcMs: Date.now(),
139
+ warnedHeld: false
140
+ };
141
+ this.sourced.set(consumer, entry);
142
+ }
143
+ entries.push(entry);
144
+ }
145
+ return entries;
146
+ }
147
+ async sweepOne(entry, shared, settled) {
148
+ const applied = entry.consumer.appliedThrough;
149
+ let page = shared;
150
+ if (applied >= shared.bound && shared.bound < settled) try {
151
+ page = await this.readPage(applied, settled);
152
+ } catch (error) {
153
+ this.logger.error("@consumer catch-up could not read present ordinals: @error", entry.consumer.consumerId, error);
154
+ return;
155
+ }
156
+ let result;
157
+ try {
158
+ result = await entry.consumer.sweep(page.bound, page.present);
159
+ } catch (error) {
160
+ this.logger.error("@consumer catch-up sweep failed: @error", entry.consumer.consumerId, error);
161
+ return;
162
+ }
163
+ if (result.to > result.from) {
164
+ entry.lastAdvanceUtcMs = Date.now();
165
+ entry.warnedHeld = false;
166
+ }
167
+ entry.blockedAt = result.blockedAt;
168
+ this.warnIfConsumerHeld(entry, settled);
169
+ if (result.to > result.from || result.replayed > 0 || result.blockedAt !== void 0) this.hooks.onSwept(result, entry.thread);
170
+ return result;
171
+ }
172
+ async warnIfWatermarkHeld() {
173
+ const status = this.watermark.status();
174
+ const since = status.stalledSinceUtcMs;
175
+ if (since === void 0) {
176
+ this.warnedStallSince = void 0;
177
+ return;
178
+ }
179
+ const heldMs = Date.now() - since;
180
+ if (heldMs < this.config.stuckWarnMs || this.warnedStallSince === since) return;
181
+ this.warnedStallSince = since;
182
+ const sessions = await this.hooks.describeSessions(status.waitingOn).catch(() => []);
183
+ this.logger.warn("settled watermark held at @settled (head @head) for @ms ms, waiting on xid @xids: @sessions", status.settledThrough, status.head, heldMs, status.waitingOn.join(", "), sessions.map((session) => `pid ${session.pid} ${session.applicationName} ${session.state} since ${session.xactStart ?? "?"}`).join("; "));
184
+ }
185
+ warnIfConsumerHeld(entry, settled) {
186
+ const applied = entry.consumer.appliedThrough;
187
+ if (applied >= settled || entry.warnedHeld) return;
188
+ const heldMs = Date.now() - entry.lastAdvanceUtcMs;
189
+ if (heldMs < this.config.stuckWarnMs) return;
190
+ entry.warnedHeld = true;
191
+ this.logger.warn("@consumer cursor held at @applied for @ms ms (settled @settled)", entry.consumer.consumerId, applied, heldMs, settled);
192
+ }
193
+ async readPage(after, settled) {
194
+ const limit = this.config.sweepPageSize;
195
+ const present = await this.operationIndex.getOrdinalsInRange(after, settled, limit);
196
+ return {
197
+ bound: present.length >= limit ? present[present.length - 1] : settled,
198
+ present
199
+ };
200
+ }
201
+ };
202
+ //#endregion
203
+ //#region src/catch-up/types.ts
204
+ const defaultCatchUpConfig = {
205
+ intervalMs: 2e3,
206
+ stuckWarnMs: 6e4,
207
+ maxTrackedAboveCursor: 1e5,
208
+ sweepPageSize: 1e4
209
+ };
210
+ //#endregion
211
+ //#region src/catch-up/contiguous-cursor.ts
212
+ /** In memory, per consumer; tracks ordinals above appliedThrough only. */
213
+ var ContiguousCursor = class {
214
+ through;
215
+ limit;
216
+ claimed = /* @__PURE__ */ new Set();
217
+ applied = /* @__PURE__ */ new Set();
218
+ constructor(through, limit) {
219
+ this.through = through;
220
+ this.limit = limit;
221
+ }
222
+ setLimit(limit) {
223
+ this.limit = limit;
224
+ }
225
+ /** Every present ordinal at or below this has been applied. */
226
+ get appliedThrough() {
227
+ return this.through;
228
+ }
229
+ get trackedAbove() {
230
+ return this.claimed.size + this.applied.size;
231
+ }
232
+ /** Ordinals the caller must apply; the rest are already covered. */
233
+ claim(ordinals) {
234
+ const mine = /* @__PURE__ */ new Set();
235
+ for (const ordinal of ordinals) {
236
+ if (ordinal <= this.through) continue;
237
+ if (this.claimed.has(ordinal) || this.applied.has(ordinal)) continue;
238
+ this.claimed.add(ordinal);
239
+ mine.add(ordinal);
240
+ }
241
+ return mine;
242
+ }
243
+ /** Releases claims; committed ones join the applied set. */
244
+ settle(ordinals, committed) {
245
+ for (const ordinal of ordinals) {
246
+ if (!this.claimed.delete(ordinal)) continue;
247
+ if (committed && ordinal > this.through) this.applied.add(ordinal);
248
+ }
249
+ }
250
+ /** Past the limit, clears the applied set; the next sweep re-applies it. */
251
+ enforceLimit() {
252
+ if (this.claimed.size + this.applied.size > this.limit) this.applied.clear();
253
+ }
254
+ /** Present ordinals above the cursor that no path has claimed or applied. */
255
+ missing(present) {
256
+ return present.filter((ordinal) => ordinal > this.through && !this.claimed.has(ordinal) && !this.applied.has(ordinal));
257
+ }
258
+ /** `settled`, held below the lowest claimed or unapplied ordinal. */
259
+ target(settled, present) {
260
+ let to = settled;
261
+ for (const ordinal of this.claimed) if (ordinal <= to) to = ordinal - 1;
262
+ for (const ordinal of present) {
263
+ if (ordinal <= this.through || ordinal > to) continue;
264
+ if (!this.applied.has(ordinal)) to = ordinal - 1;
265
+ }
266
+ return Math.max(to, this.through);
267
+ }
268
+ advance(to) {
269
+ if (to <= this.through) return;
270
+ this.through = to;
271
+ for (const ordinal of this.applied) if (ordinal <= to) this.applied.delete(ordinal);
272
+ for (const ordinal of this.claimed) if (ordinal <= to) this.claimed.delete(ordinal);
273
+ }
274
+ };
275
+ //#endregion
6
276
  //#region src/read-models/base-read-model.ts
277
+ /** Small enough that a chunk's transaction rarely outlasts the yield deadline. */
278
+ const DEFAULT_COMMIT_CHUNK_SIZE = 50;
279
+ /** Matches the executor's own default, so both paths yield on the same cadence. */
280
+ const DEFAULT_READ_MODEL_YIELD_DEADLINE_MS = 50;
281
+ const defaultReadModelIndexingConfig = {
282
+ commitChunkSize: 50,
283
+ yieldDeadlineMs: 50
284
+ };
285
+ /** For read models whose callers can observe where a batch was split. */
286
+ const unchunkedReadModelIndexingConfig = {
287
+ commitChunkSize: Number.MAX_SAFE_INTEGER,
288
+ yieldDeadlineMs: 50
289
+ };
7
290
  /**
8
- * Base class for read models that provides catch-up/rewind functionality.
9
- * Handles initialization, state tracking via ViewState table, and consistency tracking.
10
- * Subclasses override commitOperations() with their specific domain logic.
291
+ * Keeps the chunk size at one operation or more: a chunk of zero or less never
292
+ * advances the indexing loop, so the pass would spin without ever resolving.
11
293
  */
12
- var BaseReadModel = class {
13
- lastOrdinal = 0;
294
+ function normalizeIndexingConfig(config) {
295
+ if (Number.isNaN(config.commitChunkSize)) return {
296
+ ...config,
297
+ commitChunkSize: 50
298
+ };
299
+ return {
300
+ ...config,
301
+ commitChunkSize: Math.max(1, Math.min(Math.floor(config.commitChunkSize), Number.MAX_SAFE_INTEGER))
302
+ };
303
+ }
304
+ function ordinalOf(item) {
305
+ return item.context.ordinal;
306
+ }
307
+ function isTracked(ordinal) {
308
+ return Number.isFinite(ordinal) && ordinal > 0;
309
+ }
310
+ function streamKeyOf(stream) {
311
+ return `${stream.documentId}\u0000${stream.scope}\u0000${stream.branch}`;
312
+ }
313
+ function groupByStream(items) {
314
+ const groups = /* @__PURE__ */ new Map();
315
+ for (const item of items) {
316
+ const { documentId, scope, branch } = item.context;
317
+ const key = streamKeyOf(item.context);
318
+ let group = groups.get(key);
319
+ if (group === void 0) {
320
+ group = {
321
+ documentId,
322
+ scope,
323
+ branch,
324
+ lowest: ordinalOf(item),
325
+ late: []
326
+ };
327
+ groups.set(key, group);
328
+ }
329
+ group.lowest = Math.min(group.lowest, ordinalOf(item));
330
+ group.late.push(item);
331
+ }
332
+ return [...groups.values()].sort((a, b) => a.lowest - b.lowest);
333
+ }
334
+ function mergeByOrdinal(...lists) {
335
+ const byOrdinal = /* @__PURE__ */ new Map();
336
+ for (const list of lists) for (const item of list) byOrdinal.set(ordinalOf(item), item);
337
+ return [...byOrdinal.values()].sort((a, b) => ordinalOf(a) - ordinalOf(b));
338
+ }
339
+ /** The ids whose purge an operation's rows must not outlive. */
340
+ function purgeFenceIds(items) {
341
+ const ids = /* @__PURE__ */ new Set();
342
+ for (const { operation, context } of items) {
343
+ ids.add(context.documentId);
344
+ if (operation.action.type !== "ADD_RELATIONSHIP") continue;
345
+ const input = operation.action.input;
346
+ if (typeof input.sourceId === "string") ids.add(input.sourceId);
347
+ if (typeof input.targetId === "string") ids.add(input.targetId);
348
+ }
349
+ return [...ids].sort();
350
+ }
351
+ function dropPurged(items, purged) {
352
+ if (purged.size === 0) return items;
353
+ return items.filter((item) => isPurgeMarker(item.operation) || !purged.has(item.context.documentId));
354
+ }
355
+ function errorMessage(error) {
356
+ return error instanceof Error ? error.message : String(error);
357
+ }
358
+ /** A read model whose cursor only sweeps and boot replay advance. */
359
+ var BaseReadModel = class BaseReadModel {
360
+ db;
361
+ operationIndex;
362
+ writeCache;
363
+ consistencyTracker;
364
+ config;
14
365
  name;
366
+ indexing;
367
+ catchUpLogger;
368
+ cursor;
369
+ persisted = 0;
370
+ watermark;
371
+ maxTrackedAboveCursor = defaultCatchUpConfig.maxTrackedAboveCursor;
372
+ loggedFailure;
373
+ failedItem;
374
+ initialized = false;
375
+ liveInFlight = /* @__PURE__ */ new Map();
376
+ sweptListeners = /* @__PURE__ */ new Set();
15
377
  constructor(db, operationIndex, writeCache, consistencyTracker, config) {
16
378
  this.db = db;
17
379
  this.operationIndex = operationIndex;
@@ -19,33 +381,134 @@ var BaseReadModel = class {
19
381
  this.consistencyTracker = consistencyTracker;
20
382
  this.config = config;
21
383
  this.name = config.readModelId;
384
+ this.catchUpLogger = childLogger([
385
+ "reactor",
386
+ "read-model",
387
+ config.readModelId
388
+ ]);
389
+ this.indexing = normalizeIndexingConfig(config.indexing ?? defaultReadModelIndexingConfig);
390
+ this.cursor = new ContiguousCursor(0, this.maxTrackedAboveCursor);
391
+ if ((config.purgeFence ?? "locked") === "locked" && this.commitOperations !== BaseReadModel.prototype.commitOperations && !this.constructor.commitsInFenceTransaction) throw new Error(`Read model ${config.readModelId}: purgeFence "locked" commits in a transaction its commitOperations must write through. Write through the trx argument and set static commitsInFenceTransaction to true, or set purgeFence "skip" (rows on another handle) or "none" (no rows here, or its own transaction).`);
22
392
  }
23
- /**
24
- * Initializes the read model by loading state and catching up on missed operations.
25
- */
393
+ /** Set by a subclass whose commitOperations writes through its trx argument. */
394
+ static commitsInFenceTransaction = false;
395
+ get consumerId() {
396
+ return this.config.readModelId;
397
+ }
398
+ get appliedThrough() {
399
+ return this.cursor.appliedThrough;
400
+ }
401
+ get trackedAbove() {
402
+ return this.cursor.trackedAbove;
403
+ }
404
+ /** Shares a thread's watermark; otherwise boot probes through this db. */
405
+ attachCatchUp(watermark, maxTrackedAboveCursor) {
406
+ this.watermark = watermark;
407
+ this.maxTrackedAboveCursor = maxTrackedAboveCursor;
408
+ this.cursor.setLimit(maxTrackedAboveCursor);
409
+ }
410
+ /** Notified with the coordinates each sweep applied. */
411
+ onSwept(listener) {
412
+ this.sweptListeners.add(listener);
413
+ return () => {
414
+ this.sweptListeners.delete(listener);
415
+ };
416
+ }
417
+ /** A chunk that throws ends the replay; sweeps continue from below it. */
26
418
  async init() {
27
- const viewState = await this.loadState();
28
- if (viewState !== void 0) this.lastOrdinal = viewState;
29
- else await this.initializeState();
30
- let page = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
31
- while (page.results.length > 0) {
32
- const ops = this.config.rebuildStateOnInit ? await this.rebuildStateForOperations(page.results) : page.results;
33
- await this.indexOperations(ops);
34
- if (!page.next) break;
35
- page = await page.next();
419
+ if (this.initialized) {
420
+ await this.replayFromCursor();
421
+ return;
422
+ }
423
+ let stored = await this.loadState();
424
+ if (stored === void 0) {
425
+ if (this.config.startFrom === "head") {
426
+ const watermark = this.settledWatermark();
427
+ const settled = await watermark.refresh();
428
+ const head = Math.max(settled, watermark.status().head);
429
+ await this.initializeState(head);
430
+ this.resetCursor(head);
431
+ this.initialized = true;
432
+ return;
433
+ }
434
+ await this.initializeState(0);
435
+ stored = 0;
36
436
  }
437
+ this.resetCursor(stored);
438
+ this.initialized = true;
439
+ await this.replayFromCursor();
37
440
  }
38
- /**
39
- * Template method: runs domain-specific commitOperations, then persists
40
- * state and updates consistency tracking.
41
- */
42
- async indexOperations(items) {
43
- if (items.length === 0) return;
44
- await this.commitOperations(items);
45
- await this.db.transaction().execute(async (trx) => {
46
- await this.saveState(trx, items);
47
- });
48
- this.updateConsistencyTracker(items);
441
+ /** The live path: never moves the cursor. */
442
+ indexOperations(items) {
443
+ if (items.length === 0) return Promise.resolve();
444
+ return this.reserveOperations(items).apply();
445
+ }
446
+ /** Holds a queued batch as live, so a sweep neither takes nor passes it. */
447
+ reserveOperations(items) {
448
+ const cursor = this.cursor;
449
+ const owned = this.claimLive(items);
450
+ const unmark = this.markLive(owned);
451
+ let done = false;
452
+ return {
453
+ apply: () => {
454
+ if (done) return Promise.resolve();
455
+ done = true;
456
+ if (cursor !== this.cursor) {
457
+ unmark();
458
+ return this.indexOperations(items);
459
+ }
460
+ if (owned.length === 0) {
461
+ unmark();
462
+ return Promise.resolve();
463
+ }
464
+ return this.applyChunked(owned, unmark);
465
+ },
466
+ release: () => {
467
+ if (done) return;
468
+ done = true;
469
+ cursor.settle(owned.map(ordinalOf), false);
470
+ unmark();
471
+ }
472
+ };
473
+ }
474
+ async sweep(settledThrough, present, signal) {
475
+ const startedAt = performance.now();
476
+ const from = this.cursor.appliedThrough;
477
+ const range = present.filter((ordinal) => ordinal > from && ordinal <= settledThrough);
478
+ const mine = this.cursor.claim(this.cursor.missing(range));
479
+ let replayed = 0;
480
+ let reapplied = 0;
481
+ let blockedAt;
482
+ if (mine.size > 0) {
483
+ let late;
484
+ try {
485
+ late = await this.operationIndex.getByOrdinals([...mine], signal);
486
+ } catch (error) {
487
+ this.cursor.settle(mine, false);
488
+ throw error;
489
+ }
490
+ const found = new Set(late.map(ordinalOf));
491
+ this.cursor.settle([...mine].filter((ordinal) => !found.has(ordinal)), true);
492
+ for (const group of groupByStream(late)) {
493
+ const outcome = await this.sweepStream(group, signal);
494
+ replayed += outcome.replayed;
495
+ reapplied += outcome.reapplied;
496
+ blockedAt ??= outcome.blockedAt;
497
+ }
498
+ if (replayed > 0) this.catchUpLogger.warn("@consumer applied @n operations its live path never received: ordinals @first..@last", this.consumerId, replayed, Math.min(...found), Math.max(...found));
499
+ }
500
+ const to = this.cursor.target(settledThrough, range);
501
+ if (to > from) await this.moveCursor(to);
502
+ this.cursor.enforceLimit();
503
+ return {
504
+ consumerId: this.consumerId,
505
+ from,
506
+ to: this.cursor.appliedThrough,
507
+ durationMs: performance.now() - startedAt,
508
+ replayed,
509
+ reapplied,
510
+ ...blockedAt !== void 0 ? { blockedAt } : {}
511
+ };
49
512
  }
50
513
  /**
51
514
  * Waits for the read model to reach the specified consistency level.
@@ -54,10 +517,46 @@ var BaseReadModel = class {
54
517
  if (token.coordinates.length === 0) return;
55
518
  await this.consistencyTracker.waitFor(token.coordinates, timeoutMs, signal);
56
519
  }
57
- async commitOperations(items) {}
58
- /**
59
- * Rebuilds document state for each operation using the write cache.
60
- */
520
+ /** The cursor, for subclasses: every present ordinal at or below it is applied. */
521
+ get lastOrdinal() {
522
+ return this.cursor.appliedThrough;
523
+ }
524
+ /** Called after the cursor advances. */
525
+ onCursorAdvanced(appliedThrough) {}
526
+ /** Writes through `trx` when given: the purge fence's transaction. */
527
+ async commitOperations(items, trx) {}
528
+ /** The handle document_purges is read through; `db` may be the fence trx. */
529
+ purgeLookup(db) {
530
+ return db;
531
+ }
532
+ /** False when the batch writes no rows, so the fence opens no transaction. */
533
+ writesRows(items) {
534
+ return true;
535
+ }
536
+ /** Every delivery commits through here. */
537
+ async commitFenced(items) {
538
+ const fence = this.config.purgeFence ?? "locked";
539
+ if (fence === "none") {
540
+ await this.commitOperations(items);
541
+ return;
542
+ }
543
+ if (items.length === 0 || !this.writesRows(items)) {
544
+ if (items.length > 0) await this.commitOperations(items);
545
+ return;
546
+ }
547
+ const ids = purgeFenceIds(items);
548
+ if (fence === "skip") {
549
+ const live = dropPurged(items, await findPurged(this.purgeLookup(this.db), ids));
550
+ if (live.length > 0) await this.commitOperations(live);
551
+ return;
552
+ }
553
+ await this.db.transaction().execute(async (trx) => {
554
+ await acquirePurgeLocks(trx, ids, "shared");
555
+ const live = dropPurged(items, await findPurged(this.purgeLookup(trx), ids));
556
+ if (live.length > 0) await this.commitOperations(live, trx);
557
+ });
558
+ }
559
+ /** Rebuilds resultingState as the executor writes it: scopes plus header. */
61
560
  async rebuildStateForOperations(operations) {
62
561
  const result = [];
63
562
  for (const op of operations) {
@@ -68,7 +567,10 @@ var BaseReadModel = class {
68
567
  operation: op.operation,
69
568
  context: {
70
569
  ...op.context,
71
- resultingState: JSON.stringify(document)
570
+ resultingState: JSON.stringify({
571
+ ...document.state,
572
+ header: document.header
573
+ })
72
574
  }
73
575
  });
74
576
  }
@@ -84,28 +586,19 @@ var BaseReadModel = class {
84
586
  /**
85
587
  * Initializes the ViewState row for this read model.
86
588
  */
87
- async initializeState() {
589
+ async initializeState(lastOrdinal = 0) {
88
590
  await this.db.insertInto("ViewState").values({
89
591
  readModelId: this.config.readModelId,
90
- lastOrdinal: 0
91
- }).execute();
92
- }
93
- /**
94
- * Saves the last processed ordinal to the ViewState table.
95
- */
96
- async saveState(trx, items) {
97
- let maxOrdinal = 0;
98
- for (const item of items) maxOrdinal = Math.max(maxOrdinal, item.context.ordinal);
99
- this.lastOrdinal = maxOrdinal;
100
- await trx.updateTable("ViewState").set({
101
- lastOrdinal: maxOrdinal,
102
- lastOperationTimestamp: /* @__PURE__ */ new Date()
103
- }).where("readModelId", "=", this.config.readModelId).execute();
592
+ lastOrdinal
593
+ }).onConflict((oc) => oc.column("readModelId").doNothing()).execute();
104
594
  }
105
595
  /**
106
596
  * Updates the consistency tracker with the processed operations.
107
597
  */
108
598
  updateConsistencyTracker(items) {
599
+ this.consistencyTracker.update(this.coordinatesOf(items));
600
+ }
601
+ coordinatesOf(items) {
109
602
  const coordinates = [];
110
603
  for (let i = 0; i < items.length; i++) {
111
604
  const item = items[i];
@@ -116,11 +609,309 @@ var BaseReadModel = class {
116
609
  operationIndex: item.operation.index
117
610
  });
118
611
  }
119
- this.consistencyTracker.update(coordinates);
612
+ return coordinates;
613
+ }
614
+ settledWatermark() {
615
+ this.watermark ??= new SettledWatermark(createKyselyWatermarkProbe(this.db), this.catchUpLogger);
616
+ return this.watermark;
617
+ }
618
+ resetCursor(appliedThrough) {
619
+ this.cursor = new ContiguousCursor(appliedThrough, this.maxTrackedAboveCursor);
620
+ this.persisted = appliedThrough;
621
+ }
622
+ /** Items this pass must apply; untracked ordinals always apply. */
623
+ claimLive(items) {
624
+ const mine = this.cursor.claim(items.map(ordinalOf));
625
+ return items.filter((item) => {
626
+ const ordinal = ordinalOf(item);
627
+ return !isTracked(ordinal) || mine.has(ordinal);
628
+ });
629
+ }
630
+ /** Marks items in flight on their streams; returns the release. */
631
+ markLive(items) {
632
+ const keys = /* @__PURE__ */ new Set();
633
+ for (const item of items) {
634
+ if (!isTracked(ordinalOf(item))) continue;
635
+ const key = streamKeyOf(item.context);
636
+ let ordinals = this.liveInFlight.get(key);
637
+ if (ordinals === void 0) {
638
+ ordinals = /* @__PURE__ */ new Set();
639
+ this.liveInFlight.set(key, ordinals);
640
+ }
641
+ ordinals.add(ordinalOf(item));
642
+ keys.add(key);
643
+ }
644
+ return () => {
645
+ for (const item of items) this.liveInFlight.get(streamKeyOf(item.context))?.delete(ordinalOf(item));
646
+ for (const key of keys) if (this.liveInFlight.get(key)?.size === 0) this.liveInFlight.delete(key);
647
+ };
648
+ }
649
+ /** True while a live pass applies an earlier operation of the stream. */
650
+ liveBelow(group) {
651
+ const ordinals = this.liveInFlight.get(streamKeyOf(group));
652
+ if (ordinals === void 0) return false;
653
+ for (const ordinal of ordinals) if (ordinal < group.lowest) return true;
654
+ return false;
655
+ }
656
+ /** Commits in chunks, yielding with no transaction open. */
657
+ async applyChunked(items, release = this.markLive(items)) {
658
+ try {
659
+ const { commitChunkSize, yieldDeadlineMs } = this.indexing;
660
+ let lastYield = performance.now();
661
+ let committed = 0;
662
+ for (let start = 0; start < items.length; start += commitChunkSize) {
663
+ if (start > 0 && performance.now() - lastYield > yieldDeadlineMs) {
664
+ await yieldToMain();
665
+ lastYield = performance.now();
666
+ }
667
+ const chunk = items.slice(start, start + commitChunkSize);
668
+ try {
669
+ await this.commitFenced(chunk);
670
+ } catch (error) {
671
+ this.failedItem = chunk[0];
672
+ const prefix = items.slice(0, committed);
673
+ this.cursor.settle(prefix.map(ordinalOf), true);
674
+ this.cursor.settle(items.slice(committed).map(ordinalOf), false);
675
+ if (prefix.length > 0) this.updateConsistencyTracker(prefix);
676
+ throw error;
677
+ }
678
+ committed += chunk.length;
679
+ }
680
+ this.cursor.settle(items.map(ordinalOf), true);
681
+ this.cursor.enforceLimit();
682
+ this.updateConsistencyTracker(items);
683
+ } finally {
684
+ release();
685
+ }
686
+ }
687
+ async replayFromCursor() {
688
+ const settledAtBoot = await this.settledAtBoot();
689
+ let page = await this.operationIndex.getSinceOrdinal(this.cursor.appliedThrough);
690
+ while (page.results.length > 0) {
691
+ const ordinals = page.results.map(ordinalOf);
692
+ const failed = await this.replayPage(page.results);
693
+ const pageMax = Math.max(...ordinals);
694
+ const to = this.cursor.target(Math.min(settledAtBoot, pageMax), ordinals);
695
+ if (to > this.cursor.appliedThrough) await this.moveCursor(to);
696
+ if (failed || !page.next) break;
697
+ page = await page.next();
698
+ }
699
+ }
700
+ /** Without a probe, boot replay applies but leaves the cursor to sweeps. */
701
+ async settledAtBoot() {
702
+ try {
703
+ return await this.settledWatermark().refresh();
704
+ } catch (error) {
705
+ this.catchUpLogger.warn("@consumer could not probe the settled watermark at boot; the cursor waits for a sweep: @error", this.consumerId, error);
706
+ return this.cursor.appliedThrough;
707
+ }
708
+ }
709
+ /** Returns true when a chunk failed, which ends the replay. */
710
+ async replayPage(results) {
711
+ const owned = this.claimLive(results);
712
+ if (owned.length === 0) return false;
713
+ const release = this.markLive(owned);
714
+ try {
715
+ return await this.replayOwned(owned);
716
+ } finally {
717
+ release();
718
+ }
719
+ }
720
+ async replayOwned(owned) {
721
+ let rebuilt;
722
+ try {
723
+ rebuilt = await this.rebuildIfConfigured(owned);
724
+ } catch (error) {
725
+ this.cursor.settle(owned.map(ordinalOf), false);
726
+ this.block(owned[0], error);
727
+ return true;
728
+ }
729
+ this.cursor.settle(rebuilt.absent, true);
730
+ if (rebuilt.items.length === 0) return false;
731
+ try {
732
+ await this.applyChunked(rebuilt.items);
733
+ } catch (error) {
734
+ this.block(this.failedItem ?? rebuilt.items[0], error);
735
+ return true;
736
+ }
737
+ return false;
738
+ }
739
+ async sweepStream(group, signal) {
740
+ const owned = group.late.map(ordinalOf);
741
+ if (this.liveBelow(group)) {
742
+ this.cursor.settle(owned, false);
743
+ return {
744
+ replayed: 0,
745
+ reapplied: 0
746
+ };
747
+ }
748
+ const ownedSet = new Set(owned);
749
+ let items = group.late;
750
+ if (this.config.replayStreamSuffix ?? true) {
751
+ let suffix;
752
+ try {
753
+ suffix = await this.operationIndex.getStreamAfter(group, group.lowest, signal);
754
+ } catch (error) {
755
+ this.cursor.settle(owned, false);
756
+ return {
757
+ replayed: 0,
758
+ reapplied: 0,
759
+ blockedAt: this.block(group.late[0], error)
760
+ };
761
+ }
762
+ items = mergeByOrdinal(group.late, suffix);
763
+ }
764
+ let rebuilt;
765
+ try {
766
+ rebuilt = await this.rebuildIfConfigured(items);
767
+ } catch (error) {
768
+ this.cursor.settle(owned, false);
769
+ return {
770
+ replayed: 0,
771
+ reapplied: 0,
772
+ blockedAt: this.block(items[0], error)
773
+ };
774
+ }
775
+ try {
776
+ await this.commitFenced(rebuilt.items);
777
+ } catch (error) {
778
+ this.cursor.settle(owned, false);
779
+ return {
780
+ replayed: 0,
781
+ reapplied: 0,
782
+ blockedAt: this.block(rebuilt.items[0] ?? items[0], error)
783
+ };
784
+ }
785
+ this.cursor.settle(owned, true);
786
+ this.updateConsistencyTracker(rebuilt.items);
787
+ this.notifySwept(rebuilt.items);
788
+ let replayed = 0;
789
+ for (const item of rebuilt.items) if (ownedSet.has(ordinalOf(item))) replayed++;
790
+ return {
791
+ replayed,
792
+ reapplied: rebuilt.items.length - replayed
793
+ };
794
+ }
795
+ /** A document gone from the write cache, or purged, is absent. */
796
+ async rebuildIfConfigured(items) {
797
+ if (!this.config.rebuildStateOnInit) return {
798
+ items,
799
+ absent: []
800
+ };
801
+ const rebuilt = [];
802
+ const absent = [];
803
+ for (const item of items) {
804
+ if (isPurgeMarker(item.operation)) {
805
+ rebuilt.push(item);
806
+ continue;
807
+ }
808
+ let result;
809
+ try {
810
+ result = await this.rebuildStateForOperations([item]);
811
+ } catch (error) {
812
+ if (!DocumentNotFoundError.isError(error) && !await this.isPurged(item.context.documentId)) throw error;
813
+ this.catchUpLogger.warn("@consumer dropped ordinal @ordinal: document @documentId is gone", this.consumerId, ordinalOf(item), item.context.documentId);
814
+ absent.push(ordinalOf(item));
815
+ continue;
816
+ }
817
+ rebuilt.push(...result);
818
+ }
819
+ return {
820
+ items: rebuilt,
821
+ absent
822
+ };
823
+ }
824
+ /** A failed lookup is no answer: the caller's own error stands. */
825
+ async isPurged(documentId) {
826
+ try {
827
+ return (await findPurged(this.purgeLookup(this.db), [documentId])).has(documentId);
828
+ } catch {
829
+ return false;
830
+ }
831
+ }
832
+ block(item, error) {
833
+ const blockedAt = {
834
+ ordinal: ordinalOf(item),
835
+ documentId: item.context.documentId,
836
+ scope: item.context.scope,
837
+ branch: item.context.branch,
838
+ type: item.operation.action.type,
839
+ error: errorMessage(error)
840
+ };
841
+ if (this.loggedFailure !== blockedAt.ordinal) {
842
+ this.loggedFailure = blockedAt.ordinal;
843
+ this.catchUpLogger.error("@consumer cursor held at @applied: ordinal @ordinal (@documentId/@scope/@branch, @type) failed: @error", this.consumerId, this.cursor.appliedThrough, blockedAt.ordinal, blockedAt.documentId, blockedAt.scope, blockedAt.branch, blockedAt.type, blockedAt.error);
844
+ }
845
+ return blockedAt;
846
+ }
847
+ notifySwept(items) {
848
+ if (this.sweptListeners.size === 0 || items.length === 0) return;
849
+ const coordinates = this.coordinatesOf(items);
850
+ for (const listener of this.sweptListeners) listener(coordinates);
851
+ }
852
+ /** Compare-and-set against the value this process last wrote. */
853
+ async moveCursor(to) {
854
+ if (to <= this.persisted) {
855
+ this.advanceCursor(to);
856
+ return;
857
+ }
858
+ const expected = this.persisted;
859
+ const result = await this.db.updateTable("ViewState").set({
860
+ lastOrdinal: to,
861
+ lastOperationTimestamp: /* @__PURE__ */ new Date()
862
+ }).where("readModelId", "=", this.config.readModelId).where("lastOrdinal", "=", expected).executeTakeFirst();
863
+ if (Number(result.numUpdatedRows) > 0) {
864
+ this.persisted = to;
865
+ this.advanceCursor(to);
866
+ return;
867
+ }
868
+ const stored = await this.loadState();
869
+ if (stored === void 0) {
870
+ await this.initializeState(0);
871
+ this.lowered(0);
872
+ return;
873
+ }
874
+ if (stored < this.cursor.appliedThrough) {
875
+ this.lowered(stored);
876
+ return;
877
+ }
878
+ this.persisted = stored;
879
+ this.advanceCursor(Math.min(to, stored));
880
+ }
881
+ advanceCursor(to) {
882
+ const before = this.cursor.appliedThrough;
883
+ this.cursor.advance(to);
884
+ if (this.cursor.appliedThrough > before) this.onCursorAdvanced(this.cursor.appliedThrough);
885
+ }
886
+ lowered(to) {
887
+ this.catchUpLogger.info("@consumer cursor lowered externally from @old to @new; replaying", this.consumerId, this.cursor.appliedThrough, to);
888
+ this.resetCursor(to);
120
889
  }
121
890
  };
122
891
  //#endregion
123
892
  //#region src/read-models/coordinator.ts
893
+ /** Claims a batch for every model that supports it, as the batch queues. */
894
+ function reserveBatch(readModels, operations, logger) {
895
+ const reservations = /* @__PURE__ */ new Map();
896
+ for (const readModel of readModels) {
897
+ if (readModel.reserveOperations === void 0) continue;
898
+ try {
899
+ reservations.set(readModel, readModel.reserveOperations(operations));
900
+ } catch (error) {
901
+ logger.error("Read model @name could not reserve a batch: @Error", readModel.name, error);
902
+ }
903
+ }
904
+ return reservations;
905
+ }
906
+ /** Applies the model's reservation, or indexes a model registered since. */
907
+ function indexReserved(readModel, operations, reservations) {
908
+ const reservation = reservations.get(readModel);
909
+ return reservation ? reservation.apply() : readModel.indexOperations(operations);
910
+ }
911
+ /** Frees every claim a run did not apply, so a sweep can take it. */
912
+ function releaseBatch(reservations) {
913
+ for (const reservation of reservations.values()) reservation.release();
914
+ }
124
915
  /**
125
916
  * Coordinates read model synchronization by listening to operation write events
126
917
  * and updating all registered read models on per-`documentId:scope:branch`
@@ -129,6 +920,9 @@ var BaseReadModel = class {
129
920
  * implicitly.
130
921
  */
131
922
  var ReadModelCoordinator = class {
923
+ eventBus;
924
+ preReady;
925
+ postReady;
132
926
  unsubscribe;
133
927
  isRunning = false;
134
928
  chains = /* @__PURE__ */ new Map();
@@ -170,6 +964,9 @@ var ReadModelCoordinator = class {
170
964
  getChainDepth() {
171
965
  return this.chains.size;
172
966
  }
967
+ indexedReadModels() {
968
+ return [...this.preReady, ...this.postReady];
969
+ }
173
970
  addReadModel(readModel, stage) {
174
971
  if (this.readModels.some(({ name }) => name === readModel.name)) throw new Error(`Read model "${readModel.name}" is already registered`);
175
972
  if (stage === "pre_ready") this.preReady.push(readModel);
@@ -183,7 +980,9 @@ var ReadModelCoordinator = class {
183
980
  }
184
981
  const enqueuedAt = performance.now();
185
982
  const key = this.queueKeyFor(event);
186
- const current = (this.chains.get(key) ?? Promise.resolve()).then(() => this.runChain(event, enqueuedAt));
983
+ const reservations = reserveBatch(this.readModels, event.operations, this.logger);
984
+ const run = () => this.runChain(event, enqueuedAt, reservations);
985
+ const current = (this.chains.get(key) ?? Promise.resolve()).then(run, run);
187
986
  this.chains.set(key, current);
188
987
  current.finally(() => {
189
988
  if (this.chains.get(key) === current) this.chains.delete(key);
@@ -208,11 +1007,18 @@ var ReadModelCoordinator = class {
208
1007
  postReadyDurationMs: 0
209
1008
  });
210
1009
  }
211
- async runChain(event, enqueuedAt) {
1010
+ async runChain(event, enqueuedAt, reservations) {
1011
+ try {
1012
+ await this.runBatch(event, enqueuedAt, reservations);
1013
+ } finally {
1014
+ releaseBatch(reservations);
1015
+ }
1016
+ }
1017
+ async runBatch(event, enqueuedAt, reservations) {
212
1018
  const chainWaitDurationMs = performance.now() - enqueuedAt;
213
1019
  const preReadyStart = performance.now();
214
1020
  try {
215
- await Promise.all(this.preReady.map((readModel) => this.indexWithTiming(readModel, "pre_ready", event)));
1021
+ await Promise.all(this.preReady.map((readModel) => this.indexWithTiming(readModel, "pre_ready", event, reservations)));
216
1022
  } catch (error) {
217
1023
  this.logger.error("Pre-ready read model indexing failed for job @jobId: @Error", { jobId: event.jobId }, error);
218
1024
  }
@@ -230,7 +1036,7 @@ var ReadModelCoordinator = class {
230
1036
  const emitDurationMs = performance.now() - emitStart;
231
1037
  const postReadyStart = performance.now();
232
1038
  try {
233
- await Promise.all(this.postReady.map((readModel) => this.indexWithTiming(readModel, "post_ready", event)));
1039
+ await Promise.all(this.postReady.map((readModel) => this.indexWithTiming(readModel, "post_ready", event, reservations)));
234
1040
  } catch (error) {
235
1041
  this.logger.error("Post-ready read model indexing failed for job @jobId: @Error", { jobId: event.jobId }, error);
236
1042
  }
@@ -244,11 +1050,11 @@ var ReadModelCoordinator = class {
244
1050
  postReadyDurationMs
245
1051
  });
246
1052
  }
247
- async indexWithTiming(readModel, stage, event) {
1053
+ async indexWithTiming(readModel, stage, event, reservations) {
248
1054
  const start = performance.now();
249
1055
  let success = false;
250
1056
  try {
251
- await readModel.indexOperations(event.operations);
1057
+ await indexReserved(readModel, event.operations, reservations);
252
1058
  success = true;
253
1059
  } finally {
254
1060
  this.emitReadModelIndexed({
@@ -280,15 +1086,39 @@ const DOCUMENT_VIEW_READ_MODEL = "document-view";
280
1086
  const DOCUMENT_INDEXER_READ_MODEL = "document-indexer";
281
1087
  //#endregion
282
1088
  //#region src/read-models/document-view.ts
1089
+ /**
1090
+ * What a single-document read of a deleted document returns. A listing omits a
1091
+ * deleted document under either value.
1092
+ */
1093
+ let DeletedDocumentRead = /* @__PURE__ */ function(DeletedDocumentRead) {
1094
+ /**
1095
+ * The document reads as missing: `get` throws and `resolveIdOrSlug` does not
1096
+ * match its id.
1097
+ */
1098
+ DeletedDocumentRead["NotFound"] = "NotFound";
1099
+ /**
1100
+ * The document's state as of the deletion, with `state.document.isDeleted`
1101
+ * telling the caller what it holds. Only meaningful with `documentDecisions`,
1102
+ * which is what makes deletion positional.
1103
+ */
1104
+ DeletedDocumentRead["StateAtDeletion"] = "StateAtDeletion";
1105
+ return DeletedDocumentRead;
1106
+ }({});
283
1107
  var KyselyDocumentView = class extends BaseReadModel {
1108
+ operationStore;
1109
+ deletedDocumentRead;
1110
+ static commitsInFenceTransaction = true;
284
1111
  _db;
285
- constructor(db, operationStore, operationIndex, writeCache, consistencyTracker, servesDeletionBoundary) {
1112
+ constructor(db, operationStore, operationIndex, writeCache, consistencyTracker, deletedDocumentRead, indexing = defaultReadModelIndexingConfig) {
286
1113
  super(db, operationIndex, writeCache, consistencyTracker, {
287
1114
  readModelId: DOCUMENT_VIEW_READ_MODEL,
288
- rebuildStateOnInit: true
1115
+ rebuildStateOnInit: true,
1116
+ indexing,
1117
+ replayStreamSuffix: false,
1118
+ purgeFence: "locked"
289
1119
  });
290
1120
  this.operationStore = operationStore;
291
- this.servesDeletionBoundary = servesDeletionBoundary;
1121
+ this.deletedDocumentRead = deletedDocumentRead;
292
1122
  this._db = db;
293
1123
  }
294
1124
  /**
@@ -300,12 +1130,17 @@ var KyselyDocumentView = class extends BaseReadModel {
300
1130
  * without either fall back to header/document/auth, because their sibling
301
1131
  * echoes may be stale. All other action types index only header and their
302
1132
  * own scope.
1133
+ *
1134
+ * Every row accepts a write only from an operation whose global ordinal is
1135
+ * at least the one the row already carries, so an older duplicate from a
1136
+ * sweep, boot replay or chunk interleave never rolls a scope back.
303
1137
  */
304
- async commitOperations(items) {
305
- await this._db.transaction().execute(async (trx) => {
1138
+ async commitOperations(items, fenced) {
1139
+ const write = async (trx) => {
306
1140
  for (const item of items) {
307
1141
  const { operation, context } = item;
308
- const { documentId, scope, branch, documentType, resultingState } = context;
1142
+ if (isPurgeMarker(operation)) continue;
1143
+ const { documentId, scope, branch, documentType, resultingState, ordinal } = context;
309
1144
  const { index, hash } = operation;
310
1145
  if (!resultingState) throw new Error(`Missing resultingState in context for operation ${operation.id || "unknown"}. IDocumentView requires resultingState from upstream - it does not rebuild documents.`);
311
1146
  let fullState;
@@ -324,10 +1159,11 @@ var KyselyDocumentView = class extends BaseReadModel {
324
1159
  deletedAt: now,
325
1160
  lastOperationIndex: index,
326
1161
  lastOperationHash: hash,
1162
+ lastOperationOrdinal: ordinal,
327
1163
  lastUpdatedAt: now
328
- }).where("documentId", "=", documentId).where("branch", "=", branch).execute();
1164
+ }).where("documentId", "=", documentId).where("branch", "=", branch).where("lastOperationOrdinal", "<=", ordinal).execute();
329
1165
  const deletedDocumentScope = fullState?.document;
330
- if (deletedDocumentScope !== void 0) await trx.updateTable("DocumentSnapshot").set({ content: deletedDocumentScope }).where("documentId", "=", documentId).where("branch", "=", branch).where("scope", "=", "document").execute();
1166
+ if (deletedDocumentScope !== void 0) await trx.updateTable("DocumentSnapshot").set({ content: deletedDocumentScope }).where("documentId", "=", documentId).where("branch", "=", branch).where("scope", "=", "document").where("lastOperationOrdinal", "<=", ordinal).execute();
331
1167
  await trx.deleteFrom("SlugMapping").where("documentId", "=", documentId).where("branch", "=", branch).execute();
332
1168
  continue;
333
1169
  }
@@ -345,7 +1181,15 @@ var KyselyDocumentView = class extends BaseReadModel {
345
1181
  else scopesToIndex.push([scope, {}]);
346
1182
  }
347
1183
  for (const [scopeName, scopeState] of scopesToIndex) {
348
- const existingSnapshot = await trx.selectFrom("DocumentSnapshot").selectAll().where("documentId", "=", documentId).where("scope", "=", scopeName).where("branch", "=", branch).executeTakeFirst();
1184
+ const needsExistingContent = scopeName === "header" && preserveHeaderMeta;
1185
+ const existingSnapshot = await trx.selectFrom("DocumentSnapshot").select([
1186
+ "slug",
1187
+ "name",
1188
+ "isDeleted",
1189
+ "snapshotVersion",
1190
+ "lastOperationOrdinal"
1191
+ ]).$if(needsExistingContent, (qb) => qb.select("content")).where("documentId", "=", documentId).where("scope", "=", scopeName).where("branch", "=", branch).executeTakeFirst();
1192
+ if (existingSnapshot !== void 0 && existingSnapshot.lastOperationOrdinal > ordinal) continue;
349
1193
  const newState = typeof scopeState === "object" && scopeState !== null ? scopeState : {};
350
1194
  let slug = existingSnapshot?.slug ?? null;
351
1195
  let name = existingSnapshot?.name ?? null;
@@ -354,7 +1198,7 @@ var KyselyDocumentView = class extends BaseReadModel {
354
1198
  const headerName = newState.name;
355
1199
  if (typeof headerSlug === "string") slug = headerSlug;
356
1200
  if (typeof headerName === "string") name = headerName;
357
- if (preserveHeaderMeta && existingSnapshot) {
1201
+ if (needsExistingContent && existingSnapshot) {
358
1202
  const existingHeader = existingSnapshot.content;
359
1203
  if (existingHeader && "meta" in existingHeader) newState.meta = existingHeader.meta;
360
1204
  }
@@ -372,12 +1216,13 @@ var KyselyDocumentView = class extends BaseReadModel {
372
1216
  if (existingSnapshot) await trx.updateTable("DocumentSnapshot").set({
373
1217
  lastOperationIndex: index,
374
1218
  lastOperationHash: hash,
1219
+ lastOperationOrdinal: ordinal,
375
1220
  lastUpdatedAt: /* @__PURE__ */ new Date(),
376
1221
  snapshotVersion: existingSnapshot.snapshotVersion + 1,
377
1222
  content: newState,
378
1223
  slug,
379
1224
  name
380
- }).where("documentId", "=", documentId).where("scope", "=", scopeName).where("branch", "=", branch).execute();
1225
+ }).where("documentId", "=", documentId).where("scope", "=", scopeName).where("branch", "=", branch).where("lastOperationOrdinal", "<=", ordinal).execute();
381
1226
  else {
382
1227
  const snapshot = {
383
1228
  id: v4(),
@@ -390,6 +1235,7 @@ var KyselyDocumentView = class extends BaseReadModel {
390
1235
  documentType,
391
1236
  lastOperationIndex: index,
392
1237
  lastOperationHash: hash,
1238
+ lastOperationOrdinal: ordinal,
393
1239
  identifiers: null,
394
1240
  metadata: null,
395
1241
  deletedAt: null
@@ -398,13 +1244,18 @@ var KyselyDocumentView = class extends BaseReadModel {
398
1244
  }
399
1245
  }
400
1246
  }
401
- });
1247
+ };
1248
+ if (fenced) {
1249
+ await write(fenced);
1250
+ return;
1251
+ }
1252
+ await this._db.transaction().execute(write);
402
1253
  }
403
1254
  async exists(documentIds, existence, consistencyToken, signal) {
404
1255
  if (consistencyToken) await this.waitForConsistency(consistencyToken, void 0, signal);
405
1256
  if (signal?.aborted) throw new Error("Operation aborted");
406
1257
  if (documentIds.length === 0) return [];
407
- const existingIds = existence === DocumentExistence.IncludingDeleted ? await this.idsWithStreams(documentIds, signal) : await this.liveIds(documentIds);
1258
+ const existingIds = existence === "IncludingDeleted" ? await this.idsWithStreams(documentIds, signal) : await this.liveIds(documentIds);
408
1259
  return documentIds.map((id) => existingIds.has(id));
409
1260
  }
410
1261
  async get(documentId, view, consistencyToken, signal) {
@@ -412,17 +1263,17 @@ var KyselyDocumentView = class extends BaseReadModel {
412
1263
  if (signal?.aborted) throw new Error("Operation aborted");
413
1264
  const branch = view?.branch || "main";
414
1265
  let scopesToQuery;
415
- if (view?.scopes && view.scopes.length > 0) scopesToQuery = [...new Set([
1266
+ if (view?.scopes && view.scopes.length > 0) scopesToQuery = [.../* @__PURE__ */ new Set([
416
1267
  "header",
417
1268
  "document",
418
1269
  ...view.scopes
419
1270
  ])];
420
1271
  else scopesToQuery = [];
421
1272
  let query = this._db.selectFrom("DocumentSnapshot").selectAll().where("documentId", "=", documentId).where("branch", "=", branch);
422
- if (!this.servesDeletionBoundary) query = query.where("isDeleted", "=", false);
1273
+ if (this.deletedDocumentRead === "NotFound") query = query.where("isDeleted", "=", false);
423
1274
  if (scopesToQuery.length > 0) query = query.where("scope", "in", scopesToQuery);
424
1275
  const snapshots = await query.execute();
425
- if (snapshots.length === 0) throw new Error(`Document not found: ${documentId}`);
1276
+ if (snapshots.length === 0) throw new DocumentNotFoundError(documentId, `Document not found: ${documentId}`);
426
1277
  if (signal?.aborted) throw new Error("Operation aborted");
427
1278
  const headerSnapshot = snapshots.find((s) => s.scope === "header");
428
1279
  if (!headerSnapshot) throw new Error(`Document header not found: ${documentId}`);
@@ -449,7 +1300,7 @@ var KyselyDocumentView = class extends BaseReadModel {
449
1300
  if (signal?.aborted) throw new Error("Operation aborted");
450
1301
  const branch = view?.branch || "main";
451
1302
  let scopesToQuery;
452
- if (view?.scopes && view.scopes.length > 0) scopesToQuery = [...new Set([
1303
+ if (view?.scopes && view.scopes.length > 0) scopesToQuery = [.../* @__PURE__ */ new Set([
453
1304
  "header",
454
1305
  "document",
455
1306
  ...view.scopes
@@ -508,17 +1359,11 @@ var KyselyDocumentView = class extends BaseReadModel {
508
1359
  const branch = view?.branch || "main";
509
1360
  const startIndex = paging?.cursor ? parseInt(paging.cursor) : 0;
510
1361
  const limit = paging?.limit || 100;
511
- const documents = [];
512
- const processedDocumentIds = /* @__PURE__ */ new Set();
513
- const allDocumentIds = [];
514
- const snapshots = await this._db.selectFrom("DocumentSnapshot").selectAll().where("documentType", "=", type).where("branch", "=", branch).where("isDeleted", "=", false).orderBy("lastUpdatedAt", "desc").execute();
1362
+ const rows = await this._db.selectFrom("DocumentSnapshot").select("documentId").select((eb) => eb.fn.max("lastUpdatedAt").as("lastUpdatedAt")).where("documentType", "=", type).where("branch", "=", branch).where("isDeleted", "=", false).groupBy("documentId").orderBy("lastUpdatedAt", "desc").orderBy("documentId", "asc").offset(startIndex).limit(limit + 1).execute();
515
1363
  if (signal?.aborted) throw new Error("Operation aborted");
516
- for (const snapshot of snapshots) {
517
- if (processedDocumentIds.has(snapshot.documentId)) continue;
518
- processedDocumentIds.add(snapshot.documentId);
519
- allDocumentIds.push(snapshot.documentId);
520
- }
521
- const docsToFetch = allDocumentIds.slice(startIndex, startIndex + limit);
1364
+ const hasMore = rows.length > limit;
1365
+ const docsToFetch = rows.slice(0, limit).map((row) => row.documentId);
1366
+ const documents = [];
522
1367
  for (const documentId of docsToFetch) {
523
1368
  if (signal?.aborted) throw new Error("Operation aborted");
524
1369
  try {
@@ -528,7 +1373,6 @@ var KyselyDocumentView = class extends BaseReadModel {
528
1373
  continue;
529
1374
  }
530
1375
  }
531
- const hasMore = allDocumentIds.length > startIndex + limit;
532
1376
  const nextCursor = hasMore ? String(startIndex + limit) : void 0;
533
1377
  return {
534
1378
  results: documents,
@@ -537,7 +1381,6 @@ var KyselyDocumentView = class extends BaseReadModel {
537
1381
  limit: 100
538
1382
  },
539
1383
  nextCursor,
540
- totalCount: allDocumentIds.length,
541
1384
  next: hasMore ? () => this.findByType(type, view, {
542
1385
  cursor: nextCursor,
543
1386
  limit
@@ -564,7 +1407,7 @@ var KyselyDocumentView = class extends BaseReadModel {
564
1407
  if (signal?.aborted) throw new Error("Operation aborted");
565
1408
  const branch = view?.branch || "main";
566
1409
  let idCheckQuery = this._db.selectFrom("DocumentSnapshot").select("documentId").where("documentId", "=", identifier).where("branch", "=", branch);
567
- if (!this.servesDeletionBoundary) idCheckQuery = idCheckQuery.where("isDeleted", "=", false);
1410
+ if (this.deletedDocumentRead === "NotFound") idCheckQuery = idCheckQuery.where("isDeleted", "=", false);
568
1411
  const idCheckPromise = idCheckQuery.executeTakeFirst();
569
1412
  const slugCheckPromise = this._db.selectFrom("SlugMapping").select("documentId").where("slug", "=", identifier).where("branch", "=", branch).executeTakeFirst();
570
1413
  const [idMatch, slugMatch] = await Promise.all([idCheckPromise, slugCheckPromise]);
@@ -573,7 +1416,7 @@ var KyselyDocumentView = class extends BaseReadModel {
573
1416
  const slugMatchDocId = slugMatch?.documentId;
574
1417
  if (idMatchDocId && slugMatchDocId && idMatchDocId !== slugMatchDocId) throw new Error(`Ambiguous identifier "${identifier}": matches both document ID "${idMatchDocId}" and slug for document ID "${slugMatchDocId}". Please use get() for ID or resolveSlug() + get() for slug to be explicit.`);
575
1418
  const resolvedDocumentId = idMatchDocId || slugMatchDocId;
576
- if (!resolvedDocumentId) throw new Error(`Document not found: ${identifier}`);
1419
+ if (!resolvedDocumentId) throw new DocumentNotFoundError(identifier, `Document not found: ${identifier}`);
577
1420
  return resolvedDocumentId;
578
1421
  }
579
1422
  /**
@@ -713,25 +1556,59 @@ async function collectAllPages(firstPage, signal) {
713
1556
  }
714
1557
  //#endregion
715
1558
  //#region src/storage/kysely/document-indexer.ts
1559
+ function isRelationshipAction(actionType) {
1560
+ return actionType === "ADD_RELATIONSHIP" || actionType === "REMOVE_RELATIONSHIP" || actionType === "UPDATE_RELATIONSHIP";
1561
+ }
1562
+ function addRelationshipIds(items) {
1563
+ const ids = [];
1564
+ for (const { operation } of items) {
1565
+ if (operation.action.type !== "ADD_RELATIONSHIP") continue;
1566
+ const { sourceId, targetId } = operation.action.input;
1567
+ if (typeof sourceId === "string") ids.push(sourceId);
1568
+ if (typeof targetId === "string") ids.push(targetId);
1569
+ }
1570
+ return ids;
1571
+ }
1572
+ function touchesPurged(operation, purged) {
1573
+ const input = operation.action.input;
1574
+ return purged.has(input.sourceId) || purged.has(input.targetId);
1575
+ }
716
1576
  var KyselyDocumentIndexer = class extends BaseReadModel {
1577
+ static commitsInFenceTransaction = true;
717
1578
  _db;
718
- constructor(db, operationIndex, writeCache, consistencyTracker) {
1579
+ constructor(db, operationIndex, writeCache, consistencyTracker, indexing = defaultReadModelIndexingConfig) {
719
1580
  super(db, operationIndex, writeCache, consistencyTracker, {
720
1581
  readModelId: DOCUMENT_INDEXER_READ_MODEL,
721
- rebuildStateOnInit: false
1582
+ rebuildStateOnInit: false,
1583
+ indexing,
1584
+ purgeFence: "locked"
722
1585
  });
723
1586
  this._db = db;
724
1587
  }
725
- async commitOperations(items) {
726
- await this._db.transaction().execute(async (trx) => {
727
- for (const item of items) {
728
- const { operation } = item;
729
- const actionType = operation.action.type;
730
- if (actionType === "ADD_RELATIONSHIP") await this.handleAddRelationship(trx, operation);
731
- else if (actionType === "REMOVE_RELATIONSHIP") await this.handleRemoveRelationship(trx, operation);
732
- else if (actionType === "UPDATE_RELATIONSHIP") await this.handleUpdateRelationship(trx, operation);
733
- }
734
- });
1588
+ writesRows(items) {
1589
+ return items.some((item) => isRelationshipAction(item.operation.action.type));
1590
+ }
1591
+ /** Opens no transaction for a batch carrying no relationship operation. */
1592
+ async commitOperations(items, fenced) {
1593
+ const relationshipOps = items.filter((item) => isRelationshipAction(item.operation.action.type));
1594
+ if (relationshipOps.length === 0) return;
1595
+ if (fenced) {
1596
+ await this.writeRelationships(relationshipOps, fenced);
1597
+ return;
1598
+ }
1599
+ await this._db.transaction().execute((trx) => this.writeRelationships(relationshipOps, trx));
1600
+ }
1601
+ async writeRelationships(relationshipOps, trx) {
1602
+ const purged = await findPurged(trx, addRelationshipIds(relationshipOps));
1603
+ for (const item of relationshipOps) {
1604
+ const { operation } = item;
1605
+ const actionType = operation.action.type;
1606
+ if (actionType === "ADD_RELATIONSHIP") {
1607
+ if (touchesPurged(operation, purged)) continue;
1608
+ await this.handleAddRelationship(trx, operation);
1609
+ } else if (actionType === "REMOVE_RELATIONSHIP") await this.handleRemoveRelationship(trx, operation);
1610
+ else if (actionType === "UPDATE_RELATIONSHIP") await this.handleUpdateRelationship(trx, operation);
1611
+ }
735
1612
  }
736
1613
  async getOutgoing(documentId, types, paging, consistencyToken, signal) {
737
1614
  if (consistencyToken) await this.waitForConsistency(consistencyToken, void 0, signal);
@@ -896,7 +1773,7 @@ var KyselyDocumentIndexer = class extends BaseReadModel {
896
1773
  async findAncestors(documentId, types, consistencyToken, signal) {
897
1774
  if (consistencyToken) await this.waitForConsistency(consistencyToken, void 0, signal);
898
1775
  if (signal?.aborted) throw new Error("Operation aborted");
899
- const nodes = new Set([documentId]);
1776
+ const nodes = /* @__PURE__ */ new Set([documentId]);
900
1777
  const edges = [];
901
1778
  const queue = [documentId];
902
1779
  const visited = /* @__PURE__ */ new Set();
@@ -953,6 +1830,6 @@ var KyselyDocumentIndexer = class extends BaseReadModel {
953
1830
  }
954
1831
  };
955
1832
  //#endregion
956
- export { DOCUMENT_INDEXER_READ_MODEL as a, BaseReadModel as c, KyselyDocumentView as i, ConsistencyTracker as n, DOCUMENT_VIEW_READ_MODEL as o, makeConsistencyKey as r, ReadModelCoordinator as s, KyselyDocumentIndexer as t };
1833
+ export { ContiguousCursor as _, KyselyDocumentView as a, isCatchUpConsumer as b, ReadModelCoordinator as c, reserveBatch as d, BaseReadModel as f, unchunkedReadModelIndexingConfig as g, defaultReadModelIndexingConfig as h, DeletedDocumentRead as i, indexReserved as l, DEFAULT_READ_MODEL_YIELD_DEADLINE_MS as m, ConsistencyTracker as n, DOCUMENT_INDEXER_READ_MODEL as o, DEFAULT_COMMIT_CHUNK_SIZE as p, makeConsistencyKey as r, DOCUMENT_VIEW_READ_MODEL as s, KyselyDocumentIndexer as t, releaseBatch as u, defaultCatchUpConfig as v, CatchUpScheduler as y };
957
1834
 
958
- //# sourceMappingURL=document-indexer-C5Gsa1B3.js.map
1835
+ //# sourceMappingURL=document-indexer-B3yKal-y.js.map