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