@easy-cms/core 1.10.0 → 1.12.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.
@@ -59,6 +59,7 @@ var JOB_RUNS = "job-runs";
59
59
  var ROLES = "user-roles";
60
60
  var USER_IDENTITIES = "user-identities";
61
61
  var AUDIT_LOGS = "audit-logs";
62
+ var OUTBOX = "outbox";
62
63
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
63
64
  SESSIONS,
64
65
  LOGIN_ATTEMPTS,
@@ -70,7 +71,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
70
71
  JOB_RUNS,
71
72
  ROLES,
72
73
  USER_IDENTITIES,
73
- AUDIT_LOGS
74
+ AUDIT_LOGS,
75
+ OUTBOX
74
76
  ]);
75
77
  var DEFAULT_ROLES = ["admin", "editor"];
76
78
  var CREATED_BY_FIELD = {
@@ -519,6 +521,31 @@ var jobRunsCollection = {
519
521
  { name: "changedAt", type: "text" }
520
522
  ]
521
523
  };
524
+ var outboxCollection = {
525
+ slug: OUTBOX,
526
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
527
+ fields: [
528
+ // create | update | draft | delete, as the webhook event.
529
+ { name: "event", type: "text", required: true },
530
+ // The collection's or the global's slug.
531
+ { name: "target", type: "text", required: true },
532
+ { name: "global", type: "boolean" },
533
+ { name: "docId", type: "text" },
534
+ // The stored status before the save, for publish and unpublish events.
535
+ { name: "before", type: "text" },
536
+ // JSON: the document as saved (as deleted), the one before, and the user who saved.
537
+ { name: "doc", type: "textarea", required: true },
538
+ { name: "previousDoc", type: "textarea" },
539
+ { name: "user", type: "textarea" },
540
+ // Steps already done (hooks, webhooks, purge), so a retry runs only the rest.
541
+ { name: "done", type: "text" },
542
+ { name: "attempts", type: "number", required: true },
543
+ { name: "nextAttemptAt", type: "text", required: true, index: true },
544
+ // pending | running | failed
545
+ { name: "state", type: "text", required: true, index: true },
546
+ { name: "error", type: "text" }
547
+ ]
548
+ };
522
549
  var emailDeliveriesCollection = {
523
550
  slug: EMAIL_DELIVERIES,
524
551
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -572,8 +599,8 @@ function cut(value) {
572
599
  if (typeof value === "string")
573
600
  return value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE)}\u2026` : value;
574
601
  if (value !== null && typeof value === "object") {
575
- const json = JSON.stringify(value);
576
- return json.length > MAX_VALUE ? "\u2026" : value;
602
+ const json2 = JSON.stringify(value);
603
+ return json2.length > MAX_VALUE ? "\u2026" : value;
577
604
  }
578
605
  return value;
579
606
  }
@@ -3797,7 +3824,8 @@ var VersionStore = class {
3797
3824
  snapshot: args.snapshot,
3798
3825
  createdAt: now,
3799
3826
  updatedAt: now
3800
- }
3827
+ },
3828
+ outbox: args.outbox
3801
3829
  });
3802
3830
  await this.prune(parent, doc, args.max);
3803
3831
  return toSummary(created);
@@ -3874,7 +3902,7 @@ var VersionStore = class {
3874
3902
  };
3875
3903
 
3876
3904
  // src/local-api.ts
3877
- import { createHash as createHash4, createHmac as createHmac6, randomBytes as randomBytes6, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual5 } from "crypto";
3905
+ import { createHash as createHash5, createHmac as createHmac6, randomBytes as randomBytes6, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual5 } from "crypto";
3878
3906
 
3879
3907
  // src/auth/common-passwords.ts
3880
3908
  var LIST = `
@@ -4187,6 +4215,189 @@ function localizeSort(fields, sort, locale, locales = []) {
4187
4215
  );
4188
4216
  }
4189
4217
 
