astro-loader-pocketbase 2.7.1-next.3 → 2.8.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 +106 -0
- package/package.json +4 -4
- package/src/index.ts +19 -4
- package/src/loader/fetch-collection.ts +149 -0
- package/src/loader/fetch-entry.ts +55 -0
- package/src/loader/live-collection-loader.ts +37 -0
- package/src/loader/live-entry-loader.ts +21 -0
- package/src/loader/load-entries.ts +16 -85
- package/src/loader/parse-live-entry.ts +68 -0
- package/src/pocketbase-loader.ts +62 -2
- package/src/schema/generate-schema.ts +2 -1
- package/src/schema/parse-schema.ts +10 -1
- package/src/types/pocketbase-live-loader-filter.type.ts +59 -0
- package/src/types/pocketbase-loader-options.type.ts +29 -0
package/README.md
CHANGED
|
@@ -41,6 +41,9 @@ If you want to update your deployed site with new entries, you need to rebuild i
|
|
|
41
41
|
|
|
42
42
|
<sub>When running the dev server, you can trigger a reload by using `s + enter`.</sub>
|
|
43
43
|
|
|
44
|
+
> [!TIP]
|
|
45
|
+
> If you need live data on your production site, you can use the experimental live content loader described below.
|
|
46
|
+
|
|
44
47
|
## Incremental builds
|
|
45
48
|
|
|
46
49
|
Since PocketBase 0.23.0, the `updated` field is not mandatory anymore.
|
|
@@ -210,6 +213,109 @@ This will remove `undefined` from the type of these fields and mark them as requ
|
|
|
210
213
|
| `jsonSchemas` | `Record<string, z.ZodSchema>` | | A record of Zod schemas to use for type generation of `json` fields. |
|
|
211
214
|
| `improveTypes` | `boolean` | | Whether to improve the types of `number` and `boolean` fields, removing `undefined` from them. |
|
|
212
215
|
|
|
216
|
+
## Experimental live content loader
|
|
217
|
+
|
|
218
|
+
> [!WARNING]
|
|
219
|
+
> Live content collections are still experimental and may change in the future.
|
|
220
|
+
> This means that this packages live content loader is also experimental and may include breaking changes with every release.
|
|
221
|
+
|
|
222
|
+
For more information on how to enable and use live content collections, please refer to the [Astro documentation](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/).
|
|
223
|
+
|
|
224
|
+
### General usage
|
|
225
|
+
|
|
226
|
+
#### Setup
|
|
227
|
+
|
|
228
|
+
The options for this packages loader are similar to the regular PocketBase loader:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const blogLive = defineLiveCollection({
|
|
232
|
+
loader: experimentalPocketbaseLiveLoader({
|
|
233
|
+
url: "https://<your-pocketbase-url>",
|
|
234
|
+
collectionName: "<collection-in-pocketbase>"
|
|
235
|
+
})
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
export const collections = { blogLive };
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
You can also specify additional options for the live content loader, such as filters and content fields.
|
|
242
|
+
For access to private collections and hidden fields, you need to provide superuser credentials, also similar to the regular PocketBase loader.
|
|
243
|
+
|
|
244
|
+
#### Accessing a single entry
|
|
245
|
+
|
|
246
|
+
To access a single entry you can use the default `getLiveEntry` function:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// Get a single entry by its id
|
|
250
|
+
const entry = await getLiveEntry("blogLive", { id: "<entry-id>" });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
In here you need to specify the id of the entry you want to access.
|
|
254
|
+
In contrast to the regular content loader, the live content loader can not use a custom id field, so this needs to be the primary id used by PocketBase.
|
|
255
|
+
|
|
256
|
+
#### Accessing a collection of entries
|
|
257
|
+
|
|
258
|
+
To access a collection of entries, you can use the default `getLiveCollection` function:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// Get a whole collection of entries
|
|
262
|
+
const entries = await getLiveCollection("blogLive");
|
|
263
|
+
|
|
264
|
+
// Get a filtered and paginated collection of entries
|
|
265
|
+
const entries2 = await getLiveCollection("blogLive", {
|
|
266
|
+
filters: "release >= @now && deleted = false",
|
|
267
|
+
sort: "-created,id",
|
|
268
|
+
page: 1,
|
|
269
|
+
perPage: 10
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
By default, the loader will fetch all entries in the collection.
|
|
274
|
+
But you can also specify additional options, such as filters (will be added to the global filters), sorting, and pagination.
|
|
275
|
+
|
|
276
|
+
### Using cache hints
|
|
277
|
+
|
|
278
|
+
The loader automatically adds cache hints for each entry / collection.
|
|
279
|
+
By default only the `tags` hint is used, which includes the collection name and the id of the entry.
|
|
280
|
+
|
|
281
|
+
If you also want to use the `lastModified` hint, just tell the loader which field to use for this date:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
const blogLive = defineLiveCollection({
|
|
285
|
+
loader: experimentalPocketbaseLiveLoader({
|
|
286
|
+
...options,
|
|
287
|
+
updatedField: "<field-in-collection>"
|
|
288
|
+
})
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Caveats
|
|
293
|
+
|
|
294
|
+
Live content loaders do not (yet 🤞🏼) support zod schemas and thus schema generation and entry transformation.
|
|
295
|
+
This means that options like custom ids or image and file transformations are not available.
|
|
296
|
+
Dates will also be treated as plain ISO strings instead of date objects.
|
|
297
|
+
|
|
298
|
+
If you want an accompanying type for your live content, you can use the `experimental.liveTypesOnly` option on the regular loader.
|
|
299
|
+
This will cause it to skip the data fetching and only generate types, fitting the live content structure.
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
const blogTypes = defineCollection({
|
|
303
|
+
loader: pocketbaseLoader({
|
|
304
|
+
...options,
|
|
305
|
+
experimental: {
|
|
306
|
+
liveTypesOnly: true
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const blogLive = defineLiveCollection({
|
|
312
|
+
loader:
|
|
313
|
+
experimentalPocketbaseLiveLoader<CollectionEntry<"blogTypes">["data"]>(
|
|
314
|
+
options
|
|
315
|
+
)
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
213
319
|
## Special cases
|
|
214
320
|
|
|
215
321
|
### Private collections and hidden fields
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-loader-pocketbase",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0-next.1",
|
|
4
4
|
"description": "A content loader for Astro that uses the PocketBase API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"@commitlint/config-conventional": "^19.8.1",
|
|
48
48
|
"@types/node": "^22.14.1",
|
|
49
49
|
"@vitest/coverage-v8": "^3.2.4",
|
|
50
|
-
"astro": "^5.
|
|
50
|
+
"astro": "^5.13.2",
|
|
51
51
|
"globals": "^16.3.0",
|
|
52
52
|
"husky": "^9.1.7",
|
|
53
53
|
"lint-staged": "^16.1.5",
|
|
54
|
-
"oxlint": "^1.11.
|
|
54
|
+
"oxlint": "^1.11.2",
|
|
55
55
|
"prettier": "^3.6.2",
|
|
56
56
|
"prettier-plugin-organize-imports": "^4.2.0",
|
|
57
57
|
"prettier-plugin-packagejson": "^2.5.19",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"vitest": "^3.2.4"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
|
-
"astro": "^5.
|
|
62
|
+
"astro": "^5.10.0"
|
|
63
63
|
},
|
|
64
64
|
"packageManager": "npm@11.5.2",
|
|
65
65
|
"publishConfig": {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
experimentalPocketbaseLiveLoader,
|
|
3
|
+
pocketbaseLoader
|
|
4
|
+
} from "./pocketbase-loader";
|
|
5
|
+
import type {
|
|
6
|
+
ExperimentalPocketBaseLiveLoaderCollectionFilter,
|
|
7
|
+
ExperimentalPocketBaseLiveLoaderEntryFilter
|
|
8
|
+
} from "./types/pocketbase-live-loader-filter.type";
|
|
9
|
+
import type {
|
|
10
|
+
ExperimentalPocketBaseLiveLoaderOptions,
|
|
11
|
+
PocketBaseLoaderOptions
|
|
12
|
+
} from "./types/pocketbase-loader-options.type";
|
|
3
13
|
|
|
4
|
-
export { pocketbaseLoader };
|
|
5
|
-
export type {
|
|
14
|
+
export { experimentalPocketbaseLiveLoader, pocketbaseLoader };
|
|
15
|
+
export type {
|
|
16
|
+
ExperimentalPocketBaseLiveLoaderCollectionFilter,
|
|
17
|
+
ExperimentalPocketBaseLiveLoaderEntryFilter,
|
|
18
|
+
ExperimentalPocketBaseLiveLoaderOptions,
|
|
19
|
+
PocketBaseLoaderOptions
|
|
20
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
|
|
2
|
+
import type { ExperimentalPocketBaseLiveLoaderCollectionFilter } from "../types/pocketbase-live-loader-filter.type";
|
|
3
|
+
import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provides utilities to fetch entries from a PocketBase collection, supporting filtering and pagination.
|
|
7
|
+
*/
|
|
8
|
+
export type CollectionFilter = {
|
|
9
|
+
/**
|
|
10
|
+
* Date string to only fetch entries that have been modified since this date.
|
|
11
|
+
* If not provided, all entries will be fetched.
|
|
12
|
+
*/
|
|
13
|
+
lastModified?: string;
|
|
14
|
+
} & ExperimentalPocketBaseLiveLoaderCollectionFilter;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetches entries from a PocketBase collection, optionally filtering by modification date and supporting pagination.
|
|
18
|
+
*/
|
|
19
|
+
export async function fetchCollection<TEntry extends PocketBaseEntry>(
|
|
20
|
+
options: Pick<
|
|
21
|
+
PocketBaseLoaderOptions,
|
|
22
|
+
| "collectionName"
|
|
23
|
+
| "url"
|
|
24
|
+
| "updatedField"
|
|
25
|
+
| "filter"
|
|
26
|
+
| "superuserCredentials"
|
|
27
|
+
>,
|
|
28
|
+
chunkLoaded: (entries: Array<TEntry>) => Promise<void>,
|
|
29
|
+
token: string | undefined,
|
|
30
|
+
collectionFilter: CollectionFilter | undefined
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
// Build the URL for the collections endpoint
|
|
33
|
+
const collectionUrl = new URL(
|
|
34
|
+
`api/collections/${options.collectionName}/records`,
|
|
35
|
+
options.url
|
|
36
|
+
).href;
|
|
37
|
+
|
|
38
|
+
// Create the headers for the request to append the token (if available)
|
|
39
|
+
const collectionHeaders = new Headers();
|
|
40
|
+
if (token) {
|
|
41
|
+
collectionHeaders.set("Authorization", token);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Prepare pagination variables
|
|
45
|
+
let page = 0;
|
|
46
|
+
let totalPages = 0;
|
|
47
|
+
|
|
48
|
+
// Fetch all (modified) entries
|
|
49
|
+
do {
|
|
50
|
+
const searchParams = buildSearchParams(options, {
|
|
51
|
+
...collectionFilter,
|
|
52
|
+
page: collectionFilter?.page ?? ++page,
|
|
53
|
+
perPage: collectionFilter?.perPage ?? 100
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Fetch entries from the collection
|
|
57
|
+
const collectionRequest = await fetch(
|
|
58
|
+
`${collectionUrl}?${searchParams.toString()}`,
|
|
59
|
+
{
|
|
60
|
+
headers: collectionHeaders
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// If the request was not successful, print the error message and return
|
|
65
|
+
if (!collectionRequest.ok) {
|
|
66
|
+
// If the collection is locked, an superuser token is required
|
|
67
|
+
if (collectionRequest.status === 403) {
|
|
68
|
+
if (
|
|
69
|
+
options.superuserCredentials &&
|
|
70
|
+
"impersonateToken" in options.superuserCredentials
|
|
71
|
+
) {
|
|
72
|
+
throw new Error("The given impersonate token is not valid.");
|
|
73
|
+
} else {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"The collection is not accessible without superuser rights. Please provide superuser credentials in the config."
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Get the reason for the error
|
|
81
|
+
const reason = await collectionRequest
|
|
82
|
+
.json()
|
|
83
|
+
.then((data) => data.message);
|
|
84
|
+
const errorMessage = `Fetching data failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
|
|
85
|
+
throw new Error(errorMessage);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get the data from the response
|
|
89
|
+
const response = await collectionRequest.json();
|
|
90
|
+
|
|
91
|
+
// Return current chunk
|
|
92
|
+
await chunkLoaded(response.items);
|
|
93
|
+
|
|
94
|
+
// Update the page and total pages
|
|
95
|
+
page = response.page;
|
|
96
|
+
totalPages = response.totalPages;
|
|
97
|
+
} while (!collectionFilter?.perPage && page < totalPages);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build search parameters for the PocketBase collection request.
|
|
102
|
+
*/
|
|
103
|
+
function buildSearchParams(
|
|
104
|
+
loaderOptions: Pick<PocketBaseLoaderOptions, "updatedField" | "filter">,
|
|
105
|
+
collectionFilter: CollectionFilter
|
|
106
|
+
): URLSearchParams {
|
|
107
|
+
// Build search parameters
|
|
108
|
+
const searchParams = new URLSearchParams();
|
|
109
|
+
|
|
110
|
+
if (collectionFilter.page) {
|
|
111
|
+
searchParams.set("page", `${collectionFilter.page}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (collectionFilter.perPage) {
|
|
115
|
+
searchParams.set("perPage", `${collectionFilter.perPage}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const filters = [];
|
|
119
|
+
|
|
120
|
+
// If `lastModified` is set, only fetch entries that have been modified since the last fetch
|
|
121
|
+
// Sort by the updated field and id
|
|
122
|
+
if (collectionFilter.lastModified && loaderOptions.updatedField) {
|
|
123
|
+
filters.push(
|
|
124
|
+
`(${loaderOptions.updatedField}>"${collectionFilter.lastModified}")`
|
|
125
|
+
);
|
|
126
|
+
searchParams.set("sort", `-${loaderOptions.updatedField},id`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Add filter from the loader options
|
|
130
|
+
if (loaderOptions.filter) {
|
|
131
|
+
filters.push(`(${loaderOptions.filter})`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Add additional filter from the collection filter
|
|
135
|
+
if (collectionFilter.filter) {
|
|
136
|
+
filters.push(`(${collectionFilter.filter})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Add filters to search parameters
|
|
140
|
+
if (filters.length > 0) {
|
|
141
|
+
searchParams.set("filter", filters.join("&&"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (collectionFilter.sort) {
|
|
145
|
+
searchParams.set("sort", collectionFilter.sort);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return searchParams;
|
|
149
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
|
|
2
|
+
import type { ExperimentalPocketBaseLiveLoaderOptions } from "../types/pocketbase-loader-options.type";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Retrieves a specific entry from a PocketBase collection using its ID and loader options.
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchEntry<TEntry extends PocketBaseEntry>(
|
|
8
|
+
id: string,
|
|
9
|
+
options: ExperimentalPocketBaseLiveLoaderOptions,
|
|
10
|
+
token: string | undefined
|
|
11
|
+
): Promise<TEntry> {
|
|
12
|
+
// Build the URL for the entry endpoint
|
|
13
|
+
const entryUrl = new URL(
|
|
14
|
+
`api/collections/${options.collectionName}/records/${id}`,
|
|
15
|
+
options.url
|
|
16
|
+
).href;
|
|
17
|
+
|
|
18
|
+
// Create the headers for the request to append the token (if available)
|
|
19
|
+
const entryHeaders = new Headers();
|
|
20
|
+
if (token) {
|
|
21
|
+
entryHeaders.set("Authorization", token);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fetch the entry from the collection
|
|
25
|
+
const entryRequest = await fetch(entryUrl, {
|
|
26
|
+
headers: entryHeaders
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// If the request was not successful, return an error
|
|
30
|
+
if (!entryRequest.ok) {
|
|
31
|
+
// If the entry is locked, a superuser token is required
|
|
32
|
+
if (entryRequest.status === 403) {
|
|
33
|
+
if (
|
|
34
|
+
options.superuserCredentials &&
|
|
35
|
+
"impersonateToken" in options.superuserCredentials
|
|
36
|
+
) {
|
|
37
|
+
throw new Error("The given impersonate token is not valid.");
|
|
38
|
+
} else {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"The entry is not accessible without superuser rights. Please provide superuser credentials in the config."
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Get the reason for the error
|
|
46
|
+
const reason = await entryRequest.json().then((data) => data.message);
|
|
47
|
+
const errorMessage = `Fetching entry "${id}" from collection "${options.collectionName}" failed.\nReason: ${reason}`;
|
|
48
|
+
throw new Error(errorMessage);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Get the data from the response
|
|
52
|
+
const response = await entryRequest.json();
|
|
53
|
+
|
|
54
|
+
return response;
|
|
55
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LiveDataCollection, LiveDataEntry } from "astro";
|
|
2
|
+
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
|
|
3
|
+
import type { ExperimentalPocketBaseLiveLoaderCollectionFilter } from "../types/pocketbase-live-loader-filter.type";
|
|
4
|
+
import type { ExperimentalPocketBaseLiveLoaderOptions } from "../types/pocketbase-loader-options.type";
|
|
5
|
+
import { fetchCollection } from "./fetch-collection";
|
|
6
|
+
import { parseLiveEntry } from "./parse-live-entry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Loads and parses a PocketBase collection for live data, returning entries or an error.
|
|
10
|
+
*/
|
|
11
|
+
export async function liveCollectionLoader<TEntry extends PocketBaseEntry>(
|
|
12
|
+
collectionFilter:
|
|
13
|
+
| ExperimentalPocketBaseLiveLoaderCollectionFilter
|
|
14
|
+
| undefined,
|
|
15
|
+
options: ExperimentalPocketBaseLiveLoaderOptions,
|
|
16
|
+
token: string | undefined
|
|
17
|
+
): Promise<LiveDataCollection<TEntry> | { error: Error }> {
|
|
18
|
+
const entries: Array<LiveDataEntry<TEntry>> = [];
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await fetchCollection<TEntry>(
|
|
22
|
+
options,
|
|
23
|
+
// oxlint-disable-next-line require-await
|
|
24
|
+
async (chunk) => {
|
|
25
|
+
entries.push(...chunk.map((entry) => parseLiveEntry(entry, options)));
|
|
26
|
+
},
|
|
27
|
+
token,
|
|
28
|
+
collectionFilter
|
|
29
|
+
);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { error: error as Error };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
entries
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LiveDataEntry } from "astro";
|
|
2
|
+
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
|
|
3
|
+
import type { ExperimentalPocketBaseLiveLoaderOptions } from "../types/pocketbase-loader-options.type";
|
|
4
|
+
import { fetchEntry } from "./fetch-entry";
|
|
5
|
+
import { parseLiveEntry } from "./parse-live-entry";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Loads and parses a single PocketBase entry for live data, returning the entry or an error.
|
|
9
|
+
*/
|
|
10
|
+
export async function liveEntryLoader<TEntry extends PocketBaseEntry>(
|
|
11
|
+
id: string,
|
|
12
|
+
options: ExperimentalPocketBaseLiveLoaderOptions,
|
|
13
|
+
token: string | undefined
|
|
14
|
+
): Promise<LiveDataEntry<TEntry> | { error: Error }> {
|
|
15
|
+
try {
|
|
16
|
+
const entry = await fetchEntry<TEntry>(id, options, token);
|
|
17
|
+
return parseLiveEntry(entry, options);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return { error: error as Error };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LoaderContext } from "astro/loaders";
|
|
2
2
|
import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type";
|
|
3
|
+
import { fetchCollection } from "./fetch-collection";
|
|
3
4
|
import { parseEntry } from "./parse-entry";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -16,18 +17,6 @@ export async function loadEntries(
|
|
|
16
17
|
superuserToken: string | undefined,
|
|
17
18
|
lastModified: string | undefined
|
|
18
19
|
): Promise<void> {
|
|
19
|
-
// Build the URL for the collections endpoint
|
|
20
|
-
const collectionUrl = new URL(
|
|
21
|
-
`api/collections/${options.collectionName}/records`,
|
|
22
|
-
options.url
|
|
23
|
-
).href;
|
|
24
|
-
|
|
25
|
-
// Create the headers for the request to append the superuser token (if available)
|
|
26
|
-
const collectionHeaders = new Headers();
|
|
27
|
-
if (superuserToken) {
|
|
28
|
-
collectionHeaders.set("Authorization", superuserToken);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
20
|
// Log the fetching of the entries
|
|
32
21
|
context.logger.info(
|
|
33
22
|
`Fetching${lastModified ? " modified" : ""} data${
|
|
@@ -35,87 +24,29 @@ export async function loadEntries(
|
|
|
35
24
|
}${superuserToken ? " as superuser" : ""}`
|
|
36
25
|
);
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// Build search parameters
|
|
46
|
-
const searchParams = new URLSearchParams({
|
|
47
|
-
page: `${++page}`,
|
|
48
|
-
perPage: "100"
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const filters = [];
|
|
52
|
-
if (lastModified && options.updatedField) {
|
|
53
|
-
// If `lastModified` is set, only fetch entries that have been modified since the last fetch
|
|
54
|
-
filters.push(`(${options.updatedField}>"${lastModified}")`);
|
|
55
|
-
// Sort by the updated field and id
|
|
56
|
-
searchParams.set("sort", `-${options.updatedField},id`);
|
|
57
|
-
}
|
|
58
|
-
if (options.filter) {
|
|
59
|
-
filters.push(`(${options.filter})`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Add filters to search parameters
|
|
63
|
-
if (filters.length > 0) {
|
|
64
|
-
searchParams.set("filter", filters.join("&&"));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Fetch entries from the collection
|
|
68
|
-
const collectionRequest = await fetch(
|
|
69
|
-
`${collectionUrl}?${searchParams.toString()}`,
|
|
70
|
-
{
|
|
71
|
-
headers: collectionHeaders
|
|
27
|
+
let numEntries = 0;
|
|
28
|
+
await fetchCollection(
|
|
29
|
+
options,
|
|
30
|
+
async (entries) => {
|
|
31
|
+
// Parse and store the entries
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
await parseEntry(entry, context, options);
|
|
72
34
|
}
|
|
73
|
-
);
|
|
74
35
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
options.superuserCredentials &&
|
|
81
|
-
"impersonateToken" in options.superuserCredentials
|
|
82
|
-
) {
|
|
83
|
-
throw new Error("The given impersonate token is not valid.");
|
|
84
|
-
} else {
|
|
85
|
-
throw new Error(
|
|
86
|
-
"The collection is not accessible without superuser rights. Please provide superuser credentials in the config."
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Get the reason for the error
|
|
92
|
-
const reason = await collectionRequest
|
|
93
|
-
.json()
|
|
94
|
-
.then((data) => data.message);
|
|
95
|
-
const errorMessage = `Fetching data failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
|
|
96
|
-
throw new Error(errorMessage);
|
|
36
|
+
numEntries += entries.length;
|
|
37
|
+
},
|
|
38
|
+
superuserToken,
|
|
39
|
+
{
|
|
40
|
+
lastModified
|
|
97
41
|
}
|
|
98
|
-
|
|
99
|
-
// Get the data from the response
|
|
100
|
-
const response = await collectionRequest.json();
|
|
101
|
-
|
|
102
|
-
// Parse and store the entries
|
|
103
|
-
for (const entry of response.items) {
|
|
104
|
-
await parseEntry(entry, context, options);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Update the page and total pages
|
|
108
|
-
page = response.page;
|
|
109
|
-
totalPages = response.totalPages;
|
|
110
|
-
entries += response.items.length;
|
|
111
|
-
} while (page < totalPages);
|
|
42
|
+
);
|
|
112
43
|
|
|
113
44
|
// Log the number of fetched entries
|
|
114
45
|
if (lastModified) {
|
|
115
46
|
context.logger.info(
|
|
116
|
-
`Updated ${
|
|
47
|
+
`Updated ${numEntries}/${context.store.keys().length} entries.`
|
|
117
48
|
);
|
|
118
49
|
} else {
|
|
119
|
-
context.logger.info(`Fetched ${
|
|
50
|
+
context.logger.info(`Fetched ${numEntries} entries.`);
|
|
120
51
|
}
|
|
121
52
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { LiveDataEntry } from "astro";
|
|
2
|
+
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
|
|
3
|
+
import type { ExperimentalPocketBaseLiveLoaderOptions } from "../types/pocketbase-loader-options.type";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Converts a PocketBase entry into a LiveDataEntry for Astro, extracting content and cache metadata.
|
|
7
|
+
*/
|
|
8
|
+
export function parseLiveEntry<TEntry extends PocketBaseEntry>(
|
|
9
|
+
entry: TEntry,
|
|
10
|
+
options: ExperimentalPocketBaseLiveLoaderOptions
|
|
11
|
+
): LiveDataEntry<TEntry> {
|
|
12
|
+
// Build a cache tag
|
|
13
|
+
const tag = `${options.collectionName}-${entry.id}`;
|
|
14
|
+
|
|
15
|
+
let lastModified: Date | undefined = undefined;
|
|
16
|
+
// If an updated field is provided and the entry has a valid date value,
|
|
17
|
+
// use it as the last modified date cache hint
|
|
18
|
+
if (options.updatedField && entry[options.updatedField]) {
|
|
19
|
+
const value = `${entry[options.updatedField]}`;
|
|
20
|
+
try {
|
|
21
|
+
const date = new Date(value);
|
|
22
|
+
if (isNaN(date.getTime())) {
|
|
23
|
+
throw new TypeError("Invalid date");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
lastModified = date;
|
|
27
|
+
} catch {
|
|
28
|
+
console.warn(
|
|
29
|
+
`Entry ${entry.id} of collection ${options.collectionName} has an invalid date in ${options.updatedField}: ${value}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!options.contentFields) {
|
|
35
|
+
return {
|
|
36
|
+
id: entry.id,
|
|
37
|
+
data: entry,
|
|
38
|
+
cacheHint: {
|
|
39
|
+
tags: [tag],
|
|
40
|
+
lastModified
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let content: string;
|
|
46
|
+
if (typeof options.contentFields === "string") {
|
|
47
|
+
// If a single content field is provided, use it directly
|
|
48
|
+
content = `${entry[options.contentFields]}`;
|
|
49
|
+
} else {
|
|
50
|
+
// If multiple content fields are provided, concatenate them with `<section>` tags
|
|
51
|
+
content = options.contentFields
|
|
52
|
+
.map((field) => `<section>${entry[field]}</section>`)
|
|
53
|
+
.join("");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: entry.id,
|
|
58
|
+
data: entry,
|
|
59
|
+
// @ts-expect-error - Docs say this is possible
|
|
60
|
+
rendered: {
|
|
61
|
+
html: content
|
|
62
|
+
},
|
|
63
|
+
cacheHint: {
|
|
64
|
+
tags: [tag],
|
|
65
|
+
lastModified
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/pocketbase-loader.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { LiveDataCollection, LiveDataEntry } from "astro";
|
|
2
|
+
import type { LiveLoader, Loader } from "astro/loaders";
|
|
2
3
|
import type { ZodSchema } from "astro/zod";
|
|
4
|
+
import { liveCollectionLoader } from "./loader/live-collection-loader";
|
|
5
|
+
import { liveEntryLoader } from "./loader/live-entry-loader";
|
|
3
6
|
import { loader } from "./loader/loader";
|
|
4
7
|
import { generateSchema } from "./schema/generate-schema";
|
|
5
|
-
import type {
|
|
8
|
+
import type { PocketBaseEntry } from "./types/pocketbase-entry.type";
|
|
9
|
+
import type {
|
|
10
|
+
ExperimentalPocketBaseLiveLoaderCollectionFilter,
|
|
11
|
+
ExperimentalPocketBaseLiveLoaderEntryFilter
|
|
12
|
+
} from "./types/pocketbase-live-loader-filter.type";
|
|
13
|
+
import type {
|
|
14
|
+
ExperimentalPocketBaseLiveLoaderOptions,
|
|
15
|
+
PocketBaseLoaderOptions
|
|
16
|
+
} from "./types/pocketbase-loader-options.type";
|
|
6
17
|
import { createTokenPromise } from "./utils/create-token-promise";
|
|
7
18
|
|
|
8
19
|
/**
|
|
@@ -17,6 +28,14 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
|
|
|
17
28
|
return {
|
|
18
29
|
name: "pocketbase-loader",
|
|
19
30
|
load: async (context): Promise<void> => {
|
|
31
|
+
if (options.experimental?.liveTypesOnly) {
|
|
32
|
+
context.logger.label = `pocketbase-loader:${options.collectionName}`;
|
|
33
|
+
context.logger.info(
|
|
34
|
+
"Experimental live types only mode enabled. No data will be loaded, only types will be generated."
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
const token = await tokenPromise;
|
|
21
40
|
|
|
22
41
|
// Load the entries from the collection
|
|
@@ -30,3 +49,44 @@ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
|
|
|
30
49
|
}
|
|
31
50
|
};
|
|
32
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Live loader for collections stored in PocketBase.
|
|
55
|
+
* This loader is currently experimental and may change in any future release.
|
|
56
|
+
*
|
|
57
|
+
* @param options Options for the live loader. See {@link ExperimentalPocketBaseLiveLoaderOptions} for more details.
|
|
58
|
+
*
|
|
59
|
+
* @experimental Live content collections are still experimental
|
|
60
|
+
*/
|
|
61
|
+
export function experimentalPocketbaseLiveLoader<
|
|
62
|
+
TEntry extends PocketBaseEntry
|
|
63
|
+
>(
|
|
64
|
+
options: ExperimentalPocketBaseLiveLoaderOptions
|
|
65
|
+
): LiveLoader<
|
|
66
|
+
TEntry,
|
|
67
|
+
ExperimentalPocketBaseLiveLoaderEntryFilter,
|
|
68
|
+
ExperimentalPocketBaseLiveLoaderCollectionFilter
|
|
69
|
+
> {
|
|
70
|
+
// Create shared promise for the superuser token, which can be reused
|
|
71
|
+
const tokenPromise = createTokenPromise(options);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
name: "pocketbase-live-loader",
|
|
75
|
+
loadCollection: async ({
|
|
76
|
+
filter
|
|
77
|
+
}): Promise<LiveDataCollection<TEntry> | { error: Error }> => {
|
|
78
|
+
const token = await tokenPromise;
|
|
79
|
+
|
|
80
|
+
// Load entries from the collection
|
|
81
|
+
return await liveCollectionLoader(filter, options, token);
|
|
82
|
+
},
|
|
83
|
+
loadEntry: async ({
|
|
84
|
+
filter
|
|
85
|
+
}): Promise<LiveDataEntry<TEntry> | { error: Error }> => {
|
|
86
|
+
const token = await tokenPromise;
|
|
87
|
+
|
|
88
|
+
// Load a single entry from the collection
|
|
89
|
+
return await liveEntryLoader(filter.id, options, token);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -65,7 +65,8 @@ export async function generateSchema(
|
|
|
65
65
|
collection,
|
|
66
66
|
options.jsonSchemas,
|
|
67
67
|
hasSuperuserRights,
|
|
68
|
-
options.improveTypes ?? false
|
|
68
|
+
options.improveTypes ?? false,
|
|
69
|
+
options.experimental?.liveTypesOnly ?? false
|
|
69
70
|
);
|
|
70
71
|
|
|
71
72
|
// Check if custom id field is present
|
|
@@ -4,11 +4,15 @@ import type {
|
|
|
4
4
|
PocketBaseSchemaEntry
|
|
5
5
|
} from "../types/pocketbase-schema.type";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Converts PocketBase collection fields into Zod types, handling field types, required status, and custom schemas.
|
|
9
|
+
*/
|
|
7
10
|
export function parseSchema(
|
|
8
11
|
collection: PocketBaseCollection,
|
|
9
12
|
customSchemas: Record<string, z.ZodType> | undefined,
|
|
10
13
|
hasSuperuserRights: boolean,
|
|
11
|
-
improveTypes: boolean
|
|
14
|
+
improveTypes: boolean,
|
|
15
|
+
experimentalLiveTypesOnly?: boolean
|
|
12
16
|
): Record<string, z.ZodType> {
|
|
13
17
|
// Prepare the schemas fields
|
|
14
18
|
const fields: Record<string, z.ZodType> = {};
|
|
@@ -32,6 +36,11 @@ export function parseSchema(
|
|
|
32
36
|
break;
|
|
33
37
|
case "date":
|
|
34
38
|
case "autodate":
|
|
39
|
+
if (experimentalLiveTypesOnly) {
|
|
40
|
+
// If experimental live types only mode is enabled, treat dates as strings
|
|
41
|
+
fieldType = z.string();
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
35
44
|
// Coerce and parse the value as a date
|
|
36
45
|
fieldType = z.coerce.date();
|
|
37
46
|
break;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter for a single entry
|
|
3
|
+
*
|
|
4
|
+
* @experimental Live content collections are still experimental
|
|
5
|
+
*/
|
|
6
|
+
export interface ExperimentalPocketBaseLiveLoaderEntryFilter {
|
|
7
|
+
/**
|
|
8
|
+
* Id of the entry.
|
|
9
|
+
*/
|
|
10
|
+
id: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Filter for a collection of entries.
|
|
15
|
+
*
|
|
16
|
+
* @experimental Live content collections are still experimental
|
|
17
|
+
*/
|
|
18
|
+
export interface ExperimentalPocketBaseLiveLoaderCollectionFilter {
|
|
19
|
+
/**
|
|
20
|
+
* Additional filter to apply to the collection.
|
|
21
|
+
* This will be added to the filter supplied in the {@link ExperimentalPocketBaseLiveLoaderOptions}.
|
|
22
|
+
*
|
|
23
|
+
* Valid syntax can be found in the [PocketBase documentation](https://pocketbase.io/docs/api-records/#listsearch-records)
|
|
24
|
+
*
|
|
25
|
+
* Example:
|
|
26
|
+
* ```ts
|
|
27
|
+
* // config:
|
|
28
|
+
* filter: 'release >= @now && deleted = false'
|
|
29
|
+
*
|
|
30
|
+
* // request
|
|
31
|
+
* `?filter=(${loaderFilter})&&(release >= @now && deleted = false)`
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
filter?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Page number to load (1-indexed).
|
|
37
|
+
*/
|
|
38
|
+
page?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Number of entries to load per page.
|
|
41
|
+
* If not provided all entries will be loaded in chunks of 100.
|
|
42
|
+
*/
|
|
43
|
+
perPage?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Sort order in which the entries should be returned.
|
|
46
|
+
*
|
|
47
|
+
* Valid syntax can be found in the [PocketBase documentation](https://pocketbase.io/docs/api-records/#listsearch-records)
|
|
48
|
+
*
|
|
49
|
+
* Example:
|
|
50
|
+
* ```ts
|
|
51
|
+
* // config:
|
|
52
|
+
* sort: '-created,id'
|
|
53
|
+
*
|
|
54
|
+
* // request
|
|
55
|
+
* `?sort=-created,id`
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
sort?: string;
|
|
59
|
+
}
|
|
@@ -98,4 +98,33 @@ export interface PocketBaseLoaderOptions {
|
|
|
98
98
|
* The PocketBase API does always return at least `0` or `false` as the default values, even though the fields are not marked as required in the schema.
|
|
99
99
|
*/
|
|
100
100
|
improveTypes?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Experimental options for the loader.
|
|
103
|
+
*
|
|
104
|
+
* @experimental All of these options are experimental and may change in the future.
|
|
105
|
+
*/
|
|
106
|
+
experimental?: {
|
|
107
|
+
/**
|
|
108
|
+
* Whether to only create types for the live loader.
|
|
109
|
+
* This will not load any data, but only generate types that can be used with the live loader.
|
|
110
|
+
*
|
|
111
|
+
* @experimental Live content collections are still experimental
|
|
112
|
+
*/
|
|
113
|
+
liveTypesOnly?: boolean;
|
|
114
|
+
};
|
|
101
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Options for the PocketBase live loader.
|
|
119
|
+
*
|
|
120
|
+
* @experimental Live content collections are still experimental
|
|
121
|
+
*/
|
|
122
|
+
export type ExperimentalPocketBaseLiveLoaderOptions = Pick<
|
|
123
|
+
PocketBaseLoaderOptions,
|
|
124
|
+
| "url"
|
|
125
|
+
| "collectionName"
|
|
126
|
+
| "contentFields"
|
|
127
|
+
| "updatedField"
|
|
128
|
+
| "filter"
|
|
129
|
+
| "superuserCredentials"
|
|
130
|
+
>;
|