Skip to content

Commit 9981c1d

Browse files
os-zhuangclaude
andauthored
fix(showcase): seed all five projects through the FSM, and surface seed outcomes in the boot banner (#3415) (#3435)
The project seed wrote terminal statuses directly on insert, but project_status_flow gates inserts to initialStates: ['planned'] — so 4/5 projects, 9/10 master-detail tasks and 3/3 memberships were rejected on every boot, silently: SeedLoader's logs sit under the default warn level AND inside serve's boot-quiet stdout window. Fixture: seed projects in three phases. Phase 1 inserts all five as 'planned' (explicitly — seed inserts do not apply select defaults) with mode 'ignore' so replays leave walked rows untouched; phases 2-3 then walk them along legal transitions (planned→active→on_hold/completed), doubling as a live FSM demo. A new 'completed → active' reopen transition (a real PM affordance — cancelled could already be revived) keeps the walk legal on replay. Fresh boot: 5 projects across four statuses, 10 tasks, 3 memberships, zero rejections; replay preserves walked states. Surfacing: AppPlugin stashes per-boot seed counters on the kernel ('seed-summary'); the serve banner prints 'Seeds: X inserted · Y updated · Z skipped', escalating to a yellow '⚠ … N REJECTED' line when rows dropped — before the fix that line read '114 inserted · 30 REJECTED', after it reads '130 inserted · 6 updated'. Gates: seed.test.ts now statically replays every FSM-gated object's seed phases (two rounds — fresh boot AND replay) against initialStates and transitions; format.seed-summary.test.ts covers the banner contract. Both proven red against the old fixture / a dead-end terminal state. Known pre-existing follow-up (#3434): mode:'insert' datasets (memberships) duplicate on every replay boot — masked until now because the rows never inserted at all. Closes #3415 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c4df271 commit 9981c1d

8 files changed

Lines changed: 239 additions & 7 deletions

File tree

.changeset/seed-summary-banner.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
Surface seed outcomes in the `os dev` / `os serve` boot banner (#3415). Seeds run inside the boot-quiet stdout window and SeedLoader's logs sit under the default warn level, so a fixture could silently lose most of its rows — the showcase shipped 1 of 5 projects with zero terminal signal. AppPlugin now stashes the per-boot seed counters on the kernel (`seed-summary` service) and the banner prints `Seeds: X inserted · Y updated · Z skipped`, escalating to a yellow `⚠ … N REJECTED` line when records were dropped.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,11 @@ export const Project = ObjectSchema.create({
135135
planned: ['active', 'cancelled'],
136136
active: ['on_hold', 'completed', 'cancelled'],
137137
on_hold: ['active', 'cancelled'],
138-
completed: [],
138+
// `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`.
142+
completed: ['active'],
139143
cancelled: ['planned'],
140144
},
141145
},

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

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,53 @@ const contacts = defineSeed(Contact, {
100100
],
101101
});
102102

103+
/**
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).
108+
*
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.
120+
*/
103121
const projects = defineSeed(Project, {
104-
mode: 'upsert',
122+
mode: 'ignore',
105123
externalId: 'name',
106124
records: [
107-
{ 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)` },
108-
{ 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)` },
109-
{ 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)` },
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)` },
110128
{ 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)` },
111-
{ 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)` },
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, {
134+
mode: 'upsert',
135+
externalId: 'name',
136+
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' },
112150
],
113151
});
114152

@@ -395,4 +433,4 @@ const announcements = defineSeed(Announcement, {
395433
],
396434
});
397435

398-
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];
436+
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, projectsActivate, projectsSettle, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];

examples/app-showcase/test/seed.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import stack from '../objectstack.config.js';
5+
import { ShowcaseSeedData } from '../src/data/seed/index.js';
56

