Commit 89d9efa3 authored by drigle's avatar drigle

docs: sync subject schema and API references

parent c79b1658
---
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 = /^## \d+\.\s*Subject Definitions[^\n]*\n?/m.exec(document);
if (heading) {
const contentStart = heading.index + heading[0].length;
const nextHeading = /^## \d+\.\s+[^\n]*\n?/gm;
nextHeading.lastIndex = contentStart;
const next = nextHeading.exec(document);
const sectionEnd = next ? next.index : document.length;
const migrated = `${document.slice(0, contentStart)}\n${block}\n\n${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;
});
# 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.
allowBuilds:
'@heroui-pro/react': set this to true or false
'@zowe/secrets-for-zowe-sdk': set this to true or false
{ {
"name": "project_manager_xy", "name": "project_manager_xy",
"version": "1.0.3", "version": "1.0.4",
"build": "abc", "build": "abc",
"description": "merge master", "description": "merge master",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
...@@ -82,133 +82,507 @@ type Record = { ...@@ -82,133 +82,507 @@ type Record = {
## 3. Subject Definitions ## 3. Subject Definitions
## 3.1 项目管理 <!-- SUBJECT_SCHEMA_SYNC:START -->
- **subject**: `pm_project_task` > 同步根对象:`pm_project_task`
> 当前依赖闭包:1 个 Subject。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 |
| --- | --- | --- | --- | --- | --- | ### `pm_project_task`:项目管理
| 项目名称 | `project_name` | text | 否 | 否 | 是 |
| 大类 | `big_category` | select | 否 | 否 | 否 | - 类型:`normal`
| 项目 | `project` | text | 否 | 否 | 否 | - 字段数量:20
| 项目负责人 | `project_owner` | user | 否 | 是 | 否 |
| 项目协作人 | `project_collaborator` | user | 否 | 是 | 否 | | 字段标签 | 字段别名 | 字段类型 | 多值 | 必填 | 主键 | 关联/配置 |
| 任务来源 | `task_source` | select | 否 | 否 | 否 | | --- | --- | --- | --- | --- | --- | --- |
| 任务性质 | `task_nature` | select | 否 | 否 | 否 | | 项目名称 | `project_name` | text | 否 | 是 | 否 | |
| 发生频率 | `occurrence_frequency` | select | 否 | 否 | 否 | | 计划开始日期 | `planned_start_date` | date | 否 | 是 | 否 | |
| 计划开始日期 | `planned_start_date` | date | 否 | 否 | 是 | | 计划结束日期 | `planned_end_date` | date | 否 | 是 | 否 | |
| 计划结束日期 | `planned_end_date` | date | 否 | 否 | 是 | | 实际开始日期 | `actual_start_date` | text | 否 | 否 | 否 | |
| 实际开始日期 | `actual_start_date` | text | 否 | 否 | 否 | | 实际完成日期 | `actual_finish_date` | text | 否 | 否 | 否 | |
| 实际完成日期 | `actual_finish_date` | text | 否 | 否 | 否 | | 项目说明 | `description` | text | 否 | 否 | 否 | |
| 项目说明 | `description` | text | 否 | 否 | 否 | | 备注 | `remark` | text | 否 | 否 | 否 | |
| 备注 | `remark` | text | 否 | 否 | 否 | | 优先级 | `priority` | select | 否 | 否 | 否 | options: 3, 2, 1 |
| 优先级 | `priority` | select | 否 | 否 | 否 | | 项目状态 | `project_status` | select | 否 | 是 | 否 | options: draft, in_progress, completed, blocked, cancelled |
| 项目状态 | `project_status` | select | 否 | 否 | 是 | | 完成进度 | `completion_percent` | number | 否 | 否 | 否 | |
| 完成进度 | `completion_percent` | number | 否 | 否 | 否 | | 项目负责人 | `project_owner` | user | 是 | 否 | 否 | |
| 延期原因 | `delay_reason` | text | 否 | 否 | 否 | | 延期原因 | `delay_reason` | text | 否 | 否 | 否 | |
| 周进度记录 | `weekly_updates` | object | 否 | 否 | 否 | | 周进度记录 | `weekly_updates` | object | 否 | 否 | 否 | |
| 是否属于年度审计计划 | `is_annual_audit_plan` | boolean | 否 | 否 | 否 | | 大类 | `big_category` | select | 否 | 否 | 否 | options: 部门管理, 纪检工作, 审计工作 |
| 项目 | `project` | text | 否 | 否 | 否 | |
### 3.1.1 `big_category` 选项 | 项目协作人 | `project_collaborator` | user | 是 | 否 | 否 | |
| 任务来源 | `task_source` | select | 否 | 否 | 否 | options: 管理需要, 监管规定, 质效评估, 总行部署 |
| 显示值 | 存储值 | | 发生频率 | `occurrence_frequency` | select | 否 | 否 | 否 | options: 3, 2, 1 |
| --- | --- | | 任务性质 | `task_nature` | select | 否 | 否 | 否 | options: 事务性, 项目性 |
| 部门管理 | `部门管理` | | 是否属于年度审计计划 | `is_annual_audit_plan` | boolean | 否 | 否 | 否 | |
| 纪检工作 | `纪检工作` |
| 审计工作 | `审计工作` | <details>
<summary>完整对象与字段配置</summary>
### 3.1.2 `task_source` / `task_nature` 配置说明
- `task_source` 与 `task_nature` 为 `select` 类型
- 选项必须由 Subject 字段配置中的 `settings.options` 提供
- 前端表单只读取配置项生成下拉,不在代码中硬编码选项
- Excel 导入时应填写对应选项的存储值
### 3.1.3 `project_status` 选项
| 显示值 | 存储值 |
| --- | --- |
| 草稿 | `draft` |
| 进行中 | `in_progress` |
| 已完成 | `completed` |
| 受阻 | `blocked` |
| 已取消 | `cancelled` |
### 3.1.4 `occurrence_frequency` 选项
| 显示值 | 存储值 |
| --- | --- |
| 高 | `3` |
| 中 | `2` |
| 低 | `1` |
### 3.1.5 `priority` 配置说明
- 当前最新结构中,`priority` 为 `select` 类型
- 但字段定义内暂未提供 `settings.options`
- 前端生成表单时不得硬编码选项,需等对象配置或接口返回的 `settings.options`
### 3.1.6 `weekly_updates` 子结构建议
`weekly_updates` 当前字段类型为 `object`。如业务上继续沿用“周进度记录集合”方案,建议对象内维护 `items` 数组,每项至少包含以下字段:
| 字段标签 | 字段别名 | 字段类型 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- |
| 周开始日期 | `week_start_date` | date | 否 | 本周记录起始日期 |
| 周结束日期 | `week_end_date` | date | 否 | 本周记录结束日期 |
| 本周完成内容 | `weekly_summary` | text | 是 | 本周实际完成事项 |
| 更新后累计进度 | `progress_percent_after_update` | number | 否 | 本次更新后的累计完成进度 |
| 当前阻塞事项 | `blockers` | text | 否 | 当前风险、卡点、等待项 |
| 下周计划 | `next_week_plan` | text | 否 | 下一周推进安排 |
| 更新人 | `updated_by` | user | 否 | 记录本次周进度的人 |
| 更新时间 | `updated_at` | date | 否 | 本次周进度的录入时间 |
前端交互建议:
- 项目基础信息和周进度记录分开维护
- 周进度记录通过“新增本周进度 / 编辑周进度”弹窗录入
- 列表页或项目详情只展示最近一条摘要,不直接暴露底层 JSON
- 若后端最终确认 `weekly_updates` 直接存数组,可再将本节从“对象内 `items` 数组”调整为“字段直接存数组”
### 3.1.7 metadata 示例
```json ```json
{ {
"project_name": "某客户 2026 年专项项目", "access": "public",
"big_category": "审计工作", "categories": [],
"project": "2026 年专项项目", "external": null,
"project_owner": [ "fields": [
"69b12a47e92b1de77c94a9f6" {
], "aggregation": {
"project_collaborator": [ "enabled": false
"69b12a47e92b1de77c94a9f7" },
], "default": null,
"task_source": "客户年度项目", "label": "项目名称",
"task_nature": "常规", "multiple": false,
"occurrence_frequency": "2", "name": "project_name",
"planned_start_date": "2026-05-01T00:00:00.000Z", "primary": false,
"planned_end_date": "2026-06-10T00:00:00.000Z", "required": true,
"actual_start_date": "2026-05-03", "search": {
"actual_finish_date": "", "enabled": false
"description": "项目管理对象最新字段示例。", },
"remark": "结构已按最新字段同步。", "settings": {},
"priority": "", "type": "text"
"project_status": "in_progress", },
"completion_percent": 60, {
"delay_reason": "", "aggregation": {
"weekly_updates": { "enabled": false
"items": [ },
{ "default": null,
"week_start_date": "2026-05-04T00:00:00.000Z", "label": "计划开始日期",
"week_end_date": "2026-05-10T00:00:00.000Z", "multiple": false,
"weekly_summary": "完成项目启动会和资料清单确认。", "name": "planned_start_date",
"progress_percent_after_update": 20, "primary": false,
"blockers": "", "required": true,
"next_week_plan": "推进底稿准备并和客户确认时间表。", "search": {
"updated_by": "69b12a47e92b1de77c94a9f6", "enabled": false
"updated_at": "2026-05-10T00:00:00.000Z" },
"settings": {},
"type": "date"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "计划结束日期",
"multiple": false,
"name": "planned_end_date",
"primary": false,
"required": true,
"search": {
"enabled": false
},
"settings": {},
"type": "date"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "实际开始日期",
"multiple": false,
"name": "actual_start_date",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "text"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "实际完成日期",
"multiple": false,
"name": "actual_finish_date",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "text"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "项目说明",
"multiple": false,
"name": "description",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "text"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "备注",
"multiple": false,
"name": "remark",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "text"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "优先级",
"multiple": false,
"name": "priority",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {
"options": [
{
"label": "高",
"value": "3"
},
{
"label": "中",
"value": "2"
},
{
"label": "低",
"value": "1"
}
]
},
"type": "select"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "项目状态",
"multiple": false,
"name": "project_status",
"primary": false,
"required": true,
"search": {
"enabled": false
},
"settings": {
"options": [
{
"label": "草稿",
"value": "draft"
},
{
"label": "进行中",
"value": "in_progress"
},
{
"label": "已完成",
"value": "completed"
},
{
"label": "受阻",
"value": "blocked"
},
{
"label": "已取消",
"value": "cancelled"
}
]
},
"type": "select"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "完成进度",
"multiple": false,
"name": "completion_percent",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "number"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "项目负责人",
"multiple": true,
"name": "project_owner",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "user"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "延期原因",
"multiple": false,
"name": "delay_reason",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "text"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "周进度记录",
"multiple": false,
"name": "weekly_updates",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "object"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "大类",
"multiple": false,
"name": "big_category",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {
"options": [
{
"label": "部门管理",
"value": "部门管理"
},
{
"label": "纪检工作",
"value": "纪检工作"
},
{
"label": "审计工作",
"value": "审计工作"
}
]
},
"type": "select"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "项目",
"multiple": false,
"name": "project",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "text"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "项目协作人",
"multiple": true,
"name": "project_collaborator",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "user"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "任务来源",
"multiple": false,
"name": "task_source",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {
"options": [
{
"label": "管理需要",
"value": "管理需要"
},
{
"label": "监管规定",
"value": "监管规定"
},
{
"label": "质效评估",
"value": "质效评估"
},
{
"label": "总行部署",
"value": "总行部署"
} }
] ]
},
"type": "select"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "发生频率",
"multiple": false,
"name": "occurrence_frequency",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {
"options": [
{
"label": "高",
"value": "3"
},
{
"label": "中",
"value": "2"
},
{
"label": "低",
"value": "1"
} }
]
},
"type": "select"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "任务性质",
"multiple": false,
"name": "task_nature",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {
"options": [
{
"label": "事务性",
"value": "事务性"
},
{
"label": "项目性",
"value": "项目性"
}
]
},
"type": "select"
},
{
"aggregation": {
"enabled": false
},
"default": null,
"label": "是否属于年度审计计划",
"multiple": false,
"name": "is_annual_audit_plan",
"primary": false,
"required": false,
"search": {
"enabled": false
},
"settings": {},
"type": "boolean"
}
],
"history": false,
"members": [],
"name": "pm_project_task",
"order": 0,
"search": {
"content": {
"enabled": true,
"vector": false
},
"enabled": false,
"name": {
"enabled": true,
"vector": false
},
"title": {
"enabled": true,
"vector": false
},
"vector": {
"enabled": false
}
},
"settings": {},
"status": "active",
"tags": [],
"title": "项目管理",
"type": "normal",
"updated_by": {
"display_name": "david.test",
"username": "david.test"
},
"views": []
} }
``` ```
</details>
### 关联对象与子对象定义
依赖字段通过 `settings.subject` 指向的对象会递归纳入本节;`form` 资源不属于 Subject 依赖。
<!-- SUBJECT_SCHEMA_SYNC:END -->
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