@bobbykim/manguito-cms-api 0.1.1 → 0.2.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.d.mts CHANGED
@@ -1,7 +1,19 @@
1
- import { StorageAdapter, SchemaRegistry, ResolvedRateLimitConfig, CorsConfig, ServerAdapter, APIAdapter } from '@bobbykim/manguito-cms-core';
1
+ import { ProgrammaticFieldDefinition, SchemaRegistry, StorageAdapter, ResolvedRateLimitConfig, CorsConfig, ServerAdapter, APIAdapter } from '@bobbykim/manguito-cms-core';
2
2
  import { Hono } from 'hono';
3
3
  import { DrizzlePostgresInstance } from '@bobbykim/manguito-cms-db';
4
4
 
5
+ type ResolverMap = Map<string, ProgrammaticFieldDefinition>;
6
+ declare function resolverKey(schema: string, field: string): string;
7
+ declare function validateResolverBindings(registry: SchemaRegistry, resolvers: ResolverMap): void;
8
+ declare function createProgrammaticResolver(resolvers: ResolverMap, options?: {
9
+ onError?: (message: string) => void;
10
+ }): {
11
+ resolveItem: (schema: string, row: Record<string, unknown>) => Promise<Record<string, unknown>>;
12
+ resolveList: (schema: string, rows: Record<string, unknown>[]) => Promise<Record<string, unknown>[]>;
13
+ hasSchema: (schema: string) => boolean;
14
+ };
15
+ type ProgrammaticResolver = ReturnType<typeof createProgrammaticResolver>;
16
+
5
17
  type CreateCmsAppOptions = {
6
18
  /** CMS display name shown in GET /admin/api/config. Defaults to 'Manguito CMS'. */
7
19
  name?: string;
@@ -15,6 +27,8 @@ type CreateCmsAppOptions = {
15
27
  };
16
28
  rateLimit?: ResolvedRateLimitConfig;
17
29
  cors?: CorsConfig;
30
+ /** Programmatic field resolvers, keyed `${schema}::${field}`. */
31
+ resolvers?: ResolverMap;
18
32
  };
19
33
  interface ManguitoCmsAPIAdapter {
20
34
  readonly prefix: string;
@@ -38,4 +52,4 @@ type APIAdapterOptions = {
38
52
  };
39
53
  declare function createAPIAdapter(options?: APIAdapterOptions): APIAdapter;
40
54
 
41
- export { type APIAdapterOptions, type CreateCmsAppOptions, type NodeServerOptions, createAPIAdapter, createCmsApp, createServer };
55
+ export { type APIAdapterOptions, type CreateCmsAppOptions, type NodeServerOptions, type ProgrammaticResolver, type ResolverMap, createAPIAdapter, createCmsApp, createProgrammaticResolver, createServer, resolverKey, validateResolverBindings };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,19 @@
1
- import { StorageAdapter, SchemaRegistry, ResolvedRateLimitConfig, CorsConfig, ServerAdapter, APIAdapter } from '@bobbykim/manguito-cms-core';
1
+ import { ProgrammaticFieldDefinition, SchemaRegistry, StorageAdapter, ResolvedRateLimitConfig, CorsConfig, ServerAdapter, APIAdapter } from '@bobbykim/manguito-cms-core';
2
2
  import { Hono } from 'hono';
3
3
  import { DrizzlePostgresInstance } from '@bobbykim/manguito-cms-db';
4
4
 
5
+ type ResolverMap = Map<string, ProgrammaticFieldDefinition>;
6
+ declare function resolverKey(schema: string, field: string): string;
7
+ declare function validateResolverBindings(registry: SchemaRegistry, resolvers: ResolverMap): void;
8
+ declare function createProgrammaticResolver(resolvers: ResolverMap, options?: {
9
+ onError?: (message: string) => void;
10
+ }): {
11
+ resolveItem: (schema: string, row: Record<string, unknown>) => Promise<Record<string, unknown>>;
12
+ resolveList: (schema: string, rows: Record<string, unknown>[]) => Promise<Record<string, unknown>[]>;
13
+ hasSchema: (schema: string) => boolean;
14
+ };
15
+ type ProgrammaticResolver = ReturnType<typeof createProgrammaticResolver>;
16
+
5
17
  type CreateCmsAppOptions = {
6
18
  /** CMS display name shown in GET /admin/api/config. Defaults to 'Manguito CMS'. */
7
19
  name?: string;
@@ -15,6 +27,8 @@ type CreateCmsAppOptions = {
15
27
  };
16
28
  rateLimit?: ResolvedRateLimitConfig;
17
29
  cors?: CorsConfig;
30
+ /** Programmatic field resolvers, keyed `${schema}::${field}`. */
31
+ resolvers?: ResolverMap;
18
32
  };
19
33
  interface ManguitoCmsAPIAdapter {
20
34
  readonly prefix: string;
@@ -38,4 +52,4 @@ type APIAdapterOptions = {
38
52
  };
39
53
  declare function createAPIAdapter(options?: APIAdapterOptions): APIAdapter;
40
54
 
41
- export { type APIAdapterOptions, type CreateCmsAppOptions, type NodeServerOptions, createAPIAdapter, createCmsApp, createServer };
55
+ export { type APIAdapterOptions, type CreateCmsAppOptions, type NodeServerOptions, type ProgrammaticResolver, type ResolverMap, createAPIAdapter, createCmsApp, createProgrammaticResolver, createServer, resolverKey, validateResolverBindings };
package/dist/index.js CHANGED
@@ -32,7 +32,10 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  createAPIAdapter: () => createAPIAdapter,
34
34
  createCmsApp: () => createCmsApp,
35
- createServer: () => createServer
35
+ createProgrammaticResolver: () => createProgrammaticResolver,
36
+ createServer: () => createServer,
37
+ resolverKey: () => resolverKey,
38
+ validateResolverBindings: () => validateResolverBindings
36
39
  });
