migrate from eslint & prettier to biome

This commit is contained in:
2025-10-18 14:07:07 +02:00
parent 2412238236
commit 8b1e9d1cf6
23 changed files with 778 additions and 625 deletions

View File

@@ -1,66 +1,67 @@
import { pascalCase, snakeCase } from "es-toolkit";
import { z } from "zod";
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-continue.ts",
};
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-continue.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>;
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>;
...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>

View File

@@ -1,171 +1,199 @@
import { sortBy } from "es-toolkit";
import type { CollectionModel, CollectionField } from "pocketbase";
import type { GenerateOpts } from "@/server/utils.ts";
import { sortBy } from 'es-toolkit'
import type { CollectionField, CollectionModel } from 'pocketbase'
import type { GenerateOpts } from '@/server/utils.ts'
export function stringifyContent(collections: CollectionModel[], opts: GenerateOpts) {
function getCollectionSelectFields() {
return collections.flatMap((collection) =>
collection.fields
.filter((field: CollectionField) => field.type === "select")
.map((field: CollectionField) => ({
name: opts.nameEnumField(collection.name, field.name),
values: ((field as any).values ?? []) as string[],
})),
);
}
function getCollectionSelectFields() {
return collections.flatMap((collection) =>
collection.fields
.filter((field: CollectionField) => field.type === 'select')
.map((field: CollectionField) => ({
name: opts.nameEnumField(collection.name, field.name),
values: ((field as any).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 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, fields }: CollectionModel) {
const schemaName = opts.nameRecordSchema(name);
const typeName = opts.nameRecordType(name);
function stringifyRecord({ name, fields }: CollectionModel) {
const schemaName = opts.nameRecordSchema(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 fieldStrings = sortBy(customFields, ["name"]).map((field) => stringifyField(field, name));
const customFields = fields.filter((field) => !systemFields.has(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) {
return `["${name}", ${opts.nameRecordSchema(name)}]`;
}
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)}>>;`;
}
function stringifyService({ name }: CollectionModel) {
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;
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}"))` : "";
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;
schema = `z.string().pipe(z.coerce.date()${minConstraintDate}${maxConstraintDate})`
break
case "editor":
// TODO: implement convertUrls
schema = "z.string()";
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");
case 'email':
const onlyDomainsConstraint = createDomainConstraint(field.onlyDomains, true, 'email')
const exceptDomainsConstraint = createDomainConstraint(field.exceptDomains, false, 'email')
schema = `z.string().email()${onlyDomainsConstraint}${exceptDomainsConstraint}`;
break;
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 || [];
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}`;
const fileFieldMaxSelect = maxSelectFile ? `.max(${maxSelectFile})` : ''
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)
fileValidation += `.refine((file) => ${JSON.stringify(mimeTypesArray)}.includes(file.type), { message: "Invalid file type" })`;
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;
const baseFileSchema = `z.union([z.string(), ${fileValidation}])`
schema = `${baseFileSchema}${fileFieldTypeArray}`
break
case "json":
schema = field.maxSize > 0 ? `pbJsonField(${field.maxSize})` : "pbJsonField()";
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()" : "";
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;
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)`;
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;
schema = `z.string()${isOptional}${multiple}`
break
case "select":
const maxSelect = field.maxSelect === 1 ? "" : `.array().max(${field.maxSelect})`;
case 'select':
const maxSelect = field.maxSelect === 1 ? '' : `.array().max(${field.maxSelect})`
schema = `${opts.nameEnumSchema(opts.nameEnumField(collectionName, field.name))}${maxSelect}`;
break;
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})` : "";
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;
schema = `z.string()${minText}${maxText}${patternText}`
break
case "url":
const onlyDomainsUrlConstraint = createDomainConstraint(field.onlyDomains, true, "url");
const exceptDomainsUrlConstraint = createDomainConstraint(field.exceptDomains, false, "url");
case 'url':
const onlyDomainsUrlConstraint = createDomainConstraint(field.onlyDomains, true, 'url')
const exceptDomainsUrlConstraint = createDomainConstraint(field.exceptDomains, false, 'url')
schema = `z.string().url()${onlyDomainsUrlConstraint}${exceptDomainsUrlConstraint}`;
break;
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;
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()"}`;
}
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 */
/* Helpers */
const createDomainConstraint = (domains: string[], isWhitelist: boolean, type: "email" | "url") => {
if (!domains?.length) return "";
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 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 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; }" : "";
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 `.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"),
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"),
};
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[] };
export type SelectField = { name: string; values: string[] }

View File

@@ -1,30 +1,50 @@
import type { default as Pocketbase, SendOptions } from "pocketbase";
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";
import type { default as Pocketbase, SendOptions } from 'pocketbase'
import { fullListOptionsFrom, optionsFrom } from '@/options.ts'
import type { RecordsList } from '@/schemas.ts'
import { AnyRecordsList } from '@/schemas.ts'
import type {
AnyZodRecord,
RecordFullListOpts,
RecordIdRef,
RecordRef,
RecordSlugRef,
} from '@/types.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 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);
}
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 };
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 };
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 }

View File

@@ -1,6 +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";
export * from './config.js'
export * from './helpers.js'
export * from './options.js'
export * from './schemas.js'
export * from './types.ts'
export * from './utils.js'

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import type { AnyZodObject, ZodTypeAny } from "zod";
import type { AnyZodRecord, RecordFullListOpts, RecordListOpts } from "@/types.ts";
import type { AnyZodObject, ZodTypeAny } from 'zod'
import { z } from 'zod'
import type { AnyZodRecord, RecordFullListOpts, RecordListOpts } from '@/types.ts'
/**
* 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
*/
export function expandFrom<S extends AnyZodRecord>(schema: S) {
return expandFromRec(schema)
.sort((k1, k2) => (k1 < k2 ? -1 : 1))
.join(",");
return expandFromRec(schema)
.sort((k1, k2) => (k1 < k2 ? -1 : 1))
.join(',')
}
/**
@@ -21,9 +21,9 @@ export function expandFrom<S extends AnyZodRecord>(schema: S) {
* @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(",");
return fieldsFromRec(schema)
.sort((k1, k2) => (k1 < k2 ? -1 : 1))
.join(',')
}
/**
@@ -33,7 +33,7 @@ export function fieldsFrom<S extends AnyZodRecord>(schema: S) {
* @returns A new schema extended with the given expansion
*/
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
*/
export function listOptionsFrom<S extends AnyZodRecord>(schema: S, opts: RecordListOpts<S>) {
const { page = 1, perPage = 30, ...rest } = opts;
return { ...optionsFrom(schema), page, perPage, ...rest };
const { page = 1, perPage = 30, ...rest } = opts
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
* @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 });
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 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 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;
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;
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
}

View File

@@ -1,17 +1,17 @@
import { z, ZodOptional, ZodEffects } from "zod";
import type { AnyZodObject, objectUtil, ZodObject, ZodRawShape } from "zod";
import type { AnyZodRecord, HasRequiredKeys, ZodRecordKeys } from "@/types.ts";
import type { AnyZodObject, objectUtil, ZodObject, ZodRawShape } from 'zod'
import { ZodEffects, ZodOptional, z } 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),
});
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.
@@ -20,10 +20,15 @@ export const AnyRecordsList = z.object({
* @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>;
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>
}
/**
@@ -33,7 +38,7 @@ export function expand<S extends AnyZodObject, E extends ZodRawShape>(schema: S,
* @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>;
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
*/
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);
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 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 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"][] };
export type AnyRecordsList = z.infer<typeof AnyRecordsList>
export type RecordsList<S extends AnyZodRecord> = Omit<AnyRecordsList, 'items'> & {
items: S['_output'][]
}

View File

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

View File

@@ -1,92 +1,125 @@
#!/usr/bin/env bun
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 "@/server/utils.ts";
import { existsSync } from "node:fs";
import { existsSync } from 'node:fs'
import {
cancel,
confirm,
group,
intro,
isCancel,
log,
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() {
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) {
log.error("Invalid fields in your config file.");
onCancel();
}
return result.data!;
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) {
log.error('Invalid fields in your config file.')
onCancel()
}
return result.data!
}
function onCancel() {
cancel("Operation cancelled.");
process.exit(0);
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);
}
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();
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);
}
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;
return output as string
}
const main = defineCommand({
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();
const collections = await selectCollections(config);
const output = await setGeneratedFilePath(config);
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()
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.");
const s = spinner()
s.start('Generating your schemas...')
await generate(collections, { ...config, output })
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 { 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 { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CollectionModel } from 'pocketbase'
import type { ResolvedConfig } from '@/config.ts'
import { stringifyContent } from '@/content.js'
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);
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">;
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> = {
filter?: string;
page?: number;
perPage?: number;
skipTotal?: boolean;
sort?: ZodRecordSort<S>;
};
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 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 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<
{
[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;
{
[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

View File

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