Skip to content

Commit 5723e12

Browse files
committed
feat(runtime): standalone stack dispatches libsql:// through the optional Turso driver (#5820)
`detectDriverFromUrl()` refused every libSQL URL as an unsupported scheme while `resolveDatabaseUrl()` listed `TURSO_DATABASE_URL` among its URL sources — read it in, cannot dispatch it out. Since #5602 wired `libsql://` for the CLI's `os serve` / `os start`, the same `OS_DATABASE_URL=libsql://…` booted under `os start` and hard-failed under `os migrate`, which boots through this stack. - `libsql://` and `http(s)://*.turso.*` resolve to the `turso` kind — the same two spellings `inferDriverTypeFromUrl` classifies on the CLI side. - The driver comes from the OPTIONAL `@objectstack/driver-turso` package, loaded lazily in `turso-driver-factory.ts` and injected through the host driver-factory seam `DefaultDatasourcePlugin` documents for exactly this case, so connect / bootCritical verdict / escape hatch stay shared. - Package missing ⇒ loud `MissingDriverPackageError` carrying the install command as data; no SQLite fallback (#3276). - `databaseAuthToken` is consumed now (OS_DATABASE_AUTH_TOKEN, then the vendor's TURSO_AUTH_TOKEN) instead of being declared and ignored. - The file docstring's "ships separately in the ObjectStack Cloud distribution" claim expired with #4645; replaced with the facts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW
1 parent 01faeb1 commit 5723e12

6 files changed