37
40
  module.exports = __toCommonJS(index_exports);
38
41
 
@@ -463,6 +466,140 @@ function buildRolesRegistry(roles) {
463
466
  return Object.fromEntries(roles.map((r) => [r.name, r]));
464
467
  }
465
468
 
469
+ // src/programmatic/resolve.ts
470
+ var DEFAULT_TIMEOUT_MS = 5e3;
471
+ var LIST_CONCURRENCY = 10;
472
+ function resolverKey(schema, field) {
473
+ return `${schema}::${field}`;
474
+ }
475
+ function validateResolverBindings(registry, resolvers) {
476
+ const declared = /* @__PURE__ */ new Set();
477
+ const schemas = [
478
+ ...Object.values(registry.content_types),
479
+ ...Object.values(registry.taxonomy_types)
480
+ ];
481
+ for (const schema of schemas) {
482
+ for (const field of schema.fields) {
483
+ if (field.field_type === "programmatic") {
484
+ declared.add(resolverKey(schema.name, field.name));
485
+ }
486
+ }
487
+ }
488
+ const missing = [];
489
+ for (const key of declared) if (!resolvers.has(key)) missing.push(key);
490
+ const orphans = [];
491
+ for (const key of resolvers.keys()) if (!declared.has(key)) orphans.push(key);
492
+ if (missing.length === 0 && orphans.length === 0) return;
493
+ const lines = ["\u2717 Programmatic field resolver bindings are invalid."];
494
+ if (missing.length > 0) {
495
+ lines.push("", ' Declared as `type: "programmatic"` but no resolver found:');
496
+ for (const k of missing) lines.push(` - ${k} (add a programmaticField in src/programmatic)`);
497
+ }
498
+ if (orphans.length > 0) {
499
+ lines.push("", " Resolver has no matching programmatic field in the schema:");
500
+ for (const k of orphans) lines.push(` - ${k}`);
501
+ }
502
+ lines.push("", "Exiting.");
503
+ throw new Error(lines.join("\n"));
504
+ }
505
+ function buildContext(row) {
506
+ return {
507
+ get: (name) => row[name],
508
+ record: row
509
+ };
510
+ }
511
+ async function withTimeout(p, ms) {
512
+ let timer;
513
+ const timeout = new Promise((_, reject) => {
514
+ timer = setTimeout(() => reject(new Error("resolver timeout")), ms);
515
+ });
516
+ try {
517
+ return await Promise.race([p, timeout]);
518
+ } finally {
519
+ clearTimeout(timer);
520
+ }
521
+ }
522
+ async function runOne(def, row, onError) {
523
+ const fallback = def.fallback ?? null;
524
+ try {
525
+ const result = await withTimeout(
526
+ Promise.resolve(def.resolve(buildContext(row))),
527
+ def.timeout ?? DEFAULT_TIMEOUT_MS
528
+ );
529
+ return { value: result === void 0 ? null : result, failed: false };
530
+ } catch (err) {
531
+ onError(
532
+ `\u26A0 programmatic field ${resolverKey(def.schema, def.field)} failed: ${err instanceof Error ? err.message : String(err)}`
533
+ );
534
+ return { value: fallback, failed: true };
535
+ }
536
+ }
537
+ async function runPool(items, limit, fn) {
538
+ let i = 0;
539
+ async function worker() {
540
+ while (i < items.length) {
541
+ const idx = i++;
542
+ await fn(items[idx]);
543
+ }
544
+ }
545
+ const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
546
+ await Promise.all(workers);
547
+ }
548
+ function createProgrammaticResolver(resolvers, options) {
549
+ const onError = options?.onError ?? ((message) => {
550
+ process.stderr.write(message + "\n");
551
+ });
552
+ const bySchema = /* @__PURE__ */ new Map();
553
+ for (const def of resolvers.values()) {
554
+ const arr = bySchema.get(def.schema) ?? [];
555
+ arr.push(def);
556
+ bySchema.set(def.schema, arr);
557
+ }
558
+ const cache = /* @__PURE__ */ new Map();
559
+ async function resolveField(def, row) {
560
+ const itemId = row["id"] !== void 0 ? String(row["id"]) : "";
561
+ if (def.cache && itemId) {
562
+ const key = `${resolverKey(def.schema, def.field)}::${itemId}`;
563
+ const now = Date.now();
564
+ const hit = cache.get(key);
565
+ if (hit && hit.expires > now) return hit.value;
566
+ const { value: value2, failed } = await runOne(def, row, onError);
567
+ if (!failed) cache.set(key, { value: value2, expires: now + def.cache.ttl * 1e3 });
568
+ return value2;
569
+ }
570
+ const { value } = await runOne(def, row, onError);
571
+ return value;
572
+ }
573
+ async function resolveItem(schema, row) {
574
+ const defs = bySchema.get(schema);
575
+ if (!defs || defs.length === 0) return { ...row };
576
+ const out = { ...row };
577
+ await Promise.all(
578
+ defs.map(async (def) => {
579
+ out[def.field] = await resolveField(def, row);
580
+ })
581
+ );
582
+ return out;
583
+ }
584
+ async function resolveList(schema, rows) {
585
+ const defs = (bySchema.get(schema) ?? []).filter((d) => d.on_list === true);
586
+ if (defs.length === 0) return rows.map((r) => ({ ...r }));
587
+ const out = rows.map((r) => ({ ...r }));
588
+ const tasks = [];
589
+ for (let i = 0; i < rows.length; i++) {
590
+ for (const def of defs) tasks.push({ rowIndex: i, def });
591
+ }
592
+ await runPool(tasks, LIST_CONCURRENCY, async ({ rowIndex, def }) => {
593
+ out[rowIndex][def.field] = await resolveField(def, rows[rowIndex]);
594
+ });
595
+ return out;
596
+ }
597
+ function hasSchema(schema) {
598
+ return bySchema.has(schema);
599
+ }
600
+ return { resolveItem, resolveList, hasSchema };
601
+ }
602
+
466
603
  // src/routes/query-params.ts
