diff --git a/.changeset/default-datasource-adopt-seam.md b/.changeset/default-datasource-adopt-seam.md new file mode 100644 index 0000000000..69bed6b44d --- /dev/null +++ b/.changeset/default-datasource-adopt-seam.md @@ -0,0 +1,43 @@ +--- +"@objectstack/runtime": minor +"@objectstack/service-datasource": minor +"@objectstack/verify": patch +--- + +feat(runtime,datasource): the default-datasource connect seam accepts a host driver factory — adopt pre-built instances without forking the verdict (#3826) + +ADR-0062 D1's open-core convergence (#3869/#3886) left one structural question +open: a host whose `default` needs a driver the shared factory cannot build — +the cloud distribution's `turso`, or an instance pooled BEYOND one kernel (the +cloud control-plane driver doubles as the proxy base of every environment +kernel; per-environment drivers are cached across kernel rebuilds) — had only +two options, both bad: stay on the legacy pre-built `DriverPlugin` path, whose +connect verdict lives in `ObjectQLEngine.init()` (the second implementation +#3826 exists to retire), or fork the connect orchestration. Either re-opens the +#3741 → #3758 drift this whole line of work is about. + +Two additive pieces close it: + +- **`DefaultDatasourcePlugin` accepts an injected `IDatasourceDriverFactory`** + (defaults to the shared open-core factory, byte-for-byte unchanged when + omitted). The factory only changes what `create()` returns — the policy-free + init connect, `bootCritical` fail-fast, `OS_ALLOW_DRIVER_CONNECT_FAILURE` + escape hatch, and the start() replay into retained admin state are identical + either way, and the new tests pin that (an adopted instance that cannot + connect takes the exact same verdict). +- **`createPrebuiltDriverFactory(driver, { driverId?, fallback? })`** in + `@objectstack/service-datasource` — the "adopt an existing driver" seam the + first #3826 pass found missing, landed AS a factory so it composes into the + one connect path instead of becoming a second entry point. `create()` returns + the SAME instance every call: construction, pooling, and reuse stay host + concerns; only the verdict converges. Not for the common case — a `default` + expressible as `{ driver, config }` should stay a plain definition. + +The `@objectstack/verify` dogfood harness now boots through +`DefaultDatasourcePlugin` (declared `sqlite-wasm` definition) instead of a +pre-built `DriverPlugin` — so the dogfood gate exercises the same declared +-default connect path `objectstack dev`/`serve` use, which is the §Risk +mitigation ADR-0062 promised ("behind the dogfood gate") and did not yet have. +The degraded-boot parity guard stays: `ObjectQLEngine.init()`'s verdict is +still live for the boot re-verification, `DriverPlugin` escape-hatch drivers, +and the cloud compositions until they converge onto this seam. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 49512d9944..96641b1ec3 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -21,7 +21,11 @@ Drivers can be selected in two ways: override the inference (e.g. force `sqlite` for an ambiguous path). 2. **Programmatic** — register a driver instance as a kernel plugin via - `DriverPlugin` (the same path the CLI uses internally): + `DriverPlugin`. This is the escape hatch for pre-built or auxiliary + drivers — the CLI itself no longer boots the primary this way: it declares + the `default` datasource as a definition and connects it through the shared + datasource path ([#3826](https://github.com/objectstack-ai/objectstack/issues/3826), + described further down this page): ```typescript import { DriverPlugin } from '@objectstack/runtime'; import { SqlDriver } from '@objectstack/driver-sql'; diff --git a/docs/adr/0062-external-datasource-runtime.md b/docs/adr/0062-external-datasource-runtime.md index 5f0e926cdd..14bca3a23d 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 partially implemented**: declared datasources auto-connect through the one service, but the `default` driver still has its own connect + failure path — see the status correction under D1 (#3826). +**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. **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,9 @@ 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 stack's own composition** (cloud repo, `cloud-stack.ts`), which also owns the `turso` driver. Until it converges, `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. +> **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. +> +> **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. ### D2 — Connect is opt-in-safe: existing managed apps are byte-for-byte unchanged diff --git a/packages/runtime/src/default-datasource-plugin.test.ts b/packages/runtime/src/default-datasource-plugin.test.ts index 4c5aa49c7b..3a04cd27c2 100644 --- a/packages/runtime/src/default-datasource-plugin.test.ts +++ b/packages/runtime/src/default-datasource-plugin.test.ts @@ -19,13 +19,18 @@ async function assemble(opts: { withAdminPlugin?: boolean; connectPolicy?: any; bundle?: any; + /** Host-injected driver factory (the cloud/EE seam) — see the last cases. */ + factory?: any; } = {}) { const { ObjectQLPlugin } = await import('@objectstack/objectql'); const runtime = new Runtime({ cluster: false }); const kernel = runtime.getKernel(); // Order matters for START: the default datasource must connect before // ObjectQLPlugin.start() runs boot schema-sync. - await kernel.use(new DefaultDatasourcePlugin({ driver: opts.driver ?? 'memory' })); + await kernel.use(new DefaultDatasourcePlugin( + { driver: opts.driver ?? 'memory' }, + opts.factory ? { factory: opts.factory } : {}, + )); await kernel.use(new ObjectQLPlugin()); if (opts.bundle) await kernel.use(new AppPlugin(opts.bundle)); if (opts.withAdminPlugin !== false) { @@ -135,6 +140,60 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# } }, BOOT_TIMEOUT); + it('adopts a HOST-BUILT driver instance through an injected factory — same path, host pooling (#3826 seam)', async () => { + // The cloud composition's shape: the host already holds a constructed + // (possibly pooled/shared) driver instance the open-core factory cannot + // rebuild. createPrebuiltDriverFactory wraps it; the plugin's connect + // orchestration must register THAT instance — never a rebuilt copy. + const { createPrebuiltDriverFactory } = await import('@objectstack/service-datasource'); + const { InMemoryDriver } = await import('@objectstack/driver-memory'); + const hostBuilt = new InMemoryDriver(); + const kernel = await assemble({ + driver: 'turso', // a kind the SHARED factory does not support — proves dispatch + factory: createPrebuiltDriverFactory(hostBuilt, { driverId: 'turso' }), + bundle: { + manifest: { id: 'com.test.adopted-ds', name: 'Adopted DS', version: '1.0.0' }, + objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }], + }, + }); + try { + await kernel.bootstrap(); + const engine = kernel.getService('data'); + const defaultName = engine.getDefaultDriverName(); + expect(defaultName).toBeDefined(); + // Identity, not equivalence: the engine's default driver IS the host's instance. + expect(engine.getDriverByName(defaultName)).toBe(hostBuilt); + await engine.insert('note', { title: 'through-the-adopted-default' }); + const rows = await engine.find('note'); + expect(rows.map((r: any) => r.title)).toContain('through-the-adopted-default'); + } finally { + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + } + }, BOOT_TIMEOUT); + + it('an injected factory rides the SAME failure verdict — fail-fast, same escape hatch', async () => { + // The seam must never fork the verdict: an adopted instance that cannot + // connect takes the identical bootCritical fail-fast (and the identical + // OS_ALLOW_DRIVER_CONNECT_FAILURE override) as a factory-built default. + const { createPrebuiltDriverFactory } = await import('@objectstack/service-datasource'); + const failing = { + name: 'turso', + async connect() { throw new Error('connect ECONNREFUSED 127.0.0.1:8080'); }, + async disconnect() {}, + }; + const kernel = await assemble({ + driver: 'turso', + factory: createPrebuiltDriverFactory(failing, { driverId: 'turso' }), + }); + const err = await kernel.bootstrap().then( + () => { throw new Error('bootstrap() resolved but should have thrown'); }, + (e: unknown) => e as Error, + ); + expect(err.message).toMatch(/boot-critical/); + expect(err.message).toContain('OS_ALLOW_DRIVER_CONNECT_FAILURE'); + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + }, 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 4eb8c1155f..4e9cb695da 100644 --- a/packages/runtime/src/default-datasource-plugin.ts +++ b/packages/runtime/src/default-datasource-plugin.ts @@ -5,6 +5,7 @@ import { DatasourceConnectionService, createDefaultDatasourceDriverFactory, type ConnectableDatasource, + type IDatasourceDriverFactory, } from '@objectstack/service-datasource'; /** @@ -52,12 +53,37 @@ import { * drivers — it is no longer how the standalone default boots. */ export interface DefaultDatasourceDefinition { - /** Driver id the shared factory can build (`sqlite`, `sqlite-wasm`, `postgres`, `mongodb`, `memory`). */ + /** Driver id the (injected or shared) factory can build (`sqlite`, `sqlite-wasm`, `postgres`, `mongodb`, `memory`). */ driver: string; config?: Record; label?: string; } +export interface DefaultDatasourcePluginOptions { + /** Arms the shared factory's dev sqlite step-down (#2229) + loosen-only self-heal passthroughs. */ + dev?: boolean; + /** + * Host-injected driver factory. Defaults to the shared open-core factory + * (`createDefaultDatasourceDriverFactory`). The seam exists for hosts whose + * `default` needs a driver the open-core factory cannot build — the cloud + * distribution's `turso`, or an already-pooled instance adopted via + * `createPrebuiltDriverFactory` — WITHOUT forking the connect + failure + * -verdict orchestration this plugin owns (the #3741 → #3758 drift). The + * factory only changes what `create()` returns; the policy-free init + * connect, `bootCritical` verdict, escape hatch, and start() replay are + * identical either way. + * + * Note the start() replay goes through the SHARED `'datasource-connection'` + * service, whose factory is the host's admin-plugin one — after a NORMAL + * boot that replay resolves `already-registered` before any factory is + * consulted, but a degraded boot's real retry (init failed under + * `OS_ALLOW_DRIVER_CONNECT_FAILURE`) retries with the shared factory. A + * host injecting a factory for a kind the shared one cannot build should + * expect that retry to report `skipped-unsupported` rather than reattempt. + */ + factory?: IDatasourceDriverFactory; +} + export class DefaultDatasourcePlugin implements Plugin { name = 'com.objectstack.runtime.default-datasource'; version = '1.0.0'; @@ -71,10 +97,12 @@ export class DefaultDatasourcePlugin implements Plugin { private readonly def: DefaultDatasourceDefinition; private readonly dev?: boolean; + private readonly factory?: IDatasourceDriverFactory; - constructor(def: DefaultDatasourceDefinition, opts: { dev?: boolean } = {}) { + constructor(def: DefaultDatasourceDefinition, opts: DefaultDatasourcePluginOptions = {}) { this.def = def; this.dev = opts.dev; + this.factory = opts.factory; } private record(): ConnectableDatasource { @@ -90,7 +118,7 @@ export class DefaultDatasourcePlugin implements Plugin { init = async (ctx: PluginContext) => { const connection = new DatasourceConnectionService({ - factory: () => createDefaultDatasourceDriverFactory({ dev: this.dev }), + factory: () => this.factory ?? createDefaultDatasourceDriverFactory({ dev: this.dev }), engine: () => { try { return ctx.getService('data'); diff --git a/packages/runtime/src/degraded-boot-parity.test.ts b/packages/runtime/src/degraded-boot-parity.test.ts index 914cda4d36..666de812d6 100644 --- a/packages/runtime/src/degraded-boot-parity.test.ts +++ b/packages/runtime/src/degraded-boot-parity.test.ts @@ -3,20 +3,28 @@ // framework#3826 — DRIFT GUARD for the two "a datasource would not connect" // implementations. // -// ADR-0062 D1 asks for exactly one "definition → live driver" path. The -// *construction* half converged (both build through the shared datasource driver -// factory), but the *connect + failure verdict* half did not: +// ADR-0062 D1 asks for exactly one "definition → live driver" path, and for +// every OPEN-CORE boot that is now true: the `default` datasource is a declared +// definition connected by `DefaultDatasourcePlugin` through +// `DatasourceConnectionService.connect()` (#3869 standalone/artifact, #3886 the +// CLI serve fallback) — `handleFailure` (#3758) is its one failure verdict. +// But the ENGINE-side verdict — `ObjectQLEngine.init()` → `DriverConnectError` +// (#3741) — is still live code, on purpose, for three callers: // -// - the `default` driver is registered as a `driver.*` kernel service and -// connected by `ObjectQLEngine.init()` → DriverConnectError (#3741) -// - declared datasources are connected by -// `DatasourceConnectionService.connect()` → handleFailure (#3758) +// - the boot re-verification of the already-connected default (the #3741 +// fail-fast role D1 deliberately keeps in `init()`), +// - drivers registered via the pre-built `DriverPlugin` escape hatch +// (the telemetry sibling, tests, dynamic/proxy drivers), and +// - the cloud repo's own compositions (`environment-kernel-factory`, +// control-plane preset), until they converge onto the shared path. // -// They agree today only because two separate pieces of code were each written -// correctly — and the last time they disagreed, the gap survived three months -// and a second bug report (#3741 fixed one layer, #3758 was the other). Until -// the paths are actually merged, this test is what notices a divergence: -// it pins the operator-visible contract both of them owe. +// So two verdict implementations still coexist, and they agree only because +// each is written correctly — the last time they disagreed, the gap survived +// three months and a second bug report (#3741 fixed one layer, #3758 was the +// other). This test pins the operator-visible contract both of them owe: +// fail-fast by default, one identically-parsed escape hatch, DEGRADED BOOT on +// stderr. It outlives the cloud convergence: as long as `init()` can throw a +// connect verdict at all, that verdict must match the service's. // // This lives in `runtime` because it is the only package that depends on both. diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 278ca20e5d..59b9336f5d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -18,7 +18,7 @@ export type { DefaultHostConfigOptions, DefaultHostConfigResult } from './defaul // Export Plugins export { DriverPlugin } from './driver-plugin.js'; export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; -export type { DefaultDatasourceDefinition } from './default-datasource-plugin.js'; +export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleActions } from './app-plugin.js'; export { SeedLoaderService } from './seed-loader.js'; // Boot-summary seed outcome accumulator (#3415/#3430) — the single writer 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 new file mode 100644 index 0000000000..dd13738acd --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/prebuilt-driver-factory.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #3826 (fourth pass): `createPrebuiltDriverFactory` is the "adopt a +// host-built driver instance" seam, landed AS a factory so the connect + +// failure-verdict orchestration stays the one `DatasourceConnectionService` +// implementation. These tests pin the two properties the seam exists for: +// `create()` hands back the SAME instance (pooling stays a host concern), and +// an adopted instance rides the exact same connect path — including the +// verdict — as a factory-built one. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createPrebuiltDriverFactory } from '../prebuilt-driver-factory.js'; +import { DatasourceConnectionService } from '../datasource-connection-service.js'; + +// The fail-fast case below asserts the default (no-escape-hatch) verdict. +const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; +let savedEnv: string | undefined; +beforeEach(() => { savedEnv = process.env[ENV]; delete process.env[ENV]; }); +afterEach(() => { + if (savedEnv === undefined) delete process.env[ENV]; + else process.env[ENV] = savedEnv; +}); + +function stubDriver(name: string, opts: { failConnect?: boolean } = {}) { + const calls: string[] = []; + return { + calls, + name, + async connect() { + calls.push('connect'); + if (opts.failConnect) throw new Error('connect ECONNREFUSED 127.0.0.1:8080'); + }, + async disconnect() { calls.push('disconnect'); }, + async checkHealth() { calls.push('checkHealth'); return true; }, + }; +} + +describe('createPrebuiltDriverFactory — supports()', () => { + it('matches only the configured driverId (case-insensitive)', () => { + const f = createPrebuiltDriverFactory(stubDriver('turso'), { driverId: 'turso' }); + expect(f.supports('turso')).toBe(true); + expect(f.supports('TURSO')).toBe(true); + expect(f.supports('postgres')).toBe(false); + }); + + it('matches every id when no driverId is configured', () => { + const f = createPrebuiltDriverFactory(stubDriver('anything')); + expect(f.supports('turso')).toBe(true); + expect(f.supports('whatever')).toBe(true); + }); + + it('defers non-matching ids to the fallback factory', () => { + const fallback = { supports: (id: string) => id === 'sqlite', create: () => ({ driver: {} }) }; + const f = createPrebuiltDriverFactory(stubDriver('turso'), { driverId: 'turso', fallback }); + expect(f.supports('sqlite')).toBe(true); + expect(f.supports('mysql')).toBe(false); + }); +}); + +describe('createPrebuiltDriverFactory — create()', () => { + it('returns the SAME instance as the handle driver, with its lifecycle forwarded', async () => { + const driver = stubDriver('turso'); + const f = createPrebuiltDriverFactory(driver, { driverId: 'turso' }); + const handle: any = await f.create({ driver: 'turso', config: {} }); + expect(handle.driver).toBe(driver); + await handle.connect(); + await handle.checkHealth(); + await handle.disconnect(); + expect(driver.calls).toEqual(['connect', 'checkHealth', 'disconnect']); + }); + + it('dispatches a non-matching id to the fallback, and throws without one', async () => { + const built = { driver: { name: 'sqlite' } }; + const fallback = { supports: (id: string) => id === 'sqlite', create: () => built }; + const f = createPrebuiltDriverFactory(stubDriver('turso'), { driverId: 'turso', fallback }); + expect(await f.create({ driver: 'sqlite', config: {} })).toBe(built); + + const noFallback = createPrebuiltDriverFactory(stubDriver('turso'), { driverId: 'turso' }); + expect(() => noFallback.create({ driver: 'sqlite', config: {} })).toThrow(/only supports driver 'turso'/); + }); +}); + +describe('createPrebuiltDriverFactory — through DatasourceConnectionService (the actual seam)', () => { + function service(factory: ReturnType) { + const drivers = new Map(); + let defaultName: string | undefined; + const svc = new DatasourceConnectionService({ + factory: () => factory, + engine: () => ({ + registerDriver: (d: any, isDefault?: boolean) => { + drivers.set(d.name, d); + if (isDefault) defaultName = d.name; + }, + getDriverByName: (n: string) => drivers.get(n), + getDefaultDriverName: () => defaultName, + }), + logger: { warn() {} }, + }); + return { svc, drivers, getDefault: () => (defaultName ? drivers.get(defaultName) : undefined) }; + } + + it('adopts the instance as the DEFAULT driver under its natural name', async () => { + const driver = stubDriver('turso'); + const { svc, getDefault } = service(createPrebuiltDriverFactory(driver, { driverId: 'turso' })); + const result = await svc.connect( + { name: 'default', driver: 'turso', config: {}, origin: 'code', bootCritical: true }, + { asDefault: true, context: { origin: 'code', trigger: 'declared-auto' } }, + ); + expect(result.status).toBe('connected'); + expect(getDefault()).toBe(driver); // identity — never a rebuilt copy + expect(driver.name).toBe('turso'); // natural name kept (asDefault contract) + expect(driver.calls).toContain('connect'); + }); + + 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' })); + const err = await svc + .connect( + { name: 'default', driver: 'turso', config: {}, origin: 'code', bootCritical: true }, + { asDefault: true, context: { origin: 'code', trigger: 'declared-auto' } }, + ) + .then( + () => { throw new Error('connect() resolved but should have thrown'); }, + (e: unknown) => e as Error, + ); + expect(err.message).toMatch(/boot-critical/); + expect(err.message).toContain('OS_ALLOW_DRIVER_CONNECT_FAILURE'); + }); +}); diff --git a/packages/services/service-datasource/src/index.ts b/packages/services/service-datasource/src/index.ts index 6b918c7b4c..12cdf07414 100644 --- a/packages/services/service-datasource/src/index.ts +++ b/packages/services/service-datasource/src/index.ts @@ -76,6 +76,12 @@ export type { // Host glue: dev driver factory + fail-closed secret binder. export { createDefaultDatasourceDriverFactory } from './default-datasource-driver-factory.js'; +// The "adopt a host-built driver instance" seam (ADR-0062 D1, #3826) — for +// driver kinds outside open-core (cloud turso) and pooled instances whose +// lifecycle outlives one kernel; keeps the connect + failure verdict on the +// one shared path instead of the legacy DriverPlugin/engine-init pair. +export { createPrebuiltDriverFactory } from './prebuilt-driver-factory.js'; +export type { PrebuiltDriverFactoryOptions } from './prebuilt-driver-factory.js'; // Shared native-better-sqlite3 → wasm → in-memory step-down (#2229). export { resolveSqliteDriver, diff --git a/packages/services/service-datasource/src/prebuilt-driver-factory.ts b/packages/services/service-datasource/src/prebuilt-driver-factory.ts new file mode 100644 index 0000000000..3bc6c26e58 --- /dev/null +++ b/packages/services/service-datasource/src/prebuilt-driver-factory.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An {@link IDatasourceDriverFactory} over a driver instance the HOST already + * built — the "adopt an existing driver" seam of ADR-0062 D1 (#3826), landed + * as a factory instead of a second connect entry point. + * + * ## Why this exists + * + * `DatasourceConnectionService.connect()` takes a *definition* and builds the + * driver through the injected factory — that is the one connect + failure + * -verdict implementation ADR-0062 D1 requires. Some hosts, however, hold a + * driver *instance* the shared open-core factory cannot or must not rebuild: + * + * - a driver kind that ships outside open-core (the cloud distribution's + * `@objectstack/driver-turso`), + * - a pooled instance whose lifecycle outlives one kernel (the cloud + * control-plane driver, reused as the proxy base of every environment + * kernel; per-environment drivers cached across kernel rebuilds), or + * - a test double. + * + * Before this seam, such a host had exactly two options: keep the pre-built + * driver on the legacy `DriverPlugin` path — whose connect verdict lives in + * `ObjectQLEngine.init()`, the second implementation #3826 exists to retire — + * or fork the connect orchestration. Both re-open the #3741 → #3758 drift. + * + * The factory abstraction already IS the adopt seam: `create()` never promised + * to construct anything, only to return a handle. So the host wraps its + * instance in this factory and hands the pair (definition, factory) to the one + * shared path — same policy gate, same `bootCritical` fail-fast, same + * `OS_ALLOW_DRIVER_CONNECT_FAILURE` escape hatch, same retained verdict — + * while pooling/reuse stay entirely the host's business. Every open-core + * driver's `connect()` is idempotent (re-verified in #3869), so adopting an + * already-connected instance re-connects it harmlessly — that re-connect is + * the boot verification #3741 wants anyway. + * + * NOT for the common case: a host that can express its datasource as + * `{ driver, config }` should do that and let the shared factory build it — + * a definition is inspectable, restartable, and admin-visible in ways a live + * object is not. + */ + +import type { + IDatasourceDriverFactory, + DatasourceConnectionSpec, + DatasourceDriverHandle, +} from './contracts/index.js'; + +/** Minimal engine-driver surface this wrapper forwards to the handle. */ +interface PrebuiltDriverLike { + name?: string; + connect?: () => Promise | unknown; + disconnect?: () => Promise | unknown; + checkHealth?: () => Promise | boolean; +} + +export interface PrebuiltDriverFactoryOptions { + /** + * Restrict `supports()` to this driver id (e.g. `'turso'`). When omitted the + * factory answers `true` for every id — fine when the host builds the factory + * per-datasource right next to its definition, too loose to compose into a + * multi-kind dispatch. + */ + driverId?: string; + /** + * Consulted for every id {@link PrebuiltDriverFactoryOptions.driverId} does + * not match (and, when `driverId` is omitted, never) — lets one factory serve + * "this pre-built instance for `turso`, the shared open-core factory for + * everything else". + */ + fallback?: IDatasourceDriverFactory; +} + +/** + * Wrap an already-constructed engine driver in an + * {@link IDatasourceDriverFactory} so `DatasourceConnectionService` can adopt + * it through the one shared connect path. `create()` returns the SAME instance + * on every call — construction, pooling, and reuse remain host concerns. + */ +export function createPrebuiltDriverFactory( + driver: PrebuiltDriverLike, + options: PrebuiltDriverFactoryOptions = {}, +): IDatasourceDriverFactory { + const { driverId, fallback } = options; + const matches = (id: string): boolean => + driverId === undefined || String(id ?? '').toLowerCase() === driverId.toLowerCase(); + + return { + supports(id: string): boolean { + if (matches(id)) return true; + return fallback?.supports(id) ?? false; + }, + + create(spec: DatasourceConnectionSpec): Promise | DatasourceDriverHandle { + if (!matches(spec.driver)) { + if (fallback) return fallback.create(spec); + throw new Error( + `prebuilt driver factory only supports driver '${driverId}', got '${spec.driver}'`, + ); + } + // 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. + const health = + typeof driver.checkHealth === 'function' + ? async () => Boolean(await driver.checkHealth!()) + : undefined; + return { + connect: typeof driver.connect === 'function' ? async () => { await driver.connect!(); } : undefined, + disconnect: typeof driver.disconnect === 'function' ? async () => { await driver.disconnect!(); } : undefined, + checkHealth: health, + ping: health, + driver, + }; + }, + }; +} diff --git a/packages/verify/package.json b/packages/verify/package.json index 104c8d3950..5effc7e6b4 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -21,7 +21,6 @@ }, "dependencies": { "@objectstack/core": "workspace:*", - "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index ed4e5599e8..919556ac28 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -20,9 +20,8 @@ // `objectstack dev`). This is a verification harness — it never touches a real // database or production data. -import { ObjectKernel, AppPlugin, DriverPlugin, createDispatcherPlugin } from '@objectstack/runtime'; +import { ObjectKernel, AppPlugin, DefaultDatasourcePlugin, createDispatcherPlugin } from '@objectstack/runtime'; import { ObjectQLPlugin } from '@objectstack/objectql'; -import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/rest'; import { AuthPlugin } from '@objectstack/plugin-auth'; @@ -156,8 +155,14 @@ export async function bootStack( const kernel = new ObjectKernel(); // Data engine + in-memory SQLite (pure-JS WASM driver — no native build, CI-safe). + // The default datasource is a DECLARED DEFINITION connected through the + // shared DatasourceConnectionService (ADR-0062 D1, #3826) — the same boot + // shape `objectstack dev`/`serve` use since the standalone stack converged, + // so the dogfood gate exercises the real declared-default connect path (the + // §Risk mitigation the ADR promised), not the legacy pre-built DriverPlugin + // escape hatch. await kernel.use(new ObjectQLPlugin()); - await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); // HTTP server (registers the `http-server` IHttpServer service the REST + // dispatcher plugins mount their routes onto). Port 0 = ephemeral; we never diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c17f98207..1e60504dd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2322,9 +2322,6 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../core - '@objectstack/driver-sqlite-wasm': - specifier: workspace:* - version: link:../plugins/driver-sqlite-wasm '@objectstack/objectql': specifier: workspace:* version: link:../objectql