Skip to content

Commit 8b3dbf1

Browse files
committed
test(dogfood): install-local purge-sample-data pinned on a real boot
Installs the CRM example from its build-shaped artifact through the install-local door on the showcase, adds a user-authored row, purges and reseeds: { deleted: 28, skipped: 0, errors: 0 }, the user row kept, every delete seen by beforeDelete/afterDelete and the audit trail. A second boot under an organization wall purges one organization and leaves the other's 28 seed rows untouched. Dogfood declares cloud-connection and aliases it to source, the way it does for its other subjects. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ab40b1 commit 8b3dbf1

5 files changed

Lines changed: 333 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
`POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data` now deletes an installed package's sample rows. Before, it answered `500 DRIVER_UNAVAILABLE` on every runtime.
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** The purge looked up a bare `driver` service, a name no kernel registers (drivers register as `driver.<name>`), so it refused everywhere. Behind that it matched seed records by `id`, which seed records rarely carry: the CRM example's 28 records key by `name`, `email` and `subject`. It also deleted through the driver, past every engine hook.
10+
- **What it does now.** It deletes through the ObjectQL engine, so lifecycle hooks and the audit trail run, under the posture the seed was written with (record-change automation suppressed). Rows are matched by each dataset's `externalId`, the key the install and the reseed upsert by. A row whose key no seed record declares is never touched. Children are deleted before parents, in the reverse of the seed loader's own dependency order.
11+
- **Scope.** Under an organization wall the purge removes only the seed rows of the caller's active organization, the scope the install and the reseed seed into. A session with no active organization is answered `400 RESEED_SKIPPED` (`multi-tenant-no-active-org`), the way reseed answers it. Without a wall the deployment is one tenant, and the match is table-wide, as the install's own match is.
12+
- **The response keeps its shape**, `{ manifestId, deleted, skipped, errors, withSampleData }`. `skipped` counts seed records no row carries (already deleted). `errors` counts records that could not be purged, each with its reason in the server log: a delete the engine refused (for example, a user's row still requires the seed row as its parent), a key that more than one row carries, or a seed record with no key value.
13+
- A runtime with no data engine or no metadata service still answers `500 DRIVER_UNAVAILABLE`, now naming what is missing.

‎packages/qa/dogfood/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
},
4141
"devDependencies": {
4242
"@objectstack/cli": "workspace:*",
43+
"@objectstack/cloud-connection": "workspace:*",
4344
"@objectstack/core": "workspace:*",
4445
"@objectstack/driver-sql": "workspace:*",
4546
"@objectstack/driver-sqlite-wasm": "workspace:*",
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// GOLDEN REGRESSION — install-local `purge-sample-data` removes exactly the
4+
// seed rows an installed package put in the database, THROUGH THE ENGINE, in
5+
// the install's own scope (#21728).
6+
//
7+
// ## What was measured before the fix
8+
//
9+
// The showcase booted with `OS_CLOUD_URL=off`, the CRM example installed from
10+
// its built artifact (`os package install examples/app-crm/dist/objectstack.json`,
11+
// 28 seed rows landed), then, as the admin:
12+
//
13+
// POST …/install-local/com.example.crm/purge-sample-data {}
14+
// -> 500 {"code":"DRIVER_UNAVAILABLE","message":"driver service unavailable — cannot purge."}
15+
// the 28 rows still there
16+
// POST …/install-local/com.example.crm/reseed-sample-data {}
17+
// -> 422 RESEED_NO_ROWS (nothing to write: the rows never left)
18+
//
19+
// Three defects stacked: the purge asked for a bare `driver` service no kernel
20+
// registers (drivers register as `driver.<name>`); behind it, it matched seed
21+
// records by `rec.id`, which none of the CRM's 28 records carries (they key by
22+
// `name` / `email` / `subject`), so fixing the lookup alone would have answered
23+
// 28 skipped / 0 deleted; and it deleted through the driver, past every engine
24+
// hook. The unit suites stayed green because they mocked a bare `driver`.
25+
//
26+
// ## What this file pins, on a real boot
27+
//
28+
// 1. the install lands the 28 rows (precondition — measured, not assumed);
29+
// 2. the purge answers `{ deleted: 28, skipped: 0, errors: 0 }`, a
30+
// user-authored row in a seeded object survives, and every seeded object
31+
// is otherwise empty;
32+
// 3. every delete passed through the engine: `beforeDelete` and `afterDelete`
33+
// fired once per seed row, and the audit plugin wrote one `delete` row each;
34+
// 4. the reseed then succeeds (28 inserted);
35+
// 5. under an organization wall, a purge in one organization leaves another
36+
// organization's 28 seed rows exactly where they were.
37+
//
38+
// Boots fixture stacks of its own (custom plugins, a walled posture), so it
39+
// stays out of `SHARED_SHOWCASE`.
40+
41+
import { mkdtempSync, rmSync } from 'node:fs';
42+
import { tmpdir } from 'node:os';
43+
import { join } from 'node:path';
44+
45+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
46+
import showcaseStack from '@objectstack/example-showcase';
47+
import crmStack from '@objectstack/example-crm';
48+
import { bootStack, type VerifyStack } from '@objectstack/verify';
49+
import { AuditPlugin } from '@objectstack/plugin-audit';
50+
import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection';
51+
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
52+
import type { ExecutionContext } from '@objectstack/spec/kernel';
53+
import { buildShapedArtifact } from './build-shaped-artifact.js';
54+
55+
const SYS = { isSystem: true } as ExecutionContext;
56+
const CRM = 'com.example.crm';
57+
const BASE = '/marketplace/install-local';
58+
/** The CRM example's seeded objects and their seed-row counts (3 + 3 + 12 + 5 + 5). */
59+
const SEEDED: Record<string, number> = {
60+
crm_account: 3, crm_contact: 3, crm_opportunity: 12, crm_lead: 5, crm_activity: 5,
61+
};
62+
const SEED_TOTAL = Object.values(SEEDED).reduce((a, b) => a + b, 0);
63+
const OBSERVER = 'dogfood.install-local-purge-observer';
64+
65+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
66+
const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []);
67+
68+
/**
69+
* The install body `os package install <artifact>.json` sends: the whole
70+
* artifact, with the manifest's id and version lifted to the top level
71+
* (`packages/cli/src/commands/package/install.ts`).
72+
*/
73+
function installBody(): { manifest: Record<string, unknown> } {
74+
const { artifact } = buildShapedArtifact(crmStack as unknown as Record<string, unknown>);
75+
const manifest = artifact.manifest as { id?: string; version?: string };
76+
return { manifest: { ...artifact, id: manifest.id, version: manifest.version } };
77+
}
78+
79+
/** Seed-row counts per CRM object, read as the system, optionally in one organization. */
80+
async function countSeeded(ql: IObjectQLEngine, organizationId?: string): Promise<Record<string, number>> {
81+
const out: Record<string, number> = {};
82+
for (const object of Object.keys(SEEDED)) {
83+
const rows = rowsOf(await ql.find(object, {
84+
...(organizationId ? { where: { organization_id: organizationId } } : {}),
85+
context: SYS,
86+
}));
87+
out[object] = rows.length;
88+
}
89+
return out;
90+
}
91+
92+
/** Records every engine delete on a CRM object, by phase, from the engine's own hook bus. */
93+
function observeDeletes(ql: IObjectQLEngine): { before: string[]; after: string[] } {
94+
const seen = { before: [] as string[], after: [] as string[] };
95+
const objects = Object.keys(SEEDED);
96+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
97+
const idOf = (ctx: any) => String(ctx?.input?.id ?? ctx?.previous?.id ?? '');
98+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
99+
ql.registerHook('beforeDelete', (ctx: any) => { seen.before.push(`${ctx.object}#${idOf(ctx)}`); }, { object: objects, packageId: OBSERVER });
100+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
101+
ql.registerHook('afterDelete', (ctx: any) => { seen.after.push(`${ctx.object}#${idOf(ctx)}`); }, { object: objects, packageId: OBSERVER });
102+
return seen;
103+
}
104+
105+
/** `object#id` for every current seed-object row matching `where`. */
106+
async function rowKeys(ql: IObjectQLEngine, where?: Record<string, unknown>): Promise<string[]> {
107+
const keys: string[] = [];
108+
for (const object of Object.keys(SEEDED)) {
109+
for (const row of rowsOf(await ql.find(object, { ...(where ? { where } : {}), context: SYS }))) {
110+
keys.push(`${object}#${row.id}`);
111+
}
112+
}
113+
return keys.sort();
114+
}
115+
116+
async function json(res: Response): Promise<{ status: number; body: any }> { // eslint-disable-line @typescript-eslint/no-explicit-any
117+
return { status: res.status, body: await res.json().catch(() => null) };
118+
}
119+
120+
describe('dogfood: install-local purge on the single-tenant posture — the card\'s own boot', () => {
121+
let stack: VerifyStack;
122+
let storageDir: string;
123+
let ql: IObjectQLEngine;
124+
let install: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
125+
let purge: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
126+
let reseed: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
127+
let afterInstall: Record<string, number>;
128+
let afterPurge: Record<string, number>;
129+
let afterReseed: Record<string, number>;
130+
let seedRowKeys: string[];
131+
let survivors: string[];
132+
let seen: { before: string[]; after: string[] };
133+
let auditedDeletes: string[];
134+
135+
beforeAll(async () => {
136+
storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-purge-'));
137+
stack = await bootStack(showcaseStack, {
138+
extraPlugins: [new AuditPlugin(), new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
139+
});
140+
ql = stack.kernel.getService<IObjectQLEngine>('objectql');
141+
const token = await stack.signIn();
142+
143+
install = await json(await stack.apiAs(token, 'POST', BASE, installBody()));
144+
afterInstall = await countSeeded(ql);
145+
seedRowKeys = await rowKeys(ql);
146+
147+
// A user-authored row in a seeded object, written through the REST door.
148+
const user = await stack.apiAs(token, 'POST', '/data/crm_account', { name: 'User Authored Co', industry: 'technology' });
149+
expect(user.status, await user.clone().text()).toBeLessThan(300);
150+
151+
seen = observeDeletes(ql);
152+
purge = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {}));
153+
ql.unregisterHooksByPackage(OBSERVER);
154+
afterPurge = await countSeeded(ql);
155+
survivors = rowsOf(await ql.find('crm_account', { context: SYS })).map((r) => r.name);
156+
auditedDeletes = rowsOf(await ql.find('sys_audit_log', { where: { action: 'delete' }, context: SYS }))
157+
.filter((r) => r.object_name in SEEDED)
158+
.map((r) => `${r.object_name}#${r.record_id}`)
159+
.sort();
160+
161+
reseed = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {}));
162+
afterReseed = await countSeeded(ql);
163+
}, 300_000);
164+
165+
afterAll(async () => {
166+
await stack?.stop?.();
167+
if (storageDir) rmSync(storageDir, { recursive: true, force: true });
168+
});
169+
170+
it('PRECONDITION: the install landed all 28 seed rows', () => {
171+
expect(install.status, JSON.stringify(install.body)).toBe(200);
172+
expect(install.body?.data?.seeded).toMatchObject({ mode: 'inline', inserted: SEED_TOTAL });
173+
expect(afterInstall).toEqual(SEEDED);
174+
expect(seedRowKeys).toHaveLength(SEED_TOTAL);
175+
});
176+
177+
it('the purge answers { deleted: 28, skipped: 0, errors: 0 } and keeps the user-authored row', () => {
178+
expect(purge.status, JSON.stringify(purge.body)).toBe(200);
179+
expect(purge.body).toEqual({
180+
success: true,
181+
data: { manifestId: CRM, deleted: SEED_TOTAL, skipped: 0, errors: 0, withSampleData: false },
182+
});
183+
expect(afterPurge).toEqual({ ...Object.fromEntries(Object.keys(SEEDED).map((o) => [o, 0])), crm_account: 1 });
184+
expect(survivors).toEqual(['User Authored Co']);
185+
});
186+
187+
it('every delete passed through the engine: both hook phases and the audit trail saw each seed row once', () => {
188+
expect([...seen.before].sort()).toEqual(seedRowKeys);
189+
expect([...seen.after].sort()).toEqual(seedRowKeys);
190+
expect(auditedDeletes).toEqual(seedRowKeys);
191+
});
192+
193+
it('the reseed then succeeds', () => {
194+
expect(reseed.status, JSON.stringify(reseed.body)).toBe(200);
195+
expect(reseed.body?.data).toMatchObject({ inserted: SEED_TOTAL, errors: 0, withSampleData: true });
196+
expect(afterReseed).toEqual({ ...SEEDED, crm_account: SEEDED.crm_account + 1 });
197+
});
198+
});
199+
200+
describe('dogfood: install-local purge under an organization wall — one organization at a time', () => {
201+
const ORG_A = 'org_21728_a';
202+
const ORG_B = 'org_21728_b';
203+
let stack: VerifyStack;
204+
let storageDir: string;
205+
let ql: IObjectQLEngine;
206+
let token: string;
207+
let purge: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
208+
let reseedA: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
209+
let orgARowsBefore: Record<string, number>;
210+
let orgBKeysBefore: string[];
211+
let orgBKeysAfter: string[];
212+
let orgAAfter: Record<string, number>;
213+
let orgASurvivors: string[];
214+
let orgAKeys: string[];
215+
let seen: { before: string[]; after: string[] };
216+
217+
async function setActive(organizationId: string): Promise<void> {
218+
const res = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationId });
219+
expect(res.status, `set-active ${organizationId}: ${await res.clone().text()}`).toBe(200);
220+
}
221+
222+
beforeAll(async () => {
223+
storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-purge-walled-'));
224+
// `posture-only` requests the `isolated` posture — the wall is ACTIVE —
225+
// without the organizations runtime; the memberships are written by hand
226+
// (the shape `no-active-organization-write-refusal.dogfood.test.ts` uses).
227+
stack = await bootStack(showcaseStack, {
228+
multiTenant: 'posture-only',
229+
extraPlugins: [new AuditPlugin(), new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
230+
});
231+
ql = stack.kernel.getService<IObjectQLEngine>('objectql');
232+
await stack.signIn();
233+
const [admin] = rowsOf(await ql.find('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }));
234+
for (const org of [ORG_A, ORG_B]) {
235+
await ql.insert('sys_organization', { id: org, name: org, slug: org }, { context: SYS });
236+
await ql.insert('sys_member', { id: `mem_${org}`, organization_id: org, user_id: admin.id, role: 'owner' }, { context: SYS });
237+
}
238+
// A fresh session, so it is minted with the memberships above.
239+
token = await stack.signIn();
240+
241+
// Install in A, then the same package's seed in B through the reseed door.
242+
await setActive(ORG_A);
243+
const install = await json(await stack.apiAs(token, 'POST', BASE, installBody()));
244+
expect(install.status, JSON.stringify(install.body)).toBe(200);
245+
expect(install.body?.data?.seeded).toMatchObject({ mode: 'inline', inserted: SEED_TOTAL });
246+
await setActive(ORG_B);
247+
const reseedB = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {}));
248+
expect(reseedB.status, JSON.stringify(reseedB.body)).toBe(200);
249+
expect(reseedB.body?.data).toMatchObject({ inserted: SEED_TOTAL });
250+
251+
await setActive(ORG_A);
252+
const user = await stack.apiAs(token, 'POST', '/data/crm_account', { name: 'User Authored Co', industry: 'technology' });
253+
expect(user.status, await user.clone().text()).toBeLessThan(300);
254+
255+
orgARowsBefore = await countSeeded(ql, ORG_A);
256+
orgAKeys = await rowKeys(ql, { organization_id: ORG_A });
257+
orgBKeysBefore = await rowKeys(ql, { organization_id: ORG_B });
258+
259+
seen = observeDeletes(ql);
260+
purge = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {}));
261+
ql.unregisterHooksByPackage(OBSERVER);
262+
263+
orgAAfter = await countSeeded(ql, ORG_A);
264+
orgASurvivors = rowsOf(await ql.find('crm_account', { where: { organization_id: ORG_A }, context: SYS })).map((r) => r.name);
265+
orgBKeysAfter = await rowKeys(ql, { organization_id: ORG_B });
266+
267+
reseedA = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {}));
268+
}, 300_000);
269+
270+
afterAll(async () => {
271+
await stack?.stop?.();
272+
if (storageDir) rmSync(storageDir, { recursive: true, force: true });
273+
});
274+
275+
it('PRECONDITION: both organizations hold the 28 seed rows, and A also holds a user row', () => {
276+
expect(orgARowsBefore).toEqual({ ...SEEDED, crm_account: SEEDED.crm_account + 1 });
277+
expect(orgBKeysBefore).toHaveLength(SEED_TOTAL);
278+
});
279+
280+
it('a purge in organization A deletes A\'s 28 seed rows, each through both hook phases, and keeps A\'s user row', () => {
281+
expect(purge.status, JSON.stringify(purge.body)).toBe(200);
282+
expect(purge.body?.data).toEqual({ manifestId: CRM, deleted: SEED_TOTAL, skipped: 0, errors: 0, withSampleData: false });
283+
expect(orgAAfter).toEqual({ ...Object.fromEntries(Object.keys(SEEDED).map((o) => [o, 0])), crm_account: 1 });
284+
expect(orgASurvivors).toEqual(['User Authored Co']);
285+
// A's seed rows are A's rows minus the one user row: exactly what the hooks saw.
286+
const seedKeysA = orgAKeys.filter((k) => seen.after.includes(k));
287+
expect(seedKeysA).toHaveLength(SEED_TOTAL);
288+
expect([...seen.after].sort()).toEqual(seedKeysA);
289+
expect([...seen.before].sort()).toEqual(seedKeysA);
290+
});
291+
292+
it('organization B\'s seed rows are untouched — and no hook ever saw one of them', () => {
293+
expect(orgBKeysAfter).toEqual(orgBKeysBefore);
294+
expect(orgBKeysAfter).toHaveLength(SEED_TOTAL);
295+
for (const k of orgBKeysBefore) {
296+
expect(seen.before).not.toContain(k);
297+
expect(seen.after).not.toContain(k);
298+
}
299+
});
300+
301+
it('the reseed in A then succeeds', () => {
302+
expect(reseedA.status, JSON.stringify(reseedA.body)).toBe(200);
303+
expect(reseedA.body?.data).toMatchObject({ inserted: SEED_TOTAL, errors: 0 });
304+
});
305+
});

‎packages/qa/dogfood/vitest.config.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,17 @@ export default defineConfig({
253253
find: /^@objectstack\/trigger-api$/,
254254
replacement: path.resolve(__dirname, '../../triggers/trigger-api/src/index.ts'),
255255
},
256+
// [#21728] `install-local-purge-sample-data.dogfood.test.ts` mounts
257+
// `MarketplaceInstallLocalPlugin` on a real boot and drives its
258+
// install / purge / reseed doors. The plugin is the pin's subject,
259+
// so the verdict is aliased to THIS checkout's source. A relative
260+
// import of the plugin file instead would put its own dynamic
261+
// `@objectstack/runtime` import into this package's unaliased set
262+
// (`check:test-source-alias`), which is shrink-only.
263+
{
264+
find: /^@objectstack\/cloud-connection$/,
265+
replacement: path.resolve(__dirname, '../../cloud-connection/src/index.ts'),
266+
},
256267
],
257268
},
258269
test: {

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)