astro-loader-pocketbase 3.1.1 → 3.1.2-next.2

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.
Files changed (38) hide show
  1. package/dist/index.d.mts +276 -0
  2. package/dist/index.d.mts.map +1 -0
  3. package/dist/index.mjs +973 -0
  4. package/dist/index.mjs.map +1 -0
  5. package/package.json +26 -14
  6. package/src/index.ts +0 -24
  7. package/src/loader/cleanup-entries.ts +0 -137
  8. package/src/loader/fetch-collection.ts +0 -177
  9. package/src/loader/fetch-entry.ts +0 -83
  10. package/src/loader/handle-realtime-updates.ts +0 -56
  11. package/src/loader/live-collection-loader.ts +0 -51
  12. package/src/loader/live-entry-loader.ts +0 -38
  13. package/src/loader/load-entries.ts +0 -52
  14. package/src/loader/loader.ts +0 -77
  15. package/src/loader/parse-entry.ts +0 -90
  16. package/src/loader/parse-live-entry.ts +0 -66
  17. package/src/pocketbase-loader.ts +0 -98
  18. package/src/schema/generate-schema.ts +0 -200
  19. package/src/schema/generate-type.ts +0 -23
  20. package/src/schema/get-remote-schema.ts +0 -43
  21. package/src/schema/parse-schema.ts +0 -170
  22. package/src/schema/read-local-schema.ts +0 -46
  23. package/src/schema/transform-files.ts +0 -67
  24. package/src/tsconfig.json +0 -7
  25. package/src/types/errors.ts +0 -19
  26. package/src/types/pocketbase-api-response.type.ts +0 -40
  27. package/src/types/pocketbase-entry.type.ts +0 -24
  28. package/src/types/pocketbase-live-loader-filter.type.ts +0 -55
  29. package/src/types/pocketbase-loader-options.type.ts +0 -146
  30. package/src/types/pocketbase-schema.type.ts +0 -101
  31. package/src/utils/combine-fields-for-request.ts +0 -55
  32. package/src/utils/create-token-promise.ts +0 -25
  33. package/src/utils/extract-field-names.ts +0 -15
  34. package/src/utils/format-fields.ts +0 -66
  35. package/src/utils/get-superuser-token.ts +0 -76
  36. package/src/utils/is-realtime-data.ts +0 -34
  37. package/src/utils/should-refresh.ts +0 -37
  38. package/src/utils/slugify.ts +0 -21
