Skip to content

Commit 6e8ec73

Browse files
feat(cloud-connection): count an installed app's remaining sample rows the purge's way (#30613)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg1b5CXBP4Wj2cbsMH4wGP31)_ domain:server#2 · os-bill · session_01Utp6CYE3Av1KFxjNwT6qzN ## 维护者速读 - 改了什么:新增只读接口 `GET /api/v1/marketplace/install-local/:manifestId/sample-data`,回答「这个已装应用的示例数据现在还剩多少条」,即此刻点「清除示例数据」会删掉的条数。清除本身一行没动。 - 为什么改:#30323 的 ui 半边已落地,但只在本次浏览器会话里知道装了多少条;刷新后没有任何接口能给出数量,清除确认框也就说不出会删几条。 - 风险与回滚:只读,不删数据;和清除共用同一个匹配器、同一道 `manage_metadata` 权限,所以数量和清除结果一定一致。任何查询参数都会被 400 拒绝,GET 请求无论怎么写都删不了数据。回滚即还原本 PR。 - 先看:`marketplace-install-local-plugin.ts` 的 `handleSampleDataCount`;`marketplace-install-local-purge.ts` 的 `countSeedRows`;测试文件末尾新增的 `GET …/sample-data` describe。 - 两分钟验证:`pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2 src/marketplace-install-local-purge.test.ts`,预期 33 个全绿,其中 `GET …/sample-data` 下 7 个。 - 需要你做的:无,席位落地。 Fixes #30323 Clause-②: yes (widening) Metadata-protocol: none ## What changes After a reload, no API said how much sample data an installed app still had. The install answer's `data.seeded` is gone, the purge has no dry run, and the listing's `withSampleData` stops at the first seed row it finds and answers yes or no. So the console's purge confirm could only give a count for an install made in the same browser session (PR #30537, the ui half of this card). - **`GET /api/v1/marketplace/install-local/:manifestId/sample-data`** (new, read-only) answers `{ success: true, data: { manifestId, count, unidentified } }`. - `count` is the number of rows `POST …/purge-sample-data` would delete now: `countSeedRows`, which runs the purge's own `matchSeedRows` pass in full (not `firstOnly`) and deletes nothing. - `unidentified` is the seed records the purge would report in `errors` and leave in place (no usable key, a key two rows carry, an object whose rows could not be read). Each is logged at `warn` with its reason. - **Same rules as the purge, in the same order:** `manage_metadata` through the shared `requireInstallCapability` (401 / held-session 403 / 403 `FORBIDDEN`), then `404` for no install, the unreadable-ledger answer, the protocol handshake's `422 OS_PROTOCOL_INCOMPATIBLE`, then the purge's scope (`organizationWallActive` + `resolveActiveOrgId`). - **Where it differs from the purge, because it is a read:** a walled session with no active organization gets `200, count: 0` and no row is read (ADR-0123 D2 for a tenant-scoped read; the listing answers `withSampleData: false` to the same session), where the purge refuses `403`. A package with no seed datasets gets `count: 0`, where the purge answers `400 NOTHING_TO_PURGE`. No engine or metadata service gets the purge's `500 DRIVER_UNAVAILABLE`, never a `0`. - **It cannot delete.** It is a `GET` with an empty closed query set (`SAMPLE_DATA_COUNT_QUERY_PARAMS`, AGENTS.md route rule 5): `?dryRun=0`, `?confirm=true` or any other name is refused `400 VALIDATION_ERROR` with the shared located sentence (`unknownQueryParamRefusalMessage` from `@objectstack/rest`), before any row is read. - **Route ledger:** one `server-only` row beside the purge in `cloud-connection-route-ledger.ts`. - **Dependency:** `@objectstack/cloud-connection` now depends on `@objectstack/rest` (workspace) for that refusal sentence; `pnpm-lock.yaml` gains the three importer lines. No cycle: `rest` does not depend on `cloud-connection`, and `runtime`, which `cloud-connection` already depends on, already depends on `rest`. - **Unchanged:** what the purge deletes, its gate and its answer; the listing and its `withSampleData`; the install door. No `packages/spec` change. ### Why a separate read, not a count on the listing The seat ruling asked to measure both and take the smaller. The listing answers every installed package on every visit to Installed Apps, for every signed-in caller, and today reads only until the first seed row (`firstOnly`). A count has to read every seeded object's rows for every package on each listing call. The new door does that for one package, when the confirm needs it, behind the purge's gate. A `?dryRun=1` switch on the purge was rejected because a misspelled switch on a `POST` that deletes would delete. ### What "an edited sample record is kept" means The purge matches a row by its dataset's `externalId` key. A sample record whose **key field** was edited is no longer a seed row: it is neither deleted nor counted. A sample record edited only in **other fields** still carries its seed key: the purge deletes it, and the count includes it. The card's observation ("kept a sample record I had edited") holds for a key edit. The count follows the purge either way, because it is the same matcher; the pins below cover all three cases. ## Tests Pins go into the existing harness files as new top-level describes. `marketplace-install-local-purge.test.ts`: - `countSeedRows`: counts exactly the rows `purgeSeedRows` then deletes, deleting nothing itself; a key two rows carry is `unidentified`, equal to the purge's `errors`, and is not counted. - `GET …/sample-data`, through the booted plugin with the real `SeedLoaderService`: - install with samples gives count 2; add a user record, edit the sample contact's key and the sample account's non-key field, and the count is 1; the purge then deletes exactly that one row; afterwards the count is 0 and the listing says `withSampleData: false`; - wall with an active organization: counts only that organization's rows, every read pinned to it; - wall with no active organization: `200, count 0`, no seed row read; - a package without seed datasets: `count 0`, no row read; - `?dryRun=0` and `?confirm=true`: `400 VALIDATION_ERROR` naming the parameter, nothing read, nothing deleted; - no install: `404`; no metadata service: `500 DRIVER_UNAVAILABLE`; - signed in without `manage_metadata`: `403 FORBIDDEN` naming the capability, no seed row read. `marketplace-install-local-sample-data-not-loaded.test.ts`: on an entry the rehydrate refused, the count answers the purge's `422` byte for byte and changes nothing; the loadable entry beside it counts 1. `cloud-connection-auth-gate.pin.test.ts` and `marketplace-install-local-api-key-account-posture.pin.test.ts`: the door joins the census (now six), so a held session and a held owner's API key are refused `403` with the gate's code, an anonymous caller `401`, and the un-held operator is served. ## Acceptance notes - The console does not call the new door yet. That is the ui follow-up the seat files: the purge confirm and the banner can read `count` on any visit. - No hand-written doc becomes false. `content/docs/deployment/marketplace-handbook.mdx` step 6 names the reseed and purge routes and says both need `manage_metadata`, which still holds. Adding the new route there is left to the ui follow-up, when the console shows the count. - The file surface grew by `marketplace-install-local-plugin.ts` (told to the seat first; handler and one mount line only, the install door and its `heldBy` read untouched), the two auth-pin test files whose route census must list the new door, `packages/cloud-connection/package.json` and `pnpm-lock.yaml` (the `@objectstack/rest` dependency), and `packages/cloud-connection/vitest.config.ts` (its source alias). - Changeset: `patch`, because `Metadata-protocol: none` (AGENTS.md Post-Task Checklist step 3: the level follows the kind; a non-protocol widening ships `patch`). ## Verification All on head `00d0c9d5` (`origin/main` `ad2393f7` merged at `05371373`; the merge touched no `packages/cloud-connection` file; the dependency closure was rebuilt after it). <details><summary>Tests and typecheck</summary> - `pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2` under the verify lock: Test Files 70 passed (70), Tests 888 passed (888). - `pnpm --filter @objectstack/cloud-connection typecheck` (both `tsc` programs, tests included): exit 0. - `eslint --no-inline-config` over the seven changed `.ts` files: exit 0, no finding. The repo-wide `pnpm lint` is left to CI. </details> <details><summary>Ablations (scripts/ablation-replace.mjs, each restored, tree clean after each)</summary> The suites import the plugin and the matcher from `src/`, so no rebuild sits between the mutation and the run. 1. `countSeedRows` passes `firstOnly: true` (the listing's early stop): 2 red, the unit "counts exactly the rows the purge then deletes" and the route "counts N … purge then deletes exactly the count". 2. The query-parameter refusal deleted: 1 red, "a query parameter is refused with a located 400". 3. The no-active-organization `count: 0` answer deleted: 1 red, "wall, no active organization: 200 with count 0". 4. The `requireInstallCapability` admission deleted: 4 red, the `manage_metadata` 403 pin, both held-session census cases and the anonymous 401 case in `cloud-connection-auth-gate.pin.test.ts`. 5. The `DRIVER_UNAVAILABLE` branch disabled: 1 red, "no metadata service: the purge's 500 DRIVER_UNAVAILABLE, never a 0". </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `00d0c9d5`: 75 families, each run with its exit code captured before any pipe, all exit 0. `--ran`: 75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN. On the first run `check:test-source-alias` was red: the new `@objectstack/rest` import resolved to `rest/dist` in the tests. Fixed by an anchored alias in `packages/cloud-connection/vitest.config.ts`, as the gate prescribes. </details> Full dogfood battery: n/a. The title has no `!` and `Clause-②` has no `(narrowing)` arm. CI's Dogfood Regression Gate runs on the PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYE3Av1KFxjNwT6qzN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4dde0ae commit 6e8ec73

11 files changed

Lines changed: 376 additions & 19 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
feat(cloud-connection): `GET /api/v1/marketplace/install-local/:manifestId/sample-data` answers how many of an installed app's sample records a purge would delete now
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
- **New read-only route.** `GET /api/v1/marketplace/install-local/:manifestId/sample-data` answers `{ success: true, data: { manifestId, count, unidentified } }`. `count` is the number of rows `POST …/purge-sample-data` would delete if it ran now, found by the purge's own matcher with nothing deleted. A record a user added is not counted, and neither is a sample record whose key field (its dataset's `externalId`) was edited, because the purge deletes neither. A sample record edited only in other fields is still a sample row: the purge deletes it and the count includes it. `unidentified` counts the sample records the purge would report in `errors` and leave in place (no usable key value, or a key two rows carry). Before this, the count was known only from the install answer's `data.seeded`, so after a reload no API could say how much sample data was left; the installed-apps listing's `withSampleData` says only whether any is left.
11+
- **Same rules as the purge.** It requires `manage_metadata`, answers `404` for a package that is not installed and `422 OS_PROTOCOL_INCOMPATIBLE` for an entry this runtime refused to load, and counts in the purge's scope (the caller's active organization under an organization wall, every row without one). A walled session with no active organization gets `count: 0`, the same answer as the listing's `withSampleData: false` for that session. A package with no seed datasets gets `count: 0`. With no data engine or metadata service it answers `500 DRIVER_UNAVAILABLE`, never `0`.
12+
- **It cannot delete.** It is a `GET` and accepts no query parameter: any parameter (`dryRun`, `confirm`, …) is refused with `400 VALIDATION_ERROR` before any row is read.
13+
- **Unchanged.** What the purge deletes, its permission and its answer; the listing's `withSampleData`.
14+
- `@objectstack/cloud-connection` now depends on `@objectstack/rest`, for the shared closed-query-set refusal message.

