@btst/stack 2.12.0 → 2.12.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.
Files changed (46) hide show
  1. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +14 -11
  2. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +14 -11
  3. package/dist/packages/stack/src/plugins/cms/client/components/forms/relation-field.cjs +87 -15
  4. package/dist/packages/stack/src/plugins/cms/client/components/forms/relation-field.mjs +87 -15
  5. package/dist/packages/stack/src/plugins/cms/schemas.cjs +11 -6
  6. package/dist/packages/stack/src/plugins/cms/schemas.mjs +10 -6
  7. package/dist/packages/ui/src/components/auto-form/fields/array.cjs +181 -4
  8. package/dist/packages/ui/src/components/auto-form/fields/array.mjs +184 -7
  9. package/dist/packages/ui/src/components/auto-form/index.cjs +16 -1
  10. package/dist/packages/ui/src/components/auto-form/index.mjs +17 -2
  11. package/dist/plugins/cms/api/index.cjs +2 -0
  12. package/dist/plugins/cms/api/index.d.cts +3 -2
  13. package/dist/plugins/cms/api/index.d.mts +3 -2
  14. package/dist/plugins/cms/api/index.d.ts +3 -2
  15. package/dist/plugins/cms/api/index.mjs +1 -0
  16. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  17. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  18. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  19. package/dist/plugins/cms/query-keys.d.cts +398 -7
  20. package/dist/plugins/cms/query-keys.d.mts +398 -7
  21. package/dist/plugins/cms/query-keys.d.ts +398 -7
  22. package/dist/plugins/kanban/api/index.d.cts +1 -1
  23. package/dist/plugins/kanban/api/index.d.mts +1 -1
  24. package/dist/plugins/kanban/api/index.d.ts +1 -1
  25. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  26. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  27. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  28. package/dist/plugins/ui-builder/index.d.cts +1 -1
  29. package/dist/plugins/ui-builder/index.d.mts +1 -1
  30. package/dist/plugins/ui-builder/index.d.ts +1 -1
  31. package/dist/shared/{stack.B8QD11QU.d.cts → stack.DFtpFS00.d.cts} +10 -0
  32. package/dist/shared/{stack.B8QD11QU.d.mts → stack.DFtpFS00.d.mts} +10 -0
  33. package/dist/shared/{stack.B8QD11QU.d.ts → stack.DFtpFS00.d.ts} +10 -0
  34. package/package.json +1 -1
  35. package/src/plugins/cms/__tests__/schema-roundtrip.test.ts +203 -0
  36. package/src/plugins/cms/api/index.ts +1 -0
  37. package/src/plugins/cms/api/plugin.ts +33 -16
  38. package/src/plugins/cms/client/components/forms/relation-field.tsx +121 -36
  39. package/src/plugins/cms/schemas.ts +26 -6
  40. package/src/plugins/cms/types.ts +10 -0
  41. package/dist/shared/stack.DbnCLdNF.d.mts +0 -399
  42. package/dist/shared/stack.fXlShnhv.d.ts +0 -399
  43. package/dist/shared/stack.r6HohbyX.d.cts +0 -399
  44. package/dist/shared/{stack.B6S3cgwN.d.cts → stack.CuI_-z-_.d.cts} +16 -16
  45. package/dist/shared/{stack.Bzfx-_lq.d.mts → stack.D2RVkQD8.d.mts} +16 -16
  46. package/dist/shared/{stack.j5SFLC1d.d.ts → stack.DXrOtuek.d.ts} +16 -16
@@ -804,3 +804,206 @@ describe("Zod to JSON Schema roundtrip", () => {
804
804
  });
805
805
  });
806
806
  });
