@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,875 @@
1
+ import { Result } from "better-result";
2
+ import type { SchemaRegistry, SearchConfig, SearchFieldConfig } from "../schema/registry";
3
+ import {
4
+ analyzeTextValue,
5
+ canonicalizeColumnValue,
6
+ canonicalizeExactValue,
7
+ extractRawSearchValuesResult,
8
+ extractSearchExactValuesResult,
9
+ extractSearchTextValuesResult,
10
+ resolveSearchAlias,
11
+ } from "./schema";
12
+
13
+ type Token =
14
+ | { kind: "word"; value: string }
15
+ | { kind: "string"; value: string }
16
+ | { kind: "lparen" }
17
+ | { kind: "rparen" }
18
+ | { kind: "colon" }
19
+ | { kind: "op"; value: "=" | ">" | ">=" | "<" | "<=" }
20
+ | { kind: "minus" };
21
+
22
+ type ParsedQuery =
23
+ | { kind: "and"; left: ParsedQuery; right: ParsedQuery }
24
+ | { kind: "or"; left: ParsedQuery; right: ParsedQuery }
25
+ | { kind: "not"; expr: ParsedQuery }
26
+ | { kind: "has"; field: string }
27
+ | { kind: "field"; field: string; op: SearchComparisonOp; value: string; quoted: boolean }
28
+ | { kind: "bare"; value: string; quoted: boolean };
29
+
30
+ export type SearchComparisonOp = "eq" | "gt" | "gte" | "lt" | "lte";
31
+ export type SearchSortDirection = "asc" | "desc";
32
+ export type SearchSortSpec =
33
+ | { kind: "score"; direction: SearchSortDirection }
34
+ | { kind: "offset"; direction: SearchSortDirection }
35
+ | { kind: "field"; direction: SearchSortDirection; field: string; config: SearchFieldConfig };
36
+
37
+ export type SearchTextTarget = {
38
+ field: string;
39
+ config: SearchFieldConfig;
40
+ boost: number;
41
+ };
42
+
43
+ export type CompiledSearchQuery =
44
+ | { kind: "and"; left: CompiledSearchQuery; right: CompiledSearchQuery }
45
+ | { kind: "or"; left: CompiledSearchQuery; right: CompiledSearchQuery }
46
+ | { kind: "not"; expr: CompiledSearchQuery }
47
+ | { kind: "has"; field: string; config: SearchFieldConfig }
48
+ | {
49
+ kind: "compare";
50
+ field: string;
51
+ config: SearchFieldConfig;
52
+ op: SearchComparisonOp;
53
+ canonicalValue?: string;
54
+ compareValue?: bigint | number | boolean;
55
+ }
56
+ | {
57
+ kind: "keyword";
58
+ field: string;
59
+ config: SearchFieldConfig;
60
+ canonicalValue: string;
61
+ prefix: boolean;
62
+ }
63
+ | {
64
+ kind: "text";
65
+ fields: SearchTextTarget[];
66
+ tokens: string[];
67
+ phrase: boolean;
68
+ prefix: boolean;
69
+ rawText: string;
70
+ };
71
+
72
+ export type SearchRequest = {
73
+ q: CompiledSearchQuery;
74
+ size: number;
75
+ timeoutMs: number | null;
76
+ sort: SearchSortSpec[];
77
+ searchAfter: unknown[] | null;
78
+ };
79
+
80
+ export type SearchExactClause = {
81
+ field: string;
82
+ canonicalValue: string;
83
+ };
84
+
85
+ export type SearchColumnClause = {
86
+ field: string;
87
+ op: SearchComparisonOp | "has";
88
+ compareValue?: bigint | number | boolean;
89
+ };
90
+
91
+ export type SearchFtsClause =
92
+ | { kind: "has"; field: string }
93
+ | { kind: "keyword"; field: string; canonicalValue: string; prefix: boolean }
94
+ | { kind: "text"; fields: SearchTextTarget[]; tokens: string[]; phrase: boolean; prefix: boolean };
95
+
96
+ type SearchDocument = {
97
+ exactValues: Map<string, string[]>;
98
+ textValues: Map<string, string[]>;
99
+ rawValues: Map<string, unknown[]>;
100
+ };
101
+
102
+ export type SearchEvaluation = {
103
+ matched: boolean;
104
+ score: number;
105
+ matchedText: boolean;
106
+ };
107
+
108
+ export type SearchHitFieldMap = Record<string, unknown>;
109
+
110
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
111
+ return !!value && typeof value === "object" && !Array.isArray(value);
112
+ }
113
+
114
+ function tokenizeResult(input: string): Result<Token[], { message: string }> {
115
+ const tokens: Token[] = [];
116
+ let i = 0;
117
+ while (i < input.length) {
118
+ const ch = input[i];
119
+ if (/\s/.test(ch)) {
120
+ i += 1;
121
+ continue;
122
+ }
123
+ if (ch === "(") {
124
+ tokens.push({ kind: "lparen" });
125
+ i += 1;
126
+ continue;
127
+ }
128
+ if (ch === ")") {
129
+ tokens.push({ kind: "rparen" });
130
+ i += 1;
131
+ continue;
132
+ }
133
+ if (ch === ":") {
134
+ tokens.push({ kind: "colon" });
135
+ i += 1;
136
+ continue;
137
+ }
138
+ if (ch === "-") {
139
+ tokens.push({ kind: "minus" });
140
+ i += 1;
141
+ continue;
142
+ }
143
+ if (ch === ">" || ch === "<" || ch === "=") {
144
+ if ((ch === ">" || ch === "<") && input[i + 1] === "=") {
145
+ tokens.push({ kind: "op", value: `${ch}=` as ">=" | "<=" });
146
+ i += 2;
147
+ continue;
148
+ }
149
+ tokens.push({ kind: "op", value: ch as "=" | ">" | "<" });
150
+ i += 1;
151
+ continue;
152
+ }
153
+ if (ch === "\"") {
154
+ let out = "";
155
+ i += 1;
156
+ while (i < input.length) {
157
+ const cur = input[i];
158
+ if (cur === "\\") {
159
+ if (i + 1 >= input.length) return Result.err({ message: "unterminated escape in query string" });
160
+ out += input[i + 1];
161
+ i += 2;
162
+ continue;
163
+ }
164
+ if (cur === "\"") break;
165
+ out += cur;
166
+ i += 1;
167
+ }
168
+ if (i >= input.length || input[i] !== "\"") return Result.err({ message: "unterminated quoted string in search query" });
169
+ i += 1;
170
+ tokens.push({ kind: "string", value: out });
171
+ continue;
172
+ }
173
+ let j = i;
174
+ while (j < input.length) {
175
+ const cur = input[j];
176
+ if (/\s/.test(cur) || cur === "(" || cur === ")" || cur === ":" || cur === ">" || cur === "<" || cur === "=") break;
177
+ j += 1;
178
+ }
179
+ const word = input.slice(i, j);
180
+ if (word === "") return Result.err({ message: "invalid search query syntax" });
181
+ tokens.push({ kind: "word", value: word });
182
+ i = j;
183
+ }
184
+ return Result.ok(tokens);
185
+ }
186
+
187
+ class Parser {
188
+ constructor(private readonly tokens: Token[], private pos = 0) {}
189
+
190
+ parseResult(): Result<ParsedQuery, { message: string }> {
191
+ const exprRes = this.parseOrResult();
192
+ if (Result.isError(exprRes)) return exprRes;
193
+ if (!this.isAtEnd()) return Result.err({ message: "unexpected token in search query" });
194
+ return exprRes;
195
+ }
196
+
197
+ private parseOrResult(): Result<ParsedQuery, { message: string }> {
198
+ let leftRes = this.parseAndResult();
199
+ if (Result.isError(leftRes)) return leftRes;
200
+ let left = leftRes.value;
201
+ while (this.peekWord("OR")) {
202
+ this.pos += 1;
203
+ const rightRes = this.parseAndResult();
204
+ if (Result.isError(rightRes)) return rightRes;
205
+ left = { kind: "or", left, right: rightRes.value };
206
+ }
207
+ return Result.ok(left);
208
+ }
209
+
210
+ private parseAndResult(): Result<ParsedQuery, { message: string }> {
211
+ let leftRes = this.parseUnaryResult();
212
+ if (Result.isError(leftRes)) return leftRes;
213
+ let left = leftRes.value;
214
+ while (!this.isAtEnd() && !this.peekKind("rparen") && !this.peekWord("OR")) {
215
+ if (this.peekWord("AND")) this.pos += 1;
216
+ const rightRes = this.parseUnaryResult();
217
+ if (Result.isError(rightRes)) return rightRes;
218
+ left = { kind: "and", left, right: rightRes.value };
219
+ }
220
+ return Result.ok(left);
221
+ }
222
+
223
+ private parseUnaryResult(): Result<ParsedQuery, { message: string }> {
224
+ if (this.peekWord("NOT")) {
225
+ this.pos += 1;
226
+ const innerRes = this.parseUnaryResult();
227
+ if (Result.isError(innerRes)) return innerRes;
228
+ return Result.ok({ kind: "not", expr: innerRes.value });
229
+ }
230
+ if (this.peekKind("minus")) {
231
+ this.pos += 1;
232
+ const innerRes = this.parseUnaryResult();
233
+ if (Result.isError(innerRes)) return innerRes;
234
+ return Result.ok({ kind: "not", expr: innerRes.value });
235
+ }
236
+ return this.parsePrimaryResult();
237
+ }
238
+
239
+ private parsePrimaryResult(): Result<ParsedQuery, { message: string }> {
240
+ if (this.peekKind("lparen")) {
241
+ this.pos += 1;
242
+ const exprRes = this.parseOrResult();
243
+ if (Result.isError(exprRes)) return exprRes;
244
+ if (!this.peekKind("rparen")) return Result.err({ message: "missing ')' in search query" });
245
+ this.pos += 1;
246
+ return exprRes;
247
+ }
248
+
249
+ const token = this.consumeWordOrString();
250
+ if (!token) return Result.err({ message: "expected clause in search query" });
251
+ const quoted = token.kind === "string";
252
+
253
+ if (token.kind === "word" && this.peekKind("colon")) {
254
+ this.pos += 1;
255
+ if (token.value === "has") {
256
+ const fieldToken = this.consumeWordOrString();
257
+ if (!fieldToken || fieldToken.kind !== "word") return Result.err({ message: "has: requires a field name" });
258
+ return Result.ok({ kind: "has", field: fieldToken.value });
259
+ }
260
+ if (token.value === "contains") {
261
+ return Result.err({ message: "contains: is not supported on _search yet" });
262
+ }
263
+ let op: SearchComparisonOp = "eq";
264
+ if (this.peekKind("op")) {
265
+ const raw = (this.tokens[this.pos] as Extract<Token, { kind: "op" }>).value;
266
+ this.pos += 1;
267
+ op = raw === "=" ? "eq" : raw === ">" ? "gt" : raw === ">=" ? "gte" : raw === "<" ? "lt" : "lte";
268
+ }
269
+ const valueToken = this.consumeWordOrString();
270
+ if (!valueToken) return Result.err({ message: "expected value in fielded search clause" });
271
+ return Result.ok({
272
+ kind: "field",
273
+ field: token.value,
274
+ op,
275
+ value: valueToken.value,
276
+ quoted: valueToken.kind === "string",
277
+ });
278
+ }
279
+
280
+ return Result.ok({ kind: "bare", value: token.value, quoted });
281
+ }
282
+
283
+ private consumeWordOrString(): Extract<Token, { kind: "word" | "string" }> | null {
284
+ const token = this.tokens[this.pos];
285
+ if (!token || (token.kind !== "word" && token.kind !== "string")) return null;
286
+ this.pos += 1;
287
+ return token;
288
+ }
289
+
290
+ private peekKind(kind: Token["kind"]): boolean {
291
+ return this.tokens[this.pos]?.kind === kind;
292
+ }
293
+
294
+ private peekWord(value: string): boolean {
295
+ const token = this.tokens[this.pos];
296
+ return token?.kind === "word" && token.value.toUpperCase() === value;
297
+ }
298
+
299
+ private isAtEnd(): boolean {
300
+ return this.pos >= this.tokens.length;
301
+ }
302
+ }
303
+
304
+ function resolveDefaultFieldsResult(search: SearchConfig | undefined): Result<SearchTextTarget[], { message: string }> {
305
+ if (!search) return Result.err({ message: "search is not configured for this stream" });
306
+ const out: SearchTextTarget[] = [];
307
+ if (Array.isArray(search.defaultFields) && search.defaultFields.length > 0) {
308
+ for (const entry of search.defaultFields) {
309
+ const resolved = resolveSearchAlias(search, entry.field);
310
+ const config = search.fields[resolved];
311
+ if (!config) return Result.err({ message: `search default field ${entry.field} is not defined` });
312
+ if (config.kind !== "text" && config.kind !== "keyword") {
313
+ return Result.err({ message: `search default field ${entry.field} must be text or keyword` });
314
+ }
315
+ out.push({ field: resolved, config, boost: entry.boost ?? 1 });
316
+ }
317
+ return Result.ok(out);
318
+ }
319
+ for (const [field, config] of Object.entries(search.fields)) {
320
+ if (config.kind === "text") out.push({ field, config, boost: 1 });
321
+ }
322
+ if (out.length === 0) return Result.err({ message: "search.defaultFields must include at least one text or keyword field" });
323
+ return Result.ok(out);
324
+ }
325
+
326
+ function compileCompareValueResult(
327
+ config: SearchFieldConfig,
328
+ rawValue: string,
329
+ op: SearchComparisonOp
330
+ ): Result<{ canonicalValue?: string; compareValue?: bigint | number | boolean }, { message: string }> {
331
+ const canonical = canonicalizeExactValue(config, rawValue);
332
+ if (canonical == null) return Result.err({ message: "invalid value for search field" });
333
+ if (op === "eq") {
334
+ const compareValue = canonicalizeColumnValue(config, rawValue);
335
+ return Result.ok({ canonicalValue: canonical, compareValue: compareValue ?? undefined });
336
+ }
337
+ const compareValue = canonicalizeColumnValue(config, rawValue);
338
+ if (compareValue == null) return Result.err({ message: "comparison operator is only supported on typed column fields" });
339
+ return Result.ok({ canonicalValue: canonical, compareValue });
340
+ }
341
+
342
+ function compileTextClauseResult(
343
+ fields: SearchTextTarget[],
344
+ rawValue: string,
345
+ quoted: boolean
346
+ ): Result<CompiledSearchQuery, { message: string }> {
347
+ const prefix = rawValue.endsWith("*") && rawValue.length > 1;
348
+ const text = prefix ? rawValue.slice(0, -1) : rawValue;
349
+ const normalized = text.trim();
350
+ if (normalized === "") return Result.err({ message: "search text clause must not be empty" });
351
+ let tokens: string[] = [];
352
+ for (const field of fields) {
353
+ if (field.config.kind === "keyword") {
354
+ const canonical = canonicalizeExactValue(field.config, normalized);
355
+ if (canonical) tokens = [canonical];
356
+ break;
357
+ }
358
+ tokens = analyzeTextValue(normalized, field.config.analyzer);
359
+ if (tokens.length > 0) break;
360
+ }
361
+ if (tokens.length === 0) return Result.err({ message: "search text clause did not produce any searchable tokens" });
362
+ return Result.ok({
363
+ kind: "text",
364
+ fields,
365
+ tokens,
366
+ phrase: quoted,
367
+ prefix,
368
+ rawText: normalized,
369
+ });
370
+ }
371
+
372
+ function compileFieldClauseResult(
373
+ search: SearchConfig,
374
+ fieldName: string,
375
+ op: SearchComparisonOp,
376
+ rawValue: string,
377
+ quoted: boolean
378
+ ): Result<CompiledSearchQuery, { message: string }> {
379
+ const resolvedField = resolveSearchAlias(search, fieldName);
380
+ const config = search.fields[resolvedField];
381
+ if (!config) return Result.err({ message: `unknown search field ${fieldName}` });
382
+
383
+ if (config.kind === "integer" || config.kind === "float" || config.kind === "date" || config.kind === "bool") {
384
+ if (!config.column && op !== "eq") {
385
+ return Result.err({ message: `search field ${fieldName} does not support comparisons` });
386
+ }
387
+ if (!config.column && !config.exact) {
388
+ return Result.err({ message: `search field ${fieldName} does not support equality` });
389
+ }
390
+ const valueRes = compileCompareValueResult(config, rawValue, op);
391
+ if (Result.isError(valueRes)) return valueRes;
392
+ return Result.ok({
393
+ kind: "compare",
394
+ field: resolvedField,
395
+ config,
396
+ op,
397
+ canonicalValue: valueRes.value.canonicalValue,
398
+ compareValue: valueRes.value.compareValue,
399
+ });
400
+ }
401
+
402
+ if (config.kind === "keyword") {
403
+ if (op !== "eq") return Result.err({ message: `search field ${fieldName} does not support comparisons` });
404
+ const prefix = rawValue.endsWith("*") && rawValue.length > 1;
405
+ if (prefix && !config.prefix) return Result.err({ message: `search field ${fieldName} does not support prefix queries` });
406
+ if (!prefix && !config.exact) return Result.err({ message: `search field ${fieldName} does not support exact queries` });
407
+ const canonical = canonicalizeExactValue(config, prefix ? rawValue.slice(0, -1) : rawValue);
408
+ if (canonical == null) return Result.err({ message: `invalid keyword value for ${fieldName}` });
409
+ return Result.ok({
410
+ kind: "keyword",
411
+ field: resolvedField,
412
+ config,
413
+ canonicalValue: canonical,
414
+ prefix,
415
+ });
416
+ }
417
+
418
+ if (config.kind === "text") {
419
+ if (op !== "eq") return Result.err({ message: `search field ${fieldName} does not support comparisons` });
420
+ return compileTextClauseResult([{ field: resolvedField, config, boost: 1 }], rawValue, quoted);
421
+ }
422
+
423
+ return Result.err({ message: `unsupported search field ${fieldName}` });
424
+ }
425
+
426
+ function compileQueryResult(search: SearchConfig, node: ParsedQuery): Result<CompiledSearchQuery, { message: string }> {
427
+ if (node.kind === "and") {
428
+ const leftRes = compileQueryResult(search, node.left);
429
+ if (Result.isError(leftRes)) return leftRes;
430
+ const rightRes = compileQueryResult(search, node.right);
431
+ if (Result.isError(rightRes)) return rightRes;
432
+ return Result.ok({ kind: "and", left: leftRes.value, right: rightRes.value });
433
+ }
434
+ if (node.kind === "or") {
435
+ const leftRes = compileQueryResult(search, node.left);
436
+ if (Result.isError(leftRes)) return leftRes;
437
+ const rightRes = compileQueryResult(search, node.right);
438
+ if (Result.isError(rightRes)) return rightRes;
439
+ return Result.ok({ kind: "or", left: leftRes.value, right: rightRes.value });
440
+ }
441
+ if (node.kind === "not") {
442
+ const innerRes = compileQueryResult(search, node.expr);
443
+ if (Result.isError(innerRes)) return innerRes;
444
+ return Result.ok({ kind: "not", expr: innerRes.value });
445
+ }
446
+ if (node.kind === "has") {
447
+ const resolvedField = resolveSearchAlias(search, node.field);
448
+ const config = search.fields[resolvedField];
449
+ if (!config) return Result.err({ message: `unknown search field ${node.field}` });
450
+ if (!config.exists && !config.exact && !config.column && config.kind !== "text") {
451
+ return Result.err({ message: `search field ${node.field} does not support has:` });
452
+ }
453
+ return Result.ok({ kind: "has", field: resolvedField, config });
454
+ }
455
+ if (node.kind === "field") {
456
+ return compileFieldClauseResult(search, node.field, node.op, node.value, node.quoted);
457
+ }
458
+ const defaultsRes = resolveDefaultFieldsResult(search);
459
+ if (Result.isError(defaultsRes)) return defaultsRes;
460
+ return compileTextClauseResult(defaultsRes.value, node.value, node.quoted);
461
+ }
462
+
463
+ function parseSortItemResult(search: SearchConfig, raw: string): Result<SearchSortSpec, { message: string }> {
464
+ const trimmed = raw.trim();
465
+ if (trimmed === "") return Result.err({ message: "sort entries must not be empty" });
466
+ const idx = trimmed.indexOf(":");
467
+ const fieldName = idx >= 0 ? trimmed.slice(0, idx) : trimmed;
468
+ const directionRaw = idx >= 0 ? trimmed.slice(idx + 1) : "desc";
469
+ const direction = directionRaw === "asc" || directionRaw === "desc" ? directionRaw : null;
470
+ if (!direction) return Result.err({ message: `invalid sort direction for ${fieldName}` });
471
+ if (fieldName === "_score") return Result.ok({ kind: "score", direction });
472
+ if (fieldName === "offset") return Result.ok({ kind: "offset", direction });
473
+
474
+ const resolvedField = resolveSearchAlias(search, fieldName);
475
+ const config = search.fields[resolvedField];
476
+ if (!config) return Result.err({ message: `unknown sort field ${fieldName}` });
477
+ if (!config.sortable && resolvedField !== search.primaryTimestampField) {
478
+ return Result.err({ message: `search field ${fieldName} is not sortable` });
479
+ }
480
+ return Result.ok({ kind: "field", direction, field: resolvedField, config });
481
+ }
482
+
483
+ function hasScoringTextClause(query: CompiledSearchQuery): boolean {
484
+ if (query.kind === "and" || query.kind === "or") return hasScoringTextClause(query.left) || hasScoringTextClause(query.right);
485
+ if (query.kind === "not") return hasScoringTextClause(query.expr);
486
+ return query.kind === "text";
487
+ }
488
+
489
+ function normalizeSortWithTieBreaker(search: SearchConfig, query: CompiledSearchQuery, explicit: SearchSortSpec[]): SearchSortSpec[] {
490
+ const timestampSort: SearchSortSpec = {
491
+ kind: "field",
492
+ direction: "desc",
493
+ field: search.primaryTimestampField,
494
+ config: search.fields[search.primaryTimestampField]!,
495
+ };
496
+ const sorts: SearchSortSpec[] =
497
+ explicit.length > 0
498
+ ? [...explicit]
499
+ : hasScoringTextClause(query)
500
+ ? [{ kind: "score", direction: "desc" }, timestampSort, { kind: "offset", direction: "desc" }]
501
+ : [timestampSort, { kind: "offset", direction: "desc" }];
502
+ if (!sorts.some((sort) => sort.kind === "offset")) {
503
+ sorts.push({ kind: "offset", direction: "desc" });
504
+ }
505
+ return sorts;
506
+ }
507
+
508
+ function parseSearchAfterResult(raw: unknown): Result<unknown[] | null, { message: string }> {
509
+ if (raw == null) return Result.ok(null);
510
+ if (!Array.isArray(raw)) return Result.err({ message: "search_after must be an array" });
511
+ return Result.ok(raw.map((value) => structuredClone(value)));
512
+ }
513
+
514
+ export function parseSearchQueryResult(registry: SchemaRegistry, input: string): Result<CompiledSearchQuery, { message: string }> {
515
+ const search = registry.search;
516
+ if (!search) return Result.err({ message: "search is not configured for this stream" });
517
+ const trimmed = input.trim();
518
+ if (trimmed === "") return Result.err({ message: "q must not be empty" });
519
+ const tokensRes = tokenizeResult(trimmed);
520
+ if (Result.isError(tokensRes)) return tokensRes;
521
+ const parser = new Parser(tokensRes.value);
522
+ const parsedRes = parser.parseResult();
523
+ if (Result.isError(parsedRes)) return parsedRes;
524
+ return compileQueryResult(search, parsedRes.value);
525
+ }
526
+
527
+ export function parseSearchRequestBodyResult(registry: SchemaRegistry, raw: unknown): Result<SearchRequest, { message: string }> {
528
+ if (!isPlainObject(raw)) return Result.err({ message: "search request must be an object" });
529
+ if (typeof raw.q !== "string") return Result.err({ message: "q must be a string" });
530
+ const queryRes = parseSearchQueryResult(registry, raw.q);
531
+ if (Result.isError(queryRes)) return queryRes;
532
+ const size = raw.size === undefined ? 50 : Number(raw.size);
533
+ if (!Number.isFinite(size) || size <= 0 || !Number.isInteger(size) || size > 500) {
534
+ return Result.err({ message: "size must be an integer between 1 and 500" });
535
+ }
536
+ if ("track_total_hits" in raw) {
537
+ return Result.err({ message: "track_total_hits is no longer supported" });
538
+ }
539
+ const timeoutMs =
540
+ raw.timeout_ms === undefined || raw.timeout_ms === null
541
+ ? null
542
+ : typeof raw.timeout_ms === "number" && Number.isFinite(raw.timeout_ms) && raw.timeout_ms >= 0
543
+ ? Math.trunc(raw.timeout_ms)
544
+ : null;
545
+ if (raw.timeout_ms !== undefined && raw.timeout_ms !== null && timeoutMs == null) {
546
+ return Result.err({ message: "timeout_ms must be a non-negative number" });
547
+ }
548
+ const search = registry.search!;
549
+ const sortItems = raw.sort === undefined ? [] : Array.isArray(raw.sort) ? raw.sort : null;
550
+ if (raw.sort !== undefined && !sortItems) return Result.err({ message: "sort must be an array of strings" });
551
+ const parsedSorts: SearchSortSpec[] = [];
552
+ for (const entry of sortItems ?? []) {
553
+ if (typeof entry !== "string") return Result.err({ message: "sort entries must be strings" });
554
+ const sortRes = parseSortItemResult(search, entry);
555
+ if (Result.isError(sortRes)) return sortRes;
556
+ parsedSorts.push(sortRes.value);
557
+ }
558
+ const searchAfterRes = parseSearchAfterResult(raw.search_after);
559
+ if (Result.isError(searchAfterRes)) return searchAfterRes;
560
+ const sort = normalizeSortWithTieBreaker(search, queryRes.value, parsedSorts);
561
+ return Result.ok({
562
+ q: queryRes.value,
563
+ size,
564
+ timeoutMs,
565
+ sort,
566
+ searchAfter: searchAfterRes.value,
567
+ });
568
+ }
569
+
570
+ export function parseSearchRequestQueryResult(
571
+ registry: SchemaRegistry,
572
+ params: URLSearchParams
573
+ ): Result<SearchRequest, { message: string }> {
574
+ const q = params.get("q");
575
+ if (!q) return Result.err({ message: "missing q" });
576
+ if (params.has("track_total_hits")) {
577
+ return Result.err({ message: "track_total_hits is no longer supported" });
578
+ }
579
+ const sortValues = params.getAll("sort");
580
+ const splitSorts = sortValues.flatMap((value) => value.split(",")).map((value) => value.trim()).filter((value) => value !== "");
581
+ let searchAfter: unknown[] | null = null;
582
+ const searchAfterParam = params.get("search_after");
583
+ if (searchAfterParam) {
584
+ try {
585
+ const parsed = JSON.parse(searchAfterParam);
586
+ if (!Array.isArray(parsed)) return Result.err({ message: "search_after must be a JSON array" });
587
+ searchAfter = parsed;
588
+ } catch {
589
+ return Result.err({ message: "search_after must be a JSON array" });
590
+ }
591
+ }
592
+ return parseSearchRequestBodyResult(registry, {
593
+ q,
594
+ size: params.get("size") ? Number(params.get("size")) : undefined,
595
+ timeout_ms: params.get("timeout_ms") ? Number(params.get("timeout_ms")) : undefined,
596
+ sort: splitSorts.length > 0 ? splitSorts : undefined,
597
+ search_after: searchAfter,
598
+ });
599
+ }
600
+
601
+ export function collectPositiveSearchExactClauses(query: CompiledSearchQuery): SearchExactClause[] {
602
+ const out: SearchExactClause[] = [];
603
+ const visit = (node: CompiledSearchQuery, negated: boolean): void => {
604
+ if (node.kind === "and") {
605
+ visit(node.left, negated);
606
+ visit(node.right, negated);
607
+ return;
608
+ }
609
+ if (node.kind === "not") {
610
+ visit(node.expr, !negated);
611
+ return;
612
+ }
613
+ if (negated) return;
614
+ if (node.kind === "keyword" && !node.prefix && node.config.exact) {
615
+ out.push({ field: node.field, canonicalValue: node.canonicalValue });
616
+ return;
617
+ }
618
+ if (node.kind === "compare" && node.op === "eq" && node.config.exact && node.canonicalValue) {
619
+ out.push({ field: node.field, canonicalValue: node.canonicalValue });
620
+ }
621
+ };
622
+ visit(query, false);
623
+ return out;
624
+ }
625
+
626
+ export function collectPositiveSearchColumnClauses(query: CompiledSearchQuery): SearchColumnClause[] {
627
+ const out: SearchColumnClause[] = [];
628
+ const supportsColumn = (config: SearchFieldConfig): boolean =>
629
+ config.column === true && (config.kind === "integer" || config.kind === "float" || config.kind === "date" || config.kind === "bool");
630
+ const visit = (node: CompiledSearchQuery, negated: boolean): void => {
631
+ if (node.kind === "and") {
632
+ visit(node.left, negated);
633
+ visit(node.right, negated);
634
+ return;
635
+ }
636
+ if (node.kind === "not") {
637
+ visit(node.expr, !negated);
638
+ return;
639
+ }
640
+ if (negated) return;
641
+ if (node.kind === "has") {
642
+ if (supportsColumn(node.config)) out.push({ field: node.field, op: "has" });
643
+ return;
644
+ }
645
+ if (node.kind === "compare" && supportsColumn(node.config)) {
646
+ out.push({ field: node.field, op: node.op, compareValue: node.compareValue });
647
+ }
648
+ };
649
+ visit(query, false);
650
+ return out;
651
+ }
652
+
653
+ export function collectPositiveSearchFtsClauses(query: CompiledSearchQuery): SearchFtsClause[] {
654
+ const out: SearchFtsClause[] = [];
655
+ const visit = (node: CompiledSearchQuery, negated: boolean): void => {
656
+ if (node.kind === "and") {
657
+ visit(node.left, negated);
658
+ visit(node.right, negated);
659
+ return;
660
+ }
661
+ if (node.kind === "not") {
662
+ visit(node.expr, !negated);
663
+ return;
664
+ }
665
+ if (negated) return;
666
+ if (node.kind === "has") {
667
+ if (node.config.kind === "text" || (node.config.kind === "keyword" && node.config.prefix === true)) {
668
+ out.push({ kind: "has", field: node.field });
669
+ }
670
+ return;
671
+ }
672
+ if (node.kind === "keyword") {
673
+ if (node.config.kind === "keyword" && node.config.prefix === true) {
674
+ out.push({ kind: "keyword", field: node.field, canonicalValue: node.canonicalValue, prefix: node.prefix });
675
+ }
676
+ return;
677
+ }
678
+ if (node.kind === "text") {
679
+ out.push({ kind: "text", fields: node.fields, tokens: node.tokens, phrase: node.phrase, prefix: node.prefix });
680
+ }
681
+ };
682
+ visit(query, false);
683
+ return out;
684
+ }
685
+
686
+ export function buildSearchDocumentResult(
687
+ registry: SchemaRegistry,
688
+ offset: bigint,
689
+ value: unknown
690
+ ): Result<SearchDocument, { message: string }> {
691
+ const exactRes = extractSearchExactValuesResult(registry, offset, value);
692
+ if (Result.isError(exactRes)) return exactRes;
693
+ const textRes = extractSearchTextValuesResult(registry, offset, value);
694
+ if (Result.isError(textRes)) return textRes;
695
+ const rawRes = extractRawSearchValuesResult(registry, offset, value);
696
+ if (Result.isError(rawRes)) return rawRes;
697
+ return Result.ok({
698
+ exactValues: exactRes.value,
699
+ textValues: textRes.value,
700
+ rawValues: rawRes.value,
701
+ });
702
+ }
703
+
704
+ function compareCanonical(left: string, right: bigint | number | boolean | string, kind: SearchFieldConfig["kind"]): number {
705
+ if (kind === "integer" || kind === "date") {
706
+ const l = BigInt(left);
707
+ const r = right as bigint;
708
+ return l < r ? -1 : l > r ? 1 : 0;
709
+ }
710
+ if (kind === "float") {
711
+ const l = Number(left);
712
+ const r = right as number;
713
+ return l < r ? -1 : l > r ? 1 : 0;
714
+ }
715
+ if (kind === "bool") {
716
+ const l = left === "true";
717
+ const r = right === true || right === "true";
718
+ return l === r ? 0 : l ? 1 : -1;
719
+ }
720
+ const r = String(right);
721
+ return left < r ? -1 : left > r ? 1 : 0;
722
+ }
723
+
724
+ function matchPhraseTokens(docTokens: string[], queryTokens: string[], prefix: boolean): boolean {
725
+ if (queryTokens.length === 0) return false;
726
+ for (let start = 0; start < docTokens.length; start++) {
727
+ let matched = true;
728
+ for (let i = 0; i < queryTokens.length; i++) {
729
+ const docToken = docTokens[start + i];
730
+ if (docToken == null) {
731
+ matched = false;
732
+ break;
733
+ }
734
+ const queryToken = queryTokens[i];
735
+ const isLast = i === queryTokens.length - 1;
736
+ if (prefix && isLast) {
737
+ if (!docToken.startsWith(queryToken)) {
738
+ matched = false;
739
+ break;
740
+ }
741
+ } else if (docToken !== queryToken) {
742
+ matched = false;
743
+ break;
744
+ }
745
+ }
746
+ if (matched) return true;
747
+ }
748
+ return false;
749
+ }
750
+
751
+ function scoreTextTokens(docTokens: string[], queryTokens: string[], boost: number, phrase: boolean, prefix: boolean): number {
752
+ if (queryTokens.length === 0) return 0;
753
+ let matches = 0;
754
+ for (let i = 0; i < queryTokens.length; i++) {
755
+ const token = queryTokens[i];
756
+ const isLast = i === queryTokens.length - 1;
757
+ matches += docTokens.filter((docToken) => (prefix && isLast ? docToken.startsWith(token) : docToken === token)).length;
758
+ }
759
+ if (matches === 0) return 0;
760
+ const phraseBonus = phrase ? queryTokens.length * 2 : 0;
761
+ return (matches + phraseBonus) * boost;
762
+ }
763
+
764
+ function evaluateTextClause(node: Extract<CompiledSearchQuery, { kind: "text" }>, doc: SearchDocument): { matched: boolean; score: number } {
765
+ let matched = false;
766
+ let score = 0;
767
+ for (const target of node.fields) {
768
+ const values = doc.textValues.get(target.field) ?? [];
769
+ if (values.length === 0) continue;
770
+ for (const value of values) {
771
+ if (target.config.kind === "keyword") {
772
+ if (node.phrase) {
773
+ const ok = node.prefix ? value.startsWith(node.tokens[0]) : value === node.tokens[0];
774
+ if (!ok) continue;
775
+ matched = true;
776
+ score = Math.max(score, target.boost);
777
+ continue;
778
+ }
779
+ const ok = node.prefix ? value.startsWith(node.tokens[0]) : value === node.tokens[0];
780
+ if (!ok) continue;
781
+ matched = true;
782
+ score = Math.max(score, target.boost);
783
+ continue;
784
+ }
785
+ const docTokens = analyzeTextValue(value, target.config.analyzer);
786
+ const ok = node.phrase
787
+ ? matchPhraseTokens(docTokens, node.tokens, node.prefix)
788
+ : node.prefix && node.tokens.length === 1
789
+ ? docTokens.some((token) => token.startsWith(node.tokens[0]))
790
+ : node.tokens.every((token) => docTokens.includes(token));
791
+ if (!ok) continue;
792
+ matched = true;
793
+ score = Math.max(score, scoreTextTokens(docTokens, node.tokens, target.boost, node.phrase, node.prefix));
794
+ }
795
+ }
796
+ return { matched, score };
797
+ }
798
+
799
+ export function evaluateSearchQueryResult(
800
+ registry: SchemaRegistry,
801
+ offset: bigint,
802
+ query: CompiledSearchQuery,
803
+ value: unknown
804
+ ): Result<SearchEvaluation, { message: string }> {
805
+ const docRes = buildSearchDocumentResult(registry, offset, value);
806
+ if (Result.isError(docRes)) return docRes;
807
+ const doc = docRes.value;
808
+ const evalNode = (node: CompiledSearchQuery): SearchEvaluation => {
809
+ if (node.kind === "and") {
810
+ const left = evalNode(node.left);
811
+ if (!left.matched) return { matched: false, score: 0, matchedText: false };
812
+ const right = evalNode(node.right);
813
+ if (!right.matched) return { matched: false, score: 0, matchedText: false };
814
+ return { matched: true, score: left.score + right.score, matchedText: left.matchedText || right.matchedText };
815
+ }
816
+ if (node.kind === "or") {
817
+ const left = evalNode(node.left);
818
+ const right = evalNode(node.right);
819
+ if (!left.matched && !right.matched) return { matched: false, score: 0, matchedText: false };
820
+ return {
821
+ matched: true,
822
+ score: left.score + right.score,
823
+ matchedText: left.matchedText || right.matchedText,
824
+ };
825
+ }
826
+ if (node.kind === "not") {
827
+ const inner = evalNode(node.expr);
828
+ return { matched: !inner.matched, score: 0, matchedText: false };
829
+ }
830
+ if (node.kind === "has") {
831
+ const values = doc.rawValues.get(node.field);
832
+ return { matched: !!values && values.length > 0, score: 0, matchedText: false };
833
+ }
834
+ if (node.kind === "compare") {
835
+ const values = doc.exactValues.get(node.field) ?? [];
836
+ if (values.length === 0) return { matched: false, score: 0, matchedText: false };
837
+ if (node.op === "eq") {
838
+ return { matched: !!node.canonicalValue && values.includes(node.canonicalValue), score: 0, matchedText: false };
839
+ }
840
+ for (const value of values) {
841
+ const cmp = compareCanonical(value, node.compareValue!, node.config.kind);
842
+ if (node.op === "gt" && cmp > 0) return { matched: true, score: 0, matchedText: false };
843
+ if (node.op === "gte" && cmp >= 0) return { matched: true, score: 0, matchedText: false };
844
+ if (node.op === "lt" && cmp < 0) return { matched: true, score: 0, matchedText: false };
845
+ if (node.op === "lte" && cmp <= 0) return { matched: true, score: 0, matchedText: false };
846
+ }
847
+ return { matched: false, score: 0, matchedText: false };
848
+ }
849
+ if (node.kind === "keyword") {
850
+ const values = doc.exactValues.get(node.field) ?? [];
851
+ const matched = node.prefix
852
+ ? values.some((value) => value.startsWith(node.canonicalValue))
853
+ : values.includes(node.canonicalValue);
854
+ return { matched, score: 0, matchedText: false };
855
+ }
856
+ const textEval = evaluateTextClause(node, doc);
857
+ return { matched: textEval.matched, score: textEval.score, matchedText: textEval.matched };
858
+ };
859
+ return Result.ok(evalNode(query));
860
+ }
861
+
862
+ export function extractSearchHitFieldsResult(
863
+ registry: SchemaRegistry,
864
+ offset: bigint,
865
+ value: unknown
866
+ ): Result<SearchHitFieldMap, { message: string }> {
867
+ const rawRes = extractRawSearchValuesResult(registry, offset, value);
868
+ if (Result.isError(rawRes)) return rawRes;
869
+ const out: SearchHitFieldMap = {};
870
+ for (const [field, values] of rawRes.value) {
871
+ if (values.length === 1) out[field] = structuredClone(values[0]);
872
+ else if (values.length > 1) out[field] = structuredClone(values);
873
+ }
874
+ return Result.ok(out);
875
+ }