@m6d/cortex-cli 0.15.0 → 0.17.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/README.md +45 -28
- package/package.json +3 -13
- package/src/commands/apply.ts +61 -38
- package/src/commands/management.ts +64 -19
- package/src/commands/new.ts +8 -10
- package/src/commands/setup.ts +83 -38
- package/src/contracts/datasets/dataset-sql.ts +36 -2
- package/src/contracts/datasets/datasets.ts +84 -182
- package/src/contracts/egress-policy/scrub.ts +11 -2
- package/src/contracts/errors/index.ts +7 -0
- package/src/contracts/json/index.ts +40 -17
- package/src/contracts/manifest/index.ts +601 -168
- package/src/contracts/server/index.ts +44 -53
- package/src/contracts/triggers/index.ts +13 -9
- package/src/contracts/wire/index.ts +149 -27
- package/src/management/client.ts +79 -117
- package/src/management/profiles.ts +11 -22
- package/src/scaffold/files.ts +13 -11
- package/src/ui/prompts.ts +30 -9
- package/src/contracts/charts/chart-spec.ts +0 -217
- package/src/contracts/charts/index.ts +0 -8
- package/src/contracts/datasets/datasets.fixtures.json +0 -2849
- package/src/contracts/markdown/index.ts +0 -47
- package/src/contracts/pattern/index.ts +0 -276
- package/src/contracts/server/reserved-names.fixtures.json +0 -16
|
@@ -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,6 +25,16 @@ 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
|
+
/** 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
|
+
|
|
28
38
|
/**
|
|
29
39
|
* One column. `values`, on a string column, lists every value it can hold:
|
|
30
40
|
* the agent reads them, and a query comparing the column to anything else is
|
|
@@ -34,8 +44,12 @@ export const datasetColumnSchema = z
|
|
|
34
44
|
.object({
|
|
35
45
|
name: datasetIdentifierSchema,
|
|
36
46
|
type: z.enum(DATASET_COLUMN_TYPES),
|
|
37
|
-
description: z.string().max(
|
|
38
|
-
values: z
|
|
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(),
|
|
39
53
|
})
|
|
40
54
|
.refine((column) => column.values === undefined || column.type === "string", {
|
|
41
55
|
message: "only a string column lists its values",
|
|
@@ -65,11 +79,16 @@ export const DATASET_MAX_ROWS_DEFAULT = 500;
|
|
|
65
79
|
export const datasetDefinitionSchema = z.object({
|
|
66
80
|
name: datasetIdentifierSchema,
|
|
67
81
|
/** What the model reads in the catalog; the columns come only on request. */
|
|
68
|
-
description: z.string().max(
|
|
69
|
-
columns: z.array(datasetColumnSchema).min(1).max(
|
|
82
|
+
description: z.string().max(DATASET_DEFINITION_LIMITS.description),
|
|
83
|
+
columns: z.array(datasetColumnSchema).min(1).max(DATASET_DEFINITION_LIMITS.columns),
|
|
70
84
|
relations: z.record(datasetIdentifierSchema, datasetRelationSchema).default({}),
|
|
71
85
|
/** The most rows one query may answer with; the host clamps to it whatever the caller sent. */
|
|
72
|
-
maxRows: z
|
|
86
|
+
maxRows: z
|
|
87
|
+
.number()
|
|
88
|
+
.int()
|
|
89
|
+
.min(1)
|
|
90
|
+
.max(DATASET_DEFINITION_LIMITS.maxRows)
|
|
91
|
+
.default(DATASET_MAX_ROWS_DEFAULT),
|
|
73
92
|
});
|
|
74
93
|
export type DatasetDefinition = z.infer<typeof datasetDefinitionSchema>;
|
|
75
94
|
export type DatasetDefinitionInput = z.input<typeof datasetDefinitionSchema>;
|
|
@@ -219,19 +238,22 @@ export const datasetConditionSchema: z.ZodType<DatasetCondition, DatasetConditio
|
|
|
219
238
|
* A condition, a group of filters that must all hold (`all`) or of which one
|
|
220
239
|
* must hold (`any`), or the negation of one (`not`, with SQL's null
|
|
221
240
|
* semantics: a row whose comparison is unknown is left out either way). A
|
|
222
|
-
* 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.
|
|
223
243
|
*/
|
|
224
244
|
export type DatasetFilter =
|
|
225
245
|
| DatasetCondition
|
|
226
246
|
| { any: DatasetFilter[] }
|
|
227
247
|
| { all: DatasetFilter[] }
|
|
228
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() };
|
|
229
251
|
export const datasetFilterSchema: z.ZodType<DatasetFilter, DatasetFilter> = z.lazy(() =>
|
|
230
252
|
z.union([
|
|
231
253
|
datasetConditionSchema,
|
|
232
|
-
z.object({ any: z.array(datasetFilterSchema).min(1).max(20) }),
|
|
233
|
-
z.object({ all: z.array(datasetFilterSchema).min(1).max(20) }),
|
|
234
|
-
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 }),
|
|
235
257
|
]),
|
|
236
258
|
);
|
|
237
259
|
|
|
@@ -327,13 +349,18 @@ const datasetQueryObjectSchema = z.object({
|
|
|
327
349
|
.describe(
|
|
328
350
|
"By a column or select alias, a group key, an aggregate alias or a computed alias.",
|
|
329
351
|
),
|
|
330
|
-
|
|
352
|
+
/** A 32-bit integer, as `offset` is; the dataset's `maxRows` caps it. */
|
|
353
|
+
limit: z.int32().min(1).optional(),
|
|
331
354
|
/** Rows to skip before `limit`; only with a `sort`, since the row order is undefined without one. */
|
|
332
|
-
offset: z.
|
|
355
|
+
offset: z.int32().min(0).optional(),
|
|
333
356
|
});
|
|
334
357
|
|
|
335
358
|
/** How deep a query's JSON may nest, objects and arrays alike; far past what a statement needs. */
|
|
336
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;
|
|
337
364
|
|
|
338
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. */
|
|
339
366
|
function nestsWithin(value: unknown, budget: number): boolean {
|
|
@@ -352,12 +379,19 @@ export const datasetQuerySchema = z
|
|
|
352
379
|
.pipe(datasetQueryObjectSchema);
|
|
353
380
|
export type DatasetQuery = z.infer<typeof datasetQuerySchema>;
|
|
354
381
|
|
|
355
|
-
/**
|
|
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
|
+
*/
|
|
356
389
|
export const datasetRequestSchema = z.object({
|
|
357
390
|
query: datasetQuerySchema,
|
|
358
391
|
context: z.object({
|
|
359
392
|
threadId: z.string().max(128),
|
|
360
393
|
requestId: z.string().max(128).optional(),
|
|
394
|
+
timeZone: z.string().max(64).optional(),
|
|
361
395
|
}),
|
|
362
396
|
});
|
|
363
397
|
export type DatasetRequest = z.infer<typeof datasetRequestSchema>;
|
|
@@ -484,8 +518,9 @@ function filterValueIssue(filter: DatasetCondition) {
|
|
|
484
518
|
* The query as the host runs it: habits the model brings from SQL are
|
|
485
519
|
* absorbed here rather than refused. A reference prefixed with the dataset's
|
|
486
520
|
* own name (`orders.total` on `orders`) drops the prefix when no relation
|
|
487
|
-
* has that name; a relation
|
|
488
|
-
* listed
|
|
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
|
|
489
524
|
* dropped, and an ungrouped query without one selects the definition's
|
|
490
525
|
* columns. Every host receives this shape, so all of them agree on it.
|
|
491
526
|
*/
|
|
@@ -684,8 +719,9 @@ export function boundDatasetResult(
|
|
|
684
719
|
/**
|
|
685
720
|
* Every way a query can be wrong given the catalog, as messages the model
|
|
686
721
|
* can act on; an empty list means the host will understand it. Checked
|
|
687
|
-
* before anything leaves Cortex,
|
|
688
|
-
*
|
|
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.
|
|
689
725
|
*/
|
|
690
726
|
export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuery) {
|
|
691
727
|
if (!catalog.has(query.from)) return [`Unknown dataset "${query.from}"`];
|
|
@@ -716,7 +752,11 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
|
|
|
716
752
|
for (const filter of query.filter) checkFilter(check, filter);
|
|
717
753
|
const row = checkAnsweredRow(check, catalog, query);
|
|
718
754
|
checkFinishedRow(check, query, row);
|
|
719
|
-
|
|
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
|
+
}
|
|
720
760
|
return issues;
|
|
721
761
|
}
|
|
722
762
|
|
|
@@ -757,173 +797,35 @@ function checkAnsweredRow(check: QueryCheck, catalog: DatasetCatalog, query: Dat
|
|
|
757
797
|
};
|
|
758
798
|
}
|
|
759
799
|
|
|
760
|
-
/** How large any one expression a host translates may grow once every alias it names is written out in place. */
|
|
761
|
-
export const DATASET_EXPANSION_BUDGET = 2_000;
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* Hosts write a computed alias out again wherever a later calculation or
|
|
765
|
-
* HAVING names it, as SQL requires, so ten aliases each naming the previous
|
|
766
|
-
* one ten times grow to 10^10 nodes from a few hundred bytes, with or without
|
|
767
|
-
* a column among them. Each alias costs what it expands to, worked out once in
|
|
768
|
-
* order, so the expansion is measured without being performed: a named select
|
|
769
|
-
* value or group key, an aggregate and a window cost their own trees wherever
|
|
770
|
-
* they are named, since hosts write them out. Every expression a host
|
|
771
|
-
* translates is held to the budget: select values, group keys, WHERE,
|
|
772
|
-
* aggregates and their filters, windows, calculations, HAVING and QUALIFY.
|
|
773
|
-
* Sort and rank name values and add none. A window is a correlated subquery
|
|
774
|
-
* that carries the row it counts over, so it also costs that row: WHERE and
|
|
775
|
-
* every value the row projects (`columnCount` of them for a plain query with
|
|
776
|
-
* no select).
|
|
777
|
-
*/
|
|
778
|
-
function checkExpansion(check: QueryCheck, query: DatasetQuery, columnCount: number) {
|
|
779
|
-
// The source row: what WHERE, select values, group keys and aggregates read, each column once.
|
|
780
|
-
const source = new Map<string, number>();
|
|
781
|
-
const named = [...(query.select ?? []), ...query.groupBy].flatMap((entry) =>
|
|
782
|
-
typeof entry === "string" ? [] : [[entry.as, operandCost(entry.expr, source)] as const],
|
|
783
|
-
);
|
|
784
|
-
const where = query.filter.map((filter) => filterCost(filter, source));
|
|
785
|
-
const costs = [...named.map(([, total]) => total), ...where];
|
|
786
|
-
const expanded = new Map(named);
|
|
787
|
-
const aggregates = query.aggregate.map((entry) => {
|
|
788
|
-
const parts = [
|
|
789
|
-
...(entry.col === undefined ? [] : [operandCost(entry.col, source)]),
|
|
790
|
-
...entry.filter.map((filter) => filterCost(filter, source)),
|
|
791
|
-
];
|
|
792
|
-
costs.push(...parts);
|
|
793
|
-
expanded.set(entry.as, nodeCost(parts));
|
|
794
|
-
return nodeCost(parts);
|
|
795
|
-
});
|
|
796
|
-
const grouped = query.groupBy.length > 0 || query.aggregate.length > 0;
|
|
797
|
-
const projected = grouped ? query.groupBy : (query.select ?? []);
|
|
798
|
-
const carried = Math.min(
|
|
799
|
-
DATASET_EXPANSION_BUDGET + 1,
|
|
800
|
-
[
|
|
801
|
-
...where,
|
|
802
|
-
...projected.map((entry) =>
|
|
803
|
-
typeof entry === "string" ? 1 : (expanded.get(entry.as) ?? 1),
|
|
804
|
-
),
|
|
805
|
-
...(grouped || projected.length ? [] : Array<number>(columnCount).fill(1)),
|
|
806
|
-
...aggregates,
|
|
807
|
-
].reduce((total, cost) => total + cost, 0),
|
|
808
|
-
);
|
|
809
|
-
// Every window reads the row as it was before any window, its partition and the row it carries included.
|
|
810
|
-
const windows = query.window.map((entry) => {
|
|
811
|
-
const total = nodeCost([
|
|
812
|
-
...(entry.col === undefined ? [] : [operandCost(entry.col, expanded)]),
|
|
813
|
-
...entry.filter.map((filter) => filterCost(filter, expanded)),
|
|
814
|
-
...entry.partitionBy.map((name) => operandCost(name, expanded)),
|
|
815
|
-
carried,
|
|
816
|
-
]);
|
|
817
|
-
costs.push(total);
|
|
818
|
-
return [entry.as, total] as const;
|
|
819
|
-
});
|
|
820
|
-
for (const [as, total] of windows) expanded.set(as, total);
|
|
821
|
-
for (const entry of query.compute) {
|
|
822
|
-
const total = operandCost(entry.expr, expanded);
|
|
823
|
-
expanded.set(entry.as, total);
|
|
824
|
-
costs.push(total);
|
|
825
|
-
}
|
|
826
|
-
for (const filter of query.having) costs.push(filterCost(filter, expanded));
|
|
827
|
-
// A grouped row is materialized before QUALIFY reads it; without group keys the
|
|
828
|
-
// .NET host writes the calculations QUALIFY names out in full.
|
|
829
|
-
const qualifying = query.groupBy.length ? source : expanded;
|
|
830
|
-
for (const filter of query.qualify) costs.push(filterCost(filter, qualifying));
|
|
831
|
-
if (costs.some((total) => total > DATASET_EXPANSION_BUDGET)) {
|
|
832
|
-
check.issue(
|
|
833
|
-
`Computed aliases expand to more than ${DATASET_EXPANSION_BUDGET} references; name intermediate results and reuse them less`,
|
|
834
|
-
);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/**
|
|
839
|
-
* How many times a host writes one part out when it spells the node holding
|
|
840
|
-
* it, the most either host does; every other part is written once. A divisor
|
|
841
|
-
* is tested for zero before the division, ROUND's digits scale the value
|
|
842
|
-
* before and after, and LENGTH's text is tested for NULL besides measured. A
|
|
843
|
-
* date is read once per part a truncation or part needs (Zeller's congruence
|
|
844
|
-
* for the week and weekday), and a truncation other than the week, packed as
|
|
845
|
-
* a number, is read 3 more times when it meets a real date in a COALESCE, a
|
|
846
|
-
* CASE or a comparison, so those factors are the product. A condition guards
|
|
847
|
-
* its sides against NULL, and a negated condition with no opposite operator
|
|
848
|
-
* (`between`, the text matches) tests its left side once more.
|
|
849
|
-
*/
|
|
850
|
-
const EXPANSION_WEIGHTS = {
|
|
851
|
-
divisor: 2,
|
|
852
|
-
roundDigits: 2,
|
|
853
|
-
lengthText: 2,
|
|
854
|
-
conditionLeft: 3,
|
|
855
|
-
conditionRight: 2,
|
|
856
|
-
dateTrunc: { day: 9, month: 6, quarter: 6, year: 3, week: 17 },
|
|
857
|
-
datePart: { day: 1, month: 1, quarter: 1, year: 1, dayOfWeek: 16, week: 17 },
|
|
858
|
-
} as const;
|
|
859
|
-
|
|
860
|
-
/** One node plus its children's costs, held at one past the budget so no sum can overflow. */
|
|
861
|
-
function nodeCost(children: number[]) {
|
|
862
|
-
return Math.min(
|
|
863
|
-
DATASET_EXPANSION_BUDGET + 1,
|
|
864
|
-
children.reduce((total, cost) => total + cost, 1),
|
|
865
|
-
);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
/** How often a scalar function's argument is written out. */
|
|
869
|
-
function argumentWeight(fn: DatasetFunction, index: number) {
|
|
870
|
-
if (fn === "length") return EXPANSION_WEIGHTS.lengthText;
|
|
871
|
-
return fn === "round" && index === 1 ? EXPANSION_WEIGHTS.roundDigits : 1;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
800
|
/**
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
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.
|
|
879
806
|
*/
|
|
880
|
-
function
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
}
|
|
890
|
-
if ("case" in operand) {
|
|
891
|
-
return nodeCost([
|
|
892
|
-
...operand.case.map((branch) =>
|
|
893
|
-
nodeCost([filterCost(branch.when, expanded), operandCost(branch.then, expanded)]),
|
|
894
|
-
),
|
|
895
|
-
...(operand.else === undefined ? [] : [operandCost(operand.else, expanded)]),
|
|
896
|
-
]);
|
|
897
|
-
}
|
|
898
|
-
if ("args" in operand) {
|
|
899
|
-
return nodeCost(
|
|
900
|
-
operand.args.map(
|
|
901
|
-
(arg, index) => operandCost(arg, expanded) * argumentWeight(operand.fn, index),
|
|
902
|
-
),
|
|
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,
|
|
903
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;
|
|
904
822
|
}
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
/** 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. */
|
|
913
|
-
function filterCost(filter: DatasetFilter, expanded: ReadonlyMap<string, number>): number {
|
|
914
|
-
if ("not" in filter) return nodeCost([filterCost(filter.not, expanded)]);
|
|
915
|
-
if ("any" in filter || "all" in filter) {
|
|
916
|
-
const entries = "any" in filter ? filter.any : filter.all;
|
|
917
|
-
return nodeCost(entries.map((entry) => filterCost(entry, expanded)));
|
|
918
|
-
}
|
|
919
|
-
const right = isColumnValue(filter.value)
|
|
920
|
-
? operandCost(filter.value.col, expanded) * EXPANSION_WEIGHTS.conditionRight
|
|
921
|
-
: Array.isArray(filter.value)
|
|
922
|
-
? filter.value.length
|
|
923
|
-
: filter.value === undefined
|
|
924
|
-
? 0
|
|
925
|
-
: 1;
|
|
926
|
-
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);
|
|
927
829
|
}
|
|
928
830
|
|
|
929
831
|
/**
|
|
@@ -1341,7 +1243,7 @@ function filterValueTypeIssue(type: DatasetColumnType, filter: DatasetCondition)
|
|
|
1341
1243
|
return (
|
|
1342
1244
|
typeof value === "number" ||
|
|
1343
1245
|
(typeof value === "string" &&
|
|
1344
|
-
|
|
1246
|
+
JSON_NUMBER_PATTERN.test(value) &&
|
|
1345
1247
|
Number.isFinite(Number(value)))
|
|
1346
1248
|
);
|
|
1347
1249
|
case "date":
|
|
@@ -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
|
|
|
@@ -34,7 +34,9 @@ export function credentialForms(value: string) {
|
|
|
34
34
|
* transcript alike, then the credentials the request presented are
|
|
35
35
|
* redacted. Dropping first, on the patterns as well, means a credential
|
|
36
36
|
* an upstream spelled with U+0000 inside it still matches; redacting first
|
|
37
|
-
* 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.
|
|
38
40
|
*/
|
|
39
41
|
export function scrubCredentialStrings(value: unknown, credentials: string[]): unknown {
|
|
40
42
|
const dropU0000 = (text: string) => text.replaceAll("\u0000", "");
|
|
@@ -51,6 +53,13 @@ export function scrubCredentialStrings(value: unknown, credentials: string[]): u
|
|
|
51
53
|
);
|
|
52
54
|
|
|
53
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
|
+
}
|
|
54
63
|
if (Array.isArray(value)) {
|
|
55
64
|
return value.map((item) => scrubCredentialStrings(item, credentials));
|
|
56
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
|
+
}
|
|
@@ -21,27 +21,50 @@ export function escapePointerToken(token: string) {
|
|
|
21
21
|
return token.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
function unescapePointerToken(token: string) {
|
|
25
25
|
return token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} else if (isRecord(current) && Object.hasOwn(current, key)) {
|
|
41
|
-
current = current[key];
|
|
42
|
-
} else {
|
|
28
|
+
/**
|
|
29
|
+
* The reference tokens of a JSON Pointer, bare (`/a/b`) or as a URI fragment (`#/a/b`, which
|
|
30
|
+
* is percent-decoded before it splits, as RFC 6901 section 6 has it); `[]` names the whole
|
|
31
|
+
* document. Undefined for text that is neither, or a fragment whose percent-encoding is
|
|
32
|
+
* malformed.
|
|
33
|
+
*/
|
|
34
|
+
export function pointerTokens(pointer: string) {
|
|
35
|
+
let path = pointer;
|
|
36
|
+
if (pointer.startsWith("#")) {
|
|
37
|
+
try {
|
|
38
|
+
path = decodeURIComponent(pointer.slice(1));
|
|
39
|
+
} catch {
|
|
43
40
|
return undefined;
|
|
44
41
|
}
|
|
45
42
|
}
|
|
46
|
-
return
|
|
43
|
+
if (path === "") return [];
|
|
44
|
+
if (!path.startsWith("/")) return undefined;
|
|
45
|
+
return path.slice(1).split("/").map(unescapePointerToken);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A JSON Pointer token that names an array index: digits with no leading zero. */
|
|
49
|
+
export const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/;
|
|
50
|
+
|
|
51
|
+
/** A number spelled as JSON writes one: decimal, in ASCII digits so every regex engine reads the pattern alike. */
|
|
52
|
+
export const JSON_NUMBER_PATTERN = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The value one reference token names under `container`: an own key of an object, or an
|
|
56
|
+
* `ARRAY_INDEX` of an array; undefined when there is none, so an inherited key such as
|
|
57
|
+
* `constructor` never resolves.
|
|
58
|
+
*/
|
|
59
|
+
export function pointerChild(container: unknown, token: string) {
|
|
60
|
+
if (Array.isArray(container)) {
|
|
61
|
+
const items: unknown[] = container;
|
|
62
|
+
return ARRAY_INDEX.test(token) ? items[Number(token)] : undefined;
|
|
63
|
+
}
|
|
64
|
+
return isRecord(container) && Object.hasOwn(container, token) ? container[token] : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The value a JSON Pointer names in `document`, one `pointerChild` per token; undefined when it names nothing. */
|
|
68
|
+
export function readJsonPointer(document: unknown, pointer: string) {
|
|
69
|
+
return pointerTokens(pointer)?.reduce<unknown>(pointerChild, document);
|
|
47
70
|
}
|