‎packages/cloud-connection/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@objectstack/metadata-protocol": "workspace:*",
3131
"@objectstack/objectql": "workspace:*",
3232
"@objectstack/observability": "workspace:*",
33+
"@objectstack/rest": "workspace:*",
3334
"@objectstack/runtime": "workspace:*",
3435
"@objectstack/spec": "workspace:*",
3536
"@objectstack/types": "workspace:*"

‎packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* eight doors refuse on it; `/status` reads it only to decide what it
1414
* serves (#24148) and refuses no one.
1515
* - `MarketplaceInstallLocalPlugin` reads it in ONE place
16-
* (`resolveInstallPrincipal`), and all five doors answer a held session
16+
* (`resolveInstallPrincipal`), and all six doors answer a held session
1717
* through the same refusal (`refuseHeldSession`).
1818
*
1919
* Each consults `evaluateAuthGate` (`@objectstack/core`) once, with no request
@@ -384,13 +384,14 @@ async function mountInstallLocal(storageDir: string) {
384384
};
385385
}
386386

387-
/** The five doors, addressed with what each needs to act. */
387+
/** The six doors, addressed with what each needs to act. */
388388
const IL_DOORS = [
389389
{ route: `POST ${IL}`, body: { manifest: APP } },
390390
{ route: `GET ${IL}`, body: {} },
391391
{ route: `DELETE ${IL}/:manifestId`, body: {} },
392392
{ route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} },
393393
{ route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} },
394+
{ route: `GET ${IL}/:manifestId/sample-data`, body: {} },
394395
] as const;
395396

