Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/27514-diagnostics-unknown-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/rest': patch
'@objectstack/metadata-protocol': patch
---

fix(rest): `GET /meta/diagnostics` refuses an unknown type, severity or package instead of answering an all-clear, and keys an alias's stats by its canonical type (#27514)

Clause-②: no (narrowing — owes one contract-review-tier review before the queue)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) The narrowed face is a REQUEST to one read door: query values that were never honoured (an unknown type, severity or package) are now refused instead of answered with an empty sweep, and an alias's stats move to the canonical key the sweep already listed it under. No stored metadata, package source or object definition holds a diagnostics request or response, no schema key or export changes, and the remedy is the caller's own query string, so there is nothing for `objectstack migrate meta` to rewrite. -->

`/meta/diagnostics` is the door an author, an agent or CI reads to learn whether metadata is healthy, and three query values it could not honour were answered `200` with zero issues.

- **`?type=`** naming no metadata type (`zzz_unknown`) is refused with `400 INVALID_REQUEST`, by the same verdict `GET /meta/:type` uses: the platform's static type spelling, then the types this deployment carries live. A near-miss spelling of a declared type (`viewes`) keeps its existing refusal, which names the spellings it was reaching for.
- **`?severity=`** other than `error` or `warning` is refused with `400 INVALID_REQUEST`. An empty value still means the default, `error`.
- **`?package=`** naming a package that is not installed, and that no swept item is bound to, is refused with `400 INVALID_REQUEST`. An installed package with no items still answers its zeros, items bound to an uninstalled package are still swept, and `package=all` still sweeps every package.
- **An alias** (`?type=views`) is swept and keyed in `stats` and `entries` under its canonical type (`view`), so it answers exactly what `?type=view` answers.
- **One envelope.** Every refusal of this route now answers the nested `{ error: { code, message } }` the repeated-parameter refusal already used. The near-miss type refusal used to answer `{ error: '…', code }`.
52 changes: 51 additions & 1 deletion packages/metadata-protocol/src/protocol-meta-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import type { ObjectStackProtocolImplementation, ShippingPackages } from './protocol.js';
import { resolveItemLock } from './item-lock.js';
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema } from '@objectstack/spec/kernel';
import { canonicalMetaUrlType } from '@objectstack/spec/shared';
import { computeMetadataDiagnostics } from './metadata-diagnostics.js';
import type { MetadataDiagnostics } from './metadata-diagnostics.js';

