@m6d/cortex-cli 0.14.0 → 0.16.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.
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { isRecord } from "../json";
2
+ import { isRecord, JSON_NUMBER_PATTERN } from "../json";
3
3
 
4
4
  /*
5
5
  * Datasets: governed tabular reads over a host's own data. A dataset host
@@ -25,11 +25,36 @@ export const datasetColumnRefSchema = z.string().regex(DATASET_COLUMN_REF_PATTER
25
25
  export const DATASET_COLUMN_TYPES = ["string", "number", "boolean", "date"] as const;
26
26
  export type DatasetColumnType = (typeof DATASET_COLUMN_TYPES)[number];
27
27
 
28
- export const datasetColumnSchema = z.object({
29
- name: datasetIdentifierSchema,
30
- type: z.enum(DATASET_COLUMN_TYPES),
31
- description: z.string().max(200).default(""),
32
- });
28
+ /** How long a definition's texts run, how many columns and listed values it holds, and the most rows a query may answer. */
29
+ export const DATASET_DEFINITION_LIMITS = {
30
+ description: 500,
31
+ columns: 200,
32
+ columnDescription: 200,
33
+ values: 100,
34
+ valueLength: 200,
35
+ maxRows: 5000,
36
+ } as const;
37
+
38
+ /**
39
+ * One column. `values`, on a string column, lists every value it can hold:
40
+ * the agent reads them, and a query comparing the column to anything else is
41
+ * refused before it runs.
42
+ */
43
+ export const datasetColumnSchema = z
44
+ .object({
45
+ name: datasetIdentifierSchema,
46
+ type: z.enum(DATASET_COLUMN_TYPES),
47
+ description: z.string().max(DATASET_DEFINITION_LIMITS.columnDescription).default(""),
48
+ values: z
49
+ .array(z.string().min(1).max(DATASET_DEFINITION_LIMITS.valueLength))
50
+ .min(1)
51
+ .max(DATASET_DEFINITION_LIMITS.values)
52
+ .optional(),
53
+ })
54
+ .refine((column) => column.values === undefined || column.type === "string", {
55
+ message: "only a string column lists its values",
56
+ path: ["values"],
57
+ });
33
58
  export type DatasetColumn = z.infer<typeof datasetColumnSchema>;
34
59
 
35
60
  /**
@@ -54,11 +79,16 @@ export const DATASET_MAX_ROWS_DEFAULT = 500;
54
79
  export const datasetDefinitionSchema = z.object({
55
80
  name: datasetIdentifierSchema,
56
81
  /** What the model reads in the catalog; the columns come only on request. */
57
- description: z.string().max(500),
58
- columns: z.array(datasetColumnSchema).min(1).max(200),
82
+ description: z.string().max(DATASET_DEFINITION_LIMITS.description),
83
+ columns: z.array(datasetColumnSchema).min(1).max(DATASET_DEFINITION_LIMITS.columns),
59
84
  relations: z.record(datasetIdentifierSchema, datasetRelationSchema).default({}),
60
85
  /** The most rows one query may answer with; the host clamps to it whatever the caller sent. */
61
- maxRows: z.number().int().min(1).max(5000).default(DATASET_MAX_ROWS_DEFAULT),
86
+ maxRows: z
87
+ .number()
88
+ .int()
89
+ .min(1)
90
+ .max(DATASET_DEFINITION_LIMITS.maxRows)
91
+ .default(DATASET_MAX_ROWS_DEFAULT),
62
92
  });
63
93
  export type DatasetDefinition = z.infer<typeof datasetDefinitionSchema>;
64
94
  export type DatasetDefinitionInput = z.input<typeof datasetDefinitionSchema>;
@@ -208,19 +238,22 @@ export const datasetConditionSchema: z.ZodType<DatasetCondition, DatasetConditio
208
238
  * A condition, a group of filters that must all hold (`all`) or of which one
209
239
  * must hold (`any`), or the negation of one (`not`, with SQL's null
210
240
  * semantics: a row whose comparison is unknown is left out either way). A
211
- * filter list is an `all`.
241
+ * filter list is an `all`. An object carrying `col` is a condition, whatever
242
+ * else it carries; any other is an `any`, else an `all`, else a `not` group.
212
243
  */
