@stackables/bridge 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,9 +5,15 @@ Wire data between APIs, tools, and fields using `.bridge` files—no resolvers,
5
5
 
6
6
  ```bash
7
7
  npm install @stackables/bridge
8
-
9
8
  ```
10
9
 
10
+ > **Developer Preview**
11
+ > The Bridge v1.x is a public preview and is not recommended for production use.
12
+ > - Stability: Breaking changes to the .bridge language and TypeScript API will occur frequently.
13
+ > - Versioning: We follow strict SemVer starting from v2.0.0.
14
+ >
15
+ > Feedback: We are actively looking for use cases. Please share yours in our GitHub Discussions.
16
+
11
17
  ---
12
18
 
13
19
  ## The Idea
@@ -41,7 +47,7 @@ Access const values in bridges or tools via `with const as c`, then reference as
41
47
 
42
48
  ### 2. Extend Blocks (`extend`)
43
49
 
44
- Defines the "Where" and the "How." Takes a function (or parent tool) and configures i, giving it a new namet.
50
+ Defines the "Where" and the "How." Takes a function (or parent tool) and configures i, giving it a new name.
45
51
 
46
52
  ```hcl
47
53
  extend <source> as <name>
@@ -67,9 +73,17 @@ bridge <Type.field>
67
73
  with input [as <i>]
68
74
 
69
75
  # Field Mapping
70
- <field> <- <source> # Standard Pull (Lazy)
71
- <field> <-! <source> # Forced Push (Eager/Side-effect)
72
-
76
+ <field> = <json> # Constant value
77
+ <field> <- <source> # Standard Pull (lazy)
78
+ <field> <-! <source> # Forced Push (eager/side-effect)
79
+
80
+ # Pipe chain (tool transformation)
81
+ <field> <- handle:source # Route source through tool handle
82
+
83
+ # Fallbacks
84
+ <field> <- <source> || <alt> || <alt> # Null-coalesce: use alt if source is null
85
+ <field> <- <source> ?? <fallback> # Error-fallback: use fallback if chain throws
86
+
73
87
  # Array Mapping
74
88
  <field>[] <- <source>[]
75
89
  .<sub_field> <- .<sub_src> # Relative scoping
@@ -82,16 +96,64 @@ bridge <Type.field>
82
96
 
83
97
  ### Resiliency
84
98
 
85
- Two layers of fault tolerance prevent a single API failure from crashing the response:
99
+ Each layer handles a different failure mode. They compose freely.
100
+
101
+ #### Layer 1 — Tool `on error` (execution errors)
102
+
103
+ Declared inside the `extend` block. Catches any exception thrown by the tool's `fn(input)`. All tools that `extend` this tool inherit the fallback.
104
+
105
+ ```hcl
106
+ extend httpCall as geo
107
+ baseUrl = "https://nominatim.openstreetmap.org"
108
+ method = GET
109
+ path = /search
110
+ on error = { "lat": 0, "lon": 0 } # tool-level default
111
+ ```
112
+
113
+ #### Layer 2 — Wire `||` (null / absent values)
86
114
 
87
- 1. **Layer 1 Tool `on error`**: Catches tool execution failures. Child tools inherit this via `extend`.
88
- 2. **Layer 2 — Wire `??` fallback**: Catches any failure in the resolution chain (missing data, network timeout) as a last resort.
115
+ 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`.
89
116
 
90
117
  ```hcl
118
+ # JSON literal fallback
119
+ lat <- geo.lat || 0.0
120
+
121
+ # Alternative source fallback
122
+ label <- api.label || backup.label || "unknown"
123
+
124
+ # Pipe chain as alternative
125
+ textPart <- i.textBody || convert:i.htmlBody || "empty"
126
+ ```
127
+
128
+ #### Layer 3 — Wire `??` (errors and exceptions)
129
+
130
+ 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).
131
+
132
+ ```hcl
133
+ # JSON literal error fallback
91
134
  lat <- geo.lat ?? 0.0
92
135
 
136
+ # Error fallback pulls from another source
137
+ label <- api.label ?? errorHandler:i.fallbackMsg
93
138
  ```
