Skip to content

feat(db): add support for non-node libsql client #14204

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a66455f
feat(db): add support for non-node libsql client
Adammatthiesen Aug 8, 2025
2ddea20
feat(db): update remote database info retrieval to include mode param…
Adammatthiesen Aug 8, 2025
4311aa6
feat(db): export AstroDBConfig type alongside integration from core i…
Adammatthiesen Aug 8, 2025
6ad1ac8
test(db): ensure mode parameter is passed to getRemoteDatabaseInfo in…
Adammatthiesen Aug 8, 2025
e34acea
feat(db): refactor database client handling and remove legacy code
Adammatthiesen Aug 9, 2025
12a4379
test(db): remove mode parameter from getRemoteDatabaseInfo assertions
Adammatthiesen Aug 9, 2025
d9bd4b1
test(db): remove mode parameter from getRemoteDatabaseInfo call in tests
Adammatthiesen Aug 9, 2025
322b7cf
fix(db): ensure correct handling of 'node' mode in client module func…
Adammatthiesen Aug 9, 2025
475c624
refactor(db): consolidate utility functions by moving hasPrimaryKey t…
Adammatthiesen Aug 9, 2025
604648e
fix(db): re-export hasPrimaryKey from utils for consistency
Adammatthiesen Aug 9, 2025
4187ecc
fix(db): update import path for hasPrimaryKey to utils for consistency
Adammatthiesen Aug 9, 2025
f3572c8
fix(db): update client import to use VIRTUAL_CLIENT_MODULE_ID for con…
Adammatthiesen Aug 9, 2025
be17d1e
fix(db): remove commented export of hasPrimaryKey for clarity
Adammatthiesen Aug 9, 2025
2a4f737
fix(db): update DB_CLIENTS paths to use '@astrojs' for consistency
Adammatthiesen Aug 9, 2025
4b661ab
fix(db): update DB_CLIENTS paths to use PACKAGE_NAME for consistency
Adammatthiesen Aug 9, 2025
d00fc28
fix(db): simplify db-client export paths for consistency
Adammatthiesen Aug 9, 2025
5b2c056
fix(db): unify client creation function names across db-client modules
Adammatthiesen Aug 9, 2025
3bc6eac
fix(db): standardize client creation function names across db-client …
Adammatthiesen Aug 9, 2025
24a49b2
fix(db): rename remote database client import for consistency
Adammatthiesen Aug 9, 2025
92516ee
fix(db): change const to let for parsedUrl in createClient function
Adammatthiesen Aug 9, 2025
c9e5592
fix(db): update variable naming for consistency in createClient function
Adammatthiesen Aug 9, 2025
736b2d2
fix(test): add logging for prodDbPath and ASTRO_DB_REMOTE_URL in libs…
Adammatthiesen Aug 9, 2025
03892a8
fix(test): add logging for absoluteFileUrl in libsql-remote tests
Adammatthiesen Aug 9, 2025
95bf697
fix(db): correct variable reference for rawUrl in createClient function
Adammatthiesen Aug 9, 2025
95dcef0
fix(test): remove debug logging for prodDbPath in libsql-remote tests
Adammatthiesen Aug 9, 2025
d7795eb
fix(db): rename __execute to localExecution for clarity in virtual mo…
Adammatthiesen Aug 9, 2025
8e4e5c5
refactor(db): replace parseOpts with parseLibSQLConfig for improved c…
Adammatthiesen Aug 9, 2025
400ea39
fix(db): prevent redundant assignment for 'url' in LibSQL config parsing
Adammatthiesen Aug 9, 2025
cd848b6
refactor(db): streamline libSQL configuration handling in createClien…
Adammatthiesen Aug 9, 2025
910081a
fix(test): remove redundant 'url' field from config assertions in db-…
Adammatthiesen Aug 9, 2025
89153ec
refactor(db): enhance boolean parsing logic in LibSQL config handling
Adammatthiesen Aug 9, 2025
272ed52
Merge branch 'main' into main
Adammatthiesen Aug 10, 2025
013d98a
Merge branch 'main' into main
Adammatthiesen Aug 10, 2025
f0311bc
Apply suggestions from code review
Adammatthiesen Aug 11, 2025
9fde9ec
Apply suggestions from code review
Adammatthiesen Aug 11, 2025
5604a6f
Refactor localExecution handling in vitePluginDb and related functions
Adammatthiesen Aug 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/full-dingos-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@astrojs/db': minor
---

