@bobbykim/manguito-cms-api 0.4.2 → 0.5.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.
package/dist/index.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  import {
2
2
  buildRelationsMap,
3
3
  createFieldKeyMap,
4
+ createFieldKeyMapFromProjection,
4
5
  deleteParagraphField,
5
6
  isColumnBacked,
6
7
  persistJunctionField,
7
8
  persistParagraphField,
8
9
  resolveRelationBareIds,
9
10
  resolveRelationField
10
- } from "./chunk-4Y4G564P.mjs";
11
+ } from "./chunk-D5AO4BTL.mjs";
11
12
 
12
13
  // src/paths.ts
13
14
  function normalizePrefix(prefix) {
@@ -18,15 +19,20 @@ function normalizePrefix(prefix) {
18
19
  }
19
20
  function createPublicPaths(prefix) {
20
21
  return {
21
- collection: (basePath) => `${prefix}/${basePath}`,
22
- item: (basePath) => `${prefix}/${basePath}/:slug`,
23
- taxonomyCollection: (typeName) => `${prefix}/taxonomy/${typeName}`,
24
- taxonomyItem: (typeName) => `${prefix}/taxonomy/${typeName}/:id`,
25
22
  mediaCollection: () => `${prefix}/media`,
26
23
  mediaItem: () => `${prefix}/media/:id`,
27
24
  openapi: () => `${prefix}/openapi.json`
28
25
  };
29
26
  }
27
+ function createVersionedPaths(prefix, version) {
28
+ const root = version === null ? prefix : `${prefix}/${version}`;
29
+ return {
30
+ collection: (basePath) => `${root}/${basePath}`,
31
+ item: (basePath) => `${root}/${basePath}/:slug`,
32
+ taxonomyCollection: (typeName) => `${root}/taxonomy/${typeName}`,
33
+ taxonomyItem: (typeName) => `${root}/taxonomy/${typeName}/:id`
34
+ };
35
+ }
30
36
 
31
37
  // src/app.ts
32
38
  import { Hono } from "hono";
@@ -622,7 +628,7 @@ function nestedTargets(fields) {
622
628
  }
623
629
  return out;
624
630
  }