94
139
 
140
+ #### Full COALESCE — composing all three layers
141
+
142
+ `||` and `??` compose into a Postgres-style `COALESCE` with an error guard at the end:
143
+
144
+ ```hcl
145
+ # label <- A || B || C || "literal" ?? errorSource
146
+ label <- api.label || tool:api.backup.label || "unknown" ?? tool:const.errorString
147
+
148
+ # Evaluation order:
149
+ # api.label non-null → use it immediately
150
+ # api.label null → try toolIfNeeded(api.backup.label)
151
+ # that null → "unknown" (|| json literal always succeeds)
152
+ # any source throws → toolIfNeeded(const.errorString) (?? fires last)
153
+ ```
154
+
155
+ 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.
156
+
95
157
  ### Forced Wires (`<-!`)
96
158
 
97
159
  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 +163,19 @@ bridge Mutation.updateUser
101
163
  with audit.logger as log
102
164
 
103
165
  # 'log' runs even if the client doesn't query the 'status' field
104
- status <-! log|i.changeData
105
-
166
+ status <-! log:i.changeData
106
167
  ```
107
168
 
108
- ### The Pipe Operator (`|`)
169
+ ### The Pipe Operator (`:`)
109
170
 
110
- Chains data through tools right-to-left: `dest <- tool | source`.
171
+ Routes data right-to-left through one or more tool handles: `dest <- handle:source`.
111
172
 
112
173
  ```hcl
113
- # i.rawData -> normalize -> transform -> result
114
- result <- transform|normalize|i.rawData
174
+ # i.rawData normalize transform result
175
+ result <- transform:normalize:i.rawData
115
176
  ```
116
177
 
117
- Full example with a tool with 2 input parameters.
178
+ Full example with a tool that has 2 input parameters:
118
179
 
119
180
  ```hcl
120
181
  extend currencyConverter as convert
@@ -126,26 +187,27 @@ bridge Query.price
126
187
 
127
188
  c.currency <- i.currency # overrides the default per request
128
189
 
129
- # Safe to use repeatedly
130
- itemPrice <- c|i.itemPrice
131
- totalPrice <- c|i.totalPrice
190
+ # Safe to use repeatedly — each is an independent tool call
191
+ itemPrice <- c:i.itemPrice
192
+ totalPrice <- c:i.totalPrice
132
193
  ```
133
194
 
134
195
  ---
135
196
 
136
197
  ## Syntax Reference
137
198
 
138
- | Operator | Type | Behavior | Notes |
139
- | --- | --- | --- | --- |
140
- | **`=`** | **Constant** | Sets a static value. | |
141
- | **`<-`** | **Wire** | Pulls data from a source at runtime. | |
142
- | **`<-!`** | **Force** | Eagerly schedules a tool (for side-effects). | |
143
- | **`\|`** | **Pipe** | Chains data through tools right-to-left. | |
144
- | **`??`** | **Fallback** | Wire-level default if the resolution chain fails. | |
145
- | **`on error`** | **Tool Fallback** | Returns a default if the tool's `fn(input)` throws. | |
146
- | **`extend`** | **Tool Definition** | Configures a function or extends a parent tool. | |
147
- | **`const`** | **Named Value** | Declares reusable JSON constants. | |
148
- | **`[] <- []`** | **Map** | Iterates over arrays to create nested wire contexts. | |
199
+ | Operator | Type | Behavior |
200
+ | --- | --- | --- |
201
+ | **`=`** | Constant | Sets a static value. |
202
+ | **`<-`** | Wire | Pulls data from a source at runtime. |
203
+ | **`<-!`** | Force | Eagerly schedules a tool (for side-effects). |
204
+ | **`:`** | Pipe | Chains data through tools right-to-left. |
205
+ | **`\|\|`** | Null-coalesce | Next alternative if current source is `null`/`undefined`. Fires on absent values, not errors. |
206
+ | **`??`** | Error-fallback | Alternative used when the resolution chain **throws**. Fires on errors, not null values. |
207
+ | **`on error`** | Tool Fallback | Returns a default if the tool's `fn(input)` throws. |
208
+ | **`extend`** | Tool Definition | Configures a function or extends a parent tool. |
209
+ | **`const`** | Named Value | Declares reusable JSON constants. |
210
+ | **`[] <- []`** | Map | Iterates over arrays to create nested wire contexts. |
149
211
 
150
212
  ---
151
213
 
@@ -209,8 +271,8 @@ bridge Query.format
209
271
  with std.lowerCase as lo
210
272
  with input as i
211
273
 
212
- upper <- up|i.text
213
- lower <- lo|i.text
274
+ upper <- up:i.text
275
+ lower <- lo:i.text
214
276
  ```
