0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-31 23:31:30 -05:00

Fix db for projects without a seed file or with integrations (#10385)

* Add fixture and tests for integration with no user db config

* Create database tables when integration seed files load

* Defer running seed function until all modules are loaded

* Update package name

* Add test & fixture for a project with no seed file

* `recreateTables()` from `seedLocal()` (and move it out of main runtime module)

* Fix typo after rebase

* FREEZE DON’T MOVE

* Move `seedLocal` export back to runtime

* Simplify seed file normalization

* Clean up test files

* Add build tests for no-seed and integration-only fixtures

* Add changeset
This commit is contained in:
Chris Swithinbank 2024-03-11 20:07:53 +01:00 committed by GitHub
parent cd5e8d4b93
commit 38abae47b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 314 additions and 74 deletions

View file

@ -0,0 +1,5 @@
---
"@astrojs/db": patch
---
Fixes support for integrations configuring `astro:db` and for projects that use `astro:db` but do not include a seed file.

View file

@ -1,15 +1,6 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { type SQL, sql } from 'drizzle-orm';
import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core';
import { normalizePath } from 'vite';
import { createLocalDatabaseClient } from '../../runtime/db-client.js';
import type { SqliteDB } from '../../runtime/index.js';
import {
SEED_DEV_FILE_NAME,
getCreateIndexQueries,
getCreateTableQuery,
} from '../../runtime/queries.js';
import { SEED_DEV_FILE_NAME } from '../../runtime/queries.js';
import { DB_PATH, RUNTIME_CONFIG_IMPORT, RUNTIME_IMPORT, VIRTUAL_MODULE_ID } from '../consts.js';
import type { DBTables } from '../types.js';
import { type VitePlugin, getDbDirectoryUrl, getRemoteDatabaseUrl } from '../utils.js';
@ -46,9 +37,6 @@ type VitePluginDBParams =
export function vitePluginDb(params: VitePluginDBParams): VitePlugin {
const srcDirPath = normalizePath(fileURLToPath(params.srcDir));
const seedFilePaths = SEED_DEV_FILE_NAME.map((name) =>
normalizePath(fileURLToPath(new URL(name, getDbDirectoryUrl(params.root))))
);
return {
name: 'astro:db',
enforce: 'pre',
@ -67,14 +55,6 @@ export function vitePluginDb(params: VitePluginDBParams): VitePlugin {
return resolved.virtual;
},
async load(id) {
// Recreate tables whenever a seed file is loaded.
if (seedFilePaths.some((f) => id === f)) {
await recreateTables({
db: createLocalDatabaseClient({ dbUrl: new URL(DB_PATH, params.root).href }),
tables: params.tables.get(),
});
}
if (id !== resolved.virtual && id !== resolved.seedVirtual) return;
if (params.connectToStudio) {
@ -113,7 +93,8 @@ export function getLocalVirtualModContents({
// for Vite import.meta.glob
(name) => new URL(name, getDbDirectoryUrl('file:///')).pathname
);
const resolveId = (id: string) => (id.startsWith('.') ? resolve(fileURLToPath(root), id) : id);
const resolveId = (id: string) =>
id.startsWith('.') ? normalizePath(fileURLToPath(new URL(id, root))) : id;
// Use top-level imports to correctly resolve `astro:db` within seed files.
// Dynamic imports cause a silent build failure,
// potentially because of circular module references.
@ -138,6 +119,8 @@ export const db = createLocalDatabaseClient({ dbUrl });
${
shouldSeed
? `await seedLocal({
db,
tables: ${JSON.stringify(tables)},
userSeedGlob: import.meta.glob(${JSON.stringify(userSeedFilePaths)}, { eager: true }),
integrationSeedFunctions: [${integrationSeedImportNames.join(',')}],
});`
@ -180,19 +163,3 @@ function getStringifiedCollectionExports(tables: DBTables) {
)
.join('\n');
}
const sqlite = new SQLiteAsyncDialect();
async function recreateTables({ db, tables }: { db: SqliteDB; tables: DBTables }) {
const setupQueries: SQL[] = [];
for (const [name, table] of Object.entries(tables)) {
const dropQuery = sql.raw(`DROP TABLE IF EXISTS ${sqlite.escapeName(name)}`);
const createQuery = sql.raw(getCreateTableQuery(name, table));
const indexQueries = getCreateIndexQueries(name, table);
setupQueries.push(dropQuery, createQuery, ...indexQueries.map((s) => sql.raw(s)));
}
await db.batch([
db.run(sql`pragma defer_foreign_keys=true;`),
...setupQueries.map((q) => db.run(q)),
]);
}

View file

@ -1,4 +1,3 @@
import { LibsqlError } from '@libsql/client';
import { type ColumnBuilderBaseConfig, type ColumnDataType, sql } from 'drizzle-orm';
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
import {
@ -10,7 +9,6 @@ import {
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core';
import { SEED_DEFAULT_EXPORT_ERROR, SEED_ERROR } from '../core/errors.js';
import { type DBColumn, type DBTable } from '../core/types.js';
import { type SerializedSQL, isSerializedSQL } from './types.js';
@ -18,40 +16,7 @@ export { sql };
export type SqliteDB = LibSQLDatabase;
export type { Table } from './types.js';
export { createRemoteDatabaseClient, createLocalDatabaseClient } from './db-client.js';
export async function seedLocal({
// Glob all potential seed files to catch renames and deletions.
userSeedGlob,
integrationSeedFunctions: integrationSeedFunctions,
}: {
userSeedGlob: Record<string, { default?: () => Promise<void> }>;
integrationSeedFunctions: Array<() => Promise<void>>;
}) {
const seedFilePath = Object.keys(userSeedGlob)[0];
if (seedFilePath) {
const mod = userSeedGlob[seedFilePath];
if (!mod.default) {
throw new Error(SEED_DEFAULT_EXPORT_ERROR(seedFilePath));
}
try {
await mod.default();
} catch (e) {
if (e instanceof LibsqlError) {
throw new Error(SEED_ERROR(e.message));
}
throw e;
}
}
for (const seedFn of integrationSeedFunctions) {
await seedFn().catch((e) => {
if (e instanceof LibsqlError) {
throw new Error(SEED_ERROR(e.message));
}
throw e;
});
}
}
export { seedLocal } from './seed-local.js';
export function hasPrimaryKey(column: DBColumn) {
return 'primaryKey' in column.schema && !!column.schema.primaryKey;

View file

@ -0,0 +1,58 @@
import { LibsqlError } from '@libsql/client';
import { sql, type SQL } from 'drizzle-orm';
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core';
import { SEED_DEFAULT_EXPORT_ERROR, SEED_ERROR } from '../core/errors.js';
import { type DBTables } from '../core/types.js';
import { getCreateIndexQueries, getCreateTableQuery } from './queries.js';
const sqlite = new SQLiteAsyncDialect();
export async function seedLocal({
db,
tables,
// Glob all potential seed files to catch renames and deletions.
userSeedGlob,
integrationSeedFunctions,
}: {
db: LibSQLDatabase;
tables: DBTables;
userSeedGlob: Record<string, { default?: () => Promise<void> }>;
integrationSeedFunctions: Array<() => Promise<void>>;
}) {
await recreateTables({ db, tables });
const seedFunctions: Array<() => Promise<void>> = [];
const seedFilePath = Object.keys(userSeedGlob)[0];
if (seedFilePath) {
const mod = userSeedGlob[seedFilePath];
if (!mod.default) throw new Error(SEED_DEFAULT_EXPORT_ERROR(seedFilePath));
seedFunctions.push(mod.default);
}
for (const seedFn of integrationSeedFunctions) {
seedFunctions.push(seedFn);
}
for (const seed of seedFunctions) {
try {
await seed();
} catch (e) {
if (e instanceof LibsqlError) {
throw new Error(SEED_ERROR(e.message));
}
throw e;
}
}
}
async function recreateTables({ db, tables }: { db: LibSQLDatabase; tables: DBTables }) {
const setupQueries: SQL[] = [];
for (const [name, table] of Object.entries(tables)) {
const dropQuery = sql.raw(`DROP TABLE IF EXISTS ${sqlite.escapeName(name)}`);
const createQuery = sql.raw(getCreateTableQuery(name, table));
const indexQueries = getCreateIndexQueries(name, table);
setupQueries.push(dropQuery, createQuery, ...indexQueries.map((s) => sql.raw(s)));
}
await db.batch([
db.run(sql`pragma defer_foreign_keys=true;`),
...setupQueries.map((q) => db.run(q)),
]);
}

View file

@ -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()],
});

View file

@ -0,0 +1,8 @@
import { menu } from './shared';
import { defineDb } from 'astro:db';
export default defineDb({
tables: {
menu,
},
});

View file

@ -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',
});
},
},
});
}