4218
+ // src/outbox.ts
4219
+ var DELAYS = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 12 * 36e5];
4220
+ var LEASE = 5 * 6e4;
4221
+ var BATCH2 = 50;
4222
+ var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
4223
+ var json = (value) => value === void 0 ? null : JSON.stringify(value);
4224
+ var parse2 = (value) => typeof value === "string" ? JSON.parse(value) : void 0;
4225
+ function withoutHash(value) {
4226
+ if (!value || !("passwordHash" in value)) return value;
4227
+ const { passwordHash: _, ...rest } = value;
4228
+ return rest;
4229
+ }
4230
+ var Outbox = class {
4231
+ constructor(db, logger, runner) {
4232
+ this.db = db;
4233
+ this.logger = logger;
4234
+ this.runner = runner;
4235
+ }
4236
+ db;
4237
+ logger;
4238
+ runner;
4239
+ draining;
4240
+ again = false;
4241
+ /** The row a save writes. For a create, `doc` has no id yet: it gets the written one. */
4242
+ row(entry) {
4243
+ return {
4244
+ collection: OUTBOX,
4245
+ data: (id) => {
4246
+ const docId = entry.global ? null : String(entry.docId ?? id);
4247
+ const doc = entry.event === "create" ? { ...entry.doc, id } : entry.doc;
4248
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4249
+ return {
4250
+ event: entry.event,
4251
+ target: entry.target,
4252
+ global: entry.global,
4253
+ docId,
4254
+ before: entry.before,
4255
+ doc: JSON.stringify(withoutHash(doc)),
4256
+ previousDoc: json(withoutHash(entry.previousDoc)),
4257
+ user: json(withoutHash(entry.user)),
4258
+ done: null,
4259
+ attempts: 0,
4260
+ nextAttemptAt: now,
4261
+ state: "pending",
4262
+ error: null,
4263
+ createdAt: now,
4264
+ updatedAt: now
4265
+ };
4266
+ }
4267
+ };
4268
+ }
4269
+ /** Runs what is due in the background, e.g. right after a save commits. */
4270
+ kick() {
4271
+ if (this.draining) {
4272
+ this.again = true;
4273
+ return;
4274
+ }
4275
+ this.draining = (async () => {
4276
+ do {
4277
+ this.again = false;
4278
+ await this.run();
4279
+ } while (this.again);
4280
+ })().catch((error) => this.logger.error(`Outbox: ${error.message}`, { error })).finally(() => {
4281
+ this.draining = void 0;
4282
+ });
4283
+ }
4284
+ /** Waits until the background run started by `kick` is over. */
4285
+ async flush() {
4286
+ while (this.draining) await this.draining;
4287
+ }
4288
+ /**
4289
+ * Runs entries that are due: new ones, retries whose time came, and those a stopped worker
4290
+ * left claimed. Runs with scheduled jobs too, which catches what `kick` missed.
4291
+ */
4292
+ async run(now = /* @__PURE__ */ new Date()) {
4293
+ let ran = 0;
4294
+ let failed = 0;
4295
+ for (; ; ) {
4296
+ const { docs } = await this.db.find({
4297
+ collection: OUTBOX,
4298
+ where: {
4299
+ and: [
4300
+ { state: { in: ["pending", "running"] } },
4301
+ { nextAttemptAt: { lte: now.toISOString() } }
4302
+ ]
4303
+ },
4304
+ sort: ["nextAttemptAt", "id"],
4305
+ limit: BATCH2,
4306
+ page: 1
4307
+ });
4308
+ let claimed = 0;
4309
+ for (const row of docs) {
4310
+ const result = await this.runRow(row, now);
4311
+ if (result === void 0) continue;
4312
+ claimed++;
4313
+ if (result) ran++;
4314
+ else failed++;
4315
+ }
4316
+ if (docs.length < BATCH2 || claimed === 0) return { ran, failed };
4317
+ }
4318
+ }
4319
+ /** Puts a failed entry back in line, e.g. from the admin. */
4320
+ async retry(id) {
4321
+ const row = await this.db.findById({ collection: OUTBOX, id });
4322
+ if (!row || row.state !== "failed") return false;
4323
+ const { id: _, ...data } = row;
4324
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4325
+ const updated = await this.db.update({
4326
+ collection: OUTBOX,
4327
+ id,
4328
+ data: { ...data, state: "pending", attempts: 0, nextAttemptAt: now, updatedAt: now },
4329
+ where: { state: { equals: "failed" } }
4330
+ });
4331
+ if (updated) this.kick();
4332
+ return updated !== null;
4333
+ }
4334
+ /** `true` when it ran, `false` when it failed, `undefined` when another worker has it. */
4335
+ async runRow(row, now) {
4336
+ const { id, ...data } = row;
4337
+ const attempts = Number(data.attempts ?? 0) + 1;
4338
+ let current = {
4339
+ ...data,
4340
+ state: "running",
4341
+ attempts,
4342
+ nextAttemptAt: later2(now, LEASE),
4343
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4344
+ };
4345
+ const claimed = await this.db.update({
4346
+ collection: OUTBOX,
4347
+ id,
4348
+ data: current,
4349
+ where: {
4350
+ and: [{ state: { equals: data.state } }, { nextAttemptAt: { equals: data.nextAttemptAt } }]
4351
+ }
4352
+ });
4353
+ if (!claimed) return void 0;
4354
+ const done = new Set(
4355
+ String(data.done ?? "").split(",").filter(Boolean)
4356
+ );
4357
+ const mark = async (step) => {
4358
+ done.add(step);
4359
+ current = { ...current, done: [...done].join(","), updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
4360
+ await this.db.update({ collection: OUTBOX, id, data: current });
4361
+ };
4362
+ try {
4363
+ await this.runner(entryOf(data), done, mark);
4364
+ await this.db.delete({ collection: OUTBOX, id });
4365
+ return true;
4366
+ } catch (error) {
4367
+ const message2 = error instanceof Error ? error.message : String(error);
4368
+ const delay = DELAYS[attempts - 1];
4369
+ await this.db.update({
4370
+ collection: OUTBOX,
4371
+ id,
4372
+ data: {
4373
+ ...current,
4374
+ state: delay === void 0 ? "failed" : "pending",
4375
+ nextAttemptAt: later2(now, delay ?? 0),
4376
+ error: message2,
4377
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4378
+ }
4379
+ });
4380
+ const what = `${data.event} of ${data.global ? "global " : ""}"${data.target}"`;
4381
+ if (delay === void 0)
4382
+ this.logger.error(`Outbox gave up on ${what} after ${attempts} attempts: ${message2}`);
4383
+ else this.logger.warn(`Outbox: ${what} failed (${message2}); will retry`);
4384
+ return false;
4385
+ }
4386
+ }
4387
+ };
4388
+ function entryOf(row) {
4389
+ return {
4390
+ event: row.event,
4391
+ target: String(row.target),
4392
+ global: row.global === true,
4393
+ docId: typeof row.docId === "string" ? row.docId : null,
4394
+ before: typeof row.before === "string" ? row.before : null,
4395
+ doc: parse2(row.doc) ?? {},
4396
+ previousDoc: parse2(row.previousDoc),
4397
+ user: parse2(row.user) ?? null
4398
+ };
4399
+ }
4400
+
4190
4401
  // src/remote-file.ts
4191
4402
  import { lookup as dnsLookup } from "dns";
4192
4403
  import { request as httpRequest } from "http";
@@ -4426,7 +4637,7 @@ function read(response, maxBytes, deadline) {
4426
4637
  var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
4427
4638
  var CONFIG = keys(`secret db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
4428
4639
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
4429
- audit logs countCacheSeconds maxPage plugins`);
4640
+ audit logs countCacheSeconds maxPage httpCache outbox plugins`);
4430
4641
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
4431
4642
  var AUTH = keys(`roles rbac tokenExpiration maxLoginAttempts lockWindow trustedOrigins
4432
4643
  resetPasswordExpiration inviteExpiration providers providerSignUp password setupCode members emails`);
@@ -4436,8 +4647,10 @@ var BACKUPS = keys("frequency at keep dir storage sqlite encryptionKey");
4436
4647
  var AUDIT = keys("keepDays values failedLogins scope");
4437
4648
  var LOGS = keys("format requests");
4438
4649
  var COLLECTION = keys(`slug labels fields useAsTitle drafts versions schedule preview access hooks
4439
- admin`);
4440
- var GLOBAL = keys("slug label fields drafts versions schedule preview access hooks scope admin");
4650
+ admin httpCache`);
4651
+ var GLOBAL = keys(
4652
+ "slug label fields drafts versions schedule preview access hooks scope admin httpCache"
4653
+ );
4441
4654
  var CONTAINER_ADMIN = keys("icon order sidebar layout group");
4442
4655
  var COLLECTION_ADMIN = keys(
4443
4656
  "icon order sidebar layout group editIn badge count empty list ownerField confirmDelete"
@@ -4692,6 +4905,23 @@ var Webhooks = class {
4692
4905
  this.track(this.deliver(hook, payload));
4693
4906
  }
4694
4907
  }
4908
+ /**
4909
+ * `emit` for the outbox: resolves once each delivery is saved in the queue (sent, without a
4910
+ * queue), so the event can count as done. Throws when saving fails; nothing is sent then.
4911
+ */
4912
+ async emitSaved(event, target, doc) {
4913
+ const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
4914
+ for (const hook of this.hooks) {
4915
+ if (!matches(hook, payload)) continue;
4916
+ if (!this.queue) {
4917
+ await this.deliver(hook, payload);
4918
+ continue;
4919
+ }
4920
+ await new Promise(
4921
+ (saved, failed) => this.track(this.deliver(hook, payload, { saved: () => saved(), failed }))
4922
+ );
4923
+ }
4924
+ }
4695
4925
  /** Waits for deliveries in progress, e.g. before a serverless function returns. */
4696
4926
  async flush() {
4697
4927
  while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
@@ -4715,7 +4945,7 @@ var Webhooks = class {
4715
4945
  const claimed = await this.queue.claim(id, queued, {
4716
4946
  ...queued,
4717
4947
  attempts,
4718
- nextAttemptAt: later2(now, CLAIM2)
4948
+ nextAttemptAt: later3(now, CLAIM2)
4719
4949
  });
4720
4950
  if (!claimed) continue;
4721
4951
  const result = await this.attempt(hook, queued.event, queued.body, queued.delivery);
@@ -4729,7 +4959,7 @@ var Webhooks = class {
4729
4959
  await this.queue.update(id, {
4730
4960
  ...queued,
4731
4961
  attempts,
4732
- nextAttemptAt: later2(now, delay ?? 0),
4962
+ nextAttemptAt: later3(now, delay ?? 0),
4733
4963
  state: delay === void 0 ? "failed" : "pending",
4734
4964
  error: result.error
4735
4965
  });
@@ -4752,7 +4982,7 @@ var Webhooks = class {
4752
4982
  ).finally(() => this.pending.delete(tracked));
4753
4983
  this.pending.add(tracked);
4754
4984
  }
4755
- async deliver(hook, payload) {
4985
+ async deliver(hook, payload, saving) {
4756
4986
  const delivery = randomUUID2();
4757
4987
  const body = JSON.stringify(payload);
4758
4988
  const { event } = payload;
@@ -4767,13 +4997,15 @@ var Webhooks = class {
4767
4997
  error
4768
4998
  });
4769
4999
  const { queue } = this;
4770
- const saved = queue ? this.saving.then(() => queue.add(entry(0, later2(/* @__PURE__ */ new Date(), CLAIM2), null))) : Promise.resolve(void 0);
5000
+ const saved = queue ? this.saving.then(() => queue.add(entry(0, later3(/* @__PURE__ */ new Date(), CLAIM2), null))) : Promise.resolve(void 0);
4771
5001
  this.saving = saved.catch(() => {
4772
5002
  });
4773
5003
  let queued;
4774
5004
  try {
4775
5005
  queued = await saved;
5006
+ saving?.saved();
4776
5007
  } catch (error) {
5008
+ if (saving) return saving.failed(error);
4777
5009
  this.logger.error(`Could not save webhook delivery: ${error.message}`, { error });
4778
5010
  }
4779
5011
  for (let attempt = 0; ; attempt++) {
@@ -4786,7 +5018,7 @@ var Webhooks = class {
4786
5018
  if (delay === void 0) {
4787
5019
  if (queued === void 0 || !this.queue)
4788
5020
  return this.giveUp(hook, event, result.error, attempt + 1);
4789
- const next = later2(/* @__PURE__ */ new Date(), QUEUE_DELAYS2[0]);
5021
+ const next = later3(/* @__PURE__ */ new Date(), QUEUE_DELAYS2[0]);
4790
5022
  await this.queue.update(queued, entry(attempt + 1, next, result.error));
4791
5023
  this.logger.warn(
4792
5024
  `Webhook ${hook.url} failed for ${event} (${result.error}); will retry later`
@@ -4831,7 +5063,7 @@ var Webhooks = class {
4831
5063
  );
4832
5064
  }
4833
5065
  };
4834
- var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
5066
+ var later3 = (from, ms) => new Date(from.getTime() + ms).toISOString();
4835
5067
  var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
4836
5068
  function matches(hook, payload) {
4837
5069
  if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
@@ -4859,6 +5091,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
4859
5091
  "audit-logs",
4860
5092
  "api-keys",
4861
5093
  "jobs",
5094
+ "outbox",
4862
5095
  "auth",
4863
5096
  "migrations",
4864
5097
  "access",
@@ -4890,7 +5123,9 @@ function validateConfig(config) {
4890
5123
  validateAudit(config, add);
4891
5124
  validateLogs(config, add);
4892
5125
  validateCountCache(config, add);
5126
+ validateOutbox(config, add);
4893
5127
  validateMaxPage(config, add);
5128
+ validateHttpCache(config, add);
4894
5129
  validateAuth(config, add);
4895
5130
  validateCors(config.cors, add);
4896
5131
  validateLocalization(config, add);
@@ -5513,11 +5748,35 @@ function validateUpload(config, add) {
5513
5748
  var FOLDER_KEY = /^[a-z0-9][a-z0-9_-]{0,59}$/;
5514
5749
  var MIME_PATTERN = /^([\w.+-]+\/(\*|[\w.+-]+)|documents|office|archives)$/i;
5515
5750
  var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
5751
+ function validateHttpCache(config, add) {
5752
+ const times = (value, path, purge) => {
5753
+ if (value === void 0) return;
5754
+ if (value === false && !purge) return;
5755
+ if (typeof value !== "object" || value === null) {
5756
+ add(path, purge ? "must be an object" : "must be false or an object");
5757
+ return;
5758
+ }
5759
+ const v = value;
5760
+ for (const key of ["sMaxAge", "staleWhileRevalidate"])
5761
+ if (v[key] !== void 0 && !(Number.isInteger(v[key]) && v[key] >= 0))
5762
+ add(`${path}.${key}`, "must be a whole number of seconds, 0 or more");
5763
+ if (purge && v.purge !== void 0 && typeof v.purge !== "function")
5764
+ add(`${path}.purge`, "must be a function");
5765
+ };
5766
+ times(config.httpCache, "httpCache", true);
5767
+ for (const c of config.collections ?? [])
5768
+ times(c?.httpCache, `collections.${c?.slug}.httpCache`, false);
5769
+ for (const g of config.globals ?? []) times(g?.httpCache, `globals.${g?.slug}.httpCache`, false);
5770
+ }
5516
5771
  function validateMaxPage(config, add) {
5517
5772
  const max = config.maxPage;
5518
5773
  if (max !== void 0 && !(Number.isInteger(max) && max >= 1))
5519
5774
  add("maxPage", "must be a whole number, 1 or more");
5520
5775
  }
5776
+ function validateOutbox(config, add) {
5777
+ if (config.outbox !== void 0 && typeof config.outbox !== "boolean")
5778
+ add("outbox", "must be true or false");
5779
+ }
5521
5780
  function validateCountCache(config, add) {
5522
5781
  const seconds = config.countCacheSeconds;
5523
5782
  if (seconds !== void 0 && !(typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0))
@@ -6379,6 +6638,7 @@ async function resolveConfig(input) {
6379
6638
  ...config.apiKeys ? [apiKeysCollection] : [],
6380
6639
  ...config.email ? [emailDeliveriesCollection] : [],
6381
6640
  ...(config.jobs?.length ?? 0) > 0 ? [jobRunsCollection] : [],
6641
+ ...config.outbox ? [outboxCollection] : [],
6382
6642
  databaseBackupsCollection,
6383
6643
  ...config.auth?.rbac ? [rolesCollection] : [],
6384
6644
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
@@ -6413,6 +6673,41 @@ function resolveAudit(audit) {
6413
6673
  };
6414
6674
  }
6415
6675
 
6676
+ // src/rest/http-cache.ts
6677
+ import { createHash as createHash4 } from "crypto";
6678
+ var cacheTags = {
6679
+ collection: (slug2) => ["ecms", `ecms:${slug2}`],
6680
+ document: (slug2, id) => ["ecms", `ecms:${slug2}`, `ecms:${slug2}:${id}`],
6681
+ global: (slug2) => ["ecms", `ecms:global:${slug2}`]
6682
+ };
6683
+ function cacheTimes(config, container) {
6684
+ const own = container.httpCache;
6685
+ if (own === false || own === void 0 && config.httpCache === void 0) return null;
6686
+ return {
6687
+ sMaxAge: own?.sMaxAge ?? config.httpCache?.sMaxAge ?? 60,
6688
+ staleWhileRevalidate: own?.staleWhileRevalidate ?? config.httpCache?.staleWhileRevalidate ?? 300
6689
+ };
6690
+ }
6691
+ function matches2(header, etag) {
6692
+ if (!header) return false;
6693
+ if (header.trim() === "*") return true;
6694
+ const bare = (tag) => tag.trim().replace(/^W\//, "");
6695
+ return header.split(",").some((tag) => bare(tag) === bare(etag));
6696
+ }
6697
+ function cacheableResponse(request, body, status, headers, times, tags) {
6698
+ headers.set(
6699
+ "cache-control",
6700
+ `public, max-age=0, s-maxage=${times.sMaxAge}, stale-while-revalidate=${times.staleWhileRevalidate}`
6701
+ );
6702
+ headers.set("cache-tag", tags.join(","));
6703
+ headers.set("surrogate-key", tags.join(" "));
6704
+ const etag = `W/"${createHash4("sha1").update(body).digest("base64url")}"`;
6705
+ headers.set("etag", etag);
6706
+ if (matches2(request.headers.get("if-none-match"), etag))
6707
+ return new Response(null, { status: 304, headers });
6708
+ return new Response(request.method.toUpperCase() === "HEAD" ? null : body, { status, headers });
6709
+ }
6710
+
6416
6711
  // src/local-api.ts
6417
6712
  async function createEasyCMS(config, options = {}) {
6418
6713
  const resolved3 = await resolveConfig(config);
@@ -6432,8 +6727,18 @@ async function createEasyCMS(config, options = {}) {
6432
6727
  logger,
6433
6728
  interactive: options.interactive ?? false
6434
6729
  });
6730
+ if (resolved3.outbox && db.outbox !== true) {
6731
+ await db.destroy();
6732
+ throw new ConfigError([
6733
+ {
6734
+ path: "outbox",
6735
+ message: `needs a database adapter that writes outbox rows; "${resolved3.db.name}" doesn't`,
6736
+ hint: "use @easy-cms/db-sqlite or @easy-cms/db-postgres 1.12 or later"
6737
+ }
6738
+ ]);
6739
+ }
6435
6740
  const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
6436
- const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0;
6741
+ const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0 || resolved3.outbox === true;
6437
6742
  if (scheduling && options.scheduler !== false) cms.startScheduler();
6438
6743
  if (process.env.NODE_ENV === "production" && !resolved3.auth.setupCode && await cms.auth.hasUsers().catch(() => true) === false)
6439
6744
  logger.warn(
@@ -6473,6 +6778,8 @@ var EasyCMS = class {
6473
6778
  versions;
6474
6779
  webhooks;
6475
6780
  mailer;
6781
+ /** With `outbox: true`: runs after hooks and events from rows saves write. */
6782
+ outbox;
6476
6783
  /** @internal */
6477
6784
  constructor(config, db, logger = consoleLogger, storage = diskStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
6478
6785
  this.config = config;
@@ -6500,6 +6807,7 @@ var EasyCMS = class {
6500
6807
  logger,
6501
6808
  config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
6502
6809
  );
6810
+ this.outbox = config.collections.some((c) => c.slug === OUTBOX) ? new Outbox(db, logger, (entry, done, mark) => this.runOutbox(entry, done, mark)) : void 0;
6503
6811
  this.auth = new Auth(this);
6504
6812
  }
6505
6813
  /**
@@ -7369,14 +7677,21 @@ var EasyCMS = class {
7369
7677
  ]);
7370
7678
  }
7371
7679
  const draft = { ...prepared, id: parsed, createdAt: existing.createdAt, updatedAt: now };
7372
- await this.saveVersion(config, collectionParent(collection2), parsed, draft, guard);
7373
- await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
7374
- ...base,
7375
- doc: draft,
7376
- previousDoc: current,
7377
- operation: "update"
7680
+ const outbox2 = this.outboxRow(config, "draft", guard, draft, {
7681
+ docId: parsed,
7682
+ previousDoc: current
7378
7683
  });
7379
- this.emitChange(config, "draft", draft);
7684
+ await this.saveVersion(config, collectionParent(collection2), parsed, draft, guard, outbox2);
7685
+ if (outbox2) this.outbox?.kick();
7686
+ else {
7687
+ await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
7688
+ ...base,
7689
+ doc: draft,
7690
+ previousDoc: current,
7691
+ operation: "update"
7692
+ });
7693
+ this.emitChange(config, "draft", draft);
7694
+ }
7380
7695
  await this.audited(config, "draft", draft, current, guard, mode);
7381
7696
  const [out2] = await this.output(config, [draft], guard, {
7382
7697
  ...options,
@@ -7405,7 +7720,18 @@ var EasyCMS = class {
7405
7720
  }
7406
7721
  }
7407
7722
  const data = { ...prepared, createdAt: existing.createdAt, updatedAt: now };
7408
- const doc = options.where ? await this.db.update({ collection: collection2, id: parsed, data, where: options.where }) : await this.db.update({ collection: collection2, id: parsed, data });
7723
+ const outbox = this.outboxRow(
7724
+ config,
7725
+ "update",
7726
+ guard,
7727
+ { ...data, id: parsed },
7728
+ {
7729
+ docId: parsed,
7730
+ previousDoc: current,
7731
+ before: existing.status
7732
+ }
7733
+ );
7734
+ const doc = options.where ? await this.db.update({ collection: collection2, id: parsed, data, where: options.where, outbox }) : await this.db.update({ collection: collection2, id: parsed, data, outbox });
7409
7735
  if (!doc) return null;
7410
7736
  if (moved) await moved.cleanUp();
7411
7737
  if (collection2 === MEDIA_FOLDERS) {
@@ -7415,13 +7741,16 @@ var EasyCMS = class {
7415
7741
  }
7416
7742
  if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
7417
7743
  if (password !== void 0) await this.auth.revokeSessions(parsed);
7418
- await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
7419
- ...base,
7420
- doc,
7421
- previousDoc: current,
7422
- operation: "update"
7423
- });
7424
- this.emitChange(config, "update", doc, existing.status);
7744
+ if (outbox) this.outbox?.kick();
7745
+ else {
7746
+ await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
7747
+ ...base,
7748
+ doc,
7749
+ previousDoc: current,
7750
+ operation: "update"
7751
+ });
7752
+ this.emitChange(config, "update", doc, existing.status);
7753
+ }
7425
7754
  await this.audited(config, "update", doc, current, guard, mode);
7426
7755
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
7427
7756
  return out;
@@ -7459,7 +7788,8 @@ var EasyCMS = class {
7459
7788
  await this.checkMoveLimit(await this.folders.privacyAfter(parsed, { private: false }));
7460
7789
  await this.folders.release(existing);
7461
7790
  }
7462
- await this.db.delete({ collection: collection2, id: parsed });
7791
+ const outbox = this.outboxRow(config, "delete", guard, existing, { docId: parsed });
7792
+ await this.db.delete({ collection: collection2, id: parsed, outbox });
7463
7793
  if (collection2 === MEDIA_FOLDERS) {
7464
7794
  this.folders.invalidate();
7465
7795
  const parent = existing.parent ?? null;
@@ -7471,12 +7801,15 @@ var EasyCMS = class {
7471
7801
  for (const job of await this.pendingJobs(collection2, parsed))
7472
7802
  await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
7473
7803
  }
7474
- await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
7475
- ...base,
7476
- id: parsed,
7477
- doc: existing
7478
- });
7479
- this.emitChange(config, "delete", existing);
7804
+ if (outbox) this.outbox?.kick();
7805
+ else {
7806
+ await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
7807
+ ...base,
7808
+ id: parsed,
7809
+ doc: existing
7810
+ });
7811
+ this.emitChange(config, "delete", existing);
7812
+ }
7480
7813
  await this.audited(config, "delete", existing, existing, guard);
7481
7814
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
7482
7815
  return out;
@@ -7633,26 +7966,38 @@ var EasyCMS = class {
7633
7966
  const now = (/* @__PURE__ */ new Date()).toISOString();
7634
7967
  if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
7635
7968
  const draft = { ...prepared, updatedAt: now };
7636
- await this.saveVersion(config, globalParent(key), 0, draft, guard);
7969
+ const outbox2 = this.outboxRow(config, "draft", guard, draft, { previousDoc: current });
7970
+ await this.saveVersion(config, globalParent(key), 0, draft, guard, outbox2);
7971
+ if (outbox2) this.outbox?.kick();
7972
+ else {
7973
+ await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
7974
+ ...base,
7975
+ doc: draft,
7976
+ previousDoc: current,
7977
+ operation: "update"
7978
+ });
7979
+ this.emitChange(config, "draft", draft);
7980
+ }
7981
+ await this.audited(config, "draft", draft, current, guard, mode);
7982
+ return this.readGlobal(config, key, guard, { ...options, draft: true });
7983
+ }
7984
+ const data = { ...prepared, updatedAt: now };
7985
+ const outbox = this.outboxRow(config, "update", guard, data, {
7986
+ previousDoc: current,
7987
+ before: existing.status
7988
+ });
7989
+ const doc = await this.db.updateGlobal({ slug: key, data, outbox });
7990
+ await this.saveVersion(config, globalParent(key), 0, doc, guard);
7991
+ if (outbox) this.outbox?.kick();
7992
+ else {
7637
7993
  await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
7638
7994
  ...base,
7639
- doc: draft,
7995
+ doc,
7640
7996
  previousDoc: current,
7641
7997
  operation: "update"
7642
7998
  });
7643
- this.emitChange(config, "draft", draft);
7644
- await this.audited(config, "draft", draft, current, guard, mode);
7645
- return this.readGlobal(config, key, guard, { ...options, draft: true });
7999
+ this.emitChange(config, "update", doc, existing.status);
7646
8000
  }
7647
- const doc = await this.db.updateGlobal({ slug: key, data: { ...prepared, updatedAt: now } });
7648
- await this.saveVersion(config, globalParent(key), 0, doc, guard);
7649
- await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
7650
- ...base,
7651
- doc,
7652
- previousDoc: current,
7653
- operation: "update"
7654
- });
7655
- this.emitChange(config, "update", doc, existing.status);
7656
8001
  await this.audited(config, "update", doc, current, guard, mode);
7657
8002
  return this.readGlobal(config, key, guard, { ...options, draft: true });
7658
8003
  }
@@ -7766,9 +8111,21 @@ var EasyCMS = class {
7766
8111
  }
7767
8112
  };
7768
8113
  }
8114
+ /**
8115
+ * Waits until the outbox (`outbox: true`) ran what saves left so far, e.g. before a serverless
8116
+ * function returns. What fails stays queued for its retry.
8117
+ */
8118
+ flushOutbox() {
8119
+ return this.outbox?.flush() ?? Promise.resolve();
8120
+ }
8121
+ /** @internal Puts a failed outbox entry back in line (Settings → Jobs). */
8122
+ retryOutbox(id) {
8123
+ return this.outbox?.retry(id) ?? Promise.resolve(false);
8124
+ }
7769
8125
  async destroy() {
7770
8126
  if (this.schedulerTimer) clearInterval(this.schedulerTimer);
7771
8127
  await this.running;
8128
+ await this.outbox?.flush();
7772
8129
  await this.webhooks.flush();
7773
8130
  await this.mailer.flush();
7774
8131
  await this.db.destroy();
@@ -7815,7 +8172,8 @@ var EasyCMS = class {
7815
8172
  scheduled,
7816
8173
  webhooks: await this.retryWebhooks(now),
7817
8174
  emails: await this.mailer.retry(now),
7818
- jobs: await this.runConfigJobs(now)
8175
+ jobs: await this.runConfigJobs(now),
8176
+ outbox: await this.outbox?.run(now) ?? { ran: 0, failed: 0 }
7819
8177
  };
7820
8178
  const ranAt = (/* @__PURE__ */ new Date()).toISOString();
7821
8179
  await this.db.updateGlobal({ slug: JOBS_RAN, data: { at: ranAt, updatedAt: ranAt } }).catch((error) => this.logger.error(`Jobs: ${error.message}`, { error }));
@@ -8391,11 +8749,11 @@ var EasyCMS = class {
8391
8749
  const access = allowed2 === false ? false : andWhere(own === true ? void 0 : own, allowed2) ?? true;
8392
8750
  if (access === true) return;
8393
8751
  if (access !== false) {
8394
- const matches2 = await this.db.count({
8752
+ const matches3 = await this.db.count({
8395
8753
  collection: config.slug,
8396
8754
  where: andWhere(access, { id: { equals: id } })
8397
8755
  });
8398
- if (matches2 > 0) return;
8756
+ if (matches3 > 0) return;
8399
8757
  }
8400
8758
  throw deny(guard.user);
8401
8759
  }
@@ -8449,11 +8807,11 @@ var EasyCMS = class {
8449
8807
  await this.hidingDenied(collection2, guard, id, async () => {
8450
8808
  const own = await this.checkGrant(guard, { collection: collection2 }, operation);
8451
8809
  if (own === true) return;
8452
- const matches2 = await this.db.count({
8810
+ const matches3 = await this.db.count({
8453
8811
  collection: collection2,
8454
8812
  where: andWhere(own, { id: { equals: id } })
8455
8813
  });
8456
- if (matches2 === 0) throw deny(guard.user);
8814
+ if (matches3 === 0) throw deny(guard.user);
8457
8815
  });
8458
8816
  }
8459
8817
  /** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
@@ -8547,18 +8905,20 @@ var EasyCMS = class {
8547
8905
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
8548
8906
  if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
8549
8907
  const now = (/* @__PURE__ */ new Date()).toISOString();
8550
- const doc = await this.db.create({
8551
- collection: collection2,
8552
- data: { ...prepared, createdAt: now, updatedAt: now }
8553
- });
8908
+ const row = { ...prepared, createdAt: now, updatedAt: now };
8909
+ const outbox = this.outboxRow(config, "create", hookGuard ?? guard, row);
8910
+ const doc = await this.db.create({ collection: collection2, data: row, outbox });
8554
8911
  if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
8555
8912
  await this.saveVersion(config, collectionParent(collection2), doc.id, doc, guard);
8556
- await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
8557
- ...base,
8558
- doc,
8559
- operation: "create"
8560
- });
8561
- this.emitChange(config, "create", doc);
8913
+ if (outbox) this.outbox?.kick();
8914
+ else {
8915
+ await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
8916
+ ...base,
8917
+ doc,
8918
+ operation: "create"
8919
+ });
8920
+ this.emitChange(config, "create", doc);
8921
+ }
8562
8922
  await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
8563
8923
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
8564
8924
  return out;
@@ -8615,10 +8975,22 @@ var EasyCMS = class {
8615
8975
  return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
8616
8976
  });
8617
8977
  }
8618
- /** Sends webhook events for a change; `before` is the stored status before it, if any. */
8619
- emitChange(config, event, doc, before) {
8620
- const isGlobal = !this.config.collections.includes(config);
8621
- if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
8978
+ /**
8979
+ * The tags `httpCache.purge` gets for a change: what readers see changed (anything, without
8980
+ * drafts; with drafts, a published document saved, published, unpublished or deleted).
8981
+ */
8982
+ purgeTags(config, event, doc, before) {
8983
+ if (!this.config.httpCache?.purge || config.httpCache === false || event === "draft") return;
8984
+ if (config.drafts && doc.status !== "published" && before !== "published") return;
8985
+ return this.isGlobal(config) ? cacheTags.global(config.slug) : cacheTags.document(config.slug, doc.id);
8986
+ }
8987
+ isGlobal(config) {
8988
+ return !this.config.collections.includes(config);
8989
+ }
8990
+ /** The webhook events of a change; `before` is the stored status before it, if any. */
8991
+ changeEvents(config, event, doc, before) {
8992
+ const isGlobal = this.isGlobal(config);
8993
+ if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return [];
8622
8994
  const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
8623
8995
  id: doc.id,
8624
8996
  ...snapshotOf(config.fields, doc),
@@ -8626,12 +8998,77 @@ var EasyCMS = class {
8626
8998
  updatedAt: doc.updatedAt
8627
8999
  };
8628
9000
  const target = isGlobal ? { global: config.slug } : { collection: config.slug, id: doc.id };
8629
- this.webhooks.emit(event, target, body);
8630
- if (event === "draft" || event === "delete" || !config.drafts) return;
9001
+ const events = [{ event, target, body }];
9002
+ if (event === "draft" || event === "delete" || !config.drafts) return events;
8631
9003
  const after = doc.status;
8632
- if (after === "published" && before !== "published") this.webhooks.emit("publish", target, body);
9004
+ if (after === "published" && before !== "published")
9005
+ events.push({ event: "publish", target, body });
8633
9006
  if (after !== "published" && before === "published")
8634
- this.webhooks.emit("unpublish", target, body);
9007
+ events.push({ event: "unpublish", target, body });
9008
+ return events;
9009
+ }
9010
+ /**
9011
+ * Sends webhook events for a change and purges the HTTP cache. Not awaited; purge failures
9012
+ * are logged.
9013
+ */
9014
+ emitChange(config, event, doc, before) {
9015
+ for (const change of this.changeEvents(config, event, doc, before))
9016
+ this.webhooks.emit(change.event, change.target, change.body);
9017
+ const purge = this.config.httpCache?.purge;
9018
+ const tags = this.purgeTags(config, event, doc, before);
9019
+ if (!purge || !tags) return;
9020
+ void Promise.resolve().then(() => purge(tags)).catch(
9021
+ (error) => this.logger.error(`httpCache.purge failed: ${error.message}`, { error })
9022
+ );
9023
+ }
9024
+ /**
9025
+ * The outbox row of a save (`outbox: true`): written in its transaction, then run by the outbox
9026
+ * in place of its after hooks and events. `undefined` without the outbox, or with nothing to run.
9027
+ */
9028
+ outboxRow(config, event, guard, doc, extra = {}) {
9029
+ if (!this.outbox) return void 0;
9030
+ const hooks = event === "delete" ? config.hooks?.afterDelete : config.hooks?.afterChange;
9031
+ const isGlobal = this.isGlobal(config);
9032
+ const effects = (isGlobal || !INTERNAL_COLLECTIONS.has(config.slug)) && ((this.config.webhooks?.length ?? 0) > 0 || this.config.httpCache?.purge !== void 0);
9033
+ if (!hooks?.length && !effects) return void 0;
9034
+ return this.outbox.row({
9035
+ event,
9036
+ target: config.slug,
9037
+ global: isGlobal,
9038
+ docId: extra.docId,
9039
+ before: typeof extra.before === "string" ? extra.before : null,
9040
+ doc,
9041
+ previousDoc: extra.previousDoc,
9042
+ user: guard.user
9043
+ });
9044
+ }
9045
+ /** Runs an outbox entry: what its save would have run right after it, without the outbox. */
9046
+ async runOutbox(entry, done, mark) {
9047
+ const config = entry.global ? this.config.globals.find((g) => g.slug === entry.target) : this.config.collections.find((c) => c.slug === entry.target);
9048
+ if (!config) return;
9049
+ const { doc, event } = entry;
9050
+ if (!done.has("hooks")) {
9051
+ const base = this.hookArgs(config, { enforce: false, user: entry.user, context: {} });
9052
+ const hooks = event === "delete" ? config.hooks?.afterDelete : config.hooks?.afterChange;
9053
+ const args = event === "delete" ? { ...base, id: parseId(entry.docId ?? void 0), doc } : {
9054
+ ...base,
9055
+ doc,
9056
+ ...entry.previousDoc ? { previousDoc: entry.previousDoc } : {},
9057
+ operation: event === "create" ? "create" : "update"
9058
+ };
9059
+ for (const hook of hooks ?? [])
9060
+ await hook(args);
9061
+ if (hooks?.length) await mark("hooks");
9062
+ }
9063
+ if (!done.has("webhooks")) {
9064
+ const changes = this.changeEvents(config, event, doc, entry.before);
9065
+ for (const change of changes)
9066
+ await this.webhooks.emitSaved(change.event, change.target, change.body);
9067
+ if (changes.length > 0 && (this.config.webhooks?.length ?? 0) > 0) await mark("webhooks");
9068
+ }
9069
+ const purge = this.config.httpCache?.purge;
9070
+ const tags = this.purgeTags(config, event, doc, entry.before);
9071
+ if (purge && tags) await purge(tags);
8635
9072
  }
8636
9073
  /**
8637
9074
  * Writes the audit log entry for a change (`audit`): publishing, unpublishing and restoring
@@ -8662,7 +9099,7 @@ var EasyCMS = class {
8662
9099
  );
8663
9100
  }
8664
9101
  /** Records a version after a save, when the collection or global keeps versions. */
8665
- async saveVersion(config, parent, id, doc, guard) {
9102
+ async saveVersion(config, parent, id, doc, guard, outbox) {
8666
9103
  const max = versionLimit(config);
8667
9104
  if (max === void 0) return;
8668
9105
  await this.versions.save({
@@ -8671,7 +9108,8 @@ var EasyCMS = class {
8671
9108
  status: config.drafts ? doc.status ?? "draft" : null,
8672
9109
  snapshot: snapshotOf(config.fields, doc),
8673
9110
  author: guard.user?.id ?? null,
8674
- max
9111
+ max,
9112
+ outbox
8675
9113
  });
8676
9114
  }
8677
9115
  hookArgs(config, guard) {
@@ -9076,7 +9514,7 @@ function durationSeconds(value) {
9076
9514
  }
9077
9515
  function cursorQuery(collection2, where, sort, locale, draft) {
9078
9516
  const key = JSON.stringify([collection2, where ?? null, sort, locale ?? null, draft]);
9079
- return createHash4("sha256").update(key).digest("base64url").slice(0, 16);
9517
+ return createHash5("sha256").update(key).digest("base64url").slice(0, 16);
9080
9518
  }
9081
9519
  function writeCursor(keys2, query) {
9082
9520
  return Buffer.from(JSON.stringify({ k: keys2, q: query })).toString("base64url");
@@ -9100,7 +9538,7 @@ function readCursor(cursor, query) {
9100
9538
  // src/rest/admin-jobs.ts
9101
9539
  var has = (config, slug2) => config.collections.some((c) => c.slug === slug2);
9102
9540
  function hasJobsPage(config) {
9103
- return config.jobs.length > 0 || has(config, SCHEDULED_JOBS);
9541
+ return config.jobs.length > 0 || has(config, SCHEDULED_JOBS) || has(config, OUTBOX);
9104
9542
  }
9105
9543
  var text2 = (value) => typeof value === "string" && value !== "" ? value : null;
9106
9544
  async function adminJobs(cms, user) {
@@ -9133,6 +9571,7 @@ async function adminJobs(cms, user) {
9133
9571
  ...has(config, SCHEDULED_JOBS) ? ["scheduled"] : [],
9134
9572
  ...has(config, WEBHOOK_DELIVERIES) ? ["webhooks"] : [],
9135
9573
  ...has(config, EMAIL_DELIVERIES) ? ["emails"] : [],
9574
+ ...has(config, OUTBOX) ? ["outbox"] : [],
9136
9575
  ...config.backups?.frequency ? ["backups"] : [],
9137
9576
  ...config.audit ? ["audit"] : []
9138
9577
  ];
@@ -9141,7 +9580,8 @@ async function adminJobs(cms, user) {
9141
9580
  lastRanAt: await cms.jobsLastRan(),
9142
9581
  builtIn,
9143
9582
  timeZone: config.timeZone ?? null,
9144
- canChange: isSystemAdmin(user)
9583
+ canChange: isSystemAdmin(user),
9584
+ outbox: has(config, OUTBOX) ? await adminOutbox(cms) : null
9145
9585
  };
9146
9586
  if (!has(config, SCHEDULED_JOBS)) return { ...head, scheduled: null };
9147
9587
  const { docs } = await cms.db.find({
@@ -9166,6 +9606,38 @@ async function adminJobs(cms, user) {
9166
9606
  });
9167
9607
  return { ...head, scheduled };
9168
9608
  }
9609
+ async function adminOutbox(cms) {
9610
+ const count = (state) => cms.db.count({ collection: OUTBOX, where: { state: { in: state } } });
9611
+ const { docs } = await cms.db.find({
9612
+ collection: OUTBOX,
9613
+ where: { attempts: { gt: 0 } },
9614
+ sort: ["-updatedAt"],
9615
+ limit: 100,
9616
+ page: 1
9617
+ });
9618
+ return {
9619
+ waiting: await count(["pending", "running"]),
9620
+ failed: await count(["failed"]),
9621
+ entries: docs.filter((row) => text2(row.error) !== null).map((row) => ({
9622
+ id: row.id,
9623
+ event: row.event,
9624
+ ...row.global === true ? { global: String(row.target) } : { collection: String(row.target) },
9625
+ doc: text2(row.docId),
9626
+ attempts: Number(row.attempts ?? 0),
9627
+ state: row.state === "failed" || row.state === "running" ? row.state : "pending",
9628
+ error: text2(row.error),
9629
+ nextAttemptAt: String(row.nextAttemptAt)
9630
+ }))
9631
+ };
9632
+ }
9633
+ async function retryOutbox(cms, user, id) {
9634
+ if (!isSystemAdmin(user)) throw new ForbiddenError();
9635
+ if (!has(cms.config, OUTBOX)) throw new NotFoundError(OUTBOX, id);
9636
+ const row = await cms.db.findById({ collection: OUTBOX, id: parseId(id) ?? 0 });
9637
+ if (!row) throw new NotFoundError(OUTBOX, id);
9638
+ if (!await cms.retryOutbox(row.id))
9639
+ throw new EasyCMSError("Only failed entries can be retried", 409);
9640
+ }
9169
9641
  var TIME_OF_DAY2 = /^([01]\d|2[0-3]):[0-5]\d$/;
9170
9642
  async function updateJob(cms, user, name, body) {
9171
9643
  if (!isSystemAdmin(user)) throw new ForbiddenError();
@@ -9827,7 +10299,7 @@ function rank(role, config) {
9827
10299
  }
9828
10300
 
9829
10301
  // src/rest/admin-modules.ts
9830
- import { createHash as createHash5 } from "crypto";
10302
+ import { createHash as createHash6 } from "crypto";
9831
10303
  import { readFile as readFile3 } from "fs/promises";
9832
10304
  import { createRequire } from "module";
9833
10305
  import { join as join3 } from "path";
@@ -9868,11 +10340,11 @@ async function readAdminModule(cms, index) {
9868
10340
  if (!file) return void 0;
9869
10341
  const body = await readFile3(file, "utf8").catch(() => void 0);
9870
10342
  if (body === void 0) return void 0;
9871
- return { body, etag: `"${createHash5("sha1").update(body).digest("base64url")}"` };
10343
+ return { body, etag: `"${createHash6("sha1").update(body).digest("base64url")}"` };
9872
10344
  }
9873
10345
 
9874
10346
  // src/version.ts
9875
- var VERSION = true ? "1.10.0" : "0.0.0-dev";
10347
+ var VERSION = true ? "1.12.0" : "0.0.0-dev";
9876
10348
 
9877
10349
  // src/rest/handler.ts
9878
10350
  import { randomUUID as randomUUID4 } from "crypto";
@@ -9882,7 +10354,7 @@ var PER_PAGE = 50;
9882
10354
  var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
9883
10355
  var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
9884
10356
  var isDeliveryKind = (value) => value === "webhook" || value === "email";
9885
- function parse2(text3) {
10357
+ function parse3(text3) {
9886
10358
  try {
9887
10359
  const value = JSON.parse(String(text3 ?? ""));
9888
10360
  return typeof value === "object" && value !== null ? value : {};
@@ -9901,7 +10373,7 @@ function toAdmin2(kind, row) {
9901
10373
  nextAttemptAt: String(row.nextAttemptAt ?? "")
9902
10374
  };
9903
10375
  if (kind === "webhook") {
9904
- const payload = parse2(row.body);
10376
+ const payload = parse3(row.body);
9905
10377
  return {
9906
10378
  ...base,
9907
10379
  kind,
@@ -9913,7 +10385,7 @@ function toAdmin2(kind, row) {
9913
10385
  body: String(row.body ?? "")
9914
10386
  };
9915
10387
  }
9916
- const message2 = parse2(row.message);
10388
+ const message2 = parse3(row.message);
9917
10389
  const to = message2.to;
9918
10390
  return {
9919
10391
  ...base,
@@ -10917,7 +11389,18 @@ async function respondNow(cms, request, url, path, root, options, production, re
10917
11389
  );
10918
11390
  if (!result) throw new HttpError("Not found", 404);
10919
11391
  if (result.body instanceof Response) return result.body;
10920
- return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
11392
+ const body = JSON.stringify(result.body);
11393
+ const times = result.cache && !user && !ctx.via && !ctx.token && !url.searchParams.has("draft") ? cacheTimes(cms.config, result.cache.container) : null;
11394
+ if (times && result.cache)
11395
+ return cacheableResponse(
11396
+ request,
11397
+ body,
11398
+ result.status ?? 200,
11399
+ headers,
11400
+ times,
11401
+ result.cache.tags
11402
+ );
11403
+ return new Response(body, { status: result.status ?? 200, headers });
10921
11404
  } catch (error) {
10922
11405
  return errorResponse(cms, error, headers, production);
10923
11406
  }
@@ -11419,6 +11902,13 @@ async function route(cms, ctx, method, path, options) {
11419
11902
  await cms.audit.record({ action: "jobs.run", target: "jobs", detail: result });
11420
11903
  return { body: result };
11421
11904
  }
11905
+ if (third === "outbox" && segments.length === 4) {
11906
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
11907
+ const id2 = segments[3];
11908
+ await retryOutbox(cms, ctx.user, id2);
11909
+ await cms.audit.record({ action: "jobs.outbox.retry", target: "jobs", detail: { id: id2 } });
11910
+ return { body: await adminJobs(cms, ctx.user) };
11911
+ }
11422
11912
  if (third !== void 0 && segments.length === 3) {
11423
11913
  if (method !== "PATCH") throw methodNotAllowed(ctx, "PATCH");
11424
11914
  const { action, detail } = await updateJob(
@@ -11501,7 +11991,11 @@ async function route(cms, ctx, method, path, options) {
11501
11991
  })
11502
11992
  };
11503
11993
  }
11504
- return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
11994
+ const global2 = cms.config.globals.find((g) => g.slug === second);
11995
+ return {
11996
+ body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }),
11997
+ cache: { container: global2, tags: cacheTags.global(second) }
11998
+ };
11505
11999
  }
11506
12000
  if (method === "PATCH" || method === "POST") {
11507
12001
  if (method === "POST")
@@ -11598,7 +12092,8 @@ async function route(cms, ctx, method, path, options) {
11598
12092
  body: await cms.find(
11599
12093
  collection2,
11600
12094
  query.cursor === void 0 ? { ...options2, page } : options2
11601
- )
12095
+ ),
12096
+ cache: { container: cms.collection(collection2), tags: cacheTags.collection(collection2) }
11602
12097
  };
11603
12098
  }
11604
12099
  if (method === "POST") {
@@ -11625,7 +12120,13 @@ async function route(cms, ctx, method, path, options) {
11625
12120
  if (method === "GET") {
11626
12121
  const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
11627
12122
  if (!doc) throw new NotFoundError(collection2, id);
11628
- return { body: doc };
12123
+ return {
12124
+ body: doc,
12125
+ cache: {
12126
+ container: cms.collection(collection2),
12127
+ tags: cacheTags.document(collection2, doc.id)
12128
+ }
12129
+ };
11629
12130
  }
11630
12131
  if (method === "PATCH") {
11631
12132
  const body = await readJson(ctx.request);
@@ -11784,8 +12285,8 @@ function withPreviewToken(url, token) {
11784
12285
  }
11785
12286
  async function requirePreview(cms, ctx, target) {
11786
12287
  const opened = cms.verifyPreviewToken(ctx.url.searchParams.get("preview"));
11787
- 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);
11788
- if (!matches2) throw new UnauthorizedError("Invalid or expired preview token");
12288
+ const matches3 = opened !== null && ("global" in target ? "global" in opened && opened.global === target.global : "collection" in opened && opened.collection === target.collection && opened.id === target.id);
12289
+ if (!matches3) throw new UnauthorizedError("Invalid or expired preview token");
11789
12290
  if (opened.user === void 0) return {};
11790
12291
  const id = parseId(opened.user);
11791
12292
  const issuer = id === void 0 ? null : await cms.auth.activeUser(id);
@@ -12293,6 +12794,7 @@ export {
12293
12794
  DEFAULT_TOKEN_EXPIRATION,
12294
12795
  applyPlugins,
12295
12796
  resolveConfig,
12797
+ cacheTags,
12296
12798
  createEasyCMS,
12297
12799
  EasyCMS,
12298
12800
  resolveAdminModule,