Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/adr-0087-p1-conversion-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
---

ADR-0087 P1:元数据转换层(conversion layer,D2)——大多数破坏性变更对使用方零操作。

`@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`。

首批以已发布的 protocol 11 重命名回填播种:

- `flow-node-http-callout-rename`:流程回调节点 `http_request` / `http_call` / `webhook` → `http`。
- `page-kind-jsx-to-html`:页面 `kind: 'jsx'` → `'html'`(ADR-0080 规范拼写)。
- `flow-node-crud-filter-alias`:CRUD 流程节点 `config.filters` → `config.filter`。

**运行时加载 seam(存量流程零回归的关键)。** 转换不仅接在构建/校验入口,也接到运行时 `AutomationEngine.registerFlow`(在 `FlowSchema.parse` 之前跑,新增 `applyConversionsToFlow`)。这样从数据库 rehydrate 的**存量流程**也会被规范化——否则删掉 `filters` 执行器兜底会让存量 `delete_record` / `update_record` 的过滤条件被静默清空(退化成作用于全表)。这才真正兑现 D2 “applied at load, the same seam”。

**开放命名空间的冲突守卫(第三方零静默误伤)。** `flow.node.type` 是开放命名空间(ADR-0018 移除了 enum gate),退役的官方名可能被第三方复用为自定义节点。转换层新增“保留名冲突”感知:运行时 seam 传入本环境已注册的执行器类型,若某退役别名(`http_request`/`http_call`/`webhook`)正被活的自定义执行器占用,则**拒绝改写并发出响亮的结构化告警 `OS_METADATA_CONVERSION_CONFLICT`**(带节点位置、conversion id、“请改名”的处置建议),而不是静默把它改成 `http` 破坏第三方节点。构建/校验入口无注册表上下文,历史别名照常转换。

并落实 PD #12 退役路径示范:`filters` → `filter` 别名从 `service-automation` 执行器的 `readAliasedConfig` 兜底中删除,提升为上面这条声明式转换条目;执行器改为直接读取规范键 `cfg.filter`。

新增导出(纯增量,无破坏):`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 }`(向后兼容)。
19 changes: 19 additions & 0 deletions .changeset/adr-0087-p2-migration-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
'@objectstack/cli': minor
---

ADR-0087 P2:可重放迁移链 + 机器可读变更清单(D3 / D4)。

**D3 —— 迁移链(`@objectstack/spec` 新增 `migrations/`)。** 一条永久、有序、按协议大版本组织的迁移链。每个大版本的步骤由两个来源合成:**已毕业的转换**(P1 的 D2 转换条目从加载路径退役后,以其 id 引用复用,作为该大版本的“机械变换”,转换与 fixture 不重复)和**语义变更**(无损映射无法表达的破坏,以结构化 TODO —— surface / 原因 / 验收标准 —— 呈现,而非静默或有损自动改写)。

- `applyMetaMigrations(stack, fromMajor, toMajor?)` 折叠 `fromMajor+1 … 当前` 的步骤,一次性把任意历史大版本的元数据迁到当前;跨大版本是设计主场景。每一跳(hop)都做检查点,便于逐跳验证与二分定位。**时效性从不承重** —— 迟到的使用方到达时重放链即可。
- `composeMigrationChain`、`MigrationFloorError`,以及显式的发布策略旋钮 `MIGRATION_SUPPORT_FLOOR`(链能回溯到多久)。
- 种子:protocol 11 步骤 —— 机械项为三条已毕业的 P1 转换;语义项为两个真实存量窗口:`titleFormat` 复合模板 → `nameField`(需公式字段,非无损)、SQL 式 RLS 谓词 → 规范 CEL。
- CI 把整条链当作链来测:每条转换的 old-shape fixture 从支持下限重放到目标大版本,组合性破坏即发布阻断。

