astro-loader-pocketbase 0.5.0-rc.1 → 0.5.0

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/README.md CHANGED
@@ -9,18 +9,6 @@
9
9
 
10
10
  This package is a simple loader to load data from a PocketBase database into Astro using the [Astro Loader API](https://5-0-0-beta.docs.astro.build/en/reference/loader-reference/) introduced in Astro 5.
11
11
 
12
- > [!WARNING]
13
- > This package is still under development.
14
- > It will have a first stable release when Astro 5 is released.
15
- > Until then, **breaking changes can occur at any time**.
16
-
17
- ## Compatibility
18
-
19
- | Loader version | Astro version | PocketBase version |
20
- | ---------------------------------------------------------------------------- | ------------- | ------------------ |
21
- | >= 0.5.0 | >= 5.0.0-beta | >= 0.23.0 |
22
- | <= [0.4.0](https://github.com/pawcoding/astro-loader-pocketbase/tree/v0.4.0) | >= 5.0.0-beta | < 0.23.0 |
23
-
24
12
  ## Basic usage
25
13
 
26
14
  In your content configuration file, you can use the `pocketbaseLoader` function to use your PocketBase database as a data source.
@@ -39,30 +27,12 @@ const blog = defineCollection({
39
27
  export const collections = { blog };
40
28
  ```
41
29
 
30
+ By default, the loader will only fetch entries that have been modified since the last build.
42
31
  Remember that due to the nature [Astros Content Layer lifecycle](https://astro.build/blog/content-layer-deep-dive#content-layer-lifecycle), the loader will **only fetch entries at build time**, even when using on-demand rendering.
43
32
  If you want to update your deployed site with new entries, you need to rebuild it.
44
33
 
45
34
  <sub>When running the dev server, you can trigger a reload by using `s + enter`.</sub>
46
35
 
47
- ## Incremental builds
48
-
49
- Since PocketBase 0.23.0, the `updated` field is not mandatory anymore.
50
- This means that the loader can't automatically detect when an entry has been modified.
51
- To enable incremental builds, you need to provide the name of a field in your collection that stores the last update date of an entry.
52
-
53
- ```ts
54
- const blog = defineCollection({
55
- loader: pocketbaseLoader({
56
- ...options,
57
- updatedField: "<field-in-collection>"
58
- })
59
- });
60
- ```
61
-
62
- When this field is provided, the loader will only fetch entries that have been modified since the last build.
63
- Ideally, this field should be of type `autodate` and have the value "Update" or "Create/Update" in the PocketBase dashboard.
64
- This ensures that the field is automatically updated when an entry is modified.
65
-
66
36
  ## Entries
67
37
 
68
38
  After generating the schema (see below), the loader will automatically parse the content of the entries (e.g. transform ISO dates to `Date` objects, coerce numbers, etc.).
@@ -76,7 +46,7 @@ This content will then be used when calling the `render` function of [Astros con
76
46
  const blog = defineCollection({
77
47
  loader: pocketbaseLoader({
78
48
  ...options,
79
- contentFields: "<field-in-collection>"
49
+ content: "<field-in-collection>"
80
50
  })
81
51
  });
82
52
  ```
@@ -92,6 +62,25 @@ While the API only returns the filenames of these images and files, the loader w
92
62
  This doesn't mean that the files are downloaded during the build process.
93
63
  But you can directly use these URLs in your Astro components to display images or link to the files.
94
64
 
65
+ ### Custom ids
66
+
67
+ By default, the loader will use the `id` field of the collection as the unique identifier.
68
+ If you want to use another field as the id, e.g. a slug of the title, you can specify this field via the `id` option.
69
+
70
+ ```ts
71
+ const blog = defineCollection({
72
+ loader: pocketbaseLoader({
73
+ ...options,
74
+ id: "<field-in-collection>"
75
+ })
76
+ });
77
+ ```
78
+
79
+ Please note that the id should be unique for every entry in the collection.
80
+ The loader will also automatically convert the value into a slug to be easily used in URLs.
81
+ It's recommended to use e.g. the title of the entry to be easily searchable and readable.
82
+ **Do not use e.g. rich text fields as ids.**
83
+
95
84
  ## Type generation
96
85
 
97
86
  The loader can automatically generate types for your collection.
@@ -100,16 +89,14 @@ These types can be generated in two ways:
100
89
 
101
90
  ### Remote schema
102
91
 
103
- To use the lice remote schema, you need to provide superuser credentials for the PocketBase instance.
92
+ To use the lice remote schema, you need to provide the email and password of an admin of the PocketBase instance.
104
93
 
105
94
  ```ts
106
95
  const blog = defineCollection({
107
96
  loader: pocketbaseLoader({
108
97
  ...options,
109
- superuserCredentials: {
110
- email: "<superuser-email>",
111
- password: "<superuser-password>"
112
- }
98
+ adminEmail: "<admin-email>",
99
+ adminPassword: "<admin-password>"
113
100
  })
114
101
  });
115
102
  ```
@@ -118,7 +105,7 @@ Under the hood, the loader will use the [PocketBase API](https://pocketbase.io/d
118
105
 
119
106
  ### Local schema
120
107
 
121
- If you don't want to provide superuser credentials (e.g. in a public repository), you can also provide the schema manually via a JSON file.
108
+ If you don't want to provide the admin credentials (e.g. in a public repository), you can also provide the schema manually via a JSON file.
122
109
 
123
110
  ```ts
124
111
  const blog = defineCollection({
@@ -132,7 +119,7 @@ const blog = defineCollection({
132
119
  In PocketBase you can export the schema of the whole database to a `pb_schema.json` file.
133
120
  If you provide the path to this file, the loader will use this schema to generate the types locally.
134
121
 
135
- When superuser credentials are provided, the loader will **always use the remote schema** since it's more up-to-date.
122
+ When admin credentials are provided, the loader will **always use the remote schema** since it's more up-to-date.
136
123
 
137
124
  ### Manual schema
138
125
 
@@ -141,25 +128,37 @@ This manual schema will **always override the automatic type generation**.
141
128
 
142
129
  ## All options
143
130
 
144
- | Option | Type | Required | Description |
145
- | ---------------------- | ------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
146
- | `url` | `string` | x | The URL of your PocketBase instance. |
147
- | `collectionName` | `string` | x | The name of the collection in your PocketBase instance. |
148
- | `contentFields` | `string \| Array<string>` | | The field in the collection to use as content. This can also be an array of fields. |
149
- | `updatedField` | `string` | | The field in the collection that stores the last update date of an entry. This is used for incremental builds. |
150
- | `superuserCredentials` | `{ email: string, password: string }` | | The email and password of the superuser of the PocketBase instance. This is used for automatic type generation. |
151
- | `localSchema` | `string` | | The path to a local schema file. This is used for automatic type generation. |
152
- | `jsonSchemas` | `Record<string, z.ZodSchema>` | | A record of Zod schemas to use for type generation of `json` fields. |
131
+ | Option | Type | Required | Description |
132
+ | ---------------- | ----------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
133
+ | `url` | `string` | x | The URL of your PocketBase instance. |
134
+ | `collectionName` | `string` | x | The name of the collection in your PocketBase instance. |
135
+ | `content` | `string \| Array<string>` | | The field in the collection to use as content. This can also be an array of fields. |
136
+ | `adminEmail` | `string` | | The email of the admin of the PocketBase instance. This is used for automatic type generation and access to private collections. |
137
+ | `adminPassword` | `string` | | The password of the admin of the PocketBase instance. This is used for automatic type generation and access to private collections. |
138
+ | `id` | `string` | | The field in the collection to use as unique id. Defaults to `id`. |
139
+ | `localSchema` | `string` | | The path to a local schema file. This is used for automatic type generation. |
140
+ | `jsonSchemas` | `Record<string, z.ZodSchema>` | | A record of Zod schemas to use for type generation of `json` fields. |
141
+ | `forceUpdate` | `boolean` | | If set to `true`, the loader will fetch every entry instead of only the ones modified since the last build. |
153
142
 
154
143
  ## Special cases
155
144
 
156
- ### Private collections and hidden fields
145
+ ### Private collections
157
146
 
158
- If you want to access a private collection or want to access hidden fields, you also need to provide superuser credentials.
147
+ If you want to access a private collection, you also need to provide the admin credentials.
159
148
  Otherwise, you need to make the collection public in the PocketBase dashboard.
160
149
 
161
150
  Generally, it's not recommended to use private collections, especially when users should be able to see images or other files stored in the collection.
162
151
 
152
+ ### View collections
153
+
154
+ Out of the box, the loader also supports collections with the type `view`, though with some limitations.
155
+ To enable incremental builds, the loader needs to know when an entry has been modified.
156
+ Normal `base` collections have a `updated` field that is automatically updated when an entry is modified.
157
+ Thus, `view` collections that don't include this field can't be incrementally built but will be fetched every time.
158
+
159
+ You can also alias another field as `updated` (as long as it's a date field) in your view.
160
+ While this is possible, it's not recommended since it can lead to outdated data not being fetched.
161
+
163
162
  ### JSON fields
164
163
 
165
164
  PocketBase can store arbitrary JSON data in a `json` field.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-loader-pocketbase",
3
- "version": "0.5.0-rc.1",
3
+ "version": "0.5.0",
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)",
@@ -24,7 +24,7 @@
24
24
  "@eslint/js": "^9.11.1",
25
25
  "@stylistic/eslint-plugin": "^2.8.0",
26
26
  "@types/node": "^22.7.4",
27
- "astro": "^5.0.0-beta.2",
27
+ "astro": "^5.0.0-beta.6",
28
28
  "eslint": "^9.11.1",
29
29
  "globals": "^15.9.0",
30
30
  "husky": "^9.1.6",
@@ -6,12 +6,12 @@ import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.
6
6
  *
7
7
  * @param options Options for the loader.
8
8
  * @param context Context of the loader.
9
- * @param superuserToken Superuser token to access all resources.
9
+ * @param adminToken Admin token to access all resources.
10
10
  */
11
11
  export async function cleanupEntries(
12
12
  options: PocketBaseLoaderOptions,
13
13
  context: LoaderContext,
14
- superuserToken: string | undefined
14
+ adminToken: string | undefined
15
15
  ): Promise<void> {
16
16
  // Build the URL for the collections endpoint
17
17
  const collectionUrl = new URL(
@@ -19,10 +19,10 @@ export async function cleanupEntries(
19
19
  options.url
20
20
  ).href;
21
21
 
22
- // Create the headers for the request to append the superuser token (if available)
22
+ // Create the headers for the request to append the admin token (if available)
23
23
  const collectionHeaders = new Headers();
24
- if (superuserToken) {
25
- collectionHeaders.set("Authorization", superuserToken);
24
+ if (adminToken) {
25
+ collectionHeaders.set("Authorization", adminToken);
26
26
  }
27
27
 
28
28
  // Prepare pagination variables
@@ -42,10 +42,10 @@ export async function cleanupEntries(
42
42
 
43
43
  // If the request was not successful, print the error message and return
44
44
  if (!collectionRequest.ok) {
45
- // If the collection is locked, an superuser token is required
45
+ // If the collection is locked, an admin token is required
46
46
  if (collectionRequest.status === 403) {
47
47
  context.logger.error(
48
- `(${options.collectionName}) The collection is not accessible without superuser rights. Please provide superuser credentials in the config.`
48
+ `The collection ${options.collectionName} is not accessible without an admin rights. Please provide an admin email and password in the config.`
49
49
  );
50
50
  return;
51
51
  }
@@ -53,7 +53,7 @@ export async function cleanupEntries(
53
53
  const reason = await collectionRequest
54
54
  .json()
55
55
  .then((data) => data.message);
56
- const errorMessage = `(${options.collectionName}) Fetching ids failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
56
+ const errorMessage = `Fetching ids from ${options.collectionName} failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
57
57
  context.logger.error(errorMessage);
58
58
  return;
59
59
  }
@@ -74,7 +74,7 @@ export async function cleanupEntries(
74
74
  let cleanedUp = 0;
75
75
 
76
76
  // Get all ids of the entries in the store
77
- const storedIds = context.store.keys();
77
+ const storedIds = context.store.values().map((entry) => entry.data.id) as Array<string>;
78
78
  for (const id of storedIds) {
79
79
  // If the id is not in the entries set, remove the entry from the store
80
80
  if (!entries.has(id)) {
@@ -86,7 +86,7 @@ export async function cleanupEntries(
86
86
  if (cleanedUp > 0) {
87
87
  // Log the number of cleaned up entries
88
88
  context.logger.info(
89
- `(${options.collectionName}) Cleaned up ${cleanedUp} old entries.`
89
+ `Cleaned up ${cleanedUp} old entries for ${context.collection}`
90
90
  );
91
91
  }
92
92
  }
@@ -13,13 +13,31 @@ import { transformFiles } from "./utils/transform-files";
13
13
  const BASIC_SCHEMA = {
14
14
  id: z.string(),
15
15
  collectionId: z.string().length(15),
16
- collectionName: z.string()
16
+ collectionName: z.string(),
17
+ created: z.coerce.date(),
18
+ updated: z.coerce.date()
17
19
  };
18
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()))
30
+ };
31
+
32
+ /**
33
+ * Types of fields that can be used as an ID.
34
+ */
35
+ const VALID_ID_TYPES = ["text", "number", "email", "url", "date"];
36
+
19
37
  /**
20
38
  * Generate a schema for the collection based on the collection's schema in PocketBase.
21
39
  * By default, a basic schema is returned if no other schema is available.
22
- * If superuser credentials are provided, the schema is fetched from the PocketBase API.
40
+ * If admin credentials are provided, the schema is fetched from the PocketBase API.
23
41
  * If a path to a local schema file is provided, the schema is read from the file.
24
42
  *
25
43
  * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
@@ -32,8 +50,6 @@ export async function generateSchema(
32
50
  // Try to get the schema directly from the PocketBase instance
33
51
  collection = await getRemoteSchema(options);
34
52
 
35
- const hasSuperuserRights = !!collection || !!options.superuserCredentials;
36
-
37
53
  // If the schema is not available, try to read it from a local schema file
38
54
  if (!collection && options.localSchema) {
39
55
  collection = await readLocalSchema(
@@ -45,29 +61,47 @@ export async function generateSchema(
45
61
  // If the schema is still not available, return the basic schema
46
62
  if (!collection) {
47
63
  console.error(
48
- `No schema available for "${options.collectionName}". Only basic types are available. Please check your configuration and provide a valid schema file or superuser credentials.`
64
+ `No schema available for ${options.collectionName}. Only basic types are available. Please check your configuration and provide a valid schema file or admin credentials.`
49
65
  );
50
- // Return the basic schema since every collection has at least these fields
51
- return z.object(BASIC_SCHEMA);
66
+ // Return the view schema since every collection has at least the view schema
67
+ return z.object(VIEW_SCHEMA);
52
68
  }
53
69
 
54
70
  // Parse the schema
55
- const fields = parseSchema(
56
- collection,
57
- options.jsonSchemas,
58
- hasSuperuserRights
59
- );
71
+ const fields = parseSchema(collection, options.jsonSchemas);
72
+
73
+ // Check if custom id field is present
74
+ if (options.id) {
75
+ // Find the id field in the schema
76
+ const idField = collection.schema.find(
77
+ (field) => field.name === options.id
78
+ );
79
+
80
+ // Check if the id field is present and of a valid type
81
+ if (!idField) {
82
+ console.error(
83
+ `The id field "${options.id}" is not present in the schema of the collection "${options.collectionName}".`
84
+ );
85
+ } else if (!VALID_ID_TYPES.includes(idField.type)) {
86
+ console.error(
87
+ `The id field "${options.id}" for collection "${
88
+ options.collectionName
89
+ }" is of type "${
90
+ idField.type
91
+ }" which is not recommended. Please use one of the following types: ${VALID_ID_TYPES.join(
92
+ ", "
93
+ )}.`
94
+ );
95
+ }
96
+ }
60
97
 
61
98
  // Check if the content field is present
62
- if (
63
- typeof options.contentFields === "string" &&
64
- !fields[options.contentFields]
65
- ) {
99
+ if (typeof options.content === "string" && !fields[options.content]) {
66
100
  console.error(
67
- `The content field "${options.contentFields}" is not present in the schema of the collection "${options.collectionName}".`
101
+ `The content field "${options.content}" is not present in the schema of the collection "${options.collectionName}".`
68
102
  );
69
- } else if (Array.isArray(options.contentFields)) {
70
- for (const field of options.contentFields) {
103
+ } else if (Array.isArray(options.content)) {
104
+ for (const field of options.content) {
71
105
  if (!fields[field]) {
72
106
  console.error(
73
107
  `The content field "${field}" is not present in the schema of the collection "${options.collectionName}".`
@@ -76,39 +110,18 @@ export async function generateSchema(
76
110
  }
77
111
  }
78
112
 
79
- // Check if the updated field is present
80
- if (options.updatedField) {
81
- if (!fields[options.updatedField]) {
82
- console.error(
83
- `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.`
84
- );
85
- } else {
86
- const updatedField = collection.fields.find(
87
- (field) => field.name === options.updatedField
88
- );
89
- if (
90
- !updatedField ||
91
- updatedField.type !== "autodate" ||
92
- !updatedField.onUpdate
93
- ) {
94
- console.warn(
95
- `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!`
96
- );
97
- }
98
- }
99
- }
113
+ // Use the corresponding base schema for the type of collection
114
+ // Auth collections are basically a superset of the basic schema.
115
+ const base = collection.type === "view" ? VIEW_SCHEMA : BASIC_SCHEMA;
100
116
 
101
117
  // Combine the basic schema with the parsed fields
102
118
  const schema = z.object({
103
- ...BASIC_SCHEMA,
119
+ ...base,
104
120
  ...fields
105
121
  });
106
122
 
107
123
  // Get all file fields
108
- const fileFields = collection.fields
109
- .filter((field) => field.type === "file")
110
- // Only show hidden fields if the user has superuser rights
111
- .filter((field) => !field.hidden || hasSuperuserRights);
124
+ const fileFields = collection.schema.filter((field) => field.type === "file");
112
125
 
113
126
  if (fileFields.length === 0) {
114
127
  return schema;
@@ -116,6 +129,7 @@ export async function generateSchema(
116
129
 
117
130
  // Transform file names to file urls
118
131
  return schema.transform((entry) =>
132
+ // @ts-expect-error - `updated` and `created` are already transformed to dates
119
133
  transformFiles(options.url, fileFields, entry)
120
134
  );
121
135
  }
@@ -7,7 +7,7 @@ import { parseEntry } from "./utils/parse-entry";
7
7
  *
8
8
  * @param options Options for the loader.
9
9
  * @param context Context of the loader.
10
- * @param superuserToken Superuser token to access all resources.
10
+ * @param adminToken Admin token to access all resources.
11
11
  * @param lastModified Date of the last fetch to only update changed entries.
12
12
  *
13
13
  * @returns `true` if the collection has an updated column, `false` otherwise.
@@ -15,34 +15,35 @@ import { parseEntry } from "./utils/parse-entry";
15
15
  export async function loadEntries(
16
16
  options: PocketBaseLoaderOptions,
17
17
  context: LoaderContext,
18
- superuserToken: string | undefined,
18
+ adminToken: string | undefined,
19
19
  lastModified: string | undefined
20
- ): Promise<void> {
20
+ ): Promise<boolean> {
21
21
  // Build the URL for the collections endpoint
22
22
  const collectionUrl = new URL(
23
23
  `api/collections/${options.collectionName}/records`,
24
24
  options.url
25
25
  ).href;
26
26
 
27
- // Create the headers for the request to append the superuser token (if available)
27
+ // Create the headers for the request to append the admin token (if available)
28
28
  const collectionHeaders = new Headers();
29
- if (superuserToken) {
30
- collectionHeaders.set("Authorization", superuserToken);
29
+ if (adminToken) {
30
+ collectionHeaders.set("Authorization", adminToken);
31
31
  }
32
32
 
33
33
  // Log the fetching of the entries
34
34
  context.logger.info(
35
- `(${options.collectionName}) Fetching${
36
- lastModified ? " modified" : ""
37
- } data${lastModified ? ` starting at ${lastModified}` : ""}${
38
- superuserToken ? " as superuser" : ""
39
- }`
35
+ `Fetching${lastModified ? " modified" : ""} data for ${
36
+ context.collection
37
+ } from ${collectionUrl}${
38
+ lastModified ? ` starting at ${lastModified}` : ""
39
+ }${adminToken ? " with admin token" : ""}`
40
40
  );
41
41
 
42
42
  // Prepare pagination variables
43
43
  let page = 0;
44
44
  let totalPages = 0;
45
45
  let entries = 0;
46
+ let hasUpdatedColumn = !!lastModified;
46
47
 
47
48
  // Fetch all (modified) entries
48
49
  do {
@@ -50,8 +51,8 @@ export async function loadEntries(
50
51
  // If `lastModified` is set, only fetch entries that have been modified since the last fetch
51
52
  const collectionRequest = await fetch(
52
53
  `${collectionUrl}?page=${++page}&perPage=100${
53
- lastModified && options.updatedField
54
- ? `&sort=-${options.updatedField},id&filter=(${options.updatedField}>"${lastModified}")`
54
+ lastModified
55
+ ? `&sort=-updated,id&filter=(updated>"${lastModified}")`
55
56
  : ""
56
57
  }`,
57
58
  {
@@ -61,10 +62,10 @@ export async function loadEntries(
61
62
 
62
63
  // If the request was not successful, print the error message and return
63
64
  if (!collectionRequest.ok) {
64
- // If the collection is locked, an superuser token is required
65
+ // If the collection is locked, an admin token is required
65
66
  if (collectionRequest.status === 403) {
66
67
  throw new Error(
67
- `(${options.collectionName}) The collection is not accessible without superuser rights. Please provide superuser credentials in the config.`
68
+ `The collection ${options.collectionName} is not accessible without an admin rights. Please provide an admin email and password in the config.`
68
69
  );
69
70
  }
70
71
 
@@ -72,7 +73,7 @@ export async function loadEntries(
72
73
  const reason = await collectionRequest
73
74
  .json()
74
75
  .then((data) => data.message);
75
- const errorMessage = `(${options.collectionName}) Fetching data failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
76
+ const errorMessage = `Fetching data from ${options.collectionName} failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
76
77
  throw new Error(errorMessage);
77
78
  }
78
79
 
@@ -81,12 +82,13 @@ export async function loadEntries(
81
82
 
82
83
  // Parse and store the entries
83
84
  for (const entry of response.items) {
84
- await parseEntry(
85
- entry,
86
- context,
87
- options.contentFields,
88
- options.updatedField
89
- );
85
+ await parseEntry(entry, context, options.id, 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
+ }
90
92
  }
91
93
 
92
94
  // Update the page and total pages
@@ -97,8 +99,11 @@ export async function loadEntries(
97
99
 
98
100
  // Log the number of fetched entries
99
101
  context.logger.info(
100
- `(${options.collectionName}) Fetched ${entries}${
101
- lastModified ? " changed" : ""
102
- } entries.`
102
+ `Fetched ${entries}${lastModified ? " changed" : ""} entries for ${
103
+ context.collection
104
+ }`
103
105
  );
106
+
107
+ // Return if the collection has an updated column
108
+ return hasUpdatedColumn;
104
109
  }
@@ -4,7 +4,7 @@ import { cleanupEntries } from "./cleanup-entries";
4
4
  import { generateSchema } from "./generate-schema";
5
5
  import { loadEntries } from "./load-entries";
6
6
  import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type";
7
- import { getSuperuserToken } from "./utils/get-superuser-token";
7
+ import { getAdminToken } from "./utils/get-admin-token";
8
8
 
9
9
  /**
10
10
  * Loader for collections stored in PocketBase.
@@ -15,37 +15,37 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
15
15
  return {
16
16
  name: "pocketbase-loader",
17
17
  load: async (context: LoaderContext): Promise<void> => {
18
- // Get the date of the last fetch to only update changed entries.
19
- let lastModified = context.meta.get("last-modified");
20
-
21
18
  // Check if the version has changed to force an update
22
19
  const lastVersion = context.meta.get("version");
23
20
  if (lastVersion !== packageJson.version) {
24
- if (lastVersion) {
25
- context.logger.info(
26
- `PocketBase loader was updated from ${lastVersion} to ${packageJson.version}. All entries will be loaded again.`
27
- );
28
- }
21
+ context.logger.info(
22
+ `PocketBase loader was updated from ${lastVersion} to ${packageJson.version}. All entries will be loaded again.`
23
+ );
29
24
 
30
- // Disable incremental builds and clear the store
31
- lastModified = undefined;
32
- context.store.clear();
25
+ options.forceUpdate = true;
33
26
  }
34
27
 
35
- // Disable incremental builds if no updated field is provided
36
- if (!options.updatedField) {
37
- context.logger.info(
38
- `(${options.collectionName}) No "updatedField" was provided. Incremental builds are disabled.`
39
- );
40
- lastModified = undefined;
28
+ // Get the date of the last fetch to only update changed entries.
29
+ // If `forceUpdate` is set to `true`, this will be `undefined` to fetch all entries again.
30
+ const lastModified = options.forceUpdate
31
+ ? undefined
32
+ : context.meta.get("last-modified");
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
+
37
+ // Clear the store if we want to fetch all entries again
38
+ if (options.forceUpdate) {
39
+ context.store.clear();
41
40
  }
42
41
 
43
- // Try to get a superuser token to access all resources.
42
+ // Try to get an admin token to access all resources.
44
43
  let token: string | undefined;
45
- if (options.superuserCredentials) {
46
- token = await getSuperuserToken(
44
+ if (options.adminEmail && options.adminPassword) {
45
+ token = await getAdminToken(
47
46
  options.url,
48
- options.superuserCredentials,
47
+ options.adminEmail,
48
+ options.adminPassword,
49
49
  context.logger
50
50
  );
51
51
  }
@@ -56,7 +56,25 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
56
56
  }
57
57
 
58
58
  // Load the (modified) entries
59
- 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}`);
60
78
 
61
79
  // Set the last modified date to the current date
62
80
  context.meta.set("last-modified", new Date().toISOString());
@@ -14,6 +14,14 @@ interface PocketBaseBaseEntry {
14
14
  * Name of the collection the entry belongs to.
15
15
  */
16
16
  collectionName: string;
17
+ /**
18
+ * Date the entry was created.
19
+ */
20
+ created?: string | undefined;
21
+ /**
22
+ * Date the entry was last updated.
23
+ */
24
+ updated?: string | undefined;
17
25
  }
18
26
 
19
27
  /**
@@ -13,39 +13,38 @@ export interface PocketBaseLoaderOptions {
13
13
  */
14
14
  collectionName: string;
15
15
  /**
16
- * Name of the field(s) containing the content of an entry.
17
- * This must be the name of a field in the PocketBase collection that contains the content.
16
+ * Field that should be used as the unique identifier for the collection.
17
+ * This must be the name of a field in the collection that contains unique values.
18
+ * If not provided, the `id` field will be used.
19
+ * The value of this field will be used in `getEntry` and `getEntries` to load the entry or entries.
20
+ *
21
+ * If the field is a string, it will be slugified to be used in the URL.
22
+ */
23
+ id?: string;
24
+ /**
25
+ * Content of the collection in PocketBase.
26
+ * This must be the name of a field in the collection that contains the content.
18
27
  * The content will be parsed as HTML and rendered to the page.
19
28
  *
20
29
  * If you want to render multiple fields as main content, you can pass an array of field names.
21
30
  * The loader will concatenate the content of all fields in the order they are defined in the array.
22
31
  * Each block will be contained in a `<section>` element.
23
32
  */
24
- contentFields?: string | Array<string>;
33
+ content?: string | Array<string>;
25
34
  /**
26
- * Name of the field containing the last update date of an entry.
27
- * Ideally, this field should be of type `autodate` and have the value "Update" or "Create/Update".
28
- * This field is used to only fetch entries that have been modified since the last build.
35
+ * Email of an admin to get full access to the PocketBase instance.
36
+ * Together with `adminPassword` this is required to get automatic type generation and to access all resources even if they are not public.
29
37
  */
30
- updatedField?: string;
38
+ adminEmail?: string;
31
39
  /**
32
- * Credentials of a superuser to get full access to the PocketBase instance.
33
- * This is required to get automatic type generation, to access all resources even if they are not public and fetch content of hidden fields.
40
+ * Password of the admin to get full access to the PocketBase instance.
41
+ * Together with `adminEmail` this is required to get automatic type generation and to access all resources even if they are not public.
34
42
  */
35
- superuserCredentials?: {
36
- /**
37
- * Email of the superuser.
38
- */
39
- email: string;
40
- /**
41
- * Password of the superuser.
42
- */
43
- password: string;
44
- };
43
+ adminPassword?: string;
45
44
  /**
46
45
  * File path to the local schema file.
47
46
  * This file will be used to generate the schema for the collection.
48
- * If `superuserCredentials` are provided, this option will be ignored.
47
+ * If admin credentials are provided (see `adminEmail` and `adminPassword`), this option will be ignored.
49
48
  */
50
49
  localSchema?: string;
51
50
  /**
@@ -56,4 +55,9 @@ export interface PocketBaseLoaderOptions {
56
55
  * Note that this will only be used for fields of type `json`.
57
56
  */
58
57
  jsonSchemas?: Record<string, z.ZodSchema>;
58
+ /**
59
+ * By default, the loader will only fetch entries that have been modified since the last fetch.
60
+ * If you want to fetch all entries, set this to `true`.
61
+ */
62
+ forceUpdate?: boolean;
59
63
  }
