Skip to content

Commit 5afe7d7

Browse files
fix(metadata-protocol, objectql): a disabled package's export carries its items (#29914)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBTXAQtrfk4vQJzg2rvidEwL)_ Fixes #29871 domain:server#6 · os-support-ai · session_01WGufzej2VEHitTx5mznA8z Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:禁用的包导出时不再是空壳;单包导出和整库导出都带上它自己的对象、视图、仪表盘等条目。 - 为什么改:导出是本版新上的租户退出通道,禁用包导出为空、换环境安装后对象静默丢失且无报错,属于发版阻塞级的数据丢失。 - 风险与回滚:只改"按包读取层"(仅导出和匿名表单撤回判断读它);控制台、/meta、自动化等对外读取照旧隐藏禁用包。回滚即 revert 本 PR。 - 先看:`packages/metadata-protocol/src/protocol-meta-list-reads.ts`(keepDisabledPackages 两处)、`packages/objectql/src/registry.ts`(listItems 第三参)、新测 `packages/runtime/src/domains/packages-export-disabled-package.integration.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-export-disabled-package.integration.test.ts`,预期 2 passed。 - 需要你做的:无,席位落地。 ## What changes A disabled package is still exported with its items. `PackageExportBundleSchema` declares every package exported "enabled or not" and installed enabled; a disable hides the items from the served reads and leaves them stored. The export (`assemblePackageManifest`) reads each package through the protocol's per-package layer (`getMetaItemsPerPackage` → `readFlattenedMetaItems(..., 'per-package')`), and that layer hid a disabled package's items in two places: 1. `SchemaRegistry.listItems` drops a disabled package's items for every registry type but `object`; 2. the protocol's final disabled-package filter (`isItemOfDisabledPackage`) drops them for every type, `object` included, after the overlay and metadata-service merges. So `GET /packages/:id/export` and the bundle `GET /packages/export` (one producer) answered the package with `id`/`name`/`version`/`namespace` and nothing else. - `packages/objectql/src/registry.ts`: `listItems(type, packageId?, options?)` takes `{ includeDisabledPackages: true }`; unset, it hides them as before. The `IObjectQLEngine` contract signature in `packages/spec` is unchanged (the parameter is optional; the protocol host reads the registry as `any`). - `packages/metadata-protocol/src/protocol-meta-list-reads.ts`: on `layer === 'per-package'` only, the registry is listed with that option and the final disabled-package filter is skipped. The effective read (`getMetaItems`) and the execution face (`getMetaItemsForExecution`) still hide a disabled package's items, unchanged. - `packages/metadata-protocol/src/protocol.ts`: the `getMetaItemsPerPackage` docblock states the third step the layer does not take, and what it means for its two readers. The per-package layer's other reader, the anonymous form doors' withdrawal judgement (`packages/rest/src/form-routes.ts`), now also sees a disabled package's views in its withdrawal layer. The doors offer only the effective list's forms (still without the disabled package's), and a body in the withdrawal layer can only close a form, so this can narrow anonymous intake and never widen it, the direction that layer is allowed (#28238 option 3). Side effect, stated: a disabled package's view that withdraws a public form now also closes that form's name in other packages (the doors judge a withdrawal against every body in that layer, matched by slot or slug). That is the fail-closed direction. `Clause-②: yes (widening)`: `SchemaRegistry` is exported from `@objectstack/objectql`'s entry, so the optional third `listItems` argument widens that package's public surface. Level stays `patch` (`Metadata-protocol: none`). ## Tests - New `packages/runtime/src/domains/packages-export-disabled-package.integration.test.ts`, over the real door (`HttpDispatcher.handlePackages`), the real protocol and `ObjectQL` on a SQLite file: - the issue's repro: a runtime-authored package (installed through the protocol, an object and a dashboard drafted and published through `POST /packages/:id/publish-drafts`), disabled through `PATCH /packages/:id/disable`: its export is byte-identical to its export before the disable, and so is its bundle entry; - a registered package (`registerApp`: object, view container, dashboard), disabled in the registry: its export is byte-identical too; - CONTROL, each: `getMetaItems` lists the items while enabled and hides them once disabled. - `packages/qa/dogfood/test/package-export-bundle-round-trip.dogfood.test.ts`: a second top-level describe on two showcase boots, the issue's steps end to end: `POST /packages`, `PUT /meta/object/NAME?mode=draft&package=ID`, `publish-drafts`, `PATCH …/disable`; the per-package export and the bundle entry still carry the object; a fresh runtime installs the entry through `POST /marketplace/install-local` (`os package install`) and `GET /meta/object/NAME` answers 200. ## Acceptance notes - Wanted 1 (export carries a disabled package's own items, served reads keep hiding them): both integration cases and their controls. - Wanted 2 (real boot: disable a published package, its export still carries its object; a fresh install of the bundle serves it): the dogfood describe. - Serial with #29760 / #29872 on `assemblePackageManifest`: this PR does not edit that function. ## Verification Readings on objectstack-ai/objectstack, shared cloud box. The final head `7d618b49` changes only the changeset's `Clause-②` line and prose. Suites, ablation and derived gates at `68cabb9a`; `473f2d18` changes only the dogfood file (each runtime gets its own `OS_HOME`), and the dogfood readings below are at that head. <details><summary>Package suites (each under the verify lock)</summary> - `pnpm --filter @objectstack/objectql test`: 546 files, 10809 passed. - `pnpm --filter @objectstack/metadata-protocol test`: 311 passed, 4 skipped files; 22311 passed, 26 skipped. - `pnpm --filter @objectstack/runtime test`: 538 files; 8588 passed, 19 skipped (the new file included). - `pnpm --filter @objectstack/rest test` (the per-package layer's other reader, the form doors): 383 files; 7155 passed, 332 skipped. - `test:repo` is not run locally; it is CI's (`Test Core`). </details> <details><summary>Ablation of the fix</summary> Both src files restored to `00f72f94` (`git restore --source`), `objectql` and `metadata-protocol` rebuilt, the `includeDisabledPackages` marker absent from both dists (0 / 0 files): the new integration file reds 2 of 2. Restored with `git checkout HEAD --` (diff vs HEAD empty), rebuilt, marker present (2 / 1 files): 2 of 2 green. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 67 families from the 6 changed paths; all 67 run. `--ran` reconciliation: 66 run, 1 NOT MEASURED: `check:dual-build-cjs-loads` exit 3 (prerequisite: a whole-repo build), CI's. `check-plugin-teardown-shape --self-test` exited 3 on the shallow clone until its pinned fixture commit was fetched, then 48 cases passed. </details> <details><summary>Typecheck, lint, dogfood</summary> - `pnpm --filter @objectstack/objectql --filter @objectstack/metadata-protocol --filter @objectstack/runtime typecheck`: exit 0; `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - `pnpm lint` (`eslint . --no-inline-config`): exit 0. - `pnpm --filter @objectstack/dogfood exec vitest run test/package-export-bundle-round-trip.dogfood.test.ts` (after the `@objectstack/dogfood^...` closure build): 8 passed (both describes). - Ablation on the dogfood file, same recipe as above: 2 failed (the disabled export carries its object; the target serves it), 6 passed; restored: 8 passed. - The first dogfood run went red at "the target serves it" with `OBJECT_PACKAGE_DISABLED`: both boots shared one `OS_HOME`, where a disable is persisted (`package-state/`) and replayed at boot, so the target replayed the source's disable. That is the test harness, not the export (the bundle carries no enabled state); each runtime now gets its own home, and no state file is left under the default home. - Full dogfood not run: the title carries no `!` and `Clause-②` has no `(narrowing)` arm. </details> <!-- signed: domain:server#6 · os-support-ai · session_01WGufzej2VEHitTx5mznA8z --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b5b176 commit 5afe7d7

