Skip to content

Commit df34d37

Browse files
fix(cloud-connection): install-local judges datasource-name holders with one judge that reads the engine and fails closed, and a rehydrate never takes over a held name (#28418)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01LjZwNRFiG2fkp8SNf6rHNx?thread=cmsg_01LjZwNRFiG2fkp8SNf6rHNxHicY1SvtJqLC6d7EFA3M2v)_ Fixes #28143 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:本地安装包(install-local)时,判断"数据源名字是否已被别人占用"改由 runtime 里唯一的判定函数 `datasourceNameHolder` 来做;它除了读元数据,还读引擎上的驱动和代码数据源集合,读不到时拒绝安装(503),不再放行。重启时的 rehydrate 遇到被别人占用的名字,不再接管,只记一条 error 日志;卸载只关闭包自己打开的连接池。 - 为什么改:三个漏洞都在 main 上实测成立:元数据读失败时安装照样 200;只在引擎上注册的数据源(旧的 onEnable 桥)看不见,安装 200;早于 #28039 安装的包在重启时会覆盖 Setup 里同名数据源、把它变成只读,卸载时还会关掉 Setup 的连接池。 - 风险与回滚:只影响声明了数据源的包;新增的拒绝只在名字确实被占或读不到时出现。回滚即 revert 本 PR。 - 先看:`packages/runtime/src/declared-datasources.ts`(`datasourceNameHolder`、`leaveHeldNames`、`withdrawPackageDatasources` 的 `found` 判断),`packages/cloud-connection/src/marketplace-install-local-plugin.ts` 2a 段与 `findHeldDatasourceName`。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/declared-datasources.test.ts`,预期 17 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **One judge, in the runtime.** `@objectstack/runtime` exports `datasourceNameHolder(ctx, appId, name)` → `free | held (holder) | unreadable (reading, cause)`. It reads, in order: the host's `default`; the metadata registry (an item not stamped with the package: a Setup row, another package's); the code-datasource set (a name another installed package recorded, or one the host registers from code); the data engine (`objectql.getDriverByName`, a driver with no metadata item: the legacy `onEnable` bridge). The package's own earlier registration or recorded pool is `free`, so a reinstall passes. An absent service holds nothing; a read that throws is `unreadable`, never `free`. - **Install door, step 2a** (`marketplace-install-local-plugin.ts`) judges with it. A held name is the existing `409 RESOURCE_CONFLICT` (`details.datasource`), now including an engine-only holder. A holder that cannot be read is `503 SERVICE_UNAVAILABLE` with `details: { datasource, reading }`, before any write, worded like the access-upgrade check's 503 ("a check that did not run is never taken for no holder"). A runtime without the judge is that 503 as well. - **Installed calls of `connectPackageDatasources`** (install and every rehydrate) judge each declared name after withdrawing the package's own earlier registrations. A held or unreadable name is left out of all three steps (no code registration, not recorded in the code-datasource set, not connected), and one `error` line names each, its holder, the consequence and the remedy. The boot path (`installed` unset) is unchanged and still hands `connectDeclared` the very list it was given. - **Uninstall closes only what the package opened.** The per-package record now keeps, beside the names, the ones whose connect answered `already-registered`; the withdrawal does not `disconnect` those (their holder's pool), while still removing the package's registration and name. ## Why The three edges the card names, each measured on `origin/main` `75ecaf4a` before the fix, with the tests below run against the unchanged source: 1. **Read fault fails open.** `metadata.get` throwing → the install answered `200` (`expected 200 to be 503`). 2. **Engine-only holder unseen.** A driver on the engine with no metadata item → the install answered `200` (`expected 200 to be 409`). 3. **Rehydrate takes over a Setup row.** With a Setup row and its pool holding `pkg_store`, an installed call returned `pkg_store`'s verdict alongside the package's other datasource (it registered, recorded and "connected" the name); and an uninstall disconnected a pool the connect had only found (`disconnected` was `['pkg_store','pkg_vault']`, expected `['pkg_vault']`). The card's choice for (1)+(2) was "fail closed or read the engine": both are done, since the engine read alone does not close (1). ## Tests - `packages/runtime/src/declared-datasources.test.ts`: 9 new cases. The judge: each holder kind (default, Setup row, another package, engine-only), a code-set name, a metadata or engine read fault is `unreadable`, the package's own registration and recorded pool are `free` (with a control from another package's side), nothing anywhere is `free`. The installed call: a Setup row is not taken over and the uninstall leaves its pool and item alone; an engine-only holder is not taken over; an unreadable holder is not taken over and is reported; a pool found `already-registered` is not disconnected by the uninstall. The fake kernel gained an opt-in engine whose driver registry the fake connect fills, as the real one does. - `packages/cloud-connection/src/marketplace-install-local-datasource-holder.test.ts` (new): through the real install route, a metadata read fault answers `503 SERVICE_UNAVAILABLE` and writes no ledger entry or registration; an engine-only holder answers `409 RESOURCE_CONFLICT`; a control with an unrelated engine driver installs `200`. ## Acceptance notes - **"Records it on the install record"** is read as the runtime's per-package install record (what the uninstall reads): a held name is not in it, so the uninstall never touches it. The rehydrate's report is the `error` line. Nothing new is added to the GET listing or the ledger file; a listing marker like `notLoaded` would be a separate wire decision. - **A kernel with no `metadata` service** is not an outage: no metadata holder can exist there, and the package's own registration is skipped the same way, so the judge goes on to the code set and the engine rather than refusing. - **On a rehydrate, an unreadable holder is left alone too** (not taken over, reported), which leaves that package datasource unconnected until a restart reads the holder. - One file outside the claimed surface: `packages/runtime/src/index.ts`, the barrel line exporting `datasourceNameHolder` and its type. ## Verification All readings are on head `6aa099f6af` (base `origin/main` `75ecaf4a`; the 5 commits main gained since touch none of these files), in a dedicated worktree. - **Measured on main first:** the two new test files run against the unchanged source of each package: cloud-connection `Tests 2 failed | 1 passed (3)` (`expected 200 to be 503`, `expected 200 to be 409`, control green); runtime `Tests 9 failed | 8 passed (17)` (the 5 judge cases have no export to call; the 4 installed-call cases fail on the takeover and the eviction). - **runtime:** `pnpm --filter @objectstack/runtime build && test && typecheck` under the verify lock → `VERDICT command-exit 0`; `Test Files 492 passed (492)`, `Tests 8010 passed | 19 skipped (8029)`. - **cloud-connection:** `pnpm --filter @objectstack/cloud-connection test && typecheck` under the lock → `VERDICT command-exit 0`; `Test Files 63 passed (63)`, `Tests 768 passed (768)`. - **Dogfood, the one file this path owns:** `install-local-declared-datasources.dogfood.test.ts` after a full `pnpm build` → `Tests 8 passed (8)` (refusals of `showcase_external` and `default`, install, uninstall, rehydrate all unchanged). - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 commands; all 60 exited 0. Three first answered `3` / timed out for a missing prerequisite (teardown-shape self-test on a shallow clone; `check:dual-build-cjs-loads` without a full build; `check:type-check-debt` cut by my own 300 s cap during its closure build) and were re-run to exit 0 after `git fetch --unshallow` and a full `pnpm build`. `--ran`: `60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN`. - **Lint:** `pnpm lint` → exit 0. - `check:platform-checklist` not run: nothing exported is renamed or removed (one export added). - Dogfood battery: n/a (no `!` in the title, no `(narrowing)` arm on the claim's `Clause-②:` line). <details><summary>Ablations (one-off, not kept)</summary> Each through `node scripts/ablation-replace.mjs` under the verify lock; the unit suites import the mutated module from source, so no rebuild was involved. Predictions written before each run. 1. `declared-datasources.ts`, anchor `if (holding.status === 'free') continue;` → `… || holding) continue;` (nothing is ever left out). Predicted the three not-taken-over cases red. `anchor 1 -> 0, blob ddb9ddc -> 6f7e7e257f17`; `Tests 3 failed | 14 passed (17)`, exactly those three; restored `blob == HEAD`, `git diff HEAD` empty. 2. `declared-datasources.ts`, the withdrawal's `&& !record?.found.has(name)` dropped (every recorded pool closed). Predicted the found-pool case red. `anchor 1 -> 0, blob ddb9ddc -> 334b4cea3bdb`; `Tests 1 failed | 16 passed (17)`; restored `blob == HEAD`. 3. `marketplace-install-local-plugin.ts`, the 503 branch made unreachable (a guard that is always false: the current time is negative). Predicted the read-fault case red. `anchor 1 -> 0, blob 86f1411 -> 32eb6f3d33e1`; `Tests 1 failed | 2 passed (3)`; restored `blob == HEAD`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a, 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 (the 维护者速读 block is the repo's required Chinese maintainer summary). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BuoLdzNi2KSkEgUS5q)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent af6ca00 commit df34d37

6 files changed

Lines changed: 595 additions & 45 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/cloud-connection': patch
4+
---
5+
6+
fix(cloud-connection): an install-local install judges who holds a datasource name with one judge that reads the engine too and fails closed, and a restart's rehydrate no longer takes over a name another holder owns
7+
8+
Clause-②: yes (widening)
9+
The widening: `@objectstack/runtime` now exports `datasourceNameHolder` and the type `DatasourceNameHolding`, and `POST /api/v1/marketplace/install-local` gains a `503 SERVICE_UNAVAILABLE` answer.
10+
Metadata-protocol: none
11+
12+
Three edges `POST /api/v1/marketplace/install-local`'s datasource-name check left open, each measured on `main`:
13+
14+
- **A holder that cannot be read.** When the metadata registry's read of a declared datasource name threw, the check skipped it and the install went ahead (`200`). It now answers `503 SERVICE_UNAVAILABLE` with `details: { datasource, reading }` and writes nothing: a check that did not run is never taken for "no holder". Retry once that store answers.
15+
- **A datasource registered on the data engine only** (the legacy `onEnable` bridge, which leaves no metadata item) was not seen: the install answered `200` and reported that pool as the package's `already-registered`. It now holds its name, `409 RESOURCE_CONFLICT` like every other holder.
16+
- **A package installed before the check existed.** A restart's rehydrate replayed such a package's datasources over a datasource created in Setup under the same name: the Setup item was overwritten by the package's code registration, the name joined the code-defined set (the Setup row turned read-only), and the package's uninstall closed and evicted the Setup row's pool. The rehydrate now leaves a held name alone: it is not registered, recorded or connected for the package, one `error` line names it, its holder and the remedy, and the uninstall does not touch it. An uninstall also closes only the pools the package's own connect opened, never one it found already registered.
17+
18+
`@objectstack/runtime` exports `datasourceNameHolder`, the one judge both the install door and the rehydrate use: the host's `default`, a metadata item not stamped with the package (a Setup row, another package's), a name in the code-datasource set the package did not record, then a driver on the data engine.
19+
20+
**The remedy** for a package the rehydrate now reports: rename the package's datasource and install it again, or remove the datasource holding the name and restart.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28143] Step 2a of `POST /api/v1/marketplace/install-local` — a package
5+
* declaring a datasource name another holder owns is refused before any write
6+
* — judges with the runtime's ONE judge (`datasourceNameHolder`), and so:
7+
*
8+
* - a holder the metadata registry cannot be read for is `503
9+
* SERVICE_UNAVAILABLE`, never taken for "no holder": the install does not
10+
* proceed past an outage;
11+
* - a datasource registered on the data engine only (the legacy `onEnable`
12+
* bridge, with no metadata item) holds its name: `409 RESOURCE_CONFLICT`,
13+
* where the door used to install and answer that pool `already-registered`.
14+
*
15+
* Each refusal leaves the runtime as it found it: no ledger entry, nothing
16+
* registered. The end-to-end refusals (the boot's datasource, the host's
17+
* `default`) are pinned by `install-local-declared-datasources.dogfood.test.ts`.
18+
*/
19+
20+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
21+
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
22+
import { join } from 'node:path';
23+
import { tmpdir } from 'node:os';
24+
// The install and its 2a judge reach `@objectstack/runtime` through a lazy
25+
// `import()`; its first load is paid here, at module top — never inside a
26+
// clocked `it` (`scripts/check-test-source-alias.mjs`).
27+
import '@objectstack/runtime';
28+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
29+
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
30+
31+
type Handler = (c: any) => Promise<any>;
32+
33+
const ROUTE = 'POST /api/v1/marketplace/install-local';
34+
const DS = 'qah_store';
35+
36+
function makeRawApp() {
37+
const routes = new Map<string, Handler>();
38+
return {
39+
routes,
40+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
41+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
42+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
43+
};
44+
}
45+
46+
/** A kernel whose metadata registry and data engine the case shapes. */
47+
function makeCtx(rawApp: any, opts: { metadataFault?: boolean; engineDrivers?: string[] }) {
48+
const hooks = new Map<string, any>();
49+
const registered: any[] = [];
50+
const drivers = new Set(opts.engineDrivers ?? []);
51+
const services: Record<string, any> = {
52+
manifest: { register: (m: any) => registered.push({ manifest: m }) },
53+
auth: installerAuthService(),
54+
objectql: withInstallerGrants({
55+
syncSchemas: async () => undefined,
56+
registry: { getAllPackages: () => registered },
57+
getDriverByName: (name: string) => (drivers.has(name) ? { name } : undefined),
58+
}),
59+
metadata: {
60+
get: async () => {
61+
if (opts.metadataFault) throw new Error('metadata loader unavailable');
62+
return undefined;
63+
},
64+
},
65+
};
66+
return {
67+
registered,
68+
ctx: {
69+
hook: (e: string, h: any) => hooks.set(e, h),
70+
getService: (name: string) => {
71+
if (name === 'http-server') return { getRawApp: () => rawApp };
72+
const svc = services[name];
73+
if (svc === undefined) throw new Error(`no ${name}`);
74+
return svc;
75+
},
76+
registerService: (name: string, svc: unknown) => { services[name] = svc; },
77+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
78+
},
79+
fire: async () => { await hooks.get('kernel:ready')?.(); },
80+
};
81+
}
82+
83+
function makeC(body: any) {
84+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
85+
return { req: { url: 'http://localhost:3000/x', raw: new Request('http://localhost:3000/x'), json: async () => body, param: () => undefined }, json };
86+
}
87+
88+
const BODY = {
89+
manifest: {
90+
id: 'com.example.qah',
91+
version: '1.0.0',
92+
datasources: [{ name: DS, label: 'QA store', driver: 'sqlite', config: { filename: ':memory:' } }],
93+
objects: [],
94+
},
95+
};
96+
97+
let dir: string;
98+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-ds-holder-')); });
99+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
100+
101+
async function install(opts: { metadataFault?: boolean; engineDrivers?: string[] }) {
102+
const rawApp = makeRawApp();
103+
const k = makeCtx(rawApp, opts);
104+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
105+
await plugin.start(k.ctx as any);
106+
await k.fire();
107+
const res = await rawApp.routes.get(ROUTE)!(makeC(BODY));
108+
// The plugin registers its own Installed Apps UI manifest at start; only
109+
// the package's registration is this case's.
110+
const registered = k.registered.map((r) => r.manifest.id).filter((id) => id === 'com.example.qah');
111+
return { res, registered, ledger: readdirSync(dir) };
112+
}
113+
114+
describe('install-local 2a — who holds a declared datasource name', () => {
115+
it('a holder that cannot be read refuses 503, and nothing is written', async () => {
116+
const { res, registered, ledger } = await install({ metadataFault: true });
117+
expect(res.status).toBe(503);
118+
expect(res.payload?.error?.code).toBe('SERVICE_UNAVAILABLE');
119+
expect(res.payload?.error?.details).toEqual({ datasource: DS, reading: 'the metadata registry' });
120+
expect(registered).toEqual([]);
121+
expect(ledger).toEqual([]);
122+
});
123+
124+
it('a datasource registered on the engine only holds its name: 409, and nothing is written', async () => {
125+
const { res, registered, ledger } = await install({ engineDrivers: [DS] });
126+
expect(res.status).toBe(409);
127+
expect(res.payload?.error?.code).toBe('RESOURCE_CONFLICT');
128+
expect(res.payload?.error?.details).toEqual({ datasource: DS });
129+
expect(registered).toEqual([]);
130+
expect(ledger).toEqual([]);
131+
});
132+
133+
it('CONTROL: a name nobody holds installs', async () => {
134+
const { res, registered } = await install({ engineDrivers: ['someone_else'] });
135+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
136+
expect(res.payload?.success).toBe(true);
137+
expect(registered).toEqual(['com.example.qah']);
138+
});
139+
});

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

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1812,7 +1812,28 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
18121812
// hand the package's objects that holder's pool (the connect is
18131813
// idempotent on the name), and recording the name as code would
18141814
// make the holder's own datasource read-only to the admin door.
1815+
// [#28143] Judged by the runtime's ONE judge, which reads the data
1816+
// engine as well (a driver the legacy `onEnable` bridge registered
1817+
// has no metadata item), and a holder that cannot be read refuses
1818+
// `503`: a check that did not run is never taken for "no holder".
18151819
const heldDatasource = await this.findHeldDatasourceName(ctx, manifest, manifestId);
1820+
if (heldDatasource && 'reading' in heldDatasource) {
1821+
ctx.logger?.warn?.(
1822+
`[MarketplaceInstallLocal] install of ${manifestId} refused: whether another holder owns its datasource `
1823+
+ `'${heldDatasource.name}' could not be judged — ${heldDatasource.reading} could not be read (${heldDatasource.error})`,
1824+
);
1825+
return c.json({
1826+
success: false,
1827+
error: {
1828+
code: 'SERVICE_UNAVAILABLE',
1829+
message: `Package '${manifestId}' cannot be installed now: it declares the datasource '${heldDatasource.name}', `
1830+
+ `and whether another holder already owns that name could not be checked (${heldDatasource.reading} `
1831+
+ 'could not be read). A check that did not run is never taken for "no holder". Nothing was installed '
1832+
+ 'or written. Retry once that store answers.',
1833+
details: { datasource: heldDatasource.name, reading: heldDatasource.reading },
1834+
},
1835+
}, 503);
1836+
}
18161837
if (heldDatasource) {
18171838
return c.json({
18181839
success: false,
@@ -3601,37 +3622,54 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
36013622

36023623
/**
36033624
* [#28039] The first datasource `manifest` declares whose name another
3604-
* holder already owns, with that holder: the host's `default`, or a
3605-
* datasource in the metadata service not stamped with this package (one
3606-
* created in Setup, the boot artifact's, another package's). A datasource
3607-
* this package's earlier install registered is its own, so a reinstall
3608-
* passes. `undefined` when nothing collides or nothing can be read.
3625+
* holder already owns, with that holder — or [#28143] whose holder could
3626+
* not be read, with what could not be. `undefined` when every name is free.
3627+
*
3628+
* The judge is the runtime's `datasourceNameHolder`, the one the
3629+
* rehydrate's `connectPackageDatasources` leaves a held name alone on: the
3630+
* host's `default`, a datasource item in the metadata registry not stamped
3631+
* with this package (one created in Setup, another package's), a name in
3632+
* the host's code-datasource set this package did not record, and a driver
3633+
* registered on the data engine with no metadata item. This package's own
3634+
* earlier registration is its own, so a reinstall passes.
3635+
*
3636+
* ⛔ Never `undefined` on a read fault: a holder that cannot be read is the
3637+
* `reading` answer, which the door refuses `503`. A runtime without the
3638+
* judge is that answer too.
36093639
*/
36103640
private findHeldDatasourceName = async (
36113641
ctx: PluginContext,
36123642
manifest: unknown,
36133643
manifestId: string,
3614-
): Promise<{ name: string; holder: string } | undefined> => {
3644+
): Promise<{ name: string; holder: string } | { name: string; reading: string; error: string } | undefined> => {
36153645
const names = declaredDatasources(manifest)
36163646
.map((ds) => ds?.name)
36173647
.filter((name): name is string => typeof name === 'string' && name.length > 0);
36183648
if (names.length === 0) return undefined;
3619-
if (names.includes('default')) return { name: 'default', holder: "the host's primary datasource" };
3620-
let metadata: { get?: (type: string, name: string) => Promise<unknown> } | undefined;
3621-
try { metadata = ctx.getService('metadata'); } catch { return undefined; }
3622-
for (const name of names) {
3623-
let item: unknown;
3624-
try { item = await metadata?.get?.('datasource', name); } catch { continue; }
3625-
if (!isPlainObject(item)) continue;
3626-
const owner = item._packageId;
3627-
if (owner === manifestId) continue;
3649+
let judge: typeof import('@objectstack/runtime')['datasourceNameHolder'] | undefined;
3650+
let loadError: unknown;
3651+
try {
3652+
const mod: any = await import('@objectstack/runtime');
3653+
if (typeof mod?.datasourceNameHolder === 'function') judge = mod.datasourceNameHolder;
3654+
} catch (err) {
3655+
loadError = err;
3656+
}
3657+
if (!judge) {
36283658
return {
3629-
name,
3630-
holder: typeof owner === 'string' && owner.length > 0
3631-
? `the package '${owner}'`
3632-
: item.origin === 'runtime' ? 'a datasource created in Setup' : 'a datasource',
3659+
name: names[0],
3660+
reading: "the runtime's datasource-name judge",
3661+
error: loadError !== undefined
3662+
? String((loadError as Error)?.message ?? loadError)
3663+
: '@objectstack/runtime has no datasourceNameHolder — upgrade it alongside @objectstack/cloud-connection',
36333664
};
36343665
}
3666+
for (const name of names) {
3667+
const holding = await judge(ctx, manifestId, name);
3668+
if (holding.status === 'held') return { name, holder: holding.holder };
3669+
if (holding.status === 'unreadable') {
3670+
return { name, reading: holding.reading, error: String((holding.cause as Error)?.message ?? holding.cause) };
3671+
}
3672+
}
36353673
return undefined;
36363674
};
36373675

0 commit comments

Comments
 (0)