├── .github └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── eslint.config.js ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── release.config.cjs ├── scripts └── setup-pocketbase.sh ├── src ├── index.ts ├── loader │ ├── cleanup-entries.ts │ ├── handle-realtime-updates.ts │ ├── load-entries.ts │ ├── loader.ts │ └── parse-entry.ts ├── pocketbase-loader.ts ├── schema │ ├── generate-schema.ts │ ├── get-remote-schema.ts │ ├── parse-schema.ts │ ├── read-local-schema.ts │ └── transform-files.ts ├── types │ ├── pocketbase-entry.type.ts │ ├── pocketbase-loader-options.type.ts │ └── pocketbase-schema.type.ts └── utils │ ├── get-superuser-token.ts │ ├── is-realtime-data.ts │ ├── should-refresh.ts │ └── slugify.ts ├── test ├── _mocks │ ├── batch-requests.ts │ ├── check-e2e-connection.ts │ ├── create-loader-context.ts │ ├── create-loader-options.ts │ ├── create-pocketbase-entry.ts │ ├── delete-collection.ts │ ├── delete-entry.ts │ ├── insert-collection.ts │ ├── insert-entry.ts │ ├── logger.mock.ts │ ├── store.mock.ts │ └── superuser_schema.json ├── loader │ ├── cleanup-entries.e2e-spec.ts │ ├── handle-realtime-updates.spec.ts │ ├── load-entries.e2e-spec.ts │ ├── loader.spec.ts │ └── parse-entry.spec.ts ├── schema │ ├── __snapshots__ │ │ └── get-remote-schema.e2e-spec.ts.snap │ ├── generate-schema.e2e-spec.ts │ ├── get-remote-schema.e2e-spec.ts │ ├── parse-schema.spec.ts │ ├── read-local-schema.spec.ts │ └── transform-files.spec.ts └── utils │ ├── get-superuser-token.e2e-spec.ts │ ├── is-realtime-data.spec.ts │ ├── should-refresh.spec.ts │ └── slugify.spec.ts ├── tsconfig.json └── vitest.config.ts /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Publish new version 2 | # Run this on every push to master and next 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - next 8 | 9 | env: 10 | HUSKY: 0 11 | 12 | jobs: 13 | publish: 14 | # Use the latest version of Ubuntu 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | issues: write 19 | pull-requests: write 20 | id-token: write 21 | steps: 22 | # Checkout repository 23 | - name: 📥 Checkout code 24 | uses: actions/checkout@v4 25 | with: 26 | persist-credentials: false 27 | 28 | # Setup Node 29 | - name: 📦 Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: "lts/*" 33 | cache: "npm" 34 | 35 | # Install dependencies 36 | - name: 📦 Install dependencies 37 | run: npm ci 38 | 39 | # Lint code 40 | - name: 🧹 Lint code 41 | run: npm run lint 42 | 43 | # Check format 44 | - name: 🧹 Check format 45 | run: npm run format:check 46 | 47 | # Run tests 48 | - name: 🧪 Run unit tests 49 | run: npm run test:unit 50 | 51 | # Setup PocketBase 52 | - name: 🗄️ Download and setup PocketBase 53 | run: npm run test:e2e:setup 54 | 55 | # Start PocketBase 56 | - name: 🚀 Start PocketBase 57 | run: ./.pocketbase/pocketbase serve & 58 | 59 | # Wait for PocketBase to be ready 60 | - name: ⏳ Wait for PocketBase 61 | run: | 62 | until curl -s --fail http://localhost:8090/api/health; do 63 | echo 'Waiting for PocketBase...' 64 | sleep 5 65 | done 66 | 67 | # Run tests 68 | - name: 🧪 Run e2e tests 69 | run: npm run test:e2e 70 | 71 | # Create release 72 | - name: 🚀 Create release 73 | if: github.repository == 'pawcoding/astro-loader-pocketbase' 74 | id: release 75 | uses: cycjimmy/semantic-release-action@v4 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 78 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 79 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test code 2 | # Run this on every push except master and next 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | - "!master" 8 | - "!next" 9 | 10 | env: 11 | HUSKY: 0 12 | 13 | jobs: 14 | test: 15 | # Use the latest version of Ubuntu 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | issues: write 20 | pull-requests: write 21 | id-token: write 22 | steps: 23 | # Checkout repository 24 | - name: 📥 Checkout code 25 | uses: actions/checkout@v4 26 | with: 27 | persist-credentials: false 28 | 29 | # Setup Node 30 | - name: 📦 Setup Node.js 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: "lts/*" 34 | cache: "npm" 35 | 36 | # Install dependencies 37 | - name: 📦 Install dependencies 38 | run: npm ci 39 | 40 | # Lint code 41 | - name: 🧹 Lint code 42 | run: npm run lint 43 | 44 | # Check format 45 | - name: 🧹 Check format 46 | run: npm run format:check 47 | 48 | # Run tests 49 | - name: 🧪 Run unit tests 50 | run: npm run test:unit 51 | 52 | # Setup PocketBase 53 | - name: 🗄️ Download and setup PocketBase 54 | run: npm run test:e2e:setup 55 | 56 | # Start PocketBase 57 | - name: 🚀 Start PocketBase 58 | run: ./.pocketbase/pocketbase serve & 59 | 60 | # Wait for PocketBase to be ready 61 | - name: ⏳ Wait for PocketBase 62 | run: | 63 | until curl -s --fail http://localhost:8090/api/health; do 64 | echo 'Waiting for PocketBase...' 65 | sleep 5 66 | done 67 | 68 | # Run tests 69 | - name: 🧪 Run e2e tests 70 | run: npm run test:e2e 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .eslintcache 4 | coverage/ 5 | # generated types 6 | .astro/ 7 | 8 | # dependencies 9 | node_modules/ 10 | 11 | # logs 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # environment variables 18 | .env 19 | .env.production 20 | 21 | # macOS-specific files 22 | .DS_Store 23 | 24 | # jetbrains setting folder 25 | .idea/ 26 | 27 | # PocketBase folder 28 | .pocketbase/ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .eslintcache 4 | CHANGELOG.md 5 | # generated types 6 | .astro/ 7 | 8 | # dependencies 9 | node_modules/ 10 | 11 | # logs 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | 18 | # environment variables 19 | .env 20 | .env.production 21 | 22 | # macOS-specific files 23 | .DS_Store 24 | 25 | # jetbrains setting folder 26 | .idea/ 27 | 28 | # misc 29 | .husky/ 30 | .prettierignore 31 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.6.0](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.5.0...v2.6.0) (2025-04-18) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **cleanup:** apply filter when looking for outdated entries ([205aaa3](https://github.com/pawcoding/astro-loader-pocketbase/commit/205aaa3af8a568600df8c1c03a31df00bbc3dc7a)) 7 | * **cleanup:** clear whole store on error ([161741e](https://github.com/pawcoding/astro-loader-pocketbase/commit/161741e952b2a754932364c392b155f4040619a8)) 8 | 9 | 10 | ### Features 11 | 12 | * **schema:** parse `geoPoint` fields ([6067a4f](https://github.com/pawcoding/astro-loader-pocketbase/commit/6067a4ff4ff331177e2688477355a4649526cc17)) 13 | 14 | # [2.5.0](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.4.1...v2.5.0) (2025-04-09) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * **refresh:** do not re-use realtime data when custom filter is set ([16b0ec9](https://github.com/pawcoding/astro-loader-pocketbase/commit/16b0ec9033150ce9c10aa1d0baf68a54afa92e93)) 20 | 21 | 22 | ### Features 23 | 24 | * **loader:** add support for custom pocketbase filter ([#35](https://github.com/pawcoding/astro-loader-pocketbase/issues/35)) ([367af9a](https://github.com/pawcoding/astro-loader-pocketbase/commit/367af9a15ce18cf3b6c815e3fd88cdd324924a14)) 25 | 26 | ## [2.4.1](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.4.0...v2.4.1) (2025-02-16) 27 | 28 | # [2.4.0](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.3.1...v2.4.0) (2025-02-15) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * **loader:** print total numer of entries to simplify debugging ([1c8cdfd](https://github.com/pawcoding/astro-loader-pocketbase/commit/1c8cdfdecf27ef5ce73e77fe17d3e43cdbc846a0)) 34 | 35 | 36 | ### Features 37 | 38 | * **loader:** support force refresh to update all entries ([e22cc46](https://github.com/pawcoding/astro-loader-pocketbase/commit/e22cc4692d6bde95ffecb341d260899410a3bbe4)) 39 | 40 | ## [2.3.1](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.3.0...v2.3.1) (2025-02-02) 41 | 42 | # [2.3.0](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.2.1...v2.3.0) (2025-02-01) 43 | 44 | 45 | ### Features 46 | 47 | * **refresh:** re-use realtime event data to refresh collection ([efae282](https://github.com/pawcoding/astro-loader-pocketbase/commit/efae2826ad93da4d4fa918a6614dcffe1135934a)), closes [#26](https://github.com/pawcoding/astro-loader-pocketbase/issues/26) 48 | 49 | ## [2.2.1](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.2.0...v2.2.1) (2025-01-25) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * **refresh:** handle array in refresh context data ([5a51b97](https://github.com/pawcoding/astro-loader-pocketbase/commit/5a51b97a9fbf1d46b62ec5a41a9a8418a3d04a13)), closes [pawcoding/astro-integration-pocketbase#10](https://github.com/pawcoding/astro-integration-pocketbase/issues/10) 55 | 56 | # [2.2.0](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.1.0...v2.2.0) (2025-01-21) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * **schema:** remove default values from improved types ([82b6b70](https://github.com/pawcoding/astro-loader-pocketbase/commit/82b6b70273169bf74f37bcbdd3377c63486f971e)) 62 | 63 | 64 | ### Features 65 | 66 | * **schema:** add option to improve types ([d8c9780](https://github.com/pawcoding/astro-loader-pocketbase/commit/d8c9780b202cc2a55e651fb90f26a379be5bb7b5)) 67 | 68 | # [2.1.0](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.0.2...v2.1.0) (2025-01-11) 69 | 70 | 71 | ### Features 72 | 73 | * **refresh:** check refresh context for mentioned collection ([3bff3e5](https://github.com/pawcoding/astro-loader-pocketbase/commit/3bff3e509b00e0ade1f4389bf33ceae2adf45f43)), closes [#23](https://github.com/pawcoding/astro-loader-pocketbase/issues/23) 74 | 75 | ## [2.0.2](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.0.1...v2.0.2) (2025-01-11) 76 | 77 | ## [2.0.1](https://github.com/pawcoding/astro-loader-pocketbase/compare/v2.0.0...v2.0.1) (2024-12-31) 78 | 79 | # [2.0.0](https://github.com/pawcoding/astro-loader-pocketbase/compare/v1.0.2...v2.0.0) (2024-12-24) 80 | 81 | 82 | ### Features 83 | 84 | * add support for PocketBase 0.23.0 ([a98f1b4](https://github.com/pawcoding/astro-loader-pocketbase/commit/a98f1b41d07bd66aca244f1ed2f473027d011be2)) 85 | 86 | 87 | ### BREAKING CHANGES 88 | 89 | * This also removes support for PocketBase 0.22. 90 | There are a lot of breaking changes in this new version of PocketBase, 91 | e.g. new endpoint for login, new collection schema format, etc. 92 | 93 | Since this version already brings a lot of changes, I used this chance 94 | to refactor some of the internals and configuration options. Please 95 | refer to the new README for more details. 96 | 97 | ## [1.0.2](https://github.com/pawcoding/astro-loader-pocketbase/compare/v1.0.1...v1.0.2) (2024-12-16) 98 | 99 | ## [1.0.1](https://github.com/pawcoding/astro-loader-pocketbase/compare/v1.0.0...v1.0.1) (2024-12-14) 100 | 101 | 102 | ### Bug Fixes 103 | 104 | * **load:** only print update message when package was previously installed ([2977878](https://github.com/pawcoding/astro-loader-pocketbase/commit/29778788d0d4081406370c627d526e1c06f7c2f2)) 105 | * **load:** use correct date format for updated entries request ([c9df0d2](https://github.com/pawcoding/astro-loader-pocketbase/commit/c9df0d2f4638fac1aabfbc2b90ff0dd6336668fa)), closes [#18](https://github.com/pawcoding/astro-loader-pocketbase/issues/18) 106 | 107 | # [1.0.0](https://github.com/pawcoding/astro-loader-pocketbase/compare/v0.5.0...v1.0.0) (2024-12-07) 108 | 109 | 110 | ### Bug Fixes 111 | 112 | * **release:** update version number ([901af52](https://github.com/pawcoding/astro-loader-pocketbase/commit/901af52bfd91dc970e8bcee6fffcf8aaae97c75f)) 113 | 114 | 115 | ### Documentation 116 | 117 | * **README:** add note for compatibility ([2613918](https://github.com/pawcoding/astro-loader-pocketbase/commit/261391897ad6984eebbaf7bbb8195ada2382eb67)) 118 | 119 | 120 | ### BREAKING CHANGES 121 | 122 | * **release:** This is the first stable release of this package. 123 | * **README:** This marks the first stable release of this package. 124 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 pawcode Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # astro-loader-pocketbase 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/pawcoding/astro-loader-pocketbase/release.yaml?style=flat-square) 4 | [![NPM Version](https://img.shields.io/npm/v/astro-loader-pocketbase?style=flat-square)](https://www.npmjs.com/package/astro-loader-pocketbase) 5 | [![NPM Downloads](https://img.shields.io/npm/dw/astro-loader-pocketbase?style=flat-square)](https://www.npmjs.com/package/astro-loader-pocketbase) 6 | [![GitHub License](https://img.shields.io/github/license/pawcoding/astro-loader-pocketbase?style=flat-square)](https://github.com/pawcoding/astro-loader-pocketbase/blob/master/LICENSE) 7 | [![Discord](https://img.shields.io/discord/484669557747875862?style=flat-square&label=Discord)](https://discord.gg/GzgTh4hxrx) 8 | 9 | This package is a simple loader to load data from a PocketBase database into Astro using the [Astro Loader API](https://docs.astro.build/en/reference/content-loader-reference/) introduced in Astro 5. 10 | 11 | > [!TIP] 12 | > If you want to see the PocketBase data directly in your Astro toolbar, try the [`astro-integration-pocketbase`](https://github.com/pawcoding/astro-integration-pocketbase). 13 | 14 | ## Compatibility 15 | 16 | | Loader | Astro | PocketBase | 17 | | ------ | ----- | ---------- | 18 | | 2.0.0 | 5.0.0 | >= 0.23.0 | 19 | | 1.0.0 | 5.0.0 | <= 0.22.0 | 20 | 21 | ## Basic usage 22 | 23 | In your content configuration file, you can use the `pocketbaseLoader` function to use your PocketBase database as a data source. 24 | 25 | ```ts 26 | import { pocketbaseLoader } from "astro-loader-pocketbase"; 27 | import { defineCollection } from "astro:content"; 28 | 29 | const blog = defineCollection({ 30 | loader: pocketbaseLoader({ 31 | url: "https://", 32 | collectionName: "" 33 | }) 34 | }); 35 | 36 | export const collections = { blog }; 37 | ``` 38 | 39 | Remember that due to the nature [Astros Content Layer lifecycle](https://astro.build/blog/content-layer-deep-dive#content-layer-lifecycle), the loader will **only fetch entries at build time**, even when using on-demand rendering. 40 | If you want to update your deployed site with new entries, you need to rebuild it. 41 | 42 | When running the dev server, you can trigger a reload by using `s + enter`. 43 | 44 | ## Incremental builds 45 | 46 | Since PocketBase 0.23.0, the `updated` field is not mandatory anymore. 47 | This means that the loader can't automatically detect when an entry has been modified. 48 | To enable incremental builds, you need to provide the name of a field in your collection that stores the last update date of an entry. 49 | 50 | ```ts 51 | const blog = defineCollection({ 52 | loader: pocketbaseLoader({ 53 | ...options, 54 | updatedField: "" 55 | }) 56 | }); 57 | ``` 58 | 59 | When this field is provided, the loader will only fetch entries that have been modified since the last build. 60 | Ideally, this field should be of type `autodate` and have the value "Update" or "Create/Update" in the PocketBase dashboard. 61 | This ensures that the field is automatically updated when an entry is modified. 62 | 63 | ## Entries 64 | 65 | After generating the schema (see below), the loader will automatically parse the content of the entries (e.g. transform ISO dates to `Date` objects, coerce numbers, etc.). 66 | 67 | ### HTML content 68 | 69 | You can also specify a field or an array of fields to use as content. 70 | This content will then be used when calling the `render` function of [Astros content collections](https://docs.astro.build/en/guides/content-collections/#rendering-body-content). 71 | 72 | ```ts 73 | const blog = defineCollection({ 74 | loader: pocketbaseLoader({ 75 | ...options, 76 | contentFields: "" 77 | }) 78 | }); 79 | ``` 80 | 81 | Since the goal of the `render` function is to render the content as HTML, it's recommended to use a field of type `editor` (rich text) in PocketBase as content. 82 | 83 | If you specify an array of fields, the loader will wrap the content of these fields in a `
` and concatenate them. 84 | 85 | ### Images and files 86 | 87 | PocketBase can store images and files for each entry in a collection. 88 | While the API only returns the filenames of these images and files, the loader will out of the box **transform these filenames to the actual URLs** of the files. 89 | This doesn't mean that the files are downloaded during the build process. 90 | But you can directly use these URLs in your Astro components to display images or link to the files. 91 | 92 | ### Custom ids 93 | 94 | By default, the loader will use the `id` field of the collection as the unique identifier. 95 | If you want to use another field as the id, e.g. a slug of the title, you can specify this field via the `idField` option. 96 | 97 | ```ts 98 | const blog = defineCollection({ 99 | loader: pocketbaseLoader({ 100 | ...options, 101 | idField: "" 102 | }) 103 | }); 104 | ``` 105 | 106 | Please note that the id should be unique for every entry in the collection. 107 | The loader will also automatically convert the value into a slug to be easily used in URLs. 108 | It's recommended to use e.g. the title of the entry to be easily searchable and readable. 109 | **Do not use e.g. rich text fields as ids.** 110 | 111 | ### Filtering entries 112 | 113 | By default the loader will fetch all entries in the specified collection. 114 | If you want to restrict the entries to a specific subset, you can use the `filter` option. 115 | 116 | ```ts 117 | const blog = defineCollection({ 118 | loader: pocketbaseLoader({ 119 | ...options, 120 | filter: "" 121 | }) 122 | }); 123 | ``` 124 | 125 | For example, if you want to only fetch entries that are released but not deleted, you can use `"release >= @now && deleted = false"`. 126 | This filter will be added to the PocketBase API request and will only fetch entries that match the filter. 127 | This is in addition to the built-in filtering of the loader, which handles the incremental builds using the `updated` field. 128 | For more information on how to use filters, check out the [PocketBase documentation](https://pocketbase.io/docs/api-records/#listsearch-records). 129 | 130 | ## Type generation 131 | 132 | The loader can automatically generate types for your collection. 133 | This is useful for type checking and autocompletion in your editor. 134 | These types can be generated in two ways: 135 | 136 | ### Remote schema 137 | 138 | To use the live remote schema, you need to provide superuser credentials for the PocketBase instance. 139 | 140 | ```ts 141 | const blog = defineCollection({ 142 | loader: pocketbaseLoader({ 143 | ...options, 144 | superuserCredentials: { 145 | email: "", 146 | password: "" 147 | } 148 | }) 149 | }); 150 | ``` 151 | 152 | Under the hood, the loader will use the [PocketBase API](https://pocketbase.io/docs/api-collections/#view-collection) to fetch the schema of your collection and generate types with Zod based on that schema. 153 | 154 | ### Local schema 155 | 156 | If you don't want to provide superuser credentials (e.g. in a public repository), you can also provide the schema manually via a JSON file. 157 | 158 | ```ts 159 | const blog = defineCollection({ 160 | loader: pocketbaseLoader({ 161 | ...options, 162 | localSchema: "" 163 | }) 164 | }); 165 | ``` 166 | 167 | In PocketBase you can export the schema of the whole database to a `pb_schema.json` file. 168 | If you provide the path to this file, the loader will use this schema to generate the types locally. 169 | 170 | When superuser credentials are provided, the loader will **always use the remote schema** since it's more up-to-date. 171 | 172 | ### Manual schema 173 | 174 | If you don't want to use the automatic type generation, you can also [provide your own schema manually](https://docs.astro.build/en/guides/content-collections/#defining-the-collection-schema). 175 | This manual schema will **always override the automatic type generation**. 176 | 177 | ### Improved types 178 | 179 | By default PocketBase reports `number` and `boolean` fields as not required, even though the API will always return `0` and `false` respectively if no value is set. 180 | This means that the loader will add `undefined` to the type of these fields. 181 | If you want to enforce that these fields are always present, you can set the `improveTypes` option to `true`. 182 | 183 | ```ts 184 | const blog = defineCollection({ 185 | loader: pocketbaseLoader({ 186 | ...options, 187 | improveTypes: true 188 | }) 189 | }); 190 | ``` 191 | 192 | This will remove `undefined` from the type of these fields and mark them as required. 193 | 194 | ## All options 195 | 196 | | Option | Type | Required | Description | 197 | | ---------------------- | ------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------- | 198 | | `url` | `string` | x | The URL of your PocketBase instance. | 199 | | `collectionName` | `string` | x | The name of the collection in your PocketBase instance. | 200 | | `idField` | `string` | | The field in the collection to use as unique id. Defaults to `id`. | 201 | | `contentFields` | `string \| Array` | | The field in the collection to use as content. This can also be an array of fields. | 202 | | `updatedField` | `string` | | The field in the collection that stores the last update date of an entry. This is used for incremental builds. | 203 | | `filter` | `string` | | Custom filter to use when fetching entries. Used to filter the entries by specific conditions. | 204 | | `superuserCredentials` | `{ email: string, password: string }` | | The email and password of the superuser of the PocketBase instance. This is used for automatic type generation. | 205 | | `localSchema` | `string` | | The path to a local schema file. This is used for automatic type generation. | 206 | | `jsonSchemas` | `Record` | | A record of Zod schemas to use for type generation of `json` fields. | 207 | | `improveTypes` | `boolean` | | Whether to improve the types of `number` and `boolean` fields, removing `undefined` from them. | 208 | 209 | ## Special cases 210 | 211 | ### Private collections and hidden fields 212 | 213 | If you want to access a private collection or hidden fields, you also need to provide superuser credentials. 214 | Otherwise, you need to make the collection public in the PocketBase dashboard. 215 | 216 | Generally, it's not recommended to use private collections, especially when users should be able to see images or other files stored in the collection. 217 | 218 | ### JSON fields 219 | 220 | PocketBase can store arbitrary JSON data in a `json` field. 221 | While this data is checked to be valid JSON, there's no schema attached or enforced. 222 | Theoretically, every entry in a collection can have a different structure in such a field. 223 | This is why by default the loader will treat these fields as `unknown` and will not generate types for them. 224 | 225 | If you have your own schema for these fields, you can provide them via the `jsonSchemas` option. 226 | The keys of this record should be the field names of your json fields, while the values are Zod schemas. 227 | This way, the loader will also generate types for these fields and **validate the data against these schemas**. 228 | So be sure that the schema is correct, up-to-date and all entries in the collection adhere to it. 229 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import stylistic from "@stylistic/eslint-plugin"; 3 | import prettier from "eslint-config-prettier"; 4 | import globals from "globals"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | const config = tseslint.config({ 8 | files: ["**/*.{js,mjs,cjs,ts}"], 9 | ignores: [".pocketbase/**/*"], 10 | languageOptions: { 11 | globals: { ...globals.browser, ...globals.node } 12 | }, 13 | extends: [ 14 | eslint.configs.recommended, 15 | prettier, 16 | ...tseslint.configs.recommended, 17 | ...tseslint.configs.stylistic 18 | ], 19 | plugins: { 20 | "@stylistic": stylistic 21 | }, 22 | rules: { 23 | // TypeScript specific rules 24 | "@typescript-eslint/no-unused-vars": [ 25 | "warn", 26 | { 27 | argsIgnorePattern: "^_", 28 | varsIgnorePattern: "^_" 29 | } 30 | ], 31 | "@typescript-eslint/no-inferrable-types": "off", 32 | "@typescript-eslint/no-non-null-assertion": "off", 33 | "@typescript-eslint/explicit-function-return-type": "warn", 34 | "@typescript-eslint/array-type": [ 35 | "error", 36 | { 37 | default: "generic" 38 | } 39 | ], 40 | "@typescript-eslint/explicit-member-accessibility": [ 41 | "error", 42 | { 43 | accessibility: "explicit" 44 | } 45 | ], 46 | "@typescript-eslint/no-empty-function": "off", 47 | // ESLint rules 48 | "comma-dangle": "error", 49 | "no-debugger": "off", 50 | semi: "error", 51 | "no-case-declarations": "off", 52 | // Stylistic rules 53 | "@stylistic/member-delimiter-style": [ 54 | "error", 55 | { 56 | multiline: { 57 | delimiter: "semi", 58 | requireLast: true 59 | }, 60 | singleline: { 61 | delimiter: "semi", 62 | requireLast: false 63 | } 64 | } 65 | ] 66 | } 67 | }); 68 | 69 | export default config; 70 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('lint-staged').Configuration} 3 | */ 4 | export default { 5 | "!(*.ts)": "prettier --write", 6 | "*.ts": ["eslint --fix", "prettier --write"] 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-loader-pocketbase", 3 | "version": "2.6.0", 4 | "description": "A content loader for Astro that uses the PocketBase API", 5 | "keywords": [ 6 | "astro", 7 | "astro-content-loader", 8 | "astro-loader", 9 | "pocketbase", 10 | "withastro" 11 | ], 12 | "homepage": "https://github.com/pawcoding/astro-loader-pocketbase", 13 | "bugs": { 14 | "url": "https://github.com/pawcoding/astro-loader-pocketbase/issues" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/pawcoding/astro-loader-pocketbase.git" 19 | }, 20 | "license": "MIT", 21 | "author": "Luis Wolf (https://pawcode.de)", 22 | "type": "module", 23 | "exports": { 24 | ".": "./src/index.ts" 25 | }, 26 | "files": [ 27 | "src" 28 | ], 29 | "scripts": { 30 | "format": "npx prettier . --write --cache", 31 | "format:check": "npx prettier . --check --cache", 32 | "lint": "npx eslint --cache", 33 | "lint:fix": "npx eslint --fix --cache", 34 | "prepare": "husky", 35 | "test": "vitest run", 36 | "test:e2e": "vitest run $(find test -name '*.e2e-spec.ts')", 37 | "test:e2e:pocketbase": "npm run test:e2e:setup && ./.pocketbase/pocketbase serve", 38 | "test:e2e:setup": "./scripts/setup-pocketbase.sh", 39 | "test:e2e:watch": "vitest watch $(find test -name '*.e2e-spec.ts')", 40 | "test:unit": "vitest run $(find test -name '*.spec.ts')", 41 | "test:unit:watch": "vitest watch $(find test -name '*.spec.ts')", 42 | "test:watch": "vitest watch" 43 | }, 44 | "devDependencies": { 45 | "@commitlint/cli": "^19.8.0", 46 | "@commitlint/config-conventional": "^19.8.0", 47 | "@eslint/js": "^9.24.0", 48 | "@stylistic/eslint-plugin": "^4.2.0", 49 | "@types/node": "^22.14.1", 50 | "@vitest/coverage-v8": "^3.1.1", 51 | "astro": "^5.7.3", 52 | "eslint": "^9.24.0", 53 | "eslint-config-prettier": "^10.1.2", 54 | "globals": "^16.0.0", 55 | "husky": "^9.1.7", 56 | "lint-staged": "^15.5.1", 57 | "prettier": "^3.5.3", 58 | "prettier-plugin-organize-imports": "^4.1.0", 59 | "prettier-plugin-packagejson": "^2.5.10", 60 | "typescript": "^5.8.3", 61 | "typescript-eslint": "^8.30.1", 62 | "vitest": "^3.1.1" 63 | }, 64 | "peerDependencies": { 65 | "astro": "^5.0.0" 66 | }, 67 | "publishConfig": { 68 | "access": "public", 69 | "provenance": true 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | const branch = process.env.GITHUB_REF_NAME; 2 | 3 | const assetsToUpdate = ["package.json", "package-lock.json"]; 4 | if (branch === "master") { 5 | assetsToUpdate.push("CHANGELOG.md"); 6 | } 7 | 8 | const config = { 9 | branches: ["master", { name: "next", channel: "next", prerelease: true }], 10 | plugins: [ 11 | [ 12 | "@semantic-release/commit-analyzer", 13 | { 14 | preset: "angular", 15 | releaseRules: [ 16 | { type: "docs", scope: "README", release: "patch" }, 17 | { type: "build", scope: "deps", release: "patch" }, 18 | { type: "refactor", release: "patch" }, 19 | { type: "style", release: "patch" } 20 | ], 21 | parserOpts: { 22 | noteKeywords: ["BREAKING CHANGE", "BREAKING CHANGES"] 23 | } 24 | } 25 | ], 26 | [ 27 | "@semantic-release/release-notes-generator", 28 | { 29 | preset: "angular", 30 | parserOpts: { 31 | noteKeywords: ["BREAKING CHANGE", "BREAKING CHANGES"] 32 | }, 33 | writerOpts: { 34 | commitsSort: ["subject", "scope"] 35 | } 36 | } 37 | ], 38 | "@semantic-release/changelog", 39 | [ 40 | "@semantic-release/npm", 41 | { 42 | npmPublish: true 43 | } 44 | ], 45 | [ 46 | "@semantic-release/git", 47 | { 48 | assets: assetsToUpdate 49 | } 50 | ], 51 | [ 52 | "@semantic-release/github", 53 | { 54 | successCommentCondition: 55 | '<% return issue.pull_request || !nextRelease.channel || !issue.labels.some(label => label.name === "released on @next"); %>' 56 | } 57 | ] 58 | ] 59 | }; 60 | 61 | module.exports = config; 62 | -------------------------------------------------------------------------------- /scripts/setup-pocketbase.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define the local .pocketbase directory 4 | POCKETBASE_DIR="$(pwd)/.pocketbase" 5 | 6 | # Remove the existing .pocketbase directory if it exists 7 | rm -rf "$POCKETBASE_DIR" 8 | 9 | # Create the .pocketbase directory if it doesn't exist 10 | mkdir -p "$POCKETBASE_DIR" 11 | 12 | # Change to the .pocketbase directory 13 | cd "$POCKETBASE_DIR" 14 | 15 | # Get the latest release tag from PocketBase GitHub releases 16 | latest_release=$(curl -s https://api.github.com/repos/pocketbase/pocketbase/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")') 17 | 18 | # Determine the architecture 19 | arch=$(uname -m) 20 | if [ "$arch" == "x86_64" ]; then 21 | arch="amd64" 22 | elif [ "$arch" == "aarch64" ]; then 23 | arch="arm64" 24 | else 25 | echo "Unsupported architecture: $arch" 26 | exit 1 27 | fi 28 | 29 | # Construct the download URL 30 | download_url="https://github.com/pocketbase/pocketbase/releases/download/${latest_release}/pocketbase_${latest_release#v}_linux_${arch}.zip" 31 | 32 | # Download the latest release 33 | echo "Downloading PocketBase ${latest_release}..." 34 | curl -s -L -o pocketbase.zip $download_url 35 | 36 | # Check if the download was successful 37 | if [ $? -ne 0 ]; then 38 | echo "Failed to download PocketBase release." 39 | exit 1 40 | fi 41 | 42 | # Extract the executable from the zip file 43 | echo "Extracting PocketBase ${latest_release}..." 44 | unzip -qq pocketbase.zip 45 | if [ $? -ne 0 ]; then 46 | echo "Failed to unzip PocketBase release." 47 | exit 1 48 | fi 49 | 50 | # Make the executable file executable 51 | chmod +x pocketbase 52 | 53 | # Clean up 54 | rm pocketbase.zip 55 | 56 | # Setup admin user 57 | echo "Setting up admin user..." 58 | ./pocketbase superuser upsert test@pawcode.de test1234 59 | if [ $? -ne 0 ]; then 60 | echo "Failed to setup admin user." 61 | exit 1 62 | fi 63 | 64 | echo "PocketBase ${latest_release} has been downloaded and is ready to use." 65 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { pocketbaseLoader } from "./pocketbase-loader"; 2 | import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type"; 3 | 4 | export { pocketbaseLoader }; 5 | export type { PocketBaseLoaderOptions }; 6 | -------------------------------------------------------------------------------- /src/loader/cleanup-entries.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type"; 3 | 4 | /** 5 | * Cleanup entries that are no longer in the collection. 6 | * 7 | * @param options Options for the loader. 8 | * @param context Context of the loader. 9 | * @param superuserToken Superuser token to access all resources. 10 | */ 11 | export async function cleanupEntries( 12 | options: PocketBaseLoaderOptions, 13 | context: LoaderContext, 14 | superuserToken: string | undefined 15 | ): Promise { 16 | // Build the URL for the collections endpoint 17 | const collectionUrl = new URL( 18 | `api/collections/${options.collectionName}/records`, 19 | options.url 20 | ).href; 21 | 22 | // Create the headers for the request to append the superuser token (if available) 23 | const collectionHeaders = new Headers(); 24 | if (superuserToken) { 25 | collectionHeaders.set("Authorization", superuserToken); 26 | } 27 | 28 | // Prepare pagination variables 29 | let page = 0; 30 | let totalPages = 0; 31 | const entries = new Set(); 32 | 33 | // Fetch all ids of the collection 34 | do { 35 | // Build search parameters 36 | const searchParams = new URLSearchParams({ 37 | page: `${++page}`, 38 | perPage: "1000", 39 | fields: "id" 40 | }); 41 | 42 | if (options.filter) { 43 | // If a filter is set, add it to the search parameters 44 | searchParams.set("filter", `(${options.filter})`); 45 | } 46 | 47 | // Fetch ids from the collection 48 | const collectionRequest = await fetch( 49 | `${collectionUrl}?${searchParams.toString()}`, 50 | { 51 | headers: collectionHeaders 52 | } 53 | ); 54 | 55 | // If the request was not successful, print the error message and return 56 | if (!collectionRequest.ok) { 57 | // If the collection is locked, an superuser token is required 58 | if (collectionRequest.status === 403) { 59 | context.logger.error( 60 | `The collection is not accessible without superuser rights. Please provide superuser credentials in the config.` 61 | ); 62 | } else { 63 | const reason = await collectionRequest 64 | .json() 65 | .then((data) => data.message); 66 | const errorMessage = `Fetching ids failed with status code ${collectionRequest.status}.\nReason: ${reason}`; 67 | context.logger.error(errorMessage); 68 | } 69 | 70 | // Remove all entries from the store 71 | context.logger.info(`Removing all entries from the store.`); 72 | context.store.clear(); 73 | return; 74 | } 75 | 76 | // Get the data from the response 77 | const response = await collectionRequest.json(); 78 | 79 | // Add the ids to the set 80 | for (const item of response.items) { 81 | entries.add(item.id); 82 | } 83 | 84 | // Update the page and total pages 85 | page = response.page; 86 | totalPages = response.totalPages; 87 | } while (page < totalPages); 88 | 89 | let cleanedUp = 0; 90 | 91 | // Get all ids of the entries in the store 92 | const storedIds = context.store 93 | .values() 94 | .map((entry) => entry.data.id) as Array; 95 | for (const id of storedIds) { 96 | // If the id is not in the entries set, remove the entry from the store 97 | if (!entries.has(id)) { 98 | context.store.delete(id); 99 | cleanedUp++; 100 | } 101 | } 102 | 103 | if (cleanedUp > 0) { 104 | // Log the number of cleaned up entries 105 | context.logger.info(`Cleaned up ${cleanedUp} old entries.`); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/loader/handle-realtime-updates.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type"; 3 | import { isRealtimeData } from "../utils/is-realtime-data"; 4 | import { parseEntry } from "./parse-entry"; 5 | 6 | /** 7 | * Handles realtime updates for the loader without making any new network requests. 8 | * 9 | * Returns `true` if the data was handled and no further action is needed. 10 | */ 11 | export async function handleRealtimeUpdates( 12 | context: LoaderContext, 13 | options: PocketBaseLoaderOptions 14 | ): Promise { 15 | // Check if a custom filter is set 16 | if (options.filter) { 17 | // Updating an entry directly via realtime updates is not supported when using a custom filter. 18 | // This is because the filter can only be applied via the get request and is not considered in the realtime updates. 19 | // Updating the entry directly would bypass the filter and could lead to inconsistent data. 20 | return false; 21 | } 22 | 23 | // Check if data was provided via the refresh context 24 | if (!context.refreshContextData?.data) { 25 | return false; 26 | } 27 | 28 | // Check if the data is PocketBase realtime data 29 | const data = context.refreshContextData.data; 30 | if (!isRealtimeData(data)) { 31 | return false; 32 | } 33 | 34 | // Check if the collection name matches the current collection 35 | if (data.record.collectionName !== options.collectionName) { 36 | return false; 37 | } 38 | 39 | // Handle deleted entry 40 | if (data.action === "delete") { 41 | context.logger.info("Removing deleted entry"); 42 | context.store.delete(data.record.id); 43 | return true; 44 | } 45 | 46 | // Handle updated or new entry 47 | if (data.action === "update") { 48 | context.logger.info("Updating outdated entry"); 49 | } else { 50 | context.logger.info("Creating new entry"); 51 | } 52 | 53 | // Parse the entry and store 54 | await parseEntry(data.record, context, options); 55 | return true; 56 | } 57 | -------------------------------------------------------------------------------- /src/loader/load-entries.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type"; 3 | import { parseEntry } from "./parse-entry"; 4 | 5 | /** 6 | * Load (modified) entries from a PocketBase collection. 7 | * 8 | * @param options Options for the loader. 9 | * @param context Context of the loader. 10 | * @param superuserToken Superuser token to access all resources. 11 | * @param lastModified Date of the last fetch to only update changed entries. 12 | * 13 | * @returns `true` if the collection has an updated column, `false` otherwise. 14 | */ 15 | export async function loadEntries( 16 | options: PocketBaseLoaderOptions, 17 | context: LoaderContext, 18 | superuserToken: string | undefined, 19 | lastModified: string | undefined 20 | ): Promise { 21 | // Build the URL for the collections endpoint 22 | const collectionUrl = new URL( 23 | `api/collections/${options.collectionName}/records`, 24 | options.url 25 | ).href; 26 | 27 | // Create the headers for the request to append the superuser token (if available) 28 | const collectionHeaders = new Headers(); 29 | if (superuserToken) { 30 | collectionHeaders.set("Authorization", superuserToken); 31 | } 32 | 33 | // Log the fetching of the entries 34 | context.logger.info( 35 | `Fetching${lastModified ? " modified" : ""} data${ 36 | lastModified ? ` starting at ${lastModified}` : "" 37 | }${superuserToken ? " as superuser" : ""}` 38 | ); 39 | 40 | // Prepare pagination variables 41 | let page = 0; 42 | let totalPages = 0; 43 | let entries = 0; 44 | 45 | // Fetch all (modified) entries 46 | do { 47 | // Build search parameters 48 | const searchParams = new URLSearchParams({ 49 | page: `${++page}`, 50 | perPage: "100" 51 | }); 52 | 53 | const filters = []; 54 | if (lastModified && options.updatedField) { 55 | // If `lastModified` is set, only fetch entries that have been modified since the last fetch 56 | filters.push(`(${options.updatedField}>"${lastModified}")`); 57 | // Sort by the updated field and id 58 | searchParams.set("sort", `-${options.updatedField},id`); 59 | } 60 | if (options.filter) { 61 | filters.push(`(${options.filter})`); 62 | } 63 | 64 | // Add filters to search parameters 65 | if (filters.length > 0) { 66 | searchParams.set("filter", filters.join("&&")); 67 | } 68 | 69 | // Fetch entries from the collection 70 | const collectionRequest = await fetch( 71 | `${collectionUrl}?${searchParams.toString()}`, 72 | { 73 | headers: collectionHeaders 74 | } 75 | ); 76 | 77 | // If the request was not successful, print the error message and return 78 | if (!collectionRequest.ok) { 79 | // If the collection is locked, an superuser token is required 80 | if (collectionRequest.status === 403) { 81 | throw new Error( 82 | `The collection is not accessible without superuser rights. Please provide superuser credentials in the config.` 83 | ); 84 | } 85 | 86 | // Get the reason for the error 87 | const reason = await collectionRequest 88 | .json() 89 | .then((data) => data.message); 90 | const errorMessage = `Fetching data failed with status code ${collectionRequest.status}.\nReason: ${reason}`; 91 | throw new Error(errorMessage); 92 | } 93 | 94 | // Get the data from the response 95 | const response = await collectionRequest.json(); 96 | 97 | // Parse and store the entries 98 | for (const entry of response.items) { 99 | await parseEntry(entry, context, options); 100 | } 101 | 102 | // Update the page and total pages 103 | page = response.page; 104 | totalPages = response.totalPages; 105 | entries += response.items.length; 106 | } while (page < totalPages); 107 | 108 | // Log the number of fetched entries 109 | if (lastModified) { 110 | context.logger.info( 111 | `Updated ${entries}/${context.store.keys().length} entries.` 112 | ); 113 | } else { 114 | context.logger.info(`Fetched ${entries} entries.`); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/loader/loader.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import packageJson from "../../package.json"; 3 | import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type"; 4 | import { getSuperuserToken } from "../utils/get-superuser-token"; 5 | import { shouldRefresh } from "../utils/should-refresh"; 6 | import { cleanupEntries } from "./cleanup-entries"; 7 | import { handleRealtimeUpdates } from "./handle-realtime-updates"; 8 | import { loadEntries } from "./load-entries"; 9 | 10 | export async function loader( 11 | context: LoaderContext, 12 | options: PocketBaseLoaderOptions 13 | ): Promise { 14 | context.logger.label = `pocketbase-loader:${options.collectionName}`; 15 | 16 | // Check if the collection should be refreshed. 17 | const refresh = shouldRefresh( 18 | context.refreshContextData, 19 | options.collectionName 20 | ); 21 | if (refresh === "skip") { 22 | return; 23 | } 24 | 25 | // Handle realtime updates 26 | const handled = await handleRealtimeUpdates(context, options); 27 | if (handled) { 28 | return; 29 | } 30 | 31 | // Get the date of the last fetch to only update changed entries. 32 | let lastModified = context.meta.get("last-modified"); 33 | 34 | // Force a full update if the refresh is forced 35 | if (refresh === "force") { 36 | lastModified = undefined; 37 | context.store.clear(); 38 | } 39 | 40 | // Check if the version has changed to force an update 41 | const lastVersion = context.meta.get("version"); 42 | if (lastVersion !== packageJson.version) { 43 | if (lastVersion) { 44 | context.logger.info( 45 | `PocketBase loader was updated from ${lastVersion} to ${packageJson.version}. All entries will be loaded again.` 46 | ); 47 | } 48 | 49 | // Disable incremental builds and clear the store 50 | lastModified = undefined; 51 | context.store.clear(); 52 | } 53 | 54 | // Disable incremental builds if no updated field is provided 55 | if (!options.updatedField) { 56 | context.logger.info( 57 | `No "updatedField" was provided. Incremental builds are disabled.` 58 | ); 59 | lastModified = undefined; 60 | } 61 | 62 | // Try to get a superuser token to access all resources. 63 | let token: string | undefined; 64 | if (options.superuserCredentials) { 65 | token = await getSuperuserToken( 66 | options.url, 67 | options.superuserCredentials, 68 | context.logger 69 | ); 70 | } 71 | 72 | if (context.store.keys().length > 0) { 73 | // Cleanup entries that are no longer in the collection 74 | await cleanupEntries(options, context, token); 75 | } 76 | 77 | // Load the (modified) entries 78 | await loadEntries(options, context, token, lastModified); 79 | 80 | // Set the last modified date to the current date 81 | context.meta.set("last-modified", new Date().toISOString().replace("T", " ")); 82 | 83 | context.meta.set("version", packageJson.version); 84 | } 85 | -------------------------------------------------------------------------------- /src/loader/parse-entry.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import type { PocketBaseEntry } from "../types/pocketbase-entry.type"; 3 | import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type"; 4 | import { slugify } from "../utils/slugify"; 5 | 6 | /** 7 | * Parse an entry from PocketBase to match the schema and store it in the store. 8 | * 9 | * @param entry Entry to parse. 10 | * @param context Context of the loader. 11 | * @param idField Field to use as id for the entry. 12 | * If not provided, the id of the entry will be used. 13 | * @param contentFields Field(s) to use as content for the entry. 14 | * If multiple fields are used, they will be concatenated and wrapped in `
` elements. 15 | */ 16 | export async function parseEntry( 17 | entry: PocketBaseEntry, 18 | { generateDigest, parseData, store, logger }: LoaderContext, 19 | { idField, contentFields, updatedField }: PocketBaseLoaderOptions 20 | ): Promise { 21 | let id = entry.id; 22 | if (idField) { 23 | // Get the custom ID of the entry if it exists 24 | const customEntryId = entry[idField]; 25 | 26 | if (!customEntryId) { 27 | logger.warn( 28 | `The entry "${id}" does not have a value for field ${idField}. Using the default ID instead.` 29 | ); 30 | } else { 31 | id = slugify(`${customEntryId}`); 32 | } 33 | } 34 | 35 | const oldEntry = store.get(id); 36 | if (oldEntry && oldEntry.data.id !== entry.id) { 37 | logger.warn( 38 | `The entry "${entry.id}" seems to be a duplicate of "${oldEntry.data.id}". Please make sure to use unique IDs in the column "${idField}".` 39 | ); 40 | } 41 | 42 | // Parse the data to match the schema 43 | // This will throw an error if the data does not match the schema 44 | const data = await parseData({ 45 | id, 46 | data: entry 47 | }); 48 | 49 | // Get the updated date of the entry 50 | let updated: string | undefined; 51 | if (updatedField) { 52 | updated = `${entry[updatedField]}`; 53 | } 54 | 55 | // Generate a digest for the entry 56 | // If no updated date is available, the digest will be generated from the whole entry 57 | const digest = generateDigest(updated ?? entry); 58 | 59 | if (!contentFields) { 60 | // Store the entry 61 | store.set({ 62 | id, 63 | data, 64 | digest 65 | }); 66 | return; 67 | } 68 | 69 | // Generate the content for the entry 70 | let content: string; 71 | if (typeof contentFields === "string") { 72 | // Only one field is used as content 73 | content = `${entry[contentFields]}`; 74 | } else { 75 | // Multiple fields are used as content, wrap each block in a section and concatenate them 76 | content = contentFields 77 | .map((field) => entry[field]) 78 | .map((block) => `
${block}
`) 79 | .join(""); 80 | } 81 | 82 | // Store the entry 83 | store.set({ 84 | id, 85 | data, 86 | digest, 87 | rendered: { 88 | html: content 89 | } 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /src/pocketbase-loader.ts: -------------------------------------------------------------------------------- 1 | import type { Loader } from "astro/loaders"; 2 | import { loader } from "./loader/loader"; 3 | import { generateSchema } from "./schema/generate-schema"; 4 | import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type"; 5 | 6 | /** 7 | * Loader for collections stored in PocketBase. 8 | * 9 | * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details. 10 | */ 11 | export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader { 12 | return { 13 | name: "pocketbase-loader", 14 | // Load the entries from the collection 15 | load: async (context) => loader(context, options), 16 | // Generate the schema for the collection according to the API 17 | schema: async () => await generateSchema(options) 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/schema/generate-schema.ts: -------------------------------------------------------------------------------- 1 | import type { ZodSchema } from "astro/zod"; 2 | import { z } from "astro/zod"; 3 | import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type"; 4 | import type { PocketBaseCollection } from "../types/pocketbase-schema.type"; 5 | import { getRemoteSchema } from "./get-remote-schema"; 6 | import { parseSchema } from "./parse-schema"; 7 | import { readLocalSchema } from "./read-local-schema"; 8 | import { transformFiles } from "./transform-files"; 9 | 10 | /** 11 | * Basic schema for every PocketBase collection. 12 | */ 13 | const BASIC_SCHEMA = { 14 | id: z.string(), 15 | collectionId: z.string(), 16 | collectionName: z.string() 17 | }; 18 | 19 | /** 20 | * Types of fields that can be used as an ID. 21 | */ 22 | const VALID_ID_TYPES = ["text", "number", "email", "url", "date"]; 23 | 24 | /** 25 | * Generate a schema for the collection based on the collection's schema in PocketBase. 26 | * By default, a basic schema is returned if no other schema is available. 27 | * If superuser credentials are provided, the schema is fetched from the PocketBase API. 28 | * If a path to a local schema file is provided, the schema is read from the file. 29 | * 30 | * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details. 31 | */ 32 | export async function generateSchema( 33 | options: PocketBaseLoaderOptions 34 | ): Promise { 35 | let collection: PocketBaseCollection | undefined; 36 | 37 | // Try to get the schema directly from the PocketBase instance 38 | collection = await getRemoteSchema(options); 39 | 40 | const hasSuperuserRights = !!collection || !!options.superuserCredentials; 41 | 42 | // If the schema is not available, try to read it from a local schema file 43 | if (!collection && options.localSchema) { 44 | collection = await readLocalSchema( 45 | options.localSchema, 46 | options.collectionName 47 | ); 48 | } 49 | 50 | // If the schema is still not available, return the basic schema 51 | if (!collection) { 52 | console.error( 53 | `No schema available for "${options.collectionName}". Only basic types are available. Please check your configuration and provide a valid schema file or superuser credentials.` 54 | ); 55 | // Return the basic schema since every collection has at least these fields 56 | return z.object(BASIC_SCHEMA); 57 | } 58 | 59 | // Parse the schema 60 | const fields = parseSchema( 61 | collection, 62 | options.jsonSchemas, 63 | hasSuperuserRights, 64 | options.improveTypes ?? false 65 | ); 66 | 67 | // Check if custom id field is present 68 | if (options.idField) { 69 | // Find the id field in the schema 70 | const idField = collection.fields.find( 71 | (field) => field.name === options.idField 72 | ); 73 | 74 | // Check if the id field is present and of a valid type 75 | if (!idField) { 76 | console.error( 77 | `The id field "${options.idField}" is not present in the schema of the collection "${options.collectionName}".` 78 | ); 79 | } else if (!VALID_ID_TYPES.includes(idField.type)) { 80 | console.error( 81 | `The id field "${options.idField}" for collection "${ 82 | options.collectionName 83 | }" is of type "${ 84 | idField.type 85 | }" which is not recommended. Please use one of the following types: ${VALID_ID_TYPES.join( 86 | ", " 87 | )}.` 88 | ); 89 | } 90 | } 91 | 92 | // Check if the content field is present 93 | if ( 94 | typeof options.contentFields === "string" && 95 | !fields[options.contentFields] 96 | ) { 97 | console.error( 98 | `The content field "${options.contentFields}" is not present in the schema of the collection "${options.collectionName}".` 99 | ); 100 | } else if (Array.isArray(options.contentFields)) { 101 | for (const field of options.contentFields) { 102 | if (!fields[field]) { 103 | console.error( 104 | `The content field "${field}" is not present in the schema of the collection "${options.collectionName}".` 105 | ); 106 | } 107 | } 108 | } 109 | 110 | // Check if the updated field is present 111 | if (options.updatedField) { 112 | if (!fields[options.updatedField]) { 113 | console.error( 114 | `The field "${options.updatedField}" is not present in the schema of the collection "${options.collectionName}".\nThis will lead to errors when trying to fetch only updated entries.` 115 | ); 116 | } else { 117 | const updatedField = collection.fields.find( 118 | (field) => field.name === options.updatedField 119 | ); 120 | if ( 121 | !updatedField || 122 | updatedField.type !== "autodate" || 123 | !updatedField.onUpdate 124 | ) { 125 | console.warn( 126 | `The field "${options.updatedField}" is not of type "autodate" with the value "Update" or "Create/Update".\nMake sure that the field is automatically updated when the entry is updated!` 127 | ); 128 | } 129 | } 130 | } 131 | 132 | // Combine the basic schema with the parsed fields 133 | const schema = z.object({ 134 | ...BASIC_SCHEMA, 135 | ...fields 136 | }); 137 | 138 | // Get all file fields 139 | const fileFields = collection.fields 140 | .filter((field) => field.type === "file") 141 | // Only show hidden fields if the user has superuser rights 142 | .filter((field) => !field.hidden || hasSuperuserRights); 143 | 144 | if (fileFields.length === 0) { 145 | return schema; 146 | } 147 | 148 | // Transform file names to file urls 149 | return schema.transform((entry) => 150 | transformFiles(options.url, fileFields, entry) 151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /src/schema/get-remote-schema.ts: -------------------------------------------------------------------------------- 1 | import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type"; 2 | import type { PocketBaseCollection } from "../types/pocketbase-schema.type"; 3 | import { getSuperuserToken } from "../utils/get-superuser-token"; 4 | 5 | /** 6 | * Fetches the schema for the specified collection from the PocketBase instance. 7 | * 8 | * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details. 9 | */ 10 | export async function getRemoteSchema( 11 | options: PocketBaseLoaderOptions 12 | ): Promise { 13 | if (!options.superuserCredentials) { 14 | return undefined; 15 | } 16 | 17 | // Get a superuser token 18 | const token = await getSuperuserToken( 19 | options.url, 20 | options.superuserCredentials 21 | ); 22 | 23 | // If the token is invalid try another method 24 | if (!token) { 25 | return undefined; 26 | } 27 | 28 | // Build URL and headers for the schema request 29 | const schemaUrl = new URL( 30 | `api/collections/${options.collectionName}`, 31 | options.url 32 | ).href; 33 | const schemaHeaders = new Headers(); 34 | schemaHeaders.set("Authorization", token); 35 | 36 | // Fetch the schema 37 | const schemaRequest = await fetch(schemaUrl, { 38 | headers: schemaHeaders 39 | }); 40 | 41 | // If the request was not successful, try another method 42 | if (!schemaRequest.ok) { 43 | const reason = await schemaRequest.json().then((data) => data.message); 44 | const errorMessage = `Fetching schema from ${options.collectionName} failed with status code ${schemaRequest.status}.\nReason: ${reason}`; 45 | console.error(errorMessage); 46 | 47 | return undefined; 48 | } 49 | 50 | // Get the schema from the response 51 | return await schemaRequest.json(); 52 | } 53 | -------------------------------------------------------------------------------- /src/schema/parse-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "astro/zod"; 2 | import type { 3 | PocketBaseCollection, 4 | PocketBaseSchemaEntry 5 | } from "../types/pocketbase-schema.type"; 6 | 7 | export function parseSchema( 8 | collection: PocketBaseCollection, 9 | customSchemas: Record | undefined, 10 | hasSuperuserRights: boolean, 11 | improveTypes: boolean 12 | ): Record { 13 | // Prepare the schemas fields 14 | const fields: Record = {}; 15 | 16 | // Parse every field in the schema 17 | for (const field of collection.fields) { 18 | // Skip hidden fields if the user does not have superuser rights 19 | if (field.hidden && !hasSuperuserRights) { 20 | continue; 21 | } 22 | 23 | let fieldType; 24 | 25 | // Determine the field type and create the corresponding Zod type 26 | switch (field.type) { 27 | case "number": 28 | fieldType = z.number(); 29 | break; 30 | case "bool": 31 | fieldType = z.boolean(); 32 | break; 33 | case "date": 34 | case "autodate": 35 | // Coerce and parse the value as a date 36 | fieldType = z.coerce.date(); 37 | break; 38 | case "geoPoint": 39 | fieldType = z.object({ 40 | lon: z.number(), 41 | lat: z.number() 42 | }); 43 | break; 44 | case "select": 45 | if (!field.values) { 46 | throw new Error( 47 | `Field ${field.name} is of type "select" but has no values defined.` 48 | ); 49 | } 50 | 51 | // Create an enum for the select values 52 | // @ts-expect-error - Zod complains because the values are not known at compile time and thus the array is not static. 53 | const values = z.enum(field.values); 54 | 55 | // Parse the field type based on the number of values it can have 56 | fieldType = parseSingleOrMultipleValues(field, values); 57 | break; 58 | case "relation": 59 | case "file": 60 | // NOTE: Relations are currently not supported and are treated as strings 61 | // NOTE: Files are later transformed to URLs 62 | 63 | // Parse the field type based on the number of values it can have 64 | fieldType = parseSingleOrMultipleValues(field, z.string()); 65 | break; 66 | case "json": 67 | if (customSchemas && customSchemas[field.name]) { 68 | // Use the user defined custom schema for the field 69 | fieldType = customSchemas[field.name]; 70 | } else { 71 | // Parse the field as unknown JSON 72 | fieldType = z.unknown(); 73 | } 74 | break; 75 | default: 76 | // Default to a string 77 | fieldType = z.string(); 78 | break; 79 | } 80 | 81 | const isRequired = 82 | // Check if the field is required 83 | field.required || 84 | // `onCreate autodate` fields are always set 85 | (field.type === "autodate" && field.onCreate) || 86 | // Improve number and boolean types by providing default values 87 | (improveTypes && (field.type === "number" || field.type === "bool")); 88 | 89 | // If the field is not required, mark it as optional 90 | if (!isRequired) { 91 | fieldType = z.preprocess( 92 | (val) => val || undefined, 93 | z.optional(fieldType) 94 | ); 95 | } 96 | 97 | // Add the field to the fields object 98 | fields[field.name] = fieldType; 99 | } 100 | 101 | return fields; 102 | } 103 | 104 | /** 105 | * Parse the field type based on the number of values it can have 106 | * 107 | * @param field Field to parse 108 | * @param type Type of each value 109 | * 110 | * @returns The parsed field type 111 | */ 112 | function parseSingleOrMultipleValues( 113 | field: PocketBaseSchemaEntry, 114 | type: z.ZodType 115 | ): z.ZodType { 116 | // If the select allows multiple values, create an array of the enum 117 | if (field.maxSelect === undefined || field.maxSelect === 1) { 118 | return type; 119 | } else { 120 | return z.array(type); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/schema/read-local-schema.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import path from "path"; 3 | import type { PocketBaseCollection } from "../types/pocketbase-schema.type"; 4 | 5 | /** 6 | * Reads the local PocketBase schema file and returns the schema for the specified collection. 7 | * 8 | * @param localSchemaPath Path to the local schema file. 9 | * @param collectionName Name of the collection to get the schema for. 10 | */ 11 | export async function readLocalSchema( 12 | localSchemaPath: string, 13 | collectionName: string 14 | ): Promise { 15 | const realPath = path.join(process.cwd(), localSchemaPath); 16 | 17 | try { 18 | // Read the schema file 19 | const schemaFile = await fs.readFile(realPath, "utf-8"); 20 | const database: Array = JSON.parse(schemaFile); 21 | 22 | // Check if the database file is valid 23 | if (!database || !Array.isArray(database)) { 24 | throw new Error("Invalid schema file"); 25 | } 26 | 27 | // Find and return the schema for the collection 28 | const schema = database.find( 29 | (collection) => collection.name === collectionName 30 | ); 31 | 32 | if (!schema) { 33 | throw new Error( 34 | `Collection "${collectionName}" not found in schema file` 35 | ); 36 | } 37 | 38 | return schema; 39 | } catch (error) { 40 | console.error( 41 | `Failed to read local schema from ${localSchemaPath}. No types will be generated.\nReason: ${error}` 42 | ); 43 | return undefined; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/schema/transform-files.ts: -------------------------------------------------------------------------------- 1 | import type { PocketBaseEntry } from "../types/pocketbase-entry.type"; 2 | import type { PocketBaseSchemaEntry } from "../types/pocketbase-schema.type"; 3 | 4 | /** 5 | * Transforms file names in a PocketBase entry to file URLs. 6 | * 7 | * @param baseUrl URL of the PocketBase instance. 8 | * @param collection Collection of the entry. 9 | * @param entry Entry to transform. 10 | */ 11 | export function transformFiles( 12 | baseUrl: string, 13 | fileFields: Array, 14 | entry: PocketBaseEntry 15 | ): PocketBaseEntry { 16 | // Transform all file names to file URLs 17 | for (const field of fileFields) { 18 | const fieldName = field.name; 19 | 20 | if (field.maxSelect === 1) { 21 | const fileName = entry[fieldName] as string | undefined; 22 | // Check if a file name is present 23 | if (!fileName) { 24 | continue; 25 | } 26 | 27 | // Transform the file name to a file URL 28 | entry[fieldName] = transformFileUrl( 29 | baseUrl, 30 | entry.collectionName, 31 | entry.id, 32 | fileName 33 | ); 34 | } else { 35 | const fileNames = entry[fieldName] as Array | undefined; 36 | // Check if file names are present 37 | if (!fileNames) { 38 | continue; 39 | } 40 | 41 | // Transform all file names to file URLs 42 | entry[fieldName] = fileNames.map((file) => 43 | transformFileUrl(baseUrl, entry.collectionName, entry.id, file) 44 | ); 45 | } 46 | } 47 | 48 | return entry; 49 | } 50 | 51 | /** 52 | * Transforms a file name to a PocketBase file URL. 53 | * 54 | * @param base Base URL of the PocketBase instance. 55 | * @param collectionName Name of the collection. 56 | * @param entryId ID of the entry. 57 | * @param file Name of the file. 58 | */ 59 | export function transformFileUrl( 60 | base: string, 61 | collectionName: string, 62 | entryId: string, 63 | file: string 64 | ): string { 65 | return `${base}/api/files/${collectionName}/${entryId}/${file}`; 66 | } 67 | -------------------------------------------------------------------------------- /src/types/pocketbase-entry.type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base interface for all PocketBase entries. 3 | */ 4 | interface PocketBaseBaseEntry { 5 | /** 6 | * ID of the entry. 7 | */ 8 | id: string; 9 | /** 10 | * ID of the collection the entry belongs to. 11 | */ 12 | collectionId: string; 13 | /** 14 | * Name of the collection the entry belongs to. 15 | */ 16 | collectionName: string; 17 | } 18 | 19 | /** 20 | * Type for a PocketBase entry. 21 | */ 22 | export type PocketBaseEntry = PocketBaseBaseEntry & Record; 23 | -------------------------------------------------------------------------------- /src/types/pocketbase-loader-options.type.ts: -------------------------------------------------------------------------------- 1 | import type { z } from "astro/zod"; 2 | 3 | /** 4 | * Options for the PocketBase loader. 5 | */ 6 | export interface PocketBaseLoaderOptions { 7 | /** 8 | * URL of the PocketBase instance. 9 | */ 10 | url: string; 11 | /** 12 | * Name of the collection in PocketBase. 13 | */ 14 | collectionName: string; 15 | /** 16 | * Field that should be used as the unique identifier for the collection. 17 | * This must be the name of a field in the collection that contains unique values. 18 | * If not provided, the `id` field will be used. 19 | * The value of this field will be used in `getEntry` and `getEntries` to load the entry or entries. 20 | * 21 | * If the field is a string, it will be slugified to be used in the URL. 22 | */ 23 | idField?: string; 24 | /** 25 | * Name of the field(s) containing the content of an entry. 26 | * This must be the name of a field in the PocketBase collection that contains the content. 27 | * The content will be parsed as HTML and rendered to the page. 28 | * 29 | * If you want to render multiple fields as main content, you can pass an array of field names. 30 | * The loader will concatenate the content of all fields in the order they are defined in the array. 31 | * Each block will be contained in a `
` element. 32 | */ 33 | contentFields?: string | Array; 34 | /** 35 | * Name of the field containing the last update date of an entry. 36 | * Ideally, this field should be of type `autodate` and have the value "Update" or "Create/Update". 37 | * This field is used to only fetch entries that have been modified since the last build. 38 | */ 39 | updatedField?: string; 40 | /** 41 | * Custom filter that is applied when loading data from PocketBase. 42 | * Valid syntax can be found in the [PocketBase documentation](https://pocketbase.io/docs/api-records/#listsearch-records) 43 | * 44 | * The loader will also add it's own filters for incremental builds. 45 | * These will be added to your custom filter query. 46 | * 47 | * Example: 48 | * ```ts 49 | * // config: 50 | * filter: 'release >= @now && deleted = false' 51 | * 52 | * // request 53 | * `?filter=(${loaderFilter})&&(release >= @now && deleted = false)` 54 | * ``` 55 | */ 56 | filter?: string; 57 | /** 58 | * Credentials of a superuser to get full access to the PocketBase instance. 59 | * This is required to get automatic type generation without a local schema, to access all resources even if they are not public and to fetch content of hidden fields. 60 | */ 61 | superuserCredentials?: { 62 | /** 63 | * Email of the superuser. 64 | */ 65 | email: string; 66 | /** 67 | * Password of the superuser. 68 | */ 69 | password: string; 70 | }; 71 | /** 72 | * File path to the local schema file. 73 | * This file will be used to generate the schema for the collection. 74 | * If `superuserCredentials` are provided, this option will be ignored. 75 | */ 76 | localSchema?: string; 77 | /** 78 | * Record of zod schemas for all JSON fields in the collection. 79 | * The key must be the name of a field in the collection. 80 | * If no schema is provided for a field, the value will be treated as `unknown`. 81 | * 82 | * Note that this will only be used for fields of type `json`. 83 | */ 84 | jsonSchemas?: Record; 85 | /** 86 | * Whether to improve the types of the generated schema. 87 | * With this option enabled, the schema will not include `undefined` as possible value for number and boolean fields and mark them as required. 88 | * 89 | * Why do we need this option? 90 | * The PocketBase API does always return at least `0` or `false` as the default values, even though the fields are not marked as required in the schema. 91 | */ 92 | improveTypes?: boolean; 93 | } 94 | -------------------------------------------------------------------------------- /src/types/pocketbase-schema.type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Entry for a collections schema in PocketBase. 3 | */ 4 | export interface PocketBaseSchemaEntry { 5 | /** 6 | * Flag to indicate if the field is hidden. 7 | * Hidden fields are not returned in the API response. 8 | */ 9 | hidden: boolean; 10 | /** 11 | * Name of the field. 12 | */ 13 | name: string; 14 | /** 15 | * Type of the field. 16 | */ 17 | type: string; 18 | /** 19 | * Whether the field is required. 20 | */ 21 | required: boolean; 22 | /** 23 | * Values for a select field. 24 | * This is only present if the field type is "select". 25 | */ 26 | values?: Array; 27 | /** 28 | * Maximum number of values for a select field. 29 | * This is only present on "select", "relation", and "file" fields. 30 | */ 31 | maxSelect?: number; 32 | /** 33 | * Whether the field is filled when the entry is created. 34 | * This is only present on "autodate" fields. 35 | */ 36 | onCreate?: boolean; 37 | /** 38 | * Whether the field is updated when the entry is updated. 39 | * This is only present on "autodate" fields. 40 | */ 41 | onUpdate?: boolean; 42 | } 43 | 44 | /** 45 | * Schema for a PocketBase collection. 46 | */ 47 | export interface PocketBaseCollection { 48 | /** 49 | * Name of the collection. 50 | */ 51 | name: string; 52 | /** 53 | * Type of the collection. 54 | */ 55 | type: "base" | "view" | "auth"; 56 | /** 57 | * Schema of the collection. 58 | */ 59 | fields: Array; 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/get-superuser-token.ts: -------------------------------------------------------------------------------- 1 | import type { AstroIntegrationLogger } from "astro"; 2 | 3 | /** 4 | * This function will get a superuser token from the given PocketBase instance. 5 | * 6 | * @param url URL of the PocketBase instance 7 | * @param superuserCredentials Credentials of the superuser 8 | * 9 | * @returns A superuser token to access all resources of the PocketBase instance. 10 | */ 11 | export async function getSuperuserToken( 12 | url: string, 13 | superuserCredentials: { 14 | email: string; 15 | password: string; 16 | }, 17 | logger?: AstroIntegrationLogger 18 | ): Promise { 19 | // Build the URL for the login endpoint 20 | const loginUrl = new URL( 21 | `api/collections/_superusers/auth-with-password`, 22 | url 23 | ).href; 24 | 25 | // Create a new FormData object to send the login data 26 | const loginData = new FormData(); 27 | loginData.set("identity", superuserCredentials.email); 28 | loginData.set("password", superuserCredentials.password); 29 | 30 | // Send the login request to get a token 31 | const loginRequest = await fetch(loginUrl, { 32 | method: "POST", 33 | body: loginData 34 | }); 35 | 36 | // If the login request was not successful, print the error message and return undefined 37 | if (!loginRequest.ok) { 38 | const reason = await loginRequest.json().then((data) => data.message); 39 | const errorMessage = `The given email / password for ${url} was not correct. Astro can't generate type definitions automatically and may not have access to all resources.\nReason: ${reason}`; 40 | if (logger) { 41 | logger.error(errorMessage); 42 | } else { 43 | console.error(errorMessage); 44 | } 45 | return undefined; 46 | } 47 | 48 | // Return the token 49 | return await loginRequest.json().then((data) => data.token); 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/is-realtime-data.ts: -------------------------------------------------------------------------------- 1 | import { z } from "astro/zod"; 2 | 3 | /** 4 | * Schema for realtime data received from PocketBase. 5 | */ 6 | const realtimeDataSchema = z.object({ 7 | action: z.union([ 8 | z.literal("create"), 9 | z.literal("update"), 10 | z.literal("delete") 11 | ]), 12 | record: z.object({ 13 | id: z.string(), 14 | collectionName: z.string(), 15 | collectionId: z.string() 16 | }) 17 | }); 18 | 19 | /** 20 | * Type for realtime data received from PocketBase. 21 | */ 22 | export type RealtimeData = z.infer; 23 | 24 | /** 25 | * Checks if the given data is realtime data received from PocketBase. 26 | */ 27 | export function isRealtimeData(data: unknown): data is RealtimeData { 28 | try { 29 | realtimeDataSchema.parse(data); 30 | return true; 31 | } catch { 32 | return false; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/should-refresh.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | 3 | /** 4 | * Checks if the collection should be refreshed. 5 | */ 6 | export function shouldRefresh( 7 | context: LoaderContext["refreshContextData"], 8 | collectionName: string 9 | ): "refresh" | "skip" | "force" { 10 | // Check if the refresh was triggered by the `astro-integration-pocketbase` 11 | // and the correct metadata is provided. 12 | if (!context || context.source !== "astro-integration-pocketbase") { 13 | return "refresh"; 14 | } 15 | 16 | // Check if all collections should be refreshed. 17 | if (context.force) { 18 | return "force"; 19 | } 20 | 21 | if (!context.collection) { 22 | return "refresh"; 23 | } 24 | 25 | // Check if the collection name matches the current collection. 26 | if (typeof context.collection === "string") { 27 | return context.collection === collectionName ? "refresh" : "skip"; 28 | } 29 | 30 | // Check if the collection is included in the list of collections. 31 | if (Array.isArray(context.collection)) { 32 | return context.collection.includes(collectionName) ? "refresh" : "skip"; 33 | } 34 | 35 | // Should not happen but return true to be safe. 36 | return "refresh"; 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/slugify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a string to a slug. 3 | * 4 | * Example: 5 | * ```ts 6 | * slugify("Hello World!"); // hello-world 7 | * ``` 8 | */ 9 | export function slugify(input: string): string { 10 | return input 11 | .toString() 12 | .toLowerCase() 13 | .replace(/\s+/g, "-") // Replace spaces with - 14 | .replace(/ä/g, "ae") // Replace umlauts 15 | .replace(/ö/g, "oe") 16 | .replace(/ü/g, "ue") 17 | .replace(/ß/g, "ss") 18 | .replace(/[^\w-]+/g, "") // Remove all non-word chars 19 | .replace(/--+/g, "-") // Replace multiple - with single - 20 | .replace(/^-+/, "") // Trim - from start of text 21 | .replace(/-+$/, ""); // Trim - from end of text 22 | } 23 | -------------------------------------------------------------------------------- /test/_mocks/batch-requests.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "vitest"; 2 | import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; 3 | 4 | export async function sendBatchRequest( 5 | requests: Array<{ 6 | method: "POST" | "DELETE"; 7 | url: string; 8 | body?: Record; 9 | }>, 10 | options: PocketBaseLoaderOptions, 11 | superuserToken: string 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | ): Promise { 14 | await enableBatchApi(options, superuserToken); 15 | 16 | const batchRequest = await fetch(new URL(`api/batch`, options.url), { 17 | method: "POST", 18 | headers: { 19 | Authorization: superuserToken, 20 | "Content-Type": "application/json" 21 | }, 22 | body: JSON.stringify({ requests }) 23 | }); 24 | 25 | assert(batchRequest.status === 200, "Failed to send batch request."); 26 | 27 | return await batchRequest.json(); 28 | } 29 | 30 | async function enableBatchApi( 31 | options: PocketBaseLoaderOptions, 32 | superuserToken: string 33 | ): Promise { 34 | const updateSettingsRequest = await fetch( 35 | new URL(`api/settings`, options.url), 36 | { 37 | method: "PATCH", 38 | headers: { 39 | Authorization: superuserToken, 40 | "Content-Type": "application/json" 41 | }, 42 | body: JSON.stringify({ 43 | batch: { 44 | enabled: true, 45 | maxBodySize: 0, 46 | maxRequests: 300, 47 | timeout: 3 48 | } 49 | }) 50 | } 51 | ); 52 | 53 | assert( 54 | updateSettingsRequest.status === 200, 55 | "Failed to update settings for batch processing." 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /test/_mocks/check-e2e-connection.ts: -------------------------------------------------------------------------------- 1 | export async function checkE2eConnection(): Promise { 2 | try { 3 | await fetch("http://localhost:8090/api/health"); 4 | } catch { 5 | throw new Error( 6 | "E2E connection failed. Make sure the PocketBase instance is running on http://localhost:8090." 7 | ); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/_mocks/create-loader-context.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import { vi } from "vitest"; 3 | import { LoggerMock } from "./logger.mock"; 4 | import { StoreMock } from "./store.mock"; 5 | 6 | export function createLoaderContext( 7 | context?: Partial 8 | ): LoaderContext { 9 | return { 10 | collection: "testCollection", 11 | generateDigest: vi.fn().mockReturnValue("digest"), 12 | logger: new LoggerMock(), 13 | parseData: vi.fn().mockResolvedValue({}), 14 | store: new StoreMock(), 15 | meta: new Map(), 16 | ...context 17 | } satisfies Partial as unknown as LoaderContext; 18 | } 19 | -------------------------------------------------------------------------------- /test/_mocks/create-loader-options.ts: -------------------------------------------------------------------------------- 1 | import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; 2 | 3 | export function createLoaderOptions( 4 | options?: Partial 5 | ): PocketBaseLoaderOptions { 6 | return { 7 | url: "http://127.0.0.1:8090", 8 | collectionName: "test", 9 | superuserCredentials: { 10 | email: "test@pawcode.de", 11 | password: "test1234" 12 | }, 13 | ...options 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /test/_mocks/create-pocketbase-entry.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "crypto"; 2 | import type { PocketBaseEntry } from "../../src/types/pocketbase-entry.type"; 3 | 4 | export function createPocketbaseEntry( 5 | entry?: Record 6 | ): PocketBaseEntry { 7 | return { 8 | id: Math.random().toString(36).substring(2, 17), 9 | collectionId: Math.random().toString(36).substring(2, 17), 10 | collectionName: "test", 11 | customId: randomUUID(), 12 | updated: new Date().toISOString().replace("T", " "), 13 | ...entry 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /test/_mocks/delete-collection.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "vitest"; 2 | import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; 3 | 4 | export async function deleteCollection( 5 | options: PocketBaseLoaderOptions, 6 | superuserToken: string 7 | ): Promise { 8 | const deleteRequest = await fetch( 9 | new URL(`api/collections/${options.collectionName}`, options.url), 10 | { 11 | method: "DELETE", 12 | headers: { 13 | Authorization: superuserToken, 14 | "Content-Type": "application/json" 15 | } 16 | } 17 | ); 18 | 19 | assert(deleteRequest.status === 204, "Deleting collection failed."); 20 | } 21 | -------------------------------------------------------------------------------- /test/_mocks/delete-entry.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "vitest"; 2 | import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; 3 | import { sendBatchRequest } from "./batch-requests"; 4 | 5 | export async function deleteEntries( 6 | entryIds: Array, 7 | options: PocketBaseLoaderOptions, 8 | superuserToken: string 9 | ): Promise { 10 | const requests = entryIds.map((entryId) => ({ 11 | method: "DELETE" as const, 12 | url: `/api/collections/${options.collectionName}/records/${entryId}` 13 | })); 14 | 15 | const batchResponse = await sendBatchRequest( 16 | requests, 17 | options, 18 | superuserToken 19 | ); 20 | 21 | assert( 22 | batchResponse.length === entryIds.length, 23 | "Failed to delete all entries in batch request." 24 | ); 25 | } 26 | 27 | export async function deleteEntry( 28 | entryId: string, 29 | options: PocketBaseLoaderOptions, 30 | superuserToken: string 31 | ): Promise { 32 | const deleteRequest = await fetch( 33 | new URL( 34 | `api/collections/${options.collectionName}/records/${entryId}`, 35 | options.url 36 | ), 37 | { 38 | method: "DELETE", 39 | headers: { 40 | Authorization: superuserToken 41 | } 42 | } 43 | ); 44 | 45 | assert(deleteRequest.status === 204, "Deleting entry failed."); 46 | } 47 | -------------------------------------------------------------------------------- /test/_mocks/insert-collection.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "console"; 2 | import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; 3 | 4 | export async function insertCollection( 5 | fields: Array>, 6 | options: PocketBaseLoaderOptions, 7 | superuserToken: string 8 | ): Promise { 9 | const insertRequest = await fetch(new URL(`api/collections`, options.url), { 10 | method: "POST", 11 | headers: { 12 | Authorization: superuserToken, 13 | "Content-Type": "application/json" 14 | }, 15 | body: JSON.stringify({ 16 | name: options.collectionName, 17 | fields: [...fields] 18 | }) 19 | }); 20 | 21 | assert(insertRequest.status === 200, "Collection is not available."); 22 | } 23 | -------------------------------------------------------------------------------- /test/_mocks/insert-entry.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "vitest"; 2 | import type { PocketBaseEntry } from "../../src/types/pocketbase-entry.type"; 3 | import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; 4 | import { sendBatchRequest } from "./batch-requests"; 5 | 6 | export async function insertEntries( 7 | data: Array>, 8 | options: PocketBaseLoaderOptions, 9 | superuserToken: string 10 | ): Promise> { 11 | const requests = data.map((entry) => ({ 12 | method: "POST" as const, 13 | url: `/api/collections/${options.collectionName}/records`, 14 | body: entry 15 | })); 16 | 17 | const batchResponse = await sendBatchRequest( 18 | requests, 19 | options, 20 | superuserToken 21 | ); 22 | 23 | assert( 24 | batchResponse.length === data.length, 25 | "Failed to insert all entries in batch request." 26 | ); 27 | 28 | const dbEntries: Array = []; 29 | for (const entry of batchResponse) { 30 | dbEntries.push(entry.body); 31 | } 32 | return dbEntries; 33 | } 34 | 35 | export async function insertEntry( 36 | data: Record, 37 | options: PocketBaseLoaderOptions, 38 | superuserToken: string 39 | ): Promise { 40 | const insertRequest = await fetch( 41 | new URL(`api/collections/${options.collectionName}/records`, options.url), 42 | { 43 | method: "POST", 44 | headers: { 45 | Authorization: superuserToken, 46 | "Content-Type": "application/json" 47 | }, 48 | body: JSON.stringify(data) 49 | } 50 | ); 51 | 52 | const entry = await insertRequest.json(); 53 | assert(entry.id, "Entry ID is not available."); 54 | 55 | return entry; 56 | } 57 | -------------------------------------------------------------------------------- /test/_mocks/logger.mock.ts: -------------------------------------------------------------------------------- 1 | import type { AstroIntegrationLogger } from "astro"; 2 | import { vi } from "vitest"; 3 | 4 | export class LoggerMock implements AstroIntegrationLogger { 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | public options!: any; 7 | public label = "mock"; 8 | 9 | public fork(_label: string): AstroIntegrationLogger { 10 | return this; 11 | } 12 | public info = vi.fn(); 13 | public warn = vi.fn(); 14 | public error = vi.fn(); 15 | public debug = vi.fn(); 16 | } 17 | -------------------------------------------------------------------------------- /test/_mocks/store.mock.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | import type { DataStore } from "astro/loaders"; 3 | 4 | export class StoreMock implements DataStore { 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | readonly #store = new Map(); 7 | 8 | public get(key: string) { 9 | return this.#store.get(key); 10 | } 11 | 12 | public entries() { 13 | return Array.from(this.#store.entries()); 14 | } 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | public set(opts: any) { 18 | this.#store.set(opts.id, opts); 19 | return true; 20 | } 21 | 22 | public values() { 23 | return Array.from(this.#store.values()); 24 | } 25 | 26 | public keys() { 27 | return Array.from(this.#store.keys()); 28 | } 29 | 30 | public delete(key: string) { 31 | this.#store.delete(key); 32 | } 33 | 34 | public clear() { 35 | this.#store.clear(); 36 | } 37 | 38 | public has(key: string) { 39 | return this.#store.has(key); 40 | } 41 | 42 | public addModuleImport(_fileName: string) { 43 | // Do nothing 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/_mocks/superuser_schema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "pbc_3142635823", 4 | "listRule": null, 5 | "viewRule": null, 6 | "createRule": null, 7 | "updateRule": null, 8 | "deleteRule": null, 9 | "name": "_superusers", 10 | "type": "auth", 11 | "fields": [ 12 | { 13 | "autogeneratePattern": "[a-z0-9]{15}", 14 | "hidden": false, 15 | "id": "text3208210256", 16 | "max": 15, 17 | "min": 15, 18 | "name": "id", 19 | "pattern": "^[a-z0-9]+$", 20 | "presentable": false, 21 | "primaryKey": true, 22 | "required": true, 23 | "system": true, 24 | "type": "text" 25 | }, 26 | { 27 | "cost": 0, 28 | "hidden": true, 29 | "id": "password901924565", 30 | "max": 0, 31 | "min": 8, 32 | "name": "password", 33 | "pattern": "", 34 | "presentable": false, 35 | "required": true, 36 | "system": true, 37 | "type": "password" 38 | }, 39 | { 40 | "hidden": true, 41 | "id": "file376926767", 42 | "maxSelect": 1, 43 | "maxSize": 0, 44 | "mimeTypes": [ 45 | "image/jpeg", 46 | "image/png", 47 | "image/svg+xml", 48 | "image/gif", 49 | "image/webp" 50 | ], 51 | "name": "avatar", 52 | "presentable": false, 53 | "protected": false, 54 | "required": false, 55 | "system": false, 56 | "thumbs": null, 57 | "type": "file" 58 | }, 59 | { 60 | "autogeneratePattern": "[a-zA-Z0-9]{50}", 61 | "hidden": true, 62 | "id": "text2504183744", 63 | "max": 60, 64 | "min": 30, 65 | "name": "tokenKey", 66 | "pattern": "", 67 | "presentable": false, 68 | "primaryKey": false, 69 | "required": true, 70 | "system": true, 71 | "type": "text" 72 | }, 73 | { 74 | "exceptDomains": null, 75 | "hidden": false, 76 | "id": "email3885137012", 77 | "name": "email", 78 | "onlyDomains": null, 79 | "presentable": false, 80 | "required": true, 81 | "system": true, 82 | "type": "email" 83 | }, 84 | { 85 | "hidden": false, 86 | "id": "bool1547992806", 87 | "name": "emailVisibility", 88 | "presentable": false, 89 | "required": false, 90 | "system": true, 91 | "type": "bool" 92 | }, 93 | { 94 | "hidden": false, 95 | "id": "bool256245529", 96 | "name": "verified", 97 | "presentable": false, 98 | "required": false, 99 | "system": true, 100 | "type": "bool" 101 | }, 102 | { 103 | "hidden": false, 104 | "id": "autodate2990389176", 105 | "name": "created", 106 | "onCreate": true, 107 | "onUpdate": false, 108 | "presentable": false, 109 | "system": true, 110 | "type": "autodate" 111 | }, 112 | { 113 | "hidden": false, 114 | "id": "autodate3332085495", 115 | "name": "updated", 116 | "onCreate": true, 117 | "onUpdate": true, 118 | "presentable": false, 119 | "system": true, 120 | "type": "autodate" 121 | } 122 | ], 123 | "indexes": [ 124 | "CREATE UNIQUE INDEX `idx_tokenKey_pbc_3142635823` ON `_superusers` (`tokenKey`)", 125 | "CREATE UNIQUE INDEX `idx_email_pbc_3142635823` ON `_superusers` (`email`) WHERE `email` != ''" 126 | ], 127 | "system": true, 128 | "authRule": "", 129 | "manageRule": null, 130 | "authAlert": { 131 | "enabled": true, 132 | "emailTemplate": { 133 | "subject": "Login from a new location", 134 | "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

