Skip to content

Commit 7e62280

Browse files
committed
metadata-protocol: getMetaItem answers the app read keyed by package id
ADR-0048 §3.1, as GetMetaItemRequestSchema.name declares: a type-app key in the package-id grammar resolves to the package's one app (the package-scoped list's membership), read by its own name in that package. No app, or more than one, reads the key as asked and answers absence; a packageId naming another package is refused 400 INVALID_REQUEST. Both transports' item reads reach it through the protocol with no code of their own. rest: fetchCurrentMetaDocument keeps /history, /audit and /diff by name, so the arm is the plain read's alone. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ee85a9 commit 7e62280

2 files changed

Lines changed: 113 additions & 0 deletions

File tree

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ import { planUnmanagedImport, unmanagedInstallPostureRefusal } from './package-u
151151
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api';
152152
import type { ApiError, BatchOperationResult } from '@objectstack/spec/api';
153153
import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
154+
// [#23183] The app item read's package-id key — the one spelling of the arm
155+
// `GetMetaItemRequestSchema.name` declares ({@link resolveAppPackageIdKey}).
156+
import { isAppPackageIdKey } from '@objectstack/spec/api';
154157
import {
155158
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf,
156159
AggregationFunction, DateGranularity, resolveSearchFieldResolution,
@@ -10625,13 +10628,110 @@ export class ObjectStackProtocolImplementation implements
1062510628
}, options);
1062610629
}
1062710630

