diff --git a/.changeset/datasource-teardown-ownership.md b/.changeset/datasource-teardown-ownership.md
new file mode 100644
index 0000000000..129dd688fa
--- /dev/null
+++ b/.changeset/datasource-teardown-ownership.md
@@ -0,0 +1,44 @@
+---
+"@objectstack/service-datasource": minor
+"@objectstack/runtime": minor
+---
+
+feat(datasource,runtime): kernel teardown disconnects through the one datasource path — and never closes an adopted pool (#3993)
+
+After the #3826 connect convergence, ADR-0062 D5's "owns connect/disconnect"
+was half-true: nothing disconnected the `default` (or a declared datasource's
+pool) on graceful shutdown. `DriverPlugin` never had teardown, `ObjectQLPlugin`
+teardown never touched drivers, and the kernel's actual teardown phase is
+`destroy()` — the Plugin contract has no `stop()`, so stray `stop` methods were
+never called by anything.
+
+The disconnect half now mirrors the connect half:
+
+- **`DatasourceConnectionService.disconnect(name, { asDefault })`** resolves
+ the default under its NATURAL name (the same #3826 rule that makes
+ `drivers.get('default')` impossible — the old lookup could never have found
+ it), and honours a new ownership discriminator recorded at connect time.
+- **`disconnectAll()`** closes exactly the pools THIS service opened —
+ `'connected'` states only. `already-registered` drivers belong to whoever
+ registered them (an `onEnable` bridge, the default's idempotent replay) and
+ are never touched.
+- **`DatasourceDriverHandle.ownership: 'factory' | 'host'`** is the
+ discriminator. `createPrebuiltDriverFactory` stamps its handles `'host'`:
+ an ADOPTED instance's pool outlives the kernel (the cloud control-plane
+ driver doubles as every environment kernel's proxy base; per-environment
+ drivers are registry-cached across kernel rebuilds), so kernel teardown —
+ including a cloud LRU eviction's `kernel.shutdown()` — clears the retained
+ verdict but NEVER closes the pool. Factory-built instances disconnect as
+ before there was a before.
+- **`DefaultDatasourcePlugin.destroy()`** and
+ **`DatasourceAdminServicePlugin.destroy()`** wire the sweep at the kernel's
+ real teardown phase, best-effort (a failed disconnect never masks shutdown).
+
+A welcome side effect: a file-backed `sqlite-wasm` default with
+`persist: 'on-disconnect'` now actually flushes on graceful shutdown.
+
+Also flips ADR-0062's status to reflect the completed convergence (#3992):
+D1 is fully implemented across both repos since cloud#915; the remaining
+`DriverPlugin` uses are documented named-auxiliary/escape-hatch cases, and the
+degraded-boot parity guard stays with its role shifted to "the escape hatches
+must not drift".
diff --git a/docs/adr/0062-external-datasource-runtime.md b/docs/adr/0062-external-datasource-runtime.md
index 14bca3a23d..cbf79f8378 100644
--- a/docs/adr/0062-external-datasource-runtime.md
+++ b/docs/adr/0062-external-datasource-runtime.md
@@ -1,6 +1,6 @@
# ADR-0062: External Datasource Runtime — connection lifecycle, credentials, visibility & query completeness
-**Status**: Accepted (2026-06-22) — D2–D8 implemented (`service-datasource` connection service + opt-in-safe gate + fail-closed connect policy; native-SQL declines external per D6; D7 lint in `validate-expressions.ts`). **D1 implemented for every open-core boot path** (#3826: the standalone/artifact `default` and the CLI serve fallback are declared definitions connected through the one service — see the status notes under D1); the cloud repo's own stack composition is the last remaining second site, and the parity guard stays until it converges.
+**Status**: Accepted (2026-06-22) — D1–D8 implemented. **D1 fully implemented across both repos** (#3826, closed): every open-core boot path (#3968) and every cloud composition (cloud#915 — control-plane preset, objectos `artifact-kernel-factory`, `DefaultEnvironmentKernelFactory`) connects its `default` through the one `DatasourceConnectionService` path; the remaining `DriverPlugin` uses are documented named-auxiliary/escape-hatch cases, and the degraded-boot parity guard stays to pin them (see the status notes under D1). D5's teardown half landed with #3993: kernel teardown disconnects through the same service, honouring adopted (host-owned) instances — see the amendment under D5.
**Supersedes the runtime portions of**: ADR-0015 §18 addendum (kept as the historical record). ADR-0015 remains the canonical spec/binding decision; this ADR is the canonical *runtime* decision.
@@ -68,7 +68,7 @@ Introduce a single service that, given a datasource definition, builds a driver
>
> **Config-load fallback converged too (#3826, third pass).** `createStorageDriver` is gone: the CLI's serve fallback (a host `objectstack.config.ts` with objects but no driver plugin) now emits a definition via `resolveStorageDefinition` and hands it to the same `DefaultDatasourcePlugin`. `mysql` joined the shared factory for it; the dev loosen-only self-heal (#2186) rides as `config.autoMigrate` and the CLI's wasm persistence mode as `config.persist` — host-composition passthroughs the factory honours, never part of the app-facing datasource spec. `turso`/libSQL keeps its loud typed failure at *resolution* (nothing is constructed to fail later). The `telemetry` sibling datasource deliberately stays a pre-built `DriverPlugin` (the documented escape hatch for named auxiliary drivers): it is best-effort, dev-oriented, and its own `resolveSqliteDriver` step-down check replaces the old primary-resolution coupling.
>
-> **Remaining second site: the cloud repo's own compositions** — the real primary-driver sites are `environment-kernel-factory.ts` (each environment's per-tenant driver) and the control-plane preset fed by `cloud-stack.ts`'s `buildControlDriver`, which also owns the `turso` driver. Until they converge, `packages/runtime/src/degraded-boot-parity.test.ts` remains load-bearing: it pins both connect paths to the same operator-visible contract (fail-fast by default, identical `OS_ALLOW_DRIVER_CONNECT_FAILURE` parsing, `DEGRADED BOOT` on stderr). #3741 → #3758 was exactly the miss it exists to catch.
+> **Convergence completed — the cloud compositions landed (cloud#915), no second site remains.** The cloud repo's three primary-driver sites — the control-plane preset (fed by `cloud-stack.ts`'s `buildControlDriver`, whose instance doubles as every environment kernel's proxy base), `artifact-kernel-factory.ts` (the objectos per-tenant hot path), and both `DefaultEnvironmentKernelFactory` paths — now boot through `DefaultDatasourcePlugin` with their pre-built instances adopted via `createPrebuiltDriverFactory`. Every remaining `DriverPlugin` use is a **documented named-auxiliary / escape-hatch case**, not a default path: the framework's `telemetry` sibling, the cloud proxy `cloud` datasource (`registerAsDefault: false`), the objectos host routing shell, the artifact factory's `'cloud'` alias metadata registration, and test injection. `packages/runtime/src/degraded-boot-parity.test.ts` **stays load-bearing after the convergence**, with its role shifted from "the unconverged second implementation must not drift" to "the escape hatches must not drift": as long as `ObjectQLEngine.init()` can throw a connect verdict at all (the boot re-verification, pre-built `DriverPlugin` drivers), that verdict must match the service's. #3741 → #3758 was exactly the miss it exists to catch.
>
> **The convergence seam (#3826, fourth pass).** Two properties of the cloud composition made "just declare it" impossible: its driver kinds live outside open-core (`turso`), and its instances are *pooled beyond one kernel* (the control-plane driver doubles as the proxy base of every environment kernel; per-environment drivers are cached across kernel rebuilds — reconstruction per boot would multiply pools). So `DefaultDatasourcePlugin` now accepts an **injected `IDatasourceDriverFactory`** (defaulting to the shared open-core factory), and `createPrebuiltDriverFactory` wraps an already-built instance as a factory — the "adopt an existing driver" entry point this ADR's first pass found missing, landed *as a factory* so the connect orchestration (policy-free init connect, `bootCritical` verdict, shared escape hatch, start() replay into retained state) stays this one implementation. Construction and pooling remain host concerns; only the verdict converges. The `@objectstack/verify` dogfood harness also boots through the declared default now (not the `DriverPlugin` escape hatch), making the §Risk mitigation — "behind the dogfood gate" — actually true for the converged path.
@@ -108,6 +108,8 @@ Code-defined datasources surface in `GET /api/v1/datasources`, `GET /api/v1/meta
>
> **A `DatasourceConnectPolicy` denial is not a connect failure** and stays metadata-only, unchanged. A multi-tenant host that blocks egress for a tenant's plan is making a deliberate decision about a datasource it knows it is refusing; fail-fasting there would turn a policy verdict into a boot outage for every tenant on the shared runtime. The fail-fast set covers *failures* — unreachable, unauthenticated, unsupported — not *refusals*.
+> **Amendment (#3993) — teardown is one implementation too, and it distinguishes owned from adopted.** After the D1 connect convergence, "owns connect/disconnect" was half-true: nothing disconnected the `default` (or a declared datasource's pool) on graceful shutdown — `DriverPlugin` never had teardown, `ObjectQLPlugin`'s teardown never touched drivers, and the kernel's actual teardown phase is **`destroy()`** (the Plugin contract has no `stop()`; stray `stop` methods were never called). Now `DatasourceConnectionService` owns the disconnect half symmetrically: `disconnect(name, { asDefault })` resolves the default under its **natural name** (the same #3826 rule that makes `drivers.get('default')` impossible), and `disconnectAll()` — wired from `DefaultDatasourcePlugin.destroy()` and `DatasourceAdminServicePlugin.destroy()` — closes **exactly the pools this service opened** (`'connected'` states; `already-registered` drivers belong to whoever registered them). The factory handle carries the discriminator: **`ownership: 'host'`** (set by `createPrebuiltDriverFactory`) marks an ADOPTED instance whose pool outlives the kernel — the cloud constraint: the control-plane driver doubles as every environment kernel's proxy base, per-environment drivers are registry-cached across kernel rebuilds, and an LRU eviction (`kernel.shutdown()`) closing a shared pool would pull it from under every other consumer. For those, teardown clears the retained verdict and leaves the pool to its host. A welcome side effect: a file-backed `sqlite-wasm` default with `persist: 'on-disconnect'` now actually flushes on graceful shutdown.
+
### D6 — Native-analytics SQL honors the remote table/columns
The analytics native-SQL strategy compiles its own `FROM "
"` / column references outside the driver (ADR-0015 §18 noted this). It must resolve an external object's physical table (`remoteName`/`remoteSchema`) and columns (`columnMap`) the same way `SqlDriver` now does — reusing the driver's resolution (e.g. an exposed `physicalTableFor(object)` / `physicalColumnFor(object, field)`), not a second copy. Until then, analytics over external objects stays disabled rather than silently querying the wrong table.
diff --git a/packages/runtime/src/default-datasource-plugin.test.ts b/packages/runtime/src/default-datasource-plugin.test.ts
index 3a04cd27c2..042bd42a52 100644
--- a/packages/runtime/src/default-datasource-plugin.test.ts
+++ b/packages/runtime/src/default-datasource-plugin.test.ts
@@ -194,6 +194,37 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (#
try { await (kernel as any)?.stop?.(); } catch { /* noop */ }
}, BOOT_TIMEOUT);
+ it('kernel teardown disconnects an OWNED (factory-built) default through the one service (#3993)', async () => {
+ const kernel = await assemble({});
+ await kernel.bootstrap();
+ const engine = kernel.getService('data');
+ const drv = engine.getDriverByName(engine.getDefaultDriverName());
+ let disconnects = 0;
+ const orig = drv.disconnect?.bind(drv);
+ drv.disconnect = async () => { disconnects += 1; return orig?.(); };
+ await (kernel as any).shutdown();
+ expect(disconnects).toBeGreaterThan(0);
+ }, BOOT_TIMEOUT);
+
+ it('kernel teardown NEVER closes an ADOPTED (host-owned) default — the pool outlives the kernel (#3993)', async () => {
+ // The cloud shape: the instance is shared beyond this kernel (proxy base /
+ // registry cache). An LRU eviction shutting the kernel down must not pull
+ // the pool from under every other consumer.
+ const { createPrebuiltDriverFactory } = await import('@objectstack/service-datasource');
+ const { InMemoryDriver } = await import('@objectstack/driver-memory');
+ const hostBuilt = new InMemoryDriver() as any;
+ let disconnects = 0;
+ const orig = hostBuilt.disconnect?.bind(hostBuilt);
+ hostBuilt.disconnect = async () => { disconnects += 1; return orig?.(); };
+ const kernel = await assemble({
+ driver: 'turso',
+ factory: createPrebuiltDriverFactory(hostBuilt, { driverId: 'turso' }),
+ });
+ await kernel.bootstrap();
+ await (kernel as any).shutdown();
+ expect(disconnects).toBe(0);
+ }, BOOT_TIMEOUT);
+
it("rejects an app bundle that declares a datasource named 'default' (host-reserved name)", async () => {
const kernel = await assemble({
bundle: {
diff --git a/packages/runtime/src/default-datasource-plugin.ts b/packages/runtime/src/default-datasource-plugin.ts
index 4e9cb695da..8fe105e9d9 100644
--- a/packages/runtime/src/default-datasource-plugin.ts
+++ b/packages/runtime/src/default-datasource-plugin.ts
@@ -98,6 +98,8 @@ export class DefaultDatasourcePlugin implements Plugin {
private readonly def: DefaultDatasourceDefinition;
private readonly dev?: boolean;
private readonly factory?: IDatasourceDriverFactory;
+ /** The init()-time local connection service — held for destroy()'s teardown. */
+ private connection?: DatasourceConnectionService;
constructor(def: DefaultDatasourceDefinition, opts: DefaultDatasourcePluginOptions = {}) {
this.def = def;
@@ -131,6 +133,7 @@ export class DefaultDatasourcePlugin implements Plugin {
// packages.
logger: ctx.logger as unknown as ConstructorParameters[0]['logger'],
});
+ this.connection = connection;
// Throws on failure (bootCritical ⇒ fail-fast per ADR-0062 D5), aborting
// bootstrap exactly like the engine-level guard did — same escape hatch,
@@ -243,4 +246,24 @@ export class DefaultDatasourcePlugin implements Plugin {
ctx.logger.debug('[DefaultDatasourcePlugin] metadata service unavailable — default not listed', { error: e });
}
}
+
+ /**
+ * Kernel teardown (ADR-0062 D5, #3993): the default disconnects through the
+ * SAME service that connected it — one teardown implementation, mirroring
+ * the one connect implementation. The service resolves the driver the way
+ * the default was registered (natural name via `asDefault`) and honours
+ * ownership: a host-owned ADOPTED instance (`createPrebuiltDriverFactory`,
+ * the cloud compositions — pools shared beyond this kernel) is never
+ * closed here; only the retained verdict is cleared. Note the kernel's
+ * teardown phase is `destroy()` — `stop()` exists nowhere in the Plugin
+ * contract and is never called.
+ */
+ destroy = async () => {
+ try {
+ await this.connection?.disconnect('default', { asDefault: true });
+ } catch {
+ // Teardown is best-effort — the kernel is going away either way, and a
+ // failed disconnect must not mask the real shutdown path.
+ }
+ };
}
diff --git a/packages/services/service-datasource/src/__tests__/prebuilt-driver-factory.test.ts b/packages/services/service-datasource/src/__tests__/prebuilt-driver-factory.test.ts
index dd13738acd..bb0d1d3275 100644
--- a/packages/services/service-datasource/src/__tests__/prebuilt-driver-factory.test.ts
+++ b/packages/services/service-datasource/src/__tests__/prebuilt-driver-factory.test.ts
@@ -112,6 +112,80 @@ describe('createPrebuiltDriverFactory — through DatasourceConnectionService (t
expect(driver.calls).toContain('connect');
});
+ it("stamps the handle 'host'-owned, and kernel teardown leaves the adopted pool alone (#3993)", async () => {
+ // The cloud constraint: the adopted instance's pool outlives this kernel
+ // (proxy base / registry cache). disconnect() must clear the retained
+ // verdict WITHOUT closing the pool.
+ const driver = stubDriver('turso');
+ const factory = createPrebuiltDriverFactory(driver, { driverId: 'turso' });
+ const handle: any = await factory.create({ driver: 'turso', config: {} });
+ expect(handle.ownership).toBe('host');
+
+ const { svc } = service(factory);
+ const result = await svc.connect(
+ { name: 'default', driver: 'turso', config: {}, origin: 'code', bootCritical: true },
+ { asDefault: true, context: { origin: 'code', trigger: 'declared-auto' } },
+ );
+ expect(result.ownership).toBe('host');
+ expect(svc.getConnectionState('default')?.ownership).toBe('host');
+
+ await svc.disconnect('default', { asDefault: true });
+ expect(driver.calls).not.toContain('disconnect'); // the pool belongs to the host
+ expect(svc.getConnectionState('default')).toBeUndefined(); // the verdict is gone
+ });
+
+ it('a FACTORY-built default IS disconnected at teardown — natural-name resolution via asDefault (#3993)', async () => {
+ // Without ownership the instance was built for this connect: teardown may
+ // close it. `asDefault` must resolve the driver under its natural name —
+ // `getDriverByName('default')` can never find it (#3826).
+ const driver = stubDriver('sql');
+ const factoryBuilt = {
+ supports: () => true,
+ create: () => ({
+ connect: async () => { await driver.connect(); },
+ disconnect: async () => { await driver.disconnect(); },
+ driver,
+ }),
+ };
+ const { svc } = service(factoryBuilt as any);
+ await svc.connect(
+ { name: 'default', driver: 'sqlite', config: {}, origin: 'code', bootCritical: true },
+ { asDefault: true, context: { origin: 'code', trigger: 'declared-auto' } },
+ );
+ await svc.disconnect('default', { asDefault: true });
+ expect(driver.calls).toContain('disconnect');
+ expect(svc.getConnectionState('default')).toBeUndefined();
+ });
+
+ it("disconnectAll() closes only what THIS service opened — 'connected' states, never 'already-registered' (#3993)", async () => {
+ const opened = stubDriver('sql');
+ const factoryBuilt = {
+ supports: () => true,
+ create: () => ({ disconnect: async () => { await opened.disconnect(); }, driver: opened }),
+ };
+ const { svc, drivers } = service(factoryBuilt as any);
+ // A driver someone ELSE registered (the D8 onEnable escape hatch): the
+ // idempotency guard records `already-registered` — not ours to close.
+ const foreign = stubDriver('warehouse');
+ drivers.set('warehouse', foreign);
+ const pre = await svc.connect(
+ { name: 'warehouse', driver: 'sqlite', config: {}, autoConnect: true },
+ { context: { origin: 'code', trigger: 'declared-auto' } },
+ );
+ expect(pre.status).toBe('already-registered');
+ // A pool this service opened.
+ await svc.connect(
+ { name: 'analytics', driver: 'sqlite', config: {}, autoConnect: true },
+ { context: { origin: 'code', trigger: 'declared-auto' } },
+ );
+
+ await svc.disconnectAll();
+ expect(opened.calls).toContain('disconnect');
+ expect(foreign.calls).not.toContain('disconnect');
+ expect(svc.getConnectionState('analytics')).toBeUndefined();
+ expect(svc.getConnectionState('warehouse')).toBeDefined(); // untouched
+ });
+
it('a failing adopted instance takes the SAME bootCritical fail-fast verdict', async () => {
const driver = stubDriver('turso', { failConnect: true });
const { svc } = service(createPrebuiltDriverFactory(driver, { driverId: 'turso' }));
diff --git a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts
index aa17c05c07..cc83273d75 100644
--- a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts
+++ b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts
@@ -37,6 +37,19 @@ export interface DatasourceConnectionSpec {
pool?: Record;
}
+/**
+ * Who owns the TEARDOWN of the driver instance behind a handle (ADR-0062 D5,
+ * #3993):
+ * - `'factory'` (the default when absent) — the factory built this instance
+ * for this connect; the connection service may disconnect it when the
+ * kernel tears down.
+ * - `'host'` — an adopted, pre-built instance (`createPrebuiltDriverFactory`)
+ * whose lifecycle outlives the kernel (a pool shared across environment
+ * kernels, a control-plane driver doubling as a proxy base). Kernel
+ * teardown must NEVER disconnect it; the host does, on its own schedule.
+ */
+export type DatasourceDriverOwnership = 'factory' | 'host';
+
/**
* A live (or lazily-connecting) driver handle. Intentionally structural and
* fully optional so any concrete driver satisfies it — the admin service uses
@@ -47,6 +60,8 @@ export interface DatasourceDriverHandle {
connect?(): Promise;
/** Close the connection / pool. */
disconnect?(): Promise;
+ /** Teardown ownership of the underlying instance — see {@link DatasourceDriverOwnership}. */
+ ownership?: DatasourceDriverOwnership;
/** Cheap liveness round-trip (preferred for probes). */
ping?(): Promise;
/** Introspect the live schema (fallback probe when `ping` is absent). */
diff --git a/packages/services/service-datasource/src/contracts/index.ts b/packages/services/service-datasource/src/contracts/index.ts
index 0a2bb390e8..20e2b3ab2f 100644
--- a/packages/services/service-datasource/src/contracts/index.ts
+++ b/packages/services/service-datasource/src/contracts/index.ts
@@ -14,6 +14,7 @@ export type {
export type {
DatasourceConnectionSpec,
DatasourceDriverHandle,
+ DatasourceDriverOwnership,
IDatasourceDriverFactory,
} from './datasource-driver-factory.js';
diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts
index 14b1151595..07e748b795 100644
--- a/packages/services/service-datasource/src/datasource-admin-plugin.ts
+++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts
@@ -434,6 +434,16 @@ export class DatasourceAdminServicePlugin implements Plugin {
}
async destroy(): Promise {
+ // Kernel teardown (ADR-0062 D5, #3993): close exactly the pools the shared
+ // connection service opened — `'connected'` states only. `already-registered`
+ // drivers belong to someone else, and host-owned adopted instances are never
+ // closed (both filters live in the service). Until this hook, a declared or
+ // runtime-created datasource's pool was simply abandoned on graceful shutdown.
+ try {
+ await this.connection?.disconnectAll();
+ } catch {
+ // Best-effort: teardown must never mask the shutdown path.
+ }
this.service = undefined;
}
diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts
index 040f97927b..b9ae5bddd5 100644
--- a/packages/services/service-datasource/src/datasource-connection-service.ts
+++ b/packages/services/service-datasource/src/datasource-connection-service.ts
@@ -31,6 +31,7 @@ import {
import type {
IDatasourceDriverFactory,
DatasourceConnectionSpec,
+ DatasourceDriverOwnership,
} from './contracts/datasource-driver-factory.js';
import {
allowAllConnectPolicy,
@@ -151,6 +152,15 @@ export interface ConnectResult {
name: string;
status: ConnectStatus;
reason?: string;
+ /**
+ * Teardown ownership recorded at connect time from the factory handle
+ * (ADR-0062 D5, #3993). `'host'` marks an ADOPTED pre-built instance
+ * (`createPrebuiltDriverFactory`) whose pool outlives this kernel —
+ * {@link DatasourceConnectionService.disconnect} then clears the retained
+ * state but never closes the driver. Absent ⇒ factory-built for this
+ * connect; kernel teardown may disconnect it.
+ */
+ ownership?: DatasourceDriverOwnership;
}
/**
@@ -507,17 +517,32 @@ export class DatasourceConnectionService {
}
this.logger?.info?.(`datasource '${name}': connected (driver=${record.driver}, schemaMode=${record.schemaMode ?? 'managed'})`);
- return { name, status: 'connected' };
+ return { name, status: 'connected', ...(handle.ownership ? { ownership: handle.ownership } : {}) };
} catch (err) {
return this.handleFailure(record, 'failed-degraded', errMsg(err), opts.context, opts.objects);
}
}
- /** Gracefully disconnect a previously-registered datasource pool. */
- async disconnect(name: string): Promise {
+ /**
+ * Gracefully disconnect a previously-registered datasource pool.
+ *
+ * Two teardown rules (ADR-0062 D5, #3993):
+ * - `asDefault` resolves the driver the way the default was registered —
+ * under its NATURAL name via the engine's default-driver fallback
+ * (`getDriverByName('default')` can never find it, by the #3826 design).
+ * - A `'host'`-owned (adopted) instance is never closed here: its pool
+ * outlives this kernel (shared proxy base / cross-kernel registry cache),
+ * so only the retained verdict is cleared and the host keeps the pool.
+ */
+ async disconnect(name: string, opts: { asDefault?: boolean } = {}): Promise {
const engine = this.cfg.engine();
- const driver = engine?.getDriverByName?.(name) as { disconnect?: () => Promise } | undefined;
- if (typeof driver?.disconnect === 'function') {
+ const driverName = opts.asDefault ? engine?.getDefaultDriverName?.() : name;
+ const driver = (driverName ? engine?.getDriverByName?.(driverName) : undefined) as
+ | { disconnect?: () => Promise }
+ | undefined;
+ if (this.states.get(name)?.ownership === 'host') {
+ this.logger?.debug?.(`datasource '${name}': adopted (host-owned) instance — pool left to the host, clearing state only`);
+ } else if (typeof driver?.disconnect === 'function') {
try {
await driver.disconnect();
} catch (err) {
@@ -531,6 +556,23 @@ export class DatasourceConnectionService {
engine?.clearDatasourceUnavailable?.(name);
}
+ /**
+ * Kernel-teardown sweep (ADR-0062 D5, #3993): disconnect exactly the pools
+ * THIS service opened — states with status `'connected'`, nothing else.
+ * `'already-registered'` is deliberately excluded (someone else registered
+ * that driver — an `onEnable` bridge, the default's idempotent replay — and
+ * this service closing it would pull a pool from under its real owner);
+ * host-owned adopted instances are skipped inside {@link disconnect}.
+ */
+ async disconnectAll(): Promise {
+ const names = Array.from(this.states.entries())
+ .filter(([, st]) => st.status === 'connected')
+ .map(([n]) => n);
+ for (const n of names) {
+ await this.disconnect(n, { asDefault: n === 'default' });
+ }
+ }
+
/**
* Apply the D5 connect-failure policy (also covers D3 credential failures).
*
diff --git a/packages/services/service-datasource/src/index.ts b/packages/services/service-datasource/src/index.ts
index 12cdf07414..ac99ba5f5b 100644
--- a/packages/services/service-datasource/src/index.ts
+++ b/packages/services/service-datasource/src/index.ts
@@ -38,6 +38,7 @@ export type {
IDatasourceAdminService,
DatasourceConnectionSpec,
DatasourceDriverHandle,
+ DatasourceDriverOwnership,
IDatasourceDriverFactory,
// Connect policy (ADR-0062 D5 / epic #2163 seam).
DatasourceConnectPolicy,
diff --git a/packages/services/service-datasource/src/prebuilt-driver-factory.ts b/packages/services/service-datasource/src/prebuilt-driver-factory.ts
index 3bc6c26e58..5b0151dbbc 100644
--- a/packages/services/service-datasource/src/prebuilt-driver-factory.ts
+++ b/packages/services/service-datasource/src/prebuilt-driver-factory.ts
@@ -101,7 +101,9 @@ export function createPrebuiltDriverFactory(
// Mirrors the shared factory's `toHandle`: expose the driver's own
// lifecycle on the handle (async-normalized to the contract), and the
// instance itself as the `driver` escape hatch the connection service
- // registers into the engine.
+ // registers into the engine. `ownership: 'host'` is the teardown half
+ // of adoption (ADR-0062 D5, #3993): the instance's lifecycle outlives
+ // this kernel, so kernel teardown must never disconnect it.
const health =
typeof driver.checkHealth === 'function'
? async () => Boolean(await driver.checkHealth!())
@@ -109,6 +111,7 @@ export function createPrebuiltDriverFactory(
return {
connect: typeof driver.connect === 'function' ? async () => { await driver.connect!(); } : undefined,
disconnect: typeof driver.disconnect === 'function' ? async () => { await driver.disconnect!(); } : undefined,
+ ownership: 'host',
checkHealth: health,
ping: health,
driver,