\n Thanks,
\n {APP_NAME} team\n

" 135 | } 136 | }, 137 | "oauth2": { 138 | "mappedFields": { 139 | "id": "", 140 | "name": "", 141 | "username": "", 142 | "avatarURL": "" 143 | }, 144 | "enabled": false 145 | }, 146 | "passwordAuth": { 147 | "enabled": true, 148 | "identityFields": ["email"] 149 | }, 150 | "mfa": { 151 | "enabled": false, 152 | "duration": 1800, 153 | "rule": "" 154 | }, 155 | "otp": { 156 | "enabled": false, 157 | "duration": 180, 158 | "length": 8, 159 | "emailTemplate": { 160 | "subject": "OTP for {APP_NAME}", 161 | "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" 162 | } 163 | }, 164 | "authToken": { 165 | "duration": 86400 166 | }, 167 | "passwordResetToken": { 168 | "duration": 1800 169 | }, 170 | "emailChangeToken": { 171 | "duration": 1800 172 | }, 173 | "verificationToken": { 174 | "duration": 259200 175 | }, 176 | "fileToken": { 177 | "duration": 180 178 | }, 179 | "verificationTemplate": { 180 | "subject": "Verify your {APP_NAME} email", 181 | "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

" 182 | }, 183 | "resetPasswordTemplate": { 184 | "subject": "Reset your {APP_NAME} password", 185 | "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" 186 | }, 187 | "confirmEmailChangeTemplate": { 188 | "subject": "Confirm your {APP_NAME} new email address", 189 | "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" 190 | } 191 | } 192 | ] 193 | -------------------------------------------------------------------------------- /test/loader/cleanup-entries.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import { randomUUID } from "crypto"; 3 | import { 4 | assert, 5 | beforeAll, 6 | beforeEach, 7 | describe, 8 | expect, 9 | test, 10 | vi 11 | } from "vitest"; 12 | import { cleanupEntries } from "../../src/loader/cleanup-entries"; 13 | import { getSuperuserToken } from "../../src/utils/get-superuser-token"; 14 | import { checkE2eConnection } from "../_mocks/check-e2e-connection"; 15 | import { createLoaderContext } from "../_mocks/create-loader-context"; 16 | import { createLoaderOptions } from "../_mocks/create-loader-options"; 17 | import { createPocketbaseEntry } from "../_mocks/create-pocketbase-entry"; 18 | import { deleteCollection } from "../_mocks/delete-collection"; 19 | import { deleteEntry } from "../_mocks/delete-entry"; 20 | import { insertCollection } from "../_mocks/insert-collection"; 21 | import { insertEntries, insertEntry } from "../_mocks/insert-entry"; 22 | 23 | describe("cleanupEntries", () => { 24 | const options = createLoaderOptions({ collectionName: "users" }); 25 | let context: LoaderContext; 26 | let superuserToken: string; 27 | 28 | beforeAll(async () => { 29 | await checkE2eConnection(); 30 | }); 31 | 32 | beforeEach(async () => { 33 | context = createLoaderContext(); 34 | 35 | const token = await getSuperuserToken( 36 | options.url, 37 | options.superuserCredentials! 38 | ); 39 | 40 | assert(token, "Superuser token is not available."); 41 | superuserToken = token; 42 | }); 43 | 44 | test("should log error if collection is not accessible without superuser rights", async () => { 45 | const storeClearSpy = vi.spyOn(context.store, "clear"); 46 | 47 | await cleanupEntries( 48 | { ...options, collectionName: "_superusers" }, 49 | context, 50 | undefined 51 | ); 52 | 53 | expect(context.logger.error).toHaveBeenCalledOnce(); 54 | expect(storeClearSpy).toHaveBeenCalledOnce(); 55 | }); 56 | 57 | test("should log error if collection doesn't exist", async () => { 58 | const storeClearSpy = vi.spyOn(context.store, "clear"); 59 | const storeDeleteSpy = vi.spyOn(context.store, "delete"); 60 | const storeValuesSpy = vi.spyOn(context.store, "values"); 61 | 62 | await cleanupEntries( 63 | { ...options, collectionName: "nonExistent" }, 64 | context, 65 | superuserToken 66 | ); 67 | 68 | expect(context.logger.error).toHaveBeenCalledOnce(); 69 | expect(storeClearSpy).toHaveBeenCalledOnce(); 70 | expect(storeDeleteSpy).not.toHaveBeenCalled(); 71 | expect(storeValuesSpy).not.toHaveBeenCalled(); 72 | }); 73 | 74 | test("should cleanup old entries", async () => { 75 | const entry = createPocketbaseEntry(); 76 | context.store.set({ id: entry.id, data: entry }); 77 | 78 | await cleanupEntries(options, context, superuserToken); 79 | 80 | expect(context.logger.error).not.toHaveBeenCalled(); 81 | expect(context.store.has(entry.id)).toBe(false); 82 | expect(context.store.keys()).toHaveLength(0); 83 | }); 84 | 85 | test("should cleanup filtered entries", async () => { 86 | const testOptions = { 87 | ...options, 88 | collectionName: randomUUID().replace(/-/g, ""), 89 | filter: "visible=true" 90 | }; 91 | 92 | await insertCollection( 93 | [ 94 | { 95 | name: "visible", 96 | type: "bool" 97 | } 98 | ], 99 | testOptions, 100 | superuserToken 101 | ); 102 | 103 | const [visibleEntry, hiddenEntry] = await insertEntries( 104 | [ 105 | { 106 | visible: true 107 | }, 108 | { 109 | visible: false 110 | } 111 | ], 112 | testOptions, 113 | superuserToken 114 | ); 115 | 116 | context.store.set({ id: visibleEntry.id, data: visibleEntry }); 117 | context.store.set({ id: hiddenEntry.id, data: hiddenEntry }); 118 | 119 | await cleanupEntries(testOptions, context, superuserToken); 120 | 121 | expect(context.store.keys()).toHaveLength(1); 122 | expect(context.store.has(visibleEntry.id)).toBe(true); 123 | expect(context.store.has(hiddenEntry.id)).toBe(false); 124 | 125 | await deleteCollection(testOptions, superuserToken); 126 | }); 127 | 128 | test("should not cleanup entries if all are up-to-date", async () => { 129 | const entry = await insertEntry( 130 | { 131 | email: "cleanup@test.de", 132 | password: "test1234", 133 | passwordConfirm: "test1234", 134 | name: "Cleanup Test" 135 | }, 136 | options, 137 | superuserToken 138 | ); 139 | 140 | const storeDeleteSpy = vi.spyOn(context.store, "delete"); 141 | 142 | context.store.set({ id: entry.id, data: entry }); 143 | 144 | await cleanupEntries(options, context, superuserToken); 145 | 146 | expect(context.store.has(entry.id)).toBe(true); 147 | expect(context.store.keys()).toHaveLength(1); 148 | expect(storeDeleteSpy).not.toHaveBeenCalled(); 149 | 150 | await deleteEntry(entry.id, options, superuserToken); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /test/loader/handle-realtime-updates.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, expect, test, vi } from "vitest"; 2 | import { handleRealtimeUpdates } from "../../src/loader/handle-realtime-updates"; 3 | import { createLoaderContext } from "../_mocks/create-loader-context"; 4 | import { createLoaderOptions } from "../_mocks/create-loader-options"; 5 | import { createPocketbaseEntry } from "../_mocks/create-pocketbase-entry"; 6 | 7 | describe("handleRealtimeUpdates", () => { 8 | const options = createLoaderOptions(); 9 | const record = createPocketbaseEntry(); 10 | 11 | test("should return false if no refresh context data is provided", async () => { 12 | const context = createLoaderContext(); 13 | 14 | const result = await handleRealtimeUpdates(context, options); 15 | assert(!result); 16 | }); 17 | 18 | test("should return false if custom filter is set", async () => { 19 | const context = createLoaderContext({ 20 | refreshContextData: { data: { record, action: "create" } } 21 | }); 22 | const options = createLoaderOptions({ 23 | filter: "filter" 24 | }); 25 | const result = await handleRealtimeUpdates(context, options); 26 | assert(!result); 27 | }); 28 | 29 | test("should return false if data is not PocketBase realtime data", async () => { 30 | const context = createLoaderContext({ 31 | refreshContextData: { data: { invalid: "data" } } 32 | }); 33 | 34 | let result = await handleRealtimeUpdates(context, options); 35 | assert(!result, "Invalid data"); 36 | 37 | context.refreshContextData = { data: { record, action: "invalid" } }; 38 | 39 | result = await handleRealtimeUpdates(context, options); 40 | assert(!result, "Invalid action"); 41 | 42 | context.refreshContextData = { 43 | data: { record: { invalid: "data" }, action: "create" } 44 | }; 45 | 46 | result = await handleRealtimeUpdates(context, options); 47 | assert(!result, "Invalid record"); 48 | }); 49 | 50 | test("should return false if collection name does not match", async () => { 51 | const context = createLoaderContext({ 52 | refreshContextData: { 53 | data: { record, action: "create" } 54 | } 55 | }); 56 | const options = createLoaderOptions({ 57 | collectionName: record.collectionName + "invalid" 58 | }); 59 | const result = await handleRealtimeUpdates(context, options); 60 | 61 | assert(!result); 62 | }); 63 | 64 | test("should handle deleted entry and return true", async () => { 65 | const context = createLoaderContext({ 66 | refreshContextData: { 67 | data: { 68 | record, 69 | action: "delete" 70 | } 71 | } 72 | }); 73 | 74 | const deleteSpy = vi.spyOn(context.store, "delete"); 75 | 76 | const result = await handleRealtimeUpdates(context, options); 77 | assert(result); 78 | 79 | expect(deleteSpy).toHaveBeenCalledExactlyOnceWith( 80 | // @ts-expect-error data is unknown 81 | context.refreshContextData.data.record.id 82 | ); 83 | }); 84 | 85 | test("should handle updated entry and return true", async () => { 86 | const context = createLoaderContext({ 87 | refreshContextData: { 88 | data: { 89 | record, 90 | action: "update" 91 | } 92 | } 93 | }); 94 | 95 | const storeSetSpy = vi.spyOn(context.store, "set"); 96 | 97 | const result = await handleRealtimeUpdates(context, options); 98 | assert(result); 99 | 100 | expect(storeSetSpy).toHaveBeenCalledOnce(); 101 | }); 102 | 103 | test("should handle new entry and return true", async () => { 104 | const context = createLoaderContext({ 105 | refreshContextData: { 106 | data: { 107 | record, 108 | action: "create" 109 | } 110 | } 111 | }); 112 | 113 | const storeSetSpy = vi.spyOn(context.store, "set"); 114 | 115 | const result = await handleRealtimeUpdates(context, options); 116 | assert(result); 117 | 118 | expect(storeSetSpy).toHaveBeenCalledOnce(); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /test/loader/load-entries.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import { randomUUID } from "crypto"; 3 | import { 4 | afterEach, 5 | assert, 6 | beforeAll, 7 | beforeEach, 8 | describe, 9 | expect, 10 | test, 11 | vi 12 | } from "vitest"; 13 | import { loadEntries } from "../../src/loader/load-entries"; 14 | import { parseEntry } from "../../src/loader/parse-entry"; 15 | import { getSuperuserToken } from "../../src/utils/get-superuser-token"; 16 | import { checkE2eConnection } from "../_mocks/check-e2e-connection"; 17 | import { createLoaderContext } from "../_mocks/create-loader-context"; 18 | import { createLoaderOptions } from "../_mocks/create-loader-options"; 19 | import { deleteCollection } from "../_mocks/delete-collection"; 20 | import { insertCollection } from "../_mocks/insert-collection"; 21 | import { insertEntries } from "../_mocks/insert-entry"; 22 | 23 | vi.mock("../../src/loader/parse-entry"); 24 | 25 | const DAY = 24 * 60 * 60 * 1000; 26 | 27 | describe("loadEntries", () => { 28 | const options = createLoaderOptions({ collectionName: "_superusers" }); 29 | let context: LoaderContext; 30 | let superuserToken: string; 31 | 32 | beforeAll(async () => { 33 | await checkE2eConnection(); 34 | }); 35 | 36 | beforeEach(async () => { 37 | context = createLoaderContext(); 38 | 39 | const token = await getSuperuserToken( 40 | options.url, 41 | options.superuserCredentials! 42 | ); 43 | 44 | assert(token, "Superuser token is not available."); 45 | superuserToken = token; 46 | }); 47 | 48 | afterEach(() => { 49 | vi.resetAllMocks(); 50 | }); 51 | 52 | test("should fetch entries without errors", async () => { 53 | await loadEntries(options, context, superuserToken, undefined); 54 | 55 | expect(parseEntry).toHaveBeenCalledOnce(); 56 | }); 57 | 58 | test("should handle empty response gracefully", async () => { 59 | const testOptions = { ...options, collectionName: "users" }; 60 | 61 | await loadEntries(testOptions, context, superuserToken, undefined); 62 | 63 | expect(parseEntry).not.toHaveBeenCalled(); 64 | }); 65 | 66 | test("should load all pages", async () => { 67 | const testOptions = { 68 | ...options, 69 | collectionName: randomUUID().replace(/-/g, "") 70 | }; 71 | const numberOfEntries = 202; 72 | 73 | await insertCollection([], testOptions, superuserToken); 74 | await insertEntries( 75 | new Array(numberOfEntries).fill({}), 76 | testOptions, 77 | superuserToken 78 | ); 79 | 80 | await loadEntries(testOptions, context, superuserToken, undefined); 81 | 82 | expect(parseEntry).toHaveBeenCalledTimes(numberOfEntries); 83 | 84 | await deleteCollection(testOptions, superuserToken); 85 | }); 86 | 87 | test("should load filtered pages", async () => { 88 | const testOptions = { 89 | ...options, 90 | collectionName: randomUUID().replace(/-/g, ""), 91 | filter: "value=true" 92 | }; 93 | const numberOfEntries = 101; 94 | 95 | await insertCollection( 96 | [ 97 | { 98 | name: "value", 99 | type: "bool" 100 | } 101 | ], 102 | testOptions, 103 | superuserToken 104 | ); 105 | await insertEntries( 106 | new Array(numberOfEntries).fill({ value: true }), 107 | testOptions, 108 | superuserToken 109 | ); 110 | await insertEntries( 111 | new Array(numberOfEntries).fill({ value: false }), 112 | testOptions, 113 | superuserToken 114 | ); 115 | await loadEntries(testOptions, context, superuserToken, undefined); 116 | 117 | expect(parseEntry).toHaveBeenCalledTimes(numberOfEntries); 118 | 119 | await deleteCollection(testOptions, superuserToken); 120 | }); 121 | 122 | describe("incremental updates", () => { 123 | test("should fetch all entries when updatedField is missing", async () => { 124 | const lastModified = new Date(Date.now() - DAY).toISOString(); 125 | await loadEntries(options, context, superuserToken, lastModified); 126 | 127 | expect(parseEntry).toHaveBeenCalledOnce(); 128 | }); 129 | 130 | test("should fetch updated entries", async () => { 131 | const testOptions = { ...options, updatedField: "updated" }; 132 | const lastModified = new Date(Date.now() - DAY).toISOString(); 133 | 134 | await loadEntries(testOptions, context, superuserToken, lastModified); 135 | 136 | expect(parseEntry).toHaveBeenCalledOnce(); 137 | }); 138 | 139 | test("should do nothing without updated entries", async () => { 140 | const testOptions = { ...options, updatedField: "updated" }; 141 | const lastModified = new Date(Date.now() + DAY).toISOString(); 142 | 143 | await loadEntries(testOptions, context, superuserToken, lastModified); 144 | 145 | expect(parseEntry).not.toHaveBeenCalled(); 146 | }); 147 | 148 | test("should not fetch updated entries excluded from filter", async () => { 149 | const testOptions = { 150 | ...options, 151 | updatedField: "updated", 152 | filter: "verified=false" 153 | }; 154 | const lastModified = new Date(Date.now() - DAY).toISOString(); 155 | 156 | await loadEntries(testOptions, context, superuserToken, lastModified); 157 | 158 | expect(parseEntry).not.toHaveBeenCalled(); 159 | }); 160 | }); 161 | 162 | describe("error handling", () => { 163 | test("should throw error if collection is not accessible without superuser rights", async () => { 164 | const promise = loadEntries(options, context, undefined, undefined); 165 | 166 | await expect(promise).rejects.toThrow(); 167 | }); 168 | 169 | test("should throw error if collection is missing", async () => { 170 | const invalidOptions = { 171 | ...options, 172 | collectionName: "invalidCollection" 173 | }; 174 | 175 | const promise = loadEntries( 176 | invalidOptions, 177 | context, 178 | superuserToken, 179 | undefined 180 | ); 181 | 182 | await expect(promise).rejects.toThrow(); 183 | }); 184 | 185 | test("should throw error invalid filter", async () => { 186 | const invalidOptions = { 187 | ...options, 188 | filter: "invalidFilter>0" 189 | }; 190 | 191 | const promise = loadEntries( 192 | invalidOptions, 193 | context, 194 | superuserToken, 195 | undefined 196 | ); 197 | 198 | await expect(promise).rejects.toThrow(); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /test/loader/loader.spec.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; 3 | import packageJson from "../../package.json"; 4 | import { cleanupEntries } from "../../src/loader/cleanup-entries"; 5 | import { handleRealtimeUpdates } from "../../src/loader/handle-realtime-updates"; 6 | import { loadEntries } from "../../src/loader/load-entries"; 7 | import { loader } from "../../src/loader/loader"; 8 | import { getSuperuserToken } from "../../src/utils/get-superuser-token"; 9 | import { createLoaderContext } from "../_mocks/create-loader-context"; 10 | import { createLoaderOptions } from "../_mocks/create-loader-options"; 11 | import { createPocketbaseEntry } from "../_mocks/create-pocketbase-entry"; 12 | 13 | vi.mock("../../src/utils/get-superuser-token"); 14 | vi.mock("../../src/utils/should-refresh"); 15 | vi.mock("../../src/loader/cleanup-entries"); 16 | vi.mock("../../src/loader/handle-realtime-updates"); 17 | vi.mock("../../src/loader/load-entries"); 18 | 19 | describe("loader", async () => { 20 | let context: LoaderContext; 21 | const options = createLoaderOptions({ updatedField: "updated" }); 22 | const srm = await import("../../src/utils/should-refresh"); 23 | const hrum = await import("../../src/loader/handle-realtime-updates"); 24 | const gstm = await import("../../src/utils/get-superuser-token"); 25 | 26 | beforeEach(() => { 27 | context = createLoaderContext(); 28 | context.meta.set("version", packageJson.version); 29 | context.meta.set( 30 | "last-modified", 31 | new Date().toISOString().replace("T", " ") 32 | ); 33 | }); 34 | 35 | afterEach(() => { 36 | vi.resetAllMocks(); 37 | }); 38 | 39 | test('should not refresh if shouldRefresh returns "skip"', async () => { 40 | srm.shouldRefresh = vi.fn().mockReturnValue("skip"); 41 | 42 | await loader(context, options); 43 | 44 | expect(srm.shouldRefresh).toHaveBeenCalledOnce(); 45 | expect(handleRealtimeUpdates).not.toHaveBeenCalled(); 46 | expect(loadEntries).not.toHaveBeenCalled(); 47 | }); 48 | 49 | test("should not refresh if handleRealtimeUpdates handled update", async () => { 50 | srm.shouldRefresh = vi.fn().mockReturnValue("refresh"); 51 | hrum.handleRealtimeUpdates = vi.fn().mockResolvedValue(true); 52 | 53 | await loader(context, options); 54 | 55 | expect(handleRealtimeUpdates).toHaveBeenCalledOnce(); 56 | expect(loadEntries).not.toHaveBeenCalled(); 57 | }); 58 | 59 | test('should clear store and disable incremental builds if shouldRefresh returns "force"', async () => { 60 | srm.shouldRefresh = vi.fn().mockReturnValue("force"); 61 | hrum.handleRealtimeUpdates = vi.fn().mockResolvedValue(false); 62 | gstm.getSuperuserToken = vi.fn().mockResolvedValue(undefined); 63 | const storeClearSpy = vi.spyOn(context.store, "clear"); 64 | 65 | await loader(context, options); 66 | 67 | expect(storeClearSpy).toHaveBeenCalledOnce(); 68 | expect(loadEntries).toHaveBeenCalledWith( 69 | options, 70 | context, 71 | undefined, 72 | undefined 73 | ); 74 | }); 75 | 76 | test("should clear store and disable incremental builds if version changes", async () => { 77 | srm.shouldRefresh = vi.fn().mockReturnValue("refresh"); 78 | hrum.handleRealtimeUpdates = vi.fn().mockResolvedValue(false); 79 | gstm.getSuperuserToken = vi.fn().mockResolvedValue(undefined); 80 | const storeClearSpy = vi.spyOn(context.store, "clear"); 81 | context.meta.set("version", "invalidVersion"); 82 | 83 | await loader(context, options); 84 | 85 | expect(storeClearSpy).toHaveBeenCalledOnce(); 86 | expect(loadEntries).toHaveBeenCalledWith( 87 | options, 88 | context, 89 | undefined, 90 | undefined 91 | ); 92 | }); 93 | 94 | test("should disable incremental builds if no updatedField is provided", async () => { 95 | srm.shouldRefresh = vi.fn().mockReturnValue("refresh"); 96 | hrum.handleRealtimeUpdates = vi.fn().mockResolvedValue(false); 97 | gstm.getSuperuserToken = vi.fn().mockResolvedValue(undefined); 98 | options.updatedField = undefined; 99 | 100 | await loader(context, options); 101 | 102 | expect(loadEntries).toHaveBeenCalledWith( 103 | options, 104 | context, 105 | undefined, 106 | undefined 107 | ); 108 | }); 109 | 110 | test("should get superuser token if superuserCredentials are provided", async () => { 111 | const token = "token"; 112 | srm.shouldRefresh = vi.fn().mockReturnValue("refresh"); 113 | hrum.handleRealtimeUpdates = vi.fn().mockResolvedValue(false); 114 | gstm.getSuperuserToken = vi.fn().mockResolvedValue(token); 115 | const entry = createPocketbaseEntry(); 116 | context.store.set({ id: entry.id, data: entry }); 117 | 118 | await loader(context, options); 119 | 120 | expect(getSuperuserToken).toHaveBeenCalledOnce(); 121 | expect(cleanupEntries).toHaveBeenCalledWith(options, context, token); 122 | expect(loadEntries).toHaveBeenCalledWith( 123 | options, 124 | context, 125 | token, 126 | undefined 127 | ); 128 | }); 129 | 130 | test("should cleanup old entries if store has keys", async () => { 131 | srm.shouldRefresh = vi.fn().mockReturnValue("refresh"); 132 | hrum.handleRealtimeUpdates = vi.fn().mockResolvedValue(false); 133 | gstm.getSuperuserToken = vi.fn().mockResolvedValue(undefined); 134 | const entry = createPocketbaseEntry(); 135 | context.store.set({ id: entry.id, data: entry }); 136 | 137 | await loader(context, options); 138 | 139 | expect(cleanupEntries).toHaveBeenCalledWith(options, context, undefined); 140 | }); 141 | 142 | test("should set last-modified and version in meta after loading entries", async () => { 143 | srm.shouldRefresh = vi.fn().mockReturnValue("refresh"); 144 | hrum.handleRealtimeUpdates = vi.fn().mockResolvedValue(false); 145 | gstm.getSuperuserToken = vi.fn().mockResolvedValue(undefined); 146 | context.meta.delete("last-modified"); 147 | context.meta.delete("version"); 148 | 149 | await loader(context, options); 150 | 151 | expect(context.meta.get("last-modified")).toBeDefined(); 152 | expect(context.meta.get("version")).toBe(packageJson.version); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/loader/parse-entry.spec.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderContext } from "astro/loaders"; 2 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; 3 | import { parseEntry } from "../../src/loader/parse-entry"; 4 | import type { PocketBaseEntry } from "../../src/types/pocketbase-entry.type"; 5 | import { createLoaderContext } from "../_mocks/create-loader-context"; 6 | import { createLoaderOptions } from "../_mocks/create-loader-options"; 7 | import { createPocketbaseEntry } from "../_mocks/create-pocketbase-entry"; 8 | 9 | describe("parseEntry", () => { 10 | let context: LoaderContext; 11 | let entry: PocketBaseEntry; 12 | 13 | beforeEach(() => { 14 | context = createLoaderContext(); 15 | entry = createPocketbaseEntry(); 16 | }); 17 | 18 | afterEach(() => { 19 | vi.resetAllMocks(); 20 | }); 21 | 22 | test("should use default ID if no custom ID field is provided", async () => { 23 | const options = createLoaderOptions(); 24 | 25 | const storeSetSpy = vi.spyOn(context.store, "set"); 26 | 27 | await parseEntry(entry, context, options); 28 | 29 | expect(storeSetSpy).toHaveBeenCalledWith({ 30 | id: entry.id, 31 | data: {}, 32 | digest: "digest" 33 | }); 34 | }); 35 | 36 | test("should use custom ID if provided", async () => { 37 | const options = createLoaderOptions({ idField: "customId" }); 38 | 39 | const storeSetSpy = vi.spyOn(context.store, "set"); 40 | 41 | await parseEntry(entry, context, options); 42 | 43 | expect(storeSetSpy).toHaveBeenCalledWith({ 44 | id: entry.customId, 45 | data: {}, 46 | digest: "digest" 47 | }); 48 | }); 49 | 50 | test("should log a warning if custom ID field is missing", async () => { 51 | const options = createLoaderOptions({ idField: "invalidCustomId" }); 52 | 53 | await parseEntry(entry, context, options); 54 | 55 | expect(context.logger.warn).toHaveBeenCalledOnce(); 56 | }); 57 | 58 | test("should log a warning if entry is a duplicate", async () => { 59 | const options = createLoaderOptions(); 60 | 61 | context.store.set({ id: entry.id, data: { ...entry, id: "456" } }); 62 | 63 | await parseEntry(entry, context, options); 64 | 65 | expect(context.logger.warn).toHaveBeenCalledOnce(); 66 | }); 67 | 68 | test("should log a warning if entry has a duplicate custom id", async () => { 69 | const options = createLoaderOptions({ idField: "collectionName" }); 70 | 71 | context.store.set({ id: entry.collectionName, data: { id: "456" } }); 72 | 73 | await parseEntry(entry, context, options); 74 | 75 | expect(context.logger.warn).toHaveBeenCalledOnce(); 76 | }); 77 | 78 | test("should use updated field as digest if provided", async () => { 79 | const options = createLoaderOptions({ updatedField: "updated" }); 80 | 81 | await parseEntry(entry, context, options); 82 | 83 | expect(context.generateDigest).toHaveBeenCalledWith(entry.updated); 84 | }); 85 | 86 | test("should concatenate multiple content fields", async () => { 87 | const options = createLoaderOptions({ 88 | contentFields: ["collectionName", "customId"] 89 | }); 90 | 91 | const storeSetSpy = vi.spyOn(context.store, "set"); 92 | 93 | await parseEntry(entry, context, options); 94 | 95 | expect(storeSetSpy).toHaveBeenCalledWith({ 96 | id: entry.id, 97 | data: {}, 98 | digest: "digest", 99 | rendered: { 100 | html: `
${entry.collectionName}
${entry.customId}
` 101 | } 102 | }); 103 | }); 104 | 105 | test("should handle single content field", async () => { 106 | const options = createLoaderOptions({ contentFields: "collectionName" }); 107 | 108 | const storeSetSpy = vi.spyOn(context.store, "set"); 109 | 110 | await parseEntry(entry, context, options); 111 | 112 | expect(storeSetSpy).toHaveBeenCalledWith({ 113 | id: entry.id, 114 | data: {}, 115 | digest: "digest", 116 | rendered: { 117 | html: entry.collectionName 118 | } 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/schema/__snapshots__/get-remote-schema.e2e-spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`getRemoteSchema > should return schema if fetch request is successful 1`] = ` 4 | { 5 | "authRule": "", 6 | "authToken": { 7 | "duration": 604800, 8 | }, 9 | "createRule": "", 10 | "deleteRule": "id = @request.auth.id", 11 | "emailChangeToken": { 12 | "duration": 1800, 13 | }, 14 | "fields": [ 15 | { 16 | "autogeneratePattern": "[a-z0-9]{15}", 17 | "hidden": false, 18 | "max": 15, 19 | "min": 15, 20 | "name": "id", 21 | "pattern": "^[a-z0-9]+$", 22 | "presentable": false, 23 | "primaryKey": true, 24 | "required": true, 25 | "system": true, 26 | "type": "text", 27 | }, 28 | { 29 | "cost": 0, 30 | "hidden": true, 31 | "max": 0, 32 | "min": 8, 33 | "name": "password", 34 | "pattern": "", 35 | "presentable": false, 36 | "required": true, 37 | "system": true, 38 | "type": "password", 39 | }, 40 | { 41 | "autogeneratePattern": "[a-zA-Z0-9]{50}", 42 | "hidden": true, 43 | "max": 60, 44 | "min": 30, 45 | "name": "tokenKey", 46 | "pattern": "", 47 | "presentable": false, 48 | "primaryKey": false, 49 | "required": true, 50 | "system": true, 51 | "type": "text", 52 | }, 53 | { 54 | "exceptDomains": null, 55 | "hidden": false, 56 | "name": "email", 57 | "onlyDomains": null, 58 | "presentable": false, 59 | "required": true, 60 | "system": true, 61 | "type": "email", 62 | }, 63 | { 64 | "hidden": false, 65 | "name": "emailVisibility", 66 | "presentable": false, 67 | "required": false, 68 | "system": true, 69 | "type": "bool", 70 | }, 71 | { 72 | "hidden": false, 73 | "name": "verified", 74 | "presentable": false, 75 | "required": false, 76 | "system": true, 77 | "type": "bool", 78 | }, 79 | { 80 | "autogeneratePattern": "", 81 | "hidden": false, 82 | "max": 255, 83 | "min": 0, 84 | "name": "name", 85 | "pattern": "", 86 | "presentable": false, 87 | "primaryKey": false, 88 | "required": false, 89 | "system": false, 90 | "type": "text", 91 | }, 92 | { 93 | "hidden": false, 94 | "maxSelect": 1, 95 | "maxSize": 0, 96 | "mimeTypes": [ 97 | "image/jpeg", 98 | "image/png", 99 | "image/svg+xml", 100 | "image/gif", 101 | "image/webp", 102 | ], 103 | "name": "avatar", 104 | "presentable": false, 105 | "protected": false, 106 | "required": false, 107 | "system": false, 108 | "thumbs": null, 109 | "type": "file", 110 | }, 111 | { 112 | "hidden": false, 113 | "name": "created", 114 | "onCreate": true, 115 | "onUpdate": false, 116 | "presentable": false, 117 | "system": false, 118 | "type": "autodate", 119 | }, 120 | { 121 | "hidden": false, 122 | "name": "updated", 123 | "onCreate": true, 124 | "onUpdate": true, 125 | "presentable": false, 126 | "system": false, 127 | "type": "autodate", 128 | }, 129 | ], 130 | "fileToken": { 131 | "duration": 180, 132 | }, 133 | "id": "_pb_users_auth_", 134 | "indexes": [ 135 | "CREATE UNIQUE INDEX \`idx_tokenKey__pb_users_auth_\` ON \`users\` (\`tokenKey\`)", 136 | "CREATE UNIQUE INDEX \`idx_email__pb_users_auth_\` ON \`users\` (\`email\`) WHERE \`email\` != ''", 137 | ], 138 | "listRule": "id = @request.auth.id", 139 | "manageRule": null, 140 | "mfa": { 141 | "duration": 1800, 142 | "enabled": false, 143 | "rule": "", 144 | }, 145 | "name": "users", 146 | "oauth2": { 147 | "enabled": false, 148 | "mappedFields": { 149 | "avatarURL": "avatar", 150 | "id": "", 151 | "name": "name", 152 | "username": "", 153 | }, 154 | "providers": [], 155 | }, 156 | "passwordAuth": { 157 | "enabled": true, 158 | "identityFields": [ 159 | "email", 160 | ], 161 | }, 162 | "passwordResetToken": { 163 | "duration": 1800, 164 | }, 165 | "system": false, 166 | "type": "auth", 167 | "updateRule": "id = @request.auth.id", 168 | "verificationToken": { 169 | "duration": 259200, 170 | }, 171 | "viewRule": "id = @request.auth.id", 172 | } 173 | `; 174 | -------------------------------------------------------------------------------- /test/schema/generate-schema.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import type { ZodObject, ZodSchema } from "astro/zod"; 2 | import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; 3 | import { generateSchema } from "../../src/schema/generate-schema"; 4 | import { transformFileUrl } from "../../src/schema/transform-files"; 5 | import { checkE2eConnection } from "../_mocks/check-e2e-connection"; 6 | import { createLoaderOptions } from "../_mocks/create-loader-options"; 7 | 8 | describe("generateSchema", () => { 9 | const options = createLoaderOptions({ collectionName: "_superusers" }); 10 | 11 | beforeAll(async () => { 12 | await checkE2eConnection(); 13 | }); 14 | 15 | afterEach(() => { 16 | vi.resetAllMocks(); 17 | }); 18 | 19 | describe("load and parse schema", () => { 20 | it("should return basic schema if no schema is available", async () => { 21 | const result = (await generateSchema({ 22 | ...options, 23 | superuserCredentials: undefined, 24 | localSchema: undefined 25 | })) as ZodObject>>; 26 | 27 | expect(result.shape).toHaveProperty("id"); 28 | expect(result.shape).toHaveProperty("collectionId"); 29 | expect(result.shape).toHaveProperty("collectionName"); 30 | }); 31 | 32 | it("should return schema from remote if superuser credentials are provided", async () => { 33 | const result = (await generateSchema(options)) as ZodObject< 34 | Record> 35 | >; 36 | 37 | expect(Object.keys(result.shape)).toEqual([ 38 | "id", 39 | "collectionId", 40 | "collectionName", 41 | "password", 42 | "tokenKey", 43 | "email", 44 | "emailVisibility", 45 | "verified", 46 | "created", 47 | "updated" 48 | ]); 49 | }); 50 | 51 | it("should return schema from local file if path is provided", async () => { 52 | const result = (await generateSchema({ 53 | ...options, 54 | superuserCredentials: undefined, 55 | localSchema: "test/_mocks/superuser_schema.json" 56 | })) as ZodObject>>; 57 | 58 | expect(Object.keys(result.shape)).toEqual([ 59 | "id", 60 | "collectionId", 61 | "collectionName", 62 | "email", 63 | "emailVisibility", 64 | "verified", 65 | "created", 66 | "updated" 67 | ]); 68 | }); 69 | }); 70 | 71 | describe("custom id field", () => { 72 | it("should not log error if id field is present in schema", async () => { 73 | const consoleErrorSpy = vi.spyOn(console, "error"); 74 | 75 | await generateSchema({ 76 | ...options, 77 | idField: "email" 78 | }); 79 | 80 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 81 | }); 82 | 83 | it("should log error if id field is not present in schema", async () => { 84 | const consoleErrorSpy = vi.spyOn(console, "error"); 85 | 86 | await generateSchema({ 87 | ...options, 88 | idField: "nonexistent" 89 | }); 90 | 91 | expect(consoleErrorSpy).toHaveBeenCalledOnce(); 92 | }); 93 | 94 | it("should log error if id field is not of a valid type", async () => { 95 | const consoleErrorSpy = vi.spyOn(console, "error"); 96 | 97 | await generateSchema({ 98 | ...options, 99 | idField: "verified" 100 | }); 101 | 102 | expect(consoleErrorSpy).toHaveBeenCalledOnce(); 103 | }); 104 | }); 105 | 106 | describe("content fields", () => { 107 | it("should not log error if content field is present in schema", async () => { 108 | const consoleErrorSpy = vi.spyOn(console, "error"); 109 | 110 | await generateSchema({ 111 | ...options, 112 | contentFields: "email" 113 | }); 114 | 115 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 116 | }); 117 | 118 | it("should log error if content field is not present in schema", async () => { 119 | const consoleErrorSpy = vi.spyOn(console, "error"); 120 | 121 | await generateSchema({ 122 | ...options, 123 | contentFields: "nonexistent" 124 | }); 125 | 126 | expect(consoleErrorSpy).toHaveBeenCalledOnce(); 127 | }); 128 | 129 | it("should log error if one content field is not present in schema", async () => { 130 | const consoleErrorSpy = vi.spyOn(console, "error"); 131 | 132 | await generateSchema({ 133 | ...options, 134 | contentFields: ["email", "nonexistent"] 135 | }); 136 | 137 | expect(consoleErrorSpy).toHaveBeenCalledOnce(); 138 | }); 139 | }); 140 | 141 | describe("updated field", () => { 142 | it("should log error if updated field is not present in schema", async () => { 143 | const consoleErrorSpy = vi.spyOn(console, "error"); 144 | 145 | await generateSchema({ 146 | ...options, 147 | updatedField: "nonexistent" 148 | }); 149 | 150 | expect(consoleErrorSpy).toHaveBeenCalledOnce(); 151 | }); 152 | 153 | it("should log warning if updated field is not of type autodate", async () => { 154 | const consoleWarnSpy = vi.spyOn(console, "warn"); 155 | 156 | await generateSchema({ 157 | ...options, 158 | updatedField: "email" 159 | }); 160 | 161 | expect(consoleWarnSpy).toHaveBeenCalledOnce(); 162 | }); 163 | 164 | it("should log warning if updated field does not have onUpdate set", async () => { 165 | const consoleWarnSpy = vi.spyOn(console, "warn"); 166 | 167 | await generateSchema({ 168 | ...options, 169 | updatedField: "created" 170 | }); 171 | 172 | expect(consoleWarnSpy).toHaveBeenCalledOnce(); 173 | }); 174 | }); 175 | 176 | it("should return entry with transformed file fields", async () => { 177 | const testOptions = { ...options, collectionName: "users" }; 178 | const schema = await generateSchema(testOptions); 179 | 180 | const entry = { 181 | id: "123", 182 | collectionId: "456", 183 | collectionName: "users", 184 | password: "password", 185 | tokenKey: "tokenKey", 186 | email: "test@pawcode.de", 187 | created: new Date("2001-12-06"), 188 | updated: new Date(), 189 | avatar: "file.jpg" 190 | }; 191 | 192 | expect(schema.parse(entry)).toEqual({ 193 | ...entry, 194 | avatar: transformFileUrl( 195 | testOptions.url, 196 | testOptions.collectionName, 197 | entry.id, 198 | entry.avatar 199 | ) 200 | }); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/schema/get-remote-schema.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, beforeAll, describe, expect, it } from "vitest"; 2 | import { getRemoteSchema } from "../../src/schema/get-remote-schema"; 3 | import { checkE2eConnection } from "../_mocks/check-e2e-connection"; 4 | import { createLoaderOptions } from "../_mocks/create-loader-options"; 5 | 6 | describe("getRemoteSchema", () => { 7 | const options = createLoaderOptions(); 8 | 9 | beforeAll(async () => { 10 | await checkE2eConnection(); 11 | }); 12 | 13 | it("should return undefined if no superuser credentials are provided", async () => { 14 | const result = await getRemoteSchema({ 15 | ...options, 16 | superuserCredentials: undefined 17 | }); 18 | 19 | expect(result).toBeUndefined(); 20 | }); 21 | 22 | it("should return undefined if superuser token is invalid", async () => { 23 | const result = await getRemoteSchema({ 24 | ...options, 25 | superuserCredentials: { email: "invalid", password: "invalid" } 26 | }); 27 | 28 | expect(result).toBeUndefined(); 29 | }); 30 | 31 | it("should return undefined if fetch request fails", async () => { 32 | const result = await getRemoteSchema({ 33 | ...options, 34 | collectionName: "invalidCollection" 35 | }); 36 | 37 | expect(result).toBeUndefined(); 38 | }); 39 | 40 | it("should return schema if fetch request is successful", async () => { 41 | const result = await getRemoteSchema({ 42 | ...options, 43 | collectionName: "users" 44 | }); 45 | 46 | assert(result, "Schema is not defined."); 47 | 48 | // Delete unstable properties 49 | // @ts-expect-error - created is not typed 50 | delete result.created; 51 | // @ts-expect-error - updated is not typed 52 | delete result.updated; 53 | for (const field of result!.fields) { 54 | // @ts-expect-error - id is not typed 55 | delete field.id; 56 | } 57 | 58 | // Delete email templates 59 | // @ts-expect-error - authAlert is not typed 60 | delete result.authAlert; 61 | // @ts-expect-error - otp is not typed 62 | delete result.otp; 63 | // @ts-expect-error - verificationTemplate is not typed 64 | delete result.verificationTemplate; 65 | // @ts-expect-error - resetPasswordTemplate is not typed 66 | delete result.resetPasswordTemplate; 67 | // @ts-expect-error - confirmEmailChangeTemplate is not typed 68 | delete result.confirmEmailChangeTemplate; 69 | 70 | expect(result).toMatchSnapshot(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/schema/parse-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "astro/zod"; 2 | import { describe, expect, test } from "vitest"; 3 | import { parseSchema } from "../../src/schema/parse-schema"; 4 | import type { PocketBaseCollection } from "../../src/types/pocketbase-schema.type"; 5 | 6 | describe("parseSchema", () => { 7 | describe("number", () => { 8 | test("should parse number fields correctly", () => { 9 | const collection: PocketBaseCollection = { 10 | name: "numberCollection", 11 | type: "base", 12 | fields: [{ name: "age", type: "number", required: true, hidden: false }] 13 | }; 14 | 15 | const schema = parseSchema(collection, undefined, false, false); 16 | 17 | const valid = { 18 | age: 42 19 | }; 20 | 21 | expect(z.object(schema).parse(valid)).toEqual(valid); 22 | expect(() => z.object(schema).parse({ age: "42" })).toThrow(); 23 | expect(() => z.object(schema).parse({})).toThrow(); 24 | }); 25 | 26 | test("should parse optional number fields correctly", () => { 27 | const collection: PocketBaseCollection = { 28 | name: "numberCollection", 29 | type: "base", 30 | fields: [ 31 | { name: "age", type: "number", required: false, hidden: false } 32 | ] 33 | }; 34 | 35 | const schema = parseSchema(collection, undefined, false, false); 36 | 37 | const valid = { 38 | age: 42 39 | }; 40 | 41 | expect(z.object(schema).parse(valid)).toEqual(valid); 42 | expect(z.object(schema).parse({})).toEqual({}); 43 | }); 44 | 45 | test("should parse optional number fields correctly with improved types", () => { 46 | const collection: PocketBaseCollection = { 47 | name: "numberCollection", 48 | type: "base", 49 | fields: [ 50 | { name: "age", type: "number", required: false, hidden: false } 51 | ] 52 | }; 53 | 54 | const schema = parseSchema(collection, undefined, false, true); 55 | 56 | const valid = { 57 | age: 42 58 | }; 59 | 60 | expect(z.object(schema).parse(valid)).toEqual(valid); 61 | expect(() => z.object(schema).parse({})).toThrow(); 62 | }); 63 | }); 64 | 65 | describe("boolean", () => { 66 | test("should parse boolean fields correctly", () => { 67 | const collection: PocketBaseCollection = { 68 | name: "booleanCollection", 69 | type: "base", 70 | fields: [ 71 | { name: "isAdult", type: "bool", required: true, hidden: false } 72 | ] 73 | }; 74 | 75 | const schema = parseSchema(collection, undefined, false, false); 76 | 77 | const valid = { 78 | isAdult: true 79 | }; 80 | 81 | expect(z.object(schema).parse(valid)).toEqual(valid); 82 | expect(() => z.object(schema).parse({ isAdult: "true" })).toThrow(); 83 | expect(() => z.object(schema).parse({})).toThrow(); 84 | }); 85 | 86 | test("should parse optional boolean fields correctly", () => { 87 | const collection: PocketBaseCollection = { 88 | name: "booleanCollection", 89 | type: "base", 90 | fields: [ 91 | { name: "isAdult", type: "bool", required: false, hidden: false } 92 | ] 93 | }; 94 | 95 | const schema = parseSchema(collection, undefined, false, false); 96 | 97 | const valid = { 98 | isAdult: true 99 | }; 100 | 101 | expect(z.object(schema).parse(valid)).toEqual(valid); 102 | expect(z.object(schema).parse({})).toEqual({}); 103 | }); 104 | 105 | test("should parse optional boolean fields correctly with improved types", () => { 106 | const collection: PocketBaseCollection = { 107 | name: "booleanCollection", 108 | type: "base", 109 | fields: [ 110 | { name: "isAdult", type: "bool", required: false, hidden: false } 111 | ] 112 | }; 113 | 114 | const schema = parseSchema(collection, undefined, false, true); 115 | 116 | const valid = { 117 | isAdult: true 118 | }; 119 | 120 | expect(z.object(schema).parse(valid)).toEqual(valid); 121 | expect(() => z.object(schema).parse({})).toThrow(); 122 | }); 123 | }); 124 | 125 | describe("date", () => { 126 | test("should parse date fields correctly", () => { 127 | const collection: PocketBaseCollection = { 128 | name: "dateCollection", 129 | type: "base", 130 | fields: [ 131 | { name: "birthday", type: "date", required: true, hidden: false } 132 | ] 133 | }; 134 | 135 | const schema = parseSchema(collection, undefined, false, false); 136 | 137 | const valid = { 138 | birthday: new Date() 139 | }; 140 | 141 | expect(z.object(schema).parse(valid)).toEqual(valid); 142 | expect( 143 | z.object(schema).parse({ birthday: valid.birthday.toISOString() }) 144 | ).toEqual({ birthday: valid.birthday }); 145 | expect(() => z.object(schema).parse({})).toThrow(); 146 | }); 147 | 148 | test("should parse optional date fields correctly", () => { 149 | const collection: PocketBaseCollection = { 150 | name: "dateCollection", 151 | type: "base", 152 | fields: [ 153 | { name: "birthday", type: "date", required: false, hidden: false } 154 | ] 155 | }; 156 | 157 | const schema = parseSchema(collection, undefined, false, false); 158 | 159 | const valid = { 160 | birthday: new Date() 161 | }; 162 | 163 | expect(z.object(schema).parse(valid)).toEqual(valid); 164 | expect(z.object(schema).parse({})).toEqual({}); 165 | }); 166 | }); 167 | 168 | describe("autodate", () => { 169 | test("should parse autodate fields correctly", () => { 170 | const collection: PocketBaseCollection = { 171 | name: "dateCollection", 172 | type: "base", 173 | fields: [ 174 | { 175 | name: "birthday", 176 | type: "autodate", 177 | required: true, 178 | hidden: false 179 | } 180 | ] 181 | }; 182 | 183 | const schema = parseSchema(collection, undefined, false, false); 184 | 185 | const valid = { 186 | birthday: new Date() 187 | }; 188 | 189 | expect(z.object(schema).parse(valid)).toEqual(valid); 190 | expect( 191 | z.object(schema).parse({ birthday: valid.birthday.toISOString() }) 192 | ).toEqual({ birthday: valid.birthday }); 193 | expect(() => z.object(schema).parse({})).toThrow(); 194 | }); 195 | 196 | test("should parse optional autodate fields correctly", () => { 197 | const collection: PocketBaseCollection = { 198 | name: "dateCollection", 199 | type: "base", 200 | fields: [ 201 | { 202 | name: "birthday", 203 | type: "autodate", 204 | required: false, 205 | hidden: false 206 | } 207 | ] 208 | }; 209 | 210 | const schema = parseSchema(collection, undefined, false, false); 211 | 212 | const valid = { 213 | birthday: new Date() 214 | }; 215 | 216 | expect(z.object(schema).parse(valid)).toEqual(valid); 217 | expect(z.object(schema).parse({})).toEqual({}); 218 | }); 219 | 220 | test("should parse autodate fields with onCreate correctly", () => { 221 | const collection: PocketBaseCollection = { 222 | name: "dateCollection", 223 | type: "base", 224 | fields: [ 225 | { 226 | name: "birthday", 227 | type: "autodate", 228 | required: true, 229 | hidden: false, 230 | onCreate: true 231 | } 232 | ] 233 | }; 234 | 235 | const schema = parseSchema(collection, undefined, false, false); 236 | 237 | const valid = { 238 | birthday: new Date() 239 | }; 240 | 241 | expect(z.object(schema).parse(valid)).toEqual(valid); 242 | expect(() => z.object(schema).parse({})).toThrow(); 243 | }); 244 | }); 245 | 246 | describe("geoPoint", () => { 247 | test("should parse geoPoint fields correctly", () => { 248 | const collection: PocketBaseCollection = { 249 | name: "geoPointCollection", 250 | type: "base", 251 | fields: [ 252 | { 253 | name: "coordinates", 254 | type: "geoPoint", 255 | required: true, 256 | hidden: false 257 | } 258 | ] 259 | }; 260 | 261 | const schema = parseSchema(collection, undefined, false, false); 262 | 263 | const valid = { 264 | coordinates: { lon: 12.34, lat: 56.78 } 265 | }; 266 | 267 | expect(z.object(schema).parse(valid)).toEqual(valid); 268 | expect(() => 269 | z.object(schema).parse({ coordinates: { lon: true, lat: false } }) 270 | ).toThrow(); 271 | expect(() => z.object(schema).parse({ coordinates: {} })).toThrow(); 272 | expect(() => z.object(schema).parse({})).toThrow(); 273 | }); 274 | 275 | test("should parse optional geoPoint fields correctly", () => { 276 | const collection: PocketBaseCollection = { 277 | name: "geoPointCollection", 278 | type: "base", 279 | fields: [ 280 | { 281 | name: "coordinates", 282 | type: "geoPoint", 283 | required: false, 284 | hidden: false 285 | } 286 | ] 287 | }; 288 | 289 | const schema = parseSchema(collection, undefined, false, false); 290 | 291 | const valid = { 292 | coordinates: { lon: 12.34, lat: 56.78 } 293 | }; 294 | 295 | expect(z.object(schema).parse(valid)).toEqual(valid); 296 | expect(z.object(schema).parse({})).toEqual({}); 297 | }); 298 | }); 299 | 300 | describe("select", () => { 301 | test("should parse select fields correctly", () => { 302 | const collection: PocketBaseCollection = { 303 | name: "selectCollection", 304 | type: "base", 305 | fields: [ 306 | { 307 | name: "status", 308 | type: "select", 309 | required: true, 310 | hidden: false, 311 | values: ["active", "inactive"] 312 | } 313 | ] 314 | }; 315 | 316 | const schema = parseSchema(collection, undefined, false, false); 317 | 318 | const valid = { 319 | status: "active" 320 | }; 321 | 322 | expect(z.object(schema).parse(valid)).toEqual(valid); 323 | expect(() => z.object(schema).parse({ status: "invalid" })).toThrow(); 324 | expect(() => z.object(schema).parse({})).toThrow(); 325 | }); 326 | 327 | test("should throw an error if no values are defined", () => { 328 | const collection: PocketBaseCollection = { 329 | name: "selectCollection", 330 | type: "base", 331 | fields: [ 332 | { 333 | name: "status", 334 | type: "select", 335 | required: true, 336 | hidden: false 337 | } 338 | ] 339 | }; 340 | 341 | expect(() => parseSchema(collection, undefined, false, false)).toThrow(); 342 | }); 343 | 344 | test("should parse select fields with multiple values correctly", () => { 345 | const collection: PocketBaseCollection = { 346 | name: "selectCollection", 347 | type: "base", 348 | fields: [ 349 | { 350 | name: "status", 351 | type: "select", 352 | required: true, 353 | hidden: false, 354 | values: ["active", "inactive"], 355 | maxSelect: 2 356 | } 357 | ] 358 | }; 359 | 360 | const schema = parseSchema(collection, undefined, false, false); 361 | 362 | const valid = { 363 | status: ["active", "inactive"] 364 | }; 365 | 366 | expect(z.object(schema).parse(valid)).toEqual(valid); 367 | expect(() => 368 | z.object(schema).parse({ status: ["active", "invalid"] }) 369 | ).toThrow(); 370 | expect(() => z.object(schema).parse({})).toThrow(); 371 | }); 372 | 373 | test("should parse optional select fields correctly", () => { 374 | const collection: PocketBaseCollection = { 375 | name: "selectCollection", 376 | type: "base", 377 | fields: [ 378 | { 379 | name: "status", 380 | type: "select", 381 | required: false, 382 | hidden: false, 383 | values: ["active", "inactive"] 384 | } 385 | ] 386 | }; 387 | 388 | const schema = parseSchema(collection, undefined, false, false); 389 | 390 | const valid = { 391 | status: "active" 392 | }; 393 | 394 | expect(z.object(schema).parse(valid)).toEqual(valid); 395 | expect(() => z.object(schema).parse({ status: "invalid" })).toThrow(); 396 | expect(z.object(schema).parse({})).toEqual({}); 397 | }); 398 | }); 399 | 400 | describe("relation", () => { 401 | test("should parse relation fields correctly", () => { 402 | const collection: PocketBaseCollection = { 403 | name: "relationCollection", 404 | type: "base", 405 | fields: [ 406 | { 407 | name: "user", 408 | type: "relation", 409 | required: true, 410 | hidden: false 411 | } 412 | ] 413 | }; 414 | 415 | const schema = parseSchema(collection, undefined, false, false); 416 | 417 | const valid = { 418 | user: "123" 419 | }; 420 | 421 | expect(z.object(schema).parse(valid)).toEqual(valid); 422 | expect(() => z.object(schema).parse({ user: 123 })).toThrow(); 423 | expect(() => z.object(schema).parse({})).toThrow(); 424 | }); 425 | 426 | test("should parse relation fields with multiple values correctly", () => { 427 | const collection: PocketBaseCollection = { 428 | name: "relationCollection", 429 | type: "base", 430 | fields: [ 431 | { 432 | name: "user", 433 | type: "relation", 434 | required: true, 435 | hidden: false, 436 | maxSelect: 2 437 | } 438 | ] 439 | }; 440 | 441 | const schema = parseSchema(collection, undefined, false, false); 442 | 443 | const valid = { 444 | user: ["123", "456"] 445 | }; 446 | 447 | expect(z.object(schema).parse(valid)).toEqual(valid); 448 | expect(() => z.object(schema).parse({ user: ["123", 456] })).toThrow(); 449 | expect(() => z.object(schema).parse({})).toThrow(); 450 | }); 451 | 452 | test("should parse optional relation fields correctly", () => { 453 | const collection: PocketBaseCollection = { 454 | name: "relationCollection", 455 | type: "base", 456 | fields: [ 457 | { 458 | name: "user", 459 | type: "relation", 460 | required: false, 461 | hidden: false 462 | } 463 | ] 464 | }; 465 | 466 | const schema = parseSchema(collection, undefined, false, false); 467 | 468 | const valid = { 469 | user: "123" 470 | }; 471 | 472 | expect(z.object(schema).parse(valid)).toEqual(valid); 473 | expect(() => z.object(schema).parse({ user: 123 })).toThrow(); 474 | expect(z.object(schema).parse({})).toEqual({}); 475 | }); 476 | }); 477 | 478 | describe("file", () => { 479 | test("should parse file fields correctly", () => { 480 | const collection: PocketBaseCollection = { 481 | name: "fileCollection", 482 | type: "base", 483 | fields: [ 484 | { 485 | name: "avatar", 486 | type: "file", 487 | required: true, 488 | hidden: false 489 | } 490 | ] 491 | }; 492 | 493 | const schema = parseSchema(collection, undefined, false, false); 494 | 495 | const valid = { 496 | avatar: "https://example.com/avatar.jpg" 497 | }; 498 | 499 | expect(z.object(schema).parse(valid)).toEqual(valid); 500 | expect(() => z.object(schema).parse({ avatar: 123 })).toThrow(); 501 | expect(() => z.object(schema).parse({})).toThrow(); 502 | }); 503 | 504 | test("should parse file fields with multiple values correctly", () => { 505 | const collection: PocketBaseCollection = { 506 | name: "fileCollection", 507 | type: "base", 508 | fields: [ 509 | { 510 | name: "avatar", 511 | type: "file", 512 | required: true, 513 | hidden: false, 514 | maxSelect: 2 515 | } 516 | ] 517 | }; 518 | 519 | const schema = parseSchema(collection, undefined, false, false); 520 | 521 | const valid = { 522 | avatar: [ 523 | "https://example.com/avatar.jpg", 524 | "https://example.com/avatar2.jpg" 525 | ] 526 | }; 527 | 528 | expect(z.object(schema).parse(valid)).toEqual(valid); 529 | expect(() => 530 | z 531 | .object(schema) 532 | .parse({ avatar: ["https://example.com/avatar.jpg", 123] }) 533 | ).toThrow(); 534 | expect(() => z.object(schema).parse({})).toThrow(); 535 | }); 536 | 537 | test("should parse optional file fields correctly", () => { 538 | const collection: PocketBaseCollection = { 539 | name: "fileCollection", 540 | type: "base", 541 | fields: [ 542 | { 543 | name: "avatar", 544 | type: "file", 545 | required: false, 546 | hidden: false 547 | } 548 | ] 549 | }; 550 | 551 | const schema = parseSchema(collection, undefined, false, false); 552 | 553 | const valid = { 554 | avatar: "https://example.com/avatar.jpg" 555 | }; 556 | 557 | expect(z.object(schema).parse(valid)).toEqual(valid); 558 | expect(() => z.object(schema).parse({ avatar: 123 })).toThrow(); 559 | expect(z.object(schema).parse({})).toEqual({}); 560 | }); 561 | }); 562 | 563 | describe("json", () => { 564 | test("should parse json fields with custom schema correctly", () => { 565 | const collection: PocketBaseCollection = { 566 | name: "jsonCollection", 567 | type: "base", 568 | fields: [ 569 | { 570 | name: "settings", 571 | type: "json", 572 | required: true, 573 | hidden: false 574 | } 575 | ] 576 | }; 577 | 578 | const customSchemas = { 579 | settings: z.object({ 580 | theme: z.string(), 581 | darkMode: z.boolean() 582 | }) 583 | }; 584 | 585 | const schema = parseSchema(collection, customSchemas, false, false); 586 | 587 | const valid = { 588 | settings: { 589 | theme: "light", 590 | darkMode: true 591 | } 592 | }; 593 | 594 | expect(z.object(schema).parse(valid)).toEqual(valid); 595 | expect(() => 596 | z.object(schema).parse({ settings: { theme: 123 } }) 597 | ).toThrow(); 598 | expect(() => z.object(schema).parse({ settings: "invalid" })).toThrow(); 599 | expect(() => z.object(schema).parse({})).toThrow(); 600 | }); 601 | 602 | test("should parse json fields without custom schema correctly", () => { 603 | const collection: PocketBaseCollection = { 604 | name: "jsonCollection", 605 | type: "base", 606 | fields: [ 607 | { 608 | name: "settings", 609 | type: "json", 610 | required: true, 611 | hidden: false 612 | } 613 | ] 614 | }; 615 | 616 | const schema = parseSchema(collection, undefined, false, false); 617 | 618 | const valid = { 619 | settings: { 620 | theme: "light", 621 | darkMode: true 622 | } 623 | }; 624 | const valid2 = { 625 | settings: 1234 626 | }; 627 | 628 | expect(z.object(schema).parse(valid)).toEqual(valid); 629 | expect(z.object(schema).parse(valid2)).toEqual(valid2); 630 | }); 631 | 632 | test("should parse optional json fields correctly", () => { 633 | const collection: PocketBaseCollection = { 634 | name: "jsonCollection", 635 | type: "base", 636 | fields: [ 637 | { 638 | name: "settings", 639 | type: "json", 640 | required: false, 641 | hidden: false 642 | } 643 | ] 644 | }; 645 | 646 | const schema = parseSchema(collection, undefined, false, false); 647 | 648 | const valid = { 649 | settings: { 650 | theme: "light", 651 | darkMode: true 652 | } 653 | }; 654 | 655 | expect(z.object(schema).parse(valid)).toEqual(valid); 656 | expect(z.object(schema).parse({})).toEqual({}); 657 | }); 658 | }); 659 | 660 | describe("text", () => { 661 | test("should parse text fields correctly", () => { 662 | const collection: PocketBaseCollection = { 663 | name: "stringCollection", 664 | type: "base", 665 | fields: [{ name: "name", type: "text", required: true, hidden: false }] 666 | }; 667 | 668 | const schema = parseSchema(collection, undefined, false, false); 669 | 670 | const valid = { 671 | name: "John Doe" 672 | }; 673 | 674 | expect(z.object(schema).parse(valid)).toEqual(valid); 675 | expect(() => z.object(schema).parse({ name: 123 })).toThrow(); 676 | expect(() => z.object(schema).parse({})).toThrow(); 677 | }); 678 | 679 | test("should parse optional text fields correctly", () => { 680 | const collection: PocketBaseCollection = { 681 | name: "stringCollection", 682 | type: "base", 683 | fields: [{ name: "name", type: "text", required: false, hidden: false }] 684 | }; 685 | 686 | const schema = parseSchema(collection, undefined, false, false); 687 | 688 | const valid = { 689 | name: "John Doe" 690 | }; 691 | 692 | expect(z.object(schema).parse(valid)).toEqual(valid); 693 | expect(z.object(schema).parse({})).toEqual({}); 694 | }); 695 | }); 696 | }); 697 | -------------------------------------------------------------------------------- /test/schema/read-local-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import path from "path"; 3 | import { beforeEach, describe, expect, test, vi } from "vitest"; 4 | import { readLocalSchema } from "../../src/schema/read-local-schema"; 5 | 6 | vi.mock("fs/promises"); 7 | 8 | describe("readLocalSchema", () => { 9 | const localSchemaPath = "test/pb_schema.json"; 10 | const collectionName = "users"; 11 | const mockSchema = [ 12 | { 13 | name: "users", 14 | fields: [] 15 | }, 16 | { 17 | name: "messages", 18 | fields: [] 19 | } 20 | ]; 21 | 22 | beforeEach(() => { 23 | vi.clearAllMocks(); 24 | }); 25 | 26 | test("should return the schema for the specified collection", async () => { 27 | vi.spyOn(path, "join").mockReturnValue(localSchemaPath); 28 | vi.spyOn(fs, "readFile").mockResolvedValue(JSON.stringify(mockSchema)); 29 | 30 | const result = await readLocalSchema(localSchemaPath, collectionName); 31 | expect(result).toEqual(mockSchema[0]); 32 | }); 33 | 34 | test("should return undefined if the collection is not found", async () => { 35 | vi.spyOn(path, "join").mockReturnValue(localSchemaPath); 36 | vi.spyOn(fs, "readFile").mockResolvedValue(JSON.stringify(mockSchema)); 37 | 38 | const result = await readLocalSchema(localSchemaPath, "nonexistent"); 39 | expect(result).toBeUndefined(); 40 | }); 41 | 42 | test("should return undefined if the schema file is invalid", async () => { 43 | vi.spyOn(path, "join").mockReturnValue(localSchemaPath); 44 | vi.spyOn(fs, "readFile").mockResolvedValue("invalid json"); 45 | 46 | const result = await readLocalSchema(localSchemaPath, collectionName); 47 | expect(result).toBeUndefined(); 48 | }); 49 | 50 | test("should return undefined if the database is not an array", async () => { 51 | vi.spyOn(path, "join").mockReturnValue(localSchemaPath); 52 | vi.spyOn(fs, "readFile").mockResolvedValue(JSON.stringify({})); 53 | 54 | const result = await readLocalSchema(localSchemaPath, collectionName); 55 | expect(result).toBeUndefined(); 56 | }); 57 | 58 | test("should handle file read errors gracefully", async () => { 59 | vi.spyOn(path, "join").mockReturnValue(localSchemaPath); 60 | vi.spyOn(fs, "readFile").mockRejectedValue(new Error("File read error")); 61 | 62 | const result = await readLocalSchema(localSchemaPath, collectionName); 63 | expect(result).toBeUndefined(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/schema/transform-files.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { transformFiles } from "../../src/schema/transform-files"; 3 | import type { PocketBaseSchemaEntry } from "../../src/types/pocketbase-schema.type"; 4 | import { createPocketbaseEntry } from "../_mocks/create-pocketbase-entry"; 5 | 6 | describe("transformFiles", () => { 7 | const baseUrl = "http://localhost:8090"; 8 | const fileFields = [ 9 | { name: "avatar", maxSelect: 1 }, 10 | { name: "documents", maxSelect: 5 } 11 | ] as Array; 12 | 13 | test("should transform single file name to URL", () => { 14 | const entry = createPocketbaseEntry({ 15 | avatar: "avatar.png" 16 | }); 17 | 18 | const result = transformFiles(baseUrl, fileFields, entry); 19 | expect(result.avatar).toBe( 20 | `${baseUrl}/api/files/${entry.collectionName}/${entry.id}/avatar.png` 21 | ); 22 | }); 23 | 24 | test("should transform multiple file names to URLs", () => { 25 | const documents = ["doc1.pdf", "doc2.pdf"]; 26 | const entry = createPocketbaseEntry({ 27 | documents 28 | }); 29 | 30 | const result = transformFiles(baseUrl, fileFields, entry); 31 | expect(result.documents).toEqual([ 32 | `${baseUrl}/api/files/${entry.collectionName}/${entry.id}/${documents[0]}`, 33 | `${baseUrl}/api/files/${entry.collectionName}/${entry.id}/${documents[1]}` 34 | ]); 35 | }); 36 | 37 | test("should skip transformation if single file name is not present", () => { 38 | const entry = createPocketbaseEntry(); 39 | 40 | const result = transformFiles(baseUrl, fileFields, entry); 41 | expect(result.avatar).toBeUndefined(); 42 | }); 43 | 44 | test("should skip transformation if multiple file names are not present", () => { 45 | const entry = createPocketbaseEntry({ 46 | documents: [] 47 | }); 48 | 49 | const result = transformFiles(baseUrl, fileFields, entry); 50 | expect(result.documents).toEqual([]); 51 | }); 52 | 53 | test("should handle entries with no file fields", () => { 54 | const entry = createPocketbaseEntry(); 55 | 56 | const result = transformFiles(baseUrl, [], entry); 57 | expect(result).toEqual(entry); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/utils/get-superuser-token.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, it } from "vitest"; 2 | import { getSuperuserToken } from "../../src/utils/get-superuser-token"; 3 | import { checkE2eConnection } from "../_mocks/check-e2e-connection"; 4 | import { createLoaderContext } from "../_mocks/create-loader-context"; 5 | import { createLoaderOptions } from "../_mocks/create-loader-options"; 6 | 7 | describe("getSuperuserToken", () => { 8 | const options = createLoaderOptions(); 9 | 10 | beforeAll(async () => { 11 | await checkE2eConnection(); 12 | }); 13 | 14 | it("should return undefined if superuser credentials are invalid", async () => { 15 | const result = await getSuperuserToken(options.url, { 16 | email: "invalid", 17 | password: "invalid" 18 | }); 19 | expect(result).toBeUndefined(); 20 | }); 21 | 22 | it("should use integration logger if provided", async () => { 23 | const { logger } = createLoaderContext(); 24 | 25 | await getSuperuserToken( 26 | options.url, 27 | { email: "invalid", password: "invalid" }, 28 | logger 29 | ); 30 | 31 | expect(logger.error).toHaveBeenCalled(); 32 | }); 33 | 34 | it("should return token if fetch request is successful", async () => { 35 | const result = await getSuperuserToken( 36 | options.url, 37 | options.superuserCredentials! 38 | ); 39 | expect(result).toBeDefined(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/utils/is-realtime-data.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, test } from "vitest"; 2 | import { isRealtimeData } from "../../src/utils/is-realtime-data"; 3 | import { createPocketbaseEntry } from "../_mocks/create-pocketbase-entry"; 4 | 5 | describe("isRealtimeData", () => { 6 | test("should return true for valid create action", () => { 7 | const data = { 8 | action: "create", 9 | record: createPocketbaseEntry() 10 | }; 11 | 12 | assert(isRealtimeData(data)); 13 | }); 14 | 15 | test("should return true for valid update action", () => { 16 | const data = { 17 | action: "update", 18 | record: createPocketbaseEntry() 19 | }; 20 | 21 | assert(isRealtimeData(data)); 22 | }); 23 | 24 | test("should return true for valid delete action", () => { 25 | const data = { 26 | action: "delete", 27 | record: createPocketbaseEntry() 28 | }; 29 | 30 | assert(isRealtimeData(data)); 31 | }); 32 | 33 | test("should return false for invalid action", () => { 34 | const data = { 35 | action: "invalid", 36 | record: createPocketbaseEntry() 37 | }; 38 | 39 | assert(!isRealtimeData(data)); 40 | }); 41 | 42 | test("should return false for missing record", () => { 43 | const data = { 44 | action: "create" 45 | }; 46 | 47 | assert(!isRealtimeData(data)); 48 | }); 49 | 50 | test("should return false for missing action", () => { 51 | const data = { 52 | record: createPocketbaseEntry() 53 | }; 54 | 55 | assert(!isRealtimeData(data)); 56 | }); 57 | 58 | test("should return false for invalid record structure", () => { 59 | const record = createPocketbaseEntry(); 60 | // @ts-expect-error - collectionId is required 61 | delete record.collectionId; 62 | 63 | const data = { 64 | action: "create", 65 | record: record 66 | }; 67 | 68 | assert(!isRealtimeData(data)); 69 | }); 70 | 71 | test("should return false for completely invalid data", () => { 72 | const data = { 73 | foo: "bar" 74 | }; 75 | 76 | assert(!isRealtimeData(data)); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/utils/should-refresh.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { shouldRefresh } from "../../src/utils/should-refresh"; 3 | 4 | describe("shouldRefresh", () => { 5 | test('should return "refresh" if context is undefined', () => { 6 | const context = undefined; 7 | const collectionName = "testCollection"; 8 | 9 | expect(shouldRefresh(context, collectionName)).toBe("refresh"); 10 | }); 11 | 12 | test("should return \"refresh\" if context source is not 'astro-integration-pocketbase'", () => { 13 | const context = { 14 | source: "other-source", 15 | collection: "testCollection" 16 | }; 17 | const collectionName = "testCollection"; 18 | 19 | expect(shouldRefresh(context, collectionName)).toBe("refresh"); 20 | }); 21 | 22 | test('should return "refresh" if context collection is missing', () => { 23 | const context = { 24 | source: "astro-integration-pocketbase" 25 | }; 26 | const collectionName = "testCollection"; 27 | 28 | expect(shouldRefresh(context, collectionName)).toBe("refresh"); 29 | }); 30 | 31 | test('should return "refresh" if context collection is a string and matches collectionName', () => { 32 | const context = { 33 | source: "astro-integration-pocketbase", 34 | collection: "testCollection" 35 | }; 36 | const collectionName = "testCollection"; 37 | 38 | expect(shouldRefresh(context, collectionName)).toBe("refresh"); 39 | }); 40 | 41 | test('should return "skip" if context collection is a string and does not match collectionName', () => { 42 | const context = { 43 | source: "astro-integration-pocketbase", 44 | collection: "otherCollection" 45 | }; 46 | const collectionName = "testCollection"; 47 | 48 | expect(shouldRefresh(context, collectionName)).toBe("skip"); 49 | }); 50 | 51 | test('should return "refresh" if context collection is an array and includes collectionName', () => { 52 | const context = { 53 | source: "astro-integration-pocketbase", 54 | collection: ["testCollection", "otherCollection"] 55 | }; 56 | const collectionName = "testCollection"; 57 | 58 | expect(shouldRefresh(context, collectionName)).toBe("refresh"); 59 | }); 60 | 61 | test('should return "skip" if context collection is an array and does not include collectionName', () => { 62 | const context = { 63 | source: "astro-integration-pocketbase", 64 | collection: ["otherCollection"] 65 | }; 66 | const collectionName = "testCollection"; 67 | 68 | expect(shouldRefresh(context, collectionName)).toBe("skip"); 69 | }); 70 | 71 | test('should return "refresh" if context collection is an unexpected type', () => { 72 | const context = { 73 | source: "astro-integration-pocketbase", 74 | collection: 123 75 | }; 76 | const collectionName = "testCollection"; 77 | 78 | expect(shouldRefresh(context, collectionName)).toBe("refresh"); 79 | }); 80 | 81 | test('should return "force" if context force is true', () => { 82 | const context = { 83 | source: "astro-integration-pocketbase", 84 | force: true 85 | }; 86 | const collectionName = "testCollection"; 87 | 88 | expect(shouldRefresh(context, collectionName)).toBe("force"); 89 | }); 90 | 91 | test('should return "refresh" if context force is false', () => { 92 | const context = { 93 | source: "astro-integration-pocketbase", 94 | force: false 95 | }; 96 | const collectionName = "testCollection"; 97 | 98 | expect(shouldRefresh(context, collectionName)).toBe("refresh"); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/utils/slugify.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, test } from "vitest"; 2 | import { slugify } from "../../src/utils/slugify"; 3 | 4 | describe("slugify", () => { 5 | test("should convert a simple string to a slug", () => { 6 | const input = "Hello World"; 7 | const expected = "hello-world"; 8 | 9 | assert.equal(slugify(input), expected); 10 | }); 11 | 12 | test("should handle strings with special characters", () => { 13 | const input = "Hello, World!"; 14 | const expected = "hello-world"; 15 | 16 | assert.equal(slugify(input), expected); 17 | }); 18 | 19 | test("should replace umlauts correctly", () => { 20 | const input = "äöüß"; 21 | const expected = "aeoeuess"; 22 | 23 | assert.equal(slugify(input), expected); 24 | }); 25 | 26 | test("should handle multiple spaces", () => { 27 | const input = "Hello World"; 28 | const expected = "hello-world"; 29 | 30 | assert.equal(slugify(input), expected); 31 | }); 32 | 33 | test("should trim dashes from start and end", () => { 34 | const input = "-Hello World-"; 35 | const expected = "hello-world"; 36 | 37 | assert.equal(slugify(input), expected); 38 | }); 39 | 40 | test("should replace multiple dashes with a single dash", () => { 41 | const input = "Hello---World"; 42 | const expected = "hello-world"; 43 | 44 | assert.equal(slugify(input), expected); 45 | }); 46 | 47 | test("should handle empty strings", () => { 48 | const input = ""; 49 | const expected = ""; 50 | 51 | assert.equal(slugify(input), expected); 52 | }); 53 | 54 | test("should handle strings with only special characters", () => { 55 | const input = "!@#$%^&*()"; 56 | const expected = ""; 57 | 58 | assert.equal(slugify(input), expected); 59 | }); 60 | 61 | test("should handle strings with numbers", () => { 62 | const input = "Hello World 123"; 63 | const expected = "hello-world-123"; 64 | 65 | assert.equal(slugify(input), expected); 66 | }); 67 | 68 | test("should handle strings with mixed case", () => { 69 | const input = "HeLLo WoRLd"; 70 | const expected = "hello-world"; 71 | 72 | assert.equal(slugify(input), expected); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"], 5 | "compilerOptions": { 6 | "jsx": "preserve" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["test/**/*.spec.ts", "test/**/*.e2e-spec.ts"], 6 | silent: true, 7 | coverage: { 8 | include: ["src/**/*.ts"], 9 | exclude: ["src/types/**/*.ts", "src/index.ts", "src/pocketbase-loader.ts"] 10 | } 11 | } 12 | }); 13 | --------------------------------------------------------------------------------