@pramen/cms 0.0.56 → 0.0.57

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.
package/dist/index.d.ts CHANGED
@@ -327,6 +327,8 @@ export declare const cmsSchema: {
327
327
  readonly type: "uuid";
328
328
  } & {
329
329
  readonly notNull: true;
330
+ } & {
331
+ readonly index: true;
330
332
  };
331
333
  title: {
332
334
  readonly type: "text";
@@ -785,6 +787,12 @@ export declare const PREVIEW_PATH = "/cms/preview";
785
787
  /** Default preview-link lifetime: 1 hour. Long enough to share and open, short enough that
786
788
  * a link pasted into a public channel stops working the same afternoon. */
787
789
  export declare const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
790
+ /** `listPages` page size when the caller names none — the historical cap, kept so a client
791
+ * that never learned to paginate sees exactly what it always did. */
792
+ export declare const PAGE_LIST_LIMIT = 100;
793
+ /** …and the ceiling on what a caller may ask for. A list screen pages; nobody needs the
794
+ * whole table in one full-row response. */
795
+ export declare const PAGE_LIST_MAX_LIMIT = 500;
788
796
  export interface CmsHandlerOpts {
789
797
  /** Roles permitted to call the editor mutations (also enforced by the ACL). Default
790
798
  * `["editor", "admin"]`. */
@@ -925,18 +933,49 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
925
933
  getContentType: import("@pramen/server").Handler<{
926
934
  id: string;
927
935
  }, Record<string, unknown>>;
928
- /** List pages, newest first. `contentType` (a content-type SLUG) narrows the list to one
929
- * type what an editor that gives each type its own tab needs, and the only way to stay
930
- * correct once a deployment has more than `limit` entries in total: filtering the full
931
- * list client-side would silently drop the tail of every type. Omitted ⇒ all types, the
932
- * historical behaviour. An unknown slug returns nothing rather than everything, so a
933
- * typo can never read as "here is the whole CMS". */
936
+ /** List pages, newest first. Viewer-gated: the rows are FULL page records (schedule
937
+ * timestamps, revision pointer, the whole `fields` bag, every SEO column), which is the
938
+ * editing surface, not the published one `listPublishedPages` is this file's deliberate
939
+ * public projection and stays narrow.
940
+ *
941
+ * `contentType` (a content-type SLUG) narrows the list to one type — what an editor that
942
+ * gives each type its own tab needs, and the only way to stay correct once a deployment
943
+ * has more than `limit` entries in total: filtering the full list client-side would
944
+ * silently drop the tail of every type. Omitted ⇒ all types, the historical behaviour. An
945
+ * unknown slug returns nothing rather than everything, so a typo can never read as "here
946
+ * is the whole CMS" — and neither can an EMPTY one, which is why the check below is
947
+ * `=== undefined` and not a falsy test.
948
+ *
949
+ * `limit`/`offset` page the list. Without them the caller cannot tell a full first page
950
+ * from the whole table, and the editor's header reports the cap as if it were the total.
951
+ * `select` narrows the projection: a list screen needs five columns, not the widest row
952
+ * in the CMS, and on the D1 store every unasked-for column crosses RPC. */
934
953
  listPages: import("@pramen/server").Handler<{
935
954
  contentType?: string;
955
+ limit?: number;
956
+ offset?: number;
957
+ select?: string[];
936
958
  }, Record<string, unknown>[]>;
959
+ /** One page by id, for the editor opening `/pages/:id` directly. Resolving that id
960
+ * against `listPages` instead means a deep link (or a row clicked in a type's own tab)
961
+ * can miss: that list is capped and, since the editor lists per type, is not even the
962
+ * list the row came from. Viewer-gated + row-ACL'd like every other read. */
963
+ getPageById: import("@pramen/server").Handler<{
964
+ pageId: string;
965
+ }, Record<string, unknown>>;
937
966
  /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
938
- * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
939
- listPublishedPages: import("@pramen/server").Handler<unknown, {
967
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
968
+ *
969
+ * `contentType` / `locale` narrow the list HERE, for the same reason `listPages` does:
970
+ * the result is capped, so a caller filtering it afterwards is filtering an already
971
+ * truncated list and loses the tail of every type. `@pramen/cms-astro`'s `collections:
972
+ * "auto"` builds one collection per content type, each calling this — un-narrowed, all
973
+ * of them fetch the same 5000 rows and everything past the cap vanishes from the built
974
+ * site with a green build. An unknown slug returns nothing, never everything. */
975
+ listPublishedPages: import("@pramen/server").Handler<{
976
+ contentType?: string;
977
+ locale?: string;
978
+ }, {
940
979
  slug: string;
941
980
  locale: string;
942
981
  contentType: string | null;
@@ -1000,10 +1039,17 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
1000
1039
  * a client flag can hide a control but cannot make the data right, and the two drift
1001
1040
  * the moment someone adds a locale. `multilingual` is the derived answer to the only
1002
1041
  * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1042
+ /** What this deployment supports. `pagesByType` is the editor's licence to give each
1043
+ * content type its own tab and its own list: an OLDER server ignores the `contentType`
1044
+ * argument entirely and answers with the pooled list, so an editor that assumed the
1045
+ * feature would render N tabs all showing every type's pages under a heading claiming
1046
+ * otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
1047
+ * inferred: fail closed on the pooled list rather than open on N lying ones. */
1003
1048
  listCmsCapabilities: import("@pramen/server").Handler<unknown, {
1004
1049
  locales: string[];
1005
1050
  defaultLocale: string;
1006
1051
  multilingual: boolean;
1052
+ pagesByType: true;
1007
1053
  }>;
1008
1054
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1009
1055
  * store — not configuration. `listCmsCapabilities().locales` is what the deployment
@@ -1301,18 +1347,49 @@ export declare const cmsHandlers: {
1301
1347
  getContentType: import("@pramen/server").Handler<{
1302
1348
  id: string;
1303
1349
  }, Record<string, unknown>>;
1304
- /** List pages, newest first. `contentType` (a content-type SLUG) narrows the list to one
1305
- * type what an editor that gives each type its own tab needs, and the only way to stay
1306
- * correct once a deployment has more than `limit` entries in total: filtering the full
1307
- * list client-side would silently drop the tail of every type. Omitted ⇒ all types, the
1308
- * historical behaviour. An unknown slug returns nothing rather than everything, so a
1309
- * typo can never read as "here is the whole CMS". */
1350
+ /** List pages, newest first. Viewer-gated: the rows are FULL page records (schedule
1351
+ * timestamps, revision pointer, the whole `fields` bag, every SEO column), which is the
1352
+ * editing surface, not the published one `listPublishedPages` is this file's deliberate
1353
+ * public projection and stays narrow.
1354
+ *
1355
+ * `contentType` (a content-type SLUG) narrows the list to one type — what an editor that
1356
+ * gives each type its own tab needs, and the only way to stay correct once a deployment
1357
+ * has more than `limit` entries in total: filtering the full list client-side would
1358
+ * silently drop the tail of every type. Omitted ⇒ all types, the historical behaviour. An
1359
+ * unknown slug returns nothing rather than everything, so a typo can never read as "here
1360
+ * is the whole CMS" — and neither can an EMPTY one, which is why the check below is
1361
+ * `=== undefined` and not a falsy test.
1362
+ *
1363
+ * `limit`/`offset` page the list. Without them the caller cannot tell a full first page
1364
+ * from the whole table, and the editor's header reports the cap as if it were the total.
1365
+ * `select` narrows the projection: a list screen needs five columns, not the widest row
1366
+ * in the CMS, and on the D1 store every unasked-for column crosses RPC. */
1310
1367
  listPages: import("@pramen/server").Handler<{
1311
1368
  contentType?: string;
1369
+ limit?: number;
1370
+ offset?: number;
1371
+ select?: string[];
1312
1372
  }, Record<string, unknown>[]>;
1373
+ /** One page by id, for the editor opening `/pages/:id` directly. Resolving that id
1374
+ * against `listPages` instead means a deep link (or a row clicked in a type's own tab)
1375
+ * can miss: that list is capped and, since the editor lists per type, is not even the
1376
+ * list the row came from. Viewer-gated + row-ACL'd like every other read. */
1377
+ getPageById: import("@pramen/server").Handler<{
1378
+ pageId: string;
1379
+ }, Record<string, unknown>>;
1313
1380
  /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
1314
- * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
1315
- listPublishedPages: import("@pramen/server").Handler<unknown, {
1381
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
1382
+ *
1383
+ * `contentType` / `locale` narrow the list HERE, for the same reason `listPages` does:
1384
+ * the result is capped, so a caller filtering it afterwards is filtering an already
1385
+ * truncated list and loses the tail of every type. `@pramen/cms-astro`'s `collections:
1386
+ * "auto"` builds one collection per content type, each calling this — un-narrowed, all
1387
+ * of them fetch the same 5000 rows and everything past the cap vanishes from the built
1388
+ * site with a green build. An unknown slug returns nothing, never everything. */
1389
+ listPublishedPages: import("@pramen/server").Handler<{
1390
+ contentType?: string;
1391
+ locale?: string;
1392
+ }, {
1316
1393
  slug: string;
1317
1394
  locale: string;
1318
1395
  contentType: string | null;
@@ -1376,10 +1453,17 @@ export declare const cmsHandlers: {
1376
1453
  * a client flag can hide a control but cannot make the data right, and the two drift
1377
1454
  * the moment someone adds a locale. `multilingual` is the derived answer to the only
1378
1455
  * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1456
+ /** What this deployment supports. `pagesByType` is the editor's licence to give each
1457
+ * content type its own tab and its own list: an OLDER server ignores the `contentType`
1458
+ * argument entirely and answers with the pooled list, so an editor that assumed the
1459
+ * feature would render N tabs all showing every type's pages under a heading claiming
1460
+ * otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
1461
+ * inferred: fail closed on the pooled list rather than open on N lying ones. */
1379
1462
  listCmsCapabilities: import("@pramen/server").Handler<unknown, {
1380
1463
  locales: string[];
1381
1464
  defaultLocale: string;
1382
1465
  multilingual: boolean;
1466
+ pagesByType: true;
1383
1467
  }>;
1384
1468
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1385
1469
  * store — not configuration. `listCmsCapabilities().locales` is what the deployment
package/dist/index.js CHANGED
@@ -237,7 +237,13 @@ export const cmsSchema = {
237
237
  }), (r) => ({ type: r.belongsTo("cms_block_types", "typeId") })),
238
238
  cms_pages: Entity((t) => ({
239
239
  id: primaryKey(generated(t.uuid())),
240
- typeId: notNull(t.uuid()),
240
+ // Indexed because the editor lists pages ONE TYPE AT A TIME (`listPages({ contentType })`,
241
+ // a tab per type): without it every tab load scans all of cms_pages before sorting, and
242
+ // on the D1 store that scan is paid over RPC. Relation columns are never auto-indexed
243
+ // (index DDL comes only from `unique()`/`indexed()` and composite uniques), and the
244
+ // `["slug","locale"]` composite is leftmost-`slug` so it cannot serve this predicate.
245
+ // The `createdAt` sort of the narrowed set remains — single-column indexes only.
246
+ typeId: indexed(notNull(t.uuid())),
241
247
  title: notNull(t.text()),
242
248
  // A slug is unique PER LOCALE (`/en/about` + `/cs/about`) — enforced by the entity's
243
249
  // composite `unique: [["slug","locale"]]` (below). createPage/updatePage/createTranslation
@@ -953,6 +959,12 @@ export const PREVIEW_PATH = "/cms/preview";
953
959
  /** Default preview-link lifetime: 1 hour. Long enough to share and open, short enough that
954
960
  * a link pasted into a public channel stops working the same afternoon. */
955
961
  export const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
962
+ /** `listPages` page size when the caller names none — the historical cap, kept so a client
963
+ * that never learned to paginate sees exactly what it always did. */
964
+ export const PAGE_LIST_LIMIT = 100;
965
+ /** …and the ceiling on what a caller may ask for. A list screen pages; nobody needs the
966
+ * whole table in one full-row response. */
967
+ export const PAGE_LIST_MAX_LIMIT = 500;
956
968
  /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
957
969
  * gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
958
970
  export function createCmsHandlers(opts = {}) {
@@ -1039,16 +1051,24 @@ export function createCmsHandlers(opts = {}) {
1039
1051
  },
1040
1052
  };
1041
1053
  // (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
1054
+ //
1055
+ // Slugs are global across content types — the constraint is (slug, locale), NOT
1056
+ // (slug, locale, typeId) — so the colliding page is very often one the caller cannot see:
1057
+ // the editor lists ONE type per tab, and "already exists" naming only slug + locale leaves
1058
+ // them staring at a list that visibly contains no such row. Both messages name the owning
1059
+ // type, the way the trash variant already named the trash.
1042
1060
  const assertSlugFree = async (db, slug, locale, exceptId) => {
1043
- const rows = await db.exec("SELECT id, deletedAt FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1", slug, locale);
1061
+ const rows = await db.exec("SELECT id, deletedAt, typeId FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1", slug, locale);
1044
1062
  if (rows[0] && String(rows[0].id) !== exceptId) {
1063
+ const typeSlug = await contentTypeSlug(db, rows[0].typeId).catch(() => null);
1064
+ const under = typeSlug ? ` under content type '${typeSlug}'` : "";
1045
1065
  // A trashed page keeps its slug until purged (the (slug, locale) unique index is a
1046
1066
  // DB constraint, not advisory). Say so, rather than leave the caller hunting for a
1047
1067
  // page they cannot see.
1048
1068
  if (rows[0].deletedAt != null) {
1049
- throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}' — restore or purge it first`);
1069
+ throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}'${under} — restore or purge it first`);
1050
1070
  }