807
+
808
+ /**
809
+ * These tests confirm and protect against a bug where `useFieldArray` in
810
+ * AutoFormArray corrupts primitive string values in arrays like
811
+ * `structuredContraindications: z.array(z.string()).default([])`.
812
+ *
813
+ * When react-hook-form's `useFieldArray` processes a primitive array
814
+ * (e.g. `["pregnancy", "active malignancy"]`), it wraps each element as a
815
+ * tracking object `{ id: "rhf_generated_id" }`, discarding the original
816
+ * string value. When `form.watch()` fires, it returns these objects. The
817
+ * `handleValuesChange` callback then calls `setFormData(values)` which stores
818
+ * the corrupted objects. On form submit, `zodResolver` validates the corrupted
819
+ * values against `z.array(z.string())` and FAILS — causing the Save button
820
+ * to do nothing (no error shown, no API request made).
821
+ *
822
+ * The fix: AutoFormArray must NOT use `useFieldArray` for primitive (non-object)
823
+ * arrays. Instead it uses `form.watch` + `form.setValue` directly so primitive
824
+ * values are always preserved in the form state.
825
+ */
826
+ describe("Primitive string array — useFieldArray corruption bug", () => {
827
+ /**
828
+ * Simulates what zodToFormSchema + formSchemaToZod does:
829
+ * Zod schema → JSON Schema (stored in DB) → reconstructed Zod schema.
830
+ */
831
+ function roundtripSchema(schema: z.ZodType): z.ZodType {
832
+ const jsonSchema = z.toJSONSchema(schema, { unrepresentable: "any" });
833
+ return z.fromJSONSchema(jsonSchema as z.core.JSONSchema.JSONSchema);
834
+ }
835
+
836
+ it("z.array(z.string()) survives JSON Schema roundtrip and accepts string values", () => {
837
+ const schema = z.object({
838
+ structuredContraindications: z.array(z.string()).default([]),
839
+ });
840
+
841
+ const reconstructed = roundtripSchema(schema);
842
+
843
+ // Actual compound data — should PASS
844
+ expect(
845
+ reconstructed.safeParse({
846
+ structuredContraindications: [
847
+ "pregnancy",
848
+ "active malignancy",
849
+ "active cancer",
850
+ "trying to conceive",
851
+ ],
852
+ }).success,
853
+ ).toBe(true);
854
+
855
+ // Empty array — should PASS (default)
856
+ expect(
857
+ reconstructed.safeParse({ structuredContraindications: [] }).success,
858
+ ).toBe(true);
859
+ });
860
+
861
+ it("useFieldArray-corrupted values (objects) fail z.array(z.string()) validation — this is the root cause of the silent save failure", () => {
862
+ const schema = z.object({
863
+ structuredContraindications: z.array(z.string()).default([]),
864
+ });
865
+
866
+ const reconstructed = roundtripSchema(schema);
867
+
868
+ // Simulates what react-hook-form's useFieldArray returns when used with
869
+ // primitive string arrays: each string is replaced by a tracking object
870
+ // { id: "rhf_generated_id" } and the original value is lost.
871
+ const corruptedByUseFieldArray = {
872
+ structuredContraindications: [
873
+ { id: "rhf_internal_id_1" },
874
+ { id: "rhf_internal_id_2" },
875
+ { id: "rhf_internal_id_3" },
876
+ { id: "rhf_internal_id_4" },
877
+ ],
878
+ };
879
+
880
+ // This is the actual validation error that occurs when Save is clicked:
881
+ // zodResolver validates the corrupted objects against z.array(z.string())
882
+ // and FAILS, so onSubmit is never called → no API request → "nothing happens"
883
+ const result = reconstructed.safeParse(corruptedByUseFieldArray);
884
+ expect(result.success).toBe(false);
885
+ if (!result.success) {
886
+ // Confirm the error is specifically about the string array elements
887
+ const paths = result.error.issues.map((i) => i.path.join("."));
888
+ expect(
889
+ paths.some((p) => p.startsWith("structuredContraindications")),
890
+ ).toBe(true);
891
+ }
892
+ });
893
+
894
+ it("primitive string array values are preserved correctly (NOT corrupted) when form state is managed without useFieldArray", () => {
895
+ // After the fix, AutoFormArray uses form.watch + form.setValue for primitive
896
+ // arrays. The values remain as strings throughout the lifecycle:
897
+ // initialData → formData → form state → zodResolver → submit
898
+
899
+ const schema = z.object({
900
+ structuredContraindications: z.array(z.string()).default([]),
901
+ });
902
+
903
+ const reconstructed = roundtripSchema(schema);
904
+
905
+ // The correctly-preserved values (no useFieldArray wrapping)
906
+ const preservedValues = {
907
+ structuredContraindications: [
908
+ "pregnancy",
909
+ "active malignancy",
910
+ "active cancer",
911
+ "trying to conceive",
912
+ ],
913
+ };
914
+
915
+ // With the fix applied, these values pass validation → Save works
916
+ expect(reconstructed.safeParse(preservedValues).success).toBe(true);
917
+ });
918
+
919
+ it("compound schema with structuredContraindications passes full validation with string array values", () => {
920
+ // Representative subset of CompoundSchema fields that appear on the
921
+ // Epitalon compound page — verifies the full schema round-trip for the
922
+ // fields that are relevant to the reported bug.
923
+ const compoundSchema = z.object({
924
+ name: z.string().min(1),
925
+ compoundType: z.enum([
926
+ "healing-peptide",
927
+ "gh-axis",
928
+ "metabolic-peptide",
929
+ "sarm",
930
+ "steroid",
931
+ "nootropic",
932
+ "supplement",
933
+ "ancillary-pct",
934
+ "longevity",
935
+ "hair",
936
+ "skin",
937
+ "sexual",
938
+ "other",
939
+ ]),
940
+ researchStatus: z.enum([
941
+ "research-only",
942
+ "approved",
943
+ "banned",
944
+ "grey-market",
945
+ "supplement",
946
+ ]),
947
+ legalStatus: z.enum([
948
+ "OTC",
949
+ "Research",
950
+ "Grey-Market",
951
+ "Rx-Only",
952
+ "Schedule-III",
953
+ "Banned",
954
+ ]),
955
+ doseUnit: z.enum(["mcg", "mg", "IU", "ml", "g"]),
956
+ doseFrequency: z.enum([
957
+ "once-daily",
958
+ "twice-daily",
959
+ "three-times-daily",
960
+ "every-other-day",
961
+ "weekly",
962
+ "twice-weekly",
963
+ "three-times-weekly",
964
+ "as-needed",
965
+ "custom",
966
+ ]),
967
+ structuredContraindications: z.array(z.string()).default([]),
968
+ affiliates: z
969
+ .array(
970
+ z.object({
971
+ partnerId: z.object({ id: z.string() }).optional(),
972
+ title: z.string().optional(),
973
+ url: z.string().min(1),
974
+ }),
975
+ )
976
+ .default([]),
977
+ });
978
+
979
+ const reconstructed = roundtripSchema(compoundSchema);
980
+
981
+ // Epitalon-like data — all values as they come from parsedData
982
+ const epitalon = {
983
+ name: "Epitalon",
984
+ compoundType: "longevity",
985
+ researchStatus: "research-only",
986
+ legalStatus: "Research",
987
+ doseUnit: "mg",
988
+ doseFrequency: "once-daily",
989
+ // The 4 string items that were causing the silent save failure
990
+ structuredContraindications: [
991
+ "pregnancy",
992
+ "active malignancy",
993
+ "active cancer",
994
+ "trying to conceive",
995
+ ],
996
+ affiliates: [
997
+ {
998
+ partnerId: { id: "v6yAqOSO_example_id" },
999
+ title: "Buy Epitalon 10mg",
1000
+ url: "https://swisschems.is/product/epitalon-10mg-price-is-per-vial/",
1001
+ },
1002
+ ],
1003
+ };
1004
+
1005
+ const result = reconstructed.safeParse(epitalon);
1006
+ // After the fix, this should PASS (the save button works)
1007
+ expect(result.success).toBe(true);
1008
+ });
1009
+ });
@@ -18,3 +18,4 @@ export {
18
18
  type CreateCMSContentItemOptions,
19
19
  } from "./mutations";
