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
16 changes: 16 additions & 0 deletions .changeset/chilled-eels-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).

`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.

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.

Now both paths emit the same envelope:

- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`

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.
12 changes: 11 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
// ONE authored sentence answers BOTH refusals this rule can raise —
// `invalid_initial_state` on insert and `invalid_transition` on update —
// because `authoredRuleMessage` resolves one key per RULE, not per code.
// The old wording ("Invalid project status transition.") described only
// the update half, so a create rejected for being born `active` was told
// about a "transition" it had not attempted. It is translated at
// `objects.showcase_project._validations.project_status_flow.message`
// (#14253) — an authored message is emitted verbatim unless the bundle
// carries that key, which is why this one used to be the single English
// sentence on an otherwise zh-CN form.
message: 'Projects start as Planned, and then move only along the declared status flow.',
transitions: {
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — the built-in field catalog's
// own sentences have shipped zh-CN since #3957, so a rule that declares
// its own message is the one way a refusal escapes the caller's
// language. `project_status_flow` is the showcase's state machine and
// the only refusal a visitor reliably triggers (the New Project wizard
// used to offer four statuses the machine will not accept on create),
// so it read as the single English sentence on a zh-CN form.
// All FOUR of the object's rules, not just the state machines: the New
// Project wizard can trip `end_after_start` and `spent_within_budget`
// from its budget/schedule step, so translating only the status rule
// would move the single English sentence one step later rather than
// remove it.
_validations: {
project_status_flow: {
message: 'Projects start as Planned, and then move only along the declared status flow.',
},
project_health_progression: {
message: 'Health changed by more than one step — confirm this is intentional.',
},
end_after_start: {
message: 'Target End Date must be on or after the Start Date.',
},
spent_within_budget: {
message: 'Spend exceeds 120% of budget — escalate before continuing.',
},
},
},
showcase_task: {
label: 'Task',
Expand DownExpand Up@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Without these two keys the write path's own refusals arrive in
// Chinese (built-in catalog, #3957) while these author-written ones
// arrive in English, inside one error envelope.
_validations: {
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
end_after_start: { message: '结束日期不能早于开始日期。' },
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
},
// `default` — the container's DEFAULT list. `defineView({ list })`
// declares it without a `name`, and the composer therefore registers it
// as `<object>.default`; `_views` keys are that bare runtime key
Expand Down
33 changes: 30 additions & 3 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
* New Project Wizard — a multi-step (wizard) form surface. The showcase
* defines wizard/tabbed/split form view *types* but had no page that actually
* walks a user through a stepped create flow. This renders `object-form` with
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
* indicator, over showcase_project.
*
* On `status`, and why it is not a step here, see the comment on `sections`.
*/
export const NewProjectWizardPage = definePage({
name: 'showcase_new_project_wizard',
Expand All@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
formType: 'wizard',
showStepIndicator: true,
title: 'Create a Project',
description: 'A three-step wizard — basics, status, then budget & schedule.',
description: 'A three-step wizard — basics, health, then budget & schedule.',
// `status` is deliberately ABSENT from this create wizard.
//
// `showcase_project`'s `project_status_flow` state machine declares
// `initialStates: ['planned']`, so `planned` is the only status a
// project may be CREATED in — the other four are reachable only by
// transition, after the record exists. The step offered all five
// (a `select` renders its whole option list; nothing in page
// metadata narrows it to the machine's entry points), so four of
// them were dead ends: the wizard accepted the pick, walked the
// author through a third step, and only then answered
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
// state machine must not demo a dead end.
//
// With the field omitted, the option marked `default: true`
// (`planned`) supplies the value server-side — which is the same
// entry point the machine declares, so the two cannot drift. A
// one-option select would be the alternative and is strictly worse
// UI: it asks a question with exactly one answer.
//
// The GENERAL fix — a create form deriving its allowed values from
// the object's `stateMachine` — is a console (objectui) feature and
// is deliberately not built here; this app must be correct without
// it. `test/new-project-wizard-initial-status.test.ts` pins the
// invariant against the REAL metadata, so widening `initialStates`
// later re-opens the question instead of silently rotting.
sections: [
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
],
// Without this, a successful submit left the filled step-3 form in
Expand Down
203 changes: 203 additions & 0 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14311] The New Project wizard may not offer a status the state machine
* refuses on create.
*
* The wizard's second step listed `status`, and a `select` renders its whole
* option list — all five project statuses. `project_status_flow` declares
* `initialStates: ['planned']`, so four of those five were dead ends: the
* wizard accepted the pick, walked the author through a third step, and only
* then answered `400 VALIDATION_FAILED` from the create. A demo of
* "state machine + wizard" that demos a dead end teaches the wrong thing.
*
* These tests read the REAL page and the REAL object rather than a copy of
* either, so the invariant is checked against what the app actually ships:
* widening `initialStates`, re-adding the field, or adding a status option
* re-opens the question here instead of rotting silently.
*
* The last test is the end-to-end half, on the production harness (real
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
* now performs succeeds, the one it used to allow is refused, and the refusal
* carries the field location and the legal initial states a form needs to act
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
};

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;
const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

/**
* The showcase's real objects on a real engine — same wiring as
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
* REQUIRED lookup, so `Account` is registered too and a real row is created:
* a rejection for a dangling reference would otherwise be indistinguishable
* from the state-machine refusal this test is about.
*/
async function bootShowcase(): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
return engine;
}

/** The `project_status_flow` state machine, read off the real object. */
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
(r) => r?.type === 'state_machine' && r?.field === 'status',
)!;

/** Every field the wizard's create form exposes, across all of its steps. */
function wizardFields(): string[] {
const regions = (NewProjectWizardPage as unknown as {
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
}).regions ?? [];
const out: string[] = [];
for (const region of regions) {
for (const component of region.components ?? []) {
if (component?.type !== 'object-form') continue;
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
for (const section of sections) out.push(...(section.fields ?? []));
}
}
return out;
}

/** The declared option values of a select field on the real object. */
function optionValues(field: string): string[] {
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
}).fields?.[field];
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
}

describe('#14311 — the New Project wizard and the status state machine', () => {
it('the premise: the object still constrains which status a project may be created in', () => {
// If this ever stops holding, the rest of this file is asserting nothing.
expect(statusRule?.name).toBe('project_status_flow');
expect(statusRule?.initialStates).toEqual(['planned']);
expect((statusRule as { events?: string[] }).events).toContain('insert');
});

it('the wizard does not offer a status the machine refuses on create', () => {
const offered = wizardFields();
const initial = statusRule.initialStates ?? [];
const refusable = optionValues('status').filter((v) => !initial.includes(v));

// More than one legal initial state would make a narrowed select the right
// shape; with exactly one, the field must simply not be asked.
expect(refusable.length).toBeGreaterThan(0);
expect(initial).toHaveLength(1);
expect(offered).not.toContain('status');
});

it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
// Omitting the field only works because the object DEFAULTS it, and only
// stays correct because the default IS the declared initial state.
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
}).fields?.status;
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
'showcase_account', { name: 'Northwind' }, ctx as never,
);

// What the wizard now sends: no `status` at all.
const created: any = await engine.insert(
'showcase_project',
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
ctx as never,
);
expect(created.status).toBe('planned');

// What it used to let an author send from step 2.
let thrown: any;
try {
await engine.insert(
'showcase_project',
{ name: 'Born active', account: String(account.id), status: 'active' },
ctx as never,
);
} catch (e) { thrown = e; }

expect(thrown, 'expected the create to be refused').toBeDefined();
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
expect(thrown.code).toBe('VALIDATION_FAILED');
const field = thrown.fields?.find((f: any) => f.field === 'status');
// Field-located, so a multi-step form can jump to the step that owns it.
expect(field, 'the refusal must name the field it is about').toBeDefined();
expect(field.code).toBe('invalid_initial_state');
// #14311 — the facts ride along with the AUTHORED message, so a form can
// name the legal entry points without parsing the sentence.
expect(field.constraint).toEqual({ allowed: 'planned' });
expect(field.value).toBe('active');
}, 30000);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
16 changes: 16 additions & 0 deletions .changeset/chilled-eels-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).