625
- function buildProjectors(registry, fieldKeyMaps) {
631
+ function buildProjectors(registry, fieldKeyMaps, fallbacks) {
626
632
  const projectors = {};
627
633
  const sources = [
628
634
  registry.content_types,
@@ -633,7 +639,14 @@ function buildProjectors(registry, fieldKeyMaps) {
633
639
  for (const [typeName, type] of Object.entries(source)) {
634
640
  const map = fieldKeyMaps[typeName];
635
641
  if (!map) continue;
636
- projectors[typeName] = { map, nested: nestedTargets(type.fields) };
642
+ const typeFallbacks = fallbacks?.[typeName];
643
+ projectors[typeName] = {
644
+ map,
645
+ nested: nestedTargets(type.fields),
646
+ // Spread rather than assign: with exactOptionalPropertyTypes, an optional
647
+ // property may be ABSENT but not present-and-undefined.
648
+ ...typeFallbacks !== void 0 && { fallbacks: typeFallbacks }
649
+ };
637
650
  }
638
651
  }
639
652
  return projectors;
@@ -642,6 +655,11 @@ function projectRow(row, typeName, projectors) {
642
655
  const p = projectors[typeName];
643
656
  if (!p) return row;
644
657
  const out = p.map.toLabels(row);
658
+ if (p.fallbacks !== void 0) {
659
+ for (const [label, value] of Object.entries(p.fallbacks)) {
660
+ if (out[label] === null || out[label] === void 0) out[label] = value;
661
+ }
662
+ }
645
663
  for (const { label, target } of p.nested) {
646
664
  const v = out[label];
647
665
  if (v === null || v === void 0) continue;
@@ -735,13 +753,24 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
735
753
  const basePath = contentType.default_base_path;
736
754
  const repo = repos[typeName];
737
755
  if (!repo) continue;
738
- const relationFieldNames = new Set(
739
- contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
740
- );
756
+ const fieldKeys = projectors[typeName].map;
757
+ const relationFieldNames = /* @__PURE__ */ new Set();
758
+ for (const f of contentType.fields) {
759
+ if (!RELATION_FIELD_TYPES.has(f.field_type)) continue;
760
+ if (isColumnBacked(f)) {
761
+ const label = fieldKeys.labelFor(f.db_column.column_name);
762
+ if (label !== void 0) relationFieldNames.add(label);
763
+ } else {
764
+ relationFieldNames.add(f.name);
765
+ }
766
+ }
741
767
  const filterableFieldNames = /* @__PURE__ */ new Set([
742
- ...contentType.fields.filter((f) => f.field_type !== "programmatic").map((f) => f.name),
768
+ ...fieldKeys.labels,
743
769
  ...contentType.system_fields.map((f) => f.name)
744
770
  ]);
771
+ const sortableFieldNames = new Set(
772
+ [...SORTABLE_FIELDS].filter((f) => filterableFieldNames.has(f))
773
+ );
745
774
  if (contentType.only_one) {
746
775
  app.get(paths.collection(basePath), async (c) => {
747
776
  const result = await repo.findMany({ published_only: true, page: 1, per_page: 1 });
@@ -757,8 +786,6 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
757
786
  });
758
787
  } else {
759
788
  registerListRoute(paths.collection(basePath), async (c) => {
760
- const projector = projectors[typeName];
761
- const fieldKeys = projector.map;
762
789
  const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
763
790
  if (!pagination.ok) {
764
791
  return c.json(
@@ -773,13 +800,13 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
773
800
  );
774
801
  }
775
802
  const sortBy = c.req.query("sort_by") ?? "created_at";
776
- if (!SORTABLE_FIELDS.has(sortBy)) {
803
+ if (!sortableFieldNames.has(sortBy)) {
777
804
  return c.json(
778
805
  {
779
806
  ok: false,
780
807
  error: {
781
808
  code: "INVALID_SORT_FIELD",
782
- message: `'${sortBy}' is not sortable. Allowed: title, created_at, updated_at`
809
+ message: `'${sortBy}' is not sortable. Allowed: ${[...sortableFieldNames].join(", ")}`
783
810
  }
784
811
  },
785
812
  400
@@ -826,7 +853,7 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
826
853
  );
827
854
  }
828
855
  }
829
- const sortColumn = fieldKeys.columnFor(sortBy) ?? sortBy;
856
+ const sortColumn = fieldKeys.labels.includes(sortBy) ? fieldKeys.columnFor(sortBy) : sortBy;
830
857
  const result = await repo.findMany({
831
858
  published_only: true,
832
859
  page: pagination.page,
@@ -2706,7 +2733,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2706
2733
  import { sql as sql5 } from "drizzle-orm";
2707
2734
  var API_VERSION = (() => {
2708
2735
  try {
2709
- return "0.4.2";
2736
+ return "0.5.0";
2710
2737
  } catch {
2711
2738
  return "0.0.0";
2712
2739
  }
@@ -3107,7 +3134,93 @@ function createMediaRepository(db) {
3107
3134
  };
3108
3135
  }
3109
3136
 
3137
+ // src/versions.ts
3138
+ var VERSION_SEGMENT = /^v\d+$/;
3139
+ function versionNumber(version) {
3140
+ return Number.parseInt(version.slice(1), 10);
3141
+ }
3142
+ function classifyVersion(segment, model) {
3143
+ if (!VERSION_SEGMENT.test(segment)) return "not-a-version";
3144
+ if (model.live.includes(segment)) return "live";
3145
+ return versionNumber(segment) < versionNumber(model.current) ? "retired" : "unknown";
3146
+ }
3147
+ function fallbacksFor(projection) {
3148
+ const out = {};
3149
+ for (const [typeName, type] of Object.entries(projection.types)) {
3150
+ const perLabel = {};
3151
+ for (const f of type.fields) {
3152
+ if (f.fallback !== void 0) perLabel[f.exposed_as] = f.fallback;
3153
+ }
3154
+ if (Object.keys(perLabel).length > 0) out[typeName] = perLabel;
3155
+ }
3156
+ return out;
3157
+ }
3158
+ function buildVersionSurface(input) {
3159
+ const { version, projectionVersion, prefix, registry, model, makeRepo } = input;
3160
+ const paths = createVersionedPaths(prefix, version);
3161
+ const projection = model.projections[projectionVersion];
3162
+ const fieldKeyMaps = {};
3163
+ const sources = [
3164
+ registry.content_types,
3165
+ registry.taxonomy_types,
3166
+ registry.paragraph_types
3167
+ ];
3168
+ for (const source of sources) {
3169
+ for (const [typeName, type] of Object.entries(source)) {
3170
+ const projectionType = projection?.types[typeName];
3171
+ fieldKeyMaps[typeName] = projectionType ? createFieldKeyMapFromProjection(projectionType, type.fields) : createFieldKeyMap(type.fields);
3172
+ }
3173
+ }
3174
+ const fallbacks = projection ? fallbacksFor(projection) : void 0;
3175
+ const projectors = buildProjectors(registry, fieldKeyMaps, fallbacks);
3176
+ const sortableColumnsFor = (typeName) => {
3177
+ const map = fieldKeyMaps[typeName];
3178
+ const type = registry.content_types[typeName] ?? registry.taxonomy_types[typeName];
3179
+ const systemFieldNames = new Set((type?.system_fields ?? []).map((f) => f.name));
3180
+ const out = /* @__PURE__ */ new Set();
3181
+ for (const label of SORTABLE_FIELDS) {
3182
+ const column = map.columnFor(label);
3183
+ if (column !== void 0) out.add(column);
3184
+ else if (systemFieldNames.has(label)) out.add(label);
3185
+ }
3186
+ return out;
3187
+ };
3188
+ const repos = {};
3189
+ for (const [typeName, ct] of Object.entries(registry.content_types)) {
3190
+ repos[typeName] = makeRepo(typeName, ct.db.table_name, sortableColumnsFor(typeName));
3191
+ }
3192
+ for (const [typeName, tt] of Object.entries(registry.taxonomy_types)) {
3193
+ repos[typeName] = makeRepo(typeName, tt.db.table_name, sortableColumnsFor(typeName));
3194
+ }
3195
+ return { paths, fieldKeyMaps, projectors, repos };
3196
+ }
3197
+ function deprecationHeaders(input) {
3198
+ const { requested, model, successor } = input;
3199
+ const link = `<${successor}>; rel="successor-version"`;
3200
+ if (requested === null) {
3201
+ if (model.live.length <= 1) return null;
3202
+ return {
3203
+ Deprecation: "true",
3204
+ Link: link,
3205
+ Warning: `299 - "Unversioned path resolves to the latest version (${model.current}) and will change when a new version is cut. Pin a version."`
3206
+ };
3207
+ }
3208
+ if (requested === model.current) return null;
3209
+ return { Deprecation: "true", Link: link };
3210
+ }
3211
+
3110
3212
  // src/app.ts
3213
+ function versionRoutePaths(registry, paths) {
3214
+ const out = [paths.collection("content"), paths.collection("taxonomy")];
3215
+ for (const ct of Object.values(registry.content_types)) {
3216
+ out.push(paths.collection(ct.default_base_path));
3217
+ if (!ct.only_one) out.push(paths.item(ct.default_base_path));
3218
+ }
3219
+ for (const tt of Object.values(registry.taxonomy_types)) {
3220
+ out.push(paths.taxonomyCollection(tt.name), paths.taxonomyItem(tt.name));
3221
+ }
3222
+ return out;
3223
+ }
3111
3224
  var MISSING_STORAGE_ERROR = `\u2717 api.storage is required but not configured.
3112
3225
  Add a storage adapter to your manguito.config.ts:
3113
3226
 
@@ -3123,6 +3236,7 @@ function createCmsApp(options) {
3123
3236
  }
3124
3237
  const prefix = normalizePrefix(options.prefix);
3125
3238
  const publicPaths = createPublicPaths(prefix);
3239
+ const versionedPaths = createVersionedPaths(prefix, null);
3126
3240
  const { storage, registry, db, rateLimit, media, cors } = options;
3127
3241
  const cmsName = options.name ?? "Manguito CMS";
3128
3242
  const maxFileSize = media?.max_file_size;
@@ -3171,9 +3285,18 @@ function createCmsApp(options) {
3171
3285
  ])
3172
3286
  ]);
3173
3287
  const projectors = buildProjectors(registry, fieldKeyMaps);
3174
- const sortableColumnsFor = (typeName) => new Set(
3175
- [...SORTABLE_FIELDS].map((label) => fieldKeyMaps[typeName].columnFor(label) ?? label)
3176
- );
3288
+ const sortableColumnsFor = (typeName) => {
3289
+ const map = fieldKeyMaps[typeName];
3290
+ const type = registry.content_types[typeName] ?? registry.taxonomy_types[typeName];
3291
+ const systemFieldNames = new Set((type?.system_fields ?? []).map((f) => f.name));
3292
+ const out = /* @__PURE__ */ new Set();
3293
+ for (const label of SORTABLE_FIELDS) {
3294
+ const column = map.columnFor(label);
3295
+ if (column !== void 0) out.add(column);
3296
+ else if (systemFieldNames.has(label)) out.add(label);
3297
+ }
3298
+ return out;
3299
+ };
3177
3300
  const contentRepos = Object.fromEntries(
3178
3301
  Object.entries(registry.content_types).map(([typeName, ct]) => [
3179
3302
  typeName,
@@ -3192,27 +3315,14 @@ function createCmsApp(options) {
3192
3315
  );
3193
3316
  const repos = { ...contentRepos, ...taxonomyRepos };
3194
3317
  const mediaRepo = createMediaRepository(db);
3195
- const publicContentRepos = Object.fromEntries(
3196
- Object.entries(registry.content_types).map(([typeName, ct]) => [
3197
- typeName,
3198
- createDrizzleContentRepository(db, ct.db.table_name, {
3199
- relations: buildRelationsMap(ct.fields, registry),
3200
- publishedRelations: true,
3201
- sortableColumns: sortableColumnsFor(typeName)
3202
- })
3203
- ])
3204
- );
3205
- const publicTaxonomyRepos = Object.fromEntries(
3206
- Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
3207
- typeName,
3208
- createDrizzleContentRepository(db, tt.db.table_name, {
3209
- relations: buildRelationsMap(tt.fields, registry),
3210
- publishedRelations: true,
3211
- sortableColumns: sortableColumnsFor(typeName)
3212
- })
3213
- ])
3214
- );
3215
- const publicRepos = { ...publicContentRepos, ...publicTaxonomyRepos };
3318
+ const makeRepo = (typeName, tableName, sortableColumns) => {
3319
+ const type = registry.content_types[typeName] ?? registry.taxonomy_types[typeName];
3320
+ return createDrizzleContentRepository(db, tableName, {
3321
+ relations: buildRelationsMap(type.fields, registry),
3322
+ publishedRelations: true,
3323
+ sortableColumns
3324
+ });
3325
+ };
3216
3326
  const graphqlRepos = Object.fromEntries([
3217
3327
  ...Object.entries(registry.content_types).map(([typeName, ct]) => [
3218
3328
  typeName,
@@ -3235,17 +3345,17 @@ function createCmsApp(options) {
3235
3345
  for (const ct of Object.values(registry.content_types)) {
3236
3346
  const base = ct.default_base_path;
3237
3347
  if (ct.only_one) {
3238
- paths[publicPaths.collection(base)] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
3348
+ paths[versionedPaths.collection(base)] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
3239
3349
  } else {
3240
- paths[publicPaths.collection(base)] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
3241
- paths[publicPaths.item(base).replace(":slug", "{slug}")] = {
3350
+ paths[versionedPaths.collection(base)] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
3351
+ paths[versionedPaths.item(base).replace(":slug", "{slug}")] = {
3242
3352
  get: { summary: `Get ${ct.label} by slug`, tags: [ct.label] }
3243
3353
  };
3244
3354
  }
3245
3355
  }
3246
3356
  for (const tt of Object.values(registry.taxonomy_types)) {
3247
- paths[publicPaths.taxonomyCollection(tt.name)] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
3248
- paths[publicPaths.taxonomyItem(tt.name).replace(":id", "{id}")] = {
3357
+ paths[versionedPaths.taxonomyCollection(tt.name)] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
3358
+ paths[versionedPaths.taxonomyItem(tt.name).replace(":id", "{id}")] = {
3249
3359
  get: { summary: `Get ${tt.label} by id`, tags: [tt.label] }
3250
3360
  };
3251
3361
  }
@@ -3265,13 +3375,68 @@ function createCmsApp(options) {
3265
3375
  paths: {}
3266
3376
  })
3267
3377
  );
3268
- registerPublicContentRoutes(app, registry, publicRepos, projectors, publicPaths, listRateLimit, programmaticResolver);
3378
+ const model = options.versions ?? {
3379
+ current: "v1",
3380
+ live: ["v1"],
3381
+ projections: {}
3382
+ };
3383
+ const passes = [
3384
+ ...options.versions !== void 0 ? model.live.map((v) => ({ version: v, projectionVersion: v })) : [],
3385
+ { version: null, projectionVersion: model.current }
3386
+ ];
3387
+ for (const pass of passes) {
3388
+ const surface = buildVersionSurface({ ...pass, prefix, registry, model, makeRepo });
3389
+ const headers = deprecationHeaders({
3390
+ requested: pass.version,
3391
+ model,
3392
+ // The version root, with no trailing slash — never
3393
+ // createVersionedPaths(prefix, model.current).collection(''), which
3394
+ // would yield `${prefix}/${model.current}/` in every Link header.
3395
+ successor: `${prefix}/${model.current}`
3396
+ });
3397
+ if (headers !== null) {
3398
+ const attachDeprecationHeaders = async (c, next) => {
3399
+ await next();
3400
+ for (const [key, value] of Object.entries(headers)) c.res.headers.set(key, value);
3401
+ };
3402
+ for (const path of versionRoutePaths(registry, surface.paths)) {
3403
+ app.use(path, attachDeprecationHeaders);
3404
+ }
3405
+ }
3406
+ registerPublicContentRoutes(app, registry, surface.repos, surface.projectors, surface.paths, listRateLimit, programmaticResolver);
3407
+ }
3408
+ if (options.versions !== void 0) {
3409
+ app.all(`${prefix}/:version/*`, async (c, next) => {
3410
+ const kind = classifyVersion(c.req.param("version"), model);
3411
+ if (kind === "not-a-version" || kind === "live") return next();
3412
+ const live = model.live.join(", ");
3413
+ return kind === "retired" ? c.json(
3414
+ {
3415
+ ok: false,
3416
+ error: {
3417
+ code: "VERSION_RETIRED",
3418
+ message: `Version ${c.req.param("version")} is no longer served. Live versions: ${live}.`
3419
+ }
3420
+ },
3421
+ 410
3422
+ ) : c.json(
3423
+ {
3424
+ ok: false,
3425
+ error: {
3426
+ code: "VERSION_NOT_FOUND",
3427
+ message: `No version ${c.req.param("version")} is served. Live versions: ${live}.`
3428
+ }
3429
+ },
3430
+ 404
3431
+ );
3432
+ });
3433
+ }
3269
3434
  registerPublicMediaRoutes(app, mediaRepo, publicPaths, listRateLimit);
3270
3435
  if (options.graphql?.enabled) {
3271
3436
  const gqlOptions = options.graphql;
3272
3437
  let gqlHandler = null;
3273
3438
  let initError = null;
3274
- const ready = import("./handler-45O5OX2C.mjs").then(({ createGraphQLHandler }) => {
3439
+ const ready = import("./handler-X7JDHJAL.mjs").then(({ createGraphQLHandler }) => {
3275
3440
  gqlHandler = createGraphQLHandler(registry, graphqlRepos, fieldKeyMaps, programmaticResolver, db, gqlOptions);
3276
3441
  }).catch((err) => {
3277
3442
  initError = err;