Skip to content

Commit ae5bd5b

Browse files
fix(service-datasource): name the reason and the bound objects in the datasource delete refusal (#30513)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgXfJgMndozTHYhLa6rZRghr)_ domain:server#2 · os-bill · session_01Utp6Cb4nPCxmCZ7EzB1A1v Fixes #30444 Clause-②: yes (widening) — the bound-object refusal keeps `400` `DATASOURCE_ADMIN_ERROR` and its message, and gains `error.details`; nothing a client reads today moves. Metadata-protocol: none ## 维护者速读 - 改了什么:删除一个仍有对象在用的数据源时,拒绝答复除了原来那句英文,还在 `error.details` 里带上机器可读的原因 `objects_bound`、数量和这些对象的名字(排好序的完整列表)。状态码、错误码和英文原句都不变。 - 为什么改:以前拒绝只有一句英文和一个数字,客户端分不清这是哪种 400,也说不出是哪些对象挡着;设置页只能自己再去读对象元数据才能告诉管理员。现在任何客户端(设置页、CLI、API 调用方)都能用自己的语言直接列出对象名。 - 风险与回滚:只加字段,不改已有的字段。名字和数量出自同一次读取,二者不会对不上;元数据读不全时的 503 拒绝原样不动;其他 400 一律不带 `details`,路由只转发自己声明了 400 信封的拒绝的 `details`,驱动错误身上碰巧有 `details` 也不会漏出去。回滚即撤本 PR。 - 先看:`packages/services/service-datasource/src/admin-routes.ts` 的 `badRequest`(转发 `details` 的三重条件);`datasource-admin-service.ts` 的 `boundObjectsError` 与 `removeDatasource`;`datasource-admin-plugin.ts` 的 `countBoundObjectsDiagnosed`(名字与数量同出一次读取)。 - 两分钟验证:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/admin-routes.test.ts`,预期 1 个文件全绿,其中 `#30444` 一组 5 条。 - 需要你做的:无,席位落地。 ## What changes **The rule: the bound-object refusal says why and names what is in the way, in `error.details`, from the read that produced its count.** - **`DatasourceAdminService.removeDatasource` (`@objectstack/service-datasource`).** The bound-object refusal is built by `boundObjectsError`: the same message (`Cannot remove datasource 'NAME': N object(s) are still bound to it.`), plus `code: 'DATASOURCE_ADMIN_ERROR'`, `status: 400` and `details: { reason: 'objects_bound', count, objects }`. `objects` is the full list of bound object names, sorted; no cap (the list is bounded by the objects bound to one datasource). - **One read.** `DatasourceAdminServiceConfig.countBoundObjectsDiagnosed` may now return `objects?: string[]`, and the plugin wiring returns the names of the same filtered `listObjects()` rows it counts. The count in the message, `details.count` and `details.objects` all come from that one result. ⛔ Not `listObjectsBoundTo`: that is a second scan over another registry (the engine's), and a refusal built from two reads could say "2 objects" and name three. A host that supplies only `countBoundObjects` (no diagnosed read) gets `{ reason, count }` without `objects`. - **`badRequest` in `admin-routes.ts`.** A thrown error whose `status` is `400`, whose `code` is the route's own `SERVICE_ERROR_CODE[service]` and whose `details` is a plain object is answered with that `details`. All three must hold, the same pattern as the existing `503` and `404` relays, so a driver or library error that carries a stray `details` field keeps answering the bare message. Every other 400 answers exactly as before. - **`details.reason` spelling.** snake_case, following the envelope's existing `details.reason` values in `plugin-auth` (`invalid_body`, `user_not_found`, `cross_organization`, …) and AGENTS.md's rule for machine-name data values. `service-settings` spells its one reason in kebab-case (`locked-by-env`); this package had no prior `details.reason`. - **Unchanged:** the status, the code (no new ledger code; `packages/spec` untouched), the English message, the degraded-read refusal (`503` `SERVICE_UNAVAILABLE`, `metadataIncompleteError`), the code-defined refusal, the 204 delete. The `IDatasourceAdminService.removeDatasource` docblock names the new `details`. No hand-written doc page states this refusal (searched `content/docs` outside `references/` and `releases/` for the message and the code). ## Why #30444: the refusal was an English sentence with a count and no machine-readable reason or names, so no client could tell it from any other 400 of this family or name the objects without re-reading metadata (PR #30442 does exactly that in Setup). ## Verification Measured first, at `6c4c43ab` (real `DatasourceAdminService` + real `registerDatasourceAdminRoutes` on `HonoHttpServer`, a runtime datasource with two bound objects and a code-defined one): - `DELETE /api/v1/datasources/parts_db` → `400` `{"success":false,"error":{"code":"DATASOURCE_ADMIN_ERROR","message":"Cannot remove datasource 'parts_db': 2 object(s) are still bound to it."}}`, no `details`. - `DELETE /api/v1/datasources/code_db` → `400` `{"success":false,"error":{"code":"DATASOURCE_ADMIN_ERROR","message":"Datasource 'code_db' is code-defined and cannot be removed at runtime."}}`. - `badRequest` forwarded the message alone (`sendError(res, 400, code, message)`, no `extra`). <details><summary>Tests</summary> - `admin-routes.test.ts` › new top-level describe `DELETE /datasources/:name — the bound-object refusal carries details (#30444)`, over the real service and routes: two bound objects answer `400` `DATASOURCE_ADMIN_ERROR`, the unchanged message and `details { reason: 'objects_bound', count: 2, objects: ['bin', 'part'] }` (sent unsorted; the harness's `listObjectsBoundTo` answers a different name on purpose), and the record survives; a count-only host answers `{ reason, count }`; nothing bound deletes with `204`; the code-defined 400 carries no `details` (exact body); an error with a stray `details` and no 400 envelope answers the bare message (exact body). - `datasource-removal-bound-count-outage.test.ts` › the plugin-wiring describe: the diagnosed read now also asserts `objects` (`['wh_line', 'wh_order']`), and the predating-`listDiagnosed` case's exact `toEqual` gains `objects: ['wh_order']`. Every other assertion in that file is unchanged and green, the degraded 503 ones included. - `pnpm --filter @objectstack/service-datasource test`: Test Files 71 passed (71), Tests 1174 passed (1174), at `bba4b00f` and again at `f4cc8056` (after merging `main`, 14 commits, none in this package or its route dependencies). `typecheck`: exit 0 (the package tsconfig compiles `src/__tests__`, counted with `--listFiles`). </details> <details><summary>Ablations (scripts/ablation-replace.mjs; the suite imports src directly, no build between legs)</summary> | # | Mutation | Result | |:--|:--|:--| | A1 | `badRequest` relays `details` from any error (drop the `status`/`code` conjuncts) | stray-`details` pin red (1 failed) | | A2 | `badRequest`'s default 400 branch attaches a `details` | code-defined pin, stray-`details` pin and the existing `surfaces lifecycle errors` exact-body pin red (3 failed) | | A3 | names taken from `listObjectsBoundTo` (a second scan) instead of the count's read | two-bound pin and count-only pin red (2 failed) | | A4 | `badRequest` stops forwarding `details` | two-bound pin and count-only pin red (2 failed) | Each mutation landed (anchor 1 to 0, replacement 0 to 1); each restore left `git diff HEAD` empty. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `bba4b00f`: 62 families, the same 62 on `f4cc8056`. All 62 run, exit captured per line before any pipe: 60 exit 0 on the first pass; `check-plugin-teardown-shape --self-test` exit 3 (shallow clone, pinned fixture commit fetched, rerun exit 0); `check:dual-build-cjs-loads` exit 3 (no `dist`, full build under the lock, rerun exit 0). `--ran`: 62 derived, 62 run, 0 NOT-MEASURED. - `pnpm lint` (`eslint . --no-inline-config`): exit 0 on `f4cc8056`. - Full dogfood under the verify lock on `bba4b00f` (after a full `turbo run build`): VERDICT command-exit 0, Test Files 419 passed, 3 skipped (422), Tests 3492 passed, 36 skipped (3528). The merge of `main` adds no file in this package. </details> ## Acceptance notes - The bound-object count (and now the names) comes from the metadata service's `listObjects()`, while `listObjectsBoundTo` reads the engine registry; the plugin's own comment says the former is a boot-time copy that does not see runtime saves. This PR keeps the guard's read as it was (ruling 3: one read, the degraded path untouched) and does not measure whether a runtime-saved object escapes the count. Noted, not filed. - Once this lands, Setup (PR #30442) may switch from reading object metadata to `error.details.objects`; that is the ui lane's call. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6Cb4nPCxmCZ7EzB1A1v)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a0516c commit ae5bd5b

7 files changed

Lines changed: 207 additions & 14 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource): the refusal to delete a datasource that objects still use names its reason and the objects in `error.details`
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
- `DELETE /api/v1/datasources/:name` on a runtime datasource that objects are still bound to keeps answering `400` `DATASOURCE_ADMIN_ERROR` with the same English message, and now also carries `error.details`: `{ reason: 'objects_bound', count, objects }`. `reason` tells this refusal apart from every other 400 of the datasource admin routes, `count` is the number in the message, and `objects` lists the bound objects' names, sorted, so a client can name them in its own language without reading object metadata. The names come from the same read as the count, so the two cannot disagree.
11+
- `DatasourceAdminServiceConfig.countBoundObjectsDiagnosed` may now return `objects` (the names it counted); the shipped plugin wiring does. A host whose read reports only a number keeps working, and its refusal carries `count` without `objects`.
12+
- Every other 400 of these routes answers exactly as before, with no `details`. The degraded-read refusal (`503` `SERVICE_UNAVAILABLE`) is unchanged.

