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
6 changes: 6 additions & 0 deletions .changeset/seed-summary-banner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@objectstack/runtime': patch
'@objectstack/cli': patch
---

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.
6 changes: 5 additions & 1 deletion examples/app-showcase/src/data/objects/project.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ export const Project = ObjectSchema.create({
planned: ['active', 'cancelled'],
active: ['on_hold', 'completed', 'cancelled'],
on_hold: ['active', 'cancelled'],
completed: [],
// `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`.
completed: ['active'],
cancelled: ['planned'],
},
},
Expand Down
50 changes: 44 additions & 6 deletions examples/app-showcase/src/data/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,53 @@ 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).
*
* 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.
*/
const projects = defineSeed(Project, {
mode: 'upsert',
mode: 'ignore',
externalId: 'name',
records: [
{ 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: '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: 'completed', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` },
{ 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' },
],
});

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

export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, projectsActivate, projectsSettle, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];
60 changes: 60 additions & 0 deletions examples/app-showcase/test/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

/**
* Smoke test — the stack loads and registers the expected breadth of
Expand Down Expand Up @@ -33,3 +34,62 @@ describe('showcase stack', () => {
expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none
});
});

/**
* 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.
*/
describe('seed data vs state machines (#3415)', () => {
const gated = (stack.objects ?? []).flatMap((o: any) =>
(o.validations ?? [])
.filter(
(v: any) =>
v.type === 'state_machine' &&
(v.events ?? []).includes('insert') &&
Array.isArray(v.initialStates) &&
v.severity !== 'warning',
)
.map((v: any) => ({ object: o, rule: v })),
);

it('covers the project status flow (the #3415 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`, () => {
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);
}
}
}
}
});
}
});
12 changes: 12 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2427,6 +2427,17 @@ export default class Serve extends Command {
Array.isArray((config as any)?.flows) ? (config as any).flows.length : 0,
);

// ── Seed outcome summary (#3415) ───────────────────────────────
// Seeds run inside the boot-quiet window too, and SeedLoader's own
// logs sit under the default warn level — a fixture could lose 90%
// of its rows with zero terminal signal. AppPlugin stashes the
// counters on the kernel; print them here, loudly when rows dropped.
let seedSummary: { inserted: number; updated: number; skipped: number; rejected: number } | undefined;
try {
const s: any = kernel.getService?.('seed-summary');
if (s && typeof s.inserted === 'number') seedSummary = s;
} catch { /* no seeds ran — nothing to show */ }

// ── Clean startup summary ──────────────────────────────────────
printServerReady({
port,
Expand All @@ -2441,6 +2452,7 @@ export default class Serve extends Command {
multiTenant: resolveMultiOrgEnabled(),
seededAdmin,
automation: automationSummary,
seeds: seedSummary,
// #3167 — surface the default-on MCP endpoint in the dev loop, where an
// AI client can connect to operate the running app. Same decision point
// that auto-loads the plugin + gates the route, so the banner never
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/utils/format.seed-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { printServerReady, type ServerReadyOptions } from './format.js';

/**
* #3415 — the boot banner is the ONE place a developer reliably sees seed
* outcomes (SeedLoader's own logs are level-filtered and swallowed by the
* serve boot-quiet window). Assert the Seeds line prints, screams on
* rejections, and stays silent when nothing was seeded.
*/
describe('printServerReady seed summary (#3415)', () => {
const base: ServerReadyOptions = {
port: 3000,
configFile: 'objectstack.config.ts',
isDev: true,
pluginCount: 1,
};
let lines: string[];
let spy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
lines = [];
spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
lines.push(args.join(' '));
});
});
afterEach(() => spy.mockRestore());

const seedLines = () => lines.filter((l) => l.includes('Seeds:'));

it('prints a quiet one-liner for a clean seed', () => {
printServerReady({ ...base, seeds: { inserted: 42, updated: 6, skipped: 3, rejected: 0 } });
expect(seedLines()).toHaveLength(1);
expect(seedLines()[0]).toContain('42 inserted');
expect(seedLines()[0]).toContain('6 updated');
expect(seedLines()[0]).not.toContain('REJECTED');
});

it('screams when records were rejected', () => {
printServerReady({ ...base, seeds: { inserted: 24, updated: 0, skipped: 0, rejected: 14 } });
expect(seedLines()).toHaveLength(1);
expect(seedLines()[0]).toContain('14 REJECTED');
expect(seedLines()[0]).toContain('OS_LOG_LEVEL=info');
});

it('stays silent when no summary was collected or nothing ran', () => {
printServerReady({ ...base });
printServerReady({ ...base, seeds: { inserted: 0, updated: 0, skipped: 0, rejected: 0 } });
expect(seedLines()).toHaveLength(0);
});
});
40 changes: 40 additions & 0 deletions packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ export interface ServerReadyOptions {
* armed. Collected from the live engine after runtime.start().
*/
automation?: AutomationReadySummary;
/**
* Seed outcome for this boot (#3415). Seeds run inside the boot-quiet
* stdout window and SeedLoader's own logs sit under the default warn
* level, so without this line a fixture can silently lose most of its
* rows (the showcase shipped 1 of 5 projects for weeks). Rejections are
* loud; a clean seed prints one dim line.
*/
seeds?: SeedReadySummary;
/**
* Whether the MCP server surface (`/api/v1/mcp`) is on (#3167). Default-on
* core capability, but nothing in the dev loop surfaces it — an AI client
Expand All @@ -213,6 +221,14 @@ export interface ServerReadyOptions {
mcpEnabled?: boolean;
}

export interface SeedReadySummary {
inserted: number;
updated: number;
skipped: number;
/** Records dropped by validation/reference errors — the silent-loss case. */
rejected: number;
}

