astro-loader-pocketbase 0.2.2 → 0.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-loader-pocketbase",
3
- "version": "0.2.2",
3
+ "version": "0.3.0-rc.2",
4
4
  "description": "A content loader for Astro that uses the PocketBase API",
5
5
  "license": "MIT",
6
6
  "author": "Luis Wolf <development@pawcode.de> (https://pawcode.de)",
@@ -1,10 +1,11 @@
1
1
  import type { ZodSchema } from "astro/zod";
2
2
  import { z } from "astro/zod";
3
3
  import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type";
4
- import type { PocketBaseSchemaEntry } from "./types/pocketbase-schema.type";
4
+ import type { PocketBaseCollection } from "./types/pocketbase-schema.type";
5
5
  import { getRemoteSchema } from "./utils/get-remote-schema";
6
6
  import { parseSchema } from "./utils/parse-schema";
7
7
  import { readLocalSchema } from "./utils/read-local-schema";
8
+ import { transformFiles } from "./utils/transform-files";
8
9
 
9
10
  /**
10
11
  * Basic schema for every PocketBase collection.
@@ -13,39 +14,56 @@ const BASIC_SCHEMA = {
13
14
  id: z.string().length(15),
14
15
  collectionId: z.string().length(15),
15
16
  collectionName: z.string(),
16
- created: z.date({ coerce: true }),
17
- updated: z.date({ coerce: true })
17
+ created: z.coerce.date(),
18
+ updated: z.coerce.date()
19
+ };
20
+
21
+ /**
22
+ * Basic schema for a view in PocketBase.
23
+ */
24
+ const VIEW_SCHEMA = {
25
+ id: z.string(),
26
+ collectionId: z.string().length(15),
27
+ collectionName: z.string(),
28
+ created: z.preprocess((val) => val || undefined, z.optional(z.coerce.date())),
29
+ updated: z.preprocess((val) => val || undefined, z.optional(z.coerce.date()))
18
30
  };
19
31
 
20
32
  /**
21
33
  * Generate a schema for the collection based on the collection's schema in PocketBase.
22
- * If no login credentials are provided, a {@link BASIC_SCHEMA} for every PocketBase collection is returned.
34
+ * By default, a basic schema is returned if no other schema is available.
35
+ * If admin credentials are provided, the schema is fetched from the PocketBase API.
36
+ * If a path to a local schema file is provided, the schema is read from the file.
23
37
  *
24
38
  * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
25
39
  */
