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/.github/workflows/test.yml b/.github/workflows/test.yml index 9c28b5b0..acc842aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,13 +20,9 @@ jobs: - uses: pnpm/action-setup@v4 - name: Install dependencies run: pnpm install - - name: Build - run: pnpm build - - name: Check Exports - run: pnpm check:exports - - 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 diff --git a/AGENTS.md b/AGENTS.md index fd6fbec3..bcfe2ede 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,330 +1,88 @@ # 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** +- `nvm use 24` or similar in case correct node is not already installed +- `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 - -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. +If tests fail before your changes, **fix them first**. -### 3. Tests for new features +### Test requirements -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). +- **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 -### 4. Changesets +### Changesets -Every **user-facing** change requires a changeset. After making changes, create one: +Run `pnpm changeset` for every **user-facing** change. Skip for test-only, docs, or CI changes. -```bash -pnpm changeset -``` +### Language changes -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) +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) ``` -### 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 -``` - -### Key source files - -| 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 | +**Dependency flow (no cycles):** `bridge-types → bridge-stdlib → bridge-core → bridge-parser → bridge-compiler → bridge-graphql → bridge` ---- - -## 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 | +Tests are **co-located with each package**. The main test suites: -### Test helper +- **`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. -`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 - -All data flow is expressed as `Wire` — a discriminated union with 5 variants: - -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/package.json b/package.json index 013c1286..559f7048 100644 --- a/package.json +++ b/package.json @@ -5,10 +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 .", - "check:exports": "node scripts/check-exports.mjs", + "build": "pnpm -r --filter './packages/*' lint:types", + "lint": "eslint .", "smoke": "node scripts/smoke-test-packages.mjs", "e2e": "pnpm -r e2e", "depcheck": "pnpm -r exec pnpm dlx depcheck", 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/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index be2c55c9..91df2b7b 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,68 @@ 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. 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) { + 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..04f9e76b 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -46,6 +46,10 @@ 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 +108,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. */ + requestedFields: string[] | undefined; constructor( public trunk: Trunk, @@ -657,9 +663,19 @@ 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 = prefixStr ? `${prefixStr}.${sub}` : sub; + 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 +769,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 +787,7 @@ export class ExecutionTree implements TreeContext { } this.push(input); + this.requestedFields = requestedFields; const forcePromises = this.executeForced(); const { type, field } = this.trunk; @@ -832,10 +856,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/materializeShadows.ts b/packages/bridge-core/src/materializeShadows.ts index 1c3196c3..eb46da56 100644 --- a/packages/bridge-core/src/materializeShadows.ts +++ b/packages/bridge-core/src/materializeShadows.ts @@ -13,6 +13,7 @@ import { SELF_MODULE } from "./types.ts"; import { setNested } from "./tree-utils.ts"; import { isPromise, CONTINUE_SYM, BREAK_SYM } from "./tree-types.ts"; import type { MaybePromise, Trunk } from "./tree-types.ts"; +import { matchesRequestedFields } from "./requested-fields.ts"; // ── Context interface ─────────────────────────────────────────────────────── @@ -24,6 +25,8 @@ import type { MaybePromise, Trunk } from "./tree-types.ts"; export interface MaterializerHost { readonly bridge: { readonly wires: readonly Wire[] } | undefined; readonly trunk: Trunk; + /** Sparse fieldset filter — passed through from ExecutionTree. */ + readonly requestedFields?: string[] | undefined; } // ── Shadow tree duck type ─────────────────────────────────────────────────── @@ -116,6 +119,26 @@ export async function materializeShadows( pathPrefix, ); + // Apply sparse fieldset filter: remove fields not matched by requestedFields. + const { requestedFields } = host; + if (requestedFields && requestedFields.length > 0) { + const prefixStr = pathPrefix.join("."); + for (const name of [...directFields]) { + const fullPath = prefixStr ? `${prefixStr}.${name}` : name; + if (!matchesRequestedFields(fullPath, requestedFields)) { + directFields.delete(name); + const pathKey = [...pathPrefix, name].join("\0"); + wireGroupsByPath.delete(pathKey); + } + } + for (const [name] of [...deepPaths]) { + const fullPath = prefixStr ? `${prefixStr}.${name}` : name; + if (!matchesRequestedFields(fullPath, requestedFields)) { + deepPaths.delete(name); + } + } + } + // #9/#10: Fast path — no nested arrays, only direct fields. // Collect all (shadow × field) resolutions. When every value is already in // state (the hot case for element passthrough), resolvePreGrouped returns @@ -205,11 +228,19 @@ export async function materializeShadows( for (const [name, paths] of deepPaths) { if (directFields.has(name)) continue; + // Filter individual deep paths against requestedFields + const activePaths = + requestedFields && requestedFields.length > 0 + ? paths.filter((fp) => + matchesRequestedFields(fp.join("."), requestedFields), + ) + : paths; + if (activePaths.length === 0) continue; tasks.push( (async () => { const nested: Record = {}; await Promise.all( - paths.map(async (fullPath) => { + activePaths.map(async (fullPath) => { const value = await shadow.pullOutputField(fullPath); setNested(nested, fullPath.slice(pathPrefix.length + 1), value); }), 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-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/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index 12ebb72c..5c4b6c94 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,350 @@ 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, + }, + + // ── 7. Array-mapped output with requestedFields ────────────────────── + { + name: "array-mapped output filters top-level fields via requestedFields", + bridgeText: `version 1.5 +bridge Query.trips { + with input as i + with api as a + with output as o + + a.from <- i.from + a.to <- i.to + + o <- a.items[] as item { + .id <- item.id + .provider <- item.provider + .price <- item.price + .legs <- item.legs + } +}`, + operation: "Query.trips", + input: { from: "A", to: "B" }, + tools: { + api: () => ({ + items: [ + { id: 1, provider: "X", price: 50, legs: [{ name: "L1" }] }, + { id: 2, provider: "Y", price: 80, legs: [{ name: "L2" }] }, + ], + }), + }, + requestedFields: ["id", "legs"], + expected: [ + { id: 1, legs: [{ name: "L1" }] }, + { id: 2, legs: [{ name: "L2" }] }, + ], + // AOT doesn't support per-element sparse fieldsets yet. + aotSupported: false, + }, + + // ── 8. Array-mapped output: nested path filters within elements ────── + { + name: "array-mapped output with nested requestedFields path", + bridgeText: `version 1.5 +bridge Query.trains { + with input as i + with api as a + with output as o + + a.from <- i.from + a.to <- i.to + + o <- a.connections[] as c { + .id <- c.id + .provider = "SBB" + .departureTime <- c.departure + + .legs <- c.sections[] as s { + .trainName <- s.name + .destination <- s.dest + } + } +}`, + operation: "Query.trains", + input: { from: "Bern", to: "Zürich" }, + tools: { + api: () => ({ + connections: [ + { + id: 1, + departure: "08:00", + sections: [ + { name: "IC1", dest: "Zürich" }, + { name: "IC2", dest: "Basel" }, + ], + }, + ], + }), + }, + requestedFields: ["legs.destination"], + expected: [ + { + legs: [{ destination: "Zürich" }, { destination: "Basel" }], + }, + ], + aotSupported: false, + }, + + // ── 9. Deeply nested path inside array-mapped output ───────────────── + { + name: "array-mapped output: deep nested path filters sub-fields", + bridgeText: `version 1.5 +bridge Query.trains { + with input as i + with api as a + with output as o + + a.from <- i.from + + o <- a.connections[] as c { + .id <- c.id + .provider = "SBB" + + .legs <- c.sections[] as s { + .trainName <- s.name + + .destination.station.name <- s.arrStation + .destination.plannedTime <- s.arrTime + .destination.actualTime <- s.arrActual + .destination.platform <- s.arrPlatform + } + } +}`, + operation: "Query.trains", + input: { from: "Bern" }, + tools: { + api: () => ({ + connections: [ + { + id: 1, + sections: [ + { + name: "IC1", + arrStation: "Zürich", + arrTime: "08:30", + arrActual: "08:32", + arrPlatform: "3", + }, + ], + }, + ], + }), + }, + requestedFields: ["legs.destination.actualTime"], + expected: [ + { + legs: [{ destination: { actualTime: "08:32" } }], + }, + ], + aotSupported: false, + }, +]; + +runSharedSuite( + "Shared: sparse fieldsets (requestedFields)", + sparseFieldsetCases, +); diff --git a/packages/docs-site/src/content/docs/advanced/execution-modes.mdx b/packages/docs-site/src/content/docs/advanced/execution-modes.mdx new file mode 100644 index 00000000..3b0c7152 --- /dev/null +++ b/packages/docs-site/src/content/docs/advanced/execution-modes.mdx @@ -0,0 +1,58 @@ +--- +title: Execution Modes +description: Understand the differences between the Bridge Compiler, Core Interpreter, and GraphQL Adapter. +--- + +Once you have parsed your `.bridge` files into a JSON AST (Abstract Syntax Tree), you need to execute them. + +Bridge provides three distinct execution engines to fit your exact architecture and error-handling requirements. + +## High-Level Comparison + +| Feature | `@stackables/bridge-compiler` | `@stackables/bridge-core` | `@stackables/bridge-graphql` | +| -------------------- | ----------------------------- | ----------------------------------- | --------------------------------- | +| **Execution Method** | Native JavaScript (JIT/AOT) | AST Interpreter (Dynamic) | GraphQL Resolver Mapping | +| **Performance** | 🚀 Maximum (Bare-metal JS) | ⚡ Fast (Pull-based) | ⏱️ Standard GraphQL speed | +| **Environment** | Node.js, Bun, standard Deno | **Any** (Including Edge/Cloudflare) | Any GraphQL Server | +| **Sparse Fieldsets** | Yes (Compile-time DCE) | Yes (Runtime Pull-resolution) | Yes (Implicit via lazy resolvers) | +| **Error Model** | **Strict** (All-or-nothing) | **Strict** (All-or-nothing) | **Partial Success** (Graceful) | + + +## 1. The Native Compiler +#### @stackables/bridge-compiler + +**The Speed Demon.** This engine mathematically sorts your AST and generates a single, hyper-optimized JavaScript function in memory using `new AsyncFunction()`. + +- **Why use it:** It eliminates framework overhead, making it capable of handling massive Requests Per Second (RPS) with near-zero latency. Perfect for Node.js REST APIs. +- **Dead-Code Elimination:** If you pass `requestedFields`, the compiler mathematically eliminates tools for unrequested data _before_ generating the JavaScript. +- **The Error Model (Strict):** If a downstream tool fails and you didn't write a `catch` statement, the generated function throws a fatal error (HTTP 500). +- **Limitations:** It **cannot** run in strict Edge V8 Isolates (like Cloudflare Workers) because it evaluates strings dynamically. + +## 2. The Core Interpreter +#### @stackables/bridge-core + +**The Edge Native.** This engine dynamically walks the AST at runtime using a pull-based `ExecutionTree`. + +- **Why use it:** It runs absolutely anywhere. Because it doesn't use `eval()`, it is 100% compatible with strict CSPs and Edge runtimes. +- **Dynamic Sparse Fieldsets:** It accepts a `requestedFields` array and simply drops the unneeded output wires dynamically at runtime. +- **The Error Model (Strict):** Like the compiler, uncaught tool errors halt the execution tree and reject the request. + +## 3. The GraphQL Adapter +#### @stackables/bridge-graphql + +**The Gateway.** This adapter hooks Bridge directly into standard GraphQL engines like Apollo or GraphQL Yoga. + +* **Why use it:** To give your frontend teams the ultimate flexibility and conform strictly to the GraphQL spec. +* **Implicit Sparse Fieldsets:** You do not need to manually parse a `requestedFields` array here. Because the adapter hooks into the native GraphQL execution tree, sparse fieldsets happen automatically. If a client omits a field from their query, the GraphQL engine simply never calls that resolver, and the underlying Bridge interpreter never evaluates the unneeded tools. +* **The Error Model (Partial Success):** This is the biggest difference. In GraphQL, if a client requests `user.name` and `user.billingProfile`, and the billing API goes down, the request **does not fail**. The adapter translates the error, nullifies the `billingProfile` field, and appends the error to the GraphQL `errors[]` array. The client still gets the `user.name`. + +--- + +## Decision Guide: Which should I choose? + +1. **Are you deploying to Cloudflare Workers or an Edge environment?** +👉 Use `@stackables/bridge-core`. (The compiler will be blocked by V8 security constraints). +2. **Are you building a GraphQL API where clients expect partial success and standard `errors[]` arrays?** +👉 Use `@stackables/bridge-graphql`. (It natively handles GraphQL's graceful error degradation). +3. **Are you building internal REST microservices, webhooks, or RPC endpoints in Node.js/Bun?** +👉 Use `@stackables/bridge-compiler`. (You want maximum throughput, strict HTTP 500 error boundaries, and compiled dead-code elimination). \ No newline at end of file diff --git a/packages/docs-site/src/content/docs/advanced/packages.mdx b/packages/docs-site/src/content/docs/advanced/packages.mdx index 087d8c86..7b153498 100644 --- a/packages/docs-site/src/content/docs/advanced/packages.mdx +++ b/packages/docs-site/src/content/docs/advanced/packages.mdx @@ -1,6 +1,6 @@ --- -title: Package Selection -description: How to choose the right Bridge packages for your use case to optimize bundle size and performance. +title: Packages & Bundling +description: How to choose the right Bridge packages for your use case to optimize bundle size and deployment. --- import { Steps, Aside } from "@astrojs/starlight/components"; @@ -9,44 +9,38 @@ The Bridge is built à la carte. While you _can_ install the entire ecosystem in By splitting the engine into modular packages, you can ship exactly what you need—and nothing you don't—keeping your production bundles small. -## Packages +## The Packages -Choose your packages based on where and how you plan to execute your graphs. +Choose your packages based on where and how you plan to deploy your application. -| Package | Role | When to use it | -| --------------------------------- | ------------------ | ---------------------------------------------------------------------------------- | -| **`@stackables/bridge`** | **The All-in-One** | Quick starts, monoliths, or when bundle size doesn't matter. | -| **`@stackables/bridge-core`** | **The Engine** | Edge workers, serverless functions, and running pre-compiled `.bridge` files. | +| Package | Role | When to use it | +| --------------------------------- | ------------------ | --------------------------------------------------------------------------------- | +| **`@stackables/bridge`** | **The All-in-One** | Quick starts, monoliths, or when bundle size doesn't matter. Includes everything. | +| **`@stackables/bridge-core`** | **The Engine** | Edge workers, serverless functions, and running pre-compiled `.bridge` files. | | **`@stackables/bridge-parser`** | **The Parser** | Parsing `.bridge` files to JSON at build time, or parsing on the fly at startup. | -| **`@stackables/bridge-compiler`** | **The Compiler** | Compiling BridgeDocument into optimized JavaScript code. | -| **`@stackables/bridge-graphql`** | **The Adapter** | Wiring Bridge documents directly into an Apollo or Yoga GraphQL schema. | +| **`@stackables/bridge-compiler`** | **The Compiler** | Compiling a parsed AST into optimized native JavaScript code. | +| **`@stackables/bridge-graphql`** | **The Adapter** | Wiring Bridge documents directly into an Apollo or Yoga GraphQL schema. | ---- - -## Common Workflows +## Common Deployment Workflows Not sure which ones to pick? Here are the two most common architectural patterns for deploying The Bridge. -### Workflow A: The Standard GraphQL Server (JIT) +### Workflow A: The Monolith (All-in-One) -_This is the most common setup and the default in our Getting Started guide._ - -If you are running a traditional Node.js GraphQL server, you will usually parse your `.bridge` files "Just-In-Time" (JIT) right when the server starts up, and wire them into your schema. For this, you need the compiler and the GraphQL adapter. +If you are running a traditional Node.js server (Express, Fastify, Apollo) and don't have strict bundle size limits, the easiest approach is to use the all-in-one package. It includes the parser, the core engine, the standard library, and the GraphQL adapter. ```bash -npm install @stackables/bridge-graphql @stackables/bridge-parser graphql +npm install @stackables/bridge + ``` ```typescript -import { parseBridgeDiagnostics } from "@stackables/bridge-parser"; -import { bridgeTransform } from "@stackables/bridge-graphql"; +import { parseBridge, executeBridge } from "@stackables/bridge"; -// Read the files, parse them into BridgeDocuments, and attach them to the schema +// The parser reads the file at startup, and executeBridge runs it! ``` -_(Note: If you don't mind the slightly larger `node_modules` folder, you can simply install `@stackables/bridge` to grab all of these in one import)._ - -### Workflow B: The Lean Edge Worker (AOT) +### Workflow B: The Lean Edge Worker (Pre-Parsed) If you are deploying to a constrained environment like a Cloudflare Worker or a Vercel Edge function, you want the absolute smallest bundle possible. @@ -55,19 +49,21 @@ Instead of parsing files on the server, you parse them "Ahead-Of-Time" (AOT) dur 1. **The Build Step (Dev/CI)** - Install the compiler as a dev dependency so it never touches your production bundle. + Install the parser as a dev dependency so it never touches your production bundle. ```bash npm install --save-dev @stackables/bridge-parser + ``` - Write a quick build script to compile your `.bridge` files into a single `bridge.json` file. + Write a quick build script to convert your `.bridge` files into a single `bridge.json` file. 2. **The Production Runtime** Install only the core execution engine for your runtime. ```bash npm install @stackables/bridge-core + ``` At runtime, simply feed the pre-compiled JSON into the engine! 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..478c6fb2 --- /dev/null +++ b/packages/docs-site/src/content/docs/advanced/sparse-fieldsets.mdx @@ -0,0 +1,123 @@ +--- +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 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, + operation: "Query.searchTrains", + input: req.query, + requestedFields: fields, + tools: { /* ... */ }, + }); + + res.json(data); +}); + +``` + +```text +GET /api/trains?from=Bern&to=Zürich&fields=id,status,legs.duration + +``` + +## Example: GraphQL Adapter + +If you are using the `@stackables/bridge-graphql` adapter, you do not need to use the `requestedFields` option at all. + +Because the adapter hooks directly into the native GraphQL execution engine, sparse fieldsets are handled **automatically and implicitly**. If a client omits a field from their GraphQL query, the GraphQL engine simply never calls the resolver for that field. Because Bridge's interpreter executes lazily based on those resolver calls, any tools that feed exclusively into that unrequested field are never pulled. + +You get perfect, pull-based efficiency out of the box, while retaining GraphQL's standard per-field error handling (Partial Success). + +## 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. + + diff --git a/packages/playground/package.json b/packages/playground/package.json index a4a40f88..d02d19f5 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -21,6 +21,7 @@ "@codemirror/view": "^6.39.15", "@lezer/highlight": "^1.2.3", "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slot": "1.2.4", "@stackables/bridge": "workspace:*", diff --git a/packages/playground/src/App.tsx b/packages/playground/src/App.tsx index 6697c4c3..933f2838 100644 --- a/packages/playground/src/App.tsx +++ b/packages/playground/src/App.tsx @@ -7,8 +7,18 @@ import { } from "react-resizable-panels"; import { Editor } from "./components/Editor"; import { ResultView } from "./components/ResultView"; +import { StandaloneQueryPanel } from "./components/StandaloneQueryPanel"; import { examples } from "./examples"; -import { runBridge, getDiagnostics, clearHttpCache } from "./engine"; +import { + runBridge, + runBridgeStandalone, + getDiagnostics, + extractBridgeOperations, + extractOutputFields, + extractInputSkeleton, + mergeInputSkeleton, + clearHttpCache, +} from "./engine"; import type { RunResult } from "./engine"; import { buildSchema, type GraphQLSchema } from "graphql"; import { Button } from "@/components/ui/button"; @@ -21,7 +31,12 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { ShareDialog } from "./components/ShareDialog"; -import { getShareIdFromUrl, loadShare, clearShareIdFromUrl } from "./share"; +import { + getShareIdFromUrl, + loadShare, + clearShareIdFromUrl, + type PlaygroundMode, +} from "./share"; import { ChevronLeftIcon } from "lucide-react"; // ── resize handle — transparent hit area, no visual indicator ──────────────── @@ -39,7 +54,18 @@ function ResizeHandle({ direction }: { direction: "horizontal" | "vertical" }) { } // ── query tab type ──────────────────────────────────────────────────────────── -type QueryTab = { id: string; name: string; query: string }; +type QueryTab = { + id: string; + name: string; + /** Whether the name was explicitly set by the user (disables auto-rename). */ + nameManual?: boolean; + /** GraphQL query text (graphql mode). */ + query: string; + /** Standalone mode fields — parallel to the graphql `query` field. */ + operation?: string; + outputFields?: string; + inputJson?: string; +}; // ── extract GraphQL operation name from query text ─────────────────────────── function extractOperationName(query: string): string | null { @@ -62,6 +88,7 @@ type QueryTabBarProps = { onSelectTab: (id: string) => void; onAddQuery: () => void; onRemoveQuery: (id: string) => void; + onRenameQuery: (id: string, name: string) => void; onRun: () => void; runDisabled: boolean; running: boolean; @@ -73,6 +100,7 @@ function QueryTabBar({ onSelectTab, onAddQuery, onRemoveQuery, + onRenameQuery, onRun, runDisabled, running, @@ -80,6 +108,18 @@ function QueryTabBar({ }: QueryTabBarProps) { const isQueryTab = activeTabId !== "context"; const canRemove = queries.length > 1; + const [editingId, setEditingId] = useState(null); + const editRef = useRef(null); + + const commitRename = useCallback( + (id: string) => { + const val = editRef.current?.value.trim(); + if (val) onRenameQuery(id, val); + setEditingId(null); + }, + [onRenameQuery], + ); + return (
{/* Context tab — always first */} @@ -106,12 +146,31 @@ function QueryTabBar({ : "border-transparent text-slate-500 hover:text-slate-300", )} > - + {editingId === q.id ? ( + commitRename(q.id)} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(q.id); + if (e.key === "Escape") setEditingId(null); + }} + /> + ) : ( + + )} {canRemove && ( +
+ ); +} + // ── panel wrapper ───────────────────────────────────────────────────────────── function PanelBox({ children }: { children: React.ReactNode }) { return ( @@ -207,6 +305,7 @@ export function App() { const [exampleIndex, setExampleIndex] = useState(0); const ex = examples[exampleIndex] ?? examples[0]!; + const [mode, setMode] = useState(ex.mode ?? "standalone"); const [schema, setSchema] = useState(ex.schema); const [bridge, setBridge] = useState(ex.bridge); const [context, setContext] = useState(ex.context); @@ -218,13 +317,23 @@ export function App() { // ── multi-query state ── const queryCounterRef = useRef(ex.queries.length); - const [queries, setQueries] = useState(() => - ex.queries.map((q) => ({ - id: crypto.randomUUID(), - name: q.name, - query: q.query, - })), - ); + + function buildQueryTabs(e: (typeof examples)[number]): QueryTab[] { + return e.queries.map((q, i) => { + const sq = e.standaloneQueries?.[i]; + return { + id: crypto.randomUUID(), + name: q.name, + nameManual: true, + query: q.query, + operation: sq?.operation ?? "", + outputFields: sq?.outputFields ?? "", + inputJson: sq ? JSON.stringify(sq.input, null, 2) : "{}", + }; + }); + } + + const [queries, setQueries] = useState(() => buildQueryTabs(ex)); const [activeTabId, setActiveTabId] = useState( () => queries[0]?.id ?? "context", ); @@ -252,14 +361,22 @@ export function App() { clearShareIdFromUrl(); loadShare(id) .then((payload) => { + setMode(payload.mode ?? "standalone"); setSchema(payload.schema); setBridge(payload.bridge); queryCounterRef.current = payload.queries.length; - const newQ = payload.queries.map((q) => ({ - id: crypto.randomUUID(), - name: q.name, - query: q.query, - })); + const newQ: QueryTab[] = payload.queries.map((q, i) => { + const sq = payload.standaloneQueries?.[i]; + return { + id: crypto.randomUUID(), + name: q.name, + nameManual: true, + query: q.query, + operation: sq?.operation ?? "", + outputFields: sq?.outputFields ?? "", + inputJson: sq?.inputJson ?? "{}", + }; + }); setQueries(newQ); setContext(payload.context); setResults({}); @@ -274,14 +391,11 @@ export function App() { const selectExample = useCallback((index: number) => { const e = examples[index] ?? examples[0]!; setExampleIndex(index); + if (e.mode) setMode(e.mode); setSchema(e.schema); setBridge(e.bridge); queryCounterRef.current = e.queries.length; - const newQ = e.queries.map((q) => ({ - id: crypto.randomUUID(), - name: q.name, - query: q.query, - })); + const newQ = buildQueryTabs(e); setQueries(newQ); setContext(e.context); setResults({}); @@ -293,8 +407,12 @@ export function App() { setQueries((prev) => prev.map((q) => { if (q.id !== id) return q; - const opName = extractOperationName(text); - return { ...q, query: text, ...(opName ? { name: opName } : {}) }; + // Only auto-rename from GQL operation name if the user hasn't manually renamed + if (!q.nameManual) { + const opName = extractOperationName(text); + if (opName) return { ...q, query: text, name: opName }; + } + return { ...q, query: text }; }), ); }, []); @@ -305,6 +423,9 @@ export function App() { id: crypto.randomUUID(), name: `Query ${queryCounterRef.current}`, query: "", + operation: "", + outputFields: "", + inputJson: "{}", }; setQueries((prev) => [...prev, tab]); setActiveTabId(tab.id); @@ -332,13 +453,50 @@ export function App() { [activeTabId], ); + const renameQuery = useCallback((id: string, name: string) => { + setQueries((prev) => + prev.map((q) => (q.id === id ? { ...q, name, nameManual: true } : q)), + ); + }, []); + + const updateStandaloneField = useCallback( + ( + id: string, + field: "operation" | "outputFields" | "inputJson", + value: string, + ) => { + setQueries((prev) => + prev.map((q) => { + if (q.id !== id) return q; + const updated = { ...q, [field]: value }; + // When changing operation, auto-fill input skeleton if input is default + if (field === "operation" && (!q.inputJson || q.inputJson === "{}")) { + updated.inputJson = extractInputSkeleton(bridge, value); + } + return updated; + }), + ); + }, + [bridge], + ); + const handleRun = useCallback(async () => { if (!activeQuery) return; const qId = activeQuery.id; - const qText = activeQuery.query; setRunningIds((prev) => new Set(prev).add(qId)); try { - const r = await runBridge(schema, bridge, qText, {}, context); + let r: RunResult; + if (mode === "standalone") { + r = await runBridgeStandalone( + bridge, + activeQuery.operation ?? "", + activeQuery.inputJson ?? "{}", + activeQuery.outputFields ?? "", + context, + ); + } else { + r = await runBridge(schema, bridge, activeQuery.query, {}, context); + } setResults((prev) => ({ ...prev, [qId]: r })); } finally { setRunningIds((prev) => { @@ -347,7 +505,7 @@ export function App() { return next; }); } - }, [activeQuery, schema, bridge, context]); + }, [activeQuery, mode, schema, bridge, context]); const diagnostics = getDiagnostics(bridge).diagnostics; const hasErrors = diagnostics.some((d) => d.severity === "error"); @@ -364,6 +522,97 @@ export function App() { } }, [schema]); + // Extract bridge operations for standalone mode's bridge selector + const bridgeOperations = useMemo( + () => extractBridgeOperations(bridge), + [bridge], + ); + + // Auto-select first operation when the list changes and current selection is invalid + useEffect(() => { + if (mode !== "standalone" || bridgeOperations.length === 0) return; + setQueries((prev) => + prev.map((q) => { + if ( + q.operation && + bridgeOperations.some((op) => op.label === q.operation) + ) + return q; + return { ...q, operation: bridgeOperations[0]!.label }; + }), + ); + }, [bridgeOperations, mode]); + + // Handle mode change: when switching to "standalone", auto-fill operation + // and input JSON skeleton for tabs that don't already have them. + const handleModeChange = useCallback( + (newMode: PlaygroundMode) => { + setMode(newMode); + if (newMode === "standalone") { + const ops = extractBridgeOperations(bridge); + const firstOp = ops[0]?.label ?? ""; + setQueries((prev) => + prev.map((q) => { + const op = + q.operation && ops.some((o) => o.label === q.operation) + ? q.operation + : firstOp; + const inputJson = + !q.inputJson || q.inputJson === "{}" + ? extractInputSkeleton(bridge, op) + : q.inputJson; + return { ...q, operation: op, inputJson }; + }), + ); + } + }, + [bridge], + ); + + // Extract all possible output field paths for the active standalone operation + const activeOperation = activeQuery?.operation ?? ""; + const availableOutputFields = useMemo( + () => extractOutputFields(bridge, activeOperation), + [bridge, activeOperation], + ); + + // When the bridge DSL changes in standalone mode, merge new input fields + // into each tab's inputJson (adds new fields, preserves user values). + // Also prune outputFields that no longer exist in the bridge. + const prevBridgeRef = useRef(bridge); + useEffect(() => { + if (prevBridgeRef.current === bridge) return; + prevBridgeRef.current = bridge; + if (mode !== "standalone") return; + + setQueries((prev) => + prev.map((q) => { + const op = q.operation ?? ""; + if (!op) return q; + + // Merge new input fields into existing JSON + const skeleton = extractInputSkeleton(bridge, op); + const mergedInput = mergeInputSkeleton(q.inputJson ?? "{}", skeleton); + + // Prune selected output fields that no longer exist + const currentFields = extractOutputFields(bridge, op); + const validPaths = new Set(currentFields.map((f) => f.path)); + const selectedFields = (q.outputFields ?? "") + .split(",") + .map((f) => f.trim()) + .filter((f) => f && validPaths.has(f)); + + return { + ...q, + inputJson: mergedInput, + outputFields: selectedFields.join(","), + }; + }), + ); + }, [bridge, mode]); + + const isStandalone = mode === "standalone"; + return (
{/* ── Header ── */} @@ -401,10 +650,20 @@ export function App() { All code runs in-browser · no server required ({ name: q.name, query: q.query }))} context={context} + standaloneQueries={ + isStandalone + ? queries.map((q) => ({ + operation: q.operation ?? "", + outputFields: q.outputFields ?? "", + inputJson: q.inputJson ?? "{}", + })) + : undefined + } />
@@ -432,19 +691,26 @@ export function App() { {/* ── Mobile layout: vertical scrollable stack ── */}
- {/* Schema panel */} -
- GraphQL Schema -
- + {/* Schema panel — hidden in standalone mode, shows mode toggle */} + {!isStandalone ? ( +
+ +
+ +
-
+ ) : ( + /* When in standalone, show a collapsed "GraphQL Schema" bar with the toggle */ +
+ +
+ )} {/* Bridge DSL panel */}
@@ -469,6 +735,7 @@ export function App() { onSelectTab={setActiveTabId} onAddQuery={addQuery} onRemoveQuery={removeQuery} + onRenameQuery={renameQuery} onRun={handleRun} runDisabled={isActiveRunning || hasErrors} running={isActiveRunning} @@ -485,15 +752,35 @@ export function App() { autoHeight /> ) : activeQuery ? ( - updateQuery(activeTabId, v)} - language="graphql-query" - graphqlSchema={graphqlSchema} - autoHeight - /> + isStandalone ? ( + + updateStandaloneField(activeTabId, "operation", v) + } + availableFields={availableOutputFields} + outputFields={activeQuery.outputFields ?? ""} + onOutputFieldsChange={(v) => + updateStandaloneField(activeTabId, "outputFields", v) + } + inputJson={activeQuery.inputJson ?? "{}"} + onInputJsonChange={(v) => + updateStandaloneField(activeTabId, "inputJson", v) + } + autoHeight + /> + ) : ( + updateQuery(activeTabId, v)} + language="graphql-query" + graphqlSchema={graphqlSchema} + autoHeight + /> + ) ) : null}
@@ -534,33 +821,14 @@ export function App() { defaultLayout={hLayout.defaultLayout} onLayoutChanged={hLayout.onLayoutChanged} > - {/* ── LEFT column: Schema + Bridge ── */} + {/* ── LEFT column: Schema + Bridge (or Bridge only) ── */} - - {/* Schema panel */} - - - GraphQL Schema -
- -
-
-
- - - - {/* Bridge DSL panel */} - + {isStandalone ? ( + /* Standalone: collapsed schema header + bridge fills left column */ +
+
+ +
@@ -572,8 +840,48 @@ export function App() { />
- - +
+ ) : ( + /* GraphQL mode: schema + bridge in a vertical split */ + + {/* Schema panel */} + + + +
+ +
+
+
+ + + + {/* Bridge DSL panel */} + + + +
+ +
+
+
+
+ )}
@@ -596,6 +904,7 @@ export function App() { onSelectTab={setActiveTabId} onAddQuery={addQuery} onRemoveQuery={removeQuery} + onRenameQuery={renameQuery} onRun={handleRun} runDisabled={isActiveRunning || hasErrors} running={isActiveRunning} @@ -611,14 +920,37 @@ export function App() { language="json" /> ) : activeQuery ? ( - updateQuery(activeTabId, v)} - language="graphql-query" - graphqlSchema={graphqlSchema} - /> + isStandalone ? ( + + updateStandaloneField(activeTabId, "operation", v) + } + availableFields={availableOutputFields} + outputFields={activeQuery.outputFields ?? ""} + onOutputFieldsChange={(v) => + updateStandaloneField( + activeTabId, + "outputFields", + v, + ) + } + inputJson={activeQuery.inputJson ?? "{}"} + onInputJsonChange={(v) => + updateStandaloneField(activeTabId, "inputJson", v) + } + /> + ) : ( + updateQuery(activeTabId, v)} + language="graphql-query" + graphqlSchema={graphqlSchema} + /> + ) ) : null}
diff --git a/packages/playground/src/components/FieldSelector.tsx b/packages/playground/src/components/FieldSelector.tsx new file mode 100644 index 00000000..7b7b51a0 --- /dev/null +++ b/packages/playground/src/components/FieldSelector.tsx @@ -0,0 +1,112 @@ +import { useCallback, useMemo } from "react"; +import { ChevronDown } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import type { OutputFieldNode } from "../engine"; + +type Props = { + /** All possible output fields extracted from the bridge. */ + availableFields: OutputFieldNode[]; + /** Current comma-separated selected field paths (empty = all). */ + value: string; + /** Called with updated comma-separated field paths. */ + onChange: (value: string) => void; +}; + +export function FieldSelector({ availableFields, value, onChange }: Props) { + const selected = useMemo(() => { + if (!value.trim()) return new Set(); + return new Set( + value + .split(",") + .map((f) => f.trim()) + .filter(Boolean), + ); + }, [value]); + + const isAllSelected = selected.size === 0; + + const toggleField = useCallback( + (path: string) => { + const next = new Set(selected); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + onChange([...next].join(",")); + }, + [selected, onChange], + ); + + const selectAll = useCallback(() => { + onChange(""); + }, [onChange]); + + // Display label for the trigger + const triggerLabel = isAllSelected + ? "All fields" + : selected.size === 1 + ? [...selected][0]! + : `${selected.size} fields`; + + // If no fields available, fall back to a plain text input style + if (availableFields.length === 0) { + return ( + onChange(e.target.value)} + placeholder="All fields (or: name, price, legs.*)" + className="flex-1 min-w-0 rounded-md border border-slate-700 bg-slate-900 px-2.5 py-1 text-xs text-slate-300 outline-none focus:border-sky-400 placeholder:text-slate-600" + /> + ); + } + + return ( + + + + + + e.preventDefault()} + className="font-medium" + > + All fields + + + {availableFields.map((field) => ( + toggleField(field.path)} + onSelect={(e) => e.preventDefault()} + className="font-mono" + style={{ paddingLeft: `${field.depth * 16 + 32}px` }} + > + + {field.name} + + + ))} + + + ); +} diff --git a/packages/playground/src/components/ShareDialog.tsx b/packages/playground/src/components/ShareDialog.tsx index 78137da8..eda08194 100644 --- a/packages/playground/src/components/ShareDialog.tsx +++ b/packages/playground/src/components/ShareDialog.tsx @@ -32,7 +32,14 @@ function hasContext(context: string): boolean { return context.trim().length > 0 && context.trim() !== "{}"; } -export function ShareDialog({ schema, bridge, queries, context }: Props) { +export function ShareDialog({ + mode, + schema, + bridge, + queries, + context, + standaloneQueries, +}: Props) { const [open, setOpen] = useState(false); const [phase, setPhase] = useState("idle"); const [url, setUrl] = useState(""); @@ -52,7 +59,14 @@ export function ShareDialog({ schema, bridge, queries, context }: Props) { async function handleCreate() { setPhase("loading"); try { - const id = await saveShare({ schema, bridge, queries, context }); + const id = await saveShare({ + mode, + schema, + bridge, + queries, + context, + standaloneQueries, + }); setUrl(shareUrl(id)); setPhase("done"); } catch (err) { diff --git a/packages/playground/src/components/StandaloneQueryPanel.tsx b/packages/playground/src/components/StandaloneQueryPanel.tsx new file mode 100644 index 00000000..2c04f505 --- /dev/null +++ b/packages/playground/src/components/StandaloneQueryPanel.tsx @@ -0,0 +1,84 @@ +import { Editor } from "./Editor"; +import { FieldSelector } from "./FieldSelector"; +import type { BridgeOperation, OutputFieldNode } from "../engine"; + +type Props = { + /** All bridge operations parsed from the current bridge text. */ + operations: BridgeOperation[]; + /** Currently selected operation e.g. "Query.getWeather". */ + operation: string; + onOperationChange: (op: string) => void; + /** All possible output fields for the selected operation. */ + availableFields: OutputFieldNode[]; + /** Comma-separated output field names (empty = all). */ + outputFields: string; + onOutputFieldsChange: (fields: string) => void; + /** JSON string for the input object. */ + inputJson: string; + onInputJsonChange: (json: string) => void; + /** When true, use auto-height sizing (mobile). */ + autoHeight?: boolean; +}; + +export function StandaloneQueryPanel({ + operations, + operation, + onOperationChange, + availableFields, + outputFields, + onOutputFieldsChange, + inputJson, + onInputJsonChange, + autoHeight = false, +}: Props) { + return ( +
+ {/* Bridge selector + output fields */} +
+ {/* Bridge operation dropdown */} +
+ + +
+ + {/* Output fields dropdown */} +
+ + +
+
+ + {/* Input JSON editor */} +
+ +
+
+ ); +} diff --git a/packages/playground/src/components/ui/dropdown-menu.tsx b/packages/playground/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..db6b4199 --- /dev/null +++ b/packages/playground/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,192 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/packages/playground/src/engine.ts b/packages/playground/src/engine.ts index dcf24a3f..28281aeb 100644 --- a/packages/playground/src/engine.ts +++ b/packages/playground/src/engine.ts @@ -7,9 +7,11 @@ import { parseBridgeChevrotain, parseBridgeDiagnostics, + executeBridge, } from "@stackables/bridge"; import type { BridgeDiagnostic, + Bridge, ToolTrace, Logger, CacheStore, @@ -257,3 +259,370 @@ export async function runBridge( _onCacheHit = null; } } + +// ── Standalone (no-GraphQL) helpers ────────────────────────────────────────── + +export type BridgeOperation = { type: string; field: string; label: string }; + +/** + * Extract all bridge operations from a Bridge DSL source string. + * Returns e.g. `[{ type: "Query", field: "greet", label: "Query.greet" }]`. + */ +export function extractBridgeOperations(bridgeText: string): BridgeOperation[] { + try { + const { document } = parseBridgeDiagnostics(bridgeText); + return document.instructions + .filter((i): i is Bridge => i.kind === "bridge") + .map((b) => ({ + type: b.type, + field: b.field, + label: `${b.type}.${b.field}`, + })); + } catch { + return []; + } +} + +export type OutputFieldNode = { + /** Segment name, e.g. "origin" */ + name: string; + /** Full dot-separated path, e.g. "legs.origin" */ + path: string; + /** Nesting depth (0 = top-level) */ + depth: number; + /** Whether this path has children */ + hasChildren: boolean; +}; + +/** + * Extract all possible output field paths for a specific bridge operation. + * + * Walks the parsed wires, collects every `to.path` that targets the output + * trunk, and adds intermediate ancestor paths so the tree is complete. + * + * Returns a flat, depth-sorted list ready for rendering in a tree-style + * dropdown (each node carries its depth for indentation). + */ +export function extractOutputFields( + bridgeText: string, + operation: string, +): OutputFieldNode[] { + try { + const { document } = parseBridgeDiagnostics(bridgeText); + const [type, field] = operation.split("."); + if (!type || !field) return []; + + const bridge = document.instructions.find( + (i): i is Bridge => + i.kind === "bridge" && i.type === type && i.field === field, + ); + if (!bridge) return []; + + const pathSet = new Set(); + + for (const wire of bridge.wires) { + if ( + wire.to.module === "_" && + wire.to.type === type && + wire.to.field === field && + wire.to.path.length > 0 + ) { + // Add the full path + pathSet.add(wire.to.path.join(".")); + // Add all intermediate ancestor paths + for (let i = 1; i < wire.to.path.length; i++) { + pathSet.add(wire.to.path.slice(0, i).join(".")); + } + } + } + + const allPaths = [...pathSet].sort((a, b) => { + const aParts = a.split("."); + const bParts = b.split("."); + // Sort by depth first, then alphabetically + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aP = aParts[i] ?? ""; + const bP = bParts[i] ?? ""; + if (aP !== bP) return aP.localeCompare(bP); + } + return aParts.length - bParts.length; + }); + + return allPaths.map((p) => { + const parts = p.split("."); + return { + name: parts[parts.length - 1]!, + path: p, + depth: parts.length - 1, + hasChildren: allPaths.some((other) => other.startsWith(p + ".")), + }; + }); + } catch { + return []; + } +} + +/** + * Extract input field names from a bridge operation and generate a skeleton + * JSON string with empty-string placeholders. + * + * Walks the parsed wires, collects every `from.path` that reads from the + * bridge's own input (SELF_MODULE, same type/field as the bridge trunk). + * Builds a nested object with `""` as leaf values. + * + * Returns `"{}"` if no input fields are found. + */ +export function extractInputSkeleton( + bridgeText: string, + operation: string, +): string { + try { + const { document } = parseBridgeDiagnostics(bridgeText); + const [type, field] = operation.split("."); + if (!type || !field) return "{}"; + + const bridge = document.instructions.find( + (i): i is Bridge => + i.kind === "bridge" && i.type === type && i.field === field, + ); + if (!bridge) return "{}"; + + // Collect all input field paths (from wires that read from the bridge's input). + // Exclude element wires (from array mappings like `c.field`) which also use + // SELF_MODULE but have `element: true` — those are tool response fields, not inputs. + const inputPaths: string[][] = []; + for (const wire of bridge.wires) { + if ( + "from" in wire && + wire.from.module === "_" && + wire.from.type === type && + wire.from.field === field && + wire.from.path.length > 0 && + !wire.from.element + ) { + inputPaths.push([...wire.from.path]); + } + } + + if (inputPaths.length === 0) return "{}"; + + // Build a nested skeleton object + const skeleton: Record = {}; + for (const segments of inputPaths) { + let current: Record = skeleton; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + if (i === segments.length - 1) { + // Leaf — only set if not already a deeper object + if (!(seg in current)) { + current[seg] = ""; + } + } else { + // Intermediate — ensure nested object exists + if (typeof current[seg] !== "object" || current[seg] === null) { + current[seg] = {}; + } + current = current[seg] as Record; + } + } + } + + return JSON.stringify(skeleton, null, 2); + } catch { + return "{}"; + } +} + +/** + * Build a new skeleton and fill in values from the previous JSON where + * keys match exactly. Keys that no longer exist in the skeleton are dropped; + * new skeleton keys get `""` placeholders. + */ +export function mergeInputSkeleton( + existingJson: string, + skeletonJson: string, +): string { + try { + const existing = JSON.parse(existingJson) as Record; + const skeleton = JSON.parse(skeletonJson) as Record; + + function fill( + skel: Record, + prev: Record, + ): Record { + const result: Record = {}; + for (const key of Object.keys(skel)) { + if ( + key in prev && + typeof skel[key] === "object" && + skel[key] !== null && + !Array.isArray(skel[key]) && + typeof prev[key] === "object" && + prev[key] !== null && + !Array.isArray(prev[key]) + ) { + result[key] = fill( + skel[key] as Record, + prev[key] as Record, + ); + } else if (key in prev) { + result[key] = prev[key]; + } else { + result[key] = skel[key]; + } + } + return result; + } + + const merged = fill(skeleton, existing); + return JSON.stringify(merged, null, 2); + } catch { + return skeletonJson; + } +} + +/** + * Execute a bridge operation standalone — no GraphQL schema, no server. + * + * @param bridgeText Bridge DSL source + * @param operation "Type.field" e.g. "Query.searchTrains" + * @param inputJson JSON string for input arguments + * @param requestedFields Comma-separated output field names (empty = all) + * @param contextJson JSON string for context + */ +export async function runBridgeStandalone( + bridgeText: string, + operation: string, + inputJson = "{}", + requestedFields = "", + contextJson = "{}", +): Promise { + // 1. Parse Bridge DSL + let document; + try { + const result = parseBridgeDiagnostics(bridgeText); + document = result.document; + } catch (err: unknown) { + return { + errors: [ + `Bridge parse error: ${err instanceof Error ? err.message : String(err)}`, + ], + }; + } + + // 2. Parse input JSON + let input: Record; + try { + input = inputJson.trim() + ? (JSON.parse(inputJson) as Record) + : {}; + } catch (err: unknown) { + return { + errors: [ + `Input JSON error: ${err instanceof Error ? err.message : String(err)}`, + ], + }; + } + + // 3. Parse context JSON + let context: Record; + try { + context = contextJson.trim() + ? (JSON.parse(contextJson) as Record) + : {}; + } catch (err: unknown) { + return { + errors: [ + `Context JSON error: ${err instanceof Error ? err.message : String(err)}`, + ], + }; + } + + // 4. Parse requested fields + const fields = requestedFields + .split(",") + .map((f) => f.trim()) + .filter(Boolean); + + // 5. Build logger + const logs: LogEntry[] = []; + + function formatLog(args: unknown[]): string { + if (args.length === 0) return ""; + const fmt = String(args[0]); + let i = 1; + const msg = fmt.replace(/%[sdioOjf%]/g, (token) => { + if (token === "%%") return "%"; + if (i >= args.length) return token; + const val = args[i++]; + switch (token) { + case "%d": + case "%i": + case "%f": + return String(Number(val)); + case "%o": + case "%O": + case "%j": + try { + return JSON.stringify(val); + } catch { + return String(val); + } + default: + return String(val); + } + }); + const rest = args.slice(i).map(String); + return rest.length > 0 ? `${msg} ${rest.join(" ")}` : msg; + } + + const collectingLogger: Logger = { + debug: (...args: unknown[]) => + logs.push({ level: "debug", message: formatLog(args) }), + info: (...args: unknown[]) => + logs.push({ level: "info", message: formatLog(args) }), + warn: (...args: unknown[]) => + logs.push({ level: "warn", message: formatLog(args) }), + error: (...args: unknown[]) => + logs.push({ level: "error", message: formatLog(args) }), + }; + + // 6. Execute + _onCacheHit = (key: string) => { + try { + const url = new URL(key); + logs.push({ + level: "info", + message: `⚡ cache hit: ${url.pathname}${url.search}`, + }); + } catch { + logs.push({ level: "info", message: `⚡ cache hit: ${key}` }); + } + }; + try { + const result = await executeBridge({ + document, + operation, + input, + tools: { std: { ...std, httpCall: playgroundHttpCall } }, + context, + trace: "full", + logger: collectingLogger, + ...(fields.length > 0 ? { requestedFields: fields } : {}), + }); + + return { + data: result.data, + traces: result.traces.length > 0 ? result.traces : undefined, + logs: logs.length > 0 ? logs : undefined, + }; + } catch (err: unknown) { + return { + errors: [ + `Execution error: ${err instanceof Error ? err.message : String(err)}`, + ], + }; + } finally { + _onCacheHit = null; + } +} diff --git a/packages/playground/src/examples.ts b/packages/playground/src/examples.ts index 83fb2d57..ada4db6d 100644 --- a/packages/playground/src/examples.ts +++ b/packages/playground/src/examples.ts @@ -1,10 +1,19 @@ +import type { PlaygroundMode } from "./share"; + export type Example = { name: string; description: string; + mode?: PlaygroundMode; schema: string; bridge: string; queries: { name: string; query: string }[]; context: string; + /** Standalone-mode per-query state (parallel array to queries). */ + standaloneQueries?: { + operation: string; + outputFields: string; + input: Record; + }[]; }; export const examples: Example[] = [ @@ -47,6 +56,13 @@ bridge Query.greet { }`, }, ], + standaloneQueries: [ + { + operation: "Query.greet", + outputFields: "", + input: { name: "Hello Bridge" }, + }, + ], context: `{}`, }, { @@ -121,6 +137,13 @@ bridge Query.profile { }`, }, ], + standaloneQueries: [ + { + operation: "Query.profile", + outputFields: "", + input: {}, + }, + ], context: `{ "user": { "id": "usr_42", @@ -171,6 +194,13 @@ bridge Query.location { }`, }, ], + standaloneQueries: [ + { + operation: "Query.location", + outputFields: "", + input: { city: "Berlin" }, + }, + ], context: `{}`, }, { @@ -292,6 +322,18 @@ bridge Query.searchTrains { }`, }, ], + standaloneQueries: [ + { + operation: "Query.searchTrains", + outputFields: "", + input: { from: "Bern", to: "Zürich" }, + }, + { + operation: "Query.searchTrains", + outputFields: "departureTime,arrivalTime,transfers", + input: { from: "Zürich", to: "Genève" }, + }, + ], context: `{}`, }, { @@ -328,6 +370,13 @@ bridge Query.echo { }`, }, ], + standaloneQueries: [ + { + operation: "Query.echo", + outputFields: "", + input: { text: "Hello Bridge!", count: 42 }, + }, + ], context: `{}`, }, { @@ -367,6 +416,13 @@ bridge Query.pricing { }`, }, ], + standaloneQueries: [ + { + operation: "Query.pricing", + outputFields: "", + input: { dollars: 9.99, quantity: 3 }, + }, + ], context: `{}`, }, { @@ -415,6 +471,13 @@ bridge Query.pricing { }`, }, ], + standaloneQueries: [ + { + operation: "Query.pricing", + outputFields: "", + input: { isPro: true, proPrice: 49.99, basicPrice: 9.99 }, + }, + ], context: `{}`, }, { @@ -476,6 +539,13 @@ bridge Mutation.submitFeedback { }`, }, ], + standaloneQueries: [ + { + operation: "Mutation.submitFeedback", + outputFields: "", + input: { text: "Great product!", rating: 5 }, + }, + ], context: `{}`, }, { @@ -544,6 +614,13 @@ bridge Query.profile { }`, }, ], + standaloneQueries: [ + { + operation: "Query.profile", + outputFields: "", + input: { userId: "1" }, + }, + ], context: `{}`, }, { @@ -597,6 +674,18 @@ bridge Query.userProfile { }`, }, ], + standaloneQueries: [ + { + operation: "Query.userProfile", + outputFields: "", + input: { firstName: "Alice", lastName: "Smith", id: "42" }, + }, + { + operation: "Query.userProfile", + outputFields: "", + input: { firstName: "Bob", lastName: "Johnson", id: "99" }, + }, + ], context: `{}`, }, { @@ -698,6 +787,23 @@ bridge Query.createPayload { }`, }, ], + standaloneQueries: [ + { + operation: "Query.createPayload", + outputFields: "", + input: { + name: "Alice", + email: "alice@example.com", + theme: "dark", + isPro: true, + }, + }, + { + operation: "Query.createPayload", + outputFields: "", + input: { name: "Bob", email: "bob@example.com", isPro: false }, + }, + ], context: `{}`, }, { @@ -743,6 +849,137 @@ bridge Query.evaluate { }`, }, ], + standaloneQueries: [ + { + operation: "Query.evaluate", + outputFields: "", + input: { age: 25, verified: true, role: "USER" }, + }, + { + operation: "Query.evaluate", + outputFields: "", + input: { age: 15, verified: false, role: "ADMIN" }, + }, + ], + context: `{}`, + }, + // ── Standalone (no-GraphQL) examples ──────────────────────────────────── + { + name: "Weather (Standalone)", + description: + "Geocode a city and fetch its current temperature — no GraphQL schema needed", + mode: "standalone", + schema: "", + bridge: `version 1.5 + +# Tool 1: Geocoder — city name → lat/lon (Nominatim, no auth required) +tool geo from std.httpCall { + .baseUrl = "https://nominatim.openstreetmap.org" + .method = GET + .path = /search + .format = "json" + .limit = "1" + .headers.User-Agent = "BridgeDemo/1.0" +} + +# Tool 2: Weather forecast — lat/lon → temperature (Open-Meteo, no auth required) +tool weather from std.httpCall { + .baseUrl = "https://api.open-meteo.com/v1" + .method = GET + .path = /forecast + .current_weather = "true" +} + +bridge Query.getWeather { + with geo + with weather as w + with input as i + with output as o + + geo.q <- i.city + + w.latitude <- geo[0].lat + w.longitude <- geo[0].lon + + o.city <- i.city + o.lat <- geo[0].lat + o.lon <- geo[0].lon + o.temperature <- w.current_weather.temperature + o.unit = "°C" + o.timezone <- w.timezone +}`, + queries: [{ name: "Berlin", query: "" }], + standaloneQueries: [ + { + operation: "Query.getWeather", + outputFields: "", + input: { city: "Berlin" }, + }, + ], + context: `{}`, + }, + { + name: "SBB Trains (Standalone)", + description: + "Search Swiss train connections — standalone execution without GraphQL", + mode: "standalone", + schema: "", + bridge: `version 1.5 + +tool sbbApi from std.httpCall { + .baseUrl = "https://transport.opendata.ch/v1" + .method = GET + .path = "/connections" + .cache = 60 + on error = { "connections": [] } +} + +bridge Query.searchTrains { + with sbbApi as api + with input as i + with output as o + + api.from <- i.from + api.to <- i.to + + o <- api.connections[] as c { + .id <- c.from.station.id + .provider = "SBB" + .departureTime <- c.from.departure + .arrivalTime <- c.to.arrival + .transfers <- c.transfers || 0 + + .legs <- c.sections[] as s { + .trainName <- s.journey.name || s.journey.category || "Walk" + + .origin.station.id <- s.departure.station.id + .origin.station.name <- s.departure.station.name + .origin.plannedTime <- s.departure.departure + .origin.platform <- s.departure.platform + + .destination.station.id <- s.arrival.station.id + .destination.station.name <- s.arrival.station.name + .destination.plannedTime <- s.arrival.arrival + .destination.platform <- s.arrival.platform + } + } +}`, + queries: [ + { name: "Bern → Zürich", query: "" }, + { name: "Zürich → Genève", query: "" }, + ], + standaloneQueries: [ + { + operation: "Query.searchTrains", + outputFields: "", + input: { from: "Bern", to: "Zürich" }, + }, + { + operation: "Query.searchTrains", + outputFields: "departureTime,arrivalTime,transfers", + input: { from: "Zürich", to: "Genève" }, + }, + ], context: `{}`, }, ]; diff --git a/packages/playground/src/share.ts b/packages/playground/src/share.ts index 58dcd437..78273b25 100644 --- a/packages/playground/src/share.ts +++ b/packages/playground/src/share.ts @@ -5,11 +5,20 @@ * the deployed Cloudflare Worker serve the /api/share endpoints. */ +export type PlaygroundMode = "graphql" | "standalone"; + export interface SharePayload { + mode?: PlaygroundMode; schema: string; bridge: string; queries: { name: string; query: string }[]; context: string; + /** Standalone-mode per-query state (parallel array to queries). */ + standaloneQueries?: { + operation: string; + outputFields: string; + inputJson: string; + }[]; } export async function saveShare(payload: SharePayload): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38f0c3e7..ae2b5295 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 @@ -351,6 +363,9 @@ importers: '@radix-ui/react-dialog': specifier: 1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-select': specifier: 2.2.6 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1600,6 +1615,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -1631,6 +1659,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -1683,6 +1724,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -5858,6 +5912,21 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -5882,6 +5951,32 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -5929,6 +6024,23 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/number': 1.1.1 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.`); -}