‎packages/services/service-datasource/src/__tests__/admin-routes.test.ts‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
import { describe, it, expect, vi } from 'vitest';
44
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
55
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
6+
import {
7+
DatasourceAdminService,
8+
type DatasourceAdminServiceConfig,
9+
type StoredDatasource,
10+
} from '../datasource-admin-service.js';
611
import {
712
ENTITLED_CREDENTIAL,
813
createSessionAuthService,
@@ -368,3 +373,116 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
368373
});
369374
});
370375
});
376+
377+
/**
378+
* [#30444] The bound-object refusal of `DELETE /datasources/:name` names its
379+
* reason and the bound objects in `error.details`, over the REAL admin service
380+
* and the real routes, so a client can tell it from every other 400 of the
381+
* family and name the objects in its own language. Status, code and message
382+
* are unchanged.
383+
*/
384+
describe('DELETE /datasources/:name — the bound-object refusal carries details (#30444)', () => {
385+
interface Opts {
386+
/** The diagnosed read the host reports; omit for a host predating it. */
387+
diagnosed?: { count: number; degraded: boolean; errors: string[]; objects?: string[] };
388+
plainCount?: number;
389+
}
390+
391+
function deleteHarness(opts: Opts = {}) {
392+
const records: StoredDatasource[] = [
393+
{ name: 'parts_db', driver: 'sqlite', origin: 'runtime' } as StoredDatasource,
394+
{ name: 'code_db', driver: 'sqlite', origin: 'code' } as StoredDatasource,
395+
];
396+
const config = {
397+
probe: async () => ({ ok: true }),
398+
listDatasourceRecords: async () => records,
399+
getDatasourceRecord: async (n: string) => records.find((r) => r.name === n),
400+
putDatasourceRecord: async () => {},
401+
deleteDatasourceRecord: async (n: string) => {
402+
const i = records.findIndex((r) => r.name === n);
403+
if (i >= 0) records.splice(i, 1);
404+
},
405+
writeSecret: async () => 'sys_secret://unused',
406+
countBoundObjects: async () => opts.plainCount ?? opts.diagnosed?.count ?? 0,
407+
...(opts.diagnosed ? { countBoundObjectsDiagnosed: async () => opts.diagnosed! } : {}),
408+
// A DIFFERENT answer from the count's read, on purpose: the names must
409+
// come from the read the count came from, never from a second scan.
410+
listObjectsBoundTo: async () => [{ name: 'ghost' }],
411+
unregisterPool: () => {},
412+
} as unknown as DatasourceAdminServiceConfig;
413+
const app = mountServices({ 'datasource-admin': new DatasourceAdminService(config) });
414+
return {
415+
del: (name: string) => app.fetch(json(`/api/v1/datasources/${name}`, { method: 'DELETE' })),
416+
exists: (name: string) => records.some((r) => r.name === name),
417+
};
418+
}
419+
420+
it('two bound objects: 400 DATASOURCE_ADMIN_ERROR, the same message, and both names sorted in details', async () => {
421+
const h = deleteHarness({
422+
diagnosed: { count: 2, degraded: false, errors: [], objects: ['part', 'bin'] },
423+
});
424+
425+
const res = await h.del('parts_db');
426+
427+
expect(res.status).toBe(400);
428+
expect(await res.json()).toEqual({
429+
success: false,
430+
error: {
431+
code: 'DATASOURCE_ADMIN_ERROR',
432+
message: "Cannot remove datasource 'parts_db': 2 object(s) are still bound to it.",
433+
details: { reason: 'objects_bound', count: 2, objects: ['bin', 'part'] },
434+
},
435+
});
436+
expect(h.exists('parts_db'), 'the refused datasource survives').toBe(true);
437+
});
438+
439+
it('a host whose read reports only the number answers the count without names', async () => {
440+
const h = deleteHarness({ plainCount: 3 });
441+
442+
const body = (await (await h.del('parts_db')).json()) as any;
443+
444+
expect(body.error.code).toBe('DATASOURCE_ADMIN_ERROR');
445+
expect(body.error.details).toEqual({ reason: 'objects_bound', count: 3 });
446+
});
447+
448+
it('a datasource with nothing bound deletes as before (204)', async () => {
449+
const h = deleteHarness({ diagnosed: { count: 0, degraded: false, errors: [], objects: [] } });
450+
451+
const res = await h.del('parts_db');
452+
453+
expect(res.status).toBe(204);
454+
expect(h.exists('parts_db')).toBe(false);
455+
});
456+
457+
it('another 400 of the same door (a code-defined datasource) carries no details', async () => {
458+
const h = deleteHarness({ diagnosed: { count: 2, degraded: false, errors: [], objects: ['bin', 'part'] } });
459+
460+
const res = await h.del('code_db');
461+
462+
expect(res.status).toBe(400);
463+
expect(await res.json()).toEqual({
464+
success: false,
465+
error: {
466+
code: 'DATASOURCE_ADMIN_ERROR',
467+
message: "Datasource 'code_db' is code-defined and cannot be removed at runtime.",
468+
},
469+
});
470+
});
471+
472+
it('an error that carries a stray `details` but not the 400 envelope answers the bare message', async () => {
473+
// A driver or library error may carry a field named `details`; only a
474+
// refusal that declared the route's own `400` code relays it.
475+
const thrown = Object.assign(new Error('backing store offline'), {
476+
details: { host: 'db.internal', table: 'sys_datasource' },
477+
});
478+
const app = mountServices({ 'datasource-admin': { removeDatasource: async () => { throw thrown; } } });
479+
480+
const res = await app.fetch(json('/api/v1/datasources/parts_db', { method: 'DELETE' }));
481+
482+
expect(res.status).toBe(400);
483+
expect(await res.json()).toEqual({
484+
success: false,
485+
error: { code: 'DATASOURCE_ADMIN_ERROR', message: 'backing store offline' },
486+
});
487+
});
488+
});

