Skip to content

Commit 25149d8

Browse files
os-zhuangclaude
andcommitted
fix(objectql): exempt curated seed writes from state_machine validation (#3433)
`state_machine.initialStates` (#3165) enforced the FSM entry point on every insert, so seed replay silently rejected every mid-lifecycle row and cascaded its master-detail children — an empty-DB showcase boot loaded 1/5 projects and 1/10 tasks, and every marketplace template with a `closed_won`/`closed` seed row plus the rehydrate-heal and per-org replay paths hit the same "installed but no data" trap. A seed is a curated snapshot of established facts, not a record walking its lifecycle, so it is now exempt from the state_machine rule: - spec: new server-set `ExecutionContext.seedReplay` intent flag (sibling of `isSystem`; clients cannot inject it). - objectql: the four `evaluateValidationRules` call sites pass `skipStateMachine` when the context carries `seedReplay`; the rule evaluator skips `state_machine` on both insert (initialStates) and update (transitions). Scoped to state_machine only — format/cross_field/script/json_schema/ conditional still run. - metadata-protocol: `SeedLoaderService.SEED_OPTIONS` sets `seedReplay: true`, so all seed paths (boot inline, marketplace runInlineSeed, per-org replay, http-dispatcher, protocol) are covered with no call-site changes. The showcase project seed drops its three-phase FSM-walk workaround (#3415) and seeds each project directly at its real status again. Regression tests span rule-validator (skip is FSM-scoped), the real engine (seedReplay context bypasses enforcement), and the seed loader (threads the flag, loads all mid-lifecycle rows). Verified on a real empty-DB boot: showcase_project=5, showcase_task=10, no rejected seed rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent af5a224 commit 25149d8

10 files changed

Lines changed: 445 additions & 49 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
Exempt curated seed writes from `state_machine` validation (#3433).
8+
9+
A seed is a snapshot of established facts — a project already `completed`, an
10+
opportunity already `closed_won` — not a record walking its lifecycle. But once
11+
an object declared `state_machine.initialStates` (#3165), the write path enforced
12+
the FSM entry point on **every** insert, so seed replay silently rejected every
13+
mid-lifecycle row and cascaded its master-detail children. That is the "installed
14+
but no data" failure for the showcase board (1 of 5 projects), and it would hit
15+
every marketplace template (a `closed_won` opportunity, a `closed` case) plus the
16+
rehydrate-heal and per-org replay paths.
17+
18+
`SeedLoaderService` now marks its writes with a server-set `ExecutionContext.seedReplay`
19+
flag; the engine passes `skipStateMachine` to the rule evaluator for those writes,
20+
which skips the `state_machine` rule on both insert (`initialStates`) and update
21+
(transitions). The exemption is scoped to `state_machine` only — a seed must still
22+
satisfy every other validation (`format`, `cross_field`, `script`, `json_schema`,
23+
`conditional`). Because all seed paths funnel through `SeedLoaderService.SEED_OPTIONS`,
24+
the fix covers boot inline seed, marketplace install/heal, and per-org replay at once.
25+
26+
The showcase project seed drops its three-phase FSM-walk workaround (#3415) and
27+
seeds each project directly at its real status again.

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,7 @@ export const Project = ObjectSchema.create({
136136
active: ['on_hold', 'completed', 'cancelled'],
137137
on_hold: ['active', 'cancelled'],
138138
// `completed → active` is reopen — a real PM affordance (cancelled can
139-
// already be revived via `planned`). It also keeps the seed's FSM walk
140-
// (#3415) replayable: the walk re-runs on every boot, and a dead-end
141-
// terminal state would reject the hop back through `active`.
139+
// already be revived via `planned`), so no status is a dead end.
142140
completed: ['active'],
143141
cancelled: ['planned'],
144142
},

examples/app-showcase/src/data/seed/index.ts

Lines changed: 17 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -101,52 +101,28 @@ const contacts = defineSeed(Contact, {
101101
});
102102

103103
/**
104-
* Projects seed in THREE phases because `project_status_flow` (#3165) gates
105-
* inserts to `initialStates: ['planned']` and seeds deliberately run
106-
* validation. Writing the target status directly rejected 4 of 5 projects on
107-
* every boot — and their master-detail tasks/memberships with them (#3415).
104+
* Projects span every populated status so the Kanban board, Gantt and
105+
* dashboards show a realistic spread on first boot — not just one column.
108106
*
109-
* Phase 1 inserts every project as `planned` — explicitly, because seed
110-
* inserts do NOT apply select defaults (see the Accounts note above) — using
111-
* `mode: 'ignore'` so replays leave already-walked rows completely untouched.
112-
* Phases 2-3 then walk the records along LEGAL transitions, doubling as a
113-
* live demo of the state machine the seed used to violate:
114-
* planned → active (phase 2)
115-
* active → on_hold / completed (phase 3)
116-
* Same-object datasets run in declaration order (stable topological sort).
117-
* On replay, phase 1 skips wholesale (ignore), single-hop rows no-op skip,
118-
* and the two 2-hop rows re-walk `active → terminal` — legal on both edges
119-
* thanks to the `completed → active` reopen transition. Zero rejections.
107+
* `project_status_flow` (#3165) declares `initialStates: ['planned']`, and
108+
* seeds deliberately run validation. But a seed is a curated snapshot of
109+
* ESTABLISHED facts (a project already `active` / `on_hold` / `completed`),
110+
* not a record walking its lifecycle — so the platform exempts seed writes
111+
* from the `state_machine` rule (#3433). That lets each project be seeded
112+
* DIRECTLY at its real status; no FSM-walk workaround (this used to seed all
113+
* five as `planned` then upsert-hop them through legal transitions, #3415).
114+
* `mode: 'upsert'` keeps replay idempotent — an unchanged row no-ops, so
115+
* dev-server restarts and package re-publishes never churn or re-validate.
120116
*/
121117
const projects = defineSeed(Project, {
122-
mode: 'ignore',
123-
externalId: 'name',
124-
records: [
125-
{ name: 'Website Relaunch', account: 'Northwind', status: 'planned', health: 'green', budget: 150_000, spent: 60_000, owner: 'ada@example.com', start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` },
126-
{ name: 'Data Platform', account: 'Contoso', status: 'planned', health: 'yellow', budget: 600_000, spent: 420_000, owner: 'linus@example.com', start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` },
127-
{ name: 'Compliance Audit', account: 'Fabrikam', status: 'planned', health: 'red', budget: 90_000, spent: 88_000, owner: 'grace@example.com', start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` },
128-
{ name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: 'ada@example.com', start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` },
129-
{ name: 'Legacy Sunset', account: 'Northwind', status: 'planned', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` },
130-
],
131-
});
132-
133-
const projectsActivate = defineSeed(Project, {
134118
mode: 'upsert',
135119
externalId: 'name',
136120
records: [
137-
{ name: 'Website Relaunch', status: 'active' },
138-
{ name: 'Data Platform', status: 'active' },
139-
{ name: 'Compliance Audit', status: 'active' },
140-
{ name: 'Legacy Sunset', status: 'active' },
141-
],
142-
});
143-
144-
const projectsSettle = defineSeed(Project, {
145-
mode: 'upsert',
146-
externalId: 'name',
147-
records: [
148-
{ name: 'Compliance Audit', status: 'on_hold' },
149-
{ name: 'Legacy Sunset', status: 'completed' },
121+
{ name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: 'ada@example.com', start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` },
122+
{ name: 'Data Platform', account: 'Contoso', status: 'active', health: 'yellow', budget: 600_000, spent: 420_000, owner: 'linus@example.com', start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` },
123+
{ name: 'Compliance Audit', account: 'Fabrikam', status: 'on_hold', health: 'red', budget: 90_000, spent: 88_000, owner: 'grace@example.com', start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` },
124+
{ name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: 'ada@example.com', start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` },
125+
{ name: 'Legacy Sunset', account: 'Northwind', status: 'completed', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` },
150126
],
151127
});
152128

@@ -433,4 +409,4 @@ const announcements = defineSeed(Announcement, {
433409
],
434410
});
435411

436-
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, projectsActivate, projectsSettle, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];
412+
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { SeedLoaderService } from './seed-loader';
5+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
6+
7+
/**
8+
* #3433 — a curated seed is a snapshot of ESTABLISHED facts (a project already
9+
* `completed`, an opportunity `closed_won`), not a record walking its lifecycle.
10+
* When an object declares `state_machine.initialStates` (#3165), the write path
11+
* enforces that INSERTS are born in an initial state — which silently rejects
12+
* every mid-lifecycle seed row and cascades its master-detail children ("installed
13+
* but no data"). So SeedLoaderService marks its writes `seedReplay`, and the engine
14+
* skips the state_machine rule for them.
15+
*
16+
* The engine that actually enforces this lives in @objectstack/objectql, which
17+
* DEPENDS ON this package — importing it back would cycle. So this mock engine
18+
* reproduces the exact insert-time guard (reject a state ∉ initialStates UNLESS the
19+
* write carries `context.seedReplay`) to regression-test the loader's end of the
20+
* contract in isolation. Revert the `seedReplay` flag in `SEED_OPTIONS` and both
21+
* cases below go red — 4 of 5 rows rejected, the flag absent from the writes.
22+
*/
23+
24+
function createLogger() {
25+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
26+
}
27+
28+
const PROJECT = {
29+
name: 'showcase_project',
30+
fields: {
31+
name: { type: 'text' },
32+
status: { type: 'select' },
33+
},
34+
validations: [
35+
{
36+
type: 'state_machine',
37+
name: 'project_status_flow',
38+
field: 'status',
39+
initialStates: ['planned'],
40+
transitions: {
41+
planned: ['active', 'cancelled'],
42+
active: ['on_hold', 'completed', 'cancelled'],
43+
},
44+
message: 'Invalid project status transition.',
45+
},
46+
],
47+
};
48+
49+
/** Reproduces the objectql insert-time initialStates guard, honoring the #3433 exemption. */
50+
function enforceInitialStates(data: Record<string, unknown>, opts: any): void {
51+
if (opts?.context?.seedReplay === true) return; // #3433 exemption
52+
const sm = PROJECT.validations[0];
53+
const value = data[sm.field];
54+
if (value == null || value === '') return;
55+
if (!sm.initialStates.includes(String(value))) {
56+
const err: any = new Error(
57+
`invalid_initial_state: ${sm.field} '${String(value)}' not in [${sm.initialStates.join(', ')}]`,
58+
);
59+
err.code = 'VALIDATION_FAILED';
60+
throw err;
61+
}
62+
}
63+
64+
function createEnforcingEngine(): { engine: IDataEngine; store: Record<string, any[]> } {
65+
const store: Record<string, any[]> = {};
66+
let idCounter = 0;
67+
const engine = {
68+
find: vi.fn(async (objectName: string, query?: any) => {
69+
let rows = store[objectName] || [];
70+
if (query?.where) {
71+
rows = rows.filter((r) => Object.entries(query.where).every(([k, v]) => r[k] === v));
72+
}
73+
if (typeof query?.limit === 'number') rows = rows.slice(0, query.limit);
74+
return rows;
75+
}),
76+
findOne: vi.fn(async () => null),
77+
// Per-row partial-success path the seed loader prefers (framework#3172):
78+
// one verdict per row, so a rejected row is culled, not thrown as a batch.
79+
insertMany: vi.fn(async (objectName: string, rows: any[], opts: any) =>
80+
rows.map((r) => {
81+
try {
82+
enforceInitialStates(r, opts);
83+
const record = { id: `gen-${++idCounter}`, ...r };
84+
(store[objectName] ||= []).push(record);
85+
return { ok: true, record };
86+
} catch (error) {
87+
return { ok: false, error };
88+
}
89+
}),
90+
),
91+
insert: vi.fn(async (objectName: string, data: any, opts: any) => {
92+
const rows = Array.isArray(data) ? data : [data];
93+
const written = rows.map((r) => {
94+
enforceInitialStates(r, opts); // throws on violation (whole-array semantics)
95+
const record = { id: `gen-${++idCounter}`, ...r };
96+
(store[objectName] ||= []).push(record);
97+
return record;
98+
});
99+
return Array.isArray(data) ? written : written[0];
100+
}),
101+
update: vi.fn(async (objectName: string, data: any) => {
102+
const rows = store[objectName] || [];
103+
const idx = rows.findIndex((r) => r.id === data.id);
104+
if (idx >= 0) {
105+
rows[idx] = { ...rows[idx], ...data };
106+
return rows[idx];
107+
}
108+
return data;
109+
}),
110+
delete: vi.fn(async () => ({ deleted: 1 })),
111+
count: vi.fn(async (o: string) => (store[o] || []).length),
112+
aggregate: vi.fn(async () => []),
113+
} as unknown as IDataEngine;
114+
return { engine, store };
115+
}
116+
117+
function createMetadata(): IMetadataService {
118+
return {
119+
getObject: vi.fn(async (name: string) => (name === PROJECT.name ? PROJECT : undefined)),
120+
listObjects: vi.fn(async () => [PROJECT]),
121+
register: vi.fn(async () => {}),
122+
get: vi.fn(async (_t: string, name: string) => (name === PROJECT.name ? PROJECT : undefined)),
123+
list: vi.fn(async () => []),
124+
unregister: vi.fn(async () => {}),
125+
exists: vi.fn(async () => false),
126+
listNames: vi.fn(async () => []),
127+
} as unknown as IMetadataService;
128+
}
129+
130+
// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on).
131+
const CONFIG = {
132+
dryRun: false,
133+
haltOnError: false,
134+
multiPass: true,
135+
defaultMode: 'upsert',
136+
batchSize: 1000,
137+
transaction: false,
138+
} as any;
139+
140+
// A seed that deliberately spans the lifecycle: 1 born-initial + 4 mid-lifecycle,
141+
// exactly like the showcase project board (one card per Kanban column).
142+
const SEED = [
143+
{
144+
object: 'showcase_project',
145+
externalId: 'name',
146+
mode: 'upsert',
147+
env: ['prod', 'dev', 'test'],
148+
records: [
149+
{ name: 'Mobile App', status: 'planned' },
150+
{ name: 'Website Relaunch', status: 'active' },
151+
{ name: 'Data Platform', status: 'active' },
152+
{ name: 'Compliance Audit', status: 'on_hold' },
153+
{ name: 'Legacy Sunset', status: 'completed' },
154+
],
155+
},
156+
] as any[];
157+
158+
describe('seed loader — state_machine initialStates exemption (#3433)', () => {
159+
it('inserts every mid-lifecycle row on a fresh DB (no initialStates rejection)', async () => {
160+
const { engine, store } = createEnforcingEngine();
161+
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({
162+
seeds: SEED,
163+
config: CONFIG,
164+
});
165+
166+
expect(result.success).toBe(true);
167+
expect(result.summary.totalErrored).toBe(0);
168+
expect(result.summary.totalInserted).toBe(5);
169+
expect(store.showcase_project).toHaveLength(5);
170+
// The exact spread the showcase board needs — proof no state was dropped.
171+
expect(store.showcase_project.map((r) => r.status).sort()).toEqual([
172+
'active',
173+
'active',
174+
'completed',
175+
'on_hold',
176+
'planned',
177+
]);
178+
});
179+
180+
it('threads seedReplay into every project write (the flag the engine keys off)', async () => {
181+
const { engine } = createEnforcingEngine();
182+
await new SeedLoaderService(engine, createMetadata(), createLogger()).load({
183+
seeds: SEED,
184+
config: CONFIG,
185+
});
186+
187+
// Whichever write path the loader took (insertMany batch or a per-row
188+
// fallback), its options must carry the exemption flag — that is what the
189+
// engine reads to skip the state_machine rule.
190+
const writeCalls = [
191+
...(engine.insertMany as any).mock.calls,
192+
...(engine.insert as any).mock.calls,
193+
].filter(([obj]) => obj === 'showcase_project');
194+
expect(writeCalls.length).toBeGreaterThan(0);
195+
for (const call of writeCalls) {
196+
expect(call[2]?.context?.seedReplay).toBe(true);
197+
}
198+
});
199+
});

packages/metadata-protocol/src/seed-loader.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,8 +870,19 @@ export class SeedLoaderService implements ISeedLoaderService {
870870
* approvals) for it is semantically wrong and dangerous — a self-triggering
871871
* flow can loop and wedge the whole first-boot (2026-07-06 incident).
872872
* Lifecycle HOOKS (derived/default fields, validation) still run.
873+
*
874+
* `seedReplay` (#3433) tells the engine this is curated seed data so the
875+
* object's `state_machine` validation rule is skipped — both the
876+
* `initialStates` entry-point check on insert and the transition check on
877+
* update. A seed is a snapshot of established facts (a `completed` project, a
878+
* `closed_won` opportunity), not a record walking its lifecycle, so the FSM
879+
* entry/transition guards do not apply. Without this a declared
880+
* `initialStates` silently rejects every mid-lifecycle seed row and cascades
881+
* its master-detail children — the "installed but no data" failure for
882+
* showcase and every marketplace template. All OTHER validation (field
883+
* shape, `format`, `cross_field`, `script`, `json_schema`) still runs.
873884
*/
874-
private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true } } as const;
885+
private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true, seedReplay: true } } as const;
875886

876887
/**
877888
* Run an engine write; if it fails ONLY because a post-write roll-up summary

0 commit comments

Comments
 (0)