67
/**
78
* Smoke test — the stack loads and registers the expected breadth of
@@ -33,3 +34,62 @@ describe('showcase stack', () => {
3334
expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none
3435
});
3536
});
37+
38+
/**
39+
* Static shadow of what SeedLoader + validation do at boot (#3415): for every
40+
* object whose state_machine gates INSERT (`initialStates`), replay the seed
41+
* datasets in declaration order and assert each record enters through a legal
42+
* initial state and only moves along declared transitions. The fixture that
43+
* silently lost 4/5 projects (target status written directly on insert) can
44+
* never come back green.
45+
*/
46+
describe('seed data vs state machines (#3415)', () => {
47+
const gated = (stack.objects ?? []).flatMap((o: any) =>
48+
(o.validations ?? [])
49+
.filter(
50+
(v: any) =>
51+
v.type === 'state_machine' &&
52+
(v.events ?? []).includes('insert') &&
53+
Array.isArray(v.initialStates) &&
54+
v.severity !== 'warning',
55+
)
56+
.map((v: any) => ({ object: o, rule: v })),
57+
);
58+
59+
it('covers the project status flow (the #3415 gate)', () => {
60+
expect(gated.map((g: any) => `${g.object.name}.${g.rule.field}`)).toContain('showcase_project.status');
61+
});
62+
63+
for (const { object, rule } of gated) {
64+
it(`${object.name}: seeded '${rule.field}' respects initialStates and transitions — including on replay`, () => {
65+
const datasets = ShowcaseSeedData.filter((d: any) => d.object === object.name);
66+
expect(datasets.length).toBeGreaterThan(0);
67+
const current = new Map<string, string>();
68+
// Round 1 = fresh boot; round 2 = replay against the walked state.
69+
// Replay must also be violation-free (#3415 follow-up): `ignore`
70+
// datasets skip existing rows wholesale, and re-walked hops must be
71+
// legal transitions (which is what the reopen edge guarantees).
72+
for (const round of [1, 2]) {
73+
for (const ds of datasets as any[]) {
74+
for (const rec of ds.records as any[]) {
75+
const key = String(rec[ds.externalId ?? 'name']);
76+
const next = rec[rule.field];
77+
if (!current.has(key)) {
78+
// First appearance = INSERT. Seed inserts do NOT apply select
79+
// defaults, so a gated field must be explicit AND legal.
80+
expect(next, `'${key}' (round ${round}) must seed '${rule.field}' explicitly`).toBeDefined();
81+
expect(rule.initialStates, `'${key}' enters as '${next}'`).toContain(next);
82+
current.set(key, next);
83+
} else {
84+
if (ds.mode === 'ignore') continue; // existing rows untouched
85+
if (next === undefined || next === current.get(key)) continue; // no-op replay skips
86+
const from = current.get(key)!;
87+
expect(rule.transitions?.[from] ?? [], `'${key}' (round ${round}) ${from}${next}`).toContain(next);
88+
current.set(key, next);
89+
}
90+
}
91+
}
92+
}
93+
});
94+
}
95+
});

packages/cli/src/commands/serve.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2427,6 +2427,17 @@ export default class Serve extends Command {
24272427
Array.isArray((config as any)?.flows) ? (config as any).flows.length : 0,
24282428
);
24292429