1051
- throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
1071
+ throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'${under} — slugs are unique across all content types`);
1052
1072
  }
1053
1073
  };
1054
1074
  const TASK_PUBLISH = "cms:publish";
@@ -1088,6 +1108,12 @@ export function createCmsHandlers(opts = {}) {
1088
1108
  const o = asObj(raw);
1089
1109
  if (typeof o.name !== "string" || typeof o.slug !== "string")
1090
1110
  throw new BadRequest("name and slug are required");
1111
+ // Non-EMPTY, not merely a string: a content type's slug is a URL segment in the
1112
+ // editor (`/types/:slug`) and the key `listPages({ contentType })` resolves. An
1113
+ // empty one builds `/types/` — a path the router drops the empty segment from, so
1114
+ // the type gets a tab that cannot be reached and a list that cannot be addressed.
1115
+ if (o.name.trim() === "" || o.slug.trim() === "")
1116
+ throw new BadRequest("name and slug must not be empty");
1091
1117
  if (!Array.isArray(o.regions) || o.regions.length === 0)
1092
1118
  throw new BadRequest("at least one region is required");
1093
1119
  return o;
@@ -1139,6 +1165,9 @@ export function createCmsHandlers(opts = {}) {
1139
1165
  const o = asObj(raw);
1140
1166
  if (typeof o.id !== "string" && typeof o.slug !== "string")
1141
1167
  throw new BadRequest("id or slug is required");
1168
+ // `name` is the editor's tab label; blanking it leaves an unlabelled tab.
1169
+ if (typeof o.name === "string" && o.name.trim() === "")
1170
+ throw new BadRequest("name must not be empty");
1142
1171
  return o;
1143
1172
  },
1144
1173
  }),
