diff --git a/.changeset/cli-libsql-url-inference.md b/.changeset/cli-libsql-url-inference.md
new file mode 100644
index 0000000000..6a9bde522e
--- /dev/null
+++ b/.changeset/cli-libsql-url-inference.md
@@ -0,0 +1,31 @@
+---
+"@objectstack/cli": minor
+---
+
+feat(cli): `libsql://` URLs boot a Turso driver — via an optional package, never a silent SQLite fallback (#5602)
+
+`os start --database libsql://my-db.turso.io --database-auth-token $TURSO_TOKEN`
+是 `os start --help` 自己列出的 example,但在此之前它必然 `exit 1`:CLI 的
+URL → driver 推断认得 `libsql://`,却当场抛 `UnsupportedDriverError` —— 而 runtime 的
+环境 provisioning 把 turso 排在偏好第一位。两处口径相反的原因(driver 不在开源分发里)
+已随 #4645 把 `@objectstack/driver-turso` 迁回本仓而消失。
+
+现在这条 example 成真:
+
+- **识别即构造。** `libsql://` / `*.turso.io` 解析为 `turso` datasource 定义,
+ `--database-auth-token`(`OS_DATABASE_AUTH_TOKEN`,回落到 vendor 自己的
+ `TURSO_AUTH_TOKEN`)进入 driver 配置 —— 该 flag 此前只被转发进子进程环境、无人读取。
+- **可选依赖 + 动态 import。** `@objectstack/driver-turso` 声明为 CLI 的
+ **optional peer**(它会拖入 `@libsql/client`),默认安装体积不变;只有真正选了 libSQL
+ 的启动才会动态 import 它,并通过 `DefaultDatasourcePlugin` 既有的 host-factory 接缝注入。
+ 连接路径、`bootCritical` 失败裁决、`OS_ALLOW_DRIVER_CONNECT_FAILURE` 逃生舱与
+ Setup → Datasources 的状态留存因此与其他 driver 完全一致(#3826)。
+- **包缺席时响亮失败。** 抛 `MissingDriverPackageError`,消息给出精确安装命令
+ (`npm install @objectstack/driver-turso`)、说明它是 optional peer,并说明为什么
+ ⛔ 不回退 SQLite:静默降级会让服务器对着一个空的本地库启动,而你的 libSQL 数据原封不动,
+ 每一次写入都落在错误的数据库里(#3276 的教训)。
+- **仍然拒收的形状。** `--database-driver turso` 但没有任何 URL —— libSQL 没有可猜的默认值,
+ 这条继续抛 `UnsupportedDriverError`,而不是悄悄用 SQLite 默认值顶上。
+
+`os start` 的 example 加了「需安装 driver 包」注记,Drivers / Self-hosting /
+Environment variables / CLI 四处文档同步为「可选包支持」口径。
diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx
index fd1f26ea85..c64fceffd9 100644
--- a/content/docs/data-modeling/drivers.mdx
+++ b/content/docs/data-modeling/drivers.mdx
@@ -47,18 +47,24 @@ Drivers can be selected in two ways:
| `mongodb://…`, `mongodb+srv://…` | MongoDB | `@objectstack/driver-mongodb` |
| `postgres://…`, `postgresql://…` | PostgreSQL (Knex `pg`) | `@objectstack/driver-sql` + `pg` |
| `mysql://…`, `mysql2://…` | MySQL (Knex `mysql2`) | `@objectstack/driver-sql` + `mysql2` |
+| `libsql://…`, `http(s)://*.turso.…` | Turso / libSQL | `@objectstack/driver-turso` (**optional** — install it yourself) |
| `wasm-sqlite://…`, `*.wasm.db` | SQLite (pure-JS WASM) | `@objectstack/driver-sqlite-wasm` |
| `file:…`, `sqlite:…`, `:memory:`, `*.db` / `*.sqlite` | SQLite (Knex `better-sqlite3`) | `@objectstack/driver-sql` + `better-sqlite3` |
| _(unset, dev mode)_ | SQLite (native, falling back to WASM, then in-memory) | `@objectstack/driver-sql` / `-sqlite-wasm` / `-memory` |
-Turso / libSQL (`libsql://`, `*.turso.io`) is **not** inferred from a URL.
-`@objectstack/driver-turso` does live in this repo (`packages/drivers/driver-turso`)
-and you can register it yourself — a datasource with `driver: 'turso'` and
-`config: { url, authToken }` — but the CLI's URL → driver resolver does not
-construct it. A `libsql://` URL therefore fails loudly at boot rather than
-silently degrading to SQLite. Whether the inference table should construct it
-is tracked in issue #5602.
+**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:
+
+```bash
+npm install @objectstack/driver-turso
+```
+
+Without it the boot **fails loudly** with that exact command; it never degrades to
+SQLite, which would start the server against an empty local database while your
+libSQL data stayed untouched. Pass the token with `--database-auth-token`
+(`OS_DATABASE_AUTH_TOKEN`, or the vendor's own `TURSO_AUTH_TOKEN`).
## Supported Drivers
@@ -70,6 +76,7 @@ is tracked in issue #5602.
| **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` |
| **Memory** | `@objectstack/driver-memory` | `InMemoryDriver` | `memory` |
> All SQL flavours (PostgreSQL / MySQL / SQLite) are served by a single
diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx
index a51f601a5c..ff226f20b1 100644
--- a/content/docs/deployment/cli.mdx
+++ b/content/docs/deployment/cli.mdx
@@ -298,6 +298,7 @@ os start \
--port 8080
# Remote artifact + Turso/libSQL backing store
+# (needs the optional driver package: npm install @objectstack/driver-turso)
os start \
--artifact https://cdn.example.com/app.json \
--database libsql://my-db.turso.io \
@@ -351,7 +352,7 @@ The fall-through applies to the **conventional** locations only. Remote
**What it boots:**
- Reads the artifact's `manifest`, `objects`, `views`, `flows`, …
- Auto-registers the platform services declared in `requires: [...]` (e.g. `ai`, `automation`, `analytics`, `auth`, `ui`). Declaring a **service** capability (`automation`, `analytics`, `ai`, `audit`, …) is a *requirement*: if its provider package isn't installed, boot **fails fast** with a clear error instead of silently starting without a capability you asked for. (`auth` and `ui` are tier-gated with their own opt-in rules — `auth`'s secret-gated skip is described below.)
-- Auto-detects the driver from the database URL scheme (`memory://` → in-memory, `libsql://`/`https://` → Turso, `postgres[ql]://`/`pg://` → pg, `mongodb[+srv]://` → MongoDB, otherwise sqlite)
+- Auto-detects the driver from the database URL scheme (`memory://` → in-memory, `libsql://`/`https://*.turso.*` → Turso — via the optional `@objectstack/driver-turso` package, and a loud failure with the install command when it is missing rather than a fallback to sqlite —, `postgres[ql]://`/`pg://` → pg, `mongodb[+srv]://` → MongoDB, otherwise sqlite)
- Runs standalone boot mode with one active environment.
**Authentication:**
diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx
index 69890a93a3..1fb329d4f4 100644
--- a/content/docs/deployment/environment-variables.mdx
+++ b/content/docs/deployment/environment-variables.mdx
@@ -47,8 +47,9 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| Variable | Type | Default | Description |
|:---|:---|:---|:---|
-| `OS_DATABASE_URL` | url | — | Database connection string (e.g. `file:./data.sqlite`, `postgres://…`, `mongodb://…`, `memory://`). `libsql://` (Turso) is not inferred — register `@objectstack/driver-turso` explicitly. |
-| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mongodb`. |
+| `OS_DATABASE_URL` | url | — | Database connection string (e.g. `file:./data.sqlite`, `postgres://…`, `mongodb://…`, `memory://`). `libsql://` / `*.turso.io` (Turso) is inferred too, but its driver is an **optional** package: `npm install @objectstack/driver-turso`, otherwise the boot fails loudly with that command — it never falls back to SQLite. |
+| `OS_DATABASE_AUTH_TOKEN` | string | — | Auth token for a libSQL/Turso connection (`--database-auth-token`). The vendor's own `TURSO_AUTH_TOKEN` is read as a fallback and is **not** renamed (see the third-party names note above). Ignored by every other driver — their credentials live in the URL. |
+| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mysql` \| `mongodb` \| `turso`. |
| `OS_DATABASE_SQLITE_JOURNAL_MODE` | enum | `wal` | Journal mode for **file-backed** SQLite. `wal` (default) lets a dev server and CLI commands share one file without blocking each other, and is what makes the `os migrate` occupancy check reliable. Set to `delete` for SQLite's rollback journal — required when the database lives on a **network filesystem** (NFS/SMB), where WAL cannot work. The setting is applied, not merely skipped: `delete` converts a database that already adopted WAL back. Ignored for `:memory:`, for the WASM SQLite driver, and for non-SQLite drivers. A per-datasource `sqliteJournalMode` in driver config outranks it. See [Journal mode](/docs/data-modeling/drivers#journal-mode-wal-and-cross-process-access). |
| `OS_ALLOW_DRIVER_CONNECT_FAILURE` | boolean | `false` | Escape hatch for the driver-connect boot guard. By default a data driver that fails to connect at startup **refuses the boot** — a server that cannot reach its database must not report itself started and then fail every request. The same guard covers a **declared datasource** that objects bind to via `datasource: '…'`, or an `external` one with `validation.onMismatch: 'fail'`: those objects have no fallback datasource, so an unconnected one means they are all dead. Set to `1` to boot anyway, in an explicitly degraded state logged loudly at startup. There is **no reconnection**: whatever failed stays dead for the process lifetime and every query and schema sync routed to it fails. |
| `OS_STORAGE_LOCAL_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). This is the same value as **Setup → Settings → File Storage → Root directory**; setting it here pins that field (it shows as locked-by-env). Renamed from `OS_STORAGE_ROOT` — see below. |
diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx
index 62c55c5eec..0daa8e1258 100644
--- a/content/docs/deployment/self-hosting.mdx
+++ b/content/docs/deployment/self-hosting.mdx
@@ -32,7 +32,7 @@ workable default:
| Variable | Why it must be set |
|:---|:---|
-| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mongodb://…`, or a mounted `file:…` path (`libsql://` / Turso is **not** inferred from the URL — `@objectstack/driver-turso` is in-repo but must be registered explicitly in your stack config; see [Drivers](/docs/data-modeling/drivers)). `mongodb://…` is **single-tenant only**: the MongoDB driver has no row-level tenant isolation and refuses to boot unless the tenancy posture is `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported). |
+| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mongodb://…`, `libsql://…`, or a mounted `file:…` path (`libsql://` / Turso is inferred, but its driver is an **optional** package — `npm install @objectstack/driver-turso`, or the boot fails loudly rather than degrading to SQLite; see [Drivers](/docs/data-modeling/drivers)). `mongodb://…` is **single-tenant only**: the MongoDB driver has no row-level tenant isolation and refuses to boot unless the tenancy posture is `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported). |
| `OS_AUTH_SECRET` | Session secret for the auth plugin (`AUTH_SECRET` is the legacy alias). Without it, `/api/v1/auth/*` is **silently skipped** — the server runs unauthenticated. |
| `OS_SECRET_KEY` | 32-byte master key encrypting every stored secret (`openssl rand -hex 32`). On a container's ephemeral filesystem the auto-minted key is **lost on restart**, making previously-encrypted secrets undecryptable. |
| `OS_PORT` | `os start` **fails loudly** if the port is busy (it never auto-shifts like `os dev`). Pin it and keep your reverse-proxy upstream in sync. |
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 3217acfb2b..ebc2d9fb8e 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -101,10 +101,19 @@
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
+ "peerDependencies": {
+ "@objectstack/driver-turso": "workspace:^"
+ },
+ "peerDependenciesMeta": {
+ "@objectstack/driver-turso": {
+ "optional": true
+ }
+ },
"optionalDependencies": {
"better-sqlite3": "^13.0.2"
},
"devDependencies": {
+ "@objectstack/driver-turso": "workspace:*",
"@oclif/plugin-help": "^6.2.55",
"@oclif/plugin-plugins": "^5.4.86",
"@types/better-sqlite3": "^7.6.13",
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index ea4bb0b044..724a9bd8ee 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -9,7 +9,14 @@ import { bundleRequire } from 'bundle-require';
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { mergeBootConfig } from '../utils/merge-boot-config.js';
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
-import { resolveDriverType, resolveStorageDefinition, UnsupportedDriverError } from '../utils/storage-driver.js';
+import {
+ resolveDriverType,
+ resolveStorageDefinition,
+ loadTursoDriverFactory,
+ isTursoDriverId,
+ MissingDriverPackageError,
+ UnsupportedDriverError,
+} from '../utils/storage-driver.js';
// [ADR-0105 D1] `resolveMultiOrgEnabled` is deliberately NOT imported here: the
// posture is the authoritative knob and `resolveTenancyPosture()` already folds
// the legacy boolean in as its unset-fallback. serve's last direct reader of the
@@ -1027,6 +1034,11 @@ export default class Serve extends Command {
if (!hasDriver && config.objects) {
const databaseUrl = process.env.OS_DATABASE_URL;
const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl);
+ // libSQL/Turso's credential is the only one that does NOT ride inside the
+ // URL (`--database-auth-token`, forwarded by `os start` / `os dev` as
+ // OS_DATABASE_AUTH_TOKEN; TURSO_AUTH_TOKEN is the vendor's own name, kept
+ // as-is per Prime Directive #9's third-party exceptions).
+ const databaseAuthToken = process.env.OS_DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN;
try {
// #3826: the fallback no longer constructs a driver — it declares
@@ -1039,11 +1051,22 @@ export default class Serve extends Command {
// 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 });
+ const resolution = resolveStorageDefinition(driverType, { databaseUrl, isDev, authToken: databaseAuthToken });
if (resolution) {
+ // #5602: libSQL/Turso is the one kind the shared open-core factory
+ // cannot build — `@objectstack/driver-turso` is an OPTIONAL peer, so
+ // the CLI loads it here and injects it through the plugin's documented
+ // host-factory seam. Everything else about the boot is unchanged: same
+ // connect path, same bootCritical verdict, same escape hatch. A missing
+ // package throws MissingDriverPackageError BEFORE the plugin exists, so
+ // the operator sees the install command rather than a connect failure —
+ // and never a silent SQLite fallback.
+ const hostFactory = isTursoDriverId(resolution.driverId)
+ ? await loadTursoDriverFactory()
+ : undefined;
await kernel.use(new DefaultDatasourcePlugin(
{ driver: resolution.driverId, config: resolution.config },
- { dev: isDev },
+ { dev: isDev, ...(hostFactory ? { factory: hostFactory } : {}) },
));
trackPlugin(resolution.trackName);
resolvedDriverLabel = resolution.label;
@@ -1097,14 +1120,20 @@ export default class Serve extends Command {
}
}
} catch (e: any) {
- // "declared ≠ enforced" guard (#3276-class): a driver that is
- // RECOGNIZED but the CLI's resolver does not construct — currently
- // `turso`/libSQL, in-repo since #4645 but unwired — must fail LOUDLY, never silently
- // fall through to the SQLite default and ignore the selected engine.
+ // "declared ≠ enforced" guard (#3276-class): a selection the CLI
+ // RECOGNIZED but cannot honour must fail LOUDLY, never silently fall
+ // through to the SQLite default and ignore the engine that was asked for.
// 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.
+ // • UnsupportedDriverError — recognized kind, no usable definition
+ // (`--database-driver turso` with no URL to connect to).
+ // • MissingDriverPackageError (#5602) — the optional driver package for
+ // a `libsql://` selection is not installed. Fatal for the same reason
+ // and with the same remedy shape: the message carries the exact
+ // install command, and there is deliberately no SQLite fallback.
if (e instanceof UnsupportedDriverError) throw e;
+ if (e instanceof MissingDriverPackageError) throw e;
// Same class of fatal (#3724): a driver that refuses to run in this
// deployment's tenancy mode — driver-mongodb has no row-level tenant
// isolation and rejects a non-`single` posture. Swallowing it would
diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts
index 7d959590e4..28ea008fc4 100644
--- a/packages/cli/src/commands/start.ts
+++ b/packages/cli/src/commands/start.ts
@@ -51,7 +51,16 @@ export default class Start extends Command {
'<%= config.bin %> start --artifact https://cdn.example.com/app.json --port 8080',
'<%= config.bin %> start --database file:./data/prod.db',
'<%= config.bin %> start --database postgres://user:pass@host:5432/mydb',
- '<%= config.bin %> start --database libsql://my-db.turso.io --database-auth-token $TURSO_TOKEN',
+ {
+ // #5602: `libsql://` IS inferred and built — through the OPTIONAL package
+ // `@objectstack/driver-turso`, which the CLI does not bundle (it drags
+ // `@libsql/client`). Without it installed the boot fails loudly with this
+ // exact install command; it never degrades to SQLite. The note belongs in
+ // the example because copy-pasting this line is precisely how an operator
+ // meets the requirement.
+ command: '<%= config.bin %> start --database libsql://my-db.turso.io --database-auth-token $TURSO_TOKEN',
+ description: 'Turso / libSQL — requires the optional driver package: npm install @objectstack/driver-turso',
+ },
'<%= config.bin %> start --no-ui',
];
diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts
index 546d9112e2..a6b2262c7f 100644
--- a/packages/cli/src/utils/storage-driver.test.ts
+++ b/packages/cli/src/utils/storage-driver.test.ts
@@ -1,10 +1,16 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect } from 'vitest';
+// Type-only: the compile-time half of the #5602 dispatch pin (see the last test in
+// this file). `@objectstack/driver-turso` is an OPTIONAL peer of the CLI — this
+// import is erased, so nothing here requires it at runtime.
+import type { TursoDriverConfig } from '@objectstack/driver-turso';
import {
inferDriverTypeFromUrl,
resolveDriverType,
resolveStorageDefinition,
+ loadTursoDriverFactory,
+ MissingDriverPackageError,
UnsupportedDriverError,
} from './storage-driver.js';
@@ -139,50 +145,216 @@ describe('resolveStorageDefinition (#3826 — a definition, not a driver)', () =
});
});
-describe('resolveStorageDefinition: turso / libSQL is recognized but fails loud', () => {
- // `turso` (@objectstack/driver-turso) is recognized as a kind but is not one
- // this resolver constructs from a URL. Selecting it must THROW a typed error —
- // NOT fall through to the SQLite default. Remove the `turso` branch and these
- // go red: in dev it resolves to the sqlite dev-default, in prod it returns
- // null — both silently ignoring the requested turso engine (the reported bug).
- //
- // Since #4645 the package lives in THIS repo (`packages/drivers/driver-turso`),
- // so the refusal is no longer "it ships elsewhere" — it is "the CLI does not
- // build it from a URL; register it explicitly". The behaviour is unchanged;
- // only the reason given to the operator is. Whether URL inference should
- // construct it is #5602.
- it('throws UnsupportedDriverError for `turso` in DEV and PROD', () => {
- expect(() => resolveStorageDefinition('turso', { isDev: true })).toThrow(UnsupportedDriverError);
- expect(() => resolveStorageDefinition('turso', { isDev: false })).toThrow(UnsupportedDriverError);
- });
-
- it('throws for the `libsql` alias too', () => {
- expect(() => resolveStorageDefinition('libsql', { isDev: true })).toThrow(UnsupportedDriverError);
- });
-
- // The message must be actionable: name the package, name the way OUT (register
- // it explicitly), and name the alternatives this resolver does build — so an
- // operator knows exactly how to proceed. It must NOT claim the package ships
- // somewhere else; that stopped being true at #4645 and an error message that
- // sends an operator to the wrong repo is worse than a terse one.
- it('carries an actionable message (package + explicit-registration route + alternatives)', () => {
- expect(() => resolveStorageDefinition('turso', { isDev: false })).toThrow(/@objectstack\/driver-turso/);
+describe('resolveStorageDefinition: turso / libSQL is inferred AND built (#5602)', () => {
+ // These four pins used to assert the opposite verdict — a `libsql://` URL threw
+ // UnsupportedDriverError, because `@objectstack/driver-turso` shipped outside the
+ // open-source distribution. #4645 moved the package into this repo and the
+ // maintainer's #5602 ruling wired it as an optional peer + dynamic import, so the
+ // SAME URLs now produce a `turso` DEFINITION. What did NOT change is the thing the
+ // pins were protecting: a libSQL selection must never resolve to SQLite. Remove
+ // the turso branch and these go red exactly as before — dev resolves to the sqlite
+ // dev-default, prod returns null, both silently ignoring the requested engine.
+ it('declares the turso driver for a libsql:// URL in DEV and PROD', () => {
+ for (const isDev of [true, false]) {
+ const r = resolveStorageDefinition('turso', { isDev, databaseUrl: 'libsql://my-db.turso.io' });
+ expect(r).not.toBeNull();
+ expect(r!.driverId).toBe('turso');
+ expect(r!.config).toEqual({ url: 'libsql://my-db.turso.io' });
+ expect(r!.trackName).toBe('TursoDriver');
+ expect(r!.label).toBe('TursoDriver(libsql)');
+ expect(r!.displayUrl).toBe('libsql://my-db.turso.io');
+ // A remote libSQL endpoint is not an on-disk SQLite primary, so it never
+ // provisions the telemetry sibling.
+ expect(r!.sqliteFilePath).toBeUndefined();
+ }
+ });
+
+ it('accepts the `libsql` alias and carries the auth token into the config', () => {
+ const r = resolveStorageDefinition('libsql', {
+ isDev: false,
+ databaseUrl: 'libsql://my-db.turso.io',
+ authToken: 'jwt-token',
+ });
+ expect(r!.driverId).toBe('turso');
+ expect(r!.config).toEqual({ url: 'libsql://my-db.turso.io', authToken: 'jwt-token' });
+ });
+
+ // `TursoDriverConfig` declares neither `autoMigrate` nor `persist`; passing one
+ // would be a config key the driver silently ignores (the declared-≠-enforced shape
+ // this file's other pins exist to prevent), so the dev branch adds nothing.
+ it('adds no autoMigrate passthrough in dev — the turso config contract has no such key', () => {
+ const r = resolveStorageDefinition('turso', { isDev: true, databaseUrl: 'libsql://my-db.turso.io' });
+ expect(Object.keys(r!.config).sort()).toEqual(['url']);
+ });
+
+ // The one shape that still REFUSES: a turso selection with nothing to connect to.
+ // Every other kind has a meaningful default for a missing URL; libSQL has none, and
+ // inventing one (or dropping to the SQLite default) is exactly the silent
+ // substitution #3276 was about.
+ it('throws UnsupportedDriverError when `turso` is selected with no URL', () => {
+ for (const isDev of [true, false]) {
+ expect(() => resolveStorageDefinition('turso', { isDev })).toThrow(UnsupportedDriverError);
+ expect(() => resolveStorageDefinition('libsql', { isDev })).toThrow(UnsupportedDriverError);
+ expect(() => resolveStorageDefinition('turso', { isDev, databaseUrl: ' ' })).toThrow(UnsupportedDriverError);
+ }
let err: unknown;
try { resolveStorageDefinition('turso', { isDev: false }); } catch (e) { err = e; }
expect(err).toBeInstanceOf(UnsupportedDriverError);
expect((err as UnsupportedDriverError).driverType).toBe('turso');
- expect((err as Error).message).toMatch(/register it explicitly/i);
- expect((err as Error).message).toMatch(/sqlite \| postgres \| mysql \| mongodb \| memory/);
+ expect((err as Error).message).toMatch(/OS_DATABASE_URL/);
+ expect((err as Error).message).toMatch(/libsql:\/\/my-db\.turso\.io/);
+ // It must not send the operator anywhere else: the package is in this repo and
+ // is installable from npm (that claim died at #4645).
expect((err as Error).message).not.toMatch(/cloud|enterprise/i);
});
- // 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', () => {
+ // A `libsql://` / Turso URL is classified, not left unrecognized — which is what
+ // would fall through to SQLite.
+ it('routes libsql:// and *.turso.* URLs to the turso definition, never SQLite', () => {
expect(resolveDriverType(undefined, 'libsql://my-db.turso.io')).toBe('turso');
expect(resolveDriverType(undefined, 'https://my-db.turso.io')).toBe('turso');
- expect(() =>
- resolveStorageDefinition(resolveDriverType(undefined, 'libsql://my-db.turso.io'), { isDev: true }),
- ).toThrow(UnsupportedDriverError);
+ for (const url of ['libsql://my-db.turso.io', 'https://my-db.turso.io']) {
+ const r = resolveStorageDefinition(resolveDriverType(undefined, url), { isDev: true, databaseUrl: url });
+ expect(r!.driverId).toBe('turso');
+ expect(r!.driverId).not.toBe('sqlite');
+ }
+ });
+});
+
+describe('loadTursoDriverFactory: the optional driver package (#5602)', () => {
+ /** 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 } };
+ }
+
+ // ① Package present: the URL reaches a TursoDriver construction with the url and
+ // the auth token from `--database-auth-token`. No network — the substitute module
+ // proves the DISPATCH, which is the CLI's half of the contract.
+ it('builds a TursoDriver from the resolved definition when the package is installed', async () => {
+ const { built, 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);
+
+ const definition = resolveStorageDefinition('turso', {
+ isDev: false,
+ databaseUrl: 'libsql://my-db.turso.io',
+ authToken: 'jwt-token',
+ })!;
+ const handle = await factory.create({ name: 'default', driver: definition.driverId, config: definition.config });
+
+ expect(built).toEqual([{ url: 'libsql://my-db.turso.io', authToken: 'jwt-token' }]);
+ expect(handle.driver).toBeInstanceOf(module.TursoDriver);
+ // Probe surface the connection service uses — the same handle shape the
+ // open-core factory returns, ownership left at the default `'factory'` so
+ // kernel teardown disconnects an instance built for this connect.
+ expect(handle.ownership).toBeUndefined();
+ await handle.connect!();
+ await handle.disconnect!();
+ expect(await handle.checkHealth!()).toBe(true);
+ expect((handle.driver as { connected: boolean; disconnected: boolean }).connected).toBe(true);
+ expect((handle.driver as { connected: boolean; disconnected: boolean }).disconnected).toBe(true);
+ });
+
+ it('omits authToken entirely when none was supplied (no empty-string credential)', async () => {
+ const { built, module } = stubTursoModule();
+ const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module });
+ const definition = resolveStorageDefinition('turso', { isDev: false, databaseUrl: 'file:./data/local.db' })!;
+ await factory.create({ name: 'default', driver: 'turso', config: definition.config });
+ expect(built).toEqual([{ url: 'file:./data/local.db' }]);
+ });
+
+ // ② Package absent: LOUD failure carrying the exact install command, and NO
+ // fallback of any kind. The reverse verification for this change lives here —
+ // the pre-#5602 code could only ever fail, so "before red / after green" says
+ // nothing; what must be proven is that the one surviving failure mode still
+ // refuses to become SQLite.
+ 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('@objectstack/driver-turso');
+ 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 peer/i);
+ expect(missing.message).toMatch(/would start the server against an empty local database/i);
+ expect(missing.message).toMatch(/refuses rather than falling back to SQLite/i);
+ // 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'");
+ });
+
+ // The no-fallback claim, asserted rather than described: there is no path from a
+ // missing package to a driver at all — the loader throws, so no handle, no
+ // definition rewrite, nothing sqlite-shaped anywhere in the failure.
+ 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 definition the CLI would hand the plugin is still turso: nothing
+ // rewrites it to sqlite on the way out.
+ expect(
+ resolveStorageDefinition('turso', { isDev: true, databaseUrl: 'libsql://my-db.turso.io' })!.driverId,
+ ).toBe('turso');
+ });
+
+ // A package that resolves but is not the driver (shadowing stub, truncated
+ // install, a major that renamed the export) gets the same treatment — never a
+ // half-built handle.
+ 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('npm install @objectstack/driver-turso');
+ });
+
+ 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(UnsupportedDriverError);
+ });
+
+ // The config this CLI builds must be a config the REAL driver accepts. A stub
+ // module cannot check that, so the shape is pinned against the real package's
+ // published type — `tsc --noEmit` (packages/cli `typecheck`, which compiles tests)
+ // goes red if `TursoDriverConfig` renames `url`/`authToken` or turns either
+ // required-shaped in a way this dispatch no longer satisfies. Type-only: nothing
+ // is imported at runtime, so the optional package stays optional.
+ it('builds a config assignable to the real TursoDriverConfig', () => {
+ const definition = resolveStorageDefinition('turso', {
+ isDev: false,
+ databaseUrl: 'libsql://my-db.turso.io',
+ authToken: 'jwt-token',
+ })!;
+ const config = definition.config as { url: string; authToken?: string };
+ const pinned: TursoDriverConfig = config;
+ expect(pinned.url).toBe('libsql://my-db.turso.io');
+ expect(pinned.authToken).toBe('jwt-token');
});
});
diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts
index 71969f8223..6e58fa6dc7 100644
--- a/packages/cli/src/utils/storage-driver.ts
+++ b/packages/cli/src/utils/storage-driver.ts
@@ -31,18 +31,51 @@
* Note the deliberate distinction from SQLite's own `:memory:` pseudo-file:
* `OS_DATABASE_URL=:memory:` stays `sqlite` (SQLite's in-memory mode), whereas
* the `memory://` scheme and the `memory` driver select the mingo engine.
+ *
+ * ## #5602 — `libsql://` is now WIRED, through an optional package
+ *
+ * `turso`/libSQL used to be the mirror image of the #3276 bug: recognized as a
+ * kind and then refused, because `@objectstack/driver-turso` shipped outside the
+ * open-source distribution. #4645 moved that package into this repo, which
+ * retired the reason for the refusal — while `os start`'s own help still
+ * advertised `--database libsql://my-db.turso.io`, an example that exited 1.
+ *
+ * The maintainer's ruling (#5602, 2026-08-06) wires it as an **optional peer +
+ * dynamic import**: `libsql://` / `*.turso.io` resolves to a `turso` definition,
+ * and the CLI loads {@link loadTursoDriverFactory} to build the driver. The
+ * package is NOT a hard dependency — it drags `@libsql/client`, so a default
+ * install stays as light as it was. When it is absent the boot fails LOUDLY with
+ * the exact install command ({@link MissingDriverPackageError}); it never falls
+ * back to SQLite, which is the #3276 lesson kept intact: a silent step-down onto
+ * a *different* engine writes an operator's data into the wrong database.
*/
+import type {
+ DatasourceConnectionSpec,
+ DatasourceDriverHandle,
+ IDatasourceDriverFactory,
+} from '@objectstack/service-datasource';
+
/** Engines the shared sqlite step-down (`resolveSqliteDriver`) can produce. */
export type SqliteFamilyEngine = 'better-sqlite3' | 'sqlite-wasm' | 'memory';
+/** The optional package that provides the libSQL/Turso driver. */
+export const TURSO_DRIVER_PACKAGE = '@objectstack/driver-turso';
+
+/** Driver kinds this resolver treats as libSQL/Turso. */
+const TURSO_DRIVER_KINDS = new Set(['turso', 'libsql']);
+
/**
- * Thrown by {@link resolveStorageDefinition} when a driver kind is *recognized* but this
- * resolver does not construct it — currently `turso`/libSQL (`@objectstack/driver-turso`,
- * an extension of SqlDriver over `@libsql/client`). Since #4645 that package lives in
- * this repo (`packages/drivers/driver-turso`), but the CLI still does not build it from
- * a URL: it is registered explicitly in a stack config, or composed by a host's own
- * kernel factory. Whether URL inference should construct it is #5602.
+ * Thrown by {@link resolveStorageDefinition} when a driver kind is *recognized* but the
+ * selection cannot be turned into a datasource definition at all — today only
+ * `turso`/libSQL selected with **no URL** (`OS_DATABASE_DRIVER=turso` /
+ * `--database-driver turso` on its own). Every other kind has a meaningful default
+ * for a missing URL; libSQL has none — `TursoDriverConfig.url` is required and there
+ * is no local file, host or database name to guess.
+ *
+ * Not the "package missing" case: that is {@link MissingDriverPackageError}, which
+ * says something completely different to the operator (install this, versus tell me
+ * where the database is).
*
* 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
@@ -59,6 +92,30 @@ export class UnsupportedDriverError extends Error {
}
}
+/**
+ * Thrown by {@link loadTursoDriverFactory} when the OPTIONAL driver package the
+ * selected kind needs is not installed (#5602).
+ *
+ * Carries the install command as data as well as prose, so a caller can render it
+ * however it likes, and so the pin test asserts the command rather than a sentence
+ * shape. `serve.ts` re-throws it as a fatal boot error — there is deliberately NO
+ * fallback branch: degrading a `libsql://` selection to SQLite would boot the
+ * server against an empty local file while the operator's remote data sits
+ * untouched, and every write would land in the wrong database (#3276).
+ */
+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;
+ }
+}
+
/**
* Infer a canonical driver kind from an `OS_DATABASE_URL` scheme.
* Returns `''` when the URL is absent or its scheme is unrecognized (the caller
@@ -70,11 +127,13 @@ 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). This resolver does not construct that driver, but classifying it
- // lets resolveStorageDefinition fail LOUDLY with an actionable 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).
+ // libSQL / Turso URLs classify as `turso`, and since #5602 that classification
+ // CONSTRUCTS: resolveStorageDefinition returns a `turso` definition and the CLI
+ // loads @objectstack/driver-turso (an optional package) to build it. Leaving them
+ // unrecognized — returning '' — would fall through to the SQLite default and
+ // silently ignore the remote connection, which is the bug this branch exists for;
+ // that stays true whether the driver package is installed (connect) or not (loud
+ // MissingDriverPackageError).
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';
@@ -104,6 +163,15 @@ export interface ResolveStorageDefinitionOptions {
databaseUrl?: string;
/** Dev mode — arms the sqlite step-down + the loosen-only auto-migrate (#2186). */
isDev: boolean;
+ /**
+ * `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` (`--database-auth-token`) — the
+ * libSQL/Turso JWT. Read only by the `turso` branch; every other kind carries its
+ * credentials inside the URL. It rides in the definition's `config` exactly like
+ * the postgres/mysql password rides inside `config.url`: the config reaches the
+ * driver factory and nothing else — the boot banner redacts it
+ * (`redactConnectionUrl`) and no code-origin definition is persisted.
+ */
+ authToken?: string;
}
/**
@@ -146,18 +214,18 @@ export interface StorageDefinitionResolution {
* unknown/absent driver registers no datasource, so the missing driver surfaces
* loudly downstream — the pre-#3826 behavior).
*
- * Throws {@link UnsupportedDriverError} for `turso`/libSQL — recognized as a kind
- * but not one this resolver constructs. (#4645 moved `@objectstack/driver-turso`
- * into this repo at `packages/drivers/driver-turso`; wiring it into URL inference
- * is a separate decision — #5602 — so the loud refusal stands unchanged.)
+ * Throws {@link UnsupportedDriverError} for a recognized kind that cannot become a
+ * definition at all — today only `turso`/libSQL selected with no URL to connect to.
* serve.ts surfaces that as a fatal, actionable boot error so the selection never
- * silently degrades to SQLite.
+ * silently degrades to SQLite. (The other libSQL failure — the optional driver
+ * package not installed — belongs to {@link loadTursoDriverFactory}, not here: this
+ * function is synchronous and does no I/O.)
*/
export function resolveStorageDefinition(
driverType: string,
opts: ResolveStorageDefinitionOptions,
): StorageDefinitionResolution | null {
- const { databaseUrl, isDev } = opts;
+ const { databaseUrl, isDev, authToken } = opts;
// #2186: dev-only loosen-only self-heal, honored by the factory for the SQL
// kinds. Never in production, never destructive.
const autoMigrate = isDev ? ({ autoMigrate: 'safe' } as const) : {};
@@ -227,23 +295,38 @@ export function resolveStorageDefinition(
};
}
- // 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.
- // 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 (@objectstack/driver-turso) is not one the CLI '
- + 'constructs from a URL — it is not bundled into the CLI\'s driver '
- + 'resolver. To use it, register it explicitly in your stack config (a '
- + "datasource with driver: 'turso' and config { url, authToken }, with "
- + '@objectstack/driver-turso installed). Otherwise select a CLI-resolvable '
- + 'driver via OS_DATABASE_DRIVER / OS_DATABASE_URL: '
- + 'sqlite | postgres | mysql | mongodb | memory.',
- );
+ // turso / libSQL (#5602): a real definition. The driver itself is built by
+ // loadTursoDriverFactory() from the OPTIONAL `@objectstack/driver-turso` package,
+ // injected into DefaultDatasourcePlugin as its host factory — the documented seam
+ // for "a `default` whose driver the open-core factory cannot build". The connect,
+ // the bootCritical fail-fast verdict, the OS_ALLOW_DRIVER_CONNECT_FAILURE escape
+ // hatch and the retained Setup → Datasources status are therefore identical to
+ // every other kind (#3826); only the construction differs.
+ //
+ // No `autoMigrate` passthrough: unlike the postgres/mysql/sqlite branches,
+ // `TursoDriverConfig` declares no such key, and handing it one would be a config
+ // the driver silently ignores. No `sqliteFilePath` either — the telemetry sibling
+ // is provisioned next to an on-disk SQLite primary, which a libSQL endpoint is not.
+ if (TURSO_DRIVER_KINDS.has(driverType)) {
+ const url = (databaseUrl ?? '').trim();
+ if (!url) {
+ throw new UnsupportedDriverError(
+ 'turso',
+ 'The `turso`/libSQL driver was selected (OS_DATABASE_DRIVER / --database-driver) '
+ + 'but no database URL was given, and libSQL has no default to fall back on. '
+ + 'Set OS_DATABASE_URL (or --database) to your libSQL endpoint — e.g. '
+ + 'libsql://my-db.turso.io with OS_DATABASE_AUTH_TOKEN / --database-auth-token, '
+ + 'or file:./data/objectstack.db for a local libSQL file. Booting on the SQLite '
+ + 'default instead would silently ignore the driver you asked for.',
+ );
+ }
+ return {
+ driverId: 'turso',
+ config: { url, ...(authToken ? { authToken } : {}) },
+ trackName: 'TursoDriver',
+ label: 'TursoDriver(libsql)',
+ displayUrl: url,
+ };
}
// #3276: explicit in-memory (mingo) driver. Honored in dev AND production — an
@@ -277,3 +360,120 @@ export function resolveStorageDefinition(
return null;
}
+
+/** True for the driver ids {@link loadTursoDriverFactory}'s factory builds. */
+export function isTursoDriverId(driverId: string): boolean {
+ return TURSO_DRIVER_KINDS.has(driverId.trim().toLowerCase());
+}
+
+/** The exact command an operator runs to install the optional libSQL driver. */
+export const TURSO_DRIVER_INSTALL_COMMAND = `npm install ${TURSO_DRIVER_PACKAGE}`;
+
+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 be 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 (#5602).
+ *
+ * The package is an **optional peer** of the CLI: it drags `@libsql/client`
+ * (native bindings included), so making it a hard dependency would weigh down every
+ * `npx create-objectstack` install for a backend most projects do not use. The
+ * import therefore happens here, at boot, only for a selection that actually asks
+ * for libSQL.
+ *
+ * Absent package ⇒ {@link MissingDriverPackageError}, carrying the exact install
+ * command. There is no other branch: the caller must not fall back to SQLite (see
+ * the class docstring), and the failure is raised BEFORE the plugin is registered so
+ * the operator gets one clear message instead of a connect error later in boot.
+ */
+export async function loadTursoDriverFactory(
+ opts: LoadTursoDriverFactoryOptions = {},
+): Promise {
+ const load = opts.importDriverPackage ?? (() => import('@objectstack/driver-turso'));
+
+ 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 the CLI:\n\n ${TURSO_DRIVER_INSTALL_COMMAND}\n\n`
+ + `(pnpm add ${TURSO_DRIVER_PACKAGE} / yarn add ${TURSO_DRIVER_PACKAGE}.) It is an `
+ + 'OPTIONAL peer dependency, so a default install stays free of @libsql/client. '
+ + 'The boot refuses rather than falling back to SQLite: a silent fallback would start '
+ + 'the server against an empty local database while your libSQL data stays untouched, '
+ + 'and every write would land in the wrong place. To use SQLite deliberately, set '
+ + 'OS_DATABASE_URL=file:./data/objectstack.db (or --database 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') {
+ // Resolvable but not the module we expect (a shadowing stub, a truncated
+ // install, an incompatible major that renamed its export). Same operator
+ // remedy, so the same typed error — never a fallback.
+ 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) {
+ throw new UnsupportedDriverError(
+ 'turso',
+ `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/pnpm-lock.yaml b/pnpm-lock.yaml
index 32f0d88ee4..9ff1d7b783 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -546,6 +546,9 @@ importers:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
+ '@objectstack/driver-turso':
+ specifier: workspace:*
+ version: link:../drivers/driver-turso
'@oclif/plugin-help':
specifier: ^6.2.55
version: 6.2.55