Skip to content

Commit 8cc709d

Browse files
fix(objectql)!: withhold a disabled package's objectExtensions fields from the object they extend, and keep their columns (#30362)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgVBgbYzDpPvJK5AeiYUmnKk)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF ## 维护者速读 1. 客户在设置里停用一个模块后,这个模块加到别的对象上的字段(例如合同上的 O2C 汇总字段)现在也一起隐藏了,和模块自己的对象、菜单、动作一样。 2. 隐藏只管"展示和读写":对象元数据、数据接口的读写、记录页都看不到这些字段;数据库里的列和已存的值一个不动。 3. 重新启用后字段和原来的值原样回来;一个从"已停用"状态启动的环境也照样建好这些列,启用后马上能写。 4. 停用期间再写这些字段,回答和写一个对象上不存在的字段完全一样(400 INVALID_FIELD),没有新增拒绝码。 5. 需要你知道的一点:如果另一个仍启用的模块有校验规则读了被停用模块的字段,停用期间这个对象的写入会被该规则拦下(400 VALIDATION_FAILED,点名规则);公式读到的是空值,不会 500。 6. 这是一次对外对象形状的收窄(停用状态下少了字段),按 BREAKING 申报,合并前需要合同审查。 Fixes #30304 Clause-②: yes (narrowing) — while a package is disabled, the served object it extends (`GET /api/v1/meta/object/:name`, the record page, the `/data` field map) no longer carries the fields, validation rules and indexes the package's `objectExtensions` add, so a `/data` write, filter, sort or `fields` projection naming one is refused as a field the object does not have, and `GET /api/v1/packages/:id/customizations` no longer lists them among the base's `contributions`. Metadata-protocol: semantic ## What changed - **`packages/objectql/src/registry.ts`** — every served fold leaves out the `extend` contributions of a disabled package (`isPackageDisabled`, the predicate objects, flows, navigation and actions already use): - `resolveObject` (behind `getObject` / `getAllObjects`), the per-package fold (`withoutExtensionsOf` / `onlyExtensionsOf`) and `foldObjectExtendersOnto` (the protocol's by-name read over a metadata-service body); - a base layer that already carries a withheld field, rule or index (a tenant overlay saved from a served read, a metadata-service copy seeded from a resolved body) has it taken off (indexes compared by the fold's own structural key); a member the owner declares, or another enabled extension also adds, stays; - new `getStorageObject(name)` / `getAllStorageObjects()`: the fold with every contribution, disabled or not. With nothing disabled they are the served answer (same cache, same walk); - `installPackage` drops cached folds when the row lands disabled (a fold cached before the row existed read the package as enabled). - **Storage readers move to the full fold**, so a disable never drops, skips or re-shapes a column: - `ObjectQLPlugin` boot sync, `registerSchemasWithoutDdl` and the federated reconciliation (`readRegisteredObjects(seam, 'storage')`); the governance inventory keeps the served read; - `ObjectQL.syncSchemas` / `syncObjectSchema`; - the lifecycle sweep (`getAllStorageObjects` when the registry has it), so the archive datasource's schema keeps every column the hot rows carry; - `os migrate`'s composed coverage (`measureComposedCoverage`, `packages/cli/src/utils/schema-migration-plugins.ts`): its DDL-free branch (`OS_SKIP_SCHEMA_SYNC`) installs each object as the driver's managed field set with `registerObjectMetadata`, so on the served fold the drift report proposed dropping every column a disable only hides. It reads `getAllStorageObjects` (falling back to `getAllObjects`). - **`packages/runtime/src/domains/packages.ts`** — `readBaseContributions`' `field` loop skips a disabled package's contributor, as its `nav` loop does. This makes the spec docblock #30086 names (`package-lifecycle.zod.ts` "a disabled package's contributions MUST NOT be listed, as the fold drops them") true for fields. The array-level describe there still names only flows and navigation; that text is the spec lane's (#30086), not edited here. ## Measured, not assumed - **Disable scope.** Disable is per environment (`setPackageDisabled(environmentId, …)`, one registry row per kernel); there is no separate org-level disable to keep in step. Pinned through the real door, including a boot that replays the state file. - **Schema sync.** It read the served fold (`getAllObjects`). With the served fold narrowed and sync left on it, a boot that starts with the module disabled creates the table without the module's columns, and a write after enable answers 500 (measured in the ablation below). Hence the storage fold. - **Summary (`o2c_probe_total` shape).** The roll-up index is built from the served fold, so a withheld summary is not recomputed while disabled; its child object is refused (`OBJECT_PACKAGE_DISABLED`) anyway. Re-enable serves the stored value (pinned: 2000 back). - **Formula** on the object reading a withheld field answers `null` (200, no 5xx). - **Filter / sort / `fields`** naming a withheld field: byte-identical to naming an unknown field (`400 INVALID_FIELD` / `INVALID_SORT`), pinned by comparison. - **Write naming a withheld field**: byte-identical to an unknown field (`400 INVALID_FIELD`, `Unknown field '…' on object '…'`), create and update. No new code. ## Tests - `packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts` — real doors (`HttpDispatcher` → `/packages/:id/disable|enable`, `/meta/object`, `/data`, `/packages/:id/customizations`) over a file-backed SQLite: control, disable, unknown-field parity, other-field writes, contributions list, re-enable with values, disabled-at-boot column creation, and the cross-module formula / rule / query measurements. - `packages/objectql/src/registry-disabled-package-extensions.test.ts` — the four served readers, the storage readers, a folded base and a tenant overlay (fields, rules and indexes), a row that lands disabled after a cached fold. - `packages/cli/src/utils/schema-migration-plugins.test.ts` — a top-level describe over a real SQLite `SqlDriver` and a real `SchemaRegistry`: a table created with the module enabled, the module disabled, `measureComposedCoverage(…, { ddlFree: true })`, then `detectManagedDrift()` proposes no drop of the hidden column and the stored value stays. Ablations (one-time, via `scripts/ablation-replace.mjs`, restored to `HEAD` with `git diff HEAD` empty; dist restore checked with `ablation-dist-preflight --absent`): | Mutation | Result | | --- | --- | | `withholdDisabledExtensions` always answers `undefined` | registry suite 7 failed / 17 | | boot sync and `syncSchemas` read the served fold (objectql rebuilt) | runtime suite 1 failed / 11: write after enable `500` | | `readBaseContributions` field loop without the disabled skip | runtime suite 1 failed / 11 | | `measureComposedCoverage` reads `getAllObjects` (served) | cli pin red: `expected [ 'drop_column:qm_credit' ] to deeply equal []` | | withheld indexes not taken off a folded base | registry suite 2 failed / 17 (folded base, tenant overlay) | ## Gates (at `f2ce2c0f7`) - `node scripts/pm/dispatch-gates.mjs --commands` on this diff: 65 families, all run, all exit 0. `--ran`: 65 derived, 65 run, 0 NOT-MEASURED. - `pnpm --filter @objectstack/cli typecheck` / `@objectstack/objectql typecheck` / `@objectstack/runtime typecheck`: exit 0 (cli's test layer holds its ledger: 28 errors / 6 pinned signatures, unchanged). - `pnpm lint`: exit 0 (at `12d54cf31`, before the review fixes). - `@objectstack/objectql` full suite before the merge of `origin/main`: 552 files, the one red file (`plugin-registry-read-failure.test.ts`, 5 cases) fixed by scoping the storage read to the DDL seams. At `f2ce2c0f7`: the registry pins and `plugin-registry-read-failure.test.ts` green. - `@objectstack/cli` `schema-migration-plugins.test.ts` and the runtime route pin: green at `f2ce2c0f7`. - Not run locally (CI's): `test:repo`, dogfood, the whole-tree families the derivation leaves to CI. ## Contract review Round 1 FAIL (6653307077): M1 (`measureComposedCoverage` on the served fold) and S1 (a withheld extension's indexes kept on a folded base) are fixed in `024ea96e5`, the pin's type in `f2ce2c0f7`. S2 (the roll-up index reads the served fold) and S3 (another package's rule reading a withheld field) are filed by the seat, not fixed here. ## Acceptance notes - **Rule of another enabled package reading a withheld field** (measured, kept as is): it cannot be evaluated, so every write to the object is refused `400 VALIDATION_FAILED` naming the rule until the field's package is enabled again. This is the answer any rule reading an undeclared field already gets (fail closed). A suite whose modules read each other's fields should declare that dependency; whether a disable should then be refused, or such a rule skipped, is a ruling, not this card. - **UI residue:** none required by the served object: the record page draws from `/meta/object`. A page or form layout that names a withheld field explicitly is not measured here (ui lane paused). - **Layered read** `GET /meta/object/:name?layers=true` measured while disabled: `code` and `effective` carry none of the module's fields (the code layer is the metadata-service copy folded by `foldObjectExtendersOnto`). The registry's own code-layer lookup (`resolveOwnerLayer` → `getArtifactItem`, artifact identity for the save gates and the fallback when the service holds no copy) still folds every contribution: it answers "what does the code ship". 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CbdPwGwxWkDNgrE4LE <!-- signed: domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 22f0d65 commit 8cc709d

10 files changed

Lines changed: 857 additions & 15 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/cli': minor
5+
---
6+
7+
fix(objectql)!: a disabled package's `objectExtensions` fields are withheld from the object they extend, and come back with their values on enable
8+
9+
Clause-②: yes (narrowing) — while a package is disabled, the served object it extends (`GET /api/v1/meta/object/:name`, the record page, the `/data` field map) no longer carries the fields, validation rules and indexes the package's `objectExtensions` add, so a `/data` write, filter, sort or `fields` projection naming one of those fields is refused as a field the object does not have (`400 INVALID_FIELD` / `INVALID_SORT`), and `GET /api/v1/packages/:id/customizations` no longer lists them among the base's `contributions`.
10+
Metadata-protocol: semantic
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and nothing stored changes: the extension, its columns and its values stay as they are, and enabling the package serves them again. -->
13+
14+
**BREAKING** served-object narrowing under package disable (`@objectstack/objectql`, `@objectstack/runtime`, `@objectstack/cli`).
15+
16+
- **Before.** Disabling a package withheld its objects (`404 OBJECT_PACKAGE_DISABLED`), flows, navigation items, permissions and actions, but the fields its `objectExtensions` add to another package's object stayed on that object: in `/meta/object`, in every `/data` read and on the record page.
17+
- **Now.** The served fold of an object leaves out every `extend` contribution of a disabled package: its fields, its validation rules, its indexes and its field patches. A base layer that already carries them (a tenant overlay saved from a served read, a metadata-service copy of a resolved object) has those fields and rules taken off too. A field the owner itself declares, or another enabled extension also adds, stays. A write naming a withheld field answers exactly what a write naming an unknown field answers; a write through the object's other fields lands and leaves the withheld values alone.
18+
- **Kept.** Schema sync, the federated binding and the lifecycle sweep read the object with every extension folded in (`SchemaRegistry.getStorageObject` / `getAllStorageObjects`), so a disable never drops or skips a column, and a boot that starts with the package disabled still creates them. Enabling the package serves the fields again with the stored values. With `OS_SKIP_SCHEMA_SYNC=1`, `os migrate`'s drift plan no longer reports a disabled module's columns as orphan columns to drop.
19+
- **Who moves.** A client that reads a disabled module's extension fields gets the object without them. A formula on the object that reads a withheld field answers `null`. A validation rule of another, enabled package that reads a withheld field cannot be evaluated, so writes to the object are refused (`400 VALIDATION_FAILED`, naming the rule) until the field's package is enabled again or the rule's package declares and keeps that package.

‎packages/cli/src/utils/schema-migration-plugins.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { describe, it, expect, afterEach, beforeEach } from 'vitest';
55
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
66
import { tmpdir } from 'node:os';
77
import { join } from 'node:path';
8+
import { SqlDriver } from '@objectstack/driver-sql';
9+
import { SchemaRegistry } from '@objectstack/objectql';
810
import {
911
findHostConfig,
1012
composeForDeclarations,
@@ -803,6 +805,66 @@ describe('measureComposedCoverage (#13028)', () => {
803805
});
804806
});
805807

