@volter/world-core 3.0.38 → 3.0.40
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 +110 -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 +110 -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/src/derived-real.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseExactJson, exactInteger, compareExactNumbers } from './exact-json.ts';
|
|
1
2
|
// THE REAL-SYSTEM ADAPTERS, DERIVED (docs/contributing/architecture.md, "The real-system adapters"): a pack's perform,
|
|
2
3
|
// refresh and ingest made by the kernel from its manifest and its generated surface, so no pack writes adapter code.
|
|
3
4
|
// They run over the kernel executor (`RemoteExecute`), which applies the sealed credential; the credential never
|
|
@@ -8,17 +9,18 @@ import { storedType, type DerivedManifest, type IngestDecl, type ResourceDecl, t
|
|
|
8
9
|
import type { DerivedOperation, DerivedSurface } from './derived.ts';
|
|
9
10
|
import { verifyEvent, type EventScheme } from './events.ts';
|
|
10
11
|
import { hashFieldValue } from './hash.ts';
|
|
11
|
-
import { readResourceBlob } from './resource-blob.ts';
|
|
12
|
+
import { readResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
12
13
|
import { RefusedWriteError, type PerformAction, type PerformContext, type PushOutcome } from './head.ts';
|
|
13
14
|
import { observeResources, type Completed, type ObservedResource } from './observe.ts';
|
|
14
15
|
import type { RemoteExecute, RemoteExecuteResponse } from './remote-execute.ts';
|
|
15
|
-
import type
|
|
16
|
+
import { readRoot, type StateSystemAdapters } from './state-system.ts';
|
|
16
17
|
|
|
17
18
|
type Unit = { manifest: DerivedManifest; surface: DerivedSurface; lane?: string };
|
|
18
19
|
type Row = Record<string, unknown>;
|
|
19
20
|
|
|
20
21
|
/** A JSON path's value: `a.b`, `a[0].b`, `$body.a` (the leading `$body.` read as the root). */
|
|
21
22
|
function at(value: unknown, path: string): unknown {
|
|
23
|
+
if (value && typeof value === 'object' && Object.hasOwn(value, path)) return (value as Row)[path];
|
|
22
24
|
let cur = value;
|
|
23
25
|
for (const part of path.replace(/^\$body\.?/, '').split(/\.|\[(\d+)\]/).filter((p) => p !== undefined && p !== '')) {
|
|
24
26
|
if (cur === null || typeof cur !== 'object') return undefined;
|
|
@@ -27,14 +29,68 @@ function at(value: unknown, path: string): unknown {
|
|
|
27
29
|
return cur;
|
|
28
30
|
}
|
|
29
31
|
const object = (v: unknown): Row | undefined => (v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Row) : undefined);
|
|
30
|
-
const parse = (r: RemoteExecuteResponse): unknown => {
|
|
32
|
+
const parse = (r: RemoteExecuteResponse): unknown => {
|
|
33
|
+
if (r.body && r.body.trimStart().startsWith('<')) return fromXml(r.body);
|
|
34
|
+
try { return r.body ? parseExactJson(r.body) : undefined; } catch { return r.body; }
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Link header relations and extension parameters, with commas and escapes inside quoted values preserved. */
|
|
38
|
+
function linkRelations(value: string): Array<{ url: string; params: Record<string, string> }> {
|
|
39
|
+
const links: Array<{ url: string; params: Record<string, string> }> = [];
|
|
40
|
+
let rest = value.trim();
|
|
41
|
+
while (rest) {
|
|
42
|
+
const target = /^<([^<>]+)>/.exec(rest);
|
|
43
|
+
if (!target) throw new Error('refresh continuation has a malformed Link header; nothing folded');
|
|
44
|
+
rest = rest.slice(target[0].length).trimStart();
|
|
45
|
+
const params: Record<string, string> = {};
|
|
46
|
+
while (rest.startsWith(';')) {
|
|
47
|
+
const param = /^;\s*([\w!#$%&'*+.^`|~-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s;,]+))\s*/.exec(rest);
|
|
48
|
+
if (!param) throw new Error('refresh continuation has a malformed Link parameter; nothing folded');
|
|
49
|
+
const name = param[1]!.toLowerCase();
|
|
50
|
+
if (name in params) throw new Error('refresh continuation repeats a Link parameter; nothing folded');
|
|
51
|
+
params[name] = param[2] !== undefined ? param[2].replace(/\\(.)/g, '$1') : param[3]!;
|
|
52
|
+
rest = rest.slice(param[0].length);
|
|
53
|
+
}
|
|
54
|
+
links.push({ url: target[1]!, params });
|
|
55
|
+
if (rest && !rest.startsWith(',')) throw new Error('refresh continuation has a malformed Link separator; nothing folded');
|
|
56
|
+
if (rest) { rest = rest.slice(1).trimStart(); if (!rest) throw new Error('refresh continuation ends with a Link separator; nothing folded'); }
|
|
57
|
+
}
|
|
58
|
+
return links;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An XML answer (S3's lists: ListObjectsV2, ListMultipartUploads, ListParts) read as JSON, so a refresh scope names its
|
|
62
|
+
* items as it does a JSON answer's (`ListBucketResult.Contents`): an element with children is an object of them, a
|
|
63
|
+
* child repeated a list of them, a leaf its text; attributes, declarations and comments are not read. */
|
|
64
|
+
function fromXml(text: string): Row {
|
|
65
|
+
const decode = (t: string): string => t.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n))).replace(/&/g, '&');
|
|
66
|
+
type Node = { name: string; obj: Row; text: string; children: boolean };
|
|
67
|
+
const stack: Node[] = [{ name: '', obj: {}, text: '', children: false }];
|
|
68
|
+
const put = (parent: Node, name: string, value: unknown): void => {
|
|
69
|
+
const held = parent.obj[name];
|
|
70
|
+
parent.obj[name] = held === undefined ? value : Array.isArray(held) ? [...held, value] : [held, value];
|
|
71
|
+
parent.children = true;
|
|
72
|
+
};
|
|
73
|
+
for (const m of text.matchAll(/<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z_][\w:.-]*)[^>]*?(\/?)>|([^<]+)/g)) {
|
|
74
|
+
const top = stack.at(-1)!;
|
|
75
|
+
if (m[1] !== undefined) top.text += m[1];
|
|
76
|
+
else if (m[5] !== undefined) top.text += decode(m[5]);
|
|
77
|
+
else if (m[3] !== undefined && m[4] === '/') put(top, m[3], '');
|
|
78
|
+
else if (m[3] !== undefined && m[2] !== '/') stack.push({ name: m[3], obj: {}, text: '', children: false });
|
|
79
|
+
else if (m[3] !== undefined && stack.length > 1) {
|
|
80
|
+
const node = stack.pop()!;
|
|
81
|
+
put(stack.at(-1)!, node.name, node.children ? node.obj : node.text.trim());
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return stack[0]!.obj;
|
|
85
|
+
}
|
|
31
86
|
|
|
32
87
|
/** The manifest's resource a surface schema answers (the operation's `answers.resource` is the spec's schema name). */
|
|
33
88
|
function resourceOf(m: DerivedManifest, schema: string | undefined): string | undefined {
|
|
34
89
|
if (!schema) return undefined;
|
|
35
90
|
if (m.resources[schema]) return schema;
|
|
36
|
-
const
|
|
37
|
-
|
|
91
|
+
const normalized = (name: string): string => name.replace(/_/g, '').toLowerCase();
|
|
92
|
+
const lower = normalized(schema);
|
|
93
|
+
return Object.keys(m.resources).find((r) => normalized(r) === lower || normalized(storedType(m, r)) === lower);
|
|
38
94
|
}
|
|
39
95
|
|
|
40
96
|
/** The error template's fixed leaves (Slack's `ok: false`): an answer holding them all is the vendor's refusal, whatever
|
|
@@ -62,6 +118,13 @@ function refusalWords(m: DerivedManifest, body: unknown): string {
|
|
|
62
118
|
return found ?? (typeof body === 'string' ? body : JSON.stringify(body ?? null)).slice(0, 300);
|
|
63
119
|
}
|
|
64
120
|
|
|
121
|
+
/** Whether an answer is the vendor's refusal whatever its status: it holds every fixed leaf of the error template (Slack's
|
|
122
|
+
* `ok: false`), as a perform reads it ("Refresh": a 200 refusal is never folded as an empty page). */
|
|
123
|
+
function refusedIn(m: DerivedManifest, body: unknown): boolean {
|
|
124
|
+
const shape = refusalShape(m);
|
|
125
|
+
return shape.length > 0 && shape.every(([p, v]) => at(body, p) === v);
|
|
126
|
+
}
|
|
127
|
+
|
|
65
128
|
/** Every string in a body that names a subject the vendor knows by another id, as the vendor names it. */
|
|
66
129
|
function resolvedDeep(value: unknown, resolve: (v: string) => string): unknown {
|
|
67
130
|
if (typeof value === 'string') return resolve(value);
|
|
@@ -92,6 +155,30 @@ async function multipart(body: unknown, files: Row[], ctx: PerformContext): Prom
|
|
|
92
155
|
return { body: out, type: `multipart/form-data; boundary=${boundary}` };
|
|
93
156
|
}
|
|
94
157
|
|
|
158
|
+
/** What every call of an operation carries beyond its parameters: its query discriminator (S3's `?uploads`, a bare key
|
|
159
|
+
* where its value is null) and its fixed headers (AWS JSON's `x-amz-target`), as the route that serves it reads them. */
|
|
160
|
+
function marked(op: DerivedOperation, path: string): string {
|
|
161
|
+
const query = new URLSearchParams(path.includes('?') ? path.slice(path.indexOf('?') + 1) : '');
|
|
162
|
+
const marks = Object.entries(op.discriminator ?? {}).filter(([k]) => !query.has(k)).map(([k, v]) => (v === null ? encodeURIComponent(k) : `${encodeURIComponent(k)}=${encodeURIComponent(v)}`));
|
|
163
|
+
return marks.length ? `${path}${path.includes('?') ? '&' : '?'}${marks.join('&')}` : path;
|
|
164
|
+
}
|
|
165
|
+
/** Where a unit's call goes: a lane on a host of its own (`remoteHost`, the real-system adapters "Lanes") to that host,
|
|
166
|
+
* filled from the call's parameters, as an absolute URL the executor admits only on a host the descriptor declares; any
|
|
167
|
+
* other call to its path on the root's origin. A parameter is one host label, or the literal `values` maps it to. */
|
|
168
|
+
function sentTo(m: DerivedManifest, path: string, params: Record<string, unknown>): string {
|
|
169
|
+
if (!m.remoteHost) return path;
|
|
170
|
+
const { template, values } = m.remoteHost;
|
|
171
|
+
const host = template.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
172
|
+
const raw = params[name] === undefined || params[name] === null ? '' : String(params[name]);
|
|
173
|
+
const mapped = values?.[name];
|
|
174
|
+
if (mapped) { const literal = mapped[raw]; if (literal === undefined) throw new Error(`${m.vendor}: the remote host has no value for ${name} ${JSON.stringify(raw)}`); return literal; }
|
|
175
|
+
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(raw)) throw new Error(`${m.vendor}: the remote host's ${name} is missing or is not a host label`);
|
|
176
|
+
return raw;
|
|
177
|
+
});
|
|
178
|
+
return `https://${host}${path}`;
|
|
179
|
+
}
|
|
180
|
+
const fixedHeaders = (op: DerivedOperation): Record<string, string> => Object.fromEntries(Object.entries(op.headers ?? {}).filter((e): e is [string, string] => typeof e[1] === 'string'));
|
|
181
|
+
|
|
95
182
|
/** The body as the operation's encoding sends it. */
|
|
96
183
|
function encode(op: DerivedOperation, body: unknown): { body?: string; type?: string } {
|
|
97
184
|
if (body === undefined || op.bodyEncoding === 'none') return {};
|
|
@@ -111,6 +198,54 @@ function unitOf(units: Unit[], input: Row): Unit | undefined {
|
|
|
111
198
|
?? (input.graphql ? units[0] : undefined);
|
|
112
199
|
}
|
|
113
200
|
|
|
201
|
+
/** Read the schema-selected resource through response aliases and fragment selections. */
|
|
202
|
+
async function graphqlAnswers(m: DerivedManifest, surface: DerivedSurface, input: Row, parsed: unknown): Promise<Array<{ resource: string; value?: Row }>> {
|
|
203
|
+
if (!surface.graphql) throw new Error('a GraphQL perform needs the vendored schema on its derived surface');
|
|
204
|
+
const g = await import('graphql');
|
|
205
|
+
const schema = g.buildSchema(surface.graphql.sdl, { assumeValidSDL: true });
|
|
206
|
+
const sent = object(input.graphql)!;
|
|
207
|
+
const document = g.parse(String(sent.query));
|
|
208
|
+
const operation = g.getOperationAST(document, typeof sent.operationName === 'string' ? sent.operationName : undefined);
|
|
209
|
+
if (!operation || operation.operation !== 'mutation') throw new Error('the recorded GraphQL write has no selected mutation');
|
|
210
|
+
const fragments = new Map(document.definitions.filter((d): d is import('graphql').FragmentDefinitionNode => d.kind === 'FragmentDefinition').map((d) => [d.name.value, d]));
|
|
211
|
+
const fields = (selection: import('graphql').SelectionSetNode, seen = new Set<string>()): import('graphql').FieldNode[] => selection.selections.flatMap((node) => {
|
|
212
|
+
if (node.kind === 'Field') return [node];
|
|
213
|
+
if (node.kind === 'InlineFragment') return fields(node.selectionSet, seen);
|
|
214
|
+
if (seen.has(node.name.value)) return [];
|
|
215
|
+
const fragment = fragments.get(node.name.value);
|
|
216
|
+
return fragment ? fields(fragment.selectionSet, new Set([...seen, node.name.value])) : [];
|
|
217
|
+
});
|
|
218
|
+
const decode = (value: unknown, selection?: import('graphql').SelectionSetNode): unknown => {
|
|
219
|
+
if (Array.isArray(value)) return value.map((v) => decode(v, selection));
|
|
220
|
+
const row = object(value);
|
|
221
|
+
if (!row || !selection) return value;
|
|
222
|
+
const decoded: Row = {};
|
|
223
|
+
for (const field of fields(selection)) {
|
|
224
|
+
const key = field.alias?.value ?? field.name.value;
|
|
225
|
+
if (key in row) decoded[field.name.value] = decode(row[key], field.selectionSet);
|
|
226
|
+
}
|
|
227
|
+
return decoded;
|
|
228
|
+
};
|
|
229
|
+
const answers: Array<{ resource: string; value?: Row }> = [];
|
|
230
|
+
for (const root of fields(operation.selectionSet)) {
|
|
231
|
+
const definition = schema.getMutationType()?.getFields()[root.name.value];
|
|
232
|
+
if (!definition) continue;
|
|
233
|
+
const type = g.getNamedType(definition.type);
|
|
234
|
+
const value = object(at(parsed, `data.${root.alias?.value ?? root.name.value}`));
|
|
235
|
+
// Skipped selections (directives) have no answer and adopt nothing.
|
|
236
|
+
if (!value) continue;
|
|
237
|
+
const resource = resourceOf(m, type.name);
|
|
238
|
+
if (resource) { answers.push({ resource, value: object(decode(value, root.selectionSet)) }); continue; }
|
|
239
|
+
if (g.isObjectType(type) && root.selectionSet) for (const child of fields(root.selectionSet)) {
|
|
240
|
+
const field = type.getFields()[child.name.value];
|
|
241
|
+
const resource = field ? resourceOf(m, g.getNamedType(field.type).name) : undefined;
|
|
242
|
+
const selected = object(value[child.alias?.value ?? child.name.value]);
|
|
243
|
+
if (resource && selected) answers.push({ resource, value: object(decode(selected, child.selectionSet)) });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return answers;
|
|
247
|
+
}
|
|
248
|
+
|
|
114
249
|
/** The account a root is, before a perform sends a path naming the World's ("The account a root is"): the World's
|
|
115
250
|
* account its own doors made, unadopted, adopts the one account the root's credential lists. Read once per deploy (an
|
|
116
251
|
* adopted account is not asked again); a list the vendor refuses, or one naming several accounts, adopts none. */
|
|
@@ -125,7 +260,7 @@ async function adoptAccount(units: Unit[], execute: RemoteExecute, ctx: PerformC
|
|
|
125
260
|
const scope = decl.refresh;
|
|
126
261
|
const op = scope && 'list' in scope ? u.surface.operations.find((o) => o.id === scope.list) : undefined;
|
|
127
262
|
if (!local.length || !op) continue;
|
|
128
|
-
const answer = await execute({ method: op.method.toUpperCase(), path: `${op.basePath ?? u.surface.basePath ?? ''}${op.path}
|
|
263
|
+
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) });
|
|
129
264
|
if (answer.status >= 400) continue;
|
|
130
265
|
const items = itemsOf(op, parse(answer));
|
|
131
266
|
const id = items.length === 1 ? storedIdOf(decl, items[0]!) : undefined;
|
|
@@ -145,9 +280,15 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
145
280
|
const types = [...new Set(Object.keys(m.resources).map((r) => storedType(m, r)))];
|
|
146
281
|
const resolve = (v: string): string => { for (const t of types) { const r = ctx.resolve(t, v); if (r !== v) return r; } return v; };
|
|
147
282
|
let method: string; let path: string; let body: unknown; let op: DerivedOperation | undefined;
|
|
283
|
+
// the parameters a lane's own host is filled from, as the vendor names them
|
|
284
|
+
let hostParams: Record<string, unknown> = {};
|
|
148
285
|
if (input.graphql) {
|
|
149
286
|
const gql = (m as { graphql?: { paths: string[] } }).graphql;
|
|
150
|
-
method = 'POST'; path = gql?.paths[0] ?? '/graphql';
|
|
287
|
+
method = 'POST'; path = gql?.paths[0] ?? '/graphql';
|
|
288
|
+
const g = await import('graphql');
|
|
289
|
+
const recorded = object(input.graphql)!;
|
|
290
|
+
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 }; } }));
|
|
291
|
+
body = { ...recorded, query, variables: resolvedDeep(recorded.variables, resolve) };
|
|
151
292
|
} else {
|
|
152
293
|
op = surface.operations.find((o) => o.id === input.operationId)!;
|
|
153
294
|
method = op.method.toUpperCase();
|
|
@@ -171,7 +312,9 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
171
312
|
path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, name: string) => encodeURIComponent(resolve(String(params[name] ?? ''))));
|
|
172
313
|
const query = new URLSearchParams(Object.entries(queried).flatMap(([k, v]) => (Array.isArray(v) ? v : [v]).map((x): [string, string] => [k, resolve(String(x))])));
|
|
173
314
|
if ([...query].length) path += `?${query}`;
|
|
315
|
+
path = marked(op, path);
|
|
174
316
|
body = sentBody === undefined ? undefined : resolvedDeep(sentBody, resolve);
|
|
317
|
+
hostParams = Object.fromEntries(Object.entries(params).map(([k, v]) => [k, resolve(String(v))]));
|
|
175
318
|
}
|
|
176
319
|
const files = Array.isArray(input.files) ? (input.files as Row[]) : [];
|
|
177
320
|
// a body recorded as it came (not JSON) goes as it came
|
|
@@ -179,23 +322,49 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
179
322
|
const raw = asCame && typeof asCame.$text === 'string' ? { body: asCame.$text as string } : asCame && typeof asCame.$base64 === 'string' ? { body: new Uint8Array(Buffer.from(asCame.$base64 as string, 'base64')) } : undefined;
|
|
180
323
|
const sent: { body?: string | Uint8Array; type?: string } = raw ? { ...raw, ...(typeof input.contentType === 'string' ? { type: input.contentType } : {}) }
|
|
181
324
|
: files.length || op?.bodyEncoding === 'multipart' ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
|
|
182
|
-
const headers: Record<string, string> = { ...((object(input.headers) ?? {}) as Record<string, string>), ...(sent.type ? { 'content-type': sent.type } : {}) };
|
|
325
|
+
const headers: Record<string, string> = { ...(op ? fixedHeaders(op) : {}), ...((object(input.headers) ?? {}) as Record<string, string>), ...(sent.type ? { 'content-type': sent.type } : {}) };
|
|
183
326
|
// a retried deploy makes nothing twice, and two Worlds deploying into one account never collide
|
|
184
327
|
if (m.idempotency) headers[m.idempotency.header] = hashFieldValue({ credential: ctx.credential ?? '', entry: first.id });
|
|
185
|
-
const answer = await execute({ method, path,
|
|
328
|
+
const answer = await execute({ method, path: sentTo(m, path, hostParams), ...(unit.lane ? { lane: unit.lane } : {}), headers, ...(sent.body !== undefined ? { body: sent.body } : {}) });
|
|
186
329
|
const parsed = parse(answer);
|
|
187
|
-
const
|
|
188
|
-
const refused = answer.status >= 400 || (shape.length > 0 && shape.every(([p, v]) => at(parsed, p) === v));
|
|
330
|
+
const gqlErrors = input.graphql && Array.isArray(at(parsed, 'errors')) ? at(parsed, 'errors') as Row[] : [];
|
|
189
331
|
if (answer.status === 408 || answer.status === 429 || answer.status >= 500) throw new Error(`${answer.status}: ${refusalWords(m, parsed)}${answer.headers['retry-after'] ? ` (retry after ${answer.headers['retry-after']}s)` : ''}`);
|
|
332
|
+
if (gqlErrors.length) throw new RefusedWriteError('vendor', `400: ${gqlErrors.map((e) => String(e.message)).join('; ')}`, first.id);
|
|
333
|
+
const refused = answer.status >= 400 || refusedIn(m, parsed);
|
|
190
334
|
if (refused) throw new RefusedWriteError('vendor', `${answer.status >= 400 ? answer.status : 400}: ${refusalWords(m, parsed)}`, first.id);
|
|
191
335
|
// the subject the answer is: under the operation's key, inside the success envelope when the vendor has one
|
|
192
336
|
const key = op?.answers?.key;
|
|
193
|
-
|
|
337
|
+
if (input.graphql) {
|
|
338
|
+
const answers = await graphqlAnswers(m, surface, input, parsed);
|
|
339
|
+
const remaining = [...group];
|
|
340
|
+
const adopted: Array<{ actionId: string; externalId: string; data: Row }> = [];
|
|
341
|
+
for (const answer of answers) {
|
|
342
|
+
const index = remaining.findIndex((entry) => entry.subject.type === storedType(m, answer.resource));
|
|
343
|
+
if (index < 0) continue;
|
|
344
|
+
const entry = remaining.splice(index, 1)[0]!;
|
|
345
|
+
const decl = m.resources[answer.resource]!;
|
|
346
|
+
const data = answer.value ?? {};
|
|
347
|
+
const externalId = entry.fields?.deleted === true ? ctx.resolve(entry.subject.type, entry.subject.id) : storedIdOf(decl, data) ?? '';
|
|
348
|
+
adopted.push({ actionId: entry.id, externalId, data });
|
|
349
|
+
}
|
|
350
|
+
const primary = adopted.shift();
|
|
351
|
+
return primary ? { ...primary, ...(adopted.length ? { also: adopted } : {}) } : { externalId: '', actionId: group.at(-1)!.id };
|
|
352
|
+
}
|
|
353
|
+
// an XML answer is read through its document element (S3's `InitiateMultipartUploadResult`), as a JSON one under its key
|
|
354
|
+
const documentElement = (): Row | undefined => {
|
|
355
|
+
const root = object(parsed);
|
|
356
|
+
const names = root ? Object.keys(root) : [];
|
|
357
|
+
return answer.body.trimStart().startsWith('<') && names.length === 1 ? object(root![names[0]!]) : undefined;
|
|
358
|
+
};
|
|
359
|
+
const obj = object(key ? at(parsed, key) : parsed === undefined ? undefined : documentElement() ?? parsed) ?? object(parsed) ?? {};
|
|
194
360
|
// the answered resource: the spec's schema name, else the envelope key the answer is under (Slack's `channel`,
|
|
195
361
|
// `message`), when the manifest has a resource of that name
|
|
196
362
|
const answerKey = op?.answers?.key;
|
|
197
363
|
const resource = resourceOf(m, op?.answers?.resource) ?? (answerKey && m.resources[answerKey] ? answerKey : undefined);
|
|
198
|
-
|
|
364
|
+
// an answer naming no resource of the manifest (an operation's own output structure: Secrets Manager's
|
|
365
|
+
// CreateSecretResponse, S3's CreateMultipartUploadOutput) is the group's subject's, its id read by that resource
|
|
366
|
+
const subjectDecl = (): ResourceDecl | undefined => Object.entries(m.resources).find(([r]) => storedType(m, r) === group.at(-1)!.subject.type)?.[1];
|
|
367
|
+
const decl: ResourceDecl | undefined = resource ? m.resources[resource] : subjectDecl();
|
|
199
368
|
const idOf = (o: Row | undefined, d: ResourceDecl | undefined): string | undefined => { const v = o?.[d?.idAs ?? 'id']; return typeof v === 'string' ? v : undefined; };
|
|
200
369
|
// the group's entry the answer is: of the answered resource's stored type (a cascade writes children first), else
|
|
201
370
|
// the last; a delete answers its subject's own vendor id
|
|
@@ -209,9 +378,9 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
209
378
|
// a keyed subject's id is its key filled from the answer (its envelope's fields, Slack's `channel` and `ts`, among them)
|
|
210
379
|
const keyed = decl?.key ? decl : Object.entries(m.resources).find(([r]) => storedType(m, r) === primary.subject.type)?.[1];
|
|
211
380
|
const externalId = deleted ? ctx.resolve(primary.subject.type, primary.subject.id)
|
|
212
|
-
: keyed?.key ? storedIdOf(keyed, { ...(object(parsed) ?? {}), ...obj }) ?? '' : idOf(obj, decl) ?? '';
|
|
381
|
+
: 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) : '');
|
|
213
382
|
const also: Array<{ actionId: string; externalId: string }> = [];
|
|
214
|
-
for (const [companion, where] of Object.entries(decl?.companions ?? {})) {
|
|
383
|
+
for (const [companion, where] of Object.entries(decl?.companions ?? keyed?.companions ?? {})) {
|
|
215
384
|
const id = at(parsed, where) ?? at(obj, where);
|
|
216
385
|
const entry = group.find((e) => e !== primary && e.subject.type === storedType(m, companion));
|
|
217
386
|
if (entry && typeof id === 'string') also.push({ actionId: entry.id, externalId: id });
|
|
@@ -255,7 +424,7 @@ function vendorFieldParams(m: DerivedManifest, resource: string, params: Record<
|
|
|
255
424
|
const parent = decl.parent;
|
|
256
425
|
const parentType = parent ? storedType(m, parent.resource) : undefined;
|
|
257
426
|
const where = Object.entries(parent?.where ?? {}).map(([field, template]) => [field, template, fill(template)] as const);
|
|
258
|
-
const localParent = where.length ? tree.find((r) => r.type === parentType && where.every(([f, , v]) => String(r
|
|
427
|
+
const localParent = where.length ? tree.find((r) => r.type === parentType && where.every(([f, , v]) => String(at(r, f)) === v)) : undefined;
|
|
259
428
|
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;
|
|
260
429
|
if (decl.number && !decl.key) {
|
|
261
430
|
const type = storedType(m, resource);
|
|
@@ -266,16 +435,40 @@ function vendorFieldParams(m: DerivedManifest, resource: string, params: Record<
|
|
|
266
435
|
if (name !== undefined && copy?.[field] !== undefined) params[name] = String(copy[field]);
|
|
267
436
|
}
|
|
268
437
|
const parentCopy = vendorCopy(parentType ?? '', localParent);
|
|
269
|
-
if (parentCopy) for (const [field, template] of where) { const one = /^\{([^}]+)\}$/.exec(template); if (one &&
|
|
438
|
+
if (parentCopy) for (const [field, template] of where) { const one = /^\{([^}]+)\}$/.exec(template); const value = at(parentCopy, field); if (one && value !== undefined) params[one[1]!] = String(value); }
|
|
270
439
|
}
|
|
271
440
|
|
|
272
441
|
/** The items of a list's answer: under the operation's key, else the answer when it is a list. */
|
|
442
|
+
/** The items a scope names: a list, or one item where XML names a lone child (an S3 list of one object), or none. */
|
|
443
|
+
const listed = (v: unknown): unknown[] => (Array.isArray(v) ? v : v === undefined || v === null || v === '' ? [] : [v]);
|
|
444
|
+
|
|
273
445
|
function itemsOf(op: DerivedOperation, parsed: unknown): Row[] {
|
|
274
446
|
const v = op.answers?.key ? at(parsed, op.answers.key) : parsed;
|
|
275
447
|
const list = Array.isArray(v) ? v : Array.isArray(at(parsed, 'data')) ? (at(parsed, 'data') as unknown[]) : Array.isArray(at(parsed, 'results')) ? (at(parsed, 'results') as unknown[]) : [];
|
|
276
448
|
return list.map(object).filter((x): x is Row => x !== undefined);
|
|
277
449
|
}
|
|
278
450
|
|
|
451
|
+
/** A vendor response may hold download links in an object map or an array. Missing optional paths yield none. */
|
|
452
|
+
function valuesAt(value: unknown, keys: readonly string[]): unknown[] {
|
|
453
|
+
if (!keys.length) return value === undefined || value === null ? [] : [value];
|
|
454
|
+
if (!value || typeof value !== 'object') return [];
|
|
455
|
+
const [key, ...rest] = keys;
|
|
456
|
+
return key === '*' ? Object.values(value).flatMap((v) => valuesAt(v, rest)) : valuesAt((value as Row)[key!], rest);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The field a refresh scope's answer names its subject by, where it differs from the resource's (`refresh.idAs`: the
|
|
460
|
+
* Hub's repo read carries the creation's ObjectId as `_id` and its own `namespace/name` as `id`). */
|
|
461
|
+
function scopeIdOf(scope: object, fields: Record<string, unknown>): string | undefined {
|
|
462
|
+
const field = (scope as { idAs?: string }).idAs;
|
|
463
|
+
if (field?.includes('{')) return field.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
464
|
+
const value = at(fields, name);
|
|
465
|
+
if (value === undefined || value === null || value === '') throw new Error(`refresh identity lacks ${name}; nothing folded`);
|
|
466
|
+
return String(value);
|
|
467
|
+
});
|
|
468
|
+
const value = field ? at(fields, field) : undefined;
|
|
469
|
+
return value === undefined || value === null || value === '' ? undefined : String(value);
|
|
470
|
+
}
|
|
471
|
+
|
|
279
472
|
function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapters['refresh']> {
|
|
280
473
|
return async (execute, opts) => {
|
|
281
474
|
const observed: ObservedResource[] = [];
|
|
@@ -283,53 +476,228 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
283
476
|
// what the root's credential may not read (a token scoped to some of the account): left as the World holds it, said
|
|
284
477
|
const unread: Array<{ type: string; path: string; status: number }> = [];
|
|
285
478
|
let answered = 0;
|
|
286
|
-
for (const
|
|
287
|
-
const
|
|
479
|
+
for (const { manifest: m, surface, lane } of units) {
|
|
480
|
+
const onLane = lane ? { lane } : {};
|
|
288
481
|
// parents before children, however deep
|
|
289
482
|
const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
|
|
290
483
|
const depth = (r: string, seen = new Set<string>()): number => { const p = m.resources[r]?.parent?.resource; return p && !seen.has(p) ? 1 + depth(p, seen.add(r)) : 0; };
|
|
291
484
|
const order = scoped.sort(([a], [b]) => depth(a) - depth(b));
|
|
292
485
|
for (const [resource, decl] of order) {
|
|
293
486
|
const scope = decl.refresh!;
|
|
487
|
+
if ('none' in scope) continue;
|
|
488
|
+
if ('graphql' in scope && scope.graphql) {
|
|
489
|
+
await refreshGraphql(m, resource, decl, scope, execute, opts.scope, observed, complete);
|
|
490
|
+
answered += 1;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
294
493
|
const opId = 'list' in scope ? scope.list : 'get' in scope ? scope.get : '';
|
|
295
494
|
const op = surface.operations.find((o) => o.id === opId);
|
|
296
495
|
if (!op) throw new Error(`${m.vendor}: ${resource}'s refresh names ${opId}, no operation of the surface`);
|
|
297
496
|
const type = storedType(m, resource);
|
|
298
|
-
|
|
497
|
+
// the known, read one by one: a vendor that lists no whole type (Volter Identity's people and organizations) is
|
|
498
|
+
// read back subject by subject: each the vendor is known to hold (the parent tree: landed or observed, never a
|
|
499
|
+
// World-only subject, which a 404 would wrongly remove), by the vendor's id, its other path parameters filled
|
|
500
|
+
// from its own fields; a 404 is one the vendor no longer has, and only the subjects read are whole
|
|
501
|
+
if ('get' in scope && scope.known) {
|
|
502
|
+
const aliases = aliasesFrom(parentEntries(service, opts.root));
|
|
503
|
+
const held = [...readParentTreeMap(service, opts.root)].filter(([, r]) => r.type === type && r.fields.deleted !== true);
|
|
504
|
+
const read: string[] = [];
|
|
505
|
+
// a 403 leaves the type as the World holds it, never complete
|
|
506
|
+
let refused = false;
|
|
507
|
+
for (const [key, r] of held) {
|
|
508
|
+
const subject = { ...r.fields, id: r.id } as Row;
|
|
509
|
+
const vendorId = aliases.get(key) ?? String(r.id);
|
|
510
|
+
for (const [name, template] of Object.entries(scope.params ?? {})) subject[name] = template.replace(/\{([^}]+)\}/g, (_, field: string) => {
|
|
511
|
+
if (field === 'id') return vendorId;
|
|
512
|
+
if (subject[field] === undefined || subject[field] === null) throw new Error(`${m.vendor}: ${resource}'s refresh parameter ${name} lacks ${field}; nothing folded`);
|
|
513
|
+
return String(subject[field]);
|
|
514
|
+
});
|
|
515
|
+
// each path parameter is the subject's own field of that name (the Hub's {namespace} and {repo}); one the subject
|
|
516
|
+
// holds no field for, or `id`, is the vendor's id for it
|
|
517
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n: string) =>
|
|
518
|
+
encodeURIComponent(String(n === 'id' || subject[n] === undefined || subject[n] === null ? vendorId : subject[n])));
|
|
519
|
+
// a read naming its subject in the query (an RPC method: Telegram's getChat?chat_id=): the scope's `query`, each
|
|
520
|
+
// `{name}` filled as a path parameter is
|
|
521
|
+
const fillSubject = (t: string): string => t.replace(/\{([^}]+)\}/g, (_, n: string) => String(n === 'id' || subject[n] === undefined || subject[n] === null ? vendorId : subject[n]));
|
|
522
|
+
const asked = Object.entries(scope.query ?? {}).map(([k, t]) => [k, fillSubject(t)]);
|
|
523
|
+
const target = marked(op, path);
|
|
524
|
+
const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
|
|
525
|
+
const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
|
|
526
|
+
if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
527
|
+
if (answer.status === 403) { unread.push({ type, path, status: 403 }); refused = true; break; }
|
|
528
|
+
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
529
|
+
read.push(String(subject.id));
|
|
530
|
+
answered += 1;
|
|
531
|
+
if (answer.status === 404) continue;
|
|
532
|
+
if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
533
|
+
const full = object(op.answers?.key ? at(parse(answer), op.answers.key) : parse(answer)) ?? {};
|
|
534
|
+
const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
|
|
535
|
+
const fields = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
|
|
536
|
+
if (decl.parent) {
|
|
537
|
+
if (r.fields[decl.parent.field] !== undefined) fields[decl.parent.field] = r.fields[decl.parent.field];
|
|
538
|
+
for (const [name, template] of Object.entries(decl.parent.keep ?? {})) {
|
|
539
|
+
if (!name.startsWith('_')) throw new Error(`${m.vendor}: ${resource}'s parent.keep names a non-private field; nothing folded`);
|
|
540
|
+
fields[name] = fillSubject(template);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// the subject asked for, where its answer names no id of its own (a singleton under its account)
|
|
544
|
+
const id = scopeIdOf(scope, fields) ?? storedIdOf(decl, fields) ?? String(r.id);
|
|
545
|
+
observed.push({ type, id, fields: fields as ObservedResource['fields'] });
|
|
546
|
+
}
|
|
547
|
+
if (!refused) complete.push({ type, ids: read });
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
// each parent observed: the path parameters it fills and the value the child's parent field holds (its id, or for
|
|
551
|
+
// a parent the path names by several parameters, `parent.value` filled from them as the twin stores it)
|
|
552
|
+
const parents = decl.parent ? parentsOf(decl.parent, observed.filter((o) => o.type === storedType(m, decl.parent!.resource))) : [undefined];
|
|
299
553
|
let whole = 'list' in scope && scope.complete !== false;
|
|
300
|
-
for (const
|
|
301
|
-
const
|
|
302
|
-
|
|
554
|
+
for (const under of parents) for (const variant of scope.variants ?? [{ headers: {} }]) {
|
|
555
|
+
const parent = under?.value;
|
|
556
|
+
// the list's own headers, `{name}` its parent's parameter, and the variant's
|
|
557
|
+
const filled = (t: string): string => t.replace(/\{([^}]+)\}/g, (_, n: string) => under?.params[n] ?? '');
|
|
558
|
+
const listHeaders = { ...Object.fromEntries(Object.entries(scope.headers ?? {}).map(([n, t]) => [n, filled(t)])), ...variant.headers };
|
|
559
|
+
// what a child keeps of its parent's parameters, private
|
|
560
|
+
const kept = Object.fromEntries(Object.entries(decl.parent?.keep ?? {}).map(([f, t]) => {
|
|
561
|
+
if (!f.startsWith('_')) throw new Error(`${m.vendor}: ${resource}'s parent.keep names ${f}, which is not private`);
|
|
562
|
+
return [f, filled(t)];
|
|
563
|
+
}));
|
|
564
|
+
// a list asked by POST (an RPC list) carries its page in its body, as the operation's encoding sends it
|
|
565
|
+
const rpc = op.method.toLowerCase() !== 'get';
|
|
566
|
+
const unpaginated = 'list' in scope && scope.unpaginated === true;
|
|
567
|
+
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) } : {};
|
|
568
|
+
let page: Record<string, string> = { ...scope.query, ...limit };
|
|
569
|
+
const seenLinks = new Set<string>();
|
|
303
570
|
for (let taken = 0; ;) {
|
|
304
571
|
let unfilled = false;
|
|
305
|
-
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n: string) => { const v = n
|
|
572
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n: string) => { const v = under?.params[n] ?? ''; if (!v) unfilled = true; return encodeURIComponent(v); });
|
|
306
573
|
// a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
574
|
+
// and a parent named by several parameters, each the path does not take that the operation declares in its query
|
|
575
|
+
// (Slack's reactions.get: a message's `channel` and `timestamp`)
|
|
576
|
+
const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) }
|
|
577
|
+
: decl.parent?.params?.length && under ? Object.fromEntries(Object.entries(under.params).filter(([n]) => !op.path.includes(`{${n}}`) && (op.query ?? []).some((x) => x.name === n))) : {};
|
|
578
|
+
// a list asked by POST carries its parent in its body too, unless the operation reads it from its query
|
|
579
|
+
const parentInQuery = !rpc || (op.query ?? []).some((x) => x.name === decl.parent?.param);
|
|
580
|
+
const q = new URLSearchParams(rpc ? (parentInQuery ? byQuery : {}) : { ...page, ...byQuery });
|
|
581
|
+
// Fixed parameters and cursors take the operation's body types, not their manifest string encoding.
|
|
582
|
+
const typedPage = Object.fromEntries(Object.entries(page).map(([k, v]) => {
|
|
583
|
+
const type = (op.body ?? []).find((b) => b.name === k)?.type;
|
|
584
|
+
const value = type === 'integer' ? exactInteger(v) : type === 'number' ? Number(v)
|
|
585
|
+
: type === 'boolean' && (v === 'true' || v === 'false') ? v === 'true' : v;
|
|
586
|
+
return [k, value];
|
|
587
|
+
}));
|
|
588
|
+
const sent = rpc ? encode(op, { ...typedPage, ...(parentInQuery ? {} : byQuery) }) : undefined;
|
|
589
|
+
const answer = await execute({
|
|
590
|
+
method: op.method.toUpperCase(), path: sentTo(m, marked(op, [...q].length ? `${path}?${q}` : path), under?.params ?? {}), ...onLane,
|
|
591
|
+
headers: { ...fixedHeaders(op), ...listHeaders, ...(sent?.type ? { 'content-type': sent.type } : {}) }, ...(sent?.body !== undefined ? { body: sent.body } : {}),
|
|
592
|
+
});
|
|
310
593
|
if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
311
594
|
if (answer.status === 403) { unread.push({ type, path, status: 403 }); whole = false; break; }
|
|
312
595
|
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
313
596
|
// one subject read by its parent and not there (an account with no workers.dev subdomain): nothing to observe. A
|
|
314
597
|
// path missing a parameter (an empty segment) is the kernel's mistake, never the vendor's absence
|
|
315
598
|
if ('get' in scope && answer.status === 404 && !unfilled) { answered += 1; break; }
|
|
316
|
-
if (answer.status >= 400) throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
599
|
+
if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
317
600
|
answered += 1;
|
|
318
601
|
const parsed = parse(answer);
|
|
602
|
+
const shape = 'list' in scope ? scope.shape : undefined;
|
|
319
603
|
const items = 'get' in scope ? [object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {}]
|
|
320
|
-
: 'items' in scope && scope.items ? (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
604
|
+
: 'items' in scope && scope.items ? listed(at(parsed, scope.items)).map((x) => object(x)).filter((x): x is Row => x !== undefined) : itemsOf(op, parsed);
|
|
605
|
+
const keyed = 'list' in scope && scope.keyed === true;
|
|
606
|
+
const map = keyed ? object(at(parsed, scope.items ?? '$body')) : undefined;
|
|
607
|
+
if (keyed && (!map || Object.values(map).some((value) => (shape?.value ? object(value) !== undefined : !object(value)))))
|
|
608
|
+
throw new Error(`${m.vendor}: ${resource}'s keyed refresh must return an object of ${shape?.value ? 'scalars' : 'resource objects'}; nothing folded`);
|
|
609
|
+
// the answer's items as subjects' fields (refresh.shape): a scalar item or map value named, a nested array spread
|
|
610
|
+
let entries: Array<[string | undefined, Row]> = keyed
|
|
611
|
+
? Object.entries(map!).map(([key, value]): [string | undefined, Row] => (shape?.value
|
|
612
|
+
? [shape.keyAs ? undefined : key, { [shape.value]: value, ...(shape.keyAs ? { [shape.keyAs]: key } : {}) }]
|
|
613
|
+
: [key, object(value)!]))
|
|
614
|
+
: shape?.scalar && 'items' in scope && scope.items
|
|
615
|
+
? listed(at(parsed, scope.items)).filter((x) => x !== null && typeof x !== 'object').map((x): [undefined, Row] => [undefined, { [shape.scalar!]: x }])
|
|
616
|
+
: items.map((item) => [undefined, item]);
|
|
617
|
+
// paging reads the answer's own items, before any spread
|
|
618
|
+
const pageItems = entries;
|
|
619
|
+
if (shape?.spread) {
|
|
620
|
+
const { path: inner, as, carry } = shape.spread;
|
|
621
|
+
entries = entries.flatMap(([, item]) => {
|
|
622
|
+
const carried = Object.fromEntries(Object.entries(carry ?? {}).map(([f, p]) => [f, at(item, p)]));
|
|
623
|
+
return listed(at(item, inner)).flatMap((el): Array<[undefined, Row]> => {
|
|
624
|
+
const own = object(el) ?? (as && el !== null && el !== undefined ? { [as]: el } : undefined);
|
|
625
|
+
return own ? [[undefined, { ...carried, ...own }]] : [];
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
for (const [mapId, item] of entries) {
|
|
630
|
+
let full = item;
|
|
631
|
+
if ('list' in scope && scope.detail) {
|
|
632
|
+
const detail = surface.operations.find((o) => o.id === scope.detail);
|
|
633
|
+
if (!detail) throw new Error(`${m.vendor}: ${resource}'s refresh detail names ${scope.detail}, no operation of the surface`);
|
|
634
|
+
const id = mapId ?? storedIdOf(decl, item);
|
|
635
|
+
const path = `${detail.basePath ?? surface.basePath ?? ''}${detail.path}`.replace(/\{([^}]+)\}/g, (_, n: string) => {
|
|
636
|
+
const value = under?.params[n] ?? item[n] ?? id;
|
|
637
|
+
if (value === undefined) throw new Error(`${m.vendor}: ${resource}'s refresh detail has no value for ${n}`);
|
|
638
|
+
return encodeURIComponent(String(value));
|
|
639
|
+
});
|
|
640
|
+
// the subject a detail read names in its body or query (`detailWith`: AWS JSON's DescribeSecret, `{ SecretId }`)
|
|
641
|
+
const withValues = Object.fromEntries(Object.entries(scope.detailWith ?? {}).map(([k, t]) => [k, t.replace(/\{([^}]+)\}/g, (_, n: string) => String(n === 'id' ? id : item[n] ?? ''))]));
|
|
642
|
+
const detailRpc = detail.method.toLowerCase() !== 'get';
|
|
643
|
+
const detailQuery = { ...(scope.query ?? {}), ...(detailRpc ? {} : withValues) };
|
|
644
|
+
const detailSent = detailRpc && Object.keys(withValues).length ? encode(detail, withValues) : undefined;
|
|
645
|
+
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 } : {}) });
|
|
646
|
+
if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${answer.status}: ${refusalWords(m, parse(answer)) || 'the vendor refused the refresh detail; nothing folded'}`);
|
|
647
|
+
const detailBody = parse(answer);
|
|
648
|
+
const detailKey = scope.detailMerge ?? detail.answers?.key;
|
|
649
|
+
const fragment = object(detailKey ? at(detailBody, detailKey) : detailBody);
|
|
650
|
+
if (scope.detailMerge && !fragment) throw new Error(`${m.vendor}: ${resource}'s refresh detail fragment is not an object; nothing folded`);
|
|
651
|
+
const detailFields = fragment ?? {};
|
|
652
|
+
full = scope.detailMerge ? { ...item, ...detailFields } : detailFields;
|
|
653
|
+
}
|
|
654
|
+
// the vendor's shape alone: a World field the twin discloses (manifest.worldFields: Substrate's
|
|
655
|
+
// twin_local_ports) is the answering World's, never the vendor's, and is not folded in
|
|
656
|
+
const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
|
|
657
|
+
const vendorShape = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
|
|
658
|
+
const fields = { ...vendorShape, ...kept, ...(decl.parent && parent !== undefined ? { [decl.parent.field]: parent } : {}) };
|
|
659
|
+
const id = mapId ?? scopeIdOf(scope, fields) ?? storedIdOf(decl, fields);
|
|
324
660
|
if (id === undefined) continue;
|
|
325
661
|
observed.push({ type, id, fields: fields as ObservedResource['fields'] });
|
|
326
662
|
}
|
|
327
|
-
taken +=
|
|
328
|
-
if ('get' in scope) break;
|
|
663
|
+
taken += pageItems.length;
|
|
664
|
+
if ('get' in scope || unpaginated) break;
|
|
329
665
|
if (taken >= 10_000) { whole = false; break; }
|
|
666
|
+
if ('nextLink' in scope && scope.nextLink) {
|
|
667
|
+
const nextLink = scope.nextLink;
|
|
668
|
+
const header = Object.entries(answer.headers).find(([name]) => name.toLowerCase() === nextLink.header.toLowerCase())?.[1];
|
|
669
|
+
const links = header ? linkRelations(header).filter((link) => link.params.rel?.split(/\s+/).includes(nextLink.rel)) : [];
|
|
670
|
+
if (links.length > 1) throw new Error(`${m.vendor}: ambiguous refresh Link relation; nothing folded`);
|
|
671
|
+
const link = links[0];
|
|
672
|
+
if (!link || Object.entries(nextLink.when ?? {}).some(([name, value]) => link.params[name.toLowerCase()] !== value)) break;
|
|
673
|
+
let marker: string | null;
|
|
674
|
+
try { marker = new URL(link.url).searchParams.get(nextLink.query); }
|
|
675
|
+
catch { throw new Error(`${m.vendor}: refresh Link continuation is not an absolute URL; nothing folded`); }
|
|
676
|
+
if (!marker) throw new Error(`${m.vendor}: refresh Link URL lacks its declared cursor; nothing folded`);
|
|
677
|
+
const param = scope.cursor ?? nextLink.query;
|
|
678
|
+
if (page[param] === marker || seenLinks.has(marker)) throw new Error(`${m.vendor}: ${resource}'s list repeated its continuation; nothing folded`);
|
|
679
|
+
seenLinks.add(marker);
|
|
680
|
+
page = { ...page, [param]: marker };
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
330
683
|
// the next page by the manifest's list model. The list is whole only where its end shows: a cursor model
|
|
331
684
|
// gives no next cursor (a short page may still name one), or a page is empty or shorter than the limit
|
|
332
685
|
// asked; a list the twin cannot page on, or whose page size it does not know, is not known whole.
|
|
686
|
+
// the cursor the scope names (AWS's NextToken), sent back as its parameter
|
|
687
|
+
if ('next' in scope && scope.next) {
|
|
688
|
+
// the vendor's own flag that no page follows ends it, whatever cursor the page names
|
|
689
|
+
if ('more' in scope && scope.more !== undefined && !['true', true].includes(at(parsed, scope.more) as string | boolean)) break;
|
|
690
|
+
const markers = typeof scope.next === 'string' ? { [scope.cursor ?? scope.next]: scope.next } : scope.next;
|
|
691
|
+
const next = Object.fromEntries(Object.entries(markers).flatMap(([param, where]) => {
|
|
692
|
+
const marker = at(parsed, where);
|
|
693
|
+
return marker === undefined || marker === null || marker === '' ? [] : [[param, String(marker)]];
|
|
694
|
+
}));
|
|
695
|
+
if (!Object.keys(next).length) break;
|
|
696
|
+
// a page that names the cursor it was asked with would be read forever: nothing folds
|
|
697
|
+
if (Object.entries(next).every(([k, v]) => page[k] === v)) throw new Error(`${m.vendor}: ${resource}'s list repeated its continuation; nothing folded`);
|
|
698
|
+
page = { ...page, ...next };
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
333
701
|
if (m.list?.cursor) {
|
|
334
702
|
const next = at(parsed, 'next_cursor') ?? at(parsed, 'response_metadata.next_cursor');
|
|
335
703
|
if (!next) break;
|
|
@@ -337,9 +705,9 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
337
705
|
continue;
|
|
338
706
|
}
|
|
339
707
|
const size = Number(limit[m.list?.limit.param ?? ''] ?? 0);
|
|
340
|
-
if (!
|
|
708
|
+
if (!pageItems.length || (size && pageItems.length < size)) break;
|
|
341
709
|
if (!size) { whole = false; break; }
|
|
342
|
-
const last =
|
|
710
|
+
const last = pageItems.at(-1)![0] ?? pageItems.at(-1)![1][decl.idAs ?? 'id'];
|
|
343
711
|
if (m.list?.after) page = { ...page, [m.list.after]: String(last) };
|
|
344
712
|
else if (m.list?.page) page = { ...page, [m.list.page.param]: String(Number(page[m.list.page.param] ?? 1) + 1) };
|
|
345
713
|
else if (m.list?.offset) page = { ...page, [m.list.offset.param]: String(taken) };
|
|
@@ -350,17 +718,151 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
350
718
|
// scoped to one resource (`RootConfig.scope`, GitHub's `repos/o/r`) lists only what is under it, so a type with no
|
|
351
719
|
// parent is never the account's whole list there
|
|
352
720
|
if (whole && !decl.parent && !opts.scope) complete.push(type);
|
|
353
|
-
else if (whole && parents.length) complete.push({ type, field: decl.parent!.field, within: parents.map(
|
|
721
|
+
else if (whole && parents.length) complete.push({ type, field: decl.parent!.field, within: parents.map((p) => p!.value) });
|
|
354
722
|
}
|
|
355
723
|
}
|
|
356
724
|
// every read refused is a credential the vendor refuses, never an account that holds nothing
|
|
357
725
|
if (unread.length && !answered) throw new Error(`403: the vendor refused the root's credential for every read (${unread.map((u) => u.path).join(', ')}); nothing folded`);
|
|
726
|
+
const downloads: Array<{ key: string; bytes: Uint8Array }> = [];
|
|
727
|
+
for (const { manifest: m, surface, lane } of units) for (const [resource, decl] of Object.entries(m.resources)) {
|
|
728
|
+
const scope = decl.refresh;
|
|
729
|
+
if (!scope || 'none' in scope || !scope.blobs?.length) continue;
|
|
730
|
+
for (const row of observed.filter((o) => o.type === storedType(m, resource))) for (const blob of scope.blobs) {
|
|
731
|
+
// the bytes an operation of the unit answers for the subject (R2's GetObject: its list names no download link)
|
|
732
|
+
if ('operation' in blob) {
|
|
733
|
+
if (!blob.field.startsWith('_') || (blob.headersField !== undefined && !blob.headersField.startsWith('_'))) throw new Error(`${m.vendor}: ${resource}'s refresh blob fields must be private`);
|
|
734
|
+
const op = surface.operations.find((o) => o.id === blob.operation);
|
|
735
|
+
if (!op || op.method.toUpperCase() !== 'GET') throw new Error(`${m.vendor}: ${resource}'s refresh blob names ${blob.operation}, no GET of the surface`);
|
|
736
|
+
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;
|
|
737
|
+
const values: Row = { ...row.fields, ...under?.params, id: row.id };
|
|
738
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n: string) => {
|
|
739
|
+
if (values[n] === undefined || values[n] === null) throw new Error(`${m.vendor}: ${resource}'s refresh blob has no value for ${n}`);
|
|
740
|
+
return encodeURIComponent(String(values[n]));
|
|
741
|
+
});
|
|
742
|
+
const answer = await execute({ method: 'GET', path: sentTo(m, marked(op, path), values), ...(lane ? { lane } : {}), headers: { ...fixedHeaders(op), ...blob.headers }, responseType: 'bytes' });
|
|
743
|
+
if (answer.status < 200 || answer.status >= 300 || !answer.bodyBytes) throw new Error(`${m.vendor}: refresh blob ${blob.operation} answered ${answer.status}; nothing folded`);
|
|
744
|
+
const key = `refresh/${encodeURIComponent(row.type)}/${encodeURIComponent(row.id)}`;
|
|
745
|
+
// only the headers the scope names: the answer's own (its date, its length) would change every read
|
|
746
|
+
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]));
|
|
747
|
+
row.fields = { ...row.fields, [blob.field]: key, ...(blob.headersField ? { [blob.headersField]: keptHeaders } : {}) } as ObservedResource['fields'];
|
|
748
|
+
downloads.push({ key, bytes: answer.bodyBytes });
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
for (const value of valuesAt(row.fields, blob.path.split('.'))) {
|
|
752
|
+
if (typeof value !== 'string') throw new Error(`${m.vendor}: refresh blob ${blob.path} is not a URL`);
|
|
753
|
+
let url = new URL(value);
|
|
754
|
+
const boundUrl = readRoot(service, opts.root)?.url;
|
|
755
|
+
const bound = boundUrl ? new URL(boundUrl) : undefined;
|
|
756
|
+
const prefix = bound?.pathname.replace(/\/+$/, '') ?? '';
|
|
757
|
+
const primary = m.origin?.placeholder ? new URL(m.origin.placeholder).hostname : m.descriptor?.hosts?.find((h) => 'host' in h && !h.exclude);
|
|
758
|
+
const primaryHost = typeof primary === 'string' ? primary : primary && 'host' in primary ? primary.host : undefined;
|
|
759
|
+
let answer: RemoteExecuteResponse | undefined;
|
|
760
|
+
let pathname = '';
|
|
761
|
+
for (let hops = 0; hops <= 5; hops++) {
|
|
762
|
+
const fromRoot = !!bound && url.origin === bound.origin && (url.pathname === prefix || url.pathname.startsWith(prefix + '/'));
|
|
763
|
+
pathname = fromRoot ? url.pathname.slice(prefix.length) || '/' : url.pathname;
|
|
764
|
+
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))))) throw new Error(`${m.vendor}: refresh blob host is not declared: ${url.hostname}`);
|
|
765
|
+
answer = await execute({ method: 'GET', path: fromRoot || url.hostname === primaryHost ? pathname + url.search : url.href, headers: { host: url.host }, responseType: 'bytes' });
|
|
766
|
+
if (![301, 302, 303, 307, 308].includes(answer.status)) break;
|
|
767
|
+
if (hops === 5 || !answer.headers.location) throw new Error(`${m.vendor}: invalid refresh blob redirect; nothing folded`);
|
|
768
|
+
url = new URL(answer.headers.location, url);
|
|
769
|
+
}
|
|
770
|
+
if (!answer || answer.status < 200 || answer.status >= 300 || !answer.bodyBytes) throw new Error(`${m.vendor}: refresh blob ${blob.path} answered ${answer?.status}; nothing folded`);
|
|
771
|
+
const fill = (template: string, key = ''): string => template.replace(/\{pathname\}/g, decodeURIComponent(pathname)).replace(/\{key\}/g, key).replace(/\{header:([^}]+)\}/g, (_, name: string) => {
|
|
772
|
+
const value = answer!.headers[name.toLowerCase()];
|
|
773
|
+
if (value === undefined) throw new Error(`${m.vendor}: refresh blob header ${name} is missing; nothing folded`);
|
|
774
|
+
return value;
|
|
775
|
+
});
|
|
776
|
+
const key = fill(blob.key);
|
|
777
|
+
if (!key || key.startsWith('/') || key.includes('\0') || key.split(/[\\/]/).includes('..') || /\{[^}]+\}/.test(key)) throw new Error(`${m.vendor}: invalid refresh blob key`);
|
|
778
|
+
if (blob.resource) {
|
|
779
|
+
if (!blob.resource.startsWith('_') || !m.resources[blob.resource]) throw new Error(`${m.vendor}: refresh blob metadata requires a declared bookkeeping resource`);
|
|
780
|
+
observed.push({ type: storedType(m, blob.resource), id: key, fields: Object.fromEntries(Object.entries(blob.fields ?? {}).map(([name, template]) => [name, fill(template, key)])) });
|
|
781
|
+
}
|
|
782
|
+
downloads.push({ key, bytes: answer.bodyBytes });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
for (const download of downloads) await writeResourceBlob(service, download.key, download.bytes, opts.root);
|
|
358
787
|
adopt(service, opts.root, units, observed);
|
|
359
788
|
const report = observeResources(service, observed, { ...(opts.root !== undefined ? { root: opts.root } : {}), complete });
|
|
360
789
|
return unread.length ? { ...(report as object), unread } : report;
|
|
361
790
|
};
|
|
362
791
|
}
|
|
363
792
|
|
|
793
|
+
/** A declared GraphQL read over the same executor and atomic observation batch as REST. */
|
|
794
|
+
async function refreshGraphql(m: DerivedManifest, resource: string, decl: ResourceDecl,
|
|
795
|
+
scope: Exclude<NonNullable<ResourceDecl['refresh']>, { none: string }>, execute: RemoteExecute,
|
|
796
|
+
rootScope: string | undefined, observed: ObservedResource[], complete: Completed[]): Promise<void> {
|
|
797
|
+
const gql = scope.graphql!;
|
|
798
|
+
const endpoint = m.graphql?.paths[0];
|
|
799
|
+
if (!endpoint) throw new Error(`${m.vendor}: a GraphQL refresh needs a GraphQL endpoint`);
|
|
800
|
+
const type = storedType(m, resource);
|
|
801
|
+
const parents = decl.parent ? parentsOf(decl.parent, observed.filter((o) => o.type === storedType(m, decl.parent!.resource))) : [undefined];
|
|
802
|
+
let whole = 'list' in scope && scope.complete !== false;
|
|
803
|
+
for (const under of parents) {
|
|
804
|
+
const substitute = (v: unknown): unknown => typeof v === 'string' && /^\{[^}]+\}$/.test(v) ? under?.params[v.slice(1, -1)] ?? v
|
|
805
|
+
: Array.isArray(v) ? v.map(substitute) : object(v) ? Object.fromEntries(Object.entries(object(v)!).map(([k, x]) => [k, substitute(x)])) : v;
|
|
806
|
+
const variables = substitute(gql.variables ?? {}) as Row;
|
|
807
|
+
const cursors = new Set<string>();
|
|
808
|
+
for (let taken = 0; ;) {
|
|
809
|
+
const answer = await execute({ method: 'POST', path: endpoint, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: gql.query, variables }) });
|
|
810
|
+
const parsed = parse(answer);
|
|
811
|
+
if (answer.status >= 400 || (Array.isArray(at(parsed, 'errors')) && (at(parsed, 'errors') as unknown[]).length)) throw new Error(`${answer.status}: the vendor refused the GraphQL refresh; nothing folded: ${JSON.stringify(parsed)}`);
|
|
812
|
+
const value = at(parsed, gql.items);
|
|
813
|
+
if (value === undefined) throw new Error(`${m.vendor}: the GraphQL refresh returned no ${gql.items}; nothing folded`);
|
|
814
|
+
const items = listed(value).map(object).filter((x): x is Row => x !== undefined);
|
|
815
|
+
for (const item of items) {
|
|
816
|
+
const fields = { ...item, ...(decl.parent && under ? { [decl.parent.field]: under.value } : {}) };
|
|
817
|
+
const id = storedIdOf(decl, fields);
|
|
818
|
+
if (id !== undefined) observed.push({ type, id, fields: fields as ObservedResource['fields'] });
|
|
819
|
+
}
|
|
820
|
+
taken += items.length;
|
|
821
|
+
if ('get' in scope || ('list' in scope && scope.unpaginated)) break;
|
|
822
|
+
if (!gql.pageInfo) { whole = false; break; }
|
|
823
|
+
const info = object(at(parsed, gql.pageInfo));
|
|
824
|
+
if (!info || typeof info.hasNextPage !== 'boolean') throw new Error(`${m.vendor}: missing GraphQL page information; nothing folded`);
|
|
825
|
+
if (!info.hasNextPage) break;
|
|
826
|
+
if (taken >= 10_000) { whole = false; break; }
|
|
827
|
+
const cursor = info.endCursor;
|
|
828
|
+
if (typeof cursor !== 'string' || !cursor || cursors.has(cursor)) throw new Error(`${m.vendor}: invalid GraphQL continuation; nothing folded`);
|
|
829
|
+
cursors.add(cursor);
|
|
830
|
+
variables[gql.cursor ?? 'after'] = cursor;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (whole && !decl.parent && !rootScope) complete.push(type);
|
|
834
|
+
else if (whole && parents.length && decl.parent) complete.push({ type, field: decl.parent.field, within: parents.map((p) => p!.value) });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** The parents a child type is listed under, from the parent type's observations: the path parameters each fills and
|
|
838
|
+
* the value the child's `parent.field` holds. A single `param` is the parent's id. A parent the path names by several
|
|
839
|
+
* parameters (`params`, GitHub's `{owner}/{repo}`) reads each from the parent's fields through its `where` templates
|
|
840
|
+
* (`full_name: '{owner}/{repo}'`), and the child holds `value` filled from them (the parameters joined by `/` when it
|
|
841
|
+
* names none), as the derived core stores it ("Refresh"). A parent whose fields fill not every parameter is skipped. */
|
|
842
|
+
function parentsOf(parent: NonNullable<ResourceDecl['parent']>, observations: ObservedResource[]): Array<{ params: Record<string, string>; value: string }> {
|
|
843
|
+
const out = new Map<string, { params: Record<string, string>; value: string }>();
|
|
844
|
+
for (const o of observations) {
|
|
845
|
+
if (!parent.params?.length) {
|
|
846
|
+
out.set(o.id, { params: parent.param ? { [parent.param]: o.id } : {}, value: o.id });
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const params: Record<string, string> = {};
|
|
850
|
+
for (const [field, template] of Object.entries(parent.where ?? {})) {
|
|
851
|
+
const names: string[] = [];
|
|
852
|
+
// a template that is one parameter whole takes the field whole, its slashes included (an S3 key)
|
|
853
|
+
const whole = /^\{([^}]+)\}$/.exec(template);
|
|
854
|
+
if (whole) { const v = at(o.fields, field); if (v !== undefined && v !== null && v !== '') params[whole[1]!] = String(v); continue; }
|
|
855
|
+
const pattern = template.replace(/\{([^}]+)\}|[.*+?^$()|[\]\\]/g, (m, n?: string) => (n ? (names.push(n), '([^/]+)') : `\\${m}`));
|
|
856
|
+
const got = new RegExp(`^${pattern}$`).exec(String(at(o.fields, field) ?? ''));
|
|
857
|
+
if (got) names.forEach((n, i) => { params[n] = got[i + 1]!; });
|
|
858
|
+
}
|
|
859
|
+
if (!parent.params.every((n) => params[n] !== undefined)) continue;
|
|
860
|
+
const value = (parent.value ?? parent.params.map((n) => `{${n}}`).join('/')).replace(/\{([^}]+)\}/g, (_, n: string) => params[n] ?? '');
|
|
861
|
+
out.set(value, { params, value });
|
|
862
|
+
}
|
|
863
|
+
return [...out.values()];
|
|
864
|
+
}
|
|
865
|
+
|
|
364
866
|
/** A vendor's subject's stored id: its key template filled from its fields (its parent's among them), else its id
|
|
365
867
|
* field. Undefined when the subject names none. */
|
|
366
868
|
function storedIdOf(decl: ResourceDecl, fields: Row): string | undefined {
|
|
@@ -414,7 +916,7 @@ function adopt(service: string, root: string | undefined, units: Unit[], observe
|
|
|
414
916
|
const parentType = decl?.parent && m ? storedType(m, decl.parent.resource) : undefined;
|
|
415
917
|
const resolved = (v: unknown): string => aliases.get(`${parentType}:${String(v)}`) ?? String(v);
|
|
416
918
|
const siblings = pool.filter((p) => parentField === undefined || resolved(p.fields[parentField]) === String(o.fields[parentField]));
|
|
417
|
-
const keys = decl?.alternateKeys ?? [];
|
|
919
|
+
const keys = decl?.adoptBy ?? decl?.alternateKeys ?? [];
|
|
418
920
|
const match = keys.length ? siblings.filter((p) => keys.every((k) => p.fields[k] !== undefined && p.fields[k] === o.fields[k])) : siblings.length === 1 ? siblings : [];
|
|
419
921
|
if (match.length !== 1) continue;
|
|
420
922
|
o.adopts = match[0]!.id;
|
|
@@ -425,7 +927,7 @@ function adopt(service: string, root: string | undefined, units: Unit[], observe
|
|
|
425
927
|
}
|
|
426
928
|
|
|
427
929
|
/** The event's resource: the declared `types`, else the manifest's events read backwards, else `<resource>.created|updated|deleted`. */
|
|
428
|
-
function eventResource(m: DerivedManifest, decl: IngestDecl, type: string):
|
|
930
|
+
function eventResource(m: DerivedManifest, decl: IngestDecl, type: string): NonNullable<IngestDecl['types']>[string] | undefined {
|
|
429
931
|
if (decl.types?.[type]) return decl.types[type];
|
|
430
932
|
for (const [operation, sent] of Object.entries(m.events?.types ?? {})) {
|
|
431
933
|
if ((Array.isArray(sent) ? sent : [sent]).includes(type)) { const r = resourceOf(m, operation.split('.')[0]); if (r) return { resource: r, ...(/\.delete/.test(operation) ? { deleted: true as const } : {}) }; }
|
|
@@ -439,6 +941,7 @@ function eventResource(m: DerivedManifest, decl: IngestDecl, type: string): { re
|
|
|
439
941
|
function older(theirs: unknown, held: unknown): boolean {
|
|
440
942
|
if (theirs === undefined || theirs === null || held === undefined || held === null) return false;
|
|
441
943
|
const a = Number(theirs); const b = Number(held);
|
|
944
|
+
if (/^-?\d+$/.test(String(theirs)) && /^-?\d+$/.test(String(held))) return compareExactNumbers(theirs, held) < 0;
|
|
442
945
|
if (String(theirs).trim() !== '' && String(held).trim() !== '' && Number.isFinite(a) && Number.isFinite(b)) return a < b;
|
|
443
946
|
const ta = Date.parse(String(theirs)); const tb = Date.parse(String(held));
|
|
444
947
|
if (Number.isFinite(ta) && Number.isFinite(tb)) return ta < tb;
|
|
@@ -453,7 +956,7 @@ function ingestOf(units: Unit[], decl: IngestDecl, service: string): NonNullable
|
|
|
453
956
|
const schemes = (Array.isArray(decl.scheme) ? decl.scheme : [decl.scheme]) as EventScheme[];
|
|
454
957
|
if (!ctx.secret || !schemes.some((s) => verifyEvent(s, ctx.secret!, body, headers, { now, tolerance: 300 }))) return Response.json({ error: 'invalid signature' }, { status: 401 });
|
|
455
958
|
let event: unknown;
|
|
456
|
-
try { event =
|
|
959
|
+
try { event = parseExactJson(body); } catch { return Response.json({ error: 'the event is not JSON' }, { status: 400 }); }
|
|
457
960
|
if (decl.handshake && Object.entries(decl.handshake.when).every(([k, v]) => at(event, k) === v)) {
|
|
458
961
|
const answer = decl.handshake.answer.startsWith('$body.') ? at(event, decl.handshake.answer) : decl.handshake.answer;
|
|
459
962
|
return new Response(typeof answer === 'string' ? answer : JSON.stringify(answer), { status: 200 });
|
|
@@ -463,9 +966,16 @@ function ingestOf(units: Unit[], decl: IngestDecl, service: string): NonNullable
|
|
|
463
966
|
for (const { manifest: m } of units) {
|
|
464
967
|
const found = eventResource(m, decl, type);
|
|
465
968
|
if (!found) continue;
|
|
466
|
-
const item = object(at(event, decl.object));
|
|
467
969
|
const d = m.resources[found.resource]!;
|
|
468
|
-
|
|
970
|
+
// the object where its type carries it, with its parent's value where the event names the parent beside it
|
|
971
|
+
const carried = object(at(event, found.object ?? decl.object));
|
|
972
|
+
const parent = found.parent && d.parent ? at(event, found.parent) : undefined;
|
|
973
|
+
const withParent = carried && (typeof parent === 'string' || typeof parent === 'number') ? { ...carried, [d.parent!.field]: String(parent) } : carried;
|
|
974
|
+
// the subject's id where the type names it (kept out of the fields: the vendor's object carries no such field), and
|
|
975
|
+
// what the type itself says of the subject
|
|
976
|
+
const named = found.id && withParent ? at(withParent, found.id) : undefined;
|
|
977
|
+
const item = withParent ? { ...(found.id && !found.id.includes('.') ? Object.fromEntries(Object.entries(withParent).filter(([k]) => k !== found.id)) : withParent), ...(found.fields ?? {}) } : undefined;
|
|
978
|
+
const id = found.id ? (typeof named === 'string' || typeof named === 'number' ? String(named) : undefined) : item ? storedIdOf(d, item) : undefined;
|
|
469
979
|
if (!item || id === undefined) break;
|
|
470
980
|
const stored = storedType(m, found.resource);
|
|
471
981
|
// an event older than the root's copy folds nothing
|