‎packages/services/service-datasource/src/__tests__/datasource-removal-bound-count-outage.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,8 @@ describe('#6504 — the plugin wiring composes the count and the verdict correct
277277
const read = await config.countBoundObjectsDiagnosed!('warehouse');
278278

279279
expect(read.count, 'the count comes from listObjects(), filtered by datasource').toBe(2);
280+
// [#30444] The names ride on the same filtered listing, sorted.
281+
expect(read.objects).toEqual(['wh_line', 'wh_order']);
280282
expect(read.degraded).toBe(true);
281283
expect(read.errors).toEqual([LOADER_FAILURE]);
282284
expect(diagnosedCalls, 'the verdict is asked for `object`, the type being counted').toEqual([
@@ -295,6 +297,6 @@ describe('#6504 — the plugin wiring composes the count and the verdict correct
295297

296298
const read = await config.countBoundObjectsDiagnosed!('warehouse');
297299

298-
expect(read).toEqual({ count: 1, degraded: false, errors: [] });
300+
expect(read).toEqual({ count: 1, degraded: false, errors: [], objects: ['wh_order'] });
299301
});
300302
});

‎packages/services/service-datasource/src/admin-routes.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,18 @@ export function registerDatasourceAdminRoutes(
658658
if (envelope?.status === 404 && envelope?.code === 'RESOURCE_NOT_FOUND') {
659659
return sendError(res, 404, 'RESOURCE_NOT_FOUND', (err as Error).message);
660660
}
661+
// [#30444] A refusal that declared this route's own 400 envelope may carry
662+
// `details` (the bound-object refusal of `removeDatasource`: its reason and
663+
// the objects' names), relayed as thrown. All three must hold, so a driver
664+
// error with a stray `details` field keeps answering the bare message.
665+
const details = (err as { details?: unknown } | null | undefined)?.details;
666+
if (
667+
envelope?.status === 400
668+
&& envelope?.code === SERVICE_ERROR_CODE[service]
669+
&& details !== null && typeof details === 'object' && !Array.isArray(details)
670+
) {
671+
return sendError(res, 400, SERVICE_ERROR_CODE[service], (err as Error).message, { details });
672+
}
661673
return sendError(res, 400, SERVICE_ERROR_CODE[service], err instanceof Error ? err.message : String(err));
662674
};
663675

