import type { CollectionSchemaBase, SchemaBase } from './dsl.js'; import type { FrontAppSchema, ProjectApiSchema } from './project.js'; import type { DtoArrayField, DtoField, DtoMessage, DtoObjectField } from './dto.js'; import type { ExceptionSchema } from './exception.js'; import type { FlowSchema } from './flow.js'; import { exceptionEndNames } from './flow.js'; /** A backend service serving one frontend app (1:1 module), or a * platform-shared domain service (app unset — shared across modules). */ export interface ServiceSchema extends CollectionSchemaBase { type: 'service'; /** The backend api module this service belongs to (shared instance from * project.config.ts apis). Services are always backend-side, so storage is * service_schema/{api.name}/{app.name}/service/ — app unset = the api-level * common domain layer, stored at service_schema/{api.name}/common/service/. */ api: ProjectApiSchema; /** The frontend app this service serves (shared instance from project.config). * Unset = api-level common domain service shared by all modules of the api. */ app?: FrontAppSchema; /** Methods keyed by name — the map key is written back as the method name. */ methods: Record; } /** Method input for defineService: type/schema/name are set by the builder. */ export type ServiceMethodDef = Omit; export function defineService(options: { name: string; api: ProjectApiSchema; app?: FrontAppSchema; methods: Record; description?: string; }): ServiceSchema { if (!/(Service|Handler)$/.test(options.name)) { throw new Error( `service ${options.name}: name must end with 'Service' (or 'Handler' for handler-style services)`, ); } if (options.app && !options.api.apps.includes(options.app)) { throw new Error(`service ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`); } const schema: ServiceSchema = { type: 'service', name: options.name, description: options.description, api: options.api, app: options.app, methods: {}, }; for (const key of Object.keys(options.methods)) { const method = options.methods[key]; schema.methods[key] = { type: 'method', schema, ...method, name: key }; } validateFlowBindings(schema); return schema; } // A method bound to a flow: the flow's escape set (its exception ends) must // equal the method's declared throws, the flow's own args/results (when // present) must be the same objects as the method's, and one flow may serve // only one method. // // Notes: (1) the unique-binding check is per defineService call — a flow // shared across two service schemas is not detected; (2) escape-set equality // means exceptions swallowed by an internal catch (or routed through a // catch-all end) disappear from the contract, so the method must not declare // them. function validateFlowBindings(schema: ServiceSchema): void { const seenFlows = new Set(); for (const key of Object.keys(schema.methods)) { const m = schema.methods[key]; if (!m.flow) continue; if (seenFlows.has(m.flow)) { throw new Error(`service ${schema.name}: flow "${m.flow.name}" is bound to more than one method`); } seenFlows.add(m.flow); if (m.flow.args !== undefined && m.flow.args !== m.args) { throw new Error( `service ${schema.name}: method "${key}" args and its flow "${m.flow.name}" args must be the same object`, ); } if (m.flow.results !== undefined && m.flow.results !== m.results) { throw new Error( `service ${schema.name}: method "${key}" results and its flow "${m.flow.name}" results must be the same object`, ); } const escaped = exceptionEndNames(m.flow); const declared = new Set((m.throws ?? []).map((t) => t.name)); for (const e of escaped) { if (!declared.has(e)) { throw new Error(`service ${schema.name}: method "${key}" flow escapes ${e} but the method does not declare it`); } } for (const d of declared) { if (!escaped.has(d)) { throw new Error(`service ${schema.name}: method "${key}" declares throw ${d} but its flow never escapes it`); } } if (m.flow.name !== key) { console.warn(`service ${schema.name}: method "${key}" flow name "${m.flow.name}" differs from the method key`); } } } /** A method exposed by a business service — the contract with the calling * frontend. Third-party integration methods are a separate contract * (ThirdServiceMethodSchema): they can never bind a flow. */ export interface ServiceMethodSchema extends SchemaBase { type: 'method'; schema: ServiceSchema; args: DtoMessage, results : DtoMessage /** Exceptions this method may throw (e.g. IOException, CodeException). */ throws?: ExceptionSchema[]; /** Implementation flow — the method's logic graph. The method carries the * contract (args/results/throws), the flow carries the structure. */ flow?: FlowSchema; }