20
20
  export { CMS_QUERY_KEYS } from "./query-key-defs";
21
+ export { createCMSQueryKeys } from "../query-keys";
@@ -18,7 +18,10 @@ import type {
18
18
  RelationValue,
19
19
  InverseRelation,
20
20
  } from "../types";
21
- import { listContentQuerySchema } from "../schemas";
21
+ import {
22
+ createListContentQuerySchema,
23
+ DEFAULT_MAX_PAGE_SIZE,
24
+ } from "../schemas";
22
25
  import { slugify } from "../utils";
23
26
  import {
24
27
  getAllContentTypes,
@@ -492,6 +495,20 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
492
495
  }),
493
496
 
494
497
  routes: (adapter: Adapter) => {
498
+ // Build pagination schemas once — honours config.maxPageSize
499
+ const listContentQuerySchema = createListContentQuerySchema(
500
+ config.maxPageSize,
501
+ );
502
+ const paginationQuerySchema = z.object({
503
+ limit: z.coerce
504
+ .number()
505
+ .min(1)
506
+ .max(config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE)
507
+ .optional()
508
+ .default(20),
509
+ offset: z.coerce.number().min(0).optional().default(0),
510
+ });
511
+
495
512
  // Helper to get content type by slug
496
513
  const getContentType = async (
497
514
  slug: string,
@@ -525,10 +542,10 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
525
542
  sortBy: { field: "name", direction: "asc" },
526
543
  });
