├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── eslint.config.js ├── logo.png ├── logo.svg ├── package-lock.json ├── package.json ├── release.config.cjs ├── src ├── app.d.ts ├── app.html ├── lib │ ├── common.ts │ ├── components │ │ ├── Record.svelte │ │ ├── Record.svelte.test.ts │ │ ├── Records.svelte │ │ └── Records.svelte.test.ts │ ├── index.ts │ ├── stores.svelte.test.ts │ └── stores.svelte.ts └── routes │ ├── +page.svelte │ └── +page.ts ├── static └── favicon.png ├── svelte.config.js ├── tests ├── pb_schema.json ├── vitest-pocketbase-setup.d.ts └── vitest-pocketbase-setup.ts ├── tsconfig.json ├── vite.config.ts └── vitest-setup-client.ts /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | commit-message: 6 | prefix: build 7 | include: scope 8 | schedule: 9 | interval: "weekly" 10 | groups: 11 | non-major: 12 | update-types: 13 | - "minor" 14 | - "patch" 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | with: 18 | persist-credentials: false 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: '20' 24 | cache: 'npm' 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Download and extract PocketBase 30 | run: | 31 | wget https://github.com/pocketbase/pocketbase/releases/download/v0.25.8/pocketbase_0.25.8_linux_amd64.zip 32 | unzip pocketbase_0.25.8_linux_amd64.zip 33 | 34 | - name: Start PocketBase in background 35 | run: | 36 | ./pocketbase serve & 37 | # Wait for PocketBase to start 38 | sleep 2 39 | ./pocketbase superuser upsert pocketbase@svelte.com pocketbase 40 | 41 | - name: Run tests 42 | run: npm test 43 | 44 | - name: Semantic Release 45 | uses: cycjimmy/semantic-release-action@v4 46 | with: 47 | branches: main 48 | env: 49 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 50 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | .netlify 7 | .wrangler 8 | /.svelte-kit 9 | /build 10 | /dist 11 | 12 | # OS 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # Env 17 | .env 18 | .env.* 19 | !.env.example 20 | !.env.test 21 | 22 | # Vite 23 | vite.config.js.timestamp-* 24 | vite.config.ts.timestamp-* 25 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Max Shipit 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 | # SveltePocket 2 | 3 | ![The Svelte and Pocketbase logo shaking hands](https://github.com/brennerm/sveltepocket/blob/main/logo.png?raw=true) 4 | 5 | Svelte 5-ready stores and components to bring data from any Pocketbase instance into your Svelte application (even with realtime updates 🤫). 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install @shipitdev/sveltepocket 11 | ``` 12 | 13 | ## Setup 14 | 15 | Call the init method to pass your Pocketbase client SDK instance. This will be used by all stores and components so make sure it's authenticated according to your needs. 16 | 17 | ```svelte 18 | 25 | ``` 26 | 27 | ## Stores 28 | 29 | The stores provide an low level API to query data from your Pocketbase instance and are the best choice when you need to pre/postprocess the data instead of just rendering it onto the page. 30 | 31 | ### auth 32 | 33 | A readable store that holds the current user's authentication status and user record. 34 | 35 | ```svelte 36 | 39 | 40 | {#if $auth.isAuthenticated} 41 |

Welcome, {$auth.user.email}!

