@prisma/streams-server 0.1.1 → 0.1.3

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 (91) hide show
  1. package/CONTRIBUTING.md +8 -0
  2. package/package.json +2 -1
  3. package/src/app.ts +290 -17
  4. package/src/app_core.ts +1833 -698
  5. package/src/app_local.ts +144 -4
  6. package/src/auto_tune.ts +62 -0
  7. package/src/bootstrap.ts +159 -1
  8. package/src/concurrency_gate.ts +108 -0
  9. package/src/config.ts +116 -14
  10. package/src/db/db.ts +1201 -131
  11. package/src/db/schema.ts +308 -8
  12. package/src/foreground_activity.ts +55 -0
  13. package/src/index/indexer.ts +254 -124
  14. package/src/index/lexicon_file_cache.ts +261 -0
  15. package/src/index/lexicon_format.ts +93 -0
  16. package/src/index/lexicon_indexer.ts +789 -0
  17. package/src/index/secondary_indexer.ts +824 -0
  18. package/src/index/secondary_schema.ts +105 -0
  19. package/src/ingest.ts +10 -12
  20. package/src/manifest.ts +143 -8
  21. package/src/memory.ts +183 -8
  22. package/src/metrics.ts +15 -29
  23. package/src/metrics_emitter.ts +26 -3
  24. package/src/notifier.ts +121 -5
  25. package/src/objectstore/accounting.ts +92 -0
  26. package/src/objectstore/mock_r2.ts +1 -1
  27. package/src/objectstore/r2.ts +17 -1
  28. package/src/profiles/evlog/schema.ts +234 -0
  29. package/src/profiles/evlog.ts +299 -0
  30. package/src/profiles/generic.ts +47 -0
  31. package/src/profiles/index.ts +205 -0
  32. package/src/profiles/metrics/block_format.ts +109 -0
  33. package/src/profiles/metrics/normalize.ts +366 -0
  34. package/src/profiles/metrics/schema.ts +319 -0
  35. package/src/profiles/metrics.ts +85 -0
  36. package/src/profiles/profile.ts +225 -0
  37. package/src/{touch/engine.ts → profiles/stateProtocol/changes.ts} +3 -20
  38. package/src/profiles/stateProtocol/routes.ts +389 -0
  39. package/src/profiles/stateProtocol/types.ts +6 -0
  40. package/src/profiles/stateProtocol/validation.ts +51 -0
  41. package/src/profiles/stateProtocol.ts +100 -0
  42. package/src/read_filter.ts +468 -0
  43. package/src/reader.ts +2151 -164
  44. package/src/runtime/host_runtime.ts +5 -0
  45. package/src/runtime_memory.ts +200 -0
  46. package/src/runtime_memory_sampler.ts +235 -0
  47. package/src/schema/read_json.ts +43 -0
  48. package/src/schema/registry.ts +563 -59
  49. package/src/search/agg_format.ts +638 -0
  50. package/src/search/aggregate.ts +389 -0
  51. package/src/search/binary/codec.ts +162 -0
  52. package/src/search/binary/docset.ts +67 -0
  53. package/src/search/binary/restart_strings.ts +181 -0
  54. package/src/search/binary/varint.ts +34 -0
  55. package/src/search/bitset.ts +19 -0
  56. package/src/search/col_format.ts +382 -0
  57. package/src/search/col_runtime.ts +59 -0
  58. package/src/search/column_encoding.ts +43 -0
  59. package/src/search/companion_file_cache.ts +319 -0
  60. package/src/search/companion_format.ts +313 -0
  61. package/src/search/companion_manager.ts +1086 -0
  62. package/src/search/companion_plan.ts +218 -0
  63. package/src/search/fts_format.ts +423 -0
  64. package/src/search/fts_runtime.ts +333 -0
  65. package/src/search/query.ts +875 -0
  66. package/src/search/schema.ts +245 -0
  67. package/src/segment/cache.ts +93 -2
  68. package/src/segment/cached_segment.ts +89 -0
  69. package/src/segment/format.ts +108 -36
  70. package/src/segment/segmenter.ts +79 -5
  71. package/src/segment/segmenter_worker.ts +35 -6
  72. package/src/segment/segmenter_workers.ts +42 -12
  73. package/src/server.ts +150 -36
  74. package/src/sqlite/adapter.ts +185 -14
  75. package/src/sqlite/runtime_stats.ts +163 -0
  76. package/src/stats.ts +3 -3
  77. package/src/stream_size_reconciler.ts +100 -0
  78. package/src/touch/canonical_change.ts +7 -0
  79. package/src/touch/live_metrics.ts +94 -64
  80. package/src/touch/live_templates.ts +15 -1
  81. package/src/touch/manager.ts +166 -88
  82. package/src/touch/{interpreter_worker.ts → processor_worker.ts} +19 -14
  83. package/src/touch/spec.ts +95 -92
  84. package/src/touch/touch_journal.ts +4 -0
  85. package/src/touch/worker_pool.ts +8 -14
  86. package/src/touch/worker_protocol.ts +3 -3
  87. package/src/uploader.ts +77 -6
  88. package/src/util/bloom256.ts +2 -2
  89. package/src/util/byte_lru.ts +73 -0
  90. package/src/util/lru.ts +8 -0
  91. package/src/util/stream_paths.ts +19 -0
