Skip to content

Commit d866fdd

Browse files
fix(cloud-connection): a ledgered install whose register the engine refuses at boot is listed notLoaded (#31566) (#31650)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi961WGNsxKx9jXxC7GrhBueT)_ Fixes #31566 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:启动时 `rehydrate` 如果遇到引擎拒绝注册的安装记录(旧版本写进去的),现在会把这次拒绝记下来;`GET /api/v1/marketplace/install-local` 列表里这条记录带上 `notLoaded: { code, message }`(引擎自己的错误码和原句),不再显示成“已安装”。 - 为什么改:以前这种记录每次启动都被拒、什么都没加载,只在日志里写一行 `rehydrate failed`;列表却照样显示已安装,用户看不出这个应用其实没加载,也不知道该卸载还是修包。 - 风险与回滚:只改 `marketplace-install-local-plugin.ts` 一处 `register` 调用外的 try/catch 和标记类型;健康记录、协议版本不兼容记录的列表输出逐字节不变;回滚即撤销本 PR。 - 先看:`packages/cloud-connection/src/marketplace-install-local-plugin.ts` 中 rehydrate 里新增的 `register` catch(只记“声明 4xx 的拒绝”)和 `NotLoadedMarker` 联合类型;再看 `marketplace-install-local-register-refusal.test.ts` 末尾新增的 `#31566` 用例组。 - 两分钟验证:`pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-register-refusal.test.ts -t '#31566'`,预期 7 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **Rehydrate (`kernel:ready`)**: the `manifest.register` call gets its own `catch`. When the engine REFUSES the register (a throw declaring a 4xx, `isDeclaredRegisterRefusal`, the rule the install door already uses since #31478), the refusal is recorded in `refusedAtRehydrate`, keyed by manifest id and `installedAt` like #21822's protocol-range refusal. Then the error is rethrown, so the existing `rehydrate failed` log is unchanged. There is one record and one listing path (`notLoadedMarker`); nothing new is read at list time. - **`NotLoadedMarker`** becomes a closed union, each arm named member by member: - `{ code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange }`: unchanged (#21822); - `{ code, message }`: `code` is `resolveThrownHttpError(err).code`, so it is the engine's own code when it is a registered ADR-0112 member (all the registry's refusal classes are: `INVALID_METADATA`, `DUPLICATE_ARTIFACT_OBJECT_NAME`, `OBJECT_OWNERSHIP_CONFLICT`, `DEFAULT_RECORD_PAGE_CONFLICT`, `NAMESPACE_CONFLICT`, …). Otherwise it is the code its declared status derives. No code is coined here. `message` is the engine's sentence. The error is never spread into the marker. - **Only a refusal at register is marked** (seat ruling 3). Since #31614 a refused artifact registers none of its members, so nothing of the entry is loaded. A failure after a successful register (datasource connect, handler bind, side effects, heal) leaves a loaded package and is not marked. - **A register error with no declared 4xx** (seat ruling 4): the tree decides. `isDeclaredRegisterRefusal` classes it as the runtime's own fault, not a verdict on the package. The install door takes the lenient path for it ("loads at the next restart") and the next boot can load it. So it is not recorded and not marked, and no code is needed. - Changeset `.changeset/31566-install-local-register-refused-not-loaded.md`: `@objectstack/cloud-connection` patch. ## Tests New top-level `describe` in `packages/cloud-connection/src/marketplace-install-local-register-refusal.test.ts` (the shared harness file: a real `ObjectKernel` + `ObjectQLPlugin` booted to `kernel:ready`, the plugin's own rehydrate after it). `bootPlugin` gains the GET listing, DELETE, the logger, and an optional after-register fault. Each legacy entry is written straight to the ledger, as the earlier build's lenient cloud branch wrote it. - a default page that is not a record page (a body refusal) and a multi-package artifact whose second member is refused: the boot logs the refusal, registers none of the packages and keeps the entry. The listing serves the item with `notLoaded: { code, message }` in place of `withSampleData`. `message` equals the logged error's message, and the marker's keys are exactly `code` and `message`. - CONTROL: a healthy entry beside a register-refused one and a protocol-incompatible one is listed byte for byte as it is with no refused entry. The protocol entry's marker is byte for byte `{ code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange }`. - DELETE removes the refused entry, and the listing no longer serves it. - A compatible version installed over the refused entry is loaded and listed with no marker. - CONTROL: a register fault declaring no status is not marked, and the next boot loads the package. - CONTROL: a failure after a successful register (the side-effects replay throws) is not marked; the package is registered. - No existing test was edited (no forced test edits). `bootPlugin`'s `install` helper was folded into a shared `call` helper with the same request shape. ## Acceptance notes - **Console (no `ui/**` change)**: both readers of the marker (`MarketplacePackagePage.tsx` `notLoadedReason`, `MarketplaceRowActions.tsx` `MarketplaceNotLoaded`) branch on `code === 'OS_PROTOCOL_INCOMPATIBLE'` and draw `marketplace.notLoaded.otherReason` with the code for any other code. A register-refused entry shows the "not loaded" badge with its code and offers no sample-data actions. The console does not show the new `message` yet. Its `LocalInstallNotLoaded` type still declares `requiredRange: string` as required, which this arm does not carry. That is a type drift, and runtime is unaffected because the field is read only under the protocol code. Both items are listed for the seat. - The served sentence is the one the install door's `422 PLUGIN_REGISTER_FAILED` already gives the installer. The listing serves the marker to every authenticated caller, as #21822's marker is served. The sentence names packages, objects and pages, but no data. - Not measured: the two sample-data doors (reseed, purge) refuse only a protocol-incompatible entry (`refuseProtocolIncompatibleEntry` judges `checkProtocolCompat` on the entry, not `refusedAtRehydrate`). A register-refused entry is not refused there. Listed for the seat. ## Verification Head `6202c2e0`: the fix `dd867944` with origin/main merged as `99364ea7`, which carries #31584 (same package, other files). The branch was cut from `8784ccbc`, which carries #31614 / `bafbc681`. <details><summary>Red first (the new pins on main's code)</summary> `vitest run src/marketplace-install-local-register-refusal.test.ts` with the test change only: `Tests 5 failed | 14 passed (19)`. The two refusal cases, DELETE and the reinstall fail because the refused entry is served `withSampleData: false` with no `notLoaded`. In the CONTROL, the healthy and protocol-marker assertions held on main; only its last line (the refused entry's marker) failed. The two fault CONTROLs pass on main. </details> <details><summary>Ablation (scripts/ablation-replace.mjs, anchor x1 each, restored blob == HEAD 377426e and git diff HEAD empty after each leg)</summary> The plugin is imported from `src` by a relative path (no dist leg). - A1, the refusal not recorded (main's behaviour): `5 failed | 2 passed`, the five above. - A2, every register throw recorded: `1 failed | 6 passed`, the register-fault CONTROL. - A3, every rehydrate failure recorded in the outer catch: `2 failed | 5 passed`, both fault CONTROLs. - A4, the error spread into the marker: `2 failed | 5 passed`, the two closed-marker cases. </details> <details><summary>Suites, typecheck, lint, gates</summary> - On `6202c2e0`, after `pnpm install --frozen-lockfile` and `turbo run build --filter='@objectstack/cloud-connection...'` (39/39 tasks): `pnpm --filter @objectstack/cloud-connection test`: Test Files 80 passed (80), Tests 1105 passed (1105); `pnpm --filter @objectstack/cloud-connection typecheck` (both tsconfigs): exit 0. The lock's VERDICT was command-exit 0. - On `dd867944` before the merge: Test Files 78 passed (78), Tests 1074 passed (1074); typecheck exit 0. - Lint: `pnpm exec eslint --no-inline-config packages/cloud-connection`: exit 0. - Derived gates on `6202c2e0`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` listed 69 families, and all 69 ran with exit 0. `--ran` reconciliation: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero)". On `dd867944` the same 69 also ran. Two of them first exited 3 on prerequisites (`check-plugin-teardown-shape --self-test` needs fixture commit `621a4876` fetched; `check:dual-build-cjs-loads` needs every package's dist) and then ran green. - Dogfood battery: n/a (widening, no `!`, no `(narrowing)` arm). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPANpbDYYSFnjd1B9Q)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 06060c6 commit d866fdd

3 files changed

Lines changed: 242 additions & 23 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
fix(cloud-connection): an installed app whose register the engine refuses at boot is listed as not loaded, with the refusal's code and sentence
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
An earlier build could ledger a cloud-fetched install whose register the engine refuses (a default page that is not a record page, an object another package owns, two packages of one artifact defining one object, and the like). At every boot the rehydrate meets the same refusal, logs `rehydrate failed`, loads nothing of the package and keeps its ledger entry. `GET /api/v1/marketplace/install-local` listed such an entry as installed, with `withSampleData` and no `notLoaded` marker, so Installed Apps showed an app that the runtime does not hold.
11+
12+
The rehydrate now records a register refusal (a throw declaring a 4xx status, the rule the install door uses to refuse it) where it records the protocol-range refusal. The listing then marks the entry `notLoaded: { code, message }` in place of `withSampleData`: `code` is the refusal's ADR-0112 code and `message` is the engine's sentence, the same one the boot logs. The `notLoaded` marker is now a closed union of two arms. The protocol arm `{ code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange }` is unchanged. A healthy entry is listed as before. DELETE and a compatible reinstall still act on the marked entry. A register fault that declares no 4xx is not marked, because the next boot can load it. A failure after a successful register is not marked either, because the package is loaded. The console's Installed Apps already shows a non-protocol code as "not loaded".

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ import {
394394
type ObjectOwnerDeclaration,
395395
type RedeclaredOwnerFieldRefusal,
396396
} from '@objectstack/metadata-protocol';
397-
import { resolveThrownHttpError } from '@objectstack/types';
397+
import { resolveThrownHttpError, type ThrownHttpError } from '@objectstack/types';
398398
import { unknownQueryParamRefusalMessage } from '@objectstack/rest';
399399

400400
/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */
@@ -1209,19 +1209,34 @@ function seedDatasetsOf(manifest: any): PurgeSeedDataset[] {
12091209
}
12101210

12111211
/**
1212-
* [#21822] The marker the GET listing puts on a ledger entry this boot's
1213-
* `kernel:ready` rehydrate refused to load: the refusal's `code` and the range
1214-
* the package declares.
1212+
* [#21822, #31566] The marker the GET listing puts on a ledger entry this
1213+
* boot's `kernel:ready` rehydrate refused to load. A CLOSED union, one arm per
1214+
* refusal the rehydrate records, each arm's members named one by one:
12151215
*
1216-
* CLOSED: exactly these two members, named one by one from the handshake's own
1217-
* diagnostic, the way `protocolIncompatibleAnswer` names its `details`. ⛔ Not a
1218-
* spread of the diagnostic: a member added to it later does not reach the
1219-
* listing without a decision here.
1216+
* - [#21822] ADR-0087 D1's handshake refused the entry's declared protocol
1217+
* range: the refusal's `code` and the range the package declares, from the
1218+
* handshake's own diagnostic, the way `protocolIncompatibleAnswer` names
1219+
* its `details`;
1220+
* - [#31566] the engine refused the entry's register
1221+
* ({@link isDeclaredRegisterRefusal}): the refusal's `code` (the resolver's
1222+
* ADR-0112 vocabulary member — the engine's own code when it is
1223+
* registered, otherwise the code its declared status derives, never one
1224+
* coined here) and its sentence. A refusal judges before any of the
1225+
* artifact's writes stand, so nothing of the entry is loaded.
1226+
*
1227+
* ⛔ Not a spread of the diagnostic or the error: a member added to either
1228+
* later does not reach the listing without a decision here, and neither does a
1229+
* third arm.
12201230
*/
1221-
interface NotLoadedMarker {
1222-
code: ProtocolIncompatibleDiagnostic['code'];
1223-
requiredRange: ProtocolIncompatibleDiagnostic['requiredRange'];
1224-
}
1231+
type NotLoadedMarker =
1232+
| {
1233+
code: ProtocolIncompatibleDiagnostic['code'];
1234+
requiredRange: ProtocolIncompatibleDiagnostic['requiredRange'];
1235+
}
1236+
| {
1237+
code: ThrownHttpError['code'];
1238+
message: ThrownHttpError['message'];
1239+
};
12251240

12261241
/**
12271242
* [ADR-0093 D4/D5, ADR-0105 D1 / #5262] Is an organization wall actually IN
@@ -1429,9 +1444,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
14291444
*/
14301445
private readonly bootUserCodeIds = new Set<string>();
14311446
/**
1432-
* [#21822] What this boot's `kernel:ready` rehydrate refused to load under
1433-
* ADR-0087 D1's handshake, keyed by manifest id: the refused entry's
1434-
* `installedAt` and the marker the GET listing serves for it.
1447+
* [#21822, #31566] What this boot's `kernel:ready` rehydrate refused to
1448+
* load — under ADR-0087 D1's handshake, or because the engine refused its
1449+
* register — keyed by manifest id: the refused entry's `installedAt` and
1450+
* the marker the GET listing serves for it ({@link NotLoadedMarker}).
14351451
*
14361452
* The record of what the rehydrate DID, written by it alone, never a second
14371453
* judgement of the ledger: the listing does not re-run the handshake.
@@ -1613,7 +1629,28 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
16131629
// `packages[]` bundle included, whatever its entry states
16141630
// ([#28534], {@link asMarketplaceRegistration}), a ledger entry
16151631
// written before that rule included.
1616-
await manifestService!.register(asMarketplaceRegistration(converted), { source: 'marketplace' });
1632+
try {
1633+
await manifestService!.register(asMarketplaceRegistration(converted), { source: 'marketplace' });
1634+
} catch (err: unknown) {
1635+
// [#31566] A register the engine REFUSED (a declared 4xx,
1636+
// {@link isDeclaredRegisterRefusal}) is a ledger entry an
1637+
// earlier build wrote before the install door refused it
1638+
// (#31478). It meets the same refusal at every boot, and
1639+
// nothing of it is loaded — a refused artifact registers
1640+
// none of its members — so it is recorded where #21822's
1641+
// refusal is, and the listing marks it not loaded instead
1642+
// of installed. A fault (no 4xx declared) is not recorded:
1643+
// the next boot can load it. Either way the catch below
1644+
// logs it, as before.
1645+
if (isDeclaredRegisterRefusal(err)) {
1646+
const refusal = resolveThrownHttpError(err);
1647+
this.refusedAtRehydrate.set(entry.manifestId, {
1648+
installedAt: entry.installedAt,
1649+
marker: { code: refusal.code, message: refusal.message },
1650+
});
1651+
}
1652+
throw err;
1653+
}
16171654
// Sync schemas so the driver creates tables for the newly-
16181655
// registered objects (idempotent — already-synced tables
16191656
// are no-ops).
@@ -3220,6 +3257,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
32203257
* { …, "installedAt": "…", "notLoaded": { "code": "OS_PROTOCOL_INCOMPATIBLE", "requiredRange": "^16" } }
32213258
*
32223259
* read from what the rehydrate recorded ({@link refusedAtRehydrate}).
3260+
* [#31566] An entry whose register the engine refused at the rehydrate (an
3261+
* earlier build ledgered it) is marked the same way, with that refusal's
3262+
* code and sentence ({@link NotLoadedMarker}):
3263+
*
3264+
* { …, "notLoaded": { "code": "INVALID_METADATA", "message": "Page \"…\" of package \"…\": …" } }
3265+
*
32233266
* `notLoaded` stands in place of `withSampleData`: none of the package's
32243267
* objects are registered, so no seed row is read for it and the listing
32253268
* makes no claim about its rows — omitted, not `false`, for the reason

0 commit comments

Comments
 (0)