From 123da836815ec67d9eee261275d5b10e803e7f20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:23:19 +0000 Subject: [PATCH] feat(cli): infer and build libSQL/Turso from the database URL (#5602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os start --database libsql://…` is an example `os start --help` prints, and it exited 1: the URL→driver inference recognized `libsql://` and then threw UnsupportedDriverError, while runtime provisioning ranks turso first. The reason for the refusal ("the driver does not ship in the open-source distribution") died at #4645, which moved @objectstack/driver-turso into this repo. Per the maintainer's B-lite ruling: the URL now resolves to a `turso` datasource definition and the CLI dynamically imports the OPTIONAL @objectstack/driver-turso package, injecting it through DefaultDatasourcePlugin's documented host-factory seam — so the connect path, bootCritical verdict, escape hatch and retained status stay identical to every other driver (#3826). A missing package fails loudly with the exact install command; there is no SQLite fallback (#3276). `--database-auth-token` (OS_DATABASE_AUTH_TOKEN / TURSO_AUTH_TOKEN), until now forwarded and read by nobody, reaches the driver config. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ --- .changeset/cli-libsql-url-inference.md | 31 ++ content/docs/data-modeling/drivers.mdx | 21 +- content/docs/deployment/cli.mdx | 3 +- .../docs/deployment/environment-variables.mdx | 5 +- content/docs/deployment/self-hosting.mdx | 2 +- packages/cli/package.json | 9 + packages/cli/src/commands/serve.ts | 43 ++- packages/cli/src/commands/start.ts | 11 +- packages/cli/src/utils/storage-driver.test.ts | 244 +++++++++++++--- packages/cli/src/utils/storage-driver.ts | 268 +++++++++++++++--- pnpm-lock.yaml | 3 + 11 files changed, 551 insertions(+), 89 deletions(-) create mode 100644 .changeset/cli-libsql-url-inference.md 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