@zackbart/connecta 0.38.0 → 0.40.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/CHANGELOG.md +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
package/dist/guest-types.d.ts
CHANGED
|
@@ -25,7 +25,8 @@ export interface GuestApi {
|
|
|
25
25
|
text: string;
|
|
26
26
|
format: "text";
|
|
27
27
|
}>;
|
|
28
|
-
|
|
28
|
+
/** Forwards an authored block, a block from result.content, or an inline result; a paged handle is refused. */
|
|
29
|
+
emit(block: GuestNativeBlock | Exclude<GuestResult, GuestResultHandle>): PromiseLike<void>;
|
|
29
30
|
}
|
|
30
31
|
export interface GuestResourceResult {
|
|
31
32
|
contents: Array<{
|
|
@@ -114,8 +115,8 @@ interface CatalogSearchArgs {
|
|
|
114
115
|
includeSchemaKeys?: boolean;
|
|
115
116
|
}
|
|
116
117
|
interface CatalogDescribeArgs {
|
|
117
|
-
address?:
|
|
118
|
-
addresses?:
|
|
118
|
+
address?: string;
|
|
119
|
+
addresses?: string[];
|
|
119
120
|
format?: SchemaFormat;
|
|
120
121
|
fullDescriptions?: boolean;
|
|
121
122
|
}
|
|
@@ -171,8 +172,21 @@ interface CatalogSearchPage {
|
|
|
171
172
|
};
|
|
172
173
|
}
|
|
173
174
|
type CatalogSearchResult = CatalogSearchPage & {
|
|
174
|
-
tools:
|
|
175
|
-
|
|
175
|
+
tools: CatalogSearchTool[];
|
|
176
|
+
/** Complete operation reads: pass args unchanged to connecta.call(address, args). */
|
|
177
|
+
readyCalls?: Array<{
|
|
178
|
+
address: string;
|
|
179
|
+
args: {
|
|
180
|
+
path: string;
|
|
181
|
+
};
|
|
182
|
+
classification: "read";
|
|
183
|
+
summary: string;
|
|
184
|
+
guideRequired?: true;
|
|
185
|
+
guideRequiredReasons?: GuideRequiredReason[];
|
|
186
|
+
}>;
|
|
187
|
+
/** Facts shared by a connector's rows, keyed by the id before the address's first dot. */
|
|
188
|
+
connectors?: Record<string, {
|
|
189
|
+
title?: string;
|
|
176
190
|
guide?: string;
|
|
177
191
|
guideSummary?: string;
|
|
178
192
|
}>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Internal provider kit (decision 0011 S5). Unreachable from src/index.ts.
|
|
2
|
+
//
|
|
3
|
+
// A provider's own argument checks record every problem before refusing, so
|
|
4
|
+
// one invalid_args carries every finding and an agent repairs the call in one
|
|
5
|
+
// turn instead of one constraint per turn.
|
|
6
|
+
import { ConnectorCallError, issueSummary } from "../errors.js";
|
|
7
|
+
import { schemaExample } from "../validate.js";
|
|
8
|
+
/** Every argument problem one call has, then a single refusal. */
|
|
9
|
+
export class ArgumentProblems {
|
|
10
|
+
#issues = [];
|
|
11
|
+
/**
|
|
12
|
+
* Record one problem. A refusal holds only what the published schema
|
|
13
|
+
* declares and fixed text: `path` names declared properties and indices
|
|
14
|
+
* (`<key>` for any other key), `expected` states the allowed shape or bound,
|
|
15
|
+
* and `received` is a JSON type. Never a submitted string or key.
|
|
16
|
+
*/
|
|
17
|
+
add(path, expected, received) {
|
|
18
|
+
this.#issues.push({ path, expected, ...(received === undefined ? {} : { received }) });
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The one refusal for every recorded problem, or undefined when there is
|
|
22
|
+
* none. With the tool's schema it carries the schema's synthesized example;
|
|
23
|
+
* `withheld` states why a tool offers none.
|
|
24
|
+
*/
|
|
25
|
+
refusal(lead, schema, withheld) {
|
|
26
|
+
if (!this.#issues.length)
|
|
27
|
+
return undefined;
|
|
28
|
+
return new ConnectorCallError("invalid_args", `${lead}: ${issueSummary(this.#issues)}`, {
|
|
29
|
+
validation: {
|
|
30
|
+
issues: this.#issues,
|
|
31
|
+
...(withheld ? { exampleUnavailable: withheld } : schema ? schemaExample(schema) : {}),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/http/errors.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// ledger entries this kit forwards its matched proof and renewals to. A
|
|
31
31
|
// write answered 401 stays unknown; only renewal removes its handoff, and
|
|
32
32
|
// only a reviewed authentication-before-execution declaration proves refusal.
|
|
33
|
+
import { routesOf } from "../agent-routes.js";
|
|
33
34
|
import { Data } from "effect";
|
|
34
35
|
import { ConnectorCallError, isConnectorCallErrorCode, resendAdvice, unavailableCallError, } from "../errors.js";
|
|
35
36
|
import { attachFailureFacts } from "../operator-record.js";
|
|
@@ -429,7 +430,7 @@ function verdict(failure, vendor, options, dispatch = options.dispatches.find((e
|
|
|
429
430
|
body: undefined,
|
|
430
431
|
unreadable: true,
|
|
431
432
|
operation: dispatch.operation ?? { method: "", template: "" },
|
|
432
|
-
}), vendor, options.withholds?.(dispatch.operation ?? { method: "", template: "" }) === true, !dispatch.write);
|
|
433
|
+
}), vendor, options.withholds?.(dispatch.operation ?? { method: "", template: "" }) === true, !dispatch.write, routesOf(options));
|
|
433
434
|
return providerFailureMessage(mapped, failure.detail);
|
|
434
435
|
}
|
|
435
436
|
const uncertainWrite = options.classification === "write" &&
|
|
@@ -455,10 +456,10 @@ function verdict(failure, vendor, options, dispatch = options.dispatches.find((e
|
|
|
455
456
|
: unreadable, { retryable: false });
|
|
456
457
|
}
|
|
457
458
|
case "StatusFailure":
|
|
458
|
-
return statusVerdict(failure, vendor, options.withholds?.(failure.operation) === true, options.dispatches.some((dispatch) => dispatch.failure === failure && !dispatch.write));
|
|
459
|
+
return statusVerdict(failure, vendor, options.withholds?.(failure.operation) === true, options.dispatches.some((dispatch) => dispatch.failure === failure && !dispatch.write), routesOf(options));
|
|
459
460
|
}
|
|
460
461
|
}
|
|
461
|
-
function statusVerdict(failure, vendor, withheld, read) {
|
|
462
|
+
function statusVerdict(failure, vendor, withheld, read, routes) {
|
|
462
463
|
const input = classificationInput(failure, vendor, withheld);
|
|
463
464
|
const { headers, presence } = input;
|
|
464
465
|
// Classification first, over facts and headers only.
|
|
@@ -477,7 +478,7 @@ function statusVerdict(failure, vendor, withheld, read) {
|
|
|
477
478
|
withheld,
|
|
478
479
|
...(withheld ? {} : { body: failure.body }),
|
|
479
480
|
});
|
|
480
|
-
const error = new ConnectorCallError(code, words ? words(detail) : detail, {
|
|
481
|
+
const error = new ConnectorCallError(code, words ? words(detail, routes) : detail, {
|
|
481
482
|
...(retryable === undefined ? {} : { retryable }),
|
|
482
483
|
...(wait === undefined ? {} : { retryAfterMs: wait }),
|
|
483
484
|
...(current ? { current } : {}),
|
package/dist/http/fields.js
CHANGED
|
@@ -3,10 +3,29 @@
|
|
|
3
3
|
// `fields` narrows a result to the dot paths an agent names. It runs on the
|
|
4
4
|
// result a tool already built (after value safety, the privacy projection and
|
|
5
5
|
// any named projection), so it can only remove. A list narrows each item.
|
|
6
|
+
//
|
|
7
|
+
// Every `fields` argument states where its paths start, its anchor, in the
|
|
8
|
+
// schema description, derived here from the same contract that validates it:
|
|
9
|
+
// a named read's result, each item of a list's `items`, or a hatch's response
|
|
10
|
+
// body in `data`. A path is checked against the published output schema as
|
|
11
|
+
// deep as that schema is closed, so a path outside the declared contract is
|
|
12
|
+
// refused before anything is sent. A declared path the response happens to
|
|
13
|
+
// lack is never refused: absent optional values and empty lists are ordinary.
|
|
6
14
|
import { Schema } from "effect";
|
|
7
|
-
import {
|
|
8
|
-
import { isMaxChars, isMinChars } from "./json-schema.js";
|
|
15
|
+
import { ArgumentProblems } from "./argument-problems.js";
|
|
16
|
+
import { catalogSchema, isMaxChars, isMinChars } from "./json-schema.js";
|
|
9
17
|
const MAX_FIELDS = 50;
|
|
18
|
+
/** Choices listed in a refusal or a warning; more read as "a declared property". */
|
|
19
|
+
const MAX_CHOICES = 40;
|
|
20
|
+
const ANCHORS = {
|
|
21
|
+
result: "the result",
|
|
22
|
+
item: "each item in items",
|
|
23
|
+
data: "the response body in data, each item when the body is a list",
|
|
24
|
+
};
|
|
25
|
+
/** The description of a `fields` argument: its anchor, one valid example, then any tool note. */
|
|
26
|
+
export function fieldsDescription(anchor, example, note = "It only narrows the result.") {
|
|
27
|
+
return `Dot paths to keep. Paths start at ${ANCHORS[anchor]}, e.g. ${JSON.stringify([example])}. ${note}`;
|
|
28
|
+
}
|
|
10
29
|
/** The `fields` argument: at most 50 dot paths of 1 to 200 characters. */
|
|
11
30
|
export function fieldsInput(description) {
|
|
12
31
|
return Schema.Array(Schema.String.check(isMinChars(1), isMaxChars(200)))
|
|
@@ -16,18 +35,97 @@ export function fieldsInput(description) {
|
|
|
16
35
|
function record(value) {
|
|
17
36
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
37
|
}
|
|
38
|
+
/** A dot path's property names; `a[].b` and `a.b` both select `b` in each item of `a`. */
|
|
39
|
+
const segments = (path) => path.replace(/\[\](?=\.|$)/g, "").split(".");
|
|
40
|
+
/**
|
|
41
|
+
* Collect the properties a path segment may name at a schema position into
|
|
42
|
+
* `into`, false when the position is open (any key may appear: `{}`, a bare
|
|
43
|
+
* object or array, a record, a reference). Arrays are transparent, as
|
|
44
|
+
* selection narrows them item by item; a leaf adds nothing and stays closed.
|
|
45
|
+
* Below the first segment, JSON Schema's default applies: an object is closed
|
|
46
|
+
* only when it says `additionalProperties: false`. The first segment keeps the
|
|
47
|
+
* declared properties of the result or item as its closed set.
|
|
48
|
+
*/
|
|
49
|
+
function propertiesAt(node, into, root) {
|
|
50
|
+
if (!record(node))
|
|
51
|
+
return node === false;
|
|
52
|
+
const additional = node["additionalProperties"];
|
|
53
|
+
if ("$ref" in node || "allOf" in node || (additional !== undefined && additional !== false))
|
|
54
|
+
return false;
|
|
55
|
+
const branches = [node["anyOf"], node["oneOf"]].flatMap((value) => (Array.isArray(value) ? value : []));
|
|
56
|
+
if (!branches.every((branch) => propertiesAt(branch, into, root)))
|
|
57
|
+
return false;
|
|
58
|
+
if (node["items"] !== undefined && !propertiesAt(node["items"], into, root))
|
|
59
|
+
return false;
|
|
60
|
+
const properties = node["properties"];
|
|
61
|
+
if (record(properties)) {
|
|
62
|
+
for (const [name, child] of Object.entries(properties))
|
|
63
|
+
into.set(name, [...(into.get(name) ?? []), child]);
|
|
64
|
+
return root || additional === false;
|
|
65
|
+
}
|
|
66
|
+
if (branches.length || node["items"] !== undefined || "enum" in node || "const" in node)
|
|
67
|
+
return true;
|
|
68
|
+
const types = [node["type"]].flat();
|
|
69
|
+
return node["type"] !== undefined && !types.includes("object") && !types.includes("array");
|
|
70
|
+
}
|
|
71
|
+
const choices = (names) => names.length > 0 && names.length <= MAX_CHOICES ? `one of ${names.join(", ")}` : "a declared property";
|
|
72
|
+
/**
|
|
73
|
+
* Record every `fields` path the contract cannot carry. A JSON Schema is the
|
|
74
|
+
* published shape at the anchor (a result, or a list's item), checked as deep
|
|
75
|
+
* as it is closed; a set names only the first segments, which is all a hatch's
|
|
76
|
+
* pinned index can promise. A finding names the entry's index, its segment
|
|
77
|
+
* position and the declared properties there, never the submitted text.
|
|
78
|
+
*/
|
|
79
|
+
function fieldsIssues(problems, fields, contract, of) {
|
|
80
|
+
const root = contract instanceof Set
|
|
81
|
+
? { type: "object", properties: Object.fromEntries([...contract].map((n) => [n, {}])) }
|
|
82
|
+
: contract;
|
|
83
|
+
fields?.forEach((field, index) => {
|
|
84
|
+
let nodes = [root];
|
|
85
|
+
for (const [depth, name] of segments(field).entries()) {
|
|
86
|
+
const at = new Map();
|
|
87
|
+
// Selection keeps a scalar a path runs past, so below the first segment only a closed object refuses.
|
|
88
|
+
if (!nodes.every((node) => propertiesAt(node, at, depth === 0)) || (depth > 0 && at.size === 0))
|
|
89
|
+
return;
|
|
90
|
+
const next = at.get(name);
|
|
91
|
+
if (next) {
|
|
92
|
+
nodes = next;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const declared = [...at.keys()];
|
|
96
|
+
problems.add(`/fields/${index}`, depth === 0
|
|
97
|
+
? `a path beginning with a property of ${of}: ${choices(declared)}`
|
|
98
|
+
: `a declared path of ${of}; segment ${depth + 1} names a property declared at that level: ${choices(declared)}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** The single refusal naming every bad `fields` path, if any; `withheld` says why it has no example. */
|
|
104
|
+
export function fieldsProblem(fields, contract, of, withheld) {
|
|
105
|
+
const problems = new ArgumentProblems();
|
|
106
|
+
fieldsIssues(problems, fields, contract, of);
|
|
107
|
+
return problems.refusal("Invalid fields", undefined, withheld);
|
|
108
|
+
}
|
|
19
109
|
/**
|
|
20
|
-
* The
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
110
|
+
* The `fields` contract for a named read's result (`"result"`) or a list's
|
|
111
|
+
* item (`"item"`). `shape` is that result or item: a Schema rendered as the
|
|
112
|
+
* catalog renders it, or the catalog JSON Schema itself. Its first declared
|
|
113
|
+
* property is the description's example, so the example is always valid.
|
|
24
114
|
*/
|
|
25
|
-
export function
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
115
|
+
export function fieldsContract(anchor, shape, note, of = anchor === "item" ? "an item" : "the result") {
|
|
116
|
+
const schema = "ast" in shape ? catalogSchema(shape, "output") : shape;
|
|
117
|
+
const at = new Map();
|
|
118
|
+
propertiesAt(schema, at, true);
|
|
119
|
+
const example = [...at.keys()][0];
|
|
120
|
+
if (example === undefined)
|
|
121
|
+
throw new Error("fields needs a contract that declares at least one property.");
|
|
122
|
+
const description = fieldsDescription(anchor, example, note);
|
|
123
|
+
return {
|
|
124
|
+
description,
|
|
125
|
+
input: fieldsInput(description),
|
|
126
|
+
issues: (problems, fields) => fieldsIssues(problems, fields, schema, of),
|
|
127
|
+
problem: (fields) => fieldsProblem(fields, schema, of),
|
|
128
|
+
};
|
|
31
129
|
}
|
|
32
130
|
/** Keep only the named dot paths; arrays are narrowed item by item. */
|
|
33
131
|
export function selectFields(data, fields) {
|
|
@@ -40,10 +138,91 @@ export function selectFields(data, fields) {
|
|
|
40
138
|
return value;
|
|
41
139
|
const children = new Map();
|
|
42
140
|
for (const path of paths) {
|
|
43
|
-
const [key, ...tail] = path
|
|
141
|
+
const [key, ...tail] = segments(path);
|
|
44
142
|
children.set(key, [...(children.get(key) ?? []), tail.join(".")]);
|
|
45
143
|
}
|
|
46
144
|
return Object.fromEntries([...children].flatMap(([key, tails]) => Object.hasOwn(value, key) ? [[key, tails.includes("") ? value[key] : pick(value[key], tails)]] : []));
|
|
47
145
|
};
|
|
48
146
|
return pick(data, fields);
|
|
49
147
|
}
|
|
148
|
+
const empty = (value) => value === null ||
|
|
149
|
+
value === undefined ||
|
|
150
|
+
(Array.isArray(value) ? value.length === 0 : record(value) && !Object.keys(value).length);
|
|
151
|
+
/** Warning bounds: each warning's characters, the count kept, and every kept warning's characters together. */
|
|
152
|
+
export const WARNING_BUDGET = { chars: 512, warnings: 10, totalChars: 4096 };
|
|
153
|
+
/**
|
|
154
|
+
* Keep warnings within WARNING_BUDGET in order, cutting a long one with an
|
|
155
|
+
* ellipsis, then one count of what was omitted. Merged reviewed warnings and
|
|
156
|
+
* `fields` reports share the budget.
|
|
157
|
+
*/
|
|
158
|
+
/** At most `max` UTF-16 units, never splitting a surrogate pair. */
|
|
159
|
+
function codePointSlice(text, max) {
|
|
160
|
+
const end = max > 0 && /[\uD800-\uDBFF]/.test(text[max - 1] ?? "") ? max - 1 : max;
|
|
161
|
+
return text.slice(0, end);
|
|
162
|
+
}
|
|
163
|
+
export function boundWarnings(warnings) {
|
|
164
|
+
const kept = [];
|
|
165
|
+
let total = 0;
|
|
166
|
+
for (const warning of warnings) {
|
|
167
|
+
const text = warning.length > WARNING_BUDGET.chars ? `${codePointSlice(warning, WARNING_BUDGET.chars - 1)}…` : warning;
|
|
168
|
+
// Leave room for the omitted count.
|
|
169
|
+
if (kept.length >= WARNING_BUDGET.warnings || total + text.length > WARNING_BUDGET.totalChars - 64)
|
|
170
|
+
break;
|
|
171
|
+
kept.push(text);
|
|
172
|
+
total += text.length;
|
|
173
|
+
}
|
|
174
|
+
const omitted = warnings.length - kept.length;
|
|
175
|
+
return omitted ? [...kept, `${omitted} more warning${omitted === 1 ? "" : "s"} omitted.`] : kept;
|
|
176
|
+
}
|
|
177
|
+
/** Declared names listed in one warning, within its character budget. */
|
|
178
|
+
function named(names) {
|
|
179
|
+
let text = "";
|
|
180
|
+
let shown = 0;
|
|
181
|
+
for (const name of names) {
|
|
182
|
+
const next = text ? `${text}, ${name}` : name;
|
|
183
|
+
if (next.length > WARNING_BUDGET.chars - 200)
|
|
184
|
+
break;
|
|
185
|
+
text = next;
|
|
186
|
+
shown += 1;
|
|
187
|
+
}
|
|
188
|
+
return shown < names.length ? `${text}${text ? " and " : ""}${names.length - shown} more` : text;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* One warning per `fields` entry that matched nothing in a nonempty response,
|
|
192
|
+
* for contracts that cannot refuse it in advance. It names the entry's index,
|
|
193
|
+
* the segment where matching stopped and the properties the contract declares
|
|
194
|
+
* there, from `declared` (paths in `fields` coordinates). It never lists the
|
|
195
|
+
* response's own keys, which in an open map are data, nor the submitted text.
|
|
196
|
+
* A path some item matched, a path past a scalar (kept whole), or any path of
|
|
197
|
+
* an empty response, is no warning.
|
|
198
|
+
*/
|
|
199
|
+
export function fieldsMisses(data, fields, declared = []) {
|
|
200
|
+
if (!fields?.length || empty(data))
|
|
201
|
+
return [];
|
|
202
|
+
const flat = (values) => values.flatMap((value) => (Array.isArray(value) ? flat(value) : [value]));
|
|
203
|
+
const contract = declared.map((path) => segments(path.replace(/^\[\]\.?/, "")));
|
|
204
|
+
return fields.flatMap((field, index) => {
|
|
205
|
+
const path = segments(field);
|
|
206
|
+
let values = flat([data]);
|
|
207
|
+
for (const [depth, name] of path.entries()) {
|
|
208
|
+
const holders = values.filter((value) => record(value) && Object.hasOwn(value, name));
|
|
209
|
+
if (holders.length) {
|
|
210
|
+
values = flat(holders.map((value) => value[name]));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Selection keeps a scalar or null a path runs past, so only an object lacking the key is a miss.
|
|
214
|
+
if (!values.some(record))
|
|
215
|
+
return [];
|
|
216
|
+
const prefix = path.slice(0, depth);
|
|
217
|
+
const names = [
|
|
218
|
+
...new Set(contract
|
|
219
|
+
.filter((parts) => parts.length > depth && prefix.every((part, at) => parts[at] === part))
|
|
220
|
+
.map((parts) => parts[depth])),
|
|
221
|
+
];
|
|
222
|
+
return [
|
|
223
|
+
`fields/${index} matched nothing in this response after value safety and privacy filtering, at segment ${depth + 1}; ${names.length ? `declared there: ${named(names)}` : "the operation declares no properties there"}.`,
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
return [];
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* At most 100 paths and 4 KiB of path JSON, `withheld` included, an eighth of
|
|
3
|
+
* the 32 KiB details budget. That holds every path of nine in ten operations
|
|
4
|
+
* in each maintained index; larger responses keep their shallowest paths,
|
|
5
|
+
* roots first. A path over 200 characters, longer than one `fields` entry may
|
|
6
|
+
* be, is never listed.
|
|
7
|
+
*/
|
|
8
|
+
export const RESPONSE_BUDGET = { paths: 100, chars: 4096, pathChars: 200 };
|
|
9
|
+
/** `a[].b` → `["a", "b"]`. */
|
|
10
|
+
function names(path) {
|
|
11
|
+
return path.split(".").map((part) => part.replace(/\[\]$/, ""));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The capped response contract for one operation, or undefined when it
|
|
15
|
+
* declares no response properties (`fields` is then refused). Deterministic:
|
|
16
|
+
* paths keep the index's order within each depth.
|
|
17
|
+
*/
|
|
18
|
+
export function responseContract(index, op, safety) {
|
|
19
|
+
const declared = index.fields(op).filter((field) => field.in === "response");
|
|
20
|
+
if (declared.length === 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
const anchor = declared.some((field) => field.path.startsWith("[]")) ? "data[]" : "data";
|
|
23
|
+
// The coordinates `fields` validates: an item path of a list body drops its leading `[].`.
|
|
24
|
+
const paths = [
|
|
25
|
+
...new Set(declared.map((field) => field.path.replace(/^\[\]\.?/, "")).filter((path) => /^[^.[]/.test(path))),
|
|
26
|
+
];
|
|
27
|
+
// Every root `fields` accepts is listed, including one the index names only beneath it.
|
|
28
|
+
const spelled = new Map();
|
|
29
|
+
for (const path of [...paths.map((path) => names(path)[0]), ...paths]) {
|
|
30
|
+
const key = names(path).join(".");
|
|
31
|
+
if (!spelled.has(key))
|
|
32
|
+
spelled.set(key, path);
|
|
33
|
+
}
|
|
34
|
+
let withholds;
|
|
35
|
+
try {
|
|
36
|
+
withholds = safety.withholds({ method: op.method, template: op.path });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
withholds = () => false;
|
|
40
|
+
}
|
|
41
|
+
// The hint reads paths with their list markers, a list body's items included.
|
|
42
|
+
const known = new Set([...spelled]
|
|
43
|
+
.filter(([, path]) => withholds([...(anchor === "data[]" ? ["[]"] : []), ...path.split(".")]))
|
|
44
|
+
.map(([key]) => key));
|
|
45
|
+
const depth = (path) => path.split(".").length;
|
|
46
|
+
const ordered = [...spelled.values()]
|
|
47
|
+
.map((path, at) => ({ path, at }))
|
|
48
|
+
.sort((a, b) => depth(a.path) - depth(b.path) || a.at - b.at);
|
|
49
|
+
const fields = [];
|
|
50
|
+
const withheld = [];
|
|
51
|
+
let chars = 0;
|
|
52
|
+
let kept = 0;
|
|
53
|
+
for (const { path } of ordered) {
|
|
54
|
+
// A path longer than a fields input can be is counted, never listed.
|
|
55
|
+
if (path.length > RESPONSE_BUDGET.pathChars)
|
|
56
|
+
continue;
|
|
57
|
+
const hinted = known.has(names(path).join("."));
|
|
58
|
+
const cost = (JSON.stringify(path).length + 1) * (hinted ? 2 : 1);
|
|
59
|
+
if (kept >= RESPONSE_BUDGET.paths || chars + cost > RESPONSE_BUDGET.chars)
|
|
60
|
+
break;
|
|
61
|
+
fields.push(path);
|
|
62
|
+
if (hinted)
|
|
63
|
+
withheld.push(path);
|
|
64
|
+
chars += cost;
|
|
65
|
+
kept += 1;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
anchor,
|
|
69
|
+
fields,
|
|
70
|
+
...(withheld.length ? { withheld } : {}),
|
|
71
|
+
...(kept < ordered.length ? { omitted: ordered.length - kept } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|