1 Commits

Author SHA1 Message Date
8b1e9d1cf6 migrate from eslint & prettier to biome 2025-10-18 14:07:07 +02:00
23 changed files with 778 additions and 625 deletions

View File

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

9
.zed/settings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"lsp": {
"biome": {
"settings": {
"config_path": "./biome.json"
}
}
}
}

View File

@@ -55,7 +55,7 @@ export const pbJsonField = (maxSizeInBytes: number = 1048576) => {
} }
const num = Number(val); const num = Number(val);
if (!isNaN(num) && isFinite(num) && val.trim() !== '') if (!Number.isNaN(num) && Number.isFinite(num) && val.trim() !== '')
return num; return num;
if (val.startsWith('"') && val.endsWith('"')) if (val.startsWith('"') && val.endsWith('"'))

41
biome.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.6/schema.json",
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/node_modules", "!**/assets/stubs/index.ts"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"bracketSameLine": false,
"bracketSpacing": true,
"expand": "auto",
"useEditorconfig": true
},
"linter": { "enabled": true, "rules": { "recommended": false } },
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "asNeeded",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"html": { "formatter": { "selfCloseVoidElements": "always" } },
"overrides": [{ "includes": ["**/*.ts", "**/*.tsx"] }],
"assist": {
"enabled": true,
"actions": { "source": { "organizeImports": "on" } }
}
}

View File

@@ -10,6 +10,7 @@
"es-toolkit": "^1.40.0", "es-toolkit": "^1.40.0",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.6",
"@changesets/cli": "^2.29.7", "@changesets/cli": "^2.29.7",
"@types/node": "^24.7.1", "@types/node": "^24.7.1",
"@typescript-eslint/parser": "^8.46.0", "@typescript-eslint/parser": "^8.46.0",
@@ -38,6 +39,24 @@
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], "@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@biomejs/biome": ["@biomejs/biome@2.2.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.6", "@biomejs/cli-darwin-x64": "2.2.6", "@biomejs/cli-linux-arm64": "2.2.6", "@biomejs/cli-linux-arm64-musl": "2.2.6", "@biomejs/cli-linux-x64": "2.2.6", "@biomejs/cli-linux-x64-musl": "2.2.6", "@biomejs/cli-win32-arm64": "2.2.6", "@biomejs/cli-win32-x64": "2.2.6" }, "bin": { "biome": "bin/biome" } }, "sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="],
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.0.13", "", { "dependencies": { "@changesets/config": "^3.1.1", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-BIW7bofD2yAWoE8H4V40FikC+1nNFEKBisMECccS16W1rt6qqhNTBDmIw5HaqmMgtLNz9e7oiALiEUuKrQ4oHg=="], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.0.13", "", { "dependencies": { "@changesets/config": "^3.1.1", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-BIW7bofD2yAWoE8H4V40FikC+1nNFEKBisMECccS16W1rt6qqhNTBDmIw5HaqmMgtLNz9e7oiALiEUuKrQ4oHg=="],
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="],

View File

@@ -1,22 +0,0 @@
import typescript from "@typescript-eslint/eslint-plugin";
import typescriptParser from "@typescript-eslint/parser";
export default [
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.json",
},
},
plugins: {
"@typescript-eslint": typescript,
},
rules: {
...typescript.configs.recommended.rules,
},
},
];

View File