@@ -1300,43 +1329,114 @@ export function createCmsHandlers(opts = {}) {
1300
1329
  },
1301
1330
  }),
1302
1331
  // ---- pages ----
1303
- /** List pages, newest first. `contentType` (a content-type SLUG) narrows the list to one
1304
- * type what an editor that gives each type its own tab needs, and the only way to stay
1305
- * correct once a deployment has more than `limit` entries in total: filtering the full
1306
- * list client-side would silently drop the tail of every type. Omitted ⇒ all types, the
1307
- * historical behaviour. An unknown slug returns nothing rather than everything, so a
1308
- * typo can never read as "here is the whole CMS". */
1332
+ /** List pages, newest first. Viewer-gated: the rows are FULL page records (schedule
1333
+ * timestamps, revision pointer, the whole `fields` bag, every SEO column), which is the
1334
+ * editing surface, not the published one `listPublishedPages` is this file's deliberate
1335
+ * public projection and stays narrow.
1336
+ *
1337
+ * `contentType` (a content-type SLUG) narrows the list to one type — what an editor that
1338
+ * gives each type its own tab needs, and the only way to stay correct once a deployment
1339
+ * has more than `limit` entries in total: filtering the full list client-side would
1340
+ * silently drop the tail of every type. Omitted ⇒ all types, the historical behaviour. An
1341
+ * unknown slug returns nothing rather than everything, so a typo can never read as "here
1342
+ * is the whole CMS" — and neither can an EMPTY one, which is why the check below is
1343
+ * `=== undefined` and not a falsy test.
1344
+ *
1345
+ * `limit`/`offset` page the list. Without them the caller cannot tell a full first page
1346
+ * from the whole table, and the editor's header reports the cap as if it were the total.
1347
+ * `select` narrows the projection: a list screen needs five columns, not the widest row
1348
+ * in the CMS, and on the D1 store every unasked-for column crosses RPC. */
1309
1349
  listPages: query(async (ctx, input) => {
1310
- const db = cdb(ctx);
1311
- const order = { column: "createdAt", dir: "desc" };
1312
- if (!input?.contentType)
1313
- return db.find({ from: "cms_pages", orderBy: order, limit: 100 });
1314
- const types = await db.find({ from: "cms_content_types", where: { slug: input.contentType }, limit: 1 });
1315
- const typeId = types[0]?.id;
1316
- if (typeId == null)
1317
- return [];
1318
- return db.find({ from: "cms_pages", where: { typeId }, orderBy: order, limit: 100 });
1350
+ // ONE query, not two: `where` traverses the `type` belongsTo the schema already
1351
+ // declares, so the slug is resolved by a subquery. That also gives the unknown-slug
1352
+ // invariant for free (an empty subquery matches nothing) and, unlike a hand-rolled
1353
+ // lookup in cms_content_types, does not THROW for a policy set that grants cms_pages
1354
+ // but not the types table.
1355
+ const where = input?.contentType === undefined ? undefined : { type: { slug: input.contentType } };
1356
+ return cdb(ctx).find({
1357
+ from: "cms_pages",
1358
+ where,
1359
+ orderBy: { column: "createdAt", dir: "desc" },
1360
+ limit: Math.min(input?.limit ?? PAGE_LIST_LIMIT, PAGE_LIST_MAX_LIMIT),
1361
+ offset: input?.offset ?? 0,
1362
+ select: input?.select,
1363
+ });
1319
1364
  }, {
1365
+ ...viewer,
1366
+ // The parsed value is what the ACL sees (`dispatch` hands it to `Db` for `$input()`
1367
+ // resolution), so this SPREADS the raw object rather than rebuilding one from the
1368
+ // keys it knows: a host scoping cms_pages with `$input("someKey")` had its marker
1369
+ // resolving against `{}` — the scope collapsing to nothing — the moment this handler
1370
+ // grew a parser.
1320
1371
  input: (raw) => {
1321
1372
  const o = asObj(raw);
1373
+ const out = { ...o };
1322
1374
  if (o.contentType === undefined || o.contentType === null)
1323
- return {};
1324
- if (typeof o.contentType !== "string")
1375
+ delete out.contentType;
1376
+ else if (typeof o.contentType !== "string")
1325
1377
  throw new BadRequest("contentType must be a string");
1326
- return { contentType: o.contentType };
1378
+ for (const k of ["limit", "offset"]) {
1379
+ if (o[k] === undefined || o[k] === null) {
1380
+ delete out[k];
1381
+ continue;
1382
+ }
1383
+ if (typeof o[k] !== "number" || !Number.isInteger(o[k]) || o[k] < 0)
1384
+ throw new BadRequest(`${k} must be a non-negative integer`);
1385
+ }
1386
+ if (o.select === undefined || o.select === null)
1387
+ delete out.select;
1388
+ else if (!Array.isArray(o.select) || o.select.some((c) => typeof c !== "string"))
1389
+ throw new BadRequest("select must be an array of column names");
1390
+ return out;
1327
1391
  },
1328
1392
  }),
