@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.
- package/dist/{build-worker-executor-DnU3ZU4H.js → build-worker-executor-Bonu2jUB.js} +30 -8
- package/dist/build-worker-executor-Bonu2jUB.js.map +1 -0
- package/dist/{document-indexer-C5Gsa1B3.js → document-indexer-B3yKal-y.js} +981 -104
- package/dist/document-indexer-B3yKal-y.js.map +1 -0
- package/dist/entry.d.ts +2 -1
- package/dist/entry.js +13 -6
- package/dist/entry.js.map +1 -1
- package/dist/{errors-D3S6Eysd.js → errors-BM_ULVWd.js} +9 -1
- package/dist/{errors-D3S6Eysd.js.map → errors-BM_ULVWd.js.map} +1 -1
- package/dist/{drive-container-types-yZrksiJR.js → forwarding-logger-BKDP4JnD.js} +2650 -367
- package/dist/forwarding-logger-BKDP4JnD.js.map +1 -0
- package/dist/index.d.ts +4819 -3185
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4146 -892
- package/dist/index.js.map +1 -1
- package/dist/projection-entry.d.ts +2 -1
- package/dist/projection-entry.js +99 -21
- package/dist/projection-entry.js.map +1 -1
- package/dist/{projection-shard-manager-CPQc5XHL.js → projection-shard-manager-BZPCQK-4.js} +24 -4
- package/dist/projection-shard-manager-BZPCQK-4.js.map +1 -0
- package/dist/{projection-worker-wI4PwcV2.js → projection-worker-D8uNFdwX.js} +1 -1
- package/dist/{projection-worker-wI4PwcV2.js.map → projection-worker-D8uNFdwX.js.map} +1 -1
- package/dist/{transport-CuogVKN_.js → transport-B3G50KVd.js} +1 -1
- package/dist/{transport-CuogVKN_.js.map → transport-B3G50KVd.js.map} +1 -1
- package/dist/{transport-ByGviWdZ.js → transport-CZDHzfvy.js} +1 -1
- package/dist/{transport-ByGviWdZ.js.map → transport-CZDHzfvy.js.map} +1 -1
- package/dist/{worker-handle-CrERzl8s.js → worker-handle-CefOwokd.js} +30 -17
- package/dist/worker-handle-CefOwokd.js.map +1 -0
- package/package.json +20 -10
- package/dist/build-worker-executor-DnU3ZU4H.js.map +0 -1
- package/dist/document-indexer-C5Gsa1B3.js.map +0 -1
- package/dist/drive-container-types-yZrksiJR.js.map +0 -1
- package/dist/error-info-Cpu4OY3o.js +0 -62
- package/dist/error-info-Cpu4OY3o.js.map +0 -1
- package/dist/forwarding-logger-BBkMSxuJ.js +0 -85
- package/dist/forwarding-logger-BBkMSxuJ.js.map +0 -1
- package/dist/projection-shard-manager-CPQc5XHL.js.map +0 -1
- package/dist/types-DMKLa0Ok.js +0 -33
- package/dist/types-DMKLa0Ok.js.map +0 -1
- package/dist/worker-HPysBfhx.js +0 -22
- package/dist/worker-HPysBfhx.js.map +0 -1
- package/dist/worker-handle-CrERzl8s.js.map +0 -1
|
@@ -1,17 +1,379 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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
|
-
|
|
1138
|
+
async commitOperations(items, fenced) {
|
|
1139
|
+
const write = async (trx) => {
|
|
306
1140
|
for (const item of items) {
|
|
307
1141
|
const { operation, context } = item;
|
|
308
|
-
|
|
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
|
|
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 (
|
|
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 ===
|
|
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 = [
|
|
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 (
|
|
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
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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 {
|
|
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-
|
|
1835
|
+
//# sourceMappingURL=document-indexer-B3yKal-y.js.map
|