213
244
  export type DatasetFilter =
214
245
  | DatasetCondition
215
246
  | { any: DatasetFilter[] }
216
247
  | { all: DatasetFilter[] }
217
248
  | { not: DatasetFilter };
249
+ /** A group refuses `col`, so an object with one is read as a condition or not at all. */
250
+ const notACondition = { col: z.never().optional() };
218
251
  export const datasetFilterSchema: z.ZodType<DatasetFilter, DatasetFilter> = z.lazy(() =>
219
252
  z.union([
220
253
  datasetConditionSchema,
221
- z.object({ any: z.array(datasetFilterSchema).min(1).max(20) }),
222
- z.object({ all: z.array(datasetFilterSchema).min(1).max(20) }),
223
- z.object({ not: datasetFilterSchema }),
254
+ z.object({ any: z.array(datasetFilterSchema).min(1).max(20), ...notACondition }),
255
+ z.object({ all: z.array(datasetFilterSchema).min(1).max(20), ...notACondition }),
256
+ z.object({ not: datasetFilterSchema, ...notACondition }),
224
257
  ]),
225
258
  );
226
259
 
@@ -316,13 +349,18 @@ const datasetQueryObjectSchema = z.object({
316
349
  .describe(
317
350
  "By a column or select alias, a group key, an aggregate alias or a computed alias.",
318
351
  ),
319
- limit: z.number().int().min(1).optional(),
352
+ /** A 32-bit integer, as `offset` is; the dataset's `maxRows` caps it. */
353
+ limit: z.int32().min(1).optional(),
320
354
  /** Rows to skip before `limit`; only with a `sort`, since the row order is undefined without one. */
321
- offset: z.number().int().min(0).optional(),
355
+ offset: z.int32().min(0).optional(),
322
356
  });
323
357
 
324
358
  /** How deep a query's JSON may nest, objects and arrays alike; far past what a statement needs. */
325
359
  export const DATASET_QUERY_MAX_DEPTH = 32;
360
+ /** The largest body `POST {host}/{name}` takes: a query is a few kilobytes of JSON, so a host refuses anything far past that before reading it. */
361
+ export const DATASET_REQUEST_MAX_BYTES = 256 * 1024;
362
+ /** How many times a query may name its computed aliases once each is written out where it is named; far past any query a model writes. */
363
+ export const DATASET_COMPUTE_REFERENCES_MAX = 100;
326
364
 
327
365
  /** Whether `value` nests at most `budget` objects and arrays deep; the walk stops at the budget, so a deeper value costs no more than an allowed one. */
328
366
  function nestsWithin(value: unknown, budget: number): boolean {
@@ -341,12 +379,19 @@ export const datasetQuerySchema = z
341
379
  .pipe(datasetQueryObjectSchema);
342
380
  export type DatasetQuery = z.infer<typeof datasetQuerySchema>;
343
381
 
344
- /** What `POST {host}/{name}` receives: the validated query, capped, plus what the host may want in its own logs. */
382
+ /**
383
+ * What `POST {host}/{name}` receives: the validated query, capped, plus what
384
+ * the host may want in its own logs, and the IANA zone of the user the query
385
+ * runs for. A host reads that zone's calendar into the query: a bare date
386
+ * names its midnight there, a period or part of an instant is taken on its
387
+ * clock (shifted by the zone's offset now), and an instant answers on it.
388
+ */
345
389
  export const datasetRequestSchema = z.object({
346
390
  query: datasetQuerySchema,
347
391
  context: z.object({
348
392
  threadId: z.string().max(128),
349
393
  requestId: z.string().max(128).optional(),
394
+ timeZone: z.string().max(64).optional(),
350
395
  }),
351
396
  });
352
397
  export type DatasetRequest = z.infer<typeof datasetRequestSchema>;
@@ -396,10 +441,59 @@ export function resolveDatasetColumn(catalog: DatasetCatalog, from: string, ref:
396
441
  dataset = target;
397
442
  }
398
443
  const column = dataset.columns.find((candidate) => candidate.name === columnName);
399
- if (!column) return missing(`"${dataset.name}" has no column "${columnName}"`);
444
+ if (!column) return missing(unknownColumnError(dataset, columnName));
400
445
  return { ok: true as const, column, path: segments.join(".") };
401
446
  }
