Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions src/databases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,28 @@ export async function getQueryCachePath(
const cacheName = `${await nameHash(databaseName)}-${await hash(strings, ...params)}.json`;
return join(sourceDir, ".observable", "cache", cacheName);
}

/**
* Reads partial database configurations from the .observable/databases.json file.
* For security reasons, no identifiers or passwords are returned.
Copy link
Member

@mbostock mbostock Nov 20, 2025

Choose a reason for hiding this comment

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

Not sure this redaction accomplishes anything, since any process can read the .observable/databases.json file directly. I guess if we want to call this getDatabaseTypes then we could have a Map<string, string> that tells you the database types… but it’s probably just fine to load the unredacted database configs, too.

*/
export async function getDatabaseConfigs(
sourcePath: string
): Promise<Map<string, Partial<DatabaseConfig>>> {
const sourceDir = dirname(sourcePath);
const configPath = join(sourceDir, ".observable", "databases.json");
const databases = new Map<string, Partial<DatabaseConfig>>();

try {
const configs = (await json(createReadStream(configPath, "utf-8"))) as Record<
string,
DatabaseConfig
>;
for (const [name, config] of Object.entries(configs)) {
databases.set(name, {type: config.type});
}
} catch (error) {
if (!isEnoent(error)) throw error;
}
return databases;
}
211 changes: 211 additions & 0 deletions src/javascript/__snapshots__/transpile.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,216 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`does not rewrite DatabaseClient with existing options 1`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("duckdb-base", {id: 1})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`does not rewrite DatabaseClient with existing options 2`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("duckdb-base", {type: "postgres"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`rewrites DatabaseClient calls with type option 1`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("duckdb-base", {type: "duckdb"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`rewrites DatabaseClient calls with type option 2`] = `
{
"autodisplay": false,
"body": "(DatabaseClient) => {
const client = DatabaseClient("postgres-base", {type: "postgres"});
return {client};
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [
"client",
],
}
`;

exports[`rewrites DatabaseClient calls with type option 3`] = `
{
"autodisplay": false,
"body": "(DatabaseClient) => {
const x = DatabaseClient("mydb-base", {type: "sqlite"});
return {x};
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [
"x",
],
}
`;

exports[`rewrites DatabaseClient with default database names 1`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("postgres", {type: "postgres"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`rewrites DatabaseClient with default database names 2`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("duckdb", {type: "duckdb"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`rewrites DatabaseClient with default database names 3`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("sqlite", {type: "sqlite"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`rewrites DatabaseClient with file extension detection 1`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("data.duckdb", {type: "duckdb"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`rewrites DatabaseClient with file extension detection 2`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("mydata.db", {type: "sqlite"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`rewrites DatabaseClient with file extension detection 3`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("path/to/file.duckdb", {type: "duckdb"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`rewrites DatabaseClient with file extension detection 4`] = `
{
"autodisplay": true,
"body": "(DatabaseClient) => {
return (
DatabaseClient("path/to/file.db", {type: "sqlite"})
)
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [],
}
`;

exports[`transpiles DatabaseClient 1`] = `
{
"autodisplay": false,
"body": "(DatabaseClient) => {
const client = new DatabaseClient("name", {type: "duckdb"});
return {client};
}",
"inputs": [
"DatabaseClient",
],
"output": undefined,
"outputs": [
"client",
],
}
`;

exports[`transpiles JavaScript expressions 1`] = `
{
"autodisplay": true,
Expand Down
37 changes: 37 additions & 0 deletions src/javascript/databaseClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {Node} from "acorn";
import type {Sourcemap} from "./sourcemap.js";
import {getStringLiteralValue, isStringLiteral} from "./strings.js";
import {simple} from "./walk.js";
import {DatabaseConfig} from "../databases/index.js";

export function rewriteDatabaseClient(
output: Sourcemap,
body: Node,
databases: Map<string, Partial<DatabaseConfig>> = new Map()
): void {
simple(body, {
CallExpression(node) {
if (node.callee.type !== "Identifier" || node.callee.name !== "DatabaseClient") return;
if (!isStringLiteral(node.arguments[0]))
throw new Error("DatabaseClient name must be a string literal");

// if options are passed, don't change them
if (node.arguments.length !== 1) return;
Comment on lines +18 to +19
Copy link
Member

Choose a reason for hiding this comment

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

We should pass {dialect} as a third argument, and not allow more than two arguments (or fewer than one) argument, and not allow spread arguments.


const name = getStringLiteralValue(node.arguments[0]);
let type: string | undefined;
if (databases.has(name)) {
({type} = databases.get(name)!);
} else {
// see defaults in databases/index.ts ; @todo: unify?
if (name === "postgres") type = "postgres";
else if (name === "duckdb") type = "duckdb";
else if (name === "sqlite") type = "sqlite";
else if (/\.duckdb$/i.test(name)) type = "duckdb";
else if (/\.db$/i.test(name)) type = "sqlite";
else throw new Error(`database not found: ${name}`);
Copy link
Member

Choose a reason for hiding this comment

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

This needs to be a SyntaxError, not an internal Error. Ideally in the .duckdb and .db case we’d also check that the files exist (and SyntaxError if they don’t), but I’m not sure if that’s possible in this context. Or one possibility is that we find the .duckdb and .db files ahead of time when we call getDatabaseConfigs (because these files implicitly represent databases).

}
output.insertRight(node.arguments[0].end, `, {type: ${JSON.stringify(type)}}`);
}
});
}
43 changes: 43 additions & 0 deletions src/javascript/transpile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,46 @@
expect(transpile("process.stdout.write(`Node $\\{process.version}`);", "node")).toMatchSnapshot();
expect(transpile("process.stdout.write(`Node \\$\\{process.version}`);", "node")).toMatchSnapshot();
});

it("transpiles DatabaseClient", () => {
expect(transpile('const client = new DatabaseClient("name", {type: "duckdb"});', "js")).toMatchSnapshot();
});

it("rewrites DatabaseClient calls with type option", () => {
const databases = new Map([["duckdb-base", {type: "duckdb"}], ["postgres-base", {type: "postgres"}], ["mydb-base", {type: "sqlite"}]]);
expect(transpile('DatabaseClient("duckdb-base")', "js", {databases})).toMatchSnapshot();

Check failure on line 70 in src/javascript/transpile.test.ts

View workflow job for this annotation

GitHub Actions / test

Type 'Map<string, { type: string; }>' is not assignable to type 'Map<string, Partial<DatabaseConfig>>'.
expect(transpile('const client = DatabaseClient("postgres-base");', "js", {databases})).toMatchSnapshot();

Check failure on line 71 in src/javascript/transpile.test.ts

View workflow job for this annotation

GitHub Actions / test

Type 'Map<string, { type: string; }>' is not assignable to type 'Map<string, Partial<DatabaseConfig>>'.
expect(transpile('const x = DatabaseClient("mydb-base");', "js", {databases})).toMatchSnapshot();

Check failure on line 72 in src/javascript/transpile.test.ts

View workflow job for this annotation

GitHub Actions / test

Type 'Map<string, { type: string; }>' is not assignable to type 'Map<string, Partial<DatabaseConfig>>'.
});

it("does not rewrite DatabaseClient with existing options", () => {
const databases = new Map([["duckdb-base", {type: "duckdb"}]]);
expect(transpile('DatabaseClient("duckdb-base", {id: 1})', "js", {databases})).toMatchSnapshot();

Check failure on line 77 in src/javascript/transpile.test.ts

View workflow job for this annotation

GitHub Actions / test

Type 'Map<string, { type: string; }>' is not assignable to type 'Map<string, Partial<DatabaseConfig>>'.
expect(transpile('DatabaseClient("duckdb-base", {type: "postgres"})', "js", {databases})).toMatchSnapshot();

Check failure on line 78 in src/javascript/transpile.test.ts

View workflow job for this annotation

GitHub Actions / test

Type 'Map<string, { type: string; }>' is not assignable to type 'Map<string, Partial<DatabaseConfig>>'.
});

it("throws error for unknown database name", () => {
const databases = new Map([["duckdb-base", {type: "duckdb"}]]);
expect(() => transpile('DatabaseClient("unknown")', "js", {databases})).toThrow("database not found: unknown");

Check failure on line 83 in src/javascript/transpile.test.ts

View workflow job for this annotation

GitHub Actions / test

Type 'Map<string, { type: string; }>' is not assignable to type 'Map<string, Partial<DatabaseConfig>>'.
});

it("throws error for non-string-literal database name", () => {
const databases = new Map([["duckdb-base", {type: "duckdb"}]]);
expect(() => transpile("DatabaseClient(dbName)", "js", {databases})).toThrow("DatabaseClient name must be a string literal");

Check failure on line 88 in src/javascript/transpile.test.ts

View workflow job for this annotation

GitHub Actions / test

Type 'Map<string, { type: string; }>' is not assignable to type 'Map<string, Partial<DatabaseConfig>>'.
expect(() => transpile("DatabaseClient(`template-${x}`)", "js", {databases})).toThrow("DatabaseClient name must be a string literal");

Check failure on line 89 in src/javascript/transpile.test.ts

View workflow job for this annotation

GitHub Actions / test

Type 'Map<string, { type: string; }>' is not assignable to type 'Map<string, Partial<DatabaseConfig>>'.
});

it("rewrites DatabaseClient with default database names", () => {
const databases = new Map();
expect(transpile('DatabaseClient("postgres")', "js", {databases})).toMatchSnapshot();
expect(transpile('DatabaseClient("duckdb")', "js", {databases})).toMatchSnapshot();
expect(transpile('DatabaseClient("sqlite")', "js", {databases})).toMatchSnapshot();
});

it("rewrites DatabaseClient with file extension detection", () => {
const databases = new Map();
expect(transpile('DatabaseClient("data.duckdb")', "js", {databases})).toMatchSnapshot();
expect(transpile('DatabaseClient("mydata.db")', "js", {databases})).toMatchSnapshot();
expect(transpile('DatabaseClient("path/to/file.duckdb")', "js", {databases})).toMatchSnapshot();
expect(transpile('DatabaseClient("path/to/file.db")', "js", {databases})).toMatchSnapshot();
});
5 changes: 5 additions & 0 deletions src/javascript/transpile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {DatabaseConfig} from "../databases/index.js";
import type {Cell} from "../lib/notebook.js";
import {toCell} from "../lib/notebook.js";
import {rewriteDatabaseClient} from "./databaseClient.js";
import {rewriteFileExpressions} from "./files.js";
import {hasImportDeclaration} from "./imports.js";
import {rewriteImportDeclarations, rewriteImportExpressions} from "./imports.js";
Expand Down Expand Up @@ -31,6 +33,8 @@ export type TranspileOptions = {
resolveLocalImports?: boolean;
/** If true, resolve file using import.meta.url (so Vite treats it as an asset). */
resolveFiles?: boolean;
/** If present, a map of database names to their types for DatabaseClient rewriting. */
databases?: Map<string, Partial<DatabaseConfig>>;
};

/** @deprecated */
Expand Down Expand Up @@ -86,6 +90,7 @@ export function transpileJavaScript(
rewriteImportDeclarations(output, cell.body, inputs, options);
rewriteImportExpressions(output, cell.body, options);
if (options?.resolveFiles) rewriteFileExpressions(output, cell.body);
if (options?.databases) rewriteDatabaseClient(output, cell.body, options.databases);
if (cell.expression) output.insertLeft(0, `return (\n`);
output.insertLeft(0, `${async ? "async " : ""}(${inputs}) => {\n`);
if (outputs.length > 0) output.insertRight(input.length, `\nreturn {${outputs}};`);
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/stdlib/databaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface QueryOptionsSpec {
id?: number;
/** if present, results are at least as fresh as the specified date */
since?: Date | string | number;
/** if present, the type of the database */
type?: string;
}

export interface QueryOptions extends QueryOptionsSpec {
Expand All @@ -49,10 +51,11 @@ export const DatabaseClient = (name: string, options?: QueryOptionsSpec): Databa
return new DatabaseClientImpl(name, normalizeOptions(options));
};

function normalizeOptions({id, since}: QueryOptionsSpec = {}): QueryOptions {
function normalizeOptions({id, since, type}: QueryOptionsSpec = {}): QueryOptions {
const options: QueryOptions = {};
if (id !== undefined) options.id = id;
if (since !== undefined) options.since = new Date(since);
if (type !== undefined) options.type = type;
return options;
}

Expand Down
Loading
Loading