`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.

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.

Now both paths emit the same envelope:

- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`

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.
12 changes: 11 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
// ONE authored sentence answers BOTH refusals this rule can raise —
// `invalid_initial_state` on insert and `invalid_transition` on update —
// because `authoredRuleMessage` resolves one key per RULE, not per code.
// The old wording ("Invalid project status transition.") described only
// the update half, so a create rejected for being born `active` was told
// about a "transition" it had not attempted. It is translated at
// `objects.showcase_project._validations.project_status_flow.message`
// (#14253) — an authored message is emitted verbatim unless the bundle
// carries that key, which is why this one used to be the single English
// sentence on an otherwise zh-CN form.
message: 'Projects start as Planned, and then move only along the declared status flow.',
transitions: {
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — the built-in field catalog's
// own sentences have shipped zh-CN since #3957, so a rule that declares
// its own message is the one way a refusal escapes the caller's
// language. `project_status_flow` is the showcase's state machine and
// the only refusal a visitor reliably triggers (the New Project wizard
// used to offer four statuses the machine will not accept on create),
// so it read as the single English sentence on a zh-CN form.
// All FOUR of the object's rules, not just the state machines: the New
// Project wizard can trip `end_after_start` and `spent_within_budget`
// from its budget/schedule step, so translating only the status rule
// would move the single English sentence one step later rather than
// remove it.
_validations: {
project_status_flow: {
message: 'Projects start as Planned, and then move only along the declared status flow.',
},
project_health_progression: {
message: 'Health changed by more than one step — confirm this is intentional.',
},
end_after_start: {
message: 'Target End Date must be on or after the Start Date.',
},
spent_within_budget: {
message: 'Spend exceeds 120% of budget — escalate before continuing.',
},
},
},
showcase_task: {
label: 'Task',
Expand DownExpand Up@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Without these two keys the write path's own refusals arrive in
// Chinese (built-in catalog, #3957) while these author-written ones
// arrive in English, inside one error envelope.
_validations: {
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
end_after_start: { message: '结束日期不能早于开始日期。' },
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
},
// `default` — the container's DEFAULT list. `defineView({ list })`
// declares it without a `name`, and the composer therefore registers it
// as `<object>.default`; `_views` keys are that bare runtime key
Expand Down
33 changes: 30 additions & 3 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
* New Project Wizard — a multi-step (wizard) form surface. The showcase
* defines wizard/tabbed/split form view *types* but had no page that actually
* walks a user through a stepped create flow. This renders `object-form` with
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
* indicator, over showcase_project.
*
* On `status`, and why it is not a step here, see the comment on `sections`.
*/
export const NewProjectWizardPage = definePage({
name: 'showcase_new_project_wizard',
Expand All@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
formType: 'wizard',
showStepIndicator: true,
title: 'Create a Project',
description: 'A three-step wizard — basics, status, then budget & schedule.',
description: 'A three-step wizard — basics, health, then budget & schedule.',
// `status` is deliberately ABSENT from this create wizard.
//
// `showcase_project`'s `project_status_flow` state machine declares
// `initialStates: ['planned']`, so `planned` is the only status a
// project may be CREATED in — the other four are reachable only by
// transition, after the record exists. The step offered all five
// (a `select` renders its whole option list; nothing in page
// metadata narrows it to the machine's entry points), so four of
// them were dead ends: the wizard accepted the pick, walked the
// author through a third step, and only then answered
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
// state machine must not demo a dead end.
//
// With the field omitted, the option marked `default: true`
// (`planned`) supplies the value server-side — which is the same
// entry point the machine declares, so the two cannot drift. A
// one-option select would be the alternative and is strictly worse
// UI: it asks a question with exactly one answer.
//
// The GENERAL fix — a create form deriving its allowed values from
// the object's `stateMachine` — is a console (objectui) feature and
// is deliberately not built here; this app must be correct without
// it. `test/new-project-wizard-initial-status.test.ts` pins the
// invariant against the REAL metadata, so widening `initialStates`
// later re-opens the question instead of silently rotting.
sections: [
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
],
// Without this, a successful submit left the filled step-3 form in
Expand Down
203 changes: 203 additions & 0 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14311] The New Project wizard may not offer a status the state machine
* refuses on create.
*
* The wizard's second step listed `status`, and a `select` renders its whole
* option list — all five project statuses. `project_status_flow` declares
* `initialStates: ['planned']`, so four of those five were dead ends: the
* wizard accepted the pick, walked the author through a third step, and only
* then answered `400 VALIDATION_FAILED` from the create. A demo of
* "state machine + wizard" that demos a dead end teaches the wrong thing.
*
* These tests read the REAL page and the REAL object rather than a copy of
* either, so the invariant is checked against what the app actually ships:
* widening `initialStates`, re-adding the field, or adding a status option
* re-opens the question here instead of rotting silently.
*
* The last test is the end-to-end half, on the production harness (real
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
* now performs succeeds, the one it used to allow is refused, and the refusal
* carries the field location and the legal initial states a form needs to act
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
};

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;
const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

/**
* The showcase's real objects on a real engine — same wiring as
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
* REQUIRED lookup, so `Account` is registered too and a real row is created:
* a rejection for a dangling reference would otherwise be indistinguishable
* from the state-machine refusal this test is about.
*/
async function bootShowcase(): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
return engine;
}

/** The `project_status_flow` state machine, read off the real object. */
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
(r) => r?.type === 'state_machine' && r?.field === 'status',
)!;

/** Every field the wizard's create form exposes, across all of its steps. */
function wizardFields(): string[] {
const regions = (NewProjectWizardPage as unknown as {
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
}).regions ?? [];
const out: string[] = [];
for (const region of regions) {
for (const component of region.components ?? []) {
if (component?.type !== 'object-form') continue;
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
for (const section of sections) out.push(...(section.fields ?? []));
}
}
return out;
}

/** The declared option values of a select field on the real object. */
function optionValues(field: string): string[] {
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
}).fields?.[field];
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
}

describe('#14311 — the New Project wizard and the status state machine', () => {
it('the premise: the object still constrains which status a project may be created in', () => {
// If this ever stops holding, the rest of this file is asserting nothing.
expect(statusRule?.name).toBe('project_status_flow');
expect(statusRule?.initialStates).toEqual(['planned']);
expect((statusRule as { events?: string[] }).events).toContain('insert');
});

it('the wizard does not offer a status the machine refuses on create', () => {
const offered = wizardFields();
const initial = statusRule.initialStates ?? [];
const refusable = optionValues('status').filter((v) => !initial.includes(v));

// More than one legal initial state would make a narrowed select the right
// shape; with exactly one, the field must simply not be asked.
expect(refusable.length).toBeGreaterThan(0);
expect(initial).toHaveLength(1);
expect(offered).not.toContain('status');
});

it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
// Omitting the field only works because the object DEFAULTS it, and only
// stays correct because the default IS the declared initial state.
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
}).fields?.status;
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
'showcase_account', { name: 'Northwind' }, ctx as never,
);

// What the wizard now sends: no `status` at all.
const created: any = await engine.insert(
'showcase_project',
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
ctx as never,
);
expect(created.status).toBe('planned');

// What it used to let an author send from step 2.
let thrown: any;
try {
await engine.insert(
'showcase_project',
{ name: 'Born active', account: String(account.id), status: 'active' },
ctx as never,
);
} catch (e) { thrown = e; }

expect(thrown, 'expected the create to be refused').toBeDefined();
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
expect(thrown.code).toBe('VALIDATION_FAILED');
const field = thrown.fields?.find((f: any) => f.field === 'status');
// Field-located, so a multi-step form can jump to the step that owns it.
expect(field, 'the refusal must name the field it is about').toBeDefined();
expect(field.code).toBe('invalid_initial_state');
// #14311 — the facts ride along with the AUTHORED message, so a form can
// name the legal entry points without parsing the sentence.
expect(field.constraint).toEqual({ allowed: 'planned' });
expect(field.value).toBe('active');
}, 30000);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 16 additions & 0 deletions .changeset/chilled-eels-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).