**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` 工具都是它的投影。

**CLI —— `objectstack migrate meta --from N`。** 重放迁移链:展示生成的、经 `ObjectStackDefinitionSchema` 校验的机械变更 diff(逐条 `path: 旧 → 新`)与需人工判断的语义 TODO;`--to`、`--step`(逐跳检查点)、`--out <file.json>`(把规范化后的栈写为可 diff 的 JSON 快照)、`--json`。命令不静默改写 TS 配置源(AST 改写不安全且有损)—— 输出供使用方 agent 审阅采纳,这正是握手错误(P0)所指向的命令。

`normalizeStackInput` 新增可选 `convert: false`(仅做 map→array,不跑 D2 转换),供 `migrate meta` 对原始编写源重放链、把每处改写归因到对应链步。新增导出纯增量,无破坏性移除。
197 changes: 197 additions & 0 deletions packages/cli/src/commands/migrate/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Args, Command, Flags } from '@oclif/core';
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import chalk from 'chalk';
import {
ObjectStackDefinitionSchema,
applyMetaMigrations,
composeSpecChanges,
normalizeStackInput,
MigrationFloorError,
} from '@objectstack/spec';
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
import { loadConfig } from '../../utils/config.js';
import {
printHeader,
printSuccess,
printWarning,
printError,
printInfo,
printStep,
createTimer,
} from '../../utils/format.js';

/**
* `os migrate meta --from N` — replay the ADR-0087 D3 migration chain.
*
* Composes the per-major steps N+1 → … → current and applies each major's
* mechanical transforms (the graduated D2 conversions) to the loaded stack in
* one run — cross-major is the designed-for case, not an edge. It reports a
* generated, schema-validated diff (the mechanical rewrites) plus the structured
* TODOs for the semantic changes the chain cannot apply, so the consumer agent
* reviews a provably-valid change instead of hand-porting from prose.
*
* The command does not silently rewrite TS config source (that AST rewrite is
* unsafe and lossy); `--out` writes the canonicalized stack as a JSON snapshot
* the agent can diff and adopt. `--step` prints a per-hop checkpoint so a failure
* can be bisected to the exact major.
*/
export default class MigrateMeta extends Command {
static override description =
'Replay the metadata protocol migration chain from a past major to current (ADR-0087 D3).';

static override examples = [
'$ os migrate meta --from 10',
'$ os migrate meta --from 10 --step',
'$ os migrate meta --from 11 --to 12 --json',
'$ os migrate meta --from 10 --out migrated.stack.json',
];

static override args = {
config: Args.string({ description: 'Path to the stack config (defaults to auto-detected).' }),
};

static override flags = {
from: Flags.integer({
description: 'The protocol major the metadata was authored against.',
required: true,
}),
to: Flags.integer({
description: `Target protocol major (defaults to this runtime's, ${PROTOCOL_MAJOR}).`,
}),
step: Flags.boolean({
description: 'Print a per-hop checkpoint (for per-major verify / bisection).',
default: false,
}),
out: Flags.string({ description: 'Write the migrated stack as a JSON snapshot to this path.' }),
json: Flags.boolean({ description: 'Output the machine-readable migration result as JSON.' }),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(MigrateMeta);
const timer = createTimer();
const toMajor = flags.to ?? PROTOCOL_MAJOR;

if (!flags.json) printHeader('Migrate · meta');

try {
if (!flags.json) printStep('Loading configuration…');
const { config, absolutePath } = await loadConfig(args.config);

// Map→array normalization ONLY (convert:false): the chain must replay the
// conversions itself against the raw authored source so each rewrite is
// attributed to a chain hop, not silently pre-applied by the load-time
// D2 pass. Running the D2 pass here would leave the chain's diff empty.
const normalized = normalizeStackInput(config as Record<string, unknown>, { convert: false });

if (!flags.json) printStep(`Replaying chain: protocol ${flags.from} → ${toMajor}…`);
const result = applyMetaMigrations(normalized, flags.from, toMajor);

// Prove the migrated stack is schema-valid — the "generated, provably valid
// diff" the consumer agent reviews (ADR-0087 D3/D5).
const parsed = ObjectStackDefinitionSchema.safeParse(result.stack);
const specChanges = composeSpecChanges(flags.from, toMajor);

if (flags.json) {
console.log(
JSON.stringify(
{
from: result.fromMajor,
to: result.toMajor,
runtime: PROTOCOL_VERSION,
applied: result.applied,
todos: result.todos,
hops: flags.step
? result.hops.map((h) => ({
toMajor: h.toMajor,
rationale: h.rationale,
applied: h.applied,
todos: h.todos,
}))
: undefined,
specChanges,
schemaValid: parsed.success,
duration: timer.elapsed(),
},
null,
2,
),
);
if (flags.out) writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2));
return;
}

printInfo(`Config: ${chalk.white(absolutePath)}`);
printInfo(`Chain: protocol ${flags.from} → ${toMajor} (runtime ${PROTOCOL_VERSION})`);
console.log('');