@@ -0,0 +1,299 @@
1
+ import { Result } from "better-result";
2
+ import type {
3
+ CachedStreamProfile,
4
+ PreparedJsonRecord,
5
+ StreamProfileDefinition,
6
+ StreamProfilePersistResult,
7
+ StreamProfileReadResult,
8
+ StreamProfileSpec,
9
+ } from "./profile";
10
+ import {
11
+ cloneStreamProfileSpec,
12
+ expectPlainObjectResult,
13
+ isPlainObject,
14
+ normalizeProfileContentType,
15
+ parseStoredProfileJsonResult,
16
+ rejectUnknownKeysResult,
17
+ } from "./profile";
18
+ import { buildEvlogDefaultRegistry } from "./evlog/schema";
19
+
20
+ export type EvlogStreamProfile = {
21
+ kind: "evlog";
22
+ redactKeys?: string[];
23
+ };
24
+
25
+ const DEFAULT_REDACT_KEYS = ["password", "token", "secret", "authorization", "cookie", "apikey"] as const;
26
+ const REDACTED_VALUE = "[REDACTED]";
27
+ const EVLOG_RESERVED_FIELDS = new Set([
28
+ "timestamp",
29
+ "level",
30
+ "service",
31
+ "environment",
32
+ "version",
33
+ "region",
34
+ "requestId",
35
+ "traceId",
36
+ "spanId",
37
+ "method",
38
+ "path",
39
+ "status",
40
+ "duration",
41
+ "message",
42
+ "why",
43
+ "fix",
44
+ "link",
45
+ "sampling",
46
+ "redaction",
47
+ "context",
48
+ ]);
49
+
50
+ type RedactionResult = {
51
+ value: unknown;
52
+ paths: string[];
53
+ };
54
+
55
+ function cloneEvlogProfile(profile: EvlogStreamProfile): EvlogStreamProfile {
56
+ return cloneStreamProfileSpec(profile) as EvlogStreamProfile;
57
+ }
58
+
59
+ function cloneEvlogCache(cache: CachedStreamProfile | null): CachedStreamProfile | null {
60
+ if (!cache || cache.profile.kind !== "evlog") return null;
61
+ return {
62
+ profile: cloneEvlogProfile(cache.profile as EvlogStreamProfile),
63
+ updatedAtMs: cache.updatedAtMs,
64
+ };
65
+ }
66
+
67
+ function isEvlogProfile(profile: StreamProfileSpec | null | undefined): profile is EvlogStreamProfile {
68
+ return !!profile && profile.kind === "evlog";
69
+ }
70
+
71
+ function parseRedactKeysResult(raw: unknown, path: string): Result<string[] | undefined, { message: string }> {
72
+ if (raw === undefined) return Result.ok(undefined);
73
+ if (!Array.isArray(raw)) return Result.err({ message: `${path} must be an array of strings` });
74
+ if (raw.length > 64) return Result.err({ message: `${path} too large (max 64)` });
75
+
76
+ const normalized: string[] = [];
77
+ const seen = new Set<string>();
78
+ for (const item of raw) {
79
+ if (typeof item !== "string") return Result.err({ message: `${path} must be an array of strings` });
80
+ const value = item.trim().toLowerCase();
81
+ if (value === "") return Result.err({ message: `${path} must not contain empty strings` });
82
+ if (seen.has(value)) continue;
83
+ seen.add(value);
84
+ normalized.push(value);
85
+ }
86
+ return Result.ok(normalized);
87
+ }
88
+
89
+ function validateEvlogProfileResult(raw: unknown, path: string): Result<EvlogStreamProfile, { message: string }> {
90
+ const objRes = expectPlainObjectResult(raw, path);
91
+ if (Result.isError(objRes)) return objRes;
92
+ if (objRes.value.kind !== "evlog") {
93
+ return Result.err({ message: `${path}.kind must be evlog` });
94
+ }
95
+ const keyCheck = rejectUnknownKeysResult(objRes.value, ["kind", "redactKeys"], path);
96
+ if (Result.isError(keyCheck)) return keyCheck;
97
+ const redactKeysRes = parseRedactKeysResult(objRes.value.redactKeys, `${path}.redactKeys`);
98
+ if (Result.isError(redactKeysRes)) return redactKeysRes;
99
+ return Result.ok(redactKeysRes.value ? { kind: "evlog", redactKeys: redactKeysRes.value } : { kind: "evlog" });
100
+ }
101
+
102
+ function normalizeString(value: unknown): string | null {
103
+ if (typeof value !== "string") return null;
104
+ const trimmed = value.trim();
105
+ return trimmed === "" ? null : trimmed;
106
+ }
107
+
108
+ function normalizeTraceField(input: Record<string, unknown>, field: "traceId" | "spanId"): string | null {
109
+ const direct = normalizeString(input[field]);
110
+ if (direct) return direct;
111
+ const traceContext = isPlainObject(input.traceContext) ? input.traceContext : null;
112
+ return traceContext ? normalizeString(traceContext[field]) : null;
113
+ }
114
+
115
+ function normalizeOptionalNumber(value: unknown): number | null {
116
+ if (typeof value === "number" && Number.isFinite(value)) return value;
117
+ if (typeof value === "string" && value.trim() !== "") {
118
+ const n = Number(value);
119
+ if (Number.isFinite(n)) return n;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function normalizeOptionalInteger(value: unknown): number | null {
125
+ if (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value)) return value;
126
+ if (typeof value === "string" && value.trim() !== "") {
127
+ const n = Number(value);
128
+ if (Number.isFinite(n) && Number.isInteger(n)) return n;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ function deriveLevel(input: Record<string, unknown>, status: number | null): string {
134
+ const direct = normalizeString(input.level)?.toLowerCase();
135
+ if (direct === "debug" || direct === "info" || direct === "warn" || direct === "error") {
136
+ return direct;
137
+ }
138
+ if (normalizeString(input.why) || normalizeString(input.fix) || normalizeString(input.link)) return "error";
139
+ if (status != null && status >= 500) return "error";
140
+ if (status != null && status >= 400) return "warn";
141
+ return "info";
142
+ }
143
+
144
+ function redactValue(value: unknown, redactKeys: Set<string>, path = ""): RedactionResult {
145
+ if (Array.isArray(value)) {
146
+ const items = value.map((item, index) => redactValue(item, redactKeys, path === "" ? String(index) : `${path}.${index}`));
147
+ return {
148
+ value: items.map((item) => item.value),
149
+ paths: items.flatMap((item) => item.paths),
150
+ };
151
+ }
152
+ if (!isPlainObject(value)) return { value: structuredClone(value), paths: [] };
153
+
154
+ const out: Record<string, unknown> = {};
155
+ const paths: string[] = [];
156
+ for (const [key, raw] of Object.entries(value)) {
157
+ const keyPath = path === "" ? key : `${path}.${key}`;
158
+ if (redactKeys.has(key.toLowerCase())) {
159
+ out[key] = REDACTED_VALUE;
160
+ paths.push(keyPath);
161
+ continue;
162
+ }
163
+ const nested = redactValue(raw, redactKeys, keyPath);
164
+ out[key] = nested.value;
165
+ paths.push(...nested.paths);
166
+ }
167
+ return { value: out, paths };
168
+ }
169
+
170
+ function buildContext(input: Record<string, unknown>): Record<string, unknown> {
171
+ const context: Record<string, unknown> = isPlainObject(input.context) ? structuredClone(input.context) : {};
172
+ for (const [key, value] of Object.entries(input)) {
173
+ if (EVLOG_RESERVED_FIELDS.has(key)) continue;
174
+ context[key] = structuredClone(value);
175
+ }
176
+ if (!isPlainObject(input.context) && Object.prototype.hasOwnProperty.call(input, "context")) {
177
+ context.context = structuredClone(input.context);
178
+ }
179
+ return context;
180
+ }
181
+
182
+ function normalizeEvlogRecordResult(profile: EvlogStreamProfile, value: unknown): Result<PreparedJsonRecord, { message: string }> {
183
+ const objRes = expectPlainObjectResult(value, "evlog record");
184
+ if (Result.isError(objRes)) return objRes;
185
+ const input = objRes.value;
186
+
187
+ const status = normalizeOptionalInteger(input.status);
188
+ const duration = normalizeOptionalNumber(input.duration);
189
+ const timestamp = normalizeString(input.timestamp) ?? new Date().toISOString();
190
+ const requestId = normalizeString(input.requestId);
191
+ const traceId = normalizeTraceField(input, "traceId");
192
+ const spanId = normalizeTraceField(input, "spanId");
193
+ const contextRes = redactValue(buildContext(input), new Set([...DEFAULT_REDACT_KEYS, ...(profile.redactKeys ?? [])]));
194
+
195
+ const normalized = {
196
+ timestamp,
197
+ level: deriveLevel(input, status),
198
+ service: normalizeString(input.service),
199
+ environment: normalizeString(input.environment),
200
+ version: normalizeString(input.version),
201
+ region: normalizeString(input.region),
202
+ requestId,
203
+ traceId,
204
+ spanId,
205
+ method: normalizeString(input.method),
206
+ path: normalizeString(input.path),
207
+ status,
208
+ duration,
209
+ message: normalizeString(input.message),
210
+ why: normalizeString(input.why),
211
+ fix: normalizeString(input.fix),
212
+ link: normalizeString(input.link),
213
+ sampling: Object.prototype.hasOwnProperty.call(input, "sampling") ? structuredClone(input.sampling) : null,
214
+ redaction: { keys: contextRes.paths },
215
+ context: contextRes.value as Record<string, unknown>,
216
+ };
217
+
218
+ return Result.ok({
219
+ value: normalized,
220
+ routingKey: requestId ?? traceId ?? null,
221
+ });
222
+ }
223
+
224
+ export const EVLOG_STREAM_PROFILE_DEFINITION: StreamProfileDefinition = {
225
+ kind: "evlog",
226
+ usesStoredProfileRow: true,
227
+
228
+ defaultProfile(): EvlogStreamProfile {
229
+ return { kind: "evlog" };
230
+ },
231
+
232
+ validateResult(raw, path) {
233
+ return validateEvlogProfileResult(raw, path);
234
+ },
235
+
236
+ readProfileResult({ row, cached }): Result<StreamProfileReadResult, { message: string }> {
237
+ if (!row) return Result.ok({ profile: { kind: "evlog" }, cache: null });
238
+ const cachedCopy = cloneEvlogCache(cached);
239
+ if (cachedCopy && cachedCopy.updatedAtMs === row.updated_at_ms) {
240
+ return Result.ok({
241
+ profile: cloneEvlogProfile(cachedCopy.profile as EvlogStreamProfile),
242
+ cache: cachedCopy,
243
+ });
244
+ }
245
+ const parsedRes = parseStoredProfileJsonResult(row.profile_json);
246
+ if (Result.isError(parsedRes)) return parsedRes;
247
+ const profileRes = validateEvlogProfileResult(parsedRes.value, "profile");
248
+ if (Result.isError(profileRes)) return profileRes;
249
+ const profile = cloneEvlogProfile(profileRes.value);
250
+ return Result.ok({
251
+ profile: cloneEvlogProfile(profile),
252
+ cache: { profile, updatedAtMs: row.updated_at_ms },
253
+ });
254
+ },
255
+
256
+ persistProfileResult({ db, registry, stream, streamRow, profile }): Result<StreamProfilePersistResult, { kind: "bad_request"; message: string; code?: string }> {
257
+ if (!isEvlogProfile(profile)) {
258
+ return Result.err({ kind: "bad_request", message: "invalid evlog profile" });
259
+ }
260
+ const contentType = normalizeProfileContentType(streamRow.content_type);
261
+ if (contentType !== "application/json") {
262
+ return Result.err({
263
+ kind: "bad_request",
264
+ message: "evlog profile requires application/json stream content-type",
265
+ });
266
+ }
267
+ if (streamRow.profile !== "evlog" && streamRow.next_offset > 0n) {
268
+ return Result.err({
269
+ kind: "bad_request",
270
+ message: "evlog profile must be installed before appending data",
271
+ });
272
+ }
273
+
274
+ const persistedProfile = cloneEvlogProfile(profile);
275
+ const registryRes = registry.replaceRegistryResult(stream, buildEvlogDefaultRegistry(stream));
276
+ if (Result.isError(registryRes)) {
277
+ return Result.err({ kind: "bad_request", message: registryRes.error.message });
278
+ }
279
+ db.updateStreamProfile(stream, persistedProfile.kind);
280
+ db.upsertStreamProfile(stream, JSON.stringify(persistedProfile));
281
+ db.deleteStreamTouchState(stream);
282
+ const row = db.getStreamProfile(stream);
283
+ return Result.ok({
284
+ profile: cloneEvlogProfile(persistedProfile),
285
+ cache: {
286
+ profile: persistedProfile,
287
+ updatedAtMs: row?.updated_at_ms ?? db.nowMs(),
288
+ },
289
+ schemaRegistry: registryRes.value,
290
+ });
291
+ },
292
+
293
+ jsonIngest: {
294
+ prepareRecordResult({ profile, value }) {
295
+ if (!isEvlogProfile(profile)) return Result.err({ message: "invalid evlog profile" });
296
+ return normalizeEvlogRecordResult(profile, value);
297
+ },
298
+ },
299
+ };
@@ -0,0 +1,47 @@
1
+ import { Result } from "better-result";
2
+ import type {
3
+ StreamProfileDefinition,
4
+ StreamProfilePersistResult,
5
+ StreamProfileReadResult,
6
+ } from "./profile";
7
+ import { cloneStreamProfileSpec, expectPlainObjectResult, rejectUnknownKeysResult, type StreamProfileSpec } from "./profile";
8
+
9
+ export type GenericStreamProfile = {
10
+ kind: "generic";
11
+ };
12
+
13
+ function cloneGenericProfile(): GenericStreamProfile {
14
+ return { kind: "generic" };
15
+ }
16
+
17
+ export const GENERIC_STREAM_PROFILE_DEFINITION: StreamProfileDefinition = {
18
+ kind: "generic",
19
+ usesStoredProfileRow: false,
20
+
21
+ defaultProfile(): GenericStreamProfile {
22
+ return cloneGenericProfile();
23
+ },
24
+
25
+ validateResult(raw, path) {
26
+ const objRes = expectPlainObjectResult(raw, path);
27
+ if (Result.isError(objRes)) return objRes;
28
+ if (objRes.value.kind !== "generic") {
29
+ return Result.err({ message: `${path}.kind must be generic` });
30
+ }
31
+ const keyCheck = rejectUnknownKeysResult(objRes.value, ["kind"], path);
32
+ if (Result.isError(keyCheck)) return keyCheck;
33
+ return Result.ok(cloneGenericProfile());
34
+ },
35
+
36
+ readProfileResult(): Result<StreamProfileReadResult, { message: string }> {
37
+ return Result.ok({ profile: cloneGenericProfile(), cache: null });
38
+ },
39
+
40
+ persistProfileResult({ db, stream }): Result<StreamProfilePersistResult, { kind: "bad_request"; message: string; code?: string }> {
41
+ db.updateStreamProfile(stream, "generic");
42
+ db.deleteStreamProfile(stream);
43
+ db.deleteStreamTouchState(stream);
44
+ const profile: StreamProfileSpec = cloneStreamProfileSpec(cloneGenericProfile());
45
+ return Result.ok({ profile, cache: null, schemaRegistry: null });
46
+ },
47
+ };
@@ -0,0 +1,205 @@
1
+ import { Result } from "better-result";
2
+ import type { SqliteDurableStore, StreamRow } from "../db/db";
3
+ import type { SchemaRegistry, SchemaRegistryStore } from "../schema/registry";
4
+ import { LruCache } from "../util/lru";
5
+ import { dsError } from "../util/ds_error.ts";
6
+ import { GENERIC_STREAM_PROFILE_DEFINITION } from "./generic";
7
+ import { EVLOG_STREAM_PROFILE_DEFINITION } from "./evlog";
8
+ import { METRICS_STREAM_PROFILE_DEFINITION } from "./metrics";
9
+ import {
10
+ buildStreamProfileResource,
11
+ cloneStreamProfileSpec,
12
+ DEFAULT_STREAM_PROFILE,
13
+ parseProfileUpdateEnvelopeResult,
14
+ readProfileKindResult,
15
+ type CachedStreamProfile,
16
+ type StoredProfileRow,
17
+ type StreamProfileJsonIngestCapability,
18
+ type StreamProfileDefinition,
19
+ type StreamProfileMetricsCapability,
20
+ type StreamProfileReadError,
21
+ type StreamProfileResource,
22
+ type StreamProfileSpec,
23
+ type StreamProfileMutationError,
24
+ type StreamTouchCapability,
25
+ } from "./profile";
26
+ import { STATE_PROTOCOL_STREAM_PROFILE_DEFINITION } from "./stateProtocol";
27
+
28
+ export * from "./profile";
29
+ export { EVLOG_STREAM_PROFILE_DEFINITION } from "./evlog";
30
+ export { GENERIC_STREAM_PROFILE_DEFINITION } from "./generic";
31
+ export { METRICS_STREAM_PROFILE_DEFINITION } from "./metrics";
32
+ export { STATE_PROTOCOL_STREAM_PROFILE_DEFINITION } from "./stateProtocol";
33
+
34
+ const STREAM_PROFILE_DEFINITIONS: Record<string, StreamProfileDefinition> = {
35
+ [EVLOG_STREAM_PROFILE_DEFINITION.kind]: EVLOG_STREAM_PROFILE_DEFINITION,
36
+ [GENERIC_STREAM_PROFILE_DEFINITION.kind]: GENERIC_STREAM_PROFILE_DEFINITION,
37
+ [METRICS_STREAM_PROFILE_DEFINITION.kind]: METRICS_STREAM_PROFILE_DEFINITION,
38
+ [STATE_PROTOCOL_STREAM_PROFILE_DEFINITION.kind]: STATE_PROTOCOL_STREAM_PROFILE_DEFINITION,
39
+ };
40
+ // New built-in profiles are wired here. Core runtime paths must resolve the
41
+ // definition and dispatch through its hooks rather than branching on profile
42
+ // kinds directly.
43
+
44
+ function supportedProfileKindsMessage(): string {
45
+ return listSupportedStreamProfileKinds().join("|");
46
+ }
47
+
48
+ export function listSupportedStreamProfileKinds(): string[] {
49
+ return Object.keys(STREAM_PROFILE_DEFINITIONS);
50
+ }
51
+
52
+ export function resolveStreamProfileDefinition(kind: string | null | undefined): StreamProfileDefinition | null {
53
+ const normalized = typeof kind === "string" && kind !== "" ? kind : DEFAULT_STREAM_PROFILE;
54
+ return STREAM_PROFILE_DEFINITIONS[normalized] ?? null;
55
+ }
56
+
57
+ function resolveStreamProfileDefinitionResult(
58
+ kind: string | null | undefined
59
+ ): Result<StreamProfileDefinition, StreamProfileReadError> {
60
+ const normalized = typeof kind === "string" && kind !== "" ? kind : DEFAULT_STREAM_PROFILE;
61
+ const definition = resolveStreamProfileDefinition(normalized);
62
+ if (!definition) {
63
+ return Result.err({ kind: "invalid_profile", message: `unknown stream profile: ${normalized}` });
64
+ }
65
+ return Result.ok(definition);
66
+ }
67
+
68
+ function cloneCachedProfile(cache: CachedStreamProfile | null): CachedStreamProfile | null {
69
+ if (!cache) return null;
70
+ return {
71
+ profile: cloneStreamProfileSpec(cache.profile),
72
+ updatedAtMs: cache.updatedAtMs,
73
+ };
74
+ }
75
+
76
+ export function parseProfileUpdateResult(body: unknown): Result<StreamProfileSpec, { message: string }> {
77
+ const envelopeRes = parseProfileUpdateEnvelopeResult(body);
78
+ if (Result.isError(envelopeRes)) return envelopeRes;
79
+ const kindRes = readProfileKindResult(envelopeRes.value, "profile");
80
+ if (Result.isError(kindRes)) return kindRes;
81
+ const definition = resolveStreamProfileDefinition(kindRes.value);
82
+ if (!definition) {
83
+ return Result.err({ message: `profile.kind must be ${supportedProfileKindsMessage()}` });
84
+ }
85
+ return definition.validateResult(envelopeRes.value, "profile");
86
+ }
87
+
88
+ export function resolveTouchCapability(profile: StreamProfileSpec | null | undefined): StreamTouchCapability | null {
89
+ if (!profile) return null;
90
+ return resolveStreamProfileDefinition(profile.kind)?.touch ?? null;
91
+ }
92
+
93
+ export function resolveJsonIngestCapability(profile: StreamProfileSpec | null | undefined): StreamProfileJsonIngestCapability | null {
94
+ if (!profile) return null;
95
+ return resolveStreamProfileDefinition(profile.kind)?.jsonIngest ?? null;
96
+ }
97
+
98
+ export function resolveMetricsCapability(profile: StreamProfileSpec | null | undefined): StreamProfileMetricsCapability | null {
99
+ if (!profile) return null;
100
+ return resolveStreamProfileDefinition(profile.kind)?.metrics ?? null;
101
+ }
102
+
103
+ export function resolveEnabledTouchCapability(
104
+ profile: StreamProfileSpec | null | undefined
105
+ ): { capability: StreamTouchCapability; touchCfg: NonNullable<ReturnType<StreamTouchCapability["getTouchConfig"]>> } | null {
106
+ const capability = resolveTouchCapability(profile);
107
+ if (!profile || !capability) return null;
108
+ const touchCfg = capability.getTouchConfig(profile);
109
+ if (!touchCfg) return null;
110
+ return { capability, touchCfg };
111
+ }
112
+
113
+ export function listTouchCapableProfileKinds(): string[] {
114
+ return Object.values(STREAM_PROFILE_DEFINITIONS)
115
+ .filter((definition) => !!definition.touch)
116
+ .map((definition) => definition.kind);
117
+ }
118
+
119
+ export type StreamProfileUpdateResult = {
120
+ resource: StreamProfileResource;
121
+ schemaRegistry: SchemaRegistry | null;
122
+ };
123
+
124
+ export class StreamProfileStore {
125
+ private readonly db: SqliteDurableStore;
126
+ private readonly registry: SchemaRegistryStore;
127
+ private readonly cache: LruCache<string, CachedStreamProfile>;
128
+
129
+ constructor(db: SqliteDurableStore, registry: SchemaRegistryStore, opts?: { cacheEntries?: number }) {
130
+ this.db = db;
131
+ this.registry = registry;
132
+ this.cache = new LruCache(opts?.cacheEntries ?? 1024);
133
+ }
134
+
135
+ private loadRow(stream: string): StoredProfileRow | null {
136
+ return this.db.getStreamProfile(stream);
137
+ }
138
+
139
+ getProfile(stream: string, streamRow?: StreamRow | null): StreamProfileSpec {
140
+ const res = this.getProfileResult(stream, streamRow);
141
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
142
+ return res.value;
143
+ }
144
+
145
+ getProfileResult(stream: string, streamRow?: StreamRow | null): Result<StreamProfileSpec, StreamProfileReadError> {
146
+ const srow = streamRow ?? this.db.getStream(stream);
147
+ if (!srow) return Result.ok(GENERIC_STREAM_PROFILE_DEFINITION.defaultProfile());
148
+
149
+ const definitionRes = resolveStreamProfileDefinitionResult(srow.profile);
150
+ if (Result.isError(definitionRes)) return definitionRes;
151
+
152
+ const row = definitionRes.value.usesStoredProfileRow ? this.loadRow(stream) : null;
153
+ const cached = cloneCachedProfile(this.cache.get(stream) ?? null);
154
+ const readRes = definitionRes.value.readProfileResult({
155
+ row,
156
+ cached: cached && cached.profile.kind === definitionRes.value.kind ? cached : null,
157
+ });
158
+ if (Result.isError(readRes)) {
159
+ return Result.err({ kind: "invalid_profile", message: readRes.error.message });
160
+ }
161
+
162
+ if (readRes.value.cache) this.cache.set(stream, cloneCachedProfile(readRes.value.cache)!);
163
+ else this.cache.delete(stream);
164
+ return Result.ok(cloneStreamProfileSpec(readRes.value.profile));
165
+ }
166
+
167
+ getProfileResource(stream: string, streamRow?: StreamRow | null): StreamProfileResource {
168
+ const res = this.getProfileResourceResult(stream, streamRow);
169
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
170
+ return res.value;
171
+ }
172
+
173
+ getProfileResourceResult(stream: string, streamRow?: StreamRow | null): Result<StreamProfileResource, StreamProfileReadError> {
174
+ const profileRes = this.getProfileResult(stream, streamRow);
175
+ if (Result.isError(profileRes)) return profileRes;
176
+ return Result.ok(buildStreamProfileResource(profileRes.value));
177
+ }
178
+
179
+ updateProfile(stream: string, streamRow: StreamRow, profile: StreamProfileSpec): StreamProfileResource {
180
+ const res = this.updateProfileResult(stream, streamRow, profile);
181
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
182
+ return res.value.resource;
183
+ }
184
+
185
+ updateProfileResult(
186
+ stream: string,
187
+ streamRow: StreamRow,
188
+ profile: StreamProfileSpec
189
+ ): Result<StreamProfileUpdateResult, StreamProfileMutationError> {
190
+ const definition = resolveStreamProfileDefinition(profile.kind);
191
+ if (!definition) {
192
+ return Result.err({ kind: "bad_request", message: `profile.kind must be ${supportedProfileKindsMessage()}` });
193
+ }
194
+
195
+ const persistRes = definition.persistProfileResult({ db: this.db, registry: this.registry, stream, streamRow, profile });
196
+ if (Result.isError(persistRes)) return persistRes;
197
+
198
+ if (persistRes.value.cache) this.cache.set(stream, cloneCachedProfile(persistRes.value.cache)!);
199
+ else this.cache.delete(stream);
200
+ return Result.ok({
201
+ resource: buildStreamProfileResource(cloneStreamProfileSpec(persistRes.value.profile)),
202
+ schemaRegistry: persistRes.value.schemaRegistry ?? null,
203
+ });
204
+ }
205
+ }
@@ -0,0 +1,109 @@
1
+ import { Result } from "better-result";
2
+ import { zstdCompressSync, zstdDecompressSync } from "node:zlib";
3
+ import { BinaryCursor, BinaryPayloadError, BinaryWriter, readI64 } from "../../search/binary/codec";
4
+ import type { MetricsBlockRecord } from "./normalize";
5
+
6
+ export type MetricsBlockSectionInput = {
7
+ record_count: number;
8
+ min_window_start_ms?: number;
9
+ max_window_end_ms?: number;
10
+ records: MetricsBlockRecord[];
11
+ };
12
+
13
+ export type MetricsBlockFormatError = { kind: "invalid_metrics_block"; message: string };
14
+ const METRICS_BLOCK_COMPRESSION_NONE = 0;
15
+ const METRICS_BLOCK_COMPRESSION_ZSTD = 1;
16
+
17
+ function invalidMetricsBlock<T = never>(message: string): Result<T, MetricsBlockFormatError> {
18
+ return Result.err({ kind: "invalid_metrics_block", message });
19
+ }
20
+
21
+ export class MetricsBlockSectionView {
22
+ private recordsCache: MetricsBlockRecord[] | null = null;
23
+ private decodedPayload: Uint8Array | null = null;
24
+
25
+ constructor(
26
+ readonly recordCount: number,
27
+ readonly minWindowStartMs: number | null,
28
+ readonly maxWindowEndMs: number | null,
29
+ private readonly compression: number,
30
+ private readonly recordsPayload: Uint8Array
31
+ ) {}
32
+
33
+ records(): MetricsBlockRecord[] {
34
+ if (!this.recordsCache) {
35
+ this.recordsCache = JSON.parse(new TextDecoder().decode(this.payloadBytes())) as MetricsBlockRecord[];
36
+ }
37
+ return this.recordsCache;
38
+ }
39
+
40
+ private payloadBytes(): Uint8Array {
41
+ if (this.decodedPayload) return this.decodedPayload;
42
+ if (this.compression === METRICS_BLOCK_COMPRESSION_NONE) {
43
+ this.decodedPayload = this.recordsPayload;
44
+ return this.decodedPayload;
45
+ }
46
+ if (this.compression !== METRICS_BLOCK_COMPRESSION_ZSTD) {
47
+ throw new BinaryPayloadError(`unsupported metrics block compression ${this.compression}`);
48
+ }
49
+ try {
50
+ this.decodedPayload = new Uint8Array(zstdDecompressSync(this.recordsPayload));
51
+ } catch (error: unknown) {
52
+ throw new BinaryPayloadError(`invalid compressed metrics block payload: ${String((error as Error)?.message ?? error)}`);
53
+ }
54
+ return this.decodedPayload;
55
+ }
56
+ }
57
+
58
+ export function encodeMetricsBlockSegmentCompanion(input: MetricsBlockSectionInput): Uint8Array {
59
+ const jsonPayload = new TextEncoder().encode(JSON.stringify(input.records));
60
+ const payload = compressPayload(jsonPayload);
61
+ const writer = new BinaryWriter();
62
+ writer.writeU32(input.record_count);
63
+ writer.writeI64(BigInt(input.min_window_start_ms ?? -1));
64
+ writer.writeI64(BigInt(input.max_window_end_ms ?? -1));
65
+ writer.writeU8(payload.compression);
66
+ writer.writeU8(0);
67
+ writer.writeU16(0);
68
+ writer.writeU32(payload.bytes.byteLength);
69
+ writer.writeBytes(payload.bytes);
70
+ return writer.finish();
71
+ }
72
+
73
+ export function decodeMetricsBlockSegmentCompanionResult(bytes: Uint8Array): Result<MetricsBlockSectionView, MetricsBlockFormatError> {
74
+ try {
75
+ const cursor = new BinaryCursor(bytes);
76
+ const recordCount = cursor.readU32();
77
+ const minWindowStartMs = Number(readI64(bytes, 4));
78
+ const maxWindowEndMs = Number(readI64(bytes, 12));
79
+ cursor.readI64();
80
+ cursor.readI64();
81
+ const compression = cursor.readU8();
82
+ cursor.readU8();
83
+ cursor.readU16();
84
+ const payloadLength = cursor.readU32();
85
+ const payload = cursor.readBytes(payloadLength);
86
+ return Result.ok(
87
+ new MetricsBlockSectionView(
88
+ recordCount,
89
+ minWindowStartMs < 0 ? null : minWindowStartMs,
90
+ maxWindowEndMs < 0 ? null : maxWindowEndMs,
91
+ compression,
92
+ payload
93
+ )
94
+ );
95
+ } catch (e: unknown) {
96
+ return invalidMetricsBlock(String((e as any)?.message ?? e));
97
+ }
98
+ }
99
+
100
+ function compressPayload(jsonPayload: Uint8Array): { compression: number; bytes: Uint8Array } {
101
+ if (jsonPayload.byteLength === 0) {
102
+ return { compression: METRICS_BLOCK_COMPRESSION_NONE, bytes: jsonPayload };
103
+ }
104
+ const compressed = new Uint8Array(zstdCompressSync(jsonPayload));
105
+ if (compressed.byteLength >= jsonPayload.byteLength) {
106
+ return { compression: METRICS_BLOCK_COMPRESSION_NONE, bytes: jsonPayload };
107
+ }
108
+ return { compression: METRICS_BLOCK_COMPRESSION_ZSTD, bytes: compressed };
109
+ }