@@ -2,11 +2,6 @@
2
2
  * Entry for a collections schema in PocketBase.
3
3
  */
4
4
  export interface PocketBaseSchemaEntry {
5
- /**
6
- * Flag to indicate if the field is hidden.
7
- * Hidden fields are not returned in the API response.
8
- */
9
- hidden: boolean;
10
5
  /**
11
6
  * Name of the field.
12
7
  */
@@ -20,25 +15,20 @@ export interface PocketBaseSchemaEntry {
20
15
  */
21
16
  required: boolean;
22
17
  /**
23
- * Values for a select field.
24
- * This is only present if the field type is "select".
25
- */
26
- values?: Array<string>;
27
- /**
28
- * Maximum number of values for a select field.
29
- * This is only present on "select", "relation", and "file" fields.
30
- */
31
- maxSelect?: number;
32
- /**
33
- * Whether the field is filled when the entry is created.
34
- * This is only present on "autodate" fields.
35
- */
36
- onCreate?: boolean;
37
- /**
38
- * Whether the field is updated when the entry is updated.
39
- * This is only present on "autodate" fields.
40
- */
41
- onUpdate?: boolean;
18
+ * Options for the field.
19
+ */
20
+ options: {
21
+ /**
22
+ * Values for a select field.
23
+ * This is only present if the field type is "select".
24
+ */
25
+ values?: Array<string>;
26
+ /**
27
+ * Maximum number of values for a select field.
28
+ * This is only present on "select", "relation", and "file" fields.
29
+ */
30
+ maxSelect?: number;
31
+ };
42
32
  }
43
33
 
44
34
  /**
@@ -52,9 +42,9 @@ export interface PocketBaseCollection {
52
42
  /**
53
43
  * Type of the collection.
54
44
  */
55
- type: "base" | "view" | "auth";
45
+ type: 'base' | 'view' | 'auth';
56
46
  /**
57
47
  * Schema of the collection.
58
48
  */
59
- fields: Array<PocketBaseSchemaEntry>;
49
+ schema: Array<PocketBaseSchemaEntry>;
60
50
  }
