Skip to content

Commit 81cd9eb

Browse files
fix(runtime): the base's package export leaves out its pair's dashboard and report overrides (#29563) (#29597)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8CS9iUSJwYcxNgNAu6nCtWs)_ Fixes #29563 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:基础包的导出 `GET /packages/BASE/export` 不再夹带 `.custom` 配对包已发布的仪表盘、报表覆盖项;配对包的导出照旧带覆盖项和 `overrides` 声明。 - 为什么改:ADR-0146 D8 第 3 条要求基础包导出「原样」;视图已经如此,仪表盘和报表却把客户的改动写进了厂商的包文件,无法当作厂商包重新部署。 - 风险与回滚:只在导出时多过滤一类条目(声明「覆盖本包」的条目),其余读取不变;回滚即撤回本 PR 的一个 commit。 - 先看:`packages/runtime/src/domains/packages.ts` 的 `declaresOverrideOf` 与 `assemblePackageManifest` 里的那一行过滤;再看新测试 `packages-export-base-unchanged-per-type.integration.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run --project local src/domains/packages-export-base-unchanged-per-type.integration.test.ts`,预期 3 条全绿(视图、仪表盘、报表)。 - 需要你做的:无,席位落地。 ## What changes `assemblePackageManifest` (the `GET /packages/:id/export` door) now leaves out, from a package's own export read through the per-package layer, any item whose `overrides.package` names that same package. A package's own document never declares that it overrides itself: that declaration is what an item of the base's `.custom` pair carries. The pair's export is unchanged and keeps the overriding item with its `overrides` block. A protocol with no per-package layer is read as before, so the existing CONTROL in `packages-export-per-package-layer.test.ts` stays as it is. One `@objectstack/runtime` patch changeset. ## Why ADR-0146 D8 item 3: "`GET /packages/hotcrm.custom/export` carries the overriding item with its `overrides` block; `GET /packages/hotcrm/export` carries the base unchanged." Measured red first on the real protocol and engine (new pin, before the fix), per type: - view: the base's export carried only the base's document (already correct); - dashboard: `expected [ 'Shipped label', 'Edited label' ] to deeply equal [ 'Shipped label' ]`; - report: `expected [ 'Shipped label', 'Edited label' ] to deeply equal [ 'Shipped label' ]`. Where the second item comes from, read off the registry in the same harness: publishing the pair's row writes it through to the SchemaRegistry's bare slot (`hydrateOverlayIntoRegistry` in `packages/metadata-protocol/src/protocol.ts`). The artifact lookup there falls back to the base's code artifact, because the pair ships no artifact of that name, so the bare copy wears the base's envelope (`_packageId` = the base, `_provenance: 'package'`). `listItems(type, BASE)` then answers both the base's composite entry and that copy, and the per-package layer lists both under the base. Views are spared by the #22057 guard in the same hydrator, which is view-only by its own comment. The upstream (the hydrator lending a base's envelope to its pair's row) is in `protocol.ts`, which is frozen until 2026-11-03 06:00Z and outside this card's claim. It is reported in the card's os-dev-report as a follow-up, with the minimal change named. This PR closes the export contract at the door the card names. **ADR-0146 D8 item 2, "the export of X's app surface":** read here as the pair's export, the one that carries X's app surface as customized, not as `GET /packages/X/export`. Item 3 names that route and gives it the opposite rule, and `getMetaItemsPerPackage`'s docblock (`protocol.ts`) and #28409 read it the same way. The ADR text is not edited here (outside the claim); the report names the clarifying sentence for the seat. ## Tests - New `packages/runtime/src/domains/packages-export-base-unchanged-per-type.integration.test.ts`: the real door (`HttpDispatcher.handlePackages`) over the real `ObjectStackProtocolImplementation` and `ObjectQL` on a SQLite file. The base is a code package, the pair is installed through the package door, each override goes through the draft save and the per-item publish. Per type (view, dashboard, report): the effective read serves the override (CONTROL), the base's export carries only the base's document with no `overrides` block, and the pair's export carries the override with its declaration. - Ablation (fix committed first, mutation through `scripts/ablation-replace.mjs`; the test reads `packages.ts` from source, no dist in the path): deleting the filter line turned dashboard and report red with the same two messages above, view stayed green; restore proved `blob == HEAD` and `git diff HEAD` empty. ## Acceptance notes - `studio-authoring.package-customize-restore-display` acceptance[4] is a live-run checklist item; its record is not edited in this PR (outside the claim). The seat can mark it once a live run reads the base export clean. ## Verification Head `e81217fe` (main merged at `1a95c746`; the 11 later `main` commits touch none of this PR's files). - `pnpm --filter @objectstack/runtime exec vitest run --project local src/domains/packages-export…` (3 files): before the fix 2 failed | 1 passed (dashboard, report red); after, `Test Files 3 passed (3)`, `Tests 9 passed (9)`. - `pnpm --filter @objectstack/runtime test` at `e81217fe`: `Test Files 526 passed (526)`, `Tests 8489 passed | 19 skipped (8508)`, lock `VERDICT command-exit 0`. - `pnpm --filter @objectstack/runtime typecheck` at `e81217fe`: exit 0 (tsc and `check:test-typecheck`; the new file is in the checked zone with no ledger entry). - `pnpm lint`: exit 0. <details><summary>dispatch-gates --commands (60) and --ran reconciliation</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 commands; 58 exit 0. The union ran on `4a7b14f4`; the only later commit (`e81217fe`) deletes one unused line in the new test file. NOT MEASURED, exit 3 (prerequisite not met), CI runs both: - `node scripts/check-plugin-teardown-shape.mjs --self-test`: its fixture is pinned to a commit the shallow clone does not hold (checker-health only). - `pnpm check:dual-build-cjs-loads`: needs every package's `dist/`; only the runtime closure was built here. `dispatch-gates --ran`: "60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)", exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the os-dev house format). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbXd7sSh6S5XDDEd7Ei --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXd7sSh6S5XDDEd7Ei)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7d971d commit 81cd9eb

3 files changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): `GET /packages/:id/export` of a base no longer carries its `.custom` pair's dashboard or report override
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
ADR-0146 D8 item 3: the base's export carries the base unchanged, and the pair's export carries the overriding item with its `overrides` block. Views already did. After a published dashboard or report override in `<base>.custom`, the base's export carried two items of the name: the base's own and the pair's override, with the edited label and the `overrides` block. The export now leaves out, from a package's own export, any item that declares it overrides that package. The pair's export is unchanged. Nothing to migrate: re-export the base to get a file without the override.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29563, ADR-0146 D8 item 3] "`GET /packages/hotcrm.custom/export` carries
5+
* the overriding item with its `overrides` block; `GET /packages/hotcrm/export`
6+
* carries the base unchanged" — for every type a `.custom` pair overrides, not
7+
* only for views.
8+
*
9+
* Measured through the real door (`HttpDispatcher.handlePackages`) over the
10+
* real protocol and the real `ObjectQL` engine on a SQLite file: the base is
11+
* registered as a code package, its pair is installed through the package
12+
* door, and each override goes through the draft save and the per-item
13+
* publish, as Studio's Customize writes it. Per type (view, dashboard,
14+
* report):
15+
*
16+
* - the base's export carries the base's own document, with no `overrides`
17+
* block;
18+
* - the pair's export carries the overriding item with its `overrides` block;
19+
* - CONTROL: the effective read of the base's name serves the override, so
20+
* the base export's answer is the export's own and not an unpublished
21+
* override.
22+
*/
23+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
24+
import { mkdtempSync, rmSync } from 'node:fs';
25+
import { tmpdir } from 'node:os';
26+
import { join } from 'node:path';
27+
import { ObjectQL } from '@objectstack/objectql';
28+
import { SqlDriver } from '@objectstack/driver-sql';
29+
import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol';
30+
import {
31+
SysMetadataAuditObject,
32+
SysMetadataCommitObject,
33+
SysMetadataHistoryObject,
34+
SysMetadataObject,
35+
} from '@objectstack/metadata-core';
36+
import { HttpDispatcher } from '../http-dispatcher.js';
37+
38+
const BASE = 'com.example.showcase';
39+
const PAIR = 'com.example.showcase.custom';
40+
const PLATFORM_PKG = 'com.objectstack.platform';
41+
const DECLARATION = { package: BASE, basedOn: '1.0.0' } as const;
42+
43+
const CALLER: any = {
44+
request: {},
45+
environmentId: 'platform',
46+
executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['manage_metadata', 'studio.access'] },
47+
};
48+
49+
const VIEW = 'showcase_task.default';
50+
const DASHBOARD = 'showcase_ops_dashboard';
51+
const REPORT = 'showcase_hours_by_status';
52+
const BASE_LABEL = 'Shipped label';
53+
const OVERRIDE_LABEL = 'Edited label';
54+
55+
const viewBody = (label: string) => ({
56+
name: VIEW,
57+
object: 'showcase_task',
58+
viewKind: 'list',
59+
label,
60+
config: {
61+
type: 'grid',
62+
data: { provider: 'object', object: 'showcase_task' },
63+
columns: [{ field: 'title' }, { field: 'status' }],
64+
},
65+
});
66+
const dashboardBody = (label: string) => ({ name: DASHBOARD, label, columns: 12, widgets: [] });
67+
const reportBody = (label: string) => ({
68+
name: REPORT,
69+
label,
70+
type: 'summary',
71+
dataset: 'showcase_task_metrics',
72+
rows: ['status'],
73+
values: ['task_count'],
74+
});
75+
76+
const TYPES = [
77+
{ type: 'view', plural: 'views', name: VIEW, body: viewBody },
78+
{ type: 'dashboard', plural: 'dashboards', name: DASHBOARD, body: dashboardBody },
79+
{ type: 'report', plural: 'reports', name: REPORT, body: reportBody },
80+
] as const;
81+
82+
function baseManifest(): Record<string, unknown> {
83+
return {
84+
id: BASE,
85+
name: 'showcase',
86+
version: '1.0.0',
87+
namespace: 'showcase',
88+
objects: [{
89+
name: 'showcase_task',
90+
label: 'Task',
91+
sharingModel: 'private',
92+
fields: {
93+
title: { name: 'title', label: 'Title', type: 'text' },
94+
status: { name: 'status', label: 'Status', type: 'text' },
95+
},
96+
}],
97+
datasets: [{
98+
name: 'showcase_task_metrics',
99+
label: 'Task Metrics',
100+
object: 'showcase_task',
101+
dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }],
102+
measures: [{ name: 'task_count', label: 'Tasks', aggregate: 'count' }],
103+
}],
104+
// A code package's `views:` carry containers; its `list` expands to `showcase_task.default`.
105+
views: [{
106+
object: 'showcase_task',
107+
list: { label: BASE_LABEL, type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: viewBody(BASE_LABEL).config.columns },
108+
}],
109+
dashboards: [dashboardBody(BASE_LABEL)],
110+
reports: [reportBody(BASE_LABEL)],
111+
};
112+
}
113+
114+
let cleanup: Array<() => void | Promise<void>> = [];
115+
afterEach(async () => {
116+
for (const c of cleanup.reverse()) await c();
117+
cleanup = [];
118+
});
119+
120+
const envSnapshot = { OS_HOME: process.env.OS_HOME, OS_METADATA_WRITABLE: process.env.OS_METADATA_WRITABLE };
121+
let home: string;
122+
beforeAll(() => {
123+
home = mkdtempSync(join(tmpdir(), 'os-29563-home-'));
124+
process.env.OS_HOME = home;
125+
});
126+
afterAll(() => {
127+
for (const [k, v] of Object.entries(envSnapshot)) {
128+
if (v === undefined) delete process.env[k];
129+
else process.env[k] = v;
130+
}
131+
rmSync(home, { recursive: true, force: true });
132+
});
133+
beforeEach(() => {
134+
delete process.env.OS_METADATA_WRITABLE;
135+
ObjectStackProtocolImplementation.resetEnvWritableCache();
136+
resetEnvWritableMetadataTypes();
137+
vi.spyOn(console, 'warn').mockImplementation(() => {});
138+
});
139+
afterEach(() => { vi.restoreAllMocks(); });
140+
141+
async function boot() {
142+
const dir = mkdtempSync(join(tmpdir(), 'os-29563-db-'));
143+
cleanup.push(() => rmSync(dir, { recursive: true, force: true }));
144+
const driver = new SqlDriver({
145+
client: 'better-sqlite3',
146+
connection: { filename: join(dir, 'data.sqlite') },
147+
useNullAsDefault: true,
148+
});
149+
const objects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject] as any[];
150+
await driver.initObjects(objects);
151+
152+
const engine = new ObjectQL();
153+
engine.registerDriver(driver as any, true);
154+
await engine.init();
155+
for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG);
156+
cleanup.push(() => engine.destroy());
157+
engine.registerApp(baseManifest());
158+
159+
const services = new Map<string, unknown>([['objectql', engine]]);
160+
const protocol: any = new ObjectStackProtocolImplementation(engine as any, () => services as any);
161+
services.set('protocol', protocol);
162+
const get = (name: string) => services.get(name) ?? null;
163+
const dispatcher = new HttpDispatcher({ context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) } as any);
164+
165+
await protocol.installPackage({
166+
manifest: {
167+
id: PAIR, name: 'showcase-custom', version: '0.1.0', type: 'app',
168+
namespace: 'showcase_custom', dependencies: { [BASE]: '^1' },
169+
},
170+
});
171+
172+
return {
173+
protocol,
174+
async exported(id: string): Promise<Record<string, any>> {
175+
const res = await dispatcher.handlePackages(`/${encodeURIComponent(id)}/export`, 'GET', undefined, {}, CALLER);
176+
expect(res.response?.status, JSON.stringify(res.response?.body)).toBe(200);
177+
const body: any = JSON.parse(JSON.stringify(res.response?.body ?? null));
178+
return body?.data ?? body;
179+
},
180+
async override(type: string, name: string, item: Record<string, unknown>) {
181+
const saved = await protocol.saveMetaItem({ type, name, item, mode: 'draft', packageId: PAIR });
182+
expect(saved?.success, `the ${type} draft save`).toBe(true);
183+
await protocol.publishMetaItem({ type, name, packageId: PAIR });
184+
},
185+
};
186+
}
187+
188+
/** The exported items of one type under `name`, the view partition (`views` + `viewItems`) read together. */
189+
const itemsOf = (manifest: Record<string, any>, plural: string, name: string): any[] => {
190+
const all = plural === 'views'
191+
? [...(manifest.views ?? []), ...(manifest.viewItems ?? [])]
192+
: [...(manifest[plural] ?? [])];
193+
return all.filter((item) => item?.name === name);
194+
};
195+
196+
describe('[#29563] GET /packages/<base>/export carries the base unchanged for every overridden type', () => {
197+
for (const { type, plural, name, body } of TYPES) {
198+
it(`${type}: the base's export carries the base's document; the pair's export carries the override with its \`overrides\` block`, async () => {
199+
const door = await boot();
200+
await door.override(type, name, { ...body(OVERRIDE_LABEL), overrides: { ...DECLARATION } });
201+
202+
// CONTROL: the effective read of the base's name serves the published override.
203+
const effective: any = await door.protocol.getMetaItem({ type, name, packageId: BASE });
204+
expect(effective?.item?.label, `the effective ${type} read`).toBe(OVERRIDE_LABEL);
205+
206+
const base = itemsOf(await door.exported(BASE), plural, name);
207+
expect(base.map((item) => item.label), `the base's ${type} export`).toEqual([BASE_LABEL]);
208+
expect(base[0].overrides).toBeUndefined();
209+
210+
const pair = itemsOf(await door.exported(PAIR), plural, name);
211+
expect(pair.map((item) => item.label), `the pair's ${type} export`).toEqual([OVERRIDE_LABEL]);
212+
expect(pair[0].overrides).toEqual(DECLARATION);
213+
});
214+
}
215+
});

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3331,6 +3331,17 @@ function readBaseContributions(
33313331
|| a.packageId.localeCompare(b.packageId));
33323332
}
33333333

