astro-loader-pocketbase
Version:
A content loader for Astro that uses the PocketBase API
973 lines (972 loc) • 36.2 kB
JavaScript
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