Expand Down Expand Up @@ -77,8 +78,15 @@ export async function getMetaDiagnostics(
stats: Record<string, { count: number; locked: number; packages: string[] }>;
}> {
const includeWarnings = request.severity === 'warning';
// [#27514] A named type is swept, and keyed in `stats` and `entries`, under
// its CANONICAL spelling: the fold `canonicalMetaType` applies at every
// `/meta` entry point. `getMetaItems` already listed `views` as `view`, but
// the answer was keyed by the raw alias, so a consumer reading `stats.view`
// missed an alias request's counts. A spelling the fold does not map
// (`viewes`, `fieldz`) comes back unchanged, so the [#8924] refusal below
// still sees what the caller wrote.
const targetTypes = request.type
? [request.type]
? [canonicalMetaUrlType(request.type)]
: DEFAULT_METADATA_TYPE_REGISTRY
.filter((e) => getMetadataTypeSchema(e.type))
.map((e) => e.type);
Expand Down Expand Up @@ -240,6 +248,29 @@ export async function getMetaDiagnostics(
stats[t] = { count: items.length, locked: lockedCount, packages: [...pkgSet].sort() };
}

// [#27514] A package that is not installed, and that no swept item is bound
// to, scoped the sweep to nothing: every type answered `count: 0` and the
// whole answer read as a clean package. Refused instead, with the [#8924]
// refusal's status and code. Both halves are read, in this order: an item
// bound to the package is proof enough (rows of an uninstalled package are
// real, and this sweep is how an author finds them), and only an empty
// sweep asks the installed-package listing (`getMetaItems` of type
// `package`, the registry's records). An installed package with no items
// still answers its honest zeros. Fails OPEN: a listing that cannot be read
// keeps the answer, because "no such package" is an existence claim and an
// unreadable authority cannot make it.
if (request.packageId !== undefined && scannedItems === 0
&& !(await packageIsInstalled(this, request.packageId))) {
const err = new Error(
`'${request.packageId}' is not an installed package, and no metadata item is bound to it, so a sweep `
+ `scoped to it would report a clean package that does not exist. GET /api/v1/packages lists the `
+ `installed packages; omit package (or pass package=all) to sweep every package.`,
);
(err as any).code = 'INVALID_REQUEST';
(err as any).status = 400;
throw err;
}

return {
entries,
total: entries.length,
Expand All @@ -248,3 +279,22 @@ export async function getMetaDiagnostics(
stats,
};
}

/**
* [#27514] Does the installed-package listing carry this id? `true` ("cannot
* disprove, so do not refuse") when the listing cannot be read at all.
*/
async function packageIsInstalled(host: Pick<MetaDiagnosticsHost, 'getMetaItems'>, packageId: string): Promise<boolean> {
let listed: unknown;
try {
listed = await host.getMetaItems({ type: 'package' });
} catch {
return true;
}
const items = (listed as { items?: unknown } | null | undefined)?.items;
if (!Array.isArray(items)) return true;
return items.some((record) => {
const r = record as { manifest?: { id?: unknown }; id?: unknown } | null | undefined;
return (r?.manifest?.id ?? r?.id) === packageId;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,9 @@ describe('[#8924] a caller error the producer classified is refused loudly, neve
const res: any = await p.getMetaDiagnostics({ type: 'fields' });
expect(res.total).toBe(0);
expect(res.scannedTypes).toBe(1);
expect(res.stats.fields).toEqual({ count: 0, locked: 0, packages: [] });
// [#27514] Keyed by the canonical type the plural folds to, not the plural.
expect(Object.keys(res.stats)).toEqual(['field']);
expect(res.stats.field).toEqual({ count: 0, locked: 0, packages: [] });
});

it('a name that is a plural of NOTHING still answers benignly — the second verdict (#8421) lives on the mint door, not here', async () => {
Expand Down
65 changes: 63 additions & 2 deletions packages/rest/src/meta-collection-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@

import { rethrowAuthzStoreUnavailable } from '@objectstack/core';
import type { GetMetaItemsRequest } from '@objectstack/spec/api';
import { truncateClientMessage } from '@objectstack/types';
import { handleRouteError, sendFieldVisibilityFault } from './error-response.js';
import * as metaReadGate from './meta-item-read-gate.js';
import { refuseRepeatedQueryParams } from './query-multiplicity.js';
Expand Down Expand Up @@ -189,6 +190,29 @@ export interface MetaCollectionRouteHost {
metaTypeSingular: (typeof RestServer)['metaTypeSingular'];
}

/**
* [#27514] A `GET {metaPath}/diagnostics` refusal of a query value the sweep
* cannot honour: `400 INVALID_REQUEST`, the status and code of the [#8924]
* near-miss type refusal it extends. Thrown, so it leaves through the route's
* one refusal writer ({@link isDiagnosticsRefusal}) with the refusals the
* list door and the sweep throw.
*/
function diagnosticsRefusal(message: string): Error {
return Object.assign(new Error(message), { code: 'INVALID_REQUEST', status: 400 });
}

/**
* [#27514] Is this a classified `400 INVALID_REQUEST` refusal of the
* diagnostics request: {@link diagnosticsRefusal}, the list door's
* unknown-type verdict, or the sweep's near-miss spelling and unknown package?
* Those are the refusals the route answers in its nested envelope; anything
* else keeps `handleRouteError`'s classification.
*/
function isDiagnosticsRefusal(error: unknown): error is Error {
const e = error as { status?: unknown; code?: unknown } | null | undefined;
return error instanceof Error && e?.status === 400 && e?.code === 'INVALID_REQUEST';
}

/**
* Register the `/meta` type list, the whole-store maintenance family and the
* per-type list — the first three groups of
Expand Down Expand Up @@ -291,8 +315,19 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop
// value each; the `as string` casts are exactly the
// laundering that kept `tsc` silent about the array arm.
if (refuseRepeatedQueryParams(req, res, ['severity', 'type', 'package'])) return;
const severityParam = (req.query?.severity as string | undefined) ?? 'error';
const severity = severityParam === 'warning' ? 'warning' : 'error';
// [#27514] The sweep reports one of two severities, and any
// other value used to be read as `error` and answered 200:
// a caller asking for something this sweep does not have
// got the error sweep with nothing saying so. An empty value
// states nothing and keeps the default, as an empty `type`
// keeps the full sweep.
const severity = (req.query?.severity as string | undefined) || 'error';
if (severity !== 'error' && severity !== 'warning') {
throw diagnosticsRefusal(
`'${severity}' is not a diagnostics severity. Use 'error' (the default: the entries that `
+ `fail validation) or 'warning' (those, and the entries that carry warnings).`,
);
}
const diagnosticsType = (req.query?.type as string | undefined) || undefined;
// [ADR-0131 D6] No organization: the sweep reads each
// type environment → code, the partition every `/meta`
Expand All @@ -305,8 +340,34 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop
// each swept type is a list read, so `all` names no package.
packageId: metaItemPackageBinding(req.query?.package),
});
// [#27514] A named type that is not a metadata type at all
// was swept as one empty type: `200 {scannedTypes: 1}` with
// zero issues, an all-clear over nothing. Refused by the
// list door's own verdict ({@link refuseUnknownMetaListType}:
// the static spelling contract, then the live type set), so
// the two doors cannot disagree about which names are types.
// Asked AFTER the sweep on purpose: the sweep's [#8924]
// refusal of a near-miss spelling (`viewes`) names the
// spellings the caller was reaching for, and it keeps
// answering first.
if (diagnosticsType !== undefined) await this.refuseUnknownMetaListType(p, diagnosticsType);
res.json(result);
} catch (error: any) {
// [#27514] ONE envelope for every refusal this route
// answers. The repeated-parameter refusal above writes the
// ADR-0112 nested `{ error: { code, message } }`, and the
// classified 400s that are THROWN (the severity check above,
// the list door's unknown-type verdict, and the sweep's
// [#8924] near-miss spelling and unknown package) used to
// leave through `handleRouteError` as `{ error: '…', code }`,
// so a client reading `body.error.code` found it on one
// refusal and `undefined` on the next.
if (isDiagnosticsRefusal(error)) {
res.status(400).json({
error: { code: 'INVALID_REQUEST', message: truncateClientMessage(error.message) },
});
return;
}
handleRouteError(res, error);
}
},
Expand Down
Loading
Loading