Skip to content

Commit 7384d3f

Browse files
aarneCopilotCopilot
authored
Allow mixing ?? with || in wire definitions in any order (#96)
* Fix multi ?? * Migrate wire shape to unified fallbacks array (#98) * Initial plan * Migrate Wire type to unified fallbacks array, update runtime and gate tests Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * Update resilience test assertions to use new fallbacks: WireFallback[] pattern Replace old separate falsyFallback, falsyFallbackRefs properties with the unified fallbacks array in test assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refactor parser to use unified fallbacks: WireFallback[] pattern Replace all separate falsyFallback*/nullishFallback* properties across ~13 locations in parser.ts with a single fallbacks: WireFallback[] array. Also update bridge-format.ts serializer and export WireFallback from bridge-core. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate codegen from separate falsy/nullish properties to unified fallbacks array Update bridge-compiler codegen.ts and related test files to use the new Wire.fallbacks?: WireFallback[] property instead of the old separate falsyFallback/nullishFallback/falsyControl/nullishControl/falsyFallbackRefs/ nullishFallbackRefs properties. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add mixed wire chain tests, playground example, and changeset Co-authored-by: aarne <82001+aarne@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarne <82001+aarne@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarne <82001+aarne@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 93bbb94 commit 7384d3f

17 files changed

Lines changed: 735 additions & 884 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@stackables/bridge-core": minor
3+
"@stackables/bridge-parser": minor
4+
"@stackables/bridge-compiler": minor
5+
"@stackables/bridge": minor
6+
---
7+
8+
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`).

packages/bridge-compiler/src/codegen.ts

Lines changed: 50 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,10 @@ function detectControlFlow(
144144
wires: Wire[],
145145
): "break" | "continue" | "throw" | "panic" | null {
146146
for (const w of wires) {
147-
if ("nullishControl" in w && w.nullishControl) {
148-
return w.nullishControl.kind as "break" | "continue" | "throw" | "panic";
149-
}
150-
if ("falsyControl" in w && w.falsyControl) {
151-
return w.falsyControl.kind as "break" | "continue" | "throw" | "panic";
147+
if ("fallbacks" in w && w.fallbacks) {
148+
for (const fb of w.fallbacks) {
149+
if (fb.control) return fb.control.kind as "break" | "continue" | "throw" | "panic";
150+
}
152151
}
153152
if ("catchControl" in w && w.catchControl) {
154153
return w.catchControl.kind as "break" | "continue" | "throw" | "panic";
@@ -705,11 +704,10 @@ class CodegenContext {
705704
keys.add(refTrunkKey(w.condOr.leftRef));
706705
if (w.condOr.rightRef) keys.add(refTrunkKey(w.condOr.rightRef));
707706
}
708-
if ("falsyFallbackRefs" in w && w.falsyFallbackRefs) {
709-
for (const ref of w.falsyFallbackRefs) keys.add(refTrunkKey(ref));
710-
}
711-
if ("nullishFallbackRef" in w && w.nullishFallbackRef) {
712-
keys.add(refTrunkKey(w.nullishFallbackRef));
707+
if ("fallbacks" in w && w.fallbacks) {
708+
for (const fb of w.fallbacks) {
709+
if (fb.ref) keys.add(refTrunkKey(fb.ref));
710+
}
713711
}
714712
if ("catchFallbackRef" in w && w.catchFallbackRef) {
715713
keys.add(refTrunkKey(w.catchFallbackRef));
@@ -1812,8 +1810,7 @@ class CodegenContext {
18121810
const controlWire = elemWires.find(
18131811
(w) =>
18141812
w.to.path.length === 1 &&
1815-
(("nullishControl" in w && w.nullishControl != null) ||
1816-
("falsyControl" in w && w.falsyControl != null) ||
1813+
(("fallbacks" in w && w.fallbacks?.some(fb => fb.control != null)) ||
18171814
("catchControl" in w && w.catchControl != null)),
18181815
);
18191816

@@ -1836,13 +1833,13 @@ class CodegenContext {
18361833

18371834
// Determine the check type
18381835
const isNullish =
1839-
"nullishControl" in controlWire && controlWire.nullishControl != null;
1836+
controlWire.fallbacks?.some(fb => fb.type === "nullish" && fb.control != null) ?? false;
18401837

18411838
if (mode === "continue") {
18421839
if (isNullish) {
18431840
return `${pad} if (${checkExpr} == null) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`;
18441841
}
1845-
// falsyControl
1842+
// falsy fallback control
18461843
return `${pad} if (!${checkExpr}) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`;
18471844
}
18481845

@@ -2317,38 +2314,36 @@ class CodegenContext {
23172314

23182315
/** Apply falsy (||), nullish (??) and catch fallback chains to an expression. */
23192316
private applyFallbacks(w: Wire, expr: string): string {
2320-
// Falsy fallback chain (||)
2321-
if ("falsyFallbackRefs" in w && w.falsyFallbackRefs?.length) {
2322-
for (const ref of w.falsyFallbackRefs) {
2323-
expr = `(${expr} || ${this.refToExpr(ref)})`; // lgtm [js/code-injection]
2324-
}
2325-
}
2326-
if ("falsyFallback" in w && w.falsyFallback != null) {
2327-
expr = `(${expr} || ${emitCoerced(w.falsyFallback)})`; // lgtm [js/code-injection]
2328-
}
2329-
// Falsy control flow (throw/panic on || gate)
2330-
if ("falsyControl" in w && w.falsyControl) {
2331-
const ctrl = w.falsyControl;
2332-
if (ctrl.kind === "throw") {
2333-
expr = `(${expr} || (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
2334-
} else if (ctrl.kind === "panic") {
2335-
expr = `(${expr} || (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
2336-
}
2337-
}
2338-
2339-
// Nullish coalescing (??)
2340-
if ("nullishFallbackRef" in w && w.nullishFallbackRef) {
2341-
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.refToExpr(w.nullishFallbackRef)}))`; // lgtm [js/code-injection]
2342-
} else if ("nullishFallback" in w && w.nullishFallback != null) {
2343-
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(w.nullishFallback)}))`; // lgtm [js/code-injection]
2344-
}
2345-
// Nullish control flow (throw/panic on ?? gate)
2346-
if ("nullishControl" in w && w.nullishControl) {
2347-
const ctrl = w.nullishControl;
2348-
if (ctrl.kind === "throw") {
2349-
expr = `(${expr} ?? (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
2350-
} else if (ctrl.kind === "panic") {
2351-
expr = `(${expr} ?? (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
2317+
if ("fallbacks" in w && w.fallbacks) {
2318+
for (const fb of w.fallbacks) {
2319+
if (fb.type === "falsy") {
2320+
if (fb.ref) {
2321+
expr = `(${expr} || ${this.refToExpr(fb.ref)})`; // lgtm [js/code-injection]
2322+
} else if (fb.value != null) {
2323+
expr = `(${expr} || ${emitCoerced(fb.value)})`; // lgtm [js/code-injection]
2324+
} else if (fb.control) {
2325+
const ctrl = fb.control;
2326+
if (ctrl.kind === "throw") {
2327+
expr = `(${expr} || (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
2328+
} else if (ctrl.kind === "panic") {
2329+
expr = `(${expr} || (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
2330+
}
2331+
}
2332+
} else {
2333+
// nullish
2334+
if (fb.ref) {
2335+
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.refToExpr(fb.ref)}))`; // lgtm [js/code-injection]
2336+
} else if (fb.value != null) {
2337+
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(fb.value)}))`; // lgtm [js/code-injection]
2338+
} else if (fb.control) {
2339+
const ctrl = fb.control;
2340+
if (ctrl.kind === "throw") {
2341+
expr = `(${expr} ?? (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
2342+
} else if (ctrl.kind === "panic") {
2343+
expr = `(${expr} ?? (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection]
2344+
}
2345+
}
2346+
}
23522347
}
23532348
}
23542349

@@ -2600,11 +2595,11 @@ class CodegenContext {
26002595
if (w.condOr.rightRef) allRefs.add(refTrunkKey(w.condOr.rightRef));
26012596
}
26022597
// Fallback refs
2603-
if ("falsyFallbackRefs" in w && w.falsyFallbackRefs) {
2604-
for (const ref of w.falsyFallbackRefs) allRefs.add(refTrunkKey(ref));
2598+
if ("fallbacks" in w && w.fallbacks) {
2599+
for (const fb of w.fallbacks) {
2600+
if (fb.ref) allRefs.add(refTrunkKey(fb.ref));
2601+
}
26052602
}
2606-
if ("nullishFallbackRef" in w && w.nullishFallbackRef)
2607-
allRefs.add(refTrunkKey(w.nullishFallbackRef));
26082603
if ("catchFallbackRef" in w && w.catchFallbackRef)
26092604
allRefs.add(refTrunkKey(w.catchFallbackRef));
26102605
};
@@ -2925,10 +2920,11 @@ class CodegenContext {
29252920

29262921
if ("from" in w) {
29272922
collectTrunk(w.from);
2928-
if ("falsyFallbackRefs" in w && w.falsyFallbackRefs)
2929-
w.falsyFallbackRefs.forEach(collectTrunk);
2930-
if ("nullishFallbackRef" in w && w.nullishFallbackRef)
2931-
collectTrunk(w.nullishFallbackRef);
2923+
if (w.fallbacks) {
2924+
for (const fb of w.fallbacks) {
2925+
if (fb.ref) collectTrunk(fb.ref);
2926+
}
2927+
}
29322928
if ("catchFallbackRef" in w && w.catchFallbackRef)
29332929
collectTrunk(w.catchFallbackRef);
29342930
}

packages/bridge-compiler/test/codegen.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ bridge Query.refFallback {
331331
field: "nullishProbe",
332332
path: ["k"],
333333
},
334-
nullishFallback: "null",
334+
fallbacks: [{ type: "nullish", value: "null" }],
335335
},
336336
],
337337
},

packages/bridge-compiler/test/fuzz-compile.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
BridgeDocument,
88
NodeRef,
99
Wire,
10+
WireFallback,
1011
} from "@stackables/bridge-core";
1112
import { executeBridge as executeRuntime } from "@stackables/bridge-core";
1213
import { compileBridge, executeBridge as executeAot } from "../src/index.ts";
@@ -82,8 +83,13 @@ const wireArb = (type: string, field: string): fc.Arbitrary<Wire> => {
8283
{
8384
from: fromArb,
8485
to: toArb,
85-
falsyFallback: constantValueArb,
86-
nullishFallback: constantValueArb,
86+
fallbacks: fc.array(
87+
fc.oneof(
88+
fc.record({ type: fc.constant<"falsy">("falsy"), value: constantValueArb }),
89+
fc.record({ type: fc.constant<"nullish">("nullish"), value: constantValueArb }),
90+
) as fc.Arbitrary<WireFallback>,
91+
{ minLength: 0, maxLength: 2 },
92+
),
8793
catchFallback: constantValueArb,
8894
},
8995
{ requiredKeys: ["from", "to"] }, // Fallbacks are randomly omitted
@@ -263,8 +269,13 @@ const fallbackHeavyBridgeArb: fc.Arbitrary<Bridge> = fc
263269
fc.record({
264270
from: flatPathArb.map((path) => inputRef(type, field, path)),
265271
to: flatPathArb.map((path) => outputRef(type, field, path)),
266-
falsyFallback: constantValueArb,
267-
nullishFallback: constantValueArb,
272+
fallbacks: fc.array(
273+
fc.oneof(
274+
fc.record({ type: fc.constant<"falsy">("falsy"), value: constantValueArb }),
275+
fc.record({ type: fc.constant<"nullish">("nullish"), value: constantValueArb }),
276+
) as fc.Arbitrary<WireFallback>,
277+
{ minLength: 0, maxLength: 2 },
278+
),
268279
catchFallback: constantValueArb,
269280
}),
270281
{

packages/bridge-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type {
6464
ToolWire,
6565
VersionDecl,
6666
Wire,
67+
WireFallback,
6768
} from "./types.ts";
6869

6970
// ── Utilities ───────────────────────────────────────────────────────────────

packages/bridge-core/src/resolveWires.ts

Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { coerceConstant, getSimplePullRef } from "./tree-utils.ts";
1818

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

93-
// Layer 2a: Falsy Gate (||)
94-
value = await applyFalsyGate(ctx, w, value, pullChain);
95-
96-
// Layer 2b: Nullish Gate (??)
97-
value = await applyNullishGate(ctx, w, value, pullChain);
94+
// Layer 2: Fallback Gates (unified || and ?? chain)
95+
value = await applyFallbackGates(ctx, w, value, pullChain);
9896

9997
// Overdefinition Boundary
10098
if (value != null) return value;
@@ -113,55 +111,39 @@ async function resolveWiresAsync(
113111
return undefined;
114112
}
115113

116-
// ── Layer 2a: Falsy Gate (||) ────────────────────────────────────────────────
114+
// ── Layer 2: Fallback Gates (unified || and ??) ─────────────────────────────
117115

118116
/**
119-
* Apply the Falsy Gate (Layer 2a) to a resolved value.
117+
* Apply the unified Fallback Gates (Layer 2) to a resolved value.
120118
*
121-
* If the value is already truthy the gate is a no-op. Otherwise the gate
122-
* walks `falsyFallbackRefs` (chained `||` refs) in order, returning the first
123-
* truthy result. If none yields a truthy value, `falsyControl` or
124-
* `falsyFallback` is tried as a last resort.
119+
* Walks the `fallbacks` array in order. Each entry is either a falsy gate
120+
* (`||`) or a nullish gate (`??`). A falsy gate opens when `!value`;
121+
* a nullish gate opens when `value == null`. When a gate is open, the
122+
* fallback is applied (control flow, ref pull, or constant coercion) and
123+
* the result replaces `value` for subsequent gates.
125124
*/
126-
export async function applyFalsyGate(
125+
export async function applyFallbackGates(
127126
ctx: TreeContext,
128127
w: WireWithGates,
129128
value: unknown,
130129
pullChain?: Set<string>,
131130
): Promise<unknown> {
132-
if (value) return value; // already truthy — gate is closed
133-
134-
if (w.falsyFallbackRefs?.length) {
135-
for (const ref of w.falsyFallbackRefs) {
136-
const fallback = await ctx.pullSingle(ref, pullChain);
137-
if (fallback) return fallback;
131+
if (!w.fallbacks?.length) return value;
132+
133+
for (const fallback of w.fallbacks) {
134+
const isFalsyGateOpen = fallback.type === "falsy" && !value;
135+
const isNullishGateOpen = fallback.type === "nullish" && value == null;
136+
137+
if (isFalsyGateOpen || isNullishGateOpen) {
138+
if (fallback.control) return applyControlFlow(fallback.control);
139+
if (fallback.ref) {
140+
value = await ctx.pullSingle(fallback.ref, pullChain);
141+
} else if (fallback.value !== undefined) {
142+
value = coerceConstant(fallback.value);
143+
}
138144
}
139145
}
140146

141-
if (w.falsyControl) return applyControlFlow(w.falsyControl);
142-
if (w.falsyFallback != null) return coerceConstant(w.falsyFallback);
143-
return value;
144-
}
145-
146-
// ── Layer 2b: Nullish Gate (??) ──────────────────────────────────────────────
147-
148-
/**
149-
* Apply the Nullish Gate (Layer 2b) to a resolved value.
150-
*
151-
* If the value is non-nullish the gate is a no-op. Otherwise `nullishControl`,
152-
* `nullishFallbackRef`, or `nullishFallback` is applied (in priority order).
153-
*/
154-
export async function applyNullishGate(
155-
ctx: TreeContext,
156-
w: WireWithGates,
157-
value: unknown,
158-
pullChain?: Set<string>,
159-
): Promise<unknown> {
160-
if (value != null) return value; // non-nullish — gate is closed
161-
162-
if (w.nullishControl) return applyControlFlow(w.nullishControl);
163-
if (w.nullishFallbackRef) return ctx.pullSingle(w.nullishFallbackRef, pullChain);
164-
if (w.nullishFallback != null) return coerceConstant(w.nullishFallback);
165147
return value;
166148
}
167149

packages/bridge-core/src/tree-utils.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export const SIMPLE_PULL_CACHE = Symbol.for("bridge.simplePull");
172172

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

0 commit comments

Comments
 (0)