diff --git a/.changeset/full-dingos-repeat.md b/.changeset/full-dingos-repeat.md new file mode 100644 index 000000000000..215ebe1e3bf5 --- /dev/null +++ b/.changeset/full-dingos-repeat.md @@ -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/#mode). \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index 14bd01fb1d20..615731628523 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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" }, diff --git a/packages/db/src/core/cli/commands/execute/index.ts b/packages/db/src/core/cli/commands/execute/index.ts index 140f5f74bd13..0664e642d602 100644 --- a/packages/db/src/core/cli/commands/execute/index.ts +++ b/packages/db/src/core/cli/commands/execute/index.ts @@ -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 }); diff --git a/packages/db/src/core/cli/commands/push/index.ts b/packages/db/src/core/cli/commands/push/index.ts index d1755a56d4ef..663b648119cf 100644 --- a/packages/db/src/core/cli/commands/push/index.ts +++ b/packages/db/src/core/cli/commands/push/index.ts @@ -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 { @@ -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, }); diff --git a/packages/db/src/core/cli/commands/shell/index.ts b/packages/db/src/core/cli/commands/shell/index.ts index 941bf2296e8d..a4b61863c92e 100644 --- a/packages/db/src/core/cli/commands/shell/index.ts +++ b/packages/db/src/core/cli/commands/shell/index.ts @@ -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'; @@ -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); } diff --git a/packages/db/src/core/cli/migration-queries.ts b/packages/db/src/core/cli/migration-queries.ts index d0299df80c0e..5b662a3f3d22 100644 --- a/packages/db/src/core/cli/migration-queries.ts +++ b/packages/db/src/core/cli/migration-queries.ts @@ -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, @@ -434,7 +434,7 @@ async function getDbCurrentSnapshot( appToken: string, remoteUrl: string, ): Promise { - const client = createRemoteDatabaseClient({ + const client = createClient({ token: appToken, url: remoteUrl, }); diff --git a/packages/db/src/core/consts.ts b/packages/db/src/core/consts.ts index 8b8ccaf2d442..7cb3cf565cc2 100644 --- a/packages/db/src/core/consts.ts +++ b/packages/db/src/core/consts.ts @@ -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`, +}; diff --git a/packages/db/src/core/db-client/libsql-local.ts b/packages/db/src/core/db-client/libsql-local.ts new file mode 100644 index 000000000000..8d8f6ad3df30 --- /dev/null +++ b/packages/db/src/core/db-client/libsql-local.ts @@ -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; +} diff --git a/packages/db/src/core/db-client/libsql-node.ts b/packages/db/src/core/db-client/libsql-node.ts new file mode 100644 index 000000000000..1653d4047564 --- /dev/null +++ b/packages/db/src/core/db-client/libsql-node.ts @@ -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 = 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); +} diff --git a/packages/db/src/core/db-client/libsql-web.ts b/packages/db/src/core/db-client/libsql-web.ts new file mode 100644 index 000000000000..fce032f61e60 --- /dev/null +++ b/packages/db/src/core/db-client/libsql-web.ts @@ -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 = 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); +} diff --git a/packages/db/src/core/db-client/utils.ts b/packages/db/src/core/db-client/utils.ts new file mode 100644 index 000000000000..69e56e4ee8c5 --- /dev/null +++ b/packages/db/src/core/db-client/utils.ts @@ -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 = {}; + + // 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): Partial => { + 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; + } +}; diff --git a/packages/db/src/core/integration/index.ts b/packages/db/src/core/integration/index.ts index adaccda24470..2947a9650de3 100644 --- a/packages/db/src/core/integration/index.ts +++ b/packages/db/src/core/integration/index.ts @@ -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'; @@ -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; + +function astroDBIntegration(options?: AstroDBConfig): AstroIntegration { + const resolvedConfig = astroDBConfigSchema.parse(options); let connectToRemote = false; let configFileDependencies: string[] = []; let root: URL; @@ -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, @@ -95,7 +122,7 @@ function astroDBIntegration(): AstroIntegration { updateConfig({ vite: { assetsInclude: [DB_PATH], - plugins: [dbPlugin], + plugins: [dbClientPlugin, dbPlugin], }, }); }, @@ -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({ diff --git a/packages/db/src/core/integration/vite-plugin-db-client.ts b/packages/db/src/core/integration/vite-plugin-db-client.ts new file mode 100644 index 000000000000..3e47842fb385 --- /dev/null +++ b/packages/db/src/core/integration/vite-plugin-db-client.ts @@ -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); + } + }, + }; +} diff --git a/packages/db/src/core/integration/vite-plugin-db.ts b/packages/db/src/core/integration/vite-plugin-db.ts index 7394ab046f12..1e6bc96088b0 100644 --- a/packages/db/src/core/integration/vite-plugin-db.ts +++ b/packages/db/src/core/integration/vite-plugin-db.ts @@ -3,9 +3,16 @@ import { fileURLToPath } from 'node:url'; import type { AstroConfig, AstroIntegrationLogger } from 'astro'; import { type SQL, sql } from 'drizzle-orm'; import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; -import { createLocalDatabaseClient } from '../../runtime/db-client.js'; import { normalizeDatabaseUrl } from '../../runtime/index.js'; -import { DB_PATH, RUNTIME_IMPORT, RUNTIME_VIRTUAL_IMPORT, VIRTUAL_MODULE_ID } from '../consts.js'; +import { + DB_CLIENTS, + DB_PATH, + RUNTIME_IMPORT, + RUNTIME_VIRTUAL_IMPORT, + VIRTUAL_CLIENT_MODULE_ID, + VIRTUAL_MODULE_ID, +} from '../consts.js'; +import { createClient } from '../db-client/libsql-local.js'; import { getResolvedFileUrl } from '../load-file.js'; import { getCreateIndexQueries, getCreateTableQuery, SEED_DEV_FILE_NAME } from '../queries.js'; import type { DBTables } from '../types.js'; @@ -77,6 +84,7 @@ export function vitePluginDb(params: VitePluginDBParams): VitePlugin { tables: params.tables.get(), isBuild: command === 'build', output: params.output, + localExecution: false, }); } @@ -87,6 +95,7 @@ export function vitePluginDb(params: VitePluginDBParams): VitePlugin { return getLocalVirtualModContents({ root: params.root, tables: params.tables.get(), + localExecution: false, }); } @@ -108,6 +117,7 @@ export function vitePluginDb(params: VitePluginDBParams): VitePlugin { return getLocalVirtualModContents({ root: params.root, tables: params.tables.get(), + localExecution: false, }); }, }; @@ -117,15 +127,51 @@ export function getConfigVirtualModContents() { return `export * from ${RUNTIME_VIRTUAL_IMPORT}`; } -export function getLocalVirtualModContents({ tables, root }: { tables: DBTables; root: URL }) { +/** + * Get the module import for the DB client. + * This is used to pick which module to import based on whether + * the DB client is being used by the CLI, or in the Astro runtime. + * + * This is important for the `astro db execute` command to work correctly. + * + * @param localExecution - Whether the DB client is being used in a local execution context (e.g. CLI commands). + * @returns The module import string for the DB client. + */ +function getDBModule(localExecution: boolean) { + return localExecution + ? `import { createClient } from '${DB_CLIENTS.node}';` + : `import { createClient } from '${VIRTUAL_CLIENT_MODULE_ID}';`; +} + +export function getLocalVirtualModContents({ + tables, + root, + localExecution, +}: { + tables: DBTables; + root: URL; + /** + * Used for the execute command to import the client directly. + * In other cases, we use the runtime only vite virtual module. + * + * This is used to ensure that the client is imported correctly + * when executing commands like `astro db execute`. + */ + localExecution: boolean; +}) { const { ASTRO_DATABASE_FILE } = getAstroEnv(); - const dbInfo = getRemoteDatabaseInfo(); const dbUrl = new URL(DB_PATH, root); + + // If this is for the execute command, we need to import the client directly instead of using the runtime only virtual module. + const clientImport = getDBModule(localExecution); + return ` -import { asDrizzleTable, createLocalDatabaseClient, normalizeDatabaseUrl } from ${RUNTIME_IMPORT}; +import { asDrizzleTable, normalizeDatabaseUrl } from ${RUNTIME_IMPORT}; + +${clientImport} const dbUrl = normalizeDatabaseUrl(${JSON.stringify(ASTRO_DATABASE_FILE)}, ${JSON.stringify(dbUrl)}); -export const db = createLocalDatabaseClient({ dbUrl, enableTransactions: ${dbInfo.url === 'libsql'} }); +export const db = createClient({ url: dbUrl }); export * from ${RUNTIME_VIRTUAL_IMPORT}; @@ -137,11 +183,20 @@ export function getRemoteVirtualModContents({ appToken, isBuild, output, + localExecution, }: { tables: DBTables; appToken: string; isBuild: boolean; output: AstroConfig['output']; + /** + * Used for the execute command to import the client directly. + * In other cases, we use the runtime only vite virtual module. + * + * This is used to ensure that the client is imported correctly + * when executing commands like `astro db execute`. + */ + localExecution: boolean; }) { const dbInfo = getRemoteDatabaseInfo(); @@ -170,10 +225,15 @@ export function getRemoteVirtualModContents({ } } + // If this is for the execute command, we need to import the client directly instead of using the runtime only virtual module. + const clientImport = getDBModule(localExecution); + return ` -import {asDrizzleTable, createRemoteDatabaseClient} from ${RUNTIME_IMPORT}; +import {asDrizzleTable} from ${RUNTIME_IMPORT}; + +${clientImport} -export const db = await createRemoteDatabaseClient({ +export const db = await createClient({ url: ${dbUrlArg()}, token: ${appTokenArg()}, }); @@ -200,7 +260,7 @@ const sqlite = new SQLiteAsyncDialect(); async function recreateTables({ tables, root }: { tables: LateTables; root: URL }) { const { ASTRO_DATABASE_FILE } = getAstroEnv(); const dbUrl = normalizeDatabaseUrl(ASTRO_DATABASE_FILE, new URL(DB_PATH, root).href); - const db = createLocalDatabaseClient({ dbUrl }); + const db = createClient({ url: dbUrl }); const setupQueries: SQL[] = []; for (const [name, table] of Object.entries(tables.get() ?? {})) { const dropQuery = sql.raw(`DROP TABLE IF EXISTS ${sqlite.escapeName(name)}`); diff --git a/packages/db/src/core/queries.ts b/packages/db/src/core/queries.ts index cb483d4ef3fa..f772f802501e 100644 --- a/packages/db/src/core/queries.ts +++ b/packages/db/src/core/queries.ts @@ -7,8 +7,8 @@ import { FOREIGN_KEY_REFERENCES_LENGTH_ERROR, REFERENCE_DNE_ERROR, } from '../runtime/errors.js'; -import { hasPrimaryKey } from '../runtime/index.js'; import { isSerializedSQL } from '../runtime/types.js'; +import { hasPrimaryKey } from '../runtime/utils.js'; import type { BooleanColumn, ColumnType, diff --git a/packages/db/src/db-client.d.ts b/packages/db/src/db-client.d.ts new file mode 100644 index 000000000000..7709b30f6dfc --- /dev/null +++ b/packages/db/src/db-client.d.ts @@ -0,0 +1,4 @@ +declare module 'virtual:astro:db-client' { + export const createClient: typeof import('./core/db-client/libsql-node.ts').createRemoteLibSQLClient + +} \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9d7b176fae28..d96fd260932a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,3 +1,3 @@ export { cli } from './core/cli/index.js'; -export { integration as default } from './core/integration/index.js'; +export { type AstroDBConfig, integration as default } from './core/integration/index.js'; export type { TableConfig } from './core/types.js'; diff --git a/packages/db/src/runtime/db-client.ts b/packages/db/src/runtime/db-client.ts deleted file mode 100644 index 35cdd6e51138..000000000000 --- a/packages/db/src/runtime/db-client.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createClient, type Config as LibSQLConfig } from '@libsql/client'; -import type { LibSQLDatabase } from 'drizzle-orm/libsql'; -import { drizzle as drizzleLibsql } from 'drizzle-orm/libsql'; - -const isWebContainer = !!process.versions?.webcontainer; - -type LocalDbClientOptions = { - dbUrl: string; -}; - -export function createLocalDatabaseClient(options: LocalDbClientOptions): LibSQLDatabase { - const url = isWebContainer ? 'file:content.db' : options.dbUrl; - const client = createClient({ url }); - const db = drizzleLibsql(client); - return db; -} - -type RemoteDbClientOptions = { - token: string; - url: string | URL; -}; - -export function createRemoteDatabaseClient(options: RemoteDbClientOptions) { - const url = new URL(options.url); - - return createRemoteLibSQLClient(options.token, url, options.url.toString()); -} - -// this function parses the options from a `Record` -// provided from the object conversion of the searchParams and properly -// verifies that the Config object is providing the correct types. -// without this, there is runtime errors due to incorrect values -export function parseOpts(config: Record): Partial { - return { - ...config, - ...(config.syncInterval ? { syncInterval: parseInt(config.syncInterval) } : {}), - ...('readYourWrites' in config ? { readYourWrites: config.readYourWrites !== 'false' } : {}), - ...('offline' in config ? { offline: config.offline !== 'false' } : {}), - ...('tls' in config ? { tls: config.tls !== 'false' } : {}), - ...(config.concurrency ? { concurrency: parseInt(config.concurrency) } : {}), - }; -} - -function createRemoteLibSQLClient(authToken: string, dbURL: URL, rawUrl: string) { - const options: Record = Object.fromEntries(dbURL.searchParams.entries()); - dbURL.search = ''; - - let url = dbURL.toString(); - if (dbURL.protocol === 'memory:') { - // libSQL expects a special string in place of a URL - // for in-memory DBs. - url = ':memory:'; - } else if ( - dbURL.protocol === 'file:' && - dbURL.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:' + dbURL.pathname.substring(1); - } - - const client = createClient({ ...parseOpts(options), url, authToken }); - return drizzleLibsql(client); -} diff --git a/packages/db/src/runtime/index.ts b/packages/db/src/runtime/index.ts index 2ea7916d6dd2..dca3cacef069 100644 --- a/packages/db/src/runtime/index.ts +++ b/packages/db/src/runtime/index.ts @@ -11,14 +11,10 @@ import { } from 'drizzle-orm/sqlite-core'; import type { DBColumn, DBTable } from '../core/types.js'; import { isSerializedSQL, type SerializedSQL } from './types.js'; -import { pathToFileURL } from './utils.js'; +import { hasPrimaryKey, pathToFileURL } from './utils.js'; export type Database = LibSQLDatabase; -export { createLocalDatabaseClient, createRemoteDatabaseClient } from './db-client.js'; export type { Table } from './types.js'; - -export function hasPrimaryKey(column: DBColumn) { - return 'primaryKey' in column.schema && !!column.schema.primaryKey; -} +export { hasPrimaryKey } from './utils.js'; // Taken from: // https://stackoverflow.com/questions/52869695/check-if-a-date-string-is-in-iso-and-utc-format diff --git a/packages/db/src/runtime/utils.ts b/packages/db/src/runtime/utils.ts index af3fc66ec381..9af3f37570d5 100644 --- a/packages/db/src/runtime/utils.ts +++ b/packages/db/src/runtime/utils.ts @@ -1,5 +1,10 @@ import { LibsqlError } from '@libsql/client'; import { AstroError } from 'astro/errors'; +import type { DBColumn } from '../core/types.js'; + +export function hasPrimaryKey(column: DBColumn) { + return 'primaryKey' in column.schema && !!column.schema.primaryKey; +} const isWindows = process?.platform === 'win32'; @@ -33,4 +38,4 @@ export function pathToFileURL(path: string): URL { // Unix is easy return new URL('file://' + path); -} +} \ No newline at end of file diff --git a/packages/db/test/unit/db-client.test.js b/packages/db/test/unit/db-client.test.js index 22df2610e49a..c1c27ce97a91 100644 --- a/packages/db/test/unit/db-client.test.js +++ b/packages/db/test/unit/db-client.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert'; import test, { describe } from 'node:test'; -import { parseOpts } from '../../dist/runtime/db-client.js'; +import { parseLibSQLConfig } from '../../dist/core/db-client/utils.js'; describe('db client config', () => { test('parse config options from URL (docs example url)', () => { @@ -9,7 +9,7 @@ describe('db client config', () => { ); const options = Object.fromEntries(remoteURLToParse.searchParams.entries()); - const config = parseOpts(options); + const config = parseLibSQLConfig(options); assert.deepEqual(config, { encryptionKey: 'your-encryption-key', @@ -22,7 +22,7 @@ describe('db client config', () => { const remoteURLToParse = new URL('file://local-copy.db?readYourWrites&offline&tls'); const options = Object.fromEntries(remoteURLToParse.searchParams.entries()); - const config = parseOpts(options); + const config = parseLibSQLConfig(options); assert.deepEqual(config, { readYourWrites: true, @@ -37,7 +37,7 @@ describe('db client config', () => { ); const options = Object.fromEntries(remoteURLToParse.searchParams.entries()); - const config = parseOpts(options); + const config = parseLibSQLConfig(options); assert.deepEqual(config, { readYourWrites: true, @@ -50,7 +50,7 @@ describe('db client config', () => { const remoteURLToParse = new URL('file://local-copy.db?syncInterval=60&concurrency=2'); const options = Object.fromEntries(remoteURLToParse.searchParams.entries()); - const config = parseOpts(options); + const config = parseLibSQLConfig(options); assert.deepEqual(config, { syncInterval: 60,