diff --git a/.changeset/unlucky-pumas-retire.md b/.changeset/unlucky-pumas-retire.md new file mode 100644 index 000000000000..35024ae3f792 --- /dev/null +++ b/.changeset/unlucky-pumas-retire.md @@ -0,0 +1,5 @@ +--- +"@astrojs/db": patch +--- + +Expose `isDbError()` utility to handle database exceptions when querying. diff --git a/packages/db/src/runtime/config.ts b/packages/db/src/runtime/config.ts index e4b4fcb59605..f5b57cbef434 100644 --- a/packages/db/src/runtime/config.ts +++ b/packages/db/src/runtime/config.ts @@ -9,6 +9,7 @@ import type { TableConfig, TextColumnOpts, } from '../core/types.js'; +import { LibsqlError } from '@libsql/client'; export type { LibSQLDatabase } from 'drizzle-orm/libsql'; @@ -22,6 +23,10 @@ function createColumn>(type: }; } +export function isDbError(err: unknown): err is LibsqlError { + return err instanceof LibsqlError; +} + export const column = { number: (opts: T = {} as T) => { return createColumn('number', opts) satisfies { type: 'number' }; diff --git a/packages/db/test/error-handling.test.js b/packages/db/test/error-handling.test.js new file mode 100644 index 000000000000..d67bd161b8a4 --- /dev/null +++ b/packages/db/test/error-handling.test.js @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import { loadFixture } from '../../astro/test/test-utils.js'; + +const foreignKeyConstraintError = + 'LibsqlError: SQLITE_CONSTRAINT_FOREIGNKEY: FOREIGN KEY constraint failed'; + +describe('astro:db - error handling', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/error-handling/', import.meta.url), + }); + }); + + describe('development', () => { + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Raises foreign key constraint LibsqlError', async () => { + const json = await fixture.fetch('/foreign-key-constraint.json').then((res) => res.json()); + expect(json.error).to.equal(foreignKeyConstraintError); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Raises foreign key constraint LibsqlError', async () => { + const json = await fixture.readFile('/foreign-key-constraint.json'); + expect(JSON.parse(json).error).to.equal(foreignKeyConstraintError); + }); + }); +}); diff --git a/packages/db/test/fixtures/error-handling/astro.config.ts b/packages/db/test/fixtures/error-handling/astro.config.ts new file mode 100644 index 000000000000..983a6947d115 --- /dev/null +++ b/packages/db/test/fixtures/error-handling/astro.config.ts @@ -0,0 +1,10 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db()], + devToolbar: { + enabled: false, + }, +}); diff --git a/packages/db/test/fixtures/error-handling/db/config.ts b/packages/db/test/fixtures/error-handling/db/config.ts new file mode 100644 index 000000000000..bd4d6edaf425 --- /dev/null +++ b/packages/db/test/fixtures/error-handling/db/config.ts @@ -0,0 +1,26 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const Recipe = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + title: column.text(), + description: column.text(), + }, +}); + +const Ingredient = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + name: column.text(), + quantity: column.number(), + recipeId: column.number(), + }, + indexes: { + recipeIdx: { on: 'recipeId' }, + }, + foreignKeys: [{ columns: 'recipeId', references: () => [Recipe.columns.id] }], +}); + +export default defineDb({ + tables: { Recipe, Ingredient }, +}); diff --git a/packages/db/test/fixtures/error-handling/db/seed.ts b/packages/db/test/fixtures/error-handling/db/seed.ts new file mode 100644 index 000000000000..1ca219f15bf8 --- /dev/null +++ b/packages/db/test/fixtures/error-handling/db/seed.ts @@ -0,0 +1,62 @@ +import { Ingredient, Recipe, db } from 'astro:db'; + +export default async function () { + const pancakes = await db + .insert(Recipe) + .values({ + title: 'Pancakes', + description: 'A delicious breakfast', + }) + .returning() + .get(); + + await db.insert(Ingredient).values([ + { + name: 'Flour', + quantity: 1, + recipeId: pancakes.id, + }, + { + name: 'Eggs', + quantity: 2, + recipeId: pancakes.id, + }, + { + name: 'Milk', + quantity: 1, + recipeId: pancakes.id, + }, + ]); + + const pizza = await db + .insert(Recipe) + .values({ + title: 'Pizza', + description: 'A delicious dinner', + }) + .returning() + .get(); + + await db.insert(Ingredient).values([ + { + name: 'Flour', + quantity: 1, + recipeId: pizza.id, + }, + { + name: 'Eggs', + quantity: 2, + recipeId: pizza.id, + }, + { + name: 'Milk', + quantity: 1, + recipeId: pizza.id, + }, + { + name: 'Tomato Sauce', + quantity: 1, + recipeId: pizza.id, + }, + ]); +} diff --git a/packages/db/test/fixtures/error-handling/package.json b/packages/db/test/fixtures/error-handling/package.json new file mode 100644 index 000000000000..e0839956bcd9 --- /dev/null +++ b/packages/db/test/fixtures/error-handling/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/error-handling", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/error-handling/src/pages/foreign-key-constraint.json.ts b/packages/db/test/fixtures/error-handling/src/pages/foreign-key-constraint.json.ts new file mode 100644 index 000000000000..7b0c9268ec10 --- /dev/null +++ b/packages/db/test/fixtures/error-handling/src/pages/foreign-key-constraint.json.ts @@ -0,0 +1,18 @@ +import type { APIRoute } from 'astro'; +import { db, Ingredient, isDbError } from 'astro:db'; + +export const GET: APIRoute = async () => { + try { + await db.insert(Ingredient).values({ + name: 'Flour', + quantity: 1, + // Trigger foreign key constraint error + recipeId: 42, + }); + } catch (e) { + if (isDbError(e)) { + return new Response(JSON.stringify({ error: `LibsqlError: ${e.message}` })); + } + } + return new Response(JSON.stringify({ error: 'Did not raise expected exception' })); +}; diff --git a/packages/db/virtual.d.ts b/packages/db/virtual.d.ts index d522736b2180..ff7cac3c8f14 100644 --- a/packages/db/virtual.d.ts +++ b/packages/db/virtual.d.ts @@ -11,6 +11,7 @@ declare module 'astro:db' { export const column: RuntimeConfig['column']; export const defineDb: RuntimeConfig['defineDb']; export const defineTable: RuntimeConfig['defineTable']; + export const isDbError: RuntimeConfig['isDbError']; export const eq: RuntimeConfig['eq']; export const gt: RuntimeConfig['gt']; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c2e83a4aeac..7ff391e42975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3921,6 +3921,15 @@ importers: specifier: workspace:* version: link:../../../../astro + packages/db/test/fixtures/error-handling: + dependencies: + '@astrojs/db': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../astro + packages/db/test/fixtures/integration-only: dependencies: '@astrojs/db':