if (result.applied.length === 0 && result.todos.length === 0) {
printSuccess('Nothing to migrate — the metadata is already canonical for this range.');
return;
}

// Mechanical rewrites (auto-applied).
if (result.applied.length > 0) {
console.log(chalk.bold(` Applied ${result.applied.length} mechanical change(s):`));
for (const a of result.applied) {
console.log(` • ${a.path}: ${chalk.red(a.from)} → ${chalk.green(a.to)} ${chalk.dim(`(${a.conversionId})`)}`);
}
console.log('');
}

// Per-hop checkpoints.
if (flags.step) {
for (const hop of result.hops) {
console.log(chalk.bold(` ── protocol ${hop.toMajor} ──`));
console.log(chalk.dim(` ${hop.rationale}`));
console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual`));
}
console.log('');
}

// Semantic TODOs (delegated to the agent — never auto-applied).
if (result.todos.length > 0) {
console.log(chalk.bold(chalk.yellow(` ${result.todos.length} manual change(s) require your judgment:`)));
for (const t of result.todos) {
console.log(` ${chalk.yellow('⚠')} [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`);
console.log(chalk.dim(` why: ${t.reason}`));
console.log(chalk.dim(` verify: ${t.acceptanceCriteria}`));
}
console.log('');
}

if (flags.out) {
writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2));
printInfo(`Wrote migrated stack snapshot → ${chalk.white(resolve(flags.out))}`);
}

if (parsed.success) {
printSuccess(`Migrated stack is schema-valid ${chalk.dim(`(${timer.display()})`)}`);
} else {
printWarning(
'Migrated stack does not yet pass schema validation — resolve the manual changes above, ' +
'then run `os validate`.',
);
}
console.log('');
} catch (error: any) {
if (error instanceof MigrationFloorError) {
if (flags.json) {
console.log(JSON.stringify({ error: 'unsupported_from_major', message: error.message }));
this.exit(1);
}
printError(error.message);
this.exit(1);
return;
}
if (flags.json) {
console.log(JSON.stringify({ error: error.message }));
this.exit(1);
}
printError(error.message || String(error));
this.exit(1);
}
}
}
20 changes: 17 additions & 3 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createRequire } from 'node:module';
import { join } from 'node:path';
import chalk from 'chalk';
import { ZodError } from 'zod';
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
import { loadConfig } from '../utils/config.js';
import { validateStackExpressions } from '@objectstack/lint';
import { validateListViewMode } from '@objectstack/lint';
Expand Down Expand Up @@ -58,9 +58,16 @@ export default class Validate extends Command {
printKV('Load time', `${duration}ms`);
}

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

if (!result.success) {
Expand Down Expand Up @@ -341,6 +348,7 @@ export default class Validate extends Command {
manifest: config.manifest,
stats,
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings],
conversions: conversionNotices,
duration: timer.elapsed(),
}, null, 2));
return;
Expand All @@ -349,6 +357,12 @@ export default class Validate extends Command {
// 5. Warnings (non-blocking)
const warnings: string[] = [];

// ADR-0087 D2 conversion notices: the source used a deprecated shape that
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
}
for (const i of exprWarnings) {
warnings.push(`${i.where}: ${i.message}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,18 @@
* the canonical key. Removal of the alias paths is tracked as a follow-up and
* happens once the window has elapsed and graph-lint has been enforcing.
*
* @see crud-nodes.ts for the first call sites (`objectName`, `filter`).
* The `filters` → `filter` alias has since been **retired from this shim** and
* promoted into the ADR-0087 D2 conversion layer (`@objectstack/spec` conversion
* `flow-node-crud-filter-alias`): it is rewritten to the canonical key **at
* load** — including the runtime rehydration seam (`AutomationEngine.registerFlow`
* runs the conversion before parse), which is exactly the stored-prod-flow gap
* this shim describes above, now closed for `filters` by the conversion instead
* of an executor fallback. So the CRUD executors read `cfg.filter` directly. That
* is the PD #12 retirement path the ADR prescribes — a scattered consumer-side
* fallback replaced by one declared, loud, tested, expiring conversion entry. The
* remaining `object` → `objectName` alias is the next candidate to graduate.
*
* @see crud-nodes.ts for the remaining call site (`objectName`).
*/

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