diff options
author | 2025-06-05 14:25:23 +0000 | |
---|---|---|
committer | 2025-06-05 14:25:23 +0000 | |
commit | e586d7d704d475afe3373a1de6ae20d504f79d6d (patch) | |
tree | 7e3fa24807cebd48a86bd40f866d792181191ee9 /packages/db | |
download | astro-latest.tar.gz astro-latest.tar.zst astro-latest.zip |
Sync from a8e1c0a7402940e0fc5beef669522b315052df1blatest
Diffstat (limited to 'packages/db')
131 files changed, 8300 insertions, 0 deletions
diff --git a/packages/db/CHANGELOG.md b/packages/db/CHANGELOG.md new file mode 100644 index 000000000..ec8b1ece1 --- /dev/null +++ b/packages/db/CHANGELOG.md @@ -0,0 +1,774 @@ +# @astrojs/db + +## 0.15.0 + +### Minor Changes + +- [#13815](https://github.com/withastro/astro/pull/13815) [`03435f8`](https://github.com/withastro/astro/commit/03435f8269b91ce8973bc8ded8e8071481d39dda) Thanks [@jonaspm](https://github.com/jonaspm)! - Upgraded drizzle-orm to latest v0.42.0 + +## 0.14.14 + +### Patch Changes + +- [#13772](https://github.com/withastro/astro/pull/13772) [`83193d4`](https://github.com/withastro/astro/commit/83193d43cfb7fb28254f0ff3fb717a7bdd65977b) Thanks [@Adammatthiesen](https://github.com/Adammatthiesen)! - Fix options parsing for the libsql client connection to ensure that proper values are being set when adding URLSearchParams to the `ASTRO_DB_REMOTE_URL` + +- [#13783](https://github.com/withastro/astro/pull/13783) [`1609044`](https://github.com/withastro/astro/commit/1609044face6f58fff1dc82ceb14b3fd13b5ff67) Thanks [@Adammatthiesen](https://github.com/Adammatthiesen)! - Modify Database type to allow transactions to be properly typed now that Astro Studio has sunset. + +## 0.14.13 + +### Patch Changes + +- [#13731](https://github.com/withastro/astro/pull/13731) [`c3e80c2`](https://github.com/withastro/astro/commit/c3e80c25b90c803e2798b752583a8e77cdad3146) Thanks [@jsparkdev](https://github.com/jsparkdev)! - update vite to latest version for fixing CVE + +- Updated dependencies [[`c3e80c2`](https://github.com/withastro/astro/commit/c3e80c25b90c803e2798b752583a8e77cdad3146)]: + - @astrojs/studio@0.1.9 + +## 0.14.12 + +### Patch Changes + +- [#13591](https://github.com/withastro/astro/pull/13591) [`5dd2d3f`](https://github.com/withastro/astro/commit/5dd2d3fde8a138ed611dedf39ffa5dfeeed315f8) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Removes unused code + +- Updated dependencies [[`5dd2d3f`](https://github.com/withastro/astro/commit/5dd2d3fde8a138ed611dedf39ffa5dfeeed315f8)]: + - @astrojs/studio@0.1.8 + +## 0.14.11 + +### Patch Changes + +- [#13596](https://github.com/withastro/astro/pull/13596) [`3752519`](https://github.com/withastro/astro/commit/375251966d1b28a570bff45ff0fe7e7d2fe46f72) Thanks [@jsparkdev](https://github.com/jsparkdev)! - update vite to latest version to fix CVE + +- [#13547](https://github.com/withastro/astro/pull/13547) [`360cb91`](https://github.com/withastro/astro/commit/360cb9199a4314f90825c5639ff4396760e9cfcc) Thanks [@jsparkdev](https://github.com/jsparkdev)! - Updates vite to the latest version + +- Updated dependencies [[`3752519`](https://github.com/withastro/astro/commit/375251966d1b28a570bff45ff0fe7e7d2fe46f72), [`360cb91`](https://github.com/withastro/astro/commit/360cb9199a4314f90825c5639ff4396760e9cfcc)]: + - @astrojs/studio@0.1.7 + +## 0.14.10 + +### Patch Changes + +- [#13526](https://github.com/withastro/astro/pull/13526) [`ff9d69e`](https://github.com/withastro/astro/commit/ff9d69e3443c80059c54f6296d19f66bb068ead3) Thanks [@jsparkdev](https://github.com/jsparkdev)! - update `vite` to the latest version + +- Updated dependencies [[`ff9d69e`](https://github.com/withastro/astro/commit/ff9d69e3443c80059c54f6296d19f66bb068ead3)]: + - @astrojs/studio@0.1.6 + +## 0.14.9 + +### Patch Changes + +- [#13505](https://github.com/withastro/astro/pull/13505) [`a98ae5b`](https://github.com/withastro/astro/commit/a98ae5b8f5c33900379012e9e253a755c0a8927e) Thanks [@ematipico](https://github.com/ematipico)! - Updates the dependency `vite` to the latest. + +- Updated dependencies [[`a98ae5b`](https://github.com/withastro/astro/commit/a98ae5b8f5c33900379012e9e253a755c0a8927e)]: + - @astrojs/studio@0.1.5 + +## 0.14.8 + +### Patch Changes + +- [#13343](https://github.com/withastro/astro/pull/13343) [`a001a75`](https://github.com/withastro/astro/commit/a001a75d6ec08378d607531dc73959bf0a9e079e) Thanks [@dreyfus92](https://github.com/dreyfus92)! - Fix Astro DB seed failing when project path contains spaces. This resolves by properly decoding URL pathnames that contain encoded spaces (%20) before passing them to Vite's ssrLoadModule. + +- Updated dependencies []: + - @astrojs/studio@0.1.4 + +## 0.14.7 + +### Patch Changes + +- [#13314](https://github.com/withastro/astro/pull/13314) [`797a948`](https://github.com/withastro/astro/commit/797a9480b23303329dd618633194cbfb3dccb459) Thanks [@jlebras](https://github.com/jlebras)! - Expose `ilike` function from `drizzle-orm` + +- Updated dependencies []: + - @astrojs/studio@0.1.4 + +## 0.14.6 + +### Patch Changes + +- [#13011](https://github.com/withastro/astro/pull/13011) [`cf30880`](https://github.com/withastro/astro/commit/cf3088060d45227dcb48e041c4ed5e0081d71398) Thanks [@ascorbic](https://github.com/ascorbic)! - Upgrades Vite + +- Updated dependencies [[`cf30880`](https://github.com/withastro/astro/commit/cf3088060d45227dcb48e041c4ed5e0081d71398)]: + - @astrojs/studio@0.1.4 + +## 0.14.5 + +### Patch Changes + +- [#12799](https://github.com/withastro/astro/pull/12799) [`739dbfb`](https://github.com/withastro/astro/commit/739dbfba4214107cf8fc40c702834dad33eed3b0) Thanks [@ascorbic](https://github.com/ascorbic)! - Upgrades Vite to pin esbuild + +- Updated dependencies [[`739dbfb`](https://github.com/withastro/astro/commit/739dbfba4214107cf8fc40c702834dad33eed3b0)]: + - @astrojs/studio@0.1.3 + +## 0.14.4 + +### Patch Changes + +- [#12416](https://github.com/withastro/astro/pull/12416) [`618de28`](https://github.com/withastro/astro/commit/618de283f57d19397246f69dd476611abd56cf13) Thanks [@abegehr](https://github.com/abegehr)! - Fixes `isDbError()` guard for `LibsqlError` + +- [#12719](https://github.com/withastro/astro/pull/12719) [`358eae8`](https://github.com/withastro/astro/commit/358eae83b7cf3d79395eea3824e321b502522547) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes the publishing of the package + +- Updated dependencies []: + - @astrojs/studio@0.1.2 + +## 0.14.1 + +### Patch Changes + +- [#12628](https://github.com/withastro/astro/pull/12628) [`348c71e`](https://github.com/withastro/astro/commit/348c71ecdc7e2a7afb169c2251692416d5e59fcb) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes a bug that caused an error to be logged about invalid entrypoints + +- Updated dependencies []: + - @astrojs/studio@0.1.2 + +## 0.14.0 + +### Minor Changes + +- [#12008](https://github.com/withastro/astro/pull/12008) [`5608338`](https://github.com/withastro/astro/commit/560833843c6d3ce2b6c6c473ec4ae70e744bf255) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Welcome to the Astro 5 beta! This release has no changes from the latest alpha of this package, but it does bring us one step closer to the final, stable release. + + Starting from this release, no breaking changes will be introduced unless absolutely necessary. + + To learn how to upgrade, check out the [Astro v5.0 upgrade guide in our beta docs site](https://5-0-0-beta.docs.astro.build/en/guides/upgrade-to/v5/). + +### Patch Changes + +- [#12073](https://github.com/withastro/astro/pull/12073) [`acf264d`](https://github.com/withastro/astro/commit/acf264d8c003718cda5a0b9ce5fb7ac1cd6641b6) Thanks [@bluwy](https://github.com/bluwy)! - Replaces `ora` with `yocto-spinner` + +- Updated dependencies [[`acf264d`](https://github.com/withastro/astro/commit/acf264d8c003718cda5a0b9ce5fb7ac1cd6641b6)]: + - @astrojs/studio@0.1.2 + +## 0.14.0-beta.2 + +### Patch Changes + +- [#12073](https://github.com/withastro/astro/pull/12073) [`acf264d`](https://github.com/withastro/astro/commit/acf264d8c003718cda5a0b9ce5fb7ac1cd6641b6) Thanks [@bluwy](https://github.com/bluwy)! - Replaces `ora` with `yocto-spinner` + +- Updated dependencies [[`acf264d`](https://github.com/withastro/astro/commit/acf264d8c003718cda5a0b9ce5fb7ac1cd6641b6)]: + - @astrojs/studio@0.1.2-beta.0 + +## 0.14.0-beta.1 + +### Minor Changes + +- [#12008](https://github.com/withastro/astro/pull/12008) [`5608338`](https://github.com/withastro/astro/commit/560833843c6d3ce2b6c6c473ec4ae70e744bf255) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Welcome to the Astro 5 beta! This release has no changes from the latest alpha of this package, but it does bring us one step closer to the final, stable release. + + Starting from this release, no breaking changes will be introduced unless absolutely necessary. + + To learn how to upgrade, check out the [Astro v5.0 upgrade guide in our beta docs site](https://5-0-0-beta.docs.astro.build/en/guides/upgrade-to/v5/). + +### Patch Changes + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.14.3 + +### Patch Changes + +- [#11435](https://github.com/withastro/astro/pull/11435) [`f32a7a8`](https://github.com/withastro/astro/commit/f32a7a83889dd6180b2e4cde9b30286ab6874e49) Thanks [@haivuw](https://github.com/haivuw)! - Fixes a bug where `astro:db:seed` couldn't access to the environment variable `ASTRO_DATABASE_FILE` + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.14.2 + +### Patch Changes + +- [#12118](https://github.com/withastro/astro/pull/12118) [`f47b347`](https://github.com/withastro/astro/commit/f47b347da899c6e1dcd0b2e7887f7fce6ec8e270) Thanks [@Namchee](https://github.com/Namchee)! - Removes the `strip-ansi` dependency in favor of the native Node API + +- [#12089](https://github.com/withastro/astro/pull/12089) [`6e06e6e`](https://github.com/withastro/astro/commit/6e06e6ed4f1c983f842527d7e3561a45a4407777) Thanks [@Fryuni](https://github.com/Fryuni)! - Fixes initial schema push for local file and in-memory libSQL DB + +- [#12089](https://github.com/withastro/astro/pull/12089) [`6e06e6e`](https://github.com/withastro/astro/commit/6e06e6ed4f1c983f842527d7e3561a45a4407777) Thanks [@Fryuni](https://github.com/Fryuni)! - Fixes relative local libSQL db URL + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.14.1 + +### Patch Changes + +- [#11894](https://github.com/withastro/astro/pull/11894) [`cc820c5`](https://github.com/withastro/astro/commit/cc820c5d5e176a8d71594d612af75e1c94b9bf02) Thanks [@Fryuni](https://github.com/Fryuni)! - Fixes mixed environment variable for app token when using DB commands with libSQL remote. + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.14.0 + +### Minor Changes + +- [#11385](https://github.com/withastro/astro/pull/11385) [`d6611e8`](https://github.com/withastro/astro/commit/d6611e8bb05e7d913aeb5e59e90906b8b919d48e) Thanks [@Fryuni](https://github.com/Fryuni)! - Adds support for connecting Astro DB to any remote LibSQL server. This allows Astro DB to be used with self-hosting and air-gapped deployments. + + To connect Astro DB to a remote LibSQL server instead of Studio, set the following environment variables: + + - `ASTRO_DB_REMOTE_URL`: the connection URL to your LibSQL server + - `ASTRO_DB_APP_TOKEN`: the auth token to your LibSQL server + + Details of the LibSQL connection can be configured using the connection URL. For example, `memory:?syncUrl=libsql%3A%2F%2Fdb-server.example.com` would create an in-memory embedded replica for the LibSQL DB on `libsql://db-server.example.com`. + + For more details, please visit [the Astro DB documentation](https://docs.astro.build/en/guides/astro-db/#libsql) + +### Patch Changes + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.13.2 + +### Patch Changes + +- [#11744](https://github.com/withastro/astro/pull/11744) [`b677429`](https://github.com/withastro/astro/commit/b67742961a384c10e5cd04cf5b02d0f014ea7362) Thanks [@bluwy](https://github.com/bluwy)! - Disables the WebSocket server when creating a Vite server for loading config files + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.13.1 + +### Patch Changes + +- [#11733](https://github.com/withastro/astro/pull/11733) [`391324d`](https://github.com/withastro/astro/commit/391324df969db71d1c7ca25c2ed14c9eb6eea5ee) Thanks [@bluwy](https://github.com/bluwy)! - Reverts back to `yargs-parser` package for CLI argument parsing + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.13.0 + +### Minor Changes + +- [#11360](https://github.com/withastro/astro/pull/11360) [`a79a8b0`](https://github.com/withastro/astro/commit/a79a8b0230b06ed32ce1802f2a5f84a6cf92dbe7) Thanks [@ascorbic](https://github.com/ascorbic)! - Changes how type generation works + + The generated `.d.ts` file is now at a new location: + + ```diff + - .astro/db-types.d.ts + + .astro/integrations/astro_db/db.d.ts + ``` + + The following line can now be removed from `src/env.d.ts`: + + ```diff + - /// <reference path="../.astro/db-types.d.ts" /> + ``` + +### Patch Changes + +- [#11645](https://github.com/withastro/astro/pull/11645) [`849e4c6`](https://github.com/withastro/astro/commit/849e4c6c23e61f7fa59f583419048b998bef2475) Thanks [@bluwy](https://github.com/bluwy)! - Refactors internally to use `node:util` `parseArgs` instead of `yargs-parser` + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.12.0 + +### Minor Changes + +- [#11304](https://github.com/withastro/astro/pull/11304) [`2e70741`](https://github.com/withastro/astro/commit/2e70741362afc1e7d03c8b2a9d8edb8466dfe9c3) Thanks [@Fryuni](https://github.com/Fryuni)! - Removes the `AstroDbIntegration` type + + Astro integration hooks can now be extended and as such `@astrojs/db` no longer needs to declare it's own integration type. Using `AstroIntegration` will have the same type. + + If you were using the `AstroDbIntegration` type, apply this change to your integration code: + + ```diff + - import { defineDbIntegration, type AstroDbIntegration } from '@astrojs/db/utils'; + + import { defineDbIntegration } from '@astrojs/db/utils'; + import type { AstroIntegration } from 'astro'; + + - export default (): AstroDbIntegration => { + + export default (): AstroIntegration => { + return defineDbIntegration({ + name: 'your-integration', + hooks: {}, + }); + } + ``` + +### Patch Changes + +- Updated dependencies []: + - @astrojs/studio@0.1.1 + +## 0.11.7 + +### Patch Changes + +- [#11331](https://github.com/withastro/astro/pull/11331) [`f1b78a4`](https://github.com/withastro/astro/commit/f1b78a496034d53b0e9dfc276a4a1b1d691772c4) Thanks [@bluwy](https://github.com/bluwy)! - Relaxes exports condition to allow importing ESM from CJS + +- Updated dependencies [[`f1b78a4`](https://github.com/withastro/astro/commit/f1b78a496034d53b0e9dfc276a4a1b1d691772c4)]: + - @astrojs/studio@0.1.1 + +## 0.11.6 + +### Patch Changes + +- [#11262](https://github.com/withastro/astro/pull/11262) [`9b03023`](https://github.com/withastro/astro/commit/9b030239cb4db4e51a8a1da638743b60837f7e1a) Thanks [@nezouse](https://github.com/nezouse)! - Import type `Database` from correct file + +- Updated dependencies []: + - @astrojs/studio@0.1.0 + +## 0.11.5 + +### Patch Changes + +- [#11216](https://github.com/withastro/astro/pull/11216) [`29463df`](https://github.com/withastro/astro/commit/29463dff52f2e74d0d522168afe6faf70ff2fabb) Thanks [@OliverSpeir](https://github.com/OliverSpeir)! - Export type `Database` from `@astrojs/db/runtime` + +- Updated dependencies []: + - @astrojs/studio@0.1.0 + +## 0.11.4 + +### Patch Changes + +- [#11032](https://github.com/withastro/astro/pull/11032) [`b78e83f`](https://github.com/withastro/astro/commit/b78e83f448d142e83be592f6249c4822e7cd5726) Thanks [@itsMapleLeaf](https://github.com/itsMapleLeaf)! - Adds support for multiple Astro Studio workspaces (aka “Teams”) to the Astro DB CLI + + Users who are members of a team workspace in Astro Studio can now choose between those and their personal workspace when running `astro db link`. + +- [#11091](https://github.com/withastro/astro/pull/11091) [`e14ce57`](https://github.com/withastro/astro/commit/e14ce5726df73e2988fe1a39e078ef2d66d2f4a8) Thanks [@matthewp](https://github.com/matthewp)! - Fix inconsistent result type using raw SQL + +- Updated dependencies []: + - @astrojs/studio@0.1.0 + +## 0.11.3 + +### Patch Changes + +- [#11070](https://github.com/withastro/astro/pull/11070) [`1fec4a6`](https://github.com/withastro/astro/commit/1fec4a6eb986011d4d6d998410ff9b6144c28c34) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes some error messages not using the proper command to login or sync the project + +- [#11037](https://github.com/withastro/astro/pull/11037) [`9332bb1`](https://github.com/withastro/astro/commit/9332bb1c1f237f5666ded09532ccd651837b94e5) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Internal refactor, this change should have no visible effect + +- Updated dependencies [[`9332bb1`](https://github.com/withastro/astro/commit/9332bb1c1f237f5666ded09532ccd651837b94e5)]: + - @astrojs/studio@0.1.0 + +## 0.11.2 + +### Patch Changes + +- [#11027](https://github.com/withastro/astro/pull/11027) [`eb1d9a4`](https://github.com/withastro/astro/commit/eb1d9a447bc73534b8dd8fa6d3dcdb265950753a) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix `isDbError()` returning `false` for remote database errors. Astro will now return a `LibsqlError` in development and production. + +## 0.11.1 + +### Patch Changes + +- [#10967](https://github.com/withastro/astro/pull/10967) [`a134318`](https://github.com/withastro/astro/commit/a1343184da2a67439de4792e9e404d17ec3943df) Thanks [@matthewp](https://github.com/matthewp)! - Convert non-ISO date to UTC time + +## 0.11.0 + +### Minor Changes + +- [#10919](https://github.com/withastro/astro/pull/10919) [`44bafa9`](https://github.com/withastro/astro/commit/44bafa989af0cc380696bb6381048fc1ee55dd5b) Thanks [@bholmesdev](https://github.com/bholmesdev)! - - Fix duplicate table recreations when you start your dev server. + - Remove eager re-seeding when updating your seed file in development. Seeding still runs on dev server startup for SQLite inspector tools. + +## 0.10.7 + +### Patch Changes + +- [#10882](https://github.com/withastro/astro/pull/10882) [`cf58d1e`](https://github.com/withastro/astro/commit/cf58d1ed56c671d0ee077dfecc286002b4bae5ed) Thanks [@delucis](https://github.com/delucis)! - Improves the typing of the `asDrizzleTable()` utility + + Fixes a type error when passing the output of `defineTable()` to the utility and returns a more detailed type inferred from the columns of the passed table config. + +- [#10918](https://github.com/withastro/astro/pull/10918) [`ca605f4`](https://github.com/withastro/astro/commit/ca605f4dd8fcd070d3d5a5ca2f7080d921801e17) Thanks [@matthewp](https://github.com/matthewp)! - Provide a better error message when app token is missing in CI + +- [#10925](https://github.com/withastro/astro/pull/10925) [`a0c77fc`](https://github.com/withastro/astro/commit/a0c77fc7164662ea62b65c51fd1bd4c2f6028bc1) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes `ASTRO_DATABASE_FILE` not correctly resolving relative paths (e.g. `ASTRO_DATABASE_FILE=./api/database.db` + +## 0.10.6 + +### Patch Changes + +- [#10816](https://github.com/withastro/astro/pull/10816) [`8e6eb62`](https://github.com/withastro/astro/commit/8e6eb624aee40bac66a58169a30107f624a8c539) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Add `astro login` support from online editors like Stackblitz and GitHub Codespaces + +## 0.10.5 + +### Patch Changes + +- [#10789](https://github.com/withastro/astro/pull/10789) [`d4c91cb10924a0627a9e9a80bc549b3c82d043e6`](https://github.com/withastro/astro/commit/d4c91cb10924a0627a9e9a80bc549b3c82d043e6) Thanks [@NickDubelman](https://github.com/NickDubelman)! - Expose the Drizzle `alias` utility from `astro:db` to enable self-joins on a table. + +## 0.10.4 + +### Patch Changes + +- [#10764](https://github.com/withastro/astro/pull/10764) [`d1080ea81de0db1d1aed97a65c490766c17ab312`](https://github.com/withastro/astro/commit/d1080ea81de0db1d1aed97a65c490766c17ab312) Thanks [@delucis](https://github.com/delucis)! - Improves `package.json` metadata fields + +## 0.10.3 + +### Patch Changes + +- [#10720](https://github.com/withastro/astro/pull/10720) [`2d2ff58bb90e09248afc04cc8e2016ef983a7f07`](https://github.com/withastro/astro/commit/2d2ff58bb90e09248afc04cc8e2016ef983a7f07) Thanks [@matthewp](https://github.com/matthewp)! - Fix db seeding when srcDir is root + +## 0.10.2 + +### Patch Changes + +- [#10681](https://github.com/withastro/astro/pull/10681) [`4bf8bd3848a6f3461f0186854588a8e2f90f4dbc`](https://github.com/withastro/astro/commit/4bf8bd3848a6f3461f0186854588a8e2f90f4dbc) Thanks [@matthewp](https://github.com/matthewp)! - Prevent errors in finding workspaceId from interrupting link prompts + +- [#10600](https://github.com/withastro/astro/pull/10600) [`28e7535e5c7a8234049bd187cac88c7fabf15f9c`](https://github.com/withastro/astro/commit/28e7535e5c7a8234049bd187cac88c7fabf15f9c) Thanks [@matthewp](https://github.com/matthewp)! - Provide better messaging when renaming a table + +## 0.10.1 + +### Patch Changes + +- [#10677](https://github.com/withastro/astro/pull/10677) [`1662aa8a850ff4f860a80c2f33a8b33bcc7aee12`](https://github.com/withastro/astro/commit/1662aa8a850ff4f860a80c2f33a8b33bcc7aee12) Thanks [@matthewp](https://github.com/matthewp)! - Fix compatibility of @astrojs/db with Cloudflare + +## 0.10.0 + +### Minor Changes + +- [#10638](https://github.com/withastro/astro/pull/10638) [`f395ebcc08279515f8d53bb82edeee288d8579db`](https://github.com/withastro/astro/commit/f395ebcc08279515f8d53bb82edeee288d8579db) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Rename internal import from `runtime/config` to `runtime/virtual` + +## 0.9.11 + +### Patch Changes + +- [#10655](https://github.com/withastro/astro/pull/10655) [`b1eda3dc5c33c1c6d50d5ee8a2a08d34851dc253`](https://github.com/withastro/astro/commit/b1eda3dc5c33c1c6d50d5ee8a2a08d34851dc253) Thanks [@matthewp](https://github.com/matthewp)! - Pass through appToken on static sites with Astro DB + +## 0.9.10 + +### Patch Changes + +- [#10646](https://github.com/withastro/astro/pull/10646) [`713f99e849b26edb831ea6527b7103ad7a6b200f`](https://github.com/withastro/astro/commit/713f99e849b26edb831ea6527b7103ad7a6b200f) Thanks [@lilnasy](https://github.com/lilnasy)! - Fixes an issue astro:db could not be used on serverless platforms. + +## 0.9.9 + +### Patch Changes + +- [#10599](https://github.com/withastro/astro/pull/10599) [`5a7733dde52d439402404d5207a2b1d897c4e025`](https://github.com/withastro/astro/commit/5a7733dde52d439402404d5207a2b1d897c4e025) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Seed database on dev server startup, and log whenever the seed file is reloaded. + +- [#10636](https://github.com/withastro/astro/pull/10636) [`504d15d77291f0fe36aa9fecc22f276b734f83cb`](https://github.com/withastro/astro/commit/504d15d77291f0fe36aa9fecc22f276b734f83cb) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Detailed error messages for remote database exceptions. + +- [#10635](https://github.com/withastro/astro/pull/10635) [`17badaf55c79cec460c74f4da58bf188eedef7e3`](https://github.com/withastro/astro/commit/17badaf55c79cec460c74f4da58bf188eedef7e3) Thanks [@matthewp](https://github.com/matthewp)! - Give proper error when seed missing default export + +- [#10631](https://github.com/withastro/astro/pull/10631) [`157392ee44db08de0d3c01222644dae0a904044d`](https://github.com/withastro/astro/commit/157392ee44db08de0d3c01222644dae0a904044d) Thanks [@matthewp](https://github.com/matthewp)! - Make ASTRO_DATABASE_FILE work with file paths + +## 0.9.8 + +### Patch Changes + +- [#10589](https://github.com/withastro/astro/pull/10589) [`ed1031ba29af9a8a89ab386d772a228ba1414b4d`](https://github.com/withastro/astro/commit/ed1031ba29af9a8a89ab386d772a228ba1414b4d) Thanks [@column.text(),](<https://github.com/column.text(),>)! - Update the table indexes configuration to allow generated index names. The `indexes` object syntax is now deprecated in favor of an array. + + ## Migration + + You can update your `indexes` configuration object to an array like so: + + ```diff + import { defineDb, defineTable, column } from 'astro:db'; + + const Comment = defineTable({ + columns: { + postId: column.number(), + + body: column.text(), + }, + - indexes: { + - postIdIdx: { on: 'postId' }, + - authorPostIdIdx: { on: ['author, postId'], unique: true }, + - }, + + indexes: [ + + { on: 'postId' /* 'name' is optional */ }, + + { on: ['author, postId'], unique: true }, + + ] + }) + ``` + + This example will generate indexes with the names `Comment_postId_idx` and `Comment_author_postId_idx`, respectively. You can specify a name manually by adding the `name` attribute to a given object. This name will be **global,** so ensure index names do not conflict between tables. + +## 0.9.7 + +### Patch Changes + +- [#10587](https://github.com/withastro/astro/pull/10587) [`62a1d6df6916e08cb25d51814dfad352bc4cce75`](https://github.com/withastro/astro/commit/62a1d6df6916e08cb25d51814dfad352bc4cce75) Thanks [@matthewp](https://github.com/matthewp)! - Conditionally drop table with --force-reset + +- [#10460](https://github.com/withastro/astro/pull/10460) [`713abb2998bc179443a476f6274432b2fc7d8434`](https://github.com/withastro/astro/commit/713abb2998bc179443a476f6274432b2fc7d8434) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Remove legacy Astro DB internals using the "collections" naming convention instead of "tables." + +## 0.9.6 + +### Patch Changes + +- [#10579](https://github.com/withastro/astro/pull/10579) [`f5df12cfebba1abdef50faa7a5549b545f0b3f8c`](https://github.com/withastro/astro/commit/f5df12cfebba1abdef50faa7a5549b545f0b3f8c) Thanks [@matthewp](https://github.com/matthewp)! - Provide guidance when --remote is missing + + When running the build `astro build` without the `--remote`, either require a `DATABASE_FILE` variable be defined, which means you are going expert-mode and having your own database, or error suggesting to use the `--remote` flag. + +- [#10568](https://github.com/withastro/astro/pull/10568) [`764d67fc3f399d62b6a97a2ee698dca03b9f0557`](https://github.com/withastro/astro/commit/764d67fc3f399d62b6a97a2ee698dca03b9f0557) Thanks [@matthewp](https://github.com/matthewp)! - Prevent runtime from importing core code + +## 0.9.5 + +### Patch Changes + +- [#10566](https://github.com/withastro/astro/pull/10566) [`b5a80405b93a166f6f019209152b860ffe2f73ef`](https://github.com/withastro/astro/commit/b5a80405b93a166f6f019209152b860ffe2f73ef) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix `db.run()` type signature in production. + +## 0.9.4 + +### Patch Changes + +- [#10533](https://github.com/withastro/astro/pull/10533) [`6576f5d458ee8cc872210f3a7ae629439546e361`](https://github.com/withastro/astro/commit/6576f5d458ee8cc872210f3a7ae629439546e361) Thanks [@matthewp](https://github.com/matthewp)! - Ensure ASTRO_STUDIO_APP_TOKEN is found at runtime + +## 0.9.3 + +### Patch Changes + +- [#10520](https://github.com/withastro/astro/pull/10520) [`30ce9a0c47a4653a9e9619380a6514459563cf92`](https://github.com/withastro/astro/commit/30ce9a0c47a4653a9e9619380a6514459563cf92) Thanks [@matthewp](https://github.com/matthewp)! - Fix accessing remote database URL + +## 0.9.2 + +### Patch Changes + +- [#10506](https://github.com/withastro/astro/pull/10506) [`980020c5e0935a2e0e177164d02f5e49f0a9ab4b`](https://github.com/withastro/astro/commit/980020c5e0935a2e0e177164d02f5e49f0a9ab4b) Thanks [@matthewp](https://github.com/matthewp)! - Ensure --force-reset drops previous tables + +## 0.9.1 + +### Patch Changes + +- [#10498](https://github.com/withastro/astro/pull/10498) [`f0fc78c8734b2bcf39078c782998e60b49ecc146`](https://github.com/withastro/astro/commit/f0fc78c8734b2bcf39078c782998e60b49ecc146) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Expose `isDbError()` utility to handle database exceptions when querying. + +## 0.9.0 + +### Minor Changes + +- [#10479](https://github.com/withastro/astro/pull/10479) [`ad57a02c330b544770ab853fe0521eb784421016`](https://github.com/withastro/astro/commit/ad57a02c330b544770ab853fe0521eb784421016) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Expose Drizzle aggregation helpers including `count()` from the `astro:db` module. + +### Patch Changes + +- [#10501](https://github.com/withastro/astro/pull/10501) [`48310512601e0c0b2886759e4d81b4091042eb8f`](https://github.com/withastro/astro/commit/48310512601e0c0b2886759e4d81b4091042eb8f) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Remove `db.transaction()` from type definitions until it is supported by our remote database adapter. + +- [#10497](https://github.com/withastro/astro/pull/10497) [`2fc7231df28e5a3425ee47b871ba3766e0856bd8`](https://github.com/withastro/astro/commit/2fc7231df28e5a3425ee47b871ba3766e0856bd8) Thanks [@matthewp](https://github.com/matthewp)! - Remove embedded app token from CI + +- [#10405](https://github.com/withastro/astro/pull/10405) [`2ebcf94d0af5ac789c61b4190dea0ad6a402a6ea`](https://github.com/withastro/astro/commit/2ebcf94d0af5ac789c61b4190dea0ad6a402a6ea) Thanks [@43081j](https://github.com/43081j)! - Added github-slugger as a direct dependency + +## 0.8.8 + +### Patch Changes + +- [#10477](https://github.com/withastro/astro/pull/10477) [`124cdd64f20d86f936853f3cf834fde8cd6abcb7`](https://github.com/withastro/astro/commit/124cdd64f20d86f936853f3cf834fde8cd6abcb7) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Remove redundant wait time on token creation + +## 0.8.7 + +### Patch Changes + +- [#10435](https://github.com/withastro/astro/pull/10435) [`37a485b4d1d4b7e60eee2067ffd86d0eea4f03e8`](https://github.com/withastro/astro/commit/37a485b4d1d4b7e60eee2067ffd86d0eea4f03e8) Thanks [@matthewp](https://github.com/matthewp)! - Fetch new app token when previous has expired + +- [#10457](https://github.com/withastro/astro/pull/10457) [`219c49473fe44d8df2b69444b2dce0f5bc971655`](https://github.com/withastro/astro/commit/219c49473fe44d8df2b69444b2dce0f5bc971655) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix type error in db/seed.ts file before type generation is run. + +## 0.8.6 + +### Patch Changes + +- [#10439](https://github.com/withastro/astro/pull/10439) [`0989cd3284281e3e471a92ac116e14e65f59f8a5`](https://github.com/withastro/astro/commit/0989cd3284281e3e471a92ac116e14e65f59f8a5) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Add success and error logs to `astro db execute` command + +- [#10438](https://github.com/withastro/astro/pull/10438) [`5b48cc0fc8383b0659a595afd3a6ee28b28779c3`](https://github.com/withastro/astro/commit/5b48cc0fc8383b0659a595afd3a6ee28b28779c3) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Generate Astro DB types when running `astro sync`. + +## 0.8.5 + +### Patch Changes + +- [#10445](https://github.com/withastro/astro/pull/10445) [`098623c2616124bcc689e2409564dfda187f6688`](https://github.com/withastro/astro/commit/098623c2616124bcc689e2409564dfda187f6688) Thanks [@matthewp](https://github.com/matthewp)! - Prefer getting the app token from the runtime env + +- [#10441](https://github.com/withastro/astro/pull/10441) [`5166e9715a1ea18eb5c737ccf834c2ff446d253c`](https://github.com/withastro/astro/commit/5166e9715a1ea18eb5c737ccf834c2ff446d253c) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Remove duplicate astro:db log during type generation + +## 0.8.4 + +### Patch Changes + +- [#10443](https://github.com/withastro/astro/pull/10443) [`238f047b9d1ebc407f53d61ee61574b380a76ac9`](https://github.com/withastro/astro/commit/238f047b9d1ebc407f53d61ee61574b380a76ac9) Thanks [@lilnasy](https://github.com/lilnasy)! - Fixes an issue where `astro:db` could not be used in serverless environments. + +## 0.8.3 + +### Patch Changes + +- [#10431](https://github.com/withastro/astro/pull/10431) [`1076864cc4aa4b4dad570bbab9907996642cdd1f`](https://github.com/withastro/astro/commit/1076864cc4aa4b4dad570bbab9907996642cdd1f) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Add wait time for the db token to propagate + +- [#10432](https://github.com/withastro/astro/pull/10432) [`4e24628aacc556515b27d0c04361df1526ae778f`](https://github.com/withastro/astro/commit/4e24628aacc556515b27d0c04361df1526ae778f) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Add all regions to the link command + +## 0.8.2 + +### Patch Changes + +- [#10409](https://github.com/withastro/astro/pull/10409) [`96c8bca19aa477318b5eb48af12b260a6f173e25`](https://github.com/withastro/astro/commit/96c8bca19aa477318b5eb48af12b260a6f173e25) Thanks [@lilnasy](https://github.com/lilnasy)! - Fixes an issue where one table schema could not reference text fields of another table schema. + +- [#10428](https://github.com/withastro/astro/pull/10428) [`189ec47c1e3232d8b4db42035ddd44ea862ecfca`](https://github.com/withastro/astro/commit/189ec47c1e3232d8b4db42035ddd44ea862ecfca) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Fix an issue where short-lived tokens were not being authorized + +- [#10420](https://github.com/withastro/astro/pull/10420) [`2db25c05a467f2ffd6ebff5eb82076449fa9d72f`](https://github.com/withastro/astro/commit/2db25c05a467f2ffd6ebff5eb82076449fa9d72f) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes some situations where failing requests would not error properly + +## 0.8.1 + +### Patch Changes + +- [#10401](https://github.com/withastro/astro/pull/10401) [`a084d8cec66e4fb1952bd0dfe293712401f2f463`](https://github.com/withastro/astro/commit/a084d8cec66e4fb1952bd0dfe293712401f2f463) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix astro:db configuration types returning `any` + +## 0.8.0 + +### Minor Changes + +- [#10395](https://github.com/withastro/astro/pull/10395) [`a49892349ecee2b5d3184e59ac0ab54368481672`](https://github.com/withastro/astro/commit/a49892349ecee2b5d3184e59ac0ab54368481672) Thanks [@matthewp](https://github.com/matthewp)! - Sets new Astro Studio production URL + +### Patch Changes + +- [#10396](https://github.com/withastro/astro/pull/10396) [`41ca94e5136a80a58d000f3eb87029442599a4a3`](https://github.com/withastro/astro/commit/41ca94e5136a80a58d000f3eb87029442599a4a3) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Remove risk of data loss when pushing an out-of-date table schema. + +- [#10374](https://github.com/withastro/astro/pull/10374) [`f76dcb769f6869acb96b2a77898926f109f54a33`](https://github.com/withastro/astro/commit/f76dcb769f6869acb96b2a77898926f109f54a33) Thanks [@itsMapleLeaf](https://github.com/itsMapleLeaf)! - Expose DB utility types from @astrojs/db/types + +## 0.7.2 + +### Patch Changes + +- [#10391](https://github.com/withastro/astro/pull/10391) [`9667ee990ca2a02a146e442f2494981df4c88b52`](https://github.com/withastro/astro/commit/9667ee990ca2a02a146e442f2494981df4c88b52) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Remove @astrojs/runtime/config suggestion for astro:db configuration helpers. + +- [#10385](https://github.com/withastro/astro/pull/10385) [`38abae47b57af481a8dcdf2393317de6df46920a`](https://github.com/withastro/astro/commit/38abae47b57af481a8dcdf2393317de6df46920a) Thanks [@delucis](https://github.com/delucis)! - Fixes support for integrations configuring `astro:db` and for projects that use `astro:db` but do not include a seed file. + +- [#10381](https://github.com/withastro/astro/pull/10381) [`8cceab587d681d90842184904182833117687750`](https://github.com/withastro/astro/commit/8cceab587d681d90842184904182833117687750) Thanks [@delucis](https://github.com/delucis)! - Fixes builds for projects using integration seed files + +- [#10384](https://github.com/withastro/astro/pull/10384) [`cd5e8d4b9309e43f5bf884a0014b8a5769d816e0`](https://github.com/withastro/astro/commit/cd5e8d4b9309e43f5bf884a0014b8a5769d816e0) Thanks [@matthewp](https://github.com/matthewp)! - Upgrades the `@libsql/client` dependency to fix the use of `db.batch` in StackBlitz + +- [#10387](https://github.com/withastro/astro/pull/10387) [`8a23ee530cd1d7d7b4e93e9e72f4e06d1fc3d845`](https://github.com/withastro/astro/commit/8a23ee530cd1d7d7b4e93e9e72f4e06d1fc3d845) Thanks [@FredKSchott](https://github.com/FredKSchott)! - handle success=false response on api endpoints + +- [#10390](https://github.com/withastro/astro/pull/10390) [`236cdbb611587692d3c781850cb949604677ef82`](https://github.com/withastro/astro/commit/236cdbb611587692d3c781850cb949604677ef82) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Adds `--help` reference for new db and studio CLI commands + +## 0.7.1 + +### Patch Changes + +- [#10378](https://github.com/withastro/astro/pull/10378) [`41dca1e413c2f1e38f0326bd6241ccbf9b8ee0e4`](https://github.com/withastro/astro/commit/41dca1e413c2f1e38f0326bd6241ccbf9b8ee0e4) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Handle new schema API response format + +## 0.7.0 + +### Minor Changes + +## 0.7.0 + +### Breaking Changes + +- The seed file now requires an `export default async function()` wrapper +- `defineDB` has been renamed to `defineDb` + +### Minor Changes + +- [#10334](https://github.com/withastro/astro/pull/10334) [`bad9b583a267e239ba52237d45a89063ea277200`](https://github.com/withastro/astro/commit/bad9b583a267e239ba52237d45a89063ea277200) Thanks [@delucis](https://github.com/delucis)! - Changes the seed file format to require exporting a default function instead of running seed code at the top level. + + To migrate a seed file, wrap your existing code in a default function export: + + ```diff + // db/seed.ts + import { db, Table } from 'astro:db'; + + + export default async function() { + await db.insert(Table).values({ foo: 'bar' }); + + } + ``` + +- [#10352](https://github.com/withastro/astro/pull/10352) [`06fe94e29de97290cb41c4f862ab88f48cda3d4a`](https://github.com/withastro/astro/commit/06fe94e29de97290cb41c4f862ab88f48cda3d4a) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Introduce `astro build --remote` to build with a remote database connection. Running `astro build` plain will use a local database file, and `--remote` will authenticate with a studio app token. + +- [#10321](https://github.com/withastro/astro/pull/10321) [`2e4958c8a75dc9836efcc7dd272fb8ed4187c000`](https://github.com/withastro/astro/commit/2e4958c8a75dc9836efcc7dd272fb8ed4187c000) Thanks [@delucis](https://github.com/delucis)! - Adds support for integrations providing `astro:db` configuration and seed files, using the new `astro:db:setup` hook. + + To get TypeScript support for the `astro:db:setup` hook, wrap your integration object in the `defineDbIntegration()` utility: + + ```js + import { defineDbIntegration } from '@astrojs/db/utils'; + + export default function MyDbIntegration() { + return defineDbIntegration({ + name: 'my-astro-db-powered-integration', + hooks: { + 'astro:db:setup': ({ extendDb }) => { + extendDb({ + configEntrypoint: '@astronaut/my-package/config', + seedEntrypoint: '@astronaut/my-package/seed', + }); + }, + }, + }); + } + ``` + + Use the `extendDb` method to register additional `astro:db` config and seed files. + + Integration config and seed files follow the same format as their user-defined equivalents. However, often while working on integrations, you may not be able to benefit from Astro’s generated table types exported from `astro:db`. For full type safety and autocompletion support, use the `asDrizzleTable()` utility to wrap your table definitions in the seed file. + + ```js + // config.ts + import { defineTable, column } from 'astro:db'; + + export const Pets = defineTable({ + columns: { + name: column.text(), + age: column.number(), + }, + }); + ``` + + ```js + // seed.ts + import { asDrizzleTable } from '@astrojs/db/utils'; + import { db } from 'astro:db'; + import { Pets } from './config'; + + export default async function () { + // Convert the Pets table into a format ready for querying. + const typeSafePets = asDrizzleTable('Pets', Pets); + + await db.insert(typeSafePets).values([ + { name: 'Palomita', age: 7 }, + { name: 'Pan', age: 3.5 }, + ]); + } + ``` + +- [#10361](https://github.com/withastro/astro/pull/10361) [`988aad6705e5ee129cf3a28da80aca4229052bb3`](https://github.com/withastro/astro/commit/988aad6705e5ee129cf3a28da80aca4229052bb3) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Add support for batch queries with `db.batch()`. This includes an internal bump to Drizzle v0.29. + +### Patch Changes + +- [#10357](https://github.com/withastro/astro/pull/10357) [`5a9dab286f3f436f3dce18f3b13a2cd9b774a8ef`](https://github.com/withastro/astro/commit/5a9dab286f3f436f3dce18f3b13a2cd9b774a8ef) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix runtime export error when building with the node adapter + +- [#10348](https://github.com/withastro/astro/pull/10348) [`9f422e9bd338c1f6deee8f727143bf801a6b1651`](https://github.com/withastro/astro/commit/9f422e9bd338c1f6deee8f727143bf801a6b1651) Thanks [@matthewp](https://github.com/matthewp)! - Rename `experimentalVersion` to `version` + +- [#10364](https://github.com/withastro/astro/pull/10364) [`3f27e096283b6b477c4a66d0a7df52feaa3f4233`](https://github.com/withastro/astro/commit/3f27e096283b6b477c4a66d0a7df52feaa3f4233) Thanks [@delucis](https://github.com/delucis)! - Renames the Astro DB `defineDB()` helper to `defineDb()` + +## 0.6.5 + +### Patch Changes + +- [#10350](https://github.com/withastro/astro/pull/10350) [`393ad9b2aa9fde45eb14b8b01ff3526063772452`](https://github.com/withastro/astro/commit/393ad9b2aa9fde45eb14b8b01ff3526063772452) Thanks [@Fryuni](https://github.com/Fryuni)! - Includes `./virtual.d.ts` file that was previously unpublished + +## 0.6.4 + +### Patch Changes + +- [#10342](https://github.com/withastro/astro/pull/10342) [`a2e9b2b936666b2a4779feb00dcb8ff0ab82c2ec`](https://github.com/withastro/astro/commit/a2e9b2b936666b2a4779feb00dcb8ff0ab82c2ec) Thanks [@matthewp](https://github.com/matthewp)! - Fixes @astrojs/db loading TS in the fixtures + +## 0.6.3 + +### Patch Changes + +- [#10340](https://github.com/withastro/astro/pull/10340) [`a60861c960bf3d24af9b2784b5b333855c968731`](https://github.com/withastro/astro/commit/a60861c960bf3d24af9b2784b5b333855c968731) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Ensure `astro:db` types exist in your `db/config.ts` before running type generation. + +## 0.6.2 + +### Patch Changes + +- [#10336](https://github.com/withastro/astro/pull/10336) [`f2e60a96754ed1d86001fe4d5d3a0c0ef657408d`](https://github.com/withastro/astro/commit/f2e60a96754ed1d86001fe4d5d3a0c0ef657408d) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Add back confirmation handling on verify and push + +## 0.6.1 + +### Patch Changes + +- [#10315](https://github.com/withastro/astro/pull/10315) [`78ddfadbf9cc5a12a9bd25eab64ec8ec1bd8617d`](https://github.com/withastro/astro/commit/78ddfadbf9cc5a12a9bd25eab64ec8ec1bd8617d) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix type definitions for `astro:db` + +## 0.6.0 + +### Minor Changes + +- [#10312](https://github.com/withastro/astro/pull/10312) [`93ec9e264a1dbdff61233289418612f558508135`](https://github.com/withastro/astro/commit/93ec9e264a1dbdff61233289418612f558508135) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Revamp migrations system + +### Patch Changes + +- [#10313](https://github.com/withastro/astro/pull/10313) [`cb00c8b6927242369debe92ad2bc7e791616696a`](https://github.com/withastro/astro/commit/cb00c8b6927242369debe92ad2bc7e791616696a) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Fix bad package.json types + +## 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 + +- [#10223](https://github.com/withastro/astro/pull/10223) [`aa45eb9fa60b254e859750d9cef671daa605b213`](https://github.com/withastro/astro/commit/aa45eb9fa60b254e859750d9cef671daa605b213) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix: use correct remote database url during production builds + +- [#10207](https://github.com/withastro/astro/pull/10207) [`5d4ff093a21c072553b2cac6c799d3efa3cb84c0`](https://github.com/withastro/astro/commit/5d4ff093a21c072553b2cac6c799d3efa3cb84c0) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Improve error messaging when seeding invalid data. + +## 0.4.0 + +### Minor Changes + +- [`f85ace2e66370e522b5a4e9b54c578a02298fe0e`](https://github.com/withastro/astro/commit/f85ace2e66370e522b5a4e9b54c578a02298fe0e) Thanks [@matthewp](https://github.com/matthewp)! - @astrojs/db prerelease diff --git a/packages/db/README.md b/packages/db/README.md new file mode 100644 index 000000000..44ef8f8a5 --- /dev/null +++ b/packages/db/README.md @@ -0,0 +1,38 @@ +# @astrojs/db (experimental) 💿 + +This **[Astro integration][astro-integration]** enables the usage of [SQLite](https://www.sqlite.org/) in Astro Projects. + +## Documentation + +Read the [`@astrojs/db` docs][docs] + +## Support + +- Get help in the [Astro Discord][discord]. Post questions in our `#support` forum, or visit our dedicated `#dev` channel to discuss current development and more! + +- Check our [Astro Integration Documentation][astro-integration] for more on integrations. + +- Submit bug reports and feature requests as [GitHub issues][issues]. + +## Contributing + +This package is maintained by Astro's Core team. You're welcome to submit an issue or PR! These links will help you get started: + +- [Contributor Manual][contributing] +- [Code of Conduct][coc] +- [Community Guide][community] + +## License + +MIT + +Copyright (c) 2023–present [Astro][astro] + +[astro]: https://astro.build/ +[docs]: https://docs.astro.build/en/guides/integrations-guide/db/ +[contributing]: https://github.com/withastro/astro/blob/main/CONTRIBUTING.md +[coc]: https://github.com/withastro/.github/blob/main/CODE_OF_CONDUCT.md +[community]: https://github.com/withastro/.github/blob/main/COMMUNITY_GUIDE.md +[discord]: https://astro.build/chat/ +[issues]: https://github.com/withastro/astro/issues +[astro-integration]: https://docs.astro.build/en/guides/integrations-guide/ diff --git a/packages/db/index.d.ts b/packages/db/index.d.ts new file mode 100644 index 000000000..7c0e055b2 --- /dev/null +++ b/packages/db/index.d.ts @@ -0,0 +1,3 @@ +import './virtual.js'; + +export { default, cli } from './dist/index.js'; diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 000000000..5d0b25efd --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,96 @@ +{ + "name": "@astrojs/db", + "version": "0.15.0", + "description": "Add libSQL and Astro Studio support to your Astro site", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/withastro/astro.git", + "directory": "packages/db" + }, + "bugs": "https://github.com/withastro/astro/issues", + "homepage": "https://docs.astro.build/en/guides/integrations-guide/db/", + "type": "module", + "author": "withastro", + "types": "./index.d.ts", + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./dist/index.js" + }, + "./utils": { + "types": "./dist/utils.d.ts", + "default": "./dist/utils.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "default": "./dist/runtime/index.js" + }, + "./dist/runtime/virtual.js": { + "default": "./dist/runtime/virtual.js" + }, + "./types": { + "types": "./dist/core/types.d.ts", + "default": "./dist/core/types.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + ".": [ + "./index.d.ts" + ], + "types": [ + "./dist/types.d.ts" + ], + "utils": [ + "./dist/utils.d.ts" + ], + "runtime": [ + "./dist/runtime/index.d.ts" + ] + } + }, + "files": [ + "index.d.ts", + "virtual.d.ts", + "dist" + ], + "keywords": [ + "withastro", + "astro-integration" + ], + "scripts": { + "types:virtual": "tsc -p ./tsconfig.virtual.json", + "build": "astro-scripts build \"src/**/*.ts\" && tsc && pnpm types:virtual", + "build:ci": "astro-scripts build \"src/**/*.ts\"", + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "astro-scripts test \"test/**/*.test.js\"" + }, + "dependencies": { + "@astrojs/studio": "workspace:*", + "@libsql/client": "^0.15.2", + "async-listen": "^3.1.0", + "deep-diff": "^1.0.2", + "drizzle-orm": "^0.42.0", + "github-slugger": "^2.0.0", + "kleur": "^4.1.5", + "nanoid": "^5.1.5", + "open": "^10.1.0", + "prompts": "^2.4.2", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.1", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/deep-diff": "^1.0.5", + "@types/prompts": "^2.4.9", + "@types/yargs-parser": "^21.0.3", + "astro": "workspace:*", + "astro-scripts": "workspace:*", + "cheerio": "1.0.0", + "typescript": "^5.8.3", + "vite": "^6.3.4" + } +} 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 000000000..053736291 --- /dev/null +++ b/packages/db/src/core/cli/commands/execute/index.ts @@ -0,0 +1,70 @@ +import { existsSync } from 'node:fs'; +import type { AstroConfig } from 'astro'; +import { green } from 'kleur/colors'; +import type { Arguments } from 'yargs-parser'; +import { isDbError } from '../../../../runtime/utils.js'; +import { + EXEC_DEFAULT_EXPORT_ERROR, + EXEC_ERROR, + FILE_NOT_FOUND_ERROR, + MISSING_EXECUTE_PATH_ERROR, +} from '../../../errors.js'; +import { + getLocalVirtualModContents, + getStudioVirtualModContents, +} from '../../../integration/vite-plugin-db.js'; +import { bundleFile, importBundledFile } from '../../../load-file.js'; +import type { DBConfig } from '../../../types.js'; +import { getManagedRemoteToken } from '../../../utils.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); + } + + let virtualModContents: string; + if (flags.remote) { + const appToken = await getManagedRemoteToken(flags.token); + virtualModContents = getStudioVirtualModContents({ + tables: dbConfig.tables ?? {}, + appToken: appToken.token, + isBuild: false, + output: 'server', + }); + } else { + virtualModContents = getLocalVirtualModContents({ + tables: dbConfig.tables ?? {}, + root: astroConfig.root, + }); + } + const { code } = await bundleFile({ virtualModContents, root: astroConfig.root, fileUrl }); + + const mod = await importBundledFile({ code, root: astroConfig.root }); + if (typeof mod.default !== 'function') { + console.error(EXEC_DEFAULT_EXPORT_ERROR(filePath)); + process.exit(1); + } + try { + await mod.default(); + console.info(`${green('✔')} File run successfully.`); + } catch (e) { + if (isDbError(e)) throw new Error(EXEC_ERROR(e.message)); + else throw e; + } +} diff --git a/packages/db/src/core/cli/commands/link/index.ts b/packages/db/src/core/cli/commands/link/index.ts new file mode 100644 index 000000000..4a105df9d --- /dev/null +++ b/packages/db/src/core/cli/commands/link/index.ts @@ -0,0 +1,295 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename } from 'node:path'; +import { + MISSING_SESSION_ID_ERROR, + PROJECT_ID_FILE, + getAstroStudioUrl, + getSessionIdFromFile, +} from '@astrojs/studio'; +import { slug } from 'github-slugger'; +import { bgRed, cyan } from 'kleur/colors'; +import prompts from 'prompts'; +import yoctoSpinner from 'yocto-spinner'; +import { safeFetch } from '../../../../runtime/utils.js'; +import type { Result } from '../../../utils.js'; + +export async function cmd() { + const sessionToken = await getSessionIdFromFile(); + if (!sessionToken) { + console.error(MISSING_SESSION_ID_ERROR); + process.exit(1); + } + await promptBegin(); + const isLinkExisting = await promptLinkExisting(); + if (isLinkExisting) { + const workspaceId = await promptWorkspace(sessionToken); + const existingProjectData = await promptExistingProjectName({ workspaceId }); + return await linkProject(existingProjectData.id); + } + + const isLinkNew = await promptLinkNew(); + if (isLinkNew) { + const workspaceId = await promptWorkspace(sessionToken); + const newProjectName = await promptNewProjectName(); + const newProjectRegion = await promptNewProjectRegion(); + const spinner = yoctoSpinner({ text: 'Creating new project...' }).start(); + const newProjectData = await createNewProject({ + workspaceId, + name: newProjectName, + region: newProjectRegion, + }); + // TODO(fks): Actually listen for project creation before continuing + // This is just a dumb spinner that roughly matches database creation time. + await new Promise((r) => setTimeout(r, 4000)); + spinner.success('Project created!'); + return await linkProject(newProjectData.id); + } +} + +async function linkProject(id: string) { + await mkdir(new URL('.', PROJECT_ID_FILE), { recursive: true }); + await writeFile(PROJECT_ID_FILE, `${id}`); + console.info('Project linked.'); +} + +async function getWorkspaces(sessionToken: string) { + const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/workspaces.list'); + const response = await safeFetch( + linkUrl, + { + method: 'POST', + headers: { + Authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + }, + }, + (res) => { + // Unauthorized + if (res.status === 401) { + throw new Error( + `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( + 'astro login', + )} to authenticate and then try linking again.\n\n`, + ); + } + throw new Error(`Failed to fetch user workspace: ${res.status} ${res.statusText}`); + }, + ); + + const { data, success } = (await response.json()) as Result<{ id: string; name: string }[]>; + if (!success) { + throw new Error(`Failed to fetch user's workspace.`); + } + return data; +} + +/** + * Get the workspace ID to link to. + * Prompts the user to choose if they have more than one workspace in Astro Studio. + * @returns A `Promise` for the workspace ID to use. + */ +async function promptWorkspace(sessionToken: string) { + const workspaces = await getWorkspaces(sessionToken); + if (workspaces.length === 0) { + console.error('No workspaces found.'); + process.exit(1); + } + + if (workspaces.length === 1) { + return workspaces[0].id; + } + + const { workspaceId } = await prompts({ + type: 'autocomplete', + name: 'workspaceId', + message: 'Select your workspace:', + limit: 5, + choices: workspaces.map((w) => ({ title: w.name, value: w.id })), + }); + if (typeof workspaceId !== 'string') { + console.log('Canceled.'); + process.exit(0); + } + return workspaceId; +} + +async function createNewProject({ + workspaceId, + name, + region, +}: { + workspaceId: string; + name: string; + region: string; +}) { + const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/projects.create'); + const response = await safeFetch( + linkUrl, + { + method: 'POST', + headers: { + Authorization: `Bearer ${await getSessionIdFromFile()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workspaceId, name, region }), + }, + (res) => { + // Unauthorized + if (res.status === 401) { + console.error( + `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( + 'astro login', + )} to authenticate and then try linking again.\n\n`, + ); + process.exit(1); + } + console.error(`Failed to create project: ${res.status} ${res.statusText}`); + process.exit(1); + }, + ); + + const { data, success } = (await response.json()) as Result<{ id: string; idName: string }>; + if (!success) { + console.error(`Failed to create project.`); + process.exit(1); + } + return { id: data.id, idName: data.idName }; +} + +async function promptExistingProjectName({ workspaceId }: { workspaceId: string }) { + const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/projects.list'); + const response = await safeFetch( + linkUrl, + { + method: 'POST', + headers: { + Authorization: `Bearer ${await getSessionIdFromFile()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workspaceId }), + }, + (res) => { + if (res.status === 401) { + console.error( + `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( + 'astro login', + )} to authenticate and then try linking again.\n\n`, + ); + process.exit(1); + } + console.error(`Failed to fetch projects: ${res.status} ${res.statusText}`); + process.exit(1); + }, + ); + + const { data, success } = (await response.json()) as Result< + { id: string; name: string; idName: string }[] + >; + if (!success) { + console.error(`Failed to fetch projects.`); + process.exit(1); + } + const { projectId } = await prompts({ + type: 'autocomplete', + name: 'projectId', + message: 'What is your project name?', + limit: 5, + choices: data.map((p) => ({ title: p.name, value: p.id })), + }); + if (typeof projectId !== 'string') { + console.log('Canceled.'); + process.exit(0); + } + const selectedProjectData = data.find((p: any) => p.id === projectId)!; + return selectedProjectData; +} + +async function promptBegin(): Promise<void> { + // Get the current working directory relative to the user's home directory + const prettyCwd = process.cwd().replace(homedir(), '~'); + + // prompt + const { begin } = await prompts({ + type: 'confirm', + name: 'begin', + message: `Link "${prettyCwd}" with Astro Studio?`, + initial: true, + }); + if (!begin) { + console.log('Canceled.'); + process.exit(0); + } +} + +/** + * Ask the user if they want to link to an existing Astro Studio project. + * @returns A `Promise` for the user’s answer: `true` if they answer yes, otherwise `false`. + */ +async function promptLinkExisting(): Promise<boolean> { + // prompt + const { linkExisting } = await prompts({ + type: 'confirm', + name: 'linkExisting', + message: `Link with an existing project in Astro Studio?`, + initial: true, + }); + return !!linkExisting; +} + +/** + * Ask the user if they want to link to a new Astro Studio Project. + * **Exits the process if they answer no.** + * @returns A `Promise` for the user’s answer: `true` if they answer yes. + */ +async function promptLinkNew(): Promise<boolean> { + // prompt + const { linkNew } = await prompts({ + type: 'confirm', + name: 'linkNew', + message: `Create a new project in Astro Studio?`, + initial: true, + }); + if (!linkNew) { + console.log('Canceled.'); + process.exit(0); + } + return true; +} + +async function promptNewProjectName(): Promise<string> { + const { newProjectName } = await prompts({ + type: 'text', + name: 'newProjectName', + message: `What is your new project's name?`, + initial: basename(process.cwd()), + format: (val) => slug(val), + }); + if (!newProjectName) { + console.log('Canceled.'); + process.exit(0); + } + return newProjectName; +} + +async function promptNewProjectRegion(): Promise<string> { + const { newProjectRegion } = await prompts({ + type: 'select', + name: 'newProjectRegion', + message: `Where should your new database live?`, + choices: [ + { title: 'North America (East)', value: 'NorthAmericaEast' }, + { title: 'North America (West)', value: 'NorthAmericaWest' }, + { title: 'Europe (Amsterdam)', value: 'EuropeCentral' }, + { title: 'South America (Brazil)', value: 'SouthAmericaEast' }, + { title: 'Asia (India)', value: 'AsiaSouth' }, + { title: 'Asia (Japan)', value: 'AsiaNorthEast' }, + ], + initial: 0, + }); + if (!newProjectRegion) { + console.log('Canceled.'); + process.exit(0); + } + return newProjectRegion; +} diff --git a/packages/db/src/core/cli/commands/login/index.ts b/packages/db/src/core/cli/commands/login/index.ts new file mode 100644 index 000000000..0b0979384 --- /dev/null +++ b/packages/db/src/core/cli/commands/login/index.ts @@ -0,0 +1,96 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { createServer as _createServer } from 'node:http'; +import { SESSION_LOGIN_FILE, getAstroStudioUrl } from '@astrojs/studio'; +import type { AstroConfig } from 'astro'; +import { listen } from 'async-listen'; +import { cyan } from 'kleur/colors'; +import open from 'open'; +import prompt from 'prompts'; +import type { Arguments } from 'yargs-parser'; +import yoctoSpinner from 'yocto-spinner'; +import type { DBConfig } from '../../../types.js'; + +const isWebContainer = + // Stackblitz heuristic + process.versions?.webcontainer ?? + // GitHub Codespaces heuristic + process.env.CODESPACE_NAME; + +export async function cmd({ + flags, +}: { + astroConfig: AstroConfig; + dbConfig: DBConfig; + flags: Arguments; +}) { + let session = flags.session; + + if (!session && isWebContainer) { + console.log(`Please visit the following URL in your web browser:`); + console.log(cyan(`${getAstroStudioUrl()}/auth/cli/login`)); + console.log(`After login in complete, enter the verification code displayed:`); + const response = await prompt({ + type: 'text', + name: 'session', + message: 'Verification code:', + }); + if (!response.session) { + console.error('Cancelling login.'); + process.exit(0); + } + session = response.session; + console.log('Successfully logged in'); + } else if (!session) { + const { url, promise } = await createServer(); + const loginUrl = new URL('/auth/cli/login', getAstroStudioUrl()); + loginUrl.searchParams.set('returnTo', url); + console.log(`Opening the following URL in your browser...`); + console.log(cyan(loginUrl.href)); + console.log(`If something goes wrong, copy-and-paste the URL into your browser.`); + open(loginUrl.href); + const spinner = yoctoSpinner({ text: 'Waiting for confirmation...' }); + session = await promise; + spinner.success('Successfully logged in'); + } + + await mkdir(new URL('.', SESSION_LOGIN_FILE), { recursive: true }); + await writeFile(SESSION_LOGIN_FILE, `${session}`); +} + +// NOTE(fks): How the Astro CLI login process works: +// 1. The Astro CLI creates a temporary server to listen for the session token +// 2. The user is directed to studio.astro.build/ to login +// 3. The user is redirected back to the temporary server with their session token +// 4. The temporary server receives and saves the session token, logging the user in +// 5. The user is redirected one last time to a success/failure page +async function createServer(): Promise<{ url: string; promise: Promise<string> }> { + let resolve: (value: string | PromiseLike<string>) => void, reject: (reason?: Error) => void; + + const server = _createServer((req, res) => { + // Handle the request + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + const sessionParam = url.searchParams.get('session'); + // Handle the response & resolve the promise + res.statusCode = 302; + if (!sessionParam) { + res.setHeader('location', getAstroStudioUrl() + '/auth/cli/error'); + reject(new Error('Failed to log in')); + } else { + res.setHeader('location', getAstroStudioUrl() + '/auth/cli/success'); + resolve(sessionParam); + } + res.end(); + }); + + const { port } = await listen(server, 0, '127.0.0.1'); + const serverUrl = `http://localhost:${port}`; + const sessionPromise = new Promise<string>((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }).finally(() => { + server.closeAllConnections(); + server.close(); + }); + + return { url: serverUrl, promise: sessionPromise }; +} diff --git a/packages/db/src/core/cli/commands/logout/index.ts b/packages/db/src/core/cli/commands/logout/index.ts new file mode 100644 index 000000000..8b7878659 --- /dev/null +++ b/packages/db/src/core/cli/commands/logout/index.ts @@ -0,0 +1,7 @@ +import { unlink } from 'node:fs/promises'; +import { SESSION_LOGIN_FILE } from '@astrojs/studio'; + +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 new file mode 100644 index 000000000..590d4f06e --- /dev/null +++ b/packages/db/src/core/cli/commands/push/index.ts @@ -0,0 +1,173 @@ +import type { AstroConfig } from 'astro'; +import { sql } from 'drizzle-orm'; +import prompts from 'prompts'; +import type { Arguments } from 'yargs-parser'; +import { createRemoteDatabaseClient } from '../../../../runtime/index.js'; +import { safeFetch } from '../../../../runtime/utils.js'; +import { MIGRATION_VERSION } from '../../../consts.js'; +import type { DBConfig, DBSnapshot } from '../../../types.js'; +import { + type RemoteDatabaseInfo, + type Result, + getManagedRemoteToken, + getRemoteDatabaseInfo, +} from '../../../utils.js'; +import { + createCurrentSnapshot, + createEmptySnapshot, + formatDataLossMessage, + getMigrationQueries, + getProductionCurrentSnapshot, +} from '../../migration-queries.js'; + +export async function cmd({ + dbConfig, + flags, +}: { + astroConfig: AstroConfig; + dbConfig: DBConfig; + flags: Arguments; +}) { + const isDryRun = flags.dryRun; + const isForceReset = flags.forceReset; + const dbInfo = getRemoteDatabaseInfo(); + const appToken = await getManagedRemoteToken(flags.token, dbInfo); + const productionSnapshot = await getProductionCurrentSnapshot({ + dbInfo, + appToken: appToken.token, + }); + const currentSnapshot = createCurrentSnapshot(dbConfig); + const isFromScratch = !productionSnapshot; + const { queries: migrationQueries, confirmations } = await getMigrationQueries({ + oldSnapshot: isFromScratch ? createEmptySnapshot() : productionSnapshot, + newSnapshot: currentSnapshot, + reset: isForceReset, + }); + + // // push the database schema + if (migrationQueries.length === 0) { + console.log('Database schema is up to date.'); + } else { + console.log(`Database schema is out of date.`); + } + + if (isForceReset) { + const { begin } = await prompts({ + type: 'confirm', + name: 'begin', + message: `Reset your database? All of your data will be erased and your schema created from scratch.`, + initial: false, + }); + + if (!begin) { + console.log('Canceled.'); + process.exit(0); + } + + console.log(`Force-pushing to the database. All existing data will be erased.`); + } else if (confirmations.length > 0) { + console.log('\n' + formatDataLossMessage(confirmations) + '\n'); + throw new Error('Exiting.'); + } + + if (isDryRun) { + console.log('Statements:', JSON.stringify(migrationQueries, undefined, 2)); + } else { + console.log(`Pushing database schema updates...`); + await pushSchema({ + statements: migrationQueries, + dbInfo, + appToken: appToken.token, + isDryRun, + currentSnapshot: currentSnapshot, + }); + } + // cleanup and exit + await appToken.destroy(); + console.info('Push complete!'); +} + +async function pushSchema({ + statements, + dbInfo, + appToken, + isDryRun, + currentSnapshot, +}: { + statements: string[]; + dbInfo: RemoteDatabaseInfo; + appToken: string; + isDryRun: boolean; + currentSnapshot: DBSnapshot; +}) { + const requestBody: RequestBody = { + snapshot: currentSnapshot, + sql: statements, + version: MIGRATION_VERSION, + }; + if (isDryRun) { + console.info('[DRY RUN] Batch query:', JSON.stringify(requestBody, null, 2)); + return new Response(null, { status: 200 }); + } + + return dbInfo.type === 'studio' + ? pushToStudio(requestBody, appToken, dbInfo.url) + : pushToDb(requestBody, appToken, dbInfo.url); +} + +type RequestBody = { + snapshot: DBSnapshot; + sql: string[]; + version: string; +}; + +async function pushToDb(requestBody: RequestBody, appToken: string, remoteUrl: string) { + const client = createRemoteDatabaseClient({ + dbType: 'libsql', + appToken, + remoteUrl, + }); + + await client.run(sql`create table if not exists _astro_db_snapshot ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT, + snapshot BLOB + );`); + + await client.transaction(async (tx) => { + for (const stmt of requestBody.sql) { + await tx.run(sql.raw(stmt)); + } + + await tx.run(sql`insert into _astro_db_snapshot (version, snapshot) values ( + ${requestBody.version}, + ${JSON.stringify(requestBody.snapshot)} + )`); + }); +} + +async function pushToStudio(requestBody: RequestBody, appToken: string, remoteUrl: string) { + const url = new URL('/db/push', remoteUrl); + const response = await safeFetch( + url, + { + method: 'POST', + headers: new Headers({ + Authorization: `Bearer ${appToken}`, + }), + body: JSON.stringify(requestBody), + }, + async (res) => { + console.error(`${url.toString()} failed: ${res.status} ${res.statusText}`); + console.error(await res.text()); + throw new Error(`/db/push fetch failed: ${res.status} ${res.statusText}`); + }, + ); + + const result = (await response.json()) as Result<never>; + if (!result.success) { + console.error(`${url.toString()} unsuccessful`); + console.error(await response.text()); + throw new Error(`/db/push fetch unsuccessful`); + } +} diff --git a/packages/db/src/core/cli/commands/shell/index.ts b/packages/db/src/core/cli/commands/shell/index.ts new file mode 100644 index 000000000..dcc54fc70 --- /dev/null +++ b/packages/db/src/core/cli/commands/shell/index.ts @@ -0,0 +1,48 @@ +import type { AstroConfig } from 'astro'; +import { sql } from 'drizzle-orm'; +import type { Arguments } from 'yargs-parser'; +import { + createLocalDatabaseClient, + createRemoteDatabaseClient, +} from '../../../../runtime/db-client.js'; +import { normalizeDatabaseUrl } from '../../../../runtime/index.js'; +import { DB_PATH } from '../../../consts.js'; +import { SHELL_QUERY_MISSING_ERROR } from '../../../errors.js'; +import type { DBConfigInput } from '../../../types.js'; +import { getAstroEnv, getManagedRemoteToken, getRemoteDatabaseInfo } from '../../../utils.js'; + +export async function cmd({ + flags, + astroConfig, +}: { + dbConfig: DBConfigInput; + astroConfig: AstroConfig; + flags: Arguments; +}) { + const query = flags.query; + if (!query) { + console.error(SHELL_QUERY_MISSING_ERROR); + process.exit(1); + } + const dbInfo = getRemoteDatabaseInfo(); + if (flags.remote) { + const appToken = await getManagedRemoteToken(flags.token, dbInfo); + const db = createRemoteDatabaseClient({ + dbType: dbInfo.type, + remoteUrl: dbInfo.url, + appToken: appToken.token, + }); + const result = await db.run(sql.raw(query)); + await appToken.destroy(); + console.log(result); + } else { + const { ASTRO_DATABASE_FILE } = getAstroEnv(); + const dbUrl = normalizeDatabaseUrl( + ASTRO_DATABASE_FILE, + new URL(DB_PATH, astroConfig.root).href, + ); + const db = createLocalDatabaseClient({ dbUrl, enableTransactions: dbInfo.type === 'libsql' }); + const result = await db.run(sql.raw(query)); + console.log(result); + } +} diff --git a/packages/db/src/core/cli/commands/verify/index.ts b/packages/db/src/core/cli/commands/verify/index.ts new file mode 100644 index 000000000..35f489a80 --- /dev/null +++ b/packages/db/src/core/cli/commands/verify/index.ts @@ -0,0 +1,58 @@ +import type { AstroConfig } from 'astro'; +import type { Arguments } from 'yargs-parser'; +import type { DBConfig } from '../../../types.js'; +import { getManagedRemoteToken, getRemoteDatabaseInfo } from '../../../utils.js'; +import { + createCurrentSnapshot, + createEmptySnapshot, + formatDataLossMessage, + getMigrationQueries, + getProductionCurrentSnapshot, +} from '../../migration-queries.js'; + +export async function cmd({ + dbConfig, + flags, +}: { + astroConfig: AstroConfig; + dbConfig: DBConfig; + flags: Arguments; +}) { + const isJson = flags.json; + const dbInfo = getRemoteDatabaseInfo(); + const appToken = await getManagedRemoteToken(flags.token, dbInfo); + const productionSnapshot = await getProductionCurrentSnapshot({ + dbInfo, + appToken: appToken.token, + }); + const currentSnapshot = createCurrentSnapshot(dbConfig); + const { queries: migrationQueries, confirmations } = await getMigrationQueries({ + oldSnapshot: productionSnapshot || createEmptySnapshot(), + newSnapshot: currentSnapshot, + }); + + const result = { exitCode: 0, message: '', code: '', data: undefined as unknown }; + if (migrationQueries.length === 0) { + result.code = 'MATCH'; + result.message = `Database schema is up to date.`; + } else { + result.code = 'NO_MATCH'; + result.message = `Database schema is out of date.\nRun 'astro db push' to push up your latest changes.`; + } + + if (confirmations.length > 0) { + result.code = 'DATA_LOSS'; + result.exitCode = 1; + result.data = confirmations; + result.message = formatDataLossMessage(confirmations, !isJson); + } + + if (isJson) { + console.log(JSON.stringify(result)); + } else { + console.log(result.message); + } + + await appToken.destroy(); + process.exit(result.exitCode); +} diff --git a/packages/db/src/core/cli/index.ts b/packages/db/src/core/cli/index.ts new file mode 100644 index 000000000..531b016a6 --- /dev/null +++ b/packages/db/src/core/cli/index.ts @@ -0,0 +1,82 @@ +import type { AstroConfig } from 'astro'; +import type { Arguments } from 'yargs-parser'; +import { resolveDbConfig } from '../load-file.js'; +import { printHelp } from './print-help.js'; + +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]; + const { dbConfig } = await resolveDbConfig(astroConfig); + + switch (command) { + case 'shell': { + const { cmd } = await import('./commands/shell/index.js'); + return await cmd({ astroConfig, dbConfig, flags }); + } + case 'gen': { + console.log('"astro db gen" is no longer needed! Visit the docs for more information.'); + return; + } + case 'sync': { + console.log('"astro db sync" is no longer needed! Visit the docs for more information.'); + return; + } + case 'push': { + const { cmd } = await import('./commands/push/index.js'); + return await cmd({ astroConfig, dbConfig, flags }); + } + case 'verify': { + const { cmd } = await import('./commands/verify/index.js'); + 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(); + } + default: { + if (command != null) { + console.error(`Unknown command: ${command}`); + } + printHelp({ + commandName: 'astro db', + usage: '[command] [...flags]', + headline: ' ', + tables: { + Commands: [ + ['push', 'Push table schema updates to Astro Studio.'], + ['verify', 'Test schema updates /w Astro Studio (good for CI).'], + [ + 'astro db execute <file-path>', + 'Execute a ts/js file using astro:db. Use --remote to connect to Studio.', + ], + [ + 'astro db shell --query <sql-string>', + 'Execute a SQL string. Use --remote to connect to Studio.', + ], + ], + }, + }); + return; + } + } +} diff --git a/packages/db/src/core/cli/migration-queries.ts b/packages/db/src/core/cli/migration-queries.ts new file mode 100644 index 000000000..db3972d09 --- /dev/null +++ b/packages/db/src/core/cli/migration-queries.ts @@ -0,0 +1,539 @@ +import { stripVTControlCharacters } from 'node:util'; +import deepDiff from 'deep-diff'; +import { sql } from 'drizzle-orm'; +import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; +import * as color from 'kleur/colors'; +import { customAlphabet } from 'nanoid'; +import { hasPrimaryKey } from '../../runtime/index.js'; +import { createRemoteDatabaseClient } from '../../runtime/index.js'; +import { isSerializedSQL } from '../../runtime/types.js'; +import { isDbError, safeFetch } from '../../runtime/utils.js'; +import { MIGRATION_VERSION } from '../consts.js'; +import { RENAME_COLUMN_ERROR, RENAME_TABLE_ERROR } from '../errors.js'; +import { + getCreateIndexQueries, + getCreateTableQuery, + getDropTableIfExistsQuery, + getModifiers, + getReferencesConfig, + hasDefault, + schemaTypeToSqlType, +} from '../queries.js'; +import { columnSchema } from '../schemas.js'; +import type { + BooleanColumn, + ColumnType, + DBColumn, + DBColumns, + DBConfig, + DBSnapshot, + DateColumn, + JsonColumn, + NumberColumn, + ResolvedDBTable, + ResolvedDBTables, + ResolvedIndexes, + TextColumn, +} from '../types.js'; +import type { RemoteDatabaseInfo, Result } from '../utils.js'; + +const sqlite = new SQLiteAsyncDialect(); +const genTempTableName = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10); + +export async function getMigrationQueries({ + oldSnapshot, + newSnapshot, + reset = false, +}: { + oldSnapshot: DBSnapshot; + newSnapshot: DBSnapshot; + reset?: boolean; +}): Promise<{ queries: string[]; confirmations: string[] }> { + const queries: string[] = []; + const confirmations: string[] = []; + + // When doing a reset, first create DROP TABLE statements, then treat everything + // else as creation. + if (reset) { + const currentSnapshot = oldSnapshot; + oldSnapshot = createEmptySnapshot(); + queries.push(...getDropTableQueriesForSnapshot(currentSnapshot)); + } + + const addedTables = getAddedTables(oldSnapshot, newSnapshot); + const droppedTables = getDroppedTables(oldSnapshot, newSnapshot); + const notDeprecatedDroppedTables = Object.fromEntries( + Object.entries(droppedTables).filter(([, table]) => !table.deprecated), + ); + if (!isEmpty(addedTables) && !isEmpty(notDeprecatedDroppedTables)) { + const oldTable = Object.keys(notDeprecatedDroppedTables)[0]; + const newTable = Object.keys(addedTables)[0]; + throw new Error(RENAME_TABLE_ERROR(oldTable, newTable)); + } + + for (const [tableName, table] of Object.entries(addedTables)) { + queries.push(getCreateTableQuery(tableName, table)); + queries.push(...getCreateIndexQueries(tableName, table)); + } + + for (const [tableName] of Object.entries(droppedTables)) { + const dropQuery = `DROP TABLE ${sqlite.escapeName(tableName)}`; + queries.push(dropQuery); + } + + for (const [tableName, newTable] of Object.entries(newSnapshot.schema)) { + const oldTable = oldSnapshot.schema[tableName]; + if (!oldTable) continue; + const addedColumns = getAdded(oldTable.columns, newTable.columns); + const droppedColumns = getDropped(oldTable.columns, newTable.columns); + const notDeprecatedDroppedColumns = Object.fromEntries( + Object.entries(droppedColumns).filter(([, col]) => !col.schema.deprecated), + ); + if (!isEmpty(addedColumns) && !isEmpty(notDeprecatedDroppedColumns)) { + throw new Error( + RENAME_COLUMN_ERROR( + `${tableName}.${Object.keys(addedColumns)[0]}`, + `${tableName}.${Object.keys(notDeprecatedDroppedColumns)[0]}`, + ), + ); + } + const result = await getTableChangeQueries({ + tableName, + oldTable, + newTable, + }); + queries.push(...result.queries); + confirmations.push(...result.confirmations); + } + return { queries, confirmations }; +} + +export async function getTableChangeQueries({ + tableName, + oldTable, + newTable, +}: { + tableName: string; + oldTable: ResolvedDBTable; + newTable: ResolvedDBTable; +}): Promise<{ queries: string[]; confirmations: string[] }> { + const queries: string[] = []; + const confirmations: string[] = []; + const updated = getUpdatedColumns(oldTable.columns, newTable.columns); + const added = getAdded(oldTable.columns, newTable.columns); + const dropped = getDropped(oldTable.columns, newTable.columns); + /** Any foreign key changes require a full table recreate */ + const hasForeignKeyChanges = Boolean(deepDiff(oldTable.foreignKeys, newTable.foreignKeys)); + + if (!hasForeignKeyChanges && isEmpty(updated) && isEmpty(added) && isEmpty(dropped)) { + return { + queries: getChangeIndexQueries({ + tableName, + oldIndexes: oldTable.indexes, + newIndexes: newTable.indexes, + }), + confirmations, + }; + } + + if ( + !hasForeignKeyChanges && + isEmpty(updated) && + Object.values(dropped).every(canAlterTableDropColumn) && + Object.values(added).every(canAlterTableAddColumn) + ) { + queries.push( + ...getAlterTableQueries(tableName, added, dropped), + ...getChangeIndexQueries({ + tableName, + oldIndexes: oldTable.indexes, + newIndexes: newTable.indexes, + }), + ); + return { queries, confirmations }; + } + + const dataLossCheck = canRecreateTableWithoutDataLoss(added, updated); + if (dataLossCheck.dataLoss) { + const { reason, columnName } = dataLossCheck; + const reasonMsgs: Record<DataLossReason, string> = { + 'added-required': `You added new required column '${color.bold( + tableName + '.' + columnName, + )}' with no default value.\n This cannot be executed on an existing table.`, + 'updated-type': `Updating existing column ${color.bold( + tableName + '.' + columnName, + )} to a new type that cannot be handled automatically.`, + }; + confirmations.push(reasonMsgs[reason]); + } + + const primaryKeyExists = Object.entries(newTable.columns).find(([, column]) => + hasPrimaryKey(column), + ); + const droppedPrimaryKey = Object.entries(dropped).find(([, column]) => hasPrimaryKey(column)); + + const recreateTableQueries = getRecreateTableQueries({ + tableName, + newTable, + added, + hasDataLoss: dataLossCheck.dataLoss, + migrateHiddenPrimaryKey: !primaryKeyExists && !droppedPrimaryKey, + }); + queries.push(...recreateTableQueries, ...getCreateIndexQueries(tableName, newTable)); + return { queries, confirmations }; +} + +function getChangeIndexQueries({ + tableName, + oldIndexes = {}, + newIndexes = {}, +}: { + tableName: string; + oldIndexes?: ResolvedIndexes; + newIndexes?: ResolvedIndexes; +}) { + const added = getAdded(oldIndexes, newIndexes); + const dropped = getDropped(oldIndexes, newIndexes); + const updated = getUpdated(oldIndexes, newIndexes); + + Object.assign(dropped, updated); + Object.assign(added, updated); + + const queries: string[] = []; + for (const indexName of Object.keys(dropped)) { + const dropQuery = `DROP INDEX ${sqlite.escapeName(indexName)}`; + queries.push(dropQuery); + } + queries.push(...getCreateIndexQueries(tableName, { indexes: added })); + return queries; +} + +function getAddedTables(oldTables: DBSnapshot, newTables: DBSnapshot): ResolvedDBTables { + const added: ResolvedDBTables = {}; + for (const [key, newTable] of Object.entries(newTables.schema)) { + if (!(key in oldTables.schema)) added[key] = newTable; + } + return added; +} + +function getDroppedTables(oldTables: DBSnapshot, newTables: DBSnapshot): ResolvedDBTables { + const dropped: ResolvedDBTables = {}; + for (const [key, oldTable] of Object.entries(oldTables.schema)) { + if (!(key in newTables.schema)) dropped[key] = oldTable; + } + return dropped; +} + +/** + * Get ALTER TABLE queries to update the table schema. Assumes all added and dropped columns pass + * `canUseAlterTableAddColumn` and `canAlterTableDropColumn` checks! + */ +function getAlterTableQueries( + unescTableName: string, + added: DBColumns, + dropped: DBColumns, +): string[] { + const queries: string[] = []; + const tableName = sqlite.escapeName(unescTableName); + + for (const [unescColumnName, column] of Object.entries(added)) { + const columnName = sqlite.escapeName(unescColumnName); + const type = schemaTypeToSqlType(column.type); + const q = `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${type}${getModifiers( + columnName, + column, + )}`; + queries.push(q); + } + + for (const unescColumnName of Object.keys(dropped)) { + const columnName = sqlite.escapeName(unescColumnName); + const q = `ALTER TABLE ${tableName} DROP COLUMN ${columnName}`; + queries.push(q); + } + + return queries; +} + +function getRecreateTableQueries({ + tableName: unescTableName, + newTable, + added, + hasDataLoss, + migrateHiddenPrimaryKey, +}: { + tableName: string; + newTable: ResolvedDBTable; + added: Record<string, DBColumn>; + hasDataLoss: boolean; + migrateHiddenPrimaryKey: boolean; +}): string[] { + const unescTempName = `${unescTableName}_${genTempTableName()}`; + const tempName = sqlite.escapeName(unescTempName); + const tableName = sqlite.escapeName(unescTableName); + + if (hasDataLoss) { + return [`DROP TABLE ${tableName}`, getCreateTableQuery(unescTableName, newTable)]; + } + const newColumns = [...Object.keys(newTable.columns)]; + if (migrateHiddenPrimaryKey) { + newColumns.unshift('_id'); + } + const escapedColumns = newColumns + .filter((i) => !(i in added)) + .map((c) => sqlite.escapeName(c)) + .join(', '); + + return [ + getCreateTableQuery(unescTempName, newTable), + `INSERT INTO ${tempName} (${escapedColumns}) SELECT ${escapedColumns} FROM ${tableName}`, + `DROP TABLE ${tableName}`, + `ALTER TABLE ${tempName} RENAME TO ${tableName}`, + ]; +} + +function isEmpty(obj: Record<string, unknown>) { + return Object.keys(obj).length === 0; +} + +/** + * ADD COLUMN is preferred for O(1) table updates, but is only supported for _some_ column + * definitions. + * + * @see https://www.sqlite.org/lang_altertable.html#alter_table_add_column + */ +function canAlterTableAddColumn(column: DBColumn) { + if (column.schema.unique) return false; + if (hasRuntimeDefault(column)) return false; + if (!column.schema.optional && !hasDefault(column)) return false; + if (hasPrimaryKey(column)) return false; + if (getReferencesConfig(column)) return false; + return true; +} + +function canAlterTableDropColumn(column: DBColumn) { + if (column.schema.unique) return false; + if (hasPrimaryKey(column)) return false; + return true; +} + +type DataLossReason = 'added-required' | 'updated-type'; +type DataLossResponse = + | { dataLoss: false } + | { dataLoss: true; columnName: string; reason: DataLossReason }; + +function canRecreateTableWithoutDataLoss( + added: DBColumns, + updated: UpdatedColumns, +): DataLossResponse { + for (const [columnName, a] of Object.entries(added)) { + if (hasPrimaryKey(a) && a.type !== 'number' && !hasDefault(a)) { + return { dataLoss: true, columnName, reason: 'added-required' }; + } + if (!a.schema.optional && !hasDefault(a)) { + return { dataLoss: true, columnName, reason: 'added-required' }; + } + } + for (const [columnName, u] of Object.entries(updated)) { + if (u.old.type !== u.new.type && !canChangeTypeWithoutQuery(u.old, u.new)) { + return { dataLoss: true, columnName, reason: 'updated-type' }; + } + } + return { dataLoss: false }; +} + +function getAdded<T>(oldObj: Record<string, T>, newObj: Record<string, T>) { + const added: Record<string, T> = {}; + for (const [key, value] of Object.entries(newObj)) { + if (!(key in oldObj)) added[key] = value; + } + return added; +} + +function getDropped<T>(oldObj: Record<string, T>, newObj: Record<string, T>) { + const dropped: Record<string, T> = {}; + for (const [key, value] of Object.entries(oldObj)) { + if (!(key in newObj)) dropped[key] = value; + } + return dropped; +} + +function getUpdated<T>(oldObj: Record<string, T>, newObj: Record<string, T>) { + const updated: Record<string, T> = {}; + for (const [key, value] of Object.entries(newObj)) { + const oldValue = oldObj[key]; + if (!oldValue) continue; + if (deepDiff(oldValue, value)) updated[key] = value; + } + return updated; +} + +type UpdatedColumns = Record<string, { old: DBColumn; new: DBColumn }>; + +function getUpdatedColumns(oldColumns: DBColumns, newColumns: DBColumns): UpdatedColumns { + const updated: UpdatedColumns = {}; + for (const [key, newColumn] of Object.entries(newColumns)) { + let oldColumn = oldColumns[key]; + if (!oldColumn) continue; + + if (oldColumn.type !== newColumn.type && canChangeTypeWithoutQuery(oldColumn, newColumn)) { + // If we can safely change the type without a query, + // try parsing the old schema as the new schema. + // This lets us diff the columns as if they were the same type. + const asNewColumn = columnSchema.safeParse({ + type: newColumn.type, + schema: oldColumn.schema, + }); + if (asNewColumn.success) { + oldColumn = asNewColumn.data; + } + // If parsing fails, move on to the standard diff. + } + + const diff = deepDiff(oldColumn, newColumn); + + if (diff) { + updated[key] = { old: oldColumn, new: newColumn }; + } + } + return updated; +} +const typeChangesWithoutQuery: Array<{ from: ColumnType; to: ColumnType }> = [ + { from: 'boolean', to: 'number' }, + { from: 'date', to: 'text' }, + { from: 'json', to: 'text' }, +]; + +function canChangeTypeWithoutQuery(oldColumn: DBColumn, newColumn: DBColumn) { + return typeChangesWithoutQuery.some( + ({ from, to }) => oldColumn.type === from && newColumn.type === to, + ); +} + +// Using `DBColumn` will not narrow `default` based on the column `type` +// Handle each column separately +type WithDefaultDefined<T extends DBColumn> = T & Required<Pick<T['schema'], 'default'>>; +type DBColumnWithDefault = + | WithDefaultDefined<TextColumn> + | WithDefaultDefined<DateColumn> + | WithDefaultDefined<NumberColumn> + | WithDefaultDefined<BooleanColumn> + | WithDefaultDefined<JsonColumn>; + +function hasRuntimeDefault(column: DBColumn): column is DBColumnWithDefault { + return !!(column.schema.default && isSerializedSQL(column.schema.default)); +} + +export function getProductionCurrentSnapshot(options: { + dbInfo: RemoteDatabaseInfo; + appToken: string; +}): Promise<DBSnapshot | undefined> { + return options.dbInfo.type === 'studio' + ? getStudioCurrentSnapshot(options.appToken, options.dbInfo.url) + : getDbCurrentSnapshot(options.appToken, options.dbInfo.url); +} + +async function getDbCurrentSnapshot( + appToken: string, + remoteUrl: string, +): Promise<DBSnapshot | undefined> { + const client = createRemoteDatabaseClient({ + dbType: 'libsql', + appToken, + remoteUrl, + }); + + try { + const res = await client.get<{ snapshot: string }>( + // Latest snapshot + sql`select snapshot from _astro_db_snapshot order by id desc limit 1;`, + ); + + return JSON.parse(res.snapshot); + } catch (error) { + // Don't handle errors that are not from libSQL + if ( + isDbError(error) && + // If the schema was never pushed to the database yet the table won't exist. + // Treat a missing snapshot table as an empty table. + + // When connecting to a remote database in that condition + // the query will fail with the following error code and message. + ((error.code === 'SQLITE_UNKNOWN' && + error.message === 'SQLITE_UNKNOWN: SQLite error: no such table: _astro_db_snapshot') || + // When connecting to a local or in-memory database that does not have a snapshot table yet + // the query will fail with the following error code and message. + (error.code === 'SQLITE_ERROR' && + error.message === 'SQLITE_ERROR: no such table: _astro_db_snapshot')) + ) { + return; + } + + throw error; + } +} + +async function getStudioCurrentSnapshot( + appToken: string, + remoteUrl: string, +): Promise<DBSnapshot | undefined> { + const url = new URL('/db/schema', remoteUrl); + + const response = await safeFetch( + url, + { + method: 'POST', + headers: new Headers({ + Authorization: `Bearer ${appToken}`, + }), + }, + async (res) => { + console.error(`${url.toString()} failed: ${res.status} ${res.statusText}`); + console.error(await res.text()); + throw new Error(`/db/schema fetch failed: ${res.status} ${res.statusText}`); + }, + ); + + const result = (await response.json()) as Result<DBSnapshot>; + if (!result.success) { + console.error(`${url.toString()} unsuccessful`); + console.error(await response.text()); + throw new Error(`/db/schema fetch unsuccessful`); + } + return result.data; +} + +function getDropTableQueriesForSnapshot(snapshot: DBSnapshot) { + const queries = []; + for (const tableName of Object.keys(snapshot.schema)) { + const dropQuery = getDropTableIfExistsQuery(tableName); + queries.unshift(dropQuery); + } + return queries; +} + +export function createCurrentSnapshot({ tables = {} }: DBConfig): DBSnapshot { + const schema = JSON.parse(JSON.stringify(tables)); + return { version: MIGRATION_VERSION, schema }; +} + +export function createEmptySnapshot(): DBSnapshot { + return { version: MIGRATION_VERSION, schema: {} }; +} + +export function formatDataLossMessage(confirmations: string[], isColor = true): string { + const messages = []; + messages.push(color.red('✖ We found some schema changes that cannot be handled automatically:')); + messages.push(``); + messages.push(...confirmations.map((m, i) => color.red(` (${i + 1}) `) + m)); + messages.push(``); + messages.push(`To resolve, revert these changes or update your schema, and re-run the command.`); + messages.push( + `You may also run 'astro db push --force-reset' to ignore all warnings and force-push your local database schema to production instead. All data will be lost and the database will be reset.`, + ); + let finalMessage = messages.join('\n'); + if (!isColor) { + finalMessage = stripVTControlCharacters(finalMessage); + } + return finalMessage; +} diff --git a/packages/db/src/core/cli/print-help.ts b/packages/db/src/core/cli/print-help.ts new file mode 100644 index 000000000..4082380b2 --- /dev/null +++ b/packages/db/src/core/cli/print-help.ts @@ -0,0 +1,69 @@ +import { bgGreen, bgWhite, black, bold, dim, green } from 'kleur/colors'; + +/** + * Uses implementation from Astro core + * @see https://github.com/withastro/astro/blob/main/packages/astro/src/core/messages.ts#L303 + */ +export function printHelp({ + commandName, + headline, + usage, + tables, + description, +}: { + commandName: string; + headline?: string; + usage?: string; + tables?: Record<string, [command: string, help: string][]>; + description?: string; +}) { + const linebreak = () => ''; + const title = (label: string) => ` ${bgWhite(black(` ${label} `))}`; + const table = (rows: [string, string][], { padding }: { padding: number }) => { + const split = process.stdout.columns < 60; + let raw = ''; + + for (const row of rows) { + if (split) { + raw += ` ${row[0]}\n `; + } else { + raw += `${`${row[0]}`.padStart(padding)}`; + } + raw += ' ' + dim(row[1]) + '\n'; + } + + return raw.slice(0, -1); // remove latest \n + }; + + let message = []; + + if (headline) { + message.push( + linebreak(), + ` ${bgGreen(black(` ${commandName} `))} ${green( + `v${process.env.PACKAGE_VERSION ?? ''}`, + )} ${headline}`, + ); + } + + if (usage) { + message.push(linebreak(), ` ${green(commandName)} ${bold(usage)}`); + } + + if (tables) { + function calculateTablePadding(rows: [string, string][]) { + return rows.reduce((val, [first]) => Math.max(val, first.length), 0) + 2; + } + const tableEntries = Object.entries(tables); + const padding = Math.max(...tableEntries.map(([, rows]) => calculateTablePadding(rows))); + for (const [tableTitle, tableRows] of tableEntries) { + message.push(linebreak(), title(tableTitle), table(tableRows, { padding })); + } + } + + if (description) { + message.push(linebreak(), `${description}`); + } + + console.log(message.join('\n') + '\n'); +} diff --git a/packages/db/src/core/consts.ts b/packages/db/src/core/consts.ts new file mode 100644 index 000000000..8b8ccaf2d --- /dev/null +++ b/packages/db/src/core/consts.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; + +const PACKAGE_NAME = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +).name; + +export const RUNTIME_IMPORT = JSON.stringify(`${PACKAGE_NAME}/runtime`); + +export const RUNTIME_VIRTUAL_IMPORT = JSON.stringify(`${PACKAGE_NAME}/dist/runtime/virtual.js`); + +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']; + +export const MIGRATION_VERSION = '2024-03-12'; diff --git a/packages/db/src/core/errors.ts b/packages/db/src/core/errors.ts new file mode 100644 index 000000000..5272fd3c2 --- /dev/null +++ b/packages/db/src/core/errors.ts @@ -0,0 +1,50 @@ +import { bold, cyan, red } from 'kleur/colors'; + +export const MISSING_EXECUTE_PATH_ERROR = `${red( + '▶ No file path provided.', +)} Provide a path by running ${cyan('astro db execute <path>')}\n`; + +export const RENAME_TABLE_ERROR = (oldTable: string, newTable: string) => { + return ( + red('\u25B6 Potential table rename detected: ' + oldTable + ' -> ' + newTable) + + ` + You cannot add and remove tables in the same schema update batch. + + 1. Use "deprecated: true" to deprecate a table before renaming. + 2. Use "--force-reset" to ignore this warning and reset the database (deleting all of your data). + + Visit https://docs.astro.build/en/guides/astro-db/#renaming-tables to learn more.` + ); +}; + +export const RENAME_COLUMN_ERROR = (oldSelector: string, newSelector: string) => { + return ( + red('▶ Potential column rename detected: ' + oldSelector + ', ' + newSelector) + + `\n You cannot add and remove columns in the same table.` + + `\n To resolve, add a 'deprecated: true' flag to '${oldSelector}' instead.` + ); +}; + +export const FILE_NOT_FOUND_ERROR = (path: string) => `${red('▶ File not found:')} ${bold(path)}\n`; + +export const SHELL_QUERY_MISSING_ERROR = `${red( + '▶ Please provide a query to execute using the --query flag.', +)}\n`; + +export const EXEC_ERROR = (error: string) => { + return `${red(`Error while executing file:`)}\n\n${error}`; +}; + +export const EXEC_DEFAULT_EXPORT_ERROR = (fileName: string) => { + return EXEC_ERROR(`Missing default function export in ${bold(fileName)}`); +}; + +export const INTEGRATION_TABLE_CONFLICT_ERROR = ( + integrationName: string, + tableName: string, + isUserConflict: boolean, +) => { + return red('▶ Conflicting table name in integration ' + bold(integrationName)) + isUserConflict + ? `\n A user-defined table named ${bold(tableName)} already exists` + : `\n Another integration already added a table named ${bold(tableName)}`; +}; diff --git a/packages/db/src/core/integration/error-map.ts b/packages/db/src/core/integration/error-map.ts new file mode 100644 index 000000000..d2697c9ca --- /dev/null +++ b/packages/db/src/core/integration/error-map.ts @@ -0,0 +1,104 @@ +/** + * This is a modified version of Astro's error map. source: + * https://github.com/withastro/astro/blob/main/packages/astro/src/content/error-map.ts + */ +import type { z } from 'astro/zod'; + +interface TypeOrLiteralErrByPathEntry { + code: 'invalid_type' | 'invalid_literal'; + received: unknown; + expected: unknown[]; +} + +export const errorMap: z.ZodErrorMap = (baseError, ctx) => { + const baseErrorPath = flattenErrorPath(baseError.path); + if (baseError.code === 'invalid_union') { + // Optimization: Combine type and literal errors for keys that are common across ALL union types + // Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will + // raise a single error when `key` does not match: + // > Did not match union. + // > key: Expected `'tutorial' | 'blog'`, received 'foo' + const typeOrLiteralErrByPath = new Map<string, TypeOrLiteralErrByPathEntry>(); + for (const unionError of baseError.unionErrors.flatMap((e) => e.errors)) { + if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') { + const flattenedErrorPath = flattenErrorPath(unionError.path); + const typeOrLiteralErr = typeOrLiteralErrByPath.get(flattenedErrorPath); + if (typeOrLiteralErr) { + typeOrLiteralErr.expected.push(unionError.expected); + } else { + typeOrLiteralErrByPath.set(flattenedErrorPath, { + code: unionError.code, + received: (unionError as any).received, + expected: [unionError.expected], + }); + } + } + } + const messages: string[] = [ + prefix( + baseErrorPath, + typeOrLiteralErrByPath.size ? 'Did not match union:' : 'Did not match union.', + ), + ]; + return { + message: messages + .concat( + [...typeOrLiteralErrByPath.entries()] + // If type or literal error isn't common to ALL union types, + // filter it out. Can lead to confusing noise. + .filter(([, error]) => error.expected.length === baseError.unionErrors.length) + .map(([key, error]) => + // Avoid printing the key again if it's a base error + key === baseErrorPath + ? `> ${getTypeOrLiteralMsg(error)}` + : `> ${prefix(key, getTypeOrLiteralMsg(error))}`, + ), + ) + .join('\n'), + }; + } + if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') { + return { + message: prefix( + baseErrorPath, + getTypeOrLiteralMsg({ + code: baseError.code, + received: (baseError as any).received, + expected: [baseError.expected], + }), + ), + }; + } else if (baseError.message) { + return { message: prefix(baseErrorPath, baseError.message) }; + } else { + return { message: prefix(baseErrorPath, ctx.defaultError) }; + } +}; + +const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => { + if (error.received === 'undefined') return 'Required'; + const expectedDeduped = new Set(error.expected); + switch (error.code) { + case 'invalid_type': + return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify( + error.received, + )}`; + case 'invalid_literal': + return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify( + error.received, + )}`; + } +}; + +const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg); + +const unionExpectedVals = (expectedVals: Set<unknown>) => + [...expectedVals] + .map((expectedVal, idx) => { + if (idx === 0) return JSON.stringify(expectedVal); + const sep = ' | '; + return `${sep}${JSON.stringify(expectedVal)}`; + }) + .join(''); + +const flattenErrorPath = (errorPath: Array<string | number>) => errorPath.join('.'); diff --git a/packages/db/src/core/integration/file-url.ts b/packages/db/src/core/integration/file-url.ts new file mode 100644 index 000000000..76ce70cb9 --- /dev/null +++ b/packages/db/src/core/integration/file-url.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { AstroConfig, AstroIntegration } from 'astro'; +import type { VitePlugin } from '../utils.js'; + +async function copyFile(toDir: URL, fromUrl: URL, toUrl: URL) { + await fs.promises.mkdir(toDir, { recursive: true }); + await fs.promises.rename(fromUrl, toUrl); +} + +export function fileURLIntegration(): AstroIntegration { + const fileNames: string[] = []; + + function createVitePlugin(command: 'build' | 'preview' | 'dev' | 'sync'): VitePlugin { + let referenceIds: string[] = []; + return { + name: '@astrojs/db/file-url', + enforce: 'pre', + async load(id) { + if (id.endsWith('?fileurl')) { + const filePath = id.slice(0, id.indexOf('?')); + if (command === 'build') { + const data = await fs.promises.readFile(filePath); + const name = path.basename(filePath); + const referenceId = this.emitFile({ + name, + source: data, + type: 'asset', + }); + referenceIds.push(referenceId); + return `export default import.meta.ROLLUP_FILE_URL_${referenceId};`; + } + // dev mode + else { + return `export default new URL(${JSON.stringify(pathToFileURL(filePath).toString())})`; + } + } + }, + generateBundle() { + // Save file names so we can copy them back over. + for (const referenceId of referenceIds) { + fileNames.push(this.getFileName(referenceId)); + } + // Reset `referenceIds` for later generateBundle() runs. + // Prevents lookup for ids that have already been copied. + referenceIds = []; + }, + }; + } + + let config: AstroConfig; + return { + name: '@astrojs/db/file-url', + hooks: { + 'astro:config:setup'({ updateConfig, command }) { + updateConfig({ + vite: { + plugins: [createVitePlugin(command)], + }, + }); + }, + 'astro:config:done': ({ config: _config }) => { + config = _config; + }, + async 'astro:build:done'() { + if (config.output === 'static') { + // Delete the files since they are only used for the build process. + const unlinks: Promise<unknown>[] = []; + for (const fileName of fileNames) { + const url = new URL(fileName, config.outDir); + unlinks.push(fs.promises.unlink(url)); + } + await Promise.all(unlinks); + // Delete the assets directory if it is empty. + // NOTE(fks): Ignore errors here because this is expected to fail + // if the directory contains files, or if it does not exist. + // If it errors for some unknown reason, it's not a big deal. + const assetDir = new URL(config.build.assets, config.outDir); + await fs.promises.rmdir(assetDir).catch(() => []); + } else { + // Move files back over to the dist output path + const moves: Promise<unknown>[] = []; + for (const fileName of fileNames) { + const fromUrl = new URL(fileName, config.build.client); + const toUrl = new URL(fileName, config.build.server); + const toDir = new URL('./', toUrl); + moves.push(copyFile(toDir, fromUrl, toUrl)); + } + await Promise.all(moves); + } + }, + }, + }; +} diff --git a/packages/db/src/core/integration/index.ts b/packages/db/src/core/integration/index.ts new file mode 100644 index 000000000..c6f58d2fd --- /dev/null +++ b/packages/db/src/core/integration/index.ts @@ -0,0 +1,239 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ManagedAppToken } from '@astrojs/studio'; +import type { AstroIntegration } from 'astro'; +import { blue, yellow } from 'kleur/colors'; +import { + type HMRPayload, + type UserConfig, + type ViteDevServer, + createServer, + loadEnv, + mergeConfig, +} from 'vite'; +import parseArgs from 'yargs-parser'; +import { AstroDbError, isDbError } from '../../runtime/utils.js'; +import { CONFIG_FILE_NAMES, DB_PATH, VIRTUAL_MODULE_ID } from '../consts.js'; +import { EXEC_DEFAULT_EXPORT_ERROR, EXEC_ERROR } from '../errors.js'; +import { resolveDbConfig } from '../load-file.js'; +import { SEED_DEV_FILE_NAME } from '../queries.js'; +import { type VitePlugin, getDbDirectoryUrl, getManagedRemoteToken } from '../utils.js'; +import { fileURLIntegration } from './file-url.js'; +import { getDtsContent } from './typegen.js'; +import { + type LateSeedFiles, + type LateTables, + type SeedHandler, + vitePluginDb, +} from './vite-plugin-db.js'; + +function astroDBIntegration(): AstroIntegration { + let connectToRemote = false; + let configFileDependencies: string[] = []; + let root: URL; + let appToken: ManagedAppToken | undefined; + // Used during production builds to load seed files. + let tempViteServer: ViteDevServer | undefined; + + // 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 seedFiles: LateSeedFiles = { + get() { + throw new Error('[astro:db] INTERNAL Seed files not loaded yet'); + }, + }; + let seedHandler: SeedHandler = { + execute: () => { + throw new Error('[astro:db] INTERNAL Seed handler not loaded yet'); + }, + inProgress: false, + }; + + let command: 'dev' | 'build' | 'preview' | 'sync'; + let finalBuildOutput: string; + return { + name: 'astro:db', + hooks: { + 'astro:config:setup': async ({ updateConfig, config, command: _command, logger }) => { + command = _command; + root = config.root; + + if (command === 'preview') return; + + let dbPlugin: VitePlugin | undefined = undefined; + const args = parseArgs(process.argv.slice(3)); + connectToRemote = process.env.ASTRO_INTERNAL_TEST_REMOTE || args['remote']; + + if (connectToRemote) { + appToken = await getManagedRemoteToken(); + dbPlugin = vitePluginDb({ + connectToStudio: connectToRemote, + appToken: appToken.token, + tables, + root: config.root, + srcDir: config.srcDir, + output: config.output, + seedHandler, + }); + } else { + dbPlugin = vitePluginDb({ + connectToStudio: false, + tables, + seedFiles, + root: config.root, + srcDir: config.srcDir, + output: config.output, + logger, + seedHandler, + }); + } + + updateConfig({ + vite: { + assetsInclude: [DB_PATH], + plugins: [dbPlugin], + }, + }); + }, + 'astro:config:done': async ({ config, injectTypes, buildOutput }) => { + if (command === 'preview') return; + + finalBuildOutput = buildOutput; + + // TODO: refine where we load tables + // @matthewp: may want to load tables by path at runtime + const { dbConfig, dependencies, integrationSeedPaths } = await resolveDbConfig(config); + tables.get = () => dbConfig.tables; + seedFiles.get = () => integrationSeedPaths; + configFileDependencies = dependencies; + + const localDbUrl = new URL(DB_PATH, config.root); + if (!connectToRemote && !existsSync(localDbUrl)) { + await mkdir(dirname(fileURLToPath(localDbUrl)), { recursive: true }); + await writeFile(localDbUrl, ''); + } + + injectTypes({ + filename: 'db.d.ts', + content: getDtsContent(tables.get() ?? {}), + }); + }, + 'astro:server:setup': async ({ server, logger }) => { + seedHandler.execute = async (fileUrl) => { + await executeSeedFile({ fileUrl, viteServer: 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(); + } + }); + // Wait for dev server log before showing "connected". + setTimeout(() => { + logger.info( + connectToRemote ? 'Connected to remote database.' : 'New local database created.', + ); + if (connectToRemote) return; + + const localSeedPaths = SEED_DEV_FILE_NAME.map( + (name) => new URL(name, getDbDirectoryUrl(root)), + ); + // Eager load astro:db module on startup + if (seedFiles.get().length || localSeedPaths.find((path) => existsSync(path))) { + server.ssrLoadModule(VIRTUAL_MODULE_ID).catch((e) => { + logger.error(e instanceof Error ? e.message : String(e)); + }); + } + }, 100); + }, + 'astro:build:start': async ({ logger }) => { + if (!connectToRemote && !databaseFileEnvDefined() && finalBuildOutput === 'server') { + const message = `Attempting to build without the --remote flag or the ASTRO_DATABASE_FILE environment variable defined. You probably want to pass --remote to astro build.`; + const hint = + 'Learn more connecting to Studio: https://docs.astro.build/en/guides/astro-db/#connect-to-astro-studio'; + throw new AstroDbError(message, hint); + } + + logger.info('database: ' + (connectToRemote ? yellow('remote') : blue('local database.'))); + }, + 'astro:build:setup': async ({ vite }) => { + tempViteServer = await getTempViteServer({ viteConfig: vite }); + seedHandler.execute = async (fileUrl) => { + await executeSeedFile({ fileUrl, viteServer: tempViteServer! }); + }; + }, + 'astro:build:done': async ({}) => { + await appToken?.destroy(); + await tempViteServer?.close(); + }, + }, + }; +} + +function databaseFileEnvDefined() { + const env = loadEnv('', process.cwd()); + return env.ASTRO_DATABASE_FILE != null || process.env.ASTRO_DATABASE_FILE != null; +} + +export function integration(): AstroIntegration[] { + return [astroDBIntegration(), fileURLIntegration()]; +} + +async function executeSeedFile({ + fileUrl, + viteServer, +}: { + fileUrl: URL; + viteServer: ViteDevServer; +}) { + // Use decodeURIComponent to handle paths with spaces correctly + // This ensures that %20 in the pathname is properly handled + const pathname = decodeURIComponent(fileUrl.pathname); + const mod = await viteServer.ssrLoadModule(pathname); + if (typeof mod.default !== 'function') { + throw new AstroDbError(EXEC_DEFAULT_EXPORT_ERROR(fileURLToPath(fileUrl))); + } + try { + await mod.default(); + } catch (e) { + if (isDbError(e)) { + throw new AstroDbError(EXEC_ERROR(e.message)); + } + throw e; + } +} + +/** + * Inspired by Astro content collection config loader. + */ +async function getTempViteServer({ viteConfig }: { viteConfig: UserConfig }) { + const tempViteServer = await createServer( + mergeConfig(viteConfig, { + server: { middlewareMode: true, hmr: false, watch: null, ws: false }, + optimizeDeps: { noDiscovery: true }, + ssr: { external: [] }, + logLevel: 'silent', + }), + ); + + const hotSend = tempViteServer.hot.send; + tempViteServer.hot.send = (payload: HMRPayload) => { + if (payload.type === 'error') { + throw payload.err; + } + return hotSend(payload); + }; + + return tempViteServer; +} diff --git a/packages/db/src/core/integration/typegen.ts b/packages/db/src/core/integration/typegen.ts new file mode 100644 index 000000000..91364b3c3 --- /dev/null +++ b/packages/db/src/core/integration/typegen.ts @@ -0,0 +1,26 @@ +import { RUNTIME_IMPORT } from '../consts.js'; +import type { DBTable, DBTables } from '../types.js'; + +export function getDtsContent(tables: DBTables) { + const content = `// This file is generated by Astro DB +declare module 'astro:db' { +${Object.entries(tables) + .map(([name, table]) => generateTableType(name, table)) + .join('\n')} +} +`; + return content; +} + +function generateTableType(name: string, table: DBTable): string { + const sanitizedColumnsList = Object.entries(table.columns) + // Filter out deprecated columns from the typegen, so that they don't + // appear as queryable fields in the generated types / your codebase. + .filter(([, val]) => !val.schema.deprecated); + const sanitizedColumns = Object.fromEntries(sanitizedColumnsList); + let tableType = ` export const ${name}: import(${RUNTIME_IMPORT}).Table< + ${JSON.stringify(name)}, + ${JSON.stringify(sanitizedColumns)} + >;`; + return tableType; +} diff --git a/packages/db/src/core/integration/vite-plugin-db.ts b/packages/db/src/core/integration/vite-plugin-db.ts new file mode 100644 index 000000000..410d49157 --- /dev/null +++ b/packages/db/src/core/integration/vite-plugin-db.ts @@ -0,0 +1,238 @@ +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import type { AstroConfig, AstroIntegrationLogger } from 'astro'; +import { type SQL, sql } from 'drizzle-orm'; +import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; +import { createLocalDatabaseClient } from '../../runtime/db-client.js'; +import { normalizeDatabaseUrl } from '../../runtime/index.js'; +import { DB_PATH, RUNTIME_IMPORT, RUNTIME_VIRTUAL_IMPORT, VIRTUAL_MODULE_ID } from '../consts.js'; +import { getResolvedFileUrl } from '../load-file.js'; +import { SEED_DEV_FILE_NAME, getCreateIndexQueries, getCreateTableQuery } from '../queries.js'; +import type { DBTables } from '../types.js'; +import { + type VitePlugin, + getAstroEnv, + getDbDirectoryUrl, + getRemoteDatabaseInfo, +} from '../utils.js'; + +const resolved = { + module: '\0' + VIRTUAL_MODULE_ID, + importedFromSeedFile: '\0' + VIRTUAL_MODULE_ID + ':seed', +}; + +export type LateTables = { + get: () => DBTables; +}; +export type LateSeedFiles = { + get: () => Array<string | URL>; +}; +export type SeedHandler = { + inProgress: boolean; + execute: (fileUrl: URL) => Promise<void>; +}; + +type VitePluginDBParams = + | { + connectToStudio: false; + tables: LateTables; + seedFiles: LateSeedFiles; + srcDir: URL; + root: URL; + logger?: AstroIntegrationLogger; + output: AstroConfig['output']; + seedHandler: SeedHandler; + } + | { + connectToStudio: true; + tables: LateTables; + appToken: string; + srcDir: URL; + root: URL; + output: AstroConfig['output']; + seedHandler: SeedHandler; + }; + +export function vitePluginDb(params: VitePluginDBParams): VitePlugin { + let command: 'build' | 'serve' = 'build'; + return { + name: 'astro:db', + enforce: 'pre', + configResolved(resolvedConfig) { + command = resolvedConfig.command; + }, + async resolveId(id) { + if (id !== VIRTUAL_MODULE_ID) return; + if (params.seedHandler.inProgress) { + return resolved.importedFromSeedFile; + } + return resolved.module; + }, + async load(id) { + if (id !== resolved.module && id !== resolved.importedFromSeedFile) return; + + if (params.connectToStudio) { + return getStudioVirtualModContents({ + appToken: params.appToken, + tables: params.tables.get(), + isBuild: command === 'build', + output: params.output, + }); + } + + // When seeding, we resolved to a different virtual module. + // this prevents an infinite loop attempting to rerun seed files. + // Short circuit with the module contents in this case. + if (id === resolved.importedFromSeedFile) { + return getLocalVirtualModContents({ + root: params.root, + tables: params.tables.get(), + }); + } + + await recreateTables(params); + const seedFiles = getResolvedSeedFiles(params); + for await (const seedFile of seedFiles) { + // Use `addWatchFile()` to invalidate the `astro:db` module + // when a seed file changes. + this.addWatchFile(fileURLToPath(seedFile)); + if (existsSync(seedFile)) { + params.seedHandler.inProgress = true; + await params.seedHandler.execute(seedFile); + } + } + if (params.seedHandler.inProgress) { + (params.logger ?? console).info('Seeded database.'); + params.seedHandler.inProgress = false; + } + return getLocalVirtualModContents({ + root: params.root, + tables: params.tables.get(), + }); + }, + }; +} + +export function getConfigVirtualModContents() { + return `export * from ${RUNTIME_VIRTUAL_IMPORT}`; +} + +export function getLocalVirtualModContents({ + tables, + root, +}: { + tables: DBTables; + root: URL; +}) { + const { ASTRO_DATABASE_FILE } = getAstroEnv(); + const dbInfo = getRemoteDatabaseInfo(); + const dbUrl = new URL(DB_PATH, root); + return ` +import { asDrizzleTable, createLocalDatabaseClient, normalizeDatabaseUrl } from ${RUNTIME_IMPORT}; + +const dbUrl = normalizeDatabaseUrl(${JSON.stringify(ASTRO_DATABASE_FILE)}, ${JSON.stringify(dbUrl)}); +export const db = createLocalDatabaseClient({ dbUrl, enableTransactions: ${dbInfo.url === 'libsql'} }); + +export * from ${RUNTIME_VIRTUAL_IMPORT}; + +${getStringifiedTableExports(tables)}`; +} + +export function getStudioVirtualModContents({ + tables, + appToken, + isBuild, + output, +}: { + tables: DBTables; + appToken: string; + isBuild: boolean; + output: AstroConfig['output']; +}) { + const dbInfo = getRemoteDatabaseInfo(); + + function appTokenArg() { + if (isBuild) { + const envPrefix = dbInfo.type === 'studio' ? 'ASTRO_STUDIO' : 'ASTRO_DB'; + if (output === 'server') { + // In production build, always read the runtime environment variable. + return `process.env.${envPrefix}_APP_TOKEN`; + } else { + // Static mode or prerendering needs the local app token. + return `process.env.${envPrefix}_APP_TOKEN ?? ${JSON.stringify(appToken)}`; + } + } else { + return JSON.stringify(appToken); + } + } + + function dbUrlArg() { + const dbStr = JSON.stringify(dbInfo.url); + + if (isBuild) { + // Allow overriding, mostly for testing + return dbInfo.type === 'studio' + ? `import.meta.env.ASTRO_STUDIO_REMOTE_DB_URL ?? ${dbStr}` + : `import.meta.env.ASTRO_DB_REMOTE_URL ?? ${dbStr}`; + } else { + return dbStr; + } + } + + return ` +import {asDrizzleTable, createRemoteDatabaseClient} from ${RUNTIME_IMPORT}; + +export const db = await createRemoteDatabaseClient({ + dbType: ${JSON.stringify(dbInfo.type)}, + remoteUrl: ${dbUrlArg()}, + appToken: ${appTokenArg()}, +}); + +export * from ${RUNTIME_VIRTUAL_IMPORT}; + +${getStringifiedTableExports(tables)} + `; +} + +function getStringifiedTableExports(tables: DBTables) { + return Object.entries(tables) + .map( + ([name, table]) => + `export const ${name} = asDrizzleTable(${JSON.stringify(name)}, ${JSON.stringify( + table, + )}, false)`, + ) + .join('\n'); +} + +const sqlite = new SQLiteAsyncDialect(); + +async function recreateTables({ tables, root }: { tables: LateTables; root: URL }) { + const dbInfo = getRemoteDatabaseInfo(); + const { ASTRO_DATABASE_FILE } = getAstroEnv(); + const dbUrl = normalizeDatabaseUrl(ASTRO_DATABASE_FILE, new URL(DB_PATH, root).href); + const db = createLocalDatabaseClient({ dbUrl, enableTransactions: dbInfo.type === 'libsql' }); + const setupQueries: SQL[] = []; + for (const [name, table] of Object.entries(tables.get() ?? {})) { + 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)), + ]); +} + +function getResolvedSeedFiles({ + root, + seedFiles, +}: { + root: URL; + seedFiles: LateSeedFiles; +}) { + const localSeedFiles = SEED_DEV_FILE_NAME.map((name) => new URL(name, getDbDirectoryUrl(root))); + const integrationSeedFiles = seedFiles.get().map((s) => getResolvedFileUrl(root, s)); + return [...integrationSeedFiles, ...localSeedFiles]; +} diff --git a/packages/db/src/core/load-file.ts b/packages/db/src/core/load-file.ts new file mode 100644 index 000000000..027deaa60 --- /dev/null +++ b/packages/db/src/core/load-file.ts @@ -0,0 +1,206 @@ +import { existsSync } from 'node:fs'; +import { unlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { AstroConfig } from 'astro'; +import { build as esbuild } from 'esbuild'; +import { CONFIG_FILE_NAMES, VIRTUAL_MODULE_ID } from './consts.js'; +import { INTEGRATION_TABLE_CONFLICT_ERROR } from './errors.js'; +import { errorMap } from './integration/error-map.js'; +import { getConfigVirtualModContents } from './integration/vite-plugin-db.js'; +import { dbConfigSchema } from './schemas.js'; +import './types.js'; +import { getAstroEnv, getDbDirectoryUrl } from './utils.js'; + +/** + * Load a user’s `astro:db` configuration file and additional configuration files provided by integrations. + */ +export async function resolveDbConfig({ + root, + integrations, +}: Pick<AstroConfig, 'root' | 'integrations'>) { + const { mod, dependencies } = await loadUserConfigFile(root); + const userDbConfig = dbConfigSchema.parse(mod?.default ?? {}, { errorMap }); + /** Resolved `astro:db` config including tables provided by integrations. */ + const dbConfig = { tables: userDbConfig.tables ?? {} }; + + // Collect additional config and seed files from integrations. + const integrationDbConfigPaths: Array<{ name: string; configEntrypoint: string | URL }> = []; + const integrationSeedPaths: Array<string | URL> = []; + for (const integration of integrations) { + const { name, hooks } = integration; + if (hooks['astro:db:setup']) { + hooks['astro:db:setup']({ + extendDb({ configEntrypoint, seedEntrypoint }) { + if (configEntrypoint) { + integrationDbConfigPaths.push({ name, configEntrypoint }); + } + if (seedEntrypoint) { + integrationSeedPaths.push(seedEntrypoint); + } + }, + }); + } + } + for (const { name, configEntrypoint } of integrationDbConfigPaths) { + // TODO: config file dependencies are not tracked for integrations for now. + const loadedConfig = await loadIntegrationConfigFile(root, configEntrypoint); + const integrationDbConfig = dbConfigSchema.parse(loadedConfig.mod?.default ?? {}, { + errorMap, + }); + for (const key in integrationDbConfig.tables) { + if (key in dbConfig.tables) { + const isUserConflict = key in (userDbConfig.tables ?? {}); + throw new Error(INTEGRATION_TABLE_CONFLICT_ERROR(name, key, isUserConflict)); + } else { + dbConfig.tables[key] = integrationDbConfig.tables[key]; + } + } + } + + return { + /** Resolved `astro:db` config, including tables added by integrations. */ + dbConfig, + /** Dependencies imported into the user config file. */ + dependencies, + /** Additional `astro:db` seed file paths provided by integrations. */ + integrationSeedPaths, + }; +} + +async function loadUserConfigFile( + 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; + } + } + return await loadAndBundleDbConfigFile({ root, fileUrl: configFileUrl }); +} + +export function getResolvedFileUrl(root: URL, filePathOrUrl: string | URL): URL { + if (typeof filePathOrUrl === 'string') { + const { resolve } = createRequire(root); + const resolvedFilePath = resolve(filePathOrUrl); + return pathToFileURL(resolvedFilePath); + } + return filePathOrUrl; +} + +async function loadIntegrationConfigFile(root: URL, filePathOrUrl: string | URL) { + const fileUrl = getResolvedFileUrl(root, filePathOrUrl); + return await loadAndBundleDbConfigFile({ root, fileUrl }); +} + +async function loadAndBundleDbConfigFile({ + root, + fileUrl, +}: { + root: URL; + fileUrl: URL | undefined; +}): Promise<{ mod: { default?: unknown } | undefined; dependencies: string[] }> { + if (!fileUrl) { + return { mod: undefined, dependencies: [] }; + } + const { code, dependencies } = await bundleFile({ + virtualModContents: getConfigVirtualModContents(), + root, + fileUrl, + }); + 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 { ASTRO_DATABASE_FILE } = getAstroEnv(); + 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', + 'import.meta.env.ASTRO_DB_REMOTE_DB_URL': 'undefined', + 'import.meta.env.ASTRO_DATABASE_FILE': JSON.stringify(ASTRO_DATABASE_FILE ?? ''), + }, + 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.toString()); + } 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 new file mode 100644 index 000000000..8fba4f6f9 --- /dev/null +++ b/packages/db/src/core/queries.ts @@ -0,0 +1,206 @@ +import type { 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, +} from '../runtime/errors.js'; +import { hasPrimaryKey } from '../runtime/index.js'; +import { isSerializedSQL } from '../runtime/types.js'; +import type { + BooleanColumn, + ColumnType, + DBColumn, + DBTable, + DateColumn, + JsonColumn, + NumberColumn, + TextColumn, +} from './types.js'; + +const sqlite = new SQLiteAsyncDialect(); + +export const SEED_DEV_FILE_NAME = ['seed.ts', 'seed.js', 'seed.mjs', 'seed.mts']; + +export function getDropTableIfExistsQuery(tableName: string) { + return `DROP TABLE IF EXISTS ${sqlite.escapeName(tableName)}`; +} + +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<DBTable, 'indexes'>) { + 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; +} + +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<T>(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 extends DBColumn> = T & { + schema: Required<Pick<T['schema'], 'default'>>; +}; +type DBColumnWithDefault = + | WithDefaultDefined<TextColumn> + | WithDefaultDefined<DateColumn> + | WithDefaultDefined<NumberColumn> + | WithDefaultDefined<BooleanColumn> + | WithDefaultDefined<JsonColumn>; + +// 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<T>(def: T | SQL<any>): 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 { + // biome-ignore lint/suspicious/noConsoleLog: allowed + 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/schemas.ts b/packages/db/src/core/schemas.ts new file mode 100644 index 000000000..c9575a79a --- /dev/null +++ b/packages/db/src/core/schemas.ts @@ -0,0 +1,247 @@ +import { SQL } from 'drizzle-orm'; +import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; +import { type ZodTypeDef, z } from 'zod'; +import { SERIALIZED_SQL_KEY, type SerializedSQL } from '../runtime/types.js'; +import { errorMap } from './integration/error-map.js'; +import type { NumberColumn, TextColumn } from './types.js'; +import { mapObject } from './utils.js'; + +export type MaybeArray<T> = T | T[]; + +// Transform to serializable object for migration files +const sqlite = new SQLiteAsyncDialect(); + +const sqlSchema = z.instanceof(SQL<any>).transform( + (sqlObj): SerializedSQL => ({ + [SERIALIZED_SQL_KEY]: true, + sql: sqlite.sqlToQuery(sqlObj).sql, + }), +); + +const baseColumnSchema = z.object({ + label: z.string().optional(), + optional: z.boolean().optional().default(false), + unique: z.boolean().optional().default(false), + deprecated: z.boolean().optional().default(false), + + // Defined when `defineDb()` is called to resolve `references` + name: z.string().optional(), + // TODO: Update to `table`. Will need migration file version change + collection: z.string().optional(), +}); + +export const booleanColumnSchema = z.object({ + type: z.literal('boolean'), + schema: baseColumnSchema.extend({ + default: z.union([z.boolean(), sqlSchema]).optional(), + }), +}); + +const numberColumnBaseSchema = baseColumnSchema.omit({ optional: true }).and( + z.union([ + z.object({ + primaryKey: z.literal(false).optional().default(false), + optional: baseColumnSchema.shape.optional, + default: z.union([z.number(), sqlSchema]).optional(), + }), + z.object({ + // `integer primary key` uses ROWID as the default value. + // `optional` and `default` do not have an effect, + // so disable these config options for primary keys. + primaryKey: z.literal(true), + optional: z.literal(false).optional(), + default: z.literal(undefined).optional(), + }), + ]), +); + +export const numberColumnOptsSchema: z.ZodType< + z.infer<typeof numberColumnBaseSchema> & { + // ReferenceableColumn creates a circular type. Define ZodType to resolve. + references?: NumberColumn; + }, + ZodTypeDef, + z.input<typeof numberColumnBaseSchema> & { + references?: () => z.input<typeof numberColumnSchema>; + } +> = numberColumnBaseSchema.and( + z.object({ + references: z + .function() + .returns(z.lazy(() => numberColumnSchema)) + .optional() + .transform((fn) => fn?.()), + }), +); + +export const numberColumnSchema = z.object({ + type: z.literal('number'), + schema: numberColumnOptsSchema, +}); + +const textColumnBaseSchema = baseColumnSchema + .omit({ optional: true }) + .extend({ + default: z.union([z.string(), sqlSchema]).optional(), + multiline: z.boolean().optional(), + }) + .and( + z.union([ + z.object({ + primaryKey: z.literal(false).optional().default(false), + optional: baseColumnSchema.shape.optional, + }), + z.object({ + // text primary key allows NULL values. + // NULL values bypass unique checks, which could + // lead to duplicate URLs per record in Astro Studio. + // disable `optional` for primary keys. + primaryKey: z.literal(true), + optional: z.literal(false).optional(), + }), + ]), + ); + +export const textColumnOptsSchema: z.ZodType< + z.infer<typeof textColumnBaseSchema> & { + // ReferenceableColumn creates a circular type. Define ZodType to resolve. + references?: TextColumn; + }, + ZodTypeDef, + z.input<typeof textColumnBaseSchema> & { + references?: () => z.input<typeof textColumnSchema>; + } +> = textColumnBaseSchema.and( + z.object({ + references: z + .function() + .returns(z.lazy(() => textColumnSchema)) + .optional() + .transform((fn) => fn?.()), + }), +); + +export const textColumnSchema = z.object({ + type: z.literal('text'), + schema: textColumnOptsSchema, +}); + +export const dateColumnSchema = z.object({ + type: z.literal('date'), + schema: baseColumnSchema.extend({ + default: z + .union([ + sqlSchema, + // transform to ISO string for serialization + z + .date() + .transform((d) => d.toISOString()), + ]) + .optional(), + }), +}); + +export const jsonColumnSchema = z.object({ + type: z.literal('json'), + schema: baseColumnSchema.extend({ + default: z.unknown().optional(), + }), +}); + +export const columnSchema = z.discriminatedUnion('type', [ + booleanColumnSchema, + numberColumnSchema, + textColumnSchema, + dateColumnSchema, + jsonColumnSchema, +]); +export const referenceableColumnSchema = z.union([textColumnSchema, numberColumnSchema]); + +export const columnsSchema = z.record(columnSchema); + +type ForeignKeysInput = { + columns: MaybeArray<string>; + references: () => MaybeArray<Omit<z.input<typeof referenceableColumnSchema>, 'references'>>; +}; + +type ForeignKeysOutput = Omit<ForeignKeysInput, 'references'> & { + // reference fn called in `transform`. Ensures output is JSON serializable. + references: MaybeArray<Omit<z.output<typeof referenceableColumnSchema>, 'references'>>; +}; + +const foreignKeysSchema: z.ZodType<ForeignKeysOutput, ZodTypeDef, ForeignKeysInput> = z.object({ + columns: z.string().or(z.array(z.string())), + references: z + .function() + .returns(z.lazy(() => referenceableColumnSchema.or(z.array(referenceableColumnSchema)))) + .transform((fn) => fn()), +}); + +export const resolvedIndexSchema = z.object({ + on: z.string().or(z.array(z.string())), + unique: z.boolean().optional(), +}); +/** @deprecated */ +const legacyIndexesSchema = z.record(resolvedIndexSchema); + +export const indexSchema = z.object({ + on: z.string().or(z.array(z.string())), + unique: z.boolean().optional(), + name: z.string().optional(), +}); +const indexesSchema = z.array(indexSchema); + +export const tableSchema = z.object({ + columns: columnsSchema, + indexes: indexesSchema.or(legacyIndexesSchema).optional(), + foreignKeys: z.array(foreignKeysSchema).optional(), + deprecated: z.boolean().optional().default(false), +}); + +export const tablesSchema = z.preprocess((rawTables) => { + // Use `z.any()` to avoid breaking object references + 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. + table.getName = () => tableName; + 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 = tableName; + } + } + return rawTables; +}, z.record(tableSchema)); + +export const dbConfigSchema = z + .object({ + tables: tablesSchema.optional(), + }) + .transform(({ tables = {}, ...config }) => { + return { + ...config, + tables: mapObject(tables, (tableName, table) => { + const { indexes = {} } = table; + if (!Array.isArray(indexes)) { + return { ...table, indexes }; + } + const resolvedIndexes: Record<string, z.infer<typeof resolvedIndexSchema>> = {}; + for (const index of indexes) { + if (index.name) { + const { name, ...rest } = index; + resolvedIndexes[index.name] = rest; + continue; + } + // Sort index columns to ensure consistent index names + const indexOn = Array.isArray(index.on) ? index.on.sort().join('_') : index.on; + const name = tableName + '_' + indexOn + '_idx'; + resolvedIndexes[name] = index; + } + return { + ...table, + indexes: resolvedIndexes, + }; + }), + }; + }); diff --git a/packages/db/src/core/types.ts b/packages/db/src/core/types.ts new file mode 100644 index 000000000..5efc6507c --- /dev/null +++ b/packages/db/src/core/types.ts @@ -0,0 +1,102 @@ +import type { z } from 'zod'; +import type { + MaybeArray, + booleanColumnSchema, + columnSchema, + columnsSchema, + dateColumnSchema, + dbConfigSchema, + indexSchema, + jsonColumnSchema, + numberColumnOptsSchema, + numberColumnSchema, + referenceableColumnSchema, + resolvedIndexSchema, + tableSchema, + textColumnOptsSchema, + textColumnSchema, +} from './schemas.js'; + +export type ResolvedIndexes = z.output<typeof dbConfigSchema>['tables'][string]['indexes']; +export type BooleanColumn = z.infer<typeof booleanColumnSchema>; +export type BooleanColumnInput = z.input<typeof booleanColumnSchema>; +export type NumberColumn = z.infer<typeof numberColumnSchema>; +export type NumberColumnInput = z.input<typeof numberColumnSchema>; +export type TextColumn = z.infer<typeof textColumnSchema>; +export type TextColumnInput = z.input<typeof textColumnSchema>; +export type DateColumn = z.infer<typeof dateColumnSchema>; +export type DateColumnInput = z.input<typeof dateColumnSchema>; +export type JsonColumn = z.infer<typeof jsonColumnSchema>; +export type JsonColumnInput = z.input<typeof jsonColumnSchema>; + +export type ColumnType = + | BooleanColumn['type'] + | NumberColumn['type'] + | TextColumn['type'] + | DateColumn['type'] + | JsonColumn['type']; + +export type DBColumn = z.infer<typeof columnSchema>; +export type DBColumnInput = + | DateColumnInput + | BooleanColumnInput + | NumberColumnInput + | TextColumnInput + | JsonColumnInput; +export type DBColumns = z.infer<typeof columnsSchema>; +export type DBTable = z.infer<typeof tableSchema>; +export type DBTables = Record<string, DBTable>; +export type ResolvedDBTables = z.output<typeof dbConfigSchema>['tables']; +export type ResolvedDBTable = z.output<typeof dbConfigSchema>['tables'][string]; +export type DBSnapshot = { + schema: Record<string, ResolvedDBTable>; + version: string; +}; + +export type DBConfigInput = z.input<typeof dbConfigSchema>; +export type DBConfig = z.infer<typeof dbConfigSchema>; + +export type ColumnsConfig = z.input<typeof tableSchema>['columns']; +export type OutputColumnsConfig = z.output<typeof tableSchema>['columns']; + +export interface TableConfig<TColumns extends ColumnsConfig = ColumnsConfig> + // use `extends` to ensure types line up with zod, + // only adding generics for type completions. + extends Pick<z.input<typeof tableSchema>, 'columns' | 'indexes' | 'foreignKeys'> { + columns: TColumns; + foreignKeys?: Array<{ + columns: MaybeArray<Extract<keyof TColumns, string>>; + references: () => MaybeArray<z.input<typeof referenceableColumnSchema>>; + }>; + indexes?: Array<IndexConfig<TColumns>> | Record<string, LegacyIndexConfig<TColumns>>; + deprecated?: boolean; +} + +interface IndexConfig<TColumns extends ColumnsConfig> extends z.input<typeof indexSchema> { + on: MaybeArray<Extract<keyof TColumns, string>>; +} + +/** @deprecated */ +interface LegacyIndexConfig<TColumns extends ColumnsConfig> + extends z.input<typeof resolvedIndexSchema> { + on: MaybeArray<Extract<keyof TColumns, string>>; +} + +// We cannot use `Omit<NumberColumn | TextColumn, 'type'>`, +// since Omit collapses our union type on primary key. +export type NumberColumnOpts = z.input<typeof numberColumnOptsSchema>; +export type TextColumnOpts = z.input<typeof textColumnOptsSchema>; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Astro { + export interface IntegrationHooks { + 'astro:db:setup'?: (options: { + extendDb: (options: { + configEntrypoint?: URL | string; + seedEntrypoint?: URL | string; + }) => void; + }) => void | Promise<void>; + } + } +} diff --git a/packages/db/src/core/utils.ts b/packages/db/src/core/utils.ts new file mode 100644 index 000000000..b246997e2 --- /dev/null +++ b/packages/db/src/core/utils.ts @@ -0,0 +1,80 @@ +import { type ManagedAppToken, getAstroStudioEnv, getManagedAppTokenOrExit } from '@astrojs/studio'; +import type { AstroConfig, AstroIntegration } from 'astro'; +import { loadEnv } from 'vite'; +import './types.js'; + +export type VitePlugin = Required<AstroConfig['vite']>['plugins'][number]; + +export function getAstroEnv(envMode = ''): Record<`ASTRO_${string}`, string> { + const env = loadEnv(envMode, process.cwd(), 'ASTRO_'); + return env; +} + +export type RemoteDatabaseInfo = { + type: 'libsql' | 'studio'; + url: string; +}; + +export function getRemoteDatabaseInfo(): RemoteDatabaseInfo { + const astroEnv = getAstroEnv(); + const studioEnv = getAstroStudioEnv(); + + if (studioEnv.ASTRO_STUDIO_REMOTE_DB_URL) + return { + type: 'studio', + url: studioEnv.ASTRO_STUDIO_REMOTE_DB_URL, + }; + + if (astroEnv.ASTRO_DB_REMOTE_URL) + return { + type: 'libsql', + url: astroEnv.ASTRO_DB_REMOTE_URL, + }; + + return { + type: 'studio', + url: 'https://db.services.astro.build', + }; +} + +export function getManagedRemoteToken( + token?: string, + dbInfo?: RemoteDatabaseInfo, +): Promise<ManagedAppToken> { + dbInfo ??= getRemoteDatabaseInfo(); + + if (dbInfo.type === 'studio') { + return getManagedAppTokenOrExit(token); + } + + const astroEnv = getAstroEnv(); + + return Promise.resolve({ + token: token ?? astroEnv.ASTRO_DB_APP_TOKEN, + renew: () => Promise.resolve(), + destroy: () => Promise.resolve(), + }); +} + +export function getDbDirectoryUrl(root: URL | string) { + return new URL('db/', root); +} + +export function defineDbIntegration(integration: AstroIntegration): AstroIntegration { + return integration; +} + +export type Result<T> = { success: true; data: T } | { success: false; data: unknown }; + +/** + * Map an object's values to a new set of values + * while preserving types. + */ +export function mapObject<T, U = T>( + item: Record<string, T>, + callback: (key: string, value: T) => U, +): Record<string, U> { + return Object.fromEntries( + Object.entries(item).map(([key, value]) => [key, callback(key, value)]), + ); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 000000000..f7022a24a --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,3 @@ +export type { TableConfig } from './core/types.js'; +export { cli } from './core/cli/index.js'; +export { integration as default } from './core/integration/index.js'; diff --git a/packages/db/src/runtime/db-client.ts b/packages/db/src/runtime/db-client.ts new file mode 100644 index 000000000..55288951d --- /dev/null +++ b/packages/db/src/runtime/db-client.ts @@ -0,0 +1,265 @@ +import type { InStatement } from '@libsql/client'; +import { type Config as LibSQLConfig, createClient } from '@libsql/client'; +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; +import { drizzle as drizzleLibsql } from 'drizzle-orm/libsql'; +import { type SqliteRemoteDatabase, drizzle as drizzleProxy } from 'drizzle-orm/sqlite-proxy'; +import { z } from 'zod'; +import { DetailedLibsqlError, safeFetch } from './utils.js'; + +const isWebContainer = !!process.versions?.webcontainer; + +function applyTransactionNotSupported(db: SqliteRemoteDatabase) { + Object.assign(db, { + transaction() { + throw new Error( + '`db.transaction()` is not currently supported. We recommend `db.batch()` for automatic error rollbacks across multiple queries.', + ); + }, + }); +} + +type LocalDbClientOptions = { + dbUrl: string; + enableTransactions: boolean; +}; + +export function createLocalDatabaseClient(options: LocalDbClientOptions): LibSQLDatabase { + const url = isWebContainer ? 'file:content.db' : options.dbUrl; + const client = createClient({ url }); + const db = drizzleLibsql(client); + + if (!options.enableTransactions) { + applyTransactionNotSupported(db); + } + return db; +} + +const remoteResultSchema = z.object({ + columns: z.array(z.string()), + columnTypes: z.array(z.string()), + rows: z.array(z.array(z.unknown())), + rowsAffected: z.number(), + lastInsertRowid: z.unknown().optional(), +}); + +type RemoteDbClientOptions = { + dbType: 'studio' | 'libsql'; + appToken: string; + remoteUrl: string | URL; +}; + +export function createRemoteDatabaseClient(options: RemoteDbClientOptions) { + const remoteUrl = new URL(options.remoteUrl); + + return options.dbType === 'studio' + ? createStudioDatabaseClient(options.appToken, remoteUrl) + : createRemoteLibSQLClient(options.appToken, remoteUrl, options.remoteUrl.toString()); +} + +// this function parses the options from a `Record<string, string>` +// provided from the object conversion of the searchParams and properly +// verifies that the Config object is providing the correct types. +// without this, there is runtime errors due to incorrect values +export function parseOpts(config: Record<string, string>): Partial<LibSQLConfig> { + return { + ...config, + ...(config.syncInterval ? { syncInterval: parseInt(config.syncInterval) } : {}), + ...('readYourWrites' in config ? { readYourWrites: config.readYourWrites !== 'false' } : {}), + ...('offline' in config ? { offline: config.offline !== 'false' } : {}), + ...('tls' in config ? { tls: config.tls !== 'false' } : {}), + ...(config.concurrency ? { concurrency: parseInt(config.concurrency) } : {}), + }; +} + +function createRemoteLibSQLClient(appToken: string, remoteDbURL: URL, rawUrl: string) { + const options: Record<string, string> = Object.fromEntries(remoteDbURL.searchParams.entries()); + remoteDbURL.search = ''; + + let url = remoteDbURL.toString(); + if (remoteDbURL.protocol === 'memory:') { + // libSQL expects a special string in place of a URL + // for in-memory DBs. + url = ':memory:'; + } else if ( + remoteDbURL.protocol === 'file:' && + remoteDbURL.pathname.startsWith('/') && + !rawUrl.startsWith('file:/') + ) { + // libSQL accepts relative and absolute file URLs + // for local DBs. This doesn't match the URL specification. + // Parsing `file:some.db` and `file:/some.db` should yield + // the same result, but libSQL interprets the former as + // a relative path, and the latter as an absolute path. + // This detects when such a conversion happened during parsing + // and undoes it so that the URL given to libSQL is the + // same as given by the user. + url = 'file:' + remoteDbURL.pathname.substring(1); + } + + const client = createClient({ ...parseOpts(options), url, authToken: appToken }); + return drizzleLibsql(client); +} + +function createStudioDatabaseClient(appToken: string, remoteDbURL: URL) { + if (appToken == null) { + throw new Error(`Cannot create a remote client: missing app token.`); + } + + const url = new URL('/db/query', remoteDbURL); + + const db = drizzleProxy( + async (sql, parameters, method) => { + const requestBody: InStatement = { sql, args: parameters }; + const res = await safeFetch( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${appToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + async (response) => { + throw await parseRemoteError(response); + }, + ); + + let remoteResult: z.infer<typeof remoteResultSchema>; + try { + const json = await res.json(); + remoteResult = remoteResultSchema.parse(json); + } catch { + throw new DetailedLibsqlError({ + message: await getUnexpectedResponseMessage(res), + code: KNOWN_ERROR_CODES.SQL_QUERY_FAILED, + }); + } + + if (method === 'run') { + const rawRows = Array.from(remoteResult.rows); + // Implement basic `toJSON()` for Drizzle to serialize properly + (remoteResult as any).rows.toJSON = () => rawRows; + // Using `db.run()` drizzle massages the rows into an object. + // So in order to make dev/prod consistent, we need to do the same here. + // This creates an object and loops over each row replacing it with the object. + for (let i = 0; i < remoteResult.rows.length; i++) { + let row = remoteResult.rows[i]; + let item: Record<string, any> = {}; + remoteResult.columns.forEach((col, index) => { + item[col] = row[index]; + }); + (remoteResult as any).rows[i] = item; + } + return remoteResult; + } + + // Drizzle expects each row as an array of its values + const rowValues: unknown[][] = []; + + for (const row of remoteResult.rows) { + if (row != null && typeof row === 'object') { + rowValues.push(Object.values(row)); + } + } + + if (method === 'get') { + return { rows: rowValues[0] }; + } + + return { rows: rowValues }; + }, + async (queries) => { + const stmts: InStatement[] = queries.map(({ sql, params }) => ({ sql, args: params })); + const res = await safeFetch( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${appToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(stmts), + }, + async (response) => { + throw await parseRemoteError(response); + }, + ); + + let remoteResults: z.infer<typeof remoteResultSchema>[]; + try { + const json = await res.json(); + remoteResults = z.array(remoteResultSchema).parse(json); + } catch { + throw new DetailedLibsqlError({ + message: await getUnexpectedResponseMessage(res), + code: KNOWN_ERROR_CODES.SQL_QUERY_FAILED, + }); + } + let results: any[] = []; + for (const [idx, rawResult] of remoteResults.entries()) { + if (queries[idx]?.method === 'run') { + results.push(rawResult); + continue; + } + + // Drizzle expects each row as an array of its values + const rowValues: unknown[][] = []; + + for (const row of rawResult.rows) { + if (row != null && typeof row === 'object') { + rowValues.push(Object.values(row)); + } + } + + if (queries[idx]?.method === 'get') { + results.push({ rows: rowValues[0] }); + } + + results.push({ rows: rowValues }); + } + return results; + }, + ); + applyTransactionNotSupported(db); + return db; +} + +const errorSchema = z.object({ + success: z.boolean(), + error: z.object({ + code: z.string(), + details: z.string().optional(), + }), +}); + +const KNOWN_ERROR_CODES = { + SQL_QUERY_FAILED: 'SQL_QUERY_FAILED', +}; + +const getUnexpectedResponseMessage = async (response: Response) => + `Unexpected response from remote database:\n(Status ${response.status}) ${await response + .clone() + .text()}`; + +async function parseRemoteError(response: Response): Promise<DetailedLibsqlError> { + let error; + try { + error = errorSchema.parse(await response.clone().json()).error; + } catch { + return new DetailedLibsqlError({ + message: await getUnexpectedResponseMessage(response), + code: KNOWN_ERROR_CODES.SQL_QUERY_FAILED, + }); + } + // Strip LibSQL error prefixes + let baseDetails = + error.details?.replace(/.*SQLite error: /, '') ?? 'Error querying remote database.'; + // Remove duplicated "code" in details + const details = baseDetails.slice(baseDetails.indexOf(':') + 1).trim(); + let hint = `See the Astro DB guide for query and push instructions: https://docs.astro.build/en/guides/astro-db/#query-your-database`; + if (error.code === KNOWN_ERROR_CODES.SQL_QUERY_FAILED && details.includes('no such table')) { + hint = `Did you run \`astro db push\` to push your latest table schemas?`; + } + return new DetailedLibsqlError({ message: details, code: error.code, hint }); +} diff --git a/packages/db/src/runtime/errors.ts b/packages/db/src/runtime/errors.ts new file mode 100644 index 000000000..67961b1b5 --- /dev/null +++ b/packages/db/src/runtime/errors.ts @@ -0,0 +1,25 @@ +import { bold } from 'kleur/colors'; + +export const FOREIGN_KEY_DNE_ERROR = (tableName: string) => { + return `Table ${bold( + tableName, + )} references a table that does not exist. Did you apply the referenced table to the \`tables\` object in your db config?`; +}; + +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 FOREIGN_KEY_REFERENCES_EMPTY_ERROR = (tableName: string) => { + return `Foreign key on ${bold( + tableName, + )} is misconfigured. \`references\` array cannot be empty.`; +}; + +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?`; +}; diff --git a/packages/db/src/runtime/index.ts b/packages/db/src/runtime/index.ts new file mode 100644 index 000000000..caf06ef32 --- /dev/null +++ b/packages/db/src/runtime/index.ts @@ -0,0 +1,157 @@ +import { type ColumnBuilderBaseConfig, type ColumnDataType, sql } from 'drizzle-orm'; +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; +import { + type IndexBuilder, + type SQLiteColumnBuilderBase, + customType, + index, + integer, + sqliteTable, + text, +} from 'drizzle-orm/sqlite-core'; +import type { DBColumn, DBTable } from '../core/types.js'; +import { type SerializedSQL, isSerializedSQL } from './types.js'; +import { pathToFileURL } from './utils.js'; +export type Database = LibSQLDatabase; +export type { Table } from './types.js'; +export { createRemoteDatabaseClient, createLocalDatabaseClient } from './db-client.js'; + +export function hasPrimaryKey(column: DBColumn) { + return 'primaryKey' in column.schema && !!column.schema.primaryKey; +} + +// Taken from: +// https://stackoverflow.com/questions/52869695/check-if-a-date-string-is-in-iso-and-utc-format +const isISODateString = (str: string) => /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(str); + +const dateType = customType<{ data: Date; driverData: string }>({ + dataType() { + return 'text'; + }, + toDriver(value) { + return value.toISOString(); + }, + fromDriver(value) { + if (!isISODateString(value)) { + // values saved using CURRENT_TIMESTAMP are not valid ISO strings + // but *are* in UTC, so append the UTC zone. + value += 'Z'; + } + return new Date(value); + }, +}); + +const jsonType = customType<{ data: unknown; driverData: string }>({ + dataType() { + return 'text'; + }, + toDriver(value) { + return JSON.stringify(value); + }, + fromDriver(value) { + return JSON.parse(value); + }, +}); + +type D1ColumnBuilder = SQLiteColumnBuilderBase< + ColumnBuilderBaseConfig<ColumnDataType, string> & { data: unknown } +>; + +export function asDrizzleTable(name: string, table: DBTable) { + const columns: Record<string, D1ColumnBuilder> = {}; + if (!Object.entries(table.columns).some(([, column]) => hasPrimaryKey(column))) { + columns['_id'] = integer('_id').primaryKey(); + } + for (const [columnName, column] of Object.entries(table.columns)) { + columns[columnName] = columnMapper(columnName, column); + } + const drizzleTable = sqliteTable(name, columns, (ormTable) => { + const indexes: Array<IndexBuilder> = []; + 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; + + indexes.push(index(indexName).on(...onCols)); + } + return indexes; + }); + return drizzleTable; +} + +function atLeastOne<T>(arr: T[]): arr is [T, ...T[]] { + return arr.length > 0; +} + +function columnMapper(columnName: string, column: DBColumn) { + let c: ReturnType< + | typeof text + | typeof integer + | typeof jsonType + | typeof dateType + | typeof integer<string, 'boolean'> + >; + + switch (column.type) { + case 'text': { + c = text(columnName); + // Duplicate default logic across cases to preserve type inference. + // No clean generic for every column builder. + if (column.schema.default !== undefined) + c = c.default(handleSerializedSQL(column.schema.default)); + if (column.schema.primaryKey === true) c = c.primaryKey(); + break; + } + case 'number': { + c = integer(columnName); + if (column.schema.default !== undefined) + c = c.default(handleSerializedSQL(column.schema.default)); + if (column.schema.primaryKey === true) c = c.primaryKey(); + break; + } + case 'boolean': { + c = integer(columnName, { mode: 'boolean' }); + if (column.schema.default !== undefined) + c = c.default(handleSerializedSQL(column.schema.default)); + break; + } + case 'json': + c = jsonType(columnName); + if (column.schema.default !== undefined) c = c.default(column.schema.default); + break; + case 'date': { + c = dateType(columnName); + if (column.schema.default !== undefined) { + const def = handleSerializedSQL(column.schema.default); + c = c.default(typeof def === 'string' ? new Date(def) : def); + } + break; + } + } + + if (!column.schema.optional) c = c.notNull(); + if (column.schema.unique) c = c.unique(); + return c; +} + +function handleSerializedSQL<T>(def: T | SerializedSQL) { + if (isSerializedSQL(def)) { + return sql.raw(def.sql); + } + return def; +} + +export function normalizeDatabaseUrl(envDbUrl: string | undefined, defaultDbUrl: string): string { + if (envDbUrl) { + // This could be a file URL, or more likely a root-relative file path. + // Convert it to a file URL. + if (envDbUrl.startsWith('file://')) { + return envDbUrl; + } + + return new URL(envDbUrl, pathToFileURL(process.cwd()) + '/').toString(); + } else { + // This is going to be a file URL always, + return defaultDbUrl; + } +} diff --git a/packages/db/src/runtime/types.ts b/packages/db/src/runtime/types.ts new file mode 100644 index 000000000..95df67acb --- /dev/null +++ b/packages/db/src/runtime/types.ts @@ -0,0 +1,124 @@ +import type { ColumnBaseConfig, ColumnDataType } from 'drizzle-orm'; +import type { SQLiteColumn, SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core'; +import type { ColumnsConfig, DBColumn, OutputColumnsConfig } from '../core/types.js'; + +type GeneratedConfig<T extends ColumnDataType = ColumnDataType> = Pick< + ColumnBaseConfig<T, string>, + 'name' | 'tableName' | 'notNull' | 'hasDefault' +>; + +type AstroText<T extends GeneratedConfig<'string'>> = SQLiteColumn< + T & { + data: string; + dataType: 'string'; + columnType: 'SQLiteText'; + driverParam: string; + enumValues: never; + baseColumn: never; + isPrimaryKey: boolean; + isAutoincrement: boolean; + hasRuntimeDefault: boolean; + } +>; + +type AstroDate<T extends GeneratedConfig<'custom'>> = SQLiteColumn< + T & { + data: Date; + dataType: 'custom'; + columnType: 'SQLiteCustomColumn'; + driverParam: string; + enumValues: never; + baseColumn: never; + isPrimaryKey: boolean; + isAutoincrement: boolean; + hasRuntimeDefault: boolean; + } +>; + +type AstroBoolean<T extends GeneratedConfig<'boolean'>> = SQLiteColumn< + T & { + data: boolean; + dataType: 'boolean'; + columnType: 'SQLiteBoolean'; + driverParam: number; + enumValues: never; + baseColumn: never; + isPrimaryKey: boolean; + isAutoincrement: boolean; + hasRuntimeDefault: boolean; + } +>; + +type AstroNumber<T extends GeneratedConfig<'number'>> = SQLiteColumn< + T & { + data: number; + dataType: 'number'; + columnType: 'SQLiteInteger'; + driverParam: number; + enumValues: never; + baseColumn: never; + isPrimaryKey: boolean; + isAutoincrement: boolean; + hasRuntimeDefault: boolean; + } +>; + +type AstroJson<T extends GeneratedConfig<'custom'>> = SQLiteColumn< + T & { + data: unknown; + dataType: 'custom'; + columnType: 'SQLiteCustomColumn'; + driverParam: string; + enumValues: never; + baseColumn: never; + isPrimaryKey: boolean; + isAutoincrement: boolean; + hasRuntimeDefault: boolean; + } +>; + +type Column<T extends DBColumn['type'], S extends GeneratedConfig> = T extends 'boolean' + ? AstroBoolean<S> + : T extends 'number' + ? AstroNumber<S> + : T extends 'text' + ? AstroText<S> + : T extends 'date' + ? AstroDate<S> + : T extends 'json' + ? AstroJson<S> + : never; + +export type Table< + TTableName extends string, + TColumns extends OutputColumnsConfig | ColumnsConfig, +> = SQLiteTableWithColumns<{ + name: TTableName; + schema: undefined; + dialect: 'sqlite'; + columns: { + [K in Extract<keyof TColumns, string>]: Column< + TColumns[K]['type'], + { + tableName: TTableName; + name: K; + hasDefault: TColumns[K]['schema'] extends { default: NonNullable<unknown> } + ? true + : TColumns[K]['schema'] extends { primaryKey: true } + ? true + : false; + notNull: TColumns[K]['schema']['optional'] extends true ? false : true; + } + >; + }; +}>; + +export const SERIALIZED_SQL_KEY = '__serializedSQL'; +export type SerializedSQL = { + [SERIALIZED_SQL_KEY]: true; + sql: string; +}; + +export function isSerializedSQL(value: any): value is SerializedSQL { + return typeof value === 'object' && value !== null && SERIALIZED_SQL_KEY in value; +} diff --git a/packages/db/src/runtime/utils.ts b/packages/db/src/runtime/utils.ts new file mode 100644 index 000000000..9a979b062 --- /dev/null +++ b/packages/db/src/runtime/utils.ts @@ -0,0 +1,71 @@ +import { LibsqlError } from '@libsql/client'; +import { AstroError } from 'astro/errors'; + +const isWindows = process?.platform === 'win32'; + +/** + * Small wrapper around fetch that throws an error if the response is not OK. Allows for custom error handling as well through the onNotOK callback. + */ +export async function safeFetch( + url: Parameters<typeof fetch>[0], + options: Parameters<typeof fetch>[1] = {}, + onNotOK: (response: Response) => void | Promise<void> = () => { + throw new Error(`Request to ${url} returned a non-OK status code.`); + }, +): Promise<Response> { + const response = await fetch(url, options); + + if (!response.ok) { + await onNotOK(response); + } + + return response; +} + +export class AstroDbError extends AstroError { + name = 'Astro DB Error'; +} + +export class DetailedLibsqlError extends LibsqlError { + name = 'Astro DB Error'; + hint?: string; + + constructor({ + message, + code, + hint, + rawCode, + cause, + }: { message: string; code: string; hint?: string; rawCode?: number; cause?: Error }) { + super(message, code, rawCode, cause); + this.hint = hint; + } +} + +export function isDbError(err: unknown): err is LibsqlError { + return err instanceof LibsqlError || (err instanceof Error && (err as any).libsqlError === true); +} + +function slash(path: string) { + const isExtendedLengthPath = path.startsWith('\\\\?\\'); + + if (isExtendedLengthPath) { + return path; + } + + return path.replace(/\\/g, '/'); +} + +export function pathToFileURL(path: string): URL { + if (isWindows) { + let slashed = slash(path); + // Windows like C:/foo/bar + if (!slashed.startsWith('/')) { + slashed = '/' + slashed; + } + return new URL('file://' + slashed); + } + + // Unix is easy + return new URL('file://' + path); +} diff --git a/packages/db/src/runtime/virtual.ts b/packages/db/src/runtime/virtual.ts new file mode 100644 index 000000000..a44a6ade9 --- /dev/null +++ b/packages/db/src/runtime/virtual.ts @@ -0,0 +1,89 @@ +import { sql as _sql } from 'drizzle-orm'; +import type { + BooleanColumnInput, + ColumnsConfig, + DBConfigInput, + DateColumnInput, + JsonColumnInput, + NumberColumnOpts, + TableConfig, + TextColumnOpts, +} from '../core/types.js'; + +function createColumn<S extends string, T extends Record<string, unknown>>(type: S, schema: T) { + return { + type, + /** + * @internal + */ + schema, + }; +} + +export const column = { + number: <T extends NumberColumnOpts>(opts: T = {} as T) => { + return createColumn('number', opts) satisfies { type: 'number' }; + }, + boolean: <T extends BooleanColumnInput['schema']>(opts: T = {} as T) => { + return createColumn('boolean', opts) satisfies { type: 'boolean' }; + }, + text: <T extends TextColumnOpts>(opts: T = {} as T) => { + return createColumn('text', opts) satisfies { type: 'text' }; + }, + date<T extends DateColumnInput['schema']>(opts: T = {} as T) { + return createColumn('date', opts) satisfies { type: 'date' }; + }, + json<T extends JsonColumnInput['schema']>(opts: T = {} as T) { + return createColumn('json', opts) satisfies { type: 'json' }; + }, +}; + +export function defineTable<TColumns extends ColumnsConfig>(userConfig: TableConfig<TColumns>) { + return userConfig; +} + +export function defineDb(userConfig: DBConfigInput) { + return userConfig; +} + +// Exports a few common expressions +export const NOW = _sql`CURRENT_TIMESTAMP`; +export const TRUE = _sql`TRUE`; +export const FALSE = _sql`FALSE`; + +export { + sql, + eq, + gt, + gte, + lt, + lte, + ne, + isNull, + isNotNull, + inArray, + notInArray, + exists, + notExists, + between, + notBetween, + like, + ilike, + notIlike, + not, + asc, + desc, + and, + or, + count, + countDistinct, + avg, + avgDistinct, + sum, + sumDistinct, + max, + min, +} from 'drizzle-orm'; + +export { alias } from 'drizzle-orm/sqlite-core'; +export { isDbError } from './utils.js'; diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts new file mode 100644 index 000000000..7381c01d3 --- /dev/null +++ b/packages/db/src/utils.ts @@ -0,0 +1,14 @@ +export { defineDbIntegration } from './core/utils.js'; +import { tableSchema } from './core/schemas.js'; +import type { ColumnsConfig, TableConfig } from './core/types.js'; +import { type Table, asDrizzleTable as internal_asDrizzleTable } from './runtime/index.js'; + +export function asDrizzleTable< + TableName extends string = string, + TColumns extends ColumnsConfig = ColumnsConfig, +>(name: TableName, tableConfig: TableConfig<TColumns>) { + return internal_asDrizzleTable(name, tableSchema.parse(tableConfig)) as unknown as Table< + TableName, + TColumns + >; +} diff --git a/packages/db/test/basics.test.js b/packages/db/test/basics.test.js new file mode 100644 index 000000000..8d6167447 --- /dev/null +++ b/packages/db/test/basics.test.js @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { loadFixture } from '../../astro/test/test-utils.js'; +import { clearEnvironment, setupRemoteDbServer } from './test-utils.js'; + +describe('astro:db', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/basics/', import.meta.url), + output: 'server', + adapter: testAdapter(), + }); + }); + + describe({ skip: process.platform === 'darwin' }, 'development', () => { + let devServer; + + before(async () => { + clearEnvironment(); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + + it('Allows expression defaults for date columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeAdded = $($('.themes-list .theme-added')[0]).text(); + assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); + }); + + it('Defaults can be overridden for dates', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeAdded = $($('.themes-list .theme-added')[1]).text(); + assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); + }); + + it('Allows expression defaults for text columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeOwner = $($('.themes-list .theme-owner')[0]).text(); + assert.equal(themeOwner, ''); + }); + + it('Allows expression defaults for boolean columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeDark = $($('.themes-list .theme-dark')[0]).text(); + assert.match(themeDark, /dark mode/); + }); + + it('text fields an be used as references', async () => { + const html = await fixture.fetch('/login').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.match($('.session-id').text(), /12345/); + assert.match($('.username').text(), /Mario/); + }); + + it('Prints authors from raw sql call', async () => { + const json = await fixture.fetch('run.json').then((res) => res.json()); + assert.deepEqual(json, { + columns: ['_id', 'name', 'age2'], + columnTypes: ['INTEGER', 'TEXT', 'INTEGER'], + rows: [ + [1, 'Ben', null], + [2, 'Nate', null], + [3, 'Erika', null], + [4, 'Bjorn', null], + [5, 'Sarah', null], + ], + rowsAffected: 0, + lastInsertRowid: null, + }); + }); + }); + + describe({ skip: process.platform === 'darwin' }, 'development --remote', () => { + let devServer; + let remoteDbServer; + + before(async () => { + clearEnvironment(); + remoteDbServer = await setupRemoteDbServer(fixture.config); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer?.stop(); + await remoteDbServer?.stop(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + + it('Allows expression defaults for date columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeAdded = $($('.themes-list .theme-added')[0]).text(); + assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); + }); + + it('Defaults can be overridden for dates', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeAdded = $($('.themes-list .theme-added')[1]).text(); + assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); + }); + + it('Allows expression defaults for text columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeOwner = $($('.themes-list .theme-owner')[0]).text(); + assert.equal(themeOwner, ''); + }); + + it('Allows expression defaults for boolean columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeDark = $($('.themes-list .theme-dark')[0]).text(); + assert.match(themeDark, /dark mode/); + }); + + it('text fields an be used as references', async () => { + const html = await fixture.fetch('/login').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.match($('.session-id').text(), /12345/); + assert.match($('.username').text(), /Mario/); + }); + + it('Prints authors from raw sql call', async () => { + const json = await fixture.fetch('run.json').then((res) => res.json()); + assert.deepEqual(json, { + columns: ['_id', 'name', 'age2'], + columnTypes: ['INTEGER', 'TEXT', 'INTEGER'], + rows: [ + [1, 'Ben', null], + [2, 'Nate', null], + [3, 'Erika', null], + [4, 'Bjorn', null], + [5, 'Sarah', null], + ], + rowsAffected: 0, + lastInsertRowid: null, + }); + }); + }); + + describe('build --remote', () => { + let remoteDbServer; + + before(async () => { + clearEnvironment(); + process.env.ASTRO_STUDIO_APP_TOKEN = 'some token'; + remoteDbServer = await setupRemoteDbServer(fixture.config); + await fixture.build(); + }); + + after(async () => { + process.env.ASTRO_STUDIO_APP_TOKEN = ''; + await remoteDbServer?.stop(); + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + }); + }); +}); diff --git a/packages/db/test/db-in-src.test.js b/packages/db/test/db-in-src.test.js new file mode 100644 index 000000000..5e29b7372 --- /dev/null +++ b/packages/db/test/db-in-src.test.js @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/db-in-src/', import.meta.url), + output: 'server', + srcDir: '.', + adapter: testAdapter(), + }); + }); + + describe('development: db/ folder inside srcDir', () => { + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.users-list'); + assert.equal(ul.children().length, 1); + assert.match($('.users-list li').text(), /Mario/); + }); + }); +}); diff --git a/packages/db/test/error-handling.test.js b/packages/db/test/error-handling.test.js new file mode 100644 index 000000000..5ca9ce5c2 --- /dev/null +++ b/packages/db/test/error-handling.test.js @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { loadFixture } from '../../astro/test/test-utils.js'; +import { setupRemoteDbServer } from './test-utils.js'; + +const foreignKeyConstraintError = + 'LibsqlError: SQLITE_CONSTRAINT_FOREIGNKEY: FOREIGN KEY constraint failed'; + +describe('astro:db - error handling', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/error-handling/', import.meta.url), + }); + }); + + describe('development', () => { + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Raises foreign key constraint LibsqlError', async () => { + const json = await fixture.fetch('/foreign-key-constraint.json').then((res) => res.json()); + assert.deepEqual(json, { + message: foreignKeyConstraintError, + code: 'SQLITE_CONSTRAINT_FOREIGNKEY', + }); + }); + }); + + describe('build --remote', () => { + let remoteDbServer; + + before(async () => { + remoteDbServer = await setupRemoteDbServer(fixture.config); + await fixture.build(); + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Raises foreign key constraint LibsqlError', async () => { + const json = await fixture.readFile('/foreign-key-constraint.json'); + assert.deepEqual(JSON.parse(json), { + message: foreignKeyConstraintError, + code: 'SQLITE_CONSTRAINT_FOREIGNKEY', + }); + }); + }); +}); diff --git a/packages/db/test/fixtures/basics/astro.config.ts b/packages/db/test/fixtures/basics/astro.config.ts new file mode 100644 index 000000000..983a6947d --- /dev/null +++ b/packages/db/test/fixtures/basics/astro.config.ts @@ -0,0 +1,10 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db()], + devToolbar: { + enabled: false, + }, +}); diff --git a/packages/db/test/fixtures/basics/db/config.ts b/packages/db/test/fixtures/basics/db/config.ts new file mode 100644 index 000000000..010ed3a18 --- /dev/null +++ b/packages/db/test/fixtures/basics/db/config.ts @@ -0,0 +1,29 @@ +import { column, defineDb, defineTable } from 'astro:db'; +import { Themes } from './theme'; + +const Author = defineTable({ + columns: { + name: column.text(), + age2: column.number({ optional: true }), + }, +}); + +const User = defineTable({ + columns: { + id: column.text({ primaryKey: true, optional: false }), + username: column.text({ optional: false, unique: true }), + password: column.text({ optional: false }), + }, +}); + +const Session = defineTable({ + columns: { + id: column.text({ primaryKey: true, optional: false }), + expiresAt: column.number({ optional: false, name: 'expires_at' }), + userId: column.text({ optional: false, references: () => User.columns.id, name: 'user_id' }), + }, +}); + +export default defineDb({ + tables: { Author, Themes, User, Session }, +}); diff --git a/packages/db/test/fixtures/basics/db/seed.ts b/packages/db/test/fixtures/basics/db/seed.ts new file mode 100644 index 000000000..9a1ef4322 --- /dev/null +++ b/packages/db/test/fixtures/basics/db/seed.ts @@ -0,0 +1,24 @@ +import { Author, Session, User, db } from 'astro:db'; +import { asDrizzleTable } from '@astrojs/db/utils'; +import { Themes as ThemesConfig } from './theme'; + +const Themes = asDrizzleTable('Themes', ThemesConfig); +export default async function () { + await db.batch([ + db + .insert(Themes) + .values([{ name: 'dracula' }, { name: 'monokai', added: new Date() }]) + .returning({ name: Themes.name }), + db + .insert(Author) + .values([ + { name: 'Ben' }, + { name: 'Nate' }, + { name: 'Erika' }, + { name: 'Bjorn' }, + { name: 'Sarah' }, + ]), + db.insert(User).values([{ id: 'mario', username: 'Mario', password: 'itsame' }]), + db.insert(Session).values([{ id: '12345', expiresAt: new Date().valueOf(), userId: 'mario' }]), + ]); +} diff --git a/packages/db/test/fixtures/basics/db/theme.ts b/packages/db/test/fixtures/basics/db/theme.ts new file mode 100644 index 000000000..015dcc588 --- /dev/null +++ b/packages/db/test/fixtures/basics/db/theme.ts @@ -0,0 +1,15 @@ +import { NOW, column, defineTable, sql } from 'astro:db'; + +export const Themes = defineTable({ + columns: { + name: column.text(), + added: column.date({ + default: sql`CURRENT_TIMESTAMP`, + }), + updated: column.date({ + default: NOW, + }), + isDark: column.boolean({ default: sql`TRUE`, deprecated: true }), + owner: column.text({ optional: true, default: sql`NULL` }), + }, +}); diff --git a/packages/db/test/fixtures/basics/package.json b/packages/db/test/fixtures/basics/package.json new file mode 100644 index 000000000..af7cbe229 --- /dev/null +++ b/packages/db/test/fixtures/basics/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/db-aliases", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/basics/src/pages/index.astro b/packages/db/test/fixtures/basics/src/pages/index.astro new file mode 100644 index 000000000..2be0c4b23 --- /dev/null +++ b/packages/db/test/fixtures/basics/src/pages/index.astro @@ -0,0 +1,27 @@ +--- +/// <reference path="../../.astro/db-types.d.ts" /> +import { Author, Themes, db } from 'astro:db'; + +const authors = await db.select().from(Author); +const themes = await db.select().from(Themes); +--- + +<h2>Authors</h2> +<ul class="authors-list"> + {authors.map((author) => <li>{author.name}</li>)} +</ul> + +<h2>Themes</h2> +<ul class="themes-list"> + { + themes.map((theme) => ( + <li> + <div class="theme-name">{theme.name}</div> + <div class="theme-added">{theme.added}</div> + <div class="theme-updated">{theme.updated}</div> + <div class="theme-dark">{theme.isDark ? 'dark' : 'light'} mode</div> + <div class="theme-owner">{theme.owner}</div> + </li> + )) + } +</ul> diff --git a/packages/db/test/fixtures/basics/src/pages/login.astro b/packages/db/test/fixtures/basics/src/pages/login.astro new file mode 100644 index 000000000..4551fc483 --- /dev/null +++ b/packages/db/test/fixtures/basics/src/pages/login.astro @@ -0,0 +1,18 @@ +--- +import { Session, User, db, eq } from 'astro:db'; + +const users = await db.select().from(User); +const sessions = await db.select().from(Session).innerJoin(User, eq(Session.userId, User.id)); +--- + +<h2>Sessions</h2> +<ul class="sessions-list"> + { + sessions.map(({ Session, User }) => ( + <li> + <div class="session-id">{Session.id}</div> + <div class="username">{User.username}</div> + </li> + )) + } +</ul> diff --git a/packages/db/test/fixtures/basics/src/pages/run.json.ts b/packages/db/test/fixtures/basics/src/pages/run.json.ts new file mode 100644 index 000000000..a86619314 --- /dev/null +++ b/packages/db/test/fixtures/basics/src/pages/run.json.ts @@ -0,0 +1,12 @@ +import { db, sql } from 'astro:db'; +/// <reference types="@astrojs/db" /> +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async () => { + const authors = await db.run(sql`SELECT * FROM Author`); + return new Response(JSON.stringify(authors), { + headers: { + 'content-type': 'application/json', + }, + }); +}; diff --git a/packages/db/test/fixtures/db-in-src/astro.config.ts b/packages/db/test/fixtures/db-in-src/astro.config.ts new file mode 100644 index 000000000..983a6947d --- /dev/null +++ b/packages/db/test/fixtures/db-in-src/astro.config.ts @@ -0,0 +1,10 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db()], + devToolbar: { + enabled: false, + }, +}); diff --git a/packages/db/test/fixtures/db-in-src/db/config.ts b/packages/db/test/fixtures/db-in-src/db/config.ts new file mode 100644 index 000000000..44c15abe7 --- /dev/null +++ b/packages/db/test/fixtures/db-in-src/db/config.ts @@ -0,0 +1,13 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const User = defineTable({ + columns: { + id: column.text({ primaryKey: true, optional: false }), + username: column.text({ optional: false, unique: true }), + password: column.text({ optional: false }), + }, +}); + +export default defineDb({ + tables: { User }, +}); diff --git a/packages/db/test/fixtures/db-in-src/db/seed.ts b/packages/db/test/fixtures/db-in-src/db/seed.ts new file mode 100644 index 000000000..a84e63454 --- /dev/null +++ b/packages/db/test/fixtures/db-in-src/db/seed.ts @@ -0,0 +1,8 @@ +import { User, db } from 'astro:db'; +import { asDrizzleTable } from '@astrojs/db/utils'; + +export default async function () { + await db.batch([ + db.insert(User).values([{ id: 'mario', username: 'Mario', password: 'itsame' }]), + ]); +} diff --git a/packages/db/test/fixtures/db-in-src/package.json b/packages/db/test/fixtures/db-in-src/package.json new file mode 100644 index 000000000..a1580d1cb --- /dev/null +++ b/packages/db/test/fixtures/db-in-src/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/db-db-in-src", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/db-in-src/pages/index.astro b/packages/db/test/fixtures/db-in-src/pages/index.astro new file mode 100644 index 000000000..4b79dba2c --- /dev/null +++ b/packages/db/test/fixtures/db-in-src/pages/index.astro @@ -0,0 +1,11 @@ +--- +/// <reference path="../.astro/db-types.d.ts" /> +import { User, db } from 'astro:db'; + +const users = await db.select().from(User); +--- + +<h2>Users</h2> +<ul class="users-list"> + {users.map((user) => <li>{user.username}</li>)} +</ul> diff --git a/packages/db/test/fixtures/error-handling/astro.config.ts b/packages/db/test/fixtures/error-handling/astro.config.ts new file mode 100644 index 000000000..983a6947d --- /dev/null +++ b/packages/db/test/fixtures/error-handling/astro.config.ts @@ -0,0 +1,10 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db()], + devToolbar: { + enabled: false, + }, +}); diff --git a/packages/db/test/fixtures/error-handling/db/config.ts b/packages/db/test/fixtures/error-handling/db/config.ts new file mode 100644 index 000000000..bd4d6edaf --- /dev/null +++ b/packages/db/test/fixtures/error-handling/db/config.ts @@ -0,0 +1,26 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const Recipe = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + title: column.text(), + description: column.text(), + }, +}); + +const Ingredient = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + name: column.text(), + quantity: column.number(), + recipeId: column.number(), + }, + indexes: { + recipeIdx: { on: 'recipeId' }, + }, + foreignKeys: [{ columns: 'recipeId', references: () => [Recipe.columns.id] }], +}); + +export default defineDb({ + tables: { Recipe, Ingredient }, +}); diff --git a/packages/db/test/fixtures/error-handling/db/seed.ts b/packages/db/test/fixtures/error-handling/db/seed.ts new file mode 100644 index 000000000..1ca219f15 --- /dev/null +++ b/packages/db/test/fixtures/error-handling/db/seed.ts @@ -0,0 +1,62 @@ +import { Ingredient, Recipe, db } from 'astro:db'; + +export default async function () { + const pancakes = await db + .insert(Recipe) + .values({ + title: 'Pancakes', + description: 'A delicious breakfast', + }) + .returning() + .get(); + + await db.insert(Ingredient).values([ + { + name: 'Flour', + quantity: 1, + recipeId: pancakes.id, + }, + { + name: 'Eggs', + quantity: 2, + recipeId: pancakes.id, + }, + { + name: 'Milk', + quantity: 1, + recipeId: pancakes.id, + }, + ]); + + const pizza = await db + .insert(Recipe) + .values({ + title: 'Pizza', + description: 'A delicious dinner', + }) + .returning() + .get(); + + await db.insert(Ingredient).values([ + { + name: 'Flour', + quantity: 1, + recipeId: pizza.id, + }, + { + name: 'Eggs', + quantity: 2, + recipeId: pizza.id, + }, + { + name: 'Milk', + quantity: 1, + recipeId: pizza.id, + }, + { + name: 'Tomato Sauce', + quantity: 1, + recipeId: pizza.id, + }, + ]); +} diff --git a/packages/db/test/fixtures/error-handling/package.json b/packages/db/test/fixtures/error-handling/package.json new file mode 100644 index 000000000..e0839956b --- /dev/null +++ b/packages/db/test/fixtures/error-handling/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/error-handling", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/error-handling/src/pages/foreign-key-constraint.json.ts b/packages/db/test/fixtures/error-handling/src/pages/foreign-key-constraint.json.ts new file mode 100644 index 000000000..358a9a95c --- /dev/null +++ b/packages/db/test/fixtures/error-handling/src/pages/foreign-key-constraint.json.ts @@ -0,0 +1,18 @@ +import { Ingredient, db, isDbError } from 'astro:db'; +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async () => { + try { + await db.insert(Ingredient).values({ + name: 'Flour', + quantity: 1, + // Trigger foreign key constraint error + recipeId: 42, + }); + } catch (e) { + if (isDbError(e)) { + return new Response(JSON.stringify({ message: `LibsqlError: ${e.message}`, code: e.code })); + } + } + return new Response(JSON.stringify({ message: 'Did not raise expected exception' })); +}; diff --git a/packages/db/test/fixtures/integration-only/astro.config.mjs b/packages/db/test/fixtures/integration-only/astro.config.mjs new file mode 100644 index 000000000..23f52739e --- /dev/null +++ b/packages/db/test/fixtures/integration-only/astro.config.mjs @@ -0,0 +1,8 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; +import testIntegration from './integration'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db(), testIntegration()], +}); diff --git a/packages/db/test/fixtures/integration-only/integration/config.ts b/packages/db/test/fixtures/integration-only/integration/config.ts new file mode 100644 index 000000000..71490be95 --- /dev/null +++ b/packages/db/test/fixtures/integration-only/integration/config.ts @@ -0,0 +1,8 @@ +import { defineDb } from 'astro:db'; +import { menu } from './shared'; + +export default defineDb({ + tables: { + menu, + }, +}); diff --git a/packages/db/test/fixtures/integration-only/integration/index.ts b/packages/db/test/fixtures/integration-only/integration/index.ts new file mode 100644 index 000000000..b249cc253 --- /dev/null +++ b/packages/db/test/fixtures/integration-only/integration/index.ts @@ -0,0 +1,15 @@ +import { defineDbIntegration } from '@astrojs/db/utils'; + +export default function testIntegration() { + return defineDbIntegration({ + name: 'db-test-integration', + hooks: { + 'astro:db:setup'({ extendDb }) { + extendDb({ + configEntrypoint: './integration/config.ts', + seedEntrypoint: './integration/seed.ts', + }); + }, + }, + }); +} diff --git a/packages/db/test/fixtures/integration-only/integration/seed.ts b/packages/db/test/fixtures/integration-only/integration/seed.ts new file mode 100644 index 000000000..ed2b2e2eb --- /dev/null +++ b/packages/db/test/fixtures/integration-only/integration/seed.ts @@ -0,0 +1,14 @@ +import { db } from 'astro:db'; +import { asDrizzleTable } from '@astrojs/db/utils'; +import { menu } from './shared'; + +export default async function () { + const table = asDrizzleTable('menu', menu); + + await db.insert(table).values([ + { name: 'Pancakes', price: 9.5, type: 'Breakfast' }, + { name: 'French Toast', price: 11.25, type: 'Breakfast' }, + { name: 'Coffee', price: 3, type: 'Beverages' }, + { name: 'Cappuccino', price: 4.5, type: 'Beverages' }, + ]); +} diff --git a/packages/db/test/fixtures/integration-only/integration/shared.ts b/packages/db/test/fixtures/integration-only/integration/shared.ts new file mode 100644 index 000000000..d46ae65a6 --- /dev/null +++ b/packages/db/test/fixtures/integration-only/integration/shared.ts @@ -0,0 +1,9 @@ +import { column, defineTable } from 'astro:db'; + +export const menu = defineTable({ + columns: { + name: column.text(), + type: column.text(), + price: column.number(), + }, +}); diff --git a/packages/db/test/fixtures/integration-only/package.json b/packages/db/test/fixtures/integration-only/package.json new file mode 100644 index 000000000..4229f710a --- /dev/null +++ b/packages/db/test/fixtures/integration-only/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/db-integration-only", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/integration-only/src/pages/index.astro b/packages/db/test/fixtures/integration-only/src/pages/index.astro new file mode 100644 index 000000000..7b204e124 --- /dev/null +++ b/packages/db/test/fixtures/integration-only/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +/// <reference path="../../.astro/db-types.d.ts" /> +import { db, menu } from 'astro:db'; + +const menuItems = await db.select().from(menu); +--- + +<h2>Menu</h2> +<ul class="menu"> + {menuItems.map((item) => <li>{item.name}</li>)} +</ul> diff --git a/packages/db/test/fixtures/integrations/astro.config.mjs b/packages/db/test/fixtures/integrations/astro.config.mjs new file mode 100644 index 000000000..23f52739e --- /dev/null +++ b/packages/db/test/fixtures/integrations/astro.config.mjs @@ -0,0 +1,8 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; +import testIntegration from './integration'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db(), testIntegration()], +}); diff --git a/packages/db/test/fixtures/integrations/db/config.ts b/packages/db/test/fixtures/integrations/db/config.ts new file mode 100644 index 000000000..b8110406a --- /dev/null +++ b/packages/db/test/fixtures/integrations/db/config.ts @@ -0,0 +1,12 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const Author = defineTable({ + columns: { + name: column.text(), + age2: column.number({ optional: true }), + }, +}); + +export default defineDb({ + tables: { Author }, +}); diff --git a/packages/db/test/fixtures/integrations/db/seed.ts b/packages/db/test/fixtures/integrations/db/seed.ts new file mode 100644 index 000000000..56ffb5668 --- /dev/null +++ b/packages/db/test/fixtures/integrations/db/seed.ts @@ -0,0 +1,13 @@ +import { Author, db } from 'astro:db'; + +export default async () => { + await db + .insert(Author) + .values([ + { name: 'Ben' }, + { name: 'Nate' }, + { name: 'Erika' }, + { name: 'Bjorn' }, + { name: 'Sarah' }, + ]); +}; diff --git a/packages/db/test/fixtures/integrations/integration/config.ts b/packages/db/test/fixtures/integrations/integration/config.ts new file mode 100644 index 000000000..71490be95 --- /dev/null +++ b/packages/db/test/fixtures/integrations/integration/config.ts @@ -0,0 +1,8 @@ +import { defineDb } from 'astro:db'; +import { menu } from './shared'; + +export default defineDb({ + tables: { + menu, + }, +}); diff --git a/packages/db/test/fixtures/integrations/integration/index.ts b/packages/db/test/fixtures/integrations/integration/index.ts new file mode 100644 index 000000000..b249cc253 --- /dev/null +++ b/packages/db/test/fixtures/integrations/integration/index.ts @@ -0,0 +1,15 @@ +import { defineDbIntegration } from '@astrojs/db/utils'; + +export default function testIntegration() { + return defineDbIntegration({ + name: 'db-test-integration', + hooks: { + 'astro:db:setup'({ extendDb }) { + extendDb({ + configEntrypoint: './integration/config.ts', + seedEntrypoint: './integration/seed.ts', + }); + }, + }, + }); +} diff --git a/packages/db/test/fixtures/integrations/integration/seed.ts b/packages/db/test/fixtures/integrations/integration/seed.ts new file mode 100644 index 000000000..ed2b2e2eb --- /dev/null +++ b/packages/db/test/fixtures/integrations/integration/seed.ts @@ -0,0 +1,14 @@ +import { db } from 'astro:db'; +import { asDrizzleTable } from '@astrojs/db/utils'; +import { menu } from './shared'; + +export default async function () { + const table = asDrizzleTable('menu', menu); + + await db.insert(table).values([ + { name: 'Pancakes', price: 9.5, type: 'Breakfast' }, + { name: 'French Toast', price: 11.25, type: 'Breakfast' }, + { name: 'Coffee', price: 3, type: 'Beverages' }, + { name: 'Cappuccino', price: 4.5, type: 'Beverages' }, + ]); +} diff --git a/packages/db/test/fixtures/integrations/integration/shared.ts b/packages/db/test/fixtures/integrations/integration/shared.ts new file mode 100644 index 000000000..d46ae65a6 --- /dev/null +++ b/packages/db/test/fixtures/integrations/integration/shared.ts @@ -0,0 +1,9 @@ +import { column, defineTable } from 'astro:db'; + +export const menu = defineTable({ + columns: { + name: column.text(), + type: column.text(), + price: column.number(), + }, +}); diff --git a/packages/db/test/fixtures/integrations/package.json b/packages/db/test/fixtures/integrations/package.json new file mode 100644 index 000000000..1bb17a8c7 --- /dev/null +++ b/packages/db/test/fixtures/integrations/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/db-integration", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/integrations/src/pages/index.astro b/packages/db/test/fixtures/integrations/src/pages/index.astro new file mode 100644 index 000000000..3e9c30ef7 --- /dev/null +++ b/packages/db/test/fixtures/integrations/src/pages/index.astro @@ -0,0 +1,17 @@ +--- +/// <reference path="../../.astro/db-types.d.ts" /> +import { Author, db, menu } from 'astro:db'; + +const authors = await db.select().from(Author); +const menuItems = await db.select().from(menu); +--- + +<h2>Authors</h2> +<ul class="authors-list"> + {authors.map((author) => <li>{author.name}</li>)} +</ul> + +<h2>Menu</h2> +<ul class="menu"> + {menuItems.map((item) => <li>{item.name}</li>)} +</ul> diff --git a/packages/db/test/fixtures/libsql-remote/astro.config.ts b/packages/db/test/fixtures/libsql-remote/astro.config.ts new file mode 100644 index 000000000..983a6947d --- /dev/null +++ b/packages/db/test/fixtures/libsql-remote/astro.config.ts @@ -0,0 +1,10 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db()], + devToolbar: { + enabled: false, + }, +}); diff --git a/packages/db/test/fixtures/libsql-remote/db/config.ts b/packages/db/test/fixtures/libsql-remote/db/config.ts new file mode 100644 index 000000000..44c15abe7 --- /dev/null +++ b/packages/db/test/fixtures/libsql-remote/db/config.ts @@ -0,0 +1,13 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const User = defineTable({ + columns: { + id: column.text({ primaryKey: true, optional: false }), + username: column.text({ optional: false, unique: true }), + password: column.text({ optional: false }), + }, +}); + +export default defineDb({ + tables: { User }, +}); diff --git a/packages/db/test/fixtures/libsql-remote/db/seed.ts b/packages/db/test/fixtures/libsql-remote/db/seed.ts new file mode 100644 index 000000000..7d9aa3292 --- /dev/null +++ b/packages/db/test/fixtures/libsql-remote/db/seed.ts @@ -0,0 +1,7 @@ +import { User, db } from 'astro:db'; + +export default async function () { + await db.batch([ + db.insert(User).values([{ id: 'mario', username: 'Mario', password: 'itsame' }]), + ]); +} diff --git a/packages/db/test/fixtures/libsql-remote/package.json b/packages/db/test/fixtures/libsql-remote/package.json new file mode 100644 index 000000000..2970a62d5 --- /dev/null +++ b/packages/db/test/fixtures/libsql-remote/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/db-libsql-remote", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/libsql-remote/src/pages/index.astro b/packages/db/test/fixtures/libsql-remote/src/pages/index.astro new file mode 100644 index 000000000..f36d44bd4 --- /dev/null +++ b/packages/db/test/fixtures/libsql-remote/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +/// <reference path="../../.astro/db-types.d.ts" /> +import { User, db } from 'astro:db'; + +const users = await db.select().from(User); +--- + +<h2>Users</h2> +<ul class="users-list"> + {users.map((user) => <li>{user.name}</li>)} +</ul> diff --git a/packages/db/test/fixtures/local-prod/astro.config.ts b/packages/db/test/fixtures/local-prod/astro.config.ts new file mode 100644 index 000000000..983a6947d --- /dev/null +++ b/packages/db/test/fixtures/local-prod/astro.config.ts @@ -0,0 +1,10 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db()], + devToolbar: { + enabled: false, + }, +}); diff --git a/packages/db/test/fixtures/local-prod/db/config.ts b/packages/db/test/fixtures/local-prod/db/config.ts new file mode 100644 index 000000000..44c15abe7 --- /dev/null +++ b/packages/db/test/fixtures/local-prod/db/config.ts @@ -0,0 +1,13 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const User = defineTable({ + columns: { + id: column.text({ primaryKey: true, optional: false }), + username: column.text({ optional: false, unique: true }), + password: column.text({ optional: false }), + }, +}); + +export default defineDb({ + tables: { User }, +}); diff --git a/packages/db/test/fixtures/local-prod/db/seed.ts b/packages/db/test/fixtures/local-prod/db/seed.ts new file mode 100644 index 000000000..a84e63454 --- /dev/null +++ b/packages/db/test/fixtures/local-prod/db/seed.ts @@ -0,0 +1,8 @@ +import { User, db } from 'astro:db'; +import { asDrizzleTable } from '@astrojs/db/utils'; + +export default async function () { + await db.batch([ + db.insert(User).values([{ id: 'mario', username: 'Mario', password: 'itsame' }]), + ]); +} diff --git a/packages/db/test/fixtures/local-prod/package.json b/packages/db/test/fixtures/local-prod/package.json new file mode 100644 index 000000000..2d11d5347 --- /dev/null +++ b/packages/db/test/fixtures/local-prod/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/db-local-prod", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/local-prod/src/pages/index.astro b/packages/db/test/fixtures/local-prod/src/pages/index.astro new file mode 100644 index 000000000..f36d44bd4 --- /dev/null +++ b/packages/db/test/fixtures/local-prod/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +/// <reference path="../../.astro/db-types.d.ts" /> +import { User, db } from 'astro:db'; + +const users = await db.select().from(User); +--- + +<h2>Users</h2> +<ul class="users-list"> + {users.map((user) => <li>{user.name}</li>)} +</ul> diff --git a/packages/db/test/fixtures/no-apptoken/astro.config.ts b/packages/db/test/fixtures/no-apptoken/astro.config.ts new file mode 100644 index 000000000..983a6947d --- /dev/null +++ b/packages/db/test/fixtures/no-apptoken/astro.config.ts @@ -0,0 +1,10 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db()], + devToolbar: { + enabled: false, + }, +}); diff --git a/packages/db/test/fixtures/no-apptoken/db/config.ts b/packages/db/test/fixtures/no-apptoken/db/config.ts new file mode 100644 index 000000000..44c15abe7 --- /dev/null +++ b/packages/db/test/fixtures/no-apptoken/db/config.ts @@ -0,0 +1,13 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const User = defineTable({ + columns: { + id: column.text({ primaryKey: true, optional: false }), + username: column.text({ optional: false, unique: true }), + password: column.text({ optional: false }), + }, +}); + +export default defineDb({ + tables: { User }, +}); diff --git a/packages/db/test/fixtures/no-apptoken/db/seed.ts b/packages/db/test/fixtures/no-apptoken/db/seed.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/packages/db/test/fixtures/no-apptoken/db/seed.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/packages/db/test/fixtures/no-apptoken/package.json b/packages/db/test/fixtures/no-apptoken/package.json new file mode 100644 index 000000000..a7e17d1af --- /dev/null +++ b/packages/db/test/fixtures/no-apptoken/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/db-no-apptoken", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/no-apptoken/src/pages/index.astro b/packages/db/test/fixtures/no-apptoken/src/pages/index.astro new file mode 100644 index 000000000..477e18fa3 --- /dev/null +++ b/packages/db/test/fixtures/no-apptoken/src/pages/index.astro @@ -0,0 +1,16 @@ +--- +/// <reference path="../../.astro/db-types.d.ts" /> +import { User, db } from 'astro:db'; + +// Just for the side-effect of running all the code +await db.select().from(User); +--- + +<html> + <head> + <title>Testing</title> + </head> + <body> + <h1>Testing</h1> + </body> +</html> diff --git a/packages/db/test/fixtures/no-seed/astro.config.ts b/packages/db/test/fixtures/no-seed/astro.config.ts new file mode 100644 index 000000000..5ff1200e2 --- /dev/null +++ b/packages/db/test/fixtures/no-seed/astro.config.ts @@ -0,0 +1,7 @@ +import db from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db()], +}); diff --git a/packages/db/test/fixtures/no-seed/db/config.ts b/packages/db/test/fixtures/no-seed/db/config.ts new file mode 100644 index 000000000..b8110406a --- /dev/null +++ b/packages/db/test/fixtures/no-seed/db/config.ts @@ -0,0 +1,12 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const Author = defineTable({ + columns: { + name: column.text(), + age2: column.number({ optional: true }), + }, +}); + +export default defineDb({ + tables: { Author }, +}); diff --git a/packages/db/test/fixtures/no-seed/package.json b/packages/db/test/fixtures/no-seed/package.json new file mode 100644 index 000000000..66a192697 --- /dev/null +++ b/packages/db/test/fixtures/no-seed/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/db-no-seed", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/no-seed/src/pages/index.astro b/packages/db/test/fixtures/no-seed/src/pages/index.astro new file mode 100644 index 000000000..bacd873e1 --- /dev/null +++ b/packages/db/test/fixtures/no-seed/src/pages/index.astro @@ -0,0 +1,21 @@ +--- +/// <reference path="../../.astro/db-types.d.ts" /> +import { Author, db } from 'astro:db'; + +await db + .insert(Author) + .values([ + { name: 'Ben' }, + { name: 'Nate' }, + { name: 'Erika' }, + { name: 'Bjorn' }, + { name: 'Sarah' }, + ]); + +const authors = await db.select().from(Author); +--- + +<h2>Authors</h2> +<ul class="authors-list"> + {authors.map((author) => <li>{author.name}</li>)} +</ul> diff --git a/packages/db/test/fixtures/recipes/astro.config.ts b/packages/db/test/fixtures/recipes/astro.config.ts new file mode 100644 index 000000000..bd6088769 --- /dev/null +++ b/packages/db/test/fixtures/recipes/astro.config.ts @@ -0,0 +1,6 @@ +import astroDb from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + integrations: [astroDb()], +}); diff --git a/packages/db/test/fixtures/recipes/db/config.ts b/packages/db/test/fixtures/recipes/db/config.ts new file mode 100644 index 000000000..bd4d6edaf --- /dev/null +++ b/packages/db/test/fixtures/recipes/db/config.ts @@ -0,0 +1,26 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const Recipe = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + title: column.text(), + description: column.text(), + }, +}); + +const Ingredient = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + name: column.text(), + quantity: column.number(), + recipeId: column.number(), + }, + indexes: { + recipeIdx: { on: 'recipeId' }, + }, + foreignKeys: [{ columns: 'recipeId', references: () => [Recipe.columns.id] }], +}); + +export default defineDb({ + tables: { Recipe, Ingredient }, +}); diff --git a/packages/db/test/fixtures/recipes/db/seed.ts b/packages/db/test/fixtures/recipes/db/seed.ts new file mode 100644 index 000000000..1ca219f15 --- /dev/null +++ b/packages/db/test/fixtures/recipes/db/seed.ts @@ -0,0 +1,62 @@ +import { Ingredient, Recipe, db } from 'astro:db'; + +export default async function () { + const pancakes = await db + .insert(Recipe) + .values({ + title: 'Pancakes', + description: 'A delicious breakfast', + }) + .returning() + .get(); + + await db.insert(Ingredient).values([ + { + name: 'Flour', + quantity: 1, + recipeId: pancakes.id, + }, + { + name: 'Eggs', + quantity: 2, + recipeId: pancakes.id, + }, + { + name: 'Milk', + quantity: 1, + recipeId: pancakes.id, + }, + ]); + + const pizza = await db + .insert(Recipe) + .values({ + title: 'Pizza', + description: 'A delicious dinner', + }) + .returning() + .get(); + + await db.insert(Ingredient).values([ + { + name: 'Flour', + quantity: 1, + recipeId: pizza.id, + }, + { + name: 'Eggs', + quantity: 2, + recipeId: pizza.id, + }, + { + name: 'Milk', + quantity: 1, + recipeId: pizza.id, + }, + { + name: 'Tomato Sauce', + quantity: 1, + recipeId: pizza.id, + }, + ]); +} diff --git a/packages/db/test/fixtures/recipes/package.json b/packages/db/test/fixtures/recipes/package.json new file mode 100644 index 000000000..cd1e83c02 --- /dev/null +++ b/packages/db/test/fixtures/recipes/package.json @@ -0,0 +1,16 @@ +{ + "name": "@test/recipes", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/recipes/src/pages/index.astro b/packages/db/test/fixtures/recipes/src/pages/index.astro new file mode 100644 index 000000000..9fd2dac41 --- /dev/null +++ b/packages/db/test/fixtures/recipes/src/pages/index.astro @@ -0,0 +1,25 @@ +--- +/// <reference path="../../.astro/db-types.d.ts" /> +import { Ingredient, Recipe, db, eq } from 'astro:db'; + +const ingredientsByRecipe = await db + .select({ + name: Ingredient.name, + recipeName: Recipe.title, + }) + .from(Ingredient) + .innerJoin(Recipe, eq(Ingredient.recipeId, Recipe.id)); + +console.log(ingredientsByRecipe); +--- + +<h2>Shopping list</h2> +<ul> + { + ingredientsByRecipe.map(({ name, recipeName }) => ( + <li> + {name} ({recipeName}) + </li> + )) + } +</ul> diff --git a/packages/db/test/fixtures/static-remote/astro.config.ts b/packages/db/test/fixtures/static-remote/astro.config.ts new file mode 100644 index 000000000..bd6088769 --- /dev/null +++ b/packages/db/test/fixtures/static-remote/astro.config.ts @@ -0,0 +1,6 @@ +import astroDb from '@astrojs/db'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + integrations: [astroDb()], +}); diff --git a/packages/db/test/fixtures/static-remote/db/config.ts b/packages/db/test/fixtures/static-remote/db/config.ts new file mode 100644 index 000000000..8df4674d8 --- /dev/null +++ b/packages/db/test/fixtures/static-remote/db/config.ts @@ -0,0 +1,12 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const User = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + name: column.text(), + }, +}); + +export default defineDb({ + tables: { User }, +}); diff --git a/packages/db/test/fixtures/static-remote/db/seed.ts b/packages/db/test/fixtures/static-remote/db/seed.ts new file mode 100644 index 000000000..2c86f02a1 --- /dev/null +++ b/packages/db/test/fixtures/static-remote/db/seed.ts @@ -0,0 +1,9 @@ +import { User, db } from 'astro:db'; + +export default async function () { + await db.insert(User).values([ + { + name: 'Houston', + }, + ]); +} diff --git a/packages/db/test/fixtures/static-remote/package.json b/packages/db/test/fixtures/static-remote/package.json new file mode 100644 index 000000000..aa2c9c23c --- /dev/null +++ b/packages/db/test/fixtures/static-remote/package.json @@ -0,0 +1,16 @@ +{ + "name": "@test/db-static-remote", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/db/test/fixtures/static-remote/src/pages/index.astro b/packages/db/test/fixtures/static-remote/src/pages/index.astro new file mode 100644 index 000000000..849e65d18 --- /dev/null +++ b/packages/db/test/fixtures/static-remote/src/pages/index.astro @@ -0,0 +1,19 @@ +--- +import { User, db } from 'astro:db'; + +const users = await db.select().from(User); +--- + +<html> + <head> + <title>Testing</title> + </head> + <body> + <h1>Testing</h1> + + <h2>Users</h2> + <ul> + {users.map((user) => <li>{user.name}</li>)} + </ul> + </body> +</html> diff --git a/packages/db/test/fixtures/static-remote/src/pages/run.astro b/packages/db/test/fixtures/static-remote/src/pages/run.astro new file mode 100644 index 000000000..2f2ac1cce --- /dev/null +++ b/packages/db/test/fixtures/static-remote/src/pages/run.astro @@ -0,0 +1,17 @@ +--- +import { User, db, sql } from 'astro:db'; + +const results = await db.run(sql`SELECT 1 as value`); +const row = results.rows[0]; +--- + +<html> + <head> + <title>Testing</title> + </head> + <body> + <h1>Testing</h1> + + <span id="row">{row.value}</span> + </body> +</html> diff --git a/packages/db/test/fixtures/ticketing-example/.gitignore b/packages/db/test/fixtures/ticketing-example/.gitignore new file mode 100644 index 000000000..ce6405d09 --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# Cloudflare +.wrangler/ diff --git a/packages/db/test/fixtures/ticketing-example/README.md b/packages/db/test/fixtures/ticketing-example/README.md new file mode 100644 index 000000000..1db3fb399 --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/README.md @@ -0,0 +1,54 @@ +# Astro Starter Kit: Basics + +```sh +npm create astro@latest -- --template basics +``` + +[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics) +[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics) +[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json) + +> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun! + + + +## 🚀 Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +```text +/ +├── public/ +│ └── favicon.svg +├── src/ +│ ├── components/ +│ │ └── Card.astro +│ ├── layouts/ +│ │ └── Layout.astro +│ └── pages/ +│ └── index.astro +└── package.json +``` + +Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. + +There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. + +Any static assets, like images, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/packages/db/test/fixtures/ticketing-example/astro.config.ts b/packages/db/test/fixtures/ticketing-example/astro.config.ts new file mode 100644 index 000000000..616156f9a --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/astro.config.ts @@ -0,0 +1,14 @@ +import db from '@astrojs/db'; +import node from '@astrojs/node'; +import react from '@astrojs/react'; +import { defineConfig } from 'astro/config'; +import simpleStackForm from 'simple-stack-form'; + +// https://astro.build/config +export default defineConfig({ + integrations: [simpleStackForm(), db(), react()], + output: 'server', + adapter: node({ + mode: 'standalone', + }), +}); diff --git a/packages/db/test/fixtures/ticketing-example/db/config.ts b/packages/db/test/fixtures/ticketing-example/db/config.ts new file mode 100644 index 000000000..4c07b4c9c --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/db/config.ts @@ -0,0 +1,27 @@ +import { column, defineDb, defineTable } from 'astro:db'; + +const Event = defineTable({ + columns: { + id: column.number({ + primaryKey: true, + }), + name: column.text(), + description: column.text(), + ticketPrice: column.number(), + date: column.date(), + location: column.text(), + }, +}); + +const Ticket = defineTable({ + columns: { + eventId: column.number({ references: () => Event.columns.id }), + email: column.text(), + quantity: column.number(), + newsletter: column.boolean({ + default: true, + }), + }, +}); + +export default defineDb({ tables: { Event, Ticket } }); diff --git a/packages/db/test/fixtures/ticketing-example/db/seed.ts b/packages/db/test/fixtures/ticketing-example/db/seed.ts new file mode 100644 index 000000000..f68a0c85b --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/db/seed.ts @@ -0,0 +1,12 @@ +import { Event, db } from 'astro:db'; + +export default async function () { + await db.insert(Event).values({ + name: 'Sampha LIVE in Brooklyn', + description: + 'Sampha is on tour with his new, flawless album Lahai. Come see the live performance outdoors in Prospect Park. Yes, there will be a grand piano 🎹', + date: new Date('2024-01-01'), + ticketPrice: 10000, + location: 'Brooklyn, NY', + }); +} diff --git a/packages/db/test/fixtures/ticketing-example/package.json b/packages/db/test/fixtures/ticketing-example/package.json new file mode 100644 index 000000000..9d3bc0899 --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/package.json @@ -0,0 +1,26 @@ +{ + "name": "eventbrite-from-scratch", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "pnpm astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/db": "workspace:*", + "@astrojs/node": "workspace:*", + "@astrojs/react": "workspace:*", + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.6", + "astro": "workspace:*", + "open-props": "^1.7.14", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "simple-stack-form": "^0.1.12", + "typescript": "^5.8.3", + "zod": "^3.24.2" + } +} diff --git a/packages/db/test/fixtures/ticketing-example/public/favicon.svg b/packages/db/test/fixtures/ticketing-example/public/favicon.svg new file mode 100644 index 000000000..f157bd1c5 --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/public/favicon.svg @@ -0,0 +1,9 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128"> + <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" /> + <style> + path { fill: #000; } + @media (prefers-color-scheme: dark) { + path { fill: #FFF; } + } + </style> +</svg> diff --git a/packages/db/test/fixtures/ticketing-example/src/components/Form.tsx b/packages/db/test/fixtures/ticketing-example/src/components/Form.tsx new file mode 100644 index 000000000..f393d8281 --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/src/components/Form.tsx @@ -0,0 +1,119 @@ +// Generated by simple:form + +import { navigate } from 'astro:transitions/client'; +import { + type FieldErrors, + type FormState, + type FormValidator, + formNameInputProps, + getInitialFormState, + toSetValidationErrors, + toTrackAstroSubmitStatus, + toValidateField, + validateForm, +} from 'simple:form'; +import { type ComponentProps, createContext, useContext, useState } from 'react'; + +export function useCreateFormContext(validator: FormValidator, fieldErrors?: FieldErrors) { + const initial = getInitialFormState({ validator, fieldErrors }); + const [formState, setFormState] = useState<FormState>(initial); + return { + value: formState, + set: setFormState, + setValidationErrors: toSetValidationErrors(setFormState), + validateField: toValidateField(setFormState), + trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState), + }; +} + +export function useFormContext() { + const formContext = useContext(FormContext); + if (!formContext) { + throw new Error( + 'Form context not found. `useFormContext()` should only be called from children of a <Form> component.' + ); + } + return formContext; +} + +type FormContextType = ReturnType<typeof useCreateFormContext>; + +const FormContext = createContext<FormContextType | undefined>(undefined); + +export function Form({ + children, + validator, + context, + fieldErrors, + name, + ...formProps +}: { + validator: FormValidator; + context?: FormContextType; + fieldErrors?: FieldErrors; +} & Omit<ComponentProps<'form'>, 'method' | 'onSubmit'>) { + const formContext = context ?? useCreateFormContext(validator, fieldErrors); + + return ( + <FormContext.Provider value={formContext}> + <form + {...formProps} + method="POST" + onSubmit={async (e) => { + e.preventDefault(); + e.stopPropagation(); + const formData = new FormData(e.currentTarget); + formContext.set((formState) => ({ + ...formState, + isSubmitPending: true, + submitStatus: 'validating', + })); + const parsed = await validateForm({ formData, validator }); + if (parsed.data) { + navigate(formProps.action ?? '', { formData }); + return formContext.trackAstroSubmitStatus(); + } + + formContext.setValidationErrors(parsed.fieldErrors); + }} + > + {name ? <input {...formNameInputProps} value={name} /> : null} + {children} + </form> + </FormContext.Provider> + ); +} + +export function Input(inputProps: ComponentProps<'input'> & { name: string }) { + const formContext = useFormContext(); + const fieldState = formContext.value.fields[inputProps.name]; + if (!fieldState) { + throw new Error( + `Input "${inputProps.name}" not found in form. Did you use the <Form> component?` + ); + } + + const { hasErroredOnce, validationErrors, validator } = fieldState; + return ( + <> + <input + onBlur={async (e) => { + const value = e.target.value; + if (value === '') return; + formContext.validateField(inputProps.name, value, validator); + }} + onChange={async (e) => { + if (!hasErroredOnce) return; + const value = e.target.value; + formContext.validateField(inputProps.name, value, validator); + }} + {...inputProps} + /> + {validationErrors?.map((e) => ( + <p className="error" key={e}> + {e} + </p> + ))} + </> + ); +} diff --git a/packages/db/test/fixtures/ticketing-example/src/layouts/Layout.astro b/packages/db/test/fixtures/ticketing-example/src/layouts/Layout.astro new file mode 100644 index 000000000..482f10462 --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/src/layouts/Layout.astro @@ -0,0 +1,80 @@ +--- +import { ClientRouter } from 'astro:transitions'; +import 'open-props/normalize'; +import 'open-props/style'; + +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="description" content="Astro description" /> + <meta name="viewport" content="width=device-width" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="generator" content={Astro.generator} /> + <title>{title}</title> + <ClientRouter handleForms /> + </head> + <body> + <slot /> + <style is:global> + main { + max-width: 600px; + margin: 0 auto; + padding: var(--size-4); + display: flex; + flex-direction: column; + gap: var(--size-4); + } + + form { + display: flex; + flex-direction: column; + gap: var(--size-2); + margin-bottom: var(--size-4); + background: var(--surface-2); + padding-inline: var(--size-4); + padding-block: var(--size-6); + border-radius: var(--radius-2); + } + + .error { + color: var(--red-6); + margin-bottom: var(--size-2); + grid-column: 1 / -1; + } + + form button { + grid-column: 1 / -1; + background: var(--orange-8); + border-radius: var(--radius-2); + padding-block: var(--size-2); + } + + .youre-going { + background: var(--surface-2); + padding: var(--size-2); + border-radius: var(--radius-2); + display: flex; + flex-direction: column; + } + + h2 { + font-size: var(--font-size-4); + margin-bottom: var(--size-2); + } + + .newsletter { + display: flex; + align-items: center; + gap: var(--size-2); + } + </style> + </body> +</html> diff --git a/packages/db/test/fixtures/ticketing-example/src/pages/[event]/_Ticket.tsx b/packages/db/test/fixtures/ticketing-example/src/pages/[event]/_Ticket.tsx new file mode 100644 index 000000000..5e488d69d --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/src/pages/[event]/_Ticket.tsx @@ -0,0 +1,40 @@ +import { createForm } from 'simple:form'; +import { useState } from 'react'; +import { z } from 'zod'; +import { Form, Input } from '../../components/Form'; + +export const ticketForm = createForm({ + email: z.string().email(), + quantity: z.number().max(10), + newsletter: z.boolean(), +}); + +export function TicketForm({ price }: { price: number }) { + const [quantity, setQuantity] = useState(1); + return ( + <> + <Form validator={ticketForm.validator}> + <h3>${(quantity * price) / 100}</h3> + + <label htmlFor="quantity">Quantity</label> + <Input + id="quantity" + {...ticketForm.inputProps.quantity} + onInput={(e) => { + const value = Number(e.currentTarget.value); + setQuantity(value); + }} + /> + + <label htmlFor="email">Email</label> + <Input id="email" {...ticketForm.inputProps.email} /> + + <div className="newsletter"> + <Input id="newsletter" {...ticketForm.inputProps.newsletter} /> + <label htmlFor="newsletter">Hear about the next event in your area</label> + </div> + <button>Buy tickets</button> + </Form> + </> + ); +} diff --git a/packages/db/test/fixtures/ticketing-example/src/pages/[event]/index.astro b/packages/db/test/fixtures/ticketing-example/src/pages/[event]/index.astro new file mode 100644 index 000000000..7c1c4e320 --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/src/pages/[event]/index.astro @@ -0,0 +1,50 @@ +--- +import { Event, Ticket, db, eq } from 'astro:db'; +import Layout from '../../layouts/Layout.astro'; +import { TicketForm, ticketForm } from './_Ticket'; + +const eventId = Number(Astro.params.event); + +if (isNaN(eventId)) return Astro.redirect('/'); + +const event = await db.select().from(Event).where(eq(Event.id, eventId)).get(); + +if (!event) return Astro.redirect('/'); + +const res = await Astro.locals.form.getData(ticketForm); + +if (res?.data) { + await db.insert(Ticket).values({ + eventId, + email: res.data.email, + quantity: res.data.quantity, + newsletter: res.data.newsletter, + }); +} + +const ticket = await db.select().from(Ticket).where(eq(Ticket.eventId, eventId)).get(); +--- + +<Layout title="Welcome to Astro."> + <main> + <h1>{event.name}</h1> + <p> + {event.description} + </p> + + <TicketForm price={event.ticketPrice} client:load /> + { + ticket && ( + <section class="youre-going"> + <h2>You're going 🙌</h2> + <p> + You have purchased {ticket.quantity} tickets for {event.name}! + </p> + <p> + Check <strong>{ticket.email}</strong> for your tickets. + </p> + </section> + ) + } + </main> +</Layout> diff --git a/packages/db/test/fixtures/ticketing-example/src/pages/index.astro b/packages/db/test/fixtures/ticketing-example/src/pages/index.astro new file mode 100644 index 000000000..c8bbcbc70 --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/src/pages/index.astro @@ -0,0 +1,17 @@ +--- +import { Event, db } from 'astro:db'; + +const firstEvent = await db.select().from(Event).get(); +--- + +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Eventbrite</title> + </head> + <body> + <meta http-equiv="refresh" content={`0; url=${firstEvent!.id}`} /> + </body> +</html> diff --git a/packages/db/test/fixtures/ticketing-example/tsconfig.json b/packages/db/test/fixtures/ticketing-example/tsconfig.json new file mode 100644 index 000000000..2424dae7d --- /dev/null +++ b/packages/db/test/fixtures/ticketing-example/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + }, + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/packages/db/test/integration-only.test.js b/packages/db/test/integration-only.test.js new file mode 100644 index 000000000..b95d7d141 --- /dev/null +++ b/packages/db/test/integration-only.test.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db with only integrations, no user db config', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/integration-only/', import.meta.url), + }); + }); + + describe('development', () => { + let devServer; + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of menu items from integration-defined table', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('ul.menu'); + assert.equal(ul.children().length, 4); + assert.match(ul.children().eq(0).text(), /Pancakes/); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Prints the list of menu items from integration-defined table', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const ul = $('ul.menu'); + assert.equal(ul.children().length, 4); + assert.match(ul.children().eq(0).text(), /Pancakes/); + }); + }); +}); diff --git a/packages/db/test/integrations.test.js b/packages/db/test/integrations.test.js new file mode 100644 index 000000000..b05b28d6a --- /dev/null +++ b/packages/db/test/integrations.test.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db with integrations', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/integrations/', import.meta.url), + }); + }); + + describe('development', () => { + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of authors from user-defined table', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + + it('Prints the list of menu items from integration-defined table', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('ul.menu'); + assert.equal(ul.children().length, 4); + assert.match(ul.children().eq(0).text(), /Pancakes/); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Prints the list of authors from user-defined table', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + + it('Prints the list of menu items from integration-defined table', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const ul = $('ul.menu'); + assert.equal(ul.children().length, 4); + assert.match(ul.children().eq(0).text(), /Pancakes/); + }); + }); +}); diff --git a/packages/db/test/libsql-remote.test.js b/packages/db/test/libsql-remote.test.js new file mode 100644 index 000000000..ca5c021ae --- /dev/null +++ b/packages/db/test/libsql-remote.test.js @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { rm } from 'node:fs/promises'; +import { relative } from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { loadFixture } from '../../astro/test/test-utils.js'; +import { clearEnvironment, initializeRemoteDb } from './test-utils.js'; + +describe('astro:db local database', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/libsql-remote/', import.meta.url), + output: 'server', + adapter: testAdapter(), + }); + }); + + describe('build --remote with local libSQL file (absolute path)', () => { + before(async () => { + clearEnvironment(); + + const absoluteFileUrl = new URL('./fixtures/libsql-remote/dist/absolute.db', import.meta.url); + // Remove the file if it exists to avoid conflict between test runs + await rm(absoluteFileUrl, { force: true }); + + process.env.ASTRO_INTERNAL_TEST_REMOTE = true; + process.env.ASTRO_DB_REMOTE_URL = absoluteFileUrl.toString(); + await fixture.build(); + await initializeRemoteDb(fixture.config); + }); + + after(async () => { + delete process.env.ASTRO_INTERNAL_TEST_REMOTE; + delete process.env.ASTRO_DB_REMOTE_URL; + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); + }); + }); + + describe('build --remote with local libSQL file (relative path)', () => { + before(async () => { + clearEnvironment(); + + const absoluteFileUrl = new URL('./fixtures/libsql-remote/dist/relative.db', import.meta.url); + const prodDbPath = relative( + fileURLToPath(fixture.config.root), + fileURLToPath(absoluteFileUrl), + ); + // Remove the file if it exists to avoid conflict between test runs + await rm(prodDbPath, { force: true }); + + process.env.ASTRO_INTERNAL_TEST_REMOTE = true; + process.env.ASTRO_DB_REMOTE_URL = `file:${prodDbPath}`; + await fixture.build(); + await initializeRemoteDb(fixture.config); + }); + + after(async () => { + delete process.env.ASTRO_INTERNAL_TEST_REMOTE; + delete process.env.ASTRO_DB_REMOTE_URL; + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); + }); + }); +}); diff --git a/packages/db/test/local-prod.test.js b/packages/db/test/local-prod.test.js new file mode 100644 index 000000000..9bd56dad0 --- /dev/null +++ b/packages/db/test/local-prod.test.js @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { relative } from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db local database', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/local-prod/', import.meta.url), + output: 'server', + adapter: testAdapter(), + }); + }); + + describe('build (not remote) with DATABASE_FILE env (file URL)', () => { + const prodDbPath = new URL('./fixtures/basics/dist/astro.db', import.meta.url).toString(); + before(async () => { + process.env.ASTRO_DATABASE_FILE = prodDbPath; + await fixture.build(); + }); + + after(async () => { + delete process.env.ASTRO_DATABASE_FILE; + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); + }); + }); + + describe('build (not remote) with DATABASE_FILE env (relative file path)', () => { + const absoluteFileUrl = new URL('./fixtures/basics/dist/astro.db', import.meta.url); + const prodDbPath = relative(process.cwd(), fileURLToPath(absoluteFileUrl)); + + before(async () => { + process.env.ASTRO_DATABASE_FILE = prodDbPath; + await fixture.build(); + }); + + after(async () => { + delete process.env.ASTRO_DATABASE_FILE; + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); + }); + }); + + describe('build (not remote)', () => { + it('should throw during the build for server output', async () => { + delete process.env.ASTRO_DATABASE_FILE; + let buildError = null; + try { + await fixture.build(); + } catch (err) { + buildError = err; + } + + assert.equal(buildError instanceof Error, true); + }); + + it('should throw during the build for hybrid output', async () => { + let fixture2 = await loadFixture({ + root: new URL('./fixtures/local-prod/', import.meta.url), + output: 'static', + adapter: testAdapter(), + }); + + delete process.env.ASTRO_DATABASE_FILE; + let buildError = null; + try { + await fixture2.build(); + } catch (err) { + buildError = err; + } + + assert.equal(buildError instanceof Error, true); + }); + }); +}); diff --git a/packages/db/test/no-seed.test.js b/packages/db/test/no-seed.test.js new file mode 100644 index 000000000..058352176 --- /dev/null +++ b/packages/db/test/no-seed.test.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db with no seed file', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/no-seed/', import.meta.url), + }); + }); + + describe('development', () => { + let devServer; + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + }); +}); diff --git a/packages/db/test/ssr-no-apptoken.test.js b/packages/db/test/ssr-no-apptoken.test.js new file mode 100644 index 000000000..c570306e5 --- /dev/null +++ b/packages/db/test/ssr-no-apptoken.test.js @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { loadFixture } from '../../astro/test/test-utils.js'; +import { setupRemoteDbServer } from './test-utils.js'; + +describe('missing app token', () => { + let fixture; + let remoteDbServer; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/no-apptoken/', import.meta.url), + output: 'server', + adapter: testAdapter(), + }); + + remoteDbServer = await setupRemoteDbServer(fixture.config); + await fixture.build(); + // Ensure there's no token at runtime + delete process.env.ASTRO_STUDIO_APP_TOKEN; + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Errors as runtime', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + try { + await response.text(); + } catch { + assert.equal(response.status, 501); + } + }); +}); diff --git a/packages/db/test/static-remote.test.js b/packages/db/test/static-remote.test.js new file mode 100644 index 000000000..bbe71539c --- /dev/null +++ b/packages/db/test/static-remote.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { loadFixture } from '../../astro/test/test-utils.js'; +import { clearEnvironment, setupRemoteDbServer } from './test-utils.js'; + +describe('astro:db', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/static-remote/', import.meta.url), + output: 'static', + }); + }); + + describe('static build --remote', () => { + let remoteDbServer; + + before(async () => { + remoteDbServer = await setupRemoteDbServer(fixture.config); + await fixture.build(); + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Can render page', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('li').length, 1); + }); + + it('Returns correct shape from db.run()', async () => { + const html = await fixture.readFile('/run/index.html'); + const $ = cheerioLoad(html); + + assert.match($('#row').text(), /1/); + }); + }); + + describe('static build --remote with custom LibSQL', () => { + let remoteDbServer; + + before(async () => { + clearEnvironment(); + process.env.ASTRO_DB_REMOTE_URL = `memory:`; + await fixture.build(); + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Can render page', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('li').length, 1); + }); + + it('Returns correct shape from db.run()', async () => { + const html = await fixture.readFile('/run/index.html'); + const $ = cheerioLoad(html); + + assert.match($('#row').text(), /1/); + }); + }); +}); diff --git a/packages/db/test/test-utils.js b/packages/db/test/test-utils.js new file mode 100644 index 000000000..b608d75b8 --- /dev/null +++ b/packages/db/test/test-utils.js @@ -0,0 +1,172 @@ +import { createServer } from 'node:http'; +import { createClient } from '@libsql/client'; +import { z } from 'zod'; +import { cli } from '../dist/core/cli/index.js'; +import { resolveDbConfig } from '../dist/core/load-file.js'; +import { getCreateIndexQueries, getCreateTableQuery } from '../dist/core/queries.js'; +import { isDbError } from '../dist/runtime/utils.js'; + +const singleQuerySchema = z.object({ + sql: z.string(), + args: z.array(z.any()).or(z.record(z.string(), z.any())), +}); + +const querySchema = singleQuerySchema.or(z.array(singleQuerySchema)); + +let portIncrementer = 8030; + +/** + * @param {import('astro').AstroConfig} astroConfig + * @param {number | undefined} port + */ +export async function setupRemoteDbServer(astroConfig) { + const port = portIncrementer++; + process.env.ASTRO_STUDIO_REMOTE_DB_URL = `http://localhost:${port}`; + process.env.ASTRO_INTERNAL_TEST_REMOTE = true; + const server = createRemoteDbServer().listen(port); + + const { dbConfig } = await resolveDbConfig(astroConfig); + const setupQueries = []; + for (const [name, table] of Object.entries(dbConfig?.tables ?? {})) { + const createQuery = getCreateTableQuery(name, table); + const indexQueries = getCreateIndexQueries(name, table); + setupQueries.push(createQuery, ...indexQueries); + } + await fetch(`http://localhost:${port}/db/query`, { + method: 'POST', + body: JSON.stringify(setupQueries.map((sql) => ({ sql, args: [] }))), + headers: { + 'Content-Type': 'application/json', + }, + }); + await cli({ + config: astroConfig, + flags: { + _: [undefined, 'astro', 'db', 'execute', 'db/seed.ts'], + remote: true, + }, + }); + + return { + server, + async stop() { + delete process.env.ASTRO_STUDIO_REMOTE_DB_URL; + delete process.env.ASTRO_INTERNAL_TEST_REMOTE; + return new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + }, + }; +} + +export async function initializeRemoteDb(astroConfig) { + await cli({ + config: astroConfig, + flags: { + _: [undefined, 'astro', 'db', 'push'], + remote: true, + }, + }); + await cli({ + config: astroConfig, + flags: { + _: [undefined, 'astro', 'db', 'execute', 'db/seed.ts'], + remote: true, + }, + }); +} + +/** + * Clears the environment variables related to Astro DB and Astro Studio. + */ +export function clearEnvironment() { + const keys = Array.from(Object.keys(process.env)); + for (const key of keys) { + if (key.startsWith('ASTRO_DB_') || key.startsWith('ASTRO_STUDIO_')) { + delete process.env[key]; + } + } +} + +function createRemoteDbServer() { + const dbClient = createClient({ + url: ':memory:', + }); + const server = createServer((req, res) => { + if ( + !req.url.startsWith('/db/query') || + req.method !== 'POST' || + req.headers['content-type'] !== 'application/json' + ) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + }), + ); + return; + } + const rawBody = []; + req.on('data', (chunk) => { + rawBody.push(chunk); + }); + req.on('end', async () => { + let json; + try { + json = JSON.parse(Buffer.concat(rawBody).toString()); + } catch { + applyParseError(res); + return; + } + const parsed = querySchema.safeParse(json); + if (parsed.success === false) { + applyParseError(res); + return; + } + const body = parsed.data; + try { + const result = Array.isArray(body) + ? await dbClient.batch(body) + : await dbClient.execute(body); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.statusMessage = e.message; + res.end( + JSON.stringify({ + success: false, + error: { + code: isDbError(e) ? e.code : 'SQLITE_QUERY_FAILED', + details: e.message, + }, + }), + ); + } + }); + }); + + server.on('close', () => { + dbClient.close(); + }); + + return server; +} + +function applyParseError(res) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.statusMessage = 'Invalid request body'; + res.end( + JSON.stringify({ + // Use JSON response with `success: boolean` property + // to match remote error responses. + success: false, + }), + ); +} diff --git a/packages/db/test/unit/column-queries.test.js b/packages/db/test/unit/column-queries.test.js new file mode 100644 index 000000000..e4bb027a4 --- /dev/null +++ b/packages/db/test/unit/column-queries.test.js @@ -0,0 +1,496 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + getMigrationQueries, + getTableChangeQueries, +} from '../../dist/core/cli/migration-queries.js'; +import { MIGRATION_VERSION } from '../../dist/core/consts.js'; +import { tableSchema } from '../../dist/core/schemas.js'; +import { NOW, column, defineTable } from '../../dist/runtime/virtual.js'; + +const TABLE_NAME = 'Users'; + +// `parse` to resolve schema transformations +// ex. convert column.date() to ISO strings +const userInitial = tableSchema.parse( + defineTable({ + columns: { + name: column.text(), + age: column.number(), + email: column.text({ unique: true }), + mi: column.text({ optional: true }), + }, + }), +); + +function userChangeQueries(oldTable, newTable) { + return getTableChangeQueries({ + tableName: TABLE_NAME, + oldTable, + newTable, + }); +} + +function configChangeQueries(oldTables, newTables) { + return getMigrationQueries({ + oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION }, + newSnapshot: { schema: newTables, version: MIGRATION_VERSION }, + }); +} + +describe('column queries', () => { + describe('getMigrationQueries', () => { + it('should be empty when tables are the same', async () => { + const oldTables = { [TABLE_NAME]: userInitial }; + const newTables = { [TABLE_NAME]: userInitial }; + const { queries } = await configChangeQueries(oldTables, newTables); + assert.deepEqual(queries, []); + }); + + it('should create table for new tables', async () => { + const oldTables = {}; + const newTables = { [TABLE_NAME]: userInitial }; + const { queries } = await configChangeQueries(oldTables, newTables); + assert.deepEqual(queries, [ + `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + + it('should drop table for removed tables', async () => { + const oldTables = { [TABLE_NAME]: userInitial }; + const newTables = {}; + const { queries } = await configChangeQueries(oldTables, newTables); + assert.deepEqual(queries, [`DROP TABLE "${TABLE_NAME}"`]); + }); + + it('should error if possible table rename is detected', async () => { + const rename = 'Peeps'; + const oldTables = { [TABLE_NAME]: userInitial }; + const newTables = { [rename]: userInitial }; + let error = null; + try { + await configChangeQueries(oldTables, newTables); + } catch (e) { + error = e.message; + } + assert.match(error, /Potential table rename detected/); + }); + + it('should error if possible column rename is detected', async () => { + const blogInitial = tableSchema.parse({ + columns: { + title: column.text(), + }, + }); + const blogFinal = tableSchema.parse({ + columns: { + title2: column.text(), + }, + }); + let error = null; + try { + await configChangeQueries({ [TABLE_NAME]: blogInitial }, { [TABLE_NAME]: blogFinal }); + } catch (e) { + error = e.message; + } + assert.match(error, /Potential column rename detected/); + }); + }); + + describe('getTableChangeQueries', () => { + it('should be empty when tables are the same', async () => { + const { queries } = await userChangeQueries(userInitial, userInitial); + assert.deepEqual(queries, []); + }); + + it('should return warning if column type change introduces data loss', async () => { + const blogInitial = tableSchema.parse({ + ...userInitial, + columns: { + date: column.text(), + }, + }); + const blogFinal = tableSchema.parse({ + ...userInitial, + columns: { + date: column.date(), + }, + }); + const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + 'CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "date" text NOT NULL)', + ]); + assert.equal(confirmations.length, 1); + }); + + it('should return warning if new required column added', async () => { + const blogInitial = tableSchema.parse({ + ...userInitial, + columns: {}, + }); + const blogFinal = tableSchema.parse({ + ...userInitial, + columns: { + date: column.date({ optional: false }), + }, + }); + const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + 'CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "date" text NOT NULL)', + ]); + assert.equal(confirmations.length, 1); + }); + + it('should return warning if non-number primary key with no default added', async () => { + const blogInitial = tableSchema.parse({ + ...userInitial, + columns: {}, + }); + const blogFinal = tableSchema.parse({ + ...userInitial, + columns: { + id: column.text({ primaryKey: true }), + }, + }); + const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + 'CREATE TABLE "Users" ("id" text PRIMARY KEY)', + ]); + assert.equal(confirmations.length, 1); + }); + + it('should be empty when type updated to same underlying SQL type', async () => { + const blogInitial = tableSchema.parse({ + ...userInitial, + columns: { + title: column.text(), + draft: column.boolean(), + }, + }); + const blogFinal = tableSchema.parse({ + ...userInitial, + columns: { + ...blogInitial.columns, + draft: column.number(), + }, + }); + const { queries } = await userChangeQueries(blogInitial, blogFinal); + assert.deepEqual(queries, []); + }); + + it('should respect user primary key without adding a hidden id', async () => { + const user = tableSchema.parse({ + ...userInitial, + columns: { + ...userInitial.columns, + id: column.number({ primaryKey: true }), + }, + }); + + const userFinal = tableSchema.parse({ + ...user, + columns: { + ...user.columns, + name: column.text({ unique: true, optional: true }), + }, + }); + + const { queries } = await userChangeQueries(user, userFinal); + assert.equal(queries[0] !== undefined, true); + const tempTableName = getTempTableName(queries[0]); + + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (\"name\" text UNIQUE, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`, + `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\", \"id\") SELECT \"name\", \"age\", \"email\", \"mi\", \"id\" FROM \"Users\"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + describe('Lossy table recreate', () => { + it('when changing a column type', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + age: column.text(), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + + it('when adding a required column without a default', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + phoneNumber: column.text(), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text NOT NULL)`, + ]); + }); + }); + + describe('Lossless table recreate', () => { + it('when adding a primary key', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + id: column.number({ primaryKey: true }), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries[0] !== undefined, true); + + const tempTableName = getTempTableName(queries[0]); + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (\"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`, + `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when dropping a primary key', async () => { + const user = { + ...userInitial, + columns: { + ...userInitial.columns, + id: column.number({ primaryKey: true }), + }, + }; + + const { queries } = await userChangeQueries(user, userInitial); + assert.equal(queries[0] !== undefined, true); + + const tempTableName = getTempTableName(queries[0]); + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text)`, + `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when adding an optional unique column', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + phoneNumber: column.text({ unique: true, optional: true }), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text UNIQUE)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when dropping unique column', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + }, + }; + delete userFinal.columns.email; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries.length, 4); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "mi") SELECT "_id", "name", "age", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when updating to a runtime default', async () => { + const initial = tableSchema.parse({ + ...userInitial, + columns: { + ...userInitial.columns, + age: column.date(), + }, + }); + + const userFinal = tableSchema.parse({ + ...initial, + columns: { + ...initial.columns, + age: column.date({ default: NOW }), + }, + }); + + const { queries } = await userChangeQueries(initial, userFinal); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" text NOT NULL UNIQUE, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when adding a column with a runtime default', async () => { + const userFinal = tableSchema.parse({ + ...userInitial, + columns: { + ...userInitial.columns, + birthday: column.date({ default: NOW }), + }, + }); + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "birthday" text NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + /** + * REASON: to follow the "expand" and "contract" migration model, + * you'll need to update the schema from NOT NULL to NULL. + * It's up to the user to ensure all data follows the new schema! + * + * @see https://planetscale.com/blog/safely-making-database-schema-changes#backwards-compatible-changes + */ + it('when changing a column to required', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + mi: column.text(), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text NOT NULL)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when changing a column to unique', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + age: column.number({ unique: true }), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL UNIQUE, "email" text NOT NULL UNIQUE, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + }); + + describe('ALTER ADD COLUMN', () => { + it('when adding an optional column', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + birthday: column.date({ optional: true }), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.deepEqual(queries, ['ALTER TABLE "Users" ADD COLUMN "birthday" text']); + }); + + it('when adding a required column with default', async () => { + const defaultDate = new Date('2023-01-01'); + const userFinal = tableSchema.parse({ + ...userInitial, + columns: { + ...userInitial.columns, + birthday: column.date({ default: new Date('2023-01-01') }), + }, + }); + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.deepEqual(queries, [ + `ALTER TABLE "Users" ADD COLUMN "birthday" text NOT NULL DEFAULT '${defaultDate.toISOString()}'`, + ]); + }); + }); + + describe('ALTER DROP COLUMN', () => { + it('when removing optional or required columns', async () => { + const userFinal = { + ...userInitial, + columns: { + name: userInitial.columns.name, + email: userInitial.columns.email, + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.deepEqual(queries, [ + 'ALTER TABLE "Users" DROP COLUMN "age"', + 'ALTER TABLE "Users" DROP COLUMN "mi"', + ]); + }); + }); + }); +}); + +/** @param {string} query */ +function getTempTableName(query) { + return /Users_[a-z\d]+/.exec(query)?.[0]; +} diff --git a/packages/db/test/unit/db-client.test.js b/packages/db/test/unit/db-client.test.js new file mode 100644 index 000000000..22df2610e --- /dev/null +++ b/packages/db/test/unit/db-client.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import { parseOpts } from '../../dist/runtime/db-client.js'; + +describe('db client config', () => { + test('parse config options from URL (docs example url)', () => { + const remoteURLToParse = new URL( + 'file://local-copy.db?encryptionKey=your-encryption-key&syncInterval=60&syncUrl=libsql%3A%2F%2Fyour.server.io', + ); + const options = Object.fromEntries(remoteURLToParse.searchParams.entries()); + + const config = parseOpts(options); + + assert.deepEqual(config, { + encryptionKey: 'your-encryption-key', + syncInterval: 60, + syncUrl: 'libsql://your.server.io', + }); + }); + + test('parse config options from URL (test booleans without value)', () => { + const remoteURLToParse = new URL('file://local-copy.db?readYourWrites&offline&tls'); + const options = Object.fromEntries(remoteURLToParse.searchParams.entries()); + + const config = parseOpts(options); + + assert.deepEqual(config, { + readYourWrites: true, + offline: true, + tls: true, + }); + }); + + test('parse config options from URL (test booleans with value)', () => { + const remoteURLToParse = new URL( + 'file://local-copy.db?readYourWrites=true&offline=true&tls=true', + ); + const options = Object.fromEntries(remoteURLToParse.searchParams.entries()); + + const config = parseOpts(options); + + assert.deepEqual(config, { + readYourWrites: true, + offline: true, + tls: true, + }); + }); + + test('parse config options from URL (test numbers)', () => { + const remoteURLToParse = new URL('file://local-copy.db?syncInterval=60&concurrency=2'); + const options = Object.fromEntries(remoteURLToParse.searchParams.entries()); + + const config = parseOpts(options); + + assert.deepEqual(config, { + syncInterval: 60, + concurrency: 2, + }); + }); +}); diff --git a/packages/db/test/unit/index-queries.test.js b/packages/db/test/unit/index-queries.test.js new file mode 100644 index 000000000..4b4722baa --- /dev/null +++ b/packages/db/test/unit/index-queries.test.js @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getTableChangeQueries } from '../../dist/core/cli/migration-queries.js'; +import { dbConfigSchema, tableSchema } from '../../dist/core/schemas.js'; +import { column } from '../../dist/runtime/virtual.js'; + +const userInitial = tableSchema.parse({ + columns: { + name: column.text(), + age: column.number(), + email: column.text({ unique: true }), + mi: column.text({ optional: true }), + }, + indexes: {}, + writable: false, +}); + +describe('index queries', () => { + it('generates index names by table and combined column names', async () => { + // Use dbConfigSchema.parse to resolve generated idx names + const dbConfig = dbConfigSchema.parse({ + tables: { + oldTable: userInitial, + newTable: { + ...userInitial, + indexes: [ + { on: ['name', 'age'], unique: false }, + { on: ['email'], unique: true }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: dbConfig.tables.oldTable, + newTable: dbConfig.tables.newTable, + }); + + assert.deepEqual(queries, [ + 'CREATE INDEX "newTable_age_name_idx" ON "user" ("age", "name")', + 'CREATE UNIQUE INDEX "newTable_email_idx" ON "user" ("email")', + ]); + }); + + it('generates index names with consistent column ordering', async () => { + const initial = dbConfigSchema.parse({ + tables: { + user: { + ...userInitial, + indexes: [ + { on: ['email'], unique: true }, + { on: ['name', 'age'], unique: false }, + ], + }, + }, + }); + + const final = dbConfigSchema.parse({ + tables: { + user: { + ...userInitial, + indexes: [ + // flip columns + { on: ['age', 'name'], unique: false }, + // flip index order + { on: ['email'], unique: true }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: initial.tables.user, + newTable: final.tables.user, + }); + + assert.equal(queries.length, 0); + }); + + it('does not trigger queries when changing from legacy to new format', async () => { + const initial = dbConfigSchema.parse({ + tables: { + user: { + ...userInitial, + indexes: { + emailIdx: { on: ['email'], unique: true }, + nameAgeIdx: { on: ['name', 'age'], unique: false }, + }, + }, + }, + }); + + const final = dbConfigSchema.parse({ + tables: { + user: { + ...userInitial, + indexes: [ + { on: ['email'], unique: true, name: 'emailIdx' }, + { on: ['name', 'age'], unique: false, name: 'nameAgeIdx' }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: initial.tables.user, + newTable: final.tables.user, + }); + + assert.equal(queries.length, 0); + }); + + it('adds indexes', async () => { + const dbConfig = dbConfigSchema.parse({ + tables: { + oldTable: userInitial, + newTable: { + ...userInitial, + indexes: [ + { on: ['name'], unique: false, name: 'nameIdx' }, + { on: ['email'], unique: true, name: 'emailIdx' }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: dbConfig.tables.oldTable, + newTable: dbConfig.tables.newTable, + }); + + assert.deepEqual(queries, [ + 'CREATE INDEX "nameIdx" ON "user" ("name")', + 'CREATE UNIQUE INDEX "emailIdx" ON "user" ("email")', + ]); + }); + + it('drops indexes', async () => { + const dbConfig = dbConfigSchema.parse({ + tables: { + oldTable: { + ...userInitial, + indexes: [ + { on: ['name'], unique: false, name: 'nameIdx' }, + { on: ['email'], unique: true, name: 'emailIdx' }, + ], + }, + newTable: { + ...userInitial, + indexes: {}, + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: dbConfig.tables.oldTable, + newTable: dbConfig.tables.newTable, + }); + + assert.deepEqual(queries, ['DROP INDEX "nameIdx"', 'DROP INDEX "emailIdx"']); + }); + + it('drops and recreates modified indexes', async () => { + const dbConfig = dbConfigSchema.parse({ + tables: { + oldTable: { + ...userInitial, + indexes: [ + { unique: false, on: ['name'], name: 'nameIdx' }, + { unique: true, on: ['email'], name: 'emailIdx' }, + ], + }, + newTable: { + ...userInitial, + indexes: [ + { unique: true, on: ['name'], name: 'nameIdx' }, + { on: ['email'], name: 'emailIdx' }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: dbConfig.tables.oldTable, + newTable: dbConfig.tables.newTable, + }); + + assert.deepEqual(queries, [ + 'DROP INDEX "nameIdx"', + 'DROP INDEX "emailIdx"', + 'CREATE UNIQUE INDEX "nameIdx" ON "user" ("name")', + 'CREATE INDEX "emailIdx" ON "user" ("email")', + ]); + }); + + describe('legacy object config', () => { + it('adds indexes', async () => { + /** @type {import('../../dist/core/types.js').DBTable} */ + const userFinal = { + ...userInitial, + indexes: { + nameIdx: { on: ['name'], unique: false }, + emailIdx: { on: ['email'], unique: true }, + }, + }; + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: userInitial, + newTable: userFinal, + }); + + assert.deepEqual(queries, [ + 'CREATE INDEX "nameIdx" ON "user" ("name")', + 'CREATE UNIQUE INDEX "emailIdx" ON "user" ("email")', + ]); + }); + + it('drops indexes', async () => { + /** @type {import('../../dist/core/types.js').DBTable} */ + const initial = { + ...userInitial, + indexes: { + nameIdx: { on: ['name'], unique: false }, + emailIdx: { on: ['email'], unique: true }, + }, + }; + + /** @type {import('../../dist/core/types.js').DBTable} */ + const final = { + ...userInitial, + indexes: {}, + }; + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: initial, + newTable: final, + }); + + assert.deepEqual(queries, ['DROP INDEX "nameIdx"', 'DROP INDEX "emailIdx"']); + }); + + it('drops and recreates modified indexes', async () => { + /** @type {import('../../dist/core/types.js').DBTable} */ + const initial = { + ...userInitial, + indexes: { + nameIdx: { on: ['name'], unique: false }, + emailIdx: { on: ['email'], unique: true }, + }, + }; + + /** @type {import('../../dist/core/types.js').DBTable} */ + const final = { + ...userInitial, + indexes: { + nameIdx: { on: ['name'], unique: true }, + emailIdx: { on: ['email'] }, + }, + }; + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: initial, + newTable: final, + }); + + assert.deepEqual(queries, [ + 'DROP INDEX "nameIdx"', + 'DROP INDEX "emailIdx"', + 'CREATE UNIQUE INDEX "nameIdx" ON "user" ("name")', + 'CREATE INDEX "emailIdx" ON "user" ("email")', + ]); + }); + }); +}); diff --git a/packages/db/test/unit/reference-queries.test.js b/packages/db/test/unit/reference-queries.test.js new file mode 100644 index 000000000..04f5f84aa --- /dev/null +++ b/packages/db/test/unit/reference-queries.test.js @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getTableChangeQueries } from '../../dist/core/cli/migration-queries.js'; +import { tablesSchema } from '../../dist/core/schemas.js'; +import { column, defineTable } from '../../dist/runtime/virtual.js'; + +const BaseUser = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + name: column.text(), + age: column.number(), + email: column.text({ unique: true }), + mi: column.text({ optional: true }), + }, +}); + +const BaseSentBox = defineTable({ + columns: { + to: column.number(), + toName: column.text(), + subject: column.text(), + body: column.text(), + }, +}); + +/** + * @typedef {import('../../dist/core/types.js').DBTable} DBTable + * @param {{ User: DBTable, SentBox: DBTable }} params + * @returns + */ +function resolveReferences( + { User = BaseUser, SentBox = BaseSentBox } = { + User: BaseUser, + SentBox: BaseSentBox, + }, +) { + return tablesSchema.parse({ User, SentBox }); +} + +function userChangeQueries(oldTable, newTable) { + return getTableChangeQueries({ + tableName: 'User', + oldTable, + newTable, + }); +} + +describe('reference queries', () => { + it('adds references with lossless table recreate', async () => { + const { SentBox: Initial } = resolveReferences(); + const { SentBox: Final } = resolveReferences({ + SentBox: defineTable({ + columns: { + ...BaseSentBox.columns, + to: column.number({ references: () => BaseUser.columns.id }), + }, + }), + }); + + const { queries } = await userChangeQueries(Initial, Final); + + assert.equal(queries[0] !== undefined, true); + const tempTableName = getTempTableName(queries[0]); + assert.notEqual(typeof tempTableName, 'undefined'); + + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL REFERENCES \"User\" (\"id\"), \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL)`, + `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, + 'DROP TABLE "User"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, + ]); + }); + + it('removes references with lossless table recreate', async () => { + const { SentBox: Initial } = resolveReferences({ + SentBox: defineTable({ + columns: { + ...BaseSentBox.columns, + to: column.number({ references: () => BaseUser.columns.id }), + }, + }), + }); + const { SentBox: Final } = resolveReferences(); + + const { queries } = await userChangeQueries(Initial, Final); + + assert.equal(queries[0] !== undefined, true); + const tempTableName = getTempTableName(queries[0]); + assert.notEqual(typeof tempTableName, 'undefined'); + + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL)`, + `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, + 'DROP TABLE "User"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, + ]); + }); + + it('does not use ADD COLUMN when adding optional column with reference', async () => { + const { SentBox: Initial } = resolveReferences(); + const { SentBox: Final } = resolveReferences({ + SentBox: defineTable({ + columns: { + ...BaseSentBox.columns, + from: column.number({ references: () => BaseUser.columns.id, optional: true }), + }, + }), + }); + + const { queries } = await userChangeQueries(Initial, Final); + assert.equal(queries[0] !== undefined, true); + const tempTableName = getTempTableName(queries[0]); + + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL, \"from\" integer REFERENCES \"User\" (\"id\"))`, + `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, + 'DROP TABLE "User"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, + ]); + }); + + it('adds and updates foreign key with lossless table recreate', async () => { + const { SentBox: InitialWithoutFK } = resolveReferences(); + const { SentBox: InitialWithDifferentFK } = resolveReferences({ + SentBox: defineTable({ + ...BaseSentBox, + foreignKeys: [{ columns: ['to'], references: () => [BaseUser.columns.id] }], + }), + }); + const { SentBox: Final } = resolveReferences({ + SentBox: defineTable({ + ...BaseSentBox, + foreignKeys: [ + { + columns: ['to', 'toName'], + references: () => [BaseUser.columns.id, BaseUser.columns.name], + }, + ], + }), + }); + + const expected = (tempTableName) => [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL, FOREIGN KEY (\"to\", \"toName\") REFERENCES \"User\"(\"id\", \"name\"))`, + `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, + 'DROP TABLE "User"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, + ]; + + const addedForeignKey = await userChangeQueries(InitialWithoutFK, Final); + const updatedForeignKey = await userChangeQueries(InitialWithDifferentFK, Final); + + assert.notEqual(typeof addedForeignKey.queries[0], 'undefined'); + assert.notEqual(typeof updatedForeignKey.queries[0], 'undefined'); + assert.deepEqual( + addedForeignKey.queries, + expected(getTempTableName(addedForeignKey.queries[0])), + ); + + assert.deepEqual( + updatedForeignKey.queries, + expected(getTempTableName(updatedForeignKey.queries[0])), + ); + }); +}); + +/** @param {string} query */ +function getTempTableName(query) { + return /User_[a-z\d]+/.exec(query)?.[0]; +} diff --git a/packages/db/test/unit/remote-info.test.js b/packages/db/test/unit/remote-info.test.js new file mode 100644 index 000000000..2c58f28b7 --- /dev/null +++ b/packages/db/test/unit/remote-info.test.js @@ -0,0 +1,119 @@ +import assert from 'node:assert'; +import test, { after, beforeEach, describe } from 'node:test'; +import { getManagedRemoteToken, getRemoteDatabaseInfo } from '../../dist/core/utils.js'; +import { clearEnvironment } from '../test-utils.js'; + +describe('RemoteDatabaseInfo', () => { + beforeEach(() => { + clearEnvironment(); + }); + + test('default remote info', () => { + const dbInfo = getRemoteDatabaseInfo(); + + assert.deepEqual(dbInfo, { + type: 'studio', + url: 'https://db.services.astro.build', + }); + }); + + test('configured Astro Studio remote', () => { + process.env.ASTRO_STUDIO_REMOTE_DB_URL = 'https://studio.astro.build'; + const dbInfo = getRemoteDatabaseInfo(); + + assert.deepEqual(dbInfo, { + type: 'studio', + url: 'https://studio.astro.build', + }); + }); + + test('configured libSQL remote', () => { + process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted'; + const dbInfo = getRemoteDatabaseInfo(); + + assert.deepEqual(dbInfo, { + type: 'libsql', + url: 'libsql://libsql.self.hosted', + }); + }); + + test('configured both libSQL and Studio remote', () => { + process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted'; + process.env.ASTRO_STUDIO_REMOTE_DB_URL = 'https://studio.astro.build'; + const dbInfo = getRemoteDatabaseInfo(); + + assert.deepEqual(dbInfo, { + type: 'studio', + url: 'https://studio.astro.build', + }); + }); +}); + +describe('RemoteManagedToken', () => { + // Avoid conflicts with other tests + beforeEach(() => { + clearEnvironment(); + process.env.ASTRO_STUDIO_APP_TOKEN = 'studio token'; + process.env.ASTRO_DB_APP_TOKEN = 'db token'; + }); + after(() => { + clearEnvironment(); + }); + + test('given token for default remote', async () => { + const { token } = await getManagedRemoteToken('given token'); + assert.equal(token, 'given token'); + }); + + test('token for default remote', async () => { + const { token } = await getManagedRemoteToken(); + + assert.equal(token, 'studio token'); + }); + + test('given token for configured Astro Studio remote', async () => { + process.env.ASTRO_STUDIO_REMOTE_DB_URL = 'https://studio.astro.build'; + const { token } = await getManagedRemoteToken('given token'); + assert.equal(token, 'given token'); + }); + + test('token for configured Astro Studio remote', async () => { + process.env.ASTRO_STUDIO_REMOTE_DB_URL = 'https://studio.astro.build'; + const { token } = await getManagedRemoteToken(); + + assert.equal(token, 'studio token'); + }); + + test('given token for configured libSQL remote', async () => { + process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted'; + const { token } = await getManagedRemoteToken('given token'); + assert.equal(token, 'given token'); + }); + + test('token for configured libSQL remote', async () => { + process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted'; + const { token } = await getManagedRemoteToken(); + + assert.equal(token, 'db token'); + }); + + test('token for given Astro Studio remote', async () => { + process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted'; + const { token } = await getManagedRemoteToken(undefined, { + type: 'studio', + url: 'https://studio.astro.build', + }); + + assert.equal(token, 'studio token'); + }); + + test('token for given libSQL remote', async () => { + process.env.ASTRO_STUDIO_REMOTE_URL = 'libsql://libsql.self.hosted'; + const { token } = await getManagedRemoteToken(undefined, { + type: 'libsql', + url: 'libsql://libsql.self.hosted', + }); + + assert.equal(token, 'db token'); + }); +}); diff --git a/packages/db/test/unit/reset-queries.test.js b/packages/db/test/unit/reset-queries.test.js new file mode 100644 index 000000000..9fb99f91e --- /dev/null +++ b/packages/db/test/unit/reset-queries.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getMigrationQueries } from '../../dist/core/cli/migration-queries.js'; +import { MIGRATION_VERSION } from '../../dist/core/consts.js'; +import { tableSchema } from '../../dist/core/schemas.js'; +import { column, defineTable } from '../../dist/runtime/virtual.js'; + +const TABLE_NAME = 'Users'; + +// `parse` to resolve schema transformations +// ex. convert column.date() to ISO strings +const userInitial = tableSchema.parse( + defineTable({ + columns: { + name: column.text(), + age: column.number(), + email: column.text({ unique: true }), + mi: column.text({ optional: true }), + }, + }), +); + +describe('force reset', () => { + describe('getMigrationQueries', () => { + it('should drop table and create new version', async () => { + const oldTables = { [TABLE_NAME]: userInitial }; + const newTables = { [TABLE_NAME]: userInitial }; + const { queries } = await getMigrationQueries({ + oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION }, + newSnapshot: { schema: newTables, version: MIGRATION_VERSION }, + reset: true, + }); + + assert.deepEqual(queries, [ + `DROP TABLE IF EXISTS "${TABLE_NAME}"`, + `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + + it('should not drop table when previous snapshot did not have it', async () => { + const oldTables = {}; + const newTables = { [TABLE_NAME]: userInitial }; + const { queries } = await getMigrationQueries({ + oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION }, + newSnapshot: { schema: newTables, version: MIGRATION_VERSION }, + reset: true, + }); + + assert.deepEqual(queries, [ + `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + }); +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 000000000..7592ad8a1 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "exclude": ["src/runtime/virtual.ts"], + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/packages/db/tsconfig.virtual.json b/packages/db/tsconfig.virtual.json new file mode 100644 index 000000000..41d2aef4a --- /dev/null +++ b/packages/db/tsconfig.virtual.json @@ -0,0 +1,12 @@ +{ + // We want to avoid defineTable() and defineDb() import hints + // from the runtime config export instead of astro:db. + // We exclude runtime/virtual from the base types, + // and generate to a separate _internal/ directory + // for our virtual module (virtual.d.ts) to reference. + "extends": "../../tsconfig.base.json", + "files": ["./src/runtime/virtual.ts"], + "compilerOptions": { + "outDir": "./dist/_internal" + } +} diff --git a/packages/db/virtual.d.ts b/packages/db/virtual.d.ts new file mode 100644 index 000000000..89fa4d0d5 --- /dev/null +++ b/packages/db/virtual.d.ts @@ -0,0 +1,47 @@ +declare module 'astro:db' { + type RuntimeConfig = typeof import('./dist/_internal/runtime/virtual.js'); + + export const db: import('./dist/runtime/index.js').Database; + export const dbUrl: string; + + export const sql: RuntimeConfig['sql']; + export const NOW: RuntimeConfig['NOW']; + export const TRUE: RuntimeConfig['TRUE']; + export const FALSE: RuntimeConfig['FALSE']; + export const column: RuntimeConfig['column']; + export const defineDb: RuntimeConfig['defineDb']; + export const defineTable: RuntimeConfig['defineTable']; + export const isDbError: RuntimeConfig['isDbError']; + + export const eq: RuntimeConfig['eq']; + export const gt: RuntimeConfig['gt']; + export const gte: RuntimeConfig['gte']; + export const lt: RuntimeConfig['lt']; + export const lte: RuntimeConfig['lte']; + export const ne: RuntimeConfig['ne']; + export const isNull: RuntimeConfig['isNull']; + export const isNotNull: RuntimeConfig['isNotNull']; + export const inArray: RuntimeConfig['inArray']; + export const notInArray: RuntimeConfig['notInArray']; + export const exists: RuntimeConfig['exists']; + export const notExists: RuntimeConfig['notExists']; + export const between: RuntimeConfig['between']; + export const notBetween: RuntimeConfig['notBetween']; + export const like: RuntimeConfig['like']; + export const ilike: RuntimeConfig['ilike']; + export const notIlike: RuntimeConfig['notIlike']; + export const not: RuntimeConfig['not']; + export const asc: RuntimeConfig['asc']; + export const desc: RuntimeConfig['desc']; + export const and: RuntimeConfig['and']; + export const or: RuntimeConfig['or']; + export const count: RuntimeConfig['count']; + export const countDistinct: RuntimeConfig['countDistinct']; + export const avg: RuntimeConfig['avg']; + export const avgDistinct: RuntimeConfig['avgDistinct']; + export const sum: RuntimeConfig['sum']; + export const sumDistinct: RuntimeConfig['sumDistinct']; + export const max: RuntimeConfig['max']; + export const min: RuntimeConfig['min']; + export const alias: RuntimeConfig['alias']; +} |