11 Commits

17 changed files with 374 additions and 743 deletions

View File

@@ -1,9 +1,32 @@
# zod-pocketbase
# zod-pocketbase-continued
Zod tooling for your Pocketbase instance.
Zod tooling for your PocketBase instance.
To see how to get started, check out the [docs](https://zod-pocketbase.vercel.app)
This repository is a continuation of [Gregory Bouteiller's zod-pocketbase](https://github.com/gbouteiller/zod-pocketbase), as the original repository was outdated for 10 months.
## Licensing
## Documentation
[MIT Licensed](./LICENSE). Made with ❤️ by [Gregory Bouteiller](https://github.com/gbouteiller).
[Old documentation is compatible with the current version](https://zod-pocketbase.vercel.app)
## Compatibility
- **PocketBase**: v0.30.0
- **PocketBase JS SDK**: v0.26.2
## Changes from Original
### Project Structure
- Removed `doc/`, `playground/` and monorepo configuration
- Replaced `pnpm` and `node` in favor of `bun`
- Switched `.github/` to `.gitea/` and `gh` to `tea`
### Dependencies & Code
- Migrated from `tsup` to `tsdown`
- Updated all npm dependencies (except `zod`, which was only updated to the latest 3 version)
- Removed unused dependencies
- Fixed `getPocketbase` function in `sdk.ts` to match the latest PocketBase version
- Implemented most TODOs left in `content.ts`
## License
[MIT License](./LICENSE)

View File

@@ -33,6 +33,41 @@ export const RecordModel = z.object({
});
export type RecordModel = z.infer<typeof RecordModel>;
/******* JSON FIELD *******/
export const pbJsonField = (maxSizeInBytes: number = 1048576) => {
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
const jsonSchema: z.ZodType<any> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);
const stringTransform = z.string()
.max(maxSizeInBytes, `JSON field cannot exceed ${maxSizeInBytes} bytes`)
.transform((val: string) => {
if (val === "true") return true;
if (val === "false") return false;
if (val === "null") return null;
if ((val.startsWith('[') && val.endsWith(']'))||(val.startsWith('{') && val.endsWith('}')))
try {
return JSON.parse(val);
} catch {
return val;
}
const num = Number(val);
if (!isNaN(num) && isFinite(num) && val.trim() !== '')
return num;
if (val.startsWith('"') && val.endsWith('"'))
return val.slice(1, -1);
return val;
});
return z.union([jsonSchema, stringTransform]);
};
/******* RECORDS *******/
@@_RECORDS_@@

714
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
description = "Zod-PocketBase development environment with Bun";
description = "Zod-PocketBase-Continue development environment with Bun";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
@@ -30,7 +30,7 @@
];
shellHook = ''
echo "🚀 Zod-PocketBase development environment (Bun-powered)"
echo "🚀 Zod-PocketBase-Continue development environment (Bun-powered)"
echo "Bun version: $(bun --version)"
echo "Tea version: $(tea --version)"
echo ""
@@ -52,7 +52,7 @@
};
packages.default = pkgs.stdenv.mkDerivation {
name = "zod-pocketbase";
name = "zod-pocketbase-continue";
src = ./.;
buildInputs = [ bun ];

View File

@@ -1,7 +1,7 @@
{
"name": "zod-pocketbase",
"name": "zod-pocketbase-continue",
"version": "0.5.0",
"description": "",
"description": "Zod tooling for your PocketBase instance.",
"author": {
"email": "garandplg@garandplg.com",
"name": "Garand_PLG",
@@ -16,7 +16,7 @@
"type generation",
"zod"
],
"homepage": "https://gitea.garandplg.com/GarandPLG/zod-pocketbase",
"homepage": "https://gitea.garandplg.com/GarandPLG/zod-pocketbase-continue",
"publishConfig": {
"access": "public"
},
@@ -28,7 +28,7 @@
},
"main": "dist/index.js",
"bin": {
"zod-pocketbase": "dist/server/cli.js"
"zod-pocketbase-continue": "dist/server/cli.js"
},
"exports": {
".": {
@@ -45,8 +45,8 @@
"assets"
],
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"dev": "tsdown --watch",
"build": "tsdown",
"changeset": "changeset",
"release": "bun scripts/release.mjs"
},
@@ -54,20 +54,16 @@
"@clack/prompts": "^0.11.0",
"c12": "^3.3.0",
"citty": "^0.1.6",
"es-toolkit": "^1.39.10"
"es-toolkit": "^1.40.0"
},
"devDependencies": {
"@changesets/cli": "^2.29.7",
"@types/node": "^24.6.2",
"@typescript-eslint/parser": "^8.45.0",
"@types/node": "^24.7.1",
"@typescript-eslint/parser": "^8.46.0",
"eslint": "^9.37.0",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"pocketbase": "^0.26.2",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tsup": "^8.5.0",
"tsdown": "^0.15.6",
"zod": "^3.25.76"
},
"peerDependencies": {

View File

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

View File

@@ -13,7 +13,7 @@ export const defaultConfig = {
nameEnumValues: (name: string) => `${name}Values`,
nameRecordSchema: (name: string) => `${pascalCase(name)}Record`,
nameRecordType: (name: string) => `${pascalCase(name)}Record`,
output: "./zod-pocketbase.ts",
output: "./zod-pocketbase-continue.ts",
};
/**

View File

@@ -1,6 +1,6 @@
import { sortBy } from "es-toolkit";
import type { CollectionModel, CollectionField } from "pocketbase";
import type { GenerateOpts } from "./server/utils.ts";
import type { GenerateOpts } from "@/server/utils.ts";
export function stringifyContent(collections: CollectionModel[], opts: GenerateOpts) {
function getCollectionSelectFields() {
@@ -9,7 +9,7 @@ export function stringifyContent(collections: CollectionModel[], opts: GenerateO
.filter((field: CollectionField) => field.type === "select")
.map((field: CollectionField) => ({
name: opts.nameEnumField(collection.name, field.name),
values: ((field as any).options?.values ?? []) as string[],
values: ((field as any).values ?? []) as string[],
})),
);
}
@@ -26,17 +26,7 @@ export function stringifyContent(collections: CollectionModel[], opts: GenerateO
const schemaName = opts.nameRecordSchema(name);
const typeName = opts.nameRecordType(name);
// Filter out system fields that are already inherited from RecordModel or should be omitted
const systemFields = new Set([
"id",
"created",
"updated",
"collectionId",
"collectionName",
"expand", // inherited from BaseModel/RecordModel
"password",
"tokenKey", // should not be in response schema
]);
const systemFields = new Set(["id", "created", "updated", "collectionId", "collectionName", "expand", "password", "tokenKey"]);
const customFields = fields.filter((field) => !systemFields.has(field.name));
const fieldStrings = sortBy(customFields, ["name"]).map((field) => stringifyField(field, name));
@@ -44,125 +34,6 @@ export function stringifyContent(collections: CollectionModel[], opts: GenerateO
return `export const ${schemaName} = z.object({\n\t...RecordModel.omit({ expand: true }).shape,\n\tcollectionName: z.literal("${name}"),\n\t${fieldStrings.join(",\n\t")},\n});\nexport type ${typeName} = z.infer<typeof ${schemaName}>;`;
}
function stringifyField(field: CollectionField, collectionName: string) {
let schema: string;
// TODO:
console.log(`${collectionName}: ${field.type}`);
switch (field.type) {
case "bool":
schema = stringifyBoolField(field);
break;
case "date":
schema = stringifyDateField(field);
break;
case "editor":
schema = stringifyEditorField(field);
break;
case "email":
schema = stringifyEmailField(field);
break;
case "file":
schema = stringifyFileField(field);
break;
case "json":
schema = stringifyJsonField(field);
break;
case "number":
schema = stringifyNumberField(field);
break;
case "relation":
schema = stringifyRelationField(field);
break;
case "select":
schema = stringifySelectField(field, collectionName);
break;
case "text":
schema = stringifyTextField(field);
break;
case "url":
schema = stringifyUrlField(field);
break;
case "geoPoint":
schema = stringifyGeoPointField(field);
break;
default:
console.warn(`Unknown field type "${field.type}" for field "${field.name}". Using z.any() as fallback.`);
schema = "z.any()";
break;
}
return `${field.name}: ${schema}${(field as any).required ? "" : ".optional()"}`;
}
function stringifyBoolField(_: CollectionField) {
return "z.boolean()";
}
function stringifyDateField(_field: CollectionField) {
// TODO: implement min and max
return "z.string().pipe(z.coerce.date())";
}
function stringifyEditorField(_field: CollectionField) {
// TODO: implement convertUrls
return "z.string()";
}
function stringifyEmailField(_field: CollectionField) {
// TODO: implement exceptDomains and onlyDomains
return "z.string().email()";
}
function stringifyFileField(field: CollectionField) {
const maxSelect = (field as any).options?.maxSelect;
// TODO: implement maxSize, mimeTypes, protected, thumbs
return `z.string()${maxSelect === 1 ? "" : `.array()${maxSelect ? `.max(${maxSelect})` : ""}`}`;
}
function stringifyJsonField(_field: CollectionField) {
// TODO: implement maxSize and json schema
return "z.any()";
}
function stringifyNumberField(field: CollectionField) {
const options = (field as any).options || {};
const { max, min, noDecimal } = options;
return `z.number()${noDecimal ? ".int()" : ""}${min ? `.min(${min})` : ""}${max ? `.max(${max})` : ""}`;
}
function stringifyRelationField(field: CollectionField) {
const options = (field as any).options || {};
const required = (field as any).required;
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(field: CollectionField, collectionName: string) {
const maxSelect = (field as any).options?.maxSelect;
// TODO: implement values
return `${opts.nameEnumSchema(opts.nameEnumField(collectionName, field.name))}${maxSelect === 1 ? "" : `.array().max(${maxSelect})`}`;
}
function stringifyTextField(field: CollectionField) {
const options = (field as any).options || {};
const { max, min } = options;
// TODO: implement pattern
return `z.string()${min ? `.min(${min})` : ""}${max ? `.max(${max})` : ""}`;
}
function stringifyUrlField(_field: CollectionField) {
// TODO: implement exceptDomains and onlyDomains
return "z.string().url()";
}
function stringifyGeoPointField(_field: CollectionField) {
return "z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) })";
}
function stringifySchemasEntry({ name }: CollectionModel) {
return `["${name}", ${opts.nameRecordSchema(name)}]`;
}
@@ -171,6 +42,124 @@ export function stringifyContent(collections: CollectionModel[], opts: GenerateO
return `\t\tcollection(idOrName: "${name}"): RecordService<z.input<typeof ${opts.nameRecordSchema(name)}>>;`;
}
function stringifyField(field: CollectionField, collectionName: string) {
let schema: string;
switch (field.type) {
case "bool":
schema = "z.boolean()";
break;
case "date":
const minConstraintDate = field.min ? `.min(new Date("${field.min}"))` : "";
const maxConstraintDate = field.max ? `.max(new Date("${field.max}"))` : "";
schema = `z.string().pipe(z.coerce.date()${minConstraintDate}${maxConstraintDate})`;
break;
case "editor":
// TODO: implement convertUrls
schema = "z.string()";
break;
case "email":
const onlyDomainsConstraint = createDomainConstraint(field.onlyDomains, true, "email");
const exceptDomainsConstraint = createDomainConstraint(field.exceptDomains, false, "email");
schema = `z.string().email()${onlyDomainsConstraint}${exceptDomainsConstraint}`;
break;
case "file":
const maxSelectFile: number = field.maxSelect;
const maxSizeFile: number = field.maxSize;
const mimeTypesArray: string[] = field.mimeTypes || [];
// const protectedFile: boolean = field.protected;
// const thumbsFileArray: string[] = field.thumbs || [];
const fileFieldMaxSelect = maxSelectFile ? `.max(${maxSelectFile})` : "";
const fileFieldTypeArray = maxSelectFile === 1 ? "" : `.array()${fileFieldMaxSelect}`;
let fileValidation = "z.instanceof(File)";
if (maxSizeFile) fileValidation += `.refine((file) => file.size <= ${maxSizeFile}, { message: "File size too large" })`;
if (mimeTypesArray.length > 0)
fileValidation += `.refine((file) => ${JSON.stringify(mimeTypesArray)}.includes(file.type), { message: "Invalid file type" })`;
const baseFileSchema = `z.union([z.string(), ${fileValidation}])`;
schema = `${baseFileSchema}${fileFieldTypeArray}`;
break;
case "json":
schema = field.maxSize > 0 ? `pbJsonField(${field.maxSize})` : "pbJsonField()";
break;
case "number":
const maxNumber = field.maxNumber ? `.max(${field.maxNumber})` : "";
const minNumber = field.minNumber ? `.min(${field.minNumber})` : "";
const noDecimal = field.noDecimal ? ".int()" : "";
schema = `z.number()${noDecimal}${minNumber}${maxNumber}`;
break;
case "relation":
// TODO: implement cascadeDelete, displayFields, multiple records query
const multiple = field.maxSelect === 1 ? "" : `.array().min(${field.minSelect}).max(${field.maxSelect})`;
const isOptional = field.required || field.maxSelect !== 1 ? `` : `.transform((id: string) => id === "" ? undefined : id)`;
schema = `z.string()${isOptional}${multiple}`;
break;
case "select":
const maxSelect = field.maxSelect === 1 ? "" : `.array().max(${field.maxSelect})`;
schema = `${opts.nameEnumSchema(opts.nameEnumField(collectionName, field.name))}${maxSelect}`;
break;
case "text":
const patternText =
field.pattern && field.pattern.trim() !== "" ? `.regex(new RegExp("${field.pattern.replace(/"/g, '\\"')}"))` : "";
const maxText = field.max ? `.max(${field.max})` : "";
const minText = field.min ? `.min(${field.min})` : "";
schema = `z.string()${minText}${maxText}${patternText}`;
break;
case "url":
const onlyDomainsUrlConstraint = createDomainConstraint(field.onlyDomains, true, "url");
const exceptDomainsUrlConstraint = createDomainConstraint(field.exceptDomains, false, "url");
schema = `z.string().url()${onlyDomainsUrlConstraint}${exceptDomainsUrlConstraint}`;
break;
case "geoPoint":
schema = "z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180) })";
break;
default:
console.warn(`Unknown field type "${field.type}" for field "${field.name}". Using z.any() as fallback.`);
schema = "z.any()";
break;
}
return `${field.name}: ${schema}${(field as any).required ? "" : ".optional()"}`;
}
/* Helpers */
const createDomainConstraint = (domains: string[], isWhitelist: boolean, type: "email" | "url") => {
if (!domains?.length) return "";
const domainsList = domains.map((domain) => `"${domain}"`).join(", ");
const messageType = isWhitelist ? "isn't one of the allowed ones" : "is one of the disallowed ones";
const negation = isWhitelist ? "" : "!";
const domainExtraction = type === "email" ? 'const domain = value.split("@")[1];' : "const domain = new URL(value).hostname;";
const errorHandling = type === "url" ? "try { " : "";
const errorCatch = type === "url" ? " } catch { return false; }" : "";
return `.refine((value: string) => { ${errorHandling}${domainExtraction} const domainsArray = [${domainsList}]; return domain && ${negation}domainsArray.includes(domain);${errorCatch} }, { message: "Invalid ${type}, domain ${messageType}" })`;
};
return {
collectionNames: `[\n\t${collections.map(({ name }) => `"${name}"`).join(",\n\t")},\n]`,
enums: getCollectionSelectFields().map(stringifyEnum).join("\n\n"),

View File

@@ -1,7 +1,8 @@
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";
import { fullListOptionsFrom, optionsFrom } from "@/options.ts";
import type { AnyZodRecord, RecordFullListOpts, RecordIdRef, RecordRef, RecordSlugRef } from "@/types.ts";
import { AnyRecordsList } from "@/schemas.ts";
import 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"]>;

View File

@@ -1,5 +1,6 @@
import { z, type AnyZodObject, type ZodTypeAny } from "zod";
import type { AnyZodRecord, RecordFullListOpts, RecordListOpts } from "./types.ts";
import { z } from "zod";
import type { AnyZodObject, ZodTypeAny } from "zod";
import type { AnyZodRecord, RecordFullListOpts, RecordListOpts } from "@/types.ts";
/**
* Extends the given schema with the given expansion.

View File

@@ -1,5 +1,6 @@
import { type AnyZodObject, type objectUtil, z, type ZodEffects, type ZodObject, ZodOptional, type ZodRawShape } from "zod";
import type { AnyZodRecord, HasRequiredKeys, ZodRecordKeys } from "./types.ts";
import { z, ZodOptional, ZodEffects } from "zod";
import type { AnyZodObject, objectUtil, ZodObject, ZodRawShape } from "zod";
import type { AnyZodRecord, HasRequiredKeys, ZodRecordKeys } from "@/types.ts";
/**
* Records list schema.

View File

@@ -1,5 +1,5 @@
import Pocketbase from "pocketbase";
import type { Credentials } from "./config.ts";
import type { Credentials } from "@/config.ts";
let adminPocketbase: Pocketbase;

View File

@@ -1,16 +1,17 @@
#!/usr/bin/env bun
import { Config, type ResolvedConfig } from "../config.ts";
import { Config } from "@/config.ts";
import type { ResolvedConfig } from "@/config.ts";
import pkg from "../../package.json" with { 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 { fetchCollections } from "@/utils.ts";
import { generate } from "@/server/utils.ts";
import { existsSync } from "node:fs";
async function getConfig() {
const { config } = await loadConfig({ name: "zod-pocketbase", rcFile: false, dotenv: true });
const { config } = await loadConfig({ name: "zod-pocketbase-continue", 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) {
@@ -72,7 +73,7 @@ async function setGeneratedFilePath(config: ResolvedConfig) {
}
const main = defineCommand({
meta: { name: "zod-pocketbase", version: pkg.version, description: "Generate Zod schemas for your pocketbase instance." },
meta: { name: "zod-pocketbase-continue", version: pkg.version, description: "Generate Zod schemas for your pocketbase instance." },
run: async () => {
intro(`ZOD POCKETBASE`);
const config = await getConfig();

View File

@@ -2,8 +2,8 @@ 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";
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");

View File

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

View File

@@ -46,7 +46,11 @@
// 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
"allowUnusedLabels": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["dist", "assets/stubs"]
}

View File

@@ -1,4 +1,4 @@
import { defineConfig } from "tsup";
import { defineConfig } from "tsdown";
import packageJson from "./package.json" with { type: "json" };
export default defineConfig((options) => {
@@ -7,7 +7,7 @@ export default defineConfig((options) => {
entry: ["src/**/*.(ts|js)"],
format: ["esm"],
target: "node24",
bundle: true,
unbundle: true,
dts: true,
sourcemap: true,
clean: true,