`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.

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.

Now both paths emit the same envelope:

- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`

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.
12 changes: 11 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
// ONE authored sentence answers BOTH refusals this rule can raise —
// `invalid_initial_state` on insert and `invalid_transition` on update —
// because `authoredRuleMessage` resolves one key per RULE, not per code.
// The old wording ("Invalid project status transition.") described only
// the update half, so a create rejected for being born `active` was told
// about a "transition" it had not attempted. It is translated at
// `objects.showcase_project._validations.project_status_flow.message`
// (#14253) — an authored message is emitted verbatim unless the bundle
// carries that key, which is why this one used to be the single English
// sentence on an otherwise zh-CN form.
message: 'Projects start as Planned, and then move only along the declared status flow.',
transitions: {
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — the built-in field catalog's
// own sentences have shipped zh-CN since #3957, so a rule that declares
// its own message is the one way a refusal escapes the caller's
// language. `project_status_flow` is the showcase's state machine and
// the only refusal a visitor reliably triggers (the New Project wizard
// used to offer four statuses the machine will not accept on create),
// so it read as the single English sentence on a zh-CN form.
// All FOUR of the object's rules, not just the state machines: the New
// Project wizard can trip `end_after_start` and `spent_within_budget`
// from its budget/schedule step, so translating only the status rule
// would move the single English sentence one step later rather than
// remove it.
_validations: {
project_status_flow: {
message: 'Projects start as Planned, and then move only along the declared status flow.',
},
project_health_progression: {
message: 'Health changed by more than one step — confirm this is intentional.',
},
end_after_start: {
message: 'Target End Date must be on or after the Start Date.',
},
spent_within_budget: {
message: 'Spend exceeds 120% of budget — escalate before continuing.',
},
},
},
showcase_task: {
label: 'Task',
Expand DownExpand Up@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Without these two keys the write path's own refusals arrive in
// Chinese (built-in catalog, #3957) while these author-written ones
// arrive in English, inside one error envelope.
_validations: {
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
end_after_start: { message: '结束日期不能早于开始日期。' },
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
},
// `default` — the container's DEFAULT list. `defineView({ list })`
// declares it without a `name`, and the composer therefore registers it
// as `<object>.default`; `_views` keys are that bare runtime key
Expand Down
33 changes: 30 additions & 3 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
* New Project Wizard — a multi-step (wizard) form surface. The showcase
* defines wizard/tabbed/split form view *types* but had no page that actually
* walks a user through a stepped create flow. This renders `object-form` with
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
* indicator, over showcase_project.
*
* On `status`, and why it is not a step here, see the comment on `sections`.
*/
export const NewProjectWizardPage = definePage({
name: 'showcase_new_project_wizard',
Expand All@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
formType: 'wizard',
showStepIndicator: true,
title: 'Create a Project',
description: 'A three-step wizard — basics, status, then budget & schedule.',
description: 'A three-step wizard — basics, health, then budget & schedule.',
// `status` is deliberately ABSENT from this create wizard.
//
// `showcase_project`'s `project_status_flow` state machine declares
// `initialStates: ['planned']`, so `planned` is the only status a
// project may be CREATED in — the other four are reachable only by
// transition, after the record exists. The step offered all five
// (a `select` renders its whole option list; nothing in page
// metadata narrows it to the machine's entry points), so four of
// them were dead ends: the wizard accepted the pick, walked the
// author through a third step, and only then answered
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
// state machine must not demo a dead end.
//
// With the field omitted, the option marked `default: true`
// (`planned`) supplies the value server-side — which is the same
// entry point the machine declares, so the two cannot drift. A
// one-option select would be the alternative and is strictly worse
// UI: it asks a question with exactly one answer.
//
// The GENERAL fix — a create form deriving its allowed values from
// the object's `stateMachine` — is a console (objectui) feature and
// is deliberately not built here; this app must be correct without
// it. `test/new-project-wizard-initial-status.test.ts` pins the
// invariant against the REAL metadata, so widening `initialStates`
// later re-opens the question instead of silently rotting.
sections: [
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
],
// Without this, a successful submit left the filled step-3 form in
Expand Down
203 changes: 203 additions & 0 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14311] The New Project wizard may not offer a status the state machine
* refuses on create.
*
* The wizard's second step listed `status`, and a `select` renders its whole
* option list — all five project statuses. `project_status_flow` declares
* `initialStates: ['planned']`, so four of those five were dead ends: the
* wizard accepted the pick, walked the author through a third step, and only
* then answered `400 VALIDATION_FAILED` from the create. A demo of
* "state machine + wizard" that demos a dead end teaches the wrong thing.
*
* These tests read the REAL page and the REAL object rather than a copy of
* either, so the invariant is checked against what the app actually ships:
* widening `initialStates`, re-adding the field, or adding a status option
* re-opens the question here instead of rotting silently.
*
* The last test is the end-to-end half, on the production harness (real
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
* now performs succeeds, the one it used to allow is refused, and the refusal
* carries the field location and the legal initial states a form needs to act
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
};

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;
const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

/**
* The showcase's real objects on a real engine — same wiring as
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
* REQUIRED lookup, so `Account` is registered too and a real row is created:
* a rejection for a dangling reference would otherwise be indistinguishable
* from the state-machine refusal this test is about.
*/
async function bootShowcase(): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
return engine;
}

/** The `project_status_flow` state machine, read off the real object. */
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
(r) => r?.type === 'state_machine' && r?.field === 'status',
)!;

/** Every field the wizard's create form exposes, across all of its steps. */
function wizardFields(): string[] {
const regions = (NewProjectWizardPage as unknown as {
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
}).regions ?? [];
const out: string[] = [];
for (const region of regions) {
for (const component of region.components ?? []) {
if (component?.type !== 'object-form') continue;
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
for (const section of sections) out.push(...(section.fields ?? []));
}
}
return out;
}

/** The declared option values of a select field on the real object. */
function optionValues(field: string): string[] {
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
}).fields?.[field];
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
}

describe('#14311 — the New Project wizard and the status state machine', () => {
it('the premise: the object still constrains which status a project may be created in', () => {
// If this ever stops holding, the rest of this file is asserting nothing.
expect(statusRule?.name).toBe('project_status_flow');
expect(statusRule?.initialStates).toEqual(['planned']);
expect((statusRule as { events?: string[] }).events).toContain('insert');
});

it('the wizard does not offer a status the machine refuses on create', () => {
const offered = wizardFields();
const initial = statusRule.initialStates ?? [];
const refusable = optionValues('status').filter((v) => !initial.includes(v));

// More than one legal initial state would make a narrowed select the right
// shape; with exactly one, the field must simply not be asked.
expect(refusable.length).toBeGreaterThan(0);
expect(initial).toHaveLength(1);
expect(offered).not.toContain('status');
});

it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
// Omitting the field only works because the object DEFAULTS it, and only
// stays correct because the default IS the declared initial state.
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
}).fields?.status;
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
'showcase_account', { name: 'Northwind' }, ctx as never,
);

// What the wizard now sends: no `status` at all.
const created: any = await engine.insert(
'showcase_project',
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
ctx as never,
);
expect(created.status).toBe('planned');

// What it used to let an author send from step 2.
let thrown: any;
try {
await engine.insert(
'showcase_project',
{ name: 'Born active', account: String(account.id), status: 'active' },
ctx as never,
);
} catch (e) { thrown = e; }

expect(thrown, 'expected the create to be refused').toBeDefined();
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
expect(thrown.code).toBe('VALIDATION_FAILED');
const field = thrown.fields?.find((f: any) => f.field === 'status');
// Field-located, so a multi-step form can jump to the step that owns it.
expect(field, 'the refusal must name the field it is about').toBeDefined();
expect(field.code).toBe('invalid_initial_state');
// #14311 — the facts ride along with the AUTHORED message, so a form can
// name the legal entry points without parsing the sentence.
expect(field.constraint).toEqual({ allowed: 'planned' });
expect(field.value).toBe('active');
}, 30000);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 16 additions & 0 deletions .changeset/chilled-eels-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).

