Skip to content

Commit d60968c

Browse files
authored
feat(runtime,cli): surface seed / marketplace-heal outcomes in the boot summary (#3430) (#3444)
Extend the #3415 config-app Seeds banner line to a per-source summary that also covers the marketplace rehydrate/heal path. The seed-summary kernel service is now a per-source list written through a shared recordSeedOutcome helper; the CLI ready banner prints one combined, log-level-independent line, marking fresh-DB heals and escalating rejections / empty installs to a yellow warning. Closes #3430.
1 parent 6f55c63 commit d60968c

10 files changed

Lines changed: 411 additions & 65 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/cli': patch
5+
---
6+
7+
Surface marketplace rehydrate/heal seed outcomes in the `os dev` / `os serve` boot banner (#3430), extending the config-app Seeds line from #3415.
8+
9+
The seed pipeline's most useful result lines are all `logger.info`, but `os dev` forwards a default `warn` level and the serve boot-quiet window swallows stdout — so "marketplace package rehydrated onto a fresh DB with 0 rows", a fresh-DB self-heal, and row-level seed failures were all invisible unless you queried the database directly.
10+
11+
The `seed-summary` kernel service is now a per-source list. AppPlugin (config apps) and the marketplace rehydrate/heal path each contribute a labelled entry, and the banner prints one combined line that ignores the log level:
12+
13+
```
14+
Seeds: showcase 162 rows · hotcrm(marketplace) 157 ok / 5 errors ⚠
15+
```
16+
17+
Fresh-DB heals are marked `(healed on fresh db)`; a marketplace package that installed with seed datasets but landed 0 rows, and any run that dropped records, escalate to a yellow `` line instead of passing silently.

packages/cli/src/commands/serve.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
printInfo,
2424
printServerReady,
2525
type AutomationReadySummary,
26+
type SeedSourceSummary,
2627
} from '../utils/format.js';
2728
import {
2829
CONSOLE_PATH,
@@ -2427,15 +2428,18 @@ export default class Serve extends Command {
24272428
Array.isArray((config as any)?.flows) ? (config as any).flows.length : 0,
24282429
);
24292430

2430-
// ── Seed outcome summary (#3415) ───────────────────────────────
2431+
// ── Seed outcome summary (#3415/#3430) ─────────────────────────
24312432
// Seeds run inside the boot-quiet window too, and SeedLoader's own
24322433
// 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;
2434+
// of its rows, or a marketplace package rehydrate onto a fresh DB
2435+
// with zero rows, all with zero terminal signal. AppPlugin and the
2436+
// marketplace rehydrate/heal path stash a per-source entry on the
2437+
// kernel; print them here, loudly when rows dropped or an install
2438+
// came up empty.
2439+
let seedSummary: SeedSourceSummary[] | undefined;
24362440
try {
24372441
const s: any = kernel.getService?.('seed-summary');
2438-
if (s && typeof s.inserted === 'number') seedSummary = s;
2442+
if (Array.isArray(s) && s.length > 0) seedSummary = s;
24392443
} catch { /* no seeds ran — nothing to show */ }
24402444

24412445
// ── Clean startup summary ──────────────────────────────────────
Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4-
import { printServerReady, type ServerReadyOptions } from './format.js';
4+
import { printServerReady, type ServerReadyOptions, type SeedSourceSummary } from './format.js';
55

66
/**
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.
7+
* #3415/#3430 — 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 serve
9+
* boot-quiet window). Assert the Seeds line prints per source, screams on
10+
* rejections AND empty marketplace installs, marks fresh-DB heals, and stays
11+
* silent when nothing was seeded.
1112
*/
12-
describe('printServerReady seed summary (#3415)', () => {
13+
describe('printServerReady seed summary (#3415/#3430)', () => {
1314
const base: ServerReadyOptions = {
1415
port: 3000,
1516
configFile: 'objectstack.config.ts',
@@ -28,25 +29,60 @@ describe('printServerReady seed summary (#3415)', () => {
2829
afterEach(() => spy.mockRestore());
2930

3031
const seedLines = () => lines.filter((l) => l.includes('Seeds:'));
32+
const s = (o: Partial<SeedSourceSummary> & { source: string }): SeedSourceSummary => ({
33+
inserted: 0, updated: 0, skipped: 0, rejected: 0, ...o,
34+
});
35+
36+
it('prints a quiet one-liner for a clean config-app seed', () => {
37+
printServerReady({ ...base, seeds: [s({ source: 'showcase', inserted: 42, updated: 6, skipped: 3 })] });
38+
expect(seedLines()).toHaveLength(1);
39+
expect(seedLines()[0]).toContain('showcase 51 rows');
40+
expect(seedLines()[0]).not.toContain('⚠');
41+
});
3142

32-
it('prints a quiet one-liner for a clean seed', () => {
33-
printServerReady({ ...base, seeds: { inserted: 42, updated: 6, skipped: 3, rejected: 0 } });
43+
it('screams when records were rejected, naming the source', () => {
44+
printServerReady({ ...base, seeds: [s({ source: 'showcase', inserted: 24, rejected: 14 })] });
3445
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');
46+
expect(seedLines()[0]).toContain('showcase 24 ok / 14 errors ⚠');
47+
expect(lines.some((l) => l.includes('OS_LOG_LEVEL=info'))).toBe(true);
3848
});
3949

40-
it('screams when records were rejected', () => {
41-
printServerReady({ ...base, seeds: { inserted: 24, updated: 0, skipped: 0, rejected: 14 } });
50+
it('labels a marketplace package and marks a fresh-DB heal', () => {
51+
printServerReady({
52+
...base,
53+
seeds: [s({ source: 'hotcrm', marketplace: true, inserted: 157, healed: true })],
54+
});
55+
expect(seedLines()).toHaveLength(1);
56+
expect(seedLines()[0]).toContain('hotcrm(marketplace) 157 rows (healed on fresh db)');
57+
expect(seedLines()[0]).not.toContain('⚠');
58+
});
59+
60+
it('escalates an installed-but-empty marketplace package', () => {
61+
printServerReady({
62+
...base,
63+
seeds: [s({ source: 'hotcrm', marketplace: true, emptyInstall: true })],
64+
});
65+
expect(seedLines()).toHaveLength(1);
66+
expect(seedLines()[0]).toContain('hotcrm(marketplace) installed but 0 rows ⚠');
67+
});
68+
69+
it('combines multiple sources on one line', () => {
70+
printServerReady({
71+
...base,
72+
seeds: [
73+
s({ source: 'showcase', inserted: 162 }),
74+
s({ source: 'hotcrm', marketplace: true, inserted: 157, rejected: 5 }),
75+
],
76+
});
4277
expect(seedLines()).toHaveLength(1);
43-
expect(seedLines()[0]).toContain('14 REJECTED');
44-
expect(seedLines()[0]).toContain('OS_LOG_LEVEL=info');
78+
expect(seedLines()[0]).toContain('showcase 162 rows');
79+
expect(seedLines()[0]).toContain('hotcrm(marketplace) 157 ok / 5 errors ⚠');
4580
});
4681

4782
it('stays silent when no summary was collected or nothing ran', () => {
4883
printServerReady({ ...base });
49-
printServerReady({ ...base, seeds: { inserted: 0, updated: 0, skipped: 0, rejected: 0 } });
84+
printServerReady({ ...base, seeds: [] });
85+
printServerReady({ ...base, seeds: [s({ source: 'showcase' })] });
5086
expect(seedLines()).toHaveLength(0);
5187
});
5288
});

packages/cli/src/utils/format.ts

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,15 @@ export interface ServerReadyOptions {
204204
*/
205205
automation?: AutomationReadySummary;
206206
/**
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.
207+
* Per-source seed outcomes for this boot (#3415/#3430). Seeds run inside the
208+
* boot-quiet stdout window and SeedLoader's own logs sit under the default
209+
* warn level, so without this line a fixture can silently lose most of its
210+
* rows (the showcase shipped 1 of 5 projects for weeks) and a marketplace
211+
* package can rehydrate onto a fresh DB with zero rows. Each config app and
212+
* each rehydrated/healed marketplace package contributes one entry;
213+
* rejections and empty installs are loud, a clean seed prints one dim line.
212214
*/
213-
seeds?: SeedReadySummary;
215+
seeds?: SeedSourceSummary[];
214216
/**
215217
* Whether the MCP server surface (`/api/v1/mcp`) is on (#3167). Default-on
216218
* core capability, but nothing in the dev loop surfaces it — an AI client
@@ -221,12 +223,26 @@ export interface ServerReadyOptions {
221223
mcpEnabled?: boolean;
222224
}
223225

224-
export interface SeedReadySummary {
226+
export interface SeedSourceSummary {
227+
/** Display label — the config app id / marketplace manifest id that seeded. */
228+
source: string;
229+
/** True when the source is a marketplace package (vs a config-declared app). */
230+
marketplace?: boolean;
225231
inserted: number;
226232
updated: number;
227233
skipped: number;
228234
/** Records dropped by validation/reference errors — the silent-loss case. */
229235
rejected: number;
236+
/**
237+
* Rows were (re)seeded onto a fresh/empty database during rehydrate — the
238+
* "swap the DB out from under an installed package" self-heal (#3430).
239+
*/
240+
healed?: boolean;
241+
/**
242+
* A marketplace package rehydrated with seed datasets declared, yet every
243+
* seeded object came up empty — the "installed but 0 rows" case (#3430).
244+
*/
245+
emptyInstall?: boolean;
230246
}
231247

232248
export interface AutomationReadySummary {
@@ -330,26 +346,46 @@ function printAutomationSummary(a: AutomationReadySummary) {
330346
}
331347

332348
/**
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.
349+
* One-glance answer to "did my seed rows actually land — from every source?"
350+
* (#3415/#3430). Follows printAutomationSummary's contract: quiet when
351+
* everything is fine, yellow with the reason when rows were dropped or a
352+
* marketplace package came up empty. Both config apps (AppPlugin) and
353+
* rehydrated/healed marketplace packages contribute, e.g.
354+
*
355+
* Seeds: showcase 162 rows · hotcrm(marketplace) 157 ok / 5 errors ⚠
356+
*
357+
* A fixture contradiction (seed status vs a state_machine's initialStates), a
358+
* row-level lookup failure, or a marketplace package that healed onto a fresh
359+
* DB with zero rows must never pass silently again.
337360
*/
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-
);
361+
function printSeedSummary(sources: SeedSourceSummary[]) {
362+
const shown = sources.filter((s) => {
363+
// Empty installs and rejections are ALWAYS shown (they're the whole point);
364+
// a source that touched no rows and had no problem is noise — drop it.
365+
if (s.emptyInstall || s.rejected > 0) return true;
366+
return s.inserted + s.updated + s.skipped > 0;
367+
});
368+
if (shown.length === 0) return;
369+
370+
const anyProblem = shown.some((s) => s.rejected > 0 || s.emptyInstall);
371+
372+
const fragment = (s: SeedSourceSummary): string => {
373+
const label = s.marketplace ? `${s.source}(marketplace)` : s.source;
374+
if (s.emptyInstall) return `${label} installed but 0 rows ⚠`;
375+
const ok = s.inserted + s.updated + s.skipped;
376+
if (s.rejected > 0) {
377+
return `${label} ${ok} ok / ${s.rejected} error${s.rejected === 1 ? '' : 's'} ⚠`;
378+
}
379+
return `${label} ${ok} rows${s.healed ? ' (healed on fresh db)' : ''}`;
380+
};
381+
382+
const line = shown.map(fragment).join(' · ');
383+
if (anyProblem) {
384+
console.log(chalk.yellow(` ⚠ Seeds: ${line}`));
385+
console.log(chalk.dim(' run with OS_LOG_LEVEL=info to see each dropped record'));
350386
return;
351387
}
352-
console.log(chalk.dim(` Seeds: ${parts.join(' · ')}`));
388+
console.log(chalk.dim(` Seeds: ${line}`));
353389
}
354390

355391
export function printMetadataStats(stats: MetadataStats) {

packages/cloud-connection/src/marketplace-install-local-heal.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,16 @@ vi.mock('@objectstack/runtime', () => ({
3131
SeedLoaderService: class {
3232
async load(request: any) { loadCalls.push(request); return seedResult; }
3333
},
34+
// #3430 — the heal path records a per-source outcome for the boot banner.
35+
recordSeedOutcome: vi.fn(),
3436
}));
3537
vi.mock('@objectstack/spec/data', () => ({
3638
SeedLoaderRequestSchema: { parse: (x: any) => x },
3739
}));
3840

3941
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
4042
import { LocalManifestSource } from './local-manifest-source.js';
43+
import { recordSeedOutcome } from '@objectstack/runtime';
4144

4245
type Handler = (c: any) => Promise<any>;
4346

@@ -112,6 +115,7 @@ beforeEach(() => {
112115
dir = mkdtempSync(join(tmpdir(), 'mil-heal-'));
113116
seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
114117
loadCalls = [];
118+
vi.mocked(recordSeedOutcome).mockClear();
115119
});
116120
afterEach(() => {
117121
rmSync(dir, { recursive: true, force: true });
@@ -157,6 +161,13 @@ describe('rehydrate sample-data healing', () => {
157161
const entry = new LocalManifestSource(dir).read(MANIFEST.id)!;
158162
expect(entry.withSampleData).toBe(true);
159163
expect(entry.sampleDataPurged).toBe(false);
164+
165+
// #3430 — the fresh-DB heal is surfaced in the boot banner, labelled as
166+
// a marketplace source and marked healed.
167+
expect(recordSeedOutcome).toHaveBeenCalledWith(
168+
expect.anything(),
169+
expect.objectContaining({ source: MANIFEST.id, marketplace: true, healed: true, inserted: 3 }),
170+
);
160171
});
161172

162173
it('does NOT reseed when any seeded object still has rows', async () => {
@@ -183,6 +194,12 @@ describe('rehydrate sample-data healing', () => {
183194
expect(entry.withSampleData).toBe(false);
184195
// The failure is loud, with the underlying reason.
185196
expect((ctx.logger.warn as any).mock.calls.some((c: any[]) => String(c[0]).includes('database is locked'))).toBe(true);
197+
// #3430 — installed package, seeds declared, yet 0 rows landed: the
198+
// banner escalates it as an empty install instead of staying silent.
199+
expect(recordSeedOutcome).toHaveBeenCalledWith(
200+
expect.anything(),
201+
expect.objectContaining({ source: MANIFEST.id, marketplace: true, emptyInstall: true }),
202+
);
186203
});
187204

188205
it('purge → restart keeps the package empty (end to end through the endpoints)', async () => {

packages/cloud-connection/src/marketplace-install-local-plugin.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,14 +253,73 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
253253
entry.sampleDataPurged = false;
254254
try { this.ledger.write(entry); } catch { /* non-fatal */ }
255255
ctx.logger?.info?.(`[MarketplaceInstallLocal] healed sample data for ${entry.manifestId}: inserted=${summary.inserted} updated=${summary.updated} errors=${summary.errors}`);
256+
// #3430: surface the fresh-DB self-heal in the boot banner — the
257+
// info line above is swallowed by the default warn level, so this
258+
// was previously only confirmable by querying the database.
259+
await this.recordSeedSummary(ctx, {
260+
source: entry.manifestId,
261+
marketplace: true,
262+
inserted: summary.inserted ?? 0,
263+
updated: summary.updated ?? 0,
264+
skipped: summary.skipped ?? 0,
265+
rejected: summary.errors ?? 0,
266+
healed: true,
267+
});
256268
} else {
257269
ctx.logger?.warn?.(`[MarketplaceInstallLocal] sample-data heal for ${entry.manifestId} landed no rows${summary.errorSample ? ` — first error: ${summary.errorSample}` : ''}`);
270+
// Installed package, seed datasets declared, yet 0 rows landed —
271+
// the "app in the switcher, every KPI 0" case. Escalate it in the
272+
// banner (emptyInstall ⇒ ⚠) rather than let it pass silently.
273+
await this.recordSeedSummary(ctx, {
274+
source: entry.manifestId,
275+
marketplace: true,
276+
inserted: 0,
277+
updated: 0,
278+
skipped: 0,
279+
rejected: summary.errors ?? 0,
280+
emptyInstall: true,
281+
});
258282
}
259283
} catch (err: any) {
260284
ctx.logger?.warn?.(`[MarketplaceInstallLocal] sample-data heal failed for ${entry.manifestId}: ${err?.message ?? err}`);
285+
await this.recordSeedSummary(ctx, {
286+
source: entry.manifestId,
287+
marketplace: true,
288+
inserted: 0,
289+
updated: 0,
290+
skipped: 0,
291+
rejected: 0,
292+
emptyInstall: true,
293+
});
261294
}
262295
};
263296

297+
/**
298+
* Append a per-source outcome onto the kernel's `seed-summary` service so
299+
* the CLI boot banner can print it (#3430). Resolved lazily through the
300+
* runtime's shared writer contract; guarded so a runtime that predates the
301+
* helper — or a test that mocks `@objectstack/runtime` without it — simply
302+
* skips the banner line instead of crashing the heal path.
303+
*/
304+
private recordSeedSummary = async (
305+
ctx: PluginContext,
306+
outcome: {
307+
source: string;
308+
marketplace?: boolean;
309+
inserted: number;
310+
updated: number;
311+
skipped: number;
312+
rejected: number;
313+
healed?: boolean;
314+
emptyInstall?: boolean;
315+
},
316+
): Promise<void> => {
317+
try {
318+
const mod: any = await import('@objectstack/runtime');
319+
if (typeof mod?.recordSeedOutcome === 'function') mod.recordSeedOutcome(ctx, outcome);
320+
} catch { /* banner summary is best-effort — never break the heal */ }
321+
};
322+
264323
private handleInstall = async (c: any, ctx: PluginContext): Promise<Response> => {
265324
const userId = await this.requireAuthenticatedUser(c, ctx);
266325
if (!userId) {

0 commit comments

Comments
 (0)