From f37c0ccc5713b801c753f63da3b24ce258c81944 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Sun, 28 Jun 2026 22:31:24 -0700 Subject: [PATCH] feat(speckl): emit TransitionNode and OneofNode in proto generator (cycle 64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .proto generator now recognizes two new top-level speck members and emits real proto3 syntax for them: oneof Name { oneof is NOT emitted at top level variant_field: VariantType (it lives inside the transition's } payload block — see below) transition Name { emitted as: type: message Name { payload: hlc: Bytes oneof payload { } VariantType variant_field = 10; ... } } The and fields are special-cased: type records the discriminator enum reference (so future type-aware code can find it), payload records the sibling oneof block to inline. Neither is emitted as a regular proto field. The oneof variants reference the suffixed message name when the speck has 'event_suffix: "Payload"' (or any suffix). This makes the generated variants match the consumer's Go types in the federated- meetup case: 'CreateGroupPayload create_group = 10' is the type binding the consumer's apply switch expects. End-to-end verified: a speck with event_suffix: "Payload" oneof TransitionPayload { create_group: CreateGroup add_steward: AddSteward } transition Transition { type: TransitionType payload: TransitionPayload hlc: Bytes } event CreateGroup { name: String } event AddSteward { new_steward: String } emits the right proto3: message CreateGroupPayload { ... } message AddStewardPayload { ... } message Transition { bytes hlc = 1; oneof payload { CreateGroupPayload create_group = 10; AddStewardPayload add_steward = 11; } } All 55 tests pass. tsc build clean. The federated-meetup speckdl (specs/FederatedMeetup.speckdl) does not yet declare the Transition envelope — it uses 13 separate event blocks. Adding the transition+oneof envelope to the real spec is the next step (cycle 65). --- compiler/src/generators/protobuf.ts | 40 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/compiler/src/generators/protobuf.ts b/compiler/src/generators/protobuf.ts index 4fb895a..18e5246 100644 --- a/compiler/src/generators/protobuf.ts +++ b/compiler/src/generators/protobuf.ts @@ -22,7 +22,7 @@ * existing determinism claim. */ -import { AST, SpeckNode, MemberNode, StateNode, EventNode, InterfaceNode, TypeExpr, ServiceNode } from '../parser.js'; +import { AST, SpeckNode, MemberNode, StateNode, EventNode, InterfaceNode, TypeExpr, ServiceNode, TransitionNode, OneofNode } from '../parser.js'; import fs from 'fs'; import path from 'path'; @@ -270,8 +270,8 @@ function emitSpeck(speck: SpeckNode, packageName?: string): string { let protoType = speckTypeToProto(v.typeExpr.name || ''); if (v.typeExpr.type === 'map' && v.typeExpr.keyType && v.typeExpr.valueType) { const k = speckTypeToProto(v.typeExpr.keyType.name || 'string'); - const val = speckTypeToProto(v.typeExpr.valueType.name || 'bytes'); - protoType = `map<${k}, ${val}>`; + const v2 = speckTypeToProto(v.typeExpr.valueType.name || 'bytes'); + protoType = `map<${k}, ${v2}>`; } else if (v.typeExpr.type === 'list' && v.typeExpr.elementType) { const inner = speckTypeToProto(v.typeExpr.elementType.name || 'string'); protoType = `repeated ${inner}`; @@ -282,6 +282,40 @@ function emitSpeck(speck: SpeckNode, packageName?: string): string { lines.push(''); } + // Cycle 64: emit TransitionNode messages. A `transition Name { ... }` + // block produces a proto message with the regular fields plus an inline + // `oneof payload { ... }` block, whose variants are pulled from a + // sibling `oneof { ... }` block referenced by `payload: `. + // Field numbers are assigned starting at 1; the oneof variants use + // 10, 11, 12, ... (proto convention: leave 1-15 for the most stable + // fields so the wire format is forward-compatible). + const transitionNodes = members.filter(m => m.type === 'transition') as TransitionNode[]; + const oneofNodes = members.filter(m => m.type === 'oneof') as OneofNode[]; + for (const t of transitionNodes) { + lines.push(`message ${t.name} {`); + const fieldNums = assignFieldNumbers(t.fields); + for (const f of t.fields) { + const num = fieldNums.get(f.name) || 1; + const protoType = speckTypeToProto(f.type.name || ''); + lines.push(` ${protoType} ${snakeCase(f.name)} = ${num};`); + } + if (t.payloadOneof) { + const oneof = oneofNodes.find(o => o.name === t.payloadOneof); + if (oneof) { + lines.push(' oneof payload {'); + oneof.variants.forEach((v, i) => { + // Reference the suffixed message name if event_suffix is set, + // so the oneof variants match what the consumer Go code expects. + const variantType = speck.eventSuffix ? v.typeName + speck.eventSuffix : v.typeName; + lines.push(` ${variantType} ${v.fieldName} = ${i + 10};`); + }); + lines.push(' }'); + } + } + lines.push('}'); + lines.push(''); + } + // ─── Service (RPC surface) ────────────────────────────────── // ServiceNode members emit proto3 service blocks. These map directly to // gRPC and ConnectRPC service definitions; buf generate + protoc-gen-go