Skip to content

Commit d7fff21

Browse files
fix(cloud-connection): install-local purge deletes seed rows through the engine by their seed key (#21773)
Fixes #21728 Clause-②: no `POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data` answered `500 DRIVER_UNAVAILABLE` on every runtime. It now deletes an installed package's seed rows through the ObjectQL engine, matched by the seed's own key, in the install's own scope. ## What was wrong (three defects, stacked) 1. The handler looked up a bare `driver` service. No kernel registers that name; drivers register as `driver.NAME`. So the door always refused. 2. Behind the lookup, it matched seed records by `rec.id`. The CRM example's 28 records carry no `id` (they key by `name` / `email` / `subject`), so fixing the lookup alone answers `deleted: 0, skipped: 28`. Ablation leg 2 below measures exactly that. 3. It called the driver's `delete` directly, past every engine hook and the audit trail. The unit suites stayed green because four of them mocked a bare `driver` service. ## What changes - **New `packages/cloud-connection/src/marketplace-install-local-purge.ts` (`purgeSeedRows`).** - **Key.** Each dataset's `externalId` is read through the spec's own declaration (`SeedSchema.shape.externalId`, default `name`). Rows are keyed the way `SeedLoaderService` keys them for an upsert. A row whose key no seed record declares is never touched. - **Composite keys.** Reference parts (the showcase's `['team', 'project']` junction) are translated through the parent rows this purge matched, as the loader does for same-load targets. - **No guessing.** A key carried by more than one row in scope, a seed record with no key value, a key part pointing at an object this package does not seed, and an engine refusal each count in `errors`, with the reason logged. None of them counts as `skipped`, and none deletes anything. - **Order.** The reverse of the loader's own topological insert order (`SeedLoaderService.buildDependencyGraph`), so children go before parents. No order is written by hand. - **Context.** `engine.delete(object, { where: { id } })` under `SEED_WRITE_EXECUTION_CONTEXT`, the posture the seed was written with. Record-change automation is suppressed; lifecycle hooks and audit run. - **`handlePurge`** resolves `objectql` and `metadata` (`500 DRIVER_UNAVAILABLE` if either is missing, now naming which). It picks the scope with the install's own two primitives, `organizationWallActive` and `resolveActiveOrgId`, and hands both to `purgeSeedRows`. The docblock's "bypass ACL / lifecycle hooks (same pattern as cloud purge)" sentence is gone. The response shape is unchanged. - **Bare-`driver` mocks.** - Rewritten against the engine, with a pinned `delete` that opens with `assertEngineDeleteDispatch`: heal and capability-enumeration, the two suites that drive the purge. - Removed, with the reason left in place: seed-replayer and tenancy-posture, where the mock was dead fixture. - **Changeset:** `patch` on `@objectstack/cloud-connection`. This is a bug fix in a released package. It adds no export and changes no schema; the door now does what its documented response says. ## Zone 2 measurements - **A1, reach (`origin/main` 025008a, unchanged code).** - Setup: a scratch copy of the showcase on `os dev --fresh` with `OS_CLOUD_URL=off`, then `os package install examples/app-crm/dist/objectstack.json` as the admin. Result: 3/3/12/5/5 rows, all carrying the default organization. - `POST …/purge-sample-data {}` answered `500 {"code":"DRIVER_UNAVAILABLE",…}`. Afterwards `crm_account` still held 3 rows and `crm_opportunity` 12. - The reseed answered `422 RESEED_NO_ROWS`. - The same sequence on the fixed build: - The purge answered `200 {"deleted":28,"skipped":0,"errors":0}` and the user row `User Authored Co` survived. - `sys_audit_log` gained 28 `delete` rows. - The reseed answered `200 inserted: 28`. A second purge deleted 28, and a third answered `skipped: 28`. - **A2, scope.** The install and the reseed seed under `organizationWallActive(ctx)`. - Under a wall: they seed into `resolveActiveOrgId`'s organization, and the loader pins `organization_id` on both its upsert match and its writes. - Without a wall (`single`, the card's boot): no organization is pinned, and the loader's upsert match is table-wide. Measured at the door: `POST /auth/organization/create` on that boot answers `403 "Creating additional organizations is disabled on this deployment."`, so the table is one tenant. - The purge reuses those two primitives exactly. Under a wall, every read is filtered to the active organization and the delete is by primary key. A walled session with no active organization gets the reseed's skip shape: `400 RESEED_SKIPPED`, `Purge did not run: multi-tenant-no-active-org…`. - Pinned on a real walled boot: a purge in organization A leaves organization B's 28 seed rows untouched, and no delete hook ever saw one of them. - **A3, key.** The CRM's five datasets declare `externalId` `name` / `email` / `name` / `email` / `subject` (3+3+12+5+5 = 28), with 0 authored ids. The loader writes the key to that same field (`loadDataset`: `dataset.externalId || 'name'`; `externalIdKey`). The purge matches on exactly that field, in scope. - **A4, through the engine.** - Context: `SEED_WRITE_EXECUTION_CONTEXT` (`isSystem`, `skipTriggers`, `seedReplay`). `triggerHooks` runs every lifecycle hook unless `skipAutomations` is set. - Real boot: `beforeDelete` and `afterDelete` each fired once per seed row (28), and the audit plugin wrote 28 `delete` rows. - Order: the loader's graph reversed; the route test proves it against the real `SeedLoaderService` with a manifest listing the child first. - Constraint, measured: with a user opportunity still requiring seed account `Acme Corp` (`crm_opportunity.account` is required + `set_null`), the engine refused that account's delete ("still referenced by 1 Opportunity record(s)… required and cannot be cleared"). The purge answered `deleted: 27, errors: 1` with the reason logged, and the user's row was untouched. - No CRM hook refuses a delete; its only hook is `beforeInsert`/`beforeUpdate`. - **A5, ledger flag.** `sampleDataPurged` / `withSampleData` stay install-wide; the ledger shape is unchanged here. - Measured on a walled boot with a real restart: after a purge in A, `GET /install-local` read as B answers `withSampleData: false` while B still holds its 28 rows. - After the restart, A has 0 rows and B has 28. The healer returns at its wall check before reading anything, so it neither resurrects A nor touches B. - No data harm to B. The listing flag is wrong for B, and that is reported to the seat as an out-of-scope finding. - **A6, reverse verification.** Both legs ran through `scripts/ablation-replace.mjs` on `marketplace-install-local-purge.ts`, which the dogfood resolves through its source alias, so no build leg was needed. | leg | anchor → replacement | blob | dogfood pin | |---|---|---|---| | delete no-op | `const result = await engine.delete(…SEED_WRITE_EXECUTION_CONTEXT });` → `const result = true as boolean \| number;` | a6856b9 → 2d004c57 | 5 red / 3 green | | match by `rec.id` | `const ids = rowsByKey.get(keyOf(parts)) ?? [];` → `const ids: string[] = rec?.id ? [String(rec.id)] : [];` | a6856b9 → 7e1e9c83 | 5 red / 3 green; purge answered `deleted: 0, skipped: 28` | - The 3 that stayed green both times are the two preconditions and "organization B's seed rows are untouched". - Restore, both legs: blob after restore == HEAD (a6856b9) and `git diff HEAD` is empty. ## Tests (at 2b140e9) - `pnpm --filter @objectstack/cloud-connection exec vitest run`: 37 files, 459 tests pass. The new `marketplace-install-local-purge.test.ts` has 16 cases (match, no-guessing, order/context/scope, reference parts, and the route under the real loader). - `pnpm --filter @objectstack/cloud-connection typecheck`: clean (both programs). - New dogfood `install-local-purge-sample-data.dogfood.test.ts` on a real boot: 8/8 pass. `pnpm --filter @objectstack/dogfood typecheck` is clean. - `pnpm lint` (full, `eslint . --no-inline-config`): exit 0. ## Gates (at 2b140e9) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 86 families (the claim's 46 grew with the dogfood wiring, the lockfile and the ratchet baseline). - All 86 ran with exit 0. `--ran` reconciles 86 derived / 86 run / 0 NOT-MEASURED, every line carrying its exit code. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (eight unrelated packages unbuilt). After building them it read 106 entry points across 66 packages, green. - `check:slot-lookup` ratcheted down 16 → 15 for the plugin file (the untyped `driver` lookup is gone). The baseline is committed. ## Deviations - **Two stay-out dogfood files edited:** `packages/qa/dogfood/package.json` (devDependency `@objectstack/cloud-connection`) and `packages/qa/dogfood/vitest.config.ts` (one anchored source alias), plus their lockfile importer lines. - Measured reason: a relative import of the plugin from the new dogfood file puts the plugin's dynamic `@objectstack/runtime` import into dogfood's shrink-only `check:test-source-alias` set (`NEW unaliased artifact import(s): @objectstack/runtime`). - The alias is the route the config already takes for its other subjects. No test file another lane holds was touched. - **The purge door gains a refusal it did not have:** `400 RESEED_SKIPPED` when a wall is up and the session has no active organization. This was dispatched as "reseed's existing skip shape and code". A dedicated code would need a spec ledger row (`domain:spec`). - **`@objectstack/cloud-connection` gains a devDependency** on `@objectstack/metadata-core`, for `assertEngineDeleteDispatch` in its fake engines, as `check:engine-double-contract` prescribes. It is aliased to source in its vitest config. - **No merge commit.** `origin/main` stayed at 025008a from dispatch to this PR, so `git merge origin/main` is a no-op. ## Acceptance notes - Boundaries of the new purge (reported, not filed): - A seed dataset keyed by `id` and replayed into a second organization gets derived ids (`perOrganizationSeedRowId`, private to the loader), so the purge answers `skipped` for those rows. No install-local package measured has one. - Master-detail children of seed rows go with them by their declared `cascade`. - `withSampleData: false` is written even when `errors` is non-zero, which is the pre-existing flip. - Comment drift for the spec seat: in `error-code-ledger.zod.ts`, `DRIVER_UNAVAILABLE`'s note says "no driver service", and `RESEED_SKIPPED`'s says "reseed declined", but the purge now emits both. - Checklist drift: `docs/qa/platform-checklist/areas/platform-core.json` still describes the purge as id-based. - #21762 edits this file's install route. Install and reseed behaviour are untouched here. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9c555a commit d7fff21

15 files changed

Lines changed: 1180 additions & 43 deletions
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/cloud-connection/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
},
3333
"devDependencies": {
3434
"@objectstack/lint": "workspace:*",
35+
"@objectstack/metadata-core": "workspace:*",
3536
"@types/node": "^26.6.3",
3637
"typescript": "^6.0.3",
3738
"vitest": "^4.1.11"

‎packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* Three principal shapes driven through the composed plugin to the point the
2222
* state actually changes — `manifest.register()` (shared registry),
2323
* `objectql.syncSchemas()` (DDL against the shared database), the ledger file on
24-
* disk, `SeedLoaderService.load()` (rows written), `driver.delete()` (rows
24+
* disk, `SeedLoaderService.load()` (rows written), the row delete (rows
2525
* removed). All three were INDISTINGUISHABLE:
2626
*
2727
* principal install reseed purge uninstall
@@ -73,10 +73,19 @@ vi.mock('@objectstack/runtime', () => ({
7373
seedCalls.push(request);
7474
return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
7575
}
76+
// The purge's dependency order — one object, nothing referenced.
77+
async buildDependencyGraph(objectNames: string[]) {
78+
return {
79+
nodes: objectNames.map((object) => ({ object, dependsOn: [], references: [] })),
80+
insertOrder: objectNames,
81+
circularDependencies: [],
82+
};
83+
}
7684
},
7785
recordSeedOutcome: vi.fn(),
7886
}));
7987