Lines changed: 625 additions & 15 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
**`createStandaloneStack` now dispatches `libsql://` / Turso URLs** instead of refusing them as an unsupported scheme (#5820).
6+
7+
`detectDriverFromUrl()` recognised `memory://`, `postgres://`, `mongodb://` and `file:`, and threw on everything else — while `resolveDatabaseUrl()` listed `TURSO_DATABASE_URL` as one of its URL sources. A host that set it got the URL read in and then rejected on the way out. Since the CLI wired `libsql://` for `os serve` / `os start` (#5602), the same `OS_DATABASE_URL=libsql://…` booted under `os start` and failed under `os migrate`, which comes through this stack.
8+
9+
What changed:
10+
11+
- `libsql://…` and `http(s)://*.turso.…` resolve to the `turso` driver kind — the same two spellings the CLI classifies, kept identical on purpose.
12+
- `databaseDriver: 'turso'` (and `OS_DATABASE_DRIVER=turso`) is accepted by the config schema.
13+
- The driver comes from `@objectstack/driver-turso`, an **optional** install: it drags `@libsql/client` and its native bindings, so it is not a dependency of `@objectstack/runtime`. It is loaded lazily, only for a selection that asks for libSQL, and injected through the driver-factory seam `DefaultDatasourcePlugin` already exposes — so the connect path, the `bootCritical` fail-fast verdict, `OS_ALLOW_DRIVER_CONNECT_FAILURE` and the retained Setup → Datasources status are identical to every other kind.
14+
- Package missing? The boot fails **loudly**, carrying the exact install command (`npm install @objectstack/driver-turso`) as data as well as prose. There is no SQLite fallback: a silent step-down would open an empty local database while your libSQL data stays untouched, and every write — including an `os migrate` DDL — would land in the wrong place (#3276).
15+
- `databaseAuthToken` is no longer declared-and-ignored: the `turso` kind reads it, falling back to `OS_DATABASE_AUTH_TOKEN` and then the vendor's own `TURSO_AUTH_TOKEN` — the same precedence `os serve` uses.
16+
17+
Unknown schemes still throw, and the message now lists `libsql://` among the supported ones.

content/docs/data-modeling/drivers.mdx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,11 @@ Drivers can be selected in two ways:
5454

5555
<Callout type="info">
5656
**Turso / libSQL needs one extra install.** `libsql://` and `*.turso.io` URLs *are*
57-
inferred, but `@objectstack/driver-turso` is an **optional peer dependency** of the
58-
CLI — it pulls in `@libsql/client`, so it is not part of a default install:
57+
inferred — by the CLI (`os serve` / `os start` / `os dev`) and by the standalone
58+
runtime stack the one-shot commands and embedders boot through (`os migrate`,
59+
`createStandaloneStack`) alike. But `@objectstack/driver-turso` is an **optional**
60+
install — it pulls in `@libsql/client` plus native bindings, so it is not part of a
61+
default install:
5962

6063
```bash
6164
npm install @objectstack/driver-turso
@@ -76,7 +79,7 @@ libSQL data stayed untouched. Pass the token with `--database-auth-token`
7679
| **SQLite** | `@objectstack/driver-sql` (peer: `better-sqlite3`) | `SqlDriver` | `sqlite` \| `sql` |
7780
| **SQLite (WASM)** | `@objectstack/driver-sqlite-wasm` | `SqliteWasmDriver` | `sqlite-wasm` \| `wasm-sqlite` \| `wasm` |
7881
| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` (single-tenant only — see [below](#multi-tenancy-not-supported)) |
79-
| **Turso / libSQL** | `@objectstack/driver-turso` (optional peer of the CLI) | `TursoDriver` | `turso` \| `libsql` |
82+
| **Turso / libSQL** | `@objectstack/driver-turso` (optional install — see the callout above) | `TursoDriver` | `turso` \| `libsql` |
8083
| **Memory** | `@objectstack/driver-memory` | `InMemoryDriver` | `memory` |
8184

8285
> All SQL flavours (PostgreSQL / MySQL / SQLite) are served by a single
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #5820 — the standalone stack dispatches `libsql://`, on the same terms the CLI
4+
// does (#5602 / PR #5819).
5+
//
6+
// Before this, `detectDriverFromUrl()` refused every libSQL URL with
7+
// `Unsupported database URL scheme`, while `resolveDatabaseUrl()` listed
8+
// `TURSO_DATABASE_URL` as a URL SOURCE — read it in, cannot dispatch it out. The
9+
// operator-visible split was `os start` (CLI path, boots) versus `os migrate`
10+
// (this path, `Unsupported database URL scheme`) on one and the same
11+
// `OS_DATABASE_URL=libsql://…`.
12+
//
13+
// What the pins below assert, in order:
14+
// 1. detection — libSQL URLs resolve to the `turso` kind, existing schemes are
15+
// untouched, and a genuinely unknown scheme still throws;
16+
// 2. the optional package, both ways — present ⇒ a TursoDriver is built from
17+
// the definition; absent ⇒ a loud failure carrying the install command,
18+
// with no SQLite anywhere in the failure (#3276);
19+
// 3. the whole boot — `createStandaloneStack({ databaseUrl: 'libsql://…' })`
20+
// no longer produces the "unsupported scheme" refusal.
21+
//
22+
// No test here touches a real Turso endpoint: the package is substituted through
23+
// `importDriverPackage`, which is what makes the "package missing" arm testable
24+
// even in a workspace where the package happens to be installed.
25+
26+
import { describe, it, expect, afterEach } from 'vitest';
27+
import {
28+
resolveStandaloneDatabase,
29+
resolveDatabaseAuthToken,
30+
createStandaloneStack,
31+
} from './standalone-stack.js';
32+
import {
33+
loadTursoDriverFactory,
34+
MissingDriverPackageError,
35+
isTursoDriverId,
36+
TURSO_DRIVER_INSTALL_COMMAND,
37+
TURSO_DRIVER_PACKAGE,
38+
} from './turso-driver-factory.js';
39+
40+
/** Env keys these tests write; restored after every case. */
41+
const ENV_KEYS = [
42+
'OS_DATABASE_URL',
43+
'DATABASE_URL',
44+
'TURSO_DATABASE_URL',
45+
'OS_DATABASE_AUTH_TOKEN',
46+
'TURSO_AUTH_TOKEN',
47+
'OS_DATABASE_DRIVER',
48+
'OS_HOME',
49+
] as const;
50+
const ORIGINAL_ENV: Record<string, string | undefined> = Object.fromEntries(
51+
ENV_KEYS.map((k) => [k, process.env[k]]),
52+
);
53+
54+
afterEach(() => {
55+
for (const key of ENV_KEYS) {
56+
const original = ORIGINAL_ENV[key];
57+
if (original === undefined) delete process.env[key];
58+
else process.env[key] = original;
59+
}
60+
});
61+
62+
function clearUrlEnv(): void {
63+
for (const key of ENV_KEYS) delete process.env[key];
64+
}
65+
66+
describe('detectDriverFromUrl — libSQL/Turso URLs resolve to the `turso` kind (#5820)', () => {
67+
it('libsql:// resolves to turso, keeps the URL, and probes no sqlite file', () => {
68+
const r = resolveStandaloneDatabase({ databaseUrl: 'libsql://my-db.turso.io' });
69+
expect(r.driver).toBe('turso');
70+
expect(r.url).toBe('libsql://my-db.turso.io');
71+
// The occupancy probe (`os migrate`, #3917) must have nothing to say about a
72+
// remote endpoint — and must NOT read the URL as a file path.
73+
expect(r.sqliteFile).toBeNull();
74+
});
75+
76+
it('an https Turso endpoint resolves to turso — the exact spelling the CLI classifies', () => {
77+
expect(resolveStandaloneDatabase({ databaseUrl: 'https://my-db.turso.io' }).driver).toBe('turso');
78+
expect(resolveStandaloneDatabase({ databaseUrl: 'http://my-db.turso.io' }).driver).toBe('turso');
79+
});
80+
81+
// The reason this issue exists: the env var was already a URL SOURCE here.
82+
it('TURSO_DATABASE_URL now dispatches as well as resolves (the read-in/refuse-out split is gone)', () => {
83+
clearUrlEnv();
84+
process.env.TURSO_DATABASE_URL = 'libsql://from-env.turso.io';
85+
const r = resolveStandaloneDatabase();
86+
expect(r.url).toBe('libsql://from-env.turso.io');
87+
expect(r.driver).toBe('turso');
88+
});
89+
90+
it('an explicit databaseDriver: "turso" is accepted by the config schema', () => {
91+
const r = resolveStandaloneDatabase({ databaseDriver: 'turso', databaseUrl: 'libsql://explicit.turso.io' });
92+
expect(r.driver).toBe('turso');
93+
expect(r.sqliteFile).toBeNull();
94+
});
95+
96+
it('OS_DATABASE_DRIVER=turso selects the same kind', () => {
97+
clearUrlEnv();
98+
process.env.OS_DATABASE_DRIVER = 'turso';
99+
process.env.OS_DATABASE_URL = 'libsql://env-driver.turso.io';
100+
expect(resolveStandaloneDatabase().driver).toBe('turso');
101+
});
102+
});
103+
104+
describe('detectDriverFromUrl — the existing schemes are untouched (positive controls)', () => {
105+
it.each([
106+
['memory://anything', 'memory'],
107+
['postgres://user:pw@localhost:5432/db', 'postgres'],
108+
['postgresql://user:pw@localhost:5432/db', 'postgres'],
109+
['pg://user:pw@localhost:5432/db', 'postgres'],
110+
['mongodb://localhost:27017/objectstack', 'mongodb'],
111+
['mongodb+srv://cluster.example.com/db', 'mongodb'],
112+
['wasm-sqlite:///tmp/x.db', 'sqlite-wasm'],
113+
['file:/tmp/os-5820/plain.db', 'sqlite'],
114+
['/tmp/os-5820/bare-path.db', 'sqlite'],
115+
])('%s → %s', (url, kind) => {
116+
expect(resolveStandaloneDatabase({ databaseUrl: url }).driver).toBe(kind);
117+
});
118+
119+
it('an unknown scheme still throws, and the message now lists libsql', () => {
120+
expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' }))
121+
.toThrow(/Unsupported database URL scheme/);
122+
expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' }))
123+
.toThrow(/libsql:\/\//);
124+
});
125+
126+
// The turso arm is narrow on purpose: a plain https URL is not a database.
127+
it('a non-Turso https URL is still unsupported', () => {
128+
expect(() => resolveStandaloneDatabase({ databaseUrl: 'https://example.com/db' }))
129+
.toThrow(/Unsupported database URL scheme/);
130+
});
131+
});
132+
133+
describe('resolveDatabaseAuthToken — the same precedence `os serve` reads', () => {
134+
it('explicit config wins over both env vars', () => {
135+
process.env.OS_DATABASE_AUTH_TOKEN = 'from-os-env';
136+
process.env.TURSO_AUTH_TOKEN = 'from-vendor-env';
137+
expect(resolveDatabaseAuthToken({ databaseAuthToken: 'from-config' })).toBe('from-config');
138+
});
139+
140+
it('OS_DATABASE_AUTH_TOKEN (where --database-auth-token lands) wins over TURSO_AUTH_TOKEN', () => {
141+
clearUrlEnv();
142+
process.env.OS_DATABASE_AUTH_TOKEN = 'from-os-env';
143+
process.env.TURSO_AUTH_TOKEN = 'from-vendor-env';
144+
expect(resolveDatabaseAuthToken()).toBe('from-os-env');
145+
});
146+
147+
it('falls back to the vendor TURSO_AUTH_TOKEN', () => {
148+
clearUrlEnv();
149+
process.env.TURSO_AUTH_TOKEN = 'from-vendor-env';
150+
expect(resolveDatabaseAuthToken()).toBe('from-vendor-env');
151+
});
152+
153+
it('blank values are absent, not empty credentials', () => {
154+
clearUrlEnv();
155+
process.env.OS_DATABASE_AUTH_TOKEN = ' ';
156+
expect(resolveDatabaseAuthToken()).toBeUndefined();
157+
process.env.TURSO_AUTH_TOKEN = 'fallback';
158+
expect(resolveDatabaseAuthToken()).toBe('fallback');
159+
});
160+
161+
it('no source at all → undefined (so no authToken key reaches the driver config)', () => {
162+
clearUrlEnv();
163+
expect(resolveDatabaseAuthToken()).toBeUndefined();
164+
});
165+
});
166+
167+
describe('loadTursoDriverFactory — the OPTIONAL driver package, both ways (#5820)', () => {
168+
/** A stand-in for the real `@objectstack/driver-turso` module. */
169+
function stubTursoModule() {
170+
const built: Array<Record<string, unknown>> = [];
171+
class FakeTursoDriver {
172+
connected = false;
173+
disconnected = false;
174+
constructor(public readonly config: Record<string, unknown>) {
175+
built.push(config);
176+
}
177+
async connect() { this.connected = true; }
178+
async disconnect() { this.disconnected = true; }
179+
async checkHealth() { return true; }
180+
}
181+
return { built, module: { TursoDriver: FakeTursoDriver } };
182+
}
183+
184+
it('claims the turso/libsql driver ids and nothing else', async () => {
185+
const { module } = stubTursoModule();
186+
const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module });
187+
expect(factory.supports('turso')).toBe(true);
188+
expect(factory.supports('libsql')).toBe(true);
189+
expect(factory.supports('LibSQL')).toBe(true);
190+
expect(factory.supports('sqlite')).toBe(false);
191+
expect(factory.supports('memory')).toBe(false);
192+
expect(isTursoDriverId('turso')).toBe(true);
193+
expect(isTursoDriverId('sqlite')).toBe(false);
194+
});
195+
196+
// ① Package present: the definition this stack builds reaches a TursoDriver
197+
// construction with the url and the auth token. No network — the substitute
198+
// module proves the DISPATCH, which is this package's half of the contract.
199+
it('builds a TursoDriver from the stack-shaped definition (url + authToken)', async () => {
200+
const { built, module } = stubTursoModule();
201+
const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module });
202+
203+
const handle = await factory.create({
204+
name: 'default',
205+
driver: 'turso',
206+
config: { url: 'libsql://my-db.turso.io', authToken: 'jwt-token' },
207+
});
208+
209+
expect(built).toEqual([{ url: 'libsql://my-db.turso.io', authToken: 'jwt-token' }]);
210+
expect(handle.driver).toBeInstanceOf(module.TursoDriver);
211+
// Ownership left at the default `'factory'`: the instance was built for THIS
212+
// connect, so kernel teardown disconnects it.
213+
expect(handle.ownership).toBeUndefined();
214+
await handle.connect!();
215+
expect(await handle.checkHealth!()).toBe(true);
216+
await handle.disconnect!();
217+
const driver = handle.driver as { connected: boolean; disconnected: boolean };
218+
expect(driver.connected).toBe(true);
219+
expect(driver.disconnected).toBe(true);
220+
});
221+
222+
it('omits authToken entirely when none was resolved (no empty-string credential)', async () => {
223+
const { built, module } = stubTursoModule();
224+
const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module });
225+
await factory.create({ name: 'default', driver: 'turso', config: { url: 'file:./data/local.db' } });
226+
expect(built).toEqual([{ url: 'file:./data/local.db' }]);
227+
});
228+
229+
// ② Package absent: LOUD failure carrying the exact install command, and no
230+
// fallback of any kind.
231+
it('fails loudly with the exact install command when the package is missing', async () => {
232+
const err = await loadTursoDriverFactory({
233+
importDriverPackage: async () => { throw new Error("Cannot find module '@objectstack/driver-turso'"); },
234+
}).then(() => null, (e: unknown) => e);
235+
236+
expect(err).toBeInstanceOf(MissingDriverPackageError);
237+
const missing = err as MissingDriverPackageError;
238+
expect(missing.driverType).toBe('turso');
239+
expect(missing.packageName).toBe(TURSO_DRIVER_PACKAGE);
240+
expect(missing.installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND);
241+
expect(missing.installCommand).toBe('npm install @objectstack/driver-turso');
242+
// The message states the command, the consequence, and the deliberate refusal.
243+
expect(missing.message).toContain('npm install @objectstack/driver-turso');
244+
expect(missing.message).toMatch(/OPTIONAL package/);
245+
expect(missing.message).toMatch(/refuses rather than falling back to SQLite/i);
246+
expect(missing.message).toMatch(/os migrate/);
247+
// The underlying resolution error is kept: an operator debugging a broken
248+
// install needs it, and swallowing it is how "not installed" hides
249+
// "installed but crashed on import".
250+
expect(missing.message).toContain("Cannot find module '@objectstack/driver-turso'");
251+
});
252+
253+
it('offers NO silent SQLite fallback when the package is missing', async () => {
254+
const attempt = await loadTursoDriverFactory({
255+
importDriverPackage: async () => { throw new Error('boom'); },
256+
}).then((f) => ({ ok: true as const, f }), (e: unknown) => ({ ok: false as const, e }));
257+
258+
expect(attempt.ok).toBe(false);
259+
expect((attempt as { e: Error }).e).toBeInstanceOf(MissingDriverPackageError);
260+
expect((attempt as { e: Error }).e.message).not.toMatch(/falling back to sqlite instead|using sqlite/i);
261+
// …and the kind the stack resolved is still turso: nothing rewrites it to
262+
// sqlite on the way out.
263+
expect(resolveStandaloneDatabase({ databaseUrl: 'libsql://my-db.turso.io' }).driver).toBe('turso');
264+
});
265+
266+
it('rejects a resolvable module that exports no TursoDriver', async () => {
267+
const err = await loadTursoDriverFactory({
268+
importDriverPackage: async () => ({ notTheDriver: true }),
269+
}).then(() => null, (e: unknown) => e);
270+
expect(err).toBeInstanceOf(MissingDriverPackageError);
271+
expect((err as Error).message).toMatch(/exports no TursoDriver/);
272+
expect((err as MissingDriverPackageError).installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND);
273+
});
274+
275+
it('accepts a CJS-shaped module whose driver hangs off `default`', async () => {
276+
const { module } = stubTursoModule();
277+
const factory = await loadTursoDriverFactory({ importDriverPackage: async () => ({ default: module }) });
278+
const handle = await factory.create({ name: 'default', driver: 'turso', config: { url: 'libsql://x.turso.io' } });
279+
expect(handle.driver).toBeInstanceOf(module.TursoDriver);
280+
});
281+
282+
it('refuses to build a driver from a config with no url', async () => {
283+
const { module } = stubTursoModule();
284+
const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module });
285+
expect(() => factory.create({ name: 'default', driver: 'turso', config: {} }))
286+
.toThrow(/needs a libSQL url/);
287+
});
288+
});
289+
290+
// ③ The whole boot, on the URL the issue is about. `@objectstack/driver-turso`
291+
// is deliberately NOT a dependency of `@objectstack/runtime` — that is what
292+
// "optional" means here — so in this workspace the boot takes the missing-package
293+
// arm. What matters either way is the FIRST assertion: the refusal is no longer
294+
// "unsupported scheme". (Should the package ever become a dependency of this one,
295+
// this case turns red and names exactly why in this comment.)
296+
describe('createStandaloneStack — a libsql:// boot is dispatched, not refused as unknown (#5820)', () => {
297+
it('fails with the install command instead of "Unsupported database URL scheme"', async () => {
298+
clearUrlEnv();
299+
const err = await createStandaloneStack({ databaseUrl: 'libsql://my-db.turso.io' })
300+
.then(() => null, (e: unknown) => e);
301+
302+
expect(err).not.toBeNull();
303+
expect(String((err as Error).message)).not.toMatch(/Unsupported database URL scheme/);
304+
expect(err).toBeInstanceOf(MissingDriverPackageError);
305+
expect((err as MissingDriverPackageError).installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND);
306+
}, 60_000);
307+
308+
// The control on the same path: an unknown scheme is still refused as unknown,
309+
// so the new arm did not turn the throw into a catch-all. `os migrate`'s e2e
310+
// exit-code test pins this message from the CLI end.
311+
it('still refuses a genuinely unknown scheme', async () => {
312+
clearUrlEnv();
313+
await expect(createStandaloneStack({ databaseUrl: 'wat://nope' }))
314+
.rejects.toThrow(/Unsupported database URL scheme/);
315+
}, 60_000);
316+
});

0 commit comments

Comments
 (0)