@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.
@@ -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(200).default(""),
38
- values: z.array(z.string().min(1).max(200)).min(1).max(100).optional(),
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(500),
69
- columns: z.array(datasetColumnSchema).min(1).max(200),
82
+ description: z.string().max(DATASET_DEFINITION_LIMITS.description),
83
+ columns: z.array(datasetColumnSchema).min(1).max(DATASET_DEFINITION_LIMITS.columns),
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.number().int().min(1).max(5000).default(DATASET_MAX_ROWS_DEFAULT),
86
+ maxRows: z
87
+ .number()
88
+ .int()
89
+ .min(1)
90
+ .max(DATASET_DEFINITION_LIMITS.maxRows)
91
+ .default(DATASET_MAX_ROWS_DEFAULT),
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
- limit: z.number().int().min(1).optional(),
352
+ /** A 32-bit integer, as `offset` is; the dataset's `maxRows` caps it. */
353
+ limit: z.int32().min(1).optional(),
331
354
  /** Rows to skip before `limit`; only with a `sort`, since the row order is undefined without one. */
332
- offset: z.number().int().min(0).optional(),
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
- /** What `POST {host}/{name}` receives: the validated query, capped, plus what the host may want in its own logs. */
382
+ /**
383
+ * What `POST {host}/{name}` receives: the validated query, capped, plus what
384
+ * the host may want in its own logs, and the IANA zone of the user the query
385
+ * runs for. A host reads that zone's calendar into the query: a bare date
386
+ * names its midnight there, a period or part of an instant is taken on its
387
+ * clock (shifted by the zone's offset now), and an instant answers on it.
388
+ */
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 any reference names is joined without being
488
- * listed; a `select` on a grouped query that only repeats the group keys is
521
+ * has that name; a relation a reference names is joined without being
522
+ * listed, except in `compute` and `having`, which read the answered row; a
523
+ * `select` on a grouped query that only repeats the group keys is
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, so a host can trust the shape and only
688
- * has to translate.
722
+ * before anything leaves Cortex, and again by every host against its own
723
+ * catalog. Computed aliases written out where they are named are capped
724
+ * here; how large the rest grows as SQL or LINQ is each host's own bound.
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
- checkExpansion(check, query, catalog.get(query.from)?.columns.length ?? 0);
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
- * An operand's size written out: a literal or a column is 1, a reference to an
876
- * earlier computed alias is that alias's cost (a self or forward one, refused
877
- * elsewhere, is 1), and arithmetic, a date function, a scalar function, a CASE
878
- * and each of its branches are 1 plus their parts, each part times its weight.
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 operandCost(operand: DatasetOperand, expanded: ReadonlyMap<string, number>): number {
881
- if (typeof operand === "string") return expanded.get(operand) ?? 1;
882
- if (typeof operand === "number" || "value" in operand) return 1;
883
- if ("op" in operand) {
884
- const divisor = operand.op === "/" ? EXPANSION_WEIGHTS.divisor : 1;
885
- return nodeCost([
886
- operandCost(operand.left, expanded),
887
- operandCost(operand.right, expanded) * divisor,
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 weight =
906
- operand.fn === "dateTrunc"
907
- ? EXPANSION_WEIGHTS.dateTrunc[operand.unit]
908
- : EXPANSION_WEIGHTS.datePart[operand.unit];
909
- return nodeCost([operandCost(operand.of, expanded) * weight]);
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
- value.trim() !== "" &&
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
- export function unescapePointerToken(token: string) {
24
+ function unescapePointerToken(token: string) {
25
25
  return token.replaceAll("~1", "/").replaceAll("~0", "~");
26
26
  }
27
27
 
28
- /** An RFC 6901 array index: digits with no leading zero. */
29
- export const ARRAY_INDEX = /^(0|[1-9]\d*)$/;
30
-
31
- /** RFC 6901 lookup; `undefined` when the pointer names nothing (an inherited key, or an array index not written canonically, included). */
32
- export function readJsonPointer(value: unknown, pointer: string) {
33
- if (pointer === "") return value;
34
- let current: unknown = value;
35
- for (const raw of pointer.slice(1).split("/")) {
36
- const key = unescapePointerToken(raw);
37
- if (Array.isArray(current)) {
38
- if (!ARRAY_INDEX.test(key)) return undefined;
39
- current = current[Number(key)];
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 current;
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
  }