@stackables/bridge-compiler 1.0.1 → 1.0.2
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.
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Bridge, BridgeDocument, ToolMap } from "./types.ts";
|
|
1
|
+
import type { Bridge, BridgeDocument, ToolMap, Wire } from "./types.ts";
|
|
2
2
|
/** Fatal panic error — bypasses all error boundaries (`?.` and `catch`). */
|
|
3
3
|
export declare class BridgePanicError extends Error {
|
|
4
4
|
constructor(message: string);
|
|
@@ -32,6 +32,13 @@ type Trunk = {
|
|
|
32
32
|
field: string;
|
|
33
33
|
instance?: number;
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* A value that may already be resolved (synchronous) or still pending (asynchronous).
|
|
37
|
+
* Using this instead of always returning `Promise<T>` lets callers skip
|
|
38
|
+
* microtask scheduling when the value is immediately available.
|
|
39
|
+
* See docs/performance.md (#10).
|
|
40
|
+
*/
|
|
41
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
35
42
|
/** Trace verbosity level.
|
|
36
43
|
* - `"off"` (default) — no collection, zero overhead
|
|
37
44
|
* - `"basic"` — tool, fn, timing, errors; no input/output
|
|
@@ -101,6 +108,8 @@ export declare class ExecutionTree {
|
|
|
101
108
|
private toolFns?;
|
|
102
109
|
/** Shadow-tree nesting depth (0 for root). */
|
|
103
110
|
private depth;
|
|
111
|
+
/** Pre-computed `trunkKey({ ...this.trunk, element: true })`. See docs/performance.md (#4). */
|
|
112
|
+
private elementTrunkKey;
|
|
104
113
|
constructor(trunk: Trunk, document: BridgeDocument, toolFns?: ToolMap, context?: Record<string, any> | undefined, parent?: ExecutionTree | undefined);
|
|
105
114
|
/** Derive tool name from a trunk */
|
|
106
115
|
private getToolName;
|
|
@@ -115,7 +124,20 @@ export declare class ExecutionTree {
|
|
|
115
124
|
private resolveToolSource;
|
|
116
125
|
/** Call a tool dependency (cached per request) */
|
|
117
126
|
private resolveToolDep;
|
|
118
|
-
schedule(target: Trunk, pullChain?: Set<string>): any
|
|
127
|
+
schedule(target: Trunk, pullChain?: Set<string>): MaybePromise<any>;
|
|
128
|
+
/**
|
|
129
|
+
* Assemble input from resolved wire values and either invoke a direct tool
|
|
130
|
+
* function or return the data for pass-through targets (local/define/logic).
|
|
131
|
+
* Returns synchronously when the tool function (if any) returns sync.
|
|
132
|
+
* See docs/performance.md (#12).
|
|
133
|
+
*/
|
|
134
|
+
private scheduleFinish;
|
|
135
|
+
/**
|
|
136
|
+
* Full async schedule path for targets backed by a ToolDef.
|
|
137
|
+
* Resolves tool wires, bridge wires, and invokes the tool function
|
|
138
|
+
* with error recovery support.
|
|
139
|
+
*/
|
|
140
|
+
private scheduleToolDef;
|
|
119
141
|
/**
|
|
120
142
|
* Invoke a tool function, recording both an OpenTelemetry span and (when
|
|
121
143
|
* tracing is enabled) a ToolTrace entry. All three tool-call sites in the
|
|
@@ -125,6 +147,16 @@ export declare class ExecutionTree {
|
|
|
125
147
|
shadow(): ExecutionTree;
|
|
126
148
|
/** Returns collected traces (empty array when tracing is disabled). */
|
|
127
149
|
getTraces(): ToolTrace[];
|
|
150
|
+
/**
|
|
151
|
+
* Traverse `ref.path` on an already-resolved value, respecting null guards.
|
|
152
|
+
* Extracted from `pullSingle` so the sync and async paths can share logic.
|
|
153
|
+
*/
|
|
154
|
+
private applyPath;
|
|
155
|
+
/**
|
|
156
|
+
* Pull a single value. Returns synchronously when already in state;
|
|
157
|
+
* returns a Promise only when the value is a pending tool call.
|
|
158
|
+
* See docs/performance.md (#10).
|
|
159
|
+
*/
|
|
128
160
|
private pullSingle;
|
|
129
161
|
push(args: Record<string, any>): void;
|
|
130
162
|
/** Store the aggregated promise for critical forced handles so
|
|
@@ -163,7 +195,16 @@ export declare class ExecutionTree {
|
|
|
163
195
|
* After layers 1–2b, the overdefinition boundary (`!= null`) decides whether
|
|
164
196
|
* to return or continue to the next wire.
|
|
165
197
|
*/
|
|
198
|
+
/**
|
|
199
|
+
* Resolve wires, returning synchronously when the hot path allows it.
|
|
200
|
+
*
|
|
201
|
+
* Fast path: single `from` wire with no fallback/catch modifiers, which is
|
|
202
|
+
* the common case for element field wires like `.id <- it.id`. Delegates to
|
|
203
|
+
* `resolveWiresAsync` for anything more complex.
|
|
204
|
+
* See docs/performance.md (#10).
|
|
205
|
+
*/
|
|
166
206
|
private resolveWires;
|
|
207
|
+
private resolveWiresAsync;
|
|
167
208
|
/**
|
|
168
209
|
* Resolve an output field by path for use outside of a GraphQL resolver.
|
|
169
210
|
*
|
|
@@ -179,6 +220,22 @@ export declare class ExecutionTree {
|
|
|
179
220
|
* in a shadow tree (mirrors `response()` array handling).
|
|
180
221
|
*/
|
|
181
222
|
pullOutputField(path: string[], array?: boolean): Promise<unknown>;
|
|
223
|
+
/**
|
|
224
|
+
* Resolve pre-grouped wires on this shadow tree without re-filtering.
|
|
225
|
+
* Called by the parent's `materializeShadows` to skip per-element wire
|
|
226
|
+
* filtering. Returns synchronously when the wire resolves sync (hot path).
|
|
227
|
+
* See docs/performance.md (#8, #10).
|
|
228
|
+
*/
|
|
229
|
+
resolvePreGrouped(wires: Wire[]): MaybePromise<unknown>;
|
|
230
|
+
/**
|
|
231
|
+
* Materialise all output wires into a plain JS object.
|
|
232
|
+
*
|
|
233
|
+
* Used by the GraphQL adapter when a bridge field returns a scalar type
|
|
234
|
+
* (e.g. `JSON`, `JSONObject`). In that case GraphQL won't call sub-field
|
|
235
|
+
* resolvers, so we need to eagerly resolve every output wire and assemble
|
|
236
|
+
* the result ourselves — the same logic `run()` uses for object output.
|
|
237
|
+
*/
|
|
238
|
+
collectOutput(): Promise<unknown>;
|
|
182
239
|
/**
|
|
183
240
|
* Execute the bridge end-to-end without GraphQL.
|
|
184
241
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExecutionTree.d.ts","sourceRoot":"","sources":["../../../../bridge-core/src/ExecutionTree.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,MAAM,EACN,cAAc,EAMd,OAAO,
|
|
1
|
+
{"version":3,"file":"ExecutionTree.d.ts","sourceRoot":"","sources":["../../../../bridge-core/src/ExecutionTree.ts"],"names":[],"mappings":"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"}
|
|
@@ -23,6 +23,23 @@ const BREAK_SYM = Symbol.for("BRIDGE_BREAK");
|
|
|
23
23
|
/** Maximum shadow-tree nesting depth before a BridgePanicError is thrown. */
|
|
24
24
|
export const MAX_EXECUTION_DEPTH = 30;
|
|
25
25
|
const otelTracer = trace.getTracer("@stackables/bridge");
|
|
26
|
+
/**
|
|
27
|
+
* Lazily detect whether the OpenTelemetry tracer is a real (recording)
|
|
28
|
+
* tracer or the default no-op. Probed once on first tool call; result
|
|
29
|
+
* is cached for the lifetime of the process.
|
|
30
|
+
*
|
|
31
|
+
* If the SDK has not been registered by the time the first tool runs,
|
|
32
|
+
* all subsequent calls will skip OTel instrumentation.
|
|
33
|
+
*/
|
|
34
|
+
let _otelActive;
|
|
35
|
+
function isOtelActive() {
|
|
36
|
+
if (_otelActive === undefined) {
|
|
37
|
+
const probe = otelTracer.startSpan("_bridge_probe_");
|
|
38
|
+
_otelActive = probe.isRecording();
|
|
39
|
+
probe.end();
|
|
40
|
+
}
|
|
41
|
+
return _otelActive;
|
|
42
|
+
}
|
|
26
43
|
const otelMeter = metrics.getMeter("@stackables/bridge");
|
|
27
44
|
const toolCallCounter = otelMeter.createCounter("bridge.tool.calls", {
|
|
28
45
|
description: "Total number of tool invocations",
|
|
@@ -51,9 +68,17 @@ function sameTrunk(a, b) {
|
|
|
51
68
|
a.field === b.field &&
|
|
52
69
|
(a.instance ?? undefined) === (b.instance ?? undefined));
|
|
53
70
|
}
|
|
54
|
-
/** Strict path equality */
|
|
71
|
+
/** Strict path equality — manual loop avoids `.every()` closure allocation. See docs/performance.md (#7). */
|
|
55
72
|
function pathEquals(a, b) {
|
|
56
|
-
|
|
73
|
+
if (!a || !b)
|
|
74
|
+
return a === b;
|
|
75
|
+
if (a.length !== b.length)
|
|
76
|
+
return false;
|
|
77
|
+
for (let i = 0; i < a.length; i++) {
|
|
78
|
+
if (a[i] !== b[i])
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
57
82
|
}
|
|
58
83
|
/** Check whether an error is a fatal halt (abort or panic) that must bypass all error boundaries. */
|
|
59
84
|
function isFatalError(err) {
|
|
@@ -62,6 +87,37 @@ function isFatalError(err) {
|
|
|
62
87
|
err?.name === "BridgeAbortError" ||
|
|
63
88
|
err?.name === "BridgePanicError");
|
|
64
89
|
}
|
|
90
|
+
/** Returns `true` when `value` is a thenable (Promise or Promise-like). */
|
|
91
|
+
function isPromise(value) {
|
|
92
|
+
return typeof value?.then === "function";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Returns the `from` NodeRef when a wire qualifies for the simple-pull fast
|
|
96
|
+
* path (single `from` wire, no safe/falsy/nullish/catch modifiers). Returns
|
|
97
|
+
* `null` otherwise. The result is cached on the wire object so subsequent
|
|
98
|
+
* calls are a single property read. See docs/performance.md (#11).
|
|
99
|
+
*/
|
|
100
|
+
function getSimplePullRef(w) {
|
|
101
|
+
let ref = w.__simplePullRef;
|
|
102
|
+
if (ref !== undefined)
|
|
103
|
+
return ref;
|
|
104
|
+
ref =
|
|
105
|
+
"from" in w &&
|
|
106
|
+
!w.safe &&
|
|
107
|
+
!w.falsyFallbackRefs?.length &&
|
|
108
|
+
w.falsyControl == null &&
|
|
109
|
+
w.falsyFallback == null &&
|
|
110
|
+
w.nullishControl == null &&
|
|
111
|
+
!w.nullishFallbackRef &&
|
|
112
|
+
w.nullishFallback == null &&
|
|
113
|
+
!w.catchControl &&
|
|
114
|
+
!w.catchFallbackRef &&
|
|
115
|
+
w.catchFallback == null
|
|
116
|
+
? w.from
|
|
117
|
+
: null;
|
|
118
|
+
w.__simplePullRef = ref;
|
|
119
|
+
return ref;
|
|
120
|
+
}
|
|
65
121
|
/** Execute a control flow instruction, returning a sentinel or throwing. */
|
|
66
122
|
function applyControlFlow(ctrl) {
|
|
67
123
|
if (ctrl.kind === "throw")
|
|
@@ -126,14 +182,29 @@ export class TraceCollector {
|
|
|
126
182
|
* "true" → true, "false" → false, "null" → null, "42" → 42
|
|
127
183
|
* Plain strings that aren't valid JSON (like "hello", "/search") fall
|
|
128
184
|
* through and are returned as-is.
|
|
185
|
+
*
|
|
186
|
+
* Results are cached in a module-level Map because the same constant
|
|
187
|
+
* strings appear repeatedly across shadow trees. Only safe for
|
|
188
|
+
* immutable values (primitives); callers must not mutate the returned
|
|
189
|
+
* value. See docs/performance.md (#6).
|
|
129
190
|
*/
|
|
191
|
+
const constantCache = new Map();
|
|
130
192
|
function coerceConstant(raw) {
|
|
193
|
+
const cached = constantCache.get(raw);
|
|
194
|
+
if (cached !== undefined)
|
|
195
|
+
return cached;
|
|
196
|
+
let result;
|
|
131
197
|
try {
|
|
132
|
-
|
|
198
|
+
result = JSON.parse(raw);
|
|
133
199
|
}
|
|
134
200
|
catch {
|
|
135
|
-
|
|
201
|
+
result = raw;
|
|
136
202
|
}
|
|
203
|
+
// Hard cap to prevent unbounded growth over long-lived processes.
|
|
204
|
+
if (constantCache.size > 10_000)
|
|
205
|
+
constantCache.clear();
|
|
206
|
+
constantCache.set(raw, result);
|
|
207
|
+
return result;
|
|
137
208
|
}
|
|
138
209
|
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
139
210
|
function setNested(obj, path, value) {
|
|
@@ -181,6 +252,8 @@ export class ExecutionTree {
|
|
|
181
252
|
toolFns;
|
|
182
253
|
/** Shadow-tree nesting depth (0 for root). */
|
|
183
254
|
depth;
|
|
255
|
+
/** Pre-computed `trunkKey({ ...this.trunk, element: true })`. See docs/performance.md (#4). */
|
|
256
|
+
elementTrunkKey;
|
|
184
257
|
constructor(trunk, document, toolFns, context, parent) {
|
|
185
258
|
this.trunk = trunk;
|
|
186
259
|
this.document = document;
|
|
@@ -190,6 +263,7 @@ export class ExecutionTree {
|
|
|
190
263
|
if (this.depth > MAX_EXECUTION_DEPTH) {
|
|
191
264
|
throw new BridgePanicError(`Maximum execution depth exceeded (${this.depth}) at ${trunkKey(trunk)}. Check for infinite recursion or circular array mappings.`);
|
|
192
265
|
}
|
|
266
|
+
this.elementTrunkKey = `${trunk.module}:${trunk.type}:${trunk.field}:*`;
|
|
193
267
|
this.toolFns = { internal, ...(toolFns ?? {}) };
|
|
194
268
|
const instructions = document.instructions;
|
|
195
269
|
this.bridge = instructions.find((i) => i.kind === "bridge" && i.type === trunk.type && i.field === trunk.field);
|
|
@@ -482,117 +556,161 @@ export class ExecutionTree {
|
|
|
482
556
|
return this.parent.schedule(target, pullChain);
|
|
483
557
|
}
|
|
484
558
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
for (const w of bridgeWires) {
|
|
512
|
-
const key = w.to.path.join(".");
|
|
513
|
-
let group = wireGroups.get(key);
|
|
514
|
-
if (!group) {
|
|
515
|
-
group = [];
|
|
516
|
-
wireGroups.set(key, group);
|
|
517
|
-
}
|
|
518
|
-
group.push(w);
|
|
519
|
-
}
|
|
520
|
-
const groupEntries = Array.from(wireGroups.entries());
|
|
521
|
-
const resolved = await Promise.all(groupEntries.map(async ([, group]) => {
|
|
522
|
-
const value = await this.resolveWires(group, pullChain);
|
|
523
|
-
return [group[0].to.path, value];
|
|
524
|
-
}));
|
|
525
|
-
for (const [path, value] of resolved) {
|
|
526
|
-
if (path.length === 0 && value != null && typeof value === "object") {
|
|
527
|
-
Object.assign(input, value);
|
|
528
|
-
}
|
|
529
|
-
else {
|
|
530
|
-
setNested(input, path, value);
|
|
531
|
-
}
|
|
559
|
+
// ── Sync work: collect and group bridge wires ─────────────────
|
|
560
|
+
// If this target is a pipe fork, also apply bridge wires from its base
|
|
561
|
+
// handle (non-pipe wires, e.g. `c.currency <- i.currency`) as defaults
|
|
562
|
+
// before the fork-specific pipe wires.
|
|
563
|
+
const targetKey = trunkKey(target);
|
|
564
|
+
const pipeFork = this.pipeHandleMap?.get(targetKey);
|
|
565
|
+
const baseTrunk = pipeFork?.baseTrunk;
|
|
566
|
+
const baseWires = baseTrunk
|
|
567
|
+
? (this.bridge?.wires.filter((w) => !("pipe" in w) && sameTrunk(w.to, baseTrunk)) ?? [])
|
|
568
|
+
: [];
|
|
569
|
+
// Fork-specific wires (pipe wires targeting the fork's own instance)
|
|
570
|
+
const forkWires = this.bridge?.wires.filter((w) => sameTrunk(w.to, target)) ?? [];
|
|
571
|
+
// Merge: base provides defaults, fork overrides
|
|
572
|
+
const bridgeWires = [...baseWires, ...forkWires];
|
|
573
|
+
// Look up ToolDef for this target
|
|
574
|
+
const toolName = this.getToolName(target);
|
|
575
|
+
const toolDef = this.resolveToolDefByName(toolName);
|
|
576
|
+
// Group wires by target path so that || (null-fallback) and ??
|
|
577
|
+
// (error-fallback) semantics are honoured via resolveWires().
|
|
578
|
+
const wireGroups = new Map();
|
|
579
|
+
for (const w of bridgeWires) {
|
|
580
|
+
const key = w.to.path.join(".");
|
|
581
|
+
let group = wireGroups.get(key);
|
|
582
|
+
if (!group) {
|
|
583
|
+
group = [];
|
|
584
|
+
wireGroups.set(key, group);
|
|
532
585
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
586
|
+
group.push(w);
|
|
587
|
+
}
|
|
588
|
+
// ── Async path: tool definition requires resolveToolWires + callTool ──
|
|
589
|
+
if (toolDef) {
|
|
590
|
+
return this.scheduleToolDef(toolName, toolDef, wireGroups, pullChain);
|
|
591
|
+
}
|
|
592
|
+
// ── Sync-capable path: no tool definition ──
|
|
593
|
+
// For __local bindings, __define_ pass-throughs, pipe forks backed by
|
|
594
|
+
// sync tools, and logic nodes — resolve bridge wires and return
|
|
595
|
+
// synchronously when all sources are already in state.
|
|
596
|
+
// See docs/performance.md (#12).
|
|
597
|
+
const groupEntries = Array.from(wireGroups.entries());
|
|
598
|
+
const nGroups = groupEntries.length;
|
|
599
|
+
const values = new Array(nGroups);
|
|
600
|
+
let hasAsync = false;
|
|
601
|
+
for (let i = 0; i < nGroups; i++) {
|
|
602
|
+
const v = this.resolveWires(groupEntries[i][1], pullChain);
|
|
603
|
+
values[i] = v;
|
|
604
|
+
if (!hasAsync && isPromise(v))
|
|
605
|
+
hasAsync = true;
|
|
606
|
+
}
|
|
607
|
+
if (!hasAsync) {
|
|
608
|
+
return this.scheduleFinish(target, toolName, groupEntries, values, baseTrunk);
|
|
609
|
+
}
|
|
610
|
+
return Promise.all(values).then((resolved) => this.scheduleFinish(target, toolName, groupEntries, resolved, baseTrunk));
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Assemble input from resolved wire values and either invoke a direct tool
|
|
614
|
+
* function or return the data for pass-through targets (local/define/logic).
|
|
615
|
+
* Returns synchronously when the tool function (if any) returns sync.
|
|
616
|
+
* See docs/performance.md (#12).
|
|
617
|
+
*/
|
|
618
|
+
scheduleFinish(target, toolName, groupEntries, resolvedValues, baseTrunk) {
|
|
619
|
+
const input = {};
|
|
620
|
+
const resolved = [];
|
|
621
|
+
for (let i = 0; i < groupEntries.length; i++) {
|
|
622
|
+
const path = groupEntries[i][1][0].to.path;
|
|
623
|
+
const value = resolvedValues[i];
|
|
624
|
+
resolved.push([path, value]);
|
|
625
|
+
if (path.length === 0 && value != null && typeof value === "object") {
|
|
626
|
+
Object.assign(input, value);
|
|
550
627
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
// (e.g. "std.str.toLowerCase@999.1") so user-injected overrides win.
|
|
554
|
-
// For pipe forks, fall back to the baseTrunk's version since forks
|
|
555
|
-
// use synthetic instance numbers (100000+).
|
|
556
|
-
const handleVersion = this.handleVersionMap.get(trunkKey(target)) ??
|
|
557
|
-
(baseTrunk
|
|
558
|
-
? this.handleVersionMap.get(trunkKey(baseTrunk))
|
|
559
|
-
: undefined);
|
|
560
|
-
let directFn = handleVersion
|
|
561
|
-
? this.lookupToolFn(`${toolName}@${handleVersion}`)
|
|
562
|
-
: undefined;
|
|
563
|
-
if (!directFn) {
|
|
564
|
-
directFn = this.lookupToolFn(toolName);
|
|
628
|
+
else {
|
|
629
|
+
setNested(input, path, value);
|
|
565
630
|
}
|
|
566
|
-
|
|
567
|
-
|
|
631
|
+
}
|
|
632
|
+
// Direct tool function lookup by name (simple or dotted).
|
|
633
|
+
// When the handle carries a @version tag, try the versioned key first
|
|
634
|
+
// (e.g. "std.str.toLowerCase@999.1") so user-injected overrides win.
|
|
635
|
+
// For pipe forks, fall back to the baseTrunk's version since forks
|
|
636
|
+
// use synthetic instance numbers (100000+).
|
|
637
|
+
const handleVersion = this.handleVersionMap.get(trunkKey(target)) ??
|
|
638
|
+
(baseTrunk ? this.handleVersionMap.get(trunkKey(baseTrunk)) : undefined);
|
|
639
|
+
let directFn = handleVersion
|
|
640
|
+
? this.lookupToolFn(`${toolName}@${handleVersion}`)
|
|
641
|
+
: undefined;
|
|
642
|
+
if (!directFn) {
|
|
643
|
+
directFn = this.lookupToolFn(toolName);
|
|
644
|
+
}
|
|
645
|
+
if (directFn) {
|
|
646
|
+
return this.callTool(toolName, toolName, directFn, input);
|
|
647
|
+
}
|
|
648
|
+
// Define pass-through: synthetic trunks created by define inlining
|
|
649
|
+
// act as data containers — bridge wires set their values, no tool needed.
|
|
650
|
+
if (target.module.startsWith("__define_")) {
|
|
651
|
+
return input;
|
|
652
|
+
}
|
|
653
|
+
// Local binding or logic node: the wire resolves the source and stores
|
|
654
|
+
// the result — no tool call needed. For path=[] wires the resolved
|
|
655
|
+
// value may be a primitive (boolean from condAnd/condOr, string from
|
|
656
|
+
// a pipe tool like upperCase), so return the resolved value directly.
|
|
657
|
+
if (target.module === "__local" ||
|
|
658
|
+
target.field === "__and" ||
|
|
659
|
+
target.field === "__or") {
|
|
660
|
+
for (const [path, value] of resolved) {
|
|
661
|
+
if (path.length === 0)
|
|
662
|
+
return value;
|
|
568
663
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
664
|
+
return input;
|
|
665
|
+
}
|
|
666
|
+
throw new Error(`No tool found for "${toolName}"`);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Full async schedule path for targets backed by a ToolDef.
|
|
670
|
+
* Resolves tool wires, bridge wires, and invokes the tool function
|
|
671
|
+
* with error recovery support.
|
|
672
|
+
*/
|
|
673
|
+
async scheduleToolDef(toolName, toolDef, wireGroups, pullChain) {
|
|
674
|
+
// Build input object: tool wires first (base), then bridge wires (override)
|
|
675
|
+
const input = {};
|
|
676
|
+
await this.resolveToolWires(toolDef, input);
|
|
677
|
+
// Resolve bridge wires and apply on top
|
|
678
|
+
const groupEntries = Array.from(wireGroups.entries());
|
|
679
|
+
const resolved = await Promise.all(groupEntries.map(async ([, group]) => {
|
|
680
|
+
const value = await this.resolveWires(group, pullChain);
|
|
681
|
+
return [group[0].to.path, value];
|
|
682
|
+
}));
|
|
683
|
+
for (const [path, value] of resolved) {
|
|
684
|
+
if (path.length === 0 && value != null && typeof value === "object") {
|
|
685
|
+
Object.assign(input, value);
|
|
573
686
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
// value may be a primitive (boolean from condAnd/condOr, string from
|
|
577
|
-
// a pipe tool like upperCase), so return the resolved value directly.
|
|
578
|
-
if (target.module === "__local" ||
|
|
579
|
-
target.field === "__and" ||
|
|
580
|
-
target.field === "__or") {
|
|
581
|
-
for (const [path, value] of resolved) {
|
|
582
|
-
if (path.length === 0)
|
|
583
|
-
return value;
|
|
584
|
-
}
|
|
585
|
-
return input;
|
|
687
|
+
else {
|
|
688
|
+
setNested(input, path, value);
|
|
586
689
|
}
|
|
587
|
-
|
|
588
|
-
|
|
690
|
+
}
|
|
691
|
+
// Call ToolDef-backed tool function
|
|
692
|
+
const fn = this.lookupToolFn(toolDef.fn);
|
|
693
|
+
if (!fn)
|
|
694
|
+
throw new Error(`Tool function "${toolDef.fn}" not registered`);
|
|
695
|
+
// on error: wrap the tool call with fallback from onError wire
|
|
696
|
+
const onErrorWire = toolDef.wires.find((w) => w.kind === "onError");
|
|
697
|
+
try {
|
|
698
|
+
return await this.callTool(toolName, toolDef.fn, fn, input);
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
if (!onErrorWire)
|
|
702
|
+
throw err;
|
|
703
|
+
if ("value" in onErrorWire)
|
|
704
|
+
return JSON.parse(onErrorWire.value);
|
|
705
|
+
return this.resolveToolSource(onErrorWire.source, toolDef);
|
|
706
|
+
}
|
|
589
707
|
}
|
|
590
708
|
/**
|
|
591
709
|
* Invoke a tool function, recording both an OpenTelemetry span and (when
|
|
592
710
|
* tracing is enabled) a ToolTrace entry. All three tool-call sites in the
|
|
593
711
|
* engine delegate here so instrumentation lives in exactly one place.
|
|
594
712
|
*/
|
|
595
|
-
|
|
713
|
+
callTool(toolName, fnName, fnImpl, input) {
|
|
596
714
|
// Short-circuit before starting if externally aborted
|
|
597
715
|
if (this.signal?.aborted) {
|
|
598
716
|
throw new BridgeAbortError();
|
|
@@ -603,6 +721,15 @@ export class ExecutionTree {
|
|
|
603
721
|
logger: logger ?? {},
|
|
604
722
|
signal: this.signal,
|
|
605
723
|
};
|
|
724
|
+
// ── Fast path: no instrumentation configured ──────────────────
|
|
725
|
+
// When there is no internal tracer, no logger, and OpenTelemetry
|
|
726
|
+
// has its default no-op provider, skip all instrumentation to
|
|
727
|
+
// avoid closure allocation, template-string building, and no-op
|
|
728
|
+
// metric calls. See docs/performance.md (#5).
|
|
729
|
+
if (!tracer && !logger && !isOtelActive()) {
|
|
730
|
+
return fnImpl(input, toolContext);
|
|
731
|
+
}
|
|
732
|
+
// ── Instrumented path ─────────────────────────────────────────
|
|
606
733
|
const traceStart = tracer?.now();
|
|
607
734
|
const metricAttrs = {
|
|
608
735
|
"bridge.tool.name": toolName,
|
|
@@ -657,7 +784,26 @@ export class ExecutionTree {
|
|
|
657
784
|
});
|
|
658
785
|
}
|
|
659
786
|
shadow() {
|
|
660
|
-
|
|
787
|
+
// Lightweight: bypass the constructor to avoid redundant work that
|
|
788
|
+
// re-derives data identical to the parent (bridge lookup, pipeHandleMap,
|
|
789
|
+
// handleVersionMap, constObj, toolFns spread). See docs/performance.md (#2).
|
|
790
|
+
const child = Object.create(ExecutionTree.prototype);
|
|
791
|
+
child.trunk = this.trunk;
|
|
792
|
+
child.document = this.document;
|
|
793
|
+
child.parent = this;
|
|
794
|
+
child.depth = this.depth + 1;
|
|
795
|
+
if (child.depth > MAX_EXECUTION_DEPTH) {
|
|
796
|
+
throw new BridgePanicError(`Maximum execution depth exceeded (${child.depth}) at ${trunkKey(this.trunk)}. Check for infinite recursion or circular array mappings.`);
|
|
797
|
+
}
|
|
798
|
+
child.state = {};
|
|
799
|
+
child.toolDepCache = new Map();
|
|
800
|
+
child.toolDefCache = new Map();
|
|
801
|
+
// Share read-only pre-computed data from parent
|
|
802
|
+
child.bridge = this.bridge;
|
|
803
|
+
child.pipeHandleMap = this.pipeHandleMap;
|
|
804
|
+
child.handleVersionMap = this.handleVersionMap;
|
|
805
|
+
child.toolFns = this.toolFns;
|
|
806
|
+
child.elementTrunkKey = this.elementTrunkKey;
|
|
661
807
|
child.tracer = this.tracer;
|
|
662
808
|
child.logger = this.logger;
|
|
663
809
|
child.signal = this.signal;
|
|
@@ -667,11 +813,47 @@ export class ExecutionTree {
|
|
|
667
813
|
getTraces() {
|
|
668
814
|
return this.tracer?.traces ?? [];
|
|
669
815
|
}
|
|
670
|
-
|
|
671
|
-
|
|
816
|
+
/**
|
|
817
|
+
* Traverse `ref.path` on an already-resolved value, respecting null guards.
|
|
818
|
+
* Extracted from `pullSingle` so the sync and async paths can share logic.
|
|
819
|
+
*/
|
|
820
|
+
applyPath(resolved, ref) {
|
|
821
|
+
if (!ref.path.length)
|
|
822
|
+
return resolved;
|
|
823
|
+
let result = resolved;
|
|
824
|
+
// Root-level null check
|
|
825
|
+
if (result == null) {
|
|
826
|
+
if (ref.rootSafe)
|
|
827
|
+
return undefined;
|
|
828
|
+
throw new TypeError(`Cannot read properties of ${result} (reading '${ref.path[0]}')`);
|
|
829
|
+
}
|
|
830
|
+
for (let i = 0; i < ref.path.length; i++) {
|
|
831
|
+
const segment = ref.path[i];
|
|
832
|
+
if (UNSAFE_KEYS.has(segment))
|
|
833
|
+
throw new Error(`Unsafe property traversal: ${segment}`);
|
|
834
|
+
if (Array.isArray(result) && !/^\d+$/.test(segment)) {
|
|
835
|
+
this.logger?.warn?.(`[bridge] Accessing ".${segment}" on an array (${result.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`);
|
|
836
|
+
}
|
|
837
|
+
result = result[segment];
|
|
838
|
+
if (result == null && i < ref.path.length - 1) {
|
|
839
|
+
const nextSafe = ref.pathSafe?.[i + 1] ?? false;
|
|
840
|
+
if (nextSafe)
|
|
841
|
+
return undefined;
|
|
842
|
+
throw new TypeError(`Cannot read properties of ${result} (reading '${ref.path[i + 1]}')`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return result;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Pull a single value. Returns synchronously when already in state;
|
|
849
|
+
* returns a Promise only when the value is a pending tool call.
|
|
850
|
+
* See docs/performance.md (#10).
|
|
851
|
+
*/
|
|
852
|
+
pullSingle(ref, pullChain = new Set()) {
|
|
853
|
+
// Cache trunkKey on the NodeRef to avoid repeated string allocation
|
|
854
|
+
// for the same AST node. See docs/performance.md (#11).
|
|
855
|
+
const key = (ref.__key ??= trunkKey(ref));
|
|
672
856
|
// ── Cycle detection ─────────────────────────────────────────────
|
|
673
|
-
// If this exact key is already in our active pull chain, it is a
|
|
674
|
-
// circular dependency that would deadlock (await-on-self).
|
|
675
857
|
if (pullChain.has(key)) {
|
|
676
858
|
throw new BridgePanicError(`Circular dependency detected: "${key}" depends on itself`);
|
|
677
859
|
}
|
|
@@ -693,41 +875,19 @@ export class ExecutionTree {
|
|
|
693
875
|
if (ref.path.length > 0 && ref.module.startsWith("__define_")) {
|
|
694
876
|
const fieldWires = this.bridge?.wires.filter((w) => sameTrunk(w.to, ref) && pathEquals(w.to.path, ref.path)) ?? [];
|
|
695
877
|
if (fieldWires.length > 0) {
|
|
878
|
+
// resolveWires already delivers the value at ref.path — no applyPath.
|
|
696
879
|
return this.resolveWires(fieldWires, nextChain);
|
|
697
880
|
}
|
|
698
881
|
}
|
|
699
882
|
this.state[key] = this.schedule(ref, nextChain);
|
|
700
|
-
value = this.state[key];
|
|
701
|
-
}
|
|
702
|
-
// Always await in case the stored value is a Promise (e.g. from schedule()).
|
|
703
|
-
const resolved = await Promise.resolve(value);
|
|
704
|
-
if (!ref.path.length) {
|
|
705
|
-
return resolved;
|
|
883
|
+
value = this.state[key]; // sync value or Promise (see #12)
|
|
706
884
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if (ref.rootSafe)
|
|
711
|
-
return undefined;
|
|
712
|
-
throw new TypeError(`Cannot read properties of ${result} (reading '${ref.path[0]}')`);
|
|
885
|
+
// Sync fast path: value is already resolved (not a pending Promise).
|
|
886
|
+
if (!isPromise(value)) {
|
|
887
|
+
return this.applyPath(value, ref);
|
|
713
888
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (UNSAFE_KEYS.has(segment))
|
|
717
|
-
throw new Error(`Unsafe property traversal: ${segment}`);
|
|
718
|
-
if (Array.isArray(result) && !/^\d+$/.test(segment)) {
|
|
719
|
-
this.logger?.warn?.(`[bridge] Accessing ".${segment}" on an array (${result.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`);
|
|
720
|
-
}
|
|
721
|
-
result = result[segment];
|
|
722
|
-
// Check for null/undefined AFTER access, before next segment
|
|
723
|
-
if (result == null && i < ref.path.length - 1) {
|
|
724
|
-
const nextSafe = ref.pathSafe?.[i + 1] ?? false;
|
|
725
|
-
if (nextSafe)
|
|
726
|
-
return undefined;
|
|
727
|
-
throw new TypeError(`Cannot read properties of ${result} (reading '${ref.path[i + 1]}')`);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
return result;
|
|
889
|
+
// Async: chain path traversal onto the pending promise.
|
|
890
|
+
return value.then((resolved) => this.applyPath(resolved, ref));
|
|
731
891
|
}
|
|
732
892
|
push(args) {
|
|
733
893
|
this.state[trunkKey(this.trunk)] = args;
|
|
@@ -800,7 +960,26 @@ export class ExecutionTree {
|
|
|
800
960
|
* After layers 1–2b, the overdefinition boundary (`!= null`) decides whether
|
|
801
961
|
* to return or continue to the next wire.
|
|
802
962
|
*/
|
|
803
|
-
|
|
963
|
+
/**
|
|
964
|
+
* Resolve wires, returning synchronously when the hot path allows it.
|
|
965
|
+
*
|
|
966
|
+
* Fast path: single `from` wire with no fallback/catch modifiers, which is
|
|
967
|
+
* the common case for element field wires like `.id <- it.id`. Delegates to
|
|
968
|
+
* `resolveWiresAsync` for anything more complex.
|
|
969
|
+
* See docs/performance.md (#10).
|
|
970
|
+
*/
|
|
971
|
+
resolveWires(wires, pullChain) {
|
|
972
|
+
if (wires.length === 1) {
|
|
973
|
+
const w = wires[0];
|
|
974
|
+
if ("value" in w)
|
|
975
|
+
return coerceConstant(w.value);
|
|
976
|
+
const ref = getSimplePullRef(w);
|
|
977
|
+
if (ref)
|
|
978
|
+
return this.pullSingle(ref, pullChain);
|
|
979
|
+
}
|
|
980
|
+
return this.resolveWiresAsync(wires, pullChain);
|
|
981
|
+
}
|
|
982
|
+
async resolveWiresAsync(wires, pullChain) {
|
|
804
983
|
let lastError;
|
|
805
984
|
for (const w of wires) {
|
|
806
985
|
// Constant wire — always wins, no modifiers
|
|
@@ -984,11 +1163,110 @@ export class ExecutionTree {
|
|
|
984
1163
|
if (item === CONTINUE_SYM)
|
|
985
1164
|
continue;
|
|
986
1165
|
const s = this.shadow();
|
|
987
|
-
s.state[
|
|
1166
|
+
s.state[this.elementTrunkKey] = item;
|
|
988
1167
|
finalShadowTrees.push(s);
|
|
989
1168
|
}
|
|
990
1169
|
return finalShadowTrees;
|
|
991
1170
|
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Resolve pre-grouped wires on this shadow tree without re-filtering.
|
|
1173
|
+
* Called by the parent's `materializeShadows` to skip per-element wire
|
|
1174
|
+
* filtering. Returns synchronously when the wire resolves sync (hot path).
|
|
1175
|
+
* See docs/performance.md (#8, #10).
|
|
1176
|
+
*/
|
|
1177
|
+
resolvePreGrouped(wires) {
|
|
1178
|
+
return this.resolveWires(wires);
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Materialise all output wires into a plain JS object.
|
|
1182
|
+
*
|
|
1183
|
+
* Used by the GraphQL adapter when a bridge field returns a scalar type
|
|
1184
|
+
* (e.g. `JSON`, `JSONObject`). In that case GraphQL won't call sub-field
|
|
1185
|
+
* resolvers, so we need to eagerly resolve every output wire and assemble
|
|
1186
|
+
* the result ourselves — the same logic `run()` uses for object output.
|
|
1187
|
+
*/
|
|
1188
|
+
async collectOutput() {
|
|
1189
|
+
const bridge = this.bridge;
|
|
1190
|
+
if (!bridge)
|
|
1191
|
+
return undefined;
|
|
1192
|
+
const { type, field } = this.trunk;
|
|
1193
|
+
// Shadow tree (array element) — resolve element-level output fields.
|
|
1194
|
+
// For scalar arrays ([JSON!]) GraphQL won't call sub-field resolvers,
|
|
1195
|
+
// so we eagerly materialise each element here.
|
|
1196
|
+
if (this.parent) {
|
|
1197
|
+
const outputFields = new Set();
|
|
1198
|
+
for (const wire of bridge.wires) {
|
|
1199
|
+
if (wire.to.module === SELF_MODULE &&
|
|
1200
|
+
wire.to.type === type &&
|
|
1201
|
+
wire.to.field === field &&
|
|
1202
|
+
wire.to.path.length > 0) {
|
|
1203
|
+
outputFields.add(wire.to.path[0]);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (outputFields.size > 0) {
|
|
1207
|
+
const result = {};
|
|
1208
|
+
await Promise.all([...outputFields].map(async (name) => {
|
|
1209
|
+
result[name] = await this.pullOutputField([name]);
|
|
1210
|
+
}));
|
|
1211
|
+
return result;
|
|
1212
|
+
}
|
|
1213
|
+
// Passthrough: return stored element data directly
|
|
1214
|
+
return this.state[this.elementTrunkKey];
|
|
1215
|
+
}
|
|
1216
|
+
// Root wire (`o <- src`) — whole-object passthrough
|
|
1217
|
+
const hasRootWire = bridge.wires.some((w) => "from" in w &&
|
|
1218
|
+
w.to.module === SELF_MODULE &&
|
|
1219
|
+
w.to.type === type &&
|
|
1220
|
+
w.to.field === field &&
|
|
1221
|
+
w.to.path.length === 0);
|
|
1222
|
+
if (hasRootWire) {
|
|
1223
|
+
return this.pullOutputField([]);
|
|
1224
|
+
}
|
|
1225
|
+
// Object output — collect unique top-level field names
|
|
1226
|
+
const outputFields = new Set();
|
|
1227
|
+
for (const wire of bridge.wires) {
|
|
1228
|
+
if (wire.to.module === SELF_MODULE &&
|
|
1229
|
+
wire.to.type === type &&
|
|
1230
|
+
wire.to.field === field &&
|
|
1231
|
+
wire.to.path.length > 0) {
|
|
1232
|
+
outputFields.add(wire.to.path[0]);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (outputFields.size === 0)
|
|
1236
|
+
return undefined;
|
|
1237
|
+
const result = {};
|
|
1238
|
+
const resolveField = async (prefix) => {
|
|
1239
|
+
const exactWires = bridge.wires.filter((w) => w.to.module === SELF_MODULE &&
|
|
1240
|
+
w.to.type === type &&
|
|
1241
|
+
w.to.field === field &&
|
|
1242
|
+
pathEquals(w.to.path, prefix));
|
|
1243
|
+
if (exactWires.length > 0) {
|
|
1244
|
+
return this.resolveWires(exactWires);
|
|
1245
|
+
}
|
|
1246
|
+
const subFields = new Set();
|
|
1247
|
+
for (const wire of bridge.wires) {
|
|
1248
|
+
const p = wire.to.path;
|
|
1249
|
+
if (wire.to.module === SELF_MODULE &&
|
|
1250
|
+
wire.to.type === type &&
|
|
1251
|
+
wire.to.field === field &&
|
|
1252
|
+
p.length > prefix.length &&
|
|
1253
|
+
prefix.every((seg, i) => p[i] === seg)) {
|
|
1254
|
+
subFields.add(p[prefix.length]);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (subFields.size === 0)
|
|
1258
|
+
return undefined;
|
|
1259
|
+
const obj = {};
|
|
1260
|
+
await Promise.all([...subFields].map(async (sub) => {
|
|
1261
|
+
obj[sub] = await resolveField([...prefix, sub]);
|
|
1262
|
+
}));
|
|
1263
|
+
return obj;
|
|
1264
|
+
};
|
|
1265
|
+
await Promise.all([...outputFields].map(async (name) => {
|
|
1266
|
+
result[name] = await resolveField([name]);
|
|
1267
|
+
}));
|
|
1268
|
+
return result;
|
|
1269
|
+
}
|
|
992
1270
|
/**
|
|
993
1271
|
* Execute the bridge end-to-end without GraphQL.
|
|
994
1272
|
*
|
|
@@ -1108,6 +1386,9 @@ export class ExecutionTree {
|
|
|
1108
1386
|
const { type, field } = this.trunk;
|
|
1109
1387
|
const directFields = new Set();
|
|
1110
1388
|
const deepPaths = new Map();
|
|
1389
|
+
// #8: Pre-group wires by exact path — eliminates per-element re-filtering.
|
|
1390
|
+
// Key: wire.to.path joined by \0 (null char is safe — field names are identifiers).
|
|
1391
|
+
const wireGroupsByPath = new Map();
|
|
1111
1392
|
for (const wire of wires) {
|
|
1112
1393
|
const p = wire.to.path;
|
|
1113
1394
|
if (wire.to.module !== SELF_MODULE ||
|
|
@@ -1121,6 +1402,13 @@ export class ExecutionTree {
|
|
|
1121
1402
|
const name = p[pathPrefix.length];
|
|
1122
1403
|
if (p.length === pathPrefix.length + 1) {
|
|
1123
1404
|
directFields.add(name);
|
|
1405
|
+
const pathKey = p.join("\0");
|
|
1406
|
+
let group = wireGroupsByPath.get(pathKey);
|
|
1407
|
+
if (!group) {
|
|
1408
|
+
group = [];
|
|
1409
|
+
wireGroupsByPath.set(pathKey, group);
|
|
1410
|
+
}
|
|
1411
|
+
group.push(wire);
|
|
1124
1412
|
}
|
|
1125
1413
|
else {
|
|
1126
1414
|
let arr = deepPaths.get(name);
|
|
@@ -1131,6 +1419,63 @@ export class ExecutionTree {
|
|
|
1131
1419
|
arr.push(p);
|
|
1132
1420
|
}
|
|
1133
1421
|
}
|
|
1422
|
+
// #9/#10: Fast path — no nested arrays, only direct fields.
|
|
1423
|
+
// Collect all (shadow × field) resolutions. When every value is already in
|
|
1424
|
+
// state (the hot case for element passthrough), resolvePreGrouped returns
|
|
1425
|
+
// synchronously and we skip Promise.all entirely.
|
|
1426
|
+
// See docs/performance.md (#9, #10).
|
|
1427
|
+
if (deepPaths.size === 0) {
|
|
1428
|
+
const directFieldArray = [...directFields];
|
|
1429
|
+
const nFields = directFieldArray.length;
|
|
1430
|
+
const nItems = items.length;
|
|
1431
|
+
// Pre-compute pathKeys and wire groups — only depend on j, not i.
|
|
1432
|
+
// See docs/performance.md (#11).
|
|
1433
|
+
const preGroups = new Array(nFields);
|
|
1434
|
+
for (let j = 0; j < nFields; j++) {
|
|
1435
|
+
const pathKey = [...pathPrefix, directFieldArray[j]].join("\0");
|
|
1436
|
+
preGroups[j] = wireGroupsByPath.get(pathKey);
|
|
1437
|
+
}
|
|
1438
|
+
const rawValues = new Array(nItems * nFields);
|
|
1439
|
+
let hasAsync = false;
|
|
1440
|
+
for (let i = 0; i < nItems; i++) {
|
|
1441
|
+
const shadow = items[i];
|
|
1442
|
+
for (let j = 0; j < nFields; j++) {
|
|
1443
|
+
const v = shadow.resolvePreGrouped(preGroups[j]);
|
|
1444
|
+
rawValues[i * nFields + j] = v;
|
|
1445
|
+
if (!hasAsync && isPromise(v))
|
|
1446
|
+
hasAsync = true;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
const flatValues = hasAsync
|
|
1450
|
+
? await Promise.all(rawValues)
|
|
1451
|
+
: rawValues;
|
|
1452
|
+
const finalResults = [];
|
|
1453
|
+
for (let i = 0; i < items.length; i++) {
|
|
1454
|
+
const obj = {};
|
|
1455
|
+
let doBreak = false;
|
|
1456
|
+
let doSkip = false;
|
|
1457
|
+
for (let j = 0; j < nFields; j++) {
|
|
1458
|
+
const v = flatValues[i * nFields + j];
|
|
1459
|
+
if (v === BREAK_SYM) {
|
|
1460
|
+
doBreak = true;
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
if (v === CONTINUE_SYM) {
|
|
1464
|
+
doSkip = true;
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
obj[directFieldArray[j]] = v;
|
|
1468
|
+
}
|
|
1469
|
+
if (doBreak)
|
|
1470
|
+
break;
|
|
1471
|
+
if (doSkip)
|
|
1472
|
+
continue;
|
|
1473
|
+
finalResults.push(obj);
|
|
1474
|
+
}
|
|
1475
|
+
return finalResults;
|
|
1476
|
+
}
|
|
1477
|
+
// Slow path: deep paths (nested arrays) present.
|
|
1478
|
+
// Uses pre-grouped wires for direct fields (#8), original logic for the rest.
|
|
1134
1479
|
const rawResults = await Promise.all(items.map(async (shadow) => {
|
|
1135
1480
|
const obj = {};
|
|
1136
1481
|
const tasks = [];
|
|
@@ -1145,7 +1490,10 @@ export class ExecutionTree {
|
|
|
1145
1490
|
: children;
|
|
1146
1491
|
}
|
|
1147
1492
|
else {
|
|
1148
|
-
|
|
1493
|
+
// #8: wireGroupsByPath is built in the same branch that populates
|
|
1494
|
+
// directFields, so the group is always present — no fallback needed.
|
|
1495
|
+
const pathKey = fullPath.join("\0");
|
|
1496
|
+
obj[name] = await shadow.resolvePreGrouped(wireGroupsByPath.get(pathKey));
|
|
1149
1497
|
}
|
|
1150
1498
|
})());
|
|
1151
1499
|
}
|
|
@@ -1236,7 +1584,7 @@ export class ExecutionTree {
|
|
|
1236
1584
|
if (item === CONTINUE_SYM)
|
|
1237
1585
|
continue;
|
|
1238
1586
|
const s = this.shadow();
|
|
1239
|
-
s.state[
|
|
1587
|
+
s.state[this.elementTrunkKey] = item;
|
|
1240
1588
|
shadowTrees.push(s);
|
|
1241
1589
|
}
|
|
1242
1590
|
return shadowTrees;
|
|
@@ -1262,7 +1610,7 @@ export class ExecutionTree {
|
|
|
1262
1610
|
if (item === CONTINUE_SYM)
|
|
1263
1611
|
continue;
|
|
1264
1612
|
const s = this.shadow();
|
|
1265
|
-
s.state[
|
|
1613
|
+
s.state[this.elementTrunkKey] = item;
|
|
1266
1614
|
shadowTrees.push(s);
|
|
1267
1615
|
}
|
|
1268
1616
|
return shadowTrees;
|
|
@@ -1273,8 +1621,7 @@ export class ExecutionTree {
|
|
|
1273
1621
|
// where the bridge maps an inner array (e.g. `.stops <- j.stops`) but
|
|
1274
1622
|
// doesn't explicitly wire each scalar field on the element type.
|
|
1275
1623
|
if (this.parent) {
|
|
1276
|
-
const
|
|
1277
|
-
const elementData = this.state[elementKey];
|
|
1624
|
+
const elementData = this.state[this.elementTrunkKey];
|
|
1278
1625
|
if (elementData != null &&
|
|
1279
1626
|
typeof elementData === "object" &&
|
|
1280
1627
|
!Array.isArray(elementData)) {
|
|
@@ -1286,7 +1633,7 @@ export class ExecutionTree {
|
|
|
1286
1633
|
// resolve their own fields via this same fallback path.
|
|
1287
1634
|
return value.map((item) => {
|
|
1288
1635
|
const s = this.shadow();
|
|
1289
|
-
s.state[
|
|
1636
|
+
s.state[this.elementTrunkKey] = item;
|
|
1290
1637
|
return s;
|
|
1291
1638
|
});
|
|
1292
1639
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackables/bridge-compiler",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Bridge DSL parser, serializer, and language service",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"chevrotain": "^11.1.2",
|
|
26
|
-
"@stackables/bridge-core": "1.0.
|
|
26
|
+
"@stackables/bridge-core": "1.0.2",
|
|
27
27
|
"@stackables/bridge-stdlib": "1.5.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|