Skip to content

Commit bbbac0f

Browse files
hotlongclaude
andauthored
fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses (#14517)
* fix(objectql): keep constraint + value on a state_machine refusal that has an authored message `checkStateMachine` emitted the full field-error envelope only when the rule left its `message` empty; declaring one dropped `constraint` and `value`. The spec REQUIRES `message` on every rule, so the machine-readable half was reachable only via `message: ''` — a create form could not learn the legal `initialStates` without parsing the author's prose. The showcase's New Project wizard is the demo of that gap: it offered all five project statuses on create while the machine admits only `planned`, so four picks were dead ends answered three steps later — in English, because an authored message is emitted verbatim unless the bundle carries `objects.<o>._validations.<rule>.message` (#14253). - objectql: the authored branch carries `constraint` / `value` too. - showcase: drop `status` from the create wizard (the `default: true` option supplies the machine's own entry point), reword the rule message so it is honest for both refusal codes, and put it on the translation channel in `en` + `zh-CN`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(showcase): put every showcase_project rule message on the translation channel The New Project wizard can also trip `end_after_start` and `spent_within_budget` from its budget/schedule step, so translating only the status rule would have moved the single English sentence one step later rather than removed it. The pin is scoped to the object's whole rule set for the same reason. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1aba315 commit bbbac0f

7 files changed

Lines changed: 384 additions & 4 deletions

File tree

.changeset/chilled-eels-shave.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).
6+
7+
`checkStateMachine` emitted the full field-error envelope — `field`, `code`, `message`, `label`, `constraint`, `value` — when the rule left its message empty, but dropped `constraint` and `value` the moment the rule declared one. Since `ValidationRuleSchema` **requires** `message` on every rule, the machine-readable half was in practice reachable only by declaring `message: ''`: every normally-authored state machine refused writes with no way for a client to learn *which* states are legal.
8+
9+
A create form that wants to offer exactly the declared `initialStates`, or a detail page that wants to grey out illegal transitions, had to parse the author's prose or keep a second copy of the state machine.
10+
11+
Now both paths emit the same envelope:
12+
13+
- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
14+
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`
15+
16+
The author still owns the wording; only the facts beside it are restored. Nothing about which writes are refused changes, and `constraint` / `value` are already declared on `FieldValidationError` (mirroring `FieldErrorSchema`), so no consumer contract widens — REST ships the same `400 VALIDATION_FAILED` envelope with the fields it always declared.

examples/app-showcase/src/data/objects/project.object.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
135135
// `insert` in `events` is what makes the initialStates check run on create.
136136
events: ['insert', 'update'] as const,
137137
initialStates: ['planned'],
138-
message: 'Invalid project status transition.',
138+
// ONE authored sentence answers BOTH refusals this rule can raise —
139+
// `invalid_initial_state` on insert and `invalid_transition` on update —
140+
// because `authoredRuleMessage` resolves one key per RULE, not per code.
141+
// The old wording ("Invalid project status transition.") described only
142+
// the update half, so a create rejected for being born `active` was told
143+
// about a "transition" it had not attempted. It is translated at
144+
// `objects.showcase_project._validations.project_status_flow.message`
145+
// (#14253) — an authored message is emitted verbatim unless the bundle
146+
// carries that key, which is why this one used to be the single English
147+
// sentence on an otherwise zh-CN form.
148+
message: 'Projects start as Planned, and then move only along the declared status flow.',
139149
transitions: {
140150
planned: ['active', 'cancelled'],
141151
active: ['on_hold', 'completed', 'cancelled'],

examples/app-showcase/src/system/translations/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
3434
start_date: { label: 'Start Date' },
3535
end_date: { label: 'End Date' },
3636
},
37+
// An author-written `validations[].message` is emitted VERBATIM unless
38+
// the bundle carries it here (#14253) — the built-in field catalog's
39+
// own sentences have shipped zh-CN since #3957, so a rule that declares
40+
// its own message is the one way a refusal escapes the caller's
41+
// language. `project_status_flow` is the showcase's state machine and
42+
// the only refusal a visitor reliably triggers (the New Project wizard
43+
// used to offer four statuses the machine will not accept on create),
44+
// so it read as the single English sentence on a zh-CN form.
45+
// All FOUR of the object's rules, not just the state machines: the New
46+
// Project wizard can trip `end_after_start` and `spent_within_budget`
47+
// from its budget/schedule step, so translating only the status rule
48+
// would move the single English sentence one step later rather than
49+
// remove it.
50+
_validations: {
51+
project_status_flow: {
52+
message: 'Projects start as Planned, and then move only along the declared status flow.',
53+
},
54+
project_health_progression: {
55+
message: 'Health changed by more than one step — confirm this is intentional.',
56+
},
57+
end_after_start: {
58+
message: 'Target End Date must be on or after the Start Date.',
59+
},
60+
spent_within_budget: {
61+
message: 'Spend exceeds 120% of budget — escalate before continuing.',
62+
},
63+
},
3764
},
3865
showcase_task: {
3966
label: 'Task',
@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
270297
start_date: { label: '开始日期' },
271298
end_date: { label: '结束日期' },
272299
},
300+
// The zh-CN mirror of the `en` `_validations` block — see the note
301+
// there. Without these two keys the write path's own refusals arrive in
302+
// Chinese (built-in catalog, #3957) while these author-written ones
303+
// arrive in English, inside one error envelope.
304+
_validations: {
305+
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
306+
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
307+
end_after_start: { message: '结束日期不能早于开始日期。' },
308+
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
309+
},
273310
// `default` — the container's DEFAULT list. `defineView({ list })`
274311
// declares it without a `name`, and the composer therefore registers it
275312
// as `<object>.default`; `_views` keys are that bare runtime key

examples/app-showcase/src/ui/pages/new-project-wizard.page.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
66
* New Project Wizard — a multi-step (wizard) form surface. The showcase
77
* defines wizard/tabbed/split form view *types* but had no page that actually
88
* walks a user through a stepped create flow. This renders `object-form` with
9-
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
9+
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
1010
* indicator, over showcase_project.
11+
*
12+
* On `status`, and why it is not a step here, see the comment on `sections`.
1113
*/
1214
export const NewProjectWizardPage = definePage({
1315
name: 'showcase_new_project_wizard',
@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
2931
formType: 'wizard',
3032
showStepIndicator: true,
3133
title: 'Create a Project',
32-
description: 'A three-step wizard — basics, status, then budget & schedule.',
34+
description: 'A three-step wizard — basics, health, then budget & schedule.',
35+
// `status` is deliberately ABSENT from this create wizard.
36+
//
37+
// `showcase_project`'s `project_status_flow` state machine declares
38+
// `initialStates: ['planned']`, so `planned` is the only status a
39+
// project may be CREATED in — the other four are reachable only by
40+
// transition, after the record exists. The step offered all five
41+
// (a `select` renders its whole option list; nothing in page
42+
// metadata narrows it to the machine's entry points), so four of
43+
// them were dead ends: the wizard accepted the pick, walked the
44+
// author through a third step, and only then answered
45+
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
46+
// state machine must not demo a dead end.
47+
//
48+
// With the field omitted, the option marked `default: true`
49+
// (`planned`) supplies the value server-side — which is the same
50+
// entry point the machine declares, so the two cannot drift. A
51+
// one-option select would be the alternative and is strictly worse
52+
// UI: it asks a question with exactly one answer.
53+
//
54+
// The GENERAL fix — a create form deriving its allowed values from
55+
// the object's `stateMachine` — is a console (objectui) feature and
56+
// is deliberately not built here; this app must be correct without
57+
// it. `test/new-project-wizard-initial-status.test.ts` pins the
58+
// invariant against the REAL metadata, so widening `initialStates`
59+
// later re-opens the question instead of silently rotting.
3360
sections: [
3461
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
35-
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
62+
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
3663
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
3764
],
3865
// Without this, a successful submit left the filled step-3 form in
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14311] The New Project wizard may not offer a status the state machine
5+
* refuses on create.
6+
*
7+
* The wizard's second step listed `status`, and a `select` renders its whole
8+
* option list — all five project statuses. `project_status_flow` declares
9+
* `initialStates: ['planned']`, so four of those five were dead ends: the
10+
* wizard accepted the pick, walked the author through a third step, and only
11+
* then answered `400 VALIDATION_FAILED` from the create. A demo of
12+
* "state machine + wizard" that demos a dead end teaches the wrong thing.
13+
*
14+
* These tests read the REAL page and the REAL object rather than a copy of
15+
* either, so the invariant is checked against what the app actually ships:
16+
* widening `initialStates`, re-adding the field, or adding a status option
17+
* re-opens the question here instead of rotting silently.
18+
*
19+
* The last test is the end-to-end half, on the production harness (real
20+
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
21+
* now performs succeeds, the one it used to allow is refused, and the refusal
22+
* carries the field location and the legal initial states a form needs to act
23+
* on it. Asserting only "it throws" would pass against a rejection for any
24+
* other reason — including the `required` check, which is what a naive "just
25+
* drop the field" fix would have tripped.
26+
*/
27+
28+
import { describe, it, expect, afterEach } from 'vitest';
29+
import { ObjectQL } from '@objectstack/objectql';
30+
import { SqlDriver } from '@objectstack/driver-sql';
31+
32+
import { Account, Project } from '../src/data/objects/index.js';
33+
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
34+
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';
35+
36+
type Rule = {
37+
type?: string;
38+
name?: string;
39+
field?: string;
40+
initialStates?: string[];
41+
message?: string;
42+
};
43+
44+
const APP_ID = 'com.objectstack.showcase';
45+
const PACKAGE_ID = `app:${APP_ID}`;
46+
const ctx = { context: { userId: 'u_showcase', isSystem: true } };
47+
48+
const openEngines: ObjectQL[] = [];
49+
afterEach(async () => {
50+
while (openEngines.length) {
51+
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
52+
}
53+
});
54+
55+
/**
56+
* The showcase's real objects on a real engine — same wiring as
57+
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
58+
* REQUIRED lookup, so `Account` is registered too and a real row is created:
59+
* a rejection for a dangling reference would otherwise be indistinguishable
60+
* from the state-machine refusal this test is about.
61+
*/
62+
async function bootShowcase(): Promise<ObjectQL> {
63+
const driver = new SqlDriver({
64+
client: 'better-sqlite3',
65+
connection: { filename: ':memory:' },
66+
useNullAsDefault: true,
67+
});
68+
await driver.connect();
69+
70+
const engine = new ObjectQL();
71+
openEngines.push(engine);
72+
engine.registerDriver(driver as never, true);
73+
await engine.init();
74+
for (const def of [Account, Project]) {
75+
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
76+
}
77+
await engine.syncSchemas();
78+
return engine;
79+
}
80+
81+
/** The `project_status_flow` state machine, read off the real object. */
82+
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
83+
(r) => r?.type === 'state_machine' && r?.field === 'status',
84+
)!;
85+
86+
/** Every field the wizard's create form exposes, across all of its steps. */
87+
function wizardFields(): string[] {
88+
const regions = (NewProjectWizardPage as unknown as {
89+
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
90+
}).regions ?? [];
91+
const out: string[] = [];
92+
for (const region of regions) {
93+
for (const component of region.components ?? []) {
94+
if (component?.type !== 'object-form') continue;
95+
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
96+
for (const section of sections) out.push(...(section.fields ?? []));
97+
}
98+
}
99+
return out;
100+
}
101+
102+
/** The declared option values of a select field on the real object. */
103+
function optionValues(field: string): string[] {
104+
const def = (Project as unknown as {
105+
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
106+
}).fields?.[field];
107+
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
108+
}
109+
110+
describe('#14311 — the New Project wizard and the status state machine', () => {
111+
it('the premise: the object still constrains which status a project may be created in', () => {
112+
// If this ever stops holding, the rest of this file is asserting nothing.
113+
expect(statusRule?.name).toBe('project_status_flow');
114+
expect(statusRule?.initialStates).toEqual(['planned']);
115+
expect((statusRule as { events?: string[] }).events).toContain('insert');
116+
});
117+
118+
it('the wizard does not offer a status the machine refuses on create', () => {
119+
const offered = wizardFields();
120+
const initial = statusRule.initialStates ?? [];
121+
const refusable = optionValues('status').filter((v) => !initial.includes(v));
122+
123+
// More than one legal initial state would make a narrowed select the right
124+
// shape; with exactly one, the field must simply not be asked.
125+
expect(refusable.length).toBeGreaterThan(0);
126+
expect(initial).toHaveLength(1);
127+
expect(offered).not.toContain('status');
128+
});
129+
130+
it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
131+
// Omitting the field only works because the object DEFAULTS it, and only
132+
// stays correct because the default IS the declared initial state.
133+
const def = (Project as unknown as {
134+
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
135+
}).fields?.status;
136+
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
137+
expect(defaulted).toEqual(statusRule.initialStates);
138+
});
139+
140+
it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
141+
// An authored `validations[].message` is emitted VERBATIM unless the bundle
142+
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
143+
// whole object rather than to the status rule on purpose: this one wizard
144+
// can also trip `end_after_start` and `spent_within_budget` from its
145+
// budget/schedule step, so pinning only the status rule would let the single
146+
// English sentence move one step later instead of disappearing.
147+
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
148+
.filter((r) => typeof r?.name === 'string');
149+
expect(rules.length).toBeGreaterThan(1);
150+
151+
for (const rule of rules) {
152+
const name = rule.name!;
153+
for (const locale of ['en', 'zh-CN'] as const) {
154+
const entry = (ShowcaseTranslationBundle as any)[locale]
155+
?.objects?.showcase_project?._validations?.[name];
156+
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
157+
}
158+
// The zh-CN entry must actually BE Chinese — an English copy satisfies
159+
// "a key exists" while reproducing the defect exactly.
160+
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
161+
.objects.showcase_project._validations[name].message as string;
162+
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[-]/);
163+
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
164+
}
165+
});
166+
167+
it('creates with the wizard payload and refuses the status it used to offer', async () => {
168+
const engine = await bootShowcase();
169+
const account: any = await engine.insert(
170+
'showcase_account', { name: 'Northwind' }, ctx as never,
171+
);
172+
173+
// What the wizard now sends: no `status` at all.
174+
const created: any = await engine.insert(
175+
'showcase_project',
176+
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
177+
ctx as never,
178+
);
179+
expect(created.status).toBe('planned');
180+
181+
// What it used to let an author send from step 2.
182+
let thrown: any;
183+
try {
184+
await engine.insert(
185+
'showcase_project',
186+
{ name: 'Born active', account: String(account.id), status: 'active' },
187+
ctx as never,
188+
);
189+
} catch (e) { thrown = e; }
190+
191+
expect(thrown, 'expected the create to be refused').toBeDefined();
192+
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
193+
expect(thrown.code).toBe('VALIDATION_FAILED');
194+
const field = thrown.fields?.find((f: any) => f.field === 'status');
195+
// Field-located, so a multi-step form can jump to the step that owns it.
196+
expect(field, 'the refusal must name the field it is about').toBeDefined();
197+
expect(field.code).toBe('invalid_initial_state');
198+
// #14311 — the facts ride along with the AUTHORED message, so a form can
199+
// name the legal entry points without parsing the sentence.
200+
expect(field.constraint).toEqual({ allowed: 'planned' });
201+
expect(field.value).toBe('active');
202+
}, 30000);
203+
});

0 commit comments

Comments
 (0)