808+
/**
809+
* A disabled package's `objectExtensions` are withheld from the SERVED object
810+
* and kept at the storage layer. The DDL-free binding (`OS_SKIP_SCHEMA_SYNC`)
811+
* installs each object it reads as the driver's managed field set, which is
812+
* what the drift report diffs the live table against, so it must read the
813+
* storage fold: on the served one, a column a disable only hides is reported
814+
* as an orphan to drop. The one case in this file that reaches a real driver,
815+
* because the answer at stake is that driver's own drift report.
816+
*/
817+
describe('measureComposedCoverage, DDL-free, with a disabled package\'s extension', () => {
818+
const APP = 'com.qa.migrate.app';
819+
const MODULE = 'com.qa.migrate.orders';
820+
const OBJECT = 'qm_account';
821+
let dir: string | undefined;
822+
const drivers: SqlDriver[] = [];
823+
afterEach(async () => {
824+
while (drivers.length) await drivers.pop()!.disconnect().catch(() => {});
825+
if (dir) rmSync(dir, { recursive: true, force: true });
826+
dir = undefined;
827+
});
828+
829+
const sqlDriver = (file: string): SqlDriver => {
830+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true });
831+
(driver as any).logger = { debug() {}, info() {}, warn() {}, error() {} };
832+
drivers.push(driver);
833+
return driver;
834+
};
835+
836+
it('binds the column the disable hides, so the drift report proposes no drop of it and the value stays', async () => {
837+
const registry = new SchemaRegistry({ multiTenant: false });
838+
(registry as any).logLevel = 'silent';
839+
registry.installPackage({ id: APP, name: APP, version: '1.0.0', type: 'app', namespace: 'qm' } as any, 'code');
840+
registry.installPackage({ id: MODULE, name: MODULE, version: '1.0.0', type: 'module', namespace: 'qm', dependencies: { [APP]: '^1.0.0' } } as any, 'code');
841+
registry.registerObject({ name: OBJECT, fields: { name: { name: 'name', type: 'text' } } } as any, APP, undefined, 'own');
842+
registry.registerObject({ name: OBJECT, fields: { qm_credit: { name: 'qm_credit', type: 'number' } } } as any, MODULE, undefined, 'extend');
843+
844+
// The table as a boot with the module enabled created it, holding a value.
845+
dir = mkdtempSync(join(tmpdir(), 'os-migrate-disabled-ext-'));
846+
const file = join(dir, 'data.db');
847+
const first = sqlDriver(file);
848+
await first.initObjects([registry.getStorageObject(OBJECT) as any]);
849+
await (first as any).knex(OBJECT).insert({ id: 'a1', name: 'acme', qm_credit: 500, organization_id: 'org_a' });
850+
await first.disconnect();
851+
drivers.pop();
852+
853+
registry.disablePackage(MODULE);
854+
expect(Object.keys(registry.getObject(OBJECT)?.fields ?? {}), 'precondition: the served object withholds it').not.toContain('qm_credit');
855+
856+
const driver = sqlDriver(file);
857+
const engine = { registry, getDriverForObject: () => driver, syncObjectSchema: async () => { throw new Error('DDL on a DDL-free plan'); } };
858+
const kernel = { getService: (slot: string) => (slot === 'objectql' ? engine : undefined) };
859+
const out = await measureComposedCoverage(kernel, [driver], false, { ddlFree: true });
860+
expect(out.coverage).toMatchObject({ registeredObjects: 1, examinedObjects: 1 });
861+
862+
const drift = (await driver.detectManagedDrift()).map((d: any) => `${d.op?.type}:${d.op?.column ?? d.op?.indexName ?? ''}`);
863+
expect(drift.filter((op) => op.includes('qm_credit'))).toEqual([]);
864+
expect(await (driver as any).knex(OBJECT).where({ id: 'a1' }).first()).toMatchObject({ qm_credit: 500 });
865+
});
866+
});
867+
806868
/**
807869
* [#22506] What `os migrate plan` / `apply` compose AROUND the stack
808870
* (`servedPlatform`): the auth family behind `serve`'s auth gate, the provider

‎packages/cli/src/utils/schema-migration-plugins.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2399,7 +2399,13 @@ export async function measureComposedCoverage(
23992399
// the injected-columns plan's input, so it is installed before one object
24002400
// is bound. See this function's docblock.
24012401
notes.push(...installDeploymentPlatformGlobalObjects(kernel, engine?.registry));
2402-
const objects: unknown = engine?.registry?.getAllObjects?.();
2402+
// The STORAGE layer's objects: a disabled package's `objectExtensions`
2403+
// folded in. The DDL-free binding below installs each one as the driver's
2404+
// managed field set, so a served fold here would make the drift report
2405+
// propose dropping every column a disable only hides.
2406+
const objects: unknown = typeof engine?.registry?.getAllStorageObjects === 'function'
2407+
? engine.registry.getAllStorageObjects()
2408+
: engine?.registry?.getAllObjects?.();
24032409
if (
24042410
!Array.isArray(objects)
24052411
|| typeof engine?.getDriverForObject !== 'function'

‎packages/objectql/src/engine-compatibility-api.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ export async function introspectDatasource(
117117
export async function syncSchemas(
118118
this: Pick<CompatibilityApiHost, '_registry' | 'getDriverForObject' | 'logger'>,
119119
): Promise<void> {
120-
const allObjects = this._registry.getAllObjects();
120+
// The storage layer's objects: a disabled package's extensions folded in,
121+
// so the DDL keeps their columns (`SchemaRegistry.getStorageObject`).
122+
const allObjects = this._registry.getAllStorageObjects();
121123
for (const obj of allObjects) {
122124
const driver = this.getDriverForObject(obj.name);
123125
if (!driver) continue;
@@ -187,7 +189,8 @@ export async function syncObjectSchema(
187189
this: Pick<CompatibilityApiHost, '_registry' | 'getDriverForObject'>,
188190
objectName: string,
189191
): Promise<void> {
190-
const obj = this._registry.getObject(objectName) as any;
192+
// The storage layer's object, as {@link syncSchemas} reads it.
193+
const obj = this._registry.getStorageObject(objectName) as any;
191194
if (!obj) return;
192195
const driver = this.getDriverForObject(objectName);
193196
if (!driver) return;

‎packages/objectql/src/lifecycle/lifecycle-service.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ export interface LifecycleEngineLike {
100100
* exactly as it did before this member existed. Every real engine has it.
101101
*/
102102
getObject?(name: string): unknown;
103+
/**
104+
* The objects as the storage layer sees them: a disabled package's
105+
* `objectExtensions` folded in (`SchemaRegistry.getAllStorageObjects`).
106+
* The sweep reads these, so the archive datasource's schema keeps every
107+
* column the hot rows it copies carry. Optional, as `getObject` is: a
108+
* double modelling only `getAllObjects` is read through that.
109+
*/
110+
getAllStorageObjects?(): LifecycleObjectLike[];
103111
};
104112
delete(
105113
object: string,
@@ -733,8 +741,9 @@ export class LifecycleService {
733741

734742
this.sweeping = true;
735743
try {
736-
const declared = engine.registry
737-
.getAllObjects()
744+
const declared = (typeof engine.registry.getAllStorageObjects === 'function'
745+
? engine.registry.getAllStorageObjects()
746+
: engine.registry.getAllObjects())
738747
.filter((o) => o?.lifecycle && o.lifecycle.class !== 'record');
739748

740749
// Governance snapshot (P4): settings-driven overrides / quotas.

‎packages/objectql/src/plugin.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2099,8 +2099,9 @@ export class ObjectQLPlugin implements Plugin {
20992099
* exactly the incomplete test double #9154 measured.
21002100
*
21012101
* @param seam - the calling method, named in the thrown message.
2102+
* @param layer - `storage` for a DDL / binding reader, `served` otherwise.
21022103
*/
2103-
private readRegisteredObjects(seam: string): ServiceObject[] {
2104+
private readRegisteredObjects(seam: string, layer: 'served' | 'storage' = 'served'): ServiceObject[] {
21042105
const registry: any = (this.ql as any)?.registry;
21052106
const read: unknown = registry?.getAllObjects;
21062107
if (registry == null || typeof read !== 'function') {
@@ -2114,6 +2115,13 @@ export class ObjectQLPlugin implements Plugin {
21142115
'`getAllObjects()` — a duck-typed engine or an incomplete test double is the only way to reach this line.',
21152116
);
21162117
}
2118+
// A STORAGE reader (DDL, the federated binding) reads the objects with
2119+
// every extension folded in, a disabled package's included: a disable must
2120+
// not take a column away from the data it keeps
2121+
// (`SchemaRegistry.getAllStorageObjects`). A registry without that read
2122+
// answers its served fold, as before.
2123+
const storage: unknown = registry.getAllStorageObjects;
2124+
if (layer === 'storage' && typeof storage === 'function') return (storage as () => ServiceObject[]).call(registry);
21172125
return (read as () => ServiceObject[]).call(registry);
21182126
}
21192127

@@ -2186,7 +2194,7 @@ export class ObjectQLPlugin implements Plugin {
21862194
// looks healthy while declared data is simply not reachable.
21872195
let allObjects: ServiceObject[];
21882196
try {
2189-
allObjects = this.readRegisteredObjects('reconcileFederatedBindings');
2197+
allObjects = this.readRegisteredObjects('reconcileFederatedBindings', 'storage');
21902198
} catch (e: unknown) {
21912199
ctx.logger.error(
21922200
'Federated (external) object reconciliation did NOT run — the object registry could not be read, so this boot ' +
@@ -2338,7 +2346,7 @@ export class ObjectQLPlugin implements Plugin {
23382346
// [#9285] Same propagation contract as the sync pass: "the registry holds
23392347
// nothing" and "the registry could not be read" have opposite consequences
23402348
// and only the first is a truthful reason to register nothing.
2341-
const allObjects = this.readRegisteredObjects('registerSchemasWithoutDdl');
2349+
const allObjects = this.readRegisteredObjects('registerSchemasWithoutDdl', 'storage');
23422350
if (allObjects.length === 0) return;
23432351

23442352
const groups = new Map<any, ServiceObject[]>();
@@ -2423,7 +2431,7 @@ export class ObjectQLPlugin implements Plugin {
24232431
// against a store whose DDL was never run. On the `metadata:reloaded` path
24242432
// the caller already catches this and reports it at `error` (#4632), so
24252433
// propagation there is a loud durability report, not a dead kernel.
2426-
const allObjects = this.readRegisteredObjects('syncRegisteredSchemas');
2434+
const allObjects = this.readRegisteredObjects('syncRegisteredSchemas', 'storage');
24272435
if (allObjects.length === 0) return;
24282436

24292437
let synced = 0;

0 commit comments

Comments
 (0)