Skip to content

Commit 1c8bf4f

Browse files
os-zhuangclaude
andauthored
fix(marketplace): heal missing sample data when rehydrating installed packages onto a new database (#3421)
The install ledger (.objectstack/installed-packages/) is anchored to the project directory while the database can be swapped out from under it (os dev --fresh, a deleted dev.db, a --database switch). Rehydrate deliberately never re-seeds, which left a rehydrated marketplace package permanently empty on a new database: app in the switcher, tables created, zero rows — the "HotCRM installed but every KPI is 0" state. Rehydrate now replays the bundled seed datasets iff the manifest bundles them, they were never explicitly purged (new sampleDataPurged ledger marker), the runtime is single-tenant, and EVERY seeded object is empty — one surviving row anywhere leaves the database untouched, so the heal is idempotent and can never revert user edits on restart. Install now also marks withSampleData=true on an all-skipped seed run (rows already present), instead of leaving the flag false over a seeded database. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 4c5a584 commit 1c8bf4f

4 files changed

Lines changed: 391 additions & 30 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
fix(marketplace): heal missing sample data when rehydrating installed packages onto a new database
6+
7+
The install ledger (`.objectstack/installed-packages/`) is anchored to the
8+
project directory while the database can be swapped out from under it —
9+
`os dev --fresh`, a deleted `dev.db`, a `--database` switch. Rehydrate
10+
deliberately never re-seeds (existing rows must not be re-upserted over user
11+
edits on every boot), which left a rehydrated marketplace package PERMANENTLY
12+
empty on a new database: app in the switcher, tables created, zero rows — the
13+
"HotCRM installed but every KPI is 0 / Sales Pipeline all-empty" state.
14+
15+
Rehydrate now runs the bundled seed datasets iff the manifest actually bundles
16+
them, the user never explicitly purged them, the runtime is single-tenant
17+
(multi-tenant seeding stays owned by the per-org replay), and EVERY seeded
18+
object is empty — one surviving row anywhere means the data is still there and
19+
nothing is touched, so the heal is idempotent across restarts and can never
20+
revert user edits.
21+
22+
Also fixed along the way: a purge now stamps `sampleDataPurged` on the ledger
23+
entry (so healed restarts respect the deliberate empty baseline), and install
24+
marks `withSampleData: true` when the seed run reports all rows *skipped*
25+
(already present, e.g. a reinstall over live demo data) instead of leaving the
26+
flag false over a seeded database.

packages/cloud-connection/src/local-manifest-source.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export interface InstalledManifestEntry {
4444
* database. True after install (seedNow=true) or an explicit reseed;
4545
* false after a purge. Persisted so the UI can show "Add" vs "Re-seed". */
4646
withSampleData?: boolean;
47+
/** True only after an explicit purge-sample-data call. The rehydrate-time
48+
* sample-data healer (see MarketplaceInstallLocalPlugin.maybeHealSampleData)
49+
* must not resurrect demo rows the user deliberately removed — an empty
50+
* table after a purge is desired state, not data loss. Cleared again by
51+
* install/reseed runs that land rows. */
52+
sampleDataPurged?: boolean;
4753
}
4854

4955
/** Default ledger location, relative to the runtime's working directory. */
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Rehydrate-time sample-data healing.
5+
*
6+
* The install ledger lives under `<cwd>/.objectstack/installed-packages/` while
7+
* the database can be swapped out from under it (`os dev --fresh`, a deleted
8+
* dev.db, a `--database` switch). Rehydrate deliberately skips seeding
9+
* (existing rows must not be re-upserted over user edits on every boot), which
10+
* used to leave a rehydrated package PERMANENTLY empty on a new database: app
11+
* in the switcher, tables created, zero rows — the "HotCRM installed but every
12+
* KPI is 0" bug. These tests pin the healer that closes the gap:
13+
*
14+
* • all seeded objects empty → seeds run, `withSampleData` flips true
15+
* • any surviving row → untouched (no silent demo-data reverts)
16+
* • explicit purge → stays empty across restarts
17+
* • multi-tenant mode → left to the per-org replay
18+
*/
19+
20+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
21+
import { mkdtempSync, rmSync } from 'node:fs';
22+
import { join } from 'node:path';
23+
import { tmpdir } from 'node:os';
24+
25+
// What the (mocked) seed loader reports back, plus a call journal so tests can
26+
// assert whether/what the healer actually seeded.
27+
let seedResult: any = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
28+
let loadCalls: any[] = [];
29+
30+
vi.mock('@objectstack/runtime', () => ({
31+
SeedLoaderService: class {
32+
async load(request: any) { loadCalls.push(request); return seedResult; }
33+
},
34+
}));
35+
vi.mock('@objectstack/spec/data', () => ({
36+
SeedLoaderRequestSchema: { parse: (x: any) => x },
37+
}));
38+
39+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
40+
import { LocalManifestSource } from './local-manifest-source.js';
41+
42+
type Handler = (c: any) => Promise<any>;
43+
44+
function makeRawApp() {
45+
const routes = new Map<string, Handler>();
46+
return {
47+
routes,
48+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
49+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
50+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
51+
};
52+
}
53+
54+
function makeCtx(rawApp: any, services: Record<string, any>) {
55+
const hooks = new Map<string, any>();
56+
return {
57+
ctx: {
58+
hook: (e: string, h: any) => hooks.set(e, h),
59+
getService: (name: string) => {
60+
if (name === 'http-server') return { getRawApp: () => rawApp };
61+
const svc = services[name];
62+
if (svc === undefined) throw new Error(`no ${name}`);
63+
return svc;
64+
},
65+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
66+
},
67+
fire: async () => { await hooks.get('kernel:ready')?.(); },
68+
};
69+
}
70+
71+
/** A Hono-ish context. `param` carries the :manifestId route value. */
72+
function makeC(body: any, manifestId?: string) {
73+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
74+
return {
75+
req: {
76+
url: 'http://localhost:3000/api/v1/marketplace/install-local',
77+
raw: new Request('http://localhost:3000/x'),
78+
json: async () => body,
79+
param: (k: string) => (k === 'manifestId' ? manifestId : undefined),
80+
header: () => undefined,
81+
},
82+
json,
83+
};
84+
}
85+
86+
const MANIFEST = {
87+
id: 'app.test.crm',
88+
version: '1.0.0',
89+
objects: [{ name: 'crm_x', fields: { name: { type: 'text' } } }],
90+
data: [
91+
{ object: 'crm_x', records: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }] },
92+
{ object: 'crm_y', records: [{ id: 'c', name: 'c' }] },
93+
],
94+
};
95+
96+
/** Services with a controllable emptiness probe. */
97+
function makeServices(findRows: Record<string, any[]>) {
98+
return {
99+
manifest: { register: vi.fn() },
100+
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
101+
objectql: {
102+
syncSchemas: async () => undefined,
103+
find: vi.fn(async (object: string) => findRows[object] ?? []),
104+
},
105+
metadata: {},
106+
driver: { delete: vi.fn(async () => true) },
107+
};
108+
}
109+
110+
let dir: string;
111+
beforeEach(() => {
112+
dir = mkdtempSync(join(tmpdir(), 'mil-heal-'));
113+
seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
114+
loadCalls = [];
115+
});
116+
afterEach(() => {
117+
rmSync(dir, { recursive: true, force: true });
118+
delete process.env.OS_MULTI_ORG_ENABLED;
119+
vi.restoreAllMocks();
120+
});
121+
122+
/** Pre-write a ledger entry, then boot a fresh plugin so rehydrate runs. */
123+
async function rehydrateWith(entryOverrides: Record<string, any>, findRows: Record<string, any[]>) {
124+
new LocalManifestSource(dir).write({
125+
packageId: 'pkg_1',
126+
versionId: 'pkgv_1',
127+
manifestId: MANIFEST.id,
128+
version: MANIFEST.version,
129+
manifest: MANIFEST,
130+
installedAt: '2026-01-01T00:00:00.000Z',
131+
installedBy: 'admin',
132+
withSampleData: false,
133+
...entryOverrides,
134+
});
135+
const rawApp = makeRawApp();
136+
const services = makeServices(findRows);
137+
const { ctx, fire } = makeCtx(rawApp, services);
138+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
139+
await plugin.start(ctx as any);
140+
await fire();
141+
return { rawApp, services, ctx };
142+
}
143+
144+
describe('rehydrate sample-data healing', () => {
145+
it('reseeds a rehydrated package when every seeded object is empty', async () => {
146+
seedResult = { summary: { totalInserted: 3, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
147+
await rehydrateWith({}, { crm_x: [], crm_y: [] });
148+
149+
expect(loadCalls).toHaveLength(1);
150+
// The full bundled datasets are replayed, as an upsert.
151+
expect(loadCalls[0].seeds).toHaveLength(2);
152+
expect(loadCalls[0].config).toMatchObject({ defaultMode: 'upsert', multiPass: true });
153+
// No org id in single-tenant mode.
154+
expect(loadCalls[0].config.organizationId).toBeUndefined();
155+
156+
// The ledger now records that sample data is present again.
157+
const entry = new LocalManifestSource(dir).read(MANIFEST.id)!;
158+
expect(entry.withSampleData).toBe(true);
159+
expect(entry.sampleDataPurged).toBe(false);
160+
});
161+
162+
it('does NOT reseed when any seeded object still has rows', async () => {
163+
await rehydrateWith({}, { crm_x: [], crm_y: [{ id: 'c' }] });
164+
expect(loadCalls).toHaveLength(0);
165+
});
166+
167+
it('does NOT resurrect explicitly purged sample data', async () => {
168+
await rehydrateWith({ sampleDataPurged: true }, { crm_x: [], crm_y: [] });
169+
expect(loadCalls).toHaveLength(0);
170+
});
171+
172+
it('leaves multi-tenant seeding to the per-org replay', async () => {
173+
process.env.OS_MULTI_ORG_ENABLED = 'true';
174+
await rehydrateWith({}, { crm_x: [], crm_y: [] });
175+
expect(loadCalls).toHaveLength(0);
176+
});
177+
178+
it('keeps withSampleData false when the heal run lands no rows', async () => {
179+
seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [{ message: 'database is locked' }] };
180+
const { ctx } = await rehydrateWith({}, { crm_x: [], crm_y: [] });
181+
expect(loadCalls).toHaveLength(1);
182+
const entry = new LocalManifestSource(dir).read(MANIFEST.id)!;
183+
expect(entry.withSampleData).toBe(false);
184+
// The failure is loud, with the underlying reason.
185+
expect((ctx.logger.warn as any).mock.calls.some((c: any[]) => String(c[0]).includes('database is locked'))).toBe(true);
186+
});
187+
188+
it('purge → restart keeps the package empty (end to end through the endpoints)', async () => {
189+
// Install over an empty DB, with rows landing.
190+
seedResult = { summary: { totalInserted: 3, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
191+
const rawApp = makeRawApp();
192+
const services = makeServices({ crm_x: [], crm_y: [] });
193+
const { ctx, fire } = makeCtx(rawApp, services);
194+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
195+
await plugin.start(ctx as any);
196+
await fire();
197+
const installRes = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
198+
makeC({ manifest: MANIFEST }),
199+
);
200+
expect(installRes.payload?.success).toBe(true);
201+
202+
// Purge the sample data.
203+
const purgeRes = await rawApp.routes.get('POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data')!(
204+
makeC({}, MANIFEST.id),
205+
);
206+
expect(purgeRes.payload?.success).toBe(true);
207+
expect(new LocalManifestSource(dir).read(MANIFEST.id)?.sampleDataPurged).toBe(true);
208+
209+
// Restart (fresh plugin over the same ledger, DB now empty): no reseed.
210+
loadCalls = [];
211+
const rawApp2 = makeRawApp();
212+
const { ctx: ctx2, fire: fire2 } = makeCtx(rawApp2, makeServices({ crm_x: [], crm_y: [] }));
213+
const plugin2 = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
214+
await plugin2.start(ctx2 as any);
215+
await fire2();
216+
expect(loadCalls).toHaveLength(0);
217+
});
218+
219+
it('install marks withSampleData=true when rows were skipped (already present)', async () => {
220+
// Reinstall over a database that already holds the demo rows: the
221+
// loader reports all-skipped. The ledger must still say the install
222+
// carries sample data — this was the stale-`false` quirk that made
223+
// older ledgers look purge-like.
224+
seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 3 }, errors: [] };
225+
const rawApp = makeRawApp();
226+
const { ctx, fire } = makeCtx(rawApp, makeServices({ crm_x: [{ id: 'a' }], crm_y: [{ id: 'c' }] }));
227+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
228+
await plugin.start(ctx as any);
229+
await fire();
230+
const installRes = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
231+
makeC({ manifest: MANIFEST }),
232+
);
233+
expect(installRes.payload?.success).toBe(true);
234+
expect(new LocalManifestSource(dir).read(MANIFEST.id)?.withSampleData).toBe(true);
235+
});
236+
});

0 commit comments

Comments
 (0)