@volter/world-core 2.0.37 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/app-route.cjs +12 -6
- package/app-route.d.cts +1 -1
- package/dist/app-route.cjs +12 -6
- package/dist/app-route.d.cts +1 -1
- package/dist/generated/pack-facts.json +1410 -3069
- package/dist/inject.cjs +64 -9
- package/dist/pack-facts.cjs +44 -0
- package/dist/src/actions.d.ts +3 -3
- package/dist/src/actions.js +22 -16
- package/dist/src/ancestry.d.ts +14 -2
- package/dist/src/ancestry.js +92 -2
- package/dist/src/anthropic-wire.d.ts +39 -0
- package/dist/src/anthropic-wire.js +136 -0
- package/dist/src/bytes.d.ts +7 -0
- package/dist/src/bytes.js +35 -0
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +0 -0
- package/dist/src/clickhouse/index.d.ts +3 -0
- package/dist/src/clickhouse/index.js +6 -0
- package/dist/src/clickhouse/sql.d.ts +233 -0
- package/dist/src/clickhouse/sql.js +4329 -0
- package/dist/src/clickhouse/types.d.ts +18 -0
- package/dist/src/clickhouse/types.js +47 -0
- package/dist/src/clickhouse/values.d.ts +146 -0
- package/dist/src/clickhouse/values.js +858 -0
- package/dist/src/client-bundle.js +2 -3
- package/dist/src/cors.d.ts +15 -0
- package/dist/src/cors.js +31 -0
- package/dist/src/derived-core.d.ts +487 -24
- package/dist/src/derived-core.js +788 -144
- package/dist/src/derived-real.d.ts +13 -0
- package/dist/src/derived-real.js +518 -0
- package/dist/src/derived.d.ts +35 -1
- package/dist/src/derived.js +61 -9
- package/dist/src/emit.js +1 -2
- package/dist/src/events.d.ts +206 -0
- package/dist/src/events.js +341 -0
- package/dist/src/executor.d.ts +3 -0
- package/dist/src/executor.js +19 -2
- package/dist/src/file-response.d.ts +6 -0
- package/dist/src/file-response.js +30 -0
- package/dist/src/fork.js +3 -2
- package/dist/src/git/history.d.ts +7 -0
- package/dist/src/git/history.js +24 -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 +28 -0
- package/dist/src/git/lfs.js +66 -0
- package/dist/src/git/objects.js +3 -8
- package/dist/src/git/smart-http.d.ts +3 -1
- package/dist/src/git/smart-http.js +67 -6
- package/dist/src/graphql-wire.d.ts +29 -0
- package/dist/src/graphql-wire.js +101 -0
- package/dist/src/grpc-wire.d.ts +67 -0
- package/dist/src/grpc-wire.js +170 -0
- package/dist/src/h2.d.ts +40 -0
- package/dist/src/h2.js +656 -0
- package/dist/src/head.d.ts +32 -3
- package/dist/src/head.js +161 -40
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +6 -6
- package/dist/src/hpack.json +1 -0
- package/dist/src/index.d.ts +64 -75
- package/dist/src/index.js +58 -101
- package/dist/src/log.js +28 -19
- package/dist/src/machines.d.ts +50 -0
- package/dist/src/machines.js +151 -0
- package/dist/src/managed-database.d.ts +86 -0
- package/dist/src/managed-database.js +283 -0
- package/dist/src/multipart.d.ts +11 -0
- package/dist/src/multipart.js +51 -0
- package/dist/src/observe.d.ts +15 -5
- package/dist/src/observe.js +23 -9
- package/dist/src/openai-wire.d.ts +108 -0
- package/dist/src/openai-wire.js +337 -0
- package/dist/src/pack-assets.d.ts +3 -4
- package/dist/src/pack-assets.js +15 -10
- package/dist/src/pack-fetch.d.ts +77 -0
- package/dist/src/pack-fetch.js +449 -0
- package/dist/src/pack-paths.d.ts +12 -0
- package/dist/src/pack-paths.js +86 -0
- package/dist/src/packRegistry.d.ts +69 -162
- package/dist/src/packRegistry.js +55 -20
- package/dist/src/people.d.ts +13 -0
- package/dist/src/people.js +18 -0
- package/dist/src/placeholder-image.d.ts +5 -0
- package/dist/src/placeholder-image.js +114 -0
- package/dist/src/protobuf.d.ts +28 -0
- package/dist/src/protobuf.js +332 -0
- package/dist/src/redis/engine.js +1 -1
- package/dist/src/request-scope.d.ts +1 -1
- package/dist/src/request-scope.js +6 -4
- package/dist/src/resource-blob.d.ts +5 -0
- package/dist/src/resource-blob.js +11 -0
- package/dist/src/runtime.d.ts +85 -0
- package/dist/src/runtime.js +104 -0
- package/dist/src/s3/wire.d.ts +60 -0
- package/dist/src/s3/wire.js +157 -0
- package/dist/src/scenario.d.ts +3 -0
- package/dist/src/scenario.js +2 -0
- package/dist/src/schema-sample.d.ts +1 -0
- package/dist/src/schema-sample.js +21 -0
- package/dist/src/sealed-box.d.ts +14 -0
- package/dist/src/sealed-box.js +225 -0
- package/dist/src/serve-http.d.ts +14 -0
- package/dist/src/serve-http.js +27 -3
- package/dist/src/serve.d.ts +6 -0
- package/dist/src/serve.js +69 -14
- package/dist/src/signing.d.ts +135 -0
- package/dist/src/signing.js +222 -0
- package/dist/src/sigv4.d.ts +48 -0
- package/dist/src/sigv4.js +167 -0
- package/dist/src/smtp.d.ts +16 -0
- package/dist/src/smtp.js +72 -0
- package/dist/src/sockets.d.ts +51 -0
- package/dist/src/sockets.js +90 -0
- package/dist/src/state-system.d.ts +1 -0
- package/dist/src/state-system.js +1 -1
- package/dist/src/storage.d.ts +1 -1
- package/dist/src/storage.js +3 -3
- package/dist/src/trace-context.js +1 -1
- package/dist/src/twin-fetch.d.ts +0 -7
- package/dist/src/twin-fetch.js +0 -14
- package/dist/src/vendor-call.d.ts +6 -0
- package/dist/src/vendor-call.js +41 -0
- package/dist/src/world-store.js +1 -1
- package/dist/vendor-hosts.cjs +36 -125
- package/dist/vendor-hosts.d.cts +8 -0
- package/generated/pack-facts.json +1410 -3069
- package/inject.cjs +64 -9
- package/pack-facts.cjs +44 -0
- package/package.json +17 -3
- package/src/actions.ts +23 -16
- package/src/ancestry.ts +74 -2
- package/src/anthropic-wire.ts +137 -0
- package/src/bytes.ts +42 -0
- package/src/changeset.ts +5 -5
- package/src/clickhouse/index.ts +6 -0
- package/src/clickhouse/sql.ts +3059 -0
- package/src/clickhouse/types.ts +44 -0
- package/src/clickhouse/values.ts +697 -0
- package/src/client-bundle.ts +2 -3
- package/src/cors.ts +34 -0
- package/src/derived-core.ts +1013 -146
- package/src/derived-real.ts +434 -0
- package/src/derived.ts +73 -3
- package/src/emit.ts +1 -2
- package/src/events.ts +449 -0
- package/src/executor.ts +24 -2
- package/src/file-response.ts +27 -0
- package/src/fork.ts +3 -2
- package/src/git/history.ts +19 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +67 -0
- package/src/git/objects.ts +3 -5
- package/src/git/smart-http.ts +56 -6
- package/src/graphql-wire.ts +106 -0
- package/src/grpc-wire.ts +159 -0
- package/src/h2.ts +627 -0
- package/src/head.ts +132 -41
- package/src/history.ts +6 -6
- package/src/hpack.json +1 -0
- package/src/index.ts +82 -329
- package/src/log.ts +27 -18
- package/src/machines.ts +151 -0
- package/src/managed-database.ts +299 -0
- package/src/multipart.ts +51 -0
- package/src/observe.ts +31 -15
- package/src/openai-wire.ts +371 -0
- package/src/pack-assets.ts +15 -11
- package/src/pack-fetch.ts +458 -0
- package/src/pack-paths.ts +72 -0
- package/src/packRegistry.ts +79 -167
- package/src/people.ts +31 -0
- package/src/placeholder-image.ts +88 -0
- package/src/protobuf.ts +251 -0
- package/src/redis/engine.ts +1 -1
- package/src/request-scope.ts +8 -4
- package/src/resource-blob.ts +13 -0
- package/src/runtime.ts +344 -0
- package/src/s3/wire.ts +172 -0
- package/src/scenario.ts +4 -0
- package/src/schema-sample.ts +24 -0
- package/src/sealed-box.ts +182 -0
- package/src/serve-http.ts +31 -3
- package/src/serve.ts +58 -14
- package/src/signing.ts +231 -0
- package/src/sigv4.ts +158 -0
- package/src/smtp.ts +76 -0
- package/src/sockets.ts +140 -0
- package/src/state-system.ts +2 -2
- package/src/storage.ts +3 -3
- package/src/trace-context.ts +1 -1
- package/src/twin-fetch.ts +0 -20
- package/src/vendor-call.ts +41 -0
- package/src/world-store.ts +1 -1
- package/vendor-hosts.cjs +36 -125
- package/vendor-hosts.d.cts +8 -0
- package/dist/src/mirror-shell.d.ts +0 -2
- package/dist/src/mirror-shell.js +0 -13
- package/dist/src/v1-removed.d.ts +0 -159
- package/dist/src/v1-removed.js +0 -124
- package/src/mirror-shell.ts +0 -15
- package/src/v1-removed.ts +0 -172
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// THE REAL-SYSTEM ADAPTERS, DERIVED (docs/contributing/architecture.md, "The real-system adapters"): a pack's perform,
|
|
2
|
+
// refresh and ingest made by the kernel from its manifest and its generated surface, so no pack writes adapter code.
|
|
3
|
+
// They run over the kernel executor (`RemoteExecute`), which applies the sealed credential; the credential never
|
|
4
|
+
// reaches here, only its keyed fingerprint (`PerformContext.credential`, for the idempotency key).
|
|
5
|
+
import type { TwinAction } from './actions.ts';
|
|
6
|
+
import { aliasesFrom, parentEntries, readParentTreeMap, readTree } from './log.ts';
|
|
7
|
+
import { storedType, type DerivedManifest, type IngestDecl, type ResourceDecl, type VendorManifest } from './derived-core.ts';
|
|
8
|
+
import type { DerivedOperation, DerivedSurface } from './derived.ts';
|
|
9
|
+
import { verifyEvent, type EventScheme } from './events.ts';
|
|
10
|
+
import { hashFieldValue } from './hash.ts';
|
|
11
|
+
import { readResourceBlob } from './resource-blob.ts';
|
|
12
|
+
import { RefusedWriteError, type PerformAction, type PerformContext, type PushOutcome } from './head.ts';
|
|
13
|
+
import { observeResources, type Completed, type ObservedResource } from './observe.ts';
|
|
14
|
+
import type { RemoteExecute, RemoteExecuteResponse } from './remote-execute.ts';
|
|
15
|
+
import type { StateSystemAdapters } from './state-system.ts';
|
|
16
|
+
|
|
17
|
+
type Unit = { manifest: DerivedManifest; surface: DerivedSurface; lane?: string };
|
|
18
|
+
type Row = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
/** A JSON path's value: `a.b`, `a[0].b`, `$body.a` (the leading `$body.` read as the root). */
|
|
21
|
+
function at(value: unknown, path: string): unknown {
|
|
22
|
+
let cur = value;
|
|
23
|
+
for (const part of path.replace(/^\$body\.?/, '').split(/\.|\[(\d+)\]/).filter((p) => p !== undefined && p !== '')) {
|
|
24
|
+
if (cur === null || typeof cur !== 'object') return undefined;
|
|
25
|
+
cur = (cur as Row)[part];
|
|
26
|
+
}
|
|
27
|
+
return cur;
|
|
28
|
+
}
|
|
29
|
+
const object = (v: unknown): Row | undefined => (v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Row) : undefined);
|
|
30
|
+
const parse = (r: RemoteExecuteResponse): unknown => { try { return r.body ? JSON.parse(r.body) : undefined; } catch { return r.body; } };
|
|
31
|
+
|
|
32
|
+
/** The manifest's resource a surface schema answers (the operation's `answers.resource` is the spec's schema name). */
|
|
33
|
+
function resourceOf(m: DerivedManifest, schema: string | undefined): string | undefined {
|
|
34
|
+
if (!schema) return undefined;
|
|
35
|
+
if (m.resources[schema]) return schema;
|
|
36
|
+
const lower = schema.toLowerCase();
|
|
37
|
+
return Object.keys(m.resources).find((r) => r.toLowerCase() === lower || storedType(m, r).toLowerCase() === lower);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The error template's fixed leaves (Slack's `ok: false`): an answer holding them all is the vendor's refusal, whatever
|
|
41
|
+
* its status (a vendor that answers its errors 200). */
|
|
42
|
+
function refusalShape(m: DerivedManifest): Array<[string, unknown]> {
|
|
43
|
+
const out: Array<[string, unknown]> = [];
|
|
44
|
+
const walk = (v: unknown, path: string): void => {
|
|
45
|
+
if (typeof v === 'string' && /\{\w+\}/.test(v)) return;
|
|
46
|
+
const o = object(v);
|
|
47
|
+
if (o) { for (const [k, x] of Object.entries(o)) walk(x, path ? `${path}.${k}` : k); return; }
|
|
48
|
+
if (path) out.push([path, v]);
|
|
49
|
+
};
|
|
50
|
+
walk(m.error, '');
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** The words of a refusal, at the template's `{message}` (else `{code}`) place. */
|
|
54
|
+
function refusalWords(m: DerivedManifest, body: unknown): string {
|
|
55
|
+
let found: string | undefined;
|
|
56
|
+
const walk = (v: unknown, path: string): void => {
|
|
57
|
+
if (typeof v === 'string' && (v.includes('{message}') || (found === undefined && v.includes('{code}')))) { const said = at(body, path); if (said !== undefined) found = String(said); return; }
|
|
58
|
+
const o = object(v);
|
|
59
|
+
if (o) for (const [k, x] of Object.entries(o)) walk(x, path ? `${path}.${k}` : k);
|
|
60
|
+
};
|
|
61
|
+
walk(m.error, '');
|
|
62
|
+
return found ?? (typeof body === 'string' ? body : JSON.stringify(body ?? null)).slice(0, 300);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every string in a body that names a subject the vendor knows by another id, as the vendor names it. */
|
|
66
|
+
function resolvedDeep(value: unknown, resolve: (v: string) => string): unknown {
|
|
67
|
+
if (typeof value === 'string') return resolve(value);
|
|
68
|
+
if (Array.isArray(value)) return value.map((v) => resolvedDeep(v, resolve));
|
|
69
|
+
const o = object(value);
|
|
70
|
+
return o ? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolvedDeep(v, resolve)])) : value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store. */
|
|
74
|
+
async function multipart(body: unknown, files: Row[], ctx: PerformContext): Promise<{ body: Uint8Array; type: string }> {
|
|
75
|
+
const boundary = `----volter${hashFieldValue({ body, files }).slice(0, 24)}`;
|
|
76
|
+
const chunks: Uint8Array[] = [];
|
|
77
|
+
const text = (t: string): void => { chunks.push(new TextEncoder().encode(t)); };
|
|
78
|
+
for (const [k, v] of Object.entries(object(body) ?? {})) if (v !== undefined && v !== null) text(`--${boundary}\r\ncontent-disposition: form-data; name="${k}"\r\n\r\n${typeof v === 'object' ? JSON.stringify(v) : String(v)}\r\n`);
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
const bytes = await readResourceBlob(ctx.service ?? '', String(f.blob), ctx.root);
|
|
81
|
+
if (!bytes) throw new Error(`the recorded file ${String(f.name)} is not in the World's blob store (${String(f.blob)})`);
|
|
82
|
+
text(`--${boundary}\r\ncontent-disposition: form-data; name="${String(f.field)}"; filename="${String(f.name)}"\r\n${f.type ? `content-type: ${String(f.type)}\r\n` : ''}\r\n`);
|
|
83
|
+
chunks.push(bytes);
|
|
84
|
+
text('\r\n');
|
|
85
|
+
}
|
|
86
|
+
text(`--${boundary}--\r\n`);
|
|
87
|
+
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
|
88
|
+
let at = 0;
|
|
89
|
+
for (const c of chunks) { out.set(c, at); at += c.length; }
|
|
90
|
+
return { body: out, type: `multipart/form-data; boundary=${boundary}` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The body as the operation's encoding sends it. */
|
|
94
|
+
function encode(op: DerivedOperation, body: unknown): { body?: string; type?: string } {
|
|
95
|
+
if (body === undefined || op.bodyEncoding === 'none') return {};
|
|
96
|
+
if (op.bodyEncoding === 'form') {
|
|
97
|
+
const form = new URLSearchParams();
|
|
98
|
+
for (const [k, v] of Object.entries(object(body) ?? {})) if (v !== undefined && v !== null) form.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
99
|
+
return { body: form.toString(), type: 'application/x-www-form-urlencoded' };
|
|
100
|
+
}
|
|
101
|
+
return { body: JSON.stringify(body), type: 'application/json' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The unit an entry was written through: its recorded lane, else the one whose surface names its operation. */
|
|
105
|
+
function unitOf(units: Unit[], input: Row): Unit | undefined {
|
|
106
|
+
const id = input.operationId;
|
|
107
|
+
return (typeof input.lane === 'string' ? units.find((u) => u.lane === input.lane) : undefined)
|
|
108
|
+
?? units.find((u) => u.surface.operations.some((o) => o.id === id))
|
|
109
|
+
?? (input.graphql ? units[0] : undefined);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function performOf(units: Unit[]): PerformAction {
|
|
113
|
+
return async (execute: RemoteExecute, group: TwinAction[], ctx: PerformContext): Promise<PushOutcome> => {
|
|
114
|
+
const first = group[0]!;
|
|
115
|
+
const input = (first.input ?? {}) as Row;
|
|
116
|
+
const unit = unitOf(units, input);
|
|
117
|
+
if (!unit) throw new RefusedWriteError('vendor', `the write records no operation of the vendor's surface (${String(input.operationId ?? 'none')})`, first.id);
|
|
118
|
+
const { manifest: m, surface } = unit;
|
|
119
|
+
// ids the World minted are sent as the vendor's: every stored type's adopted aliases, tried in turn
|
|
120
|
+
const types = [...new Set(Object.keys(m.resources).map((r) => storedType(m, r)))];
|
|
121
|
+
const resolve = (v: string): string => { for (const t of types) { const r = ctx.resolve(t, v); if (r !== v) return r; } return v; };
|
|
122
|
+
let method: string; let path: string; let body: unknown; let op: DerivedOperation | undefined;
|
|
123
|
+
if (input.graphql) {
|
|
124
|
+
const gql = (m as { graphql?: { paths: string[] } }).graphql;
|
|
125
|
+
method = 'POST'; path = gql?.paths[0] ?? '/graphql'; body = resolvedDeep(input.graphql, resolve);
|
|
126
|
+
} else {
|
|
127
|
+
op = surface.operations.find((o) => o.id === input.operationId)!;
|
|
128
|
+
method = op.method.toUpperCase();
|
|
129
|
+
const params = { ...((object(input.path) ?? {}) as Record<string, string>) };
|
|
130
|
+
const queried = { ...((object(input.query) ?? {}) as Record<string, string | string[]>) };
|
|
131
|
+
const sentBody = input.body === undefined ? undefined : object(input.body) ? { ...object(input.body)! } : input.body;
|
|
132
|
+
// a subject named by its key's parts (Slack's message: its `channel` and its `ts`): the parts the request sends,
|
|
133
|
+
// resolved as one id and sent as the vendor's parts
|
|
134
|
+
for (const [resource, decl] of Object.entries(m.resources)) {
|
|
135
|
+
if (!decl.key) continue;
|
|
136
|
+
const names = [...decl.key.matchAll(/\{([^}]+)\}/g)].map((x) => x[1]!);
|
|
137
|
+
const where = (n: string): Record<string, unknown> | undefined => [params, queried, object(sentBody)].find((o) => o !== undefined && typeof o[n] === 'string') as Record<string, unknown> | undefined;
|
|
138
|
+
if (!names.every((n) => where(n))) continue;
|
|
139
|
+
const local = decl.key.replace(/\{([^}]+)\}/g, (_, n: string) => String(where(n)![n]));
|
|
140
|
+
const vendorId = ctx.resolve(storedType(m, resource), local);
|
|
141
|
+
if (vendorId === local) continue;
|
|
142
|
+
const parts = new RegExp(`^${decl.key.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '(.+?)')}$`).exec(vendorId);
|
|
143
|
+
if (parts) names.forEach((n, i) => { where(n)![n] = parts[i + 1]!; });
|
|
144
|
+
}
|
|
145
|
+
if (op.resource && ctx.service !== undefined) vendorFieldParams(m, op.resource, params, ctx);
|
|
146
|
+
path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, name: string) => encodeURIComponent(resolve(String(params[name] ?? ''))));
|
|
147
|
+
const query = new URLSearchParams(Object.entries(queried).flatMap(([k, v]) => (Array.isArray(v) ? v : [v]).map((x): [string, string] => [k, resolve(String(x))])));
|
|
148
|
+
if ([...query].length) path += `?${query}`;
|
|
149
|
+
body = sentBody === undefined ? undefined : resolvedDeep(sentBody, resolve);
|
|
150
|
+
}
|
|
151
|
+
const files = Array.isArray(input.files) ? (input.files as Row[]) : [];
|
|
152
|
+
// a body recorded as it came (not JSON) goes as it came
|
|
153
|
+
const asCame = object(input.body);
|
|
154
|
+
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;
|
|
155
|
+
const sent: { body?: string | Uint8Array; type?: string } = raw ? { ...raw, ...(typeof input.contentType === 'string' ? { type: input.contentType } : {}) }
|
|
156
|
+
: files.length ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
|
|
157
|
+
const headers: Record<string, string> = { ...((object(input.headers) ?? {}) as Record<string, string>), ...(sent.type ? { 'content-type': sent.type } : {}) };
|
|
158
|
+
// a retried deploy makes nothing twice, and two Worlds deploying into one account never collide
|
|
159
|
+
if (m.idempotency) headers[m.idempotency.header] = hashFieldValue({ credential: ctx.credential ?? '', entry: first.id });
|
|
160
|
+
const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}) });
|
|
161
|
+
const parsed = parse(answer);
|
|
162
|
+
const shape = refusalShape(m);
|
|
163
|
+
const refused = answer.status >= 400 || (shape.length > 0 && shape.every(([p, v]) => at(parsed, p) === v));
|
|
164
|
+
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)` : ''}`);
|
|
165
|
+
if (refused) throw new RefusedWriteError('vendor', `${answer.status >= 400 ? answer.status : 400}: ${refusalWords(m, parsed)}`, first.id);
|
|
166
|
+
// the subject the answer is: under the operation's key, inside the success envelope when the vendor has one
|
|
167
|
+
const key = op?.answers?.key;
|
|
168
|
+
const obj = object(key ? at(parsed, key) : parsed) ?? object(parsed) ?? {};
|
|
169
|
+
// the answered resource: the spec's schema name, else the envelope key the answer is under (Slack's `channel`,
|
|
170
|
+
// `message`), when the manifest has a resource of that name
|
|
171
|
+
const answerKey = op?.answers?.key;
|
|
172
|
+
const resource = resourceOf(m, op?.answers?.resource) ?? (answerKey && m.resources[answerKey] ? answerKey : undefined);
|
|
173
|
+
const decl: ResourceDecl | undefined = resource ? m.resources[resource] : undefined;
|
|
174
|
+
const idOf = (o: Row | undefined, d: ResourceDecl | undefined): string | undefined => { const v = o?.[d?.idAs ?? 'id']; return typeof v === 'string' ? v : undefined; };
|
|
175
|
+
// the group's entry the answer is: of the answered resource's stored type (a cascade writes children first), else
|
|
176
|
+
// the last; a delete answers its subject's own vendor id
|
|
177
|
+
const answered = resource ? [...group].reverse().find((e) => e.subject.type === storedType(m, resource)) : undefined;
|
|
178
|
+
const primary = answered ?? group.at(-1)!;
|
|
179
|
+
// an answer about a subject no entry of the group is (Slack's conversations.join answers the channel; the group is
|
|
180
|
+
// the membership and its channel_join message) gives no entry its id or its fields: each settles deployed with no
|
|
181
|
+
// vendor id, and refresh adopts it ("Perform", step 2)
|
|
182
|
+
if (resource && !answered) return { externalId: '', actionId: primary.id };
|
|
183
|
+
const deleted = primary.fields?.deleted === true;
|
|
184
|
+
// a keyed subject's id is its key filled from the answer (its envelope's fields, Slack's `channel` and `ts`, among them)
|
|
185
|
+
const keyed = decl?.key ? decl : Object.entries(m.resources).find(([r]) => storedType(m, r) === primary.subject.type)?.[1];
|
|
186
|
+
const externalId = deleted ? ctx.resolve(primary.subject.type, primary.subject.id)
|
|
187
|
+
: keyed?.key ? storedIdOf(keyed, { ...(object(parsed) ?? {}), ...obj }) ?? '' : idOf(obj, decl) ?? '';
|
|
188
|
+
const also: Array<{ actionId: string; externalId: string }> = [];
|
|
189
|
+
for (const [companion, where] of Object.entries(decl?.companions ?? {})) {
|
|
190
|
+
const id = at(parsed, where) ?? at(obj, where);
|
|
191
|
+
const entry = group.find((e) => e !== primary && e.subject.type === storedType(m, companion));
|
|
192
|
+
if (entry && typeof id === 'string') also.push({ actionId: entry.id, externalId: id });
|
|
193
|
+
}
|
|
194
|
+
return { externalId, actionId: primary.id, data: obj, ...(also.length ? { also } : {}) };
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Path parameters that name a subject by a field rather than its id, sent as the vendor's value of that field, read
|
|
199
|
+
* from the adopted landed copy ("Perform", step 1): a parent found by `parent.where` (a repository addressed as
|
|
200
|
+
* `{owner}/{repo}`), and a subject named by the number it counts per parent (`number`, an issue's `issue_number`).
|
|
201
|
+
* A subject the vendor has not given another id keeps the World's values, which are then the vendor's. */
|
|
202
|
+
function vendorFieldParams(m: DerivedManifest, resource: string, params: Record<string, string>, ctx: PerformContext): void {
|
|
203
|
+
const decl = m.resources[resource];
|
|
204
|
+
if (!decl || (!decl.number && !decl.parent?.where)) return;
|
|
205
|
+
const tree = readTree(ctx.service!, ctx.root) as Row[];
|
|
206
|
+
const landed = readParentTreeMap(ctx.service!, ctx.root);
|
|
207
|
+
const vendorCopy = (type: string, local: Row | undefined): Row | undefined => {
|
|
208
|
+
if (!local) return undefined;
|
|
209
|
+
const id = ctx.resolve(type, String(local.id));
|
|
210
|
+
return id === String(local.id) ? undefined : (landed.get(`${type}:${id}`)?.fields as Row | undefined);
|
|
211
|
+
};
|
|
212
|
+
const fill = (template: string): string => template.replace(/\{([^}]+)\}/g, (_, n: string) => params[n] ?? '');
|
|
213
|
+
// the parent as the World stores it, read before any parameter becomes the vendor's
|
|
214
|
+
const parent = decl.parent;
|
|
215
|
+
const parentType = parent ? storedType(m, parent.resource) : undefined;
|
|
216
|
+
const where = Object.entries(parent?.where ?? {}).map(([field, template]) => [field, template, fill(template)] as const);
|
|
217
|
+
const localParent = where.length ? tree.find((r) => r.type === parentType && where.every(([f, , v]) => String(r[f]) === v)) : undefined;
|
|
218
|
+
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;
|
|
219
|
+
if (decl.number && !decl.key) {
|
|
220
|
+
const type = storedType(m, resource);
|
|
221
|
+
const name = Object.keys(params).at(-1);
|
|
222
|
+
const field = decl.number.field;
|
|
223
|
+
const local = name === undefined ? undefined : tree.find((r) => r.type === type && String(r[field]) === params[name] && (!parent || String(r[parent.field]) === parentId));
|
|
224
|
+
const copy = vendorCopy(type, local);
|
|
225
|
+
if (name !== undefined && copy?.[field] !== undefined) params[name] = String(copy[field]);
|
|
226
|
+
}
|
|
227
|
+
const parentCopy = vendorCopy(parentType ?? '', localParent);
|
|
228
|
+
if (parentCopy) for (const [field, template] of where) { const one = /^\{([^}]+)\}$/.exec(template); if (one && parentCopy[field] !== undefined) params[one[1]!] = String(parentCopy[field]); }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The items of a list's answer: under the operation's key, else the answer when it is a list. */
|
|
232
|
+
function itemsOf(op: DerivedOperation, parsed: unknown): Row[] {
|
|
233
|
+
const v = op.answers?.key ? at(parsed, op.answers.key) : parsed;
|
|
234
|
+
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[]) : [];
|
|
235
|
+
return list.map(object).filter((x): x is Row => x !== undefined);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapters['refresh']> {
|
|
239
|
+
return async (execute, opts) => {
|
|
240
|
+
const observed: ObservedResource[] = [];
|
|
241
|
+
const complete: Completed[] = [];
|
|
242
|
+
for (const { manifest: m, surface } of units) {
|
|
243
|
+
// parents before children, however deep
|
|
244
|
+
const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
|
|
245
|
+
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; };
|
|
246
|
+
const order = scoped.sort(([a], [b]) => depth(a) - depth(b));
|
|
247
|
+
for (const [resource, decl] of order) {
|
|
248
|
+
const scope = decl.refresh!;
|
|
249
|
+
const opId = 'list' in scope ? scope.list : 'get' in scope ? scope.get : '';
|
|
250
|
+
const op = surface.operations.find((o) => o.id === opId);
|
|
251
|
+
if (!op) throw new Error(`${m.vendor}: ${resource}'s refresh names ${opId}, no operation of the surface`);
|
|
252
|
+
const type = storedType(m, resource);
|
|
253
|
+
const parents = decl.parent ? [...new Set(observed.filter((o) => o.type === storedType(m, decl.parent!.resource)).map((o) => o.id))] : [undefined];
|
|
254
|
+
let whole = 'list' in scope && scope.complete !== false;
|
|
255
|
+
for (const parent of parents) {
|
|
256
|
+
const limit = m.list?.limit && (op.query ?? []).some((q) => q.name === m.list!.limit.param) ? { [m.list.limit.param]: String(m.list.limit.max) } : {};
|
|
257
|
+
let page: Record<string, string> = { ...limit };
|
|
258
|
+
for (let taken = 0; ;) {
|
|
259
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n: string) => encodeURIComponent(n === decl.parent?.param ? String(parent) : ''));
|
|
260
|
+
// a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
|
|
261
|
+
const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) } : {};
|
|
262
|
+
const q = new URLSearchParams({ ...page, ...byQuery });
|
|
263
|
+
const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path });
|
|
264
|
+
if (answer.status === 401 || answer.status === 403) throw new Error(`${answer.status}: the vendor refused the root's credential; nothing folded`);
|
|
265
|
+
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
266
|
+
if (answer.status >= 400) throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
267
|
+
const parsed = parse(answer);
|
|
268
|
+
const items = 'get' in scope ? [object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {}]
|
|
269
|
+
: 'items' in scope && scope.items ? ((at(parsed, scope.items) as unknown[] | undefined) ?? []).map((x) => object(x)).filter((x): x is Row => x !== undefined) : itemsOf(op, parsed);
|
|
270
|
+
for (const item of items) {
|
|
271
|
+
const fields = { ...item, ...(decl.parent && parent !== undefined ? { [decl.parent.field]: parent } : {}) };
|
|
272
|
+
const id = storedIdOf(decl, fields);
|
|
273
|
+
if (id === undefined) continue;
|
|
274
|
+
observed.push({ type, id, fields: fields as ObservedResource['fields'] });
|
|
275
|
+
}
|
|
276
|
+
taken += items.length;
|
|
277
|
+
if ('get' in scope) break;
|
|
278
|
+
if (taken >= 10_000) { whole = false; break; }
|
|
279
|
+
// the next page by the manifest's list model. The list is whole only where its end shows: a cursor model
|
|
280
|
+
// gives no next cursor (a short page may still name one), or a page is empty or shorter than the limit
|
|
281
|
+
// asked; a list the twin cannot page on, or whose page size it does not know, is not known whole.
|
|
282
|
+
if (m.list?.cursor) {
|
|
283
|
+
const next = at(parsed, 'next_cursor') ?? at(parsed, 'response_metadata.next_cursor');
|
|
284
|
+
if (!next) break;
|
|
285
|
+
page = { ...page, [m.list.cursor.param]: String(next) };
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const size = Number(limit[m.list?.limit.param ?? ''] ?? 0);
|
|
289
|
+
if (!items.length || (size && items.length < size)) break;
|
|
290
|
+
if (!size) { whole = false; break; }
|
|
291
|
+
const last = items.at(-1)![decl.idAs ?? 'id'];
|
|
292
|
+
if (m.list?.after) page = { ...page, [m.list.after]: String(last) };
|
|
293
|
+
else if (m.list?.page) page = { ...page, [m.list.page.param]: String(Number(page[m.list.page.param] ?? 1) + 1) };
|
|
294
|
+
else if (m.list?.offset) page = { ...page, [m.list.offset.param]: String(taken) };
|
|
295
|
+
else { whole = false; break; }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// a type under a parent is whole only for the parents observed: what it held under another parent stands. A root
|
|
299
|
+
// scoped to one resource (`RootConfig.scope`, GitHub's `repos/o/r`) lists only what is under it, so a type with no
|
|
300
|
+
// parent is never the account's whole list there
|
|
301
|
+
if (whole && !decl.parent && !opts.scope) complete.push(type);
|
|
302
|
+
else if (whole && parents.length) complete.push({ type, field: decl.parent!.field, within: parents.map(String) });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
adopt(service, opts.root, units, observed);
|
|
306
|
+
return observeResources(service, observed, { ...(opts.root !== undefined ? { root: opts.root } : {}), complete });
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A vendor's subject's stored id: its key template filled from its fields (its parent's among them), else its id
|
|
311
|
+
* field. Undefined when the subject names none. */
|
|
312
|
+
function storedIdOf(decl: ResourceDecl, fields: Row): string | undefined {
|
|
313
|
+
if (decl.key) {
|
|
314
|
+
let missing = false;
|
|
315
|
+
const id = decl.key.replace(/\{([^}]+)\}/g, (_, n: string) => { const v = fields[n]; if (typeof v !== 'string' && typeof v !== 'number') missing = true; return String(v ?? ''); });
|
|
316
|
+
return missing ? undefined : id;
|
|
317
|
+
}
|
|
318
|
+
const id = fields[decl.idAs ?? 'id'];
|
|
319
|
+
return typeof id === 'string' || typeof id === 'number' ? String(id) : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Adoption ("Refresh"): an observation whose vendor id the World holds under no subject, matching a subject a deploy
|
|
323
|
+
* settled with no vendor id (the same type and parent, and equal on the resource's `alternateKeys`, else the only
|
|
324
|
+
* such subject under that parent), is that subject: it lands as its alias instead of a second subject beside it. */
|
|
325
|
+
function adopt(service: string, root: string | undefined, units: Unit[], observed: ObservedResource[]): void {
|
|
326
|
+
const entries = parentEntries(service, root);
|
|
327
|
+
const aliases = aliasesFrom(entries);
|
|
328
|
+
const tree = readParentTreeMap(service, root);
|
|
329
|
+
const pending = new Map<string, Array<{ id: string; fields: Record<string, unknown> }>>();
|
|
330
|
+
for (const e of entries) {
|
|
331
|
+
if (e.receipt?.status !== 'deployed' || e.receipt.externalId || e.landsId === undefined || e.aliasOf !== undefined) continue;
|
|
332
|
+
const key = `${e.subject.type}:${e.subject.id}`;
|
|
333
|
+
const held = tree.get(key);
|
|
334
|
+
if (aliases.has(key) || !held || held.fields.deleted === true) continue;
|
|
335
|
+
const list = pending.get(e.subject.type) ?? [];
|
|
336
|
+
if (!list.some((p) => p.id === e.subject.id)) list.push({ id: e.subject.id, fields: held.fields });
|
|
337
|
+
pending.set(e.subject.type, list);
|
|
338
|
+
}
|
|
339
|
+
if (!pending.size) return;
|
|
340
|
+
const declOf = (type: string): { m: DerivedManifest; decl: ResourceDecl } | undefined => {
|
|
341
|
+
for (const u of units) { const hit = Object.entries(u.manifest.resources).find(([r]) => storedType(u.manifest, r) === type); if (hit) return { m: u.manifest, decl: hit[1] }; }
|
|
342
|
+
return undefined;
|
|
343
|
+
};
|
|
344
|
+
for (const o of observed) {
|
|
345
|
+
const pool = pending.get(o.type);
|
|
346
|
+
if (!pool?.length || tree.has(`${o.type}:${o.id}`)) continue;
|
|
347
|
+
const { m, decl } = declOf(o.type) ?? {};
|
|
348
|
+
const parentField = decl?.parent?.field;
|
|
349
|
+
// a local child names its parent by the parent's local id, which the parent's adoption aliased to the vendor's
|
|
350
|
+
const parentType = decl?.parent && m ? storedType(m, decl.parent.resource) : undefined;
|
|
351
|
+
const resolved = (v: unknown): string => aliases.get(`${parentType}:${String(v)}`) ?? String(v);
|
|
352
|
+
const siblings = pool.filter((p) => parentField === undefined || resolved(p.fields[parentField]) === String(o.fields[parentField]));
|
|
353
|
+
const keys = decl?.alternateKeys ?? [];
|
|
354
|
+
const match = keys.length ? siblings.filter((p) => keys.every((k) => p.fields[k] !== undefined && p.fields[k] === o.fields[k])) : siblings.length === 1 ? siblings : [];
|
|
355
|
+
if (match.length !== 1) continue;
|
|
356
|
+
o.adopts = match[0]!.id;
|
|
357
|
+
pool.splice(pool.indexOf(match[0]!), 1);
|
|
358
|
+
// its children, observed after it, name it by the local id this adoption aliases
|
|
359
|
+
aliases.set(`${o.type}:${o.adopts}`, o.id);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The event's resource: the declared `types`, else the manifest's events read backwards, else `<resource>.created|updated|deleted`. */
|
|
364
|
+
function eventResource(m: DerivedManifest, decl: IngestDecl, type: string): { resource: string; deleted?: true } | undefined {
|
|
365
|
+
if (decl.types?.[type]) return decl.types[type];
|
|
366
|
+
for (const [operation, sent] of Object.entries(m.events?.types ?? {})) {
|
|
367
|
+
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 } : {}) }; }
|
|
368
|
+
}
|
|
369
|
+
const pattern = /^(.+)\.(created|updated|deleted)$/.exec(type);
|
|
370
|
+
const r = pattern ? resourceOf(m, pattern[1]) : undefined;
|
|
371
|
+
return r ? { resource: r, ...(pattern![2] === 'deleted' ? { deleted: true as const } : {}) } : undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A version older than the held one: numbers as numbers ("9" before "10"), timestamps as instants, else as text. */
|
|
375
|
+
function older(theirs: unknown, held: unknown): boolean {
|
|
376
|
+
if (theirs === undefined || theirs === null || held === undefined || held === null) return false;
|
|
377
|
+
const a = Number(theirs); const b = Number(held);
|
|
378
|
+
if (String(theirs).trim() !== '' && String(held).trim() !== '' && Number.isFinite(a) && Number.isFinite(b)) return a < b;
|
|
379
|
+
const ta = Date.parse(String(theirs)); const tb = Date.parse(String(held));
|
|
380
|
+
if (Number.isFinite(ta) && Number.isFinite(tb)) return ta < tb;
|
|
381
|
+
return String(theirs) < String(held);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function ingestOf(units: Unit[], decl: IngestDecl, service: string): NonNullable<StateSystemAdapters['ingest']> {
|
|
385
|
+
return async (request, ctx) => {
|
|
386
|
+
const body = await request.text();
|
|
387
|
+
const headers = Object.fromEntries(request.headers);
|
|
388
|
+
const now = Math.floor(Date.now() / 1000);
|
|
389
|
+
const schemes = (Array.isArray(decl.scheme) ? decl.scheme : [decl.scheme]) as EventScheme[];
|
|
390
|
+
if (!ctx.secret || !schemes.some((s) => verifyEvent(s, ctx.secret!, body, headers, { now, tolerance: 300 }))) return Response.json({ error: 'invalid signature' }, { status: 401 });
|
|
391
|
+
let event: unknown;
|
|
392
|
+
try { event = JSON.parse(body); } catch { return Response.json({ error: 'the event is not JSON' }, { status: 400 }); }
|
|
393
|
+
if (decl.handshake && Object.entries(decl.handshake.when).every(([k, v]) => at(event, k) === v)) {
|
|
394
|
+
const answer = decl.handshake.answer.startsWith('$body.') ? at(event, decl.handshake.answer) : decl.handshake.answer;
|
|
395
|
+
return new Response(typeof answer === 'string' ? answer : JSON.stringify(answer), { status: 200 });
|
|
396
|
+
}
|
|
397
|
+
const named = [decl.type.header ? request.headers.get(decl.type.header) : decl.type.body ? at(event, decl.type.body) : undefined, decl.type.action ? at(event, decl.type.action) : undefined].filter((x) => typeof x === 'string' && x !== '');
|
|
398
|
+
const type = named.join('.');
|
|
399
|
+
for (const { manifest: m } of units) {
|
|
400
|
+
const found = eventResource(m, decl, type);
|
|
401
|
+
if (!found) continue;
|
|
402
|
+
const item = object(at(event, decl.object));
|
|
403
|
+
const d = m.resources[found.resource]!;
|
|
404
|
+
const id = item ? storedIdOf(d, item) : undefined;
|
|
405
|
+
if (!item || id === undefined) break;
|
|
406
|
+
const stored = storedType(m, found.resource);
|
|
407
|
+
// an event older than the root's copy folds nothing
|
|
408
|
+
const held = d.version ? (readTree(service, ctx.root) as Row[]).find((r) => r.type === stored && r.id === String(id)) : undefined;
|
|
409
|
+
if (held && d.version && older(item[d.version], held[d.version])) break;
|
|
410
|
+
observeResources(service, [{ type: stored, id: String(id), fields: item as ObservedResource['fields'], ...(found.deleted ? { deleted: true } : {}) }], ctx.root !== undefined ? { root: ctx.root } : {});
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
return Response.json({ received: true });
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function adaptersOf(units: Unit[], service: string, ingest: IngestDecl | undefined): StateSystemAdapters {
|
|
418
|
+
return { perform: performOf(units), refresh: refreshOf(units, service), ...(ingest ? { ingest: ingestOf(units, ingest, service) } : {}) };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** A derived pack's adapters, from its manifest and its generated surface. */
|
|
422
|
+
export function deriveStateSystem(manifest: DerivedManifest, surface: DerivedSurface, lanes: Record<string, { manifest: DerivedManifest; surface: DerivedSurface }> = {}): StateSystemAdapters {
|
|
423
|
+
// a pack with an API of its own and lanes beside it (Clerk's Backend API and its Frontend API, `fapi`): one state
|
|
424
|
+
// system over its surface and each lane's
|
|
425
|
+
const units = [{ manifest, surface }, ...Object.entries(lanes).map(([lane, u]) => ({ ...u, lane }))];
|
|
426
|
+
return adaptersOf(units, manifest.service, manifest.ingest ?? units.find((u) => u.manifest.ingest)?.manifest.ingest);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** A vendor of lanes' adapters: one state system over every lane's surface (the lanes keep the vendor's one store). */
|
|
430
|
+
export function deriveVendorStateSystem(vendor: VendorManifest, lanes: Record<string, { manifest: DerivedManifest; surface: DerivedSurface }>): StateSystemAdapters {
|
|
431
|
+
const units = Object.entries(lanes).map(([lane, u]) => ({ ...u, lane }));
|
|
432
|
+
const service = units[0]?.manifest.service ?? vendor.vendor;
|
|
433
|
+
return adaptersOf(units, service, vendor.ingest ?? units.find((u) => u.manifest.ingest)?.manifest.ingest);
|
|
434
|
+
}
|
package/src/derived.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// existing fetch, which keeps serving everything not yet moved (and every route outside the spec, such
|
|
6
6
|
// as the `/twin` door). `owners` says, per operation, which of the two serves it: the count a pack's
|
|
7
7
|
// move is measured by. Workerd-clean: no fs, no clock, nothing at import.
|
|
8
|
+
import type { WebSocketUpgrade } from './serve-http.ts';
|
|
8
9
|
import { runWithRequestTrace } from './trace-context.ts';
|
|
9
10
|
|
|
10
11
|
/** The slice of a generated surface the dispatch reads. */
|
|
@@ -20,12 +21,24 @@ export type DerivedOperation = {
|
|
|
20
21
|
headers?: Record<string, string | null>;
|
|
21
22
|
/** the status the vendor answers success with (201 for a create, 204 for an empty answer) */
|
|
22
23
|
successStatus?: number;
|
|
24
|
+
/** how the spec says the request body is encoded (derive-pack: json, form, multipart, none) */
|
|
25
|
+
bodyEncoding?: string;
|
|
26
|
+
/** the success is documented with no content (a setting's update answered 200 and nothing else) */
|
|
27
|
+
emptySuccess?: boolean;
|
|
23
28
|
/** a form-encoded operation's number and boolean fields, by bracket path (`items.[].quantity`, `metadata.*`) */
|
|
24
29
|
scalars?: Readonly<Record<string, string | undefined>>;
|
|
25
30
|
/** what the success answer is: a resource, a list of them, and the envelope key holding it */
|
|
26
31
|
answers?: { resource: string; list: boolean; key?: string };
|
|
27
32
|
/** the path its own server puts before it, where the operation names a server other than the surface's */
|
|
28
33
|
basePath?: string;
|
|
34
|
+
/** the query's declared parameters (a strict body checks what is not one of them) */
|
|
35
|
+
query?: ReadonlyArray<{ name: string; type: string; required?: boolean }>;
|
|
36
|
+
/** the credential scopes any one of which lets a caller make the call, and the security schemes it is made with
|
|
37
|
+
* (`anonymous` where it may also go without; absent when it takes none), from the spec's security */
|
|
38
|
+
scopes?: ReadonlyArray<string>;
|
|
39
|
+
credentials?: ReadonlyArray<string>;
|
|
40
|
+
/** the body's declared fields: name, type, and the numeric bounds the spec states (a strict body, `ctx.fields`) */
|
|
41
|
+
body?: ReadonlyArray<{ name: string; type: string; required?: boolean; minimum?: number; maximum?: number }>;
|
|
29
42
|
};
|
|
30
43
|
/** `basePath` is what the vendor's server URL puts before every operation path; `spanning` names the
|
|
31
44
|
* path parameters whose values may hold slashes (a git ref, a file path), which no spec says. */
|
|
@@ -33,6 +46,52 @@ export type DerivedSurface = { version: string; basePath?: string; spanning?: Re
|
|
|
33
46
|
|
|
34
47
|
/** What a semantics handler receives: the request, the operation it matched, and its path parameters. */
|
|
35
48
|
export type DerivedCall = { request: Request; operation: DerivedOperation; params: Record<string, string> };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A request's body as text, read once per request: the dispatch reads it and tags the request with it, and every clone
|
|
52
|
+
* made from a tagged request carries the tag, so the malformed-JSON guard, the idempotency signature, the handler's
|
|
53
|
+
* context and the parameters all take the one string (a publish's body is megabytes, and each read made a copy).
|
|
54
|
+
*/
|
|
55
|
+
const BODY_TEXT = Symbol('volter.bodyText');
|
|
56
|
+
type Tagged = Request & { [BODY_TEXT]?: string };
|
|
57
|
+
function tagBody(request: Request, text: string, key: object = {}): Request {
|
|
58
|
+
const tagged = request as Tagged;
|
|
59
|
+
tagged[BODY_TEXT] = text;
|
|
60
|
+
BODY_PARSE_KEY.set(request, key);
|
|
61
|
+
const clone = request.clone.bind(request);
|
|
62
|
+
tagged.clone = () => tagBody(clone(), text, key);
|
|
63
|
+
return request;
|
|
64
|
+
}
|
|
65
|
+
export async function bodyTextOf(request: Request): Promise<string> {
|
|
66
|
+
const held = (request as Tagged)[BODY_TEXT];
|
|
67
|
+
return held !== undefined ? held : request.text();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The body parsed by the malformed-JSON guard, which only checks it: handed once to the handler's context (that parse
|
|
71
|
+
* would be the same), and never shared further, so no reader sees another's changes. */
|
|
72
|
+
const BODY_PARSED = new WeakMap<object, { value: unknown }>();
|
|
73
|
+
/** The body's text, as JSON, or undefined when it is not JSON; the parse is kept for the first taker. */
|
|
74
|
+
export async function checkJsonBody(request: Request): Promise<boolean> {
|
|
75
|
+
const text = await bodyTextOf(request);
|
|
76
|
+
if (!text) return true;
|
|
77
|
+
// a body labelled otherwise and not shaped as JSON is the form it is labelled (an OAuth token request), not bad JSON
|
|
78
|
+
if (!(request.headers.get('content-type') ?? '').includes('json') && !/^\s*[[{]/.test(text)) return true;
|
|
79
|
+
try {
|
|
80
|
+
const value = JSON.parse(text);
|
|
81
|
+
const tag = (request as Tagged)[BODY_TEXT] !== undefined ? BODY_PARSE_KEY.get(request) : undefined;
|
|
82
|
+
if (tag) BODY_PARSED.set(tag, { value });
|
|
83
|
+
return true;
|
|
84
|
+
} catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
/** The guard's parse of this request's body, taken once (a clone of a tagged request shares its key). */
|
|
87
|
+
export function takeParsedBody(request: Request): { value: unknown } | undefined {
|
|
88
|
+
const tag = BODY_PARSE_KEY.get(request);
|
|
89
|
+
const held = tag ? BODY_PARSED.get(tag) : undefined;
|
|
90
|
+
if (tag && held) BODY_PARSED.delete(tag);
|
|
91
|
+
return held;
|
|
92
|
+
}
|
|
93
|
+
/** One key per request as the dispatch received it, shared by every clone made from it. */
|
|
94
|
+
const BODY_PARSE_KEY = new WeakMap<Request, object>();
|
|
36
95
|
export type DerivedHandler = (call: DerivedCall) => Promise<Response>;
|
|
37
96
|
|
|
38
97
|
/** What the derived core made of an operation: an answer, or why it cannot model it. */
|
|
@@ -50,7 +109,7 @@ export type DerivedFetchOptions = {
|
|
|
50
109
|
* checks, idempotent replay). */
|
|
51
110
|
around?: (call: DerivedCall, next: () => Promise<Response>) => Promise<Response>;
|
|
52
111
|
/** The vendor's answer for an operation nothing models: the pack's gap. */
|
|
53
|
-
gap: (request: Request, operation: DerivedOperation | undefined, reason: string) => Response
|
|
112
|
+
gap: (request: Request, operation: DerivedOperation | undefined, reason: string) => Response | Promise<Response>;
|
|
54
113
|
};
|
|
55
114
|
|
|
56
115
|
class Unmodeled extends Error {}
|
|
@@ -80,7 +139,9 @@ function compile(operation: DerivedOperation, basePath: string, spanning: Readon
|
|
|
80
139
|
}).join('');
|
|
81
140
|
})
|
|
82
141
|
.join('/');
|
|
83
|
-
|
|
142
|
+
// a path the spec writes with its trailing slash (Django's `/persons/`) is the same path without it, as a router that
|
|
143
|
+
// takes both answers it (PostHog's `trailing_slash = r"/?"`)
|
|
144
|
+
return { operation, pattern: new RegExp(`^${body.replace(/\/$/, '')}/?$`), names, literals };
|
|
84
145
|
}
|
|
85
146
|
|
|
86
147
|
const compiled = new WeakMap<DerivedSurface, Map<string, Route[]>>(); // cache: compiled routes per surface; a surface is immutable generated data
|
|
@@ -126,10 +187,15 @@ export function matchOperation(routes: Map<string, Route[]>, method: string, pat
|
|
|
126
187
|
|
|
127
188
|
export type DerivedOwner = 'handler' | 'core' | 'gap';
|
|
128
189
|
|
|
190
|
+
/** The header the kernel carries a path prefix's named groups in, from routing to the call (never a client's). */
|
|
191
|
+
export const PREFIX_PARAMS_HEADER = 'x-volter-path-prefix-params';
|
|
192
|
+
|
|
129
193
|
export type DerivedFetch = ((request: Request) => Promise<Response>) & {
|
|
130
194
|
/** Per operationId, who serves it: a semantics handler, the derived core, the pack's existing
|
|
131
195
|
* fetch while it moves, or nothing (the vendor's gap). */
|
|
132
196
|
owners(): Record<string, DerivedOwner>;
|
|
197
|
+
/** The vendor's sockets (sockets.ts), for the serve seam to upgrade to, when its manifest declares any. */
|
|
198
|
+
upgrade?: WebSocketUpgrade;
|
|
133
199
|
};
|
|
134
200
|
|
|
135
201
|
export function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch {
|
|
@@ -148,7 +214,11 @@ export function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch {
|
|
|
148
214
|
? [...routes.keys()].map((m) => matchOperation(routes, m, url.pathname, url.searchParams, request.headers)).find(Boolean)
|
|
149
215
|
: matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers);
|
|
150
216
|
if (!matched) return fallback(request, undefined, 'no operation at this path');
|
|
151
|
-
|
|
217
|
+
// the body read once, as text, for everything the dispatch does with it (bodyTextOf)
|
|
218
|
+
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body && !(request.headers.get('content-type') ?? '').includes('multipart')) tagBody(request, await request.clone().text());
|
|
219
|
+
// the path prefix's named groups (pack-fetch.ts, `pathPrefix`) are the call's parameters too
|
|
220
|
+
const prefixed = request.headers.get(PREFIX_PARAMS_HEADER);
|
|
221
|
+
const call: DerivedCall = { request, operation: matched.operation, params: prefixed ? { ...(JSON.parse(prefixed) as Record<string, string>), ...matched.params } : matched.params };
|
|
152
222
|
const around = options.around ?? ((_c: DerivedCall, next: () => Promise<Response>) => next());
|
|
153
223
|
const handler = handlers[matched.operation.id];
|
|
154
224
|
if (handler) return around(call, () => handler(call));
|
package/src/emit.ts
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
// `TwinEmitter` and registered on its `TwinPack.emitter` (same argument as `browserRouting`:
|
|
7
7
|
// vendor knowledge in the descriptor, kernel stays vendor-agnostic).
|
|
8
8
|
//
|
|
9
|
-
// The operator surface is the PACK's bin (`world-stripe emit …`),
|
|
10
|
-
// UIs — the kernel never imports packs, so `volter-twin emit <service>` only works when a
|
|
9
|
+
// The operator surface is the PACK's bin (`world-stripe emit …`) — the kernel never imports packs, so `volter-twin emit <service>` only works when a
|
|
11
10
|
// consumer registered the pack in-process, and otherwise fails loudly pointing at the
|
|
12
11
|
// vendor bin. Unlike the twin's own background emission on state changes (fire-and-forget,
|
|
13
12
|
// vendor-faithful), an explicit DELIVER is an operator asking for exactly this delivery, so
|