From e9e260b089fbbce42ae5755677f53c0b8fef59f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:13:48 +0000 Subject: [PATCH 1/8] Initial plan From a48da78151c7a925176c1353eae0e0afcbb22559 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:30:04 +0000 Subject: [PATCH 2/8] feat: add requestedFields (sparse fieldsets) to both runtime and compiler engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add requestedFields option to ExecuteBridgeOptions in bridge-core and bridge-compiler - Implement matchesRequestedFields utility with dot-separated path and wildcard support - Runtime: filter output fields in ExecutionTree.run() and resolveNestedField() - Compiler: filter output wires and use backward reachability for dead code elimination - Update compiler cache key to include sorted requestedFields - Add 20 shared parity tests covering field filtering, tool skipping, A||B→C chains, wildcards Co-authored-by: aarne <82001+aarne@users.noreply.github.com> --- packages/bridge-compiler/src/codegen.ts | 115 +++++++--- .../bridge-compiler/src/execute-bridge.ts | 43 +++- packages/bridge-core/src/ExecutionTree.ts | 29 ++- packages/bridge-core/src/execute-bridge.ts | 15 +- packages/bridge-core/src/index.ts | 4 + packages/bridge-core/src/requested-fields.ts | 76 +++++++ packages/bridge/test/shared-parity.test.ts | 200 ++++++++++++++++++ 7 files changed, 438 insertions(+), 44 deletions(-) create mode 100644 packages/bridge-core/src/requested-fields.ts diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index be2c55c9..d111eb5d 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -30,6 +30,7 @@ import type { NodeRef, ToolDef, } from "@stackables/bridge-core"; +import { matchesRequestedFields } from "@stackables/bridge-core"; const SELF_MODULE = "_"; @@ -38,6 +39,12 @@ const SELF_MODULE = "_"; export interface CompileOptions { /** The operation to compile, e.g. "Query.livingStandard" */ operation: string; + /** + * Sparse fieldset filter — only emit code for the listed output fields. + * Supports dot-separated paths and a trailing `*` wildcard. + * Omit or pass an empty array to compile all output fields. + */ + requestedFields?: string[]; } export interface CompileResult { @@ -88,7 +95,7 @@ export function compileBridge( (i): i is ToolDef => i.kind === "tool", ); - const ctx = new CodegenContext(bridge, constDefs, toolDefs); + const ctx = new CodegenContext(bridge, constDefs, toolDefs, options.requestedFields); return ctx.compile(); } @@ -231,16 +238,20 @@ class CodegenContext { /** Map from ToolDef dependency tool name to its emitted variable name. * Populated lazily by emitToolDeps to avoid duplicating calls. */ private toolDepVars = new Map(); + /** Sparse fieldset filter for output wire pruning. */ + private requestedFields: string[] | undefined; constructor( bridge: Bridge, constDefs: Map, toolDefs: ToolDef[], + requestedFields?: string[], ) { this.bridge = bridge; this.constDefs = constDefs; this.toolDefs = toolDefs; this.selfTrunkKey = `${SELF_MODULE}:${bridge.type}:${bridge.field}`; + this.requestedFields = requestedFields?.length ? requestedFields : undefined; for (const h of bridge.handles) { switch (h.kind) { @@ -452,7 +463,7 @@ class CodegenContext { } // Separate wires into tool inputs, define containers, and output - const outputWires: Wire[] = []; + const allOutputWires: Wire[] = []; const toolWires = new Map(); const defineWires = new Map(); @@ -465,7 +476,7 @@ class CodegenContext { ? `${w.to.module}:${w.to.type}:${w.to.field}` : toKey; if (toTrunkNoElement === this.selfTrunkKey) { - outputWires.push(w); + allOutputWires.push(w); } else if (this.defineContainers.has(toKey)) { // Wire targets a define-in/out container const arr = defineWires.get(toKey) ?? []; @@ -478,6 +489,19 @@ class CodegenContext { } } + // ── Sparse fieldset filtering ────────────────────────────────────── + // When requestedFields is provided, drop output wires for fields that + // weren't requested. Kahn's algorithm will then naturally eliminate + // tools that only feed into those dropped wires. + const outputWires = this.requestedFields + ? allOutputWires.filter((w) => { + // Root wires (path length 0) and element wires are always included + if (w.to.path.length === 0) return true; + const fieldPath = w.to.path.join("."); + return matchesRequestedFields(fieldPath, this.requestedFields); + }) + : allOutputWires; + // Ensure force-only tools (no wires targeting them from output) are // still included in the tool map for scheduling for (const [tk] of forceMap) { @@ -618,38 +642,61 @@ class CodegenContext { lines.push(` }`); // ── Dead tool detection ──────────────────────────────────────────── - // Detect tools whose output is never referenced by any output wire, - // other tool wire, or define container wire. These are dead code - // (e.g. a pipe-only handle whose forks are all element-scoped). - const referencedToolKeys = new Set(); - const allWireSources = [...outputWires, ...bridge.wires]; - for (const w of allWireSources) { - if ("from" in w) referencedToolKeys.add(refTrunkKey(w.from)); - if ("cond" in w) { - referencedToolKeys.add(refTrunkKey(w.cond)); - if (w.thenRef) referencedToolKeys.add(refTrunkKey(w.thenRef)); - if (w.elseRef) referencedToolKeys.add(refTrunkKey(w.elseRef)); - } - if ("condAnd" in w) { - referencedToolKeys.add(refTrunkKey(w.condAnd.leftRef)); - if (w.condAnd.rightRef) - referencedToolKeys.add(refTrunkKey(w.condAnd.rightRef)); - } - if ("condOr" in w) { - referencedToolKeys.add(refTrunkKey(w.condOr.leftRef)); - if (w.condOr.rightRef) - referencedToolKeys.add(refTrunkKey(w.condOr.rightRef)); - } - // Also count falsy/nullish/catch fallback refs - if ("falsyFallbackRefs" in w && w.falsyFallbackRefs) { - for (const ref of w.falsyFallbackRefs) - referencedToolKeys.add(refTrunkKey(ref)); - } - if ("nullishFallbackRef" in w && w.nullishFallbackRef) { - referencedToolKeys.add(refTrunkKey(w.nullishFallbackRef)); + // Detect which tools are reachable from the (possibly filtered) output + // wires. Uses a backward reachability analysis: start from tools + // referenced in output wires, then transitively follow tool-input + // wires to discover all upstream dependencies. Tools not in the + // reachable set are dead code and can be skipped. + + /** Extract all tool trunk keys referenced as sources in a set of wires. */ + const collectSourceKeys = (wires: Wire[]): Set => { + const keys = new Set(); + for (const w of wires) { + if ("from" in w) keys.add(refTrunkKey(w.from)); + if ("cond" in w) { + keys.add(refTrunkKey(w.cond)); + if (w.thenRef) keys.add(refTrunkKey(w.thenRef)); + if (w.elseRef) keys.add(refTrunkKey(w.elseRef)); + } + if ("condAnd" in w) { + keys.add(refTrunkKey(w.condAnd.leftRef)); + if (w.condAnd.rightRef) keys.add(refTrunkKey(w.condAnd.rightRef)); + } + if ("condOr" in w) { + keys.add(refTrunkKey(w.condOr.leftRef)); + if (w.condOr.rightRef) keys.add(refTrunkKey(w.condOr.rightRef)); + } + if ("falsyFallbackRefs" in w && w.falsyFallbackRefs) { + for (const ref of w.falsyFallbackRefs) keys.add(refTrunkKey(ref)); + } + if ("nullishFallbackRef" in w && w.nullishFallbackRef) { + keys.add(refTrunkKey(w.nullishFallbackRef)); + } + if ("catchFallbackRef" in w && w.catchFallbackRef) { + keys.add(refTrunkKey(w.catchFallbackRef)); + } } - if ("catchFallbackRef" in w && w.catchFallbackRef) { - referencedToolKeys.add(refTrunkKey(w.catchFallbackRef)); + return keys; + }; + + // Seed: tools directly referenced by output wires + forced tools + const referencedToolKeys = collectSourceKeys(outputWires); + for (const tk of forceMap.keys()) referencedToolKeys.add(tk); + + // Transitive closure: walk backward through tool input wires + const visited = new Set(); + const queue = [...referencedToolKeys]; + while (queue.length > 0) { + const tk = queue.pop()!; + if (visited.has(tk)) continue; + visited.add(tk); + const deps = toolWires.get(tk); + if (!deps) continue; + for (const key of collectSourceKeys(deps)) { + if (!visited.has(key)) { + referencedToolKeys.add(key); + queue.push(key); + } } } diff --git a/packages/bridge-compiler/src/execute-bridge.ts b/packages/bridge-compiler/src/execute-bridge.ts index b763d365..8c81ace9 100644 --- a/packages/bridge-compiler/src/execute-bridge.ts +++ b/packages/bridge-compiler/src/execute-bridge.ts @@ -57,6 +57,20 @@ export type ExecuteBridgeOptions = { * - `"full"` — everything including input and output */ trace?: TraceLevel; + /** + * Sparse fieldset filter. + * + * When provided, only the listed output fields (and their transitive + * dependencies) are compiled and executed. Tools that feed exclusively + * into unrequested fields are eliminated by the compiler's dead-code + * analysis (Kahn's algorithm). + * + * Supports dot-separated paths and a trailing wildcard: + * `["id", "price", "legs.*"]` + * + * Omit or pass an empty array to resolve all fields (the default). + */ + requestedFields?: string[]; }; export type ExecuteBridgeResult = { @@ -91,20 +105,37 @@ const AsyncFunction = Object.getPrototypeOf(async function () {}) .constructor as typeof Function; /** - * Cache: one compiled function per (document identity × operation). + * Cache: one compiled function per (document identity × operation × requestedFields). * Uses a WeakMap keyed on the document object so entries are GC'd when * the document is no longer referenced. */ const fnCache = new WeakMap>(); -function getOrCompile(document: BridgeDocument, operation: string): BridgeFn { +/** Build a cache key that includes the sorted requestedFields. */ +function cacheKey( + operation: string, + requestedFields?: string[], +): string { + if (!requestedFields || requestedFields.length === 0) return operation; + return `${operation}:${[...requestedFields].sort().join(",")}`; +} + +function getOrCompile( + document: BridgeDocument, + operation: string, + requestedFields?: string[], +): BridgeFn { + const key = cacheKey(operation, requestedFields); let opMap = fnCache.get(document); if (opMap) { - const cached = opMap.get(operation); + const cached = opMap.get(key); if (cached) return cached; } - const { functionBody } = compileBridge(document, { operation }); + const { functionBody } = compileBridge(document, { + operation, + requestedFields, + }); let fn: BridgeFn; try { @@ -133,7 +164,7 @@ function getOrCompile(document: BridgeDocument, operation: string): BridgeFn { opMap = new Map(); fnCache.set(document, opMap); } - opMap.set(operation, fn); + opMap.set(key, fn); return fn; } @@ -202,7 +233,7 @@ export async function executeBridge( logger, } = options; - const fn = getOrCompile(document, operation); + const fn = getOrCompile(document, operation, options.requestedFields); // Merge built-in std namespace with user-provided tools, then flatten // so the generated code can access them via dotted keys like tools["std.str.toUpperCase"]. diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 07970ace..726d3d1f 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -46,6 +46,7 @@ import type { Wire, } from "./types.ts"; import { SELF_MODULE } from "./types.ts"; +import { filterOutputFields, matchesRequestedFields } from "./requested-fields.ts"; import { raceTimeout } from "./utils.ts"; export class ExecutionTree implements TreeContext { @@ -104,6 +105,8 @@ export class ExecutionTree implements TreeContext { private depth: number; /** Pre-computed `trunkKey({ ...this.trunk, element: true })`. See docs/performance.md (#4). */ private elementTrunkKey: string; + /** Sparse fieldset filter — set by `run()` when requestedFields is provided. */ + private requestedFields: string[] | undefined; constructor( public trunk: Trunk, @@ -657,9 +660,18 @@ export class ExecutionTree implements TreeContext { } if (subFields.size === 0) return undefined; + // Apply sparse fieldset filter at nested level + const activeSubFields = this.requestedFields + ? [...subFields].filter((sub) => { + const fullPath = [...prefix, sub].join("."); + return matchesRequestedFields(fullPath, this.requestedFields); + }) + : [...subFields]; + if (activeSubFields.length === 0) return undefined; + const obj: Record = {}; await Promise.all( - [...subFields].map(async (sub) => { + activeSubFields.map(async (sub) => { obj[sub] = await this.resolveNestedField([...prefix, sub]); }), ); @@ -753,9 +765,16 @@ export class ExecutionTree implements TreeContext { * and materialises every output field into a plain JS object (or array of * objects for array-mapped bridges). * + * When `requestedFields` is provided, only matching output fields are + * resolved — unneeded tools are never called because the pull-based + * engine never reaches them. + * * This is the single entry-point used by `executeBridge()`. */ - async run(input: Record): Promise { + async run( + input: Record, + requestedFields?: string[], + ): Promise { const bridge = this.bridge; if (!bridge) { throw new Error( @@ -764,6 +783,7 @@ export class ExecutionTree implements TreeContext { } this.push(input); + this.requestedFields = requestedFields; const forcePromises = this.executeForced(); const { type, field } = this.trunk; @@ -832,10 +852,13 @@ export class ExecutionTree implements TreeContext { ); } + // Apply sparse fieldset filter + const activeFields = filterOutputFields(outputFields, requestedFields); + const result: Record = {}; await Promise.all([ - ...[...outputFields].map(async (name) => { + ...[...activeFields].map(async (name) => { result[name] = await this.resolveNestedField([name]); }), ...forcePromises, diff --git a/packages/bridge-core/src/execute-bridge.ts b/packages/bridge-core/src/execute-bridge.ts index 2e5bed10..89bcc253 100644 --- a/packages/bridge-core/src/execute-bridge.ts +++ b/packages/bridge-core/src/execute-bridge.ts @@ -59,6 +59,19 @@ export type ExecuteBridgeOptions = { * Default: 30. Increase for deeply nested array mappings. */ maxDepth?: number; + /** + * Sparse fieldset filter. + * + * When provided, only the listed output fields (and their transitive + * dependencies) are resolved. Tools that feed exclusively into + * unrequested fields are never called. + * + * Supports dot-separated paths and a trailing wildcard: + * `["id", "price", "legs.*"]` + * + * Omit or pass an empty array to resolve all fields (the default). + */ + requestedFields?: string[]; }; export type ExecuteBridgeResult = { @@ -134,7 +147,7 @@ export async function executeBridge( tree.tracer = new TraceCollector(traceLevel); } - const data = await tree.run(input); + const data = await tree.run(input, options.requestedFields); return { data: data as T, traces: tree.getTraces() }; } diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index 6b55ff92..0e7f5604 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -69,4 +69,8 @@ export type { // ── Utilities ─────────────────────────────────────────────────────────────── export { parsePath } from "./utils.ts"; +export { + matchesRequestedFields, + filterOutputFields, +} from "./requested-fields.ts"; diff --git a/packages/bridge-core/src/requested-fields.ts b/packages/bridge-core/src/requested-fields.ts new file mode 100644 index 00000000..91ec3c94 --- /dev/null +++ b/packages/bridge-core/src/requested-fields.ts @@ -0,0 +1,76 @@ +/** + * Sparse Fieldsets — filter output fields based on a dot-separated pattern list. + * + * Patterns use dot-separated paths with a `*` wildcard that matches + * any single segment at the end. Examples: + * + * `["id", "price", "legs.*"]` + * + * `"id"` matches the top-level `id` field. + * `"legs.*"` matches any immediate child of `legs` (e.g. `legs.duration`). + * + * If `requestedFields` is `undefined` or empty, all fields are included. + */ + +/** + * Returns `true` when the given output field path is matched by at least + * one pattern in `requestedFields`. + * + * A field is included when: + * - `requestedFields` is undefined/empty (no filter — include everything) + * - An exact pattern matches the field name (e.g. `"id"` matches `"id"`) + * - A parent pattern matches (e.g. `"legs"` matches `"legs"` and `"legs.duration"`) + * - A wildcard pattern matches (e.g. `"legs.*"` matches `"legs.duration"`) + * - The field is an ancestor of a requested deeper path + * (e.g. `"legs.duration"` means `"legs"` must be included) + */ +export function matchesRequestedFields( + fieldPath: string, + requestedFields: string[] | undefined, +): boolean { + if (!requestedFields || requestedFields.length === 0) return true; + + for (const pattern of requestedFields) { + // Exact match + if (pattern === fieldPath) return true; + + // Pattern is a parent prefix of the field (e.g. pattern "legs" matches "legs.x") + if (fieldPath.startsWith(pattern + ".")) return true; + + // Field is a parent prefix of the pattern (e.g. field "legs" is needed for pattern "legs.x") + if (pattern.startsWith(fieldPath + ".")) return true; + + // Wildcard: "legs.*" matches "legs.duration" (one segment after the prefix) + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -2); // strip ".*" + if (fieldPath.startsWith(prefix + ".")) { + // Ensure it's exactly one segment after the prefix + const rest = fieldPath.slice(prefix.length + 1); + if (!rest.includes(".")) return true; + } + // Also: field "legs" is an ancestor needed for "legs.*" + if (fieldPath === prefix) return true; + } + } + + return false; +} + +/** + * Filter a set of top-level output field names against `requestedFields`. + * Returns the filtered set. If `requestedFields` is undefined/empty, + * returns the original set unchanged. + */ +export function filterOutputFields( + outputFields: Set, + requestedFields: string[] | undefined, +): Set { + if (!requestedFields || requestedFields.length === 0) return outputFields; + const filtered = new Set(); + for (const name of outputFields) { + if (matchesRequestedFields(name, requestedFields)) { + filtered.add(name); + } + } + return filtered; +} diff --git a/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index 12ebb72c..26bc6d76 100644 --- a/packages/bridge/test/shared-parity.test.ts +++ b/packages/bridge/test/shared-parity.test.ts @@ -39,6 +39,8 @@ interface SharedTestCase { aotSupported?: boolean; /** Whether to expect an error (message pattern) instead of a result */ expectedError?: RegExp; + /** Sparse fieldset filter — only resolve listed fields */ + requestedFields?: string[]; } // ── Runners ───────────────────────────────────────────────────────────────── @@ -53,6 +55,7 @@ async function runRuntime(c: SharedTestCase): Promise { input: c.input ?? {}, tools: c.tools ?? {}, context: c.context, + requestedFields: c.requestedFields, }); return data; } @@ -65,6 +68,7 @@ async function runAot(c: SharedTestCase): Promise { input: c.input ?? {}, tools: c.tools ?? {}, context: c.context, + requestedFields: c.requestedFields, }); return data; } @@ -1340,3 +1344,199 @@ bridge Query.test { ]; runSharedSuite("Shared: break/continue", breakContinueCases); + +// ── Sparse Fieldsets (requestedFields) ────────────────────────────────────── + +const sparseFieldsetCases: SharedTestCase[] = [ + // ── 1. Basic filtering — request only a subset of fields ────────────── + { + name: "only requested fields are returned, unrequested tool is not called", + bridgeText: `version 1.5 +bridge Query.data { + with input as i + with expensive as exp + with cheap as ch + with output as o + + exp.x <- i.x + ch.y <- i.y + + o.a <- exp.result + o.b <- ch.result +}`, + operation: "Query.data", + input: { x: 1, y: 2 }, + tools: { + expensive: () => { throw new Error("expensive tool should not be called"); }, + cheap: (p: any) => ({ result: p.y * 10 }), + }, + requestedFields: ["b"], + expected: { b: 20 }, + }, + + // ── 2. No filter — all fields returned (backward-compat) ───────────── + { + name: "no requestedFields returns all fields", + bridgeText: `version 1.5 +bridge Query.data { + with input as i + with toolA as a + with toolB as b + with output as o + + a.x <- i.x + b.y <- i.y + + o.first <- a.result + o.second <- b.result +}`, + operation: "Query.data", + input: { x: 1, y: 2 }, + tools: { + toolA: (p: any) => ({ result: p.x + 100 }), + toolB: (p: any) => ({ result: p.y + 200 }), + }, + expected: { first: 101, second: 202 }, + }, + + // ── 3. Wildcard matching — legs.* ──────────────────────────────────── + { + name: "wildcard legs.* matches all immediate children", + bridgeText: `version 1.5 +bridge Query.trip { + with input as i + with api as a + with output as o + + a.id <- i.id + + o.id <- a.id + o.legs { + .duration <- a.duration + .distance <- a.distance + } + o.price <- a.price +}`, + operation: "Query.trip", + input: { id: 42 }, + tools: { + api: (p: any) => ({ id: p.id, duration: "2h", distance: 150, price: 99 }), + }, + requestedFields: ["id", "legs.*"], + expected: { id: 42, legs: { duration: "2h", distance: 150 } }, + }, + + // ── 4. Fallback chain (A || B → C) with requestedFields ────────────── + // + // Setup: + // - toolA feeds o.fromA (independently wired) + // - toolB feeds o.fromB (with falsy fallback to toolC) + // - toolC feeds the fallback of o.fromB AND depends on toolB + // + // When we request only ["fromA"], toolB and toolC should NOT be called. + // When we request only ["fromB"], toolA should NOT be called. + { + name: "A||B→C: requesting only 'fromA' skips B and C", + bridgeText: `version 1.5 +bridge Query.chain { + with input as i + with toolA as a + with toolB as b + with toolC as c + with output as o + + a.x <- i.x + b.y <- i.y + c.z <- b.partial + + o.fromA <- a.result + o.fromB <- b.result || c.result +}`, + operation: "Query.chain", + input: { x: 10, y: 20 }, + tools: { + toolA: (p: any) => ({ result: p.x * 2 }), + toolB: () => { throw new Error("toolB should not be called"); }, + toolC: () => { throw new Error("toolC should not be called"); }, + }, + requestedFields: ["fromA"], + expected: { fromA: 20 }, + }, + { + name: "A||B→C: requesting only 'fromB' skips A, calls B and fallback C", + bridgeText: `version 1.5 +bridge Query.chain { + with input as i + with toolA as a + with toolB as b + with toolC as c + with output as o + + a.x <- i.x + b.y <- i.y + c.z <- b.partial + + o.fromA <- a.result + o.fromB <- b.result || c.result +}`, + operation: "Query.chain", + input: { x: 10, y: 20 }, + tools: { + toolA: () => { throw new Error("toolA should not be called"); }, + toolB: (p: any) => ({ result: null, partial: p.y }), + toolC: (p: any) => ({ result: p.z + 5 }), + }, + requestedFields: ["fromB"], + expected: { fromB: 25 }, + }, + + // ── 5. Multiple fields requested ───────────────────────────────────── + { + name: "requesting multiple fields returns only those", + bridgeText: `version 1.5 +bridge Query.multi { + with input as i + with output as o + + o.a <- i.a + o.b <- i.b + o.c <- i.c +}`, + operation: "Query.multi", + input: { a: 1, b: 2, c: 3 }, + requestedFields: ["a", "c"], + expected: { a: 1, c: 3 }, + }, + + // ── 6. Nested field path request ───────────────────────────────────── + { + name: "requesting nested path includes parent and specified children", + bridgeText: `version 1.5 +bridge Query.nested { + with input as i + with api as a + with output as o + + a.id <- i.id + + o.id <- i.id + o.detail { + .name <- a.name + .age <- a.age + } +}`, + operation: "Query.nested", + input: { id: 1 }, + tools: { + api: (p: any) => ({ name: "Alice", age: 30 }), + }, + requestedFields: ["detail.name"], + expected: { detail: { name: "Alice" } }, + // The AOT compiler emits a static object tree — individual nested + // fields inside a scope block can't be independently pruned in the + // current codegen. Runtime handles this via resolveNestedField. + aotSupported: false, + }, +]; + +runSharedSuite("Shared: sparse fieldsets (requestedFields)", sparseFieldsetCases); From 3052a7d48a3026b83fa643e5028663a1f1209b4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:36:15 +0000 Subject: [PATCH 3/8] docs: add sparse fieldsets documentation and changeset Co-authored-by: aarne <82001+aarne@users.noreply.github.com> --- .changeset/sparse-fieldsets.md | 12 ++ packages/bridge-compiler/README.md | 21 ++-- .../docs/advanced/sparse-fieldsets.mdx | 109 ++++++++++++++++++ 3 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 .changeset/sparse-fieldsets.md create mode 100644 packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx diff --git a/.changeset/sparse-fieldsets.md b/.changeset/sparse-fieldsets.md new file mode 100644 index 00000000..3e8266c2 --- /dev/null +++ b/.changeset/sparse-fieldsets.md @@ -0,0 +1,12 @@ +--- +"@stackables/bridge-core": minor +"@stackables/bridge-compiler": minor +--- + +Add `requestedFields` option to `executeBridge()` for sparse fieldset filtering. + +When provided, only the listed output fields (and their transitive tool dependencies) are resolved. +Tools that feed exclusively into unrequested fields are never called, reducing latency and upstream +bandwidth. + +Supports dot-separated paths and a trailing wildcard (`["id", "price", "legs.*"]`). diff --git a/packages/bridge-compiler/README.md b/packages/bridge-compiler/README.md index a874b8d1..3bd99d3a 100644 --- a/packages/bridge-compiler/README.md +++ b/packages/bridge-compiler/README.md @@ -73,16 +73,17 @@ console.log(code); // Prints the raw `export default async function...` string ## API: `ExecuteBridgeOptions` -| Option | Type | What it does | -| ---------------- | --------------------- | -------------------------------------------------------------------------------- | -| `document` | `BridgeDocument` | The parsed AST from `@stackables/bridge-parser`. | -| `operation` | `string` | Which bridge to run, e.g. `"Query.myField"`. | -| `input?` | `Record` | Input arguments — equivalent to GraphQL field args. | -| `tools?` | `ToolMap` | Your custom tool functions (merged with built-in `std`). | -| `context?` | `Record` | Shared data available via `with context as ctx` in `.bridge` files. | -| `signal?` | `AbortSignal` | Pass an `AbortSignal` to cancel execution and upstream HTTP requests mid-flight. | -| `toolTimeoutMs?` | `number` | Fails the execution if a single tool takes longer than this threshold. | -| `logger?` | `Logger` | Structured logger for tool calls. | +| Option | Type | What it does | +| ------------------ | --------------------- | -------------------------------------------------------------------------------- | +| `document` | `BridgeDocument` | The parsed AST from `@stackables/bridge-parser`. | +| `operation` | `string` | Which bridge to run, e.g. `"Query.myField"`. | +| `input?` | `Record` | Input arguments — equivalent to GraphQL field args. | +| `tools?` | `ToolMap` | Your custom tool functions (merged with built-in `std`). | +| `context?` | `Record` | Shared data available via `with context as ctx` in `.bridge` files. | +| `signal?` | `AbortSignal` | Pass an `AbortSignal` to cancel execution and upstream HTTP requests mid-flight. | +| `toolTimeoutMs?` | `number` | Fails the execution if a single tool takes longer than this threshold. | +| `logger?` | `Logger` | Structured logger for tool calls. | +| `requestedFields?` | `string[]` | Sparse fieldset filter — only resolve the listed output fields. Supports dot-separated paths and a trailing `*` wildcard (e.g. `["id", "legs.*"]`). Omit to resolve all fields. | _Returns:_ `Promise<{ data: T }>` diff --git a/packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx b/packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx new file mode 100644 index 00000000..50e0438a --- /dev/null +++ b/packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx @@ -0,0 +1,109 @@ +--- +title: Sparse Fieldsets +description: Request only the output fields you need — skip unnecessary tool calls and reduce payload size. +--- + +import { Aside } from "@astrojs/starlight/components"; + +When a client only needs a subset of a bridge's output, **Sparse Fieldsets** +let you tell the engine exactly which fields to resolve. Tools that feed +exclusively into unrequested fields are never called, saving time and +upstream bandwidth. + + + +## The `requestedFields` option + +Both the interpreter (`@stackables/bridge-core`) and the compiler +(`@stackables/bridge-compiler`) accept an optional `requestedFields` array +in `ExecuteBridgeOptions`: + +```ts +import { executeBridge, parseBridge } from "@stackables/bridge"; + +const document = parseBridge(bridgeText); + +const { data } = await executeBridge({ + document, + operation: "Query.searchTrains", + input: { from: "Bern", to: "Zürich" }, + requestedFields: ["id", "status", "legs.*"], + tools: { /* ... */ }, +}); +``` + +When `requestedFields` is omitted (or empty), every output field is resolved +— the default behavior. + +## Pattern syntax + +Patterns are **dot-separated paths** with an optional trailing wildcard: + +| Pattern | Matches | +| ----------------- | ------------------------------------------------------------ | +| `"id"` | The top-level `id` field | +| `"legs"` | The entire `legs` object (all children included) | +| `"legs.duration"` | Only `legs.duration` — other `legs` children are skipped | +| `"legs.*"` | Every immediate child of `legs` (e.g. `legs.duration`, `legs.distance`) | + +A field is included if **any** pattern matches it. Ancestor fields are +included automatically when a deeper path is requested (e.g., requesting +`"legs.duration"` ensures the `legs` object exists in the output). + +## Example: REST / RPC endpoint + +Sparse fieldsets are especially useful when mapping HTTP query parameters +to bridge execution, allowing mobile apps to request lightweight payloads +while desktop apps fetch richer data from the same `.bridge` file: + +```ts +// Express / Fastify handler +app.get("/api/trains", async (req, res) => { + const fields = req.query.fields?.split(","); // e.g. "id,status,legs.duration" + + const { data } = await executeBridge({ + document, + operation: "Query.searchTrains", + input: req.query, + requestedFields: fields, + tools: { /* ... */ }, + }); + + res.json(data); +}); +``` + +``` +GET /api/trains?from=Bern&to=Zürich&fields=id,status,legs.duration +``` + +## How it works + +### Interpreter (bridge-core) + +The interpreter filters the set of output fields collected from output wires +**before** beginning the pull loop. Because execution is pull-based, dropping +an output field means the engine never traces backward to the tools that +feed it — they are simply never scheduled. + +### Compiler (bridge-compiler) + +The compiler filters output wires at code-generation time. A backward +reachability analysis then eliminates all tools that are no longer +transitively referenced by any remaining output wire. The generated +JavaScript function contains only the code paths needed for the requested +fields. + +Different `requestedFields` shapes produce different compiled functions. +Each shape is cached independently so subsequent calls with the same +field set reuse the optimally-sized function. + + From 81fbc101ec3f218aa050f82b873cfb6dd76610ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:38:54 +0000 Subject: [PATCH 4/8] refactor: address code review feedback (validation, perf, docs) Co-authored-by: aarne <82001+aarne@users.noreply.github.com> --- packages/bridge-compiler/src/codegen.ts | 9 ++++++++- packages/bridge-core/src/ExecutionTree.ts | 3 ++- .../src/content/docs/advanced/sparse-fieldsets.mdx | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index d111eb5d..91df2b7b 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -648,7 +648,14 @@ class CodegenContext { // wires to discover all upstream dependencies. Tools not in the // reachable set are dead code and can be skipped. - /** Extract all tool trunk keys referenced as sources in a set of wires. */ + /** + * Extract all tool trunk keys referenced as **sources** in a set of + * wires. A "source key" is the trunk key of a node that feeds data + * into a wire (the right-hand side of `target <- source`). This + * includes pull refs, ternary branches, condAnd/condOr operands, + * and all fallback refs. Used by the backward reachability analysis + * to discover which tools are transitively needed by the output. + */ const collectSourceKeys = (wires: Wire[]): Set => { const keys = new Set(); for (const w of wires) { diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 726d3d1f..f02cec2a 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -661,9 +661,10 @@ export class ExecutionTree implements TreeContext { if (subFields.size === 0) return undefined; // Apply sparse fieldset filter at nested level + const prefixStr = prefix.join("."); const activeSubFields = this.requestedFields ? [...subFields].filter((sub) => { - const fullPath = [...prefix, sub].join("."); + const fullPath = prefixStr ? `${prefixStr}.${sub}` : sub; return matchesRequestedFields(fullPath, this.requestedFields); }) : [...subFields]; diff --git a/packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx b/packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx index 50e0438a..d8ab1b1f 100644 --- a/packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx +++ b/packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx @@ -63,7 +63,10 @@ while desktop apps fetch richer data from the same `.bridge` file: ```ts // Express / Fastify handler app.get("/api/trains", async (req, res) => { - const fields = req.query.fields?.split(","); // e.g. "id,status,legs.duration" + const raw = req.query.fields; // e.g. "id,status,legs.duration" + const fields = typeof raw === "string" + ? raw.split(",").filter((f) => /^[\w.*]+$/.test(f)) + : undefined; const { data } = await executeBridge({ document, From 397904f34b59233d404932169b7406193b032c23 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 14:58:24 +0100 Subject: [PATCH 5/8] Lint & build --- package.json | 5 ++--- packages/bridge/test/shared-parity.test.ts | 23 ++++++++++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 013c1286..53367f34 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,8 @@ "packageManager": "pnpm@10.30.3+sha256.ff0a72140f6a6d66c0b284f6c9560aff605518e28c29aeac25fb262b74331588", "scripts": { "test": "pnpm -r test", - "build": "pnpm -r build", - "lint:types": "pnpm -r --filter './packages/*' lint:types", - "lint:eslint": "eslint .", + "build": "pnpm -r --filter './packages/*' lint:types", + "lint": "eslint .", "check:exports": "node scripts/check-exports.mjs", "smoke": "node scripts/smoke-test-packages.mjs", "e2e": "pnpm -r e2e", diff --git a/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index 26bc6d76..5167b740 100644 --- a/packages/bridge/test/shared-parity.test.ts +++ b/packages/bridge/test/shared-parity.test.ts @@ -1367,7 +1367,9 @@ bridge Query.data { operation: "Query.data", input: { x: 1, y: 2 }, tools: { - expensive: () => { throw new Error("expensive tool should not be called"); }, + expensive: () => { + throw new Error("expensive tool should not be called"); + }, cheap: (p: any) => ({ result: p.y * 10 }), }, requestedFields: ["b"], @@ -1456,8 +1458,12 @@ bridge Query.chain { input: { x: 10, y: 20 }, tools: { toolA: (p: any) => ({ result: p.x * 2 }), - toolB: () => { throw new Error("toolB should not be called"); }, - toolC: () => { throw new Error("toolC should not be called"); }, + toolB: () => { + throw new Error("toolB should not be called"); + }, + toolC: () => { + throw new Error("toolC should not be called"); + }, }, requestedFields: ["fromA"], expected: { fromA: 20 }, @@ -1482,7 +1488,9 @@ bridge Query.chain { operation: "Query.chain", input: { x: 10, y: 20 }, tools: { - toolA: () => { throw new Error("toolA should not be called"); }, + toolA: () => { + throw new Error("toolA should not be called"); + }, toolB: (p: any) => ({ result: null, partial: p.y }), toolC: (p: any) => ({ result: p.z + 5 }), }, @@ -1528,7 +1536,7 @@ bridge Query.nested { operation: "Query.nested", input: { id: 1 }, tools: { - api: (p: any) => ({ name: "Alice", age: 30 }), + api: (_p: any) => ({ name: "Alice", age: 30 }), }, requestedFields: ["detail.name"], expected: { detail: { name: "Alice" } }, @@ -1539,4 +1547,7 @@ bridge Query.nested { }, ]; -runSharedSuite("Shared: sparse fieldsets (requestedFields)", sparseFieldsetCases); +runSharedSuite( + "Shared: sparse fieldsets (requestedFields)", + sparseFieldsetCases, +); From 0802069f66099c5c1b44c2f5c36320b45d830a7e Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 15:24:12 +0100 Subject: [PATCH 6/8] Move graphql tests --- AGENTS.md | 336 +++--------------- packages/bridge-graphql/package.json | 8 +- packages/bridge-graphql/test/_gateway.ts | 35 ++ .../test/executeGraph.test.ts | 289 ++++++++++++++- .../test/logging.test.ts | 4 +- .../test/property-search.bridge | 0 .../test/property-search.test.ts | 2 +- .../test/tracing.test.ts | 4 +- packages/bridge/test/email.test.ts | 108 ------ packages/bridge/test/parser-compat.test.ts | 1 - pnpm-lock.yaml | 16 +- 11 files changed, 395 insertions(+), 408 deletions(-) create mode 100644 packages/bridge-graphql/test/_gateway.ts rename packages/{bridge => bridge-graphql}/test/executeGraph.test.ts (54%) rename packages/{bridge => bridge-graphql}/test/logging.test.ts (97%) rename packages/{bridge => bridge-graphql}/test/property-search.bridge (100%) rename packages/{bridge => bridge-graphql}/test/property-search.test.ts (98%) rename packages/{bridge => bridge-graphql}/test/tracing.test.ts (99%) delete mode 100644 packages/bridge/test/email.test.ts diff --git a/AGENTS.md b/AGENTS.md index fd6fbec3..5e29c05d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,330 +1,86 @@ # AGENTS.md — Coding Agent Instructions -> This document is for AI coding agents working on The Bridge codebase. -> Read this first before making any changes. - ---- +> For AI coding agents working on The Bridge codebase. ## What is The Bridge? -A declarative dataflow language (`.bridge` files) and pull-based execution engine for API orchestration. Instead of writing imperative resolver code, developers describe **what** data they need and **where** it comes from. The engine builds a dependency graph and executes it automatically — handling parallelization, fallback chains, and data reshaping. - -The project is a **pnpm monorepo** with multiple packages under `packages/` and runnable examples under `examples/`. +A declarative dataflow language (`.bridge` files) and pull-based execution engine for API orchestration. Developers describe **what** data they need and **where** it comes from; the engine builds a dependency graph and executes it automatically. ---- +**pnpm monorepo** — packages under `packages/`, examples under `examples/`. ## Prerequisites -- **Node.js ≥ 24** (the test runner uses `node:test`) -- **pnpm ≥ 10** - -```bash -pnpm install # install all workspace dependencies -pnpm build # build all packages -pnpm test # run all unit tests -pnpm e2e # run all end-to-end tests -``` - ---- +- **Node.js ≥ 24**, **pnpm ≥ 10** +- `pnpm install` to set up ## Mandatory Workflow -### 1. Tests must always pass - -There are **zero** pre-existing test failures. Before starting any work, confirm the baseline: +### Always verify ```bash -pnpm test # all unit tests must pass -pnpm e2e # all e2e tests must pass +pnpm build # type-check (0 errors required) +pnpm lint # coding standards (0 errors required) +pnpm test # all unit tests (0 failures baseline) +pnpm e2e # end-to-end tests ``` -If you find failing tests before your changes, **fix them first** — do not proceed with new work on a broken baseline. - -### 2. Test-first for bug fixes +If tests fail before your changes, **fix them first**. -When fixing a bug, **write a failing test first** that reproduces the bug, then implement the fix. The test proves the bug existed and prevents regression. +### Test requirements -### 3. Tests for new features +- **Bug fixes:** write a failing test first, then fix +- **New features:** every new feature, syntax addition, or behavioral change needs test coverage +- Tests use `node:test` + `node:assert` — no Jest or Vitest -Every new feature, syntax addition, or behavioral change needs test coverage. Match the test file to the area you're changing (see test index below). +### Changesets -### 4. Changesets +Run `pnpm changeset` for every **user-facing** change. Skip for test-only, docs, or CI changes. -Every **user-facing** change requires a changeset. After making changes, create one: +### Language changes -```bash -pnpm changeset -``` - -This will interactively prompt you to: - -1. Select which packages changed (use space to select, enter to confirm) -2. Choose the semver bump type (patch / minor / major) -3. Write a brief summary of the change - -The changeset file is committed with your code. The CI pipeline uses it to version and publish. - -**Do NOT create a changeset for non-user-facing changes.** These don't trigger a package release. Examples of changes that do **not** need a changeset: - -- Adding or updating tests -- Updating READMEs, documentation, or comments -- CI/tooling configuration changes -- Changes to `AGENTS.md`, `CONTRIBUTING.md`, or similar repo-level docs - -### 5. Build verification - -After any code change, verify the build is clean: - -```bash -pnpm build # must complete with 0 errors -``` - -TypeScript is strict — `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch` are all enabled. - -### 6. Syntax highlighting and SDL - -For every language change, review and adjust the playground and vscode extension functionality. Especially syntax highlighting and autocomplete - ---- +For every language change, also review and adjust the **playground** and **VS Code extension** (syntax highlighting, autocomplete). ## Package Architecture ``` -packages/ - bridge-types/ Shared type definitions (ToolContext, ToolCallFn, ToolMap, CacheStore) - bridge-compiler/ Parser (Chevrotain), serializer, linter, language service - bridge-core/ Execution engine (ExecutionTree), type definitions (Wire, Bridge, NodeRef) - bridge-stdlib/ Standard library tools (httpCall, strings, arrays, audit, assert) - bridge-graphql/ GraphQL schema adapter (bridgeTransform) - bridge/ Umbrella package — re-exports everything as @stackables/bridge - bridge-syntax-highlight/ VS Code extension (TextMate grammar, language server) - docs-site/ Documentation website (Astro + Starlight) - playground/ Browser playground (Vite + React) -``` - -### Dependency graph (no cycles) - -``` -bridge-types ← shared types, no dependencies - ↑ -bridge-stdlib ← depends on bridge-types - ↑ -bridge-core ← depends on bridge-types + bridge-stdlib - ↑ -bridge-compiler ← depends on bridge-core (for type imports) - ↑ -bridge-graphql ← depends on bridge-core + bridge-compiler - ↑ -bridge ← umbrella, re-exports all of the above +bridge-types/ Shared type definitions +bridge-stdlib/ Standard library tools (httpCall, strings, arrays, audit, assert) +bridge-core/ Execution engine (ExecutionTree), core types (Wire, Bridge, NodeRef) +bridge-parser/ Parser (Chevrotain), serializer, linter, language service +bridge-compiler/ AOT compiler (bridge → optimised JS) +bridge-graphql/ GraphQL schema adapter (bridgeTransform) +bridge/ Umbrella — re-exports everything as @stackables/bridge +bridge-syntax-highlight/ VS Code extension (TextMate grammar, language server) +docs-site/ Documentation website (Astro + Starlight) +playground/ Browser playground (Vite + React) ``` -### Key source files +**Dependency flow (no cycles):** `bridge-types → bridge-stdlib → bridge-core → bridge-parser → bridge-compiler → bridge-graphql → bridge` -| File | What it does | -| ----------------------------------------- | ---------------------------------------------------------------------- | -| `bridge-compiler/src/parser/lexer.ts` | Chevrotain token definitions (keywords, operators) | -| `bridge-compiler/src/parser/parser.ts` | Grammar rules (`BridgeParser` class) + CST→AST visitor (`toBridgeAst`) | -| `bridge-compiler/src/bridge-format.ts` | AST → `.bridge` text serializer | -| `bridge-compiler/src/bridge-lint.ts` | Linter rules | -| `bridge-compiler/src/language-service.ts` | Hover info, diagnostics for IDE integration | -| `bridge-core/src/types.ts` | Core types: `Wire`, `Bridge`, `NodeRef`, `Instruction`, `ToolDef` | -| `bridge-core/src/ExecutionTree.ts` | Pull-based execution engine (the runtime core) | -| `bridge-core/src/execute-bridge.ts` | Standalone (non-GraphQL) bridge execution entry point | -| `bridge-core/src/tools/internal.ts` | Engine-internal tools (math ops, concat, comparisons) | -| `bridge-stdlib/src/tools/http-call.ts` | `httpCall` REST client with LRU caching | -| `bridge-stdlib/src/tools/strings.ts` | String tools (upper, lower, slice, pad, etc.) | -| `bridge-stdlib/src/tools/arrays.ts` | Array tools (find, first, toArray, flat, sort, etc.) | -| `bridge-stdlib/src/tools/audit.ts` | Audit logging tool | -| `bridge-stdlib/src/tools/assert.ts` | Input assertion tool | -| `bridge-graphql/src/bridge-transform.ts` | Wraps GraphQL field resolvers with bridge execution | - ---- - -## Test Index - -Tests live in `packages/bridge/test/`. They use `node:test` and `node:assert` — no Jest or Vitest. +## Tests **Run a single test file:** - ```bash -cd packages/bridge node --experimental-transform-types --conditions source --test test/.test.ts ``` -| Test file | What it covers | When to add tests here | -| --------------------------------- | ---------------------------------------------- | ------------------------------- | -| `parser-compat.test.ts` | Parse → serialize round-trips (snapshot-style) | New syntax, grammar changes | -| `bridge-format.test.ts` | Bridge text formatting | Serializer changes | -| `executeGraph.test.ts` | End-to-end execution with GraphQL schema | Core wiring, field resolution | -| `tool-features.test.ts` | Tool inheritance, wire merging, onError | Tool block changes | -| `builtin-tools.test.ts` | std namespace tools, bundle shape | Adding/changing stdlib tools | -| `resilience.test.ts` | Error fallback, null coalescing, catch | Fallback chain changes | -| `control-flow.test.ts` | break, continue, throw, panic | Control flow changes | -| `expressions.test.ts` | Ternary, and/or, not, math, comparisons | Expression/alias changes | -| `ternary.test.ts` | Ternary operator specifics | Ternary behavior changes | -| `chained.test.ts` | Pipe operator chains | Pipe syntax changes | -| `scheduling.test.ts` | Concurrency, dedup, parallelism | Execution scheduling changes | -| `force-wire.test.ts` | `force` statement execution | Force statement changes | -| `scope-and-edges.test.ts` | Handle scoping, define blocks | Define/scope changes | -| `path-scoping.test.ts` | Path resolution, nested access | Path traversal changes | -| `tracing.test.ts` | Trace output shape, timing | Tracing/observability changes | -| `logging.test.ts` | Logger integration | Logger changes | -| `execute-bridge.test.ts` | Standalone (non-GraphQL) execution | `executeBridge()` changes | -| `string-interpolation.test.ts` | Template string interpolation | String template changes | -| `interpolation-universal.test.ts` | Universal interpolation | Interpolation changes | -| `coalesce-cost.test.ts` | Cost-sorted coalesce resolution | Overdefinition/coalesce changes | -| `fallback-bug.test.ts` | Specific fallback regression tests | Fallback regressions | -| `prototype-pollution.test.ts` | Security: prototype pollution guards | Security changes | -| `email.test.ts` | Mutation + response header extraction | Mutation handling | -| `property-search.test.ts` | File-based .bridge fixture test | Complex multi-tool scenarios | - -### E2E tests - -E2E tests live in each example directory and spin up a real GraphQL server: - -```bash -pnpm e2e # run all e2e tests -cd examples/weather-api && pnpm e2e # single example -``` - -| Example | What it tests | -| ---------------------------- | ----------------------------------------------- | -| `examples/weather-api/` | Tool chaining, geocoding + weather, no API keys | -| `examples/builtin-tools/` | std tools (format, findEmployee) | -| `examples/composed-gateway/` | Multi-source gateway composition | -| `examples/travel-api/` | Provider switching, error fallbacks | -| `examples/without-graphql/` | Standalone `executeBridge()` without GraphQL | - -### Test helper - -`packages/bridge/test/_gateway.ts` exports `createGateway({ bridgeText, typeDefs, tools?, options? })` — sets up a graphql-yoga server for integration tests. The `_` prefix keeps it out of the test glob. - ---- - -## Documentation Index - -End-user documentation lives in `packages/docs-site/src/content/docs/`. Consult these when you need to understand language semantics or user-facing behavior: - -### Guides (how-to) - -| File | Title | Content | -| ---------------------------- | ------------------ | -------------------------------------- | -| `guides/getting-started.mdx` | Getting Started | First bridge file, setup, basic wiring | -| `guides/bff.mdx` | The "No-Code" BFF | Backend-for-Frontend pattern | -| `guides/egress.mdx` | The Egress Gateway | Centralizing third-party API calls | -| `guides/rule-engine.mdx` | The Rule Engine | Conditional logic and data enrichment | - -### Language Reference - -| File | Title | Content | -| ----------------------------------------- | ------------------------ | ------------------------------------------------- | -| `reference/10-core-concepts.mdx` | Core Concepts | Mental model, execution engine, file structure | -| `reference/20-structural-blocks.mdx` | Structural Blocks | `bridge`, `tool`, `define`, `const` blocks | -| `reference/30-wiring-routing.mdx` | Wiring & Routing | `<-`, `=`, nested payloads, `force` | -| `reference/40-using-tools-pipes.mdx` | Using Tools & Pipes | Pipe chains, caching | -| `reference/50-fallbacks-resilience.mdx` | Fallbacks & Resilience | `\|\|`, `??`, `catch`, `on error`, overdefinition | -| `reference/60-expressions-formatting.mdx` | Expressions & Formatting | Math, ternary, string interpolation, `alias` | -| `reference/70-array-mapping.mdx` | Array Mapping | `[] as iter { }`, `break`, `continue` | - -### Built-in Tools Reference - -| File | Title | Content | -| ------------------------ | ----------------- | ---------------------------------------------- | -| `tools/10-httpCall.mdx` | REST API client | httpCall tool: methods, headers, caching | -| `tools/11-audit.mdx` | Audit Log | Structured logging tool for side-effects | -| `tools/array-tools.mdx` | Array Operations | find, first, toArray, flat, sort, unique, etc. | -| `tools/string-tools.mdx` | String Operations | upper, lower, slice, pad, replace, etc. | - -### Advanced Topics - -| File | Title | Content | -| ------------------------------ | ----------------- | --------------------------------------------- | -| `advanced/custom-tools.md` | Custom Tools | Writing custom tool functions | -| `advanced/dynamic-routing.md` | Dynamic Routing | Context-aware instruction selection | -| `advanced/input-validation.md` | Asserting Inputs | assert tool for input validation | -| `advanced/observability.md` | Observability | Traces, metrics, and logs | -| `advanced/packages.mdx` | Package Selection | Choosing the right packages for your use case | - -### Internal developer docs - -| File | Content | -| ------------------- | ---------------------------------------------------------------------- | -| `docs/developer.md` | Architecture deep-dive: parser pipeline, execution engine, serializer | -| `docs/llm-notes.md` | Detailed internal notes: types, APIs, design decisions, test structure | - ---- - -## Key Concepts to Understand - -### The Wire type +Tests are **co-located with each package**. The main test suites: -All data flow is expressed as `Wire` — a discriminated union with 5 variants: +- **`packages/bridge/test/`** — language behavior, execution engine, expressions, control flow, resilience, scheduling, etc. +- **`packages/bridge-graphql/test/`** — GraphQL driver: per-field errors, tracing via extensions, logging, mutations, field fallthrough. +- **`packages/bridge-core/test/`**, **`packages/bridge-stdlib/test/`**, **`packages/bridge-parser/test/`** — package-level unit tests. +- **`examples/*/e2e.test.ts`** — end-to-end tests spinning up real servers. -1. **Pull wire** (`from → to`) — pull data from a source at runtime -2. **Constant wire** (`value → to`) — set a fixed value -3. **Conditional wire** (`cond ? then : else → to`) — ternary -4. **condAnd wire** (`left && right → to`) — short-circuit AND -5. **condOr wire** (`left || right → to`) — short-circuit OR - -All wire variants (except constant) support modifier layers: - -- `falsyFallbackRefs` + `falsyFallback` + `falsyControl` — falsy gate (`||`) -- `nullishFallbackRef` + `nullishFallback` + `nullishControl` — nullish gate (`??`) -- `catchFallbackRef` + `catchFallback` + `catchControl` — error boundary (`catch`) - -### The ExecutionTree - -Pull-based: resolution starts from a demanded field and works backward. Key methods: - -- `resolveWires(wires)` — unified loop over all wire types with 3 modifier layers + overdefinition boundary -- `pullSingle(ref)` — recursive resolution of a single NodeRef -- `schedule(target)` — schedules a tool call, builds its input from wires -- `callTool(...)` — invokes a tool function with OpenTelemetry tracing - -### The Parser - -Chevrotain CstParser. Two-phase: grammar rules produce CST, then `toBridgeAst` visitor converts to typed `Instruction[]`. When adding syntax: - -1. Add token in `lexer.ts` (with `longer_alt: Identifier`) -2. Add grammar rule in `parser.ts` -3. Add visitor logic in `toBridgeAst` -4. Add parser-compat snapshot test -5. Update serializer in `bridge-format.ts` if round-trip is needed - ---- - -## Common Patterns - -### Adding a new built-in tool - -1. Create the tool in `packages/bridge-stdlib/src/tools/` -2. Export from `packages/bridge-stdlib/src/index.ts` under the `std` namespace -3. Add tests in `packages/bridge/test/builtin-tools.test.ts` -4. Update `packages/docs-site/` with documentation -5. Update the VS Code extension syntax if new keywords are involved - -### Changing parser/language syntax - -1. Modify tokens in `bridge-compiler/src/parser/lexer.ts` -2. Add/modify grammar rules in `bridge-compiler/src/parser/parser.ts` -3. Update visitor logic in the same file -4. Update serializer in `bridge-compiler/src/bridge-format.ts` -5. Add snapshot tests in `packages/bridge/test/parser-compat.test.ts` -6. Add execution tests in the relevant test file - -### Changing execution semantics - -1. Modify `bridge-core/src/ExecutionTree.ts` -2. Add tests in the relevant test file (usually `executeGraph.test.ts`, `resilience.test.ts`, or `expressions.test.ts`) -3. Verify with `pnpm test && pnpm e2e` +## TypeScript Conventions ---- +- **ESM** (`"type": "module"`) with `.ts` import extensions (handled by `rewriteRelativeImportExtensions`) +- **Strict mode** — `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch` +- **Dev running:** `--experimental-transform-types --conditions source` +- **Path mappings:** `tsconfig.base.json` maps `@stackables/*` for cross-package imports -## TypeScript Conventions +## Deep-dive docs -- **Module system:** ESM (`"type": "module"`) -- **Import extensions:** Use `.ts` extensions in source imports (the `rewriteRelativeImportExtensions` compiler option handles build output) -- **Strict mode:** All strict checks enabled -- **Build:** `tsc` per package, output to `build/` -- **Dev running:** `--experimental-transform-types --conditions source` (runs TypeScript directly, resolves `source` export condition to `src/`) -- **Path mappings:** `tsconfig.base.json` maps `@stackables/*` packages for cross-package imports during development +For architecture details, internal types, Wire semantics, parser pipeline, and design decisions, see: +- `docs/developer.md` — architecture deep-dive +- `docs/llm-notes.md` — detailed internal notes for LLMs +- `packages/docs-site/src/content/docs/` — end-user language reference diff --git a/packages/bridge-graphql/package.json b/packages/bridge-graphql/package.json index 0efa983d..8cdfbf2a 100644 --- a/packages/bridge-graphql/package.json +++ b/packages/bridge-graphql/package.json @@ -19,7 +19,8 @@ "scripts": { "build": "tsc -p tsconfig.json", "lint:types": "tsc -p tsconfig.check.json", - "prepack": "pnpm build" + "prepack": "pnpm build", + "test": "node --experimental-transform-types --conditions source --test test/*.test.ts" }, "repository": { "type": "git", @@ -31,6 +32,11 @@ "@stackables/bridge-stdlib": "workspace:*" }, "devDependencies": { + "@graphql-tools/executor-http": "^3.1.0", + "@stackables/bridge-parser": "workspace:*", + "@types/node": "^25.3.2", + "graphql": "^16.13.0", + "graphql-yoga": "^5.18.0", "typescript": "^5.9.3" }, "peerDependencies": { diff --git a/packages/bridge-graphql/test/_gateway.ts b/packages/bridge-graphql/test/_gateway.ts new file mode 100644 index 00000000..1be6fe7c --- /dev/null +++ b/packages/bridge-graphql/test/_gateway.ts @@ -0,0 +1,35 @@ +import { createSchema, createYoga } from "graphql-yoga"; +import type { DocumentSource } from "../src/index.ts"; +import { bridgeTransform, useBridgeTracing } from "../src/index.ts"; +import type { ToolMap, Logger, TraceLevel } from "@stackables/bridge-core"; + +type GatewayOptions = { + context?: Record; + tools?: ToolMap; + /** Enable tool-call tracing — `"basic"` for timings only, `"full"` for everything, `"off"` to disable (default) */ + trace?: TraceLevel; + /** Structured logger passed to the engine (and to tools via ToolContext) */ + logger?: Logger; +}; + +export function createGateway( + typeDefs: string, + document: DocumentSource, + options?: GatewayOptions, +) { + const schema = createSchema({ typeDefs }); + const tracing = options?.trace ?? "off"; + + return createYoga({ + schema: bridgeTransform(schema, document, { + tools: options?.tools, + trace: tracing, + logger: options?.logger, + }), + plugins: tracing !== "off" ? [useBridgeTracing()] : [], + context: () => ({ + ...(options?.context ?? {}), + }), + graphqlEndpoint: "*", + }); +} diff --git a/packages/bridge/test/executeGraph.test.ts b/packages/bridge-graphql/test/executeGraph.test.ts similarity index 54% rename from packages/bridge/test/executeGraph.test.ts rename to packages/bridge-graphql/test/executeGraph.test.ts index f7d18ff6..f9d6fef2 100644 --- a/packages/bridge/test/executeGraph.test.ts +++ b/packages/bridge-graphql/test/executeGraph.test.ts @@ -2,7 +2,7 @@ import { buildHTTPExecutor } from "@graphql-tools/executor-http"; import { parse } from "graphql"; import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { parseBridgeFormat as parseBridge } from "../src/index.ts"; +import { parseBridgeFormat as parseBridge } from "@stackables/bridge-parser"; import { createGateway } from "./_gateway.ts"; const typeDefs = /* GraphQL */ ` @@ -363,3 +363,290 @@ bridge Query.catalog { }); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// GraphQL-specific behavior +// +// These tests cover aspects unique to the GraphQL driver: +// - Per-field error reporting (errors don't fail the entire response) +// - Fields without bridge instructions fall through to default resolvers +// - Mutation support via GraphQL +// - Multiple bridge fields in one query +// ═══════════════════════════════════════════════════════════════════════════ + +describe("executeGraph: per-field error handling", () => { + test("tool error surfaces as GraphQL field error, not full failure", async () => { + const typeDefs = /* GraphQL */ ` + type Query { + lookup(q: String!): Result + } + type Result { + label: String + score: Int + } + `; + + const bridge = `version 1.5 +bridge Query.lookup { + with geocoder as g + with input as i + with output as o + + g.q <- i.q + o.label <- g.label + o.score <- g.score +}`; + + const instructions = parseBridge(bridge); + const gateway = createGateway(typeDefs, instructions, { + tools: { + geocoder: async () => { + throw new Error("API rate limit exceeded"); + }, + }, + }); + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + const result: any = await executor({ + document: parse(`{ lookup(q: "x") { label score } }`), + }); + + // GraphQL returns partial data + errors array + assert.ok( + result.errors, + `errors array should be present, got: ${JSON.stringify(result)}`, + ); + assert.ok(result.errors.length > 0, "should have at least one error"); + // GraphQL-yoga may wrap errors — check message contains original text + // or the error is at least present with a path + const hasToolError = result.errors.some( + (e: any) => + e.message.includes("API rate limit exceeded") || + e.message === "Unexpected error.", + ); + assert.ok( + hasToolError, + `expected a field error, got: ${JSON.stringify(result.errors.map((e: any) => e.message))}`, + ); + }); + + test("error in one field does not prevent other fields from resolving", async () => { + const typeDefs = /* GraphQL */ ` + type Query { + good: GoodResult + bad: BadResult + } + type GoodResult { + value: String + } + type BadResult { + value: String + } + `; + + const bridge = `version 1.5 +bridge Query.good { + with output as o + o.value = "hello" +} + +bridge Query.bad { + with failing as f + with output as o + o.value <- f.value +}`; + + const instructions = parseBridge(bridge); + const gateway = createGateway(typeDefs, instructions, { + tools: { + failing: async () => { + throw new Error("tool broke"); + }, + }, + }); + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + const result: any = await executor({ + document: parse(`{ good { value } bad { value } }`), + }); + + // Good field resolves + assert.equal(result.data.good.value, "hello"); + // Bad field errors but doesn't break the whole response + assert.ok(result.errors, "errors present"); + assert.ok( + result.errors.some((e: any) => e.path?.includes("bad")), + "error path should reference 'bad' field", + ); + }); +}); + +describe("executeGraph: field fallthrough", () => { + test("field without bridge instruction falls through to default resolver", async () => { + const typeDefs = /* GraphQL */ ` + type Query { + bridged(name: String!): BridgedResult + unbridged: String + } + type BridgedResult { + greeting: String + } + `; + + const bridge = `version 1.5 +bridge Query.bridged { + with input as i + with output as o + o.greeting <- i.name +}`; + + const instructions = parseBridge(bridge); + // unbridged has no bridge instruction — should use default resolver + const { createSchema } = await import("graphql-yoga"); + const { bridgeTransform } = await import("../src/index.ts"); + + const rawSchema = createSchema({ + typeDefs, + resolvers: { + Query: { + unbridged: () => "hand-coded", + }, + }, + }); + const schema = bridgeTransform(rawSchema, instructions); + const { createYoga } = await import("graphql-yoga"); + const yoga = createYoga({ schema, graphqlEndpoint: "*" }); + const executor = buildHTTPExecutor({ fetch: yoga.fetch as any }); + + const result: any = await executor({ + document: parse(`{ bridged(name: "World") { greeting } unbridged }`), + }); + + assert.equal(result.data.bridged.greeting, "World"); + assert.equal(result.data.unbridged, "hand-coded"); + }); +}); + +describe("executeGraph: mutations via GraphQL", () => { + test("sends email mutation and extracts response header path", async () => { + const typeDefs = /* GraphQL */ ` + type Query { + _: Boolean + } + type Mutation { + sendEmail( + to: String! + from: String! + subject: String! + body: String! + ): EmailResult + } + type EmailResult { + messageId: String + } + `; + + const bridgeText = `version 1.5 +bridge Mutation.sendEmail { + with sendgrid.send as sg + with input as i + with output as o + + sg.to <- i.to + sg.from <- i.from + sg.subject <- i.subject + sg.content <- i.body + o.messageId <- sg.headers.x-message-id +}`; + + const fakeEmailTool = async (_params: Record) => ({ + statusCode: 202, + headers: { "x-message-id": "msg_abc123" }, + body: { message: "Queued" }, + }); + + const instructions = parseBridge(bridgeText); + const gateway = createGateway(typeDefs, instructions, { + tools: { "sendgrid.send": fakeEmailTool }, + }); + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + + const result: any = await executor({ + document: parse(` + mutation { + sendEmail( + to: "alice@example.com" + from: "bob@example.com" + subject: "Hello" + body: "Hi there" + ) { + messageId + } + } + `), + }); + assert.equal(result.data.sendEmail.messageId, "msg_abc123"); + }); + + test("tool receives renamed fields from mutation args", async () => { + const typeDefs = /* GraphQL */ ` + type Query { + _: Boolean + } + type Mutation { + sendEmail( + to: String! + from: String! + subject: String! + body: String! + ): EmailResult + } + type EmailResult { + messageId: String + } + `; + + const bridgeText = `version 1.5 +bridge Mutation.sendEmail { + with sendgrid.send as sg + with input as i + with output as o + + sg.to <- i.to + sg.from <- i.from + sg.subject <- i.subject + sg.content <- i.body + o.messageId <- sg.headers.x-message-id +}`; + + let capturedParams: Record = {}; + const capture = async (params: Record) => { + capturedParams = params; + return { headers: { "x-message-id": "test" } }; + }; + + const instructions = parseBridge(bridgeText); + const gateway = createGateway(typeDefs, instructions, { + tools: { "sendgrid.send": capture }, + }); + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + + await executor({ + document: parse(` + mutation { + sendEmail( + to: "alice@example.com" + from: "bob@example.com" + subject: "Hello" + body: "Hi there" + ) { + messageId + } + } + `), + }); + + assert.equal(capturedParams.to, "alice@example.com"); + assert.equal(capturedParams.from, "bob@example.com"); + assert.equal(capturedParams.subject, "Hello"); + assert.equal(capturedParams.content, "Hi there"); // body -> content rename + }); +}); diff --git a/packages/bridge/test/logging.test.ts b/packages/bridge-graphql/test/logging.test.ts similarity index 97% rename from packages/bridge/test/logging.test.ts rename to packages/bridge-graphql/test/logging.test.ts index 00c75d76..135f2a7e 100644 --- a/packages/bridge/test/logging.test.ts +++ b/packages/bridge-graphql/test/logging.test.ts @@ -3,9 +3,9 @@ import { createSchema, createYoga } from "graphql-yoga"; import { parse } from "graphql"; import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { parseBridgeFormat as parseBridge } from "../src/index.ts"; +import { parseBridgeFormat as parseBridge } from "@stackables/bridge-parser"; import { bridgeTransform } from "../src/index.ts"; -import type { Logger } from "../src/index.ts"; +import type { Logger } from "@stackables/bridge-core"; // ═══════════════════════════════════════════════════════════════════════════ // Logging diff --git a/packages/bridge/test/property-search.bridge b/packages/bridge-graphql/test/property-search.bridge similarity index 100% rename from packages/bridge/test/property-search.bridge rename to packages/bridge-graphql/test/property-search.bridge diff --git a/packages/bridge/test/property-search.test.ts b/packages/bridge-graphql/test/property-search.test.ts similarity index 98% rename from packages/bridge/test/property-search.test.ts rename to packages/bridge-graphql/test/property-search.test.ts index b4ba7768..44825c20 100644 --- a/packages/bridge/test/property-search.test.ts +++ b/packages/bridge-graphql/test/property-search.test.ts @@ -3,7 +3,7 @@ import { parse } from "graphql"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { describe, test } from "node:test"; -import { parseBridgeFormat as parseBridge } from "../src/index.ts"; +import { parseBridgeFormat as parseBridge } from "@stackables/bridge-parser"; import { createGateway } from "./_gateway.ts"; const typeDefs = /* GraphQL */ ` diff --git a/packages/bridge/test/tracing.test.ts b/packages/bridge-graphql/test/tracing.test.ts similarity index 99% rename from packages/bridge/test/tracing.test.ts rename to packages/bridge-graphql/test/tracing.test.ts index cffaed7c..8c030dcd 100644 --- a/packages/bridge/test/tracing.test.ts +++ b/packages/bridge-graphql/test/tracing.test.ts @@ -2,8 +2,8 @@ import { buildHTTPExecutor } from "@graphql-tools/executor-http"; import { parse } from "graphql"; import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import type { ToolTrace } from "../src/index.ts"; -import { parseBridgeFormat as parseBridge } from "../src/index.ts"; +import type { ToolTrace } from "@stackables/bridge-core"; +import { parseBridgeFormat as parseBridge } from "@stackables/bridge-parser"; import { createGateway } from "./_gateway.ts"; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/bridge/test/email.test.ts b/packages/bridge/test/email.test.ts deleted file mode 100644 index f5e8ffd6..00000000 --- a/packages/bridge/test/email.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { buildHTTPExecutor } from "@graphql-tools/executor-http"; -import { parse } from "graphql"; -import assert from "node:assert/strict"; -import { describe, test } from "node:test"; -import { parseBridgeFormat as parseBridge } from "../src/index.ts"; -import { createGateway } from "./_gateway.ts"; - -const typeDefs = /* GraphQL */ ` - type Query { - _: Boolean - } - type Mutation { - sendEmail( - to: String! - from: String! - subject: String! - body: String! - ): EmailResult - } - type EmailResult { - messageId: String - } -`; - -const bridgeText = `version 1.5 -bridge Mutation.sendEmail { - with sendgrid.send as sg - with input as i - with output as o - -sg.to <- i.to -sg.from <- i.from -sg.subject <- i.subject -sg.content <- i.body -o.messageId <- sg.headers.x-message-id - -}`; - -const fakeEmailTool = async (_params: Record) => ({ - statusCode: 202, - headers: { - "x-message-id": "msg_abc123", - }, - body: { message: "Queued" }, -}); - -function makeExecutor() { - const instructions = parseBridge(bridgeText); - const gateway = createGateway(typeDefs, instructions, { - tools: { "sendgrid.send": fakeEmailTool }, - }); - return buildHTTPExecutor({ fetch: gateway.fetch as any }); -} - -describe("email mutation", () => { - test("sends email and extracts message id from response header path", async () => { - const executor = makeExecutor(); - const result: any = await executor({ - document: parse(` - mutation { - sendEmail( - to: "alice@example.com" - from: "bob@example.com" - subject: "Hello" - body: "Hi there" - ) { - messageId - } - } - `), - }); - assert.equal(result.data.sendEmail.messageId, "msg_abc123"); - }); - - test("tool receives renamed fields", async () => { - let capturedParams: Record = {}; - const capture = async (params: Record) => { - capturedParams = params; - return { headers: { "x-message-id": "test" } }; - }; - - const instructions = parseBridge(bridgeText); - const gateway = createGateway(typeDefs, instructions, { - tools: { "sendgrid.send": capture }, - }); - const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); - - await executor({ - document: parse(` - mutation { - sendEmail( - to: "alice@example.com" - from: "bob@example.com" - subject: "Hello" - body: "Hi there" - ) { - messageId - } - } - `), - }); - - assert.equal(capturedParams.to, "alice@example.com"); - assert.equal(capturedParams.from, "bob@example.com"); - assert.equal(capturedParams.subject, "Hello"); - assert.equal(capturedParams.content, "Hi there"); // body -> content rename - }); -}); diff --git a/packages/bridge/test/parser-compat.test.ts b/packages/bridge/test/parser-compat.test.ts index cbe7e15e..3dfdea1e 100644 --- a/packages/bridge/test/parser-compat.test.ts +++ b/packages/bridge/test/parser-compat.test.ts @@ -345,7 +345,6 @@ describe("parser — real .bridge files", () => { const bridgeFiles = [ join(root, "examples/weather-api/Weather.bridge"), join(root, "examples/builtin-tools/builtin-tools.bridge"), - join(__dirname, "property-search.bridge"), ]; for (const filePath of bridgeFiles) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38f0c3e7..318b2b3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,10 +185,22 @@ importers: '@stackables/bridge-stdlib': specifier: workspace:* version: link:../bridge-stdlib + devDependencies: + '@graphql-tools/executor-http': + specifier: ^3.1.0 + version: 3.1.0(@types/node@25.3.2)(graphql@16.13.0) + '@stackables/bridge-parser': + specifier: workspace:* + version: link:../bridge-parser + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 graphql: - specifier: ^16 + specifier: ^16.13.0 version: 16.13.0 - devDependencies: + graphql-yoga: + specifier: ^5.18.0 + version: 5.18.0(graphql@16.13.0) typescript: specifier: ^5.9.3 version: 5.9.3 From 686a6b4274b103ac01744140d71f9c695145000f Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 15:32:37 +0100 Subject: [PATCH 7/8] refactor: remove check exports script and related commands --- .github/workflows/test.yml | 2 - package.json | 1 - scripts/check-exports.mjs | 95 -------------------------------------- 3 files changed, 98 deletions(-) delete mode 100644 scripts/check-exports.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c28b5b0..7c415d79 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,8 +22,6 @@ jobs: run: pnpm install - name: Build run: pnpm build - - name: Check Exports - run: pnpm check:exports - name: Lint Types run: pnpm lint:types - name: Test diff --git a/package.json b/package.json index 53367f34..559f7048 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "test": "pnpm -r test", "build": "pnpm -r --filter './packages/*' lint:types", "lint": "eslint .", - "check:exports": "node scripts/check-exports.mjs", "smoke": "node scripts/smoke-test-packages.mjs", "e2e": "pnpm -r e2e", "depcheck": "pnpm -r exec pnpm dlx depcheck", diff --git a/scripts/check-exports.mjs b/scripts/check-exports.mjs deleted file mode 100644 index 2a45afc1..00000000 --- a/scripts/check-exports.mjs +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env node - -/** - * Validates that every publishable package's exported entry points - * (main, types, and all export-map conditions) resolve to real files - * after `pnpm build`. - * - * This catches the rootDir-drift bug where tsc outputs nested folders - * (e.g. build/bridge-core/src/) instead of flat build/ output. - * - * Run: node scripts/check-exports.mjs - */ - -import { readFileSync, existsSync } from "node:fs"; -import { resolve, dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = resolve(__dirname, ".."); - -// Discover packages by scanning the ./packages directory -const packageDirs = []; - -// Find all publishable package.json files (those with a "name" starting with @stackables/) -import { readdirSync } from "node:fs"; - -function findPublishablePackages(baseDir) { - const entries = readdirSync(baseDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const pkgJsonPath = join(baseDir, entry.name, "package.json"); - if (existsSync(pkgJsonPath)) { - const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")); - if (pkg.name?.startsWith("@stackables/") && !pkg.private) { - packageDirs.push({ - name: pkg.name, - dir: join(baseDir, entry.name), - pkg, - }); - } - } - } -} - -findPublishablePackages(join(root, "packages")); - -let errors = 0; - -for (const { name, dir, pkg } of packageDirs) { - const filesToCheck = []; - - // Collect main and types - if (pkg.main) filesToCheck.push({ field: "main", file: pkg.main }); - if (pkg.types) filesToCheck.push({ field: "types", file: pkg.types }); - - // Collect all export conditions (except "source" which points to src/) - if (pkg.exports) { - for (const [exportPath, conditions] of Object.entries(pkg.exports)) { - if (typeof conditions === "string") { - filesToCheck.push({ - field: `exports["${exportPath}"]`, - file: conditions, - }); - } else if (typeof conditions === "object") { - for (const [condition, file] of Object.entries(conditions)) { - if (condition === "source") continue; // source points to src/, skip - filesToCheck.push({ - field: `exports["${exportPath}"].${condition}`, - file, - }); - } - } - } - } - - for (const { field, file } of filesToCheck) { - const resolved = resolve(dir, file); - if (!existsSync(resolved)) { - console.error(` ✗ ${name} → ${field}: ${file} does not exist`); - errors++; - } else { - console.log(` ✓ ${name} → ${field}: ${file}`); - } - } -} - -console.log(); -if (errors > 0) { - console.error( - `✗ ${errors} missing export(s) detected. Build output is broken.`, - ); - process.exit(1); -} else { - console.log(`✓ All package exports verified.`); -} From f194369ca3e7a207a7b5b0516088c8b34a51aa4d Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 15:37:01 +0100 Subject: [PATCH 8/8] Reorder and update CI steps in test workflow --- .github/workflows/test.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c415d79..acc842aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,11 +20,9 @@ jobs: - uses: pnpm/action-setup@v4 - name: Install dependencies run: pnpm install - - name: Build - run: pnpm build - - name: Lint Types - run: pnpm lint:types - name: Test run: pnpm test + - name: Build + run: pnpm build - name: Lint with ESLint - run: pnpm lint:eslint + run: pnpm lint