@tungthedev/streams-server 0.2.0

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 (183) hide show
  1. package/CODE_OF_CONDUCT.md +45 -0
  2. package/CONTRIBUTING.md +76 -0
  3. package/LICENSE +201 -0
  4. package/README.md +58 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/prisma-streams-server +2 -0
  7. package/package.json +46 -0
  8. package/src/app.ts +583 -0
  9. package/src/app_core.ts +3144 -0
  10. package/src/app_local.ts +206 -0
  11. package/src/auth.ts +124 -0
  12. package/src/auto_tune.ts +69 -0
  13. package/src/backpressure.ts +66 -0
  14. package/src/bootstrap.ts +613 -0
  15. package/src/compute/demo_entry.ts +415 -0
  16. package/src/compute/demo_site.ts +1242 -0
  17. package/src/compute/entry.ts +19 -0
  18. package/src/compute/package_entry.ts +4 -0
  19. package/src/compute/virtual-modules.d.ts +15 -0
  20. package/src/compute/worker_module_url.ts +9 -0
  21. package/src/concurrency_gate.ts +108 -0
  22. package/src/config.ts +402 -0
  23. package/src/db/bootstrap_store.ts +9 -0
  24. package/src/db/db.ts +2424 -0
  25. package/src/db/schema.ts +925 -0
  26. package/src/db/sqlite_manifest_snapshot.ts +81 -0
  27. package/src/db/sqlite_touch_store.ts +491 -0
  28. package/src/db/sqlite_wal_store.ts +472 -0
  29. package/src/details/full_mode_details.ts +568 -0
  30. package/src/expiry_sweeper.ts +47 -0
  31. package/src/foreground_activity.ts +55 -0
  32. package/src/hist.ts +169 -0
  33. package/src/index/binary_fuse.ts +379 -0
  34. package/src/index/indexer.ts +947 -0
  35. package/src/index/lexicon_file_cache.ts +261 -0
  36. package/src/index/lexicon_format.ts +93 -0
  37. package/src/index/lexicon_indexer.ts +863 -0
  38. package/src/index/run_cache.ts +84 -0
  39. package/src/index/run_format.ts +213 -0
  40. package/src/index/schedule.ts +28 -0
  41. package/src/index/secondary_indexer.ts +901 -0
  42. package/src/index/secondary_schema.ts +105 -0
  43. package/src/ingest.ts +309 -0
  44. package/src/lens/lens.ts +501 -0
  45. package/src/manifest.ts +249 -0
  46. package/src/memory.ts +334 -0
  47. package/src/metrics.ts +147 -0
  48. package/src/metrics_emitter.ts +83 -0
  49. package/src/notifier.ts +180 -0
  50. package/src/objectstore/accounting.ts +151 -0
  51. package/src/objectstore/interface.ts +13 -0
  52. package/src/objectstore/mock_r2.ts +269 -0
  53. package/src/objectstore/null.ts +32 -0
  54. package/src/objectstore/r2.ts +318 -0
  55. package/src/observe/pairing.ts +61 -0
  56. package/src/observe/request.ts +772 -0
  57. package/src/offset.ts +70 -0
  58. package/src/postgres/bootstrap.ts +269 -0
  59. package/src/postgres/companions.ts +197 -0
  60. package/src/postgres/control_restore.ts +109 -0
  61. package/src/postgres/details.ts +189 -0
  62. package/src/postgres/lexicon_index.ts +260 -0
  63. package/src/postgres/routing_index.ts +189 -0
  64. package/src/postgres/rows.ts +132 -0
  65. package/src/postgres/schema.ts +355 -0
  66. package/src/postgres/secondary_index.ts +238 -0
  67. package/src/postgres/segments.ts +900 -0
  68. package/src/postgres/stats.ts +103 -0
  69. package/src/postgres/store.ts +947 -0
  70. package/src/postgres/touch.ts +591 -0
  71. package/src/postgres/types.ts +32 -0
  72. package/src/profiles/evlog/schema.ts +234 -0
  73. package/src/profiles/evlog.ts +473 -0
  74. package/src/profiles/generic.ts +51 -0
  75. package/src/profiles/index.ts +237 -0
  76. package/src/profiles/metrics/block_format.ts +109 -0
  77. package/src/profiles/metrics/normalize.ts +366 -0
  78. package/src/profiles/metrics/schema.ts +319 -0
  79. package/src/profiles/metrics.ts +83 -0
  80. package/src/profiles/otelTraces/normalize.ts +955 -0
  81. package/src/profiles/otelTraces/otlp.ts +1002 -0
  82. package/src/profiles/otelTraces/schema.ts +408 -0
  83. package/src/profiles/otelTraces.ts +390 -0
  84. package/src/profiles/profile.ts +284 -0
  85. package/src/profiles/stateProtocol/change_event_conformance.typecheck.ts +35 -0
  86. package/src/profiles/stateProtocol/changes.ts +24 -0
  87. package/src/profiles/stateProtocol/ingest.ts +115 -0
  88. package/src/profiles/stateProtocol/routes.ts +511 -0
  89. package/src/profiles/stateProtocol/types.ts +6 -0
  90. package/src/profiles/stateProtocol/validation.ts +51 -0
  91. package/src/profiles/stateProtocol.ts +107 -0
  92. package/src/read_filter.ts +468 -0
  93. package/src/reader.ts +2986 -0
  94. package/src/runtime/hash.ts +156 -0
  95. package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
  96. package/src/runtime/hash_vendor/NOTICE.md +8 -0
  97. package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
  98. package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
  99. package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
  100. package/src/runtime/host_runtime.ts +5 -0
  101. package/src/runtime_memory.ts +200 -0
  102. package/src/runtime_memory_sampler.ts +237 -0
  103. package/src/schema/lens_schema.ts +290 -0
  104. package/src/schema/proof.ts +547 -0
  105. package/src/schema/read_json.ts +51 -0
  106. package/src/schema/registry.ts +966 -0
  107. package/src/search/agg_format.ts +638 -0
  108. package/src/search/aggregate.ts +409 -0
  109. package/src/search/binary/codec.ts +162 -0
  110. package/src/search/binary/docset.ts +67 -0
  111. package/src/search/binary/restart_strings.ts +181 -0
  112. package/src/search/binary/varint.ts +34 -0
  113. package/src/search/bitset.ts +19 -0
  114. package/src/search/col_format.ts +382 -0
  115. package/src/search/col_runtime.ts +59 -0
  116. package/src/search/column_encoding.ts +43 -0
  117. package/src/search/companion_file_cache.ts +319 -0
  118. package/src/search/companion_format.ts +327 -0
  119. package/src/search/companion_manager.ts +1305 -0
  120. package/src/search/companion_plan.ts +229 -0
  121. package/src/search/exact_format.ts +281 -0
  122. package/src/search/exact_runtime.ts +55 -0
  123. package/src/search/fts_format.ts +423 -0
  124. package/src/search/fts_runtime.ts +333 -0
  125. package/src/search/query.ts +875 -0
  126. package/src/search/schema.ts +245 -0
  127. package/src/segment/cache.ts +270 -0
  128. package/src/segment/cached_segment.ts +89 -0
  129. package/src/segment/format.ts +403 -0
  130. package/src/segment/segmenter.ts +412 -0
  131. package/src/segment/segmenter_worker.ts +72 -0
  132. package/src/segment/segmenter_workers.ts +130 -0
  133. package/src/server.ts +264 -0
  134. package/src/server_auto_tune.ts +158 -0
  135. package/src/sqlite/adapter.ts +335 -0
  136. package/src/sqlite/runtime_stats.ts +163 -0
  137. package/src/stats.ts +205 -0
  138. package/src/store/append.ts +50 -0
  139. package/src/store/bootstrap_restore_store.ts +71 -0
  140. package/src/store/capabilities.ts +86 -0
  141. package/src/store/full_mode_details_store.ts +71 -0
  142. package/src/store/index_store.ts +104 -0
  143. package/src/store/profile_touch_store.ts +1 -0
  144. package/src/store/rows.ts +144 -0
  145. package/src/store/schema_profile_store.ts +73 -0
  146. package/src/store/schema_publication.ts +6 -0
  147. package/src/store/segment_manifest_store.ts +129 -0
  148. package/src/store/segment_read_store.ts +22 -0
  149. package/src/store/stats_accounting_store.ts +83 -0
  150. package/src/store/touch_store.ts +98 -0
  151. package/src/store/wal_store.ts +21 -0
  152. package/src/stream_size_reconciler.ts +100 -0
  153. package/src/touch/canonical_change.ts +7 -0
  154. package/src/touch/live_keys.ts +158 -0
  155. package/src/touch/live_metrics.ts +841 -0
  156. package/src/touch/live_templates.ts +449 -0
  157. package/src/touch/manager.ts +1292 -0
  158. package/src/touch/process_batch.ts +576 -0
  159. package/src/touch/processor_worker.ts +85 -0
  160. package/src/touch/spec.ts +459 -0
  161. package/src/touch/touch_journal.ts +771 -0
  162. package/src/touch/touch_key_id.ts +20 -0
  163. package/src/touch/worker_pool.ts +191 -0
  164. package/src/touch/worker_protocol.ts +57 -0
  165. package/src/types/proper-lockfile.d.ts +1 -0
  166. package/src/uploader.ts +358 -0
  167. package/src/util/base32_crockford.ts +81 -0
  168. package/src/util/bloom256.ts +67 -0
  169. package/src/util/byte_lru.ts +73 -0
  170. package/src/util/cleanup.ts +22 -0
  171. package/src/util/crc32c.ts +29 -0
  172. package/src/util/ds_error.ts +15 -0
  173. package/src/util/duration.ts +17 -0
  174. package/src/util/endian.ts +53 -0
  175. package/src/util/json_pointer.ts +148 -0
  176. package/src/util/log.ts +25 -0
  177. package/src/util/lru.ts +53 -0
  178. package/src/util/retry.ts +35 -0
  179. package/src/util/siphash.ts +71 -0
  180. package/src/util/stream_paths.ts +50 -0
  181. package/src/util/time.ts +14 -0
  182. package/src/util/yield.ts +3 -0
  183. package/src/util/zstd.ts +24 -0
