Skip to content

Commit 16b4bf6

Browse files
authored
feat(spec,cli): ADR-0087 P1+P2 — conversion layer, migration chain & change manifest (#2897)
Implements P1 (conversion layer, D2) and P2 (migration chain + spec-changes manifest, D3/D4) of the ADR-0087 epic (#2643) on top of the merged P0 handshake, plus runtime-load-seam wiring and an open-namespace conflict guard. - P1 (#2645): versioned lossless conversion table applied at load (build/validate seam AND the runtime AutomationEngine.registerFlow seam), rewriting old metadata shapes to canonical protocol-N shapes with a structured OS_METADATA_CONVERTED notice. Node-type renames are conflict-aware (OS_METADATA_CONVERSION_CONFLICT) so a third-party custom node under a retired official name is never silently clobbered. PD #12 filters→filter executor fallback retired into the table. - P2 (#2647): permanent per-major migration chain (applyMetaMigrations, migrate meta --from N), semantic changes as structured TODOs, and the Zod-defined spec-changes.json manifest (composeSpecChanges). Refs #2643, #2645, #2647 · ADR-0087 (D2/D3/D4).
1 parent 1dede32 commit 16b4bf6

24 files changed

Lines changed: 1964 additions & 35 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
---
5+
6+
ADR-0087 P1:元数据转换层(conversion layer,D2)——大多数破坏性变更对使用方零操作。
7+
8+
`@objectstack/spec` 新增 `conversions/` 模块:一张按协议大版本组织、声明式、无损的转换表,在**加载时**(`normalizeStackInput` —— `defineStack` / `objectstack validate` / `lint` / `info` / `doctor` 共用的同一入口)把旧(N−1)形态的元数据改写为规范的 N 形态,并对每处改写发出结构化弃用通知(`OS_METADATA_CONVERTED`)。使用方仍按旧形态编写也能零操作加载,运行时只会看到规范形态。这是把 Kubernetes storage-version/conversion 模型套用到元数据上;它与 Prime Directive #12 禁止的“使用方侧方言兜底”在每个维度上都相反:一张集中、随 spec 版本化、声明化、显式(每次应用都发通知)、带测试(每条附 old→new fixture)、会过期(仅在一个大版本内加载期生效,之后退役并沉淀进 P2 迁移链)的表,而非散落的 `cfg.a ?? cfg.b`
9+
10+
首批以已发布的 protocol 11 重命名回填播种:
11+
12+
- `flow-node-http-callout-rename`:流程回调节点 `http_request` / `http_call` / `webhook``http`
13+
- `page-kind-jsx-to-html`:页面 `kind: 'jsx'``'html'`(ADR-0080 规范拼写)。
14+
- `flow-node-crud-filter-alias`:CRUD 流程节点 `config.filters``config.filter`
15+
16+
**运行时加载 seam(存量流程零回归的关键)。** 转换不仅接在构建/校验入口,也接到运行时 `AutomationEngine.registerFlow`(在 `FlowSchema.parse` 之前跑,新增 `applyConversionsToFlow`)。这样从数据库 rehydrate 的**存量流程**也会被规范化——否则删掉 `filters` 执行器兜底会让存量 `delete_record` / `update_record` 的过滤条件被静默清空(退化成作用于全表)。这才真正兑现 D2 “applied at load, the same seam”。
17+
18+
**开放命名空间的冲突守卫(第三方零静默误伤)。** `flow.node.type` 是开放命名空间(ADR-0018 移除了 enum gate),退役的官方名可能被第三方复用为自定义节点。转换层新增“保留名冲突”感知:运行时 seam 传入本环境已注册的执行器类型,若某退役别名(`http_request`/`http_call`/`webhook`)正被活的自定义执行器占用,则**拒绝改写并发出响亮的结构化告警 `OS_METADATA_CONVERSION_CONFLICT`**(带节点位置、conversion id、“请改名”的处置建议),而不是静默把它改成 `http` 破坏第三方节点。构建/校验入口无注册表上下文,历史别名照常转换。
19+
20+
并落实 PD #12 退役路径示范:`filters``filter` 别名从 `service-automation` 执行器的 `readAliasedConfig` 兜底中删除,提升为上面这条声明式转换条目;执行器改为直接读取规范键 `cfg.filter`
21+
22+
新增导出(纯增量,无破坏):`applyConversions``applyConversionsToFlow``collectConversionNotices``ALL_CONVERSIONS``CONVERSIONS_BY_MAJOR``CONVERSION_NOTICE_CODE``CONVERSION_CONFLICT_CODE`,以及类型 `MetadataConversion``ConversionNotice``ConversionApplication``ConversionFixture``ConversionContext``ConversionConflictNotice``ConversionConflictDetail``ApplyConversionsOptions``NormalizeStackInputOptions``normalizeStackInput` 现接受可选第二参 `{ onConversionNotice, convert }`(向后兼容)。
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
ADR-0087 P2:可重放迁移链 + 机器可读变更清单(D3 / D4)。
7+
8+
**D3 —— 迁移链(`@objectstack/spec` 新增 `migrations/`)。** 一条永久、有序、按协议大版本组织的迁移链。每个大版本的步骤由两个来源合成:**已毕业的转换**(P1 的 D2 转换条目从加载路径退役后,以其 id 引用复用,作为该大版本的“机械变换”,转换与 fixture 不重复)和**语义变更**(无损映射无法表达的破坏,以结构化 TODO —— surface / 原因 / 验收标准 —— 呈现,而非静默或有损自动改写)。
9+
10+
- `applyMetaMigrations(stack, fromMajor, toMajor?)` 折叠 `fromMajor+1 … 当前` 的步骤,一次性把任意历史大版本的元数据迁到当前;跨大版本是设计主场景。每一跳(hop)都做检查点,便于逐跳验证与二分定位。**时效性从不承重** —— 迟到的使用方到达时重放链即可。
11+
- `composeMigrationChain``MigrationFloorError`,以及显式的发布策略旋钮 `MIGRATION_SUPPORT_FLOOR`(链能回溯到多久)。
12+
- 种子:protocol 11 步骤 —— 机械项为三条已毕业的 P1 转换;语义项为两个真实存量窗口:`titleFormat` 复合模板 → `nameField`(需公式字段,非无损)、SQL 式 RLS 谓词 → 规范 CEL。
13+
- CI 把整条链当作链来测:每条转换的 old-shape fixture 从支持下限重放到目标大版本,组合性破坏即发布阻断。
14+
15+
**D4 —— `spec-changes.json` 变更清单。** Zod 定义的机器可读记录 `{ from, to, added, converted, migrated, removed }`,由 `composeSpecChanges(from, to, surfaceDiff?)` 跨大版本折叠转换表(D2)与迁移集(D3),并与发布期 api-surface 差异连接。按大版本的清单可组合成单一 `from→to` 视图;后续生成式升级指南与 P3 的 MCP `spec_changes` 工具都是它的投影。
16+
17+
**CLI —— `objectstack migrate meta --from N`** 重放迁移链:展示生成的、经 `ObjectStackDefinitionSchema` 校验的机械变更 diff(逐条 `path: 旧 → 新`)与需人工判断的语义 TODO;`--to``--step`(逐跳检查点)、`--out <file.json>`(把规范化后的栈写为可 diff 的 JSON 快照)、`--json`。命令不静默改写 TS 配置源(AST 改写不安全且有损)—— 输出供使用方 agent 审阅采纳,这正是握手错误(P0)所指向的命令。
18+
19+
`normalizeStackInput` 新增可选 `convert: false`(仅做 map→array,不跑 D2 转换),供 `migrate meta` 对原始编写源重放链、把每处改写归因到对应链步。新增导出纯增量,无破坏性移除。
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Args, Command, Flags } from '@oclif/core';
4+
import { writeFileSync } from 'node:fs';
5+
import { resolve } from 'node:path';
6+
import chalk from 'chalk';
7+
import {
8+
ObjectStackDefinitionSchema,
9+
applyMetaMigrations,
10+
composeSpecChanges,
11+
normalizeStackInput,
12+
MigrationFloorError,
13+
} from '@objectstack/spec';
14+
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
15+
import { loadConfig } from '../../utils/config.js';
16+
import {
17+
printHeader,
18+
printSuccess,
19+
printWarning,
20+
printError,
21+
printInfo,
22+
printStep,
23+
createTimer,
24+
} from '../../utils/format.js';
25+
26+
/**
27+
* `os migrate meta --from N` — replay the ADR-0087 D3 migration chain.
28+
*
29+
* Composes the per-major steps N+1 → … → current and applies each major's
30+
* mechanical transforms (the graduated D2 conversions) to the loaded stack in
31+
* one run — cross-major is the designed-for case, not an edge. It reports a
32+
* generated, schema-validated diff (the mechanical rewrites) plus the structured
33+
* TODOs for the semantic changes the chain cannot apply, so the consumer agent
34+
* reviews a provably-valid change instead of hand-porting from prose.
35+
*
36+
* The command does not silently rewrite TS config source (that AST rewrite is
37+
* unsafe and lossy); `--out` writes the canonicalized stack as a JSON snapshot
38+
* the agent can diff and adopt. `--step` prints a per-hop checkpoint so a failure
39+
* can be bisected to the exact major.
40+
*/
41+
export default class MigrateMeta extends Command {
42+
static override description =
43+
'Replay the metadata protocol migration chain from a past major to current (ADR-0087 D3).';
44+
45+
static override examples = [
46+
'$ os migrate meta --from 10',
47+
'$ os migrate meta --from 10 --step',
48+
'$ os migrate meta --from 11 --to 12 --json',
49+
'$ os migrate meta --from 10 --out migrated.stack.json',
50+
];
51+
52+
static override args = {
53+
config: Args.string({ description: 'Path to the stack config (defaults to auto-detected).' }),
54+
};
55+
56+
static override flags = {
57+
from: Flags.integer({
58+
description: 'The protocol major the metadata was authored against.',
59+
required: true,
60+
}),
61+
to: Flags.integer({
62+
description: `Target protocol major (defaults to this runtime's, ${PROTOCOL_MAJOR}).`,
63+
}),
64+
step: Flags.boolean({
65+
description: 'Print a per-hop checkpoint (for per-major verify / bisection).',
66+
default: false,
67+
}),
68+
out: Flags.string({ description: 'Write the migrated stack as a JSON snapshot to this path.' }),
69+
json: Flags.boolean({ description: 'Output the machine-readable migration result as JSON.' }),
70+
};
71+
72+
async run(): Promise<void> {
73+
const { args, flags } = await this.parse(MigrateMeta);
74+
const timer = createTimer();
75+
const toMajor = flags.to ?? PROTOCOL_MAJOR;
76+
77+
if (!flags.json) printHeader('Migrate · meta');
78+
79+
try {
80+
if (!flags.json) printStep('Loading configuration…');
81+
const { config, absolutePath } = await loadConfig(args.config);
82+
83+
// Map→array normalization ONLY (convert:false): the chain must replay the
84+
// conversions itself against the raw authored source so each rewrite is
85+
// attributed to a chain hop, not silently pre-applied by the load-time
86+
// D2 pass. Running the D2 pass here would leave the chain's diff empty.
87+
const normalized = normalizeStackInput(config as Record<string, unknown>, { convert: false });
88+
89+
if (!flags.json) printStep(`Replaying chain: protocol ${flags.from}${toMajor}…`);
90+
const result = applyMetaMigrations(normalized, flags.from, toMajor);
91+
92+
// Prove the migrated stack is schema-valid — the "generated, provably valid
93+
// diff" the consumer agent reviews (ADR-0087 D3/D5).
94+
const parsed = ObjectStackDefinitionSchema.safeParse(result.stack);
95+
const specChanges = composeSpecChanges(flags.from, toMajor);
96+
97+
if (flags.json) {
98+
console.log(
99+
JSON.stringify(
100+
{
101+
from: result.fromMajor,
102+
to: result.toMajor,
103+
runtime: PROTOCOL_VERSION,
104+
applied: result.applied,
105+
todos: result.todos,
106+
hops: flags.step
107+
? result.hops.map((h) => ({
108+
toMajor: h.toMajor,
109+
rationale: h.rationale,
110+
applied: h.applied,
111+
todos: h.todos,
112+
}))
113+
: undefined,
114+
specChanges,
115+
schemaValid: parsed.success,
116+
duration: timer.elapsed(),
117+
},
118+
null,
119+
2,
120+
),
121+
);
122+
if (flags.out) writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2));
123+
return;
124+
}
125+
126+
printInfo(`Config: ${chalk.white(absolutePath)}`);
127+
printInfo(`Chain: protocol ${flags.from}${toMajor} (runtime ${PROTOCOL_VERSION})`);
128+
console.log('');
129+
130+
if (result.applied.length === 0 && result.todos.length === 0) {
131+
printSuccess('Nothing to migrate — the metadata is already canonical for this range.');
132+
return;
133+
}
134+
135+
// Mechanical rewrites (auto-applied).
136+
if (result.applied.length > 0) {
137+
console.log(chalk.bold(` Applied ${result.applied.length} mechanical change(s):`));
138+
for (const a of result.applied) {
139+
console.log(` • ${a.path}: ${chalk.red(a.from)}${chalk.green(a.to)} ${chalk.dim(`(${a.conversionId})`)}`);
140+
}
141+
console.log('');
142+
}
143+
144+
// Per-hop checkpoints.
145+
if (flags.step) {
146+
for (const hop of result.hops) {
147+
console.log(chalk.bold(` ── protocol ${hop.toMajor} ──`));
148+
console.log(chalk.dim(` ${hop.rationale}`));
149+
console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual`));
150+
}
151+
console.log('');
152+
}
153+
154+
// Semantic TODOs (delegated to the agent — never auto-applied).
155+
if (result.todos.length > 0) {
156+
console.log(chalk.bold(chalk.yellow(` ${result.todos.length} manual change(s) require your judgment:`)));
157+
for (const t of result.todos) {
158+
console.log(` ${chalk.yellow('⚠')} [protocol ${t.toMajor}] ${t.surface}${t.replacement}`);
159+
console.log(chalk.dim(` why: ${t.reason}`));
160+
console.log(chalk.dim(` verify: ${t.acceptanceCriteria}`));
161+
}
162+
console.log('');
163+
}
164+
165+
if (flags.out) {
166+
writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2));
167+
printInfo(`Wrote migrated stack snapshot → ${chalk.white(resolve(flags.out))}`);
168+
}
169+
170+
if (parsed.success) {
171+
printSuccess(`Migrated stack is schema-valid ${chalk.dim(`(${timer.display()})`)}`);
172+
} else {
173+
printWarning(
174+
'Migrated stack does not yet pass schema validation — resolve the manual changes above, ' +
175+
'then run `os validate`.',
176+
);
177+
}
178+
console.log('');
179+
} catch (error: any) {
180+
if (error instanceof MigrationFloorError) {
181+
if (flags.json) {
182+
console.log(JSON.stringify({ error: 'unsupported_from_major', message: error.message }));
183+
this.exit(1);
184+
}
185+
printError(error.message);
186+
this.exit(1);
187+
return;
188+
}
189+
if (flags.json) {
190+
console.log(JSON.stringify({ error: error.message }));
191+
this.exit(1);
192+
}
193+
printError(error.message || String(error));
194+
this.exit(1);
195+
}
196+
}
197+
}

packages/cli/src/commands/validate.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createRequire } from 'node:module';
66
import { join } from 'node:path';
77
import chalk from 'chalk';
88
import { ZodError } from 'zod';
9-
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
9+
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
1010
import { loadConfig } from '../utils/config.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateListViewMode } from '@objectstack/lint';
@@ -58,9 +58,16 @@ export default class Validate extends Command {
5858
printKV('Load time', `${duration}ms`);
5959
}
6060

61-
// 2. Normalize map-formatted stack definition and validate against schema
61+
// 2. Normalize map-formatted stack definition and validate against schema.
62+
// The ADR-0087 D2 conversion layer runs here (inside normalizeStackInput);
63+
// surface each applied conversion as a non-blocking deprecation notice so
64+
// the author knows the source still carries an old-shape key that will
65+
// retire from the load path in a future major.
6266
if (!flags.json) printStep('Validating against ObjectStack Protocol...');
63-
const normalized = normalizeStackInput(config as Record<string, unknown>);
67+
const conversionNotices: ConversionNotice[] = [];
68+
const normalized = normalizeStackInput(config as Record<string, unknown>, {
69+
onConversionNotice: (n) => conversionNotices.push(n),
70+
});
6471
const result = ObjectStackDefinitionSchema.safeParse(normalized);
6572

6673
if (!result.success) {
@@ -341,6 +348,7 @@ export default class Validate extends Command {
341348
manifest: config.manifest,
342349
stats,
343350
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings],
351+
conversions: conversionNotices,
344352
duration: timer.elapsed(),
345353
}, null, 2));
346354
return;
@@ -349,6 +357,12 @@ export default class Validate extends Command {
349357
// 5. Warnings (non-blocking)
350358
const warnings: string[] = [];
351359

360+
// ADR-0087 D2 conversion notices: the source used a deprecated shape that
361+
// was auto-converted at load. No action is required to keep loading, but
362+
// the notice steers the author to the canonical key before it retires.
363+
for (const n of conversionNotices) {
364+
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
365+
}
352366
for (const i of exprWarnings) {
353367
warnings.push(`${i.where}: ${i.message}`);
354368
}

packages/services/service-automation/src/builtin/config-aliases.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,18 @@
2525
* the canonical key. Removal of the alias paths is tracked as a follow-up and
2626
* happens once the window has elapsed and graph-lint has been enforcing.
2727
*
28-
* @see crud-nodes.ts for the first call sites (`objectName`, `filter`).
28+
* The `filters` → `filter` alias has since been **retired from this shim** and
29+
* promoted into the ADR-0087 D2 conversion layer (`@objectstack/spec` conversion
30+
* `flow-node-crud-filter-alias`): it is rewritten to the canonical key **at
31+
* load** — including the runtime rehydration seam (`AutomationEngine.registerFlow`
32+
* runs the conversion before parse), which is exactly the stored-prod-flow gap
33+
* this shim describes above, now closed for `filters` by the conversion instead
34+
* of an executor fallback. So the CRUD executors read `cfg.filter` directly. That
35+
* is the PD #12 retirement path the ADR prescribes — a scattered consumer-side
36+
* fallback replaced by one declared, loud, tested, expiring conversion entry. The
37+
* remaining `object` → `objectName` alias is the next candidate to graduate.
38+
*
39+
* @see crud-nodes.ts for the remaining call site (`objectName`).
2940
*/
3041

3142
/** One-time-warning ledger, keyed by `${nodeType}:${canonical}<-${alias}`. */

0 commit comments

Comments
 (0)