215
277
 
216
278
  Use an `extend` block when you need to configure defaults:
@@ -224,7 +286,7 @@ bridge Query.onlyResult
224
286
  with someApi as api
225
287
  with input as i
226
288
 
227
- value <- pf|api.items
289
+ value <- pf:api.items
228
290
  ```
229
291
 
230
292
  ### Adding Custom Tools
@@ -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.\n * If a wire has a `fallback` value and all sources reject, return the fallback. */
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
  }
@@ -317,7 +317,36 @@ export class ExecutionTree {
317
317
  return result;
318
318
  }
319
319
  async pull(refs) {
320
- return Promise.any(refs.map((ref) => this.pullSingle(ref)));
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.\n * If a wire has a `fallback` value and all sources reject, return the fallback. */
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
- // 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)
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(fallbackWire.fallback);
408
+ return JSON.parse(errorFallbackWire.fallback);
357
409
  }
358
410
  catch {
359
- // Not valid JSON — return as raw string
360
- return fallbackWire.fallback;
411
+ return errorFallbackWire.fallback;
361
412
  }
362
413
  });
363
414
  }
@@ -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 <- source OR target <-! source (forced)
167
- // Optional fallback: target <- source ?? <json_value>
168
- const arrowMatch = line.match(/^(\S+)\s*<-(!?)\s*(\S+(?:\|\S+)*)(?:\s*\?\?\s*(.+))?$/);
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, sourceStr, fallbackRaw] = arrowMatch;
242
+ const [, targetStr, forceFlag, rhs] = arrowMatch;
171
243
  const force = forceFlag === "!";
172
- const fallback = fallbackRaw?.trim();
173
- // Array mapping: target[] <- source[]
174
- if (targetStr.endsWith("[]") && sourceStr.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 = sourceStr.slice(0, -2);
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
- // Pipe chain: target <- tok1|tok2|...|source
184
- // Each token is either "handle" (input field defaults to "in") or
185
- // "handle.field" (explicit input field name).
186
- // Every token creates an INDEPENDENT fork — a fresh tool invocation with
187
- // its own instance number — so repeated use of the same handle produces
188
- // separate calls.
189
- // Execution order: source → tokN → … → tok1 → target (right-to-left).
190
- const parts = sourceStr.split("|");
191
- if (parts.length > 1) {
192
- const actualSource = parts[parts.length - 1];
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
- let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField);
208
- const reversedTokens = [...tokenChain].reverse();
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
- const fromRef = resolveAddress(sourceStr, handleRes, bridgeType, bridgeField);
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
- wires.push({
235
- from: fromRef,
236
- to: toRef,
237
- ...(force ? { force: true } : {}),
238
- ...(fallback ? { fallback } : {}),
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 fb = w.fallback ? ` ?? ${w.fallback}` : "";
910
- lines.push(`${toStr} ${arrow} ${fromStr}${fb}`);
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|h2|…|source
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 fb = outWire.fallback ? ` ?? ${outWire.fallback}` : "";
955
- lines.push(`${destStr} ${arrow} ${handleChain.join("|")}|${sourceStr}${fb}`);
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|h2|source` shorthand
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.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Declarative dataflow for GraphQL",
5
5
  "main": "./build/index.js",
6
6
  "type": "module",