aboutsummaryrefslogtreecommitdiff
path: root/packages/db/test
diff options
context:
space:
mode:
Diffstat (limited to 'packages/db/test')
-rw-r--r--packages/db/test/basics.test.js205
-rw-r--r--packages/db/test/db-in-src.test.js38
-rw-r--r--packages/db/test/error-handling.test.js57
-rw-r--r--packages/db/test/fixtures/basics/astro.config.ts10
-rw-r--r--packages/db/test/fixtures/basics/db/config.ts29
-rw-r--r--packages/db/test/fixtures/basics/db/seed.ts24
-rw-r--r--packages/db/test/fixtures/basics/db/theme.ts15
-rw-r--r--packages/db/test/fixtures/basics/package.json14
-rw-r--r--packages/db/test/fixtures/basics/src/pages/index.astro27
-rw-r--r--packages/db/test/fixtures/basics/src/pages/login.astro18
-rw-r--r--packages/db/test/fixtures/basics/src/pages/run.json.ts12
-rw-r--r--packages/db/test/fixtures/db-in-src/astro.config.ts10
-rw-r--r--packages/db/test/fixtures/db-in-src/db/config.ts13
-rw-r--r--packages/db/test/fixtures/db-in-src/db/seed.ts8
-rw-r--r--packages/db/test/fixtures/db-in-src/package.json14
-rw-r--r--packages/db/test/fixtures/db-in-src/pages/index.astro11
-rw-r--r--packages/db/test/fixtures/error-handling/astro.config.ts10
-rw-r--r--packages/db/test/fixtures/error-handling/db/config.ts26
-rw-r--r--packages/db/test/fixtures/error-handling/db/seed.ts62
-rw-r--r--packages/db/test/fixtures/error-handling/package.json14
-rw-r--r--packages/db/test/fixtures/error-handling/src/pages/foreign-key-constraint.json.ts18
-rw-r--r--packages/db/test/fixtures/integration-only/astro.config.mjs8
-rw-r--r--packages/db/test/fixtures/integration-only/integration/config.ts8
-rw-r--r--packages/db/test/fixtures/integration-only/integration/index.ts15
-rw-r--r--packages/db/test/fixtures/integration-only/integration/seed.ts14
-rw-r--r--packages/db/test/fixtures/integration-only/integration/shared.ts9
-rw-r--r--packages/db/test/fixtures/integration-only/package.json14
-rw-r--r--packages/db/test/fixtures/integration-only/src/pages/index.astro11
-rw-r--r--packages/db/test/fixtures/integrations/astro.config.mjs8
-rw-r--r--packages/db/test/fixtures/integrations/db/config.ts12
-rw-r--r--packages/db/test/fixtures/integrations/db/seed.ts13
-rw-r--r--packages/db/test/fixtures/integrations/integration/config.ts8
-rw-r--r--packages/db/test/fixtures/integrations/integration/index.ts15
-rw-r--r--packages/db/test/fixtures/integrations/integration/seed.ts14
-rw-r--r--packages/db/test/fixtures/integrations/integration/shared.ts9
-rw-r--r--packages/db/test/fixtures/integrations/package.json14
-rw-r--r--packages/db/test/fixtures/integrations/src/pages/index.astro17
-rw-r--r--packages/db/test/fixtures/libsql-remote/astro.config.ts10
-rw-r--r--packages/db/test/fixtures/libsql-remote/db/config.ts13
-rw-r--r--packages/db/test/fixtures/libsql-remote/db/seed.ts7
-rw-r--r--packages/db/test/fixtures/libsql-remote/package.json14
-rw-r--r--packages/db/test/fixtures/libsql-remote/src/pages/index.astro11
-rw-r--r--packages/db/test/fixtures/local-prod/astro.config.ts10
-rw-r--r--packages/db/test/fixtures/local-prod/db/config.ts13
-rw-r--r--packages/db/test/fixtures/local-prod/db/seed.ts8
-rw-r--r--packages/db/test/fixtures/local-prod/package.json14
-rw-r--r--packages/db/test/fixtures/local-prod/src/pages/index.astro11
-rw-r--r--packages/db/test/fixtures/no-apptoken/astro.config.ts10
-rw-r--r--packages/db/test/fixtures/no-apptoken/db/config.ts13
-rw-r--r--packages/db/test/fixtures/no-apptoken/db/seed.ts1
-rw-r--r--packages/db/test/fixtures/no-apptoken/package.json14
-rw-r--r--packages/db/test/fixtures/no-apptoken/src/pages/index.astro16
-rw-r--r--packages/db/test/fixtures/no-seed/astro.config.ts7
-rw-r--r--packages/db/test/fixtures/no-seed/db/config.ts12
-rw-r--r--packages/db/test/fixtures/no-seed/package.json14
-rw-r--r--packages/db/test/fixtures/no-seed/src/pages/index.astro21
-rw-r--r--packages/db/test/fixtures/recipes/astro.config.ts6
-rw-r--r--packages/db/test/fixtures/recipes/db/config.ts26
-rw-r--r--packages/db/test/fixtures/recipes/db/seed.ts62
-rw-r--r--packages/db/test/fixtures/recipes/package.json16
-rw-r--r--packages/db/test/fixtures/recipes/src/pages/index.astro25
-rw-r--r--packages/db/test/fixtures/static-remote/astro.config.ts6
-rw-r--r--packages/db/test/fixtures/static-remote/db/config.ts12
-rw-r--r--packages/db/test/fixtures/static-remote/db/seed.ts9
-rw-r--r--packages/db/test/fixtures/static-remote/package.json16
-rw-r--r--packages/db/test/fixtures/static-remote/src/pages/index.astro19
-rw-r--r--packages/db/test/fixtures/static-remote/src/pages/run.astro17
-rw-r--r--packages/db/test/fixtures/ticketing-example/.gitignore24
-rw-r--r--packages/db/test/fixtures/ticketing-example/README.md54
-rw-r--r--packages/db/test/fixtures/ticketing-example/astro.config.ts14
-rw-r--r--packages/db/test/fixtures/ticketing-example/db/config.ts27
-rw-r--r--packages/db/test/fixtures/ticketing-example/db/seed.ts12
-rw-r--r--packages/db/test/fixtures/ticketing-example/package.json26
-rw-r--r--packages/db/test/fixtures/ticketing-example/public/favicon.svg9
-rw-r--r--packages/db/test/fixtures/ticketing-example/src/components/Form.tsx119
-rw-r--r--packages/db/test/fixtures/ticketing-example/src/layouts/Layout.astro80
-rw-r--r--packages/db/test/fixtures/ticketing-example/src/pages/[event]/_Ticket.tsx40
-rw-r--r--packages/db/test/fixtures/ticketing-example/src/pages/[event]/index.astro50
-rw-r--r--packages/db/test/fixtures/ticketing-example/src/pages/index.astro17
-rw-r--r--packages/db/test/fixtures/ticketing-example/tsconfig.json9
-rw-r--r--packages/db/test/integration-only.test.js48
-rw-r--r--packages/db/test/integrations.test.js67
-rw-r--r--packages/db/test/libsql-remote.test.js77
-rw-r--r--packages/db/test/local-prod.test.js89
-rw-r--r--packages/db/test/no-seed.test.js48
-rw-r--r--packages/db/test/ssr-no-apptoken.test.js37
-rw-r--r--packages/db/test/static-remote.test.js70
-rw-r--r--packages/db/test/test-utils.js172
-rw-r--r--packages/db/test/unit/column-queries.test.js496
-rw-r--r--packages/db/test/unit/db-client.test.js60
-rw-r--r--packages/db/test/unit/index-queries.test.js283
-rw-r--r--packages/db/test/unit/reference-queries.test.js169
-rw-r--r--packages/db/test/unit/remote-info.test.js119
-rw-r--r--packages/db/test/unit/reset-queries.test.js54
94 files changed, 3527 insertions, 0 deletions
diff --git a/packages/db/test/basics.test.js b/packages/db/test/basics.test.js
new file mode 100644
index 000000000..8d6167447
--- /dev/null
+++ b/packages/db/test/basics.test.js
@@ -0,0 +1,205 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { load as cheerioLoad } from 'cheerio';
+import testAdapter from '../../astro/test/test-adapter.js';
+import { loadFixture } from '../../astro/test/test-utils.js';
+import { clearEnvironment, setupRemoteDbServer } from './test-utils.js';
+
+describe('astro:db', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/basics/', import.meta.url),
+ output: 'server',
+ adapter: testAdapter(),
+ });
+ });
+
+ describe({ skip: process.platform === 'darwin' }, 'development', () => {
+ let devServer;
+
+ before(async () => {
+ clearEnvironment();
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('Prints the list of authors', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const ul = $('.authors-list');
+ assert.equal(ul.children().length, 5);
+ assert.match(ul.children().eq(0).text(), /Ben/);
+ });
+
+ it('Allows expression defaults for date columns', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const themeAdded = $($('.themes-list .theme-added')[0]).text();
+ assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false);
+ });
+
+ it('Defaults can be overridden for dates', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const themeAdded = $($('.themes-list .theme-added')[1]).text();
+ assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false);
+ });
+
+ it('Allows expression defaults for text columns', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const themeOwner = $($('.themes-list .theme-owner')[0]).text();
+ assert.equal(themeOwner, '');
+ });
+
+ it('Allows expression defaults for boolean columns', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const themeDark = $($('.themes-list .theme-dark')[0]).text();
+ assert.match(themeDark, /dark mode/);
+ });
+
+ it('text fields an be used as references', async () => {
+ const html = await fixture.fetch('/login').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ assert.match($('.session-id').text(), /12345/);
+ assert.match($('.username').text(), /Mario/);
+ });
+
+ it('Prints authors from raw sql call', async () => {
+ const json = await fixture.fetch('run.json').then((res) => res.json());
+ assert.deepEqual(json, {
+ columns: ['_id', 'name', 'age2'],
+ columnTypes: ['INTEGER', 'TEXT', 'INTEGER'],
+ rows: [
+ [1, 'Ben', null],
+ [2, 'Nate', null],
+ [3, 'Erika', null],
+ [4, 'Bjorn', null],
+ [5, 'Sarah', null],
+ ],
+ rowsAffected: 0,
+ lastInsertRowid: null,
+ });
+ });
+ });
+
+ describe({ skip: process.platform === 'darwin' }, 'development --remote', () => {
+ let devServer;
+ let remoteDbServer;
+
+ before(async () => {
+ clearEnvironment();
+ remoteDbServer = await setupRemoteDbServer(fixture.config);
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer?.stop();
+ await remoteDbServer?.stop();
+ });
+
+ it('Prints the list of authors', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const ul = $('.authors-list');
+ assert.equal(ul.children().length, 5);
+ assert.match(ul.children().eq(0).text(), /Ben/);
+ });
+
+ it('Allows expression defaults for date columns', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const themeAdded = $($('.themes-list .theme-added')[0]).text();
+ assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false);
+ });
+
+ it('Defaults can be overridden for dates', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const themeAdded = $($('.themes-list .theme-added')[1]).text();
+ assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false);
+ });
+
+ it('Allows expression defaults for text columns', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const themeOwner = $($('.themes-list .theme-owner')[0]).text();
+ assert.equal(themeOwner, '');
+ });
+
+ it('Allows expression defaults for boolean columns', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const themeDark = $($('.themes-list .theme-dark')[0]).text();
+ assert.match(themeDark, /dark mode/);
+ });
+
+ it('text fields an be used as references', async () => {
+ const html = await fixture.fetch('/login').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ assert.match($('.session-id').text(), /12345/);
+ assert.match($('.username').text(), /Mario/);
+ });
+
+ it('Prints authors from raw sql call', async () => {
+ const json = await fixture.fetch('run.json').then((res) => res.json());
+ assert.deepEqual(json, {
+ columns: ['_id', 'name', 'age2'],
+ columnTypes: ['INTEGER', 'TEXT', 'INTEGER'],
+ rows: [
+ [1, 'Ben', null],
+ [2, 'Nate', null],
+ [3, 'Erika', null],
+ [4, 'Bjorn', null],
+ [5, 'Sarah', null],
+ ],
+ rowsAffected: 0,
+ lastInsertRowid: null,
+ });
+ });
+ });
+
+ describe('build --remote', () => {
+ let remoteDbServer;
+
+ before(async () => {
+ clearEnvironment();
+ process.env.ASTRO_STUDIO_APP_TOKEN = 'some token';
+ remoteDbServer = await setupRemoteDbServer(fixture.config);
+ await fixture.build();
+ });
+
+ after(async () => {
+ process.env.ASTRO_STUDIO_APP_TOKEN = '';
+ await remoteDbServer?.stop();
+ });
+
+ it('Can render page', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerioLoad(html);
+
+ const ul = $('.authors-list');
+ assert.equal(ul.children().length, 5);
+ });
+ });
+});
diff --git a/packages/db/test/db-in-src.test.js b/packages/db/test/db-in-src.test.js
new file mode 100644
index 000000000..5e29b7372
--- /dev/null
+++ b/packages/db/test/db-in-src.test.js
@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { load as cheerioLoad } from 'cheerio';
+import testAdapter from '../../astro/test/test-adapter.js';
+import { loadFixture } from '../../astro/test/test-utils.js';
+
+describe('astro:db', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/db-in-src/', import.meta.url),
+ output: 'server',
+ srcDir: '.',
+ adapter: testAdapter(),
+ });
+ });
+
+ describe('development: db/ folder inside srcDir', () => {
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('Prints the list of authors', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const ul = $('.users-list');
+ assert.equal(ul.children().length, 1);
+ assert.match($('.users-list li').text(), /Mario/);
+ });
+ });
+});
diff --git a/packages/db/test/error-handling.test.js b/packages/db/test/error-handling.test.js
new file mode 100644
index 000000000..5ca9ce5c2
--- /dev/null
+++ b/packages/db/test/error-handling.test.js
@@ -0,0 +1,57 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { loadFixture } from '../../astro/test/test-utils.js';
+import { setupRemoteDbServer } from './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());
+ assert.deepEqual(json, {
+ message: foreignKeyConstraintError,
+ code: 'SQLITE_CONSTRAINT_FOREIGNKEY',
+ });
+ });
+ });
+
+ describe('build --remote', () => {
+ let remoteDbServer;
+
+ before(async () => {
+ remoteDbServer = await setupRemoteDbServer(fixture.config);
+ await fixture.build();
+ });
+
+ after(async () => {
+ await remoteDbServer?.stop();
+ });
+
+ it('Raises foreign key constraint LibsqlError', async () => {
+ const json = await fixture.readFile('/foreign-key-constraint.json');
+ assert.deepEqual(JSON.parse(json), {
+ message: foreignKeyConstraintError,
+ code: 'SQLITE_CONSTRAINT_FOREIGNKEY',
+ });
+ });
+ });
+});
diff --git a/packages/db/test/fixtures/basics/astro.config.ts b/packages/db/test/fixtures/basics/astro.config.ts
new file mode 100644
index 000000000..983a6947d
--- /dev/null
+++ b/packages/db/test/fixtures/basics/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/basics/db/config.ts b/packages/db/test/fixtures/basics/db/config.ts
new file mode 100644
index 000000000..010ed3a18
--- /dev/null
+++ b/packages/db/test/fixtures/basics/db/config.ts
@@ -0,0 +1,29 @@
+import { column, defineDb, defineTable } from 'astro:db';
+import { Themes } from './theme';
+
+const Author = defineTable({
+ columns: {
+ name: column.text(),
+ age2: column.number({ optional: true }),
+ },
+});
+
+const User = defineTable({
+ columns: {
+ id: column.text({ primaryKey: true, optional: false }),
+ username: column.text({ optional: false, unique: true }),
+ password: column.text({ optional: false }),
+ },
+});
+
+const Session = defineTable({
+ columns: {
+ id: column.text({ primaryKey: true, optional: false }),
+ expiresAt: column.number({ optional: false, name: 'expires_at' }),
+ userId: column.text({ optional: false, references: () => User.columns.id, name: 'user_id' }),
+ },
+});
+
+export default defineDb({
+ tables: { Author, Themes, User, Session },
+});
diff --git a/packages/db/test/fixtures/basics/db/seed.ts b/packages/db/test/fixtures/basics/db/seed.ts
new file mode 100644
index 000000000..9a1ef4322
--- /dev/null
+++ b/packages/db/test/fixtures/basics/db/seed.ts
@@ -0,0 +1,24 @@
+import { Author, Session, User, db } from 'astro:db';
+import { asDrizzleTable } from '@astrojs/db/utils';
+import { Themes as ThemesConfig } from './theme';
+
+const Themes = asDrizzleTable('Themes', ThemesConfig);
+export default async function () {
+ await db.batch([
+ db
+ .insert(Themes)
+ .values([{ name: 'dracula' }, { name: 'monokai', added: new Date() }])
+ .returning({ name: Themes.name }),
+ db
+ .insert(Author)
+ .values([
+ { name: 'Ben' },
+ { name: 'Nate' },
+ { name: 'Erika' },
+ { name: 'Bjorn' },
+ { name: 'Sarah' },
+ ]),
+ db.insert(User).values([{ id: 'mario', username: 'Mario', password: 'itsame' }]),
+ db.insert(Session).values([{ id: '12345', expiresAt: new Date().valueOf(), userId: 'mario' }]),
+ ]);
+}
diff --git a/packages/db/test/fixtures/basics/db/theme.ts b/packages/db/test/fixtures/basics/db/theme.ts
new file mode 100644
index 000000000..015dcc588
--- /dev/null
+++ b/packages/db/test/fixtures/basics/db/theme.ts
@@ -0,0 +1,15 @@
+import { NOW, column, defineTable, sql } from 'astro:db';
+
+export const Themes = defineTable({
+ columns: {
+ name: column.text(),
+ added: column.date({
+ default: sql`CURRENT_TIMESTAMP`,
+ }),
+ updated: column.date({
+ default: NOW,
+ }),
+ isDark: column.boolean({ default: sql`TRUE`, deprecated: true }),
+ owner: column.text({ optional: true, default: sql`NULL` }),
+ },
+});
diff --git a/packages/db/test/fixtures/basics/package.json b/packages/db/test/fixtures/basics/package.json
new file mode 100644
index 000000000..af7cbe229
--- /dev/null
+++ b/packages/db/test/fixtures/basics/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@test/db-aliases",
+ "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/basics/src/pages/index.astro b/packages/db/test/fixtures/basics/src/pages/index.astro
new file mode 100644
index 000000000..2be0c4b23
--- /dev/null
+++ b/packages/db/test/fixtures/basics/src/pages/index.astro
@@ -0,0 +1,27 @@
+---
+/// <reference path="../../.astro/db-types.d.ts" />
+import { Author, Themes, db } from 'astro:db';
+
+const authors = await db.select().from(Author);
+const themes = await db.select().from(Themes);
+---
+
+<h2>Authors</h2>
+<ul class="authors-list">
+ {authors.map((author) => <li>{author.name}</li>)}
+</ul>
+
+<h2>Themes</h2>
+<ul class="themes-list">
+ {
+ themes.map((theme) => (
+ <li>
+ <div class="theme-name">{theme.name}</div>
+ <div class="theme-added">{theme.added}</div>
+ <div class="theme-updated">{theme.updated}</div>
+ <div class="theme-dark">{theme.isDark ? 'dark' : 'light'} mode</div>
+ <div class="theme-owner">{theme.owner}</div>
+ </li>
+ ))
+ }
+</ul>
diff --git a/packages/db/test/fixtures/basics/src/pages/login.astro b/packages/db/test/fixtures/basics/src/pages/login.astro
new file mode 100644
index 000000000..4551fc483
--- /dev/null
+++ b/packages/db/test/fixtures/basics/src/pages/login.astro
@@ -0,0 +1,18 @@
+---
+import { Session, User, db, eq } from 'astro:db';
+
+const users = await db.select().from(User);
+const sessions = await db.select().from(Session).innerJoin(User, eq(Session.userId, User.id));
+---
+
+<h2>Sessions</h2>
+<ul class="sessions-list">
+ {
+ sessions.map(({ Session, User }) => (
+ <li>
+ <div class="session-id">{Session.id}</div>
+ <div class="username">{User.username}</div>
+ </li>
+ ))
+ }
+</ul>
diff --git a/packages/db/test/fixtures/basics/src/pages/run.json.ts b/packages/db/test/fixtures/basics/src/pages/run.json.ts
new file mode 100644
index 000000000..a86619314
--- /dev/null
+++ b/packages/db/test/fixtures/basics/src/pages/run.json.ts
@@ -0,0 +1,12 @@
+import { db, sql } from 'astro:db';
+/// <reference types="@astrojs/db" />
+import type { APIRoute } from 'astro';
+
+export const GET: APIRoute = async () => {
+ const authors = await db.run(sql`SELECT * FROM Author`);
+ return new Response(JSON.stringify(authors), {
+ headers: {
+ 'content-type': 'application/json',
+ },
+ });
+};
diff --git a/packages/db/test/fixtures/db-in-src/astro.config.ts b/packages/db/test/fixtures/db-in-src/astro.config.ts
new file mode 100644
index 000000000..983a6947d
--- /dev/null
+++ b/packages/db/test/fixtures/db-in-src/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/db-in-src/db/config.ts b/packages/db/test/fixtures/db-in-src/db/config.ts
new file mode 100644
index 000000000..44c15abe7
--- /dev/null
+++ b/packages/db/test/fixtures/db-in-src/db/config.ts
@@ -0,0 +1,13 @@
+import { column, defineDb, defineTable } from 'astro:db';
+
+const User = defineTable({
+ columns: {
+ id: column.text({ primaryKey: true, optional: false }),
+ username: column.text({ optional: false, unique: true }),
+ password: column.text({ optional: false }),
+ },
+});
+
+export default defineDb({
+ tables: { User },
+});
diff --git a/packages/db/test/fixtures/db-in-src/db/seed.ts b/packages/db/test/fixtures/db-in-src/db/seed.ts
new file mode 100644
index 000000000..a84e63454
--- /dev/null
+++ b/packages/db/test/fixtures/db-in-src/db/seed.ts
@@ -0,0 +1,8 @@
+import { User, db } from 'astro:db';
+import { asDrizzleTable } from '@astrojs/db/utils';
+
+export default async function () {
+ await db.batch([
+ db.insert(User).values([{ id: 'mario', username: 'Mario', password: 'itsame' }]),
+ ]);
+}
diff --git a/packages/db/test/fixtures/db-in-src/package.json b/packages/db/test/fixtures/db-in-src/package.json
new file mode 100644
index 000000000..a1580d1cb
--- /dev/null
+++ b/packages/db/test/fixtures/db-in-src/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@test/db-db-in-src",
+ "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/db-in-src/pages/index.astro b/packages/db/test/fixtures/db-in-src/pages/index.astro
new file mode 100644
index 000000000..4b79dba2c
--- /dev/null
+++ b/packages/db/test/fixtures/db-in-src/pages/index.astro
@@ -0,0 +1,11 @@
+---
+/// <reference path="../.astro/db-types.d.ts" />
+import { User, db } from 'astro:db';
+
+const users = await db.select().from(User);
+---
+
+<h2>Users</h2>
+<ul class="users-list">
+ {users.map((user) => <li>{user.username}</li>)}
+</ul>
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 000000000..983a6947d
--- /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 000000000..bd4d6edaf
--- /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 000000000..1ca219f15
--- /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 000000000..e0839956b
--- /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 000000000..358a9a95c
--- /dev/null
+++ b/packages/db/test/fixtures/error-handling/src/pages/foreign-key-constraint.json.ts
@@ -0,0 +1,18 @@
+import { Ingredient, db, isDbError } from 'astro:db';
+import type { APIRoute } from 'astro';
+
+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({ message: `LibsqlError: ${e.message}`, code: e.code }));
+ }
+ }
+ return new Response(JSON.stringify({ message: 'Did not raise expected exception' }));
+};
diff --git a/packages/db/test/fixtures/integration-only/astro.config.mjs b/packages/db/test/fixtures/integration-only/astro.config.mjs
new file mode 100644
index 000000000..23f52739e
--- /dev/null
+++ b/packages/db/test/fixtures/integration-only/astro.config.mjs
@@ -0,0 +1,8 @@
+import db from '@astrojs/db';
+import { defineConfig } from 'astro/config';
+import testIntegration from './integration';
+
+// https://astro.build/config
+export default defineConfig({
+ integrations: [db(), testIntegration()],
+});
diff --git a/packages/db/test/fixtures/integration-only/integration/config.ts b/packages/db/test/fixtures/integration-only/integration/config.ts
new file mode 100644
index 000000000..71490be95
--- /dev/null
+++ b/packages/db/test/fixtures/integration-only/integration/config.ts
@@ -0,0 +1,8 @@
+import { defineDb } from 'astro:db';
+import { menu } from './shared';
+
+export default defineDb({
+ tables: {
+ menu,
+ },
+});
diff --git a/packages/db/test/fixtures/integration-only/integration/index.ts b/packages/db/test/fixtures/integration-only/integration/index.ts
new file mode 100644
index 000000000..b249cc253
--- /dev/null
+++ b/packages/db/test/fixtures/integration-only/integration/index.ts
@@ -0,0 +1,15 @@
+import { defineDbIntegration } from '@astrojs/db/utils';
+
+export default function testIntegration() {
+ return defineDbIntegration({
+ name: 'db-test-integration',
+ hooks: {
+ 'astro:db:setup'({ extendDb }) {
+ extendDb({
+ configEntrypoint: './integration/config.ts',
+ seedEntrypoint: './integration/seed.ts',
+ });
+ },
+ },
+ });
+}
diff --git a/packages/db/test/fixtures/integration-only/integration/seed.ts b/packages/db/test/fixtures/integration-only/integration/seed.ts
new file mode 100644
index 000000000..ed2b2e2eb
--- /dev/null
+++ b/packages/db/test/fixtures/integration-only/integration/seed.ts
@@ -0,0 +1,14 @@
+import { db } from 'astro:db';
+import { asDrizzleTable } from '@astrojs/db/utils';
+import { menu } from './shared';
+
+export default async function () {
+ const table = asDrizzleTable('menu', menu);
+
+ await db.insert(table).values([
+ { name: 'Pancakes', price: 9.5, type: 'Breakfast' },
+ { name: 'French Toast', price: 11.25, type: 'Breakfast' },
+ { name: 'Coffee', price: 3, type: 'Beverages' },
+ { name: 'Cappuccino', price: 4.5, type: 'Beverages' },
+ ]);
+}
diff --git a/packages/db/test/fixtures/integration-only/integration/shared.ts b/packages/db/test/fixtures/integration-only/integration/shared.ts
new file mode 100644
index 000000000..d46ae65a6
--- /dev/null
+++ b/packages/db/test/fixtures/integration-only/integration/shared.ts
@@ -0,0 +1,9 @@
+import { column, defineTable } from 'astro:db';
+
+export const menu = defineTable({
+ columns: {
+ name: column.text(),
+ type: column.text(),
+ price: column.number(),
+ },
+});
diff --git a/packages/db/test/fixtures/integration-only/package.json b/packages/db/test/fixtures/integration-only/package.json
new file mode 100644
index 000000000..4229f710a
--- /dev/null
+++ b/packages/db/test/fixtures/integration-only/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@test/db-integration-only",
+ "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/integration-only/src/pages/index.astro b/packages/db/test/fixtures/integration-only/src/pages/index.astro
new file mode 100644
index 000000000..7b204e124
--- /dev/null
+++ b/packages/db/test/fixtures/integration-only/src/pages/index.astro
@@ -0,0 +1,11 @@
+---
+/// <reference path="../../.astro/db-types.d.ts" />
+import { db, menu } from 'astro:db';
+
+const menuItems = await db.select().from(menu);
+---
+
+<h2>Menu</h2>
+<ul class="menu">
+ {menuItems.map((item) => <li>{item.name}</li>)}
+</ul>
diff --git a/packages/db/test/fixtures/integrations/astro.config.mjs b/packages/db/test/fixtures/integrations/astro.config.mjs
new file mode 100644
index 000000000..23f52739e
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/astro.config.mjs
@@ -0,0 +1,8 @@
+import db from '@astrojs/db';
+import { defineConfig } from 'astro/config';
+import testIntegration from './integration';
+
+// https://astro.build/config
+export default defineConfig({
+ integrations: [db(), testIntegration()],
+});
diff --git a/packages/db/test/fixtures/integrations/db/config.ts b/packages/db/test/fixtures/integrations/db/config.ts
new file mode 100644
index 000000000..b8110406a
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/db/config.ts
@@ -0,0 +1,12 @@
+import { column, defineDb, defineTable } from 'astro:db';
+
+const Author = defineTable({
+ columns: {
+ name: column.text(),
+ age2: column.number({ optional: true }),
+ },
+});
+
+export default defineDb({
+ tables: { Author },
+});
diff --git a/packages/db/test/fixtures/integrations/db/seed.ts b/packages/db/test/fixtures/integrations/db/seed.ts
new file mode 100644
index 000000000..56ffb5668
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/db/seed.ts
@@ -0,0 +1,13 @@
+import { Author, db } from 'astro:db';
+
+export default async () => {
+ await db
+ .insert(Author)
+ .values([
+ { name: 'Ben' },
+ { name: 'Nate' },
+ { name: 'Erika' },
+ { name: 'Bjorn' },
+ { name: 'Sarah' },
+ ]);
+};
diff --git a/packages/db/test/fixtures/integrations/integration/config.ts b/packages/db/test/fixtures/integrations/integration/config.ts
new file mode 100644
index 000000000..71490be95
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/integration/config.ts
@@ -0,0 +1,8 @@
+import { defineDb } from 'astro:db';
+import { menu } from './shared';
+
+export default defineDb({
+ tables: {
+ menu,
+ },
+});
diff --git a/packages/db/test/fixtures/integrations/integration/index.ts b/packages/db/test/fixtures/integrations/integration/index.ts
new file mode 100644
index 000000000..b249cc253
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/integration/index.ts
@@ -0,0 +1,15 @@
+import { defineDbIntegration } from '@astrojs/db/utils';
+
+export default function testIntegration() {
+ return defineDbIntegration({
+ name: 'db-test-integration',
+ hooks: {
+ 'astro:db:setup'({ extendDb }) {
+ extendDb({
+ configEntrypoint: './integration/config.ts',
+ seedEntrypoint: './integration/seed.ts',
+ });
+ },
+ },
+ });
+}
diff --git a/packages/db/test/fixtures/integrations/integration/seed.ts b/packages/db/test/fixtures/integrations/integration/seed.ts
new file mode 100644
index 000000000..ed2b2e2eb
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/integration/seed.ts
@@ -0,0 +1,14 @@
+import { db } from 'astro:db';
+import { asDrizzleTable } from '@astrojs/db/utils';
+import { menu } from './shared';
+
+export default async function () {
+ const table = asDrizzleTable('menu', menu);
+
+ await db.insert(table).values([
+ { name: 'Pancakes', price: 9.5, type: 'Breakfast' },
+ { name: 'French Toast', price: 11.25, type: 'Breakfast' },
+ { name: 'Coffee', price: 3, type: 'Beverages' },
+ { name: 'Cappuccino', price: 4.5, type: 'Beverages' },
+ ]);
+}
diff --git a/packages/db/test/fixtures/integrations/integration/shared.ts b/packages/db/test/fixtures/integrations/integration/shared.ts
new file mode 100644
index 000000000..d46ae65a6
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/integration/shared.ts
@@ -0,0 +1,9 @@
+import { column, defineTable } from 'astro:db';
+
+export const menu = defineTable({
+ columns: {
+ name: column.text(),
+ type: column.text(),
+ price: column.number(),
+ },
+});
diff --git a/packages/db/test/fixtures/integrations/package.json b/packages/db/test/fixtures/integrations/package.json
new file mode 100644
index 000000000..1bb17a8c7
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@test/db-integration",
+ "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/integrations/src/pages/index.astro b/packages/db/test/fixtures/integrations/src/pages/index.astro
new file mode 100644
index 000000000..3e9c30ef7
--- /dev/null
+++ b/packages/db/test/fixtures/integrations/src/pages/index.astro
@@ -0,0 +1,17 @@
+---
+/// <reference path="../../.astro/db-types.d.ts" />
+import { Author, db, menu } from 'astro:db';
+
+const authors = await db.select().from(Author);
+const menuItems = await db.select().from(menu);
+---
+
+<h2>Authors</h2>
+<ul class="authors-list">
+ {authors.map((author) => <li>{author.name}</li>)}
+</ul>
+
+<h2>Menu</h2>
+<ul class="menu">
+ {menuItems.map((item) => <li>{item.name}</li>)}
+</ul>
diff --git a/packages/db/test/fixtures/libsql-remote/astro.config.ts b/packages/db/test/fixtures/libsql-remote/astro.config.ts
new file mode 100644
index 000000000..983a6947d
--- /dev/null
+++ b/packages/db/test/fixtures/libsql-remote/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/libsql-remote/db/config.ts b/packages/db/test/fixtures/libsql-remote/db/config.ts
new file mode 100644
index 000000000..44c15abe7
--- /dev/null
+++ b/packages/db/test/fixtures/libsql-remote/db/config.ts
@@ -0,0 +1,13 @@
+import { column, defineDb, defineTable } from 'astro:db';
+
+const User = defineTable({
+ columns: {
+ id: column.text({ primaryKey: true, optional: false }),
+ username: column.text({ optional: false, unique: true }),
+ password: column.text({ optional: false }),
+ },
+});
+
+export default defineDb({
+ tables: { User },
+});
diff --git a/packages/db/test/fixtures/libsql-remote/db/seed.ts b/packages/db/test/fixtures/libsql-remote/db/seed.ts
new file mode 100644
index 000000000..7d9aa3292
--- /dev/null
+++ b/packages/db/test/fixtures/libsql-remote/db/seed.ts
@@ -0,0 +1,7 @@
+import { User, db } from 'astro:db';
+
+export default async function () {
+ await db.batch([
+ db.insert(User).values([{ id: 'mario', username: 'Mario', password: 'itsame' }]),
+ ]);
+}
diff --git a/packages/db/test/fixtures/libsql-remote/package.json b/packages/db/test/fixtures/libsql-remote/package.json
new file mode 100644
index 000000000..2970a62d5
--- /dev/null
+++ b/packages/db/test/fixtures/libsql-remote/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@test/db-libsql-remote",
+ "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/libsql-remote/src/pages/index.astro b/packages/db/test/fixtures/libsql-remote/src/pages/index.astro
new file mode 100644
index 000000000..f36d44bd4
--- /dev/null
+++ b/packages/db/test/fixtures/libsql-remote/src/pages/index.astro
@@ -0,0 +1,11 @@
+---
+/// <reference path="../../.astro/db-types.d.ts" />
+import { User, db } from 'astro:db';
+
+const users = await db.select().from(User);
+---
+
+<h2>Users</h2>
+<ul class="users-list">
+ {users.map((user) => <li>{user.name}</li>)}
+</ul>
diff --git a/packages/db/test/fixtures/local-prod/astro.config.ts b/packages/db/test/fixtures/local-prod/astro.config.ts
new file mode 100644
index 000000000..983a6947d
--- /dev/null
+++ b/packages/db/test/fixtures/local-prod/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/local-prod/db/config.ts b/packages/db/test/fixtures/local-prod/db/config.ts
new file mode 100644
index 000000000..44c15abe7
--- /dev/null
+++ b/packages/db/test/fixtures/local-prod/db/config.ts
@@ -0,0 +1,13 @@
+import { column, defineDb, defineTable } from 'astro:db';
+
+const User = defineTable({
+ columns: {
+ id: column.text({ primaryKey: true, optional: false }),
+ username: column.text({ optional: false, unique: true }),
+ password: column.text({ optional: false }),
+ },
+});
+
+export default defineDb({
+ tables: { User },
+});
diff --git a/packages/db/test/fixtures/local-prod/db/seed.ts b/packages/db/test/fixtures/local-prod/db/seed.ts
new file mode 100644
index 000000000..a84e63454
--- /dev/null
+++ b/packages/db/test/fixtures/local-prod/db/seed.ts
@@ -0,0 +1,8 @@
+import { User, db } from 'astro:db';
+import { asDrizzleTable } from '@astrojs/db/utils';
+
+export default async function () {
+ await db.batch([
+ db.insert(User).values([{ id: 'mario', username: 'Mario', password: 'itsame' }]),
+ ]);
+}
diff --git a/packages/db/test/fixtures/local-prod/package.json b/packages/db/test/fixtures/local-prod/package.json
new file mode 100644
index 000000000..2d11d5347
--- /dev/null
+++ b/packages/db/test/fixtures/local-prod/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@test/db-local-prod",
+ "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/local-prod/src/pages/index.astro b/packages/db/test/fixtures/local-prod/src/pages/index.astro
new file mode 100644
index 000000000..f36d44bd4
--- /dev/null
+++ b/packages/db/test/fixtures/local-prod/src/pages/index.astro
@@ -0,0 +1,11 @@
+---
+/// <reference path="../../.astro/db-types.d.ts" />
+import { User, db } from 'astro:db';
+
+const users = await db.select().from(User);
+---
+
+<h2>Users</h2>
+<ul class="users-list">
+ {users.map((user) => <li>{user.name}</li>)}
+</ul>
diff --git a/packages/db/test/fixtures/no-apptoken/astro.config.ts b/packages/db/test/fixtures/no-apptoken/astro.config.ts
new file mode 100644
index 000000000..983a6947d
--- /dev/null
+++ b/packages/db/test/fixtures/no-apptoken/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/no-apptoken/db/config.ts b/packages/db/test/fixtures/no-apptoken/db/config.ts
new file mode 100644
index 000000000..44c15abe7
--- /dev/null
+++ b/packages/db/test/fixtures/no-apptoken/db/config.ts
@@ -0,0 +1,13 @@
+import { column, defineDb, defineTable } from 'astro:db';
+
+const User = defineTable({
+ columns: {
+ id: column.text({ primaryKey: true, optional: false }),
+ username: column.text({ optional: false, unique: true }),
+ password: column.text({ optional: false }),
+ },
+});
+
+export default defineDb({
+ tables: { User },
+});
diff --git a/packages/db/test/fixtures/no-apptoken/db/seed.ts b/packages/db/test/fixtures/no-apptoken/db/seed.ts
new file mode 100644
index 000000000..ea9b101e1
--- /dev/null
+++ b/packages/db/test/fixtures/no-apptoken/db/seed.ts
@@ -0,0 +1 @@
+export default function () {}
diff --git a/packages/db/test/fixtures/no-apptoken/package.json b/packages/db/test/fixtures/no-apptoken/package.json
new file mode 100644
index 000000000..a7e17d1af
--- /dev/null
+++ b/packages/db/test/fixtures/no-apptoken/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@test/db-no-apptoken",
+ "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/no-apptoken/src/pages/index.astro b/packages/db/test/fixtures/no-apptoken/src/pages/index.astro
new file mode 100644
index 000000000..477e18fa3
--- /dev/null
+++ b/packages/db/test/fixtures/no-apptoken/src/pages/index.astro
@@ -0,0 +1,16 @@
+---
+/// <reference path="../../.astro/db-types.d.ts" />
+import { User, db } from 'astro:db';
+
+// Just for the side-effect of running all the code
+await db.select().from(User);
+---
+
+<html>
+ <head>
+ <title>Testing</title>
+ </head>
+ <body>
+ <h1>Testing</h1>
+ </body>
+</html>
diff --git a/packages/db/test/fixtures/no-seed/astro.config.ts b/packages/db/test/fixtures/no-seed/astro.config.ts
new file mode 100644
index 000000000..5ff1200e2
--- /dev/null
+++ b/packages/db/test/fixtures/no-seed/astro.config.ts
@@ -0,0 +1,7 @@
+import db from '@astrojs/db';
+import { defineConfig } from 'astro/config';
+
+// https://astro.build/config
+export default defineConfig({
+ integrations: [db()],
+});
diff --git a/packages/db/test/fixtures/no-seed/db/config.ts b/packages/db/test/fixtures/no-seed/db/config.ts
new file mode 100644
index 000000000..b8110406a
--- /dev/null
+++ b/packages/db/test/fixtures/no-seed/db/config.ts
@@ -0,0 +1,12 @@
+import { column, defineDb, defineTable } from 'astro:db';
+
+const Author = defineTable({
+ columns: {
+ name: column.text(),
+ age2: column.number({ optional: true }),
+ },
+});
+
+export default defineDb({
+ tables: { Author },
+});
diff --git a/packages/db/test/fixtures/no-seed/package.json b/packages/db/test/fixtures/no-seed/package.json
new file mode 100644
index 000000000..66a192697
--- /dev/null
+++ b/packages/db/test/fixtures/no-seed/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@test/db-no-seed",
+ "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/no-seed/src/pages/index.astro b/packages/db/test/fixtures/no-seed/src/pages/index.astro
new file mode 100644
index 000000000..bacd873e1
--- /dev/null
+++ b/packages/db/test/fixtures/no-seed/src/pages/index.astro
@@ -0,0 +1,21 @@
+---
+/// <reference path="../../.astro/db-types.d.ts" />
+import { Author, db } from 'astro:db';
+
+await db
+ .insert(Author)
+ .values([
+ { name: 'Ben' },
+ { name: 'Nate' },
+ { name: 'Erika' },
+ { name: 'Bjorn' },
+ { name: 'Sarah' },
+ ]);
+
+const authors = await db.select().from(Author);
+---
+
+<h2>Authors</h2>
+<ul class="authors-list">
+ {authors.map((author) => <li>{author.name}</li>)}
+</ul>
diff --git a/packages/db/test/fixtures/recipes/astro.config.ts b/packages/db/test/fixtures/recipes/astro.config.ts
new file mode 100644
index 000000000..bd6088769
--- /dev/null
+++ b/packages/db/test/fixtures/recipes/astro.config.ts
@@ -0,0 +1,6 @@
+import astroDb from '@astrojs/db';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [astroDb()],
+});
diff --git a/packages/db/test/fixtures/recipes/db/config.ts b/packages/db/test/fixtures/recipes/db/config.ts
new file mode 100644
index 000000000..bd4d6edaf
--- /dev/null
+++ b/packages/db/test/fixtures/recipes/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/recipes/db/seed.ts b/packages/db/test/fixtures/recipes/db/seed.ts
new file mode 100644
index 000000000..1ca219f15
--- /dev/null
+++ b/packages/db/test/fixtures/recipes/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/recipes/package.json b/packages/db/test/fixtures/recipes/package.json
new file mode 100644
index 000000000..cd1e83c02
--- /dev/null
+++ b/packages/db/test/fixtures/recipes/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@test/recipes",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "@astrojs/db": "workspace:*",
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/db/test/fixtures/recipes/src/pages/index.astro b/packages/db/test/fixtures/recipes/src/pages/index.astro
new file mode 100644
index 000000000..9fd2dac41
--- /dev/null
+++ b/packages/db/test/fixtures/recipes/src/pages/index.astro
@@ -0,0 +1,25 @@
+---
+/// <reference path="../../.astro/db-types.d.ts" />
+import { Ingredient, Recipe, db, eq } from 'astro:db';
+
+const ingredientsByRecipe = await db
+ .select({
+ name: Ingredient.name,
+ recipeName: Recipe.title,
+ })
+ .from(Ingredient)
+ .innerJoin(Recipe, eq(Ingredient.recipeId, Recipe.id));
+
+console.log(ingredientsByRecipe);
+---
+
+<h2>Shopping list</h2>
+<ul>
+ {
+ ingredientsByRecipe.map(({ name, recipeName }) => (
+ <li>
+ {name} ({recipeName})
+ </li>
+ ))
+ }
+</ul>
diff --git a/packages/db/test/fixtures/static-remote/astro.config.ts b/packages/db/test/fixtures/static-remote/astro.config.ts
new file mode 100644
index 000000000..bd6088769
--- /dev/null
+++ b/packages/db/test/fixtures/static-remote/astro.config.ts
@@ -0,0 +1,6 @@
+import astroDb from '@astrojs/db';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [astroDb()],
+});
diff --git a/packages/db/test/fixtures/static-remote/db/config.ts b/packages/db/test/fixtures/static-remote/db/config.ts
new file mode 100644
index 000000000..8df4674d8
--- /dev/null
+++ b/packages/db/test/fixtures/static-remote/db/config.ts
@@ -0,0 +1,12 @@
+import { column, defineDb, defineTable } from 'astro:db';
+
+const User = defineTable({
+ columns: {
+ id: column.number({ primaryKey: true }),
+ name: column.text(),
+ },
+});
+
+export default defineDb({
+ tables: { User },
+});
diff --git a/packages/db/test/fixtures/static-remote/db/seed.ts b/packages/db/test/fixtures/static-remote/db/seed.ts
new file mode 100644
index 000000000..2c86f02a1
--- /dev/null
+++ b/packages/db/test/fixtures/static-remote/db/seed.ts
@@ -0,0 +1,9 @@
+import { User, db } from 'astro:db';
+
+export default async function () {
+ await db.insert(User).values([
+ {
+ name: 'Houston',
+ },
+ ]);
+}
diff --git a/packages/db/test/fixtures/static-remote/package.json b/packages/db/test/fixtures/static-remote/package.json
new file mode 100644
index 000000000..aa2c9c23c
--- /dev/null
+++ b/packages/db/test/fixtures/static-remote/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@test/db-static-remote",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "@astrojs/db": "workspace:*",
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/db/test/fixtures/static-remote/src/pages/index.astro b/packages/db/test/fixtures/static-remote/src/pages/index.astro
new file mode 100644
index 000000000..849e65d18
--- /dev/null
+++ b/packages/db/test/fixtures/static-remote/src/pages/index.astro
@@ -0,0 +1,19 @@
+---
+import { User, db } from 'astro:db';
+
+const users = await db.select().from(User);
+---
+
+<html>
+ <head>
+ <title>Testing</title>
+ </head>
+ <body>
+ <h1>Testing</h1>
+
+ <h2>Users</h2>
+ <ul>
+ {users.map((user) => <li>{user.name}</li>)}
+ </ul>
+ </body>
+</html>
diff --git a/packages/db/test/fixtures/static-remote/src/pages/run.astro b/packages/db/test/fixtures/static-remote/src/pages/run.astro
new file mode 100644
index 000000000..2f2ac1cce
--- /dev/null
+++ b/packages/db/test/fixtures/static-remote/src/pages/run.astro
@@ -0,0 +1,17 @@
+---
+import { User, db, sql } from 'astro:db';
+
+const results = await db.run(sql`SELECT 1 as value`);
+const row = results.rows[0];
+---
+
+<html>
+ <head>
+ <title>Testing</title>
+ </head>
+ <body>
+ <h1>Testing</h1>
+
+ <span id="row">{row.value}</span>
+ </body>
+</html>
diff --git a/packages/db/test/fixtures/ticketing-example/.gitignore b/packages/db/test/fixtures/ticketing-example/.gitignore
new file mode 100644
index 000000000..ce6405d09
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/.gitignore
@@ -0,0 +1,24 @@
+# build output
+dist/
+
+# generated types
+.astro/
+
+# dependencies
+node_modules/
+
+# logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# environment variables
+.env
+.env.production
+
+# macOS-specific files
+.DS_Store
+
+# Cloudflare
+.wrangler/
diff --git a/packages/db/test/fixtures/ticketing-example/README.md b/packages/db/test/fixtures/ticketing-example/README.md
new file mode 100644
index 000000000..1db3fb399
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/README.md
@@ -0,0 +1,54 @@
+# Astro Starter Kit: Basics
+
+```sh
+npm create astro@latest -- --template basics
+```
+
+[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
+[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
+[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
+
+> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
+
+![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554)
+
+## 🚀 Project Structure
+
+Inside of your Astro project, you'll see the following folders and files:
+
+```text
+/
+├── public/
+│ └── favicon.svg
+├── src/
+│ ├── components/
+│ │ └── Card.astro
+│ ├── layouts/
+│ │ └── Layout.astro
+│ └── pages/
+│ └── index.astro
+└── package.json
+```
+
+Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
+
+There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
+
+Any static assets, like images, can be placed in the `public/` directory.
+
+## 🧞 Commands
+
+All commands are run from the root of the project, from a terminal:
+
+| Command | Action |
+| :------------------------ | :----------------------------------------------- |
+| `npm install` | Installs dependencies |
+| `npm run dev` | Starts local dev server at `localhost:4321` |
+| `npm run build` | Build your production site to `./dist/` |
+| `npm run preview` | Preview your build locally, before deploying |
+| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
+| `npm run astro -- --help` | Get help using the Astro CLI |
+
+## 👀 Want to learn more?
+
+Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
diff --git a/packages/db/test/fixtures/ticketing-example/astro.config.ts b/packages/db/test/fixtures/ticketing-example/astro.config.ts
new file mode 100644
index 000000000..616156f9a
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/astro.config.ts
@@ -0,0 +1,14 @@
+import db from '@astrojs/db';
+import node from '@astrojs/node';
+import react from '@astrojs/react';
+import { defineConfig } from 'astro/config';
+import simpleStackForm from 'simple-stack-form';
+
+// https://astro.build/config
+export default defineConfig({
+ integrations: [simpleStackForm(), db(), react()],
+ output: 'server',
+ adapter: node({
+ mode: 'standalone',
+ }),
+});
diff --git a/packages/db/test/fixtures/ticketing-example/db/config.ts b/packages/db/test/fixtures/ticketing-example/db/config.ts
new file mode 100644
index 000000000..4c07b4c9c
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/db/config.ts
@@ -0,0 +1,27 @@
+import { column, defineDb, defineTable } from 'astro:db';
+
+const Event = defineTable({
+ columns: {
+ id: column.number({
+ primaryKey: true,
+ }),
+ name: column.text(),
+ description: column.text(),
+ ticketPrice: column.number(),
+ date: column.date(),
+ location: column.text(),
+ },
+});
+
+const Ticket = defineTable({
+ columns: {
+ eventId: column.number({ references: () => Event.columns.id }),
+ email: column.text(),
+ quantity: column.number(),
+ newsletter: column.boolean({
+ default: true,
+ }),
+ },
+});
+
+export default defineDb({ tables: { Event, Ticket } });
diff --git a/packages/db/test/fixtures/ticketing-example/db/seed.ts b/packages/db/test/fixtures/ticketing-example/db/seed.ts
new file mode 100644
index 000000000..f68a0c85b
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/db/seed.ts
@@ -0,0 +1,12 @@
+import { Event, db } from 'astro:db';
+
+export default async function () {
+ await db.insert(Event).values({
+ name: 'Sampha LIVE in Brooklyn',
+ description:
+ 'Sampha is on tour with his new, flawless album Lahai. Come see the live performance outdoors in Prospect Park. Yes, there will be a grand piano 🎹',
+ date: new Date('2024-01-01'),
+ ticketPrice: 10000,
+ location: 'Brooklyn, NY',
+ });
+}
diff --git a/packages/db/test/fixtures/ticketing-example/package.json b/packages/db/test/fixtures/ticketing-example/package.json
new file mode 100644
index 000000000..9d3bc0899
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "eventbrite-from-scratch",
+ "type": "module",
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "pnpm astro dev",
+ "build": "astro check && astro build",
+ "preview": "astro preview",
+ "astro": "astro"
+ },
+ "dependencies": {
+ "@astrojs/check": "^0.9.4",
+ "@astrojs/db": "workspace:*",
+ "@astrojs/node": "workspace:*",
+ "@astrojs/react": "workspace:*",
+ "@types/react": "^18.3.20",
+ "@types/react-dom": "^18.3.6",
+ "astro": "workspace:*",
+ "open-props": "^1.7.14",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "simple-stack-form": "^0.1.12",
+ "typescript": "^5.8.3",
+ "zod": "^3.24.2"
+ }
+}
diff --git a/packages/db/test/fixtures/ticketing-example/public/favicon.svg b/packages/db/test/fixtures/ticketing-example/public/favicon.svg
new file mode 100644
index 000000000..f157bd1c5
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/public/favicon.svg
@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
+ <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
+ <style>
+ path { fill: #000; }
+ @media (prefers-color-scheme: dark) {
+ path { fill: #FFF; }
+ }
+ </style>
+</svg>
diff --git a/packages/db/test/fixtures/ticketing-example/src/components/Form.tsx b/packages/db/test/fixtures/ticketing-example/src/components/Form.tsx
new file mode 100644
index 000000000..f393d8281
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/src/components/Form.tsx
@@ -0,0 +1,119 @@
+// Generated by simple:form
+
+import { navigate } from 'astro:transitions/client';
+import {
+ type FieldErrors,
+ type FormState,
+ type FormValidator,
+ formNameInputProps,
+ getInitialFormState,
+ toSetValidationErrors,
+ toTrackAstroSubmitStatus,
+ toValidateField,
+ validateForm,
+} from 'simple:form';
+import { type ComponentProps, createContext, useContext, useState } from 'react';
+
+export function useCreateFormContext(validator: FormValidator, fieldErrors?: FieldErrors) {
+ const initial = getInitialFormState({ validator, fieldErrors });
+ const [formState, setFormState] = useState<FormState>(initial);
+ return {
+ value: formState,
+ set: setFormState,
+ setValidationErrors: toSetValidationErrors(setFormState),
+ validateField: toValidateField(setFormState),
+ trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState),
+ };
+}
+
+export function useFormContext() {
+ const formContext = useContext(FormContext);
+ if (!formContext) {
+ throw new Error(
+ 'Form context not found. `useFormContext()` should only be called from children of a <Form> component.'
+ );
+ }
+ return formContext;
+}
+
+type FormContextType = ReturnType<typeof useCreateFormContext>;
+
+const FormContext = createContext<FormContextType | undefined>(undefined);
+
+export function Form({
+ children,
+ validator,
+ context,
+ fieldErrors,
+ name,
+ ...formProps
+}: {
+ validator: FormValidator;
+ context?: FormContextType;
+ fieldErrors?: FieldErrors;
+} & Omit<ComponentProps<'form'>, 'method' | 'onSubmit'>) {
+ const formContext = context ?? useCreateFormContext(validator, fieldErrors);
+
+ return (
+ <FormContext.Provider value={formContext}>
+ <form
+ {...formProps}
+ method="POST"
+ onSubmit={async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const formData = new FormData(e.currentTarget);
+ formContext.set((formState) => ({
+ ...formState,
+ isSubmitPending: true,
+ submitStatus: 'validating',
+ }));
+ const parsed = await validateForm({ formData, validator });
+ if (parsed.data) {
+ navigate(formProps.action ?? '', { formData });
+ return formContext.trackAstroSubmitStatus();
+ }
+
+ formContext.setValidationErrors(parsed.fieldErrors);
+ }}
+ >
+ {name ? <input {...formNameInputProps} value={name} /> : null}
+ {children}
+ </form>
+ </FormContext.Provider>
+ );
+}
+
+export function Input(inputProps: ComponentProps<'input'> & { name: string }) {
+ const formContext = useFormContext();
+ const fieldState = formContext.value.fields[inputProps.name];
+ if (!fieldState) {
+ throw new Error(
+ `Input "${inputProps.name}" not found in form. Did you use the <Form> component?`
+ );
+ }
+
+ const { hasErroredOnce, validationErrors, validator } = fieldState;
+ return (
+ <>
+ <input
+ onBlur={async (e) => {
+ const value = e.target.value;
+ if (value === '') return;
+ formContext.validateField(inputProps.name, value, validator);
+ }}
+ onChange={async (e) => {
+ if (!hasErroredOnce) return;
+ const value = e.target.value;
+ formContext.validateField(inputProps.name, value, validator);
+ }}
+ {...inputProps}
+ />
+ {validationErrors?.map((e) => (
+ <p className="error" key={e}>
+ {e}
+ </p>
+ ))}
+ </>
+ );
+}
diff --git a/packages/db/test/fixtures/ticketing-example/src/layouts/Layout.astro b/packages/db/test/fixtures/ticketing-example/src/layouts/Layout.astro
new file mode 100644
index 000000000..482f10462
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/src/layouts/Layout.astro
@@ -0,0 +1,80 @@
+---
+import { ClientRouter } from 'astro:transitions';
+import 'open-props/normalize';
+import 'open-props/style';
+
+interface Props {
+ title: string;
+}
+
+const { title } = Astro.props;
+---
+
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="description" content="Astro description" />
+ <meta name="viewport" content="width=device-width" />
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+ <meta name="generator" content={Astro.generator} />
+ <title>{title}</title>
+ <ClientRouter handleForms />
+ </head>
+ <body>
+ <slot />
+ <style is:global>
+ main {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: var(--size-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--size-4);
+ }
+
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--size-2);
+ margin-bottom: var(--size-4);
+ background: var(--surface-2);
+ padding-inline: var(--size-4);
+ padding-block: var(--size-6);
+ border-radius: var(--radius-2);
+ }
+
+ .error {
+ color: var(--red-6);
+ margin-bottom: var(--size-2);
+ grid-column: 1 / -1;
+ }
+
+ form button {
+ grid-column: 1 / -1;
+ background: var(--orange-8);
+ border-radius: var(--radius-2);
+ padding-block: var(--size-2);
+ }
+
+ .youre-going {
+ background: var(--surface-2);
+ padding: var(--size-2);
+ border-radius: var(--radius-2);
+ display: flex;
+ flex-direction: column;
+ }
+
+ h2 {
+ font-size: var(--font-size-4);
+ margin-bottom: var(--size-2);
+ }
+
+ .newsletter {
+ display: flex;
+ align-items: center;
+ gap: var(--size-2);
+ }
+ </style>
+ </body>
+</html>
diff --git a/packages/db/test/fixtures/ticketing-example/src/pages/[event]/_Ticket.tsx b/packages/db/test/fixtures/ticketing-example/src/pages/[event]/_Ticket.tsx
new file mode 100644
index 000000000..5e488d69d
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/src/pages/[event]/_Ticket.tsx
@@ -0,0 +1,40 @@
+import { createForm } from 'simple:form';
+import { useState } from 'react';
+import { z } from 'zod';
+import { Form, Input } from '../../components/Form';
+
+export const ticketForm = createForm({
+ email: z.string().email(),
+ quantity: z.number().max(10),
+ newsletter: z.boolean(),
+});
+
+export function TicketForm({ price }: { price: number }) {
+ const [quantity, setQuantity] = useState(1);
+ return (
+ <>
+ <Form validator={ticketForm.validator}>
+ <h3>${(quantity * price) / 100}</h3>
+
+ <label htmlFor="quantity">Quantity</label>
+ <Input
+ id="quantity"
+ {...ticketForm.inputProps.quantity}
+ onInput={(e) => {
+ const value = Number(e.currentTarget.value);
+ setQuantity(value);
+ }}
+ />
+
+ <label htmlFor="email">Email</label>
+ <Input id="email" {...ticketForm.inputProps.email} />
+
+ <div className="newsletter">
+ <Input id="newsletter" {...ticketForm.inputProps.newsletter} />
+ <label htmlFor="newsletter">Hear about the next event in your area</label>
+ </div>
+ <button>Buy tickets</button>
+ </Form>
+ </>
+ );
+}
diff --git a/packages/db/test/fixtures/ticketing-example/src/pages/[event]/index.astro b/packages/db/test/fixtures/ticketing-example/src/pages/[event]/index.astro
new file mode 100644
index 000000000..7c1c4e320
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/src/pages/[event]/index.astro
@@ -0,0 +1,50 @@
+---
+import { Event, Ticket, db, eq } from 'astro:db';
+import Layout from '../../layouts/Layout.astro';
+import { TicketForm, ticketForm } from './_Ticket';
+
+const eventId = Number(Astro.params.event);
+
+if (isNaN(eventId)) return Astro.redirect('/');
+
+const event = await db.select().from(Event).where(eq(Event.id, eventId)).get();
+
+if (!event) return Astro.redirect('/');
+
+const res = await Astro.locals.form.getData(ticketForm);
+
+if (res?.data) {
+ await db.insert(Ticket).values({
+ eventId,
+ email: res.data.email,
+ quantity: res.data.quantity,
+ newsletter: res.data.newsletter,
+ });
+}
+
+const ticket = await db.select().from(Ticket).where(eq(Ticket.eventId, eventId)).get();
+---
+
+<Layout title="Welcome to Astro.">
+ <main>
+ <h1>{event.name}</h1>
+ <p>
+ {event.description}
+ </p>
+
+ <TicketForm price={event.ticketPrice} client:load />
+ {
+ ticket && (
+ <section class="youre-going">
+ <h2>You're going 🙌</h2>
+ <p>
+ You have purchased {ticket.quantity} tickets for {event.name}!
+ </p>
+ <p>
+ Check <strong>{ticket.email}</strong> for your tickets.
+ </p>
+ </section>
+ )
+ }
+ </main>
+</Layout>
diff --git a/packages/db/test/fixtures/ticketing-example/src/pages/index.astro b/packages/db/test/fixtures/ticketing-example/src/pages/index.astro
new file mode 100644
index 000000000..c8bbcbc70
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/src/pages/index.astro
@@ -0,0 +1,17 @@
+---
+import { Event, db } from 'astro:db';
+
+const firstEvent = await db.select().from(Event).get();
+---
+
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Eventbrite</title>
+ </head>
+ <body>
+ <meta http-equiv="refresh" content={`0; url=${firstEvent!.id}`} />
+ </body>
+</html>
diff --git a/packages/db/test/fixtures/ticketing-example/tsconfig.json b/packages/db/test/fixtures/ticketing-example/tsconfig.json
new file mode 100644
index 000000000..2424dae7d
--- /dev/null
+++ b/packages/db/test/fixtures/ticketing-example/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "react"
+ },
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"]
+}
diff --git a/packages/db/test/integration-only.test.js b/packages/db/test/integration-only.test.js
new file mode 100644
index 000000000..b95d7d141
--- /dev/null
+++ b/packages/db/test/integration-only.test.js
@@ -0,0 +1,48 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { load as cheerioLoad } from 'cheerio';
+import { loadFixture } from '../../astro/test/test-utils.js';
+
+describe('astro:db with only integrations, no user db config', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/integration-only/', import.meta.url),
+ });
+ });
+
+ describe('development', () => {
+ let devServer;
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('Prints the list of menu items from integration-defined table', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const ul = $('ul.menu');
+ assert.equal(ul.children().length, 4);
+ assert.match(ul.children().eq(0).text(), /Pancakes/);
+ });
+ });
+
+ describe('build', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ it('Prints the list of menu items from integration-defined table', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerioLoad(html);
+
+ const ul = $('ul.menu');
+ assert.equal(ul.children().length, 4);
+ assert.match(ul.children().eq(0).text(), /Pancakes/);
+ });
+ });
+});
diff --git a/packages/db/test/integrations.test.js b/packages/db/test/integrations.test.js
new file mode 100644
index 000000000..b05b28d6a
--- /dev/null
+++ b/packages/db/test/integrations.test.js
@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { load as cheerioLoad } from 'cheerio';
+import { loadFixture } from '../../astro/test/test-utils.js';
+
+describe('astro:db with integrations', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/integrations/', import.meta.url),
+ });
+ });
+
+ describe('development', () => {
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('Prints the list of authors from user-defined table', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const ul = $('.authors-list');
+ assert.equal(ul.children().length, 5);
+ assert.match(ul.children().eq(0).text(), /Ben/);
+ });
+
+ it('Prints the list of menu items from integration-defined table', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const ul = $('ul.menu');
+ assert.equal(ul.children().length, 4);
+ assert.match(ul.children().eq(0).text(), /Pancakes/);
+ });
+ });
+
+ describe('build', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ it('Prints the list of authors from user-defined table', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerioLoad(html);
+
+ const ul = $('.authors-list');
+ assert.equal(ul.children().length, 5);
+ assert.match(ul.children().eq(0).text(), /Ben/);
+ });
+
+ it('Prints the list of menu items from integration-defined table', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerioLoad(html);
+
+ const ul = $('ul.menu');
+ assert.equal(ul.children().length, 4);
+ assert.match(ul.children().eq(0).text(), /Pancakes/);
+ });
+ });
+});
diff --git a/packages/db/test/libsql-remote.test.js b/packages/db/test/libsql-remote.test.js
new file mode 100644
index 000000000..ca5c021ae
--- /dev/null
+++ b/packages/db/test/libsql-remote.test.js
@@ -0,0 +1,77 @@
+import assert from 'node:assert/strict';
+import { rm } from 'node:fs/promises';
+import { relative } from 'node:path';
+import { after, before, describe, it } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import testAdapter from '../../astro/test/test-adapter.js';
+import { loadFixture } from '../../astro/test/test-utils.js';
+import { clearEnvironment, initializeRemoteDb } from './test-utils.js';
+
+describe('astro:db local database', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/libsql-remote/', import.meta.url),
+ output: 'server',
+ adapter: testAdapter(),
+ });
+ });
+
+ describe('build --remote with local libSQL file (absolute path)', () => {
+ before(async () => {
+ clearEnvironment();
+
+ const absoluteFileUrl = new URL('./fixtures/libsql-remote/dist/absolute.db', import.meta.url);
+ // Remove the file if it exists to avoid conflict between test runs
+ await rm(absoluteFileUrl, { force: true });
+
+ process.env.ASTRO_INTERNAL_TEST_REMOTE = true;
+ process.env.ASTRO_DB_REMOTE_URL = absoluteFileUrl.toString();
+ await fixture.build();
+ await initializeRemoteDb(fixture.config);
+ });
+
+ after(async () => {
+ delete process.env.ASTRO_INTERNAL_TEST_REMOTE;
+ delete process.env.ASTRO_DB_REMOTE_URL;
+ });
+
+ it('Can render page', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ assert.equal(response.status, 200);
+ });
+ });
+
+ describe('build --remote with local libSQL file (relative path)', () => {
+ before(async () => {
+ clearEnvironment();
+
+ const absoluteFileUrl = new URL('./fixtures/libsql-remote/dist/relative.db', import.meta.url);
+ const prodDbPath = relative(
+ fileURLToPath(fixture.config.root),
+ fileURLToPath(absoluteFileUrl),
+ );
+ // Remove the file if it exists to avoid conflict between test runs
+ await rm(prodDbPath, { force: true });
+
+ process.env.ASTRO_INTERNAL_TEST_REMOTE = true;
+ process.env.ASTRO_DB_REMOTE_URL = `file:${prodDbPath}`;
+ await fixture.build();
+ await initializeRemoteDb(fixture.config);
+ });
+
+ after(async () => {
+ delete process.env.ASTRO_INTERNAL_TEST_REMOTE;
+ delete process.env.ASTRO_DB_REMOTE_URL;
+ });
+
+ it('Can render page', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ assert.equal(response.status, 200);
+ });
+ });
+});
diff --git a/packages/db/test/local-prod.test.js b/packages/db/test/local-prod.test.js
new file mode 100644
index 000000000..9bd56dad0
--- /dev/null
+++ b/packages/db/test/local-prod.test.js
@@ -0,0 +1,89 @@
+import assert from 'node:assert/strict';
+import { relative } from 'node:path';
+import { after, before, describe, it } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import testAdapter from '../../astro/test/test-adapter.js';
+import { loadFixture } from '../../astro/test/test-utils.js';
+
+describe('astro:db local database', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/local-prod/', import.meta.url),
+ output: 'server',
+ adapter: testAdapter(),
+ });
+ });
+
+ describe('build (not remote) with DATABASE_FILE env (file URL)', () => {
+ const prodDbPath = new URL('./fixtures/basics/dist/astro.db', import.meta.url).toString();
+ before(async () => {
+ process.env.ASTRO_DATABASE_FILE = prodDbPath;
+ await fixture.build();
+ });
+
+ after(async () => {
+ delete process.env.ASTRO_DATABASE_FILE;
+ });
+
+ it('Can render page', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ assert.equal(response.status, 200);
+ });
+ });
+
+ describe('build (not remote) with DATABASE_FILE env (relative file path)', () => {
+ const absoluteFileUrl = new URL('./fixtures/basics/dist/astro.db', import.meta.url);
+ const prodDbPath = relative(process.cwd(), fileURLToPath(absoluteFileUrl));
+
+ before(async () => {
+ process.env.ASTRO_DATABASE_FILE = prodDbPath;
+ await fixture.build();
+ });
+
+ after(async () => {
+ delete process.env.ASTRO_DATABASE_FILE;
+ });
+
+ it('Can render page', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ assert.equal(response.status, 200);
+ });
+ });
+
+ describe('build (not remote)', () => {
+ it('should throw during the build for server output', async () => {
+ delete process.env.ASTRO_DATABASE_FILE;
+ let buildError = null;
+ try {
+ await fixture.build();
+ } catch (err) {
+ buildError = err;
+ }
+
+ assert.equal(buildError instanceof Error, true);
+ });
+
+ it('should throw during the build for hybrid output', async () => {
+ let fixture2 = await loadFixture({
+ root: new URL('./fixtures/local-prod/', import.meta.url),
+ output: 'static',
+ adapter: testAdapter(),
+ });
+
+ delete process.env.ASTRO_DATABASE_FILE;
+ let buildError = null;
+ try {
+ await fixture2.build();
+ } catch (err) {
+ buildError = err;
+ }
+
+ assert.equal(buildError instanceof Error, true);
+ });
+ });
+});
diff --git a/packages/db/test/no-seed.test.js b/packages/db/test/no-seed.test.js
new file mode 100644
index 000000000..058352176
--- /dev/null
+++ b/packages/db/test/no-seed.test.js
@@ -0,0 +1,48 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { load as cheerioLoad } from 'cheerio';
+import { loadFixture } from '../../astro/test/test-utils.js';
+
+describe('astro:db with no seed file', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/no-seed/', import.meta.url),
+ });
+ });
+
+ describe('development', () => {
+ let devServer;
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('Prints the list of authors', async () => {
+ const html = await fixture.fetch('/').then((res) => res.text());
+ const $ = cheerioLoad(html);
+
+ const ul = $('.authors-list');
+ assert.equal(ul.children().length, 5);
+ assert.match(ul.children().eq(0).text(), /Ben/);
+ });
+ });
+
+ describe('build', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ it('Prints the list of authors', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerioLoad(html);
+
+ const ul = $('.authors-list');
+ assert.equal(ul.children().length, 5);
+ assert.match(ul.children().eq(0).text(), /Ben/);
+ });
+ });
+});
diff --git a/packages/db/test/ssr-no-apptoken.test.js b/packages/db/test/ssr-no-apptoken.test.js
new file mode 100644
index 000000000..c570306e5
--- /dev/null
+++ b/packages/db/test/ssr-no-apptoken.test.js
@@ -0,0 +1,37 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import testAdapter from '../../astro/test/test-adapter.js';
+import { loadFixture } from '../../astro/test/test-utils.js';
+import { setupRemoteDbServer } from './test-utils.js';
+
+describe('missing app token', () => {
+ let fixture;
+ let remoteDbServer;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/no-apptoken/', import.meta.url),
+ output: 'server',
+ adapter: testAdapter(),
+ });
+
+ remoteDbServer = await setupRemoteDbServer(fixture.config);
+ await fixture.build();
+ // Ensure there's no token at runtime
+ delete process.env.ASTRO_STUDIO_APP_TOKEN;
+ });
+
+ after(async () => {
+ await remoteDbServer?.stop();
+ });
+
+ it('Errors as runtime', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ try {
+ await response.text();
+ } catch {
+ assert.equal(response.status, 501);
+ }
+ });
+});
diff --git a/packages/db/test/static-remote.test.js b/packages/db/test/static-remote.test.js
new file mode 100644
index 000000000..bbe71539c
--- /dev/null
+++ b/packages/db/test/static-remote.test.js
@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { load as cheerioLoad } from 'cheerio';
+import { loadFixture } from '../../astro/test/test-utils.js';
+import { clearEnvironment, setupRemoteDbServer } from './test-utils.js';
+
+describe('astro:db', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/static-remote/', import.meta.url),
+ output: 'static',
+ });
+ });
+
+ describe('static build --remote', () => {
+ let remoteDbServer;
+
+ before(async () => {
+ remoteDbServer = await setupRemoteDbServer(fixture.config);
+ await fixture.build();
+ });
+
+ after(async () => {
+ await remoteDbServer?.stop();
+ });
+
+ it('Can render page', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerioLoad(html);
+
+ assert.equal($('li').length, 1);
+ });
+
+ it('Returns correct shape from db.run()', async () => {
+ const html = await fixture.readFile('/run/index.html');
+ const $ = cheerioLoad(html);
+
+ assert.match($('#row').text(), /1/);
+ });
+ });
+
+ describe('static build --remote with custom LibSQL', () => {
+ let remoteDbServer;
+
+ before(async () => {
+ clearEnvironment();
+ process.env.ASTRO_DB_REMOTE_URL = `memory:`;
+ await fixture.build();
+ });
+
+ after(async () => {
+ await remoteDbServer?.stop();
+ });
+
+ it('Can render page', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerioLoad(html);
+
+ assert.equal($('li').length, 1);
+ });
+
+ it('Returns correct shape from db.run()', async () => {
+ const html = await fixture.readFile('/run/index.html');
+ const $ = cheerioLoad(html);
+
+ assert.match($('#row').text(), /1/);
+ });
+ });
+});
diff --git a/packages/db/test/test-utils.js b/packages/db/test/test-utils.js
new file mode 100644
index 000000000..b608d75b8
--- /dev/null
+++ b/packages/db/test/test-utils.js
@@ -0,0 +1,172 @@
+import { createServer } from 'node:http';
+import { createClient } from '@libsql/client';
+import { z } from 'zod';
+import { cli } from '../dist/core/cli/index.js';
+import { resolveDbConfig } from '../dist/core/load-file.js';
+import { getCreateIndexQueries, getCreateTableQuery } from '../dist/core/queries.js';
+import { isDbError } from '../dist/runtime/utils.js';
+
+const singleQuerySchema = z.object({
+ sql: z.string(),
+ args: z.array(z.any()).or(z.record(z.string(), z.any())),
+});
+
+const querySchema = singleQuerySchema.or(z.array(singleQuerySchema));
+
+let portIncrementer = 8030;
+
+/**
+ * @param {import('astro').AstroConfig} astroConfig
+ * @param {number | undefined} port
+ */
+export async function setupRemoteDbServer(astroConfig) {
+ const port = portIncrementer++;
+ process.env.ASTRO_STUDIO_REMOTE_DB_URL = `http://localhost:${port}`;
+ process.env.ASTRO_INTERNAL_TEST_REMOTE = true;
+ const server = createRemoteDbServer().listen(port);
+
+ const { dbConfig } = await resolveDbConfig(astroConfig);
+ const setupQueries = [];
+ for (const [name, table] of Object.entries(dbConfig?.tables ?? {})) {
+ const createQuery = getCreateTableQuery(name, table);
+ const indexQueries = getCreateIndexQueries(name, table);
+ setupQueries.push(createQuery, ...indexQueries);
+ }
+ await fetch(`http://localhost:${port}/db/query`, {
+ method: 'POST',
+ body: JSON.stringify(setupQueries.map((sql) => ({ sql, args: [] }))),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ await cli({
+ config: astroConfig,
+ flags: {
+ _: [undefined, 'astro', 'db', 'execute', 'db/seed.ts'],
+ remote: true,
+ },
+ });
+
+ return {
+ server,
+ async stop() {
+ delete process.env.ASTRO_STUDIO_REMOTE_DB_URL;
+ delete process.env.ASTRO_INTERNAL_TEST_REMOTE;
+ return new Promise((resolve, reject) => {
+ server.close((err) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+ },
+ };
+}
+
+export async function initializeRemoteDb(astroConfig) {
+ await cli({
+ config: astroConfig,
+ flags: {
+ _: [undefined, 'astro', 'db', 'push'],
+ remote: true,
+ },
+ });
+ await cli({
+ config: astroConfig,
+ flags: {
+ _: [undefined, 'astro', 'db', 'execute', 'db/seed.ts'],
+ remote: true,
+ },
+ });
+}
+
+/**
+ * Clears the environment variables related to Astro DB and Astro Studio.
+ */
+export function clearEnvironment() {
+ const keys = Array.from(Object.keys(process.env));
+ for (const key of keys) {
+ if (key.startsWith('ASTRO_DB_') || key.startsWith('ASTRO_STUDIO_')) {
+ delete process.env[key];
+ }
+ }
+}
+
+function createRemoteDbServer() {
+ const dbClient = createClient({
+ url: ':memory:',
+ });
+ const server = createServer((req, res) => {
+ if (
+ !req.url.startsWith('/db/query') ||
+ req.method !== 'POST' ||
+ req.headers['content-type'] !== 'application/json'
+ ) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(
+ JSON.stringify({
+ success: false,
+ }),
+ );
+ return;
+ }
+ const rawBody = [];
+ req.on('data', (chunk) => {
+ rawBody.push(chunk);
+ });
+ req.on('end', async () => {
+ let json;
+ try {
+ json = JSON.parse(Buffer.concat(rawBody).toString());
+ } catch {
+ applyParseError(res);
+ return;
+ }
+ const parsed = querySchema.safeParse(json);
+ if (parsed.success === false) {
+ applyParseError(res);
+ return;
+ }
+ const body = parsed.data;
+ try {
+ const result = Array.isArray(body)
+ ? await dbClient.batch(body)
+ : await dbClient.execute(body);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(result));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.statusMessage = e.message;
+ res.end(
+ JSON.stringify({
+ success: false,
+ error: {
+ code: isDbError(e) ? e.code : 'SQLITE_QUERY_FAILED',
+ details: e.message,
+ },
+ }),
+ );
+ }
+ });
+ });
+
+ server.on('close', () => {
+ dbClient.close();
+ });
+
+ return server;
+}
+
+function applyParseError(res) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.statusMessage = 'Invalid request body';
+ res.end(
+ JSON.stringify({
+ // Use JSON response with `success: boolean` property
+ // to match remote error responses.
+ success: false,
+ }),
+ );
+}
diff --git a/packages/db/test/unit/column-queries.test.js b/packages/db/test/unit/column-queries.test.js
new file mode 100644
index 000000000..e4bb027a4
--- /dev/null
+++ b/packages/db/test/unit/column-queries.test.js
@@ -0,0 +1,496 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import {
+ getMigrationQueries,
+ getTableChangeQueries,
+} from '../../dist/core/cli/migration-queries.js';
+import { MIGRATION_VERSION } from '../../dist/core/consts.js';
+import { tableSchema } from '../../dist/core/schemas.js';
+import { NOW, column, defineTable } from '../../dist/runtime/virtual.js';
+
+const TABLE_NAME = 'Users';
+
+// `parse` to resolve schema transformations
+// ex. convert column.date() to ISO strings
+const userInitial = tableSchema.parse(
+ defineTable({
+ columns: {
+ name: column.text(),
+ age: column.number(),
+ email: column.text({ unique: true }),
+ mi: column.text({ optional: true }),
+ },
+ }),
+);
+
+function userChangeQueries(oldTable, newTable) {
+ return getTableChangeQueries({
+ tableName: TABLE_NAME,
+ oldTable,
+ newTable,
+ });
+}
+
+function configChangeQueries(oldTables, newTables) {
+ return getMigrationQueries({
+ oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION },
+ newSnapshot: { schema: newTables, version: MIGRATION_VERSION },
+ });
+}
+
+describe('column queries', () => {
+ describe('getMigrationQueries', () => {
+ it('should be empty when tables are the same', async () => {
+ const oldTables = { [TABLE_NAME]: userInitial };
+ const newTables = { [TABLE_NAME]: userInitial };
+ const { queries } = await configChangeQueries(oldTables, newTables);
+ assert.deepEqual(queries, []);
+ });
+
+ it('should create table for new tables', async () => {
+ const oldTables = {};
+ const newTables = { [TABLE_NAME]: userInitial };
+ const { queries } = await configChangeQueries(oldTables, newTables);
+ assert.deepEqual(queries, [
+ `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`,
+ ]);
+ });
+
+ it('should drop table for removed tables', async () => {
+ const oldTables = { [TABLE_NAME]: userInitial };
+ const newTables = {};
+ const { queries } = await configChangeQueries(oldTables, newTables);
+ assert.deepEqual(queries, [`DROP TABLE "${TABLE_NAME}"`]);
+ });
+
+ it('should error if possible table rename is detected', async () => {
+ const rename = 'Peeps';
+ const oldTables = { [TABLE_NAME]: userInitial };
+ const newTables = { [rename]: userInitial };
+ let error = null;
+ try {
+ await configChangeQueries(oldTables, newTables);
+ } catch (e) {
+ error = e.message;
+ }
+ assert.match(error, /Potential table rename detected/);
+ });
+
+ it('should error if possible column rename is detected', async () => {
+ const blogInitial = tableSchema.parse({
+ columns: {
+ title: column.text(),
+ },
+ });
+ const blogFinal = tableSchema.parse({
+ columns: {
+ title2: column.text(),
+ },
+ });
+ let error = null;
+ try {
+ await configChangeQueries({ [TABLE_NAME]: blogInitial }, { [TABLE_NAME]: blogFinal });
+ } catch (e) {
+ error = e.message;
+ }
+ assert.match(error, /Potential column rename detected/);
+ });
+ });
+
+ describe('getTableChangeQueries', () => {
+ it('should be empty when tables are the same', async () => {
+ const { queries } = await userChangeQueries(userInitial, userInitial);
+ assert.deepEqual(queries, []);
+ });
+
+ it('should return warning if column type change introduces data loss', async () => {
+ const blogInitial = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ date: column.text(),
+ },
+ });
+ const blogFinal = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ date: column.date(),
+ },
+ });
+ const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal);
+ assert.deepEqual(queries, [
+ 'DROP TABLE "Users"',
+ 'CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "date" text NOT NULL)',
+ ]);
+ assert.equal(confirmations.length, 1);
+ });
+
+ it('should return warning if new required column added', async () => {
+ const blogInitial = tableSchema.parse({
+ ...userInitial,
+ columns: {},
+ });
+ const blogFinal = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ date: column.date({ optional: false }),
+ },
+ });
+ const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal);
+ assert.deepEqual(queries, [
+ 'DROP TABLE "Users"',
+ 'CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "date" text NOT NULL)',
+ ]);
+ assert.equal(confirmations.length, 1);
+ });
+
+ it('should return warning if non-number primary key with no default added', async () => {
+ const blogInitial = tableSchema.parse({
+ ...userInitial,
+ columns: {},
+ });
+ const blogFinal = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ id: column.text({ primaryKey: true }),
+ },
+ });
+ const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal);
+ assert.deepEqual(queries, [
+ 'DROP TABLE "Users"',
+ 'CREATE TABLE "Users" ("id" text PRIMARY KEY)',
+ ]);
+ assert.equal(confirmations.length, 1);
+ });
+
+ it('should be empty when type updated to same underlying SQL type', async () => {
+ const blogInitial = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ title: column.text(),
+ draft: column.boolean(),
+ },
+ });
+ const blogFinal = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ ...blogInitial.columns,
+ draft: column.number(),
+ },
+ });
+ const { queries } = await userChangeQueries(blogInitial, blogFinal);
+ assert.deepEqual(queries, []);
+ });
+
+ it('should respect user primary key without adding a hidden id', async () => {
+ const user = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ id: column.number({ primaryKey: true }),
+ },
+ });
+
+ const userFinal = tableSchema.parse({
+ ...user,
+ columns: {
+ ...user.columns,
+ name: column.text({ unique: true, optional: true }),
+ },
+ });
+
+ const { queries } = await userChangeQueries(user, userFinal);
+ assert.equal(queries[0] !== undefined, true);
+ const tempTableName = getTempTableName(queries[0]);
+
+ assert.deepEqual(queries, [
+ `CREATE TABLE \"${tempTableName}\" (\"name\" text UNIQUE, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`,
+ `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\", \"id\") SELECT \"name\", \"age\", \"email\", \"mi\", \"id\" FROM \"Users\"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+
+ describe('Lossy table recreate', () => {
+ it('when changing a column type', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ age: column.text(),
+ },
+ };
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+
+ assert.deepEqual(queries, [
+ 'DROP TABLE "Users"',
+ `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`,
+ ]);
+ });
+
+ it('when adding a required column without a default', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ phoneNumber: column.text(),
+ },
+ };
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+
+ assert.deepEqual(queries, [
+ 'DROP TABLE "Users"',
+ `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text NOT NULL)`,
+ ]);
+ });
+ });
+
+ describe('Lossless table recreate', () => {
+ it('when adding a primary key', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ id: column.number({ primaryKey: true }),
+ },
+ };
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+ assert.equal(queries[0] !== undefined, true);
+
+ const tempTableName = getTempTableName(queries[0]);
+ assert.deepEqual(queries, [
+ `CREATE TABLE \"${tempTableName}\" (\"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`,
+ `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+
+ it('when dropping a primary key', async () => {
+ const user = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ id: column.number({ primaryKey: true }),
+ },
+ };
+
+ const { queries } = await userChangeQueries(user, userInitial);
+ assert.equal(queries[0] !== undefined, true);
+
+ const tempTableName = getTempTableName(queries[0]);
+ assert.deepEqual(queries, [
+ `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text)`,
+ `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+
+ it('when adding an optional unique column', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ phoneNumber: column.text({ unique: true, optional: true }),
+ },
+ };
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+ assert.equal(queries.length, 4);
+
+ const tempTableName = getTempTableName(queries[0]);
+ assert.equal(typeof tempTableName, 'string');
+ assert.deepEqual(queries, [
+ `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text UNIQUE)`,
+ `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+
+ it('when dropping unique column', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ },
+ };
+ delete userFinal.columns.email;
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+ assert.equal(queries.length, 4);
+ assert.equal(queries.length, 4);
+
+ const tempTableName = getTempTableName(queries[0]);
+ assert.equal(typeof tempTableName, 'string');
+ assert.deepEqual(queries, [
+ `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "mi" text)`,
+ `INSERT INTO "${tempTableName}" ("_id", "name", "age", "mi") SELECT "_id", "name", "age", "mi" FROM "Users"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+
+ it('when updating to a runtime default', async () => {
+ const initial = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ age: column.date(),
+ },
+ });
+
+ const userFinal = tableSchema.parse({
+ ...initial,
+ columns: {
+ ...initial.columns,
+ age: column.date({ default: NOW }),
+ },
+ });
+
+ const { queries } = await userChangeQueries(initial, userFinal);
+ assert.equal(queries.length, 4);
+
+ const tempTableName = getTempTableName(queries[0]);
+ assert.equal(typeof tempTableName, 'string');
+ assert.deepEqual(queries, [
+ `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" text NOT NULL UNIQUE, "mi" text)`,
+ `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+
+ it('when adding a column with a runtime default', async () => {
+ const userFinal = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ birthday: column.date({ default: NOW }),
+ },
+ });
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+ assert.equal(queries.length, 4);
+
+ const tempTableName = getTempTableName(queries[0]);
+ assert.equal(typeof tempTableName, 'string');
+ assert.deepEqual(queries, [
+ `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "birthday" text NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
+ `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+
+ /**
+ * REASON: to follow the "expand" and "contract" migration model,
+ * you'll need to update the schema from NOT NULL to NULL.
+ * It's up to the user to ensure all data follows the new schema!
+ *
+ * @see https://planetscale.com/blog/safely-making-database-schema-changes#backwards-compatible-changes
+ */
+ it('when changing a column to required', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ mi: column.text(),
+ },
+ };
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+
+ assert.equal(queries.length, 4);
+
+ const tempTableName = getTempTableName(queries[0]);
+ assert.equal(typeof tempTableName, 'string');
+ assert.deepEqual(queries, [
+ `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text NOT NULL)`,
+ `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+
+ it('when changing a column to unique', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ age: column.number({ unique: true }),
+ },
+ };
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+ assert.equal(queries.length, 4);
+
+ const tempTableName = getTempTableName(queries[0]);
+ assert.equal(typeof tempTableName, 'string');
+ assert.deepEqual(queries, [
+ `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL UNIQUE, "email" text NOT NULL UNIQUE, "mi" text)`,
+ `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
+ 'DROP TABLE "Users"',
+ `ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
+ ]);
+ });
+ });
+
+ describe('ALTER ADD COLUMN', () => {
+ it('when adding an optional column', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ birthday: column.date({ optional: true }),
+ },
+ };
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+ assert.deepEqual(queries, ['ALTER TABLE "Users" ADD COLUMN "birthday" text']);
+ });
+
+ it('when adding a required column with default', async () => {
+ const defaultDate = new Date('2023-01-01');
+ const userFinal = tableSchema.parse({
+ ...userInitial,
+ columns: {
+ ...userInitial.columns,
+ birthday: column.date({ default: new Date('2023-01-01') }),
+ },
+ });
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+ assert.deepEqual(queries, [
+ `ALTER TABLE "Users" ADD COLUMN "birthday" text NOT NULL DEFAULT '${defaultDate.toISOString()}'`,
+ ]);
+ });
+ });
+
+ describe('ALTER DROP COLUMN', () => {
+ it('when removing optional or required columns', async () => {
+ const userFinal = {
+ ...userInitial,
+ columns: {
+ name: userInitial.columns.name,
+ email: userInitial.columns.email,
+ },
+ };
+
+ const { queries } = await userChangeQueries(userInitial, userFinal);
+ assert.deepEqual(queries, [
+ 'ALTER TABLE "Users" DROP COLUMN "age"',
+ 'ALTER TABLE "Users" DROP COLUMN "mi"',
+ ]);
+ });
+ });
+ });
+});
+
+/** @param {string} query */
+function getTempTableName(query) {
+ return /Users_[a-z\d]+/.exec(query)?.[0];
+}
diff --git a/packages/db/test/unit/db-client.test.js b/packages/db/test/unit/db-client.test.js
new file mode 100644
index 000000000..22df2610e
--- /dev/null
+++ b/packages/db/test/unit/db-client.test.js
@@ -0,0 +1,60 @@
+import assert from 'node:assert';
+import test, { describe } from 'node:test';
+import { parseOpts } from '../../dist/runtime/db-client.js';
+
+describe('db client config', () => {
+ test('parse config options from URL (docs example url)', () => {
+ const remoteURLToParse = new URL(
+ 'file://local-copy.db?encryptionKey=your-encryption-key&syncInterval=60&syncUrl=libsql%3A%2F%2Fyour.server.io',
+ );
+ const options = Object.fromEntries(remoteURLToParse.searchParams.entries());
+
+ const config = parseOpts(options);
+
+ assert.deepEqual(config, {
+ encryptionKey: 'your-encryption-key',
+ syncInterval: 60,
+ syncUrl: 'libsql://your.server.io',
+ });
+ });
+
+ test('parse config options from URL (test booleans without value)', () => {
+ const remoteURLToParse = new URL('file://local-copy.db?readYourWrites&offline&tls');
+ const options = Object.fromEntries(remoteURLToParse.searchParams.entries());
+
+ const config = parseOpts(options);
+
+ assert.deepEqual(config, {
+ readYourWrites: true,
+ offline: true,
+ tls: true,
+ });
+ });
+
+ test('parse config options from URL (test booleans with value)', () => {
+ const remoteURLToParse = new URL(
+ 'file://local-copy.db?readYourWrites=true&offline=true&tls=true',
+ );
+ const options = Object.fromEntries(remoteURLToParse.searchParams.entries());
+
+ const config = parseOpts(options);
+
+ assert.deepEqual(config, {
+ readYourWrites: true,
+ offline: true,
+ tls: true,
+ });
+ });
+
+ test('parse config options from URL (test numbers)', () => {
+ const remoteURLToParse = new URL('file://local-copy.db?syncInterval=60&concurrency=2');
+ const options = Object.fromEntries(remoteURLToParse.searchParams.entries());
+
+ const config = parseOpts(options);
+
+ assert.deepEqual(config, {
+ syncInterval: 60,
+ concurrency: 2,
+ });
+ });
+});
diff --git a/packages/db/test/unit/index-queries.test.js b/packages/db/test/unit/index-queries.test.js
new file mode 100644
index 000000000..4b4722baa
--- /dev/null
+++ b/packages/db/test/unit/index-queries.test.js
@@ -0,0 +1,283 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { getTableChangeQueries } from '../../dist/core/cli/migration-queries.js';
+import { dbConfigSchema, tableSchema } from '../../dist/core/schemas.js';
+import { column } from '../../dist/runtime/virtual.js';
+
+const userInitial = tableSchema.parse({
+ columns: {
+ name: column.text(),
+ age: column.number(),
+ email: column.text({ unique: true }),
+ mi: column.text({ optional: true }),
+ },
+ indexes: {},
+ writable: false,
+});
+
+describe('index queries', () => {
+ it('generates index names by table and combined column names', async () => {
+ // Use dbConfigSchema.parse to resolve generated idx names
+ const dbConfig = dbConfigSchema.parse({
+ tables: {
+ oldTable: userInitial,
+ newTable: {
+ ...userInitial,
+ indexes: [
+ { on: ['name', 'age'], unique: false },
+ { on: ['email'], unique: true },
+ ],
+ },
+ },
+ });
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: dbConfig.tables.oldTable,
+ newTable: dbConfig.tables.newTable,
+ });
+
+ assert.deepEqual(queries, [
+ 'CREATE INDEX "newTable_age_name_idx" ON "user" ("age", "name")',
+ 'CREATE UNIQUE INDEX "newTable_email_idx" ON "user" ("email")',
+ ]);
+ });
+
+ it('generates index names with consistent column ordering', async () => {
+ const initial = dbConfigSchema.parse({
+ tables: {
+ user: {
+ ...userInitial,
+ indexes: [
+ { on: ['email'], unique: true },
+ { on: ['name', 'age'], unique: false },
+ ],
+ },
+ },
+ });
+
+ const final = dbConfigSchema.parse({
+ tables: {
+ user: {
+ ...userInitial,
+ indexes: [
+ // flip columns
+ { on: ['age', 'name'], unique: false },
+ // flip index order
+ { on: ['email'], unique: true },
+ ],
+ },
+ },
+ });
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: initial.tables.user,
+ newTable: final.tables.user,
+ });
+
+ assert.equal(queries.length, 0);
+ });
+
+ it('does not trigger queries when changing from legacy to new format', async () => {
+ const initial = dbConfigSchema.parse({
+ tables: {
+ user: {
+ ...userInitial,
+ indexes: {
+ emailIdx: { on: ['email'], unique: true },
+ nameAgeIdx: { on: ['name', 'age'], unique: false },
+ },
+ },
+ },
+ });
+
+ const final = dbConfigSchema.parse({
+ tables: {
+ user: {
+ ...userInitial,
+ indexes: [
+ { on: ['email'], unique: true, name: 'emailIdx' },
+ { on: ['name', 'age'], unique: false, name: 'nameAgeIdx' },
+ ],
+ },
+ },
+ });
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: initial.tables.user,
+ newTable: final.tables.user,
+ });
+
+ assert.equal(queries.length, 0);
+ });
+
+ it('adds indexes', async () => {
+ const dbConfig = dbConfigSchema.parse({
+ tables: {
+ oldTable: userInitial,
+ newTable: {
+ ...userInitial,
+ indexes: [
+ { on: ['name'], unique: false, name: 'nameIdx' },
+ { on: ['email'], unique: true, name: 'emailIdx' },
+ ],
+ },
+ },
+ });
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: dbConfig.tables.oldTable,
+ newTable: dbConfig.tables.newTable,
+ });
+
+ assert.deepEqual(queries, [
+ 'CREATE INDEX "nameIdx" ON "user" ("name")',
+ 'CREATE UNIQUE INDEX "emailIdx" ON "user" ("email")',
+ ]);
+ });
+
+ it('drops indexes', async () => {
+ const dbConfig = dbConfigSchema.parse({
+ tables: {
+ oldTable: {
+ ...userInitial,
+ indexes: [
+ { on: ['name'], unique: false, name: 'nameIdx' },
+ { on: ['email'], unique: true, name: 'emailIdx' },
+ ],
+ },
+ newTable: {
+ ...userInitial,
+ indexes: {},
+ },
+ },
+ });
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: dbConfig.tables.oldTable,
+ newTable: dbConfig.tables.newTable,
+ });
+
+ assert.deepEqual(queries, ['DROP INDEX "nameIdx"', 'DROP INDEX "emailIdx"']);
+ });
+
+ it('drops and recreates modified indexes', async () => {
+ const dbConfig = dbConfigSchema.parse({
+ tables: {
+ oldTable: {
+ ...userInitial,
+ indexes: [
+ { unique: false, on: ['name'], name: 'nameIdx' },
+ { unique: true, on: ['email'], name: 'emailIdx' },
+ ],
+ },
+ newTable: {
+ ...userInitial,
+ indexes: [
+ { unique: true, on: ['name'], name: 'nameIdx' },
+ { on: ['email'], name: 'emailIdx' },
+ ],
+ },
+ },
+ });
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: dbConfig.tables.oldTable,
+ newTable: dbConfig.tables.newTable,
+ });
+
+ assert.deepEqual(queries, [
+ 'DROP INDEX "nameIdx"',
+ 'DROP INDEX "emailIdx"',
+ 'CREATE UNIQUE INDEX "nameIdx" ON "user" ("name")',
+ 'CREATE INDEX "emailIdx" ON "user" ("email")',
+ ]);
+ });
+
+ describe('legacy object config', () => {
+ it('adds indexes', async () => {
+ /** @type {import('../../dist/core/types.js').DBTable} */
+ const userFinal = {
+ ...userInitial,
+ indexes: {
+ nameIdx: { on: ['name'], unique: false },
+ emailIdx: { on: ['email'], unique: true },
+ },
+ };
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: userInitial,
+ newTable: userFinal,
+ });
+
+ assert.deepEqual(queries, [
+ 'CREATE INDEX "nameIdx" ON "user" ("name")',
+ 'CREATE UNIQUE INDEX "emailIdx" ON "user" ("email")',
+ ]);
+ });
+
+ it('drops indexes', async () => {
+ /** @type {import('../../dist/core/types.js').DBTable} */
+ const initial = {
+ ...userInitial,
+ indexes: {
+ nameIdx: { on: ['name'], unique: false },
+ emailIdx: { on: ['email'], unique: true },
+ },
+ };
+
+ /** @type {import('../../dist/core/types.js').DBTable} */
+ const final = {
+ ...userInitial,
+ indexes: {},
+ };
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: initial,
+ newTable: final,
+ });
+
+ assert.deepEqual(queries, ['DROP INDEX "nameIdx"', 'DROP INDEX "emailIdx"']);
+ });
+
+ it('drops and recreates modified indexes', async () => {
+ /** @type {import('../../dist/core/types.js').DBTable} */
+ const initial = {
+ ...userInitial,
+ indexes: {
+ nameIdx: { on: ['name'], unique: false },
+ emailIdx: { on: ['email'], unique: true },
+ },
+ };
+
+ /** @type {import('../../dist/core/types.js').DBTable} */
+ const final = {
+ ...userInitial,
+ indexes: {
+ nameIdx: { on: ['name'], unique: true },
+ emailIdx: { on: ['email'] },
+ },
+ };
+
+ const { queries } = await getTableChangeQueries({
+ tableName: 'user',
+ oldTable: initial,
+ newTable: final,
+ });
+
+ assert.deepEqual(queries, [
+ 'DROP INDEX "nameIdx"',
+ 'DROP INDEX "emailIdx"',
+ 'CREATE UNIQUE INDEX "nameIdx" ON "user" ("name")',
+ 'CREATE INDEX "emailIdx" ON "user" ("email")',
+ ]);
+ });
+ });
+});
diff --git a/packages/db/test/unit/reference-queries.test.js b/packages/db/test/unit/reference-queries.test.js
new file mode 100644
index 000000000..04f5f84aa
--- /dev/null
+++ b/packages/db/test/unit/reference-queries.test.js
@@ -0,0 +1,169 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { getTableChangeQueries } from '../../dist/core/cli/migration-queries.js';
+import { tablesSchema } from '../../dist/core/schemas.js';
+import { column, defineTable } from '../../dist/runtime/virtual.js';
+
+const BaseUser = defineTable({
+ columns: {
+ id: column.number({ primaryKey: true }),
+ name: column.text(),
+ age: column.number(),
+ email: column.text({ unique: true }),
+ mi: column.text({ optional: true }),
+ },
+});
+
+const BaseSentBox = defineTable({
+ columns: {
+ to: column.number(),
+ toName: column.text(),
+ subject: column.text(),
+ body: column.text(),
+ },
+});
+
+/**
+ * @typedef {import('../../dist/core/types.js').DBTable} DBTable
+ * @param {{ User: DBTable, SentBox: DBTable }} params
+ * @returns
+ */
+function resolveReferences(
+ { User = BaseUser, SentBox = BaseSentBox } = {
+ User: BaseUser,
+ SentBox: BaseSentBox,
+ },
+) {
+ return tablesSchema.parse({ User, SentBox });
+}
+
+function userChangeQueries(oldTable, newTable) {
+ return getTableChangeQueries({
+ tableName: 'User',
+ oldTable,
+ newTable,
+ });
+}
+
+describe('reference queries', () => {
+ it('adds references with lossless table recreate', async () => {
+ const { SentBox: Initial } = resolveReferences();
+ const { SentBox: Final } = resolveReferences({
+ SentBox: defineTable({
+ columns: {
+ ...BaseSentBox.columns,
+ to: column.number({ references: () => BaseUser.columns.id }),
+ },
+ }),
+ });
+
+ const { queries } = await userChangeQueries(Initial, Final);
+
+ assert.equal(queries[0] !== undefined, true);
+ const tempTableName = getTempTableName(queries[0]);
+ assert.notEqual(typeof tempTableName, 'undefined');
+
+ assert.deepEqual(queries, [
+ `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL REFERENCES \"User\" (\"id\"), \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL)`,
+ `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`,
+ 'DROP TABLE "User"',
+ `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`,
+ ]);
+ });
+
+ it('removes references with lossless table recreate', async () => {
+ const { SentBox: Initial } = resolveReferences({
+ SentBox: defineTable({
+ columns: {
+ ...BaseSentBox.columns,
+ to: column.number({ references: () => BaseUser.columns.id }),
+ },
+ }),
+ });
+ const { SentBox: Final } = resolveReferences();
+
+ const { queries } = await userChangeQueries(Initial, Final);
+
+ assert.equal(queries[0] !== undefined, true);
+ const tempTableName = getTempTableName(queries[0]);
+ assert.notEqual(typeof tempTableName, 'undefined');
+
+ assert.deepEqual(queries, [
+ `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL)`,
+ `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`,
+ 'DROP TABLE "User"',
+ `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`,
+ ]);
+ });
+
+ it('does not use ADD COLUMN when adding optional column with reference', async () => {
+ const { SentBox: Initial } = resolveReferences();
+ const { SentBox: Final } = resolveReferences({
+ SentBox: defineTable({
+ columns: {
+ ...BaseSentBox.columns,
+ from: column.number({ references: () => BaseUser.columns.id, optional: true }),
+ },
+ }),
+ });
+
+ const { queries } = await userChangeQueries(Initial, Final);
+ assert.equal(queries[0] !== undefined, true);
+ const tempTableName = getTempTableName(queries[0]);
+
+ assert.deepEqual(queries, [
+ `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL, \"from\" integer REFERENCES \"User\" (\"id\"))`,
+ `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`,
+ 'DROP TABLE "User"',
+ `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`,
+ ]);
+ });
+
+ it('adds and updates foreign key with lossless table recreate', async () => {
+ const { SentBox: InitialWithoutFK } = resolveReferences();
+ const { SentBox: InitialWithDifferentFK } = resolveReferences({
+ SentBox: defineTable({
+ ...BaseSentBox,
+ foreignKeys: [{ columns: ['to'], references: () => [BaseUser.columns.id] }],
+ }),
+ });
+ const { SentBox: Final } = resolveReferences({
+ SentBox: defineTable({
+ ...BaseSentBox,
+ foreignKeys: [
+ {
+ columns: ['to', 'toName'],
+ references: () => [BaseUser.columns.id, BaseUser.columns.name],
+ },
+ ],
+ }),
+ });
+
+ const expected = (tempTableName) => [
+ `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL, FOREIGN KEY (\"to\", \"toName\") REFERENCES \"User\"(\"id\", \"name\"))`,
+ `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`,
+ 'DROP TABLE "User"',
+ `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`,
+ ];
+
+ const addedForeignKey = await userChangeQueries(InitialWithoutFK, Final);
+ const updatedForeignKey = await userChangeQueries(InitialWithDifferentFK, Final);
+
+ assert.notEqual(typeof addedForeignKey.queries[0], 'undefined');
+ assert.notEqual(typeof updatedForeignKey.queries[0], 'undefined');
+ assert.deepEqual(
+ addedForeignKey.queries,
+ expected(getTempTableName(addedForeignKey.queries[0])),
+ );
+
+ assert.deepEqual(
+ updatedForeignKey.queries,
+ expected(getTempTableName(updatedForeignKey.queries[0])),
+ );
+ });
+});
+
+/** @param {string} query */
+function getTempTableName(query) {
+ return /User_[a-z\d]+/.exec(query)?.[0];
+}
diff --git a/packages/db/test/unit/remote-info.test.js b/packages/db/test/unit/remote-info.test.js
new file mode 100644
index 000000000..2c58f28b7
--- /dev/null
+++ b/packages/db/test/unit/remote-info.test.js
@@ -0,0 +1,119 @@
+import assert from 'node:assert';
+import test, { after, beforeEach, describe } from 'node:test';
+import { getManagedRemoteToken, getRemoteDatabaseInfo } from '../../dist/core/utils.js';
+import { clearEnvironment } from '../test-utils.js';
+
+describe('RemoteDatabaseInfo', () => {
+ beforeEach(() => {
+ clearEnvironment();
+ });
+
+ test('default remote info', () => {
+ const dbInfo = getRemoteDatabaseInfo();
+
+ assert.deepEqual(dbInfo, {
+ type: 'studio',
+ url: 'https://db.services.astro.build',
+ });
+ });
+
+ test('configured Astro Studio remote', () => {
+ process.env.ASTRO_STUDIO_REMOTE_DB_URL = 'https://studio.astro.build';
+ const dbInfo = getRemoteDatabaseInfo();
+
+ assert.deepEqual(dbInfo, {
+ type: 'studio',
+ url: 'https://studio.astro.build',
+ });
+ });
+
+ test('configured libSQL remote', () => {
+ process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted';
+ const dbInfo = getRemoteDatabaseInfo();
+
+ assert.deepEqual(dbInfo, {
+ type: 'libsql',
+ url: 'libsql://libsql.self.hosted',
+ });
+ });
+
+ test('configured both libSQL and Studio remote', () => {
+ process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted';
+ process.env.ASTRO_STUDIO_REMOTE_DB_URL = 'https://studio.astro.build';
+ const dbInfo = getRemoteDatabaseInfo();
+
+ assert.deepEqual(dbInfo, {
+ type: 'studio',
+ url: 'https://studio.astro.build',
+ });
+ });
+});
+
+describe('RemoteManagedToken', () => {
+ // Avoid conflicts with other tests
+ beforeEach(() => {
+ clearEnvironment();
+ process.env.ASTRO_STUDIO_APP_TOKEN = 'studio token';
+ process.env.ASTRO_DB_APP_TOKEN = 'db token';
+ });
+ after(() => {
+ clearEnvironment();
+ });
+
+ test('given token for default remote', async () => {
+ const { token } = await getManagedRemoteToken('given token');
+ assert.equal(token, 'given token');
+ });
+
+ test('token for default remote', async () => {
+ const { token } = await getManagedRemoteToken();
+
+ assert.equal(token, 'studio token');
+ });
+
+ test('given token for configured Astro Studio remote', async () => {
+ process.env.ASTRO_STUDIO_REMOTE_DB_URL = 'https://studio.astro.build';
+ const { token } = await getManagedRemoteToken('given token');
+ assert.equal(token, 'given token');
+ });
+
+ test('token for configured Astro Studio remote', async () => {
+ process.env.ASTRO_STUDIO_REMOTE_DB_URL = 'https://studio.astro.build';
+ const { token } = await getManagedRemoteToken();
+
+ assert.equal(token, 'studio token');
+ });
+
+ test('given token for configured libSQL remote', async () => {
+ process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted';
+ const { token } = await getManagedRemoteToken('given token');
+ assert.equal(token, 'given token');
+ });
+
+ test('token for configured libSQL remote', async () => {
+ process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted';
+ const { token } = await getManagedRemoteToken();
+
+ assert.equal(token, 'db token');
+ });
+
+ test('token for given Astro Studio remote', async () => {
+ process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted';
+ const { token } = await getManagedRemoteToken(undefined, {
+ type: 'studio',
+ url: 'https://studio.astro.build',
+ });
+
+ assert.equal(token, 'studio token');
+ });
+
+ test('token for given libSQL remote', async () => {
+ process.env.ASTRO_STUDIO_REMOTE_URL = 'libsql://libsql.self.hosted';
+ const { token } = await getManagedRemoteToken(undefined, {
+ type: 'libsql',
+ url: 'libsql://libsql.self.hosted',
+ });
+
+ assert.equal(token, 'db token');
+ });
+});
diff --git a/packages/db/test/unit/reset-queries.test.js b/packages/db/test/unit/reset-queries.test.js
new file mode 100644
index 000000000..9fb99f91e
--- /dev/null
+++ b/packages/db/test/unit/reset-queries.test.js
@@ -0,0 +1,54 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { getMigrationQueries } from '../../dist/core/cli/migration-queries.js';
+import { MIGRATION_VERSION } from '../../dist/core/consts.js';
+import { tableSchema } from '../../dist/core/schemas.js';
+import { column, defineTable } from '../../dist/runtime/virtual.js';
+
+const TABLE_NAME = 'Users';
+
+// `parse` to resolve schema transformations
+// ex. convert column.date() to ISO strings
+const userInitial = tableSchema.parse(
+ defineTable({
+ columns: {
+ name: column.text(),
+ age: column.number(),
+ email: column.text({ unique: true }),
+ mi: column.text({ optional: true }),
+ },
+ }),
+);
+
+describe('force reset', () => {
+ describe('getMigrationQueries', () => {
+ it('should drop table and create new version', async () => {
+ const oldTables = { [TABLE_NAME]: userInitial };
+ const newTables = { [TABLE_NAME]: userInitial };
+ const { queries } = await getMigrationQueries({
+ oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION },
+ newSnapshot: { schema: newTables, version: MIGRATION_VERSION },
+ reset: true,
+ });
+
+ assert.deepEqual(queries, [
+ `DROP TABLE IF EXISTS "${TABLE_NAME}"`,
+ `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`,
+ ]);
+ });
+
+ it('should not drop table when previous snapshot did not have it', async () => {
+ const oldTables = {};
+ const newTables = { [TABLE_NAME]: userInitial };
+ const { queries } = await getMigrationQueries({
+ oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION },
+ newSnapshot: { schema: newTables, version: MIGRATION_VERSION },
+ reset: true,
+ });
+
+ assert.deepEqual(queries, [
+ `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`,
+ ]);
+ });
+ });
+});