From 5723e1205c3f73624af85da3e7421a87a73ef495 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 12:46:04 +0000 Subject: [PATCH] feat(runtime): standalone stack dispatches libsql:// through the optional Turso driver (#5820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detectDriverFromUrl()` refused every libSQL URL as an unsupported scheme while `resolveDatabaseUrl()` listed `TURSO_DATABASE_URL` among its URL sources — read it in, cannot dispatch it out. Since #5602 wired `libsql://` for the CLI's `os serve` / `os start`, the same `OS_DATABASE_URL=libsql://…` booted under `os start` and hard-failed under `os migrate`, which boots through this stack. - `libsql://` and `http(s)://*.turso.*` resolve to the `turso` kind — the same two spellings `inferDriverTypeFromUrl` classifies on the CLI side. - The driver comes from the OPTIONAL `@objectstack/driver-turso` package, loaded lazily in `turso-driver-factory.ts` and injected through the host driver-factory seam `DefaultDatasourcePlugin` documents for exactly this case, so connect / bootCritical verdict / escape hatch stay shared. - Package missing ⇒ loud `MissingDriverPackageError` carrying the install command as data; no SQLite fallback (#3276). - `databaseAuthToken` is consumed now (OS_DATABASE_AUTH_TOKEN, then the vendor's TURSO_AUTH_TOKEN) instead of being declared and ignored. - The file docstring's "ships separately in the ObjectStack Cloud distribution" claim expired with #4645; replaced with the facts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW --- .../standalone-stack-libsql-dispatch.md | 17 + content/docs/data-modeling/drivers.mdx | 9 +- .../src/standalone-stack.libsql.test.ts | 316 ++++++++++++++++++ packages/runtime/src/standalone-stack.ts | 106 +++++- packages/runtime/src/turso-driver-factory.ts | 188 +++++++++++ packages/runtime/tsup.config.ts | 4 + 6 files changed, 625 insertions(+), 15 deletions(-) create mode 100644 .changeset/standalone-stack-libsql-dispatch.md create mode 100644 packages/runtime/src/standalone-stack.libsql.test.ts create mode 100644 packages/runtime/src/turso-driver-factory.ts diff --git a/.changeset/standalone-stack-libsql-dispatch.md b/.changeset/standalone-stack-libsql-dispatch.md new file mode 100644 index 0000000000..0928dca725 --- /dev/null +++ b/.changeset/standalone-stack-libsql-dispatch.md @@ -0,0 +1,17 @@ +--- +'@objectstack/runtime': minor +--- + +**`createStandaloneStack` now dispatches `libsql://` / Turso URLs** instead of refusing them as an unsupported scheme (#5820). + +`detectDriverFromUrl()` recognised `memory://`, `postgres://`, `mongodb://` and `file:`, and threw on everything else — while `resolveDatabaseUrl()` listed `TURSO_DATABASE_URL` as one of its URL sources. A host that set it got the URL read in and then rejected on the way out. Since the CLI wired `libsql://` for `os serve` / `os start` (#5602), the same `OS_DATABASE_URL=libsql://…` booted under `os start` and failed under `os migrate`, which comes through this stack. + +What changed: + +- `libsql://…` and `http(s)://*.turso.…` resolve to the `turso` driver kind — the same two spellings the CLI classifies, kept identical on purpose. +- `databaseDriver: 'turso'` (and `OS_DATABASE_DRIVER=turso`) is accepted by the config schema. +- The driver comes from `@objectstack/driver-turso`, an **optional** install: it drags `@libsql/client` and its native bindings, so it is not a dependency of `@objectstack/runtime`. It is loaded lazily, only for a selection that asks for libSQL, and injected through the driver-factory seam `DefaultDatasourcePlugin` already exposes — so the connect path, the `bootCritical` fail-fast verdict, `OS_ALLOW_DRIVER_CONNECT_FAILURE` and the retained Setup → Datasources status are identical to every other kind. +- Package missing? The boot fails **loudly**, carrying the exact install command (`npm install @objectstack/driver-turso`) as data as well as prose. There is no SQLite fallback: a silent step-down would open an empty local database while your libSQL data stays untouched, and every write — including an `os migrate` DDL — would land in the wrong place (#3276). +- `databaseAuthToken` is no longer declared-and-ignored: the `turso` kind reads it, falling back to `OS_DATABASE_AUTH_TOKEN` and then the vendor's own `TURSO_AUTH_TOKEN` — the same precedence `os serve` uses. + +Unknown schemes still throw, and the message now lists `libsql://` among the supported ones. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index be6116f717..f80e20083a 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -54,8 +54,11 @@ Drivers can be selected in two ways: **Turso / libSQL needs one extra install.** `libsql://` and `*.turso.io` URLs *are* -inferred, but `@objectstack/driver-turso` is an **optional peer dependency** of the -CLI — it pulls in `@libsql/client`, so it is not part of a default install: +inferred — by the CLI (`os serve` / `os start` / `os dev`) and by the standalone +runtime stack the one-shot commands and embedders boot through (`os migrate`, +`createStandaloneStack`) alike. But `@objectstack/driver-turso` is an **optional** +install — it pulls in `@libsql/client` plus native bindings, so it is not part of a +default install: ```bash npm install @objectstack/driver-turso @@ -76,7 +79,7 @@ libSQL data stayed untouched. Pass the token with `--database-auth-token` | **SQLite** | `@objectstack/driver-sql` (peer: `better-sqlite3`) | `SqlDriver` | `sqlite` \| `sql` | | **SQLite (WASM)** | `@objectstack/driver-sqlite-wasm` | `SqliteWasmDriver` | `sqlite-wasm` \| `wasm-sqlite` \| `wasm` | | **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` (single-tenant only — see [below](#multi-tenancy-not-supported)) | -| **Turso / libSQL** | `@objectstack/driver-turso` (optional peer of the CLI) | `TursoDriver` | `turso` \| `libsql` | +| **Turso / libSQL** | `@objectstack/driver-turso` (optional install — see the callout above) | `TursoDriver` | `turso` \| `libsql` | | **Memory** | `@objectstack/driver-memory` | `InMemoryDriver` | `memory` | > All SQL flavours (PostgreSQL / MySQL / SQLite) are served by a single diff --git a/packages/runtime/src/standalone-stack.libsql.test.ts b/packages/runtime/src/standalone-stack.libsql.test.ts new file mode 100644 index 0000000000..75c2c7e35e --- /dev/null +++ b/packages/runtime/src/standalone-stack.libsql.test.ts @@ -0,0 +1,316 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5820 — the standalone stack dispatches `libsql://`, on the same terms the CLI +// does (#5602 / PR #5819). +// +// Before this, `detectDriverFromUrl()` refused every libSQL URL with +// `Unsupported database URL scheme`, while `resolveDatabaseUrl()` listed +// `TURSO_DATABASE_URL` as a URL SOURCE — read it in, cannot dispatch it out. The +// operator-visible split was `os start` (CLI path, boots) versus `os migrate` +// (this path, `Unsupported database URL scheme`) on one and the same +// `OS_DATABASE_URL=libsql://…`. +// +// What the pins below assert, in order: +// 1. detection — libSQL URLs resolve to the `turso` kind, existing schemes are +// untouched, and a genuinely unknown scheme still throws; +// 2. the optional package, both ways — present ⇒ a TursoDriver is built from +// the definition; absent ⇒ a loud failure carrying the install command, +// with no SQLite anywhere in the failure (#3276); +// 3. the whole boot — `createStandaloneStack({ databaseUrl: 'libsql://…' })` +// no longer produces the "unsupported scheme" refusal. +// +// No test here touches a real Turso endpoint: the package is substituted through +// `importDriverPackage`, which is what makes the "package missing" arm testable +// even in a workspace where the package happens to be installed. + +import { describe, it, expect, afterEach } from 'vitest'; +import { + resolveStandaloneDatabase, + resolveDatabaseAuthToken, + createStandaloneStack, +} from './standalone-stack.js'; +import { + loadTursoDriverFactory, + MissingDriverPackageError, + isTursoDriverId, + TURSO_DRIVER_INSTALL_COMMAND, + TURSO_DRIVER_PACKAGE, +} from './turso-driver-factory.js'; + +/** Env keys these tests write; restored after every case. */ +const ENV_KEYS = [ + 'OS_DATABASE_URL', + 'DATABASE_URL', + 'TURSO_DATABASE_URL', + 'OS_DATABASE_AUTH_TOKEN', + 'TURSO_AUTH_TOKEN', + 'OS_DATABASE_DRIVER', + 'OS_HOME', +] as const; +const ORIGINAL_ENV: Record = Object.fromEntries( + ENV_KEYS.map((k) => [k, process.env[k]]), +); + +afterEach(() => { + for (const key of ENV_KEYS) { + const original = ORIGINAL_ENV[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + } +}); + +function clearUrlEnv(): void { + for (const key of ENV_KEYS) delete process.env[key]; +} + +describe('detectDriverFromUrl — libSQL/Turso URLs resolve to the `turso` kind (#5820)', () => { + it('libsql:// resolves to turso, keeps the URL, and probes no sqlite file', () => { + const r = resolveStandaloneDatabase({ databaseUrl: 'libsql://my-db.turso.io' }); + expect(r.driver).toBe('turso'); + expect(r.url).toBe('libsql://my-db.turso.io'); + // The occupancy probe (`os migrate`, #3917) must have nothing to say about a + // remote endpoint — and must NOT read the URL as a file path. + expect(r.sqliteFile).toBeNull(); + }); + + it('an https Turso endpoint resolves to turso — the exact spelling the CLI classifies', () => { + expect(resolveStandaloneDatabase({ databaseUrl: 'https://my-db.turso.io' }).driver).toBe('turso'); + expect(resolveStandaloneDatabase({ databaseUrl: 'http://my-db.turso.io' }).driver).toBe('turso'); + }); + + // The reason this issue exists: the env var was already a URL SOURCE here. + it('TURSO_DATABASE_URL now dispatches as well as resolves (the read-in/refuse-out split is gone)', () => { + clearUrlEnv(); + process.env.TURSO_DATABASE_URL = 'libsql://from-env.turso.io'; + const r = resolveStandaloneDatabase(); + expect(r.url).toBe('libsql://from-env.turso.io'); + expect(r.driver).toBe('turso'); + }); + + it('an explicit databaseDriver: "turso" is accepted by the config schema', () => { + const r = resolveStandaloneDatabase({ databaseDriver: 'turso', databaseUrl: 'libsql://explicit.turso.io' }); + expect(r.driver).toBe('turso'); + expect(r.sqliteFile).toBeNull(); + }); + + it('OS_DATABASE_DRIVER=turso selects the same kind', () => { + clearUrlEnv(); + process.env.OS_DATABASE_DRIVER = 'turso'; + process.env.OS_DATABASE_URL = 'libsql://env-driver.turso.io'; + expect(resolveStandaloneDatabase().driver).toBe('turso'); + }); +}); + +describe('detectDriverFromUrl — the existing schemes are untouched (positive controls)', () => { + it.each([ + ['memory://anything', 'memory'], + ['postgres://user:pw@localhost:5432/db', 'postgres'], + ['postgresql://user:pw@localhost:5432/db', 'postgres'], + ['pg://user:pw@localhost:5432/db', 'postgres'], + ['mongodb://localhost:27017/objectstack', 'mongodb'], + ['mongodb+srv://cluster.example.com/db', 'mongodb'], + ['wasm-sqlite:///tmp/x.db', 'sqlite-wasm'], + ['file:/tmp/os-5820/plain.db', 'sqlite'], + ['/tmp/os-5820/bare-path.db', 'sqlite'], + ])('%s → %s', (url, kind) => { + expect(resolveStandaloneDatabase({ databaseUrl: url }).driver).toBe(kind); + }); + + it('an unknown scheme still throws, and the message now lists libsql', () => { + expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' })) + .toThrow(/Unsupported database URL scheme/); + expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' })) + .toThrow(/libsql:\/\//); + }); + + // The turso arm is narrow on purpose: a plain https URL is not a database. + it('a non-Turso https URL is still unsupported', () => { + expect(() => resolveStandaloneDatabase({ databaseUrl: 'https://example.com/db' })) + .toThrow(/Unsupported database URL scheme/); + }); +}); + +describe('resolveDatabaseAuthToken — the same precedence `os serve` reads', () => { + it('explicit config wins over both env vars', () => { + process.env.OS_DATABASE_AUTH_TOKEN = 'from-os-env'; + process.env.TURSO_AUTH_TOKEN = 'from-vendor-env'; + expect(resolveDatabaseAuthToken({ databaseAuthToken: 'from-config' })).toBe('from-config'); + }); + + it('OS_DATABASE_AUTH_TOKEN (where --database-auth-token lands) wins over TURSO_AUTH_TOKEN', () => { + clearUrlEnv(); + process.env.OS_DATABASE_AUTH_TOKEN = 'from-os-env'; + process.env.TURSO_AUTH_TOKEN = 'from-vendor-env'; + expect(resolveDatabaseAuthToken()).toBe('from-os-env'); + }); + + it('falls back to the vendor TURSO_AUTH_TOKEN', () => { + clearUrlEnv(); + process.env.TURSO_AUTH_TOKEN = 'from-vendor-env'; + expect(resolveDatabaseAuthToken()).toBe('from-vendor-env'); + }); + + it('blank values are absent, not empty credentials', () => { + clearUrlEnv(); + process.env.OS_DATABASE_AUTH_TOKEN = ' '; + expect(resolveDatabaseAuthToken()).toBeUndefined(); + process.env.TURSO_AUTH_TOKEN = 'fallback'; + expect(resolveDatabaseAuthToken()).toBe('fallback'); + }); + + it('no source at all → undefined (so no authToken key reaches the driver config)', () => { + clearUrlEnv(); + expect(resolveDatabaseAuthToken()).toBeUndefined(); + }); +}); + +describe('loadTursoDriverFactory — the OPTIONAL driver package, both ways (#5820)', () => { + /** A stand-in for the real `@objectstack/driver-turso` module. */ + function stubTursoModule() { + const built: Array> = []; + class FakeTursoDriver { + connected = false; + disconnected = false; + constructor(public readonly config: Record) { + built.push(config); + } + async connect() { this.connected = true; } + async disconnect() { this.disconnected = true; } + async checkHealth() { return true; } + } + return { built, module: { TursoDriver: FakeTursoDriver } }; + } + + it('claims the turso/libsql driver ids and nothing else', async () => { + const { module } = stubTursoModule(); + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module }); + expect(factory.supports('turso')).toBe(true); + expect(factory.supports('libsql')).toBe(true); + expect(factory.supports('LibSQL')).toBe(true); + expect(factory.supports('sqlite')).toBe(false); + expect(factory.supports('memory')).toBe(false); + expect(isTursoDriverId('turso')).toBe(true); + expect(isTursoDriverId('sqlite')).toBe(false); + }); + + // ① Package present: the definition this stack builds reaches a TursoDriver + // construction with the url and the auth token. No network — the substitute + // module proves the DISPATCH, which is this package's half of the contract. + it('builds a TursoDriver from the stack-shaped definition (url + authToken)', async () => { + const { built, module } = stubTursoModule(); + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module }); + + const handle = await factory.create({ + name: 'default', + driver: 'turso', + config: { url: 'libsql://my-db.turso.io', authToken: 'jwt-token' }, + }); + + expect(built).toEqual([{ url: 'libsql://my-db.turso.io', authToken: 'jwt-token' }]); + expect(handle.driver).toBeInstanceOf(module.TursoDriver); + // Ownership left at the default `'factory'`: the instance was built for THIS + // connect, so kernel teardown disconnects it. + expect(handle.ownership).toBeUndefined(); + await handle.connect!(); + expect(await handle.checkHealth!()).toBe(true); + await handle.disconnect!(); + const driver = handle.driver as { connected: boolean; disconnected: boolean }; + expect(driver.connected).toBe(true); + expect(driver.disconnected).toBe(true); + }); + + it('omits authToken entirely when none was resolved (no empty-string credential)', async () => { + const { built, module } = stubTursoModule(); + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module }); + await factory.create({ name: 'default', driver: 'turso', config: { url: 'file:./data/local.db' } }); + expect(built).toEqual([{ url: 'file:./data/local.db' }]); + }); + + // ② Package absent: LOUD failure carrying the exact install command, and no + // fallback of any kind. + it('fails loudly with the exact install command when the package is missing', async () => { + const err = await loadTursoDriverFactory({ + importDriverPackage: async () => { throw new Error("Cannot find module '@objectstack/driver-turso'"); }, + }).then(() => null, (e: unknown) => e); + + expect(err).toBeInstanceOf(MissingDriverPackageError); + const missing = err as MissingDriverPackageError; + expect(missing.driverType).toBe('turso'); + expect(missing.packageName).toBe(TURSO_DRIVER_PACKAGE); + expect(missing.installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); + expect(missing.installCommand).toBe('npm install @objectstack/driver-turso'); + // The message states the command, the consequence, and the deliberate refusal. + expect(missing.message).toContain('npm install @objectstack/driver-turso'); + expect(missing.message).toMatch(/OPTIONAL package/); + expect(missing.message).toMatch(/refuses rather than falling back to SQLite/i); + expect(missing.message).toMatch(/os migrate/); + // The underlying resolution error is kept: an operator debugging a broken + // install needs it, and swallowing it is how "not installed" hides + // "installed but crashed on import". + expect(missing.message).toContain("Cannot find module '@objectstack/driver-turso'"); + }); + + it('offers NO silent SQLite fallback when the package is missing', async () => { + const attempt = await loadTursoDriverFactory({ + importDriverPackage: async () => { throw new Error('boom'); }, + }).then((f) => ({ ok: true as const, f }), (e: unknown) => ({ ok: false as const, e })); + + expect(attempt.ok).toBe(false); + expect((attempt as { e: Error }).e).toBeInstanceOf(MissingDriverPackageError); + expect((attempt as { e: Error }).e.message).not.toMatch(/falling back to sqlite instead|using sqlite/i); + // …and the kind the stack resolved is still turso: nothing rewrites it to + // sqlite on the way out. + expect(resolveStandaloneDatabase({ databaseUrl: 'libsql://my-db.turso.io' }).driver).toBe('turso'); + }); + + it('rejects a resolvable module that exports no TursoDriver', async () => { + const err = await loadTursoDriverFactory({ + importDriverPackage: async () => ({ notTheDriver: true }), + }).then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(MissingDriverPackageError); + expect((err as Error).message).toMatch(/exports no TursoDriver/); + expect((err as MissingDriverPackageError).installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); + }); + + it('accepts a CJS-shaped module whose driver hangs off `default`', async () => { + const { module } = stubTursoModule(); + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => ({ default: module }) }); + const handle = await factory.create({ name: 'default', driver: 'turso', config: { url: 'libsql://x.turso.io' } }); + expect(handle.driver).toBeInstanceOf(module.TursoDriver); + }); + + it('refuses to build a driver from a config with no url', async () => { + const { module } = stubTursoModule(); + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module }); + expect(() => factory.create({ name: 'default', driver: 'turso', config: {} })) + .toThrow(/needs a libSQL url/); + }); +}); + +// ③ The whole boot, on the URL the issue is about. `@objectstack/driver-turso` +// is deliberately NOT a dependency of `@objectstack/runtime` — that is what +// "optional" means here — so in this workspace the boot takes the missing-package +// arm. What matters either way is the FIRST assertion: the refusal is no longer +// "unsupported scheme". (Should the package ever become a dependency of this one, +// this case turns red and names exactly why in this comment.) +describe('createStandaloneStack — a libsql:// boot is dispatched, not refused as unknown (#5820)', () => { + it('fails with the install command instead of "Unsupported database URL scheme"', async () => { + clearUrlEnv(); + const err = await createStandaloneStack({ databaseUrl: 'libsql://my-db.turso.io' }) + .then(() => null, (e: unknown) => e); + + expect(err).not.toBeNull(); + expect(String((err as Error).message)).not.toMatch(/Unsupported database URL scheme/); + expect(err).toBeInstanceOf(MissingDriverPackageError); + expect((err as MissingDriverPackageError).installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); + }, 60_000); + + // The control on the same path: an unknown scheme is still refused as unknown, + // so the new arm did not turn the throw into a catch-all. `os migrate`'s e2e + // exit-code test pins this message from the CLI end. + it('still refuses a genuinely unknown scheme', async () => { + clearUrlEnv(); + await expect(createStandaloneStack({ databaseUrl: 'wat://nope' })) + .rejects.toThrow(/Unsupported database URL scheme/); + }, 60_000); +}); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 16a2e95c3a..b658e0462e 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -16,17 +16,26 @@ * Auto-detects the appropriate driver from the database URL scheme: * - `memory://*` → InMemoryDriver * - `postgres[ql]://`, `pg://` → SqlDriver (pg) - * - `mongodb[+srv]://` → MongoDBDriver (peer-dep `@objectstack/driver-mongodb`) + * - `mongodb[+srv]://` → MongoDBDriver (optional `@objectstack/driver-mongodb`) + * - `libsql://`, `http(s)://*.turso.*` → TursoDriver (optional `@objectstack/driver-turso`) * - `file:` / no scheme → SqlDriver (better-sqlite3) * * Unknown URL schemes throw — we never silently fall back to sqlite, since * that historically created bogus directories on disk (e.g. `mongodb:/`) * when an unsupported URL was treated as a file path. * - * NOTE: `libsql://` / Turso support is provided by `@objectstack/driver-turso`, - * which ships separately in the ObjectStack Cloud distribution. The open-core - * runtime no longer dispatches `libsql://` URLs; cloud builds register the - * Turso driver via their own stack composition (`cloud-stack.ts`). + * NOTE: `libsql://` / Turso support comes from `@objectstack/driver-turso`, + * which lives in THIS repository (`packages/drivers/driver-turso`) since #4645 + * but is an OPTIONAL install: it drags `@libsql/client` plus native bindings, + * which a default install of a stack that never talks to libSQL should not pay + * for. So this stack loads it lazily, through the driver-factory seam + * `DefaultDatasourcePlugin` exposes for exactly this case + * (`turso-driver-factory.ts`), and fails LOUDLY with the install command when + * the package is absent — never a silent step-down to SQLite (#3276). This is + * the same shape and the same ruling the CLI's `os serve`/`os start` path landed + * under (#5602 / PR #5819); before #5820 the two disagreed, and one + * `OS_DATABASE_URL=libsql://…` booted under `os start` while `os migrate` — which + * comes through here — refused it as an unsupported scheme. */ import { resolve as resolvePath } from 'node:path'; @@ -34,7 +43,9 @@ import { mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { z } from 'zod'; import { readEnvWithDeprecation, stampSearchPinyinEnabled } from '@objectstack/types'; +import type { IDatasourceDriverFactory } from '@objectstack/service-datasource'; import { loadArtifactBundle, isHttpUrl } from './load-artifact-bundle.js'; +import { loadTursoDriverFactory } from './turso-driver-factory.js'; /** * Resolve the ObjectStack home directory used to store cwd-independent @@ -60,8 +71,14 @@ export function resolveObjectStackHome(): string { export const StandaloneStackConfigSchema = z.object({ databaseUrl: z.string().optional(), + /** + * libSQL/Turso JWT, read ONLY by the `turso` kind — every other kind carries + * its credentials inside the URL. Falls back to `OS_DATABASE_AUTH_TOKEN`, + * then to the vendor's own `TURSO_AUTH_TOKEN` (the same pair `os serve` + * reads, and the same pair `--database-auth-token` forwards into). + */ databaseAuthToken: z.string().optional(), - databaseDriver: z.enum(['sqlite', 'sqlite-wasm', 'memory', 'postgres', 'mongodb']).optional(), + databaseDriver: z.enum(['sqlite', 'sqlite-wasm', 'memory', 'postgres', 'mongodb', 'turso']).optional(), environmentId: z.string().optional(), artifactPath: z.string().optional(), /** @@ -142,12 +159,22 @@ export interface StandaloneStackResult { positions?: any[]; } -type ResolvedDriverKind = 'memory' | 'postgres' | 'mongodb' | 'sqlite' | 'sqlite-wasm'; +type ResolvedDriverKind = 'memory' | 'postgres' | 'mongodb' | 'turso' | 'sqlite' | 'sqlite-wasm'; function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { if (/^memory:\/\//i.test(dbUrl)) return 'memory'; if (/^(postgres(ql)?|pg):\/\//i.test(dbUrl)) return 'postgres'; if (/^mongodb(\+srv)?:\/\//i.test(dbUrl)) return 'mongodb'; + // libSQL / Turso (#5820). The same two spellings the CLI classifies as + // `turso` (`utils/storage-driver.ts` `inferDriverTypeFromUrl`, #5602) — kept + // identical on purpose: this function and that one answer the same question + // for the same `OS_DATABASE_URL`, and until #5820 they disagreed, so + // `os start` booted a libSQL URL that `os migrate` refused. The driver is + // built from the OPTIONAL `@objectstack/driver-turso` package; when it is + // missing the boot fails loudly with the install command instead of + // stepping down to SQLite (#3276). + if (/^libsql:\/\//i.test(dbUrl)) return 'turso'; + if (/^https?:\/\//i.test(dbUrl) && /\.turso\./i.test(dbUrl)) return 'turso'; if (/^wasm-sqlite:\/\//i.test(dbUrl)) return 'sqlite-wasm'; if (/\.wasm\.db$/i.test(dbUrl)) return 'sqlite-wasm'; if (/^file:/i.test(dbUrl)) return 'sqlite'; @@ -155,7 +182,8 @@ function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(dbUrl)) return 'sqlite'; throw new Error( `[StandaloneStack] Unsupported database URL scheme: ${dbUrl}. ` + - `Supported schemes: memory://, postgres://, pg://, mongodb://, mongodb+srv://, file:` + `Supported schemes: memory://, postgres://, pg://, mongodb://, mongodb+srv://, ` + + `libsql:// (optional @objectstack/driver-turso), file:` ); } @@ -196,6 +224,11 @@ export interface ResolvedStandaloneDatabase { * `OS_DATABASE_URL`/`DATABASE_URL` → `TURSO_DATABASE_URL` → `OS_HOME` → * project root → user home), factored out so a caller can answer "which file * am I about to open?" first. Pure: reads env, touches no filesystem. + * + * The `TURSO_DATABASE_URL` source only started meaning something in #5820: the + * URL was read here and then rejected by `detectDriverFromUrl` as an unsupported + * scheme, so a host that set it got a hard failure rather than a libSQL + * connection. Reading a source you cannot dispatch is worse than not reading it. */ export function resolveStandaloneDatabase(config?: StandaloneStackConfig): ResolvedStandaloneDatabase { const cfg = StandaloneStackConfigSchema.parse(config ?? {}); @@ -223,6 +256,31 @@ function resolveDatabaseUrl(cfg: z.output): : `file:${resolvePath(resolveObjectStackHome(), 'data/standalone.db')}`)); } +/** + * The libSQL/Turso auth token for this boot, or `undefined` when none was given. + * + * Read ONLY by the `turso` kind: every other kind carries its credentials inside + * the URL. Precedence mirrors `os serve` exactly (`commands/serve.ts`) so the + * same environment produces the same credential on both paths — explicit config, + * then `OS_DATABASE_AUTH_TOKEN` (which is where `--database-auth-token` lands), + * then the vendor's own `TURSO_AUTH_TOKEN` (a documented third-party exception + * to the `OS_` prefix rule, AGENTS.md Prime Directive #9). + * + * Empty/blank values are treated as absent — `authToken: ''` is not a credential, + * and passing one to the driver would fail differently than not passing it. + * + * Exported (module-level, not from the package barrel) so this precedence has a + * pin of its own: the boot itself cannot expose it, because a libSQL boot in a + * workspace without the optional driver package fails before any definition is + * observable. + */ +export function resolveDatabaseAuthToken(cfg: StandaloneStackConfig = {}): string | undefined { + const token = cfg.databaseAuthToken?.trim() + || process.env.OS_DATABASE_AUTH_TOKEN?.trim() + || process.env.TURSO_AUTH_TOKEN?.trim(); + return token ? token : undefined; +} + export async function createStandaloneStack(config?: StandaloneStackConfig): Promise { const cfg = StandaloneStackConfigSchema.parse(config ?? {}); @@ -242,9 +300,10 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro ? artifactPathInput : resolvePath(cwd, artifactPathInput)); - // `databaseAuthToken` / `OS_DATABASE_AUTH_TOKEN` are preserved in the - // config schema for cloud builds that compose their own turso driver; - // the standalone (open-core) runtime no longer consumes them directly. + // `databaseAuthToken` / `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are + // consumed by the `turso` kind below (#5820). They used to be declared here + // and read by nobody — the same "reads it in, cannot dispatch it out" split + // `TURSO_DATABASE_URL` had. const { url: dbUrl, driver: dbDriver } = resolveStandaloneDatabase(cfg); // Translate the database URL into the `default` datasource DEFINITION @@ -262,6 +321,13 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro const factoryDev = cfg.dev ?? process.env.NODE_ENV === 'development'; let driverId: string; let driverConfig: Record; + /** + * Host-injected driver factory — set ONLY for `turso`, whose driver the + * shared open-core factory cannot build (the package is an optional + * install). `DefaultDatasourcePlugin` documents this seam for exactly that + * case; everything else about the connect stays shared. + */ + let hostFactory: IDatasourceDriverFactory | undefined; if (dbDriver === 'memory') { driverId = 'memory'; driverConfig = {}; @@ -275,6 +341,22 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // message rides inside it) — add the peer dependency to fix. driverId = 'mongodb'; driverConfig = { url: dbUrl }; + } else if (dbDriver === 'turso') { + // libSQL / Turso (#5820). Unlike every other kind, the driver comes from + // an OPTIONAL package, so the stack loads it here and hands the result + // to the plugin as its host factory. The load runs BEFORE the plugin is + // constructed, so a missing package produces one clear message with the + // install command instead of a connect error later in boot — and never + // a step-down to SQLite, which would open an empty local database while + // the operator's libSQL data stays untouched (#3276). + // + // No `autoMigrate` passthrough: `TursoDriverConfig` declares no such + // key, and handing the driver a config it silently ignores is the kind + // of "declared ≠ enforced" the CLI side deliberately avoided too. + driverId = 'turso'; + const authToken = resolveDatabaseAuthToken(cfg); + driverConfig = { url: dbUrl, ...(authToken ? { authToken } : {}) }; + hostFactory = await loadTursoDriverFactory(); } else if (dbDriver === 'sqlite-wasm') { driverId = 'sqlite-wasm'; const filename = sqliteFilenameFromUrl(dbUrl, 'sqlite-wasm'); @@ -291,7 +373,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro } const defaultDatasourcePlugin = new DefaultDatasourcePlugin( { driver: driverId, config: driverConfig }, - { dev: factoryDev }, + { dev: factoryDev, ...(hostFactory ? { factory: hostFactory } : {}) }, ); const artifactBundle = await loadArtifactBundle(artifactPath, { diff --git a/packages/runtime/src/turso-driver-factory.ts b/packages/runtime/src/turso-driver-factory.ts new file mode 100644 index 0000000000..1a02cbb3a8 --- /dev/null +++ b/packages/runtime/src/turso-driver-factory.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * libSQL/Turso driver loading for the standalone (runtime-only) stack (#5820). + * + * `standalone-stack.ts` translates a database URL into the `default` datasource + * DEFINITION (ADR-0062 D1, #3826) and never constructs a driver itself — the + * shared `DatasourceConnectionService` connects it. That works for every kind + * the open-core factory can build; `turso` is the one kind it cannot, because + * `@objectstack/driver-turso` drags `@libsql/client` (native bindings included) + * and is therefore an OPTIONAL install rather than a dependency. + * + * So this module builds the host driver factory `DefaultDatasourcePlugin` + * accepts (`options.factory`) — the documented seam for exactly this case: "a + * host whose `default` needs a driver the open-core factory cannot build". + * Everything else stays identical to every other kind: same connect path, same + * `bootCritical` fail-fast verdict, same `OS_ALLOW_DRIVER_CONNECT_FAILURE` + * escape hatch, same retained status in Setup → Datasources. Only the + * construction differs. + * + * ## Why the loud failure, and never a SQLite fallback + * + * When the optional package is absent the load fails with + * {@link MissingDriverPackageError}, carrying the exact install command as DATA + * (not only prose). There is deliberately no other branch. Degrading a + * `libsql://` selection to SQLite would open an empty local file while the + * operator's remote database sits untouched, and every write — including an + * `os migrate` DDL — would land in the wrong database. That is the #3276 lesson + * (a driver kind advertised but silently resolved to a *different* engine), and + * it is the same ruling the CLI side landed under (#5602 / PR #5819). + * + * ## Relationship to the CLI's `loadTursoDriverFactory` + * + * `packages/cli/src/utils/storage-driver.ts` carries the same shape for the + * `os serve` / `os start` path. The two are independent today because the + * dependency direction forbids the reverse import (cli → runtime, never + * runtime → cli), and because #5602's file face was the CLI alone. Collapsing + * them onto one owner — this module, with the CLI delegating — is filed as a + * follow-up rather than done here, so this PR stays inside #5820's face. + */ + +import type { + DatasourceConnectionSpec, + DatasourceDriverHandle, + IDatasourceDriverFactory, +} from '@objectstack/service-datasource'; + +/** The optional package that provides the libSQL/Turso driver. */ +export const TURSO_DRIVER_PACKAGE = '@objectstack/driver-turso'; + +/** The exact command an operator runs to install the optional libSQL driver. */ +export const TURSO_DRIVER_INSTALL_COMMAND = `npm install ${TURSO_DRIVER_PACKAGE}`; + +/** Driver ids this factory builds — the same pair the CLI's resolver treats as libSQL. */ +const TURSO_DRIVER_IDS = new Set(['turso', 'libsql']); + +/** True for the driver ids {@link loadTursoDriverFactory}'s factory builds. */ +export function isTursoDriverId(driverId: string): boolean { + return TURSO_DRIVER_IDS.has(driverId.trim().toLowerCase()); +} + +/** + * Thrown by {@link loadTursoDriverFactory} when the OPTIONAL driver package the + * selection needs is not installed, or resolves to something that is not the + * driver (a shadowing stub, a truncated install, a major that renamed the + * export). + * + * The install command rides as a field as well as inside the message so a + * caller can render it however it likes, and so the pin test asserts the + * command rather than a sentence shape. + */ +export class MissingDriverPackageError extends Error { + readonly driverType: string; + readonly packageName: string; + readonly installCommand: string; + constructor(args: { driverType: string; packageName: string; installCommand: string; message: string }) { + super(args.message); + this.name = 'MissingDriverPackageError'; + this.driverType = args.driverType; + this.packageName = args.packageName; + this.installCommand = args.installCommand; + } +} + +export interface LoadTursoDriverFactoryOptions { + /** + * Test seam: substitute the dynamic `import('@objectstack/driver-turso')`. + * Production passes nothing. Tests pass a stub module (dispatch WITH the + * package) or a rejecting thunk (dispatch WITHOUT it) — neither needs a real + * Turso endpoint, and the missing-package path must stay testable in a + * workspace where the package happens to be installed. + */ + importDriverPackage?: () => Promise; +} + +/** + * Load the OPTIONAL libSQL/Turso driver package and wrap it as the host driver + * factory `DefaultDatasourcePlugin` accepts. + * + * The import happens here, at boot, and only for a selection that actually asks + * for libSQL — never at module load, so a stack that never sees a `libsql://` + * URL pays nothing for this arm existing. + */ +export async function loadTursoDriverFactory( + opts: LoadTursoDriverFactoryOptions = {}, +): Promise { + // `as any` on the specifier: the package is deliberately NOT a dependency of + // `@objectstack/runtime` (that is what "optional" means here), so the literal + // must not be type-resolved. Same shape the shared factory uses for the other + // optional drivers (`default-datasource-driver-factory.ts`). + const load = opts.importDriverPackage ?? (() => import('@objectstack/driver-turso' as any)); + + let mod: unknown; + try { + mod = await load(); + } catch (err) { + throw new MissingDriverPackageError({ + driverType: 'turso', + packageName: TURSO_DRIVER_PACKAGE, + installCommand: TURSO_DRIVER_INSTALL_COMMAND, + message: + `A libSQL/Turso database was selected, but the driver package ${TURSO_DRIVER_PACKAGE} ` + + `is not installed. Install it next to your app:\n\n ${TURSO_DRIVER_INSTALL_COMMAND}\n\n` + + `(pnpm add ${TURSO_DRIVER_PACKAGE} / yarn add ${TURSO_DRIVER_PACKAGE}.) It is an ` + + 'OPTIONAL package, so a default install stays free of @libsql/client and its native ' + + 'bindings. The boot refuses rather than falling back to SQLite: a silent fallback would ' + + 'open an empty local database while your libSQL data stays untouched, and every write — ' + + 'including an `os migrate` DDL — would land in the wrong database. To use SQLite ' + + 'deliberately, set OS_DATABASE_URL=file:./data/objectstack.db. ' + + `Import error: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const record = (mod ?? {}) as { TursoDriver?: unknown; default?: { TursoDriver?: unknown } }; + const TursoDriverCtor = (record.TursoDriver ?? record.default?.TursoDriver) as + | (new (config: { url: string; authToken?: string }) => object) + | undefined; + if (typeof TursoDriverCtor !== 'function') { + throw new MissingDriverPackageError({ + driverType: 'turso', + packageName: TURSO_DRIVER_PACKAGE, + installCommand: TURSO_DRIVER_INSTALL_COMMAND, + message: + `${TURSO_DRIVER_PACKAGE} resolved but exports no TursoDriver class, so the libSQL ` + + `database cannot be opened. Reinstall it:\n\n ${TURSO_DRIVER_INSTALL_COMMAND}\n\n` + + 'The boot refuses rather than falling back to SQLite, which would write your data ' + + 'into a different database than the one you configured.', + }); + } + + return { + supports: (driverId: string) => isTursoDriverId(driverId), + create: (spec: DatasourceConnectionSpec): DatasourceDriverHandle => { + const config = (spec.config ?? {}) as { url?: unknown; authToken?: unknown }; + const url = typeof config.url === 'string' ? config.url : ''; + if (!url) { + // Defensive: the standalone stack always resolves a URL before it + // selects this kind. A host composing the definition by hand can still + // get here, and an empty libSQL url has no default to fall back on. + throw new Error( + `[StandaloneStack] datasource '${spec.name ?? 'default'}': driver '${spec.driver}' needs a ` + + 'libSQL url in its config (e.g. libsql://my-db.turso.io or file:./data/objectstack.db).', + ); + } + const driver = new TursoDriverCtor({ + url, + ...(typeof config.authToken === 'string' && config.authToken + ? { authToken: config.authToken } + : {}), + }) as { + connect?: () => Promise; + disconnect?: () => Promise; + checkHealth?: () => Promise; + }; + // Same handle shape the open-core factory builds (`toHandle`): ownership + // stays the default `'factory'` — this instance was built for THIS + // connect, so kernel teardown disconnects it. + return { + ...(typeof driver.connect === 'function' ? { connect: () => driver.connect!() } : {}), + ...(typeof driver.disconnect === 'function' ? { disconnect: () => driver.disconnect!() } : {}), + ...(typeof driver.checkHealth === 'function' + ? { checkHealth: () => driver.checkHealth!(), ping: () => driver.checkHealth!() } + : {}), + driver, + }; + }, + }; +} diff --git a/packages/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts index 0e3f256726..f3e18f209e 100644 --- a/packages/runtime/tsup.config.ts +++ b/packages/runtime/tsup.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ '@objectstack/driver-sql', '@objectstack/driver-sqlite-wasm', '@objectstack/driver-mongodb', + // OPTIONAL install, loaded through `turso-driver-factory.ts`'s lazy + // `import()` (#5820). External so esbuild never tries to resolve/bundle a + // package that is deliberately not a dependency of this one. + '@objectstack/driver-turso', '@objectstack/metadata', '@objectstack/objectql', ],