@btst/stack 5.0.0 → 5.1.0

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 (113) hide show
  1. package/dist/api/index.d.cts +2 -2
  2. package/dist/api/index.d.mts +2 -2
  3. package/dist/api/index.d.ts +2 -2
  4. package/dist/client/index.d.cts +2 -2
  5. package/dist/client/index.d.mts +2 -2
  6. package/dist/client/index.d.ts +2 -2
  7. package/dist/context/index.d.cts +1 -1
  8. package/dist/context/index.d.mts +1 -1
  9. package/dist/context/index.d.ts +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/packages/stack/src/plugins/cms/api/getters.cjs +142 -6
  14. package/dist/packages/stack/src/plugins/cms/api/getters.mjs +139 -8
  15. package/dist/packages/stack/src/plugins/cms/api/operations.cjs +67 -61
  16. package/dist/packages/stack/src/plugins/cms/api/operations.mjs +69 -63
  17. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +26 -8
  18. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +27 -9
  19. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.cjs +15 -1
  20. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.mjs +14 -2
  21. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.cjs +45 -24
  22. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.mjs +45 -24
  23. package/dist/packages/stack/src/plugins/cms/schemas.cjs +22 -0
  24. package/dist/packages/stack/src/plugins/cms/schemas.mjs +20 -1
  25. package/dist/plugins/api/index.d.cts +2 -2
  26. package/dist/plugins/api/index.d.mts +2 -2
  27. package/dist/plugins/api/index.d.ts +2 -2
  28. package/dist/plugins/blog/api/index.d.cts +2 -2
  29. package/dist/plugins/blog/api/index.d.mts +2 -2
  30. package/dist/plugins/blog/api/index.d.ts +2 -2
  31. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  32. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  33. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  34. package/dist/plugins/blog/client/index.d.cts +2 -2
  35. package/dist/plugins/blog/client/index.d.mts +2 -2
  36. package/dist/plugins/blog/client/index.d.ts +2 -2
  37. package/dist/plugins/blog/query-keys.d.cts +2 -2
  38. package/dist/plugins/blog/query-keys.d.mts +2 -2
  39. package/dist/plugins/blog/query-keys.d.ts +2 -2
  40. package/dist/plugins/client/index.d.cts +2 -2
  41. package/dist/plugins/client/index.d.mts +2 -2
  42. package/dist/plugins/client/index.d.ts +2 -2
  43. package/dist/plugins/cms/api/index.cjs +1 -0
  44. package/dist/plugins/cms/api/index.d.cts +43 -11
  45. package/dist/plugins/cms/api/index.d.mts +43 -11
  46. package/dist/plugins/cms/api/index.d.ts +43 -11
  47. package/dist/plugins/cms/api/index.mjs +1 -1
  48. package/dist/plugins/cms/client/hooks/index.d.cts +90 -45
  49. package/dist/plugins/cms/client/hooks/index.d.mts +90 -45
  50. package/dist/plugins/cms/client/hooks/index.d.ts +90 -45
  51. package/dist/plugins/cms/client/index.d.cts +2 -1
  52. package/dist/plugins/cms/client/index.d.mts +2 -1
  53. package/dist/plugins/cms/client/index.d.ts +2 -1
  54. package/dist/plugins/cms/query-keys.cjs +69 -13
  55. package/dist/plugins/cms/query-keys.d.cts +89 -212
  56. package/dist/plugins/cms/query-keys.d.mts +89 -212
  57. package/dist/plugins/cms/query-keys.d.ts +89 -212
  58. package/dist/plugins/cms/query-keys.mjs +70 -14
  59. package/dist/plugins/kanban/client/index.d.cts +1 -1
  60. package/dist/plugins/kanban/client/index.d.mts +1 -1
  61. package/dist/plugins/kanban/client/index.d.ts +1 -1
  62. package/dist/plugins/route-docs/client/index.d.cts +1 -1
  63. package/dist/plugins/route-docs/client/index.d.mts +1 -1
  64. package/dist/plugins/route-docs/client/index.d.ts +1 -1
  65. package/dist/plugins/ui-builder/client/hooks/index.d.cts +3 -3
  66. package/dist/plugins/ui-builder/client/hooks/index.d.mts +3 -3
  67. package/dist/plugins/ui-builder/client/hooks/index.d.ts +3 -3
  68. package/dist/plugins/ui-builder/client/index.d.cts +3 -3
  69. package/dist/plugins/ui-builder/client/index.d.mts +3 -3
  70. package/dist/plugins/ui-builder/client/index.d.ts +3 -3
  71. package/dist/plugins/ui-builder/query-keys.d.cts +4 -3
  72. package/dist/plugins/ui-builder/query-keys.d.mts +4 -3
  73. package/dist/plugins/ui-builder/query-keys.d.ts +4 -3
  74. package/dist/shared/{stack.DMj68qY0.d.mts → stack.B2jlVVhD.d.mts} +5 -5
  75. package/dist/shared/{stack.Cvnj99cs.d.ts → stack.BB9tubJJ.d.ts} +9 -3
  76. package/dist/shared/{stack.Bt3WG2yb.d.cts → stack.BWduIMKG.d.cts} +35 -35
  77. package/dist/shared/stack.Bw09Lg2z.d.ts +1420 -0
  78. package/dist/shared/{stack.CkEz7vSt.d.mts → stack.C2Pvl-RS.d.mts} +9 -3
  79. package/dist/shared/{stack.C2l2wd_X.d.ts → stack.Ch9-ppmQ.d.ts} +35 -35
  80. package/dist/shared/{stack.Ht8mhDj5.d.cts → stack.D17yFglU.d.cts} +5 -5
  81. package/dist/shared/{stack.CiD115Ov.d.mts → stack.D6fPBfYe.d.mts} +35 -35
  82. package/dist/shared/stack.DFGj0gb7.d.mts +1420 -0
  83. package/dist/shared/{stack.pi0FxoEa.d.ts → stack.Dcfg4GNN.d.ts} +5 -5
  84. package/dist/shared/{stack.3rxARhgS.d.cts → stack.DrB3L7Ih.d.cts} +52 -3
  85. package/dist/shared/{stack.3rxARhgS.d.mts → stack.DrB3L7Ih.d.mts} +52 -3
  86. package/dist/shared/{stack.3rxARhgS.d.ts → stack.DrB3L7Ih.d.ts} +52 -3
  87. package/dist/shared/stack.NNrBKBkM.d.cts +1420 -0
  88. package/dist/shared/{stack.mRRyZBUg.d.cts → stack.Rb87lHqK.d.cts} +9 -3
  89. package/package.json +1 -1
  90. package/src/__tests__/cms-query-keys.test.ts +57 -0
  91. package/src/plugins/api/index.ts +1 -0
  92. package/src/plugins/api/operation.ts +16 -3
  93. package/src/plugins/cms/__tests__/authorization.test.ts +652 -187
  94. package/src/plugins/cms/__tests__/client-runtime.browser.test.tsx +655 -0
  95. package/src/plugins/cms/__tests__/content-read.typecheck.ts +351 -0
  96. package/src/plugins/cms/__tests__/getters.test.ts +286 -0
  97. package/src/plugins/cms/api/getters.ts +270 -12
  98. package/src/plugins/cms/api/index.ts +10 -0
  99. package/src/plugins/cms/api/operations.ts +163 -75
  100. package/src/plugins/cms/api/plugin.ts +50 -12
  101. package/src/plugins/cms/api/query-key-defs.ts +29 -2
  102. package/src/plugins/cms/client/hooks/cms-hooks.tsx +400 -91
  103. package/src/plugins/cms/client/index.ts +9 -0
  104. package/src/plugins/cms/query-keys.ts +171 -16
  105. package/src/plugins/cms/schemas.ts +30 -0
  106. package/src/plugins/cms/types.ts +49 -2
  107. package/src/plugins/media/__tests__/identity-partition.test.tsx +26 -16
  108. package/dist/shared/stack.CAmVUwjV.d.cts +0 -764
  109. package/dist/shared/stack.CQK02ujt.d.ts +0 -764
  110. package/dist/shared/stack.D9x1fR5a.d.mts +0 -764
  111. package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
  112. package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
  113. package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