`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.

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.

Now both paths emit the same envelope:

- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`

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.
12 changes: 11 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
// ONE authored sentence answers BOTH refusals this rule can raise —
// `invalid_initial_state` on insert and `invalid_transition` on update —
// because `authoredRuleMessage` resolves one key per RULE, not per code.
// The old wording ("Invalid project status transition.") described only
// the update half, so a create rejected for being born `active` was told
// about a "transition" it had not attempted. It is translated at
// `objects.showcase_project._validations.project_status_flow.message`
// (#14253) — an authored message is emitted verbatim unless the bundle
// carries that key, which is why this one used to be the single English
// sentence on an otherwise zh-CN form.
message: 'Projects start as Planned, and then move only along the declared status flow.',
transitions: {
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — the built-in field catalog's
// own sentences have shipped zh-CN since #3957, so a rule that declares
// its own message is the one way a refusal escapes the caller's
// language. `project_status_flow` is the showcase's state machine and
// the only refusal a visitor reliably triggers (the New Project wizard
// used to offer four statuses the machine will not accept on create),
// so it read as the single English sentence on a zh-CN form.
// All FOUR of the object's rules, not just the state machines: the New
// Project wizard can trip `end_after_start` and `spent_within_budget`
// from its budget/schedule step, so translating only the status rule
// would move the single English sentence one step later rather than
// remove it.
_validations: {
project_status_flow: {
message: 'Projects start as Planned, and then move only along the declared status flow.',
},
project_health_progression: {
message: 'Health changed by more than one step — confirm this is intentional.',
},
end_after_start: {
message: 'Target End Date must be on or after the Start Date.',
},
spent_within_budget: {
message: 'Spend exceeds 120% of budget — escalate before continuing.',
},
},
},
showcase_task: {
label: 'Task',
Expand DownExpand Up@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Without these two keys the write path's own refusals arrive in
// Chinese (built-in catalog, #3957) while these author-written ones
// arrive in English, inside one error envelope.
_validations: {
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
end_after_start: { message: '结束日期不能早于开始日期。' },
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
},
// `default` — the container's DEFAULT list. `defineView({ list })`
// declares it without a `name`, and the composer therefore registers it
// as `<object>.default`; `_views` keys are that bare runtime key
Expand Down
33 changes: 30 additions & 3 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
* New Project Wizard — a multi-step (wizard) form surface. The showcase
* defines wizard/tabbed/split form view *types* but had no page that actually
* walks a user through a stepped create flow. This renders `object-form` with
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
* indicator, over showcase_project.
*
* On `status`, and why it is not a step here, see the comment on `sections`.
*/
export const NewProjectWizardPage = definePage({
name: 'showcase_new_project_wizard',
Expand All@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
formType: 'wizard',
showStepIndicator: true,
title: 'Create a Project',
description: 'A three-step wizard — basics, status, then budget & schedule.',
description: 'A three-step wizard — basics, health, then budget & schedule.',
// `status` is deliberately ABSENT from this create wizard.
//
// `showcase_project`'s `project_status_flow` state machine declares
// `initialStates: ['planned']`, so `planned` is the only status a
// project may be CREATED in — the other four are reachable only by
// transition, after the record exists. The step offered all five
// (a `select` renders its whole option list; nothing in page
// metadata narrows it to the machine's entry points), so four of
// them were dead ends: the wizard accepted the pick, walked the
// author through a third step, and only then answered
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
// state machine must not demo a dead end.
//
// With the field omitted, the option marked `default: true`
// (`planned`) supplies the value server-side — which is the same
// entry point the machine declares, so the two cannot drift. A
// one-option select would be the alternative and is strictly worse
// UI: it asks a question with exactly one answer.
//
// The GENERAL fix — a create form deriving its allowed values from
// the object's `stateMachine` — is a console (objectui) feature and
// is deliberately not built here; this app must be correct without
// it. `test/new-project-wizard-initial-status.test.ts` pins the
// invariant against the REAL metadata, so widening `initialStates`
// later re-opens the question instead of silently rotting.
sections: [
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
],
// Without this, a successful submit left the filled step-3 form in
Expand Down
203 changes: 203 additions & 0 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14311] The New Project wizard may not offer a status the state machine
* refuses on create.
*
* The wizard's second step listed `status`, and a `select` renders its whole
* option list — all five project statuses. `project_status_flow` declares
* `initialStates: ['planned']`, so four of those five were dead ends: the
* wizard accepted the pick, walked the author through a third step, and only
* then answered `400 VALIDATION_FAILED` from the create. A demo of
* "state machine + wizard" that demos a dead end teaches the wrong thing.
*
* These tests read the REAL page and the REAL object rather than a copy of
* either, so the invariant is checked against what the app actually ships:
* widening `initialStates`, re-adding the field, or adding a status option
* re-opens the question here instead of rotting silently.
*
* The last test is the end-to-end half, on the production harness (real
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
* now performs succeeds, the one it used to allow is refused, and the refusal
* carries the field location and the legal initial states a form needs to act
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
};

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;
const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

/**
* The showcase's real objects on a real engine — same wiring as
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
* REQUIRED lookup, so `Account` is registered too and a real row is created:
* a rejection for a dangling reference would otherwise be indistinguishable
* from the state-machine refusal this test is about.
*/
async function bootShowcase(): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
return engine;
}

/** The `project_status_flow` state machine, read off the real object. */
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
(r) => r?.type === 'state_machine' && r?.field === 'status',
)!;

/** Every field the wizard's create form exposes, across all of its steps. */
function wizardFields(): string[] {
const regions = (NewProjectWizardPage as unknown as {
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
}).regions ?? [];
const out: string[] = [];
for (const region of regions) {
for (const component of region.components ?? []) {
if (component?.type !== 'object-form') continue;
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
for (const section of sections) out.push(...(section.fields ?? []));
}
}
return out;
}

/** The declared option values of a select field on the real object. */
function optionValues(field: string): string[] {
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
}).fields?.[field];
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
}

describe('#14311 — the New Project wizard and the status state machine', () => {
it('the premise: the object still constrains which status a project may be created in', () => {
// If this ever stops holding, the rest of this file is asserting nothing.
expect(statusRule?.name).toBe('project_status_flow');
expect(statusRule?.initialStates).toEqual(['planned']);
expect((statusRule as { events?: string[] }).events).toContain('insert');
});

it('the wizard does not offer a status the machine refuses on create', () => {
const offered = wizardFields();
const initial = statusRule.initialStates ?? [];
const refusable = optionValues('status').filter((v) => !initial.includes(v));

// More than one legal initial state would make a narrowed select the right
// shape; with exactly one, the field must simply not be asked.
expect(refusable.length).toBeGreaterThan(0);
expect(initial).toHaveLength(1);
expect(offered).not.toContain('status');
});

it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
// Omitting the field only works because the object DEFAULTS it, and only
// stays correct because the default IS the declared initial state.
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
}).fields?.status;
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
'showcase_account', { name: 'Northwind' }, ctx as never,
);

// What the wizard now sends: no `status` at all.
const created: any = await engine.insert(
'showcase_project',
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
ctx as never,
);
expect(created.status).toBe('planned');

// What it used to let an author send from step 2.
let thrown: any;
try {
await engine.insert(
'showcase_project',
{ name: 'Born active', account: String(account.id), status: 'active' },
ctx as never,
);
} catch (e) { thrown = e; }

expect(thrown, 'expected the create to be refused').toBeDefined();
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
expect(thrown.code).toBe('VALIDATION_FAILED');
const field = thrown.fields?.find((f: any) => f.field === 'status');
// Field-located, so a multi-step form can jump to the step that owns it.
expect(field, 'the refusal must name the field it is about').toBeDefined();
expect(field.code).toBe('invalid_initial_state');
// #14311 — the facts ride along with the AUTHORED message, so a form can
// name the legal entry points without parsing the sentence.
expect(field.constraint).toEqual({ allowed: 'planned' });
expect(field.value).toBe('active');
}, 30000);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
16 changes: 16 additions & 0 deletions .changeset/chilled-eels-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).