10631+
/**
10632+
* [#23183] The packages {@link resolveAppPackageIdKey} has reported as
10633+
* holding more than one app, keyed by package and app set, so the operator
10634+
* is told once per state rather than once per read.
10635+
*/
10636+
private readonly reportedMultiAppPackages = new Set<string>();
10637+
10638+
/**
10639+
* [#23183] ADR-0048 §3.1: "The route/container coordinate for an installed
10640+
* package's UI is its package id … With the current one-app-per-package
10641+
* invariant, the app *is* the package container." So the app item read
10642+
* answers for a package id as it answers for the app's own name, as
10643+
* `GetMetaItemRequestSchema.name` declares. This is the one place the key is
10644+
* resolved: both transports' item reads hand {@link getMetaItem} the key
10645+
* they were asked for, and neither resolves it itself.
10646+
*
10647+
* Recognised by `isAppPackageIdKey` (`@objectstack/spec/api`): `type: 'app'`
10648+
* and a key in the package-id grammar, which no app name is in. Every other
10649+
* request is returned untouched, so every by-name read of every type is
10650+
* unchanged.
10651+
*
10652+
* "The package's app" is what the package-scoped list serves
10653+
* ({@link getMetaItems} with `packageId`, the read behind
10654+
* `GET /meta/app?package=`): the apps the package ships, its stored rows
10655+
* included, none for a disabled package. Its drafts are in view exactly
10656+
* when this read reads drafts (`state: 'draft'` or `previewDrafts`), so the
10657+
* key resolves to the app the read itself would serve.
10658+
*
10659+
* - ONE app: the request becomes that app's by-name read, scoped to the
10660+
* package (`packageId`, ADR-0048 prefer-local). The envelope names the
10661+
* app by its own `name`, the address a save of it uses and the item its
10662+
* `version` token belongs to.
10663+
* - NONE, or MORE THAN ONE (a package breaking ADR-0019 D3's at-most-one
10664+
* rule, which the save door does not enforce): the request is returned
10665+
* as asked, and its by-name read answers absence, because no legal app
10666+
* name is in the package-id grammar. Absence, and no answer of its own:
10667+
* the doors' read gate answers an unpublished app as absent (ADR-0045
10668+
* §3), and a separate "two apps" answer would let a caller see an app
10669+
* being built in the package before it is published. The operator is
10670+
* told instead, in the log.
10671+
*
10672+
* A `packageId` naming a different package is refused `400 INVALID_REQUEST`:
10673+
* the key already names the package, and a prefer-local scope from a
10674+
* second package would read that package's same-name app, not this one's.
10675+
*
10676+
* A failed list read propagates: a store outage is never answered as "no
10677+
* such package" (#5532).
10678+
*/
10679+
private async resolveAppPackageIdKey<R extends {
10680+
type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean,
10681+
}>(request: R): Promise<R> {
10682+
if (!isAppPackageIdKey(request.type, request.name)) return request;
10683+
const packageId = request.name;
10684+
if (request.packageId !== undefined && request.packageId !== packageId) {
10685+
const err: any = new Error(
10686+
`The app read's key '${packageId}' is a package id, and it names a different package `
10687+
+ `than packageId '${request.packageId}'. Name the package once: read the app by its `
10688+
+ `package id alone, or by its own app name with packageId as its scope.`,
10689+
);
10690+
err.code = 'INVALID_REQUEST';
10691+
err.status = 400;
10692+
throw err;
10693+
}
10694+
const readsDrafts = request.state === 'draft' || request.previewDrafts === true;
10695+
const listed = await this.getMetaItems({
10696+
type: 'app',
10697+
packageId,
10698+
...(readsDrafts ? { previewDrafts: true } : {}),
10699+
});
10700+
const names = [...new Set(
10701+
((listed as { items?: unknown[] } | undefined)?.items ?? [])
10702+
.map((app) => (app as { name?: unknown } | null)?.name)
10703+
.filter((name): name is string => typeof name === 'string' && name.length > 0),
10704+
)];
10705+
if (names.length === 1) return { ...request, name: names[0], packageId };
10706+
if (names.length > 1) {
10707+
const key = `${packageId}|${[...names].sort().join(',')}`;
10708+
if (!this.reportedMultiAppPackages.has(key)) {
10709+
this.reportedMultiAppPackages.add(key);
10710+
console.warn(
10711+
`[metadata-protocol] Package '${packageId}' holds ${names.length} apps `
10712+
+ `(${[...names].sort().join(', ')}), so reading the app by its package id answers `
10713+
+ `"not found": with one app per package the app is the package container `
10714+
+ `(ADR-0048 §3.1), and a package may define at most one app (ADR-0019 D3). `
10715+
+ `Fold them into one app, or move all but one into packages of their own.`,
10716+
);
10717+
}
10718+
}
10719+
return request;
10720+
}
10721+
1062810722
async getMetaItem(request: {
1062910723
type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean,
1063010724
/** [#22877] The bundle member to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */
1063110725
member?: string,
1063210726
}) {
1063310727
// #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}.
1063410728
request = canonicalizeMetaRequestType(request);
10729+
// [#23183] ADR-0048 §3.1: the app read keyed by PACKAGE ID, resolved
10730+
// ONCE, here, to the package's one app read by its own name in that
10731+
// package, so every step below (the stored row, the lock, the version
10732+
// token) reads the app exactly as its by-name read does. See
10733+
// {@link resolveAppPackageIdKey}.
10734+
request = await this.resolveAppPackageIdKey(request);
1063510735
// [#22877] Ruling 6116165299 (Q2 i): ONE member of a bundled type is
1063610736
// addressed by name, the selector's or else the canonical one. Every
1063710737
// layer below answers that member or nothing — the stored row, the

‎packages/rest/src/rest-server.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ import {
133133
HistoryMetaItemRequestSchema,
134134
AuditMetaItemRequestSchema,
135135
} from '@objectstack/spec/api';
136+
// [#23183] The app item read's package-id key, spelled once in the spec — see
137+
// `fetchCurrentMetaDocument`, whose doors stay by-name.
138+
import { isAppPackageIdKey } from '@objectstack/spec/api';
136139
// [commit 2a29caa53] Declared request shapes for the meta-read doors below — imported so
137140
// each door's request literal is compiled against the spec contract instead of
138141
// being smuggled past it with `as any` (see `TransportScopedMetaRequest`).
@@ -3505,11 +3508,21 @@ export class RestServer {
35053508
* serves no document of its own (`/history` and `/audit` serve events,
35063509
* `/diff` a comparison) and so judges the item the plain read would judge.
35073510
* `undefined` when nothing is behind the name.
3511+
*
3512+
* [#23183] …by NAME. These doors read their own data (events, the diff)
3513+
* under the literal `:name`, so the document they judge is that name's.
3514+
* The app item read's package-id key (ADR-0048 §3.1,
3515+
* `GetMetaItemRequestSchema.name`) is the plain read's arm, not theirs: the
3516+
* protocol would answer it with the package's app under the APP's name,
3517+
* which is not the item these doors read. Such a key is answered as no
3518+
* current document, exactly as it was before the arm existed: no app name
3519+
* is in the package-id grammar, so nothing is stored under it.
35083520
*/
35093521
private async fetchCurrentMetaDocument(
35103522
req: any,
35113523
p: RestProtocol,
35123524
): Promise<any | undefined> {
3525+
if (isAppPackageIdKey(RestServer.metaTypeSingular(req.params.type), req.params.name)) return undefined;
35133526
const currentRequest: GetMetaItemRequest = {
35143527
type: req.params.type,
35153528
name: req.params.name,

0 commit comments

Comments
 (0)