@uipath/maestro-builder-sdk 6.4.0 → 6.5.1
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/dist/bpmn/uipath-moddle.v1.json +21 -0
- package/dist/cli/commands/registry.js +68 -12
- package/dist/registry/archive/entry-policy.d.ts +36 -0
- package/dist/registry/archive/entry-policy.js +60 -0
- package/dist/registry/archive/extract.d.ts +31 -0
- package/dist/registry/archive/extract.js +92 -0
- package/dist/registry/archive/zip-reader.d.ts +38 -0
- package/dist/registry/archive/zip-reader.js +256 -0
- package/dist/registry/codecs/pyjson.d.ts +62 -0
- package/dist/registry/codecs/pyjson.js +208 -0
- package/dist/registry/compat/compare.d.ts +18 -0
- package/dist/registry/compat/compare.js +32 -0
- package/dist/registry/compat/paths.d.ts +4 -0
- package/dist/registry/compat/paths.js +17 -0
- package/dist/registry/compat/pystr.d.ts +50 -0
- package/dist/registry/compat/pystr.js +221 -0
- package/dist/registry/concurrency.d.ts +10 -0
- package/dist/registry/concurrency.js +34 -0
- package/dist/registry/curated-overlay.d.ts +36 -4
- package/dist/registry/curated-overlay.js +87 -14
- package/dist/registry/errors.d.ts +26 -0
- package/dist/registry/errors.js +38 -0
- package/dist/registry/extractor.d.ts +9 -10
- package/dist/registry/extractor.js +5 -15
- package/dist/registry/files.d.ts +26 -0
- package/dist/registry/files.js +160 -0
- package/dist/registry/generators/descriptors.d.ts +81 -0
- package/dist/registry/generators/descriptors.js +405 -0
- package/dist/registry/generators/fields.d.ts +46 -0
- package/dist/registry/generators/fields.js +111 -0
- package/dist/registry/generators/markdown/common.d.ts +21 -0
- package/dist/registry/generators/markdown/common.js +55 -0
- package/dist/registry/generators/markdown/index.d.ts +33 -0
- package/dist/registry/generators/markdown/index.js +37 -0
- package/dist/registry/generators/markdown/page.d.ts +8 -0
- package/dist/registry/generators/markdown/page.js +52 -0
- package/dist/registry/generators/markdown/pyvalue.d.ts +34 -0
- package/dist/registry/generators/markdown/pyvalue.js +106 -0
- package/dist/registry/generators/markdown/sections/choosing-object.d.ts +2 -0
- package/dist/registry/generators/markdown/sections/choosing-object.js +52 -0
- package/dist/registry/generators/markdown/sections/example.d.ts +22 -0
- package/dist/registry/generators/markdown/sections/example.js +295 -0
- package/dist/registry/generators/markdown/sections/filtering.d.ts +2 -0
- package/dist/registry/generators/markdown/sections/filtering.js +76 -0
- package/dist/registry/generators/markdown/sections/header.d.ts +2 -0
- package/dist/registry/generators/markdown/sections/header.js +37 -0
- package/dist/registry/generators/markdown/sections/inputs.d.ts +30 -0
- package/dist/registry/generators/markdown/sections/inputs.js +151 -0
- package/dist/registry/generators/markdown/sections/lookups.d.ts +12 -0
- package/dist/registry/generators/markdown/sections/lookups.js +150 -0
- package/dist/registry/generators/markdown/sections/outputs.d.ts +3 -0
- package/dist/registry/generators/markdown/sections/outputs.js +9 -0
- package/dist/registry/generators/markdown/sections/resolving-fields.d.ts +2 -0
- package/dist/registry/generators/markdown/sections/resolving-fields.js +139 -0
- package/dist/registry/generators/markdown/sections/schema-dynamic.d.ts +2 -0
- package/dist/registry/generators/markdown/sections/schema-dynamic.js +86 -0
- package/dist/registry/generators/materialize.d.ts +30 -0
- package/dist/registry/generators/materialize.js +132 -0
- package/dist/registry/generators/md-symbols.d.ts +22 -0
- package/dist/registry/generators/md-symbols.js +58 -0
- package/dist/registry/generators/triggers.d.ts +15 -0
- package/dist/registry/generators/triggers.js +85 -0
- package/dist/registry/generators/type-registry.d.ts +50 -0
- package/dist/registry/generators/type-registry.js +154 -0
- package/dist/registry/generators/type-tree.d.ts +65 -0
- package/dist/registry/generators/type-tree.js +179 -0
- package/dist/registry/library-build/build.d.ts +57 -0
- package/dist/registry/library-build/build.js +286 -0
- package/dist/registry/library-build/canonical.d.ts +119 -0
- package/dist/registry/library-build/canonical.js +512 -0
- package/dist/registry/library-build/cli.d.ts +45 -0
- package/dist/registry/library-build/cli.js +182 -0
- package/dist/registry/library-build/context.d.ts +107 -0
- package/dist/registry/library-build/context.js +267 -0
- package/dist/registry/library-build/enrich.d.ts +36 -0
- package/dist/registry/library-build/enrich.js +158 -0
- package/dist/registry/library-build/events.d.ts +108 -0
- package/dist/registry/library-build/events.js +476 -0
- package/dist/registry/library-build/generic-expand.d.ts +77 -0
- package/dist/registry/library-build/generic-expand.js +363 -0
- package/dist/registry/library-build/plan.d.ts +71 -0
- package/dist/registry/library-build/plan.js +270 -0
- package/dist/registry/library-build/py.d.ts +45 -0
- package/dist/registry/library-build/py.js +167 -0
- package/dist/registry/library-build/schema-action.d.ts +38 -0
- package/dist/registry/library-build/schema-action.js +107 -0
- package/dist/registry/library-build/write.d.ts +6 -0
- package/dist/registry/library-build/write.js +26 -0
- package/dist/registry/library-reader.d.ts +20 -0
- package/dist/registry/library-reader.js +50 -0
- package/dist/registry/lookup-specs.d.ts +55 -0
- package/dist/registry/lookup-specs.js +140 -0
- package/dist/registry/materialize-cli.js +28 -14
- package/dist/registry/materializer.d.ts +25 -3
- package/dist/registry/materializer.js +45 -29
- package/dist/registry/model/library.d.ts +254 -0
- package/dist/registry/model/library.js +23 -0
- package/dist/registry/naming.d.ts +54 -0
- package/dist/registry/naming.js +142 -0
- package/dist/registry/orphans.d.ts +4 -0
- package/dist/registry/orphans.js +86 -0
- package/dist/registry/prepare/actions.d.ts +26 -0
- package/dist/registry/prepare/actions.js +94 -0
- package/dist/registry/prepare/connections.d.ts +181 -0
- package/dist/registry/prepare/connections.js +399 -0
- package/dist/registry/prepare/event.d.ts +96 -0
- package/dist/registry/prepare/event.js +268 -0
- package/dist/registry/prepare/handoff.d.ts +21 -0
- package/dist/registry/prepare/handoff.js +91 -0
- package/dist/registry/prepare/ledger.d.ts +104 -0
- package/dist/registry/prepare/ledger.js +216 -0
- package/dist/registry/prepare/messages.d.ts +190 -0
- package/dist/registry/prepare/messages.js +472 -0
- package/dist/registry/prepare/objects.d.ts +130 -0
- package/dist/registry/prepare/objects.js +318 -0
- package/dist/registry/prepare/overlay.d.ts +149 -0
- package/dist/registry/prepare/overlay.js +487 -0
- package/dist/registry/prepare/parents.d.ts +41 -0
- package/dist/registry/prepare/parents.js +122 -0
- package/dist/registry/prepare/pipeline.d.ts +91 -0
- package/dist/registry/prepare/pipeline.js +376 -0
- package/dist/registry/prepare/regenerate.d.ts +9 -0
- package/dist/registry/prepare/regenerate.js +50 -0
- package/dist/registry/prepare/resolve.d.ts +199 -0
- package/dist/registry/prepare/resolve.js +470 -0
- package/dist/registry/prepare/retry.d.ts +83 -0
- package/dist/registry/prepare/retry.js +109 -0
- package/dist/registry/prepare/source-import.d.ts +54 -0
- package/dist/registry/prepare/source-import.js +192 -0
- package/dist/registry/prepare/support.d.ts +41 -0
- package/dist/registry/prepare/support.js +178 -0
- package/dist/registry/prepare-cli.d.ts +16 -5
- package/dist/registry/prepare-cli.js +60 -53
- package/dist/registry/pull-cli.js +8 -7
- package/dist/registry/reporter.d.ts +26 -0
- package/dist/registry/reporter.js +41 -0
- package/dist/registry/uip/client.d.ts +99 -0
- package/dist/registry/uip/client.js +226 -0
- package/dist/registry/uip/commands.d.ts +40 -0
- package/dist/registry/uip/commands.js +80 -0
- package/dist/registry/uip/envelope.d.ts +37 -0
- package/dist/registry/uip/envelope.js +84 -0
- package/dist/registry/uip/response-cache.d.ts +28 -0
- package/dist/registry/uip/response-cache.js +57 -0
- package/lib/README.md +10 -8
- package/package.json +3 -3
- package/dist/generators/_connections.py +0 -331
- package/dist/generators/_events.py +0 -355
- package/dist/generators/_lookups.py +0 -114
- package/dist/generators/_naming.py +0 -185
- package/dist/generators/_objects.py +0 -299
- package/dist/generators/_resolve.py +0 -319
- package/dist/generators/convert_library_to_md.py +0 -1441
- package/dist/generators/extract_library.py +0 -125
- package/dist/generators/generate_connectors_ts.py +0 -936
- package/dist/generators/generate_library_json.py +0 -2527
- package/dist/generators/generate_triggers_ts.py +0 -110
- package/dist/generators/materialize_registry.py +0 -155
- package/dist/generators/prepare_connector.py +0 -1860
- package/dist/registry/python.d.ts +0 -26
- package/dist/registry/python.js +0 -46
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Refusal } from "../errors.js";
|
|
2
|
+
import type { UipClient } from "../uip/client.js";
|
|
3
|
+
/**
|
|
4
|
+
* A scan has to stop somewhere, and the caps must be set against the MEASURED
|
|
5
|
+
* page size rather than a guess. Integration Service serves **1,000 records
|
|
6
|
+
* per page** for a curated collection (verified against a live Slack
|
|
7
|
+
* connection: `curated_channels` returns 1000 with `HasMore: "true"`).
|
|
8
|
+
*
|
|
9
|
+
* The first version of this capped at 400 records — a number chosen for how
|
|
10
|
+
* many rows a person might scroll, which is the wrong unit entirely. It meant
|
|
11
|
+
* the loop broke partway through page ONE and never paginated at all. The eval
|
|
12
|
+
* task built to exercise this puts its target channel on page 3 (record
|
|
13
|
+
* ~2,001), so the resolver reported "not found" for a channel that is plainly
|
|
14
|
+
* there.
|
|
15
|
+
*
|
|
16
|
+
* 25 pages is ~30s of requests in the worst case, spent once, in an explicit
|
|
17
|
+
* command whose whole job is this. A collection with a `filterPattern` never
|
|
18
|
+
* reaches the scan at all.
|
|
19
|
+
*/
|
|
20
|
+
export declare const PAGE_SIZE_HINT = 1000;
|
|
21
|
+
export declare const MAX_SCANNED: number;
|
|
22
|
+
export declare const PAGE_GUARD = 50;
|
|
23
|
+
/** A lookup that could not be resolved, with the reason a human needs (`_resolve.ResolutionError`). */
|
|
24
|
+
export declare class ResolutionRefusal extends Refusal {
|
|
25
|
+
constructor(message: string, code: string);
|
|
26
|
+
}
|
|
27
|
+
/** What {@link LookupExhausted} knows about the scan that found nothing. */
|
|
28
|
+
export interface LookupExhaustedFacts {
|
|
29
|
+
field: string;
|
|
30
|
+
collection: string;
|
|
31
|
+
by: string;
|
|
32
|
+
value: string;
|
|
33
|
+
/** Records seen across every page fetched. */
|
|
34
|
+
scanned: number;
|
|
35
|
+
/** Pages FETCHED (not transitions): the count the message prints. */
|
|
36
|
+
pages: number;
|
|
37
|
+
/** True when a cap, not the collection's end, stopped the scan. */
|
|
38
|
+
capped: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The collection was read to the end on THIS connection and had no match
|
|
42
|
+
* (`_resolve.LookupExhausted`).
|
|
43
|
+
*
|
|
44
|
+
* Split out from its parent because the two failures want opposite responses
|
|
45
|
+
* and the caller cannot tell them apart from a message. A transport or
|
|
46
|
+
* permission failure says nothing about whether the value exists — retrying it
|
|
47
|
+
* on another connection would hide a broken call behind a confident "no
|
|
48
|
+
* connection has it". An exhaustive miss is positive evidence about this
|
|
49
|
+
* connection specifically, and is the signal `prepare` uses to demote it and
|
|
50
|
+
* try the next candidate (flow-builder-sdk#744).
|
|
51
|
+
*
|
|
52
|
+
* `capped` flips that: a scan stopped at the record cap did NOT reach the end,
|
|
53
|
+
* so it is not evidence of absence and must not demote anything.
|
|
54
|
+
*
|
|
55
|
+
* The attributes carry the counts so a caller can report per-candidate results
|
|
56
|
+
* without re-parsing the sentence.
|
|
57
|
+
*/
|
|
58
|
+
export declare class LookupExhausted extends ResolutionRefusal {
|
|
59
|
+
readonly field: string;
|
|
60
|
+
readonly collection: string;
|
|
61
|
+
readonly by: string;
|
|
62
|
+
readonly value: string;
|
|
63
|
+
readonly scanned: number;
|
|
64
|
+
readonly pages: number;
|
|
65
|
+
readonly capped: boolean;
|
|
66
|
+
constructor(message: string, facts: LookupExhaustedFacts);
|
|
67
|
+
}
|
|
68
|
+
/** One `--resolve <field>:<by>=<value>`, parsed. */
|
|
69
|
+
export type ResolveRequest = readonly [field: string, by: string, value: string];
|
|
70
|
+
/**
|
|
71
|
+
* What a lookup spec must say for `resolveOne`: the same fields a library
|
|
72
|
+
* `reference` and a `LookupSpecData` carry, read loosely.
|
|
73
|
+
*/
|
|
74
|
+
export interface ResolveSpec {
|
|
75
|
+
path?: string;
|
|
76
|
+
by?: readonly string[];
|
|
77
|
+
value?: string;
|
|
78
|
+
filterPattern?: string;
|
|
79
|
+
dependsOn?: readonly string[];
|
|
80
|
+
[key: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `channel:profile.email=dustin@example.com` -> `[field, by, value]`.
|
|
84
|
+
*
|
|
85
|
+
* Split on the FIRST colon and the FIRST `=`. Both halves can legitimately
|
|
86
|
+
* contain the other delimiter — `by` is a dotted path and a value can be
|
|
87
|
+
* anything a person typed — so a greedy split silently mis-parses real input.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parseResolveFlag(raw: string): ResolveRequest;
|
|
90
|
+
type SpecTable = ReadonlyMap<string, ResolveSpec> | Readonly<Record<string, ResolveSpec>>;
|
|
91
|
+
/**
|
|
92
|
+
* Order the requests so a `dependsOn` prerequisite resolves first.
|
|
93
|
+
*
|
|
94
|
+
* 27 fields in the shipped library are not addressable until another field is
|
|
95
|
+
* known — Jira's issue type needs the project, Amazon Connect's phone number
|
|
96
|
+
* needs the instance. Resolving in the order the flags were typed leaves an
|
|
97
|
+
* unsubstituted `{placeholder}` in a URL, and the error the connector returns
|
|
98
|
+
* for that names neither field.
|
|
99
|
+
*
|
|
100
|
+
* A field requested twice keeps its first position and its LAST `by`/`value`,
|
|
101
|
+
* as the Python's dict did.
|
|
102
|
+
*/
|
|
103
|
+
export declare function orderResolutions(requested: readonly ResolveRequest[], specs: SpecTable): ResolveRequest[];
|
|
104
|
+
/**
|
|
105
|
+
* The records out of a `uip is resources run list` reply.
|
|
106
|
+
*
|
|
107
|
+
* The reply is `{"Result": "Success", "Code": ..., "Data": {"items": [...],
|
|
108
|
+
* "Pagination": {...}}}` — records live under `Data`, and `Result` is the
|
|
109
|
+
* STATUS STRING, not a container. Reading a top-level `items` finds nothing
|
|
110
|
+
* and reports "scanned 0 records" against a collection that is full, which is
|
|
111
|
+
* exactly what this did until an eval run walked it into the real CLI.
|
|
112
|
+
*/
|
|
113
|
+
export declare function envelopeItems(payload: unknown): unknown[];
|
|
114
|
+
/** The `Pagination` block, from under `Data`. */
|
|
115
|
+
export declare function envelopePagination(payload: unknown): Record<string, unknown>;
|
|
116
|
+
/**
|
|
117
|
+
* `HasMore` arrives as the STRING "false" as often as the boolean.
|
|
118
|
+
*
|
|
119
|
+
* `if payload["Pagination"]["HasMore"]` on the string "false" is True, which
|
|
120
|
+
* pages forever on a collection that just said it has no more.
|
|
121
|
+
*/
|
|
122
|
+
export declare function truthy(value: unknown): boolean;
|
|
123
|
+
/**
|
|
124
|
+
* The reviver that makes `JSON.parse` read a number the way `json.loads` did.
|
|
125
|
+
*
|
|
126
|
+
* Python kept every number as its text spelled it — `1.0` stayed a float,
|
|
127
|
+
* whose `str()` is `"1.0"`, and an integer stayed exact however many digits it
|
|
128
|
+
* had — so `str(got)` lost nothing on the way into `resolutions.json`.
|
|
129
|
+
* `JSON.parse` folds both cases: `1.0` parses to the integer `1`, and an
|
|
130
|
+
* integer above 2^53 is rounded to the nearest double, and the rounded number
|
|
131
|
+
* is what would have been recorded as the id. The reviver's third argument
|
|
132
|
+
* (`JSON.parse` source text access, present in every Node the package
|
|
133
|
+
* supports — `engines` says >= 22.6) is the spelling itself, so the two lossy
|
|
134
|
+
* cases are rewritten here as the string Python's `str()` would have produced.
|
|
135
|
+
* {@link matchRecord} and the recorded id only ever see that string, so
|
|
136
|
+
* nothing downstream can tell the difference — except that it is now right,
|
|
137
|
+
* and a typed `--resolve x:count=1.0` matches a JSON `1.0` as it did in Python.
|
|
138
|
+
*/
|
|
139
|
+
export declare function reviveLikeJsonLoads(_key: string, value: unknown, context?: {
|
|
140
|
+
source?: string;
|
|
141
|
+
}): unknown;
|
|
142
|
+
/**
|
|
143
|
+
* The first JSON document in a listing's stdout, with its record values parsed
|
|
144
|
+
* as `json.loads` would have read them, or `undefined`.
|
|
145
|
+
*/
|
|
146
|
+
export declare function parseListingDocument(text: string): unknown;
|
|
147
|
+
/**
|
|
148
|
+
* One `uip is resources run list` page, as the parsed envelope.
|
|
149
|
+
*
|
|
150
|
+
* `uip` prints a self-update failure to stderr before its real output, so
|
|
151
|
+
* stderr being non-empty is not evidence of anything. Judge the exit code and
|
|
152
|
+
* the payload.
|
|
153
|
+
*/
|
|
154
|
+
export declare function listPage(client: UipClient, connectorKey: string, collection: string, connectionId: string, query: string | undefined): Promise<Record<string, unknown>>;
|
|
155
|
+
/** `profile.email` out of a record, or `null` (Python's `None`) when any step is missing. */
|
|
156
|
+
export declare function readPath(record: unknown, dotted: string): unknown;
|
|
157
|
+
type Resolved = ReadonlyMap<string, string> | Readonly<Record<string, string>>;
|
|
158
|
+
/** Fill `{fields.project.key}` placeholders from already-resolved fields, in their order. */
|
|
159
|
+
export declare function substitute(template: string, resolved: Resolved): string;
|
|
160
|
+
/**
|
|
161
|
+
* True when the record's `by` path, coerced with `str()`, equals `value`.
|
|
162
|
+
*
|
|
163
|
+
* The coercion is the Python's: a boolean reads `True`, a missing path `None`,
|
|
164
|
+
* a nested object its `repr`. It is what lets `--resolve x:active=True` match a
|
|
165
|
+
* JSON `true`, and it is kept exactly.
|
|
166
|
+
*/
|
|
167
|
+
export declare function matchRecord(record: unknown, by: string, value: string): boolean;
|
|
168
|
+
/** Where a scan stopped, for the refusal that reports it. */
|
|
169
|
+
export interface ScanOutcome {
|
|
170
|
+
/** Records seen across every page fetched. */
|
|
171
|
+
scanned: number;
|
|
172
|
+
/** Page TRANSITIONS made: the number of pages fetched minus one. */
|
|
173
|
+
pages: number;
|
|
174
|
+
/** True when a cap, not the collection's end, stopped the scan. */
|
|
175
|
+
capped: boolean;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Page through a collection, yielding each page's records and returning where
|
|
179
|
+
* the scan stopped.
|
|
180
|
+
*
|
|
181
|
+
* Pages with exactly `nextPage=<token>` appended to the query. Guessing `page`
|
|
182
|
+
* / `pageToken` / `nextPageToken` is a documented way to loop forever on page
|
|
183
|
+
* one. Stops when the reply says there is no more, when it carries no token,
|
|
184
|
+
* at {@link MAX_SCANNED} records, or after {@link PAGE_GUARD} transitions.
|
|
185
|
+
*/
|
|
186
|
+
export declare function scanCollection(client: UipClient, connectorKey: string, collection: string, connectionId: string, query: string | undefined): AsyncGenerator<unknown[], ScanOutcome, void>;
|
|
187
|
+
/**
|
|
188
|
+
* The connector-facing id for one lookup, or throw {@link ResolutionRefusal}.
|
|
189
|
+
*
|
|
190
|
+
* `resolved` carries the values of fields resolved earlier in this run, which
|
|
191
|
+
* is what makes a `dependsOn` path addressable. Parameters follow the Python
|
|
192
|
+
* `resolve_one(connector_key, spec, field, by, value, connection_id, resolved)`
|
|
193
|
+
* with the client in front.
|
|
194
|
+
*
|
|
195
|
+
* A collection read to its end with no match throws {@link LookupExhausted},
|
|
196
|
+
* the subclass the caller's retry keys on; every other failure is the parent.
|
|
197
|
+
*/
|
|
198
|
+
export declare function resolveOne(client: UipClient, connectorKey: string, spec: ResolveSpec, field: string, by: string, value: string, connectionId: string, resolved?: Resolved): Promise<string>;
|
|
199
|
+
export {};
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a connector lookup field against a live connection.
|
|
3
|
+
*
|
|
4
|
+
* `prepare --resolve channel:profile.email=dustin@example.com` turns a
|
|
5
|
+
* human-meaningful value into the opaque id the connector wants, and records
|
|
6
|
+
* the answer so `compile` can read it offline. This module is the retrieval
|
|
7
|
+
* half.
|
|
8
|
+
*
|
|
9
|
+
* WHY IT LIVES HERE AND NOT IN COMPILE. Resolution needs the network; compile
|
|
10
|
+
* must not have it. The eval harness runs `check` and `compile` with no `uip`
|
|
11
|
+
* login, and the byte-lock gates require that the same source produces the
|
|
12
|
+
* same bytes — a compile that made live calls would make those gates depend on
|
|
13
|
+
* tenant state and they would stop meaning anything.
|
|
14
|
+
*
|
|
15
|
+
* THE STRATEGY IS DATA, NOT CODE. The spec says how to retrieve the collection:
|
|
16
|
+
* a `filterPattern` means one request; its absence means paging; `dependsOn`
|
|
17
|
+
* means another field must resolve first.
|
|
18
|
+
*
|
|
19
|
+
* Ported from `integrations/scripts/_resolve.py`; the refusal text is verbatim.
|
|
20
|
+
*/
|
|
21
|
+
import { pyFloatRepr } from "../codecs/pyjson.js";
|
|
22
|
+
import { pyStrip, pyStripChars, splitOnce } from "../compat/pystr.js";
|
|
23
|
+
import { Refusal } from "../errors.js";
|
|
24
|
+
import { isResourcesRunList } from "../uip/commands.js";
|
|
25
|
+
import { isPlainObject, notJsonReason, pyOr, pyRepr, pyStrAny, pyTruthy } from "./support.js";
|
|
26
|
+
/**
|
|
27
|
+
* A scan has to stop somewhere, and the caps must be set against the MEASURED
|
|
28
|
+
* page size rather than a guess. Integration Service serves **1,000 records
|
|
29
|
+
* per page** for a curated collection (verified against a live Slack
|
|
30
|
+
* connection: `curated_channels` returns 1000 with `HasMore: "true"`).
|
|
31
|
+
*
|
|
32
|
+
* The first version of this capped at 400 records — a number chosen for how
|
|
33
|
+
* many rows a person might scroll, which is the wrong unit entirely. It meant
|
|
34
|
+
* the loop broke partway through page ONE and never paginated at all. The eval
|
|
35
|
+
* task built to exercise this puts its target channel on page 3 (record
|
|
36
|
+
* ~2,001), so the resolver reported "not found" for a channel that is plainly
|
|
37
|
+
* there.
|
|
38
|
+
*
|
|
39
|
+
* 25 pages is ~30s of requests in the worst case, spent once, in an explicit
|
|
40
|
+
* command whose whole job is this. A collection with a `filterPattern` never
|
|
41
|
+
* reaches the scan at all.
|
|
42
|
+
*/
|
|
43
|
+
export const PAGE_SIZE_HINT = 1000;
|
|
44
|
+
export const MAX_SCANNED = 25 * PAGE_SIZE_HINT;
|
|
45
|
+
export const PAGE_GUARD = 50;
|
|
46
|
+
/** A lookup that could not be resolved, with the reason a human needs (`_resolve.ResolutionError`). */
|
|
47
|
+
export class ResolutionRefusal extends Refusal {
|
|
48
|
+
constructor(message, code) {
|
|
49
|
+
super(message, code);
|
|
50
|
+
this.name = "ResolutionRefusal";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The collection was read to the end on THIS connection and had no match
|
|
55
|
+
* (`_resolve.LookupExhausted`).
|
|
56
|
+
*
|
|
57
|
+
* Split out from its parent because the two failures want opposite responses
|
|
58
|
+
* and the caller cannot tell them apart from a message. A transport or
|
|
59
|
+
* permission failure says nothing about whether the value exists — retrying it
|
|
60
|
+
* on another connection would hide a broken call behind a confident "no
|
|
61
|
+
* connection has it". An exhaustive miss is positive evidence about this
|
|
62
|
+
* connection specifically, and is the signal `prepare` uses to demote it and
|
|
63
|
+
* try the next candidate (flow-builder-sdk#744).
|
|
64
|
+
*
|
|
65
|
+
* `capped` flips that: a scan stopped at the record cap did NOT reach the end,
|
|
66
|
+
* so it is not evidence of absence and must not demote anything.
|
|
67
|
+
*
|
|
68
|
+
* The attributes carry the counts so a caller can report per-candidate results
|
|
69
|
+
* without re-parsing the sentence.
|
|
70
|
+
*/
|
|
71
|
+
export class LookupExhausted extends ResolutionRefusal {
|
|
72
|
+
field;
|
|
73
|
+
collection;
|
|
74
|
+
by;
|
|
75
|
+
value;
|
|
76
|
+
scanned;
|
|
77
|
+
pages;
|
|
78
|
+
capped;
|
|
79
|
+
constructor(message, facts) {
|
|
80
|
+
super(message, "RESOLVE_NO_MATCH");
|
|
81
|
+
this.name = "LookupExhausted";
|
|
82
|
+
this.field = facts.field;
|
|
83
|
+
this.collection = facts.collection;
|
|
84
|
+
this.by = facts.by;
|
|
85
|
+
this.value = facts.value;
|
|
86
|
+
this.scanned = facts.scanned;
|
|
87
|
+
this.pages = facts.pages;
|
|
88
|
+
this.capped = facts.capped;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `channel:profile.email=dustin@example.com` -> `[field, by, value]`.
|
|
93
|
+
*
|
|
94
|
+
* Split on the FIRST colon and the FIRST `=`. Both halves can legitimately
|
|
95
|
+
* contain the other delimiter — `by` is a dotted path and a value can be
|
|
96
|
+
* anything a person typed — so a greedy split silently mis-parses real input.
|
|
97
|
+
*/
|
|
98
|
+
export function parseResolveFlag(raw) {
|
|
99
|
+
const [field, rest] = splitOnce(raw, ":");
|
|
100
|
+
if (rest === undefined || !field) {
|
|
101
|
+
throw new ResolutionRefusal(`--resolve ${pyRepr(raw)}: expected <field>:<by>=<value>, ` +
|
|
102
|
+
"e.g. --resolve channel:profile.email=someone@example.com", "RESOLVE_BAD_FLAG");
|
|
103
|
+
}
|
|
104
|
+
const [by, value] = splitOnce(rest, "=");
|
|
105
|
+
if (value === undefined || !by) {
|
|
106
|
+
throw new ResolutionRefusal(`--resolve ${pyRepr(raw)}: expected <field>:<by>=<value>, ` +
|
|
107
|
+
`but found no '=' after the search field ${pyRepr(by || rest)}.`, "RESOLVE_BAD_FLAG");
|
|
108
|
+
}
|
|
109
|
+
return [field, by, value];
|
|
110
|
+
}
|
|
111
|
+
function specOf(specs, field) {
|
|
112
|
+
if (specs instanceof Map)
|
|
113
|
+
return specs.get(field);
|
|
114
|
+
const table = specs;
|
|
115
|
+
return Object.prototype.hasOwnProperty.call(table, field) ? table[field] : undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Order the requests so a `dependsOn` prerequisite resolves first.
|
|
119
|
+
*
|
|
120
|
+
* 27 fields in the shipped library are not addressable until another field is
|
|
121
|
+
* known — Jira's issue type needs the project, Amazon Connect's phone number
|
|
122
|
+
* needs the instance. Resolving in the order the flags were typed leaves an
|
|
123
|
+
* unsubstituted `{placeholder}` in a URL, and the error the connector returns
|
|
124
|
+
* for that names neither field.
|
|
125
|
+
*
|
|
126
|
+
* A field requested twice keeps its first position and its LAST `by`/`value`,
|
|
127
|
+
* as the Python's dict did.
|
|
128
|
+
*/
|
|
129
|
+
export function orderResolutions(requested, specs) {
|
|
130
|
+
const byField = new Map();
|
|
131
|
+
for (const request of requested)
|
|
132
|
+
byField.set(request[0], request);
|
|
133
|
+
const ordered = [];
|
|
134
|
+
const placed = new Set();
|
|
135
|
+
const place = (field, trail) => {
|
|
136
|
+
if (placed.has(field))
|
|
137
|
+
return;
|
|
138
|
+
if (trail.includes(field)) {
|
|
139
|
+
const cycle = [...trail, field].join(" -> ");
|
|
140
|
+
throw new ResolutionRefusal(`circular dependsOn among lookup fields: ${cycle}`, "RESOLVE_CYCLE");
|
|
141
|
+
}
|
|
142
|
+
const dependsOn = specOf(specs, field)?.dependsOn;
|
|
143
|
+
for (const prerequisite of Array.isArray(dependsOn) ? dependsOn : []) {
|
|
144
|
+
if (byField.has(prerequisite)) {
|
|
145
|
+
place(prerequisite, [...trail, field]);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
throw new ResolutionRefusal(`cannot resolve ${pyRepr(field)}: its collection is addressed through ` +
|
|
149
|
+
`${pyRepr(prerequisite)}, which has no --resolve of its own. Add ` +
|
|
150
|
+
`--resolve ${prerequisite}:<by>=<value> and re-run.`, "RESOLVE_MISSING_PREREQUISITE");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
placed.add(field);
|
|
154
|
+
ordered.push(byField.get(field));
|
|
155
|
+
};
|
|
156
|
+
for (const [field] of requested)
|
|
157
|
+
place(field, []);
|
|
158
|
+
return ordered;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The records out of a `uip is resources run list` reply.
|
|
162
|
+
*
|
|
163
|
+
* The reply is `{"Result": "Success", "Code": ..., "Data": {"items": [...],
|
|
164
|
+
* "Pagination": {...}}}` — records live under `Data`, and `Result` is the
|
|
165
|
+
* STATUS STRING, not a container. Reading a top-level `items` finds nothing
|
|
166
|
+
* and reports "scanned 0 records" against a collection that is full, which is
|
|
167
|
+
* exactly what this did until an eval run walked it into the real CLI.
|
|
168
|
+
*/
|
|
169
|
+
export function envelopeItems(payload) {
|
|
170
|
+
if (!isPlainObject(payload))
|
|
171
|
+
return [];
|
|
172
|
+
const data = payload.Data;
|
|
173
|
+
if (isPlainObject(data) && Array.isArray(data.items))
|
|
174
|
+
return data.items;
|
|
175
|
+
if (Array.isArray(payload.items))
|
|
176
|
+
return payload.items;
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
/** The `Pagination` block, from under `Data`. */
|
|
180
|
+
export function envelopePagination(payload) {
|
|
181
|
+
if (!isPlainObject(payload))
|
|
182
|
+
return {};
|
|
183
|
+
const data = payload.Data;
|
|
184
|
+
if (isPlainObject(data) && isPlainObject(data.Pagination))
|
|
185
|
+
return data.Pagination;
|
|
186
|
+
if (isPlainObject(payload.Pagination))
|
|
187
|
+
return payload.Pagination;
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* `HasMore` arrives as the STRING "false" as often as the boolean.
|
|
192
|
+
*
|
|
193
|
+
* `if payload["Pagination"]["HasMore"]` on the string "false" is True, which
|
|
194
|
+
* pages forever on a collection that just said it has no more.
|
|
195
|
+
*/
|
|
196
|
+
export function truthy(value) {
|
|
197
|
+
if (typeof value === "string") {
|
|
198
|
+
const lowered = pyStrip(value).toLowerCase();
|
|
199
|
+
return lowered === "true" || lowered === "1" || lowered === "yes";
|
|
200
|
+
}
|
|
201
|
+
return pyTruthy(value);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The reviver that makes `JSON.parse` read a number the way `json.loads` did.
|
|
205
|
+
*
|
|
206
|
+
* Python kept every number as its text spelled it — `1.0` stayed a float,
|
|
207
|
+
* whose `str()` is `"1.0"`, and an integer stayed exact however many digits it
|
|
208
|
+
* had — so `str(got)` lost nothing on the way into `resolutions.json`.
|
|
209
|
+
* `JSON.parse` folds both cases: `1.0` parses to the integer `1`, and an
|
|
210
|
+
* integer above 2^53 is rounded to the nearest double, and the rounded number
|
|
211
|
+
* is what would have been recorded as the id. The reviver's third argument
|
|
212
|
+
* (`JSON.parse` source text access, present in every Node the package
|
|
213
|
+
* supports — `engines` says >= 22.6) is the spelling itself, so the two lossy
|
|
214
|
+
* cases are rewritten here as the string Python's `str()` would have produced.
|
|
215
|
+
* {@link matchRecord} and the recorded id only ever see that string, so
|
|
216
|
+
* nothing downstream can tell the difference — except that it is now right,
|
|
217
|
+
* and a typed `--resolve x:count=1.0` matches a JSON `1.0` as it did in Python.
|
|
218
|
+
*/
|
|
219
|
+
export function reviveLikeJsonLoads(_key, value, context) {
|
|
220
|
+
if (typeof value !== "number")
|
|
221
|
+
return value;
|
|
222
|
+
const source = context?.source;
|
|
223
|
+
if (source === undefined) {
|
|
224
|
+
// An engine without source access. The integral float is undetectable
|
|
225
|
+
// here; a rounded integer is not, and must not be written as if exact.
|
|
226
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
|
227
|
+
throw new ResolutionRefusal(`a record carries the integer ${String(value)}, which this JavaScript engine ` +
|
|
228
|
+
"rounded while parsing and cannot read exactly; run on Node >= 22.6.", "RESOLVE_IMPRECISE_NUMBER");
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
if (/[.eE]/.test(source)) {
|
|
233
|
+
// A float in Python. `pyStr` already renders a non-integral double as
|
|
234
|
+
// Python's repr; only an integral one loses its `.0` (or exponent form).
|
|
235
|
+
return Number.isInteger(value) ? pyFloatRepr(value) : value;
|
|
236
|
+
}
|
|
237
|
+
// An int in Python: exact whatever its length, and `-0` is plain `0`.
|
|
238
|
+
if (Object.is(value, -0))
|
|
239
|
+
return "0";
|
|
240
|
+
return Number.isSafeInteger(value) ? value : source;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The first JSON document in a listing's stdout, or `undefined`.
|
|
244
|
+
*
|
|
245
|
+
* The same search `uip/envelope.ts`'s `findJsonDocument` makes — the whole
|
|
246
|
+
* text first, then every line that begins with a bracket, since the banner the
|
|
247
|
+
* CLI prints ahead of its payload never starts a line with one. Repeated here
|
|
248
|
+
* because that module has no hook for a reviver and only the resolver needs
|
|
249
|
+
* one: nowhere else does a JSON number become an id that is written down.
|
|
250
|
+
*/
|
|
251
|
+
function findJsonDocument(text, reviver) {
|
|
252
|
+
const trimmed = text.trim();
|
|
253
|
+
if (trimmed === "")
|
|
254
|
+
return undefined;
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(trimmed, reviver);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error instanceof ResolutionRefusal)
|
|
260
|
+
throw error;
|
|
261
|
+
// fall through to the line scan
|
|
262
|
+
}
|
|
263
|
+
const lines = text.split(/\r?\n/);
|
|
264
|
+
for (let i = 0; i < lines.length; i++) {
|
|
265
|
+
const head = lines[i].trimStart();
|
|
266
|
+
if (!head.startsWith("{") && !head.startsWith("["))
|
|
267
|
+
continue;
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(lines.slice(i).join("\n"), reviver);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error instanceof ResolutionRefusal)
|
|
273
|
+
throw error;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The plainly parsed envelope with only its RECORD values revived.
|
|
281
|
+
*
|
|
282
|
+
* {@link reviveLikeJsonLoads} is about ids: a record value that a flow will
|
|
283
|
+
* send back to the provider has to keep the spelling Python's `str()` gave it.
|
|
284
|
+
* The envelope AROUND the records is control data the code reads itself, and
|
|
285
|
+
* rewriting a number there changes behaviour rather than preserving it —
|
|
286
|
+
* `HasMore: 1.0` became the string `"1.0"`, which {@link truthy} reads as
|
|
287
|
+
* false, so paging stopped on a collection that had just said it has more.
|
|
288
|
+
* (Only that spelling flips: `true`, `1`, `false`, `"false"` and `0` all
|
|
289
|
+
* survive the reviver unchanged, which is why it went unnoticed.)
|
|
290
|
+
*
|
|
291
|
+
* So the records come from the revived parse and everything else from a plain
|
|
292
|
+
* one. A second `JSON.parse` of a page of listing output is cheap next to the
|
|
293
|
+
* `uip` call that produced it. A document that is not an envelope has no
|
|
294
|
+
* control fields to protect and stays fully revived.
|
|
295
|
+
*/
|
|
296
|
+
function recordsRevived(plain, revived) {
|
|
297
|
+
if (!isPlainObject(plain) || !isPlainObject(revived))
|
|
298
|
+
return revived;
|
|
299
|
+
const data = plain.Data;
|
|
300
|
+
const revivedData = revived.Data;
|
|
301
|
+
if (isPlainObject(data) && Array.isArray(data.items) && isPlainObject(revivedData) && Array.isArray(revivedData.items)) {
|
|
302
|
+
return { ...plain, Data: { ...data, items: revivedData.items } };
|
|
303
|
+
}
|
|
304
|
+
// The flat shape {@link envelopeItems} falls back to.
|
|
305
|
+
if (Array.isArray(plain.items) && Array.isArray(revived.items))
|
|
306
|
+
return { ...plain, items: revived.items };
|
|
307
|
+
return plain;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The first JSON document in a listing's stdout, with its record values parsed
|
|
311
|
+
* as `json.loads` would have read them, or `undefined`.
|
|
312
|
+
*/
|
|
313
|
+
export function parseListingDocument(text) {
|
|
314
|
+
const revived = findJsonDocument(text, reviveLikeJsonLoads);
|
|
315
|
+
if (revived === undefined)
|
|
316
|
+
return undefined;
|
|
317
|
+
const plain = findJsonDocument(text);
|
|
318
|
+
return plain === undefined ? revived : recordsRevived(plain, revived);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* One `uip is resources run list` page, as the parsed envelope.
|
|
322
|
+
*
|
|
323
|
+
* `uip` prints a self-update failure to stderr before its real output, so
|
|
324
|
+
* stderr being non-empty is not evidence of anything. Judge the exit code and
|
|
325
|
+
* the payload.
|
|
326
|
+
*/
|
|
327
|
+
export async function listPage(client, connectorKey, collection, connectionId, query) {
|
|
328
|
+
const result = await client.run(isResourcesRunList(connectorKey, collection, connectionId, query));
|
|
329
|
+
if (result.code !== 0) {
|
|
330
|
+
throw new ResolutionRefusal(`listing ${collection} failed: ${pyStrip(result.stderr) || "no output"}`, "RESOLVE_LISTING_FAILED");
|
|
331
|
+
}
|
|
332
|
+
const envelope = parseListingDocument(result.stdout);
|
|
333
|
+
if (!isPlainObject(envelope)) {
|
|
334
|
+
throw new ResolutionRefusal(`listing ${collection} returned output that is not JSON: ${notJsonReason(result.stdout)}`, "RESOLVE_LISTING_NOT_JSON");
|
|
335
|
+
}
|
|
336
|
+
return envelope;
|
|
337
|
+
}
|
|
338
|
+
/** `profile.email` out of a record, or `null` (Python's `None`) when any step is missing. */
|
|
339
|
+
export function readPath(record, dotted) {
|
|
340
|
+
let cursor = record;
|
|
341
|
+
for (const part of dotted.split(".")) {
|
|
342
|
+
if (!isPlainObject(cursor))
|
|
343
|
+
return null;
|
|
344
|
+
cursor = Object.prototype.hasOwnProperty.call(cursor, part) ? (cursor[part] ?? null) : null;
|
|
345
|
+
}
|
|
346
|
+
return cursor;
|
|
347
|
+
}
|
|
348
|
+
function resolvedEntries(resolved) {
|
|
349
|
+
return resolved instanceof Map ? [...resolved.entries()] : Object.entries(resolved);
|
|
350
|
+
}
|
|
351
|
+
/** Fill `{fields.project.key}` placeholders from already-resolved fields, in their order. */
|
|
352
|
+
export function substitute(template, resolved) {
|
|
353
|
+
let out = template;
|
|
354
|
+
for (const [field, value] of resolvedEntries(resolved)) {
|
|
355
|
+
out = out.replaceAll(`{${field}}`, pyStrAny(value));
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* True when the record's `by` path, coerced with `str()`, equals `value`.
|
|
361
|
+
*
|
|
362
|
+
* The coercion is the Python's: a boolean reads `True`, a missing path `None`,
|
|
363
|
+
* a nested object its `repr`. It is what lets `--resolve x:active=True` match a
|
|
364
|
+
* JSON `true`, and it is kept exactly.
|
|
365
|
+
*/
|
|
366
|
+
export function matchRecord(record, by, value) {
|
|
367
|
+
return pyStrAny(readPath(record, by)) === value;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Page through a collection, yielding each page's records and returning where
|
|
371
|
+
* the scan stopped.
|
|
372
|
+
*
|
|
373
|
+
* Pages with exactly `nextPage=<token>` appended to the query. Guessing `page`
|
|
374
|
+
* / `pageToken` / `nextPageToken` is a documented way to loop forever on page
|
|
375
|
+
* one. Stops when the reply says there is no more, when it carries no token,
|
|
376
|
+
* at {@link MAX_SCANNED} records, or after {@link PAGE_GUARD} transitions.
|
|
377
|
+
*/
|
|
378
|
+
export async function* scanCollection(client, connectorKey, collection, connectionId, query) {
|
|
379
|
+
let scanned = 0;
|
|
380
|
+
let pages = 0;
|
|
381
|
+
for (;;) {
|
|
382
|
+
const payload = await listPage(client, connectorKey, collection, connectionId, query);
|
|
383
|
+
const items = envelopeItems(payload);
|
|
384
|
+
scanned += items.length;
|
|
385
|
+
yield items;
|
|
386
|
+
const pagination = envelopePagination(payload);
|
|
387
|
+
const token = pagination.NextPageToken;
|
|
388
|
+
if (!truthy(pagination.HasMore) || !pyTruthy(token) || scanned >= MAX_SCANNED)
|
|
389
|
+
break;
|
|
390
|
+
pages += 1;
|
|
391
|
+
if (pages > PAGE_GUARD)
|
|
392
|
+
break;
|
|
393
|
+
// Exactly `nextPage`. Guessing `page` / `pageToken` / `nextPageToken`
|
|
394
|
+
// is a documented way to loop forever on page one.
|
|
395
|
+
query = query ? `${query}&nextPage=${pyStrAny(token)}` : `nextPage=${pyStrAny(token)}`;
|
|
396
|
+
}
|
|
397
|
+
return { scanned, pages, capped: scanned >= MAX_SCANNED || pages > PAGE_GUARD };
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The connector-facing id for one lookup, or throw {@link ResolutionRefusal}.
|
|
401
|
+
*
|
|
402
|
+
* `resolved` carries the values of fields resolved earlier in this run, which
|
|
403
|
+
* is what makes a `dependsOn` path addressable. Parameters follow the Python
|
|
404
|
+
* `resolve_one(connector_key, spec, field, by, value, connection_id, resolved)`
|
|
405
|
+
* with the client in front.
|
|
406
|
+
*
|
|
407
|
+
* A collection read to its end with no match throws {@link LookupExhausted},
|
|
408
|
+
* the subclass the caller's retry keys on; every other failure is the parent.
|
|
409
|
+
*/
|
|
410
|
+
export async function resolveOne(client, connectorKey, spec, field, by, value, connectionId, resolved = new Map()) {
|
|
411
|
+
const searchableBy = Array.isArray(spec.by) ? spec.by : [];
|
|
412
|
+
if (!searchableBy.includes(by)) {
|
|
413
|
+
const searchable = searchableBy.map(pyRepr).join(", ");
|
|
414
|
+
throw new ResolutionRefusal(`${field}: cannot search by ${pyRepr(by)}. Searchable by ${searchable || "(nothing declared)"}.`, "RESOLVE_BAD_BY");
|
|
415
|
+
}
|
|
416
|
+
const path = substitute(pyStrAny(pyOr(spec.path, "")), resolved);
|
|
417
|
+
if (path.includes("{")) {
|
|
418
|
+
const open = path.indexOf("{");
|
|
419
|
+
const close = path.indexOf("}");
|
|
420
|
+
const missing = close >= 0 ? path.slice(open + 1, close) : "?";
|
|
421
|
+
throw new ResolutionRefusal(`${field}: the collection path still contains {${missing}}. ` +
|
|
422
|
+
`Resolve ${pyRepr(missing)} first with --resolve ${missing}:<by>=<value>.`, "RESOLVE_UNSUBSTITUTED");
|
|
423
|
+
}
|
|
424
|
+
const [beforeQuery, afterQuery] = splitOnce(path, "?");
|
|
425
|
+
const collection = pyStripChars(beforeQuery, "/");
|
|
426
|
+
let query = afterQuery || undefined;
|
|
427
|
+
const send = pyStrAny(pyOr(spec.value, "id"));
|
|
428
|
+
// A server-side filter turns the whole scan into one request. Where the
|
|
429
|
+
// metadata declares one, use it; where it does not, page.
|
|
430
|
+
const filterPattern = spec.filterPattern;
|
|
431
|
+
if (pyTruthy(filterPattern)) {
|
|
432
|
+
const filtered = pyStrAny(filterPattern).replaceAll("{filter}", value);
|
|
433
|
+
query = query ? `${query}&${filtered}` : filtered;
|
|
434
|
+
}
|
|
435
|
+
const scan = scanCollection(client, connectorKey, collection, connectionId, query);
|
|
436
|
+
let step = await scan.next();
|
|
437
|
+
while (!step.done) {
|
|
438
|
+
for (const record of step.value) {
|
|
439
|
+
if (matchRecord(record, by, value)) {
|
|
440
|
+
const got = readPath(record, send);
|
|
441
|
+
if (got === null) {
|
|
442
|
+
throw new ResolutionRefusal(`${field}: matched a record on ${by}=${pyRepr(value)}, but it ` +
|
|
443
|
+
`carries no ${pyRepr(send)} to send.`, "RESOLVE_NO_VALUE");
|
|
444
|
+
}
|
|
445
|
+
return pyStrAny(got);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
step = await scan.next();
|
|
449
|
+
}
|
|
450
|
+
const { scanned, pages, capped } = step.value;
|
|
451
|
+
// This states the FACT and stops; `prepare` owns the remedy, because
|
|
452
|
+
// `prepare` is what knows the other candidate connections.
|
|
453
|
+
//
|
|
454
|
+
// It deliberately carries NO "check the value" and NO `uip is resources`
|
|
455
|
+
// pointer. The old text led with both: it named only the value as suspect
|
|
456
|
+
// when the connection is equally suspect, and then sent the reader to a
|
|
457
|
+
// tenant crawl the skill explicitly rules out ("Tenant discovery is not a
|
|
458
|
+
// phase of either loop", references/CLI-LOOP.md). An agent followed that
|
|
459
|
+
// advice into exactly what it had been told not to do — 50 of 94 Bash calls
|
|
460
|
+
// hand-crawling Integration Service, 41 of them paging this collection, and
|
|
461
|
+
// the run hit its ceiling without producing a flow (flow-builder-sdk#744).
|
|
462
|
+
//
|
|
463
|
+
// The listing command is still offered, once, by the caller's terminal
|
|
464
|
+
// report — where every connection has been tried and seeing what IS there
|
|
465
|
+
// is finally the next step rather than a detour.
|
|
466
|
+
throw new LookupExhausted(`${field}: no record in ${pyRepr(collection)} has ${by}=${pyRepr(value)} ` +
|
|
467
|
+
`(scanned ${scanned} record(s) across ${pages + 1} page(s)` +
|
|
468
|
+
(capped ? `; STOPPED AT THE ${MAX_SCANNED}-record cap, so the value may be further in` : "; collection exhausted") +
|
|
469
|
+
`) on connection ${connectionId}.`, { field, collection, by, value, scanned, pages: pages + 1, capped });
|
|
470
|
+
}
|