UNPKG

pocketbase-typegen

Version:

Generate pocketbase record types from your database

521 lines (497 loc) 17.2 kB
#!/usr/bin/env node // src/cli.ts import dotenv from "dotenv-flow"; // src/schema.ts import { promises as fs } from "fs"; // src/sqlite.ts var isBun = typeof globalThis.Bun !== "undefined"; async function createNodeAdapter() { const BetterSqlite3 = (await import("better-sqlite3")).default; return { queryAll(dbPath, sql) { const db = new BetterSqlite3(dbPath, { readonly: true }); const rows = db.prepare(sql).all(); db.close(); return rows; } }; } async function createBunAdapter() { const { Database } = await import("bun:sqlite"); return { queryAll(dbPath, sql) { const db = new Database(dbPath, { readonly: true }); const rows = db.prepare(sql).all(); db.close(); return rows; } }; } async function getSQLiteAdapter() { if (isBun) { return createBunAdapter(); } return createNodeAdapter(); } // src/http.ts async function fetchWithAuth(url, token, fetchFn = globalThis.fetch) { const res = await fetchFn(url, { headers: { Authorization: token } }); if (!res?.ok) throw res; return res.json(); } async function loginAndFetch(url, email, password, fetchFn = globalThis.fetch) { const formData = new FormData(); formData.append("identity", email); formData.append("password", password); const loginRes = await fetchFn( `${url}/api/collections/_superusers/auth-with-password`, { body: formData, method: "POST" } ); if (!loginRes?.ok) throw loginRes; const loginData = await loginRes.json(); return fetchWithAuth( `${url}/api/collections?perPage=200`, loginData.token, fetchFn ); } // src/schema.ts async function fromDatabase(dbPath) { const adapter = await getSQLiteAdapter(); const result = adapter.queryAll(dbPath, "SELECT * FROM _collections"); return result.map((collection) => ({ ...collection, fields: JSON.parse(collection.fields ?? collection.schema ?? "{}") })); } async function fromJSON(path) { const schemaStr = await fs.readFile(path, { encoding: "utf8" }); return JSON.parse(schemaStr); } async function fromURLWithToken(url, token = "") { try { const result = await fetchWithAuth( `${url}/api/collections?perPage=200`, token ); return result.items; } catch (error) { throw new Error(`Failed to load schema from URL: ${error}`); } } async function fromURLWithPassword(url, email = "", password = "") { try { const result = await loginAndFetch( url, email, password ); return result.items; } catch (error) { throw new Error(`Failed to load schema from URL: ${error}`); } } // src/utils.ts import { promises as fs2 } from "fs"; function toPascalCase(str) { if (/^[\p{L}\d]+$/iu.test(str)) { return str.charAt(0).toUpperCase() + str.slice(1); } return str.replace( /([\p{L}\d])([\p{L}\d]*)/giu, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase() ).replace(/[^\p{L}\d]/giu, ""); } function sanitizeFieldName(name) { return !isNaN(parseFloat(name.charAt(0))) ? `"${name}"` : name; } async function saveFile(outPath, typeString) { await fs2.writeFile(outPath, typeString, "utf8"); console.log(`Created typescript definitions at ${outPath}`); } function getSystemFields(type) { switch (type) { case "auth": return "AuthSystemFields"; default: return "BaseSystemFields"; } } function getOptionEnumName(recordName, fieldName) { return `${toPascalCase(recordName)}${toPascalCase(fieldName)}Options`; } function getOptionValues(field) { const values = field.values; if (!values) return []; return values.filter((val, i) => values.indexOf(val) === i); } function containsGeoPoint(collections) { return collections.some( (collection) => collection.fields.some((field) => field.type === "geoPoint") ); } // src/collections.ts function createCollectionEnum(collectionNames) { const collections = collectionNames.map((name) => ` ${toPascalCase(name)}: "${name}",`).join("\n"); return `export const Collections = { ${collections} } as const export type Collections = typeof Collections[keyof typeof Collections]`; } function createCollectionRecords(collectionNames) { const nameRecordMap = collectionNames.map((name) => ` ${name}: ${toPascalCase(name)}Record`).join("\n"); return `export type CollectionRecords = { ${nameRecordMap} }`; } function createCollectionResponses(collectionNames) { const nameRecordMap = collectionNames.map((name) => ` ${name}: ${toPascalCase(name)}Response`).join("\n"); return `export type CollectionResponses = { ${nameRecordMap} }`; } // src/constants.ts var EXPORT_COMMENT = `/** * This file was @generated using pocketbase-typegen */`; var IMPORTS = `import type PocketBase from 'pocketbase' import type { RecordService } from 'pocketbase'`; var RECORD_TYPE_COMMENT = `// Record types for each collection`; var RESPONSE_TYPE_COMMENT = `// Response types include system fields and match responses from the PocketBase API`; var ALL_RECORD_RESPONSE_COMMENT = `// Types containing all Records and Responses, useful for creating typing helper functions`; var TYPED_POCKETBASE_TYPE = `// Type for usage with type asserted PocketBase instance // https://github.com/pocketbase/js-sdk#specify-typescript-definitions export type TypedPocketBase = { collection<T extends keyof CollectionResponses>( idOrName: T ): RecordService<CollectionResponses[T]> } & PocketBase`; var EXPAND_GENERIC_NAME = "expand"; var DATE_STRING_TYPE_NAME = `IsoDateString`; var AUTODATE_STRING_TYPE_NAME = `IsoAutoDateString`; var RECORD_ID_STRING_NAME = `RecordIdString`; var FILE_NAME_STRING_NAME = `FileNameString`; var HTML_STRING_NAME = `HTMLString`; var GEOPOINT_TYPE_NAME = `GeoPoint`; var ALIAS_TYPE_DEFINITIONS = `// Alias types for improved usability export type ${DATE_STRING_TYPE_NAME} = string export type ${AUTODATE_STRING_TYPE_NAME} = string & { readonly autodate: unique symbol } export type ${RECORD_ID_STRING_NAME} = string export type ${FILE_NAME_STRING_NAME} = string & { readonly filename: unique symbol } export type ${HTML_STRING_NAME} = string`; var BASE_SYSTEM_FIELDS_DEFINITION = `// System fields export type BaseSystemFields<T = unknown> = { id: ${RECORD_ID_STRING_NAME} collectionId: string collectionName: Collections } & ExpandType<T>`; var AUTH_SYSTEM_FIELDS_DEFINITION = `export type AuthSystemFields<T = unknown> = { email: string emailVisibility: boolean username: string verified: boolean } & BaseSystemFields<T>`; var EXPAND_TYPE_DEFINITION = `type ExpandType<T> = unknown extends T ? T extends unknown ? { expand?: unknown } : { expand: T } : { expand: T }`; var GEOPOINT_TYPE_DEFINITION = `export type ${GEOPOINT_TYPE_NAME} = { lon: number lat: number }`; var UTILITY_TYPES = `// Utility types for create/update operations type ProcessCreateAndUpdateFields<T> = Omit<{ // Omit AutoDate fields [K in keyof T as Extract<T[K], IsoAutoDateString> extends never ? K : never]: // Convert FileNameString to File T[K] extends infer U ? U extends (FileNameString | FileNameString[]) ? U extends any[] ? File[] : File : U : never }, 'id'> // Create type for Auth collections export type CreateAuth<T> = { id?: ${RECORD_ID_STRING_NAME} email: string emailVisibility?: boolean password: string passwordConfirm: string verified?: boolean } & ProcessCreateAndUpdateFields<T> // Create type for Base collections export type CreateBase<T> = { id?: RecordIdString } & ProcessCreateAndUpdateFields<T> // Update type for Auth collections export type UpdateAuth<T> = Partial< Omit<ProcessCreateAndUpdateFields<T>, keyof AuthSystemFields> > & { email?: string emailVisibility?: boolean oldPassword?: string password?: string passwordConfirm?: string verified?: boolean } // Update type for Base collections export type UpdateBase<T> = Partial< Omit<ProcessCreateAndUpdateFields<T>, keyof BaseSystemFields> > // Get the correct create type for any collection export type Create<T extends keyof CollectionResponses> = CollectionResponses[T] extends AuthSystemFields ? CreateAuth<CollectionRecords[T]> : CreateBase<CollectionRecords[T]> // Get the correct update type for any collection export type Update<T extends keyof CollectionResponses> = CollectionResponses[T] extends AuthSystemFields ? UpdateAuth<CollectionRecords[T]> : UpdateBase<CollectionRecords[T]>`; // src/generics.ts function fieldNameToGeneric(name) { return `T${name}`; } function getGenericArgList(schema) { const jsonFields = schema.filter((field) => field.type === "json").map((field) => fieldNameToGeneric(field.name)).sort(); return jsonFields; } function getGenericArgStringForRecord(schema) { const argList = getGenericArgList(schema); if (argList.length === 0) return ""; return `<${argList.map((name) => `${name}`).join(", ")}>`; } function getGenericArgStringWithDefault(schema, opts) { const argList = getGenericArgList(schema); if (opts.includeExpand) { argList.push(fieldNameToGeneric(EXPAND_GENERIC_NAME)); } if (argList.length === 0) return ""; return `<${argList.map((name) => `${name} = unknown`).join(", ")}>`; } // src/fields.ts var pbSchemaTypescriptMap = { // Basic fields bool: "boolean", date: DATE_STRING_TYPE_NAME, autodate: AUTODATE_STRING_TYPE_NAME, editor: HTML_STRING_NAME, email: "string", geoPoint: GEOPOINT_TYPE_NAME, text: "string", url: "string", password: "string", number: "number", // Dependent on schema file: (fieldSchema) => fieldSchema.maxSelect && fieldSchema.maxSelect > 1 ? `${FILE_NAME_STRING_NAME}[]` : `${FILE_NAME_STRING_NAME}`, json: (fieldSchema) => `null | ${fieldNameToGeneric(fieldSchema.name)}`, relation: (fieldSchema) => fieldSchema.maxSelect && fieldSchema.maxSelect > 1 ? `${RECORD_ID_STRING_NAME}[]` : RECORD_ID_STRING_NAME, select: (fieldSchema, collectionName) => { const valueType = fieldSchema.values ? getOptionEnumName(collectionName, fieldSchema.name) : "string"; return fieldSchema.maxSelect && fieldSchema.maxSelect > 1 ? `${valueType}[]` : valueType; }, // DEPRECATED: PocketBase v0.8 does not have a dedicated user relation user: (fieldSchema) => fieldSchema.maxSelect && fieldSchema.maxSelect > 1 ? `${RECORD_ID_STRING_NAME}[]` : RECORD_ID_STRING_NAME }; function createTypeField(collectionName, fieldSchema) { let typeStringOrFunc; if (!(fieldSchema.type in pbSchemaTypescriptMap)) { console.log(`WARNING: unknown type "${fieldSchema.type}" found in schema`); typeStringOrFunc = "unknown"; } else { typeStringOrFunc = pbSchemaTypescriptMap[fieldSchema.type]; } const typeString = typeof typeStringOrFunc === "function" ? typeStringOrFunc(fieldSchema, collectionName) : typeStringOrFunc; const fieldName = sanitizeFieldName(fieldSchema.name); const required = fieldSchema.type === "autodate" && !fieldSchema.onCreate || fieldSchema.type !== "autodate" && !fieldSchema.required ? "?" : ""; return ` ${fieldName}${required}: ${typeString}`; } function createSelectOptions(recordName, fields) { const selectFields = fields.filter((field) => field.type === "select"); const typestring = selectFields.map((field) => { const name = getOptionEnumName(recordName, field.name); const values = getOptionValues(field); const entries = values.map( (val) => ` "${getSelectOptionEnumName(val)}": "${val}",` ).join("\n"); return `export const ${name} = { ${entries} } as const export type ${name} = typeof ${name}[keyof typeof ${name}] `; }).join("\n"); return typestring; } function getSelectOptionEnumName(val) { if (!isNaN(Number(val))) { return `E${val}`; } else { return val; } } // src/lib.ts function generate(results, options2) { const collectionNames = []; const recordTypes = []; const responseTypes = [RESPONSE_TYPE_COMMENT]; results.sort((a, b) => a.name.localeCompare(b.name)).forEach((row) => { if (row.name) collectionNames.push(row.name); if (row.fields) { recordTypes.push(createRecordType(row.name, row.fields)); responseTypes.push(createResponseType(row)); } }); const sortedCollectionNames = collectionNames; const includeGeoPoint = containsGeoPoint(results); const fileParts = [ EXPORT_COMMENT, options2.sdk && IMPORTS, createCollectionEnum(sortedCollectionNames), ALIAS_TYPE_DEFINITIONS, includeGeoPoint && GEOPOINT_TYPE_DEFINITION, EXPAND_TYPE_DEFINITION, BASE_SYSTEM_FIELDS_DEFINITION, AUTH_SYSTEM_FIELDS_DEFINITION, RECORD_TYPE_COMMENT, ...recordTypes, responseTypes.join("\n"), ALL_RECORD_RESPONSE_COMMENT, createCollectionRecords(sortedCollectionNames), createCollectionResponses(sortedCollectionNames), UTILITY_TYPES, options2.sdk && TYPED_POCKETBASE_TYPE ]; return fileParts.filter(Boolean).join("\n\n") + "\n"; } function createRecordType(name, schema) { const selectOptionEnums = createSelectOptions(name, schema); const typeName = toPascalCase(name); const genericArgs = getGenericArgStringWithDefault(schema, { includeExpand: false }); const fields = schema.map((fieldSchema) => createTypeField(name, fieldSchema)).sort().join("\n"); return `${selectOptionEnums}export type ${typeName}Record${genericArgs} = ${fields ? `{ ${fields} }` : "never"}`; } function createResponseType(collectionSchemaEntry) { const { name, fields, type } = collectionSchemaEntry; const pascaleName = toPascalCase(name); const genericArgsWithDefaults = getGenericArgStringWithDefault(fields, { includeExpand: true }); const genericArgsForRecord = getGenericArgStringForRecord(fields); const systemFields = getSystemFields(type); const expandArgString = `<T${EXPAND_GENERIC_NAME}>`; return `export type ${pascaleName}Response${genericArgsWithDefaults} = Required<${pascaleName}Record${genericArgsForRecord}> & ${systemFields}${expandArgString}`; } // src/index.ts function generateFromSchema(collections, options2) { return generate(collections, { sdk: options2?.sdk ?? true }); } // src/cli.ts import { program } from "commander"; // package.json var version = "1.5.0"; // src/cli.ts function resolveFromEnv(options2) { dotenv.config( typeof options2.env === "string" ? { path: options2.env } : void 0 ); const url = process.env.PB_TYPEGEN_URL; if (!url) return null; if (process.env.PB_TYPEGEN_TOKEN) { return { resolve: () => fromURLWithToken(url, process.env.PB_TYPEGEN_TOKEN) }; } if (process.env.PB_TYPEGEN_EMAIL && process.env.PB_TYPEGEN_PASSWORD) { return { resolve: () => fromURLWithPassword( url, process.env.PB_TYPEGEN_EMAIL, process.env.PB_TYPEGEN_PASSWORD ) }; } return null; } function resolveSchemaSource(options2) { if (options2.db) return { resolve: () => fromDatabase(options2.db) }; if (options2.json) return { resolve: () => fromJSON(options2.json) }; if (options2.url && options2.token) return { resolve: () => fromURLWithToken(options2.url, options2.token) }; if (options2.url) return { resolve: () => fromURLWithPassword(options2.url, options2.email, options2.password) }; if (options2.env) return resolveFromEnv(options2); return null; } program.name("Pocketbase Typegen").version(version).description( "CLI to create typescript typings for your pocketbase.io records." ).option( "-u, --url <url>", "URL to your hosted pocketbase instance. When using this options you must also provide email and password options or auth token option." ).option( "--email <email>", "Email for a pocketbase superuser. Use this with the --url option." ).option( "-p, --password <password>", "Password for a pocketbase superuser. Use this with the --url option." ).option( "-t, --token <token>", "Auth token for a pocketbase superuser. Use this with the --url option." ).option("-d, --db <path>", "Path to the pocketbase SQLite database.").option( "-j, --json <path>", "Path to JSON schema exported from pocketbase admin UI." ).option( "--env [dir]", "Use environment variables for configuration. Add PB_TYPEGEN_URL, PB_TYPEGEN_EMAIL, PB_TYPEGEN_PASSWORD to your .env file. Optionally provide a path to a directory containing a .env file", true ).option( "-o, --out <path>", "Path to save the typescript output file.", "pocketbase-types.ts" ).option( "--no-sdk", "Removes the pocketbase package dependency. A typed version of the SDK will not be generated." ); program.parse(process.argv); var options = program.opts(); async function main(options2) { const source = resolveSchemaSource(options2); if (!source) { if (options2.env) { console.error( "Missing PB_TYPEGEN_URL or PB_TYPEGEN_TOKEN environment variables" ); } else { console.error( "Missing schema path. Check options: pocketbase-typegen --help" ); } return; } let schema; try { schema = await source.resolve(); } catch (e) { console.error(e instanceof Error ? e.message : e); process.exit(1); } const typeString = generateFromSchema(schema, { sdk: options2.sdk }); await saveFile(options2.out, typeString); return typeString; } main(options);