Skip to content

Commit cea0871

Browse files
refactor(objectql): move the package registration bodies out of engine.ts into engine-package-registration.ts (#25522 cut 17) (#26881)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X5TDYgc33GQV9hiYvxWt8f8)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 + 6441032442 ## 维护者速读 - **改了什么**:把 `engine.ts` 里包注册区的三个方法体(`convertPackageBodyForward` 注册门的协议前向转换、`registerPlugin` 嵌套插件注册、`resolveNavObjectNames` 导航对象名解析)原样搬进新文件 `engine-package-registration.ts`;类上的方法签名、注释、位置不变,方法体换成转交。两者与留下的 `registerApp` 共用的 `stampPackage` / `stampObjectPackage` 原地加 `export`、由新文件导入(不复制)。`engine.ts` 21,733 → 21,661 行。 - **为什么改**:#25522 按缝拆分 `engine.ts` 的第 17 刀。本区原定价 −233,但 #26337 落地时在 `packages/spec/liveness/` 里把 `engine.ts#registerMetadataCollections` 登记为生产者(18 处),它按系列规则留在原文件;剩下可搬的三个方法重定价 −75,仍高于 −30,按派单取本区。 - **风险与回滚**:纯搬移,无行为变化;公开 `.d.ts` 六个文件与基线逐字节相同(含正控);运行期导出名单相同;构建产物里只多出新文件一个区段,外部包的导入顺序一条不差(新文件在 `engine.ts` 原先导入 `@objectstack/metadata-core` 的位置导入);一次性晚绑定探针在新旧两版构建产物上逐行一致。回滚 = revert 本 PR。 - **先看**:`packages/objectql/src/engine-package-registration.ts`(三个函数与 `PackageRegistrationHost`)、`packages/objectql/src/engine.ts` 里三个转交桩、新的导入行与两处原地 `export`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 src/engine-nested-plugin-collections.test.ts src/registry-installed-package-retired-page-declarations.test.ts`,应见两个文件全部通过(嵌套插件注册与注册门前向转换各自的测试)。 - **需要你做的**:无,席位落地。 Cut 17 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`), under the claim `6450667239`. Three bodies of the "Package, app, plugin and driver registration" section move unedited into a new module, `packages/objectql/src/engine-package-registration.ts`, behind `PackageRegistrationHost`: `convertPackageBodyForward` (ADR-0087 D2's forward conversion at the package registration door), `registerPlugin` (a nested plugin's objects, plugin-as-app and stack collections, registered under its parent package) and `resolveNavObjectNames` (the navigation `objectName` resolution). No behaviour changes. Every published declaration file is byte-identical to the base. #25522 stays open for the later cuts. Branch `claude/project-thread-y39a3j` (the card thread's harness-pinned branch), cut from `origin/main` at `e1bfde3601`, which contains cut 16's merge (`d8b943b620`, #26792) and #26337. ## What changes ### Why this area, and why it now pays -75 and not -233 Re-measured on `e1bfde3601` with cut 11's measure script and cut 14's price step (`price3.mjs`), both unchanged. `engine.ts` is 21,733 lines, 6,745 of them from the last 30 days (`git blame --since=2026-09-27T18:45Z`, clone deepened to 2026-09-21 first); `class ObjectQL` holds 324 members, 259 methods and accessors. Query-options erasure sites: 8 at base (lines 8372, 9690, 9936, 10000, 10332, 13612, 13911, 18522), none in this area. Cut 16's table priced the section at -233 with four bodies, `registerMetadataCollections` (166 body lines) the largest. #26337, the PR that held the section out, landed with 18 producer pointers `packages/objectql/src/engine.ts#registerMetadataCollections` in `packages/spec/liveness/queue.json` (6) and `assignment_rule.json` (12). A body named by an `engine.ts#NAME` pointer under `packages/spec` stays in this series, and the claim forbids `packages/spec/**` edits, so `registerMetadataCollections` stays, and with it `METADATA_ARRAY_KEYS`, `resolveMetadataItemName` and `permissionSetOwnerRegistrationRefusal` (its helpers) and the `check-stack-collection-maps.mjs` row that names `METADATA_ARRAY_KEYS` in `engine.ts`. `registerApp` and `registerDriver` stay as the order says (public, inferred return type). The other three re-price at **-75**. That still pays more than -30, so under the order this cut takes the section rather than the fallback (Delete cascade and reference walking, -124). It is also the colder of the two: 36 of 98 body lines from the last 30 days, against 128 of 167. <details><summary>Seam table on <code>e1bfde3601</code> (43 sections of <code>class ObjectQL</code>; U = untyped receivers)</summary> | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | 4706-4937 | Instance state and constructor | `txStore` … `constructor` | 232 | 56 | 0 (0 public) | - | 0 | - | | 4938-4960 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 4961-5304 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5305-5660 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 356 | 61 | 7 (0 public) | - | 0 | - | | 5661-5823 | Action registry and activation | `registerAction` … `setActionActive` | 163 | 24 | 10 (10 public) | - | 1 | - | | 5824-6105 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 116 | 6 (6 public) | - | 0 | - | | 6106-6747 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 642 | 509 | 16 (2 public) | - | 0 | - | | 6748-6854 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 6855-7032 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 178 | 45 | 7 (0 public) | - | 0 | - | | 7033-7241 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 209 | 150 | 6 (0 public) | - | 0 | - | | 7242-7451 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 7452-7817 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 4 | 6 (0 public) | - | 0 | - | | 7818-8430 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 613 | 80 | 6 (0 public) | - | 1 | - | | 8431-9095 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9096-9304 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 9305-9456 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 152 | 18 | 4 (1 public) | - | 0 | - | | 9457-9564 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 108 | 24 | 4 (1 public) | - | 0 | - | | 9565-10273 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 709 | 308 | 12 (0 public) | - | 0 | - | | 10274-10389 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 10390-10484 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 10485-10904 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 420 | 61 | 9 (3 public) | - | 0 | - | | 10905-10973 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 25 | 3 (2 public) | - | 0 | - | | 10974-11239 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 266 | 20 | 8 (3 public) | setDatasourceMapping | 0 | - | | 11240-11407 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 168 | 16 | 3 (3 public) | init | 0 | - | | 11408-11481 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 11482-12071 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 590 | 96 | 17 (8 public) | - | 0 | - | | 12072-12094 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 12095-12113 | Expand/cascade limits (statics) | `MAX_EXPAND_DEPTH` … `MAX_BULK_PER_ROW_HOOK_ROWS` | 19 | 0 | 0 (0 public) | - | 0 | MAX_EXPAND_DEPTH, MAX_CASCADE_DEPTH | | 12114-12564 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 12565-12818 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 12819-13016 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 198 | 183 | 5 (0 public) | - | 0 | - | | 13017-13495 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 479 | 271 | 9 (0 public) | - | 0 | - | | 13496-14052 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 142 | 4 (2 public) | - | 0 | - | | 14053-14364 | validate | `validate` … `assignment` | 312 | 224 | 4 (1 public) | - | 0 | - | | 14365-15734 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1370 | 462 | 3 (2 public) | - | 1 | - | | 15735-17682 | update | `update` … `update` | 1948 | 290 | 1 (1 public) | - | 0 | - | | 17683-19223 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 733 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 19224-19793 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 19794-20413 | count / aggregate | `count` … `aggregate` | 620 | 192 | 3 (2 public) | - | 0 | - | | 20414-20722 | execute and transactions | `execute` … `enforceTransactionOrigin` | 309 | 74 | 8 (2 public) | - | 0 | - | | 20723-21015 | Compatibility API | `registerObject` … `create` | 293 | 52 | 16 (16 public) | - | 0 | - | The per-member screen and the full price output are in the project files, `pm-orders/server/25522-cut17-seam-table.md`. (The seam script's section boundary after `validate` now lands on an orphaned docblock, so its "validate" row reads `validate` … `assignment`; the member screen is unaffected.) </details> | price (net lines) | section | taken? | |---|---|---| | -233 priced at `fe53769a8e`, **-75 re-priced**, **-72 landed** | Package, app, plugin and driver registration | **yes** (`convertPackageBodyForward` 28 body lines, `resolveNavObjectNames` 15, `registerPlugin` 55; `registerMetadataCollections` stays, pointed at from `packages/spec/liveness/`) | | -124 | Delete cascade and reference walking | no: the order takes the registration section while it pays more than -30 | | -71 or less | count / aggregate, Query lowering, the rest | pays less | | -27 | `resolveFormulaRelated` | a later cut, as the order says | ### The shape - The class keeps each of the three methods with name, modifiers, signature, docblock and position. A body becomes one host literal of exactly the `PackageRegistrationHost` members it reads, plus a `.call`, marked `[#25522 cut 17]`, as cuts 8 and 10 to 16 did. Fields go as getters over a `readX` closure (`registrationConversionWarned`, `logger`, `_registry`); methods go as late-bound closures (`resolveNavObjectNames`, `registerMetadataCollections`), so a spy or double set on the engine is still the one a call reaches. `resolveNavObjectNames` reads no member, so its stub calls the function directly. No body assigns a field (`registrationConversionWarned.add(` mutates the set the getter returns, the same object), so there is no setter. - **Series limits.** `convertPackageBodyForward` is public with a declared return type (`any`); `registerPlugin` and `resolveNavObjectNames` are private, so their `.d.ts` line is `private NAME;` either way. No moved body has a `this.FIELD.delete/insert/update(` site, a private-static read, a self-call to `insert` / `update` / `delete`, an `engine.ts#NAME` pointer or an ADR anchor paragraph of its own. - **Helpers, under the cut-14 seat ruling.** `registerPlugin` reads two top-level helpers, both also read by bodies that stay: `stampObjectPackage` (`registerApp`) and `stampPackage` (`registerApp`, `registerMetadataCollections`, `stampObjectPackage`). Both gain `export` in place, byte for byte otherwise (leading docblocks included), and the module imports them as values from `./engine.js`; nothing is copied. Measured alone before the move (`dts-export-variant-cut17.sh`, base engine.ts plus the `export`, no move): each export and both together leave all published declaration files byte-identical. `index.ts` and `core.ts` name their exports singly, so nothing new is published. - **The run-time cycle and the load order (measured).** The value import makes `engine.ts` and the module import each other. The module runs nothing at evaluation (declarations only). The moved `convertPackageBodyForward` was `engine.ts`'s only reader of `@objectstack/metadata-core`, so that import line leaves `engine.ts` with it. The first build imported the module after `./engine-validate.js` (the cut-16 position); that kept the internal module order but moved `@objectstack/metadata-core`'s first import in the ESM bundles three externals later (after `@objectstack/metadata/view-container`, `ajv`, `ajv-formats`). So the module is imported exactly where `engine.ts` imported `@objectstack/metadata-core`. The module's other imports (`./registry.js`, `./engine.js`, a type from `@objectstack/spec/data`) are already loaded by then: the bundles place `registry.ts` long before that region. Measured on the built bundles (`bundlecmp.py`; `index.mjs`, `core.mjs`, `index.js`, `core.js`): with the `// src/engine.ts` markers set aside (9 at base, 10 at head, because the new region splits one), the `// src/...` region order at head equals the base's with one line added, `// src/engine-package-registration.ts`, inside the `engine.ts` region where the import sat. Every external `import` statement (113 and 96 in the ESM bundles) and every `require(` call (148 and 130 in the CJS bundles) is identical and in the same order. The late-binding probe answers the same on base and head (Tests). - **Imports.** The module imports `ServiceObject` (type), `applyArtifactForwardConversions` and `resolveDeclaredRange` (`@objectstack/metadata-core`), `computeFQN` (`./registry.js`), `stampObjectPackage` and `stampPackage` (values) and `ObjectQL` (type) from `./engine.js`. `engine.ts` loses the `@objectstack/metadata-core` import line (its last reader moved) and `computeFQN` from the `./registry.js` line (likewise). - The module header names what moved, how the bodies reach the class, why `registerApp`, `registerMetadataCollections` and `registerDriver` stay, and how the helpers were handled. ### Net lines (the seat's rule) `engine.ts` goes from 21,733 to 21,661 lines, **net -72** (30 added, 102 removed). The module is 192 lines. The diff against `origin/main` is 324 changed lines over 2 files (+222 / -102). ### Readers of the moved lines - No census, ADR anchor, docs row or test key names a moved member by `engine.ts` path. Searched: `engine.ts#NAME` and `engine.ts … NAME` spellings of all three methods, the two helpers, `registrationConversionWarned`, `applyArtifactForwardConversions`, `resolveDeclaredRange` and `computeFQN` under `packages/**`, `scripts/**`, `docs/**` and `content/**`: none. The prose mentions found (`packages/cli/src/**` comments, `docs/qa/platform-checklist/areas/platform-core.json`, ADR-0029 line 559) name `registerApp`, `registerMetadataCollections`, `METADATA_ARRAY_KEYS` or `computeFQN`'s behaviour, not a moved body. - `scripts/check-stack-collection-maps.mjs`'s `METADATA_ARRAY_KEYS` row reads `packages/objectql/src/engine.ts`, where the constant stays (it is `registerMetadataCollections`' helper), so it does not move. The gate is green at head. - The bodies call no `canReadObject(` (core's `row-serving-door-exposure.pin.test.ts` is unchanged), no `referenceTargetOf(` (`second-object-read-exposure.pin.test.ts` is unchanged), no `isFederatedObject` / `resolveTenantFieldName` (`federated-injected-column-readers.test.ts` keys none), and read no `isSystem` (`system-context.mdx` is unchanged; its census is green). They hold no `query-options/no-any-erasure` site and no tenant-audit untyped receiver. - **No ADR anchor paragraph describes a moved body** (the `engine.ts` entry is the transaction-seam paragraph), so the module needs no anchor entry of its own. - **Read-seam census (the cut-21 trap of #25467).** `check-durability-degradation-log-level --list` at base and head: identical once line numbers are masked (164 lines: 48 durability seams and 88 read seams, the same entries); none of the moved names appears. - **Decision-letter count:** `check-adr-anchors` at base `e1bfde3601` (clean detached worktree): **1,898** decision-letter citations on the anchored surface, 59,997 resolving citations across 8,409 files. At head: **1,898**, 59,998 across 8,410 (the new module: one more file, and the header's `ADR-0087 D2`). The moved bodies cite ADR-0087, ADR-0010 and ADR-0122 without a decision letter, so no letter leaves the anchored surface. ## Why #25522 splits `engine.ts` one seam at a time so that a change to one area reads, reviews and conflicts as that area alone. The registration section was next in cut 16's table and was held out only for #26337, which has landed. Nothing about what the engine does changes. ## Tests No test is added and none is edited: the move is proved by identity, not by new behaviour, and no test reads a moved line by path. <details><summary>Public <code>.d.ts</code>: byte-identical (ruling <code>6348891160</code> item 1), with the <code>6441032442</code> report block and a positive control</summary> Built `@objectstack/objectql` at the base (`e1bfde3601`: `engine.ts` blob `fa37297312da`, no `engine-package-registration.ts`) and at head (`engine.ts` blob `f9aa217bc55d`), each from a clean `src/` (`dts-cut17.sh`: the base leg restores all of `src/` from the base and deletes the module; both legs check the blob). The builds are the moved code, not a stale cache: `convertPackageBodyForward.call(host` occurs 0 times in the base `dist/index.mjs` and once in the head's. Every declaration file is **raw byte-identical** base to head, so `6348891160` item 1 holds without any normalisation, and no chunk is renamed (`util-DdsJlVTF` at both). The `6441032442` block, for the record (normaliser `pm-orders/server/evidence/25522/dtsnorm.mjs`, sha256 `25226e0677bdad24726e9eff76a7e247bf7319440d2430d28cbaab0db6add94b`, run as `node dtsnorm.mjs --diff BASE_FILE HEAD_FILE`): | file | raw sha256 (base = head) | raw-diff lines | statements base / head | normalised sha256 (base = head) | declared-surface digest (base = head) | import / export specifier sets | |---|---|---|---|---|---|---| | `core.d.ts` | `642b5a3662c27aabde2dae4bb5877f515cb89f4c1e8075f21c0598b7e6025206` | 0 | 14 / 14 | `40e2fde86719d3ccd1cf492314c426c977ab836a5ab54580800cd1f41352247a` | `4450aaf478092516…` (1 decl) EQUAL | 0 / 84, EQUAL | | `core.d.mts` | `07f5718a09a2662c2f94bc3d79a8e74b67310efa82071a5d81ed1b716adaa69c` | 0 | 14 / 14 | `171eab8743fd8dfabc014a162c40c3e034be29f262b26a2bcc24cb3e06be0459` | `4450aaf478092516…` EQUAL | 0 / 84, EQUAL | | `index.d.ts` | `6dd970eb9c7dc4364e2fe99d22460672edc270302287f812231d823090491122` | 0 | 122 / 122 | `2bb6a21a929b41c8dbb85c72c600f87ed3a3af12ca731d1cace61f5d4aa3d57e` | `9136189ce4d0aba3…` (103 decls) EQUAL | 18 / 284, EQUAL | | `index.d.mts` | `37153868b48bef0e9ad655d74b549a30f51510977cb704e0d6624db1d8e83781` | 0 | 122 / 122 | `c7d7bab765f3b0fb35241fa03bd0b716a93139c98e5638d209379dad96528f98` | `9136189ce4d0aba3…` EQUAL | 18 / 284, EQUAL | | `util-DdsJlVTF.d.ts` | `856d98e0e45ced3ec15bf72a1ac09420a2c4f73b5165ab598c1d7d0000705f02` | 0 | 185 / 185 | `a4372dd015163a46e90b82473d806423a5ca982b8bef21e6e8bfed2b0655b9dd` | `71a464127e0b90b1…` (173 decls) EQUAL | 55 / 147, EQUAL | | `util-DdsJlVTF.d.mts` | `856d98e0e45ced3ec15bf72a1ac09420a2c4f73b5165ab598c1d7d0000705f02` | 0 | 185 / 185 | `a4372dd015163a46e90b82473d806423a5ca982b8bef21e6e8bfed2b0655b9dd` | `71a464127e0b90b1…` EQUAL | 55 / 147, EQUAL | - (a) Every file: statements in the same order, `statements only in base: 0; only in head: 0`, declared-surface digest EQUAL. (b) Every normalised sha256 EQUAL. (c) Specifier sets EQUAL; the run-time export lists are too (`index` 182 names and `core` 54, ESM and CJS, the same sorted list at base and head). (d) Raw diff: 0 lines in every file; the same 14 files in `dist/` at both. (e) After the head build, the full `turbo run build` (116 of 116 tasks, the dependents' DTS builds included) and `pnpm --filter @objectstack/core typecheck` ran on the head `dist/`: exit 0 each. - Premises re-checked at this head: (P2) `packages/objectql/package.json` `exports` names `./dist/index.*` and `./dist/core.*` only, `driver-sql`'s `./dist/index.*` only; (P3) `git grep -n -E 'objectql/dist|driver-sql/dist' -- scripts package.json`: 0 hits. - **Each in-place export alone, before the move** (`dts-export-variant-cut17.sh`, base `engine.ts` with only `export` added): `none`, `stampObjectPackage`, `stampPackage`, and both together all print the same `util-DdsJlVTF.d.ts`, `index.d.ts` `6dd970eb9c7d…` and `core.d.ts` `642b5a3662c2…`. **Positive control.** On this head, one `sed` dropped `private` from ` private registerPlugin(` (a moved member's stub; anchor count 1 to 0, replacement count 0 to 1; `engine.ts` blob `f9aa217bc55d` to `7c36c1c279c1`) and rebuilt (`posctl17.sh`, build exit 0). The chunk is renamed `util-Xhg7Kcdw` and now publishes `registerPlugin(plugin: any, parentId: string, parentNamespace?: string, parentVersion?: string): void;` (line 7555); its normalised sha256 is `668033198231cb9bda8545cd23975b0ce61c92a81952ef2dc84f8f692e694852` ≠ `a4372dd0…`, its declared-surface digest `a2448d3c517a96ad…` ≠ `71a464127e0b90b1…` (the `declare class ObjectQL` statement named as the one changed), and the raw `core.d.ts` (`d1b150bdbefd…`) and `index.d.ts` (`ac50bb3fc445…`) change with the chunk name. `index.d.ts` normalises EQUAL under the control (an entry only re-exports), which is why every declaration file is read. The restore leg put the blob back to `f9aa217bc55d` (`git diff HEAD` empty), and `@objectstack/objectql` was rebuilt and its chunk compared equal to the head build before anything else read `dist/`. </details> <details><summary>AST compare (move-only)</summary> `astcmp.mjs` (TypeScript compiler API, comments and whitespace ignored, tokens compared) on base `engine.ts` against head `engine.ts` plus the module: - The three moved bodies are **SAME** against base `e1bfde3601` (parameters, return type, `async`, body tokens, and every body line equal after one level of indent): `convertPackageBodyForward` 309 tokens / 28 lines, `resolveNavObjectNames` 122 / 15, `registerPlugin` 367 / 55. - `class ObjectQL`: 324 of 324 members kept, in the same order; 321 byte-identical, and the 3 stubs keep docblock, modifiers and signature identical. - Top level: 229 of 234 base statements byte-identical. The other 5 (`exportcmp.mjs`): the `@objectstack/metadata-core` import, moved verbatim to the module; the `./registry.js` import, which loses `computeFQN`; `stampPackage` and `stampObjectPackage`, exported in place with statement and leading docblock otherwise identical; and the class. The head adds one statement: the module import. </details> <details><summary>Load order (base and head bundles)</summary> `bundlecmp.py` on the four built bundles. `// src/engine.ts` markers set aside (9 at base, 10 at head: the new region splits one), the region order at head is the base's plus `// src/engine-package-registration.ts`, everything else in order (`index.mjs` 98 to 99 regions, `core.mjs` 87 to 88, `index.js` 99 to 100, `core.js` 88 to 89). The external module statements are identical and in the same order: 113 `import` statements in `index.mjs` and 96 in `core.mjs`, 148 `require(` calls in `index.js` and 130 in `core.js`. The first build of this cut, with the module imported after `./engine-validate.js`, kept the region order but printed `engine.ts`'s `@objectstack/metadata-core` import (now the module's) 41 statements later in the ESM bundles, after `@objectstack/metadata/view-container`, `ajv` and `ajv-formats`; the second commit moves the import to the line where `engine.ts` imported `@objectstack/metadata-core`, which closes that. </details> <details><summary>Late-binding probe (base and head <code>dist</code>)</summary> `zz-probe-cut17.mjs` (a one-off, not committed; in the project files) loads the built `ObjectQL` and drives the three moved bodies with doubles set on the instance after construction. Cases: `convertPackageBodyForward` on a non-object, a number and an array (returned as is); on a body with no declared range (the same reference back); on HotCRM 4.0.1's published body from `src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json` (pages converted, four `warn` lines, the second call warns nothing and returns a new body); with `logger` replaced after construction (the replacement warns); with `registrationConversionWarned` replaced after construction by a set that already holds one pair (that pair is not warned again, and the replacement set is the one filled). `resolveNavObjectNames` on nested navigation and on an app with none (the same reference). `registerPlugin` with array objects, navigation and an `actions` collection (objects stamped with the parent's id and version, the plugin-as-app registered, `resolveNavObjectNames` and `registerMetadataCollections` reached through spies set on the instance); with map objects and no version; with a registry that throws on objects and on the app (both `warn` lines); with `_registry` and `logger` replaced after construction; with both host methods replaced by doubles; and `registerApp` reaching a nested plugin through the real registry. The 42 output lines are identical on base `e1bfde3601` and head (the 15 construction log lines, which carry timestamps, set aside); stderr is identical too. </details> <details><summary>Suites</summary> Under `scripts/pm/os-verify-lock.sh` on head `1e7a0e96f`: - Full `turbo run build` (the root `build` filters): 116 of 116 tasks, exit 0. - `typecheck` of `@objectstack/objectql` and `@objectstack/core`: exit 0 each. - `@objectstack/objectql` vitest `--project local`: 475 files, 8,843 tests passed, exit 0 (`engine-nested-plugin-collections.test.ts`, `engine-capability-provenance.test.ts` and `registry-installed-package-retired-page-declarations.test.ts`, which drive these bodies, among them). - Every `packages/*/vitest.repo-tests.json` project: core 6 files / 83 tests, objectql 1 / 5, rest 5 / 280 passed and 18 skipped, runtime 3 / 947, spec 80 / 1,295, types 1 / 11. Exit 0 each. </details> ## Verification Head `1e7a0e96fe6b004796be314008b5972bd2292c6f`. The gate list is `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff at that head (48 commands, merge base `e1bfde3601`), each run with its exit code captured before any pipe, then reconciled with `--ran`. - **Derived gates: 48 of 48 run, exit 0 each; `--ran`: 48 run, 0 NOT-MEASURED, 0 UNRUN.** `check:dual-build-cjs-loads` and `check:dts-closure` ran after the full build; `check-plugin-teardown-shape --self-test` after fetching `621a4876` and deepening the clone. The exit codes are in `pm-orders/server/evidence/25522/cut17-ran-final.txt`. - **Hand gates, exit 0 each:** `node scripts/check-adr-anchors.mjs` (counts above), `node scripts/check-tenant-audit-census.mjs`, `node scripts/check-adr-symbol-anchors.mjs`, `node scripts/check-platform-checklist.mjs --changed-from origin/main`, `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`, `node scripts/check-system-context-census.mjs`, `node scripts/check-nul-bytes.mjs`, `node scripts/check-durability-degradation-log-level.mjs --list` (base and head identical, masked), and eslint as `pnpm lint` runs it (`--no-inline-config`) on `packages/objectql`. ## Acceptance notes - **Serial order.** Nothing was edited before the claim `6450667239` named this branch. The branch is `origin/main` at `e1bfde3601` plus two commits. - **Overlap.** Open PRs touching `packages/objectql/**`, `scripts/check-stack-collection-maps.mjs` or the two liveness files, listed at start (each head fetched, `git diff --name-only origin/main...pr/N`) and again just before opening this. At start: none touched `engine.ts`. Before opening: #26864 now edits `engine.ts` in the delete-cascade section (hunks at lines 4518 and 18680 to 19416) and #26831 edits `engine-execution-plumbing.ts`; neither touches this area, the two helpers or the import lines, and `git merge-tree` of this head with #26864's head and with `origin/main` `20457f6f9` exits 0. Whichever lands second merges `main`. - **Why -72 and not -233.** #26337 made `registerMetadataCollections` a named producer in `packages/spec/liveness/queue.json` and `assignment_rule.json` (`engine.ts#registerMetadataCollections`, 18 rows). Moving it would mean re-keying those rows, a `packages/spec` edit this claim forbids, so it stays. Should a later card re-key them, `registerMetadataCollections` (about -150) and its three helpers can follow this module as their own cut. - **The second commit.** The first commit imported the module where cut 16 imported its own (after `./engine-validate.js`). Measured, that moved the first `@objectstack/metadata-core` import in the ESM bundles three externals later; the second commit moves the import line so the external order is identical (Load order, above). Both commits are in the PR so the measurement can be re-run on either. - **Receivers.** The host literal carries `registrationConversionWarned`, `logger` and `_registry` as getters, so a body reads whatever the instance holds when it reads it: the probe replaces each after construction and sees the moved bodies use the replacements. No body assigns a field, so there is no setter. - **Left as is.** The interface docblock on `PackageRegistrationHost` is the series' shared paragraph, word for word as on `ValidateHost`. The moved `registerPlugin` body's comment "the manifest map branch above" now points across files; it is moved verbatim, not edited. ADR-0029 line 559 says `computeFQN` lives in `engine.ts`; it has lived in `registry.ts` since before this cut, and `docs/adr/**` is outside this claim. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind (`PackageRegistrationHost`, `engine-package-registration.ts`). - [x] 3. No formatter ran; `pnpm lint` passes on the touched package. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. No publish-visible change, so `skip-changeset`; both declaration lines are filled in above. - [x] 6. `packages/spec` not touched. - [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, apart from the maintainer quick-read block the project asks for. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. `skip-changeset`: move-only inside `@objectstack/objectql`, with every published declaration file byte-identical and no behaviour change, so there is nothing for a release note to say. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5psvNaMgx2AgmL3NgA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57e00f5 commit cea0871

2 files changed

Lines changed: 222 additions & 102 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 17] The package registration bodies of `ObjectQL` that a
5+
* registration door shares: `convertPackageBodyForward` (ADR-0087 D2's forward
6+
* conversion of a package body that declares its protocol range),
7+
* `registerPlugin` (a nested plugin's objects, plugin-as-app and stack
8+
* collections, registered under the parent package) and
9+
* `resolveNavObjectNames` (the navigation `objectName` resolution both
10+
* registration seams run), moved out of `engine.ts`.
11+
*
12+
* A MOVE, not a redesign. Each exported function below is the body of the
13+
* `ObjectQL` method of the same name as it stood, re-indented one level and
14+
* otherwise unedited. Each method's docblock stays on the method in
15+
* `engine.ts`, which is still the contract.
16+
*
17+
* ## How the moved bodies reach the class
18+
*
19+
* The shape #23050 set for `rest-server.ts`, applied as
20+
* `engine-driver-options.ts` applies it. The class keeps each method with
21+
* name, modifiers, signature, docblock and position; its body becomes one
22+
* literal of exactly the {@link PackageRegistrationHost} members it reads,
23+
* plus a `.call`. The fields read (`registrationConversionWarned`, `logger`,
24+
* `_registry`) go as GETTERS, so each read happens when the body makes it, as
25+
* it did; no body assigns a field. The methods called
26+
* (`resolveNavObjectNames`, `registerMetadataCollections`) go as late-bound
27+
* closures over the instance, so a double or spy set on the engine is still
28+
* the one a call reaches. `registerApp`, `registerMetadataCollections` and
29+
* `registerDriver` stay in the class: the first and last are public with an
30+
* inferred return type, and `registerMetadataCollections` is named by path
31+
* as a producer in `packages/spec/liveness/`.
32+
*
33+
* ## Module helpers
34+
*
35+
* Two helpers are read by `registerPlugin` AND by bodies that stay:
36+
* `stampObjectPackage` (`registerApp`) and `stampPackage` (`registerApp`,
37+
* `registerMetadataCollections`, `stampObjectPackage`). One home each: they
38+
* stay declared in `engine.ts`, exported in place, and are imported here,
39+
* never copied. Nothing re-exports them from the package entry, so the
40+
* published surface does not move.
41+
*
42+
* That import is a run-time module cycle (`engine.ts` imports this module,
43+
* this module imports `engine.ts`). Nothing here runs at module evaluation,
44+
* only declarations, and `engine.ts` imports this module after every module
45+
* this one imports, so the engine's evaluation order is what it was. The
46+
* published build bundles both files into one, where the cycle does not
47+
* exist.
48+
*/
49+
50+
import type { ServiceObject } from '@objectstack/spec/data';
51+
import { applyArtifactForwardConversions, resolveDeclaredRange } from '@objectstack/metadata-core';
52+
import { computeFQN } from './registry.js';
53+
import { stampObjectPackage, stampPackage } from './engine.js';
54+
import type { ObjectQL } from './engine.js';
55+
56+
/**
57+
* The `ObjectQL` members the functions below read — every one of them, and no
58+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
59+
* typed AS the class member (an indexed access on the class), so a change to a
60+
* member's type on the class is a change here too, and naming a member the
61+
* class does not have is a compile error. See the module header for which
62+
* members are getters, and which one also has a setter.
63+
*/
64+
export interface PackageRegistrationHost {
65+
readonly registrationConversionWarned: ObjectQL['registrationConversionWarned'];
66+
readonly logger: ObjectQL['logger'];
67+
readonly _registry: ObjectQL['_registry'];
68+
resolveNavObjectNames: ObjectQL['resolveNavObjectNames'];
69+
registerMetadataCollections: ObjectQL['registerMetadataCollections'];
70+
}
71+
72+
/** The body of `ObjectQL#convertPackageBodyForward`; the method's docblock there is its contract. */
73+
export function convertPackageBodyForward(
74+
this: Pick<PackageRegistrationHost, 'registrationConversionWarned' | 'logger'>,
75+
body: any,
76+
): any {
77+
if (!body || typeof body !== 'object' || Array.isArray(body)) return body;
78+
if (resolveDeclaredRange(body) === null) return body;
79+
const result = applyArtifactForwardConversions(body);
80+
if (result.notices.length === 0) return result.definition;
81+
82+
const id = String(body.id || body.name || '(unnamed)');
83+
const byConversion = new Map<string, { count: number; firstPath: string; message: string }>();
84+
for (const n of result.notices) {
85+
const existing = byConversion.get(n.conversionId);
86+
if (existing) existing.count += 1;
87+
else byConversion.set(n.conversionId, { count: 1, firstPath: n.path, message: n.message });
88+
}
89+
const retiredAfter = new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter]));
90+
for (const [conversionId, agg] of byConversion) {
91+
const key = `${id}|${conversionId}`;
92+
if (this.registrationConversionWarned.has(key)) continue;
93+
this.registrationConversionWarned.add(key);
94+
const pastFloor = retiredAfter.get(conversionId);
95+
this.logger.warn(
96+
`[ObjectQL] package '${id}' registered with declared protocol floor ${result.authoredFloor ?? '(unreadable)'} `
97+
+ `on @objectstack/spec ${result.runtimeSpecVersion ?? '(unknown)'} (${result.verdict}) — ADR-0087 conversion `
98+
+ `'${conversionId}'${pastFloor !== undefined ? ` (a retirement this runtime enforces past the body's floor; the shape was last accepted by @objectstack/spec ${pastFloor})` : ''} `
99+
+ `converted ${agg.count} site(s) forward (first at ${agg.firstPath}). ${agg.message} `
100+
+ `The installed body itself is unchanged, so it converts again at every registration until the package `
101+
+ `is rebuilt ('os build') and re-published with tooling from a release that ships this retirement.`,
102+
);
103+
}
104+
return result.definition;
105+
}
106+
107+
/** The body of `ObjectQL#resolveNavObjectNames`; the method's docblock there is its contract. */
108+
export function resolveNavObjectNames(
109+
app: any,
110+
namespace: string,
111+
): any {
112+
if (!app.navigation) return app;
113+
114+
const resolveItems = (items: any[]): any[] =>
115+
items.map((item: any) => {
116+
const resolved = { ...item };
117+
if (resolved.objectName && !resolved.objectName.includes('__')) {
118+
resolved.objectName = computeFQN(namespace, resolved.objectName);
119+
}
120+
if (Array.isArray(resolved.children)) {
121+
resolved.children = resolveItems(resolved.children);
122+
}
123+
return resolved;
124+
});
125+
126+
return { ...app, navigation: resolveItems(app.navigation) };
127+
}
128+
129+
/** The body of `ObjectQL#registerPlugin`; the method's docblock there is its contract. */
130+
export function registerPlugin(
131+
this: Pick<PackageRegistrationHost, 'logger' | '_registry' | 'resolveNavObjectNames' | 'registerMetadataCollections'>,
132+
plugin: any,
133+
parentId: string,
134+
parentNamespace?: string,
135+
parentVersion?: string,
136+
) {
137+
// `parentVersion` is the owning package's version, stamped like `parentId`.
138+
const pluginName = plugin.name || plugin.id || 'unnamed';
139+
const pluginNamespace = plugin.namespace || parentNamespace;
140+
141+
// Use parentId as the owning package for namespace consistency.
142+
// The parent package already claimed the namespace — nested plugins
143+
// contribute objects UNDER the parent's ownership.
144+
const ownerId = parentId;
145+
146+
// Register objects (supports both Array and Map formats)
147+
if (plugin.objects) {
148+
try {
149+
if (Array.isArray(plugin.objects)) {
150+
this.logger.debug('Registering plugin objects (Array)', { pluginName, count: plugin.objects.length });
151+
for (const objDef of plugin.objects) {
152+
const fqn = this._registry.registerObject(stampObjectPackage(objDef, ownerId, parentVersion), ownerId, pluginNamespace, 'own');
153+
this.logger.debug('Registered Object', { fqn, from: pluginName });
154+
}
155+
} else {
156+
// Same contract statement as the manifest map branch above —
157+
// authored `ServiceObject`s (INPUT shape, ADR-0122 phase 2).
158+
const entries = Object.entries(plugin.objects) as [string, ServiceObject][];
159+
this.logger.debug('Registering plugin objects (Map)', { pluginName, count: entries.length });
160+
for (const [name, objDef] of entries) {
161+
objDef.name = name;
162+
const fqn = this._registry.registerObject(stampObjectPackage(objDef, ownerId, parentVersion), ownerId, pluginNamespace, 'own');
163+
this.logger.debug('Registered Object', { fqn, from: pluginName });
164+
}
165+
}
166+
} catch (err: any) {
167+
this.logger.warn('Failed to register plugin objects', { pluginName, error: err.message });
168+
}
169+
}
170+
171+
// Register plugin as app if it has navigation (for sidebar display)
172+
if (plugin.name && plugin.navigation) {
173+
try {
174+
const resolved = pluginNamespace ? this.resolveNavObjectNames(plugin, pluginNamespace) : plugin;
175+
this._registry.registerApp(stampPackage(resolved, ownerId, parentVersion), ownerId);
176+
this.logger.debug('Registered plugin-as-app', { app: plugin.name, from: pluginName });
177+
} catch (err: any) {
178+
this.logger.warn('Failed to register plugin as app', { pluginName, error: err.message });
179+
}
180+
}
181+
182+
// Register metadata arrays (actions, views, triggers, etc.) through the
183+
// SAME seam the manifest path uses — same stamping seam, one level down: a
184+
// nested plugin's declarations must carry the parent package's ADR-0010
185+
// provenance too, or the declared-≠-enforced hole reopens for packages
186+
// that ship a collection from a nested plugin (`capabilities` #5870;
187+
// `jobs` / `emailTemplates` / `tools` / `skills` #7049, which is why the
188+
// list is no longer copied here to be patched one name at a time; the
189+
// aggregated-view expansion, #7163, for why the loop BODY is no longer
190+
// copied here either).
191+
this.registerMetadataCollections(plugin, ownerId, 'nested plugin', parentVersion);
192+
}

0 commit comments

Comments
 (0)