402
447
 
448
+ /**
449
+ * Why `dataset` has no column `name`, with the columns it has and up to
450
+ * three closest ones: names containing it or contained in it (ignoring
451
+ * case, and only for names longer than two characters), else names two
452
+ * edits or fewer away. A query that guessed `name` reads `departments has
453
+ * no column "name". Closest: nameAr, nameEn. Columns: id, nameAr, ...`
454
+ */
455
+ export function unknownColumnError(dataset: DatasetDefinition, name: string) {
456
+ const columns = dataset.columns.map((column) => column.name);
457
+ const wanted = name.toLowerCase();
458
+ const containing = columns.filter((column) => {
459
+ const lower = column.toLowerCase();
460
+ return (
461
+ (wanted.length > 2 && lower.includes(wanted)) ||
462
+ (lower.length > 2 && wanted.includes(lower))
463
+ );
464
+ });
465
+ const closest = (
466
+ containing.length > 0
467
+ ? containing
468
+ : // Lengths more than two apart are more than two edits apart, so a
469
+ // name of any length costs only its own columns' comparisons.
470
+ columns.filter(
471
+ (column) =>
472
+ Math.abs(column.length - wanted.length) <= 2 &&
473
+ editDistance(column.toLowerCase(), wanted) <= 2,
474
+ )
475
+ ).slice(0, 3);
476
+ const hint = closest.length > 0 ? ` Closest: ${closest.join(", ")}.` : "";
477
+ return `${dataset.name} has no column "${name}".${hint} Columns: ${columns.join(", ")}.`;
478
+ }
479
+
480
+ /** Levenshtein distance: the fewest single-character edits from `a` to `b`. */
481
+ function editDistance(a: string, b: string) {
482
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
483
+ for (let i = 1; i <= a.length; i++) {
484
+ const current = [i];
485
+ for (let j = 1; j <= b.length; j++) {
486
+ current[j] = Math.min(
487
+ previous[j]! + 1,
488
+ current[j - 1]! + 1,
489
+ previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
490
+ );
491
+ }
492
+ previous = current;
493
+ }
494
+ return previous[b.length]!;
495
+ }
496
+
403
497
  function refError(kind: string, ref: string, reason: string) {
404
498
  return `${kind} "${ref}": ${reason}`;
405
499
  }
@@ -424,8 +518,9 @@ function filterValueIssue(filter: DatasetCondition) {
424
518
  * The query as the host runs it: habits the model brings from SQL are
425
519
  * absorbed here rather than refused. A reference prefixed with the dataset's
426
520
  * own name (`orders.total` on `orders`) drops the prefix when no relation
427
- * has that name; a relation any reference names is joined without being
428
- * listed; a `select` on a grouped query that only repeats the group keys is
521
+ * has that name; a relation a reference names is joined without being
522
+ * listed, except in `compute` and `having`, which read the answered row; a
523
+ * `select` on a grouped query that only repeats the group keys is
429
524
  * dropped, and an ungrouped query without one selects the definition's
430
525
  * columns. Every host receives this shape, so all of them agree on it.
431
526
  */
@@ -624,8 +719,9 @@ export function boundDatasetResult(
624
719
  /**
625
720
  * Every way a query can be wrong given the catalog, as messages the model
626
721
  * can act on; an empty list means the host will understand it. Checked
627
- * before anything leaves Cortex, so a host can trust the shape and only
628
- * has to translate.
722
+ * before anything leaves Cortex, and again by every host against its own
723
+ * catalog. Computed aliases written out where they are named are capped
724
+ * here; how large the rest grows as SQL or LINQ is each host's own bound.
629
725
  */
630
726
  export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuery) {
631
727
  if (!catalog.has(query.from)) return [`Unknown dataset "${query.from}"`];
@@ -654,6 +750,23 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
654
750
  } satisfies QueryCheck;
655
751
 
656
752
  for (const filter of query.filter) checkFilter(check, filter);
753
+ const row = checkAnsweredRow(check, catalog, query);
754
+ checkFinishedRow(check, query, row);
755
+ if (computeReferences(query) > DATASET_COMPUTE_REFERENCES_MAX) {
756
+ check.issue(
757
+ `computed aliases are named more than ${DATASET_COMPUTE_REFERENCES_MAX} times once each is written out where it is named; name earlier computed aliases fewer times`,
758
+ );
759
+ }
760
+ return issues;
761
+ }
762
+
763
+ /**
764
+ * The row a query answers, checked as it is built: on a grouped query its
765
+ * keys and aggregate aliases (never one name for both, and no select), on a
766
+ * plain one its select aliases; each with the names that are a column as it
767
+ * is, which keep what the column says.
768
+ */
769
+ function checkAnsweredRow(check: QueryCheck, catalog: DatasetCatalog, query: DatasetQuery) {
657
770
  const groupKeys = checkGroupBy(check, query.groupBy);
658
771
  const aliases = checkAggregates(check, query.aggregate);
659
772
  for (const alias of aliases.keys()) {
@@ -671,181 +784,48 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
671
784
  );
672
785
  }
