common-qa-mcp 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/remoteMcp/adapter.js +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +29 -0
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/adapter.js +143 -17
- package/dist/adapters/simplifyqa/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/executionFailures.d.ts +59 -0
- package/dist/adapters/simplifyqa/executionFailures.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/executionFailures.js +638 -0
- package/dist/adapters/simplifyqa/executionFailures.js.map +1 -0
- package/dist/adapters/simplifyqa/failureCategory.d.ts +26 -0
- package/dist/adapters/simplifyqa/failureCategory.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/failureCategory.js +208 -0
- package/dist/adapters/simplifyqa/failureCategory.js.map +1 -0
- package/dist/adapters/simplifyqa/failureReport.d.ts +50 -0
- package/dist/adapters/simplifyqa/failureReport.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/failureReport.js +353 -0
- package/dist/adapters/simplifyqa/failureReport.js.map +1 -0
- package/dist/adapters/simplifyqa/failureTypes.d.ts +193 -0
- package/dist/adapters/simplifyqa/failureTypes.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/failureTypes.js +8 -0
- package/dist/adapters/simplifyqa/failureTypes.js.map +1 -0
- package/dist/adapters/simplifyqa/flow.d.ts +55 -0
- package/dist/adapters/simplifyqa/flow.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/flow.js +325 -0
- package/dist/adapters/simplifyqa/flow.js.map +1 -0
- package/dist/adapters/simplifyqa/mapLimit.d.ts +3 -0
- package/dist/adapters/simplifyqa/mapLimit.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/mapLimit.js +14 -0
- package/dist/adapters/simplifyqa/mapLimit.js.map +1 -0
- package/dist/adapters/simplifyqa/mapper.d.ts +3 -0
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/mapper.js +6 -7
- package/dist/adapters/simplifyqa/mapper.js.map +1 -1
- package/dist/adapters/simplifyqa/planTree.d.ts +31 -0
- package/dist/adapters/simplifyqa/planTree.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/planTree.js +110 -0
- package/dist/adapters/simplifyqa/planTree.js.map +1 -0
- package/dist/adapters/simplifyqa/searchEngine.d.ts +237 -0
- package/dist/adapters/simplifyqa/searchEngine.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/searchEngine.js +1206 -0
- package/dist/adapters/simplifyqa/searchEngine.js.map +1 -0
- package/dist/adapters/simplifyqa/searchRegistry.d.ts +112 -0
- package/dist/adapters/simplifyqa/searchRegistry.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/searchRegistry.js +1413 -0
- package/dist/adapters/simplifyqa/searchRegistry.js.map +1 -0
- package/dist/adapters/simplifyqa/searchResolve.d.ts +84 -0
- package/dist/adapters/simplifyqa/searchResolve.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/searchResolve.js +319 -0
- package/dist/adapters/simplifyqa/searchResolve.js.map +1 -0
- package/dist/adapters/simplifyqa/stepRules.d.ts +91 -0
- package/dist/adapters/simplifyqa/stepRules.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/stepRules.js +228 -0
- package/dist/adapters/simplifyqa/stepRules.js.map +1 -0
- package/dist/domain/types.d.ts +4 -2
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +32 -9
- package/dist/initAgents.js.map +1 -1
- package/dist/mcpResult.d.ts +19 -0
- package/dist/mcpResult.d.ts.map +1 -1
- package/dist/mcpResult.js +11 -0
- package/dist/mcpResult.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +70 -16
- package/dist/server.js.map +1 -1
- package/dist/tools/discovery.tools.d.ts.map +1 -1
- package/dist/tools/discovery.tools.js +36 -10
- package/dist/tools/discovery.tools.js.map +1 -1
- package/dist/tools/failure.tools.d.ts +14 -0
- package/dist/tools/failure.tools.d.ts.map +1 -0
- package/dist/tools/failure.tools.js +65 -0
- package/dist/tools/failure.tools.js.map +1 -0
- package/dist/tools/query.tools.d.ts +20 -0
- package/dist/tools/query.tools.d.ts.map +1 -0
- package/dist/tools/query.tools.js +310 -0
- package/dist/tools/query.tools.js.map +1 -0
- package/dist/tools/testcase.tools.d.ts.map +1 -1
- package/dist/tools/testcase.tools.js +1 -2
- package/dist/tools/testcase.tools.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic SQA query engine — the implementation behind the single `sqa_query` tool.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - resolve a collection alias to its registry entry
|
|
6
|
+
* - validate fields/operators (the backend silently ignores unknown columns, so we must not)
|
|
7
|
+
* - inject `deleted:false` + the active project scope on every read
|
|
8
|
+
* - resolve custom-field names -> `customFields.<key>` via `/pm/fields/{parentId}`
|
|
9
|
+
* - compile `where`/`sort`/`select` into the SQA `searchFields` payload
|
|
10
|
+
* - normalize `{ totalCount, data }` and paginate with `skipStartIndex` (row offset, not page)
|
|
11
|
+
*/
|
|
12
|
+
import { notDeleted, pickArray, str, numericId } from './mapper.js';
|
|
13
|
+
import { dateWindow, IST_OFFSET_MINUTES, latestById, matchEnumWord, matchLifecycleWord, matchNamed, matchPeople, normaliseCode, personQuery, SELF_WORDS, spellings, toLifecycleOptions, VERSION_RANK_FIELDS, } from './searchResolve.js';
|
|
14
|
+
import { childrenOf, descendants, folderStats, memberIds } from './planTree.js';
|
|
15
|
+
import { mapLimit } from './mapLimit.js';
|
|
16
|
+
import { resolveCollection, SQA_COLLECTIONS, } from './searchRegistry.js';
|
|
17
|
+
const DEFAULT_LIMIT = 50;
|
|
18
|
+
const HARD_LIMIT = 500;
|
|
19
|
+
/** op:"count" — rows read at most when a breakdown has to be built from rows (10 calls). */
|
|
20
|
+
const COUNT_SCAN_CAP = 5000;
|
|
21
|
+
/** op:"count" — a groupBy with more configured values than this is counted from rows instead. */
|
|
22
|
+
const MAX_VALUE_COUNTS = 30;
|
|
23
|
+
const COUNT_CONCURRENCY = 5;
|
|
24
|
+
/** Groups returned, largest first. */
|
|
25
|
+
const MAX_GROUPS = 50;
|
|
26
|
+
/** Folders read for a folder tree (test plans). */
|
|
27
|
+
const MAX_FOLDERS = 2000;
|
|
28
|
+
/** Users read for name lookups and display names (the directory is customer-wide). */
|
|
29
|
+
const MAX_USERS = 10000;
|
|
30
|
+
/** How long a reference lookup (users, releases, sprints) is reused before it is read again. */
|
|
31
|
+
const REF_TTL_MS = 5 * 60000;
|
|
32
|
+
/** Largest first, "(unset)" last, capped at MAX_GROUPS. */
|
|
33
|
+
function sortGroups(groups) {
|
|
34
|
+
return [...groups]
|
|
35
|
+
.sort((a, b) => (a.value === '') !== (b.value === '') ? (a.value === '' ? 1 : -1) : b.count - a.count)
|
|
36
|
+
.slice(0, MAX_GROUPS);
|
|
37
|
+
}
|
|
38
|
+
function fail(msg) {
|
|
39
|
+
throw new Error(msg);
|
|
40
|
+
}
|
|
41
|
+
function asArray(raw) {
|
|
42
|
+
const rows = pickArray(raw, 'data', 'content');
|
|
43
|
+
return rows.filter((r) => !!r && typeof r === 'object' && !Array.isArray(r));
|
|
44
|
+
}
|
|
45
|
+
function totalOf(raw, rows) {
|
|
46
|
+
const t = raw?.totalCount;
|
|
47
|
+
return typeof t === 'number' ? t : rows.length;
|
|
48
|
+
}
|
|
49
|
+
function unwrapOne(raw) {
|
|
50
|
+
const doc = raw?.data ?? raw;
|
|
51
|
+
if (Array.isArray(doc)) {
|
|
52
|
+
const first = doc[0];
|
|
53
|
+
return first && typeof first === 'object' ? first : {};
|
|
54
|
+
}
|
|
55
|
+
return doc && typeof doc === 'object' ? doc : {};
|
|
56
|
+
}
|
|
57
|
+
/** Every value a row holds for a field: scalars, arrays, and `obj.field` over an array of objects. */
|
|
58
|
+
function valuesAt(row, key) {
|
|
59
|
+
const keep = (v) => (typeof v === 'string' && v !== '') || typeof v === 'number';
|
|
60
|
+
const dot = key.indexOf('.');
|
|
61
|
+
if (dot >= 0) {
|
|
62
|
+
const head = row[key.slice(0, dot)];
|
|
63
|
+
const tail = key.slice(dot + 1);
|
|
64
|
+
const list = Array.isArray(head) ? head : head && typeof head === 'object' ? [head] : [];
|
|
65
|
+
return list.map((e) => (e && typeof e === 'object' ? e[tail] : undefined)).filter(keep);
|
|
66
|
+
}
|
|
67
|
+
const v = row[key];
|
|
68
|
+
return Array.isArray(v) ? v.filter(keep) : keep(v) ? [v] : [];
|
|
69
|
+
}
|
|
70
|
+
/** `array[].field` support for `through` relations. */
|
|
71
|
+
function pluckPath(row, path) {
|
|
72
|
+
const marker = '[].';
|
|
73
|
+
const idx = path.indexOf(marker);
|
|
74
|
+
if (idx >= 0) {
|
|
75
|
+
const head = path.slice(0, idx);
|
|
76
|
+
const tail = path.slice(idx + marker.length);
|
|
77
|
+
const value = row[head];
|
|
78
|
+
if (!Array.isArray(value))
|
|
79
|
+
return [];
|
|
80
|
+
return value
|
|
81
|
+
.map((entry) => (entry && typeof entry === 'object' ? entry[tail] : undefined))
|
|
82
|
+
.filter((v) => typeof v === 'string' || typeof v === 'number');
|
|
83
|
+
}
|
|
84
|
+
const value = row[path];
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return value.filter((v) => typeof v === 'string' || typeof v === 'number');
|
|
87
|
+
}
|
|
88
|
+
return value === undefined || value === null ? [] : [value];
|
|
89
|
+
}
|
|
90
|
+
function versionIdFromRow(row) {
|
|
91
|
+
const direct = str(row, 'versionId', 'selectedVersionId');
|
|
92
|
+
if (direct)
|
|
93
|
+
return direct;
|
|
94
|
+
const details = row['versionsDetails'];
|
|
95
|
+
if (details && typeof details === 'object') {
|
|
96
|
+
const v = str(details, 'id', '_id', 'versionId');
|
|
97
|
+
if (v)
|
|
98
|
+
return v;
|
|
99
|
+
}
|
|
100
|
+
const versions = row['versions'];
|
|
101
|
+
if (Array.isArray(versions) && versions.length)
|
|
102
|
+
return str(versions[0], 'id', '_id');
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
/** Value resolution is on by default; `QA_MCP_SQA_RESOLVE=off|0|false|no` turns it off (kill switch). */
|
|
106
|
+
export function resolveValuesFromEnv(env) {
|
|
107
|
+
return !/^(off|0|false|no)$/i.test((env['QA_MCP_SQA_RESOLVE'] ?? '').trim());
|
|
108
|
+
}
|
|
109
|
+
export class SqaSearchEngine {
|
|
110
|
+
constructor(http, projectId, options = {}) {
|
|
111
|
+
this.http = http;
|
|
112
|
+
this.projectId = projectId;
|
|
113
|
+
this.options = options;
|
|
114
|
+
this.customFieldCache = new Map();
|
|
115
|
+
this.fieldRowsCache = new Map();
|
|
116
|
+
/** Reference lookups (users, a project's releases/sprints), reused for REF_TTL_MS so a record
|
|
117
|
+
* created mid-session resolves soon after; failures are not kept. */
|
|
118
|
+
this.refCache = new Map();
|
|
119
|
+
}
|
|
120
|
+
get resolvesValues() {
|
|
121
|
+
return this.options.resolveValues === true;
|
|
122
|
+
}
|
|
123
|
+
// ------------------------------------------------------------------ catalog
|
|
124
|
+
catalog(collection) {
|
|
125
|
+
const names = collection
|
|
126
|
+
? [this.requireCollection(collection).name]
|
|
127
|
+
: Object.keys(SQA_COLLECTIONS);
|
|
128
|
+
return names.map((name) => {
|
|
129
|
+
const c = SQA_COLLECTIONS[name];
|
|
130
|
+
return {
|
|
131
|
+
name: c.name,
|
|
132
|
+
aliases: c.aliases,
|
|
133
|
+
description: c.description,
|
|
134
|
+
usedFor: c.usedFor,
|
|
135
|
+
...(c.parentId !== undefined ? { parentId: c.parentId } : {}),
|
|
136
|
+
searchPath: c.searchPath,
|
|
137
|
+
...(c.getPath ? { getPath: c.getPath } : {}),
|
|
138
|
+
scope: c.scope,
|
|
139
|
+
idField: c.idField,
|
|
140
|
+
...(c.keyField ? { keyField: c.keyField } : {}),
|
|
141
|
+
labelField: c.labelField,
|
|
142
|
+
defaultSelect: c.defaultSelect,
|
|
143
|
+
fields: Object.values(c.fields).map((f) => ({
|
|
144
|
+
key: f.key,
|
|
145
|
+
type: f.type,
|
|
146
|
+
ops: f.ops,
|
|
147
|
+
...(f.label ? { label: f.label } : {}),
|
|
148
|
+
})),
|
|
149
|
+
relations: Object.entries(c.relations ?? {}).map(([rel, r]) => ({
|
|
150
|
+
name: rel,
|
|
151
|
+
kind: r.kind,
|
|
152
|
+
...(r.to ? { to: r.to } : {}),
|
|
153
|
+
description: r.description,
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// ------------------------------------------------------------- custom fields
|
|
159
|
+
/** Every field row (default + custom) for a collection's parentId — one cached call that serves
|
|
160
|
+
* both custom-field resolution and lifecycle options. A failed read is not cached. */
|
|
161
|
+
fieldRows(c) {
|
|
162
|
+
const cacheKey = `${this.projectId}:${c.parentId}`;
|
|
163
|
+
const cached = this.fieldRowsCache.get(cacheKey);
|
|
164
|
+
if (cached)
|
|
165
|
+
return cached;
|
|
166
|
+
const promise = (async () => {
|
|
167
|
+
const raw = await this.http.getJson(`/pm/fields/${c.parentId}?fieldType=allFields&projectId=${this.projectId}`);
|
|
168
|
+
const rows = Array.isArray(raw)
|
|
169
|
+
? raw
|
|
170
|
+
: Array.isArray(raw.data)
|
|
171
|
+
? (raw.data)
|
|
172
|
+
: [];
|
|
173
|
+
return rows.filter((r) => !!r && typeof r === 'object');
|
|
174
|
+
})();
|
|
175
|
+
this.fieldRowsCache.set(cacheKey, promise);
|
|
176
|
+
promise.catch(() => this.fieldRowsCache.delete(cacheKey));
|
|
177
|
+
return promise;
|
|
178
|
+
}
|
|
179
|
+
/** The configured options of one default field (state / priority / severity). */
|
|
180
|
+
async lifecycleOptions(c, key) {
|
|
181
|
+
if (c.parentId === undefined)
|
|
182
|
+
return [];
|
|
183
|
+
const row = (await this.fieldRows(c)).find((r) => String(r['fieldType'] ?? '') === 'defaultFld' && r['key'] === key);
|
|
184
|
+
return toLifecycleOptions(row?.['values']);
|
|
185
|
+
}
|
|
186
|
+
async customFields(collection) {
|
|
187
|
+
const c = this.requireCollection(collection);
|
|
188
|
+
if (c.parentId === undefined)
|
|
189
|
+
return [];
|
|
190
|
+
const cached = this.customFieldCache.get(`${this.projectId}:${c.parentId}`);
|
|
191
|
+
if (cached)
|
|
192
|
+
return cached;
|
|
193
|
+
const promise = (async () => {
|
|
194
|
+
const rows = await this.fieldRows(c);
|
|
195
|
+
return rows
|
|
196
|
+
.filter((r) => String(r['fieldType'] ?? '') !== 'defaultFld')
|
|
197
|
+
.map((r) => {
|
|
198
|
+
const key = String(r['key'] ?? '');
|
|
199
|
+
const label = String(r['label'] ?? r['originalLabel'] ?? key);
|
|
200
|
+
const values = Array.isArray(r['values'])
|
|
201
|
+
? r['values'].map((v) => ({
|
|
202
|
+
...(v['key'] !== undefined ? { key: String(v['key']) } : {}),
|
|
203
|
+
...(v['value'] !== undefined ? { value: String(v['value']) } : {}),
|
|
204
|
+
}))
|
|
205
|
+
: undefined;
|
|
206
|
+
return {
|
|
207
|
+
key,
|
|
208
|
+
label,
|
|
209
|
+
...(r['originalLabel'] !== undefined ? { originalLabel: String(r['originalLabel']) } : {}),
|
|
210
|
+
column: `customFields.${key}`,
|
|
211
|
+
fieldType: String(r['fieldType'] ?? 'customFld'),
|
|
212
|
+
...(r['controlType'] !== undefined ? { controlType: String(r['controlType']) } : {}),
|
|
213
|
+
...(typeof r['mandatory'] === 'boolean' ? { mandatory: r['mandatory'] } : {}),
|
|
214
|
+
...(values && values.length ? { values } : {}),
|
|
215
|
+
};
|
|
216
|
+
})
|
|
217
|
+
.filter((f) => !!f.key);
|
|
218
|
+
})();
|
|
219
|
+
this.customFieldCache.set(`${this.projectId}:${c.parentId}`, promise);
|
|
220
|
+
try {
|
|
221
|
+
return await promise;
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
this.customFieldCache.delete(`${this.projectId}:${c.parentId}`);
|
|
225
|
+
throw e;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async resolveCustomField(collection, name) {
|
|
229
|
+
const all = await this.customFields(collection);
|
|
230
|
+
if (!all.length) {
|
|
231
|
+
fail(`Collection "${collection}" has no custom fields configured in this project.`);
|
|
232
|
+
}
|
|
233
|
+
const needle = name.trim().toLowerCase();
|
|
234
|
+
const matches = all.filter((f) => f.label.trim().toLowerCase() === needle
|
|
235
|
+
|| f.key.trim().toLowerCase() === needle
|
|
236
|
+
|| (f.originalLabel ?? '').trim().toLowerCase() === needle
|
|
237
|
+
|| f.column.toLowerCase() === needle);
|
|
238
|
+
if (!matches.length) {
|
|
239
|
+
fail(`Custom field "${name}" not found on ${collection}. Available: ` +
|
|
240
|
+
all.map((f) => f.label).join(', '));
|
|
241
|
+
}
|
|
242
|
+
if (matches.length > 1) {
|
|
243
|
+
fail(`Custom field "${name}" is ambiguous on ${collection}: ` +
|
|
244
|
+
matches.map((f) => `${f.label} (${f.key})`).join(', ') +
|
|
245
|
+
'. Use the exact key or a more specific label.');
|
|
246
|
+
}
|
|
247
|
+
return matches[0];
|
|
248
|
+
}
|
|
249
|
+
// -------------------------------------------------------------- search/related
|
|
250
|
+
async search(req) {
|
|
251
|
+
const c = this.requireCollection(req.collection);
|
|
252
|
+
const offset = Math.max(0, req.offset ?? 0);
|
|
253
|
+
const limit = Math.min(Math.max(1, req.limit ?? DEFAULT_LIMIT), c.maxLimit ?? HARD_LIMIT);
|
|
254
|
+
if (c.method === 'GET')
|
|
255
|
+
return this.searchGet(c, req, offset, limit);
|
|
256
|
+
const resolutions = [];
|
|
257
|
+
const payload = await this.compile(c, req, offset, limit, resolutions);
|
|
258
|
+
const resolved = resolutions.length ? { resolved: resolutions } : {};
|
|
259
|
+
const collapse = this.resolvesValues && c.versioned === true && !req.allVersions;
|
|
260
|
+
if (collapse && Array.isArray(payload['selectFields'])) {
|
|
261
|
+
// The rank fields decide which version row is the entity.
|
|
262
|
+
payload['selectFields'] = [...new Set([...payload['selectFields'], 'id', ...VERSION_RANK_FIELDS])];
|
|
263
|
+
}
|
|
264
|
+
if (req.dryRun) {
|
|
265
|
+
return { collection: c.name, rows: [], returned: 0, offset, payload, ...resolved };
|
|
266
|
+
}
|
|
267
|
+
const raw = await this.http.postJson(c.searchPath, payload);
|
|
268
|
+
const fetched = asArray(raw);
|
|
269
|
+
// Paging stays in stored rows (nextOffset is exact); a page may hold fewer entities than rows
|
|
270
|
+
// when versions of one entity sit on it.
|
|
271
|
+
const rows = collapse ? latestById(fetched) : fetched;
|
|
272
|
+
if (this.resolvesValues)
|
|
273
|
+
await this.decorate(c, rows);
|
|
274
|
+
const rawTotal = totalOf(raw, fetched);
|
|
275
|
+
const nextOffset = typeof rawTotal === 'number' && rawTotal > offset + fetched.length
|
|
276
|
+
? offset + fetched.length
|
|
277
|
+
: undefined;
|
|
278
|
+
// The backend counts stored rows. With versions collapsed that is the item count only when this
|
|
279
|
+
// page holds every row; otherwise it is flagged so it is never read as a number of items.
|
|
280
|
+
const wholeSet = offset === 0 && nextOffset === undefined;
|
|
281
|
+
const totalCount = collapse && wholeSet ? rows.length : rawTotal;
|
|
282
|
+
const totalIsRows = collapse && !wholeSet && rawTotal !== undefined;
|
|
283
|
+
return {
|
|
284
|
+
collection: c.name,
|
|
285
|
+
rows,
|
|
286
|
+
...(totalCount !== undefined ? { totalCount } : {}),
|
|
287
|
+
...(totalIsRows ? { totalCountIsRows: true } : {}),
|
|
288
|
+
returned: rows.length,
|
|
289
|
+
offset,
|
|
290
|
+
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
291
|
+
...(fetched.length > rows.length ? { collapsedVersions: fetched.length - rows.length } : {}),
|
|
292
|
+
...resolved,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
async related(req) {
|
|
296
|
+
const c = this.requireCollection(req.collection);
|
|
297
|
+
const relName = req.relation ?? fail('`relation` is required for op:"related".');
|
|
298
|
+
const rel = c.relations?.[relName];
|
|
299
|
+
if (!rel) {
|
|
300
|
+
fail(`Unknown relation "${relName}" on ${c.name}. Available: ` +
|
|
301
|
+
(c.relations ? Object.keys(c.relations).join(', ') : 'none'));
|
|
302
|
+
}
|
|
303
|
+
const id = req.id ?? fail('`id` is required for op:"related".');
|
|
304
|
+
// The parent is read first, through the soft-delete rule: a deleted record has no related rows
|
|
305
|
+
// (its steps, its test cases …) — `get` reports it as not found, as the AI assistant does.
|
|
306
|
+
const parent = await this.get(c.name, id);
|
|
307
|
+
if (rel.kind === 'subresource') {
|
|
308
|
+
const rows = await this.fetchSubresource(c, rel, id, req.versionId, parent);
|
|
309
|
+
return { collection: rel.to ?? c.name, rows, totalCount: rows.length, returned: rows.length, offset: 0 };
|
|
310
|
+
}
|
|
311
|
+
if (rel.kind === 'query') {
|
|
312
|
+
const target = rel.to ?? fail(`Relation ${relName} has no target.`);
|
|
313
|
+
const where = [
|
|
314
|
+
...Object.entries(rel.where ?? {}).map(([field, value]) => ({ field, op: 'eq', value })),
|
|
315
|
+
{
|
|
316
|
+
field: rel.remoteField ?? fail(`Relation ${relName} has no remoteField.`),
|
|
317
|
+
op: rel.arrayContains ? 'in' : 'eq',
|
|
318
|
+
value: rel.arrayContains ? [id] : id,
|
|
319
|
+
},
|
|
320
|
+
...(req.where ?? []),
|
|
321
|
+
];
|
|
322
|
+
const out = await this.search({
|
|
323
|
+
...req,
|
|
324
|
+
op: 'search',
|
|
325
|
+
collection: target,
|
|
326
|
+
where,
|
|
327
|
+
});
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
// through: extract the parent's local ids, query the target
|
|
331
|
+
let ids = pluckPath(parent, rel.localField);
|
|
332
|
+
if (rel.tree) {
|
|
333
|
+
// Folder trees: sub-folders come from both edge directions; 'subtree' also gathers the
|
|
334
|
+
// members filed in every folder below this one (a plan often holds nothing directly).
|
|
335
|
+
const folders = await this.allFolders(c);
|
|
336
|
+
const self = Number(parent['id'] ?? numericId(id));
|
|
337
|
+
const children = childrenOf(folders);
|
|
338
|
+
if (rel.tree === 'children') {
|
|
339
|
+
ids = children.get(self) ?? [];
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const scope = new Set([self, ...descendants(self, children)]);
|
|
343
|
+
const field = rel.localField.replace('[].id', '');
|
|
344
|
+
ids = [...new Set(folders.filter((f) => scope.has(Number(f['id']))).flatMap((f) => memberIds(f[field])))];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (!ids.length) {
|
|
348
|
+
return { collection: rel.to ?? c.name, rows: [], totalCount: 0, returned: 0, offset: 0 };
|
|
349
|
+
}
|
|
350
|
+
const targetName = rel.to ?? fail(`Relation ${relName} has no target.`);
|
|
351
|
+
const target = this.requireCollection(targetName);
|
|
352
|
+
const out = await this.search({
|
|
353
|
+
...req,
|
|
354
|
+
op: 'search',
|
|
355
|
+
collection: targetName,
|
|
356
|
+
where: [
|
|
357
|
+
{ field: target.idField, op: 'in', value: ids },
|
|
358
|
+
...(req.where ?? []),
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
// ---------------------------------------------------------------------- get
|
|
364
|
+
async get(collection, id) {
|
|
365
|
+
const c = this.requireCollection(collection);
|
|
366
|
+
const idType = c.fields[c.idField]?.type;
|
|
367
|
+
if (c.getPath) {
|
|
368
|
+
let lookup = id.trim();
|
|
369
|
+
if (idType === 'number' && !/^\d+$/.test(lookup)) {
|
|
370
|
+
// Business key (TC-240 / US_123): resolve to the numeric id first.
|
|
371
|
+
if (c.keyField) {
|
|
372
|
+
const found = await this.search({
|
|
373
|
+
op: 'search',
|
|
374
|
+
collection: c.name,
|
|
375
|
+
where: [{ field: c.keyField, op: 'eq', value: id }],
|
|
376
|
+
limit: 1,
|
|
377
|
+
});
|
|
378
|
+
const row = found.rows[0];
|
|
379
|
+
if (row)
|
|
380
|
+
lookup = String(row[c.idField] ?? row['id'] ?? numericId(id));
|
|
381
|
+
else
|
|
382
|
+
lookup = numericId(id);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
lookup = numericId(id);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const path = c.getPath.replace('{id}', encodeURIComponent(lookup)).replace('{projectId}', String(this.projectId));
|
|
389
|
+
const row = unwrapOne(await this.http.getJson(path));
|
|
390
|
+
// A direct GET by id does not apply the soft-delete filter the searches do.
|
|
391
|
+
if (c.softDelete && row[c.softDelete] === true)
|
|
392
|
+
fail(`${c.name} ${id} not found`);
|
|
393
|
+
return row;
|
|
394
|
+
}
|
|
395
|
+
// No direct GET: search by id first (string ids such as versions/testdata work), then by key.
|
|
396
|
+
const numeric = idType === 'number' ? Number.parseInt(numericId(id), 10) : undefined;
|
|
397
|
+
const byId = await this.search({
|
|
398
|
+
op: 'search',
|
|
399
|
+
collection: c.name,
|
|
400
|
+
where: [{
|
|
401
|
+
field: c.idField,
|
|
402
|
+
op: 'eq',
|
|
403
|
+
value: numeric !== undefined && Number.isFinite(numeric) ? numeric : id,
|
|
404
|
+
}],
|
|
405
|
+
limit: 1,
|
|
406
|
+
});
|
|
407
|
+
if (byId.rows[0])
|
|
408
|
+
return byId.rows[0];
|
|
409
|
+
if (c.keyField) {
|
|
410
|
+
const byKey = await this.search({
|
|
411
|
+
op: 'search',
|
|
412
|
+
collection: c.name,
|
|
413
|
+
where: [{ field: c.keyField, op: 'eq', value: id }],
|
|
414
|
+
limit: 1,
|
|
415
|
+
});
|
|
416
|
+
if (byKey.rows[0])
|
|
417
|
+
return byKey.rows[0];
|
|
418
|
+
}
|
|
419
|
+
return fail(`${c.name} ${id} not found`);
|
|
420
|
+
}
|
|
421
|
+
async getWithExpand(req) {
|
|
422
|
+
const c = this.requireCollection(req.collection);
|
|
423
|
+
const id = req.id ?? fail('`id` is required for op:"get".');
|
|
424
|
+
const row = await this.get(c.name, id);
|
|
425
|
+
const expanded = {};
|
|
426
|
+
for (const name of req.expand ?? []) {
|
|
427
|
+
const rel = c.relations?.[name];
|
|
428
|
+
if (!rel) {
|
|
429
|
+
fail(`Unknown expansion "${name}" on ${c.name}. Available: ${c.relations ? Object.keys(c.relations).join(', ') : 'none'}`);
|
|
430
|
+
}
|
|
431
|
+
if (rel.kind === 'subresource') {
|
|
432
|
+
expanded[name] = await this.fetchSubresource(c, rel, id, req.versionId ?? versionIdFromRow(row), row);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const out = await this.related({ ...req, op: 'related', relation: name, id });
|
|
436
|
+
expanded[name] = out.rows;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
op: 'get',
|
|
441
|
+
collection: c.name,
|
|
442
|
+
row,
|
|
443
|
+
...(Object.keys(expanded).length ? { expanded } : {}),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
// ----------------------------------------------------------------- compile
|
|
447
|
+
async compile(c, req, offset, limit, resolutions) {
|
|
448
|
+
const searchFields = [];
|
|
449
|
+
// Hard-coded soft-delete filter. The backend does NOT add it (searchUtil only adds customerId).
|
|
450
|
+
if (c.softDelete)
|
|
451
|
+
searchFields.push({ column: c.softDelete, value: false });
|
|
452
|
+
// Project scope: always the active project; a caller-supplied project filter is overridden.
|
|
453
|
+
if (c.scope === 'project')
|
|
454
|
+
searchFields.push({ column: 'projectId', value: this.projectId });
|
|
455
|
+
if (c.scope === 'projectIds')
|
|
456
|
+
searchFields.push({ column: 'projectIds', value: this.projectId });
|
|
457
|
+
// Rows the product hides unless asked (obsolete test cases): `$nor {field:true}` keeps
|
|
458
|
+
// false AND missing, as the UI does. Naming the field in a filter turns this off.
|
|
459
|
+
if (this.resolvesValues && !req.includeHidden) {
|
|
460
|
+
for (const hidden of c.hiddenUnlessAsked ?? []) {
|
|
461
|
+
if (!(req.where ?? []).some((w) => w.field === hidden)) {
|
|
462
|
+
searchFields.push({ column: hidden, value: true, operator: 'nor' });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const df = c.defaultFilter;
|
|
466
|
+
if (df && !(req.where ?? []).some((w) => df.unless.includes(w.field))) {
|
|
467
|
+
for (const [column, value] of Object.entries(df.where))
|
|
468
|
+
searchFields.push({ column, value });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const w of req.where ?? []) {
|
|
472
|
+
if (w.field === 'deleted' || w.field === 'projectId' || w.field === 'projectIds')
|
|
473
|
+
continue;
|
|
474
|
+
const resolved = await this.resolveWhere(c, w);
|
|
475
|
+
if (this.resolvesValues && resolved.type === 'date') {
|
|
476
|
+
searchFields.push(this.dateWire(resolved.field, resolved.op, w.value, w.condition, resolutions));
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
// Convenience alias: the UI/adapter historically said type:"TC"; the backend stores TESTCASE.
|
|
480
|
+
let value = coerceValue(resolved.type, w.value);
|
|
481
|
+
if (c.name === 'executions' && resolved.field === 'type' && value === 'TC')
|
|
482
|
+
value = 'TESTCASE';
|
|
483
|
+
let op = resolved.op;
|
|
484
|
+
const def = c.fields[w.field];
|
|
485
|
+
if (this.resolvesValues && def?.resolve && w.custom !== true) {
|
|
486
|
+
({ op, value } = await this.translate(c, def, op, value, resolutions));
|
|
487
|
+
}
|
|
488
|
+
searchFields.push(this.toWire(resolved.field, op, value, w.condition));
|
|
489
|
+
}
|
|
490
|
+
const sortFields = [];
|
|
491
|
+
for (const s of req.sort ?? []) {
|
|
492
|
+
const field = this.resolveSortField(c, s.field);
|
|
493
|
+
sortFields.push({ column: field, sort: s.dir === 'desc' ? 'dsc' : 'asc' });
|
|
494
|
+
}
|
|
495
|
+
const body = {
|
|
496
|
+
searchFields: [...searchFields, ...sortFields],
|
|
497
|
+
startIndex: offset,
|
|
498
|
+
limit,
|
|
499
|
+
// The backend treats startIndex as a PAGE unless this is set. Always use row offsets.
|
|
500
|
+
// Joi requires a NUMBER here (1), not a boolean.
|
|
501
|
+
skipStartIndex: 1,
|
|
502
|
+
};
|
|
503
|
+
if (req.select?.length) {
|
|
504
|
+
if (!(req.select.length === 1 && req.select[0] === '*')) {
|
|
505
|
+
body['selectFields'] = req.select;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else if (c.defaultSelect.length) {
|
|
509
|
+
body['selectFields'] = c.defaultSelect;
|
|
510
|
+
}
|
|
511
|
+
return body;
|
|
512
|
+
}
|
|
513
|
+
async resolveWhere(c, w) {
|
|
514
|
+
if (!w.field || !w.field.trim())
|
|
515
|
+
fail('Each filter needs a non-empty `field`.');
|
|
516
|
+
const isCustom = w.custom === true || w.field.startsWith('custom:') || w.field.startsWith('customFields.');
|
|
517
|
+
let field;
|
|
518
|
+
let allowed;
|
|
519
|
+
let type;
|
|
520
|
+
if (isCustom) {
|
|
521
|
+
if (w.field.startsWith('customFields.')) {
|
|
522
|
+
field = w.field;
|
|
523
|
+
allowed = ['eq', 'ne', 'in', 'not_in', 'contains', 'gt', 'gte', 'lt', 'lte', 'exists'];
|
|
524
|
+
type = 'string';
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
const name = w.field.startsWith('custom:') ? w.field.slice('custom:'.length) : w.field;
|
|
528
|
+
const cf = await this.resolveCustomField(c.name, name);
|
|
529
|
+
field = cf.column;
|
|
530
|
+
allowed = allowedOpsForControl(cf.controlType);
|
|
531
|
+
type = controlTypeToFieldType(cf.controlType);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
const def = c.fields[w.field];
|
|
536
|
+
if (!def) {
|
|
537
|
+
fail(`Unknown field "${w.field}" on ${c.name}. ` +
|
|
538
|
+
`Fields: ${Object.keys(c.fields).join(', ')}. ` +
|
|
539
|
+
'For a custom field pass `custom: true` (or `field: "custom:<label>"`).');
|
|
540
|
+
}
|
|
541
|
+
field = def.key;
|
|
542
|
+
allowed = def.ops;
|
|
543
|
+
type = def.type;
|
|
544
|
+
}
|
|
545
|
+
const op = w.op ?? (Array.isArray(w.value) ? 'in' : 'eq');
|
|
546
|
+
if (!allowed.includes(op)) {
|
|
547
|
+
fail(`Operator "${op}" is not supported for ${c.name}.${w.field}. Supported: ${allowed.join(', ')}.`);
|
|
548
|
+
}
|
|
549
|
+
return { field, op, type };
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Translate filter words to stored values for a field with `resolve` set. Only eq/ne/in/not_in
|
|
553
|
+
* are translated (`contains` stays a literal match). Values already stored exactly as given are
|
|
554
|
+
* returned untouched — so an existing query's payload never changes — and when the options
|
|
555
|
+
* cannot be read the filter is sent as given (today's behaviour), never dropped. A word that
|
|
556
|
+
* matches nothing is an error naming the valid values, never a silent empty result.
|
|
557
|
+
*/
|
|
558
|
+
async translate(c, def, op, value, resolutions) {
|
|
559
|
+
if (value === undefined || !['eq', 'ne', 'in', 'not_in'].includes(op))
|
|
560
|
+
return { op, value };
|
|
561
|
+
const words = (Array.isArray(value) ? value : [value]).map((v) => String(v));
|
|
562
|
+
const out = [];
|
|
563
|
+
const unknown = [];
|
|
564
|
+
if (def.resolve === 'ref') {
|
|
565
|
+
// Ids stay ids: only words that are not numbers are looked up.
|
|
566
|
+
const isId = (w) => /^\d+$/.test(w.trim());
|
|
567
|
+
if (words.every(isId))
|
|
568
|
+
return { op, value };
|
|
569
|
+
for (const w of words) {
|
|
570
|
+
if (isId(w)) {
|
|
571
|
+
out.push(Number(w));
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const ids = await this.resolveRef(def.refTo, w);
|
|
575
|
+
if (ids.length)
|
|
576
|
+
out.push(...ids);
|
|
577
|
+
else
|
|
578
|
+
unknown.push(w);
|
|
579
|
+
}
|
|
580
|
+
if (unknown.length) {
|
|
581
|
+
fail(`${c.name}.${def.key}: ${unknown.map((u) => this.noMatchText(def.refTo, u)).join(' ')}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
else if (def.resolve === 'enum') {
|
|
585
|
+
const values = def.enumValues ?? [];
|
|
586
|
+
if (words.every((w) => values.includes(w)))
|
|
587
|
+
return { op, value };
|
|
588
|
+
for (const w of words) {
|
|
589
|
+
const hit = matchEnumWord(w, values, def.enumGroups);
|
|
590
|
+
if (hit.length)
|
|
591
|
+
out.push(...spellings(hit));
|
|
592
|
+
else
|
|
593
|
+
unknown.push(w);
|
|
594
|
+
}
|
|
595
|
+
if (unknown.length) {
|
|
596
|
+
const groups = Object.entries(def.enumGroups ?? {}).map(([g, vs]) => `${g} (= ${vs.join(' + ')})`);
|
|
597
|
+
fail(`${c.name}.${def.key} has no value ${unknown.map((u) => `"${u}"`).join(', ')}. ` +
|
|
598
|
+
`Valid: ${[...values, ...groups].join(', ')}.`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
let options;
|
|
603
|
+
try {
|
|
604
|
+
options = await this.lifecycleOptions(c, def.key);
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return { op, value };
|
|
608
|
+
}
|
|
609
|
+
if (!options.length || words.every((w) => options.some((o) => o.key === w)))
|
|
610
|
+
return { op, value };
|
|
611
|
+
for (const w of words) {
|
|
612
|
+
const hit = options.some((o) => o.key === w) ? [w] : matchLifecycleWord(w, options, def.key);
|
|
613
|
+
if (hit.length)
|
|
614
|
+
out.push(...hit);
|
|
615
|
+
else
|
|
616
|
+
unknown.push(w);
|
|
617
|
+
}
|
|
618
|
+
if (unknown.length) {
|
|
619
|
+
fail(`${c.name}.${def.key} has no value ${unknown.map((u) => `"${u}"`).join(', ')} in this project. ` +
|
|
620
|
+
`Valid: ${options.map((o) => (o.label === o.key ? o.key : `${o.label} (${o.key})`)).join(', ')}.`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const to = [...new Set(out)];
|
|
624
|
+
resolutions.push({ field: def.key, from: words, to: to.map(String) });
|
|
625
|
+
// Keep the caller's shape: a single eq/ne stays scalar; several values become in/not_in.
|
|
626
|
+
if (op === 'eq' || op === 'ne') {
|
|
627
|
+
if (to.length === 1)
|
|
628
|
+
return { op, value: to[0] };
|
|
629
|
+
return { op: op === 'eq' ? 'in' : 'not_in', value: to };
|
|
630
|
+
}
|
|
631
|
+
return { op, value: to };
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* A date filter as SQA's search reads one: `YYYY-MM-DD` + `offset` (minutes, IST) so the backend
|
|
635
|
+
* builds day boundaries — without `offset` the value is compared as a plain string and range
|
|
636
|
+
* operators are ignored. A window of several days uses the backend's `range` operator.
|
|
637
|
+
*/
|
|
638
|
+
dateWire(column, op, value, condition, resolutions) {
|
|
639
|
+
if (op === 'exists')
|
|
640
|
+
return this.toWire(column, op, value, condition);
|
|
641
|
+
if (typeof value !== 'string')
|
|
642
|
+
fail(`${column}: give one date or window as text (e.g. "2026-10-01", "2026-09", "last 7 days").`);
|
|
643
|
+
const w = dateWindow(value);
|
|
644
|
+
if (!w) {
|
|
645
|
+
fail(`Cannot read "${value}" as a date for ${column}. Use YYYY-MM-DD, YYYY-MM, "A..B", today, ` +
|
|
646
|
+
'yesterday, this week / month / year, or last N days / weeks / months / years.');
|
|
647
|
+
}
|
|
648
|
+
const oneDay = w.from === w.to;
|
|
649
|
+
resolutions.push({ field: column, from: [value], to: [oneDay ? w.from : `${w.from}..${w.to}`] });
|
|
650
|
+
const out = { column, offset: IST_OFFSET_MINUTES };
|
|
651
|
+
switch (op) {
|
|
652
|
+
case 'eq':
|
|
653
|
+
if (oneDay)
|
|
654
|
+
out['value'] = w.from;
|
|
655
|
+
else {
|
|
656
|
+
out['value'] = { fromDate: w.from, toDate: w.to };
|
|
657
|
+
out['operator'] = 'range';
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
case 'ne':
|
|
661
|
+
if (!oneDay)
|
|
662
|
+
fail(`${column}: "not on" works for one day only; use lt / gt for a window.`);
|
|
663
|
+
out['value'] = w.from;
|
|
664
|
+
out['operator'] = 'nor';
|
|
665
|
+
break;
|
|
666
|
+
case 'gt':
|
|
667
|
+
out['value'] = w.to;
|
|
668
|
+
out['operator'] = 'gt';
|
|
669
|
+
break; // after the window
|
|
670
|
+
case 'gte':
|
|
671
|
+
out['value'] = w.from;
|
|
672
|
+
out['operator'] = 'gte';
|
|
673
|
+
break; // from its first day
|
|
674
|
+
case 'lt':
|
|
675
|
+
out['value'] = w.from;
|
|
676
|
+
out['operator'] = 'lt';
|
|
677
|
+
break; // before the window
|
|
678
|
+
case 'lte':
|
|
679
|
+
out['value'] = w.to;
|
|
680
|
+
out['operator'] = 'lte';
|
|
681
|
+
break; // up to its last day
|
|
682
|
+
default: fail(`Operator "${op}" is not supported on the date field ${column}.`);
|
|
683
|
+
}
|
|
684
|
+
if (condition === 'or')
|
|
685
|
+
out['condition'] = 'or';
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
// ------------------------------------------------------------ reference lookups
|
|
689
|
+
cachedRef(key, load) {
|
|
690
|
+
const hit = this.refCache.get(key);
|
|
691
|
+
if (hit && Date.now() - hit.at < REF_TTL_MS)
|
|
692
|
+
return hit.value;
|
|
693
|
+
const entry = { at: Date.now(), value: load() };
|
|
694
|
+
this.refCache.set(key, entry);
|
|
695
|
+
entry.value.catch(() => {
|
|
696
|
+
if (this.refCache.get(key) === entry)
|
|
697
|
+
this.refCache.delete(key);
|
|
698
|
+
});
|
|
699
|
+
return entry.value;
|
|
700
|
+
}
|
|
701
|
+
/** Rows of a registry collection, read with stored values only (never re-translated). */
|
|
702
|
+
async rowsOf(collection, where, select, limit = HARD_LIMIT, offset = 0, includeHidden = false) {
|
|
703
|
+
const c = this.requireCollection(collection);
|
|
704
|
+
const req = { op: 'search', collection, where, select, limit, ...(includeHidden ? { includeHidden } : {}) };
|
|
705
|
+
const payload = await this.compile(c, req, offset, Math.min(limit, c.maxLimit ?? HARD_LIMIT), []);
|
|
706
|
+
return asArray(await this.http.postJson(c.searchPath, payload));
|
|
707
|
+
}
|
|
708
|
+
/** Every matching row of a registry collection, page by page (the collection's own page size),
|
|
709
|
+
* up to `cap` rows. */
|
|
710
|
+
async allRowsOf(collection, select, cap, includeHidden = false) {
|
|
711
|
+
const pageSize = Math.min(HARD_LIMIT, this.requireCollection(collection).maxLimit ?? HARD_LIMIT);
|
|
712
|
+
const out = [];
|
|
713
|
+
for (let offset = 0; offset < cap; offset += pageSize) {
|
|
714
|
+
const page = await this.rowsOf(collection, [], select, pageSize, offset, includeHidden);
|
|
715
|
+
out.push(...page);
|
|
716
|
+
if (page.length < pageSize)
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
/** Every folder of a folder-tree collection in this project (no defaults: sub-folders too),
|
|
722
|
+
* read once per request — the busiest project measured has 26. */
|
|
723
|
+
allFolders(c) {
|
|
724
|
+
return this.allRowsOf(c.name, ['id', 'code', 'name', 'isRoot', 'parentId', 'childIds', 'suiteIds', 'testcaseIds'], MAX_FOLDERS, true);
|
|
725
|
+
}
|
|
726
|
+
/** The customer's user directory (users carry no project) — every page, not just the first. */
|
|
727
|
+
people() {
|
|
728
|
+
return this.cachedRef('people', async () => (await this.allRowsOf('users', ['id', 'name', 'username', 'email'], MAX_USERS))
|
|
729
|
+
.filter((r) => typeof r['id'] === 'number')
|
|
730
|
+
.map((r) => ({
|
|
731
|
+
id: r['id'],
|
|
732
|
+
...(typeof r['name'] === 'string' ? { name: r['name'] } : {}),
|
|
733
|
+
...(typeof r['username'] === 'string' ? { username: r['username'] } : {}),
|
|
734
|
+
...(typeof r['email'] === 'string' ? { email: r['email'] } : {}),
|
|
735
|
+
})));
|
|
736
|
+
}
|
|
737
|
+
named(rows, labelField = 'name') {
|
|
738
|
+
return rows
|
|
739
|
+
.filter((r) => typeof r['id'] === 'number')
|
|
740
|
+
.map((r) => ({
|
|
741
|
+
id: r['id'],
|
|
742
|
+
...(typeof r[labelField] === 'string' ? { name: r[labelField] } : {}),
|
|
743
|
+
...(typeof r['code'] === 'string' ? { code: r['code'] } : {}),
|
|
744
|
+
}));
|
|
745
|
+
}
|
|
746
|
+
/** This project's releases, and the sprints under them (sprints carry no project field). */
|
|
747
|
+
projectReleases() {
|
|
748
|
+
return this.cachedRef('releases', async () => this.named(await this.rowsOf('releases', [], ['id', 'code', 'name'])));
|
|
749
|
+
}
|
|
750
|
+
projectSprints() {
|
|
751
|
+
return this.cachedRef('sprints', async () => {
|
|
752
|
+
const releaseIds = (await this.projectReleases()).map((r) => r.id);
|
|
753
|
+
if (!releaseIds.length)
|
|
754
|
+
return [];
|
|
755
|
+
return this.named(await this.rowsOf('sprints', [{ field: 'releaseId', op: 'in', value: releaseIds }], ['id', 'code', 'name']));
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/** Label rows: one per tagging, so one label name is many ids — all of them count. */
|
|
759
|
+
async tagRows(where) {
|
|
760
|
+
return asArray(await this.http.postJson('/pm/tags/search', {
|
|
761
|
+
searchFields: [{ column: 'deleted', value: false }, { column: 'projectId', value: this.projectId }, ...where],
|
|
762
|
+
startIndex: 0, limit: HARD_LIMIT, skipStartIndex: 1,
|
|
763
|
+
}));
|
|
764
|
+
}
|
|
765
|
+
/** A word naming a `refTo` record → every id it means. [] = nothing matches. */
|
|
766
|
+
async resolveRef(refTo, word) {
|
|
767
|
+
const text = word.trim();
|
|
768
|
+
switch (refTo) {
|
|
769
|
+
case 'users': {
|
|
770
|
+
const q = personQuery(text);
|
|
771
|
+
if (SELF_WORDS.has(q)) {
|
|
772
|
+
if (this.options.currentUserId === undefined) {
|
|
773
|
+
fail('Cannot tell who "me" is on this connection — set QA_MCP_USER_ID or use a session token.');
|
|
774
|
+
}
|
|
775
|
+
return [this.options.currentUserId];
|
|
776
|
+
}
|
|
777
|
+
return q ? matchPeople(q, await this.people()) : [];
|
|
778
|
+
}
|
|
779
|
+
case 'tags': {
|
|
780
|
+
const rows = await this.tagRows([{ column: 'name', value: text, regEx: true }]);
|
|
781
|
+
return [...new Set(rows
|
|
782
|
+
.filter((r) => typeof r['id'] === 'number' && String(r['name'] ?? '').trim().toLowerCase() === text.toLowerCase())
|
|
783
|
+
.map((r) => r['id']))];
|
|
784
|
+
}
|
|
785
|
+
case 'releases':
|
|
786
|
+
return matchNamed(text, await this.projectReleases(), true);
|
|
787
|
+
case 'sprints': {
|
|
788
|
+
// A sprint NAME matches across the customer (the boards match by name, and four sprints are
|
|
789
|
+
// called "Sprint 1"); a code or a near-spelling only within this project's releases.
|
|
790
|
+
const byName = this.named(await this.rowsOf('sprints', [{ field: 'name', op: 'contains', value: text }], ['id', 'code', 'name']));
|
|
791
|
+
const exact = matchNamed(text, byName.map((r) => ({ id: r.id, ...(r.name ? { name: r.name } : {}) })));
|
|
792
|
+
return exact.length ? exact : matchNamed(text, await this.projectSprints(), true);
|
|
793
|
+
}
|
|
794
|
+
default: {
|
|
795
|
+
// modules and linked work items / test cases: code first, else exact name ("Web 2" reads
|
|
796
|
+
// like a code but may be a module's name).
|
|
797
|
+
const target = this.requireCollection(refTo);
|
|
798
|
+
const code = normaliseCode(text);
|
|
799
|
+
if (code && target.keyField) {
|
|
800
|
+
const byCode = await this.rowsOf(refTo, [{ field: target.keyField, op: 'eq', value: code }], ['id', target.keyField, target.labelField]);
|
|
801
|
+
const ids = matchNamed(code, this.named(byCode, target.labelField));
|
|
802
|
+
if (ids.length)
|
|
803
|
+
return ids;
|
|
804
|
+
}
|
|
805
|
+
const byName = await this.rowsOf(refTo, [{ field: target.labelField, op: 'contains', value: text }], ['id', target.labelField]);
|
|
806
|
+
return matchNamed(text, this.named(byName, target.labelField).map((r) => ({ id: r.id, ...(r.name ? { name: r.name } : {}) })));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// ------------------------------------------------------------------ display values
|
|
811
|
+
/**
|
|
812
|
+
* Add `_display: { field: "readable value" }` to each row — state/priority/severity labels, user
|
|
813
|
+
* names, module/release/sprint/label names, linked item codes. Raw fields are never changed.
|
|
814
|
+
* Best-effort: a lookup that fails leaves that field without a display value.
|
|
815
|
+
*/
|
|
816
|
+
async decorate(c, rows) {
|
|
817
|
+
if (!rows.length)
|
|
818
|
+
return;
|
|
819
|
+
if (c.folderTree) {
|
|
820
|
+
// Direct and with-sub-folder counts + path: a parent plan that files everything in
|
|
821
|
+
// sub-folders otherwise reads as empty.
|
|
822
|
+
try {
|
|
823
|
+
const stats = folderStats(await this.allFolders(c));
|
|
824
|
+
for (const row of rows) {
|
|
825
|
+
const s = stats.get(Number(row['id']));
|
|
826
|
+
if (s)
|
|
827
|
+
row['_folder'] = s;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch { /* counts are best-effort */ }
|
|
831
|
+
}
|
|
832
|
+
const defs = Object.values(c.fields).filter((f) => f.resolve === 'lifecycle' || f.resolve === 'ref');
|
|
833
|
+
const present = defs.filter((f) => rows.some((r) => valuesAt(r, f.key).length));
|
|
834
|
+
const labels = await this.labelMaps(c, present, rows);
|
|
835
|
+
for (const row of rows) {
|
|
836
|
+
const display = {};
|
|
837
|
+
for (const f of present) {
|
|
838
|
+
const map = labels.get(f.key);
|
|
839
|
+
const vals = valuesAt(row, f.key);
|
|
840
|
+
if (!map || !vals.length)
|
|
841
|
+
continue;
|
|
842
|
+
display[f.key] = vals.map((v) => map.get(String(v)) ?? (f.resolve === 'ref' ? `#${v}` : String(v))).join(', ');
|
|
843
|
+
}
|
|
844
|
+
if (Object.keys(display).length)
|
|
845
|
+
row['_display'] = display;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/** field key → stored value → readable label, for the lifecycle/ref fields given. One lookup per
|
|
849
|
+
* reference kind; a failed lookup just leaves that field without labels. */
|
|
850
|
+
async labelMaps(c, present, rows) {
|
|
851
|
+
const labels = new Map(); // field or refTo → raw → label
|
|
852
|
+
const idsByRef = new Map();
|
|
853
|
+
for (const f of present.filter((p) => p.resolve === 'ref')) {
|
|
854
|
+
const set = idsByRef.get(f.refTo) ?? new Set();
|
|
855
|
+
for (const r of rows)
|
|
856
|
+
for (const v of valuesAt(r, f.key))
|
|
857
|
+
if (/^\d+$/.test(String(v)))
|
|
858
|
+
set.add(Number(v));
|
|
859
|
+
idsByRef.set(f.refTo, set);
|
|
860
|
+
}
|
|
861
|
+
await Promise.all([
|
|
862
|
+
...present.filter((f) => f.resolve === 'lifecycle').map(async (f) => {
|
|
863
|
+
try {
|
|
864
|
+
labels.set(f.key, new Map((await this.lifecycleOptions(c, f.key)).map((o) => [o.key, o.label])));
|
|
865
|
+
}
|
|
866
|
+
catch { /* no labels for this field */ }
|
|
867
|
+
}),
|
|
868
|
+
...[...idsByRef].map(async ([refTo, set]) => {
|
|
869
|
+
const ids = [...set].slice(0, HARD_LIMIT);
|
|
870
|
+
if (!ids.length)
|
|
871
|
+
return;
|
|
872
|
+
try {
|
|
873
|
+
let pairs;
|
|
874
|
+
if (refTo === 'users') {
|
|
875
|
+
pairs = (await this.people()).map((p) => [p.id, p.name ?? p.username ?? p.email ?? `#${p.id}`]);
|
|
876
|
+
}
|
|
877
|
+
else if (refTo === 'tags') {
|
|
878
|
+
pairs = (await this.tagRows([{ column: 'id', value: ids }]))
|
|
879
|
+
.map((r) => [Number(r['id']), String(r['name'] ?? '').trim()]);
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
const t = this.requireCollection(refTo);
|
|
883
|
+
// Linked work items / test cases read best by code; modules, releases, sprints by name.
|
|
884
|
+
const linked = !['modules', 'releases', 'sprints'].includes(refTo);
|
|
885
|
+
pairs = (await this.rowsOf(refTo, [{ field: t.idField, op: 'in', value: ids }], ['id', 'code', t.labelField], ids.length))
|
|
886
|
+
.map((r) => [Number(r['id']), String((linked ? r['code'] : undefined) ?? r[t.labelField] ?? r['code'] ?? '')]);
|
|
887
|
+
}
|
|
888
|
+
labels.set(refTo, new Map(pairs.filter(([, l]) => l).map(([id, l]) => [String(id), l])));
|
|
889
|
+
}
|
|
890
|
+
catch { /* no names for this reference */ }
|
|
891
|
+
}),
|
|
892
|
+
]);
|
|
893
|
+
const byField = new Map();
|
|
894
|
+
for (const f of present) {
|
|
895
|
+
const map = labels.get(f.resolve === 'ref' ? f.refTo : f.key);
|
|
896
|
+
if (map)
|
|
897
|
+
byField.set(f.key, map);
|
|
898
|
+
}
|
|
899
|
+
return byField;
|
|
900
|
+
}
|
|
901
|
+
// ------------------------------------------------------------------------- count
|
|
902
|
+
/** How many rows a request matches — one call, `limit:1`, no display lookups. */
|
|
903
|
+
async countWhere(c, req) {
|
|
904
|
+
const resolved = [];
|
|
905
|
+
const payload = await this.compile(c, { ...req, select: [c.idField] }, 0, 1, resolved);
|
|
906
|
+
const raw = await this.http.postJson(c.searchPath, payload);
|
|
907
|
+
return { total: totalOf(raw, asArray(raw)) ?? 0, resolved };
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* op:"count": the number of rows a search matches, optionally per value of one field.
|
|
911
|
+
* - plain count → the backend's totalCount (one call);
|
|
912
|
+
* - groupBy a state/priority/severity/enum field → one count call per configured value, plus
|
|
913
|
+
* "(unset)" for the remainder;
|
|
914
|
+
* - any other groupBy, and versioned collections (one entity, several rows) → read the matching
|
|
915
|
+
* rows (up to COUNT_SCAN_CAP) and count them here; past the cap the result is `approximate`.
|
|
916
|
+
*/
|
|
917
|
+
async count(req) {
|
|
918
|
+
const c = this.requireCollection(req.collection);
|
|
919
|
+
const versioned = this.resolvesValues && c.versioned === true && !req.allVersions;
|
|
920
|
+
let def;
|
|
921
|
+
if (req.groupBy) {
|
|
922
|
+
def = c.fields[req.groupBy];
|
|
923
|
+
if (!def)
|
|
924
|
+
fail(`Cannot group ${c.name} by "${req.groupBy}". Fields: ${Object.keys(c.fields).join(', ')}.`);
|
|
925
|
+
if (def.type === 'object' || def.type === 'date')
|
|
926
|
+
fail(`Cannot group by ${c.name}.${def.key} (${def.type}).`);
|
|
927
|
+
}
|
|
928
|
+
const base = { ...req, op: 'search', offset: 0 };
|
|
929
|
+
if (c.method === 'GET') {
|
|
930
|
+
if (def)
|
|
931
|
+
fail(`Grouping is not supported on ${c.name}.`);
|
|
932
|
+
const r = await this.search({ ...base, limit: HARD_LIMIT });
|
|
933
|
+
return { collection: c.name, total: r.totalCount ?? r.returned };
|
|
934
|
+
}
|
|
935
|
+
if (!def && !versioned) {
|
|
936
|
+
const r = await this.countWhere(c, base);
|
|
937
|
+
return { collection: c.name, total: r.total, ...(r.resolved.length ? { resolved: r.resolved } : {}) };
|
|
938
|
+
}
|
|
939
|
+
const perValue = def && !versioned && (def.resolve === 'lifecycle' || def.resolve === 'enum');
|
|
940
|
+
if (def && perValue) {
|
|
941
|
+
const values = def.resolve === 'enum'
|
|
942
|
+
? (def.enumValues ?? []).map((v) => ({ value: v, label: v }))
|
|
943
|
+
: (await this.lifecycleOptions(c, def.key).catch(() => [])).map((o) => ({ value: o.key, label: o.label }));
|
|
944
|
+
if (values.length && values.length <= MAX_VALUE_COUNTS) {
|
|
945
|
+
const first = await this.countWhere(c, base);
|
|
946
|
+
const counts = await mapLimit(values, COUNT_CONCURRENCY, async (v) => {
|
|
947
|
+
const r = await this.countWhere(c, {
|
|
948
|
+
...base,
|
|
949
|
+
where: [...(req.where ?? []), { field: def.key, op: 'in', value: def.resolve === 'enum' ? spellings([v.value]) : [v.value] }],
|
|
950
|
+
});
|
|
951
|
+
return { ...v, count: r.total };
|
|
952
|
+
});
|
|
953
|
+
const groups = counts.filter((g) => g.count > 0);
|
|
954
|
+
const unset = first.total - groups.reduce((n, g) => n + g.count, 0);
|
|
955
|
+
if (unset > 0)
|
|
956
|
+
groups.push({ value: '', label: '(unset)', count: unset });
|
|
957
|
+
return {
|
|
958
|
+
collection: c.name, total: first.total, groupBy: def.key, groups: sortGroups(groups),
|
|
959
|
+
...(first.resolved.length ? { resolved: first.resolved } : {}),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// Read the matching rows and count them here.
|
|
964
|
+
const select = [c.idField, ...(def ? [def.key.split('.')[0]] : []), ...(versioned ? VERSION_RANK_FIELDS : [])];
|
|
965
|
+
const fetched = [];
|
|
966
|
+
let rawTotal = 0;
|
|
967
|
+
const resolved = [];
|
|
968
|
+
for (let offset = 0; offset < COUNT_SCAN_CAP; offset += HARD_LIMIT) {
|
|
969
|
+
const payload = await this.compile(c, { ...base, select }, offset, HARD_LIMIT, offset === 0 ? resolved : []);
|
|
970
|
+
const raw = await this.http.postJson(c.searchPath, payload);
|
|
971
|
+
const page = asArray(raw);
|
|
972
|
+
fetched.push(...page);
|
|
973
|
+
rawTotal = totalOf(raw, page) ?? fetched.length;
|
|
974
|
+
if (page.length < HARD_LIMIT || fetched.length >= rawTotal)
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
const complete = fetched.length >= rawTotal;
|
|
978
|
+
const rows = versioned ? latestById(fetched) : fetched;
|
|
979
|
+
const total = complete ? rows.length : rawTotal;
|
|
980
|
+
const out = {
|
|
981
|
+
collection: c.name, total, scanned: fetched.length,
|
|
982
|
+
...(complete ? {} : { approximate: true }),
|
|
983
|
+
...(resolved.length ? { resolved } : {}),
|
|
984
|
+
};
|
|
985
|
+
if (!def)
|
|
986
|
+
return out;
|
|
987
|
+
const counts = new Map();
|
|
988
|
+
for (const row of rows) {
|
|
989
|
+
const vals = valuesAt(row, def.key);
|
|
990
|
+
for (const v of vals.length ? vals : [''])
|
|
991
|
+
counts.set(String(v), (counts.get(String(v)) ?? 0) + 1);
|
|
992
|
+
}
|
|
993
|
+
const labels = (await this.labelMaps(c, def.resolve ? [def] : [], rows)).get(def.key);
|
|
994
|
+
const groups = [...counts].map(([value, count]) => ({
|
|
995
|
+
value,
|
|
996
|
+
label: value === '' ? '(unset)' : labels?.get(value) ?? (def.resolve === 'ref' ? `#${value}` : value),
|
|
997
|
+
count,
|
|
998
|
+
}));
|
|
999
|
+
return { ...out, groupBy: def.key, groups: sortGroups(groups) };
|
|
1000
|
+
}
|
|
1001
|
+
noMatchText(refTo, word) {
|
|
1002
|
+
const noun = {
|
|
1003
|
+
users: 'user', tags: 'label', releases: 'release', sprints: 'sprint', modules: 'module',
|
|
1004
|
+
stories: 'user story', epics: 'epic', features: 'feature', defects: 'defect', testcases: 'test case',
|
|
1005
|
+
};
|
|
1006
|
+
const what = noun[refTo] ?? refTo;
|
|
1007
|
+
const hint = refTo === 'users' ? 'Use their name, username or email.' : 'Use its exact name, its code, or its id.';
|
|
1008
|
+
return `No ${what} matches "${word}" in this project. ${hint}`;
|
|
1009
|
+
}
|
|
1010
|
+
resolveSortField(c, field) {
|
|
1011
|
+
if (field.startsWith('customFields.'))
|
|
1012
|
+
return field;
|
|
1013
|
+
const def = c.fields[field];
|
|
1014
|
+
if (!def) {
|
|
1015
|
+
fail(`Unknown sort field "${field}" on ${c.name}. Fields: ${Object.keys(c.fields).join(', ')}.`);
|
|
1016
|
+
}
|
|
1017
|
+
if (def.sort === false)
|
|
1018
|
+
fail(`Field "${field}" on ${c.name} is not sortable.`);
|
|
1019
|
+
if (def.type === 'array' || def.type === 'object') {
|
|
1020
|
+
fail(`Field "${field}" on ${c.name} is not sortable (${def.type}).`);
|
|
1021
|
+
}
|
|
1022
|
+
return def.key;
|
|
1023
|
+
}
|
|
1024
|
+
toWire(column, op, value, condition) {
|
|
1025
|
+
const out = { column };
|
|
1026
|
+
switch (op) {
|
|
1027
|
+
case 'eq':
|
|
1028
|
+
out['value'] = value;
|
|
1029
|
+
break;
|
|
1030
|
+
case 'contains':
|
|
1031
|
+
out['value'] = value;
|
|
1032
|
+
out['regEx'] = true;
|
|
1033
|
+
break;
|
|
1034
|
+
case 'in':
|
|
1035
|
+
out['value'] = Array.isArray(value) ? value : [value];
|
|
1036
|
+
break;
|
|
1037
|
+
case 'not_in':
|
|
1038
|
+
out['value'] = Array.isArray(value) ? value : [value];
|
|
1039
|
+
out['operator'] = 'nor';
|
|
1040
|
+
break;
|
|
1041
|
+
case 'ne':
|
|
1042
|
+
out['value'] = value;
|
|
1043
|
+
out['operator'] = 'nor';
|
|
1044
|
+
break;
|
|
1045
|
+
case 'gt':
|
|
1046
|
+
case 'gte':
|
|
1047
|
+
case 'lt':
|
|
1048
|
+
case 'lte':
|
|
1049
|
+
case 'exists':
|
|
1050
|
+
out['value'] = value;
|
|
1051
|
+
out['operator'] = op;
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
if (condition === 'or')
|
|
1055
|
+
out['condition'] = 'or';
|
|
1056
|
+
return out;
|
|
1057
|
+
}
|
|
1058
|
+
// --------------------------------------------------------------- subresource
|
|
1059
|
+
async fetchSubresource(c, rel, id, versionId, parentRow) {
|
|
1060
|
+
const path = rel.subresourcePath ?? fail(`Relation on ${c.name} has no subresourcePath.`);
|
|
1061
|
+
if (path.includes('getstepResult')) {
|
|
1062
|
+
const row = parentRow ?? (await this.get(c.name, id));
|
|
1063
|
+
const executionId = Number(row['id'] ?? id);
|
|
1064
|
+
const executionRunId = str(row, 'executionRunId', 'runId', 'uuid');
|
|
1065
|
+
const parentId = Number(row['executionItemId'] ?? row['parentId'] ?? 0);
|
|
1066
|
+
if (!executionRunId) {
|
|
1067
|
+
fail('This execution row has no executionRunId — step results need the run UUID. ' +
|
|
1068
|
+
'Use the execution tool (`run_status`) for step-level results.');
|
|
1069
|
+
}
|
|
1070
|
+
const raw = await this.http.postJson('/reports/execution/getstepResult', {
|
|
1071
|
+
executionId,
|
|
1072
|
+
executionRunId,
|
|
1073
|
+
parentId,
|
|
1074
|
+
projectId: this.projectId,
|
|
1075
|
+
});
|
|
1076
|
+
return notDeleted(asArray(raw));
|
|
1077
|
+
}
|
|
1078
|
+
const params = new URLSearchParams();
|
|
1079
|
+
params.set('projectId', String(this.projectId));
|
|
1080
|
+
for (const [key, value] of Object.entries(rel.subresourceParams ?? {})) {
|
|
1081
|
+
params.set(key, value === 'id' ? id : value);
|
|
1082
|
+
}
|
|
1083
|
+
let vid = versionId;
|
|
1084
|
+
if (!vid && (c.name === 'testcases' || c.name === 'functions')) {
|
|
1085
|
+
const row = parentRow ?? (await this.get(c.name, id));
|
|
1086
|
+
vid = versionIdFromRow(row);
|
|
1087
|
+
}
|
|
1088
|
+
if (!vid && c.name === 'functions') {
|
|
1089
|
+
vid = await this.newestFunctionVersion(id);
|
|
1090
|
+
}
|
|
1091
|
+
if (vid && !params.has('versionId'))
|
|
1092
|
+
params.set('versionId', vid);
|
|
1093
|
+
const raw = await this.http.getJson(`${path}?${params.toString()}`);
|
|
1094
|
+
return notDeleted(asArray(raw));
|
|
1095
|
+
}
|
|
1096
|
+
async newestFunctionVersion(id) {
|
|
1097
|
+
const raw = await this.http.getJson(`/tm/function/version/${encodeURIComponent(id)}/${this.projectId}`);
|
|
1098
|
+
const rows = asArray(raw);
|
|
1099
|
+
if (!rows.length)
|
|
1100
|
+
return undefined;
|
|
1101
|
+
const sorted = [...rows].sort((a, b) => Number(b['versionNumber'] ?? 0) - Number(a['versionNumber'] ?? 0));
|
|
1102
|
+
return str(sorted[0], 'id', '_id', 'versionId');
|
|
1103
|
+
}
|
|
1104
|
+
// ---------------------------------------------------------------- GET search
|
|
1105
|
+
async searchGet(c, req, offset, limit) {
|
|
1106
|
+
const path = c.searchPath.replace('{projectId}', String(this.projectId));
|
|
1107
|
+
const raw = await this.http.getJson(path);
|
|
1108
|
+
let rows = asArray(raw);
|
|
1109
|
+
// Client-side filters for GET collections (only environments today).
|
|
1110
|
+
for (const w of req.where ?? []) {
|
|
1111
|
+
if (w.field === 'deleted' || w.field === 'projectId')
|
|
1112
|
+
continue;
|
|
1113
|
+
const def = c.fields[w.field] ?? fail(`Unknown field "${w.field}" on ${c.name}.`);
|
|
1114
|
+
const op = w.op ?? 'eq';
|
|
1115
|
+
rows = rows.filter((r) => matchClient(r[def.key], op, w.value));
|
|
1116
|
+
}
|
|
1117
|
+
if (c.softDelete)
|
|
1118
|
+
rows = rows.filter((r) => r[c.softDelete] !== true);
|
|
1119
|
+
const totalCount = rows.length;
|
|
1120
|
+
const page = rows.slice(offset, offset + limit);
|
|
1121
|
+
return {
|
|
1122
|
+
collection: c.name,
|
|
1123
|
+
rows: page,
|
|
1124
|
+
totalCount,
|
|
1125
|
+
returned: page.length,
|
|
1126
|
+
offset,
|
|
1127
|
+
...(totalCount > offset + page.length ? { nextOffset: offset + page.length } : {}),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
requireCollection(name) {
|
|
1131
|
+
if (!name)
|
|
1132
|
+
fail('`collection` is required. Call sqa_query({op:"catalog"}) to list collections.');
|
|
1133
|
+
const c = resolveCollection(name);
|
|
1134
|
+
if (!c) {
|
|
1135
|
+
fail(`Unknown collection "${name}". Available: ${Object.keys(SQA_COLLECTIONS).join(', ')}.`);
|
|
1136
|
+
}
|
|
1137
|
+
return c;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function controlTypeToFieldType(controlType) {
|
|
1141
|
+
switch ((controlType ?? '').toUpperCase()) {
|
|
1142
|
+
case 'NUMBER': return 'number';
|
|
1143
|
+
case 'DATE': return 'date';
|
|
1144
|
+
case 'CHECKBOX':
|
|
1145
|
+
case 'LIST':
|
|
1146
|
+
case 'RADIO': return 'array';
|
|
1147
|
+
case 'USER':
|
|
1148
|
+
case 'CLONE':
|
|
1149
|
+
case 'RELEASE-SPRINT': return 'number';
|
|
1150
|
+
default: return 'string';
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
/** Coerce values by registry/control type so a numeric id passed as "42" still matches a number field. */
|
|
1154
|
+
function coerceValue(type, value) {
|
|
1155
|
+
if (value === undefined)
|
|
1156
|
+
return value;
|
|
1157
|
+
const toNumber = (v) => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : v;
|
|
1158
|
+
if (type === 'number') {
|
|
1159
|
+
if (Array.isArray(value))
|
|
1160
|
+
return value.map(toNumber);
|
|
1161
|
+
return toNumber(value);
|
|
1162
|
+
}
|
|
1163
|
+
if (type === 'boolean' && typeof value === 'string') {
|
|
1164
|
+
if (value === 'true')
|
|
1165
|
+
return true;
|
|
1166
|
+
if (value === 'false')
|
|
1167
|
+
return false;
|
|
1168
|
+
}
|
|
1169
|
+
if (type === 'array' && Array.isArray(value))
|
|
1170
|
+
return value.map(toNumber);
|
|
1171
|
+
return value;
|
|
1172
|
+
}
|
|
1173
|
+
function allowedOpsForControl(controlType) {
|
|
1174
|
+
switch ((controlType ?? '').toUpperCase()) {
|
|
1175
|
+
case 'NUMBER':
|
|
1176
|
+
return ['eq', 'ne', 'in', 'not_in', 'gt', 'gte', 'lt', 'lte'];
|
|
1177
|
+
case 'DATE':
|
|
1178
|
+
return ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
|
|
1179
|
+
case 'LIST':
|
|
1180
|
+
case 'CHECKBOX':
|
|
1181
|
+
case 'RADIO':
|
|
1182
|
+
return ['in', 'not_in'];
|
|
1183
|
+
case 'USER':
|
|
1184
|
+
case 'CLONE':
|
|
1185
|
+
case 'RELEASE-SPRINT':
|
|
1186
|
+
return ['eq', 'in', 'ne'];
|
|
1187
|
+
case 'TEXT':
|
|
1188
|
+
case 'TEXT-EDITOR':
|
|
1189
|
+
default:
|
|
1190
|
+
return ['eq', 'contains', 'ne'];
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
function matchClient(value, op, expected) {
|
|
1194
|
+
if (op === 'eq')
|
|
1195
|
+
return value === expected;
|
|
1196
|
+
if (op === 'ne')
|
|
1197
|
+
return value !== expected;
|
|
1198
|
+
if (op === 'contains')
|
|
1199
|
+
return String(value ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
|
|
1200
|
+
if (op === 'in')
|
|
1201
|
+
return Array.isArray(expected) && expected.includes(value);
|
|
1202
|
+
if (op === 'not_in')
|
|
1203
|
+
return Array.isArray(expected) && !expected.includes(value);
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
//# sourceMappingURL=searchEngine.js.map
|