Skip to content

Commit 8f4e8fb

Browse files
feat(cloud-connection)!: the marketplace catalog list filters by taxonomy facets on the server, and retires ?category= (#31234)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XHJo3uQpXK1vctgUQNzvpkw)_ Fixes #31173 Clause-②: no (narrowing) — the facets are declared by the published `MarketplaceSearchRequestSchema`; refusing the retired `?category=` filter, and a facet the catalog cannot answer, narrows what the proxy honours Metadata-protocol: none Skills-docs: none — no page under `skills/**` or `content/docs/**` describes the query of `GET /api/v1/marketplace/packages` (a `git grep` for `category=` and `marketplace/packages` there finds none); the generated `references/marketplace/marketplace.mdx` already lists the facet keys from the schema, and nothing in it moves ## 维护者速读 - 改了什么:市场目录列表 `GET /api/v1/marketplace/packages` 现在在服务端按职能(`businessFunctions`)、行业(`industries`)、解决方案类型(`solutionTypes`)和成熟度(`maturity`)筛选,同一维度内取「或」、维度之间取「与」,再在服务端分页;旧的 `?category=` 筛选删除,带它的请求回 400,并附上规格里退役该键时给出的改法(改用哪个维度)。 - 为什么改:控制台要按三个维度筛几百个模板,只拉 100 条在前端筛不动(#31011 第 5 项、#30904 裁决 §6);`?category=` 在 cloud#3661 去掉该列后会静默失效。 - 风险与回滚:只改 `@objectstack/cloud-connection` 一个文件加一个测试,patch 级;`?category=`、拼错的维度值、重复的维度键、空维度现在回 400 `VALIDATION_ERROR`,仓内没有任何调用方发这些参数;回滚即还原本 PR。 - 先看:`packages/cloud-connection/src/marketplace-proxy-plugin.ts` 的 `readCatalogListQuery`(拒绝规则,含 `category`)与 `catalogRowMatches`(或/与、缺省值),再看 handler 里在 R2 与 cloud 两条路之前读查询的那一段。 - 两分钟验证:`pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-proxy-browse-only.test.ts`,预期 16 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `packages/cloud-connection/src/marketplace-proxy-plugin.ts`, the catalog list door `GET /api/v1/marketplace/packages` (and `HEAD`): - **The facets, the spec's spelling.** `businessFunctions`, `industries`, `solutionTypes` (plural, each a list) and `maturity` (one value), the keys `MarketplaceSearchRequestSchema` declares in `@objectstack/spec/marketplace`. A facet is ONE comma-separated value, `?businessFunctions=sales,marketing`, the spelling the platform's other list-valued query parameters take (`names` on the `/meta` list, `fields` / `searchFields` on the data routes); each value trimmed, blanks dropped, repeats folded. The allowed values are read from the spec schemas' `.options`, never copied. - **OR within, AND across.** A row passes a facet when it carries any selected value; facets AND with each other, with `maturity` and with `q`. Rows are the catalog's own (snake_case `business_functions`, `industries`, `solution_type`, `maturity`, the columns objectstack-ai/cloud#3661 adds and #31011's console reads). A row with no `solution_type` counts as the spec's default `app` (`DEFAULT_SOLUTION_TYPE`) and one with no `maturity` as `stable`, the spec's reading of an absent key and the console's (`effectiveSolutionType` on PR #31187). A row without the facet columns passes no business-function or industry filter, so until the cloud serves the taxonomy those two facets return nothing, as #31011 plans. - **Paged on the server.** On the public snapshot path the proxy filters the whole snapshot, then `limit` / `offset` page it (`limit` 1–100, default 50, as before) and `total` counts every match. - **`?category=` retired, and refused.** The `category` branch is gone. A `category` parameter is refused `400 VALIDATION_ERROR` with the spec's own prescription, `retiredCategoryGuidance('MarketplaceSearchRequest.category')` from `@objectstack/spec/marketplace`: the text a parse of `MarketplaceSearchRequestSchema` gives the same retired key (migration entry `18.marketplace-category-retired`), naming the facets to send instead. No second spelling is kept, and a caller still filtering by category is told so instead of receiving the whole catalog. - **Refused, before either path is asked**, `400` with the proxy's `{ success: false, error: { code: 'VALIDATION_ERROR', message } }`: `category` (above), a value the spec does not declare for the facet (`?businessFunctions=crm`, `?maturity=preview`), a facet or `maturity` supplied twice (`?industries=retail&industries=logistics`, the shared `repeatedQueryParamMessage`), and a facet naming no value (`?solutionTypes=`). The read sits in the handler ahead of the R2 fast path and the cloud path, so the answer does not depend on which one serves the list. ## Tests The catalog pins live in the proxy's existing harness, `packages/cloud-connection/src/marketplace-proxy-browse-only.test.ts` (its `captureHandler` and `fakeCtx`, plus a stubbed `fetch`), 13 new beside its 3: OR within a facet, AND across, the `app` / `stable` defaults, rows without the taxonomy, folded values, `q` plus server paging (`total`, `limit`, `offset`), `?category=` refused with the spec prescription alone, beside a real filter and on the cloud-only configuration, each facet refusal on the R2 configuration and on the cloud-only one with no fetch made, and the cloud path forwarding the facet query verbatim. <details><summary>Suites and ablations</summary> - `pnpm --filter @objectstack/cloud-connection test`: on head `868cfd7e` (in the dogfood chain, under the lock), Test Files 73 passed (73), Tests 952 passed (952). `typecheck` (`tsc --noEmit` plus `tsconfig.test.json`, which compiles the test): exit 0 on `868cfd7e`. - Ablations through `scripts/ablation-replace.mjs` on the committed implementation (`868cfd7e`), each anchor hit once, each restore proven by blob == HEAD and an empty `git diff HEAD`. The suite imports the plugin from `src/` relatively, so no build sits on the path. - A1, the `category` refusal disabled (red-first for the retargeted pins): both `?category=` cases red, `expected undefined to be 400`, 2 failed / 14 passed. - A2, the undeclared-value refusal disabled: 2 failed / 14. - A3, the repeated-key refusal disabled: 1 failed / 15. - A4, the empty-facet refusal disabled: 1 failed / 15. - A5, OR within a facet turned into every-of: 3 failed / 13. </details> ## Acceptance notes - **Card vs spec spelling.** The card writes `solutionType`; the spec's request schema declares `solutionTypes` (a list, like the other two facets). The door implements the declared spelling only; `?solutionType=` is ignored like any parameter the door does not read (the proxy refused no query parameter before this change, and `category` is the one retired key it now refuses). `solutionType` (singular) is the listing's own key, not a filter. - **The cloud path.** With no `OS_MARKETPLACE_PUBLIC_BASE_URL` (the default: `resolveMarketplacePublicBaseUrl` returns empty when it is unset) every list request takes the cloud path, which forwards `pathname + search` to the control plane unchanged once the query passes the door (pinned by the last test). Whether the cloud filters by these names is the cloud's: NOT MEASURED here, reason: objectstack-ai/cloud is not readable from this session. Until it does, the console's switch-over sees server filtering only where the public snapshot path is configured. - **The request schema is not this route's whole query.** `MarketplaceSearchRequestSchema` also declares `query` / `page` / `pageSize`, while this door (and the console, and the cloud route it proxies) speak `q` / `limit` / `offset`, and the list answers the catalog's row shape, not `MarketplaceSearchResponseSchema`. This PR takes only the facet keys and values from the schema and leaves the paging names as they were. - **Kept as before.** `?limit=abc` still reads `NaN` and answers an empty page on the snapshot path (`limit: null` on the wire): unchanged here, not in this card. The console switch-over to the server parameters is the `domain:ui` follow-up the card names. ## Verification Head `868cfd7e` (rework round after contract review 6711888512; `origin/main` merged once at `248a01c7`). Gate union derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (60 families), run after a full `pnpm turbo run build --concurrency=2` (VERDICT command-exit 0), each exit code captured before any pipe: - **60 run, exit 0 each.** `--ran`: "60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED". `check-plugin-teardown-shape --self-test` needed `git fetch --depth=1 origin 621a487` for its fixture on this shallow clone. - **Full dogfood battery** (the `(narrowing)` arm), `pnpm --filter @objectstack/dogfood test` under the lock on `868cfd7e`: VERDICT command-exit 0, Test Files 435 passed, 3 skipped (438), Tests 3609 passed, 41 skipped (3650). - `pnpm lint` scoped: `eslint --no-inline-config` over the two touched source files, exit 0. - CI families are read from this PR's check-runs, not re-run here. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kRdiXKLNW3aoF5Zhj)_ <!-- signed: domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd3ab0a commit 8f4e8fb

3 files changed

Lines changed: 418 additions & 31 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
feat(cloud-connection)!: the marketplace catalog list filters by business function, industry, solution type and maturity on the server, and the `?category=` filter is retired
6+
7+
Clause-②: no (narrowing) — the facets are declared by the published `MarketplaceSearchRequestSchema` (`businessFunctions`, `industries`, `solutionTypes`, `maturity`), so implementing them widens no contract; refusing the retired `?category=` filter, and a facet the catalog cannot answer, narrows what the proxy honours. No export, type or error code of the package is added.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of the package changes: the catalog list door reads query-string names the spec already declares and refuses one the spec already retired, with the spec's own prescription. Nothing at rest records a catalog query, so there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**The facet filters.** `GET /api/v1/marketplace/packages` reads `businessFunctions`, `industries` and `solutionTypes`, each one comma-separated value (`?businessFunctions=sales,marketing`), and `maturity`, one value. Values inside one facet are OR-ed, facets are AND-ed with each other, with `maturity` and with `q`, and `limit` / `offset` page the filtered set, so `total` counts every matching listing. A listing that declares no solution type is the spec's default `app`, and one that declares no maturity is `stable`. On the public snapshot path (`OS_MARKETPLACE_PUBLIC_BASE_URL`) the proxy filters and pages the snapshot itself; on the cloud path it forwards the query string to the cloud unchanged, as before.
13+
14+
**BREAKING** accept-set narrowing at the catalog list (`@objectstack/cloud-connection`).
15+
16+
- **Before.** `?category=crm` filtered the public snapshot by the listing's retired `category` column.
17+
- **Now.** `?category=` is refused with `400` and the spec's retirement prescription for `MarketplaceSearchRequest.category` (`retiredCategoryGuidance`, the text a parse of `MarketplaceSearchRequestSchema` gives the same key), on the snapshot and the cloud path alike, before either is asked. A facet value the spec does not declare (`?businessFunctions=crm`), a facet supplied twice (`?industries=retail&industries=logistics`) and a facet naming no value (`?solutionTypes=`) are refused with `400` and `{ "success": false, "error": { "code": "VALIDATION_ERROR", "message" } }` before the snapshot or the cloud is asked; the message names the parameter and the values it takes.
18+
- **Who moves.** Nothing in the workspace sends `?category=` or a facet today; the Console filters the loaded catalog itself. A script still sending `?category=` gets the 400 instead of a filtered list.
19+
20+
**The remedy.** Replace `?category=X` with the facet that says what you mean (`?businessFunctions=`, `?industries=`, `?solutionTypes=`), and send several values of one facet as one comma-separated value.

‎packages/cloud-connection/src/marketplace-proxy-browse-only.test.ts‎

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@
66
* requests pass through (`next()`) so a host-supplied install route (mounted
77
* via the createObjectOSStack `extraPlugins` seam) can claim them — instead of
88
* the old 405 "install via cloud" dead-end (framework#1548).
9+
*
10+
* The catalog list door (`GET /api/v1/marketplace/packages`) filters by the
11+
* spec's taxonomy facets on the server — `businessFunctions`, `industries`,
12+
* `solutionTypes` (each one comma-separated value) and `maturity` — OR inside a
13+
* facet, AND across, and pages the filtered set. The retired `?category=`
14+
* filter is refused with the spec's prescription, and so is a facet the
15+
* catalog cannot answer, before either path (public snapshot or cloud) is asked.
916
*/
1017

11-
import { describe, it, expect } from 'vitest';
18+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19+
import { retiredCategoryGuidance } from '@objectstack/spec/marketplace';
1220
import { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js';
1321

1422
/** Drive the plugin's kernel:ready wiring and capture the registered handler. */
@@ -78,3 +86,180 @@ describe('MarketplaceProxyPlugin — browse-only (no install dead-end)', () => {
7886
expect(result.__body?.error?.code).toBe('MARKETPLACE_UNAVAILABLE');
7987
});
8088
});
89+
90+
// ── The catalog list door: facet filters on the server ──────────────────────
91+
92+
const PUBLIC_BASE = 'https://public.test';
93+
const CLOUD = 'http://cloud.test';
94+
const LIST = '/api/v1/marketplace/packages';
95+
96+
const row = (id: string, extra: Record<string, unknown> = {}) => ({
97+
id,
98+
manifest_id: `com.test.${id}`,
99+
display_name: id.toUpperCase(),
100+
latest_version: null,
101+
...extra,
102+
});
103+
104+
/** A catalog snapshot: three taxonomy rows, one row from before the taxonomy. */
105+
const ROWS = [
106+
row('crm', {
107+
business_functions: ['sales', 'marketing'], industries: ['retail'],
108+
solution_type: 'app', maturity: 'stable', category: 'crm',
109+
}),
110+
row('helpdesk', {
111+
business_functions: ['customer_service'], industries: ['retail', 'logistics'],
112+
solution_type: 'template', maturity: 'beta', category: 'crm',
113+
}),
114+
row('ledger', {
115+
business_functions: ['finance', 'sales'], industries: [],
116+
solution_type: 'connector', category: 'finance',
117+
}),
118+
row('legacy', { category: 'crm' }),
119+
];
120+
121+
let fetchCalls: string[];
122+
123+
beforeEach(() => {
124+
fetchCalls = [];
125+
vi.stubGlobal('fetch', vi.fn(async (input: any) => {
126+
const url = String(input);
127+
fetchCalls.push(url);
128+
if (url === `${PUBLIC_BASE}/packages.json`) {
129+
return new Response(JSON.stringify({
130+
success: true,
131+
data: { items: ROWS, total: ROWS.length, limit: 100, offset: 0 },
132+
}), { status: 200, headers: { 'content-type': 'application/json' } });
133+
}
134+
if (url.startsWith(CLOUD)) {
135+
return new Response(JSON.stringify({ success: true, data: { items: [], total: 0 } }), {
136+
status: 200, headers: { 'content-type': 'application/json' },
137+
});
138+
}
139+
return new Response('not found', { status: 404 });
140+
}));
141+
});
142+
143+
afterEach(() => {
144+
vi.unstubAllGlobals();
145+
});
146+
147+
async function list(query: string, opts: { publicBase?: string } = { publicBase: PUBLIC_BASE }) {
148+
const plugin = new MarketplaceProxyPlugin({
149+
controlPlaneUrl: CLOUD,
150+
cacheDisabled: true,
151+
publicMarketplaceBaseUrl: opts.publicBase ?? '',
152+
});
153+
const handler = await captureHandler(plugin);
154+
return handler(fakeCtx('GET', `${LIST}${query}`), async () => 'NEXT');
155+
}
156+
157+
async function listIds(query: string): Promise<{ ids: string[]; total: number; limit: number; offset: number }> {
158+
const res: Response = await list(query);
159+
expect(res.status).toBe(200);
160+
const body: any = await res.json();
161+
return {
162+
ids: body.data.items.map((r: any) => r.id),
163+
total: body.data.total,
164+
limit: body.data.limit,
165+
offset: body.data.offset,
166+
};
167+
}
168+
169+
describe('catalog list — facet filters on the public snapshot', () => {
170+
it('ORs the values inside one facet', async () => {
171+
const { ids, total } = await listIds('?businessFunctions=marketing,finance');
172+
expect(ids).toEqual(['crm', 'ledger']);
173+
expect(total).toBe(2);
174+
});
175+
176+
it('ANDs across facets', async () => {
177+
const { ids } = await listIds('?businessFunctions=sales,customer_service&industries=logistics');
178+
expect(ids).toEqual(['helpdesk']);
179+
});
180+
181+
it('reads a row with no solution type as the spec default (app) and no maturity as stable', async () => {
182+
expect((await listIds('?solutionTypes=app')).ids).toEqual(['crm', 'legacy']);
183+
expect((await listIds('?solutionTypes=template,connector')).ids).toEqual(['helpdesk', 'ledger']);
184+
expect((await listIds('?maturity=beta')).ids).toEqual(['helpdesk']);
185+
expect((await listIds('?maturity=stable')).ids).toEqual(['crm', 'ledger', 'legacy']);
186+
});
187+
188+
it('a row without the taxonomy columns passes no business-function or industry filter', async () => {
189+
expect((await listIds('?businessFunctions=sales')).ids).toEqual(['crm', 'ledger']);
190+
expect((await listIds('?industries=retail')).ids).toEqual(['crm', 'helpdesk']);
191+
});
192+
193+
it('folds repeats and trims values', async () => {
194+
expect((await listIds('?businessFunctions=%20sales%20,sales,')).ids).toEqual(['crm', 'ledger']);
195+
});
196+
197+
it('combines with q and pages the filtered set on the server', async () => {
198+
const page = await listIds('?businessFunctions=sales,customer_service,finance&limit=2&offset=1');
199+
expect(page).toEqual({ ids: ['helpdesk', 'ledger'], total: 3, limit: 2, offset: 1 });
200+
expect((await listIds('?q=help&industries=retail')).ids).toEqual(['helpdesk']);
201+
});
202+
});
203+
204+
describe('catalog list — the retired ?category= filter', () => {
205+
it('is refused with the spec prescription, before either path is asked', async () => {
206+
const res: any = await list('?category=crm');
207+
expect(res.__status).toBe(400);
208+
expect(res.__body?.success).toBe(false);
209+
expect(res.__body?.error?.code).toBe('VALIDATION_ERROR');
210+
expect(res.__body?.error?.message).toContain(retiredCategoryGuidance('MarketplaceSearchRequest.category'));
211+
expect(fetchCalls).toEqual([]);
212+
});
213+
214+
it('is refused beside a filter the door reads, and on the cloud-only configuration', async () => {
215+
const beside: any = await list('?category=finance&solutionTypes=app,template');
216+
expect(beside.__status).toBe(400);
217+
expect(beside.__body?.error?.code).toBe('VALIDATION_ERROR');
218+
const cloudOnly: any = await list('?category=', { publicBase: '' });
219+
expect(cloudOnly.__status).toBe(400);
220+
expect(cloudOnly.__body?.error?.code).toBe('VALIDATION_ERROR');
221+
expect(fetchCalls).toEqual([]);
222+
});
223+
});
224+
225+
describe('catalog list — refused facets', () => {
226+
const refusal = async (query: string, opts?: { publicBase?: string }) => {
227+
const res: any = await list(query, opts);
228+
expect(res.__status).toBe(400);
229+
expect(res.__body?.success).toBe(false);
230+
expect(res.__body?.error?.code).toBe('VALIDATION_ERROR');
231+
return String(res.__body?.error?.message);
232+
};
233+
234+
it('refuses a value the spec does not declare for the facet, naming it', async () => {
235+
expect(await refusal('?businessFunctions=sales,crm')).toContain('"crm"');
236+
await refusal('?industries=Retail');
237+
await refusal('?solutionTypes=theme');
238+
await refusal('?maturity=preview');
239+
expect(fetchCalls).toEqual([]);
240+
});
241+
242+
it('refuses a facet supplied twice instead of choosing one occurrence', async () => {
243+
expect(await refusal('?businessFunctions=sales&businessFunctions=marketing')).toContain('2 times');
244+
await refusal('?maturity=beta&maturity=stable');
245+
});
246+
247+
it('refuses a facet that names no value', async () => {
248+
await refusal('?industries=');
249+
await refusal('?solutionTypes=,');
250+
});
251+
252+
it('refuses the same way when only the cloud path is configured', async () => {
253+
await refusal('?businessFunctions=crm', { publicBase: '' });
254+
expect(fetchCalls).toEqual([]);
255+
});
256+
});
257+
258+
describe('catalog list — cloud path', () => {
259+
it('forwards the facet query to the cloud verbatim', async () => {
260+
const query = '?businessFunctions=sales,finance&solutionTypes=template&maturity=beta&limit=20';
261+
const res: Response = await list(query, { publicBase: '' });
262+
expect(res.status).toBe(200);
263+
expect(fetchCalls).toEqual([`${CLOUD}${LIST}${query}`]);
264+
});
265+
});

0 commit comments

Comments
 (0)