Skip to content

Commit c3bcb42

Browse files
os-zhuangclaude
andauthored
feat(runtime,datasource): default-datasource factory seam + adopt pre-built drivers via one connect path (#3968)
* feat(runtime,datasource): default-datasource factory seam + adopt pre-built drivers via one connect path (#3826) DefaultDatasourcePlugin accepts an injected IDatasourceDriverFactory (default: the shared open-core factory, unchanged when omitted), and createPrebuiltDriverFactory wraps a host-built driver instance as a factory — the "adopt an existing driver" seam, landed as a factory so the connect + failure-verdict orchestration stays the single DatasourceConnectionService implementation. This is what the cloud compositions (turso, pooled control-plane/environment drivers) need to converge without forking the verdict. The @objectstack/verify dogfood harness now boots the declared default (sqlite-wasm definition) instead of a pre-built DriverPlugin, so the dogfood gate covers the converged path — the ADR-0062 §Risk mitigation. ADR-0062's header no longer understates D1 (open-core converged; the cloud repo is the last second site), and the degraded-boot parity guard's comment now describes what it actually guards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GK5nd3gtehcAhrF6Zzp2on * chore: update pnpm-lock.yaml for the removed verify dependency CI frozen-lockfile caught the miss: dropping @objectstack/driver-sqlite-wasm from @objectstack/verify's dependencies needs the lockfile regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GK5nd3gtehcAhrF6Zzp2on * docs(drivers): DriverPlugin is the escape hatch, not "the same path the CLI uses" Stale since #3886: the CLI's primary boots as a declared default definition, not a pre-built DriverPlugin. Flagged by the docs drift check on this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GK5nd3gtehcAhrF6Zzp2on --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7cb922e commit c3bcb42

13 files changed

Lines changed: 426 additions & 27 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/service-datasource": minor
4+
"@objectstack/verify": patch
5+
---
6+
7+
feat(runtime,datasource): the default-datasource connect seam accepts a host driver factory — adopt pre-built instances without forking the verdict (#3826)
8+
9+
ADR-0062 D1's open-core convergence (#3869/#3886) left one structural question
10+
open: a host whose `default` needs a driver the shared factory cannot build —
11+
the cloud distribution's `turso`, or an instance pooled BEYOND one kernel (the
12+
cloud control-plane driver doubles as the proxy base of every environment
13+
kernel; per-environment drivers are cached across kernel rebuilds) — had only
14+
two options, both bad: stay on the legacy pre-built `DriverPlugin` path, whose
15+
connect verdict lives in `ObjectQLEngine.init()` (the second implementation
16+
#3826 exists to retire), or fork the connect orchestration. Either re-opens the
17+
#3741#3758 drift this whole line of work is about.
18+
19+
Two additive pieces close it:
20+
21+
- **`DefaultDatasourcePlugin` accepts an injected `IDatasourceDriverFactory`**
22+
(defaults to the shared open-core factory, byte-for-byte unchanged when
23+
omitted). The factory only changes what `create()` returns — the policy-free
24+
init connect, `bootCritical` fail-fast, `OS_ALLOW_DRIVER_CONNECT_FAILURE`
25+
escape hatch, and the start() replay into retained admin state are identical
26+
either way, and the new tests pin that (an adopted instance that cannot
27+
connect takes the exact same verdict).
28+
- **`createPrebuiltDriverFactory(driver, { driverId?, fallback? })`** in
29+
`@objectstack/service-datasource` — the "adopt an existing driver" seam the
30+
first #3826 pass found missing, landed AS a factory so it composes into the
31+
one connect path instead of becoming a second entry point. `create()` returns
32+
the SAME instance every call: construction, pooling, and reuse stay host
33+
concerns; only the verdict converges. Not for the common case — a `default`
34+
expressible as `{ driver, config }` should stay a plain definition.
35+
36+
The `@objectstack/verify` dogfood harness now boots through
37+
`DefaultDatasourcePlugin` (declared `sqlite-wasm` definition) instead of a
38+
pre-built `DriverPlugin` — so the dogfood gate exercises the same declared
39+
-default connect path `objectstack dev`/`serve` use, which is the §Risk
40+
mitigation ADR-0062 promised ("behind the dogfood gate") and did not yet have.
41+
The degraded-boot parity guard stays: `ObjectQLEngine.init()`'s verdict is
42+
still live for the boot re-verification, `DriverPlugin` escape-hatch drivers,
43+
and the cloud compositions until they converge onto this seam.

content/docs/data-modeling/drivers.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ Drivers can be selected in two ways:
2121
override the inference (e.g. force `sqlite` for an ambiguous path).
2222

2323
2. **Programmatic** — register a driver instance as a kernel plugin via
24-
`DriverPlugin` (the same path the CLI uses internally):
24+
`DriverPlugin`. This is the escape hatch for pre-built or auxiliary
25+
drivers — the CLI itself no longer boots the primary this way: it declares
26+
the `default` datasource as a definition and connects it through the shared
27+
datasource path ([#3826](https://github.com/objectstack-ai/objectstack/issues/3826),
28+
described further down this page):
2529
```typescript
2630
import { DriverPlugin } from '@objectstack/runtime';
2731
import { SqlDriver } from '@objectstack/driver-sql';

docs/adr/0062-external-datasource-runtime.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0062: External Datasource Runtime — connection lifecycle, credentials, visibility & query completeness
22

3-
**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).
3+
**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.
44

55
**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.
66

@@ -68,7 +68,9 @@ Introduce a single service that, given a datasource definition, builds a driver
6868
>
6969
> **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.
7070
>
71-
> **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.
71+
> **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.
72+
>
73+
> **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.
7274
7375
### D2 — Connect is opt-in-safe: existing managed apps are byte-for-byte unchanged
7476

packages/runtime/src/default-datasource-plugin.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,18 @@ async function assemble(opts: {
1919
withAdminPlugin?: boolean;
2020
connectPolicy?: any;
2121
bundle?: any;
22+
/** Host-injected driver factory (the cloud/EE seam) — see the last cases. */
23+
factory?: any;
2224
} = {}) {
2325
const { ObjectQLPlugin } = await import('@objectstack/objectql');
2426
const runtime = new Runtime({ cluster: false });
2527
const kernel = runtime.getKernel();
2628
// Order matters for START: the default datasource must connect before
2729
// ObjectQLPlugin.start() runs boot schema-sync.
28-
await kernel.use(new DefaultDatasourcePlugin({ driver: opts.driver ?? 'memory' }));
30+
await kernel.use(new DefaultDatasourcePlugin(
31+
{ driver: opts.driver ?? 'memory' },
32+
opts.factory ? { factory: opts.factory } : {},
33+
));
2934
await kernel.use(new ObjectQLPlugin());
3035
if (opts.bundle) await kernel.use(new AppPlugin(opts.bundle));
3136
if (opts.withAdminPlugin !== false) {
@@ -135,6 +140,60 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (#
135140
}
136141
}, BOOT_TIMEOUT);
137142

143+
it('adopts a HOST-BUILT driver instance through an injected factory — same path, host pooling (#3826 seam)', async () => {
144+
// The cloud composition's shape: the host already holds a constructed
145+
// (possibly pooled/shared) driver instance the open-core factory cannot
146+
// rebuild. createPrebuiltDriverFactory wraps it; the plugin's connect
147+
// orchestration must register THAT instance — never a rebuilt copy.
148+
const { createPrebuiltDriverFactory } = await import('@objectstack/service-datasource');
149+
const { InMemoryDriver } = await import('@objectstack/driver-memory');
150+
const hostBuilt = new InMemoryDriver();
151+
const kernel = await assemble({
152+
driver: 'turso', // a kind the SHARED factory does not support — proves dispatch
153+
factory: createPrebuiltDriverFactory(hostBuilt, { driverId: 'turso' }),
154+
bundle: {
155+
manifest: { id: 'com.test.adopted-ds', name: 'Adopted DS', version: '1.0.0' },
156+
objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }],
157+
},
158+
});
159+
try {
160+
await kernel.bootstrap();
161+
const engine = kernel.getService<any>('data');
162+
const defaultName = engine.getDefaultDriverName();
163+
expect(defaultName).toBeDefined();
164+
// Identity, not equivalence: the engine's default driver IS the host's instance.
165+
expect(engine.getDriverByName(defaultName)).toBe(hostBuilt);
166+
await engine.insert('note', { title: 'through-the-adopted-default' });
167+
const rows = await engine.find('note');
168+
expect(rows.map((r: any) => r.title)).toContain('through-the-adopted-default');
169+
} finally {
170+
try { await (kernel as any)?.stop?.(); } catch { /* noop */ }
171+
}
172+
}, BOOT_TIMEOUT);
173+
174+
it('an injected factory rides the SAME failure verdict — fail-fast, same escape hatch', async () => {
175+
// The seam must never fork the verdict: an adopted instance that cannot
176+
// connect takes the identical bootCritical fail-fast (and the identical
177+
// OS_ALLOW_DRIVER_CONNECT_FAILURE override) as a factory-built default.
178+
const { createPrebuiltDriverFactory } = await import('@objectstack/service-datasource');
179+
const failing = {
180+
name: 'turso',
181+
async connect() { throw new Error('connect ECONNREFUSED 127.0.0.1:8080'); },
182+
async disconnect() {},
183+
};
184+
const kernel = await assemble({
185+
driver: 'turso',
186+
factory: createPrebuiltDriverFactory(failing, { driverId: 'turso' }),
187+
});
188+
const err = await kernel.bootstrap().then(
189+
() => { throw new Error('bootstrap() resolved but should have thrown'); },
190+
(e: unknown) => e as Error,
191+
);
192+
expect(err.message).toMatch(/boot-critical/);
193+
expect(err.message).toContain('OS_ALLOW_DRIVER_CONNECT_FAILURE');
194+
try { await (kernel as any)?.stop?.(); } catch { /* noop */ }
195+
}, BOOT_TIMEOUT);
196+
138197
it("rejects an app bundle that declares a datasource named 'default' (host-reserved name)", async () => {
139198
const kernel = await assemble({
140199
bundle: {

packages/runtime/src/default-datasource-plugin.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
DatasourceConnectionService,
66
createDefaultDatasourceDriverFactory,
77
type ConnectableDatasource,
8+
type IDatasourceDriverFactory,
89
} from '@objectstack/service-datasource';
910

1011
/**
@@ -52,12 +53,37 @@ import {
5253
* drivers — it is no longer how the standalone default boots.
5354
*/
5455
export interface DefaultDatasourceDefinition {
55-
/** Driver id the shared factory can build (`sqlite`, `sqlite-wasm`, `postgres`, `mongodb`, `memory`). */
56+
/** Driver id the (injected or shared) factory can build (`sqlite`, `sqlite-wasm`, `postgres`, `mongodb`, `memory`). */
5657
driver: string;
5758
config?: Record<string, unknown>;
5859
label?: string;
5960
}
6061

62+
export interface DefaultDatasourcePluginOptions {
63+
/** Arms the shared factory's dev sqlite step-down (#2229) + loosen-only self-heal passthroughs. */
64+
dev?: boolean;
65+
/**
66+
* Host-injected driver factory. Defaults to the shared open-core factory
67+
* (`createDefaultDatasourceDriverFactory`). The seam exists for hosts whose
68+
* `default` needs a driver the open-core factory cannot build — the cloud
69+
* distribution's `turso`, or an already-pooled instance adopted via
70+
* `createPrebuiltDriverFactory` — WITHOUT forking the connect + failure
71+
* -verdict orchestration this plugin owns (the #3741 → #3758 drift). The
72+
* factory only changes what `create()` returns; the policy-free init
73+
* connect, `bootCritical` verdict, escape hatch, and start() replay are
74+
* identical either way.
75+
*
76+
* Note the start() replay goes through the SHARED `'datasource-connection'`
77+
* service, whose factory is the host's admin-plugin one — after a NORMAL
78+
* boot that replay resolves `already-registered` before any factory is
79+
* consulted, but a degraded boot's real retry (init failed under
80+
* `OS_ALLOW_DRIVER_CONNECT_FAILURE`) retries with the shared factory. A
81+
* host injecting a factory for a kind the shared one cannot build should
82+
* expect that retry to report `skipped-unsupported` rather than reattempt.
83+
*/
84+
factory?: IDatasourceDriverFactory;
85+
}
86+
6187
export class DefaultDatasourcePlugin implements Plugin {
6288
name = 'com.objectstack.runtime.default-datasource';
6389
version = '1.0.0';
@@ -71,10 +97,12 @@ export class DefaultDatasourcePlugin implements Plugin {
7197

7298
private readonly def: DefaultDatasourceDefinition;
7399
private readonly dev?: boolean;
100+
private readonly factory?: IDatasourceDriverFactory;
74101

75-
constructor(def: DefaultDatasourceDefinition, opts: { dev?: boolean } = {}) {
102+
constructor(def: DefaultDatasourceDefinition, opts: DefaultDatasourcePluginOptions = {}) {
76103
this.def = def;
77104
this.dev = opts.dev;
105+
this.factory = opts.factory;
78106
}
79107

80108
private record(): ConnectableDatasource {
@@ -90,7 +118,7 @@ export class DefaultDatasourcePlugin implements Plugin {
90118

91119
init = async (ctx: PluginContext) => {
92120
const connection = new DatasourceConnectionService({
93-
factory: () => createDefaultDatasourceDriverFactory({ dev: this.dev }),
121+
factory: () => this.factory ?? createDefaultDatasourceDriverFactory({ dev: this.dev }),
94122
engine: () => {
95123
try {
96124
return ctx.getService('data');

packages/runtime/src/degraded-boot-parity.test.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,28 @@
33
// framework#3826 — DRIFT GUARD for the two "a datasource would not connect"
44
// implementations.
55
//
6-
// ADR-0062 D1 asks for exactly one "definition → live driver" path. The
7-
// *construction* half converged (both build through the shared datasource driver
8-
// factory), but the *connect + failure verdict* half did not:
6+
// ADR-0062 D1 asks for exactly one "definition → live driver" path, and for
7+
// every OPEN-CORE boot that is now true: the `default` datasource is a declared
8+
// definition connected by `DefaultDatasourcePlugin` through
9+
// `DatasourceConnectionService.connect()` (#3869 standalone/artifact, #3886 the
10+
// CLI serve fallback) — `handleFailure` (#3758) is its one failure verdict.
11+
// But the ENGINE-side verdict — `ObjectQLEngine.init()` → `DriverConnectError`
12+
// (#3741) — is still live code, on purpose, for three callers:
913
//
10-
// - the `default` driver is registered as a `driver.*` kernel service and
11-
// connected by `ObjectQLEngine.init()` → DriverConnectError (#3741)
12-
// - declared datasources are connected by
13-
// `DatasourceConnectionService.connect()` → handleFailure (#3758)
14+
// - the boot re-verification of the already-connected default (the #3741
15+
// fail-fast role D1 deliberately keeps in `init()`),
16+
// - drivers registered via the pre-built `DriverPlugin` escape hatch
17+
// (the telemetry sibling, tests, dynamic/proxy drivers), and
18+
// - the cloud repo's own compositions (`environment-kernel-factory`,
19+
// control-plane preset), until they converge onto the shared path.
1420
//
15-
// They agree today only because two separate pieces of code were each written
16-
// correctly — and the last time they disagreed, the gap survived three months
17-
// and a second bug report (#3741 fixed one layer, #3758 was the other). Until
18-
// the paths are actually merged, this test is what notices a divergence:
19-
// it pins the operator-visible contract both of them owe.
21+
// So two verdict implementations still coexist, and they agree only because
22+
// each is written correctly — the last time they disagreed, the gap survived
23+
// three months and a second bug report (#3741 fixed one layer, #3758 was the
24+
// other). This test pins the operator-visible contract both of them owe:
25+
// fail-fast by default, one identically-parsed escape hatch, DEGRADED BOOT on
26+
// stderr. It outlives the cloud convergence: as long as `init()` can throw a
27+
// connect verdict at all, that verdict must match the service's.
2028
//
2129
// This lives in `runtime` because it is the only package that depends on both.
2230

packages/runtime/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type { DefaultHostConfigOptions, DefaultHostConfigResult } from './defaul
1818
// Export Plugins
1919
export { DriverPlugin } from './driver-plugin.js';
2020
export { DefaultDatasourcePlugin } from './default-datasource-plugin.js';
21-
export type { DefaultDatasourceDefinition } from './default-datasource-plugin.js';
21+
export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js';
2222
export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleActions } from './app-plugin.js';
2323
export { SeedLoaderService } from './seed-loader.js';
2424
// Boot-summary seed outcome accumulator (#3415/#3430) — the single writer

0 commit comments

Comments
 (0)