@m6d/cortex-cli 0.3.0 → 0.5.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,3 +1,4 @@
1
+ import { isRecord } from "../json";
1
2
  import type {
2
3
  DatasetAggregate,
3
4
  DatasetArithmeticOp,
@@ -443,10 +444,10 @@ class Parser {
443
444
  const from = this.identifier("FROM needs a dataset name.");
444
445
  const alias = this.optionalAlias();
445
446
  const joins = this.joins();
446
- const filter = this.peekWord("WHERE") ? (this.take(), this.predicates()) : [];
447
+ const filter = this.optionalWord("WHERE") ? this.predicates() : [];
447
448
  const groupBy = this.groupBy();
448
- const having = this.peekWord("HAVING") ? (this.take(), this.predicates()) : [];
449
- const qualify = this.peekWord("QUALIFY") ? (this.take(), this.predicates()) : [];
449
+ const having = this.optionalWord("HAVING") ? this.predicates() : [];
450
+ const qualify = this.optionalWord("QUALIFY") ? this.predicates() : [];
450
451
  return {
451
452
  from,
452
453
  aliases: alias === undefined ? [] : [alias],
@@ -564,14 +565,11 @@ class Parser {
564
565
  const joins: JoinClause[] = [];
565
566
  for (;;) {
566
567
  const inner = !this.peekWord("LEFT");
567
- if (this.peekWord("INNER") || this.peekWord("LEFT")) {
568
- this.take();
569
- if (this.peekWord("OUTER")) this.take();
568
+ if (this.optionalWord("INNER") || this.optionalWord("LEFT")) {
569
+ this.optionalWord("OUTER");
570
570
  this.expectWord("JOIN", "INNER and LEFT are followed by JOIN.");
571
- } else if (!this.peekWord("JOIN")) {
571
+ } else if (!this.optionalWord("JOIN")) {
572
572
  return joins;
573
- } else {
574
- this.take();
575
573
  }
576
574
  const dataset = this.identifier("JOIN needs a dataset name.");
577
575
  const alias = this.optionalAlias();
@@ -602,8 +600,7 @@ class Parser {
602
600
  this.take();
603
601
  this.expectPunct("(", "OVER is followed by () or (PARTITION BY ...).");
604
602
  let partitionBy: Term[] = [];
605
- if (this.peekWord("PARTITION")) {
606
- this.take();
603
+ if (this.optionalWord("PARTITION")) {
607
604
  this.expectWord("BY", "PARTITION is followed by BY.");
608
605
  partitionBy = this.termList("PARTITION BY takes column names or aliases.");
609
606
  }
@@ -619,7 +616,7 @@ class Parser {
619
616
  }
620
617
  return { fn: base, term: undefined, filter: [] as Predicate[] };
621
618
  }
622
- const distinct = this.peekWord("DISTINCT") ? (this.take(), true) : false;
619
+ const distinct = this.optionalWord("DISTINCT");
623
620
  if (distinct && base !== "count") {
624
621
  throw new DatasetSqlError(
625
622
  `DISTINCT is only valid inside COUNT; ${word}(DISTINCT ...) is not.`,
@@ -636,8 +633,7 @@ class Parser {
636
633
  }
637
634
 
638
635
  private filterClause() {
639
- if (!this.peekWord("FILTER")) return [] as Predicate[];
640
- this.take();
636
+ if (!this.optionalWord("FILTER")) return [] as Predicate[];
641
637
  this.expectPunct("(", "FILTER is written FILTER (WHERE condition).");
642
638
  this.expectWord("WHERE", "FILTER is written FILTER (WHERE condition).");
643
639
  const filter = this.predicates();
@@ -760,16 +756,18 @@ class Parser {
760
756
  } satisfies Term;
761
757
  }
762
758
 
763
- /** `CASE WHEN p THEN v [WHEN ...] [ELSE v] END`; ELSE NULL is the same as no ELSE. */
764
- /** `CASE WHEN p THEN v ... END`, or the simple form `CASE x WHEN v THEN r ... END` where each WHEN compares x with a value. */
759
+ /**
760
+ * `CASE WHEN p THEN v [WHEN ...] [ELSE v] END`, or the simple form
761
+ * `CASE x WHEN v THEN r ... END` where each WHEN compares x with a value.
762
+ * ELSE NULL is the same as no ELSE.
763
+ */
765
764
  private caseTerm() {
766
765
  this.take();
767
766
  const subject = this.peekWord("WHEN")
768
767
  ? undefined
769
768
  : this.term("CASE is followed by WHEN, or by a value to compare.");
770
769
  const branches: { when: Predicate[]; then: Term }[] = [];
771
- while (this.peekWord("WHEN")) {
772
- this.take();
770
+ while (this.optionalWord("WHEN")) {
773
771
  const when = subject ? [this.simpleCaseMatch(subject)] : this.predicates();
774
772
  this.expectWord("THEN", "CASE WHEN condition needs THEN value.");
775
773
  if (this.peekWord("NULL")) {
@@ -788,10 +786,8 @@ class Parser {
788
786
  );
789
787
  }
790
788
  let otherwise: Term | undefined;
791
- if (this.peekWord("ELSE")) {
792
- this.take();
793
- if (this.peekWord("NULL")) this.take();
794
- else otherwise = this.term("ELSE takes a column, a literal or arithmetic.");
789
+ if (this.optionalWord("ELSE") && !this.optionalWord("NULL")) {
790
+ otherwise = this.term("ELSE takes a column, a literal or arithmetic.");
795
791
  }
796
792
  this.expectWord("END", "CASE ... needs END.");
797
793
  return {
@@ -802,31 +798,24 @@ class Parser {
802
798
  }
803
799
 
804
800
  /** `a AND b OR c AND (d OR e)`: AND binds tighter than OR; the result is an AND list whose OR parts are `any` groups. */
805
- // eslint-disable-next-line m6d/no-explicit-return-type -- mutually recursive with predicate through parenthesized groups
806
- private predicates(): Predicate[] {
801
+ private predicates() {
807
802
  const branches = [this.conjunction()];
808
- while (this.peekWord("OR")) {
809
- this.take();
810
- branches.push(this.conjunction());
811
- }
803
+ while (this.optionalWord("OR")) branches.push(this.conjunction());
812
804
  if (branches.length === 1) return branches[0]!;
813
- return [{ kind: "any", of: branches.map((branch) => grouped(branch)) }];
805
+ return [{ kind: "any", of: branches.map((branch) => grouped(branch)) } satisfies Predicate];
814
806
  }
815
807
 
816
808
  private conjunction() {
817
809
  const predicates: Predicate[] = [];
818
810
  do {
819
811
  predicates.push(this.predicate());
820
- } while (this.peekWord("AND") && this.take());
812
+ } while (this.optionalWord("AND"));
821
813
  return predicates;
822
814
  }
823
815
 
824
816
  /** `NOT p`, `(p AND q)`, `RANK() OVER (...) <= n` or a condition. */
825
817
  private predicate(): Predicate {
826
- if (this.peekWord("NOT")) {
827
- this.take();
828
- return { kind: "not", of: this.predicate() };
829
- }
818
+ if (this.optionalWord("NOT")) return { kind: "not", of: this.predicate() };
830
819
  if (this.peekRank()) {
831
820
  const window = this.rankWindow(WINDOW_PLACE);
832
821
  return { kind: "rank", ...window, limit: this.rankLimit("RANK()") };
@@ -858,7 +847,7 @@ class Parser {
858
847
  const left = this.term("A condition starts with a column name.");
859
848
  const shown = describeTerm(left);
860
849
  if (this.peekWord("IS")) return this.nullCheck(left);
861
- const negated = this.peekWord("NOT") ? (this.take(), true) : false;
850
+ const negated = this.optionalWord("NOT");
862
851
  if (this.peekWord("IN")) return this.inList(left, negated);
863
852
  let condition: Predicate;
864
853
  if (this.peekWord("BETWEEN")) condition = this.between(left);
@@ -910,7 +899,7 @@ class Parser {
910
899
 
911
900
  private nullCheck(left: Term) {
912
901
  this.take();
913
- const negated = this.peekWord("NOT") ? (this.take(), true) : false;
902
+ const negated = this.optionalWord("NOT");
914
903
  this.expectWord("NULL", "IS is followed by NULL or NOT NULL.");
915
904
  return { kind: "cond", left, op: negated ? "isNotNull" : "isNull" } satisfies Predicate;
916
905
  }
@@ -954,10 +943,7 @@ class Parser {
954
943
  return { kind: "cond", left, op: "=", value: true } satisfies Predicate;
955
944
  }
956
945
  const operator = this.take();
957
- if (
958
- operator.kind !== "punct" ||
959
- !["=", "!=", "<>", "<", "<=", ">", ">="].includes(operator.value)
960
- ) {
946
+ if (operator.kind !== "punct" || !COMPARISONS.includes(operator.value)) {
961
947
  throw new DatasetSqlError(
962
948
  `"${shown}" needs a comparison: = != < <= > >= IN BETWEEN LIKE IS NULL.`,
963
949
  );
@@ -1099,7 +1085,7 @@ class Parser {
1099
1085
  );
1100
1086
  }
1101
1087
  if (RANK_WORDS.includes(upper)) throw new DatasetSqlError(WINDOW_PLACE);
1102
- if (word.upper === "NULLIF") {
1088
+ if (upper === "NULLIF") {
1103
1089
  throw new DatasetSqlError(
1104
1090
  "NULLIF() is not needed: a division by zero is already null, so write a / b.",
1105
1091
  );
@@ -1108,8 +1094,7 @@ class Parser {
1108
1094
  }
1109
1095
 
1110
1096
  private groupBy() {
1111
- if (!this.peekWord("GROUP")) return [] as Term[];
1112
- this.take();
1097
+ if (!this.optionalWord("GROUP")) return [] as Term[];
1113
1098
  this.expectWord("BY", "GROUP is followed by BY.");
1114
1099
  const items: Term[] = [];
1115
1100
  do {
@@ -1125,8 +1110,7 @@ class Parser {
1125
1110
  }
1126
1111
 
1127
1112
  private orderBy() {
1128
- if (!this.peekWord("ORDER")) return [] as SortList;
1129
- this.take();
1113
+ if (!this.optionalWord("ORDER")) return [] as SortList;
1130
1114
  this.expectWord("BY", "ORDER is followed by BY.");
1131
1115
  return this.sortList();
1132
1116
  }
@@ -1138,17 +1122,15 @@ class Parser {
1138
1122
  if (term.kind === "num") {
1139
1123
  throw new DatasetSqlError("ORDER BY names columns or aliases, not positions.");
1140
1124
  }
1141
- const dir = this.peekWord("DESC")
1142
- ? (this.take(), "desc" as const)
1143
- : (this.peekWord("ASC") && this.take(), "asc" as const);
1144
- items.push({ term, dir });
1125
+ const descending = this.optionalWord("DESC");
1126
+ if (!descending) this.optionalWord("ASC");
1127
+ items.push({ term, dir: descending ? "desc" : "asc" });
1145
1128
  } while (this.optionalPunct(","));
1146
1129
  return items;
1147
1130
  }
1148
1131
 
1149
1132
  private limit() {
1150
- if (!this.peekWord("LIMIT")) return undefined;
1151
- this.take();
1133
+ if (!this.optionalWord("LIMIT")) return undefined;
1152
1134
  const count = this.take();
1153
1135
  if (count.kind !== "number" || !Number.isInteger(count.value) || count.value < 1) {
1154
1136
  throw new DatasetSqlError("LIMIT takes a positive whole number.");
@@ -1157,8 +1139,7 @@ class Parser {
1157
1139
  }
1158
1140
 
1159
1141
  private offset(sorted: boolean) {
1160
- if (!this.peekWord("OFFSET")) return undefined;
1161
- this.take();
1142
+ if (!this.optionalWord("OFFSET")) return undefined;
1162
1143
  const count = this.take();
1163
1144
  if (count.kind !== "number" || !Number.isInteger(count.value) || count.value < 0) {
1164
1145
  throw new DatasetSqlError("OFFSET takes a whole number.");
@@ -1177,8 +1158,7 @@ class Parser {
1177
1158
  if (!isName(token)) throw new DatasetSqlError(message);
1178
1159
  this.take();
1179
1160
  let ref = token.value;
1180
- while (this.peekPunct(".")) {
1181
- this.take();
1161
+ while (this.optionalPunct(".")) {
1182
1162
  const next = this.take();
1183
1163
  if (next.kind !== "word")
1184
1164
  throw new DatasetSqlError(`"${ref}." needs a column name after the dot.`);
@@ -1221,10 +1201,7 @@ class Parser {
1221
1201
  }
1222
1202
 
1223
1203
  private optionalAlias() {
1224
- if (this.peekWord("AS")) {
1225
- this.take();
1226
- return this.identifier("AS needs a name.");
1227
- }
1204
+ if (this.optionalWord("AS")) return this.identifier("AS needs a name.");
1228
1205
  const token = this.peek();
1229
1206
  if (!isName(token)) return undefined;
1230
1207
  this.take();
@@ -1253,6 +1230,11 @@ class Parser {
1253
1230
  const token = this.peek();
1254
1231
  return token.kind === "word" && token.upper === upper;
1255
1232
  }
1233
+ private optionalWord(upper: string) {
1234
+ if (!this.peekWord(upper)) return false;
1235
+ this.take();
1236
+ return true;
1237
+ }
1256
1238
  private peekPunct(value: string, offset = 0) {
1257
1239
  const token = this.peek(offset);
1258
1240
  return token.kind === "punct" && token.value === value;
@@ -1263,16 +1245,12 @@ class Parser {
1263
1245
  return true;
1264
1246
  }
1265
1247
  private expectWord(upper: string, message: string) {
1266
- if (!this.peekWord(upper)) {
1267
- const token = this.peek();
1268
- if (token.kind === "word" && token.upper in REFUSED_CLAUSES) {
1269
- throw new DatasetSqlError(
1270
- REFUSED_CLAUSES[token.upper as keyof typeof REFUSED_CLAUSES],
1271
- );
1272
- }
1273
- throw new DatasetSqlError(message);
1248
+ if (this.optionalWord(upper)) return;
1249
+ const token = this.peek();
1250
+ if (token.kind === "word" && token.upper in REFUSED_CLAUSES) {
1251
+ throw new DatasetSqlError(REFUSED_CLAUSES[token.upper as keyof typeof REFUSED_CLAUSES]);
1274
1252
  }
1275
- this.take();
1253
+ throw new DatasetSqlError(message);
1276
1254
  }
1277
1255
  private expectPunct(value: string, message: string) {
1278
1256
  if (!this.optionalPunct(value)) throw new DatasetSqlError(message);
@@ -1298,13 +1276,13 @@ function likeFilter(shown: string, pattern: string) {
1298
1276
  const COMPARISONS = ["=", "!=", "<>", "<", "<=", ">", ">="];
1299
1277
  const CONDITION_WORDS = ["AND", "OR", "NOT", "IN", "IS", "LIKE", "BETWEEN"];
1300
1278
 
1301
- /** A token that can only appear inside a condition, never inside a value. */
1302
1279
  /** +1 on the opener, -1 on the closer, 0 otherwise; punctuation and words alike. */
1303
1280
  function nesting(token: Token, open: string, close: string) {
1304
1281
  const text = token.kind === "word" ? token.upper : token.kind === "punct" ? token.value : "";
1305
1282
  return text === open ? 1 : text === close ? -1 : 0;
1306
1283
  }
1307
1284
 
1285
+ /** A token that can only appear inside a condition, never inside a value. */
1308
1286
  function isConditionWord(token: Token) {
1309
1287
  if (token.kind === "punct") return COMPARISONS.includes(token.value);
1310
1288
  return token.kind === "word" && CONDITION_WORDS.includes(token.upper);
@@ -1425,7 +1403,7 @@ function predicateRefs(predicate: Predicate): string[] {
1425
1403
  function isTermValue(
1426
1404
  value: Extract<Predicate, { kind: "cond" }>["value"],
1427
1405
  ): value is { term: Term } {
1428
- return typeof value === "object" && value !== null && !Array.isArray(value);
1406
+ return isRecord(value);
1429
1407
  }
1430
1408
 
1431
1409
  /** Whether the term, or any value inside it (a window's operand and conditions included), passes `test`. */
@@ -1517,6 +1495,7 @@ function describeTerm(term: Term): string {
1517
1495
  return "CASE ... END";
1518
1496
  }
1519
1497
  }
1498
+
1520
1499
  /**
1521
1500
  * The JOIN clause(s) that reach a relation, as `describeDataset` prints them
1522
1501
  * and refusals suggest them. Hosts always join left, so that is what is
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { isRecord } from "../json";
2
3
 
3
4
  /*
4
5
  * Datasets: governed tabular reads over a host's own data. A dataset host
@@ -170,11 +171,15 @@ export const datasetNamedOperandSchema = z.object({
170
171
  as: datasetIdentifierSchema,
171
172
  });
172
173
  export type DatasetNamedOperand = z.infer<typeof datasetNamedOperandSchema>;
173
- /** The left side of a condition: a column or arithmetic, never a bare number. */
174
+ /**
175
+ * The left side of a condition: a column or arithmetic, never a bare number.
176
+ * Zod's refinement does not narrow the type, so the cast narrows it to what the
177
+ * refinement checks.
178
+ */
174
179
  const datasetConditionLeftSchema = datasetOperandSchema.refine(
175
180
  (operand) => typeof operand !== "number",
176
181
  "a condition starts with a column",
177
- );
182
+ ) as z.ZodType<DatasetCondition["col"], DatasetCondition["col"]>;
178
183
 
179
184
  export type DatasetCondition = {
180
185
  col: Exclude<DatasetOperand, number>;
@@ -183,10 +188,7 @@ export type DatasetCondition = {
183
188
  value?: string | number | boolean | (string | number | boolean)[] | { col: DatasetOperand };
184
189
  };
185
190
  export const datasetConditionSchema: z.ZodType<DatasetCondition, DatasetCondition> = z.object({
186
- col: datasetConditionLeftSchema as z.ZodType<
187
- Exclude<DatasetOperand, number>,
188
- Exclude<DatasetOperand, number>
189
- >,
191
+ col: datasetConditionLeftSchema,
190
192
  op: z.enum(DATASET_FILTER_OPS),
191
193
  value: z
192
194
  .union([
@@ -227,12 +229,7 @@ const datasetSortSchema = z.object({
227
229
  export const datasetAggregateSchema = z.object({
228
230
  fn: z.enum(DATASET_AGGREGATES),
229
231
  /** The column or arithmetic aggregated; `count` may omit it to count rows. */
230
- col: (
231
- datasetConditionLeftSchema as z.ZodType<
232
- Exclude<DatasetOperand, number>,
233
- Exclude<DatasetOperand, number>
234
- >
235
- )
232
+ col: datasetConditionLeftSchema
236
233
  .optional()
237
234
  .describe("The column or arithmetic to aggregate; omit for count."),
238
235
  as: datasetIdentifierSchema.describe(
@@ -480,25 +477,23 @@ function normalizeDatasetQuery(catalog: DatasetCatalog, query: DatasetQuery) {
480
477
  ...entry,
481
478
  expr: mapOperandRefs(entry.expr, strip),
482
479
  });
483
- const groupBy = query.groupBy.map((group) =>
484
- typeof group === "string" ? strip(group) : stripNamed(group),
485
- );
486
- const aggregate = query.aggregate.map((entry) => ({
480
+ // A window is an aggregate plus its partition, so both strip the same way.
481
+ const stripAggregate = (entry: DatasetQuery["aggregate"][number]) => ({
487
482
  ...entry,
488
483
  ...(entry.col === undefined
489
484
  ? {}
490
485
  : { col: mapOperandRefs(entry.col, strip) as DatasetCondition["col"] }),
491
486
  filter: entry.filter.map(stripFilter),
492
- }));
487
+ });
488
+ const groupBy = query.groupBy.map((group) =>
489
+ typeof group === "string" ? strip(group) : stripNamed(group),
490
+ );
491
+ const aggregate = query.aggregate.map(stripAggregate);
493
492
  const filter = query.filter.map(stripFilter);
494
493
  const having = query.having.map(stripFilter);
495
494
  const compute = query.compute.map(stripNamed);
496
495
  const window = query.window.map((entry) => ({
497
- ...entry,
498
- ...(entry.col === undefined
499
- ? {}
500
- : { col: mapOperandRefs(entry.col, strip) as DatasetCondition["col"] }),
501
- filter: entry.filter.map(stripFilter),
496
+ ...stripAggregate(entry),
502
497
  partitionBy: entry.partitionBy.map(strip),
503
498
  }));
504
499
  const qualify = query.qualify.map(stripFilter);
@@ -593,10 +588,9 @@ function mapFilterRefs(filter: DatasetFilter, fn: (ref: string) => string): Data
593
588
  if ("any" in filter) return { any: filter.any.map((entry) => mapFilterRefs(entry, fn)) };
594
589
  if ("all" in filter) return { all: filter.all.map((entry) => mapFilterRefs(entry, fn)) };
595
590
  if ("not" in filter) return { not: mapFilterRefs(filter.not, fn) };
596
- const value =
597
- typeof filter.value === "object" && filter.value !== null && !Array.isArray(filter.value)
598
- ? { col: mapOperandRefs(filter.value.col, fn) }
599
- : filter.value;
591
+ const value = isColumnValue(filter.value)
592
+ ? { col: mapOperandRefs(filter.value.col, fn) }
593
+ : filter.value;
600
594
  return {
601
595
  col: mapOperandRefs(filter.col, fn) as DatasetCondition["col"],
602
596
  op: filter.op,
@@ -605,7 +599,7 @@ function mapFilterRefs(filter: DatasetFilter, fn: (ref: string) => string): Data
605
599
  }
606
600
 
607
601
  /** Every column reference an operand names. */
608
- export function operandRefs(operand: DatasetOperand): string[] {
602
+ function operandRefs(operand: DatasetOperand): string[] {
609
603
  if (typeof operand === "string") return [operand];
610
604
  if (typeof operand === "number" || "value" in operand) return [];
611
605
  if ("op" in operand) return [...operandRefs(operand.left), ...operandRefs(operand.right)];
@@ -633,7 +627,7 @@ export function filterRefs(filter: DatasetFilter): string[] {
633
627
 
634
628
  /** A condition's right side that names a column or arithmetic rather than a literal. */
635
629
  export function isColumnValue(value: DatasetCondition["value"]): value is { col: DatasetOperand } {
636
- return typeof value === "object" && value !== null && !Array.isArray(value);
630
+ return isRecord(value);
637
631
  }
638
632
 
639
633
  /**
@@ -1099,6 +1093,13 @@ export function describeOperand(operand: DatasetOperand): string {
1099
1093
  return `${operand.fn}(${operand.unit}, ${describeOperand(operand.of)})`;
1100
1094
  }
1101
1095
 
1096
+ /** `optional` counts the trailing arguments a call may leave out. */
1097
+ type FunctionSignature = {
1098
+ args: readonly DatasetColumnType[];
1099
+ optional?: number;
1100
+ answers: DatasetColumnType | undefined;
1101
+ };
1102
+
1102
1103
  /** What each scalar function takes and answers; `coalesce` answers its arguments' shared type. */
1103
1104
  export const FUNCTION_SIGNATURES = {
1104
1105
  lower: { args: ["string"], answers: "string" },
@@ -1108,14 +1109,7 @@ export const FUNCTION_SIGNATURES = {
1108
1109
  abs: { args: ["number"], answers: "number" },
1109
1110
  round: { args: ["number", "number"], optional: 1, answers: "number" },
1110
1111
  coalesce: { args: [], answers: undefined },
1111
- } as const satisfies Record<
1112
- DatasetFunction,
1113
- {
1114
- args: readonly DatasetColumnType[];
1115
- optional?: number;
1116
- answers: DatasetColumnType | undefined;
1117
- }
1118
- >;
1112
+ } as const satisfies Record<DatasetFunction, FunctionSignature>;
1119
1113
 
1120
1114
  /**
1121
1115
  * The type an operand yields: the column's own, number for a literal number
@@ -1192,11 +1186,7 @@ function functionType(
1192
1186
  shown: string,
1193
1187
  expect: (side: DatasetOperand, wanted: DatasetColumnType, what: string) => boolean,
1194
1188
  ) {
1195
- const signature: {
1196
- args: readonly DatasetColumnType[];
1197
- optional?: number;
1198
- answers: DatasetColumnType | undefined;
1199
- } = FUNCTION_SIGNATURES[operand.fn];
1189
+ const signature: FunctionSignature = FUNCTION_SIGNATURES[operand.fn];
1200
1190
  if (operand.fn === "coalesce") {
1201
1191
  if (operand.args.length < 2) {
1202
1192
  check.issue(refError(kind, shown, "coalesce takes two or more values"));
@@ -1224,7 +1214,7 @@ function functionType(
1224
1214
  return fine.every(Boolean) ? signature.answers : undefined;
1225
1215
  }
1226
1216
 
1227
- const COLUMN_COMPARISONS: readonly DatasetFilterOp[] = ["=", "!=", "<", "<=", ">", ">="];
1217
+ const COLUMN_COMPARISONS = new Set<DatasetFilterOp>(["=", "!=", "<", "<=", ">", ">="]);
1228
1218
 
1229
1219
  function checkFilter(check: QueryCheck, filter: DatasetFilter, kind = "filter") {
1230
1220
  if ("not" in filter) {
@@ -1272,7 +1262,7 @@ function columnValueIssue(
1272
1262
  op: DatasetFilterOp,
1273
1263
  right: DatasetOperand,
1274
1264
  ) {
1275
- if (!COLUMN_COMPARISONS.includes(op)) return `${op} takes a literal, not a column`;
1265
+ if (!COLUMN_COMPARISONS.has(op)) return `${op} takes a literal, not a column`;
1276
1266
  const rightType = operandType(check, kind, right);
1277
1267
  return rightType && rightType !== type ? `compares a ${type} with a ${rightType}` : undefined;
1278
1268
  }
@@ -10,8 +10,12 @@ import { LOCALES, TOOL_SURFACES, type Greeting, type Suggestions } from "../wire
10
10
  export const AGENT_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
11
11
  export const agentSlugSchema = z.string().regex(AGENT_SLUG_PATTERN);
12
12
 
13
- /** Tool names become `tools.<name>()` sandbox bindings on the server. */
14
- export const TOOL_NAME_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
13
+ /**
14
+ * An ASCII JavaScript identifier: what a tool name (a `tools.<name>()` sandbox
15
+ * binding on the server) and a variable name must be, and what a rendered
16
+ * signature prints bare rather than quoted.
17
+ */
18
+ export const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
15
19
 
16
20
  /** Consumer-facing slugs: service keys, skill names, and trigger names share it. */
17
21
  export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
@@ -29,10 +33,7 @@ export const sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/);
29
33
  * prompts and as tool input sources alike.
30
34
  */
31
35
  export const variableDeclarationSchema = z.object({
32
- name: z
33
- .string()
34
- .regex(/^[A-Za-z_$][A-Za-z0-9_$]*$/)
35
- .max(64),
36
+ name: z.string().regex(IDENTIFIER_PATTERN).max(64),
36
37
  description: z.string().max(500),
37
38
  example: z.string().max(200),
38
39
  /** Computed by the SDK per request (`utcTime`, `locale`); no session value can set it. */
@@ -46,7 +47,7 @@ export const boundVariablesSchema = z.array(variableDeclarationSchema.shape.name
46
47
  /** A sandbox binding a piece of rich text mentions, so the turn can register it before the model reads the text. */
47
48
  export const mentionedToolSchema = z.object({
48
49
  toolId: z.uuid(),
49
- name: z.string().regex(TOOL_NAME_PATTERN),
50
+ name: z.string().regex(IDENTIFIER_PATTERN),
50
51
  readOnly: z.boolean(),
51
52
  /** A tool that parks for approval is never a sandbox binding, mentioned or not. */
52
53
  requiresApproval: z.boolean(),
@@ -106,14 +107,14 @@ export const suggestionsSchema = z.record(
106
107
  .max(SUGGESTION_LIMITS.perLocale),
107
108
  ) satisfies z.ZodType<Suggestions>;
108
109
 
110
+ /** One string per locale, at most 500 characters each; author copy the widget shows as is. */
111
+ export const localizedTextSchema = z.record(localeSchema, z.string().max(500));
112
+
113
+ export const GREETING_MAX_LENGTH = 200;
109
114
  /**
110
115
  * The blank-chat landing's headline per locale. Empty means the widget's
111
116
  * built-in default for that locale; like suggestions, served verbatim.
112
117
  */
113
- export const GREETING_MAX_LENGTH = 200;
114
- /** One string per locale, at most 500 characters each; author copy the widget shows as is. */
115
- export const localizedTextSchema = z.record(localeSchema, z.string().max(500));
116
-
117
118
  export const greetingSchema = z.record(
118
119
  localeSchema,
119
120
  z
@@ -1,3 +1,6 @@
1
+ import { isRecord } from "../json";
2
+ import { IDENTIFIER_PATTERN } from "./grammar";
3
+
1
4
  /*
2
5
  * The one rendering of a dynamic tool the model sees, whichever side
3
6
  * produced it: Control Center renders its registry tools at resolve time,
@@ -11,14 +14,8 @@
11
14
  /** Past this nesting an object prints as `object`: deeper detail costs more prompt than it earns. */
12
15
  const MAX_DEPTH = 3;
13
16
 
14
- const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
15
-
16
- function isRecord(value: unknown): value is Record<string, unknown> {
17
- return typeof value === "object" && value !== null && !Array.isArray(value);
18
- }
19
-
20
17
  function propertyName(name: string) {
21
- return IDENTIFIER.test(name) ? name : JSON.stringify(name);
18
+ return IDENTIFIER_PATTERN.test(name) ? name : JSON.stringify(name);
22
19
  }
23
20
 
24
21
  function compactLine(value: string) {
@@ -248,7 +245,9 @@ export type ToolSignatureInput = {
248
245
  /** `/** summary *\/\nname(input: {...}): { fields }`. */
249
246
  export function renderToolSignature(tool: ToolSignatureInput) {
250
247
  const summary = [summarize(tool.description), ...(tool.notes ?? [])].filter(Boolean).join(" ");
251
- const callee = IDENTIFIER.test(tool.name) ? tool.name : `tools[${JSON.stringify(tool.name)}]`;
248
+ const callee = IDENTIFIER_PATTERN.test(tool.name)
249
+ ? tool.name
250
+ : `tools[${JSON.stringify(tool.name)}]`;
252
251
  const returns =
253
252
  typeof tool.returns === "string" ? tool.returns : responseShape(tool.returns.schema);
254
253
  const input = inputType(tool.inputSchema, resolver(tool.inputSchema));
@@ -32,9 +32,11 @@ export const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
32
32
  export const MAX_ATTACHMENTS_PER_MESSAGE = 5;
33
33
 
34
34
  export const ATTACHMENT_STATUSES = ["pending", "described", "failed", "skipped"] as const;
35
+ export type AttachmentStatus = (typeof ATTACHMENT_STATUSES)[number];
35
36
 
36
37
  /** Who put the file in the thread: the user through the composer, or a tool mid-turn. */
37
38
  export const ATTACHMENT_ORIGINS = ["upload", "tool"] as const;
39
+ export type AttachmentOrigin = (typeof ATTACHMENT_ORIGINS)[number];
38
40
 
39
41
  export function checkAttachmentPolicy(contentType: string, sizeBytes: number) {
40
42
  if (!ATTACHMENT_MIME_TYPES.some((type) => type === contentType)) return "type";
@@ -42,10 +44,6 @@ export function checkAttachmentPolicy(contentType: string, sizeBytes: number) {
42
44
  return "ok";
43
45
  }
44
46
 
45
- export type AttachmentStatus = (typeof ATTACHMENT_STATUSES)[number];
46
-
47
- export type AttachmentOrigin = (typeof ATTACHMENT_ORIGINS)[number];
48
-
49
47
  export type AttachmentSummary = {
50
48
  id: string;
51
49
  filename: string;
@@ -190,7 +188,7 @@ export function toUiMessage<TPart>(message: CortexMessage<TPart>) {
190
188
  * parked client tool calls on a continuation request. The server owns the
191
189
  * transcript (a continuation sends `messages: []`), so the answered assistant
192
190
  * message itself never crosses the wire — the SDK's park-boundary snapshot
193
- * does not preserve its id, and round-tripping it duplicated the stored
191
+ * does not preserve its id, and round-tripping it would duplicate the stored
194
192
  * message.
195
193
  */
196
194
  export const CLIENT_TOOL_ANSWERS_KEY = "clientToolAnswers";
@@ -206,6 +204,15 @@ export type ClientToolAnswer = {
206
204
  state: "complete" | "error" | "approved" | "denied";
207
205
  };
208
206
 
207
+ /**
208
+ * The id a parked tool call's decision is filed under: the one on its
209
+ * approval record, or `approval_<toolCallId>`, which is how the SDK names
210
+ * every approval it requests, for a part that carries no record.
211
+ */
212
+ export function approvalIdOf(part: { id: string; approval?: { id: string } }) {
213
+ return part.approval?.id ?? `approval_${part.id}`;
214
+ }
215
+
209
216
  /**
210
217
  * Response of `POST /chat/:threadId/tools/:toolCallId/initiate`. `embed:
211
218
  * false` means the pending call is not a CC client tool — the widget