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
8 changes: 8 additions & 0 deletions .changeset/unified-fallbacks-array.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@stackables/bridge-core": minor
"@stackables/bridge-parser": minor
"@stackables/bridge-compiler": minor
"@stackables/bridge": minor
---

Migrate wire shape from separate `falsyFallback*`/`nullishFallback*` properties to a unified `fallbacks: WireFallback[]` array, enabling mixed `||` and `??` chains in any order (e.g. `A ?? B || C ?? D`).
104 changes: 50 additions & 54 deletions packages/bridge-compiler/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,10 @@ function detectControlFlow(
wires: Wire[],
): "break" | "continue" | "throw" | "panic" | null {
for (const w of wires) {
if ("nullishControl" in w && w.nullishControl) {
return w.nullishControl.kind as "break" | "continue" | "throw" | "panic";
}
if ("falsyControl" in w && w.falsyControl) {
return w.falsyControl.kind as "break" | "continue" | "throw" | "panic";
if ("fallbacks" in w && w.fallbacks) {
for (const fb of w.fallbacks) {
if (fb.control) return fb.control.kind as "break" | "continue" | "throw" | "panic";
}
}
if ("catchControl" in w && w.catchControl) {
return w.catchControl.kind as "break" | "continue" | "throw" | "panic";
Expand Down Expand Up @@ -705,11 +704,10 @@ class CodegenContext {
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 ("fallbacks" in w && w.fallbacks) {
for (const fb of w.fallbacks) {
if (fb.ref) keys.add(refTrunkKey(fb.ref));
}
}
if ("catchFallbackRef" in w && w.catchFallbackRef) {
keys.add(refTrunkKey(w.catchFallbackRef));
Expand Down Expand Up @@ -1812,8 +1810,7 @@ class CodegenContext {
const controlWire = elemWires.find(
(w) =>
w.to.path.length === 1 &&
(("nullishControl" in w && w.nullishControl != null) ||
("falsyControl" in w && w.falsyControl != null) ||
(("fallbacks" in w && w.fallbacks?.some(fb => fb.control != null)) ||
("catchControl" in w && w.catchControl != null)),
);

Expand All @@ -1836,13 +1833,13 @@ class CodegenContext {

// Determine the check type
const isNullish =
"nullishControl" in controlWire && controlWire.nullishControl != null;
controlWire.fallbacks?.some(fb => fb.type === "nullish" && fb.control != null) ?? false;

if (mode === "continue") {
if (isNullish) {
return `${pad} if (${checkExpr} == null) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`;
}
// falsyControl
// falsy fallback control
return `${pad} if (!${checkExpr}) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`;
}

Expand Down Expand Up @@ -2317,38 +2314,36 @@ class CodegenContext {

/** Apply falsy (||), nullish (??) and catch fallback chains to an expression. */
private applyFallbacks(w: Wire, expr: string): string {
// Falsy fallback chain (||)
if ("falsyFallbackRefs" in w && w.falsyFallbackRefs?.length) {
for (const ref of w.falsyFallbackRefs) {
expr = `(${expr} || ${this.refToExpr(ref)})`; // lgtm [js/code-injection]
}
}
if ("falsyFallback" in w && w.falsyFallback != null) {
expr = `(${expr} || ${emitCoerced(w.falsyFallback)})`; // lgtm [js/code-injection]
}
// Falsy control flow (throw/panic on || gate)
if ("falsyControl" in w && w.falsyControl) {
const ctrl = w.falsyControl;
if (ctrl.kind === "throw") {
expr = `(${expr} || (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
} else if (ctrl.kind === "panic") {
expr = `(${expr} || (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
}
}

// Nullish coalescing (??)
if ("nullishFallbackRef" in w && w.nullishFallbackRef) {
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.refToExpr(w.nullishFallbackRef)}))`; // lgtm [js/code-injection]
} else if ("nullishFallback" in w && w.nullishFallback != null) {
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(w.nullishFallback)}))`; // lgtm [js/code-injection]
}
// Nullish control flow (throw/panic on ?? gate)
if ("nullishControl" in w && w.nullishControl) {
const ctrl = w.nullishControl;
if (ctrl.kind === "throw") {
expr = `(${expr} ?? (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
} else if (ctrl.kind === "panic") {
expr = `(${expr} ?? (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
if ("fallbacks" in w && w.fallbacks) {
for (const fb of w.fallbacks) {
if (fb.type === "falsy") {
if (fb.ref) {
expr = `(${expr} || ${this.refToExpr(fb.ref)})`; // lgtm [js/code-injection]
} else if (fb.value != null) {
expr = `(${expr} || ${emitCoerced(fb.value)})`; // lgtm [js/code-injection]
} else if (fb.control) {
const ctrl = fb.control;
if (ctrl.kind === "throw") {
expr = `(${expr} || (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
} else if (ctrl.kind === "panic") {
expr = `(${expr} || (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
}
}
} else {
// nullish
if (fb.ref) {
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.refToExpr(fb.ref)}))`; // lgtm [js/code-injection]
} else if (fb.value != null) {
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(fb.value)}))`; // lgtm [js/code-injection]
} else if (fb.control) {
const ctrl = fb.control;
if (ctrl.kind === "throw") {
expr = `(${expr} ?? (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
} else if (ctrl.kind === "panic") {
expr = `(${expr} ?? (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
}
}
}
}
}

Expand Down Expand Up @@ -2600,11 +2595,11 @@ class CodegenContext {
if (w.condOr.rightRef) allRefs.add(refTrunkKey(w.condOr.rightRef));
}
// Fallback refs
if ("falsyFallbackRefs" in w && w.falsyFallbackRefs) {
for (const ref of w.falsyFallbackRefs) allRefs.add(refTrunkKey(ref));
if ("fallbacks" in w && w.fallbacks) {
for (const fb of w.fallbacks) {
if (fb.ref) allRefs.add(refTrunkKey(fb.ref));
}
}
if ("nullishFallbackRef" in w && w.nullishFallbackRef)
allRefs.add(refTrunkKey(w.nullishFallbackRef));
if ("catchFallbackRef" in w && w.catchFallbackRef)
allRefs.add(refTrunkKey(w.catchFallbackRef));
};
Expand Down Expand Up @@ -2925,10 +2920,11 @@ class CodegenContext {

if ("from" in w) {
collectTrunk(w.from);
if ("falsyFallbackRefs" in w && w.falsyFallbackRefs)
w.falsyFallbackRefs.forEach(collectTrunk);
if ("nullishFallbackRef" in w && w.nullishFallbackRef)
collectTrunk(w.nullishFallbackRef);
if (w.fallbacks) {
for (const fb of w.fallbacks) {
if (fb.ref) collectTrunk(fb.ref);
}
}
if ("catchFallbackRef" in w && w.catchFallbackRef)
collectTrunk(w.catchFallbackRef);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge-compiler/test/codegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ bridge Query.refFallback {
field: "nullishProbe",
path: ["k"],
},
nullishFallback: "null",
fallbacks: [{ type: "nullish", value: "null" }],
},
],
},
Expand Down
19 changes: 15 additions & 4 deletions packages/bridge-compiler/test/fuzz-compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
BridgeDocument,
NodeRef,
Wire,
WireFallback,
} from "@stackables/bridge-core";
import { executeBridge as executeRuntime } from "@stackables/bridge-core";
import { compileBridge, executeBridge as executeAot } from "../src/index.ts";
Expand Down Expand Up @@ -82,8 +83,13 @@ const wireArb = (type: string, field: string): fc.Arbitrary<Wire> => {
{
from: fromArb,
to: toArb,
falsyFallback: constantValueArb,
nullishFallback: constantValueArb,
fallbacks: fc.array(
fc.oneof(
fc.record({ type: fc.constant<"falsy">("falsy"), value: constantValueArb }),
fc.record({ type: fc.constant<"nullish">("nullish"), value: constantValueArb }),
) as fc.Arbitrary<WireFallback>,
{ minLength: 0, maxLength: 2 },
),
catchFallback: constantValueArb,
},
{ requiredKeys: ["from", "to"] }, // Fallbacks are randomly omitted
Expand Down Expand Up @@ -263,8 +269,13 @@ const fallbackHeavyBridgeArb: fc.Arbitrary<Bridge> = fc
fc.record({
from: flatPathArb.map((path) => inputRef(type, field, path)),
to: flatPathArb.map((path) => outputRef(type, field, path)),
falsyFallback: constantValueArb,
nullishFallback: constantValueArb,
fallbacks: fc.array(
fc.oneof(
fc.record({ type: fc.constant<"falsy">("falsy"), value: constantValueArb }),
fc.record({ type: fc.constant<"nullish">("nullish"), value: constantValueArb }),
) as fc.Arbitrary<WireFallback>,
{ minLength: 0, maxLength: 2 },
),
catchFallback: constantValueArb,
}),
{
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export type {
ToolWire,
VersionDecl,
Wire,
WireFallback,
} from "./types.ts";

// ── Utilities ───────────────────────────────────────────────────────────────
Expand Down
78 changes: 30 additions & 48 deletions packages/bridge-core/src/resolveWires.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { coerceConstant, getSimplePullRef } from "./tree-utils.ts";

/**
* A non-constant wire — any Wire variant that carries gate modifiers
* (`falsyFallback`, `nullishFallbackRef`, `catchFallback`, etc.).
* (`fallbacks`, `catchFallback`, etc.).
* Excludes the `{ value: string; to: NodeRef }` constant wire which has no
* modifier slots.
*/
Expand All @@ -31,19 +31,20 @@ type WireWithGates = Exclude<Wire, { value: string }>;
*
* Architecture: two distinct resolution axes —
*
* **Falsy Gate** (`||`, within a wire): `falsyFallbackRefs` + `falsyFallback`
* → truthy check — falsy values (0, "", false) trigger fallback chain.
* **Fallback Gates** (`||` / `??`, within a wire): unified `fallbacks` array
* → falsy gates trigger on falsy values (0, "", false, null, undefined)
* → nullish gates trigger only on null/undefined
* → gates are processed left-to-right, allowing mixed `||` and `??` chains
*
* **Overdefinition** (across wires): multiple wires target the same path
* → nullish check — only null/undefined falls through to the next wire.
*
* Per-wire layers:
* Layer 1 — Execution (pullSingle + safe modifier)
* Layer 2a — Falsy Gate (falsyFallbackRefs → falsyFallback / falsyControl)
* Layer 2b — Nullish Gate (nullishFallbackRef / nullishFallback / nullishControl)
* Layer 2 — Fallback Gates (unified fallbacks array: || and ?? in order)
* Layer 3 — Catch (catchFallbackRef / catchFallback / catchControl)
*
* After layers 1–2b, the overdefinition boundary (`!= null`) decides whether
* After layers 1–2, the overdefinition boundary (`!= null`) decides whether
* to return or continue to the next wire.
*
* ---
Expand Down Expand Up @@ -90,11 +91,8 @@ async function resolveWiresAsync(
// Layer 1: Execution
let value = await evaluateWireSource(ctx, w, pullChain);

// Layer 2a: Falsy Gate (||)
value = await applyFalsyGate(ctx, w, value, pullChain);

// Layer 2b: Nullish Gate (??)
value = await applyNullishGate(ctx, w, value, pullChain);
// Layer 2: Fallback Gates (unified || and ?? chain)
value = await applyFallbackGates(ctx, w, value, pullChain);

// Overdefinition Boundary
if (value != null) return value;
Expand All @@ -113,55 +111,39 @@ async function resolveWiresAsync(
return undefined;
}

// ── Layer 2a: Falsy Gate (||) ────────────────────────────────────────────────
// ── Layer 2: Fallback Gates (unified || and ??) ─────────────────────────────

/**
* Apply the Falsy Gate (Layer 2a) to a resolved value.
* Apply the unified Fallback Gates (Layer 2) to a resolved value.
*
* If the value is already truthy the gate is a no-op. Otherwise the gate
* walks `falsyFallbackRefs` (chained `||` refs) in order, returning the first
* truthy result. If none yields a truthy value, `falsyControl` or
* `falsyFallback` is tried as a last resort.
* Walks the `fallbacks` array in order. Each entry is either a falsy gate
* (`||`) or a nullish gate (`??`). A falsy gate opens when `!value`;
* a nullish gate opens when `value == null`. When a gate is open, the
* fallback is applied (control flow, ref pull, or constant coercion) and
* the result replaces `value` for subsequent gates.
*/
export async function applyFalsyGate(
export async function applyFallbackGates(
ctx: TreeContext,
w: WireWithGates,
value: unknown,
pullChain?: Set<string>,
): Promise<unknown> {
if (value) return value; // already truthy — gate is closed

if (w.falsyFallbackRefs?.length) {
for (const ref of w.falsyFallbackRefs) {
const fallback = await ctx.pullSingle(ref, pullChain);
if (fallback) return fallback;
if (!w.fallbacks?.length) return value;

for (const fallback of w.fallbacks) {
const isFalsyGateOpen = fallback.type === "falsy" && !value;
const isNullishGateOpen = fallback.type === "nullish" && value == null;

if (isFalsyGateOpen || isNullishGateOpen) {
if (fallback.control) return applyControlFlow(fallback.control);
if (fallback.ref) {
value = await ctx.pullSingle(fallback.ref, pullChain);
} else if (fallback.value !== undefined) {
value = coerceConstant(fallback.value);
}
}
}

if (w.falsyControl) return applyControlFlow(w.falsyControl);
if (w.falsyFallback != null) return coerceConstant(w.falsyFallback);
return value;
}

// ── Layer 2b: Nullish Gate (??) ──────────────────────────────────────────────

/**
* Apply the Nullish Gate (Layer 2b) to a resolved value.
*
* If the value is non-nullish the gate is a no-op. Otherwise `nullishControl`,
* `nullishFallbackRef`, or `nullishFallback` is applied (in priority order).
*/
export async function applyNullishGate(
ctx: TreeContext,
w: WireWithGates,
value: unknown,
pullChain?: Set<string>,
): Promise<unknown> {
if (value != null) return value; // non-nullish — gate is closed

if (w.nullishControl) return applyControlFlow(w.nullishControl);
if (w.nullishFallbackRef) return ctx.pullSingle(w.nullishFallbackRef, pullChain);
if (w.nullishFallback != null) return coerceConstant(w.nullishFallback);
return value;
}

Expand Down
9 changes: 2 additions & 7 deletions packages/bridge-core/src/tree-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export const SIMPLE_PULL_CACHE = Symbol.for("bridge.simplePull");

/**
* Returns the `from` NodeRef when a wire qualifies for the simple-pull fast
* path (single `from` wire, no safe/falsy/nullish/catch modifiers). Returns
* path (single `from` wire, no safe/fallbacks/catch modifiers). Returns
* `null` otherwise. The result is cached on the wire via a Symbol key so
* subsequent calls are a single property read without affecting V8 shapes.
* See docs/performance.md (#11).
Expand All @@ -183,12 +183,7 @@ export function getSimplePullRef(w: Wire): NodeRef | null {
if (cached !== undefined) return cached;
const ref =
!w.safe &&
!w.falsyFallbackRefs?.length &&
w.falsyControl == null &&
w.falsyFallback == null &&
w.nullishControl == null &&
!w.nullishFallbackRef &&
w.nullishFallback == null &&
!w.fallbacks?.length &&
!w.catchControl &&
!w.catchFallbackRef &&
w.catchFallback == null
Expand Down
Loading