Skip to content

Commit 4bf30da

Browse files
feat(runtime): export the package disable door's dependent-rule verdict and its sentence on the package entry (#31157)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiUfC8G7ZWNaCdELoQcXkaCC)_ Fixes #31067 Clause-②: yes Metadata-protocol: none ## 维护者速读 - 改了什么:平台把「停用包」时检查依赖校验规则的判定函数和拒绝提示语,作为 `@objectstack/runtime` 的公开导出开放出来(`readPackageDisableDependents`、`formatPackageDisableDependents`、`DisableDependentRule` 类型);平台自己的停用接口用的就是同一份。 - 为什么改:cloud 控制面的「按安装停用」和软卸载会绕过这道检查,导致对象之后的写入全被拒;按裁决「平台开放检查,cloud 接上(推荐)」,平台开放、cloud 接上,检查逻辑和提示语只保留一份。 - 风险与回滚:只是新增导出,现有接口行为不变;公开面变宽(Clause-② yes),需要席位做契约复审。回滚即撤掉这次导出。 - 先看:`packages/runtime/src/index.ts`(新增的三项导出)、`packages/runtime/src/domains/packages.ts`(两个函数加 `export` 和说明)、测试文件末尾新增的 describe。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-disable-module-extension-fields.test.ts`,预期 33 个用例全部通过(其中 4 个是新增的)。 - 需要你做的:无,席位落地。 ## What changes - `packages/runtime/src/domains/packages.ts`: `readDisableDependentRules` and `disableDependentRulesMessage` gain `export` and a JSDoc paragraph naming their public spelling. No line of either body changes, and both call sites (`PATCH /packages/:id/disable` and the `POST /packages` `enableOnInstall: false` arm) still call them directly. - `packages/runtime/src/index.ts`: the package entry re-exports them as `readPackageDisableDependents` and `formatPackageDisableDependents`, named after what each answers, with the `DisableDependentRule` type beside them. The door's function and the entry's export are the same function object, so there is one scan and one sentence. The test pins that identity. - `.changeset/31067-runtime-export-disable-dependents.md`: `@objectstack/runtime` patch, carrying both declaration lines. Clause-②: the package entry's public surface widens by two functions and one type. The seat owes the contract review. ## Tests A new top-level describe in the existing harness file `packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts`, `[#31067] the verdict the disable door asks is the package entry's export`. It uses a bare `SchemaRegistry` plus the real `HttpDispatcher` and boots no kernel. It has four cases: 1. Identity: `runtime.readPackageDisableDependents` is `readDisableDependentRules`, and `runtime.formatPackageDisableDependents` is `disableDependentRulesMessage`. 2. The verdict answers the one active rule (the switched-off rule is left out), writes nothing (the package stays enabled and the field stays served), and answers `[]` for a package that withholds nothing and for a registry it cannot read. 3. The door's `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` message equals exactly what the exported builder spells over the exported verdict. The sentence is also spelled out literally once. 4. Control: once the reading package is disabled, the verdict is `[]` and the door answers `200`. The existing #30825 / #30379 / #30756 batteries in the same file are untouched and green. ## Acceptance notes - The code `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` is not exported from runtime. It is already in the `@objectstack/spec` error-code ledger (`ApiError['code']`), so a host door can spell it type-checked from there. Exporting the runtime wire pair as well would be a second public spelling of the same code. - `registry` stays typed `any`, as the door passes it. The verdict probes `getObjectContributors` / `getAllObjects` and answers `[]` for anything without them. A narrower structural type would be a separate contract decision. - The uninstall arm and the `DELETE` door are not touched. #30999 and #31023 queue behind this card. - The ablation was skipped on purpose: no tested behaviour changes. Case 1 is the pin that can fail: re-pointing the alias at any other function reds it. ## Verification Tree `objectstack-ai/objectstack`. The runtime suite and the first gate sweep ran on `aae146a8` (merge base `5f003744`). `origin/main` (`cadd6aa2`) was then merged as `72296d6c`. Its incoming paths touch `formula`, `plugin-auth`, `service-settings` and `platform-objects`, and none touches `runtime` or `objectql`. The delta against main is still exactly these 4 files. After a rebuild, the scoped re-checks and the whole derived gate sweep ran again on `72296d6c`. <details><summary>Runtime package</summary> - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` :: exit 0 (on `aae146a8`). Test Files 554 passed (554), Tests 8918 passed / 19 skipped. - After the merge, on `72296d6c`: `pnpm build` (119/119) `&& pnpm --filter @objectstack/runtime typecheck && vitest run` of the three disable-door files (`packages-disable-module-extension-fields`, `packages-disableable-verdict`, `packages-disable-app-module`) :: exit 0. Test Files 3/3, Tests 82/82. - `pnpm --filter @objectstack/runtime typecheck` :: exit 0. The test layer holds 27 files / 186 errors / 66 pinned signatures, unchanged. - `eslint packages/runtime --no-inline-config` :: exit 0. - Harness file wall time: base 17.02s (29 tests) against head 17.90s (33 tests), measured back to back under the lock. </details> <details><summary>Derived gates (dispatch-gates --commands)</summary> On `72296d6c`, all 62 derived commands exit 0, each exit code captured before any pipe (`check:type-check-debt` ran under the verify lock). `dispatch-gates --ran`: 62 derived, 62 run, 0 NOT-MEASURED (a derived zero: every entry carries an exit code). On the first sweep (`aae146a8`), three commands exited 3 and were rerun green. `check-plugin-teardown-shape --self-test` could not reach its control commit on the shallow clone; that commit was fetched into a private ref. `check:dual-build-cjs-loads` needed a full `pnpm build`. `check:type-check-debt` timed out in the lock queue. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 13aa6a3 commit 4bf30da