42 | {/if} 43 | ``` 44 | 45 | ### createRecordStore 46 | 47 | Creates a readable store that fetches a single record identified by id or filter from a Pocketbase collection. 48 | 49 | ```svelte 50 | 55 | 56 | {#if $post.record} 57 |

{$post.record.title}

58 | {/if} 59 | ``` 60 | 61 | ### createRecordsStore 62 | 63 | Creates a readable store that fetches multiple records from a Pocketbase collection. 64 | 65 | ```svelte 66 | 71 | 72 | {#if $posts.records} 73 | 78 | {/if} 79 | ``` 80 | 81 | ## Components 82 | 83 | If you only care about rendering the queried data, these components are the way to go. 84 | 85 | You can pass snippets that will be rendered during different states, e.g. when loading the data, when data is not found or when an error occurs. 86 | 87 | ### \ 88 | 89 | A component that fetches a single record either by ID or filter from a Pocketbase collection and renders it. 90 | 91 | ```svelte 92 | 93 | 94 | {#snippet render(post)} 95 |

{post.title}

96 | by {post.expand.author.name} 97 | {/snippet} 98 |
99 | 100 | 101 | 102 | {#snippet render(post)} 103 |

{post.title}

104 | {/snippet} 105 |
106 | ``` 107 | 108 | ### \ 109 | 110 | A component that fetches multiple records from a Pocketbase collection and renders them. 111 | 112 | ```svelte 113 | 114 | {#snippet render(posts)} 115 |
    116 | {#each posts as post} 117 |
  • {post.title} by {post.expand.author.name}
  • 118 | {/each} 119 |
120 | {/snippet} 121 |
122 | ``` 123 | 124 | ## Realtime Updates 125 | 126 | All stores and components support the `realtime` parameter. If set to `true`, SveltePocket will setup a subscription to PocketBase's realtime updates and keep the data up to date. 127 | 128 | Combined with Svelte's reactivity, your app will rerender automatically when the data changes. 129 | 130 | ```svelte 131 | 132 | 133 | {#snippet render(posts)} 134 |
    135 | {#each posts as post} 136 |
  • {post.title}
  • 137 | {/each} 138 |
139 | {/snippet} 140 |
141 | ``` 142 | 143 | ## Type Safety 144 | 145 | All stores and components take an optional record type, e.g. generated by [pocketbase-typegen](https://github.com/patmood/pocketbase-typegen). 146 | This gives you full type safety on the returned records. 147 | 148 | ### Store 149 | 150 | ```svelte 151 | 157 | ``` 158 | 159 | ### Components 160 | 161 | ```svelte 162 | 163 | {#snippet render(records: PostRecord[])} 164 | ... 165 | {/snippet} 166 | 167 | ``` 168 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier'; 2 | import js from '@eslint/js'; 3 | import { includeIgnoreFile } from '@eslint/compat'; 4 | import svelte from 'eslint-plugin-svelte'; 5 | import globals from 'globals'; 6 | import { fileURLToPath } from 'node:url'; 7 | import ts from 'typescript-eslint'; 8 | const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); 9 | 10 | export default ts.config( 11 | includeIgnoreFile(gitignorePath), 12 | js.configs.recommended, 13 | ...ts.configs.recommended, 14 | ...svelte.configs['flat/recommended'], 15 | prettier, 16 | ...svelte.configs['flat/prettier'], 17 | { 18 | languageOptions: { 19 | globals: { 20 | ...globals.browser, 21 | ...globals.node 22 | } 23 | } 24 | }, 25 | { 26 | files: ['**/*.svelte'], 27 | 28 | languageOptions: { 29 | parserOptions: { 30 | parser: ts.parser 31 | } 32 | } 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brennerm/sveltepocket/156a7938dfba30454f34f922769a1ece876082b4/logo.png -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 483 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shipitdev/sveltepocket", 3 | "description": "Svelte stores and components to query data from PocketBase", 4 | "version": "0.0.3", 5 | "license": "MIT", 6 | "keywords": [ 7 | "svelte", 8 | "pocketbase" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/brennerm/sveltepocket.git" 13 | }, 14 | "scripts": { 15 | "dev": "vite dev", 16 | "build": "vite build && npm run prepack", 17 | "preview": "vite preview", 18 | "prepare": "svelte-kit sync || echo ''", 19 | "prepack": "svelte-kit sync && svelte-package && publint", 20 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 21 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 22 | "format": "prettier --write .", 23 | "lint": "prettier --check . && eslint .", 24 | "test:unit": "vitest --no-file-parallelism", 25 | "test": "npm run test:unit -- --run" 26 | }, 27 | "files": [ 28 | "dist", 29 | "!dist/**/*.test.*", 30 | "!dist/**/*.spec.*" 31 | ], 32 | "sideEffects": [ 33 | "**/*.css" 34 | ], 35 | "svelte": "./dist/index.js", 36 | "types": "./dist/index.d.ts", 37 | "type": "module", 38 | "exports": { 39 | ".": { 40 | "types": "./dist/index.d.ts", 41 | "svelte": "./dist/index.js" 42 | } 43 | }, 44 | "peerDependencies": { 45 | "svelte": "^5.0.0" 46 | }, 47 | "devDependencies": { 48 | "@eslint/compat": "^1.2.5", 49 | "@eslint/js": "^9.18.0", 50 | "@sveltejs/adapter-auto": "^4.0.0", 51 | "@sveltejs/kit": "^2.16.0", 52 | "@sveltejs/package": "^2.0.0", 53 | "@sveltejs/vite-plugin-svelte": "^5.0.0", 54 | "@testing-library/jest-dom": "^6.6.3", 55 | "@testing-library/svelte": "^5.2.7", 56 | "eslint": "^9.18.0", 57 | "eslint-config-prettier": "^10.0.1", 58 | "eslint-plugin-svelte": "^3.0.2", 59 | "globals": "^16.0.0", 60 | "jsdom": "^26.0.0", 61 | "pocketbase": "^0.26.0", 62 | "prettier": "^3.4.2", 63 | "prettier-plugin-svelte": "^3.3.3", 64 | "publint": "^0.3.2", 65 | "svelte": "^5.0.0", 66 | "svelte-check": "^4.0.0", 67 | "typescript": "^5.0.0", 68 | "typescript-eslint": "^8.20.0", 69 | "vite": "^6.0.0", 70 | "vitest": "^3.0.0" 71 | } 72 | } -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('semantic-release').GlobalConfig} 3 | */ 4 | module.exports = { 5 | branches: ["main"], 6 | plugins: [ 7 | [ 8 | "@semantic-release/commit-analyzer", 9 | { 10 | "preset": "angular", 11 | "releaseRules": [ 12 | { "type": "build", "release": "patch" } 13 | ], 14 | } 15 | ], 16 | "@semantic-release/release-notes-generator", 17 | "@semantic-release/github", 18 | "@semantic-release/npm" 19 | ], 20 | prefix: "angular" 21 | }; -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export { }; 14 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/common.ts: -------------------------------------------------------------------------------- 1 | import type { ClientResponseError } from 'pocketbase'; 2 | import type { Snippet } from 'svelte'; 3 | 4 | export type CommonParams = { 5 | /** the collection to fetch records from */ 6 | collection: string; 7 | /** the filter to apply, e.g. "status = 'published'" */ 8 | filter?: string; 9 | /** fields to expand, e.g "author,comments" */ 10 | expand?: string; 11 | /** sort expression, e.g. "-created" */ 12 | sort?: string; 13 | /** whether to subscribe to realtime updates */ 14 | realtime?: boolean; 15 | /** the snippet to show while loading the data from Pocketbase */ 16 | loading?: Snippet; 17 | /** the snippet to show when an error occurs */ 18 | error?: Snippet<[error: ClientResponseError]>; 19 | /** the snippet to show when no record is found or the collection does not exist */ 20 | notFound?: Snippet; 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/components/Record.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 48 | 49 | {JSON.stringify($store)} 50 | 51 | {#if $store.isLoading} 52 | {@render loading?.()} 53 | {:else if $store.error} 54 | {@render error?.($store.error)} 55 | {:else if $store.record === null} 56 | {@render notFound?.()} 57 | {:else if $store.record} 58 | {@render render?.($store.record)} 59 | {/if} 60 | -------------------------------------------------------------------------------- /src/lib/components/Record.svelte.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import { describe, expect } from 'vitest'; 3 | import Record from './Record.svelte'; 4 | import { AUTHORS, pbtest, POSTS } from '../../../tests/vitest-pocketbase-setup.js'; 5 | import { createRawSnippet } from 'svelte'; 6 | 7 | describe.sequential('Record.svelte', () => { 8 | pbtest('loading', async () => { 9 | const { baseElement } = render(Record, { 10 | collection: 'posts', id: '000000000000000', 11 | loading: createRawSnippet(() => ({ 12 | render: () => 'Loading...' 13 | })) 14 | }); 15 | expect(baseElement).toHaveTextContent('Loading...'); 16 | }); 17 | 18 | pbtest('notFound', async () => { 19 | const { baseElement } = render(Record, { 20 | collection: 'posts', id: 'nonexistent', 21 | notFound: createRawSnippet(() => ({ 22 | render: () => 'Not Found' 23 | })) 24 | }); 25 | 26 | await screen.findByText('Not Found'); 27 | expect(baseElement).toHaveTextContent('Not Found'); 28 | }); 29 | 30 | pbtest('error', async ({ pb }) => { 31 | pb.authStore.clear(); 32 | 33 | const { baseElement } = render(Record, { 34 | collection: 'posts', id: '000000000000000', 35 | error: createRawSnippet((error) => ({ 36 | render: () => `Error ${error().response.status}` 37 | })) 38 | }); 39 | 40 | await screen.findByText('Error 403'); 41 | expect(baseElement).toHaveTextContent('Error 403'); 42 | }); 43 | 44 | pbtest('id', async () => { 45 | const { baseElement } = render(Record, { 46 | collection: 'posts', id: '000000000000000', 47 | render: createRawSnippet((record) => ({ 48 | render: () => `

${record().title}

` 49 | })), 50 | loading: createRawSnippet(() => ({ 51 | render: () => 'Loading...' 52 | })) 53 | }); 54 | 55 | await screen.findByRole('heading'); 56 | expect(baseElement).toHaveTextContent(POSTS[0].title); 57 | expect(baseElement).not.toHaveTextContent('Loading...'); 58 | }); 59 | 60 | pbtest('filter', async () => { 61 | const { baseElement } = render(Record, { 62 | collection: 'posts', filter: 'published = false', 63 | render: createRawSnippet((record) => ({ 64 | render: () => `

${record().title}

` 65 | })), 66 | }); 67 | 68 | await screen.findByRole('heading'); 69 | expect(baseElement).toHaveTextContent(POSTS[1].title); 70 | }); 71 | 72 | pbtest('expand', async () => { 73 | const { baseElement } = render(Record, { 74 | collection: 'posts', id: '000000000000000', expand: 'author', 75 | render: createRawSnippet((record) => ({ 76 | render: () => `

${record().expand.author.name}

` 77 | })), 78 | }); 79 | 80 | await screen.findByRole('heading'); 81 | expect(baseElement).toHaveTextContent(AUTHORS[0].name); 82 | }); 83 | }); -------------------------------------------------------------------------------- /src/lib/components/Records.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 55 | 56 | {#if $store.isLoading} 57 | {@render loading?.()} 58 | {:else if $store.error} 59 | {@render error?.($store.error)} 60 | {:else if $store.records === null} 61 | {@render notFound?.()} 62 | {:else if $store.records} 63 | {@render render?.($store.records, $store.totalPages, $store.totalItems)} 64 | {/if} 65 | -------------------------------------------------------------------------------- /src/lib/components/Records.svelte.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import { describe, expect } from 'vitest'; 3 | import Records from './Records.svelte'; 4 | import { AUTHORS, pbtest, POSTS } from '../../../tests/vitest-pocketbase-setup.js'; 5 | import { createRawSnippet } from 'svelte'; 6 | 7 | describe.sequential('Records.svelte', () => { 8 | pbtest('loading', async () => { 9 | const { baseElement } = render(Records, { 10 | collection: 'posts', 11 | loading: createRawSnippet(() => ({ 12 | render: () => 'Loading...' 13 | })) 14 | }); 15 | expect(baseElement).toHaveTextContent('Loading...'); 16 | }); 17 | 18 | pbtest('notFound', async () => { 19 | const { baseElement } = render(Records, { 20 | collection: 'nonexistent', 21 | notFound: createRawSnippet(() => ({ 22 | render: () => 'Not Found' 23 | })) 24 | }); 25 | 26 | await screen.findByText('Not Found'); 27 | expect(baseElement).toHaveTextContent('Not Found'); 28 | }); 29 | 30 | pbtest('error', async ({ pb }) => { 31 | pb.authStore.clear(); 32 | 33 | const { baseElement } = render(Records, { 34 | collection: 'posts', 35 | error: createRawSnippet((error) => ({ 36 | render: () => `Error ${error().response.status}` 37 | })) 38 | }); 39 | 40 | await screen.findByText('Error 403'); 41 | expect(baseElement).toHaveTextContent('Error 403'); 42 | }); 43 | 44 | pbtest('render', async () => { 45 | const { baseElement } = render(Records, { 46 | collection: 'posts', 47 | render: createRawSnippet((records) => ({ 48 | render: () => `

${records().map((r) => r.title).join(',')}

` 49 | })), 50 | loading: createRawSnippet(() => ({ 51 | render: () => 'Loading...' 52 | })) 53 | }); 54 | 55 | await screen.findByRole('heading'); 56 | expect(baseElement).toHaveTextContent(POSTS.map((r) => r.title).join(',')); 57 | expect(baseElement).not.toHaveTextContent('Loading...'); 58 | }); 59 | 60 | pbtest('sort', async () => { 61 | const { baseElement } = render(Records, { 62 | collection: 'posts', sort: '-id', 63 | render: createRawSnippet((records) => ({ 64 | render: () => `

${records().map((r) => r.title).join(',')}

` 65 | })), 66 | }); 67 | 68 | await screen.findByRole('heading'); 69 | expect(baseElement).toHaveTextContent(POSTS.sort((a, b) => b.id - a.id).map((r) => r.title).join(',')); 70 | }); 71 | 72 | pbtest('filter', async () => { 73 | const { baseElement } = render(Records, { 74 | collection: 'posts', filter: 'published = false', 75 | render: createRawSnippet((records) => ({ 76 | render: () => `

${records().map((r) => r.title).join(',')}

` 77 | })), 78 | }); 79 | 80 | await screen.findByRole('heading'); 81 | expect(baseElement).toHaveTextContent(POSTS.filter((r) => !r.published).map((r) => r.title).join(',')); 82 | }); 83 | 84 | pbtest('expand', async () => { 85 | const { baseElement } = render(Records, { 86 | collection: 'posts', id: '000000000000000', expand: 'author', 87 | render: createRawSnippet((records) => ({ 88 | render: () => `

${records().map((r) => r.expand.author.name).join(',')}

` 89 | })), 90 | }); 91 | 92 | await screen.findByRole('heading'); 93 | expect(baseElement).toHaveTextContent(POSTS.map((p) => AUTHORS.find((a) => a.id === p.author).name).join(',')); 94 | }); 95 | }); -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { auth, init, createRecordStore, createRecordsStore } from "./stores.svelte.js"; 2 | import Record from "./components/Record.svelte"; 3 | import Records from "./components/Records.svelte"; 4 | 5 | export { auth, init, createRecordStore, createRecordsStore, Record, Records }; 6 | -------------------------------------------------------------------------------- /src/lib/stores.svelte.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, vi } from 'vitest'; 2 | import { createRecordStore, createRecordsStore, auth } from './stores.svelte.js'; 3 | import { get } from 'svelte/store'; 4 | import { AUTHORS, pbtest, POSTS, USERS } from '../../tests/vitest-pocketbase-setup.js'; 5 | 6 | describe.sequential('record store', () => { 7 | pbtest('non-existing collection', async () => { 8 | const store = createRecordStore('non-existing-collection', { id: 'id' }); 9 | store.subscribe(() => { }) 10 | 11 | await vi.waitUntil(() => get(store).record !== undefined) 12 | expect(get(store).record).toBe(null); 13 | }); 14 | 15 | pbtest('non-existing id', async () => { 16 | const store = createRecordStore('posts', { id: 'non-existing-id' }); 17 | store.subscribe(() => { }) 18 | 19 | await vi.waitUntil(() => get(store).record !== undefined) 20 | expect(get(store).record).toBe(null); 21 | }) 22 | 23 | pbtest('id', async () => { 24 | const store = createRecordStore('posts', { id: '000000000000000' }); 25 | store.subscribe(() => { }) 26 | 27 | await vi.waitUntil(() => get(store).record !== undefined) 28 | const record = get(store).record; 29 | expect(record.id).toBe(POSTS[0].id); 30 | expect(record.title).toBe(POSTS[0].title); 31 | expect(record.published).toBe(POSTS[0].published); 32 | }) 33 | 34 | pbtest('filter', async () => { 35 | const store = createRecordStore('posts', { filter: 'published = false' }); 36 | store.subscribe(() => { }) 37 | 38 | await vi.waitUntil(() => get(store).record !== undefined) 39 | const record = get(store).record; 40 | expect(record.id).toBe(POSTS[1].id); 41 | expect(record.title).toBe(POSTS[1].title); 42 | expect(record.published).toBe(POSTS[1].published); 43 | }) 44 | 45 | pbtest('expand', async () => { 46 | const store = createRecordStore('posts', { id: '000000000000000', expand: 'author' }); 47 | store.subscribe(() => { }) 48 | 49 | await vi.waitUntil(() => get(store).record !== undefined) 50 | const record = get(store).record; 51 | expect(record.id).toBe(POSTS[0].id); 52 | expect(record.title).toBe(POSTS[0].title); 53 | expect(record.published).toBe(POSTS[0].published); 54 | expect(record.expand.author.id).toBe(AUTHORS[0].id); 55 | expect(record.expand.author.name).toBe(AUTHORS[0].name); 56 | }) 57 | 58 | pbtest('realtime', async ({ pb, skip }) => { 59 | skip('Realtime test is skipped because we need to find a way to mock the EventSource object'); 60 | 61 | const store = createRecordStore('posts', { id: '000000000000000', realtime: true }); 62 | store.subscribe(() => { }) 63 | 64 | await vi.waitUntil(() => get(store).record !== undefined) 65 | const record = get(store).record; 66 | expect(record.id).toBe(POSTS[0].id); 67 | expect(record.title).toBe(POSTS[0].title); 68 | expect(record.published).toBe(POSTS[0].published); 69 | 70 | await pb.collection('posts').update(POSTS[0].id, { title: 'New Title' }); 71 | 72 | await vi.waitUntil(() => get(store).record.title === 'New Title'); 73 | }) 74 | }); 75 | 76 | describe.sequential('records store', () => { 77 | pbtest('non-existing collection', async () => { 78 | const store = createRecordsStore('non-existing-collection'); 79 | store.subscribe(() => { }) 80 | await vi.waitUntil(() => get(store).records !== undefined) 81 | 82 | expect(get(store).records).toBe(null); 83 | }); 84 | 85 | pbtest('existing collection', async () => { 86 | const store = createRecordsStore('posts'); 87 | store.subscribe(() => { }) 88 | await vi.waitUntil(() => get(store).records !== undefined) 89 | 90 | const records = get(store).records; 91 | expect(records.length).toBe(POSTS.length); 92 | records.forEach((record, i) => { 93 | expect(record.id).toBe(POSTS[i].id); 94 | expect(record.title).toBe(POSTS[i].title); 95 | expect(record.published).toBe(POSTS[i].published); 96 | }) 97 | }) 98 | 99 | pbtest('sort', async () => { 100 | const store = createRecordsStore('posts', { sort: '-id' }); 101 | store.subscribe(() => { }) 102 | await vi.waitUntil(() => get(store).records !== undefined) 103 | 104 | const records = get(store).records; 105 | expect(records.length).toBe(POSTS.length); 106 | records.forEach((record, i) => { 107 | expect(record.id).toBe(POSTS[9 - i].id); 108 | expect(record.title).toBe(POSTS[9 - i].title); 109 | expect(record.published).toBe(POSTS[9 - i].published); 110 | }) 111 | }) 112 | 113 | pbtest('expand', async () => { 114 | const store = createRecordsStore('posts', { expand: 'author' }); 115 | store.subscribe(() => { }) 116 | await vi.waitUntil(() => get(store).records !== undefined) 117 | 118 | const records = get(store).records; 119 | expect(records.length).toBe(POSTS.length); 120 | records.forEach((record, i) => { 121 | expect(record.id).toBe(POSTS[i].id); 122 | expect(record.title).toBe(POSTS[i].title); 123 | expect(record.published).toBe(POSTS[i].published); 124 | expect(record.expand.author.name).not.toBe(undefined); 125 | }) 126 | }) 127 | 128 | pbtest('filter', async () => { 129 | const store = createRecordsStore('posts', { filter: 'published = false' }); 130 | store.subscribe(() => { }) 131 | await vi.waitUntil(() => get(store).records !== undefined) 132 | 133 | const privatePosts = POSTS.filter(post => !post.published); 134 | 135 | const records = get(store).records; 136 | expect(records.length).toBe(privatePosts.length); 137 | records.forEach((record, i) => { 138 | expect(record.id).toBe(privatePosts[i].id); 139 | expect(record.title).toBe(privatePosts[i].title); 140 | expect(record.published).toBe(privatePosts[i].published); 141 | }) 142 | }) 143 | }); 144 | 145 | describe.sequential('auth', () => { 146 | pbtest('superuser', async () => { 147 | auth.subscribe(() => { }); 148 | 149 | // we already should be authenticated as a superuser 150 | expect(get(auth).isAuthenticated).toBe(true); 151 | expect(get(auth).isSuperuser).toBe(true); 152 | }); 153 | 154 | pbtest('login', async ({ pb }) => { 155 | auth.subscribe(() => { }); 156 | 157 | pb.authStore.clear(); 158 | expect(get(auth).isAuthenticated).toBe(false); 159 | 160 | await pb.collection('users').authWithPassword(USERS[0].email, USERS[0].password); 161 | 162 | vi.waitUntil(() => get(auth).isAuthenticated); 163 | expect(get(auth).isSuperuser).toBe(false); 164 | expect(get(auth).user.email).toBe(USERS[0].email); 165 | }); 166 | 167 | pbtest('logout', async ({ pb }) => { 168 | auth.subscribe(() => { }); 169 | 170 | expect(get(auth).isAuthenticated).toBe(true); 171 | pb.authStore.clear(); 172 | expect(get(auth).isAuthenticated).toBe(false); 173 | }); 174 | }); -------------------------------------------------------------------------------- /src/lib/stores.svelte.ts: -------------------------------------------------------------------------------- 1 | import Pocketbase from 'pocketbase'; 2 | import { readable } from 'svelte/store'; 3 | import type { 4 | RecordModel, 5 | RecordListOptions, 6 | RecordSubscribeOptions, 7 | ClientResponseError, 8 | AuthRecord 9 | } from 'pocketbase'; 10 | import { BROWSER } from 'esm-env' 11 | 12 | export const PB = () => { 13 | return _pb; 14 | }; 15 | 16 | let _pb = $state(null); 17 | 18 | export const init = (value: Pocketbase) => { 19 | _pb = value; 20 | }; 21 | 22 | /** a Svelte store that holds the current user's authentication status and user record */ 23 | export const auth = readable<{ 24 | isAuthenticated: boolean | undefined; 25 | isSuperuser: boolean | undefined; 26 | user: AuthRecord | undefined; 27 | }>({ isAuthenticated: undefined, isSuperuser: undefined, user: undefined }, (set) => { 28 | const unsubscribe = _pb?.authStore.onChange((_, record) => { 29 | set({ 30 | isAuthenticated: _pb?.authStore.isValid, 31 | isSuperuser: _pb?.authStore.isSuperuser, 32 | user: record 33 | }); 34 | }, true); 35 | 36 | return unsubscribe; 37 | }); 38 | 39 | type RecordStoreOptions = { 40 | id?: string; 41 | filter?: string; 42 | expand?: string; 43 | realtime?: boolean; 44 | }; 45 | 46 | /** create a Svelte store that fetches a single record identified by id or filter from a Pocketbase collection */ 47 | export const createRecordStore = ( 48 | collection: string, 49 | { id, filter, expand, realtime }: RecordStoreOptions = {} 50 | ) => { 51 | return readable<{ 52 | record: T | undefined | null; 53 | isLoading: boolean; 54 | error: ClientResponseError | null; 55 | }>( 56 | { 57 | record: undefined, 58 | isLoading: false, 59 | error: null 60 | }, 61 | (_, update) => { 62 | let unsubscribe = () => { }; 63 | 64 | const subscribe = (recordId: string) => { 65 | if (!BROWSER || !realtime) return; 66 | 67 | _pb 68 | ?.collection(collection) 69 | .subscribe(recordId, ({ action, record }) => { 70 | switch (action) { 71 | case 'update': 72 | update((data) => ({ ...data, record: record as unknown as T })); 73 | break; 74 | case 'delete': 75 | update((data) => ({ ...data, record: null })); 76 | break; 77 | } 78 | }, { expand }) 79 | .then((value) => { 80 | unsubscribe = value; 81 | }); 82 | }; 83 | 84 | const promise = id 85 | ? _pb?.collection(collection).getOne(id, { expand }) 86 | : filter 87 | ? _pb?.collection(collection).getFirstListItem(filter, { expand }) 88 | : null; 89 | 90 | if (promise) { 91 | update((data) => ({ ...data, isLoading: true })); 92 | promise 93 | .then((value) => { 94 | update((data) => ({ ...data, record: value as T })); 95 | subscribe(value.id); 96 | }) 97 | .catch((reason) => { 98 | if (reason.response.status === 404) { 99 | update((data) => ({ ...data, record: null })); 100 | } else { 101 | update((data) => ({ ...data, error: reason })); 102 | } 103 | }) 104 | .finally(() => { 105 | update((data) => ({ ...data, isLoading: false })); 106 | }); 107 | } 108 | 109 | return () => { 110 | unsubscribe(); 111 | unsubscribe = () => { }; 112 | }; 113 | } 114 | ); 115 | }; 116 | 117 | type RecordsStoreOptions = { 118 | sort?: string; 119 | expand?: string; 120 | filter?: string; 121 | realtime?: boolean; 122 | listOptions?: RecordListOptions; 123 | subscribeOptions?: RecordSubscribeOptions; 124 | }; 125 | 126 | /** create a Svelte store that fetches multiple records from a Pocketbase collection */ 127 | export const createRecordsStore = ( 128 | collection: string, 129 | { sort, expand, filter, realtime, listOptions, subscribeOptions }: RecordsStoreOptions = {} 130 | ) => { 131 | return readable<{ 132 | records: T[] | undefined | null; 133 | totalPages: number; 134 | totalItems: number; 135 | isLoading: boolean; 136 | error: ClientResponseError | null; 137 | }>( 138 | { 139 | records: undefined, 140 | totalPages: 0, 141 | totalItems: 0, 142 | isLoading: false, 143 | error: null 144 | }, 145 | (_, update) => { 146 | let unsubscribe = () => { }; 147 | 148 | const subscribe = () => { 149 | if (!BROWSER || !realtime) return; 150 | 151 | _pb 152 | ?.collection(collection) 153 | .subscribe( 154 | '*', 155 | ({ action, record }) => { 156 | update((data) => { 157 | if (!data.records) return data; 158 | 159 | switch (action) { 160 | case 'create': 161 | return { 162 | ...data, 163 | records: [record as T, ...data.records] 164 | }; 165 | case 'update': 166 | return { 167 | ...data, 168 | records: data.records.map( 169 | (item) => (item.id === record.id ? record : item) as T 170 | ) 171 | }; 172 | case 'delete': 173 | return { 174 | ...data, 175 | records: data.records.filter((item) => item.id !== record.id) 176 | }; 177 | default: 178 | return data; 179 | } 180 | }); 181 | }, 182 | { ...subscribeOptions, expand, filter } 183 | ) 184 | .then((value) => { 185 | unsubscribe = value; 186 | }); 187 | }; 188 | 189 | update((data) => ({ ...data, isLoading: true })); 190 | _pb 191 | ?.collection(collection) 192 | .getList(undefined, undefined, { ...listOptions, sort, expand, filter }) 193 | .then((value) => { 194 | update((data) => ({ 195 | ...data, 196 | records: value.items as T[], 197 | totalPages: value.totalPages, 198 | totalItems: value.totalItems 199 | })); 200 | subscribe(); 201 | }) 202 | .catch((reason: ClientResponseError) => { 203 | if (reason.response.status === 404) { 204 | update((data) => ({ ...data, records: null })); 205 | } else { 206 | update((data) => ({ ...data, error: reason })); 207 | } 208 | }) 209 | .finally(() => { 210 | update((data) => ({ ...data, isLoading: false })); 211 | }); 212 | 213 | return () => { 214 | unsubscribe(); 215 | unsubscribe = () => { }; 216 | }; 217 | } 218 | ); 219 | }; 220 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |

Welcome to the PocketSvelte library

21 | 22 | {#if $auth?.isAuthenticated === false} 23 |
{ 25 | event.preventDefault(); 26 | await pb.collection('users').authWithPassword(form.email, form.password); 27 | }} 28 | > 29 | 30 | 31 | 32 | 33 | 34 |
35 | {:else} 36 | 37 | {#snippet loading()} 38 |

Loading...

39 | {/snippet} 40 | {#snippet notFound()} 41 |

Not found

42 | {/snippet} 43 | {#snippet render(records: PostRecord[])} 44 |
    45 | {#each records as record} 46 |
  • 47 | {record.title} 48 |
  • 49 | {/each} 50 |
51 | {/snippet} 52 | {#snippet error(msg)} 53 | {JSON.stringify(msg)} 54 | {/snippet} 55 |
56 | 57 | 58 | {#snippet loading()} 59 |

Loading...

60 | {/snippet} 61 | {#snippet render(record: PostRecord)} 62 |

{record.title}

63 | {/snippet} 64 | {#snippet notFound()} 65 |

Not found

66 | {/snippet} 67 |
68 | 69 | 70 | {#snippet loading()} 71 |

Loading...

72 | {/snippet} 73 | {#snippet render(record: PostRecord)} 74 |

{record.title}

75 | {/snippet} 76 | {#snippet notFound()} 77 |

Not found

78 | {/snippet} 79 |
80 | {/if} 81 | -------------------------------------------------------------------------------- /src/routes/+page.ts: -------------------------------------------------------------------------------- 1 | export const ssr = false -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brennerm/sveltepocket/156a7938dfba30454f34f922769a1ece876082b4/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://svelte.dev/docs/kit/integrations 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /tests/pb_schema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "pbc_526341563", 4 | "listRule": null, 5 | "viewRule": null, 6 | "createRule": null, 7 | "updateRule": null, 8 | "deleteRule": null, 9 | "name": "authors", 10 | "type": "base", 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 | "autogeneratePattern": "", 28 | "hidden": false, 29 | "id": "text1579384326", 30 | "max": 0, 31 | "min": 0, 32 | "name": "name", 33 | "pattern": "", 34 | "presentable": false, 35 | "primaryKey": false, 36 | "required": true, 37 | "system": false, 38 | "type": "text" 39 | }, 40 | { 41 | "hidden": false, 42 | "id": "autodate2990389176", 43 | "name": "created", 44 | "onCreate": true, 45 | "onUpdate": false, 46 | "presentable": false, 47 | "system": false, 48 | "type": "autodate" 49 | }, 50 | { 51 | "hidden": false, 52 | "id": "autodate3332085495", 53 | "name": "updated", 54 | "onCreate": true, 55 | "onUpdate": true, 56 | "presentable": false, 57 | "system": false, 58 | "type": "autodate" 59 | } 60 | ], 61 | "indexes": [], 62 | "system": false 63 | }, 64 | { 65 | "id": "pbc_1125843985", 66 | "listRule": null, 67 | "viewRule": null, 68 | "createRule": null, 69 | "updateRule": null, 70 | "deleteRule": null, 71 | "name": "posts", 72 | "type": "base", 73 | "fields": [ 74 | { 75 | "autogeneratePattern": "[a-z0-9]{15}", 76 | "hidden": false, 77 | "id": "text3208210256", 78 | "max": 15, 79 | "min": 15, 80 | "name": "id", 81 | "pattern": "^[a-z0-9]+$", 82 | "presentable": false, 83 | "primaryKey": true, 84 | "required": true, 85 | "system": true, 86 | "type": "text" 87 | }, 88 | { 89 | "autogeneratePattern": "", 90 | "hidden": false, 91 | "id": "text724990059", 92 | "max": 0, 93 | "min": 0, 94 | "name": "title", 95 | "pattern": "", 96 | "presentable": false, 97 | "primaryKey": false, 98 | "required": true, 99 | "system": false, 100 | "type": "text" 101 | }, 102 | { 103 | "hidden": false, 104 | "id": "bool1748787223", 105 | "name": "published", 106 | "presentable": false, 107 | "required": false, 108 | "system": false, 109 | "type": "bool" 110 | }, 111 | { 112 | "cascadeDelete": false, 113 | "collectionId": "pbc_526341563", 114 | "hidden": false, 115 | "id": "relation3182418120", 116 | "maxSelect": 1, 117 | "minSelect": 0, 118 | "name": "author", 119 | "presentable": false, 120 | "required": true, 121 | "system": false, 122 | "type": "relation" 123 | }, 124 | { 125 | "hidden": false, 126 | "id": "autodate2990389176", 127 | "name": "created", 128 | "onCreate": true, 129 | "onUpdate": false, 130 | "presentable": false, 131 | "system": false, 132 | "type": "autodate" 133 | }, 134 | { 135 | "hidden": false, 136 | "id": "autodate3332085495", 137 | "name": "updated", 138 | "onCreate": true, 139 | "onUpdate": true, 140 | "presentable": false, 141 | "system": false, 142 | "type": "autodate" 143 | } 144 | ], 145 | "indexes": [], 146 | "system": false 147 | } 148 | ] -------------------------------------------------------------------------------- /tests/vitest-pocketbase-setup.d.ts: -------------------------------------------------------------------------------- 1 | import Pocketbase from 'pocketbase'; 2 | export declare const AUTHORS: { 3 | id: string; 4 | name: string; 5 | }[]; 6 | export declare const POSTS: { 7 | id: string; 8 | title: string; 9 | author: string; 10 | published: boolean; 11 | }[]; 12 | export declare const USERS: { 13 | id: any; 14 | email: string; 15 | password: string; 16 | passwordConfirm: string; 17 | }[]; 18 | export declare const pbtest: import("vitest").TestAPI<{ 19 | pb: Pocketbase; 20 | }>; 21 | -------------------------------------------------------------------------------- /tests/vitest-pocketbase-setup.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, beforeEach, test } from 'vitest'; 2 | import Pocketbase from 'pocketbase'; 3 | import schema from './pb_schema.json' 4 | import { init } from '$lib/stores.svelte.js'; 5 | import { randomBytes } from 'crypto'; 6 | 7 | export const AUTHORS = [ 8 | { id: '000000000000000', name: 'John Doe' }, 9 | { id: '000000000000001', name: 'Jane Doe' }, 10 | { id: '000000000000002', name: 'Alice' }, 11 | { id: '000000000000003', name: 'Bob' }, 12 | ] 13 | 14 | export const POSTS = [ 15 | { id: '000000000000000', title: 'Post 1', author: '000000000000000', published: true }, 16 | { id: '000000000000001', title: 'Post 2', author: '000000000000000', published: false }, 17 | { id: '000000000000002', title: 'Post 3', author: '000000000000000', published: true }, 18 | { id: '000000000000003', title: 'Post 4', author: '000000000000001', published: false }, 19 | { id: '000000000000004', title: 'Post 5', author: '000000000000001', published: true }, 20 | { id: '000000000000005', title: 'Post 6', author: '000000000000002', published: false }, 21 | { id: '000000000000006', title: 'Post 7', author: '000000000000003', published: true }, 22 | { id: '000000000000007', title: 'Post 8', author: '000000000000003', published: false }, 23 | { id: '000000000000008', title: 'Post 9', author: '000000000000003', published: true }, 24 | { id: '000000000000009', title: 'Post 10', author: '000000000000003', published: false }, 25 | ] 26 | 27 | export const USERS = [ 28 | { id: randomBytes(20).toString('hex').substring(0, 15), email: `${randomBytes(5).toString('hex')}@bar.com`, password: 'password', passwordConfirm: 'password' }, 29 | ] 30 | 31 | interface PbTestFixtures { 32 | pb: Pocketbase 33 | } 34 | 35 | const pb = new Pocketbase('http://localhost:8090'); 36 | 37 | export const pbtest = test.extend({ 38 | pb: pb 39 | }) 40 | 41 | beforeAll(async ({ id }) => { 42 | await pb.collection('_superusers').authWithPassword('pocketbase@svelte.com', 'pocketbase'); 43 | await pb.collections.import(schema) 44 | 45 | for (const user of USERS) { 46 | await pb.collection('users').create(user) 47 | } 48 | 49 | init(pb); 50 | }); 51 | 52 | beforeEach(async () => { 53 | await pb.collection('_superusers').authWithPassword('pocketbase@svelte.com', 'pocketbase'); 54 | await pb.collections.truncate('posts') 55 | await pb.collections.truncate('authors') 56 | 57 | for (const author of AUTHORS) { 58 | await pb.collection('authors').create(author) 59 | } 60 | 61 | for (const post of POSTS) { 62 | await pb.collection('posts').create(post) 63 | } 64 | }); 65 | 66 | afterAll(async () => { 67 | await pb.collection('_superusers').authWithPassword('pocketbase@svelte.com', 'pocketbase'); 68 | await pb.collection('users').delete(USERS[0].id) 69 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "module": "NodeNext", 13 | "moduleResolution": "NodeNext" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { svelteTesting } from '@testing-library/svelte/vite'; 2 | import { sveltekit } from '@sveltejs/kit/vite'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [sveltekit()], 7 | 8 | test: { 9 | workspace: [ 10 | { 11 | extends: './vite.config.ts', 12 | plugins: [svelteTesting()], 13 | 14 | test: { 15 | name: 'client', 16 | environment: 'jsdom', 17 | clearMocks: true, 18 | include: ['src/**/*.svelte.{test,spec}.{js,ts}'], 19 | exclude: ['src/lib/server/**'], 20 | setupFiles: ['./vitest-setup-client.ts'], 21 | // poolOptions: { 22 | // threads: { 23 | // singleThread: true 24 | // } 25 | // } 26 | } 27 | }, 28 | // { 29 | // extends: './vite.config.ts', 30 | 31 | // test: { 32 | // name: 'server', 33 | // environment: 'node', 34 | // include: ['src/**/*.{test,spec}.{js,ts}'], 35 | // exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] 36 | // } 37 | // } 38 | ], 39 | setupFiles: ['./tests/vitest-pocketbase-setup.ts'], 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /vitest-setup-client.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | import { vi } from 'vitest'; 3 | 4 | // required for svelte5 + jsdom as jsdom does not support matchMedia 5 | Object.defineProperty(window, 'matchMedia', { 6 | writable: true, 7 | enumerable: true, 8 | value: vi.fn().mockImplementation((query) => ({ 9 | matches: false, 10 | media: query, 11 | onchange: null, 12 | addEventListener: vi.fn(), 13 | removeEventListener: vi.fn(), 14 | dispatchEvent: vi.fn() 15 | })) 16 | }); 17 | 18 | // add more mocks here if you need them 19 | --------------------------------------------------------------------------------