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
27 changes: 27 additions & 0 deletions .changeset/seed-writes-exempt-state-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/objectql': patch
'@objectstack/spec': patch
---

Exempt curated seed writes from `state_machine` validation (#3433).

A seed is a snapshot of established facts — a project already `completed`, an
opportunity already `closed_won` — not a record walking its lifecycle. But once
an object declared `state_machine.initialStates` (#3165), the write path enforced
the FSM entry point on **every** insert, so seed replay silently rejected every
mid-lifecycle row and cascaded its master-detail children. That is the "installed
but no data" failure for the showcase board (1 of 5 projects), and it would hit
every marketplace template (a `closed_won` opportunity, a `closed` case) plus the
rehydrate-heal and per-org replay paths.

`SeedLoaderService` now marks its writes with a server-set `ExecutionContext.seedReplay`
flag; the engine passes `skipStateMachine` to the rule evaluator for those writes,
which skips the `state_machine` rule on both insert (`initialStates`) and update
(transitions). The exemption is scoped to `state_machine` only — a seed must still
satisfy every other validation (`format`, `cross_field`, `script`, `json_schema`,
`conditional`). Because all seed paths funnel through `SeedLoaderService.SEED_OPTIONS`,
the fix covers boot inline seed, marketplace install/heal, and per-org replay at once.

The showcase project seed drops its three-phase FSM-walk workaround (#3415) and
seeds each project directly at its real status again.
1 change: 1 addition & 0 deletions content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const result = ExecutionContext.parse(data);
| **isSystem** | `boolean` | ✅ | |
| **skipTriggers** | `boolean` | optional | |
| **skipAutomations** | `boolean` | optional | |
| **seedReplay** | `boolean` | optional | |
| **oauthScopes** | `string[]` | optional | |
| **accessToken** | `string` | optional | |
| **transaction** | `any` | optional | |
Expand Down
4 changes: 1 addition & 3 deletions examples/app-showcase/src/data/objects/project.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,7 @@ export const Project = ObjectSchema.create({
active: ['on_hold', 'completed', 'cancelled'],
on_hold: ['active', 'cancelled'],
// `completed → active` is reopen — a real PM affordance (cancelled can
// already be revived via `planned`). It also keeps the seed's FSM walk
// (#3415) replayable: the walk re-runs on every boot, and a dead-end
// terminal state would reject the hop back through `active`.
// already be revived via `planned`), so no status is a dead end.
completed: ['active'],
cancelled: ['planned'],
},
Expand Down
58 changes: 17 additions & 41 deletions examples/app-showcase/src/data/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,52 +101,28 @@ const contacts = defineSeed(Contact, {
});

/**
* Projects seed in THREE phases because `project_status_flow` (#3165) gates
* inserts to `initialStates: ['planned']` and seeds deliberately run
* validation. Writing the target status directly rejected 4 of 5 projects on
* every boot — and their master-detail tasks/memberships with them (#3415).
* Projects span every populated status so the Kanban board, Gantt and
* dashboards show a realistic spread on first boot — not just one column.
*
* Phase 1 inserts every project as `planned` — explicitly, because seed
* inserts do NOT apply select defaults (see the Accounts note above) — using
* `mode: 'ignore'` so replays leave already-walked rows completely untouched.
* Phases 2-3 then walk the records along LEGAL transitions, doubling as a
* live demo of the state machine the seed used to violate:
* planned → active (phase 2)
* active → on_hold / completed (phase 3)
* Same-object datasets run in declaration order (stable topological sort).
* On replay, phase 1 skips wholesale (ignore), single-hop rows no-op skip,
* and the two 2-hop rows re-walk `active → terminal` — legal on both edges
* thanks to the `completed → active` reopen transition. Zero rejections.
* `project_status_flow` (#3165) declares `initialStates: ['planned']`, and
* seeds deliberately run validation. But a seed is a curated snapshot of
* ESTABLISHED facts (a project already `active` / `on_hold` / `completed`),
* not a record walking its lifecycle — so the platform exempts seed writes
* from the `state_machine` rule (#3433). That lets each project be seeded
* DIRECTLY at its real status; no FSM-walk workaround (this used to seed all
* five as `planned` then upsert-hop them through legal transitions, #3415).
* `mode: 'upsert'` keeps replay idempotent — an unchanged row no-ops, so
* dev-server restarts and package re-publishes never churn or re-validate.
*/
const projects = defineSeed(Project, {
mode: 'ignore',
externalId: 'name',
records: [
{ 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)` },
{ 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)` },
{ 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)` },
{ 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)` },
{ 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)` },
],
});

const projectsActivate = defineSeed(Project, {
mode: 'upsert',
externalId: 'name',
records: [
{ name: 'Website Relaunch', status: 'active' },
{ name: 'Data Platform', status: 'active' },
{ name: 'Compliance Audit', status: 'active' },
{ name: 'Legacy Sunset', status: 'active' },
],
});

const projectsSettle = defineSeed(Project, {
mode: 'upsert',
externalId: 'name',
records: [
{ name: 'Compliance Audit', status: 'on_hold' },
{ name: 'Legacy Sunset', status: 'completed' },
{ 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)` },
{ 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)` },
{ 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)` },
{ 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)` },
{ 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)` },
],
});