‎packages/services/service-datasource/src/contracts/datasource-admin-service.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,9 @@ export interface IDatasourceAdminService {
188188

189189
/**
190190
* Remove a runtime datasource. Rejects for code-defined ones and while
191-
* objects are still bound to it.
191+
* objects are still bound to it; that refusal carries `details`
192+
* `{ reason: 'objects_bound', count, objects? }`, `objects` being the bound
193+
* objects' names, sorted.
192194
*/
193195
removeDatasource(name: string): Promise<void>;
194196

‎packages/services/service-datasource/src/datasource-admin-plugin.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -560,16 +560,24 @@ export class DatasourceAdminServicePlugin implements Plugin {
560560
const metadata = metadataOf();
561561
const objects = ((await metadata?.listObjects?.()) ??
562562
(await metadata?.list('object')) ??
563-
[]) as Array<{ datasource?: string }>;
564-
const count = objects.filter((o) => o?.datasource === datasource).length;
563+
[]) as Array<{ name?: unknown; datasource?: string }>;
564+
// [#30444] The names ride on the same filtered listing as the count,
565+
// so a refusal's message and its `details` describe one read.
566+
const bound = objects.filter((o) => o?.datasource === datasource);
567+
const count = bound.length;
568+
const names = bound
569+
.map((o) => o.name)
570+
.filter((n): n is string => typeof n === 'string')
571+
.sort();
565572
if (typeof metadata?.listDiagnosed !== 'function') {
566-
return { count, degraded: false, errors: [] };
573+
return { count, degraded: false, errors: [], objects: names };
567574
}
568575
const diagnosed = await metadata.listDiagnosed('object');
569576
return {
570577
count,
571578
degraded: diagnosed?.degraded === true,
572579
errors: Array.isArray(diagnosed?.errors) ? diagnosed.errors : [],
580+
objects: names,
573581
};
574582
},
575583