`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.

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.

Now both paths emit the same envelope:

- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`

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.
12 changes: 11 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
// ONE authored sentence answers BOTH refusals this rule can raise —
// `invalid_initial_state` on insert and `invalid_transition` on update —
// because `authoredRuleMessage` resolves one key per RULE, not per code.
// The old wording ("Invalid project status transition.") described only
// the update half, so a create rejected for being born `active` was told
// about a "transition" it had not attempted. It is translated at
// `objects.showcase_project._validations.project_status_flow.message`
// (#14253) — an authored message is emitted verbatim unless the bundle
// carries that key, which is why this one used to be the single English
// sentence on an otherwise zh-CN form.
message: 'Projects start as Planned, and then move only along the declared status flow.',
transitions: {
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — the built-in field catalog's
// own sentences have shipped zh-CN since #3957, so a rule that declares
// its own message is the one way a refusal escapes the caller's
// language. `project_status_flow` is the showcase's state machine and
// the only refusal a visitor reliably triggers (the New Project wizard
// used to offer four statuses the machine will not accept on create),
// so it read as the single English sentence on a zh-CN form.
// All FOUR of the object's rules, not just the state machines: the New
// Project wizard can trip `end_after_start` and `spent_within_budget`
// from its budget/schedule step, so translating only the status rule
// would move the single English sentence one step later rather than
// remove it.
_validations: {
project_status_flow: {
message: 'Projects start as Planned, and then move only along the declared status flow.',
},
project_health_progression: {
message: 'Health changed by more than one step — confirm this is intentional.',
},
end_after_start: {
message: 'Target End Date must be on or after the Start Date.',
},
spent_within_budget: {
message: 'Spend exceeds 120% of budget — escalate before continuing.',
},
},
},
showcase_task: {
label: 'Task',
Expand DownExpand Up@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Without these two keys the write path's own refusals arrive in
// Chinese (built-in catalog, #3957) while these author-written ones
// arrive in English, inside one error envelope.
_validations: {
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
end_after_start: { message: '结束日期不能早于开始日期。' },
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
},
// `default` — the container's DEFAULT list. `defineView({ list })`
// declares it without a `name`, and the composer therefore registers it
// as `<object>.default`; `_views` keys are that bare runtime key
Expand Down
33 changes: 30 additions & 3 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
* New Project Wizard — a multi-step (wizard) form surface. The showcase
* defines wizard/tabbed/split form view *types* but had no page that actually
* walks a user through a stepped create flow. This renders `object-form` with
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
* indicator, over showcase_project.
*
* On `status`, and why it is not a step here, see the comment on `sections`.
*/
export const NewProjectWizardPage = definePage({
name: 'showcase_new_project_wizard',
Expand All@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
formType: 'wizard',
showStepIndicator: true,
title: 'Create a Project',
description: 'A three-step wizard — basics, status, then budget & schedule.',
description: 'A three-step wizard — basics, health, then budget & schedule.',
// `status` is deliberately ABSENT from this create wizard.
//
// `showcase_project`'s `project_status_flow` state machine declares
// `initialStates: ['planned']`, so `planned` is the only status a
// project may be CREATED in — the other four are reachable only by
// transition, after the record exists. The step offered all five
// (a `select` renders its whole option list; nothing in page
// metadata narrows it to the machine's entry points), so four of
// them were dead ends: the wizard accepted the pick, walked the
// author through a third step, and only then answered
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
// state machine must not demo a dead end.
//
// With the field omitted, the option marked `default: true`
// (`planned`) supplies the value server-side — which is the same
// entry point the machine declares, so the two cannot drift. A
// one-option select would be the alternative and is strictly worse
// UI: it asks a question with exactly one answer.
//
// The GENERAL fix — a create form deriving its allowed values from
// the object's `stateMachine` — is a console (objectui) feature and
// is deliberately not built here; this app must be correct without
// it. `test/new-project-wizard-initial-status.test.ts` pins the
// invariant against the REAL metadata, so widening `initialStates`
// later re-opens the question instead of silently rotting.
sections: [
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
],
// Without this, a successful submit left the filled step-3 form in
Expand Down
203 changes: 203 additions & 0 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14311] The New Project wizard may not offer a status the state machine
* refuses on create.
*
* The wizard's second step listed `status`, and a `select` renders its whole
* option list — all five project statuses. `project_status_flow` declares
* `initialStates: ['planned']`, so four of those five were dead ends: the
* wizard accepted the pick, walked the author through a third step, and only
* then answered `400 VALIDATION_FAILED` from the create. A demo of
* "state machine + wizard" that demos a dead end teaches the wrong thing.
*
* These tests read the REAL page and the REAL object rather than a copy of
* either, so the invariant is checked against what the app actually ships:
* widening `initialStates`, re-adding the field, or adding a status option
* re-opens the question here instead of rotting silently.
*
* The last test is the end-to-end half, on the production harness (real
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
* now performs succeeds, the one it used to allow is refused, and the refusal
* carries the field location and the legal initial states a form needs to act
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
};

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;
const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

/**
* The showcase's real objects on a real engine — same wiring as
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
* REQUIRED lookup, so `Account` is registered too and a real row is created:
* a rejection for a dangling reference would otherwise be indistinguishable
* from the state-machine refusal this test is about.
*/
async function bootShowcase(): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
return engine;
}

/** The `project_status_flow` state machine, read off the real object. */
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
(r) => r?.type === 'state_machine' && r?.field === 'status',
)!;

/** Every field the wizard's create form exposes, across all of its steps. */
function wizardFields(): string[] {
const regions = (NewProjectWizardPage as unknown as {
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
}).regions ?? [];
const out: string[] = [];
for (const region of regions) {
for (const component of region.components ?? []) {
if (component?.type !== 'object-form') continue;
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
for (const section of sections) out.push(...(section.fields ?? []));
}
}
return out;
}

/** The declared option values of a select field on the real object. */
function optionValues(field: string): string[] {
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
}).fields?.[field];
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
}

describe('#14311 — the New Project wizard and the status state machine', () => {
it('the premise: the object still constrains which status a project may be created in', () => {
// If this ever stops holding, the rest of this file is asserting nothing.
expect(statusRule?.name).toBe('project_status_flow');
expect(statusRule?.initialStates).toEqual(['planned']);
expect((statusRule as { events?: string[] }).events).toContain('insert');
});

it('the wizard does not offer a status the machine refuses on create', () => {
const offered = wizardFields();
const initial = statusRule.initialStates ?? [];
const refusable = optionValues('status').filter((v) => !initial.includes(v));

// More than one legal initial state would make a narrowed select the right
// shape; with exactly one, the field must simply not be asked.
expect(refusable.length).toBeGreaterThan(0);
expect(initial).toHaveLength(1);
expect(offered).not.toContain('status');
});

it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
// Omitting the field only works because the object DEFAULTS it, and only
// stays correct because the default IS the declared initial state.
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
}).fields?.status;
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
'showcase_account', { name: 'Northwind' }, ctx as never,
);

// What the wizard now sends: no `status` at all.
const created: any = await engine.insert(
'showcase_project',
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
ctx as never,
);
expect(created.status).toBe('planned');

// What it used to let an author send from step 2.
let thrown: any;
try {
await engine.insert(
'showcase_project',
{ name: 'Born active', account: String(account.id), status: 'active' },
ctx as never,
);
} catch (e) { thrown = e; }

expect(thrown, 'expected the create to be refused').toBeDefined();
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
expect(thrown.code).toBe('VALIDATION_FAILED');
const field = thrown.fields?.find((f: any) => f.field === 'status');
// Field-located, so a multi-step form can jump to the step that owns it.
expect(field, 'the refusal must name the field it is about').toBeDefined();
expect(field.code).toBe('invalid_initial_state');
// #14311 — the facts ride along with the AUTHORED message, so a form can
// name the legal entry points without parsing the sentence.
expect(field.constraint).toEqual({ allowed: 'planned' });
expect(field.value).toBe('active');
}, 30000);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 16 additions & 0 deletions .changeset/chilled-eels-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).