Expand Down Expand Up @@ -433,4 +409,4 @@ const announcements = defineSeed(Announcement, {
],
});

export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, projectsActivate, projectsSettle, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];
78 changes: 45 additions & 33 deletions examples/app-showcase/test/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,20 @@ describe('showcase stack', () => {
});

/**
* Static shadow of what SeedLoader + validation do at boot (#3415): for every
* object whose state_machine gates INSERT (`initialStates`), replay the seed
* datasets in declaration order and assert each record enters through a legal
* initial state and only moves along declared transitions. The fixture that
* silently lost 4/5 projects (target status written directly on insert) can
* never come back green.
* Static shadow of the seed contract after #3433: a seed write is a curated
* end-state fact, so the platform EXEMPTS it from the object's `state_machine`
* rule — a project is seeded directly `active` / `on_hold` / `completed`
* without walking the FSM up from `planned` (the three-phase walk workaround
* of #3415 is gone). This guard pins the new contract for every object whose
* state_machine gates INSERT (`initialStates`):
* 1. every seeded value is still a state the FSM DECLARES (a curated fact,
* not a typo — the exemption is not a license to write garbage); and
* 2. the fixture actually EXERCISES the exemption by seeding ≥1 non-initial
* state, so a regression back to "all rows enter as the initial state"
* (a re-introduced walk, or a fixture that collapses the board to one
* column) fails here. The #3433 failure was 1/5 projects surviving.
*/
describe('seed data vs state machines (#3415)', () => {
describe('seed data vs state machines (#3433)', () => {
const gated = (stack.objects ?? []).flatMap((o: any) =>
(o.validations ?? [])
.filter(
Expand All @@ -56,40 +62,46 @@ describe('seed data vs state machines (#3415)', () => {
.map((v: any) => ({ object: o, rule: v })),
);

it('covers the project status flow (the #3415 gate)', () => {
it('covers the project status flow (the #3433 gate)', () => {
expect(gated.map((g: any) => `${g.object.name}.${g.rule.field}`)).toContain('showcase_project.status');
});

for (const { object, rule } of gated) {
it(`${object.name}: seeded '${rule.field}' respects initialStates and transitions — including on replay`, () => {
it(`${object.name}: seeded '${rule.field}' is FSM-exempt but stays within declared states (#3433)`, () => {
const datasets = ShowcaseSeedData.filter((d: any) => d.object === object.name);
expect(datasets.length).toBeGreaterThan(0);
const current = new Map<string, string>();
// Round 1 = fresh boot; round 2 = replay against the walked state.
// Replay must also be violation-free (#3415 follow-up): `ignore`
// datasets skip existing rows wholesale, and re-walked hops must be
// legal transitions (which is what the reopen edge guarantees).
for (const round of [1, 2]) {
for (const ds of datasets as any[]) {
for (const rec of ds.records as any[]) {
const key = String(rec[ds.externalId ?? 'name']);
const next = rec[rule.field];
if (!current.has(key)) {
// First appearance = INSERT. Seed inserts do NOT apply select
// defaults, so a gated field must be explicit AND legal.
expect(next, `'${key}' (round ${round}) must seed '${rule.field}' explicitly`).toBeDefined();
expect(rule.initialStates, `'${key}' enters as '${next}'`).toContain(next);
current.set(key, next);
} else {
if (ds.mode === 'ignore') continue; // existing rows untouched
if (next === undefined || next === current.get(key)) continue; // no-op replay skips
const from = current.get(key)!;
expect(rule.transitions?.[from] ?? [], `'${key}' (round ${round}) ${from} → ${next}`).toContain(next);
current.set(key, next);
}
}

// Every state the FSM knows about — the legal value universe, derived
// from the rule itself (no dependency on the field's option shape).
const fsmStates = new Set<string>(
[
...rule.initialStates,
...Object.keys(rule.transitions ?? {}),
...Object.values(rule.transitions ?? {}).flat(),
].map(String),
);

const seeded = new Set<string>();
for (const ds of datasets as any[]) {
for (const rec of ds.records as any[]) {
const v = rec[rule.field];
if (v === undefined || v === null) continue;
const key = String(rec[ds.externalId ?? 'name']);
// #3433: a seed value need NOT be an initialState (the FSM entry
// guard is exempt), but it must be a state the machine declares.
expect(fsmStates, `'${key}' seeds '${rule.field}=${String(v)}'`).toContain(String(v));
seeded.add(String(v));
}
}

// The exemption must actually be used: seed at least one state the FSM
// entry point would reject on INSERT. Guards against a silent regression
// to a planned-only fixture (or a re-introduced FSM walk).
const nonInitial = [...seeded].filter((v) => !rule.initialStates.includes(v));
expect(
nonInitial.length,
`${object.name} seeds only initial states (${[...seeded].join(', ')}) — #3433 exemption unused`,
).toBeGreaterThan(0);
});
}
});
Loading