@@ -1,31 +1,27 @@
1
1
  import type { AstroIntegrationLogger } from "astro";
2
2
 
3
3
  /**
4
- * This function will get a superuser token from the given PocketBase instance.
4
+ * This function will get an admin token from the given PocketBase instance.
5
5
  *
6
6
  * @param url URL of the PocketBase instance
7
- * @param superuserCredentials Credentials of the superuser
7
+ * @param email Email of the admin
8
+ * @param password Password of the admin
8
9
  *
9
- * @returns A superuser token to access all resources of the PocketBase instance.
10
+ * @returns An admin token to access all resources of the PocketBase instance.
10
11
  */
11
- export async function getSuperuserToken(
12
+ export async function getAdminToken(
12
13
  url: string,
13
- superuserCredentials: {
14
- email: string;
15
- password: string;
16
- },
14
+ email: string,
15
+ password: string,
17
16
  logger?: AstroIntegrationLogger
18
17
  ): Promise<string | undefined> {
19
18
  // Build the URL for the login endpoint
20
- const loginUrl = new URL(
21
- `api/collections/_superusers/auth-with-password`,
22
- url
23
- ).href;
19
+ const loginUrl = new URL(`api/admins/auth-with-password`, url).href;
24
20
 
25
21
  // Create a new FormData object to send the login data
26
22
  const loginData = new FormData();
27
- loginData.set("identity", superuserCredentials.email);
28
- loginData.set("password", superuserCredentials.password);
23
+ loginData.set("identity", email);
24
+ loginData.set("password", password);
29
25
 
30
26
  // Send the login request to get a token
31
27
  const loginRequest = await fetch(loginUrl, {
@@ -1,6 +1,6 @@
1
1
  import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type";
2
2
  import type { PocketBaseCollection } from "../types/pocketbase-schema.type";
3
- import { getSuperuserToken } from "./get-superuser-token";
3
+ import { getAdminToken } from "./get-admin-token";
4
4
 
5
5
  /**
6
6
  * Fetches the schema for the specified collection from the PocketBase instance.
@@ -10,17 +10,18 @@ import { getSuperuserToken } from "./get-superuser-token";
10
10
  export async function getRemoteSchema(
11
11
  options: PocketBaseLoaderOptions
12
12
  ): Promise<PocketBaseCollection | undefined> {
13
- if (!options.superuserCredentials) {
13
+ if (!options.adminEmail || !options.adminPassword) {
14
14
  return undefined;
15
15
  }
16
16
 
17
- // Get a superuser token
18
- const token = await getSuperuserToken(
17
+ // Get the admin token
18
+ const token = await getAdminToken(
19
19
  options.url,
20
- options.superuserCredentials
20
+ options.adminEmail,
21
+ options.adminPassword
21
22
  );
22
23
 
23
- // If the token is invalid try another method
24
+ // If the token is invalid, return the basic schema
24
25
  if (!token) {
25
26
  return undefined;
26
27
  }
@@ -38,7 +39,7 @@ export async function getRemoteSchema(
38
39
  headers: schemaHeaders
39
40
  });
40
41
 
41
- // If the request was not successful, try another method
42
+ // If the request was not successful, return the basic schema
42
43
  if (!schemaRequest.ok) {
43
44
  const reason = await schemaRequest.json().then((data) => data.message);
44
45
  const errorMessage = `Fetching schema from ${options.collectionName} failed with status code ${schemaRequest.status}.\nReason: ${reason}`;
@@ -1,41 +1,61 @@
1
1
  import type { LoaderContext } from "astro/loaders";
2
2
  import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
3
+ import { slugify } from "./slugify";
3
4
 
4
5
  /**
5
6
  * Parse an entry from PocketBase to match the schema and store it in the store.
6
7
  *
7
8
  * @param entry Entry to parse.
8
9
  * @param context Context of the loader.
10
+ * @param idField Field to use as id for the entry.
11
+ * If not provided, the id of the entry will be used.
9
12
  * @param contentFields Field(s) to use as content for the entry.
10
13
  * If multiple fields are used, they will be concatenated and wrapped in `<section>` elements.
11
14
  */
12
15
  export async function parseEntry(
13
16
  entry: PocketBaseEntry,
14
- { generateDigest, parseData, store }: LoaderContext,
15
- contentFields: string | Array<string> | undefined,
16
- updatedField: string | undefined
17
+ { generateDigest, parseData, store, logger }: LoaderContext,
18
+ idField?: string,
19
+ contentFields?: string | Array<string>
17
20
  ): Promise<void> {
21
+ let id = entry.id;
22
+ if (idField) {
23
+ // Get the custom ID of the entry if it exists
24
+ const customEntryId = entry[idField];
25
+
26
+ if (!customEntryId) {
27
+ logger.warn(
28
+ `The entry "${id}" does not have a value for field ${idField}. Using the default ID instead.`
29
+ );
30
+ } else {
31
+ id = slugify(`${customEntryId}`);
32
+ }
33
+ }
34
+
35
+ const oldEntry = store.get(id);
36
+ if (oldEntry && oldEntry.data.id !== entry.id) {
37
+ logger.warn(
38
+ `The entry "${entry.id}" seems to be a duplicate of "${oldEntry.data.id}". Please make sure to use unique IDs in the column "${idField}".`
39
+ );
40
+ }
41
+
18
42
  // Parse the data to match the schema
19
43
  // This will throw an error if the data does not match the schema
20
44
  const data = await parseData({
21
- id: entry.id,
45
+ id,
22
46
  data: entry
23
47
  });
24
48
 
25
- // Get the updated date of the entry
26
- let updated: string | undefined;
27
- if (updatedField) {
28
- updated = `${entry[updatedField]}`;
29
- }
30
-
31
49
  // Generate a digest for the entry
32
- // If no updated date is available, the digest will be generated from the whole entry
33
- const digest = generateDigest(updated ?? entry);
50
+ // Normal collections use the updated date that is always updated when the entry is updated.
51
+ // If the entry was never updated, the created date can be used as a fallback.
52
+ // View collections don't necessarily publish the updated date, so the whole entry is used for the digest.
53
+ const digest = generateDigest(entry.updated ?? entry.created ?? entry);
34
54
 
35
55
  if (!contentFields) {
36
56
  // Store the entry
37
57
  store.set({
38
- id: entry.id,
58
+ id,
39
59
  data,
40
60
  digest
41
61
  });
@@ -57,7 +77,7 @@ export async function parseEntry(
57
77
 
58
78
  // Store the entry
59
79
  store.set({
60
- id: entry.id,
80
+ id,
61
81
  data,
62
82
  digest,
63
83
  rendered: {
@@ -6,19 +6,13 @@ import type {
6
6
 
7
7
  export function parseSchema(
8
8
  collection: PocketBaseCollection,
9
- customSchemas: Record<string, z.ZodType> | undefined,
10
- hasSuperuserRights: boolean
9
+ customSchemas: Record<string, z.ZodType> | undefined
11
10
  ): Record<string, z.ZodType> {
12
11
  // Prepare the schemas fields
13
12
  const fields: Record<string, z.ZodType> = {};
14
13
 
15
14
  // Parse every field in the schema
16
- for (const field of collection.fields) {
17
- // Skip hidden fields if the user does not have superuser rights
18
- if (field.hidden && !hasSuperuserRights) {
19
- continue;
20
- }
21
-
15
+ for (const field of collection.schema) {
22
16
  let fieldType;
23
17
 
24
18
  // Determine the field type and create the corresponding Zod type
@@ -32,12 +26,11 @@ export function parseSchema(
32
26
  fieldType = z.coerce.boolean();
33
27
  break;
34
28
  case "date":
35
- case "autodate":
36
29
  // Coerce and parse the value as a date
37
30
  fieldType = z.coerce.date();
38
31
  break;
39
32
  case "select":
40
- if (!field.values) {
33
+ if (!field.options.values) {
41
34
  throw new Error(
42
35
  `Field ${field.name} is of type "select" but has no values defined.`
43
36
  );
@@ -45,7 +38,7 @@ export function parseSchema(
45
38
 
46
39
  // Create an enum for the select values
47
40
  // @ts-expect-error - Zod complains because the values are not known at compile time and thus the array is not static.
48
- const values = z.enum(field.values);
41
+ const values = z.enum(field.options.values);
49
42
 
50
43
  // Parse the field type based on the number of values it can have
51
44
  fieldType = parseSingleOrMultipleValues(field, values);
@@ -73,12 +66,8 @@ export function parseSchema(
73
66
  break;
74
67
  }
75
68
 
76
- // Check if the field is required (onCreate autodate fields are always set)
77
- const isRequired =
78
- field.required || (field.type === "autodate" && field.onCreate);
79
-
80
69
  // If the field is not required, mark it as optional
81
- if (!isRequired) {
70
+ if (!field.required) {
82
71
  fieldType = z.preprocess(
83
72
  (val) => val || undefined,
84
73
  z.optional(fieldType)
@@ -105,7 +94,7 @@ function parseSingleOrMultipleValues(
105
94
  type: z.ZodType
106
95
  ) {
107
96
  // If the select allows multiple values, create an array of the enum
108
- if (field.maxSelect === undefined || field.maxSelect === 1) {
97
+ if (field.options.maxSelect === undefined || field.options.maxSelect === 1) {
109
98
  return type;
110
99
  } else {
111
100
  return z.array(type);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Convert a string to a slug.
3
+ *
4
+ * Example:
5
+ * ```ts
6
+ * slugify("Hello World!"); // hello-world
7
+ * ```
8
+ */
9
+ export function slugify(input: string): string {
10
+ return input
11
+ .toString()
12
+ .toLowerCase()
13
+ .replace(/\s+/g, "-") // Replace spaces with -
14
+ .replace(/ä/g, "ae") // Replace umlauts
15
+ .replace(/ö/g, "oe")
16
+ .replace(/ü/g, "ue")
17
+ .replace(/ß/g, "ss")
18
+ .replace(/[^\w-]+/g, "") // Remove all non-word chars
19
+ .replace(/--+/g, "-") // Replace multiple - with single -
20
+ .replace(/^-+/, "") // Trim - from start of text
21
+ .replace(/-+$/, ""); // Trim - from end of text
22
+ }
@@ -17,7 +17,7 @@ export function transformFiles(
17
17
  for (const field of fileFields) {
18
18
  const fieldName = field.name;
19
19
 
20
- if (field.maxSelect === 1) {
20
+ if (field.options.maxSelect === 1) {
21
21
  const fileName = entry[fieldName] as string | undefined;
22
22
  // Check if a file name is present
23
23
  if (!fileName) {