527
544
 
528
- // Get item counts for each content type
545
+ // Get item counts for each content type via adapter.count() (avoids N+1 scan)
529
546
  const typesWithCounts = await Promise.all(
530
547
  contentTypes.map(async (ct) => {
531
- const items = await adapter.findMany<ContentItem>({
548
+ const itemCount: number = await adapter.count({
532
549
  model: "contentItem",
533
550
  where: [
534
551
  {
@@ -540,7 +557,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
540
557
  });
541
558
  return {
542
559
  ...serializeContentType(ct),
543
- itemCount: items.length,
560
+ itemCount,
544
561
  };
545
562
  }),
546
563
  );
@@ -964,12 +981,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
964
981
  {
965
982
  method: "GET",
966
983
  params: z.object({ typeSlug: z.string() }),
967
- query: z.object({
968
- field: z.string(),
969
- targetId: z.string(),
970
- limit: z.coerce.number().min(1).max(100).optional().default(20),
971
- offset: z.coerce.number().min(0).optional().default(0),
972
- }),
984
+ query: z
985
+ .object({
986
+ field: z.string(),
987
+ targetId: z.string(),
988
+ })
989
+ .merge(paginationQuerySchema),
973
990
  },
974
991
  async (ctx) => {
975
992
  const { typeSlug } = ctx.params;
@@ -1144,12 +1161,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
1144
1161
  slug: z.string(),
1145
1162
  sourceType: z.string(),
1146
1163
  }),
1147
- query: z.object({
1148
- itemId: z.string(),
1149
- fieldName: z.string(),
1150
- limit: z.coerce.number().min(1).max(100).optional().default(20),
1151
- offset: z.coerce.number().min(0).optional().default(0),
1152
- }),
1164
+ query: z
1165
+ .object({
1166
+ itemId: z.string(),
1167
+ fieldName: z.string(),
1168
+ })
1169
+ .merge(paginationQuerySchema),
1153
1170
  },
1154
1171
  async (ctx) => {
1155
1172
  const { slug, sourceType } = ctx.params;
@@ -1,7 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useCallback, useMemo } from "react";
4
+ import { useQueries } from "@tanstack/react-query";
5
+ import { createApiClient } from "@btst/stack/plugins/client";
6
+ import { usePluginOverrides } from "@btst/stack/context";
4
7
  import { useContent, useCreateContent } from "../../hooks";
8
+ import type { CMSApiRouter } from "../../../api";
9
+ import type { SerializedContentItemWithType } from "../../../types";
10
+ import type { CMSPluginOverrides } from "../../overrides";
11
+ import { createCMSQueryKeys } from "../../../query-keys";
5
12
  import MultipleSelector from "@workspace/ui/components/multi-select";
6
13
  import type { Option } from "@workspace/ui/components/multi-select";
7
14
  import { Button } from "@workspace/ui/components/button";
@@ -19,6 +26,16 @@ import { Textarea } from "@workspace/ui/components/textarea";
19
26
  import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
20
27
  import type { RelationConfig } from "../../../types";
21
28
 
29
+ /** Match cms-hooks SHARED_QUERY_CONFIG for detail fetches (deduped labels). */
30
+ const RELATION_DETAIL_QUERY_OPTS = {
31
+ retry: false,
32
+ refetchOnWindowFocus: false,
33
+ refetchOnMount: false,
34
+ refetchOnReconnect: false,
35
+ staleTime: 1000 * 60 * 5,
36
+ gcTime: 1000 * 60 * 10,
37
+ } as const;
38
+
22
39
  interface RelationFieldProps extends AutoFormInputComponentProps {
23
40
  relation: RelationConfig;
24
41
  }
@@ -43,16 +60,25 @@ export function RelationField({
43
60
  const [newItemDescription, setNewItemDescription] = useState("");
44
61
  const [createError, setCreateError] = useState<string | null>(null);
45
62
 
46
- // For belongsTo (single relation), we only allow one selection
47
- const isSingleSelect = relation.type === "belongsTo";
63
+ const { apiBaseURL, apiBasePath, headers } =
64
+ usePluginOverrides<CMSPluginOverrides>("cms");
48
65
 
49
- // Fetch available items from the target content type
50
- const { items: availableItems, isLoading } = useContent(relation.targetType, {
51
- limit: 100, // Load a good chunk for the dropdown
52
- });
66
+ const listClient = useMemo(
67
+ () =>
68
+ createApiClient<CMSApiRouter>({
69
+ baseURL: apiBaseURL,
70
+ basePath: apiBasePath,
71
+ }),
72
+ [apiBaseURL, apiBasePath],
73
+ );
53
74
 
54
- // Mutation for creating new items
55
- const createMutation = useCreateContent(relation.targetType);
75
+ const cmsQueries = useMemo(
76
+ () => createCMSQueryKeys(listClient, headers),
77
+ [listClient, headers],
78
+ );
79
+
80
+ // For belongsTo (single relation), we only allow one selection
81
+ const isSingleSelect = relation.type === "belongsTo";
56
82
 
57
83
  // Normalize the field value to an array for internal use
58
84
  // belongsTo stores as single object { id }, hasMany/manyToMany store as array
@@ -72,36 +98,95 @@ export function RelationField({
72
98
  return (field.value as Array<{ id: string }>) || [];
73
99
  }, [field.value, isSingleSelect]);
74
100
 
75
- // Convert normalized value to Option[] for MultipleSelector
76
- const selectedOptions: Option[] = normalizedValue
77
- .map((v) => {
78
- const item = availableItems.find((item) => item.id === v.id);
79
- if (item) {
80
- const displayValue =
81
- (item.parsedData as Record<string, unknown>)?.[
82
- relation.displayField
83
- ] || item.slug;
84
- return {
85
- value: item.id,
86
- label: String(displayValue),
87
- };
101
+ // Fetch available items from the target content type (first page only)
102
+ const { items: availableItems, isLoading } = useContent(relation.targetType, {
103
+ limit: 500,
104
+ });
105
+
106
+ const missingDetailIds = useMemo(() => {
107
+ const loadedIds = new Set(availableItems.map((i) => i.id));
108
+ return normalizedValue
109
+ .map((v) => v.id)
110
+ .filter((id) => id.length > 0 && !loadedIds.has(id));
111
+ }, [availableItems, normalizedValue]);
112
+
113
+ const hydrationResult = useQueries({
114
+ queries: missingDetailIds.map((id) => ({
115
+ ...cmsQueries.cmsContent.detail(relation.targetType, id),
116
+ ...RELATION_DETAIL_QUERY_OPTS,
117
+ enabled: Boolean(relation.targetType && id),
118
+ })),
119
+ combine: (results) => ({
120
+ data: results.map(
121
+ (r) => r.data as SerializedContentItemWithType | null | undefined,
122
+ ),
123
+ isHydrating: results.some((r) => r.isFetching),
124
+ }),
125
+ });
126
+
127
+ const isHydratingLabels = hydrationResult.isHydrating;
128
+
129
+ const itemById = useMemo(() => {
130
+ const m = new Map<string, SerializedContentItemWithType>();
131
+ for (const it of availableItems) {
132
+ m.set(it.id, it as SerializedContentItemWithType);
133
+ }
134
+ for (let i = 0; i < missingDetailIds.length; i++) {
135
+ const row = hydrationResult.data[i];
136
+ if (row?.id) {
137
+ m.set(row.id, row);
88
138
  }
89
- // Item not found in loaded items - show ID
90
- return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
91
- })
92
- .filter(Boolean);
139
+ }
140
+ return m;
141
+ }, [availableItems, missingDetailIds, hydrationResult.data]);
93
142
 
94
- // Convert available items to options
95
- const options: Option[] = availableItems.map((item) => {
96
- const displayValue =
97
- (item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
98
- item.slug;
99
- return {
100
- value: item.id,
101
- label: String(displayValue),
102
- };
143
+ // Convert normalized value to Option[] for MultipleSelector
144
+ const selectedOptions: Option[] = normalizedValue.map((v) => {
145
+ const item = itemById.get(v.id);
146
+ if (item) {
147
+ const displayValue =
148
+ (item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
149
+ item.slug;
150
+ return {
151
+ value: item.id,
152
+ label: String(displayValue),
153
+ };
154
+ }
155
+ return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
103
156
  });
104
157
 
158
+ // Listed options + any selected partners loaded by id (not on first list page)
159
+ const options: Option[] = useMemo(() => {
160
+ const merged: SerializedContentItemWithType[] = [
161
+ ...(availableItems as SerializedContentItemWithType[]),
162
+ ];
163
+ const seen = new Set(merged.map((x) => x.id));
164
+ for (let i = 0; i < missingDetailIds.length; i++) {
165
+ const row = hydrationResult.data[i];
166
+ if (row?.id && !seen.has(row.id)) {
167
+ merged.push(row);
168
+ seen.add(row.id);
169
+ }
170
+ }
171
+ return merged.map((item) => {
172
+ const displayValue =
173
+ (item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
174
+ item.slug;
175
+ return {
176
+ value: item.id,
177
+ label: String(displayValue),
178
+ };
179
+ });
180
+ }, [
181
+ availableItems,
182
+ hydrationResult.data,
183
+ missingDetailIds,
184
+ relation.displayField,
185
+ ]);
186
+
187
+ // Mutation for creating new items
188
+ const createMutation = useCreateContent(relation.targetType);
189
+
105
190
  // Handle selection change - convert back to appropriate format
106
191
  const handleChange = useCallback(
107
192
  (newOptions: Option[]) => {
@@ -187,11 +272,11 @@ export function RelationField({
187
272
  onChange={handleChange}
188
273
  options={options}
189
274
  placeholder={
190
- isLoading
275
+ isLoading || isHydratingLabels
191
276
  ? "Loading..."
192
277
  : `Select ${relation.targetType}${isSingleSelect ? "" : "(s)"}...`
193
278
  }
194
- disabled={isLoading}
279
+ disabled={isLoading || isHydratingLabels}
195
280
  hidePlaceholderWhenSelected
196
281
  emptyIndicator={
197
282
  <p className="text-center text-sm text-muted-foreground py-4">
@@ -1,13 +1,33 @@
1
1
  import { z } from "zod";
2
2
 
3
+ /** Default upper bound for a single page when no maxPageSize is configured. */
4
+ export const DEFAULT_MAX_PAGE_SIZE = 1000;
5
+
3
6
  /**
4
- * Schema for listing content items with pagination
7
+ * Factory that creates the list-content query schema with a configurable
8
+ * upper bound on the `limit` parameter.
9
+ *
10
+ * Use this inside the backend plugin factory (where `config.maxPageSize` is
11
+ * available) so the cap is set at registration time rather than hardcoded.
5
12
  */
6
- export const listContentQuerySchema = z.object({
7
- slug: z.string().optional(),
8
- limit: z.coerce.number().min(1).max(100).optional().default(20),
9
- offset: z.coerce.number().min(0).optional().default(0),
10
- });
13
+ export function createListContentQuerySchema(
14
+ maxPageSize = DEFAULT_MAX_PAGE_SIZE,
15
+ ) {
16
+ return z.object({
17
+ slug: z.string().optional(),
18
+ limit: z.coerce.number().min(1).max(maxPageSize).optional().default(20),
19
+ offset: z.coerce.number().min(0).optional().default(0),
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Schema for listing content items with pagination.
25
+ * Uses the default maxPageSize (1000).
26
+ *
27
+ * @deprecated Prefer {@link createListContentQuerySchema} inside plugin
28
+ * factories so consumers can configure the upper bound via `maxPageSize`.
29
+ */
30
+ export const listContentQuerySchema = createListContentQuerySchema();
11
31
 
12
32
  /**
13
33
  * Schema for creating a content item
@@ -303,4 +303,14 @@ export interface CMSBackendConfig {
303
303
  contentTypes: ContentTypeConfig[];
304
304
  /** Optional hooks for customizing behavior */
305
305
  hooks?: CMSBackendHooks;
306
+ /**
307
+ * Maximum number of items that can be requested in a single page.
308
+ * Applied to all list endpoints (`/content/:typeSlug`, by-relation, and inverse-relation).
309
+ *
310
+ * Raise this when your content types have many items and you need to fetch
311
+ * large pages (e.g. for SSG, CSV export, or programmatic access).
312
+ *
313
+ * @default 1000
314
+ */
315
+ maxPageSize?: number;
306
316
  }