@glubean/sdk 0.1.39 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract-core.d.ts +96 -0
- package/dist/contract-core.d.ts.map +1 -0
- package/dist/contract-core.js +747 -0
- package/dist/contract-core.js.map +1 -0
- package/dist/contract-http/adapter.d.ts +52 -0
- package/dist/contract-http/adapter.d.ts.map +1 -0
- package/dist/contract-http/adapter.js +650 -0
- package/dist/contract-http/adapter.js.map +1 -0
- package/dist/contract-http/factory.d.ts +32 -0
- package/dist/contract-http/factory.d.ts.map +1 -0
- package/dist/contract-http/factory.js +83 -0
- package/dist/contract-http/factory.js.map +1 -0
- package/dist/contract-http/flow-helpers.d.ts +12 -0
- package/dist/contract-http/flow-helpers.d.ts.map +1 -0
- package/dist/contract-http/flow-helpers.js +34 -0
- package/dist/contract-http/flow-helpers.js.map +1 -0
- package/dist/contract-http/index.d.ts +15 -0
- package/dist/contract-http/index.d.ts.map +1 -0
- package/dist/contract-http/index.js +14 -0
- package/dist/contract-http/index.js.map +1 -0
- package/dist/contract-http/markdown.d.ts +10 -0
- package/dist/contract-http/markdown.d.ts.map +1 -0
- package/dist/contract-http/markdown.js +21 -0
- package/dist/contract-http/markdown.js.map +1 -0
- package/dist/contract-http/openapi.d.ts +15 -0
- package/dist/contract-http/openapi.d.ts.map +1 -0
- package/dist/contract-http/openapi.js +38 -0
- package/dist/contract-http/openapi.js.map +1 -0
- package/dist/contract-http/types.d.ts +252 -0
- package/dist/contract-http/types.d.ts.map +1 -0
- package/dist/contract-http/types.js +13 -0
- package/dist/contract-http/types.js.map +1 -0
- package/dist/contract-types.d.ts +420 -467
- package/dist/contract-types.d.ts.map +1 -1
- package/dist/contract-types.js +16 -4
- package/dist/contract-types.js.map +1 -1
- package/dist/index.d.ts +21 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +22 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/contract.d.ts +0 -64
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -793
- package/dist/contract.js.map +0 -1
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol-agnostic contract core.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Adapter registry (`_adapters`)
|
|
6
|
+
* - `contract.register(protocol, adapter)` — plugin extension point
|
|
7
|
+
* - `contract[protocol](id, spec)` dispatcher — validates 1:1 case keys,
|
|
8
|
+
* invokes adapter.execute per case, registers tests + ContractRegistryMeta
|
|
9
|
+
* - `contract.flow(id)` — protocol-agnostic FlowBuilder
|
|
10
|
+
* - `runFlow(flow, ctx)` — core flow execution helper (Rule 1/2 teardown)
|
|
11
|
+
* - `normalizeFlow(runtime)` — Runtime → ExtractedFlowProjection
|
|
12
|
+
* - Permissive Proxy tracer for `compute` nodes (best-effort reads/writes)
|
|
13
|
+
*
|
|
14
|
+
* HTTP is NOT handled here — it registers itself in `./contract-http/`.
|
|
15
|
+
* This file has zero HTTP-specific code.
|
|
16
|
+
*
|
|
17
|
+
* See:
|
|
18
|
+
* - `internal/40-discovery/proposals/contract-generics-complete.md` v5
|
|
19
|
+
* - `internal/40-discovery/proposals/contract-flow.md` v9
|
|
20
|
+
*/
|
|
21
|
+
import { registerTest } from "./internal.js";
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Adapter registry
|
|
24
|
+
// =============================================================================
|
|
25
|
+
const _adapters = new Map();
|
|
26
|
+
/** Internal accessor for plugins / downstream (scanner, runner). */
|
|
27
|
+
export function getAdapter(protocol) {
|
|
28
|
+
return _adapters.get(protocol);
|
|
29
|
+
}
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// contract.register + dispatcher
|
|
32
|
+
// =============================================================================
|
|
33
|
+
const RESERVED_PROTOCOL_NAMES = new Set([
|
|
34
|
+
"register",
|
|
35
|
+
"flow",
|
|
36
|
+
"getAdapter",
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* Register an adapter. Called by built-in HTTP adapter on SDK load, and by
|
|
40
|
+
* external adapter plugins (`@glubean/contract-grpc` etc.) on their import.
|
|
41
|
+
*/
|
|
42
|
+
function register(protocol, adapter) {
|
|
43
|
+
if (RESERVED_PROTOCOL_NAMES.has(protocol)) {
|
|
44
|
+
throw new Error(`Cannot register reserved protocol name "${protocol}"`);
|
|
45
|
+
}
|
|
46
|
+
_adapters.set(protocol, adapter);
|
|
47
|
+
// Attach contract[protocol] dispatcher dynamically.
|
|
48
|
+
contract[protocol] = (id, spec) => {
|
|
49
|
+
return dispatchContract(protocol, adapter, id, spec);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Generic contract dispatcher — shared across all protocols.
|
|
54
|
+
* Called by `contract[protocol](id, spec)` above.
|
|
55
|
+
*/
|
|
56
|
+
function dispatchContract(protocol, adapter, id, spec) {
|
|
57
|
+
const rawProjection = adapter.project(spec);
|
|
58
|
+
// Registered protocol name is source of truth — adapters may report a
|
|
59
|
+
// canonical name that drifts from the registration name.
|
|
60
|
+
const projection = {
|
|
61
|
+
...rawProjection,
|
|
62
|
+
protocol,
|
|
63
|
+
};
|
|
64
|
+
// 1:1 key invariant between spec.cases and projection.cases
|
|
65
|
+
validateCaseKeys(protocol, spec.cases ?? {}, projection.cases);
|
|
66
|
+
const cases = spec.cases ?? {};
|
|
67
|
+
const contractTagsRaw = spec.tags;
|
|
68
|
+
const contractTags = Array.isArray(contractTagsRaw)
|
|
69
|
+
? contractTagsRaw
|
|
70
|
+
: contractTagsRaw
|
|
71
|
+
? [contractTagsRaw]
|
|
72
|
+
: [];
|
|
73
|
+
const projCaseMap = new Map(projection.cases.map((c) => [c.key, c]));
|
|
74
|
+
const tests = Object.entries(cases).map(([caseKey, caseSpec]) => {
|
|
75
|
+
const testId = `${id}.${caseKey}`;
|
|
76
|
+
const testName = `${id} — ${caseKey}`;
|
|
77
|
+
const projCase = projCaseMap.get(caseKey);
|
|
78
|
+
const caseTags = caseSpec.tags ?? [];
|
|
79
|
+
const allTags = [...contractTags, ...caseTags];
|
|
80
|
+
// Projection lifecycle/severity are authoritative
|
|
81
|
+
const requires = projCase.requires ?? caseSpec.requires ?? "headless";
|
|
82
|
+
const defaultRun = projCase.defaultRun ?? caseSpec.defaultRun ??
|
|
83
|
+
(requires !== "headless" ? "opt-in" : "always");
|
|
84
|
+
const runtimeTags = [];
|
|
85
|
+
if (requires !== "headless")
|
|
86
|
+
runtimeTags.push(`requires:${requires}`);
|
|
87
|
+
if (defaultRun === "opt-in")
|
|
88
|
+
runtimeTags.push("default-run:opt-in");
|
|
89
|
+
const finalTags = [...allTags, ...runtimeTags];
|
|
90
|
+
const skipDeprecated = projCase.lifecycle === "deprecated"
|
|
91
|
+
? `deprecated: ${projCase.deprecatedReason ?? caseSpec.deprecated ?? "deprecated"}`
|
|
92
|
+
: caseSpec.deprecated
|
|
93
|
+
? `deprecated: ${caseSpec.deprecated}`
|
|
94
|
+
: undefined;
|
|
95
|
+
const skipDeferred = projCase.lifecycle === "deferred"
|
|
96
|
+
? (projCase.deferredReason ?? caseSpec.deferred ?? "deferred")
|
|
97
|
+
: caseSpec.deferred;
|
|
98
|
+
const testDef = {
|
|
99
|
+
meta: {
|
|
100
|
+
id: testId,
|
|
101
|
+
name: testName,
|
|
102
|
+
description: projCase.description,
|
|
103
|
+
tags: finalTags.length > 0 ? finalTags : undefined,
|
|
104
|
+
deferred: skipDeferred,
|
|
105
|
+
deprecated: skipDeprecated
|
|
106
|
+
? (projCase.deprecatedReason ?? caseSpec.deprecated ?? "deprecated")
|
|
107
|
+
: undefined,
|
|
108
|
+
requires,
|
|
109
|
+
defaultRun,
|
|
110
|
+
},
|
|
111
|
+
type: "simple",
|
|
112
|
+
fn: async (ctx) => {
|
|
113
|
+
if (skipDeprecated)
|
|
114
|
+
ctx.skip(skipDeprecated);
|
|
115
|
+
if (skipDeferred)
|
|
116
|
+
ctx.skip(skipDeferred);
|
|
117
|
+
await adapter.execute(ctx, caseSpec, spec);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
// Build ContractRegistryMeta
|
|
121
|
+
const payloadSummary = adapter.describePayload
|
|
122
|
+
? (() => {
|
|
123
|
+
// describePayload expects SafeSchemas — we don't have safe form yet,
|
|
124
|
+
// so pass projCase.schemas as a best-effort (adapters using this in
|
|
125
|
+
// runtime meta should be tolerant of live objects).
|
|
126
|
+
try {
|
|
127
|
+
return adapter.describePayload((projCase.schemas ?? undefined));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
})()
|
|
133
|
+
: undefined;
|
|
134
|
+
const registryMeta = {
|
|
135
|
+
target: projection.target,
|
|
136
|
+
protocol: projection.protocol,
|
|
137
|
+
caseKey,
|
|
138
|
+
lifecycle: projCase.lifecycle,
|
|
139
|
+
severity: projCase.severity,
|
|
140
|
+
instanceName: projection.instanceName,
|
|
141
|
+
payloadSummary,
|
|
142
|
+
meta: projCase.meta,
|
|
143
|
+
};
|
|
144
|
+
registerTest({
|
|
145
|
+
id: testId,
|
|
146
|
+
name: testName,
|
|
147
|
+
type: "simple",
|
|
148
|
+
tags: finalTags.length > 0 ? finalTags : undefined,
|
|
149
|
+
description: projCase.description,
|
|
150
|
+
groupId: id,
|
|
151
|
+
requires,
|
|
152
|
+
defaultRun,
|
|
153
|
+
contract: registryMeta,
|
|
154
|
+
});
|
|
155
|
+
return testDef;
|
|
156
|
+
});
|
|
157
|
+
// Core injects id into both _projection and _spec carrier
|
|
158
|
+
const enrichedProjection = { ...projection, id };
|
|
159
|
+
const arr = [...tests];
|
|
160
|
+
const contractObj = Object.assign(arr, {
|
|
161
|
+
_projection: enrichedProjection,
|
|
162
|
+
_spec: spec,
|
|
163
|
+
case(key) {
|
|
164
|
+
// Delegate fail-fast validation to adapter (e.g. HTTP rejects
|
|
165
|
+
// function-valued body/params/query/headers).
|
|
166
|
+
adapter.validateCaseForFlow?.(spec, key, id);
|
|
167
|
+
return makeContractCaseRef(protocol, id, projection.target, key, contractObj, spec);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
return contractObj;
|
|
171
|
+
}
|
|
172
|
+
function validateCaseKeys(protocol, specCases, projectionCases) {
|
|
173
|
+
// Duplicate check
|
|
174
|
+
const keys = projectionCases.map((c) => c.key);
|
|
175
|
+
const keySet = new Set(keys);
|
|
176
|
+
if (keySet.size !== keys.length) {
|
|
177
|
+
const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
|
|
178
|
+
throw new Error(`contract.register("${protocol}"): project() returned duplicate case key(s): ${[...new Set(dupes)].join(", ")}. ` +
|
|
179
|
+
`Each projected case key must be unique.`);
|
|
180
|
+
}
|
|
181
|
+
const specKeys = new Set(Object.keys(specCases));
|
|
182
|
+
for (const key of keySet) {
|
|
183
|
+
if (!specKeys.has(key)) {
|
|
184
|
+
throw new Error(`contract.register("${protocol}"): project() returned case "${key}" not present in spec.cases. ` +
|
|
185
|
+
`Projected cases must 1:1 match spec.cases keys.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const key of specKeys) {
|
|
189
|
+
if (!keySet.has(key)) {
|
|
190
|
+
throw new Error(`contract.register("${protocol}"): spec.cases has "${key}" but project() did not return it. ` +
|
|
191
|
+
`Projected cases must 1:1 match spec.cases keys.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Construct a ContractCaseRef. Adapters may extend this via module
|
|
197
|
+
* augmentation if they want stricter typing for their specific I/O shapes.
|
|
198
|
+
*
|
|
199
|
+
* This function performs the `function-valued field fail-fast` check
|
|
200
|
+
* required by contract-flow §5.1.1 — but since the check is
|
|
201
|
+
* protocol-specific (what counts as an "input slot" varies), adapters
|
|
202
|
+
* should enforce it in their own `.case()` wrapper if needed. For
|
|
203
|
+
* built-in HTTP adapter, see `./contract-http/adapter.ts`.
|
|
204
|
+
*/
|
|
205
|
+
function makeContractCaseRef(protocol, contractId, target, caseKey, contract, _spec) {
|
|
206
|
+
return {
|
|
207
|
+
__glubean_type: "contract-case-ref",
|
|
208
|
+
contractId,
|
|
209
|
+
caseKey,
|
|
210
|
+
protocol,
|
|
211
|
+
target,
|
|
212
|
+
contract,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export const contract = {
|
|
216
|
+
register,
|
|
217
|
+
flow,
|
|
218
|
+
};
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// Flow: FlowBuilder + runFlow + normalizeFlow + tracePureFn
|
|
221
|
+
// =============================================================================
|
|
222
|
+
/**
|
|
223
|
+
* Protocol-agnostic flow builder. See contract-flow.md v9 §4.1.
|
|
224
|
+
*/
|
|
225
|
+
export function flow(idOrMeta) {
|
|
226
|
+
const meta = typeof idOrMeta === "string"
|
|
227
|
+
? { id: idOrMeta }
|
|
228
|
+
: idOrMeta;
|
|
229
|
+
const steps = [];
|
|
230
|
+
let setupFn;
|
|
231
|
+
let teardownFn;
|
|
232
|
+
let built = false;
|
|
233
|
+
let extraMeta = {};
|
|
234
|
+
const builder = {
|
|
235
|
+
__glubean_type: "flow-builder",
|
|
236
|
+
meta(m) {
|
|
237
|
+
extraMeta = { ...extraMeta, ...m };
|
|
238
|
+
return builder;
|
|
239
|
+
},
|
|
240
|
+
setup(fn) {
|
|
241
|
+
setupFn = fn;
|
|
242
|
+
return builder;
|
|
243
|
+
},
|
|
244
|
+
teardown(fn) {
|
|
245
|
+
teardownFn = fn;
|
|
246
|
+
return builder;
|
|
247
|
+
},
|
|
248
|
+
step(ref, bindings) {
|
|
249
|
+
const adapter = _adapters.get(ref.protocol);
|
|
250
|
+
if (!adapter) {
|
|
251
|
+
throw new Error(`contract.flow(${JSON.stringify(meta.id)}).step: unknown protocol "${ref.protocol}". ` +
|
|
252
|
+
`Did you forget to import a contract plugin package (e.g. "@glubean/contract-grpc")?`);
|
|
253
|
+
}
|
|
254
|
+
if (!adapter.executeCaseInFlow) {
|
|
255
|
+
throw new Error(`contract.flow(${JSON.stringify(meta.id)}).step: adapter for "${ref.protocol}" ` +
|
|
256
|
+
`does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
|
|
257
|
+
}
|
|
258
|
+
const step = {
|
|
259
|
+
kind: "contract-call",
|
|
260
|
+
name: bindings?.name,
|
|
261
|
+
ref,
|
|
262
|
+
caseKey: ref.caseKey,
|
|
263
|
+
contract: ref.contract,
|
|
264
|
+
bindings: bindings
|
|
265
|
+
? { in: bindings.in, out: bindings.out }
|
|
266
|
+
: undefined,
|
|
267
|
+
};
|
|
268
|
+
steps.push(step);
|
|
269
|
+
return builder;
|
|
270
|
+
},
|
|
271
|
+
compute(fn) {
|
|
272
|
+
const step = {
|
|
273
|
+
kind: "compute",
|
|
274
|
+
fn: fn,
|
|
275
|
+
};
|
|
276
|
+
steps.push(step);
|
|
277
|
+
return builder;
|
|
278
|
+
},
|
|
279
|
+
build() {
|
|
280
|
+
return finalize();
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
// Auto-finalize via microtask (mirrors TestBuilder pattern)
|
|
284
|
+
queueMicrotask(() => {
|
|
285
|
+
if (!built)
|
|
286
|
+
finalize();
|
|
287
|
+
});
|
|
288
|
+
function finalize() {
|
|
289
|
+
if (built)
|
|
290
|
+
return builtResult;
|
|
291
|
+
built = true;
|
|
292
|
+
const runtime = {
|
|
293
|
+
protocol: "flow",
|
|
294
|
+
description: extraMeta.description,
|
|
295
|
+
tags: extraMeta.tags,
|
|
296
|
+
extensions: extraMeta.extensions,
|
|
297
|
+
setup: setupFn,
|
|
298
|
+
teardown: teardownFn,
|
|
299
|
+
steps,
|
|
300
|
+
};
|
|
301
|
+
// Build the single Test that runFlow orchestrates.
|
|
302
|
+
const flowTest = {
|
|
303
|
+
meta: {
|
|
304
|
+
id: meta.id,
|
|
305
|
+
name: extraMeta.name ?? meta.id,
|
|
306
|
+
tags: extraMeta.tags,
|
|
307
|
+
description: extraMeta.description,
|
|
308
|
+
// `FlowMeta.skip: string` (the reason) → `TestMeta.deferred: string`
|
|
309
|
+
// mirrors the ContractCase.deferred convention so downstream
|
|
310
|
+
// reporters render the skip reason consistently.
|
|
311
|
+
...(extraMeta.skip !== undefined ? { deferred: extraMeta.skip } : {}),
|
|
312
|
+
...(extraMeta.only !== undefined ? { only: extraMeta.only } : {}),
|
|
313
|
+
},
|
|
314
|
+
type: "simple",
|
|
315
|
+
fn: async (ctx) => {
|
|
316
|
+
// Belt-and-suspenders: runtime ctx.skip in case the runner didn't
|
|
317
|
+
// filter on meta.deferred (e.g. the user ran with an explicit
|
|
318
|
+
// target that bypasses skip filters).
|
|
319
|
+
if (extraMeta.skip)
|
|
320
|
+
ctx.skip(extraMeta.skip);
|
|
321
|
+
await runFlow(resultHandle, ctx);
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
const arr = [flowTest];
|
|
325
|
+
const runtimeWithId = { ...runtime, id: meta.id };
|
|
326
|
+
// Pre-compute extracted projection so downstream consumers (scanner,
|
|
327
|
+
// CLI, MCP, Cloud) get full field mappings + compute reads/writes
|
|
328
|
+
// without having to import the SDK to call normalizeFlow themselves.
|
|
329
|
+
const extracted = normalizeFlow(runtimeWithId);
|
|
330
|
+
const resultHandle = Object.assign(arr, {
|
|
331
|
+
_flow: runtimeWithId,
|
|
332
|
+
_extracted: extracted,
|
|
333
|
+
});
|
|
334
|
+
// Register flow in the registry
|
|
335
|
+
const flowRegistryMeta = {
|
|
336
|
+
id: extracted.id,
|
|
337
|
+
description: extracted.description,
|
|
338
|
+
tags: extracted.tags,
|
|
339
|
+
steps: extracted.steps.map(stepProjectionToRegistry),
|
|
340
|
+
setupDynamic: extracted.setupDynamic,
|
|
341
|
+
};
|
|
342
|
+
registerTest({
|
|
343
|
+
id: meta.id,
|
|
344
|
+
name: extraMeta.name ?? meta.id,
|
|
345
|
+
type: "simple",
|
|
346
|
+
tags: extraMeta.tags,
|
|
347
|
+
description: extraMeta.description,
|
|
348
|
+
flow: flowRegistryMeta,
|
|
349
|
+
});
|
|
350
|
+
builtResult = resultHandle;
|
|
351
|
+
return resultHandle;
|
|
352
|
+
}
|
|
353
|
+
let builtResult;
|
|
354
|
+
return builder;
|
|
355
|
+
}
|
|
356
|
+
function stepProjectionToRegistry(step) {
|
|
357
|
+
if (step.kind === "compute") {
|
|
358
|
+
return {
|
|
359
|
+
kind: "compute",
|
|
360
|
+
name: step.name,
|
|
361
|
+
reads: step.reads,
|
|
362
|
+
writes: step.writes,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
kind: "contract-call",
|
|
367
|
+
name: step.name,
|
|
368
|
+
contractId: step.contractId,
|
|
369
|
+
caseKey: step.caseKey,
|
|
370
|
+
protocol: step.protocol,
|
|
371
|
+
target: step.target,
|
|
372
|
+
inputs: step.inputs,
|
|
373
|
+
outputs: step.outputs,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Core flow execution helper. Implements the Rule 1 / Rule 2 teardown
|
|
378
|
+
* semantics from contract-flow.md §7.
|
|
379
|
+
*/
|
|
380
|
+
export async function runFlow(flowContract, ctx) {
|
|
381
|
+
const runtime = flowContract._flow;
|
|
382
|
+
// If flow.setup throws → flow.teardown does NOT run (Rule 2).
|
|
383
|
+
let state = runtime.setup
|
|
384
|
+
? await runtime.setup(ctx)
|
|
385
|
+
: undefined;
|
|
386
|
+
try {
|
|
387
|
+
for (const step of runtime.steps) {
|
|
388
|
+
if (step.kind === "compute") {
|
|
389
|
+
// Compute: synchronous pure function. Enforce both syntactic and
|
|
390
|
+
// value-level async rejection.
|
|
391
|
+
if (step.fn.constructor?.name === "AsyncFunction") {
|
|
392
|
+
throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
|
|
393
|
+
`async functions are not allowed — compute must be synchronous and I/O-free`);
|
|
394
|
+
}
|
|
395
|
+
const result = step.fn(state);
|
|
396
|
+
if (result && typeof result.then === "function") {
|
|
397
|
+
throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
|
|
398
|
+
`returned a thenable (Promise or Promise-like) — compute must not return async values. ` +
|
|
399
|
+
`If you need async initialization, use flow.setup() instead.`);
|
|
400
|
+
}
|
|
401
|
+
state = result;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
// contract-call branch
|
|
405
|
+
const adapter = _adapters.get(step.ref.protocol);
|
|
406
|
+
if (!adapter) {
|
|
407
|
+
throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
|
|
408
|
+
`no registered adapter for protocol "${step.ref.protocol}". ` +
|
|
409
|
+
`Did you forget to import a contract plugin package?`);
|
|
410
|
+
}
|
|
411
|
+
if (!adapter.executeCaseInFlow) {
|
|
412
|
+
throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
|
|
413
|
+
`adapter for "${step.ref.protocol}" does not implement executeCaseInFlow`);
|
|
414
|
+
}
|
|
415
|
+
const resolvedInputs = step.bindings?.in?.(state);
|
|
416
|
+
const response = await adapter.executeCaseInFlow({
|
|
417
|
+
ctx,
|
|
418
|
+
contract: step.contract,
|
|
419
|
+
caseKey: step.caseKey,
|
|
420
|
+
resolvedInputs,
|
|
421
|
+
});
|
|
422
|
+
if (step.bindings?.out) {
|
|
423
|
+
state = step.bindings.out(state, response);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
if (runtime.teardown) {
|
|
429
|
+
// flow.teardown runs in Rule 2 outer-finally. Its errors are logged
|
|
430
|
+
// but must not mask the primary exception.
|
|
431
|
+
try {
|
|
432
|
+
await runtime.teardown(ctx, state);
|
|
433
|
+
}
|
|
434
|
+
catch (teardownErr) {
|
|
435
|
+
ctx.log?.(`flow.teardown failed: ${String(teardownErr)}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// =============================================================================
|
|
441
|
+
// normalizeFlow + permissive Proxy tracer
|
|
442
|
+
// =============================================================================
|
|
443
|
+
/**
|
|
444
|
+
* Normalize a RuntimeFlowProjection to JSON-safe ExtractedFlowProjection.
|
|
445
|
+
* Runs Proxy dry-run of lens functions to extract FieldMappings.
|
|
446
|
+
*
|
|
447
|
+
* Lens purity is enforced here: if a `step.bindings.in` or `.out` lens
|
|
448
|
+
* violates purity (method call, `new`, etc.), `LensPurityError` is thrown
|
|
449
|
+
* with step context so the author sees which step needs fixing.
|
|
450
|
+
*/
|
|
451
|
+
export function normalizeFlow(runtime) {
|
|
452
|
+
return {
|
|
453
|
+
id: runtime.id,
|
|
454
|
+
protocol: "flow",
|
|
455
|
+
description: runtime.description,
|
|
456
|
+
tags: runtime.tags,
|
|
457
|
+
extensions: runtime.extensions,
|
|
458
|
+
setupDynamic: runtime.setup ? true : undefined,
|
|
459
|
+
steps: runtime.steps.map((s, idx) => {
|
|
460
|
+
const stepLabel = s.name ??
|
|
461
|
+
(s.kind === "contract-call" ? `${s.contract._projection.id}#${s.caseKey}` : `step-${idx + 1}`);
|
|
462
|
+
if (s.kind === "compute") {
|
|
463
|
+
try {
|
|
464
|
+
const { reads, writes } = traceComputeFn(s.fn);
|
|
465
|
+
return { kind: "compute", name: s.name, reads, writes };
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
// Wrap compute-tracer failures with the same step-context format
|
|
469
|
+
// used for lens errors, so authoring mistakes are equally easy
|
|
470
|
+
// to localize regardless of which tracer caught them.
|
|
471
|
+
throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (compute): ${err instanceof Error ? err.message : String(err)}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
let inputs;
|
|
475
|
+
if (s.bindings?.in) {
|
|
476
|
+
try {
|
|
477
|
+
inputs = extractMappings(s.bindings.in);
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
if (err instanceof LensPurityError) {
|
|
481
|
+
throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (in lens): ${err.message}`);
|
|
482
|
+
}
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
let outputs;
|
|
487
|
+
if (s.bindings?.out) {
|
|
488
|
+
try {
|
|
489
|
+
outputs = extractMappingsOut(s.bindings.out);
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
if (err instanceof LensPurityError) {
|
|
493
|
+
throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (out lens): ${err.message}`);
|
|
494
|
+
}
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
kind: "contract-call",
|
|
500
|
+
name: s.name,
|
|
501
|
+
contractId: s.contract._projection.id,
|
|
502
|
+
caseKey: s.caseKey,
|
|
503
|
+
protocol: s.ref.protocol,
|
|
504
|
+
target: s.ref.target,
|
|
505
|
+
inputs,
|
|
506
|
+
outputs,
|
|
507
|
+
};
|
|
508
|
+
}),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Run a pure lens `fn: (state) => output` with a tracing Proxy, extracting
|
|
513
|
+
* FieldMappings from state paths to output paths.
|
|
514
|
+
*
|
|
515
|
+
* Pure-lens enforcement: method calls, `new`, and coercion on the state
|
|
516
|
+
* proxy raise `LensPurityError`. Errors are **not** swallowed — the caller
|
|
517
|
+
* (typically `normalizeFlow`) wraps them with step context and re-throws,
|
|
518
|
+
* so authors see the failure at flow build time rather than silently
|
|
519
|
+
* losing projection data.
|
|
520
|
+
*/
|
|
521
|
+
export function extractMappings(fn) {
|
|
522
|
+
const proxy = makeLensProxy("state");
|
|
523
|
+
const result = fn(proxy);
|
|
524
|
+
return collectMappings(result, []);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Extract FieldMappings from `out: (state, response) => newState`.
|
|
528
|
+
* Same purity contract as `extractMappings`.
|
|
529
|
+
*/
|
|
530
|
+
export function extractMappingsOut(fn) {
|
|
531
|
+
const stateProxy = makeLensProxy("state");
|
|
532
|
+
const resProxy = makeLensProxy("response");
|
|
533
|
+
const result = fn(stateProxy, resProxy);
|
|
534
|
+
// outputs target is always state.X
|
|
535
|
+
return collectMappings(result, ["state"]);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Recursively descend into an object produced by a traced lens, collecting
|
|
539
|
+
* paths. Lens outputs are plain objects whose leaf values are TracedValue
|
|
540
|
+
* markers (recording their source path).
|
|
541
|
+
*/
|
|
542
|
+
function collectMappings(out, targetPath) {
|
|
543
|
+
const mappings = [];
|
|
544
|
+
walk(out, targetPath);
|
|
545
|
+
return mappings;
|
|
546
|
+
function walk(val, path) {
|
|
547
|
+
if (isTracedValue(val)) {
|
|
548
|
+
mappings.push({
|
|
549
|
+
target: path.join("."),
|
|
550
|
+
source: { kind: "path", path: val[TRACE_MARKER] },
|
|
551
|
+
});
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (val === undefined || val === null)
|
|
555
|
+
return;
|
|
556
|
+
if (typeof val === "object" && !Array.isArray(val)) {
|
|
557
|
+
for (const [k, v] of Object.entries(val)) {
|
|
558
|
+
walk(v, [...path, k]);
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (Array.isArray(val)) {
|
|
563
|
+
val.forEach((v, i) => walk(v, [...path, String(i)]));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
// Primitive literal
|
|
567
|
+
mappings.push({
|
|
568
|
+
target: path.join("."),
|
|
569
|
+
source: { kind: "literal", value: val },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// --- Traced values (returned by lens proxy on property access) ---------------
|
|
574
|
+
//
|
|
575
|
+
// The trace marker is a Symbol (not a string key) so it is invisible to
|
|
576
|
+
// `Object.keys` / spread / JSON.stringify. Without this, `{ ...s }` in a lens
|
|
577
|
+
// would copy the tracer sentinel into the result and trip the traced-value
|
|
578
|
+
// check against the entire spread object.
|
|
579
|
+
const TRACE_MARKER = Symbol.for("@glubean/lens-trace");
|
|
580
|
+
function isTracedValue(v) {
|
|
581
|
+
// Lens proxy uses a callable function target (for apply-trap purity
|
|
582
|
+
// enforcement), so the proxy itself reports `typeof === "function"` in
|
|
583
|
+
// V8. Accept either object or function.
|
|
584
|
+
return ((typeof v === "object" || typeof v === "function") &&
|
|
585
|
+
v !== null &&
|
|
586
|
+
v[TRACE_MARKER] !== undefined);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Raised by the strict lens Proxy when a user lens fn attempts an operation
|
|
590
|
+
* that breaks the "pure field access + repack" contract. Caught and re-
|
|
591
|
+
* thrown with step context by `normalizeFlow`.
|
|
592
|
+
*/
|
|
593
|
+
export class LensPurityError extends Error {
|
|
594
|
+
path;
|
|
595
|
+
operation;
|
|
596
|
+
constructor(path, operation) {
|
|
597
|
+
super(`Flow step in/out lens must be a pure select/repack function ` +
|
|
598
|
+
`(no method calls, branches, or arithmetic). ` +
|
|
599
|
+
`Illegal operation: "${operation}" on path "${path}". ` +
|
|
600
|
+
`If you need computation, move it to a .compute(s => ...) step between ` +
|
|
601
|
+
`this .step() and the next one.`);
|
|
602
|
+
this.name = "LensPurityError";
|
|
603
|
+
this.path = path;
|
|
604
|
+
this.operation = operation;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Strict lens proxy — records top-level property access path as a TracedValue
|
|
609
|
+
* on read. Used for `.step()` in/out lenses where we want precise field
|
|
610
|
+
* mappings. Trace marker is a Symbol so it doesn't leak through spread.
|
|
611
|
+
*
|
|
612
|
+
* Enforces the lens purity invariant (contract-flow v9 §3.1 + §3.3):
|
|
613
|
+
* method calls / coercion / method dispatch on state or response throw
|
|
614
|
+
* `LensPurityError`. Authors who need computation must move it to a
|
|
615
|
+
* `.compute(s => ...)` step.
|
|
616
|
+
*/
|
|
617
|
+
function makeLensProxy(rootPath) {
|
|
618
|
+
function childProxy(path) {
|
|
619
|
+
// Use a callable **arrow** function target so `apply` trap fires on
|
|
620
|
+
// method calls. Arrow functions don't own `prototype`, so ownKeys can
|
|
621
|
+
// return [] without violating Proxy invariants (regular functions own
|
|
622
|
+
// a non-configurable `prototype` that would have to be surfaced).
|
|
623
|
+
const target = (() => { });
|
|
624
|
+
return new Proxy(target, {
|
|
625
|
+
get(_target, prop) {
|
|
626
|
+
if (prop === TRACE_MARKER)
|
|
627
|
+
return path;
|
|
628
|
+
if (typeof prop === "symbol")
|
|
629
|
+
return undefined;
|
|
630
|
+
return childProxy(`${path}.${String(prop)}`);
|
|
631
|
+
},
|
|
632
|
+
apply(_target, _thisArg, _args) {
|
|
633
|
+
// Method call — forbidden. Extract the leaf segment as the offending op.
|
|
634
|
+
const dot = path.lastIndexOf(".");
|
|
635
|
+
const op = dot >= 0 ? path.slice(dot + 1) : path;
|
|
636
|
+
const owner = dot >= 0 ? path.slice(0, dot) : path;
|
|
637
|
+
throw new LensPurityError(owner, `${op}()`);
|
|
638
|
+
},
|
|
639
|
+
construct(_target, _args) {
|
|
640
|
+
throw new LensPurityError(path, "new");
|
|
641
|
+
},
|
|
642
|
+
// ownKeys returns nothing so spread copies no fields.
|
|
643
|
+
ownKeys() {
|
|
644
|
+
return [];
|
|
645
|
+
},
|
|
646
|
+
getOwnPropertyDescriptor() {
|
|
647
|
+
return undefined;
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return childProxy(rootPath);
|
|
652
|
+
}
|
|
653
|
+
// --- Permissive compute proxy (different role, different trap rules) --------
|
|
654
|
+
/**
|
|
655
|
+
* Trace a `compute` fn to extract top-level reads + writes.
|
|
656
|
+
*
|
|
657
|
+
* Compute allows arbitrary sync TS (template literals, method calls, .map()),
|
|
658
|
+
* so the Proxy must be permissive: respond to primitive coercion, swallow
|
|
659
|
+
* method calls, etc. We only record top-level property access paths.
|
|
660
|
+
*
|
|
661
|
+
* See contract-flow.md §4.1.1 for design + known limitations.
|
|
662
|
+
*/
|
|
663
|
+
export function traceComputeFn(fn) {
|
|
664
|
+
const reads = new Set();
|
|
665
|
+
const proxy = makeComputeProxy("state", reads);
|
|
666
|
+
// compute fn is permitted to do arbitrary synchronous TS — the proxy is
|
|
667
|
+
// tolerant of method calls / coercion / iteration via its permissive
|
|
668
|
+
// trap rules, so any exception here is genuine (e.g. fn threw explicitly).
|
|
669
|
+
// Don't silently swallow: let the error propagate so callers surface it.
|
|
670
|
+
const result = fn(proxy);
|
|
671
|
+
const writes = result && typeof result === "object" && !Array.isArray(result)
|
|
672
|
+
? Object.keys(result).filter((k) => typeof k === "string")
|
|
673
|
+
: [];
|
|
674
|
+
return { reads: [...reads].sort(), writes };
|
|
675
|
+
}
|
|
676
|
+
function makeComputeProxy(rootPath, reads) {
|
|
677
|
+
// Arrow-function target: callable (for `apply` trap on method calls) but
|
|
678
|
+
// does NOT own `prototype`, so `ownKeys` returning [] is valid and spread
|
|
679
|
+
// (`{ ...s }`) works without triggering a Proxy invariant violation.
|
|
680
|
+
return new Proxy((() => { }), {
|
|
681
|
+
get(_target, prop) {
|
|
682
|
+
if (prop === Symbol.toPrimitive)
|
|
683
|
+
return () => "";
|
|
684
|
+
if (prop === "toString")
|
|
685
|
+
return () => "";
|
|
686
|
+
if (prop === "valueOf")
|
|
687
|
+
return () => 0;
|
|
688
|
+
if (prop === Symbol.iterator)
|
|
689
|
+
return function* () { };
|
|
690
|
+
if (prop === "length")
|
|
691
|
+
return 0;
|
|
692
|
+
if (prop === Symbol.isConcatSpreadable)
|
|
693
|
+
return false;
|
|
694
|
+
if (typeof prop !== "string")
|
|
695
|
+
return undefined;
|
|
696
|
+
reads.add(`${rootPath}.${prop}`);
|
|
697
|
+
return makePermissiveChildProxy();
|
|
698
|
+
},
|
|
699
|
+
apply() {
|
|
700
|
+
return makePermissiveChildProxy();
|
|
701
|
+
},
|
|
702
|
+
has() {
|
|
703
|
+
return true;
|
|
704
|
+
},
|
|
705
|
+
ownKeys() {
|
|
706
|
+
return [];
|
|
707
|
+
},
|
|
708
|
+
getOwnPropertyDescriptor() {
|
|
709
|
+
return { configurable: true, enumerable: true, value: undefined };
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Totally-permissive child proxy — swallows every access / invocation,
|
|
715
|
+
* returns itself. Used for depth-2+ of compute tracing where we don't track.
|
|
716
|
+
*/
|
|
717
|
+
function makePermissiveChildProxy() {
|
|
718
|
+
const self = new Proxy((() => { }), {
|
|
719
|
+
get(_target, prop) {
|
|
720
|
+
if (prop === Symbol.toPrimitive)
|
|
721
|
+
return () => "";
|
|
722
|
+
if (prop === "toString")
|
|
723
|
+
return () => "";
|
|
724
|
+
if (prop === "valueOf")
|
|
725
|
+
return () => 0;
|
|
726
|
+
if (prop === Symbol.iterator)
|
|
727
|
+
return function* () { };
|
|
728
|
+
if (prop === "length")
|
|
729
|
+
return 0;
|
|
730
|
+
return self;
|
|
731
|
+
},
|
|
732
|
+
apply() {
|
|
733
|
+
return self;
|
|
734
|
+
},
|
|
735
|
+
has() {
|
|
736
|
+
return true;
|
|
737
|
+
},
|
|
738
|
+
ownKeys() {
|
|
739
|
+
return [];
|
|
740
|
+
},
|
|
741
|
+
getOwnPropertyDescriptor() {
|
|
742
|
+
return { configurable: true, enumerable: true, value: undefined };
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
return self;
|
|
746
|
+
}
|
|
747
|
+
//# sourceMappingURL=contract-core.js.map
|