@unoverse-platform/studio 0.1.90 → 0.1.92
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/local/plugin.ts +21 -65
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +153 -0
- package/vendor/base/src/agent-mcp/lean.ts +114 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +138 -0
- package/vendor/base/src/auth/auth.ts +307 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +155 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +659 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +246 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +212 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +82 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +110 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +156 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +196 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +157 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
- package/local/mcp.ts +0 -265
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { evaluate } from "../templating.js";
|
|
2
|
+
import { decodeDynamoJson } from "../auth/aws.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reading a response off the wire. Bytes to payloads, and nothing else.
|
|
6
|
+
*
|
|
7
|
+
* Part of the manifest runtime (DECLARATIVE_NODES.md §2): the manifest DESCRIBES the
|
|
8
|
+
* service, this half COMPUTES it. Split by concern so each piece stays readable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Emission {
|
|
12
|
+
/**
|
|
13
|
+
* The output connector this fired on. Absent on a `send` row, which addresses a NODE
|
|
14
|
+
* instead of a connector and therefore has no dot on the canvas and no edge to draw.
|
|
15
|
+
*/
|
|
16
|
+
emit?: string;
|
|
17
|
+
/** Target NODE id, resolved from the row's `send` template. Present only on a send row. */
|
|
18
|
+
to?: string;
|
|
19
|
+
/** Input handle on the target node. Defaults to `input`. */
|
|
20
|
+
handle?: string;
|
|
21
|
+
value: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a Server-Sent Events stream into parsed payloads.
|
|
26
|
+
*
|
|
27
|
+
* Reading and DECIDING are separate: this turns bytes into events, and events.ts decides
|
|
28
|
+
* what leaves the node. Keeping them apart is why the tool loop can watch the same stream
|
|
29
|
+
* for a tool call without the emission table knowing anything about tools.
|
|
30
|
+
*/
|
|
31
|
+
export async function readSse(res: Response, terminator: string | undefined, onPayload: (payload: any) => Promise<void>): Promise<void> {
|
|
32
|
+
const reader = res.body?.getReader();
|
|
33
|
+
if (!reader) throw new Error("response has no body to stream");
|
|
34
|
+
const decoder = new TextDecoder();
|
|
35
|
+
let buffer = "";
|
|
36
|
+
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done) break;
|
|
40
|
+
buffer += decoder.decode(value, { stream: true });
|
|
41
|
+
|
|
42
|
+
// SSE frames are separated by a blank line; a frame's data may span lines.
|
|
43
|
+
let split: number;
|
|
44
|
+
while ((split = buffer.indexOf("\n\n")) !== -1) {
|
|
45
|
+
const frame = buffer.slice(0, split);
|
|
46
|
+
buffer = buffer.slice(split + 2);
|
|
47
|
+
|
|
48
|
+
const data = frame
|
|
49
|
+
.split("\n")
|
|
50
|
+
.filter((l) => l.startsWith("data:"))
|
|
51
|
+
.map((l) => l.slice(5).trim())
|
|
52
|
+
.join("");
|
|
53
|
+
if (!data || (terminator && data === terminator)) continue;
|
|
54
|
+
|
|
55
|
+
let payload: any;
|
|
56
|
+
try {
|
|
57
|
+
payload = JSON.parse(data);
|
|
58
|
+
} catch {
|
|
59
|
+
continue; // a keep-alive or comment frame
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await onPayload(payload);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read a settled response body, per the declared transport and encoding.
|
|
69
|
+
*
|
|
70
|
+
* `encoding` is a second axis to `transport`: transport says how the reply is FRAMED
|
|
71
|
+
* (one body, or a stream of events), encoding says how the values inside it are SPELLED.
|
|
72
|
+
* DynamoDB is JSON-framed and type-tagged, so it needs both.
|
|
73
|
+
*/
|
|
74
|
+
export async function readSettled(res: Response, transport: string, encoding?: string): Promise<any> {
|
|
75
|
+
if (transport === "text") return res.text();
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* THE HEADERS ARE THE ANSWER. A HEAD request has no body by definition: the size, the
|
|
79
|
+
* content type and the modified date all come back as headers and there is nothing else.
|
|
80
|
+
*
|
|
81
|
+
* Reading it as `text` gave an empty string and a manifest with no way to reach any of it,
|
|
82
|
+
* which is what makes this a transport rather than a flag. Lower-cased keys, because HTTP
|
|
83
|
+
* header names are case-insensitive and an expression asking for `content-length` should
|
|
84
|
+
* not depend on what the vendor happened to capitalise.
|
|
85
|
+
*/
|
|
86
|
+
if (transport === "headers") {
|
|
87
|
+
const out: Record<string, string> = {};
|
|
88
|
+
res.headers.forEach((value, key) => {
|
|
89
|
+
out[key.toLowerCase()] = value;
|
|
90
|
+
});
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* XML, for the vendors that never moved. S3's list endpoints are the reason.
|
|
96
|
+
*
|
|
97
|
+
* Parsed to the same plain object shape a JSON reply would give, so an events table reads
|
|
98
|
+
* `response.ListBucketResult.Contents` either way and nothing downstream knows or cares
|
|
99
|
+
* which wire format it came from.
|
|
100
|
+
*
|
|
101
|
+
* `isArray` is the setting that matters. XML cannot distinguish one element from a list of
|
|
102
|
+
* one, so a bucket holding a SINGLE object parses `Contents` as an object while a bucket
|
|
103
|
+
* holding two parses it as an array. A manifest written against the second silently breaks
|
|
104
|
+
* on the first, and the failure only appears once a bucket happens to have one file in it.
|
|
105
|
+
* Naming the repeatable elements up front makes them always arrays.
|
|
106
|
+
*/
|
|
107
|
+
if (transport === "xml") {
|
|
108
|
+
const { XMLParser } = await import("fast-xml-parser");
|
|
109
|
+
const parser = new XMLParser({
|
|
110
|
+
ignoreAttributes: false,
|
|
111
|
+
attributeNamePrefix: "@",
|
|
112
|
+
isArray: (name) => XML_REPEATED.has(name),
|
|
113
|
+
});
|
|
114
|
+
return parser.parse(await res.text());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* BYTES. Audio a model generated, an image to send on to another model, a file to store.
|
|
119
|
+
*
|
|
120
|
+
* Handed over as BASE64 rather than a Buffer or a stream, because what leaves a node
|
|
121
|
+
* travels on the event bus and through JSON: a Buffer does not survive that trip, and a
|
|
122
|
+
* stream cannot be handed to two consumers. base64 is the form every downstream thing can
|
|
123
|
+
* actually hold.
|
|
124
|
+
*
|
|
125
|
+
* `contentType` comes along because bytes without their type are unusable — the receiver
|
|
126
|
+
* has to guess whether it is holding an mp3 or a PDF — and the vendor already said.
|
|
127
|
+
*/
|
|
128
|
+
if (transport === "binary") {
|
|
129
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
130
|
+
return {
|
|
131
|
+
base64: bytes.toString("base64"),
|
|
132
|
+
contentType: res.headers.get("content-type") || "application/octet-stream",
|
|
133
|
+
bytes: bytes.length,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const payload = await res.json().catch(() => ({}));
|
|
138
|
+
return encoding === "dynamodbJson" ? decodeDynamoJson(payload) : payload;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Elements that are a LIST even when there is one of them.
|
|
143
|
+
*
|
|
144
|
+
* Named rather than inferred, because the ambiguity is in XML itself and no parser can
|
|
145
|
+
* resolve it without being told. Add to this when a node meets a new repeatable element;
|
|
146
|
+
* getting it wrong shows up as "works with 2 files, breaks with 1".
|
|
147
|
+
*/
|
|
148
|
+
const XML_REPEATED = new Set(["Contents", "CommonPrefixes", "Bucket", "Version", "DeleteMarker", "member", "item"]);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A 200 carrying an error body must not read as success.
|
|
152
|
+
*
|
|
153
|
+
* This was declared by every node and implemented on the service channel only, so on the
|
|
154
|
+
* workflow channel a failed stream emitted nothing and reported success. Same class of
|
|
155
|
+
* gap as `retry`: named in the schema, absent from the executor.
|
|
156
|
+
*/
|
|
157
|
+
export async function assertOk(node: unknown, call: any, payload: any, where: string): Promise<void> {
|
|
158
|
+
if (!call.error?.when) return;
|
|
159
|
+
if (!(await evaluate(call.error.when, { response: payload }))) return;
|
|
160
|
+
const message = call.error.message ? await evaluate(call.error.message, { response: payload }) : "upstream error";
|
|
161
|
+
throw new Error(`${where}: ${message}`);
|
|
162
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MANIFEST RUNTIME — the half that COMPUTES what a manifest DESCRIBES.
|
|
3
|
+
*
|
|
4
|
+
* The split (DECLARATIVE_NODES.md §2): every enum value in api.schema.json must have an
|
|
5
|
+
* implementation somewhere in this folder and NOWHERE else, which is what makes accepting a
|
|
6
|
+
* manifest from someone else safe. `manifest-capabilities.test.ts` enforces that parity by
|
|
7
|
+
* walking the schema and this tree, so a capability offered without code fails the build rather
|
|
8
|
+
* than a stranger's workflow.
|
|
9
|
+
*
|
|
10
|
+
* Nothing in here is per-vendor. Adding an auth scheme or a transport makes it available to
|
|
11
|
+
* every node that will ever be written.
|
|
12
|
+
*
|
|
13
|
+
* THIS FILE IS A BARREL and should stay one. It held the two channels, the narrator and the
|
|
14
|
+
* last-call dispatch until it reached 425 lines of four unrelated concerns, which is the shape
|
|
15
|
+
* of file nobody reads and everybody appends to. One import surface, no logic:
|
|
16
|
+
*
|
|
17
|
+
* context.ts what a manifest may see, and the resolvers applied first
|
|
18
|
+
* templating.ts {{ }} and `return ...`, both borrowed from the platform
|
|
19
|
+
* events.ts the events table: everything that LEAVES a node, in one ordered list
|
|
20
|
+
* narrate.ts the running commentary while it works
|
|
21
|
+
* state.ts platform storage, and the run's saved context
|
|
22
|
+
* channels/ the two ways a node is reached, which never cross
|
|
23
|
+
* workflow.ts the GRAPH runs it: the shape of a run, start to finish
|
|
24
|
+
* final.ts the last call, the only one that may stream — one dispatch per reply shape
|
|
25
|
+
* service.ts an AGENT calls it: one method, one value handed straight back
|
|
26
|
+
* http/ building and sending. One fetch, one allowedHosts check
|
|
27
|
+
* auth/ the schemes, per scheme
|
|
28
|
+
* loops/ many requests, or many passes, for one node: paginate, poll, chunk, loop
|
|
29
|
+
* duplex/ a socket that stays open. Two of them: the vendor's and the audio lane
|
|
30
|
+
* tools/ the HTTP tool exchange, by turns
|
|
31
|
+
*/
|
|
32
|
+
export type { RunContext } from "./context.js";
|
|
33
|
+
export type { Emission } from "./http/response.js";
|
|
34
|
+
export { emptyContext, applyResolvers, RESOLVERS } from "./context.js";
|
|
35
|
+
export { render, evaluate, primeTemplating } from "./templating.js";
|
|
36
|
+
export { assertAllowedHost } from "./http/allowedHosts.js";
|
|
37
|
+
export { buildRequest, resolveBody, sendRequest, runCalls } from "./http/request.js";
|
|
38
|
+
export { readSse, readSettled, assertOk } from "./http/response.js";
|
|
39
|
+
export { makeStateStore, performState, type StateStore } from "./state.js";
|
|
40
|
+
export { fetchPaginated } from "./loops/paginate.js";
|
|
41
|
+
export { fetchPolled } from "./loops/poll.js";
|
|
42
|
+
export { sendChunked } from "./loops/chunk.js";
|
|
43
|
+
// Iteration bookkeeping for the LoopStart/LoopEnd pair. ONE capability rather than the seven raw
|
|
44
|
+
// Redis commands the retired nodes reached for — see loops/loop.ts for why that distinction is
|
|
45
|
+
// the whole point.
|
|
46
|
+
export { performLoop, normaliseItems, makeLoopOps, type LoopOps, type LoopPass, type LoopStep } from "./loops/loop.js";
|
|
47
|
+
export { makeEmitter, type Emitter, type EventSource } from "./events.js";
|
|
48
|
+
export { makeNarrator, type Narrator } from "./narrate.js";
|
|
49
|
+
export { runToolLoop, type ToolBridge } from "./tools/toolloop.js";
|
|
50
|
+
export { performDoc, MUTATING_DOC_OPS, docKeyFor } from "./docstore/index.js";
|
|
51
|
+
export { runDuplexSession } from "./duplex/session.js";
|
|
52
|
+
// The audio lane is its own module: session.ts owns the VENDOR socket, audioLane.ts owns the bytes
|
|
53
|
+
// and the browser. Two sockets, two files.
|
|
54
|
+
export { resamplePcm16, makeAudioBuffer, CLIENT_RATE, type AudioLane } from "./duplex/audioLane.js";
|
|
55
|
+
export { performApi, type RunResult } from "./channels/workflow.js";
|
|
56
|
+
export { runFinal } from "./channels/final.js";
|
|
57
|
+
export { performService } from "./channels/service.js";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHUNKED WRITES. One call in the manifest, many requests on the wire.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of pagination: that one loops because the vendor decides how much comes back,
|
|
5
|
+
* this one loops because the vendor decides how much may go in at once. Airtable takes 10
|
|
6
|
+
* records per write, HubSpot 100, Salesforce 200, so writing a collection of unknown length
|
|
7
|
+
* is N requests and a manifest has no way to repeat a call N times.
|
|
8
|
+
*
|
|
9
|
+
* The loop is computation, the batch size and the body shape are description, which is the
|
|
10
|
+
* usual split. Every node that writes a collection inherits this.
|
|
11
|
+
*
|
|
12
|
+
* The reply is `{ sent, batches, results, errors }`. Partial success is the normal outcome
|
|
13
|
+
* of a batched write and it must be visible: one rejected batch out of ten is not a failed
|
|
14
|
+
* call, and it is not a successful one either.
|
|
15
|
+
*/
|
|
16
|
+
import type { ComposedNode } from "../../compose.js";
|
|
17
|
+
import type { RunContext } from "../context.js";
|
|
18
|
+
import { evaluate } from "../templating.js";
|
|
19
|
+
import { sendRequest } from "../http/request.js";
|
|
20
|
+
import { fetchPolled } from "./poll.js";
|
|
21
|
+
import { readSettled } from "../http/response.js";
|
|
22
|
+
|
|
23
|
+
export interface Chunked {
|
|
24
|
+
/** Items actually accepted, across every batch that succeeded. */
|
|
25
|
+
sent: number;
|
|
26
|
+
batches: number;
|
|
27
|
+
/**
|
|
28
|
+
* What the walk was OVER, in order — the resolved collection, not a count of it.
|
|
29
|
+
*
|
|
30
|
+
* The other four fields all describe what HAPPENED, and for a write that is the whole
|
|
31
|
+
* story. Fanning out a READ needs the input as well: pairing reply i back to the thing
|
|
32
|
+
* that produced it is impossible from a summary, and re-deriving the list in the events
|
|
33
|
+
* table means the same expression written twice, free to drift.
|
|
34
|
+
*/
|
|
35
|
+
items: unknown[];
|
|
36
|
+
/**
|
|
37
|
+
* One entry per batch, IN ORDER, `null` where the batch failed.
|
|
38
|
+
*
|
|
39
|
+
* Positional, not compacted. A write only ever reads `sent`, so the difference did not
|
|
40
|
+
* show until `chunk` was used to fan out a READ (one item per request), where the caller
|
|
41
|
+
* has to pair each reply back to the item that produced it. Dropping the failures would
|
|
42
|
+
* shift every later reply onto the wrong item and label the results wrongly, which is
|
|
43
|
+
* worse than an error, because it looks like an answer.
|
|
44
|
+
*/
|
|
45
|
+
results: unknown[];
|
|
46
|
+
/** One entry per FAILED batch, naming which. Empty means every batch landed. */
|
|
47
|
+
errors: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Independent of anything the manifest says, so a bad `size` cannot melt a vendor. */
|
|
51
|
+
const HARD_BATCH_CAP = 200;
|
|
52
|
+
|
|
53
|
+
export async function sendChunked(
|
|
54
|
+
node: ComposedNode,
|
|
55
|
+
call: any,
|
|
56
|
+
ctx: RunContext,
|
|
57
|
+
label: string,
|
|
58
|
+
): Promise<Chunked> {
|
|
59
|
+
const c = call.chunk;
|
|
60
|
+
const all = await evaluate(c.items, ctx as unknown as Record<string, unknown>);
|
|
61
|
+
const items: unknown[] = Array.isArray(all) ? all : [];
|
|
62
|
+
const size = Math.min(Math.max(Number(c.size) || 1, 1), HARD_BATCH_CAP);
|
|
63
|
+
|
|
64
|
+
const out: Chunked = { sent: 0, batches: 0, items, results: [], errors: [] };
|
|
65
|
+
if (!items.length) return out;
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < items.length; i += size) {
|
|
68
|
+
const batch = items.slice(i, i + size);
|
|
69
|
+
const n = out.batches + 1;
|
|
70
|
+
|
|
71
|
+
// `batch` is in scope for the body, which is the whole point: the manifest describes
|
|
72
|
+
// what ONE request looks like for a slice, and this repeats it.
|
|
73
|
+
const scoped = { ...ctx, batch } as RunContext;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// The two loops COMPOSE, because they are orthogonal: this one walks the collection,
|
|
77
|
+
// `poll` waits on one request. A vendor whose endpoint takes a single item AND runs
|
|
78
|
+
// it as a job (Hyperbrowser's fetch is both) needs both, and declaring them mutually
|
|
79
|
+
// exclusive would put that node back in TypeScript for no reason.
|
|
80
|
+
out.results.push(
|
|
81
|
+
call.poll
|
|
82
|
+
? await fetchPolled(node, call, scoped, `${label} (batch ${n})`)
|
|
83
|
+
: await readSettled(await sendRequest(node, call, scoped, `${label} (batch ${n})`), call.transport, call.encoding),
|
|
84
|
+
);
|
|
85
|
+
out.sent += batch.length;
|
|
86
|
+
} catch (err: any) {
|
|
87
|
+
// KEEP GOING. A batched write that abandons everything because batch 3 of 10 failed
|
|
88
|
+
// is worse than one that reports which: the first two already landed and cannot be
|
|
89
|
+
// taken back, so stopping leaves the caller unable to tell what happened.
|
|
90
|
+
out.errors.push(`batch ${n}: ${err?.message ?? err}`);
|
|
91
|
+
// Holds the slot, so `results[i]` is always the reply to batch `i`.
|
|
92
|
+
out.results.push(null);
|
|
93
|
+
}
|
|
94
|
+
out.batches = n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ITERATION BOOKKEEPING — the capability behind the LoopStart / LoopEnd pair.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS ONE CAPABILITY AND NOT SEVERAL STATE OPS. The retired executors reached for
|
|
5
|
+
* hset, hget, hincrby, rpush, lrange, exists, del and expire. Declaring those one by one in
|
|
6
|
+
* api.schema.json would have amounted to "a manifest may run arbitrary Redis", which is the
|
|
7
|
+
* exact authority this format exists to withhold (DECLARATIVE_NODES.md §2). So the manifest
|
|
8
|
+
* names the three things a loop actually DOES, and the key layout, the counter and the
|
|
9
|
+
* teardown stay in here where they can be read once and audited:
|
|
10
|
+
*
|
|
11
|
+
* open take an array, record it, hand back the first item
|
|
12
|
+
* read hand back the item at the CURRENT index, without moving it
|
|
13
|
+
* advance collect this pass's value, move the index, and say whether there is more
|
|
14
|
+
*
|
|
15
|
+
* THE PAIR IS NOT AN ACTOR, which is the thing to understand before changing any of this.
|
|
16
|
+
* There is no XState access and no generator: LoopStart is an ordinary settle-once node that
|
|
17
|
+
* fires on its `items` input and then fires AGAIN because LoopEnd routes a `continue` signal
|
|
18
|
+
* back to it along a real edge. Each pass is a separate execution of both nodes, which is why
|
|
19
|
+
* all the state has to live outside them — a local variable would not survive the gap.
|
|
20
|
+
*
|
|
21
|
+
* `read` and `advance` are deliberately separate so that ITEM EMISSION HAS ONE HOME. LoopEnd
|
|
22
|
+
* moves the index and LoopStart reads it; if LoopEnd also returned the next item, the same
|
|
23
|
+
* fact would be computed in two places and could disagree by one.
|
|
24
|
+
*
|
|
25
|
+
* The engine has its own stake in the output shape: `executingState.ts` lifts {item, index,
|
|
26
|
+
* total} off a node whose type is literally "LoopStart" into `activeLoop`, and that is what
|
|
27
|
+
* makes {{ loop.item }} resolve for every node inside the body. So those three names are a
|
|
28
|
+
* contract, not a preference.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The Redis surface a loop needs. Injected, so this file touches no platform global. */
|
|
32
|
+
export interface LoopOps {
|
|
33
|
+
open(executionId: string, loopId: string, items: unknown): Promise<LoopPass>;
|
|
34
|
+
read(executionId: string, loopId: string): Promise<LoopPass>;
|
|
35
|
+
advance(executionId: string, loopId: string, collect: unknown): Promise<LoopStep>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One pass, as LoopStart emits it. The three names the engine reads. */
|
|
39
|
+
export interface LoopPass {
|
|
40
|
+
item: unknown;
|
|
41
|
+
index: number;
|
|
42
|
+
total: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The end of one pass, as LoopEnd emits it. */
|
|
46
|
+
export interface LoopStep {
|
|
47
|
+
continuing: boolean;
|
|
48
|
+
index: number;
|
|
49
|
+
total: number;
|
|
50
|
+
collected?: unknown[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An hour, matching the retired nodes. The state is within-run; this only stops a leak. */
|
|
54
|
+
const TTL_SECONDS = 3600;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* THE ITEMS INPUT, normalised — and this is executor work rather than manifest work because
|
|
58
|
+
* what it unwraps is the PLATFORM'S OWN envelope, not the shape of anyone's data.
|
|
59
|
+
*
|
|
60
|
+
* A wired input arrives nested twice, by source node id and then by that source's output
|
|
61
|
+
* handle: `{ upstream1: { rows: [...] } }`. The retired executor peeled that off, and doing it
|
|
62
|
+
* in an expression instead would mean repeating `config.items || signal.items || []` five times
|
|
63
|
+
* inside one nested ternary, because the sandbox cannot bind an intermediate. That is the
|
|
64
|
+
* FieldValidator trap, and the answer here is that the envelope belongs to us.
|
|
65
|
+
*
|
|
66
|
+
* TRANSCRIBED from LoopStart's executor, branch for branch:
|
|
67
|
+
* an array, or anything not an object use it as-is
|
|
68
|
+
* one source holding an object the first ARRAY field in it, else the object itself
|
|
69
|
+
* one source holding anything else that value
|
|
70
|
+
* then a non-array becomes a one-item array, so "loop over a single thing" means one pass
|
|
71
|
+
* rather than nothing.
|
|
72
|
+
*/
|
|
73
|
+
export function normaliseItems(raw: unknown): unknown[] {
|
|
74
|
+
let items: any = raw ?? [];
|
|
75
|
+
|
|
76
|
+
if (!Array.isArray(items) && items !== null && typeof items === "object") {
|
|
77
|
+
const firstSource = Object.values(items)[0];
|
|
78
|
+
if (firstSource && typeof firstSource === "object" && !Array.isArray(firstSource)) {
|
|
79
|
+
items = Object.values(firstSource).find((v) => Array.isArray(v)) || firstSource;
|
|
80
|
+
} else {
|
|
81
|
+
items = firstSource || [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Array.isArray(items) ? items : [items];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Bound to a live client. `undefined` when the platform has no Redis.
|
|
90
|
+
*
|
|
91
|
+
* KEYS ARE NAMESPACED, unlike the saved-context hash in state.ts, and the difference is about
|
|
92
|
+
* who else reads them. `saved:<executionId>` is the engine's and must stay bare. These two are
|
|
93
|
+
* ours alone, read by nothing outside this file, and both halves of the pair always move
|
|
94
|
+
* versions together — so there is no bare-versus-prefixed mismatch to create.
|
|
95
|
+
*/
|
|
96
|
+
export function makeLoopOps(redis: any, namespace: string | undefined): LoopOps | undefined {
|
|
97
|
+
if (!redis) return undefined;
|
|
98
|
+
const ns = namespace ? `${namespace}:` : "";
|
|
99
|
+
const stateKey = (executionId: string, loopId: string) => `${ns}loop:${executionId}:${loopId}`;
|
|
100
|
+
const collectedKey = (executionId: string, loopId: string) => `${ns}loop:collected:${executionId}:${loopId}`;
|
|
101
|
+
|
|
102
|
+
const parseItem = (raw: string): unknown => {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(raw);
|
|
105
|
+
} catch {
|
|
106
|
+
return raw; // a value stored as plain text reads back as text rather than failing the loop
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
async open(executionId, loopId, items) {
|
|
112
|
+
const list = normaliseItems(items);
|
|
113
|
+
const key = stateKey(executionId, loopId);
|
|
114
|
+
await redis.hset(key, { items: JSON.stringify(list), index: "0", total: String(list.length) });
|
|
115
|
+
await redis.expire(key, TTL_SECONDS);
|
|
116
|
+
return { item: list.length ? list[0] : null, index: 0, total: list.length };
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async read(executionId, loopId) {
|
|
120
|
+
const key = stateKey(executionId, loopId);
|
|
121
|
+
const [itemsJson, indexStr, totalStr] = await Promise.all([
|
|
122
|
+
redis.hget(key, "items"),
|
|
123
|
+
redis.hget(key, "index"),
|
|
124
|
+
redis.hget(key, "total"),
|
|
125
|
+
]);
|
|
126
|
+
const list: unknown[] = JSON.parse(itemsJson || "[]");
|
|
127
|
+
const index = parseInt(indexStr || "0", 10);
|
|
128
|
+
return { item: list[index] ?? null, index, total: parseInt(totalStr || "0", 10) };
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async advance(executionId, loopId, collect) {
|
|
132
|
+
const key = stateKey(executionId, loopId);
|
|
133
|
+
const bag = collectedKey(executionId, loopId);
|
|
134
|
+
|
|
135
|
+
// A MISSING LOOP IS AN ERROR, not an empty result. It means LoopEnd is pointing at a
|
|
136
|
+
// LoopStart that never ran — usually a mistyped loopStartNodeId — and returning "done"
|
|
137
|
+
// would silently complete a loop that never had a first pass.
|
|
138
|
+
if (!(await redis.exists(key))) throw new Error(`loop state not found for ${executionId}:${loopId}`);
|
|
139
|
+
|
|
140
|
+
// Empty string as well as null: `collect` is a template field, so a config left blank
|
|
141
|
+
// resolves to "" rather than to nothing, and storing that would put a blank entry in the
|
|
142
|
+
// results for every pass.
|
|
143
|
+
if (collect !== undefined && collect !== null && collect !== "") {
|
|
144
|
+
// An ARRAY IS SPREAD into separate entries rather than nested, matching the retired
|
|
145
|
+
// node: collecting a per-item list gives one flat result list at the end.
|
|
146
|
+
for (const item of Array.isArray(collect) ? collect : [collect]) await redis.rpush(bag, JSON.stringify(item));
|
|
147
|
+
await redis.expire(bag, TTL_SECONDS);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// hincrby, so two nodes finishing at once cannot both read the same index and skip one.
|
|
151
|
+
const index: number = await redis.hincrby(key, "index", 1);
|
|
152
|
+
const total = parseInt((await redis.hget(key, "total")) || "0", 10);
|
|
153
|
+
if (index < total) return { continuing: true, index, total };
|
|
154
|
+
|
|
155
|
+
const collected = (await redis.exists(bag)) ? ((await redis.lrange(bag, 0, -1)) as string[]).map(parseItem) : [];
|
|
156
|
+
// Torn down on the last pass, so a long workflow does not carry every loop it ever ran.
|
|
157
|
+
await redis.del(key, bag);
|
|
158
|
+
return { continuing: false, index, total, collected };
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Perform one declared loop operation.
|
|
165
|
+
*
|
|
166
|
+
* Unlike a state read, THERE IS NO DEGRADED MODE. A cache that reads cold is a cache doing its
|
|
167
|
+
* job badly; a loop with nowhere to keep its index cannot iterate at all, and pretending
|
|
168
|
+
* otherwise would run the body once and call it done.
|
|
169
|
+
*/
|
|
170
|
+
export async function performLoop(
|
|
171
|
+
op: string,
|
|
172
|
+
executionId: string | undefined,
|
|
173
|
+
loopId: string,
|
|
174
|
+
value: unknown,
|
|
175
|
+
ops: LoopOps | undefined,
|
|
176
|
+
): Promise<LoopPass | LoopStep> {
|
|
177
|
+
if (!ops) throw new Error(`loop "${op}" needs Redis to keep its index, and this platform has none configured`);
|
|
178
|
+
if (!executionId) throw new Error(`loop "${op}" needs the run's executionId, and this run supplied none`);
|
|
179
|
+
if (!loopId) throw new Error(`loop "${op}" needs the LoopStart node's id as its "key", and none resolved`);
|
|
180
|
+
|
|
181
|
+
switch (op) {
|
|
182
|
+
case "open":
|
|
183
|
+
return ops.open(executionId, loopId, value);
|
|
184
|
+
case "read":
|
|
185
|
+
return ops.read(executionId, loopId);
|
|
186
|
+
case "advance":
|
|
187
|
+
return ops.advance(executionId, loopId, value);
|
|
188
|
+
default:
|
|
189
|
+
throw new Error(`loop operation "${op}" is declared in the schema but not implemented in the executor`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAGINATION. One call in the manifest, many requests on the wire.
|
|
3
|
+
*
|
|
4
|
+
* Vendors disagree about the mechanism and agree about the shape: ask, get a page and a way
|
|
5
|
+
* to ask for the next, repeat until there is no next or you have enough. The disagreement
|
|
6
|
+
* is description (where the token lives, what it goes back as); the loop is computation, so
|
|
7
|
+
* it belongs here and every list-returning node inherits it (DECLARATIVE_NODES.md §2).
|
|
8
|
+
*
|
|
9
|
+
* Without this a manifest cannot express a second page at all, because it has no way to
|
|
10
|
+
* repeat a call an unknown number of times. `calls` is an ordered list of DIFFERENT calls,
|
|
11
|
+
* not a loop.
|
|
12
|
+
*
|
|
13
|
+
* A paginated call's reply is `{ items, pages, truncated }`, not the last page's body. The
|
|
14
|
+
* accumulation is the answer, and handing back the final page would quietly lose the rest.
|
|
15
|
+
*/
|
|
16
|
+
import type { ComposedNode } from "../../compose.js";
|
|
17
|
+
import type { RunContext } from "../context.js";
|
|
18
|
+
import { evaluate } from "../templating.js";
|
|
19
|
+
import { sendRequest } from "../http/request.js";
|
|
20
|
+
import { readSettled, assertOk } from "../http/response.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Put the page token where the manifest said. Explicit on BOTH values rather than treating
|
|
24
|
+
* query as an implicit else: the capability guard reads the enum and asks the executor to
|
|
25
|
+
* name every member, which is what stops a schema value existing with no implementation.
|
|
26
|
+
*/
|
|
27
|
+
function place(call: any, where: string, into: string, value: unknown): any {
|
|
28
|
+
switch (where) {
|
|
29
|
+
case "query":
|
|
30
|
+
return { ...call, query: { ...(call.query ?? {}), [into]: value } };
|
|
31
|
+
case "body":
|
|
32
|
+
return { ...call, body: withBodyKey(call.body, into, value) };
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`pagination "in: ${where}" is declared in the schema but not implemented in the executor`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Set a key on a body that may be an object OR a `return ...` expression. */
|
|
39
|
+
function withBodyKey(body: any, key: string, value: unknown): any {
|
|
40
|
+
return typeof body === "string"
|
|
41
|
+
? `return Object.assign({}, (${body.replace(/^return\s+/, "")}), ${JSON.stringify({ [key]: value })})`
|
|
42
|
+
: { ...(body ?? {}), [key]: value };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Paginated {
|
|
46
|
+
items: unknown[];
|
|
47
|
+
pages: number;
|
|
48
|
+
/** True when a bound stopped the walk while the vendor still had more. */
|
|
49
|
+
truncated: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A hard ceiling on requests, independent of anything the manifest says.
|
|
54
|
+
*
|
|
55
|
+
* A vendor that keeps returning the same cursor, or a manifest whose `cursor` expression
|
|
56
|
+
* always finds a value, would otherwise loop until the process died. `max` is the author's
|
|
57
|
+
* bound; this is the platform's, and it exists because the author's can be wrong.
|
|
58
|
+
*/
|
|
59
|
+
const HARD_PAGE_CAP = 100;
|
|
60
|
+
|
|
61
|
+
export async function fetchPaginated(
|
|
62
|
+
node: ComposedNode,
|
|
63
|
+
call: any,
|
|
64
|
+
ctx: RunContext,
|
|
65
|
+
label: string,
|
|
66
|
+
): Promise<Paginated> {
|
|
67
|
+
const p = call.paginate;
|
|
68
|
+
|
|
69
|
+
// Branch on the declared strategy. Without this the enum would be decoration: a value
|
|
70
|
+
// added to the schema would silently walk as a cursor and look like it worked.
|
|
71
|
+
if (p.strategy !== "cursor" && p.strategy !== "page" && p.strategy !== "offset")
|
|
72
|
+
throw new Error(`pagination strategy "${p.strategy}" is declared in the schema but not implemented in the executor`);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* UNBOUNDED IS THE DEFAULT, and it must not go through the sandbox to get there.
|
|
76
|
+
*
|
|
77
|
+
* This read `evaluate(p.max ?? "return Infinity")`, which threw `unknown identifier
|
|
78
|
+
* 'Infinity'` — the expression allowlist has no such global, by design. So EVERY
|
|
79
|
+
* paginated call that did not set `max` failed at the first page. AirtableInsert's dedup
|
|
80
|
+
* scan is one of them, in the shipped tree.
|
|
81
|
+
*
|
|
82
|
+
* It survived because the two nodes written since both happen to set `max` from config,
|
|
83
|
+
* and a default only breaks when someone takes it.
|
|
84
|
+
*/
|
|
85
|
+
const max = p.max
|
|
86
|
+
? Number(await evaluate(String(p.max), ctx as unknown as Record<string, unknown>)) || Infinity
|
|
87
|
+
: Infinity;
|
|
88
|
+
|
|
89
|
+
const items: unknown[] = [];
|
|
90
|
+
// `page` counts from 1, `offset` counts ITEMS from 0, and both go out on the first
|
|
91
|
+
// request; `cursor` is absent until a reply hands one over.
|
|
92
|
+
let cursor: unknown = p.strategy === "page" ? 1 : p.strategy === "offset" ? 0 : undefined;
|
|
93
|
+
let pages = 0;
|
|
94
|
+
let more = false;
|
|
95
|
+
|
|
96
|
+
for (; pages < HARD_PAGE_CAP; ) {
|
|
97
|
+
// The token rides on a COPY of the call, so the manifest's own keys are untouched.
|
|
98
|
+
//
|
|
99
|
+
// `in` says WHERE: a query parameter by default, or the body, because a JSON-body search
|
|
100
|
+
// endpoint takes its page number in the body and there is nowhere else to put it.
|
|
101
|
+
const spec = cursor === undefined ? call : place(call, p.in ?? "query", p.into, cursor);
|
|
102
|
+
|
|
103
|
+
const res = await sendRequest(node, spec, ctx, `${label} (page ${pages + 1})`);
|
|
104
|
+
const payload = await readSettled(res, call.transport, call.encoding);
|
|
105
|
+
await assertOk(node, call, payload, `${label} (page ${pages + 1})`);
|
|
106
|
+
pages++;
|
|
107
|
+
|
|
108
|
+
const page = await evaluate(p.items, { response: payload } as Record<string, unknown>);
|
|
109
|
+
for (const item of Array.isArray(page) ? page : []) {
|
|
110
|
+
if (items.length >= max) break;
|
|
111
|
+
items.push(item);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (p.strategy === "page" || p.strategy === "offset") {
|
|
115
|
+
// No token to follow, so the END is a SHORT PAGE: fewer items than a full one means
|
|
116
|
+
// there is nothing after it. Both counting strategies end the same way; they differ
|
|
117
|
+
// only in what the number MEANS, and so in what it steps by.
|
|
118
|
+
//
|
|
119
|
+
// page an index of pages, 1, 2, 3 → step 1
|
|
120
|
+
// offset a count of ITEMS already seen → step size
|
|
121
|
+
//
|
|
122
|
+
// Getting that step wrong is the quiet kind of wrong: stepping an offset by 1 re-reads
|
|
123
|
+
// the same window minus one row and the walk returns mostly duplicates.
|
|
124
|
+
const got = Array.isArray(page) ? page.length : 0;
|
|
125
|
+
const size = Number(await evaluate(String(p.size ?? "return 0"), ctx as unknown as Record<string, unknown>)) || 0;
|
|
126
|
+
if (got === 0 || (size && got < size)) return { items, pages, truncated: false };
|
|
127
|
+
cursor = Number(cursor) + (p.strategy === "offset" ? (size || got) : 1);
|
|
128
|
+
} else {
|
|
129
|
+
cursor = await evaluate(p.cursor, { response: payload } as Record<string, unknown>);
|
|
130
|
+
// A falsy cursor is the vendor saying "that was the last page".
|
|
131
|
+
if (!cursor) return { items, pages, truncated: false };
|
|
132
|
+
}
|
|
133
|
+
if (items.length >= max) {
|
|
134
|
+
more = true;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (pages >= HARD_PAGE_CAP)
|
|
140
|
+
console.warn(`[manifests] ${label}: stopped at the ${HARD_PAGE_CAP}-page ceiling with a cursor still present`);
|
|
141
|
+
|
|
142
|
+
return { items, pages, truncated: more || pages >= HARD_PAGE_CAP };
|
|
143
|
+
}
|