Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/serve-fallback-declared-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
"@objectstack/cli": minor
"@objectstack/service-datasource": minor
---

feat(cli): the serve storage fallback declares the default datasource instead of constructing a driver (#3826)

The last open-core second site of "definition → live driver": when a host
`objectstack.config.ts` supplies objects but no driver plugin, `serve` built a
driver via `createStorageDriver` and registered it through `DriverPlugin`, with
its connect and failure verdict landing in `ObjectQLEngine.init()` — the same
split #3869 removed from the standalone stack.

- **`createStorageDriver` is gone.** `resolveStorageDefinition` translates the
driver kind + URL into `{ driverId, config }` (a pure host-side translation,
like `standalone-stack`'s), and serve hands it to the runtime's
`DefaultDatasourcePlugin` — same shared factory, same `bootCritical` failure
verdict, same `OS_ALLOW_DRIVER_CONNECT_FAILURE` escape hatch, and the primary
DB's real status in Setup → Datasources.
- **`mysql`/`mysql2` joined the shared driver factory** (SqlDriver over
`mysql2`; DSN or discrete fields, secret as password).
- **Host-composition passthroughs**: the factory honours `config.autoMigrate`
(the #2186 dev loosen-only self-heal, for the SQL kinds) and `config.persist`
(the CLI's wasm `on-disconnect` mode). Connection builders ignore both keys.
- **`turso`/libSQL fails loud at resolution**, same typed
`UnsupportedDriverError`, same actionable message — nothing is constructed to
fail later.
- **The `telemetry` sibling datasource stays a pre-built `DriverPlugin`** — the
documented escape hatch for named auxiliary drivers. Its provisioning now
gates on the statically-known sqlite file path; the old coupling to the
primary's *resolved* engine is replaced by the telemetry provision's own
step-down check, which already guarded the ABI-broken case.

Verified end to end: a host-composed config (plugins + objects, no driver)
boots through the declared fallback with the same banner labels; the artifact
path (`dev:crm --fresh`) is table-for-table unchanged (71 tables, zero
`no such table`).

**Migration.** None for CLI users — same URLs, same env vars, same banner. The
removed `createStorageDriver` was CLI-internal; `resolveDriverType`,
`inferDriverTypeFromUrl` and `UnsupportedDriverError` are unchanged.
4 changes: 3 additions & 1 deletion docs/adr/0062-external-datasource-runtime.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,9 @@ Introduce a single service that, given a datasource definition, builds a driver
>
> **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.
>
> **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.
> **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.

### D2 — Connect is opt-in-safe: existing managed apps are byte-for-byte unchanged

Expand Down
35 changes: 24 additions & 11 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import chalk from 'chalk';
import { bundleRequire } from 'bundle-require';
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
import { resolveDriverType, createStorageDriver, UnsupportedDriverError } from '../utils/storage-driver.js';
import { resolveDriverType, resolveStorageDefinition, UnsupportedDriverError } from '../utils/storage-driver.js';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
import { missingProviderMessage } from '../utils/capability-preflight.js';
Expand DownExpand Up@@ -887,14 +887,22 @@ export default class Serve extends Command {
const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl);

try {
const { DriverPlugin } = await import('@objectstack/runtime');
const resolution = await createStorageDriver(driverType, {
databaseUrl,
isDev,
warn: (m) => console.warn(chalk.yellow(m)),
});
// #3826: the fallback no longer constructs a driver — it declares
// the `default` datasource and lets the runtime's
// DefaultDatasourcePlugin connect it at boot through the shared
// DatasourceConnectionService (one connect path, one failure
// verdict incl. OS_ALLOW_DRIVER_CONNECT_FAILURE, retained status in
// Setup → Datasources). URL→config translation stays host-side in
// resolveStorageDefinition. The dev sqlite step-down (#2229) and
// the loosen-only self-heal (#2186, via config.autoMigrate) now run
// inside the factory at connect.
const { DriverPlugin, DefaultDatasourcePlugin } = await import('@objectstack/runtime');
const resolution = resolveStorageDefinition(driverType, { databaseUrl, isDev });
if (resolution) {
await kernel.use(new DriverPlugin(resolution.driver as any));
await kernel.use(new DefaultDatasourcePlugin(
{ driver: resolution.driverId, config: resolution.config },
{ dev: isDev },
));
trackPlugin(resolution.trackName);
resolvedDriverLabel = resolution.label;
resolvedDatabaseUrl = resolution.displayUrl;
Expand All@@ -906,9 +914,14 @@ export default class Serve extends Command {
// for file-backed primaries; `OS_TELEMETRY_DB=0` opts out,
// `OS_TELEMETRY_DB=<path>` opts in anywhere (incl. serve). Gated on
// an explicit SQLite primary (`sqliteFilePath`, unset for the mingo
// memory driver AND the dev-default `:memory:` store) whose resolved
// engine is real SQLite — never mingo in-memory.
if (resolution.sqliteFilePath && resolution.engine !== 'memory') {
// memory driver AND the dev-default `:memory:` store). The old
// `resolution.engine !== 'memory'` refinement is unknowable now
// that the primary connects later (#3826); the telemetry
// provision's own `telemetry.engine !== 'memory'` check below
// still guards the ABI-broken step-down case. The telemetry
// driver itself stays a pre-built DriverPlugin — the documented
// escape hatch for named auxiliary drivers.
if (resolution.sqliteFilePath) {
const { resolveTelemetryDbPath } = await import('../utils/telemetry-datasource.js');
const telemetryPath = resolveTelemetryDbPath({ primaryPath: resolution.sqliteFilePath, env: process.env, dev: isDev });
if (telemetryPath) {
Expand Down
Loading
Loading