@@ -1,6 +1,15 @@
1
1
  export { cmsClientPlugin } from "./plugin";
2
2
  export type { CMSClientConfig, CMSClientHooks, LoaderContext } from "./plugin";
3
3
  export type { ContentTypeConfig } from "../types";
4
+ export type {
5
+ CMSReadOptions,
6
+ CMSContentFields,
7
+ CMSSelectedData,
8
+ CMSReadView,
9
+ SerializedContentItemContent,
10
+ CMSContentItemResult,
11
+ PaginatedContentItems,
12
+ } from "../types";
4
13
  export type {
5
14
  CMSPluginOverrides,
6
15
  CMSProviderConfig,
@@ -1,4 +1,5 @@
1
1
  import type { CMSApiRouter } from "./api";
2
+ import { normalizeContentFields } from "./schemas";
2
3
  import {
3
4
  createApiClient,
4
5
  createResourceQueryKeys,
@@ -9,11 +10,19 @@ import type {
9
10
  SerializedContentItemWithType,
10
11
  PaginatedContentItems,
11
12
  InverseRelation,
13
+ CMSReadOptions,
14
+ CMSContentFields,
15
+ CMSReadView,
16
+ CMSContentItemResult,
12
17
  } from "./types";
13
- import { contentListDiscriminator } from "./api/query-key-defs";
18
+ import {
19
+ contentListDiscriminator,
20
+ contentReadDiscriminator,
21
+ contentReadKey,
22
+ } from "./api/query-key-defs";
14
23
 
15
24
  /** Params for the paginated content list (one page per `limit`). */
16
- export interface ContentListParams {
25
+ export interface ContentListParams extends CMSReadOptions {
17
26
  typeSlug: string;
18
27
  limit?: number;
19
28
  /**
@@ -26,13 +35,13 @@ export interface ContentListParams {
26
35
  search?: string;
27
36
  }
28
37
 
29
- interface ContentOptionsParams {
38
+ interface ContentOptionsParams extends CMSReadOptions {
30
39
  typeSlug: string;
31
40
  search?: string;
32
41
  limit?: number;
33
42
  }
34
43
 
35
- interface ContentByRelationParams {
44
+ interface ContentByRelationParams extends CMSReadOptions {
36
45
  typeSlug: string;
37
46
  field: string;
38
47
  targetId: string;
@@ -44,11 +53,13 @@ interface InverseRelationsParams {
44
53
  itemId: string;
45
54
  }
46
55
 
47
- interface InverseRelationItemsParams {
56
+ interface InverseRelationItemsParams extends CMSReadOptions {
48
57
  slug: string;
49
58
  sourceType: string;
50
59
  itemId: string;
51
60
  fieldName: string;
61
+ limit?: number;
62
+ offset?: number;
52
63
  }
53
64
 
54
65
  /** Normalizes an empty/whitespace search term to `undefined`. */
@@ -56,6 +67,29 @@ function normalizeSearch(search: string | undefined): string | undefined {
56
67
  return search !== undefined && search.trim() === "" ? undefined : search;
57
68
  }
58
69
 
70
+ function contentReadQuery(options?: CMSReadOptions) {
71
+ return {
72
+ view: options?.view,
73
+ fields:
74
+ options?.fields === undefined
75
+ ? undefined
76
+ : JSON.stringify(normalizeContentFields(options.fields)),
77
+ };
78
+ }
79
+
80
+ function relationPageKey(
81
+ params: CMSReadOptions & { limit?: number; offset?: number },
82
+ ) {
83
+ const limit = params.limit ?? 20;
84
+ const offset = params.offset ?? 0;
85
+ return limit === 20 &&
86
+ offset === 0 &&
87
+ !params.view &&
88
+ params.fields === undefined
89
+ ? []
90
+ : [{ limit, offset, ...contentReadDiscriminator(params) }];
91
+ }
92
+
59
93
  /**
60
94
  * `getNextPageParam` for `{ items, total }` page envelopes: stop when the
61
95
  * last page is short or everything is loaded, otherwise continue at the
@@ -122,14 +156,18 @@ export const cmsResources = {
122
156
  sourceType: p.sourceType,
123
157
  }),
124
158
  query: (p: InverseRelationItemsParams) => ({
159
+ ...contentReadQuery(p),
125
160
  itemId: p.itemId,
126
161
  fieldName: p.fieldName,
162
+ limit: p.limit ?? 20,
163
+ offset: p.offset ?? 0,
127
164
  }),
128
165
  key: (p: InverseRelationItemsParams) => [
129
166
  p.slug,
130
167
  p.sourceType,
131
168
  p.itemId,
132
169
  p.fieldName,
170
+ ...relationPageKey(p),
133
171
  ],
134
172
  select: (
135
173
  data: any,
@@ -145,6 +183,7 @@ export const cmsResources = {
145
183
  path: "/content/:typeSlug",
146
184
  params: (p: ContentListParams) => ({ typeSlug: p.typeSlug }),
147
185
  query: (p: ContentListParams) => ({
186
+ ...contentReadQuery(p),
148
187
  limit: p.limit ?? 20,
149
188
  search: normalizeSearch(p.search),
150
189
  }),
@@ -162,21 +201,44 @@ export const cmsResources = {
162
201
 
163
202
  detail: {
164
203
  path: "/content/:typeSlug/:id",
165
- params: (typeSlug: string, id: string) => ({ typeSlug, id }),
166
- key: (typeSlug: string, id: string) => [typeSlug, id],
204
+ params: (typeSlug: string, id: string, _options?: CMSReadOptions) => ({
205
+ typeSlug,
206
+ id,
207
+ }),
208
+ query: (_typeSlug: string, _id: string, options?: CMSReadOptions) =>
209
+ contentReadQuery(options),
210
+ key: (typeSlug: string, id: string, options?: CMSReadOptions) => [
211
+ typeSlug,
212
+ id,
213
+ ...contentReadKey(options),
214
+ ],
167
215
  select: (
168
216
  data: any,
169
217
  _typeSlug: string,
170
218
  _id: string,
219
+ _options?: CMSReadOptions,
171
220
  ): SerializedContentItemWithType | null => data ?? null,
172
221
  skip: (typeSlug: string, id: string) => !typeSlug || !id,
173
222
  },
174
223
 
175
224
  bySlug: {
176
225
  path: "/content/:typeSlug",
177
- params: (typeSlug: string, _slug: string) => ({ typeSlug }),
178
- query: (_typeSlug: string, slug: string) => ({ slug, limit: 1 }),
179
- key: (typeSlug: string, slug: string) => ["bySlug", typeSlug, slug],
226
+ params: (
227
+ typeSlug: string,
228
+ _slug: string,
229
+ _options?: CMSReadOptions,
230
+ ) => ({ typeSlug }),
231
+ query: (_typeSlug: string, slug: string, options?: CMSReadOptions) => ({
232
+ slug,
233
+ limit: 1,
234
+ ...contentReadQuery(options),
235
+ }),
236
+ key: (typeSlug: string, slug: string, options?: CMSReadOptions) => [
237
+ "bySlug",
238
+ typeSlug,
239
+ slug,
240
+ ...contentReadKey(options),
241
+ ],
180
242
  select: (data: any): SerializedContentItemWithType | null =>
181
243
  data?.items?.[0] ?? null,
182
244
  skip: (typeSlug: string, slug: string) => !typeSlug || !slug,
@@ -195,11 +257,17 @@ export const cmsResources = {
195
257
  path: "/content/:typeSlug/by-relation",
196
258
  params: (p: ContentByRelationParams) => ({ typeSlug: p.typeSlug }),
197
259
  query: (p: ContentByRelationParams) => ({
260
+ ...contentReadQuery(p),
198
261
  field: p.field,
199
262
  targetId: p.targetId,
200
263
  limit: p.limit ?? 20,
201
264
  }),
202
- key: (p: ContentByRelationParams) => [p.typeSlug, p.field, p.targetId],
265
+ key: (p: ContentByRelationParams) => [
266
+ p.typeSlug,
267
+ p.field,
268
+ p.targetId,
269
+ ...relationPageKey(p),
270
+ ],
203
271
  select: (
204
272
  data: any,
205
273
  _p: ContentByRelationParams,
@@ -218,6 +286,7 @@ export const cmsResources = {
218
286
  path: "/content/:typeSlug",
219
287
  params: (p: ContentOptionsParams) => ({ typeSlug: p.typeSlug }),
220
288
  query: (p: ContentOptionsParams) => ({
289
+ ...contentReadQuery(p),
221
290
  search: normalizeSearch(p.search),
222
291
  limit: p.limit ?? 50,
223
292
  }),
@@ -226,6 +295,7 @@ export const cmsResources = {
226
295
  p.typeSlug,
227
296
  normalizeSearch(p.search),
228
297
  p.limit ?? 50,
298
+ ...contentReadKey(p),
229
299
  ],
230
300
  select: (
231
301
  data: any,
@@ -247,7 +317,12 @@ export const cmsResources = {
247
317
  body: { slug: vars.slug, data: vars.data },
248
318
  }),
249
319
  select: (data: any) => data as SerializedContentItemWithType | null,
250
- invalidates: ["cmsContent.list", "cmsTypes.list"],
320
+ invalidates: [
321
+ "cmsContent",
322
+ "cmsTypes.list",
323
+ "cmsTypes.inverseRelationItems",
324
+ "cmsTypes.inverseRelations",
325
+ ],
251
326
  },
252
327
  update: {
253
328
  path: "@put/content/:typeSlug/:id",
@@ -261,7 +336,11 @@ export const cmsResources = {
261
336
  body: vars.data,
262
337
  }),
263
338
  select: (data: any) => data as SerializedContentItemWithType | null,
264
- invalidates: ["cmsContent.list"],
339
+ invalidates: [
340
+ "cmsContent",
341
+ "cmsTypes.inverseRelationItems",
342
+ "cmsTypes.inverseRelations",
343
+ ],
265
344
  setData: {
266
345
  query: "detail",
267
346
  args: (updated: SerializedContentItemWithType | null) =>
@@ -277,12 +356,88 @@ export const cmsResources = {
277
356
  params: { typeSlug: vars.typeSlug, id: vars.id },
278
357
  }),
279
358
  select: (data: any) => data as { success: boolean },
280
- invalidates: ["cmsContent", "cmsTypes.list"],
359
+ invalidates: [
360
+ "cmsContent",
361
+ "cmsTypes.list",
362
+ "cmsTypes.inverseRelationItems",
363
+ "cmsTypes.inverseRelations",
364
+ ],
281
365
  },
282
366
  },
283
367
  },
284
368
  } satisfies ResourcesDeclaration;
285
369
 
370
+ type CMSResourceQueryKeys = ReturnType<
371
+ typeof createResourceQueryKeys<typeof cmsResources>
372
+ >;
373
+ type ContentQueryResult<TData> = {
374
+ queryKey: readonly unknown[];
375
+ queryFn: (context?: { pageParam?: unknown }) => Promise<TData>;
376
+ };
377
+ type CMSItemQueryEntry = {
378
+ <
379
+ TView extends CMSReadView = undefined,
380
+ const TFields extends CMSContentFields = undefined,
381
+ >(
382
+ typeSlug: string,
383
+ identity: string,
384
+ options?: CMSReadOptions<TView, TFields>,
385
+ ): ContentQueryResult<CMSContentItemResult<
386
+ Record<string, unknown>,
387
+ TView,
388
+ TFields
389
+ > | null>;
390
+ _def: readonly [string, string];
391
+ };
392
+ type CMSRelationQueryEntry<TParams> = {
393
+ <
394
+ TView extends CMSReadView = undefined,
395
+ const TFields extends CMSContentFields = undefined,
396
+ >(
397
+ params: Omit<TParams, "view" | "fields"> & CMSReadOptions<TView, TFields>,
398
+ ): ContentQueryResult<
399
+ PaginatedContentItems<Record<string, unknown>, TView, TFields>
400
+ >;
401
+ _def: readonly [string, string];
402
+ };
403
+ type CMSQueryKeys = Omit<CMSResourceQueryKeys, "cmsContent" | "cmsTypes"> & {
404
+ cmsTypes: Omit<CMSResourceQueryKeys["cmsTypes"], "inverseRelationItems"> & {
405
+ inverseRelationItems: CMSRelationQueryEntry<InverseRelationItemsParams>;
406
+ };
407
+ cmsContent: Omit<
408
+ CMSResourceQueryKeys["cmsContent"],
409
+ "list" | "detail" | "bySlug" | "byRelation" | "options"
410
+ > & {
411
+ list: {
412
+ <
413
+ TView extends CMSReadView = undefined,
414
+ const TFields extends CMSContentFields = undefined,
415
+ >(
416
+ params: Omit<ContentListParams, "view" | "fields"> &
417
+ CMSReadOptions<TView, TFields>,
418
+ ): ContentQueryResult<
419
+ PaginatedContentItems<Record<string, unknown>, TView, TFields>
420
+ >;
421
+ _def: readonly [string, string];
422
+ };
423
+ options: {
424
+ <
425
+ TView extends CMSReadView = undefined,
426
+ const TFields extends CMSContentFields = undefined,
427
+ >(
428
+ params: Omit<ContentOptionsParams, "view" | "fields"> &
429
+ CMSReadOptions<TView, TFields>,
430
+ ): ContentQueryResult<
431
+ CMSContentItemResult<Record<string, unknown>, TView, TFields>[]
432
+ >;
433
+ _def: readonly [string, string];
434
+ };
435
+ detail: CMSItemQueryEntry;
436
+ bySlug: CMSItemQueryEntry;
437
+ byRelation: CMSRelationQueryEntry<ContentByRelationParams>;
438
+ };
439
+ };
440
+
286
441
  /**
287
442
  * Create CMS query keys for React Query
288
443
  * Used by consumers and SSR loaders to fetch content types and content items
@@ -290,6 +445,6 @@ export const cmsResources = {
290
445
  export function createCMSQueryKeys(
291
446
  client: ReturnType<typeof createApiClient<CMSApiRouter>>,
292
447
  headers?: HeadersInit,
293
- ) {
294
- return createResourceQueryKeys(client, cmsResources, headers);
448
+ ): CMSQueryKeys {
449
+ return createResourceQueryKeys(client, cmsResources, headers) as CMSQueryKeys;
295
450
  }
@@ -1,8 +1,37 @@
1
1
  import { z } from "zod";
2
+ /** Normalize a field set without changing the caller's array. */
3
+ export function normalizeContentFields(
4
+ fields: readonly string[] | undefined,
5
+ ): string[] | undefined {
6
+ return fields === undefined ? undefined : [...new Set(fields)].sort();
7
+ }
2
8
 
3
9
  /** Default upper bound for a single page when no maxPageSize is configured. */
4
10
  export const DEFAULT_MAX_PAGE_SIZE = 1000;
5
11
 
12
+ /** Shared operation and HTTP output options; authorization remains unchanged. */
13
+ export const contentReadOptionsSchema = z.object({
14
+ view: z.literal("content").optional(),
15
+ fields: z
16
+ .array(z.string())
17
+ .min(1)
18
+ .transform((fields) => normalizeContentFields(fields)!)
19
+ .optional(),
20
+ });
21
+
22
+ /** HTTP transports one JSON-encoded array; operation inputs retain string arrays. */
23
+ export const contentReadQuerySchema = contentReadOptionsSchema.extend({
24
+ fields: z.preprocess((value) => {
25
+ if (value === undefined) return undefined;
26
+ if (typeof value !== "string") return z.NEVER;
27
+ try {
28
+ return JSON.parse(value);
29
+ } catch {
30
+ return value;
31
+ }
32
+ }, contentReadOptionsSchema.shape.fields),
33
+ });
34
+
6
35
  /**
7
36
  * Factory that creates the list-content query schema with a configurable
8
37
  * upper bound on the `limit` parameter.
@@ -14,6 +43,7 @@ export function createListContentQuerySchema(
14
43
  maxPageSize = DEFAULT_MAX_PAGE_SIZE,
15
44
  ) {
16
45
  return z.object({
46
+ ...contentReadOptionsSchema.shape,
17
47
  slug: z.string().optional(),
18
48
  search: z.string().max(200).optional(),
19
49
  limit: z.coerce.number().min(1).max(maxPageSize).optional().default(20),
@@ -189,12 +189,59 @@ export interface SerializedContentItemWithType<TData = Record<string, unknown>>
189
189
  _relations?: Record<string, SerializedContentItemWithType[]>;
190
190
  }
191
191
 
192
+ /** Output shape for CMS reads; omission retains the full editor envelope. */
193
+ export type CMSReadView = "content" | undefined;
194
+
195
+ /** Literal top-level property names selected from parsed content. */
196
+ export type CMSContentFields = readonly string[] | undefined;
197
+
198
+ /** Select known literal properties, or an honest partial shape for dynamic arrays. */
199
+ export type CMSSelectedData<
200
+ TData,
201
+ TFields extends CMSContentFields = undefined,
202
+ > = TFields extends readonly string[]
203
+ ? string extends TFields[number]
204
+ ? Partial<TData>
205
+ : Pick<TData, Extract<TFields[number], keyof TData>>
206
+ : TData;
207
+
208
+ /** Optional output controls for CMS reads. These do not change access checks. */
209
+ export interface CMSReadOptions<
210
+ TView extends CMSReadView = CMSReadView,
211
+ TFields extends CMSContentFields = CMSContentFields,
212
+ > {
213
+ /** Return identity, parsed content and timestamps without editor metadata. */
214
+ view?: TView;
215
+ /** Literal declared top-level content properties. Requires content view. */
216
+ fields?: TFields;
217
+ }
218
+
219
+ /** Serialized content envelope without raw JSON or editor metadata. */
220
+ export type SerializedContentItemContent<TData = Record<string, unknown>> =
221
+ Pick<
222
+ SerializedContentItemWithType<TData>,
223
+ "id" | "slug" | "parsedData" | "createdAt" | "updatedAt"
224
+ >;
225
+
226
+ /** Result envelope selected by a statically known read view. */
227
+ export type CMSContentItemResult<
228
+ TData = Record<string, unknown>,
229
+ TView extends CMSReadView = undefined,
230
+ TFields extends CMSContentFields = undefined,
231
+ > = TView extends "content"
232
+ ? SerializedContentItemContent<CMSSelectedData<TData, TFields>>
233
+ : SerializedContentItemWithType<TData>;
234
+
192
235
  /**
193
236
  * Paginated list response for content items
194
237
  * @template TData - The type of the parsed data (defaults to Record<string, unknown>)
195
238
  */
196
- export interface PaginatedContentItems<TData = Record<string, unknown>> {
197
- items: SerializedContentItemWithType<TData>[];
239
+ export interface PaginatedContentItems<
240
+ TData = Record<string, unknown>,
241
+ TView extends CMSReadView = undefined,
242
+ TFields extends CMSContentFields = undefined,
243
+ > {
244
+ items: CMSContentItemResult<TData, TView, TFields>[];
198
245
  total: number;
199
246
  limit: number;
200
247
  offset: number;
@@ -499,20 +499,26 @@ describe("Media protected query identity partition", () => {
499
499
  status: 200,
500
500
  headers: { "content-type": "application/json" },
501
501
  }),
502
- );
502
+ )
503
+ .mockResolvedValueOnce(responseFor("user-a-refreshed"));
503
504
  const auth = createIdentityTestAuth(vi.fn(() => null));
504
505
  let attemptedIdentity: string | undefined;
506
+ let committedIdentity: string | undefined;
507
+ let filename: string | undefined;
505
508
  let deleteAsset:
506
509
  | ReturnType<typeof useDeleteAsset>["mutateAsync"]
507
510
  | undefined;
508
- let suspend = false;
509
511
  const never = new Promise<never>(() => {});
510
512
 
511
513
  function Probe() {
512
- attemptedIdentity = useIdentity().identity?.id;
513
- useAssets({ limit: 40 });
514
+ const identity = useIdentity().identity?.id;
515
+ attemptedIdentity = identity;
516
+ useLayoutEffect(() => {
517
+ committedIdentity = identity;
518
+ }, [identity]);
519
+ filename = useAssets({ limit: 40 }).data?.pages[0]?.items[0]?.filename;
514
520
  deleteAsset = useDeleteAsset().mutateAsync;
515
- if (suspend) throw never;
521
+ if (identity === "user-b") throw never;
516
522
  return null;
517
523
  }
518
524
 
@@ -532,24 +538,28 @@ describe("Media protected query identity partition", () => {
532
538
  );
533
539
 
534
540
  await act(async () => root.render(tree({ id: "user-a" })));
535
- await waitFor(() => fetchMock.mock.calls.length === 1);
541
+ await waitFor(() => filename === "user-a.jpg");
536
542
  const committedDelete = deleteAsset;
537
- suspend = true;
538
543
  await act(() => {
539
544
  startTransition(() => root.render(tree({ id: "user-b" })));
540
545
  });
541
546
  await waitFor(() => attemptedIdentity === "user-b");
547
+ expect(committedIdentity).toBe("user-a");
542
548
  await act(async () => committedDelete?.("asset-user-a"));
543
549
 
544
- expect(
545
- queryClient.getQueryData(
546
- MEDIA_QUERY_KEYS.assetsList(
547
- { limit: 40 },
548
- { id: "user-a" },
549
- MEDIA_ENDPOINT,
550
- ),
550
+ const committedData = queryClient.getQueryData<{
551
+ pages: Array<{ items: Array<{ filename: string }> }>;
552
+ }>(
553
+ MEDIA_QUERY_KEYS.assetsList(
554
+ { limit: 40 },
555
+ { id: "user-a" },
556
+ MEDIA_ENDPOINT,
551
557
  ),
552
- ).toBeUndefined();
558
+ );
559
+ expect(committedIdentity).toBe("user-a");
560
+ expect(committedData?.pages[0]?.items[0]?.filename).toBe(
561
+ "user-a-refreshed.jpg",
562
+ );
553
563
  expect(
554
564
  queryClient.getQueryData(
555
565
  MEDIA_QUERY_KEYS.assetsList(
@@ -559,7 +569,7 @@ describe("Media protected query identity partition", () => {
559
569
  ),
560
570
  ),
561
571
  ).toBeUndefined();
562
- expect(fetchMock).toHaveBeenCalledTimes(2);
572
+ expect(fetchMock).toHaveBeenCalledTimes(3);
563
573
  });
564
574
 
565
575
  it("does not refetch an unmounted identity during a replacement commit", async () => {