@@ -0,0 +1,966 @@
1
+ import Ajv from "ajv";
2
+ import { createHash } from "node:crypto";
3
+ import { Result } from "better-result";
4
+ import { LruCache } from "../util/lru";
5
+ import { DURABLE_LENS_V1_SCHEMA } from "./lens_schema";
6
+ import { compileLensResult, lensFromJson, type CompiledLens, type Lens } from "../lens/lens";
7
+ import { validateLensAgainstSchemasResult, fillLensDefaultsResult } from "./proof";
8
+ import { parseJsonPointerResult } from "../util/json_pointer";
9
+ import { parseDurationMsResult } from "../util/duration";
10
+ import { dsError } from "../util/ds_error.ts";
11
+ import type { SchemaRegistryRow, SchemaStore } from "../store/schema_profile_store";
12
+ import type { StreamReadRow } from "../store/segment_read_store";
13
+
14
+ export const SCHEMA_REGISTRY_API_VERSION = "durable.streams/schema-registry/v1" as const;
15
+
16
+ export type RoutingKeyConfig = {
17
+ jsonPointer: string;
18
+ required: boolean;
19
+ };
20
+
21
+ export type SearchFieldKind = "keyword" | "text" | "integer" | "float" | "date" | "bool";
22
+
23
+ export type SearchFieldBinding = {
24
+ version: number;
25
+ jsonPointer: string;
26
+ };
27
+
28
+ export type SearchDefaultField = {
29
+ field: string;
30
+ boost?: number;
31
+ };
32
+
33
+ export type SearchFieldConfig = {
34
+ kind: SearchFieldKind;
35
+ bindings: SearchFieldBinding[];
36
+ normalizer?: "identity_v1" | "lowercase_v1";
37
+ analyzer?: "unicode_word_v1";
38
+ exact?: boolean;
39
+ prefix?: boolean;
40
+ column?: boolean;
41
+ exists?: boolean;
42
+ sortable?: boolean;
43
+ aggregatable?: boolean;
44
+ contains?: boolean;
45
+ positions?: boolean;
46
+ };
47
+
48
+ export type SearchRollupMeasureConfig =
49
+ | { kind: "count"; include?: string }
50
+ | { kind: "summary"; field: string; histogram?: "log2_v1" }
51
+ | {
52
+ kind: "summary_parts";
53
+ countJsonPointer: string;
54
+ sumJsonPointer: string;
55
+ minJsonPointer: string;
56
+ maxJsonPointer: string;
57
+ histogramJsonPointer?: string;
58
+ };
59
+
60
+ export type SearchRollupConfig = {
61
+ timestampField?: string;
62
+ include?: string;
63
+ dimensions?: string[];
64
+ intervals: string[];
65
+ measures: Record<string, SearchRollupMeasureConfig>;
66
+ };
67
+
68
+ export type SearchConfig = {
69
+ profile?: string;
70
+ primaryTimestampField: string;
71
+ defaultFields?: SearchDefaultField[];
72
+ containsDefaultFields?: string[];
73
+ aliases?: Record<string, string>;
74
+ fields: Record<string, SearchFieldConfig>;
75
+ rollups?: Record<string, SearchRollupConfig>;
76
+ };
77
+
78
+ export type SchemaRegistry = {
79
+ apiVersion: typeof SCHEMA_REGISTRY_API_VERSION;
80
+ schema: string;
81
+ currentVersion: number;
82
+ routingKey?: RoutingKeyConfig;
83
+ search?: SearchConfig;
84
+ boundaries: Array<{ offset: number; version: number }>;
85
+ schemas: Record<string, any>;
86
+ lenses: Record<string, any>;
87
+ };
88
+
89
+ export type SchemaRegistryMutationError = {
90
+ kind: "version_mismatch" | "bad_request";
91
+ message: string;
92
+ code?: string;
93
+ };
94
+
95
+ export type SchemaRegistryReadError = {
96
+ kind: "invalid_registry" | "invalid_lens_chain";
97
+ message: string;
98
+ code?: string;
99
+ };
100
+
101
+ type Validator = ReturnType<Ajv["compile"]>;
102
+
103
+ const AJV = new Ajv({
104
+ allErrors: true,
105
+ strict: false,
106
+ allowUnionTypes: true,
107
+ validateSchema: false,
108
+ });
109
+
110
+ function isDateTimeString(value: string): boolean {
111
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
112
+ return false;
113
+ }
114
+ return !Number.isNaN(Date.parse(value));
115
+ }
116
+
117
+ AJV.addFormat("date-time", {
118
+ type: "string",
119
+ validate: isDateTimeString,
120
+ });
121
+
122
+ const LENS_VALIDATOR = AJV.compile(DURABLE_LENS_V1_SCHEMA);
123
+
124
+ function sha256Hex(input: string): string {
125
+ return createHash("sha256").update(input).digest("hex");
126
+ }
127
+
128
+ function defaultRegistry(stream: string): SchemaRegistry {
129
+ return {
130
+ apiVersion: SCHEMA_REGISTRY_API_VERSION,
131
+ schema: stream,
132
+ currentVersion: 0,
133
+ boundaries: [],
134
+ schemas: {},
135
+ lenses: {},
136
+ };
137
+ }
138
+
139
+ function ensureNoRefResult(schema: any): Result<void, { message: string }> {
140
+ const stack: any[] = [schema];
141
+ while (stack.length > 0) {
142
+ const cur = stack.pop();
143
+ if (!cur || typeof cur !== "object") continue;
144
+ if (Object.prototype.hasOwnProperty.call(cur, "$ref")) {
145
+ return Result.err({ message: "external $ref is not supported" });
146
+ }
147
+ for (const v of Object.values(cur)) {
148
+ if (v && typeof v === "object") stack.push(v);
149
+ }
150
+ }
151
+ return Result.ok(undefined);
152
+ }
153
+
154
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
155
+ return !!value && typeof value === "object" && !Array.isArray(value);
156
+ }
157
+
158
+ function rejectUnknownKeysResult(
159
+ obj: Record<string, unknown>,
160
+ allowed: readonly string[],
161
+ path: string
162
+ ): Result<void, { message: string }> {
163
+ const allowedSet = new Set(allowed);
164
+ for (const key of Object.keys(obj)) {
165
+ if (!allowedSet.has(key)) return Result.err({ message: `${path}.${key} is not supported` });
166
+ }
167
+ return Result.ok(undefined);
168
+ }
169
+
170
+ function parseRoutingKeyConfigResult(raw: unknown, path: string): Result<RoutingKeyConfig | null, { message: string }> {
171
+ if (raw == null) return Result.ok(null);
172
+ if (!isPlainObject(raw)) return Result.err({ message: `${path} must be an object or null` });
173
+ const keyCheck = rejectUnknownKeysResult(raw, ["jsonPointer", "required"], path);
174
+ if (Result.isError(keyCheck)) return keyCheck;
175
+ if (typeof raw.jsonPointer !== "string") return Result.err({ message: `${path}.jsonPointer must be a string` });
176
+ const pointerRes = parseJsonPointerResult(raw.jsonPointer);
177
+ if (Result.isError(pointerRes)) return Result.err({ message: pointerRes.error.message });
178
+ if (typeof raw.required !== "boolean") return Result.err({ message: `${path}.required must be boolean` });
179
+ return Result.ok({ jsonPointer: raw.jsonPointer, required: raw.required });
180
+ }
181
+
182
+ function validateSearchFieldNameResult(name: string, path: string): Result<string, { message: string }> {
183
+ const trimmed = name.trim();
184
+ if (trimmed === "") return Result.err({ message: `${path} must not be empty` });
185
+ if (trimmed.length > 64) return Result.err({ message: `${path} too long (max 64)` });
186
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(trimmed)) {
187
+ return Result.err({ message: `${path} must match ^[a-zA-Z0-9][a-zA-Z0-9._-]*$` });
188
+ }
189
+ return Result.ok(trimmed);
190
+ }
191
+
192
+ function parseSearchFieldBindingResult(raw: unknown, path: string): Result<SearchFieldBinding, { message: string }> {
193
+ if (!isPlainObject(raw)) return Result.err({ message: `${path} must be an object` });
194
+ const keyCheck = rejectUnknownKeysResult(raw, ["version", "jsonPointer"], path);
195
+ if (Result.isError(keyCheck)) return keyCheck;
196
+ if (typeof raw.version !== "number" || !Number.isFinite(raw.version) || raw.version <= 0 || !Number.isInteger(raw.version)) {
197
+ return Result.err({ message: `${path}.version must be a positive integer` });
198
+ }
199
+ if (typeof raw.jsonPointer !== "string") return Result.err({ message: `${path}.jsonPointer must be a string` });
200
+ const pointerRes = parseJsonPointerResult(raw.jsonPointer);
201
+ if (Result.isError(pointerRes)) return Result.err({ message: pointerRes.error.message });
202
+ return Result.ok({
203
+ version: raw.version,
204
+ jsonPointer: raw.jsonPointer,
205
+ });
206
+ }
207
+
208
+ function parseSearchDefaultFieldResult(raw: unknown, path: string): Result<SearchDefaultField, { message: string }> {
209
+ if (!isPlainObject(raw)) return Result.err({ message: `${path} must be an object` });
210
+ const keyCheck = rejectUnknownKeysResult(raw, ["field", "boost"], path);
211
+ if (Result.isError(keyCheck)) return keyCheck;
212
+ if (typeof raw.field !== "string") return Result.err({ message: `${path}.field must be a string` });
213
+ const fieldRes = validateSearchFieldNameResult(raw.field, `${path}.field`);
214
+ if (Result.isError(fieldRes)) return fieldRes;
215
+ if (raw.boost !== undefined && (typeof raw.boost !== "number" || !Number.isFinite(raw.boost) || raw.boost <= 0)) {
216
+ return Result.err({ message: `${path}.boost must be a positive number` });
217
+ }
218
+ return Result.ok({
219
+ field: fieldRes.value,
220
+ boost: typeof raw.boost === "number" ? raw.boost : undefined,
221
+ });
222
+ }
223
+
224
+ function parseSearchFieldConfigResult(raw: unknown, path: string): Result<SearchFieldConfig, { message: string }> {
225
+ if (!isPlainObject(raw)) return Result.err({ message: `${path} must be an object` });
226
+ const keyCheck = rejectUnknownKeysResult(
227
+ raw,
228
+ ["kind", "bindings", "normalizer", "analyzer", "exact", "prefix", "column", "exists", "sortable", "aggregatable", "contains", "positions"],
229
+ path
230
+ );
231
+ if (Result.isError(keyCheck)) return keyCheck;
232
+ if (
233
+ raw.kind !== "keyword" &&
234
+ raw.kind !== "text" &&
235
+ raw.kind !== "integer" &&
236
+ raw.kind !== "float" &&
237
+ raw.kind !== "date" &&
238
+ raw.kind !== "bool"
239
+ ) {
240
+ return Result.err({ message: `${path}.kind must be keyword, text, integer, float, date, or bool` });
241
+ }
242
+ if (!Array.isArray(raw.bindings) || raw.bindings.length === 0) {
243
+ return Result.err({ message: `${path}.bindings must be a non-empty array` });
244
+ }
245
+ const bindings: SearchFieldBinding[] = [];
246
+ const seenVersions = new Set<number>();
247
+ for (let i = 0; i < raw.bindings.length; i++) {
248
+ const bindingRes = parseSearchFieldBindingResult(raw.bindings[i], `${path}.bindings[${i}]`);
249
+ if (Result.isError(bindingRes)) return bindingRes;
250
+ if (seenVersions.has(bindingRes.value.version)) {
251
+ return Result.err({ message: `${path}.bindings[${i}].version duplicates ${bindingRes.value.version}` });
252
+ }
253
+ seenVersions.add(bindingRes.value.version);
254
+ bindings.push(bindingRes.value);
255
+ }
256
+ if (raw.normalizer !== undefined && raw.normalizer !== "identity_v1" && raw.normalizer !== "lowercase_v1") {
257
+ return Result.err({ message: `${path}.normalizer must be identity_v1 or lowercase_v1` });
258
+ }
259
+ if (raw.analyzer !== undefined && raw.analyzer !== "unicode_word_v1") {
260
+ return Result.err({ message: `${path}.analyzer must be unicode_word_v1` });
261
+ }
262
+ const out: SearchFieldConfig = {
263
+ kind: raw.kind,
264
+ bindings,
265
+ normalizer: raw.normalizer as SearchFieldConfig["normalizer"] | undefined,
266
+ analyzer: raw.analyzer as SearchFieldConfig["analyzer"] | undefined,
267
+ exact: raw.exact === true ? true : undefined,
268
+ prefix: raw.prefix === true ? true : undefined,
269
+ column: raw.column === true ? true : undefined,
270
+ exists: raw.exists === true ? true : undefined,
271
+ sortable: raw.sortable === true ? true : undefined,
272
+ aggregatable: raw.aggregatable === true ? true : undefined,
273
+ contains: raw.contains === true ? true : undefined,
274
+ positions: raw.positions === true ? true : undefined,
275
+ };
276
+ if (out.kind === "text") {
277
+ if (!out.analyzer) return Result.err({ message: `${path}.analyzer is required for text fields` });
278
+ if (out.column) return Result.err({ message: `${path}.column is not supported for text fields` });
279
+ if (out.sortable) return Result.err({ message: `${path}.sortable is not supported for text fields` });
280
+ if (out.aggregatable) return Result.err({ message: `${path}.aggregatable is not supported for text fields` });
281
+ } else {
282
+ if (out.positions) return Result.err({ message: `${path}.positions is only supported for text fields` });
283
+ }
284
+ if (out.kind === "keyword") {
285
+ if (out.analyzer) return Result.err({ message: `${path}.analyzer is not supported for keyword fields` });
286
+ }
287
+ if (out.kind === "integer" || out.kind === "float" || out.kind === "date" || out.kind === "bool") {
288
+ if (out.prefix) return Result.err({ message: `${path}.prefix is not supported for typed fields` });
289
+ if (out.contains) return Result.err({ message: `${path}.contains is not supported for typed fields` });
290
+ if (out.normalizer) return Result.err({ message: `${path}.normalizer is not supported for typed fields` });
291
+ }
292
+ return Result.ok(out);
293
+ }
294
+
295
+ function parseSearchRollupMeasureResult(
296
+ raw: unknown,
297
+ path: string,
298
+ fields: Record<string, SearchFieldConfig>
299
+ ): Result<SearchRollupMeasureConfig, { message: string }> {
300
+ if (!isPlainObject(raw)) return Result.err({ message: `${path} must be an object` });
301
+ if (raw.kind === "count") {
302
+ const keyCheck = rejectUnknownKeysResult(raw, ["kind", "include"], path);
303
+ if (Result.isError(keyCheck)) return keyCheck;
304
+ if (raw.include !== undefined && (typeof raw.include !== "string" || raw.include.trim() === "")) {
305
+ return Result.err({ message: `${path}.include must be a non-empty string` });
306
+ }
307
+ return Result.ok({ kind: "count", include: typeof raw.include === "string" ? raw.include.trim() : undefined });
308
+ }
309
+ if (raw.kind === "summary") {
310
+ const keyCheck = rejectUnknownKeysResult(raw, ["kind", "field", "histogram"], path);
311
+ if (Result.isError(keyCheck)) return keyCheck;
312
+ if (typeof raw.field !== "string") return Result.err({ message: `${path}.field must be a string` });
313
+ const fieldRes = validateSearchFieldNameResult(raw.field, `${path}.field`);
314
+ if (Result.isError(fieldRes)) return fieldRes;
315
+ const field = fields[fieldRes.value];
316
+ if (!field) return Result.err({ message: `${path}.field must reference a declared search field` });
317
+ if (!field.aggregatable) return Result.err({ message: `${path}.field must reference an aggregatable search field` });
318
+ if (field.kind !== "integer" && field.kind !== "float") {
319
+ return Result.err({ message: `${path}.field must reference an integer or float field` });
320
+ }
321
+ if (raw.histogram !== undefined && raw.histogram !== "log2_v1") {
322
+ return Result.err({ message: `${path}.histogram must be log2_v1` });
323
+ }
324
+ return Result.ok({
325
+ kind: "summary",
326
+ field: fieldRes.value,
327
+ histogram: raw.histogram === "log2_v1" ? "log2_v1" : undefined,
328
+ });
329
+ }
330
+ if (raw.kind === "summary_parts") {
331
+ const keyCheck = rejectUnknownKeysResult(
332
+ raw,
333
+ ["kind", "countJsonPointer", "sumJsonPointer", "minJsonPointer", "maxJsonPointer", "histogramJsonPointer"],
334
+ path
335
+ );
336
+ if (Result.isError(keyCheck)) return keyCheck;
337
+ for (const key of ["countJsonPointer", "sumJsonPointer", "minJsonPointer", "maxJsonPointer"] as const) {
338
+ if (typeof raw[key] !== "string") return Result.err({ message: `${path}.${key} must be a string` });
339
+ const pointerRes = parseJsonPointerResult(raw[key]);
340
+ if (Result.isError(pointerRes)) return Result.err({ message: pointerRes.error.message });
341
+ }
342
+ if (raw.histogramJsonPointer !== undefined) {
343
+ if (typeof raw.histogramJsonPointer !== "string") return Result.err({ message: `${path}.histogramJsonPointer must be a string` });
344
+ const pointerRes = parseJsonPointerResult(raw.histogramJsonPointer);
345
+ if (Result.isError(pointerRes)) return Result.err({ message: pointerRes.error.message });
346
+ }
347
+ return Result.ok({
348
+ kind: "summary_parts",
349
+ countJsonPointer: raw.countJsonPointer as string,
350
+ sumJsonPointer: raw.sumJsonPointer as string,
351
+ minJsonPointer: raw.minJsonPointer as string,
352
+ maxJsonPointer: raw.maxJsonPointer as string,
353
+ histogramJsonPointer: typeof raw.histogramJsonPointer === "string" ? raw.histogramJsonPointer : undefined,
354
+ });
355
+ }
356
+ return Result.err({ message: `${path}.kind must be count, summary, or summary_parts` });
357
+ }
358
+
359
+ function parseSearchRollupConfigResult(
360
+ raw: unknown,
361
+ path: string,
362
+ fields: Record<string, SearchFieldConfig>,
363
+ primaryTimestampField: string
364
+ ): Result<SearchRollupConfig, { message: string }> {
365
+ if (!isPlainObject(raw)) return Result.err({ message: `${path} must be an object` });
366
+ const keyCheck = rejectUnknownKeysResult(raw, ["timestampField", "include", "dimensions", "intervals", "measures"], path);
367
+ if (Result.isError(keyCheck)) return keyCheck;
368
+
369
+ const timestampFieldRaw = raw.timestampField === undefined ? primaryTimestampField : raw.timestampField;
370
+ if (typeof timestampFieldRaw !== "string") return Result.err({ message: `${path}.timestampField must be a string` });
371
+ const timestampFieldRes = validateSearchFieldNameResult(timestampFieldRaw, `${path}.timestampField`);
372
+ if (Result.isError(timestampFieldRes)) return timestampFieldRes;
373
+ const timestampField = fields[timestampFieldRes.value];
374
+ if (!timestampField) return Result.err({ message: `${path}.timestampField must reference a declared field` });
375
+ if (timestampField.kind !== "date") return Result.err({ message: `${path}.timestampField must reference a date field` });
376
+
377
+ let include: string | undefined;
378
+ if (raw.include !== undefined) {
379
+ if (typeof raw.include !== "string" || raw.include.trim() === "") {
380
+ return Result.err({ message: `${path}.include must be a non-empty string` });
381
+ }
382
+ include = raw.include.trim();
383
+ }
384
+
385
+ let dimensions: string[] | undefined;
386
+ if (raw.dimensions !== undefined) {
387
+ if (!Array.isArray(raw.dimensions)) return Result.err({ message: `${path}.dimensions must be an array` });
388
+ dimensions = [];
389
+ const seen = new Set<string>();
390
+ for (let i = 0; i < raw.dimensions.length; i++) {
391
+ if (typeof raw.dimensions[i] !== "string") return Result.err({ message: `${path}.dimensions[${i}] must be a string` });
392
+ const dimRes = validateSearchFieldNameResult(raw.dimensions[i], `${path}.dimensions[${i}]`);
393
+ if (Result.isError(dimRes)) return dimRes;
394
+ if (seen.has(dimRes.value)) return Result.err({ message: `${path}.dimensions[${i}] duplicates ${dimRes.value}` });
395
+ const field = fields[dimRes.value];
396
+ if (!field) return Result.err({ message: `${path}.dimensions[${i}] must reference a declared field` });
397
+ if (!field.exact) return Result.err({ message: `${path}.dimensions[${i}] must reference an exact-capable field` });
398
+ seen.add(dimRes.value);
399
+ dimensions.push(dimRes.value);
400
+ }
401
+ }
402
+
403
+ if (!Array.isArray(raw.intervals) || raw.intervals.length === 0) {
404
+ return Result.err({ message: `${path}.intervals must be a non-empty array` });
405
+ }
406
+ const intervals: string[] = [];
407
+ const seenIntervals = new Set<string>();
408
+ for (let i = 0; i < raw.intervals.length; i++) {
409
+ if (typeof raw.intervals[i] !== "string") return Result.err({ message: `${path}.intervals[${i}] must be a string` });
410
+ const parsedRes = parseDurationMsResult(raw.intervals[i]);
411
+ if (Result.isError(parsedRes) || parsedRes.value <= 0) {
412
+ return Result.err({ message: `${path}.intervals[${i}] must be a positive duration string` });
413
+ }
414
+ if (seenIntervals.has(raw.intervals[i])) {
415
+ return Result.err({ message: `${path}.intervals[${i}] duplicates ${raw.intervals[i]}` });
416
+ }
417
+ seenIntervals.add(raw.intervals[i]);
418
+ intervals.push(raw.intervals[i]);
419
+ }
420
+
421
+ if (!isPlainObject(raw.measures) || Object.keys(raw.measures).length === 0) {
422
+ return Result.err({ message: `${path}.measures must be a non-empty object` });
423
+ }
424
+ const measures: Record<string, SearchRollupMeasureConfig> = {};
425
+ for (const [measureName, measureRaw] of Object.entries(raw.measures)) {
426
+ const nameRes = validateSearchFieldNameResult(measureName, `${path}.measures`);
427
+ if (Result.isError(nameRes)) return nameRes;
428
+ const measureRes = parseSearchRollupMeasureResult(measureRaw, `${path}.measures.${measureName}`, fields);
429
+ if (Result.isError(measureRes)) return measureRes;
430
+ measures[nameRes.value] = measureRes.value;
431
+ }
432
+
433
+ return Result.ok({
434
+ timestampField: timestampFieldRes.value,
435
+ include,
436
+ dimensions,
437
+ intervals,
438
+ measures,
439
+ });
440
+ }
441
+
442
+ function parseSearchConfigResult(raw: unknown, path: string): Result<SearchConfig | null, { message: string }> {
443
+ if (raw == null) return Result.ok(null);
444
+ if (!isPlainObject(raw)) return Result.err({ message: `${path} must be an object` });
445
+ const keyCheck = rejectUnknownKeysResult(
446
+ raw,
447
+ ["profile", "primaryTimestampField", "defaultFields", "containsDefaultFields", "aliases", "fields", "rollups"],
448
+ path
449
+ );
450
+ if (Result.isError(keyCheck)) return keyCheck;
451
+ if (typeof raw.primaryTimestampField !== "string") {
452
+ return Result.err({ message: `${path}.primaryTimestampField must be a string` });
453
+ }
454
+ const primaryFieldRes = validateSearchFieldNameResult(raw.primaryTimestampField, `${path}.primaryTimestampField`);
455
+ if (Result.isError(primaryFieldRes)) return primaryFieldRes;
456
+ if (!isPlainObject(raw.fields) || Object.keys(raw.fields).length === 0) {
457
+ return Result.err({ message: `${path}.fields must be a non-empty object` });
458
+ }
459
+ const fields: Record<string, SearchFieldConfig> = {};
460
+ for (const [fieldName, fieldRaw] of Object.entries(raw.fields)) {
461
+ const nameRes = validateSearchFieldNameResult(fieldName, `${path}.fields`);
462
+ if (Result.isError(nameRes)) return nameRes;
463
+ const fieldRes = parseSearchFieldConfigResult(fieldRaw, `${path}.fields.${fieldName}`);
464
+ if (Result.isError(fieldRes)) return fieldRes;
465
+ fields[nameRes.value] = fieldRes.value;
466
+ }
467
+ if (!fields[primaryFieldRes.value]) {
468
+ return Result.err({ message: `${path}.primaryTimestampField must reference a declared field` });
469
+ }
470
+ if (fields[primaryFieldRes.value].kind !== "date") {
471
+ return Result.err({ message: `${path}.primaryTimestampField must reference a date field` });
472
+ }
473
+ let defaultFields: SearchDefaultField[] | undefined;
474
+ if (raw.defaultFields !== undefined) {
475
+ if (!Array.isArray(raw.defaultFields)) return Result.err({ message: `${path}.defaultFields must be an array` });
476
+ defaultFields = [];
477
+ for (let i = 0; i < raw.defaultFields.length; i++) {
478
+ const fieldRes = parseSearchDefaultFieldResult(raw.defaultFields[i], `${path}.defaultFields[${i}]`);
479
+ if (Result.isError(fieldRes)) return fieldRes;
480
+ if (!fields[fieldRes.value.field]) {
481
+ return Result.err({ message: `${path}.defaultFields[${i}].field must reference a declared field` });
482
+ }
483
+ defaultFields.push(fieldRes.value);
484
+ }
485
+ }
486
+ let containsDefaultFields: string[] | undefined;
487
+ if (raw.containsDefaultFields !== undefined) {
488
+ if (!Array.isArray(raw.containsDefaultFields)) {
489
+ return Result.err({ message: `${path}.containsDefaultFields must be an array` });
490
+ }
491
+ containsDefaultFields = [];
492
+ for (let i = 0; i < raw.containsDefaultFields.length; i++) {
493
+ if (typeof raw.containsDefaultFields[i] !== "string") {
494
+ return Result.err({ message: `${path}.containsDefaultFields[${i}] must be a string` });
495
+ }
496
+ const nameRes = validateSearchFieldNameResult(raw.containsDefaultFields[i], `${path}.containsDefaultFields[${i}]`);
497
+ if (Result.isError(nameRes)) return nameRes;
498
+ if (!fields[nameRes.value]) {
499
+ return Result.err({ message: `${path}.containsDefaultFields[${i}] must reference a declared field` });
500
+ }
501
+ containsDefaultFields.push(nameRes.value);
502
+ }
503
+ }
504
+ let aliases: Record<string, string> | undefined;
505
+ if (raw.aliases !== undefined) {
506
+ if (!isPlainObject(raw.aliases)) return Result.err({ message: `${path}.aliases must be an object` });
507
+ aliases = {};
508
+ for (const [aliasRaw, targetRaw] of Object.entries(raw.aliases)) {
509
+ const aliasRes = validateSearchFieldNameResult(aliasRaw, `${path}.aliases`);
510
+ if (Result.isError(aliasRes)) return aliasRes;
511
+ if (typeof targetRaw !== "string") return Result.err({ message: `${path}.aliases.${aliasRaw} must be a string` });
512
+ const targetRes = validateSearchFieldNameResult(targetRaw, `${path}.aliases.${aliasRaw}`);
513
+ if (Result.isError(targetRes)) return targetRes;
514
+ if (!fields[targetRes.value]) {
515
+ return Result.err({ message: `${path}.aliases.${aliasRaw} must reference a declared field` });
516
+ }
517
+ aliases[aliasRes.value] = targetRes.value;
518
+ }
519
+ }
520
+ let rollups: Record<string, SearchRollupConfig> | undefined;
521
+ if (raw.rollups !== undefined) {
522
+ if (!isPlainObject(raw.rollups)) return Result.err({ message: `${path}.rollups must be an object` });
523
+ rollups = {};
524
+ for (const [rollupName, rollupRaw] of Object.entries(raw.rollups)) {
525
+ const nameRes = validateSearchFieldNameResult(rollupName, `${path}.rollups`);
526
+ if (Result.isError(nameRes)) return nameRes;
527
+ const rollupRes = parseSearchRollupConfigResult(
528
+ rollupRaw,
529
+ `${path}.rollups.${rollupName}`,
530
+ fields,
531
+ primaryFieldRes.value
532
+ );
533
+ if (Result.isError(rollupRes)) return rollupRes;
534
+ rollups[nameRes.value] = rollupRes.value;
535
+ }
536
+ }
537
+ return Result.ok({
538
+ profile: typeof raw.profile === "string" ? raw.profile : undefined,
539
+ primaryTimestampField: primaryFieldRes.value,
540
+ defaultFields,
541
+ containsDefaultFields,
542
+ aliases,
543
+ fields,
544
+ rollups,
545
+ });
546
+ }
547
+
548
+ function validateJsonSchemaResult(schema: any): Result<void, { message: string }> {
549
+ const noRefRes = ensureNoRefResult(schema);
550
+ if (Result.isError(noRefRes)) return noRefRes;
551
+ try {
552
+ const validate = AJV.compile(schema);
553
+ if (!validate) return Result.err({ message: "schema validation failed" });
554
+ } catch (e: any) {
555
+ return Result.err({ message: String(e?.message ?? e) });
556
+ }
557
+ return Result.ok(undefined);
558
+ }
559
+
560
+ function parseRegistryResult(stream: string, json: string): Result<SchemaRegistry, { message: string }> {
561
+ let raw: unknown;
562
+ try {
563
+ raw = JSON.parse(json);
564
+ } catch (e: any) {
565
+ return Result.err({ message: String(e?.message ?? e) });
566
+ }
567
+ if (!isPlainObject(raw)) return Result.err({ message: "invalid schema registry" });
568
+ const keyCheck = rejectUnknownKeysResult(
569
+ raw,
570
+ ["apiVersion", "schema", "currentVersion", "routingKey", "search", "boundaries", "schemas", "lenses"],
571
+ "registry"
572
+ );
573
+ if (Result.isError(keyCheck)) return keyCheck;
574
+ if (raw.apiVersion !== SCHEMA_REGISTRY_API_VERSION) return Result.err({ message: "invalid registry apiVersion" });
575
+
576
+ const routingKeyRes = parseRoutingKeyConfigResult(raw.routingKey, "routingKey");
577
+ if (Result.isError(routingKeyRes)) return routingKeyRes;
578
+ const searchRes = parseSearchConfigResult(raw.search, "search");
579
+ if (Result.isError(searchRes)) return searchRes;
580
+
581
+ const boundariesRaw = Array.isArray(raw.boundaries) ? raw.boundaries : [];
582
+ const boundaries: Array<{ offset: number; version: number }> = [];
583
+ for (const item of boundariesRaw) {
584
+ if (!isPlainObject(item)) return Result.err({ message: "invalid boundary entry" });
585
+ const offset = typeof item.offset === "number" && Number.isFinite(item.offset) ? item.offset : null;
586
+ const version = typeof item.version === "number" && Number.isFinite(item.version) ? item.version : null;
587
+ if (offset == null || version == null) return Result.err({ message: "invalid boundary entry" });
588
+ boundaries.push({ offset, version });
589
+ }
590
+
591
+ const schemas = isPlainObject(raw.schemas) ? raw.schemas : {};
592
+ const lenses = isPlainObject(raw.lenses) ? raw.lenses : {};
593
+ const currentVersion =
594
+ typeof raw.currentVersion === "number" && Number.isFinite(raw.currentVersion) ? raw.currentVersion : 0;
595
+ const schemaName = typeof raw.schema === "string" && raw.schema.trim() !== "" ? raw.schema : stream;
596
+
597
+ return Result.ok({
598
+ apiVersion: SCHEMA_REGISTRY_API_VERSION,
599
+ schema: schemaName,
600
+ currentVersion,
601
+ routingKey: routingKeyRes.value ?? undefined,
602
+ search: searchRes.value ?? undefined,
603
+ boundaries,
604
+ schemas,
605
+ lenses,
606
+ });
607
+ }
608
+
609
+ function serializeRegistry(reg: SchemaRegistry): string {
610
+ return JSON.stringify(reg);
611
+ }
612
+
613
+ function validateLensResult(raw: any): Result<Lens, { message: string }> {
614
+ const ok = LENS_VALIDATOR(raw);
615
+ if (!ok) {
616
+ const msg = AJV.errorsText(LENS_VALIDATOR.errors || undefined);
617
+ return Result.err({ message: `invalid lens: ${msg}` });
618
+ }
619
+ return Result.ok(raw as Lens);
620
+ }
621
+
622
+ export function parseSchemaUpdateResult(
623
+ body: unknown
624
+ ): Result<{ schema?: any; lens?: any; routingKey?: RoutingKeyConfig | null; search?: SearchConfig | null }, { message: string }> {
625
+ if (!isPlainObject(body)) return Result.err({ message: "schema update must be a JSON object" });
626
+ const keyCheck = rejectUnknownKeysResult(body, ["apiVersion", "schema", "lens", "routingKey", "search"], "schemaUpdate");
627
+ if (Result.isError(keyCheck)) return keyCheck;
628
+ if (body.apiVersion !== undefined && body.apiVersion !== SCHEMA_REGISTRY_API_VERSION) {
629
+ return Result.err({ message: "invalid schema apiVersion" });
630
+ }
631
+
632
+ const hasSchema = Object.prototype.hasOwnProperty.call(body, "schema");
633
+ const hasRoutingKey = Object.prototype.hasOwnProperty.call(body, "routingKey");
634
+ const hasSearch = Object.prototype.hasOwnProperty.call(body, "search");
635
+ if (!hasSchema && !hasRoutingKey && !hasSearch) {
636
+ return Result.err({ message: "schema update must include schema, routingKey, or search" });
637
+ }
638
+ if (!hasSchema && body.lens !== undefined) {
639
+ return Result.err({ message: "schema update lens requires schema" });
640
+ }
641
+
642
+ const routingKeyRes = hasRoutingKey ? parseRoutingKeyConfigResult(body.routingKey, "routingKey") : Result.ok(null);
643
+ if (Result.isError(routingKeyRes)) return routingKeyRes;
644
+ if (hasSchema && hasRoutingKey && routingKeyRes.value == null) {
645
+ return Result.err({ message: "schema update routingKey must be an object when schema is provided" });
646
+ }
647
+
648
+ const searchRes = hasSearch ? parseSearchConfigResult(body.search, "search") : Result.ok(null);
649
+ if (Result.isError(searchRes)) return searchRes;
650
+
651
+ const out: { schema?: any; lens?: any; routingKey?: RoutingKeyConfig | null; search?: SearchConfig | null } = {};
652
+ if (hasSchema) out.schema = body.schema;
653
+ if (body.lens !== undefined) out.lens = body.lens;
654
+ if (hasRoutingKey) out.routingKey = routingKeyRes.value;
655
+ if (hasSearch) out.search = searchRes.value;
656
+ return Result.ok(out);
657
+ }
658
+
659
+ function bigintToNumberSafeResult(v: bigint): Result<number, { message: string }> {
660
+ const max = BigInt(Number.MAX_SAFE_INTEGER);
661
+ if (v > max) return Result.err({ message: "offset exceeds MAX_SAFE_INTEGER" });
662
+ return Result.ok(Number(v));
663
+ }
664
+
665
+ export class SchemaRegistryStore {
666
+ private readonly store: SchemaStore;
667
+ private readonly registryCache: LruCache<string, { reg: SchemaRegistry; registryJson: string; updatedAtMs: bigint }>;
668
+ private readonly validatorCache: LruCache<string, Validator>;
669
+ private readonly lensCache: LruCache<string, CompiledLens>;
670
+ private readonly lensChainCache: LruCache<string, CompiledLens[]>;
671
+
672
+ constructor(store: SchemaStore, opts?: { registryCacheEntries?: number; validatorCacheEntries?: number; lensCacheEntries?: number }) {
673
+ this.store = store;
674
+ this.registryCache = new LruCache(opts?.registryCacheEntries ?? 1024);
675
+ this.validatorCache = new LruCache(opts?.validatorCacheEntries ?? 256);
676
+ this.lensCache = new LruCache(opts?.lensCacheEntries ?? 256);
677
+ this.lensChainCache = new LruCache(opts?.lensCacheEntries ?? 256);
678
+ }
679
+
680
+ private async loadRow(stream: string): Promise<SchemaRegistryRow | null> {
681
+ return this.store.getSchemaRegistryForRead(stream);
682
+ }
683
+
684
+ async getRegistry(stream: string): Promise<SchemaRegistry> {
685
+ const res = await this.getRegistryResult(stream);
686
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
687
+ return res.value;
688
+ }
689
+
690
+ async getRegistryResult(stream: string): Promise<Result<SchemaRegistry, SchemaRegistryReadError>> {
691
+ const row = await this.loadRow(stream);
692
+ if (!row) return Result.ok(defaultRegistry(stream));
693
+ const cached = this.registryCache.get(stream);
694
+ if (cached && cached.updatedAtMs === row.updated_at_ms && cached.registryJson === row.registry_json) return Result.ok(cached.reg);
695
+ const parseRes = parseRegistryResult(stream, row.registry_json);
696
+ if (Result.isError(parseRes)) {
697
+ return Result.err({ kind: "invalid_registry", message: parseRes.error.message });
698
+ }
699
+ const reg = parseRes.value;
700
+ this.registryCache.set(stream, { reg, registryJson: row.registry_json, updatedAtMs: row.updated_at_ms });
701
+ return Result.ok(reg);
702
+ }
703
+
704
+ updateRegistry(
705
+ stream: string,
706
+ update: { schema: any; lens?: any; routingKey?: RoutingKeyConfig; search?: SearchConfig | null }
707
+ ): Promise<SchemaRegistry> {
708
+ return (async () => {
709
+ const res = await this.updateRegistryResult(stream, update);
710
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
711
+ return res.value;
712
+ })();
713
+ }
714
+
715
+ async updateRegistryResult(
716
+ stream: string,
717
+ update: { schema: any; lens?: any; routingKey?: RoutingKeyConfig; search?: SearchConfig | null }
718
+ ): Promise<Result<SchemaRegistry, SchemaRegistryMutationError>> {
719
+ const validationRes = this.validateSchemaUpdate(stream, update);
720
+ if (Result.isError(validationRes)) return validationRes;
721
+
722
+ const commitRes = await this.store.commitSchemaMetadataMutation<SchemaRegistry, SchemaRegistryMutationError>(stream, ({ streamRow, registryRow }) => {
723
+ return this.buildRegistryUpdatePlan(stream, streamRow, registryRow, update);
724
+ });
725
+ if (Result.isError(commitRes)) return commitRes;
726
+ this.registryCache.set(stream, {
727
+ reg: commitRes.value.registry,
728
+ registryJson: serializeRegistry(commitRes.value.registry),
729
+ updatedAtMs: commitRes.value.updatedAtMs,
730
+ });
731
+ return Result.ok(commitRes.value.registry);
732
+ }
733
+
734
+ private validateSchemaUpdate(
735
+ stream: string,
736
+ update: { schema: any; lens?: any; routingKey?: RoutingKeyConfig; search?: SearchConfig | null }
737
+ ): Result<void, SchemaRegistryMutationError> {
738
+ void stream;
739
+ if (update.routingKey) {
740
+ const pointerRes = parseJsonPointerResult(update.routingKey.jsonPointer);
741
+ if (Result.isError(pointerRes)) {
742
+ return Result.err({ kind: "bad_request", message: pointerRes.error.message });
743
+ }
744
+ if (typeof update.routingKey.required !== "boolean") {
745
+ return Result.err({ kind: "bad_request", message: "routingKey.required must be boolean" });
746
+ }
747
+ }
748
+ if (update.schema === undefined) return Result.err({ kind: "bad_request", message: "missing schema" });
749
+ const schemaRes = validateJsonSchemaResult(update.schema);
750
+ if (Result.isError(schemaRes)) return Result.err({ kind: "bad_request", message: schemaRes.error.message });
751
+ return Result.ok(undefined);
752
+ }
753
+
754
+ private buildRegistryUpdatePlan(
755
+ stream: string,
756
+ streamRow: StreamReadRow | null,
757
+ registryRow: SchemaRegistryRow | null,
758
+ update: { schema: any; lens?: any; routingKey?: RoutingKeyConfig; search?: SearchConfig | null }
759
+ ): Result<{ registry: SchemaRegistry; registryJson: string; value: SchemaRegistry }, SchemaRegistryMutationError> {
760
+ if (!streamRow) return Result.err({ kind: "bad_request", message: "stream not found" });
761
+ const regRes = registryRow ? parseRegistryResult(stream, registryRow.registry_json) : Result.ok(defaultRegistry(stream));
762
+ if (Result.isError(regRes)) return Result.err({ kind: "bad_request", message: regRes.error.message });
763
+ const reg = regRes.value;
764
+ const currentVersion = reg.currentVersion ?? 0;
765
+ const streamEmpty = streamRow.next_offset === 0n;
766
+
767
+ if (currentVersion === 0) {
768
+ if (!streamEmpty) return Result.err({ kind: "bad_request", message: "first schema requires empty stream" });
769
+ if (update.lens) {
770
+ const lensRes = validateLensResult(update.lens);
771
+ if (Result.isError(lensRes)) return Result.err({ kind: "bad_request", message: lensRes.error.message });
772
+ if (lensRes.value.from !== 0 || lensRes.value.to !== 1) {
773
+ return Result.err({
774
+ kind: "version_mismatch",
775
+ message: "lens version mismatch",
776
+ code: "schema_lens_version_mismatch",
777
+ });
778
+ }
779
+ }
780
+ const nextReg: SchemaRegistry = {
781
+ apiVersion: "durable.streams/schema-registry/v1",
782
+ schema: stream,
783
+ currentVersion: 1,
784
+ routingKey: update.routingKey,
785
+ search: update.search === undefined ? reg.search : update.search ?? undefined,
786
+ boundaries: [{ offset: 0, version: 1 }],
787
+ schemas: { ...reg.schemas, ["1"]: update.schema },
788
+ lenses: { ...reg.lenses },
789
+ };
790
+ return Result.ok({ registry: nextReg, registryJson: serializeRegistry(nextReg), value: nextReg });
791
+ }
792
+
793
+ if (!update.lens) return Result.err({ kind: "bad_request", message: "lens required" });
794
+ const lensRes = validateLensResult(update.lens);
795
+ if (Result.isError(lensRes)) return Result.err({ kind: "bad_request", message: lensRes.error.message });
796
+ const lens = lensRes.value;
797
+ if (lens.from !== currentVersion || lens.to !== currentVersion + 1) {
798
+ return Result.err({
799
+ kind: "version_mismatch",
800
+ message: "lens version mismatch",
801
+ code: "schema_lens_version_mismatch",
802
+ });
803
+ }
804
+ if (lens.schema && lens.schema !== reg.schema) return Result.err({ kind: "bad_request", message: "lens schema mismatch" });
805
+
806
+ const oldSchema = reg.schemas[String(currentVersion)];
807
+ if (!oldSchema) return Result.err({ kind: "bad_request", message: "missing current schema" });
808
+ const proofRes = validateLensAgainstSchemasResult(oldSchema, update.schema, lens);
809
+ if (Result.isError(proofRes)) return Result.err({ kind: "bad_request", message: proofRes.error.message });
810
+ const defaultsRes = fillLensDefaultsResult(lens, update.schema);
811
+ if (Result.isError(defaultsRes)) return Result.err({ kind: "bad_request", message: defaultsRes.error.message });
812
+
813
+ const boundaryRes = bigintToNumberSafeResult(streamRow.next_offset);
814
+ if (Result.isError(boundaryRes)) return Result.err({ kind: "bad_request", message: boundaryRes.error.message });
815
+
816
+ const nextVersion = currentVersion + 1;
817
+ const nextReg: SchemaRegistry = {
818
+ apiVersion: "durable.streams/schema-registry/v1",
819
+ schema: reg.schema ?? stream,
820
+ currentVersion: nextVersion,
821
+ routingKey: update.routingKey ?? reg.routingKey,
822
+ search: update.search === undefined ? reg.search : update.search ?? undefined,
823
+ boundaries: [...reg.boundaries, { offset: boundaryRes.value, version: nextVersion }],
824
+ schemas: { ...reg.schemas, [String(nextVersion)]: update.schema },
825
+ lenses: { ...reg.lenses, [String(currentVersion)]: defaultsRes.value },
826
+ };
827
+ return Result.ok({ registry: nextReg, registryJson: serializeRegistry(nextReg), value: nextReg });
828
+ }
829
+
830
+ async updateRoutingKey(stream: string, routingKey: RoutingKeyConfig | null): Promise<SchemaRegistry> {
831
+ const res = await this.updateRoutingKeyResult(stream, routingKey);
832
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
833
+ return res.value;
834
+ }
835
+
836
+ async updateRoutingKeyResult(stream: string, routingKey: RoutingKeyConfig | null): Promise<Result<SchemaRegistry, SchemaRegistryMutationError>> {
837
+ if (routingKey) {
838
+ const pointerRes = parseJsonPointerResult(routingKey.jsonPointer);
839
+ if (Result.isError(pointerRes)) {
840
+ return Result.err({ kind: "bad_request", message: pointerRes.error.message });
841
+ }
842
+ if (typeof routingKey.required !== "boolean") {
843
+ return Result.err({ kind: "bad_request", message: "routingKey.required must be boolean" });
844
+ }
845
+ }
846
+ const commitRes = await this.store.commitSchemaMetadataMutation<SchemaRegistry, SchemaRegistryMutationError>(stream, ({ registryRow }) => {
847
+ const regRes = registryRow ? parseRegistryResult(stream, registryRow.registry_json) : Result.ok(defaultRegistry(stream));
848
+ if (Result.isError(regRes)) return Result.err({ kind: "bad_request", message: regRes.error.message });
849
+ const nextReg: SchemaRegistry = {
850
+ ...regRes.value,
851
+ routingKey: routingKey ?? undefined,
852
+ };
853
+ return Result.ok({ registry: nextReg, registryJson: serializeRegistry(nextReg), value: nextReg });
854
+ });
855
+ if (Result.isError(commitRes)) return commitRes;
856
+ this.registryCache.set(stream, {
857
+ reg: commitRes.value.registry,
858
+ registryJson: serializeRegistry(commitRes.value.registry),
859
+ updatedAtMs: commitRes.value.updatedAtMs,
860
+ });
861
+ return Result.ok(commitRes.value.registry);
862
+ }
863
+
864
+ async updateSearch(stream: string, search: SearchConfig | null): Promise<SchemaRegistry> {
865
+ const res = await this.updateSearchResult(stream, search);
866
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
867
+ return res.value;
868
+ }
869
+
870
+ async updateSearchResult(stream: string, search: SearchConfig | null): Promise<Result<SchemaRegistry, SchemaRegistryMutationError>> {
871
+ const searchRes = parseSearchConfigResult(search, "search");
872
+ if (Result.isError(searchRes)) return Result.err({ kind: "bad_request", message: searchRes.error.message });
873
+ const commitRes = await this.store.commitSchemaMetadataMutation<SchemaRegistry, SchemaRegistryMutationError>(stream, ({ registryRow }) => {
874
+ const regRes = registryRow ? parseRegistryResult(stream, registryRow.registry_json) : Result.ok(defaultRegistry(stream));
875
+ if (Result.isError(regRes)) return Result.err({ kind: "bad_request", message: regRes.error.message });
876
+ if (searchRes.value && (regRes.value.currentVersion <= 0 || regRes.value.boundaries.length === 0)) {
877
+ return Result.err({
878
+ kind: "bad_request",
879
+ message: "search config requires an installed schema version",
880
+ });
881
+ }
882
+ const nextReg: SchemaRegistry = {
883
+ ...regRes.value,
884
+ search: searchRes.value ?? undefined,
885
+ };
886
+ return Result.ok({ registry: nextReg, registryJson: serializeRegistry(nextReg), value: nextReg });
887
+ });
888
+ if (Result.isError(commitRes)) return commitRes;
889
+ this.registryCache.set(stream, {
890
+ reg: commitRes.value.registry,
891
+ registryJson: serializeRegistry(commitRes.value.registry),
892
+ updatedAtMs: commitRes.value.updatedAtMs,
893
+ });
894
+ return Result.ok(commitRes.value.registry);
895
+ }
896
+
897
+ async replaceRegistry(stream: string, registry: SchemaRegistry): Promise<SchemaRegistry> {
898
+ const res = await this.replaceRegistryResult(stream, registry);
899
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
900
+ return res.value;
901
+ }
902
+
903
+ async replaceRegistryResult(stream: string, registry: SchemaRegistry): Promise<Result<SchemaRegistry, SchemaRegistryMutationError>> {
904
+ const parseRes = parseRegistryResult(stream, JSON.stringify(registry));
905
+ if (Result.isError(parseRes)) return Result.err({ kind: "bad_request", message: parseRes.error.message });
906
+ const commitRes = await this.store.commitSchemaMetadataMutation<SchemaRegistry, SchemaRegistryMutationError>(stream, () =>
907
+ Result.ok({ registry: parseRes.value, registryJson: serializeRegistry(parseRes.value), value: parseRes.value })
908
+ );
909
+ if (Result.isError(commitRes)) return commitRes;
910
+ this.registryCache.set(stream, {
911
+ reg: commitRes.value.registry,
912
+ registryJson: serializeRegistry(commitRes.value.registry),
913
+ updatedAtMs: commitRes.value.updatedAtMs,
914
+ });
915
+ return Result.ok(commitRes.value.registry);
916
+ }
917
+
918
+ getValidatorForVersion(reg: SchemaRegistry, version: number): Validator | null {
919
+ const schema = reg.schemas[String(version)];
920
+ if (!schema) return null;
921
+ const hash = sha256Hex(JSON.stringify(schema));
922
+ const cached = this.validatorCache.get(hash);
923
+ if (cached) return cached;
924
+ const validate = AJV.compile(schema);
925
+ this.validatorCache.set(hash, validate);
926
+ return validate;
927
+ }
928
+
929
+ getLensChain(reg: SchemaRegistry, fromVersion: number, toVersion: number): CompiledLens[] {
930
+ const res = this.getLensChainResult(reg, fromVersion, toVersion);
931
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
932
+ return res.value;
933
+ }
934
+
935
+ getLensChainResult(reg: SchemaRegistry, fromVersion: number, toVersion: number): Result<CompiledLens[], SchemaRegistryReadError> {
936
+ const key = `${reg.schema}:${fromVersion}->${toVersion}`;
937
+ const cached = this.lensChainCache.get(key);
938
+ if (cached) return Result.ok(cached);
939
+ const chain: CompiledLens[] = [];
940
+ for (let v = fromVersion; v < toVersion; v++) {
941
+ const lensRaw = reg.lenses[String(v)];
942
+ if (!lensRaw) {
943
+ return Result.err({
944
+ kind: "invalid_lens_chain",
945
+ message: `missing lens v${v}->v${v + 1}`,
946
+ });
947
+ }
948
+ const hash = sha256Hex(JSON.stringify(lensRaw));
949
+ let compiled = this.lensCache.get(hash);
950
+ if (!compiled) {
951
+ const compiledRes = compileLensResult(lensFromJson(lensRaw));
952
+ if (Result.isError(compiledRes)) {
953
+ return Result.err({
954
+ kind: "invalid_lens_chain",
955
+ message: compiledRes.error.message,
956
+ });
957
+ }
958
+ compiled = compiledRes.value;
959
+ this.lensCache.set(hash, compiled);
960
+ }
961
+ chain.push(compiled);
962
+ }
963
+ this.lensChainCache.set(key, chain);
964
+ return Result.ok(chain);
965
+ }
966
+ }