1393
+ /** One page by id, for the editor opening `/pages/:id` directly. Resolving that id
1394
+ * against `listPages` instead means a deep link (or a row clicked in a type's own tab)
1395
+ * can miss: that list is capped and, since the editor lists per type, is not even the
1396
+ * list the row came from. Viewer-gated + row-ACL'd like every other read. */
1397
+ getPageById: query(async (ctx, input) => {
1398
+ const rows = await cdb(ctx).find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1399
+ return rows[0] ?? null;
1400
+ }, { ...viewer, ...pageIdInput }),
1329
1401
  /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
1330
- * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
1331
- listPublishedPages: query(async (ctx) => {
1402
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
1403
+ *
1404
+ * `contentType` / `locale` narrow the list HERE, for the same reason `listPages` does:
1405
+ * the result is capped, so a caller filtering it afterwards is filtering an already
1406
+ * truncated list and loses the tail of every type. `@pramen/cms-astro`'s `collections:
1407
+ * "auto"` builds one collection per content type, each calling this — un-narrowed, all
1408
+ * of them fetch the same 5000 rows and everything past the cap vanishes from the built
1409
+ * site with a green build. An unknown slug returns nothing, never everything. */
1410
+ listPublishedPages: query(async (ctx, input) => {
1332
1411
  const db = cdb(ctx);
1333
- const rows = await db.find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
1412
+ const where = { status: "published" };
1413
+ if (input?.contentType !== undefined)
1414
+ where.type = { slug: input.contentType };
1415
+ if (input?.locale !== undefined)
1416
+ where.locale = input.locale;
1417
+ const rows = await db.find({ from: "cms_pages", where, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
1334
1418
  // Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
1335
1419
  // vs pages) without a second round-trip.
1336
1420
  const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v) => typeof v === "string"))];
1337
1421
  const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
1338
1422
  const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
1339
1423
  return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
1424
+ }, {
1425
+ // Spreads the raw object for the same reason `listPages` does — the parsed value is
1426
+ // what `$input()` policy markers resolve against.
1427
+ input: (raw) => {
1428
+ const o = asObj(raw);
1429
+ const out = { ...o };
1430
+ for (const k of ["contentType", "locale"]) {
1431
+ if (o[k] === undefined || o[k] === null) {
1432
+ delete out[k];
1433
+ continue;
1434
+ }
1435
+ if (typeof o[k] !== "string")
1436
+ throw new BadRequest(`${k} must be a string`);
1437
+ }
1438
+ return out;
1439
+ },
1340
1440
  }),
1341
1441
  /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
1342
1442
  updatePageSeo: mutation(async (ctx, input) => {
@@ -1546,7 +1646,13 @@ export function createCmsHandlers(opts = {}) {
1546
1646
  * a client flag can hide a control but cannot make the data right, and the two drift
1547
1647
  * the moment someone adds a locale. `multilingual` is the derived answer to the only
1548
1648
  * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1549
- listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1 }), viewer),
1649
+ /** What this deployment supports. `pagesByType` is the editor's licence to give each
1650
+ * content type its own tab and its own list: an OLDER server ignores the `contentType`
1651
+ * argument entirely and answers with the pooled list, so an editor that assumed the
1652
+ * feature would render N tabs all showing every type's pages under a heading claiming
1653
+ * otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
1654
+ * inferred: fail closed on the pooled list rather than open on N lying ones. */
1655
+ listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1, pagesByType: true }), viewer),
1550
1656
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1551
1657
  * store — not configuration. `listCmsCapabilities().locales` is what the deployment
1552
1658
  * declares; these two differ while a locale is declared but not yet authored. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.56",
3
+ "version": "0.0.57",
4
4
  "description": "Optional block/page builder for pramen — Drupal-Paragraphs-style typed blocks in named regions, reusable blocks, scheduled publishing, built entirely from pramen primitives.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@pramen/server": "0.0.56"
44
+ "@pramen/server": "0.0.57"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "react": ">=18"
package/src/index.ts CHANGED
@@ -484,7 +484,13 @@ export const cmsSchema = {
484
484
  cms_pages: Entity(
485
485
  (t) => ({
486
486
  id: primaryKey(generated(t.uuid())),
487
- typeId: notNull(t.uuid()),
487
+ // Indexed because the editor lists pages ONE TYPE AT A TIME (`listPages({ contentType })`,
488
+ // a tab per type): without it every tab load scans all of cms_pages before sorting, and
489
+ // on the D1 store that scan is paid over RPC. Relation columns are never auto-indexed
490
+ // (index DDL comes only from `unique()`/`indexed()` and composite uniques), and the
491
+ // `["slug","locale"]` composite is leftmost-`slug` so it cannot serve this predicate.
492
+ // The `createdAt` sort of the narrowed set remains — single-column indexes only.
493
+ typeId: indexed(notNull(t.uuid())),
488
494
  title: notNull(t.text()),
489
495
  // A slug is unique PER LOCALE (`/en/about` + `/cs/about`) — enforced by the entity's
490
496
  // composite `unique: [["slug","locale"]]` (below). createPage/updatePage/createTranslation
@@ -1136,6 +1142,9 @@ interface CmsDb {
1136
1142
  limit?: number;
1137
1143
  offset?: number;
1138
1144
  with?: Record<string, unknown>;
1145
+ /** Column projection. Each name is ACL-checked by `Db` (an unreadable or `hidden()`
1146
+ * column is a 403), so it is safe to build one from caller input. */
1147
+ select?: readonly string[];
1139
1148
  }): Promise<Array<Record<string, unknown>>>;
1140
1149
  insert(table: string, values: Record<string, unknown>): Promise<Record<string, unknown>>;
1141
1150
  update(table: string, id: string, patch: Record<string, unknown>): Promise<Record<string, unknown> | undefined>;
@@ -1373,6 +1382,13 @@ export const PREVIEW_PATH = "/cms/preview";
1373
1382
  * a link pasted into a public channel stops working the same afternoon. */
1374
1383
  export const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
1375
1384
 
1385
+ /** `listPages` page size when the caller names none — the historical cap, kept so a client
1386
+ * that never learned to paginate sees exactly what it always did. */
1387
+ export const PAGE_LIST_LIMIT = 100;
1388
+ /** …and the ceiling on what a caller may ask for. A list screen pages; nobody needs the
1389
+ * whole table in one full-row response. */
1390
+ export const PAGE_LIST_MAX_LIMIT = 500;
1391
+
1376
1392
  export interface CmsHandlerOpts {
1377
1393
  /** Roles permitted to call the editor mutations (also enforced by the ACL). Default
1378
1394
  * `["editor", "admin"]`. */
@@ -1491,20 +1507,28 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1491
1507
  };
1492
1508
 
1493
1509
  // (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
1510
+ //
1511
+ // Slugs are global across content types — the constraint is (slug, locale), NOT
1512
+ // (slug, locale, typeId) — so the colliding page is very often one the caller cannot see:
1513
+ // the editor lists ONE type per tab, and "already exists" naming only slug + locale leaves
1514
+ // them staring at a list that visibly contains no such row. Both messages name the owning
1515
+ // type, the way the trash variant already named the trash.
1494
1516
  const assertSlugFree = async (db: CmsDb, slug: string, locale: string, exceptId?: string): Promise<void> => {
1495
1517
  const rows = await db.exec(
1496
- "SELECT id, deletedAt FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1",
1518
+ "SELECT id, deletedAt, typeId FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1",
1497
1519
  slug,
1498
1520
  locale,
1499
1521
  );
1500
1522
  if (rows[0] && String(rows[0].id) !== exceptId) {
1523
+ const typeSlug = await contentTypeSlug(db, rows[0].typeId).catch(() => null);
1524
+ const under = typeSlug ? ` under content type '${typeSlug}'` : "";
1501
1525
  // A trashed page keeps its slug until purged (the (slug, locale) unique index is a
1502
1526
  // DB constraint, not advisory). Say so, rather than leave the caller hunting for a
1503
1527
  // page they cannot see.
1504
1528
  if (rows[0].deletedAt != null) {
1505
- throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}' — restore or purge it first`);
1529
+ throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}'${under} — restore or purge it first`);
1506
1530
  }
