diff --git a/.changeset/29613-sealed-pair-override-row-served.md b/.changeset/29613-sealed-pair-override-row-served.md new file mode 100644 index 00000000000..50894842c24 --- /dev/null +++ b/.changeset/29613-sealed-pair-override-row-served.md @@ -0,0 +1,22 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/plugin-hono-server': patch +--- + +fix(metadata-protocol): a `.custom` pair's published override of a page, app, dataset, doc or book is served from its stored row by every read, and no longer sits in the registry under the base's envelope + +Clause-②: no +Metadata-protocol: none + +Pages, apps, datasets, docs and books take no environment overlay, so every read declined their stored rows as overlays of a package's item. That included the override a base's `.custom` pair publishes under ADR-0146. The override was served only from a copy the registry kept under the item's bare name, wearing the base's envelope. On a server with a metadata service (every real boot), the by-name read answered the base's shipped item instead, while the lists answered the override. + +The reads now serve the pair's declared override from its row, as they already did for dashboards and reports. Every other stored row of such a name is still not served. The registry no longer keeps the copy under the base's envelope. What moves, for each of the five types, all toward ADR-0146 D8: + +- `GET /meta/TYPE/NAME` with no package, the base or the pair: the override on every server (item 2). Before, it served the base's shipped item whenever a metadata service was present. +- The layered read naming the base, `effective`: the override (item 2). Before, the base's shipped item. +- The per-package layer naming the pair: the override (item 3). Before, empty. So the pair's export (`GET /packages/PAIR/export`) now carries the override with its `overrides` block, as it does for a dashboard. +- The per-package layer naming the base: the base's own item only (item 3). Before, it also listed the override, or listed only the override when a metadata service was present. +- `GET /me/apps` (`@objectstack/plugin-hono-server`): it now lists the protocol's served apps (what `GET /meta/app` serves) when the protocol service is present, so the pair's override of an app is listed. With no protocol service it reads the registry as before. +- A registry read that names the item directly answers the base's own item. + +Reads that already served the override still do: the effective list (no package, the base). Nothing to migrate: a restart re-hydrates the registry under the new rule. diff --git a/packages/metadata-protocol/src/protocol-lock-state.ts b/packages/metadata-protocol/src/protocol-lock-state.ts index 57797410d47..421ddb1e6d2 100644 --- a/packages/metadata-protocol/src/protocol-lock-state.ts +++ b/packages/metadata-protocol/src/protocol-lock-state.ts @@ -50,6 +50,7 @@ import { canonicalMetaType } from './protocol.js'; import type { ItemLock } from './item-lock.js'; import { isOriginGatedType } from './packaged-base-regime.js'; import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js'; +import { servesSealedOverrideRow } from './protocol-package-bindings.js'; import { isCodeArtifactBody } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { @@ -147,7 +148,15 @@ export function declinesStoredRow( return !isServedOverrideRowOf(row, this.packagedArtifactOwner({ type: 'flow', name: name as string })); } if (typeof name !== 'string' || name === '') return false; - return this.isDeclaredCodeDatasource(type, name) || this.isUnservedSealedOverlay(type, name); + if (this.isDeclaredCodeDatasource(type, name)) return true; + if (!this.isUnservedSealedOverlay(type, name)) return false; + // [#29613, ADR-0146 D8 item 2] …except, for a page, app, dataset, doc or + // book, the shipping package's declared override: judged as the flow's + // above, on the row's own `package_id` and body, against the package + // that ships the name. The reads then serve it from its row, as they + // serve a dashboard's. Every other sealed row of the name stays declined. + return !(servesSealedOverrideRow(type) + && isServedOverrideRowOf(row, this.packagedArtifactOwner({ type, name }))); } /** The body of `ObjectStackProtocolImplementation#isUnservedSealedOverlay`; the method's docblock there is its contract. */ diff --git a/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts b/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts new file mode 100644 index 00000000000..4db880c19f1 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29613] The two closed type lists the row-served override path rests on: + * the sealed types whose declared `.custom` override the reads serve from its + * row ({@link servesSealedOverrideRow}), and the types whose such row stays out + * of the registry's bare slot ({@link declinesBorrowedEnvelope}). Every type of + * the first is in the second, so no override loses its bare entry before its + * row is served, and none keeps a bare entry wearing the base's envelope after. + * + * The readings themselves (every reader, per type, with and without a + * metadata service) are pinned in `@objectstack/objectql`'s + * `protocol-custom-override-sealed-row-served.test.ts`, over the real registry. + */ +import { describe, expect, it } from 'vitest'; +import { declinesBorrowedEnvelope, servesSealedOverrideRow } from './protocol-package-bindings.js'; + +const ROW_SERVED = ['page', 'app', 'dataset', 'doc', 'book'] as const; +/** The base's code artifact the hydrator's lookup falls back to for a pair's row. */ +const BASE_ARTIFACT = { _packageId: 'com.example.showcase' }; + +describe('[#29613] the sealed row-served override types', () => { + it.each(ROW_SERVED)('%s: its declared override is served from its row, and its row takes no borrowed envelope', (type) => { + expect(servesSealedOverrideRow(type)).toBe(true); + expect(declinesBorrowedEnvelope(type, 'com.example.showcase.custom', BASE_ARTIFACT)).toBe(true); + }); + + it('folds a plural spelling to the canonical type', () => { + expect(servesSealedOverrideRow('pages')).toBe(true); + expect(servesSealedOverrideRow('apps')).toBe(true); + }); + + it.each(['mapping', 'action', 'flow', 'object', 'permission', 'view', 'dashboard', 'report', 'email_template'])( + '%s: not a sealed row-served override type', + (type) => { + expect(servesSealedOverrideRow(type)).toBe(false); + }, + ); + + it.each(ROW_SERVED)('%s: the #4624 case keeps its bare entry (a row of the package that ships the artifact)', (type) => { + expect(declinesBorrowedEnvelope(type, 'com.example.showcase', BASE_ARTIFACT)).toBe(false); + expect(declinesBorrowedEnvelope(type, null, BASE_ARTIFACT)).toBe(false); + }); +}); diff --git a/packages/metadata-protocol/src/protocol-package-bindings.ts b/packages/metadata-protocol/src/protocol-package-bindings.ts index abf747635a7..4b9fe938e69 100644 --- a/packages/metadata-protocol/src/protocol-package-bindings.ts +++ b/packages/metadata-protocol/src/protocol-package-bindings.ts @@ -306,14 +306,53 @@ function boundPackageOf(packageId: unknown): string | undefined { * no reader the row does not. `view` is not here: it has its own, wider rule * (#22057, `anotherPackageShips`). * - * ⛔ A closed list, judged on the canonical type. `page`, `app`, `dataset`, - * `doc` and `book` are left out on purpose: their reads serve a `.custom` - * pair's override FROM the bare entry (the by-name read, the list, and - * `registry.getAllApps`), so they need a row-served path before their bare - * entry can go. `flow` registers with no artifact envelope at all (Regime C, + * ⛔ A closed list, judged on the canonical type. [#29613] It includes the + * five {@link SEALED_ROW_SERVED_OVERRIDE_TYPES} since their reads serve a + * `.custom` pair's declared override from its row, as these three types' + * reads do. `flow` registers with no artifact envelope at all (Regime C, * #20913), and the boot pull arms its declared override from the bare slot. */ -const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet = new Set(['dashboard', 'report', 'email_template']); +const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet = new Set([ + 'dashboard', + 'report', + 'email_template', + // [#29613] The SEALED_ROW_SERVED_OVERRIDE_TYPES, spelled out (`protocol-package-bindings.sealed-override.test.ts` pins both lists). + 'page', + 'app', + 'dataset', + 'doc', + 'book', +]); + +/** + * [#29613, ADR-0146 D8 item 2] The SEALED types (ADR-0131 D6: a registry + * entry with `allowEnvironmentOverlay: false`) whose stored row the reads + * serve when it is the shipping package's declared `.custom` override + * ({@link isServedOverrideRowOf}): `page`, `app`, `dataset`, `doc` and + * `book`, the overridable types of ADR-0146 D2 that open no environment + * overlay. Every other stored row of a name a managed package ships stays + * unserved for them ({@link isUnservedSealedOverlay}). + * + * Before, the reads declined the override's row with the rest, and the + * override was served only from the registry's bare slot, where the hydrator + * registered it wearing the base's envelope. With a metadata service (every + * real boot) the by-name read answered the service's copy of the base's body + * instead, while the lists answered the override, and the layered read's + * effective layer and the pair's per-package layer never saw it. + * + * ⛔ A closed list, judged on the canonical type. `mapping` is overridable and + * sealed too, and is not here: no card has measured its reads. `action` and + * `flow` wait for ADR-0146 D9's conditions (`flow` has its own admission, + * `declinesStoredRow`'s first branch). Every type here is also in + * {@link BORROWED_ENVELOPE_DECLINED_TYPES}: the bare entry served no reader + * the row does not serve now. + */ +const SEALED_ROW_SERVED_OVERRIDE_TYPES: ReadonlySet = new Set(['page', 'app', 'dataset', 'doc', 'book']); + +/** [#29613] Whether `type` is one of {@link SEALED_ROW_SERVED_OVERRIDE_TYPES}, folded to the canonical singular. */ +function servesSealedOverrideRow(type: string): boolean { + return SEALED_ROW_SERVED_OVERRIDE_TYPES.has(canonicalMetaType(type)); +} /** * [#29600] Whether the hydrator keeps a stored row of `canonicalType`, bound @@ -462,6 +501,7 @@ export { isShippedViewItem, mergeArtifactProtection, perPackageLayerEnvelope, + servesSealedOverrideRow, stateTenantAuthorship, stripDerivedProvenance, }; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index acae72bf506..48efa7122b2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8761,7 +8761,10 @@ export class ObjectStackProtocolImplementation implements * regime D6 recognises — the package definition wins. Such rows were * written through the `OS_METADATA_WRITABLE` hatch before the seal; they * stay at rest, untouched, and the boot report names them per type with - * the two remedies ({@link reportSealedOverlayRows}). + * the two remedies ({@link reportSealedOverlayRows}). [#29613] Except, + * for a `page`, `app`, `dataset`, `doc` or `book`, the shipping + * package's declared `.custom` override (`row`, below): ADR-0146 D8 + * item 2 serves it, as a dashboard's. * * ⛔ Never a row's, slot's or body's `origin` — the caller sets it. * @@ -10848,11 +10851,11 @@ export class ObjectStackProtocolImplementation implements * after, measured for each reader. What changes is the registry-direct * read of the name, which answers the shipping package's own item. * - * ⛔ Those three types only. A row of a package that ships its own artifact - * of the name keeps its bare entry (#4624). `page`, `app`, `dataset`, `doc` - * and `book` still register: their reads serve a pair's override from the - * bare entry, so it can go only once they serve it from the row. `flow` - * registers with no artifact envelope (below). + * ⛔ Those three types, and [#29613] `page`, `app`, `dataset`, `doc` and + * `book`, whose reads serve a pair's declared override from its row since + * {@link declinesStoredRow} admits it. A row of a package that ships its own + * artifact of the name keeps its bare entry (#4624). `flow` registers with + * no artifact envelope (below). * * ## [#23233] The entry carries the row's package binding * @@ -11012,7 +11015,7 @@ export class ObjectStackProtocolImplementation implements // bound to a package that ships no artifact of its name, under a name // another package ships, is not registered under the bare name, where // it would wear that other package's envelope. Its reads answer it from - // its row. ⛔ Those three types only. + // its row. ⛔ Those three types, and [#29613] page, app, dataset, doc and book. if (declinesBorrowedEnvelope(canonicalType, options.packageId, artifact)) return true; // [#16702] Say what this row IS before the artifact envelope is grafted // on top of it. Every body reaching this hydrator came out of a diff --git a/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts b/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts index 48986a84724..852f435a184 100644 --- a/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts +++ b/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts @@ -35,6 +35,40 @@ * * Test-path resolution: the protocol is `@objectstack/metadata-protocol`'s * `dist/`, so a change there is read here after that package is rebuilt. + * + * ## [#29613] The second describe: page, app, dataset, doc and book + * + * A `.custom` pair's published override of a base's + * page, app, dataset, doc or book is served from its ROW, as a dashboard's + * is, and no longer from a copy in the registry's bare slot wearing the + * base's envelope. + * + * These five types are SEALED (ADR-0131 D6: `allowEnvironmentOverlay: + * false`), so every read declined their stored row as an overlay of managed + * content (`declinesStoredRow`), the pair's declared override with the rest. + * The override was served only from the bare slot, where the hydrator + * registered it under the base's envelope. Measured on main 744211fc, the + * #29600 harness run without and with a metadata service (S = shipped, + * O = the override): + * + * - by-name (no package, BASE, PAIR): O from the bare slot without a + * metadata service; S with one (a real boot), the service's copy of the + * base's body answering before the registry; + * - the effective list (no package, BASE): O, from the bare slot; + * - the layered read's effective layer naming BASE: S; + * - the per-package layer naming PAIR: []; naming BASE: [S, O] without a + * metadata service, [O] with one; + * - `registry.getAllApps` (the app): [S, O]. + * + * Pinned here, per type, with and without a metadata service, at once and + * after a restart: the served reads all answer O (ADR-0146 D8 item 2), the + * per-package layer lists S under BASE and O under PAIR (D8 item 3), and the + * registry's bare slot holds no copy of the override, so its direct reads + * (and `registry.getAllApps`) answer the base's own item. CONTROL: a sealed + * row that is not the pair's declared override (a package-less environment + * row of the base's name) stays unserved. Its boot is + * `environment-overlay-email-locale-restart.test.ts`'s: the hydration + * `ObjectQLPlugin.start()` runs, then the pair. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ServiceObject } from '@objectstack/spec/data'; @@ -49,6 +83,10 @@ const OTHER = 'com.example.other'; const DECLARATION = { package: BASE, basedOn: '1.0.0' }; const SHIPPED = 'Shipped label'; const EDITED = 'Edited label'; +/** [#29613] A sealed row that is not the pair's declared override. */ +const RESIDUE = 'Residue label'; +/** Unique across boots: a restart is a new driver over the same stores. */ +let nextId = 0; const sysMetadataObject: ServiceObject = { name: 'sys_metadata', @@ -70,14 +108,13 @@ const sysMetadataObject: ServiceObject = { }, }; -function makeMemoryDriver() { - const stores = new Map>>(); +/** [#29613] `stores` shared by two boots = a restart over the same database. */ +function makeMemoryDriver(stores: Map>> = new Map()) { const storeFor = (obj: string) => { let s = stores.get(obj); if (!s) { s = new Map(); stores.set(obj, s); } return s; }; - let nextId = 0; const matchesWhere = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; for (const [k, v] of Object.entries(where)) { @@ -308,3 +345,145 @@ describe('[#29600] a pair\'s published override never takes the base\'s envelope expect(bare._provenance).toBe('package'); }); }); + +const pageBody = (label: string) => ({ name: 'showcase_settings', label, type: 'app', regions: [] }); +const appBody = (label: string) => ({ name: 'showcase', label }); +const datasetBody = (label: string) => ({ + name: 'showcase_task_metrics', label, object: 'showcase_task', + dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }], + measures: [{ name: 'task_count', label: 'Tasks', aggregate: 'count' }], +}); +const docBody = (label: string) => ({ name: 'showcase_index', label, content: '# Index' }); +const bookBody = (label: string) => ({ + name: 'showcase_manual', label, groups: [{ key: 'start', label: 'Start', pages: ['showcase_index'] }], +}); + +/** Per type: its body and the manifest collection that ships it. */ +const SEALED_TYPES = [ + { type: 'page', body: pageBody, collection: 'pages' }, + { type: 'app', body: appBody, collection: 'apps' }, + { type: 'dataset', body: datasetBody, collection: 'datasets' }, + { type: 'doc', body: docBody, collection: 'docs' }, + { type: 'book', body: bookBody, collection: 'books' }, +] as const; + +function sealedBaseManifest(): Record { + const manifest: Record = { + id: BASE, name: 'showcase', version: '1.0.0', namespace: 'showcase', + objects: [{ + name: 'showcase_task', label: 'Task', sharingModel: 'private', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + status: { name: 'status', label: 'Status', type: 'text' }, + }, + }], + }; + for (const t of SEALED_TYPES) manifest[t.collection] = [t.body(SHIPPED)]; + return manifest; +} + +type Stores = Map>>; + +/** + * One boot over `stores`: engine, the base, the protocol, the hydration + * `ObjectQLPlugin.start()` runs, then the pair. Called twice over the same + * stores = a restart. `withService` registers a metadata service holding the + * code package's bodies, as every real boot's MetadataService holds them: the + * by-name read consults it before the registry. + */ +async function bootSealed(stores: Stores, withService: boolean) { + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores).driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test-package'); + } + engine.registerApp(sealedBaseManifest()); + const services = new Map(); + if (withService) { + const shipped = new Map(SEALED_TYPES.map((t) => [t.type as string, { ...t.body(SHIPPED), _packageId: BASE }])); + services.set('metadata', { + async get(type: string, name: string) { + const item = shipped.get(type); + return item && item.name === name ? item : undefined; + }, + async list(type: string) { + const item = shipped.get(type); + return item ? [item] : []; + }, + }); + } + const protocol: any = new ObjectStackProtocolImplementation(engine, () => services as any); + const load = await protocol.loadMetaFromDb(); + expect(load.errors, 'the boot hydration').toBe(0); + await protocol.ensureCustomizationPackage({ basePackageId: BASE }); + return { engine, protocol }; +} + +const byNameReading = async (protocol: any, type: string, name: string, packageId?: string): Promise => + reading((await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) }))?.item); + +/** Every reading the card names, for one type and name. */ +async function sealedReadings(engine: ObjectQL, protocol: any, type: string, name: string) { + return { + byName: [await byNameReading(protocol, type, name), await byNameReading(protocol, type, name, BASE), await byNameReading(protocol, type, name, PAIR)], + list: [await effectiveList(protocol, type, name), await effectiveList(protocol, type, name, BASE)], + layered: (await protocol.getMetaItemLayered({ type, name, packageId: BASE }))?.effective?.label, + perPackage: { + base: await perPackage(protocol, type, name, BASE), + pair: await perPackage(protocol, type, name, PAIR), + unscoped: await perPackage(protocol, type, name), + }, + bareSlot: reading((engine.registry as any).metadata.get(type)?.get(name)), + registryGetItem: reading(engine.registry.getItem(type, name)), + ...(type === 'app' ? { getAllApps: engine.registry.getAllApps().filter((a: any) => a?.name === name).map(reading) } : {}), + }; +} + +/** What every reading answers once the override is published (ADR-0146 D8 items 2 and 3). */ +const servedReadings = (type: string) => ({ + byName: [`${EDITED}/${BASE}`, `${EDITED}/${BASE}`, `${EDITED}/${BASE}`], + list: [[EDITED], [EDITED]], + layered: EDITED, + perPackage: { base: [SHIPPED], pair: [EDITED], unscoped: [EDITED, SHIPPED] }, + bareSlot: null, + registryGetItem: `${SHIPPED}/${BASE}`, + ...(type === 'app' ? { getAllApps: [`${SHIPPED}/${BASE}`] } : {}), +}); + +describe('[#29613] a pair\'s published override of a sealed type is served from its row', () => { + for (const withService of [false, true]) { + const shape = withService ? 'with a metadata service (a real boot)' : 'with no metadata service'; + for (const { type, body } of SEALED_TYPES) { + const name = body(SHIPPED).name; + + it(`${type}, ${shape}: every reader serves the override, at once and after a restart`, async () => { + const stores: Stores = new Map(); + const first = await bootSealed(stores, withService); + await publishOverride(first.protocol, type, name, body(EDITED)); + expect(await sealedReadings(first.engine, first.protocol, type, name), `${type} at once`).toEqual(servedReadings(type)); + + const restarted = await bootSealed(stores, withService); + expect(await sealedReadings(restarted.engine, restarted.protocol, type, name), `${type} after a restart`).toEqual(servedReadings(type)); + }); + } + } + + for (const { type, body } of SEALED_TYPES) { + const name = body(SHIPPED).name; + it(`CONTROL, ${type}: a package-less environment row of the base's name is not the pair's override and stays unserved`, async () => { + const stores: Stores = new Map(); + await bootSealed(stores, true); + // A row written through the hatch before the ADR-0131 D6 seal: bound to no package, no declaration. + stores.get('sys_metadata')!.set(`residue_${type}`, { + id: `residue_${type}`, type, name, organization_id: null, package_id: null, state: 'active', version: 1, + metadata: JSON.stringify(body(RESIDUE)), + }); + const { protocol } = await bootSealed(stores, true); + expect(await byNameReading(protocol, type, name), `${type} by name`).toBe(`${SHIPPED}/${BASE}`); + expect(await byNameReading(protocol, type, name, BASE), `${type} by name, the base`).toBe(`${SHIPPED}/${BASE}`); + expect((await protocol.getMetaItemLayered({ type, name, packageId: BASE }))?.effective?.label, `${type} layered`).toBe(SHIPPED); + }); + } +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts index 96cdee29815..b93d0c2602f 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts @@ -315,3 +315,108 @@ describe('[#7616] a failed resolution fails CLOSED, as the contract requires', ( expect(await appNamesOf(mount({ security: 'throws' }))).toEqual(['open']); }); }); + +// [#29613, ADR-0146 D8 item 2] `/me/apps` lists the apps the protocol SERVES +// when the protocol service is present, so a `.custom` pair's published +// override of a base's app is listed over the base's app. +// +// The override used to reach `/me/apps` only through the registry: the +// hydrator registered it in the registry's bare slot wearing the base's +// envelope, and `registry.getAllApps()` listed it there beside the base's own +// app. Since the override is served from its row (`declinesStoredRow` admits +// the pair's declared override), the bare slot holds no copy of it, and the +// registry lists the base's app alone. The protocol's served list +// (`getMetaItems`, what `GET /meta/app` serves) answers the override. +// +// Pinned both ways in the describe below: with a protocol service the +// served list is the source, and the registry is not read; with none, +// today's registry path is unchanged. +// A protocol whose list read fails is answered from the registry. + +/** The registry's list: the base's app alone (no bare-slot copy of the override). */ +const REGISTRY_APPS = [{ name: 'showcase', label: 'Shipped label', _packageId: 'com.example.showcase' }]; +/** The protocol's served list: the pair's override, served as the base's item. */ +const SERVED_APPS = [{ name: 'showcase', label: 'Edited label', _packageId: 'com.example.showcase' }]; + +function mountAppsSource(protocol: 'served' | 'throws' | 'absent' | 'no-list-read') { + let registryReads = 0; + const warnings: string[] = []; + const services: Record = { + auth: { + api: { + getSession: async () => ({ + user: { id: USER, email: 'member@example.com' }, + session: { activeOrganizationId: 'org_active' }, + }), + }, + }, + objectql: { + find: async () => [], + registry: { + getAllApps: () => { + registryReads += 1; + return REGISTRY_APPS; + }, + getAllObjects: () => [], + }, + getSchema: () => undefined, + }, + metadata: { list: async () => [] as unknown[] }, + }; + if (protocol === 'served') { + services.protocol = { getMetaItems: async ({ type }: { type: string }) => ({ type, items: type === 'app' ? SERVED_APPS : [] }) }; + } else if (protocol === 'throws') { + services.protocol = { + getMetaItems: async () => { + throw new Error('metadata store unavailable'); + }, + }; + } else if (protocol === 'no-list-read') { + services.protocol = { getDiscovery: async () => ({}) }; + } + const app = new Hono(); + registerCurrentUserEndpoints({ + rawApp: app, + ctx: { + logger: { debug() {}, warn(message: string) { warnings.push(message); } }, + getService: (name: string): T => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name] as T; + }, + }, + }); + return { app, registryReads: () => registryReads, warnings }; +} + +const labelsOf = async (app: Hono): Promise => { + const res = await app.request(`http://localhost${ME_APPS}`); + expect(res.status).toBe(200); + return ((await res.json()) as { apps: Array<{ label: string }> }).apps.map((a) => a.label); +}; + +describe('[#29613] /me/apps lists the protocol\'s served apps', () => { + it('with a protocol service: the served list, the pair\'s override over the base\'s app; the registry is not read', async () => { + const m = mountAppsSource('served'); + expect(await labelsOf(m.app)).toEqual(['Edited label']); + expect(m.registryReads()).toBe(0); + }); + + it('with no protocol service: the registry, as before', async () => { + const m = mountAppsSource('absent'); + expect(await labelsOf(m.app)).toEqual(['Shipped label']); + expect(m.registryReads()).toBe(1); + }); + + it('with a protocol service that has no list read: the registry, as before', async () => { + const m = mountAppsSource('no-list-read'); + expect(await labelsOf(m.app)).toEqual(['Shipped label']); + expect(m.registryReads()).toBe(1); + }); + + it('a protocol list read that fails: answered from the registry, and warned', async () => { + const m = mountAppsSource('throws'); + expect(await labelsOf(m.app)).toEqual(['Shipped label']); + expect(m.registryReads()).toBe(1); + expect(m.warnings.some((w) => w.includes('/me/apps'))).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index cd998afd6b3..91b6c15387f 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -336,6 +336,34 @@ type AnyQueueReader = Pick; * * It never throws. */ +/** The one protocol member `/me/apps` reads: its served list (`ObjectStackProtocolImplementation.getMetaItems`). */ +interface ServedAppLister { + getMetaItems(request: { type: string }): Promise<{ items?: unknown } | undefined>; +} + +/** + * [#29613, ADR-0146 D8 item 2] The apps the protocol SERVES — the list + * `GET /meta/app` answers, a `.custom` pair's published override over the + * base's app — or `undefined` when no protocol service is registered (or it + * has no list read), and `/me/apps` reads the registry as it always has. + * + * A protocol list read that fails is warned once per request and answered + * from the registry, the degraded source this endpoint already falls back on; + * the caller still gets the apps it may enter. + */ +async function servedAppsOf(ctx: CurrentUserEndpointsContext): Promise { + let protocol: ServedAppLister | undefined; + try { protocol = ctx.getService('protocol'); } catch { return undefined; } + if (typeof protocol?.getMetaItems !== 'function') return undefined; + try { + const answer = await protocol.getMetaItems({ type: 'app' }); + return Array.isArray(answer?.items) ? answer.items : undefined; + } catch (err: any) { + ctx.logger?.warn?.('[hono] /me/apps: the protocol app list failed; answering from the registry', { err: err?.message }); + return undefined; + } +} + async function organizationHasAnyQueue( ctx: CurrentUserEndpointsContext, organizationId: string | null, @@ -1101,7 +1129,9 @@ export function registerCurrentUserEndpoints( // AppSchema.requiredPermissions with no enforced consumer. Source // from `registry.getAllApps()` (the same authority the meta routes // use, nav contributions merged), with the metadata service kept as - // an additive fallback for runtime-draft-published apps. Filters: + // an additive fallback for runtime-draft-published apps. [#29613] With + // the protocol service present, its served list stands in for the + // registry's ({@link servedAppsOf}). Filters: // 1. AppSchema.requiredPermissions ⊆ ctx.systemPermissions // 2. ctx.tabPermissions[app.name] !== 'hidden' // Anonymous users get an empty array. When SecurityPlugin is absent @@ -1111,7 +1141,18 @@ export function registerCurrentUserEndpoints( if (!execCtx?.userId) return c.json({ apps: [] }); try { const byName = new Map(); - try { + // [#29613, ADR-0146 D8 item 2] With the protocol service present, + // the apps are the protocol's SERVED list (`getMetaItems`, what + // `GET /meta/app` serves, nav contributions merged), so a `.custom` + // pair's published override of a base's app is listed over the + // base's app. The registry's list holds the base's app alone under + // the name since the override no longer takes its bare slot. + const served = await servedAppsOf(ctx); + if (served !== undefined) { + for (const app of served) { + if ((app as { name?: unknown })?.name) byName.set(String((app as { name: unknown }).name), app); + } + } else try { // The PUBLIC accessor. This read used to reach ObjectQL's // private `_registry` while `/auth/me/permissions` read the // `registry` getter over the same field, on the same object, diff --git a/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts b/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts index 2abb61fa6e5..b0270993cb2 100644 --- a/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts +++ b/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts @@ -32,10 +32,10 @@ * item in it carries an `overrides` block; * - the same holds on a restart over the same database; * - the pair's export carries the overriding item with its `overrides` block - * (view, dashboard, report; see `pairServed`); - * - CONTROL: the pair's override row is published, and for view, dashboard - * and report the effective read of the base's name serves it, so the base - * export's answer is the export's own. + * (every type since #29613; see `pairServed`); + * - CONTROL: the pair's override row is published, and the effective read of + * the base's name serves it, so the base export's answer is the export's + * own. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -116,20 +116,21 @@ const SERVICE_COLLECTIONS = { /** * `pairServed`: whether the pair's export and the effective read serve the - * override. For the five types #29640 left in the bare slot neither does on - * this boot shape (the pair's per-package layer lists none of them, and the - * effective read serves the base's document; #29613), so only the base's half - * is pinned for them. + * override. [#29613] True for every type: page, app, dataset, doc and book + * were false while the reads declined their pair override as a sealed overlay + * (the pair's per-package layer listed none of them, and on this boot shape, + * with a `MetadataManager`, the effective read served the base's document). + * The reads now serve it from its row, as a dashboard's. */ const TYPES = [ { type: 'view', plural: 'viewItems', name: VIEW, pairServed: true }, { type: 'dashboard', plural: 'dashboards', name: DASHBOARD, pairServed: true }, { type: 'report', plural: 'reports', name: REPORT, pairServed: true }, - { type: 'page', plural: 'pages', name: 'showcase_start_here', pairServed: false }, - { type: 'app', plural: 'apps', name: 'showcase_app', pairServed: false }, - { type: 'dataset', plural: 'datasets', name: 'showcase_task_metrics', pairServed: false }, - { type: 'doc', plural: 'docs', name: 'showcase_index', pairServed: false }, - { type: 'book', plural: 'books', name: 'showcase_manual', pairServed: false }, + { type: 'page', plural: 'pages', name: 'showcase_start_here', pairServed: true }, + { type: 'app', plural: 'apps', name: 'showcase_app', pairServed: true }, + { type: 'dataset', plural: 'datasets', name: 'showcase_task_metrics', pairServed: true }, + { type: 'doc', plural: 'docs', name: 'showcase_index', pairServed: true }, + { type: 'book', plural: 'books', name: 'showcase_manual', pairServed: true }, ] as const; /** The base's body, as the showcase's `defineStack` carries it: one fresh copy per reader. */