@osqd/jql 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/LICENSE +102 -0
  3. package/README.md +84 -0
  4. package/bin/jql.mjs +5 -0
  5. package/conformance/cases.json +290 -0
  6. package/dist/array.d.ts +11 -0
  7. package/dist/async.d.ts +44 -0
  8. package/dist/canonical.d.ts +18 -0
  9. package/dist/cjs/array.d.ts +11 -0
  10. package/dist/cjs/async.d.ts +44 -0
  11. package/dist/cjs/canonical.d.ts +18 -0
  12. package/dist/cjs/cli.d.ts +15 -0
  13. package/dist/cjs/collections.d.ts +34 -0
  14. package/dist/cjs/core.d.ts +117 -0
  15. package/dist/cjs/errors.d.ts +17 -0
  16. package/dist/cjs/explain.d.ts +30 -0
  17. package/dist/cjs/global.d.ts +90 -0
  18. package/dist/cjs/group.d.ts +47 -0
  19. package/dist/cjs/index.d.ts +25 -0
  20. package/dist/cjs/internal/closest.d.ts +9 -0
  21. package/dist/cjs/internal/duration.d.ts +15 -0
  22. package/dist/cjs/internal/equal.d.ts +34 -0
  23. package/dist/cjs/internal/glob.d.ts +30 -0
  24. package/dist/cjs/internal/order.d.ts +24 -0
  25. package/dist/cjs/internal/path.d.ts +109 -0
  26. package/dist/cjs/internal/record.d.ts +18 -0
  27. package/dist/cjs/internal/values.d.ts +41 -0
  28. package/dist/cjs/limits.d.ts +64 -0
  29. package/dist/cjs/operators.d.ts +81 -0
  30. package/dist/cjs/package.json +3 -0
  31. package/dist/cjs/plan.d.ts +79 -0
  32. package/dist/cjs/search.d.ts +42 -0
  33. package/dist/cjs/targets/mongo.d.ts +55 -0
  34. package/dist/cjs/text/index.d.ts +12 -0
  35. package/dist/cjs/text/parse.d.ts +91 -0
  36. package/dist/cjs/text/suggest.d.ts +16 -0
  37. package/dist/cjs/text/write.d.ts +34 -0
  38. package/dist/cjs/types.d.ts +236 -0
  39. package/dist/cjs/vocabulary.d.ts +106 -0
  40. package/dist/cli.d.ts +15 -0
  41. package/dist/cli.js +2729 -0
  42. package/dist/cli.js.map +1 -0
  43. package/dist/collections.d.ts +34 -0
  44. package/dist/core.d.ts +117 -0
  45. package/dist/errors.d.ts +17 -0
  46. package/dist/explain.d.ts +30 -0
  47. package/dist/global.cjs +1953 -0
  48. package/dist/global.cjs.map +1 -0
  49. package/dist/global.d.ts +90 -0
  50. package/dist/global.js +1950 -0
  51. package/dist/global.js.map +1 -0
  52. package/dist/group.d.ts +47 -0
  53. package/dist/index.cjs +2529 -0
  54. package/dist/index.cjs.map +1 -0
  55. package/dist/index.d.ts +25 -0
  56. package/dist/index.js +2495 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/internal/closest.d.ts +9 -0
  59. package/dist/internal/duration.d.ts +15 -0
  60. package/dist/internal/equal.d.ts +34 -0
  61. package/dist/internal/glob.d.ts +30 -0
  62. package/dist/internal/order.d.ts +24 -0
  63. package/dist/internal/path.d.ts +109 -0
  64. package/dist/internal/record.d.ts +18 -0
  65. package/dist/internal/values.d.ts +41 -0
  66. package/dist/limits.d.ts +64 -0
  67. package/dist/mongo.cjs +357 -0
  68. package/dist/mongo.cjs.map +1 -0
  69. package/dist/mongo.js +354 -0
  70. package/dist/mongo.js.map +1 -0
  71. package/dist/operators.d.ts +81 -0
  72. package/dist/plan.d.ts +79 -0
  73. package/dist/search.d.ts +42 -0
  74. package/dist/targets/mongo.d.ts +55 -0
  75. package/dist/text/index.d.ts +12 -0
  76. package/dist/text/parse.d.ts +91 -0
  77. package/dist/text/suggest.d.ts +16 -0
  78. package/dist/text/write.d.ts +34 -0
  79. package/dist/text.cjs +674 -0
  80. package/dist/text.cjs.map +1 -0
  81. package/dist/text.js +667 -0
  82. package/dist/text.js.map +1 -0
  83. package/dist/types.d.ts +236 -0
  84. package/dist/vocabulary.d.ts +106 -0
  85. package/docs/course/01-first-query.md +217 -0
  86. package/docs/course/02-operators.md +285 -0
  87. package/docs/course/03-arrays-and-paths.md +239 -0
  88. package/docs/course/04-combining.md +221 -0
  89. package/docs/course/05-dates.md +214 -0
  90. package/docs/course/06-typed-queries.md +240 -0
  91. package/docs/course/07-requests.md +261 -0
  92. package/docs/course/08-grouping.md +210 -0
  93. package/docs/course/09-explaining.md +171 -0
  94. package/docs/course/10-vocabulary.md +276 -0
  95. package/docs/course/11-the-search-box.md +349 -0
  96. package/docs/course/12-untrusted.md +257 -0
  97. package/docs/course/13-saved-filters.md +199 -0
  98. package/docs/course/14-streams-and-cli.md +276 -0
  99. package/docs/course/15-pushdown.md +240 -0
  100. package/docs/course/16-extending.md +199 -0
  101. package/docs/course/index.md +185 -0
  102. package/docs/design/decisions.md +198 -0
  103. package/docs/design/performance.md +102 -0
  104. package/docs/guides/adopting.md +81 -0
  105. package/docs/guides/pushdown.md +147 -0
  106. package/docs/guides/typescript.md +115 -0
  107. package/docs/guides/untrusted-input.md +86 -0
  108. package/docs/index.md +102 -0
  109. package/docs/reference/api.md +266 -0
  110. package/docs/reference/cli.md +103 -0
  111. package/docs/reference/index.md +12 -0
  112. package/docs/reference/specification.md +549 -0
  113. package/docs/reference/text-syntax.md +152 -0
  114. package/docs/start/quick-start.md +84 -0
  115. package/package.json +136 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * How much of a query the engine will take.