467
604
  var SORTABLE_FIELDS = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
468
605
  var RELATION_FIELD_TYPES = /* @__PURE__ */ new Set([
@@ -514,7 +651,7 @@ function parseFilters(url, validFields) {
514
651
  function isPublished(item) {
515
652
  return item["published"] === true;
516
653
  }
517
- function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
654
+ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolver) {
518
655
  function registerListRoute(path, handler) {
519
656
  if (listRateLimit) {
520
657
  app.get(path, listRateLimit, handler);
@@ -541,13 +678,13 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
541
678
  const basePath = contentType.default_base_path;
542
679
  const repo = repos[typeName];
543
680
  if (!repo) continue;
544
- const schemaFieldNames = /* @__PURE__ */ new Set([
545
- ...contentType.fields.map((f) => f.name),
546
- ...contentType.system_fields.map((f) => f.name)
547
- ]);
548
681
  const relationFieldNames = new Set(
549
682
  contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
550
683
  );
684
+ const filterableFieldNames = /* @__PURE__ */ new Set([
685
+ ...contentType.fields.filter((f) => f.field_type !== "programmatic").map((f) => f.name),
686
+ ...contentType.system_fields.map((f) => f.name)
687
+ ]);
551
688
  if (contentType.only_one) {
552
689
  app.get(`/api/${basePath}`, async (c) => {
553
690
  const result = await repo.findMany({ published_only: true, page: 1, per_page: 1 });
@@ -557,7 +694,9 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
557
694
  404
558
695
  );
559
696
  }
560
- return c.json({ ok: true, data: result.data[0] });
697
+ let data = result.data[0];
698
+ if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
699
+ return c.json({ ok: true, data });
561
700
  });
562
701
  } else {
563
702
  registerListRoute(`/api/${basePath}`, async (c) => {
@@ -600,7 +739,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
600
739
  400
601
740
  );
602
741
  }
603
- const filtersResult = parseFilters(c.req.url, schemaFieldNames);
742
+ const filtersResult = parseFilters(c.req.url, filterableFieldNames);
604
743
  if (!filtersResult.ok) {
605
744
  return c.json(
606
745
  {
@@ -637,6 +776,10 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
637
776
  filters: filtersResult.filters,
638
777
  include
639
778
  });
779
+ if (resolver?.hasSchema(typeName)) {
780
+ const resolved = await resolver.resolveList(typeName, result.data);
781
+ return c.json({ ...result, data: resolved });
782
+ }
640
783
  return c.json(result);
641
784
  });
642
785
  app.get(`/api/${basePath}/:slug`, async (c) => {
@@ -666,7 +809,9 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
666
809
  404
667
810
  );
668
811
  }
669
- return c.json({ ok: true, data: item });
812
+ let data = item;
813
+ if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
814
+ return c.json({ ok: true, data });
670
815
  });
671
816
  }
