Files
16gagent/scripts/model-contract.mjs
T
2026-06-06 10:40:48 +08:00

649 lines
25 KiB
JavaScript

/**
* Shared Model Contract Layer
*
* 单一字段定义源(Single Source of Truth)。
* 所有 agent 通过 import { createContract } from "./model-contract.mjs" 引用,
* 禁止自行定义任何字段名。
*
* 提供:
* - entities[] — 所有实体的字段定义(PascalCase 名 + snake_case 表名)
* - auth{} — 认证配置
* - permissions[] — RBAC 权限矩阵
* - fieldMapping{} — snake_case ↔ camelCase 双向映射
* - validation{} — 字段级校验规则
*/
// ═══════════════════════════════════════════════════════
// 1. Domain-Specific Entity Templates
// ═══════════════════════════════════════════════════════
/**
* Base entity templates for common domains.
* These are the canonical field definitions for every entity.
* Key: domain name → Array of entity templates.
*/
const DOMAIN_ENTITIES = {
enterprise: [
{
name: "User",
table: "users",
description: "系统用户",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
{ name: "nickname", type: "VARCHAR(128)" },
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
{ name: "phone", type: "VARCHAR(20)" },
{ name: "avatar_url", type: "TEXT" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [
{ type: "hasMany", entity: "contracts", via: "user_id" },
{ type: "hasMany", entity: "customers", via: "user_id" },
],
},
{
name: "Contract",
table: "contracts",
description: "合同",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
{ name: "title", type: "VARCHAR(256)", required: true },
{ name: "party_a", type: "VARCHAR(128)" },
{ name: "party_b", type: "VARCHAR(128)" },
{ name: "amount", type: "DECIMAL(14,2)" },
{ name: "signed_at", type: "DATE" },
{ name: "expires_at", type: "DATE" },
{ name: "status", type: "VARCHAR(32)", defaultValue: "draft", enum: ["draft", "pending", "active", "expired", "terminated"] },
{ name: "file_url", type: "TEXT" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [
{ type: "belongsTo", entity: "users", via: "user_id" },
],
},
{
name: "Approval",
table: "approvals",
description: "审批流程",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
{ name: "entity_type", type: "VARCHAR(32)", required: true },
{ name: "entity_id", type: "UUID" },
{ name: "applicant_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
{ name: "status", type: "VARCHAR(32)", defaultValue: "pending", enum: ["pending", "approved", "rejected"] },
{ name: "form_data", type: "JSONB" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [
{ type: "belongsTo", entity: "users", via: "user_id" },
{ type: "belongsTo", entity: "users", via: "applicant_id", as: "applicant" },
],
},
{
name: "Customer",
table: "customers",
description: "客户",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
{ name: "name", type: "VARCHAR(128)", required: true },
{ name: "email", type: "VARCHAR(256)" },
{ name: "phone", type: "VARCHAR(20)" },
{ name: "company", type: "VARCHAR(128)" },
{ name: "source", type: "VARCHAR(64)" },
{ name: "tags", type: "JSONB", defaultValue: "[]" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [
{ type: "belongsTo", entity: "users", via: "user_id" },
],
},
{
name: "Reminder",
table: "reminders",
description: "到期提醒",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
{ name: "entity_type", type: "VARCHAR(32)", required: true },
{ name: "entity_id", type: "UUID" },
{ name: "remind_at", type: "TIMESTAMP", required: true },
{ name: "message", type: "TEXT" },
{ name: "sent", type: "BOOLEAN", defaultValue: "false" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [
{ type: "belongsTo", entity: "users", via: "user_id" },
],
},
],
pet: [
{
name: "User",
table: "users",
description: "系统用户",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
{ name: "nickname", type: "VARCHAR(128)" },
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
{ name: "phone", type: "VARCHAR(20)" },
{ name: "avatar_url", type: "TEXT" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [],
},
{
name: "Pet",
table: "pets",
description: "宠物",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
{ name: "name", type: "VARCHAR(64)", required: true },
{ name: "species", type: "VARCHAR(32)" },
{ name: "breed", type: "VARCHAR(64)" },
{ name: "birth_date", type: "DATE" },
{ name: "weight_kg", type: "DECIMAL(5,2)" },
{ name: "avatar_url", type: "TEXT" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [
{ type: "belongsTo", entity: "users", via: "user_id" },
],
},
],
ecommerce: [
{
name: "User",
table: "users",
description: "系统用户",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
{ name: "nickname", type: "VARCHAR(128)" },
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
{ name: "phone", type: "VARCHAR(20)" },
{ name: "avatar_url", type: "TEXT" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [],
},
{
name: "Product",
table: "products",
description: "商品",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
{ name: "title", type: "VARCHAR(256)", required: true },
{ name: "description", type: "TEXT" },
{ name: "price", type: "DECIMAL(10,2)" },
{ name: "stock", type: "INTEGER", defaultValue: "0" },
{ name: "images", type: "JSONB", defaultValue: "[]" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [
{ type: "belongsTo", entity: "users", via: "user_id" },
],
},
{
name: "Order",
table: "orders",
description: "订单",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
{ name: "status", type: "VARCHAR(32)", defaultValue: "pending", enum: ["pending", "paid", "shipped", "delivered", "cancelled"] },
{ name: "total_amount", type: "DECIMAL(12,2)" },
{ name: "address_id", type: "UUID", fkEntity: "addresses", fkColumn: "id" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [
{ type: "belongsTo", entity: "users", via: "user_id" },
],
},
],
education: [
{
name: "User",
table: "users",
description: "系统用户",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
{ name: "nickname", type: "VARCHAR(128)" },
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin", "instructor"] },
{ name: "phone", type: "VARCHAR(20)" },
{ name: "avatar_url", type: "TEXT" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [],
},
],
note: [
{
name: "User",
table: "users",
description: "系统用户",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
{ name: "nickname", type: "VARCHAR(128)" },
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
{ name: "phone", type: "VARCHAR(20)" },
{ name: "avatar_url", type: "TEXT" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [],
},
],
fitness: [
{
name: "User",
table: "users",
description: "系统用户",
fields: [
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
{ name: "nickname", type: "VARCHAR(128)" },
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
{ name: "phone", type: "VARCHAR(20)" },
{ name: "avatar_url", type: "TEXT" },
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
],
relationships: [],
},
],
};
// ═══════════════════════════════════════════════════════
// 2. Field Mapping Engine
// ═══════════════════════════════════════════════════════
/**
* Build bidirectional snake_case ↔ camelCase mappings from all contract entities.
* Scanned once; cached in the returned contract.
*/
function buildFieldMappings(entities) {
const snakeToCamel = {};
const camelToSnake = {};
// Base conversions from common SQL columns
const baseMappings = {
snakeToCamel: {
"created_at": "createdAt",
"updated_at": "updatedAt",
"user_id": "userId",
},
camelToSnake: {
"createdAt": "created_at",
"updatedAt": "updated_at",
"userId": "user_id",
},
};
Object.assign(snakeToCamel, baseMappings.snakeToCamel);
Object.assign(camelToSnake, baseMappings.camelToSnake);
for (const entity of entities) {
for (const field of entity.fields) {
const snake = field.name; // fields are stored in snake_case
// Convert to camelCase
const camel = snake.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
if (snake !== camel) {
snakeToCamel[snake] = camel;
camelToSnake[camel] = snake;
}
}
}
// Add common patterns
const known = [
["id", "id"],
["username", "username"],
["password_hash", "passwordHash"],
["nickname", "nickname"],
["role", "role"],
["phone", "phone"],
["avatar_url", "avatarUrl"],
["title", "title"],
["description", "description"],
["status", "status"],
["data", "data"],
["amount", "amount"],
["party_a", "partyA"],
["party_b", "partyB"],
["signed_at", "signedAt"],
["expires_at", "expiresAt"],
["file_url", "fileUrl"],
["entity_type", "entityType"],
["entity_id", "entityId"],
["applicant_id", "applicantId"],
["form_data", "formData"],
["name", "name"],
["email", "email"],
["company", "company"],
["source", "source"],
["tags", "tags"],
["breed", "breed"],
["species", "species"],
["birth_date", "birthDate"],
["weight_kg", "weightKg"],
["price", "price"],
["stock", "stock"],
["images", "images"],
["total_amount", "totalAmount"],
["address_id", "addressId"],
["remind_at", "remindAt"],
["message", "message"],
["sent", "sent"],
];
for (const [snake, camel] of known) {
if (!snakeToCamel[snake]) snakeToCamel[snake] = camel;
if (!camelToSnake[camel]) camelToSnake[camel] = snake;
}
return { snakeToCamel, camelToSnake };
}
// ═══════════════════════════════════════════════════════
// 3. Auth Configuration
// ═══════════════════════════════════════════════════════
function buildAuthConfig(domain) {
// Enterprise domain has stricter auth
const isEnterprise = domain === "enterprise";
return {
registrationFields: ["username", "password", "nickname"],
loginFields: ["username", "password"],
jwtPayload: ["userId", "username", "role"],
passwordPolicy: {
minLength: isEnterprise ? 8 : 6,
requireSpecial: isEnterprise,
},
};
}
// ═══════════════════════════════════════════════════════
// 4. Permissions Matrix
// ═══════════════════════════════════════════════════════
function buildPermissions(domain) {
return [
{ role: "admin", allow: ["*"] },
{ role: "user", allow: ["read:own", "create:*", "update:own", "delete:own"] },
];
}
// ═══════════════════════════════════════════════════════
// 5. Validation Rules
// ═══════════════════════════════════════════════════════
function buildValidation(entities) {
const rules = {};
for (const entity of entities) {
const fieldRules = {};
for (const field of entity.fields) {
if (field.validation) {
fieldRules[field.name] = { ...field.validation };
}
// Auto-generate from field properties
if (field.required || field.unique || field.enum || field.defaultValue !== undefined) {
if (!fieldRules[field.name]) fieldRules[field.name] = {};
}
if (field.required) {
fieldRules[field.name].required = true;
}
if (field.unique) {
fieldRules[field.name].unique = true;
}
if (field.enum) {
fieldRules[field.name].enum = field.enum;
}
if (field.defaultValue !== undefined) {
fieldRules[field.name].defaultValue = field.defaultValue;
}
}
if (Object.keys(fieldRules).length > 0) {
rules[entity.name] = fieldRules;
}
}
return rules;
}
// ═══════════════════════════════════════════════════════
// 6. Main Contract Builder
// ═══════════════════════════════════════════════════════
/**
* Create a Model Contract from PRD and Architecture.
*
* The contract is the SINGLE SOURCE OF TRUTH for all entity field definitions.
* All generators MUST read from the contract; they MUST NOT invent field names.
*
* @param {object} prd — PRD from SF-01 (project-intake-agent)
* @param {object} arch — Architecture from SF-02 (architecture-agent), optional
* @returns {object} Contract object
*/
export function createContract(prd, arch) {
const domain = (prd && prd.domain) || (arch && arch.domain) || "generic";
const projectName = (prd && prd.projectName) || (arch && arch.projectName) || "App";
// ── Get domain-specific entities ──
let entities = DOMAIN_ENTITIES[domain] || DOMAIN_ENTITIES.enterprise;
// Deep clone to avoid mutation
entities = JSON.parse(JSON.stringify(entities));
// ── Augment with domain-specific entities from features/APIs ──
// If architecture has derived tables, merge them into the contract
if (arch && arch.databaseSchema) {
const existingTables = new Set(entities.map(e => e.table));
const existingNames = new Set(entities.map(e => e.name));
for (const table of arch.databaseSchema) {
if (!existingTables.has(table.table) && table.table !== "users") {
// Auto-generate PascalCase entity name from snake_case table
const pascalName = table.table
.split("_")
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join("");
if (!existingNames.has(pascalName)) {
const entity = {
name: pascalName,
table: table.table,
description: table.description || `${pascalName}`,
fields: (table.fields || []).map(f => ({
name: f.name,
type: f.type || "TEXT",
required: (f.constraints || "").includes("NOT NULL") || (f.constraints || "").includes("PK"),
isPrimary: (f.constraints || "").includes("PK"),
isAuto: f.name === "created_at" || f.name === "updated_at" || (f.constraints || "").includes("DEFAULT"),
...((f.constraints || "").includes("UNIQUE") ? { unique: true } : {}),
...((f.constraints || "").includes("FK") ? {
fkEntity: (f.name.endsWith("_id") ? f.name.replace(/_id$/, "s") : "items"),
fkColumn: "id",
} : {}),
})),
relationships: [],
};
// Run FK detection on fields
for (const field of entity.fields) {
if (field.fkEntity) {
entity.relationships.push({
type: "belongsTo",
entity: field.fkEntity,
via: field.name,
});
}
}
entities.push(entity);
existingTables.add(table.table);
existingNames.add(pascalName);
}
}
}
}
// ── Build mappings ──
const fieldMapping = buildFieldMappings(entities);
const auth = buildAuthConfig(domain);
const permissions = buildPermissions(domain);
const validation = buildValidation(entities);
return {
projectName,
domain,
entities,
auth,
permissions,
fieldMapping,
validation,
};
}
// ═══════════════════════════════════════════════════════
// 7. Contract Utility Functions
// ═══════════════════════════════════════════════════════
/**
* Look up a field in the contract by entity.table and field name (snake_case).
*
* @param {object} contract
* @param {string} tableName — snake_case table name
* @param {string} fieldName — snake_case field name
* @returns {object|null} Field definition
*/
export function lookupField(contract, tableName, fieldName) {
for (const entity of contract.entities) {
if (entity.table === tableName) {
return entity.fields.find(f => f.name === fieldName) || null;
}
}
return null;
}
/**
* Get the camelCase name for a snake_case column from the contract's fieldMapping.
*
* @param {object} contract
* @param {string} snakeName
* @returns {string}
*/
export function toCamel(contract, snakeName) {
return contract.fieldMapping.snakeToCamel[snakeName] || snakeName;
}
/**
* Get the snake_case name for a camelCase property from the contract's fieldMapping.
*
* @param {object} contract
* @param {string} camelName
* @returns {string}
*/
export function toSnake(contract, camelName) {
return contract.fieldMapping.camelToSnake[camelName] || camelName;
}
/**
* Get the TypeScript type for a contract field.
*
* @param {object} field — Contract field definition
* @returns {string} TypeScript type string
*/
export function tsType(field) {
const t = (field.type || "").toUpperCase();
if (t.includes("INT") || t.includes("SERIAL") || t.includes("BIGINT") || t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("REAL")) return "number";
if (t.includes("BOOL")) return "boolean";
if (t.includes("JSONB") || t.includes("JSON")) return "Record<string, unknown>";
return "string";
}
/**
* Get the SQLite column type for a contract field.
*
* @param {object} field — Contract field definition
* @returns {string} SQLite type
*/
export function sqliteType(field) {
const t = (field.type || "").toUpperCase();
if (t.includes("UUID") || t.includes("TEXT") || t.includes("VARCHAR") || t.includes("CHAR")) return "TEXT";
if (t.includes("INT") || t.includes("SERIAL") || t.includes("BIGINT")) return "INTEGER";
if (t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("REAL")) return "REAL";
if (t.includes("BOOL")) return "INTEGER";
if (t.includes("DATE") || t.includes("TIME") || t.includes("TIMESTAMP")) return "TEXT";
if (t.includes("JSONB") || t.includes("JSON")) return "TEXT";
return "TEXT";
}
/**
* Get the non-auto fields for CreateInput (exclude id, created_at, updated_at).
*
* @param {object} entity — Contract entity
* @returns {object[]} Fields for CreateInput
*/
export function createInputFields(entity) {
return entity.fields.filter(f =>
!f.isPrimary && !f.isAuto && f.name !== "created_at" && f.name !== "updated_at"
);
}
/**
* Get the non-identity fields for UpdateInput (exclude id, created_at, updated_at).
*
* @param {object} entity — Contract entity
* @returns {object[]} Fields for UpdateInput
*/
export function updateInputFields(entity) {
return createInputFields(entity);
}
/**
* Check if an entity has the given field (by snake_case name).
*/
export function hasField(entity, snakeName) {
return entity.fields.some(f => f.name === snakeName);
}
// ═══════════════════════════════════════════════════════
// 8. Export DOMAIN_ENTITIES for introspection
// ═══════════════════════════════════════════════════════
export { DOMAIN_ENTITIES };