1507
- throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
1531
+ throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'${under} — slugs are unique across all content types`);
1508
1532
  }
1509
1533
  };
1510
1534
 
@@ -1546,6 +1570,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1546
1570
  input: (raw): { name: string; slug: string; regions: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] } => {
1547
1571
  const o = asObj(raw);
1548
1572
  if (typeof o.name !== "string" || typeof o.slug !== "string") throw new BadRequest("name and slug are required");
1573
+ // Non-EMPTY, not merely a string: a content type's slug is a URL segment in the
1574
+ // editor (`/types/:slug`) and the key `listPages({ contentType })` resolves. An
1575
+ // empty one builds `/types/` — a path the router drops the empty segment from, so
1576
+ // the type gets a tab that cannot be reached and a list that cannot be addressed.
1577
+ if (o.name.trim() === "" || o.slug.trim() === "") throw new BadRequest("name and slug must not be empty");
1549
1578
  if (!Array.isArray(o.regions) || o.regions.length === 0) throw new BadRequest("at least one region is required");
1550
1579
  return o as never;
1551
1580
  },
@@ -1591,6 +1620,8 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1591
1620
  input: (raw): { id?: string; slug?: string; name?: string; regions?: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] } => {
1592
1621
  const o = asObj(raw);
1593
1622
  if (typeof o.id !== "string" && typeof o.slug !== "string") throw new BadRequest("id or slug is required");
1623
+ // `name` is the editor's tab label; blanking it leaves an unlabelled tab.
1624
+ if (typeof o.name === "string" && o.name.trim() === "") throw new BadRequest("name must not be empty");
1594
1625
  return o as never;
1595
1626
  },
