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
64 changes: 64 additions & 0 deletions .changeset/export-field-meta-constraints-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@objectstack/rest": major
"@objectstack/spec": minor
---

refactor(rest)!: 按 ADR-0049 退役 `ExportFieldMeta` 的八个约束键 —— 唯一的读者已随导入 dry run 的镜像一起退役 (#6536)

**BREAKING.** `@objectstack/rest` 导出的 `ExportFieldMeta` 不再声明
`required` / `system` / `readonly` / `hasDefault` / `min` / `max` /
`minLength` / `maxLength`,`buildFieldMetaMap` 也不再计算它们。
`ExportFieldMeta` 本身、以及全部展示类键(`name` / `type` / `label` /
`options` / `reference` / `displayField` / `multiple`)原样保留。

这是一次**休眠代码清扫,不是缺陷修复** —— 今天没有任何用户会撞上它。

## 为什么这八个键留不住

它们只为一个消费者存在:导入 dry run 手抄的前置校验镜像
(`firstMissingRequiredField` / `firstConstraintViolation`,framework#3956)。
#4633 ruling D 已经退役了那份镜像(PR #6532)—— dry run 改为通过
`DataProtocol.validateData` 向引擎要判决,而引擎读的是对象自己的 schema。
于是 `buildFieldMetaMap` 每次导入照算不误、却**没有任何代码再读**,正是
ADR-0049 enforce-or-remove 针对的「已声明、无人读」形状。PR #6532 当时重写了
注释、把键留在原地,并写明退役是一次独立的清扫 —— 本 PR 就是它承诺的那次。

关键在于:这八个键**从来不是事实来源**。`buildFieldMetaMap(schema)` 是从调用方
自己传进来的那个 `schema` 上**派生**出它们的,所以这张表只是把调用方手里已有的
事实抄了第二份。约束词表旁边没有执行者,却和展示词表并排站着 —— 这恰恰是
AI 生成的消费端最容易误当成契约的形状。

## 迁移:FROM → TO

只有一类代码受影响:直接调用 `buildFieldMetaMap`(或通过
`prepareImportRequest` 拿到 `PreparedImport.metaMap`)并读取这八个键的外部消费者。
仓内、以及 `objectui` 同级仓,逐键逐类型核查后**读者为零**。

```ts
// FROM
const meta = buildFieldMetaMap(schema).get('amount');
if (meta?.required && !meta.hasDefault) reject();
if (meta?.max != null && value > meta.max) reject();

// TO —— 从你本来就持有的那个 schema 上读,也就是引擎读的同一份
const field = schema.fields['amount'];
if (field?.required && field.defaultValue == null) reject();
if (field?.max != null && value > field.max) reject();
```

一行版:**把读取点从派生副本移回 `schema.fields[name]`。**

`hasDefault` 没有一对一的替代键 —— 它本身就是派生谓词
`defaultValue != null`,镜像的是引擎 `applyFieldDefaults` 的判断
(`packages/objectql/src/engine.ts`,`if (f.defaultValue == null) continue;`)。
那条事实仍然成立,只是它的权威出处一直在引擎里,不在这份副本里;所以请读
`field.defaultValue` 并自己套用同一个 `!= null` 判断。

⚠️ **请对着一次真实运行验证,而不是只看 tsc 变绿**:这八个是**可选**键,挂在一个
本身继续存在的接口上,所以 JS 消费者(或任何 `any` 类型的读取)升级后读到的是
`undefined`,编译期一个字都不会说。TypeScript 消费者才会在读取处收到编译错误。

字段定义上的 `required` / `min` / `maxLength` 等**照旧完全可写、且照旧由引擎强制** ——
本次没有任何可编写或已存储的元数据形状发生变化。

<!-- adr-0087: registered export-field-meta-constraints-retired -->
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,9 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh
- **`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
- 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.
- 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.
- **`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
- 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).
- 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.

---

Expand Down
80 changes: 79 additions & 1 deletion packages/rest/src/export-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
*/

import { describe, it, expect } from 'vitest';
import { toArgb, cellFontColor, exportContentDisposition, type ExportFieldMeta } from './export-format';
import {
toArgb,
cellFontColor,
exportContentDisposition,
buildFieldMetaMap,
type ExportFieldMeta,
} from './export-format';

describe('exportContentDisposition', () => {
const NOW = new Date(2026, 6, 14, 15, 30, 45); // 2026-07-14 15:30:45 local
Expand Down Expand Up @@ -97,3 +103,75 @@ describe('cellFontColor', () => {
expect(cellFontColor('high', multi)).toBeUndefined();
});
});

/**
* `buildFieldMetaMap` builds PRESENTATION metadata only (#6536).
*
* The eight constraint keys (`required` / `system` / `readonly` / `hasDefault` /
* `min` / `max` / `minLength` / `maxLength`) were retired under ADR-0049 once
* #4633 ruling D (PR #6532) replaced the import dry run's hand-copied pre-check
* mirror with `DataProtocol.validateData` — the engine reads the object's own
* schema, so nothing consulted the copies any more.
*
* WHY THE ASSERTION IS AN EXACT KEY SET, and not eight `not.toHaveProperty`
* calls: a removal is only observable as ABSENCE, and absence has no natural
* red. Pinning the whole set is what gives this test a direction — restore any
* retired key to the builder and it goes red on an unexpected key, drop a
* surviving presentation key and it goes red on a missing one. Written as
* per-key absence checks it could only ever catch the first of those, and it
* would stay green against a NINTH constraint key added later, which is exactly
* the drift ADR-0049 is about.
*
* Note these are the keys `buildFieldMetaMap` WRITES, so every one is present
* on every entry even when its value is `undefined` — the builder assigns each
* unconditionally rather than omitting it.
*/
describe('buildFieldMetaMap — presentation keys only (#6536)', () => {
const PRESENTATION_KEYS = [
'displayField', 'label', 'multiple', 'name', 'options', 'reference', 'type',
];

/** One field declaring every retired constraint key alongside the presentation ones. */
const FIELD = {
name: 'amount',
type: 'number',
label: '金额',
reference: 'contracts',
displayField: 'title',
multiple: false,
// The eight retired keys — still legal on a field definition, since the
// ENGINE reads them off the object schema. They must not travel into the
// export/import metadata copy.
required: true,
system: true,
readonly: true,
defaultValue: 0, // the input `hasDefault` used to be derived from
min: 1,
max: 99,
minLength: 2,
maxLength: 20,
};

it('stores exactly the presentation keys — object-map `fields` shape', () => {
const meta = buildFieldMetaMap({ fields: { amount: FIELD } }).get('amount')!;
expect(Object.keys(meta).sort()).toEqual(PRESENTATION_KEYS);
});

it('stores exactly the presentation keys — array `fields` shape', () => {
const meta = buildFieldMetaMap({ fields: [FIELD] }).get('amount')!;
expect(Object.keys(meta).sort()).toEqual(PRESENTATION_KEYS);
});

it('still carries the presentation values it is built for', () => {
const meta = buildFieldMetaMap({ fields: { amount: FIELD } }).get('amount')!;
expect(meta).toEqual({
name: 'amount',
type: 'number',
label: '金额',
options: undefined,
reference: 'contracts',
displayField: 'title',
multiple: false,
});
});
});
54 changes: 18 additions & 36 deletions packages/rest/src/export-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,32 +24,25 @@ export interface ExportFieldMeta {
displayField?: string;
/** Field holds multiple values (an array), e.g. a `multiple: true` lookup. */
multiple?: boolean;
// ── constraint metadata, no longer read by the import path ──────────
// Every key above is a PRESENTATION key: each one is read to turn a storage
// value into a readable cell (or a readable cell back into a storage value).
//
// The eight keys below were added for the import dry run's hand-copied
// pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`,
// framework#3956). That mirror is retired: the dry run now asks the engine
// for its verdict through `DataProtocol.validateData` (#4633 ruling D), which
// reads the object's own schema — so nothing in this repo consults these any
// more. Kept for now rather than removed in the same PR: `ExportFieldMeta` is
// exported from `@objectstack/rest`, and their retirement is a separable
// change with its own sweep.
/** Field is required — a value (or default) must exist on insert. */
required?: boolean;
/** Engine-owned column the client never supplies (never required of import). */
system?: boolean;
/** Read-only column the client never supplies (never required of import). */
readonly?: boolean;
/** Field declares a `defaultValue` the engine applies on insert (satisfies required). */
hasDefault?: boolean;
/** Lower bound for numeric fields. */
min?: number;
/** Upper bound for numeric fields. */
max?: number;
/** Minimum character count for string fields. */
minLength?: number;
/** Maximum character count for string fields. */
maxLength?: number;
// ── retired: the eight constraint keys (#6536) ──────────────────────
//
// `required` / `system` / `readonly` / `hasDefault` / `min` / `max` /
// `minLength` / `maxLength` used to sit here. They were added for the import
// dry run's hand-copied pre-check mirror (`firstMissingRequiredField` /
// `firstConstraintViolation`, framework#3956); #4633 ruling D retired that
// mirror (PR #6532) — the dry run now asks the engine for its verdict through
// `DataProtocol.validateData`, which reads the object's own schema. That left
// all eight computed on every import and read by nothing, so ADR-0049
// enforce-or-remove retires them rather than leaving a constraint vocabulary
// standing next to the presentation one with no enforcer behind it.
//
// They were never a source of truth: `buildFieldMetaMap` derived each one
// from the very `schema` its caller passed in, so a caller that wants a
// field's constraints reads them off that schema (`fields[name].required`, …)
// — the same place the engine reads them.
}

/**
Expand Down Expand Up @@ -143,17 +136,6 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
reference: typeof f.reference === 'string' ? f.reference : undefined,
displayField: typeof f.displayField === 'string' ? f.displayField : undefined,
multiple: f.multiple === true,
required: f.required === true,
system: f.system === true,
readonly: f.readonly === true,
// Mirror the engine's `applyFieldDefaults` gate (`f.defaultValue == null`
// ⇒ no default): any non-null default — literal, expression object, or the
// `current_user` token — counts as satisfying a required field.
hasDefault: f.defaultValue != null,
min: typeof f.min === 'number' ? f.min : undefined,
max: typeof f.max === 'number' ? f.max : undefined,
minLength: typeof f.minLength === 'number' ? f.minLength : undefined,
maxLength: typeof f.maxLength === 'number' ? f.maxLength : undefined,
});
}
return map;
Expand Down
Loading
Loading