astro-loader-pocketbase 0.2.1 → 0.3.0-rc.1

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.1",
3
+ "version": "0.3.0-rc.1",
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,7 +1,7 @@
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";
@@ -13,39 +13,56 @@ const BASIC_SCHEMA = {
13
13
  id: z.string().length(15),
14
14
  collectionId: z.string().length(15),
15
15
  collectionName: z.string(),
16
- created: z.date({ coerce: true }),
17
- updated: z.date({ coerce: true })
16
+ created: z.coerce.date(),
17
+ updated: z.coerce.date()
18
+ };
19
+
20
+ /**
21
+ * Basic schema for a view in PocketBase.
22
+ */
23
+ const VIEW_SCHEMA = {
24
+ id: z.string(),
25
+ collectionId: z.string().length(15),
26
+ collectionName: z.string(),
27
+ created: z.preprocess((val) => val || undefined, z.optional(z.coerce.date())),
28
+ updated: z.preprocess((val) => val || undefined, z.optional(z.coerce.date()))
18
29
  };
19
30
 
20
31
  /**
21
32
  * 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.
33
+ * By default, a basic schema is returned if no other schema is available.
34
+ * If admin credentials are provided, the schema is fetched from the PocketBase API.
35
+ * If a path to a local schema file is provided, the schema is read from the file.
23
36
  *
24
37
  * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
25
38
  */
26
39
  export async function generateSchema(
27
40
  options: PocketBaseLoaderOptions
28
41
  ): Promise<ZodSchema> {
29
- let schema: Array<PocketBaseSchemaEntry> | undefined;
42
+ let collection: PocketBaseCollection | undefined;
30
43
 
31
44
  // Try to get the schema directly from the PocketBase instance
32
- schema = await getRemoteSchema(options);
45
+ collection = await getRemoteSchema(options);
33
46
 
34
47
  // 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);
48
+ if (!collection && options.localSchema) {
49
+ collection = await readLocalSchema(
50
+ options.localSchema,
51
+ options.collectionName
52
+ );
37
53
  }
38
54
 
39
55
  // If the schema is still not available, return the basic schema
40
- if (!schema) {
56
+ if (!collection) {
41
57
  console.error(
42
58
  `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
59
  );
44
- return z.object(BASIC_SCHEMA);
60
+ // Return the view schema since every collection has at least the view schema
61
+ return z.object(VIEW_SCHEMA);
45
62
  }
46
63
 
47
64
  // Parse the schema
48
- const fields = parseSchema(schema);
65
+ const fields = parseSchema(collection);
49
66
 
50
67
  // Check if the content field is present
51
68
  if (typeof options.content === "string" && !fields[options.content]) {
@@ -62,9 +79,13 @@ export async function generateSchema(
62
79
  }
63
80
  }
64
81
 
82
+ // Use the corresponding base schema for the type of collection
83
+ // Auth collections are basically a superset of the basic schema.
84
+ const base = collection.type === "view" ? VIEW_SCHEMA : BASIC_SCHEMA;
85
+
65
86
  // Combine the basic schema with the parsed fields
66
87
  return z.object({
67
- ...BASIC_SCHEMA,
88
+ ...base,
68
89
  ...fields
69
90
  });
70
91
  }
@@ -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 = false;
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
  }
@@ -20,6 +20,9 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
20
20
  ? undefined
21
21
  : context.meta.get("last-modified");
22
22
 
23
+ // Get the `has-updated-column` meta to check if the collection has an updated column
24
+ let hasUpdatedColumn = context.meta.get("has-updated-column") === "true";
25
+
23
26
  // Clear the store if we want to fetch all entries again
24
27
  if (options.forceUpdate) {
25
28
  context.store.clear();
@@ -42,7 +45,25 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
42
45
  }
43
46
 
44
47
  // Load the (modified) entries
45
- await loadEntries(options, context, token, lastModified);
48
+ try {
49
+ hasUpdatedColumn = await loadEntries(
50
+ options,
51
+ context,
52
+ token,
53
+ // Only fetch entries that have been modified since the last fetch
54
+ // If the collection does not have an updated column, all entries will be fetched
55
+ hasUpdatedColumn ? lastModified : undefined
56
+ );
57
+ } catch (error) {
58
+ // Set the `has-updated-column` meta to `false` if an error occurred
59
+ // This will force the loader to fetch all entries again in the next run
60
+ context.meta.set("has-updated-column", `${false}`);
61
+
62
+ throw error;
63
+ }
64
+
65
+ // Set the `has-updated-column` meta to `true` if the collection has an updated column
66
+ context.meta.set("has-updated-column", `${hasUpdatedColumn}`);
46
67
 
47
68
  // Set the last modified date to the current date
48
69
  context.meta.set("last-modified", new Date().toISOString());
@@ -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
  /**
@@ -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.
@@ -22,8 +22,10 @@ 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);
27
29
 
28
30
  // Generate the content for the entry
29
31
  let content: string;
@@ -1,29 +1,32 @@
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
15
18
  switch (field.type) {
16
19
  case "number":
17
20
  // Coerce the value to a number
18
- fieldType = z.number({ coerce: true });
21
+ fieldType = z.coerce.number();
19
22
  break;
20
23
  case "bool":
21
24
  // Coerce the value to a boolean
22
- fieldType = z.boolean({ coerce: true });
25
+ fieldType = z.coerce.boolean();
23
26
  break;
24
27
  case "date":
25
28
  // Coerce and parse the value as a date
26
- fieldType = z.date({ coerce: true });
29
+ fieldType = z.coerce.date();
27
30
  break;
28
31
  case "select":
29
32
  if (!field.options.values) {
@@ -58,7 +61,10 @@ export function parseSchema(
58
61
 
59
62
  // If the field is not required, mark it as optional
60
63
  if (!field.required) {
61
- fieldType.isOptional();
64
+ fieldType = z.preprocess(
65
+ (val) => val || undefined,
66
+ z.optional(fieldType)
67
+ );
62
68
  }
63
69
 
64
70
  // Add the field to the fields object
@@ -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}`