This commit is contained in:
2025-10-05 13:50:18 +02:00
commit 41f083d69e
71 changed files with 17398 additions and 0 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

11
.changeset/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["playground"]
}

19
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"dependencyDashboard": true,
"lockFileMaintenance": {
"enabled": true
},
"postUpdateOptions": ["pnpmDedupe"],
"packageRules": [
{
"groupName": "all dependencies",
"groupSlug": "all",
"matchPackagePatterns": ["*"],
"schedule": ["before 4am on Monday"],
"rangeStrategy": "bump"
}
],
"ignoreDeps": ["node"]
}

25
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Build
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PNPM
run: corepack enable && pnpm -v
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 18.19.0
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm --filter astro-pocketbase build

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
TODO.md

22
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#93a1fb",
"activityBar.background": "#93a1fb",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#d70824",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#e7e7e799",
"sash.hoverBorder": "#93a1fb",
"statusBar.background": "#6276f9",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#93a1fb",
"statusBarItem.remoteBackground": "#6276f9",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#6276f9",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#6276f999",
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.color": "#6276f9"
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Gregory Bouteiller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

9
README.md Normal file
View File

@@ -0,0 +1,9 @@
# zod-pocketbase
Zod tooling for your Pocketbase instance.
To see how to get started, check out the [docs](https://zod-pocketbase.vercel.app)
## Licensing
[MIT Licensed](./LICENSE). Made with ❤️ by [Gregory Bouteiller](https://github.com/gbouteiller).

21
doc/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

4
doc/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
doc/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

13
doc/CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# doc
## 0.1.0
### Minor Changes
- remove "cache" option for "helpersFrom" and replace it with "fetch" to allow a more generic custom fetch and remove "@11ty/eleventy-fetch" dependency
## 0.0.2
### Patch Changes
- update packages

55
doc/README.md Normal file
View File

@@ -0,0 +1,55 @@
# Starlight Starter Kit: Basics
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)
```
npm create astro@latest -- --template starlight
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro + Starlight project, you'll see the following folders and files:
```
.
├── public/
├── src/
│ ├── assets/
│ ├── content/
│ │ ├── docs/
│ │ └── config.ts
│ └── env.d.ts
├── astro.config.mjs
├── package.json
└── tsconfig.json
```
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
Static assets, like favicons, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Check out [Starlights docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).

29
doc/astro.config.mjs Normal file
View File

@@ -0,0 +1,29 @@
// @ts-check
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
// https://astro.build/config
export default defineConfig({
integrations: [
starlight({
title: "Zod PocketBase",
social: {
github: "https://github.com/gbouteiller/zod-pocketbase",
},
sidebar: [
{
label: "Start here",
autogenerate: { directory: "start-here" },
},
{
label: "Guides",
autogenerate: { directory: "guides" },
},
{
label: "Reference",
autogenerate: { directory: "reference" },
},
],
}),
],
});

19
doc/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "doc",
"type": "module",
"version": "0.1.0",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/starlight": "^0.30.3",
"astro": "^5.1.1",
"sharp": "^0.33.5",
"typescript": "^5.7.2"
}
}

5217
doc/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
doc/public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>

After

Width:  |  Height:  |  Size: 696 B

BIN
doc/src/assets/cli.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
doc/src/assets/houston.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,6 @@
import { defineCollection } from 'astro:content';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ schema: docsSchema() }),
};

View File

@@ -0,0 +1,71 @@
---
title: CLI
description: The CLI generates Zod schemas for your PocketBase instance.
---
import { Tabs, TabItem } from '@astrojs/starlight/components';
import { Image } from 'astro:assets';
import cliImage from '../../../assets/cli.png';
The CLI generates Zod schemas for your PocketBase instance.
## Usage
To generate your schemas, run the following from your project directory:
<Tabs>
<TabItem label="npm">
```shell
npx zod-pocketbase
```
</TabItem>
<TabItem label="pnpm">
```shell
pnpm zod-pocketbase
```
</TabItem>
<TabItem label="yarn">
```shell
yarn zod-pocketbase
```
</TabItem>
</Tabs>
<Image src={cliImage} alt="Zod PocketBase CLI." />
## Config File
You can provide a `zod-pocketbase.config.ts` or `zod-pocketbase.config.js` config file.
```ts title="zod-pocketbase.config.ts"
import type { Config } from "zod-pocketbase";
export default {
// default values
adminEmail: undefined,
adminPassword: undefined,
ignore: [],
nameEnum: (name: string) => snakeCase(name).toUpperCase(),
nameEnumField: (collectionName: string, fieldName: string) => `${collectionName}${pascalCase(fieldName)}`,
nameEnumSchema: (name: string) => pascalCase(name),
nameEnumType: (name: string) => pascalCase(name),
nameEnumValues: (name: string) => `${name}Values`,
nameRecordSchema: (name: string) => `${pascalCase(name)}Record`,
nameRecordType: (name: string) => `${pascalCase(name)}Record`,
output: "./zod-pocketbase.ts",
url: undefined,
} satisfies Config;
```
:::tip[For more details]
See the [reference](/reference/config).
:::
## Environment variables
You can provide your admin credentials and your **PocketBase** instance url as environment variables:
```shell
ZOD_POCKETBASE_ADMIN_EMAIL="admin@mydomain.com"
ZOD_POCKETBASE_ADMIN_PASSWORD="mypassword"
ZOD_POCKETBASE_URL="https://myproject.pockethost.io"
```

View File

@@ -0,0 +1,52 @@
---
title: Helpers
description: Helpers are syntactic sugar to get records from your PocketBase instance.
---
Helpers are syntactic sugar to get records from your PocketBase instance. In addition to simplifying the process of fetching records, they also provide type safety, autocompletion and caching.
To access helpers, you have to, at least, provide an instance of PocketBase SDK to the `helpersFrom` function.
```ts
import Fetch from "@11ty/eleventy-fetch";
import { helpersFrom } from "zod-pocketbase";
import Pocketbase from "pocketbase";
const { getRecord, getRecords } = helpersFrom({
pocketbase: new Pocketbase(import.meta.env.ZOD_POCKETBASE_URL),
fetch: async (url, fetchOptions) => {
const { body, ...init } = await Fetch(url, { fetchOptions, returnType: "response", type: "json" });
return new Response(JSON.stringify(body), init);
},
});
```
## getRecord
`getRecord` is a helper to get a single record from your PocketBase instance. It takes a [RecordRef](/reference/types#recordref) as its first argument and an object with a schema as its second argument.
```ts
const myFirstPost = await getRecord({ collection: "posts", slug: "my-first-post" }, { schema: PostRecord });
const mySecondPost = await getRecord({ collection: "posts", id: "3vwc4g9d23orc1r" }, { schema: PostRecord });
```
:::tip[For more details]
See the [reference](/reference/methods#getrecord).
:::
## getRecords
`getRecords` is a helper to get multiple records from your PocketBase instance. It takes a collection name as its first argument and an object with some options (at least a record schema) as its second argument.
```ts
const { items: myPosts } = await getRecords("posts", { schema: PostRecord });
const { items: someSpecificPosts } = await getRecords("posts", { schema: PostRecord, sort: "-updated", page: 2, perPage: 10 });
```
:::caution
The provided schema is only for a record. The method returns a [RecordsList](/reference/types/#recordslist) object.
:::
:::tip[For more details]
See the [reference](/reference/methods#getrecords).
:::

View File

@@ -0,0 +1,121 @@
---
title: Schemas
description: How to secure and simplify schemas for your PocketBase instance.
---
Here you can find how to secure and simplify schemas for your PocketBase instance:
1. with the CLI generated schemas
2. with the `expand` method
3. with the `pick` method
4. with the `select` method
## 0 - A Zod schema for a Post collection
Here is the original schema for a `Post` collection:
```ts
const Post = z
.object({
content: z.string(),
expand: z.object({
author: z
.object({
name: z.string(),
expand: z.object({
image: z.object({
alt: z.string(),
src: z.string(),
}),
}),
})
.transform(({ expand, ...rest }) => ({ ...rest, ...expand })),
image: z.object({
alt: z.string(),
src: z.string(),
}),
}),
title: z.string(),
updated: z.coerce.date(),
})
.transform(({ expand, ...rest }) => ({ ...rest, ...expand }));
```
## 1 - With CLI generated schema
All schemas are generated from your PocketBase.
```ts
import {AuthorRecord, ImageRecord, PostRecord} from "./schemas";
const Post = PostRecord.pick({ content: true, title: true, updated: true })
.extend({
expand: z.object({
author: AuthorRecord.pick({ name: true })
.extend({
expand: z.object({
image: ImageRecord.pick({ alt: true, src: true }),
}),
})
.transform(({ expand, ...rest }) => ({ ...rest, ...expand })),
image: ImageRecord.pick({ alt: true, src: true }),
}),
})
.transform(({ expand, ...rest }) => ({ ...rest, ...expand }));
```
## 2 - With pick
`pick` is a syntactic sugar for native zod `pick`.
```ts
import { pick } from "zod-pocketbase";
import {AuthorRecord, ImageRecord, PostRecord} from "./schemas";
const Post = pick(PostRecord, ["content", "title", "updated"])
.extend({
expand: z.object({
author: pick(AuthorRecord, ["name"])
.extend({
expand: z.object({
image: pick(ImageRecord, ["alt", "src"]),
}),
})
.transform(({ expand, ...rest }) => ({ ...rest, ...expand })),
image: pick(ImageRecord, ["alt", "src"]),
}),
})
.transform(({ expand, ...rest }) => ({ ...rest, ...expand }));
```
## 3 - With expand
`expand` is a syntactic sugar for native zod `extend` on the property `expand` coupled with `transform`.
```ts
import { expand, pick } from "zod-pocketbase";
import {AuthorRecord, ImageRecord, PostRecord} from "./schemas";
const Post = expand(pick(PostRecord, ["content", "title", "updated"]), {
author: expand(pick(AuthorRecord, ["name"]), {
image: pick(ImageRecord, ["alt", "src"])
}),
image: pick(ImageRecord, ["alt", "src"])
});
```
## 4 - With select
`select` is the union of `pick` and `expand`.
```ts
import { select } from "zod-pocketbase";
import {AuthorRecord, ImageRecord, PostRecord} from "./schemas";
const Post = select(PostRecord, ["content", "title", "updated"], {
author: select(AuthorRecord, ["name"], {
image: select(ImageRecord, ["alt", "src"])
}),
image: select(ImageRecord, ["alt", "src"])
});
```

View File

@@ -0,0 +1,30 @@
---
title: Welcome to Zod PocketBase
description: This library adds Zod safety with ease to your PocketBase instance.
template: splash
hero:
tagline: Add Zod to PocketBase with ease!
image:
file: ../../assets/houston.webp
actions:
- text: Why this library?
link: /start-here/why/
icon: right-arrow
- text: View on GitHub
link: https://github.com/gbouteiller/zod-pocketbase
icon: external
variant: minimal
---
import { CardGrid, Card } from '@astrojs/starlight/components';
## Also check out...
<CardGrid stagger>
<Card title="Astro PocketBase" icon="puzzle">
Add [PocketBase to Astro](https://astro-pocketbase-five.vercel.app) with ease!
</Card>
<Card title="Astro Superforms" icon="puzzle">
Add [Superforms to Astro](https://astro-superforms.vercel.app) with ease!
</Card>
</CardGrid>

View File

@@ -0,0 +1,122 @@
---
title: Config
description: A reference for the Config file.
---
## ignore
- **Type:** `string[]`
- **Default:** `[]`
The `ignore` option allows you to ignore specific collections from being processed.
```ts
{
ignore: ["users"],
}
```
## adminEmail
- **Type:** `string`
- **Default:** `undefined`
`adminEmail` represents the email of an admin user of your PocketBase instance.
```ts
{
adminEmail: "admin@mydomain.com",
}
```
## adminPassword
- **Type:** `string`
- **Default:** `undefined`
`adminPassword` represents the password of the above admin user of your PocketBase instance.
```ts
{
adminPassword: "mypassword",
}
```
## nameEnum
- **Type:** `(enumFieldName: string) => string`
- **Default:** `(enumFieldName) => snakeCase(enumFieldName).toUpperCase()`
`nameEnum` is a function that takes an enum field name and returns the name of the generated enum.
## nameEnumField
- **Type:** `(collectionName: string, fieldName: string) => string`
- **Default:** `(collectionName, fieldName) => collectionName + pascalName(fieldName)`
`nameEnumField` is a function that takes a field name and its collection name and returns the name of the generated enum field.
## nameEnumSchema
- **Type:** `(enumFieldName: string) => string`
- **Default:** `(enumFieldName) => pascalName(enumFieldName)`
`nameEnumSchema` is a function that takes an enum field name and returns the name of the generated enum schema.
## nameEnumType
- **Type:** `(enumFieldName: string) => string`
- **Default:** `(enumFieldName) => pascalName(enumFieldName)`
`nameEnumType` is a function that takes an enum field name and returns the name of the generated enum type.
## nameEnumValues
- **Type:** `(enumFieldName: string) => string`
- **Default:** `(enumFieldName) => enumFieldName + "Values"`
`nameEnumValues` is a function that takes an enum field name and returns the name of the generated enum values.
## nameRecordSchema
- **Type:** `(collectionName: string) => string`
- **Default:** `(collectionName) => pascalName(collectionName) + "Record"`
`nameRecordSchema` is a function that takes a collection name and returns the name of the generated record schema.
## nameRecordType
- **Type:** `(collectionName: string) => string`
- **Default:** `(collectionName) => pascalName(collectionName) + "Record"`
`nameRecordType` is a function that takes a collection name and returns the name of the generated record type.
## output
- **Type:** `string`
- **Default:** `./zod-pocketbase.ts`
`output` represents the path of the generated file.
```ts
{
output: "./src/lib/pocketbase/schemas.ts",
}
```
:::caution
`output` must be a relative path.
:::
## url
- **Type:** `string`
- **Default:** `undefined`
`url` represents the url of your PocketBase instance.
```ts
{
url: "https://myproject.pockethost.io",
}
```

View File

@@ -0,0 +1,88 @@
---
title: Methods
description: A reference for the methods.
---
## helpersFrom
```ts
import { helpersFrom } from "zod-pocketbase";
const helpers = helpersFrom({ fetch, pocketbase });
```
The `helpersFrom` method returns an object with two methods: `getRecord` and `getRecords` described below.
### fetch
- **Type:** `(url: RequestInfo | URL, config?: RequestInit) => Promise<Response>`
Optional custom fetch function to use for sending the request.
### pocketbase
- **Required**
- **Type:** `TypedPocketbase`
The `pocketbase` parameter is a mandatory parameter that specifies a PocketBase instance.
## getRecord
```ts
const { getRecord } = helpersFrom({ pocketbase });
const record = await getRecord(reference, { schema });
```
The `getRecord` method returns a single record from your PocketBase instance.
### reference
- **Required**
- **Type:** [`RecordRef`](/reference/types#recordref)
### schema
- **Required**
- **Type:** [`AnyZodRecord`](/reference/types#anyzodrecord)
## getRecords
```ts
const { getRecords } = helpersFrom({ pocketbase });
const recordsList = await getRecords(collection, { filter, page, perPage, schema, skipTotal, sort });
```
The `getRecords` method returns a records list from your PocketBase instance.
### collection
- **Required**
- **Type:** `string`
### filter
- **Type:** `string`
### page
- **Type:** `number`
- **Default:** `1`
### perPage
- **Type:** `number`
- **Default:** `200`
### schema
- **Required**
- **Type:** [`AnyZodRecord`](/reference/types#anyzodrecord)
### skipTotal
- **Type:** `boolean`
- **Default:** `true`
### sort
- **Type:** [`ZodRecordSort`](/reference/types#zodrecordsort)

View File

@@ -0,0 +1,80 @@
---
title: Types
description: A reference for the types.
---
## RecordIdRef
`RecordIdRef` represents a reference to a record in a collection by its `id` and its `collection` name.
```ts
type RecordIdRef<C extends string> = { collection: C; id: string };
```
## RecordSlugRef
`RecordSlugRef` represents a reference to a record in a collection by its `slug` and its `collection` name.
```ts
type RecordSlugRef<C extends string> = { collection: C; slug: string };
```
:::tip[What is slug?]
`slug` is not a default field of a collection but it can be used as another unique identifier for a record more readble than its `id`.
:::
## RecordRef
`RecordRef` represents either a `RecordIdRef` or a `RecordSlugRef`.
```ts
type RecordRef<C extends string> = RecordIdRef<C> | RecordSlugRef<C>;
```
## RecordFullListOpts
```ts
type RecordFullListOpts<S extends AnyZodRecord> = RecordListOpts<S> & { batch?: number };
```
## RecordListOpts
```ts
type RecordListOpts<S extends AnyZodRecord> = {
filter?: string;
page?: number;
perPage?: number;
skipTotal?: boolean;
sort?: ZodRecordSort<S>;
};
```
## AnyZodRecord
`AnyZodRecord` represents the type for a record schema.
```ts
type AnyZodRecord = AnyZodObject | ZodEffects<AnyZodObject>;
```
## ZodRecordSort
`ZodRecordSort` represents the type for a record sort options.
```ts
export type ZodRecordSort<S extends AnyZodRecord> = `${"+" | "-"}${ZodRecordMainKeys<S>}` | "@random";
```
## RecordsList
`RecordsList` represents the type for a records list.
```ts
export type RecordsList<S extends AnyZodRecord> = {
items: S["_output"][];
page: number;
perPage: number;
totalItems: number;
totalPages: number;
};
```

View File

@@ -0,0 +1,32 @@
---
title: Getting started
description: This library adds Zod safety with ease to your PocketBase instance.
---
import { Tabs, TabItem } from '@astrojs/starlight/components';
This library adds Zod safety with ease to your PocketBase instance. It provides you with:
- a CLI to generate schemas for your selected collections
- methods to make pocketbase SDK typesafe
- helpers to simplify the way you fetch your collections
## Installation
To install `zod-pocketbase`, run the following from your project directory:
<Tabs>
<TabItem label="npm">
```shell
npm install zod-pocketbase
```
</TabItem>
<TabItem label="pnpm">
```shell
pnpm add zod-pocketbase
```
</TabItem>
<TabItem label="yarn">
```shell
yarn install zod-pocketbase
```
</TabItem>
</Tabs>

View File

@@ -0,0 +1,202 @@
---
title: Why this library?
description: A guide in my new Starlight docs site.
---
You want to get the last 10 updated posts from your PocketBase collection. So you start with...
## The PocketBase SDK
```ts
import PocketBase from "pocketbase";
import { z } from "zod";
const pocketbase = new PocketBase("https://my-pocketbase.com");
const options = {
sort: "-updated"
};
const { items: firstPosts } = await pocketbase.collection("posts").getList(1, 10, options);
```
Oops, you forgot to expand the `author` field because you don't want the author id but the author name. So you...
## Expand
```diff lang="ts"
import PocketBase from "pocketbase";
import { z } from "zod";
const pocketbase = new PocketBase("https://my-pocketbase.com");
const options = {
sort: "-updated",
+ expand: ["author"],
};
const { items: firstPosts } = await pocketbase.collection("posts").getList(1, 10, options);
```
Great, but now you want to validate the data you get from PocketBase because the rule is that you should not trust anything for the outer world.
You also just include the fields that you really need for your app and remove the ugly `expand` properties. So you add...
## A Zod Schema
```diff lang="ts"
import PocketBase from "pocketbase";
import { z } from "zod";
const pocketbase = new PocketBase("https://my-pocketbase.com");
+ const Post = z
+ .object({
+ content: z.string(),
+ expand: z.object({
+ author: z.object({
+ name: z.string(),
+ }),
+ }),
+ title: z.string(),
+ })
+ .transform(({ expand, ...rest }) => ({ ...rest, ...expand }));
const options = {
sort: "-updated",
expand: ["author"],
};
const { items } = await pocketbase.collection("posts").getList(1, 10, options);
+ const firstPosts = Post.array().parse(items);
```
Better, but even if you get your validated structured data now, you could reduce the size of the response from the server by adding...
## Fields
```diff lang="ts"
import PocketBase from "pocketbase";
import { z } from "zod";
const pocketbase = new PocketBase("https://my-pocketbase.com");
const Post = z
.object({
content: z.string(),
expand: z.object({
author: z.object({
name: z.string(),
}),
}),
title: z.string(),
})
.transform(({ expand, ...rest }) => ({ ...rest, ...expand }));
const options = {
sort: "-updated",
expand: ["author"],
+ fields: ["content", "expand.author.name", "title"],
};
const { items } = await pocketbase.collection("posts").getList(1, 10, options);
const firstPosts = Post.array().parse(items);
```
Cool! So what would you need Zod PocketBase for?
## Generated Schemas
```diff lang="ts"
import PocketBase from "pocketbase";
import { z } from "zod";
+ import { pick, select } from "zod-pocketbase";
+ import { AuthorRecord, PostRecord } from "./schemas";
const pocketbase = new PocketBase("https://my-pocketbase.com");
- const Post = z
- .object({
- content: z.string(),
- expand: z.object({
- author: z.object({
- name: z.string(),
- }),
- }),
- title: z.string(),
- })
- .transform(({ expand, ...rest }) => ({ ...rest, ...expand }));
+ const Post = PostRecord.pick({content: true, title: true }).extend({
+ expand: z.object({
+ author: AuthorRecord.pick({ name: true })
+ }),
+ }).transform(({ expand, ...rest }) => ({ ...rest, ...expand }));
/* Or, for the syntactic sugar addicts */
+ const Post = select(PostRecord, ["content", "title"], {
+ author: select(AuthorRecord, ["name"])
+ });
const options = {
sort: "-updated",
expand: ["author"],
fields: ["content", "expand.author.name", "title"],
};
const { items } = await pocketbase.collection("posts").getList(1, 10, options);
const firstPosts = Post.array().parse(items);
```
## Type-safe expand and fields options
```diff lang="ts"
import PocketBase from "pocketbase";
import { z } from "zod";
import { select } from "zod-pocketbase";
+ import { expandFrom, fieldsFrom, listOptionsFrom } from "zod-pocketbase";
import { AuthorRecord, PostRecord } from "./schemas";
const pocketbase = new PocketBase("https://my-pocketbase.com");
const Post = select(PostRecord, ["content", "title"], {
author: select(AuthorRecord, ["name"])
});
const options = {
sort: "-updated",
- expand: ["author"],
+ expand: expandFrom(Post),
- fields: ["content", "expand.author.name", "title"],
+ fields: fieldsFrom(Post),
};
/* Or, for the syntactic sugar addicts */
+ const options = listOptionsFrom(Post, { sort: "-updated" });
const { items } = await pocketbase.collection("posts").getList(1, 10, options);
const firstPosts = Post.array().parse(items);
```
## More sugar with helpers
```diff lang="ts"
import PocketBase from "pocketbase";
import { z } from "zod";
import { select } from "zod-pocketbase";
+ import { helpersFrom } from "zod-pocketbase";
import { AuthorRecord, PostRecord } from "./schemas";
const pocketbase = new PocketBase("https://my-pocketbase.com");
+ const { getRecords } = helpersFrom({ pocketbase });
const Post = select(PostRecord, ["content", "title"], {
author: select(AuthorRecord, ["name"])
});
- const options = optionsFrom(Post, { sort: "-updated" });
+ const options = { perPage: 10, schema: Post, sort: "-updated" };
- const { items } = await pocketbase.collection("posts").getList(1, 10, options);
- const firstPosts = Post.array().parse(items);
+ const { items: firstPosts } = getRecords("posts", options);
```

2
doc/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

3
doc/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}

3
eslint.config.mjs Normal file
View File

@@ -0,0 +1,3 @@
import astro from "eslint-plugin-astro";
export default [...astro.configs["flat/recommended"], ...astro.configs["flat/jsx-a11y-strict"]];

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "root",
"private": true,
"packageManager": "pnpm@9.9.0",
"engines": {
"node": ">=18.20.3"
},
"scripts": {
"doc:dev": "pnpm --filter doc dev",
"package:dev": "pnpm --filter zod-pocketbase dev",
"playground:dev": "pnpm --filter playground dev",
"playground:gen": "pnpm --filter playground generate",
"changeset": "changeset",
"release": "node scripts/release.mjs"
},
"devDependencies": {
"@changesets/cli": "^2.27.11",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.9"
}
}

1
package/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist

106
package/CHANGELOG.md Normal file
View File

@@ -0,0 +1,106 @@
# zod-pocketbase
## 0.5.0
### Minor Changes
- remove "cache" option for "helpersFrom" and replace it with "fetch" to allow a more generic custom fetch and remove "@11ty/eleventy-fetch" dependency
## 0.4.1
### Patch Changes
- update packages
- fix json import
## 0.4.0
### Minor Changes
- separate files that use node packages in server directory
## 0.3.8
### Patch Changes
- fix cached version
## 0.3.7
### Patch Changes
- d5481dc: fix getRecords return type
## 0.3.6
### Patch Changes
- a8d3098: add recordsListFrom method to construct a records list schema from a record schema
- a8d3098: fix getRecords helper return type
## 0.3.5
### Patch Changes
- optimize SKD calls and options
## 0.3.4
### Patch Changes
- rename out to output
## 0.3.3
### Patch Changes
- refactor code
## 0.3.2
### Patch Changes
- fix syntax
## 0.3.1
### Patch Changes
- fix generated syntax
## 0.3.0
### Minor Changes
- refactor CLI
## 0.2.4
### Patch Changes
- 03555e2: fix optional expand in expand method
## 0.2.3
### Patch Changes
- cdf2b59: fix the transformation when expand is undefined
## 0.2.2
### Patch Changes
- 6980455: fix the case when expand only contains keys with optional schemas
## 0.2.1
### Patch Changes
- be3d479: add cache option to helpers
## 0.2.0
### Minor Changes
- 5435793: rename getHelpers to helpersFrom
- 5435793: add collectionName literal to schema

View File

@@ -0,0 +1,42 @@
import type Pocketbase from "pocketbase";
import type { RecordService } from "pocketbase";
import { z } from "zod";
/******* ENUMS *******/
export const collectionValues = @@_COLLECTION_NAMES_@@ as const;
export const Collection = z.enum(collectionValues);
export type Collection = z.infer<typeof Collection>;
export const COLLECTION = Collection.enum;
@@_ENUMS_@@
/******* BASE *******/
export const BaseModel = z.object({
created: z.string().pipe(z.coerce.date()),
id: z.string(),
updated: z.string().pipe(z.coerce.date()),
});
export type BaseModel = z.infer<typeof BaseModel>;
export const AdminModel = z.object({
...BaseModel.shape,
avatar: z.string(),
email: z.string().email(),
});
export type AdminModel = z.infer<typeof AdminModel>;
export const RecordModel = z.object({
...BaseModel.shape,
collectionId: z.string(),
collectionName: z.string(),
expand: z.any().optional(),
});
export type RecordModel = z.infer<typeof RecordModel>;
/******* RECORDS *******/
@@_RECORDS_@@
/******* CLIENT *******/
export type TypedPocketbase = Pocketbase & {
@@_SERVICES_@@
};

63
package/package.json Normal file
View File

@@ -0,0 +1,63 @@
{
"name": "zod-pocketbase",
"version": "0.5.0",
"description": "",
"author": {
"email": "gregory.bouteiller@niama.re",
"name": "Gregory Bouteiller",
"url": "https://github.com/gbouteiller"
},
"license": "MIT",
"keywords": [
"pocketbase",
"schemas",
"typescript",
"typegen",
"type generation",
"zod"
],
"homepage": "https://github.com/gbouteiller/zod-pocketbase",
"publishConfig": {
"access": "public"
},
"type": "module",
"sideEffects": false,
"main": "dist/index.js",
"bin": {
"zod-pocketbase": "dist/server/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"default": "./dist/server/index.js"
}
},
"files": [
"dist",
"assets"
],
"scripts": {
"dev": "tsup --watch",
"build": "tsup"
},
"dependencies": {
"@clack/prompts": "^0.9.0",
"c12": "^2.0.1",
"citty": "^0.1.6",
"es-toolkit": "^1.30.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"pocketbase": "<0.22.0",
"tsup": "^8.3.5",
"zod": "^3.24.1"
},
"peerDependencies": {
"pocketbase": "<0.22.0",
"zod": "^3.23.8"
}
}

1209
package/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

66
package/src/config.ts Normal file
View File

@@ -0,0 +1,66 @@
import { pascalCase, snakeCase } from "es-toolkit";
import { z } from "zod";
/**
* Default config values.
*/
export const defaultConfig = {
ignore: [],
nameEnum: (name: string) => snakeCase(name).toUpperCase(),
nameEnumField: (collectionName: string, fieldName: string) => `${collectionName}${pascalCase(fieldName)}`,
nameEnumSchema: (name: string) => pascalCase(name),
nameEnumType: (name: string) => pascalCase(name),
nameEnumValues: (name: string) => `${name}Values`,
nameRecordSchema: (name: string) => `${pascalCase(name)}Record`,
nameRecordType: (name: string) => `${pascalCase(name)}Record`,
output: "./zod-pocketbase.ts",
};
/**
* Schema for the PocketBase credentials.
*/
export const Credentials = z.object({
adminEmail: z.string().email(),
adminPassword: z.string(),
url: z.string().url(),
});
export type Credentials = z.infer<typeof Credentials>;
/**
* Schema for the config file.
*/
export const Config = z.object({
...Credentials.partial().shape,
ignore: z.string().array().default(defaultConfig.ignore),
nameEnum: z
.function(z.tuple([z.string()]), z.string())
.optional()
.transform((f) => f ?? defaultConfig.nameEnum),
nameEnumField: z
.function(z.tuple([z.string(), z.string()]), z.string())
.optional()
.transform((f) => f ?? defaultConfig.nameEnumField),
nameEnumSchema: z
.function(z.tuple([z.string()]), z.string())
.optional()
.transform((f) => f ?? defaultConfig.nameEnumSchema),
nameEnumType: z
.function(z.tuple([z.string()]), z.string())
.optional()
.transform((f) => f ?? defaultConfig.nameEnumType),
nameEnumValues: z
.function(z.tuple([z.string()]), z.string())
.optional()
.transform((f) => f ?? defaultConfig.nameEnumValues),
nameRecordSchema: z
.function(z.tuple([z.string()]), z.string())
.optional()
.transform((f) => f ?? defaultConfig.nameRecordSchema),
nameRecordType: z
.function(z.tuple([z.string()]), z.string())
.optional()
.transform((f) => f ?? defaultConfig.nameRecordType),
output: z.string().default(defaultConfig.output),
});
export type Config = z.input<typeof Config>;
export type ResolvedConfig = z.infer<typeof Config>;

120
package/src/content.ts Normal file
View File

@@ -0,0 +1,120 @@
import { sortBy } from "es-toolkit";
import type { CollectionModel, SchemaField } from "pocketbase";
import type { GenerateOpts } from "./server/utils.ts";
export function stringifyContent(collections: CollectionModel[], opts: GenerateOpts) {
function getCollectionSelectFields() {
return collections.flatMap((collection) =>
collection.schema
.filter((field) => field.type === "select")
.map((field) => ({ name: opts.nameEnumField(collection.name, field.name), values: (field.options.values ?? []) as string[] })),
);
}
function stringifyEnum({ name, values }: SelectField) {
const valuesName = opts.nameEnumValues(name);
const schemaName = opts.nameEnumSchema(name);
const enumName = opts.nameEnum(name);
const typeName = opts.nameEnumType(name);
return `export const ${valuesName} = [\n\t${values.map((value) => `"${value}"`).join(",\n\t")},\n] as const;\nexport const ${schemaName} = z.enum(${valuesName});\nexport type ${typeName} = z.infer<typeof ${schemaName}>;\nexport const ${enumName} = ${schemaName}.enum;`;
}
function stringifyRecord({ name, schema }: CollectionModel) {
const schemaName = opts.nameRecordSchema(name);
const typeName = opts.nameRecordType(name);
const fields = sortBy(schema, ["name"]).map((field) => stringifyField(field, name));
return `export const ${schemaName} = z.object({\n\t...RecordModel.omit({ expand: true }).shape,\n\tcollectionName: z.literal("${name}"),\n\t${fields.join(",\n\t")},\n});\nexport type ${typeName} = z.infer<typeof ${schemaName}>;`;
}
function stringifyField(field: SchemaField, collectionName: string) {
let schema: string | undefined;
if (field.type === "bool") schema = stringifyBoolField(field);
else if (field.type === "date") schema = stringifyDateField(field);
else if (field.type === "editor") schema = stringifyEditorField(field);
else if (field.type === "email") schema = stringifyEmailField(field);
else if (field.type === "file") schema = stringifyFileField(field);
else if (field.type === "json") schema = stringifyJsonField(field);
else if (field.type === "number") schema = stringifyNumberField(field);
else if (field.type === "relation") schema = stringifyRelationField(field);
else if (field.type === "select") schema = stringifySelectField(field, collectionName);
else if (field.type === "text") schema = stringifyTextField(field);
else if (field.type === "url") schema = stringifyUrlField(field);
// TODO: manage unknown field type
return `${field.name}: ${schema}${field.required ? "" : ".optional()"}`;
}
function stringifyBoolField(_: SchemaField) {
return "z.boolean()";
}
function stringifyDateField(_field: SchemaField) {
// TODO: implement min and max
return "z.string().pipe(z.coerce.date())";
}
function stringifyEditorField(_field: SchemaField) {
// TODO: implement convertUrls
return "z.string()";
}
function stringifyEmailField(_field: SchemaField) {
// TODO: implement exceptDomains and onlyDomains
return "z.string().email()";
}
function stringifyFileField({ options: { maxSelect } }: SchemaField) {
// TODO: implement maxSize, mimeTypes, protected, thumbs
return `z.string()${maxSelect === 1 ? "" : `.array().max(${maxSelect})`}`;
}
function stringifyJsonField(_field: SchemaField) {
// TODO: implement maxSize and json schema
return "z.any()";
}
function stringifyNumberField({ options: { max, min, noDecimal } }: SchemaField) {
return `z.number()${noDecimal ? ".int()" : ""}${min ? `.min(${min})` : ""}${max ? `.max(${max})` : ""}`;
}
function stringifyRelationField({ options, required }: SchemaField) {
const { maxSelect, minSelect } = options;
// TODO: implement cascadeDelete, displayFields
const min = minSelect ? `.min(${minSelect})` : "";
const max = maxSelect ? `.max(${maxSelect})` : "";
const multiple = maxSelect === 1 ? "" : `.array()${min}${max}`;
const isOptional = required || maxSelect !== 1 ? `` : `.transform((id) => id === "" ? undefined : id)`;
return `z.string()${isOptional}${multiple}`;
}
function stringifySelectField({ name, options: { maxSelect } }: SchemaField, collectionName: string) {
// TODO: implement values
return `${opts.nameEnumSchema(opts.nameEnumField(collectionName, name))}${maxSelect === 1 ? "" : `.array().max(${maxSelect})`}`;
}
function stringifyTextField({ options: { max, min } }: SchemaField) {
// TODO: implement pattern
return `z.string()${min ? `.min(${min})` : ""}${max ? `.max(${max})` : ""}`;
}
function stringifyUrlField(_field: SchemaField) {
// TODO: implement exceptDomains and onlyDomains
return "z.string().url()";
}
function stringifySchemasEntry({ name }: CollectionModel) {
return `["${name}", ${opts.nameRecordSchema(name)}]`;
}
function stringifyService({ name }: CollectionModel) {
return `\t\tcollection(idOrName: "${name}"): RecordService<z.input<typeof ${opts.nameRecordSchema(name)}>>;`;
}
return {
collectionNames: `[\n\t${collections.map(({ name }) => `"${name}"`).join(",\n\t")},\n]`,
enums: getCollectionSelectFields().map(stringifyEnum).join("\n\n"),
records: `${collections.map(stringifyRecord).join("\n\n")}\n\nexport const records = new Map<Collection, z.AnyZodObject>([\n\t${collections.map(stringifySchemasEntry).join(",\n\t")},\n]);`,
services: collections.map(stringifyService).join("\n"),
};
}
export type SelectField = { name: string; values: string[] };

29
package/src/helpers.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { default as Pocketbase, SendOptions } from "pocketbase";
import { fullListOptionsFrom, optionsFrom } from "./options.js";
import type { AnyZodRecord, RecordFullListOpts, RecordIdRef, RecordRef, RecordSlugRef } from "./types.ts";
import { AnyRecordsList, type RecordsList } from "./schemas.ts";
export function helpersFrom({ fetch, pocketbase }: HelpersFromOpts) {
async function getRecord<C extends string, S extends AnyZodRecord>(ref: RecordSlugRef<C>, opts: GetRecordOpts<S>): Promise<S["_output"]>;
async function getRecord<C extends string, S extends AnyZodRecord>(ref: RecordIdRef<C>, opts: GetRecordOpts<S>): Promise<S["_output"]>;
async function getRecord<C extends string, S extends AnyZodRecord>(ref: RecordRef<C>, opts: GetRecordOpts<S>) {
const { schema } = opts;
const sdkOpts = { ...optionsFrom(schema), ...(fetch ? { fetch } : {}) };
const unsafeRecord = await ("id" in ref
? pocketbase.collection(ref.collection).getOne(ref.id, sdkOpts)
: pocketbase.collection(ref.collection).getFirstListItem(`slug = "${ref.slug}"`, sdkOpts));
return schema.parseAsync(unsafeRecord);
}
async function getRecords<C extends string, S extends AnyZodRecord>(collection: C, opts: GetRecordsOpts<S>): Promise<RecordsList<S>> {
const { schema, ...otherOpts } = opts;
const sdkOpts = { ...fullListOptionsFrom(schema, otherOpts), ...(fetch ? { fetch } : {}) };
const recordsList = await pocketbase.collection(collection).getList(sdkOpts.page, sdkOpts.perPage, sdkOpts);
return AnyRecordsList.extend({ items: schema.array() }).parseAsync(recordsList);
}
return { getRecord, getRecords };
}
export type GetRecordOpts<S extends AnyZodRecord> = { schema: S };
export type GetRecordsOpts<S extends AnyZodRecord> = RecordFullListOpts<S> & { schema: S };
export type HelpersFromOpts = { fetch?: SendOptions["fetch"]; pocketbase: Pocketbase };

6
package/src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from "./config.js";
export * from "./helpers.js";
export * from "./options.js";
export * from "./schemas.js";
export * from "./types.ts";
export * from "./utils.js";

97
package/src/options.ts Normal file
View File

@@ -0,0 +1,97 @@
import { z, type AnyZodObject, type ZodTypeAny } from "zod";
import type { AnyZodRecord, RecordFullListOpts, RecordListOpts } from "./types.ts";
/**
* Extends the given schema with the given expansion.
* @param schema - The original schema
* @param shape - The shape of the expansion
* @returns A new schema extended with the given expansion
*/
export function expandFrom<S extends AnyZodRecord>(schema: S) {
return expandFromRec(schema)
.sort((k1, k2) => (k1 < k2 ? -1 : 1))
.join(",");
}
/**
* Extends the given schema with the given expansion.
* @param schema - The original schema
* @param shape - The shape of the expansion
* @returns A new schema extended with the given expansion
*/
export function fieldsFrom<S extends AnyZodRecord>(schema: S) {
return fieldsFromRec(schema)
.sort((k1, k2) => (k1 < k2 ? -1 : 1))
.join(",");
}
/**
* Extends the given schema with the given expansion.
* @param schema - The original schema
* @param shape - The shape of the expansion
* @returns A new schema extended with the given expansion
*/
export function optionsFrom<S extends AnyZodRecord>(schema: S) {
return { expand: expandFrom(schema), fields: fieldsFrom(schema) };
}
/**
* Extends the given schema with the given expansion.
* @param schema - The original schema
* @param shape - The shape of the expansion
* @returns A new schema extended with the given expansion
*/
export function listOptionsFrom<S extends AnyZodRecord>(schema: S, opts: RecordListOpts<S>) {
const { page = 1, perPage = 30, ...rest } = opts;
return { ...optionsFrom(schema), page, perPage, ...rest };
}
/**
* Extends the given schema with the given expansion.
* @param schema - The original schema
* @param shape - The shape of the expansion
* @returns A new schema extended with the given expansion
*/
export function fullListOptionsFrom<S extends AnyZodRecord>(schema: S, opts: RecordFullListOpts<S>) {
const { page = 1, perPage = 200, skipTotal = true, ...rest } = opts;
return listOptionsFrom(schema, { page, perPage, skipTotal, ...rest });
}
function expandFromRec<S extends ZodTypeAny>(schema: S, prefix = "") {
let expands: string[] = [];
const shape = getObjectSchemaDescendant(schema)?.shape;
if (!shape || !("expand" in shape)) return [];
for (const [key, value] of Object.entries(getObjectSchemaDescendant(shape.expand)!.shape)) {
expands = [...expands, `${prefix}${key}`];
if (hasObjectSchemaDescendant(value)) expands = [...expands, ...expandFromRec(getObjectSchemaDescendant(value)!, `${prefix}${key}.`)];
}
return expands;
}
function fieldsFromRec<S extends z.ZodTypeAny>(schema: S, prefix = "") {
let fields: string[] = [];
const shape = getObjectSchemaDescendant(schema)?.shape;
if (!shape) return [];
for (const [key, value] of Object.entries(shape)) {
fields = [
...fields,
...(hasObjectSchemaDescendant(value) ? fieldsFromRec(getObjectSchemaDescendant(value)!, `${prefix}${key}.`) : [`${prefix}${key}`]),
];
}
return fields.sort((k1, k2) => (k1 < k2 ? -1 : 1));
}
function hasObjectSchemaDescendant(value: unknown): value is z.ZodTypeAny {
if (value instanceof z.ZodEffects) return hasObjectSchemaDescendant(value.innerType());
if (value instanceof z.ZodArray) return hasObjectSchemaDescendant(value.element);
if (value instanceof z.ZodOptional) return hasObjectSchemaDescendant(value.unwrap());
return value instanceof z.ZodObject;
}
function getObjectSchemaDescendant<S extends z.ZodTypeAny>(schema: S): AnyZodObject | undefined {
if (schema instanceof z.ZodEffects) return getObjectSchemaDescendant(schema.innerType());
if (schema instanceof z.ZodArray) return getObjectSchemaDescendant(schema.element);
if (schema instanceof z.ZodOptional) return getObjectSchemaDescendant(schema.unwrap());
if (schema instanceof z.ZodObject) return schema;
return;
}

71
package/src/schemas.ts Normal file
View File

@@ -0,0 +1,71 @@
import { type AnyZodObject, type objectUtil, z, type ZodEffects, type ZodObject, ZodOptional, type ZodRawShape } from "zod";
import type { AnyZodRecord, HasRequiredKeys, ZodRecordKeys } from "./types.ts";
/**
* Records list schema.
*/
export const AnyRecordsList = z.object({
items: z.any().array(),
page: z.number().int().min(1),
perPage: z.number().int().min(1),
totalItems: z.number().int().min(-1),
totalPages: z.number().int().min(-1),
});
/**
* Extends the given schema with the given expansion.
* @param schema - The original schema
* @param shape - The shape of the expansion
* @returns A new schema extended with the given expansion
*/
export function expand<S extends AnyZodObject, E extends ZodRawShape>(schema: S, shape: E) {
const isExpandOptional = Object.entries(shape).every(([, value]) => value instanceof z.ZodOptional);
return z
.object({ ...schema.shape, expand: isExpandOptional ? z.object(shape).optional() : z.object(shape) })
.transform(({ expand, ...rest }) => ({ ...rest, ...(expand ?? {}) })) as ZodObjectExpand<S, E>;
}
/**
* Picks the given keys from the given schema.
* @param schema - The original schema
* @param keys - The keys to keep
* @returns A new schema with only the given keys
*/
export function pick<S extends AnyZodObject, K extends ZodRecordKeys<S>[]>(schema: S, keys: K) {
return schema.pick(Object.fromEntries(keys.map((key) => [key, true]))) as ZodObjectPick<S, K>;
}
/**
* Picks the given keys from the given schema and extends it with the given expansion.
* @param schema - The original schema
* @param keys - The keys to keep
* @param shape - The shape of the expansion
* @returns A new schema with only the given keys
*/
export function select<S extends AnyZodObject, K extends ZodRecordKeys<S>[], E extends ZodRawShape>(
schema: S,
keys: K,
shape: E,
): ZodObjectExpand<ZodObjectPick<S, K>, E>;
export function select<S extends AnyZodObject, K extends ZodRecordKeys<S>[]>(schema: S, keys: K): ZodObjectPick<S, K>;
export function select<S extends AnyZodObject, K extends ZodRecordKeys<S>[], E extends ZodRawShape | undefined>(
schema: S,
keys: K,
shape?: E,
) {
return shape ? expand(pick(schema, keys), shape) : pick(schema, keys);
}
export type ZodObjectExpand<S extends AnyZodObject, E extends ZodRawShape> =
S extends ZodObject<infer T, infer U, infer C>
? ZodEffects<
ZodObject<objectUtil.extendShape<T, { expand: HasRequiredKeys<E> extends true ? ZodObject<E> : ZodOptional<ZodObject<E>> }>, U, C>,
ZodObject<objectUtil.extendShape<T, E>, U, C>["_output"]
>
: never;
export type ZodObjectPick<S extends AnyZodObject, K extends ZodRecordKeys<S>[]> =
S extends ZodObject<infer T, infer U, infer C> ? ZodObject<Pick<T, K[number]>, U, C> : never;
export type AnyRecordsList = z.infer<typeof AnyRecordsList>;
export type RecordsList<S extends AnyZodRecord> = Omit<AnyRecordsList, "items"> & { items: S["_output"][] };

13
package/src/sdk.ts Normal file
View File

@@ -0,0 +1,13 @@
import Pocketbase from "pocketbase";
import type { Credentials } from "./config.ts";
let adminPocketbase: Pocketbase;
export async function getPocketbase({ adminEmail, adminPassword, url }: Credentials) {
if (!adminPocketbase) {
const pocketbase = new Pocketbase(url);
await pocketbase.admins.authWithPassword(adminEmail, adminPassword);
adminPocketbase = pocketbase;
}
return adminPocketbase;
}

91
package/src/server/cli.ts Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
import { Config, type ResolvedConfig } from "../config.ts";
import pkg from "../../package.json" assert { type: "json" };
import { loadConfig } from "c12";
import { defineCommand, runMain } from "citty";
import { cancel, group, intro, log, outro, confirm, text, spinner, multiselect, isCancel } from "@clack/prompts";
import { fetchCollections } from "../utils.ts";
import { generate } from "./utils.ts";
import { existsSync } from "node:fs";
async function getConfig() {
const { config } = await loadConfig({ name: "zod-pocketbase", rcFile: false, dotenv: true });
const { ZOD_POCKETBASE_ADMIN_EMAIL: adminEmail, ZOD_POCKETBASE_ADMIN_PASSWORD: adminPassword, ZOD_POCKETBASE_URL: url } = process.env;
const result = Config.safeParse({ ...config, adminEmail, adminPassword, url });
if (!result.success) {
log.error("Invalid fields in your config file.");
onCancel();
}
return result.data!;
}
function onCancel() {
cancel("Operation cancelled.");
process.exit(0);
}
async function selectCollections(config: ResolvedConfig) {
const credentialPrompts = {
url: () => text({ message: "What is the url of your pocketbase instance?", initialValue: config.url ?? "" }),
adminEmail: () => text({ message: "What is your admin's email?", initialValue: config.adminEmail ?? "" }),
adminPassword: () => text({ message: "What is your admin's password?", initialValue: config.adminPassword ?? "" }),
};
const credentials = await group(credentialPrompts, { onCancel });
const s = spinner();
s.start("Fetching collections...");
try {
const allCollections = await fetchCollections(credentials);
s.stop("Successfully fetched collections.");
const collectionNames = await multiselect({
message: "What collections do you want to generate schemas for?",
options: allCollections.map(({ name: value }) => ({ value })),
initialValues: allCollections.filter(({ name }) => !config.ignore.includes(name)).map(({ name }) => name),
});
if (isCancel(collectionNames)) onCancel();
return allCollections.filter(({ name }) => (collectionNames as string[]).includes(name));
} catch {
s.stop("Failed to fetch collections.Please check your credentials and try again.");
return selectCollections(config);
}
}
async function setGeneratedFilePath(config: ResolvedConfig) {
const output = await text({
message: "What is the generated file path?",
initialValue: config.output,
validate: (value) => {
if (!value) return "Please enter a path.";
if (value[0] !== ".") return "Please enter a relative path.";
return;
},
});
if (isCancel(output)) onCancel();
if (existsSync(output as string)) {
const confirmed = await confirm({ message: "The file already exists, would you like to overwrite it?" });
if (isCancel(confirmed)) onCancel();
if (!confirmed) return setGeneratedFilePath(config);
}
return output as string;
}
const main = defineCommand({
meta: { name: "zod-pocketbase", version: pkg.version, description: "Generate Zod schemas for your pocketbase instance." },
run: async () => {
intro(`ZOD POCKETBASE`);
const config = await getConfig();
const collections = await selectCollections(config);
const output = await setGeneratedFilePath(config);
const s = spinner();
s.start("Generating your schemas...");
await generate(collections, { ...config, output });
s.stop("Schemas successfully generated.");
outro("Operation completed.");
},
});
runMain(main);

View File

@@ -0,0 +1 @@
export * from "./utils.js";

View File

@@ -0,0 +1,19 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { CollectionModel } from "pocketbase";
import { stringifyContent } from "../content.js";
import type { ResolvedConfig } from "../config.ts";
export async function generate(collections: CollectionModel[], opts: GenerateOpts) {
const stub = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../../assets/stubs/index.ts"), "utf-8");
const { collectionNames, enums, records, services } = stringifyContent(collections, opts);
const content = stub
.replace("@@_COLLECTION_NAMES_@@", collectionNames)
.replace("@@_ENUMS_@@", enums)
.replace("@@_RECORDS_@@", records)
.replace("@@_SERVICES_@@", services);
mkdirSync(dirname(opts.output), { recursive: true });
writeFileSync(opts.output, content);
}
export type GenerateOpts = Omit<ResolvedConfig, "adminEmail" | "adminPassword" | "ignore" | "url">;

31
package/src/types.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { AnyZodObject, ZodEffects, ZodOptional, ZodRawShape, ZodTypeAny } from "zod";
export type AnyZodRecord = AnyZodObject | ZodEffects<AnyZodObject>;
export type RecordFullListOpts<S extends AnyZodRecord> = RecordListOpts<S> & { batch?: number };
export type RecordListOpts<S extends AnyZodRecord> = {
filter?: string;
page?: number;
perPage?: number;
skipTotal?: boolean;
sort?: ZodRecordSort<S>;
};
export type RecordIdRef<C extends string> = { collection: C; id: string };
export type RecordSlugRef<C extends string> = { collection: C; slug: string };
export type RecordRef<C extends string> = RecordIdRef<C> | RecordSlugRef<C>;
export type ZodRecordKeys<S extends AnyZodRecord> = Extract<keyof S["_input"], string>;
export type ZodRecordMainKeys<S extends AnyZodRecord> = Exclude<ZodRecordKeys<S>, "expand">;
export type ZodRecordSort<S extends AnyZodRecord> = `${"+" | "-"}${ZodRecordMainKeys<S>}` | "@random";
type RequiredKeysOf<S extends ZodRawShape> = Exclude<
{
[Key in keyof S]: S[Key] extends ZodOptional<ZodTypeAny> ? never : Key;
}[keyof S],
undefined
>;
export type HasRequiredKeys<S extends ZodRawShape> = RequiredKeysOf<S> extends never ? false : true;

10
package/src/utils.ts Normal file
View File

@@ -0,0 +1,10 @@
import { sortBy } from "es-toolkit";
import type { CollectionModel } from "pocketbase";
import { getPocketbase } from "./sdk.js";
import type { Credentials } from "./config.ts";
export async function fetchCollections(credentials: Credentials): Promise<CollectionModel[]> {
const pocketbase = await getPocketbase(credentials);
const collections = await pocketbase.collections.getFullList();
return sortBy(collections, ["name"]);
}

52
package/tsconfig.json Normal file
View File

@@ -0,0 +1,52 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
// Enable top-level await, and other modern ESM features.
"target": "ESNext",
"module": "NodeNext",
// Enable module resolution without file extensions on relative paths, for things like npm package imports.
"moduleResolution": "Node16",
// Allow importing TypeScript files using their native extension (.ts(x)).
"allowImportingTsExtensions": true,
// Enable JSON imports.
"resolveJsonModule": true,
// Enforce the usage of type-only imports when needed, which helps avoiding bundling issues.
"verbatimModuleSyntax": true,
// Ensure that each file can be transpiled without relying on other imports.
// This is redundant with the previous option, however it ensures that it's on even if someone disable `verbatimModuleSyntax`
"isolatedModules": true,
// Astro directly run TypeScript code, no transpilation needed.
"noEmit": true,
// Report an error when importing a file using a casing different from another import of the same file.
"forceConsistentCasingInFileNames": true,
// Properly support importing CJS modules in ESM
"esModuleInterop": true,
// Skip typechecking libraries and .d.ts files
"skipLibCheck": true,
// Allow JavaScript files to be imported
"allowJs": true,
// Allow JSX files (or files that are internally considered JSX, like Astro files) to be imported inside `.js` and `.ts` files.
"jsx": "preserve",
// Enable strict mode. This enables a few options at a time, see https://www.typescriptlang.org/tsconfig#strict for a list.
"strict": true,
// Report errors for fallthrough cases in switch statements
"noFallthroughCasesInSwitch": true,
// Force functions designed to override their parent class to be specified as `override`.
"noImplicitOverride": true,
// Force functions to specify that they can return `undefined` if a possible code path does not return a value.
"noImplicitReturns": true,
// Report an error when a variable is declared but never used.
"noUnusedLocals": true,
// Report an error when a parameter is declared but never used.
"noUnusedParameters": true,
// Force the usage of the indexed syntax to access fields declared using an index signature.
"noUncheckedIndexedAccess": true,
// Report an error when the value `undefined` is given to an optional property that doesn't specify `undefined` as a valid value.
"exactOptionalPropertyTypes": true,
// Report an error for unreachable code instead of just a warning.
"allowUnreachableCode": false,
// Report an error for unused labels instead of just a warning.
"allowUnusedLabels": false
},
"exclude": ["dist"]
}

19
package/tsup.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "tsup";
import { peerDependencies } from "./package.json";
export default defineConfig((options) => {
const dev = !!options.watch;
return {
entry: ["src/**/*.(ts|js)"],
format: ["esm"],
target: "node18",
bundle: true,
dts: true,
sourcemap: true,
clean: true,
splitting: false,
minify: !dev,
external: [...Object.keys(peerDependencies), "dotenv"],
tsconfig: "tsconfig.json",
};
});

26
playground/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
.cache

4
playground/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
playground/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

48
playground/README.md Normal file
View File

@@ -0,0 +1,48 @@
# Astro Starter Kit: Basics
```sh
npm create astro@latest -- --template basics
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554)
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
│ └── favicon.svg
├── src/
│ ├── layouts/
│ │ └── Layout.astro
│ └── pages/
│ └── index.astro
└── package.json
```
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View File

@@ -0,0 +1,11 @@
// @ts-check
import { defineConfig, envField } from "astro/config";
// https://astro.build/config
export default defineConfig({
env: {
schema: {
ZOD_POCKETBASE_URL: envField.string({ context: "server", access: "public" }),
},
},
});

20
playground/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "playground",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"generate": "pnpm exec zod-pocketbase"
},
"dependencies": {
"@11ty/eleventy-fetch": "^5.0.1",
"astro": "^5.1.1",
"es-toolkit": "^1.30.1",
"pocketbase": "<0.22.0",
"zod": "^3.24.1",
"zod-pocketbase": "workspace:*"
}
}

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

10
playground/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import type { helpersFrom } from "zod-pocketbase";
declare global {
declare namespace App {
interface Locals {
getRecord: ReturnType<typeof helpersFrom>["getRecord"];
getRecords: ReturnType<typeof helpersFrom>["getRecords"];
}
}
}

View File

@@ -0,0 +1,200 @@
import type Pocketbase from "pocketbase";
import type { RecordService } from "pocketbase";
import { z } from "zod";
/******* ENUMS *******/
export const collectionValues = [
"config",
"events",
"images",
"knowledges",
"pages",
"places",
"posts",
"products",
"services",
"testimonies",
] as const;
export const Collection = z.enum(collectionValues);
export type Collection = z.infer<typeof Collection>;
export const COLLECTION = Collection.enum;
export const servicesCategoryValues = [
"consult",
"training",
"workshop",
] as const;
export const ServicesCategory = z.enum(servicesCategoryValues);
export type ServicesCategory = z.infer<typeof ServicesCategory>;
export const SERVICES_CATEGORY = ServicesCategory.enum;
/******* BASE *******/
export const BaseModel = z.object({
created: z.string().pipe(z.coerce.date()),
id: z.string(),
updated: z.string().pipe(z.coerce.date()),
});
export type BaseModel = z.infer<typeof BaseModel>;
export const AdminModel = z.object({
...BaseModel.shape,
avatar: z.string(),
email: z.string().email(),
});
export type AdminModel = z.infer<typeof AdminModel>;
export const RecordModel = z.object({
...BaseModel.shape,
collectionId: z.string(),
collectionName: z.string(),
expand: z.any().optional(),
});
export type RecordModel = z.infer<typeof RecordModel>;
/******* RECORDS *******/
export const zConfigRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("config"),
city: z.string(),
email: z.string().email(),
facebook: z.string().url(),
instagram: z.string().url(),
phone: z.string(),
street: z.string(),
title: z.string(),
website: z.string().url(),
zipcode: z.string(),
});
export type ConfigRecord = z.infer<typeof zConfigRecord>;
export const zEventsRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("events"),
excerpt: z.string(),
from: z.string().pipe(z.coerce.date()),
image: z.string(),
name: z.string(),
places: z.string().array(),
service: z.string(),
slug: z.string(),
to: z.string().pipe(z.coerce.date()),
url: z.string().url(),
});
export type EventsRecord = z.infer<typeof zEventsRecord>;
export const zImagesRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("images"),
alt: z.string(),
height: z.number().int(),
src: z.string(),
width: z.number().int(),
});
export type ImagesRecord = z.infer<typeof zImagesRecord>;
export const zKnowledgesRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("knowledges"),
image: z.string(),
name: z.string(),
slug: z.string(),
text: z.string(),
});
export type KnowledgesRecord = z.infer<typeof zKnowledgesRecord>;
export const zPagesRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("pages"),
knowledge: z.string(),
post: z.string(),
services: z.string().array().optional(),
slug: z.string(),
testimoniesImage: z.string().transform((id) => id === "" ? undefined : id).optional(),
title: z.string(),
});
export type PagesRecord = z.infer<typeof zPagesRecord>;
export const zPlacesRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("places"),
name: z.string(),
slug: z.string(),
});
export type PlacesRecord = z.infer<typeof zPlacesRecord>;
export const zPostsRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("posts"),
excerpt: z.string(),
image: z.string().transform((id) => id === "" ? undefined : id).optional(),
knowledge: z.string(),
slug: z.string(),
text: z.string(),
title: z.string(),
});
export type PostsRecord = z.infer<typeof zPostsRecord>;
export const zProductsRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("products"),
excerpt: z.string(),
image: z.string(),
name: z.string(),
price: z.string(),
slug: z.string(),
text: z.string(),
url: z.string().url(),
});
export type ProductsRecord = z.infer<typeof zProductsRecord>;
export const zServicesRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("services"),
category: ServicesCategory,
duration: z.string(),
excerpt: z.string(),
image: z.string(),
knowledge: z.string(),
name: z.string(),
places: z.string().array(),
price: z.string(),
slug: z.string(),
text: z.string(),
});
export type ServicesRecord = z.infer<typeof zServicesRecord>;
export const zTestimoniesRecord = z.object({
...RecordModel.omit({ expand: true }).shape,
collectionName: z.literal("testimonies"),
author: z.string(),
text: z.string(),
title: z.string(),
});
export type TestimoniesRecord = z.infer<typeof zTestimoniesRecord>;
export const records = new Map<Collection, z.AnyZodObject>([
["config", zConfigRecord],
["events", zEventsRecord],
["images", zImagesRecord],
["knowledges", zKnowledgesRecord],
["pages", zPagesRecord],
["places", zPlacesRecord],
["posts", zPostsRecord],
["products", zProductsRecord],
["services", zServicesRecord],
["testimonies", zTestimoniesRecord],
]);
/******* CLIENT *******/
export type TypedPocketbase = Pocketbase & {
collection(idOrName: "config"): RecordService<z.input<typeof zConfigRecord>>;
collection(idOrName: "events"): RecordService<z.input<typeof zEventsRecord>>;
collection(idOrName: "images"): RecordService<z.input<typeof zImagesRecord>>;
collection(idOrName: "knowledges"): RecordService<z.input<typeof zKnowledgesRecord>>;
collection(idOrName: "pages"): RecordService<z.input<typeof zPagesRecord>>;
collection(idOrName: "places"): RecordService<z.input<typeof zPlacesRecord>>;
collection(idOrName: "posts"): RecordService<z.input<typeof zPostsRecord>>;
collection(idOrName: "products"): RecordService<z.input<typeof zProductsRecord>>;
collection(idOrName: "services"): RecordService<z.input<typeof zServicesRecord>>;
collection(idOrName: "testimonies"): RecordService<z.input<typeof zTestimoniesRecord>>;
};

View File

@@ -0,0 +1,21 @@
// @ts-ignore
import Fetch from "@11ty/eleventy-fetch";
import { defineMiddleware } from "astro:middleware";
import { ZOD_POCKETBASE_URL } from "astro:env/server";
import Pocketbase from "pocketbase";
import { helpersFrom } from "zod-pocketbase";
export const onRequest = defineMiddleware((context, next) => {
const { getRecord, getRecords } = helpersFrom({
pocketbase: new Pocketbase(ZOD_POCKETBASE_URL),
fetch: import.meta.env.DEV
? async (url, fetchOptions) => {
const { body, ...init } = await Fetch(url, { fetchOptions, returnType: "response", type: "json" });
return new Response(JSON.stringify(body), init);
}
: undefined,
});
context.locals.getRecord = getRecord;
context.locals.getRecords = getRecords;
return next();
});

View File

@@ -0,0 +1,19 @@
---
import { COLLECTION, zKnowledgesRecord } from "@/lib/pocketbase";
const { items } = await Astro.locals.getRecords(COLLECTION.knowledges, { schema: zKnowledgesRecord, sort: "+name" });
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Astro Basics</title>
</head>
<body>
<ul>{items.map(({ name }) => <li>{name}</li>)}</ul>
</body>
</html>

11
playground/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View File

@@ -0,0 +1,8 @@
import { pascalCase } from "es-toolkit";
import type { Config } from "zod-pocketbase";
export default {
ignore: ["users"],
nameRecordSchema: (name: string) => `z${pascalCase(name)}Record`,
output: "./src/lib/pocketbase.ts",
} satisfies Config;

8566
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
packages:
- doc
- package
- playground

6
prettier.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import("prettier").Config} */
export default {
printWidth: 140,
plugins: ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
overrides: [{ files: "*.astro", options: { parser: "astro" } }],
};

51
scripts/release.mjs Normal file
View File

@@ -0,0 +1,51 @@
import { spawn } from "node:child_process";
import { resolve } from "node:path";
/**
*
* @param {string} command
* @param {...Array<string>} args
*
* @returns {Promise<string>}
*/
const run = async (command, ...args) => {
const cwd = resolve();
return new Promise((resolve) => {
const cmd = spawn(command, args, {
stdio: ["inherit", "pipe", "pipe"], // Inherit stdin, pipe stdout, pipe stderr
shell: true,
cwd,
});
let output = "";
cmd.stdout.on("data", (data) => {
process.stdout.write(data.toString());
output += data.toString();
});
cmd.stderr.on("data", (data) => {
process.stderr.write(data.toString());
});
cmd.on("close", () => {
resolve(output);
});
});
};
const main = async () => {
await run("pnpm changeset version");
await run("git add .");
await run('git commit -m "chore: update version"');
await run("git push");
await run("pnpm --filter zod-pocketbase build");
await run("pnpm changeset publish");
await run("git push --follow-tags");
const tag = (await run("git describe --abbrev=0")).replace("\n", "");
await run(
`gh release create ${tag} --title ${tag} --notes "Please refer to [CHANGELOG.md](https://github.com/gbouteiller/zod-pocketbase/blob/main/package/CHANGELOG.md) for details."`,
);
};
main();