View file

@ -0,0 +1,14 @@
import { asDrizzleTable } from '@astrojs/db/utils';
import { menu } from './shared';
import { db } from 'astro:db';
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' },
]);
}

View file

@ -0,0 +1,9 @@
import { column, defineTable } from 'astro:db';
export const menu = defineTable({
columns: {
name: column.text(),
type: column.text(),
price: column.number(),
},
});

View file

@ -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:*"
}
}

View file

@ -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>

View file

@ -0,0 +1,7 @@
import db from '@astrojs/db';
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
integrations: [db()],
});

View file

@ -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 },
});

View file

@ -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:*"
}
}

View file

@ -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>

View file

@ -0,0 +1,47 @@
import { expect } from 'chai';
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');
expect(ul.children()).to.have.a.lengthOf(4);
expect(ul.children().eq(0).text()).to.equal('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');
expect(ul.children()).to.have.a.lengthOf(4);
expect(ul.children().eq(0).text()).to.equal('Pancakes');
});
});
});

View file

@ -0,0 +1,47 @@
import { expect } from 'chai';
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');
expect(ul.children()).to.have.a.lengthOf(5);
expect(ul.children().eq(0).text()).to.equal('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');
expect(ul.children()).to.have.a.lengthOf(5);
expect(ul.children().eq(0).text()).to.equal('Ben');
});
});
});

18
pnpm-lock.yaml generated
View file

@ -3909,6 +3909,15 @@ importers:
specifier: workspace:*
version: link:../../../../astro
packages/db/test/fixtures/integration-only:
dependencies:
'@astrojs/db':
specifier: workspace:*
version: link:../../..
astro:
specifier: workspace:*
version: link:../../../../astro
packages/db/test/fixtures/integrations:
dependencies:
'@astrojs/db':
@ -3918,6 +3927,15 @@ importers:
specifier: workspace:*
version: link:../../../../astro
packages/db/test/fixtures/no-seed:
dependencies:
'@astrojs/db':
specifier: workspace:*
version: link:../../..
astro:
specifier: workspace:*
version: link:../../../../astro
packages/db/test/fixtures/recipes:
dependencies:
'@astrojs/db':