UNPKG

astro-loader-pocketbase

Version:

A content loader for Astro that uses the PocketBase API

973 lines (972 loc) 36.2 kB
import { LiveCollectionError, LiveCollectionValidationError, LiveEntryNotFoundError } from "astro/content/runtime"; import { z } from "astro/zod"; import fs from "fs/promises"; import path from "path"; import { createAuxiliaryTypeStore, createTypeAlias, printNode, zodToTs } from "zod-to-ts"; //#region src/types/errors.ts /** * Error thrown when there is an authentication issue with PocketBase. */ var PocketBaseAuthenticationError = class extends LiveCollectionError { constructor(collection, message) { super(collection, message); this.name = "PocketBaseAuthenticationError"; } static is(error) { return !!error && error.name === "PocketBaseAuthenticationError"; } }; //#endregion //#region src/types/pocketbase-entry.type.ts /** * Schema for a PocketBase entry. */ const pocketBaseEntry = z.looseObject({ /** * ID of the entry. */ id: z.string(), /** * ID of the collection the entry belongs to. */ collectionId: z.string(), /** * Name of the collection the entry belongs to. */ collectionName: z.string() }); //#endregion //#region src/types/pocketbase-api-response.type.ts /** * The schema for a PocketBase error response. */ const pocketBaseErrorResponse = z.object({ /** * The error message returned by PocketBase. */ message: z.string() }); /** * The schema for a PocketBase list response. */ const pocketBaseListResponse = z.object({ /** * Current page number. */ page: z.number(), /** * Total number of pages available. */ totalPages: z.number(), /** * Array of items in the current page. */ items: z.array(pocketBaseEntry) }); /** * The schema for a PocketBase login response. */ const pocketBaseLoginResponse = z.object({ /** * The authentication token returned by PocketBase. */ token: z.string() }); //#endregion //#region src/utils/combine-fields-for-request.ts /** * Combine basic, special, and user-specified fields for PocketBase API requests. * This utility ensures that required system fields are always included in API requests. * * @param userFields Array of fields specified by the user, or undefined for all fields * @param options PocketBase loader options containing custom field configurations * @returns Combined array of fields to include in the API request, or undefined for all fields */ function combineFieldsForRequest(userFields, options) { if (!userFields) return; const basicFields = [ "id", "collectionId", "collectionName" ]; const specialFields = []; if (options.idField && options.idField !== "id") specialFields.push(options.idField); if (options.updatedField) specialFields.push(options.updatedField); if (options.contentFields) if (Array.isArray(options.contentFields)) specialFields.push(...options.contentFields); else specialFields.push(options.contentFields); return [.../* @__PURE__ */ new Set([ ...basicFields, ...specialFields, ...userFields ])]; } //#endregion //#region src/utils/format-fields.ts /** * Format fields option into an array and validate for expand usage. * Handles wildcard "*" and preserves excerpt field modifiers. * * @param fields The fields option (string or array) * @returns Formatted fields array, or undefined if no fields specified or "*" wildcard is used */ function formatFields(fields) { if (!fields || fields.length === 0) return; let fieldList; if (Array.isArray(fields)) fieldList = fields.map((f) => f.trim()); else fieldList = splitFieldsString(fields).map((f) => f.trim()); if (fieldList.some((field) => field.includes("expand"))) { console.warn("The \"expand\" parameter is not currently supported by astro-loader-pocketbase and will be filtered out."); fieldList = fieldList.filter((field) => !field.includes("expand")); } if (fieldList.some((field) => field === "*")) return; return fieldList; } /** * Splits the fields string at `,` but respects the `:excerpt(number, boolean)` option */ function splitFieldsString(fieldsString) { const initialSplit = fieldsString.split(","); const fields = []; for (let i = 0; i < initialSplit.length; i++) { const part = initialSplit.at(i); if (!part) continue; if (part.includes("(") && !part.includes(")")) { fields.push(`${part},${initialSplit[++i]}`); continue; } fields.push(part); } return fields; } //#endregion //#region src/loader/fetch-collection.ts /** * Fetches entries from a PocketBase collection, optionally filtering by modification date and supporting pagination. */ async function fetchCollection(options, chunkLoaded, token, collectionFilter) { const collectionUrl = new URL(`api/collections/${options.collectionName}/records`, options.url); const collectionHeaders = new Headers(); if (token) collectionHeaders.set("Authorization", token); const combinedFields = combineFieldsForRequest(formatFields(options.fields), options); let page = 0; let totalPages = 0; do { collectionUrl.search = buildSearchParams(options, combinedFields, { ...collectionFilter, page: collectionFilter?.page ?? ++page, perPage: collectionFilter?.perPage ?? 100 }).toString(); const collectionRequest = await fetch(collectionUrl.href, { headers: collectionHeaders }); if (!collectionRequest.ok) { if (collectionRequest.status === 403) if (options.superuserCredentials && "impersonateToken" in options.superuserCredentials) throw new PocketBaseAuthenticationError(options.collectionName, "The given impersonate token is not valid."); else throw new PocketBaseAuthenticationError(options.collectionName, "The collection is not accessible without superuser rights. Please provide superuser credentials in the config."); if (collectionRequest.status === 404) throw new LiveEntryNotFoundError(options.collectionName, { ...collectionFilter }); const errorResponse = pocketBaseErrorResponse.parse(await collectionRequest.json()); throw new LiveCollectionError(options.collectionName, errorResponse.message); } const response = pocketBaseListResponse.parse(await collectionRequest.json()); await chunkLoaded(response.items); page = response.page; totalPages = response.totalPages; } while (!collectionFilter?.perPage && page < totalPages); } /** * Build search parameters for the PocketBase collection request. */ function buildSearchParams(loaderOptions, combinedFields, collectionFilter) { const searchParams = new URLSearchParams(); if (collectionFilter.page) searchParams.set("page", `${collectionFilter.page}`); if (collectionFilter.perPage) searchParams.set("perPage", `${collectionFilter.perPage}`); const filters = []; if (collectionFilter.lastModified && loaderOptions.updatedField) { filters.push(`(${loaderOptions.updatedField}>"${collectionFilter.lastModified}")`); searchParams.set("sort", `-${loaderOptions.updatedField},id`); } if (loaderOptions.filter) filters.push(`(${loaderOptions.filter})`); if (collectionFilter.filter) filters.push(`(${collectionFilter.filter})`); if (filters.length > 0) searchParams.set("filter", filters.join("&&")); if (collectionFilter.sort) searchParams.set("sort", collectionFilter.sort); if (combinedFields) searchParams.set("fields", combinedFields.join(",")); return searchParams; } //#endregion //#region src/loader/parse-live-entry.ts /** * Converts a PocketBase entry into a LiveDataEntry for Astro, extracting content and cache metadata. */ function parseLiveEntry(entry, options) { const tag = `${options.collectionName}-${entry.id}`; let lastModified = void 0; if (options.updatedField && entry[options.updatedField]) { const value = `${entry[options.updatedField]}`; const date = z.coerce.date().safeParse(value); if (!date.success) throw new LiveCollectionValidationError(options.collectionName, entry.id, date.error); lastModified = date.data; } if (!options.contentFields) return { id: entry.id, data: entry, cacheHint: { tags: [tag], lastModified } }; let content; if (typeof options.contentFields === "string") content = `${entry[options.contentFields]}`; else content = options.contentFields.map((field) => `<section id="${field}">${entry[field]}</section>`).join(""); return { id: entry.id, data: entry, rendered: { html: content }, cacheHint: { tags: [tag], lastModified } }; } //#endregion //#region src/loader/live-collection-loader.ts /** * Loads and parses a PocketBase collection for live data, returning entries or an error. */ async function liveCollectionLoader(collectionFilter, options, token) { const entries = []; try { await fetchCollection(options, async (chunk) => { entries.push(...chunk.map((entry) => parseLiveEntry(entry, options))); }, token, collectionFilter); } catch (error) { if (error instanceof LiveCollectionError) return { error }; if (error instanceof Error) return { error: new LiveCollectionError(options.collectionName, error.message, error) }; return { error: new LiveCollectionError(options.collectionName, String(error)) }; } return { entries }; } //#endregion //#region src/loader/fetch-entry.ts /** * Retrieves a specific entry from a PocketBase collection using its ID and loader options. */ async function fetchEntry(id, options, token) { const entryUrl = new URL(`api/collections/${options.collectionName}/records/${id}`, options.url); const combinedFields = combineFieldsForRequest(formatFields(options.fields), options); if (combinedFields) entryUrl.searchParams.set("fields", combinedFields.join(",")); const entryHeaders = new Headers(); if (token) entryHeaders.set("Authorization", token); const entryRequest = await fetch(entryUrl.href, { headers: entryHeaders }); if (!entryRequest.ok) { if (entryRequest.status === 403) if (options.superuserCredentials && "impersonateToken" in options.superuserCredentials) throw new PocketBaseAuthenticationError(options.collectionName, "The given impersonate token is not valid."); else throw new PocketBaseAuthenticationError(options.collectionName, "The entry is not accessible without superuser rights. Please provide superuser credentials in the config."); if (entryRequest.status === 404) throw new LiveEntryNotFoundError(options.collectionName, id); const errorResponse = pocketBaseErrorResponse.parse(await entryRequest.json()); throw new LiveCollectionError(options.collectionName, errorResponse.message); } return pocketBaseEntry.parse(await entryRequest.json()); } //#endregion //#region src/loader/live-entry-loader.ts /** * Loads and parses a single PocketBase entry for live data, returning the entry or an error. */ async function liveEntryLoader(id, options, token) { try { return parseLiveEntry(await fetchEntry(id, options, token), options); } catch (error) { if (error instanceof LiveCollectionError) return { error }; if (error instanceof Error) return { error: new LiveCollectionError(options.collectionName, error.message, error) }; return { error: new LiveCollectionError(options.collectionName, String(error)) }; } } //#endregion //#region package.json var version = "4.0.1"; //#endregion //#region src/utils/should-refresh.ts /** * Checks if the collection should be refreshed. */ function shouldRefresh(context, collectionName) { if (context?.source !== "astro-integration-pocketbase") return "refresh"; if (context.force) return "force"; if (!context.collection) return "refresh"; if (typeof context.collection === "string") return context.collection === collectionName ? "refresh" : "skip"; if (Array.isArray(context.collection)) return context.collection.includes(collectionName) ? "refresh" : "skip"; return "refresh"; } //#endregion //#region src/loader/cleanup-entries.ts /** * Cleanup entries that are no longer in the collection. * * @param options Options for the loader. * @param context Context of the loader. * @param superuserToken Superuser token to access all resources. */ async function cleanupEntries(options, context, superuserToken) { const collectionUrl = new URL(`api/collections/${options.collectionName}/records`, options.url).href; const collectionHeaders = new Headers(); if (superuserToken) collectionHeaders.set("Authorization", superuserToken); let page = 0; let totalPages = 0; const entries = /* @__PURE__ */ new Set(); do { const searchParams = new URLSearchParams({ page: `${++page}`, perPage: "1000", fields: "id" }); if (options.filter) searchParams.set("filter", `(${options.filter})`); const collectionRequest = await fetch(`${collectionUrl}?${searchParams.toString()}`, { headers: collectionHeaders }); if (!collectionRequest.ok) { if (collectionRequest.status === 403) if (options.superuserCredentials && "impersonateToken" in options.superuserCredentials) context.logger.error("The given impersonate token is not valid."); else context.logger.error("The collection is not accessible without superuser rights. Please provide superuser credentials in the config."); else { const errorResponse = pocketBaseErrorResponse.parse(await collectionRequest.json()); const errorMessage = `Fetching ids failed with status code ${collectionRequest.status}.\nReason: ${errorResponse.message}`; context.logger.error(errorMessage); } context.logger.info(`Removing all entries from the store.`); context.store.clear(); return; } const response = cleanUpEntriesResponse.parse(await collectionRequest.json()); for (const item of response.items) entries.add(item.id); page = response.page; totalPages = response.totalPages; } while (page < totalPages); let cleanedUp = 0; const storedIds = new Map(context.store.values().map((entry) => [entry.data.id, entry.id])); for (const [pocketbaseId, storeKey] of storedIds.entries()) if (!entries.has(pocketbaseId)) { context.store.delete(storeKey); cleanedUp++; } if (cleanedUp > 0) context.logger.info(`Cleaned up ${cleanedUp} old entries.`); } /** * The response schema for cleaning up entries. */ const cleanUpEntriesResponse = pocketBaseListResponse.omit({ items: true }).extend({ items: z.array(pocketBaseEntry.pick({ id: true })) }); //#endregion //#region src/utils/is-realtime-data.ts /** * Schema for realtime data received from PocketBase. */ const realtimeDataSchema = z.object({ action: z.union([ z.literal("create"), z.literal("update"), z.literal("delete") ]), record: z.object({ id: z.string(), collectionName: z.string(), collectionId: z.string() }) }); /** * Checks if the given data is realtime data received from PocketBase. */ function isRealtimeData(data) { try { realtimeDataSchema.parse(data); return true; } catch { return false; } } //#endregion //#region src/utils/slugify.ts /** * Convert a string to a slug. * * Example: * ```ts * slugify("Hello World!"); // hello-world * ``` */ function slugify(input) { return input.toLowerCase().replaceAll(/\s+/g, "-").replaceAll("ä", "ae").replaceAll("ö", "oe").replaceAll("ü", "ue").replaceAll("ß", "ss").replaceAll(/[^\w-]+/g, "").replaceAll(/--+/g, "-").replace(/^-+/, "").replace(/-+$/, ""); } //#endregion //#region src/loader/parse-entry.ts /** * Parse an entry from PocketBase to match the schema and store it in the store. * * @param entry Entry to parse. * @param context Context of the loader. * @param idField Field to use as id for the entry. * If not provided, the id of the entry will be used. * @param contentFields Field(s) to use as content for the entry. * If multiple fields are used, they will be concatenated and wrapped in `<section>` elements. */ async function parseEntry(entry, { generateDigest, parseData, store, logger }, { idField, contentFields, updatedField }) { let id = entry.id; if (idField) { const customEntryId = entry[idField]; if (!customEntryId) logger.warn(`The entry "${id}" does not have a value for field ${idField}. Using the default ID instead.`); else id = slugify(`${customEntryId}`); } const oldEntry = store.get(id); if (oldEntry && oldEntry.data.id !== entry.id) logger.warn(`The entry "${entry.id}" seems to be a duplicate of "${oldEntry.data.id}". Please make sure to use unique IDs in the column "${idField}".`); const data = await parseData({ id, data: entry }); let updated; if (updatedField) updated = `${entry[updatedField]}`; const digest = generateDigest(updated ?? entry); if (!contentFields) { store.set({ id, data, digest }); return; } let content; if (typeof contentFields === "string") content = `${entry[contentFields]}`; else content = contentFields.map((field) => `<section id="${field}">${entry[field]}</section>`).join(""); store.set({ id, data, digest, rendered: { html: content } }); } //#endregion //#region src/loader/handle-realtime-updates.ts /** * Handles realtime updates for the loader without making any new network requests. * * Returns `true` if the data was handled and no further action is needed. */ async function handleRealtimeUpdates(context, options) { if (options.filter) return false; if (!context.refreshContextData?.data) return false; const data = context.refreshContextData.data; if (!isRealtimeData(data)) return false; if (data.record.collectionName !== options.collectionName) return false; if (data.action === "delete") { context.logger.info("Removing deleted entry"); context.store.delete(data.record.id); return true; } if (data.action === "update") context.logger.info("Updating outdated entry"); else context.logger.info("Creating new entry"); await parseEntry(data.record, context, options); return true; } //#endregion //#region src/loader/load-entries.ts /** * Load (modified) entries from a PocketBase collection. * * @param options Options for the loader. * @param context Context of the loader. * @param superuserToken Superuser token to access all resources. * @param lastModified Date of the last fetch to only update changed entries. */ async function loadEntries(options, context, superuserToken, lastModified) { context.logger.info(`Fetching${lastModified ? " modified" : ""} data${lastModified ? ` starting at ${lastModified}` : ""}${superuserToken ? " as superuser" : ""}`); let numEntries = 0; await fetchCollection(options, async (entries) => { for (const entry of entries) await parseEntry(entry, context, options); numEntries += entries.length; }, superuserToken, { lastModified }); if (lastModified) context.logger.info(`Updated ${numEntries}/${context.store.keys().length} entries.`); else context.logger.info(`Fetched ${numEntries} entries.`); } //#endregion //#region src/loader/loader.ts /** * Load entries from a PocketBase collection. */ async function loader(context, options, token) { context.logger.label = `pocketbase-loader:${options.collectionName}`; const refresh = shouldRefresh(context.refreshContextData, options.collectionName); if (refresh === "skip") return; if (await handleRealtimeUpdates(context, options)) return; let lastModified = context.meta.get("last-modified"); if (refresh === "force") { lastModified = void 0; context.store.clear(); } const lastVersion = context.meta.get("version"); if (lastVersion !== version) { if (lastVersion) context.logger.info(`PocketBase loader was updated from ${lastVersion} to ${version}. All entries will be loaded again.`); lastModified = void 0; context.store.clear(); } if (!options.updatedField) { context.logger.info(`No "updatedField" was provided. Incremental builds are disabled.`); lastModified = void 0; } if (context.store.keys().length > 0) await cleanupEntries(options, context, token); await loadEntries(options, context, token, lastModified); context.meta.set("last-modified", (/* @__PURE__ */ new Date()).toISOString().replace("T", " ")); context.meta.set("version", version); } //#endregion //#region src/utils/extract-field-names.ts /** * Extract field names from fields that may contain modifiers like :excerpt(). * * @param fields Array of field specifications that may contain modifiers * @returns Array of clean field names suitable for schema parsing */ function extractFieldNames(fields) { if (!fields) return; return fields.map((field) => field.split(":").at(0) ?? field); } //#endregion //#region src/types/pocketbase-schema.type.ts /** * Entry for a collections schema in PocketBase. */ const pocketBaseSchemaEntry = z.object({ /** * Flag to indicate if the field is hidden. * Hidden fields are not returned in the API response. */ hidden: z.optional(z.boolean()), /** * Unique identifier for the field. */ id: z.string(), /** * Name of the field. */ name: z.string(), /** * Help text for the field. * This is only present if the field has help text defined. */ help: z.optional(z.string()), /** * Type of the field. */ type: z.enum([ "text", "editor", "number", "bool", "email", "url", "date", "autodate", "select", "file", "relation", "json", "geoPoint", "password" ]), /** * Whether the field is required. */ required: z.optional(z.boolean()), /** * Values for a select field. * This is only present if the field type is "select". */ values: z.optional(z.array(z.string())), /** * Maximum number of values for a select field. * This is only present on "select", "relation", and "file" fields. */ maxSelect: z.optional(z.number()), /** * Whether the field is filled when the entry is created. * This is only present on "autodate" fields. */ onCreate: z.optional(z.boolean()), /** * Whether the field is updated when the entry is updated. * This is only present on "autodate" fields. */ onUpdate: z.optional(z.boolean()) }); /** * Schema for a PocketBase collection. */ const pocketBaseCollection = z.object({ /** * Name of the collection. */ name: z.string(), /** * Type of the collection. */ type: z.enum([ "base", "view", "auth" ]), /** * Schema of the collection. */ fields: z.array(pocketBaseSchemaEntry) }); /** * Schema for an entire PocketBase database. */ const pocketBaseDatabase = z.array(pocketBaseCollection); //#endregion //#region src/schema/get-remote-schema.ts /** * Fetches the schema for the specified collection from the PocketBase instance. * * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details. * @param token The superuser token to authenticate the request. */ async function getRemoteSchema(options, token) { const schemaUrl = new URL(`api/collections/${options.collectionName}`, options.url).href; const schemaHeaders = new Headers(); schemaHeaders.set("Authorization", token); const schemaRequest = await fetch(schemaUrl, { headers: schemaHeaders }); if (!schemaRequest.ok) { const errorResponse = pocketBaseErrorResponse.parse(await schemaRequest.json()); const errorMessage = `Fetching schema from ${options.collectionName} failed with status code ${schemaRequest.status}.\nReason: ${errorResponse.message}`; console.error(errorMessage); return; } return pocketBaseCollection.parse(await schemaRequest.json()); } //#endregion //#region src/schema/parse-schema.ts /** * Converts PocketBase collection fields into Zod types, handling field types, required status, and custom schemas. */ function parseSchema(collection, customSchemas, options) { const fields = {}; for (const field of collection.fields) { if (options.fieldsToInclude && !options.fieldsToInclude.includes(field.name)) continue; if (field.hidden && !options.hasSuperuserRights) { if (options.fieldsToInclude) console.warn(`"${field.name}" is requested but hidden. Provide superuser credentials to include this field.`); continue; } let fieldType; switch (field.type) { case "number": fieldType = z.number(); break; case "bool": fieldType = z.boolean(); break; case "date": case "autodate": if (options.experimentalLiveTypesOnly) { fieldType = z.string(); break; } fieldType = z.coerce.date(); break; case "geoPoint": fieldType = z.object({ lon: z.number(), lat: z.number() }); break; case "select": if (!field.values) throw new Error(`Field ${field.name} is of type "select" but has no values defined.`); fieldType = parseSingleOrMultipleValues(field, z.enum(field.values)); break; case "relation": case "file": fieldType = parseSingleOrMultipleValues(field, z.string()); break; case "json": fieldType = customSchemas?.[field.name] ?? z.unknown(); break; default: fieldType = z.string(); break; } if (!(field.required || field.type === "autodate" && field.onCreate || field.type === "number" || field.type === "bool")) fieldType = z.preprocess((val) => val || void 0, z.optional(fieldType)); fields[field.name] = fieldType.meta({ id: field.id, title: field.name, description: getFieldDescription(field) }); } return fields; } /** * Parse the field type based on the number of values it can have * * @param field Field to parse * @param type Type of each value * * @returns The parsed field type */ function parseSingleOrMultipleValues(field, type) { if (field.maxSelect === void 0 || field.maxSelect === 1) return type; return z.array(type); } /** * Get the description for a field based on its help text and type. */ function getFieldDescription(field) { switch (true) { case !!field.help: return field.help; case field.type === "autodate" && field.onUpdate: return "Date when the entry was last updated. This field is automatically updated by PocketBase whenever the entry is updated."; case field.type === "autodate" && field.onCreate: return "Date when the entry was created. This field is automatically set by PocketBase when the entry is created."; case field.name === "id": return "The unique identifier for the entry."; case field.hidden: return "This field is hidden and may require superuser credentials to access."; default: return; } } //#endregion //#region src/schema/read-local-schema.ts /** * Reads the local PocketBase schema file and returns the schema for the specified collection. * * @param localSchemaPath Path to the local schema file. * @param collectionName Name of the collection to get the schema for. */ async function readLocalSchema(localSchemaPath, collectionName) { const realPath = path.join(process.cwd(), localSchemaPath); try { const schemaFile = await fs.readFile(realPath, "utf-8"); const fileContent = pocketBaseDatabase.safeParse(JSON.parse(schemaFile)); if (!fileContent.success) throw new Error("Invalid schema file"); const schema = fileContent.data.find((collection) => collection.name === collectionName); if (!schema) throw new Error(`Collection "${collectionName}" not found in schema file`); return schema; } catch (error) { console.error(`Failed to read local schema from ${localSchemaPath}. No types will be generated.\nReason: ${error}`); return; } } //#endregion //#region src/schema/transform-files.ts /** * Transforms file names in a PocketBase entry to file URLs. * * @param baseUrl URL of the PocketBase instance. * @param collection Collection of the entry. * @param entry Entry to transform. */ function transformFiles(baseUrl, fileFields, entry) { for (const field of fileFields) { const fieldName = field.name; if (field.maxSelect === 1) { const fileName = z.optional(z.string()).parse(entry[fieldName]); if (!fileName) continue; entry[fieldName] = transformFileUrl(baseUrl, entry.collectionName, entry.id, fileName); } else { const fileNames = z.optional(z.array(z.string())).parse(entry[fieldName]); if (!fileNames) continue; entry[fieldName] = fileNames.map((file) => transformFileUrl(baseUrl, entry.collectionName, entry.id, file)); } } return entry; } /** * Transforms a file name to a PocketBase file URL. * * @param base Base URL of the PocketBase instance. * @param collectionName Name of the collection. * @param entryId ID of the entry. * @param file Name of the file. */ function transformFileUrl(base, collectionName, entryId, file) { return new URL(`api/files/${collectionName}/${entryId}/${file}`, base).href; } //#endregion //#region src/schema/generate-schema.ts /** * Basic schema for every PocketBase collection. */ const BASIC_SCHEMA = z.object({ id: z.string().meta({ title: "id", description: "The unique identifier for the entry." }), collectionId: z.string().meta({ title: "collectionId", description: "The unique identifier for the collection the entity belongs to." }), collectionName: z.string().meta({ title: "collectionName", description: "The name of the collection the entity belongs to." }) }); /** * Types of fields that can be used as an ID. */ const VALID_ID_TYPES = [ "text", "number", "email", "url", "date" ]; /** * Generate a schema for the collection based on the collection's schema in PocketBase. * By default, a basic schema is returned if no other schema is available. * If superuser credentials are provided, the schema is fetched from the PocketBase API. * If a path to a local schema file is provided, the schema is read from the file. * * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details. * @param token The superuser token to authenticate the request. */ async function generateSchema(options, token) { let collection; if (token) collection = await getRemoteSchema(options, token); const hasSuperuserRights = !!collection || !!options.superuserCredentials; if (!collection && options.localSchema) collection = await readLocalSchema(options.localSchema, options.collectionName); if (!collection) { console.error(`No schema available for "${options.collectionName}". Only basic types are available. Please check your configuration and provide a valid schema file or superuser credentials.`); return BASIC_SCHEMA; } const fieldsToInclude = combineFieldsForRequest(extractFieldNames(formatFields(options.fields)), options); const fields = parseSchema(collection, options.jsonSchemas, { hasSuperuserRights, fieldsToInclude, experimentalLiveTypesOnly: options.experimental?.liveTypesOnly }); checkCustomIdField(collection, options); checkContentField(fields, options); checkUpdatedField(fields, collection, options); const schema = z.object({ ...BASIC_SCHEMA.shape, ...fields }); const fileFields = collection.fields.filter((field) => field.type === "file").filter((field) => !field.hidden || hasSuperuserRights); if (fileFields.length === 0) return schema; return schema.transform((entry) => transformFiles(options.url, fileFields, entry)); } /** * Check if the custom id field is present */ function checkCustomIdField(collection, options) { if (!options.idField) return; const idField = collection.fields.find((field) => field.name === options.idField); if (!idField) console.error(`The id field "${options.idField}" is not present in the schema of the collection "${options.collectionName}".`); else if (!VALID_ID_TYPES.includes(idField.type)) console.error(`The id field "${options.idField}" for collection "${options.collectionName}" is of type "${idField.type}" which is not recommended. Please use one of the following types: ${VALID_ID_TYPES.join(", ")}.`); } /** * Check if the content field(s) are present */ function checkContentField(fields, options) { if (typeof options.contentFields === "string" && !fields[options.contentFields]) console.error(`The content field "${options.contentFields}" is not present in the schema of the collection "${options.collectionName}".`); else if (Array.isArray(options.contentFields)) { for (const field of options.contentFields) if (!fields[field]) console.error(`The content field "${field}" is not present in the schema of the collection "${options.collectionName}".`); } } /** * Check if the updated field is present */ function checkUpdatedField(fields, collection, options) { if (!options.updatedField) return; if (!fields[options.updatedField]) console.error(`The field "${options.updatedField}" is not present in the schema of the collection "${options.collectionName}".\nThis will lead to errors when trying to fetch only updated entries.`); else { const updatedField = collection.fields.find((field) => field.name === options.updatedField); if (updatedField?.type !== "autodate" || !updatedField.onUpdate) console.warn(`The field "${options.updatedField}" is not of type "autodate" with the value "Update" or "Create/Update".\nMake sure that the field is automatically updated when the entry is updated!`); } } //#endregion //#region src/schema/generate-type.ts /** * Generate a TypeScript type from a Zod schema. */ function generateType(schema) { const { node } = zodToTs(schema.type === "pipe" ? schema.in : schema, { auxiliaryTypeStore: createAuxiliaryTypeStore() }); return `export ${printNode(createTypeAlias(node, "Entry"))}`; } //#endregion //#region src/utils/get-superuser-token.ts /** * This function will get a superuser token from the given PocketBase instance. * * @param url URL of the PocketBase instance * @param superuserCredentials Credentials of the superuser * * @returns A superuser token to access all resources of the PocketBase instance. */ async function getSuperuserToken(url, superuserCredentials, logger) { const loginUrl = new URL(`api/collections/_superusers/auth-with-password`, url).href; const loginData = new FormData(); loginData.set("identity", superuserCredentials.email); loginData.set("password", superuserCredentials.password); const loginRequest = await fetch(loginUrl, { method: "POST", body: loginData }); if (!loginRequest.ok) { if (loginRequest.status === 429) { const info = "A rate limit was hit while trying to authenticate with PocketBase. Consider using an `impersonateToken` as credentials to avoid this issue."; if (logger) logger.info(info); else console.info(info); const retryAfter = Math.random() * 5 + 3; await new Promise((resolve) => { setTimeout(resolve, retryAfter * 1e3); }); return getSuperuserToken(url, superuserCredentials, logger); } const errorMessage = `The given email / password for ${url} was not correct. Astro can't generate type definitions automatically and may not have access to all resources.\nReason: ${pocketBaseErrorResponse.parse(await loginRequest.json()).message}`; if (logger) logger.error(errorMessage); else console.error(errorMessage); return; } return pocketBaseLoginResponse.parse(await loginRequest.json()).token; } //#endregion //#region src/utils/create-token-promise.ts /** * Creates a promise that resolves to a superuser token or undefined. */ async function createTokenPromise(options) { if (options.superuserCredentials) { if ("impersonateToken" in options.superuserCredentials) return options.superuserCredentials.impersonateToken; return await getSuperuserToken(options.url, options.superuserCredentials); } } //#endregion //#region src/pocketbase-loader.ts /** * Loader for collections stored in PocketBase. * * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details. */ function pocketbaseLoader(options) { const tokenPromise = createTokenPromise(options); return { name: "pocketbase-loader", load: async (context) => { if (options.experimental?.liveTypesOnly) { context.logger.label = `pocketbase-loader:${options.collectionName}`; context.logger.info("Experimental live types only mode enabled. No data will be loaded, only types will be generated."); return; } await loader(context, options, await tokenPromise); }, createSchema: async () => { const schema = await generateSchema(options, await tokenPromise); return { schema, types: generateType(schema) }; } }; } /** * Live loader for collections stored in PocketBase. * * @param options Options for the live loader. See {@link PocketBaseLiveLoaderOptions} for more details. */ function pocketbaseLiveLoader(options) { const tokenPromise = createTokenPromise(options); return { name: "pocketbase-live-loader", loadCollection: async ({ filter }) => { return liveCollectionLoader(filter, options, await tokenPromise); }, loadEntry: async ({ filter }) => { const token = await tokenPromise; return liveEntryLoader(filter.id, options, token); } }; } //#endregion export { PocketBaseAuthenticationError, pocketbaseLiveLoader, pocketbaseLoader, transformFileUrl }; //# sourceMappingURL=index.mjs.map