672
817
  }
@@ -692,6 +837,10 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
692
837
  page: pagination.page,
693
838
  per_page: pagination.per_page
694
839
  });
840
+ if (resolver?.hasSchema(typeName)) {
841
+ const resolved = await resolver.resolveList(typeName, result.data);
842
+ return c.json({ ...result, data: resolved });
843
+ }
695
844
  return c.json(result);
696
845
  });
697
846
  app.get(`/api/taxonomy/${typeName}/:id`, async (c) => {
@@ -706,7 +855,9 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
706
855
  404
707
856
  );
708
857
  }
709
- return c.json({ ok: true, data: item });
858
+ let data = item;
859
+ if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
860
+ return c.json({ ok: true, data });
710
861
  });
711
862
  }
712
863
  }
@@ -2662,7 +2813,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2662
2813
  var import_drizzle_orm6 = require("drizzle-orm");
2663
2814
  var API_VERSION = (() => {
2664
2815
  try {
2665
- return "0.1.1";
2816
+ return "0.2.0";
2666
2817
  } catch {
2667
2818
  return "0.0.0";
2668
2819
  }
@@ -3078,6 +3229,9 @@ function createCmsApp(options) {
3078
3229
  const cmsName = options.name ?? "Manguito CMS";
3079
3230
  const maxFileSize = media?.max_file_size;
3080
3231
  const rolesRegistry = buildRolesRegistry(registry.roles.roles);
3232
+ const resolverMap = options.resolvers ?? /* @__PURE__ */ new Map();
3233
+ validateResolverBindings(registry, resolverMap);
3234
+ const programmaticResolver = createProgrammaticResolver(resolverMap);
3081
3235
  const app = new import_hono.Hono();
3082
3236
  const uploadOrigins = storage.getUploadOrigins?.() ?? [];
3083
3237
  app.use("*", createSecurityHeadersMiddleware({ connectSrc: uploadOrigins }));
@@ -3162,7 +3316,7 @@ function createCmsApp(options) {
3162
3316
  paths: {}
3163
3317
  })
3164
3318
  );
3165
- registerPublicContentRoutes(app, registry, publicRepos, listRateLimit);
3319
+ registerPublicContentRoutes(app, registry, publicRepos, listRateLimit, programmaticResolver);
3166
3320
  registerPublicMediaRoutes(app, mediaRepo, listRateLimit);
3167
3321
  registerConfigRoute(
3168
3322
  app,
@@ -3223,6 +3377,9 @@ function createAPIAdapter(options = {}) {
3223
3377
  0 && (module.exports = {
3224
3378
  createAPIAdapter,
3225
3379
  createCmsApp,
3226
- createServer
3380
+ createProgrammaticResolver,
3381
+ createServer,
3382
+ resolverKey,
3383
+ validateResolverBindings
3227
3384
  });
3228
3385
  //# sourceMappingURL=index.js.map