@@ -1,73 +1,65 @@
{ {
"name": "zod-pocketbase-continue", "name": "zod-pocketbase-continue",
"version": "0.5.0", "version": "0.5.1",
"description": "Zod tooling for your PocketBase instance.", "description": "Zod tooling for your PocketBase instance.",
"author": { "author": {
"email": "garandplg@garandplg.com", "email": "garandplg@garandplg.com",
"name": "Garand_PLG", "name": "Garand_PLG",
"url": "https://gitea.garandplg.com" "url": "https://gitea.garandplg.com"
}, },
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": ["pocketbase", "schemas", "typescript", "typegen", "type generation", "zod"],
"pocketbase", "homepage": "https://gitea.garandplg.com/GarandPLG/zod-pocketbase-continue",
"schemas", "publishConfig": {
"typescript", "access": "public"
"typegen", },
"type generation", "type": "module",
"zod" "sideEffects": false,
], "packageManager": "bun@1.2.23",
"homepage": "https://gitea.garandplg.com/GarandPLG/zod-pocketbase-continue", "engines": {
"publishConfig": { "node": ">=24.9.0"
"access": "public" },
}, "main": "dist/index.js",
"type": "module", "bin": {
"sideEffects": false, "zod-pocketbase-continue": "dist/server/cli.js"
"packageManager": "bun@1.2.23", },
"engines": { "exports": {
"node": ">=24.9.0" ".": {
}, "types": "./dist/index.d.ts",
"main": "dist/index.js", "default": "./dist/index.js"
"bin": { },
"zod-pocketbase-continue": "dist/server/cli.js" "./server": {
}, "types": "./dist/server/index.d.ts",
"exports": { "default": "./dist/server/index.js"
".": { }
"types": "./dist/index.d.ts", },
"default": "./dist/index.js" "files": ["dist", "assets"],
}, "scripts": {
"./server": { "dev": "tsdown --watch",
"types": "./dist/server/index.d.ts", "build": "tsdown",
"default": "./dist/server/index.js" "changeset": "changeset",
} "release": "bun scripts/release.mjs",
}, "lint": "biome check .",
"files": [ "lint:w": "biome check --write .",
"dist", "format": "biome format .",
"assets" "format:w": "biome format --write ."
], },
"scripts": { "dependencies": {
"dev": "tsdown --watch", "@clack/prompts": "^0.11.0",
"build": "tsdown", "c12": "^3.3.0",
"changeset": "changeset", "citty": "^0.1.6",
"release": "bun scripts/release.mjs" "es-toolkit": "^1.40.0"
}, },
"dependencies": { "devDependencies": {
"@clack/prompts": "^0.11.0", "@biomejs/biome": "2.2.6",
"c12": "^3.3.0", "@changesets/cli": "^2.29.7",
"citty": "^0.1.6", "@types/node": "^24.7.1",
"es-toolkit": "^1.40.0" "pocketbase": "^0.26.2",
}, "tsdown": "^0.15.6",
"devDependencies": { "zod": "^3.25.76"
"@changesets/cli": "^2.29.7", },
"@types/node": "^24.7.1", "peerDependencies": {
"@typescript-eslint/parser": "^8.46.0", "pocketbase": "^0.26.2",
"eslint": "^9.37.0", "zod": "^3.25.76"
"pocketbase": "^0.26.2", }
"prettier": "^3.6.2",
"tsdown": "^0.15.6",
"zod": "^3.25.76"
},
"peerDependencies": {
"pocketbase": "^0.26.2",
"zod": "^3.25.76"
}
} }

View File

@@ -1,4 +0,0 @@
/** @type {import("prettier").Config} */
export default {
printWidth: 140,
};

View File

