@volter/world-core 3.0.38 → 3.0.39
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/app-route.cjs +26 -25
- package/app-route.d.cts +1 -0
- package/dist/app-route.cjs +26 -25
- package/dist/app-route.d.cts +1 -0
- package/dist/generated/pack-facts.json +409 -352
- package/dist/inject.cjs +474 -95
- package/dist/network-policy.cjs +108 -13
- package/dist/network-policy.d.cts +8 -3
- package/dist/src/actions.js +2 -1
- package/dist/src/anthropic-wire.d.ts +2 -0
- package/dist/src/anthropic-wire.js +5 -0
- package/dist/src/attestation.d.ts +6 -0
- package/dist/src/attestation.js +27 -0
- package/dist/src/bytes.d.ts +3 -0
- package/dist/src/bytes.js +17 -0
- package/dist/src/changeset.js +2 -1
- package/dist/src/clickhouse/sql.js +73 -14
- package/dist/src/derived-core.d.ts +210 -32
- package/dist/src/derived-core.js +510 -244
- package/dist/src/derived-real.d.ts +1 -1
- package/dist/src/derived-real.js +704 -93
- package/dist/src/derived.d.ts +18 -2
- package/dist/src/derived.js +21 -2
- package/dist/src/events.d.ts +28 -45
- package/dist/src/events.js +40 -58
- package/dist/src/exact-json.d.ts +10 -0
- package/dist/src/exact-json.js +98 -0
- package/dist/src/executor.d.ts +17 -0
- package/dist/src/executor.js +91 -30
- package/dist/src/file-response.js +1 -1
- package/dist/src/form.d.ts +8 -0
- package/dist/src/form.js +76 -0
- package/dist/src/git/format.d.ts +16 -0
- package/dist/src/git/format.js +110 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +1 -1
- package/dist/src/git/lfs.js +4 -3
- package/dist/src/graphql-wire.d.ts +6 -0
- package/dist/src/graphql-wire.js +6 -3
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/head.d.ts +1 -0
- package/dist/src/head.js +15 -4
- package/dist/src/held-reads.d.ts +6 -0
- package/dist/src/held-reads.js +60 -0
- package/dist/src/history.js +5 -4
- package/dist/src/host-port.d.ts +5 -0
- package/dist/src/host-port.js +14 -0
- package/dist/src/index.d.ts +11 -3
- package/dist/src/index.js +9 -4
- package/dist/src/log.js +12 -7
- package/dist/src/machines.d.ts +81 -5
- package/dist/src/machines.js +125 -9
- package/dist/src/managed-database.d.ts +15 -11
- package/dist/src/managed-database.js +38 -37
- package/dist/src/multipart.d.ts +21 -3
- package/dist/src/multipart.js +112 -38
- package/dist/src/observe.d.ts +3 -0
- package/dist/src/observe.js +6 -2
- package/dist/src/openai-wire.d.ts +16 -0
- package/dist/src/openai-wire.js +43 -12
- package/dist/src/pack-fetch.d.ts +4 -6
- package/dist/src/pack-fetch.js +189 -57
- package/dist/src/packRegistry.d.ts +7 -1
- package/dist/src/packRegistry.js +20 -3
- package/dist/src/private-transport.d.ts +1 -0
- package/dist/src/private-transport.js +11 -0
- package/dist/src/protobuf.js +10 -4
- package/dist/src/rateBudget.d.ts +8 -1
- package/dist/src/rateBudget.js +1 -0
- package/dist/src/redis/engine.d.ts +14 -5
- package/dist/src/redis/engine.js +271 -72
- package/dist/src/redis/frames.d.ts +17 -0
- package/dist/src/redis/frames.js +123 -0
- package/dist/src/redis/index.d.ts +6 -0
- package/dist/src/redis/index.js +6 -0
- package/dist/src/redis/json.d.ts +2 -0
- package/dist/src/redis/json.js +14 -0
- package/dist/src/redis/lua-libs.d.ts +4 -0
- package/dist/src/redis/lua-libs.js +173 -0
- package/dist/src/redis/lua.d.ts +1 -0
- package/dist/src/redis/lua.js +36 -0
- package/dist/src/redis/member-storage.d.ts +5 -0
- package/dist/src/redis/member-storage.js +75 -0
- package/dist/src/redis/resp2.d.ts +4 -0
- package/dist/src/redis/resp2.js +29 -0
- package/dist/src/redis/stream.d.ts +14 -0
- package/dist/src/redis/stream.js +335 -0
- package/dist/src/remote-execute.d.ts +2 -2
- package/dist/src/request-body.d.ts +11 -0
- package/dist/src/request-body.js +11 -0
- package/dist/src/runtime.d.ts +6 -5
- package/dist/src/runtime.js +5 -5
- package/dist/src/s3/wire.d.ts +4 -4
- package/dist/src/s3/wire.js +5 -3
- package/dist/src/scenario.d.ts +22 -0
- package/dist/src/scenario.js +35 -31
- package/dist/src/serve-http.d.ts +2 -1
- package/dist/src/serve-http.js +10 -1
- package/dist/src/serve.js +7 -4
- package/dist/src/signing.d.ts +23 -5
- package/dist/src/signing.js +74 -11
- package/dist/src/sigv4.d.ts +3 -1
- package/dist/src/sigv4.js +4 -2
- package/dist/src/smtp.js +2 -1
- package/dist/src/sockets.js +8 -3
- package/dist/src/state-system.d.ts +3 -0
- package/dist/src/storage.js +5 -2
- package/dist/src/twin-fetch.d.ts +3 -1
- package/dist/src/twin-fetch.js +3 -2
- package/dist/src/vendor-call.d.ts +10 -3
- package/dist/src/vendor-call.js +58 -14
- package/dist/test-fixtures/attestation.SOURCE.md +1 -0
- package/dist/test-fixtures/attestation.json +50 -0
- package/generated/pack-facts.json +409 -352
- package/inject.cjs +474 -95
- package/network-policy.cjs +108 -13
- package/network-policy.d.cts +8 -3
- package/package.json +11 -4
- package/src/actions.ts +2 -1
- package/src/anthropic-wire.ts +6 -0
- package/src/attestation.ts +23 -0
- package/src/bytes.ts +21 -0
- package/src/changeset.ts +2 -1
- package/src/clickhouse/sql.ts +50 -16
- package/src/derived-core.ts +573 -249
- package/src/derived-real.ts +552 -42
- package/src/derived.ts +23 -4
- package/src/events.ts +54 -85
- package/src/exact-json.ts +71 -0
- package/src/executor.ts +86 -27
- package/src/file-response.ts +1 -1
- package/src/form.ts +66 -0
- package/src/git/format.ts +89 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +5 -4
- package/src/graphql-wire.ts +8 -3
- package/src/grpc-wire.ts +2 -1
- package/src/head.ts +15 -5
- package/src/held-reads.ts +54 -0
- package/src/history.ts +5 -4
- package/src/host-port.ts +9 -0
- package/src/index.ts +10 -4
- package/src/log.ts +11 -7
- package/src/machines.ts +135 -13
- package/src/managed-database.ts +48 -40
- package/src/multipart.ts +110 -35
- package/src/observe.ts +6 -3
- package/src/openai-wire.ts +45 -14
- package/src/pack-fetch.ts +173 -50
- package/src/packRegistry.ts +27 -4
- package/src/private-transport.ts +8 -0
- package/src/protobuf.ts +8 -4
- package/src/rateBudget.ts +6 -1
- package/src/redis/engine.ts +168 -63
- package/src/redis/frames.ts +53 -0
- package/src/redis/index.ts +6 -0
- package/src/redis/json.ts +13 -0
- package/src/redis/lua-libs.ts +62 -0
- package/src/redis/lua.ts +20 -0
- package/src/redis/member-storage.ts +58 -0
- package/src/redis/resp2.ts +25 -0
- package/src/redis/stream.ts +102 -0
- package/src/remote-execute.ts +2 -2
- package/src/request-body.ts +12 -0
- package/src/runtime.ts +6 -5
- package/src/s3/wire.ts +8 -5
- package/src/scenario.ts +34 -13
- package/src/serve-http.ts +11 -2
- package/src/serve.ts +4 -3
- package/src/signing.ts +71 -17
- package/src/sigv4.ts +5 -3
- package/src/smtp.ts +3 -2
- package/src/sockets.ts +8 -3
- package/src/state-system.ts +3 -0
- package/src/storage.ts +5 -2
- package/src/twin-fetch.ts +4 -3
- package/src/vendor-call.ts +59 -12
- package/test-fixtures/attestation.json +50 -0
package/dist/src/derived-real.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { parseExactJson, exactInteger, compareExactNumbers } from "./exact-json.js";
|
|
1
2
|
import { aliasesFrom, parentEntries, readParentTreeMap, readTree } from "./log.js";
|
|
2
3
|
import { storedType } from "./derived-core.js";
|
|
3
4
|
import { verifyEvent } from "./events.js";
|
|
4
5
|
import { hashFieldValue } from "./hash.js";
|
|
5
|
-
import { readResourceBlob } from "./resource-blob.js";
|
|
6
|
+
import { readResourceBlob, writeResourceBlob } from "./resource-blob.js";
|
|
6
7
|
import { RefusedWriteError } from "./head.js";
|
|
7
8
|
import { observeResources } from "./observe.js";
|
|
9
|
+
import { readRoot } from "./state-system.js";
|
|
8
10
|
/** A JSON path's value: `a.b`, `a[0].b`, `$body.a` (the leading `$body.` read as the root). */
|
|
9
11
|
function at(value, path) {
|
|
12
|
+
if (value && typeof value === 'object' && Object.hasOwn(value, path))
|
|
13
|
+
return value[path];
|
|
10
14
|
let cur = value;
|
|
11
15
|
for (const part of path.replace(/^\$body\.?/, '').split(/\.|\[(\d+)\]/).filter((p) => p !== undefined && p !== '')) {
|
|
12
16
|
if (cur === null || typeof cur !== 'object')
|
|
@@ -16,20 +20,84 @@ function at(value, path) {
|
|
|
16
20
|
return cur;
|
|
17
21
|
}
|
|
18
22
|
const object = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
|
|
19
|
-
const parse = (r) => {
|
|
20
|
-
|
|
23
|
+
const parse = (r) => {
|
|
24
|
+
if (r.body && r.body.trimStart().startsWith('<'))
|
|
25
|
+
return fromXml(r.body);
|
|
26
|
+
try {
|
|
27
|
+
return r.body ? parseExactJson(r.body) : undefined;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return r.body;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** Link header relations and extension parameters, with commas and escapes inside quoted values preserved. */
|
|
34
|
+
function linkRelations(value) {
|
|
35
|
+
const links = [];
|
|
36
|
+
let rest = value.trim();
|
|
37
|
+
while (rest) {
|
|
38
|
+
const target = /^<([^<>]+)>/.exec(rest);
|
|
39
|
+
if (!target)
|
|
40
|
+
throw new Error('refresh continuation has a malformed Link header; nothing folded');
|
|
41
|
+
rest = rest.slice(target[0].length).trimStart();
|
|
42
|
+
const params = {};
|
|
43
|
+
while (rest.startsWith(';')) {
|
|
44
|
+
const param = /^;\s*([\w!#$%&'*+.^`|~-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s;,]+))\s*/.exec(rest);
|
|
45
|
+
if (!param)
|
|
46
|
+
throw new Error('refresh continuation has a malformed Link parameter; nothing folded');
|
|
47
|
+
const name = param[1].toLowerCase();
|
|
48
|
+
if (name in params)
|
|
49
|
+
throw new Error('refresh continuation repeats a Link parameter; nothing folded');
|
|
50
|
+
params[name] = param[2] !== undefined ? param[2].replace(/\\(.)/g, '$1') : param[3];
|
|
51
|
+
rest = rest.slice(param[0].length);
|
|
52
|
+
}
|
|
53
|
+
links.push({ url: target[1], params });
|
|
54
|
+
if (rest && !rest.startsWith(','))
|
|
55
|
+
throw new Error('refresh continuation has a malformed Link separator; nothing folded');
|
|
56
|
+
if (rest) {
|
|
57
|
+
rest = rest.slice(1).trimStart();
|
|
58
|
+
if (!rest)
|
|
59
|
+
throw new Error('refresh continuation ends with a Link separator; nothing folded');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return links;
|
|
63
|
+
}
|
|
64
|
+
/** An XML answer (S3's lists: ListObjectsV2, ListMultipartUploads, ListParts) read as JSON, so a refresh scope names its
|
|
65
|
+
* items as it does a JSON answer's (`ListBucketResult.Contents`): an element with children is an object of them, a
|
|
66
|
+
* child repeated a list of them, a leaf its text; attributes, declarations and comments are not read. */
|
|
67
|
+
function fromXml(text) {
|
|
68
|
+
const decode = (t) => t.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&/g, '&');
|
|
69
|
+
const stack = [{ name: '', obj: {}, text: '', children: false }];
|
|
70
|
+
const put = (parent, name, value) => {
|
|
71
|
+
const held = parent.obj[name];
|
|
72
|
+
parent.obj[name] = held === undefined ? value : Array.isArray(held) ? [...held, value] : [held, value];
|
|
73
|
+
parent.children = true;
|
|
74
|
+
};
|
|
75
|
+
for (const m of text.matchAll(/<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z_][\w:.-]*)[^>]*?(\/?)>|([^<]+)/g)) {
|
|
76
|
+
const top = stack.at(-1);
|
|
77
|
+
if (m[1] !== undefined)
|
|
78
|
+
top.text += m[1];
|
|
79
|
+
else if (m[5] !== undefined)
|
|
80
|
+
top.text += decode(m[5]);
|
|
81
|
+
else if (m[3] !== undefined && m[4] === '/')
|
|
82
|
+
put(top, m[3], '');
|
|
83
|
+
else if (m[3] !== undefined && m[2] !== '/')
|
|
84
|
+
stack.push({ name: m[3], obj: {}, text: '', children: false });
|
|
85
|
+
else if (m[3] !== undefined && stack.length > 1) {
|
|
86
|
+
const node = stack.pop();
|
|
87
|
+
put(stack.at(-1), node.name, node.children ? node.obj : node.text.trim());
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return stack[0].obj;
|
|
21
91
|
}
|
|
22
|
-
catch {
|
|
23
|
-
return r.body;
|
|
24
|
-
} };
|
|
25
92
|
/** The manifest's resource a surface schema answers (the operation's `answers.resource` is the spec's schema name). */
|
|
26
93
|
function resourceOf(m, schema) {
|
|
27
94
|
if (!schema)
|
|
28
95
|
return undefined;
|
|
29
96
|
if (m.resources[schema])
|
|
30
97
|
return schema;
|
|
31
|
-
const
|
|
32
|
-
|
|
98
|
+
const normalized = (name) => name.replace(/_/g, '').toLowerCase();
|
|
99
|
+
const lower = normalized(schema);
|
|
100
|
+
return Object.keys(m.resources).find((r) => normalized(r) === lower || normalized(storedType(m, r)) === lower);
|
|
33
101
|
}
|
|
34
102
|
/** The error template's fixed leaves (Slack's `ok: false`): an answer holding them all is the vendor's refusal, whatever
|
|
35
103
|
* its status (a vendor that answers its errors 200). */
|
|
@@ -68,6 +136,12 @@ function refusalWords(m, body) {
|
|
|
68
136
|
walk(m.error, '');
|
|
69
137
|
return found ?? (typeof body === 'string' ? body : JSON.stringify(body ?? null)).slice(0, 300);
|
|
70
138
|
}
|
|
139
|
+
/** Whether an answer is the vendor's refusal whatever its status: it holds every fixed leaf of the error template (Slack's
|
|
140
|
+
* `ok: false`), as a perform reads it ("Refresh": a 200 refusal is never folded as an empty page). */
|
|
141
|
+
function refusedIn(m, body) {
|
|
142
|
+
const shape = refusalShape(m);
|
|
143
|
+
return shape.length > 0 && shape.every(([p, v]) => at(body, p) === v);
|
|
144
|
+
}
|
|
71
145
|
/** Every string in a body that names a subject the vendor knows by another id, as the vendor names it. */
|
|
72
146
|
function resolvedDeep(value, resolve) {
|
|
73
147
|
if (typeof value === 'string')
|
|
@@ -104,6 +178,36 @@ async function multipart(body, files, ctx) {
|
|
|
104
178
|
}
|
|
105
179
|
return { body: out, type: `multipart/form-data; boundary=${boundary}` };
|
|
106
180
|
}
|
|
181
|
+
/** What every call of an operation carries beyond its parameters: its query discriminator (S3's `?uploads`, a bare key
|
|
182
|
+
* where its value is null) and its fixed headers (AWS JSON's `x-amz-target`), as the route that serves it reads them. */
|
|
183
|
+
function marked(op, path) {
|
|
184
|
+
const query = new URLSearchParams(path.includes('?') ? path.slice(path.indexOf('?') + 1) : '');
|
|
185
|
+
const marks = Object.entries(op.discriminator ?? {}).filter(([k]) => !query.has(k)).map(([k, v]) => (v === null ? encodeURIComponent(k) : `${encodeURIComponent(k)}=${encodeURIComponent(v)}`));
|
|
186
|
+
return marks.length ? `${path}${path.includes('?') ? '&' : '?'}${marks.join('&')}` : path;
|
|
187
|
+
}
|
|
188
|
+
/** Where a unit's call goes: a lane on a host of its own (`remoteHost`, the real-system adapters "Lanes") to that host,
|
|
189
|
+
* filled from the call's parameters, as an absolute URL the executor admits only on a host the descriptor declares; any
|
|
190
|
+
* other call to its path on the root's origin. A parameter is one host label, or the literal `values` maps it to. */
|
|
191
|
+
function sentTo(m, path, params) {
|
|
192
|
+
if (!m.remoteHost)
|
|
193
|
+
return path;
|
|
194
|
+
const { template, values } = m.remoteHost;
|
|
195
|
+
const host = template.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
196
|
+
const raw = params[name] === undefined || params[name] === null ? '' : String(params[name]);
|
|
197
|
+
const mapped = values?.[name];
|
|
198
|
+
if (mapped) {
|
|
199
|
+
const literal = mapped[raw];
|
|
200
|
+
if (literal === undefined)
|
|
201
|
+
throw new Error(`${m.vendor}: the remote host has no value for ${name} ${JSON.stringify(raw)}`);
|
|
202
|
+
return literal;
|
|
203
|
+
}
|
|
204
|
+
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(raw))
|
|
205
|
+
throw new Error(`${m.vendor}: the remote host's ${name} is missing or is not a host label`);
|
|
206
|
+
return raw;
|
|
207
|
+
});
|
|
208
|
+
return `https://${host}${path}`;
|
|
209
|
+
}
|
|
210
|
+
const fixedHeaders = (op) => Object.fromEntries(Object.entries(op.headers ?? {}).filter((e) => typeof e[1] === 'string'));
|
|
107
211
|
/** The body as the operation's encoding sends it. */
|
|
108
212
|
function encode(op, body) {
|
|
109
213
|
if (body === undefined || op.bodyEncoding === 'none')
|
|
@@ -124,6 +228,68 @@ function unitOf(units, input) {
|
|
|
124
228
|
?? units.find((u) => u.surface.operations.some((o) => o.id === id))
|
|
125
229
|
?? (input.graphql ? units[0] : undefined);
|
|
126
230
|
}
|
|
231
|
+
/** Read the schema-selected resource through response aliases and fragment selections. */
|
|
232
|
+
async function graphqlAnswers(m, surface, input, parsed) {
|
|
233
|
+
if (!surface.graphql)
|
|
234
|
+
throw new Error('a GraphQL perform needs the vendored schema on its derived surface');
|
|
235
|
+
const g = await import('graphql');
|
|
236
|
+
const schema = g.buildSchema(surface.graphql.sdl, { assumeValidSDL: true });
|
|
237
|
+
const sent = object(input.graphql);
|
|
238
|
+
const document = g.parse(String(sent.query));
|
|
239
|
+
const operation = g.getOperationAST(document, typeof sent.operationName === 'string' ? sent.operationName : undefined);
|
|
240
|
+
if (!operation || operation.operation !== 'mutation')
|
|
241
|
+
throw new Error('the recorded GraphQL write has no selected mutation');
|
|
242
|
+
const fragments = new Map(document.definitions.filter((d) => d.kind === 'FragmentDefinition').map((d) => [d.name.value, d]));
|
|
243
|
+
const fields = (selection, seen = new Set()) => selection.selections.flatMap((node) => {
|
|
244
|
+
if (node.kind === 'Field')
|
|
245
|
+
return [node];
|
|
246
|
+
if (node.kind === 'InlineFragment')
|
|
247
|
+
return fields(node.selectionSet, seen);
|
|
248
|
+
if (seen.has(node.name.value))
|
|
249
|
+
return [];
|
|
250
|
+
const fragment = fragments.get(node.name.value);
|
|
251
|
+
return fragment ? fields(fragment.selectionSet, new Set([...seen, node.name.value])) : [];
|
|
252
|
+
});
|
|
253
|
+
const decode = (value, selection) => {
|
|
254
|
+
if (Array.isArray(value))
|
|
255
|
+
return value.map((v) => decode(v, selection));
|
|
256
|
+
const row = object(value);
|
|
257
|
+
if (!row || !selection)
|
|
258
|
+
return value;
|
|
259
|
+
const decoded = {};
|
|
260
|
+
for (const field of fields(selection)) {
|
|
261
|
+
const key = field.alias?.value ?? field.name.value;
|
|
262
|
+
if (key in row)
|
|
263
|
+
decoded[field.name.value] = decode(row[key], field.selectionSet);
|
|
264
|
+
}
|
|
265
|
+
return decoded;
|
|
266
|
+
};
|
|
267
|
+
const answers = [];
|
|
268
|
+
for (const root of fields(operation.selectionSet)) {
|
|
269
|
+
const definition = schema.getMutationType()?.getFields()[root.name.value];
|
|
270
|
+
if (!definition)
|
|
271
|
+
continue;
|
|
272
|
+
const type = g.getNamedType(definition.type);
|
|
273
|
+
const value = object(at(parsed, `data.${root.alias?.value ?? root.name.value}`));
|
|
274
|
+
// Skipped selections (directives) have no answer and adopt nothing.
|
|
275
|
+
if (!value)
|
|
276
|
+
continue;
|
|
277
|
+
const resource = resourceOf(m, type.name);
|
|
278
|
+
if (resource) {
|
|
279
|
+
answers.push({ resource, value: object(decode(value, root.selectionSet)) });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (g.isObjectType(type) && root.selectionSet)
|
|
283
|
+
for (const child of fields(root.selectionSet)) {
|
|
284
|
+
const field = type.getFields()[child.name.value];
|
|
285
|
+
const resource = field ? resourceOf(m, g.getNamedType(field.type).name) : undefined;
|
|
286
|
+
const selected = object(value[child.alias?.value ?? child.name.value]);
|
|
287
|
+
if (resource && selected)
|
|
288
|
+
answers.push({ resource, value: object(decode(selected, child.selectionSet)) });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return answers;
|
|
292
|
+
}
|
|
127
293
|
/** The account a root is, before a perform sends a path naming the World's ("The account a root is"): the World's
|
|
128
294
|
* account its own doors made, unadopted, adopts the one account the root's credential lists. Read once per deploy (an
|
|
129
295
|
* adopted account is not asked again); a list the vendor refuses, or one naming several accounts, adopts none. */
|
|
@@ -141,7 +307,7 @@ async function adoptAccount(units, execute, ctx) {
|
|
|
141
307
|
const op = scope && 'list' in scope ? u.surface.operations.find((o) => o.id === scope.list) : undefined;
|
|
142
308
|
if (!local.length || !op)
|
|
143
309
|
continue;
|
|
144
|
-
const answer = await execute({ method: op.method.toUpperCase(), path: `${op.basePath ?? u.surface.basePath ?? ''}${op.path}
|
|
310
|
+
const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, marked(op, `${op.basePath ?? u.surface.basePath ?? ''}${op.path}`), {}), ...(u.lane ? { lane: u.lane } : {}), headers: fixedHeaders(op) });
|
|
145
311
|
if (answer.status >= 400)
|
|
146
312
|
continue;
|
|
147
313
|
const items = itemsOf(op, parse(answer));
|
|
@@ -170,11 +336,16 @@ function performOf(units) {
|
|
|
170
336
|
let path;
|
|
171
337
|
let body;
|
|
172
338
|
let op;
|
|
339
|
+
// the parameters a lane's own host is filled from, as the vendor names them
|
|
340
|
+
let hostParams = {};
|
|
173
341
|
if (input.graphql) {
|
|
174
342
|
const gql = m.graphql;
|
|
175
343
|
method = 'POST';
|
|
176
344
|
path = gql?.paths[0] ?? '/graphql';
|
|
177
|
-
|
|
345
|
+
const g = await import('graphql');
|
|
346
|
+
const recorded = object(input.graphql);
|
|
347
|
+
const query = g.print(g.visit(g.parse(String(recorded.query)), { StringValue(node) { const value = resolve(node.value); return value === node.value ? undefined : { ...node, value }; } }));
|
|
348
|
+
body = { ...recorded, query, variables: resolvedDeep(recorded.variables, resolve) };
|
|
178
349
|
}
|
|
179
350
|
else {
|
|
180
351
|
op = surface.operations.find((o) => o.id === input.operationId);
|
|
@@ -205,7 +376,9 @@ function performOf(units) {
|
|
|
205
376
|
const query = new URLSearchParams(Object.entries(queried).flatMap(([k, v]) => (Array.isArray(v) ? v : [v]).map((x) => [k, resolve(String(x))])));
|
|
206
377
|
if ([...query].length)
|
|
207
378
|
path += `?${query}`;
|
|
379
|
+
path = marked(op, path);
|
|
208
380
|
body = sentBody === undefined ? undefined : resolvedDeep(sentBody, resolve);
|
|
381
|
+
hostParams = Object.fromEntries(Object.entries(params).map(([k, v]) => [k, resolve(String(v))]));
|
|
209
382
|
}
|
|
210
383
|
const files = Array.isArray(input.files) ? input.files : [];
|
|
211
384
|
// a body recorded as it came (not JSON) goes as it came
|
|
@@ -213,26 +386,54 @@ function performOf(units) {
|
|
|
213
386
|
const raw = asCame && typeof asCame.$text === 'string' ? { body: asCame.$text } : asCame && typeof asCame.$base64 === 'string' ? { body: new Uint8Array(Buffer.from(asCame.$base64, 'base64')) } : undefined;
|
|
214
387
|
const sent = raw ? { ...raw, ...(typeof input.contentType === 'string' ? { type: input.contentType } : {}) }
|
|
215
388
|
: files.length || op?.bodyEncoding === 'multipart' ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
|
|
216
|
-
const headers = { ...(object(input.headers) ?? {}), ...(sent.type ? { 'content-type': sent.type } : {}) };
|
|
389
|
+
const headers = { ...(op ? fixedHeaders(op) : {}), ...(object(input.headers) ?? {}), ...(sent.type ? { 'content-type': sent.type } : {}) };
|
|
217
390
|
// a retried deploy makes nothing twice, and two Worlds deploying into one account never collide
|
|
218
391
|
if (m.idempotency)
|
|
219
392
|
headers[m.idempotency.header] = hashFieldValue({ credential: ctx.credential ?? '', entry: first.id });
|
|
220
|
-
const answer = await execute({ method, path,
|
|
393
|
+
const answer = await execute({ method, path: sentTo(m, path, hostParams), ...(unit.lane ? { lane: unit.lane } : {}), headers, ...(sent.body !== undefined ? { body: sent.body } : {}) });
|
|
221
394
|
const parsed = parse(answer);
|
|
222
|
-
const
|
|
223
|
-
const refused = answer.status >= 400 || (shape.length > 0 && shape.every(([p, v]) => at(parsed, p) === v));
|
|
395
|
+
const gqlErrors = input.graphql && Array.isArray(at(parsed, 'errors')) ? at(parsed, 'errors') : [];
|
|
224
396
|
if (answer.status === 408 || answer.status === 429 || answer.status >= 500)
|
|
225
397
|
throw new Error(`${answer.status}: ${refusalWords(m, parsed)}${answer.headers['retry-after'] ? ` (retry after ${answer.headers['retry-after']}s)` : ''}`);
|
|
398
|
+
if (gqlErrors.length)
|
|
399
|
+
throw new RefusedWriteError('vendor', `400: ${gqlErrors.map((e) => String(e.message)).join('; ')}`, first.id);
|
|
400
|
+
const refused = answer.status >= 400 || refusedIn(m, parsed);
|
|
226
401
|
if (refused)
|
|
227
402
|
throw new RefusedWriteError('vendor', `${answer.status >= 400 ? answer.status : 400}: ${refusalWords(m, parsed)}`, first.id);
|
|
228
403
|
// the subject the answer is: under the operation's key, inside the success envelope when the vendor has one
|
|
229
404
|
const key = op?.answers?.key;
|
|
230
|
-
|
|
405
|
+
if (input.graphql) {
|
|
406
|
+
const answers = await graphqlAnswers(m, surface, input, parsed);
|
|
407
|
+
const remaining = [...group];
|
|
408
|
+
const adopted = [];
|
|
409
|
+
for (const answer of answers) {
|
|
410
|
+
const index = remaining.findIndex((entry) => entry.subject.type === storedType(m, answer.resource));
|
|
411
|
+
if (index < 0)
|
|
412
|
+
continue;
|
|
413
|
+
const entry = remaining.splice(index, 1)[0];
|
|
414
|
+
const decl = m.resources[answer.resource];
|
|
415
|
+
const data = answer.value ?? {};
|
|
416
|
+
const externalId = entry.fields?.deleted === true ? ctx.resolve(entry.subject.type, entry.subject.id) : storedIdOf(decl, data) ?? '';
|
|
417
|
+
adopted.push({ actionId: entry.id, externalId, data });
|
|
418
|
+
}
|
|
419
|
+
const primary = adopted.shift();
|
|
420
|
+
return primary ? { ...primary, ...(adopted.length ? { also: adopted } : {}) } : { externalId: '', actionId: group.at(-1).id };
|
|
421
|
+
}
|
|
422
|
+
// an XML answer is read through its document element (S3's `InitiateMultipartUploadResult`), as a JSON one under its key
|
|
423
|
+
const documentElement = () => {
|
|
424
|
+
const root = object(parsed);
|
|
425
|
+
const names = root ? Object.keys(root) : [];
|
|
426
|
+
return answer.body.trimStart().startsWith('<') && names.length === 1 ? object(root[names[0]]) : undefined;
|
|
427
|
+
};
|
|
428
|
+
const obj = object(key ? at(parsed, key) : parsed === undefined ? undefined : documentElement() ?? parsed) ?? object(parsed) ?? {};
|
|
231
429
|
// the answered resource: the spec's schema name, else the envelope key the answer is under (Slack's `channel`,
|
|
232
430
|
// `message`), when the manifest has a resource of that name
|
|
233
431
|
const answerKey = op?.answers?.key;
|
|
234
432
|
const resource = resourceOf(m, op?.answers?.resource) ?? (answerKey && m.resources[answerKey] ? answerKey : undefined);
|
|
235
|
-
|
|
433
|
+
// an answer naming no resource of the manifest (an operation's own output structure: Secrets Manager's
|
|
434
|
+
// CreateSecretResponse, S3's CreateMultipartUploadOutput) is the group's subject's, its id read by that resource
|
|
435
|
+
const subjectDecl = () => Object.entries(m.resources).find(([r]) => storedType(m, r) === group.at(-1).subject.type)?.[1];
|
|
436
|
+
const decl = resource ? m.resources[resource] : subjectDecl();
|
|
236
437
|
const idOf = (o, d) => { const v = o?.[d?.idAs ?? 'id']; return typeof v === 'string' ? v : undefined; };
|
|
237
438
|
// the group's entry the answer is: of the answered resource's stored type (a cascade writes children first), else
|
|
238
439
|
// the last; a delete answers its subject's own vendor id
|
|
@@ -247,9 +448,9 @@ function performOf(units) {
|
|
|
247
448
|
// a keyed subject's id is its key filled from the answer (its envelope's fields, Slack's `channel` and `ts`, among them)
|
|
248
449
|
const keyed = decl?.key ? decl : Object.entries(m.resources).find(([r]) => storedType(m, r) === primary.subject.type)?.[1];
|
|
249
450
|
const externalId = deleted ? ctx.resolve(primary.subject.type, primary.subject.id)
|
|
250
|
-
: keyed?.key ? storedIdOf(keyed, { ...(object(parsed) ?? {}), ...obj }) ?? '' : idOf(obj, decl) ?? '';
|
|
451
|
+
: keyed?.key ? storedIdOf(keyed, { ...(object(parsed) ?? {}), ...obj }) ?? '' : idOf(obj, decl) ?? (ctx.resolve(primary.subject.type, primary.subject.id) !== primary.subject.id ? ctx.resolve(primary.subject.type, primary.subject.id) : '');
|
|
251
452
|
const also = [];
|
|
252
|
-
for (const [companion, where] of Object.entries(decl?.companions ?? {})) {
|
|
453
|
+
for (const [companion, where] of Object.entries(decl?.companions ?? keyed?.companions ?? {})) {
|
|
253
454
|
const id = at(parsed, where) ?? at(obj, where);
|
|
254
455
|
const entry = group.find((e) => e !== primary && e.subject.type === storedType(m, companion));
|
|
255
456
|
if (entry && typeof id === 'string')
|
|
@@ -296,7 +497,7 @@ function vendorFieldParams(m, resource, params, ctx) {
|
|
|
296
497
|
const parent = decl.parent;
|
|
297
498
|
const parentType = parent ? storedType(m, parent.resource) : undefined;
|
|
298
499
|
const where = Object.entries(parent?.where ?? {}).map(([field, template]) => [field, template, fill(template)]);
|
|
299
|
-
const localParent = where.length ? tree.find((r) => r.type === parentType && where.every(([f, , v]) => String(r
|
|
500
|
+
const localParent = where.length ? tree.find((r) => r.type === parentType && where.every(([f, , v]) => String(at(r, f)) === v)) : undefined;
|
|
300
501
|
const parentId = parent ? (where.length ? (localParent ? String(localParent.id) : undefined) : parent.params ? fill(parent.value ?? parent.params.map((p) => `{${p}}`).join('/')) : parent.param !== undefined ? params[parent.param] : undefined) : undefined;
|
|
301
502
|
if (decl.number && !decl.key) {
|
|
302
503
|
const type = storedType(m, resource);
|
|
@@ -311,16 +512,42 @@ function vendorFieldParams(m, resource, params, ctx) {
|
|
|
311
512
|
if (parentCopy)
|
|
312
513
|
for (const [field, template] of where) {
|
|
313
514
|
const one = /^\{([^}]+)\}$/.exec(template);
|
|
314
|
-
|
|
315
|
-
|
|
515
|
+
const value = at(parentCopy, field);
|
|
516
|
+
if (one && value !== undefined)
|
|
517
|
+
params[one[1]] = String(value);
|
|
316
518
|
}
|
|
317
519
|
}
|
|
318
520
|
/** The items of a list's answer: under the operation's key, else the answer when it is a list. */
|
|
521
|
+
/** The items a scope names: a list, or one item where XML names a lone child (an S3 list of one object), or none. */
|
|
522
|
+
const listed = (v) => (Array.isArray(v) ? v : v === undefined || v === null || v === '' ? [] : [v]);
|
|
319
523
|
function itemsOf(op, parsed) {
|
|
320
524
|
const v = op.answers?.key ? at(parsed, op.answers.key) : parsed;
|
|
321
525
|
const list = Array.isArray(v) ? v : Array.isArray(at(parsed, 'data')) ? at(parsed, 'data') : Array.isArray(at(parsed, 'results')) ? at(parsed, 'results') : [];
|
|
322
526
|
return list.map(object).filter((x) => x !== undefined);
|
|
323
527
|
}
|
|
528
|
+
/** A vendor response may hold download links in an object map or an array. Missing optional paths yield none. */
|
|
529
|
+
function valuesAt(value, keys) {
|
|
530
|
+
if (!keys.length)
|
|
531
|
+
return value === undefined || value === null ? [] : [value];
|
|
532
|
+
if (!value || typeof value !== 'object')
|
|
533
|
+
return [];
|
|
534
|
+
const [key, ...rest] = keys;
|
|
535
|
+
return key === '*' ? Object.values(value).flatMap((v) => valuesAt(v, rest)) : valuesAt(value[key], rest);
|
|
536
|
+
}
|
|
537
|
+
/** The field a refresh scope's answer names its subject by, where it differs from the resource's (`refresh.idAs`: the
|
|
538
|
+
* Hub's repo read carries the creation's ObjectId as `_id` and its own `namespace/name` as `id`). */
|
|
539
|
+
function scopeIdOf(scope, fields) {
|
|
540
|
+
const field = scope.idAs;
|
|
541
|
+
if (field?.includes('{'))
|
|
542
|
+
return field.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
543
|
+
const value = at(fields, name);
|
|
544
|
+
if (value === undefined || value === null || value === '')
|
|
545
|
+
throw new Error(`refresh identity lacks ${name}; nothing folded`);
|
|
546
|
+
return String(value);
|
|
547
|
+
});
|
|
548
|
+
const value = field ? at(fields, field) : undefined;
|
|
549
|
+
return value === undefined || value === null || value === '' ? undefined : String(value);
|
|
550
|
+
}
|
|
324
551
|
function refreshOf(units, service) {
|
|
325
552
|
return async (execute, opts) => {
|
|
326
553
|
const observed = [];
|
|
@@ -328,114 +555,489 @@ function refreshOf(units, service) {
|
|
|
328
555
|
// what the root's credential may not read (a token scoped to some of the account): left as the World holds it, said
|
|
329
556
|
const unread = [];
|
|
330
557
|
let answered = 0;
|
|
331
|
-
for (const
|
|
332
|
-
const
|
|
558
|
+
for (const { manifest: m, surface, lane } of units) {
|
|
559
|
+
const onLane = lane ? { lane } : {};
|
|
333
560
|
// parents before children, however deep
|
|
334
561
|
const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
|
|
335
562
|
const depth = (r, seen = new Set()) => { const p = m.resources[r]?.parent?.resource; return p && !seen.has(p) ? 1 + depth(p, seen.add(r)) : 0; };
|
|
336
563
|
const order = scoped.sort(([a], [b]) => depth(a) - depth(b));
|
|
337
564
|
for (const [resource, decl] of order) {
|
|
338
565
|
const scope = decl.refresh;
|
|
566
|
+
if ('none' in scope)
|
|
567
|
+
continue;
|
|
568
|
+
if ('graphql' in scope && scope.graphql) {
|
|
569
|
+
await refreshGraphql(m, resource, decl, scope, execute, opts.scope, observed, complete);
|
|
570
|
+
answered += 1;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
339
573
|
const opId = 'list' in scope ? scope.list : 'get' in scope ? scope.get : '';
|
|
340
574
|
const op = surface.operations.find((o) => o.id === opId);
|
|
341
575
|
if (!op)
|
|
342
576
|
throw new Error(`${m.vendor}: ${resource}'s refresh names ${opId}, no operation of the surface`);
|
|
343
577
|
const type = storedType(m, resource);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
const
|
|
578
|
+
// the known, read one by one: a vendor that lists no whole type (Volter Identity's people and organizations) is
|
|
579
|
+
// read back subject by subject: each the vendor is known to hold (the parent tree: landed or observed, never a
|
|
580
|
+
// World-only subject, which a 404 would wrongly remove), by the vendor's id, its other path parameters filled
|
|
581
|
+
// from its own fields; a 404 is one the vendor no longer has, and only the subjects read are whole
|
|
582
|
+
if ('get' in scope && scope.known) {
|
|
583
|
+
const aliases = aliasesFrom(parentEntries(service, opts.root));
|
|
584
|
+
const held = [...readParentTreeMap(service, opts.root)].filter(([, r]) => r.type === type && r.fields.deleted !== true);
|
|
585
|
+
const read = [];
|
|
586
|
+
// a 403 leaves the type as the World holds it, never complete
|
|
587
|
+
let refused = false;
|
|
588
|
+
for (const [key, r] of held) {
|
|
589
|
+
const subject = { ...r.fields, id: r.id };
|
|
590
|
+
const vendorId = aliases.get(key) ?? String(r.id);
|
|
591
|
+
for (const [name, template] of Object.entries(scope.params ?? {}))
|
|
592
|
+
subject[name] = template.replace(/\{([^}]+)\}/g, (_, field) => {
|
|
593
|
+
if (field === 'id')
|
|
594
|
+
return vendorId;
|
|
595
|
+
if (subject[field] === undefined || subject[field] === null)
|
|
596
|
+
throw new Error(`${m.vendor}: ${resource}'s refresh parameter ${name} lacks ${field}; nothing folded`);
|
|
597
|
+
return String(subject[field]);
|
|
598
|
+
});
|
|
599
|
+
// each path parameter is the subject's own field of that name (the Hub's {namespace} and {repo}); one the subject
|
|
600
|
+
// holds no field for, or `id`, is the vendor's id for it
|
|
601
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n) => encodeURIComponent(String(n === 'id' || subject[n] === undefined || subject[n] === null ? vendorId : subject[n])));
|
|
602
|
+
// a read naming its subject in the query (an RPC method: Telegram's getChat?chat_id=): the scope's `query`, each
|
|
603
|
+
// `{name}` filled as a path parameter is
|
|
604
|
+
const fillSubject = (t) => t.replace(/\{([^}]+)\}/g, (_, n) => String(n === 'id' || subject[n] === undefined || subject[n] === null ? vendorId : subject[n]));
|
|
605
|
+
const asked = Object.entries(scope.query ?? {}).map(([k, t]) => [k, fillSubject(t)]);
|
|
606
|
+
const target = marked(op, path);
|
|
607
|
+
const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
|
|
608
|
+
const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
|
|
357
609
|
if (answer.status === 401)
|
|
358
610
|
throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
359
611
|
if (answer.status === 403) {
|
|
360
612
|
unread.push({ type, path, status: 403 });
|
|
361
|
-
|
|
613
|
+
refused = true;
|
|
362
614
|
break;
|
|
363
615
|
}
|
|
364
616
|
if (answer.status === 429)
|
|
365
617
|
throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
366
|
-
|
|
367
|
-
// path missing a parameter (an empty segment) is the kernel's mistake, never the vendor's absence
|
|
368
|
-
if ('get' in scope && answer.status === 404 && !unfilled) {
|
|
369
|
-
answered += 1;
|
|
370
|
-
break;
|
|
371
|
-
}
|
|
372
|
-
if (answer.status >= 400)
|
|
373
|
-
throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
618
|
+
read.push(String(subject.id));
|
|
374
619
|
answered += 1;
|
|
375
|
-
|
|
376
|
-
const items = 'get' in scope ? [object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {}]
|
|
377
|
-
: 'items' in scope && scope.items ? (at(parsed, scope.items) ?? []).map((x) => object(x)).filter((x) => x !== undefined) : itemsOf(op, parsed);
|
|
378
|
-
for (const item of items) {
|
|
379
|
-
const fields = { ...item, ...(decl.parent && parent !== undefined ? { [decl.parent.field]: parent } : {}) };
|
|
380
|
-
const id = storedIdOf(decl, fields);
|
|
381
|
-
if (id === undefined)
|
|
382
|
-
continue;
|
|
383
|
-
observed.push({ type, id, fields: fields });
|
|
384
|
-
}
|
|
385
|
-
taken += items.length;
|
|
386
|
-
if ('get' in scope)
|
|
387
|
-
break;
|
|
388
|
-
if (taken >= 10_000) {
|
|
389
|
-
whole = false;
|
|
390
|
-
break;
|
|
391
|
-
}
|
|
392
|
-
// the next page by the manifest's list model. The list is whole only where its end shows: a cursor model
|
|
393
|
-
// gives no next cursor (a short page may still name one), or a page is empty or shorter than the limit
|
|
394
|
-
// asked; a list the twin cannot page on, or whose page size it does not know, is not known whole.
|
|
395
|
-
if (m.list?.cursor) {
|
|
396
|
-
const next = at(parsed, 'next_cursor') ?? at(parsed, 'response_metadata.next_cursor');
|
|
397
|
-
if (!next)
|
|
398
|
-
break;
|
|
399
|
-
page = { ...page, [m.list.cursor.param]: String(next) };
|
|
620
|
+
if (answer.status === 404)
|
|
400
621
|
continue;
|
|
622
|
+
if (answer.status >= 400 || refusedIn(m, parse(answer)))
|
|
623
|
+
throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
624
|
+
const full = object(op.answers?.key ? at(parse(answer), op.answers.key) : parse(answer)) ?? {};
|
|
625
|
+
const local = m.worldFields?.[resource] ?? {};
|
|
626
|
+
const fields = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
|
|
627
|
+
if (decl.parent) {
|
|
628
|
+
if (r.fields[decl.parent.field] !== undefined)
|
|
629
|
+
fields[decl.parent.field] = r.fields[decl.parent.field];
|
|
630
|
+
for (const [name, template] of Object.entries(decl.parent.keep ?? {})) {
|
|
631
|
+
if (!name.startsWith('_'))
|
|
632
|
+
throw new Error(`${m.vendor}: ${resource}'s parent.keep names a non-private field; nothing folded`);
|
|
633
|
+
fields[name] = fillSubject(template);
|
|
634
|
+
}
|
|
401
635
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (!size) {
|
|
406
|
-
whole = false;
|
|
407
|
-
break;
|
|
408
|
-
}
|
|
409
|
-
const last = items.at(-1)[decl.idAs ?? 'id'];
|
|
410
|
-
if (m.list?.after)
|
|
411
|
-
page = { ...page, [m.list.after]: String(last) };
|
|
412
|
-
else if (m.list?.page)
|
|
413
|
-
page = { ...page, [m.list.page.param]: String(Number(page[m.list.page.param] ?? 1) + 1) };
|
|
414
|
-
else if (m.list?.offset)
|
|
415
|
-
page = { ...page, [m.list.offset.param]: String(taken) };
|
|
416
|
-
else {
|
|
417
|
-
whole = false;
|
|
418
|
-
break;
|
|
419
|
-
}
|
|
636
|
+
// the subject asked for, where its answer names no id of its own (a singleton under its account)
|
|
637
|
+
const id = scopeIdOf(scope, fields) ?? storedIdOf(decl, fields) ?? String(r.id);
|
|
638
|
+
observed.push({ type, id, fields: fields });
|
|
420
639
|
}
|
|
640
|
+
if (!refused)
|
|
641
|
+
complete.push({ type, ids: read });
|
|
642
|
+
continue;
|
|
421
643
|
}
|
|
644
|
+
// each parent observed: the path parameters it fills and the value the child's parent field holds (its id, or for
|
|
645
|
+
// a parent the path names by several parameters, `parent.value` filled from them as the twin stores it)
|
|
646
|
+
const parents = decl.parent ? parentsOf(decl.parent, observed.filter((o) => o.type === storedType(m, decl.parent.resource))) : [undefined];
|
|
647
|
+
let whole = 'list' in scope && scope.complete !== false;
|
|
648
|
+
for (const under of parents)
|
|
649
|
+
for (const variant of scope.variants ?? [{ headers: {} }]) {
|
|
650
|
+
const parent = under?.value;
|
|
651
|
+
// the list's own headers, `{name}` its parent's parameter, and the variant's
|
|
652
|
+
const filled = (t) => t.replace(/\{([^}]+)\}/g, (_, n) => under?.params[n] ?? '');
|
|
653
|
+
const listHeaders = { ...Object.fromEntries(Object.entries(scope.headers ?? {}).map(([n, t]) => [n, filled(t)])), ...variant.headers };
|
|
654
|
+
// what a child keeps of its parent's parameters, private
|
|
655
|
+
const kept = Object.fromEntries(Object.entries(decl.parent?.keep ?? {}).map(([f, t]) => {
|
|
656
|
+
if (!f.startsWith('_'))
|
|
657
|
+
throw new Error(`${m.vendor}: ${resource}'s parent.keep names ${f}, which is not private`);
|
|
658
|
+
return [f, filled(t)];
|
|
659
|
+
}));
|
|
660
|
+
// a list asked by POST (an RPC list) carries its page in its body, as the operation's encoding sends it
|
|
661
|
+
const rpc = op.method.toLowerCase() !== 'get';
|
|
662
|
+
const unpaginated = 'list' in scope && scope.unpaginated === true;
|
|
663
|
+
const limit = !unpaginated && m.list?.limit && [...(op.query ?? []), ...(rpc ? op.body ?? [] : [])].some((q) => q.name === m.list.limit.param) ? { [m.list.limit.param]: String(m.list.limit.max) } : {};
|
|
664
|
+
let page = { ...scope.query, ...limit };
|
|
665
|
+
const seenLinks = new Set();
|
|
666
|
+
for (let taken = 0;;) {
|
|
667
|
+
let unfilled = false;
|
|
668
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n) => { const v = under?.params[n] ?? ''; if (!v)
|
|
669
|
+
unfilled = true; return encodeURIComponent(v); });
|
|
670
|
+
// a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
|
|
671
|
+
// and a parent named by several parameters, each the path does not take that the operation declares in its query
|
|
672
|
+
// (Slack's reactions.get: a message's `channel` and `timestamp`)
|
|
673
|
+
const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) }
|
|
674
|
+
: decl.parent?.params?.length && under ? Object.fromEntries(Object.entries(under.params).filter(([n]) => !op.path.includes(`{${n}}`) && (op.query ?? []).some((x) => x.name === n))) : {};
|
|
675
|
+
// a list asked by POST carries its parent in its body too, unless the operation reads it from its query
|
|
676
|
+
const parentInQuery = !rpc || (op.query ?? []).some((x) => x.name === decl.parent?.param);
|
|
677
|
+
const q = new URLSearchParams(rpc ? (parentInQuery ? byQuery : {}) : { ...page, ...byQuery });
|
|
678
|
+
// Fixed parameters and cursors take the operation's body types, not their manifest string encoding.
|
|
679
|
+
const typedPage = Object.fromEntries(Object.entries(page).map(([k, v]) => {
|
|
680
|
+
const type = (op.body ?? []).find((b) => b.name === k)?.type;
|
|
681
|
+
const value = type === 'integer' ? exactInteger(v) : type === 'number' ? Number(v)
|
|
682
|
+
: type === 'boolean' && (v === 'true' || v === 'false') ? v === 'true' : v;
|
|
683
|
+
return [k, value];
|
|
684
|
+
}));
|
|
685
|
+
const sent = rpc ? encode(op, { ...typedPage, ...(parentInQuery ? {} : byQuery) }) : undefined;
|
|
686
|
+
const answer = await execute({
|
|
687
|
+
method: op.method.toUpperCase(), path: sentTo(m, marked(op, [...q].length ? `${path}?${q}` : path), under?.params ?? {}), ...onLane,
|
|
688
|
+
headers: { ...fixedHeaders(op), ...listHeaders, ...(sent?.type ? { 'content-type': sent.type } : {}) }, ...(sent?.body !== undefined ? { body: sent.body } : {}),
|
|
689
|
+
});
|
|
690
|
+
if (answer.status === 401)
|
|
691
|
+
throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
692
|
+
if (answer.status === 403) {
|
|
693
|
+
unread.push({ type, path, status: 403 });
|
|
694
|
+
whole = false;
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
if (answer.status === 429)
|
|
698
|
+
throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
699
|
+
// one subject read by its parent and not there (an account with no workers.dev subdomain): nothing to observe. A
|
|
700
|
+
// path missing a parameter (an empty segment) is the kernel's mistake, never the vendor's absence
|
|
701
|
+
if ('get' in scope && answer.status === 404 && !unfilled) {
|
|
702
|
+
answered += 1;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
if (answer.status >= 400 || refusedIn(m, parse(answer)))
|
|
706
|
+
throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
707
|
+
answered += 1;
|
|
708
|
+
const parsed = parse(answer);
|
|
709
|
+
const shape = 'list' in scope ? scope.shape : undefined;
|
|
710
|
+
const items = 'get' in scope ? [object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {}]
|
|
711
|
+
: 'items' in scope && scope.items ? listed(at(parsed, scope.items)).map((x) => object(x)).filter((x) => x !== undefined) : itemsOf(op, parsed);
|
|
712
|
+
const keyed = 'list' in scope && scope.keyed === true;
|
|
713
|
+
const map = keyed ? object(at(parsed, scope.items ?? '$body')) : undefined;
|
|
714
|
+
if (keyed && (!map || Object.values(map).some((value) => (shape?.value ? object(value) !== undefined : !object(value)))))
|
|
715
|
+
throw new Error(`${m.vendor}: ${resource}'s keyed refresh must return an object of ${shape?.value ? 'scalars' : 'resource objects'}; nothing folded`);
|
|
716
|
+
// the answer's items as subjects' fields (refresh.shape): a scalar item or map value named, a nested array spread
|
|
717
|
+
let entries = keyed
|
|
718
|
+
? Object.entries(map).map(([key, value]) => (shape?.value
|
|
719
|
+
? [shape.keyAs ? undefined : key, { [shape.value]: value, ...(shape.keyAs ? { [shape.keyAs]: key } : {}) }]
|
|
720
|
+
: [key, object(value)]))
|
|
721
|
+
: shape?.scalar && 'items' in scope && scope.items
|
|
722
|
+
? listed(at(parsed, scope.items)).filter((x) => x !== null && typeof x !== 'object').map((x) => [undefined, { [shape.scalar]: x }])
|
|
723
|
+
: items.map((item) => [undefined, item]);
|
|
724
|
+
// paging reads the answer's own items, before any spread
|
|
725
|
+
const pageItems = entries;
|
|
726
|
+
if (shape?.spread) {
|
|
727
|
+
const { path: inner, as, carry } = shape.spread;
|
|
728
|
+
entries = entries.flatMap(([, item]) => {
|
|
729
|
+
const carried = Object.fromEntries(Object.entries(carry ?? {}).map(([f, p]) => [f, at(item, p)]));
|
|
730
|
+
return listed(at(item, inner)).flatMap((el) => {
|
|
731
|
+
const own = object(el) ?? (as && el !== null && el !== undefined ? { [as]: el } : undefined);
|
|
732
|
+
return own ? [[undefined, { ...carried, ...own }]] : [];
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
for (const [mapId, item] of entries) {
|
|
737
|
+
let full = item;
|
|
738
|
+
if ('list' in scope && scope.detail) {
|
|
739
|
+
const detail = surface.operations.find((o) => o.id === scope.detail);
|
|
740
|
+
if (!detail)
|
|
741
|
+
throw new Error(`${m.vendor}: ${resource}'s refresh detail names ${scope.detail}, no operation of the surface`);
|
|
742
|
+
const id = mapId ?? storedIdOf(decl, item);
|
|
743
|
+
const path = `${detail.basePath ?? surface.basePath ?? ''}${detail.path}`.replace(/\{([^}]+)\}/g, (_, n) => {
|
|
744
|
+
const value = under?.params[n] ?? item[n] ?? id;
|
|
745
|
+
if (value === undefined)
|
|
746
|
+
throw new Error(`${m.vendor}: ${resource}'s refresh detail has no value for ${n}`);
|
|
747
|
+
return encodeURIComponent(String(value));
|
|
748
|
+
});
|
|
749
|
+
// the subject a detail read names in its body or query (`detailWith`: AWS JSON's DescribeSecret, `{ SecretId }`)
|
|
750
|
+
const withValues = Object.fromEntries(Object.entries(scope.detailWith ?? {}).map(([k, t]) => [k, t.replace(/\{([^}]+)\}/g, (_, n) => String(n === 'id' ? id : item[n] ?? ''))]));
|
|
751
|
+
const detailRpc = detail.method.toLowerCase() !== 'get';
|
|
752
|
+
const detailQuery = { ...(scope.query ?? {}), ...(detailRpc ? {} : withValues) };
|
|
753
|
+
const detailSent = detailRpc && Object.keys(withValues).length ? encode(detail, withValues) : undefined;
|
|
754
|
+
const answer = await execute({ method: detail.method.toUpperCase(), path: sentTo(m, marked(detail, Object.keys(detailQuery).length ? `${path}?${new URLSearchParams(detailQuery)}` : path), { ...item, ...under?.params }), ...onLane, headers: { ...fixedHeaders(detail), ...(detailSent?.type ? { 'content-type': detailSent.type } : {}) }, ...(detailSent?.body !== undefined ? { body: detailSent.body } : {}) });
|
|
755
|
+
if (answer.status >= 400 || refusedIn(m, parse(answer)))
|
|
756
|
+
throw new Error(`${answer.status}: ${refusalWords(m, parse(answer)) || 'the vendor refused the refresh detail; nothing folded'}`);
|
|
757
|
+
const detailBody = parse(answer);
|
|
758
|
+
const detailKey = scope.detailMerge ?? detail.answers?.key;
|
|
759
|
+
const fragment = object(detailKey ? at(detailBody, detailKey) : detailBody);
|
|
760
|
+
if (scope.detailMerge && !fragment)
|
|
761
|
+
throw new Error(`${m.vendor}: ${resource}'s refresh detail fragment is not an object; nothing folded`);
|
|
762
|
+
const detailFields = fragment ?? {};
|
|
763
|
+
full = scope.detailMerge ? { ...item, ...detailFields } : detailFields;
|
|
764
|
+
}
|
|
765
|
+
// the vendor's shape alone: a World field the twin discloses (manifest.worldFields: Substrate's
|
|
766
|
+
// twin_local_ports) is the answering World's, never the vendor's, and is not folded in
|
|
767
|
+
const local = m.worldFields?.[resource] ?? {};
|
|
768
|
+
const vendorShape = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
|
|
769
|
+
const fields = { ...vendorShape, ...kept, ...(decl.parent && parent !== undefined ? { [decl.parent.field]: parent } : {}) };
|
|
770
|
+
const id = mapId ?? scopeIdOf(scope, fields) ?? storedIdOf(decl, fields);
|
|
771
|
+
if (id === undefined)
|
|
772
|
+
continue;
|
|
773
|
+
observed.push({ type, id, fields: fields });
|
|
774
|
+
}
|
|
775
|
+
taken += pageItems.length;
|
|
776
|
+
if ('get' in scope || unpaginated)
|
|
777
|
+
break;
|
|
778
|
+
if (taken >= 10_000) {
|
|
779
|
+
whole = false;
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
if ('nextLink' in scope && scope.nextLink) {
|
|
783
|
+
const nextLink = scope.nextLink;
|
|
784
|
+
const header = Object.entries(answer.headers).find(([name]) => name.toLowerCase() === nextLink.header.toLowerCase())?.[1];
|
|
785
|
+
const links = header ? linkRelations(header).filter((link) => link.params.rel?.split(/\s+/).includes(nextLink.rel)) : [];
|
|
786
|
+
if (links.length > 1)
|
|
787
|
+
throw new Error(`${m.vendor}: ambiguous refresh Link relation; nothing folded`);
|
|
788
|
+
const link = links[0];
|
|
789
|
+
if (!link || Object.entries(nextLink.when ?? {}).some(([name, value]) => link.params[name.toLowerCase()] !== value))
|
|
790
|
+
break;
|
|
791
|
+
let marker;
|
|
792
|
+
try {
|
|
793
|
+
marker = new URL(link.url).searchParams.get(nextLink.query);
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
throw new Error(`${m.vendor}: refresh Link continuation is not an absolute URL; nothing folded`);
|
|
797
|
+
}
|
|
798
|
+
if (!marker)
|
|
799
|
+
throw new Error(`${m.vendor}: refresh Link URL lacks its declared cursor; nothing folded`);
|
|
800
|
+
const param = scope.cursor ?? nextLink.query;
|
|
801
|
+
if (page[param] === marker || seenLinks.has(marker))
|
|
802
|
+
throw new Error(`${m.vendor}: ${resource}'s list repeated its continuation; nothing folded`);
|
|
803
|
+
seenLinks.add(marker);
|
|
804
|
+
page = { ...page, [param]: marker };
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
// the next page by the manifest's list model. The list is whole only where its end shows: a cursor model
|
|
808
|
+
// gives no next cursor (a short page may still name one), or a page is empty or shorter than the limit
|
|
809
|
+
// asked; a list the twin cannot page on, or whose page size it does not know, is not known whole.
|
|
810
|
+
// the cursor the scope names (AWS's NextToken), sent back as its parameter
|
|
811
|
+
if ('next' in scope && scope.next) {
|
|
812
|
+
// the vendor's own flag that no page follows ends it, whatever cursor the page names
|
|
813
|
+
if ('more' in scope && scope.more !== undefined && !['true', true].includes(at(parsed, scope.more)))
|
|
814
|
+
break;
|
|
815
|
+
const markers = typeof scope.next === 'string' ? { [scope.cursor ?? scope.next]: scope.next } : scope.next;
|
|
816
|
+
const next = Object.fromEntries(Object.entries(markers).flatMap(([param, where]) => {
|
|
817
|
+
const marker = at(parsed, where);
|
|
818
|
+
return marker === undefined || marker === null || marker === '' ? [] : [[param, String(marker)]];
|
|
819
|
+
}));
|
|
820
|
+
if (!Object.keys(next).length)
|
|
821
|
+
break;
|
|
822
|
+
// a page that names the cursor it was asked with would be read forever: nothing folds
|
|
823
|
+
if (Object.entries(next).every(([k, v]) => page[k] === v))
|
|
824
|
+
throw new Error(`${m.vendor}: ${resource}'s list repeated its continuation; nothing folded`);
|
|
825
|
+
page = { ...page, ...next };
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (m.list?.cursor) {
|
|
829
|
+
const next = at(parsed, 'next_cursor') ?? at(parsed, 'response_metadata.next_cursor');
|
|
830
|
+
if (!next)
|
|
831
|
+
break;
|
|
832
|
+
page = { ...page, [m.list.cursor.param]: String(next) };
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const size = Number(limit[m.list?.limit.param ?? ''] ?? 0);
|
|
836
|
+
if (!pageItems.length || (size && pageItems.length < size))
|
|
837
|
+
break;
|
|
838
|
+
if (!size) {
|
|
839
|
+
whole = false;
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
const last = pageItems.at(-1)[0] ?? pageItems.at(-1)[1][decl.idAs ?? 'id'];
|
|
843
|
+
if (m.list?.after)
|
|
844
|
+
page = { ...page, [m.list.after]: String(last) };
|
|
845
|
+
else if (m.list?.page)
|
|
846
|
+
page = { ...page, [m.list.page.param]: String(Number(page[m.list.page.param] ?? 1) + 1) };
|
|
847
|
+
else if (m.list?.offset)
|
|
848
|
+
page = { ...page, [m.list.offset.param]: String(taken) };
|
|
849
|
+
else {
|
|
850
|
+
whole = false;
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
422
855
|
// a type under a parent is whole only for the parents observed: what it held under another parent stands. A root
|
|
423
856
|
// scoped to one resource (`RootConfig.scope`, GitHub's `repos/o/r`) lists only what is under it, so a type with no
|
|
424
857
|
// parent is never the account's whole list there
|
|
425
858
|
if (whole && !decl.parent && !opts.scope)
|
|
426
859
|
complete.push(type);
|
|
427
860
|
else if (whole && parents.length)
|
|
428
|
-
complete.push({ type, field: decl.parent.field, within: parents.map(
|
|
861
|
+
complete.push({ type, field: decl.parent.field, within: parents.map((p) => p.value) });
|
|
429
862
|
}
|
|
430
863
|
}
|
|
431
864
|
// every read refused is a credential the vendor refuses, never an account that holds nothing
|
|
432
865
|
if (unread.length && !answered)
|
|
433
866
|
throw new Error(`403: the vendor refused the root's credential for every read (${unread.map((u) => u.path).join(', ')}); nothing folded`);
|
|
867
|
+
const downloads = [];
|
|
868
|
+
for (const { manifest: m, surface, lane } of units)
|
|
869
|
+
for (const [resource, decl] of Object.entries(m.resources)) {
|
|
870
|
+
const scope = decl.refresh;
|
|
871
|
+
if (!scope || 'none' in scope || !scope.blobs?.length)
|
|
872
|
+
continue;
|
|
873
|
+
for (const row of observed.filter((o) => o.type === storedType(m, resource)))
|
|
874
|
+
for (const blob of scope.blobs) {
|
|
875
|
+
// the bytes an operation of the unit answers for the subject (R2's GetObject: its list names no download link)
|
|
876
|
+
if ('operation' in blob) {
|
|
877
|
+
if (!blob.field.startsWith('_') || (blob.headersField !== undefined && !blob.headersField.startsWith('_')))
|
|
878
|
+
throw new Error(`${m.vendor}: ${resource}'s refresh blob fields must be private`);
|
|
879
|
+
const op = surface.operations.find((o) => o.id === blob.operation);
|
|
880
|
+
if (!op || op.method.toUpperCase() !== 'GET')
|
|
881
|
+
throw new Error(`${m.vendor}: ${resource}'s refresh blob names ${blob.operation}, no GET of the surface`);
|
|
882
|
+
const under = decl.parent ? parentsOf(decl.parent, observed.filter((o) => o.type === storedType(m, decl.parent.resource))).find((p) => p.value === row.fields[decl.parent.field]) : undefined;
|
|
883
|
+
const values = { ...row.fields, ...under?.params, id: row.id };
|
|
884
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n) => {
|
|
885
|
+
if (values[n] === undefined || values[n] === null)
|
|
886
|
+
throw new Error(`${m.vendor}: ${resource}'s refresh blob has no value for ${n}`);
|
|
887
|
+
return encodeURIComponent(String(values[n]));
|
|
888
|
+
});
|
|
889
|
+
const answer = await execute({ method: 'GET', path: sentTo(m, marked(op, path), values), ...(lane ? { lane } : {}), headers: { ...fixedHeaders(op), ...blob.headers }, responseType: 'bytes' });
|
|
890
|
+
if (answer.status < 200 || answer.status >= 300 || !answer.bodyBytes)
|
|
891
|
+
throw new Error(`${m.vendor}: refresh blob ${blob.operation} answered ${answer.status}; nothing folded`);
|
|
892
|
+
const key = `refresh/${encodeURIComponent(row.type)}/${encodeURIComponent(row.id)}`;
|
|
893
|
+
// only the headers the scope names: the answer's own (its date, its length) would change every read
|
|
894
|
+
const keptHeaders = Object.fromEntries(Object.entries(answer.headers).filter(([h]) => (blob.keepHeaders ?? []).some((n) => (n.endsWith('*') ? h.toLowerCase().startsWith(n.slice(0, -1).toLowerCase()) : h.toLowerCase() === n.toLowerCase()))).map(([h, v]) => [h.toLowerCase(), v]));
|
|
895
|
+
row.fields = { ...row.fields, [blob.field]: key, ...(blob.headersField ? { [blob.headersField]: keptHeaders } : {}) };
|
|
896
|
+
downloads.push({ key, bytes: answer.bodyBytes });
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
for (const value of valuesAt(row.fields, blob.path.split('.'))) {
|
|
900
|
+
if (typeof value !== 'string')
|
|
901
|
+
throw new Error(`${m.vendor}: refresh blob ${blob.path} is not a URL`);
|
|
902
|
+
let url = new URL(value);
|
|
903
|
+
const boundUrl = readRoot(service, opts.root)?.url;
|
|
904
|
+
const bound = boundUrl ? new URL(boundUrl) : undefined;
|
|
905
|
+
const prefix = bound?.pathname.replace(/\/+$/, '') ?? '';
|
|
906
|
+
const primary = m.origin?.placeholder ? new URL(m.origin.placeholder).hostname : m.descriptor?.hosts?.find((h) => 'host' in h && !h.exclude);
|
|
907
|
+
const primaryHost = typeof primary === 'string' ? primary : primary && 'host' in primary ? primary.host : undefined;
|
|
908
|
+
let answer;
|
|
909
|
+
let pathname = '';
|
|
910
|
+
for (let hops = 0; hops <= 5; hops++) {
|
|
911
|
+
const fromRoot = !!bound && url.origin === bound.origin && (url.pathname === prefix || url.pathname.startsWith(prefix + '/'));
|
|
912
|
+
pathname = fromRoot ? url.pathname.slice(prefix.length) || '/' : url.pathname;
|
|
913
|
+
if (url.username || url.password || (!fromRoot && (url.protocol !== 'https:' || (url.port && url.port !== '443'))) || (!fromRoot && !m.descriptor?.hosts?.some((h) => 'host' in h && !h.exclude && h.host === url.hostname && (!h.pathPattern || new RegExp(h.pathPattern).test(url.pathname)))))
|
|
914
|
+
throw new Error(`${m.vendor}: refresh blob host is not declared: ${url.hostname}`);
|
|
915
|
+
answer = await execute({ method: 'GET', path: fromRoot || url.hostname === primaryHost ? pathname + url.search : url.href, headers: { host: url.host }, responseType: 'bytes' });
|
|
916
|
+
if (![301, 302, 303, 307, 308].includes(answer.status))
|
|
917
|
+
break;
|
|
918
|
+
if (hops === 5 || !answer.headers.location)
|
|
919
|
+
throw new Error(`${m.vendor}: invalid refresh blob redirect; nothing folded`);
|
|
920
|
+
url = new URL(answer.headers.location, url);
|
|
921
|
+
}
|
|
922
|
+
if (!answer || answer.status < 200 || answer.status >= 300 || !answer.bodyBytes)
|
|
923
|
+
throw new Error(`${m.vendor}: refresh blob ${blob.path} answered ${answer?.status}; nothing folded`);
|
|
924
|
+
const fill = (template, key = '') => template.replace(/\{pathname\}/g, decodeURIComponent(pathname)).replace(/\{key\}/g, key).replace(/\{header:([^}]+)\}/g, (_, name) => {
|
|
925
|
+
const value = answer.headers[name.toLowerCase()];
|
|
926
|
+
if (value === undefined)
|
|
927
|
+
throw new Error(`${m.vendor}: refresh blob header ${name} is missing; nothing folded`);
|
|
928
|
+
return value;
|
|
929
|
+
});
|
|
930
|
+
const key = fill(blob.key);
|
|
931
|
+
if (!key || key.startsWith('/') || key.includes('\0') || key.split(/[\\/]/).includes('..') || /\{[^}]+\}/.test(key))
|
|
932
|
+
throw new Error(`${m.vendor}: invalid refresh blob key`);
|
|
933
|
+
if (blob.resource) {
|
|
934
|
+
if (!blob.resource.startsWith('_') || !m.resources[blob.resource])
|
|
935
|
+
throw new Error(`${m.vendor}: refresh blob metadata requires a declared bookkeeping resource`);
|
|
936
|
+
observed.push({ type: storedType(m, blob.resource), id: key, fields: Object.fromEntries(Object.entries(blob.fields ?? {}).map(([name, template]) => [name, fill(template, key)])) });
|
|
937
|
+
}
|
|
938
|
+
downloads.push({ key, bytes: answer.bodyBytes });
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
for (const download of downloads)
|
|
943
|
+
await writeResourceBlob(service, download.key, download.bytes, opts.root);
|
|
434
944
|
adopt(service, opts.root, units, observed);
|
|
435
945
|
const report = observeResources(service, observed, { ...(opts.root !== undefined ? { root: opts.root } : {}), complete });
|
|
436
946
|
return unread.length ? { ...report, unread } : report;
|
|
437
947
|
};
|
|
438
948
|
}
|
|
949
|
+
/** A declared GraphQL read over the same executor and atomic observation batch as REST. */
|
|
950
|
+
async function refreshGraphql(m, resource, decl, scope, execute, rootScope, observed, complete) {
|
|
951
|
+
const gql = scope.graphql;
|
|
952
|
+
const endpoint = m.graphql?.paths[0];
|
|
953
|
+
if (!endpoint)
|
|
954
|
+
throw new Error(`${m.vendor}: a GraphQL refresh needs a GraphQL endpoint`);
|
|
955
|
+
const type = storedType(m, resource);
|
|
956
|
+
const parents = decl.parent ? parentsOf(decl.parent, observed.filter((o) => o.type === storedType(m, decl.parent.resource))) : [undefined];
|
|
957
|
+
let whole = 'list' in scope && scope.complete !== false;
|
|
958
|
+
for (const under of parents) {
|
|
959
|
+
const substitute = (v) => typeof v === 'string' && /^\{[^}]+\}$/.test(v) ? under?.params[v.slice(1, -1)] ?? v
|
|
960
|
+
: Array.isArray(v) ? v.map(substitute) : object(v) ? Object.fromEntries(Object.entries(object(v)).map(([k, x]) => [k, substitute(x)])) : v;
|
|
961
|
+
const variables = substitute(gql.variables ?? {});
|
|
962
|
+
const cursors = new Set();
|
|
963
|
+
for (let taken = 0;;) {
|
|
964
|
+
const answer = await execute({ method: 'POST', path: endpoint, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: gql.query, variables }) });
|
|
965
|
+
const parsed = parse(answer);
|
|
966
|
+
if (answer.status >= 400 || (Array.isArray(at(parsed, 'errors')) && at(parsed, 'errors').length))
|
|
967
|
+
throw new Error(`${answer.status}: the vendor refused the GraphQL refresh; nothing folded: ${JSON.stringify(parsed)}`);
|
|
968
|
+
const value = at(parsed, gql.items);
|
|
969
|
+
if (value === undefined)
|
|
970
|
+
throw new Error(`${m.vendor}: the GraphQL refresh returned no ${gql.items}; nothing folded`);
|
|
971
|
+
const items = listed(value).map(object).filter((x) => x !== undefined);
|
|
972
|
+
for (const item of items) {
|
|
973
|
+
const fields = { ...item, ...(decl.parent && under ? { [decl.parent.field]: under.value } : {}) };
|
|
974
|
+
const id = storedIdOf(decl, fields);
|
|
975
|
+
if (id !== undefined)
|
|
976
|
+
observed.push({ type, id, fields: fields });
|
|
977
|
+
}
|
|
978
|
+
taken += items.length;
|
|
979
|
+
if ('get' in scope || ('list' in scope && scope.unpaginated))
|
|
980
|
+
break;
|
|
981
|
+
if (!gql.pageInfo) {
|
|
982
|
+
whole = false;
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
const info = object(at(parsed, gql.pageInfo));
|
|
986
|
+
if (!info || typeof info.hasNextPage !== 'boolean')
|
|
987
|
+
throw new Error(`${m.vendor}: missing GraphQL page information; nothing folded`);
|
|
988
|
+
if (!info.hasNextPage)
|
|
989
|
+
break;
|
|
990
|
+
if (taken >= 10_000) {
|
|
991
|
+
whole = false;
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
const cursor = info.endCursor;
|
|
995
|
+
if (typeof cursor !== 'string' || !cursor || cursors.has(cursor))
|
|
996
|
+
throw new Error(`${m.vendor}: invalid GraphQL continuation; nothing folded`);
|
|
997
|
+
cursors.add(cursor);
|
|
998
|
+
variables[gql.cursor ?? 'after'] = cursor;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (whole && !decl.parent && !rootScope)
|
|
1002
|
+
complete.push(type);
|
|
1003
|
+
else if (whole && parents.length && decl.parent)
|
|
1004
|
+
complete.push({ type, field: decl.parent.field, within: parents.map((p) => p.value) });
|
|
1005
|
+
}
|
|
1006
|
+
/** The parents a child type is listed under, from the parent type's observations: the path parameters each fills and
|
|
1007
|
+
* the value the child's `parent.field` holds. A single `param` is the parent's id. A parent the path names by several
|
|
1008
|
+
* parameters (`params`, GitHub's `{owner}/{repo}`) reads each from the parent's fields through its `where` templates
|
|
1009
|
+
* (`full_name: '{owner}/{repo}'`), and the child holds `value` filled from them (the parameters joined by `/` when it
|
|
1010
|
+
* names none), as the derived core stores it ("Refresh"). A parent whose fields fill not every parameter is skipped. */
|
|
1011
|
+
function parentsOf(parent, observations) {
|
|
1012
|
+
const out = new Map();
|
|
1013
|
+
for (const o of observations) {
|
|
1014
|
+
if (!parent.params?.length) {
|
|
1015
|
+
out.set(o.id, { params: parent.param ? { [parent.param]: o.id } : {}, value: o.id });
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
const params = {};
|
|
1019
|
+
for (const [field, template] of Object.entries(parent.where ?? {})) {
|
|
1020
|
+
const names = [];
|
|
1021
|
+
// a template that is one parameter whole takes the field whole, its slashes included (an S3 key)
|
|
1022
|
+
const whole = /^\{([^}]+)\}$/.exec(template);
|
|
1023
|
+
if (whole) {
|
|
1024
|
+
const v = at(o.fields, field);
|
|
1025
|
+
if (v !== undefined && v !== null && v !== '')
|
|
1026
|
+
params[whole[1]] = String(v);
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const pattern = template.replace(/\{([^}]+)\}|[.*+?^$()|[\]\\]/g, (m, n) => (n ? (names.push(n), '([^/]+)') : `\\${m}`));
|
|
1030
|
+
const got = new RegExp(`^${pattern}$`).exec(String(at(o.fields, field) ?? ''));
|
|
1031
|
+
if (got)
|
|
1032
|
+
names.forEach((n, i) => { params[n] = got[i + 1]; });
|
|
1033
|
+
}
|
|
1034
|
+
if (!parent.params.every((n) => params[n] !== undefined))
|
|
1035
|
+
continue;
|
|
1036
|
+
const value = (parent.value ?? parent.params.map((n) => `{${n}}`).join('/')).replace(/\{([^}]+)\}/g, (_, n) => params[n] ?? '');
|
|
1037
|
+
out.set(value, { params, value });
|
|
1038
|
+
}
|
|
1039
|
+
return [...out.values()];
|
|
1040
|
+
}
|
|
439
1041
|
/** A vendor's subject's stored id: its key template filled from its fields (its parent's among them), else its id
|
|
440
1042
|
* field. Undefined when the subject names none. */
|
|
441
1043
|
function storedIdOf(decl, fields) {
|
|
@@ -503,7 +1105,7 @@ function adopt(service, root, units, observed) {
|
|
|
503
1105
|
const parentType = decl?.parent && m ? storedType(m, decl.parent.resource) : undefined;
|
|
504
1106
|
const resolved = (v) => aliases.get(`${parentType}:${String(v)}`) ?? String(v);
|
|
505
1107
|
const siblings = pool.filter((p) => parentField === undefined || resolved(p.fields[parentField]) === String(o.fields[parentField]));
|
|
506
|
-
const keys = decl?.alternateKeys ?? [];
|
|
1108
|
+
const keys = decl?.adoptBy ?? decl?.alternateKeys ?? [];
|
|
507
1109
|
const match = keys.length ? siblings.filter((p) => keys.every((k) => p.fields[k] !== undefined && p.fields[k] === o.fields[k])) : siblings.length === 1 ? siblings : [];
|
|
508
1110
|
if (match.length !== 1)
|
|
509
1111
|
continue;
|
|
@@ -534,6 +1136,8 @@ function older(theirs, held) {
|
|
|
534
1136
|
return false;
|
|
535
1137
|
const a = Number(theirs);
|
|
536
1138
|
const b = Number(held);
|
|
1139
|
+
if (/^-?\d+$/.test(String(theirs)) && /^-?\d+$/.test(String(held)))
|
|
1140
|
+
return compareExactNumbers(theirs, held) < 0;
|
|
537
1141
|
if (String(theirs).trim() !== '' && String(held).trim() !== '' && Number.isFinite(a) && Number.isFinite(b))
|
|
538
1142
|
return a < b;
|
|
539
1143
|
const ta = Date.parse(String(theirs));
|
|
@@ -552,7 +1156,7 @@ function ingestOf(units, decl, service) {
|
|
|
552
1156
|
return Response.json({ error: 'invalid signature' }, { status: 401 });
|
|
553
1157
|
let event;
|
|
554
1158
|
try {
|
|
555
|
-
event =
|
|
1159
|
+
event = parseExactJson(body);
|
|
556
1160
|
}
|
|
557
1161
|
catch {
|
|
558
1162
|
return Response.json({ error: 'the event is not JSON' }, { status: 400 });
|
|
@@ -567,9 +1171,16 @@ function ingestOf(units, decl, service) {
|
|
|
567
1171
|
const found = eventResource(m, decl, type);
|
|
568
1172
|
if (!found)
|
|
569
1173
|
continue;
|
|
570
|
-
const item = object(at(event, decl.object));
|
|
571
1174
|
const d = m.resources[found.resource];
|
|
572
|
-
|
|
1175
|
+
// the object where its type carries it, with its parent's value where the event names the parent beside it
|
|
1176
|
+
const carried = object(at(event, found.object ?? decl.object));
|
|
1177
|
+
const parent = found.parent && d.parent ? at(event, found.parent) : undefined;
|
|
1178
|
+
const withParent = carried && (typeof parent === 'string' || typeof parent === 'number') ? { ...carried, [d.parent.field]: String(parent) } : carried;
|
|
1179
|
+
// the subject's id where the type names it (kept out of the fields: the vendor's object carries no such field), and
|
|
1180
|
+
// what the type itself says of the subject
|
|
1181
|
+
const named = found.id && withParent ? at(withParent, found.id) : undefined;
|
|
1182
|
+
const item = withParent ? { ...(found.id && !found.id.includes('.') ? Object.fromEntries(Object.entries(withParent).filter(([k]) => k !== found.id)) : withParent), ...(found.fields ?? {}) } : undefined;
|
|
1183
|
+
const id = found.id ? (typeof named === 'string' || typeof named === 'number' ? String(named) : undefined) : item ? storedIdOf(d, item) : undefined;
|
|
573
1184
|
if (!item || id === undefined)
|
|
574
1185
|
break;
|
|
575
1186
|
const stored = storedType(m, found.resource);
|