2430+
// ── Seed outcome summary (#3415) ───────────────────────────────
2431+
// Seeds run inside the boot-quiet window too, and SeedLoader's own
2432+
// logs sit under the default warn level — a fixture could lose 90%
2433+
// of its rows with zero terminal signal. AppPlugin stashes the
2434+
// counters on the kernel; print them here, loudly when rows dropped.
2435+
let seedSummary: { inserted: number; updated: number; skipped: number; rejected: number } | undefined;
2436+
try {
2437+
const s: any = kernel.getService?.('seed-summary');
2438+
if (s && typeof s.inserted === 'number') seedSummary = s;
2439+
} catch { /* no seeds ran — nothing to show */ }
2440+
24302441
// ── Clean startup summary ──────────────────────────────────────
24312442
printServerReady({
24322443
port,
@@ -2441,6 +2452,7 @@ export default class Serve extends Command {
24412452
multiTenant: resolveMultiOrgEnabled(),
24422453
seededAdmin,
24432454
automation: automationSummary,
2455+
seeds: seedSummary,
24442456
// #3167 — surface the default-on MCP endpoint in the dev loop, where an
24452457
// AI client can connect to operate the running app. Same decision point
24462458
// that auto-loads the plugin + gates the route, so the banner never
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4+
import { printServerReady, type ServerReadyOptions } from './format.js';
5+
6+
/**
7+
* #3415 — the boot banner is the ONE place a developer reliably sees seed
8+
* outcomes (SeedLoader's own logs are level-filtered and swallowed by the
9+
* serve boot-quiet window). Assert the Seeds line prints, screams on
10+
* rejections, and stays silent when nothing was seeded.
11+
*/
12+
describe('printServerReady seed summary (#3415)', () => {
13+
const base: ServerReadyOptions = {
14+
port: 3000,
15+
configFile: 'objectstack.config.ts',
16+
isDev: true,
17+
pluginCount: 1,
18+
};
19+
let lines: string[];
20+
let spy: ReturnType<typeof vi.spyOn>;
21+
22+
beforeEach(() => {
23+
lines = [];
24+
spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
25+
lines.push(args.join(' '));
26+
});
27+
});
28+
afterEach(() => spy.mockRestore());
29+
30+
const seedLines = () => lines.filter((l) => l.includes('Seeds:'));
31+
32+
it('prints a quiet one-liner for a clean seed', () => {
33+
printServerReady({ ...base, seeds: { inserted: 42, updated: 6, skipped: 3, rejected: 0 } });
34+
expect(seedLines()).toHaveLength(1);
35+
expect(seedLines()[0]).toContain('42 inserted');
36+
expect(seedLines()[0]).toContain('6 updated');
37+
expect(seedLines()[0]).not.toContain('REJECTED');
38+
});
39+
40+
it('screams when records were rejected', () => {
41+
printServerReady({ ...base, seeds: { inserted: 24, updated: 0, skipped: 0, rejected: 14 } });
42+
expect(seedLines()).toHaveLength(1);
43+
expect(seedLines()[0]).toContain('14 REJECTED');
44+
expect(seedLines()[0]).toContain('OS_LOG_LEVEL=info');
45+
});
46+
47+
it('stays silent when no summary was collected or nothing ran', () => {
48+
printServerReady({ ...base });
49+
printServerReady({ ...base, seeds: { inserted: 0, updated: 0, skipped: 0, rejected: 0 } });
50+
expect(seedLines()).toHaveLength(0);
51+
});
52+
});

packages/cli/src/utils/format.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,14 @@ export interface ServerReadyOptions {
203203
* armed. Collected from the live engine after runtime.start().
204204
*/
205205
automation?: AutomationReadySummary;
206+
/**
207+
* Seed outcome for this boot (#3415). Seeds run inside the boot-quiet
208+
* stdout window and SeedLoader's own logs sit under the default warn
209+
* level, so without this line a fixture can silently lose most of its
210+
* rows (the showcase shipped 1 of 5 projects for weeks). Rejections are
211+
* loud; a clean seed prints one dim line.
212+
*/
213+
seeds?: SeedReadySummary;
206214
/**
207215
* Whether the MCP server surface (`/api/v1/mcp`) is on (#3167). Default-on
208216
* core capability, but nothing in the dev loop surfaces it — an AI client
@@ -213,6 +221,14 @@ export interface ServerReadyOptions {
213221
mcpEnabled?: boolean;
214222
}
215223

224+
export interface SeedReadySummary {
225+
inserted: number;
226+
updated: number;
227+
skipped: number;
228+
/** Records dropped by validation/reference errors — the silent-loss case. */
229+
rejected: number;
230+
}
231+
216232
export interface AutomationReadySummary {
217233
/** Whether the automation service is registered at all. */
218234
enabled: boolean;
@@ -268,6 +284,7 @@ export function printServerReady(opts: ServerReadyOptions) {
268284
console.log(chalk.dim(` ${opts.pluginNames.join(', ')}`));
269285
}
270286
if (opts.automation) printAutomationSummary(opts.automation);
287+
if (opts.seeds) printSeedSummary(opts.seeds);
271288
console.log('');
272289
console.log(chalk.dim(' Press Ctrl+C to stop'));
273290
console.log('');
@@ -312,6 +329,29 @@ function printAutomationSummary(a: AutomationReadySummary) {
312329
}
313330
}
314331

332+
/**
333+
* One-glance answer to "did my seed rows actually land?" (#3415). Follows
334+
* printAutomationSummary's contract: quiet when everything is fine, yellow
335+
* with a count when rows were dropped — a fixture contradiction (e.g. seed
336+
* status vs a state_machine's initialStates) must not pass silently again.
337+
*/
338+
function printSeedSummary(s: SeedReadySummary) {
339+
const total = s.inserted + s.updated + s.skipped + s.rejected;
340+
if (total === 0) return;
341+
const parts = [`${s.inserted} inserted`];
342+
if (s.updated > 0) parts.push(`${s.updated} updated`);
343+
if (s.skipped > 0) parts.push(`${s.skipped} skipped`);
344+
if (s.rejected > 0) {
345+
console.log(
346+
chalk.yellow(
347+
` ⚠ Seeds: ${parts.join(' · ')} · ${s.rejected} REJECTED — run with OS_LOG_LEVEL=info to see each reason`,
348+
),
349+
);
350+
return;
351+
}
352+
console.log(chalk.dim(` Seeds: ${parts.join(' · ')}`));
353+
}
354+
315355
export function printMetadataStats(stats: MetadataStats) {
316356
const sections: Array<{ label: string; items: Array<[string, number]> }> = [
317357
{

packages/runtime/src/app-plugin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,26 @@ export class AppPlugin implements Plugin {
842842
});
843843
const result = await seedLoader.load(request);
844844
const { totalInserted, totalUpdated, totalSkipped, totalErrored } = result.summary;
845+
// #3415: stash the outcome on the kernel so the CLI boot
846+
// banner can print a Seeds line. The logs below never
847+
// reach `os dev` output — info is under the default warn
848+
// level, and the serve boot-quiet window swallows stdout
849+
// — so without this a fixture can lose most of its rows
850+
// with no signal at all. Accumulates across apps.
851+
try {
852+
const kernelRef: any = (ctx as any).kernel;
853+
const prev = (() => {
854+
try { return kernelRef?.getService?.('seed-summary') as any; } catch { return undefined; }
855+
})();
856+
const summary = {
857+
inserted: (prev?.inserted ?? 0) + totalInserted,
858+
updated: (prev?.updated ?? 0) + totalUpdated,
859+
skipped: (prev?.skipped ?? 0) + totalSkipped,
860+
rejected: (prev?.rejected ?? 0) + totalErrored,
861+
};
862+
if (kernelRef?.registerService) kernelRef.registerService('seed-summary', summary);
863+
else if (typeof (ctx as any).registerService === 'function') (ctx as any).registerService('seed-summary', summary);
864+
} catch { /* banner summary is best-effort */ }
845865
if (result.success) {
846866
ctx.logger.info('[Seeder] Seed loading complete', {
847867
inserted: totalInserted,

0 commit comments

Comments
 (0)