diff --git a/.changeset/curvy-donkeys-knock.md b/.changeset/curvy-donkeys-knock.md deleted file mode 100644 index 0197a800e052..000000000000 --- a/.changeset/curvy-donkeys-knock.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"astro": patch ---- - -Fixes a regression introduced in v4.4.5 where image optimization did not work in dev mode when a base was configured. diff --git a/.changeset/few-worms-rush.md b/.changeset/few-worms-rush.md deleted file mode 100644 index 3ca708f94865..000000000000 --- a/.changeset/few-worms-rush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"astro": patch ---- - -Adds auto completion for `astro:` event names when adding or removing event listeners on `document`. diff --git a/.changeset/purple-tips-camp.md b/.changeset/purple-tips-camp.md new file mode 100644 index 000000000000..e2f1279b3ef3 --- /dev/null +++ b/.changeset/purple-tips-camp.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes jerky scrolling on IOS when using view transitions. diff --git a/.changeset/smooth-singers-kiss.md b/.changeset/smooth-singers-kiss.md deleted file mode 100644 index 56d1ea893af4..000000000000 --- a/.changeset/smooth-singers-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@astrojs/node": patch ---- - -Fixes the `server.host` option to properly listen on all network interfaces when set to `true` diff --git a/examples/basics/package.json b/examples/basics/package.json index 86f892f41087..e4946c0b9d90 100644 --- a/examples/basics/package.json +++ b/examples/basics/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.4.6" + "astro": "^4.4.9" } } diff --git a/examples/blog/package.json b/examples/blog/package.json index c51c8592d4bc..95f9cf22756d 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -14,6 +14,6 @@ "@astrojs/mdx": "^2.1.1", "@astrojs/rss": "^4.0.5", "@astrojs/sitemap": "^3.1.1", - "astro": "^4.4.6" + "astro": "^4.4.9" } } diff --git a/examples/component/package.json b/examples/component/package.json index 1cdb6b33662d..f29368a01892 100644 --- a/examples/component/package.json +++ b/examples/component/package.json @@ -15,7 +15,7 @@ ], "scripts": {}, "devDependencies": { - "astro": "^4.4.6" + "astro": "^4.4.9" }, "peerDependencies": { "astro": "^4.0.0" diff --git a/examples/framework-alpine/package.json b/examples/framework-alpine/package.json index b203a9e4167f..0c49a23bec6e 100644 --- a/examples/framework-alpine/package.json +++ b/examples/framework-alpine/package.json @@ -14,6 +14,6 @@ "@astrojs/alpinejs": "^0.4.0", "@types/alpinejs": "^3.13.5", "alpinejs": "^3.13.3", - "astro": "^4.4.6" + "astro": "^4.4.9" } } diff --git a/examples/framework-lit/package.json b/examples/framework-lit/package.json index 6d8d7ff1291a..e5206af4b2e9 100644 --- a/examples/framework-lit/package.json +++ b/examples/framework-lit/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/lit": "^4.0.1", "@webcomponents/template-shadowroot": "^0.2.1", - "astro": "^4.4.6", + "astro": "^4.4.9", "lit": "^3.1.2" } } diff --git a/examples/framework-multiple/package.json b/examples/framework-multiple/package.json index 65bb39f7d3e8..7d5cef5ba949 100644 --- a/examples/framework-multiple/package.json +++ b/examples/framework-multiple/package.json @@ -16,7 +16,7 @@ "@astrojs/solid-js": "^4.0.1", "@astrojs/svelte": "^5.2.0", "@astrojs/vue": "^4.0.8", - "astro": "^4.4.6", + "astro": "^4.4.9", "preact": "^10.19.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/examples/framework-preact/package.json b/examples/framework-preact/package.json index 5f61f5790fc4..efafb58ce98c 100644 --- a/examples/framework-preact/package.json +++ b/examples/framework-preact/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/preact": "^3.1.1", "@preact/signals": "^1.2.1", - "astro": "^4.4.6", + "astro": "^4.4.9", "preact": "^10.19.2" } } diff --git a/examples/framework-react/package.json b/examples/framework-react/package.json index ad2b47014a5f..fba2fb54fba2 100644 --- a/examples/framework-react/package.json +++ b/examples/framework-react/package.json @@ -14,7 +14,7 @@ "@astrojs/react": "^3.0.10", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", - "astro": "^4.4.6", + "astro": "^4.4.9", "react": "^18.2.0", "react-dom": "^18.2.0" } diff --git a/examples/framework-solid/package.json b/examples/framework-solid/package.json index f4a1cd063707..e4e5023f29a9 100644 --- a/examples/framework-solid/package.json +++ b/examples/framework-solid/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/solid-js": "^4.0.1", - "astro": "^4.4.6", + "astro": "^4.4.9", "solid-js": "^1.8.5" } } diff --git a/examples/framework-svelte/package.json b/examples/framework-svelte/package.json index c4b7e3b43332..b50d0c2e362b 100644 --- a/examples/framework-svelte/package.json +++ b/examples/framework-svelte/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/svelte": "^5.2.0", - "astro": "^4.4.6", + "astro": "^4.4.9", "svelte": "^4.2.5" } } diff --git a/examples/framework-vue/package.json b/examples/framework-vue/package.json index 5864c5a006a1..453833c1f469 100644 --- a/examples/framework-vue/package.json +++ b/examples/framework-vue/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/vue": "^4.0.8", - "astro": "^4.4.6", + "astro": "^4.4.9", "vue": "^3.3.8" } } diff --git a/examples/hackernews/package.json b/examples/hackernews/package.json index 3560057d1e79..1022917fbd3d 100644 --- a/examples/hackernews/package.json +++ b/examples/hackernews/package.json @@ -11,7 +11,7 @@ "astro": "astro" }, "dependencies": { - "@astrojs/node": "^8.2.1", - "astro": "^4.4.6" + "@astrojs/node": "^8.2.3", + "astro": "^4.4.9" } } diff --git a/examples/integration/package.json b/examples/integration/package.json index bd2cd6879db1..aa8f861c9111 100644 --- a/examples/integration/package.json +++ b/examples/integration/package.json @@ -15,7 +15,7 @@ ], "scripts": {}, "devDependencies": { - "astro": "^4.4.6" + "astro": "^4.4.9" }, "peerDependencies": { "astro": "^4.0.0" diff --git a/examples/middleware/package.json b/examples/middleware/package.json index 9ee9f9fa601b..6931f2506e78 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -12,8 +12,8 @@ "server": "node dist/server/entry.mjs" }, "dependencies": { - "@astrojs/node": "^8.2.1", - "astro": "^4.4.6", + "@astrojs/node": "^8.2.3", + "astro": "^4.4.9", "html-minifier": "^4.0.0" }, "devDependencies": { diff --git a/examples/minimal/package.json b/examples/minimal/package.json index 85580d9cd383..daaec679ddcc 100644 --- a/examples/minimal/package.json +++ b/examples/minimal/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.4.6" + "astro": "^4.4.9" } } diff --git a/examples/non-html-pages/package.json b/examples/non-html-pages/package.json index f00fb4b5b97a..9d81c702904f 100644 --- a/examples/non-html-pages/package.json +++ b/examples/non-html-pages/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.4.6" + "astro": "^4.4.9" } } diff --git a/examples/portfolio/package.json b/examples/portfolio/package.json index e52d98f04c54..63c6a7c1a47a 100644 --- a/examples/portfolio/package.json +++ b/examples/portfolio/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.4.6" + "astro": "^4.4.9" } } diff --git a/examples/ssr/package.json b/examples/ssr/package.json index 592fc7077b67..f30f14b86189 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -12,9 +12,9 @@ "server": "node dist/server/entry.mjs" }, "dependencies": { - "@astrojs/node": "^8.2.1", + "@astrojs/node": "^8.2.3", "@astrojs/svelte": "^5.2.0", - "astro": "^4.4.6", + "astro": "^4.4.9", "svelte": "^4.2.5" } } diff --git a/examples/starlog/package.json b/examples/starlog/package.json index 83df3e1f30c6..646a712aea10 100644 --- a/examples/starlog/package.json +++ b/examples/starlog/package.json @@ -10,7 +10,7 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.4.6", + "astro": "^4.4.9", "sass": "^1.69.5", "sharp": "^0.32.6" } diff --git a/examples/view-transitions/package.json b/examples/view-transitions/package.json index a50877a080aa..9b14f2f865dc 100644 --- a/examples/view-transitions/package.json +++ b/examples/view-transitions/package.json @@ -11,7 +11,7 @@ }, "devDependencies": { "@astrojs/tailwind": "^5.1.0", - "@astrojs/node": "^8.2.1", - "astro": "^4.4.6" + "@astrojs/node": "^8.2.3", + "astro": "^4.4.9" } } diff --git a/examples/with-markdoc/package.json b/examples/with-markdoc/package.json index 2246568f6c20..d2f4f5b927f1 100644 --- a/examples/with-markdoc/package.json +++ b/examples/with-markdoc/package.json @@ -11,7 +11,7 @@ "astro": "astro" }, "dependencies": { - "@astrojs/markdoc": "^0.9.0", - "astro": "^4.4.6" + "@astrojs/markdoc": "^0.9.1", + "astro": "^4.4.9" } } diff --git a/examples/with-markdown-plugins/package.json b/examples/with-markdown-plugins/package.json index 14caff03e549..a0d3e4fedcc0 100644 --- a/examples/with-markdown-plugins/package.json +++ b/examples/with-markdown-plugins/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/markdown-remark": "^4.2.1", - "astro": "^4.4.6", + "astro": "^4.4.9", "hast-util-select": "^6.0.2", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", diff --git a/examples/with-markdown-shiki/package.json b/examples/with-markdown-shiki/package.json index 2a295880e3ab..b5ac1ec1a71e 100644 --- a/examples/with-markdown-shiki/package.json +++ b/examples/with-markdown-shiki/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.4.6" + "astro": "^4.4.9" } } diff --git a/examples/with-mdx/package.json b/examples/with-mdx/package.json index 4ceaf2373c7a..a67bb505190e 100644 --- a/examples/with-mdx/package.json +++ b/examples/with-mdx/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/mdx": "^2.1.1", "@astrojs/preact": "^3.1.1", - "astro": "^4.4.6", + "astro": "^4.4.9", "preact": "^10.19.2" } } diff --git a/examples/with-nanostores/package.json b/examples/with-nanostores/package.json index 909328a27997..330464bb0f38 100644 --- a/examples/with-nanostores/package.json +++ b/examples/with-nanostores/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/preact": "^3.1.1", "@nanostores/preact": "^0.5.0", - "astro": "^4.4.6", + "astro": "^4.4.9", "nanostores": "^0.9.5", "preact": "^10.19.2" } diff --git a/examples/with-tailwindcss/package.json b/examples/with-tailwindcss/package.json index 3d9a75496793..39a205342d5c 100644 --- a/examples/with-tailwindcss/package.json +++ b/examples/with-tailwindcss/package.json @@ -14,7 +14,7 @@ "@astrojs/mdx": "^2.1.1", "@astrojs/tailwind": "^5.1.0", "@types/canvas-confetti": "^1.6.3", - "astro": "^4.4.6", + "astro": "^4.4.9", "autoprefixer": "^10.4.15", "canvas-confetti": "^1.9.1", "postcss": "^8.4.28", diff --git a/examples/with-vitest/package.json b/examples/with-vitest/package.json index f337edc99d6e..05c636d421ad 100644 --- a/examples/with-vitest/package.json +++ b/examples/with-vitest/package.json @@ -12,7 +12,7 @@ "test": "vitest" }, "dependencies": { - "astro": "^4.4.6", + "astro": "^4.4.9", "vitest": "^1.3.1" } } diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index 901e49d22925..7df2f801fa3f 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -1,5 +1,31 @@ # astro +## 4.4.9 + +### Patch Changes + +- [#10278](https://github.com/withastro/astro/pull/10278) [`a548a3a99c2835c19662fc38636f92b2bda26614`](https://github.com/withastro/astro/commit/a548a3a99c2835c19662fc38636f92b2bda26614) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes original images sometimes being kept / deleted when they shouldn't in both MDX and Markdoc + +- [#10280](https://github.com/withastro/astro/pull/10280) [`3488be9b59d1cb65325b0e087c33bcd74aaa4926`](https://github.com/withastro/astro/commit/3488be9b59d1cb65325b0e087c33bcd74aaa4926) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Finalize db API to a shared db/ directory. + +## 4.4.8 + +### Patch Changes + +- [#10275](https://github.com/withastro/astro/pull/10275) [`5e3e74b61daa2ba44c761c9ab5745818661a656e`](https://github.com/withastro/astro/commit/5e3e74b61daa2ba44c761c9ab5745818661a656e) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes dev toolbar warning about using the proper loading attributes on images using `data:` URIs + +## 4.4.7 + +### Patch Changes + +- [#10274](https://github.com/withastro/astro/pull/10274) [`e556151603a2f0173059d0f98fdcbec0610b48ff`](https://github.com/withastro/astro/commit/e556151603a2f0173059d0f98fdcbec0610b48ff) Thanks [@lilnasy](https://github.com/lilnasy)! - Fixes a regression introduced in v4.4.5 where image optimization did not work in dev mode when a base was configured. + +- [#10263](https://github.com/withastro/astro/pull/10263) [`9bdbed723e0aa4243d7d6ee64d1c1df3b75b9aeb`](https://github.com/withastro/astro/commit/9bdbed723e0aa4243d7d6ee64d1c1df3b75b9aeb) Thanks [@martrapp](https://github.com/martrapp)! - Adds auto completion for `astro:` event names when adding or removing event listeners on `document`. + +- [#10284](https://github.com/withastro/astro/pull/10284) [`07f89429a1ef5173d3321e0b362a9dc71fc74fe5`](https://github.com/withastro/astro/commit/07f89429a1ef5173d3321e0b362a9dc71fc74fe5) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes an issue where in Node SSR, the image endpoint could be used maliciously to reveal unintended information about the underlying system. + + Thanks to Google Security Team for reporting this issue. + ## 4.4.6 ### Patch Changes diff --git a/packages/astro/package.json b/packages/astro/package.json index 561ba56dbff8..50ff30518012 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "astro", - "version": "4.4.6", + "version": "4.4.9", "description": "Astro is a modern site builder with web best practices, performance, and DX front-of-mind.", "type": "module", "author": "withastro", @@ -108,6 +108,7 @@ "dev": "astro-scripts dev --copy-wasm --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.{ts,js}\"", "postbuild": "astro-scripts copy \"src/**/*.astro\" && astro-scripts copy \"src/**/*.wasm\"", "test": "pnpm run test:node", + "test:match": "pnpm run test:node --match", "test:e2e": "playwright test", "test:e2e:match": "playwright test -g", "test:node": "astro-scripts test \"test/**/*.test.js\"" diff --git a/packages/astro/src/assets/build/generate.ts b/packages/astro/src/assets/build/generate.ts index 1c73c1592cd8..496dc1e6e4a7 100644 --- a/packages/astro/src/assets/build/generate.ts +++ b/packages/astro/src/assets/build/generate.ts @@ -119,7 +119,13 @@ export async function generateImagesForPath( !globalThis.astroAsset.referencedImages?.has(transformsAndPath.originalSrcPath) ) { try { - await fs.promises.unlink(getFullImagePath(originalFilePath, env)); + if (transformsAndPath.originalSrcPath) { + env.logger.debug( + 'assets', + `Deleting ${originalFilePath} as it's not referenced outside of image processing.` + ); + await fs.promises.unlink(getFullImagePath(originalFilePath, env)); + } } catch (e) { /* No-op, it's okay if we fail to delete one of the file, we're not too picky. */ } diff --git a/packages/astro/src/assets/endpoint/node.ts b/packages/astro/src/assets/endpoint/node.ts index cabf02a76e6b..4d29a7fadd02 100644 --- a/packages/astro/src/assets/endpoint/node.ts +++ b/packages/astro/src/assets/endpoint/node.ts @@ -1,4 +1,7 @@ -import os from 'os'; +/* eslint-disable no-console */ +import os from 'node:os'; +import { isAbsolute } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { isRemotePath, removeQueryString } from '@astrojs/internal-helpers/path'; import { readFile } from 'fs/promises'; import mime from 'mime/lite.js'; @@ -7,23 +10,44 @@ import { getConfiguredImageService } from '../internal.js'; import { etag } from '../utils/etag.js'; import { isRemoteAllowed } from '../utils/remotePattern.js'; // @ts-expect-error -import { assetsDir, imageConfig } from 'astro:assets'; +import { assetsDir, imageConfig, outDir } from 'astro:assets'; function replaceFileSystemReferences(src: string) { return os.platform().includes('win32') ? src.replace(/^\/@fs\//, '') : src.replace(/^\/@fs/, ''); } async function loadLocalImage(src: string, url: URL) { - const filePath = import.meta.env.DEV - ? removeQueryString(replaceFileSystemReferences(src)) - : new URL('.' + src, assetsDir); + const assetsDirPath = fileURLToPath(assetsDir); + + let fileUrl; + if (import.meta.env.DEV) { + fileUrl = pathToFileURL(removeQueryString(replaceFileSystemReferences(src))); + } else { + try { + fileUrl = new URL('.' + src, outDir); + const filePath = fileURLToPath(fileUrl); + + if (!isAbsolute(filePath) || !filePath.startsWith(assetsDirPath)) { + return undefined; + } + } catch (err: unknown) { + return undefined; + } + } + let buffer: Buffer | undefined = undefined; try { - buffer = await readFile(filePath); + buffer = await readFile(fileUrl); } catch (e) { - const sourceUrl = new URL(src, url.origin); - buffer = await loadRemoteImage(sourceUrl); + // Fallback to try to load the file using `fetch` + try { + const sourceUrl = new URL(src, url.origin); + buffer = await loadRemoteImage(sourceUrl); + } catch (err: unknown) { + console.error('Could not process image request:', err); + return undefined; + } } return buffer; @@ -58,7 +82,11 @@ export const GET: APIRoute = async ({ request }) => { const transform = await imageService.parseURL(url, imageConfig); if (!transform?.src) { - throw new Error('Incorrect transform returned by `parseURL`'); + const err = new Error( + 'Incorrect transform returned by `parseURL`. Expected a transform with a `src` property.' + ); + console.error('Could not parse image transform from URL:', err); + return new Response('Internal Server Error', { status: 500 }); } let inputBuffer: Buffer | undefined = undefined; @@ -74,7 +102,7 @@ export const GET: APIRoute = async ({ request }) => { } if (!inputBuffer) { - return new Response('Not Found', { status: 404 }); + return new Response('Internal Server Error', { status: 500 }); } const { data, format } = await imageService.transform(inputBuffer, transform, imageConfig); @@ -89,8 +117,12 @@ export const GET: APIRoute = async ({ request }) => { }, }); } catch (err: unknown) { - // eslint-disable-next-line no-console console.error('Could not process image request:', err); - return new Response(`Server Error: ${err}`, { status: 500 }); + return new Response( + import.meta.env.DEV ? `Could not process image request: ${err}` : `Internal Server Error`, + { + status: 500, + } + ); } }; diff --git a/packages/astro/src/assets/utils/proxy.ts b/packages/astro/src/assets/utils/proxy.ts index 2b4389e4d142..e5c7ce7a094d 100644 --- a/packages/astro/src/assets/utils/proxy.ts +++ b/packages/astro/src/assets/utils/proxy.ts @@ -11,7 +11,11 @@ export function getProxyCode(options: ImageMetadata, isSSR: boolean): string { if (name === 'fsPath') { return ${stringifiedFSPath}; } - ${!isSSR ? `globalThis.astroAsset.referencedImages.add(${stringifiedFSPath});` : ''} + ${ + !isSSR + ? `if (target[name] !== undefined) globalThis.astroAsset.referencedImages.add(${stringifiedFSPath});` + : '' + } return target[name]; } }) diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index f531270e3b73..28fcbef39a10 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -67,13 +67,14 @@ export default function assets({ export { default as Picture } from "astro/components/Picture.astro"; export const imageConfig = ${JSON.stringify(settings.config.image)}; - export const assetsDir = new URL(${JSON.stringify( + export const outDir = new URL(${JSON.stringify( new URL( isServerLikeOutput(settings.config) ? settings.config.build.client : settings.config.outDir ) )}); + export const assetsDir = new URL(${JSON.stringify(settings.config.build.assets)}, outDir); export const getImage = async (options) => await getImageInternal(options, imageConfig); `; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index ceb65ba2138e..344318f4e15e 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -108,7 +108,6 @@ export const AstroConfigSchema = z.object({ .optional() .default('attribute'), adapter: z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }).optional(), - db: z.object({}).passthrough().default({}).optional(), integrations: z.preprocess( // preprocess (val) => (Array.isArray(val) ? val.flat(Infinity).filter(Boolean) : val), diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index 3f9c9f4177b5..45ab41ec92ce 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -28,6 +28,7 @@ export type LoggerLabel = | 'preferences' | 'redirects' | 'toolbar' + | 'assets' // SKIP_FORMAT: A special label that tells the logger not to apply any formatting. // Useful for messages that are already formatted, like the server start message. | 'SKIP_FORMAT'; diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/perf.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/perf.ts index 197553a25fc7..4b67fcbf5575 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/perf.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/perf.ts @@ -38,6 +38,9 @@ export const perf: AuditRuleWithSelector[] = [ // Ignore elements that are above the fold, they should be loaded eagerly if (htmlElement.offsetTop < window.innerHeight) return false; + // Ignore elements using `data:` URI, the `loading` attribute doesn't do anything for these + if (htmlElement.src.startsWith('data:')) return false; + return true; }, }, @@ -53,6 +56,9 @@ export const perf: AuditRuleWithSelector[] = [ // Ignore elements that are below the fold, they should be loaded lazily if (htmlElement.offsetTop > window.innerHeight) return false; + // Ignore elements using `data:` URI, the `loading` attribute doesn't do anything for these + if (htmlElement.src.startsWith('data:')) return false; + return true; }, }, diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts index 6ca3f666a871..02e2d69dfd20 100644 --- a/packages/astro/src/transitions/router.ts +++ b/packages/astro/src/transitions/router.ts @@ -90,28 +90,6 @@ if (inBrowser) { } } -const throttle = (cb: (...args: any[]) => any, delay: number) => { - let wait = false; - // During the waiting time additional events are lost. - // So repeat the callback at the end if we have swallowed events. - let onceMore = false; - return (...args: any[]) => { - if (wait) { - onceMore = true; - return; - } - cb(...args); - wait = true; - setTimeout(() => { - if (onceMore) { - onceMore = false; - cb(...args); - } - wait = false; - }, delay); - }; -}; - // returns the contents of the page or null if the router can't deal with it. async function fetchHTML( href: string, @@ -625,10 +603,15 @@ function onPopState(ev: PopStateEvent) { transition(direction, originalLocation, new URL(location.href), {}, state); } -// There's not a good way to record scroll position before a back button. -// So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position. -const onScroll = () => { - updateScrollPosition({ scrollX, scrollY }); +const onScrollEnd = () => { + // NOTE: our "popstate" event handler may call `pushState()` or + // `replaceState()` and then `scrollTo()`, which will fire "scroll" and + // "scrollend" events. To avoid redundant work and expensive calls to + // `replaceState()`, we simply check that the values are different before + // updating. + if (scrollX !== history.state.scrollX || scrollY !== history.state.scrollY) { + updateScrollPosition({ scrollX, scrollY }); + } }; // initialization @@ -637,8 +620,42 @@ if (inBrowser) { originalLocation = new URL(location.href); addEventListener('popstate', onPopState); addEventListener('load', onPageLoad); - if ('onscrollend' in window) addEventListener('scrollend', onScroll); - else addEventListener('scroll', throttle(onScroll, 350), { passive: true }); + // There's not a good way to record scroll position before a history back + // navigation, so we will record it when the user has stopped scrolling. + if ('onscrollend' in window) addEventListener('scrollend', onScrollEnd); + else { + // Keep track of state between intervals + let intervalId: number | undefined, lastY: number, lastX: number, lastIndex: State['index']; + const scrollInterval = () => { + // Check the index to see if a popstate event was fired + if (lastIndex !== history.state?.index) { + clearInterval(intervalId); + intervalId = undefined; + return; + } + // Check if the user stopped scrolling + if (lastY === scrollY && lastX === scrollX) { + // Cancel the interval and update scroll positions + clearInterval(intervalId); + intervalId = undefined; + onScrollEnd(); + return; + } else { + // Update vars with current positions + (lastY = scrollY), (lastX = scrollX); + } + }; + // We can't know when or how often scroll events fire, so we'll just use them to start intervals + addEventListener( + 'scroll', + () => { + if (intervalId !== undefined) return; + (lastIndex = history.state.index), (lastY = scrollY), (lastX = scrollX); + intervalId = window.setInterval(scrollInterval, 50); + }, + { passive: true } + ); + } } for (const script of document.scripts) { script.dataset.astroExec = ''; diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js index 590e77000557..3f53fba96180 100644 --- a/packages/astro/test/core-image.test.js +++ b/packages/astro/test/core-image.test.js @@ -1103,6 +1103,96 @@ describe('astro:image', () => { assert.equal(response.status, 200); }); + it('endpoint handle malformed requests', async () => { + const badPaths = [ + '../../../../../../../../../../../../etc/hosts%00', + '../../../../../../../../../../../../etc/hosts', + '../../boot.ini', + '/../../../../../../../../%2A', + '../../../../../../../../../../../../etc/passwd%00', + '../../../../../../../../../../../../etc/passwd', + '../../../../../../../../../../../../etc/shadow%00', + '../../../../../../../../../../../../etc/shadow', + '/../../../../../../../../../../etc/passwd^^', + '/../../../../../../../../../../etc/shadow^^', + '/../../../../../../../../../../etc/passwd', + '/../../../../../../../../../../etc/shadow', + '/./././././././././././etc/passwd', + '/./././././././././././etc/shadow', + '....................etcpasswd', + '....................etcshadow', + '....................etcpasswd', + '....................etcshadow', + '/..../..../..../..../..../..../etc/passwd', + '/..../..../..../..../..../..../etc/shadow', + '.\\./.\\./.\\./.\\./.\\./.\\./etc/passwd', + '.\\./.\\./.\\./.\\./.\\./.\\./etc/shadow', + '....................etcpasswd%00', + '....................etcshadow%00', + '....................etcpasswd%00', + '....................etcshadow%00', + '%0a/bin/cat%20/etc/passwd', + '%0a/bin/cat%20/etc/shadow', + '%00/etc/passwd%00', + '%00/etc/shadow%00', + '%00../../../../../../etc/passwd', + '%00../../../../../../etc/shadow', + '/../../../../../../../../../../../etc/passwd%00.jpg', + '/../../../../../../../../../../../etc/passwd%00.html', + '/..%c0%af../..%c0%af../..%c0%af../..%c0%af../..%c0%af../..%c0%af../etc/passwd', + '/..%c0%af../..%c0%af../..%c0%af../..%c0%af../..%c0%af../..%c0%af../etc/shadow', + '/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd,', + '/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/shadow,', + '%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%,25%5c..%25%5c..%25%5c..%25%5c..%00', + '/%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..,%25%5c..%25%5c..%25%5c..%25%5c..%00', + '%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%,25%5c..%25%5c..% 25%5c..%25%5c..%00', + '%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%,25%5c..%25%5c..% 25%5c..%25%5c..%255cboot.ini', + '/%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..%25%5c..,%25%5c..%25%5c..%25%5c..%25%5c..winnt/desktop.ini', + '\\'/bin/cat%20/etc/passwd\\'', + '\\'/bin/cat%20/etc/shadow\\'', + '../../../../../../../../conf/server.xml', + '/../../../../../../../../bin/id|', + 'C:/inetpub/wwwroot/global.asa', + 'C:inetpubwwwrootglobal.asa', + 'C:/boot.ini', + 'C:\boot.ini', + '../../../../../../../../../../../../localstart.asp%00', + '../../../../../../../../../../../../localstart.asp', + '../../../../../../../../../../../../boot.ini%00', + '../../../../../../../../../../../../boot.ini', + '/./././././././././././boot.ini', + '/../../../../../../../../../../../boot.ini%00', + '/../../../../../../../../../../../boot.ini', + '/..../..../..../..../..../..../boot.ini', + '/.\\./.\\./.\\./.\\./.\\./.\\./boot.ini', + '....................\boot.ini', + '....................\boot.ini%00', + '....................\boot.ini', + '/../../../../../../../../../../../boot.ini%00.html', + '/../../../../../../../../../../../boot.ini%00.jpg', + '/.../.../.../.../.../ ', + '..%c0%af../..%c0%af../..%c0%af../..%c0%af../..%c0%af../..%c0%af../boot.ini', + '/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/boot.ini', + '../prerender/index.html', + ]; + + const app = await fixture.loadTestAdapterApp(); + + for (const path of badPaths) { + let request = new Request('http://example.com/_image?href=' + path); + let response = await app.render(request); + const body = await response.text(); + + assert.equal(response.status, 500); + assert.equal(body.includes('Internal Server Error'), true); + } + + // Server should still be running + let request = new Request('http://example.com/'); + let response = await app.render(request); + assert.equal(response.status, 200); + }); + it('prerendered routes images are built', async () => { const html = await fixture.readFile('/client/prerender/index.html'); const $ = cheerio.load(html); diff --git a/packages/astro/test/fixtures/core-image-deletion/astro.config.mjs b/packages/astro/test/fixtures/core-image-deletion/astro.config.mjs new file mode 100644 index 000000000000..8ba730fc91bc --- /dev/null +++ b/packages/astro/test/fixtures/core-image-deletion/astro.config.mjs @@ -0,0 +1,7 @@ +import markdoc from '@astrojs/markdoc'; +import mdx from '@astrojs/mdx'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + integrations: [mdx(), markdoc()], +}); diff --git a/packages/astro/test/fixtures/core-image-deletion/package.json b/packages/astro/test/fixtures/core-image-deletion/package.json index 40a2ee1a64b1..e55fc847dce8 100644 --- a/packages/astro/test/fixtures/core-image-deletion/package.json +++ b/packages/astro/test/fixtures/core-image-deletion/package.json @@ -3,7 +3,9 @@ "version": "0.0.0", "private": true, "dependencies": { - "astro": "workspace:*" + "astro": "workspace:*", + "@astrojs/mdx": "workspace:*", + "@astrojs/markdoc": "workspace:*" }, "scripts": { "dev": "astro dev" diff --git a/packages/astro/test/fixtures/core-image-deletion/src/assets/markdocStillExists.jpg b/packages/astro/test/fixtures/core-image-deletion/src/assets/markdocStillExists.jpg new file mode 100644 index 000000000000..8e38327233fc Binary files /dev/null and b/packages/astro/test/fixtures/core-image-deletion/src/assets/markdocStillExists.jpg differ diff --git a/packages/astro/test/fixtures/core-image-deletion/src/assets/mdxDontExist.jpg b/packages/astro/test/fixtures/core-image-deletion/src/assets/mdxDontExist.jpg new file mode 100644 index 000000000000..8c5ba062c2f0 Binary files /dev/null and b/packages/astro/test/fixtures/core-image-deletion/src/assets/mdxDontExist.jpg differ diff --git a/packages/astro/test/fixtures/core-image-deletion/src/content/blog/markdoc.mdoc b/packages/astro/test/fixtures/core-image-deletion/src/content/blog/markdoc.mdoc new file mode 100644 index 000000000000..41119a9e7a53 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-deletion/src/content/blog/markdoc.mdoc @@ -0,0 +1,7 @@ +--- +title: "Markdoc" +--- + +There is an image below + +![A penguin](../../assets/markdocStillExists.jpg) diff --git a/packages/astro/test/fixtures/core-image-deletion/src/content/blog/mdx.mdx b/packages/astro/test/fixtures/core-image-deletion/src/content/blog/mdx.mdx new file mode 100644 index 000000000000..3de8bda4ffe6 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-deletion/src/content/blog/mdx.mdx @@ -0,0 +1,7 @@ +--- +title: "MDX" +--- + +There is an image below + +![A penguin](../../assets/mdxDontExist.jpg) diff --git a/packages/astro/test/fixtures/core-image-deletion/src/content/config.ts b/packages/astro/test/fixtures/core-image-deletion/src/content/config.ts new file mode 100644 index 000000000000..1c0eae662372 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-deletion/src/content/config.ts @@ -0,0 +1,12 @@ +import { defineCollection, z } from 'astro:content'; + +const blog = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + }) +}); + +export const collections = { + blog: blog, +}; diff --git a/packages/astro/test/fixtures/core-image-deletion/src/pages/blog/[slug].astro b/packages/astro/test/fixtures/core-image-deletion/src/pages/blog/[slug].astro new file mode 100644 index 000000000000..418d449bb64c --- /dev/null +++ b/packages/astro/test/fixtures/core-image-deletion/src/pages/blog/[slug].astro @@ -0,0 +1,22 @@ +--- +import type { GetStaticPaths } from "astro"; +import { getCollection } from "astro:content"; + +export const getStaticPaths = (async () => { + const blog = await getCollection("blog"); + return blog.map((post) => ({ + params: { + slug: post.slug, + }, + props: { + post + } + })); +}) satisfies GetStaticPaths; + +const { post } = Astro.props; + +const { Content } = await post.render(); +--- + + diff --git a/packages/astro/test/image-deletion.test.js b/packages/astro/test/image-deletion.test.js index 4283e8c06f3b..cb4b464bf6aa 100644 --- a/packages/astro/test/image-deletion.test.js +++ b/packages/astro/test/image-deletion.test.js @@ -3,7 +3,7 @@ import { before, describe, it } from 'node:test'; import { testImageService } from './test-image-service.js'; import { loadFixture } from './test-utils.js'; -describe('astro:assets - delete images that are unused', () => { +describe('astro:assets - delete images that are unused zzz', () => { /** @type {import('./test-utils.js').Fixture} */ let fixture; @@ -33,5 +33,15 @@ describe('astro:assets - delete images that are unused', () => { const imagesUsedElsewhere = await fixture.glob('_astro/url.*.*'); assert.equal(imagesUsedElsewhere.length, 2); }); + + it('should delete MDX images only used for optimization', async () => { + const imagesOnlyOptimized = await fixture.glob('_astro/mdxDontExist.*.*'); + assert.equal(imagesOnlyOptimized.length, 1); + }); + + it('should always keep Markdoc images', async () => { + const imagesUsedElsewhere = await fixture.glob('_astro/markdocStillExists.*.*'); + assert.equal(imagesUsedElsewhere.length, 2); + }); }); }); diff --git a/packages/db/CHANGELOG.md b/packages/db/CHANGELOG.md index 75d993f3de00..0f9a08f2b64a 100644 --- a/packages/db/CHANGELOG.md +++ b/packages/db/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/db +## 0.5.0 + +### Minor Changes + +- [#10280](https://github.com/withastro/astro/pull/10280) [`3488be9b59d1cb65325b0e087c33bcd74aaa4926`](https://github.com/withastro/astro/commit/3488be9b59d1cb65325b0e087c33bcd74aaa4926) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Finalize db API to a shared db/ directory. + ## 0.4.1 ### Patch Changes diff --git a/packages/db/config-augment.d.ts b/packages/db/config-augment.d.ts deleted file mode 100644 index 3278e6c2abd1..000000000000 --- a/packages/db/config-augment.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare namespace Config { - type DBUserConfig = import('./dist/core/types.js').DBUserConfig; - export interface Database extends DBUserConfig {} -} diff --git a/packages/db/index.d.ts b/packages/db/index.d.ts index 3a2134b9fc22..81af4fb43cfc 100644 --- a/packages/db/index.d.ts +++ b/packages/db/index.d.ts @@ -1,3 +1,5 @@ -/// -export * from './dist/index.js'; -export { default } from './dist/index.js'; +export { default, cli } from './dist/index.js'; + +declare module 'astro:db' { + export { defineTable, defineDB, column, sql, NOW, TRUE, FALSE } from './dist/index.js'; +} diff --git a/packages/db/package.json b/packages/db/package.json index b2055f20d452..407f938973e5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@astrojs/db", - "version": "0.4.1", + "version": "0.5.0", "description": "", "license": "MIT", "type": "module", @@ -12,6 +12,10 @@ "types": "./index.d.ts", "import": "./dist/index.js" }, + "./utils": { + "types": "./dist/utils.d.ts", + "import": "./dist/utils.js" + }, "./runtime": { "types": "./dist/runtime/index.d.ts", "import": "./dist/runtime/index.js" @@ -20,6 +24,10 @@ "types": "./dist/runtime/drizzle.d.ts", "import": "./dist/runtime/drizzle.js" }, + "./runtime/config": { + "types": "./dist/runtime/config.d.ts", + "import": "./dist/runtime/config.js" + }, "./package.json": "./package.json" }, "typesVersions": { @@ -27,11 +35,17 @@ ".": [ "./index.d.ts" ], + "utils": [ + "./dist/utils.d.ts" + ], "runtime": [ "./dist/runtime/index.d.ts" ], "runtime/drizzle": [ "./dist/runtime/drizzle.d.ts" + ], + "runtime/config": [ + "./dist/runtime/config.d.ts" ] } }, diff --git a/packages/db/src/core/cli/commands/execute/index.ts b/packages/db/src/core/cli/commands/execute/index.ts new file mode 100644 index 000000000000..1863691e706e --- /dev/null +++ b/packages/db/src/core/cli/commands/execute/index.ts @@ -0,0 +1,40 @@ +import { existsSync } from 'node:fs'; +import type { AstroConfig } from 'astro'; +import type { Arguments } from 'yargs-parser'; +import { FILE_NOT_FOUND_ERROR, MISSING_EXECUTE_PATH_ERROR } from '../../../errors.js'; +import { getStudioVirtualModContents } from '../../../integration/vite-plugin-db.js'; +import { bundleFile, importBundledFile } from '../../../load-file.js'; +import { getManagedAppTokenOrExit } from '../../../tokens.js'; +import { type DBConfig } from '../../../types.js'; + +export async function cmd({ + astroConfig, + dbConfig, + flags, +}: { + astroConfig: AstroConfig; + dbConfig: DBConfig; + flags: Arguments; +}) { + const filePath = flags._[4]; + if (typeof filePath !== 'string') { + console.error(MISSING_EXECUTE_PATH_ERROR); + process.exit(1); + } + + const fileUrl = new URL(filePath, astroConfig.root); + if (!existsSync(fileUrl)) { + console.error(FILE_NOT_FOUND_ERROR(filePath)); + process.exit(1); + } + + const appToken = await getManagedAppTokenOrExit(flags.token); + + const virtualModContents = getStudioVirtualModContents({ + tables: dbConfig.tables ?? {}, + appToken: appToken.token, + }); + const { code } = await bundleFile({ virtualModContents, root: astroConfig.root, fileUrl }); + // Executable files use top-level await. Importing will run the file. + await importBundledFile({ code, root: astroConfig.root }); +} diff --git a/packages/db/src/core/cli/commands/gen/index.ts b/packages/db/src/core/cli/commands/gen/index.ts index 25f59be45a83..be157d4a5545 100644 --- a/packages/db/src/core/cli/commands/gen/index.ts +++ b/packages/db/src/core/cli/commands/gen/index.ts @@ -1,7 +1,11 @@ import { writeFile } from 'node:fs/promises'; +import { relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { AstroConfig } from 'astro'; -import { bgRed, red, reset } from 'kleur/colors'; +import { bgRed, bold, red, reset } from 'kleur/colors'; import type { Arguments } from 'yargs-parser'; +import type { DBConfig } from '../../../types.js'; +import { getMigrationsDirectoryUrl } from '../../../utils.js'; import { getMigrationQueries } from '../../migration-queries.js'; import { MIGRATIONS_CREATED, @@ -10,11 +14,19 @@ import { initializeMigrationsDirectory, } from '../../migrations.js'; -export async function cmd({ config }: { config: AstroConfig; flags: Arguments }) { - const migration = await getMigrationStatus(config); +export async function cmd({ + astroConfig, + dbConfig, +}: { + astroConfig: AstroConfig; + dbConfig: DBConfig; + flags: Arguments; +}) { + const migration = await getMigrationStatus({ dbConfig, root: astroConfig.root }); + const migrationsDir = getMigrationsDirectoryUrl(astroConfig.root); if (migration.state === 'no-migrations-found') { - await initializeMigrationsDirectory(migration.currentSnapshot); + await initializeMigrationsDirectory(migration.currentSnapshot, migrationsDir); console.log(MIGRATIONS_CREATED); return; } else if (migration.state === 'up-to-date') { @@ -30,14 +42,15 @@ export async function cmd({ config }: { config: AstroConfig; flags: Arguments }) // Warn the user about any changes that lead to data-loss. // When the user runs `db push`, they will be prompted to confirm these changes. confirmations.map((message) => console.log(bgRed(' !!! ') + ' ' + red(message))); - const migrationFileContent = { + const content = { diff, db: migrationQueries, // TODO(fks): Encode the relevant data, instead of the raw message. // This will give `db push` more control over the formatting of the message. confirm: confirmations.map((c) => reset(c)), }; - const migrationFileName = `./migrations/${newFilename}`; - await writeFile(migrationFileName, JSON.stringify(migrationFileContent, undefined, 2)); - console.log(migrationFileName + ' created!'); + const fileUrl = new URL(newFilename, migrationsDir); + const relativePath = relative(fileURLToPath(astroConfig.root), fileURLToPath(fileUrl)); + await writeFile(fileUrl, JSON.stringify(content, undefined, 2)); + console.log(bold(relativePath) + ' created!'); } diff --git a/packages/db/src/core/cli/commands/link/index.ts b/packages/db/src/core/cli/commands/link/index.ts index 4acfa7ec4849..f92a1818ca41 100644 --- a/packages/db/src/core/cli/commands/link/index.ts +++ b/packages/db/src/core/cli/commands/link/index.ts @@ -1,17 +1,15 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename } from 'node:path'; -import type { AstroConfig } from 'astro'; import { slug } from 'github-slugger'; import { bgRed, cyan } from 'kleur/colors'; import ora from 'ora'; import prompts from 'prompts'; -import type { Arguments } from 'yargs-parser'; import { MISSING_SESSION_ID_ERROR } from '../../../errors.js'; import { PROJECT_ID_FILE, getSessionIdFromFile } from '../../../tokens.js'; import { getAstroStudioUrl } from '../../../utils.js'; -export async function cmd({}: { config: AstroConfig; flags: Arguments }) { +export async function cmd() { const sessionToken = await getSessionIdFromFile(); if (!sessionToken) { console.error(MISSING_SESSION_ID_ERROR); diff --git a/packages/db/src/core/cli/commands/login/index.ts b/packages/db/src/core/cli/commands/login/index.ts index 215d5723e158..2a3f16447d87 100644 --- a/packages/db/src/core/cli/commands/login/index.ts +++ b/packages/db/src/core/cli/commands/login/index.ts @@ -7,6 +7,7 @@ import open from 'open'; import ora from 'ora'; import type { Arguments } from 'yargs-parser'; import { SESSION_LOGIN_FILE } from '../../../tokens.js'; +import type { DBConfig } from '../../../types.js'; import { getAstroStudioUrl } from '../../../utils.js'; // NOTE(fks): How the Astro CLI login process works: @@ -47,7 +48,13 @@ async function createServer(): Promise<{ url: string; promise: Promise } return { url: serverUrl, promise: sessionPromise }; } -export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) { +export async function cmd({ + flags, +}: { + astroConfig: AstroConfig; + dbConfig: DBConfig; + flags: Arguments; +}) { let session = flags.session; if (!session) { diff --git a/packages/db/src/core/cli/commands/logout/index.ts b/packages/db/src/core/cli/commands/logout/index.ts index 0881dcf4c82e..fbc4ba78cd83 100644 --- a/packages/db/src/core/cli/commands/logout/index.ts +++ b/packages/db/src/core/cli/commands/logout/index.ts @@ -1,9 +1,7 @@ import { unlink } from 'node:fs/promises'; -import type { AstroConfig } from 'astro'; -import type { Arguments } from 'yargs-parser'; import { SESSION_LOGIN_FILE } from '../../../tokens.js'; -export async function cmd({}: { config: AstroConfig; flags: Arguments }) { +export async function cmd() { await unlink(SESSION_LOGIN_FILE); console.log('Successfully logged out of Astro Studio.'); } diff --git a/packages/db/src/core/cli/commands/push/index.ts b/packages/db/src/core/cli/commands/push/index.ts index 8c3f21e4d773..81d232cb2904 100644 --- a/packages/db/src/core/cli/commands/push/index.ts +++ b/packages/db/src/core/cli/commands/push/index.ts @@ -1,18 +1,14 @@ -import { type InStatement, createClient } from '@libsql/client'; import type { AstroConfig } from 'astro'; -import { drizzle as drizzleLibsql } from 'drizzle-orm/libsql'; -import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; -import { drizzle as drizzleProxy } from 'drizzle-orm/sqlite-proxy'; import { red } from 'kleur/colors'; import prompts from 'prompts'; import type { Arguments } from 'yargs-parser'; import { MISSING_SESSION_ID_ERROR } from '../../../errors.js'; -import { recreateTables, seedData } from '../../../queries.js'; import { getManagedAppTokenOrExit } from '../../../tokens.js'; -import { type AstroConfigWithDB, type DBSnapshot, tablesSchema } from '../../../types.js'; -import { getRemoteDatabaseUrl } from '../../../utils.js'; +import { type DBConfig, type DBSnapshot } from '../../../types.js'; +import { getMigrationsDirectoryUrl, getRemoteDatabaseUrl } from '../../../utils.js'; import { getMigrationQueries } from '../../migration-queries.js'; import { + INITIAL_SNAPSHOT, MIGRATIONS_NOT_INITIALIZED, MIGRATIONS_UP_TO_DATE, MIGRATION_NEEDED, @@ -23,10 +19,18 @@ import { loadMigration, } from '../../migrations.js'; -export async function cmd({ config, flags }: { config: AstroConfig; flags: Arguments }) { +export async function cmd({ + astroConfig, + dbConfig, + flags, +}: { + astroConfig: AstroConfig; + dbConfig: DBConfig; + flags: Arguments; +}) { const isDryRun = flags.dryRun; const appToken = await getManagedAppTokenOrExit(flags.token); - const migration = await getMigrationStatus(config); + const migration = await getMigrationStatus({ dbConfig, root: astroConfig.root }); if (migration.state === 'no-migrations-found') { console.log(MIGRATIONS_NOT_INITIALIZED); process.exit(1); @@ -34,9 +38,10 @@ export async function cmd({ config, flags }: { config: AstroConfig; flags: Argum console.log(MIGRATION_NEEDED); process.exit(1); } + const migrationsDir = getMigrationsDirectoryUrl(astroConfig.root); // get all migrations from the filesystem - const allLocalMigrations = await getMigrations(); + const allLocalMigrations = await getMigrations(migrationsDir); let missingMigrations: string[] = []; try { const { data } = await prepareMigrateQuery({ @@ -63,14 +68,12 @@ export async function cmd({ config, flags }: { config: AstroConfig; flags: Argum console.log(`Pushing ${missingMigrations.length} migrations...`); await pushSchema({ migrations: missingMigrations, + migrationsDir, appToken: appToken.token, isDryRun, currentSnapshot: migration.currentSnapshot, }); } - // push the database seed data - console.info('Pushing data...'); - await pushData({ config, appToken: appToken.token, isDryRun }); // cleanup and exit await appToken.destroy(); console.info('Push complete!'); @@ -78,25 +81,29 @@ export async function cmd({ config, flags }: { config: AstroConfig; flags: Argum async function pushSchema({ migrations, + migrationsDir, appToken, isDryRun, currentSnapshot, }: { migrations: string[]; + migrationsDir: URL; appToken: string; isDryRun: boolean; currentSnapshot: DBSnapshot; }) { // load all missing migrations - const initialSnapshot = migrations.find((m) => m === '0000_snapshot.json'); - const filteredMigrations = migrations.filter((m) => m !== '0000_snapshot.json'); - const missingMigrationContents = await Promise.all(filteredMigrations.map(loadMigration)); + const initialSnapshot = migrations.find((m) => m === INITIAL_SNAPSHOT); + const filteredMigrations = migrations.filter((m) => m !== INITIAL_SNAPSHOT); + const missingMigrationContents = await Promise.all( + filteredMigrations.map((m) => loadMigration(m, migrationsDir)) + ); // create a migration for the initial snapshot, if needed const initialMigrationBatch = initialSnapshot ? ( await getMigrationQueries({ oldSnapshot: createEmptySnapshot(), - newSnapshot: await loadInitialSnapshot(), + newSnapshot: await loadInitialSnapshot(migrationsDir), }) ).queries : []; @@ -130,76 +137,6 @@ async function pushSchema({ await runMigrateQuery({ queries, migrations, snapshot: currentSnapshot, appToken, isDryRun }); } -const sqlite = new SQLiteAsyncDialect(); - -async function pushData({ - config, - appToken, - isDryRun, -}: { - config: AstroConfigWithDB; - appToken: string; - isDryRun?: boolean; -}) { - const queries: InStatement[] = []; - if (config.db?.data) { - const libsqlClient = createClient({ url: ':memory:' }); - // Stand up tables locally to mirror inserts. - // Needed to generate return values. - await recreateTables({ - db: drizzleLibsql(libsqlClient), - tables: tablesSchema.parse(config.db.tables ?? {}), - }); - - for (const [collectionName, { writable }] of Object.entries(config.db.tables ?? {})) { - if (!writable) { - queries.push({ - sql: `DELETE FROM ${sqlite.escapeName(collectionName)}`, - args: [], - }); - } - } - - // Use proxy to trace all queries to queue up in a batch. - const db = await drizzleProxy(async (sqlQuery, params, method) => { - const stmt: InStatement = { sql: sqlQuery, args: params }; - queries.push(stmt); - // Use in-memory database to generate results for `returning()`. - const { rows } = await libsqlClient.execute(stmt); - const rowValues: unknown[][] = []; - for (const row of rows) { - if (row != null && typeof row === 'object') { - rowValues.push(Object.values(row)); - } - } - if (method === 'get') { - return { rows: rowValues[0] }; - } - return { rows: rowValues }; - }); - await seedData({ - db, - mode: 'build', - data: config.db.data, - }); - } - - const url = new URL('/db/query', getRemoteDatabaseUrl()); - - if (isDryRun) { - console.info('[DRY RUN] Batch data seed:', JSON.stringify(queries, null, 2)); - return new Response(null, { status: 200 }); - } - - return await fetch(url, { - method: 'POST', - headers: new Headers({ - Authorization: `Bearer ${appToken}`, - }), - body: JSON.stringify(queries), - }); -} - async function runMigrateQuery({ queries: baseQueries, migrations, diff --git a/packages/db/src/core/cli/commands/shell/index.ts b/packages/db/src/core/cli/commands/shell/index.ts index 13450bf31144..ef54b6b70db2 100644 --- a/packages/db/src/core/cli/commands/shell/index.ts +++ b/packages/db/src/core/cli/commands/shell/index.ts @@ -3,9 +3,16 @@ import { sql } from 'drizzle-orm'; import type { Arguments } from 'yargs-parser'; import { createRemoteDatabaseClient } from '../../../../runtime/db-client.js'; import { getManagedAppTokenOrExit } from '../../../tokens.js'; +import type { DBConfigInput } from '../../../types.js'; import { getRemoteDatabaseUrl } from '../../../utils.js'; -export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) { +export async function cmd({ + flags, +}: { + dbConfig: DBConfigInput; + astroConfig: AstroConfig; + flags: Arguments; +}) { const query = flags.query; const appToken = await getManagedAppTokenOrExit(flags.token); const db = createRemoteDatabaseClient(appToken.token, getRemoteDatabaseUrl()); diff --git a/packages/db/src/core/cli/commands/verify/index.ts b/packages/db/src/core/cli/commands/verify/index.ts index 6d839d60673c..3b95835f7a64 100644 --- a/packages/db/src/core/cli/commands/verify/index.ts +++ b/packages/db/src/core/cli/commands/verify/index.ts @@ -1,5 +1,6 @@ import type { AstroConfig } from 'astro'; import type { Arguments } from 'yargs-parser'; +import type { DBConfig } from '../../../types.js'; import { getMigrationQueries } from '../../migration-queries.js'; import { MIGRATIONS_NOT_INITIALIZED, @@ -8,8 +9,16 @@ import { getMigrationStatus, } from '../../migrations.js'; -export async function cmd({ config, flags }: { config: AstroConfig; flags: Arguments }) { - const status = await getMigrationStatus(config); +export async function cmd({ + astroConfig, + dbConfig, + flags, +}: { + astroConfig: AstroConfig; + dbConfig: DBConfig; + flags: Arguments; +}) { + const status = await getMigrationStatus({ dbConfig, root: astroConfig.root }); const { state } = status; if (flags.json) { if (state === 'ahead') { diff --git a/packages/db/src/core/cli/index.ts b/packages/db/src/core/cli/index.ts index 5d29b8dda0de..9ffaee7b3059 100644 --- a/packages/db/src/core/cli/index.ts +++ b/packages/db/src/core/cli/index.ts @@ -1,50 +1,56 @@ import type { AstroConfig } from 'astro'; import type { Arguments } from 'yargs-parser'; -import { STUDIO_CONFIG_MISSING_CLI_ERROR } from '../errors.js'; +import { loadDbConfigFile } from '../load-file.js'; +import { dbConfigSchema } from '../types.js'; -export async function cli({ flags, config }: { flags: Arguments; config: AstroConfig }) { +export async function cli({ + flags, + config: astroConfig, +}: { + flags: Arguments; + config: AstroConfig; +}) { const args = flags._ as string[]; // Most commands are `astro db foo`, but for now login/logout // are also handled by this package, so first check if this is a db command. const command = args[2] === 'db' ? args[3] : args[2]; - - switch (command) { - case 'login': { - const { cmd } = await import('./commands/login/index.js'); - return await cmd({ config, flags }); - } - case 'logout': { - const { cmd } = await import('./commands/logout/index.js'); - return await cmd({ config, flags }); - } - } - - if (!config.db?.studio) { - console.log(STUDIO_CONFIG_MISSING_CLI_ERROR); - process.exit(1); - } + const { mod } = await loadDbConfigFile(astroConfig.root); + // TODO: parseConfigOrExit() + const dbConfig = dbConfigSchema.parse(mod?.default ?? {}); switch (command) { case 'shell': { const { cmd } = await import('./commands/shell/index.js'); - return await cmd({ config, flags }); + return await cmd({ astroConfig, dbConfig, flags }); } case 'gen': case 'sync': { const { cmd } = await import('./commands/gen/index.js'); - return await cmd({ config, flags }); + return await cmd({ astroConfig, dbConfig, flags }); } case 'push': { const { cmd } = await import('./commands/push/index.js'); - return await cmd({ config, flags }); + return await cmd({ astroConfig, dbConfig, flags }); } case 'verify': { const { cmd } = await import('./commands/verify/index.js'); - return await cmd({ config, flags }); + return await cmd({ astroConfig, dbConfig, flags }); + } + case 'execute': { + const { cmd } = await import('./commands/execute/index.js'); + return await cmd({ astroConfig, dbConfig, flags }); + } + case 'login': { + const { cmd } = await import('./commands/login/index.js'); + return await cmd({ astroConfig, dbConfig, flags }); + } + case 'logout': { + const { cmd } = await import('./commands/logout/index.js'); + return await cmd(); } case 'link': { const { cmd } = await import('./commands/link/index.js'); - return await cmd({ config, flags }); + return await cmd(); } default: { if (command == null) { diff --git a/packages/db/src/core/cli/migration-queries.ts b/packages/db/src/core/cli/migration-queries.ts index e031108e4747..4265f36e424a 100644 --- a/packages/db/src/core/cli/migration-queries.ts +++ b/packages/db/src/core/cli/migration-queries.ts @@ -4,7 +4,6 @@ import * as color from 'kleur/colors'; import { customAlphabet } from 'nanoid'; import prompts from 'prompts'; import { hasPrimaryKey } from '../../runtime/index.js'; -import { isSerializedSQL } from '../../runtime/types.js'; import { getCreateIndexQueries, getCreateTableQuery, @@ -12,7 +11,8 @@ import { getReferencesConfig, hasDefault, schemaTypeToSqlType, -} from '../queries.js'; +} from '../../runtime/queries.js'; +import { isSerializedSQL } from '../../runtime/types.js'; import { type BooleanColumn, type ColumnType, diff --git a/packages/db/src/core/cli/migrations.ts b/packages/db/src/core/cli/migrations.ts index 671862b61495..514d4e798a11 100644 --- a/packages/db/src/core/cli/migrations.ts +++ b/packages/db/src/core/cli/migrations.ts @@ -1,8 +1,8 @@ -import type { AstroConfig } from 'astro'; import deepDiff from 'deep-diff'; import { mkdir, readFile, readdir, writeFile } from 'fs/promises'; import { cyan, green, yellow } from 'kleur/colors'; -import { type DBSnapshot, tablesSchema } from '../types.js'; +import { type DBConfig, type DBSnapshot } from '../types.js'; +import { getMigrationsDirectoryUrl } from '../utils.js'; const { applyChange, diff: generateDiff } = deepDiff; export type MigrationStatus = @@ -24,9 +24,18 @@ export type MigrationStatus = currentSnapshot: DBSnapshot; }; -export async function getMigrationStatus(config: AstroConfig): Promise { - const currentSnapshot = createCurrentSnapshot(config); - const allMigrationFiles = await getMigrations(); +export const INITIAL_SNAPSHOT = '0000_snapshot.json'; + +export async function getMigrationStatus({ + dbConfig, + root, +}: { + dbConfig: DBConfig; + root: URL; +}): Promise { + const currentSnapshot = createCurrentSnapshot(dbConfig); + const dir = getMigrationsDirectoryUrl(root); + const allMigrationFiles = await getMigrations(dir); if (allMigrationFiles.length === 0) { return { @@ -35,7 +44,7 @@ export async function getMigrationStatus(config: AstroConfig): Promise { - const migrationFiles = await readdir('./migrations').catch((err) => { +export async function getMigrations(dir: URL): Promise { + const migrationFiles = await readdir(dir).catch((err) => { if (err.code === 'ENOENT') { return []; } @@ -94,13 +103,14 @@ export async function getMigrations(): Promise { } export async function loadMigration( - migration: string + migration: string, + dir: URL ): Promise<{ diff: any[]; db: string[]; confirm?: string[] }> { - return JSON.parse(await readFile(`./migrations/${migration}`, 'utf-8')); + return JSON.parse(await readFile(new URL(migration, dir), 'utf-8')); } -export async function loadInitialSnapshot(): Promise { - const snapshot = JSON.parse(await readFile('./migrations/0000_snapshot.json', 'utf-8')); +export async function loadInitialSnapshot(dir: URL): Promise { + const snapshot = JSON.parse(await readFile(new URL(INITIAL_SNAPSHOT, dir), 'utf-8')); // `experimentalVersion: 1` -- added the version column if (snapshot.experimentalVersion === 1) { return snapshot; @@ -112,16 +122,19 @@ export async function loadInitialSnapshot(): Promise { throw new Error('Invalid snapshot format'); } -export async function initializeMigrationsDirectory(currentSnapshot: DBSnapshot) { - await mkdir('./migrations', { recursive: true }); - await writeFile('./migrations/0000_snapshot.json', JSON.stringify(currentSnapshot, undefined, 2)); +export async function initializeMigrationsDirectory(currentSnapshot: DBSnapshot, dir: URL) { + await mkdir(dir, { recursive: true }); + await writeFile(new URL(INITIAL_SNAPSHOT, dir), JSON.stringify(currentSnapshot, undefined, 2)); } -export async function initializeFromMigrations(allMigrationFiles: string[]): Promise { - const prevSnapshot = await loadInitialSnapshot(); +export async function initializeFromMigrations( + allMigrationFiles: string[], + dir: URL +): Promise { + const prevSnapshot = await loadInitialSnapshot(dir); for (const migration of allMigrationFiles) { - if (migration === '0000_snapshot.json') continue; - const migrationContent = await loadMigration(migration); + if (migration === INITIAL_SNAPSHOT) continue; + const migrationContent = await loadMigration(migration, dir); migrationContent.diff.forEach((change: any) => { applyChange(prevSnapshot, {}, change); }); @@ -129,10 +142,8 @@ export async function initializeFromMigrations(allMigrationFiles: string[]): Pro return prevSnapshot; } -export function createCurrentSnapshot(config: AstroConfig): DBSnapshot { - // Parse to resolve non-serializable types like () => references - const tablesConfig = tablesSchema.parse(config.db?.tables ?? {}); - const schema = JSON.parse(JSON.stringify(tablesConfig)); +export function createCurrentSnapshot({ tables = {} }: DBConfig): DBSnapshot { + const schema = JSON.parse(JSON.stringify(tables)); return { experimentalVersion: 1, schema }; } export function createEmptySnapshot(): DBSnapshot { diff --git a/packages/db/src/core/consts.ts b/packages/db/src/core/consts.ts index 1f6771b04f85..bf295a2dc9a8 100644 --- a/packages/db/src/core/consts.ts +++ b/packages/db/src/core/consts.ts @@ -6,9 +6,12 @@ export const PACKAGE_NAME = JSON.parse( export const RUNTIME_IMPORT = JSON.stringify(`${PACKAGE_NAME}/runtime`); export const RUNTIME_DRIZZLE_IMPORT = JSON.stringify(`${PACKAGE_NAME}/runtime/drizzle`); +export const RUNTIME_CONFIG_IMPORT = JSON.stringify(`${PACKAGE_NAME}/runtime/config`); export const DB_TYPES_FILE = 'db-types.d.ts'; export const VIRTUAL_MODULE_ID = 'astro:db'; export const DB_PATH = '.astro/content.db'; + +export const CONFIG_FILE_NAMES = ['config.ts', 'config.js', 'config.mts', 'config.mjs']; diff --git a/packages/db/src/core/errors.ts b/packages/db/src/core/errors.ts index 76a66d0dc954..706553a0673e 100644 --- a/packages/db/src/core/errors.ts +++ b/packages/db/src/core/errors.ts @@ -10,44 +10,41 @@ export const MISSING_PROJECT_ID_ERROR = `${red('▶ Directory not linked.')} To link this directory to an Astro Studio project, run ${cyan('astro db link')}\n`; -export const STUDIO_CONFIG_MISSING_WRITABLE_TABLE_ERROR = (tableName: string) => `${red( - `▶ Writable table ${bold(tableName)} requires Astro Studio or the ${yellow( - 'unsafeWritable' - )} option.` -)} - - Visit ${cyan('https://astro.build/studio')} to create your account - and set ${green('studio: true')} in your astro.config.mjs file to enable Studio.\n`; +export const MIGRATIONS_NOT_INITIALIZED = `${yellow( + '▶ No migrations found!' +)}\n\n To scaffold your migrations folder, run\n ${cyan('astro db sync')}\n`; -export const UNSAFE_WRITABLE_WARNING = `${yellow( - 'unsafeWritable' -)} option is enabled and you are using writable tables. - Redeploying your app may result in wiping away your database. - I hope you know what you are doing.\n`; +export const MISSING_EXECUTE_PATH_ERROR = `${red( + '▶ No file path provided.' +)} Provide a path by running ${cyan('astro db execute ')}\n`; -export const STUDIO_CONFIG_MISSING_CLI_ERROR = `${red('▶ This command requires Astro Studio.')} +export const FILE_NOT_FOUND_ERROR = (path: string) => + `${red('▶ File not found:')} ${bold(path)}\n`; - Visit ${cyan('https://astro.build/studio')} to create your account - and set ${green('studio: true')} in your astro.config.mjs file to enable Studio.\n`; +export const SEED_ERROR = (error: string) => { + return `${red(`Error while seeding database:`)}\n\n${error}`; +}; -export const MIGRATIONS_NOT_INITIALIZED = `${yellow( - '▶ No migrations found!' -)}\n\n To scaffold your migrations folder, run\n ${cyan('astro db sync')}\n`; +export const REFERENCE_DNE_ERROR = (columnName: string) => { + return `Column ${bold( + columnName + )} references a table that does not exist. Did you apply the referenced table to the \`tables\` object in your db config?`; +}; -export const SEED_WRITABLE_IN_PROD_ERROR = (tableName: string) => { - return `${red( - `Writable tables should not be seeded in production with data().` - )} You can seed ${bold( +export const FOREIGN_KEY_DNE_ERROR = (tableName: string) => { + return `Table ${bold( tableName - )} in development mode only using the "mode" flag. See the docs for more: https://www.notion.so/astroinc/astrojs-db-README-dcf6fa10de9a4f528be56cee96e8c054?pvs=4#278aed3fc37e4cec80240d1552ff6ac5`; + )} references a table that does not exist. Did you apply the referenced table to the \`tables\` object in your db config?`; }; -export const SEED_ERROR = (tableName: string, error: string) => { - return `${red(`Error seeding table ${bold(tableName)}:`)}\n\n${error}`; +export const FOREIGN_KEY_REFERENCES_LENGTH_ERROR = (tableName: string) => { + return `Foreign key on ${bold( + tableName + )} is misconfigured. \`columns\` and \`references\` must be the same length.`; }; -export const SEED_EMPTY_ARRAY_ERROR = (tableName: string) => { - // Drizzle error says "values() must be called with at least one value." - // This is specific to db.insert(). Prettify for seed(). - return SEED_ERROR(tableName, `Empty array was passed. seed() must receive at least one value.`); +export const FOREIGN_KEY_REFERENCES_EMPTY_ERROR = (tableName: string) => { + return `Foreign key on ${bold( + tableName + )} is misconfigured. \`references\` array cannot be empty.`; }; diff --git a/packages/db/src/core/integration/index.ts b/packages/db/src/core/integration/index.ts index d9cddfd72433..4361ddfe7f67 100644 --- a/packages/db/src/core/integration/index.ts +++ b/packages/db/src/core/integration/index.ts @@ -4,25 +4,29 @@ import { fileURLToPath } from 'url'; import type { AstroIntegration } from 'astro'; import { mkdir, rm, writeFile } from 'fs/promises'; import { blue, yellow } from 'kleur/colors'; -import { createLocalDatabaseClient } from '../../runtime/db-client.js'; -import { DB_PATH } from '../consts.js'; -import { STUDIO_CONFIG_MISSING_WRITABLE_TABLE_ERROR, UNSAFE_WRITABLE_WARNING } from '../errors.js'; -import { recreateTables, seedData } from '../queries.js'; +import { CONFIG_FILE_NAMES, DB_PATH } from '../consts.js'; +import { loadDbConfigFile } from '../load-file.js'; import { type ManagedAppToken, getManagedAppTokenOrExit } from '../tokens.js'; -import { type DBTables, astroConfigWithDbSchema } from '../types.js'; -import { type VitePlugin } from '../utils.js'; +import { type DBConfig, dbConfigSchema } from '../types.js'; +import { type VitePlugin, getDbDirectoryUrl } from '../utils.js'; import { errorMap } from './error-map.js'; import { fileURLIntegration } from './file-url.js'; import { typegen } from './typegen.js'; -import { vitePluginDb } from './vite-plugin-db.js'; +import { type LateTables, vitePluginDb } from './vite-plugin-db.js'; import { vitePluginInjectEnvTs } from './vite-plugin-inject-env-ts.js'; function astroDBIntegration(): AstroIntegration { - let connectedToRemote = false; + let connectToStudio = false; + let configFileDependencies: string[] = []; + let root: URL; let appToken: ManagedAppToken | undefined; - let schemas = { - tables(): DBTables { - throw new Error('tables not found'); + let dbConfig: DBConfig; + + // Make table loading "late" to pass to plugins from `config:setup`, + // but load during `config:done` to wait for integrations to settle. + let tables: LateTables = { + get() { + throw new Error('[astro:db] INTERNAL Tables not loaded yet'); }, }; let command: 'dev' | 'build' | 'preview'; @@ -31,25 +35,28 @@ function astroDBIntegration(): AstroIntegration { hooks: { 'astro:config:setup': async ({ updateConfig, config, command: _command, logger }) => { command = _command; - if (_command === 'preview') return; + root = config.root; + + if (command === 'preview') return; let dbPlugin: VitePlugin | undefined = undefined; - const studio = config.db?.studio ?? false; + connectToStudio = command === 'build'; - if (studio && command === 'build' && process.env.ASTRO_DB_TEST_ENV !== '1') { + if (connectToStudio) { appToken = await getManagedAppTokenOrExit(); - connectedToRemote = true; dbPlugin = vitePluginDb({ - connectToStudio: true, + connectToStudio, appToken: appToken.token, - schemas, + tables, root: config.root, + srcDir: config.srcDir, }); } else { dbPlugin = vitePluginDb({ connectToStudio: false, - schemas, + tables, root: config.root, + srcDir: config.srcDir, }); } @@ -60,67 +67,50 @@ function astroDBIntegration(): AstroIntegration { }, }); }, - 'astro:config:done': async ({ config, logger }) => { + 'astro:config:done': async ({ config }) => { // TODO: refine where we load tables // @matthewp: may want to load tables by path at runtime - const configWithDb = astroConfigWithDbSchema.parse(config, { errorMap }); - const tables = configWithDb.db?.tables ?? {}; - // Redefine getTables so our integration can grab them - schemas.tables = () => tables; - - const studio = configWithDb.db?.studio ?? false; - const unsafeWritable = Boolean(configWithDb.db?.unsafeWritable); - const foundWritableCollection = Object.entries(tables).find(([, c]) => c.writable); - const writableAllowed = studio || unsafeWritable; - if (!writableAllowed && foundWritableCollection) { - logger.error(STUDIO_CONFIG_MISSING_WRITABLE_TABLE_ERROR(foundWritableCollection[0])); - process.exit(1); - } - // Using writable tables with the opt-in flag. Warn them to let them - // know the risk. - else if (unsafeWritable && foundWritableCollection) { - logger.warn(UNSAFE_WRITABLE_WARNING); - } + const { mod, dependencies } = await loadDbConfigFile(config.root); + configFileDependencies = dependencies; + dbConfig = dbConfigSchema.parse(mod?.default ?? {}, { + errorMap, + }); + // TODO: resolve integrations here? + tables.get = () => dbConfig.tables ?? {}; - if (!connectedToRemote) { + if (!connectToStudio && !process.env.TEST_IN_MEMORY_DB) { const dbUrl = new URL(DB_PATH, config.root); if (existsSync(dbUrl)) { await rm(dbUrl); } await mkdir(dirname(fileURLToPath(dbUrl)), { recursive: true }); await writeFile(dbUrl, ''); - - using db = await createLocalDatabaseClient({ - tables, - dbUrl: dbUrl.toString(), - seeding: true, - }); - await recreateTables({ db, tables }); - if (configWithDb.db?.data) { - await seedData({ - db, - data: configWithDb.db.data, - logger, - mode: command === 'dev' ? 'dev' : 'build', - }); - } - logger.debug('Database setup complete.'); } - await typegen({ tables, root: config.root }); + await typegen({ tables: tables.get() ?? {}, root: config.root }); }, 'astro:server:start': async ({ logger }) => { // Wait for the server startup to log, so that this can come afterwards. setTimeout(() => { logger.info( - connectedToRemote ? 'Connected to remote database.' : 'New local database created.' + connectToStudio ? 'Connected to remote database.' : 'New local database created.' ); }, 100); }, + 'astro:server:setup': async ({ server }) => { + const filesToWatch = [ + ...CONFIG_FILE_NAMES.map((c) => new URL(c, getDbDirectoryUrl(root))), + ...configFileDependencies.map((c) => new URL(c, root)), + ]; + server.watcher.on('all', (event, relativeEntry) => { + const entry = new URL(relativeEntry, root); + if (filesToWatch.some((f) => entry.href === f.href)) { + server.restart(); + } + }); + }, 'astro:build:start': async ({ logger }) => { - logger.info( - 'database: ' + (connectedToRemote ? yellow('remote') : blue('local database.')) - ); + logger.info('database: ' + (connectToStudio ? yellow('remote') : blue('local database.'))); }, 'astro:build:done': async ({}) => { await appToken?.destroy(); diff --git a/packages/db/src/core/integration/typegen.ts b/packages/db/src/core/integration/typegen.ts index 0436261e16ba..4fad1bc92189 100644 --- a/packages/db/src/core/integration/typegen.ts +++ b/packages/db/src/core/integration/typegen.ts @@ -28,19 +28,7 @@ ${Object.entries(tables) function generateTableType(name: string, collection: DBTable): string { let tableType = ` export const ${name}: import(${RUNTIME_IMPORT}).Table< ${JSON.stringify(name)}, - ${JSON.stringify( - Object.fromEntries( - Object.entries(collection.columns).map(([columnName, column]) => [ - columnName, - { - // Only select columns Drizzle needs for inference - type: column.type, - optional: column.schema.optional, - default: column.schema.default, - }, - ]) - ) - )} + ${JSON.stringify(collection.columns)} >;`; return tableType; } diff --git a/packages/db/src/core/integration/vite-plugin-db.ts b/packages/db/src/core/integration/vite-plugin-db.ts index 121d0eca1d62..65a9fa6a03b6 100644 --- a/packages/db/src/core/integration/vite-plugin-db.ts +++ b/packages/db/src/core/integration/vite-plugin-db.ts @@ -1,70 +1,125 @@ -import { DB_PATH, RUNTIME_DRIZZLE_IMPORT, RUNTIME_IMPORT, VIRTUAL_MODULE_ID } from '../consts.js'; +import { fileURLToPath } from 'node:url'; +import { normalizePath } from 'vite'; +import { SEED_DEV_FILE_NAME } from '../../runtime/queries.js'; +import { + DB_PATH, + RUNTIME_CONFIG_IMPORT, + RUNTIME_DRIZZLE_IMPORT, + RUNTIME_IMPORT, + VIRTUAL_MODULE_ID, +} from '../consts.js'; import type { DBTables } from '../types.js'; -import { type VitePlugin, getRemoteDatabaseUrl } from '../utils.js'; +import { type VitePlugin, getDbDirectoryUrl, getRemoteDatabaseUrl } from '../utils.js'; + +const LOCAL_DB_VIRTUAL_MODULE_ID = 'astro:local'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; +const resolvedLocalDbVirtualModuleId = LOCAL_DB_VIRTUAL_MODULE_ID + '/local-db'; +const resolvedSeedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID + '?shouldSeed'; -type LateSchema = { - tables: () => DBTables; +export type LateTables = { + get: () => DBTables; }; type VitePluginDBParams = | { connectToStudio: false; - schemas: LateSchema; + tables: LateTables; + srcDir: URL; root: URL; } | { connectToStudio: true; - schemas: LateSchema; + tables: LateTables; appToken: string; + srcDir: URL; root: URL; }; export function vitePluginDb(params: VitePluginDBParams): VitePlugin { + const srcDirPath = normalizePath(fileURLToPath(params.srcDir)); return { name: 'astro:db', enforce: 'pre', - resolveId(id) { - if (id === VIRTUAL_MODULE_ID) { - return resolvedVirtualModuleId; + async resolveId(id, rawImporter) { + if (id === LOCAL_DB_VIRTUAL_MODULE_ID) return resolvedLocalDbVirtualModuleId; + if (id !== VIRTUAL_MODULE_ID) return; + if (params.connectToStudio) return resolvedVirtualModuleId; + + const importer = rawImporter ? await this.resolve(rawImporter) : null; + if (!importer) return resolvedVirtualModuleId; + + if (importer.id.startsWith(srcDirPath)) { + // Seed only if the importer is in the src directory. + // Otherwise, we may get recursive seed calls (ex. import from db/seed.ts). + return resolvedSeedVirtualModuleId; } + return resolvedVirtualModuleId; }, load(id) { - if (id !== resolvedVirtualModuleId) return; + if (id === resolvedLocalDbVirtualModuleId) { + const dbUrl = new URL(DB_PATH, params.root); + return `import { createLocalDatabaseClient } from ${RUNTIME_IMPORT}; + const dbUrl = ${JSON.stringify(dbUrl)}; + + export const db = createLocalDatabaseClient({ dbUrl });`; + } + + if (id !== resolvedVirtualModuleId && id !== resolvedSeedVirtualModuleId) return; if (params.connectToStudio) { return getStudioVirtualModContents({ appToken: params.appToken, - tables: params.schemas.tables(), + tables: params.tables.get(), }); } - return getVirtualModContents({ + return getLocalVirtualModContents({ root: params.root, - tables: params.schemas.tables(), + tables: params.tables.get(), + shouldSeed: id === resolvedSeedVirtualModuleId, }); }, }; } -export function getVirtualModContents({ tables, root }: { tables: DBTables; root: URL }) { - const dbUrl = new URL(DB_PATH, root); +export function getConfigVirtualModContents() { + return `export * from ${RUNTIME_CONFIG_IMPORT}`; +} + +export function getLocalVirtualModContents({ + tables, + shouldSeed, +}: { + tables: DBTables; + root: URL; + shouldSeed: boolean; +}) { + const seedFilePaths = SEED_DEV_FILE_NAME.map( + // Format as /db/[name].ts + // for Vite import.meta.glob + (name) => new URL(name, getDbDirectoryUrl('file:///')).pathname + ); + return ` -import { collectionToTable, createLocalDatabaseClient } from ${RUNTIME_IMPORT}; -import dbUrl from ${JSON.stringify(`${dbUrl}?fileurl`)}; +import { asDrizzleTable, seedLocal } from ${RUNTIME_IMPORT}; +import { db as _db } from ${JSON.stringify(LOCAL_DB_VIRTUAL_MODULE_ID)}; -const params = ${JSON.stringify({ - tables, - seeding: false, - })}; -params.dbUrl = dbUrl; +export const db = _db; -export const db = await createLocalDatabaseClient(params); +${ + shouldSeed + ? `await seedLocal({ + db: _db, + tables: ${JSON.stringify(tables)}, + fileGlob: import.meta.glob(${JSON.stringify(seedFilePaths)}), +})` + : '' +} export * from ${RUNTIME_DRIZZLE_IMPORT}; +export * from ${RUNTIME_CONFIG_IMPORT}; -${getStringifiedCollectionExports(tables)} -`; +${getStringifiedCollectionExports(tables)}`; } export function getStudioVirtualModContents({ @@ -75,13 +130,14 @@ export function getStudioVirtualModContents({ appToken: string; }) { return ` -import {collectionToTable, createRemoteDatabaseClient} from ${RUNTIME_IMPORT}; +import {asDrizzleTable, createRemoteDatabaseClient} from ${RUNTIME_IMPORT}; export const db = await createRemoteDatabaseClient(${JSON.stringify( appToken // Respect runtime env for user overrides in SSR )}, import.meta.env.ASTRO_STUDIO_REMOTE_DB_URL ?? ${JSON.stringify(getRemoteDatabaseUrl())}); export * from ${RUNTIME_DRIZZLE_IMPORT}; +export * from ${RUNTIME_CONFIG_IMPORT}; ${getStringifiedCollectionExports(tables)} `; @@ -91,7 +147,7 @@ function getStringifiedCollectionExports(tables: DBTables) { return Object.entries(tables) .map( ([name, collection]) => - `export const ${name} = collectionToTable(${JSON.stringify(name)}, ${JSON.stringify( + `export const ${name} = asDrizzleTable(${JSON.stringify(name)}, ${JSON.stringify( collection )}, false)` ) diff --git a/packages/db/src/core/load-file.ts b/packages/db/src/core/load-file.ts new file mode 100644 index 000000000000..cc1b23c014b2 --- /dev/null +++ b/packages/db/src/core/load-file.ts @@ -0,0 +1,117 @@ +import { existsSync } from 'node:fs'; +import { unlink, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { build as esbuild } from 'esbuild'; +import { CONFIG_FILE_NAMES, VIRTUAL_MODULE_ID } from './consts.js'; +import { getConfigVirtualModContents } from './integration/vite-plugin-db.js'; +import { getDbDirectoryUrl } from './utils.js'; + +export async function loadDbConfigFile( + root: URL +): Promise<{ mod: { default?: unknown } | undefined; dependencies: string[] }> { + let configFileUrl: URL | undefined; + for (const fileName of CONFIG_FILE_NAMES) { + const fileUrl = new URL(fileName, getDbDirectoryUrl(root)); + if (existsSync(fileUrl)) { + configFileUrl = fileUrl; + } + } + if (!configFileUrl) { + return { mod: undefined, dependencies: [] }; + } + const { code, dependencies } = await bundleFile({ + virtualModContents: getConfigVirtualModContents(), + root, + fileUrl: configFileUrl, + }); + return { + mod: await importBundledFile({ code, root }), + dependencies, + }; +} + +/** + * Bundle arbitrary `mjs` or `ts` file. + * Simplified fork from Vite's `bundleConfigFile` function. + * + * @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L961 + */ +export async function bundleFile({ + fileUrl, + root, + virtualModContents, +}: { + fileUrl: URL; + root: URL; + virtualModContents: string; +}) { + const result = await esbuild({ + absWorkingDir: process.cwd(), + entryPoints: [fileURLToPath(fileUrl)], + outfile: 'out.js', + packages: 'external', + write: false, + target: ['node16'], + platform: 'node', + bundle: true, + format: 'esm', + sourcemap: 'inline', + metafile: true, + define: { + 'import.meta.env.ASTRO_STUDIO_REMOTE_DB_URL': 'undefined', + }, + plugins: [ + { + name: 'resolve-astro-db', + setup(build) { + build.onResolve({ filter: /^astro:db$/ }, ({ path }) => { + return { path, namespace: VIRTUAL_MODULE_ID }; + }); + build.onLoad({ namespace: VIRTUAL_MODULE_ID, filter: /.*/ }, () => { + return { + contents: virtualModContents, + // Needed to resolve runtime dependencies + resolveDir: fileURLToPath(root), + }; + }); + }, + }, + ], + }); + + const file = result.outputFiles[0]; + if (!file) { + throw new Error(`Unexpected: no output file`); + } + + return { + code: file.text, + dependencies: Object.keys(result.metafile.inputs), + }; +} + +/** + * Forked from Vite config loader, replacing CJS-based path concat with ESM only + * + * @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L1074 + */ +export async function importBundledFile({ + code, + root, +}: { + code: string; + root: URL; +}): Promise<{ default?: unknown }> { + // Write it to disk, load it with native Node ESM, then delete the file. + const tmpFileUrl = new URL(`./db.timestamp-${Date.now()}.mjs`, root); + await writeFile(tmpFileUrl, code, { encoding: 'utf8' }); + try { + return await import(/* @vite-ignore */ tmpFileUrl.pathname); + } finally { + try { + await unlink(tmpFileUrl); + } catch { + // already removed if this function is called twice simultaneously + } + } +} diff --git a/packages/db/src/core/queries.ts b/packages/db/src/core/queries.ts deleted file mode 100644 index f699a297a3bf..000000000000 --- a/packages/db/src/core/queries.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { AstroIntegrationLogger } from 'astro'; -import { type SQL, getTableName, sql } from 'drizzle-orm'; -import { SQLiteAsyncDialect, type SQLiteInsert } from 'drizzle-orm/sqlite-core'; -import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy'; -import { bold } from 'kleur/colors'; -import { - type BooleanColumn, - type ColumnType, - type DBColumn, - type DBTable, - type DBTables, - type DateColumn, - type JsonColumn, - type NumberColumn, - type TextColumn, -} from '../core/types.js'; -import type { - ColumnsConfig, - DBUserConfig, - MaybeArray, - ResolvedCollectionConfig, -} from '../core/types.js'; -import { hasPrimaryKey } from '../runtime/index.js'; -import { isSerializedSQL } from '../runtime/types.js'; -import { SEED_EMPTY_ARRAY_ERROR, SEED_ERROR, SEED_WRITABLE_IN_PROD_ERROR } from './errors.js'; - -const sqlite = new SQLiteAsyncDialect(); - -export async function recreateTables({ - db, - tables, -}: { - db: SqliteRemoteDatabase; - tables: DBTables; -}) { - const setupQueries: SQL[] = []; - for (const [name, collection] of Object.entries(tables)) { - const dropQuery = sql.raw(`DROP TABLE IF EXISTS ${sqlite.escapeName(name)}`); - const createQuery = sql.raw(getCreateTableQuery(name, collection)); - const indexQueries = getCreateIndexQueries(name, collection); - setupQueries.push(dropQuery, createQuery, ...indexQueries.map((s) => sql.raw(s))); - } - for (const q of setupQueries) { - await db.run(q); - } -} - -export async function seedData({ - db, - data, - logger, - mode, -}: { - db: SqliteRemoteDatabase; - data: DBUserConfig['data']; - logger?: AstroIntegrationLogger; - mode: 'dev' | 'build'; -}) { - const dataFns = Array.isArray(data) ? data : [data]; - try { - for (const dataFn of dataFns) { - await dataFn({ - seed: async (config, values) => { - seedErrorChecks(mode, config, values); - try { - await db.insert(config.table).values(values as any); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - throw new Error(SEED_ERROR(getTableName(config.table), msg)); - } - }, - seedReturning: async (config, values) => { - seedErrorChecks(mode, config, values); - try { - let result: SQLiteInsert = db - .insert(config.table) - .values(values as any) - .returning(); - if (!Array.isArray(values)) { - result = result.get(); - } - return result; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - throw new Error(SEED_ERROR(getTableName(config.table), msg)); - } - }, - db, - mode, - }); - } - } catch (e) { - if (!(e instanceof Error)) throw e; - (logger ?? console).error(e.message); - } -} - -function seedErrorChecks( - mode: 'dev' | 'build', - { table, writable }: ResolvedCollectionConfig, - values: MaybeArray -) { - const tableName = getTableName(table); - if (writable && mode === 'build' && process.env.ASTRO_DB_TEST_ENV !== '1') { - throw new Error(SEED_WRITABLE_IN_PROD_ERROR(tableName)); - } - if (Array.isArray(values) && values.length === 0) { - throw new Error(SEED_EMPTY_ARRAY_ERROR(tableName)); - } -} - -export function getCreateTableQuery(collectionName: string, collection: DBTable) { - let query = `CREATE TABLE ${sqlite.escapeName(collectionName)} (`; - - const colQueries = []; - const colHasPrimaryKey = Object.entries(collection.columns).find(([, column]) => - hasPrimaryKey(column) - ); - if (!colHasPrimaryKey) { - colQueries.push('_id INTEGER PRIMARY KEY'); - } - for (const [columnName, column] of Object.entries(collection.columns)) { - const colQuery = `${sqlite.escapeName(columnName)} ${schemaTypeToSqlType( - column.type - )}${getModifiers(columnName, column)}`; - colQueries.push(colQuery); - } - - colQueries.push(...getCreateForeignKeyQueries(collectionName, collection)); - - query += colQueries.join(', ') + ')'; - return query; -} - -export function getCreateIndexQueries( - collectionName: string, - collection: Pick -) { - let queries: string[] = []; - for (const [indexName, indexProps] of Object.entries(collection.indexes ?? {})) { - const onColNames = asArray(indexProps.on); - const onCols = onColNames.map((colName) => sqlite.escapeName(colName)); - - const unique = indexProps.unique ? 'UNIQUE ' : ''; - const indexQuery = `CREATE ${unique}INDEX ${sqlite.escapeName( - indexName - )} ON ${sqlite.escapeName(collectionName)} (${onCols.join(', ')})`; - queries.push(indexQuery); - } - return queries; -} - -export function getCreateForeignKeyQueries(collectionName: string, collection: DBTable) { - let queries: string[] = []; - for (const foreignKey of collection.foreignKeys ?? []) { - const columns = asArray(foreignKey.columns); - const references = asArray(foreignKey.references); - - if (columns.length !== references.length) { - throw new Error( - `Foreign key on ${collectionName} is misconfigured. \`columns\` and \`references\` must be the same length.` - ); - } - const referencedCollection = references[0]?.schema.collection; - if (!referencedCollection) { - throw new Error( - `Foreign key on ${collectionName} is misconfigured. \`references\` cannot be empty.` - ); - } - const query = `FOREIGN KEY (${columns - .map((f) => sqlite.escapeName(f)) - .join(', ')}) REFERENCES ${sqlite.escapeName(referencedCollection)}(${references - .map((r) => sqlite.escapeName(r.schema.name!)) - .join(', ')})`; - queries.push(query); - } - return queries; -} - -function asArray(value: T | T[]) { - return Array.isArray(value) ? value : [value]; -} - -export function schemaTypeToSqlType(type: ColumnType): 'text' | 'integer' { - switch (type) { - case 'date': - case 'text': - case 'json': - return 'text'; - case 'number': - case 'boolean': - return 'integer'; - } -} - -export function getModifiers(columnName: string, column: DBColumn) { - let modifiers = ''; - if (hasPrimaryKey(column)) { - return ' PRIMARY KEY'; - } - if (!column.schema.optional) { - modifiers += ' NOT NULL'; - } - if (column.schema.unique) { - modifiers += ' UNIQUE'; - } - if (hasDefault(column)) { - modifiers += ` DEFAULT ${getDefaultValueSql(columnName, column)}`; - } - const references = getReferencesConfig(column); - if (references) { - const { collection, name } = references.schema; - if (!collection || !name) { - throw new Error( - `Column ${collection}.${name} references a collection that does not exist. Did you apply the referenced collection to the \`tables\` object in your Astro config?` - ); - } - - modifiers += ` REFERENCES ${sqlite.escapeName(collection)} (${sqlite.escapeName(name)})`; - } - return modifiers; -} - -export function getReferencesConfig(column: DBColumn) { - const canHaveReferences = column.type === 'number' || column.type === 'text'; - if (!canHaveReferences) return undefined; - return column.schema.references; -} - -// Using `DBColumn` will not narrow `default` based on the column `type` -// Handle each column separately -type WithDefaultDefined = T & { - schema: Required>; -}; -type DBColumnWithDefault = - | WithDefaultDefined - | WithDefaultDefined - | WithDefaultDefined - | WithDefaultDefined - | WithDefaultDefined; - -// Type narrowing the default fails on union types, so use a type guard -export function hasDefault(column: DBColumn): column is DBColumnWithDefault { - if (column.schema.default !== undefined) { - return true; - } - if (hasPrimaryKey(column) && column.type === 'number') { - return true; - } - return false; -} - -function toDefault(def: T | SQL): string { - const type = typeof def; - if (type === 'string') { - return sqlite.escapeString(def as string); - } else if (type === 'boolean') { - return def ? 'TRUE' : 'FALSE'; - } else { - return def + ''; - } -} - -function getDefaultValueSql(columnName: string, column: DBColumnWithDefault): string { - if (isSerializedSQL(column.schema.default)) { - return column.schema.default.sql; - } - - switch (column.type) { - case 'boolean': - case 'number': - case 'text': - case 'date': - return toDefault(column.schema.default); - case 'json': { - let stringified = ''; - try { - stringified = JSON.stringify(column.schema.default); - } catch (e) { - // eslint-disable-next-line no-console - console.log( - `Invalid default value for column ${bold( - columnName - )}. Defaults must be valid JSON when using the \`json()\` type.` - ); - process.exit(0); - } - - return sqlite.escapeString(stringified); - } - } -} diff --git a/packages/db/src/core/types.ts b/packages/db/src/core/types.ts index a4896a8bead6..f9de8f8ee519 100644 --- a/packages/db/src/core/types.ts +++ b/packages/db/src/core/types.ts @@ -1,8 +1,6 @@ -import type { InferSelectModel } from 'drizzle-orm'; import { SQL } from 'drizzle-orm'; -import { SQLiteAsyncDialect, type SQLiteInsertValue } from 'drizzle-orm/sqlite-core'; +import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; import { type ZodTypeDef, z } from 'zod'; -import { type SqliteDB, type Table, collectionToTable } from '../runtime/index.js'; import { SERIALIZED_SQL_KEY, type SerializedSQL } from '../runtime/types.js'; import { errorMap } from './integration/error-map.js'; @@ -26,6 +24,7 @@ const baseColumnSchema = z.object({ // Defined when `defineReadableTable()` is called name: z.string().optional(), + // TODO: rename to `tableName`. Breaking schema change collection: z.string().optional(), }); @@ -181,41 +180,26 @@ const foreignKeysSchema: z.ZodType>; -const baseCollectionSchema = z.object({ +export const tableSchema = z.object({ columns: columnsSchema, indexes: z.record(indexSchema).optional(), foreignKeys: z.array(foreignKeysSchema).optional(), }); -export const readableCollectionSchema = baseCollectionSchema.extend({ - writable: z.literal(false), -}); - -export const writableCollectionSchema = baseCollectionSchema.extend({ - writable: z.literal(true), -}); - -export const collectionSchema = z.union([readableCollectionSchema, writableCollectionSchema]); -export const tablesSchema = z.preprocess((rawCollections) => { +export const tablesSchema = z.preprocess((rawTables) => { // Use `z.any()` to avoid breaking object references - const tables = z.record(z.any()).parse(rawCollections, { errorMap }); - for (const [collectionName, collection] of Object.entries(tables)) { - // Append `table` object for data seeding. - // Must append at runtime so table name exists. - collection.table = collectionToTable( - collectionName, - collectionSchema.parse(collection, { errorMap }) - ); - // Append collection and column names to columns. - // Used to track collection info for references. - const { columns } = z.object({ columns: z.record(z.any()) }).parse(collection, { errorMap }); + const tables = z.record(z.any()).parse(rawTables, { errorMap }); + for (const [tableName, table] of Object.entries(tables)) { + // Append table and column names to columns. + // Used to track table info for references. + const { columns } = z.object({ columns: z.record(z.any()) }).parse(table, { errorMap }); for (const [columnName, column] of Object.entries(columns)) { column.schema.name = columnName; - column.schema.collection = collectionName; + column.schema.collection = tableName; } } - return rawCollections; -}, z.record(collectionSchema)); + return rawTables; +}, z.record(tableSchema)); export type BooleanColumn = z.infer; export type BooleanColumnInput = z.input; @@ -243,7 +227,7 @@ export type DBColumnInput = | TextColumnInput | JsonColumnInput; export type DBColumns = z.infer; -export type DBTable = z.infer; +export type DBTable = z.infer; export type DBTables = Record; export type DBSnapshot = { schema: Record; @@ -253,62 +237,24 @@ export type DBSnapshot = { */ experimentalVersion: number; }; -export type ReadableDBTable = z.infer; -export type WritableDBTable = z.infer; - -export type DBDataContext = { - db: SqliteDB; - seed: ( - collection: ResolvedCollectionConfig, - data: MaybeArray>> - ) => Promise; - seedReturning: < - TColumns extends ColumnsConfig, - TData extends MaybeArray>>, - >( - collection: ResolvedCollectionConfig, - data: TData - ) => Promise< - TData extends Array>> - ? InferSelectModel>[] - : InferSelectModel> - >; - mode: 'dev' | 'build'; -}; - -export function defineData(fn: (ctx: DBDataContext) => MaybePromise) { - return fn; -} - -const dbDataFn = z.function().returns(z.union([z.void(), z.promise(z.void())])); export const dbConfigSchema = z.object({ - studio: z.boolean().optional(), tables: tablesSchema.optional(), - data: z.union([dbDataFn, z.array(dbDataFn)]).optional(), - unsafeWritable: z.boolean().optional().default(false), }); -type DataFunction = (params: DBDataContext) => MaybePromise; - -export type DBUserConfig = Omit, 'data'> & { - data: DataFunction | DataFunction[]; -}; +export type DBConfigInput = z.input; +export type DBConfig = z.infer; -export const astroConfigWithDbSchema = z.object({ - db: dbConfigSchema.optional(), -}); +export type ColumnsConfig = z.input['columns']; +export type OutputColumnsConfig = z.output['columns']; -export type ColumnsConfig = z.input['columns']; - -interface CollectionConfig +export interface TableConfig // use `extends` to ensure types line up with zod, // only adding generics for type completions. - extends Pick, 'columns' | 'indexes' | 'foreignKeys'> { + extends Pick, 'columns' | 'indexes' | 'foreignKeys'> { columns: TColumns; foreignKeys?: Array<{ columns: MaybeArray>; - // TODO: runtime error if parent collection doesn't match for all columns. Can't put a generic here... references: () => MaybeArray>; }>; indexes?: Record>; @@ -318,69 +264,11 @@ interface IndexConfig extends z.input>; } -export type ResolvedCollectionConfig< - TColumns extends ColumnsConfig = ColumnsConfig, - Writable extends boolean = boolean, -> = CollectionConfig & { - writable: Writable; - table: Table; -}; - -function baseDefineCollection( - userConfig: CollectionConfig, - writable: TWritable -): ResolvedCollectionConfig { - return { - ...userConfig, - writable, - // set at runtime to get the table name - table: null!, - }; -} - -export function defineReadableTable( - userConfig: CollectionConfig -): ResolvedCollectionConfig { - return baseDefineCollection(userConfig, false); -} - -export function defineWritableTable( - userConfig: CollectionConfig -): ResolvedCollectionConfig { - return baseDefineCollection(userConfig, true); -} - -export type AstroConfigWithDB = z.input; +/** @deprecated Use `TableConfig` instead */ +export type ResolvedCollectionConfig = + TableConfig; // We cannot use `Omit`, // since Omit collapses our union type on primary key. -type NumberColumnOpts = z.input; -type TextColumnOpts = z.input; - -function createColumn>(type: S, schema: T) { - return { - type, - /** - * @internal - */ - schema, - }; -} - -export const column = { - number: (opts: T = {} as T) => { - return createColumn('number', opts) satisfies { type: 'number' }; - }, - boolean: (opts: T = {} as T) => { - return createColumn('boolean', opts) satisfies { type: 'boolean' }; - }, - text: (opts: T = {} as T) => { - return createColumn('text', opts) satisfies { type: 'text' }; - }, - date(opts: T = {} as T) { - return createColumn('date', opts) satisfies { type: 'date' }; - }, - json(opts: T = {} as T) { - return createColumn('json', opts) satisfies { type: 'json' }; - }, -}; +export type NumberColumnOpts = z.input; +export type TextColumnOpts = z.input; diff --git a/packages/db/src/core/utils.ts b/packages/db/src/core/utils.ts index b579770400bc..674514711075 100644 --- a/packages/db/src/core/utils.ts +++ b/packages/db/src/core/utils.ts @@ -17,3 +17,11 @@ export function getAstroStudioUrl(): string { const env = getAstroStudioEnv(); return env.ASTRO_STUDIO_URL || 'https://stardate.astro.build'; } + +export function getDbDirectoryUrl(root: URL | string) { + return new URL('db/', root); +} + +export function getMigrationsDirectoryUrl(root: URL | string) { + return new URL('migrations/', getDbDirectoryUrl(root)); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f280f35587b7..0073f2b33c16 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,5 +1,4 @@ -export { defineReadableTable, defineWritableTable, defineData, column } from './core/types.js'; -export type { ResolvedCollectionConfig, DBDataContext } from './core/types.js'; +export type { ResolvedCollectionConfig, TableConfig } from './core/types.js'; export { cli } from './core/cli/index.js'; export { integration as default } from './core/integration/index.js'; -export { sql, NOW, TRUE, FALSE } from './runtime/index.js'; +export { sql, NOW, TRUE, FALSE, defineDB, defineTable, column } from './runtime/config.js'; diff --git a/packages/db/src/runtime/config.ts b/packages/db/src/runtime/config.ts new file mode 100644 index 000000000000..5fb87e7a5c15 --- /dev/null +++ b/packages/db/src/runtime/config.ts @@ -0,0 +1,48 @@ +import type { + BooleanColumnInput, + ColumnsConfig, + DBConfigInput, + DateColumnInput, + JsonColumnInput, + NumberColumnOpts, + TableConfig, + TextColumnOpts, +} from '../core/types.js'; + +function createColumn>(type: S, schema: T) { + return { + type, + /** + * @internal + */ + schema, + }; +} + +export const column = { + number: (opts: T = {} as T) => { + return createColumn('number', opts) satisfies { type: 'number' }; + }, + boolean: (opts: T = {} as T) => { + return createColumn('boolean', opts) satisfies { type: 'boolean' }; + }, + text: (opts: T = {} as T) => { + return createColumn('text', opts) satisfies { type: 'text' }; + }, + date(opts: T = {} as T) { + return createColumn('date', opts) satisfies { type: 'date' }; + }, + json(opts: T = {} as T) { + return createColumn('json', opts) satisfies { type: 'json' }; + }, +}; + +export function defineTable(userConfig: TableConfig) { + return userConfig; +} + +export function defineDB(userConfig: DBConfigInput) { + return userConfig; +} + +export { sql, NOW, TRUE, FALSE } from './index.js'; diff --git a/packages/db/src/runtime/db-client.ts b/packages/db/src/runtime/db-client.ts index 9058206760af..a16a7b7c0540 100644 --- a/packages/db/src/runtime/db-client.ts +++ b/packages/db/src/runtime/db-client.ts @@ -1,59 +1,19 @@ import type { InStatement } from '@libsql/client'; import { createClient } from '@libsql/client'; -import { getTableName } from 'drizzle-orm'; import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import { drizzle as drizzleLibsql } from 'drizzle-orm/libsql'; -import { type SQLiteTable } from 'drizzle-orm/sqlite-core'; import { drizzle as drizzleProxy } from 'drizzle-orm/sqlite-proxy'; import { z } from 'zod'; -import { type DBTables } from '../core/types.js'; const isWebContainer = !!process.versions?.webcontainer; -interface LocalDatabaseClient extends LibSQLDatabase, Disposable {} - -export async function createLocalDatabaseClient({ - tables, - dbUrl, - seeding, -}: { - dbUrl: string; - tables: DBTables; - seeding: boolean; -}): Promise { +export function createLocalDatabaseClient({ dbUrl }: { dbUrl: string }): LibSQLDatabase { const url = isWebContainer ? 'file:content.db' : dbUrl; - const client = createClient({ url }); - const db = Object.assign(drizzleLibsql(client), { - [Symbol.dispose || Symbol.for('Symbol.dispose')]() { - client.close(); - }, - }); - - if (seeding) return db; - - const { insert: drizzleInsert, update: drizzleUpdate, delete: drizzleDelete } = db; - return Object.assign(db, { - insert(Table: SQLiteTable) { - checkIfModificationIsAllowed(tables, Table); - return drizzleInsert.call(this, Table); - }, - update(Table: SQLiteTable) { - checkIfModificationIsAllowed(tables, Table); - return drizzleUpdate.call(this, Table); - }, - delete(Table: SQLiteTable) { - checkIfModificationIsAllowed(tables, Table); - return drizzleDelete.call(this, Table); - }, - }); -} + console.log('memory', process.env.TEST_IN_MEMORY_DB); + const client = createClient({ url: process.env.TEST_IN_MEMORY_DB ? ':memory:' : url }); + const db = drizzleLibsql(client); -function checkIfModificationIsAllowed(tables: DBTables, Table: SQLiteTable) { - const tableName = getTableName(Table); - const collection = tables[tableName]; - if (!collection.writable) { - throw new Error(`The [${tableName}] collection is read-only.`); - } + return db; } export function createRemoteDatabaseClient(appToken: string, remoteDbURL: string) { @@ -61,8 +21,6 @@ export function createRemoteDatabaseClient(appToken: string, remoteDbURL: string const db = drizzleProxy(async (sql, parameters, method) => { const requestBody: InStatement = { sql, args: parameters }; - // eslint-disable-next-line no-console - console.info(JSON.stringify(requestBody)); const res = await fetch(url, { method: 'POST', headers: { @@ -107,5 +65,9 @@ export function createRemoteDatabaseClient(appToken: string, remoteDbURL: string return { rows: rowValues }; }); + + (db as any).batch = (_drizzleQueries: Array>) => { + throw new Error('db.batch() is not currently supported.'); + }; return db; } diff --git a/packages/db/src/runtime/index.ts b/packages/db/src/runtime/index.ts index 7ab5264cdca3..501ae7a22233 100644 --- a/packages/db/src/runtime/index.ts +++ b/packages/db/src/runtime/index.ts @@ -1,4 +1,5 @@ import { type ColumnBuilderBaseConfig, type ColumnDataType, sql } from 'drizzle-orm'; +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import { type IndexBuilder, type SQLiteColumnBuilderBase, @@ -8,14 +9,14 @@ import { sqliteTable, text, } from 'drizzle-orm/sqlite-core'; -import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy'; import { type DBColumn, type DBTable } from '../core/types.js'; import { type SerializedSQL, isSerializedSQL } from './types.js'; export { sql }; -export type SqliteDB = SqliteRemoteDatabase; +export type SqliteDB = LibSQLDatabase; export type { Table } from './types.js'; export { createRemoteDatabaseClient, createLocalDatabaseClient } from './db-client.js'; +export { seedLocal } from './queries.js'; export function hasPrimaryKey(column: DBColumn) { return 'primaryKey' in column.schema && !!column.schema.primaryKey; @@ -54,17 +55,17 @@ type D1ColumnBuilder = SQLiteColumnBuilderBase< ColumnBuilderBaseConfig & { data: unknown } >; -export function collectionToTable(name: string, collection: DBTable) { +export function asDrizzleTable(name: string, table: DBTable) { const columns: Record = {}; - if (!Object.entries(collection.columns).some(([, column]) => hasPrimaryKey(column))) { + if (!Object.entries(table.columns).some(([, column]) => hasPrimaryKey(column))) { columns['_id'] = integer('_id').primaryKey(); } - for (const [columnName, column] of Object.entries(collection.columns)) { + for (const [columnName, column] of Object.entries(table.columns)) { columns[columnName] = columnMapper(columnName, column); } - const table = sqliteTable(name, columns, (ormTable) => { + const drizzleTable = sqliteTable(name, columns, (ormTable) => { const indexes: Record = {}; - for (const [indexName, indexProps] of Object.entries(collection.indexes ?? {})) { + for (const [indexName, indexProps] of Object.entries(table.indexes ?? {})) { const onColNames = Array.isArray(indexProps.on) ? indexProps.on : [indexProps.on]; const onCols = onColNames.map((colName) => ormTable[colName]); if (!atLeastOne(onCols)) continue; @@ -73,7 +74,7 @@ export function collectionToTable(name: string, collection: DBTable) { } return indexes; }); - return table; + return drizzleTable; } function atLeastOne(arr: T[]): arr is [T, ...T[]] { diff --git a/packages/db/src/runtime/queries.ts b/packages/db/src/runtime/queries.ts new file mode 100644 index 000000000000..4341fd1237ab --- /dev/null +++ b/packages/db/src/runtime/queries.ts @@ -0,0 +1,244 @@ +import { LibsqlError } from '@libsql/client'; +import { type SQL, sql } from 'drizzle-orm'; +import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; +import { bold } from 'kleur/colors'; +import { + FOREIGN_KEY_DNE_ERROR, + FOREIGN_KEY_REFERENCES_EMPTY_ERROR, + FOREIGN_KEY_REFERENCES_LENGTH_ERROR, + REFERENCE_DNE_ERROR, + SEED_ERROR, +} from '../core/errors.js'; +import type { + BooleanColumn, + ColumnType, + DBColumn, + DBTable, + DBTables, + DateColumn, + JsonColumn, + NumberColumn, + TextColumn, +} from '../core/types.js'; +import { type SqliteDB, hasPrimaryKey } from './index.js'; +import { isSerializedSQL } from './types.js'; + +const sqlite = new SQLiteAsyncDialect(); + +export const SEED_DEV_FILE_NAME = ['seed.ts', 'seed.js', 'seed.mjs', 'seed.mts']; + +export async function seedLocal({ + db, + tables, + // Glob all potential seed files to catch renames and deletions. + fileGlob, +}: { + db: SqliteDB; + tables: DBTables; + fileGlob: Record Promise>; +}) { + await recreateTables({ db, tables }); + for (const fileName of SEED_DEV_FILE_NAME) { + const key = Object.keys(fileGlob).find((f) => f.endsWith(fileName)); + if (key) { + await fileGlob[key]().catch((e) => { + if (e instanceof LibsqlError) { + throw new Error(SEED_ERROR(e.message)); + } + throw e; + }); + return; + } + } +} + +export 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)), + ]); +} + +export function getCreateTableQuery(tableName: string, table: DBTable) { + let query = `CREATE TABLE ${sqlite.escapeName(tableName)} (`; + + const colQueries = []; + const colHasPrimaryKey = Object.entries(table.columns).find(([, column]) => + hasPrimaryKey(column) + ); + if (!colHasPrimaryKey) { + colQueries.push('_id INTEGER PRIMARY KEY'); + } + for (const [columnName, column] of Object.entries(table.columns)) { + const colQuery = `${sqlite.escapeName(columnName)} ${schemaTypeToSqlType( + column.type + )}${getModifiers(columnName, column)}`; + colQueries.push(colQuery); + } + + colQueries.push(...getCreateForeignKeyQueries(tableName, table)); + + query += colQueries.join(', ') + ')'; + return query; +} + +export function getCreateIndexQueries(tableName: string, table: Pick) { + let queries: string[] = []; + for (const [indexName, indexProps] of Object.entries(table.indexes ?? {})) { + const onColNames = asArray(indexProps.on); + const onCols = onColNames.map((colName) => sqlite.escapeName(colName)); + + const unique = indexProps.unique ? 'UNIQUE ' : ''; + const indexQuery = `CREATE ${unique}INDEX ${sqlite.escapeName( + indexName + )} ON ${sqlite.escapeName(tableName)} (${onCols.join(', ')})`; + queries.push(indexQuery); + } + return queries; +} + +export function getCreateForeignKeyQueries(tableName: string, table: DBTable) { + let queries: string[] = []; + for (const foreignKey of table.foreignKeys ?? []) { + const columns = asArray(foreignKey.columns); + const references = asArray(foreignKey.references); + + if (columns.length !== references.length) { + throw new Error(FOREIGN_KEY_REFERENCES_LENGTH_ERROR(tableName)); + } + const firstReference = references[0]; + if (!firstReference) { + throw new Error(FOREIGN_KEY_REFERENCES_EMPTY_ERROR(tableName)); + } + const referencedTable = firstReference.schema.collection; + if (!referencedTable) { + throw new Error(FOREIGN_KEY_DNE_ERROR(tableName)); + } + const query = `FOREIGN KEY (${columns + .map((f) => sqlite.escapeName(f)) + .join(', ')}) REFERENCES ${sqlite.escapeName(referencedTable)}(${references + .map((r) => sqlite.escapeName(r.schema.name!)) + .join(', ')})`; + queries.push(query); + } + return queries; +} + +function asArray(value: T | T[]) { + return Array.isArray(value) ? value : [value]; +} + +export function schemaTypeToSqlType(type: ColumnType): 'text' | 'integer' { + switch (type) { + case 'date': + case 'text': + case 'json': + return 'text'; + case 'number': + case 'boolean': + return 'integer'; + } +} + +export function getModifiers(columnName: string, column: DBColumn) { + let modifiers = ''; + if (hasPrimaryKey(column)) { + return ' PRIMARY KEY'; + } + if (!column.schema.optional) { + modifiers += ' NOT NULL'; + } + if (column.schema.unique) { + modifiers += ' UNIQUE'; + } + if (hasDefault(column)) { + modifiers += ` DEFAULT ${getDefaultValueSql(columnName, column)}`; + } + const references = getReferencesConfig(column); + if (references) { + const { collection: tableName, name } = references.schema; + if (!tableName || !name) { + throw new Error(REFERENCE_DNE_ERROR(columnName)); + } + + modifiers += ` REFERENCES ${sqlite.escapeName(tableName)} (${sqlite.escapeName(name)})`; + } + return modifiers; +} + +export function getReferencesConfig(column: DBColumn) { + const canHaveReferences = column.type === 'number' || column.type === 'text'; + if (!canHaveReferences) return undefined; + return column.schema.references; +} + +// Using `DBColumn` will not narrow `default` based on the column `type` +// Handle each column separately +type WithDefaultDefined = T & { + schema: Required>; +}; +type DBColumnWithDefault = + | WithDefaultDefined + | WithDefaultDefined + | WithDefaultDefined + | WithDefaultDefined + | WithDefaultDefined; + +// Type narrowing the default fails on union types, so use a type guard +export function hasDefault(column: DBColumn): column is DBColumnWithDefault { + if (column.schema.default !== undefined) { + return true; + } + if (hasPrimaryKey(column) && column.type === 'number') { + return true; + } + return false; +} + +function toDefault(def: T | SQL): string { + const type = typeof def; + if (type === 'string') { + return sqlite.escapeString(def as string); + } else if (type === 'boolean') { + return def ? 'TRUE' : 'FALSE'; + } else { + return def + ''; + } +} + +function getDefaultValueSql(columnName: string, column: DBColumnWithDefault): string { + if (isSerializedSQL(column.schema.default)) { + return column.schema.default.sql; + } + + switch (column.type) { + case 'boolean': + case 'number': + case 'text': + case 'date': + return toDefault(column.schema.default); + case 'json': { + let stringified = ''; + try { + stringified = JSON.stringify(column.schema.default); + } catch (e) { + // eslint-disable-next-line no-console + console.log( + `Invalid default value for column ${bold( + columnName + )}. Defaults must be valid JSON when using the \`json()\` type.` + ); + process.exit(0); + } + + return sqlite.escapeString(stringified); + } + } +} diff --git a/packages/db/src/runtime/types.ts b/packages/db/src/runtime/types.ts index 9bc35bda61ac..08ab16a0cc71 100644 --- a/packages/db/src/runtime/types.ts +++ b/packages/db/src/runtime/types.ts @@ -1,6 +1,6 @@ import type { ColumnBaseConfig, ColumnDataType } from 'drizzle-orm'; import type { SQLiteColumn, SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core'; -import type { ColumnsConfig, DBColumn } from '../core/types.js'; +import type { ColumnsConfig, DBColumn, OutputColumnsConfig } from '../core/types.js'; type GeneratedConfig = Pick< ColumnBaseConfig, @@ -76,7 +76,7 @@ export type Column = T ex export type Table< TTableName extends string, - TColumns extends ColumnsConfig, + TColumns extends OutputColumnsConfig | ColumnsConfig, > = SQLiteTableWithColumns<{ name: TTableName; schema: undefined; diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts new file mode 100644 index 000000000000..0b4c31832f06 --- /dev/null +++ b/packages/db/src/utils.ts @@ -0,0 +1 @@ +export { asDrizzleTable } from './runtime/index.js'; diff --git a/packages/db/test/basics.test.js b/packages/db/test/basics.test.js index 8a8d9caea2e6..19c105532b12 100644 --- a/packages/db/test/basics.test.js +++ b/packages/db/test/basics.test.js @@ -3,10 +3,6 @@ import { load as cheerioLoad } from 'cheerio'; import testAdapter from '../../astro/test/test-adapter.js'; import { loadFixture } from '../../astro/test/test-utils.js'; -// TODO(fks): Rename this to something more generic/generally useful -// like `ASTRO_MONOREPO_TEST_ENV` if @astrojs/db is merged into astro. -process.env.ASTRO_DB_TEST_ENV = '1'; - describe('astro:db', () => { let fixture; before(async () => { @@ -17,16 +13,25 @@ describe('astro:db', () => { }); }); - describe('production', () => { + // Note(bholmesdev): Use in-memory db to avoid + // Multiple dev servers trying to unlink and remount + // the same database file. + process.env.TEST_IN_MEMORY_DB = 'true'; + describe('development', () => { + let devServer; + before(async () => { - await fixture.build(); + console.log('starting dev server'); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + process.env.TEST_IN_MEMORY_DB = undefined; }); it('Prints the list of authors', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const res = await app.render(request); - const html = await res.text(); + const html = await fixture.fetch('/').then((res) => res.text()); const $ = cheerioLoad(html); const ul = $('.authors-list'); @@ -34,71 +39,36 @@ describe('astro:db', () => { expect(ul.children().eq(0).text()).to.equal('Ben'); }); - it('Errors when inserting to a readonly collection', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/insert-into-readonly'); - const res = await app.render(request); - const html = await res.text(); + it('Allows expression defaults for date columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); const $ = cheerioLoad(html); - expect($('#error').text()).to.equal('The [Author] collection is read-only.'); + const themeAdded = $($('.themes-list .theme-added')[0]).text(); + expect(new Date(themeAdded).getTime()).to.not.be.NaN; }); - it('Does not error when inserting into writable collection', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/insert-into-writable'); - const res = await app.render(request); - const html = await res.text(); + it('Defaults can be overridden for dates', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); const $ = cheerioLoad(html); - expect($('#error').text()).to.equal(''); + const themeAdded = $($('.themes-list .theme-added')[1]).text(); + expect(new Date(themeAdded).getTime()).to.not.be.NaN; }); - describe('Expression defaults', () => { - let app; - before(async () => { - app = await fixture.loadTestAdapterApp(); - }); - - it('Allows expression defaults for date columns', async () => { - const request = new Request('http://example.com/'); - const res = await app.render(request); - const html = await res.text(); - const $ = cheerioLoad(html); - - const themeAdded = $($('.themes-list .theme-added')[0]).text(); - expect(new Date(themeAdded).getTime()).to.not.be.NaN; - }); - - it('Defaults can be overridden for dates', async () => { - const request = new Request('http://example.com/'); - const res = await app.render(request); - const html = await res.text(); - const $ = cheerioLoad(html); - - const themeAdded = $($('.themes-list .theme-added')[1]).text(); - expect(new Date(themeAdded).getTime()).to.not.be.NaN; - }); - - it('Allows expression defaults for text columns', async () => { - const request = new Request('http://example.com/'); - const res = await app.render(request); - const html = await res.text(); - const $ = cheerioLoad(html); + 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(); - expect(themeOwner).to.equal(''); - }); + const themeOwner = $($('.themes-list .theme-owner')[0]).text(); + expect(themeOwner).to.equal(''); + }); - it('Allows expression defaults for boolean columns', async () => { - const request = new Request('http://example.com/'); - const res = await app.render(request); - const html = await res.text(); - const $ = cheerioLoad(html); + 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(); - expect(themeDark).to.equal('dark mode'); - }); + const themeDark = $($('.themes-list .theme-dark')[0]).text(); + expect(themeDark).to.equal('dark mode'); }); }); }); diff --git a/packages/db/test/fixtures/basics/astro.config.mjs b/packages/db/test/fixtures/basics/astro.config.mjs new file mode 100644 index 000000000000..5ff1200e2418 --- /dev/null +++ b/packages/db/test/fixtures/basics/astro.config.mjs @@ -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/basics/astro.config.ts b/packages/db/test/fixtures/basics/astro.config.ts deleted file mode 100644 index 665568a82129..000000000000 --- a/packages/db/test/fixtures/basics/astro.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import db, { defineReadableTable, column } from '@astrojs/db'; -import { defineConfig } from 'astro/config'; -import { themes } from './themes-integration'; - -const Author = defineReadableTable({ - columns: { - name: column.text(), - }, -}); - -// https://astro.build/config -export default defineConfig({ - integrations: [db(), themes()], - db: { - studio: false, - unsafeWritable: true, - tables: { Author }, - async data({ seed }) { - await seed(Author, [ - { name: 'Ben' }, - { name: 'Nate' }, - { name: 'Erika' }, - { name: 'Bjorn' }, - { name: 'Sarah' }, - ]); - }, - }, -}); 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 000000000000..f216caab6e45 --- /dev/null +++ b/packages/db/test/fixtures/basics/db/config.ts @@ -0,0 +1,12 @@ +import { Themes } from './theme'; +import { column, defineDB, defineTable } from 'astro:db'; + +const Author = defineTable({ + columns: { + name: column.text(), + }, +}); + +export default defineDB({ + tables: { Author, Themes }, +}); 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 000000000000..33d82d523782 --- /dev/null +++ b/packages/db/test/fixtures/basics/db/seed.ts @@ -0,0 +1,19 @@ +import { asDrizzleTable } from '@astrojs/db/utils'; +import { Themes as ThemesConfig } from './theme'; +import { Author, db } from 'astro:db'; + +const Themes = asDrizzleTable('Themes', ThemesConfig); + +await db + .insert(Themes) + .values([{ name: 'dracula' }, { name: 'monokai', added: new Date() }]) + .returning({ name: Themes.name }); +await db + .insert(Author) + .values([ + { name: 'Ben' }, + { name: 'Nate' }, + { name: 'Erika' }, + { name: 'Bjorn' }, + { name: 'Sarah' }, + ]); 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 000000000000..aecc67f7d5ae --- /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` }), + 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 index f537f998df79..af7cbe229800 100644 --- a/packages/db/test/fixtures/basics/package.json +++ b/packages/db/test/fixtures/basics/package.json @@ -2,6 +2,11 @@ "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 index 4e8882d57e0a..2d21f81103b8 100644 --- a/packages/db/test/fixtures/basics/src/pages/index.astro +++ b/packages/db/test/fixtures/basics/src/pages/index.astro @@ -1,4 +1,5 @@ --- +/// import { Author, db, Themes } from 'astro:db'; const authors = await db.select().from(Author); diff --git a/packages/db/test/fixtures/basics/src/pages/insert-into-readonly.astro b/packages/db/test/fixtures/basics/src/pages/insert-into-readonly.astro deleted file mode 100644 index 7a6bb37bc3db..000000000000 --- a/packages/db/test/fixtures/basics/src/pages/insert-into-readonly.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import { Author, db } from 'astro:db'; - -const authors = await db.select().from(Author); - -let error: any = {}; -try { - db.insert(Author).values({ name: 'Person A' }); -} catch (err) { - error = err; -} ---- - -
{error.message}
diff --git a/packages/db/test/fixtures/basics/src/pages/insert-into-writable.astro b/packages/db/test/fixtures/basics/src/pages/insert-into-writable.astro deleted file mode 100644 index 3e3ad2f31ac5..000000000000 --- a/packages/db/test/fixtures/basics/src/pages/insert-into-writable.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -import { Themes, db } from 'astro:db'; - -let error: any = {}; -try { - db.insert(Themes).values({ name: 'Person A' }); -} catch (err) { - error = err; -} ---- - -
{error.message}
diff --git a/packages/db/test/fixtures/basics/themes-integration.ts b/packages/db/test/fixtures/basics/themes-integration.ts deleted file mode 100644 index d034d840f85b..000000000000 --- a/packages/db/test/fixtures/basics/themes-integration.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NOW, column, defineWritableTable, sql } from '@astrojs/db'; -import type { AstroIntegration } from 'astro'; - -const Themes = defineWritableTable({ - columns: { - name: column.text(), - added: column.date({ - default: sql`CURRENT_TIMESTAMP`, - }), - updated: column.date({ - default: NOW, - }), - isDark: column.boolean({ default: sql`TRUE` }), - owner: column.text({ optional: true, default: sql`NULL` }), - }, -}); - -export function themes(): AstroIntegration { - return { - name: 'themes-integration', - hooks: { - 'astro:config:setup': ({ updateConfig }) => { - updateConfig({ - db: { - tables: { Themes }, - async data({ seed }) { - // Seed writable tables in dev mode, only - // but in this case we do it for both, due to tests - await seed(Themes, [{ name: 'dracula' }, { name: 'monokai', added: new Date() }]); - }, - }, - }); - }, - }, - }; -} diff --git a/packages/db/test/fixtures/glob/astro.config.ts b/packages/db/test/fixtures/glob/astro.config.ts deleted file mode 100644 index d84117f4e6e3..000000000000 --- a/packages/db/test/fixtures/glob/astro.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import db, { defineReadableTable, column } from '@astrojs/db'; -import { defineConfig } from 'astro/config'; -import { asJson, createGlob } from './utils'; - -const Quote = defineReadableTable({ - columns: { - author: column.text(), - body: column.text(), - file: column.text({ unique: true }), - }, -}); - -export default defineConfig({ - db: { - tables: { Quote }, - data({ seed, ...ctx }) { - const glob = createGlob(ctx); - glob('quotes/*.json', { - into: Quote, - parse: asJson, - }); - }, - }, - integrations: [db()], -}); diff --git a/packages/db/test/fixtures/glob/package.json b/packages/db/test/fixtures/glob/package.json deleted file mode 100644 index d7db0815c927..000000000000 --- a/packages/db/test/fixtures/glob/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "glob", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "dev": "astro dev", - "build": "astro build", - "preview": "astro preview" - }, - "dependencies": { - "@astrojs/db": "workspace:*", - "astro": "workspace:*", - "chokidar": "^3.5.3", - "drizzle-orm": "^0.28.6", - "fast-glob": "^3.3.2" - }, - "keywords": [], - "author": "", - "license": "ISC" -} diff --git a/packages/db/test/fixtures/glob/quotes/erika.json b/packages/db/test/fixtures/glob/quotes/erika.json deleted file mode 100644 index c7ece9ca6ee3..000000000000 --- a/packages/db/test/fixtures/glob/quotes/erika.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "author": "Erika", - "body": "Put the quote in the database." -} diff --git a/packages/db/test/fixtures/glob/quotes/tony.json b/packages/db/test/fixtures/glob/quotes/tony.json deleted file mode 100644 index 3aaed7746b83..000000000000 --- a/packages/db/test/fixtures/glob/quotes/tony.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "author": "Tony Sull", - "body": "All content is data, but not all data is content." -} diff --git a/packages/db/test/fixtures/glob/src/pages/index.astro b/packages/db/test/fixtures/glob/src/pages/index.astro deleted file mode 100644 index 6d2fdf22c0eb..000000000000 --- a/packages/db/test/fixtures/glob/src/pages/index.astro +++ /dev/null @@ -1,25 +0,0 @@ ---- -/// -import { Quote, db } from 'astro:db'; - -const quotes = await db.select().from(Quote); ---- - - - - - - - Document - - - { - quotes.map((q) => ( -
-
{q.body}
-
{q.author}
-
- )) - } - - diff --git a/packages/db/test/fixtures/glob/utils.ts b/packages/db/test/fixtures/glob/utils.ts deleted file mode 100644 index 5602c610807c..000000000000 --- a/packages/db/test/fixtures/glob/utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type DBDataContext, type ResolvedCollectionConfig } from '@astrojs/db'; -import chokidar from 'chokidar'; -import { eq } from 'drizzle-orm'; -import fastGlob from 'fast-glob'; -import { readFile } from 'fs/promises'; - -export function createGlob({ db, mode }: Pick) { - return async function glob( - pattern: string, - opts: { - into: ResolvedCollectionConfig; - parse: (params: { file: string; content: string }) => Record; - } - ) { - // TODO: expose `table` - const { table } = opts.into as any; - const fileColumn = table.file; - if (!fileColumn) { - throw new Error('`file` column is required for glob tables.'); - } - if (mode === 'dev') { - chokidar - .watch(pattern) - .on('add', async (file) => { - const content = await readFile(file, 'utf-8'); - const parsed = opts.parse({ file, content }); - await db.insert(table).values({ ...parsed, file }); - }) - .on('change', async (file) => { - const content = await readFile(file, 'utf-8'); - const parsed = opts.parse({ file, content }); - await db - .insert(table) - .values({ ...parsed, file }) - .onConflictDoUpdate({ - target: fileColumn, - set: parsed, - }); - }) - .on('unlink', async (file) => { - await db.delete(table).where(eq(fileColumn, file)); - }); - } else { - const files = await fastGlob(pattern); - for (const file of files) { - const content = await readFile(file, 'utf-8'); - const parsed = opts.parse({ file, content }); - await db.insert(table).values({ ...parsed, file }); - } - } - }; -} - -export function asJson(params: { file: string; content: string }) { - try { - return JSON.parse(params.content); - } catch (e) { - throw new Error(`Error parsing ${params.file}: ${e.message}`); - } -} diff --git a/packages/db/test/fixtures/recipes/astro.config.ts b/packages/db/test/fixtures/recipes/astro.config.ts index bb7ad225e4cd..bd6088769ffa 100644 --- a/packages/db/test/fixtures/recipes/astro.config.ts +++ b/packages/db/test/fixtures/recipes/astro.config.ts @@ -1,82 +1,6 @@ -import astroDb, { defineReadableTable, column } from '@astrojs/db'; +import astroDb from '@astrojs/db'; import { defineConfig } from 'astro/config'; -const Recipe = defineReadableTable({ - columns: { - id: column.number({ primaryKey: true }), - title: column.text(), - description: column.text(), - }, -}); - -const Ingredient = defineReadableTable({ - 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 defineConfig({ integrations: [astroDb()], - db: { - tables: { Recipe, Ingredient }, - async data({ seed, seedReturning }) { - const pancakes = await seedReturning(Recipe, { - title: 'Pancakes', - description: 'A delicious breakfast', - }); - - await seed(Ingredient, [ - { - name: 'Flour', - quantity: 1, - recipeId: pancakes.id, - }, - { - name: 'Eggs', - quantity: 2, - recipeId: pancakes.id, - }, - { - name: 'Milk', - quantity: 1, - recipeId: pancakes.id, - }, - ]); - - const pizza = await seedReturning(Recipe, { - title: 'Pizza', - description: 'A delicious dinner', - }); - - await seed(Ingredient, [ - { - 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/db/config.ts b/packages/db/test/fixtures/recipes/db/config.ts new file mode 100644 index 000000000000..6334ba8edcfa --- /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 000000000000..7a4892376fde --- /dev/null +++ b/packages/db/test/fixtures/recipes/db/seed.ts @@ -0,0 +1,60 @@ +import { Ingredient, Recipe, db } from 'astro:db'; + +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/ticketing-example/astro.config.ts b/packages/db/test/fixtures/ticketing-example/astro.config.ts index 1f34f6f0d75a..616156f9af9c 100644 --- a/packages/db/test/fixtures/ticketing-example/astro.config.ts +++ b/packages/db/test/fixtures/ticketing-example/astro.config.ts @@ -1,32 +1,9 @@ -import db, { defineReadableTable, defineWritableTable, column } from '@astrojs/db'; +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'; -const Event = defineReadableTable({ - columns: { - id: column.number({ - primaryKey: true, - }), - name: column.text(), - description: column.text(), - ticketPrice: column.number(), - date: column.date(), - location: column.text(), - }, -}); -const Ticket = defineWritableTable({ - columns: { - eventId: column.number({ references: () => Event.columns.id }), - email: column.text(), - quantity: column.number(), - newsletter: column.boolean({ - default: false, - }), - }, -}); - // https://astro.build/config export default defineConfig({ integrations: [simpleStackForm(), db(), react()], @@ -34,23 +11,4 @@ export default defineConfig({ adapter: node({ mode: 'standalone', }), - db: { - studio: true, - tables: { - Event, - Ticket, - }, - data({ seed }) { - seed(Event, [ - { - 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/db/config.ts b/packages/db/test/fixtures/ticketing-example/db/config.ts new file mode 100644 index 000000000000..f8148eaed305 --- /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 000000000000..c9789cbd666d --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/db/seed.ts @@ -0,0 +1,10 @@ +import { Event, db } from 'astro:db'; + +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/unit/field-queries.test.js b/packages/db/test/unit/column-queries.test.js similarity index 89% rename from packages/db/test/unit/field-queries.test.js rename to packages/db/test/unit/column-queries.test.js index dc7945f8c8be..c4d60d5c6f37 100644 --- a/packages/db/test/unit/field-queries.test.js +++ b/packages/db/test/unit/column-queries.test.js @@ -4,16 +4,17 @@ import { getCollectionChangeQueries, getMigrationQueries, } from '../../dist/core/cli/migration-queries.js'; -import { getCreateTableQuery } from '../../dist/core/queries.js'; -import { collectionSchema, column, defineReadableTable } from '../../dist/core/types.js'; +import { tableSchema } from '../../dist/core/types.js'; +import { column, defineTable } from '../../dist/runtime/config.js'; import { NOW } from '../../dist/runtime/index.js'; +import { getCreateTableQuery } from '../../dist/runtime/queries.js'; -const COLLECTION_NAME = 'Users'; +const TABLE_NAME = 'Users'; // `parse` to resolve schema transformations // ex. convert column.date() to ISO strings -const userInitial = collectionSchema.parse( - defineReadableTable({ +const userInitial = tableSchema.parse( + defineTable({ columns: { name: column.text(), age: column.number(), @@ -28,15 +29,11 @@ const defaultAmbiguityResponses = { columnRenames: {}, }; -function userChangeQueries( - oldCollection, - newCollection, - ambiguityResponses = defaultAmbiguityResponses -) { +function userChangeQueries(oldTable, newTable, ambiguityResponses = defaultAmbiguityResponses) { return getCollectionChangeQueries({ - collectionName: COLLECTION_NAME, - oldCollection, - newCollection, + collectionName: TABLE_NAME, + oldCollection: oldTable, + newCollection: newTable, ambiguityResponses, }); } @@ -56,35 +53,35 @@ function configChangeQueries( describe('column queries', () => { describe('getMigrationQueries', () => { it('should be empty when tables are the same', async () => { - const oldCollections = { [COLLECTION_NAME]: userInitial }; - const newCollections = { [COLLECTION_NAME]: userInitial }; + const oldCollections = { [TABLE_NAME]: userInitial }; + const newCollections = { [TABLE_NAME]: userInitial }; const { queries } = await configChangeQueries(oldCollections, newCollections); expect(queries).to.deep.equal([]); }); it('should create table for new tables', async () => { const oldCollections = {}; - const newCollections = { [COLLECTION_NAME]: userInitial }; + const newCollections = { [TABLE_NAME]: userInitial }; const { queries } = await configChangeQueries(oldCollections, newCollections); - expect(queries).to.deep.equal([getCreateTableQuery(COLLECTION_NAME, userInitial)]); + expect(queries).to.deep.equal([getCreateTableQuery(TABLE_NAME, userInitial)]); }); it('should drop table for removed tables', async () => { - const oldCollections = { [COLLECTION_NAME]: userInitial }; + const oldCollections = { [TABLE_NAME]: userInitial }; const newCollections = {}; const { queries } = await configChangeQueries(oldCollections, newCollections); - expect(queries).to.deep.equal([`DROP TABLE "${COLLECTION_NAME}"`]); + expect(queries).to.deep.equal([`DROP TABLE "${TABLE_NAME}"`]); }); it('should rename table for renamed tables', async () => { const rename = 'Peeps'; - const oldCollections = { [COLLECTION_NAME]: userInitial }; + const oldCollections = { [TABLE_NAME]: userInitial }; const newCollections = { [rename]: userInitial }; const { queries } = await configChangeQueries(oldCollections, newCollections, { ...defaultAmbiguityResponses, - collectionRenames: { [rename]: COLLECTION_NAME }, + collectionRenames: { [rename]: TABLE_NAME }, }); - expect(queries).to.deep.equal([`ALTER TABLE "${COLLECTION_NAME}" RENAME TO "${rename}"`]); + expect(queries).to.deep.equal([`ALTER TABLE "${TABLE_NAME}" RENAME TO "${rename}"`]); }); }); @@ -95,14 +92,14 @@ describe('column queries', () => { }); it('should be empty when type updated to same underlying SQL type', async () => { - const blogInitial = collectionSchema.parse({ + const blogInitial = tableSchema.parse({ ...userInitial, columns: { title: column.text(), draft: column.boolean(), }, }); - const blogFinal = collectionSchema.parse({ + const blogFinal = tableSchema.parse({ ...userInitial, columns: { ...blogInitial.columns, @@ -114,7 +111,7 @@ describe('column queries', () => { }); it('should respect user primary key without adding a hidden id', async () => { - const user = collectionSchema.parse({ + const user = tableSchema.parse({ ...userInitial, columns: { ...userInitial.columns, @@ -122,7 +119,7 @@ describe('column queries', () => { }, }); - const userFinal = collectionSchema.parse({ + const userFinal = tableSchema.parse({ ...user, columns: { ...user.columns, @@ -155,10 +152,10 @@ describe('column queries', () => { const { queries } = await userChangeQueries(userInitial, userFinal, { collectionRenames: {}, - columnRenames: { [COLLECTION_NAME]: { middleInitial: 'mi' } }, + columnRenames: { [TABLE_NAME]: { middleInitial: 'mi' } }, }); expect(queries).to.deep.equal([ - `ALTER TABLE "${COLLECTION_NAME}" RENAME COLUMN "mi" TO "middleInitial"`, + `ALTER TABLE "${TABLE_NAME}" RENAME COLUMN "mi" TO "middleInitial"`, ]); }); }); @@ -287,7 +284,7 @@ describe('column queries', () => { }); it('when updating to a runtime default', async () => { - const initial = collectionSchema.parse({ + const initial = tableSchema.parse({ ...userInitial, columns: { ...userInitial.columns, @@ -295,7 +292,7 @@ describe('column queries', () => { }, }); - const userFinal = collectionSchema.parse({ + const userFinal = tableSchema.parse({ ...initial, columns: { ...initial.columns, @@ -317,7 +314,7 @@ describe('column queries', () => { }); it('when adding a column with a runtime default', async () => { - const userFinal = collectionSchema.parse({ + const userFinal = tableSchema.parse({ ...userInitial, columns: { ...userInitial.columns, @@ -407,7 +404,7 @@ describe('column queries', () => { it('when adding a required column with default', async () => { const defaultDate = new Date('2023-01-01'); - const userFinal = collectionSchema.parse({ + const userFinal = tableSchema.parse({ ...userInitial, columns: { ...userInitial.columns, diff --git a/packages/db/test/unit/index-queries.test.js b/packages/db/test/unit/index-queries.test.js index 17b2599bdeb2..ad588959d173 100644 --- a/packages/db/test/unit/index-queries.test.js +++ b/packages/db/test/unit/index-queries.test.js @@ -1,9 +1,10 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; import { getCollectionChangeQueries } from '../../dist/core/cli/migration-queries.js'; -import { collectionSchema, column } from '../../dist/core/types.js'; +import { tableSchema } from '../../dist/core/types.js'; +import { column } from '../../dist/runtime/config.js'; -const userInitial = collectionSchema.parse({ +const userInitial = tableSchema.parse({ columns: { name: column.text(), age: column.number(), diff --git a/packages/db/test/unit/reference-queries.test.js b/packages/db/test/unit/reference-queries.test.js index 4c2e3af8d6bb..a4b0bdd2d923 100644 --- a/packages/db/test/unit/reference-queries.test.js +++ b/packages/db/test/unit/reference-queries.test.js @@ -1,9 +1,10 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; import { getCollectionChangeQueries } from '../../dist/core/cli/migration-queries.js'; -import { column, defineReadableTable, tablesSchema } from '../../dist/core/types.js'; +import { tablesSchema } from '../../dist/core/types.js'; +import { column, defineTable } from '../../dist/runtime/config.js'; -const BaseUser = defineReadableTable({ +const BaseUser = defineTable({ columns: { id: column.number({ primaryKey: true }), name: column.text(), @@ -13,7 +14,7 @@ const BaseUser = defineReadableTable({ }, }); -const BaseSentBox = defineReadableTable({ +const BaseSentBox = defineTable({ columns: { to: column.number(), toName: column.text(), @@ -58,7 +59,7 @@ describe('reference queries', () => { it('adds references with lossless table recreate', async () => { const { SentBox: Initial } = resolveReferences(); const { SentBox: Final } = resolveReferences({ - SentBox: defineReadableTable({ + SentBox: defineTable({ columns: { ...BaseSentBox.columns, to: column.number({ references: () => BaseUser.columns.id }), @@ -82,7 +83,7 @@ describe('reference queries', () => { it('removes references with lossless table recreate', async () => { const { SentBox: Initial } = resolveReferences({ - SentBox: defineReadableTable({ + SentBox: defineTable({ columns: { ...BaseSentBox.columns, to: column.number({ references: () => BaseUser.columns.id }), @@ -108,7 +109,7 @@ describe('reference queries', () => { it('does not use ADD COLUMN when adding optional column with reference', async () => { const { SentBox: Initial } = resolveReferences(); const { SentBox: Final } = resolveReferences({ - SentBox: defineReadableTable({ + SentBox: defineTable({ columns: { ...BaseSentBox.columns, from: column.number({ references: () => BaseUser.columns.id, optional: true }), @@ -131,13 +132,13 @@ describe('reference queries', () => { it('adds and updates foreign key with lossless table recreate', async () => { const { SentBox: InitialWithoutFK } = resolveReferences(); const { SentBox: InitialWithDifferentFK } = resolveReferences({ - SentBox: defineReadableTable({ + SentBox: defineTable({ ...BaseSentBox, foreignKeys: [{ columns: ['to'], references: () => [BaseUser.columns.id] }], }), }); const { SentBox: Final } = resolveReferences({ - SentBox: defineReadableTable({ + SentBox: defineTable({ ...BaseSentBox, foreignKeys: [ { diff --git a/packages/integrations/markdoc/CHANGELOG.md b/packages/integrations/markdoc/CHANGELOG.md index 3acfb43cece9..fcfb941e8976 100644 --- a/packages/integrations/markdoc/CHANGELOG.md +++ b/packages/integrations/markdoc/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/markdoc +## 0.9.1 + +### Patch Changes + +- [#10278](https://github.com/withastro/astro/pull/10278) [`a548a3a99c2835c19662fc38636f92b2bda26614`](https://github.com/withastro/astro/commit/a548a3a99c2835c19662fc38636f92b2bda26614) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes original images sometimes being kept / deleted when they shouldn't in both MDX and Markdoc + ## 0.9.0 ### Minor Changes diff --git a/packages/integrations/markdoc/package.json b/packages/integrations/markdoc/package.json index 9d82609720e4..0d0917b58631 100644 --- a/packages/integrations/markdoc/package.json +++ b/packages/integrations/markdoc/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/markdoc", "description": "Add support for Markdoc in your Astro site", - "version": "0.9.0", + "version": "0.9.1", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/integrations/markdoc/src/content-entry-type.ts b/packages/integrations/markdoc/src/content-entry-type.ts index fe15d03b01ea..89f9f9e8687b 100644 --- a/packages/integrations/markdoc/src/content-entry-type.ts +++ b/packages/integrations/markdoc/src/content-entry-type.ts @@ -196,7 +196,19 @@ async function emitOptimizedImages( ctx.pluginContext.meta.watchMode, ctx.pluginContext.emitFile ); - node.attributes[attributeName] = src; + + const fsPath = resolved.id; + + if (src) { + // We cannot track images in Markdoc, Markdoc rendering always strips out the proxy. As such, we'll always + // assume that the image is referenced elsewhere, to be on safer side. + if (ctx.astroConfig.output === 'static') { + if (globalThis.astroAsset.referencedImages) + globalThis.astroAsset.referencedImages.add(fsPath); + } + + node.attributes[attributeName] = { ...src, fsPath }; + } } else { throw new MarkdocError({ message: `Could not resolve image ${JSON.stringify( diff --git a/packages/integrations/node/CHANGELOG.md b/packages/integrations/node/CHANGELOG.md index d95cb355c814..5d4f27635f64 100644 --- a/packages/integrations/node/CHANGELOG.md +++ b/packages/integrations/node/CHANGELOG.md @@ -1,5 +1,17 @@ # @astrojs/node +## 8.2.3 + +### Patch Changes + +- [#10285](https://github.com/withastro/astro/pull/10285) [`d5277df5a4d1e9a8a7b6c8d7b87912e13a163f7f`](https://github.com/withastro/astro/commit/d5277df5a4d1e9a8a7b6c8d7b87912e13a163f7f) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes an issue where malformed requests could cause the server to error in certain cases. + +## 8.2.2 + +### Patch Changes + +- [#10282](https://github.com/withastro/astro/pull/10282) [`b47dcaa25968ec85ba96fce23381c94a94e389f6`](https://github.com/withastro/astro/commit/b47dcaa25968ec85ba96fce23381c94a94e389f6) Thanks [@SatanshuMishra](https://github.com/SatanshuMishra)! - Fixes the `server.host` option to properly listen on all network interfaces when set to `true` + ## 8.2.1 ### Patch Changes diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index fea5c39b1cb4..0f9d55cc7207 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/node", "description": "Deploy your site to a Node.js server", - "version": "8.2.1", + "version": "8.2.3", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/integrations/node/src/serve-app.ts b/packages/integrations/node/src/serve-app.ts index f2fc61f010d4..a9840b72148c 100644 --- a/packages/integrations/node/src/serve-app.ts +++ b/packages/integrations/node/src/serve-app.ts @@ -8,7 +8,15 @@ import type { RequestHandler } from './types.js'; */ export function createAppHandler(app: NodeApp): RequestHandler { return async (req, res, next, locals) => { - const request = NodeApp.createRequest(req); + let request; + try { + request = NodeApp.createRequest(req); + } catch (err) { + res.statusCode = 500; + res.end('Internal Server Error'); + return; + } + const routeData = app.match(request); if (routeData) { const response = await app.render(request, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0eb28d9453b..e9caf5b9b10c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,7 +134,7 @@ importers: examples/basics: dependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/blog: @@ -149,13 +149,13 @@ importers: specifier: ^3.1.1 version: link:../../packages/integrations/sitemap astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/component: devDependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/framework-alpine: @@ -170,7 +170,7 @@ importers: specifier: ^3.13.3 version: 3.13.3 astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/framework-lit: @@ -182,7 +182,7 @@ importers: specifier: ^0.2.1 version: 0.2.1 astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro lit: specifier: ^3.1.2 @@ -206,7 +206,7 @@ importers: specifier: ^4.0.8 version: link:../../packages/integrations/vue astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro preact: specifier: ^10.19.2 @@ -236,7 +236,7 @@ importers: specifier: ^1.2.1 version: 1.2.1(preact@10.19.3) astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro preact: specifier: ^10.19.2 @@ -254,7 +254,7 @@ importers: specifier: ^18.2.15 version: 18.2.18 astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro react: specifier: ^18.2.0 @@ -269,7 +269,7 @@ importers: specifier: ^4.0.1 version: link:../../packages/integrations/solid astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro solid-js: specifier: ^1.8.5 @@ -281,7 +281,7 @@ importers: specifier: ^5.2.0 version: link:../../packages/integrations/svelte astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro svelte: specifier: ^4.2.5 @@ -293,7 +293,7 @@ importers: specifier: ^4.0.8 version: link:../../packages/integrations/vue astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro vue: specifier: ^3.3.8 @@ -302,25 +302,25 @@ importers: examples/hackernews: dependencies: '@astrojs/node': - specifier: ^8.2.1 + specifier: ^8.2.3 version: link:../../packages/integrations/node astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/integration: devDependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/middleware: dependencies: '@astrojs/node': - specifier: ^8.2.1 + specifier: ^8.2.3 version: link:../../packages/integrations/node astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro html-minifier: specifier: ^4.0.0 @@ -333,31 +333,31 @@ importers: examples/minimal: dependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/non-html-pages: dependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/portfolio: dependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/ssr: dependencies: '@astrojs/node': - specifier: ^8.2.1 + specifier: ^8.2.3 version: link:../../packages/integrations/node '@astrojs/svelte': specifier: ^5.2.0 version: link:../../packages/integrations/svelte astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro svelte: specifier: ^4.2.5 @@ -366,7 +366,7 @@ importers: examples/starlog: dependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro sass: specifier: ^1.69.5 @@ -378,22 +378,22 @@ importers: examples/view-transitions: devDependencies: '@astrojs/node': - specifier: ^8.2.1 + specifier: ^8.2.3 version: link:../../packages/integrations/node '@astrojs/tailwind': specifier: ^5.1.0 version: link:../../packages/integrations/tailwind astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/with-markdoc: dependencies: '@astrojs/markdoc': - specifier: ^0.9.0 + specifier: ^0.9.1 version: link:../../packages/integrations/markdoc astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/with-markdown-plugins: @@ -402,7 +402,7 @@ importers: specifier: ^4.2.1 version: link:../../packages/markdown/remark astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro hast-util-select: specifier: ^6.0.2 @@ -423,7 +423,7 @@ importers: examples/with-markdown-shiki: dependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro examples/with-mdx: @@ -435,7 +435,7 @@ importers: specifier: ^3.1.1 version: link:../../packages/integrations/preact astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro preact: specifier: ^10.19.2 @@ -450,7 +450,7 @@ importers: specifier: ^0.5.0 version: 0.5.0(nanostores@0.9.5)(preact@10.19.3) astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro nanostores: specifier: ^0.9.5 @@ -471,7 +471,7 @@ importers: specifier: ^1.6.3 version: 1.6.4 astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro autoprefixer: specifier: ^10.4.15 @@ -489,7 +489,7 @@ importers: examples/with-vitest: dependencies: astro: - specifier: ^4.4.6 + specifier: ^4.4.9 version: link:../../packages/astro vitest: specifier: ^1.3.1 @@ -2516,6 +2516,12 @@ importers: packages/astro/test/fixtures/core-image-deletion: dependencies: + '@astrojs/markdoc': + specifier: workspace:* + version: link:../../../../integrations/markdoc + '@astrojs/mdx': + specifier: workspace:* + version: link:../../../../integrations/mdx astro: specifier: workspace:* version: link:../../.. @@ -3885,24 +3891,6 @@ importers: specifier: workspace:* version: link:../../../../astro - packages/db/test/fixtures/glob: - dependencies: - '@astrojs/db': - specifier: workspace:* - version: link:../../.. - astro: - specifier: workspace:* - version: link:../../../../astro - chokidar: - specifier: ^3.5.3 - version: 3.5.3 - drizzle-orm: - specifier: ^0.28.6 - version: 0.28.6(@libsql/client@0.4.3) - fast-glob: - specifier: ^3.3.2 - version: 3.3.2 - packages/db/test/fixtures/recipes: dependencies: '@astrojs/db':