From aeb675a183df52c14bcb32011d50e1bd1d80abac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 13:05:14 +0000 Subject: [PATCH] feat(runtime): the standalone default datasource is a declaration, connected through the one datasource path (#3826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0062 D1 asked for exactly one "definition → live driver" path. Construction converged earlier; connect + failure verdict did not — the standalone `default` was pre-built and smuggled into the engine as a `driver.*` kernel service, so "what if it cannot connect" lived in `ObjectQLEngine.init()`, a second implementation of the policy `DatasourceConnectionService` owns for every other datasource. #3741 → #3758 showed what two copies cost. - `createStandaloneStack` emits a datasource DEFINITION; the new `DefaultDatasourcePlugin` connects it through the shared connection service. The connect happens in init() with a hard dependency on ObjectQLPlugin: the kernel resolves BOTH phases from the dependency graph, so list position proves nothing — the first cut connected in start() and a serve boot hoisted ObjectQL ahead of it, shipping a server with no tables (caught by the dev:crm smoke, fixed by phase separation: all inits precede all starts). - `bootCritical` on ConnectableDatasource: a third D5 fail-fast cause — the host declares the platform cannot run without it; shares OS_ALLOW_DRIVER_CONNECT_FAILURE and the DEGRADED BOOT banner. The default deliberately bypasses the connect policy (byte-for-byte with the old boot; that gate exists for optional datasources). - `connect(record, { asDefault: true })`: registers under the driver's natural name with isDefault, guarded by engine.getDefaultDriverName() idempotency. start() replays through the shared service so the primary DB shows a real status in Setup → Datasources (#3827). - `sqlite-wasm` joined the shared driver factory (last bespoke construction). - `default` is host-reserved: rejected in app bundles at load (AppPlugin) and in runtime-admin create. - The `driver.` kernel service is still registered (os migrate's findSqlDriver and serve's storage detection read it); ObjectQL's discovery loop no-ops on it via the engine's skip-if-present guard. - serve's hasDriver detection counts DefaultDatasourcePlugin as a driver provider so the storage-driver fallback doesn't build a duplicate pool. The config-load fallback (createStorageDriver, mysql/turso, telemetry coupling) remains a tracked second site in #3826. - ObjectQLEngine.init() unchanged: it re-connects the already-connected default (all open-core drivers' connect() is idempotent) — the #3741 verification role D1 leaves it. Verified end to end: dev:crm --fresh creates the same 71 tables as unmodified main (the broken intermediate produced 10), zero "no such table", authenticated data reads serve seeded rows, and the artifact-serve path boots with `default` status 'ok' and no duplicate driver. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TQVM3A9Yd6N2eZS8ZcdnMk --- .changeset/default-datasource-declared.md | 55 +++++ content/docs/data-modeling/drivers.mdx | 8 + docs/adr/0062-external-datasource-runtime.md | 4 +- packages/cli/src/commands/serve.ts | 11 +- packages/objectql/src/engine.ts | 10 + packages/runtime/src/app-plugin.ts | 25 ++ .../src/default-datasource-plugin.test.ts | 153 ++++++++++++ .../runtime/src/default-datasource-plugin.ts | 218 ++++++++++++++++++ packages/runtime/src/index.ts | 2 + packages/runtime/src/standalone-stack.test.ts | 76 +++--- packages/runtime/src/standalone-stack.ts | 132 +++++------ .../default-datasource-driver-factory.test.ts | 57 +++++ .../src/datasource-admin-service.ts | 8 + .../src/datasource-connection-service.ts | 74 +++++- .../src/default-datasource-driver-factory.ts | 34 ++- 15 files changed, 751 insertions(+), 116 deletions(-) create mode 100644 .changeset/default-datasource-declared.md create mode 100644 packages/runtime/src/default-datasource-plugin.test.ts create mode 100644 packages/runtime/src/default-datasource-plugin.ts create mode 100644 packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts diff --git a/.changeset/default-datasource-declared.md b/.changeset/default-datasource-declared.md new file mode 100644 index 0000000000..30c4d58384 --- /dev/null +++ b/.changeset/default-datasource-declared.md @@ -0,0 +1,55 @@ +--- +"@objectstack/runtime": minor +"@objectstack/service-datasource": minor +"@objectstack/cli": patch +--- + +feat(runtime)!: the standalone `default` datasource is a declaration, connected through the one datasource path (#3826) + +ADR-0062 D1 asked for exactly one "definition → live driver" path. Construction +converged earlier; the *connect + failure verdict* half did not — the standalone +`default` driver was pre-built and smuggled into the engine as a `driver.*` +kernel service, so "what if it cannot connect" lived in `ObjectQLEngine.init()`, +a second implementation of the policy `DatasourceConnectionService` owns for +every other datasource. #3741 → #3758 showed what two copies cost: a fix to one +missed the other for three months. + +- **`createStandaloneStack` now emits a datasource DEFINITION**, not a driver. + URL→config translation and `mkdir` stay host concerns; the new + **`DefaultDatasourcePlugin`** (exported from `@objectstack/runtime`) connects + the definition at boot through the shared `DatasourceConnectionService` — + same driver factory, same failure verdict, same retained state. It must be + registered before `ObjectQLPlugin` (boot schema-sync needs the driver); + `createStandaloneStack` orders it correctly. +- **`sqlite-wasm` joined the shared driver factory** (`sqlite-wasm` / + `wasm-sqlite` ids) — it was the last bespoke construction site. +- **`bootCritical` on `ConnectableDatasource`**: the host declares a datasource + the platform cannot run without; a boot connect failure is then fatal + regardless of object bindings, sharing `OS_ALLOW_DRIVER_CONNECT_FAILURE` and + the `DEGRADED BOOT` banner with the engine-level guard. A connect policy that + denies a boot-critical datasource fails the boot loudly — the #3828 "denial is + not a failure" boundary was drawn for optional datasources. +- **`connect(record, { asDefault: true })`**: registers the built driver as the + engine's default under its natural name (no `'default'` stamping — routing to + `default` goes through the engine's default-driver fallback, and the natural + name keeps logs/lookups byte-for-byte with the previous boot). +- **`default` is a host-reserved name**: an app bundle declaring a datasource + named `default` is rejected at load (`AppPlugin`), and the runtime-admin + create rejects it too. It would shadow the host's primary datasource and, if + it passed the auto-connect gate, silently divert every unbound object. +- The primary DB now shows a REAL `status` in Setup → Datasources (#3827) — + `ok` when connected, `error` + reason when the operator boots degraded. +- `ObjectQLEngine.init()` is unchanged and keeps its fail-fast: it re-connects + the already-connected default (every open-core driver's `connect()` is + idempotent), which is exactly the boot verification #3741 wants. +- `DriverPlugin` remains the escape hatch for tests and pre-built/proxy drivers + (e.g. the CLI's `telemetry` datasource) — no longer how the standalone + default boots. The CLI serve config-load fallback (`createStorageDriver`, + incl. mysql/turso) still constructs directly; tracked in #3826. + +**Migration.** Boots through `createStandaloneStack` (CLI `serve`/`dev` +artifact path, quickstarts, embedders using the stack factory) change shape but +not behavior: same driver kinds, same URLs, same fail-fast semantics, same +escape hatch. Embedders that composed `DriverPlugin` manually are unaffected. +An app that declared a datasource literally named `default` now fails to load +with a rename instruction — that name never routed correctly to begin with. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index d84180c9e5..49512d9944 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -95,6 +95,14 @@ The same guard covers **declared datasources** whose objects have no fallback see [When auto-connect fails](/docs/data-modeling/external-datasources#when-auto-connect-fails) ([#3758](https://github.com/objectstack-ai/objectstack/issues/3758)). +The standalone `default` datasource itself is now a **declared definition** +([#3826](https://github.com/objectstack-ai/objectstack/issues/3826)): the stack +translates `OS_DATABASE_URL` into `{ driver, config }` and connects it at boot +through the same datasource connection path — one failure verdict, one escape +hatch, and a real `status` for the primary DB in **Setup → Datasources**. The +name `default` is host-reserved: an app bundle declaring a datasource with that +name is rejected at load. + `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` boots anyway, in an explicitly degraded state announced by a `DEGRADED BOOT` banner. Queries to a failed driver fail diff --git a/docs/adr/0062-external-datasource-runtime.md b/docs/adr/0062-external-datasource-runtime.md index 38074ea2e4..9dcfe6b80d 100644 --- a/docs/adr/0062-external-datasource-runtime.md +++ b/docs/adr/0062-external-datasource-runtime.md @@ -64,9 +64,9 @@ Introduce a single service that, given a datasource definition, builds a driver > | pool teardown | kernel shutdown via `DriverPlugin` | `DatasourceConnectionService.disconnect()` | > | connect policy | not consulted | `DatasourceConnectPolicy` | > -> **What actually blocks the merge is an input-shape mismatch, not ordering.** The kernel's init-all-then-start-all means the connection service *does* exist by `ObjectQLPlugin.start()`, so timing is available. The obstacles are: (1) `DatasourceConnectionService.connect()` takes a datasource *definition* and **builds** the driver, while `default` arrives as an already-constructed driver instance published as a `driver.*` kernel service — there is no "adopt this driver" entry point; and (2) routing `default` through the service would make `ObjectQLPlugin`'s boot depend on an **optional service from a higher layer** (`service-datasource`), inverting the layering for the one driver every app needs. Closing this means either adding an `adoptDriver()` seam to the connection service, or making the standalone `default` a real declared datasource definition — a design decision, not a mechanical move, and still the riskiest single step per §Risk. +> **Resolution (#3826, second pass) — the standalone `default` is now a declared definition.** The input-shape mismatch was resolved by making the definition the input: `createStandaloneStack` translates the database URL into a `{ driver, config }` definition (URL→config translation and `mkdir` stay host concerns) and the runtime's **`DefaultDatasourcePlugin`** — registered before `ObjectQLPlugin`, so the driver exists before boot schema-sync — connects it through `DatasourceConnectionService.connect(record, { asDefault: true })`. The definition is marked **`bootCritical`**, which adds a third fail-fast cause to D5 (the platform cannot run without it; every unbound object routes to it), sharing `OS_ALLOW_DRIVER_CONNECT_FAILURE` and the `DEGRADED BOOT` banner with the engine guard. `asDefault` keeps the driver's **natural name** (routing to `default` uses the engine's default-driver fallback, never `drivers.get('default')`) and registers with `isDefault: true`. The presumed layering inversion did not materialize: the *runtime host* orchestrates (runtime already depends on `service-datasource`); `ObjectQLPlugin` learned nothing. When the datasource-admin plugin is present its shared connection service is used (so `default` shows a real `status` in Setup → Datasources, #3827); a lite kernel instantiates the same class locally — one implementation either way. `sqlite-wasm` joined the shared factory (the last bespoke construction site), `default` became a host-reserved name (rejected in app bundles at load and in runtime-admin create), and `ObjectQLEngine.init()` keeps its #3741 fail-fast unchanged — it re-connects the already-connected default (all open-core drivers' `connect()` is idempotent), which is precisely the boot *verification* role D1 leaves it. > -> Until then the divergence is guarded rather than assumed: `packages/runtime/src/degraded-boot-parity.test.ts` pins both paths to the same operator-visible contract (fail-fast by default, identical `OS_ALLOW_DRIVER_CONNECT_FAILURE` parsing, `DEGRADED BOOT` on stderr), so a change to one that forgets the other fails CI instead of shipping. #3741 → #3758 was exactly that miss, and it cost three months and a second bug report. +> **Remaining second sites, tracked in #3826:** the CLI serve **config-load fallback** (`createStorageDriver` + `DriverPlugin`, used when a host `objectstack.config.ts` supplies no driver — it also carries mysql/turso kinds the shared factory does not build, and the `telemetry` sibling-datasource provisioning is coupled to its resolution result), and the cloud stack's own composition. Until those 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), so a change to one that forgets the other fails CI instead of shipping. #3741 → #3758 was exactly that miss. ### D2 — Connect is opt-in-safe: existing managed apps are byte-for-byte unchanged diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index acb1f3b62b..0f30ee11be 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -872,7 +872,16 @@ export default class Serve extends Command { // whole dispatch is unit-testable (storage-driver.test.ts). #3276: the // `memory` kind now maps to the mingo InMemoryDriver instead of silently // falling through to the dev SQLite `:memory:` default. - const hasDriver = plugins.some((p: any) => p.name?.includes('driver') || p.constructor?.name?.includes('Driver')); + // A DefaultDatasourcePlugin counts as a driver provider (#3826): the + // standalone stack now DECLARES its `default` datasource and connects it + // at boot through the datasource connection service, so building a + // storage driver here would construct a duplicate pool the engine then + // discards as already-registered. + const hasDriver = plugins.some((p: any) => + p.name?.includes('driver') || + p.constructor?.name?.includes('Driver') || + p.name === 'com.objectstack.runtime.default-datasource' || + p.constructor?.name === 'DefaultDatasourcePlugin'); if (!hasDriver && config.objects) { const databaseUrl = process.env.OS_DATABASE_URL; const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2e9663163a..5ec3fbe48b 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1519,6 +1519,16 @@ export class ObjectQL implements IDataEngine { this.unavailableDatasources.delete(name); } + /** + * Name of the DEFAULT driver, when one is registered (#3826). The default + * driver keeps its natural name (`registerDriver(driver, true)` — nothing + * routes by `drivers.get('default')`), so the datasource connection layer's + * `asDefault` idempotency guard needs this rather than a name lookup. + */ + getDefaultDriverName(): string | undefined { + return this.defaultDriver ?? undefined; + } + /** * Datasources that were declared but are NOT usable, with the reason class. * diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 5bf49a7bcd..773c33c466 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -372,6 +372,31 @@ export class AppPlugin implements Plugin { // ONLY — never persisted to the runtime DB store — and stamped // `origin:'code'` so the admin service enforces them as read-only. // The engine already indexed them for the write gate via registerApp(). + // + // `default` is a HOST-owned reserved name (#3826): the runtime declares + // and connects it (DefaultDatasourcePlugin). An app declaring it would + // shadow the host's metadata row and — if it passed the D2 gate — + // divert every unbound object to a fresh connection. Contract-first: + // reject at load, loudly (outside the lenient catch below), instead of + // letting the collision produce undefined routing. + { + const dsDefs = this.bundle.datasources; + const declared = Array.isArray(dsDefs) + ? dsDefs + : dsDefs && typeof dsDefs === 'object' + ? Object.values(dsDefs as Record) + : []; + const names = Array.isArray(dsDefs) + ? declared.map((d: any) => d?.name) + : Object.keys((dsDefs as Record) ?? {}); + if (declared.some((d: any) => d?.name === 'default') || names.includes('default')) { + throw new Error( + `[AppPlugin] app '${appId}' declares a datasource named 'default' — that name is ` + + `reserved for the host's primary datasource. Rename it (e.g. '${appId.split('.').pop()}_primary') ` + + `and route objects to it explicitly, or omit it to use the host default.`, + ); + } + } try { const dsDefs = this.bundle.datasources; const dsList = Array.isArray(dsDefs) diff --git a/packages/runtime/src/default-datasource-plugin.test.ts b/packages/runtime/src/default-datasource-plugin.test.ts new file mode 100644 index 0000000000..4c5aa49c7b --- /dev/null +++ b/packages/runtime/src/default-datasource-plugin.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0062 D1 (#3826): the standalone `default` datasource is a DECLARATION, +// connected at boot by DefaultDatasourcePlugin through the same +// DatasourceConnectionService as every declared/runtime datasource — one +// connect path, one failure verdict, one escape hatch. These boots exercise +// the real kernel (init-all → start-all) with the real driver factory. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Runtime } from './runtime.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; +import { AppPlugin } from './app-plugin.js'; + +const BOOT_TIMEOUT = 60_000; +const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; + +async function assemble(opts: { + driver?: string; + withAdminPlugin?: boolean; + connectPolicy?: any; + bundle?: 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 ObjectQLPlugin()); + if (opts.bundle) await kernel.use(new AppPlugin(opts.bundle)); + if (opts.withAdminPlugin !== false) { + const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import( + '@objectstack/service-datasource' + ); + await kernel.use( + new DatasourceAdminServicePlugin({ + driverFactory: createDefaultDatasourceDriverFactory(), + connectPolicy: opts.connectPolicy, + }), + ); + } + return kernel; +} + +describe('DefaultDatasourcePlugin — the default datasource as a declaration (#3826)', () => { + let saved: string | undefined; + beforeEach(() => { saved = process.env[ENV]; delete process.env[ENV]; }); + afterEach(() => { + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + }); + + it('boots, registers the driver as DEFAULT, and serves reads/writes end to end', async () => { + const kernel = await assemble({ + bundle: { + manifest: { id: 'com.test.default-ds', name: 'Default DS', version: '1.0.0' }, + objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }], + }, + }); + try { + await kernel.bootstrap(); + const engine = kernel.getService('data'); + // The driver keeps its NATURAL name (no 'default' stamping) — routing to + // `default` goes through the engine's default-driver fallback. + expect(engine.getDriverByName('default')).toBeUndefined(); + await engine.insert('note', { title: 'through-the-default' }); + const rows = await engine.find('note'); + expect(rows.map((r: any) => r.title)).toContain('through-the-default'); + } finally { + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + } + }, BOOT_TIMEOUT); + + it('shows the primary DB in the datasource-admin list with a REAL status (#3827)', async () => { + const kernel = await assemble({}); + try { + await kernel.bootstrap(); + const admin = kernel.getService<{ listDatasources(): Promise }>('datasource-admin'); + const def = (await admin.listDatasources()).find((d) => d.name === 'default'); + expect(def).toBeDefined(); + expect(def!.status).toBe('ok'); + } finally { + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + } + }, BOOT_TIMEOUT); + + it('works without the datasource-admin plugin — same class, locally instantiated', async () => { + const kernel = await assemble({ withAdminPlugin: false }); + try { + await kernel.bootstrap(); + const engine = kernel.getService('data'); + await engine.insert('sys_metadata', undefined as never).catch(() => { /* shape probe only */ }); + // The default driver exists and the engine can answer a trivial query path. + expect(typeof engine.find).toBe('function'); + } finally { + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + } + }, BOOT_TIMEOUT); + + it('refuses the boot when the default cannot be built/connected (bootCritical ⇒ fail-fast)', async () => { + const kernel = await assemble({ driver: 'not-a-real-driver' }); + const err = await kernel.bootstrap().then( + () => { throw new Error('bootstrap() resolved but should have thrown'); }, + (e: unknown) => e as Error, + ); + expect(err.message).toMatch(/default/); + 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('boots degraded under OS_ALLOW_DRIVER_CONNECT_FAILURE — same escape hatch as the engine guard', async () => { + process.env[ENV] = '1'; + const kernel = await assemble({ driver: 'not-a-real-driver' }); + try { + await expect(kernel.bootstrap()).resolves.not.toThrow(); + } finally { + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + } + }, BOOT_TIMEOUT); + + it('is NOT gated by the host connect policy — a deny-all policy cannot block the primary DB', async () => { + // Byte-for-byte with the pre-#3826 boot: the default never consulted a + // DatasourceConnectPolicy (that gate exists for optional/external + // datasources). A multi-tenant host's deny-all must not brick every boot. + const kernel = await assemble({ + connectPolicy: { canConnect: () => ({ allow: false, reason: 'egress blocked' }) }, + }); + try { + await expect(kernel.bootstrap()).resolves.not.toThrow(); + const engine = kernel.getService('data'); + expect(engine.getDefaultDriverName()).toBeDefined(); + } finally { + 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: { + manifest: { id: 'com.test.reserved', name: 'Reserved', version: '1.0.0' }, + objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }], + datasources: [{ name: 'default', driver: 'memory', config: {} }], + }, + }); + const err = await kernel.bootstrap().then( + () => { throw new Error('bootstrap() resolved but should have thrown'); }, + (e: unknown) => e as Error, + ); + expect(err.message).toMatch(/reserved for the host's primary datasource/); + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + }, BOOT_TIMEOUT); +}); diff --git a/packages/runtime/src/default-datasource-plugin.ts b/packages/runtime/src/default-datasource-plugin.ts new file mode 100644 index 0000000000..4eb8c1155f --- /dev/null +++ b/packages/runtime/src/default-datasource-plugin.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { + DatasourceConnectionService, + createDefaultDatasourceDriverFactory, + type ConnectableDatasource, +} from '@objectstack/service-datasource'; + +/** + * DefaultDatasourcePlugin — the `default` datasource as a DECLARATION + * (ADR-0062 D1, #3826). + * + * Before this plugin, the standalone stack pre-built the default driver and + * smuggled it into the engine as a `driver.*` kernel service (`DriverPlugin`), + * so its connect — and the "what if it cannot connect" decision — lived in + * `ObjectQLEngine.init()`, a second implementation of the policy + * `DatasourceConnectionService` already owns for every declared datasource. + * #3741 → #3758 showed what two copies of that decision cost: a fix to one + * missed the other for three months. + * + * Now the host hands this plugin a datasource *definition* (URL→config + * translation and `mkdir` stay host concerns in `standalone-stack`), and the + * plugin connects it through the SAME `DatasourceConnectionService` code path + * as declared/runtime datasources: same driver factory, same failure verdict + * (fail-fast — the definition is `bootCritical`; `OS_ALLOW_DRIVER_CONNECT_FAILURE` + * to degrade), same retained state. + * + * **Ordering — phase, not list position.** The kernel resolves BOTH init and + * start order from the plugin dependency graph, so registration order proves + * nothing (a serve boot hoists `ObjectQLPlugin` ahead of anything a service + * plugin depends on — exactly how the first cut of this plugin ended up + * starting after boot schema-sync and shipping a server with no tables). The + * connect therefore happens in **`init()`** — Phase 1 completes before ANY + * `start()` runs, so the driver exists before `ObjectQLPlugin.start()`'s + * `ql.init()` + schema sync in every topology — and this plugin declares a + * hard dependency on ObjectQL so ITS `init()` (which registers the `'data'` + * engine) runs first. `ObjectQLEngine.init()` then re-connects the already- + * connected driver: every open-core driver's `connect()` is idempotent, and + * the re-connect is exactly the boot verification #3741 wants kept. + * + * The connect itself always uses a locally-instantiated + * `DatasourceConnectionService` (the shared `'datasource-connection'` service + * is registered by the datasource-admin plugin's init, whose order relative to + * this one is undetermined). `start()` then replays the registration through + * the SHARED service when present — the `asDefault` idempotency guard turns it + * into `already-registered` — so the `default` verdict lands in the retained + * state the admin list reads and Setup → Datasources shows the primary DB's + * real status (#3827). + * + * `DriverPlugin` remains the escape hatch for tests and pre-built/proxy + * 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: string; + config?: Record; + label?: string; +} + +export class DefaultDatasourcePlugin implements Plugin { + name = 'com.objectstack.runtime.default-datasource'; + version = '1.0.0'; + /** + * Hard dependency: ObjectQL's init() must register the `'data'` engine + * before this plugin's init() connects the default driver into it. Any boot + * composing this plugin composes ObjectQL (the standalone stack always + * does); the kernel fails loudly on a genuinely missing dependency. + */ + dependencies = ['com.objectstack.engine.objectql']; + + private readonly def: DefaultDatasourceDefinition; + private readonly dev?: boolean; + + constructor(def: DefaultDatasourceDefinition, opts: { dev?: boolean } = {}) { + this.def = def; + this.dev = opts.dev; + } + + private record(): ConnectableDatasource { + return { + name: 'default', + label: this.def.label ?? 'Default', + driver: this.def.driver, + config: this.def.config ?? {}, + origin: 'code', + bootCritical: true, + }; + } + + init = async (ctx: PluginContext) => { + const connection = new DatasourceConnectionService({ + factory: () => createDefaultDatasourceDriverFactory({ dev: this.dev }), + engine: () => { + try { + return ctx.getService('data'); + } catch { + return undefined; + } + }, + // Structural cast: the kernel logger's `warn(msg, meta?)` satisfies the + // service's minimal Logger shape; the nominal types come from different + // packages. + logger: ctx.logger as unknown as ConstructorParameters[0]['logger'], + }); + + // Throws on failure (bootCritical ⇒ fail-fast per ADR-0062 D5), aborting + // bootstrap exactly like the engine-level guard did — same escape hatch, + // same DEGRADED BOOT banner when the operator overrides. + const result = await connection.connect(this.record(), { + asDefault: true, + context: { origin: 'code', trigger: 'declared-auto' }, + }); + if (result.status === 'skipped-no-infra') { + // A kernel with no data engine has nothing to connect a driver INTO — + // not an error (metadata-only hosts exist), but worth a trace. + ctx.logger.debug('[DefaultDatasourcePlugin] no engine — default datasource left unconnected'); + return; + } + ctx.logger.info('[DefaultDatasourcePlugin] default datasource ready', { + driver: this.def.driver, + status: result.status, + }); + + // Keep the `driver.` kernel-service surface DriverPlugin used to + // provide: `os migrate` locates the SQL driver through it + // (`schema-migrate.ts` SQL_DRIVER_SERVICES), and serve's storage detection + // reads it too. ObjectQLPlugin's discovery loop will see this service and + // call registerDriver again — the engine's skip-if-present guard makes + // that a no-op for the same driver name. + try { + const engine = ctx.getService('data'); + const driverName = engine?.getDefaultDriverName?.(); + const driver = driverName ? engine?.getDriverByName?.(driverName) : undefined; + if (driver) { + ctx.registerService(`driver.${driverName}`, driver); + } + } catch (e) { + ctx.logger.debug('[DefaultDatasourcePlugin] driver.* service registration skipped', { error: e }); + } + }; + + start = async (ctx: PluginContext) => { + // Replay through the SHARED connection service (registered by the + // datasource-admin plugin's init — present by Phase 2 when that plugin is + // in the stack). The asDefault guard makes this `already-registered` when + // init() connected, and a REAL retry when the operator booted degraded via + // OS_ALLOW_DRIVER_CONNECT_FAILURE — either way the verdict lands in the + // retained state the admin list reads (#3827). + // Note the default deliberately does NOT go through the host's + // `DatasourceConnectPolicy`: the init()-time connect uses a policy-free + // local instance (byte-for-byte with the pre-#3826 boot, where the default + // never consulted a policy — that gate exists for OPTIONAL, typically + // external, datasources), and on the replay below the `asDefault` + // idempotency guard resolves before the policy gate. Only a degraded boot + // (init failed under OS_ALLOW_DRIVER_CONNECT_FAILURE) reaches the shared + // policy here — a denial then is worth a loud warning, not a brick: the + // operator already chose to boot without the primary DB. + try { + const shared = ctx.getService('datasource-connection'); + if (typeof shared?.connect === 'function') { + const result = await shared.connect(this.record(), { + asDefault: true, + context: { origin: 'code', trigger: 'declared-auto' }, + }); + if (result.status === 'skipped-policy') { + ctx.logger.warn( + `[DefaultDatasourcePlugin] the datasource connect policy denied the degraded-boot retry of the ` + + `boot-critical 'default' datasource${result.reason ? ` (${result.reason})` : ''} — fix the host's ` + + `DatasourceConnectPolicy; it should never gate the primary datasource.`, + ); + } + } + } catch { + // No shared service (lite kernel) — the init()-time connect stands alone. + } + + // Metadata visibility — now, and AGAIN on `kernel:ready` because the + // MetadataPlugin's artifact load rebuilds the registry and drops rows + // registered before it. Idempotent: registerInMemory is last-write-wins. + await this.registerVisibility(ctx); + ctx.hook('kernel:ready', async () => { + await this.registerVisibility(ctx); + }); + }; + + /** + * Two registries, two consumers: + * - `registerInMemory('datasource', …)` feeds the datasource-admin list + * (`metadata.list('datasource')`), so Setup → Datasources shows the + * primary DB — and, via the retained connect verdict, its REAL status + * (#3827). Stamped `origin:'code'` → read-only in the admin UI. + * - `addDatasource(…)` keeps parity with what DriverPlugin.start() used to + * register for legacy `getDatasources()` consumers. + */ + private async registerVisibility(ctx: PluginContext): Promise { + try { + const metadata = ctx.getService('metadata'); + if (typeof metadata?.registerInMemory === 'function') { + metadata.registerInMemory('datasource', 'default', { + name: 'default', + label: this.def.label ?? 'Default', + driver: this.def.driver, + origin: 'code', + }); + } + if (typeof metadata?.addDatasource === 'function') { + const existing = typeof metadata.getDatasources === 'function' ? metadata.getDatasources() : []; + const hasDefault = Array.isArray(existing) && existing.some((ds: any) => ds?.name === 'default'); + if (!hasDefault) { + await metadata.addDatasource({ name: 'default', driver: this.def.driver }); + } + } + } catch (e) { + ctx.logger.debug('[DefaultDatasourcePlugin] metadata service unavailable — default not listed', { error: e }); + } + } +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6e455a44e3..4893862cb0 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -17,6 +17,8 @@ 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 { 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/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts index ed38342618..b72635d15a 100644 --- a/packages/runtime/src/standalone-stack.test.ts +++ b/packages/runtime/src/standalone-stack.test.ts @@ -121,61 +121,83 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 }, BOOT_TIMEOUT); }); -// ADR-0062 (Variant A) — the standalone `default` driver's CONSTRUCTION is -// unified: the user-facing kinds (memory / better-sqlite3 / postgres / mongodb) -// go through the SAME `createDefaultDatasourceDriverFactory` used for -// declared/runtime datasources, so there is one "driver kind → instance" path. -// The pure-JS WASM sqlite driver stays bespoke (it's the standalone-specific -// CI-safe default, not a user-creatable datasource type — its only construction -// site). These tests extract the constructed driver from the stack's -// `DriverPlugin` and exercise it directly (connect → syncSchema → create → -// find), proving the right driver is built per kind AND that it actually -// connects + does I/O — without booting the full kernel (the MetadataPlugin -// file-artifact boot doesn't play well with vitest's module runner, and isn't -// what this test is about). postgres/mongodb need a live server, so they're -// covered by the factory's own usage + the runtime-admin path. -describe('createStandaloneStack — default driver construction unified via the factory (ADR-0062)', () => { +// ADR-0062 D1 (#3826) — the standalone `default` datasource is a DECLARATION. +// The stack no longer constructs a driver: it translates the database URL into +// a `{ driver, config }` definition carried by `DefaultDatasourcePlugin`, which +// connects it at boot through the shared `DatasourceConnectionService`. These +// tests verify (a) the URL → definition translation per kind, and (b) that the +// definition round-trips through the SAME shared factory the plugin uses at +// boot (connect → syncSchema → create → find) — without booting the full +// kernel (the MetadataPlugin file-artifact boot doesn't play well with +// vitest's module runner; the full-kernel path is covered by +// `default-datasource-plugin.test.ts`). postgres/mongodb need a live server, +// so they're covered by the factory's own usage + the runtime-admin path. +describe('createStandaloneStack — default datasource declared, built via the shared factory (ADR-0062 D1)', () => { let dir: string; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'os-standalone-driver-')); }); afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); const NOTE = { name: 'note', fields: { id: { type: 'text' }, title: { type: 'text' } } }; - async function driverRoundTrip( + function defaultDefOf(stack: Awaited>): { + plugin: any; + def: { driver: string; config?: Record }; + } { + const plugin = stack.plugins.find((p: any) => p?.name === 'com.objectstack.runtime.default-datasource'); + expect(plugin, 'stack must carry the DefaultDatasourcePlugin').toBeDefined(); + return { plugin, def: (plugin as any).def }; + } + + async function definitionRoundTrip( cfg: Parameters[0], - ): Promise<{ kind: string | undefined; titles: string[] }> { + ): Promise<{ driverId: string; kind: string | undefined; titles: string[] }> { const stack = await createStandaloneStack(cfg); - const plugin = stack.plugins.find( - (p: any) => p?.driver && typeof p.driver.find === 'function', - ) as { driver: any } | undefined; - const driver = plugin!.driver; + const { def } = defaultDefOf(stack); + const { createDefaultDatasourceDriverFactory } = await import('@objectstack/service-datasource'); + const handle: any = await createDefaultDatasourceDriverFactory({ dev: false }).create({ + driver: def.driver, + config: def.config ?? {}, + }); + const driver = handle.driver ?? handle; const kind = driver?.constructor?.name as string | undefined; await driver.connect?.(); try { await driver.syncSchema('note', NOTE); await driver.create('note', { id: 'n1', title: 'hello-driver' }); const rows = (await driver.find('note', {})) as Array<{ title?: string }>; - return { kind, titles: rows.map((r) => r.title as string) }; + return { driverId: def.driver, kind, titles: rows.map((r) => r.title as string) }; } finally { try { await driver.disconnect?.(); } catch { /* noop */ } } } - it('memory:// → InMemoryDriver (factory), connects + round-trips', async () => { - const r = await driverRoundTrip({ databaseUrl: 'memory://default-driver' }); + it('memory:// → declares driver "memory"; factory builds InMemoryDriver that round-trips', async () => { + const r = await definitionRoundTrip({ databaseUrl: 'memory://default-driver' }); + expect(r.driverId).toBe('memory'); expect(r.kind).toMatch(/InMemoryDriver$/); expect(r.titles).toContain('hello-driver'); }, BOOT_TIMEOUT); - it('file: → better-sqlite3 SqlDriver (factory), connects + round-trips', async () => { - const r = await driverRoundTrip({ databaseUrl: `file:${join(dir, 'better.db')}` }); + it('file: → declares driver "sqlite" with the file path; factory builds SqlDriver that round-trips', async () => { + const r = await definitionRoundTrip({ databaseUrl: `file:${join(dir, 'better.db')}` }); + expect(r.driverId).toBe('sqlite'); expect(r.kind).toMatch(/SqlDriver$/); expect(r.titles).toContain('hello-driver'); }, BOOT_TIMEOUT); - it('databaseDriver:sqlite-wasm → SqliteWasmDriver (bespoke), connects + round-trips', async () => { - const r = await driverRoundTrip({ databaseDriver: 'sqlite-wasm', databaseUrl: `file:${join(dir, 'wasm.db')}` }); + it('databaseDriver:sqlite-wasm → declares driver "sqlite-wasm"; factory builds SqliteWasmDriver that round-trips', async () => { + const r = await definitionRoundTrip({ databaseDriver: 'sqlite-wasm', databaseUrl: `file:${join(dir, 'wasm.db')}` }); + expect(r.driverId).toBe('sqlite-wasm'); expect(r.kind).toMatch(/SqliteWasmDriver$/); expect(r.titles).toContain('hello-driver'); }, BOOT_TIMEOUT); + + it('the DefaultDatasourcePlugin precedes ObjectQLPlugin (schema sync needs the driver)', async () => { + const stack = await createStandaloneStack({ databaseUrl: 'memory://default-order' }); + const names = stack.plugins.map((p: any) => String(p?.name ?? p?.constructor?.name ?? '')); + const dsIdx = names.indexOf('com.objectstack.runtime.default-datasource'); + const qlIdx = names.findIndex((n: string) => /objectql/i.test(n)); + expect(dsIdx).toBeGreaterThanOrEqual(0); + expect(qlIdx).toBeGreaterThan(dsIdx); + }, BOOT_TIMEOUT); }); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index f97cb43882..149bbba7b1 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -4,9 +4,14 @@ * Standalone (runtime-only) stack factory. * * Builds the minimal plugin list for embedding ObjectStack in another - * framework: ObjectQL + Driver + Metadata, plus AppPlugin if a compiled - * artifact is available. No authentication, no Studio data, no control - * plane — REST routes are served unauthenticated. + * framework: the declared `default` datasource + Metadata + ObjectQL, plus + * AppPlugin if a compiled artifact is available. No authentication, no Studio + * data, no control plane — REST routes are served unauthenticated. + * + * The `default` datasource is a DECLARATION (ADR-0062 D1, #3826): this stack + * translates the database URL into a datasource definition and + * `DefaultDatasourcePlugin` connects it at boot through the same + * `DatasourceConnectionService` used for declared/runtime datasources. * * Auto-detects the appropriate driver from the database URL scheme: * - `memory://*` → InMemoryDriver @@ -136,7 +141,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro const { ObjectQLPlugin } = await import('@objectstack/objectql'); const { MetadataPlugin } = await import('@objectstack/metadata'); - const { DriverPlugin } = await import('./driver-plugin.js'); + const { DefaultDatasourcePlugin } = await import('./default-datasource-plugin.js'); const { AppPlugin } = await import('./app-plugin.js'); const cwd = process.cwd(); @@ -165,84 +170,60 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro ?? (process.env.OS_DATABASE_DRIVER?.trim() as ResolvedDriverKind | undefined); const dbDriver: ResolvedDriverKind = explicitDriver ?? detectDriverFromUrl(dbUrl); - // Build the default driver. The user-facing kinds (memory / postgres / - // better-sqlite3 / mongodb) go through the SHARED datasource driver factory - // (ADR-0062) — the SAME `create({driver,config})` used for declared/runtime - // datasources — so adding a dialect or changing connection/pool defaults - // happens in ONE place instead of being mirrored here by hand. This stack - // still owns what's standalone-specific: URL→config translation, filesystem - // prep (`mkdir`), and `DriverPlugin` registration (pre-engine — unchanged). - let driverPlugin: any; - if (dbDriver === 'sqlite-wasm') { - // The pure-JS WASM sqlite driver is the standalone-specific, CI-safe - // (no native build) default — NOT a user-creatable runtime datasource - // type, so it isn't part of the shared factory's surface. Construct it - // directly here (this is its only construction site, so no duplication). - const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm' as any); + // Translate the database URL into the `default` datasource DEFINITION + // (ADR-0062 D1, #3826). The stack no longer builds a driver: the definition + // is handed to `DefaultDatasourcePlugin`, which connects it at boot through + // the SAME `DatasourceConnectionService` path (shared factory, shared + // failure verdict incl. `OS_ALLOW_DRIVER_CONNECT_FAILURE`, retained status + // for Setup → Datasources) as every declared/runtime datasource — every + // kind including the CI-safe `sqlite-wasm` default, which the factory now + // builds too. This stack still owns what's standalone-specific: URL→config + // translation and filesystem prep (`mkdir`). + // + // #2229: `dev` arms the factory's native-better-sqlite3 → wasm → in-memory + // step-down. Falls back to NODE_ENV when the caller did not pass it. + const factoryDev = cfg.dev ?? process.env.NODE_ENV === 'development'; + let driverId: string; + let driverConfig: Record; + if (dbDriver === 'memory') { + driverId = 'memory'; + driverConfig = {}; + } else if (dbDriver === 'postgres') { + // Factory applies the pg pool default ({ min: 0, max: 5 }) internally. + driverId = 'postgres'; + driverConfig = { url: dbUrl }; + } else if (dbDriver === 'mongodb') { + // A missing @objectstack/driver-mongodb peer dep surfaces at boot via + // the connection service's fail-fast (the factory's "not installed" + // message rides inside it) — add the peer dependency to fix. + driverId = 'mongodb'; + driverConfig = { url: dbUrl }; + } else if (dbDriver === 'sqlite-wasm') { + driverId = 'sqlite-wasm'; const filename = dbUrl .replace(/^wasm-sqlite:(\/\/)?/i, '') .replace(/^file:(\/\/)?/i, '') || ':memory:'; if (filename !== ':memory:') { mkdirSync(resolvePath(filename, '..'), { recursive: true }); } - driverPlugin = new DriverPlugin( - new SqliteWasmDriver({ - filename, - persist: filename !== ':memory:' ? 'on-write' : undefined, - }) as any, - ); + driverConfig = { filename }; } else { - const { createDefaultDatasourceDriverFactory } = await import('@objectstack/service-datasource'); - // #2229: in dev, a native better-sqlite3 ABI/load failure steps down to - // wasm SQLite (real SQL + on-disk persistence) then in-memory; in prod it - // fails loudly. Falls back to NODE_ENV when the caller did not pass `dev`. - const factoryDev = cfg.dev ?? process.env.NODE_ENV === 'development'; - let driverId: string; - let driverConfig: Record; - if (dbDriver === 'memory') { - driverId = 'memory'; - driverConfig = {}; - } else if (dbDriver === 'postgres') { - // Factory applies the pg pool default ({ min: 0, max: 5 }) internally. - driverId = 'postgres'; - driverConfig = { url: dbUrl }; - } else if (dbDriver === 'mongodb') { - driverId = 'mongodb'; - driverConfig = { url: dbUrl }; - } else { - // sqlite (better-sqlite3) - driverId = 'sqlite'; - const filename = dbUrl.replace(/^file:(\/\/)?/, ''); - if (!filename || /^[a-z][a-z0-9+.-]*:\/\//i.test(filename)) { - throw new Error( - `[StandaloneStack] sqlite driver was selected but the URL does not look like a file path: "${dbUrl}". ` + - `Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` - ); - } - mkdirSync(resolvePath(filename, '..'), { recursive: true }); - driverConfig = { filename }; + // sqlite (better-sqlite3) + driverId = 'sqlite'; + const filename = dbUrl.replace(/^file:(\/\/)?/, ''); + if (!filename || /^[a-z][a-z0-9+.-]*:\/\//i.test(filename)) { + throw new Error( + `[StandaloneStack] sqlite driver was selected but the URL does not look like a file path: "${dbUrl}". ` + + `Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` + ); } - - let driverHandle: { driver?: unknown } | unknown; - try { - driverHandle = await createDefaultDatasourceDriverFactory({ dev: factoryDev }).create({ driver: driverId, config: driverConfig }); - } catch (err: any) { - // Preserve the actionable hint the bespoke path gave for the optional - // mongo peer dep (the factory throws a generic "not installed" message). - if (dbDriver === 'mongodb') { - throw new Error( - `[StandaloneStack] mongodb URL detected but @objectstack/driver-mongodb is not installed. ` + - `Add it as a dependency or pass an explicit driverPlugin. (${err?.message ?? err})` - ); - } - throw err; - } - // The factory returns a handle whose `.driver` is the concrete engine - // driver (falls back to the handle itself for structural drivers). - driverPlugin = new DriverPlugin( - ((driverHandle as { driver?: unknown })?.driver ?? driverHandle) as any, - ); + mkdirSync(resolvePath(filename, '..'), { recursive: true }); + driverConfig = { filename }; } + const defaultDatasourcePlugin = new DefaultDatasourcePlugin( + { driver: driverId, config: driverConfig }, + { dev: factoryDev }, + ); const artifactBundle = await loadArtifactBundle(artifactPath, { tag: '[StandaloneStack]', @@ -250,7 +231,10 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro }); const plugins: any[] = [ - driverPlugin, + // MUST precede ObjectQLPlugin: its start() connects the default driver + // through the datasource connection service, and ObjectQLPlugin.start() + // runs boot schema-sync right after — the driver has to exist by then. + defaultDatasourcePlugin, new MetadataPlugin({ // Source-file scanner OFF — declarative metadata is loaded // from the compiled artifact, not from yaml/json files on diff --git a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts new file mode 100644 index 0000000000..c7bdf55d7b --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #3826: `sqlite-wasm` joined the shared driver factory so the standalone +// stack's declared `default` datasource — whose CI-safe default is the wasm +// driver — builds through the same `create({driver,config})` as every other +// kind. These are the first direct tests of the factory's id → driver mapping. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; + +const factory = () => createDefaultDatasourceDriverFactory({ dev: false }); + +describe('createDefaultDatasourceDriverFactory — driver id surface', () => { + it('supports the sqlite-wasm id and its alias', () => { + expect(factory().supports('sqlite-wasm')).toBe(true); + expect(factory().supports('wasm-sqlite')).toBe(true); + expect(factory().supports('SQLITE-WASM')).toBe(true); // ids are case-insensitive + }); + + it('still rejects unknown ids', () => { + expect(factory().supports('not-a-real-driver')).toBe(false); + }); +}); + +describe('createDefaultDatasourceDriverFactory — sqlite-wasm construction (#3826)', () => { + let dir: string; + beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'os-factory-wasm-')); }); + afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + const NOTE = { name: 'note', fields: { id: { type: 'text' }, title: { type: 'text' } } }; + + async function roundTrip(config: Record) { + const handle: any = await factory().create({ driver: 'sqlite-wasm', config }); + const driver = handle.driver ?? handle; + expect(driver?.constructor?.name).toMatch(/SqliteWasmDriver$/); + await driver.connect(); + try { + await driver.syncSchema('note', NOTE); + await driver.create('note', { id: 'n1', title: 'wasm-hello' }); + const rows = (await driver.find('note', {})) as Array<{ title?: string }>; + return rows.map((r) => r.title); + } finally { + try { await driver.disconnect(); } catch { /* noop */ } + } + } + + it('builds a file-backed SqliteWasmDriver that connects and round-trips', async () => { + expect(await roundTrip({ filename: join(dir, 'w.db') })).toContain('wasm-hello'); + }, 30_000); + + it('defaults to :memory: when no filename is configured', async () => { + expect(await roundTrip({})).toContain('wasm-hello'); + }, 30_000); +}); diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index 426eaf2ec3..bf80f3a348 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -317,6 +317,14 @@ export class DatasourceAdminService implements IDatasourceAdminService { `Invalid datasource name '${name ?? ''}': must match /^[a-z_][a-z0-9_]*$/.`, ); } + // Host-owned reserved name (#3826): the runtime declares and connects + // `default` itself (DefaultDatasourcePlugin); a runtime-created pool under + // that name would shadow the primary datasource's row and confuse routing. + if (name === 'default') { + throw new Error( + `Datasource name 'default' is reserved for the host's primary datasource. Pick another name.`, + ); + } } private toRecord(input: DatasourceDraft): StoredDatasource { diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index 17a3a04107..040f97927b 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -59,6 +59,15 @@ export interface ConnectableDatasource { * unrouted datasource. Defaults to false. */ autoConnect?: boolean; + /** + * The HOST declares the platform cannot run without this datasource, so a + * boot-time (`declared-auto`) connect failure is fatal regardless of object + * bindings (#3826). Set by the runtime for the standalone `default` — + * everything without an explicit binding routes TO it, so "no fallback path" + * holds by construction even though nothing binds to it *explicitly*. Not + * part of the app-facing datasource spec: host-composition plumbing only. + */ + bootCritical?: boolean; } /** Minimal object shape used for the D2 routing gate + post-connect schema sync. */ @@ -85,6 +94,13 @@ export interface ConnectionEngineLike { * `onEnable` bridge does manually). */ syncObjectSchema?: (objectName: string) => Promise; + /** + * Name of the engine's DEFAULT driver, when one is set. Used by the + * `asDefault` connect path's idempotency guard (#3826): the default driver + * keeps its natural name, so `getDriverByName('default')` can never detect a + * prior registration. + */ + getDefaultDriverName?: () => string | undefined; /** * Tell the engine a datasource was *declared* but is not connected, and why * (framework#3828). Without this the engine cannot distinguish "the app @@ -307,7 +323,21 @@ export class DatasourceConnectionService { */ async connect( record: ConnectableDatasource, - opts: { objects?: readonly string[]; context?: DatasourceConnectContext } = {}, + opts: { + objects?: readonly string[]; + context?: DatasourceConnectContext; + /** + * Register the built driver as the engine's DEFAULT driver, under the + * driver's own name (#3826). Set by the runtime for the standalone + * `default` datasource. Two deliberate differences from a normal connect: + * the driver keeps its natural name (`sql`/`memory`/…) instead of being + * stamped with the datasource name — routing to `default` goes through + * the engine's default-driver fallback, never `drivers.get('default')`, + * and renaming would change every name-keyed log/lookup the pre-#3826 + * boot produced — and `registerDriver` is called with `isDefault: true`. + */ + asDefault?: boolean; + } = {}, ): Promise { try { const result = await this.attemptConnect(record, opts); @@ -353,15 +383,21 @@ export class DatasourceConnectionService { private async attemptConnect( record: ConnectableDatasource, - opts: { objects?: readonly string[]; context?: DatasourceConnectContext } = {}, + opts: { objects?: readonly string[]; context?: DatasourceConnectContext; asDefault?: boolean } = {}, ): Promise { const name = record.name; const engine = this.cfg.engine(); const factory = this.cfg.factory(); // Idempotent: never double-register (e.g. a legacy `onEnable` bridge already - // registered this driver — the D8 escape hatch). - if (engine?.getDriverByName?.(name)) { + // registered this driver — the D8 escape hatch). The default driver keeps + // its natural name, so its guard is "does the engine already have a + // default", not a name lookup. + if (opts.asDefault) { + if (engine?.getDefaultDriverName?.()) { + return { name, status: 'already-registered' }; + } + } else if (engine?.getDriverByName?.(name)) { return { name, status: 'already-registered' }; } @@ -441,13 +477,19 @@ export class DatasourceConnectionService { // The engine routes a datasource to a driver by `driver.name === `. // Prefer the factory's underlying engine driver (the `driver` escape hatch); // fall back to the handle. Stamp the name so routing resolves to this pool. + // The DEFAULT driver (#3826) keeps its natural name instead: routing to + // `default` goes through the engine's default-driver fallback, never + // `drivers.get('default')`, and the natural name keeps logs/lookups + // byte-for-byte with the pre-#3826 boot. const engineDriver = (handle.driver ?? handle) as { name?: string }; - try { - engineDriver.name = name; - } catch { - /* frozen driver — registration may still work if name already matches */ + if (!opts.asDefault) { + try { + engineDriver.name = name; + } catch { + /* frozen driver — registration may still work if name already matches */ + } } - engine.registerDriver(engineDriver); + engine.registerDriver(engineDriver, opts.asDefault === true); engine.registerDatasourceDef?.({ name, schemaMode: record.schemaMode, @@ -493,7 +535,7 @@ export class DatasourceConnectionService { * Apply the D5 connect-failure policy (also covers D3 credential failures). * * A boot-time (`declared-auto`) connect failure is **fatal** when the - * datasource has no fallback path, which is true in two cases: + * datasource has no fallback path, which is true in three cases: * * - **(a)** it is `external` with `validation.onMismatch:'fail'` — the author * asked for a hard stop explicitly; or @@ -503,7 +545,11 @@ export class DatasourceConnectionService { * (framework#3758). Leaving this at a warning produced the worst possible * shape: a server that boots clean, serves most of the app, and fails every * read/write of the bound objects with an error that reads nothing like - * "the analytics database is unreachable". + * "the analytics database is unreachable"; or + * - **(c)** the host marked it {@link ConnectableDatasource.bootCritical} — + * the standalone `default` (#3826): everything WITHOUT a binding routes to + * it, so "no fallback" holds by construction, mirroring the engine-level + * guard (#3741) this connect path replaces. * * Anything else degrades with a warning: `autoConnect:true` means "connect it * if you can" with nothing declaring a dependency on it, and runtime-admin @@ -541,6 +587,12 @@ export class DatasourceConnectionService { `and have no fallback datasource — every read/write of them would fail`, ); } + if (record.bootCritical === true) { + causes.push( + `declared boot-critical by the host — it is the platform's primary datasource and ` + + `every object without an explicit binding routes to it`, + ); + } } if (causes.length === 0) { this.logger?.warn?.(`${msg} — degrading (datasource left unconnected)`); diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 99be7e45fb..6d7e11acaa 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -13,9 +13,15 @@ * stack auto-detects: * - `postgres` / `pg` / `postgresql` → `@objectstack/driver-sql` (client `pg`) * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3) + * - `sqlite-wasm` / `wasm-sqlite` → `@objectstack/driver-sqlite-wasm` (pure-JS) * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep) * - `memory` / `inmemory` → `@objectstack/driver-memory` * + * `sqlite-wasm` joined for ADR-0062 D1 (#3826): the standalone stack's + * `default` datasource is a *declared definition* connected through the shared + * `DatasourceConnectionService`, and its CI-safe wasm default must therefore be + * a driver id this factory can build — the last bespoke construction site. + * * Anything else returns `supports() === false`, so the admin service degrades * gracefully (testConnection → `{ ok: false }`, create skips hot pool reg). * @@ -29,7 +35,7 @@ import type { DatasourceDriverHandle, } from './contracts/index.js'; -type ResolvedKind = 'postgres' | 'sqlite' | 'mongodb' | 'memory'; +type ResolvedKind = 'postgres' | 'sqlite' | 'sqlite-wasm' | 'mongodb' | 'memory'; const DRIVER_ID_ALIASES: Record = { postgres: 'postgres', @@ -38,6 +44,8 @@ const DRIVER_ID_ALIASES: Record = { sqlite: 'sqlite', sqlite3: 'sqlite', 'better-sqlite3': 'sqlite', + 'sqlite-wasm': 'sqlite-wasm', + 'wasm-sqlite': 'sqlite-wasm', mongodb: 'mongodb', mongo: 'mongodb', memory: 'memory', @@ -167,6 +175,30 @@ export function createDefaultDatasourceDriverFactory( return toHandle(resolved.driver, () => sqlServerVersion(resolved.driver, 'sqlite')); } + if (kind === 'sqlite-wasm') { + // Pure-JS WASM sqlite: real SQL with no native build. File-backed + // databases persist on write; `:memory:` stays ephemeral. Mirrors the + // construction the standalone stack used before its `default` became a + // declared datasource (#3826). Lazy + caught like mongodb: the wasm + // driver rides as an optional install for published consumers. + let SqliteWasmDriver: any; + try { + ({ SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm' as any)); + } catch (err: any) { + throw new Error( + `sqlite-wasm driver requested but @objectstack/driver-sqlite-wasm is not installed (${err?.message ?? err}).`, + ); + } + const conn = buildSqlConnection(spec, 'better-sqlite3') as { filename?: string }; + const filename = conn.filename ?? ':memory:'; + const driver = new SqliteWasmDriver({ + filename, + persist: filename !== ':memory:' ? 'on-write' : undefined, + ...(schemaMode ? { schemaMode } : {}), + }); + return toHandle(driver, () => sqlServerVersion(driver, 'sqlite')); + } + if (kind === 'mongodb') { let MongoDBDriver: any; try {