673
786
  const selectAliases = checkSelect(check, query.select ?? []);
674
- const row = grouped
675
- ? { names: new Map([...groupKeys, ...aliases]), kind: "grouped" as const }
676
- : { names: selectAliases, kind: "plain" as const };
677
- checkFinishedRow(check, query, row);
678
- checkExpansion(check, query, catalog.get(query.from)?.columns.length ?? 0);
679
- return issues;
787
+ return grouped
788
+ ? {
789
+ names: new Map([...groupKeys, ...aliases]),
790
+ kind: "grouped" as const,
791
+ columns: passedColumns(catalog, query, query.groupBy),
792
+ }
793
+ : {
794
+ names: selectAliases,
795
+ kind: "plain" as const,
796
+ columns: passedColumns(catalog, query, query.select ?? []),
797
+ };
680
798
  }
681
799
 
682
- /** How large any one expression a host translates may grow once every alias it names is written out in place. */
683
- export const DATASET_EXPANSION_BUDGET = 2_000;
684
-
685
800
  /**
686
- * Hosts write a computed alias out again wherever a later calculation or
687
- * HAVING names it, as SQL requires, so ten aliases each naming the previous
688
- * one ten times grow to 10^10 nodes from a few hundred bytes, with or without
689
- * a column among them. Each alias costs what it expands to, worked out once in
690
- * order, so the expansion is measured without being performed: a named select
691
- * value or group key, an aggregate and a window cost their own trees wherever
692
- * they are named, since hosts write them out. Every expression a host
693
- * translates is held to the budget: select values, group keys, WHERE,
694
- * aggregates and their filters, windows, calculations, HAVING and QUALIFY.
695
- * Sort and rank name values and add none. A window is a correlated subquery
696
- * that carries the row it counts over, so it also costs that row: WHERE and
697
- * every value the row projects (`columnCount` of them for a plain query with
698
- * no select).
801
+ * How many times the query names its computed aliases once each is written
802
+ * out where it is named, as SQL needs: every name counts once plus what the
803
+ * named value itself names, so ten values each naming the previous one ten
804
+ * times count past a billion. Worked out once per alias, capped just past
805
+ * the limit.
699
806
  */