package/dist/index.mjs ADDED
@@ -0,0 +1,973 @@
1
+ import { LiveCollectionError, LiveCollectionValidationError, LiveEntryNotFoundError } from "astro/content/runtime";
2
+ import { z } from "astro/zod";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+ import { createAuxiliaryTypeStore, createTypeAlias, printNode, zodToTs } from "zod-to-ts";
6
+ //#region src/types/errors.ts
7
+ /**
8
+ * Error thrown when there is an authentication issue with PocketBase.
9
+ */
10
+ var PocketBaseAuthenticationError = class extends LiveCollectionError {
11
+ constructor(collection, message) {
12
+ super(collection, message);
13
+ this.name = "PocketBaseAuthenticationError";
14
+ }
15
+ static is(error) {
16
+ return !!error && error.name === "PocketBaseAuthenticationError";
17
+ }
18
+ };
19
+ //#endregion
20
+ //#region src/types/pocketbase-entry.type.ts
21
+ /**
22
+ * Schema for a PocketBase entry.
23
+ */
24
+ const pocketBaseEntry = z.looseObject({
25
+ /**
26
+ * ID of the entry.
27
+ */
28
+ id: z.string(),
29
+ /**
30
+ * ID of the collection the entry belongs to.
31
+ */
32
+ collectionId: z.string(),
33
+ /**
34
+ * Name of the collection the entry belongs to.
35
+ */
36
+ collectionName: z.string()
37
+ });
38
+ //#endregion
39
+ //#region src/types/pocketbase-api-response.type.ts
40
+ /**
41
+ * The schema for a PocketBase error response.
42
+ */
43
+ const pocketBaseErrorResponse = z.object({
44
+ /**
45
+ * The error message returned by PocketBase.
46
+ */
47
+ message: z.string() });
48
+ /**
49
+ * The schema for a PocketBase list response.
50
+ */
51
+ const pocketBaseListResponse = z.object({
52
+ /**
53
+ * Current page number.
54
+ */
55
+ page: z.number(),
56
+ /**
57
+ * Total number of pages available.
58
+ */
59
+ totalPages: z.number(),
60
+ /**
61
+ * Array of items in the current page.
62
+ */
63
+ items: z.array(pocketBaseEntry)
64
+ });
65
+ /**
66
+ * The schema for a PocketBase login response.
67
+ */
68
+ const pocketBaseLoginResponse = z.object({
69
+ /**
70
+ * The authentication token returned by PocketBase.
71
+ */
72
+ token: z.string() });
73
+ //#endregion
74
+ //#region src/utils/combine-fields-for-request.ts
75
+ /**
76
+ * Combine basic, special, and user-specified fields for PocketBase API requests.
77
+ * This utility ensures that required system fields are always included in API requests.
78
+ *
79
+ * @param userFields Array of fields specified by the user, or undefined for all fields
80
+ * @param options PocketBase loader options containing custom field configurations
81
+ * @returns Combined array of fields to include in the API request, or undefined for all fields
82
+ */
83
+ function combineFieldsForRequest(userFields, options) {
84
+ if (!userFields) return;
85
+ const basicFields = [
86
+ "id",
87
+ "collectionId",
88
+ "collectionName"
89
+ ];
90
+ const specialFields = [];
91
+ if (options.idField && options.idField !== "id") specialFields.push(options.idField);
92
+ if (options.updatedField) specialFields.push(options.updatedField);
93
+ if (options.contentFields) if (Array.isArray(options.contentFields)) specialFields.push(...options.contentFields);
94
+ else specialFields.push(options.contentFields);
95
+ return [...new Set([
96
+ ...basicFields,
97
+ ...specialFields,
98
+ ...userFields
99
+ ])];
100
+ }
101
+ //#endregion
102
+ //#region src/utils/format-fields.ts
103
+ /**
104
+ * Format fields option into an array and validate for expand usage.
105
+ * Handles wildcard "*" and preserves excerpt field modifiers.
106
+ *
107
+ * @param fields The fields option (string or array)
108
+ * @returns Formatted fields array, or undefined if no fields specified or "*" wildcard is used
109
+ */
110
+ function formatFields(fields) {
111
+ if (!fields || fields.length === 0) return;
112
+ let fieldList;
113
+ if (Array.isArray(fields)) fieldList = fields.map((f) => f.trim());
114
+ else fieldList = splitFieldsString(fields).map((f) => f.trim());
115
+ if (fieldList.some((field) => field.includes("expand"))) {
116
+ console.warn("The \"expand\" parameter is not currently supported by astro-loader-pocketbase and will be filtered out.");
117
+ fieldList = fieldList.filter((field) => !field.includes("expand"));
118
+ }
119
+ if (fieldList.some((field) => field === "*")) return;
120
+ return fieldList;
121
+ }
122
+ /**
123
+ * Splits the fields string at `,` but respects the `:excerpt(number, boolean)` option
124
+ */
125
+ function splitFieldsString(fieldsString) {
126
+ const initialSplit = fieldsString.split(",");
127
+ const fields = [];
128
+ for (let i = 0; i < initialSplit.length; i++) {
129
+ const part = initialSplit.at(i);
130
+ if (!part) continue;
131
+ if (part.includes("(") && !part.includes(")")) {
132
+ fields.push(`${part},${initialSplit[++i]}`);
133
+ continue;
134
+ }
135
+ fields.push(part);
136
+ }
137
+ return fields;
138
+ }
139
+ //#endregion
140
+ //#region src/loader/fetch-collection.ts
141
+ /**
142
+ * Fetches entries from a PocketBase collection, optionally filtering by modification date and supporting pagination.
143
+ */
144
+ async function fetchCollection(options, chunkLoaded, token, collectionFilter) {
145
+ const collectionUrl = new URL(`api/collections/${options.collectionName}/records`, options.url);
146
+ const collectionHeaders = new Headers();
147
+ if (token) collectionHeaders.set("Authorization", token);
148
+ const combinedFields = combineFieldsForRequest(formatFields(options.fields), options);
149
+ let page = 0;
150
+ let totalPages = 0;
151
+ do {
152
+ collectionUrl.search = buildSearchParams(options, combinedFields, {
153
+ ...collectionFilter,
154
+ page: collectionFilter?.page ?? ++page,
155
+ perPage: collectionFilter?.perPage ?? 100
156
+ }).toString();
157
+ const collectionRequest = await fetch(collectionUrl.href, { headers: collectionHeaders });
158
+ if (!collectionRequest.ok) {
159
+ if (collectionRequest.status === 403) if (options.superuserCredentials && "impersonateToken" in options.superuserCredentials) throw new PocketBaseAuthenticationError(options.collectionName, "The given impersonate token is not valid.");
160
+ else throw new PocketBaseAuthenticationError(options.collectionName, "The collection is not accessible without superuser rights. Please provide superuser credentials in the config.");
161
+ if (collectionRequest.status === 404) throw new LiveEntryNotFoundError(options.collectionName, { ...collectionFilter });
162
+ const errorResponse = pocketBaseErrorResponse.parse(await collectionRequest.json());
163
+ throw new LiveCollectionError(options.collectionName, errorResponse.message);
164
+ }
165
+ const response = pocketBaseListResponse.parse(await collectionRequest.json());
166
+ await chunkLoaded(response.items);
167
+ page = response.page;
168
+ totalPages = response.totalPages;
169
+ } while (!collectionFilter?.perPage && page < totalPages);
170
+ }
171
+ /**
172
+ * Build search parameters for the PocketBase collection request.
173
+ */
174
+ function buildSearchParams(loaderOptions, combinedFields, collectionFilter) {
175
+ const searchParams = new URLSearchParams();
176
+ if (collectionFilter.page) searchParams.set("page", `${collectionFilter.page}`);
177
+ if (collectionFilter.perPage) searchParams.set("perPage", `${collectionFilter.perPage}`);
178
+ const filters = [];
179
+ if (collectionFilter.lastModified && loaderOptions.updatedField) {
180
+ filters.push(`(${loaderOptions.updatedField}>"${collectionFilter.lastModified}")`);
181
+ searchParams.set("sort", `-${loaderOptions.updatedField},id`);
182
+ }
183
+ if (loaderOptions.filter) filters.push(`(${loaderOptions.filter})`);
184
+ if (collectionFilter.filter) filters.push(`(${collectionFilter.filter})`);
185
+ if (filters.length > 0) searchParams.set("filter", filters.join("&&"));
186
+ if (collectionFilter.sort) searchParams.set("sort", collectionFilter.sort);
187
+ if (combinedFields) searchParams.set("fields", combinedFields.join(","));
188
+ return searchParams;
189
+ }
190
+ //#endregion
191
+ //#region src/loader/parse-live-entry.ts
192
+ /**
193
+ * Converts a PocketBase entry into a LiveDataEntry for Astro, extracting content and cache metadata.
194
+ */
195
+ function parseLiveEntry(entry, options) {
196
+ const tag = `${options.collectionName}-${entry.id}`;
197
+ let lastModified = void 0;
198
+ if (options.updatedField && entry[options.updatedField]) {
199
+ const value = `${entry[options.updatedField]}`;
200
+ const date = z.coerce.date().safeParse(value);
201
+ if (!date.success) throw new LiveCollectionValidationError(options.collectionName, entry.id, date.error);
202
+ lastModified = date.data;
203
+ }
204
+ if (!options.contentFields) return {
205
+ id: entry.id,
206
+ data: entry,
207
+ cacheHint: {
208
+ tags: [tag],
209
+ lastModified
210
+ }
211
+ };
212
+ let content;
213
+ if (typeof options.contentFields === "string") content = `${entry[options.contentFields]}`;
214
+ else content = options.contentFields.map((field) => `<section id="${field}">${entry[field]}</section>`).join("");
215
+ return {
216
+ id: entry.id,
217
+ data: entry,
218
+ rendered: { html: content },
219
+ cacheHint: {
220
+ tags: [tag],
221
+ lastModified
222
+ }
223
+ };
224
+ }
225
+ //#endregion
226
+ //#region src/loader/live-collection-loader.ts
227
+ /**
228
+ * Loads and parses a PocketBase collection for live data, returning entries or an error.
229
+ */
230
+ async function liveCollectionLoader(collectionFilter, options, token) {
231
+ const entries = [];
232
+ try {
233
+ await fetchCollection(options, async (chunk) => {
234
+ entries.push(...chunk.map((entry) => parseLiveEntry(entry, options)));
235
+ }, token, collectionFilter);
236
+ } catch (error) {
237
+ if (error instanceof LiveCollectionError) return { error };
238
+ if (error instanceof Error) return { error: new LiveCollectionError(options.collectionName, error.message, error) };
239
+ return { error: new LiveCollectionError(options.collectionName, String(error)) };
240
+ }
241
+ return { entries };
242
+ }
243
+ //#endregion
244
+ //#region src/loader/fetch-entry.ts
245
+ /**
246
+ * Retrieves a specific entry from a PocketBase collection using its ID and loader options.
247
+ */
248
+ async function fetchEntry(id, options, token) {
249
+ const entryUrl = new URL(`api/collections/${options.collectionName}/records/${id}`, options.url);
250
+ const combinedFields = combineFieldsForRequest(formatFields(options.fields), options);
251
+ if (combinedFields) entryUrl.searchParams.set("fields", combinedFields.join(","));
252
+ const entryHeaders = new Headers();
253
+ if (token) entryHeaders.set("Authorization", token);
254
+ const entryRequest = await fetch(entryUrl.href, { headers: entryHeaders });
255
+ if (!entryRequest.ok) {
256
+ if (entryRequest.status === 403) if (options.superuserCredentials && "impersonateToken" in options.superuserCredentials) throw new PocketBaseAuthenticationError(options.collectionName, "The given impersonate token is not valid.");
257
+ else throw new PocketBaseAuthenticationError(options.collectionName, "The entry is not accessible without superuser rights. Please provide superuser credentials in the config.");
258
+ if (entryRequest.status === 404) throw new LiveEntryNotFoundError(options.collectionName, id);
259
+ const errorResponse = pocketBaseErrorResponse.parse(await entryRequest.json());
260
+ throw new LiveCollectionError(options.collectionName, errorResponse.message);
261
+ }
262
+ return pocketBaseEntry.parse(await entryRequest.json());
263
+ }
264
+ //#endregion
265
+ //#region src/loader/live-entry-loader.ts
266
+ /**
267
+ * Loads and parses a single PocketBase entry for live data, returning the entry or an error.
268
+ */
269
+ async function liveEntryLoader(id, options, token) {
270
+ try {
271
+ return parseLiveEntry(await fetchEntry(id, options, token), options);
272
+ } catch (error) {
273
+ if (error instanceof LiveCollectionError) return { error };
274
+ if (error instanceof Error) return { error: new LiveCollectionError(options.collectionName, error.message, error) };
275
+ return { error: new LiveCollectionError(options.collectionName, String(error)) };
276
+ }
277
+ }
278
+ //#endregion
279
+ //#region package.json
280
+ var version = "3.1.2-next.2";
281
+ //#endregion
282
+ //#region src/utils/should-refresh.ts
283
+ /**
284
+ * Checks if the collection should be refreshed.
285
+ */
286
+ function shouldRefresh(context, collectionName) {
287
+ if (context?.source !== "astro-integration-pocketbase") return "refresh";
288
+ if (context.force) return "force";
289
+ if (!context.collection) return "refresh";
290
+ if (typeof context.collection === "string") return context.collection === collectionName ? "refresh" : "skip";
291
+ if (Array.isArray(context.collection)) return context.collection.includes(collectionName) ? "refresh" : "skip";
292
+ return "refresh";
293
+ }
294
+ //#endregion
295
+ //#region src/loader/cleanup-entries.ts
296
+ /**
297
+ * Cleanup entries that are no longer in the collection.
298
+ *
299
+ * @param options Options for the loader.
300
+ * @param context Context of the loader.
301
+ * @param superuserToken Superuser token to access all resources.
302
+ */
303
+ async function cleanupEntries(options, context, superuserToken) {
304
+ const collectionUrl = new URL(`api/collections/${options.collectionName}/records`, options.url).href;
305
+ const collectionHeaders = new Headers();
306
+ if (superuserToken) collectionHeaders.set("Authorization", superuserToken);
307
+ let page = 0;
308
+ let totalPages = 0;
309
+ const entries = /* @__PURE__ */ new Set();
310
+ do {
311
+ const searchParams = new URLSearchParams({
312
+ page: `${++page}`,
313
+ perPage: "1000",
314
+ fields: "id"
315
+ });
316
+ if (options.filter) searchParams.set("filter", `(${options.filter})`);
317
+ const collectionRequest = await fetch(`${collectionUrl}?${searchParams.toString()}`, { headers: collectionHeaders });
318
+ if (!collectionRequest.ok) {
319
+ if (collectionRequest.status === 403) if (options.superuserCredentials && "impersonateToken" in options.superuserCredentials) context.logger.error("The given impersonate token is not valid.");
320
+ else context.logger.error("The collection is not accessible without superuser rights. Please provide superuser credentials in the config.");
321
+ else {
322
+ const errorResponse = pocketBaseErrorResponse.parse(await collectionRequest.json());
323
+ const errorMessage = `Fetching ids failed with status code ${collectionRequest.status}.\nReason: ${errorResponse.message}`;
324
+ context.logger.error(errorMessage);
325
+ }
326
+ context.logger.info(`Removing all entries from the store.`);
327
+ context.store.clear();
328
+ return;
329
+ }
330
+ const response = cleanUpEntriesResponse.parse(await collectionRequest.json());
331
+ for (const item of response.items) entries.add(item.id);
332
+ page = response.page;
333
+ totalPages = response.totalPages;
334
+ } while (page < totalPages);
335
+ let cleanedUp = 0;
336
+ const storedIds = new Map(context.store.values().map((entry) => [entry.data.id, entry.id]));
337
+ for (const [pocketbaseId, storeKey] of storedIds.entries()) if (!entries.has(pocketbaseId)) {
338
+ context.store.delete(storeKey);
339
+ cleanedUp++;
340
+ }
341
+ if (cleanedUp > 0) context.logger.info(`Cleaned up ${cleanedUp} old entries.`);
342
+ }
343
+ /**
344
+ * The response schema for cleaning up entries.
345
+ */
346
+ const cleanUpEntriesResponse = pocketBaseListResponse.omit({ items: true }).extend({ items: z.array(pocketBaseEntry.pick({ id: true })) });
347
+ //#endregion
348
+ //#region src/utils/is-realtime-data.ts
349
+ /**
350
+ * Schema for realtime data received from PocketBase.
351
+ */
352
+ const realtimeDataSchema = z.object({
353
+ action: z.union([
354
+ z.literal("create"),
355
+ z.literal("update"),
356
+ z.literal("delete")
357
+ ]),
358
+ record: z.object({
359
+ id: z.string(),
360
+ collectionName: z.string(),
361
+ collectionId: z.string()
362
+ })
363
+ });
364
+ /**
365
+ * Checks if the given data is realtime data received from PocketBase.
366
+ */
367
+ function isRealtimeData(data) {
368
+ try {
369
+ realtimeDataSchema.parse(data);
370
+ return true;
371
+ } catch {
372
+ return false;
373
+ }
374
+ }
375
+ //#endregion
376
+ //#region src/utils/slugify.ts
377
+ /**
378
+ * Convert a string to a slug.
379
+ *
380
+ * Example:
381
+ * ```ts
382
+ * slugify("Hello World!"); // hello-world
383
+ * ```
384
+ */
385
+ function slugify(input) {
386
+ return input.toLowerCase().replaceAll(/\s+/g, "-").replaceAll("ä", "ae").replaceAll("ö", "oe").replaceAll("ü", "ue").replaceAll("ß", "ss").replaceAll(/[^\w-]+/g, "").replaceAll(/--+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
387
+ }
388
+ //#endregion
389
+ //#region src/loader/parse-entry.ts
390
+ /**
391
+ * Parse an entry from PocketBase to match the schema and store it in the store.
392
+ *
393
+ * @param entry Entry to parse.
394
+ * @param context Context of the loader.
395
+ * @param idField Field to use as id for the entry.
396
+ * If not provided, the id of the entry will be used.
397
+ * @param contentFields Field(s) to use as content for the entry.
398
+ * If multiple fields are used, they will be concatenated and wrapped in `<section>` elements.
399
+ */
400
+ async function parseEntry(entry, { generateDigest, parseData, store, logger }, { idField, contentFields, updatedField }) {
401
+ let id = entry.id;
402
+ if (idField) {
403
+ const customEntryId = entry[idField];
404
+ if (!customEntryId) logger.warn(`The entry "${id}" does not have a value for field ${idField}. Using the default ID instead.`);
405
+ else id = slugify(`${customEntryId}`);
406
+ }
407
+ const oldEntry = store.get(id);
408
+ if (oldEntry && oldEntry.data.id !== entry.id) logger.warn(`The entry "${entry.id}" seems to be a duplicate of "${oldEntry.data.id}". Please make sure to use unique IDs in the column "${idField}".`);
409
+ const data = await parseData({
410
+ id,
411
+ data: entry
412
+ });
413
+ let updated;
414
+ if (updatedField) updated = `${entry[updatedField]}`;
415
+ const digest = generateDigest(updated ?? entry);
416
+ if (!contentFields) {
417
+ store.set({
418
+ id,
419
+ data,
420
+ digest
421
+ });
422
+ return;
423
+ }
424
+ let content;
425
+ if (typeof contentFields === "string") content = `${entry[contentFields]}`;
426
+ else content = contentFields.map((field) => `<section id="${field}">${entry[field]}</section>`).join("");
427
+ store.set({
428
+ id,
429
+ data,
430
+ digest,
431
+ rendered: { html: content }
432
+ });
433
+ }
434
+ //#endregion
435
+ //#region src/loader/handle-realtime-updates.ts
436
+ /**
437
+ * Handles realtime updates for the loader without making any new network requests.
438
+ *
439
+ * Returns `true` if the data was handled and no further action is needed.
440
+ */
441
+ async function handleRealtimeUpdates(context, options) {
442
+ if (options.filter) return false;
443
+ if (!context.refreshContextData?.data) return false;
444
+ const data = context.refreshContextData.data;
445
+ if (!isRealtimeData(data)) return false;
446
+ if (data.record.collectionName !== options.collectionName) return false;
447
+ if (data.action === "delete") {
448
+ context.logger.info("Removing deleted entry");
449
+ context.store.delete(data.record.id);
450
+ return true;
451
+ }
452
+ if (data.action === "update") context.logger.info("Updating outdated entry");
453
+ else context.logger.info("Creating new entry");
454
+ await parseEntry(data.record, context, options);
455
+ return true;
456
+ }
457
+ //#endregion
458
+ //#region src/loader/load-entries.ts
459
+ /**
460
+ * Load (modified) entries from a PocketBase collection.
461
+ *
462
+ * @param options Options for the loader.
463
+ * @param context Context of the loader.
464
+ * @param superuserToken Superuser token to access all resources.
465
+ * @param lastModified Date of the last fetch to only update changed entries.
466
+ */
467
+ async function loadEntries(options, context, superuserToken, lastModified) {
468
+ context.logger.info(`Fetching${lastModified ? " modified" : ""} data${lastModified ? ` starting at ${lastModified}` : ""}${superuserToken ? " as superuser" : ""}`);
469
+ let numEntries = 0;
470
+ await fetchCollection(options, async (entries) => {
471
+ for (const entry of entries) await parseEntry(entry, context, options);
472
+ numEntries += entries.length;
473
+ }, superuserToken, { lastModified });
474
+ if (lastModified) context.logger.info(`Updated ${numEntries}/${context.store.keys().length} entries.`);
475
+ else context.logger.info(`Fetched ${numEntries} entries.`);
476
+ }
477
+ //#endregion
478
+ //#region src/loader/loader.ts
479
+ /**
480
+ * Load entries from a PocketBase collection.
481
+ */
482
+ async function loader(context, options, token) {
483
+ context.logger.label = `pocketbase-loader:${options.collectionName}`;
484
+ const refresh = shouldRefresh(context.refreshContextData, options.collectionName);
485
+ if (refresh === "skip") return;
486
+ if (await handleRealtimeUpdates(context, options)) return;
487
+ let lastModified = context.meta.get("last-modified");
488
+ if (refresh === "force") {
489
+ lastModified = void 0;
490
+ context.store.clear();
491
+ }
492
+ const lastVersion = context.meta.get("version");
493
+ if (lastVersion !== version) {
494
+ if (lastVersion) context.logger.info(`PocketBase loader was updated from ${lastVersion} to ${version}. All entries will be loaded again.`);
495
+ lastModified = void 0;
496
+ context.store.clear();
497
+ }
498
+ if (!options.updatedField) {
499
+ context.logger.info(`No "updatedField" was provided. Incremental builds are disabled.`);
500
+ lastModified = void 0;
501
+ }
502
+ if (context.store.keys().length > 0) await cleanupEntries(options, context, token);
503
+ await loadEntries(options, context, token, lastModified);
504
+ context.meta.set("last-modified", (/* @__PURE__ */ new Date()).toISOString().replace("T", " "));
505
+ context.meta.set("version", version);
506
+ }
507
+ //#endregion
508
+ //#region src/utils/extract-field-names.ts
509
+ /**
510
+ * Extract field names from fields that may contain modifiers like :excerpt().
511
+ *
512
+ * @param fields Array of field specifications that may contain modifiers
513
+ * @returns Array of clean field names suitable for schema parsing
514
+ */
515
+ function extractFieldNames(fields) {
516
+ if (!fields) return;
517
+ return fields.map((field) => field.split(":").at(0) ?? field);
518
+ }
519
+ //#endregion
520
+ //#region src/types/pocketbase-schema.type.ts
521
+ /**
522
+ * Entry for a collections schema in PocketBase.
523
+ */
524
+ const pocketBaseSchemaEntry = z.object({
525
+ /**
526
+ * Flag to indicate if the field is hidden.
527
+ * Hidden fields are not returned in the API response.
528
+ */
529
+ hidden: z.optional(z.boolean()),
530
+ /**
531
+ * Unique identifier for the field.
532
+ */
533
+ id: z.string(),
534
+ /**
535
+ * Name of the field.
536
+ */
537
+ name: z.string(),
538
+ /**
539
+ * Help text for the field.
540
+ * This is only present if the field has help text defined.
541
+ */
542
+ help: z.optional(z.string()),
543
+ /**
544
+ * Type of the field.
545
+ */
546
+ type: z.enum([
547
+ "text",
548
+ "editor",
549
+ "number",
550
+ "bool",
551
+ "email",
552
+ "url",
553
+ "date",
554
+ "autodate",
555
+ "select",
556
+ "file",
557
+ "relation",
558
+ "json",
559
+ "geoPoint",
560
+ "password"
561
+ ]),
562
+ /**
563
+ * Whether the field is required.
564
+ */
565
+ required: z.optional(z.boolean()),
566
+ /**
567
+ * Values for a select field.
568
+ * This is only present if the field type is "select".
569
+ */
570
+ values: z.optional(z.array(z.string())),
571
+ /**
572
+ * Maximum number of values for a select field.
573
+ * This is only present on "select", "relation", and "file" fields.
574
+ */
575
+ maxSelect: z.optional(z.number()),
576
+ /**
577
+ * Whether the field is filled when the entry is created.
578
+ * This is only present on "autodate" fields.
579
+ */
580
+ onCreate: z.optional(z.boolean()),
581
+ /**
582
+ * Whether the field is updated when the entry is updated.
583
+ * This is only present on "autodate" fields.
584
+ */
585
+ onUpdate: z.optional(z.boolean())
586
+ });
587
+ /**
588
+ * Schema for a PocketBase collection.
589
+ */
590
+ const pocketBaseCollection = z.object({
591
+ /**
592
+ * Name of the collection.
593
+ */
594
+ name: z.string(),
595
+ /**
596
+ * Type of the collection.
597
+ */
598
+ type: z.enum([
599
+ "base",
600
+ "view",
601
+ "auth"
602
+ ]),
603
+ /**
604
+ * Schema of the collection.
605
+ */
606
+ fields: z.array(pocketBaseSchemaEntry)
607
+ });
608
+ /**
609
+ * Schema for an entire PocketBase database.
610
+ */
611
+ const pocketBaseDatabase = z.array(pocketBaseCollection);
612
+ //#endregion
613
+ //#region src/schema/get-remote-schema.ts
614
+ /**
615
+ * Fetches the schema for the specified collection from the PocketBase instance.
616
+ *
617
+ * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
618
+ * @param token The superuser token to authenticate the request.
619
+ */
620
+ async function getRemoteSchema(options, token) {
621
+ const schemaUrl = new URL(`api/collections/${options.collectionName}`, options.url).href;
622
+ const schemaHeaders = new Headers();
623
+ schemaHeaders.set("Authorization", token);
624
+ const schemaRequest = await fetch(schemaUrl, { headers: schemaHeaders });
625
+ if (!schemaRequest.ok) {
626
+ const errorResponse = pocketBaseErrorResponse.parse(await schemaRequest.json());
627
+ const errorMessage = `Fetching schema from ${options.collectionName} failed with status code ${schemaRequest.status}.\nReason: ${errorResponse.message}`;
628
+ console.error(errorMessage);
629
+ return;
630
+ }
631
+ return pocketBaseCollection.parse(await schemaRequest.json());
632
+ }
633
+ //#endregion
634
+ //#region src/schema/parse-schema.ts
635
+ /**
636
+ * Converts PocketBase collection fields into Zod types, handling field types, required status, and custom schemas.
637
+ */
638
+ function parseSchema(collection, customSchemas, options) {
639
+ const fields = {};
640
+ for (const field of collection.fields) {
641
+ if (options.fieldsToInclude && !options.fieldsToInclude.includes(field.name)) continue;
642
+ if (field.hidden && !options.hasSuperuserRights) {
643
+ if (options.fieldsToInclude) console.warn(`"${field.name}" is requested but hidden. Provide superuser credentials to include this field.`);
644
+ continue;
645
+ }
646
+ let fieldType;
647
+ switch (field.type) {
648
+ case "number":
649
+ fieldType = z.number();
650
+ break;
651
+ case "bool":
652
+ fieldType = z.boolean();
653
+ break;
654
+ case "date":
655
+ case "autodate":
656
+ if (options.experimentalLiveTypesOnly) {
657
+ fieldType = z.string();
658
+ break;
659
+ }
660
+ fieldType = z.coerce.date();
661
+ break;
662
+ case "geoPoint":
663
+ fieldType = z.object({
664
+ lon: z.number(),
665
+ lat: z.number()
666
+ });
667
+ break;
668
+ case "select":
669
+ if (!field.values) throw new Error(`Field ${field.name} is of type "select" but has no values defined.`);
670
+ fieldType = parseSingleOrMultipleValues(field, z.enum(field.values));
671
+ break;
672
+ case "relation":
673
+ case "file":
674
+ fieldType = parseSingleOrMultipleValues(field, z.string());
675
+ break;
676
+ case "json":
677
+ fieldType = customSchemas?.[field.name] ?? z.unknown();
678
+ break;
679
+ default:
680
+ fieldType = z.string();
681
+ break;
682
+ }
683
+ if (!(field.required || field.type === "autodate" && field.onCreate || field.type === "number" || field.type === "bool")) fieldType = z.preprocess((val) => val || void 0, z.optional(fieldType));
684
+ fields[field.name] = fieldType.meta({
685
+ id: field.id,
686
+ title: field.name,
687
+ description: getFieldDescription(field)
688
+ });
689
+ }
690
+ return fields;
691
+ }
692
+ /**
693
+ * Parse the field type based on the number of values it can have
694
+ *
695
+ * @param field Field to parse
696
+ * @param type Type of each value
697
+ *
698
+ * @returns The parsed field type
699
+ */
700
+ function parseSingleOrMultipleValues(field, type) {
701
+ if (field.maxSelect === void 0 || field.maxSelect === 1) return type;
702
+ return z.array(type);
703
+ }
704
+ /**
705
+ * Get the description for a field based on its help text and type.
706
+ */
707
+ function getFieldDescription(field) {
708
+ switch (true) {
709
+ case !!field.help: return field.help;
710
+ case field.type === "autodate" && field.onUpdate: return "Date when the entry was last updated. This field is automatically updated by PocketBase whenever the entry is updated.";
711
+ case field.type === "autodate" && field.onCreate: return "Date when the entry was created. This field is automatically set by PocketBase when the entry is created.";
712
+ case field.name === "id": return "The unique identifier for the entry.";
713
+ case field.hidden: return "This field is hidden and may require superuser credentials to access.";
714
+ default: return;
715
+ }
716
+ }
717
+ //#endregion
718
+ //#region src/schema/read-local-schema.ts
719
+ /**
720
+ * Reads the local PocketBase schema file and returns the schema for the specified collection.
721
+ *
722
+ * @param localSchemaPath Path to the local schema file.
723
+ * @param collectionName Name of the collection to get the schema for.
724
+ */
725
+ async function readLocalSchema(localSchemaPath, collectionName) {
726
+ const realPath = path.join(process.cwd(), localSchemaPath);
727
+ try {
728
+ const schemaFile = await fs.readFile(realPath, "utf-8");
729
+ const fileContent = pocketBaseDatabase.safeParse(JSON.parse(schemaFile));
730
+ if (!fileContent.success) throw new Error("Invalid schema file");
731
+ const schema = fileContent.data.find((collection) => collection.name === collectionName);
732
+ if (!schema) throw new Error(`Collection "${collectionName}" not found in schema file`);
733
+ return schema;
734
+ } catch (error) {
735
+ console.error(`Failed to read local schema from ${localSchemaPath}. No types will be generated.\nReason: ${error}`);
736
+ return;
737
+ }
738
+ }
739
+ //#endregion
740
+ //#region src/schema/transform-files.ts
741
+ /**
742
+ * Transforms file names in a PocketBase entry to file URLs.
743
+ *
744
+ * @param baseUrl URL of the PocketBase instance.
745
+ * @param collection Collection of the entry.
746
+ * @param entry Entry to transform.
747
+ */
748
+ function transformFiles(baseUrl, fileFields, entry) {
749
+ for (const field of fileFields) {
750
+ const fieldName = field.name;
751
+ if (field.maxSelect === 1) {
752
+ const fileName = z.optional(z.string()).parse(entry[fieldName]);
753
+ if (!fileName) continue;
754
+ entry[fieldName] = transformFileUrl(baseUrl, entry.collectionName, entry.id, fileName);
755
+ } else {
756
+ const fileNames = z.optional(z.array(z.string())).parse(entry[fieldName]);
757
+ if (!fileNames) continue;
758
+ entry[fieldName] = fileNames.map((file) => transformFileUrl(baseUrl, entry.collectionName, entry.id, file));
759
+ }
760
+ }
761
+ return entry;
762
+ }
763
+ /**
764
+ * Transforms a file name to a PocketBase file URL.
765
+ *
766
+ * @param base Base URL of the PocketBase instance.
767
+ * @param collectionName Name of the collection.
768
+ * @param entryId ID of the entry.
769
+ * @param file Name of the file.
770
+ */
771
+ function transformFileUrl(base, collectionName, entryId, file) {
772
+ return new URL(`api/files/${collectionName}/${entryId}/${file}`, base).href;
773
+ }
774
+ //#endregion
775
+ //#region src/schema/generate-schema.ts
776
+ /**
777
+ * Basic schema for every PocketBase collection.
778
+ */
779
+ const BASIC_SCHEMA = z.object({
780
+ id: z.string().meta({
781
+ title: "id",
782
+ description: "The unique identifier for the entry."
783
+ }),
784
+ collectionId: z.string().meta({
785
+ title: "collectionId",
786
+ description: "The unique identifier for the collection the entity belongs to."
787
+ }),
788
+ collectionName: z.string().meta({
789
+ title: "collectionName",
790
+ description: "The name of the collection the entity belongs to."
791
+ })
792
+ });
793
+ /**
794
+ * Types of fields that can be used as an ID.
795
+ */
796
+ const VALID_ID_TYPES = [
797
+ "text",
798
+ "number",
799
+ "email",
800
+ "url",
801
+ "date"
802
+ ];
803
+ /**
804
+ * Generate a schema for the collection based on the collection's schema in PocketBase.
805
+ * By default, a basic schema is returned if no other schema is available.
806
+ * If superuser credentials are provided, the schema is fetched from the PocketBase API.
807
+ * If a path to a local schema file is provided, the schema is read from the file.
808
+ *
809
+ * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
810
+ * @param token The superuser token to authenticate the request.
811
+ */
812
+ async function generateSchema(options, token) {
813
+ let collection;
814
+ if (token) collection = await getRemoteSchema(options, token);
815
+ const hasSuperuserRights = !!collection || !!options.superuserCredentials;
816
+ if (!collection && options.localSchema) collection = await readLocalSchema(options.localSchema, options.collectionName);
817
+ if (!collection) {
818
+ console.error(`No schema available for "${options.collectionName}". Only basic types are available. Please check your configuration and provide a valid schema file or superuser credentials.`);
819
+ return BASIC_SCHEMA;
820
+ }
821
+ const fieldsToInclude = combineFieldsForRequest(extractFieldNames(formatFields(options.fields)), options);
822
+ const fields = parseSchema(collection, options.jsonSchemas, {
823
+ hasSuperuserRights,
824
+ fieldsToInclude,
825
+ experimentalLiveTypesOnly: options.experimental?.liveTypesOnly
826
+ });
827
+ checkCustomIdField(collection, options);
828
+ checkContentField(fields, options);
829
+ checkUpdatedField(fields, collection, options);
830
+ const schema = z.object({
831
+ ...BASIC_SCHEMA.shape,
832
+ ...fields
833
+ });
834
+ const fileFields = collection.fields.filter((field) => field.type === "file").filter((field) => !field.hidden || hasSuperuserRights);
835
+ if (fileFields.length === 0) return schema;
836
+ return schema.transform((entry) => transformFiles(options.url, fileFields, entry));
837
+ }
838
+ /**
839
+ * Check if the custom id field is present
840
+ */
841
+ function checkCustomIdField(collection, options) {
842
+ if (!options.idField) return;
843
+ const idField = collection.fields.find((field) => field.name === options.idField);
844
+ if (!idField) console.error(`The id field "${options.idField}" is not present in the schema of the collection "${options.collectionName}".`);
845
+ else if (!VALID_ID_TYPES.includes(idField.type)) console.error(`The id field "${options.idField}" for collection "${options.collectionName}" is of type "${idField.type}" which is not recommended. Please use one of the following types: ${VALID_ID_TYPES.join(", ")}.`);
846
+ }
847
+ /**
848
+ * Check if the content field(s) are present
849
+ */
850
+ function checkContentField(fields, options) {
851
+ if (typeof options.contentFields === "string" && !fields[options.contentFields]) console.error(`The content field "${options.contentFields}" is not present in the schema of the collection "${options.collectionName}".`);
852
+ else if (Array.isArray(options.contentFields)) {
853
+ for (const field of options.contentFields) if (!fields[field]) console.error(`The content field "${field}" is not present in the schema of the collection "${options.collectionName}".`);
854
+ }
855
+ }
856
+ /**
857
+ * Check if the updated field is present
858
+ */
859
+ function checkUpdatedField(fields, collection, options) {
860
+ if (!options.updatedField) return;
861
+ if (!fields[options.updatedField]) console.error(`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.`);
862
+ else {
863
+ const updatedField = collection.fields.find((field) => field.name === options.updatedField);
864
+ if (updatedField?.type !== "autodate" || !updatedField.onUpdate) console.warn(`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!`);
865
+ }
866
+ }
867
+ //#endregion
868
+ //#region src/schema/generate-type.ts
869
+ /**
870
+ * Generate a TypeScript type from a Zod schema.
871
+ */
872
+ function generateType(schema) {
873
+ const { node } = zodToTs(schema.type === "pipe" ? schema.in : schema, { auxiliaryTypeStore: createAuxiliaryTypeStore() });
874
+ return `export ${printNode(createTypeAlias(node, "Entry"))}`;
875
+ }
876
+ //#endregion
877
+ //#region src/utils/get-superuser-token.ts
878
+ /**
879
+ * This function will get a superuser token from the given PocketBase instance.
880
+ *
881
+ * @param url URL of the PocketBase instance
882
+ * @param superuserCredentials Credentials of the superuser
883
+ *
884
+ * @returns A superuser token to access all resources of the PocketBase instance.
885
+ */
886
+ async function getSuperuserToken(url, superuserCredentials, logger) {
887
+ const loginUrl = new URL(`api/collections/_superusers/auth-with-password`, url).href;
888
+ const loginData = new FormData();
889
+ loginData.set("identity", superuserCredentials.email);
890
+ loginData.set("password", superuserCredentials.password);
891
+ const loginRequest = await fetch(loginUrl, {
892
+ method: "POST",
893
+ body: loginData
894
+ });
895
+ if (!loginRequest.ok) {
896
+ if (loginRequest.status === 429) {
897
+ const info = "A rate limit was hit while trying to authenticate with PocketBase. Consider using an `impersonateToken` as credentials to avoid this issue.";
898
+ if (logger) logger.info(info);
899
+ else console.info(info);
900
+ const retryAfter = Math.random() * 5 + 3;
901
+ await new Promise((resolve) => {
902
+ setTimeout(resolve, retryAfter * 1e3);
903
+ });
904
+ return getSuperuserToken(url, superuserCredentials, logger);
905
+ }
906
+ const errorMessage = `The given email / password for ${url} was not correct. Astro can't generate type definitions automatically and may not have access to all resources.\nReason: ${pocketBaseErrorResponse.parse(await loginRequest.json()).message}`;
907
+ if (logger) logger.error(errorMessage);
908
+ else console.error(errorMessage);
909
+ return;
910
+ }
911
+ return pocketBaseLoginResponse.parse(await loginRequest.json()).token;
912
+ }
913
+ //#endregion
914
+ //#region src/utils/create-token-promise.ts
915
+ /**
916
+ * Creates a promise that resolves to a superuser token or undefined.
917
+ */
918
+ async function createTokenPromise(options) {
919
+ if (options.superuserCredentials) {
920
+ if ("impersonateToken" in options.superuserCredentials) return options.superuserCredentials.impersonateToken;
921
+ return await getSuperuserToken(options.url, options.superuserCredentials);
922
+ }
923
+ }
924
+ //#endregion
925
+ //#region src/pocketbase-loader.ts
926
+ /**
927
+ * Loader for collections stored in PocketBase.
928
+ *
929
+ * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
930
+ */
931
+ function pocketbaseLoader(options) {
932
+ const tokenPromise = createTokenPromise(options);
933
+ return {
934
+ name: "pocketbase-loader",
935
+ load: async (context) => {
936
+ if (options.experimental?.liveTypesOnly) {
937
+ context.logger.label = `pocketbase-loader:${options.collectionName}`;
938
+ context.logger.info("Experimental live types only mode enabled. No data will be loaded, only types will be generated.");
939
+ return;
940
+ }
941
+ await loader(context, options, await tokenPromise);
942
+ },
943
+ createSchema: async () => {
944
+ const schema = await generateSchema(options, await tokenPromise);
945
+ return {
946
+ schema,
947
+ types: generateType(schema)
948
+ };
949
+ }
950
+ };
951
+ }
952
+ /**
953
+ * Live loader for collections stored in PocketBase.
954
+ *
955
+ * @param options Options for the live loader. See {@link PocketBaseLiveLoaderOptions} for more details.
956
+ */
957
+ function pocketbaseLiveLoader(options) {
958
+ const tokenPromise = createTokenPromise(options);
959
+ return {
960
+ name: "pocketbase-live-loader",
961
+ loadCollection: async ({ filter }) => {
962
+ return liveCollectionLoader(filter, options, await tokenPromise);
963
+ },
964
+ loadEntry: async ({ filter }) => {
965
+ const token = await tokenPromise;
966
+ return liveEntryLoader(filter.id, options, token);
967
+ }
968
+ };
969
+ }
970
+ //#endregion
971
+ export { PocketBaseAuthenticationError, pocketbaseLiveLoader, pocketbaseLoader, transformFileUrl };
972
+
973
+ //# sourceMappingURL=index.mjs.map