diff options
Diffstat (limited to 'packages/db/test')
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 +``` + +[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics) +[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics) +[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json) + +> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun! + + + +## 🚀 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)`, + ]); + }); + }); +}); |