@stackables/bridge 1.0.0 → 1.1.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/README.md +89 -33
- package/build/ExecutionTree.d.ts +3 -1
- package/build/ExecutionTree.js +60 -9
- package/build/bridge-format.js +207 -66
- package/build/types.d.ts +3 -1
- package/package.json +4 -5
package/README.md
CHANGED
|
@@ -67,9 +67,17 @@ bridge <Type.field>
|
|
|
67
67
|
with input [as <i>]
|
|
68
68
|
|
|
69
69
|
# Field Mapping
|
|
70
|
-
<field>
|
|
71
|
-
<field>
|
|
72
|
-
|
|
70
|
+
<field> = <json> # Constant value
|
|
71
|
+
<field> <- <source> # Standard Pull (lazy)
|
|
72
|
+
<field> <-! <source> # Forced Push (eager/side-effect)
|
|
73
|
+
|
|
74
|
+
# Pipe chain (tool transformation)
|
|
75
|
+
<field> <- handle:source # Route source through tool handle
|
|
76
|
+
|
|
77
|
+
# Fallbacks
|
|
78
|
+
<field> <- <source> || <alt> || <alt> # Null-coalesce: use alt if source is null
|
|
79
|
+
<field> <- <source> ?? <fallback> # Error-fallback: use fallback if chain throws
|
|
80
|
+
|
|
73
81
|
# Array Mapping
|
|
74
82
|
<field>[] <- <source>[]
|
|
75
83
|
.<sub_field> <- .<sub_src> # Relative scoping
|
|
@@ -82,16 +90,64 @@ bridge <Type.field>
|
|
|
82
90
|
|
|
83
91
|
### Resiliency
|
|
84
92
|
|
|
85
|
-
|
|
93
|
+
Each layer handles a different failure mode. They compose freely.
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
#### Layer 1 — Tool `on error` (execution errors)
|
|
96
|
+
|
|
97
|
+
Declared inside the `extend` block. Catches any exception thrown by the tool's `fn(input)`. All tools that `extend` this tool inherit the fallback.
|
|
89
98
|
|
|
90
99
|
```hcl
|
|
100
|
+
extend httpCall as geo
|
|
101
|
+
baseUrl = "https://nominatim.openstreetmap.org"
|
|
102
|
+
method = GET
|
|
103
|
+
path = /search
|
|
104
|
+
on error = { "lat": 0, "lon": 0 } # tool-level default
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### Layer 2 — Wire `||` (null / absent values)
|
|
108
|
+
|
|
109
|
+
Fires when a source resolves **successfully but returns `null` or `undefined`**. The fallback can be a JSON literal or another source expression (handle path or pipe chain). Multiple `||` alternatives chain left-to-right like `COALESCE`.
|
|
110
|
+
|
|
111
|
+
```hcl
|
|
112
|
+
# JSON literal fallback
|
|
113
|
+
lat <- geo.lat || 0.0
|
|
114
|
+
|
|
115
|
+
# Alternative source fallback
|
|
116
|
+
label <- api.label || backup.label || "unknown"
|
|
117
|
+
|
|
118
|
+
# Pipe chain as alternative
|
|
119
|
+
textPart <- i.textBody || convert:i.htmlBody || "empty"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
#### Layer 3 — Wire `??` (errors and exceptions)
|
|
123
|
+
|
|
124
|
+
Fires when the **entire resolution chain throws** (network failure, tool down, dependency error). Does not fire on null values — that's `||`'s job. The fallback can be a JSON literal or a source/pipe expression (evaluated lazily, only when the error fires).
|
|
125
|
+
|
|
126
|
+
```hcl
|
|
127
|
+
# JSON literal error fallback
|
|
91
128
|
lat <- geo.lat ?? 0.0
|
|
92
129
|
|
|
130
|
+
# Error fallback pulls from another source
|
|
131
|
+
label <- api.label ?? errorHandler:i.fallbackMsg
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### Full COALESCE — composing all three layers
|
|
135
|
+
|
|
136
|
+
`||` and `??` compose into a Postgres-style `COALESCE` with an error guard at the end:
|
|
137
|
+
|
|
138
|
+
```hcl
|
|
139
|
+
# label <- A || B || C || "literal" ?? errorSource
|
|
140
|
+
label <- api.label || tool:api.backup.label || "unknown" ?? tool:const.errorString
|
|
141
|
+
|
|
142
|
+
# Evaluation order:
|
|
143
|
+
# api.label non-null → use it immediately
|
|
144
|
+
# api.label null → try toolIfNeeded(api.backup.label)
|
|
145
|
+
# that null → "unknown" (|| json literal always succeeds)
|
|
146
|
+
# any source throws → toolIfNeeded(const.errorString) (?? fires last)
|
|
93
147
|
```
|
|
94
148
|
|
|
149
|
+
Multiple `||` sources desugar to **parallel wires** — all sources are evaluated concurrently and the first that resolves to a non-null value wins. Cheaper/faster sources (like `input` fields) naturally win without any priority hints.
|
|
150
|
+
|
|
95
151
|
### Forced Wires (`<-!`)
|
|
96
152
|
|
|
97
153
|
By default, the engine is **lazy**. Use `<-!` to force execution regardless of demand—perfect for side-effects like analytics, audit logging, or cache warming.
|
|
@@ -101,20 +157,19 @@ bridge Mutation.updateUser
|
|
|
101
157
|
with audit.logger as log
|
|
102
158
|
|
|
103
159
|
# 'log' runs even if the client doesn't query the 'status' field
|
|
104
|
-
status <-! log
|
|
105
|
-
|
|
160
|
+
status <-! log:i.changeData
|
|
106
161
|
```
|
|
107
162
|
|
|
108
|
-
### The Pipe Operator (
|
|
163
|
+
### The Pipe Operator (`:`)
|
|
109
164
|
|
|
110
|
-
|
|
165
|
+
Routes data right-to-left through one or more tool handles: `dest <- handle:source`.
|
|
111
166
|
|
|
112
167
|
```hcl
|
|
113
|
-
# i.rawData
|
|
114
|
-
result <- transform
|
|
168
|
+
# i.rawData → normalize → transform → result
|
|
169
|
+
result <- transform:normalize:i.rawData
|
|
115
170
|
```
|
|
116
171
|
|
|
117
|
-
Full example with a tool
|
|
172
|
+
Full example with a tool that has 2 input parameters:
|
|
118
173
|
|
|
119
174
|
```hcl
|
|
120
175
|
extend currencyConverter as convert
|
|
@@ -126,26 +181,27 @@ bridge Query.price
|
|
|
126
181
|
|
|
127
182
|
c.currency <- i.currency # overrides the default per request
|
|
128
183
|
|
|
129
|
-
# Safe to use repeatedly
|
|
130
|
-
itemPrice
|
|
131
|
-
totalPrice <- c
|
|
184
|
+
# Safe to use repeatedly — each is an independent tool call
|
|
185
|
+
itemPrice <- c:i.itemPrice
|
|
186
|
+
totalPrice <- c:i.totalPrice
|
|
132
187
|
```
|
|
133
188
|
|
|
134
189
|
---
|
|
135
190
|
|
|
136
191
|
## Syntax Reference
|
|
137
192
|
|
|
138
|
-
| Operator | Type | Behavior |
|
|
139
|
-
| --- | --- | --- |
|
|
140
|
-
| **`=`** |
|
|
141
|
-
| **`<-`** |
|
|
142
|
-
| **`<-!`** |
|
|
143
|
-
|
|
|
144
|
-
|
|
|
145
|
-
|
|
|
146
|
-
| **`
|
|
147
|
-
| **`
|
|
148
|
-
| **`
|
|
193
|
+
| Operator | Type | Behavior |
|
|
194
|
+
| --- | --- | --- |
|
|
195
|
+
| **`=`** | Constant | Sets a static value. |
|
|
196
|
+
| **`<-`** | Wire | Pulls data from a source at runtime. |
|
|
197
|
+
| **`<-!`** | Force | Eagerly schedules a tool (for side-effects). |
|
|
198
|
+
| **`:`** | Pipe | Chains data through tools right-to-left. |
|
|
199
|
+
| **`\|\|`** | Null-coalesce | Next alternative if current source is `null`/`undefined`. Fires on absent values, not errors. |
|
|
200
|
+
| **`??`** | Error-fallback | Alternative used when the resolution chain **throws**. Fires on errors, not null values. |
|
|
201
|
+
| **`on error`** | Tool Fallback | Returns a default if the tool's `fn(input)` throws. |
|
|
202
|
+
| **`extend`** | Tool Definition | Configures a function or extends a parent tool. |
|
|
203
|
+
| **`const`** | Named Value | Declares reusable JSON constants. |
|
|
204
|
+
| **`[] <- []`** | Map | Iterates over arrays to create nested wire contexts. |
|
|
149
205
|
|
|
150
206
|
---
|
|
151
207
|
|
|
@@ -209,8 +265,8 @@ bridge Query.format
|
|
|
209
265
|
with std.lowerCase as lo
|
|
210
266
|
with input as i
|
|
211
267
|
|
|
212
|
-
upper <- up
|
|
213
|
-
lower <- lo
|
|
268
|
+
upper <- up:i.text
|
|
269
|
+
lower <- lo:i.text
|
|
214
270
|
```
|
|
215
271
|
|
|
216
272
|
Use an `extend` block when you need to configure defaults:
|
|
@@ -224,7 +280,7 @@ bridge Query.onlyResult
|
|
|
224
280
|
with someApi as api
|
|
225
281
|
with input as i
|
|
226
282
|
|
|
227
|
-
value <- pf
|
|
283
|
+
value <- pf:api.items
|
|
228
284
|
```
|
|
229
285
|
|
|
230
286
|
### Adding Custom Tools
|
|
@@ -286,6 +342,6 @@ bridgeTransform(schema, instructions, {
|
|
|
286
342
|
|
|
287
343
|
## Why The Bridge?
|
|
288
344
|
|
|
289
|
-
* **No Resolver Sprawl:** Stop writing identical `fetch` and `map` logic
|
|
290
|
-
* **Provider Agnostic:** Swap implementations (e.g., SendGrid vs Postmark) at the request level
|
|
291
|
-
* **Edge-Ready:** Small footprint; works in Node, Bun, and Cloudflare Workers
|
|
345
|
+
* **No Resolver Sprawl:** Stop writing identical `fetch` and `map` logic
|
|
346
|
+
* **Provider Agnostic:** Swap implementations (e.g., SendGrid vs Postmark) at the request level
|
|
347
|
+
* **Edge-Ready:** Small footprint; works in Node, Bun, and Cloudflare Workers
|
package/build/ExecutionTree.d.ts
CHANGED
|
@@ -43,7 +43,9 @@ export declare class ExecutionTree {
|
|
|
43
43
|
push(args: Record<string, any>): void;
|
|
44
44
|
/** Eagerly schedule tools targeted by forced (<-!) wires. */
|
|
45
45
|
executeForced(): void;
|
|
46
|
-
/** Resolve a set of matched wires — constants win, then pull from sources
|
|
46
|
+
/** Resolve a set of matched wires — constants win, then pull from sources.
|
|
47
|
+
* `||` (nullFallback): fires when all sources resolve to null/undefined.
|
|
48
|
+
* `??` (fallback/fallbackRef): fires when all sources reject (throw/error). */
|
|
47
49
|
private resolveWires;
|
|
48
50
|
response(ipath: Path, array: boolean): Promise<any>;
|
|
49
51
|
}
|
package/build/ExecutionTree.js
CHANGED
|
@@ -317,7 +317,36 @@ export class ExecutionTree {
|
|
|
317
317
|
return result;
|
|
318
318
|
}
|
|
319
319
|
async pull(refs) {
|
|
320
|
-
|
|
320
|
+
if (refs.length === 1)
|
|
321
|
+
return this.pullSingle(refs[0]);
|
|
322
|
+
// Multiple sources: all start in parallel.
|
|
323
|
+
// Return the first that resolves to a non-null/undefined value.
|
|
324
|
+
// If all resolve to null/undefined → resolve undefined (lets || fire).
|
|
325
|
+
// If all reject → throw AggregateError (lets ?? fire).
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
let remaining = refs.length;
|
|
328
|
+
let hasValue = false;
|
|
329
|
+
const errors = [];
|
|
330
|
+
const settle = () => {
|
|
331
|
+
if (--remaining === 0 && !hasValue) {
|
|
332
|
+
if (errors.length === refs.length) {
|
|
333
|
+
reject(new AggregateError(errors, "All sources failed"));
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
resolve(undefined); // all resolved to null/undefined
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
for (const ref of refs) {
|
|
341
|
+
this.pullSingle(ref).then((value) => {
|
|
342
|
+
if (!hasValue && value != null) {
|
|
343
|
+
hasValue = true;
|
|
344
|
+
resolve(value);
|
|
345
|
+
}
|
|
346
|
+
settle();
|
|
347
|
+
}, (err) => { errors.push(err); settle(); });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
321
350
|
}
|
|
322
351
|
push(args) {
|
|
323
352
|
this.state[trunkKey(this.trunk)] = args;
|
|
@@ -340,24 +369,46 @@ export class ExecutionTree {
|
|
|
340
369
|
Promise.resolve(this.state[key]).catch(() => { });
|
|
341
370
|
}
|
|
342
371
|
}
|
|
343
|
-
/** Resolve a set of matched wires — constants win, then pull from sources
|
|
372
|
+
/** Resolve a set of matched wires — constants win, then pull from sources.
|
|
373
|
+
* `||` (nullFallback): fires when all sources resolve to null/undefined.
|
|
374
|
+
* `??` (fallback/fallbackRef): fires when all sources reject (throw/error). */
|
|
344
375
|
resolveWires(wires) {
|
|
345
376
|
const constant = wires.find((w) => "value" in w);
|
|
346
377
|
if (constant)
|
|
347
378
|
return Promise.resolve(constant.value);
|
|
348
379
|
const pulls = wires.filter((w) => "from" in w);
|
|
349
|
-
//
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
380
|
+
// First wire with each fallback kind wins
|
|
381
|
+
const nullFallbackWire = pulls.find((w) => w.nullFallback != null);
|
|
382
|
+
// Error fallback: JSON literal (`fallback`) or source/pipe reference (`fallbackRef`)
|
|
383
|
+
const errorFallbackWire = pulls.find((w) => w.fallback != null || w.fallbackRef != null);
|
|
384
|
+
let result = this.pull(pulls.map((w) => w.from));
|
|
385
|
+
// || null-guard: fires when resolution succeeds but value is null/undefined
|
|
386
|
+
if (nullFallbackWire) {
|
|
387
|
+
result = result.then((value) => {
|
|
388
|
+
if (value != null)
|
|
389
|
+
return value;
|
|
390
|
+
try {
|
|
391
|
+
return JSON.parse(nullFallbackWire.nullFallback);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return nullFallbackWire.nullFallback;
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// ?? error-guard: fires when resolution throws
|
|
399
|
+
if (!errorFallbackWire)
|
|
353
400
|
return result;
|
|
354
401
|
return result.catch(() => {
|
|
402
|
+
// Source/pipe reference: schedule it lazily and pull the result
|
|
403
|
+
if (errorFallbackWire.fallbackRef) {
|
|
404
|
+
return this.pullSingle(errorFallbackWire.fallbackRef);
|
|
405
|
+
}
|
|
406
|
+
// JSON literal
|
|
355
407
|
try {
|
|
356
|
-
return JSON.parse(
|
|
408
|
+
return JSON.parse(errorFallbackWire.fallback);
|
|
357
409
|
}
|
|
358
410
|
catch {
|
|
359
|
-
|
|
360
|
-
return fallbackWire.fallback;
|
|
411
|
+
return errorFallbackWire.fallback;
|
|
361
412
|
}
|
|
362
413
|
});
|
|
363
414
|
}
|
package/build/bridge-format.js
CHANGED
|
@@ -75,6 +75,15 @@ export function parseBridge(text) {
|
|
|
75
75
|
return instructions;
|
|
76
76
|
}
|
|
77
77
|
// ── Bridge block parser ─────────────────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Returns true when the token looks like a JSON literal rather than a
|
|
80
|
+
* source reference (dotted path or pipe chain).
|
|
81
|
+
* JSON start chars: `"`, `{`, `[`, digit, `-` + digit, `true`, `false`, `null`.
|
|
82
|
+
*/
|
|
83
|
+
function isJsonLiteral(s) {
|
|
84
|
+
return /^["\{\[\d]/.test(s) || /^-\d/.test(s) ||
|
|
85
|
+
s === "true" || s === "false" || s === "null";
|
|
86
|
+
}
|
|
78
87
|
function parseBridgeBlock(block, lineOffset) {
|
|
79
88
|
const lines = block.split("\n").map((l) => l.trimEnd());
|
|
80
89
|
const instructions = [];
|
|
@@ -121,6 +130,60 @@ function parseBridgeBlock(block, lineOffset) {
|
|
|
121
130
|
* fork instances that can never collide with regular handle instances. */
|
|
122
131
|
let nextForkSeq = 0;
|
|
123
132
|
const pipeHandleEntries = [];
|
|
133
|
+
/**
|
|
134
|
+
* Parse a source expression (`handle.path` or `h1:h2:source`) into bridge
|
|
135
|
+
* wires, returning the terminal NodeRef.
|
|
136
|
+
*
|
|
137
|
+
* For pipe chains: pushes the intermediate `.in <- prev` wires and registers
|
|
138
|
+
* the fork instances, then returns the fork-root ref. The caller is
|
|
139
|
+
* responsible for pushing the TERMINAL wire (forkRoot → target).
|
|
140
|
+
*
|
|
141
|
+
* For simple refs: returns the resolved NodeRef directly (no wires pushed).
|
|
142
|
+
*
|
|
143
|
+
* @param forceOnOutermost When true, marks the outermost intermediate pipe
|
|
144
|
+
* wire with `force: true` (used for `<-!`).
|
|
145
|
+
*/
|
|
146
|
+
function buildSourceExpr(sourceStr, lineNum, forceOnOutermost) {
|
|
147
|
+
const parts = sourceStr.split(":");
|
|
148
|
+
if (parts.length === 1) {
|
|
149
|
+
return resolveAddress(sourceStr, handleRes, bridgeType, bridgeField);
|
|
150
|
+
}
|
|
151
|
+
// Pipe chain
|
|
152
|
+
const actualSource = parts[parts.length - 1];
|
|
153
|
+
const tokenChain = parts.slice(0, -1);
|
|
154
|
+
const parseToken = (t) => {
|
|
155
|
+
const dot = t.indexOf(".");
|
|
156
|
+
return dot === -1
|
|
157
|
+
? { handleName: t, fieldName: "in" }
|
|
158
|
+
: { handleName: t.substring(0, dot), fieldName: t.substring(dot + 1) };
|
|
159
|
+
};
|
|
160
|
+
for (const tok of tokenChain) {
|
|
161
|
+
const { handleName } = parseToken(tok);
|
|
162
|
+
if (!handleRes.has(handleName)) {
|
|
163
|
+
throw new Error(`Line ${lineNum}: Undeclared handle in pipe: "${handleName}". Add 'with <tool> as ${handleName}' to the bridge header.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField);
|
|
167
|
+
const reversedTokens = [...tokenChain].reverse();
|
|
168
|
+
for (let idx = 0; idx < reversedTokens.length; idx++) {
|
|
169
|
+
const tok = reversedTokens[idx];
|
|
170
|
+
const { handleName, fieldName } = parseToken(tok);
|
|
171
|
+
const res = handleRes.get(handleName);
|
|
172
|
+
const forkInstance = 100000 + nextForkSeq++;
|
|
173
|
+
const forkKey = `${res.module}:${res.type}:${res.field}:${forkInstance}`;
|
|
174
|
+
pipeHandleEntries.push({
|
|
175
|
+
key: forkKey,
|
|
176
|
+
handle: handleName,
|
|
177
|
+
baseTrunk: { module: res.module, type: res.type, field: res.field, instance: res.instance },
|
|
178
|
+
});
|
|
179
|
+
const forkInRef = { module: res.module, type: res.type, field: res.field, instance: forkInstance, path: parsePath(fieldName) };
|
|
180
|
+
const forkRootRef = { module: res.module, type: res.type, field: res.field, instance: forkInstance, path: [] };
|
|
181
|
+
const isOutermost = idx === reversedTokens.length - 1;
|
|
182
|
+
wires.push({ from: prevOutRef, to: forkInRef, pipe: true, ...(forceOnOutermost && isOutermost ? { force: true } : {}) });
|
|
183
|
+
prevOutRef = forkRootRef;
|
|
184
|
+
}
|
|
185
|
+
return prevOutRef; // fork-root ref
|
|
186
|
+
}
|
|
124
187
|
for (let i = bodyStartIndex; i < lines.length; i++) {
|
|
125
188
|
const raw = lines[i];
|
|
126
189
|
const line = raw.trim();
|
|
@@ -163,80 +226,105 @@ function parseBridgeBlock(block, lineOffset) {
|
|
|
163
226
|
wires.push({ value, to: toRef });
|
|
164
227
|
continue;
|
|
165
228
|
}
|
|
166
|
-
// Wire: target <-
|
|
167
|
-
//
|
|
168
|
-
|
|
229
|
+
// ── Wire: target <- A [|| B [|| C]] [|| "nullLiteral"] [?? errorSrc|"errorLiteral"]
|
|
230
|
+
//
|
|
231
|
+
// A, B, C are source expressions (handle.path or pipe|chain|src).
|
|
232
|
+
// `||` separates null-coalescing alternatives — evaluated left to right;
|
|
233
|
+
// the last alternative may be a JSON literal (→ nullFallback).
|
|
234
|
+
// `??` is the error fallback — fires when ALL sources throw.
|
|
235
|
+
// Can be a JSON literal (→ fallback) or a source expression (→ fallbackRef).
|
|
236
|
+
//
|
|
237
|
+
// Each `||` source becomes a separate wire targeting the same field.
|
|
238
|
+
// The last source wire carries nullFallback + error-fallback attributes.
|
|
239
|
+
// Desugars transparently: serializer emits back as multiple wire lines.
|
|
240
|
+
const arrowMatch = line.match(/^(\S+)\s*<-(!?)\s*(.+)$/);
|
|
169
241
|
if (arrowMatch) {
|
|
170
|
-
const [, targetStr, forceFlag,
|
|
242
|
+
const [, targetStr, forceFlag, rhs] = arrowMatch;
|
|
171
243
|
const force = forceFlag === "!";
|
|
172
|
-
const
|
|
173
|
-
// Array mapping: target[] <- source[]
|
|
174
|
-
if (targetStr.endsWith("[]") &&
|
|
244
|
+
const rhsTrimmed = rhs.trim();
|
|
245
|
+
// ── Array mapping: target[] <- source[] (no || or ?? here)
|
|
246
|
+
if (targetStr.endsWith("[]") && /^\S+\[\]$/.test(rhsTrimmed)) {
|
|
175
247
|
const toClean = targetStr.slice(0, -2);
|
|
176
|
-
const fromClean =
|
|
248
|
+
const fromClean = rhsTrimmed.slice(0, -2);
|
|
177
249
|
const fromRef = resolveAddress(fromClean, handleRes, bridgeType, bridgeField);
|
|
178
250
|
const toRef = resolveAddress(toClean, handleRes, bridgeType, bridgeField);
|
|
179
251
|
wires.push({ from: fromRef, to: toRef });
|
|
180
252
|
currentArrayToPath = toRef.path;
|
|
181
253
|
continue;
|
|
182
254
|
}
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const tokenChain = parts.slice(0, -1); // [tok1, …, tokN] outermost→innermost
|
|
194
|
-
/** Parse "handle" or "handle.field" → {handleName, fieldName} */
|
|
195
|
-
const parseToken = (t) => {
|
|
196
|
-
const dot = t.indexOf(".");
|
|
197
|
-
return dot === -1
|
|
198
|
-
? { handleName: t, fieldName: "in" }
|
|
199
|
-
: { handleName: t.substring(0, dot), fieldName: t.substring(dot + 1) };
|
|
200
|
-
};
|
|
201
|
-
for (const tok of tokenChain) {
|
|
202
|
-
const { handleName } = parseToken(tok);
|
|
203
|
-
if (!handleRes.has(handleName)) {
|
|
204
|
-
throw new Error(`Line ${ln(i)}: Undeclared handle in pipe: "${handleName}". Add 'with <tool> as ${handleName}' to the bridge header.`);
|
|
205
|
-
}
|
|
255
|
+
// ── Strip the ?? tail (last " ?? " wins in case source contains " ?? ")
|
|
256
|
+
let exprCore = rhsTrimmed;
|
|
257
|
+
let fallback;
|
|
258
|
+
let fallbackRefStr;
|
|
259
|
+
const qqIdx = rhsTrimmed.lastIndexOf(" ?? ");
|
|
260
|
+
if (qqIdx !== -1) {
|
|
261
|
+
exprCore = rhsTrimmed.slice(0, qqIdx).trim();
|
|
262
|
+
const tail = rhsTrimmed.slice(qqIdx + 4).trim();
|
|
263
|
+
if (isJsonLiteral(tail)) {
|
|
264
|
+
fallback = tail;
|
|
206
265
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
for (let idx = 0; idx < reversedTokens.length; idx++) {
|
|
210
|
-
const tok = reversedTokens[idx];
|
|
211
|
-
const { handleName, fieldName } = parseToken(tok);
|
|
212
|
-
const res = handleRes.get(handleName);
|
|
213
|
-
// Allocate a unique fork instance (100000+ avoids collision with
|
|
214
|
-
// regular instances which start at 1).
|
|
215
|
-
const forkInstance = 100000 + nextForkSeq++;
|
|
216
|
-
const forkKey = `${res.module}:${res.type}:${res.field}:${forkInstance}`;
|
|
217
|
-
pipeHandleEntries.push({
|
|
218
|
-
key: forkKey,
|
|
219
|
-
handle: handleName,
|
|
220
|
-
baseTrunk: { module: res.module, type: res.type, field: res.field, instance: res.instance },
|
|
221
|
-
});
|
|
222
|
-
const forkInRef = { module: res.module, type: res.type, field: res.field, instance: forkInstance, path: parsePath(fieldName) };
|
|
223
|
-
const forkRootRef = { module: res.module, type: res.type, field: res.field, instance: forkInstance, path: [] };
|
|
224
|
-
const isOutermost = idx === reversedTokens.length - 1;
|
|
225
|
-
wires.push({ from: prevOutRef, to: forkInRef, pipe: true, ...(force && isOutermost ? { force: true } : {}) });
|
|
226
|
-
prevOutRef = forkRootRef;
|
|
266
|
+
else {
|
|
267
|
+
fallbackRefStr = tail;
|
|
227
268
|
}
|
|
228
|
-
const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
|
|
229
|
-
wires.push({ from: prevOutRef, to: toRef, pipe: true, ...(fallback ? { fallback } : {}) });
|
|
230
|
-
continue;
|
|
231
269
|
}
|
|
232
|
-
|
|
270
|
+
// ── Split on " || " to get coalesce chain parts
|
|
271
|
+
const orParts = exprCore.split(" || ").map((s) => s.trim());
|
|
272
|
+
// Last part may be a JSON literal → becomes nullFallback on the last source wire
|
|
273
|
+
let nullFallback;
|
|
274
|
+
let sourceParts = orParts;
|
|
275
|
+
if (orParts.length > 1 && isJsonLiteral(orParts[orParts.length - 1])) {
|
|
276
|
+
nullFallback = orParts[orParts.length - 1];
|
|
277
|
+
sourceParts = orParts.slice(0, -1);
|
|
278
|
+
}
|
|
279
|
+
if (sourceParts.length === 0) {
|
|
280
|
+
throw new Error(`Line ${ln(i)}: Wire has no source expression: ${line}`);
|
|
281
|
+
}
|
|
282
|
+
// ── Parse the ?? source/pipe into a fallbackRef (if needed)
|
|
283
|
+
// Wires added by buildSourceExpr for the fallback fork are deferred and
|
|
284
|
+
// pushed AFTER the source wires so that wire order is stable across
|
|
285
|
+
// parse → serialize → re-parse cycles.
|
|
286
|
+
let fallbackRef;
|
|
287
|
+
let fallbackInternalWires = [];
|
|
288
|
+
if (fallbackRefStr) {
|
|
289
|
+
const preLen = wires.length;
|
|
290
|
+
fallbackRef = buildSourceExpr(fallbackRefStr, ln(i), false);
|
|
291
|
+
// Splice out internal wires buildSourceExpr just added; push after sources.
|
|
292
|
+
fallbackInternalWires = wires.splice(preLen);
|
|
293
|
+
}
|
|
294
|
+
// ── Build wires for each coalesce part
|
|
233
295
|
const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
296
|
+
for (let ci = 0; ci < sourceParts.length; ci++) {
|
|
297
|
+
const isFirst = ci === 0;
|
|
298
|
+
const isLast = ci === sourceParts.length - 1;
|
|
299
|
+
const srcStr = sourceParts[ci];
|
|
300
|
+
// Parse source expression; for pipe chains buildSourceExpr pushes
|
|
301
|
+
// intermediate wires and returns the fork-root ref.
|
|
302
|
+
const termRef = buildSourceExpr(srcStr, ln(i), force && isFirst);
|
|
303
|
+
const isPipeFork = termRef.instance != null && termRef.path.length === 0
|
|
304
|
+
&& srcStr.includes(":");
|
|
305
|
+
// attrs carried only on the LAST wire of the coalesce chain
|
|
306
|
+
const lastAttrs = isLast ? {
|
|
307
|
+
...(nullFallback ? { nullFallback } : {}),
|
|
308
|
+
...(fallback ? { fallback } : {}),
|
|
309
|
+
...(fallbackRef ? { fallbackRef } : {}),
|
|
310
|
+
} : {};
|
|
311
|
+
if (isPipeFork) {
|
|
312
|
+
// Terminal pipe wire: fork-root → target (force only on outermost
|
|
313
|
+
// intermediate wire, already set inside buildSourceExpr)
|
|
314
|
+
wires.push({ from: termRef, to: toRef, pipe: true, ...lastAttrs });
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
wires.push({
|
|
318
|
+
from: termRef,
|
|
319
|
+
to: toRef,
|
|
320
|
+
...(force && isFirst ? { force: true } : {}),
|
|
321
|
+
...lastAttrs,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Push fallbackRef internal wires after all source wires (stable round-trip
|
|
326
|
+
// order: same position whether parsed inline or from serialized separate lines)
|
|
327
|
+
wires.push(...fallbackInternalWires);
|
|
240
328
|
continue;
|
|
241
329
|
}
|
|
242
330
|
throw new Error(`Line ${ln(i)}: Unrecognized line: ${line}`);
|
|
@@ -796,6 +884,53 @@ function serializeToolBlock(tool) {
|
|
|
796
884
|
}
|
|
797
885
|
return lines.join("\n");
|
|
798
886
|
}
|
|
887
|
+
/**
|
|
888
|
+
* Serialize a fallback NodeRef as a human-readable source string.
|
|
889
|
+
*
|
|
890
|
+
* If the ref is a pipe-fork root, reconstructs the pipe chain by walking
|
|
891
|
+
* the `toInMap` backward (same logic as the main pipe serializer).
|
|
892
|
+
* Otherwise delegates to `serializeRef`.
|
|
893
|
+
*
|
|
894
|
+
* This is used to emit `?? handle.path` or `?? pipe:source` for wire
|
|
895
|
+
* `fallbackRef` values.
|
|
896
|
+
*/
|
|
897
|
+
function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle) {
|
|
898
|
+
const refTk = ref.instance != null
|
|
899
|
+
? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
|
|
900
|
+
: `${ref.module}:${ref.type}:${ref.field}`;
|
|
901
|
+
if (ref.path.length === 0 && pipeHandleTrunkKeys.has(refTk)) {
|
|
902
|
+
// Pipe-fork root — walk the chain to reconstruct `pipe:source` notation
|
|
903
|
+
const handleChain = [];
|
|
904
|
+
let currentTk = refTk;
|
|
905
|
+
let actualSourceRef = null;
|
|
906
|
+
for (;;) {
|
|
907
|
+
const handleName = handleMap.get(currentTk);
|
|
908
|
+
if (!handleName)
|
|
909
|
+
break;
|
|
910
|
+
const inWire = toInMap.get(currentTk);
|
|
911
|
+
const fieldName = inWire?.to.path[0] ?? "in";
|
|
912
|
+
const token = fieldName === "in" ? handleName : `${handleName}.${fieldName}`;
|
|
913
|
+
handleChain.push(token);
|
|
914
|
+
if (!inWire)
|
|
915
|
+
break;
|
|
916
|
+
const fromTk = inWire.from.instance != null
|
|
917
|
+
? `${inWire.from.module}:${inWire.from.type}:${inWire.from.field}:${inWire.from.instance}`
|
|
918
|
+
: `${inWire.from.module}:${inWire.from.type}:${inWire.from.field}`;
|
|
919
|
+
if (inWire.from.path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) {
|
|
920
|
+
currentTk = fromTk;
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
actualSourceRef = inWire.from;
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (actualSourceRef && handleChain.length > 0) {
|
|
928
|
+
const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
|
|
929
|
+
return `${handleChain.join(":")}:${sourceStr}`;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return serializeRef(ref, bridge, handleMap, inputHandle, true);
|
|
933
|
+
}
|
|
799
934
|
function serializeBridgeBlock(bridge) {
|
|
800
935
|
const lines = [];
|
|
801
936
|
// ── Header ──────────────────────────────────────────────────────────
|
|
@@ -906,13 +1041,16 @@ function serializeBridgeBlock(bridge) {
|
|
|
906
1041
|
const fromStr = serializeRef(w.from, bridge, handleMap, inputHandle, true);
|
|
907
1042
|
const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
|
|
908
1043
|
const arrow = w.force ? "<-!" : "<-";
|
|
909
|
-
const
|
|
910
|
-
|
|
1044
|
+
const nfb = w.nullFallback ? ` || ${w.nullFallback}` : "";
|
|
1045
|
+
const errf = w.fallbackRef
|
|
1046
|
+
? ` ?? ${serializePipeOrRef(w.fallbackRef, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle)}`
|
|
1047
|
+
: w.fallback ? ` ?? ${w.fallback}` : "";
|
|
1048
|
+
lines.push(`${toStr} ${arrow} ${fromStr}${nfb}${errf}`);
|
|
911
1049
|
}
|
|
912
1050
|
// ── Pipe wires ───────────────────────────────────────────────────────
|
|
913
1051
|
// Find terminal fromOutMap entries — their destination is NOT another
|
|
914
1052
|
// pipe handle's .in. Follow the chain backward to reconstruct:
|
|
915
|
-
// dest <- h1
|
|
1053
|
+
// dest <- h1:h2:…:source
|
|
916
1054
|
const serializedPipeTrunks = new Set();
|
|
917
1055
|
for (const [tk, outWire] of fromOutMap.entries()) {
|
|
918
1056
|
// Non-terminal: this fork's result feeds another fork's input field
|
|
@@ -951,8 +1089,11 @@ function serializeBridgeBlock(bridge) {
|
|
|
951
1089
|
const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
|
|
952
1090
|
const destStr = serializeRef(outWire.to, bridge, handleMap, inputHandle, false);
|
|
953
1091
|
const arrow = chainForced ? "<-!" : "<-";
|
|
954
|
-
const
|
|
955
|
-
|
|
1092
|
+
const nfb = outWire.nullFallback ? ` || ${outWire.nullFallback}` : "";
|
|
1093
|
+
const errf = outWire.fallbackRef
|
|
1094
|
+
? ` ?? ${serializePipeOrRef(outWire.fallbackRef, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle)}`
|
|
1095
|
+
: outWire.fallback ? ` ?? ${outWire.fallback}` : "";
|
|
1096
|
+
lines.push(`${destStr} ${arrow} ${handleChain.join(":")}:${sourceStr}${nfb}${errf}`);
|
|
956
1097
|
}
|
|
957
1098
|
}
|
|
958
1099
|
return lines.join("\n");
|
package/build/types.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type NodeRef = {
|
|
|
25
25
|
*
|
|
26
26
|
* Constant wires (`=`) set a fixed value on the target.
|
|
27
27
|
* Pull wires (`<-`) resolve the source at runtime.
|
|
28
|
-
* Pipe wires (`pipe: true`) are generated by the `<- h1
|
|
28
|
+
* Pipe wires (`pipe: true`) are generated by the `<- h1:h2:source` shorthand
|
|
29
29
|
* and route data through declared tool handles; the serializer collapses them
|
|
30
30
|
* back to pipe notation.
|
|
31
31
|
*/
|
|
@@ -34,7 +34,9 @@ export type Wire = {
|
|
|
34
34
|
to: NodeRef;
|
|
35
35
|
pipe?: true;
|
|
36
36
|
force?: true;
|
|
37
|
+
nullFallback?: string;
|
|
37
38
|
fallback?: string;
|
|
39
|
+
fallbackRef?: NodeRef;
|
|
38
40
|
} | {
|
|
39
41
|
value: string;
|
|
40
42
|
to: NodeRef;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackables/bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Declarative dataflow for GraphQL",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -28,14 +28,13 @@
|
|
|
28
28
|
"@tsconfig/node22": "^22.0.5",
|
|
29
29
|
"@types/node": "^25.3.0",
|
|
30
30
|
"graphql-yoga": "^5.18.0",
|
|
31
|
-
"semantic-release": "^24.2.7",
|
|
32
31
|
"tsx": "^4.21.0",
|
|
33
32
|
"typescript": "^5.9.3"
|
|
34
33
|
},
|
|
35
34
|
"dependencies": {
|
|
36
|
-
"@graphql-tools/utils": "^11
|
|
37
|
-
"graphql": "^16
|
|
38
|
-
"lru-cache": "^11
|
|
35
|
+
"@graphql-tools/utils": "^11",
|
|
36
|
+
"graphql": "^16",
|
|
37
|
+
"lru-cache": "^11"
|
|
39
38
|
},
|
|
40
39
|
"publishConfig": {
|
|
41
40
|
"access": "public"
|