@firedrill-tools/salesforce 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +156 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/api-limit-exceeded.scenario.json +11 -0
  5. package/firedrill/baseline.scenario.json +2213 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/row-locked.scenario.json +11 -0
  8. package/firedrill/salesforce-api-limit-exceeded.drill.json +336 -0
  9. package/firedrill/salesforce-collections-composite.drill.json +277 -0
  10. package/firedrill/salesforce-denied.drill.json +74 -0
  11. package/firedrill/salesforce-fresh-install.drill.json +86 -0
  12. package/firedrill/salesforce-inactive-user.drill.json +43 -0
  13. package/firedrill/salesforce-invalid-session.drill.json +336 -0
  14. package/firedrill/salesforce-large-responses.drill.json +138 -0
  15. package/firedrill/salesforce-mcp-aliases.drill.json +156 -0
  16. package/firedrill/salesforce-profile-permissions.drill.json +241 -0
  17. package/firedrill/salesforce-read-only.drill.json +154 -0
  18. package/firedrill/salesforce-rest-flow.drill.json +714 -0
  19. package/firedrill/salesforce-row-locked.drill.json +227 -0
  20. package/firedrill/salesforce-sharing.drill.json +201 -0
  21. package/firedrill/salesforce-soql.drill.json +139 -0
  22. package/firedrill/salesforce-tight-limits.drill.json +336 -0
  23. package/firedrill/salesforce-write-committed-lost.drill.json +176 -0
  24. package/firedrill/tight-limits.scenario.json +41 -0
  25. package/firedrill/tools/salesforce/behavior.mjs +637 -0
  26. package/firedrill/tools/salesforce/lib/bytes.mjs +56 -0
  27. package/firedrill/tools/salesforce/lib/ids.mjs +68 -0
  28. package/firedrill/tools/salesforce/lib/match.mjs +352 -0
  29. package/firedrill/tools/salesforce/lib/records.mjs +506 -0
  30. package/firedrill/tools/salesforce/lib/schema.mjs +429 -0
  31. package/firedrill/tools/salesforce/lib/search.mjs +92 -0
  32. package/firedrill/tools/salesforce/lib/soql.mjs +1119 -0
  33. package/firedrill/tools/salesforce/lib/state.mjs +378 -0
  34. package/firedrill/tools/salesforce/lib/wire.mjs +109 -0
  35. package/firedrill/tools/salesforce/salesforce.tool.json +3939 -0
  36. package/firedrill/world.json +2705 -0
  37. package/firedrill/write-committed-lost.scenario.json +11 -0
  38. package/firedrill.json +5 -0
  39. package/package.json +64 -0
  40. package/starter.json +2212 -0
  41. package/test/conformance.mjs +1122 -0
