diff --git a/.changeset/seed-summary-marketplace.md b/.changeset/seed-summary-marketplace.md new file mode 100644 index 000000000..f215a97de --- /dev/null +++ b/.changeset/seed-summary-marketplace.md @@ -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. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index d10b8a6d9..fa86be5fc 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -23,6 +23,7 @@ import { printInfo, printServerReady, type AutomationReadySummary, + type SeedSourceSummary, } from '../utils/format.js'; import { CONSOLE_PATH, @@ -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 ────────────────────────────────────── diff --git a/packages/cli/src/utils/format.seed-summary.test.ts b/packages/cli/src/utils/format.seed-summary.test.ts index 88281233c..f445e8896 100644 --- a/packages/cli/src/utils/format.seed-summary.test.ts +++ b/packages/cli/src/utils/format.seed-summary.test.ts @@ -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', @@ -28,25 +29,60 @@ describe('printServerReady seed summary (#3415)', () => { afterEach(() => spy.mockRestore()); const seedLines = () => lines.filter((l) => l.includes('Seeds:')); + const s = (o: Partial & { 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); }); }); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index fb71d1bec..bdd1b190b 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -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 @@ -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 { @@ -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) { diff --git a/packages/cloud-connection/src/marketplace-install-local-heal.test.ts b/packages/cloud-connection/src/marketplace-install-local-heal.test.ts index 3781dddf9..f41c1f7cf 100644 --- a/packages/cloud-connection/src/marketplace-install-local-heal.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-heal.test.ts @@ -31,6 +31,8 @@ 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 }, @@ -38,6 +40,7 @@ vi.mock('@objectstack/spec/data', () => ({ 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; @@ -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 }); @@ -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 () => { @@ -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 () => { diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index efa332c08..201feff98 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -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 => { + 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 => { const userId = await this.requireAuthenticatedUser(c, ctx); if (!userId) { diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index c8be1cade..184ebca29 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -4,6 +4,7 @@ import { Plugin, PluginContext, wireAuthoredTranslationSync } from '@objectstack import { assertProtocolCompat } from '@objectstack/metadata-core'; import { resolveMultiOrgEnabled } from '@objectstack/types'; import { SeedLoaderService } from './seed-loader.js'; +import { recordSeedOutcome } from './seed-summary.js'; import { loadDisabledPackageIds } from './package-state-store.js'; import type { IMetadataService, II18nService } from '@objectstack/spec/contracts'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; @@ -842,26 +843,19 @@ 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 */ } + // #3415/#3430: stash a per-source 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. One labelled entry per config app. + recordSeedOutcome(ctx, { + source: String(appId), + inserted: totalInserted, + updated: totalUpdated, + skipped: totalSkipped, + rejected: totalErrored, + }); if (result.success) { ctx.logger.info('[Seeder] Seed loading complete', { inserted: totalInserted, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2f50dbc89..74c94f36e 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -19,6 +19,10 @@ export type { DefaultHostConfigOptions, DefaultHostConfigResult } from './defaul export { DriverPlugin } from './driver-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleActions } from './app-plugin.js'; export { SeedLoaderService } from './seed-loader.js'; +// Boot-summary seed outcome accumulator (#3415/#3430) — the single writer +// contract shared by AppPlugin and the marketplace rehydrate/heal path. +export { recordSeedOutcome } from './seed-summary.js'; +export type { SeedSourceOutcome } from './seed-summary.js'; // External Datasource Federation — boot-validation gate (ADR-0015, Gate 2) export { ExternalValidationPlugin, createExternalValidationPlugin } from './external-validation-plugin.js'; export type { ExternalSchemaDriftEvent } from './external-validation-plugin.js'; diff --git a/packages/runtime/src/seed-summary.test.ts b/packages/runtime/src/seed-summary.test.ts new file mode 100644 index 000000000..bdb5f7913 --- /dev/null +++ b/packages/runtime/src/seed-summary.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { recordSeedOutcome, type SeedSourceOutcome } from './seed-summary.js'; + +/** + * #3415/#3430 — the boot-summary writer contract. The kernel's registerService + * THROWS on a duplicate name, so the accumulator must register the list once and + * then mutate the same reference in place. These tests pin that (a second source + * on the same boot must not crash) plus the per-source merge semantics. + */ +describe('recordSeedOutcome', () => { + /** A kernel-ish context: getService throws on miss, registerService throws on dupe. */ + function makeCtx() { + const services = new Map(); + return { + services, + getService: (n: string) => { + if (!services.has(n)) throw new Error(`no service ${n}`); + return services.get(n); + }, + registerService: (n: string, v: unknown) => { + if (services.has(n)) throw new Error(`[Kernel] Service '${n}' already registered`); + services.set(n, v); + }, + }; + } + + const out = (o: Partial & { source: string }): SeedSourceOutcome => ({ + inserted: 0, updated: 0, skipped: 0, rejected: 0, ...o, + }); + + it('registers a new summary list on the first call', () => { + const ctx = makeCtx(); + recordSeedOutcome(ctx, out({ source: 'showcase', inserted: 42 })); + expect(ctx.services.get('seed-summary')).toEqual([ + { source: 'showcase', inserted: 42, updated: 0, skipped: 0, rejected: 0 }, + ]); + }); + + it('appends a second source WITHOUT a duplicate-register throw', () => { + const ctx = makeCtx(); + recordSeedOutcome(ctx, out({ source: 'showcase', inserted: 10 })); + // registerService would throw on a second register — the helper must + // instead mutate the list already stored under the same name. + expect(() => recordSeedOutcome(ctx, out({ source: 'hotcrm', marketplace: true, inserted: 5 }))).not.toThrow(); + const list = ctx.services.get('seed-summary') as SeedSourceOutcome[]; + expect(list).toHaveLength(2); + expect(list.map((e) => e.source)).toEqual(['showcase', 'hotcrm']); + }); + + it('merges counts and ORs flags for the same (source, marketplace) key', () => { + const ctx = makeCtx(); + recordSeedOutcome(ctx, out({ source: 'hotcrm', marketplace: true, inserted: 3, rejected: 1 })); + recordSeedOutcome(ctx, out({ source: 'hotcrm', marketplace: true, updated: 2, rejected: 4, healed: true })); + const list = ctx.services.get('seed-summary') as SeedSourceOutcome[]; + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ inserted: 3, updated: 2, rejected: 5, healed: true }); + }); + + it('keeps a config app and a same-named marketplace package as separate entries', () => { + const ctx = makeCtx(); + recordSeedOutcome(ctx, out({ source: 'crm', inserted: 1 })); + recordSeedOutcome(ctx, out({ source: 'crm', marketplace: true, inserted: 2 })); + const list = ctx.services.get('seed-summary') as SeedSourceOutcome[]; + expect(list).toHaveLength(2); + }); + + it('never throws when the context exposes no service methods', () => { + expect(() => recordSeedOutcome({}, out({ source: 'x', inserted: 1 }))).not.toThrow(); + expect(() => recordSeedOutcome(undefined, out({ source: 'x', inserted: 1 }))).not.toThrow(); + }); +}); diff --git a/packages/runtime/src/seed-summary.ts b/packages/runtime/src/seed-summary.ts new file mode 100644 index 000000000..21b6dc6ac --- /dev/null +++ b/packages/runtime/src/seed-summary.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Boot-time seed outcome accumulator (#3415, extended #3430). + * + * Seeds run inside the CLI's boot-quiet stdout window and SeedLoader's own + * result logs sit under the default `warn` level, so `os dev` shows NOTHING + * about how seeding actually went — a fixture can silently lose most of its + * rows, a marketplace package can rehydrate onto a fresh DB with zero rows, + * and a partial row-level failure leaves no signal at all. + * + * Every seeding producer (AppPlugin's inline config-app seed, the + * marketplace rehydrate/heal path) records a per-source outcome on the + * kernel's `seed-summary` service via {@link recordSeedOutcome}; the CLI + * reads it once after `runtime.start()` and prints a `Seeds:` line in the + * ready banner that is immune to the log level. This module is the ONE + * writer contract so those producers can't drift into separate shapes. + */ + +/** One seed source's contribution to the boot summary. */ +export interface SeedSourceOutcome { + /** 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; + /** Rows inserted this boot. */ + inserted: number; + /** Rows updated this boot. */ + updated: number; + /** Rows already present (upsert no-op). */ + skipped: number; + /** Rows dropped by validation/reference errors — the silent-loss case. */ + rejected: number; + /** + * The rows were (re)seeded onto a fresh/empty database during rehydrate — + * the "swap the DB out from under an installed package" self-heal. Surfaced + * so that event is observable instead of confirmable only by querying the DB. + */ + healed?: boolean; + /** + * A marketplace package rehydrated with seed datasets declared, yet every + * seeded object came up empty (the heal ran and landed no rows, or could + * not run). The "installed but 0 rows" state — must never pass silently. + */ + emptyInstall?: boolean; +} + +/** + * Append (or merge) a per-source seed outcome onto the kernel's `seed-summary` + * service. Best-effort: never throws, so a banner-only concern can't break boot. + * + * The kernel's `registerService` throws on a duplicate name, and `getService` + * returns the SAME array reference, so we mutate the stored list in place and + * only register the first time. Entries are keyed by (source, marketplace) so a + * producer that runs twice accumulates rather than duplicating a row. + */ +export function recordSeedOutcome(ctx: unknown, outcome: SeedSourceOutcome): void { + try { + const c = ctx as { + kernel?: { getService?: (n: string) => unknown; registerService?: (n: string, v: unknown) => unknown }; + getService?: (n: string) => unknown; + registerService?: (n: string, v: unknown) => unknown; + }; + const kernel = c?.kernel; + + const readSummary = (): unknown => { + // Prefer the plugin context's own resolver; fall back to a raw kernel. + // Both throw when the service is unregistered — swallow and treat as absent. + if (typeof c?.getService === 'function') { + try { return c.getService('seed-summary'); } catch { /* not registered */ } + } + if (typeof kernel?.getService === 'function') { + try { return kernel.getService('seed-summary'); } catch { /* not registered */ } + } + return undefined; + }; + + const register = (value: unknown): void => { + if (typeof kernel?.registerService === 'function') { kernel.registerService('seed-summary', value); return; } + if (typeof c?.registerService === 'function') { c.registerService('seed-summary', value); } + }; + + const current = readSummary(); + const list: SeedSourceOutcome[] = Array.isArray(current) ? (current as SeedSourceOutcome[]) : []; + + const existing = list.find( + (e) => e.source === outcome.source && Boolean(e.marketplace) === Boolean(outcome.marketplace), + ); + if (existing) { + existing.inserted += outcome.inserted; + existing.updated += outcome.updated; + existing.skipped += outcome.skipped; + existing.rejected += outcome.rejected; + existing.healed = existing.healed || outcome.healed; + existing.emptyInstall = existing.emptyInstall || outcome.emptyInstall; + } else { + list.push({ ...outcome }); + } + + // getService returns the live reference we just mutated, so an existing + // summary is already up to date — only a brand-new list needs registering. + if (!Array.isArray(current)) register(list); + } catch { + /* banner summary is best-effort — never break boot */ + } +}