6 files changed

Lines changed: 357 additions & 11 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(metadata-protocol, objectql): a disabled package's export carries its items, so a re-install no longer loses them
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
`GET /api/v1/packages/:id/export` and the whole-deployment bundle `GET /api/v1/packages/export` answered a disabled package as an empty package: its id, name and version, and none of its objects, views, dashboards or other items. Installing that entry elsewhere answered 200 and left the items absent. The bundle's own contract (`PackageExportBundleSchema`) exports every package "enabled or not" and installs it enabled, so a tenant who switched a package off and exported the deployment silently lost its contents.
12+
13+
The export reads each package through the protocol's per-package layer (`getMetaItemsPerPackage`), and that layer hid a disabled package's items the way the served reads do. It now keeps them: a disable leaves the items stored, and the package's own address still holds them. Every served and executed read (`/meta/*`, the console, the automation engine) still hides them, unchanged.
14+
15+
`SchemaRegistry.listItems(type, packageId, options)` gains an optional third argument, `{ includeDisabledPackages: true }`, which only the per-package layer passes. Without it the list hides a disabled package's items, as before.
16+
17+
The per-package layer's other reader, the anonymous form doors' withdrawal judgement, now also sees a disabled package's views: a disabled package's view that withdraws a form also closes that form's name in other packages. That only closes forms, never opens one.

