diff --git a/.changeset/cli-turso-loud-error.md b/.changeset/cli-turso-loud-error.md new file mode 100644 index 0000000000..7cc6d0bcdd --- /dev/null +++ b/.changeset/cli-turso-loud-error.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): fail loudly when `turso`/libSQL is selected in the open-core CLI (#3276 follow-up) + +Same "declared ≠ enforced" class as the `memory` fix: the CLI advertised `turso` +(`--database-driver turso`, `OS_DATABASE_DRIVER=turso`, `libsql://` URLs) but the +driver dispatch had no `turso` branch, so it silently fell through to the SQLite +default and ignored the requested engine. + +`turso`/libSQL ships in the cloud / enterprise distribution +(`@objectstack/driver-turso`, composed by the cloud runtime's own kernel factory — +open-core's standalone stack deliberately does not consume it). Rather than pull an +EE driver into open-core, `createStorageDriver` now throws a typed +`UnsupportedDriverError` for `turso`/`libsql`, and `serve.ts` surfaces it as a +fatal, actionable boot error (naming the cloud/EE package and the open-core +alternatives) instead of silently degrading to SQLite. `libsql://` / `*.turso.*` +URLs stay classified as `turso` so they hit the same loud failure. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 73f90a8f5d..ce770599bb 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 } from '../utils/storage-driver.js'; +import { resolveDriverType, createStorageDriver, UnsupportedDriverError } from '../utils/storage-driver.js'; import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; import { PLATFORM_CAPABILITY_TOKENS, canonicalizePlatformCapability } from '@objectstack/spec/kernel'; import { resolveObjectStackHome } from '@objectstack/runtime'; @@ -937,6 +937,14 @@ export default class Serve extends Command { } } } catch (e: any) { + // "declared ≠ enforced" guard (#3276-class): a driver that is + // RECOGNIZED but the open-core CLI cannot construct — currently + // `turso`/libSQL, a cloud/EE driver — must fail LOUDLY, never silently + // fall through to the SQLite default and ignore the selected engine. + // Re-throw so run()'s fatal handler restores output, prints the + // actionable message, and exits 1 (in dev AND prod). All OTHER driver + // construction errors keep the prior best-effort silent behavior. + if (e instanceof UnsupportedDriverError) throw e; // silent } } diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index 0d3e20c098..91f297d041 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -6,6 +6,7 @@ import { inferDriverTypeFromUrl, resolveDriverType, createStorageDriver, + UnsupportedDriverError, } from './storage-driver.js'; describe('inferDriverTypeFromUrl', () => { @@ -131,3 +132,42 @@ describe('createStorageDriver', () => { expect(await createStorageDriver('nonsense', { isDev: false })).toBeNull(); }); }); + +describe('createStorageDriver: turso / libSQL is recognized but fails loud', () => { + // turso is a cloud/EE driver (@objectstack/driver-turso) the open-core CLI + // cannot construct. Selecting it must THROW a typed error — NOT fall through + // to the SQLite default. Remove the `turso` branch in storage-driver.ts and + // these go red: in dev it resolves to a SqlDriver, in prod it returns null — + // both silently ignoring the requested turso engine (the reported bug). + it('throws UnsupportedDriverError for `turso` in DEV and PROD', async () => { + await expect(createStorageDriver('turso', { isDev: true })).rejects.toBeInstanceOf(UnsupportedDriverError); + await expect(createStorageDriver('turso', { isDev: false })).rejects.toBeInstanceOf(UnsupportedDriverError); + }); + + it('throws for the `libsql` alias too', async () => { + await expect(createStorageDriver('libsql', { isDev: true })).rejects.toBeInstanceOf(UnsupportedDriverError); + }); + + // The message must be actionable: name the cloud/EE package and the open-core + // alternatives, so an operator knows exactly how to proceed. + it('carries an actionable message (cloud/EE package + open-core alternatives)', async () => { + await expect(createStorageDriver('turso', { isDev: false })).rejects.toThrow(/@objectstack\/driver-turso/); + await expect(createStorageDriver('turso', { isDev: false })).rejects.toThrow(/cloud|enterprise/i); + const err = await createStorageDriver('turso', { isDev: false }).catch((e) => e); + expect(err).toBeInstanceOf(UnsupportedDriverError); + expect((err as UnsupportedDriverError).driverType).toBe('turso'); + }); + + // A `libsql://` / Turso URL routes to the same loud failure — it is NOT left + // unrecognized (which would silently fall through to SQLite). + it('routes libsql:// and *.turso.* URLs to the turso failure, never SQLite', async () => { + expect(resolveDriverType(undefined, 'libsql://my-db.turso.io')).toBe('turso'); + expect(resolveDriverType(undefined, 'https://my-db.turso.io')).toBe('turso'); + await expect( + createStorageDriver(resolveDriverType(undefined, 'libsql://my-db.turso.io'), { + databaseUrl: 'libsql://my-db.turso.io', + isDev: true, + }), + ).rejects.toBeInstanceOf(UnsupportedDriverError); + }); +}); diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index 48bbc2d02e..25f3debb5d 100644 --- a/packages/cli/src/utils/storage-driver.ts +++ b/packages/cli/src/utils/storage-driver.ts @@ -32,6 +32,28 @@ /** Engines the shared sqlite step-down (`resolveSqliteDriver`) can produce. */ export type SqliteFamilyEngine = 'better-sqlite3' | 'sqlite-wasm' | 'memory'; +/** + * Thrown by {@link createStorageDriver} when a driver kind is *recognized* but the + * open-core CLI cannot construct it — currently `turso`/libSQL, which ships in the + * ObjectStack cloud / enterprise distribution (`@objectstack/driver-turso`, an + * extension of SqlDriver over `@libsql/client`), composed by the cloud runtime's + * own kernel factory, not by open-core's auto driver-registration. + * + * The whole point of surfacing this as a *typed* error is so `serve.ts` can fail + * LOUDLY (fatal) instead of letting the selection fall through to the SQLite + * default. That silent fall-through is the same "declared ≠ enforced" bug as + * #3276 (the CLI advertised `memory`/`turso` but had no dispatch branch, so both + * silently became SQLite-in-memory). + */ +export class UnsupportedDriverError extends Error { + readonly driverType: string; + constructor(driverType: string, message: string) { + super(message); + this.name = 'UnsupportedDriverError'; + this.driverType = driverType; + } +} + /** * Infer a canonical driver kind from an `OS_DATABASE_URL` scheme. * Returns `''` when the URL is absent or its scheme is unrecognized (the caller @@ -43,6 +65,11 @@ export function inferDriverTypeFromUrl(url: string | undefined): string { if (/^mongodb(\+srv)?:\/\//i.test(u)) return 'mongodb'; if (/^postgres(ql)?:\/\//i.test(u)) return 'postgres'; if (/^mysql2?:\/\//i.test(u)) return 'mysql'; + // libSQL / Turso URLs are DELIBERATELY still classified as `turso` (not left + // unrecognized). Open-core can't construct that driver, but classifying it + // lets createStorageDriver fail LOUDLY with a clear cloud/EE message — if we + // returned '' here instead, a `libsql://` URL would fall through to the SQLite + // default and silently ignore the remote connection (the very bug we're fixing). if (/^libsql:\/\//i.test(u)) return 'turso'; if (/^https?:\/\//i.test(u) && /\.turso\./i.test(u)) return 'turso'; if (/^wasm-sqlite:\/\//i.test(u) || /\.wasm\.db$/i.test(u)) return 'sqlite-wasm'; @@ -101,6 +128,10 @@ export interface StorageDriverResolution { * nothing matches and we are NOT in dev (production with an unknown/absent * driver registers no driver, matching the prior inline behavior). * + * Throws {@link UnsupportedDriverError} for `turso`/libSQL — a cloud/EE driver the + * open-core CLI cannot construct. serve.ts surfaces that as a fatal, actionable + * boot error so the selection never silently degrades to SQLite. + * * @see {@link resolveDriverType} */ export async function createStorageDriver( @@ -207,6 +238,27 @@ export async function createStorageDriver( }; } + // turso / libSQL: recognized but NOT constructible by the open-core CLI. The + // driver (`@objectstack/driver-turso`) ships in the cloud / enterprise + // distribution and is composed by the cloud runtime's own kernel factory — + // runtime/standalone-stack.ts explicitly stopped consuming its auth token, and + // its config schema lives in the cloud package so it never pollutes open-core + // `@objectstack/spec`. Fail LOUDLY here rather than let the selection fall + // through to the SQLite default (the reported "declared ≠ enforced" bug): + // serve.ts turns this typed error into a fatal, actionable boot message. + if (driverType === 'turso' || driverType === 'libsql') { + throw new UnsupportedDriverError( + 'turso', + 'The `turso`/libSQL driver ships with the ObjectStack cloud / enterprise ' + + 'distribution (@objectstack/driver-turso), not the open-core CLI. To use ' + + "it, register it explicitly in your stack config (a datasource with driver: " + + "'turso' and config { url, authToken }, with @objectstack/driver-turso " + + 'installed), or run under the cloud distribution. Otherwise select an ' + + 'open-core driver via OS_DATABASE_DRIVER / OS_DATABASE_URL: ' + + 'sqlite | postgres | mysql | mongodb | memory.', + ); + } + // #3276: explicit in-memory (mingo) driver. Honored in dev AND production — an // operator asking for `memory` gets the mingo InMemoryDriver (ephemeral, not // real SQL), never the SQLite `:memory:` default. This is the branch whose