3334+
/**
3335+
* [#29563] Whether an item declares that it overrides `packageId`
3336+
* (`overrides.package`, ADR-0146 D2). A package's own document never does: the
3337+
* declaration names the base an item of the base's `.custom` pair replaces.
3338+
*/
3339+
function declaresOverrideOf(item: unknown, packageId: string): boolean {
3340+
const overrides = (item as { overrides?: unknown } | null | undefined)?.overrides;
3341+
return typeof overrides === 'object' && overrides !== null
3342+
&& (overrides as { package?: unknown }).package === packageId;
3343+
}
3344+
33343345
async function assemblePackageManifest(
33353346
deps: DomainHandlerDeps,
33363347
packageId: string,
@@ -3377,6 +3388,7 @@ context: HttpProtocolContext,
33773388

33783389
const manifest: Record<string, any> = {};
33793390
let total = 0;
3391+
const perPackageLayer = typeof protocol.getMetaItemsPerPackage === 'function';
33803392
for (const plural of [...exportPluralKeys, EXTENSIONS_KEY]) {
33813393
const singular = singularOf(plural);
33823394
let items: any[] = [];
@@ -3398,6 +3410,17 @@ context: HttpProtocolContext,
33983410
// Unknown/unsupported type for this runtime — skip.
33993411
continue;
34003412
}
3413+
// [#29563, ADR-0146 D8 item 3] …and, from that layer, never an item
3414+
// that declares it overrides THIS package: that is the pair's
3415+
// override, not one of the package's own documents. The layer can list
3416+
// one under the base: a published `.custom` row is registered in the
3417+
// registry's bare slot wearing the base's envelope (`_packageId` = the
3418+
// base's), and for every type but `view` (#22057) that copy is listed
3419+
// beside the base's own item. Measured for `dashboard` and `report`:
3420+
// the base's export carried both, the second with the edited label and
3421+
// the `overrides` block. A protocol with no per-package layer is read
3422+
// as before.
3423+
if (perPackageLayer) items = items.filter((it) => !declaresOverrideOf(it, packageId));
34013424
if (items.length === 0) continue;
34023425
// [#5320] `views` is partitioned rather than dumped: the registry's
34033426
// ADR-0017 dual-read returns the container AND its expanded per-view

0 commit comments

Comments
 (0)