3
+ *
4
+ * Queries arrive from places the caller does not control — a URL, a request body, a
5
+ * saved filter somebody else wrote — and every limit here is a way such a query could
6
+ * otherwise cost more than the data it filters. Each one refuses with a `JqlError` rather
7
+ * than truncating, because a query that was quietly cut short answers a different
8
+ * question from the one that was asked.
9
+ *
10
+ * The defaults are far past anything a person writes and far short of anything that
11
+ * hurts. The caps are exported so a caller can say what it accepts in its own
12
+ * documentation and refuse a query before it is even parsed.
13
+ */
14
+ export interface Limits {
15
+ /** How deep operators may nest. Default 32: recursion over pasted input must stop well short of the stack. */
16
+ readonly maxDepth: number;
17
+ /** How many operators and fields a query may hold in total. Default 10 000. `$in` lists do not count: they cost one set lookup each. */
18
+ readonly maxNodes: number;
19
+ /** The longest `$regex` pattern. Default 1024. */
20
+ readonly maxPatternLength: number;
21
+ /**
22
+ * The longest `$glob`. Default 1024, and 256 for untrusted input.
23
+ *
24
+ * A glob cannot backtrack exponentially, but matching one holding `?` is the length of the
25
+ * pattern times the length of the value — so a megabyte of `?` against a megabyte of text
26
+ * is hours per document. It has a cap of its own because `$regex` is turned *off* for
27
+ * untrusted callers while `$glob` stays on, which is exactly when the cap has to hold.
28
+ */
29
+ readonly maxGlobLength: number;
30
+ /** How deep `$text` looks into a document. Default 16: past that a document is a graph, not a record. */
31
+ readonly maxTextDepth: number;
32
+ /**
33
+ * Which operators a query may use. Default `"all"`.
34
+ *
35
+ * An allowlist, because a public endpoint's answer to "what may a caller ask?" should be
36
+ * a list it wrote rather than everything the language grows later. `$text` walks a whole
37
+ * document and `$elemMatch` carries a query of its own; an endpoint that needs neither can
38
+ * say so once, here, instead of discovering later which of them somebody found.
39
+ *
40
+ * Names are checked when the query compiles: an operator outside the list is refused by
41
+ * name, never ignored. `$options` and `$comment` are exempt — they modify and annotate
42
+ * rather than ask anything — and `$field` counts as an operator, because comparing two
43
+ * fields is a capability in its own right.
44
+ */
45
+ readonly allowOperators: readonly string[] | "all";
46
+ /**
47
+ * Whether `$regex` is accepted at all. Default `true`.
48
+ *
49
+ * Turn it off for queries from untrusted callers. No engine can tell a pattern that
50
+ * backtracks for a minute from one that does not without running it, and the string
51
+ * operators (`$contains`, `$startsWith`, `$endsWith`, `$word`) answer almost every
52
+ * question a pattern would, in linear time.
53
+ */
54
+ readonly allowRegex: boolean;
55
+ }
56
+ /** The defaults, frozen, so they can be spread and never edited in place. */
57
+ export declare const DEFAULT_LIMITS: Readonly<Limits>;
58
+ /**
59
+ * What an untrusted query gets: no patterns, and a tighter size.
60
+ *
61
+ * Public so a service taking queries from a URL can use the same answer as every other
62
+ * service in the project, rather than each one picking its own numbers.
63
+ */
64
+ export declare const UNTRUSTED_LIMITS: Readonly<Limits>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What each field operator means, as a test on one value.
3
+ *
4
+ * Every builder here runs once, when a query is compiled, and returns the smallest
5
+ * closure that answers its question: the operand is validated, normalised and, where it
6
+ * helps, turned into a `Set` or a lower-cased copy before any document is seen. The
7
+ * returned test only ever does the part that depends on the document.
8
+ *
9
+ * None of these knows about arrays or paths. Whether a test is also tried against each
10
+ * element of an array, and how a field is reached, is `core.ts`'s business — so each
11
+ * operator's meaning is written exactly once.
12
+ */
13
+ export type Test = (value: unknown) => boolean;
14
+ export declare const NEVER: Test;
15
+ /**
16
+ * Equality with one literal.
17
+ *
18
+ * `null` also matches a missing field, because "has no value" is what
19
+ * people mean by it far more often than "has the value null". `$exists` is there for
20
+ * the times the difference matters.
21
+ */
22
+ export declare function equalsTest(literal: unknown, ignoreCase: boolean, at: string, now: number): Test;
23
+ /** Membership. A `Set` for the primitives, and a walk only for the object and array literals. */
24
+ export declare function inTest(list: unknown, ignoreCase: boolean, at: string, now: number): Test;
25
+ type Ordering = "$gt" | "$gte" | "$lt" | "$lte";
26
+ /**
27
+ * An ordering comparison.
28
+ *
29
+ * Only like with like: a number against numbers (and bigints), a string against strings
30
+ * in code-unit order, a `$date` against anything that reads as a date. Nothing is
31
+ * coerced, so `"10"` is not greater than `9` — a comparison that converted would give a
32
+ * different answer depending on which side happened to be a string, and a query that
33
+ * means different things on different documents is not one anybody can reason about.
34
+ */
35
+ export declare function orderTest(operator: Ordering, bound: unknown, at: string, now: number): Test;
36
+ type StringOperator = "$contains" | "$startsWith" | "$endsWith" | "$word" | "$glob";
37
+ /**
38
+ * The string operators, each taking one value or a list meaning "any of these".
39
+ *
40
+ * A list is there because "the path starts with any of `/health`, `/metrics`" is a single
41
+ * thought, and spelling it as an `$or` of three conditions makes it three.
42
+ */
43
+ export declare function stringTest(operator: StringOperator, operand: unknown, ignoreCase: boolean, at: string, maxGlobLength?: number): Test;
44
+ /**
45
+ * The value as a whole component of the text: bounded on each side by the start or end of
46
+ * the text, or by a character that is not a letter or digit.
47
+ *
48
+ * Ported from BotHandler's actor matching, where it replaced a substring test that let
49
+ * `1.2.3.4` match `1.2.3.45` and `11.2.3.4` — so excluding one address excluded its
50
+ * neighbours too. A side of the value that is itself a separator has chosen its own
51
+ * boundary: `203.0.113.` finds the whole network.
52
+ */
53
+ export declare function containsWord(actual: string, wanted: string): boolean;
54
+ export declare function parseOptions(options: unknown, at: string): string;
55
+ export declare function regexTest(pattern: unknown, flags: string, limits: {
56
+ allowRegex: boolean;
57
+ maxPatternLength: number;
58
+ }, at: string): Test;
59
+ export declare function typeTest(operand: unknown, at: string): Test;
60
+ export declare function modTest(operand: unknown, at: string): Test;
61
+ /** `$all`: every literal is equal to the value or to one of its elements. An empty list matches nothing. */
62
+ export declare function allTest(list: unknown, ignoreCase: boolean, at: string, now: number): Test;
63
+ /**
64
+ * The order of two values that both came out of a document, or `undefined` when they
65
+ * cannot be compared.
66
+ *
67
+ * Only like with like, as everywhere else in the language: numbers with numbers, strings
68
+ * with strings, dates with dates. Nothing is read as a date here, because neither side is
69
+ * a query saying it is one — `{ "$field": … }` compares what the item holds.
70
+ */
71
+ export declare function orderOf(a: unknown, b: unknown): number | undefined;
72
+ /**
73
+ * Refuses a literal JSON cannot carry.
74
+ *
75
+ * A `RegExp`, a `Date`, a function or `undefined` in a query would work in the process
76
+ * that wrote it and mean something else — or nothing — once the query was serialised.
77
+ * The language is the JSON, so the engine holds to it even when handed an object that
78
+ * could have been more.
79
+ */
80
+ export declare function checkLiteral(value: unknown, at: string, depth?: number): void;
81
+ export {};
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,79 @@
1
+ import { type CompileOptions } from "./core.js";
2
+ import type { QueryLike, UntypedQuery } from "./types.js";
3
+ /**
4
+ * Splitting a query between a store and this engine.
5
+ *
6
+ * A backend can usually answer part of a question — an index on a field, an operator it has
7
+ * natively — and nothing of the rest. The shape that works is the one HackerPot's stores
8
+ * already use: push what the store can do, keep what it cannot, and make sure the two
9
+ * together are exactly the question that was asked.
10
+ *
11
+ * That last part is the whole contract, and it is why this splits only on **conjunctions**.
12
+ * A query's keys all have to hold, and so do the parts of an `$and`, so each one can go to
13
+ * either side independently. Anything else — a branch of an `$or`, an operator inside a
14
+ * condition — cannot be split without widening or narrowing what is asked, so a conjunct is
15
+ * pushed whole or not at all:
16
+ *
17
+ * pushed ∧ remaining ≡ query
18
+ *
19
+ * The store filters with `pushed` and this engine filters what comes back with `remaining`.
20
+ * Neither side is ever wider than the query, so a caller that runs only `pushed` gets too
21
+ * many items rather than too few — but `complete` says when that is safe, and it is the only
22
+ * thing worth checking before skipping the second pass.
23
+ */
24
+ export interface Capabilities {
25
+ /**
26
+ * The fields the store can filter on, as the store names them — a path, after any
27
+ * vocabulary alias is resolved. `"all"` for a store that can filter on anything.
28
+ *
29
+ * A vocabulary field that is *computed* is never pushed whatever this says: the store has
30
+ * no way to run a function that lives here.
31
+ */
32
+ readonly fields?: readonly string[] | "all" | undefined;
33
+ /** The operators the store can answer, or `"all"`. Names are JQL's, not the store's. */
34
+ readonly operators?: readonly string[] | "all" | undefined;
35
+ /** Whether the store can answer `$or`. Default `false`: plenty of key-value stores cannot. */
36
+ readonly or?: boolean | undefined;
37
+ /** Whether it can answer a negation — `$not`, `$nor`, `$ne`, `$nin`, `$exists: false`. Default `false`. */
38
+ readonly not?: boolean | undefined;
39
+ /**
40
+ * A last word on one field's condition, for a store whose limits depend on the values
41
+ * rather than only on the operator names.
42
+ *
43
+ * MongoDB is the example this exists for: it answers `$eq` exactly, but not `$eq` against
44
+ * an embedded document (its equality is key-order sensitive where JQL's is not), and not
45
+ * the case-insensitive form of one. Without a say on the values, a target has to choose
46
+ * between dropping `$eq` altogether and pushing a clause that comes back with the wrong
47
+ * rows.
48
+ *
49
+ * The condition is given as written, with a bare value shown as `{ $eq: value }`, and the
50
+ * field under the name the store knows. Returning `false` keeps the clause here.
51
+ */
52
+ readonly accepts?: ((field: string, condition: Readonly<Record<string, unknown>>) => boolean) | undefined;
53
+ }
54
+ export interface Kept {
55
+ /** Where the conjunct is in the query. */
56
+ readonly at: string;
57
+ /** The conjunct itself. */
58
+ readonly clause: unknown;
59
+ /** Why the store cannot answer it. */
60
+ readonly why: string;
61
+ }
62
+ export interface Plan {
63
+ /** What the store should filter by, or `undefined` when it can answer nothing. */
64
+ readonly pushed: UntypedQuery | undefined;
65
+ /** What this engine must still filter, or `undefined` when the store answers everything. */
66
+ readonly remaining: UntypedQuery | undefined;
67
+ /** True when `remaining` is `undefined`: the store's answer needs no second pass. */
68
+ readonly complete: boolean;
69
+ /** One entry per conjunct that stayed here, and why. */
70
+ readonly kept: readonly Kept[];
71
+ }
72
+ /**
73
+ * Splits a query into the part a store can answer and the part that stays here.
74
+ *
75
+ * Relative dates in the pushed part are resolved to instants, so what the store receives is
76
+ * self-contained: `{ "$ago": "1h" }` means nothing to a backend, and resolving it here keeps
77
+ * both halves of the split judged against the same moment.
78
+ */
79
+ export declare function plan<T, Extra extends object = {}>(query: QueryLike<T, NoInfer<Extra>>, capabilities: Capabilities, options?: CompileOptions<Extra>): Plan;
@@ -0,0 +1,42 @@
1
+ import { type CompileOptions } from "./core.js";
2
+ import type { FieldName, Request, Source } from "./types.js";
3
+ type Items<T> = Source<T> | ReadonlyMap<unknown, T>;
4
+ /**
5
+ * Runs a request. With `fields`, each result is a new object holding only those paths.
6
+ *
7
+ * A `Map` is read by value, like everywhere else in this library, so its overloads come
8
+ * first: a `Map` also satisfies `Iterable<[key, value]>`, and without them the types would
9
+ * describe a request over the pairs while the code ran one over the values.
10
+ */
11
+ export declare function search<K, V, Extra extends object = {}>(source: ReadonlyMap<K, V>, request: Request<V, NoInfer<Extra>> & {
12
+ readonly fields: readonly FieldName<V, NoInfer<Extra>>[];
13
+ }, options?: CompileOptions<Extra>): Record<string, unknown>[];
14
+ export declare function search<K, V, Extra extends object = {}>(source: ReadonlyMap<K, V>, request: Request<V, NoInfer<Extra>> & {
15
+ readonly omit: readonly FieldName<V, NoInfer<Extra>>[];
16
+ }, options?: CompileOptions<Extra>): Partial<V>[];
17
+ export declare function search<K, V, Extra extends object = {}>(source: ReadonlyMap<K, V>, request?: Request<V, NoInfer<Extra>>, options?: CompileOptions<Extra>): V[];
18
+ export declare function search<T, Extra extends object = {}>(source: Items<T>, request: Request<T, NoInfer<Extra>> & {
19
+ readonly fields: readonly FieldName<T, NoInfer<Extra>>[];
20
+ }, options?: CompileOptions<Extra>): Record<string, unknown>[];
21
+ export declare function search<T, Extra extends object = {}>(source: Items<T>, request: Request<T, NoInfer<Extra>> & {
22
+ readonly omit: readonly FieldName<T, NoInfer<Extra>>[];
23
+ }, options?: CompileOptions<Extra>): Partial<T>[];
24
+ export declare function search<T, Extra extends object = {}>(source: Items<T>, request?: Request<T, NoInfer<Extra>>, options?: CompileOptions<Extra>): T[];
25
+ /**
26
+ * Everything about a request that does not depend on where the items come from.
27
+ *
28
+ * Pulled out so the asynchronous helpers run the same paging, the same heap and the same
29
+ * projection as the synchronous ones. Two copies of "the best twenty, skipping forty"
30
+ * would be two chances to page differently.
31
+ */
32
+ export declare function planRequest<T>(request: Request<T, object>, options: CompileOptions<object>): {
33
+ collect: () => Collector<T>;
34
+ test: ((item: T) => boolean) | undefined;
35
+ shape: (items: T[]) => unknown[];
36
+ };
37
+ /** Gathers the page as items arrive. `offer` returns true once nothing further can change the answer. */
38
+ export interface Collector<T> {
39
+ offer(item: T, index: number): boolean;
40
+ finish(): T[];
41
+ }
42
+ export {};
@@ -0,0 +1,55 @@
1
+ import type { Capabilities } from "../plan.js";
2
+ import type { Vocabulary } from "../vocabulary.js";
3
+ /**
4
+ * JQL as a MongoDB filter.
5
+ *
6
+ * The first target, because it is the one where the two languages nearly agree: most of
7
+ * JQL's operators *are* MongoDB's, with the same array semantics, so the translation is
8
+ * mostly a copy and the interesting part is the handful that differ.
9
+ *
10
+ * The translation is checked by *running* it, not by comparing its shape: `tests/helpers/mongo.ts`
11
+ * is a second reading of MongoDB's matching rules, taken from its documented behaviour rather
12
+ * than from this engine, and the split test runs both halves of every generated query through
13
+ * it and through the engine and compares the answers.
14
+ *
15
+ * That check found the one difference no capability can express: **an array held directly
16
+ * inside another array**. A path segment in MongoDB applies to an array's elements but not to
17
+ * the elements of *those* arrays; JQL sees an array through at every level. Which rows come
18
+ * back then differs, in either direction, and nothing in the query says so — the shape is in
19
+ * the data. It is written down in specification §3 and in the pushdown guide, because a
20
+ * caller with such a collection must not read `complete` as permission to skip the second
21
+ * pass.
22
+ *
23
+ * What it will not do is guess. Every operator MongoDB cannot answer exactly —
24
+ * `$word`, `$length`, a `{ "$field": … }` reference, `$text` — is left out of
25
+ * `MONGO_CAPABILITIES`, so `plan()` keeps those clauses here and this function never sees
26
+ * them. A translation that was *nearly* right would be the worst of both: fewer rows than
27
+ * the query asked for, from a store that looked like it had answered.
28
+ */
29
+ /**
30
+ * What a MongoDB `find` filter can answer, in JQL's own names.
31
+ *
32
+ * Give it to `plan()` and it splits a query into the filter to send and the predicate to
33
+ * apply to what comes back.
34
+ */
35
+ export declare const MONGO_CAPABILITIES: Capabilities;
36
+ export interface MongoOptions {
37
+ /**
38
+ * Resolves a vocabulary's names and aliases to the paths the collection stores.
39
+ *
40
+ * Only needed for a query that did not come from `plan()`: what `plan` pushes already
41
+ * carries the stored names.
42
+ */
43
+ readonly vocabulary?: Vocabulary<never, object> | undefined;
44
+ /** Where "now" comes from, for a relative date. Default the system clock, read once. */
45
+ readonly now?: (() => number) | undefined;
46
+ }
47
+ /** A MongoDB filter document. Values may be `Date` and `RegExp` objects, which a driver expects. */
48
+ export type MongoFilter = Record<string, unknown>;
49
+ /**
50
+ * Translates a JQL query into a MongoDB filter.
51
+ *
52
+ * Give it only what `plan(query, MONGO_CAPABILITIES)` pushed. Anything else is refused by
53
+ * name rather than approximated.
54
+ */
55
+ export declare function toMongoFilter(query: unknown, options?: MongoOptions): MongoFilter;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@osqd/jql/text` — the search-box syntax, compiled into JQL.
3
+ *
4
+ * A separate entry because a service that only ever receives JSON queries has no reason
5
+ * to load a tokenizer.
6
+ */
7
+ export { parseText, MAX_TEXT_CHARS, MAX_TEXT_DEPTH, TEXT_OPERATORS } from "./parse.js";
8
+ export { suggest } from "./suggest.js";
9
+ export { toText } from "./write.js";
10
+ export type { TextOptions } from "./parse.js";
11
+ export type { Suggestions } from "./suggest.js";
12
+ export type { TextForm, Unexpressed } from "./write.js";
@@ -0,0 +1,91 @@
1
+ import type { UntypedQuery } from "../types.js";
2
+ import { type Vocabulary } from "../vocabulary.js";
3
+ /**
4
+ * The search-box syntax, compiled into a JQL document.
5
+ *
6
+ * Ported from BotHandler's feed filter, where it was its own language with its own
7
+ * matcher. Here it has no matcher at all: it produces the JSON query that means the same
8
+ * thing, and the engine runs that. So a filter typed into a box and the JSON a service
9
+ * receives cannot drift apart — there is only one set of semantics, and the text is a way
10
+ * of writing it.
11
+ *
12
+ * ```text
13
+ * actor:203.0.113.4 -path:/health that address, except its health checks
14
+ * verdict:human $or verdict:unknown either verdict
15
+ * $not path:/health the same as -path:/health, spelled out
16
+ * action:$in(block, drop) one of a set
17
+ * action:$notin(allow, tag) none of a set
18
+ * score:>70 score:>=70 score:10..20 numeric comparisons and a range
19
+ * at:>2026-09-01 a date comparison
20
+ * at:>-1h the last hour, and it stays the last hour
21
+ * has:rule -has:rule the field is set, or is not
22
+ * (verdict:human $or score:<20) $and $not path:/health
23
+ * "GET /api/v2/orders" a phrase, spaces and all
24
+ * ```
25
+ *
26
+ * **Adjacent terms mean AND**, which is what narrowing means. `$or` binds more loosely than
27
+ * `$and`, and `$not` more tightly than either — the conventional precedence. Operators
28
+ * carry a `$` because a bare `or` is a word that appears in User-Agents and paths, and a
29
+ * language where an ordinary search word silently becomes an operator is a language that
30
+ * lies about what it matched.
31
+ *
32
+ * **Nothing here throws for anything anybody types.** It backs live search boxes, so
33
+ * half-typed input is the normal state rather than an error: an unclosed bracket, a dangling
34
+ * `$or`, a `$in(` with nothing after it all parse to the best reading available. A
35
+ * *vocabulary* that is not one is the exception — that is a mistake in the calling code
36
+ * rather than in the box, and it is refused by name instead of failing later, deeper, as a
37
+ * `TypeError` nobody can place. That is the opposite of the JSON
38
+ * engine's rule, and on purpose: JSON is written by a program that can be told it is wrong,
39
+ * a search box by a person who has not finished typing.
40
+ */
41
+ export interface TextOptions {
42
+ /** The field names the text may use, and how each one reads a value. */
43
+ readonly vocabulary?: Vocabulary<never, object> | undefined;
44
+ /**
45
+ * What a name before a colon may be.
46
+ *
47
+ * - `"vocabulary"` — only a vocabulary field; anything else is searched for as text,
48
+ * because a path or a User-Agent can contain a colon. The default when a vocabulary is given.
49
+ * - `"any"` — any name that looks like a path (`address.city:London`), read as text.
50
+ * The default without a vocabulary, since there is nothing else to go on. A URL then
51
+ * has to be quoted: `"http://example.com"`.
52
+ */
53
+ readonly fields?: "vocabulary" | "any" | undefined;
54
+ }
55
+ /**
56
+ * The longest input this will read.
57
+ *
58
+ * Parsing is linear, so length is not a cliff the way nesting is, but this runs on every
59
+ * keystroke and on whatever arrives in a URL. Eight kilobytes is past any query a person
60
+ * writes. Longer input is cut, not refused, because refusing would be the one way this
61
+ * function could fail.
62
+ */
63
+ export declare const MAX_TEXT_CHARS = 8192;
64
+ /**
65
+ * How deep brackets may nest.
66
+ *
67
+ * Past anything anyone types, and deliberately no deeper than the tightest limit the engine
68
+ * is asked to compile under (`UNTRUSTED_LIMITS` allows sixteen levels): a box that produced
69
+ * a query the engine would refuse would be a box that lies about what it accepts.
70
+ */
71
+ export declare const MAX_TEXT_DEPTH = 16;
72
+ /** The name that asks whether a field is set, rather than naming one. */
73
+ export declare const EXISTS_TERM = "has";
74
+ /** The operator words the text syntax understands. */
75
+ export declare const TEXT_OPERATORS: readonly string[];
76
+ /**
77
+ * Parses search-box text into a JQL query. Never throws; an empty input is `{}`, which
78
+ * matches everything. The result is an `UntypedQuery`, so it can be handed to a typed
79
+ * collection: what the text names is only known once somebody has typed it.
80
+ */
81
+ export declare function parseText(input: string, options?: TextOptions): UntypedQuery;
82
+ export declare const SET_TERM: RegExp;
83
+ /**
84
+ * A comparison written out in full, on a field nothing is known about.
85
+ *
86
+ * Without a vocabulary every field reads as text, and `score:>70` would search for the
87
+ * characters ">70" — which nobody typing it means. An operator or a range with a number
88
+ * on it is unambiguous, so it compares. A bare `70` stays text, because on an unknown
89
+ * field it is as likely to be part of an identifier as a quantity.
90
+ */
91
+ export declare const EXPLICIT_COMPARISON: RegExp;
@@ -0,0 +1,16 @@
1
+ import { type Vocabulary } from "../vocabulary.js";
2
+ /**
3
+ * What to offer for the token the caret is in.
4
+ *
5
+ * Returns the completions and the span they replace, so a caller can put one in without
6
+ * disturbing the rest of the input. A token that already names a field with a closed set
7
+ * of values completes the value; anything else completes the field name. Every name
8
+ * offered comes from the vocabulary the parser reads, so nothing offered is something the
9
+ * language would not accept.
10
+ */
11
+ export interface Suggestions {
12
+ readonly options: readonly string[];
13
+ readonly from: number;
14
+ readonly to: number;
15
+ }
16
+ export declare function suggest(input: string, caret: number, vocabulary?: Vocabulary<never, object>): Suggestions;
@@ -0,0 +1,34 @@
1
+ import { type TextOptions } from "./parse.js";
2
+ /**
3
+ * A query, written back as something somebody can type.
4
+ *
5
+ * A dashboard that stores filters as JSON still has to put one in a search box when
6
+ * somebody opens it to edit. That is this, and it is deliberately **not** total: the text
7
+ * syntax has no spelling for `$elemMatch`, `$size`, `$glob` or a `{ "$field": … }`
8
+ * reference, and inventing one per operator would turn a box people can type into a
9
+ * language they have to learn.
10
+ *
11
+ * So the honest answer is two things: the text, and what was left out of it. **The text is
12
+ * exact for what it says** — parsed back with the same vocabulary it means precisely the
13
+ * clauses it carries, which the tests hold it to — but a caller that ignores `unexpressed`
14
+ * is showing somebody a filter wider than the one that is running. `complete` is there so
15
+ * that check is one line.
16
+ */
17
+ export interface Unexpressed {
18
+ /** Where the clause is in the query: `$or[1].tags`. */
19
+ readonly at: string;
20
+ /** The clause itself, as it was written. */
21
+ readonly clause: unknown;
22
+ /** Why the text cannot say it. */
23
+ readonly why: string;
24
+ }
25
+ export interface TextForm {
26
+ /** The query as text. Parsing it back gives exactly the clauses it carries. */
27
+ readonly text: string;
28
+ /** True when the text says everything the query does. */
29
+ readonly complete: boolean;
30
+ /** The clauses with no spelling in the text syntax, in the order they were met. */
31
+ readonly unexpressed: readonly Unexpressed[];
32
+ }
33
+ /** Writes a query as search-box text, and says what it could not write. */
34
+ export declare function toText(query: unknown, options?: TextOptions): TextForm;