@easy-cms/core 0.5.0 → 0.6.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 (3) hide show
  1. package/dist/index.d.ts +170 -10
  2. package/dist/index.js +593 -79
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -82,6 +82,31 @@ var PayloadTooLargeError = class extends EasyCMSError {
82
82
  }
83
83
  };
84
84
 
85
+ // src/fields.ts
86
+ var hasRows = (field) => field.type === "array" || field.type === "blocks";
87
+ function rowFields(field, row) {
88
+ if (field.type === "array") return field.fields;
89
+ const type = row && typeof row === "object" ? row.blockType : void 0;
90
+ return field.blocks.find((block) => block.slug === type)?.fields;
91
+ }
92
+ var FIELD_TYPES = [
93
+ "text",
94
+ "textarea",
95
+ "number",
96
+ "boolean",
97
+ "date",
98
+ "email",
99
+ "json",
100
+ "select",
101
+ "slug",
102
+ "richText",
103
+ "upload",
104
+ "relationship",
105
+ "array",
106
+ "group",
107
+ "blocks"
108
+ ];
109
+
85
110
  // src/access-control.ts
86
111
  async function evaluateAccess(access, args) {
87
112
  const result = await (access ?? isLoggedIn)(args);
@@ -123,11 +148,12 @@ async function stripFields(fields, doc, read) {
123
148
  const value = result[field.name];
124
149
  if (field.type === "group" && value && typeof value === "object") {
125
150
  result[field.name] = await stripFields(field.fields, value, read);
126
- } else if (field.type === "array" && Array.isArray(value)) {
151
+ } else if (hasRows(field) && Array.isArray(value)) {
127
152
  result[field.name] = await Promise.all(
128
- value.map(
129
- (row) => row && typeof row === "object" ? stripFields(field.fields, row, read) : row
130
- )
153
+ value.map((row) => {
154
+ const fields2 = rowFields(field, row);
155
+ return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read) : row;
156
+ })
131
157
  );
132
158
  }
133
159
  }
@@ -144,11 +170,12 @@ async function filterInput(fields, data, update) {
144
170
  const value = result[field.name];
145
171
  if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
146
172
  result[field.name] = await filterInput(field.fields, value, update);
147
- } else if (field.type === "array" && Array.isArray(value)) {
173
+ } else if (hasRows(field) && Array.isArray(value)) {
148
174
  result[field.name] = await Promise.all(
149
- value.map(
150
- (row) => row && typeof row === "object" ? filterInput(field.fields, row, update) : row
151
- )
175
+ value.map((row) => {
176
+ const fields2 = rowFields(field, row);
177
+ return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update) : row;
178
+ })
152
179
  );
153
180
  }
154
181
  }
@@ -161,10 +188,12 @@ var MEDIA = "media";
161
188
  var SESSIONS = "sessions";
162
189
  var LOGIN_ATTEMPTS = "login-attempts";
163
190
  var VERSIONS = "document-versions";
191
+ var SCHEDULED_JOBS = "scheduled-jobs";
164
192
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
165
193
  SESSIONS,
166
194
  LOGIN_ATTEMPTS,
167
- VERSIONS
195
+ VERSIONS,
196
+ SCHEDULED_JOBS
168
197
  ]);
169
198
  var DEFAULT_ROLES = ["admin", "editor"];
170
199
  var adminOrSelf = ({ user }) => {
@@ -345,6 +374,22 @@ var versionsCollection = {
345
374
  { name: "snapshot", type: "json", required: true }
346
375
  ]
347
376
  };
377
+ var scheduledJobsCollection = {
378
+ slug: SCHEDULED_JOBS,
379
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
380
+ fields: [
381
+ // Collection slug, or `global:<slug>`.
382
+ { name: "parent", type: "text", required: true, index: true },
383
+ // Document id; 0 for globals.
384
+ { name: "doc", type: "number", required: true, index: true },
385
+ { name: "action", type: "text", required: true },
386
+ { name: "runAt", type: "text", required: true, index: true },
387
+ // pending | done | failed
388
+ { name: "state", type: "text", required: true, index: true },
389
+ { name: "error", type: "text" },
390
+ { name: "author", type: "number" }
391
+ ]
392
+ };
348
393
 
349
394
  // src/auth/password.ts
350
395
  import { randomBytes, scrypt, timingSafeEqual } from "crypto";
@@ -654,11 +699,12 @@ function applyDefaults(fields, data, localization = null) {
654
699
  isPlainObject(value) ? value : {},
655
700
  localization
656
701
  );
657
- } else if (field.type === "array") {
702
+ } else if (hasRows(field) && !field.localized) {
658
703
  if (Array.isArray(value)) {
659
- result[field.name] = value.map(
660
- (row) => isPlainObject(row) ? applyDefaults(field.fields, row, localization) : row
661
- );
704
+ result[field.name] = value.map((row) => {
705
+ const fields2 = rowFields(field, row);
706
+ return fields2 && isPlainObject(row) ? applyDefaults(fields2, row, localization) : row;
707
+ });
662
708
  }
663
709
  } else if (field.localized && localization && field.defaultValue !== void 0) {
664
710
  const map = isPlainObject(value) ? value : {};
@@ -681,7 +727,7 @@ function fillMissing(fields, data) {
681
727
  if (field.type === "group") {
682
728
  result[field.name] = fillMissing(field.fields, isPlainObject(value) ? value : {});
683
729
  } else if (value === void 0) {
684
- result[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
730
+ result[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
685
731
  }
686
732
  }
687
733
  return result;
@@ -711,10 +757,11 @@ function generateSlugs(fields, data, localization = null) {
711
757
  }
712
758
  } else if (field.type === "group" && isPlainObject(result[field.name])) {
713
759
  result[field.name] = generateSlugs(field.fields, result[field.name], localization);
714
- } else if (field.type === "array" && Array.isArray(result[field.name])) {
715
- result[field.name] = result[field.name].map(
716
- (row) => isPlainObject(row) ? generateSlugs(field.fields, row, localization) : row
717
- );
760
+ } else if (hasRows(field) && Array.isArray(result[field.name])) {
761
+ result[field.name] = result[field.name].map((row) => {
762
+ const fields2 = rowFields(field, row);
763
+ return fields2 && isPlainObject(row) ? generateSlugs(fields2, row, localization) : row;
764
+ });
718
765
  }
719
766
  }
720
767
  return result;
@@ -779,9 +826,9 @@ async function validateFields(fields, input, options, prefix = "") {
779
826
  const localePath = own ? path : `${path}.${locale}`;
780
827
  const failLocale = (message) => errors.push({ field: localePath, message });
781
828
  const value2 = map[locale];
782
- if (isEmpty(value2)) {
829
+ if (isEmpty(value2) || Array.isArray(value2) && value2.length === 0) {
783
830
  if (own && field.required && !options.skipRequired) failLocale("is required");
784
- out[locale] = null;
831
+ out[locale] = hasRows(field) || isHasMany(field) ? [] : null;
785
832
  if (own && !field.required) await runCustom(field, null, options, failLocale);
786
833
  continue;
787
834
  }
@@ -803,7 +850,7 @@ async function validateFields(fields, input, options, prefix = "") {
803
850
  }
804
851
  if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
805
852
  if (field.required && !options.skipRequired) fail("is required");
806
- data[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
853
+ data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
807
854
  if (!field.required) await runCustom(field, null, options, fail);
808
855
  continue;
809
856
  }
@@ -905,10 +952,36 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
905
952
  }
906
953
  return rows;
907
954
  }
955
+ case "blocks":
956
+ return normalizeBlocks(field, raw, path, options, fail, errors, references);
908
957
  case "group":
909
958
  return raw;
910
959
  }
