You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 529e535
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metadata-protocol): a tenant's edit follows its item across the packages of one App (#31653)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Wtro5Gqzz97Z6DD47Tkksr)_Fixes#31593
Clause-②: no — no accept set changes; the lookup scope widens only
within one suite (triage 6724363147)
Metadata-protocol: none
## 维护者速读
- 改了什么:同一个 App 的某个条目(如视图)在新版本里换了所属包(扁平 →
组合拆包,或回滚),租户对它的修改现在会跟着条目走,在新的所属包下照常生效;按名读取、列表、分层读取三处一致。
- 为什么改:修改是按"编辑当时的所属包"存的,拆包或回滚后读取只找新所属包,修改还在库里却不再生效,客户看到定制"消失"。HotCRM
6.2.0 组合版上线前必须修。
- 风险与回滚:只改读取时的查找范围,不写任何数据,不改保存行为;"同一 App"只从安装记录(`appOf`
读已安装清单)判定,旧模块已卸载时以该行绑定在本 App 的 `.custom` 包为凭据,绝不按 id 前缀猜。回滚即还原本 PR。
-
先看:`packages/metadata-protocol/src/suite-moved-overlay.ts`(两条判定)、`packages/metadata-protocol/src/protocol-overlay-merge.ts`(`servedOverlayRowCandidates`
的候选顺序与
`holdsServedOverlayCandidate`)、`packages/metadata-protocol/src/protocol-environment-rows.ts`(按名读取接入)。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/meta-overlay-follows-suite.test.ts`,预期 6 个全绿(需先构建
metadata-protocol)。
- 需要你做的:无,席位落地。
## What changes
**Premise as measured on `origin/main`.** The card measured the edit as
a row bound to the base (`package_id = app.objectstack.hotcrm`). On
`main` that save is closed (ADR-0146 D11): a save naming the read-only
base is refused and prescribes the App's pair, so today the edit lands
in `app.objectstack.hotcrm.custom` declaring `overrides: { package:
OWNER_AT_THE_TIME, basedOn }`. The pair is one per App (ADR-0146 D3 as
amended), so after a suite split only the declaration still names the
former owner, and the same symptom recurs: the row is kept, the reads
serve the vendor's item. Both shapes are fixed here: the pair's declared
override (every edit made on a current kernel) and the pre-close row
bound to the base (every edit a tenant made on an earlier release, which
`os migrate packageless-overlays` cannot move once the bound base stops
shipping the name).
**The rule (option 1, the seat's ruling): a tenant's edit follows its
item across the packages of one App.** New module
`packages/metadata-protocol/src/suite-moved-overlay.ts`, two pure
predicates:
- `isMovedOverrideRowOf(row, base, installed, ships)`: the body declares
`overrides.package` = a former owner, not `base`; `base` ships the name
and the former owner does not; the former owner is an installed package
of `base`'s App, or is no longer installed. Every other check is
`isServedOverrideRowOf`'s own, asked of the row as if it named `base`
(binding is one of `base`'s pairs, overridable type), so the one
predicate stays the one spelling of binding and type.
- `isMovedBaseRowOf(row, base, installed, ships)`: the row is bound to
another installed, non-pair package of `base`'s App that no longer ships
the name while `base` does, of an overridable type, declaring nothing.
**Same App, from the install record, never a prefix.** Two installed
packages are of one App when `appOf` (metadata-core) answers the same
App over the registry's installed manifests. When the former owner is no
longer installed (rollback from composed to flat), the record is the
row's binding to the App's pair: the save door admits a declaration into
a pair only for a package of that pair's App (`isPairOf`, ADR-0146 D2
write gate).
**Where it is read: the one served-row resolution, so every read
agrees.** `servedOverlayRowCandidates` gains two candidates when the
read names which packages ship the item (`ships`, new optional field of
the address): the pair's moved override right after the package's own
declared override, and a moved base row LAST, after the package-less row
(which wins over another package's row, ADR-0146 D8 item 1).
`holdsServedOverlayCandidate` is the one reading both readers apply.
`findServedOverlayRow` (by-name and layered reads) reads `ships` from
`shippedArtifactsOf`; `mergePackageAwareOverlay` (list) reads it from
the slot's lower-layer contributions. A moved edit is served as the
current owner's item (`overrideOf`, the current owner's envelope). Two
former owners' base rows that could both follow are never guessed
between. Write addresses pass no `ships` and are unchanged.
**What it never does.** Writes nothing (the row keeps its binding and
body; a rollback needs no reverse step). Never takes an item from a
package that still ships it. Never reaches an `object` (still
`ITEM_LOCKED`, and a stored object row never follows).
Files: `suite-moved-overlay.ts` (new), `protocol-overlay-merge.ts`,
`protocol-environment-rows.ts`, `protocol.ts` (two host members passed
to `findServedOverlayRow`), the test, a patch changeset. No
`packages/spec/**`, no `ui/**`, no `registry.ts`, no
`served-override.ts` (#31627's file is not edited). No forced test
edits.
## Tests
New `packages/runtime/src/domains/meta-overlay-follows-suite.test.ts`, 6
cases. One SQLite file is the tenant DB; each boot is a fresh kernel
over it with one version's packages (real `ObjectQL`, `SqlDriver`,
registry, protocol; nothing stubbed). Each positive case reads by name
with and without `?package=`, the list and the layered `effective`.
1. flat → composed, edit in the App's pair declaring the App: served at
the module.
2. composed → flat (rollback), edit declaring the module: served at the
App with the module uninstalled; re-upgrade serves it again.
3. flat → composed, a pre-close row bound to the App itself: served at
the module.
4. NEGATIVE: a row bound to an unrelated installed App, that App's pair
declaring HotCRM's App, and HotCRM's pair declaring the unrelated App:
none applies to the module's view.
5. CONTROL: the former owner still ships the view: it keeps its edit,
the module serves its own view.
6. CONTROL: an object save naming the owner answers `ITEM_LOCKED`; an
object row bound to the App never follows to the module.
<details><summary>Red on main first (6924440, metadata-protocol src
unchanged): 3 failed | 3 passed (6)</summary>
```
× flat → composed: the edit saved under the App is served at the module that ships the view now
× composed → flat (rollback): the edit saved under the module is served at the App, though the module is gone
× flat → composed: an edit bound to the App itself (a Regime O row from before the D11 close) follows too
- "bare": "Open cases (ours)", + "bare": "All",
- "layered": "Open cases (ours)", + "layered": "All",
"list": [["app.objectstack.hotcrm.service", - "Open cases (ours)" + "All"]],
- "owner": "Open cases (ours)", + "owner": "All",
Tests 3 failed | 3 passed (6)
```
The 3 passes are the NEGATIVE and the two CONTROLs, as on main. A first
measurement also confirmed the premise shift: the Regime O save naming
the base answers the D11 refusal prescribing
`app.objectstack.hotcrm.custom`.
</details>
<details><summary>Ablations (fix committed first;
`scripts/ablation-replace.mjs`, dist preflight, restore proven blob ==
HEAD each leg)</summary>
The runtime test resolves `@objectstack/metadata-protocol` through its
`dist/`, so each leg rebuilt the package and
`ablation-dist-preflight.mjs` proved the marker present (2 built files)
before the run and absent (all 24 files) after the restore build; the
tree was clean against HEAD after each leg.
- **A. Same-App check removed** (`ofOneApp` answers true for any
installed pair, blob `05cc47b2` → `51420353`): `Tests 1 failed | 5
passed (6)`, the NEGATIVE case, `expected 'Open cases (ours)' to be
'All'` (the row bound to the unrelated App followed). Restored
`05cc47b2` == HEAD.
- **B. "Former owner still ships" guard removed** (blob `05cc47b2` →
`322a4e8d`): `Tests 1 failed | 5 passed (6)`, the CONTROL case, the
module served the App's edit instead of its own view. Restored
`05cc47b2` == HEAD.
</details>
## Acceptance notes
- The card's acceptance is cloud-side
(`composed-version-upgrade.test.ts` case 4); this PR pins the same three
facts objectstack-side. The cloud test seeds a base-bound row, the shape
case 3 covers.
- `GET /packages/:id/customizations` (`listServedOverrides`) still lists
a pair override under the package its declaration names; a moved
override is served at the new owner but listed under neither until it is
saved again. The item lock resolution is unchanged. Both are named in
the report's open questions.
## Verification
Head `0fad163d` (branch `claude/project-thread-jx84uj`). #31614 merged
first; `origin/main` merged in at `a59426ee` (merge, no rebase), merge
base `8784ccbc`.
- **Build:** `pnpm install` 0; `turbo build --filter ./packages/**`
81/81 after the merge.
- **Tests:** the new file 6/6; runtime `src/domains` slice 34 passed
(this file, `meta-legacy-pair-served`, `packages-customizations`);
`@objectstack/metadata-protocol` 327 files / 22593 tests passed;
`@objectstack/objectql` 571 files / 11581 tests passed. Heavy runs
through `os-verify-lock.sh` (VERDICT command-exit 0).
- **Typecheck:** metadata-protocol 0, runtime 0.
- **Lint:** eslint over the 4 changed sources and the test, exit 0.
- **Gates:** `dispatch-gates --commands` derived 72 families for these 6
paths; all 72 run at `0fad163d`, each exit 0. `dispatch-gates --ran`: 72
derived, 72 run, 0 NOT-MEASURED, 0 UNRUN. One gate along the way:
`check:query-options-erasure` first counted the test's untyped option
bags (236 → 237); `0fad163d` types them (`EngineQueryOptions`,
`DataEngineInsertOptions`) and it reads 236 again.
- **Dogfood:** not owed (no `!` in the title, no narrowing arm: reads
widen within one App, no accept set changes).
- **`test:repo`:** declared to CI, not run locally.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmR2hWY1vfpcDK2q19Q)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(metadata-protocol): a tenant's edit follows its item across the packages of one App
6
+
7
+
Clause-②: no — no accept set changes; the lookup scope widens only within one suite (triage 6724363147)
8
+
Metadata-protocol: none
9
+
10
+
When a later version of an App moves an item from one of its packages to another (an ADR-0130 D4 suite split such as a flat App becoming an App plus modules, or the rollback of one), the item's reads no longer drop the tenant's edit of it. Before, the edit was still stored and every read served the vendor's item: the reads looked for the edit under the package that ships the item now, and the edit names the package that shipped it at the time of the edit.
11
+
12
+
-**What follows.** The App pair's (`<app>.custom`) declared override whose `overrides.package` names a former owner of the item, and a row bound to the former owner itself (a Regime O save stored before ADR-0146 D11 closed saves naming the read-only base). Each is served at the package that ships the item now, by name (with and without `?package=`), in the list and as the layered read's `effective`.
13
+
-**Same App, read from the install record.** Two installed packages are of one App when `appOf` answers the same App for both over the registry's installed manifests. When the former owner is no longer installed (a rollback), the record is the row's binding to the App's pair, which the save door admits only for a package of that App. Never an id or name prefix.
14
+
-**What never follows.** A row bound to, or declaring, a package of another App; a row of another App's pair; a row whose former owner still ships the item (it stays that package's edit); an `object` (it takes no tenant overlay, `ITEM_LOCKED`); and, between two former owners' rows, neither.
15
+
-**Nothing is written.** The row keeps its binding and body, so a rollback needs no reverse step; the next save of the item writes the current owner's address, as every save does.
0 commit comments