forked from ChaiHao/BPOS-MVP
UI②:数据对象全用中文名 —— label 随物化落 PG 注释,全链读回
此前 label 在建表那一步就丢了,治理台只剩 project__intake_record 这种键给人看。 用表/列注释而非另建元数据表:名字与表同生共死,不会对不上。 设计台/运营台/对象详情/触发器下拉一律中文名在前、键在后。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -441,7 +441,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
// 记录浏览只读正式版 —— 跨任务只见过门的事实
|
||||
const page = await container.ledger.query({ address, where: {}, orderBy: [], limit: 50, offset: 0 });
|
||||
return c.json({
|
||||
columns: described.columns.map((col) => ({ name: col.name, type: col.type })),
|
||||
...(described.label === undefined ? {} : { label: described.label }),
|
||||
columns: described.columns.map((col) => ({
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
...(col.label === undefined ? {} : { label: col.label }),
|
||||
})),
|
||||
primaryKey: [...described.primaryKey],
|
||||
rows: page.rows,
|
||||
total: page.total,
|
||||
|
||||
@@ -35,3 +35,10 @@
|
||||
标识符过 `IDENT` 正则 + `quoteIdent`
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
|
||||
近变(2026-07-28):**人读名落库** —— 物化时把声明里的 `label` 写成 PG 的表/列注释,
|
||||
`describe` 与 `listObjects` 一并读回(`MaterializedObject.label` /
|
||||
`MaterializedColumn.label` / `listObjects()` 出参加 `label?`)。
|
||||
此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。
|
||||
用注释而非另建元数据表:名字与表同生共死,不会对不上。
|
||||
`comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。
|
||||
|
||||
@@ -91,7 +91,8 @@ export type ReleaseWriteResult = {
|
||||
export type LedgerService = {
|
||||
applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>;
|
||||
describe(address: ObjectAddress): Promise<MaterializedObject | undefined>;
|
||||
listObjects(): Promise<ObjectAddress[]>;
|
||||
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
||||
listObjects(): Promise<Array<ObjectAddress & { label?: string | undefined }>>;
|
||||
|
||||
systemWrite(write: SystemWrite): Promise<void>;
|
||||
draftWrite(write: DraftWrite): Promise<void>;
|
||||
|
||||
@@ -63,13 +63,21 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
const store: LedgerStore = {
|
||||
async describe(ref) {
|
||||
const table = tableNameOf(ref);
|
||||
const cols = await runner.query<{ column_name: string; data_type: string }>(
|
||||
`select column_name, data_type from information_schema.columns
|
||||
where table_schema = $1 and table_name = $2 order by ordinal_position`,
|
||||
// 列注释即人读名:与列定义同一次查询取回,不多跑一趟
|
||||
const cols = await runner.query<{ column_name: string; data_type: string; label: string | null }>(
|
||||
`select c.column_name, c.data_type,
|
||||
col_description(($1 || '.' || quote_ident($2))::regclass, c.ordinal_position) as label
|
||||
from information_schema.columns c
|
||||
where c.table_schema = $1 and c.table_name = $2 order by c.ordinal_position`,
|
||||
[LEDGER_SCHEMA, table],
|
||||
);
|
||||
if (cols.rows.length === 0) return undefined;
|
||||
|
||||
const tableLabel = await runner.query<{ label: string | null }>(
|
||||
`select obj_description(($1 || '.' || quote_ident($2))::regclass) as label`,
|
||||
[LEDGER_SCHEMA, table],
|
||||
);
|
||||
|
||||
const pk = await runner.query<{ attname: string }>(
|
||||
`select a.attname from pg_index i
|
||||
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
|
||||
@@ -77,30 +85,53 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
[`${LEDGER_SCHEMA}.${quoteIdent(table)}`],
|
||||
);
|
||||
|
||||
const label = tableLabel.rows[0]?.label ?? undefined;
|
||||
const materialized: MaterializedObject = {
|
||||
domain: ref.domain,
|
||||
object: ref.object,
|
||||
columns: cols.rows.map((c) => ({ name: c.column_name, type: FROM_PG[c.data_type] ?? 'text' })),
|
||||
...(label === null || label === undefined ? {} : { label }),
|
||||
columns: cols.rows.map((c) => ({
|
||||
name: c.column_name,
|
||||
type: FROM_PG[c.data_type] ?? 'text',
|
||||
...(c.label === null ? {} : { label: c.label }),
|
||||
})),
|
||||
primaryKey: pk.rows.map((p) => p.attname),
|
||||
};
|
||||
return materialized;
|
||||
},
|
||||
|
||||
async listObjects() {
|
||||
const res = await runner.query<{ table_name: string }>(
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = $1 and table_name not like '\\_\\_%' order by table_name`,
|
||||
// 表注释即人读名 —— 与表名同一次查询取回,治理台不必逐个 describe 才知道叫什么
|
||||
const res = await runner.query<{ table_name: string; label: string | null }>(
|
||||
`select t.table_name,
|
||||
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label
|
||||
from information_schema.tables t
|
||||
where t.table_schema = $1 and t.table_name not like '\\_\\_%' order by t.table_name`,
|
||||
[LEDGER_SCHEMA],
|
||||
);
|
||||
return res.rows.flatMap((r) => {
|
||||
const idx = r.table_name.indexOf('__');
|
||||
if (idx <= 0) return [];
|
||||
return [{ domain: r.table_name.slice(0, idx), object: r.table_name.slice(idx + 2) }];
|
||||
return [
|
||||
{
|
||||
domain: r.table_name.slice(0, idx),
|
||||
object: r.table_name.slice(idx + 2),
|
||||
...(r.label === null ? {} : { label: r.label }),
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
|
||||
async applyChanges(ref, changes) {
|
||||
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
|
||||
/*
|
||||
* 注释即人读名;没有 label 就不写,读回来是 null,呈现自然退回键名。
|
||||
* `comment on` 是**工具语句**,不吃 $1 占位符 —— 只能拼字面量,故自己转义单引号。
|
||||
*/
|
||||
const comment = async (target: string, label: string | undefined) => {
|
||||
if (label === undefined || label.trim() === '') return;
|
||||
await runner.query(`comment on ${target} is '${label.replaceAll("'", "''")}'`);
|
||||
};
|
||||
for (const change of changes) {
|
||||
switch (change.kind) {
|
||||
case 'create-table': {
|
||||
@@ -110,12 +141,22 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})`
|
||||
: '';
|
||||
await runner.query(`create table if not exists ${table} (${cols.join(', ')}${pk})`);
|
||||
/*
|
||||
* 人读名落**表/列注释** —— 声明里带的 label 不能在建表这一步丢掉:
|
||||
* 丢了,治理台就只剩 `project__intake_record` 这种键给人看。
|
||||
* 用注释而不是另建元数据表:名字与表同生共死,不会对不上。
|
||||
*/
|
||||
await comment(`table ${table}`, change.object.label);
|
||||
for (const f of change.object.fields) {
|
||||
await comment(`column ${table}.${quoteIdent(f.name)}`, f.label);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'add-column':
|
||||
await runner.query(
|
||||
`alter table ${table} add column if not exists ${quoteIdent(change.field.name)} ${PG_TYPE[change.field.type]}`,
|
||||
);
|
||||
await comment(`column ${table}.${quoteIdent(change.field.name)}`, change.field.label);
|
||||
break;
|
||||
case 'widen-column':
|
||||
await runner.query(
|
||||
|
||||
@@ -185,11 +185,15 @@ export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): M
|
||||
export type MaterializedColumn = {
|
||||
readonly name: string;
|
||||
readonly type: LedgerFieldType;
|
||||
/** 人读名 —— 落在 PG 的列注释上,建表/加列时随声明写下 */
|
||||
readonly label?: string | undefined;
|
||||
};
|
||||
|
||||
export type MaterializedObject = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
/** 人读名 —— 落在 PG 的表注释上;没有就退回对象键 */
|
||||
readonly label?: string | undefined;
|
||||
readonly columns: readonly MaterializedColumn[];
|
||||
readonly primaryKey: readonly string[];
|
||||
};
|
||||
|
||||
@@ -20,7 +20,8 @@ export type ObjectRef = { readonly domain: string; readonly object: string };
|
||||
export type LedgerStore = {
|
||||
/** 已物化对象的列与主键;未物化返回 undefined */
|
||||
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
|
||||
listObjects(): Promise<ObjectRef[]>;
|
||||
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
||||
listObjects(): Promise<Array<ObjectRef & { label?: string | undefined }>>;
|
||||
/** 执行「只增不破坏」的变更计划 */
|
||||
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user