@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,14 +1,93 @@
1
1
  import type { DBAdapter as Adapter } from "@btst/db";
2
- import { DEFAULT_MAX_PAGE_SIZE } from "../schemas";
2
+ import {
3
+ DEFAULT_MAX_PAGE_SIZE,
4
+ contentReadOptionsSchema,
5
+ normalizeContentFields,
6
+ } from "../schemas";
3
7
  import type {
4
8
  ContentType,
5
9
  ContentItem,
6
10
  ContentItemWithType,
11
+ ContentRelation,
7
12
  SerializedContentType,
8
13
  SerializedContentItem,
9
14
  SerializedContentItemWithType,
15
+ CMSReadOptions,
16
+ CMSContentFields,
17
+ CMSSelectedData,
18
+ CMSReadView,
19
+ CMSContentItemResult,
10
20
  } from "../types";
11
21
 
22
+ /** Invalid CMS content-field selection supplied to a lower-level read. */
23
+ export class CMSContentSelectionError extends Error {}
24
+
25
+ /** @internal Validate against schema properties before reading/projecting a page. */
26
+ export function validateContentReadOptions(
27
+ contentType: Pick<ContentType, "jsonSchema"> | undefined,
28
+ options?: CMSReadOptions,
29
+ ): void {
30
+ contentReadOptionsSchema.parse(options ?? {});
31
+ if (options?.fields === undefined) return;
32
+ if (options.view !== "content") {
33
+ throw new CMSContentSelectionError("fields requires view: content");
34
+ }
35
+ const properties = contentType
36
+ ? JSON.parse(contentType.jsonSchema).properties
37
+ : undefined;
38
+ for (const field of options.fields) {
39
+ if (!properties || !Object.hasOwn(properties, field)) {
40
+ throw new CMSContentSelectionError(
41
+ `Unknown top-level content field: ${field}`,
42
+ );
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Apply output options only after filtering and request authorization checks. */
48
+ export function applyContentReadOptions<
49
+ TItem extends Pick<
50
+ SerializedContentItemWithType,
51
+ "id" | "slug" | "parsedData" | "createdAt" | "updatedAt"
52
+ >,
53
+ TView extends CMSReadView = undefined,
54
+ const TFields extends CMSContentFields = undefined,
55
+ >(
56
+ item: TItem,
57
+ options?: CMSReadOptions<TView, TFields>,
58
+ ): TView extends "content"
59
+ ? Omit<
60
+ Pick<TItem, "id" | "slug" | "parsedData" | "createdAt" | "updatedAt">,
61
+ "parsedData"
62
+ > & { parsedData: CMSSelectedData<TItem["parsedData"], TFields> }
63
+ : TItem {
64
+ const result =
65
+ options?.view === "content"
66
+ ? {
67
+ id: item.id,
68
+ slug: item.slug,
69
+ parsedData: options.fields
70
+ ? Object.fromEntries(
71
+ normalizeContentFields(options.fields)!
72
+ .filter((field) => Object.hasOwn(item.parsedData, field))
73
+ .map((field) => [
74
+ field,
75
+ (item.parsedData as Record<string, unknown>)[field],
76
+ ]),
77
+ )
78
+ : item.parsedData,
79
+ createdAt: item.createdAt,
80
+ updatedAt: item.updatedAt,
81
+ }
82
+ : item;
83
+ return result as TView extends "content"
84
+ ? Omit<
85
+ Pick<TItem, "id" | "slug" | "parsedData" | "createdAt" | "updatedAt">,
86
+ "parsedData"
87
+ > & { parsedData: CMSSelectedData<TItem["parsedData"], TFields> }
88
+ : TItem;
89
+ }
90
+
12
91
  /**
13
92
  * Serialize a ContentType for SSR/SSG use (convert dates to strings).
14
93
  * Applies lazy migration for legacy schemas (version 1 → 2).
@@ -145,12 +224,20 @@ function contentItemMatchesSearch(
145
224
  * @param params - Optional filter/pagination parameters. `search` matches
146
225
  * case-insensitively against item slugs and string values in the item data.
147
226
  */
148
- export async function getAllContentItems(
227
+ export async function getAllContentItems<
228
+ TView extends CMSReadView = undefined,
229
+ const TFields extends CMSContentFields = undefined,
230
+ >(
149
231
  adapter: Adapter,
150
232
  contentTypeSlug: string,
151
- params?: { slug?: string; limit?: number; offset?: number; search?: string },
233
+ params?: {
234
+ slug?: string;
235
+ limit?: number;
236
+ offset?: number;
237
+ search?: string;
238
+ } & CMSReadOptions<TView, TFields>,
152
239
  ): Promise<{
153
- items: SerializedContentItemWithType[];
240
+ items: CMSContentItemResult<Record<string, unknown>, TView, TFields>[];
154
241
  total: number;
155
242
  limit?: number;
156
243
  offset?: number;
@@ -175,6 +262,7 @@ export async function getAllContentItems(
175
262
  };
176
263
  }
177
264
 
265
+ validateContentReadOptions(contentType, params);
178
266
  const whereConditions: Array<{
179
267
  field: string;
180
268
  value: string;
@@ -237,7 +325,7 @@ export async function getAllContentItems(
237
325
  limit !== undefined ? offset + limit : undefined,
238
326
  );
239
327
  return {
240
- items: result,
328
+ items: result.map((item) => applyContentReadOptions(item, params)),
241
329
  total,
242
330
  limit: params?.limit,
243
331
  offset: params?.offset,
@@ -245,7 +333,7 @@ export async function getAllContentItems(
245
333
  }
246
334
 
247
335
  return {
248
- items: result,
336
+ items: result.map((item) => applyContentReadOptions(item, params)),
249
337
  total: dbTotal ?? result.length,
250
338
  limit: params?.limit,
251
339
  offset: params?.offset,
@@ -263,17 +351,26 @@ export async function getAllContentItems(
263
351
  * @param adapter - The database adapter
264
352
  * @param id - The content item ID (UUID)
265
353
  */
266
- export async function getContentItemById(
354
+ export async function getContentItemById<
355
+ TView extends CMSReadView = undefined,
356
+ const TFields extends CMSContentFields = undefined,
357
+ >(
267
358
  adapter: Adapter,
268
359
  id: string,
269
- ): Promise<SerializedContentItemWithType | null> {
360
+ options?: CMSReadOptions<TView, TFields>,
361
+ ): Promise<CMSContentItemResult<
362
+ Record<string, unknown>,
363
+ TView,
364
+ TFields
365
+ > | null> {
270
366
  const item = await adapter.findOne<ContentItemWithType>({
271
367
  model: "contentItem",
272
368
  where: [{ field: "id", value: id, operator: "eq" as const }],
273
369
  join: { contentType: true },
274
370
  });
275
371
  if (!item) return null;
276
- return serializeContentItemWithType(item);
372
+ validateContentReadOptions(item.contentType, options);
373
+ return applyContentReadOptions(serializeContentItemWithType(item), options);
277
374
  }
278
375
 
279
376
  /**
@@ -288,11 +385,19 @@ export async function getContentItemById(
288
385
  * @param contentTypeSlug - The slug of the content type
289
386
  * @param slug - The slug of the content item
290
387
  */
291
- export async function getContentItemBySlug(
388
+ export async function getContentItemBySlug<
389
+ TView extends CMSReadView = undefined,
390
+ const TFields extends CMSContentFields = undefined,
391
+ >(
292
392
  adapter: Adapter,
293
393
  contentTypeSlug: string,
294
394
  slug: string,
295
- ): Promise<SerializedContentItemWithType | null> {
395
+ options?: CMSReadOptions<TView, TFields>,
396
+ ): Promise<CMSContentItemResult<
397
+ Record<string, unknown>,
398
+ TView,
399
+ TFields
400
+ > | null> {
296
401
  const contentType = await adapter.findOne<ContentType>({
297
402
  model: "contentType",
298
403
  where: [
@@ -307,6 +412,7 @@ export async function getContentItemBySlug(
307
412
  if (!contentType) {
308
413
  return null;
309
414
  }
415
+ validateContentReadOptions(contentType, options);
310
416
 
311
417
  const item = await adapter.findOne<ContentItemWithType>({
312
418
  model: "contentItem",
@@ -325,5 +431,157 @@ export async function getContentItemBySlug(
325
431
  return null;
326
432
  }
327
433
 
328
- return serializeContentItemWithType(item);
434
+ return applyContentReadOptions(serializeContentItemWithType(item), options);
435
+ }
436
+
437
+ /** Parameters for a page of items referencing one relation target. */
438
+ export interface ContentItemsByRelationParams<
439
+ TView extends CMSReadView = CMSReadView,
440
+ TFields extends CMSContentFields = CMSContentFields,
441
+ > extends CMSReadOptions<TView, TFields> {
442
+ /** Declared top-level relation field on the source content type. */
443
+ field: string;
444
+ /** ID of the item referenced by the relation. */
445
+ targetId: string;
446
+ /** Number of items to return (default: 20). */
447
+ limit?: number;
448
+ /** Number of matching items to skip (default: 0). */
449
+ offset?: number;
450
+ }
451
+
452
+ /** A page of unique related items, ordered by creation time then ID descending. */
453
+ export interface ContentItemsByRelationResult<
454
+ TView extends CMSReadView = undefined,
455
+ TFields extends CMSContentFields = undefined,
456
+ > {
457
+ items: CMSContentItemResult<Record<string, unknown>, TView, TFields>[];
458
+ total: number;
459
+ limit: number;
460
+ offset: number;
461
+ }
462
+
463
+ /**
464
+ * Retrieve source items whose relation field references a target item.
465
+ * Relation rows are scanned completely, metadata is read in batches, and full
466
+ * content is fetched only for the requested page. Internal scans grow with the
467
+ * relation count; this does not promise constant query counts or native joins.
468
+ * Set `view: "content"` for identity, parsed content and serialized timestamps
469
+ * without the editor envelope. Omitting it preserves the full item response.
470
+ *
471
+ * @remarks This lower-level read does not authorize requests, validate relation
472
+ * configuration or target access, or compose lifecycle hooks. Callers own those
473
+ * checks and pagination validation. Use `forRequest(request).operations.cms` for
474
+ * request work or trusted operations for validated, lifecycle-aware server work.
475
+ */
476
+ export async function getContentItemsByRelation<
477
+ TView extends CMSReadView = undefined,
478
+ const TFields extends CMSContentFields = undefined,
479
+ >(
480
+ adapter: Adapter,
481
+ contentTypeSlug: string,
482
+ params: ContentItemsByRelationParams<TView, TFields>,
483
+ ): Promise<ContentItemsByRelationResult<TView, TFields>> {
484
+ const contentType = await adapter.findOne<ContentType>({
485
+ model: "contentType",
486
+ where: [{ field: "slug", value: contentTypeSlug, operator: "eq" }],
487
+ });
488
+ if (!contentType) {
489
+ return {
490
+ items: [],
491
+ total: 0,
492
+ limit: params.limit ?? 20,
493
+ offset: params.offset ?? 0,
494
+ };
495
+ }
496
+ validateContentReadOptions(contentType, params);
497
+ const result = await getContentItemsByRelationForType(
498
+ adapter,
499
+ contentType,
500
+ params,
501
+ );
502
+ return {
503
+ ...result,
504
+ items: result.items.map((item) => applyContentReadOptions(item, params)),
505
+ };
506
+ }
507
+
508
+ /** @internal Reuses the checked source type without resolving its slug again. */
509
+ export async function getContentItemsByRelationForType(
510
+ adapter: Adapter,
511
+ contentType: ContentType,
512
+ params: ContentItemsByRelationParams,
513
+ ): Promise<ContentItemsByRelationResult> {
514
+ const limit = params.limit ?? 20;
515
+ const offset = params.offset ?? 0;
516
+ const batchSize = 100;
517
+ const sourceIds = new Set<string>();
518
+ for (let relationOffset = 0; ; relationOffset += batchSize) {
519
+ const relations = await adapter.findMany<Pick<ContentRelation, "sourceId">>(
520
+ {
521
+ model: "contentRelation",
522
+ where: [
523
+ { field: "targetId", value: params.targetId, operator: "eq" },
524
+ { field: "fieldName", value: params.field, operator: "eq" },
525
+ ],
526
+ select: ["sourceId"],
527
+ limit: batchSize,
528
+ offset: relationOffset,
529
+ sortBy: { field: "id", direction: "asc" },
530
+ },
531
+ );
532
+ for (const relation of relations) sourceIds.add(relation.sourceId);
533
+ if (relations.length < batchSize) break;
534
+ }
535
+
536
+ const ids = [...sourceIds];
537
+ const metadata: Pick<ContentItem, "id" | "createdAt">[] = [];
538
+ for (let start = 0; start < ids.length; start += batchSize) {
539
+ metadata.push(
540
+ ...(await adapter.findMany<Pick<ContentItem, "id" | "createdAt">>({
541
+ model: "contentItem",
542
+ where: [
543
+ {
544
+ field: "id",
545
+ value: ids.slice(start, start + batchSize),
546
+ operator: "in",
547
+ },
548
+ { field: "contentTypeId", value: contentType.id, operator: "eq" },
549
+ ],
550
+ select: ["id", "createdAt"],
551
+ limit: batchSize,
552
+ })),
553
+ );
554
+ }
555
+ metadata.sort(
556
+ (left, right) =>
557
+ right.createdAt.getTime() - left.createdAt.getTime() ||
558
+ (left.id < right.id ? 1 : left.id > right.id ? -1 : 0),
559
+ );
560
+ const pageIds = metadata.slice(offset, offset + limit).map((item) => item.id);
561
+ const itemsById = new Map<string, ContentItemWithType>();
562
+ for (let start = 0; start < pageIds.length; start += batchSize) {
563
+ const items = await adapter.findMany<ContentItemWithType>({
564
+ model: "contentItem",
565
+ where: [
566
+ {
567
+ field: "id",
568
+ value: pageIds.slice(start, start + batchSize),
569
+ operator: "in",
570
+ },
571
+ { field: "contentTypeId", value: contentType.id, operator: "eq" },
572
+ ],
573
+ limit: batchSize,
574
+ join: { contentType: true },
575
+ });
576
+ for (const item of items) itemsById.set(item.id, item);
577
+ }
578
+ return {
579
+ items: pageIds.flatMap((id) => {
580
+ const item = itemsById.get(id);
581
+ return item ? [serializeContentItemWithType(item)] : [];
582
+ }),
583
+ total: metadata.length,
584
+ limit,
585
+ offset,
586
+ };
329
587
  }
@@ -7,6 +7,9 @@ export {
7
7
  export {
8
8
  getAllContentTypes,
9
9
  getAllContentItems,
10
+ getContentItemsByRelation,
11
+ type ContentItemsByRelationParams,
12
+ type ContentItemsByRelationResult,
10
13
  getContentItemBySlug,
11
14
  getContentItemById,
12
15
  serializeContentType,
@@ -38,4 +41,11 @@ export type {
38
41
  CMSUpdateOperationContext,
39
42
  CMSUpdateResultContext,
40
43
  ContentTypeConfig,
44
+ CMSReadOptions,
45
+ CMSContentFields,
46
+ CMSSelectedData,
47
+ CMSReadView,
48
+ SerializedContentItemContent,
49
+ CMSContentItemResult,
50
+ PaginatedContentItems,
41
51
  } from "../types";