1596
1627
  }),
@@ -1752,43 +1783,105 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1752
1783
  }),
1753
1784
 
1754
1785
  // ---- pages ----
1755
- /** List pages, newest first. `contentType` (a content-type SLUG) narrows the list to one
1756
- * type what an editor that gives each type its own tab needs, and the only way to stay
1757
- * correct once a deployment has more than `limit` entries in total: filtering the full
1758
- * list client-side would silently drop the tail of every type. Omitted ⇒ all types, the
1759
- * historical behaviour. An unknown slug returns nothing rather than everything, so a
1760
- * typo can never read as "here is the whole CMS". */
1786
+ /** List pages, newest first. Viewer-gated: the rows are FULL page records (schedule
1787
+ * timestamps, revision pointer, the whole `fields` bag, every SEO column), which is the
1788
+ * editing surface, not the published one `listPublishedPages` is this file's deliberate
1789
+ * public projection and stays narrow.
1790
+ *
1791
+ * `contentType` (a content-type SLUG) narrows the list to one type — what an editor that
1792
+ * gives each type its own tab needs, and the only way to stay correct once a deployment
1793
+ * has more than `limit` entries in total: filtering the full list client-side would
1794
+ * silently drop the tail of every type. Omitted ⇒ all types, the historical behaviour. An
1795
+ * unknown slug returns nothing rather than everything, so a typo can never read as "here
1796
+ * is the whole CMS" — and neither can an EMPTY one, which is why the check below is
1797
+ * `=== undefined` and not a falsy test.
1798
+ *
1799
+ * `limit`/`offset` page the list. Without them the caller cannot tell a full first page
1800
+ * from the whole table, and the editor's header reports the cap as if it were the total.
1801
+ * `select` narrows the projection: a list screen needs five columns, not the widest row
1802
+ * in the CMS, and on the D1 store every unasked-for column crosses RPC. */
1761
1803
  listPages: query(
1762
- async (ctx, input: { contentType?: string }) => {
1763
- const db = cdb(ctx);
1764
- const order = { column: "createdAt", dir: "desc" } as const;
1765
- if (!input?.contentType) return db.find({ from: "cms_pages", orderBy: order, limit: 100 });
1766
- const types = await db.find({ from: "cms_content_types", where: { slug: input.contentType }, limit: 1 });
1767
- const typeId = types[0]?.id;
1768
- if (typeId == null) return [];
1769
- return db.find({ from: "cms_pages", where: { typeId }, orderBy: order, limit: 100 });
1804
+ async (ctx, input: { contentType?: string; limit?: number; offset?: number; select?: string[] }) => {
1805
+ // ONE query, not two: `where` traverses the `type` belongsTo the schema already
1806
+ // declares, so the slug is resolved by a subquery. That also gives the unknown-slug
1807
+ // invariant for free (an empty subquery matches nothing) and, unlike a hand-rolled
1808
+ // lookup in cms_content_types, does not THROW for a policy set that grants cms_pages
1809
+ // but not the types table.
1810
+ const where = input?.contentType === undefined ? undefined : { type: { slug: input.contentType } };
1811
+ return cdb(ctx).find({
1812
+ from: "cms_pages",
1813
+ where,
1814
+ orderBy: { column: "createdAt", dir: "desc" },
1815
+ limit: Math.min(input?.limit ?? PAGE_LIST_LIMIT, PAGE_LIST_MAX_LIMIT),
1816
+ offset: input?.offset ?? 0,
1817
+ select: input?.select,
1818
+ });
1770
1819
  },
1771
1820
  {
1772
- input: (raw): { contentType?: string } => {
1821
+ ...viewer,
1822
+ // The parsed value is what the ACL sees (`dispatch` hands it to `Db` for `$input()`
1823
+ // resolution), so this SPREADS the raw object rather than rebuilding one from the
1824
+ // keys it knows: a host scoping cms_pages with `$input("someKey")` had its marker
1825
+ // resolving against `{}` — the scope collapsing to nothing — the moment this handler
1826
+ // grew a parser.
1827
+ input: (raw): { contentType?: string; limit?: number; offset?: number; select?: string[] } => {
1773
1828
  const o = asObj(raw);
1774
- if (o.contentType === undefined || o.contentType === null) return {};
1775
- if (typeof o.contentType !== "string") throw new BadRequest("contentType must be a string");
1776
- return { contentType: o.contentType };
1829
+ const out: Record<string, unknown> = { ...o };
1830
+ if (o.contentType === undefined || o.contentType === null) delete out.contentType;
1831
+ else if (typeof o.contentType !== "string") throw new BadRequest("contentType must be a string");
1832
+ for (const k of ["limit", "offset"] as const) {
1833
+ if (o[k] === undefined || o[k] === null) { delete out[k]; continue; }
1834
+ if (typeof o[k] !== "number" || !Number.isInteger(o[k]) || (o[k] as number) < 0) throw new BadRequest(`${k} must be a non-negative integer`);
1835
+ }
1836
+ if (o.select === undefined || o.select === null) delete out.select;
1837
+ else if (!Array.isArray(o.select) || o.select.some((c) => typeof c !== "string")) throw new BadRequest("select must be an array of column names");
1838
+ return out as never;
1777
1839
  },
1778
1840
  },
1779
1841
  ),
1780
1842
 
1843
+ /** One page by id, for the editor opening `/pages/:id` directly. Resolving that id
1844
+ * against `listPages` instead means a deep link (or a row clicked in a type's own tab)
1845
+ * can miss: that list is capped and, since the editor lists per type, is not even the
1846
+ * list the row came from. Viewer-gated + row-ACL'd like every other read. */
1847
+ getPageById: query(async (ctx, input: { pageId: string }) => {
1848
+ const rows = await cdb(ctx).find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1849
+ return rows[0] ?? null;
1850
+ }, { ...viewer, ...pageIdInput }),
1851
+
1781
1852
  /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
1782
- * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
1783
- listPublishedPages: query(async (ctx) => {
1853
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
1854
+ *
1855
+ * `contentType` / `locale` narrow the list HERE, for the same reason `listPages` does:
1856
+ * the result is capped, so a caller filtering it afterwards is filtering an already
1857
+ * truncated list and loses the tail of every type. `@pramen/cms-astro`'s `collections:
1858
+ * "auto"` builds one collection per content type, each calling this — un-narrowed, all
1859
+ * of them fetch the same 5000 rows and everything past the cap vanishes from the built
1860
+ * site with a green build. An unknown slug returns nothing, never everything. */
1861
+ listPublishedPages: query(async (ctx, input: { contentType?: string; locale?: string }) => {
1784
1862
  const db = cdb(ctx);
1785
- const rows = await db.find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
1863
+ const where: Record<string, unknown> = { status: "published" };
1864
+ if (input?.contentType !== undefined) where.type = { slug: input.contentType };
1865
+ if (input?.locale !== undefined) where.locale = input.locale;
1866
+ const rows = await db.find({ from: "cms_pages", where, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
1786
1867
  // Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
1787
1868
  // vs pages) without a second round-trip.
1788
1869
  const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v): v is string => typeof v === "string"))];
1789
1870
  const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
1790
1871
  const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
1791
1872
  return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
1873
+ }, {
1874
+ // Spreads the raw object for the same reason `listPages` does — the parsed value is
1875
+ // what `$input()` policy markers resolve against.
1876
+ input: (raw): { contentType?: string; locale?: string } => {
1877
+ const o = asObj(raw);
1878
+ const out: Record<string, unknown> = { ...o };
1879
+ for (const k of ["contentType", "locale"] as const) {
1880
+ if (o[k] === undefined || o[k] === null) { delete out[k]; continue; }
1881
+ if (typeof o[k] !== "string") throw new BadRequest(`${k} must be a string`);
1882
+ }
1883
+ return out as never;
1884
+ },
1792
1885
  }),
1793
1886
 
1794
1887
  /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
@@ -1990,7 +2083,13 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1990
2083
  * a client flag can hide a control but cannot make the data right, and the two drift
1991
2084
  * the moment someone adds a locale. `multilingual` is the derived answer to the only
1992
2085
  * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1993
- listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1 }), viewer),
2086
+ /** What this deployment supports. `pagesByType` is the editor's licence to give each
2087
+ * content type its own tab and its own list: an OLDER server ignores the `contentType`
2088
+ * argument entirely and answers with the pooled list, so an editor that assumed the
2089
+ * feature would render N tabs all showing every type's pages under a heading claiming
2090
+ * otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
2091
+ * inferred: fail closed on the pooled list rather than open on N lying ones. */
2092
+ listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1, pagesByType: true as const }), viewer),
1994
2093
 
1995
2094
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1996
2095
  * store — not configuration. `listCmsCapabilities().locales` is what the deployment