Skip to content

Commit 3aef718

Browse files
os-zhuangclaude
andauthored
feat(migrate): os migrate value-shapes —— 引用/结构化 JSON 值形态的按部署门禁(#3438 第 2 项) (#4235)
媒体类已由 #3681 按部署翻转;引用(`lookup`/`master_detail`/`user`/`tree`)与结构化 JSON(`location`/`address`/`composite`/`repeater`/`record`/`vector`)此前只能借文件 迁移那个标记——而后者断言的是「文件值已迁移、归属已对账」,对 `lookup` id 或 `location` 载荷是否规范什么都没说。本命令给它们自己的门禁。 刻意没有回填:姊妹迁移转换遗留文件值,因为是平台收窄了那个存储形态、因此欠这笔转换; 而畸形的 `location` 是应用数据,正确值只有作者知道。所以这条命令只报告并给出处方(点名 对象、字段、类型、条数、样本 record id、parse issue),由运维修完重跑。由此得到一个 简化:没有可转换的东西,`--apply` 唯一的写入就是标记行,于是 #3617 的不变量平凡成立 ——dry run 什么都不改,且「这次运行是否改变了本部署的姿态」从不取决于它发现了什么。 反漂移:`valueShapeViolation` / `isScannableValueShapeField` 从 `record-validator` 导出、被扫描器 import。两份「什么算畸形」的实现只要差一个子句,部署就会通过扫描却仍被 拒写——那等于标记在断言一个校验器不认的事实,正是 ADR 上一层禁止的「借来的证据」。一个 测试直接钉住这条属性:同一个值必须既被扫描器计数、又被 strict 写入路径拒绝。 分层:`scanValueShapes` 只读(它接受的 engine 类型没有写入面)。标记的读者用 spec 契约, 只有写者依赖 platform-objects——这是 `sys-migration.object.ts` 自己写下的设计意图;让 引擎去写标记会反转依赖方向,所以组合放在 CLI 命令里。 其余:`OS_ALLOW_LAX_VALUE_SHAPES` 逃生阀,优先级与媒体半边一致(opt-out > 全类 opt-in > 标记),矛盾配置落在安全侧;引擎两个标记的读取重构成一个按 id 参数化的 helper,"所有 不知道的方式都答 false" 只写一遍;截断或有对象读不出来的扫描即使零违规也不开门——「我们 读到的那部分里没有」不是标记所断言的命题。 #4215 在本 PR 开着时落了 #3438 第 3 项,并顺带声明了同名的 `VALUE_SHAPES_MIGRATION_ID`; 合并保留一份声明加 main 的 `CREATION_ATTESTED_MIGRATION_IDS`。合并同时暴露的文档不自洽 一并修掉:新小节曾把 files-to-references 的收尾段与「写入规则」callout 劈开,挪回各自 命令名下;「其他值类保持 warn-first」改为指向现已存在的门禁;v17 发布说明补上本命令、 分开标记的理由、独立的升级清单条目,以及「从空创建」那节缺的 `OS_ALLOW_LAX_VALUE_SHAPES`。 顺带发现(另开 issue,未在本 PR 扩大范围):`sys_migration` 目前只由 service-storage 注册,这在只有存储一个消费者时说得通,现在有了第二个与存储无关的消费者。实际部署路径没 问题(`os serve` 会自动装配 storage),但账本是平台基础设施,不该由一个可选服务持有; 已在 `data-migration-plugins.ts` 注释中记录。 #3438 三项至此全部完成:第 1 项 698cbc2,第 3 项 #4215,本 PR 是第 2 项。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7777e8f commit 3aef718

12 files changed

Lines changed: 1017 additions & 89 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/cli": minor
5+
---
6+
7+
feat(migrate): `os migrate value-shapes` — the per-deployment gate for reference and structured-JSON value shapes (#3438)
8+
9+
The second of ADR-0104 D1's two evidence gates. Media value shapes already
10+
enforce once a deployment has verified its file migration (#3681); the
11+
reference (`lookup` / `master_detail` / `user` / `tree`) and structured-JSON
12+
(`location` / `address` / `composite` / `repeater` / `record` / `vector`)
13+
classes now get a gate of their own.
14+
15+
```bash
16+
os migrate value-shapes # scan: reports, writes nothing
17+
os migrate value-shapes --apply # scan + record the deployment flag when clean
18+
```
19+
20+
The run walks every stored value of those classes against
21+
`valueSchemaFor(field, 'stored')` — the same predicate the write path enforces,
22+
imported rather than re-derived — and, at zero violations, records
23+
`sys_migration { id: 'adr-0104-value-shapes', verified_at, blocking: 0 }`.
24+
Strict enforcement of these classes reads **that row**, never the platform
25+
version, so upgrading changes nothing until a deployment produces its own
26+
evidence.
27+
28+
**There is no backfill, deliberately.** The file migration converts legacy
29+
values because the platform narrowed that storage form and owes the conversion.
30+
A malformed `location` is application data whose correct value only its author
31+
knows, so this run reports and prescribes — naming the object, field, type,
32+
count, offending record ids and the parse issue — and the operator fixes and
33+
re-runs. With nothing to convert, `--apply`'s only write is the flag row, which
34+
keeps the #3617 invariant trivially: a dry run changes nothing, and whether a
35+
run changed this deployment's posture never depends on what it found.
36+
37+
**A separate flag from the file migration**, because it attests a separate
38+
fact. That flag says file values were migrated and their ownership reconciled;
39+
it says nothing about whether a `lookup` id or a `location` payload is well
40+
formed. Gating these classes on it would be borrowing evidence for a fact it
41+
does not cover.
42+
43+
- New escape hatch **`OS_ALLOW_LAX_VALUE_SHAPES=1`** returns a verified
44+
deployment to warnings, with the same precedence as its media sibling: the
45+
opt-out beats `OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, which beats the flag.
46+
Wrongly staying lenient costs a warning; wrongly enforcing stops a working
47+
app from writing.
48+
- `@objectstack/spec/system` exports `VALUE_SHAPES_MIGRATION_ID`.
49+
- `@objectstack/objectql` exports `scanValueShapes`, `valueShapeScanPassed`
50+
and `formatValueShapeScanReport`. The scanner is read-only and does **not**
51+
record the flag: readers of a migration flag use the spec contract, only
52+
writers depend on `@objectstack/platform-objects`, so the composition lives
53+
with the CLI command rather than inverting the engine's dependencies.
54+
- `validateRecord` gains `valueShapeStrict`, the sibling of
55+
`mediaValueShapeStrict`. Both default to `false`: a caller that cannot say
56+
stays lenient, so nothing starts rejecting merely because the evidence was
57+
unavailable.
58+
59+
**Nothing changes for an existing deployment until it runs the command.** A
60+
scan that is truncated, or that cannot read an object, fails the gate even with
61+
zero violations found — "none in the part we read" is not the claim the flag
62+
makes.

content/docs/deployment/cli.mdx

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,7 @@ where the data lives.
628628
| Command | Description |
629629
|---------|-------------|
630630
| `os migrate files-to-references` | Convert legacy file-field values to `sys_file` references, verify the ownership ledger, and record the deployment's migration flag |
631+
| `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
631632

632633
```bash
633634
os migrate files-to-references # Dry run: full report, writes nothing
@@ -660,8 +661,9 @@ Exit status is `0` only when the self-check passes, so CI can gate on it.
660661
| **Released-file collection** | A field file whose one owning record lets go (the field is cleared or the record deleted) is tombstoned into the declared 30-day grace window; re-referencing the id within the window revives it, and after it the platform sweep reclaims the row and its bytes. Unverified deployments keep every released file forever. |
661662

662663
Other value classes are unaffected: a `lookup` or `location` value keeps its own
663-
warn-first rollout, because this migration is evidence about *file* values and
664-
says nothing about theirs.
664+
warn-first rollout until `os migrate value-shapes` (below) supplies *their*
665+
evidence, because this migration is evidence about *file* values and says
666+
nothing about theirs.
665667

666668
<Callout type="warn">
667669
A dry run writes **nothing** — not the conversions, and not the flag either,
@@ -675,6 +677,53 @@ delete check re-reads the flag fresh, so a later failing run stops collection
675677
without a restart.
676678
</Callout>
677679

680+
#### `os migrate value-shapes`
681+
682+
The same gate for the **non-media** value classes — references (`lookup`,
683+
`master_detail`, `user`, `tree`) and structured JSON (`location`, `address`,
684+
`composite`, `repeater`, `record`, `vector`).
685+
686+
```bash
687+
os migrate value-shapes # Scan: full report, writes nothing
688+
os migrate value-shapes --apply # Scan, then record the flag if clean (prompts)
689+
os migrate value-shapes --apply --yes --json # CI / scripts
690+
os migrate value-shapes --object contact # Restrict to one object (repeatable)
691+
```
692+
693+
**This one converts nothing.** Its sibling rewrites legacy file values because
694+
the platform narrowed that storage form and therefore owes the conversion; a
695+
`location` stored as `{latitude, longitude}` instead of `{lat, lng}` is
696+
*application* data whose correct value only its author knows. So the run reports
697+
— object, field, type, how many records, sample record ids, and the parse issue
698+
— and you fix the values (or the code writing them) and re-run until it is
699+
green. Because there is nothing to convert, `--apply`'s only write is the flag
700+
row itself.
701+
702+
A scan that is **truncated** (by `--max-records`) or that cannot read an object
703+
fails the gate even with zero violations found: "none in the part we read" is
704+
not the claim the flag makes.
705+
706+
| Once verified | Effect |
707+
| :--- | :--- |
708+
| **Reference + structured-JSON value shapes** | A malformed value of those classes is **rejected** (`400 invalid_type`) instead of warned about. Set `OS_ALLOW_LAX_VALUE_SHAPES=1` to re-open leniency while diagnosing. |
709+
710+
This flag is deliberately **separate** from the file migration's. That one
711+
attests that file values were migrated and their ownership reconciled — it says
712+
nothing about whether a `lookup` id or a `location` payload is well formed, so
713+
it may not vouch for these classes. A deployment can legitimately have passed
714+
either without the other.
715+
716+
<Callout type="warn">
717+
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` turns on **every** value class at once,
718+
regardless of which migrations this deployment has run. It is the "I already
719+
know my data" lever, not the route to strictness — the route is running the
720+
migration that produces the evidence.
721+
</Callout>
722+
723+
Same writing rules as its sibling: a dry run writes **nothing**, `--apply` is
724+
the only writing mode, a later failing run clears the verified state, and a
725+
running server reads the flag once — **restart it** after a successful apply.
726+
678727
#### A database created by this version needs no migration
679728

680729
A deployment whose database the platform **creates from empty** records these
@@ -691,8 +740,10 @@ attests nothing and produces its evidence by running the command, because
691740

692741
Importing legacy values into such a deployment is rejected at the write path
693742
rather than silently accepted. That is the intended outcome; if you must admit
694-
them temporarily, `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency, and
695-
re-running the migration re-establishes the flag from the data itself.
743+
them temporarily, `OS_ALLOW_LAX_MEDIA_VALUES=1` (media) and
744+
`OS_ALLOW_LAX_VALUE_SHAPES=1` (references and structured JSON) re-open leniency
745+
per class, and re-running the corresponding migration re-establishes its flag
746+
from the data itself.
696747

697748
### Scaffolding
698749

content/docs/releases/v17.mdx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,23 @@ records** — never the platform version — is what authorises both strict medi
984984
value shapes and irreversible file collection. Upgrading changes neither;
985985
running the migration does.
986986

987+
**The non-media classes have their own gate** (#3438) — references (`lookup`,
988+
`master_detail`, `user`, `tree`) and structured JSON (`location`, `address`,
989+
`composite`, `repeater`, `record`, `vector`):
990+
991+
```bash
992+
os migrate value-shapes # scan: reports, writes nothing
993+
os migrate value-shapes --apply # scan, then record the flag if clean
994+
```
995+
996+
A separate flag because it attests a separate fact: the file migration says file
997+
values were converted and their ownership reconciled, which tells you nothing
998+
about whether a `lookup` id or a `location` payload is well formed. This one
999+
converts nothing — a malformed `location` is application data only its author
1000+
can correct — so it reports the object, field, type, count, sample record ids
1001+
and parse issue, and you re-run until it is clean. `OS_ALLOW_LAX_VALUE_SHAPES=1`
1002+
re-opens leniency while diagnosing.
1003+
9871004
**A database created by 17 attests both flags at creation** (#3438), so a new
9881005
deployment enforces from its first boot instead of waiting for someone to run a
9891006
migration that, for an empty store, does nothing. The platform attests only a
@@ -1306,7 +1323,11 @@ objectui commits on top of the pin 16.1.0 shipped.
13061323
media value shapes *and* released-file collection for this deployment, so
13071324
read the report before you `--apply`. Do **not** reach for
13081325
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED` to get there: it opts every value class
1309-
in at once, including ones with no migration behind them.
1326+
in at once, regardless of which migrations this deployment has actually run.
1327+
- **Reference and structured-JSON values:** run `os migrate value-shapes` and
1328+
fix what it reports before `--apply`. It converts nothing — the values it
1329+
names are application data — and a scan that was truncated or could not read
1330+
an object fails the gate even at zero violations.
13101331
- **Datasources:** verify every declared datasource connects in every
13111332
environment — a bound datasource that cannot connect now fails the boot
13121333
instead of failing every later query.

packages/cli/src/commands/migrate/files-to-references.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { bootSchemaStack } from '../../utils/schema-migrate.js';
1818
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
1919
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
20-
import { resolveStorageCapabilityArg } from '../serve.js';
20+
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
2121

2222
async function confirm(question: string): Promise<boolean> {
2323
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
@@ -30,36 +30,6 @@ async function confirm(question: string): Promise<boolean> {
3030
}
3131
}
3232

33-
/**
34-
* The settings + storage service plugins, so the booted kernel carries
35-
* `sys_file`/`sys_migration` and the deployment's REAL storage adapter.
36-
* Settings first: the storage plugin re-resolves its adapter from persisted
37-
* settings when a settings service is present, which is how an S3-configured
38-
* deployment's backfill uploads land in S3 rather than on this machine.
39-
* Storage config goes through the SAME resolver `os serve` uses
40-
* (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly where
41-
* the server would. It did not, and that mattered here more than anywhere: this
42-
* command reconciles what records claim against what storage actually holds, so
43-
* a root that disagrees with the server's reconciles against the wrong tree.
44-
* It built `{ driver: 'local', root }` — keys `StorageServicePluginOptions` does
45-
* not declare — so the adapter silently used the plugin's own `./storage`
46-
* default while the server (since framework#4096) writes under
47-
* `.objectstack/data/uploads`.
48-
*/
49-
async function buildDataMigrationPlugins(): Promise<unknown[]> {
50-
const plugins: unknown[] = [];
51-
try {
52-
const { SettingsServicePlugin } = await import('@objectstack/service-settings');
53-
plugins.push(new SettingsServicePlugin({ registerRoutes: false }));
54-
} catch {
55-
// optional — without it, constructor/env-driven storage config still applies
56-
}
57-
const { StorageServicePlugin } = await import('@objectstack/service-storage');
58-
const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
59-
plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false }));
60-
return plugins;
61-
}
62-
6333
/**
6434
* `os migrate files-to-references` — the ADR-0104 D3 data migration, with its
6535
* self-check gate (#3617).

0 commit comments

Comments
 (0)