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,
});
}