88+
import { assertEngineDeleteDispatch } from '@objectstack/metadata-core';
8089
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
8190

8291
const ROUTE_BASE = '/api/v1/marketplace/install-local';
@@ -138,7 +147,7 @@ const APP = {
138147
namespace: 'gated',
139148
version: '1.0.0',
140149
objects: [{ name: 'widget', fields: { code: { type: 'text' } } }],
141-
data: [{ object: 'widget', records: [{ id: 'w1', code: 'a' }, { id: 'w2', code: 'b' }] }],
150+
data: [{ object: 'widget', externalId: 'code', records: [{ code: 'a' }, { code: 'b' }] }],
142151
};
143152

144153
const LEDGER_FILE = 'com.acme.gated.json';
@@ -155,7 +164,19 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMo
155164
async function mount(shape: Shape, storageDir: string) {
156165
const register = vi.fn(async () => undefined);
157166
const syncSchemas = vi.fn(async () => undefined);
158-
const driverDelete = vi.fn(async () => true);
167+
// The rows the install seeded. The purge deletes them through the ENGINE
168+
// (a bare `driver` service stood here once — no kernel registers one, and
169+
// mocking it is how a purge that always answered 500 stayed green), so the
170+
// engine's `delete` is the observer, opened with the real dispatch contract.
171+
const widgets = [{ id: 'w1', code: 'a' }, { id: 'w2', code: 'b' }];
172+
const engineDelete = vi.fn(async (object: string, options?: any) => {
173+
const dispatch = assertEngineDeleteDispatch(options);
174+
if (object !== 'widget' || dispatch.kind !== 'by-id') return false;
175+
const at = widgets.findIndex((w) => w.id === dispatch.id);
176+
if (at < 0) return false;
177+
widgets.splice(at, 1);
178+
return true;
179+
});
159180
const rawApp = makeRawApp();
160181
const hooks = new Map<string, any>();
161182

@@ -167,9 +188,12 @@ async function mount(shape: Shape, storageDir: string) {
167188
const services: Record<string, any> = {
168189
manifest: { register },
169190
auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } },
170-
objectql: { syncSchemas, find: async (object: string) => rows[object] ?? [] },
191+
objectql: {
192+
syncSchemas,
193+
find: async (object: string) => (object === 'widget' ? widgets.map((w) => ({ ...w })) : rows[object] ?? []),
194+
delete: engineDelete,
195+
},
171196
metadata: { getObject: async () => ({ name: 'widget', fields: {} }) },
172-
driver: { delete: driverDelete },
173197
};
174198
const ctx: any = {
175199
hook: (e: string, h: any) => hooks.set(e, h),
@@ -189,7 +213,7 @@ async function mount(shape: Shape, storageDir: string) {
189213
// counters must start from AFTER that so a refusal case cannot be fooled by
190214
// boot-time activity it never caused.
191215
register.mockClear();
192-
return { rawApp, register, syncSchemas, driverDelete };
216+
return { rawApp, register, syncSchemas, engineDelete };
193217
}
194218

195219
function makeC(body: unknown, headers: Record<string, string>, manifestId?: string) {
@@ -254,7 +278,7 @@ const DOORS: readonly Door[] = [
254278
route: `POST ${ROUTE_BASE}/:manifestId/purge-sample-data`,
255279
body: {},
256280
needsInstalled: true,
257-
effectsFired: (o) => o.driverDelete.mock.calls.length,
281+
effectsFired: (o) => o.engineDelete.mock.calls.length,
258282
},
259283
];
260284

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ let loadCalls: any[] = [];
3030
vi.mock('@objectstack/runtime', () => ({
3131
SeedLoaderService: class {
3232
async load(request: any) { loadCalls.push(request); return seedResult; }
33+
// The purge reads the loader's dependency order; these objects
34+
// reference nothing, so the order is the order asked.
35+
async buildDependencyGraph(objectNames: string[]) {
36+
return {
37+
nodes: objectNames.map((object) => ({ object, dependsOn: [], references: [] })),
38+
insertOrder: objectNames,
39+
circularDependencies: [],
40+
};
41+
}
3342
},
3443
// #3430 — the heal path records a per-source outcome for the boot banner.
3544
recordSeedOutcome: vi.fn(),
@@ -46,6 +55,7 @@ import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugi
4655
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
4756
import { LocalManifestSource } from './local-manifest-source.js';
4857
import { recordSeedOutcome } from '@objectstack/runtime';
58+
import { assertEngineDeleteDispatch } from '@objectstack/metadata-core';
4959

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

@@ -101,17 +111,31 @@ const MANIFEST = {
101111
],
102112
};
103113

104-
/** Services with a controllable emptiness probe. */
114+
/**
115+
* Services with a controllable emptiness probe. The purge deletes through the
116+
* `objectql` ENGINE, so that is where `delete` lives — opened with the real
117+
* dispatch contract. A bare `driver` service used to stand here: no kernel
118+
* registers one (drivers register as `driver.<name>`), and mocking it is how a
119+
* purge that always answered 500 stayed green.
120+
*/
105121
function makeServices(findRows: Record<string, any[]>) {
106122
return {
107123
manifest: { register: vi.fn() },
108124
auth: installerAuthService(),
109125
objectql: withInstallerGrants({
110126
syncSchemas: async () => undefined,
111127
find: vi.fn(async (object: string) => findRows[object] ?? []),
128+
delete: vi.fn(async (object: string, options?: any) => {
129+
const dispatch = assertEngineDeleteDispatch(options);
130+
if (dispatch.kind !== 'by-id') throw new Error('fake engine: the purge deletes by primary key only');
131+
const rows = findRows[object] ?? [];
132+
const at = rows.findIndex((r) => r.id === dispatch.id);
133+
if (at < 0) return false;
134+
rows.splice(at, 1);
135+
return true;
136+
}),
112137
}),
113138
metadata: {},
114-
driver: { delete: vi.fn(async () => true) },
115139
};
116140
}
117141

@@ -211,7 +235,8 @@ describe('rehydrate sample-data healing', () => {
211235
// Install over an empty DB, with rows landing.
212236
seedResult = { summary: { totalInserted: 3, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
213237
const rawApp = makeRawApp();
214-
const services = makeServices({ crm_x: [], crm_y: [] });
238+
const db: Record<string, any[]> = { crm_x: [], crm_y: [] };
239+
const services = makeServices(db);
215240
const { ctx, fire } = makeCtx(rawApp, services);
216241
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
217242
await plugin.start(ctx as any);
@@ -220,12 +245,18 @@ describe('rehydrate sample-data healing', () => {
220245
makeC({ manifest: MANIFEST }),
221246
);
222247
expect(installRes.payload?.success).toBe(true);
248+
// What the (mocked) loader reported, as rows: keyed by `name`, the
249+
// spec's default `externalId` — these datasets declare none.
250+
db.crm_x.push({ id: 'row-1', name: 'a' }, { id: 'row-2', name: 'b' });
251+
db.crm_y.push({ id: 'row-3', name: 'c' });
223252

224253
// Purge the sample data.
225254
const purgeRes = await rawApp.routes.get('POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data')!(
226255
makeC({}, MANIFEST.id),
227256
);
228257
expect(purgeRes.payload?.success).toBe(true);
258+
expect(purgeRes.payload?.data).toMatchObject({ deleted: 3, skipped: 0, errors: 0 });
259+
expect(db).toEqual({ crm_x: [], crm_y: [] });
229260
expect(new LocalManifestSource(dir).read(MANIFEST.id).entry?.sampleDataPurged).toBe(true);
230261

231262
// Restart (fresh plugin over the same ledger, DB now empty): no reseed.

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

Lines changed: 60 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import {
9797
} from './local-manifest-source.js';
9898
import { ConnectionCredentialStore } from './connection-credential-store.js';
9999
import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js';
100+
import { purgeSeedRows } from './marketplace-install-local-purge.js';
100101
import type { IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts';
101102
import type { DeletePackageRequest, UninstallCleanupOutcome } from '@objectstack/metadata-protocol';
102103

@@ -1683,11 +1684,28 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
16831684
/**
16841685
* POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data
16851686
*
1686-
* Deletes every record whose id is declared in the cached manifest's
1687-
* seed datasets. Uses the `driver` service directly to bypass ACL /
1688-
* lifecycle hooks (same pattern as cloud purge). User-created records
1689-
* are never touched — only ids declared in the package's bundled
1690-
* datasets are removed. Already-deleted rows count as `skipped`.
1687+
* Deletes the rows the cached manifest's seed datasets put in the
1688+
* database, THROUGH THE OBJECTQL ENGINE, so the deletes run every
1689+
* lifecycle hook (audit, sharing, storage, the package's own) — the same
1690+
* posture the seed was written with (`SEED_WRITE_EXECUTION_CONTEXT`).
1691+
* {@link purgeSeedRows} carries the rules; this handler supplies the scope.
1692+
*
1693+
* • A seed row is matched by the seed's own key — each dataset's
1694+
* `externalId`, the key the install's upsert and the reseed match on.
1695+
* A row whose key no seed record declares (user-authored data) is never
1696+
* touched; a key carried by more than one row in scope is not guessed.
1697+
* • Scope is the install's: under an organization wall, the caller's
1698+
* active organization (the same `organizationWallActive` +
1699+
* `resolveActiveOrgId` the install and reseed seed under), and a session
1700+
* with none is answered the way reseed answers it. Without a wall the
1701+
* deployment is one logical tenant and the match is table-wide, as the
1702+
* loader's upsert match is.
1703+
* • Children are deleted before parents — the reverse of the loader's own
1704+
* dependency order.
1705+
*
1706+
* `skipped` counts seed records no row in scope carries (already deleted);
1707+
* `errors` counts records that could not be purged — refused by the engine,
1708+
* or not identifiable — each reason logged.
16911709
*/
16921710
private handlePurge = async (c: any, ctx: PluginContext): Promise<Response> => {
16931711
const admission = await this.requireInstallCapability(c, ctx, 'Purging sample data');
@@ -1718,36 +1736,50 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
17181736
}, 400);
17191737
}
17201738

1721-
let driver: any;
1722-
try { driver = ctx.getService('driver'); } catch { /* none */ }
1723-
if (!driver || typeof driver.delete !== 'function') {
1739+
// The data engine the seed was written through, and the metadata its
1740+
// dependency order is read from — the pair the seed run itself needs.
1741+
let ql: IObjectQLEngine | undefined;
1742+
let metadata: IMetadataService | undefined;
1743+
try { ql = ctx.getService<IObjectQLEngine>('objectql'); } catch { /* no data engine */ }
1744+
try { metadata = ctx.getService<IMetadataService>('metadata'); } catch { /* no metadata service */ }
1745+
if (!ql || !metadata) {
17241746
return c.json({
17251747
success: false,
1726-
error: { code: 'DRIVER_UNAVAILABLE', message: 'driver service unavailable — cannot purge.' },
1748+
error: {
1749+
code: 'DRIVER_UNAVAILABLE',
1750+
message: 'The data engine (objectql) or the metadata service is not available on this runtime — cannot purge sample data.',
1751+
},
17271752
}, 500);
17281753
}
17291754

1730-
let deleted = 0;
1731-
let skipped = 0;
1732-
let errors = 0;
1733-
for (const ds of datasets) {
1734-
const object = String(ds.object);
1735-
for (const rec of ds.records as any[]) {
1736-
const id = rec?.id;
1737-
if (id === undefined || id === null || id === '') { skipped++; continue; }
1738-
try {
1739-
const r = await driver.delete(object, id);
1740-
if (r === false || r === 0 || r?.deleted === 0) skipped++;
1741-
else deleted++;
1742-
} catch (err: any) {
1743-
// Treat "not found" as skipped; anything else as error.
1744-
const msg = String(err?.message ?? err);
1745-
if (/not.?found|no row/i.test(msg)) skipped++;
1746-
else { errors++; ctx.logger?.warn?.(`[MarketplaceInstallLocal] purge ${object}#${id}: ${msg}`); }
1747-
}
1755+
// Scope: the one the install and the reseed seed under (applySideEffects).
1756+
let organizationId: string | undefined;
1757+
if (organizationWallActive(ctx)) {
1758+
const resolved = await this.resolveActiveOrgId(c, ctx);
1759+
if (!resolved) {
1760+
return c.json({
1761+
success: false,
1762+
error: {
1763+
code: 'RESEED_SKIPPED',
1764+
message: 'Purge did not run: multi-tenant-no-active-org. A purge removes the sample rows of the '
1765+
+ "caller's active organization only, and this session has none — set an active organization and retry.",
1766+
},
1767+
}, 400);
17481768
}
1769+
organizationId = resolved;
17491770
}
17501771

1772+
const { SeedLoaderService } = await import('@objectstack/runtime');
1773+
const loader = new (SeedLoaderService as any)(ql, metadata, ctx.logger);
1774+
const graph = await loader.buildDependencyGraph([...new Set(datasets.map((d: any) => String(d.object)))]);
1775+
const { deleted, skipped, errors } = await purgeSeedRows({
1776+
engine: ql,
1777+
datasets,
1778+
graph,
1779+
organizationId,
1780+
warn: (message) => ctx.logger?.warn?.(`[MarketplaceInstallLocal] ${manifestId}: ${message}`),
1781+
});
1782+
17511783
// Flip flag so UI reflects the empty baseline. `sampleDataPurged`
17521784
// additionally tells the rehydrate-time healer this emptiness is
17531785
// deliberate — demo rows must not come back on the next restart.
@@ -1757,7 +1789,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
17571789
this.ledger.write(entry);
17581790
} catch { /* non-fatal */ }
17591791

1760-
ctx.logger?.info?.(`[MarketplaceInstallLocal] purged ${manifestId}: deleted=${deleted} skipped=${skipped} errors=${errors}`);
1792+
ctx.logger?.info?.(`[MarketplaceInstallLocal] purged ${manifestId}${organizationId ? ` (org=${organizationId})` : ''}: deleted=${deleted} skipped=${skipped} errors=${errors}`);
17611793
return c.json({
17621794
success: true,
17631795
data: { manifestId, deleted, skipped, errors, withSampleData: false },

0 commit comments

Comments
 (0)