26
40
  export async function generateSchema(
27
41
  options: PocketBaseLoaderOptions
28
42
  ): Promise<ZodSchema> {
29
- let schema: Array<PocketBaseSchemaEntry> | undefined;
43
+ let collection: PocketBaseCollection | undefined;
30
44
 
31
45
  // Try to get the schema directly from the PocketBase instance
32
- schema = await getRemoteSchema(options);
46
+ collection = await getRemoteSchema(options);
33
47
 
34
48
  // If the schema is not available, try to read it from a local schema file
35
- if (!schema && options.localSchema) {
36
- schema = await readLocalSchema(options.localSchema, options.collectionName);
49
+ if (!collection && options.localSchema) {
50
+ collection = await readLocalSchema(
51
+ options.localSchema,
52
+ options.collectionName
53
+ );
37
54
  }
38
55
 
39
56
  // If the schema is still not available, return the basic schema
40
- if (!schema) {
57
+ if (!collection) {
41
58
  console.error(
42
59
  `No schema available for ${options.collectionName}. Only basic types are available. Please check your configuration and provide a valid schema file or admin credentials.`
43
60
  );
44
- return z.object(BASIC_SCHEMA);
61
+ // Return the view schema since every collection has at least the view schema
62
+ return z.object(VIEW_SCHEMA);
45
63
  }
46
64
 
47
65
  // Parse the schema
48
- const fields = parseSchema(schema);
66
+ const fields = parseSchema(collection);
49
67
 
50
68
  // Check if the content field is present
51
69
  if (typeof options.content === "string" && !fields[options.content]) {
@@ -62,9 +80,26 @@ export async function generateSchema(
62
80
  }
63
81
  }
64
82
 
83
+ // Use the corresponding base schema for the type of collection
84
+ // Auth collections are basically a superset of the basic schema.
85
+ const base = collection.type === "view" ? VIEW_SCHEMA : BASIC_SCHEMA;
86
+
65
87
  // Combine the basic schema with the parsed fields
66
- return z.object({
67
- ...BASIC_SCHEMA,
88
+ const schema = z.object({
89
+ ...base,
68
90
  ...fields
69
91
  });
92
+
93
+ // Get all file fields
94
+ const fileFields = collection.schema.filter((field) => field.type === "file");
95
+
96
+ if (fileFields.length === 0) {
97
+ return schema;
98
+ }
99
+
100
+ // Transform file names to file urls
101
+ return schema.transform((entry) =>
102
+ // @ts-expect-error - `updated` and `created` are already transformed to dates
103
+ transformFiles(options.url, fileFields, entry)
104
+ );
70
105
  }
@@ -9,13 +9,15 @@ import { parseEntry } from "./utils/parse-entry";
9
9
  * @param context Context of the loader.
10
10
  * @param adminToken Admin token to access all resources.
11
11
  * @param lastModified Date of the last fetch to only update changed entries.
12
+ *
13
+ * @returns `true` if the collection has an updated column, `false` otherwise.
12
14
  */
13
15
  export async function loadEntries(
14
16
  options: PocketBaseLoaderOptions,
15
17
  context: LoaderContext,
16
18
  adminToken: string | undefined,
17
19
  lastModified: string | undefined
18
- ): Promise<void> {
20
+ ): Promise<boolean> {
19
21
  // Build the URL for the collections endpoint
20
22
  const collectionUrl = new URL(
21
23
  `api/collections/${options.collectionName}/records`,
@@ -41,14 +43,17 @@ export async function loadEntries(
41
43
  let page = 1;
42
44
  let totalPages = 0;
43
45
  let entries = 0;
46
+ let hasUpdatedColumn = !!lastModified;
44
47
 
45
48
  // Fetch all (modified) entries
46
49
  do {
47
50
  // Fetch entries from the collection
48
51
  // If `lastModified` is set, only fetch entries that have been modified since the last fetch
49
52
  const collectionRequest = await fetch(
50
- `${collectionUrl}?page=${page}&perPage=100&sort=-updated,id${
51
- lastModified ? `&filter=(updated>"${lastModified}")` : ""
53
+ `${collectionUrl}?page=${page}&perPage=100${
54
+ lastModified
55
+ ? `&sort=-updated,id&filter=(updated>"${lastModified}")`
56
+ : ""
52
57
  }`,
53
58
  {
54
59
  headers: collectionHeaders
@@ -59,10 +64,9 @@ export async function loadEntries(
59
64
  if (!collectionRequest.ok) {
60
65
  // If the collection is locked, an admin token is required
61
66
  if (collectionRequest.status === 403) {
62
- context.logger.error(
67
+ throw new Error(
63
68
  `The collection ${options.collectionName} is not accessible without an admin rights. Please provide an admin email and password in the config.`
64
69
  );
65
- return;
66
70
  }
67
71
 
68
72
  // Get the reason for the error
@@ -70,8 +74,7 @@ export async function loadEntries(
70
74
  .json()
71
75
  .then((data) => data.message);
72
76
  const errorMessage = `Fetching data from ${options.collectionName} failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
73
- context.logger.error(errorMessage);
74
- return;
77
+ throw new Error(errorMessage);
75
78
  }
76
79
 
77
80
  // Get the data from the response
@@ -80,6 +83,12 @@ export async function loadEntries(
80
83
  // Parse and store the entries
81
84
  for (const entry of response.items) {
82
85
  await parseEntry(entry, context, options.content);
86
+
87
+ // Check if the entry has an `updated` column
88
+ // This is used to enable the incremental fetching of entries
89
+ if (!hasUpdatedColumn && "updated" in entry) {
90
+ hasUpdatedColumn = true;
91
+ }
83
92
  }
84
93
 
85
94
  // Update the page and total pages
@@ -94,4 +103,7 @@ export async function loadEntries(
94
103
  context.collection
95
104
  }`
96
105
  );
106
+
107
+ // Return if the collection has an updated column
108
+ return hasUpdatedColumn;
97
109
  }
@@ -1,4 +1,5 @@
1
1
  import type { Loader, LoaderContext } from "astro/loaders";
2
+ import packageJson from "./../package.json";
2
3
  import { cleanupEntries } from "./cleanup-entries";
3
4
  import { generateSchema } from "./generate-schema";
4
5
  import { loadEntries } from "./load-entries";
@@ -14,12 +15,25 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
14
15
  return {
15
16
  name: "pocketbase-loader",
16
17
  load: async (context: LoaderContext): Promise<void> => {
18
+ // Check if the version has changed to force an update
19
+ const lastVersion = context.meta.get("version");
20
+ if (lastVersion !== packageJson.version) {
21
+ context.logger.info(
22
+ `PocketBase loader was updated from ${lastVersion} to ${packageJson.version}. All entries will be loaded again.`
23
+ );
24
+
25
+ options.forceUpdate = true;
26
+ }
27
+
17
28
  // Get the date of the last fetch to only update changed entries.
18
29
  // If `forceUpdate` is set to `true`, this will be `undefined` to fetch all entries again.
19
30
  const lastModified = options.forceUpdate
20
31
  ? undefined
21
32
  : context.meta.get("last-modified");
22
33
 
34
+ // Get the `has-updated-column` meta to check if the collection has an updated column
35
+ let hasUpdatedColumn = context.meta.get("has-updated-column") === "true";
36
+
23
37
  // Clear the store if we want to fetch all entries again
24
38
  if (options.forceUpdate) {
25
39
  context.store.clear();
@@ -42,10 +56,30 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
42
56
  }
43
57
 
44
58
  // Load the (modified) entries
45
- await loadEntries(options, context, token, lastModified);
59
+ try {
60
+ hasUpdatedColumn = await loadEntries(
61
+ options,
62
+ context,
63
+ token,
64
+ // Only fetch entries that have been modified since the last fetch
65
+ // If the collection does not have an updated column, all entries will be fetched
66
+ hasUpdatedColumn ? lastModified : undefined
67
+ );
68
+ } catch (error) {
69
+ // Set the `has-updated-column` meta to `false` if an error occurred
70
+ // This will force the loader to fetch all entries again in the next run
71
+ context.meta.set("has-updated-column", `${false}`);
72
+
73
+ throw error;
74
+ }
75
+
76
+ // Set the `has-updated-column` meta to `true` if the collection has an updated column
77
+ context.meta.set("has-updated-column", `${hasUpdatedColumn}`);
46
78
 
47
79
  // Set the last modified date to the current date
48
80
  context.meta.set("last-modified", new Date().toISOString());
81
+
82
+ context.meta.set("version", packageJson.version);
49
83
  },
50
84
  schema: async () => {
51
85
  // Generate the schema for the collection according to the API
@@ -17,11 +17,11 @@ interface PocketBaseBaseEntry {
17
17
  /**
18
18
  * Date the entry was created.
19
19
  */
20
- created: string;
20
+ created?: string | undefined;
21
21
  /**
22
22
  * Date the entry was last updated.
23
23
  */
24
- updated: string;
24
+ updated?: string | undefined;
25
25
  }
26
26
 
27
27
  /**
@@ -19,7 +19,7 @@ export interface PocketBaseLoaderOptions {
19
19
  * The loader will concatenate the content of all fields in the order they are defined in the array.
20
20
  * Each block will be contained in a `<section>` element.
21
21
  */
22
- content: string | Array<string>;
22
+ content?: string | Array<string>;
23
23
  /**
24
24
  * Email of an admin to get full access to the PocketBase instance.
25
25
  * Together with `adminPassword` this is required to get automatic type generation and to access all resources even if they are not public.
@@ -39,6 +39,10 @@ export interface PocketBaseCollection {
39
39
  * Name of the collection.
40
40
  */
41
41
  name: string;
42
+ /**
43
+ * Type of the collection.
44
+ */
45
+ type: 'base' | 'view' | 'auth';
42
46
  /**
43
47
  * Schema of the collection.
44
48
  */
@@ -1,8 +1,5 @@
1
1
  import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type";
2
- import type {
3
- PocketBaseCollection,
4
- PocketBaseSchemaEntry
5
- } from "../types/pocketbase-schema.type";
2
+ import type { PocketBaseCollection } from "../types/pocketbase-schema.type";
6
3
  import { getAdminToken } from "./get-admin-token";
7
4
 
8
5
  /**
@@ -12,7 +9,7 @@ import { getAdminToken } from "./get-admin-token";
12
9
  */
13
10
  export async function getRemoteSchema(
14
11
  options: PocketBaseLoaderOptions
15
- ): Promise<Array<PocketBaseSchemaEntry> | undefined> {
12
+ ): Promise<PocketBaseCollection | undefined> {
16
13
  if (!options.adminEmail || !options.adminPassword) {
17
14
  return undefined;
18
15
  }
@@ -52,6 +49,5 @@ export async function getRemoteSchema(
52
49
  }
53
50
 
54
51
  // Get the schema from the response
55
- const schema: PocketBaseCollection = await schemaRequest.json();
56
- return schema.schema;
52
+ return await schemaRequest.json();
57
53
  }
@@ -1,5 +1,5 @@
1
1
  import type { LoaderContext } from "astro/loaders";
2
- import type { PocketBaseEntry } from "../types/pocketbase-base.type";
2
+ import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
3
3
 
4
4
  /**
5
5
  * Parse an entry from PocketBase to match the schema and store it in the store.
@@ -12,7 +12,7 @@ import type { PocketBaseEntry } from "../types/pocketbase-base.type";
12
12
  export async function parseEntry(
13
13
  entry: PocketBaseEntry,
14
14
  { generateDigest, parseData, store }: LoaderContext,
15
- contentFields: string | Array<string>
15
+ contentFields?: string | Array<string>
16
16
  ): Promise<void> {
17
17
  // Parse the data to match the schema
18
18
  // This will throw an error if the data does not match the schema
@@ -22,8 +22,20 @@ export async function parseEntry(
22
22
  });
23
23
 
24
24
  // Generate a digest for the entry
25
- // We can use the updated data as an identifier if the entry has changed since PocketBase automatically updates this value on every change
26
- const digest = generateDigest(entry.updated);
25
+ // Normal collections use the updated date that is always updated when the entry is updated.
26
+ // If the entry was never updated, the created date can be used as a fallback.
27
+ // View collections don't necessarily publish the updated date, so the whole entry is used for the digest.
28
+ const digest = generateDigest(entry.updated ?? entry.created ?? entry);
29
+
30
+ if (!contentFields) {
31
+ // Store the entry
32
+ store.set({
33
+ id: entry.id,
34
+ data,
35
+ digest
36
+ });
37
+ return;
38
+ }
27
39
 
28
40
  // Generate the content for the entry
29
41
  let content: string;
@@ -1,14 +1,17 @@
1
1
  import { z } from "astro/zod";
2
- import type { PocketBaseSchemaEntry } from "../types/pocketbase-schema.type";
2
+ import type {
3
+ PocketBaseCollection,
4
+ PocketBaseSchemaEntry
5
+ } from "../types/pocketbase-schema.type";
3
6
 
4
7
  export function parseSchema(
5
- schema: Array<PocketBaseSchemaEntry>
8
+ collection: PocketBaseCollection
6
9
  ): Record<string, z.ZodType> {
7
10
  // Prepare the schemas fields
8
11
  const fields: Record<string, z.ZodType> = {};
9
12
 
10
13
  // Parse every field in the schema
11
- for (const field of schema) {
14
+ for (const field of collection.schema) {
12
15
  let fieldType;
13
16
 
14
17
  // Determine the field type and create the corresponding Zod type
@@ -41,7 +44,8 @@ export function parseSchema(
41
44
  break;
42
45
  case "relation":
43
46
  case "file":
44
- // NOTE: Relations and files are currently not supported and are treated as strings
47
+ // NOTE: Relations are currently not supported and are treated as strings
48
+ // NOTE: Files are later transformed to URLs
45
49
 
46
50
  // Parse the field type based on the number of values it can have
47
51
  fieldType = parseSingleOrMultipleValues(field, z.string());
@@ -1,9 +1,6 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
- import type {
4
- PocketBaseCollection,
5
- PocketBaseSchemaEntry
6
- } from "../types/pocketbase-schema.type";
3
+ import type { PocketBaseCollection } from "../types/pocketbase-schema.type";
7
4
 
8
5
  /**
9
6
  * Reads the local PocketBase schema file and returns the schema for the specified collection.
@@ -14,7 +11,7 @@ import type {
14
11
  export async function readLocalSchema(
15
12
  localSchemaPath: string,
16
13
  collectionName: string
17
- ): Promise<Array<PocketBaseSchemaEntry> | undefined> {
14
+ ): Promise<PocketBaseCollection | undefined> {
18
15
  const realPath = path.join(process.cwd(), localSchemaPath);
19
16
 
20
17
  try {
@@ -38,7 +35,7 @@ export async function readLocalSchema(
38
35
  );
39
36
  }
40
37
 
41
- return schema.schema;
38
+ return schema;
42
39
  } catch (error) {
43
40
  console.error(
44
41
  `Failed to read local schema from ${localSchemaPath}. No types will be generated.\nReason: ${error}`
@@ -0,0 +1,66 @@
1
+ import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
2
+ import type { PocketBaseSchemaEntry } from "../types/pocketbase-schema.type";
3
+
4
+ /**
5
+ * Transforms file names in a PocketBase entry to file URLs.
6
+ *
7
+ * @param baseUrl URL of the PocketBase instance.
8
+ * @param collection Collection of the entry.
9
+ * @param entry Entry to transform.
10
+ */
11
+ export function transformFiles(
12
+ baseUrl: string,
13
+ fileFields: Array<PocketBaseSchemaEntry>,
14
+ entry: PocketBaseEntry
15
+ ): PocketBaseEntry {
16
+ // Transform all file names to file URLs
17
+ for (const field of fileFields) {
18
+ const fieldName = field.name;
19
+
20
+ if (field.options.maxSelect === 1) {
21
+ const fileName = entry[fieldName] as string | undefined;
22
+ // Check if a file name is present
23
+ if (!fileName) {
24
+ continue;
25
+ }
26
+
27
+ // Transform the file name to a file URL
28
+ entry[fieldName] = transformFileUrl(
29
+ baseUrl,
30
+ entry.collectionName,
31
+ entry.id,
32
+ fileName
33
+ );
34
+ } else {
35
+ const fileNames = entry[fieldName] as Array<string> | undefined;
36
+ // Check if file names are present
37
+ if (!fileNames) {
38
+ continue;
39
+ }
40
+
41
+ // Transform all file names to file URLs
42
+ entry[fieldName] = fileNames.map((file) =>
43
+ transformFileUrl(baseUrl, entry.collectionName, entry.id, file)
44
+ );
45
+ }
46
+ }
47
+
48
+ return entry;
49
+ }
50
+
51
+ /**
52
+ * Transforms a file name to a PocketBase file URL.
53
+ *
54
+ * @param base Base URL of the PocketBase instance.
55
+ * @param collectionName Name of the collection.
56
+ * @param entryId ID of the entry.
57
+ * @param file Name of the file.
58
+ */
59
+ function transformFileUrl(
60
+ base: string,
61
+ collectionName: string,
62
+ entryId: string,
63
+ file: string
64
+ ): string {
65
+ return `${base}/api/files/${collectionName}/${entryId}/${file}`;
66
+ }