@stackables/bridge 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,17 +47,17 @@ 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
- extend <source> as <name>
53
+ extend <source> as <name> {
48
54
  [with context] # Injects GraphQL context (auth, secrets, etc.)
49
55
  [on error = <json_fallback>] # Fallback value if tool fails
50
56
  [on error <- <source>] # Pull fallback from context/tool
51
57
 
52
58
  <param> = <value> # Constant/Default value
53
59
  <param> <- <source> # Dynamic wire
54
-
60
+ }
55
61
  ```
56
62
 
57
63
  When `<source>` is a function name (e.g. `httpCall`), a new tool is created.
@@ -62,7 +68,7 @@ When `<source>` is an existing tool name, the new tool inherits its configuratio
62
68
  The resolver logic connecting GraphQL schema fields to your tools.
63
69
 
64
70
  ```hcl
65
- bridge <Type.field>
71
+ bridge <Type.field> {
66
72
  with <tool> [as <alias>]
67
73
  with input [as <i>]
68
74
 
@@ -81,7 +87,7 @@ bridge <Type.field>
81
87
  # Array Mapping
82
88
  <field>[] <- <source>[]
83
89
  .<sub_field> <- .<sub_src> # Relative scoping
84
-
90
+ }
85
91
  ```
86
92
 
87
93
  ---
@@ -97,11 +103,12 @@ Each layer handles a different failure mode. They compose freely.
97
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.
98
104
 
99
105
  ```hcl
100
- extend httpCall as geo
106
+ extend httpCall as geo {
101
107
  baseUrl = "https://nominatim.openstreetmap.org"
102
108
  method = GET
103
109
  path = /search
104
110
  on error = { "lat": 0, "lon": 0 } # tool-level default
111
+ }
105
112
  ```
106
113
 
107
114
  #### Layer 2 — Wire `||` (null / absent values)
@@ -153,11 +160,12 @@ Multiple `||` sources desugar to **parallel wires** — all sources are evaluate
153
160
  By default, the engine is **lazy**. Use `<-!` to force execution regardless of demand—perfect for side-effects like analytics, audit logging, or cache warming.
154
161
 
155
162
  ```hcl
156
- bridge Mutation.updateUser
163
+ bridge Mutation.updateUser {
157
164
  with audit.logger as log
158
165
 
159
166
  # 'log' runs even if the client doesn't query the 'status' field
160
167
  status <-! log:i.changeData
168
+ }
161
169
  ```
162
170
 
163
171
  ### The Pipe Operator (`:`)
@@ -172,18 +180,20 @@ result <- transform:normalize:i.rawData
172
180
  Full example with a tool that has 2 input parameters:
173
181
 
174
182
  ```hcl
175
- extend currencyConverter as convert
183
+ extend currencyConverter as convert {
176
184
  currency = EUR # default currency
185
+ }
177
186
 
178
- bridge Query.price
187
+ bridge Query.price {
179
188
  with convert as c
180
189
  with input as i
181
190
 
182
- c.currency <- i.currency # overrides the default per request
191
+ c.currency <- i.currency # overrides the default per request
183
192
 
184
- # Safe to use repeatedly — each is an independent tool call
185
- itemPrice <- c:i.itemPrice
186
- totalPrice <- c:i.totalPrice
193
+ # Safe to use repeatedly — each is an independent tool call
194
+ itemPrice <- c:i.itemPrice
195
+ totalPrice <- c:i.totalPrice
196
+ }
187
197
  ```
188
198
 
189
199
  ---
@@ -260,27 +270,30 @@ The Bridge ships with built-in tools under the `std` namespace, always available
260
270
  **No `extend` block needed** for pipe-like tools — reference them with the `std.` prefix in the `with` header:
261
271
 
262
272
  ```hcl
263
- bridge Query.format
273
+ bridge Query.format {
264
274
  with std.upperCase as up
265
275
  with std.lowerCase as lo
266
276
  with input as i
267
277
 
268
- upper <- up:i.text
269
- lower <- lo:i.text
278
+ upper <- up:i.text
279
+ lower <- lo:i.text
280
+ }
270
281
  ```
271
282
 
272
283
  Use an `extend` block when you need to configure defaults:
273
284
 
274
285
  ```hcl