700
- function checkExpansion(check: QueryCheck, query: DatasetQuery, columnCount: number) {
701
- // The source row: what WHERE, select values, group keys and aggregates read, each column once.
702
- const source = new Map<string, number>();
703
- const named = [...(query.select ?? []), ...query.groupBy].flatMap((entry) =>
704
- typeof entry === "string" ? [] : [[entry.as, operandCost(entry.expr, source)] as const],
705
- );
706
- const where = query.filter.map((filter) => filterCost(filter, source));
707
- const costs = [...named.map(([, total]) => total), ...where];
708
- const expanded = new Map(named);
709
- const aggregates = query.aggregate.map((entry) => {
710
- const parts = [
711
- ...(entry.col === undefined ? [] : [operandCost(entry.col, source)]),
712
- ...entry.filter.map((filter) => filterCost(filter, source)),
713
- ];
714
- costs.push(...parts);
715
- expanded.set(entry.as, nodeCost(parts));
716
- return nodeCost(parts);
717
- });
718
- const grouped = query.groupBy.length > 0 || query.aggregate.length > 0;
719
- const projected = grouped ? query.groupBy : (query.select ?? []);
720
- const carried = Math.min(
721
- DATASET_EXPANSION_BUDGET + 1,
722
- [
723
- ...where,
724
- ...projected.map((entry) =>
725
- typeof entry === "string" ? 1 : (expanded.get(entry.as) ?? 1),
726
- ),
727
- ...(grouped || projected.length ? [] : Array<number>(columnCount).fill(1)),
728
- ...aggregates,
729
- ].reduce((total, cost) => total + cost, 0),
730
- );
731
- // Every window reads the row as it was before any window, its partition and the row it carries included.
732
- const windows = query.window.map((entry) => {
733
- const total = nodeCost([
734
- ...(entry.col === undefined ? [] : [operandCost(entry.col, expanded)]),
735
- ...entry.filter.map((filter) => filterCost(filter, expanded)),
736
- ...entry.partitionBy.map((name) => operandCost(name, expanded)),
737
- carried,
738
- ]);
739
- costs.push(total);
740
- return [entry.as, total] as const;
741
- });
742
- for (const [as, total] of windows) expanded.set(as, total);
743
- for (const entry of query.compute) {
744
- const total = operandCost(entry.expr, expanded);
745
- expanded.set(entry.as, total);
746
- costs.push(total);
747
- }
748
- for (const filter of query.having) costs.push(filterCost(filter, expanded));
749
- // A grouped row is materialized before QUALIFY reads it; without group keys the
750
- // .NET host writes the calculations QUALIFY names out in full.
751
- const qualifying = query.groupBy.length ? source : expanded;
752
- for (const filter of query.qualify) costs.push(filterCost(filter, qualifying));
753
- if (costs.some((total) => total > DATASET_EXPANSION_BUDGET)) {
754
- check.issue(
755
- `Computed aliases expand to more than ${DATASET_EXPANSION_BUDGET} references; name intermediate results and reuse them less`,
807
+ function computeReferences(query: DatasetQuery) {
808
+ const written = new Map<string, number>();
809
+ const count = (refs: string[]) =>
810
+ Math.min(
811
+ refs.reduce((sum, ref) => {
812
+ const inner = written.get(ref);
813
+ return inner === undefined ? sum : sum + 1 + inner;
814
+ }, 0),
815
+ DATASET_COMPUTE_REFERENCES_MAX + 1,
756
816
  );
817
+ let total = 0;
818
+ for (const entry of query.compute) {
819
+ const own = count(operandRefs(entry.expr));
820
+ written.set(entry.as, own);
821
+ total += own;
757
822
  }
758
- }
759
-
760
- /**
761
- * How many times a host writes one part out when it spells the node holding
762
- * it, the most either host does; every other part is written once. A divisor
763
- * is tested for zero before the division, ROUND's digits scale the value
764
- * before and after, and LENGTH's text is tested for NULL besides measured. A
765
- * date is read once per part a truncation or part needs (Zeller's congruence
766
- * for the week and weekday), and a truncation other than the week, packed as
767
- * a number, is read 3 more times when it meets a real date in a COALESCE, a
768
- * CASE or a comparison, so those factors are the product. A condition guards
769
- * its sides against NULL, and a negated condition with no opposite operator
770
- * (`between`, the text matches) tests its left side once more.
771
- */
772
- const EXPANSION_WEIGHTS = {
773
- divisor: 2,
774
- roundDigits: 2,
775
- lengthText: 2,
776
- conditionLeft: 3,
777
- conditionRight: 2,
778
- dateTrunc: { day: 9, month: 6, quarter: 6, year: 3, week: 17 },
779
- datePart: { day: 1, month: 1, quarter: 1, year: 1, dayOfWeek: 16, week: 17 },
780
- } as const;
781
-
782
- /** One node plus its children's costs, held at one past the budget so no sum can overflow. */
783
- function nodeCost(children: number[]) {
784
- return Math.min(
785
- DATASET_EXPANSION_BUDGET + 1,
786
- children.reduce((total, cost) => total + cost, 1),
787
- );
788
- }
789
-
790
- /** How often a scalar function's argument is written out. */
791
- function argumentWeight(fn: DatasetFunction, index: number) {
792
- if (fn === "length") return EXPANSION_WEIGHTS.lengthText;
793
- return fn === "round" && index === 1 ? EXPANSION_WEIGHTS.roundDigits : 1;
794
- }
795
-
796
- /**
797
- * An operand's size written out: a literal or a column is 1, a reference to an
798
- * earlier computed alias is that alias's cost (a self or forward one, refused
799
- * elsewhere, is 1), and arithmetic, a date function, a scalar function, a CASE
800
- * and each of its branches are 1 plus their parts, each part times its weight.
801
- */
802
- function operandCost(operand: DatasetOperand, expanded: ReadonlyMap<string, number>): number {
803
- if (typeof operand === "string") return expanded.get(operand) ?? 1;
804
- if (typeof operand === "number" || "value" in operand) return 1;
805
- if ("op" in operand) {
806
- const divisor = operand.op === "/" ? EXPANSION_WEIGHTS.divisor : 1;
807
- return nodeCost([
808
- operandCost(operand.left, expanded),
809
- operandCost(operand.right, expanded) * divisor,
810
- ]);
811
- }
812
- if ("case" in operand) {
813
- return nodeCost([
814
- ...operand.case.map((branch) =>
815
- nodeCost([filterCost(branch.when, expanded), operandCost(branch.then, expanded)]),
816
- ),
817
- ...(operand.else === undefined ? [] : [operandCost(operand.else, expanded)]),
818
- ]);
819
- }
820
- if ("args" in operand) {
821
- return nodeCost(
822
- operand.args.map(
823
- (arg, index) => operandCost(arg, expanded) * argumentWeight(operand.fn, index),
824
- ),
825
- );
826
- }
827
- const weight =
828
- operand.fn === "dateTrunc"
829
- ? EXPANSION_WEIGHTS.dateTrunc[operand.unit]
830
- : EXPANSION_WEIGHTS.datePart[operand.unit];
831
- return nodeCost([operandCost(operand.of, expanded) * weight]);
832
- }
833
-
834
- /** A filter's size written out: 1 per condition or group plus its parts; a condition's left side is weighted, each literal on its right is 1, and a `{ col }` right side is its weighted operand cost. */
835
- function filterCost(filter: DatasetFilter, expanded: ReadonlyMap<string, number>): number {
836
- if ("not" in filter) return nodeCost([filterCost(filter.not, expanded)]);
837
- if ("any" in filter || "all" in filter) {
838
- const entries = "any" in filter ? filter.any : filter.all;
839
- return nodeCost(entries.map((entry) => filterCost(entry, expanded)));
840
- }
841
- const right = isColumnValue(filter.value)
842
- ? operandCost(filter.value.col, expanded) * EXPANSION_WEIGHTS.conditionRight
843
- : Array.isArray(filter.value)
844
- ? filter.value.length
845
- : filter.value === undefined
846
- ? 0
847
- : 1;
848
- return nodeCost([operandCost(filter.col, expanded) * EXPANSION_WEIGHTS.conditionLeft, right]);
823
+ const rowNames = [
824
+ ...[...query.having, ...query.qualify].flatMap(filterRefs),
825
+ ...[...query.sort, ...(query.rank?.sort ?? [])].map((entry) => entry.col),
826
+ ...(query.rank?.partitionBy ?? []),
827
+ ];
828
+ return total + count(rowNames);
849
829
  }
850
830
 
851
831
  /**
@@ -856,7 +836,7 @@ function filterCost(filter: DatasetFilter, expanded: ReadonlyMap<string, number>
856
836
  function checkFinishedRow(
857
837
  check: QueryCheck,
858
838
  query: DatasetQuery,
859
- row: { names: Map<string, DatasetColumnType>; kind: "grouped" | "plain" },
839
+ row: RowNames & { names: Map<string, DatasetColumnType> },
860
840
  ) {
861
841
  const rowCheck = rowScope(check, row);
862
842
  // On a plain row a selected column is a name too; a window or calculation may not take it.
@@ -881,7 +861,7 @@ function rowScope(check: QueryCheck, row: RowNames) {
881
861
  issue: (message: string) => check.issue(message),
882
862
  columnOf(kind, ref) {
883
863
  const type = row.names.get(ref);
884
- if (type) return { name: ref, type, description: "" };
864
+ if (type) return row.columns.get(ref) ?? { name: ref, type, description: "" };
885
865
  if (row.kind === "plain") return check.columnOf(kind, ref);
886
866
  check.issue(
887
867
  refError(
@@ -1203,10 +1183,31 @@ function checkFilter(check: QueryCheck, filter: DatasetFilter, kind = "filter")
1203
1183
  ? columnValueIssue(check, kind, type, filter.op, filter.value.col)
1204
1184
  : (filterValueIssue(filter) ??
1205
1185
  filterValueTypeIssue(type, filter) ??
1206
- truncatedDateIssue(filter));
1186
+ truncatedDateIssue(filter) ??
1187
+ listedValueIssue(check, kind, filter));
1207
1188
  if (issue) check.issue(refError(kind, shown, issue));
1208
1189
  }
1209
1190
 
1191
+ const LISTED_VALUE_OPS = new Set<DatasetFilterOp>(["=", "!=", "in", "notIn"]);
1192
+
1193
+ /**
1194
+ * A column that lists its values is only compared with one of them: any
1195
+ * other literal would match no row, or every row, and the host would answer
1196
+ * as if the data said so.
1197
+ */
1198
+ function listedValueIssue(check: QueryCheck, kind: string, filter: DatasetCondition) {
1199
+ if (typeof filter.col !== "string" || !LISTED_VALUE_OPS.has(filter.op)) return undefined;
1200
+ if (filter.value === undefined || isColumnValue(filter.value)) return undefined;
1201
+ const listed = check.columnOf(kind, filter.col)?.values;
1202
+ if (!listed) return undefined;
1203
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
1204
+ // A number or a boolean is compared as the text the host turns it into.
1205
+ const odd = values.find((value) => !listed.includes(String(value)));
1206
+ return odd === undefined
1207
+ ? undefined
1208
+ : `${JSON.stringify(odd)} is not one of its values: ${listed.join(", ")}`;
1209
+ }
1210
+
1210
1211
  /** A truncated date is a whole day, so a literal against it is one too ("2024-03-01"); a time or a bare month would never match. */
1211
1212
  function truncatedDateIssue(filter: DatasetCondition) {
1212
1213
  const truncated =
@@ -1242,7 +1243,7 @@ function filterValueTypeIssue(type: DatasetColumnType, filter: DatasetCondition)
1242
1243
  return (
1243
1244
  typeof value === "number" ||
1244
1245
  (typeof value === "string" &&
1245
- value.trim() !== "" &&
1246
+ JSON_NUMBER_PATTERN.test(value) &&
1246
1247
  Number.isFinite(Number(value)))
1247
1248
  );
1248
1249
  case "date":
@@ -1314,7 +1315,31 @@ function aggregateType(
1314
1315
  }
1315
1316
 
1316
1317
  /** What sort and rank may name: on a grouped query a group key or an alias; on a plain one a select alias or any reachable column. */
1317
- type RowNames = { names: ReadonlyMap<string, DatasetColumnType>; kind: "grouped" | "plain" };
1318
+ type RowNames = {
1319
+ names: ReadonlyMap<string, DatasetColumnType>;
1320
+ kind: "grouped" | "plain";
1321
+ /** The names that are a column as it is (group keys, select entries), so what the column says (its values) still holds. */
1322
+ columns: ReadonlyMap<string, DatasetColumn>;
1323
+ };
1324
+
1325
+ /**
1326
+ * The row names that are a column as it is, each with the column: a bare
1327
+ * column (`status`), or one under an alias (`{ expr: "status", as: "state" }`).
1328
+ */
1329
+ function passedColumns(
1330
+ catalog: DatasetCatalog,
1331
+ query: DatasetQuery,
1332
+ entries: ReadonlyArray<string | { expr: DatasetOperand; as: string }>,
1333
+ ) {
1334
+ return new Map(
1335
+ entries.flatMap((entry) => {
1336
+ const [name, ref] = typeof entry === "string" ? [entry, entry] : [entry.as, entry.expr];
1337
+ if (typeof ref !== "string") return [];
1338
+ const resolved = resolveDatasetColumn(catalog, query.from, ref);
1339
+ return resolved.ok ? [[name, resolved.column] as const] : [];
1340
+ }),
1341
+ );
1342
+ }
1318
1343
 
1319
1344
  function checkSort(check: QueryCheck, sort: DatasetQuery["sort"], kind: string, row: RowNames) {
1320
1345
  for (const { col } of sort) checkRowName(check, kind, col, row);
@@ -1,4 +1,4 @@
1
- import { isRecord } from "../json";
1
+ import { isRecord, JSON_NUMBER_PATTERN } from "../json";
2
2
 
3
3
  const REDACTION_MARKER = "[REDACTED]";
4
4
 
@@ -7,19 +7,22 @@ const REDACTION_MARKER = "[REDACTED]";
7
7
  * itself and, after a leading scheme word (`Bearer xyz`, `Basic abc`), the
8
8
  * bare secret the scheme merely labels; each of those raw as in a header,
9
9
  * percent-encoded as in a path slot (`encodeURIComponent`) or a query value
10
- * (`URLSearchParams`, which spells a space as `+`), and JSON-escaped as inside
11
- * a request body. The scrubber replaces longer forms first.
10
+ * (`URLSearchParams`, which spells a space as `+`), JSON-escaped as inside
11
+ * a request body, and JSON-escaped then percent-encoded as a form field sent
12
+ * as JSON text is. The scrubber replaces longer forms first.
12
13
  */
13
14
  export function credentialForms(value: string) {
14
15
  const bare = /^[A-Za-z][\w.-]* +(\S.*)$/.exec(value)?.[1];
16
+ const percentEncoded = (form: string) => [
17
+ encodeURIComponent(form),
18
+ new URLSearchParams({ v: form }).toString().slice(2),
19
+ ];
15
20
  return [
16
21
  ...new Set(
17
- (bare ? [value, bare] : [value]).flatMap((form) => [
18
- form,
19
- encodeURIComponent(form),
20
- new URLSearchParams({ v: form }).toString().slice(2),
21
- JSON.stringify(form).slice(1, -1),
22
- ]),
22
+ (bare ? [value, bare] : [value]).flatMap((form) => {
23
+ const json = JSON.stringify(form).slice(1, -1);
24
+ return [form, ...percentEncoded(form), json, ...percentEncoded(json)];
25
+ }),
23
26
  ),
24
27
  ];
25
28
  }
@@ -31,7 +34,9 @@ export function credentialForms(value: string) {
31
34
  * transcript alike, then the credentials the request presented are
32
35
  * redacted. Dropping first, on the patterns as well, means a credential
33
36
  * an upstream spelled with U+0000 inside it still matches; redacting first
34
- * would let the strip reassemble it.
37
+ * would let the strip reassemble it. A number is matched whole, by value,
38
+ * against each credential read as a number, so a digit credential echoed
39
+ * as a JSON number is redacted even when parsing rounded it.
35
40
  */
36
41
  export function scrubCredentialStrings(value: unknown, credentials: string[]): unknown {
37
42
  const dropU0000 = (text: string) => text.replaceAll("\u0000", "");
@@ -48,6 +53,13 @@ export function scrubCredentialStrings(value: unknown, credentials: string[]): u
48
53
  );
49
54
 
50
55
  if (typeof value === "string") return scrub(value);
56
+ if (typeof value === "number") {
57
+ return ordered.some(
58
+ (credential) => JSON_NUMBER_PATTERN.test(credential) && Number(credential) === value,
59
+ )
60
+ ? REDACTION_MARKER
61
+ : value;
62
+ }
51
63
  if (Array.isArray(value)) {
52
64
  return value.map((item) => scrubCredentialStrings(item, credentials));
53
65
  }
@@ -36,3 +36,10 @@ export const backendErrorSchema = z.object({
36
36
  }),
37
37
  });
38
38
  export type BackendError = z.infer<typeof backendErrorSchema>;
39
+
40
+ /** The envelope a backend answers a refusal in, its detail cut to the length Cortex accepts. */
41
+ export function errorEnvelope(kind: BackendErrorKind, detail: string) {
42
+ return {
43
+ error: { kind, detail: detail.slice(0, BACKEND_ERROR_DETAIL_MAX) },
44
+ } satisfies BackendError;
45
+ }