@terminus-ai/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1055 -0
- package/bin/agent-discovery.mjs +71 -0
- package/bin/agent-icon.mjs +77 -0
- package/bin/agent-models.mjs +77 -0
- package/bin/agent-type.mjs +51 -0
- package/bin/agentdev.mjs +657 -0
- package/bin/app-route-script.mjs +59 -0
- package/bin/app-runtime-contract.mjs +2 -0
- package/bin/appdev-remote.mjs +346 -0
- package/bin/appdev.mjs +4446 -0
- package/bin/apps.mjs +5512 -0
- package/bin/capability-calls.mjs +437 -0
- package/bin/capsule-data.mjs +260 -0
- package/bin/client.mjs +189 -0
- package/bin/commands.mjs +1194 -0
- package/bin/dev-capsules.mjs +1599 -0
- package/bin/dev-contract.mjs +262 -0
- package/bin/dev-data.mjs +287 -0
- package/bin/dev-members.mjs +18 -0
- package/bin/dev-net.mjs +316 -0
- package/bin/dev-notification-popup.mjs +628 -0
- package/bin/dev-ports.mjs +567 -0
- package/bin/dev-server-binding.mjs +35 -0
- package/bin/dev-server-ops.mjs +1086 -0
- package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
- package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
- package/bin/dev-ui/OFL.txt +92 -0
- package/bin/dev-ui/agent-robot.webp +0 -0
- package/bin/dev-ui/app.js +5217 -0
- package/bin/dev-ui/highlight.js +195 -0
- package/bin/dev-ui/index.html +34 -0
- package/bin/dev-ui/style.css +3640 -0
- package/bin/devlint.mjs +112 -0
- package/bin/devserver.mjs +2127 -0
- package/bin/devtriggers.mjs +367 -0
- package/bin/endpoints.mjs +156 -0
- package/bin/errors.mjs +61 -0
- package/bin/files.mjs +169 -0
- package/bin/horizontal-capabilities/v1/contract.json +280 -0
- package/bin/http.mjs +500 -0
- package/bin/lint-manifests/justbash-commands.json +88 -0
- package/bin/lint-manifests/python-stdlib.json +295 -0
- package/bin/login-page.mjs +488 -0
- package/bin/schedules.mjs +664 -0
- package/bin/server-sandbox.mjs +204 -0
- package/bin/servicedev.mjs +425 -0
- package/bin/sync.mjs +357 -0
- package/bin/terminus.js +3666 -0
- package/bin/toolchain.mjs +125 -0
- package/bin/vendor/app-runtime-v1/app-host.json +124 -0
- package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
- package/bin/vendor/app-runtime-v1/doors.json +2867 -0
- package/bin/vendor/appd/node-harness.mjs +209 -0
- package/bin/vendor/appd/python-harness.py +12 -0
- package/bin/vendor/appd/server-protocol.json +84 -0
- package/bin/vendor/where.mjs +541 -0
- package/bin/versioning.mjs +72 -0
- package/bin/write-rules.mjs +398 -0
- package/package.json +41 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@terminus-ai/app-sdk/where` — the ONE JavaScript evaluator for the §2.6
|
|
3
|
+
* managed-record query grammar: `where` validation + matching, `sort`
|
|
4
|
+
* ordering, and `search` text extraction + matching.
|
|
5
|
+
*
|
|
6
|
+
* The backend (`parse_record_filter` + the SQL it builds over `jsonb`) is
|
|
7
|
+
* the specification; this module mirrors it so that the SDK's client-side
|
|
8
|
+
* projections (`LiveCollection`), the `terminus dev` harness, and tests all
|
|
9
|
+
* agree with production. `conformance/where-grammar.json` carries the shared
|
|
10
|
+
* vectors.
|
|
11
|
+
*
|
|
12
|
+
* Known gaps versus PostgreSQL (documented, not hidden):
|
|
13
|
+
* - String ordering follows `localeCompare("en")`, an approximation of the
|
|
14
|
+
* database collation (en_US). Code-unit order is used only as a tiebreak.
|
|
15
|
+
* - Full-text search mirrors `websearch_to_tsquery('simple', …)` over
|
|
16
|
+
* `to_tsvector('simple', …)`: whole-token matching, AND/OR/NOT, quoted
|
|
17
|
+
* phrases. The tokenizer approximates the default text-search parser
|
|
18
|
+
* (words, numbers, hyphenated compounds, decimals, hosts/emails); URL
|
|
19
|
+
* paths, file paths, and other exotic token classes are tokenized as
|
|
20
|
+
* plain words here but as compound tokens by PostgreSQL.
|
|
21
|
+
* - Top-level jsonb scalar-versus-array ordering quirks (a raw scalar
|
|
22
|
+
* compares as a one-element array) are reproduced, but only matter when
|
|
23
|
+
* one field mixes scalars and arrays across records.
|
|
24
|
+
*/
|
|
25
|
+
export const WHERE_OPERATORS = Object.freeze([
|
|
26
|
+
"eq",
|
|
27
|
+
"ne",
|
|
28
|
+
"gt",
|
|
29
|
+
"gte",
|
|
30
|
+
"lt",
|
|
31
|
+
"lte",
|
|
32
|
+
"in",
|
|
33
|
+
"exists",
|
|
34
|
+
]);
|
|
35
|
+
const OPERATOR_SET = new Set(WHERE_OPERATORS);
|
|
36
|
+
/** Mirrors the backend's limits exactly. */
|
|
37
|
+
export const WHERE_MAX_CONDITIONS = 8;
|
|
38
|
+
export const WHERE_MAX_IN_OPTIONS = 32;
|
|
39
|
+
export const WHERE_MAX_FIELD_LENGTH = 64;
|
|
40
|
+
/** Thrown by `validateWhere`/`validateSort` for exactly the inputs the
|
|
41
|
+
* backend rejects with HTTP 400. */
|
|
42
|
+
export class WhereGrammarError extends Error {
|
|
43
|
+
code = "invalid_where";
|
|
44
|
+
status = 400;
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "WhereGrammarError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const FIELD_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
51
|
+
/** Mirrors the backend's `safe_field_name`. Field names are literal
|
|
52
|
+
* top-level keys: `"a.b"` addresses the key named `a.b`, not a path. */
|
|
53
|
+
export function isSafeFieldName(field) {
|
|
54
|
+
return typeof field === "string" && FIELD_NAME.test(field);
|
|
55
|
+
}
|
|
56
|
+
function isPlainObject(value) {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Validate a `where` object and compile it into containment + conditions.
|
|
61
|
+
* Throws `WhereGrammarError` for every input the backend answers with 400:
|
|
62
|
+
* non-object filters, unsafe field names, `in` without 1-32 literals,
|
|
63
|
+
* non-boolean `exists`, or more than 8 operator conditions.
|
|
64
|
+
*/
|
|
65
|
+
export function validateWhere(where) {
|
|
66
|
+
if (where === undefined || where === null)
|
|
67
|
+
return { containment: {}, conditions: [] };
|
|
68
|
+
if (!isPlainObject(where))
|
|
69
|
+
throw new WhereGrammarError("where must be a JSON object");
|
|
70
|
+
const containment = {};
|
|
71
|
+
const conditions = [];
|
|
72
|
+
for (const [field, spec] of Object.entries(where)) {
|
|
73
|
+
if (!isSafeFieldName(field))
|
|
74
|
+
throw new WhereGrammarError(`invalid where field '${field}'`);
|
|
75
|
+
if (spec === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
if (!isPlainObject(spec)) {
|
|
78
|
+
containment[field] = spec;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// An object whose keys are ALL operators is a condition set — e.g.
|
|
82
|
+
// { gte: a, lte: b } is a range. Anything else keeps containment.
|
|
83
|
+
const keys = Object.keys(spec);
|
|
84
|
+
const allOperators = keys.length > 0 && keys.every((key) => OPERATOR_SET.has(key));
|
|
85
|
+
if (!allOperators) {
|
|
86
|
+
containment[field] = spec;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
for (const [key, operand] of Object.entries(spec)) {
|
|
90
|
+
const op = key;
|
|
91
|
+
switch (op) {
|
|
92
|
+
case "eq":
|
|
93
|
+
containment[field] = operand;
|
|
94
|
+
break;
|
|
95
|
+
case "ne":
|
|
96
|
+
conditions.push([field, { op: "ne", operand }]);
|
|
97
|
+
break;
|
|
98
|
+
case "gt":
|
|
99
|
+
case "gte":
|
|
100
|
+
case "lt":
|
|
101
|
+
case "lte":
|
|
102
|
+
conditions.push([field, { op, operand }]);
|
|
103
|
+
break;
|
|
104
|
+
case "in": {
|
|
105
|
+
if (!Array.isArray(operand)) {
|
|
106
|
+
throw new WhereGrammarError("'in' takes an array of literals");
|
|
107
|
+
}
|
|
108
|
+
if (operand.length === 0 || operand.length > WHERE_MAX_IN_OPTIONS) {
|
|
109
|
+
throw new WhereGrammarError("'in' takes 1-32 literals");
|
|
110
|
+
}
|
|
111
|
+
conditions.push([field, { op: "in", options: operand }]);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "exists":
|
|
115
|
+
if (typeof operand !== "boolean") {
|
|
116
|
+
throw new WhereGrammarError("'exists' takes true or false");
|
|
117
|
+
}
|
|
118
|
+
conditions.push([field, { op: "exists", expected: operand }]);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (conditions.length > WHERE_MAX_CONDITIONS) {
|
|
124
|
+
throw new WhereGrammarError("where supports at most 8 operator conditions");
|
|
125
|
+
}
|
|
126
|
+
return { containment, conditions };
|
|
127
|
+
}
|
|
128
|
+
/** Validate a `sort` spec (`field` or `-field`). Returns the field and
|
|
129
|
+
* direction; throws `WhereGrammarError` on an unsafe field name. */
|
|
130
|
+
export function validateSort(sort) {
|
|
131
|
+
if (typeof sort !== "string")
|
|
132
|
+
throw new WhereGrammarError("sort must be a string");
|
|
133
|
+
const descending = sort.startsWith("-");
|
|
134
|
+
const field = descending ? sort.slice(1) : sort;
|
|
135
|
+
if (!isSafeFieldName(field))
|
|
136
|
+
throw new WhereGrammarError(`invalid sort field '${field}'`);
|
|
137
|
+
return { field, descending };
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// jsonb semantics
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
/** Deep JSON equality (key-order independent), as `jsonb = jsonb`. */
|
|
143
|
+
export function jsonEquals(left, right) {
|
|
144
|
+
if (Object.is(left, right))
|
|
145
|
+
return true;
|
|
146
|
+
if (typeof left === "number" && typeof right === "number")
|
|
147
|
+
return left === right;
|
|
148
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
149
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
|
|
150
|
+
return false;
|
|
151
|
+
return left.every((item, index) => jsonEquals(item, right[index]));
|
|
152
|
+
}
|
|
153
|
+
if (isPlainObject(left) && isPlainObject(right)) {
|
|
154
|
+
const leftKeys = Object.keys(left);
|
|
155
|
+
if (leftKeys.length !== Object.keys(right).length)
|
|
156
|
+
return false;
|
|
157
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) && jsonEquals(left[key], right[key]));
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* jsonb containment (`haystack @> needle`) for nested values: objects contain
|
|
163
|
+
* objects whose every key is present with a contained value; arrays contain
|
|
164
|
+
* arrays whose every element is contained by some element; primitives must
|
|
165
|
+
* be equal. (The top-level "array contains primitive" exception never applies
|
|
166
|
+
* here because a `where` object is always an object.)
|
|
167
|
+
*/
|
|
168
|
+
export function jsonContains(haystack, needle) {
|
|
169
|
+
if (isPlainObject(needle)) {
|
|
170
|
+
if (!isPlainObject(haystack))
|
|
171
|
+
return false;
|
|
172
|
+
return Object.entries(needle).every(([key, value]) => Object.hasOwn(haystack, key) && jsonContains(haystack[key], value));
|
|
173
|
+
}
|
|
174
|
+
if (Array.isArray(needle)) {
|
|
175
|
+
if (!Array.isArray(haystack))
|
|
176
|
+
return false;
|
|
177
|
+
return needle.every((item) => {
|
|
178
|
+
if (isPlainObject(item))
|
|
179
|
+
return haystack.some((candidate) => jsonContains(candidate, item));
|
|
180
|
+
if (Array.isArray(item))
|
|
181
|
+
return haystack.some((candidate) => jsonContains(candidate, item));
|
|
182
|
+
return haystack.some((candidate) => jsonEquals(candidate, item));
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return jsonEquals(haystack, needle);
|
|
186
|
+
}
|
|
187
|
+
/** `jsonb_typeof` */
|
|
188
|
+
export function jsonTypeOf(value) {
|
|
189
|
+
if (value === undefined)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (value === null)
|
|
192
|
+
return "null";
|
|
193
|
+
if (Array.isArray(value))
|
|
194
|
+
return "array";
|
|
195
|
+
switch (typeof value) {
|
|
196
|
+
case "string":
|
|
197
|
+
return "string";
|
|
198
|
+
case "number":
|
|
199
|
+
return "number";
|
|
200
|
+
case "boolean":
|
|
201
|
+
return "boolean";
|
|
202
|
+
case "object":
|
|
203
|
+
return "object";
|
|
204
|
+
default:
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const TYPE_RANK = {
|
|
209
|
+
null: 0,
|
|
210
|
+
string: 1,
|
|
211
|
+
number: 2,
|
|
212
|
+
boolean: 3,
|
|
213
|
+
array: 4,
|
|
214
|
+
object: 5,
|
|
215
|
+
};
|
|
216
|
+
export function compareStrings(left, right) {
|
|
217
|
+
const collated = left.localeCompare(right, "en");
|
|
218
|
+
if (collated !== 0)
|
|
219
|
+
return collated < 0 ? -1 : 1;
|
|
220
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
221
|
+
}
|
|
222
|
+
/** jsonb object keys sort by length first, then bytewise. */
|
|
223
|
+
function compareKeys(left, right) {
|
|
224
|
+
if (left.length !== right.length)
|
|
225
|
+
return left.length < right.length ? -1 : 1;
|
|
226
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Total order over JSON values as PostgreSQL orders `jsonb`
|
|
230
|
+
* (Object > Array > Boolean > Number > String > Null; arrays by length then
|
|
231
|
+
* elements; objects by pair count then sorted pairs). Never receives
|
|
232
|
+
* `undefined` — callers handle missing fields (NULLS LAST) themselves.
|
|
233
|
+
*/
|
|
234
|
+
export function compareJson(left, right) {
|
|
235
|
+
const leftType = jsonTypeOf(left) ?? "null";
|
|
236
|
+
const rightType = jsonTypeOf(right) ?? "null";
|
|
237
|
+
if (leftType !== rightType) {
|
|
238
|
+
// Raw scalars are stored as one-element arrays: against an array they
|
|
239
|
+
// compare by length first, then sort before the array.
|
|
240
|
+
if (leftType === "array" && rightType !== "object") {
|
|
241
|
+
const rightLength = 1;
|
|
242
|
+
if (left.length !== rightLength) {
|
|
243
|
+
return left.length < rightLength ? -1 : 1;
|
|
244
|
+
}
|
|
245
|
+
return 1;
|
|
246
|
+
}
|
|
247
|
+
if (rightType === "array" && leftType !== "object") {
|
|
248
|
+
const leftLength = 1;
|
|
249
|
+
if (leftLength !== right.length) {
|
|
250
|
+
return leftLength < right.length ? -1 : 1;
|
|
251
|
+
}
|
|
252
|
+
return -1;
|
|
253
|
+
}
|
|
254
|
+
return TYPE_RANK[leftType] < TYPE_RANK[rightType] ? -1 : 1;
|
|
255
|
+
}
|
|
256
|
+
switch (leftType) {
|
|
257
|
+
case "null":
|
|
258
|
+
return 0;
|
|
259
|
+
case "boolean":
|
|
260
|
+
return left === right ? 0 : left ? 1 : -1;
|
|
261
|
+
case "number": {
|
|
262
|
+
const a = left;
|
|
263
|
+
const b = right;
|
|
264
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
265
|
+
}
|
|
266
|
+
case "string":
|
|
267
|
+
return compareStrings(left, right);
|
|
268
|
+
case "array": {
|
|
269
|
+
const a = left;
|
|
270
|
+
const b = right;
|
|
271
|
+
if (a.length !== b.length)
|
|
272
|
+
return a.length < b.length ? -1 : 1;
|
|
273
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
274
|
+
const order = compareJson(a[index], b[index]);
|
|
275
|
+
if (order)
|
|
276
|
+
return order;
|
|
277
|
+
}
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
case "object": {
|
|
281
|
+
const a = left;
|
|
282
|
+
const b = right;
|
|
283
|
+
const aKeys = Object.keys(a).sort(compareKeys);
|
|
284
|
+
const bKeys = Object.keys(b).sort(compareKeys);
|
|
285
|
+
if (aKeys.length !== bKeys.length)
|
|
286
|
+
return aKeys.length < bKeys.length ? -1 : 1;
|
|
287
|
+
for (let index = 0; index < aKeys.length; index += 1) {
|
|
288
|
+
const keyOrder = compareKeys(aKeys[index], bKeys[index]);
|
|
289
|
+
if (keyOrder)
|
|
290
|
+
return keyOrder;
|
|
291
|
+
const valueOrder = compareJson(a[aKeys[index]], b[bKeys[index]]);
|
|
292
|
+
if (valueOrder)
|
|
293
|
+
return valueOrder;
|
|
294
|
+
}
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
default:
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// where matching
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
function fieldValue(value, field) {
|
|
305
|
+
return isPlainObject(value) && Object.hasOwn(value, field) ? value[field] : undefined;
|
|
306
|
+
}
|
|
307
|
+
function conditionMatches(actual, condition) {
|
|
308
|
+
switch (condition.op) {
|
|
309
|
+
case "ne":
|
|
310
|
+
// IS DISTINCT FROM: a missing field is distinct from every operand.
|
|
311
|
+
return actual === undefined || !jsonEquals(actual, condition.operand);
|
|
312
|
+
case "in":
|
|
313
|
+
return actual !== undefined && condition.options.some((option) => jsonEquals(actual, option));
|
|
314
|
+
case "exists":
|
|
315
|
+
return (actual !== undefined) === condition.expected;
|
|
316
|
+
default: {
|
|
317
|
+
// Same-type jsonb comparison: type mismatches and missing fields never match.
|
|
318
|
+
if (actual === undefined || jsonTypeOf(actual) !== jsonTypeOf(condition.operand))
|
|
319
|
+
return false;
|
|
320
|
+
const order = compareJson(actual, condition.operand);
|
|
321
|
+
switch (condition.op) {
|
|
322
|
+
case "gt":
|
|
323
|
+
return order > 0;
|
|
324
|
+
case "gte":
|
|
325
|
+
return order >= 0;
|
|
326
|
+
case "lt":
|
|
327
|
+
return order < 0;
|
|
328
|
+
case "lte":
|
|
329
|
+
return order <= 0;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Evaluate a compiled filter against one record value. */
|
|
335
|
+
export function compiledWhereMatches(value, compiled) {
|
|
336
|
+
if (Object.keys(compiled.containment).length && !jsonContains(value, compiled.containment)) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
return compiled.conditions.every(([field, condition]) => conditionMatches(fieldValue(value, field), condition));
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Does `value` satisfy `where`? Validates first (throws `WhereGrammarError`
|
|
343
|
+
* exactly where the backend would answer 400). For repeated evaluation
|
|
344
|
+
* compile once with `validateWhere` and call `compiledWhereMatches`.
|
|
345
|
+
*/
|
|
346
|
+
export function whereMatches(value, where) {
|
|
347
|
+
return compiledWhereMatches(value, validateWhere(where));
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Comparator for `ORDER BY data->field [DESC] NULLS LAST, record_id ASC`,
|
|
351
|
+
* or plain `ORDER BY record_id` when `sort` is omitted. Missing fields sort
|
|
352
|
+
* last in either direction; JSON `null` is a value (lowest) and follows
|
|
353
|
+
* the direction.
|
|
354
|
+
*/
|
|
355
|
+
export function recordComparator(sort) {
|
|
356
|
+
if (!sort)
|
|
357
|
+
return (left, right) => compareStrings(left.id, right.id);
|
|
358
|
+
const { field, descending } = validateSort(sort);
|
|
359
|
+
return (left, right) => {
|
|
360
|
+
const a = fieldValue(left.value, field);
|
|
361
|
+
const b = fieldValue(right.value, field);
|
|
362
|
+
if (a === undefined || b === undefined) {
|
|
363
|
+
if (a === undefined && b === undefined)
|
|
364
|
+
return compareStrings(left.id, right.id);
|
|
365
|
+
return a === undefined ? 1 : -1;
|
|
366
|
+
}
|
|
367
|
+
const order = compareJson(a, b);
|
|
368
|
+
if (order)
|
|
369
|
+
return descending ? -order : order;
|
|
370
|
+
return compareStrings(left.id, right.id);
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/** Sort a copy of `records` by `sort` (see `recordComparator`). */
|
|
374
|
+
export function sortRecords(records, sort) {
|
|
375
|
+
return [...records].sort(recordComparator(sort));
|
|
376
|
+
}
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// search
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
/**
|
|
381
|
+
* The searchable text for a record (mirrors `record_search_text`): the
|
|
382
|
+
* declared search fields joined by newlines — strings verbatim, arrays of
|
|
383
|
+
* strings one per line, other non-null values as JSON — or the whole record
|
|
384
|
+
* as compact JSON when no fields are declared.
|
|
385
|
+
*/
|
|
386
|
+
export function searchText(searchFields, value) {
|
|
387
|
+
const fields = Array.isArray(searchFields) ? searchFields.filter((f) => typeof f === "string") : [];
|
|
388
|
+
if (!fields.length)
|
|
389
|
+
return JSON.stringify(value ?? null);
|
|
390
|
+
let text = "";
|
|
391
|
+
for (const field of fields) {
|
|
392
|
+
const item = fieldValue(value, field);
|
|
393
|
+
if (typeof item === "string") {
|
|
394
|
+
text += `${item}\n`;
|
|
395
|
+
}
|
|
396
|
+
else if (Array.isArray(item)) {
|
|
397
|
+
for (const entry of item)
|
|
398
|
+
if (typeof entry === "string")
|
|
399
|
+
text += `${entry}\n`;
|
|
400
|
+
}
|
|
401
|
+
else if (item !== undefined && item !== null) {
|
|
402
|
+
text += `${JSON.stringify(item)}\n`;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return text;
|
|
406
|
+
}
|
|
407
|
+
const WORD = "[\\p{L}\\p{N}]+";
|
|
408
|
+
// Compound classes the default parser emits as one lexeme (plus parts for
|
|
409
|
+
// hyphenated words): emails, hosts/versions (dotted), hyphenated words,
|
|
410
|
+
// decimal numbers; then plain words.
|
|
411
|
+
const TOKEN_PATTERN = new RegExp([
|
|
412
|
+
`(?<email>[\\p{L}\\p{N}_.+-]+@[\\p{L}\\p{N}-]+(?:\\.[\\p{L}\\p{N}-]+)+)`,
|
|
413
|
+
`(?<host>[\\p{L}\\p{N}-]+(?:\\.[\\p{L}\\p{N}-]+)+)`,
|
|
414
|
+
`(?<hyphenated>${WORD}(?:-${WORD})+)`,
|
|
415
|
+
`(?<word>${WORD})`,
|
|
416
|
+
].join("|"), "gu");
|
|
417
|
+
/**
|
|
418
|
+
* Approximates `to_tsvector('simple', text)`: lowercase lexemes with
|
|
419
|
+
* positions. Hyphenated compounds yield the whole word plus each part
|
|
420
|
+
* (numeric parts keep their sign, as PostgreSQL does).
|
|
421
|
+
*/
|
|
422
|
+
export function tokenize(text) {
|
|
423
|
+
const tokens = [];
|
|
424
|
+
let position = 0;
|
|
425
|
+
const push = (lexeme) => {
|
|
426
|
+
position += 1;
|
|
427
|
+
tokens.push({ lexeme: lexeme.toLowerCase(), position });
|
|
428
|
+
};
|
|
429
|
+
for (const match of String(text ?? "").matchAll(TOKEN_PATTERN)) {
|
|
430
|
+
const groups = match.groups ?? {};
|
|
431
|
+
if (groups.email !== undefined || groups.host !== undefined) {
|
|
432
|
+
push(match[0]);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (groups.hyphenated !== undefined) {
|
|
436
|
+
push(match[0]);
|
|
437
|
+
match[0].split("-").forEach((part, index) => {
|
|
438
|
+
push(index > 0 && /^\p{N}+$/u.test(part) ? `-${part}` : part);
|
|
439
|
+
});
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
push(match[0]);
|
|
443
|
+
}
|
|
444
|
+
return tokens;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Parse `websearch_to_tsquery` syntax: unquoted words AND together,
|
|
448
|
+
* `"quoted words"` form a phrase, `or` separates alternatives, a leading
|
|
449
|
+
* `-` negates the following word or phrase. Every word is itself run
|
|
450
|
+
* through the tokenizer so hyphenated query words become phrases, as in
|
|
451
|
+
* PostgreSQL. Returns `[]` when the query carries no lexemes.
|
|
452
|
+
*/
|
|
453
|
+
export function parseSearchQuery(query) {
|
|
454
|
+
const groups = [[]];
|
|
455
|
+
const source = String(query ?? "");
|
|
456
|
+
const scanner = /"([^"]*)"?|(\S+)/g;
|
|
457
|
+
let negateNext = false;
|
|
458
|
+
for (const match of source.matchAll(scanner)) {
|
|
459
|
+
const quoted = match[1];
|
|
460
|
+
const word = match[2];
|
|
461
|
+
if (quoted !== undefined) {
|
|
462
|
+
const lexemes = tokenize(quoted).map((token) => token.lexeme);
|
|
463
|
+
if (lexemes.length)
|
|
464
|
+
groups[groups.length - 1].push({ phrase: lexemes, negated: negateNext });
|
|
465
|
+
negateNext = false;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (word.toLowerCase() === "or") {
|
|
469
|
+
negateNext = false;
|
|
470
|
+
if (groups[groups.length - 1].length)
|
|
471
|
+
groups.push([]);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
let body = word;
|
|
475
|
+
let negated = negateNext;
|
|
476
|
+
negateNext = false;
|
|
477
|
+
if (body.startsWith("-") && body.length > 1) {
|
|
478
|
+
negated = true;
|
|
479
|
+
body = body.slice(1);
|
|
480
|
+
}
|
|
481
|
+
else if (body === "-") {
|
|
482
|
+
negateNext = true;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const lexemes = tokenize(body).map((token) => token.lexeme);
|
|
486
|
+
if (!lexemes.length)
|
|
487
|
+
continue;
|
|
488
|
+
groups[groups.length - 1].push({ phrase: lexemes, negated });
|
|
489
|
+
}
|
|
490
|
+
return groups.filter((group) => group.length);
|
|
491
|
+
}
|
|
492
|
+
function phraseMatches(positions, phrase) {
|
|
493
|
+
const first = positions.get(phrase[0]);
|
|
494
|
+
if (!first)
|
|
495
|
+
return false;
|
|
496
|
+
if (phrase.length === 1)
|
|
497
|
+
return true;
|
|
498
|
+
return first.some((start) => phrase.every((lexeme, offset) => positions.get(lexeme)?.includes(start + offset) ?? false));
|
|
499
|
+
}
|
|
500
|
+
/** Evaluate a parsed query against tokenized text. */
|
|
501
|
+
export function searchTokensMatch(tokens, query) {
|
|
502
|
+
if (!query.length)
|
|
503
|
+
return false;
|
|
504
|
+
const positions = new Map();
|
|
505
|
+
for (const token of tokens) {
|
|
506
|
+
const list = positions.get(token.lexeme);
|
|
507
|
+
if (list)
|
|
508
|
+
list.push(token.position);
|
|
509
|
+
else
|
|
510
|
+
positions.set(token.lexeme, [token.position]);
|
|
511
|
+
}
|
|
512
|
+
return query.some((group) => group.every((term) => phraseMatches(positions, term.phrase) !== term.negated));
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* `to_tsvector('simple', text) @@ websearch_to_tsquery('simple', query)`.
|
|
516
|
+
* Whole-token matching: `"dent"` does not match "Dentist". A query with no
|
|
517
|
+
* lexemes matches nothing, as in PostgreSQL.
|
|
518
|
+
*/
|
|
519
|
+
export function searchMatches(text, query) {
|
|
520
|
+
return searchTokensMatch(tokenize(text), parseSearchQuery(query));
|
|
521
|
+
}
|
|
522
|
+
/** What PostgreSQL's parser reads as tokens of their own and `tokenize` does
|
|
523
|
+
* not reproduce: URLs, protocols, hosts with paths and file paths (anything
|
|
524
|
+
* with a slash), markup tags and entities (which PostgreSQL drops), and
|
|
525
|
+
* numbers carrying their own sign or a signed exponent. */
|
|
526
|
+
const INEXACT_TEXT = /\/|<[\p{L}!?]|&[#\p{L}\p{N}]+;|(?:^|[^\p{L}\p{N}])[-+]\p{N}|\p{N}[eE][-+]\p{N}/u;
|
|
527
|
+
/**
|
|
528
|
+
* Whether `tokenize` reads `text` exactly as PostgreSQL's default parser
|
|
529
|
+
* does, so a search judged here agrees with the platform — false when the
|
|
530
|
+
* text holds a class this approximation does not reproduce (see the known
|
|
531
|
+
* gaps above). A live query judges nothing it cannot judge exactly: such a
|
|
532
|
+
* change makes it read its window afresh instead.
|
|
533
|
+
*/
|
|
534
|
+
export function searchTextIsExact(text) {
|
|
535
|
+
return !INEXACT_TEXT.test(String(text ?? ""));
|
|
536
|
+
}
|
|
537
|
+
/** Convenience: does a record value match a search over its declared fields? */
|
|
538
|
+
export function recordSearchMatches(searchFields, value, query) {
|
|
539
|
+
return searchMatches(searchText(searchFields, value), query);
|
|
540
|
+
}
|
|
541
|
+
//# sourceMappingURL=where.js.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The one version grammar for creations. terminus-backend src/versioning.rs
|
|
2
|
+
// is the truth: test/contracts/backend-release-version-vectors.json is its
|
|
3
|
+
// cases, rendered by the backend and pinned here, and the tests run every one.
|
|
4
|
+
// Strict MAJOR.MINOR.PATCH, three decimal integers (no leading zeros, no
|
|
5
|
+
// prerelease/build tags in v1), each at most 2147483647. Stored bare
|
|
6
|
+
// ("1.2.0"), displayed with a v ("v1.2.0"); parsing accepts one leading v so
|
|
7
|
+
// typed input normalizes instead of erroring. New creations start at 0.0.1;
|
|
8
|
+
// every publish must be strictly greater than the latest.
|
|
9
|
+
|
|
10
|
+
export const FIRST_RELEASE_VERSION = "0.0.1";
|
|
11
|
+
|
|
12
|
+
const SEGMENT = "(0|[1-9][0-9]{0,9})";
|
|
13
|
+
const GRAMMAR = new RegExp(`^${SEGMENT}\\.${SEGMENT}\\.${SEGMENT}$`);
|
|
14
|
+
const MAX_SEGMENT = 2147483647;
|
|
15
|
+
|
|
16
|
+
// Rust's `str::trim`: Unicode White_Space at either end. Not JS `trim()`,
|
|
17
|
+
// which also strips U+FEFF and keeps U+0085.
|
|
18
|
+
const RUST_WHITESPACE = "[\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]+";
|
|
19
|
+
const EDGE_WHITESPACE = new RegExp(`^${RUST_WHITESPACE}|${RUST_WHITESPACE}$`, "g");
|
|
20
|
+
|
|
21
|
+
/** Parse a strict release version; null when it does not fit the grammar. */
|
|
22
|
+
export function parseReleaseVersion(value) {
|
|
23
|
+
const trimmed = String(value ?? "").replace(EDGE_WHITESPACE, "");
|
|
24
|
+
const bare = /^[vV]/.test(trimmed) ? trimmed.slice(1) : trimmed;
|
|
25
|
+
const match = GRAMMAR.exec(bare);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const [major, minor, patch] = match.slice(1).map(Number);
|
|
28
|
+
if (major > MAX_SEGMENT || minor > MAX_SEGMENT || patch > MAX_SEGMENT) return null;
|
|
29
|
+
return { major, minor, patch, canonical: `${major}.${minor}.${patch}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Semver ordering over two parsed versions: negative, zero, positive. */
|
|
33
|
+
export function compareReleaseVersions(a, b) {
|
|
34
|
+
return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The next patch above a released version: 0.0.1 → 0.0.2, 1.2.3 → 1.2.4.
|
|
39
|
+
*
|
|
40
|
+
* The same bump the platform takes when a publish names no version at all
|
|
41
|
+
* (terminus-backend `publish_release`), so a suggestion made here and a
|
|
42
|
+
* version chosen there agree. Null at the ceiling, where there is no next.
|
|
43
|
+
*
|
|
44
|
+
* Always computed from the RELEASE, never from what the folder happens to
|
|
45
|
+
* say: a working copy sitting on 0.1.0 under a 0.2.0 release would otherwise
|
|
46
|
+
* be told to go to 0.1.1, which is refused for the same reason 0.1.0 was.
|
|
47
|
+
*/
|
|
48
|
+
export function nextPatchVersion(value) {
|
|
49
|
+
const parsed = typeof value === "object" && value !== null ? value : parseReleaseVersion(value);
|
|
50
|
+
if (!parsed || parsed.patch >= MAX_SEGMENT) return null;
|
|
51
|
+
return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The v-prefixed display form every surface prints. */
|
|
55
|
+
export function formatReleaseVersion(value) {
|
|
56
|
+
const parsed = typeof value === "object" && value !== null ? value : parseReleaseVersion(value);
|
|
57
|
+
if (!parsed) return String(value ?? "");
|
|
58
|
+
return `v${parsed.canonical ?? `${parsed.major}.${parsed.minor}.${parsed.patch}`}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How far a pinned version sits behind a current one:
|
|
63
|
+
* "none" | "patch" | "minor" | "major". Junk on either side is "none".
|
|
64
|
+
*/
|
|
65
|
+
export function behindClass(pinned, current) {
|
|
66
|
+
const from = parseReleaseVersion(pinned);
|
|
67
|
+
const to = parseReleaseVersion(current);
|
|
68
|
+
if (!from || !to || compareReleaseVersions(to, from) <= 0) return "none";
|
|
69
|
+
if (to.major !== from.major) return "major";
|
|
70
|
+
if (to.minor !== from.minor) return "minor";
|
|
71
|
+
return "patch";
|
|
72
|
+
}
|