275
- extend std.pickFirst as pf
286
+ extend std.pickFirst as pf {
276
287
  strict = true
288
+ }
277
289
 
278
- bridge Query.onlyResult
290
+ bridge Query.onlyResult {
279
291
  with pf
280
292
  with someApi as api
281
293
  with input as i
282
294
 
283
- value <- pf:api.items
295
+ value <- pf:api.items
296
+ }
284
297
  ```
285
298
 
286
299
  ### Adding Custom Tools
@@ -315,11 +328,12 @@ const schema = bridgeTransform(createSchema({ typeDefs }), instructions, {
315
328
  Add `cache = <seconds>` to any `httpCall` tool to enable TTL-based response caching. Identical requests (same method + URL + params) return the cached result without hitting the network.
316
329
 
317
330
  ```hcl
318
- extend httpCall as geo
331
+ extend httpCall as geo {
319
332
  cache = 300 # cache for 5 minutes
320
333
  baseUrl = "https://nominatim.openstreetmap.org"
321
334
  method = GET
322
335
  path = /search
336
+ }
323
337
  ```
324
338
 
325
339
  The default is an in-memory store. For Redis or other backends, pass a custom `CacheStore` to `createHttpCall`:
@@ -85,7 +85,28 @@ function isJsonLiteral(s) {
85
85
  s === "true" || s === "false" || s === "null";
86
86
  }
87
87
  function parseBridgeBlock(block, lineOffset) {
88
- const lines = block.split("\n").map((l) => l.trimEnd());
88
+ // Validate mandatory braces: `bridge Foo.bar {` ... `}`
89
+ const rawLines = block.split("\n");
90
+ const keywordIdx = rawLines.findIndex((l) => /^bridge\s/i.test(l.trim()));
91
+ if (keywordIdx !== -1) {
92
+ const kw = rawLines[keywordIdx].trim();
93
+ if (!kw.endsWith("{")) {
94
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: bridge block must use braces: bridge Type.field {`);
95
+ }
96
+ const hasClose = rawLines.some((l) => l.trimEnd() === "}");
97
+ if (!hasClose) {
98
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: bridge block missing closing }`);
99
+ }
100
+ }
101
+ // Strip braces for internal parsing
102
+ const lines = rawLines.map((l) => {
103
+ const trimmed = l.trimEnd();
104
+ if (trimmed === "}")
105
+ return "";
106
+ if (/^bridge\s/i.test(trimmed) && trimmed.endsWith("{"))
107
+ return trimmed.replace(/\s*\{\s*$/, "");
108
+ return trimmed;
109
+ });
89
110
  const instructions = [];
90
111
  /** 1-based global line number for error messages */
91
112
  const ln = (i) => lineOffset + i + 1;
@@ -650,7 +671,35 @@ function parseConstLines(block, lineOffset) {
650
671
  * is treated as a function name.
651
672
  */
652
673
  function parseToolBlock(block, lineOffset, previousInstructions) {
653
- const lines = block.split("\n").map((l) => l.trimEnd());
674
+ // Validate mandatory braces for blocks that have a body (deps / wires)
675
+ const rawLines = block.split("\n");
676
+ const keywordIdx = rawLines.findIndex((l) => /^(tool|extend)\s/i.test(l.trim()));
677
+ if (keywordIdx !== -1) {
678
+ // Check if there are non-blank, non-comment body lines after the keyword
679
+ const bodyLines = rawLines.slice(keywordIdx + 1).filter((l) => {
680
+ const t = l.trim();
681
+ return t !== "" && !t.startsWith("#") && t !== "}";
682
+ });
683
+ const kw = rawLines[keywordIdx].trim();
684
+ if (bodyLines.length > 0) {
685
+ if (!kw.endsWith("{")) {
686
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: extend/tool block with body must use braces: extend Foo as bar {`);
687
+ }
688
+ const hasClose = rawLines.some((l) => l.trimEnd() === "}");
689
+ if (!hasClose) {
690
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: extend/tool block missing closing }`);
691
+ }
692
+ }
693
+ }
694
+ // Strip braces for internal parsing
695
+ const lines = rawLines.map((l) => {
696
+ const trimmed = l.trimEnd();
697
+ if (trimmed === "}")
698
+ return "";
699
+ if (/^(tool|extend)\s/i.test(trimmed) && trimmed.endsWith("{"))
700
+ return trimmed.replace(/\s*\{\s*$/, "");
701
+ return trimmed;
702
+ });
654
703
  /** 1-based global line number for error messages */
655
704
  const ln = (i) => lineOffset + i + 1;
656
705
  let toolName = "";
@@ -826,16 +875,17 @@ export function serializeBridge(instructions) {
826
875
  for (const bridge of bridges) {
827
876
  blocks.push(serializeBridgeBlock(bridge));
828
877
  }
829
- return blocks.join("\n\n---\n\n") + "\n";
878
+ return blocks.join("\n\n") + "\n";
830
879
  }
831
880
  function serializeToolBlock(tool) {
832
881
  const lines = [];
882
+ const hasBody = tool.deps.length > 0 || tool.wires.length > 0;
833
883
  // Declaration line — use `extend` format
834
884
  if (tool.extends) {
835
- lines.push(`extend ${tool.extends} as ${tool.name}`);
885
+ lines.push(hasBody ? `extend ${tool.extends} as ${tool.name} {` : `extend ${tool.extends} as ${tool.name}`);
836
886
  }
837
887
  else {
838
- lines.push(`extend ${tool.fn} as ${tool.name}`);
888
+ lines.push(hasBody ? `extend ${tool.fn} as ${tool.name} {` : `extend ${tool.fn} as ${tool.name}`);
839
889
  }
840
890
  // Dependencies
841
891
  for (const dep of tool.deps) {
@@ -882,6 +932,8 @@ function serializeToolBlock(tool) {
882
932
  lines.push(` ${wire.target} <- ${wire.source}`);
883
933
  }
884
934
  }
935
+ if (hasBody)
936
+ lines.push(`}`);
885
937
  return lines.join("\n");
886
938
  }
887
939
  /**
@@ -934,7 +986,7 @@ function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge
934
986
  function serializeBridgeBlock(bridge) {
935
987
  const lines = [];
936
988
  // ── Header ──────────────────────────────────────────────────────────
937
- lines.push(`bridge ${bridge.type}.${bridge.field}`);
989
+ lines.push(`bridge ${bridge.type}.${bridge.field} {`);
938
990
  for (const h of bridge.handles) {
939
991
  switch (h.kind) {
940
992
  case "tool": {
@@ -966,6 +1018,8 @@ function serializeBridgeBlock(bridge) {
966
1018
  }
967
1019
  }
968
1020
  lines.push("");
1021
+ // Mark where the wire body starts — everything after this gets 2-space indent
1022
+ const wireBodyStart = lines.length;
969
1023
  // ── Build handle map for reverse resolution ─────────────────────────
970
1024
  const { handleMap, inputHandle } = buildHandleMap(bridge);
971
1025
  // ── Pipe fork registry ──────────────────────────────────────────────
@@ -1096,10 +1150,15 @@ function serializeBridgeBlock(bridge) {
1096
1150
  lines.push(`${destStr} ${arrow} ${handleChain.join(":")}:${sourceStr}${nfb}${errf}`);
1097
1151
  }
1098
1152
  }
1153
+ // Indent wire body lines and close the block
1154
+ for (let i = wireBodyStart; i < lines.length; i++) {
1155
+ if (lines[i] !== "")
1156
+ lines[i] = ` ${lines[i]}`;
1157
+ }
1158
+ lines.push(`}`);
1099
1159
  return lines.join("\n");
1100
1160
  }
1101
1161
  /**
1102
- * Build a reverse lookup: trunk key → handle name.
1103
1162
  * Recomputes instance numbers from handle bindings in declaration order.
1104
1163
  */
1105
1164
  function buildHandleMap(bridge) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackables/bridge",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Declarative dataflow for GraphQL",
5
5
  "main": "./build/index.js",
6
6
  "type": "module",