Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,50 @@ const blogLive = defineLiveCollection({
});
```

### Expanding relations

The live loader supports expanding relation fields directly in the API request, which will include the related records in the response.
This is useful when you need to access related data without making additional requests.

```ts
const blogLive = defineLiveCollection({
loader: experimentalPocketbaseLiveLoader({
...options,
experimental: {
// Expand single relation field
expand: ["author"]

// Expand multiple relation fields
expand: ["author", "category"]

// Expand nested relations (up to 6 levels deep)
expand: ["author.profile", "category.parent"]
}
})
});
```

When you fetch entries with expanded relations, the related records will be available in the `expand` property:

```ts
const entry = await getLiveEntry("blogLive", { id: "<entry-id>" });

// Access expanded relation data
console.log(entry.expand.author.name);
console.log(entry.expand.category.name);

// Access nested expanded relations
console.log(entry.expand.author.expand.profile.bio);
```

> [!NOTE]
> The expand parameter:
>
> - Supports up to 6 levels of nested relations (enforced by PocketBase)
> - Must use separate array entries for each field (e.g., `["author", "category"]` not `["author,category"]`)
> - Is not compatible with the `fields` option (yet)
> - Does not support schema generation (yet) - expanded data will have `unknown | undefined` types

### Error handling

The live content loader follows Astro's standard error handling conventions for live collections. For more information on how to handle errors in your components, see the [Astro documentation on error handling](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/#error-handling).
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "astro-loader-pocketbase",
"version": "2.9.0",
"version": "2.10.0-live-expand.1",
"description": "A content loader for Astro that uses the PocketBase API",
"keywords": [
"astro",
Expand Down
2 changes: 1 addition & 1 deletion release.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ if (customTag) {
branches.push({
name: branch,
channel: customTag,
prerelease: true
prerelease: customTag
});
}

Expand Down
22 changes: 19 additions & 3 deletions src/loader/fetch-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import {
} from "../types/pocketbase-api-response.type";
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
import type { ExperimentalPocketBaseLiveLoaderCollectionFilter } from "../types/pocketbase-live-loader-filter.type";
import type { PocketBaseLoaderBaseOptions } from "../types/pocketbase-loader-options.type";
import type {
ExperimentalPocketBaseLiveLoaderOptions,
PocketBaseLoaderOptions
} from "../types/pocketbase-loader-options.type";
import { combineFieldsForRequest } from "../utils/combine-fields-for-request";
import { formatExpand } from "../utils/format-expand";
import { formatFields } from "../utils/format-fields";

/**
Expand All @@ -28,7 +32,7 @@ export type CollectionFilter = {
* Fetches entries from a PocketBase collection, optionally filtering by modification date and supporting pagination.
*/
export async function fetchCollection<TEntry extends PocketBaseEntry>(
options: PocketBaseLoaderBaseOptions,
options: PocketBaseLoaderOptions | ExperimentalPocketBaseLiveLoaderOptions,
chunkLoaded: (entries: Array<TEntry>) => Promise<void>,
token: string | undefined,
collectionFilter: CollectionFilter | undefined
Expand Down Expand Up @@ -124,7 +128,9 @@ export async function fetchCollection<TEntry extends PocketBaseEntry>(
* Build search parameters for the PocketBase collection request.
*/
function buildSearchParams(
loaderOptions: PocketBaseLoaderBaseOptions,
loaderOptions:
| PocketBaseLoaderOptions
| ExperimentalPocketBaseLiveLoaderOptions,
combinedFields: Array<string> | undefined,
collectionFilter: CollectionFilter
): URLSearchParams {
Expand Down Expand Up @@ -173,5 +179,15 @@ function buildSearchParams(
searchParams.set("fields", combinedFields.join(","));
}

if (loaderOptions.experimental && "expand" in loaderOptions.experimental) {
const expandString = formatExpand(
loaderOptions.experimental.expand,
loaderOptions.collectionName
);
if (expandString) {
searchParams.set("expand", expandString);
}
}

return searchParams;
}
19 changes: 17 additions & 2 deletions src/loader/fetch-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ import { PocketBaseAuthenticationError } from "../types/errors";
import { pocketBaseErrorResponse } from "../types/pocketbase-api-response.type";
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
import { pocketBaseEntry } from "../types/pocketbase-entry.type";
import type { ExperimentalPocketBaseLiveLoaderOptions } from "../types/pocketbase-loader-options.type";
import type {
ExperimentalPocketBaseLiveLoaderOptions,
PocketBaseLoaderOptions
} from "../types/pocketbase-loader-options.type";
import { combineFieldsForRequest } from "../utils/combine-fields-for-request";
import { formatExpand } from "../utils/format-expand";
import { formatFields } from "../utils/format-fields";

/**
* Retrieves a specific entry from a PocketBase collection using its ID and loader options.
*/
export async function fetchEntry<TEntry extends PocketBaseEntry>(
id: string,
options: ExperimentalPocketBaseLiveLoaderOptions,
options: PocketBaseLoaderOptions | ExperimentalPocketBaseLiveLoaderOptions,
token: string | undefined
): Promise<TEntry> {
// Build the URL for the entry endpoint
Expand All @@ -31,6 +35,17 @@ export async function fetchEntry<TEntry extends PocketBaseEntry>(
entryUrl.searchParams.set("fields", combinedFields.join(","));
}

// Add expand parameter if specified in experimental options
if (options.experimental && "expand" in options.experimental) {
const expandString = formatExpand(
options.experimental.expand,
options.collectionName
);
if (expandString) {
entryUrl.searchParams.set("expand", expandString);
}
}

// Create the headers for the request to append the token (if available)
const entryHeaders = new Headers();
if (token) {
Expand Down
6 changes: 5 additions & 1 deletion src/schema/generate-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ export async function generateSchema(
// Combine the basic schema with the parsed fields
const schema = z.object({
...BASIC_SCHEMA,
...fields
...fields,
// Add expand field for live types only mode to support expanded relations
...(options.experimental?.liveTypesOnly && {
expand: z.optional(z.unknown())
})
});

// Get all file fields
Expand Down
18 changes: 18 additions & 0 deletions src/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,21 @@ export class PocketBaseAuthenticationError extends LiveCollectionError {
);
}
}

/**
* Error thrown when there is a configuration issue with the loader.
*/
export class PocketBaseConfigurationError extends LiveCollectionError {
constructor(collection: string, message: string) {
super(collection, message);
this.name = "PocketBaseConfigurationError";
}

static is(error: unknown): error is PocketBaseConfigurationError {
// This is similar to the original implementation in Astro itself.
return (
// oxlint-disable-next-line no-unsafe-type-assertion
!!error && (error as Error)?.name === "PocketBaseConfigurationError"
);
}
}
31 changes: 30 additions & 1 deletion src/types/pocketbase-loader-options.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,33 @@ export type PocketBaseLoaderOptions = PocketBaseLoaderBaseOptions & {
* @experimental Live content collections are still experimental
*/
export type ExperimentalPocketBaseLiveLoaderOptions =
PocketBaseLoaderBaseOptions;
PocketBaseLoaderBaseOptions & {
/**
* Experimental options for the live loader.
*
* @experimental All of these options are experimental and may change in the future.
*/
experimental?: {
/**
* Specify relations to auto expand in the API response.
* This can be an array of relation field names to expand.
* Supports dot notation for nested relations up to 6 levels deep.
*
* Note: This option is not compatible with the `fields` option.
*
* Example:
* ```ts
* // Using array format:
* expand: ['author', 'category']
*
* // Nested relations:
* expand: ['author.profile', 'category.parent']
* ```
*
* @see {@link https://pocketbase.io/docs/working-with-relations/#expanding-relations PocketBase documentation} for valid syntax
*
* @experimental This feature is experimental and may change in the future
*/
expand?: Array<string>;
};
};
11 changes: 10 additions & 1 deletion src/types/pocketbase-schema.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ export const pocketBaseSchemaEntry = z.object({
* Whether the field is updated when the entry is updated.
* This is only present on "autodate" fields.
*/
onUpdate: z.optional(z.boolean())
onUpdate: z.optional(z.boolean()),
/**
* Id of the associated collection that the relation is referencing.
* This is only present on "relation" fields.
*/
collectionId: z.optional(z.string())
});

/**
Expand All @@ -67,6 +72,10 @@ export type PocketBaseSchemaEntry = z.infer<typeof pocketBaseSchemaEntry>;
* Schema for a PocketBase collection.
*/
export const pocketBaseCollection = z.object({
/**
* Id of the collection.
*/
id: z.string(),
/**
* Name of the collection.
*/
Expand Down
41 changes: 41 additions & 0 deletions src/utils/format-expand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { PocketBaseConfigurationError } from "../types/errors";

/**
* Maximum nesting depth for expand relations as enforced by PocketBase
*/
const MAX_EXPAND_DEPTH = 6;

/**
* Format and validate expand option for PocketBase API requests.
* Validates nesting depth and returns formatted expand string.
*/
export function formatExpand(
expand: Array<string> | undefined,
collectionName: string
): string | undefined {
if (!expand || expand.length === 0) {
return undefined;
}

// Validate each expand field for maximum nesting depth and invalid characters
for (const field of expand) {
// Check for comma in field name
if (field.includes(",")) {
throw new PocketBaseConfigurationError(
collectionName,
`Expand field "${field}" contains a comma. Use separate array entries instead of comma-separated values.`
);
}

const depth = (field.match(/\./g) || []).length + 1;
if (depth > MAX_EXPAND_DEPTH) {
throw new PocketBaseConfigurationError(
collectionName,
`Expand field "${field}" exceeds maximum nesting depth of ${MAX_EXPAND_DEPTH} levels.`
);
}
}

// Join all expand fields with comma as required by PocketBase
return expand.join(",");
}
4 changes: 2 additions & 2 deletions test/_mocks/delete-collection.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { assert } from "vitest";
import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type";
import type { PocketBaseLoaderBaseOptions } from "../../src/types/pocketbase-loader-options.type";

export async function deleteCollection(
options: PocketBaseLoaderOptions,
options: PocketBaseLoaderBaseOptions,
superuserToken: string
): Promise<void> {
const deleteRequest = await fetch(
Expand Down
6 changes: 3 additions & 3 deletions test/_mocks/delete-entry.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { assert } from "vitest";
import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type";
import type { PocketBaseLoaderBaseOptions } from "../../src/types/pocketbase-loader-options.type";
import { sendBatchRequest } from "./batch-requests";

export async function deleteEntries(
entryIds: Array<string>,
options: PocketBaseLoaderOptions,
options: PocketBaseLoaderBaseOptions,
superuserToken: string
): Promise<void> {
const requests = entryIds.map((entryId) => ({
Expand All @@ -26,7 +26,7 @@ export async function deleteEntries(

export async function deleteEntry(
entryId: string,
options: PocketBaseLoaderOptions,
options: PocketBaseLoaderBaseOptions,
superuserToken: string
): Promise<void> {
const deleteRequest = await fetch(
Expand Down
11 changes: 8 additions & 3 deletions test/_mocks/insert-collection.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { assert } from "console";
import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type";
import type { PocketBaseLoaderBaseOptions } from "../../src/types/pocketbase-loader-options.type";
import type { PocketBaseSchemaEntry } from "../../src/types/pocketbase-schema.type";

export async function insertCollection(
fields: Array<PocketBaseSchemaEntry>,
options: PocketBaseLoaderOptions,
options: PocketBaseLoaderBaseOptions,
superuserToken: string
): Promise<void> {
): Promise<string> {
const insertRequest = await fetch(new URL(`api/collections`, options.url), {
method: "POST",
headers: {
Expand All @@ -20,4 +20,9 @@ export async function insertCollection(
});

assert(insertRequest.status === 200, "Collection is not available.");

const insertResponse = await insertRequest.json();
assert(insertResponse.id, "Collection ID is not available.");

return insertResponse.id;
}
6 changes: 3 additions & 3 deletions test/_mocks/insert-entry.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { assert } from "vitest";
import type { PocketBaseEntry } from "../../src/types/pocketbase-entry.type";
import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type";
import type { PocketBaseLoaderBaseOptions } from "../../src/types/pocketbase-loader-options.type";
import { sendBatchRequest } from "./batch-requests";

export async function insertEntries(
data: Array<Record<string, unknown>>,
options: PocketBaseLoaderOptions,
options: PocketBaseLoaderBaseOptions,
superuserToken: string
): Promise<Array<PocketBaseEntry>> {
const requests = data.map((entry) => ({
Expand Down Expand Up @@ -34,7 +34,7 @@ export async function insertEntries(

export async function insertEntry(
data: Record<string, unknown>,
options: PocketBaseLoaderOptions,
options: PocketBaseLoaderBaseOptions,
superuserToken: string
): Promise<PocketBaseEntry> {
const insertRequest = await fetch(
Expand Down
Loading