`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.

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.

Now both paths emit the same envelope:

- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`

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.
12 changes: 11 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
// ONE authored sentence answers BOTH refusals this rule can raise —
// `invalid_initial_state` on insert and `invalid_transition` on update —
// because `authoredRuleMessage` resolves one key per RULE, not per code.
// The old wording ("Invalid project status transition.") described only
// the update half, so a create rejected for being born `active` was told
// about a "transition" it had not attempted. It is translated at
// `objects.showcase_project._validations.project_status_flow.message`
// (#14253) — an authored message is emitted verbatim unless the bundle
// carries that key, which is why this one used to be the single English
// sentence on an otherwise zh-CN form.
message: 'Projects start as Planned, and then move only along the declared status flow.',
transitions: {
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — the built-in field catalog's
// own sentences have shipped zh-CN since #3957, so a rule that declares
// its own message is the one way a refusal escapes the caller's
// language. `project_status_flow` is the showcase's state machine and
// the only refusal a visitor reliably triggers (the New Project wizard
// used to offer four statuses the machine will not accept on create),
// so it read as the single English sentence on a zh-CN form.
// All FOUR of the object's rules, not just the state machines: the New
// Project wizard can trip `end_after_start` and `spent_within_budget`
// from its budget/schedule step, so translating only the status rule
// would move the single English sentence one step later rather than
// remove it.
_validations: {
project_status_flow: {
message: 'Projects start as Planned, and then move only along the declared status flow.',
},
project_health_progression: {
message: 'Health changed by more than one step — confirm this is intentional.',
},
end_after_start: {
message: 'Target End Date must be on or after the Start Date.',
},
spent_within_budget: {
message: 'Spend exceeds 120% of budget — escalate before continuing.',
},
},
},
showcase_task: {
label: 'Task',
Expand DownExpand Up@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Without these two keys the write path's own refusals arrive in
// Chinese (built-in catalog, #3957) while these author-written ones
// arrive in English, inside one error envelope.
_validations: {
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
end_after_start: { message: '结束日期不能早于开始日期。' },
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
},
// `default` — the container's DEFAULT list. `defineView({ list })`
// declares it without a `name`, and the composer therefore registers it
// as `<object>.default`; `_views` keys are that bare runtime key
Expand Down
33 changes: 30 additions & 3 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
* New Project Wizard — a multi-step (wizard) form surface. The showcase
* defines wizard/tabbed/split form view *types* but had no page that actually
* walks a user through a stepped create flow. This renders `object-form` with
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
* indicator, over showcase_project.
*
* On `status`, and why it is not a step here, see the comment on `sections`.
*/
export const NewProjectWizardPage = definePage({
name: 'showcase_new_project_wizard',
Expand All@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
formType: 'wizard',
showStepIndicator: true,
title: 'Create a Project',
description: 'A three-step wizard — basics, status, then budget & schedule.',
description: 'A three-step wizard — basics, health, then budget & schedule.',
// `status` is deliberately ABSENT from this create wizard.
//
// `showcase_project`'s `project_status_flow` state machine declares
// `initialStates: ['planned']`, so `planned` is the only status a
// project may be CREATED in — the other four are reachable only by
// transition, after the record exists. The step offered all five
// (a `select` renders its whole option list; nothing in page
// metadata narrows it to the machine's entry points), so four of
// them were dead ends: the wizard accepted the pick, walked the
// author through a third step, and only then answered
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
// state machine must not demo a dead end.
//
// With the field omitted, the option marked `default: true`
// (`planned`) supplies the value server-side — which is the same
// entry point the machine declares, so the two cannot drift. A
// one-option select would be the alternative and is strictly worse
// UI: it asks a question with exactly one answer.
//
// The GENERAL fix — a create form deriving its allowed values from
// the object's `stateMachine` — is a console (objectui) feature and
// is deliberately not built here; this app must be correct without
// it. `test/new-project-wizard-initial-status.test.ts` pins the
// invariant against the REAL metadata, so widening `initialStates`
// later re-opens the question instead of silently rotting.
sections: [
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
],
// Without this, a successful submit left the filled step-3 form in
Expand Down
203 changes: 203 additions & 0 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14311] The New Project wizard may not offer a status the state machine
* refuses on create.
*
* The wizard's second step listed `status`, and a `select` renders its whole
* option list — all five project statuses. `project_status_flow` declares
* `initialStates: ['planned']`, so four of those five were dead ends: the
* wizard accepted the pick, walked the author through a third step, and only
* then answered `400 VALIDATION_FAILED` from the create. A demo of
* "state machine + wizard" that demos a dead end teaches the wrong thing.
*
* These tests read the REAL page and the REAL object rather than a copy of
* either, so the invariant is checked against what the app actually ships:
* widening `initialStates`, re-adding the field, or adding a status option
* re-opens the question here instead of rotting silently.
*
* The last test is the end-to-end half, on the production harness (real
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
* now performs succeeds, the one it used to allow is refused, and the refusal
* carries the field location and the legal initial states a form needs to act
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
};

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;
const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

/**
* The showcase's real objects on a real engine — same wiring as
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
* REQUIRED lookup, so `Account` is registered too and a real row is created:
* a rejection for a dangling reference would otherwise be indistinguishable
* from the state-machine refusal this test is about.
*/
async function bootShowcase(): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
return engine;
}

/** The `project_status_flow` state machine, read off the real object. */
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
(r) => r?.type === 'state_machine' && r?.field === 'status',
)!;

/** Every field the wizard's create form exposes, across all of its steps. */
function wizardFields(): string[] {
const regions = (NewProjectWizardPage as unknown as {
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
}).regions ?? [];
const out: string[] = [];
for (const region of regions) {
for (const component of region.components ?? []) {
if (component?.type !== 'object-form') continue;
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
for (const section of sections) out.push(...(section.fields ?? []));
}
}
return out;
}

/** The declared option values of a select field on the real object. */
function optionValues(field: string): string[] {
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
}).fields?.[field];
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
}

describe('#14311 — the New Project wizard and the status state machine', () => {
it('the premise: the object still constrains which status a project may be created in', () => {
// If this ever stops holding, the rest of this file is asserting nothing.
expect(statusRule?.name).toBe('project_status_flow');
expect(statusRule?.initialStates).toEqual(['planned']);
expect((statusRule as { events?: string[] }).events).toContain('insert');
});

it('the wizard does not offer a status the machine refuses on create', () => {
const offered = wizardFields();
const initial = statusRule.initialStates ?? [];
const refusable = optionValues('status').filter((v) => !initial.includes(v));

// More than one legal initial state would make a narrowed select the right
// shape; with exactly one, the field must simply not be asked.
expect(refusable.length).toBeGreaterThan(0);
expect(initial).toHaveLength(1);
expect(offered).not.toContain('status');
});

it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
// Omitting the field only works because the object DEFAULTS it, and only
// stays correct because the default IS the declared initial state.
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
}).fields?.status;
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
'showcase_account', { name: 'Northwind' }, ctx as never,
);

// What the wizard now sends: no `status` at all.
const created: any = await engine.insert(
'showcase_project',
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
ctx as never,
);
expect(created.status).toBe('planned');

// What it used to let an author send from step 2.
let thrown: any;
try {
await engine.insert(
'showcase_project',
{ name: 'Born active', account: String(account.id), status: 'active' },
ctx as never,
);
} catch (e) { thrown = e; }

expect(thrown, 'expected the create to be refused').toBeDefined();
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
expect(thrown.code).toBe('VALIDATION_FAILED');
const field = thrown.fields?.find((f: any) => f.field === 'status');
// Field-located, so a multi-step form can jump to the step that owns it.
expect(field, 'the refusal must name the field it is about').toBeDefined();
expect(field.code).toBe('invalid_initial_state');
// #14311 — the facts ride along with the AUTHORED message, so a form can
// name the legal entry points without parsing the sentence.
expect(field.constraint).toEqual({ allowed: 'planned' });
expect(field.value).toBe('active');
}, 30000);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 16 additions & 0 deletions .changeset/chilled-eels-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).