396397
/** Install the app as the un-held operator, so the follow-on doors have a target. */
@@ -402,15 +403,15 @@ async function seedInstall(storageDir: string) {
402403
}
403404

404405
describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => {
405-
it('census: five routes, none allow-listed, all five covered below', async () => {
406+
it('census: six routes, none allow-listed, all six covered below', async () => {
406407
const { rawApp } = await mountInstallLocal(dir);
407408
const keys = [...rawApp.routes.keys()].sort();
408409
expect(keys).toEqual(IL_DOORS.map((d) => d.route).sort());
409410
for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false);
410411
});
411412

412413
for (const { user, gate } of HELD) {
413-
it(`HELD (${gate.code}): all five doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => {
414+
it(`HELD (${gate.code}): all six doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => {
414415
await seedInstall(dir);
415416
const ledgerBefore = existsSync(join(dir, LEDGER_FILE));
416417
const { rawApp, effects } = await mountInstallLocal(dir);
@@ -430,6 +431,9 @@ describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => {
430431
const list = await rawApp.routes.get(`GET ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member'));
431432
expect(list.status).toBe(200);
432433
expect(list.payload.success).toBe(true);
434+
const counted = await rawApp.routes.get(`GET ${IL}/:manifestId/sample-data`)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id));
435+
expect(counted.status, JSON.stringify(counted.payload)).toBe(200);
436+
expect(counted.payload.success).toBe(true);
433437
for (const route of [`POST ${IL}/:manifestId/reseed-sample-data`, `POST ${IL}/:manifestId/purge-sample-data`, `DELETE ${IL}/:manifestId`]) {
434438
const res = await rawApp.routes.get(route)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id));
435439
expect(res.status, `${route}: ${JSON.stringify(res.payload)}`).toBe(200);

‎packages/cloud-connection/src/cloud-connection-route-ledger.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,17 @@ export const CLOUD_CONNECTION_ROUTE_LEDGER: readonly CloudConnectionRouteLedgerE
250250
+ '`manage_metadata` (never merely a signed-in session) and driven from the Console. No `@objectstack/client` method builds this URL, and '
251251
+ 'a bulk data-purge door is deliberately not something the application SDK exposes.',
252252
},
253+
{
254+
route: 'GET /api/v1/marketplace/install-local/:manifestId/sample-data',
255+
family: 'marketplace-install-local',
256+
mountedIn: 'marketplace-install-local-plugin.ts',
257+
disposition: 'server-only',
258+
note:
259+
'the read-only count beside purge: how many of the packaged app\'s sample rows the caller\'s scope still holds, '
260+
+ 'counted by the purge\'s own matcher, so it is the number a purge would delete now. It deletes nothing and '
261+
+ 'takes no query parameter. Behind `manage_metadata`, the purge\'s gate, since it exists to tell an operator '
262+
+ 'what a purge would remove; no `@objectstack/client` method builds this URL, for the reason purge has none.',
263+
},
253264

254265
// ── marketplace browse passthrough ─────────────────────────────────
255266
{

‎packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*
1616
* ## What is pinned, through the booted plugin's own doors
1717
*
18-
* - a key whose owner holds `MFA_REQUIRED` (scope `account`) → all five doors
18+
* - a key whose owner holds `MFA_REQUIRED` (scope `account`) → every door
1919
* answer `403 MFA_REQUIRED` with the session's message, before any effect;
2020
* - a key whose owner holds only `PASSWORD_RESET_REQUIRED` (scope
2121
* `credential`) → served;
@@ -201,13 +201,14 @@ function withKey(body: unknown = {}, manifestId?: string) {
201201
};
202202
}
203203

204-
/** The five doors, addressed with what each needs to act. */
204+
/** The six doors, addressed with what each needs to act. */
205205
const IL_DOORS = [
206206
{ route: `POST ${IL}`, body: { manifest: APP } },
207207
{ route: `GET ${IL}`, body: {} },
208208
{ route: `DELETE ${IL}/:manifestId`, body: {} },
209209
{ route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} },
210210
{ route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} },
211+
{ route: `GET ${IL}/:manifestId/sample-data`, body: {} },
211212
] as const;
212213

213214
let dir: string;
@@ -221,7 +222,7 @@ afterEach(() => {
221222
});
222223

223224
describe('install-local doors — a key answers as its owner\'s session does (#23455, ruling C′)', () => {
224-
it('REFUSES a key whose owner holds MFA_REQUIRED on all five doors, with the session\'s 403 code and message, before any effect', async () => {
225+
it('REFUSES a key whose owner holds MFA_REQUIRED on all six doors, with the session\'s 403 code and message, before any effect', async () => {
225226
const postures: Record<string, Posture[]> = {};
226227
const m = await mountInstallLocal(dir, postures);
227228
// Installed by the same key while its owner is not held, so the

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

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ import {
240240
import { ConnectionCredentialStore } from './connection-credential-store.js';
241241
import { readJsonRequestBody } from './request-json-body.js';
242242
import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js';
243-
import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js';
243+
import { countSeedRows, matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js';
244244
import { planCustomizationPairInstall, type PairInstallItem } from './marketplace-install-local-customization-pair.js';
245245
import type {
246246
AuthSessionApi, ConnectorCredentialRef, IAuthService, IAutomationService, IHttpServer, IMetadataService, IObjectQLEngine,
@@ -268,6 +268,7 @@ import { judgeOverrideWrite, overrideWriteIdentityOf, overrideWriteItemsOf, type
268268
// [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge, step 1c''.
269269
import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol';
270270
import { resolveThrownHttpError } from '@objectstack/types';
271+
import { unknownQueryParamRefusalMessage } from '@objectstack/rest';
271272
// [#25366] The code the registry's security-catalog refusal carries, compared
272273
// rather than `instanceof`-tested: the engine keeps the class unexported so a
273274
// consumer holding the other build realm's copy cannot misread it.
@@ -278,6 +279,13 @@ type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>
278279

279280
const ROUTE_BASE = '/api/v1/marketplace/install-local';
280281

282+
/**
283+
* The closed query-parameter set of `GET …/:manifestId/sample-data` (AGENTS.md,
284+
* route rule 5): it reads none. An unknown name is refused with a located
285+
* `400`, never ignored — the door has no dry-run switch to mistype.
286+
*/
287+
export const SAMPLE_DATA_COUNT_QUERY_PARAMS: readonly string[] = Object.freeze([]);
288+
281289
/**
282290
* [#25366] Whether a `manifest.register` failure is the registry's security
283291
* catalog refusal (`SecurityCatalogNameConflictError`, ADR-0048 §3.4 narrowed;
@@ -1240,9 +1248,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
12401248

12411249
const reseedHandler = async (c: any) => this.handleReseed(c, ctx);
12421250
const purgeHandler = async (c: any) => this.handlePurge(c, ctx);
1251+
const sampleDataCountHandler = async (c: any) => this.handleSampleDataCount(c, ctx);
12431252

12441253
if (typeof rawApp.post === 'function') rawApp.post(ROUTE_BASE, postHandler);
12451254
if (typeof rawApp.get === 'function') rawApp.get(ROUTE_BASE, getHandler);
1255+
if (typeof rawApp.get === 'function') rawApp.get(`${ROUTE_BASE}/:manifestId/sample-data`, sampleDataCountHandler);
12461256
if (typeof rawApp.delete === 'function') rawApp.delete(`${ROUTE_BASE}/:manifestId`, deleteHandler);
12471257
if (typeof rawApp.post === 'function') {
12481258
rawApp.post(`${ROUTE_BASE}/:manifestId/reseed-sample-data`, reseedHandler);
@@ -4537,6 +4547,108 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
45374547
}, 200);
45384548
};
45394549

4550+
/**
4551+
* GET /api/v1/marketplace/install-local/:manifestId/sample-data
4552+
*
4553+
* How many of the package's sample rows the caller's scope still holds:
4554+
* `{ manifestId, count, unidentified }`. `count` is the number of rows a
4555+
* purge run now would delete — {@link countSeedRows}, the purge's own
4556+
* {@link matchSeedRows} pass with nothing deleted — so the console can say
4557+
* "N records will go" after a reload, when the install answer's
4558+
* `data.seeded` is long gone. `unidentified` is the seed records the purge
4559+
* would report in `errors` and leave in place.
4560+
*
4561+
* A read of its own, not a switch on the purge: a GET can delete nothing,
4562+
* whatever the request carries, and it takes no query parameter at all
4563+
* ({@link SAMPLE_DATA_COUNT_QUERY_PARAMS}). Not a field on the listing
4564+
* either: the listing answers `withSampleData` for every installed package
4565+
* on every visit, from the FIRST seed row it finds; a count reads every
4566+
* seeded object's rows, so it is asked for one package, when it is needed.
4567+
*
4568+
* Every answer is the purge's for the same request, read-only:
4569+
* • the purge's gate, `manage_metadata` ({@link requireInstallCapability}),
4570+
* since the count exists to word the purge's confirm;
4571+
* • `404` for no install, the unreadable-entry answer, and the protocol
4572+
* handshake's `422 OS_PROTOCOL_INCOMPATIBLE` (#21834), in that order;
4573+
* • the purge's scope: under an organization wall the caller's active
4574+
* organization, table-wide without one. A walled session with NO active
4575+
* organization reads nothing and answers `count: 0` — ADR-0123 D2's
4576+
* answer for a tenant-scoped READ, the listing's `withSampleData: false`
4577+
* for the same session; the purge it would word refuses that session;
4578+
* • a package that declares no seed datasets answers `count: 0` (the
4579+
* purge's `400 NOTHING_TO_PURGE` is about acting, and nothing is acted on);
4580+
* • no data engine or metadata service: the purge's `500
4581+
* DRIVER_UNAVAILABLE`, never a `0` that reads as "nothing left".
4582+
*/
4583+
private handleSampleDataCount = async (c: any, ctx: PluginContext): Promise<Response> => {
4584+
const admission = await this.requireInstallCapability(c, ctx, 'Counting sample data');
4585+
if (!admission.ok) return admission.response;
4586+
const query = typeof c.req?.query === 'function' ? c.req.query() : undefined;
4587+
const queryRefusal = unknownQueryParamRefusalMessage(query, SAMPLE_DATA_COUNT_QUERY_PARAMS);
4588+
if (queryRefusal) {
4589+
return c.json({ success: false, error: { code: 'VALIDATION_ERROR', message: queryRefusal } }, 400);
4590+
}
4591+
const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim();
4592+
if (!manifestId) {
4593+
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'manifestId path param required.' } }, 400);
4594+
}
4595+
if (!this.ledger.has(manifestId)) {
4596+
return c.json({ success: false, error: { code: 'RESOURCE_NOT_FOUND', message: `No marketplace install for ${manifestId}.` } }, 404);
4597+
}
4598+
const { entry, failure } = this.ledger.read(manifestId);
4599+
if (!entry) {
4600+
return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'count sample data');
4601+
}
4602+
const incompatible = this.refuseProtocolIncompatibleEntry(c, entry);
4603+
if (incompatible) return incompatible;
4604+
4605+
const answer = (count: number, unidentified: number): Response => c.json({
4606+
success: true,
4607+
data: { manifestId, count, unidentified },
4608+
}, 200);
4609+
4610+
const datasets = seedDatasetsOf(entry.manifest);
4611+
if (datasets.length === 0) return answer(0, 0);
4612+
4613+
let ql: IObjectQLEngine | undefined;
4614+
let metadata: IMetadataService | undefined;
4615+
try { ql = ctx.getService<IObjectQLEngine>('objectql'); } catch { /* no data engine */ }
4616+
try { metadata = ctx.getService<IMetadataService>('metadata'); } catch { /* no metadata service */ }
4617+
if (!ql || !metadata) {
4618+
return c.json({
4619+
success: false,
4620+
error: {
4621+
code: 'DRIVER_UNAVAILABLE',
4622+
message: 'The data engine (objectql) or the metadata service is not available on this runtime — cannot count sample data.',
4623+
},
4624+
}, 500);
4625+
}
4626+
4627+
let organizationId: string | undefined;
4628+
if (organizationWallActive(ctx)) {
4629+
const resolved = await this.resolveActiveOrgId(c, ctx);
4630+
// [ADR-0123 D2] No organization, so no organization's rows to count.
4631+
if (!resolved) return answer(0, 0);
4632+
organizationId = resolved;
4633+
}
4634+
4635+
const { SeedLoaderService } = await import('@objectstack/runtime');
4636+
const loader = new (SeedLoaderService as any)(ql, metadata, ctx.logger);
4637+
const graph = await loader.buildDependencyGraph([...new Set(datasets.map((d: any) => String(d.object)))]);
4638+
const { count, unidentified } = await countSeedRows({
4639+
engine: ql,
4640+
datasets,
4641+
graph,
4642+
organizationId,
4643+
report: (problem) => ctx.logger?.warn?.(
4644+
`[MarketplaceInstallLocal] ${manifestId}: the sample-data count could not identify `
4645+
+ `${problem.records} seed record(s) of ${problem.object} (${problem.kind}: ${problem.detail}); `
4646+
+ 'a purge would leave them in place',
4647+
),
4648+
});
4649+
return answer(count, unidentified);
4650+
};
4651+
45404652
/**
45414653
* [#21834] ADR-0087 D1's handshake on the two sample-data doors: the answer
45424654
* reseed and purge give a ledger entry whose declared protocol range

0 commit comments

Comments
 (0)