import type { ObjectSchema } from "@wrnexus/validation"; import type { AnyProcedures, InferInput, ProcedureDef, ServiceContract } from "./types.ts"; /** A service name lands in a URL path, so keep it unescaped-safe. */ const SAFE_SERVICE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; /** A procedure name is also a property the caller writes as client.doThing(). */ const SAFE_PROCEDURE = /^[a-z][a-zA-Z0-9]*$/; /** * Fluent, IMMUTABLE builder: every method returns a new builder, so a shared * base can be branched without one branch mutating another. */ export class ProcedureBuilder { private constructor(private readonly def: ProcedureDef) {} static create(): ProcedureBuilder { return new ProcedureBuilder({}); } input>(schema: S): ProcedureBuilder, Output> { return new ProcedureBuilder, Output>({ ...this.def, input: schema, } as ProcedureDef, Output>); } output(): ProcedureBuilder { return new ProcedureBuilder({ ...this.def } as ProcedureDef); } permission(id: string): ProcedureBuilder { return new ProcedureBuilder({ ...this.def, permission: id }); } /** Mark safe to retry. Anything not marked is never retried. */ idempotent(): ProcedureBuilder { return new ProcedureBuilder({ ...this.def, idempotent: true }); } build(): ProcedureDef { return Object.freeze({ ...this.def }); } } export const procedure = ProcedureBuilder.create(); export function defineService(def: { name: string; procedures: Procedures; }): ServiceContract { if (!SAFE_SERVICE.test(def.name)) { throw new Error( `WRN-RPC-CONTRACT: service name ${JSON.stringify(def.name)} must be lowercase ` + `alphanumeric with single hyphens, e.g. "billing" or "billing-v2".`, ); } for (const name of Object.keys(def.procedures)) { if (!SAFE_PROCEDURE.test(name)) { throw new Error( `WRN-RPC-CONTRACT: procedure name ${JSON.stringify(name)} on service ` + `'${def.name}' must be a lowercase-initial identifier, e.g. "createInvoice".`, ); } } // Freeze each procedure, not just the map. AnyProcedures accepts any object // of ProcedureDef shape, so a hand-built def that never went through // procedure.build() would otherwise stay mutable and the "single source of // truth" guarantee would rest on every call site remembering the builder. const frozen: Record = {}; for (const [name, value] of Object.entries(def.procedures)) { frozen[name] = Object.freeze({ ...value }); } return Object.freeze({ name: def.name, procedures: Object.freeze(frozen) as Procedures, }); }