`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.

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.

Now both paths emit the same envelope:

- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`

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.
12 changes: 11 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
// ONE authored sentence answers BOTH refusals this rule can raise —
// `invalid_initial_state` on insert and `invalid_transition` on update —
// because `authoredRuleMessage` resolves one key per RULE, not per code.
// The old wording ("Invalid project status transition.") described only
// the update half, so a create rejected for being born `active` was told
// about a "transition" it had not attempted. It is translated at
// `objects.showcase_project._validations.project_status_flow.message`
// (#14253) — an authored message is emitted verbatim unless the bundle
// carries that key, which is why this one used to be the single English
// sentence on an otherwise zh-CN form.
message: 'Projects start as Planned, and then move only along the declared status flow.',
transitions: {
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — the built-in field catalog's
// own sentences have shipped zh-CN since #3957, so a rule that declares
// its own message is the one way a refusal escapes the caller's
// language. `project_status_flow` is the showcase's state machine and
// the only refusal a visitor reliably triggers (the New Project wizard
// used to offer four statuses the machine will not accept on create),
// so it read as the single English sentence on a zh-CN form.
// All FOUR of the object's rules, not just the state machines: the New
// Project wizard can trip `end_after_start` and `spent_within_budget`
// from its budget/schedule step, so translating only the status rule
// would move the single English sentence one step later rather than
// remove it.
_validations: {
project_status_flow: {
message: 'Projects start as Planned, and then move only along the declared status flow.',
},
project_health_progression: {
message: 'Health changed by more than one step — confirm this is intentional.',
},
end_after_start: {
message: 'Target End Date must be on or after the Start Date.',
},
spent_within_budget: {
message: 'Spend exceeds 120% of budget — escalate before continuing.',
},
},
},
showcase_task: {
label: 'Task',
Expand DownExpand Up@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Without these two keys the write path's own refusals arrive in
// Chinese (built-in catalog, #3957) while these author-written ones
// arrive in English, inside one error envelope.
_validations: {
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
end_after_start: { message: '结束日期不能早于开始日期。' },
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
},
// `default` — the container's DEFAULT list. `defineView({ list })`
// declares it without a `name`, and the composer therefore registers it
// as `<object>.default`; `_views` keys are that bare runtime key
Expand Down
33 changes: 30 additions & 3 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
* New Project Wizard — a multi-step (wizard) form surface. The showcase
* defines wizard/tabbed/split form view *types* but had no page that actually
* walks a user through a stepped create flow. This renders `object-form` with
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
* indicator, over showcase_project.
*
* On `status`, and why it is not a step here, see the comment on `sections`.
*/
export const NewProjectWizardPage = definePage({
name: 'showcase_new_project_wizard',
Expand All@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
formType: 'wizard',
showStepIndicator: true,
title: 'Create a Project',
description: 'A three-step wizard — basics, status, then budget & schedule.',
description: 'A three-step wizard — basics, health, then budget & schedule.',
// `status` is deliberately ABSENT from this create wizard.
//
// `showcase_project`'s `project_status_flow` state machine declares
// `initialStates: ['planned']`, so `planned` is the only status a
// project may be CREATED in — the other four are reachable only by
// transition, after the record exists. The step offered all five
// (a `select` renders its whole option list; nothing in page
// metadata narrows it to the machine's entry points), so four of
// them were dead ends: the wizard accepted the pick, walked the
// author through a third step, and only then answered
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
// state machine must not demo a dead end.
//
// With the field omitted, the option marked `default: true`
// (`planned`) supplies the value server-side — which is the same
// entry point the machine declares, so the two cannot drift. A
// one-option select would be the alternative and is strictly worse
// UI: it asks a question with exactly one answer.
//
// The GENERAL fix — a create form deriving its allowed values from
// the object's `stateMachine` — is a console (objectui) feature and
// is deliberately not built here; this app must be correct without
// it. `test/new-project-wizard-initial-status.test.ts` pins the
// invariant against the REAL metadata, so widening `initialStates`
// later re-opens the question instead of silently rotting.
sections: [
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
],
// Without this, a successful submit left the filled step-3 form in
Expand Down
203 changes: 203 additions & 0 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14311] The New Project wizard may not offer a status the state machine
* refuses on create.
*
* The wizard's second step listed `status`, and a `select` renders its whole
* option list — all five project statuses. `project_status_flow` declares
* `initialStates: ['planned']`, so four of those five were dead ends: the
* wizard accepted the pick, walked the author through a third step, and only
* then answered `400 VALIDATION_FAILED` from the create. A demo of
* "state machine + wizard" that demos a dead end teaches the wrong thing.
*
* These tests read the REAL page and the REAL object rather than a copy of
* either, so the invariant is checked against what the app actually ships:
* widening `initialStates`, re-adding the field, or adding a status option
* re-opens the question here instead of rotting silently.
*
* The last test is the end-to-end half, on the production harness (real
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
* now performs succeeds, the one it used to allow is refused, and the refusal
* carries the field location and the legal initial states a form needs to act
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
};

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;
const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

/**
* The showcase's real objects on a real engine — same wiring as
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
* REQUIRED lookup, so `Account` is registered too and a real row is created:
* a rejection for a dangling reference would otherwise be indistinguishable
* from the state-machine refusal this test is about.
*/
async function bootShowcase(): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
return engine;
}

/** The `project_status_flow` state machine, read off the real object. */
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
(r) => r?.type === 'state_machine' && r?.field === 'status',
)!;

/** Every field the wizard's create form exposes, across all of its steps. */
function wizardFields(): string[] {
const regions = (NewProjectWizardPage as unknown as {
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
}).regions ?? [];
const out: string[] = [];
for (const region of regions) {
for (const component of region.components ?? []) {
if (component?.type !== 'object-form') continue;
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
for (const section of sections) out.push(...(section.fields ?? []));
}
}
return out;
}

/** The declared option values of a select field on the real object. */
function optionValues(field: string): string[] {
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
}).fields?.[field];
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
}

describe('#14311 — the New Project wizard and the status state machine', () => {
it('the premise: the object still constrains which status a project may be created in', () => {
// If this ever stops holding, the rest of this file is asserting nothing.
expect(statusRule?.name).toBe('project_status_flow');
expect(statusRule?.initialStates).toEqual(['planned']);
expect((statusRule as { events?: string[] }).events).toContain('insert');
});

it('the wizard does not offer a status the machine refuses on create', () => {
const offered = wizardFields();
const initial = statusRule.initialStates ?? [];
const refusable = optionValues('status').filter((v) => !initial.includes(v));

// More than one legal initial state would make a narrowed select the right
// shape; with exactly one, the field must simply not be asked.
expect(refusable.length).toBeGreaterThan(0);
expect(initial).toHaveLength(1);
expect(offered).not.toContain('status');
});

it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
// Omitting the field only works because the object DEFAULTS it, and only
// stays correct because the default IS the declared initial state.
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
}).fields?.status;
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
'showcase_account', { name: 'Northwind' }, ctx as never,
);

// What the wizard now sends: no `status` at all.
const created: any = await engine.insert(
'showcase_project',
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
ctx as never,
);
expect(created.status).toBe('planned');

// What it used to let an author send from step 2.
let thrown: any;
try {
await engine.insert(
'showcase_project',
{ name: 'Born active', account: String(account.id), status: 'active' },
ctx as never,
);
} catch (e) { thrown = e; }

expect(thrown, 'expected the create to be refused').toBeDefined();
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
expect(thrown.code).toBe('VALIDATION_FAILED');
const field = thrown.fields?.find((f: any) => f.field === 'status');
// Field-located, so a multi-step form can jump to the step that owns it.
expect(field, 'the refusal must name the field it is about').toBeDefined();
expect(field.code).toBe('invalid_initial_state');
// #14311 — the facts ride along with the AUTHORED message, so a form can
// name the legal entry points without parsing the sentence.
expect(field.constraint).toEqual({ allowed: 'planned' });
expect(field.value).toBe('active');
}, 30000);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
16 changes: 16 additions & 0 deletions .changeset/chilled-eels-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).