911
960
  }
961
+ async function normalizeBlocks(field, raw, path, options, fail, errors, references) {
962
+ if (!Array.isArray(raw)) return fail("must be an array of blocks");
963
+ if (field.minRows !== void 0 && raw.length < field.minRows)
964
+ return fail(`must have at least ${field.minRows} blocks`);
965
+ if (field.maxRows !== void 0 && raw.length > field.maxRows)
966
+ return fail(`must have at most ${field.maxRows} blocks`);
967
+ const rows = [];
968
+ for (const [i, row] of raw.entries()) {
969
+ const fields = rowFields(field, row);
970
+ if (!isPlainObject(row) || !fields) {
971
+ errors.push({
972
+ field: `${path}.${i}.blockType`,
973
+ message: `must be one of: ${field.blocks.map((b) => b.slug).join(", ")}`
974
+ });
975
+ continue;
976
+ }
977
+ const sub = await validateFields(fields, row, options, `${path}.${i}.`);
978
+ errors.push(...sub.errors);
979
+ references.push(...sub.references);
980
+ const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
981
+ rows.push({ id, blockType: row.blockType, ...sub.data });
982
+ }
983
+ return rows;
984
+ }
912
985
  function normalizeSelect(field, raw, fail) {
913
986
  const allowed2 = field.options.map((o) => typeof o === "string" ? o : o.value);
914
987
  const values = field.hasMany ? raw : [raw];
@@ -920,29 +993,94 @@ function normalizeSelect(field, raw, fail) {
920
993
  return field.hasMany ? [...new Set(values)] : values[0];
921
994
  }
922
995
 
923
- // src/fields.ts
924
- var FIELD_TYPES = [
925
- "text",
926
- "textarea",
927
- "number",
928
- "boolean",
929
- "date",
930
- "email",
931
- "json",
932
- "select",
933
- "slug",
934
- "richText",
935
- "upload",
936
- "relationship",
937
- "array",
938
- "group"
939
- ];
940
-
941
996
  // src/load-config.ts
942
997
  import { existsSync } from "fs";
943
998
  import { resolve } from "path";
944
999
  import { createJiti } from "jiti";
945
1000
 
1001
+ // src/webhooks.ts
1002
+ import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
1003
+ var WEBHOOK_EVENTS = [
1004
+ "create",
1005
+ "update",
1006
+ "delete",
1007
+ "publish",
1008
+ "unpublish",
1009
+ "draft"
1010
+ ];
1011
+ var RETRY_DELAYS = [1e3, 5e3];
1012
+ var TIMEOUT = 1e4;
1013
+ var Webhooks = class {
1014
+ constructor(hooks, logger, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
1015
+ this.hooks = hooks;
1016
+ this.logger = logger;
1017
+ this.fetcher = fetcher;
1018
+ this.retryDelays = retryDelays;
1019
+ }
1020
+ hooks;
1021
+ logger;
1022
+ fetcher;
1023
+ retryDelays;
1024
+ pending = /* @__PURE__ */ new Set();
1025
+ emit(event, target, doc) {
1026
+ if (this.hooks.length === 0) return;
1027
+ const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
1028
+ for (const hook of this.hooks) {
1029
+ if (!matches(hook, payload)) continue;
1030
+ const delivery = this.deliver(hook, payload).finally(() => this.pending.delete(delivery));
1031
+ this.pending.add(delivery);
1032
+ }
1033
+ }
1034
+ /** Waits for deliveries in progress, e.g. before a serverless function returns. */
1035
+ async flush() {
1036
+ while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
1037
+ }
1038
+ async deliver(hook, payload) {
1039
+ const body = JSON.stringify(payload);
1040
+ const headers = {
1041
+ "content-type": "application/json",
1042
+ "user-agent": "easy-cms-webhooks",
1043
+ "x-easy-cms-event": payload.event,
1044
+ "x-easy-cms-delivery": randomUUID2(),
1045
+ ...hook.headers
1046
+ };
1047
+ if (hook.secret) {
1048
+ headers["x-easy-cms-signature"] = `sha256=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
1049
+ }
1050
+ for (let attempt = 0; ; attempt++) {
1051
+ try {
1052
+ const response = await this.fetcher(hook.url, {
1053
+ method: "POST",
1054
+ headers,
1055
+ body,
1056
+ signal: AbortSignal.timeout(TIMEOUT)
1057
+ });
1058
+ if (response.ok || response.status < 500 && response.status !== 429) {
1059
+ if (!response.ok)
1060
+ this.logger.warn(`Webhook ${hook.url} answered ${response.status} to ${payload.event}`);
1061
+ return;
1062
+ }
1063
+ throw new Error(`status ${response.status}`);
1064
+ } catch (error) {
1065
+ const delay = this.retryDelays[attempt];
1066
+ if (delay === void 0) {
1067
+ this.logger.error(
1068
+ `Webhook ${hook.url} failed for ${payload.event}: ${error.message}`
1069
+ );
1070
+ return;
1071
+ }
1072
+ await new Promise((resolve3) => setTimeout(resolve3, delay));
1073
+ }
1074
+ }
1075
+ }
1076
+ };
1077
+ function matches(hook, payload) {
1078
+ if (hook.events && !hook.events.includes(payload.event)) return false;
1079
+ if (payload.collection !== void 0)
1080
+ return !hook.collections || hook.collections.includes(payload.collection);
1081
+ return !hook.globals || hook.globals.includes(payload.global);
1082
+ }
1083
+
946
1084
  // src/validate-config.ts
947
1085
  var MIN_SECRET_LENGTH = 32;
948
1086
  var BUILTIN_COLLECTIONS = ["users", "media"];
@@ -952,6 +1090,8 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
952
1090
  "sessions",
953
1091
  "login-attempts",
954
1092
  "document-versions",
1093
+ "scheduled-jobs",
1094
+ "jobs",
955
1095
  "migrations",
956
1096
  "access"
957
1097
  ]);
@@ -974,6 +1114,7 @@ function validateConfig(config) {
974
1114
  validateAuth(config, add);
975
1115
  validateCors(config.cors, add);
976
1116
  validateLocalization(config, add);
1117
+ validateWebhooks(config, add);
977
1118
  const collections = asArray(config.collections, "collections", add);
978
1119
  const globals = asArray(config.globals, "globals", add);
979
1120
  const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
@@ -1063,7 +1204,9 @@ var LOCALIZABLE = /* @__PURE__ */ new Set([
1063
1204
  "json",
1064
1205
  "select",
1065
1206
  "upload",
1066
- "relationship"
1207
+ "relationship",
1208
+ "blocks",
1209
+ "array"
1067
1210
  ]);
1068
1211
  function validateLocalization(config, add) {
1069
1212
  const localization = config.localization;
@@ -1090,7 +1233,7 @@ function validateLocalization(config, add) {
1090
1233
  }
1091
1234
  }
1092
1235
  }
1093
- const walk = (fields, path) => {
1236
+ const walk = (fields, path, insideLocalized = false) => {
1094
1237
  if (!Array.isArray(fields)) return;
1095
1238
  for (const field of fields) {
1096
1239
  if (typeof field?.name !== "string") continue;
@@ -1108,18 +1251,52 @@ function validateLocalization(config, add) {
1108
1251
  `${field.type} fields cannot be localized`,
1109
1252
  "localize the fields inside it instead"
1110
1253
  );
1111
- } else if ("hasMany" in field && field.hasMany) {
1112
- add(`${fieldPath}.localized`, "hasMany fields cannot be localized yet");
1254
+ } else if (insideLocalized) {
1255
+ add(
1256
+ `${fieldPath}.localized`,
1257
+ "is inside a localized field, which already holds one value per locale"
1258
+ );
1113
1259
  }
1114
1260
  }
1261
+ const inside = insideLocalized || field.localized === true;
1115
1262
  if (field.type === "group" || field.type === "array")
1116
- walk(field.fields, `${fieldPath}.fields`);
1263
+ walk(field.fields, `${fieldPath}.fields`, inside);
1264
+ if (field.type === "blocks")
1265
+ for (const block of field.blocks ?? [])
1266
+ walk(block?.fields, `${fieldPath}.blocks.${block?.slug}`, inside);
1117
1267
  }
1118
1268
  };
1119
1269
  for (const [i, c] of (config.collections ?? []).entries())
1120
1270
  walk(c?.fields, `collections[${i}].fields`);
1121
1271
  for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
1122
1272
  }
1273
+ function validateWebhooks(config, add) {
1274
+ const hooks = config.webhooks;
1275
+ if (hooks === void 0) return;
1276
+ if (!Array.isArray(hooks)) {
1277
+ add("webhooks", "must be a list", "webhooks: [{ url: 'https://example.com/hook' }]");
1278
+ return;
1279
+ }
1280
+ const collections = /* @__PURE__ */ new Set([
1281
+ ...BUILTIN_COLLECTIONS,
1282
+ ...(config.collections ?? []).map((c) => c?.slug)
1283
+ ]);
1284
+ const globals = new Set((config.globals ?? []).map((g) => g?.slug));
1285
+ for (const [i, hook] of hooks.entries()) {
1286
+ const path = `webhooks[${i}]`;
1287
+ if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
1288
+ add(`${path}.url`, "must be an http(s) URL");
1289
+ }
1290
+ for (const event of hook?.events ?? []) {
1291
+ if (!WEBHOOK_EVENTS.includes(event))
1292
+ add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
1293
+ }
1294
+ for (const slug of hook?.collections ?? [])
1295
+ if (!collections.has(slug)) add(`${path}.collections`, `unknown collection "${slug}"`);
1296
+ for (const slug of hook?.globals ?? [])
1297
+ if (!globals.has(slug)) add(`${path}.globals`, `unknown global "${slug}"`);
1298
+ }
1299
+ }
1123
1300
  function validateCors(cors, add) {
1124
1301
  if (cors === void 0 || cors === "*") return;
1125
1302
  if (!Array.isArray(cors)) {
@@ -1187,6 +1364,9 @@ function validateContainer(container, path, collectionSlugs, add) {
1187
1364
  add(`${path}.versions.max`, "must be a positive integer");
1188
1365
  }
1189
1366
  }
1367
+ if (container.schedule && !container.drafts) {
1368
+ add(`${path}.schedule`, "needs `drafts: true`", "scheduling publishes and unpublishes drafts");
1369
+ }
1190
1370
  if (container.preview !== void 0 && typeof container.preview !== "function") {
1191
1371
  add(
1192
1372
  `${path}.preview`,
@@ -1271,6 +1451,43 @@ function validateField(field, path, siblings, collectionSlugs, add) {
1271
1451
  checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
1272
1452
  validateFields2(field.fields, `${path}.fields`, /* @__PURE__ */ new Set(["id"]), collectionSlugs, add);
1273
1453
  break;
1454
+ case "blocks": {
1455
+ if (!Array.isArray(field.blocks) || field.blocks.length === 0) {
1456
+ add(
1457
+ `${path}.blocks`,
1458
+ "must contain at least one block",
1459
+ "blocks: [{ slug: 'hero', fields: [...] }]"
1460
+ );
1461
+ break;
1462
+ }
1463
+ checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
1464
+ const seen = /* @__PURE__ */ new Set();
1465
+ for (const [i, block] of field.blocks.entries()) {
1466
+ const blockPath = `${path}.blocks[${i}]`;
1467
+ if (typeof block?.slug !== "string" || !SLUG_PATTERN.test(block.slug)) {
1468
+ add(
1469
+ `${blockPath}.slug`,
1470
+ 'must be lowercase letters, digits, "-" or "_", starting with a letter'
1471
+ );
1472
+ } else if (seen.has(block.slug)) {
1473
+ add(`${blockPath}.slug`, `duplicate block "${block.slug}"`);
1474
+ } else {
1475
+ seen.add(block.slug);
1476
+ }
1477
+ if (!Array.isArray(block?.fields)) {
1478
+ add(`${blockPath}.fields`, "must be an array");
1479
+ continue;
1480
+ }
1481
+ validateFields2(
1482
+ block.fields,
1483
+ `${blockPath}.fields`,
1484
+ /* @__PURE__ */ new Set(["id", "blockType"]),
1485
+ collectionSlugs,
1486
+ add
1487
+ );
1488
+ }
1489
+ break;
1490
+ }
1274
1491
  }
1275
1492
  }
1276
1493
  function checkRange(min, max, minName, maxName, path, add) {
@@ -1362,7 +1579,8 @@ async function resolveConfig(input) {
1362
1579
  collections: [
1363
1580
  ...config.collections ?? [],
1364
1581
  ...internalCollections,
1365
- ...usesVersions(config) ? [versionsCollection] : []
1582
+ ...usesVersions(config) ? [versionsCollection] : [],
1583
+ ...usesSchedule(config) ? [scheduledJobsCollection] : []
1366
1584
  ],
1367
1585
  globals: config.globals ?? []
1368
1586
  };
@@ -1372,6 +1590,9 @@ async function resolveConfig(input) {
1372
1590
  function usesVersions(config) {
1373
1591
  return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.versions);
1374
1592
  }
1593
+ function usesSchedule(config) {
1594
+ return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
1595
+ }
1375
1596
 
1376
1597
  // src/load-config.ts
1377
1598
  var CONFIG_FILE_NAMES = [
@@ -1430,16 +1651,20 @@ function pickLocale(fields, data, locale, localization, fallback) {
1430
1651
  const result = { ...data };
1431
1652
  for (const field of fields) {
1432
1653
  const value = result[field.name];
1433
- if (field.type === "group" && isPlainObject2(value)) {
1434
- result[field.name] = pickLocale(field.fields, value, locale, localization, fallback);
1435
- } else if (field.type === "array" && Array.isArray(value)) {
1436
- result[field.name] = value.map(
1437
- (row) => isPlainObject2(row) ? pickLocale(field.fields, row, locale, localization, fallback) : row
1438
- );
1439
- } else if (field.localized) {
1654
+ if (field.localized) {
1440
1655
  const map = isPlainObject2(value) ? value : {};
1441
1656
  const own = map[locale];
1442
- result[field.name] = isEmpty2(own) && fallback ? map[localization.defaultLocale] ?? null : own ?? null;
1657
+ const blank = isEmpty2(own) || Array.isArray(own) && own.length === 0;
1658
+ const picked = blank && fallback ? map[localization.defaultLocale] ?? null : own ?? null;
1659
+ const list = hasRows(field) || "hasMany" in field && field.hasMany === true;
1660
+ result[field.name] = picked === null && list ? [] : picked;
1661
+ } else if (field.type === "group" && isPlainObject2(value)) {
1662
+ result[field.name] = pickLocale(field.fields, value, locale, localization, fallback);
1663
+ } else if (hasRows(field) && Array.isArray(value)) {
1664
+ result[field.name] = value.map((row) => {
1665
+ const fields2 = rowFields(field, row);
1666
+ return fields2 && isPlainObject2(row) ? pickLocale(fields2, row, locale, localization, fallback) : row;
1667
+ });
1443
1668
  }
1444
1669
  }
1445
1670
  return result;
@@ -1450,57 +1675,62 @@ function toLocaleMaps(fields, input, current, locale) {
1450
1675
  if (!Object.hasOwn(input, field.name)) continue;
1451
1676
  const value = input[field.name];
1452
1677
  const previous = current[field.name];
1453
- if (field.type === "group" && isPlainObject2(value)) {
1678
+ if (field.localized) {
1679
+ const map = isPlainObject2(previous) ? previous : {};
1680
+ result[field.name] = { ...map, [locale]: value };
1681
+ } else if (field.type === "group" && isPlainObject2(value)) {
1454
1682
  result[field.name] = toLocaleMaps(
1455
1683
  field.fields,
1456
1684
  value,
1457
1685
  isPlainObject2(previous) ? previous : {},
1458
1686
  locale
1459
1687
  );
1460
- } else if (field.type === "array" && Array.isArray(value)) {
1688
+ } else if (hasRows(field) && Array.isArray(value)) {
1461
1689
  const rows = new Map(
1462
1690
  (Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
1463
1691
  );
1464
- result[field.name] = value.map(
1465
- (row) => isPlainObject2(row) ? toLocaleMaps(field.fields, row, rows.get(String(row.id)) ?? {}, locale) : row
1466
- );
1467
- } else if (field.localized) {
1468
- const map = isPlainObject2(previous) ? previous : {};
1469
- result[field.name] = { ...map, [locale]: value };
1692
+ result[field.name] = value.map((row) => {
1693
+ const fields2 = rowFields(field, row);
1694
+ return fields2 && isPlainObject2(row) ? toLocaleMaps(fields2, row, rows.get(String(row.id)) ?? {}, locale) : row;
1695
+ });
1470
1696
  }
1471
1697
  }
1472
1698
  return result;
1473
1699
  }
1474
- function localizePath(fields, path, locale) {
1700
+ function localizePath(fields, path, locale, locales = []) {
1475
1701
  const segments = path.split(".");
1476
1702
  let list = fields;
1477
1703
  for (let i = 0; i < segments.length; i++) {
1478
1704
  const field = list.find((f) => f.name === segments[i]);
1479
1705
  if (!field) return path;
1706
+ if (field.localized) {
1707
+ const next = segments[i + 1];
1708
+ if (next !== void 0 && locales.includes(next)) return path;
1709
+ return [...segments.slice(0, i + 1), locale, ...segments.slice(i + 1)].join(".");
1710
+ }
1480
1711
  if (field.type === "group" || field.type === "array") {
1481
1712
  list = field.fields;
1482
1713
  continue;
1483
1714
  }
1484
- if (field.localized && i === segments.length - 1) return `${path}.${locale}`;
1485
1715
  return path;
1486
1716
  }
1487
1717
  return path;
1488
1718
  }
1489
- function localizeWhere(fields, where, locale) {
1719
+ function localizeWhere(fields, where, locale, locales = []) {
1490
1720
  if (!where) return where;
1491
1721
  const out = {};
1492
1722
  for (const [key2, value] of Object.entries(where)) {
1493
1723
  if ((key2 === "and" || key2 === "or") && Array.isArray(value)) {
1494
- out[key2] = value.map((w) => localizeWhere(fields, w, locale));
1724
+ out[key2] = value.map((w) => localizeWhere(fields, w, locale, locales));
1495
1725
  } else {
1496
- out[localizePath(fields, key2, locale)] = value;
1726
+ out[localizePath(fields, key2, locale, locales)] = value;
1497
1727
  }
1498
1728
  }
1499
1729
  return out;
1500
1730
  }
1501
- function localizeSort(fields, sort, locale) {
1731
+ function localizeSort(fields, sort, locale, locales = []) {
1502
1732
  return sort.map(
1503
- (entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale)}` : localizePath(fields, entry, locale)
1733
+ (entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale, locales)}` : localizePath(fields, entry, locale, locales)
1504
1734
  );
1505
1735
  }
1506
1736
 
@@ -1644,10 +1874,16 @@ function collectSlots(fields, data, bySlug, slots) {
1644
1874
  if (value === null || value === void 0) continue;
1645
1875
  if (field.type === "group") {
1646
1876
  if (typeof value === "object") collectSlots(field.fields, value, bySlug, slots);
1647
- } else if (field.type === "array") {
1648
- if (Array.isArray(value))
1649
- for (const row of value) collectSlots(field.fields, row, bySlug, slots);
1877
+ } else if (hasRows(field)) {
1878
+ if (Array.isArray(value)) {
1879
+ for (const row of value) {
1880
+ const fields2 = rowFields(field, row);
1881
+ if (fields2 && row && typeof row === "object")
1882
+ collectSlots(fields2, row, bySlug, slots);
1883
+ }
1884
+ }
1650
1885
  } else if (field.type === "relationship" || field.type === "upload") {
1886
+ if (typeof value === "object" && !Array.isArray(value)) continue;
1651
1887
  const collection2 = field.type === "upload" ? "media" : field.to;
1652
1888
  if (!bySlug.has(collection2)) continue;
1653
1889
  if (Array.isArray(value)) {
@@ -1862,8 +2098,19 @@ async function createEasyCMS(config, options = {}) {
1862
2098
  logger,
1863
2099
  interactive: options.interactive ?? false
1864
2100
  });
1865
- return new EasyCMS(resolved2, db, logger, storage);
2101
+ const cms = new EasyCMS(resolved2, db, logger, storage);
2102
+ const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule);
2103
+ if (scheduling && options.scheduler !== false) cms.startScheduler();
2104
+ return cms;
1866
2105
  }
2106
+ var toJob = (row) => ({
2107
+ id: row.id,
2108
+ action: row.action,
2109
+ runAt: String(row.runAt),
2110
+ state: row.state,
2111
+ error: row.error ?? null,
2112
+ author: row.author ?? null
2113
+ });
1867
2114
  var EasyCMS = class {
1868
2115
  config;
1869
2116
  db;
@@ -1873,12 +2120,14 @@ var EasyCMS = class {
1873
2120
  /** Where uploaded files are stored. */
1874
2121
  storage;
1875
2122
  versions;
2123
+ webhooks;
1876
2124
  constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
1877
2125
  this.config = config;
1878
2126
  this.db = db;
1879
2127
  this.logger = logger;
1880
2128
  this.storage = storage;
1881
2129
  this.versions = new VersionStore(db);
2130
+ this.webhooks = new Webhooks(config.webhooks ?? [], logger);
1882
2131
  this.auth = new Auth(this);
1883
2132
  }
1884
2133
  async find(collection2, options = {}) {
@@ -1899,7 +2148,8 @@ var EasyCMS = class {
1899
2148
  const sort = locale === void 0 ? requested : localizeSort(
1900
2149
  config.fields,
1901
2150
  requested,
1902
- locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale
2151
+ locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale,
2152
+ this.config.localization?.locales ?? []
1903
2153
  );
1904
2154
  const result = await this.db.find({ collection: collection2, where, sort, limit, page });
1905
2155
  const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
@@ -2268,6 +2518,7 @@ var EasyCMS = class {
2268
2518
  previousDoc: current,
2269
2519
  operation: "update"
2270
2520
  });
2521
+ this.emit(config, "draft", draft);
2271
2522
  const [out2] = await this.output(config, [draft], guard, {
2272
2523
  ...options,
2273
2524
  draft: true
@@ -2289,6 +2540,7 @@ var EasyCMS = class {
2289
2540
  previousDoc: current,
2290
2541
  operation: "update"
2291
2542
  });
2543
+ this.emit(config, "update", doc, existing.status);
2292
2544
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
2293
2545
  return out;
2294
2546
  }
@@ -2306,11 +2558,16 @@ var EasyCMS = class {
2306
2558
  if (config.slug === USERS) await this.auth.revokeSessions(parsed);
2307
2559
  await this.db.delete({ collection: collection2, id: parsed });
2308
2560
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
2561
+ if (config.schedule) {
2562
+ for (const job of await this.pendingJobs(collection2, parsed))
2563
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
2564
+ }
2309
2565
  await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
2310
2566
  ...base,
2311
2567
  id: parsed,
2312
2568
  doc: existing
2313
2569
  });
2570
+ this.emit(config, "delete", existing);
2314
2571
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
2315
2572
  return out;
2316
2573
  }
@@ -2437,6 +2694,7 @@ var EasyCMS = class {
2437
2694
  previousDoc: current,
2438
2695
  operation: "update"
2439
2696
  });
2697
+ this.emit(config, "draft", draft);
2440
2698
  return this.findGlobal(slug, { ...options, draft: true });
2441
2699
  }
2442
2700
  const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
@@ -2447,6 +2705,7 @@ var EasyCMS = class {
2447
2705
  previousDoc: current,
2448
2706
  operation: "update"
2449
2707
  });
2708
+ this.emit(config, "update", doc, existing.status);
2450
2709
  return this.findGlobal(slug, { ...options, draft: true });
2451
2710
  }
2452
2711
  /**
@@ -2468,9 +2727,177 @@ var EasyCMS = class {
2468
2727
  return { update: await check("update"), delete: await check("delete") };
2469
2728
  }
2470
2729
  /** Closes the database connection. */
2730
+ /**
2731
+ * Waits until webhook deliveries in progress are done. Call it before a serverless function
2732
+ * returns, which may otherwise stop them.
2733
+ */
2734
+ flushWebhooks() {
2735
+ return this.webhooks.flush();
2736
+ }
2471
2737
  async destroy() {
2738
+ if (this.schedulerTimer) clearInterval(this.schedulerTimer);
2739
+ await this.running;
2740
+ await this.webhooks.flush();
2472
2741
  await this.db.destroy();
2473
2742
  }
2743
+ // --- Scheduled publishing --------------------------------------------------
2744
+ schedulerTimer;
2745
+ running = Promise.resolve();
2746
+ /** Runs due jobs every `interval` ms in this process (`createEasyCMS` does this by default). */
2747
+ startScheduler(interval = 6e4) {
2748
+ if (this.schedulerTimer) return;
2749
+ this.schedulerTimer = setInterval(() => {
2750
+ this.running = this.running.then(
2751
+ () => this.runScheduled().catch(
2752
+ (error) => this.logger.error(`Scheduled jobs failed: ${error.message}`)
2753
+ )
2754
+ );
2755
+ }, interval);
2756
+ this.schedulerTimer.unref?.();
2757
+ }
2758
+ /**
2759
+ * Publishes or unpublishes documents whose scheduled time has come. Safe to call often, e.g.
2760
+ * from a cron job (`GET <api>/jobs/run`) where no process keeps running.
2761
+ */
2762
+ async runScheduled(now = /* @__PURE__ */ new Date()) {
2763
+ if (!this.config.collections.some((c) => c.slug === SCHEDULED_JOBS))
2764
+ return { ran: 0, failed: 0 };
2765
+ const due = await this.db.find({
2766
+ collection: SCHEDULED_JOBS,
2767
+ where: {
2768
+ and: [{ state: { equals: "pending" } }, { runAt: { lte: now.toISOString() } }]
2769
+ },
2770
+ sort: ["runAt"],
2771
+ limit: 100,
2772
+ page: 1
2773
+ });
2774
+ let failed = 0;
2775
+ for (const row of due.docs) {
2776
+ const parent = String(row.parent);
2777
+ const action = row.action;
2778
+ try {
2779
+ if (parent.startsWith("global:")) {
2780
+ const slug = parent.slice("global:".length);
2781
+ if (action === "publish") await this.saveGlobal(slug, { status: "published" }, {});
2782
+ else await this.saveGlobal(slug, { status: "draft" }, {}, "unpublish");
2783
+ } else if (action === "publish") {
2784
+ await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
2785
+ } else {
2786
+ await this.updateDocument(
2787
+ parent,
2788
+ row.doc,
2789
+ { status: "draft" },
2790
+ { depth: 0 },
2791
+ "unpublish"
2792
+ );
2793
+ }
2794
+ await this.db.update({
2795
+ collection: SCHEDULED_JOBS,
2796
+ id: row.id,
2797
+ data: { ...row, state: "done" }
2798
+ });
2799
+ } catch (error) {
2800
+ failed++;
2801
+ const message = error.message;
2802
+ this.logger.error(`Scheduled ${action} of ${parent} ${row.doc} failed: ${message}`);
2803
+ await this.db.update({
2804
+ collection: SCHEDULED_JOBS,
2805
+ id: row.id,
2806
+ data: { ...row, state: "failed", error: message.slice(0, 500) }
2807
+ });
2808
+ }
2809
+ }
2810
+ return { ran: due.docs.length - failed, failed };
2811
+ }
2812
+ /** Schedules a publish or unpublish, replacing a pending job with the same action. */
2813
+ async schedule(collection2, id, job, options = {}) {
2814
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2815
+ return this.addJob(collection2, parsed, job, options);
2816
+ }
2817
+ /** Pending jobs of a document, soonest first. */
2818
+ async scheduled(collection2, id, options = {}) {
2819
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2820
+ return this.pendingJobs(collection2, parsed);
2821
+ }
2822
+ async cancelSchedule(collection2, id, jobId, options = {}) {
2823
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2824
+ await this.removeJob(collection2, parsed, jobId);
2825
+ }
2826
+ async scheduleGlobal(slug, job, options = {}) {
2827
+ await this.globalScheduleTarget(slug, options);
2828
+ return this.addJob(`global:${slug}`, 0, job, options);
2829
+ }
2830
+ async scheduledGlobal(slug, options = {}) {
2831
+ await this.globalScheduleTarget(slug, options);
2832
+ return this.pendingJobs(`global:${slug}`, 0);
2833
+ }
2834
+ async cancelGlobalSchedule(slug, jobId, options = {}) {
2835
+ await this.globalScheduleTarget(slug, options);
2836
+ await this.removeJob(`global:${slug}`, 0, jobId);
2837
+ }
2838
+ async scheduleTarget(collection2, id, options) {
2839
+ const config = this.collection(collection2);
2840
+ if (!config.schedule) throw new QueryError(`"${collection2}" has no schedule`);
2841
+ const parsed = parseId(id);
2842
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
2843
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
2844
+ await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
2845
+ return { config, parsed };
2846
+ }
2847
+ async globalScheduleTarget(slug, options) {
2848
+ const config = this.global(slug);
2849
+ if (!config.schedule) throw new QueryError(`"${slug}" has no schedule`);
2850
+ await this.checkGlobalAccess(config, "update", guardOf(options));
2851
+ }
2852
+ async addJob(parent, doc, job, options) {
2853
+ const runAt = new Date(job.at);
2854
+ const errors = [];
2855
+ if (job.action !== "publish" && job.action !== "unpublish")
2856
+ errors.push({ field: "action", message: 'must be "publish" or "unpublish"' });
2857
+ if (Number.isNaN(runAt.getTime())) errors.push({ field: "at", message: "must be a valid date" });
2858
+ if (errors.length > 0) throw new ValidationError(SCHEDULED_JOBS, errors);
2859
+ for (const pending of await this.pendingJobs(parent, doc)) {
2860
+ if (pending.action === job.action)
2861
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: pending.id });
2862
+ }
2863
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2864
+ const row = await this.db.create({
2865
+ collection: SCHEDULED_JOBS,
2866
+ data: {
2867
+ parent,
2868
+ doc,
2869
+ action: job.action,
2870
+ runAt: runAt.toISOString(),
2871
+ state: "pending",
2872
+ error: null,
2873
+ author: guardOf(options).user?.id ?? null,
2874
+ createdAt: now,
2875
+ updatedAt: now
2876
+ }
2877
+ });
2878
+ return toJob(row);
2879
+ }
2880
+ async pendingJobs(parent, doc) {
2881
+ const rows = await this.db.find({
2882
+ collection: SCHEDULED_JOBS,
2883
+ where: {
2884
+ and: [
2885
+ { parent: { equals: parent } },
2886
+ { doc: { equals: doc } },
2887
+ { state: { equals: "pending" } }
2888
+ ]
2889
+ },
2890
+ sort: ["runAt"],
2891
+ limit: 0,
2892
+ page: 1
2893
+ });
2894
+ return rows.docs.map(toJob);
2895
+ }
2896
+ async removeJob(parent, doc, jobId) {
2897
+ const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
2898
+ if (!job) throw new NotFoundError("scheduled job", jobId);
2899
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
2900
+ }
2474
2901
  // -------------------------------------------------------------------------
2475
2902
  /** @internal */
2476
2903
  collection(slug) {
@@ -2499,11 +2926,11 @@ var EasyCMS = class {
2499
2926
  });
2500
2927
  if (access === true) return;
2501
2928
  if (access !== false) {
2502
- const matches = await this.db.count({
2929
+ const matches2 = await this.db.count({
2503
2930
  collection: config.slug,
2504
2931
  where: andWhere(access, { id: { equals: id } })
2505
2932
  });
2506
- if (matches > 0) return;
2933
+ if (matches2 > 0) return;
2507
2934
  }
2508
2935
  throw deny(guard.user);
2509
2936
  }
@@ -2613,6 +3040,7 @@ var EasyCMS = class {
2613
3040
  doc,
2614
3041
  operation: "create"
2615
3042
  });
3043
+ this.emit(config, "create", doc);
2616
3044
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
2617
3045
  return out;
2618
3046
  }
@@ -2650,7 +3078,8 @@ var EasyCMS = class {
2650
3078
  return localizeWhere(
2651
3079
  config.fields,
2652
3080
  where,
2653
- locale === ALL_LOCALES ? localization.defaultLocale : locale
3081
+ locale === ALL_LOCALES ? localization.defaultLocale : locale,
3082
+ localization.locales
2654
3083
  );
2655
3084
  }
2656
3085
  /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
@@ -2667,6 +3096,24 @@ var EasyCMS = class {
2667
3096
  return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
2668
3097
  });
2669
3098
  }
3099
+ /** Sends webhook events for a change; `before` is the stored status before it, if any. */
3100
+ emit(config, event, doc, before) {
3101
+ const isGlobal = !this.config.collections.includes(config);
3102
+ if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
3103
+ const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
3104
+ id: doc.id,
3105
+ ...snapshotOf(config.fields, doc),
3106
+ createdAt: doc.createdAt,
3107
+ updatedAt: doc.updatedAt
3108
+ };
3109
+ const target = isGlobal ? { global: config.slug } : { collection: config.slug, id: doc.id };
3110
+ this.webhooks.emit(event, target, body);
3111
+ if (event === "draft" || event === "delete" || !config.drafts) return;
3112
+ const after = doc.status;
3113
+ if (after === "published" && before !== "published") this.webhooks.emit("publish", target, body);
3114
+ if (after !== "published" && before === "published")
3115
+ this.webhooks.emit("unpublish", target, body);
3116
+ }
2670
3117
  /** Records a version after a save, when the collection or global keeps versions. */
2671
3118
  async saveVersion(config, parent, id, doc, guard) {
2672
3119
  const max = versionLimit(config);
@@ -2951,6 +3398,17 @@ async function serializeFields(fields, update, localized) {
2951
3398
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
2952
3399
  f.fields = await serializeFields(field.fields, update, localized);
2953
3400
  break;
3401
+ case "blocks":
3402
+ if (field.minRows !== void 0) f.minRows = field.minRows;
3403
+ if (field.maxRows !== void 0) f.maxRows = field.maxRows;
3404
+ f.blocks = await Promise.all(
3405
+ field.blocks.map(async (block) => ({
3406
+ slug: block.slug,
3407
+ ...block.labels ? { labels: block.labels } : {},
3408
+ fields: await serializeFields(block.fields, update, localized)
3409
+ }))
3410
+ );
3411
+ break;
2954
3412
  case "group":
2955
3413
  f.fields = await serializeFields(field.fields, update, localized);
2956
3414
  break;
@@ -2965,6 +3423,7 @@ async function collection(config, user, localized) {
2965
3423
  drafts: config.drafts === true,
2966
3424
  versions: Boolean(config.versions),
2967
3425
  preview: typeof config.preview === "function",
3426
+ schedule: config.schedule === true,
2968
3427
  fields: await serializeFields(
2969
3428
  config.fields,
2970
3429
  new FieldAccessChecker("update", { user }),
@@ -2987,6 +3446,7 @@ async function global(config, user, localized) {
2987
3446
  drafts: config.drafts === true,
2988
3447
  versions: Boolean(config.versions),
2989
3448
  preview: typeof config.preview === "function",
3449
+ schedule: config.schedule === true,
2990
3450
  fields: await serializeFields(
2991
3451
  config.fields,
2992
3452
  new FieldAccessChecker("update", { user }),
@@ -3225,6 +3685,16 @@ async function route(cms, ctx, method, segments, options) {
3225
3685
  }
3226
3686
  throw new HttpError("Not found", 404);
3227
3687
  }
3688
+ if (first === "jobs" && second === "run" && third === void 0) {
3689
+ if (method !== "GET" && method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
3690
+ const secret = cms.config.cronSecret ?? process.env.CRON_SECRET;
3691
+ const header = ctx.request.headers.get("authorization") ?? "";
3692
+ const byCron = !!secret && safeEqual(header, `Bearer ${secret}`);
3693
+ if (!byCron && ctx.user?.role !== "admin") {
3694
+ throw ctx.user ? new HttpError("Forbidden", 403) : new UnauthorizedError();
3695
+ }
3696
+ return { body: await cms.runScheduled() };
3697
+ }
3228
3698
  if (first === "admin") {
3229
3699
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
3230
3700
  if (!ctx.user) throw new UnauthorizedError();
@@ -3349,6 +3819,20 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
3349
3819
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3350
3820
  return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
3351
3821
  }
3822
+ if (action === "schedule" && path.length <= 2) {
3823
+ if (path.length === 2) {
3824
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
3825
+ await cms.cancelSchedule(collection2, id, versionId, access);
3826
+ return { body: { message: "Cancelled" } };
3827
+ }
3828
+ if (method === "GET") return { body: await cms.scheduled(collection2, id, access) };
3829
+ if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
3830
+ const body = await readJson(ctx.request);
3831
+ return {
3832
+ status: 201,
3833
+ body: await cms.schedule(collection2, id, scheduleJob(body), access)
3834
+ };
3835
+ }
3352
3836
  if (path.length === 1 && action === "preview") {
3353
3837
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3354
3838
  const body = await readJson(ctx.request);
@@ -3383,6 +3867,17 @@ async function globalAction(cms, ctx, method, slug, path) {
3383
3867
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3384
3868
  return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
3385
3869
  }
3870
+ if (action === "schedule" && path.length <= 2) {
3871
+ if (path.length === 2) {
3872
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
3873
+ await cms.cancelGlobalSchedule(slug, versionId, access);
3874
+ return { body: { message: "Cancelled" } };
3875
+ }
3876
+ if (method === "GET") return { body: await cms.scheduledGlobal(slug, access) };
3877
+ if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
3878
+ const body = await readJson(ctx.request);
3879
+ return { status: 201, body: await cms.scheduleGlobal(slug, scheduleJob(body), access) };
3880
+ }
3386
3881
  if (path.length === 1 && action === "preview") {
3387
3882
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3388
3883
  const body = await readJson(ctx.request);
@@ -3399,6 +3894,12 @@ async function globalAction(cms, ctx, method, slug, path) {
3399
3894
  }
3400
3895
  throw new HttpError("Not found", 404);
3401
3896
  }
3897
+ function scheduleJob(body) {
3898
+ return {
3899
+ action: body.action,
3900
+ at: typeof body.at === "string" ? body.at : ""
3901
+ };
3902
+ }
3402
3903
  function parseLocale(url) {
3403
3904
  const locale = url.searchParams.get("locale");
3404
3905
  const fallback = url.searchParams.get("fallback-locale");
@@ -3416,8 +3917,8 @@ function withPreviewToken(url, token) {
3416
3917
  }
3417
3918
  function requirePreview(cms, ctx, target) {
3418
3919
  const opened = cms.verifyPreviewToken(ctx.url.searchParams.get("preview"));
3419
- const matches = opened !== null && ("global" in target ? "global" in opened && opened.global === target.global : "collection" in opened && opened.collection === target.collection && opened.id === target.id);
3420
- if (!matches) throw new UnauthorizedError("Invalid or expired preview token");
3920
+ const matches2 = opened !== null && ("global" in target ? "global" in opened && opened.global === target.global : "collection" in opened && opened.collection === target.collection && opened.id === target.id);
3921
+ if (!matches2) throw new UnauthorizedError("Invalid or expired preview token");
3421
3922
  }
3422
3923
  function parsePage(url) {
3423
3924
  const { page, limit } = parseListQuery(url);
@@ -3633,6 +4134,16 @@ ${indent}}`;
3633
4134
  ${indent} id: string
3634
4135
  ${fieldLines(field.fields, n, `${indent} `).join("\n")}
3635
4136
  ${indent}}[]`;
4137
+ case "blocks": {
4138
+ const kinds = field.blocks.map(
4139
+ (block) => `{
4140
+ ${indent} id: string
4141
+ ${indent} blockType: ${quote(block.slug)}
4142
+ ${fieldLines(block.fields, n, `${indent} `).join("\n")}
4143
+ ${indent}}`
4144
+ );
4145
+ return `(${kinds.join(" | ")})[]`;
4146
+ }
3636
4147
  }
3637
4148
  }
3638
4149
  function fieldLines(fields, n, indent) {
@@ -3640,7 +4151,7 @@ function fieldLines(fields, n, indent) {
3640
4151
  for (const field of fields) {
3641
4152
  if (field.hidden) continue;
3642
4153
  const type = fieldType(field, n, indent);
3643
- const alwaysPresent = field.type === "array" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
4154
+ const alwaysPresent = field.type === "array" || field.type === "blocks" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
3644
4155
  if (field.required || alwaysPresent) lines.push(`${indent}${key(field.name)}: ${type}`);
3645
4156
  else lines.push(`${indent}${key(field.name)}?: ${type} | null`);
3646
4157
  }
@@ -3748,6 +4259,7 @@ export {
3748
4259
  TooManyRequestsError,
3749
4260
  UnauthorizedError,
3750
4261
  ValidationError,
4262
+ WEBHOOK_EVENTS,
3751
4263
  anyone,
3752
4264
  applyDefaults,
3753
4265
  consoleLogger,
@@ -3758,6 +4270,7 @@ export {
3758
4270
  findConfigFile,
3759
4271
  generateSlugs,
3760
4272
  generateTypes,
4273
+ hasRows,
3761
4274
  hashPassword,
3762
4275
  imageDimensions,
3763
4276
  importConfig,
@@ -3771,6 +4284,7 @@ export {
3771
4284
  populate,
3772
4285
  readCookie,
3773
4286
  resolveConfig,
4287
+ rowFields,
3774
4288
  silentLogger,
3775
4289
  singularize,
3776
4290
  slugify,