@m6d/cortex-cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cc/client.ts +58 -90
- package/src/cc/flags.ts +8 -11
- package/src/cc/output.ts +3 -6
- package/src/cc/profiles.ts +8 -11
- package/src/commands/cc.ts +18 -11
- package/src/commands/graph.ts +17 -18
- package/src/commands/new.ts +5 -6
- package/src/commands/swagger.ts +35 -49
- package/src/config/load.ts +6 -6
- package/src/config/validate.ts +2 -2
- package/src/contracts/charts/chart-spec.ts +9 -6
- package/src/contracts/credentials/index.ts +22 -5
- package/src/contracts/egress/index.ts +8 -47
- package/src/contracts/egress/scrub.ts +3 -1
- package/src/contracts/egress/url.ts +101 -9
- package/src/contracts/graph/helpers.ts +8 -10
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/runtime/api.ts +2 -2
- package/src/contracts/runtime/dataset-sql.ts +49 -70
- package/src/contracts/runtime/datasets.ts +33 -43
- package/src/contracts/runtime/grammar.ts +12 -11
- package/src/contracts/runtime/signature.ts +7 -8
- package/src/contracts/wire/index.ts +12 -5
- package/src/graph/expand-domains.ts +7 -9
- package/src/graph/generate-cypher.ts +1 -1
- package/src/graph/seed.ts +6 -14
- package/src/scaffold/files.ts +19 -25
- package/src/swagger/extract-endpoints.ts +79 -76
- package/src/swagger/openapi-schema.ts +21 -14
- package/src/swagger/parse-response.ts +1 -1
- package/src/ui/report.ts +1 -1
|
@@ -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.
|
|
447
|
+
const filter = this.optionalWord("WHERE") ? this.predicates() : [];
|
|
447
448
|
const groupBy = this.groupBy();
|
|
448
|
-
const having = this.
|
|
449
|
-
const qualify = this.
|
|
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.
|
|
568
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
764
|
-
|
|
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.
|
|
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.
|
|
792
|
-
this.
|
|
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
|
-
|
|
806
|
-
private predicates(): Predicate[] {
|
|
801
|
+
private predicates() {
|
|
807
802
|
const branches = [this.conjunction()];
|
|
808
|
-
while (this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
|
1142
|
-
|
|
1143
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
14
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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
|