Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/cute-states-wash.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
"@stackables/bridge-compiler": minor
"@stackables/bridge-parser": minor
"@stackables/bridge-core": minor
---

Support object spread in path-scoped scope blocks
91 changes: 82 additions & 9 deletions packages/bridge-compiler/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1348,13 +1348,24 @@ class CodegenContext {
const arrayIterators = this.bridge.arrayIterators ?? {};
const isRootArray = "" in arrayIterators;

// Check for root passthrough (wire with empty path) — but not if it's a root array source
const rootWire = outputWires.find((w) => w.to.path.length === 0);
if (rootWire && !isRootArray) {
lines.push(` return ${this.wireToExpr(rootWire)};`);
// Separate root wires into passthrough vs spread
const rootWires = outputWires.filter((w) => w.to.path.length === 0);
const spreadRootWires = rootWires.filter(
(w) => "from" in w && "spread" in w && w.spread,
);
const passthroughRootWire = rootWires.find(
(w) => !("from" in w && "spread" in w && w.spread),
);

// Passthrough (non-spread root wire) — return directly
if (passthroughRootWire && !isRootArray) {
lines.push(` return ${this.wireToExpr(passthroughRootWire)};`);
return;
}

// Check for root passthrough (wire with empty path) — but not if it's a root array source
const rootWire = rootWires[0]; // for backwards compat with array handling below

// Handle root array output (o <- src.items[] as item { ... })
if (isRootArray && rootWire) {
const elemWires = outputWires.filter(
Expand Down Expand Up @@ -1461,6 +1472,13 @@ class CodegenContext {
} else if (arrayFields.has(topField) && w.to.path.length === 1) {
// Root wire for an array field
arraySourceWires.set(topField, w);
} else if (
"from" in w &&
"spread" in w &&
w.spread &&
w.to.path.length === 0
) {
// Spread root wire — handled separately via spreadRootWires
} else {
scalarWires.push(w);
}
Expand All @@ -1470,11 +1488,42 @@ class CodegenContext {
interface TreeNode {
expr?: string;
terminal?: boolean;
spreadExprs?: string[];
children: Map<string, TreeNode>;
}
const tree: TreeNode = { children: new Map() };

for (const w of scalarWires) {
// First pass: handle nested spread wires (spread with path.length > 0)
const nestedSpreadWires = scalarWires.filter(
(w) => "from" in w && "spread" in w && w.spread && w.to.path.length > 0,
);
const normalScalarWires = scalarWires.filter(
(w) => !("from" in w && "spread" in w && w.spread),
);

// Add nested spread expressions to tree nodes
for (const w of nestedSpreadWires) {
const path = w.to.path;
let current = tree;
// Navigate to parent of the target
for (let i = 0; i < path.length - 1; i++) {
const seg = path[i]!;
if (!current.children.has(seg)) {
current.children.set(seg, { children: new Map() });
}
current = current.children.get(seg)!;
}
const lastSeg = path[path.length - 1]!;
if (!current.children.has(lastSeg)) {
current.children.set(lastSeg, { children: new Map() });
}
const node = current.children.get(lastSeg)!;
// Add spread expression to this node
if (!node.spreadExprs) node.spreadExprs = [];
node.spreadExprs.push(this.wireToExpr(w));
}

for (const w of normalScalarWires) {
const path = w.to.path;
let current = tree;
for (let i = 0; i < path.length - 1; i++) {
Expand Down Expand Up @@ -1561,7 +1610,9 @@ class CodegenContext {
}

// Serialize the tree to a return statement
const objStr = this.serializeOutputTree(tree, 4);
// Include spread expressions at the start if present
const spreadExprs = spreadRootWires.map((w) => this.wireToExpr(w));
const objStr = this.serializeOutputTree(tree, 4, spreadExprs);
lines.push(` return ${objStr};`);
}

Expand All @@ -1571,15 +1622,37 @@ class CodegenContext {
children: Map<string, { expr?: string; children: Map<string, any> }>;
},
indent: number,
spreadExprs?: string[],
): string {
const pad = " ".repeat(indent);
const entries: string[] = [];

// Add spread expressions first (they come before field overrides)
if (spreadExprs) {
for (const expr of spreadExprs) {
entries.push(`${pad}...${expr}`);
}
}

for (const [key, child] of node.children) {
if (child.expr != null && child.children.size === 0) {
// Check if child has spread expressions
const childSpreadExprs = (child as { spreadExprs?: string[] })
.spreadExprs;

if (
child.expr != null &&
child.children.size === 0 &&
!childSpreadExprs
) {
// Simple leaf with just an expression
entries.push(`${pad}${JSON.stringify(key)}: ${child.expr}`);
} else if (child.children.size > 0 && child.expr == null) {
const nested = this.serializeOutputTree(child, indent + 2);
} else if (childSpreadExprs || child.children.size > 0) {
// Nested object: may have spreads, children, or both
const nested = this.serializeOutputTree(
child,
indent + 2,
childSpreadExprs,
);
entries.push(`${pad}${JSON.stringify(key)}: ${nested}`);
} else {
// Has both expr and children — use expr (children override handled elsewhere)
Expand Down
83 changes: 75 additions & 8 deletions packages/bridge-core/src/ExecutionTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,16 @@ export class ExecutionTree implements TreeContext {
w.to.field === field &&
pathEquals(w.to.path, prefix),
);
if (exactWires.length > 0) {

// Separate spread wires from regular wires
const spreadWires = exactWires.filter(
(w) => "from" in w && "spread" in w && w.spread,
);
const regularWires = exactWires.filter(
(w) => !("from" in w && "spread" in w && w.spread),
);

if (regularWires.length > 0) {
// Check for array mapping: exact wires (the array source) PLUS
// element-level wires deeper than prefix (the field mappings).
// E.g. `o.entries <- src[] as x { .id <- x.item_id }` produces
Expand All @@ -639,15 +648,16 @@ export class ExecutionTree implements TreeContext {
if (hasElementWires) {
// Array mapping on a sub-field: resolve the array source,
// create shadow trees, and materialise with field mappings.
const resolved = await this.resolveWires(exactWires);
const resolved = await this.resolveWires(regularWires);
if (!Array.isArray(resolved)) return resolved;
const shadows = this.createShadowArray(resolved);
return this.materializeShadows(shadows, prefix);
}

return this.resolveWires(exactWires);
return this.resolveWires(regularWires);
}

// Collect sub-fields from deeper wires
const subFields = new Set<string>();
for (const wire of bridge.wires) {
const p = wire.to.path;
Expand All @@ -661,6 +671,37 @@ export class ExecutionTree implements TreeContext {
subFields.add(p[prefix.length]!);
}
}

// Spread wires: resolve and merge, then overlay sub-field wires
if (spreadWires.length > 0) {
const result: Record<string, unknown> = {};

// First resolve spread sources (in order)
for (const wire of spreadWires) {
const spreadValue = await this.resolveWires([wire]);
if (spreadValue != null && typeof spreadValue === "object") {
Object.assign(result, spreadValue);
}
}

// Then resolve sub-fields and overlay on spread result
const prefixStr = prefix.join(".");
const activeSubFields = this.requestedFields
? [...subFields].filter((sub) => {
const fullPath = prefixStr ? `${prefixStr}.${sub}` : sub;
return matchesRequestedFields(fullPath, this.requestedFields);
})
: [...subFields];

await Promise.all(
activeSubFields.map(async (sub) => {
result[sub] = await this.resolveNestedField([...prefix, sub]);
}),
);

return result;
}

if (subFields.size === 0) return undefined;

// Apply sparse fieldset filter at nested level
Expand Down Expand Up @@ -792,8 +833,8 @@ export class ExecutionTree implements TreeContext {

const { type, field } = this.trunk;

// Is there a root-level wire targeting the output with path []?
const hasRootWire = bridge.wires.some(
// Separate root-level wires into passthrough vs spread
const rootWires = bridge.wires.filter(
(w) =>
"from" in w &&
w.to.module === SELF_MODULE &&
Expand All @@ -802,6 +843,18 @@ export class ExecutionTree implements TreeContext {
w.to.path.length === 0,
);

// Passthrough wire: root wire without spread flag
const hasPassthroughWire = rootWires.some(
(w) => "from" in w && !("spread" in w && w.spread),
);

// Spread wires: root wires with spread flag
const spreadWires = rootWires.filter(
(w) => "from" in w && "spread" in w && w.spread,
);

const hasRootWire = rootWires.length > 0;

// Array-mapped output (`o <- items[] as x { ... }`) has BOTH a root wire
// AND element-level wires (from.element === true). A plain passthrough
// (`o <- api.user`) only has the root wire.
Expand All @@ -827,8 +880,8 @@ export class ExecutionTree implements TreeContext {
return this.materializeShadows(shadows, []);
}

// Whole-object passthrough: `o <- api.user`
if (hasRootWire) {
// Whole-object passthrough: `o <- api.user` (non-spread root wire)
if (hasPassthroughWire) {
const [result] = await Promise.all([
this.pullOutputField([]),
...forcePromises,
Expand All @@ -849,7 +902,11 @@ export class ExecutionTree implements TreeContext {
}
}

if (outputFields.size === 0) {
// Spread wires: resolve and merge source objects
// Later field wires will override spread properties
const hasSpreadWires = spreadWires.length > 0;

if (outputFields.size === 0 && !hasSpreadWires) {
throw new Error(
`Bridge "${type}.${field}" has no output wires. ` +
`Ensure at least one wire targets the output (e.g. \`o.field <- ...\`).`,
Expand All @@ -861,6 +918,16 @@ export class ExecutionTree implements TreeContext {

const result: Record<string, unknown> = {};

// First resolve spread wires (in order) to build base object
// Each spread source's properties are merged into result
for (const wire of spreadWires) {
const spreadValue = await this.resolveWires([wire]);
if (spreadValue != null && typeof spreadValue === "object") {
Object.assign(result, spreadValue);
}
}

// Then resolve explicit field wires - these override spread properties
await Promise.all([
...[...activeFields].map(async (name) => {
result[name] = await this.resolveNestedField([name]);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ export type NodeRef = {
* Pipe wires (`pipe: true`) are generated by the `<- h1:h2:source` shorthand
* and route data through declared tool handles; the serializer collapses them
* back to pipe notation.
* Spread wires (`spread: true`) merge source object properties into the target.
*/
export type Wire =
| {
from: NodeRef;
to: NodeRef;
pipe?: true;
/** When true, this wire merges source properties into target (from `...source` syntax). */
spread?: true;
safe?: true;
falsyFallbackRefs?: NodeRef[];
falsyFallback?: string;
Expand Down
4 changes: 4 additions & 0 deletions packages/bridge-parser/src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2221,6 +2221,7 @@ function processElementLines(
element: true,
path: elemToPath,
},
spread: true as const,
...(spreadSafe ? { safe: true as const } : {}),
});
}
Expand Down Expand Up @@ -2368,6 +2369,7 @@ function processElementScopeLines(
element: true,
path: spreadToPath,
},
spread: true as const,
...(spreadSafe ? { safe: true as const } : {}),
});
}
Expand Down Expand Up @@ -4228,6 +4230,7 @@ function buildBridgeBody(
wires.push({
from: fromRef,
to: nestedToRef,
spread: true as const,
...(spreadSafe ? { safe: true as const } : {}),
});
}
Expand Down Expand Up @@ -4880,6 +4883,7 @@ function buildBridgeBody(
wires.push({
from: fromRef,
to: toRef,
spread: true as const,
...(spreadSafe ? { safe: true as const } : {}),
});
}
Expand Down
Loading
Loading