Skip to content

Commit 443273d

Browse files
Copilotpawcoding
andcommitted
feat(live-loader): add experimental expand parameter support
Co-authored-by: pawcoding <78467484+pawcoding@users.noreply.github.com>
1 parent b0da17c commit 443273d

File tree

6 files changed

+175
-6
lines changed

6 files changed

+175
-6
lines changed

src/loader/fetch-collection.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ import {
99
} from "../types/pocketbase-api-response.type";
1010
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
1111
import type { ExperimentalPocketBaseLiveLoaderCollectionFilter } from "../types/pocketbase-live-loader-filter.type";
12-
import type { PocketBaseLoaderBaseOptions } from "../types/pocketbase-loader-options.type";
12+
import type {
13+
ExperimentalPocketBaseLiveLoaderOptions,
14+
PocketBaseLoaderOptions
15+
} from "../types/pocketbase-loader-options.type";
1316
import { combineFieldsForRequest } from "../utils/combine-fields-for-request";
17+
import { formatExpand } from "../utils/format-expand";
1418
import { formatFields } from "../utils/format-fields";
1519

1620
/**
@@ -28,7 +32,7 @@ export type CollectionFilter = {
2832
* Fetches entries from a PocketBase collection, optionally filtering by modification date and supporting pagination.
2933
*/
3034
export async function fetchCollection<TEntry extends PocketBaseEntry>(
31-
options: PocketBaseLoaderBaseOptions,
35+
options: PocketBaseLoaderOptions | ExperimentalPocketBaseLiveLoaderOptions,
3236
chunkLoaded: (entries: Array<TEntry>) => Promise<void>,
3337
token: string | undefined,
3438
collectionFilter: CollectionFilter | undefined
@@ -124,7 +128,9 @@ export async function fetchCollection<TEntry extends PocketBaseEntry>(
124128
* Build search parameters for the PocketBase collection request.
125129
*/
126130
function buildSearchParams(
127-
loaderOptions: PocketBaseLoaderBaseOptions,
131+
loaderOptions:
132+
| PocketBaseLoaderOptions
133+
| ExperimentalPocketBaseLiveLoaderOptions,
128134
combinedFields: Array<string> | undefined,
129135
collectionFilter: CollectionFilter
130136
): URLSearchParams {
@@ -173,5 +179,15 @@ function buildSearchParams(
173179
searchParams.set("fields", combinedFields.join(","));
174180
}
175181

182+
if (loaderOptions.experimental && "expand" in loaderOptions.experimental) {
183+
const expandString = formatExpand(
184+
loaderOptions.experimental.expand,
185+
loaderOptions.collectionName
186+
);
187+
if (expandString) {
188+
searchParams.set("expand", expandString);
189+
}
190+
}
191+
176192
return searchParams;
177193
}

src/loader/fetch-entry.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@ import { PocketBaseAuthenticationError } from "../types/errors";
66
import { pocketBaseErrorResponse } from "../types/pocketbase-api-response.type";
77
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
88
import { pocketBaseEntry } from "../types/pocketbase-entry.type";
9-
import type { ExperimentalPocketBaseLiveLoaderOptions } from "../types/pocketbase-loader-options.type";
9+
import type {
10+
ExperimentalPocketBaseLiveLoaderOptions,
11+
PocketBaseLoaderOptions
12+
} from "../types/pocketbase-loader-options.type";
1013
import { combineFieldsForRequest } from "../utils/combine-fields-for-request";
14+
import { formatExpand } from "../utils/format-expand";
1115
import { formatFields } from "../utils/format-fields";
1216

1317
/**
1418
* Retrieves a specific entry from a PocketBase collection using its ID and loader options.
1519
*/
1620
export async function fetchEntry<TEntry extends PocketBaseEntry>(
1721
id: string,
18-
options: ExperimentalPocketBaseLiveLoaderOptions,
22+
options: PocketBaseLoaderOptions | ExperimentalPocketBaseLiveLoaderOptions,
1923
token: string | undefined
2024
): Promise<TEntry> {
2125
// Build the URL for the entry endpoint
@@ -31,6 +35,17 @@ export async function fetchEntry<TEntry extends PocketBaseEntry>(
3135
entryUrl.searchParams.set("fields", combinedFields.join(","));
3236
}
3337

38+
// Add expand parameter if specified in experimental options
39+
if (options.experimental && "expand" in options.experimental) {
40+
const expandString = formatExpand(
41+
options.experimental.expand,
42+
options.collectionName
43+
);
44+
if (expandString) {
45+
entryUrl.searchParams.set("expand", expandString);
46+
}
47+
}
48+
3449
// Create the headers for the request to append the token (if available)
3550
const entryHeaders = new Headers();
3651
if (token) {

src/types/errors.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,21 @@ export class PocketBaseAuthenticationError extends LiveCollectionError {
1717
);
1818
}
1919
}
20+
21+
/**
22+
* Error thrown when there is a configuration issue with the loader.
23+
*/
24+
export class PocketBaseConfigurationError extends LiveCollectionError {
25+
constructor(collection: string, message: string) {
26+
super(collection, message);
27+
this.name = "PocketBaseConfigurationError";
28+
}
29+
30+
static is(error: unknown): error is PocketBaseConfigurationError {
31+
// This is similar to the original implementation in Astro itself.
32+
return (
33+
// oxlint-disable-next-line no-unsafe-type-assertion
34+
!!error && (error as Error)?.name === "PocketBaseConfigurationError"
35+
);
36+
}
37+
}

src/types/pocketbase-loader-options.type.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,33 @@ export type PocketBaseLoaderOptions = PocketBaseLoaderBaseOptions & {
156156
* @experimental Live content collections are still experimental
157157
*/
158158
export type ExperimentalPocketBaseLiveLoaderOptions =
159-
PocketBaseLoaderBaseOptions;
159+
PocketBaseLoaderBaseOptions & {
160+
/**
161+
* Experimental options for the live loader.
162+
*
163+
* @experimental All of these options are experimental and may change in the future.
164+
*/
165+
experimental?: {
166+
/**
167+
* Specify relations to auto expand in the API response.
168+
* This can be an array of relation field names to expand.
169+
* Supports dot notation for nested relations up to 6 levels deep.
170+
*
171+
* Note: This option is not compatible with the `fields` option.
172+
*
173+
* Example:
174+
* ```ts
175+
* // Using array format:
176+
* expand: ['author', 'category']
177+
*
178+
* // Nested relations:
179+
* expand: ['author.profile', 'category.parent']
180+
* ```
181+
*
182+
* @see {@link https://pocketbase.io/docs/collections/#expand-relation-fields PocketBase documentation} for valid syntax
183+
*
184+
* @experimental This feature is experimental and may change in the future
185+
*/
186+
expand?: Array<string>;
187+
};
188+
};

src/utils/format-expand.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { PocketBaseConfigurationError } from "../types/errors";
2+
3+
/**
4+
* Maximum nesting depth for expand relations as enforced by PocketBase
5+
*/
6+
const MAX_EXPAND_DEPTH = 6;
7+
8+
/**
9+
* Format and validate expand option for PocketBase API requests.
10+
* Validates nesting depth and returns formatted expand string.
11+
*/
12+
export function formatExpand(
13+
expand: Array<string> | undefined,
14+
collectionName: string
15+
): string | undefined {
16+
if (!expand || expand.length === 0) {
17+
return undefined;
18+
}
19+
20+
// Validate each expand field for maximum nesting depth and invalid characters
21+
for (const field of expand) {
22+
// Check for comma in field name
23+
if (field.includes(",")) {
24+
throw new PocketBaseConfigurationError(
25+
collectionName,
26+
`Expand field "${field}" contains a comma. Use separate array entries instead of comma-separated values.`
27+
);
28+
}
29+
30+
const depth = (field.match(/\./g) || []).length + 1;
31+
if (depth > MAX_EXPAND_DEPTH) {
32+
throw new PocketBaseConfigurationError(
33+
collectionName,
34+
`Expand field "${field}" exceeds maximum nesting depth of ${MAX_EXPAND_DEPTH} levels.`
35+
);
36+
}
37+
}
38+
39+
// Join all expand fields with comma as required by PocketBase
40+
return expand.join(",");
41+
}

test/utils/format-expand.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, test } from "vitest";
2+
import { PocketBaseConfigurationError } from "../../src/types/errors";
3+
import { formatExpand } from "../../src/utils/format-expand";
4+
5+
describe("formatExpand", () => {
6+
test("should return undefined when expand is undefined", () => {
7+
const result = formatExpand(undefined, "");
8+
expect(result).toBeUndefined();
9+
});
10+
11+
test("should return undefined when expand is empty array", () => {
12+
const result = formatExpand([], "");
13+
expect(result).toBeUndefined();
14+
});
15+
16+
test("should format single expand field", () => {
17+
const result = formatExpand(["author"], "");
18+
expect(result).toBe("author");
19+
});
20+
21+
test("should format multiple expand fields with comma", () => {
22+
const result = formatExpand(["author", "category"], "");
23+
expect(result).toBe("author,category");
24+
});
25+
26+
test("should handle nested expand fields", () => {
27+
const result = formatExpand(["author.profile", "category.parent"], "");
28+
expect(result).toBe("author.profile,category.parent");
29+
});
30+
31+
test("should handle deeply nested expand fields up to 6 levels", () => {
32+
const result = formatExpand(
33+
["level1.level2.level3.level4.level5.level6"],
34+
""
35+
);
36+
expect(result).toBe("level1.level2.level3.level4.level5.level6");
37+
});
38+
39+
test("should throw error when expand exceeds 6 levels", () => {
40+
expect(() =>
41+
formatExpand(["level1.level2.level3.level4.level5.level6.level7"], "")
42+
).toThrow(PocketBaseConfigurationError);
43+
});
44+
45+
test("should throw error when field contains comma", () => {
46+
expect(() => formatExpand(["author,category"], "")).toThrow(
47+
PocketBaseConfigurationError
48+
);
49+
});
50+
});

0 commit comments

Comments
 (0)