astro-loader-pocketbase 1.0.0 → 2.0.0-next.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/README.md +40 -30
- package/package.json +1 -1
- package/src/cleanup-entries.ts +9 -9
- package/src/generate-schema.ts +50 -34
- package/src/load-entries.ts +20 -30
- package/src/pocketbase-loader.ts +23 -41
- package/src/types/pocketbase-entry.type.ts +0 -8
- package/src/types/pocketbase-loader-options.type.ts +21 -16
- package/src/types/pocketbase-schema.type.ts +26 -16
- package/src/utils/get-remote-schema.ts +7 -8
- package/src/utils/{get-admin-token.ts → get-superuser-token.ts} +14 -10
- package/src/utils/parse-entry.ts +10 -6
- package/src/utils/parse-schema.ts +17 -6
- package/src/utils/transform-files.ts +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ This package is a simple loader to load data from a PocketBase database into Ast
|
|
|
15
15
|
|
|
16
16
|
| Loader | Astro | PocketBase |
|
|
17
17
|
| ------ | ----- | ---------- |
|
|
18
|
+
| 2.0.0 | 5.0.0 | >= 0.23.0 |
|
|
18
19
|
| 1.0.0 | 5.0.0 | <= 0.22.0 |
|
|
19
20
|
|
|
20
21
|
## Basic usage
|
|
@@ -35,12 +36,30 @@ const blog = defineCollection({
|
|
|
35
36
|
export const collections = { blog };
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
By default, the loader will only fetch entries that have been modified since the last build.
|
|
39
39
|
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.
|
|
40
40
|
If you want to update your deployed site with new entries, you need to rebuild it.
|
|
41
41
|
|
|
42
42
|
<sub>When running the dev server, you can trigger a reload by using `s + enter`.</sub>
|
|
43
43
|
|
|
44
|
+
## Incremental builds
|
|
45
|
+
|
|
46
|
+
Since PocketBase 0.23.0, the `updated` field is not mandatory anymore.
|
|
47
|
+
This means that the loader can't automatically detect when an entry has been modified.
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const blog = defineCollection({
|
|
52
|
+
loader: pocketbaseLoader({
|
|
53
|
+
...options,
|
|
54
|
+
updatedField: "<field-in-collection>"
|
|
55
|
+
})
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
When this field is provided, the loader will only fetch entries that have been modified since the last build.
|
|
60
|
+
Ideally, this field should be of type `autodate` and have the value "Update" or "Create/Update" in the PocketBase dashboard.
|
|
61
|
+
This ensures that the field is automatically updated when an entry is modified.
|
|
62
|
+
|
|
44
63
|
## Entries
|
|
45
64
|
|
|
46
65
|
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.).
|
|
@@ -54,7 +73,7 @@ This content will then be used when calling the `render` function of [Astros con
|
|
|
54
73
|
const blog = defineCollection({
|
|
55
74
|
loader: pocketbaseLoader({
|
|
56
75
|
...options,
|
|
57
|
-
|
|
76
|
+
contentFields: "<field-in-collection>"
|
|
58
77
|
})
|
|
59
78
|
});
|
|
60
79
|
```
|
|
@@ -97,14 +116,16 @@ These types can be generated in two ways:
|
|
|
97
116
|
|
|
98
117
|
### Remote schema
|
|
99
118
|
|
|
100
|
-
To use the
|
|
119
|
+
To use the live remote schema, you need to provide superuser credentials for the PocketBase instance.
|
|
101
120
|
|
|
102
121
|
```ts
|
|
103
122
|
const blog = defineCollection({
|
|
104
123
|
loader: pocketbaseLoader({
|
|
105
124
|
...options,
|
|
106
|
-
|
|
107
|
-
|
|
125
|
+
superuserCredentials: {
|
|
126
|
+
email: "<superuser-email>",
|
|
127
|
+
password: "<superuser-password>"
|
|
128
|
+
}
|
|
108
129
|
})
|
|
109
130
|
});
|
|
110
131
|
```
|
|
@@ -113,7 +134,7 @@ Under the hood, the loader will use the [PocketBase API](https://pocketbase.io/d
|
|
|
113
134
|
|
|
114
135
|
### Local schema
|
|
115
136
|
|
|
116
|
-
If you don't want to provide
|
|
137
|
+
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.
|
|
117
138
|
|
|
118
139
|
```ts
|
|
119
140
|
const blog = defineCollection({
|
|
@@ -127,7 +148,7 @@ const blog = defineCollection({
|
|
|
127
148
|
In PocketBase you can export the schema of the whole database to a `pb_schema.json` file.
|
|
128
149
|
If you provide the path to this file, the loader will use this schema to generate the types locally.
|
|
129
150
|
|
|
130
|
-
When
|
|
151
|
+
When superuser credentials are provided, the loader will **always use the remote schema** since it's more up-to-date.
|
|
131
152
|
|
|
132
153
|
### Manual schema
|
|
133
154
|
|
|
@@ -136,37 +157,26 @@ This manual schema will **always override the automatic type generation**.
|
|
|
136
157
|
|
|
137
158
|
## All options
|
|
138
159
|
|
|
139
|
-
| Option
|
|
140
|
-
|
|
|
141
|
-
| `url`
|
|
142
|
-
| `collectionName`
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `localSchema`
|
|
148
|
-
| `jsonSchemas`
|
|
149
|
-
| `forceUpdate` | `boolean` | | If set to `true`, the loader will fetch every entry instead of only the ones modified since the last build. |
|
|
160
|
+
| Option | Type | Required | Description |
|
|
161
|
+
| ---------------------- | ------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
|
|
162
|
+
| `url` | `string` | x | The URL of your PocketBase instance. |
|
|
163
|
+
| `collectionName` | `string` | x | The name of the collection in your PocketBase instance. |
|
|
164
|
+
| `idField` | `string` | | The field in the collection to use as unique id. Defaults to `id`. |
|
|
165
|
+
| `contentFields` | `string \| Array<string>` | | The field in the collection to use as content. This can also be an array of fields. |
|
|
166
|
+
| `updatedField` | `string` | | The field in the collection that stores the last update date of an entry. This is used for incremental builds. |
|
|
167
|
+
| `superuserCredentials` | `{ email: string, password: string }` | | The email and password of the superuser of the PocketBase instance. This is used for automatic type generation. |
|
|
168
|
+
| `localSchema` | `string` | | The path to a local schema file. This is used for automatic type generation. |
|
|
169
|
+
| `jsonSchemas` | `Record<string, z.ZodSchema>` | | A record of Zod schemas to use for type generation of `json` fields. |
|
|
150
170
|
|
|
151
171
|
## Special cases
|
|
152
172
|
|
|
153
|
-
### Private collections
|
|
173
|
+
### Private collections and hidden fields
|
|
154
174
|
|
|
155
|
-
If you want to access a private collection, you also need to provide
|
|
175
|
+
If you want to access a private collection or hidden fields, you also need to provide superuser credentials.
|
|
156
176
|
Otherwise, you need to make the collection public in the PocketBase dashboard.
|
|
157
177
|
|
|
158
178
|
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.
|
|
159
179
|
|
|
160
|
-
### View collections
|
|
161
|
-
|
|
162
|
-
Out of the box, the loader also supports collections with the type `view`, though with some limitations.
|
|
163
|
-
To enable incremental builds, the loader needs to know when an entry has been modified.
|
|
164
|
-
Normal `base` collections have a `updated` field that is automatically updated when an entry is modified.
|
|
165
|
-
Thus, `view` collections that don't include this field can't be incrementally built but will be fetched every time.
|
|
166
|
-
|
|
167
|
-
You can also alias another field as `updated` (as long as it's a date field) in your view.
|
|
168
|
-
While this is possible, it's not recommended since it can lead to outdated data not being fetched.
|
|
169
|
-
|
|
170
180
|
### JSON fields
|
|
171
181
|
|
|
172
182
|
PocketBase can store arbitrary JSON data in a `json` field.
|
package/package.json
CHANGED
package/src/cleanup-entries.ts
CHANGED
|
@@ -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
|
|
9
|
+
* @param superuserToken Superuser token to access all resources.
|
|
10
10
|
*/
|
|
11
11
|
export async function cleanupEntries(
|
|
12
12
|
options: PocketBaseLoaderOptions,
|
|
13
13
|
context: LoaderContext,
|
|
14
|
-
|
|
14
|
+
superuserToken: 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
|
|
22
|
+
// Create the headers for the request to append the superuser token (if available)
|
|
23
23
|
const collectionHeaders = new Headers();
|
|
24
|
-
if (
|
|
25
|
-
collectionHeaders.set("Authorization",
|
|
24
|
+
if (superuserToken) {
|
|
25
|
+
collectionHeaders.set("Authorization", superuserToken);
|
|
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
|
|
45
|
+
// If the collection is locked, an superuser token is required
|
|
46
46
|
if (collectionRequest.status === 403) {
|
|
47
47
|
context.logger.error(
|
|
48
|
-
`
|
|
48
|
+
`(${options.collectionName}) The collection is not accessible without superuser rights. Please provide superuser credentials 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 = `
|
|
56
|
+
const errorMessage = `(${options.collectionName}) Fetching ids failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
|
|
57
57
|
context.logger.error(errorMessage);
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
@@ -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
|
-
`Cleaned up ${cleanedUp} old entries
|
|
89
|
+
`(${options.collectionName}) Cleaned up ${cleanedUp} old entries.`
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
}
|
package/src/generate-schema.ts
CHANGED
|
@@ -13,20 +13,7 @@ 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()
|
|
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()))
|
|
16
|
+
collectionName: z.string()
|
|
30
17
|
};
|
|
31
18
|
|
|
32
19
|
/**
|
|
@@ -37,7 +24,7 @@ const VALID_ID_TYPES = ["text", "number", "email", "url", "date"];
|
|
|
37
24
|
/**
|
|
38
25
|
* Generate a schema for the collection based on the collection's schema in PocketBase.
|
|
39
26
|
* By default, a basic schema is returned if no other schema is available.
|
|
40
|
-
* If
|
|
27
|
+
* If superuser credentials are provided, the schema is fetched from the PocketBase API.
|
|
41
28
|
* If a path to a local schema file is provided, the schema is read from the file.
|
|
42
29
|
*
|
|
43
30
|
* @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
|
|
@@ -50,6 +37,8 @@ export async function generateSchema(
|
|
|
50
37
|
// Try to get the schema directly from the PocketBase instance
|
|
51
38
|
collection = await getRemoteSchema(options);
|
|
52
39
|
|
|
40
|
+
const hasSuperuserRights = !!collection || !!options.superuserCredentials;
|
|
41
|
+
|
|
53
42
|
// If the schema is not available, try to read it from a local schema file
|
|
54
43
|
if (!collection && options.localSchema) {
|
|
55
44
|
collection = await readLocalSchema(
|
|
@@ -61,30 +50,34 @@ export async function generateSchema(
|
|
|
61
50
|
// If the schema is still not available, return the basic schema
|
|
62
51
|
if (!collection) {
|
|
63
52
|
console.error(
|
|
64
|
-
`No schema available for ${options.collectionName}. Only basic types are available. Please check your configuration and provide a valid schema file or
|
|
53
|
+
`No schema available for "${options.collectionName}". Only basic types are available. Please check your configuration and provide a valid schema file or superuser credentials.`
|
|
65
54
|
);
|
|
66
|
-
// Return the
|
|
67
|
-
return z.object(
|
|
55
|
+
// Return the basic schema since every collection has at least these fields
|
|
56
|
+
return z.object(BASIC_SCHEMA);
|
|
68
57
|
}
|
|
69
58
|
|
|
70
59
|
// Parse the schema
|
|
71
|
-
const fields = parseSchema(
|
|
60
|
+
const fields = parseSchema(
|
|
61
|
+
collection,
|
|
62
|
+
options.jsonSchemas,
|
|
63
|
+
hasSuperuserRights
|
|
64
|
+
);
|
|
72
65
|
|
|
73
66
|
// Check if custom id field is present
|
|
74
|
-
if (options.
|
|
67
|
+
if (options.idField) {
|
|
75
68
|
// Find the id field in the schema
|
|
76
|
-
const idField = collection.
|
|
77
|
-
(field) => field.name === options.
|
|
69
|
+
const idField = collection.fields.find(
|
|
70
|
+
(field) => field.name === options.idField
|
|
78
71
|
);
|
|
79
72
|
|
|
80
73
|
// Check if the id field is present and of a valid type
|
|
81
74
|
if (!idField) {
|
|
82
75
|
console.error(
|
|
83
|
-
`The id field "${options.
|
|
76
|
+
`The id field "${options.idField}" is not present in the schema of the collection "${options.collectionName}".`
|
|
84
77
|
);
|
|
85
78
|
} else if (!VALID_ID_TYPES.includes(idField.type)) {
|
|
86
79
|
console.error(
|
|
87
|
-
`The id field "${options.
|
|
80
|
+
`The id field "${options.idField}" for collection "${
|
|
88
81
|
options.collectionName
|
|
89
82
|
}" is of type "${
|
|
90
83
|
idField.type
|
|
@@ -96,12 +89,15 @@ export async function generateSchema(
|
|
|
96
89
|
}
|
|
97
90
|
|
|
98
91
|
// Check if the content field is present
|
|
99
|
-
if (
|
|
92
|
+
if (
|
|
93
|
+
typeof options.contentFields === "string" &&
|
|
94
|
+
!fields[options.contentFields]
|
|
95
|
+
) {
|
|
100
96
|
console.error(
|
|
101
|
-
`The content field "${options.
|
|
97
|
+
`The content field "${options.contentFields}" is not present in the schema of the collection "${options.collectionName}".`
|
|
102
98
|
);
|
|
103
|
-
} else if (Array.isArray(options.
|
|
104
|
-
for (const field of options.
|
|
99
|
+
} else if (Array.isArray(options.contentFields)) {
|
|
100
|
+
for (const field of options.contentFields) {
|
|
105
101
|
if (!fields[field]) {
|
|
106
102
|
console.error(
|
|
107
103
|
`The content field "${field}" is not present in the schema of the collection "${options.collectionName}".`
|
|
@@ -110,18 +106,39 @@ export async function generateSchema(
|
|
|
110
106
|
}
|
|
111
107
|
}
|
|
112
108
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
// Check if the updated field is present
|
|
110
|
+
if (options.updatedField) {
|
|
111
|
+
if (!fields[options.updatedField]) {
|
|
112
|
+
console.error(
|
|
113
|
+
`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.`
|
|
114
|
+
);
|
|
115
|
+
} else {
|
|
116
|
+
const updatedField = collection.fields.find(
|
|
117
|
+
(field) => field.name === options.updatedField
|
|
118
|
+
);
|
|
119
|
+
if (
|
|
120
|
+
!updatedField ||
|
|
121
|
+
updatedField.type !== "autodate" ||
|
|
122
|
+
!updatedField.onUpdate
|
|
123
|
+
) {
|
|
124
|
+
console.warn(
|
|
125
|
+
`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!`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
116
130
|
|
|
117
131
|
// Combine the basic schema with the parsed fields
|
|
118
132
|
const schema = z.object({
|
|
119
|
-
...
|
|
133
|
+
...BASIC_SCHEMA,
|
|
120
134
|
...fields
|
|
121
135
|
});
|
|
122
136
|
|
|
123
137
|
// Get all file fields
|
|
124
|
-
const fileFields = collection.
|
|
138
|
+
const fileFields = collection.fields
|
|
139
|
+
.filter((field) => field.type === "file")
|
|
140
|
+
// Only show hidden fields if the user has superuser rights
|
|
141
|
+
.filter((field) => !field.hidden || hasSuperuserRights);
|
|
125
142
|
|
|
126
143
|
if (fileFields.length === 0) {
|
|
127
144
|
return schema;
|
|
@@ -129,7 +146,6 @@ export async function generateSchema(
|
|
|
129
146
|
|
|
130
147
|
// Transform file names to file urls
|
|
131
148
|
return schema.transform((entry) =>
|
|
132
|
-
// @ts-expect-error - `updated` and `created` are already transformed to dates
|
|
133
149
|
transformFiles(options.url, fileFields, entry)
|
|
134
150
|
);
|
|
135
151
|
}
|
package/src/load-entries.ts
CHANGED
|
@@ -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
|
|
10
|
+
* @param superuserToken Superuser 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,35 +15,34 @@ import { parseEntry } from "./utils/parse-entry";
|
|
|
15
15
|
export async function loadEntries(
|
|
16
16
|
options: PocketBaseLoaderOptions,
|
|
17
17
|
context: LoaderContext,
|
|
18
|
-
|
|
18
|
+
superuserToken: string | undefined,
|
|
19
19
|
lastModified: string | undefined
|
|
20
|
-
): Promise<
|
|
20
|
+
): Promise<void> {
|
|
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
|
|
27
|
+
// Create the headers for the request to append the superuser token (if available)
|
|
28
28
|
const collectionHeaders = new Headers();
|
|
29
|
-
if (
|
|
30
|
-
collectionHeaders.set("Authorization",
|
|
29
|
+
if (superuserToken) {
|
|
30
|
+
collectionHeaders.set("Authorization", superuserToken);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// Log the fetching of the entries
|
|
34
34
|
context.logger.info(
|
|
35
|
-
`
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
}
|
|
35
|
+
`(${options.collectionName}) Fetching${
|
|
36
|
+
lastModified ? " modified" : ""
|
|
37
|
+
} data${lastModified ? ` starting at ${lastModified}` : ""}${
|
|
38
|
+
superuserToken ? " as superuser" : ""
|
|
39
|
+
}`
|
|
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;
|
|
47
46
|
|
|
48
47
|
// Fetch all (modified) entries
|
|
49
48
|
do {
|
|
@@ -51,8 +50,8 @@ export async function loadEntries(
|
|
|
51
50
|
// If `lastModified` is set, only fetch entries that have been modified since the last fetch
|
|
52
51
|
const collectionRequest = await fetch(
|
|
53
52
|
`${collectionUrl}?page=${++page}&perPage=100${
|
|
54
|
-
lastModified
|
|
55
|
-
? `&sort
|
|
53
|
+
lastModified && options.updatedField
|
|
54
|
+
? `&sort=-${options.updatedField},id&filter=(${options.updatedField}>"${lastModified}")`
|
|
56
55
|
: ""
|
|
57
56
|
}`,
|
|
58
57
|
{
|
|
@@ -62,10 +61,10 @@ export async function loadEntries(
|
|
|
62
61
|
|
|
63
62
|
// If the request was not successful, print the error message and return
|
|
64
63
|
if (!collectionRequest.ok) {
|
|
65
|
-
// If the collection is locked, an
|
|
64
|
+
// If the collection is locked, an superuser token is required
|
|
66
65
|
if (collectionRequest.status === 403) {
|
|
67
66
|
throw new Error(
|
|
68
|
-
`
|
|
67
|
+
`(${options.collectionName}) The collection is not accessible without superuser rights. Please provide superuser credentials in the config.`
|
|
69
68
|
);
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -73,7 +72,7 @@ export async function loadEntries(
|
|
|
73
72
|
const reason = await collectionRequest
|
|
74
73
|
.json()
|
|
75
74
|
.then((data) => data.message);
|
|
76
|
-
const errorMessage = `
|
|
75
|
+
const errorMessage = `(${options.collectionName}) Fetching data failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
|
|
77
76
|
throw new Error(errorMessage);
|
|
78
77
|
}
|
|
79
78
|
|
|
@@ -82,13 +81,7 @@ export async function loadEntries(
|
|
|
82
81
|
|
|
83
82
|
// Parse and store the entries
|
|
84
83
|
for (const entry of response.items) {
|
|
85
|
-
await parseEntry(entry, context, options
|
|
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
|
-
}
|
|
84
|
+
await parseEntry(entry, context, options);
|
|
92
85
|
}
|
|
93
86
|
|
|
94
87
|
// Update the page and total pages
|
|
@@ -99,11 +92,8 @@ export async function loadEntries(
|
|
|
99
92
|
|
|
100
93
|
// Log the number of fetched entries
|
|
101
94
|
context.logger.info(
|
|
102
|
-
`Fetched ${entries}${
|
|
103
|
-
|
|
104
|
-
}
|
|
95
|
+
`(${options.collectionName}) Fetched ${entries}${
|
|
96
|
+
lastModified ? " changed" : ""
|
|
97
|
+
} entries.`
|
|
105
98
|
);
|
|
106
|
-
|
|
107
|
-
// Return if the collection has an updated column
|
|
108
|
-
return hasUpdatedColumn;
|
|
109
99
|
}
|
package/src/pocketbase-loader.ts
CHANGED
|
@@ -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 {
|
|
7
|
+
import { getSuperuserToken } from "./utils/get-superuser-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
|
+
|
|
18
21
|
// Check if the version has changed to force an update
|
|
19
22
|
const lastVersion = context.meta.get("version");
|
|
20
23
|
if (lastVersion !== packageJson.version) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
// Disable incremental builds and clear the store
|
|
31
|
+
lastModified = undefined;
|
|
32
|
+
context.store.clear();
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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();
|
|
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;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
// Try to get
|
|
43
|
+
// Try to get a superuser token to access all resources.
|
|
43
44
|
let token: string | undefined;
|
|
44
|
-
if (options.
|
|
45
|
-
token = await
|
|
45
|
+
if (options.superuserCredentials) {
|
|
46
|
+
token = await getSuperuserToken(
|
|
46
47
|
options.url,
|
|
47
|
-
options.
|
|
48
|
-
options.adminPassword,
|
|
48
|
+
options.superuserCredentials,
|
|
49
49
|
context.logger
|
|
50
50
|
);
|
|
51
51
|
}
|
|
@@ -56,25 +56,7 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Load the (modified) entries
|
|
59
|
-
|
|
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}`);
|
|
59
|
+
await loadEntries(options, context, token, lastModified);
|
|
78
60
|
|
|
79
61
|
// Set the last modified date to the current date
|
|
80
62
|
context.meta.set("last-modified", new Date().toISOString());
|
|
@@ -14,14 +14,6 @@ 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;
|
|
25
17
|
}
|
|
26
18
|
|
|
27
19
|
/**
|
|
@@ -20,31 +20,41 @@ export interface PocketBaseLoaderOptions {
|
|
|
20
20
|
*
|
|
21
21
|
* If the field is a string, it will be slugified to be used in the URL.
|
|
22
22
|
*/
|
|
23
|
-
|
|
23
|
+
idField?: string;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* This must be the name of a field in the collection that contains the content.
|
|
25
|
+
* Name of the field(s) containing the content of an entry.
|
|
26
|
+
* This must be the name of a field in the PocketBase collection that contains the content.
|
|
27
27
|
* The content will be parsed as HTML and rendered to the page.
|
|
28
28
|
*
|
|
29
29
|
* If you want to render multiple fields as main content, you can pass an array of field names.
|
|
30
30
|
* The loader will concatenate the content of all fields in the order they are defined in the array.
|
|
31
31
|
* Each block will be contained in a `<section>` element.
|
|
32
32
|
*/
|
|
33
|
-
|
|
33
|
+
contentFields?: string | Array<string>;
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* Name of the field containing the last update date of an entry.
|
|
36
|
+
* Ideally, this field should be of type `autodate` and have the value "Update" or "Create/Update".
|
|
37
|
+
* This field is used to only fetch entries that have been modified since the last build.
|
|
37
38
|
*/
|
|
38
|
-
|
|
39
|
+
updatedField?: string;
|
|
39
40
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
41
|
+
* Credentials of a superuser to get full access to the PocketBase instance.
|
|
42
|
+
* This is required to get automatic type generation without a local schema, to access all resources even if they are not public and to fetch content of hidden fields.
|
|
42
43
|
*/
|
|
43
|
-
|
|
44
|
+
superuserCredentials?: {
|
|
45
|
+
/**
|
|
46
|
+
* Email of the superuser.
|
|
47
|
+
*/
|
|
48
|
+
email: string;
|
|
49
|
+
/**
|
|
50
|
+
* Password of the superuser.
|
|
51
|
+
*/
|
|
52
|
+
password: string;
|
|
53
|
+
};
|
|
44
54
|
/**
|
|
45
55
|
* File path to the local schema file.
|
|
46
56
|
* This file will be used to generate the schema for the collection.
|
|
47
|
-
* If
|
|
57
|
+
* If `superuserCredentials` are provided, this option will be ignored.
|
|
48
58
|
*/
|
|
49
59
|
localSchema?: string;
|
|
50
60
|
/**
|
|
@@ -55,9 +65,4 @@ export interface PocketBaseLoaderOptions {
|
|
|
55
65
|
* Note that this will only be used for fields of type `json`.
|
|
56
66
|
*/
|
|
57
67
|
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;
|
|
63
68
|
}
|
|
@@ -2,6 +2,11 @@
|
|
|
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;
|
|
5
10
|
/**
|
|
6
11
|
* Name of the field.
|
|
7
12
|
*/
|
|
@@ -15,20 +20,25 @@ export interface PocketBaseSchemaEntry {
|
|
|
15
20
|
*/
|
|
16
21
|
required: boolean;
|
|
17
22
|
/**
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
/**
|
|
@@ -42,9 +52,9 @@ export interface PocketBaseCollection {
|
|
|
42
52
|
/**
|
|
43
53
|
* Type of the collection.
|
|
44
54
|
*/
|
|
45
|
-
type:
|
|
55
|
+
type: "base" | "view" | "auth";
|
|
46
56
|
/**
|
|
47
57
|
* Schema of the collection.
|
|
48
58
|
*/
|
|
49
|
-
|
|
59
|
+
fields: Array<PocketBaseSchemaEntry>;
|
|
50
60
|
}
|
|
@@ -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 {
|
|
3
|
+
import { getSuperuserToken } from "./get-superuser-token";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Fetches the schema for the specified collection from the PocketBase instance.
|
|
@@ -10,18 +10,17 @@ import { getAdminToken } from "./get-admin-token";
|
|
|
10
10
|
export async function getRemoteSchema(
|
|
11
11
|
options: PocketBaseLoaderOptions
|
|
12
12
|
): Promise<PocketBaseCollection | undefined> {
|
|
13
|
-
if (!options.
|
|
13
|
+
if (!options.superuserCredentials) {
|
|
14
14
|
return undefined;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// Get
|
|
18
|
-
const token = await
|
|
17
|
+
// Get a superuser token
|
|
18
|
+
const token = await getSuperuserToken(
|
|
19
19
|
options.url,
|
|
20
|
-
options.
|
|
21
|
-
options.adminPassword
|
|
20
|
+
options.superuserCredentials
|
|
22
21
|
);
|
|
23
22
|
|
|
24
|
-
// If the token is invalid
|
|
23
|
+
// If the token is invalid try another method
|
|
25
24
|
if (!token) {
|
|
26
25
|
return undefined;
|
|
27
26
|
}
|
|
@@ -39,7 +38,7 @@ export async function getRemoteSchema(
|
|
|
39
38
|
headers: schemaHeaders
|
|
40
39
|
});
|
|
41
40
|
|
|
42
|
-
// If the request was not successful,
|
|
41
|
+
// If the request was not successful, try another method
|
|
43
42
|
if (!schemaRequest.ok) {
|
|
44
43
|
const reason = await schemaRequest.json().then((data) => data.message);
|
|
45
44
|
const errorMessage = `Fetching schema from ${options.collectionName} failed with status code ${schemaRequest.status}.\nReason: ${reason}`;
|
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
import type { AstroIntegrationLogger } from "astro";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* This function will get
|
|
4
|
+
* This function will get a superuser token from the given PocketBase instance.
|
|
5
5
|
*
|
|
6
6
|
* @param url URL of the PocketBase instance
|
|
7
|
-
* @param
|
|
8
|
-
* @param password Password of the admin
|
|
7
|
+
* @param superuserCredentials Credentials of the superuser
|
|
9
8
|
*
|
|
10
|
-
* @returns
|
|
9
|
+
* @returns A superuser token to access all resources of the PocketBase instance.
|
|
11
10
|
*/
|
|
12
|
-
export async function
|
|
11
|
+
export async function getSuperuserToken(
|
|
13
12
|
url: string,
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
superuserCredentials: {
|
|
14
|
+
email: string;
|
|
15
|
+
password: string;
|
|
16
|
+
},
|
|
16
17
|
logger?: AstroIntegrationLogger
|
|
17
18
|
): Promise<string | undefined> {
|
|
18
19
|
// Build the URL for the login endpoint
|
|
19
|
-
const loginUrl = new URL(
|
|
20
|
+
const loginUrl = new URL(
|
|
21
|
+
`api/collections/_superusers/auth-with-password`,
|
|
22
|
+
url
|
|
23
|
+
).href;
|
|
20
24
|
|
|
21
25
|
// Create a new FormData object to send the login data
|
|
22
26
|
const loginData = new FormData();
|
|
23
|
-
loginData.set("identity", email);
|
|
24
|
-
loginData.set("password", password);
|
|
27
|
+
loginData.set("identity", superuserCredentials.email);
|
|
28
|
+
loginData.set("password", superuserCredentials.password);
|
|
25
29
|
|
|
26
30
|
// Send the login request to get a token
|
|
27
31
|
const loginRequest = await fetch(loginUrl, {
|
package/src/utils/parse-entry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LoaderContext } from "astro/loaders";
|
|
2
2
|
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
|
|
3
|
+
import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type";
|
|
3
4
|
import { slugify } from "./slugify";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -15,8 +16,7 @@ import { slugify } from "./slugify";
|
|
|
15
16
|
export async function parseEntry(
|
|
16
17
|
entry: PocketBaseEntry,
|
|
17
18
|
{ generateDigest, parseData, store, logger }: LoaderContext,
|
|
18
|
-
idField
|
|
19
|
-
contentFields?: string | Array<string>
|
|
19
|
+
{ idField, contentFields, updatedField }: PocketBaseLoaderOptions
|
|
20
20
|
): Promise<void> {
|
|
21
21
|
let id = entry.id;
|
|
22
22
|
if (idField) {
|
|
@@ -46,11 +46,15 @@ export async function parseEntry(
|
|
|
46
46
|
data: entry
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
// Get the updated date of the entry
|
|
50
|
+
let updated: string | undefined;
|
|
51
|
+
if (updatedField) {
|
|
52
|
+
updated = `${entry[updatedField]}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
// Generate a digest for the entry
|
|
50
|
-
//
|
|
51
|
-
|
|
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);
|
|
56
|
+
// If no updated date is available, the digest will be generated from the whole entry
|
|
57
|
+
const digest = generateDigest(updated ?? entry);
|
|
54
58
|
|
|
55
59
|
if (!contentFields) {
|
|
56
60
|
// Store the entry
|
|
@@ -6,13 +6,19 @@ import type {
|
|
|
6
6
|
|
|
7
7
|
export function parseSchema(
|
|
8
8
|
collection: PocketBaseCollection,
|
|
9
|
-
customSchemas: Record<string, z.ZodType> | undefined
|
|
9
|
+
customSchemas: Record<string, z.ZodType> | undefined,
|
|
10
|
+
hasSuperuserRights: boolean
|
|
10
11
|
): Record<string, z.ZodType> {
|
|
11
12
|
// Prepare the schemas fields
|
|
12
13
|
const fields: Record<string, z.ZodType> = {};
|
|
13
14
|
|
|
14
15
|
// Parse every field in the schema
|
|
15
|
-
for (const field of collection.
|
|
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
|
+
|
|
16
22
|
let fieldType;
|
|
17
23
|
|
|
18
24
|
// Determine the field type and create the corresponding Zod type
|
|
@@ -26,11 +32,12 @@ export function parseSchema(
|
|
|
26
32
|
fieldType = z.coerce.boolean();
|
|
27
33
|
break;
|
|
28
34
|
case "date":
|
|
35
|
+
case "autodate":
|
|
29
36
|
// Coerce and parse the value as a date
|
|
30
37
|
fieldType = z.coerce.date();
|
|
31
38
|
break;
|
|
32
39
|
case "select":
|
|
33
|
-
if (!field.
|
|
40
|
+
if (!field.values) {
|
|
34
41
|
throw new Error(
|
|
35
42
|
`Field ${field.name} is of type "select" but has no values defined.`
|
|
36
43
|
);
|
|
@@ -38,7 +45,7 @@ export function parseSchema(
|
|
|
38
45
|
|
|
39
46
|
// Create an enum for the select values
|
|
40
47
|
// @ts-expect-error - Zod complains because the values are not known at compile time and thus the array is not static.
|
|
41
|
-
const values = z.enum(field.
|
|
48
|
+
const values = z.enum(field.values);
|
|
42
49
|
|
|
43
50
|
// Parse the field type based on the number of values it can have
|
|
44
51
|
fieldType = parseSingleOrMultipleValues(field, values);
|
|
@@ -66,8 +73,12 @@ export function parseSchema(
|
|
|
66
73
|
break;
|
|
67
74
|
}
|
|
68
75
|
|
|
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
|
+
|
|
69
80
|
// If the field is not required, mark it as optional
|
|
70
|
-
if (!
|
|
81
|
+
if (!isRequired) {
|
|
71
82
|
fieldType = z.preprocess(
|
|
72
83
|
(val) => val || undefined,
|
|
73
84
|
z.optional(fieldType)
|
|
@@ -94,7 +105,7 @@ function parseSingleOrMultipleValues(
|
|
|
94
105
|
type: z.ZodType
|
|
95
106
|
) {
|
|
96
107
|
// If the select allows multiple values, create an array of the enum
|
|
97
|
-
if (field.
|
|
108
|
+
if (field.maxSelect === undefined || field.maxSelect === 1) {
|
|
98
109
|
return type;
|
|
99
110
|
} else {
|
|
100
111
|
return z.array(type);
|
|
@@ -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.
|
|
20
|
+
if (field.maxSelect === 1) {
|
|
21
21
|
const fileName = entry[fieldName] as string | undefined;
|
|
22
22
|
// Check if a file name is present
|
|
23
23
|
if (!fileName) {
|