@easy-cms/core 0.5.0 → 0.7.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 +193 -10
  2. package/dist/index.js +748 -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,14 @@ 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";
192
+ var WEBHOOK_DELIVERIES = "webhook-deliveries";
164
193
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
165
194
  SESSIONS,
166
195
  LOGIN_ATTEMPTS,
167
- VERSIONS
196
+ VERSIONS,
197
+ SCHEDULED_JOBS,
198
+ WEBHOOK_DELIVERIES
168
199
  ]);
169
200
  var DEFAULT_ROLES = ["admin", "editor"];
170
201
  var adminOrSelf = ({ user }) => {
@@ -345,6 +376,38 @@ var versionsCollection = {
345
376
  { name: "snapshot", type: "json", required: true }
346
377
  ]
347
378
  };
379
+ var scheduledJobsCollection = {
380
+ slug: SCHEDULED_JOBS,
381
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
382
+ fields: [
383
+ // Collection slug, or `global:<slug>`.
384
+ { name: "parent", type: "text", required: true, index: true },
385
+ // Document id; 0 for globals.
386
+ { name: "doc", type: "number", required: true, index: true },
387
+ { name: "action", type: "text", required: true },
388
+ { name: "runAt", type: "text", required: true, index: true },
389
+ // pending | done | failed
390
+ { name: "state", type: "text", required: true, index: true },
391
+ { name: "error", type: "text" },
392
+ { name: "author", type: "number" }
393
+ ]
394
+ };
395
+ var webhookDeliveriesCollection = {
396
+ slug: WEBHOOK_DELIVERIES,
397
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
398
+ fields: [
399
+ { name: "url", type: "text", required: true },
400
+ { name: "event", type: "text", required: true },
401
+ // The JSON body as first sent: retries are byte-for-byte the same, signature included.
402
+ { name: "body", type: "textarea", required: true },
403
+ { name: "delivery", type: "text", required: true },
404
+ { name: "attempts", type: "number", required: true },
405
+ { name: "nextAttemptAt", type: "text", required: true, index: true },
406
+ // pending | failed
407
+ { name: "state", type: "text", required: true, index: true },
408
+ { name: "error", type: "text" }
409
+ ]
410
+ };
348
411
 
349
412
  // src/auth/password.ts
350
413
  import { randomBytes, scrypt, timingSafeEqual } from "crypto";
@@ -654,11 +717,12 @@ function applyDefaults(fields, data, localization = null) {
654
717
  isPlainObject(value) ? value : {},
655
718
  localization
656
719
  );
657
- } else if (field.type === "array") {
720
+ } else if (hasRows(field) && !field.localized) {
658
721
  if (Array.isArray(value)) {
659
- result[field.name] = value.map(
660
- (row) => isPlainObject(row) ? applyDefaults(field.fields, row, localization) : row
661
- );
722
+ result[field.name] = value.map((row) => {
723
+ const fields2 = rowFields(field, row);
724
+ return fields2 && isPlainObject(row) ? applyDefaults(fields2, row, localization) : row;
725
+ });
662
726
  }
663
727
  } else if (field.localized && localization && field.defaultValue !== void 0) {
664
728
  const map = isPlainObject(value) ? value : {};
@@ -681,7 +745,7 @@ function fillMissing(fields, data) {
681
745
  if (field.type === "group") {
682
746
  result[field.name] = fillMissing(field.fields, isPlainObject(value) ? value : {});
683
747
  } else if (value === void 0) {
684
- result[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
748
+ result[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
685
749
  }
686
750
  }
687
751
  return result;
@@ -711,10 +775,11 @@ function generateSlugs(fields, data, localization = null) {
711
775
  }
712
776
  } else if (field.type === "group" && isPlainObject(result[field.name])) {
713
777
  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
- );
778
+ } else if (hasRows(field) && Array.isArray(result[field.name])) {
779
+ result[field.name] = result[field.name].map((row) => {
780
+ const fields2 = rowFields(field, row);
781
+ return fields2 && isPlainObject(row) ? generateSlugs(fields2, row, localization) : row;
782
+ });
718
783
  }
719
784
  }
720
785
  return result;
@@ -779,9 +844,9 @@ async function validateFields(fields, input, options, prefix = "") {
779
844
  const localePath = own ? path : `${path}.${locale}`;
780
845
  const failLocale = (message) => errors.push({ field: localePath, message });
781
846
  const value2 = map[locale];
782
- if (isEmpty(value2)) {
847
+ if (isEmpty(value2) || Array.isArray(value2) && value2.length === 0) {
783
848
  if (own && field.required && !options.skipRequired) failLocale("is required");
784
- out[locale] = null;
849
+ out[locale] = hasRows(field) || isHasMany(field) ? [] : null;
785
850
  if (own && !field.required) await runCustom(field, null, options, failLocale);
786
851
  continue;
787
852
  }
@@ -803,7 +868,7 @@ async function validateFields(fields, input, options, prefix = "") {
803
868
  }
804
869
  if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
805
870
  if (field.required && !options.skipRequired) fail("is required");
806
- data[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
871
+ data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
807
872
  if (!field.required) await runCustom(field, null, options, fail);
808
873
  continue;
809
874
  }
@@ -905,10 +970,36 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
905
970
  }
906
971
  return rows;
907
972
  }
973
+ case "blocks":
974
+ return normalizeBlocks(field, raw, path, options, fail, errors, references);
908
975
  case "group":
909
976
  return raw;
910
977
  }
911
978
  }
979
+ async function normalizeBlocks(field, raw, path, options, fail, errors, references) {
980
+ if (!Array.isArray(raw)) return fail("must be an array of blocks");
981
+ if (field.minRows !== void 0 && raw.length < field.minRows)
982
+ return fail(`must have at least ${field.minRows} blocks`);
983
+ if (field.maxRows !== void 0 && raw.length > field.maxRows)
984
+ return fail(`must have at most ${field.maxRows} blocks`);
985
+ const rows = [];
986
+ for (const [i, row] of raw.entries()) {
987
+ const fields = rowFields(field, row);
988
+ if (!isPlainObject(row) || !fields) {
989
+ errors.push({
990
+ field: `${path}.${i}.blockType`,
991
+ message: `must be one of: ${field.blocks.map((b) => b.slug).join(", ")}`
992
+ });
993
+ continue;
994
+ }
995
+ const sub = await validateFields(fields, row, options, `${path}.${i}.`);
996
+ errors.push(...sub.errors);
997
+ references.push(...sub.references);
998
+ const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
999
+ rows.push({ id, blockType: row.blockType, ...sub.data });
1000
+ }
1001
+ return rows;
1002
+ }
912
1003
  function normalizeSelect(field, raw, fail) {
913
1004
  const allowed2 = field.options.map((o) => typeof o === "string" ? o : o.value);
914
1005
  const values = field.hasMany ? raw : [raw];
@@ -920,29 +1011,172 @@ function normalizeSelect(field, raw, fail) {
920
1011
  return field.hasMany ? [...new Set(values)] : values[0];
921
1012
  }
922
1013
 
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
1014
  // src/load-config.ts
942
1015
  import { existsSync } from "fs";
943
1016
  import { resolve } from "path";
944
1017
  import { createJiti } from "jiti";
945
1018
 
1019
+ // src/webhooks.ts
1020
+ import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
1021
+ var WEBHOOK_EVENTS = [
1022
+ "create",
1023
+ "update",
1024
+ "delete",
1025
+ "publish",
1026
+ "unpublish",
1027
+ "draft"
1028
+ ];
1029
+ var RETRY_DELAYS = [1e3, 5e3];
1030
+ var QUEUE_DELAYS = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
1031
+ var CLAIM = 5 * 6e4;
1032
+ var TIMEOUT = 1e4;
1033
+ var Webhooks = class {
1034
+ constructor(hooks, logger, queue, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
1035
+ this.hooks = hooks;
1036
+ this.logger = logger;
1037
+ this.queue = queue;
1038
+ this.fetcher = fetcher;
1039
+ this.retryDelays = retryDelays;
1040
+ }
1041
+ hooks;
1042
+ logger;
1043
+ queue;
1044
+ fetcher;
1045
+ retryDelays;
1046
+ pending = /* @__PURE__ */ new Set();
1047
+ emit(event, target, doc) {
1048
+ if (this.hooks.length === 0) return;
1049
+ const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
1050
+ for (const hook of this.hooks) {
1051
+ if (!matches(hook, payload)) continue;
1052
+ this.track(this.deliver(hook, payload));
1053
+ }
1054
+ }
1055
+ /** Waits for deliveries in progress, e.g. before a serverless function returns. */
1056
+ async flush() {
1057
+ while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
1058
+ }
1059
+ /**
1060
+ * Tries queued deliveries whose next attempt is due. Runs with scheduled jobs: every minute
1061
+ * in a server, or from the cron endpoint.
1062
+ */
1063
+ async retry(now = /* @__PURE__ */ new Date()) {
1064
+ if (!this.queue) return { sent: 0, failed: 0 };
1065
+ let sent = 0;
1066
+ let failed = 0;
1067
+ for (const entry of await this.queue.due(now, 50)) {
1068
+ const { id, ...queued } = entry;
1069
+ const hook = this.hooks.find((h) => h.url === queued.url);
1070
+ if (!hook) {
1071
+ await this.queue.remove(id);
1072
+ continue;
1073
+ }
1074
+ const attempts = queued.attempts + 1;
1075
+ await this.queue.update(id, { ...queued, attempts, nextAttemptAt: later(now, CLAIM) });
1076
+ const result = await this.attempt(hook, queued.event, queued.body, queued.delivery);
1077
+ if (result.ok) {
1078
+ await this.queue.remove(id);
1079
+ sent++;
1080
+ continue;
1081
+ }
1082
+ const delay = QUEUE_DELAYS[attempts - this.retryDelays.length - 1];
1083
+ if (delay === void 0) failed++;
1084
+ await this.queue.update(id, {
1085
+ ...queued,
1086
+ attempts,
1087
+ nextAttemptAt: later(now, delay ?? 0),
1088
+ state: delay === void 0 ? "failed" : "pending",
1089
+ error: result.error
1090
+ });
1091
+ if (delay === void 0) this.giveUp(hook, queued.event, result.error, attempts);
1092
+ }
1093
+ return { sent, failed };
1094
+ }
1095
+ track(delivery) {
1096
+ const tracked = delivery.catch((error) => this.logger.error(`Webhook queue failed: ${error.message}`)).finally(() => this.pending.delete(tracked));
1097
+ this.pending.add(tracked);
1098
+ }
1099
+ async deliver(hook, payload) {
1100
+ const delivery = randomUUID2();
1101
+ const body = JSON.stringify(payload);
1102
+ const { event } = payload;
1103
+ let queued;
1104
+ for (let attempt = 0; ; attempt++) {
1105
+ const result = await this.attempt(hook, event, body, delivery);
1106
+ if (result.ok) {
1107
+ if (queued !== void 0) await this.queue?.remove(queued);
1108
+ return;
1109
+ }
1110
+ const entry = (nextAttemptAt) => ({
1111
+ url: hook.url,
1112
+ event,
1113
+ body,
1114
+ delivery,
1115
+ attempts: attempt + 1,
1116
+ nextAttemptAt,
1117
+ state: "pending",
1118
+ error: result.error
1119
+ });
1120
+ const delay = this.retryDelays[attempt];
1121
+ if (delay === void 0) {
1122
+ if (!this.queue) return this.giveUp(hook, event, result.error, attempt + 1);
1123
+ const next = entry(later(/* @__PURE__ */ new Date(), QUEUE_DELAYS[0]));
1124
+ if (queued === void 0) await this.queue.add(next);
1125
+ else await this.queue.update(queued, next);
1126
+ this.logger.warn(
1127
+ `Webhook ${hook.url} failed for ${event} (${result.error}); will retry later`
1128
+ );
1129
+ return;
1130
+ }
1131
+ if (this.queue && queued === void 0) {
1132
+ queued = await this.queue.add(entry(later(/* @__PURE__ */ new Date(), CLAIM)));
1133
+ }
1134
+ await new Promise((resolve3) => setTimeout(resolve3, delay));
1135
+ }
1136
+ }
1137
+ /** One POST. Client errors other than rate limiting count as done: retrying won't help. */
1138
+ async attempt(hook, event, body, delivery) {
1139
+ const headers = {
1140
+ "content-type": "application/json",
1141
+ "user-agent": "easy-cms-webhooks",
1142
+ "x-easy-cms-event": event,
1143
+ "x-easy-cms-delivery": delivery,
1144
+ ...hook.headers
1145
+ };
1146
+ if (hook.secret) {
1147
+ headers["x-easy-cms-signature"] = `sha256=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
1148
+ }
1149
+ try {
1150
+ const response = await this.fetcher(hook.url, {
1151
+ method: "POST",
1152
+ headers,
1153
+ body,
1154
+ signal: AbortSignal.timeout(TIMEOUT)
1155
+ });
1156
+ if (response.ok || response.status < 500 && response.status !== 429) {
1157
+ if (!response.ok)
1158
+ this.logger.warn(`Webhook ${hook.url} answered ${response.status} to ${event}`);
1159
+ return { ok: true };
1160
+ }
1161
+ return { ok: false, error: `status ${response.status}` };
1162
+ } catch (error) {
1163
+ return { ok: false, error: error.message };
1164
+ }
1165
+ }
1166
+ giveUp(hook, event, error, attempts) {
1167
+ this.logger.error(
1168
+ `Webhook ${hook.url} failed for ${event} after ${attempts} attempts: ${error}`
1169
+ );
1170
+ }
1171
+ };
1172
+ var later = (from, ms) => new Date(from.getTime() + ms).toISOString();
1173
+ function matches(hook, payload) {
1174
+ if (hook.events && !hook.events.includes(payload.event)) return false;
1175
+ if (payload.collection !== void 0)
1176
+ return !hook.collections || hook.collections.includes(payload.collection);
1177
+ return !hook.globals || hook.globals.includes(payload.global);
1178
+ }
1179
+
946
1180
  // src/validate-config.ts
947
1181
  var MIN_SECRET_LENGTH = 32;
948
1182
  var BUILTIN_COLLECTIONS = ["users", "media"];
@@ -952,6 +1186,9 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
952
1186
  "sessions",
953
1187
  "login-attempts",
954
1188
  "document-versions",
1189
+ "scheduled-jobs",
1190
+ "webhook-deliveries",
1191
+ "jobs",
955
1192
  "migrations",
956
1193
  "access"
957
1194
  ]);
@@ -974,6 +1211,7 @@ function validateConfig(config) {
974
1211
  validateAuth(config, add);
975
1212
  validateCors(config.cors, add);
976
1213
  validateLocalization(config, add);
1214
+ validateWebhooks(config, add);
977
1215
  const collections = asArray(config.collections, "collections", add);
978
1216
  const globals = asArray(config.globals, "globals", add);
979
1217
  const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
@@ -1063,7 +1301,9 @@ var LOCALIZABLE = /* @__PURE__ */ new Set([
1063
1301
  "json",
1064
1302
  "select",
1065
1303
  "upload",
1066
- "relationship"
1304
+ "relationship",
1305
+ "blocks",
1306
+ "array"
1067
1307
  ]);
1068
1308
  function validateLocalization(config, add) {
1069
1309
  const localization = config.localization;
@@ -1090,7 +1330,7 @@ function validateLocalization(config, add) {
1090
1330
  }
1091
1331
  }
1092
1332
  }
1093
- const walk = (fields, path) => {
1333
+ const walk = (fields, path, insideLocalized = false) => {
1094
1334
  if (!Array.isArray(fields)) return;
1095
1335
  for (const field of fields) {
1096
1336
  if (typeof field?.name !== "string") continue;
@@ -1108,18 +1348,52 @@ function validateLocalization(config, add) {
1108
1348
  `${field.type} fields cannot be localized`,
1109
1349
  "localize the fields inside it instead"
1110
1350
  );
1111
- } else if ("hasMany" in field && field.hasMany) {
1112
- add(`${fieldPath}.localized`, "hasMany fields cannot be localized yet");
1351
+ } else if (insideLocalized) {
1352
+ add(
1353
+ `${fieldPath}.localized`,
1354
+ "is inside a localized field, which already holds one value per locale"
1355
+ );
1113
1356
  }
1114
1357
  }
1358
+ const inside = insideLocalized || field.localized === true;
1115
1359
  if (field.type === "group" || field.type === "array")
1116
- walk(field.fields, `${fieldPath}.fields`);
1360
+ walk(field.fields, `${fieldPath}.fields`, inside);
1361
+ if (field.type === "blocks")
1362
+ for (const block of field.blocks ?? [])
1363
+ walk(block?.fields, `${fieldPath}.blocks.${block?.slug}`, inside);
1117
1364
  }
1118
1365
  };
1119
1366
  for (const [i, c] of (config.collections ?? []).entries())
1120
1367
  walk(c?.fields, `collections[${i}].fields`);
1121
1368
  for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
1122
1369
  }
1370
+ function validateWebhooks(config, add) {
1371
+ const hooks = config.webhooks;
1372
+ if (hooks === void 0) return;
1373
+ if (!Array.isArray(hooks)) {
1374
+ add("webhooks", "must be a list", "webhooks: [{ url: 'https://example.com/hook' }]");
1375
+ return;
1376
+ }
1377
+ const collections = /* @__PURE__ */ new Set([
1378
+ ...BUILTIN_COLLECTIONS,
1379
+ ...(config.collections ?? []).map((c) => c?.slug)
1380
+ ]);
1381
+ const globals = new Set((config.globals ?? []).map((g) => g?.slug));
1382
+ for (const [i, hook] of hooks.entries()) {
1383
+ const path = `webhooks[${i}]`;
1384
+ if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
1385
+ add(`${path}.url`, "must be an http(s) URL");
1386
+ }
1387
+ for (const event of hook?.events ?? []) {
1388
+ if (!WEBHOOK_EVENTS.includes(event))
1389
+ add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
1390
+ }
1391
+ for (const slug of hook?.collections ?? [])
1392
+ if (!collections.has(slug)) add(`${path}.collections`, `unknown collection "${slug}"`);
1393
+ for (const slug of hook?.globals ?? [])
1394
+ if (!globals.has(slug)) add(`${path}.globals`, `unknown global "${slug}"`);
1395
+ }
1396
+ }
1123
1397
  function validateCors(cors, add) {
1124
1398
  if (cors === void 0 || cors === "*") return;
1125
1399
  if (!Array.isArray(cors)) {
@@ -1187,6 +1461,9 @@ function validateContainer(container, path, collectionSlugs, add) {
1187
1461
  add(`${path}.versions.max`, "must be a positive integer");
1188
1462
  }
1189
1463
  }
1464
+ if (container.schedule && !container.drafts) {
1465
+ add(`${path}.schedule`, "needs `drafts: true`", "scheduling publishes and unpublishes drafts");
1466
+ }
1190
1467
  if (container.preview !== void 0 && typeof container.preview !== "function") {
1191
1468
  add(
1192
1469
  `${path}.preview`,
@@ -1271,6 +1548,43 @@ function validateField(field, path, siblings, collectionSlugs, add) {
1271
1548
  checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
1272
1549
  validateFields2(field.fields, `${path}.fields`, /* @__PURE__ */ new Set(["id"]), collectionSlugs, add);
1273
1550
  break;
1551
+ case "blocks": {
1552
+ if (!Array.isArray(field.blocks) || field.blocks.length === 0) {
1553
+ add(
1554
+ `${path}.blocks`,
1555
+ "must contain at least one block",
1556
+ "blocks: [{ slug: 'hero', fields: [...] }]"
1557
+ );
1558
+ break;
1559
+ }
1560
+ checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
1561
+ const seen = /* @__PURE__ */ new Set();
1562
+ for (const [i, block] of field.blocks.entries()) {
1563
+ const blockPath = `${path}.blocks[${i}]`;
1564
+ if (typeof block?.slug !== "string" || !SLUG_PATTERN.test(block.slug)) {
1565
+ add(
1566
+ `${blockPath}.slug`,
1567
+ 'must be lowercase letters, digits, "-" or "_", starting with a letter'
1568
+ );
1569
+ } else if (seen.has(block.slug)) {
1570
+ add(`${blockPath}.slug`, `duplicate block "${block.slug}"`);
1571
+ } else {
1572
+ seen.add(block.slug);
1573
+ }
1574
+ if (!Array.isArray(block?.fields)) {
1575
+ add(`${blockPath}.fields`, "must be an array");
1576
+ continue;
1577
+ }
1578
+ validateFields2(
1579
+ block.fields,
1580
+ `${blockPath}.fields`,
1581
+ /* @__PURE__ */ new Set(["id", "blockType"]),
1582
+ collectionSlugs,
1583
+ add
1584
+ );
1585
+ }
1586
+ break;
1587
+ }
1274
1588
  }
1275
1589
  }
1276
1590
  function checkRange(min, max, minName, maxName, path, add) {
@@ -1362,7 +1676,9 @@ async function resolveConfig(input) {
1362
1676
  collections: [
1363
1677
  ...config.collections ?? [],
1364
1678
  ...internalCollections,
1365
- ...usesVersions(config) ? [versionsCollection] : []
1679
+ ...usesVersions(config) ? [versionsCollection] : [],
1680
+ ...usesSchedule(config) ? [scheduledJobsCollection] : [],
1681
+ ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : []
1366
1682
  ],
1367
1683
  globals: config.globals ?? []
1368
1684
  };
@@ -1372,6 +1688,9 @@ async function resolveConfig(input) {
1372
1688
  function usesVersions(config) {
1373
1689
  return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.versions);
1374
1690
  }
1691
+ function usesSchedule(config) {
1692
+ return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
1693
+ }
1375
1694
 
1376
1695
  // src/load-config.ts
1377
1696
  var CONFIG_FILE_NAMES = [
@@ -1430,16 +1749,20 @@ function pickLocale(fields, data, locale, localization, fallback) {
1430
1749
  const result = { ...data };
1431
1750
  for (const field of fields) {
1432
1751
  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) {
1752
+ if (field.localized) {
1440
1753
  const map = isPlainObject2(value) ? value : {};
1441
1754
  const own = map[locale];
1442
- result[field.name] = isEmpty2(own) && fallback ? map[localization.defaultLocale] ?? null : own ?? null;
1755
+ const blank = isEmpty2(own) || Array.isArray(own) && own.length === 0;
1756
+ const picked = blank && fallback ? map[localization.defaultLocale] ?? null : own ?? null;
1757
+ const list = hasRows(field) || "hasMany" in field && field.hasMany === true;
1758
+ result[field.name] = picked === null && list ? [] : picked;
1759
+ } else if (field.type === "group" && isPlainObject2(value)) {
1760
+ result[field.name] = pickLocale(field.fields, value, locale, localization, fallback);
1761
+ } else if (hasRows(field) && Array.isArray(value)) {
1762
+ result[field.name] = value.map((row) => {
1763
+ const fields2 = rowFields(field, row);
1764
+ return fields2 && isPlainObject2(row) ? pickLocale(fields2, row, locale, localization, fallback) : row;
1765
+ });
1443
1766
  }
1444
1767
  }
1445
1768
  return result;
@@ -1450,57 +1773,66 @@ function toLocaleMaps(fields, input, current, locale) {
1450
1773
  if (!Object.hasOwn(input, field.name)) continue;
1451
1774
  const value = input[field.name];
1452
1775
  const previous = current[field.name];
1453
- if (field.type === "group" && isPlainObject2(value)) {
1776
+ if (field.localized) {
1777
+ const map = isPlainObject2(previous) ? previous : {};
1778
+ result[field.name] = { ...map, [locale]: value };
1779
+ } else if (field.type === "group" && isPlainObject2(value)) {
1454
1780
  result[field.name] = toLocaleMaps(
1455
1781
  field.fields,
1456
1782
  value,
1457
1783
  isPlainObject2(previous) ? previous : {},
1458
1784
  locale
1459
1785
  );
1460
- } else if (field.type === "array" && Array.isArray(value)) {
1786
+ } else if (hasRows(field) && Array.isArray(value)) {
1461
1787
  const rows = new Map(
1462
1788
  (Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
1463
1789
  );
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 };
1790
+ result[field.name] = value.map((row) => {
1791
+ const fields2 = rowFields(field, row);
1792
+ return fields2 && isPlainObject2(row) ? toLocaleMaps(fields2, row, rows.get(String(row.id)) ?? {}, locale) : row;
1793
+ });
1470
1794
  }
1471
1795
  }
1472
1796
  return result;
1473
1797
  }
1474
- function localizePath(fields, path, locale) {
1798
+ function localizePath(fields, path, locale, locales = []) {
1475
1799
  const segments = path.split(".");
1476
1800
  let list = fields;
1477
1801
  for (let i = 0; i < segments.length; i++) {
1478
1802
  const field = list.find((f) => f.name === segments[i]);
1479
1803
  if (!field) return path;
1804
+ if (field.localized) {
1805
+ const next = segments[i + 1];
1806
+ if (next !== void 0 && locales.includes(next)) return path;
1807
+ return [...segments.slice(0, i + 1), locale, ...segments.slice(i + 1)].join(".");
1808
+ }
1480
1809
  if (field.type === "group" || field.type === "array") {
1481
1810
  list = field.fields;
1482
1811
  continue;
1483
1812
  }
1484
- if (field.localized && i === segments.length - 1) return `${path}.${locale}`;
1813
+ if (field.type === "blocks") {
1814
+ list = field.blocks.flatMap((block) => block.fields);
1815
+ continue;
1816
+ }
1485
1817
  return path;
1486
1818
  }
1487
1819
  return path;
1488
1820
  }
1489
- function localizeWhere(fields, where, locale) {
1821
+ function localizeWhere(fields, where, locale, locales = []) {
1490
1822
  if (!where) return where;
1491
1823
  const out = {};
1492
1824
  for (const [key2, value] of Object.entries(where)) {
1493
1825
  if ((key2 === "and" || key2 === "or") && Array.isArray(value)) {
1494
- out[key2] = value.map((w) => localizeWhere(fields, w, locale));
1826
+ out[key2] = value.map((w) => localizeWhere(fields, w, locale, locales));
1495
1827
  } else {
1496
- out[localizePath(fields, key2, locale)] = value;
1828
+ out[localizePath(fields, key2, locale, locales)] = value;
1497
1829
  }
1498
1830
  }
1499
1831
  return out;
1500
1832
  }
1501
- function localizeSort(fields, sort, locale) {
1833
+ function localizeSort(fields, sort, locale, locales = []) {
1502
1834
  return sort.map(
1503
- (entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale)}` : localizePath(fields, entry, locale)
1835
+ (entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale, locales)}` : localizePath(fields, entry, locale, locales)
1504
1836
  );
1505
1837
  }
1506
1838
 
@@ -1644,10 +1976,16 @@ function collectSlots(fields, data, bySlug, slots) {
1644
1976
  if (value === null || value === void 0) continue;
1645
1977
  if (field.type === "group") {
1646
1978
  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);
1979
+ } else if (hasRows(field)) {
1980
+ if (Array.isArray(value)) {
1981
+ for (const row of value) {
1982
+ const fields2 = rowFields(field, row);
1983
+ if (fields2 && row && typeof row === "object")
1984
+ collectSlots(fields2, row, bySlug, slots);
1985
+ }
1986
+ }
1650
1987
  } else if (field.type === "relationship" || field.type === "upload") {
1988
+ if (typeof value === "object" && !Array.isArray(value)) continue;
1651
1989
  const collection2 = field.type === "upload" ? "media" : field.to;
1652
1990
  if (!bySlug.has(collection2)) continue;
1653
1991
  if (Array.isArray(value)) {
@@ -1862,8 +2200,19 @@ async function createEasyCMS(config, options = {}) {
1862
2200
  logger,
1863
2201
  interactive: options.interactive ?? false
1864
2202
  });
1865
- return new EasyCMS(resolved2, db, logger, storage);
2203
+ const cms = new EasyCMS(resolved2, db, logger, storage);
2204
+ const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule) || (resolved2.webhooks?.length ?? 0) > 0;
2205
+ if (scheduling && options.scheduler !== false) cms.startScheduler();
2206
+ return cms;
1866
2207
  }
2208
+ var toJob = (row) => ({
2209
+ id: row.id,
2210
+ action: row.action,
2211
+ runAt: String(row.runAt),
2212
+ state: row.state,
2213
+ error: row.error ?? null,
2214
+ author: row.author ?? null
2215
+ });
1867
2216
  var EasyCMS = class {
1868
2217
  config;
1869
2218
  db;
@@ -1873,12 +2222,18 @@ var EasyCMS = class {
1873
2222
  /** Where uploaded files are stored. */
1874
2223
  storage;
1875
2224
  versions;
2225
+ webhooks;
1876
2226
  constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
1877
2227
  this.config = config;
1878
2228
  this.db = db;
1879
2229
  this.logger = logger;
1880
2230
  this.storage = storage;
1881
2231
  this.versions = new VersionStore(db);
2232
+ this.webhooks = new Webhooks(
2233
+ config.webhooks ?? [],
2234
+ logger,
2235
+ config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES) ? this.webhookQueue() : void 0
2236
+ );
1882
2237
  this.auth = new Auth(this);
1883
2238
  }
1884
2239
  async find(collection2, options = {}) {
@@ -1899,7 +2254,8 @@ var EasyCMS = class {
1899
2254
  const sort = locale === void 0 ? requested : localizeSort(
1900
2255
  config.fields,
1901
2256
  requested,
1902
- locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale
2257
+ locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale,
2258
+ this.config.localization?.locales ?? []
1903
2259
  );
1904
2260
  const result = await this.db.find({ collection: collection2, where, sort, limit, page });
1905
2261
  const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
@@ -2268,6 +2624,7 @@ var EasyCMS = class {
2268
2624
  previousDoc: current,
2269
2625
  operation: "update"
2270
2626
  });
2627
+ this.emit(config, "draft", draft);
2271
2628
  const [out2] = await this.output(config, [draft], guard, {
2272
2629
  ...options,
2273
2630
  draft: true
@@ -2289,6 +2646,7 @@ var EasyCMS = class {
2289
2646
  previousDoc: current,
2290
2647
  operation: "update"
2291
2648
  });
2649
+ this.emit(config, "update", doc, existing.status);
2292
2650
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
2293
2651
  return out;
2294
2652
  }
@@ -2306,11 +2664,16 @@ var EasyCMS = class {
2306
2664
  if (config.slug === USERS) await this.auth.revokeSessions(parsed);
2307
2665
  await this.db.delete({ collection: collection2, id: parsed });
2308
2666
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
2667
+ if (config.schedule) {
2668
+ for (const job of await this.pendingJobs(collection2, parsed))
2669
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
2670
+ }
2309
2671
  await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
2310
2672
  ...base,
2311
2673
  id: parsed,
2312
2674
  doc: existing
2313
2675
  });
2676
+ this.emit(config, "delete", existing);
2314
2677
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
2315
2678
  return out;
2316
2679
  }
@@ -2437,6 +2800,7 @@ var EasyCMS = class {
2437
2800
  previousDoc: current,
2438
2801
  operation: "update"
2439
2802
  });
2803
+ this.emit(config, "draft", draft);
2440
2804
  return this.findGlobal(slug, { ...options, draft: true });
2441
2805
  }
2442
2806
  const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
@@ -2447,6 +2811,7 @@ var EasyCMS = class {
2447
2811
  previousDoc: current,
2448
2812
  operation: "update"
2449
2813
  });
2814
+ this.emit(config, "update", doc, existing.status);
2450
2815
  return this.findGlobal(slug, { ...options, draft: true });
2451
2816
  }
2452
2817
  /**
@@ -2468,9 +2833,226 @@ var EasyCMS = class {
2468
2833
  return { update: await check("update"), delete: await check("delete") };
2469
2834
  }
2470
2835
  /** Closes the database connection. */
2836
+ /**
2837
+ * Waits until webhook deliveries in progress are done. Call it before a serverless function
2838
+ * returns, which may otherwise stop them.
2839
+ */
2840
+ flushWebhooks() {
2841
+ return this.webhooks.flush();
2842
+ }
2471
2843
  async destroy() {
2844
+ if (this.schedulerTimer) clearInterval(this.schedulerTimer);
2845
+ await this.running;
2846
+ await this.webhooks.flush();
2472
2847
  await this.db.destroy();
2473
2848
  }
2849
+ // --- Scheduled publishing --------------------------------------------------
2850
+ schedulerTimer;
2851
+ running = Promise.resolve();
2852
+ /** Runs `runJobs` every `interval` ms in this process (`createEasyCMS` does this by default). */
2853
+ startScheduler(interval = 6e4) {
2854
+ if (this.schedulerTimer) return;
2855
+ this.schedulerTimer = setInterval(() => {
2856
+ this.running = this.running.then(
2857
+ () => this.runJobs().catch(
2858
+ (error) => this.logger.error(`Scheduled jobs failed: ${error.message}`)
2859
+ )
2860
+ );
2861
+ }, interval);
2862
+ this.schedulerTimer.unref?.();
2863
+ }
2864
+ /**
2865
+ * Runs due scheduled publishes and unpublishes, and retries failed webhook deliveries. What the
2866
+ * scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
2867
+ */
2868
+ async runJobs(now = /* @__PURE__ */ new Date()) {
2869
+ const scheduled = await this.runScheduled(now);
2870
+ return { ...scheduled, webhooks: await this.retryWebhooks(now) };
2871
+ }
2872
+ /**
2873
+ * Tries webhook deliveries that failed earlier and are due for another attempt. Failed
2874
+ * deliveries are kept in the database, so they survive restarts.
2875
+ */
2876
+ retryWebhooks(now = /* @__PURE__ */ new Date()) {
2877
+ return this.webhooks.retry(now);
2878
+ }
2879
+ /** Failed webhook deliveries, stored in `webhook-deliveries`. */
2880
+ webhookQueue() {
2881
+ const collection2 = WEBHOOK_DELIVERIES;
2882
+ const toRow = (d, createdAt) => {
2883
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2884
+ return { ...d, createdAt: createdAt ?? now, updatedAt: now };
2885
+ };
2886
+ return {
2887
+ add: async (delivery) => (await this.db.create({ collection: collection2, data: toRow(delivery) })).id,
2888
+ update: async (id, delivery) => {
2889
+ const current = await this.db.findById({ collection: collection2, id });
2890
+ await this.db.update({
2891
+ collection: collection2,
2892
+ id,
2893
+ data: toRow(delivery, current?.createdAt)
2894
+ });
2895
+ },
2896
+ remove: async (id) => {
2897
+ await this.db.delete({ collection: collection2, id });
2898
+ },
2899
+ due: async (now, limit) => {
2900
+ const { docs } = await this.db.find({
2901
+ collection: collection2,
2902
+ where: {
2903
+ and: [{ state: { equals: "pending" } }, { nextAttemptAt: { lte: now.toISOString() } }]
2904
+ },
2905
+ sort: ["nextAttemptAt"],
2906
+ limit,
2907
+ page: 1
2908
+ });
2909
+ return docs;
2910
+ }
2911
+ };
2912
+ }
2913
+ /**
2914
+ * Publishes or unpublishes documents whose scheduled time has come. Safe to call often, e.g.
2915
+ * from a cron job (`GET <api>/jobs/run`) where no process keeps running.
2916
+ */
2917
+ async runScheduled(now = /* @__PURE__ */ new Date()) {
2918
+ if (!this.config.collections.some((c) => c.slug === SCHEDULED_JOBS))
2919
+ return { ran: 0, failed: 0 };
2920
+ const due = await this.db.find({
2921
+ collection: SCHEDULED_JOBS,
2922
+ where: {
2923
+ and: [{ state: { equals: "pending" } }, { runAt: { lte: now.toISOString() } }]
2924
+ },
2925
+ sort: ["runAt"],
2926
+ limit: 100,
2927
+ page: 1
2928
+ });
2929
+ let failed = 0;
2930
+ for (const row of due.docs) {
2931
+ const parent = String(row.parent);
2932
+ const action = row.action;
2933
+ try {
2934
+ if (parent.startsWith("global:")) {
2935
+ const slug = parent.slice("global:".length);
2936
+ if (action === "publish") await this.saveGlobal(slug, { status: "published" }, {});
2937
+ else await this.saveGlobal(slug, { status: "draft" }, {}, "unpublish");
2938
+ } else if (action === "publish") {
2939
+ await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
2940
+ } else {
2941
+ await this.updateDocument(
2942
+ parent,
2943
+ row.doc,
2944
+ { status: "draft" },
2945
+ { depth: 0 },
2946
+ "unpublish"
2947
+ );
2948
+ }
2949
+ await this.db.update({
2950
+ collection: SCHEDULED_JOBS,
2951
+ id: row.id,
2952
+ data: { ...row, state: "done" }
2953
+ });
2954
+ } catch (error) {
2955
+ failed++;
2956
+ const message = error.message;
2957
+ this.logger.error(`Scheduled ${action} of ${parent} ${row.doc} failed: ${message}`);
2958
+ await this.db.update({
2959
+ collection: SCHEDULED_JOBS,
2960
+ id: row.id,
2961
+ data: { ...row, state: "failed", error: message.slice(0, 500) }
2962
+ });
2963
+ }
2964
+ }
2965
+ return { ran: due.docs.length - failed, failed };
2966
+ }
2967
+ /** Schedules a publish or unpublish, replacing a pending job with the same action. */
2968
+ async schedule(collection2, id, job, options = {}) {
2969
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2970
+ return this.addJob(collection2, parsed, job, options);
2971
+ }
2972
+ /** Pending jobs of a document, soonest first. */
2973
+ async scheduled(collection2, id, options = {}) {
2974
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2975
+ return this.pendingJobs(collection2, parsed);
2976
+ }
2977
+ async cancelSchedule(collection2, id, jobId, options = {}) {
2978
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2979
+ await this.removeJob(collection2, parsed, jobId);
2980
+ }
2981
+ async scheduleGlobal(slug, job, options = {}) {
2982
+ await this.globalScheduleTarget(slug, options);
2983
+ return this.addJob(`global:${slug}`, 0, job, options);
2984
+ }
2985
+ async scheduledGlobal(slug, options = {}) {
2986
+ await this.globalScheduleTarget(slug, options);
2987
+ return this.pendingJobs(`global:${slug}`, 0);
2988
+ }
2989
+ async cancelGlobalSchedule(slug, jobId, options = {}) {
2990
+ await this.globalScheduleTarget(slug, options);
2991
+ await this.removeJob(`global:${slug}`, 0, jobId);
2992
+ }
2993
+ async scheduleTarget(collection2, id, options) {
2994
+ const config = this.collection(collection2);
2995
+ if (!config.schedule) throw new QueryError(`"${collection2}" has no schedule`);
2996
+ const parsed = parseId(id);
2997
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
2998
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
2999
+ await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
3000
+ return { config, parsed };
3001
+ }
3002
+ async globalScheduleTarget(slug, options) {
3003
+ const config = this.global(slug);
3004
+ if (!config.schedule) throw new QueryError(`"${slug}" has no schedule`);
3005
+ await this.checkGlobalAccess(config, "update", guardOf(options));
3006
+ }
3007
+ async addJob(parent, doc, job, options) {
3008
+ const runAt = new Date(job.at);
3009
+ const errors = [];
3010
+ if (job.action !== "publish" && job.action !== "unpublish")
3011
+ errors.push({ field: "action", message: 'must be "publish" or "unpublish"' });
3012
+ if (Number.isNaN(runAt.getTime())) errors.push({ field: "at", message: "must be a valid date" });
3013
+ if (errors.length > 0) throw new ValidationError(SCHEDULED_JOBS, errors);
3014
+ for (const pending of await this.pendingJobs(parent, doc)) {
3015
+ if (pending.action === job.action)
3016
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: pending.id });
3017
+ }
3018
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3019
+ const row = await this.db.create({
3020
+ collection: SCHEDULED_JOBS,
3021
+ data: {
3022
+ parent,
3023
+ doc,
3024
+ action: job.action,
3025
+ runAt: runAt.toISOString(),
3026
+ state: "pending",
3027
+ error: null,
3028
+ author: guardOf(options).user?.id ?? null,
3029
+ createdAt: now,
3030
+ updatedAt: now
3031
+ }
3032
+ });
3033
+ return toJob(row);
3034
+ }
3035
+ async pendingJobs(parent, doc) {
3036
+ const rows = await this.db.find({
3037
+ collection: SCHEDULED_JOBS,
3038
+ where: {
3039
+ and: [
3040
+ { parent: { equals: parent } },
3041
+ { doc: { equals: doc } },
3042
+ { state: { equals: "pending" } }
3043
+ ]
3044
+ },
3045
+ sort: ["runAt"],
3046
+ limit: 0,
3047
+ page: 1
3048
+ });
3049
+ return rows.docs.map(toJob);
3050
+ }
3051
+ async removeJob(parent, doc, jobId) {
3052
+ const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
3053
+ if (!job) throw new NotFoundError("scheduled job", jobId);
3054
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
3055
+ }
2474
3056
  // -------------------------------------------------------------------------
2475
3057
  /** @internal */
2476
3058
  collection(slug) {
@@ -2499,11 +3081,11 @@ var EasyCMS = class {
2499
3081
  });
2500
3082
  if (access === true) return;
2501
3083
  if (access !== false) {
2502
- const matches = await this.db.count({
3084
+ const matches2 = await this.db.count({
2503
3085
  collection: config.slug,
2504
3086
  where: andWhere(access, { id: { equals: id } })
2505
3087
  });
2506
- if (matches > 0) return;
3088
+ if (matches2 > 0) return;
2507
3089
  }
2508
3090
  throw deny(guard.user);
2509
3091
  }
@@ -2613,6 +3195,7 @@ var EasyCMS = class {
2613
3195
  doc,
2614
3196
  operation: "create"
2615
3197
  });
3198
+ this.emit(config, "create", doc);
2616
3199
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
2617
3200
  return out;
2618
3201
  }
@@ -2650,7 +3233,8 @@ var EasyCMS = class {
2650
3233
  return localizeWhere(
2651
3234
  config.fields,
2652
3235
  where,
2653
- locale === ALL_LOCALES ? localization.defaultLocale : locale
3236
+ locale === ALL_LOCALES ? localization.defaultLocale : locale,
3237
+ localization.locales
2654
3238
  );
2655
3239
  }
2656
3240
  /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
@@ -2667,6 +3251,24 @@ var EasyCMS = class {
2667
3251
  return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
2668
3252
  });
2669
3253
  }
3254
+ /** Sends webhook events for a change; `before` is the stored status before it, if any. */
3255
+ emit(config, event, doc, before) {
3256
+ const isGlobal = !this.config.collections.includes(config);
3257
+ if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
3258
+ const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
3259
+ id: doc.id,
3260
+ ...snapshotOf(config.fields, doc),
3261
+ createdAt: doc.createdAt,
3262
+ updatedAt: doc.updatedAt
3263
+ };
3264
+ const target = isGlobal ? { global: config.slug } : { collection: config.slug, id: doc.id };
3265
+ this.webhooks.emit(event, target, body);
3266
+ if (event === "draft" || event === "delete" || !config.drafts) return;
3267
+ const after = doc.status;
3268
+ if (after === "published" && before !== "published") this.webhooks.emit("publish", target, body);
3269
+ if (after !== "published" && before === "published")
3270
+ this.webhooks.emit("unpublish", target, body);
3271
+ }
2670
3272
  /** Records a version after a save, when the collection or global keeps versions. */
2671
3273
  async saveVersion(config, parent, id, doc, guard) {
2672
3274
  const max = versionLimit(config);
@@ -2951,6 +3553,17 @@ async function serializeFields(fields, update, localized) {
2951
3553
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
2952
3554
  f.fields = await serializeFields(field.fields, update, localized);
2953
3555
  break;
3556
+ case "blocks":
3557
+ if (field.minRows !== void 0) f.minRows = field.minRows;
3558
+ if (field.maxRows !== void 0) f.maxRows = field.maxRows;
3559
+ f.blocks = await Promise.all(
3560
+ field.blocks.map(async (block) => ({
3561
+ slug: block.slug,
3562
+ ...block.labels ? { labels: block.labels } : {},
3563
+ fields: await serializeFields(block.fields, update, localized)
3564
+ }))
3565
+ );
3566
+ break;
2954
3567
  case "group":
2955
3568
  f.fields = await serializeFields(field.fields, update, localized);
2956
3569
  break;
@@ -2965,6 +3578,7 @@ async function collection(config, user, localized) {
2965
3578
  drafts: config.drafts === true,
2966
3579
  versions: Boolean(config.versions),
2967
3580
  preview: typeof config.preview === "function",
3581
+ schedule: config.schedule === true,
2968
3582
  fields: await serializeFields(
2969
3583
  config.fields,
2970
3584
  new FieldAccessChecker("update", { user }),
@@ -2987,6 +3601,7 @@ async function global(config, user, localized) {
2987
3601
  drafts: config.drafts === true,
2988
3602
  versions: Boolean(config.versions),
2989
3603
  preview: typeof config.preview === "function",
3604
+ schedule: config.schedule === true,
2990
3605
  fields: await serializeFields(
2991
3606
  config.fields,
2992
3607
  new FieldAccessChecker("update", { user }),
@@ -3225,6 +3840,16 @@ async function route(cms, ctx, method, segments, options) {
3225
3840
  }
3226
3841
  throw new HttpError("Not found", 404);
3227
3842
  }
3843
+ if (first === "jobs" && second === "run" && third === void 0) {
3844
+ if (method !== "GET" && method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
3845
+ const secret = cms.config.cronSecret ?? process.env.CRON_SECRET;
3846
+ const header = ctx.request.headers.get("authorization") ?? "";
3847
+ const byCron = !!secret && safeEqual(header, `Bearer ${secret}`);
3848
+ if (!byCron && ctx.user?.role !== "admin") {
3849
+ throw ctx.user ? new HttpError("Forbidden", 403) : new UnauthorizedError();
3850
+ }
3851
+ return { body: await cms.runJobs() };
3852
+ }
3228
3853
  if (first === "admin") {
3229
3854
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
3230
3855
  if (!ctx.user) throw new UnauthorizedError();
@@ -3349,6 +3974,20 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
3349
3974
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3350
3975
  return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
3351
3976
  }
3977
+ if (action === "schedule" && path.length <= 2) {
3978
+ if (path.length === 2) {
3979
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
3980
+ await cms.cancelSchedule(collection2, id, versionId, access);
3981
+ return { body: { message: "Cancelled" } };
3982
+ }
3983
+ if (method === "GET") return { body: await cms.scheduled(collection2, id, access) };
3984
+ if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
3985
+ const body = await readJson(ctx.request);
3986
+ return {
3987
+ status: 201,
3988
+ body: await cms.schedule(collection2, id, scheduleJob(body), access)
3989
+ };
3990
+ }
3352
3991
  if (path.length === 1 && action === "preview") {
3353
3992
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3354
3993
  const body = await readJson(ctx.request);
@@ -3383,6 +4022,17 @@ async function globalAction(cms, ctx, method, slug, path) {
3383
4022
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3384
4023
  return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
3385
4024
  }
4025
+ if (action === "schedule" && path.length <= 2) {
4026
+ if (path.length === 2) {
4027
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
4028
+ await cms.cancelGlobalSchedule(slug, versionId, access);
4029
+ return { body: { message: "Cancelled" } };
4030
+ }
4031
+ if (method === "GET") return { body: await cms.scheduledGlobal(slug, access) };
4032
+ if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
4033
+ const body = await readJson(ctx.request);
4034
+ return { status: 201, body: await cms.scheduleGlobal(slug, scheduleJob(body), access) };
4035
+ }
3386
4036
  if (path.length === 1 && action === "preview") {
3387
4037
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3388
4038
  const body = await readJson(ctx.request);
@@ -3399,6 +4049,12 @@ async function globalAction(cms, ctx, method, slug, path) {
3399
4049
  }
3400
4050
  throw new HttpError("Not found", 404);
3401
4051
  }
4052
+ function scheduleJob(body) {
4053
+ return {
4054
+ action: body.action,
4055
+ at: typeof body.at === "string" ? body.at : ""
4056
+ };
4057
+ }
3402
4058
  function parseLocale(url) {
3403
4059
  const locale = url.searchParams.get("locale");
3404
4060
  const fallback = url.searchParams.get("fallback-locale");
@@ -3416,8 +4072,8 @@ function withPreviewToken(url, token) {
3416
4072
  }
3417
4073
  function requirePreview(cms, ctx, target) {
3418
4074
  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");
4075
+ 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);
4076
+ if (!matches2) throw new UnauthorizedError("Invalid or expired preview token");
3421
4077
  }
3422
4078
  function parsePage(url) {
3423
4079
  const { page, limit } = parseListQuery(url);
@@ -3633,6 +4289,16 @@ ${indent}}`;
3633
4289
  ${indent} id: string
3634
4290
  ${fieldLines(field.fields, n, `${indent} `).join("\n")}
3635
4291
  ${indent}}[]`;
4292
+ case "blocks": {
4293
+ const kinds = field.blocks.map(
4294
+ (block) => `{
4295
+ ${indent} id: string
4296
+ ${indent} blockType: ${quote(block.slug)}
4297
+ ${fieldLines(block.fields, n, `${indent} `).join("\n")}
4298
+ ${indent}}`
4299
+ );
4300
+ return `(${kinds.join(" | ")})[]`;
4301
+ }
3636
4302
  }
3637
4303
  }
3638
4304
  function fieldLines(fields, n, indent) {
@@ -3640,7 +4306,7 @@ function fieldLines(fields, n, indent) {
3640
4306
  for (const field of fields) {
3641
4307
  if (field.hidden) continue;
3642
4308
  const type = fieldType(field, n, indent);
3643
- const alwaysPresent = field.type === "array" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
4309
+ const alwaysPresent = field.type === "array" || field.type === "blocks" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
3644
4310
  if (field.required || alwaysPresent) lines.push(`${indent}${key(field.name)}: ${type}`);
3645
4311
  else lines.push(`${indent}${key(field.name)}?: ${type} | null`);
3646
4312
  }
@@ -3748,6 +4414,7 @@ export {
3748
4414
  TooManyRequestsError,
3749
4415
  UnauthorizedError,
3750
4416
  ValidationError,
4417
+ WEBHOOK_EVENTS,
3751
4418
  anyone,
3752
4419
  applyDefaults,
3753
4420
  consoleLogger,
@@ -3758,6 +4425,7 @@ export {
3758
4425
  findConfigFile,
3759
4426
  generateSlugs,
3760
4427
  generateTypes,
4428
+ hasRows,
3761
4429
  hashPassword,
3762
4430
  imageDimensions,
3763
4431
  importConfig,
@@ -3771,6 +4439,7 @@ export {
3771
4439
  populate,
3772
4440
  readCookie,
3773
4441
  resolveConfig,
4442
+ rowFields,
3774
4443
  silentLogger,
3775
4444
  singularize,
3776
4445
  slugify,