`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.

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.

Now both paths emit the same envelope:

- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`

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.
12 changes: 11 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
// ONE authored sentence answers BOTH refusals this rule can raise —
// `invalid_initial_state` on insert and `invalid_transition` on update —
// because `authoredRuleMessage` resolves one key per RULE, not per code.
// The old wording ("Invalid project status transition.") described only
// the update half, so a create rejected for being born `active` was told
// about a "transition" it had not attempted. It is translated at
// `objects.showcase_project._validations.project_status_flow.message`
// (#14253) — an authored message is emitted verbatim unless the bundle
// carries that key, which is why this one used to be the single English
// sentence on an otherwise zh-CN form.
message: 'Projects start as Planned, and then move only along the declared status flow.',
transitions: {
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — the built-in field catalog's
// own sentences have shipped zh-CN since #3957, so a rule that declares
// its own message is the one way a refusal escapes the caller's
// language. `project_status_flow` is the showcase's state machine and
// the only refusal a visitor reliably triggers (the New Project wizard
// used to offer four statuses the machine will not accept on create),
// so it read as the single English sentence on a zh-CN form.
// All FOUR of the object's rules, not just the state machines: the New
// Project wizard can trip `end_after_start` and `spent_within_budget`
// from its budget/schedule step, so translating only the status rule
// would move the single English sentence one step later rather than
// remove it.
_validations: {
project_status_flow: {
message: 'Projects start as Planned, and then move only along the declared status flow.',
},
project_health_progression: {
message: 'Health changed by more than one step — confirm this is intentional.',
},
end_after_start: {
message: 'Target End Date must be on or after the Start Date.',
},
spent_within_budget: {
message: 'Spend exceeds 120% of budget — escalate before continuing.',
},
},
},
showcase_task: {
label: 'Task',
Expand DownExpand Up@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Without these two keys the write path's own refusals arrive in
// Chinese (built-in catalog, #3957) while these author-written ones
// arrive in English, inside one error envelope.
_validations: {
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
end_after_start: { message: '结束日期不能早于开始日期。' },
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
},
// `default` — the container's DEFAULT list. `defineView({ list })`
// declares it without a `name`, and the composer therefore registers it
// as `<object>.default`; `_views` keys are that bare runtime key
Expand Down
33 changes: 30 additions & 3 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,10 @@ import { definePage } from '@objectstack/spec/ui';
* New Project Wizard — a multi-step (wizard) form surface. The showcase
* defines wizard/tabbed/split form view *types* but had no page that actually
* walks a user through a stepped create flow. This renders `object-form` with
* `formType: 'wizard'` directly: Basics → Status → Budget, with a step
* `formType: 'wizard'` directly: Basics → Health → Budget, with a step
* indicator, over showcase_project.
*
* On `status`, and why it is not a step here, see the comment on `sections`.
*/
export const NewProjectWizardPage = definePage({
name: 'showcase_new_project_wizard',
Expand All@@ -29,10 +31,35 @@ export const NewProjectWizardPage = definePage({
formType: 'wizard',
showStepIndicator: true,
title: 'Create a Project',
description: 'A three-step wizard — basics, status, then budget & schedule.',
description: 'A three-step wizard — basics, health, then budget & schedule.',
// `status` is deliberately ABSENT from this create wizard.
//
// `showcase_project`'s `project_status_flow` state machine declares
// `initialStates: ['planned']`, so `planned` is the only status a
// project may be CREATED in — the other four are reachable only by
// transition, after the record exists. The step offered all five
// (a `select` renders its whole option list; nothing in page
// metadata narrows it to the machine's entry points), so four of
// them were dead ends: the wizard accepted the pick, walked the
// author through a third step, and only then answered
// `400 VALIDATION_FAILED` from the create. A wizard demonstrating a
// state machine must not demo a dead end.
//
// With the field omitted, the option marked `default: true`
// (`planned`) supplies the value server-side — which is the same
// entry point the machine declares, so the two cannot drift. A
// one-option select would be the alternative and is strictly worse
// UI: it asks a question with exactly one answer.
//
// The GENERAL fix — a create form deriving its allowed values from
// the object's `stateMachine` — is a console (objectui) feature and
// is deliberately not built here; this app must be correct without
// it. `test/new-project-wizard-initial-status.test.ts` pins the
// invariant against the REAL metadata, so widening `initialStates`
// later re-opens the question instead of silently rotting.
sections: [
{ label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] },
{ label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] },
{ label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] },
{ label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] },
],
// Without this, a successful submit left the filled step-3 form in
Expand Down
203 changes: 203 additions & 0 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14311] The New Project wizard may not offer a status the state machine
* refuses on create.
*
* The wizard's second step listed `status`, and a `select` renders its whole
* option list — all five project statuses. `project_status_flow` declares
* `initialStates: ['planned']`, so four of those five were dead ends: the
* wizard accepted the pick, walked the author through a third step, and only
* then answered `400 VALIDATION_FAILED` from the create. A demo of
* "state machine + wizard" that demos a dead end teaches the wrong thing.
*
* These tests read the REAL page and the REAL object rather than a copy of
* either, so the invariant is checked against what the app actually ships:
* widening `initialStates`, re-adding the field, or adding a status option
* re-opens the question here instead of rotting silently.
*
* The last test is the end-to-end half, on the production harness (real
* `ObjectQL`, real `SqlDriver`, the app's REAL object): the create the wizard
* now performs succeeds, the one it used to allow is refused, and the refusal
* carries the field location and the legal initial states a form needs to act
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
};

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;
const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

/**
* The showcase's real objects on a real engine — same wiring as
* `hook-body-persisted-writes.test.ts`. `showcase_project.account` is a
* REQUIRED lookup, so `Account` is registered too and a real row is created:
* a rejection for a dangling reference would otherwise be indistinguishable
* from the state-machine refusal this test is about.
*/
async function bootShowcase(): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
return engine;
}

/** The `project_status_flow` state machine, read off the real object. */
const statusRule = ((Project as unknown as { validations?: Rule[] }).validations ?? []).find(
(r) => r?.type === 'state_machine' && r?.field === 'status',
)!;

/** Every field the wizard's create form exposes, across all of its steps. */
function wizardFields(): string[] {
const regions = (NewProjectWizardPage as unknown as {
regions?: Array<{ components?: Array<{ type?: string; properties?: Record<string, unknown> }> }>;
}).regions ?? [];
const out: string[] = [];
for (const region of regions) {
for (const component of region.components ?? []) {
if (component?.type !== 'object-form') continue;
const sections = (component.properties?.sections ?? []) as Array<{ fields?: string[] }>;
for (const section of sections) out.push(...(section.fields ?? []));
}
}
return out;
}

/** The declared option values of a select field on the real object. */
function optionValues(field: string): string[] {
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string } | string> }>;
}).fields?.[field];
return (def?.options ?? []).map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
}

describe('#14311 — the New Project wizard and the status state machine', () => {
it('the premise: the object still constrains which status a project may be created in', () => {
// If this ever stops holding, the rest of this file is asserting nothing.
expect(statusRule?.name).toBe('project_status_flow');
expect(statusRule?.initialStates).toEqual(['planned']);
expect((statusRule as { events?: string[] }).events).toContain('insert');
});

it('the wizard does not offer a status the machine refuses on create', () => {
const offered = wizardFields();
const initial = statusRule.initialStates ?? [];
const refusable = optionValues('status').filter((v) => !initial.includes(v));

// More than one legal initial state would make a narrowed select the right
// shape; with exactly one, the field must simply not be asked.
expect(refusable.length).toBeGreaterThan(0);
expect(initial).toHaveLength(1);
expect(offered).not.toContain('status');
});

it('the value the wizard relies on is the machine entry point (a default, not a copy of it)', () => {
// Omitting the field only works because the object DEFAULTS it, and only
// stays correct because the default IS the declared initial state.
const def = (Project as unknown as {
fields?: Record<string, { options?: Array<{ value?: string; default?: boolean }> }>;
}).fields?.status;
const defaulted = (def?.options ?? []).filter((o) => o?.default).map((o) => String(o.value));
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
'showcase_account', { name: 'Northwind' }, ctx as never,
);

// What the wizard now sends: no `status` at all.
const created: any = await engine.insert(
'showcase_project',
{ name: 'Wizard smoke', account: String(account.id), health: 'green' },
ctx as never,
);
expect(created.status).toBe('planned');

// What it used to let an author send from step 2.
let thrown: any;
try {
await engine.insert(
'showcase_project',
{ name: 'Born active', account: String(account.id), status: 'active' },
ctx as never,
);
} catch (e) { thrown = e; }

expect(thrown, 'expected the create to be refused').toBeDefined();
// ADR-0112 envelope — REST maps this to `400 VALIDATION_FAILED` verbatim.
expect(thrown.code).toBe('VALIDATION_FAILED');
const field = thrown.fields?.find((f: any) => f.field === 'status');
// Field-located, so a multi-step form can jump to the step that owns it.
expect(field, 'the refusal must name the field it is about').toBeDefined();
expect(field.code).toBe('invalid_initial_state');
// #14311 — the facts ride along with the AUTHORED message, so a form can
// name the legal entry points without parsing the sentence.
expect(field.constraint).toEqual({ allowed: 'planned' });
expect(field.value).toBe('active');
}, 30000);
});
Loading
Loading