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

Surface marketplace rehydrate/heal seed outcomes in the `os dev` / `os serve` boot banner (#3430), extending the config-app Seeds line from #3415.

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.

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:

```
Seeds: showcase 162 rows · hotcrm(marketplace) 157 ok / 5 errors ⚠
```

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.
14 changes: 9 additions & 5 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
printInfo,
printServerReady,
type AutomationReadySummary,
type SeedSourceSummary,
} from '../utils/format.js';
import {
CONSOLE_PATH,
Expand Down Expand Up @@ -2427,15 +2428,18 @@ export default class Serve extends Command {
Array.isArray((config as any)?.flows) ? (config as any).flows.length : 0,
);

// ── Seed outcome summary (#3415) ───────────────────────────────
// ── Seed outcome summary (#3415/#3430) ─────────────────────────
// 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;
// of its rows, or a marketplace package rehydrate onto a fresh DB
// with zero rows, all with zero terminal signal. AppPlugin and the
// marketplace rehydrate/heal path stash a per-source entry on the
// kernel; print them here, loudly when rows dropped or an install
// came up empty.
let seedSummary: SeedSourceSummary[] | undefined;
try {
const s: any = kernel.getService?.('seed-summary');
if (s && typeof s.inserted === 'number') seedSummary = s;
if (Array.isArray(s) && s.length > 0) seedSummary = s;
} catch { /* no seeds ran — nothing to show */ }

// ── Clean startup summary ──────────────────────────────────────
Expand Down
68 changes: 52 additions & 16 deletions packages/cli/src/utils/format.seed-summary.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
// 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';
import { printServerReady, type ServerReadyOptions, type SeedSourceSummary } 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.
* #3415/#3430 — 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 per source, screams on
* rejections AND empty marketplace installs, marks fresh-DB heals, and stays
* silent when nothing was seeded.
*/
describe('printServerReady seed summary (#3415)', () => {
describe('printServerReady seed summary (#3415/#3430)', () => {
const base: ServerReadyOptions = {
port: 3000,
configFile: 'objectstack.config.ts',
Expand All @@ -28,25 +29,60 @@ describe('printServerReady seed summary (#3415)', () => {
afterEach(() => spy.mockRestore());

const seedLines = () => lines.filter((l) => l.includes('Seeds:'));
const s = (o: Partial<SeedSourceSummary> & { source: string }): SeedSourceSummary => ({
inserted: 0, updated: 0, skipped: 0, rejected: 0, ...o,
});

it('prints a quiet one-liner for a clean config-app seed', () => {
printServerReady({ ...base, seeds: [s({ source: 'showcase', inserted: 42, updated: 6, skipped: 3 })] });
expect(seedLines()).toHaveLength(1);
expect(seedLines()[0]).toContain('showcase 51 rows');
expect(seedLines()[0]).not.toContain('⚠');
});

it('prints a quiet one-liner for a clean seed', () => {
printServerReady({ ...base, seeds: { inserted: 42, updated: 6, skipped: 3, rejected: 0 } });
it('screams when records were rejected, naming the source', () => {
printServerReady({ ...base, seeds: [s({ source: 'showcase', inserted: 24, rejected: 14 })] });
expect(seedLines()).toHaveLength(1);
expect(seedLines()[0]).toContain('42 inserted');
expect(seedLines()[0]).toContain('6 updated');
expect(seedLines()[0]).not.toContain('REJECTED');
expect(seedLines()[0]).toContain('showcase 24 ok / 14 errors ⚠');
expect(lines.some((l) => l.includes('OS_LOG_LEVEL=info'))).toBe(true);
});

it('screams when records were rejected', () => {
printServerReady({ ...base, seeds: { inserted: 24, updated: 0, skipped: 0, rejected: 14 } });
it('labels a marketplace package and marks a fresh-DB heal', () => {
printServerReady({
...base,
seeds: [s({ source: 'hotcrm', marketplace: true, inserted: 157, healed: true })],
});
expect(seedLines()).toHaveLength(1);
expect(seedLines()[0]).toContain('hotcrm(marketplace) 157 rows (healed on fresh db)');
expect(seedLines()[0]).not.toContain('⚠');
});

it('escalates an installed-but-empty marketplace package', () => {
printServerReady({
...base,
seeds: [s({ source: 'hotcrm', marketplace: true, emptyInstall: true })],
});
expect(seedLines()).toHaveLength(1);
expect(seedLines()[0]).toContain('hotcrm(marketplace) installed but 0 rows ⚠');
});

it('combines multiple sources on one line', () => {
printServerReady({
...base,
seeds: [
s({ source: 'showcase', inserted: 162 }),
s({ source: 'hotcrm', marketplace: true, inserted: 157, rejected: 5 }),
],
});
expect(seedLines()).toHaveLength(1);
expect(seedLines()[0]).toContain('14 REJECTED');
expect(seedLines()[0]).toContain('OS_LOG_LEVEL=info');
expect(seedLines()[0]).toContain('showcase 162 rows');
expect(seedLines()[0]).toContain('hotcrm(marketplace) 157 ok / 5 errors ⚠');
});

it('stays silent when no summary was collected or nothing ran', () => {
printServerReady({ ...base });
printServerReady({ ...base, seeds: { inserted: 0, updated: 0, skipped: 0, rejected: 0 } });
printServerReady({ ...base, seeds: [] });
printServerReady({ ...base, seeds: [s({ source: 'showcase' })] });
expect(seedLines()).toHaveLength(0);
});
});
84 changes: 60 additions & 24 deletions packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,15 @@ export interface ServerReadyOptions {
*/
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.
* Per-source seed outcomes for this boot (#3415/#3430). 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) and a marketplace
* package can rehydrate onto a fresh DB with zero rows. Each config app and
* each rehydrated/healed marketplace package contributes one entry;
* rejections and empty installs are loud, a clean seed prints one dim line.
*/
seeds?: SeedReadySummary;
seeds?: SeedSourceSummary[];
/**
* 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 @@ -221,12 +223,26 @@ export interface ServerReadyOptions {
mcpEnabled?: boolean;
}

export interface SeedReadySummary {
export interface SeedSourceSummary {
/** Display label — the config app id / marketplace manifest id that seeded. */
source: string;
/** True when the source is a marketplace package (vs a config-declared app). */
marketplace?: boolean;
inserted: number;
updated: number;
skipped: number;
/** Records dropped by validation/reference errors — the silent-loss case. */
rejected: number;
/**
* Rows were (re)seeded onto a fresh/empty database during rehydrate — the
* "swap the DB out from under an installed package" self-heal (#3430).
*/
healed?: boolean;
/**
* A marketplace package rehydrated with seed datasets declared, yet every
* seeded object came up empty — the "installed but 0 rows" case (#3430).
*/
emptyInstall?: boolean;
}

export interface AutomationReadySummary {
Expand Down Expand Up @@ -330,26 +346,46 @@ 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.
* One-glance answer to "did my seed rows actually land — from every source?"
* (#3415/#3430). Follows printAutomationSummary's contract: quiet when
* everything is fine, yellow with the reason when rows were dropped or a
* marketplace package came up empty. Both config apps (AppPlugin) and
* rehydrated/healed marketplace packages contribute, e.g.
*
* Seeds: showcase 162 rows · hotcrm(marketplace) 157 ok / 5 errors ⚠
*
* A fixture contradiction (seed status vs a state_machine's initialStates), a
* row-level lookup failure, or a marketplace package that healed onto a fresh
* DB with zero rows must never 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`,
),
);
function printSeedSummary(sources: SeedSourceSummary[]) {
const shown = sources.filter((s) => {
// Empty installs and rejections are ALWAYS shown (they're the whole point);
// a source that touched no rows and had no problem is noise — drop it.
if (s.emptyInstall || s.rejected > 0) return true;
return s.inserted + s.updated + s.skipped > 0;
});
if (shown.length === 0) return;

const anyProblem = shown.some((s) => s.rejected > 0 || s.emptyInstall);

const fragment = (s: SeedSourceSummary): string => {
const label = s.marketplace ? `${s.source}(marketplace)` : s.source;
if (s.emptyInstall) return `${label} installed but 0 rows ⚠`;
const ok = s.inserted + s.updated + s.skipped;
if (s.rejected > 0) {
return `${label} ${ok} ok / ${s.rejected} error${s.rejected === 1 ? '' : 's'} ⚠`;
}
return `${label} ${ok} rows${s.healed ? ' (healed on fresh db)' : ''}`;
};

const line = shown.map(fragment).join(' · ');
if (anyProblem) {
console.log(chalk.yellow(` ⚠ Seeds: ${line}`));
console.log(chalk.dim(' run with OS_LOG_LEVEL=info to see each dropped record'));
return;
}
console.log(chalk.dim(` Seeds: ${parts.join(' · ')}`));
console.log(chalk.dim(` Seeds: ${line}`));
}

export function printMetadataStats(stats: MetadataStats) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@ vi.mock('@objectstack/runtime', () => ({
SeedLoaderService: class {
async load(request: any) { loadCalls.push(request); return seedResult; }
},
// #3430 — the heal path records a per-source outcome for the boot banner.
recordSeedOutcome: vi.fn(),
}));
vi.mock('@objectstack/spec/data', () => ({
SeedLoaderRequestSchema: { parse: (x: any) => x },
}));

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

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

Expand Down Expand Up @@ -112,6 +115,7 @@ beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mil-heal-'));
seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
loadCalls = [];
vi.mocked(recordSeedOutcome).mockClear();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
Expand Down Expand Up @@ -157,6 +161,13 @@ describe('rehydrate sample-data healing', () => {
const entry = new LocalManifestSource(dir).read(MANIFEST.id)!;
expect(entry.withSampleData).toBe(true);
expect(entry.sampleDataPurged).toBe(false);

// #3430 — the fresh-DB heal is surfaced in the boot banner, labelled as
// a marketplace source and marked healed.
expect(recordSeedOutcome).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ source: MANIFEST.id, marketplace: true, healed: true, inserted: 3 }),
);
});

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

it('purge → restart keeps the package empty (end to end through the endpoints)', async () => {
Expand Down
59 changes: 59 additions & 0 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,73 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
entry.sampleDataPurged = false;
try { this.ledger.write(entry); } catch { /* non-fatal */ }
ctx.logger?.info?.(`[MarketplaceInstallLocal] healed sample data for ${entry.manifestId}: inserted=${summary.inserted} updated=${summary.updated} errors=${summary.errors}`);
// #3430: surface the fresh-DB self-heal in the boot banner — the
// info line above is swallowed by the default warn level, so this
// was previously only confirmable by querying the database.
await this.recordSeedSummary(ctx, {
source: entry.manifestId,
marketplace: true,
inserted: summary.inserted ?? 0,
updated: summary.updated ?? 0,
skipped: summary.skipped ?? 0,
rejected: summary.errors ?? 0,
healed: true,
});
} else {
ctx.logger?.warn?.(`[MarketplaceInstallLocal] sample-data heal for ${entry.manifestId} landed no rows${summary.errorSample ? ` — first error: ${summary.errorSample}` : ''}`);
// Installed package, seed datasets declared, yet 0 rows landed —
// the "app in the switcher, every KPI 0" case. Escalate it in the
// banner (emptyInstall ⇒ ⚠) rather than let it pass silently.
await this.recordSeedSummary(ctx, {
source: entry.manifestId,
marketplace: true,
inserted: 0,
updated: 0,
skipped: 0,
rejected: summary.errors ?? 0,
emptyInstall: true,
});
}
} catch (err: any) {
ctx.logger?.warn?.(`[MarketplaceInstallLocal] sample-data heal failed for ${entry.manifestId}: ${err?.message ?? err}`);
await this.recordSeedSummary(ctx, {
source: entry.manifestId,
marketplace: true,
inserted: 0,
updated: 0,
skipped: 0,
rejected: 0,
emptyInstall: true,
});
}
};

/**
* Append a per-source outcome onto the kernel's `seed-summary` service so
* the CLI boot banner can print it (#3430). Resolved lazily through the
* runtime's shared writer contract; guarded so a runtime that predates the
* helper — or a test that mocks `@objectstack/runtime` without it — simply
* skips the banner line instead of crashing the heal path.
*/
private recordSeedSummary = async (
ctx: PluginContext,
outcome: {
source: string;
marketplace?: boolean;
inserted: number;
updated: number;
skipped: number;
rejected: number;
healed?: boolean;
emptyInstall?: boolean;
},
): Promise<void> => {
try {
const mod: any = await import('@objectstack/runtime');
if (typeof mod?.recordSeedOutcome === 'function') mod.recordSeedOutcome(ctx, outcome);
} catch { /* banner summary is best-effort — never break the heal */ }
};

private handleInstall = async (c: any, ctx: PluginContext): Promise<Response> => {
const userId = await this.requireAuthenticatedUser(c, ctx);
if (!userId) {
Expand Down
Loading