diff --git a/.changeset/30323-sample-data-count.md b/.changeset/30323-sample-data-count.md new file mode 100644 index 00000000000..cf485dd0823 --- /dev/null +++ b/.changeset/30323-sample-data-count.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cloud-connection': patch +--- + +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 + +Clause-②: yes (widening) +Metadata-protocol: none + +- **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. +- **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`. +- **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. +- **Unchanged.** What the purge deletes, its permission and its answer; the listing's `withSampleData`. +- `@objectstack/cloud-connection` now depends on `@objectstack/rest`, for the shared closed-query-set refusal message. diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index a72a717f520..017084a8205 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -30,6 +30,7 @@ "@objectstack/metadata-protocol": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/observability": "workspace:*", + "@objectstack/rest": "workspace:*", "@objectstack/runtime": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*" diff --git a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts index 3f445813e6a..b9329a948d0 100644 --- a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts @@ -13,7 +13,7 @@ * eight doors refuse on it; `/status` reads it only to decide what it * serves (#24148) and refuses no one. * - `MarketplaceInstallLocalPlugin` reads it in ONE place - * (`resolveInstallPrincipal`), and all five doors answer a held session + * (`resolveInstallPrincipal`), and all six doors answer a held session * through the same refusal (`refuseHeldSession`). * * Each consults `evaluateAuthGate` (`@objectstack/core`) once, with no request @@ -384,13 +384,14 @@ async function mountInstallLocal(storageDir: string) { }; } -/** The five doors, addressed with what each needs to act. */ +/** The six doors, addressed with what each needs to act. */ const IL_DOORS = [ { route: `POST ${IL}`, body: { manifest: APP } }, { route: `GET ${IL}`, body: {} }, { route: `DELETE ${IL}/:manifestId`, body: {} }, { route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} }, { route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} }, + { route: `GET ${IL}/:manifestId/sample-data`, body: {} }, ] as const; /** Install the app as the un-held operator, so the follow-on doors have a target. */ @@ -402,7 +403,7 @@ async function seedInstall(storageDir: string) { } describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => { - it('census: five routes, none allow-listed, all five covered below', async () => { + it('census: six routes, none allow-listed, all six covered below', async () => { const { rawApp } = await mountInstallLocal(dir); const keys = [...rawApp.routes.keys()].sort(); expect(keys).toEqual(IL_DOORS.map((d) => d.route).sort()); @@ -410,7 +411,7 @@ describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => { }); for (const { user, gate } of HELD) { - it(`HELD (${gate.code}): all five doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => { + it(`HELD (${gate.code}): all six doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => { await seedInstall(dir); const ledgerBefore = existsSync(join(dir, LEDGER_FILE)); const { rawApp, effects } = await mountInstallLocal(dir); @@ -430,6 +431,9 @@ describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => { const list = await rawApp.routes.get(`GET ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member')); expect(list.status).toBe(200); expect(list.payload.success).toBe(true); + const counted = await rawApp.routes.get(`GET ${IL}/:manifestId/sample-data`)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id)); + expect(counted.status, JSON.stringify(counted.payload)).toBe(200); + expect(counted.payload.success).toBe(true); for (const route of [`POST ${IL}/:manifestId/reseed-sample-data`, `POST ${IL}/:manifestId/purge-sample-data`, `DELETE ${IL}/:manifestId`]) { const res = await rawApp.routes.get(route)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id)); expect(res.status, `${route}: ${JSON.stringify(res.payload)}`).toBe(200); diff --git a/packages/cloud-connection/src/cloud-connection-route-ledger.ts b/packages/cloud-connection/src/cloud-connection-route-ledger.ts index db53775821d..1661ae175f6 100644 --- a/packages/cloud-connection/src/cloud-connection-route-ledger.ts +++ b/packages/cloud-connection/src/cloud-connection-route-ledger.ts @@ -250,6 +250,17 @@ export const CLOUD_CONNECTION_ROUTE_LEDGER: readonly CloudConnectionRouteLedgerE + '`manage_metadata` (never merely a signed-in session) and driven from the Console. No `@objectstack/client` method builds this URL, and ' + 'a bulk data-purge door is deliberately not something the application SDK exposes.', }, + { + route: 'GET /api/v1/marketplace/install-local/:manifestId/sample-data', + family: 'marketplace-install-local', + mountedIn: 'marketplace-install-local-plugin.ts', + disposition: 'server-only', + note: + 'the read-only count beside purge: how many of the packaged app\'s sample rows the caller\'s scope still holds, ' + + 'counted by the purge\'s own matcher, so it is the number a purge would delete now. It deletes nothing and ' + + 'takes no query parameter. Behind `manage_metadata`, the purge\'s gate, since it exists to tell an operator ' + + 'what a purge would remove; no `@objectstack/client` method builds this URL, for the reason purge has none.', + }, // ── marketplace browse passthrough ───────────────────────────────── { diff --git a/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts b/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts index 72dd977ab75..916ceb7f6bc 100644 --- a/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts @@ -15,7 +15,7 @@ * * ## What is pinned, through the booted plugin's own doors * - * - a key whose owner holds `MFA_REQUIRED` (scope `account`) → all five doors + * - a key whose owner holds `MFA_REQUIRED` (scope `account`) → every door * answer `403 MFA_REQUIRED` with the session's message, before any effect; * - a key whose owner holds only `PASSWORD_RESET_REQUIRED` (scope * `credential`) → served; @@ -201,13 +201,14 @@ function withKey(body: unknown = {}, manifestId?: string) { }; } -/** The five doors, addressed with what each needs to act. */ +/** The six doors, addressed with what each needs to act. */ const IL_DOORS = [ { route: `POST ${IL}`, body: { manifest: APP } }, { route: `GET ${IL}`, body: {} }, { route: `DELETE ${IL}/:manifestId`, body: {} }, { route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} }, { route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} }, + { route: `GET ${IL}/:manifestId/sample-data`, body: {} }, ] as const; let dir: string; @@ -221,7 +222,7 @@ afterEach(() => { }); describe('install-local doors — a key answers as its owner\'s session does (#23455, ruling C′)', () => { - 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 () => { + 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 () => { const postures: Record = {}; const m = await mountInstallLocal(dir, postures); // Installed by the same key while its owner is not held, so the diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 7961d02321d..81017c1ef51 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -240,7 +240,7 @@ import { import { ConnectionCredentialStore } from './connection-credential-store.js'; import { readJsonRequestBody } from './request-json-body.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; -import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; +import { countSeedRows, matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; import { planCustomizationPairInstall, type PairInstallItem } from './marketplace-install-local-customization-pair.js'; import type { AuthSessionApi, ConnectorCredentialRef, IAuthService, IAutomationService, IHttpServer, IMetadataService, IObjectQLEngine, @@ -268,6 +268,7 @@ import { judgeOverrideWrite, overrideWriteIdentityOf, overrideWriteItemsOf, type // [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge, step 1c''. import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol'; import { resolveThrownHttpError } from '@objectstack/types'; +import { unknownQueryParamRefusalMessage } from '@objectstack/rest'; // [#25366] The code the registry's security-catalog refusal carries, compared // rather than `instanceof`-tested: the engine keeps the class unexported so a // consumer holding the other build realm's copy cannot misread it. @@ -278,6 +279,13 @@ type SessionRead = Awaited> const ROUTE_BASE = '/api/v1/marketplace/install-local'; +/** + * The closed query-parameter set of `GET …/:manifestId/sample-data` (AGENTS.md, + * route rule 5): it reads none. An unknown name is refused with a located + * `400`, never ignored — the door has no dry-run switch to mistype. + */ +export const SAMPLE_DATA_COUNT_QUERY_PARAMS: readonly string[] = Object.freeze([]); + /** * [#25366] Whether a `manifest.register` failure is the registry's security * catalog refusal (`SecurityCatalogNameConflictError`, ADR-0048 §3.4 narrowed; @@ -1240,9 +1248,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const reseedHandler = async (c: any) => this.handleReseed(c, ctx); const purgeHandler = async (c: any) => this.handlePurge(c, ctx); + const sampleDataCountHandler = async (c: any) => this.handleSampleDataCount(c, ctx); if (typeof rawApp.post === 'function') rawApp.post(ROUTE_BASE, postHandler); if (typeof rawApp.get === 'function') rawApp.get(ROUTE_BASE, getHandler); + if (typeof rawApp.get === 'function') rawApp.get(`${ROUTE_BASE}/:manifestId/sample-data`, sampleDataCountHandler); if (typeof rawApp.delete === 'function') rawApp.delete(`${ROUTE_BASE}/:manifestId`, deleteHandler); if (typeof rawApp.post === 'function') { rawApp.post(`${ROUTE_BASE}/:manifestId/reseed-sample-data`, reseedHandler); @@ -4537,6 +4547,108 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 200); }; + /** + * GET /api/v1/marketplace/install-local/:manifestId/sample-data + * + * How many of the package's sample rows the caller's scope still holds: + * `{ manifestId, count, unidentified }`. `count` is the number of rows a + * purge run now would delete — {@link countSeedRows}, the purge's own + * {@link matchSeedRows} pass with nothing deleted — so the console can say + * "N records will go" after a reload, when the install answer's + * `data.seeded` is long gone. `unidentified` is the seed records the purge + * would report in `errors` and leave in place. + * + * A read of its own, not a switch on the purge: a GET can delete nothing, + * whatever the request carries, and it takes no query parameter at all + * ({@link SAMPLE_DATA_COUNT_QUERY_PARAMS}). Not a field on the listing + * either: the listing answers `withSampleData` for every installed package + * on every visit, from the FIRST seed row it finds; a count reads every + * seeded object's rows, so it is asked for one package, when it is needed. + * + * Every answer is the purge's for the same request, read-only: + * • the purge's gate, `manage_metadata` ({@link requireInstallCapability}), + * since the count exists to word the purge's confirm; + * • `404` for no install, the unreadable-entry answer, and the protocol + * handshake's `422 OS_PROTOCOL_INCOMPATIBLE` (#21834), in that order; + * • the purge's scope: under an organization wall the caller's active + * organization, table-wide without one. A walled session with NO active + * organization reads nothing and answers `count: 0` — ADR-0123 D2's + * answer for a tenant-scoped READ, the listing's `withSampleData: false` + * for the same session; the purge it would word refuses that session; + * • a package that declares no seed datasets answers `count: 0` (the + * purge's `400 NOTHING_TO_PURGE` is about acting, and nothing is acted on); + * • no data engine or metadata service: the purge's `500 + * DRIVER_UNAVAILABLE`, never a `0` that reads as "nothing left". + */ + private handleSampleDataCount = async (c: any, ctx: PluginContext): Promise => { + const admission = await this.requireInstallCapability(c, ctx, 'Counting sample data'); + if (!admission.ok) return admission.response; + const query = typeof c.req?.query === 'function' ? c.req.query() : undefined; + const queryRefusal = unknownQueryParamRefusalMessage(query, SAMPLE_DATA_COUNT_QUERY_PARAMS); + if (queryRefusal) { + return c.json({ success: false, error: { code: 'VALIDATION_ERROR', message: queryRefusal } }, 400); + } + const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim(); + if (!manifestId) { + return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'manifestId path param required.' } }, 400); + } + if (!this.ledger.has(manifestId)) { + return c.json({ success: false, error: { code: 'RESOURCE_NOT_FOUND', message: `No marketplace install for ${manifestId}.` } }, 404); + } + const { entry, failure } = this.ledger.read(manifestId); + if (!entry) { + return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'count sample data'); + } + const incompatible = this.refuseProtocolIncompatibleEntry(c, entry); + if (incompatible) return incompatible; + + const answer = (count: number, unidentified: number): Response => c.json({ + success: true, + data: { manifestId, count, unidentified }, + }, 200); + + const datasets = seedDatasetsOf(entry.manifest); + if (datasets.length === 0) return answer(0, 0); + + let ql: IObjectQLEngine | undefined; + let metadata: IMetadataService | undefined; + try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } + try { metadata = ctx.getService('metadata'); } catch { /* no metadata service */ } + if (!ql || !metadata) { + return c.json({ + success: false, + error: { + code: 'DRIVER_UNAVAILABLE', + message: 'The data engine (objectql) or the metadata service is not available on this runtime — cannot count sample data.', + }, + }, 500); + } + + let organizationId: string | undefined; + if (organizationWallActive(ctx)) { + const resolved = await this.resolveActiveOrgId(c, ctx); + // [ADR-0123 D2] No organization, so no organization's rows to count. + if (!resolved) return answer(0, 0); + organizationId = resolved; + } + + const { SeedLoaderService } = await import('@objectstack/runtime'); + const loader = new (SeedLoaderService as any)(ql, metadata, ctx.logger); + const graph = await loader.buildDependencyGraph([...new Set(datasets.map((d: any) => String(d.object)))]); + const { count, unidentified } = await countSeedRows({ + engine: ql, + datasets, + graph, + organizationId, + report: (problem) => ctx.logger?.warn?.( + `[MarketplaceInstallLocal] ${manifestId}: the sample-data count could not identify ` + + `${problem.records} seed record(s) of ${problem.object} (${problem.kind}: ${problem.detail}); ` + + 'a purge would leave them in place', + ), + }); + return answer(count, unidentified); + }; + /** * [#21834] ADR-0087 D1's handshake on the two sample-data doors: the answer * reseed and purge give a ledger entry whose declared protocol range diff --git a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts index 5eb6d420357..ab5a1d56bcd 100644 --- a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts @@ -32,10 +32,10 @@ import { tmpdir } from 'node:os'; import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; -import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset, type SeedMatchProblem } from './marketplace-install-local-purge.js'; +import { countSeedRows, matchSeedRows, purgeSeedRows, type PurgeSeedDataset, type SeedMatchProblem } from './marketplace-install-local-purge.js'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource } from './local-manifest-source.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { installerAuthService, installerEmail, INSTALLER_USER_ID, withInstallerGrants } from './install-local-principal.fixtures.js'; // The purge route loads the REAL `SeedLoaderService` through a dynamic import; // paid here, during collection, so no clocked window measures that load. import '@objectstack/runtime'; @@ -342,6 +342,38 @@ describe('matchSeedRows — the identification, read-only', () => { }); }); +describe('countSeedRows — the purge\'s reading, before it deletes', () => { + it('counts exactly the rows the purge then deletes, and deletes nothing itself', async () => { + const tables: Record = { + acc: [{ id: 'a1', name: 'Acme' }, { id: 'a2', name: 'Globex' }, { id: 'u1', name: 'User Co' }], + con: [{ id: 'c1', email: 'ada@acme.test', account: 'a1' }], + }; + const { engine, deletes } = makeEngine(tables); + const counted = await countSeedRows({ engine, datasets: DATASETS, graph: GRAPH, report: () => {} }); + expect(counted).toEqual({ count: 3, unidentified: 0 }); + expect(deletes).toEqual([]); + + const purged = await purgeSeedRows({ engine, datasets: DATASETS, graph: GRAPH, warn: () => {} }); + expect(purged.deleted).toBe(counted.count); + expect(names(tables.acc)).toEqual(['User Co']); + }); + + it('a key two rows carry is unidentified — counted out, the purge\'s error, never a row it deletes', async () => { + const tables: Record = { + acc: [{ id: 'a1', name: 'Acme' }, { id: 'a9', name: 'Acme' }, { id: 'a2', name: 'Globex' }], + con: [], + }; + const { engine } = makeEngine(tables); + const problems: SeedMatchProblem[] = []; + const counted = await countSeedRows({ engine, datasets: DATASETS, graph: GRAPH, report: (p) => problems.push(p) }); + expect(counted).toEqual({ count: 1, unidentified: 1 }); + expect(problems.map((p) => p.kind)).toEqual(['ambiguous']); + + const purged = await purgeSeedRows({ engine, datasets: DATASETS, graph: GRAPH, warn: () => {} }); + expect(purged).toMatchObject({ deleted: counted.count, errors: counted.unidentified }); + }); +}); + // ── The route: scope chosen by the install's own rule, order from the loader ── type Handler = (c: any) => Promise; @@ -361,14 +393,17 @@ const MANIFEST = { const PURGE = 'POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data'; const LIST = 'GET /api/v1/marketplace/install-local'; +const COUNT = 'GET /api/v1/marketplace/install-local/:manifestId/sample-data'; -function makeC(manifestId: string) { +function makeC(manifestId: string, query?: Record) { return { req: { url: 'http://localhost:3000/api/v1/marketplace/install-local', raw: new Request('http://localhost:3000/x'), json: async () => ({}), param: (k: string) => (k === 'manifestId' ? manifestId : undefined), + // Hono's `c.req.query()`: every parameter, by name. + query: () => ({ ...query }), header: () => undefined, }, json: vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })), @@ -389,13 +424,16 @@ async function bootWith(opts: { posture?: 'single' | 'isolated'; activeOrg?: string; services?: Record; + /** The ledgered package; {@link MANIFEST} unless a pin needs another shape. */ + manifest?: Record & { id: string; version: string }; }) { + const manifest = opts.manifest ?? MANIFEST; new LocalManifestSource(dir).write({ - packageId: MANIFEST.id, - versionId: MANIFEST.version, - manifestId: MANIFEST.id, - version: MANIFEST.version, - manifest: MANIFEST, + packageId: manifest.id, + versionId: manifest.version, + manifestId: manifest.id, + version: manifest.version, + manifest, installedAt: '2026-01-01T00:00:00.000Z', installedBy: 'admin', withSampleData: true, @@ -438,7 +476,7 @@ async function bootWith(opts: { const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); await plugin.start(ctx as any); await hooks.get('kernel:ready')?.(); - return { purge: routes.get(PURGE)!, list: routes.get(LIST)!, deletes, reads, logger }; + return { purge: routes.get(PURGE)!, list: routes.get(LIST)!, count: routes.get(COUNT)!, deletes, reads, logger }; } describe('POST …/purge-sample-data — through the engine, in the install\'s scope', () => { @@ -589,3 +627,120 @@ describe('GET … listing — `withSampleData` is the caller\'s own scope, read expect(warned).toContain('no such table: pg_account'); }); }); + +/** The count door's answer for the one ledgered package. */ +async function counted(count: Handler, query?: Record): Promise<{ status: number; payload: any }> { + const res = await count(makeC(MANIFEST.id, query)); + return { status: res.status, payload: res.payload }; +} + +describe('GET …/sample-data — how many sample rows a purge would delete now (#30323)', () => { + it('no wall: counts N; a user-added row and a key-edited sample row are not counted, a field-edited one is; the purge then deletes exactly the count; after it, 0 and withSampleData false', async () => { + const tables: Record = { + pg_account: [{ id: 'a1', name: 'Acme' }], + pg_contact: [{ id: 'c1', email: 'ada@acme.test', account: 'a1' }], + }; + const { count, purge, list, deletes } = await bootWith({ tables, posture: 'single' }); + + // Freshly installed: both sample rows. + expect(await counted(count)).toEqual({ + status: 200, + payload: { success: true, data: { manifestId: MANIFEST.id, count: 2, unidentified: 0 } }, + }); + + // A user adds a record to a seeded object, edits the sample contact's + // KEY (its email), and edits a non-key field of the sample account. + tables.pg_account.push({ id: 'u1', name: 'User Authored Co' }); + tables.pg_contact[0].email = 'ada@renamed.test'; + tables.pg_account[0].phone = '555-0100'; + expect((await counted(count)).payload.data).toEqual({ manifestId: MANIFEST.id, count: 1, unidentified: 0 }); + expect(deletes).toEqual([]); + + // The purge that follows deletes exactly what the count said. + const purged = await purge(makeC(MANIFEST.id)); + expect(purged.payload.data).toMatchObject({ deleted: 1, errors: 0 }); + expect(deletes.map((d) => `${d.object}#${d.id}`)).toEqual(['pg_account#a1']); + expect(tables.pg_account.map((r) => r.id)).toEqual(['u1']); + expect(tables.pg_contact.map((r) => r.id)).toEqual(['c1']); + + // After it: nothing left, and the listing agrees. + expect((await counted(count)).payload.data).toEqual({ manifestId: MANIFEST.id, count: 0, unidentified: 0 }); + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: false, items: 1 }); + }); + + it('wall, active organization: only that organization\'s rows are counted, every read pinned to it', async () => { + const tables: Record = { + pg_account: [{ id: 'a1', name: 'Acme', organization_id: 'org_a' }, { id: 'b1', name: 'Acme', organization_id: 'org_b' }], + pg_contact: [{ id: 'd1', email: 'ada@acme.test', account: 'b1', organization_id: 'org_b' }], + }; + const { count, reads } = await bootWith({ tables, posture: 'isolated', activeOrg: 'org_a' }); + reads.length = 0; + expect((await counted(count)).payload.data).toEqual({ manifestId: MANIFEST.id, count: 1, unidentified: 0 }); + const rowReads = reads.filter((r) => r.object in tables); + expect(rowReads.length).toBeGreaterThan(0); + for (const r of rowReads) expect(r.query.where).toEqual({ organization_id: 'org_a' }); + }); + + it('wall, no active organization: 200 with count 0 and no seed row read (ADR-0123 D2, a read), where the purge refuses', async () => { + const tables: Record = { pg_account: [{ id: 'a1', name: 'Acme', organization_id: 'org_a' }], pg_contact: [] }; + const { count, reads } = await bootWith({ tables, posture: 'isolated' }); + expect(await counted(count)).toEqual({ + status: 200, + payload: { success: true, data: { manifestId: MANIFEST.id, count: 0, unidentified: 0 } }, + }); + expect(reads.filter((r) => r.object in tables)).toEqual([]); + }); + + it('a package that declares no seed datasets: count 0, and no row is read', async () => { + const bare = { ...MANIFEST, id: 'app.test.bare', data: [] }; + const tables: Record = { pg_account: [{ id: 'a1', name: 'Acme' }], pg_contact: [] }; + const { count, reads } = await bootWith({ tables, posture: 'single', manifest: bare }); + const res = await count(makeC(bare.id)); + expect([res.status, res.payload]).toEqual([200, { success: true, data: { manifestId: bare.id, count: 0, unidentified: 0 } }]); + expect(reads.filter((r) => r.object in tables)).toEqual([]); + }); + + it('a query parameter is refused with a located 400 — a GET carrying "dryRun=0" or "confirm" still deletes and reads nothing', async () => { + const tables: Record = { pg_account: [{ id: 'a1', name: 'Acme' }], pg_contact: [] }; + const { count, deletes, reads } = await bootWith({ tables, posture: 'single' }); + reads.length = 0; + for (const query of [{ dryRun: '0' }, { confirm: 'true' }] as Array>) { + const res = await counted(count, query); + expect(res.status).toBe(400); + expect(res.payload.error.code).toBe('VALIDATION_ERROR'); + expect(res.payload.error.message).toContain(`"${Object.keys(query)[0]}" query parameter is not supported`); + } + expect(deletes).toEqual([]); + expect(reads.filter((r) => r.object in tables)).toEqual([]); + expect(tables.pg_account).toHaveLength(1); + }); + + it('no install: 404; no metadata service: the purge\'s 500 DRIVER_UNAVAILABLE, never a 0', async () => { + const tables: Record = { pg_account: [{ id: 'a1', name: 'Acme' }], pg_contact: [] }; + const first = await bootWith({ tables, posture: 'single' }); + const missing = await first.count(makeC('app.not.installed')); + expect([missing.status, missing.payload.error.code]).toEqual([404, 'RESOURCE_NOT_FOUND']); + + const { count } = await bootWith({ tables, posture: 'single', services: { metadata: undefined } }); + const res = await counted(count); + expect([res.status, res.payload.error.code]).toEqual([500, 'DRIVER_UNAVAILABLE']); + }); + + it('a signed-in caller without `manage_metadata` is refused the purge\'s 403, and no seed row is read', async () => { + // The installer's user row, and no permission-set grant at all. + const tables: Record = { + pg_account: [{ id: 'a1', name: 'Acme' }], + pg_contact: [], + sys_user: [{ id: INSTALLER_USER_ID, email: installerEmail(), email_verified: true }], + }; + const ungranted = makeEngine(tables); + const { count, logger } = await bootWith({ tables, posture: 'single', services: { objectql: ungranted.engine } }); + ungranted.reads.length = 0; + const res = await counted(count); + expect(res.status, JSON.stringify(res.payload)).toBe(403); + expect(res.payload.error).toMatchObject({ code: 'FORBIDDEN' }); + expect(res.payload.error.message).toContain('manage_metadata'); + expect(ungranted.reads.filter((r) => r.object.startsWith('pg_'))).toEqual([]); + expect(logger.warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('refused Counting sample data'); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-purge.ts b/packages/cloud-connection/src/marketplace-install-local-purge.ts index 069039a7c2e..01c436c32f5 100644 --- a/packages/cloud-connection/src/marketplace-install-local-purge.ts +++ b/packages/cloud-connection/src/marketplace-install-local-purge.ts @@ -25,8 +25,10 @@ * The identification is its own pass, {@link matchSeedRows}, and it only reads. * The purge deletes what it identifies; the install-local listing asks it * whether the caller's scope holds ANY seed row, and answers that as the - * entry's `withSampleData` (#21775). So "is this package's sample data here?" - * and "what would a purge delete?" are one answer, read one way. + * entry's `withSampleData` (#21775); the sample-data count door asks it HOW + * MANY, through {@link countSeedRows}. So "is this package's sample data + * here?", "how much of it is left?" and "what would a purge delete?" are one + * answer, read one way. * * ## Scope — the install's own * @@ -288,6 +290,36 @@ export async function matchSeedRows(input: SeedRowMatchInput): Promise; + +/** + * Count, READ-ONLY, the rows a purge would delete right now: the full + * {@link matchSeedRows} pass {@link purgeSeedRows} runs, with nothing deleted. + * One matcher, so the count cannot disagree with the purge that follows it + * (save rows the engine then refuses, which the purge reports as `errors`). + */ +export async function countSeedRows(input: SampleDataCountInput): Promise { + const { engine, datasets, graph, organizationId, report } = input; + const match = await matchSeedRows({ engine, datasets, graph, organizationId, report }); + return { count: match.rows.length, unidentified: match.unidentified }; +} + /** A matcher problem in the purge's own words (its log lines predate the matcher). */ function describePurgeProblem(p: SeedMatchProblem): string { switch (p.kind) { diff --git a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts index 9048182fafa..0e3219c1e2d 100644 --- a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts @@ -215,6 +215,7 @@ async function restartWith( reseed: door('reseed-sample-data'), purge: door('purge-sample-data'), uninstall: (manifestId: string) => routes.get(`DELETE ${ROUTE}/:manifestId`)!(makeC(undefined, { manifestId })), + count: (manifestId: string) => routes.get(`GET ${ROUTE}/:manifestId/sample-data`)!(makeC(undefined, { manifestId })), install: (body: unknown) => routes.get(`POST ${ROUTE}`)!(makeC(body)), }; } @@ -350,6 +351,21 @@ describe('reseed and purge on an entry the rehydrate refused to load', () => { }); }); +describe('GET …/sample-data on an entry the rehydrate refused to load (#30323)', () => { + it('answers the purge\'s 422 OS_PROTOCOL_INCOMPATIBLE, reads and writes nothing; the loadable entry beside it is counted', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const before = h.state(); + const res = await h.count(REFUSED.id); + expect(res.status, JSON.stringify(res.payload)).toBe(422); + expect(res.payload.error.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(JSON.stringify(res.payload)).toBe(JSON.stringify((await h.purge(REFUSED.id)).payload)); + expect(h.state()).toEqual(before); + + const loaded = await h.count(LOADED.id); + expect([loaded.status, loaded.payload.data]).toEqual([200, { manifestId: LOADED.id, count: 1, unidentified: 0 }]); + }); +}); + /** * [#28604] An entry an install wrote before #28535, in the body * `os package install ` sends: the manifest nested under `manifest:`, diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts index 12ea2615a0b..28b77c0ffdd 100644 --- a/packages/cloud-connection/vitest.config.ts +++ b/packages/cloud-connection/vitest.config.ts @@ -100,6 +100,14 @@ export default defineConfig({ find: /^@objectstack\/observability$/, replacement: path.resolve(__dirname, '../observability/src/index.ts'), }, + // [#30323] The sample-data count door refuses an unknown query parameter + // with REST's shared sentence (`unknownQueryParamRefusalMessage`), so the + // suites that drive it judge THIS checkout's sentence, not a + // `rest/dist` that may be behind it. Anchored like the entries above. + { + find: /^@objectstack\/rest$/, + replacement: path.resolve(__dirname, '../rest/src/index.ts'), + }, ], }, // Discovery is untouched — but this file is NOT "the alias and nothing else". diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 599ecd11a74..dcf0637e4a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -875,6 +875,9 @@ importers: '@objectstack/observability': specifier: workspace:* version: link:../observability + '@objectstack/rest': + specifier: workspace:* + version: link:../rest '@objectstack/runtime': specifier: workspace:* version: link:../runtime