Skip to content

Commit 7f1a635

Browse files
os-zhuangclaude
andauthored
fix(lint,spec,objectql): resolve registry-injected system columns at author time (#5378) (#5904)
`buildFieldIndex` (validate-expressions.ts) and the `highlightFields` existence check (validate-semantic-roles.ts) resolved field references against the AUTHORED `fields` map only, so every registry-injected system column was invisible: `has(record.owner_id)` was hard-rejected as an unknown field and `highlightFields: ['owner_id']` warned that the column "is not a field on this object". The platform's own linter denied the platform's own contract, and apps escaped it by re-declaring system columns — hotcrm#548 declared `owner_id` on all 12 business objects for exactly this reason. Add `resolveInjectedSystemColumns()` (@objectstack/spec/data): a pure per-object derivation of WHICH system columns the platform provisions, and make the registry's `applySystemFields()` consume it. Same split #3786 established for the audit family — the spec declares which columns exist, the registry owns what each one looks like. `@objectstack/lint` (contract: depends on spec, never on a runtime) reads the same derivation, so the author-time verdict cannot disagree with the runtime that provisions the column. The union is CONDITIONAL per object, not a blanket allowance of every system name: `ownership: 'org' | 'none'` gets no `owner_id`, so `record.owner_id` there is still the error it should be; `tenancy.enabled: false` gets no `organization_id`; `systemFields: { audit: false }` gets no audit family; `systemFields: false` / `managedBy: 'better-auth'` get nothing but the driver-provisioned `id`. Injected names also join the "did you mean?" candidates. The type-soundness and null-guard indexes deliberately stay declared-only (reasons at each site): column types and nullability belong to the registry's definitions, and the null-guard index feeds a build-breaking verdict. Injection behaviour is unchanged — a new parity pin compares the derivation against the live injection pass across the full condition matrix. Fixes #5378 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 76766e0 commit 7f1a635

12 files changed

Lines changed: 810 additions & 50 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": patch
4+
"@objectstack/lint": patch
5+
---
6+
7+
fix(lint,spec,objectql): 编写期表达式与 `highlightFields` 校验识别注册表注入的系统列 (#5378)
8+
9+
平台在每个业务对象上注入 `owner_id` / `created_at` / `organization_id` 等系统列,
10+
文档也把 `ownership: 'user'` 写作 "injects reassignable owner_id"。但编写期的两处
11+
校验只读**作者声明的** `fields`,于是注入列一律当作不存在:
12+
13+
- `validate-expressions.ts``buildFieldIndex``has(record.owner_id)` 直接
14+
报错 `unknown field owner_id`;
15+
- `highlightFields` 存在性检查对 `['owner_id']` 发出 "is not a field on this
16+
object — it is silently skipped by every consumer"。
17+
18+
也就是平台自己的 linter 否认平台自己的契约。结果是应用被迫**重声明系统列**才能通过
19+
编写期校验:hotcrm#548 为此在全部 12 个业务对象上显式声明了 `owner_id`(6 个对象曾
20+
`highlightFields` 警告,`contact_welcome` 触发器的 `has(record.owner_id)` 被硬
21+
拒)。这正是本项目视为缺陷的形状:能力已声明(列确实注入且有文档),但执行层不认。
22+
23+
**权威来源只有一份。** 新增纯派生 `resolveInjectedSystemColumns()`
24+
(`@objectstack/spec/data`)回答"这个对象带哪些系统列",并由 registry 的
25+
`applySystemFields()` **消费**它——沿用 #3786 为审计字段族确立的分工:spec 声明
26+
**有哪些**列,registry 拥有**每列长什么样**。lint 通过同一派生取答案,因此编写期
27+
判断与运行时注入不可能不一致(`@objectstack/lint` 的包契约是"只依赖 spec,绝不依赖
28+
运行时",此前它根本无法读到权威)。两个消费面共用同一判定,不各写一份。
29+
30+
**并入是按对象有条件的**,不是无条件放行整张系统列名单:`ownership: 'org' | 'none'`
31+
的对象没有 `owner_id`,那里的 `record.owner_id` 仍然是真错误并继续报;
32+
`tenancy.enabled: false``organization_id`;`systemFields: { audit: false }`
33+
审计四列;`systemFields: false` / `managedBy: 'better-auth'` 什么都不注入(只剩驱动
34+
提供的主键 `id`)。真正拼错的字段照旧被拒,并且注入列现在也进入 "did you mean?" 候选
35+
(`record.ownerid` → 提示 `owner_id`)。
36+
37+
被解析的注入列在诊断与补全语义上与授权字段等同;类型健全性与 null-guard 两个索引
38+
**刻意**仍只读声明字段,原因写在各自注释里:列的 `type` 与可空性属于 registry 的列
39+
定义,在 lint 侧另立一份就是本次要消灭的第二份副本,而 null-guard 喂的是会中断构建的
40+
判定,擅自并入会让今天能构建的 stack 变红。
41+
42+
注入行为本身零改动:`applySystemFields` 的输出在全条件矩阵上逐列不变(新增 parity
43+
pin 用实跑注入代码比对)。已显式重声明系统列的应用不受影响——重声明仍然合法,
44+
examples 三个 app 的 `os validate` 输出改动前后完全一致。

packages/lint/src/system-fields.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,40 @@
3232
* genuinely does not have.
3333
*/
3434

35-
import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';
35+
import { FIELD_GROUP_SYSTEM_FIELDS, resolveInjectedSystemColumns } from '@objectstack/spec/data';
3636
import { SystemFieldName } from '@objectstack/spec/system';
3737

3838
/**
3939
* Registry-injected columns addressable at runtime without being authored in
4040
* `fields` — the union of the spec's two system-field declarations.
41+
*
42+
* OBJECT-INDEPENDENT by construction: it answers "could this name be a system
43+
* column anywhere", which is the right question for a rule that only ever needs
44+
* to NOT flag the name. A rule that must decide whether the column exists **on
45+
* one particular object** — because it resolves a reference, rather than
46+
* skipping one — wants {@link injectedColumnsFor} instead.
4147
*/
4248
export const SYSTEM_FIELDS: ReadonlySet<string> = new Set<string>([
4349
...FIELD_GROUP_SYSTEM_FIELDS,
4450
...Object.values(SystemFieldName),
4551
]);
52+
53+
/**
54+
* The system columns addressable on ONE object without being authored (#5378).
55+
*
56+
* Delegates to the spec's `resolveInjectedSystemColumns` — the same derivation
57+
* the registry's `applySystemFields` consumes to decide what it injects — so an
58+
* author-time verdict about a column's existence cannot disagree with the
59+
* runtime that provisions it. ⛔ Never hand-copy the conditions here: a second
60+
* copy of "does `ownership: 'none'` get `owner_id`?" is the drift this indirection
61+
* exists to prevent, and it drifts silently (the wrong answer is a FALSE
62+
* diagnostic on valid metadata, or silence on a genuinely missing column).
63+
*
64+
* Use this — not {@link SYSTEM_FIELDS} — wherever the rule RESOLVES a field
65+
* reference. The two differ exactly where it matters: on `ownership: 'none'`
66+
* the platform injects no `owner_id`, so `record.owner_id` there is a real
67+
* defect that must still be reported.
68+
*/
69+
export function injectedColumnsFor(objectDef: unknown): ReadonlySet<string> {
70+
return resolveInjectedSystemColumns(objectDef).names;
71+
}

packages/lint/src/validate-expressions.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1887,3 +1887,134 @@ describe('validateStackExpressions — the field-formula check now actually runs
18871887
expect(keysReadOff('f').filter((k) => !declared.includes(k))).toEqual([]);
18881888
});
18891889
});
1890+
1891+
// ---------------------------------------------------------------------------
1892+
// [#5378] Registry-injected system columns resolve like any other field.
1893+
//
1894+
// The defect this pins: `buildFieldIndex` read the AUTHORED `fields` map only,
1895+
// so every column the registry injects was invisible and any predicate touching
1896+
// one was hard-rejected — the platform's linter denying the platform's own
1897+
// contract. Apps could only escape by re-declaring system columns (hotcrm#548
1898+
// added `owner_id` to all 12 business objects for exactly this).
1899+
//
1900+
// The counter-examples matter as much as the fix: injection is CONDITIONAL, so
1901+
// the pass must keep rejecting an injected column on an object the registry does
1902+
// NOT inject it on. The conditions come from the spec derivation the registry
1903+
// itself consumes (`resolveInjectedSystemColumns`), never a list copied here.
1904+
// ---------------------------------------------------------------------------
1905+
describe('validateStackExpressions — injected system columns (#5378)', () => {
1906+
/** Injection-only: declares NO system column of its own. */
1907+
const injectionOnly = (extra: Record<string, unknown> = {}) => ({
1908+
name: 'crm_contact',
1909+
fields: { name: { type: 'text' }, email: { type: 'email' } },
1910+
...extra,
1911+
});
1912+
1913+
const withCondition = (object: Record<string, unknown>, condition: string) =>
1914+
validateStackExpressions({
1915+
objects: [object],
1916+
objectValidations: undefined,
1917+
flows: [{
1918+
name: 'contact_welcome',
1919+
nodes: [
1920+
{ id: 'start', type: 'start', config: { objectName: 'crm_contact', condition } },
1921+
],
1922+
edges: [],
1923+
}],
1924+
});
1925+
1926+
// The issue's acceptance criterion #1, verbatim.
1927+
it('accepts has(record.owner_id) on an object that does not declare owner_id', () => {
1928+
expect(withCondition(injectionOnly(), 'has(record.owner_id)')).toHaveLength(0);
1929+
});
1930+
1931+
it.each([
1932+
'has(record.created_at)',
1933+
'has(record.created_by)',
1934+
'has(record.updated_at)',
1935+
'has(record.updated_by)',
1936+
'has(record.organization_id)',
1937+
'has(record.owning_business_unit_id)',
1938+
// Driver-provisioned primary key — the same class, and the commonest of all.
1939+
'has(record.id)',
1940+
])('accepts %s on an injection-only object', (condition) => {
1941+
expect(withCondition(injectionOnly(), condition)).toHaveLength(0);
1942+
});
1943+
1944+
// ── Counter-examples: the pass must NOT become a blanket allowance ──
1945+
1946+
it("still rejects record.owner_id on ownership: 'none' (no owner column is injected)", () => {
1947+
const issues = withCondition(injectionOnly({ ownership: 'none' }), 'has(record.owner_id)');
1948+
expect(issues).toHaveLength(1);
1949+
expect(issues[0].message).toMatch(/unknown field `owner_id`/);
1950+
});
1951+
1952+
it("still rejects record.owner_id on ownership: 'org'", () => {
1953+
const issues = withCondition(injectionOnly({ ownership: 'org' }), 'has(record.owner_id)');
1954+
expect(issues).toHaveLength(1);
1955+
expect(issues[0].message).toMatch(/unknown field `owner_id`/);
1956+
});
1957+
1958+
it('still rejects record.organization_id when the object opts out of tenancy', () => {
1959+
const issues = withCondition(
1960+
injectionOnly({ tenancy: { enabled: false } }),
1961+
'has(record.organization_id)',
1962+
);
1963+
expect(issues).toHaveLength(1);
1964+
expect(issues[0].message).toMatch(/unknown field `organization_id`/);
1965+
});
1966+
1967+
it('still rejects an audit column when the object opts out of audit fields', () => {
1968+
const issues = withCondition(
1969+
injectionOnly({ systemFields: { audit: false } }),
1970+
'has(record.created_at)',
1971+
);
1972+
expect(issues).toHaveLength(1);
1973+
expect(issues[0].message).toMatch(/unknown field `created_at`/);
1974+
});
1975+
1976+
it('still rejects a genuinely unknown field on an injection-only object', () => {
1977+
const issues = withCondition(injectionOnly(), 'has(record.no_such_field)');
1978+
expect(issues).toHaveLength(1);
1979+
expect(issues[0].message).toMatch(/unknown field `no_such_field`/);
1980+
});
1981+
1982+
// The resolved names join the "did you mean?" candidates, so a typo'd system
1983+
// column now gets the same help an authored field always got.
1984+
it('suggests an injected column for a near-miss spelling', () => {
1985+
const issues = withCondition(injectionOnly(), 'has(record.ownerid)');
1986+
expect(issues).toHaveLength(1);
1987+
expect(issues[0].message).toMatch(/unknown field `ownerid`/);
1988+
expect(issues[0].message).toMatch(/did you mean `owner_id`/);
1989+
});
1990+
1991+
// A DECLARED system column keeps working — re-declaration stays legal, which
1992+
// is why an existing app that took the hotcrm#548 workaround sees no change.
1993+
it('accepts a declared owner_id exactly as before (re-declaration stays legal)', () => {
1994+
const declared = {
1995+
name: 'crm_contact',
1996+
fields: {
1997+
name: { type: 'text' },
1998+
owner_id: { type: 'lookup', reference: 'sys_user', system: true },
1999+
},
2000+
};
2001+
expect(withCondition(declared, 'has(record.owner_id)')).toHaveLength(0);
2002+
});
2003+
2004+
// Reverse verification, in the direction that is actually available here: the
2005+
// injected half is what carries the verdict, so removing it must turn the
2006+
// acceptance criterion RED while leaving the counter-examples untouched. Pinned
2007+
// by construction — an empty injected set is exactly pre-#5378 behaviour.
2008+
it('the injected half is load-bearing: an object whose plan injects nothing rejects every system column', () => {
2009+
// `systemFields: false` is the platform's own "inject nothing" switch, so it
2010+
// reproduces the old blindness on purpose — minus `id`, which is the
2011+
// driver's and survives the opt-out.
2012+
const optedOut = injectionOnly({ systemFields: false });
2013+
for (const condition of ['has(record.owner_id)', 'has(record.created_at)', 'has(record.organization_id)']) {
2014+
const issues = withCondition(optedOut, condition);
2015+
expect(issues, condition).toHaveLength(1);
2016+
expect(issues[0].message).toMatch(/unknown field/);
2017+
}
2018+
expect(withCondition(optedOut, 'has(record.id)')).toHaveLength(0);
2019+
});
2020+
});