@@ -1,5 +1,5 @@
import { spawn } from "node:child_process"; import { spawn } from 'node:child_process'
import { resolve } from "node:path"; import { resolve } from 'node:path'
/** /**
* *
@@ -9,41 +9,41 @@ import { resolve } from "node:path";
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
const run = async (command, ...args) => { const run = async (command, ...args) => {
const cwd = resolve(); const cwd = resolve()
return new Promise((resolve) => { return new Promise((resolve) => {
const cmd = spawn(command, args, { const cmd = spawn(command, args, {
stdio: ["inherit", "pipe", "pipe"], // Inherit stdin, pipe stdout, pipe stderr stdio: ['inherit', 'pipe', 'pipe'], // Inherit stdin, pipe stdout, pipe stderr
shell: true, shell: true,
cwd, cwd,
}); })
let output = ""; let output = ''
cmd.stdout.on("data", (data) => { cmd.stdout.on('data', (data) => {
process.stdout.write(data.toString()); process.stdout.write(data.toString())
output += data.toString(); output += data.toString()
}); })
cmd.stderr.on("data", (data) => { cmd.stderr.on('data', (data) => {
process.stderr.write(data.toString()); process.stderr.write(data.toString())
}); })
cmd.on("close", () => { cmd.on('close', () => {
resolve(output); resolve(output)
}); })
}); })
}; }
const main = async () => { const main = async () => {
await run("bun changeset version"); await run('bun changeset version')
await run("git add ."); await run('git add .')
await run('git commit -m "chore: update version"'); await run('git commit -m "chore: update version"')
await run("git push"); await run('git push')
await run("bun run build"); await run('bun run build')
await run("bun changeset publish"); await run('bun changeset publish')
await run("git push --follow-tags"); await run('git push --follow-tags')
const tag = (await run("git describe --abbrev=0")).replace("\n", ""); const tag = (await run('git describe --abbrev=0')).replace('\n', '')
await run(`tea releases create --tag ${tag} --title "${tag}"`); await run(`tea releases create --tag ${tag} --title "${tag}"`)
}; }
main(); main()

View File

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

View File

@@ -1,171 +1,199 @@
import { sortBy } from "es-toolkit"; import { sortBy } from 'es-toolkit'
import type { CollectionModel, CollectionField } from "pocketbase"; import type { CollectionField, CollectionModel } from 'pocketbase'
import type { GenerateOpts } from "@/server/utils.ts"; import type { GenerateOpts } from '@/server/utils.ts'
export function stringifyContent(collections: CollectionModel[], opts: GenerateOpts) { export function stringifyContent(collections: CollectionModel[], opts: GenerateOpts) {
function getCollectionSelectFields() { function getCollectionSelectFields() {
return collections.flatMap((collection) => return collections.flatMap((collection) =>
collection.fields collection.fields
.filter((field: CollectionField) => field.type === "select") .filter((field: CollectionField) => field.type === 'select')
.map((field: CollectionField) => ({ .map((field: CollectionField) => ({
name: opts.nameEnumField(collection.name, field.name), name: opts.nameEnumField(collection.name, field.name),
values: ((field as any).values ?? []) as string[], values: ((field as any).values ?? []) as string[],
})), })),
); )
} }
function stringifyEnum({ name, values }: SelectField) { function stringifyEnum({ name, values }: SelectField) {
const valuesName = opts.nameEnumValues(name); const valuesName = opts.nameEnumValues(name)
const schemaName = opts.nameEnumSchema(name); const schemaName = opts.nameEnumSchema(name)
const enumName = opts.nameEnum(name); const enumName = opts.nameEnum(name)
const typeName = opts.nameEnumType(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;`; 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, fields }: CollectionModel) { function stringifyRecord({ name, fields }: CollectionModel) {
const schemaName = opts.nameRecordSchema(name); const schemaName = opts.nameRecordSchema(name)
const typeName = opts.nameRecordType(name); const typeName = opts.nameRecordType(name)
const systemFields = new Set(["id", "created", "updated", "collectionId", "collectionName", "expand", "password", "tokenKey"]); const systemFields = new Set([
'id',
'created',
'updated',
'collectionId',
'collectionName',
'expand',
'password',
'tokenKey',
])
const customFields = fields.filter((field) => !systemFields.has(field.name)); const customFields = fields.filter((field) => !systemFields.has(field.name))
const fieldStrings = sortBy(customFields, ["name"]).map((field) => stringifyField(field, name)); const fieldStrings = sortBy(customFields, ['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${fieldStrings.join(",\n\t")},\n});\nexport type ${typeName} = z.infer<typeof ${schemaName}>;`; 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 stringifySchemasEntry({ name }: CollectionModel) { function stringifySchemasEntry({ name }: CollectionModel) {
return `["${name}", ${opts.nameRecordSchema(name)}]`; return `["${name}", ${opts.nameRecordSchema(name)}]`
} }
function stringifyService({ name }: CollectionModel) { function stringifyService({ name }: CollectionModel) {
return `\t\tcollection(idOrName: "${name}"): RecordService<z.input<typeof ${opts.nameRecordSchema(name)}>>;`; return `\t\tcollection(idOrName: "${name}"): RecordService<z.input<typeof ${opts.nameRecordSchema(name)}>>;`
} }
function stringifyField(field: CollectionField, collectionName: string) { function stringifyField(field: CollectionField, collectionName: string) {
let schema: string; let schema: string
switch (field.type) { switch (field.type) {
case "bool": case 'bool':
schema = "z.boolean()"; schema = 'z.boolean()'
break; break
case "date": case 'date':
const minConstraintDate = field.min ? `.min(new Date("${field.min}"))` : ""; const minConstraintDate = field.min ? `.min(new Date("${field.min}"))` : ''
const maxConstraintDate = field.max ? `.max(new Date("${field.max}"))` : ""; const maxConstraintDate = field.max ? `.max(new Date("${field.max}"))` : ''
schema = `z.string().pipe(z.coerce.date()${minConstraintDate}${maxConstraintDate})`; schema = `z.string().pipe(z.coerce.date()${minConstraintDate}${maxConstraintDate})`
break; break
case "editor": case 'editor':
// TODO: implement convertUrls // TODO: implement convertUrls
schema = "z.string()"; schema = 'z.string()'
break; break
case "email": case 'email':
const onlyDomainsConstraint = createDomainConstraint(field.onlyDomains, true, "email"); const onlyDomainsConstraint = createDomainConstraint(field.onlyDomains, true, 'email')
const exceptDomainsConstraint = createDomainConstraint(field.exceptDomains, false, "email"); const exceptDomainsConstraint = createDomainConstraint(field.exceptDomains, false, 'email')
schema = `z.string().email()${onlyDomainsConstraint}${exceptDomainsConstraint}`; schema = `z.string().email()${onlyDomainsConstraint}${exceptDomainsConstraint}`
break; break
case "file": case 'file':
const maxSelectFile: number = field.maxSelect; const maxSelectFile: number = field.maxSelect
const maxSizeFile: number = field.maxSize; const maxSizeFile: number = field.maxSize
const mimeTypesArray: string[] = field.mimeTypes || []; const mimeTypesArray: string[] = field.mimeTypes || []
// const protectedFile: boolean = field.protected; // const protectedFile: boolean = field.protected;
// const thumbsFileArray: string[] = field.thumbs || []; // const thumbsFileArray: string[] = field.thumbs || [];
const fileFieldMaxSelect = maxSelectFile ? `.max(${maxSelectFile})` : ""; const fileFieldMaxSelect = maxSelectFile ? `.max(${maxSelectFile})` : ''
const fileFieldTypeArray = maxSelectFile === 1 ? "" : `.array()${fileFieldMaxSelect}`; const fileFieldTypeArray = maxSelectFile === 1 ? '' : `.array()${fileFieldMaxSelect}`
let fileValidation = "z.instanceof(File)"; let fileValidation = 'z.instanceof(File)'
if (maxSizeFile) fileValidation += `.refine((file) => file.size <= ${maxSizeFile}, { message: "File size too large" })`; if (maxSizeFile)
fileValidation += `.refine((file) => file.size <= ${maxSizeFile}, { message: "File size too large" })`
if (mimeTypesArray.length > 0) if (mimeTypesArray.length > 0)
fileValidation += `.refine((file) => ${JSON.stringify(mimeTypesArray)}.includes(file.type), { message: "Invalid file type" })`; fileValidation += `.refine((file) => ${JSON.stringify(mimeTypesArray)}.includes(file.type), { message: "Invalid file type" })`
const baseFileSchema = `z.union([z.string(), ${fileValidation}])`; const baseFileSchema = `z.union([z.string(), ${fileValidation}])`
schema = `${baseFileSchema}${fileFieldTypeArray}`; schema = `${baseFileSchema}${fileFieldTypeArray}`
break; break
case "json": case 'json':
schema = field.maxSize > 0 ? `pbJsonField(${field.maxSize})` : "pbJsonField()"; schema = field.maxSize > 0 ? `pbJsonField(${field.maxSize})` : 'pbJsonField()'
break; break
case "number": case 'number':
const maxNumber = field.maxNumber ? `.max(${field.maxNumber})` : ""; const maxNumber = field.maxNumber ? `.max(${field.maxNumber})` : ''
const minNumber = field.minNumber ? `.min(${field.minNumber})` : ""; const minNumber = field.minNumber ? `.min(${field.minNumber})` : ''
const noDecimal = field.noDecimal ? ".int()" : ""; const noDecimal = field.noDecimal ? '.int()' : ''
schema = `z.number()${noDecimal}${minNumber}${maxNumber}`; schema = `z.number()${noDecimal}${minNumber}${maxNumber}`
break; break
case "relation": case 'relation':
// TODO: implement cascadeDelete, displayFields, multiple records query // TODO: implement cascadeDelete, displayFields, multiple records query
const multiple = field.maxSelect === 1 ? "" : `.array().min(${field.minSelect}).max(${field.maxSelect})`; const multiple =
const isOptional = field.required || field.maxSelect !== 1 ? `` : `.transform((id: string) => id === "" ? undefined : id)`; 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}`; schema = `z.string()${isOptional}${multiple}`
break; break
case "select": case 'select':
const maxSelect = field.maxSelect === 1 ? "" : `.array().max(${field.maxSelect})`; const maxSelect = field.maxSelect === 1 ? '' : `.array().max(${field.maxSelect})`
schema = `${opts.nameEnumSchema(opts.nameEnumField(collectionName, field.name))}${maxSelect}`; schema = `${opts.nameEnumSchema(opts.nameEnumField(collectionName, field.name))}${maxSelect}`
break; break
case "text": case 'text':
const patternText = const patternText =
field.pattern && field.pattern.trim() !== "" ? `.regex(new RegExp("${field.pattern.replace(/"/g, '\\"')}"))` : ""; field.pattern && field.pattern.trim() !== ''
const maxText = field.max ? `.max(${field.max})` : ""; ? `.regex(new RegExp("${field.pattern.replace(/"/g, '\\"')}"))`
const minText = field.min ? `.min(${field.min})` : ""; : ''
const maxText = field.max ? `.max(${field.max})` : ''
const minText = field.min ? `.min(${field.min})` : ''
schema = `z.string()${minText}${maxText}${patternText}`; schema = `z.string()${minText}${maxText}${patternText}`
break; break
case "url": case 'url':
const onlyDomainsUrlConstraint = createDomainConstraint(field.onlyDomains, true, "url"); const onlyDomainsUrlConstraint = createDomainConstraint(field.onlyDomains, true, 'url')
const exceptDomainsUrlConstraint = createDomainConstraint(field.exceptDomains, false, "url"); const exceptDomainsUrlConstraint = createDomainConstraint(field.exceptDomains, false, 'url')
schema = `z.string().url()${onlyDomainsUrlConstraint}${exceptDomainsUrlConstraint}`; schema = `z.string().url()${onlyDomainsUrlConstraint}${exceptDomainsUrlConstraint}`
break; break
case "geoPoint": case 'geoPoint':
schema = "z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180) })"; schema =
break; 'z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180) })'
break
default: default:
console.warn(`Unknown field type "${field.type}" for field "${field.name}". Using z.any() as fallback.`); console.warn(
schema = "z.any()"; `Unknown field type "${field.type}" for field "${field.name}". Using z.any() as fallback.`,
break; )
} schema = 'z.any()'
return `${field.name}: ${schema}${(field as any).required ? "" : ".optional()"}`; break
} }
return `${field.name}: ${schema}${(field as any).required ? '' : '.optional()'}`
}
/* Helpers */ /* Helpers */
const createDomainConstraint = (domains: string[], isWhitelist: boolean, type: "email" | "url") => { const createDomainConstraint = (
if (!domains?.length) return ""; domains: string[],
isWhitelist: boolean,
type: 'email' | 'url',
) => {
if (!domains?.length) return ''
const domainsList = domains.map((domain) => `"${domain}"`).join(", "); const domainsList = domains.map((domain) => `"${domain}"`).join(', ')
const messageType = isWhitelist ? "isn't one of the allowed ones" : "is one of the disallowed ones"; const messageType = isWhitelist
const negation = 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 domainExtraction =
type === 'email'
? 'const domain = value.split("@")[1];'
: 'const domain = new URL(value).hostname;'
const errorHandling = type === "url" ? "try { " : ""; const errorHandling = type === 'url' ? 'try { ' : ''
const errorCatch = type === "url" ? " } catch { return false; }" : ""; 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 `.refine((value: string) => { ${errorHandling}${domainExtraction} const domainsArray = [${domainsList}]; return domain && ${negation}domainsArray.includes(domain);${errorCatch} }, { message: "Invalid ${type}, domain ${messageType}" })`
}; }
return { return {
collectionNames: `[\n\t${collections.map(({ name }) => `"${name}"`).join(",\n\t")},\n]`, collectionNames: `[\n\t${collections.map(({ name }) => `"${name}"`).join(',\n\t')},\n]`,
enums: getCollectionSelectFields().map(stringifyEnum).join("\n\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]);`, 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"), services: collections.map(stringifyService).join('\n'),
}; }
} }
export type SelectField = { name: string; values: string[] }; export type SelectField = { name: string; values: string[] }

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
import { z, ZodOptional, ZodEffects } from "zod"; import type { AnyZodObject, objectUtil, ZodObject, ZodRawShape } from 'zod'
import type { AnyZodObject, objectUtil, ZodObject, ZodRawShape } from "zod"; import { ZodEffects, ZodOptional, z } from 'zod'
import type { AnyZodRecord, HasRequiredKeys, ZodRecordKeys } from "@/types.ts"; import type { AnyZodRecord, HasRequiredKeys, ZodRecordKeys } from '@/types.ts'
/** /**
* Records list schema. * Records list schema.
*/ */
export const AnyRecordsList = z.object({ export const AnyRecordsList = z.object({
items: z.any().array(), items: z.any().array(),
page: z.number().int().min(1), page: z.number().int().min(1),
perPage: z.number().int().min(1), perPage: z.number().int().min(1),
totalItems: z.number().int().min(-1), totalItems: z.number().int().min(-1),
totalPages: z.number().int().min(-1), totalPages: z.number().int().min(-1),
}); })
/** /**
* Extends the given schema with the given expansion. * Extends the given schema with the given expansion.
@@ -20,10 +20,15 @@ export const AnyRecordsList = z.object({
* @returns A new schema extended with the given expansion * @returns A new schema extended with the given expansion
*/ */
export function expand<S extends AnyZodObject, E extends ZodRawShape>(schema: S, shape: E) { export function expand<S extends AnyZodObject, E extends ZodRawShape>(schema: S, shape: E) {
const isExpandOptional = Object.entries(shape).every(([, value]) => value instanceof z.ZodOptional); const isExpandOptional = Object.entries(shape).every(
return z ([, value]) => value instanceof z.ZodOptional,
.object({ ...schema.shape, expand: isExpandOptional ? z.object(shape).optional() : z.object(shape) }) )
.transform(({ expand, ...rest }) => ({ ...rest, ...(expand ?? {}) })) as ZodObjectExpand<S, E>; return z
.object({
...schema.shape,
expand: isExpandOptional ? z.object(shape).optional() : z.object(shape),
})
.transform(({ expand, ...rest }) => ({ ...rest, ...(expand ?? {}) })) as ZodObjectExpand<S, E>
} }
/** /**
@@ -33,7 +38,7 @@ export function expand<S extends AnyZodObject, E extends ZodRawShape>(schema: S,
* @returns A new schema with only the given keys * @returns A new schema with only the given keys
*/ */
export function pick<S extends AnyZodObject, K extends ZodRecordKeys<S>[]>(schema: S, keys: K) { 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>; return schema.pick(Object.fromEntries(keys.map((key) => [key, true]))) as ZodObjectPick<S, K>
} }
/** /**
@@ -44,29 +49,46 @@ export function pick<S extends AnyZodObject, K extends ZodRecordKeys<S>[]>(schem
* @returns A new schema with only the given keys * @returns A new schema with only the given keys
*/ */
export function select<S extends AnyZodObject, K extends ZodRecordKeys<S>[], E extends ZodRawShape>( export function select<S extends AnyZodObject, K extends ZodRecordKeys<S>[], E extends ZodRawShape>(
schema: S, schema: S,
keys: K, keys: K,
shape: E, shape: E,
): ZodObjectExpand<ZodObjectPick<S, K>, 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>[]>(
export function select<S extends AnyZodObject, K extends ZodRecordKeys<S>[], E extends ZodRawShape | undefined>( schema: S,
schema: S, keys: K,
keys: K, ): ZodObjectPick<S, K>
shape?: E, export function select<
) { S extends AnyZodObject,
return shape ? expand(pick(schema, keys), shape) : pick(schema, keys); 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> = export type ZodObjectExpand<S extends AnyZodObject, E extends ZodRawShape> = S extends ZodObject<
S extends ZodObject<infer T, infer U, infer C> infer T,
? ZodEffects< infer U,
ZodObject<objectUtil.extendShape<T, { expand: HasRequiredKeys<E> extends true ? ZodObject<E> : ZodOptional<ZodObject<E>> }>, U, C>, infer C
ZodObject<objectUtil.extendShape<T, E>, U, C>["_output"] >
> ? ZodEffects<
: never; 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>[]> = export type ZodObjectPick<
S extends ZodObject<infer T, infer U, infer C> ? ZodObject<Pick<T, K[number]>, U, C> : never; 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 AnyRecordsList = z.infer<typeof AnyRecordsList>
export type RecordsList<S extends AnyZodRecord> = Omit<AnyRecordsList, "items"> & { items: S["_output"][] }; export type RecordsList<S extends AnyZodRecord> = Omit<AnyRecordsList, 'items'> & {
items: S['_output'][]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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