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 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.7.1-next.3",
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.12.9",
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.1",
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.0.0"
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 { pocketbaseLoader } from "./pocketbase-loader";
2
- import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type";
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 { PocketBaseLoaderOptions };
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
- // Prepare pagination variables
39
- let page = 0;
40
- let totalPages = 0;
41
- let entries = 0;
42
-
43
- // Fetch all (modified) entries
44
- do {
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
- // If the request was not successful, print the error message and return
76
- if (!collectionRequest.ok) {
77
- // If the collection is locked, an superuser token is required
78
- if (collectionRequest.status === 403) {
79
- if (
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 ${entries}/${context.store.keys().length} entries.`
47
+ `Updated ${numEntries}/${context.store.keys().length} entries.`
117
48
  );
118
49
  } else {
119
- context.logger.info(`Fetched ${entries} entries.`);
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
+ }
@@ -1,8 +1,19 @@
1
- import type { Loader } from "astro/loaders";
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 { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.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
+ >;