diff --git a/.changeset/29649-base-export-keeps-overridden-items.md b/.changeset/29649-base-export-keeps-overridden-items.md new file mode 100644 index 00000000000..af877efbbd5 --- /dev/null +++ b/.changeset/29649-base-export-keeps-overridden-items.md @@ -0,0 +1,8 @@ +--- +'@objectstack/runtime': patch +--- + +Clause-②: no +Metadata-protocol: none + +`GET /packages//export` carries the base's own document again while its `.custom` pair's override of an item is live (measured for dashboard, report, page, app, dataset, doc and book) (ADR-0146 D8 item 3). On a boot that also holds the base's code documents in the metadata service, as every `defineStack` host does, the per-package layer listed only the override copy under the base's name, and the export's override filter (#29563) left the item out of the base's export. The export now serves the base's code artifact in that item's place, so the base's export is byte-identical with or without a published override, and still carries no `overrides` block. 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 new file mode 100644 index 00000000000..2abb61fa6e5 --- /dev/null +++ b/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29649, ADR-0146 D8 item 3] "`GET /packages/hotcrm/export` carries the base + * unchanged" while its `.custom` pair's override of an item is live — on a base + * booted as the showcase boots, where the base's code documents are held twice: + * by the registry (`ObjectQL.registerApp`) AND by the metadata service + * (`MetadataPlugin`'s artifact door, which stamps each body with the base's + * envelope before it stores it). + * + * That second holder is what the #29563 pin + * (`packages-export-base-unchanged-per-type.integration.test.ts`) does not + * register, and why it stayed green while a real showcase boot lost the item: + * the per-package layer merges the registry's items over the metadata + * service's, one per `(name, package)` slot, and the published override is + * registered in the registry's bare slot wearing the base's envelope (#29600). + * So the base's slot of the name holds the override alone, and the export's + * "never an override of THIS package" filter (#29563) left nothing in it. + * Measured on a real showcase boot at b391feb1: dashboards 3 → 2, reports + * 4 → 3, views 36 → 36. #29640 (7dae3b3c) took the dashboard and report copy + * out of the bare slot; on a real showcase boot at 7dae3b3c a published page, + * app, dataset, doc or book override still dropped the item (pages 29 → 28, + * apps 1 → 0, datasets 4 → 3, docs 8 → 7, books 1 → 0). + * + * Per type (view, dashboard, report, and the five types #29640 left in the + * registry's bare slot: page, app, dataset, doc, book), over the real door + * (`HttpDispatcher.handlePackages`), the real protocol, the real `ObjectQL` + * engine on a SQLite file and the real `MetadataManager`: + * + * - the base's export after the override is published is byte-identical to + * the base's export before it, so the item is present and equal, and no + * 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. + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { MetadataManager } from '@objectstack/metadata'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { + SysMetadataAuditObject, + SysMetadataCommitObject, + SysMetadataHistoryObject, + SysMetadataObject, +} from '@objectstack/metadata-core'; +import { applyProtection } from '@objectstack/spec/shared'; +import { expandViewContainer } from '@objectstack/spec/ui'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const BASE = 'com.example.showcase'; +const BASE_VERSION = '0.1.0'; +const PAIR = 'com.example.showcase.custom'; +const PLATFORM_PKG = 'com.objectstack.platform'; +const DECLARATION = { package: BASE, basedOn: BASE_VERSION } as const; + +const CALLER: any = { + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['manage_metadata', 'studio.access'] }, +}; + +const OBJECT = 'showcase_task'; +const VIEW = `${OBJECT}.in_progress`; +const DASHBOARD = 'showcase_ops_dashboard'; +const REPORT = 'showcase_hours_by_status'; +const OVERRIDE_LABEL = 'Edited label'; + +const COLUMNS = [{ field: 'title' }, { field: 'status' }]; +const viewContainer = () => ({ + object: OBJECT, + list: { label: 'All Tasks', type: 'grid', data: { provider: 'object', object: OBJECT }, columns: COLUMNS }, + listViews: { + in_progress: { label: 'In Progress', type: 'grid', data: { provider: 'object', object: OBJECT }, columns: COLUMNS }, + }, +}); +const dashboards = () => [ + { name: 'showcase_chart_gallery', label: 'Chart Gallery', columns: 12, widgets: [] }, + { name: DASHBOARD, label: 'Delivery Operations', columns: 12, widgets: [] }, + { name: 'showcase_revenue_pulse', label: 'Revenue Pulse', columns: 12, widgets: [] }, +]; +const report = (name: string, label: string) => ({ + name, + label, + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['task_count'], +}); +const reports = () => [ + report(REPORT, 'Hours by Status'), + report('showcase_task_overview', 'Task Overview'), +]; + +/** + * The base's collections the metadata service holds besides its views, as the + * artifact door files them: plural collection → metadata type. Every one is a + * type a `.custom` pair can publish an override of. + */ +const SERVICE_COLLECTIONS = { + apps: 'app', + pages: 'page', + datasets: 'dataset', + dashboards: 'dashboard', + reports: 'report', + docs: 'doc', + books: 'book', +} as const; + +/** + * `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. + */ +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 }, +] as const; + +/** The base's body, as the showcase's `defineStack` carries it: one fresh copy per reader. */ +function baseBody(): Record { + return { + id: BASE, + name: 'showcase', + version: BASE_VERSION, + namespace: 'showcase', + objects: [{ + name: OBJECT, + label: 'Task', + sharingModel: 'private', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + status: { name: 'status', label: 'Status', type: 'text' }, + }, + }], + datasets: [{ + name: 'showcase_task_metrics', + label: 'Task Metrics', + object: OBJECT, + dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }], + measures: [{ name: 'task_count', label: 'Tasks', aggregate: 'count' }], + }], + apps: [{ name: 'showcase_app', label: 'Showcase', navigation: [] }], + pages: [ + { name: 'showcase_capability_map', label: 'Capability Map', type: 'app', regions: [] }, + { name: 'showcase_start_here', label: 'Start Here', type: 'app', regions: [] }, + ], + views: [viewContainer()], + dashboards: dashboards(), + reports: reports(), + docs: [ + { name: 'showcase_docs_guide', label: 'Docs Guide', content: '# Docs guide' }, + { name: 'showcase_index', label: 'Showcase', content: '# Showcase' }, + ], + books: [{ name: 'showcase_manual', label: 'Showcase Manual', groups: [] }], + }; +} + +/** + * The metadata service's copy of the base's documents, stored as + * `MetadataPlugin`'s artifact door stores them: each body stamped with the + * base's envelope (`applyProtection`), a view container under its object and + * each view it expands to under its own name. + */ +async function metadataServiceOf(): Promise { + const manager = new MetadataManager({ formats: ['json'], loaders: [] }); + const body = baseBody(); + const stamp = >(item: T): T => + applyProtection(item as any, { packageId: BASE, packageVersion: BASE_VERSION }) as T; + for (const container of body.views) { + await manager.register('view', OBJECT, stamp(container), { notify: false }); + for (const view of expandViewContainer(OBJECT, container)) { + await manager.register('view', view.name, stamp(view as any), { notify: false }); + } + } + for (const [plural, type] of Object.entries(SERVICE_COLLECTIONS)) { + for (const item of body[plural]) await manager.register(type, item.name, stamp(item), { notify: false }); + } + return manager; +} + +let cleanup: Array<() => void | Promise> = []; +afterEach(async () => { + for (const c of cleanup.reverse()) await c(); + cleanup = []; +}); + +const envSnapshot = { OS_HOME: process.env.OS_HOME, OS_METADATA_WRITABLE: process.env.OS_METADATA_WRITABLE }; +let home: string; +beforeAll(() => { + home = mkdtempSync(join(tmpdir(), 'os-29649-home-')); + process.env.OS_HOME = home; +}); +afterAll(() => { + for (const [k, v] of Object.entries(envSnapshot)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(home, { recursive: true, force: true }); +}); +beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { vi.restoreAllMocks(); }); + +/** One boot over the SQLite file in `dir`; the pair is installed on the first boot only. */ +async function boot(dir: string, { installPair }: { installPair: boolean }) { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: join(dir, 'data.sqlite') }, + useNullAsDefault: true, + }); + const objects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject] as any[]; + await driver.initObjects(objects); + + const engine = new ObjectQL(); + engine.registerDriver(driver as any, true); + await engine.init(); + for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG); + cleanup.push(() => engine.destroy()); + engine.registerApp(baseBody()); + + const services = new Map([['objectql', engine], ['metadata', await metadataServiceOf()]]); + const protocol: any = new ObjectStackProtocolImplementation(engine as any, () => services as any); + services.set('protocol', protocol); + const get = (name: string) => services.get(name) ?? null; + const dispatcher = new HttpDispatcher({ context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) } as any); + + if (installPair) { + await protocol.installPackage({ + manifest: { + id: PAIR, name: 'showcase-custom', version: '0.1.0', type: 'app', + namespace: 'showcase_custom', dependencies: { [BASE]: '^0' }, + }, + }); + } + + return { + protocol, + /** The pair's active (published) rows of `(type, name)`, read off the store. */ + async publishedRows(type: string, name: string): Promise { + return await driver.find('sys_metadata', { where: { type, name, package_id: PAIR, state: 'active' } }); + }, + async exported(id: string): Promise> { + const res = await dispatcher.handlePackages(`/${encodeURIComponent(id)}/export`, 'GET', undefined, {}, CALLER); + expect(res.response?.status, JSON.stringify(res.response?.body)).toBe(200); + const body: any = JSON.parse(JSON.stringify(res.response?.body ?? null)); + return body?.data ?? body; + }, + async override(type: string, name: string, item: Record) { + const saved = await protocol.saveMetaItem({ type, name, item, mode: 'draft', packageId: PAIR }); + expect(saved?.success, `the ${type} draft save`).toBe(true); + await protocol.publishMetaItem({ type, name, packageId: PAIR }); + }, + }; +} + +const itemsOf = (manifest: Record, plural: string, name: string): any[] => + (manifest[plural] ?? []).filter((item: any) => item?.name === name); + +/** Every exported item, across the collections, that carries an `overrides` block. */ +const overridingItems = (manifest: Record): string[] => Object.entries(manifest) + .filter(([, value]) => Array.isArray(value)) + .flatMap(([plural, value]) => (value as any[]) + .filter((item) => item && typeof item === 'object' && 'overrides' in item) + .map((item) => `${plural}/${item.name}`)); + +describe('[#29649] GET /packages//export carries the base unchanged on a showcase-shaped boot', () => { + for (const { type, plural, name, pairServed } of TYPES) { + it(`${type}: the base's export is byte-identical with the override published, and after a restart`, async () => { + const dir = mkdtempSync(join(tmpdir(), 'os-29649-db-')); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + + const door = await boot(dir, { installPair: true }); + const before = await door.exported(BASE); + const shipped = itemsOf(before, plural, name); + expect(shipped, `the base's ${type} before the override`).toHaveLength(1); + + await door.override(type, name, { ...shipped[0], label: OVERRIDE_LABEL, overrides: { ...DECLARATION } }); + + // CONTROL: the override is published, and, where the protocol serves + // it, the effective read of the base's name serves it. + expect(await door.publishedRows(type, name), `the pair's published ${type} row`).toHaveLength(1); + if (pairServed) { + const effective: any = await door.protocol.getMetaItem({ type, name, packageId: BASE }); + expect(effective?.item?.label, `the effective ${type} read`).toBe(OVERRIDE_LABEL); + } + + const after = await door.exported(BASE); + expect(itemsOf(after, plural, name), `the base's ${type} with the override live`).toEqual(shipped); + expect(overridingItems(after), `the base's export carries no override`).toEqual([]); + expect(JSON.stringify(after), `the base's whole export with the ${type} override live`).toBe(JSON.stringify(before)); + + if (pairServed) { + const pair = itemsOf(await door.exported(PAIR), plural, name); + expect(pair.map((item) => item.label), `the pair's ${type} export`).toEqual([OVERRIDE_LABEL]); + expect(pair[0].overrides).toEqual(DECLARATION); + } + + // A restart over the same database: the published row is hydrated from storage. + const restarted = await boot(dir, { installPair: false }); + expect(JSON.stringify(await restarted.exported(BASE)), `the base's whole export after a restart`).toBe(JSON.stringify(before)); + }); + } +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index fac0bdc3bad..1dad8d5010a 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -3342,6 +3342,42 @@ function declaresOverrideOf(item: unknown, packageId: string): boolean { && (overrides as { package?: unknown }).package === packageId; } +/** + * [#29563, #29649, ADR-0146 D8 item 3] A package's own documents of one type, + * out of its per-package layer `items`: an item that declares it overrides + * `packageId` ({@link declaresOverrideOf}) is the package's `.custom` pair's + * override, never one of the package's documents, so it is replaced, in its + * place, by the package's own code artifact of the name when the layer lists + * none beside it, and dropped otherwise. The artifact is the registry's + * prefer-local answer for the package (`getArtifactItem`), kept only when it is + * the package's own (`_packageId`) and declares no override itself. + */ +function ownDocumentsOf(items: any[], type: string, packageId: string, registry: any): any[] { + const own = new Set(items + .filter((it) => !declaresOverrideOf(it, packageId)) + .map((it) => (it as { name?: unknown } | null)?.name)); + const out: any[] = []; + for (const it of items) { + if (!declaresOverrideOf(it, packageId)) { + out.push(it); + continue; + } + const name = (it as { name?: unknown } | null)?.name; + if (typeof name !== 'string' || own.has(name)) continue; + let artifact: unknown; + try { + artifact = registry?.getArtifactItem?.(type, name, packageId); + } catch { + artifact = undefined; + } + if ((artifact as { _packageId?: unknown } | null | undefined)?._packageId !== packageId) continue; + if (declaresOverrideOf(artifact, packageId)) continue; + own.add(name); + out.push(artifact); + } + return out; +} + async function assemblePackageManifest( deps: DomainHandlerDeps, packageId: string, @@ -3420,7 +3456,23 @@ context: HttpProtocolContext, // the base's export carried both, the second with the edited label and // the `overrides` block. A protocol with no per-package layer is read // as before. - if (perPackageLayer) items = items.filter((it) => !declaresOverrideOf(it, packageId)); + // + // [#29649] …and the package's own document is served in its place: the + // override copy is not always listed BESIDE the base's item. On a boot + // that also holds the base's code documents in the metadata service + // (`MetadataPlugin`'s artifact door, as every `defineStack` host does), + // the layer merges the registry's items over the service's, one per + // `(name, package)` slot, so the copy wearing the base's envelope takes + // the base's slot and the base's own item is not listed at all. + // Measured on a real showcase boot: a published dashboard or report + // override left the base's export without the item, and after #29640 + // took those two out of the bare slot, a page, app, dataset, doc or + // book override still did (#29613 holds them). The package's own + // document is its code artifact, read prefer-local from the registry + // (`getArtifactItem` with the package's id: the composite slot + // `registerApp` filled, which the bare-slot copy never reaches), the + // same body the layer serves for the name while no override is live. + if (perPackageLayer) items = ownDocumentsOf(items, singular, packageId, registry); if (items.length === 0) continue; // [#5320] `views` is partitioned rather than dumped: the registry's // ADR-0017 dual-read returns the container AND its expanded per-view