export interface AutomationReadySummary {
/** Whether the automation service is registered at all. */
enabled: boolean;
Expand Down Expand Up @@ -268,6 +284,7 @@ export function printServerReady(opts: ServerReadyOptions) {
console.log(chalk.dim(` ${opts.pluginNames.join(', ')}`));
}
if (opts.automation) printAutomationSummary(opts.automation);
if (opts.seeds) printSeedSummary(opts.seeds);
console.log('');
console.log(chalk.dim(' Press Ctrl+C to stop'));
console.log('');
Expand Down Expand Up @@ -312,6 +329,29 @@ function printAutomationSummary(a: AutomationReadySummary) {
}
}

/**
* One-glance answer to "did my seed rows actually land?" (#3415). Follows
* printAutomationSummary's contract: quiet when everything is fine, yellow
* with a count when rows were dropped — a fixture contradiction (e.g. seed
* status vs a state_machine's initialStates) must not pass silently again.
*/
function printSeedSummary(s: SeedReadySummary) {
const total = s.inserted + s.updated + s.skipped + s.rejected;
if (total === 0) return;
const parts = [`${s.inserted} inserted`];
if (s.updated > 0) parts.push(`${s.updated} updated`);
if (s.skipped > 0) parts.push(`${s.skipped} skipped`);
if (s.rejected > 0) {
console.log(
chalk.yellow(
` ⚠ Seeds: ${parts.join(' · ')} · ${s.rejected} REJECTED — run with OS_LOG_LEVEL=info to see each reason`,
),
);
return;
}
console.log(chalk.dim(` Seeds: ${parts.join(' · ')}`));
}

export function printMetadataStats(stats: MetadataStats) {
const sections: Array<{ label: string; items: Array<[string, number]> }> = [
{
Expand Down
20 changes: 20 additions & 0 deletions packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,26 @@ export class AppPlugin implements Plugin {
});
const result = await seedLoader.load(request);
const { totalInserted, totalUpdated, totalSkipped, totalErrored } = result.summary;
// #3415: stash the outcome on the kernel so the CLI boot
// banner can print a Seeds line. The logs below never
// reach `os dev` output — info is under the default warn
// level, and the serve boot-quiet window swallows stdout
// — so without this a fixture can lose most of its rows
// with no signal at all. Accumulates across apps.
try {
const kernelRef: any = (ctx as any).kernel;
const prev = (() => {
try { return kernelRef?.getService?.('seed-summary') as any; } catch { return undefined; }
})();
const summary = {
inserted: (prev?.inserted ?? 0) + totalInserted,
updated: (prev?.updated ?? 0) + totalUpdated,
skipped: (prev?.skipped ?? 0) + totalSkipped,
rejected: (prev?.rejected ?? 0) + totalErrored,
};
if (kernelRef?.registerService) kernelRef.registerService('seed-summary', summary);
else if (typeof (ctx as any).registerService === 'function') (ctx as any).registerService('seed-summary', summary);
} catch { /* banner summary is best-effort */ }
if (result.success) {
ctx.logger.info('[Seeder] Seed loading complete', {
inserted: totalInserted,
Expand Down