@prisma/streams-server 0.1.0 → 0.1.1
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/CODE_OF_CONDUCT.md +1 -1
- package/CONTRIBUTING.md +5 -5
- package/README.md +3 -3
- package/SECURITY.md +2 -2
- package/package.json +2 -2
- package/src/app_core.ts +114 -391
- package/src/db/db.ts +0 -54
- package/src/db/schema.ts +12 -6
- package/src/touch/interpreter_worker.ts +16 -33
- package/src/touch/live_metrics.ts +18 -49
- package/src/touch/manager.ts +26 -168
- package/src/touch/spec.ts +8 -78
- package/src/touch/worker_protocol.ts +0 -2
- package/src/touch/naming.ts +0 -13
- package/src/touch/routing_key_notifier.ts +0 -275
package/src/touch/manager.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Config } from "../config";
|
|
2
2
|
import type { SqliteDurableStore } from "../db/db";
|
|
3
|
-
import { STREAM_FLAG_TOUCH } from "../db/db";
|
|
4
3
|
import type { IngestQueue } from "../ingest";
|
|
5
4
|
import type { StreamNotifier } from "../notifier";
|
|
6
5
|
import type { SchemaRegistryStore } from "../schema/registry";
|
|
@@ -8,11 +7,9 @@ import { isTouchEnabled } from "./spec";
|
|
|
8
7
|
import { TouchInterpreterWorkerPool } from "./worker_pool";
|
|
9
8
|
import { LruCache } from "../util/lru";
|
|
10
9
|
import type { BackpressureGate } from "../backpressure";
|
|
11
|
-
import { resolveTouchStreamName } from "./naming";
|
|
12
10
|
import { LiveTemplateRegistry, type TemplateDecl } from "./live_templates";
|
|
13
11
|
import { LiveMetricsV2 } from "./live_metrics";
|
|
14
12
|
import type { TouchConfig } from "./spec";
|
|
15
|
-
import type { RoutingKeyNotifier } from "./routing_key_notifier";
|
|
16
13
|
import { TouchJournal } from "./touch_journal";
|
|
17
14
|
import { Result } from "better-result";
|
|
18
15
|
|
|
@@ -74,18 +71,12 @@ const HOT_INTEREST_MAX_KEYS = 64;
|
|
|
74
71
|
|
|
75
72
|
type TouchRecord = {
|
|
76
73
|
keyId: number;
|
|
77
|
-
key?: string;
|
|
78
74
|
watermark: string;
|
|
79
75
|
entity: string;
|
|
80
76
|
kind: "table" | "template";
|
|
81
77
|
templateId?: string;
|
|
82
78
|
};
|
|
83
79
|
|
|
84
|
-
type EnsureTouchStreamError = {
|
|
85
|
-
kind: "touch_stream_content_type_mismatch";
|
|
86
|
-
message: string;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
80
|
type RestrictedTemplateBucketState = {
|
|
90
81
|
bucketId: number;
|
|
91
82
|
templateKeyIds: Set<number>;
|
|
@@ -111,22 +102,17 @@ type StreamRuntimeTotals = {
|
|
|
111
102
|
export class TouchInterpreterManager {
|
|
112
103
|
private readonly cfg: Config;
|
|
113
104
|
private readonly db: SqliteDurableStore;
|
|
114
|
-
private readonly ingest: IngestQueue;
|
|
115
|
-
private readonly notifier: StreamNotifier;
|
|
116
105
|
private readonly registry: SchemaRegistryStore;
|
|
117
|
-
private readonly backpressure?: BackpressureGate;
|
|
118
106
|
private readonly pool: TouchInterpreterWorkerPool;
|
|
119
107
|
private timer: any | null = null;
|
|
120
108
|
private running = false;
|
|
121
109
|
private stopping = false;
|
|
122
110
|
private readonly dirty = new Set<string>();
|
|
123
111
|
private readonly failures = new FailureTracker(1024);
|
|
124
|
-
private readonly lastTrimMs = new LruCache<string, number>(1024);
|
|
125
112
|
private readonly lastBaseWalGc = new LruCache<string, { atMs: number; through: bigint }>(1024);
|
|
126
113
|
private readonly templates: LiveTemplateRegistry;
|
|
127
114
|
private readonly liveMetrics: LiveMetricsV2;
|
|
128
115
|
private readonly lastTemplateGcMsByStream = new LruCache<string, number>(1024);
|
|
129
|
-
private readonly routingKeyNotifier?: RoutingKeyNotifier;
|
|
130
116
|
private readonly journals = new Map<string, TouchJournal>();
|
|
131
117
|
private readonly fineLagCoarseOnlyByStream = new Map<string, boolean>();
|
|
132
118
|
private readonly touchModeByStream = new Map<string, "idle" | "fine" | "restricted" | "coarseOnly">();
|
|
@@ -146,26 +132,20 @@ export class TouchInterpreterManager {
|
|
|
146
132
|
ingest: IngestQueue,
|
|
147
133
|
notifier: StreamNotifier,
|
|
148
134
|
registry: SchemaRegistryStore,
|
|
149
|
-
backpressure?: BackpressureGate
|
|
150
|
-
routingKeyNotifier?: RoutingKeyNotifier
|
|
135
|
+
backpressure?: BackpressureGate
|
|
151
136
|
) {
|
|
152
137
|
this.cfg = cfg;
|
|
153
138
|
this.db = db;
|
|
154
|
-
this.ingest = ingest;
|
|
155
|
-
this.notifier = notifier;
|
|
156
139
|
this.registry = registry;
|
|
157
|
-
this.backpressure = backpressure;
|
|
158
140
|
this.pool = new TouchInterpreterWorkerPool(cfg, cfg.interpreterWorkers);
|
|
159
141
|
this.templates = new LiveTemplateRegistry(db);
|
|
160
142
|
this.liveMetrics = new LiveMetricsV2(db, ingest, {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const j = this.journals.get(derivedStream);
|
|
143
|
+
getTouchJournal: (stream) => {
|
|
144
|
+
const j = this.journals.get(stream);
|
|
164
145
|
if (!j) return null;
|
|
165
146
|
return { meta: j.getMeta(), interval: j.snapshotAndResetIntervalStats() };
|
|
166
147
|
},
|
|
167
148
|
});
|
|
168
|
-
this.routingKeyNotifier = routingKeyNotifier;
|
|
169
149
|
}
|
|
170
150
|
|
|
171
151
|
start(): void {
|
|
@@ -261,33 +241,6 @@ export class TouchInterpreterManager {
|
|
|
261
241
|
}
|
|
262
242
|
}
|
|
263
243
|
|
|
264
|
-
// Opportunistically enforce touch retention. This keeps internal touch
|
|
265
|
-
// WAL bounded even if the touch stream isn't being actively read.
|
|
266
|
-
for (const stream of stateByStream.keys()) {
|
|
267
|
-
if (this.stopping) break;
|
|
268
|
-
const srow = this.db.getStream(stream);
|
|
269
|
-
if (!srow || this.db.isDeleted(srow)) continue;
|
|
270
|
-
const regRes = this.registry.getRegistryResult(stream);
|
|
271
|
-
if (Result.isError(regRes)) {
|
|
272
|
-
// eslint-disable-next-line no-console
|
|
273
|
-
console.error("touch registry read failed", stream, regRes.error.message);
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
const reg = regRes.value;
|
|
277
|
-
if (!isTouchEnabled(reg.interpreter)) continue;
|
|
278
|
-
const derived = resolveTouchStreamName(stream, reg.interpreter.touch);
|
|
279
|
-
if ((reg.interpreter.touch.storage ?? "memory") === "sqlite") {
|
|
280
|
-
const ensureRes = this.ensureTouchStream(derived);
|
|
281
|
-
if (Result.isError(ensureRes)) {
|
|
282
|
-
// eslint-disable-next-line no-console
|
|
283
|
-
console.error("touch retention stream validation failed", stream, ensureRes.error.message);
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
const retentionMs = reg.interpreter.touch.retention?.maxAgeMs;
|
|
287
|
-
if (retentionMs != null) this.maybeTrimTouchStream(derived, retentionMs);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
244
|
// Opportunistically GC base WAL beyond the interpreter checkpoint.
|
|
292
245
|
//
|
|
293
246
|
// commitManifest() already GC's on upload, but it can't retroactively GC
|
|
@@ -369,7 +322,6 @@ export class TouchInterpreterManager {
|
|
|
369
322
|
return;
|
|
370
323
|
}
|
|
371
324
|
const touchCfg = reg.interpreter.touch;
|
|
372
|
-
const touchStorage = touchCfg.storage ?? "memory";
|
|
373
325
|
const failProcessing = (message: string): void => {
|
|
374
326
|
this.failures.recordFailure(stream);
|
|
375
327
|
this.liveMetrics.recordInterpreterError(stream, touchCfg);
|
|
@@ -378,24 +330,19 @@ export class TouchInterpreterManager {
|
|
|
378
330
|
};
|
|
379
331
|
|
|
380
332
|
const nowMs = Date.now();
|
|
381
|
-
const hotFine =
|
|
333
|
+
const hotFine = this.getHotFineSnapshot(stream, touchCfg, nowMs);
|
|
382
334
|
const fineWaitersActive = hotFine?.fineWaitersActive ?? 0;
|
|
383
335
|
const coarseWaitersActive = hotFine?.coarseWaitersActive ?? 0;
|
|
384
|
-
const hasAnyWaiters =
|
|
336
|
+
const hasAnyWaiters = fineWaitersActive + coarseWaitersActive > 0;
|
|
385
337
|
const hasFineDemand =
|
|
386
|
-
|
|
387
|
-
? fineWaitersActive > 0 || (hotFine?.broadFineWaitersActive ?? 0) > 0 || (hotFine?.hotKeyCount ?? 0) > 0 || (hotFine?.hotTemplateCount ?? 0) > 0
|
|
388
|
-
: true;
|
|
338
|
+
fineWaitersActive > 0 || (hotFine?.broadFineWaitersActive ?? 0) > 0 || (hotFine?.hotKeyCount ?? 0) > 0 || (hotFine?.hotTemplateCount ?? 0) > 0;
|
|
389
339
|
|
|
390
340
|
// Guardrail: when lag/backlog grows too large, temporarily suppress
|
|
391
341
|
// fine/template touches (coarse table touches are still emitted).
|
|
392
342
|
const lagAtStart = toOffset >= interpretedThrough ? toOffset - interpretedThrough : 0n;
|
|
393
343
|
const suppressFineDueToLag = this.computeSuppressFineDueToLag(stream, touchCfg, lagAtStart, hasFineDemand);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const j = this.getOrCreateJournal(derived, touchCfg);
|
|
397
|
-
j.setCoalesceMs(this.computeAdaptiveCoalesceMs(touchCfg, lagAtStart, hasAnyWaiters));
|
|
398
|
-
}
|
|
344
|
+
const j = this.getOrCreateJournal(stream, touchCfg);
|
|
345
|
+
j.setCoalesceMs(this.computeAdaptiveCoalesceMs(touchCfg, lagAtStart, hasAnyWaiters));
|
|
399
346
|
|
|
400
347
|
const fineBudgetPerBatch = Math.max(0, Math.floor(touchCfg.fineTouchBudgetPerBatch ?? 2000));
|
|
401
348
|
const lagReservedFineBudgetPerBatch = Math.max(0, Math.floor(touchCfg.lagReservedFineTouchBudgetPerBatch ?? 200));
|
|
@@ -480,7 +427,7 @@ export class TouchInterpreterManager {
|
|
|
480
427
|
let fineSkippedColdKey = 0;
|
|
481
428
|
let fineSkippedTemplateBucket = 0;
|
|
482
429
|
|
|
483
|
-
if (
|
|
430
|
+
if (hotFine && hotFine.keyFilteringEnabled && fineGranularity !== "template") {
|
|
484
431
|
const keyActiveSet = hotFine.hotKeyActiveSet;
|
|
485
432
|
const keyGraceSet = hotFine.hotKeyGraceSet;
|
|
486
433
|
const keyCount = (keyActiveSet?.size ?? 0) + (keyGraceSet?.size ?? 0);
|
|
@@ -503,76 +450,22 @@ export class TouchInterpreterManager {
|
|
|
503
450
|
}
|
|
504
451
|
}
|
|
505
452
|
|
|
506
|
-
if (
|
|
453
|
+
if (fineGranularity === "template" && touches.length > 0) {
|
|
507
454
|
const coalesced = this.coalesceRestrictedTemplateTouches(stream, touchCfg, touches);
|
|
508
455
|
touches = coalesced.touches;
|
|
509
456
|
fineSkippedTemplateBucket = coalesced.dropped;
|
|
510
457
|
}
|
|
511
458
|
|
|
512
459
|
if (touches.length > 0) {
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const encoder = new TextEncoder();
|
|
521
|
-
for (const t of touches) {
|
|
522
|
-
if (!t.key) {
|
|
523
|
-
failProcessing("sqlite touch storage requires routing key strings");
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
const rows = touches.map((t) => ({
|
|
528
|
-
routingKey: encoder.encode(t.key!),
|
|
529
|
-
contentType: "application/json",
|
|
530
|
-
payload: encoder.encode(
|
|
531
|
-
JSON.stringify({
|
|
532
|
-
sourceOffset: t.watermark,
|
|
533
|
-
entity: t.entity,
|
|
534
|
-
kind: t.kind,
|
|
535
|
-
...(t.kind === "template" && t.templateId ? { templateId: t.templateId } : {}),
|
|
536
|
-
})
|
|
537
|
-
),
|
|
538
|
-
}));
|
|
539
|
-
const appendRes = await this.ingest.appendInternal({
|
|
540
|
-
stream: derived,
|
|
541
|
-
baseAppendMs: this.db.nowMs(),
|
|
542
|
-
rows,
|
|
543
|
-
contentType: "application/json",
|
|
544
|
-
});
|
|
545
|
-
if (Result.isError(appendRes)) {
|
|
546
|
-
failProcessing(`touch append failed: ${appendRes.error.kind}`);
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
if (appendRes.value.appendedRows > 0) {
|
|
550
|
-
this.notifier.notify(derived, appendRes.value.lastOffset);
|
|
551
|
-
if (this.routingKeyNotifier) {
|
|
552
|
-
const appended = appendRes.value.appendedRows;
|
|
553
|
-
const start = appendRes.value.lastOffset - BigInt(appended) + 1n;
|
|
554
|
-
let keys = touches.map((t) => t.key ?? "");
|
|
555
|
-
// Be defensive: only notify for the rows we actually appended.
|
|
556
|
-
if (keys.length !== appended) keys = keys.slice(Math.max(0, keys.length - appended));
|
|
557
|
-
let seq = start;
|
|
558
|
-
for (const k of keys) {
|
|
559
|
-
if (!k) continue;
|
|
560
|
-
this.routingKeyNotifier.notify(derived, k, seq);
|
|
561
|
-
seq += 1n;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
} else {
|
|
566
|
-
const j = this.getOrCreateJournal(derived, touchCfg);
|
|
567
|
-
for (const t of touches) {
|
|
568
|
-
let sourceOffsetSeq: bigint | undefined;
|
|
569
|
-
try {
|
|
570
|
-
sourceOffsetSeq = BigInt(t.watermark);
|
|
571
|
-
} catch {
|
|
572
|
-
sourceOffsetSeq = undefined;
|
|
573
|
-
}
|
|
574
|
-
j.touch(t.keyId >>> 0, sourceOffsetSeq);
|
|
460
|
+
const j = this.getOrCreateJournal(stream, touchCfg);
|
|
461
|
+
for (const t of touches) {
|
|
462
|
+
let sourceOffsetSeq: bigint | undefined;
|
|
463
|
+
try {
|
|
464
|
+
sourceOffsetSeq = BigInt(t.watermark);
|
|
465
|
+
} catch {
|
|
466
|
+
sourceOffsetSeq = undefined;
|
|
575
467
|
}
|
|
468
|
+
j.touch(t.keyId >>> 0, sourceOffsetSeq);
|
|
576
469
|
}
|
|
577
470
|
}
|
|
578
471
|
|
|
@@ -649,38 +542,6 @@ export class TouchInterpreterManager {
|
|
|
649
542
|
this.failures.recordSuccess(stream);
|
|
650
543
|
}
|
|
651
544
|
|
|
652
|
-
private ensureTouchStream(stream: string): Result<void, EnsureTouchStreamError> {
|
|
653
|
-
const existing = this.db.getStream(stream);
|
|
654
|
-
if (existing) {
|
|
655
|
-
if (String(existing.content_type) !== "application/json") {
|
|
656
|
-
return Result.err({
|
|
657
|
-
kind: "touch_stream_content_type_mismatch",
|
|
658
|
-
message: `touch stream content-type mismatch: ${existing.content_type}`,
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
if ((existing.stream_flags & STREAM_FLAG_TOUCH) === 0) this.db.addStreamFlags(stream, STREAM_FLAG_TOUCH);
|
|
662
|
-
return Result.ok(undefined);
|
|
663
|
-
}
|
|
664
|
-
this.db.ensureStream(stream, { contentType: "application/json", streamFlags: STREAM_FLAG_TOUCH });
|
|
665
|
-
return Result.ok(undefined);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
private maybeTrimTouchStream(stream: string, maxAgeMs: number): void {
|
|
669
|
-
const now = Date.now();
|
|
670
|
-
const last = this.lastTrimMs.get(stream) ?? 0;
|
|
671
|
-
// Throttle trims; tick can run frequently.
|
|
672
|
-
if (now - last < 10_000) return;
|
|
673
|
-
this.lastTrimMs.set(stream, now);
|
|
674
|
-
|
|
675
|
-
try {
|
|
676
|
-
const res = this.db.trimWalByAge(stream, maxAgeMs);
|
|
677
|
-
if (res.trimmedBytes > 0 && this.backpressure) this.backpressure.adjustOnWalTrim(res.trimmedBytes);
|
|
678
|
-
} catch (e) {
|
|
679
|
-
// eslint-disable-next-line no-console
|
|
680
|
-
console.error("touch retention trim failed", stream, e);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
545
|
private maybeGcBaseWal(stream: string, uploadedThrough: bigint, interpretedThrough: bigint): void {
|
|
685
546
|
const gcTargetThrough = interpretedThrough < uploadedThrough ? interpretedThrough : uploadedThrough;
|
|
686
547
|
if (gcTargetThrough < 0n) return;
|
|
@@ -784,8 +645,6 @@ export class TouchInterpreterManager {
|
|
|
784
645
|
templateIdsUsed: string[];
|
|
785
646
|
interestMode: "fine" | "coarse";
|
|
786
647
|
}): () => void {
|
|
787
|
-
if ((args.touchCfg.storage ?? "memory") !== "memory") return () => {};
|
|
788
|
-
|
|
789
648
|
const nowMs = Date.now();
|
|
790
649
|
const limits = this.getHotFineLimits(args.touchCfg);
|
|
791
650
|
const state = this.getOrCreateHotFineState(args.stream);
|
|
@@ -878,10 +737,9 @@ export class TouchInterpreterManager {
|
|
|
878
737
|
journalTimeoutSweepMsTotal: number;
|
|
879
738
|
} {
|
|
880
739
|
const nowMs = Date.now();
|
|
881
|
-
const hot =
|
|
740
|
+
const hot = this.getHotFineSnapshot(args.stream, args.touchCfg, nowMs);
|
|
882
741
|
const totals = this.getOrCreateRuntimeTotals(args.stream);
|
|
883
|
-
const
|
|
884
|
-
const journal = (args.touchCfg.storage ?? "memory") === "memory" ? this.journals.get(derived) ?? null : null;
|
|
742
|
+
const journal = this.journals.get(args.stream) ?? null;
|
|
885
743
|
const journalTotals = journal?.getTotalStats();
|
|
886
744
|
return {
|
|
887
745
|
lagSourceOffsets: this.lagSourceOffsetsByStream.get(args.stream) ?? 0,
|
|
@@ -953,8 +811,8 @@ export class TouchInterpreterManager {
|
|
|
953
811
|
return Array.from(entities);
|
|
954
812
|
}
|
|
955
813
|
|
|
956
|
-
getOrCreateJournal(
|
|
957
|
-
const existing = this.journals.get(
|
|
814
|
+
getOrCreateJournal(stream: string, touchCfg: TouchConfig): TouchJournal {
|
|
815
|
+
const existing = this.journals.get(stream);
|
|
958
816
|
if (existing) return existing;
|
|
959
817
|
const mem = touchCfg.memory ?? {};
|
|
960
818
|
const j = new TouchJournal({
|
|
@@ -964,7 +822,7 @@ export class TouchInterpreterManager {
|
|
|
964
822
|
pendingMaxKeys: mem.pendingMaxKeys ?? 100_000,
|
|
965
823
|
keyIndexMaxKeys: mem.keyIndexMaxKeys ?? 32,
|
|
966
824
|
});
|
|
967
|
-
this.journals.set(
|
|
825
|
+
this.journals.set(stream, j);
|
|
968
826
|
return j;
|
|
969
827
|
}
|
|
970
828
|
|
|
@@ -978,8 +836,8 @@ export class TouchInterpreterManager {
|
|
|
978
836
|
return maxCoalesceMs;
|
|
979
837
|
}
|
|
980
838
|
|
|
981
|
-
getJournalIfExists(
|
|
982
|
-
return this.journals.get(
|
|
839
|
+
getJournalIfExists(stream: string): TouchJournal | null {
|
|
840
|
+
return this.journals.get(stream) ?? null;
|
|
983
841
|
}
|
|
984
842
|
|
|
985
843
|
private computeSuppressFineDueToLag(stream: string, touchCfg: TouchConfig, lagAtStart: bigint, hasFineDemand: boolean): boolean {
|
|
@@ -1014,7 +872,7 @@ export class TouchInterpreterManager {
|
|
|
1014
872
|
hasActiveWaiters = false;
|
|
1015
873
|
} else {
|
|
1016
874
|
const reg = regRes.value;
|
|
1017
|
-
if (isTouchEnabled(reg.interpreter)
|
|
875
|
+
if (isTouchEnabled(reg.interpreter)) {
|
|
1018
876
|
const snap = this.getHotFineSnapshot(stream, reg.interpreter.touch, nowMs);
|
|
1019
877
|
hasActiveWaiters = snap.fineWaitersActive + snap.coarseWaitersActive > 0;
|
|
1020
878
|
}
|
package/src/touch/spec.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Result } from "better-result";
|
|
2
|
-
import { parseDurationMsResult } from "../util/duration.ts";
|
|
3
2
|
import { dsError } from "../util/ds_error.ts";
|
|
4
3
|
|
|
5
4
|
export type StreamInterpreterConfig = {
|
|
@@ -15,22 +14,6 @@ export type StreamInterpreterConfigValidationError = {
|
|
|
15
14
|
|
|
16
15
|
export type TouchConfig = {
|
|
17
16
|
enabled: boolean;
|
|
18
|
-
/**
|
|
19
|
-
* Touch storage backend.
|
|
20
|
-
*
|
|
21
|
-
* - memory: in-process, lossy (false positives allowed), epoch-scoped journal.
|
|
22
|
-
* - sqlite: derived companion stream in SQLite WAL (legacy).
|
|
23
|
-
*
|
|
24
|
-
* Default: memory.
|
|
25
|
-
*/
|
|
26
|
-
storage?: "memory" | "sqlite";
|
|
27
|
-
/**
|
|
28
|
-
* Advanced override. When unset, the server exposes touches as a companion of
|
|
29
|
-
* the source stream at `/v1/stream/<name>/touch` and uses an internal derived
|
|
30
|
-
* stream name.
|
|
31
|
-
*/
|
|
32
|
-
derivedStream?: string;
|
|
33
|
-
retention?: { maxAgeMs: number };
|
|
34
17
|
/**
|
|
35
18
|
* Coarse invalidation interval. The server emits at most one table-touch per
|
|
36
19
|
* entity per interval.
|
|
@@ -209,72 +192,22 @@ function parseIntegerField(
|
|
|
209
192
|
return Result.ok(n);
|
|
210
193
|
}
|
|
211
194
|
|
|
212
|
-
function validateRetentionResult(
|
|
213
|
-
raw: any
|
|
214
|
-
): Result<{ maxAgeMs: number } | undefined, StreamInterpreterConfigValidationError> {
|
|
215
|
-
if (raw == null) return Result.ok(undefined);
|
|
216
|
-
if (!raw || typeof raw !== "object") return invalidInterpreter("interpreter.touch.retention must be an object");
|
|
217
|
-
const maxAge = raw.maxAge;
|
|
218
|
-
const maxAgeMsRaw = raw.maxAgeMs;
|
|
219
|
-
if (maxAge !== undefined && maxAgeMsRaw !== undefined) {
|
|
220
|
-
return invalidInterpreter("interpreter.touch.retention must specify only one of maxAge|maxAgeMs");
|
|
221
|
-
}
|
|
222
|
-
let ms: number | null = null;
|
|
223
|
-
if (maxAgeMsRaw !== undefined) {
|
|
224
|
-
if (typeof maxAgeMsRaw !== "number" || !Number.isFinite(maxAgeMsRaw) || maxAgeMsRaw < 0) {
|
|
225
|
-
return invalidInterpreter("interpreter.touch.retention.maxAgeMs must be a non-negative number");
|
|
226
|
-
}
|
|
227
|
-
ms = maxAgeMsRaw;
|
|
228
|
-
} else if (maxAge !== undefined) {
|
|
229
|
-
if (typeof maxAge !== "string" || maxAge.trim() === "") {
|
|
230
|
-
return invalidInterpreter("interpreter.touch.retention.maxAge must be a non-empty duration string");
|
|
231
|
-
}
|
|
232
|
-
const durationRes = parseDurationMsResult(maxAge);
|
|
233
|
-
if (Result.isError(durationRes)) {
|
|
234
|
-
return invalidInterpreter(`interpreter.touch.retention.maxAge ${durationRes.error.message}`);
|
|
235
|
-
}
|
|
236
|
-
ms = durationRes.value;
|
|
237
|
-
if (ms < 0) ms = 0;
|
|
238
|
-
} else {
|
|
239
|
-
return invalidInterpreter("interpreter.touch.retention must include maxAge or maxAgeMs");
|
|
240
|
-
}
|
|
241
|
-
return Result.ok({ maxAgeMs: ms });
|
|
242
|
-
}
|
|
243
|
-
|
|
244
195
|
function validateTouchConfigResult(raw: any): Result<TouchConfig, StreamInterpreterConfigValidationError> {
|
|
245
196
|
if (!raw || typeof raw !== "object") return invalidInterpreter("interpreter.touch must be an object");
|
|
246
197
|
const enabled = !!raw.enabled;
|
|
247
198
|
if (!enabled) {
|
|
248
|
-
return Result.ok({
|
|
249
|
-
enabled: false,
|
|
250
|
-
storage: undefined,
|
|
251
|
-
derivedStream: undefined,
|
|
252
|
-
retention: undefined,
|
|
253
|
-
});
|
|
199
|
+
return Result.ok({ enabled: false });
|
|
254
200
|
}
|
|
255
201
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return invalidInterpreter("interpreter.touch.storage must be memory|sqlite");
|
|
202
|
+
if (raw.storage !== undefined) {
|
|
203
|
+
return invalidInterpreter("interpreter.touch.storage is no longer supported; touch always uses the in-memory journal");
|
|
259
204
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
: typeof raw.derivedStream === "string" && raw.derivedStream.trim() !== ""
|
|
266
|
-
? raw.derivedStream
|
|
267
|
-
: null;
|
|
268
|
-
if (derivedStream === null) {
|
|
269
|
-
return invalidInterpreter("interpreter.touch.derivedStream must be a non-empty string when provided");
|
|
205
|
+
if (raw.derivedStream !== undefined) {
|
|
206
|
+
return invalidInterpreter("interpreter.touch.derivedStream is no longer supported");
|
|
207
|
+
}
|
|
208
|
+
if (raw.retention !== undefined) {
|
|
209
|
+
return invalidInterpreter("interpreter.touch.retention is no longer supported");
|
|
270
210
|
}
|
|
271
|
-
|
|
272
|
-
// Touch companions are intended as short-retention invalidation journals.
|
|
273
|
-
// Default to 24h to prevent unbounded growth if retention is omitted.
|
|
274
|
-
const retentionRaw = raw.retention;
|
|
275
|
-
const retentionRes = retentionRaw === undefined ? Result.ok({ maxAgeMs: 24 * 60 * 60 * 1000 }) : validateRetentionResult(retentionRaw);
|
|
276
|
-
if (Result.isError(retentionRes)) return invalidInterpreter(retentionRes.error.message);
|
|
277
|
-
const retention = retentionRes.value;
|
|
278
211
|
|
|
279
212
|
const coarseIntervalMsRes = parseNumberField(
|
|
280
213
|
raw.coarseIntervalMs,
|
|
@@ -454,9 +387,6 @@ function validateTouchConfigResult(raw: any): Result<TouchConfig, StreamInterpre
|
|
|
454
387
|
|
|
455
388
|
return Result.ok({
|
|
456
389
|
enabled: true,
|
|
457
|
-
storage,
|
|
458
|
-
derivedStream,
|
|
459
|
-
retention,
|
|
460
390
|
coarseIntervalMs: coarseIntervalMsRes.value,
|
|
461
391
|
touchCoalesceWindowMs: touchCoalesceWindowMsRes.value,
|
|
462
392
|
onMissingBefore,
|
|
@@ -2,7 +2,6 @@ import type { StreamInterpreterConfig } from "./spec.ts";
|
|
|
2
2
|
|
|
3
3
|
export type TouchRow = {
|
|
4
4
|
keyId: number;
|
|
5
|
-
key?: string;
|
|
6
5
|
watermark: string; // source stream offset (base-10 string)
|
|
7
6
|
entity: string;
|
|
8
7
|
kind: "table" | "template";
|
|
@@ -30,7 +29,6 @@ export type ProcessResult = {
|
|
|
30
29
|
type: "result";
|
|
31
30
|
id: number;
|
|
32
31
|
stream: string;
|
|
33
|
-
derivedStream: string;
|
|
34
32
|
processedThrough: bigint;
|
|
35
33
|
touches: TouchRow[];
|
|
36
34
|
stats: {
|
package/src/touch/naming.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { TouchConfig } from "./spec.ts";
|
|
2
|
-
|
|
3
|
-
const DEFAULT_TOUCH_SUFFIX = ".__touch";
|
|
4
|
-
|
|
5
|
-
export function defaultTouchStreamName(sourceStream: string): string {
|
|
6
|
-
return `${sourceStream}${DEFAULT_TOUCH_SUFFIX}`;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function resolveTouchStreamName(sourceStream: string, touch: TouchConfig): string {
|
|
10
|
-
const override = touch.derivedStream;
|
|
11
|
-
if (override && override.trim() !== "") return override;
|
|
12
|
-
return defaultTouchStreamName(sourceStream);
|
|
13
|
-
}
|