@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.
- package/README.md +43 -19
- package/package.json +3 -13
- package/src/commands/apply.ts +171 -38
- package/src/commands/management.ts +73 -20
- package/src/commands/new.ts +8 -10
- package/src/commands/setup.ts +77 -37
- package/src/contracts/datasets/dataset-sql.ts +58 -7
- package/src/contracts/datasets/datasets.ts +220 -195
- package/src/contracts/egress-policy/scrub.ts +22 -10
- package/src/contracts/errors/index.ts +7 -0
- package/src/contracts/json/index.ts +45 -1
- package/src/contracts/manifest/index.ts +506 -134
- package/src/contracts/server/index.ts +45 -56
- package/src/contracts/triggers/index.ts +13 -9
- package/src/contracts/wire/index.ts +153 -18
- package/src/management/client.ts +79 -117
- package/src/management/profiles.ts +11 -22
- package/src/scaffold/files.ts +11 -9
- package/src/ui/prompts.ts +38 -8
- 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 -18
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
58
|
-
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),
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
428
|
-
* 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
|
|
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,
|
|
628
|
-
*
|
|
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
|
-
|
|
675
|
-
? {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
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
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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 `+`),
|
|
11
|
-
* a request body
|
|
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
|
-
|
|
20
|
-
|
|
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
|
+
}
|