@stackables/bridge 1.0.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.
@@ -0,0 +1,403 @@
1
+ import { parsePath } from "./bridge-format.js";
2
+ import { SELF_MODULE } from "./types.js";
3
+ /** Stable string key for the state map */
4
+ function trunkKey(ref) {
5
+ if (ref.element)
6
+ return `${ref.module}:${ref.type}:${ref.field}:*`;
7
+ return `${ref.module}:${ref.type}:${ref.field}${ref.instance != null ? `:${ref.instance}` : ""}`;
8
+ }
9
+ /** Match two trunks (ignoring path and element) */
10
+ function sameTrunk(a, b) {
11
+ return (a.module === b.module &&
12
+ a.type === b.type &&
13
+ a.field === b.field &&
14
+ (a.instance ?? undefined) === (b.instance ?? undefined));
15
+ }
16
+ /** Strict path equality */
17
+ function pathEquals(a, b) {
18
+ return a.length === b.length && a.every((v, i) => v === b[i]);
19
+ }
20
+ /** Set a value at a nested path, creating intermediate objects/arrays as needed */
21
+ function setNested(obj, path, value) {
22
+ for (let i = 0; i < path.length - 1; i++) {
23
+ const key = path[i];
24
+ const nextKey = path[i + 1];
25
+ if (obj[key] == null) {
26
+ obj[key] = /^\d+$/.test(nextKey) ? [] : {};
27
+ }
28
+ obj = obj[key];
29
+ }
30
+ if (path.length > 0) {
31
+ obj[path[path.length - 1]] = value;
32
+ }
33
+ }
34
+ export class ExecutionTree {
35
+ trunk;
36
+ instructions;
37
+ toolFns;
38
+ context;
39
+ parent;
40
+ state = {};
41
+ bridge;
42
+ toolDepCache = new Map();
43
+ toolDefCache = new Map();
44
+ pipeHandleMap;
45
+ constructor(trunk, instructions, toolFns, context, parent) {
46
+ this.trunk = trunk;
47
+ this.instructions = instructions;
48
+ this.toolFns = toolFns;
49
+ this.context = context;
50
+ this.parent = parent;
51
+ this.bridge = instructions.find((i) => i.kind === "bridge" && i.type === trunk.type && i.field === trunk.field);
52
+ if (this.bridge?.pipeHandles) {
53
+ this.pipeHandleMap = new Map(this.bridge.pipeHandles.map((ph) => [ph.key, ph]));
54
+ }
55
+ if (context) {
56
+ this.state[trunkKey({ module: SELF_MODULE, type: "Context", field: "context" })] = context;
57
+ }
58
+ // Collect const definitions into a single namespace object
59
+ const constObj = {};
60
+ for (const inst of instructions) {
61
+ if (inst.kind === "const") {
62
+ constObj[inst.name] = JSON.parse(inst.value);
63
+ }
64
+ }
65
+ if (Object.keys(constObj).length > 0) {
66
+ this.state[trunkKey({ module: SELF_MODULE, type: "Const", field: "const" })] = constObj;
67
+ }
68
+ }
69
+ /** Derive tool name from a trunk */
70
+ getToolName(target) {
71
+ if (target.module === SELF_MODULE)
72
+ return target.field;
73
+ return `${target.module}.${target.field}`;
74
+ }
75
+ /** Deep-lookup a tool function by dotted name (e.g. "std.upperCase").
76
+ * Falls back to a flat key lookup for backward compat (e.g. "hereapi.geocode" as literal key). */
77
+ lookupToolFn(name) {
78
+ if (name.includes(".")) {
79
+ // Try namespace traversal first
80
+ const parts = name.split(".");
81
+ let current = this.toolFns;
82
+ for (const part of parts) {
83
+ if (current == null || typeof current !== "object") {
84
+ current = undefined;
85
+ break;
86
+ }
87
+ current = current[part];
88
+ }
89
+ if (typeof current === "function")
90
+ return current;
91
+ // Fall back to flat key (e.g. "hereapi.geocode" as a literal property name)
92
+ const flat = this.toolFns?.[name];
93
+ return typeof flat === "function" ? flat : undefined;
94
+ }
95
+ // Try root level first
96
+ const fn = this.toolFns?.[name];
97
+ if (typeof fn === "function")
98
+ return fn;
99
+ // Fall back to std namespace (builtins are callable without std. prefix)
100
+ const stdFn = this.toolFns?.std?.[name];
101
+ return typeof stdFn === "function" ? stdFn : undefined;
102
+ }
103
+ /** Resolve a ToolDef by name, merging the extends chain (cached) */
104
+ resolveToolDefByName(name) {
105
+ if (this.toolDefCache.has(name))
106
+ return this.toolDefCache.get(name) ?? undefined;
107
+ const toolDefs = this.instructions.filter((i) => i.kind === "tool");
108
+ const base = toolDefs.find((t) => t.name === name);
109
+ if (!base) {
110
+ this.toolDefCache.set(name, null);
111
+ return undefined;
112
+ }
113
+ // Build extends chain: root → ... → leaf
114
+ const chain = [base];
115
+ let current = base;
116
+ while (current.extends) {
117
+ const parent = toolDefs.find((t) => t.name === current.extends);
118
+ if (!parent)
119
+ throw new Error(`Tool "${current.name}" extends unknown tool "${current.extends}"`);
120
+ chain.unshift(parent);
121
+ current = parent;
122
+ }
123
+ // Merge: root provides base, each child overrides
124
+ const merged = {
125
+ kind: "tool",
126
+ name,
127
+ fn: chain[0].fn, // fn from root ancestor
128
+ deps: [],
129
+ wires: [],
130
+ };
131
+ for (const def of chain) {
132
+ // Merge deps (dedupe by handle)
133
+ for (const dep of def.deps) {
134
+ if (!merged.deps.some((d) => d.handle === dep.handle)) {
135
+ merged.deps.push(dep);
136
+ }
137
+ }
138
+ // Merge wires (child overrides parent by target; onError replaces onError)
139
+ for (const wire of def.wires) {
140
+ if (wire.kind === "onError") {
141
+ const idx = merged.wires.findIndex((w) => w.kind === "onError");
142
+ if (idx >= 0)
143
+ merged.wires[idx] = wire;
144
+ else
145
+ merged.wires.push(wire);
146
+ }
147
+ else {
148
+ const idx = merged.wires.findIndex((w) => "target" in w && w.target === wire.target);
149
+ if (idx >= 0)
150
+ merged.wires[idx] = wire;
151
+ else
152
+ merged.wires.push(wire);
153
+ }
154
+ }
155
+ }
156
+ this.toolDefCache.set(name, merged);
157
+ return merged;
158
+ }
159
+ /** Resolve a tool definition's wires into a nested input object */
160
+ async resolveToolWires(toolDef, input) {
161
+ // Constants applied synchronously
162
+ for (const wire of toolDef.wires) {
163
+ if (wire.kind === "constant") {
164
+ setNested(input, parsePath(wire.target), wire.value);
165
+ }
166
+ }
167
+ // Pull wires resolved in parallel (independent deps shouldn't wait on each other)
168
+ const pullWires = toolDef.wires.filter((w) => w.kind === "pull");
169
+ if (pullWires.length > 0) {
170
+ const resolved = await Promise.all(pullWires.map(async (wire) => ({
171
+ target: wire.target,
172
+ value: await this.resolveToolSource(wire.source, toolDef),
173
+ })));
174
+ for (const { target, value } of resolved) {
175
+ setNested(input, parsePath(target), value);
176
+ }
177
+ }
178
+ }
179
+ /** Resolve a source reference from a tool wire against its dependencies */
180
+ async resolveToolSource(source, toolDef) {
181
+ const dotIdx = source.indexOf(".");
182
+ const handle = dotIdx === -1 ? source : source.substring(0, dotIdx);
183
+ const restPath = dotIdx === -1 ? [] : source.substring(dotIdx + 1).split(".");
184
+ const dep = toolDef.deps.find((d) => d.handle === handle);
185
+ if (!dep)
186
+ throw new Error(`Unknown source "${handle}" in tool "${toolDef.name}"`);
187
+ let value;
188
+ if (dep.kind === "context") {
189
+ value = this.context ?? this.parent?.context;
190
+ }
191
+ else if (dep.kind === "const") {
192
+ value = this.state[trunkKey({ module: SELF_MODULE, type: "Const", field: "const" })] ?? this.parent?.state[trunkKey({ module: SELF_MODULE, type: "Const", field: "const" })];
193
+ }
194
+ else if (dep.kind === "tool") {
195
+ value = await this.resolveToolDep(dep.tool);
196
+ }
197
+ for (const segment of restPath) {
198
+ value = value?.[segment];
199
+ }
200
+ return value;
201
+ }
202
+ /** Call a tool dependency (cached per request) */
203
+ resolveToolDep(toolName) {
204
+ // Check parent first (shadow trees delegate)
205
+ if (this.parent)
206
+ return this.parent.resolveToolDep(toolName);
207
+ if (this.toolDepCache.has(toolName))
208
+ return this.toolDepCache.get(toolName);
209
+ const promise = (async () => {
210
+ const toolDef = this.resolveToolDefByName(toolName);
211
+ if (!toolDef)
212
+ throw new Error(`Tool dependency "${toolName}" not found`);
213
+ const input = {};
214
+ await this.resolveToolWires(toolDef, input);
215
+ const fn = this.lookupToolFn(toolDef.fn);
216
+ if (!fn)
217
+ throw new Error(`Tool function "${toolDef.fn}" not registered`);
218
+ // on error: wrap the tool call with fallback from onError wire
219
+ const onErrorWire = toolDef.wires.find((w) => w.kind === "onError");
220
+ try {
221
+ return await fn(input);
222
+ }
223
+ catch (err) {
224
+ if (!onErrorWire)
225
+ throw err;
226
+ if ("value" in onErrorWire)
227
+ return JSON.parse(onErrorWire.value);
228
+ return this.resolveToolSource(onErrorWire.source, toolDef);
229
+ }
230
+ })();
231
+ this.toolDepCache.set(toolName, promise);
232
+ return promise;
233
+ }
234
+ schedule(target) {
235
+ // Delegate to parent (shadow trees don't schedule directly)
236
+ if (this.parent) {
237
+ return this.parent.schedule(target);
238
+ }
239
+ return (async () => {
240
+ // If this target is a pipe fork, also apply bridge wires from its base
241
+ // handle (non-pipe wires, e.g. `c.currency <- i.currency`) as defaults
242
+ // before the fork-specific pipe wires.
243
+ const targetKey = trunkKey(target);
244
+ const pipeFork = this.pipeHandleMap?.get(targetKey);
245
+ const baseTrunk = pipeFork?.baseTrunk;
246
+ const baseWires = baseTrunk
247
+ ? (this.bridge?.wires.filter((w) => !("pipe" in w) && sameTrunk(w.to, baseTrunk)) ?? [])
248
+ : [];
249
+ // Fork-specific wires (pipe wires targeting the fork's own instance)
250
+ const forkWires = this.bridge?.wires.filter((w) => sameTrunk(w.to, target)) ?? [];
251
+ // Merge: base provides defaults, fork overrides
252
+ const bridgeWires = [...baseWires, ...forkWires];
253
+ // Look up ToolDef for this target
254
+ const toolName = this.getToolName(target);
255
+ const toolDef = this.resolveToolDefByName(toolName);
256
+ // Build input object: tool wires first (base), then bridge wires (override)
257
+ const input = {};
258
+ if (toolDef) {
259
+ await this.resolveToolWires(toolDef, input);
260
+ }
261
+ // Resolve bridge wires and apply on top
262
+ const resolved = await Promise.all(bridgeWires.map(async (w) => {
263
+ const value = "value" in w ? w.value : await this.pullSingle(w.from);
264
+ return [w.to.path, value];
265
+ }));
266
+ for (const [path, value] of resolved) {
267
+ setNested(input, path, value);
268
+ }
269
+ // Call ToolDef-backed tool function
270
+ if (toolDef) {
271
+ const fn = this.lookupToolFn(toolDef.fn);
272
+ if (!fn)
273
+ throw new Error(`Tool function "${toolDef.fn}" not registered`);
274
+ // on error: wrap the tool call with fallback from onError wire
275
+ const onErrorWire = toolDef.wires.find((w) => w.kind === "onError");
276
+ try {
277
+ return await fn(input);
278
+ }
279
+ catch (err) {
280
+ if (!onErrorWire)
281
+ throw err;
282
+ if ("value" in onErrorWire)
283
+ return JSON.parse(onErrorWire.value);
284
+ return this.resolveToolSource(onErrorWire.source, toolDef);
285
+ }
286
+ }
287
+ // Direct tool function lookup by name (simple or dotted)
288
+ const directFn = this.lookupToolFn(toolName);
289
+ if (directFn) {
290
+ return directFn(input);
291
+ }
292
+ throw new Error(`No tool found for "${toolName}"`);
293
+ })();
294
+ }
295
+ shadow() {
296
+ return new ExecutionTree(this.trunk, this.instructions, this.toolFns, undefined, this);
297
+ }
298
+ async pullSingle(ref) {
299
+ const key = trunkKey(ref);
300
+ let value = this.state[key] ?? this.parent?.state[key];
301
+ if (value === undefined) {
302
+ this.state[key] = this.schedule(ref);
303
+ value = this.state[key];
304
+ }
305
+ // Always await in case the stored value is a Promise (e.g. from schedule()).
306
+ const resolved = await Promise.resolve(value);
307
+ if (!ref.path.length) {
308
+ return resolved;
309
+ }
310
+ let result = resolved;
311
+ for (const segment of ref.path) {
312
+ if (Array.isArray(result) && !/^\d+$/.test(segment)) {
313
+ console.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(".")}`);
314
+ }
315
+ result = result?.[segment];
316
+ }
317
+ return result;
318
+ }
319
+ async pull(refs) {
320
+ return Promise.any(refs.map((ref) => this.pullSingle(ref)));
321
+ }
322
+ push(args) {
323
+ this.state[trunkKey(this.trunk)] = args;
324
+ }
325
+ /** Eagerly schedule tools targeted by forced (<-!) wires. */
326
+ executeForced() {
327
+ const forcedWires = this.bridge?.wires.filter((w) => "from" in w && !!w.force) ?? [];
328
+ const scheduled = new Set();
329
+ for (const wire of forcedWires) {
330
+ // For pipe wires the target is the fork trunk; for regular wires it's
331
+ // the tool trunk. In both cases scheduling the target kicks off
332
+ // resolution of all its input wires (including the forced source).
333
+ const key = trunkKey(wire.to);
334
+ if (scheduled.has(key) || this.state[key] !== undefined)
335
+ continue;
336
+ scheduled.add(key);
337
+ this.state[key] = this.schedule(wire.to);
338
+ // Fire-and-forget: suppress unhandled rejection for side-effect tools
339
+ // whose output is never consumed.
340
+ Promise.resolve(this.state[key]).catch(() => { });
341
+ }
342
+ }
343
+ /** Resolve a set of matched wires — constants win, then pull from sources.\n * If a wire has a `fallback` value and all sources reject, return the fallback. */
344
+ resolveWires(wires) {
345
+ const constant = wires.find((w) => "value" in w);
346
+ if (constant)
347
+ return Promise.resolve(constant.value);
348
+ const pulls = wires.filter((w) => "from" in w);
349
+ // Collect any fallback value from the wires (first one wins)
350
+ const fallbackWire = pulls.find((w) => w.fallback != null);
351
+ const result = this.pull(pulls.map((w) => w.from));
352
+ if (!fallbackWire)
353
+ return result;
354
+ return result.catch(() => {
355
+ try {
356
+ return JSON.parse(fallbackWire.fallback);
357
+ }
358
+ catch {
359
+ // Not valid JSON — return as raw string
360
+ return fallbackWire.fallback;
361
+ }
362
+ });
363
+ }
364
+ async response(ipath, array) {
365
+ // Build path segments from GraphQL resolver info
366
+ const pathSegments = [];
367
+ let index = ipath;
368
+ while (index.prev) {
369
+ pathSegments.unshift(`${index.key}`);
370
+ index = index.prev;
371
+ }
372
+ if (pathSegments.length === 0) {
373
+ // Direct output for scalar/list return types (e.g. [String!])
374
+ const directOutput = this.bridge?.wires.filter((w) => sameTrunk(w.to, this.trunk) &&
375
+ w.to.path.length === 1 &&
376
+ w.to.path[0] === this.trunk.field) ?? [];
377
+ if (directOutput.length > 0) {
378
+ return this.resolveWires(directOutput);
379
+ }
380
+ }
381
+ // Strip numeric indices (array positions) from path for wire matching
382
+ const cleanPath = pathSegments.filter((p) => !/^\d+$/.test(p));
383
+ // Find wires whose target matches this trunk + path
384
+ const matches = this.bridge?.wires.filter((w) => !w.to.element &&
385
+ sameTrunk(w.to, this.trunk) &&
386
+ pathEquals(w.to.path, cleanPath)) ?? [];
387
+ if (matches.length > 0) {
388
+ const response = this.resolveWires(matches);
389
+ if (!array) {
390
+ return response;
391
+ }
392
+ // Array: create shadow trees for per-element resolution
393
+ const items = (await response);
394
+ return items.map((item) => {
395
+ const s = this.shadow();
396
+ s.state[trunkKey({ ...this.trunk, element: true })] = item;
397
+ return s;
398
+ });
399
+ }
400
+ // Return self to trigger downstream resolvers
401
+ return this;
402
+ }
403
+ }
@@ -0,0 +1,24 @@
1
+ import type { Instruction } from "./types.js";
2
+ /**
3
+ * Parse .bridge text format into structured instructions.
4
+ *
5
+ * The .bridge format is a human-readable representation of connection wires.
6
+ * Multiple blocks are separated by `---`.
7
+ * Tool blocks define API tools, bridge blocks define wire mappings.
8
+ *
9
+ * @param text - Bridge definition text
10
+ * @returns Array of instructions (Bridge, ToolDef)
11
+ */
12
+ export declare function parseBridge(text: string): Instruction[];
13
+ /**
14
+ * Parse a dot-separated path with optional array indices.
15
+ *
16
+ * "items[0].position.lat" → ["items", "0", "position", "lat"]
17
+ * "properties[]" → ["properties"] ([] is stripped, signals array)
18
+ * "x-message-id" → ["x-message-id"]
19
+ */
20
+ export declare function parsePath(text: string): string[];
21
+ /**
22
+ * Serialize structured instructions back to .bridge text format.
23
+ */
24
+ export declare function serializeBridge(instructions: Instruction[]): string;