@@ -0,0 +1,1119 @@
1
+ // The SOQL subset: tokenizer, parser, evaluator, date literals, ORDER BY, paging and the
2
+ // self-contained query locator. Everything is deterministic and computed from context.state; the
3
+ // supported grammar is listed in the README (unsupported syntax fails MALFORMED_QUERY, never
4
+ // silently).
5
+
6
+ import { childRelationship } from "./schema.mjs";
7
+ import { readPath, renderPaths, requireReadableType, resolvePath, visibleRows } from "./records.mjs";
8
+ import { clip, dateNow, fieldsOf, parseInstant, permissions, recordError } from "./state.mjs";
9
+ import { compileLike, foldText } from "./match.mjs";
10
+ import { RESPONSE_BYTE_BUDGET, byteBudget, jsonBytes } from "./bytes.mjs";
11
+
12
+ const DAY_MS = 86400000;
13
+ const MAX_OFFSET = 2000;
14
+ // LIMIT and OFFSET are 32-bit integers in Salesforce; larger literals fail MALFORMED_QUERY.
15
+ const MAX_INT32 = 2147483647;
16
+ const FIELDS_LIMIT = 200;
17
+ // Salesforce's maximum SOQL statement length.
18
+ export const MAX_STATEMENT_LENGTH = 100000;
19
+ // Salesforce allows at most 32 fields in ORDER BY.
20
+ const MAX_ORDER_BY_FIELDS = 32;
21
+ // Parenthesis / NOT nesting bound of this Tool (keeps the recursive parser and evaluator off the stack limit).
22
+ export const MAX_NESTING_DEPTH = 100;
23
+ // Salesforce's maximum length of a string literal in a WHERE clause.
24
+ export const MAX_STRING_LITERAL = 4000;
25
+ // Longest text area that stays filterable and sortable; longer ones are long text areas, which Salesforce
26
+ // refuses in WHERE and ORDER BY (INVALID_FIELD).
27
+ const MAX_FILTERABLE_TEXTAREA = 255;
28
+ /**
29
+ * Filter work bound of one request: WHERE comparisons × candidate rows, summed over every statement,
30
+ * subquery and search `where` of the request (a composite shares it). Filterable values hold at most
31
+ * 255 characters and string literals at most 4,000, so each unit costs a bounded amount of work.
32
+ */
33
+ export const MAX_FILTER_WORK = 500000;
34
+ /**
35
+ * Sort key bound of one request: ORDER BY keys actually computed (a key is read only when earlier keys
36
+ * tie), summed over every statement, subquery and search `orderBy` of the request.
37
+ */
38
+ export const MAX_SORT_WORK = 200000;
39
+ // Backslash escapes Salesforce accepts inside quoted strings; `\_` and `\%` stay literal in LIKE.
40
+ const STRING_ESCAPES = { n: "\n", N: "\n", r: "\r", R: "\r", t: "\t", T: "\t", b: "\b", B: "\b", f: "\f", F: "\f", '"': '"', "'": "'", "\\": "\\", _: "_", "%": "%" };
41
+ // Salesforce clamps batchSize to 200–2000; the Tool honours values down to 1 so paging can be
42
+ // exercised against small worlds (documented deviation).
43
+ export const MIN_BATCH = 1;
44
+ export const MAX_BATCH = 2000;
45
+
46
+ const KEYWORDS = new Set(["SELECT", "FROM", "WHERE", "ORDER", "BY", "LIMIT", "OFFSET", "AND", "OR", "NOT", "IN", "LIKE", "ASC", "DESC", "NULLS", "FIRST", "LAST", "TRUE", "FALSE", "NULL"]);
47
+ const UNSUPPORTED_CLAUSES = { GROUP: "GROUP BY", HAVING: "HAVING", WITH: "WITH", USING: "USING SCOPE", FOR: "FOR UPDATE / FOR VIEW / FOR REFERENCE", ALL: "ALL ROWS", TYPEOF: "TYPEOF", INCLUDES: "INCLUDES", EXCLUDES: "EXCLUDES" };
48
+ const AGGREGATES = new Set(["COUNT_DISTINCT", "SUM", "AVG", "MIN", "MAX"]);
49
+ const DATE_LITERALS = new Set(["TODAY", "YESTERDAY", "TOMORROW", "THIS_WEEK", "LAST_WEEK", "NEXT_WEEK", "THIS_MONTH", "LAST_MONTH", "NEXT_MONTH", "THIS_YEAR", "LAST_YEAR", "LAST_90_DAYS", "NEXT_90_DAYS"]);
50
+ const N_DATE_LITERALS = new Set(["LAST_N_DAYS", "NEXT_N_DAYS", "LAST_N_MONTHS", "NEXT_N_MONTHS"]);
51
+ const UNSUPPORTED_DATE_LITERALS = /^(N_DAYS_AGO|LAST_N_WEEKS|NEXT_N_WEEKS|N_WEEKS_AGO|N_MONTHS_AGO|LAST_N_QUARTERS|NEXT_N_QUARTERS|THIS_QUARTER|LAST_QUARTER|NEXT_QUARTER|N_QUARTERS_AGO|LAST_N_YEARS|NEXT_N_YEARS|N_YEARS_AGO|THIS_FISCAL_.*|LAST_FISCAL_.*|NEXT_FISCAL_.*|N_FISCAL_.*|LAST_N_FISCAL_.*|NEXT_N_FISCAL_.*)$/;
52
+
53
+ function malformed(message) {
54
+ return recordError("MALFORMED_QUERY", message);
55
+ }
56
+
57
+ function unsupported(what) {
58
+ return malformed(`${what}: unsupported in this synthetic Salesforce Tool`);
59
+ }
60
+
61
+ function unexpected(token) {
62
+ return malformed(token === undefined ? "unexpected token: '<EOF>'" : `unexpected token: '${clip(token.text)}'`);
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------------------------
66
+ // Tokenizer
67
+ // ---------------------------------------------------------------------------------------------
68
+
69
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,3})?(Z|[+-]\d{2}:?\d{2}))?/;
70
+ const NUMBER_PATTERN = /^-?\d+(\.\d+)?/;
71
+ const IDENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*/;
72
+
73
+ export function tokenize(text) {
74
+ if (text.length > MAX_STATEMENT_LENGTH) throw malformed(`SOQL statements cannot exceed ${MAX_STATEMENT_LENGTH} characters`);
75
+ const tokens = [];
76
+ let index = 0;
77
+ while (index < text.length) {
78
+ const char = text[index];
79
+ if (/\s/.test(char)) {
80
+ index += 1;
81
+ continue;
82
+ }
83
+ const rest = text.slice(index);
84
+ if (char === "'") {
85
+ // `wild` parallels `value` code unit for code unit: `%`/`_` mark unescaped LIKE wildcards, `.` literals.
86
+ let value = "";
87
+ let wild = "";
88
+ let cursor = index + 1;
89
+ let closed = false;
90
+ while (cursor < text.length) {
91
+ const current = text[cursor];
92
+ if (current === "\\" && cursor + 1 < text.length) {
93
+ const escaped = text[cursor + 1];
94
+ const decoded = Object.hasOwn(STRING_ESCAPES, escaped) ? STRING_ESCAPES[escaped] : undefined;
95
+ if (decoded === undefined) {
96
+ const shown = String.fromCodePoint(text.codePointAt(cursor + 1));
97
+ throw malformed(`Invalid string literal: illegal character sequence '\\${shown}' in string literal`);
98
+ }
99
+ value += decoded;
100
+ wild += ".";
101
+ cursor += 2;
102
+ continue;
103
+ }
104
+ if (current === "'") {
105
+ closed = true;
106
+ cursor += 1;
107
+ break;
108
+ }
109
+ value += current;
110
+ wild += current === "%" || current === "_" ? current : ".";
111
+ cursor += 1;
112
+ }
113
+ if (!closed) throw malformed("unexpected token: '<EOF>' (unterminated string literal)");
114
+ if (value.length > MAX_STRING_LITERAL) throw malformed(`String literal exceeds the maximum length of ${MAX_STRING_LITERAL} characters`);
115
+ tokens.push({ kind: "string", value, wild, text: text.slice(index, cursor) });
116
+ index = cursor;
117
+ continue;
118
+ }
119
+ const date = DATE_PATTERN.exec(rest);
120
+ if (date !== null) {
121
+ tokens.push({ kind: "date", value: date[0], datetime: date[1] !== undefined, text: date[0] });
122
+ index += date[0].length;
123
+ continue;
124
+ }
125
+ const number = NUMBER_PATTERN.exec(rest);
126
+ if (number !== null && (char !== "-" || tokens.length === 0 || tokens[tokens.length - 1].kind === "punct")) {
127
+ tokens.push({ kind: "number", value: Number(number[0]), text: number[0] });
128
+ index += number[0].length;
129
+ continue;
130
+ }
131
+ const ident = IDENT_PATTERN.exec(rest);
132
+ if (ident !== null) {
133
+ tokens.push({ kind: "ident", value: ident[0], upper: ident[0].toUpperCase(), text: ident[0] });
134
+ index += ident[0].length;
135
+ continue;
136
+ }
137
+ const punct = ["<>", "!=", "<=", ">=", "=", "<", ">", "(", ")", ",", ":"].find((candidate) => rest.startsWith(candidate));
138
+ if (punct !== undefined) {
139
+ tokens.push({ kind: "punct", value: punct, text: punct });
140
+ index += punct.length;
141
+ continue;
142
+ }
143
+ throw malformed(`unexpected token: '${char}'`);
144
+ }
145
+ return tokens;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------------------------
149
+ // Parser
150
+ // ---------------------------------------------------------------------------------------------
151
+
152
+ class Parser {
153
+ constructor(tokens) {
154
+ this.tokens = tokens;
155
+ this.index = 0;
156
+ this.depth = 0;
157
+ }
158
+ enter() {
159
+ this.depth += 1;
160
+ if (this.depth > MAX_NESTING_DEPTH) throw malformed(`WHERE expression nesting exceeds the supported depth of ${MAX_NESTING_DEPTH}`);
161
+ }
162
+ leave() {
163
+ this.depth -= 1;
164
+ }
165
+ peek(offset = 0) {
166
+ return this.tokens[this.index + offset];
167
+ }
168
+ next() {
169
+ const token = this.tokens[this.index];
170
+ this.index += 1;
171
+ return token;
172
+ }
173
+ isKeyword(word, offset = 0) {
174
+ const token = this.peek(offset);
175
+ return token !== undefined && token.kind === "ident" && token.upper === word;
176
+ }
177
+ isPunct(value, offset = 0) {
178
+ const token = this.peek(offset);
179
+ return token !== undefined && token.kind === "punct" && token.value === value;
180
+ }
181
+ expectKeyword(word) {
182
+ if (!this.isKeyword(word)) throw unexpected(this.peek());
183
+ return this.next();
184
+ }
185
+ expectPunct(value) {
186
+ if (!this.isPunct(value)) throw unexpected(this.peek());
187
+ return this.next();
188
+ }
189
+ expectName() {
190
+ const token = this.peek();
191
+ if (token === undefined || token.kind !== "ident" || KEYWORDS.has(token.upper)) throw unexpected(token);
192
+ return this.next().value;
193
+ }
194
+ expectInteger() {
195
+ const token = this.peek();
196
+ if (token === undefined || token.kind !== "number" || !Number.isInteger(token.value) || token.value < 0) throw unexpected(token);
197
+ if (token.value > MAX_INT32) throw malformed(`numeric value out of range: '${clip(token.text)}' (an integer between 0 and ${MAX_INT32} is expected)`);
198
+ return this.next().value;
199
+ }
200
+ }
201
+
202
+ function parseSelectList(parser, nested) {
203
+ const items = [];
204
+ for (;;) {
205
+ if (parser.isPunct("(")) {
206
+ if (nested) throw unsupported("nested subqueries");
207
+ parser.next();
208
+ const subquery = parseSelect(parser, true);
209
+ parser.expectPunct(")");
210
+ items.push({ kind: "subquery", ...subquery });
211
+ } else {
212
+ const token = parser.peek();
213
+ if (token === undefined || token.kind !== "ident") throw unexpected(token);
214
+ if (parser.isPunct("(", 1)) {
215
+ const name = token.upper;
216
+ parser.next();
217
+ parser.next();
218
+ if (name === "COUNT") {
219
+ if (!parser.isPunct(")")) throw unsupported("COUNT(fieldName)");
220
+ parser.next();
221
+ items.push({ kind: "count" });
222
+ } else if (name === "FIELDS") {
223
+ const scope = parser.peek();
224
+ if (scope === undefined || scope.kind !== "ident" || !["ALL", "STANDARD", "CUSTOM"].includes(scope.upper)) throw unexpected(scope);
225
+ parser.next();
226
+ parser.expectPunct(")");
227
+ items.push({ kind: "fields", scope: scope.upper });
228
+ } else {
229
+ throw unsupported(AGGREGATES.has(name) ? `aggregate function ${clip(name)}()` : `${clip(token.value)}()`);
230
+ }
231
+ } else {
232
+ if (token.upper === "TYPEOF") throw unsupported("TYPEOF");
233
+ items.push({ kind: "field", path: parser.expectName() });
234
+ }
235
+ }
236
+ if (parser.isPunct(",")) {
237
+ parser.next();
238
+ continue;
239
+ }
240
+ return items;
241
+ }
242
+ }
243
+
244
+ function parseValue(parser, allowSubquery) {
245
+ const token = parser.peek();
246
+ if (token === undefined) throw unexpected(token);
247
+ if (token.kind === "string") {
248
+ parser.next();
249
+ return { kind: "string", value: token.value, wild: token.wild };
250
+ }
251
+ if (token.kind === "number") return { kind: "number", value: parser.next().value };
252
+ if (token.kind === "date") {
253
+ parser.next();
254
+ return { kind: "date", value: token.value, datetime: token.datetime };
255
+ }
256
+ if (token.kind === "punct" && token.value === "(" && parser.isKeyword("SELECT", 1)) {
257
+ if (allowSubquery) throw unsupported("semi-join / anti-join subqueries (WHERE … IN (SELECT …))");
258
+ throw unexpected(token);
259
+ }
260
+ if (token.kind === "ident") {
261
+ if (token.upper === "TRUE" || token.upper === "FALSE") {
262
+ parser.next();
263
+ return { kind: "boolean", value: token.upper === "TRUE" };
264
+ }
265
+ if (token.upper === "NULL") {
266
+ parser.next();
267
+ return { kind: "null" };
268
+ }
269
+ if (DATE_LITERALS.has(token.upper)) {
270
+ parser.next();
271
+ return { kind: "dateLiteral", name: token.upper, n: null };
272
+ }
273
+ if (N_DATE_LITERALS.has(token.upper)) {
274
+ parser.next();
275
+ parser.expectPunct(":");
276
+ return { kind: "dateLiteral", name: token.upper, n: parser.expectInteger() };
277
+ }
278
+ if (UNSUPPORTED_DATE_LITERALS.test(token.upper)) throw unsupported(`date literal ${clip(token.upper)}`);
279
+ if (parser.isPunct("(", 1)) throw unsupported(`${clip(token.value)}()`);
280
+ }
281
+ throw unexpected(token);
282
+ }
283
+
284
+ function parseComparison(parser) {
285
+ const token = parser.peek();
286
+ if (token !== undefined && token.kind === "ident" && parser.isPunct("(", 1)) throw unsupported(`${clip(token.value)}()`);
287
+ const path = parser.expectName();
288
+ const operatorToken = parser.peek();
289
+ if (operatorToken === undefined) throw unexpected(operatorToken);
290
+ if (operatorToken.kind === "punct" && ["=", "!=", "<>", "<", "<=", ">", ">="].includes(operatorToken.value)) {
291
+ parser.next();
292
+ return { kind: "compare", path, operator: operatorToken.value === "<>" ? "!=" : operatorToken.value, value: parseValue(parser, true) };
293
+ }
294
+ if (operatorToken.kind === "ident" && operatorToken.upper === "LIKE") {
295
+ parser.next();
296
+ const value = parseValue(parser, false);
297
+ if (value.kind !== "string") throw malformed("LIKE requires a string literal");
298
+ return { kind: "like", path, value: value.value, wild: value.wild };
299
+ }
300
+ let negated = false;
301
+ if (operatorToken.kind === "ident" && operatorToken.upper === "NOT") {
302
+ negated = true;
303
+ parser.next();
304
+ }
305
+ const listToken = parser.peek();
306
+ if (listToken !== undefined && listToken.kind === "ident" && (listToken.upper === "INCLUDES" || listToken.upper === "EXCLUDES")) throw unsupported(listToken.upper);
307
+ if (listToken === undefined || listToken.kind !== "ident" || listToken.upper !== "IN") throw unexpected(listToken);
308
+ parser.next();
309
+ parser.expectPunct("(");
310
+ if (parser.isKeyword("SELECT")) throw unsupported("semi-join / anti-join subqueries (WHERE … IN (SELECT …))");
311
+ const values = [];
312
+ for (;;) {
313
+ values.push(parseValue(parser, false));
314
+ if (parser.isPunct(",")) {
315
+ parser.next();
316
+ continue;
317
+ }
318
+ break;
319
+ }
320
+ parser.expectPunct(")");
321
+ return { kind: "in", path, negated, values };
322
+ }
323
+
324
+ function parseNot(parser) {
325
+ if (parser.isKeyword("NOT")) {
326
+ parser.next();
327
+ parser.enter();
328
+ const operand = parseNot(parser);
329
+ parser.leave();
330
+ return { kind: "not", operand };
331
+ }
332
+ if (parser.isPunct("(") && !parser.isKeyword("SELECT", 1)) {
333
+ parser.next();
334
+ parser.enter();
335
+ const inner = parseOr(parser);
336
+ parser.leave();
337
+ parser.expectPunct(")");
338
+ return inner;
339
+ }
340
+ return parseComparison(parser);
341
+ }
342
+
343
+ function parseAnd(parser) {
344
+ const operands = [parseNot(parser)];
345
+ while (parser.isKeyword("AND")) {
346
+ parser.next();
347
+ operands.push(parseNot(parser));
348
+ }
349
+ return operands.length === 1 ? operands[0] : { kind: "and", operands };
350
+ }
351
+
352
+ function parseOr(parser) {
353
+ const operands = [parseAnd(parser)];
354
+ while (parser.isKeyword("OR")) {
355
+ parser.next();
356
+ operands.push(parseAnd(parser));
357
+ }
358
+ return operands.length === 1 ? operands[0] : { kind: "or", operands };
359
+ }
360
+
361
+ function parseOrderBy(parser) {
362
+ const entries = [];
363
+ for (;;) {
364
+ const path = parser.expectName();
365
+ let descending = false;
366
+ let nullsFirst = true;
367
+ if (parser.isKeyword("ASC") || parser.isKeyword("DESC")) descending = parser.next().upper === "DESC";
368
+ if (parser.isKeyword("NULLS")) {
369
+ parser.next();
370
+ const which = parser.peek();
371
+ if (which === undefined || which.kind !== "ident" || (which.upper !== "FIRST" && which.upper !== "LAST")) throw unexpected(which);
372
+ nullsFirst = parser.next().upper === "FIRST";
373
+ }
374
+ entries.push({ path, descending, nullsFirst });
375
+ if (entries.length > MAX_ORDER_BY_FIELDS) throw malformed(`ORDER BY accepts at most ${MAX_ORDER_BY_FIELDS} fields`);
376
+ if (parser.isPunct(",")) {
377
+ parser.next();
378
+ continue;
379
+ }
380
+ return entries;
381
+ }
382
+ }
383
+
384
+ function parseSelect(parser, nested) {
385
+ parser.expectKeyword("SELECT");
386
+ const select = parseSelectList(parser, nested);
387
+ parser.expectKeyword("FROM");
388
+ const from = parser.expectName();
389
+ let where = null;
390
+ let orderBy = [];
391
+ let limit = null;
392
+ let offset = null;
393
+ if (parser.isKeyword("WHERE")) {
394
+ parser.next();
395
+ where = parseOr(parser);
396
+ }
397
+ const trailing = parser.peek();
398
+ if (trailing !== undefined && trailing.kind === "ident" && UNSUPPORTED_CLAUSES[trailing.upper] !== undefined) throw unsupported(UNSUPPORTED_CLAUSES[trailing.upper]);
399
+ if (parser.isKeyword("ORDER")) {
400
+ parser.next();
401
+ parser.expectKeyword("BY");
402
+ orderBy = parseOrderBy(parser);
403
+ }
404
+ if (parser.isKeyword("LIMIT")) {
405
+ parser.next();
406
+ limit = parser.expectInteger();
407
+ }
408
+ if (parser.isKeyword("OFFSET")) {
409
+ if (nested) throw unsupported("OFFSET inside a subquery");
410
+ parser.next();
411
+ offset = parser.expectInteger();
412
+ }
413
+ return { select, from, where, orderBy, limit, offset };
414
+ }
415
+
416
+ /** Parse one SOQL statement (MALFORMED_QUERY on syntax errors or unsupported syntax). */
417
+ export function parseQuery(text) {
418
+ const parser = new Parser(tokenize(text));
419
+ const query = parseSelect(parser, false);
420
+ const trailing = parser.peek();
421
+ if (trailing !== undefined) {
422
+ if (trailing.kind === "ident" && UNSUPPORTED_CLAUSES[trailing.upper] !== undefined) throw unsupported(UNSUPPORTED_CLAUSES[trailing.upper]);
423
+ throw unexpected(trailing);
424
+ }
425
+ return query;
426
+ }
427
+
428
+ /** Parse a bare WHERE expression (parameterized search `where`). */
429
+ export function parseWhere(text) {
430
+ const parser = new Parser(tokenize(text));
431
+ const expression = parseOr(parser);
432
+ const trailing = parser.peek();
433
+ if (trailing !== undefined) throw unexpected(trailing);
434
+ return expression;
435
+ }
436
+
437
+ /** Parse a bare ORDER BY list (parameterized search `orderBy`). */
438
+ export function parseOrder(text) {
439
+ const parser = new Parser(tokenize(text));
440
+ const entries = parseOrderBy(parser);
441
+ const trailing = parser.peek();
442
+ if (trailing !== undefined) throw unexpected(trailing);
443
+ return entries;
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------------------------
447
+ // Date literals (UTC, Sunday-start weeks)
448
+ // ---------------------------------------------------------------------------------------------
449
+
450
+ function dayStart(ms) {
451
+ return Math.floor(ms / DAY_MS) * DAY_MS;
452
+ }
453
+
454
+ function monthStart(ms, delta = 0) {
455
+ const date = new Date(ms);
456
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + delta, 1);
457
+ }
458
+
459
+ function yearStart(ms, delta = 0) {
460
+ return Date.UTC(new Date(ms).getUTCFullYear() + delta, 0, 1);
461
+ }
462
+
463
+ function weekStart(ms, delta = 0) {
464
+ const start = dayStart(ms);
465
+ const weekday = new Date(start).getUTCDay();
466
+ return start - weekday * DAY_MS + delta * 7 * DAY_MS;
467
+ }
468
+
469
+ /** The `[start, end)` range (epoch ms) of a date literal relative to virtual today. */
470
+ export function dateLiteralRange(session, literal) {
471
+ const today = dayStart(parseInstant(dateNow(session)));
472
+ const n = literal.n ?? 0;
473
+ switch (literal.name) {
474
+ case "TODAY":
475
+ return [today, today + DAY_MS];
476
+ case "YESTERDAY":
477
+ return [today - DAY_MS, today];
478
+ case "TOMORROW":
479
+ return [today + DAY_MS, today + 2 * DAY_MS];
480
+ case "THIS_WEEK":
481
+ return [weekStart(today), weekStart(today, 1)];
482
+ case "LAST_WEEK":
483
+ return [weekStart(today, -1), weekStart(today)];
484
+ case "NEXT_WEEK":
485
+ return [weekStart(today, 1), weekStart(today, 2)];
486
+ case "THIS_MONTH":
487
+ return [monthStart(today), monthStart(today, 1)];
488
+ case "LAST_MONTH":
489
+ return [monthStart(today, -1), monthStart(today)];
490
+ case "NEXT_MONTH":
491
+ return [monthStart(today, 1), monthStart(today, 2)];
492
+ case "THIS_YEAR":
493
+ return [yearStart(today), yearStart(today, 1)];
494
+ case "LAST_YEAR":
495
+ return [yearStart(today, -1), yearStart(today)];
496
+ case "LAST_90_DAYS":
497
+ return [today - 90 * DAY_MS, today + DAY_MS];
498
+ case "NEXT_90_DAYS":
499
+ return [today + DAY_MS, today + 91 * DAY_MS];
500
+ case "LAST_N_DAYS":
501
+ return [today - n * DAY_MS, today + DAY_MS];
502
+ case "NEXT_N_DAYS":
503
+ return [today + DAY_MS, today + (n + 1) * DAY_MS];
504
+ case "LAST_N_MONTHS":
505
+ return [monthStart(today, -n), monthStart(today)];
506
+ case "NEXT_N_MONTHS":
507
+ return [monthStart(today, 1), monthStart(today, n + 1)];
508
+ default:
509
+ throw unsupported(`date literal ${clip(literal.name)}`);
510
+ }
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------------------------
514
+ // Predicate compilation
515
+ // ---------------------------------------------------------------------------------------------
516
+
517
+ function typeOfPath(resolved) {
518
+ return resolved.typeField ? "string" : resolved.field.type;
519
+ }
520
+
521
+ function isTextType(type) {
522
+ return ["id", "string", "textarea", "email", "url", "phone", "picklist", "reference"].includes(type);
523
+ }
524
+
525
+ function isNumberType(type) {
526
+ return ["int", "double", "currency", "percent"].includes(type);
527
+ }
528
+
529
+ /** Long text areas (and a relationship path ending in one) cannot be filtered or sorted in SOQL. */
530
+ export function isLongText(field) {
531
+ return field !== null && field !== undefined && field.type === "textarea" && (field.length ?? 0) > MAX_FILTERABLE_TEXTAREA;
532
+ }
533
+
534
+ function requireFilterable(resolved) {
535
+ if (!resolved.typeField && isLongText(resolved.field)) throw recordError("INVALID_FIELD", `field '${resolved.field.name}' can not be filtered in query call`, [resolved.field.name]);
536
+ }
537
+
538
+ function requireSortable(resolved) {
539
+ if (!resolved.typeField && isLongText(resolved.field)) throw recordError("INVALID_FIELD", `field '${resolved.field.name}' can not be sorted in a query call`, [resolved.field.name]);
540
+ }
541
+
542
+ /** Number of comparisons (LIKE, IN and operator leaves) in a WHERE tree. */
543
+ export function comparisonCount(expression) {
544
+ let count = 0;
545
+ const stack = [expression];
546
+ while (stack.length > 0) {
547
+ const node = stack.pop();
548
+ if (node.kind === "and" || node.kind === "or") stack.push(...node.operands);
549
+ else if (node.kind === "not") stack.push(node.operand);
550
+ else count += 1;
551
+ }
552
+ return count;
553
+ }
554
+
555
+ /**
556
+ * Charge `comparisons × rows` to the request's filter work before a filter runs; beyond MAX_FILTER_WORK
557
+ * the statement fails MALFORMED_QUERY with Salesforce's "too complicated" wording.
558
+ */
559
+ export function chargeFilterWork(session, expression, rows) {
560
+ if (expression === null || rows === 0) return;
561
+ session.filterWork = (session.filterWork ?? 0) + comparisonCount(expression) * rows;
562
+ if (session.filterWork > MAX_FILTER_WORK) {
563
+ throw malformed(`Query is either selecting too many fields or the filter conditions are too complicated (this Tool evaluates at most ${MAX_FILTER_WORK} WHERE comparisons per row per request)`);
564
+ }
565
+ }
566
+
567
+ function typedMismatch(resolved, expected) {
568
+ return malformed(`value of filter criterion for field '${clip(resolved.path)}' must be of type ${expected} and should not be enclosed in quotes`);
569
+ }
570
+
571
+ /**
572
+ * Compile one comparison of a field with a literal into `(value) => boolean`; null semantics as
573
+ * Salesforce. The literal is type-checked and normalised once, not per row.
574
+ */
575
+ function compileComparison(session, resolved, operator, literal) {
576
+ const type = typeOfPath(resolved);
577
+ if (literal.kind === "null") {
578
+ if (operator === "=") return (value) => value === null;
579
+ if (operator === "!=") return (value) => value !== null;
580
+ return () => false;
581
+ }
582
+ if (isTextType(type)) {
583
+ if (literal.kind !== "string") throw typedMismatch(resolved, "string");
584
+ const right = literal.value.toLowerCase();
585
+ return (value, lower) => {
586
+ if (value === null) return operator === "!=";
587
+ const left = lower();
588
+ return orderCompare(left < right ? -1 : left > right ? 1 : 0, operator);
589
+ };
590
+ }
591
+ if (isNumberType(type)) {
592
+ if (literal.kind !== "number") throw typedMismatch(resolved, "number");
593
+ return (value) => (value === null ? operator === "!=" : orderCompare(value < literal.value ? -1 : value > literal.value ? 1 : 0, operator));
594
+ }
595
+ if (type === "boolean") {
596
+ if (literal.kind !== "boolean") throw typedMismatch(resolved, "boolean");
597
+ if (operator !== "=" && operator !== "!=") throw malformed(`operator '${operator}' is not valid for boolean field '${clip(resolved.path)}'`);
598
+ return (value) => (value === null ? operator === "!=" : operator === "=" ? value === literal.value : value !== literal.value);
599
+ }
600
+ if (type === "date" || type === "datetime") {
601
+ const [start, end] = literalRange(session, resolved, type, literal);
602
+ return (value) => {
603
+ if (value === null) return operator === "!=";
604
+ const ms = parseInstant(String(value));
605
+ if (ms === null) return operator === "!=";
606
+ switch (operator) {
607
+ case "=":
608
+ return ms >= start && ms < end;
609
+ case "!=":
610
+ return ms < start || ms >= end;
611
+ case "<":
612
+ return ms < start;
613
+ case "<=":
614
+ return ms < end;
615
+ case ">":
616
+ return ms >= end;
617
+ default:
618
+ return ms >= start;
619
+ }
620
+ };
621
+ }
622
+ throw typedMismatch(resolved, type);
623
+ }
624
+
625
+ function literalRange(session, resolved, type, literal) {
626
+ if (literal.kind === "dateLiteral") return dateLiteralRange(session, literal);
627
+ if (literal.kind === "date") {
628
+ const ms = parseInstant(literal.value);
629
+ if (ms === null) throw malformed(`invalid date: ${clip(literal.value)}`);
630
+ return literal.datetime ? [ms, ms + 1] : [dayStart(ms), dayStart(ms) + DAY_MS];
631
+ }
632
+ throw typedMismatch(resolved, type === "date" ? "date" : "dateTime");
633
+ }
634
+
635
+ /**
636
+ * Compile `IN (…)` into a membership test whose per-row cost does not grow with the list: text and
637
+ * numbers and booleans become a Set, dates a sorted list of ranges searched by bisection.
638
+ */
639
+ function compileMembership(session, resolved, literals) {
640
+ const type = typeOfPath(resolved);
641
+ const hasNull = literals.some((literal) => literal.kind === "null");
642
+ const values = literals.filter((literal) => literal.kind !== "null");
643
+ if (isTextType(type) || isNumberType(type) || type === "boolean") {
644
+ const expected = isTextType(type) ? "string" : isNumberType(type) ? "number" : "boolean";
645
+ const set = new Set();
646
+ for (const literal of values) {
647
+ if (literal.kind !== expected) throw typedMismatch(resolved, expected);
648
+ set.add(expected === "string" ? literal.value.toLowerCase() : literal.value);
649
+ }
650
+ return (value, lower) => (value === null ? hasNull : set.has(expected === "string" ? lower() : value));
651
+ }
652
+ if (type === "date" || type === "datetime") {
653
+ const ranges = values.map((literal) => literalRange(session, resolved, type, literal)).sort((a, b) => a[0] - b[0]);
654
+ // Merge overlapping ranges so bisection on the start finds the only candidate.
655
+ const merged = [];
656
+ for (const range of ranges) {
657
+ const last = merged[merged.length - 1];
658
+ if (last !== undefined && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
659
+ else merged.push([range[0], range[1]]);
660
+ }
661
+ return (value) => {
662
+ if (value === null) return hasNull;
663
+ const ms = parseInstant(String(value));
664
+ if (ms === null) return false;
665
+ let low = 0;
666
+ let high = merged.length - 1;
667
+ while (low <= high) {
668
+ const middle = (low + high) >> 1;
669
+ if (ms < merged[middle][0]) high = middle - 1;
670
+ else if (ms >= merged[middle][1]) low = middle + 1;
671
+ else return true;
672
+ }
673
+ return false;
674
+ };
675
+ }
676
+ if (values.length > 0) throw typedMismatch(resolved, type);
677
+ return (value) => value === null && hasNull;
678
+ }
679
+
680
+ function orderCompare(sign, operator) {
681
+ switch (operator) {
682
+ case "=":
683
+ return sign === 0;
684
+ case "!=":
685
+ return sign !== 0;
686
+ case "<":
687
+ return sign < 0;
688
+ case "<=":
689
+ return sign <= 0;
690
+ case ">":
691
+ return sign > 0;
692
+ default:
693
+ return sign >= 0;
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Per-query column cache: each row's value of one path is read, lower-cased and case-folded at most once
699
+ * per query, however many clauses test it.
700
+ */
701
+ function columnOf(session, type, resolved, columns) {
702
+ const key = resolved.path.toLowerCase();
703
+ let column = columns.get(key);
704
+ if (column !== undefined) return column;
705
+ const entries = new Map();
706
+ const entry = (row) => {
707
+ let cached = entries.get(row);
708
+ if (cached === undefined) {
709
+ cached = { value: readPath(session, type, row, resolved), lower: null, folded: null };
710
+ entries.set(row, cached);
711
+ }
712
+ return cached;
713
+ };
714
+ column = {
715
+ entry,
716
+ lower: (cached) => (cached.lower ??= String(cached.value).toLowerCase()),
717
+ folded: (cached) => (cached.folded ??= foldText(String(cached.value))),
718
+ };
719
+ columns.set(key, column);
720
+ return column;
721
+ }
722
+
723
+ /**
724
+ * Compile a parsed WHERE tree into `(row) => boolean` for `type`, resolving every field path. Call
725
+ * `chargeFilterWork` with the candidate row count before filtering with it.
726
+ */
727
+ export function compilePredicate(session, type, expression, columns = new Map()) {
728
+ switch (expression.kind) {
729
+ case "and": {
730
+ const parts = expression.operands.map((operand) => compilePredicate(session, type, operand, columns));
731
+ return (row) => parts.every((part) => part(row));
732
+ }
733
+ case "or": {
734
+ const parts = expression.operands.map((operand) => compilePredicate(session, type, operand, columns));
735
+ return (row) => parts.some((part) => part(row));
736
+ }
737
+ case "not": {
738
+ const inner = compilePredicate(session, type, expression.operand, columns);
739
+ return (row) => !inner(row);
740
+ }
741
+ case "compare": {
742
+ const resolved = resolvePath(session, type, expression.path, "entity");
743
+ requireFilterable(resolved);
744
+ const test = compileComparison(session, resolved, expression.operator, expression.value);
745
+ const column = columnOf(session, type, resolved, columns);
746
+ return (row) => {
747
+ const cached = column.entry(row);
748
+ return test(cached.value, () => column.lower(cached));
749
+ };
750
+ }
751
+ case "like": {
752
+ const resolved = resolvePath(session, type, expression.path, "entity");
753
+ requireFilterable(resolved);
754
+ if (!isTextType(typeOfPath(resolved))) throw malformed(`LIKE is only valid on text fields: '${clip(resolved.path)}'`);
755
+ const like = compileLike(expression.value, expression.wild, (message) => {
756
+ throw malformed(message);
757
+ });
758
+ const column = columnOf(session, type, resolved, columns);
759
+ return (row) => {
760
+ const cached = column.entry(row);
761
+ return cached.value !== null && like(String(cached.value), () => column.folded(cached));
762
+ };
763
+ }
764
+ case "in": {
765
+ const resolved = resolvePath(session, type, expression.path, "entity");
766
+ requireFilterable(resolved);
767
+ const member = compileMembership(session, resolved, expression.values);
768
+ const column = columnOf(session, type, resolved, columns);
769
+ return (row) => {
770
+ const cached = column.entry(row);
771
+ const hit = member(cached.value, () => column.lower(cached));
772
+ return expression.negated ? !hit : hit;
773
+ };
774
+ }
775
+ default:
776
+ throw malformed("unsupported expression");
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Compile ORDER BY entries into `(rows) => sortedCopy` (ASC NULLS FIRST by default). Sort keys are
782
+ * computed once per row (read, lower-cased, parsed), so comparisons never re-read or re-fold values.
783
+ * The sort is stable.
784
+ */
785
+ export function compileOrder(session, type, entries) {
786
+ const resolved = entries.map((entry) => {
787
+ const path = resolvePath(session, type, entry.path, "entity");
788
+ requireSortable(path);
789
+ return { ...entry, resolved: path, kind: typeOfPath(path) };
790
+ });
791
+ const keyOf = (value, kind) => {
792
+ if (value === null) return null;
793
+ if (isNumberType(kind) || kind === "boolean") return value;
794
+ if (kind === "date" || kind === "datetime") return parseInstant(String(value)) ?? 0;
795
+ return String(value).toLowerCase();
796
+ };
797
+ // Keys are computed lazily, at most once per row and key: a later key is read only for rows whose
798
+ // earlier keys tie.
799
+ const key = (decorated, index) => {
800
+ let value = decorated.keys[index];
801
+ if (value === undefined) {
802
+ session.sortWork = (session.sortWork ?? 0) + 1;
803
+ if (session.sortWork > MAX_SORT_WORK) {
804
+ throw malformed(`Query is either selecting too many fields or the filter conditions are too complicated (this Tool computes at most ${MAX_SORT_WORK} ORDER BY keys per request)`);
805
+ }
806
+ const entry = resolved[index];
807
+ value = keyOf(readPath(session, type, decorated.row, entry.resolved), entry.kind);
808
+ decorated.keys[index] = value;
809
+ }
810
+ return value;
811
+ };
812
+ const compare = (left, right) => {
813
+ for (let index = 0; index < resolved.length; index += 1) {
814
+ const entry = resolved[index];
815
+ const a = key(left, index);
816
+ const b = key(right, index);
817
+ if (a === null || b === null) {
818
+ if (a === null && b === null) continue;
819
+ const nullFirst = entry.nullsFirst ? -1 : 1;
820
+ return a === null ? nullFirst : -nullFirst;
821
+ }
822
+ const sign = a < b ? -1 : a > b ? 1 : 0;
823
+ if (sign !== 0) return entry.descending ? -sign : sign;
824
+ }
825
+ return 0;
826
+ };
827
+ return (rows) =>
828
+ rows
829
+ .map((row) => ({ row, keys: new Array(resolved.length) }))
830
+ .sort(compare)
831
+ .map((decorated) => decorated.row);
832
+ }
833
+
834
+ // ---------------------------------------------------------------------------------------------
835
+ // Evaluation
836
+ // ---------------------------------------------------------------------------------------------
837
+
838
+ function selectionFor(session, type, items, limit) {
839
+ const paths = [];
840
+ const subqueries = [];
841
+ const selected = new Set();
842
+ let count = false;
843
+ for (const item of items) {
844
+ if (item.kind === "count") count = true;
845
+ else if (item.kind === "fields") {
846
+ if ((item.scope === "ALL" || item.scope === "CUSTOM") && (limit === null || limit > FIELDS_LIMIT)) {
847
+ throw malformed(`The SOQL FIELDS(${item.scope}) function must be used with a LIMIT of at most ${FIELDS_LIMIT}`);
848
+ }
849
+ for (const field of fieldsOf(session, type)) {
850
+ if (item.scope === "ALL" || (item.scope === "CUSTOM" && field.custom) || (item.scope === "STANDARD" && !field.custom)) paths.push({ path: field.name, field, via: null });
851
+ }
852
+ } else if (item.kind === "field") {
853
+ const resolved = resolvePath(session, type, item.path, "entity");
854
+ // Salesforce rejects a field selected twice; this also bounds the select list to distinct paths.
855
+ const key = resolved.path.toLowerCase();
856
+ if (selected.has(key)) throw malformed(`duplicate field selected: ${clip(resolved.path)}`);
857
+ selected.add(key);
858
+ paths.push(resolved);
859
+ } else subqueries.push(item);
860
+ }
861
+ if (count && (paths.length > 0 || subqueries.length > 0)) throw unsupported("COUNT() combined with other select items");
862
+ return { count, paths, subqueries };
863
+ }
864
+
865
+ function relationshipFor(session, type, subquery) {
866
+ const relationship = childRelationship(type, subquery.from);
867
+ if (relationship === null) {
868
+ throw recordError("INVALID_TYPE", `Didn't understand relationship '${clip(subquery.from)}' in FROM part of query call. If you are attempting to use a custom relationship, be sure to append the '__r' after the custom relationship name. Please reference your WSDL or the describe call for the appropriate names.`);
869
+ }
870
+ const childType = relationship.childSObject;
871
+ if (!permissions(session, childType).read) throw recordError("INVALID_TYPE", `sObject type '${childType}' is not supported. If you are attempting to use a custom object, be sure to append the '__c' after the entity name. Please reference your WSDL or the describe call for the appropriate names.`);
872
+ const selection = selectionFor(session, childType, subquery.select, subquery.limit);
873
+ if (selection.count) throw unsupported("COUNT() inside a subquery");
874
+ return {
875
+ relationship,
876
+ childType,
877
+ selection,
878
+ where: subquery.where,
879
+ predicate: subquery.where === null ? null : compilePredicate(session, childType, subquery.where),
880
+ order: subquery.orderBy.length === 0 ? null : compileOrder(session, childType, subquery.orderBy),
881
+ limit: subquery.limit,
882
+ };
883
+ }
884
+
885
+ /**
886
+ * Visible child rows of one subquery grouped by parent id, computed once per query (filtered, sorted,
887
+ * limited per parent) so a page of parents costs one pass over the children, not one pass per parent.
888
+ */
889
+ function childGroups(session, plan) {
890
+ let rows = visibleRows(session, plan.childType, false);
891
+ if (plan.predicate !== null) {
892
+ chargeFilterWork(session, plan.where, rows.length);
893
+ rows = rows.filter(plan.predicate);
894
+ }
895
+ if (plan.order !== null) rows = plan.order(rows);
896
+ const groups = new Map();
897
+ for (const row of rows) {
898
+ const parentId = row.fields?.[plan.relationship.field];
899
+ if (typeof parentId !== "string") continue;
900
+ let group = groups.get(parentId);
901
+ if (group === undefined) {
902
+ group = [];
903
+ groups.set(parentId, group);
904
+ }
905
+ if (plan.limit === null || group.length < plan.limit) group.push(row);
906
+ }
907
+ return groups;
908
+ }
909
+
910
+ /** Message of the LIMIT_EXCEEDED raised when not even one row fits the response budget. */
911
+ export function tooLargeMessage(limit) {
912
+ return `The response would exceed the ${limit.toLocaleString("en-US")}-byte response budget of this Tool; select fewer or narrower fields, add a filter or query fewer child rows`;
913
+ }
914
+
915
+ /**
916
+ * Render one page row, measuring each child record before admitting it. Returns `{ rendered, size }`
917
+ * (size = encoded bytes of the row) or null as soon as the row alone would pass `room` bytes.
918
+ */
919
+ function renderRowWithin(session, type, row, selection, children, groups, version, room) {
920
+ const rendered = renderPaths(session, type, row, version, selection.paths);
921
+ for (const child of children) rendered[child.relationship.relationshipName] = null;
922
+ let size = jsonBytes(rendered);
923
+ if (size > room) return null;
924
+ children.forEach((child, index) => {
925
+ if (size === null) return;
926
+ const rows = groups[index].get(row.Id);
927
+ if (rows === undefined || rows.length === 0) return;
928
+ const nested = { totalSize: rows.length, done: true, records: [] };
929
+ size += jsonBytes(nested) - 4; // replaces the "null" placeholder
930
+ for (const childRow of rows) {
931
+ const record = renderPaths(session, child.childType, childRow, version, child.selection.paths);
932
+ size += jsonBytes(record) + (nested.records.length > 0 ? 1 : 0);
933
+ if (size > room) {
934
+ size = null;
935
+ return;
936
+ }
937
+ nested.records.push(record);
938
+ }
939
+ rendered[child.relationship.relationshipName] = nested;
940
+ });
941
+ return size === null ? null : { rendered, size };
942
+ }
943
+
944
+ function nextRecordsUrl(session, options, offset) {
945
+ const locator = encodeLocator({ q: options.text, includeDeleted: options.includeDeleted, offset, batchSize: options.batchSize, userId: session.user.Id });
946
+ return `/services/data/${options.version}/${options.includeDeleted ? "queryAll" : "query"}/${locator}`;
947
+ }
948
+
949
+ /**
950
+ * Execute a parsed query. Returns Salesforce's `{ totalSize, done, records, nextRecordsUrl? }`;
951
+ * `offset` is the paging position (from a locator), distinct from the query's own OFFSET clause.
952
+ * A page holds at most `batchSize` rows and stops early once the encoded body would pass
953
+ * `options.byteLimit` (default RESPONSE_BYTE_BUDGET); the locator then resumes at the first row not
954
+ * returned. A single row larger than the budget fails LIMIT_EXCEEDED (never a truncated row).
955
+ */
956
+ export function executeQuery(session, query, options) {
957
+ const type = requireReadableType(session, query.from);
958
+ if (query.offset !== null && query.offset > MAX_OFFSET) throw malformed(`NUMBER_OUTSIDE_VALID_RANGE: Maximum SOQL offset allowed is ${MAX_OFFSET}`);
959
+ const selection = selectionFor(session, type, query.select, query.limit);
960
+ const predicate = query.where === null ? null : compilePredicate(session, type, query.where);
961
+ const order = query.orderBy.length === 0 ? null : compileOrder(session, type, query.orderBy);
962
+ const children = selection.subqueries.map((subquery) => relationshipFor(session, type, subquery));
963
+ let rows = visibleRows(session, type, options.includeDeleted);
964
+ if (predicate !== null) {
965
+ chargeFilterWork(session, query.where, rows.length);
966
+ rows = rows.filter(predicate);
967
+ }
968
+ if (order !== null) rows = order(rows);
969
+ if (query.offset !== null) rows = rows.slice(query.offset);
970
+ if (query.limit !== null) rows = rows.slice(0, query.limit);
971
+ if (selection.count) return { totalSize: rows.length, done: true, records: [] };
972
+ const start = options.offset;
973
+ if (start > 0 && start >= rows.length) throw recordError("INVALID_QUERY_LOCATOR", "invalid query locator");
974
+ const last = Math.min(rows.length, start + options.batchSize);
975
+ const limit = Math.min(RESPONSE_BYTE_BUDGET, options.byteLimit ?? RESPONSE_BYTE_BUDGET);
976
+ // Envelope overhead with the longest locator this query can produce (offset digits grow at most to rows.length).
977
+ const budget = byteBudget(limit, jsonBytes({ totalSize: rows.length, done: false, nextRecordsUrl: nextRecordsUrl(session, options, rows.length), records: [] }));
978
+ const groups = start >= last ? [] : children.map((child) => childGroups(session, child));
979
+ const records = [];
980
+ let end = start;
981
+ while (end < last) {
982
+ const separator = records.length > 0 ? 1 : 0;
983
+ const room = limit - budget.used - separator;
984
+ const entry = room <= 0 ? null : renderRowWithin(session, type, rows[end], selection, children, groups, options.version, room);
985
+ if (entry === null) {
986
+ if (records.length === 0) throw recordError("LIMIT_EXCEEDED", tooLargeMessage(limit));
987
+ break;
988
+ }
989
+ budget.add(entry.size + separator);
990
+ records.push(entry.rendered);
991
+ end += 1;
992
+ }
993
+ const done = end >= rows.length;
994
+ const result = { totalSize: rows.length, done, records };
995
+ if (!done) result.nextRecordsUrl = nextRecordsUrl(session, options, end);
996
+ return result;
997
+ }
998
+
999
+ /** Clamp `Sforce-Query-Options: batchSize=N` into the 1–2000 window (default 2000). */
1000
+ export function clampBatchSize(value) {
1001
+ if (typeof value !== "number" || !Number.isFinite(value)) return MAX_BATCH;
1002
+ return Math.min(MAX_BATCH, Math.max(MIN_BATCH, Math.floor(value)));
1003
+ }
1004
+
1005
+ // ---------------------------------------------------------------------------------------------
1006
+ // Query locator: `01g` + base64url(JSON) + `-<offset>` — self-contained, so paging is stateless
1007
+ // ---------------------------------------------------------------------------------------------
1008
+
1009
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1010
+
1011
+ function utf8Encode(text) {
1012
+ const bytes = [];
1013
+ for (const char of text) {
1014
+ const code = char.codePointAt(0);
1015
+ if (code < 0x80) bytes.push(code);
1016
+ else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
1017
+ else if (code < 0x10000) bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
1018
+ else bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
1019
+ }
1020
+ return bytes;
1021
+ }
1022
+
1023
+ /**
1024
+ * Strict UTF-8 decoder for caller-supplied locator bytes: null (never a throw) for a truncated
1025
+ * sequence, a continuation byte that is not 10xxxxxx, a stray continuation or 0xF8+ lead byte, an
1026
+ * overlong form, a surrogate code point or a code point above U+10FFFF.
1027
+ */
1028
+ function utf8Decode(bytes) {
1029
+ let text = "";
1030
+ for (let index = 0; index < bytes.length; ) {
1031
+ const byte = bytes[index];
1032
+ let code;
1033
+ let width;
1034
+ let min;
1035
+ if (byte < 0x80) {
1036
+ code = byte;
1037
+ width = 1;
1038
+ min = 0;
1039
+ } else if ((byte & 0xe0) === 0xc0) {
1040
+ code = byte & 0x1f;
1041
+ width = 2;
1042
+ min = 0x80;
1043
+ } else if ((byte & 0xf0) === 0xe0) {
1044
+ code = byte & 0x0f;
1045
+ width = 3;
1046
+ min = 0x800;
1047
+ } else if ((byte & 0xf8) === 0xf0) {
1048
+ code = byte & 0x07;
1049
+ width = 4;
1050
+ min = 0x10000;
1051
+ } else {
1052
+ return null;
1053
+ }
1054
+ if (index + width > bytes.length) return null;
1055
+ for (let offset = 1; offset < width; offset += 1) {
1056
+ const next = bytes[index + offset];
1057
+ if ((next & 0xc0) !== 0x80) return null;
1058
+ code = (code << 6) | (next & 0x3f);
1059
+ }
1060
+ if (code < min || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return null;
1061
+ text += String.fromCodePoint(code);
1062
+ index += width;
1063
+ }
1064
+ return text;
1065
+ }
1066
+
1067
+ function base64urlEncode(bytes) {
1068
+ let out = "";
1069
+ for (let index = 0; index < bytes.length; index += 3) {
1070
+ const a = bytes[index];
1071
+ const b = bytes[index + 1];
1072
+ const c = bytes[index + 2];
1073
+ out += B64[a >> 2];
1074
+ out += B64[((a & 3) << 4) | ((b ?? 0) >> 4)];
1075
+ if (b !== undefined) out += B64[((b & 15) << 2) | ((c ?? 0) >> 6)];
1076
+ if (c !== undefined) out += B64[c & 63];
1077
+ }
1078
+ return out;
1079
+ }
1080
+
1081
+ function base64urlDecode(text) {
1082
+ if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) return null;
1083
+ const bytes = [];
1084
+ let buffer = 0;
1085
+ let bits = 0;
1086
+ for (const char of text) {
1087
+ buffer = (buffer << 6) | B64.indexOf(char);
1088
+ bits += 6;
1089
+ if (bits >= 8) {
1090
+ bits -= 8;
1091
+ bytes.push((buffer >> bits) & 0xff);
1092
+ }
1093
+ }
1094
+ return bytes;
1095
+ }
1096
+
1097
+ export function encodeLocator(payload) {
1098
+ return `01g${base64urlEncode(utf8Encode(JSON.stringify(payload)))}-${payload.offset}`;
1099
+ }
1100
+
1101
+ /** Decode a locator; null when it is not one of ours. */
1102
+ export function decodeLocator(locator) {
1103
+ if (typeof locator !== "string") return null;
1104
+ const match = /^01g([A-Za-z0-9_-]+)-(\d+)$/.exec(locator);
1105
+ if (match === null) return null;
1106
+ const bytes = base64urlDecode(match[1]);
1107
+ if (bytes === null) return null;
1108
+ const text = utf8Decode(bytes);
1109
+ if (text === null) return null;
1110
+ let payload;
1111
+ try {
1112
+ payload = JSON.parse(text);
1113
+ } catch {
1114
+ return null;
1115
+ }
1116
+ if (typeof payload !== "object" || payload === null || typeof payload.q !== "string" || typeof payload.userId !== "string") return null;
1117
+ if (!Number.isInteger(payload.offset) || payload.offset !== Number(match[2]) || !Number.isInteger(payload.batchSize)) return null;
1118
+ return { q: payload.q, includeDeleted: payload.includeDeleted === true, offset: payload.offset, batchSize: clampBatchSize(payload.batchSize), userId: payload.userId };
1119
+ }