packages/lint/src/validate-expressions.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import { validateExpression, collectCelRootIdentifiers } from '@objectstack/form
8080
import { collectFlowGraphs, resolveFlowNodeExpressions } from '@objectstack/spec/automation';
8181
import type { FlowNodeParsed } from '@objectstack/spec/automation';
8282

83+
import { injectedColumnsFor } from './system-fields.js';
8384
import { findUnguardedNullableOperands, nullGuardMessage } from './validate-null-guards.js';
8485
import type { NullGuardOutcome } from './validate-null-guards.js';
8586

@@ -106,7 +107,24 @@ function asArray(v: unknown): AnyRec[] {
106107
return [];
107108
}
108109

109-
/** object name → set of its field names, for schema-aware field checks. */
110+
/**
111+
* object name → set of its field names, for schema-aware field checks.
112+
*
113+
* Authored fields UNION the system columns the platform injects on that object
114+
* (#5378). Both halves are needed because the runtime resolves both: a predicate
115+
* saying `has(record.owner_id)` on an object that never declares `owner_id`
116+
* evaluates fine, because the registry provisioned the column — so rejecting it
117+
* here was the platform's own linter denying the platform's own contract, and it
118+
* pushed apps into re-declaring system columns purely to satisfy this pass
119+
* (hotcrm#548 declared `owner_id` on all 12 business objects for that reason).
120+
*
121+
* The injected half is CONDITIONAL, per object, via the spec derivation the
122+
* registry itself consumes — not a blanket allowance of every system name. That
123+
* distinction is the whole value: on `ownership: 'none'` the platform injects no
124+
* `owner_id`, so `record.owner_id` there stays the error it should be. A blanket
125+
* union would have traded a false positive for a false negative and called it
126+
* fixed.
127+
*/
110128
function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
111129
const idx = new Map<string, string[]>();
112130
for (const obj of objects) {
@@ -116,7 +134,10 @@ function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
116134
let names: string[] = [];
117135
if (Array.isArray(fields)) names = fields.map(f => (f as AnyRec).name).filter((n): n is string => typeof n === 'string');
118136
else if (fields && typeof fields === 'object') names = Object.keys(fields as AnyRec);
119-
idx.set(name, names);
137+
// Injected columns come second, de-duplicated by insertion order: a DECLARED
138+
// `owner_id` is the author's field (the registry lets it win), so the
139+
// authored spelling keeps its position in the "did you mean?" candidates.
140+
idx.set(name, [...new Set([...names, ...injectedColumnsFor(obj)])]);
120141
}
121142
return idx;
122143
}
@@ -125,6 +146,14 @@ function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
125146
* object name → (field name → field type), for the #1928 tier-4 type-soundness
126147
* check. Handles both `fields` shapes (array of `{name, type}` and name-keyed
127148
* map). Fields with a non-string `type` are simply omitted (treated as `dyn`).
149+
*
150+
* DECLARED fields only — deliberately NOT widened with the injected columns
151+
* {@link buildFieldIndex} resolves (#5378). Their TYPES are the registry's
152+
* (`AUDIT_FIELD_DEFS` and its siblings own what each column looks like; the spec
153+
* derivation owns only which ones exist), so typing them here would be the
154+
* hand-copied second table this change exists to avoid. Omitted ⇒ `dyn` ⇒ no
155+
* type-soundness verdict on an injected column, which is the safe direction: the
156+
* pass gains no false finding, it simply does not cover them yet.
128157
*/
129158
function buildFieldTypeIndex(objects: AnyRec[]): Map<string, Record<string, string>> {
130159
const idx = new Map<string, Record<string, string>>();
@@ -186,7 +215,18 @@ function isNullableField(def: AnyRec): boolean {
186215
return true;
187216
}
188217

189-
/** object name → set of field names that may hold `null` (#4763). */
218+
/**
219+
* object name → set of field names that may hold `null` (#4763).
220+
*
221+
* DECLARED fields only, like {@link buildFieldTypeIndex} and for a sharper
222+
* reason: this index feeds a BUILD-BREAKING verdict, so adding the injected
223+
* columns {@link buildFieldIndex} now resolves would turn `record.created_at`
224+
* comparisons into errors on stacks that ship today. Nullability there is also
225+
* genuinely per-column and not derivable from the existence plan — the driver
226+
* always populates `created_at`, while `owner_id` really can be NULL — so it
227+
* needs the registry's column definitions, not this module's guesses. Leaving
228+
* them out costs coverage, never a false error.
229+
*/
190230
function buildNullableFieldIndex(objects: AnyRec[]): Map<string, Set<string>> {
191231
const idx = new Map<string, Set<string>>();
192232
for (const obj of objects) {

0 commit comments

Comments
 (0)