‎packages/services/service-datasource/src/datasource-admin-service.ts‎

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,15 @@ export interface DatasourceAdminServiceConfig {
333333
* whose metadata service predates the verdict cannot report the distinction,
334334
* so its absence means "unchanged behaviour", never "complete". A host that
335335
* supplies it gets a removal that refuses rather than guesses.
336+
*
337+
* `objects` names the bound objects the count was taken over, from the SAME
338+
* read, so the refusal's message and its `details` cannot disagree. Optional,
339+
* so a host that reports only the number keeps working: its refusal then
340+
* carries the count without the names.
336341
*/
337342
countBoundObjectsDiagnosed?: (
338343
datasource: string,
339-
) => Promise<{ count: number; degraded: boolean; errors: string[] }>;
344+
) => Promise<{ count: number; degraded: boolean; errors: string[]; objects?: string[] }>;
340345
/**
341346
* [ADR-0015 §4.2 · #25277] The objects that name `datasource` as theirs, each
342347
* with its `external` binding as declared (absent when it has none) — the
@@ -527,6 +532,41 @@ function assertSecretNotEmpty(heading: string, secret: SecretInput | undefined):
527532
);
528533
}
529534

535+
/**
536+
* The refusal {@link DatasourceAdminService.removeDatasource} answers while
537+
* objects are still bound to the datasource.
538+
*
539+
* The answer stays `400` `DATASOURCE_ADMIN_ERROR` with its English sentence;
540+
* what this adds is `details`, so a client can tell this refusal from every
541+
* other 400 of the family and name the objects in its own language without
542+
* re-reading object metadata:
543+
*
544+
* { reason: 'objects_bound', count: 2, objects: ['bin', 'part'] }
545+
*
546+
* `reason` is the discriminator, snake_case like the other `details.reason`
547+
* values of the ADR-0112 envelope. `count` is the number in the message.
548+
* `objects` is the full list, sorted, present when the host's read names them
549+
* (`countBoundObjectsDiagnosed` with `objects`); a host that reports only a
550+
* number gets `count` alone.
551+
*
552+
* `code` and `status` are set so the admin routes' `badRequest` can recognise a
553+
* refusal that declared its own envelope and relay its `details`, the same way
554+
* it relays the `503` and `404` ones; both are required there.
555+
*/
556+
function boundObjectsError(name: string, count: number, objects: string[] | undefined): Error {
557+
const err = new Error(
558+
`Cannot remove datasource '${name}': ${count} object(s) are still bound to it.`,
559+
) as Error & { code?: string; status?: number; details?: Record<string, unknown> };
560+
err.code = 'DATASOURCE_ADMIN_ERROR';
561+
err.status = 400;
562+
err.details = {
563+
reason: 'objects_bound',
564+
count,
565+
...(objects ? { objects: [...objects].sort() } : {}),
566+
};
567+
return err;
568+
}
569+
530570
function metadataIncompleteError(message: string, errors: string[]): Error {
531571
const err = new Error(message) as Error & { code?: string; status?: number; cause?: unknown };
532572
err.code = 'SERVICE_UNAVAILABLE';
@@ -1154,9 +1194,10 @@ export class DatasourceAdminService implements IDatasourceAdminService {
11541194
throw new Error(`Datasource '${name}' is code-defined and cannot be removed at runtime.`);
11551195
}
11561196

1157-
const bound = this.config.countBoundObjectsDiagnosed
1158-
? await this.config.countBoundObjectsDiagnosed(name)
1159-
: { count: await this.config.countBoundObjects(name), degraded: false, errors: [] };
1197+
const bound: { count: number; degraded: boolean; errors: string[]; objects?: string[] } =
1198+
this.config.countBoundObjectsDiagnosed
1199+
? await this.config.countBoundObjectsDiagnosed(name)
1200+
: { count: await this.config.countBoundObjects(name), degraded: false, errors: [] };
11601201

11611202
if (bound.degraded) {
11621203
throw metadataIncompleteError(
@@ -1168,11 +1209,9 @@ export class DatasourceAdminService implements IDatasourceAdminService {
11681209
);
11691210
}
11701211

1171-
if (bound.count > 0) {
1172-
throw new Error(
1173-
`Cannot remove datasource '${name}': ${bound.count} object(s) are still bound to it.`,
1174-
);
1175-
}
1212+
// [#30444] One read: the count in the message and the names in `details`
1213+
// both come from `bound`, so they cannot disagree.
1214+
if (bound.count > 0) throw boundObjectsError(name, bound.count, bound.objects);
11761215

11771216
await this.config.deleteDatasourceRecord(name);
11781217
if (existing.external?.credentialsRef) await this.tryRemoveSecret(existing.external.credentialsRef);

0 commit comments

Comments
 (0)