Adds a new libSQL web driver to support environments that require a non-Node.js libSQL client such as Cloudflare or Deno. Also adds a new `mode` configuration option to allow you to set your client connection type: `node` (default) or `web`.


The default db `node` driver mode is identical to the previous AstroDB functionality. No changes have been made to how AstroDB works in Node.js environments, and this is still the integration's default behavior. If you are currently using AstroDB, no changes to your project code are required and setting a `mode` is not required.

However, if you have previously been unable to use AstroDB because you required a non-Node.js libSQL client, you can now install and configure the libSQL web driver by setting `mode: 'web'` in your `db` configuration:

```ts
import db from '@astrojs/db';
import { defineConfig } from 'astro/config';

// https://astro.build/config
export default defineConfig({
integrations: [db({ mode: 'web' })],
});
```

For more information, see the [`@astrojs/db` documentation](https://docs.astro.build/en/guides/integrations-guide/db/).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
For more information, see the [`@astrojs/db` documentation](https://docs.astro.build/en/guides/integrations-guide/db/).
For more information, see the [`@astrojs/db` documentation](https://docs.astro.build/en/guides/integrations-guide/db/#mode).

We can come back and update this with the direct link to the mode configuration option after the docs are written! I suspect it will end up being just #mode.

1 change: 1 addition & 0 deletions packages/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"types": "./dist/runtime/index.d.ts",
"default": "./dist/runtime/index.js"
},
"./db-client/*": "./dist/core/db-client/*",
"./dist/runtime/virtual.js": {
"default": "./dist/runtime/virtual.js"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/core/cli/commands/execute/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ export async function cmd({
appToken: flags.token ?? dbInfo.token,
isBuild: false,
output: 'server',
localExecution: true,
});
} else {
virtualModContents = getLocalVirtualModContents({
tables: dbConfig.tables ?? {},
root: astroConfig.root,
localExecution: true,
});
}
const { code } = await bundleFile({ virtualModContents, root: astroConfig.root, fileUrl });
Expand Down
4 changes: 2 additions & 2 deletions packages/db/src/core/cli/commands/push/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { AstroConfig } from 'astro';
import { sql } from 'drizzle-orm';
import prompts from 'prompts';
import type { Arguments } from 'yargs-parser';
import { createRemoteDatabaseClient } from '../../../../runtime/index.js';
import { MIGRATION_VERSION } from '../../../consts.js';
import { createClient } from '../../../db-client/libsql-node.js';
import type { DBConfig, DBSnapshot } from '../../../types.js';
import { getRemoteDatabaseInfo, type RemoteDatabaseInfo } from '../../../utils.js';
import {
Expand Down Expand Up @@ -108,7 +108,7 @@ type RequestBody = {
};

async function pushToDb(requestBody: RequestBody, appToken: string, remoteUrl: string) {
const client = createRemoteDatabaseClient({
const client = createClient({
token: appToken,
url: remoteUrl,
});
Expand Down
8 changes: 3 additions & 5 deletions packages/db/src/core/cli/commands/shell/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import type { AstroConfig } from 'astro';
import { sql } from 'drizzle-orm';
import type { Arguments } from 'yargs-parser';
import {
createLocalDatabaseClient,
createRemoteDatabaseClient,
} from '../../../../runtime/db-client.js';
import { normalizeDatabaseUrl } from '../../../../runtime/index.js';
import { DB_PATH } from '../../../consts.js';
import { createClient as createLocalDatabaseClient } from '../../../db-client/libsql-local.js';
import { createClient as createRemoteDatabaseClient } from '../../../db-client/libsql-node.js';
import { SHELL_QUERY_MISSING_ERROR } from '../../../errors.js';
import type { DBConfigInput } from '../../../types.js';
import { getAstroEnv, getRemoteDatabaseInfo } from '../../../utils.js';
Expand Down Expand Up @@ -35,7 +33,7 @@ export async function cmd({
ASTRO_DATABASE_FILE,
new URL(DB_PATH, astroConfig.root).href,
);
const db = createLocalDatabaseClient({ dbUrl });
const db = createLocalDatabaseClient({ url: dbUrl });
const result = await db.run(sql.raw(query));
console.log(result);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/db/src/core/cli/migration-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { sql } from 'drizzle-orm';
import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core';
import * as color from 'kleur/colors';
import { customAlphabet } from 'nanoid';
import { createRemoteDatabaseClient, hasPrimaryKey } from '../../runtime/index.js';
import { isSerializedSQL } from '../../runtime/types.js';
import { isDbError } from '../../runtime/utils.js';
import { hasPrimaryKey, isDbError } from '../../runtime/utils.js';
import { MIGRATION_VERSION } from '../consts.js';
import { createClient } from '../db-client/libsql-node.js';
import { RENAME_COLUMN_ERROR, RENAME_TABLE_ERROR } from '../errors.js';
import {
getCreateIndexQueries,
Expand Down Expand Up @@ -434,7 +434,7 @@ async function getDbCurrentSnapshot(
appToken: string,
remoteUrl: string,
): Promise<DBSnapshot | undefined> {
const client = createRemoteDatabaseClient({
const client = createClient({
token: appToken,
url: remoteUrl,
});
Expand Down
8 changes: 8 additions & 0 deletions packages/db/src/core/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,11 @@ export const DB_PATH = '.astro/content.db';
export const CONFIG_FILE_NAMES = ['config.ts', 'config.js', 'config.mts', 'config.mjs'];

export const MIGRATION_VERSION = '2024-03-12';

export const VIRTUAL_CLIENT_MODULE_ID = 'virtual:astro:db-client';

export const DB_CLIENTS = {
node: `${PACKAGE_NAME}/db-client/libsql-node.js`,
web: `${PACKAGE_NAME}/db-client/libsql-web.js`,
local: `${PACKAGE_NAME}/db-client/libsql-local.js`,
};
15 changes: 15 additions & 0 deletions packages/db/src/core/db-client/libsql-local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createClient as createLibsqlClient } from '@libsql/client';
import { drizzle as drizzleLibsql, type LibSQLDatabase } from 'drizzle-orm/libsql';

const isWebContainer = !!process.versions?.webcontainer;

type LocalDbClientOptions = {
url: string;
};

export function createClient(options: LocalDbClientOptions): LibSQLDatabase {
const url = isWebContainer ? 'file:content.db' : options.url;
const client = createLibsqlClient({ url });
const db = drizzleLibsql(client);
return db;
}
43 changes: 43 additions & 0 deletions packages/db/src/core/db-client/libsql-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createClient as createLibsqlClient } from '@libsql/client';
import { drizzle as drizzleLibsql } from 'drizzle-orm/libsql';
import { parseLibSQLConfig } from './utils.js';

type RemoteDbClientOptions = {
token: string;
url: string;
};

export function createClient(opts: RemoteDbClientOptions) {
const { token, url: rawUrl } = opts;

let parsedUrl = new URL(rawUrl);

const options: Record<string, string> = Object.fromEntries(parsedUrl.searchParams.entries());
parsedUrl.search = '';

let url = parsedUrl.toString();
if (parsedUrl.protocol === 'memory:') {
// libSQL expects a special string in place of a URL
// for in-memory DBs.
url = ':memory:';
} else if (
parsedUrl.protocol === 'file:' &&
parsedUrl.pathname.startsWith('/') &&
!rawUrl.startsWith('file:/')
) {
// libSQL accepts relative and absolute file URLs
// for local DBs. This doesn't match the URL specification.
// Parsing `file:some.db` and `file:/some.db` should yield
// the same result, but libSQL interprets the former as
// a relative path, and the latter as an absolute path.
// This detects when such a conversion happened during parsing
// and undoes it so that the URL given to libSQL is the
// same as given by the user.
url = 'file:' + parsedUrl.pathname.substring(1);
}

const libSQLOptions = parseLibSQLConfig(options);

const client = createLibsqlClient({ ...libSQLOptions, url, authToken: token });
return drizzleLibsql(client);
}
33 changes: 33 additions & 0 deletions packages/db/src/core/db-client/libsql-web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createClient as createLibsqlClient } from '@libsql/client/web';
import { drizzle as drizzleLibsql } from 'drizzle-orm/libsql/web';
import { parseLibSQLConfig } from './utils.js';

type RemoteDbClientOptions = {
token: string;
url: string;
};

export function createClient(opts: RemoteDbClientOptions) {
const { token, url: rawUrl } = opts;

let parsedUrl = new URL(rawUrl);

const options: Record<string, string> = Object.fromEntries(parsedUrl.searchParams.entries());

parsedUrl.search = '';

let url = parsedUrl.toString();

const supportedProtocols = ['http:', 'https:', 'libsql:'];

if (!supportedProtocols.includes(parsedUrl.protocol)) {
throw new Error(
`Unsupported protocol "${parsedUrl.protocol}" for libSQL web client. Supported protocols are: ${supportedProtocols.join(', ')}.`,
);
}

const libSQLOptions = parseLibSQLConfig(options);

const client = createLibsqlClient({ ...libSQLOptions, url, authToken: token });
return drizzleLibsql(client);
}
57 changes: 57 additions & 0 deletions packages/db/src/core/db-client/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Config as LibSQLConfig } from '@libsql/client';
import z from 'zod';

const rawLibSQLOptions = z.record(z.string());

const parseNumber = (value: string) => z.coerce.number().parse(value);
const parseBoolean = (value: string) => z.coerce.boolean().parse(value);

const booleanValues = ['true', 'false'];

// parse a value that should be a boolean, but could be a valueless variable:
// e.g. 'file://local-copy.db?readYourWrites' & 'file://local-copy.db?readYourWrites=true' should be parsed as true
const parseOptionalBoolean = (value: string) => {
if (booleanValues.includes(value)) {
return parseBoolean(value);
}
return true; // If the value is not explicitly 'true' or 'false', assume it's true (valueless variable)
};

const libSQLConfigTransformed = rawLibSQLOptions.transform((raw) => {
// Ensure the URL is always present
const parsed: Partial<LibSQLConfig> = {};

// Optional fields
for (const [key, value] of Object.entries(raw)) {
switch (key) {
case 'syncInterval':
case 'concurrency':
parsed[key] = parseNumber(value);
break;
case 'readYourWrites':
case 'offline':
case 'tls':
parsed[key] = parseOptionalBoolean(value);
break;
case 'authToken':
case 'encryptionKey':
case 'syncUrl':
parsed[key] = value;
break;
}
}

// Return the parsed config
return parsed;
});

export const parseLibSQLConfig = (config: Record<string, string>): Partial<LibSQLConfig> => {
try {
return libSQLConfigTransformed.parse(config);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid LibSQL config: ${error.errors.map((e) => e.message).join(', ')}`);
}
throw error;
}
};
35 changes: 31 additions & 4 deletions packages/db/src/core/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type ViteDevServer,
} from 'vite';
import parseArgs from 'yargs-parser';
import { z } from 'zod';
import { AstroDbError, isDbError } from '../../runtime/utils.js';
import { CONFIG_FILE_NAMES, DB_PATH, VIRTUAL_MODULE_ID } from '../consts.js';
import { EXEC_DEFAULT_EXPORT_ERROR, EXEC_ERROR } from '../errors.js';
Expand All @@ -27,8 +28,29 @@ import {
type SeedHandler,
vitePluginDb,
} from './vite-plugin-db.js';
import { vitePluginDbClient } from './vite-plugin-db-client.js';

function astroDBIntegration(): AstroIntegration {
const astroDBConfigSchema = z
.object({
/**
* Sets the mode of the underlying `@libsql/client` connection.
*
* In most cases, the default 'node' mode is sufficient. On platforms like Cloudflare, or Deno, you may need to set this to 'web'.
*
* @default 'node'
*/
mode: z
.union([z.literal('node'), z.literal('web')])
.optional()
.default('node'),
})
.optional()
.default({});

export type AstroDBConfig = z.infer<typeof astroDBConfigSchema>;

function astroDBIntegration(options?: AstroDBConfig): AstroIntegration {
const resolvedConfig = astroDBConfigSchema.parse(options);
let connectToRemote = false;
let configFileDependencies: string[] = [];
let root: URL;
Expand Down Expand Up @@ -69,6 +91,11 @@ function astroDBIntegration(): AstroIntegration {
const args = parseArgs(process.argv.slice(3));
connectToRemote = process.env.ASTRO_INTERNAL_TEST_REMOTE || args['remote'];

const dbClientPlugin = vitePluginDbClient({
connectToRemote,
mode: resolvedConfig.mode,
});

if (connectToRemote) {
dbPlugin = vitePluginDb({
connectToRemote,
Expand All @@ -95,7 +122,7 @@ function astroDBIntegration(): AstroIntegration {
updateConfig({
vite: {
assetsInclude: [DB_PATH],
plugins: [dbPlugin],
plugins: [dbClientPlugin, dbPlugin],
},
});
},
Expand Down Expand Up @@ -182,8 +209,8 @@ function databaseFileEnvDefined() {
return env.ASTRO_DATABASE_FILE != null || process.env.ASTRO_DATABASE_FILE != null;
}

export function integration(): AstroIntegration[] {
return [astroDBIntegration(), fileURLIntegration()];
export function integration(options?: AstroDBConfig): AstroIntegration[] {
return [astroDBIntegration(options), fileURLIntegration()];
}

async function executeSeedFile({
Expand Down
51 changes: 51 additions & 0 deletions packages/db/src/core/integration/vite-plugin-db-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { DB_CLIENTS, VIRTUAL_CLIENT_MODULE_ID } from '../consts.js';
import type { VitePlugin } from '../utils.js';

type VitePluginDBClientParams = {
connectToRemote: boolean;
mode: 'node' | 'web';
};

function getRemoteClientModule(mode: 'node' | 'web') {
switch (mode) {
case 'web':
return `export { createClient } from '${DB_CLIENTS.web}';`;
case 'node':
default:
return `export { createClient } from '${DB_CLIENTS.node}';`;
}
}

function getLocalClientModule(mode: 'node' | 'web') {
switch (mode) {
case 'node':
case 'web':
default:
return `export { createClient } from '${DB_CLIENTS.local}';`;
}
}

const resolved = '\0' + VIRTUAL_CLIENT_MODULE_ID;

export function vitePluginDbClient(params: VitePluginDBClientParams): VitePlugin {
return {
name: 'virtual:astro:db-client',
enforce: 'pre',
async resolveId(id) {
if (id !== VIRTUAL_CLIENT_MODULE_ID) return;
return resolved;
},
async load(id) {
if (id !== resolved) return;

switch (params.connectToRemote) {
case true:
return getRemoteClientModule(params.mode);
case false:
default:
// Local client is always available, even if not used.
return getLocalClientModule(params.mode);
}
},
};
}
Loading
Loading