4 files changed

Lines changed: 138 additions & 2 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
feat(runtime): `@objectstack/runtime` exports the package disable door's dependent-rule verdict, `readPackageDisableDependents`, and its sentence, `formatPackageDisableDependents`, with the `DisableDependentRule` type
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
`readPackageDisableDependents(registry, packageId)` answers the validation rules a disable of the package would leave reading a field it adds through `objectExtensions`: each as `{ packageId, object, rule, fields }`, from any enabled package (declared dependent or not, an app's `.custom` pair included), ordered by package, object and rule. It reads the registry and writes nothing, and answers an empty list for a package that withholds nothing. `formatPackageDisableDependents(lead, dependents)` spells the refusal's message: `lead`, every rule as `package · object · rule (reads FIELD, …)`, then the remedy.
11+
12+
They are the same two functions `PATCH /packages/:id/disable` and `POST /packages` with `enableOnInstall: false` ask, now on the package entry, so a host door that takes a package out of service another way (a control plane's per-install disable, a soft-disable uninstall) asks the same scan before its write and answers the same `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` (the code is in the `@objectstack/spec` error-code ledger) instead of keeping a second copy. Nothing existing changes: every door answers exactly as before.

‎packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import { resolveThrownHttpError } from '@objectstack/types';
5858
import { AppPlugin } from '../app-plugin.js';
5959
import { HttpDispatcher } from '../http-dispatcher.js';
6060
import { loadDisabledPackageIds, setPackageDisabled } from '../package-state-store.js';
61+
import * as runtime from '../index.js';
62+
import { disableDependentRulesMessage, readDisableDependentRules } from './packages.js';
6163

6264
const ENVIRONMENT_ID = 'env_suite_extension_fields';
6365
const ADMIN = {
@@ -1132,3 +1134,101 @@ describe('[#30756] the enable door judges the rules on an object the package own
11321134
expect(registry.getPackage(OWNER)?.enabled).toBe(true);
11331135
});
11341136
});
1137+
1138+
describe('[#31067] the verdict the disable door asks is the package entry\'s export', () => {
1139+
const OWNER = 'com.qa.verdict.owner';
1140+
const READER = 'com.qa.verdict.reader';
1141+
const EXTENDER = 'com.qa.verdict.flags';
1142+
const THING = 'qv_thing';
1143+
const FLAG = 'qv_flag';
1144+
const RULE = 'qv_flag_required';
1145+
const RULE_OFF = 'qv_flag_switched_off';
1146+
const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE';
1147+
const NAMESPACES: Record<string, string> = { [OWNER]: 'qv', [READER]: 'qw', [EXTENDER]: 'qx' };
1148+
const manifestOf = (id: string) => ({ id, name: id, namespace: NAMESPACES[id], version: '1.0.0', type: 'module' });
1149+
1150+
let previousHome: string | undefined;
1151+
beforeAll(() => {
1152+
previousHome = process.env.OS_HOME;
1153+
process.env.OS_HOME = mkdtempSync(join(home, 'disable-verdict-'));
1154+
});
1155+
afterAll(() => {
1156+
process.env.OS_HOME = previousHome;
1157+
});
1158+
1159+
/**
1160+
* The owner's object, the extender's field on it, and an ENABLED reader's two rules on it reading that field,
1161+
* one switched off: the extender's disable would withhold the field from the one active rule.
1162+
*/
1163+
async function door() {
1164+
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' } as any);
1165+
(registry as any).logLevel = 'silent';
1166+
const kernel: any = {
1167+
getService: (name: string) => (name === 'objectql' ? Promise.resolve({ registry }) : null),
1168+
context: { getService: () => null },
1169+
};
1170+
const dispatcher = new HttpDispatcher(kernel);
1171+
const context = {
1172+
request: {},
1173+
executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] },
1174+
} as any;
1175+
for (const id of [OWNER, READER, EXTENDER]) {
1176+
const installed = await dispatcher.handlePackages('', 'POST', { manifest: manifestOf(id) }, {}, context);
1177+
expect(installed.response?.status, `precondition: ${id} installs`).toBe(201);
1178+
}
1179+
registry.registerObject({ name: THING, label: 'Thing', fields: { name: { name: 'name', type: 'text', label: 'Name' } } } as any, OWNER);
1180+
registry.registerObject({ name: THING, fields: { [FLAG]: { name: FLAG, type: 'boolean', label: 'Flag' } } } as any, EXTENDER, undefined, 'extend');
1181+
registry.registerObject({
1182+
name: THING,
1183+
validations: [
1184+
{ name: RULE, type: 'script', severity: 'error', message: 'Flag it', condition: `record.${FLAG} == null` },
1185+
{ name: RULE_OFF, type: 'script', severity: 'error', message: 'Off', condition: `record.${FLAG} == true`, active: false },
1186+
],
1187+
} as any, READER, undefined, 'extend');
1188+
const disable = (id: string) => dispatcher.handlePackages(`/${id}/disable`, 'PATCH', {}, {}, context);
1189+
return { registry, disable };
1190+
}
1191+
1192+
it('is the one function the door asks, exported on the package entry with its type beside it', () => {
1193+
expect(runtime.readPackageDisableDependents).toBe(readDisableDependentRules);
1194+
expect(runtime.formatPackageDisableDependents).toBe(disableDependentRulesMessage);
1195+
});
1196+
1197+
it('answers the active rules a disable would leave reading a withheld field, and writes nothing', async () => {
1198+
const { registry } = await door();
1199+
const dependents: runtime.DisableDependentRule[] = runtime.readPackageDisableDependents(registry, EXTENDER);
1200+
expect(dependents).toEqual([{ packageId: READER, object: THING, rule: RULE, fields: [FLAG] }]);
1201+
// Pure: the package stays enabled and the field stays served.
1202+
expect(registry.getPackage(EXTENDER)?.enabled).not.toBe(false);
1203+
expect(Object.keys(registry.getObject(THING)?.fields ?? {})).toContain(FLAG);
1204+
// A package that withholds nothing has no dependents; nor does a registry the verdict cannot read.
1205+
expect(runtime.readPackageDisableDependents(registry, READER)).toEqual([]);
1206+
expect(runtime.readPackageDisableDependents({}, EXTENDER)).toEqual([]);
1207+
});
1208+
1209+
it('the door answers 409 with exactly the sentence the exported builder spells over the exported verdict', async () => {
1210+
const { registry, disable } = await door();
1211+
const expected = runtime.formatPackageDisableDependents(
1212+
`Package '${EXTENDER}' cannot be disabled`,
1213+
runtime.readPackageDisableDependents(registry, EXTENDER),
1214+
);
1215+
expect(expected).toBe(
1216+
`Package '${EXTENDER}' cannot be disabled: an active validation rule reads a field it adds through objectExtensions, `
1217+
+ `which the disable would withhold — ${READER} · ${THING} · ${RULE} (reads ${FLAG}). Switch off or change that rule first, `
1218+
+ 'or keep the package enabled.',
1219+
);
1220+
const result: any = await disable(EXTENDER);
1221+
expect({ status: result.response?.status, code: result.response?.body?.error?.code }).toEqual({ status: 409, code: CODE });
1222+
expect(String(result.response?.body?.error?.message)).toBe(expected);
1223+
expect(registry.getPackage(EXTENDER)?.enabled).not.toBe(false);
1224+
});
1225+
1226+
it('control: once the reading package is disabled the verdict is empty, and the door proceeds', async () => {
1227+
const { registry, disable } = await door();
1228+
expect((await disable(READER)).response?.status).toBe(200);
1229+
expect(runtime.readPackageDisableDependents(registry, EXTENDER)).toEqual([]);
1230+
const result: any = await disable(EXTENDER);
1231+
expect(result.response?.status, String(result.response?.body?.error?.message)).toBe(200);
1232+
expect(registry.getPackage(EXTENDER)?.enabled).toBe(false);
1233+
});
1234+
});

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,8 +1051,18 @@ function declaredRulesOf(body: unknown): Array<{ name?: unknown; active?: unknow
10511051
*
10521052
* Ordered by package, object and rule. Empty for a package already disabled
10531053
* (the disable withholds nothing more) and for one that extends nothing.
1054+
*
1055+
* ## On the package entry
1056+
*
1057+
* Exported as `readPackageDisableDependents` from `@objectstack/runtime`, with
1058+
* {@link disableDependentRulesMessage} as `formatPackageDisableDependents`, so
1059+
* a host door that takes a package out of service another way (a control
1060+
* plane's per-install disable, a soft-disable uninstall) asks the scan this
1061+
* door asks and answers the sentence it answers, instead of a second copy of
1062+
* either. Pure over `registry`: it reads the registry and writes nothing, so
1063+
* a host asks it BEFORE its own write, as this door does.
10541064
*/
1055-
function readDisableDependentRules(registry: any, packageId: string): DisableDependentRule[] {
1065+
export function readDisableDependentRules(registry: any, packageId: string): DisableDependentRule[] {
10561066
if (typeof registry?.getObjectContributors !== 'function' || typeof registry?.getAllObjects !== 'function') return [];
10571067
const disabled = (id: unknown): boolean =>
10581068
typeof registry.isPackageDisabled === 'function' && registry.isPackageDisabled(id);
@@ -1178,8 +1188,11 @@ function ruleLayerLabel(layer: { packageId?: unknown; ownership?: unknown }): st
11781188
/**
11791189
* The sentence a dependent-rule refusal carries: `lead` (what was refused),
11801190
* then every rule as `package · object · rule (reads FIELD, …)`, then the remedy.
1191+
* Exported as `formatPackageDisableDependents` from `@objectstack/runtime`
1192+
* beside {@link readDisableDependentRules}; a host door answers it with `409`
1193+
* `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, as this door does.
11811194
*/
1182-
function disableDependentRulesMessage(lead: string, dependents: readonly DisableDependentRule[]): string {
1195+
export function disableDependentRulesMessage(lead: string, dependents: readonly DisableDependentRule[]): string {
11831196
const one = dependents.length === 1;
11841197
const listed = dependents
11851198
.map((d) => `${d.packageId} · ${d.object} · ${d.rule} (reads ${d.fields.join(', ')})`)

‎packages/runtime/src/index.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,17 @@ export type { KernelResolver } from './http-dispatcher.js';
249249
export { DomainHandlerRegistry } from './domain-handler-registry.js';
250250
export type { DomainRoute, DomainHandler, DomainRequest, DomainHandlerDeps } from './domain-handler-registry.js';
251251
export { MiddlewareManager } from './middleware.js';
252+
// The package disable door's dependent-rule verdict and its sentence, for a
253+
// host door that takes a package out of service another way: the validation
254+
// rules a disable would leave reading a withheld `objectExtensions` field, and
255+
// the `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` message naming them.
256+
// The same two functions `PATCH /packages/:id/disable` asks: one scan, one
257+
// sentence. ⛔ Never re-spell either in a host.
258+
export {
259+
readDisableDependentRules as readPackageDisableDependents,
260+
disableDependentRulesMessage as formatPackageDisableDependents,
261+
} from './domains/packages.js';
262+
export type { DisableDependentRule } from './domains/packages.js';
252263

253264
// ── Security primitives ───────────────────────────────────────────────
254265
// Adapter-agnostic helpers for response hardening (CSP/HSTS/XCTO/…) and

0 commit comments

Comments
 (0)