‎packages/metadata-protocol/src/protocol-meta-list-reads.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
8383
layer: 'effective' | 'per-package' = 'effective',) {
8484
const { packageId } = request;
8585
const servedOverride = layer === 'effective';
86+
// [#29871] The per-package layer is each package's own address, and a
87+
// disable empties none: a disabled package's items stay stored and come
88+
// back on enable. So this layer keeps them (the package export reads it,
89+
// and exports every package, enabled or not); the effective and the
90+
// execution reads hide them, at the registry and after the merges below.
91+
const keepDisabledPackages = layer === 'per-package';
92+
const listRegistry = (type: string): unknown[] => (keepDisabledPackages
93+
? host.engine.registry.listItems(type, packageId, { includeDisabledPackages: true })
94+
: host.engine.registry.listItems(type, packageId));
8695
// ── [ADR-0131 D6] The environment → code read ──────────────────────
8796
//
8897
// The list serves the environment's stored rows over the code
@@ -101,11 +110,11 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
101110
// (plugin-auth, plugin-security, plugin-audit, …) — those are globally
102111
// shared and must be visible at every project's meta endpoint.
103112
if (host.environmentId === undefined) {
104-
items = [...host.engine.registry.listItems(request.type, packageId)];
113+
items = [...listRegistry(request.type)];
105114
// Normalize singular/plural using explicit mapping
106115
if (items.length === 0) {
107116
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
108-
if (alt) items = [...host.engine.registry.listItems(alt, packageId)];
117+
if (alt) items = [...listRegistry(alt)];
109118
}
110119
} else {
111120
// For project kernels: the SchemaRegistry is owned by THIS
@@ -115,10 +124,10 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
115124
// package alike. The `_packageId` tag added by `listItems`
116125
// (registry.ts) is preserved for the sidebar to compute the
117126
// correct navigation URL.
118-
items = [...host.engine.registry.listItems(request.type, packageId)];
127+
items = [...listRegistry(request.type)];
119128
if (items.length === 0) {
120129
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
121-
if (alt) items = [...host.engine.registry.listItems(alt, packageId)];
130+
if (alt) items = [...listRegistry(alt)];
122131
}
123132
}
124133

@@ -746,7 +755,12 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
746755
//
747756
// [#24210] The predicate is {@link isItemOfDisabledPackage}, the one the
748757
// by-name read asks, so the list and the by-name read hide the same items.
749-
if (request.type !== 'package') {
758+
//
759+
// [#29871] …on the effective and execution reads. The per-package layer
760+
// keeps them (see `keepDisabledPackages` above): a package's export is
761+
// what the package holds, and an export that dropped a disabled
762+
// package's items installed elsewhere as an empty package.
763+
if (request.type !== 'package' && !keepDisabledPackages) {
750764
items = (items as any[]).filter((it) => !isItemOfDisabledPackage(host.engine.registry, it));
751765
}
752766
// [#28877] …and, on the effective served list, an action that launches

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4201,8 +4201,11 @@ export class ObjectStackProtocolImplementation implements
42014201
* BEFORE the one effective read: {@link getMetaItems} with the served
42024202
* decorations, with every package's item of a name kept (ADR-0048,
42034203
* one item per package of a name), and with no package's declared
4204-
* override served over that package's own item. The same sources, the
4205-
* same merges, the same envelope; two steps of the effective read are not
4204+
* override served over that package's own item, and [#29871] with a
4205+
* DISABLED package's items kept: a disable hides them from every served
4206+
* and executed read and leaves them stored (they come back on enable), so
4207+
* the package's own address still holds them. The same sources, the same
4208+
* merges, the same envelope; three steps of the effective read are not
42064209
* taken, and nothing else differs.
42074210
*
42084211
* The list {@link getMetaItems} served before ADR-0146 D8, kept for its
@@ -4214,11 +4217,16 @@ export class ObjectStackProtocolImplementation implements
42144217
* hard requirement ruled on #28238, option 3). So the doors judge it
42154218
* against this layer beside the effective list, never against a layer
42164219
* narrower than this one — it still holds a dependent package's
4217-
* same-named row and the base's own document under a declared override;
4220+
* same-named row and the base's own document under a declared override
4221+
* (and a disabled package's view, whose withdrawal can only close a
4222+
* form: the doors offer only the effective list's forms);
42184223
* - **the package export** (`GET /packages/:id/export` in
42194224
* `@objectstack/runtime`). A package exports its own items: the base's
42204225
* export carries the base unchanged, and its pair's export carries the
4221-
* overriding item with its `overrides` block (ADR-0146 D8 item 3).
4226+
* overriding item with its `overrides` block (ADR-0146 D8 item 3); and a
4227+
* disabled package's export carries its items, as
4228+
* `PackageExportBundleSchema` declares (every package exported, enabled
4229+
* or not, and installed enabled).
42224230
*
42234231
* ⛔ Not a second list for anything else. Every door that serves a
42244232
* package's name serves {@link getMetaItems}, the one effective read.

‎packages/objectql/src/registry.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7589,8 +7589,13 @@ export class SchemaRegistry {
75897589

75907590
/**
75917591
* Universal List Method
7592+
*
7593+
* [#29871] `options.includeDisabledPackages` keeps a disabled package's
7594+
* items. Only the protocol's per-package layer passes it (the package
7595+
* export, which carries a package's own items enabled or not); every
7596+
* served read leaves it unset and hides them.
75927597
*/
7593-
listItems<T>(type: string, packageId?: string): T[] {
7598+
listItems<T>(type: string, packageId?: string, options?: { includeDisabledPackages?: boolean }): T[] {
75947599
// Special handling for 'object' and 'objects' types - use objectContributors
75957600
if (type === 'object' || type === 'objects') {
75967601
return this.getAllObjects(packageId) as unknown as T[];
@@ -7606,7 +7611,7 @@ export class SchemaRegistry {
76067611
// `package` type itself is never filtered — the Packages page must still
76077612
// list disabled packages so they can be re-enabled. Disable is reversible:
76087613
// items remain registered and reappear on enable.
7609-
if (type !== 'package') {
7614+
if (type !== 'package' && options?.includeDisabledPackages !== true) {
76107615
result = result.filter((item: any) => !this.isPackageDisabled((item as any)?._packageId));
76117616
}
76127617
return result;

‎packages/qa/dogfood/test/package-export-bundle-round-trip.dogfood.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
// order, with the same namespace, dependencies, and the same item names
2626
// and counts per collection.
2727
//
28+
// [#29871] A second describe pins the bundle's "enabled or not" for a
29+
// DISABLED package: a runtime-authored package (`POST /packages`, an object
30+
// drafted into it and published) is disabled before the export; the bundle
31+
// and its per-package export still carry the object, and a fresh runtime that
32+
// installs the entry serves it (`GET /meta/object/<name>` 200), where before
33+
// the entry was an empty package and the object came back 404.
34+
//
2835
// Boots fixture stacks of its own (the install-local plugin mounted on both),
2936
// so it stays out of `SHARED_SHOWCASE`. Every credential below is an
3037
// obviously-fake placeholder, and every assertion on one is its absence.
@@ -228,3 +235,90 @@ describe('dogfood: the whole-deployment export round-trips onto a fresh runtime,
228235
expect((pair?.dashboards ?? []).map((d: any) => [d.name, d.label, d.overrides?.package])).toEqual([[DASHBOARD, EDITED, BASE]]);
229236
});
230237
});
238+
239+
const OFF = 'com.dogfood.exportoff';
240+
const OFF_OBJECT = 'exportoff_note';
241+
242+
describe('dogfood: a disabled package exports its items, and a fresh runtime that installs the entry serves them', () => {
243+
const dirs: string[] = [];
244+
const answers: Record<string, Answer> = {};
245+
let offEntry: any;
246+
let previousHome: string | undefined;
247+
248+
beforeAll(async () => {
249+
// Each runtime gets its own home: a disable is persisted there
250+
// (`package-state/`) and replayed at boot, so a shared home would carry
251+
// the source's disable onto the target, which no fresh runtime has.
252+
previousHome = process.env.OS_HOME;
253+
const sourceDir = mkdtempSync(join(tmpdir(), 'dogfood-export-disabled-source-'));
254+
dirs.push(sourceDir);
255+
process.env.OS_HOME = join(sourceDir, 'home');
256+
const source = await bootShowcase({
257+
extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: sourceDir })],
258+
});
259+
try {
260+
const token = await source.signIn();
261+
answers.created = await call(source, token, 'POST', '/packages', {
262+
manifest: { id: OFF, name: 'Export disabled', version: '0.1.0', type: 'app', namespace: 'exportoff' },
263+
});
264+
answers.saved = await call(source, token, 'PUT', `/meta/object/${OFF_OBJECT}?mode=draft&package=${OFF}`, {
265+
name: OFF_OBJECT, label: 'Note', sharingModel: 'private', fields: { body: { type: 'text', label: 'Body' } },
266+
});
267+
answers.published = await call(source, token, 'POST', `/packages/${OFF}/publish-drafts`, {});
268+
answers.enabledExport = await call(source, token, 'GET', `/packages/${OFF}/export`);
269+
answers.disabled = await call(source, token, 'PATCH', `/packages/${OFF}/disable`);
270+
answers.servedAfterDisable = await call(source, token, 'GET', `/meta/object/${OFF_OBJECT}`);
271+
answers.disabledExport = await call(source, token, 'GET', `/packages/${OFF}/export`);
272+
answers.bundle = await call(source, token, 'GET', '/packages/export');
273+
offEntry = (answers.bundle.body?.data?.packages ?? []).find((p: any) => p?.id === OFF);
274+
} finally {
275+
await source.stop();
276+
}
277+
278+
const targetDir = mkdtempSync(join(tmpdir(), 'dogfood-export-disabled-target-'));
279+
dirs.push(targetDir);
280+
process.env.OS_HOME = join(targetDir, 'home');
281+
const target = await bootShowcase({
282+
extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: targetDir })],
283+
});
284+
try {
285+
const token = await target.signIn();
286+
answers.installed = await call(target, token, 'POST', ROUTE, { manifest: offEntry });
287+
answers.targetServed = await call(target, token, 'GET', `/meta/object/${OFF_OBJECT}`);
288+
} finally {
289+
await target.stop();
290+
}
291+
}, 900_000);
292+
293+
afterAll(() => {
294+
if (previousHome === undefined) delete process.env.OS_HOME;
295+
else process.env.OS_HOME = previousHome;
296+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
297+
});
298+
299+
const objectsOf = (manifest: any): string[] => (manifest?.objects ?? []).map((o: any) => o?.name);
300+
301+
it('source: the package is created, its object published, and the disable lands (preconditions)', () => {
302+
for (const key of ['created', 'saved', 'published', 'disabled'] as const) {
303+
expect(answers[key]?.status, `${key}: ${JSON.stringify(answers[key]?.body)}`).toBeLessThan(300);
304+
}
305+
expect(answers.disabled.body?.data?.enabled).toBe(false);
306+
// CONTROL: enabled, the export carries the object; disabled, the served read hides it.
307+
expect(objectsOf(answers.enabledExport.body?.data)).toEqual([OFF_OBJECT]);
308+
expect(answers.servedAfterDisable.status).toBe(404);
309+
});
310+
311+
it('source: the disabled package\'s export and its bundle entry still carry its object', () => {
312+
expect(answers.disabledExport.status, JSON.stringify(answers.disabledExport.body)).toBe(200);
313+
expect(answers.disabledExport.body?.data).toEqual(answers.enabledExport.body?.data);
314+
expect(answers.bundle.status, JSON.stringify(answers.bundle.body)).toBe(200);
315+
expect(PackageExportBundleSchema.safeParse(answers.bundle.body?.data).success).toBe(true);
316+
expect(objectsOf(offEntry), JSON.stringify(offEntry)).toEqual([OFF_OBJECT]);
317+
});
318+
319+
it('target: the entry installs, and the object is served', () => {
320+
expect(answers.installed.status, JSON.stringify(answers.installed.body)).toBe(200);
321+
expect(answers.installed.body?.data?.manifestId).toBe(OFF);
322+
expect(answers.targetServed.status, JSON.stringify(answers.targetServed.body)).toBe(200);
323+
});
324+
});

0 commit comments

Comments
 (0)