Skip to content

Commit 9b9b70f

Browse files
refactor(rest)!: retire ExportFieldMeta's eight constraint keys — read by nothing since the import dry run's mirror retired (#6536) (#6821)
* refactor(rest)!: retire ExportFieldMeta's eight constraint keys (#6536) * test(rest): pin buildFieldMetaMap's exact key set (#6536) * docs(spec): ADR-0087 ledger entry + changeset for #6536 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e120a5a commit 9b9b70f

6 files changed

Lines changed: 235 additions & 37 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/rest": major
3+
"@objectstack/spec": minor
4+
---
5+
6+
refactor(rest)!: 按 ADR-0049 退役 `ExportFieldMeta` 的八个约束键 —— 唯一的读者已随导入 dry run 的镜像一起退役 (#6536)
7+
8+
**BREAKING.** `@objectstack/rest` 导出的 `ExportFieldMeta` 不再声明
9+
`required` / `system` / `readonly` / `hasDefault` / `min` / `max` /
10+
`minLength` / `maxLength``buildFieldMetaMap` 也不再计算它们。
11+
`ExportFieldMeta` 本身、以及全部展示类键(`name` / `type` / `label` /
12+
`options` / `reference` / `displayField` / `multiple`)原样保留。
13+
14+
这是一次**休眠代码清扫,不是缺陷修复** —— 今天没有任何用户会撞上它。
15+
16+
## 为什么这八个键留不住
17+
18+
它们只为一个消费者存在:导入 dry run 手抄的前置校验镜像
19+
`firstMissingRequiredField` / `firstConstraintViolation`,framework#3956)。
20+
#4633 ruling D 已经退役了那份镜像(PR #6532)—— dry run 改为通过
21+
`DataProtocol.validateData` 向引擎要判决,而引擎读的是对象自己的 schema。
22+
于是 `buildFieldMetaMap` 每次导入照算不误、却**没有任何代码再读**,正是
23+
ADR-0049 enforce-or-remove 针对的「已声明、无人读」形状。PR #6532 当时重写了
24+
注释、把键留在原地,并写明退役是一次独立的清扫 —— 本 PR 就是它承诺的那次。
25+
26+
关键在于:这八个键**从来不是事实来源**`buildFieldMetaMap(schema)` 是从调用方
27+
自己传进来的那个 `schema`**派生**出它们的,所以这张表只是把调用方手里已有的
28+
事实抄了第二份。约束词表旁边没有执行者,却和展示词表并排站着 —— 这恰恰是
29+
AI 生成的消费端最容易误当成契约的形状。
30+
31+
## 迁移:FROM → TO
32+
33+
只有一类代码受影响:直接调用 `buildFieldMetaMap`(或通过
34+
`prepareImportRequest` 拿到 `PreparedImport.metaMap`)并读取这八个键的外部消费者。
35+
仓内、以及 `objectui` 同级仓,逐键逐类型核查后**读者为零**
36+
37+
```ts
38+
// FROM
39+
const meta = buildFieldMetaMap(schema).get('amount');
40+
if (meta?.required && !meta.hasDefault) reject();
41+
if (meta?.max != null && value > meta.max) reject();
42+
43+
// TO —— 从你本来就持有的那个 schema 上读,也就是引擎读的同一份
44+
const field = schema.fields['amount'];
45+
if (field?.required && field.defaultValue == null) reject();
46+
if (field?.max != null && value > field.max) reject();
47+
```
48+
49+
一行版:**把读取点从派生副本移回 `schema.fields[name]`**
50+
51+
`hasDefault` 没有一对一的替代键 —— 它本身就是派生谓词
52+
`defaultValue != null`,镜像的是引擎 `applyFieldDefaults` 的判断
53+
`packages/objectql/src/engine.ts``if (f.defaultValue == null) continue;`)。
54+
那条事实仍然成立,只是它的权威出处一直在引擎里,不在这份副本里;所以请读
55+
`field.defaultValue` 并自己套用同一个 `!= null` 判断。
56+
57+
⚠️ **请对着一次真实运行验证,而不是只看 tsc 变绿**:这八个是**可选**键,挂在一个
58+
本身继续存在的接口上,所以 JS 消费者(或任何 `any` 类型的读取)升级后读到的是
59+
`undefined`,编译期一个字都不会说。TypeScript 消费者才会在读取处收到编译错误。
60+
61+
字段定义上的 `required` / `min` / `maxLength`**照旧完全可写、且照旧由引擎强制** ——
62+
本次没有任何可编写或已存储的元数据形状发生变化。
63+
64+
<!-- adr-0087: registered export-field-meta-constraints-retired -->

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,9 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh
392392
- **`action-descriptor-resume-authority-default-flip`**`automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)` → an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning
393393
- Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.
394394
- Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is a declaration nothing enforces (#5703), so an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is warned about by NEITHER channel — check those by hand against the same rule.
395+
- **`export-field-meta-constraints-retired`**`@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)` → the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift
396+
- Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred).
397+
- Done when: No code of yours reads any of the eight off a `buildFieldMetaMap` / `prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / `.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an `ExportFieldMeta`-typed value; each hit moves to the object schema you already passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, so an untyped or `any`-typed read compiles clean and silently becomes `undefined` — assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. Note `hasDefault` has no one-to-one replacement key: it was the derived predicate `defaultValue != null`, mirroring the engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply that same `!= null` test yourself.
395398

396399
---
397400

packages/rest/src/export-format.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
*/
99

1010
import { describe, it, expect } from 'vitest';
11-
import { toArgb, cellFontColor, exportContentDisposition, type ExportFieldMeta } from './export-format';
11+
import {
12+
toArgb,
13+
cellFontColor,
14+
exportContentDisposition,
15+
buildFieldMetaMap,
16+
type ExportFieldMeta,
17+
} from './export-format';
1218

1319
describe('exportContentDisposition', () => {
1420
const NOW = new Date(2026, 6, 14, 15, 30, 45); // 2026-07-14 15:30:45 local
@@ -97,3 +103,75 @@ describe('cellFontColor', () => {
97103
expect(cellFontColor('high', multi)).toBeUndefined();
98104
});
99105
});
106+
107+
/**
108+
* `buildFieldMetaMap` builds PRESENTATION metadata only (#6536).
109+
*
110+
* The eight constraint keys (`required` / `system` / `readonly` / `hasDefault` /
111+
* `min` / `max` / `minLength` / `maxLength`) were retired under ADR-0049 once
112+
* #4633 ruling D (PR #6532) replaced the import dry run's hand-copied pre-check
113+
* mirror with `DataProtocol.validateData` — the engine reads the object's own
114+
* schema, so nothing consulted the copies any more.
115+
*
116+
* WHY THE ASSERTION IS AN EXACT KEY SET, and not eight `not.toHaveProperty`
117+
* calls: a removal is only observable as ABSENCE, and absence has no natural
118+
* red. Pinning the whole set is what gives this test a direction — restore any
119+
* retired key to the builder and it goes red on an unexpected key, drop a
120+
* surviving presentation key and it goes red on a missing one. Written as
121+
* per-key absence checks it could only ever catch the first of those, and it
122+
* would stay green against a NINTH constraint key added later, which is exactly
123+
* the drift ADR-0049 is about.
124+
*
125+
* Note these are the keys `buildFieldMetaMap` WRITES, so every one is present
126+
* on every entry even when its value is `undefined` — the builder assigns each
127+
* unconditionally rather than omitting it.
128+
*/
129+
describe('buildFieldMetaMap — presentation keys only (#6536)', () => {
130+
const PRESENTATION_KEYS = [
131+
'displayField', 'label', 'multiple', 'name', 'options', 'reference', 'type',
132+
];
133+
134+
/** One field declaring every retired constraint key alongside the presentation ones. */
135+
const FIELD = {
136+
name: 'amount',
137+
type: 'number',
138+
label: '金额',
139+
reference: 'contracts',
140+
displayField: 'title',
141+
multiple: false,
142+
// The eight retired keys — still legal on a field definition, since the
143+
// ENGINE reads them off the object schema. They must not travel into the
144+
// export/import metadata copy.
145+
required: true,
146+
system: true,
147+
readonly: true,
148+
defaultValue: 0, // the input `hasDefault` used to be derived from
149+
min: 1,
150+
max: 99,
151+
minLength: 2,
152+
maxLength: 20,
153+
};
154+
155+
it('stores exactly the presentation keys — object-map `fields` shape', () => {
156+
const meta = buildFieldMetaMap({ fields: { amount: FIELD } }).get('amount')!;
157+
expect(Object.keys(meta).sort()).toEqual(PRESENTATION_KEYS);
158+
});
159+
160+
it('stores exactly the presentation keys — array `fields` shape', () => {
161+
const meta = buildFieldMetaMap({ fields: [FIELD] }).get('amount')!;
162+
expect(Object.keys(meta).sort()).toEqual(PRESENTATION_KEYS);
163+
});
164+
165+
it('still carries the presentation values it is built for', () => {
166+
const meta = buildFieldMetaMap({ fields: { amount: FIELD } }).get('amount')!;
167+
expect(meta).toEqual({
168+
name: 'amount',
169+
type: 'number',
170+
label: '金额',
171+
options: undefined,
172+
reference: 'contracts',
173+
displayField: 'title',
174+
multiple: false,
175+
});
176+
});
177+
});

packages/rest/src/export-format.ts

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,32 +24,25 @@ export interface ExportFieldMeta {
2424
displayField?: string;
2525
/** Field holds multiple values (an array), e.g. a `multiple: true` lookup. */
2626
multiple?: boolean;
27-
// ── constraint metadata, no longer read by the import path ──────────
27+
// Every key above is a PRESENTATION key: each one is read to turn a storage
28+
// value into a readable cell (or a readable cell back into a storage value).
2829
//
29-
// The eight keys below were added for the import dry run's hand-copied
30-
// pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`,
31-
// framework#3956). That mirror is retired: the dry run now asks the engine
32-
// for its verdict through `DataProtocol.validateData` (#4633 ruling D), which
33-
// reads the object's own schema — so nothing in this repo consults these any
34-
// more. Kept for now rather than removed in the same PR: `ExportFieldMeta` is
35-
// exported from `@objectstack/rest`, and their retirement is a separable
36-
// change with its own sweep.
37-
/** Field is required — a value (or default) must exist on insert. */
38-
required?: boolean;
39-
/** Engine-owned column the client never supplies (never required of import). */
40-
system?: boolean;
41-
/** Read-only column the client never supplies (never required of import). */
42-
readonly?: boolean;
43-
/** Field declares a `defaultValue` the engine applies on insert (satisfies required). */
44-
hasDefault?: boolean;
45-
/** Lower bound for numeric fields. */
46-
min?: number;
47-
/** Upper bound for numeric fields. */
48-
max?: number;
49-
/** Minimum character count for string fields. */
50-
minLength?: number;
51-
/** Maximum character count for string fields. */
52-
maxLength?: number;
30+
// ── retired: the eight constraint keys (#6536) ──────────────────────
31+
//
32+
// `required` / `system` / `readonly` / `hasDefault` / `min` / `max` /
33+
// `minLength` / `maxLength` used to sit here. They were added for the import
34+
// dry run's hand-copied pre-check mirror (`firstMissingRequiredField` /
35+
// `firstConstraintViolation`, framework#3956); #4633 ruling D retired that
36+
// mirror (PR #6532) — the dry run now asks the engine for its verdict through
37+
// `DataProtocol.validateData`, which reads the object's own schema. That left
38+
// all eight computed on every import and read by nothing, so ADR-0049
39+
// enforce-or-remove retires them rather than leaving a constraint vocabulary
40+
// standing next to the presentation one with no enforcer behind it.
41+
//
42+
// They were never a source of truth: `buildFieldMetaMap` derived each one
43+
// from the very `schema` its caller passed in, so a caller that wants a
44+
// field's constraints reads them off that schema (`fields[name].required`, …)
45+
// — the same place the engine reads them.
5346
}
5447

5548
/**
@@ -143,17 +136,6 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
143136
reference: typeof f.reference === 'string' ? f.reference : undefined,
144137
displayField: typeof f.displayField === 'string' ? f.displayField : undefined,
145138
multiple: f.multiple === true,
146-
required: f.required === true,
147-
system: f.system === true,
148-
readonly: f.readonly === true,
149-
// Mirror the engine's `applyFieldDefaults` gate (`f.defaultValue == null`
150-
// ⇒ no default): any non-null default — literal, expression object, or the
151-
// `current_user` token — counts as satisfying a required field.
152-
hasDefault: f.defaultValue != null,
153-
min: typeof f.min === 'number' ? f.min : undefined,
154-
max: typeof f.max === 'number' ? f.max : undefined,
155-
minLength: typeof f.minLength === 'number' ? f.minLength : undefined,
156-
maxLength: typeof f.maxLength === 'number' ? f.maxLength : undefined,
157139
});
158140
}
159141
return map;

0 commit comments

Comments
 (0)