@@ -151,6 +151,9 @@ import { planUnmanagedImport, unmanagedInstallPostureRefusal } from './package-u
151151import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api';
152152import type { ApiError, BatchOperationResult } from '@objectstack/spec/api';
153153import { 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';
154157import {
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
0 commit comments