Commit 0eba3150 authored by drigle's avatar drigle

feat: add project subject schema manager skill

parent a562482f
---
name: subject-schema-manager
description: Sync Stream Subject definitions from the API into project documentation, remove unreferenced object sections, and apply confirmed object/field/configuration changes through the documented Subject API.
---
# Subject Schema Manager
Use this skill when the user asks to synchronize Subject/object documentation or asks the AI to create, update, or configure Stream Subjects and fields.
## Source of truth
- The live Subject API is authoritative for object and field structure.
- Read the repository's `SUBJECT_API.md`, `SUBJECT_CONFIGURATION.md`, `record-api.md`, and `subjectsDefinition.md` before acting. Prefer `src/services/record-api.md` and `src/services/subjectsDefinition.md` when the files exist there.
- `subjectsDefinition.md` is a versioned, human-readable snapshot generated from the API. It is not a runtime API client.
- Form resources are managed by `/form`; do not treat a `form` field as a Subject dependency.
## Modes
### `sync`
Use for requests to update object documentation or remove copied-project objects.
1. Discover Subject roots from static Subject constants and literal Subject API calls in the project's source. Prefer explicit roots supplied by the user or the script's `--roots` option when discovery is ambiguous.
2. Exchange the supplied ticket for a token using `POST /auth/login` with `{ "type": "ticket", "ticket": "..." }`. Never put the ticket or token in a file, generated Markdown, or normal command output.
3. Fetch each root with `GET /subject/{subject}`. Recursively follow every non-empty `field.settings.subject` on `reference`, `subject`, or other fields. Stop at cycles after documenting the object once.
4. Rebuild only the managed object-definition block in `subjectsDefinition.md`. The block must contain the root objects, every recursively referenced object, complete field properties, and dependency edges. Object sections outside the current dependency closure are removed from the managed block.
5. Do not mutate remote objects in this mode. A missing/ambiguous `settings.subject` is documented as unresolved; never invent a target object.
Run the deterministic helper from the project root:
```bash
node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs sync \
--project "$PWD" --ticket-stdin
```
Use `--check` for a read-only diff. The helper discovers the API base URL from `--base-url`, `STREAMS_API_URL`, or an absolute proxy target in `.umirc.ts`.
### `apply`
Use for requests that create objects, create/update fields, or update object configuration.
1. Inspect the API docs and produce an exact operation plan containing object names, field aliases, payloads, and dependency order. Include a concise human-readable summary and ask for explicit confirmation before any remote mutation.
2. After confirmation, write the plan as JSON outside the repository (for example `/tmp/subject-schema-plan.json`) and run the helper with `--confirmed --plan-file`. Pass the ticket via `SUBJECT_TICKET` or `--ticket-stdin`; never hardcode it.
3. The helper validates aliases and payloads, previews new objects with `POST /subject?preview=true`, then executes only the bounded operations in the plan:
- object create: `POST /subject`
- object update: `PUT /subject/{subject}`
- field create: `POST /subject/{subject}/field`
- field update: `PUT /subject/{subject}/field/{field}`
- field import/reorder when explicitly present in the plan
4. Remote object/field deletion is never implied by documentation cleanup. It requires explicit user intent and the helper's destructive-operation flag.
5. After successful mutations, re-fetch the complete dependency closure and run the same documentation sync using the authenticated token. Report successes and any partial-failure operation; there is no automatic rollback.
Run:
```bash
node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs apply \
--project "$PWD" --plan-file /tmp/subject-schema-plan.json \
--ticket-stdin --confirmed
```
The plan format is documented in [references/plan-schema.md](references/plan-schema.md). Do not use a generic arbitrary-URL request list; keep mutations within the documented Subject operations.
## Safety and documentation rules
- A plan confirmation authorizes only the listed operations. Do not add inferred fields, dependencies, or destructive operations after confirmation.
- Validate `select.settings.options`, `reference/subject settings.subject`, `required`, `multiple`, and `primary` against the API response or the confirmed plan.
- Use IDs for `reference` and `user` values, ISO 8601 for dates, and preserve `form` values as `{ form, data, settings? }`.
- If the API or local docs conflict, stop before mutation and show the exact conflict.
- If a business workflow or state rule changes, update the module's business-flow document separately; schema synchronization alone does not authorize workflow changes.
interface:
display_name: "Subject Schema Manager"
short_description: "Sync Subject docs and apply confirmed schema plans"
default_prompt: "Use $subject-schema-manager to sync current Subject definitions or apply a confirmed object/field configuration plan."
# Subject Mutation Plan
`apply` accepts a JSON file containing only the explicitly approved Subject API operations. Keep
the file outside the repository when it contains environment-specific values.
```json
{
"version": 1,
"operations": [
{
"op": "create_subject",
"subject": {
"name": "customers",
"title": "Customers",
"type": "normal",
"access": "public",
"fields": [],
"views": []
}
},
{
"op": "update_subject",
"subject": "orders",
"patch": {"title": "Orders", "history": true}
},
{
"op": "create_field",
"subject": "orders",
"field": {
"name": "customer",
"label": "Customer",
"type": "reference",
"required": false,
"multiple": false,
"settings": {"subject": "customers"}
}
},
{
"op": "update_field",
"subject": "orders",
"field": "status",
"patch": {
"label": "Order status",
"settings": {"options": [{"label": "Open", "value": "open"}]}
}
},
{
"op": "import_fields",
"subject": "orders",
"fields": [{"name": "amount", "label": "Amount", "type": "number"}]
},
{
"op": "reorder_fields",
"subject": "orders",
"fields": ["order_no", "customer", "status", "amount"]
}
]
}
```
Supported operation names are `create_subject`, `update_subject`, `create_field`, `update_field`,
`import_fields`, `reorder_fields`, `delete_subject`, and `delete_field`. Object and field aliases
must contain only letters, digits, `_`, or `-`. `update_subject.patch` and `update_field.patch`
must contain only the properties supported by the corresponding API endpoints in
`SUBJECT_API.md`; the helper rejects arbitrary URLs or methods.
Creation of an object is always sent to `POST /subject?preview=true` before any mutating request.
Deletion operations are accepted only with the command-line flag `--allow-remote-delete` in
addition to `--confirmed`. A deletion may set `force: true` and `confirm` where the API requires
it; `confirm` must exactly equal the alias being deleted. Documentation synchronization alone
never creates a deletion plan.
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const START_MARKER = '<!-- SUBJECT_SCHEMA_SYNC:START -->';
const END_MARKER = '<!-- SUBJECT_SCHEMA_SYNC:END -->';
const ALIAS_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const VOLATILE_KEYS = new Set([
'_id', 'created_at', 'updated_at', 'deleted_at', '__v', 'created_by',
'table', 'state', 'schema_version',
]);
const SUBJECT_PATCH_KEYS = new Set([
'title', 'description', 'order', 'tags', 'categories', 'templates', 'settings',
'access', 'members', 'search', 'history',
]);
const FIELD_PATCH_KEYS = new Set([
'type', 'label', 'description', 'default', 'primary', 'required', 'multiple',
'search', 'aggregation', 'settings',
]);
function usage() {
console.error(`Usage:
subject_schema_manager.mjs sync --project <path> [--ticket-stdin] [--check] [--roots a,b]
subject_schema_manager.mjs apply --project <path> --plan-file <path> --confirmed [--ticket-stdin]
Options:
--base-url <url> API base; otherwise STREAMS_API_URL or .umirc.* proxy target
--doc <path> subjectsDefinition.md path relative to project or absolute path
--ticket-stdin read the ticket from stdin (never prints it)
--allow-remote-delete required for explicit delete_subject/delete_field operations
`);
}
function parseArgs(argv) {
const [command, ...rest] = argv;
if (!command || !['sync', 'apply'].includes(command)) {
usage();
throw new Error('command must be sync or apply');
}
const options = { command, roots: [] };
for (let index = 0; index < rest.length; index += 1) {
const value = rest[index];
if (value === '--check' || value === '--confirmed' || value === '--ticket-stdin' || value === '--allow-remote-delete') {
options[value.slice(2).replaceAll('-', '_')] = true;
continue;
}
if (value.startsWith('--')) {
const key = value.slice(2).replaceAll('-', '_');
const next = rest[index + 1];
if (!next || next.startsWith('--')) throw new Error(`${value} requires a value`);
index += 1;
if (key === 'roots') options.roots = next.split(',').map((item) => item.trim()).filter(Boolean);
else options[key] = next;
continue;
}
throw new Error(`unknown argument: ${value}`);
}
return options;
}
function findProjectDoc(project, explicitDoc) {
if (explicitDoc) return path.resolve(project, explicitDoc);
const candidates = [
path.join(project, 'src/services/subjectsDefinition.md'),
path.join(project, 'subjectsDefinition.md'),
];
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
}
function readText(file) {
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
}
function walkSourceFiles(directory, result = []) {
if (!fs.existsSync(directory)) return result;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (entry.name.startsWith('.') || ['node_modules', 'dist', 'build', '.next'].includes(entry.name)) continue;
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) walkSourceFiles(fullPath, result);
else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) result.push(fullPath);
}
return result;
}
function discoverRoots(project, explicitRoots) {
if (explicitRoots?.length) return [...new Set(explicitRoots)];
const sourceRoot = path.join(project, 'src');
const roots = new Set();
const constantPattern = /(?:^|\n|\r)\s*(?:export\s+)?const\s+[A-Z][A-Z0-9_]*SUBJECT[A-Z0-9_]*\s*=\s*['"]([A-Za-z][A-Za-z0-9_-]*)['"]/g;
const literalSubjectPattern = /\bsubject\s*:\s*['"]([A-Za-z][A-Za-z0-9_-]*)['"]/g;
const loadSubjectPattern = /\bloadSubject\(\s*['"]([A-Za-z][A-Za-z0-9_-]*)['"]\s*\)/g;
for (const file of walkSourceFiles(sourceRoot)) {
const source = readText(file);
for (const pattern of [constantPattern, literalSubjectPattern, loadSubjectPattern]) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(source))) roots.add(match[1]);
}
}
if (!roots.size) throw new Error('No static Subject roots found; pass --roots alias1,alias2');
return [...roots].sort();
}
function resolveBaseUrl(project, explicitBase) {
const candidate = explicitBase || process.env.STREAMS_API_URL;
if (candidate && /^https?:\/\//i.test(candidate)) return candidate.replace(/\/$/, '');
const configFiles = ['.umirc.ts', '.umirc.js', '.umirc.tsx', 'config/config.ts', 'config/config.js'];
for (const file of configFiles) {
const source = readText(path.join(project, file));
const matches = [...source.matchAll(/target\s*:\s*['"](https?:\/\/[^'"]+)['"]/g)];
const apiTarget = matches.find((match) => /\/api(?:\/|$)/.test(match[1])) || matches[0];
if (apiTarget) return apiTarget[1].replace(/\/$/, '');
}
if (candidate && candidate.startsWith('/')) {
throw new Error(`API base ${candidate} is relative; pass --base-url with an absolute URL for the Node helper`);
}
throw new Error('Cannot resolve API base URL; pass --base-url or set STREAMS_API_URL');
}
function joinUrl(baseUrl, requestPath) {
return new URL(requestPath.replace(/^\//, ''), `${baseUrl.replace(/\/$/, '')}/`).toString();
}
function safeErrorMessage(status, payload) {
const message = payload && typeof payload === 'object'
? payload.message || payload.error || payload.code
: '';
return `${status}${message ? `: ${String(message).slice(0, 300)}` : ''}`;
}
async function request(baseUrl, token, requestPath, { method = 'GET', body } = {}) {
const response = await fetch(joinUrl(baseUrl, requestPath), {
method,
headers: {
Accept: 'application/json',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
let payload = null;
if (text) {
try { payload = JSON.parse(text); } catch { payload = { message: text.slice(0, 300) }; }
}
if (!response.ok) throw new Error(`API ${method} ${requestPath} failed (${safeErrorMessage(response.status, payload)})`);
return payload;
}
function readTicket(options) {
const ticket = process.env.SUBJECT_TICKET?.trim()
|| (options.ticket_stdin ? fs.readFileSync(0, 'utf8').trim() : '');
if (!ticket) throw new Error('Provide the ticket via SUBJECT_TICKET or --ticket-stdin');
return ticket;
}
async function exchangeTicket(baseUrl, ticket) {
const payload = await request(baseUrl, '', '/auth/login', {
method: 'POST',
body: { type: 'ticket', ticket },
});
const token = payload?.token || payload?.data?.token || payload?.result?.token;
if (!token) throw new Error('Ticket exchange succeeded but no API token was returned');
return token;
}
function findNested(value, predicate, depth = 0) {
if (depth > 5 || value === null || typeof value !== 'object') return null;
if (predicate(value)) return value;
for (const child of Object.values(value)) {
const found = findNested(child, predicate, depth + 1);
if (found) return found;
}
return null;
}
function normalizeSubject(payload, expectedName) {
const subject = findNested(payload, (value) => (
value && typeof value === 'object' && value.name === expectedName
&& (Array.isArray(value.fields) || typeof value.type === 'string' || typeof value.title === 'string')
));
if (!subject) throw new Error(`API response did not contain Subject ${expectedName}`);
return { ...subject, fields: Array.isArray(subject.fields) ? subject.fields : undefined };
}
function normalizeFields(payload) {
const fields = findNested(payload, (value) => Array.isArray(value)
&& value.every((item) => item && typeof item === 'object' && typeof item.name === 'string'));
return fields || [];
}
async function fetchSubject(baseUrl, token, name) {
let subject;
try {
subject = normalizeSubject(await request(baseUrl, token, `/subject/${encodeURIComponent(name)}`), name);
} catch (error) {
if (/\b404\b|NOT_FOUND|not found/i.test(error.message)) throw new Error(`Subject ${name} could not be resolved (${error.message})`);
throw error;
}
if (!Array.isArray(subject.fields)) {
subject.fields = normalizeFields(await request(baseUrl, token, `/subject/${encodeURIComponent(name)}/field`));
}
return subject;
}
function dependencyNames(subject) {
const names = [];
for (const field of subject.fields || []) {
if (field?.type === 'form') continue;
const target = field?.settings?.subject;
if (typeof target === 'string' && target.trim()) names.push(target.trim());
}
return [...new Set(names)];
}
async function fetchClosure(baseUrl, token, roots) {
const subjects = new Map();
const unresolved = [];
const queue = [...roots];
while (queue.length) {
const name = queue.shift();
if (subjects.has(name)) continue;
try {
const subject = await fetchSubject(baseUrl, token, name);
subjects.set(name, subject);
for (const target of dependencyNames(subject)) {
if (!subjects.has(target)) queue.push(target);
}
} catch (error) {
unresolved.push({ name, reason: error.message });
}
}
return { subjects, unresolved };
}
function stableClone(value) {
if (Array.isArray(value)) return value.map(stableClone);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).sort().filter((key) => !VOLATILE_KEYS.has(key)).map((key) => [key, stableClone(value[key])]));
}
function json(value) {
return JSON.stringify(stableClone(value), null, 2);
}
function fieldSettingsSummary(field) {
const settings = field?.settings || {};
const target = settings.subject ? `Subject: ${settings.subject}` : '';
const options = Array.isArray(settings.options) ? `options: ${settings.options.map((item) => item?.value ?? item?.label ?? item).join(', ')}` : '';
const extras = Object.entries(settings)
.filter(([key]) => !['subject', 'options'].includes(key))
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`);
return [target, options, ...extras].filter(Boolean).join('; ');
}
function renderSubject(subject) {
const fields = subject.fields || [];
const lines = [
`### \`${subject.name}\`:${subject.title || subject.name}`,
'',
`- 类型:\`${subject.type || 'unknown'}\``,
`- 字段数量:${fields.length}`,
subject.description ? `- 说明:${subject.description}` : '',
'',
'| 字段标签 | 字段别名 | 字段类型 | 多值 | 必填 | 主键 | 关联/配置 |',
'| --- | --- | --- | --- | --- | --- | --- |',
...fields.map((field) => [
field.label || field.name,
`\`${field.name}\``,
field.type || '',
field.multiple ? '是' : '否',
field.required ? '是' : '否',
field.primary ? '是' : '否',
fieldSettingsSummary(field),
].map((cell) => String(cell).replaceAll('|', '\\|')).join(' | ').replace(/^/, '| ').concat(' |')),
'',
'<details>',
'<summary>完整对象与字段配置</summary>',
'',
'```json',
json(subject),
'```',
'',
'</details>',
'',
];
return lines.filter((line, index) => !(line === '' && lines[index - 1] === '')) .join('\n');
}
function renderManagedBlock(subjects, roots, unresolved) {
const ordered = [...subjects.values()].sort((a, b) => {
const ai = roots.indexOf(a.name); const bi = roots.indexOf(b.name);
if (ai >= 0 || bi >= 0) return (ai < 0 ? 1 : bi < 0 ? -1 : ai - bi);
return a.name.localeCompare(b.name);
});
const lines = [START_MARKER, '', `> 同步根对象:${roots.map((name) => `\`${name}\``).join('、')}`, `> 当前依赖闭包:${ordered.length} 个 Subject。`, ''];
for (const subject of ordered) lines.push(renderSubject(subject));
lines.push('### 关联对象与子对象定义', '', '依赖字段通过 `settings.subject` 指向的对象会递归纳入本节;`form` 资源不属于 Subject 依赖。', '');
for (const subject of ordered) {
for (const field of subject.fields || []) {
if (field?.type === 'form') continue;
const target = field?.settings?.subject;
if (typeof target === 'string' && target.trim()) lines.push(`- \`${subject.name}.${field.name}\` -> \`${target.trim()}\``);
}
}
if (unresolved.length) {
lines.push('', '### 未解析的关联对象', '', ...unresolved.map((item) => `- \`${item.name}\`:${item.reason}`));
}
lines.push('', END_MARKER);
return lines.join('\n');
}
function replaceManagedBlock(document, block) {
const start = document.indexOf(START_MARKER);
const end = document.indexOf(END_MARKER);
if (start >= 0 && end >= 0 && end > start) {
const replaced = `${document.slice(0, start)}${block}${document.slice(end + END_MARKER.length)}`;
return removeLegacyDependencySection(replaced);
}
const heading = /^## 2\.\s*Subject Definitions[^\n]*\n?/m.exec(document);
if (heading) {
const contentStart = heading.index + heading[0].length;
const nextHeading = /^## (?!#)[^\n]*\n?/gm;
nextHeading.lastIndex = contentStart;
const next = nextHeading.exec(document);
const sectionEnd = next ? next.index : document.length;
const section = document.slice(contentStart, sectionEnd);
const objectHeadings = [...section.matchAll(/^### [^\n]*`([A-Za-z][A-Za-z0-9_-]*)`[^\n]*\n?/gm)];
let migratedSection = section;
if (objectHeadings.length) {
for (let index = objectHeadings.length - 1; index >= 0; index -= 1) {
const current = objectHeadings[index];
const startIndex = current.index;
const endIndex = objectHeadings[index + 1]?.index ?? section.length;
migratedSection = `${migratedSection.slice(0, startIndex)}${migratedSection.slice(endIndex)}`;
}
const firstIndex = objectHeadings[0].index;
migratedSection = `${migratedSection.slice(0, firstIndex)}\n${block}\n\n${migratedSection.slice(firstIndex)}`;
} else {
migratedSection = `\n${block}\n\n${section}`;
}
let migrated = `${document.slice(0, contentStart)}${migratedSection}${document.slice(sectionEnd)}`;
return removeLegacyDependencySection(migrated);
}
return `${document.trimEnd()}\n\n## 2. Subject Definitions\n\n${block}\n`;
}
function removeLegacyDependencySection(document) {
const legacy = /^## 3\.\s*关联对象与子对象定义[^\n]*\n?/m.exec(document);
if (!legacy) return document;
const next = /^## 4\.\s*类型写入协议[^\n]*\n?/m.exec(document.slice(legacy.index + legacy[0].length));
const nextIndex = next ? legacy.index + legacy[0].length + next.index : document.length;
return `${document.slice(0, legacy.index)}${document.slice(nextIndex)}`;
}
async function synchronize({ project, doc, baseUrl, token, roots, check = false }) {
const closure = await fetchClosure(baseUrl, token, roots);
const current = readText(doc);
const next = replaceManagedBlock(current, renderManagedBlock(closure.subjects, roots, closure.unresolved));
if (check) {
if (current !== next) {
console.error(`Documentation is out of date: ${path.relative(project, doc)}`);
process.exitCode = 2;
} else {
console.log(`Documentation is synchronized: ${closure.subjects.size} Subject(s)`);
}
} else {
fs.mkdirSync(path.dirname(doc), { recursive: true });
fs.writeFileSync(doc, next, 'utf8');
console.log(`Synchronized ${closure.subjects.size} Subject(s) in ${path.relative(project, doc)}`);
}
if (closure.unresolved.length) {
console.error(`Unresolved dependency count: ${closure.unresolved.length}`);
}
return closure;
}
function assertAlias(value, label) {
if (typeof value !== 'string' || !ALIAS_RE.test(value)) throw new Error(`${label} must match ${ALIAS_RE}`);
}
function validateField(field, label) {
if (!field || typeof field !== 'object') throw new Error(`${label} must be an object`);
assertAlias(field.name, `${label}.name`);
if (typeof field.label !== 'string' || !field.label.trim()) throw new Error(`${label}.label is required`);
if (typeof field.type !== 'string' || !field.type.trim()) throw new Error(`${label}.type is required`);
if (field.type === 'select') {
if (!Array.isArray(field.settings?.options)) throw new Error(`${label}.settings.options is required for select fields`);
const values = field.settings.options.map((option) => option?.value);
if (values.some((value) => value === undefined || value === null || value === '') || new Set(values).size !== values.length) {
throw new Error(`${label}.settings.options must have unique non-empty values`);
}
}
if (['reference', 'subject'].includes(field.type) || field.settings?.subject) {
if (typeof field.settings?.subject !== 'string' || !field.settings.subject.trim()) throw new Error(`${label}.settings.subject is required for a Subject relation`);
}
}
function validatePlan(plan, options) {
if (!plan || plan.version !== 1 || !Array.isArray(plan.operations)) throw new Error('Plan must contain version: 1 and an operations array');
const destructive = new Set(['delete_subject', 'delete_field']);
for (const [index, operation] of plan.operations.entries()) {
const label = `operations[${index}]`;
if (!operation || typeof operation.op !== 'string') throw new Error(`${label}.op is required`);
if (destructive.has(operation.op) && !options.allow_remote_delete) throw new Error(`${label} is destructive; pass --allow-remote-delete explicitly`);
if (operation.op === 'create_subject') {
if (!operation.subject || typeof operation.subject !== 'object') throw new Error(`${label}.subject is required`);
assertAlias(operation.subject.name, `${label}.subject.name`);
if (typeof operation.subject.title !== 'string' || !operation.subject.title.trim()) throw new Error(`${label}.subject.title is required`);
if (typeof operation.subject.type !== 'string' || !operation.subject.type.trim()) throw new Error(`${label}.subject.type is required`);
for (const [fieldIndex, field] of (operation.subject.fields || []).entries()) validateField(field, `${label}.subject.fields[${fieldIndex}]`);
} else if (operation.op === 'update_subject') {
assertAlias(operation.subject, `${label}.subject`);
validatePatch(operation.patch, SUBJECT_PATCH_KEYS, label);
} else if (operation.op === 'create_field') {
assertAlias(operation.subject, `${label}.subject`); validateField(operation.field, `${label}.field`);
} else if (operation.op === 'update_field') {
assertAlias(operation.subject, `${label}.subject`); assertAlias(operation.field, `${label}.field`); validatePatch(operation.patch, FIELD_PATCH_KEYS, label);
} else if (operation.op === 'import_fields') {
assertAlias(operation.subject, `${label}.subject`);
if (!Array.isArray(operation.fields)) throw new Error(`${label}.fields must be an array`);
for (const [fieldIndex, field] of operation.fields.entries()) validateField(field, `${label}.fields[${fieldIndex}]`);
} else if (operation.op === 'reorder_fields') {
assertAlias(operation.subject, `${label}.subject`);
if (!Array.isArray(operation.fields) || operation.fields.some((field) => !ALIAS_RE.test(field))) throw new Error(`${label}.fields must be an alias array`);
} else if (operation.op === 'delete_subject') {
assertAlias(operation.subject, `${label}.subject`);
if (operation.force !== undefined && typeof operation.force !== 'boolean') throw new Error(`${label}.force must be boolean`);
if (operation.confirm !== undefined && operation.confirm !== operation.subject) throw new Error(`${label}.confirm must equal the subject alias`);
} else if (operation.op === 'delete_field') {
assertAlias(operation.subject, `${label}.subject`); assertAlias(operation.field, `${label}.field`);
if (operation.confirm !== undefined && operation.confirm !== operation.field) throw new Error(`${label}.confirm must equal the field alias`);
} else {
throw new Error(`${label}.op ${operation.op} is not supported`);
}
}
}
function validatePatch(patch, allowed, label) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error(`${label}.patch must be an object`);
const invalid = Object.keys(patch).filter((key) => !allowed.has(key));
if (invalid.length) throw new Error(`${label}.patch contains unsupported properties: ${invalid.join(', ')}`);
}
async function applyPlan({ baseUrl, token, plan, options }) {
validatePlan(plan, options);
const creations = plan.operations.filter((operation) => operation.op === 'create_subject');
for (const operation of creations) {
await request(baseUrl, token, '/subject?preview=true', { method: 'POST', body: operation.subject });
}
const results = [];
for (const operation of plan.operations) {
let response;
switch (operation.op) {
case 'create_subject': response = await request(baseUrl, token, '/subject', { method: 'POST', body: operation.subject }); break;
case 'update_subject': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}`, { method: 'PUT', body: operation.patch }); break;
case 'create_field': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field`, { method: 'POST', body: operation.field }); break;
case 'update_field': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field/${encodeURIComponent(operation.field)}`, { method: 'PUT', body: operation.patch }); break;
case 'import_fields': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field/import`, { method: 'PUT', body: { fields: operation.fields } }); break;
case 'reorder_fields': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field/reorder`, { method: 'PUT', body: { fields: operation.fields } }); break;
case 'delete_subject': {
const query = new URLSearchParams();
if (operation.force) query.set('force', 'true');
if (operation.confirm) query.set('confirm', operation.confirm);
const suffix = query.toString() ? `?${query.toString()}` : '';
response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}${suffix}`, { method: 'DELETE' });
break;
}
case 'delete_field': {
const suffix = operation.confirm ? `?confirm=${encodeURIComponent(operation.confirm)}` : '';
response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field/${encodeURIComponent(operation.field)}${suffix}`, { method: 'DELETE' });
break;
}
default: throw new Error(`Unsupported operation ${operation.op}`);
}
results.push({ operation: operation.op, subject: operation.subject, field: operation.field, response });
console.log(`Applied ${operation.op} ${operation.subject}${operation.field ? `.${operation.field}` : ''}`);
}
return results;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const project = path.resolve(options.project || process.cwd());
const baseUrl = resolveBaseUrl(project, options.base_url);
const doc = findProjectDoc(project, options.doc);
if (options.command === 'sync') {
const roots = discoverRoots(project, options.roots);
roots.forEach((root) => assertAlias(root, 'Subject root'));
const ticket = readTicket(options);
const token = await exchangeTicket(baseUrl, ticket);
await synchronize({ project, doc, baseUrl, token, roots, check: options.check });
return;
}
if (!options.confirmed) throw new Error('apply requires --confirmed after an explicit human-readable plan confirmation');
if (!options.plan_file) throw new Error('apply requires --plan-file');
const plan = JSON.parse(fs.readFileSync(path.resolve(options.plan_file), 'utf8'));
validatePlan(plan, options);
const roots = discoverRoots(project, options.roots);
roots.forEach((root) => assertAlias(root, 'Subject root'));
const ticket = readTicket(options);
const token = await exchangeTicket(baseUrl, ticket);
await applyPlan({ baseUrl, token, plan, options });
await synchronize({ project, doc, baseUrl, token, roots, check: false });
}
main().catch((error) => {
console.error(`subject-schema-manager: ${error.message}`);
process.exitCode = 1;
});
...@@ -21,12 +21,13 @@ ...@@ -21,12 +21,13 @@
- **常量**:使用全大写下划线 (SNAKE_CASE)。 - **常量**:使用全大写下划线 (SNAKE_CASE)。
- **技术栈偏好**: - **技术栈偏好**:
- 优先使用 React Hooks 和函数组件。 - 优先使用 React Hooks 和函数组件。
- 样式处理:**优先使用 Tailwind Plus / Catalyst UI Kit(见 `src/catalyst-ui-kit`)+ Tailwind CSS**。 - 样式处理:**优先使用 HeroUI / HeroUI Pro 官方组件与 compound API**,结合项目现有 Tailwind CSS 和主题 Token。
- 交互组件:**优先使用 Headless UI**(已在 Catalyst 组件内封装,除非缺组件才直接用 `@headlessui/react`)。 - 交互组件:**优先使用 HeroUI / HeroUI Pro**;弹窗、下拉、选择器等交互使用其官方可访问性实现,禁止直接引入 Headless UI。
- 组件选择优先级(从高到低): - 组件选择优先级(从高到低):
1. `src/catalyst-ui-kit/javascript/*`(Button/Input/Dialog/Table/Combobox/SidebarLayout/...) 1. `@heroui/react`(Button/Input/Autocomplete/DatePicker/Checkbox/Modal/Tabs 等基础组件)
2. 基于 Catalyst 样式规范的自定义组件(仅在 Kit 无现成组件时) 2. `@heroui-pro/react` 或已验证的 Pro 子路径(Sidebar/Navbar/DataGrid/DropZone/ActionBar/EmptyState/KPI 等)
3. 原生 HTML + 自写样式(尽量避免) 3. 基于 HeroUI compound API 的业务无关共享组合(仅在官方组件无法直接满足组合需求时)
4. 原生 HTML + 自写样式(仅在 HeroUI/HeroUI Pro 均无对应能力时,且需保持项目主题和可访问性)
## 4. 接口请求规范 ## 4. 接口请求规范
...@@ -39,12 +40,12 @@ ...@@ -39,12 +40,12 @@
- 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。 - 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。
- 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。 - 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。
## 6. UI 规范(Tailwind Plus / Headless UI 强制约束) ## 6. UI 规范(HeroUI / HeroUI Pro 强制约束)
- **全项目 UI 统一**:默认使用 `src/catalyst-ui-kit` 中的 Catalyst 组件与样式体系,避免引入其它 UI 框架。 - **全项目 UI 统一**:默认使用 `@heroui/react` 与 `@heroui-pro/react` 的官方组件和样式体系,禁止使用 `src/catalyst-ui-kit` 或引入其它 UI 框架。
- **Headless UI 优先**:弹窗/下拉/选择器等交互优先用 Headless UI(直接或通过 Catalyst 组件)。 - **官方交互优先**:弹窗、下拉、选择器、侧栏等交互必须使用 HeroUI/HeroUI Pro 官方 compound API,保留 focus trap、Escape 关闭和键盘导航等可访问性行为。
- **一致性**:表单控件优先用 Catalyst 的 `Input/Select/Checkbox/Textarea/Fieldset`,表格用 `Table`,对话框用 `Dialog`。 - **一致性**:基础表单控件使用 HeroUI `Input/Autocomplete/DatePicker/Checkbox/Textarea`,表格使用 HeroUI Pro `DataGrid`,对话框使用 HeroUI `Modal` / `AlertDialog`,不得以旧组件或自制包装层替代。
- **可访问性**:移动端侧栏/弹层必须使用 Headless UI 的 Dialog 等可访问性组件(focus trap / esc 关闭等)。 - **事件与组合约定**:按钮和交互控件使用 `onPress`;复合组件使用官方点号结构(例如 `Modal.Root`、`Tabs.List`),不得复刻旧组件 props、DOM event 或样式钩子。
# AI 开发执行规范 # AI 开发执行规范
...@@ -54,12 +55,12 @@ ...@@ -54,12 +55,12 @@
| 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 | | 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 |
| :-- | :-- | :-- | | :-- | :-- | :-- |
| **文本 (String)** | Catalyst `Input` | 标准文本输入 | | **文本 (String)** | HeroUI `Input` | 标准文本输入 |
| **引用 (Reference)** | `ReferenceSearchSelect`(Headless UI Combobox) | 必须通过 API 获取列表,选中后仅存储 `_id` | | **引用 (Reference)** | `ReferenceSearchSelect`(HeroUI `Autocomplete`) | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | Catalyst `Input`(`type="number"`) | 仅允许输入数字 | | **数字 (Number)** | HeroUI `Input`(`type="number"`) | 仅允许输入数字 |
| **日期 (Date)** | Catalyst `Input`(`type="date"` / `datetime-local`) | 提交时格式化为 `ISO 8601` 字符串 | | **日期 (Date)** | HeroUI `DatePicker` / `DateField` | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | Catalyst `Checkbox` | 映射为 true/false | | **布尔 (Boolean)** | HeroUI `Checkbox` | 映射为 true/false |
| **JSON** | Catalyst `Textarea` | 需要包含 JSON 校验逻辑 | | **JSON** | HeroUI `Textarea` | 需要包含 JSON 校验逻辑 |
| **子对象 (Subject)** | **动态表单递归** | **核心逻辑:** 必须先调用 `loadSubject(subjectName)` 接口读取该子对象的字段定义,然后递归应用本映射表生成子表单。 | | **子对象 (Subject)** | **动态表单递归** | **核心逻辑:** 必须先调用 `loadSubject(subjectName)` 接口读取该子对象的字段定义,然后递归应用本映射表生成子表单。 |
--- ---
...@@ -81,7 +82,7 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守: ...@@ -81,7 +82,7 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守:
1. **Step 1**: 查找 `subjectsDefinition.md` 获取该 Subject 的基础字段。 1. **Step 1**: 查找 `subjectsDefinition.md` 获取该 Subject 的基础字段。
2. **Step 2**: 检查字段类型。如果遇到 `Subject` 类型,**自动生成**一段调用 `loadSubject` 的代码以获取深层结构。 2. **Step 2**: 检查字段类型。如果遇到 `Subject` 类型,**自动生成**一段调用 `loadSubject` 的代码以获取深层结构。
3. **Step 3**: 参照上表(第 1 节)选择 tailwind (或你指定的 UI 库) 的组件。 3. **Step 3**: 参照上表(第 1 节)选择 HeroUI / HeroUI Pro 组件,并遵守官方 compound API。
4. **Step 4**: 自动生成 `onChange` 处理函数,确保数据实时同步到 `metadata` 对象的对应路径下。 4. **Step 4**: 自动生成 `onChange` 处理函数,确保数据实时同步到 `metadata` 对象的对应路径下。
--- ---
...@@ -106,6 +107,12 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守: ...@@ -106,6 +107,12 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守:
- 项目管理模块 - 请阅读 `/src/pages/project_manager_xy/project-management.business-flow.md` - 项目管理模块 - 请阅读 `/src/pages/project_manager_xy/project-management.business-flow.md`
## Subject Schema Skill
- 项目内置 Skill 位于 `.agents/skills/subject-schema-manager/`,用于同步 `subjectsDefinition.md` 及执行已确认的 Subject 对象、字段和配置计划。
- 需要同步文档时运行 `node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs sync --project "$PWD" --ticket-stdin`。
- 需要修改线上配置时,必须先展示并确认 JSON 计划,再使用 `apply --plan-file ... --confirmed`;ticket 只允许通过 `SUBJECT_TICKET` 或标准输入提供,不得写入仓库。
## 获取当前登录用户信息 ## 获取当前登录用户信息
可使用 src/hooks/user.jsx 中的 hook useUserInfo 数据结构如下 { "username": "yang_admin", "password_changed": true, "token_expires_at": null, "\_id": "69b12a47e92b1de77c94a9f5", "display_name": "杨康", "type": "admin", "avatar": null, "profile": {}, "groups": [ { "\_id": "649e5a55ad1c1038911baabd", "name": "default", "display_name": "默认用户组", "users": [ { "_id": "649e5a55ad1c1038911baac0", "username": "system", "display_name": "系统" } ], "settings": {} } ], "applications": [ { "name": "fund_audit", "title": "数智报告平台", "groups": [ "default" ], "roles": [ "成员" ], "permissions": [] }, { "name": "aml_tables", "title": "反洗钱数据表", "groups": [ "default" ], "roles": [ "管理" ], "permissions": [] } ] } 可使用 src/hooks/user.jsx 中的 hook useUserInfo 数据结构如下 { "username": "yang_admin", "password_changed": true, "token_expires_at": null, "\_id": "69b12a47e92b1de77c94a9f5", "display_name": "杨康", "type": "admin", "avatar": null, "profile": {}, "groups": [ { "\_id": "649e5a55ad1c1038911baabd", "name": "default", "display_name": "默认用户组", "users": [ { "_id": "649e5a55ad1c1038911baac0", "username": "system", "display_name": "系统" } ], "settings": {} } ], "applications": [ { "name": "fund_audit", "title": "数智报告平台", "groups": [ "default" ], "roles": [ "成员" ], "permissions": [] }, { "name": "aml_tables", "title": "反洗钱数据表", "groups": [ "default" ], "roles": [ "管理" ], "permissions": [] } ] }
# Streams 对象、字段与配置 API
本文档描述 Streams 中对象(Subject)、字段(Field)、视图(View)和对象配置包的 HTTP API,并补充通过登录票据换取 API Token 的调用方式。
## 1. 基本约定
- Base URL 由部署环境决定,本文档中的路径均不包含域名。
- 请求和响应默认使用 `application/json`。服务端 JSON 请求体上限为 100 MB。
- 本文档涉及的对象、字段、视图和配置迁移接口,除 `GET /subject/field/type` 外都经过认证中间件,需要有效登录。
- Token 可放在请求头 `Authorization: Bearer <token>` 中,也可以使用服务端写入的 `streams_auth_token` 签名 Cookie。
- 对象和字段的稳定标识使用别名(`name`),不是 MongoDB 的 `_id`。
- 成功响应通常直接返回对象、字段数组或操作结果;错误响应统一包含 `code`、`status`、`message`,部分错误还包含 `details`。
对象详情、对象更新和配置导入还会执行对象级访问检查;外部对象的字段结构变更会额外检查 `write` 权限和托管表所有权。字段/视图接口的路由要求登录,调用方仍应在网关或应用权限层限制可写对象范围。
示例请求头:
```http
Authorization: Bearer eyJ...
Content-Type: application/json
```
## 2. 认证:通过 ticket 换 token
### 2.1 为用户生成登录票据
```http
PUT /user/{username}/ticket
Authorization: Bearer <管理员Token>
```
请求体为空。该接口位于用户管理的管理员权限范围内,调用方需要登录并具备管理员权限。
成功响应:
```json
{
"ticket": "3c1c8a..."
}
```
每次调用都会覆盖该用户之前的 ticket。ticket 是登录凭证,应通过 HTTPS 传输并避免写入日志。
### 2.2 使用 ticket 换取 API token
```http
POST /auth/login
Content-Type: application/json
```
请求体:
```json
{
"type": "ticket",
"ticket": "3c1c8a..."
}
```
成功响应:
```json
{
"username": "alice",
"password_changed": true,
"token": "a7d2...",
"token_expires_at": null
}
```
ticket 登录不会设置 `streams_auth_token` Cookie,客户端应保存响应中的 `token`,并在后续请求中发送 `Authorization: Bearer <token>`。ticket 登录得到的 token 当前不会设置过期时间(响应中的 `token_expires_at` 为 `null`);实际部署仍应按安全策略定期重新生成 ticket。
常见错误:
| HTTP 状态 | `message` | 说明 |
| --- | --- | --- |
| 400/500 | `ticket is required` | 未提供 `ticket` |
| 400/500 | `没有找到用户` | ticket 无效或用户不存在 |
| 400/500 | `用户已被禁用` | 用户状态不是 `active` |
错误状态码取决于 Koa 错误是否带有显式状态;生产环境不应依赖 `message` 以外的内部堆栈信息。
## 3. 字段类型
### 3.1 查询支持的字段类型
```http
GET /subject/field/type
```
成功响应是字符串数组:
```json
[
"text", "long_text", "boolean", "number", "select", "date",
"rich_text", "formula", "object", "file", "action", "application",
"subject", "reference", "rule", "view", "template", "task", "tag",
"category", "document", "user", "group", "form"
]
```
类型按用途可以分为:
| 类型 | 用途和说明 |
| --- | --- |
| `text` | 单行文本 |
| `long_text` | 长文本 |
| `rich_text` | 富文本 |
| `boolean` | 布尔值 |
| `number` | 数字 |
| `date` | 日期或时间 |
| `select` | 单选或多选;选项放在 `settings.options` |
| `formula` | 公式计算字段;公式放在 `settings.formula`,不保存为外部表列 |
| `object` | JSON 对象 |
| `file` | 文件值或文件列表 |
| `form` | 表单关联值 |
| `subject` | 嵌入对象/对象值 |
| `reference` | 引用另一个对象;目标对象放在 `settings.subject` |
| `application`、`rule`、`view`、`template`、`task` | 对应平台对象关联 |
| `tag`、`category` | 标签或分类关联 |
| `document` | 文档关联 |
| `user`、`group` | 用户或用户组关联 |
| `action` | 动作字段;不保存为外部表列 |
服务端对 `settings` 不做统一的键级校验,具体设置由字段类型和前端约定决定。`select`、`reference`/`subject` 是配置迁移预检时会额外校验的类型。
### 3.2 字段对象结构
字段可用于创建、更新和对象配置包:
| 属性 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `name` | string | 创建时是 | 自动生成 | 2~128 个字符;普通对象允许 `A-Z a-z 0-9 _ - .`,外部对象字段名还必须符合 SQL 标识符规则 |
| `label` | string | 是 | 无 | 展示名称,最长 128 个字符 |
| `description` | string | 否 | `""` | 最长 512 个字符 |
| `default` | any | 否 | `null` | 新记录或结构变更回填时使用的默认值 |
| `multiple` | boolean | 否 | `false` | 是否允许数组值 |
| `required` | boolean | 否 | `false` | 是否要求有值 |
| `primary` | boolean | 否 | `false` | 是否作为主字段;外部对象主字段必须是必填、单值且可索引类型 |
| `type` | string | 是 | `text` | 必须是 `/subject/field/type` 返回的类型 |
| `search` | object | 否 | `{ "enabled": false }` | 当前支持 `enabled` |
| `aggregation` | object | 否 | `{ "enabled": false }` | 支持 `enabled` 和父级字段名 `parent` |
| `settings` | object | 否 | `{}` | 类型专用配置,见下表 |
常用 `settings`:
```json
{
"options": [
{"label": "启用", "value": "enabled"},
{"label": "停用", "value": "disabled"}
]
}
```
| 字段类型 | 必要或常用设置 |
| --- | --- |
| `select` | `options` 必须是数组;每个选项必须有非空且唯一的 `value`,可选 `label` |
| `reference`、`subject` | `subject` 为目标对象别名,例如 `{ "subject": "customers" }` |
| `formula` | `formula` 为公式表达式 |
| 其他类型 | 使用 `settings` 扩展类型特定配置;未识别键会原样保存 |
## 4. 对象 API
### 4.1 对象结构
```json
{
"name": "orders",
"title": "订单",
"description": "订单主数据",
"type": "normal",
"order": 0,
"access": "public",
"history": true,
"templates": {
"title": "{{name}}",
"name": "order",
"content": ""
},
"settings": {},
"search": {
"enabled": true,
"title": {"enabled": true, "vector": false},
"name": {"enabled": true, "vector": false},
"content": {"enabled": true, "vector": false},
"vector": {"enabled": false, "embedding_model": ""}
},
"fields": [],
"views": []
}
```
对象属性:
| 属性 | 可选值/限制 | 说明 |
| --- | --- | --- |
| `name` | 2~128 个字符,`^[\w-]*$` | 全局唯一别名;创建后不支持通过更新接口改名 |
| `title` | 最长 128 个字符 | 展示名称 |
| `description` | 最长 512 个字符 | 描述 |
| `type` | `normal`、`embedded`、`external` | 对象类型;已存在对象不能切换类型 |
| `order` | number | 列表排序值 |
| `access` | `public`、`protected`、`private` | 访问级别;`protected` 结合 `members` 权限 |
| `history` | boolean | 是否记录历史;外部对象始终不启用 |
| `templates` | object | 标题、名称和内容模板 |
| `settings` | object | 对象级扩展配置 |
| `search` | object | 搜索和向量索引配置;更新时按对象合并 |
| `fields` | Field[] | 字段列表,字段名必须唯一 |
| `views` | View[] | 视图列表,视图名必须唯一 |
### 4.2 查询对象
查询全部可见对象:
```http
GET /subject
```
分页、筛选和排序查询:
```http
POST /subject/query
Content-Type: application/json
```
请求体示例:
```json
{
"page": 0,
"page_size": 20,
"status": "active",
"type": ["normal", "external"],
"keyword": "订单",
"tags": "tag_name",
"sort": "-order -created_at _id",
"filter": {}
}
```
响应:
```json
{
"current_page": 0,
"page_size": 20,
"total_pages": 1,
"total_subjects": 1,
"subjects": []
}
```
`page` 从 0 开始,`page_size` 默认 20,最大 1000。未指定 `type` 时默认查询 `normal` 和 `external`,不包含 `embedded`。
获取单个对象:
```http
GET /subject/{subject}
```
`{subject}` 可以是对象别名或 MongoDB ObjectId。对象不存在时返回 404;无访问权限时返回 403。
### 4.3 创建对象
```http
POST /subject
POST /subject?preview=true
Content-Type: application/json
```
普通对象示例:
```json
{
"name": "orders",
"title": "订单",
"type": "normal",
"access": "public",
"history": true,
"fields": [
{"name": "order_no", "label": "订单号", "type": "text", "primary": true, "required": true},
{"name": "status", "label": "状态", "type": "select", "settings": {"options": [
{"label": "待支付", "value": "pending"},
{"label": "已完成", "value": "completed"}
]}}
],
"views": []
}
```
`preview=true` 只执行模型构造和校验,不写入数据库,也不创建外部表。
外部对象示例:
```json
{
"name": "external_orders",
"title": "外部订单",
"type": "external",
"external": {"connector": "business_dm"},
"fields": [
{"name": "order_no", "label": "订单号", "type": "text", "primary": true, "required": true}
]
}
```
外部对象创建要求目标 connector 存在且是受支持的 SQL 数据源;服务端按 `dm_<name>` 生成表名并一次性建表。配置包导入还会额外要求 connector 处于启用状态。请求中的外部 `table`、`state`、`schema_version` 等运行时字段不会由客户端决定。
### 4.4 更新对象配置
```http
PUT /subject/{subject}
Content-Type: application/json
```
请求体只需要包含要变更的属性:
```json
{
"title": "销售订单",
"description": "更新后的描述",
"order": 10,
"access": "protected",
"templates": {"title": "{{order_no}}"},
"settings": {"layout": "wide"},
"search": {
"enabled": true,
"content": {"enabled": true, "vector": true}
},
"history": false,
"tags": [],
"categories": [],
"members": []
}
```
可更新属性为 `title`、`description`、`order`、`tags`、`categories`、`templates`、`settings`、`access`、`members`、`search` 和(非外部对象的)`history`。`name`、`type`、`status`、`external` 不能通过此接口修改。`search` 是深度合并,`settings`、`templates`、`members` 等对象属性按请求值替换。
### 4.5 删除、恢复对象
软删除(进入回收站):
```http
DELETE /subject/{subject}
```
恢复:
```http
PUT /subject/{subject}/restore
```
永久删除已在回收站中的对象:
```http
DELETE /subject/{subject}?force=true
```
外部对象永久删除还必须提供确认参数,值为删除前对象别名的前缀:
```http
DELETE /subject/external_orders?force=true&confirm=external_orders
```
外部对象的软删除、恢复和永久删除会同步检查托管表所有权;永久删除会删除由 Streams 托管的物理表。
## 5. 字段 API
以下 `{subject}` 是对象别名或 ObjectId,`{field}` 是字段别名。
### 5.1 查询字段
```http
GET /subject/{subject}/field
GET /subject/{subject}/field/{field}
```
第一个接口返回字段数组,第二个接口返回单个字段。字段不存在时,单字段接口返回空结果或 404,客户端应同时检查响应状态和内容。
### 5.2 创建字段
```http
POST /subject/{subject}/field
Content-Type: application/json
```
请求体示例:
```json
{
"name": "customer",
"label": "客户",
"description": "下单客户",
"type": "reference",
"required": false,
"multiple": false,
"search": {"enabled": true},
"settings": {"subject": "customers"}
}
```
普通对象会将字段追加到 `fields` 末尾并保存整个对象。字段 `name` 必须唯一。外部对象会创建一条外部 schema change,并同步执行 SQL 列变更;外部对象必须处于 `external.state=ready`,且当前用户拥有该托管表。
成功响应为更新后的完整对象(包含 `fields` 和 `views`),不是单独的字段值。
### 5.3 更新字段
```http
PUT /subject/{subject}/field/{field}
Content-Type: application/json
```
请求体示例:
```json
{
"label": "客户名称",
"description": "更新后的描述",
"required": true,
"search": {"enabled": true},
"aggregation": {"enabled": true, "parent": "customer_group"},
"settings": {"subject": "customers"}
}
```
普通对象支持更新 `type`、`label`、`description`、`default`、`primary`、`required`、`multiple`、`search`、`aggregation` 和 `settings`;字段别名由路径参数确定,不支持改名。未提供的属性保持原值。
成功响应为更新后的完整对象。
当前实现仅在 `default != null` 时写入 `default`,因此不能用 `{"default": null}` 清除已有默认值;需要清除时应通过完整字段导入或直接调整数据模型后再调用接口。
外部对象的更新会经过 schema change 流程。字段类型变更只有在底层存储兼容时才允许;将已有字段改为 `required` 时,如果已有记录为空,必须同时提供可用于回填的 `default`。
### 5.4 批量导入或更新字段
```http
PUT /subject/{subject}/field/import
Content-Type: application/json
```
请求体:
```json
{
"fields": [
{"name": "order_no", "label": "订单号", "type": "text", "required": true},
{"name": "amount", "label": "金额", "type": "number", "settings": {"unit": "CNY"}}
]
}
```
普通对象中,同名字段会按请求字段覆盖属性,新字段只有在包含 `label` 时才会追加;目标环境中只存在的字段会保留。外部对象同样走单条 schema change,成功后响应为对象字段数组。
### 5.5 调整字段顺序
```http
PUT /subject/{subject}/field/reorder
Content-Type: application/json
```
请求体必须提供字段别名数组:
```json
{
"fields": ["order_no", "customer", "status", "amount"]
}
```
数组应包含对象当前的全部字段,并按期望顺序排列。目标环境中未出现在数组里的字段会被排到最前面,因此不要提交不完整列表。外部对象在有未完成 schema change 时会拒绝排序。
成功响应为排序后的字段数组。
### 5.6 删除字段
普通对象:
```http
DELETE /subject/{subject}/field/{field}
```
外部对象删除已持久化字段会删除外部数据库列及其中数据,必须在查询参数中原样确认字段名:
```http
DELETE /subject/external_orders/field/amount?confirm=amount
```
不提供正确确认值时返回 `EXTERNAL_FIELD_DELETE_CONFIRMATION_REQUIRED`,不会执行删除。非持久化字段(`formula`、`action`)不需要删除外部列确认。
成功响应为更新后的完整对象。
删除影响预览:
```http
GET /subject/{subject}/field/{field}/removal-preview
```
示例响应:
```json
{
"field": "amount",
"persisted": true,
"total_records": 1200,
"affected_records": 860
}
```
该预览接口只对外部对象开放,`affected_records` 表示字段非空的记录数。
## 6. 视图配置 API
视图结构:
```json
{
"name": "recent",
"title": "最近订单",
"description": "按创建时间倒序",
"type": "table",
"settings": {
"filter": {"status": "pending"},
"sort": ["-created_at"]
}
}
```
`name` 需为 2~128 个字符且在对象内唯一,`title` 最长 128 个字符,`description` 最长 512 个字符。`settings` 的具体键由视图类型解释。
接口:
| 方法 | 路径 | 请求体 | 说明 |
| --- | --- | --- | --- |
| GET | `/subject/{subject}/view` | 无 | 返回视图数组 |
| GET | `/subject/{subject}/view/{view}` | 无 | 返回单个视图 |
| POST | `/subject/{subject}/view` | 完整或部分 View,创建时需 `name`、`title` | 创建视图并追加到末尾 |
| PUT | `/subject/{subject}/view/{view}` | `title`、`description`、`type`、`settings` | 更新指定视图;`name` 不可改 |
| PUT | `/subject/{subject}/view/reorder` | `{ "views": ["recent", "all"] }` | 调整顺序 |
| DELETE | `/subject/{subject}/view/{view}` | 无 | 删除视图 |
视图创建、更新和删除成功时返回更新后的完整对象;排序接口返回排序后的视图数组。
示例:
```http
POST /subject/orders/view
Content-Type: application/json
{"name":"pending","title":"待支付","type":"table","settings":{"filter":{"status":"pending"}}}
```
## 7. 外部对象专用 API
外部对象使用 connector 连接 SQL 数据源,并由 Streams 托管物理表。配置接口只接受稳定的 connector 别名,不接受主机、账号、密码等凭据。
### 7.1 重试建表
```http
POST /subject/{subject}/external/retry
```
用于外部对象初次建表失败后的重试。成功后 `external.state` 变为 `ready`;失败时变为 `error` 并保留错误信息。
### 7.2 查询和重试字段结构变更
```http
GET /subject/{subject}/external/schema-change
POST /subject/{subject}/external/schema-change/retry
```
查询响应:
```json
{
"current": null,
"changes": []
}
```
字段新增、更新、批量导入和删除都会产生 schema change 记录。失败且仍处于可重试状态时调用 retry;存在未完成变更时,不能并发提交另一条字段结构变更。
### 7.3 创建外部副本和恢复内部对象
从普通对象创建外部副本:
```http
POST /subject/{subject}/external/convert
Content-Type: application/json
{"connector":"business_dm","table":"dm_orders"}
```
也可以省略 `table`,服务端使用默认表名。只有 `normal` 对象支持转换;目标表必须是可验证的 Streams 托管表,已有任意物理表不会被自动接管或覆盖。
恢复为普通对象:
```http
POST /subject/{subject}/external/restore
```
该操作只解除对象与外部表的绑定,不自动删除外部表;服务端会检查表所有权和可恢复状态。
## 8. 对象配置包迁移 API
完整的配置包格式、字段校验、依赖处理、外部 connector 前置条件和限制见 [SUBJECT_CONFIGURATION.md](./SUBJECT_CONFIGURATION.md)。下面列出调用入口。
### 8.1 导出
```http
POST /subject/configuration/export
Content-Type: application/json
```
请求体:
```json
{
"subjects": ["orders"],
"include_dependencies": true
}
```
`include_dependencies` 默认为 `true`。字段类型为 `reference` 或 `subject` 且设置了 `settings.subject` 时,被引用的对象会自动加入配置包。调用方必须拥有选中对象的读取权限。
### 8.2 预检
```http
POST /subject/configuration/import/preview
Content-Type: application/json
```
请求体可以直接传配置包,也可以包在 `package` 属性中:
```json
{
"mode": "upsert",
"package": {
"kind": "streams.subject-configuration",
"version": 1,
"subjects": []
}
}
```
预检响应包含 `can_import`、`errors`、`warnings`、`summary` 和逐对象的创建/更新差异:
```json
{
"kind": "streams.subject-configuration",
"version": 1,
"mode": "upsert",
"can_import": true,
"errors": [],
"warnings": [],
"summary": {
"subjects_total": 1,
"subjects_create": 1,
"subjects_update": 0,
"subjects_unchanged": 0,
"subjects_error": 0,
"fields_create": 2,
"fields_update": 0,
"fields_reorder": 0,
"views_create": 0,
"views_update": 0,
"views_reorder": 0
},
"subjects": []
}
```
`mode` 当前只支持 `upsert`。预检会检查对象和字段别名、字段类型、重复选择项、引用目标、字段类型冲突、权限以及外部 connector/物理表可用性。
### 8.3 导入
```http
POST /subject/configuration/import
Content-Type: application/json
```
请求体与预检相同。服务端会再次执行预检,只有 `can_import=true` 才会写入;预检失败返回 `SUBJECT_CONFIGURATION_PRECHECK_FAILED`,错误详情中包含完整预检报告。
导入是 upsert:
1. 创建普通对象空壳,使引用对象名先存在。
2. 创建新的外部对象,并使用完整字段定义只建表一次。
3. 更新对象属性。
4. 导入普通字段,再导入引用字段。
5. 按配置包顺序重排字段。
6. 创建或更新视图并重排视图。
配置包中没有的目标字段和视图会保留,不执行删除。记录、成员、标签、分类、connector 配置和凭据都不包含在配置包中。
## 9. 常见错误码
| 错误码 | 适用场景 |
| --- | --- |
| `SUBJECT_NOT_FOUND` / `Subject not found` | 对象不存在 |
| `Field not found` / `EXTERNAL_FIELD_NOT_FOUND` | 字段不存在 |
| `view not found` | 视图不存在 |
| `Forbidden` | 未通过对象或管理员权限检查 |
| `Unauthorized` | 未提供有效认证信息 |
| `INVALID_FIELDS` | 批量字段请求缺少数组 `fields` |
| `INVALID_FIELD_TYPE` | 字段类型不在支持列表 |
| `DUPLICATE_FIELD` / `EXTERNAL_FIELD_DUPLICATE` | 字段别名重复 |
| `INVALID_SELECT_OPTIONS` / `DUPLICATE_SELECT_OPTION` | 选择字段选项缺失或值重复 |
| `MISSING_REFERENCE_TARGET` | 引用字段缺少 `settings.subject` |
| `EXTERNAL_CONNECTOR_NOT_FOUND` | 外部 connector 不存在 |
| `EXTERNAL_CONNECTOR_INACTIVE` | 外部 connector 未启用 |
| `EXTERNAL_CONNECTOR_UNSUPPORTED` | connector 不是受支持的 SQL 数据源 |
| `EXTERNAL_TABLE_EXISTS` | 目标物理表已存在,创建不会覆盖或接管 |
| `EXTERNAL_SUBJECT_NOT_READY` | 外部对象仍在建表或处于错误状态 |
| `EXTERNAL_SCHEMA_CHANGE_IN_PROGRESS` | 已有字段结构变更未完成 |
| `EXTERNAL_SCHEMA_CHANGE_UNSUPPORTED` | 底层 SQL 列类型不兼容 |
| `EXTERNAL_REQUIRED_FIELD_DEFAULT_REQUIRED` | 给已有记录增加必填字段时缺少回填默认值 |
| `EXTERNAL_FIELD_DELETE_CONFIRMATION_REQUIRED` | 删除外部持久化字段未确认字段名 |
| `SUBJECT_CONFIGURATION_PRECHECK_FAILED` | 配置包预检未通过 |
收到外部 schema change 错误时,先调用 `GET /subject/{subject}/external/schema-change` 查看 `current` 和 `changes`,确认没有并发变更后再重试或修正字段定义。
# Object Configuration Migration
对象、字段、视图和 ticket 换 token 的完整 HTTP API 参考见 [SUBJECT_API.md](./SUBJECT_API.md)。本文保留配置包迁移的详细说明。
The object configuration package moves metadata between Streams environments. It does not move records, database IDs, object members, tags, categories, connector settings, credentials, or existing external database tables.
## Workflow
1. Select one or more normal, embedded, or external objects in Admin > Object Management and choose `Export Configuration`.
2. Referenced objects are included automatically. The selected objects are listed in `selected_subjects`; dependency objects follow them in `subjects`.
3. In the target environment choose `Import Configuration` and select the JSON file.
4. Review the precheck. Only a package with no blocking issues can be applied.
5. The import uses `upsert`: matching objects and fields are updated, new ones are created, and target-only fields and views are preserved.
For an external object, create and enable a DM8 connector in the target environment before import. Its stable `connector.name` must match the alias exported in `external.connector`. A new external object provisions a new managed table using the `dm_<subject.name>` convention; import never adopts, binds to, or overwrites a pre-existing physical table.
## Stable identifiers
The following identifiers are portable business codes and must not be replaced with database IDs:
- object: `subject.name`
- field: `field.name`
- select option: `field.settings.options[].value`
- reference target: `field.settings.subject`
- external connector: `subject.external.connector` (`connector.name`)
For example:
```json
{
"kind": "streams.subject-configuration",
"version": 1,
"selected_subjects": ["orders"],
"include_dependencies": true,
"subjects": [
{
"name": "orders",
"title": "Orders",
"type": "normal",
"fields": [
{
"name": "status",
"label": "Status",
"type": "select",
"settings": {
"options": [
{"label": "Open", "value": "open"}
]
}
},
{
"name": "customer",
"label": "Customer",
"type": "reference",
"settings": {"subject": "customers"}
}
],
"views": []
},
{
"name": "customers",
"title": "Customers",
"type": "normal",
"fields": [],
"views": []
}
]
}
```
An external object only exports its connector alias:
```json
{
"name": "external_orders",
"title": "External Orders",
"type": "external",
"external": {
"connector": "business_dm"
},
"fields": [],
"views": []
}
```
The package never contains the connector host, account, password, settings, runtime state, physical table metadata, or records.
## API
- `POST /subject/configuration/export` with `{subjects: ["orders"], include_dependencies: true}`
- `POST /subject/configuration/import/preview` with `{mode: "upsert", package: {...}}`
- `POST /subject/configuration/import` with `{mode: "upsert", package: {...}}`
The import order is normal object shells, complete new external objects, object properties, normal fields, reference fields, and views. A new external object is created once with its complete field definition so the managed table is provisioned once. This also allows circular references between objects to be imported after referenced object names exist.
External-object precheck blocks import when:
- `external.connector` is missing or invalid;
- the same-alias connector does not exist, is inactive, or is not DM8-compatible;
- the target table for a new external object already exists;
- an existing external object's connector alias differs from the package;
- an existing external object is not in the `ready` state;
- an external field change is not supported by the managed-table schema.
## Deliberate limitations in V1
- Records are never included.
- Connector settings and credentials are never included; target connectors are resolved by alias.
- Existing physical tables are never adopted or overwritten by configuration import.
- Members, tags, and categories are reported as warnings and are not imported.
- Upsert never deletes fields or views that only exist in the target environment.
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment