import { SchemaBase } from './dsl.js'; import type { TableSchema } from './db.js'; // Project topology definitions: describe the applications (frontends) and // backend APIs of a repository, and which frontends each API serves. /** Frontend form factor. Closed enum, extend when new form factors appear. */ export type FrontType = 'admin' | 'wxmini' | 'mobile'; /** A frontend application (e.g. admin console, wechat mini program). */ export interface FrontAppSchema extends SchemaBase { type: FrontType; /** Source directory relative to project root, e.g. 'web-admin/'. */ dir: string; /** * Tenant table for this app. The tenant column of a business table is * deterministic: `{tenant.phrase}_{tenant.pk}` (e.g. shop with pk id → * `shop_id`). Tables carrying that column get automatic tenant scoping; * tables without it are global tables (e.g. system config) — both valid. */ tenant?: TableSchema; } /** A backend API service. apps references shared FrontAppSchema instances. */ export interface ProjectApiSchema extends SchemaBase { /** Source directory relative to project root, e.g. 'api/'. */ dir: string; /** Frontends this API serves. Direct instance references (see defineProject). */ apps: FrontAppSchema[]; /** API base URL prefix shared by all apps it serves, e.g. '/mall'. '' = no prefix. */ contextPath?: string; /** API service base URL for node clients, e.g. 'http://127.0.0.1:3000'. */ baseUrl?: string; } /** A third-party system (e.g. wechat pay, unionpay). Owns its own * implementation dir and contract (controller_types), just like an API, * but is not part of this repo's served surface. */ export interface ThirdApiSchema extends SchemaBase { /** Source directory relative to project root, e.g. 'wechat/'. */ dir: string; } export interface ProjectSchema extends SchemaBase { apps: FrontAppSchema[]; apis: ProjectApiSchema[]; thirdApis: ThirdApiSchema[]; } // Instance naming: lowercase letters/digits/dashes only. Underscores belong // to table names; the instance export symbol in project.config.ts must equal // the kebab-camel of the name, so 'admin_api' cannot map to a valid symbol. const INSTANCE_NAME_RE = /^[a-z][a-z0-9-]*$/; function checkInstanceName(project: string, kind: string, name: string): void { if (!INSTANCE_NAME_RE.test(name)) { throw new Error( `project ${project}: ${kind} name '${name}' must match ${INSTANCE_NAME_RE} (lowercase letters/digits/dashes; underscores are table-only)`, ); } } // Directory convention: the instance dir equals its name ('api/' == 'api'). // One concept, one spelling — no separate dir/name pairs to keep in sync. function normalizedDir(dir: string): string { return dir.replace(/[\\/]+$/, ''); } function checkDirMatchesName(project: string, kind: string, name: string, dir: string): void { if (normalizedDir(dir) !== name) { throw new Error(`project ${project}: ${kind} '${name}' dir must equal its name (got '${dir}')`); } } /** * Defines the project topology. FrontAppSchema instances are shared value objects: * api.apps references the same instances from project.apps, so an app served * by multiple APIs is defined once and referenced many times. * * Runtime-validates app type whitelist, unique names, api.apps reference * integrity (same style as defineTable/defineCurd), plus two naming * conventions: every app/api/thirdApi dir equals its name ('api/' == 'api'), * and the first api must be named exactly 'api' (prefixed names like * 'xx-api' are only allowed from the second api on). */ export function defineProject( name: string, schema: { description?: string; apps: FrontAppSchema[]; apis: ProjectApiSchema[]; thirdApis?: ThirdApiSchema[]; }, ): ProjectSchema { const project: ProjectSchema = { name, ...schema, thirdApis: schema.thirdApis ?? [] }; const appNames = new Set(); for (const app of project.apps) { if (!app.name) throw new Error(`project ${name}: app name is required`); checkInstanceName(name, 'app', app.name); if (appNames.has(app.name)) throw new Error(`project ${name}: duplicate app name '${app.name}'`); appNames.add(app.name); if (app.type !== 'admin' && app.type !== 'wxmini' && app.type !== 'mobile') { throw new Error(`project ${name}: app '${app.name}' must be type 'admin', 'wxmini' or 'mobile' (got '${app.type}')`); } if (!app.dir) throw new Error(`project ${name}: app '${app.name}' dir is required`); checkDirMatchesName(name, 'app', app.name, app.dir); } const apiNames = new Set(); for (const api of project.apis) { if (!api.name) throw new Error(`project ${name}: api name is required`); checkInstanceName(name, 'api', api.name); if (apiNames.has(api.name)) throw new Error(`project ${name}: duplicate api name '${api.name}'`); apiNames.add(api.name); if (!api.dir) throw new Error(`project ${name}: api '${api.name}' dir is required`); checkDirMatchesName(name, 'api', api.name, api.dir); for (const ref of api.apps) { if (!project.apps.includes(ref)) { throw new Error(`project ${name}: api '${api.name}' references app '${ref.name}' that is not a shared instance in project.apps (define once and reference it)`); } } } if (project.apis.length > 0 && project.apis[0].name !== 'api') { throw new Error( `project ${name}: first api must be named 'api' (got '${project.apis[0].name}'); prefixed names like 'xx-api' are only allowed from the second api on`, ); } for (const third of project.thirdApis) { if (!third.name) throw new Error(`project ${name}: thirdApi name is required`); checkInstanceName(name, 'thirdApi', third.name); if (!third.dir) throw new Error(`project ${name}: thirdApi '${third.name}' dir is required`); checkDirMatchesName(name, 'thirdApi', third.name, third.dir); } return project; }