Repository navigation
Commit 0c1829a
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XT6ZDDQFYhmZ6kxDp4hSdwM)_
Fixes #28574
Clause-②: yes (widening)
Metadata-protocol: added
## 维护者速读
- **改了什么**:对象扩展(`object_extension`,即「给别的包的对象加字段和规则」)成了可写包里的普通元数据项:通过现有的
`/api/v1/meta` 草稿、发布、历史回滚和删除口保存;发布后整个扩展体注册为该包的 `extend`
贡献者,字段出现在对象的每一种读取里,表也加上列;删除或卸载包时撤回;包导出把它带成 `objectExtensions`,可以再装回去。
- **为什么改**:Studio 在基础对象上「加字段」(N-23c)要写的就是这个:写进基础包配对的 `.custom` 包、字段带
`__c` 后缀、基础包不动(ADR-0146 D10)。以前这类数据只能由代码包的 manifest 带进来,运行时没有保存口。
- **风险与回滚**:只影响新类型 `object_extension` 的行;manifest 里的 `objectExtensions`
注册方式不变。顺带修正导出:一个包扩展了别人的对象时,导出不再把那个对象当成自己的对象输出(以前再安装会被所有权冲突拒绝)。回滚 =
revert 本 PR。
- **先看**:①
`packages/metadata-protocol/src/protocol-object-extension-item.ts`(保存/发布/回滚口的拒绝顺序与注册体);②
`packages/metadata-protocol/src/protocol.ts` 里
`registerStoredObjectExtension` / `resyncStoredObjectExtensions` 和列表合并
`listSlotNameOf` 那几处;③ `packages/runtime/src/domains/packages.ts`
的导出循环(`objectExtensions` 一腿和 `objects` 只留自有对象的过滤)。
- **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/meta-object-extension-item.test.ts`,预期 19 个测试全绿。
- **需要你做的**:无,席位落地。
## What changes
- **The type** (`packages/spec`, the one hunk seat 1 ruled, shape A):
`object_extension` joins `MetadataTypeSchema` and
`DEFAULT_METADATA_TYPE_REGISTRY` (`allowRuntimeCreate: true`,
`supportsVersioning: true`, no overlay, `domain: 'data'`, `filePatterns:
['**/*.extension.ts']`, the convention of the one code file that holds
an extension,
`examples/app-showcase/src/data/extensions/account.extension.ts`; the
registry pin `metadata-plugin.test.ts` requires a pattern on every
entry), plus the one classification row the `satisfies` (a record over
every `MetadataType`) on `OVERRIDE_TYPE_CLASSIFICATION` compile-forces:
`object_extension: { disposition: 'excluded', basis:
'adr_0146_d10_extended' }`. Regenerated: `meta-url-data.generated.ts`
(`gen:meta-url-spelling`) and the two reference pages `check:generated`
named (`gen:docs`). No other spec key, no new error code,
`ObjectExtensionSchema` untouched. The route ledger, SDK and OpenAPI did
not move. `examples/app-showcase/src/coverage.ts` gains the
`KIND_COVERAGE` entry the closed kind roster requires (admitted by the
same ruling).
- **The gate** —
`packages/metadata-protocol/src/protocol-object-extension-item.ts`
(new), asked by the save door, the per-item publish door and the history
restore (`protocol-authoring-gate.ts`), beside the `__c` judge it calls.
`VALIDATION_ERROR` / 400 with an issue path, in this order: a write
bound to no package; the registry's own `defaultRowScope` /
`membershipKeys` refusals with their sentences
(`extendContributionRefusal`, now one exported function in
`objectql/src/registry.ts` that `registerObject` also calls); a row
filed under a name other than its `extend`; an unregistered target; a
target outside the written package's closure
(`narrowObjectsToPackageClosure`, with `closureDependencyRemedy`'s
prescription); the `__c` judge from #28564
(`judgeExtensionFieldSuffix`), unchanged. The body's shape is the doors'
own schema gate (`resolveOverlaySchema` → `ObjectExtensionSchema`,
strict, over the body with the ADR-0010 envelope peeled off): 422
`INVALID_METADATA`.
- **Registration** — `registerStoredObjectExtension` registers the WHOLE
parsed body (`ObjectExtensionSchema` output, `_provenance: 'org'`)
through `SchemaRegistry.registerObjectExtension` → `registerObject(...,
'extend', priority)`. Called from the mutation sync
(`applyRegistryWriteThrough`) and the stored-row hydration every boot /
reload path shares (`hydrateOverlayIntoRegistry`). The manifest loop in
`engine.ts` is not touched: what a manifest extension registers is
unchanged.
- **Withdrawal** — `restoreArtifactRegistryView` (the delete and
uninstall heal) calls `resyncStoredObjectExtensions(target)`: it
withdraws every stored-row (`_provenance: 'org'`) `extend` contributor
of the target whose package has no active row left
(`SchemaRegistry.withdrawObjectExtension`, new) and re-registers the
rows that remain. Code-package contributors are never withdrawn.
- **Storage** — `ensureObjectStorage` syncs the target's table on an
`object_extension` publish, so a record can store the new field without
a restart.
- **List reads** — an `object_extension` body carries `extend` and no
`name` (its schema refuses one), so `mergePackageAwareOverlay` slotted
none of them and the list and per-package reads answered `[]`.
`listSlotNameOf` slots such a body by its target. The body is served as
stored: no `name` is added, so what GET answers PUTs back through the
strict schema.
- **Export** — `assemblePackageManifest`
(`packages/runtime/src/domains/packages.ts`) reads the package's
`object_extension` items (per-package layer) into `objectExtensions`.
Measured on base `7a48bf73`: it did NOT carry them (the base-red export
test). And a package's `objects` keep only the objects it owns: the
registry lists an object under every contributing package, so an
extending package exported the target as its own object, and re-install
refused it with `ObjectOwnershipConflictError`.
- **Reference index** — `reference-sites.ts` walks `object_extension`
with `ObjectExtensionSchema` (the type is deliberately not in
`BUILTIN_METADATA_TYPE_SCHEMAS`), which keeps the
`unwalkableSourceTypes` pin at exactly `['external_catalog']` instead of
widening it.
- **Translations** (`packages/platform-objects`): the metadata-forms
bundles are generated from the registry, so the type's `label` /
`description` arrive in every locale (`check-i18n-bundles --write`).
Authored in zh-CN (对象扩展), ja-JP (オブジェクト拡張) and es-ES (Extensión de
Objeto) on arrival, as `queue` and `assignment_rule` were (`pnpm
i18n:extract` then dropped the six provenance rows). The two translation
ledgers that count the registry move by the new type:
`bare-type-display-echo-decisions.test.ts` (31 → 32 types, 14 → 15 bare,
24 → 26 leaves) and the label positive control in
`object-lifecycle-panel-echo-decisions.test.ts` (697 → 698), each with
its one-line history entry.
- **The `__c` judge's door list** (`protocol-extension-field-suffix.ts`
header, prose only): names the `object_extension` gate and the
adopt-orphans door #28768 added (seat 1's note from that PR's contract
review).
- `@objectstack/lint/runtime` re-exports `closureDependencyRemedy` (one
line), so the gate prints the closure sentence the other save doors
print instead of re-spelling it.
- `.changeset/28574-object-extension-package-item.md`: minor on `spec`,
`lint`, `objectql`, `metadata-protocol`, `runtime`, `platform-objects`.
## Tests
`packages/runtime/src/domains/meta-object-extension-item.test.ts` (new,
19 tests) over a real better-sqlite3 `SqlDriver`, a real `ObjectQL`
engine, the real protocol and `HttpDispatcher.handlePackages` for the
export. Fixture: code base `com.acme.crm` owning `crm_account`, its pair
`com.acme.crm.custom` installed through `installPackage`, and an
unrelated `com.acme.other`.
All readings at head `ccfa0893` (after merging `origin/main` with #28768
in), unless named.
| Reading | Result |
|---|---|
| Card file at head | `Tests 19 passed (19)` (lock VERDICT command-exit
0) |
| Card file on base `8afaf12d` (every production file of this diff
restored to the merge base, the new test kept, five packages rebuilt) |
`Tests 18 failed, 1 passed (19)`. On base the type does not exist, so
every save answers `'object_extension' is not a metadata type`; the one
green is the read-only-base control, refused on base too (400 for the
unknown type). Restore: `git checkout HEAD -- .`, `git diff HEAD` empty,
rebuilt. |
The 19 tests: the field on the by-name, list, layered and registry reads
with the pair as its only `extend` contributor and the row stored; a
draft not live until publish; a record storing a value in the new
column; the extension's `validations` reaching the object (the whole
body is registered); a boot (`loadMetaFromDb`) re-registering the row;
the pair's export carrying `objectExtensions` that parse; the base's
export carrying none; that export re-installing into a fresh kernel
through `registerApp`; delete and uninstall each removing the field from
every read (each asserts it was on every read first); seven refusals,
each `VALIDATION_ERROR` / 400 with its issue path where it has one,
leaving no row and no contributor (outside the closure, unknown target,
a `.custom` field without `__c`, `defaultRowScope`, `membershipKeys`, a
package-less write, a name other than the target); the read-only base
refused; and the `excluded` disposition not closing the type to the
pair.
<details><summary>Ablations (one per door call, <code>node
scripts/ablation-replace.mjs</code> from committed HEAD, package rebuilt
and <code>ablation-dist-preflight</code> marker found in dist before
each run)</summary>
- **The save / publish / restore gate.** `objectExtensionItemRefusal`'s
type check rewritten to return `undefined` always (marker `ABL1`): blob
`796dce22cb1c` → `14a8016fdac0`. `Tests 7 failed, 12 passed (19)`:
exactly the seven refusals. Restored: blob equals HEAD, `git diff HEAD`
empty.
- **The `'extend'` registration.** `registerStoredObjectExtension`'s
`registry.registerObjectExtension(...)` call guarded off (marker
`ABL2`): blob `bf4d4c0f79a5` → `cc5b2b90db49`. `Tests 7 failed, 12
passed (19)`: the reads, draft-then-publish, record, validations, boot,
delete and uninstall tests. Restored clean.
- **The export leg.** `objectExtensions` dropped from the export loop
(marker `ABL3`): blob `eec70a607e34` → `ffbfc47a15cc`. `Tests 2 failed,
17 passed (19)`: the export test and the re-install test. Restored
clean; runtime rebuilt, `--absent` preflight green, card file 19/19
again.
- A first export ablation run in the same chain read 9 red because
metadata-protocol's dist still carried `ABL2` (the ablation tool
restores source only); it was discarded and re-run alone on a rebuilt
dist. The reading above is that second run.
</details>
<details><summary>Full suites and typecheck at head
<code>ccfa0893</code> (each under the verify lock, VERDICT command-exit
0)</summary>
| Suite | Result |
|---|---|
| `@objectstack/metadata-protocol` | 295 files passed, 4 skipped · 22069
tests passed, 26 skipped |
| `@objectstack/objectql` (local) | 518 files · 9959 tests |
| `@objectstack/runtime` (local) | 509 files · 8247 tests passed, 19
skipped |
| `@objectstack/spec` | 875 files · 24715 tests, 1 todo |
| `@objectstack/lint` | 189 files · 7814 tests |
| `@objectstack/platform-objects` | 107 files · 1497 tests |
| `registry-extension-system-columns.test.ts` +
`registry-object-extension-nonenumerated-prop-discard.test.ts` | 2 files
· 16 tests |
| showcase `test/coverage.test.ts`; cli
`generate-file-name-registry-parity.test.ts` | 13 / 43 tests |
| `tsc --noEmit` spec, lint, objectql, metadata-protocol, runtime,
showcase | exit 0 each (pre-final-merge head; `check:type-check-debt`
re-measured at head: 0 errors) |
| `eslint --no-inline-config` over the 11 changed TS files | exit 0 |
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at head: 117 families. All 117 exit 0;
`--ran`: **117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN**. On the way:
`check:i18n` red until the translation bundles were regenerated for the
new registry row; `check:query-options-erasure` red on two `as any`
casts in the new test, removed; `check:generated` / `check:docs` red
after each merge until `gen:docs`; `check-plugin-teardown-shape
--self-test` exit 3 on the shallow clone until its fixture commit was
fetched. No gate was added.
</details>
## Acceptance notes
- **Two costs of the spec ruling, on purpose.** `/meta/types` serves no
JSON schema for `object_extension`, and a draft carries no
`_diagnostics` badge: the type is not bound in
`BUILTIN_METADATA_TYPE_SCHEMAS`, because that map requires the ADR-0010
envelope, which would change the schema a manifest's `objectExtensions`
parse with.
- **`excluded` does not close the type to a pair.**
`OVERRIDE_TYPE_CLASSIFICATION` lists `object_extension` as `excluded`
(it never replaces a base item, so it never carries `overrides`); the
pair's save is admitted regardless. Pinned by the last test.
- **Withdrawal is a new registry verb, not
`subtractExtenderContributions`.** The order and card name
`subtractExtenderContributions` for delete. Measured, that is the fold's
idempotency helper (it removes from a base the `indexes` an extender is
about to re-add, `registry.ts`); it removes no contributor.
`withdrawObjectExtension` splices the package's `extend` contributor and
drops the merged-object cache entry.
- **The round trip goes through `registerApp`, the path the export is
assembled for.** `assemblePackageManifest`'s own docblock names it:
`marketplace-install-local` → `manifestService.register()` →
`engine.registerApp()`. `installPackage` (`POST /api/v1/packages`)
parses `PackageInstallManifestSchema`, which refuses every collection
key but `objects` (measured on spec dist at head: `views`, `flows` and
`objectExtensions` all answer "Unrecognized key(s)"), so no export
carrying views or flows was ever an `installPackage` input. No second
code path was written.
- **The export fix to `objects`** is a defect this card makes reachable
(an extending package's export), fixed where the extension leg is added.
- **Outside the claimed file surface:** `packages/lint/src/runtime.ts`
(one re-export line),
`packages/metadata-protocol/src/reference-sites.ts` (the walk above),
the `protocol-extension-field-suffix.ts` header (prose), the regenerated
reference pages and translation bundles, and the two translation-ledger
count pins. Each is forced by the registry row or asked by seat 1.
- **Package-less rows and adopt-orphans.** A package-less
`object_extension` save is refused by the gate's first step (pinned).
Whether the adopt-orphans door should judge `object_extension` rows it
rebinds is left open in the report, not widened here.
- **Open question for N-23c (#28575):** whether Studio's add-field needs
the type bound in `BUILTIN_METADATA_TYPE_SCHEMAS` (JSON schema and
`_diagnostics`), which needs a ruling on the envelope. Recorded in the
report.
- Serial: #28728 (#28565), #28693 (#28412), #28770 (#28717) and #28768
(#28735) landed while this was in flight. `origin/main` was merged three
times (after #28728 / #28693, after main moved ten more commits, and
after #28768), each with no conflict, and every reading in this body was
retaken after the last one. `registerObjectExtension` hands the whole
body, so `fieldPatches` reaches #28565's apply unchanged.
## Verification
Live probe on the stock showcase at head `ad2bfc9c` (this diff merged
with `origin/main` once; the later commits change only tests,
translations, prose and the file pattern), `pnpm dev -- --fresh -p 3917`
in `examples/app-showcase`, signed in as the seeded admin (`POST
/api/v1/auth/sign-in/email` with `Origin: http://localhost:3917`, cookie
jar). `B=http://localhost:3917/api/v1`.
```text
POST $B/packages/com.example.showcase/ensure-customization
201 {"packageId":"com.example.showcase.custom","namespace":"showcase_custom","created":true,"writable":true}
PUT $B/meta/object_extension/showcase_account?package=com.example.showcase.custom&mode=draft
{"extend":"showcase_account","fields":{"region__c":{"type":"text","label":"Region"}}}
200 Saved object_extension 'showcase_account' (env-wide, package=com.example.showcase.custom, state=draft) [seq=1]
POST $B/meta/object_extension/showcase_account/publish?package=com.example.showcase.custom
200 Published draft — type=object_extension, name=showcase_account [seq=2]
GET $B/meta/object/showcase_account
200 item.fields.region__c = {"label":"Region","type":"text",...} (item._packageId com.example.showcase)
GET $B/meta/object/showcase_account/layers
200 code.fields.region__c present, effective.fields.region__c present
GET $B/packages/com.example.showcase/customizations
200 {"extensions":[{"object":"showcase_account","kind":"field","name":"region__c"}],"counts":{"extensions":1,...}}
(the pair's 'extend' contributor, read off the registry)
GET $B/meta/object_extension?package=com.example.showcase.custom
200 {"items":[{"extend":"showcase_account","fields":{"region__c":{...}},"_packageId":"com.example.showcase.custom"}]}
GET $B/packages/com.example.showcase.custom/export
200 objectExtensions: [{"extend":"showcase_account","fields":{"region__c":{"type":"text","label":"Region"}}}], objects: []
POST $B/data/showcase_account {"name":"Probe Co","region__c":"EMEA"}
201 record.region__c = "EMEA"
PUT $B/meta/object_extension/showcase_account?package=com.example.showcase.custom&mode=draft
{"extend":"showcase_account","fields":{"region":{"type":"text"}}}
400 VALIDATION_ERROR, issues[0].path "fields.region": name it `region__c`, or change a base field's display properties in the extension's patch (`fieldPatches`).
DELETE $B/meta/object_extension/showcase_account?package=com.example.showcase.custom
200 Deleted object_extension 'showcase_account' — it no longer exists. [seq=3]
GET $B/meta/object/showcase_account → region__c absent
GET $B/packages/com.example.showcase.custom/export → no objectExtensions
```
Two-minute check: `pnpm --filter @objectstack/runtime exec vitest run
src/domains/meta-object-extension-item.test.ts`, 19/19.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5kkSYvKhs52WGfTj3c)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1920e48 commit 0c1829a
22 files changed
Lines changed: 1082 additions & 34 deletions
File tree
- .changeset
- content/docs/references
- api
- kernel
- examples/app-showcase/src
- packages
- lint/src
- metadata-protocol/src
- objectql/src
- platform-objects/src/apps/translations
- runtime/src/domains
- spec/src
- kernel
- meta-spelling
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
642 | 642 | | |
643 | 643 | | |
644 | 644 | | |
645 | | - | |
| 645 | + | |
646 | 646 | | |
647 | 647 | | |
648 | 648 | | |
| |||
709 | 709 | | |
710 | 710 | | |
711 | 711 | | |
712 | | - | |
| 712 | + | |
713 | 713 | | |
714 | 714 | | |
715 | 715 | | |
716 | 716 | | |
717 | 717 | | |
718 | 718 | | |
719 | 719 | | |
| 720 | + | |
720 | 721 | | |
721 | 722 | | |
722 | 723 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
179 | 179 | | |
180 | 180 | | |
181 | 181 | | |
182 | | - | |
| 182 | + | |
183 | 183 | | |
184 | 184 | | |
185 | 185 | | |
| |||
226 | 226 | | |
227 | 227 | | |
228 | 228 | | |
| 229 | + | |
229 | 230 | | |
230 | 231 | | |
231 | 232 | | |
| |||
266 | 267 | | |
267 | 268 | | |
268 | 269 | | |
269 | | - | |
| 270 | + | |
270 | 271 | | |
271 | 272 | | |
272 | 273 | | |
| |||
283 | 284 | | |
284 | 285 | | |
285 | 286 | | |
| 287 | + | |
286 | 288 | | |
287 | 289 | | |
288 | 290 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
60 | 69 | | |
61 | 70 | | |
62 | 71 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
73 | 78 | | |
74 | 79 | | |
75 | 80 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
| 82 | + | |
82 | 83 | | |
83 | 84 | | |
84 | 85 | | |
| |||
101 | 102 | | |
102 | 103 | | |
103 | 104 | | |
104 | | - | |
| 105 | + | |
105 | 106 | | |
106 | 107 | | |
107 | 108 | | |
| |||
112 | 113 | | |
113 | 114 | | |
114 | 115 | | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
115 | 119 | | |
116 | 120 | | |
117 | 121 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
16 | 19 | | |
17 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
18 | 24 | | |
19 | 25 | | |
20 | 26 | | |
| |||
0 commit comments