@easy-cms/core 1.11.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);
@@ -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 httpCache 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`);
@@ -4694,6 +4905,23 @@ var Webhooks = class {
4694
4905
  this.track(this.deliver(hook, payload));
4695
4906
  }
4696
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
+ }
4697
4925
  /** Waits for deliveries in progress, e.g. before a serverless function returns. */
4698
4926
  async flush() {
4699
4927
  while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
@@ -4717,7 +4945,7 @@ var Webhooks = class {
4717
4945
  const claimed = await this.queue.claim(id, queued, {
4718
4946
  ...queued,
4719
4947
  attempts,
4720
- nextAttemptAt: later2(now, CLAIM2)
4948
+ nextAttemptAt: later3(now, CLAIM2)
4721
4949
  });
4722
4950
  if (!claimed) continue;
4723
4951
  const result = await this.attempt(hook, queued.event, queued.body, queued.delivery);
@@ -4731,7 +4959,7 @@ var Webhooks = class {
4731
4959
  await this.queue.update(id, {
4732
4960
  ...queued,
4733
4961
  attempts,
4734
- nextAttemptAt: later2(now, delay ?? 0),
4962
+ nextAttemptAt: later3(now, delay ?? 0),
4735
4963
  state: delay === void 0 ? "failed" : "pending",
4736
4964
  error: result.error
4737
4965
  });
@@ -4754,7 +4982,7 @@ var Webhooks = class {
4754
4982
  ).finally(() => this.pending.delete(tracked));
4755
4983
  this.pending.add(tracked);
4756
4984
  }
4757
- async deliver(hook, payload) {
4985
+ async deliver(hook, payload, saving) {
4758
4986
  const delivery = randomUUID2();
4759
4987
  const body = JSON.stringify(payload);
4760
4988
  const { event } = payload;
@@ -4769,13 +4997,15 @@ var Webhooks = class {
4769
4997
  error
4770
4998
  });
4771
4999
  const { queue } = this;
4772
- 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);
4773
5001
  this.saving = saved.catch(() => {
4774
5002
  });
4775
5003
  let queued;
4776
5004
  try {
4777
5005
  queued = await saved;
5006
+ saving?.saved();
4778
5007
  } catch (error) {
5008
+ if (saving) return saving.failed(error);
4779
5009
  this.logger.error(`Could not save webhook delivery: ${error.message}`, { error });
4780
5010
  }
4781
5011
  for (let attempt = 0; ; attempt++) {
@@ -4788,7 +5018,7 @@ var Webhooks = class {
4788
5018
  if (delay === void 0) {
4789
5019
  if (queued === void 0 || !this.queue)
4790
5020
  return this.giveUp(hook, event, result.error, attempt + 1);
4791
- const next = later2(/* @__PURE__ */ new Date(), QUEUE_DELAYS2[0]);
5021
+ const next = later3(/* @__PURE__ */ new Date(), QUEUE_DELAYS2[0]);
4792
5022
  await this.queue.update(queued, entry(attempt + 1, next, result.error));
4793
5023
  this.logger.warn(
4794
5024
  `Webhook ${hook.url} failed for ${event} (${result.error}); will retry later`
@@ -4833,7 +5063,7 @@ var Webhooks = class {
4833
5063
  );
4834
5064
  }
4835
5065
  };
4836
- var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
5066
+ var later3 = (from, ms) => new Date(from.getTime() + ms).toISOString();
4837
5067
  var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
4838
5068
  function matches(hook, payload) {
4839
5069
  if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
@@ -4861,6 +5091,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
4861
5091
  "audit-logs",
4862
5092
  "api-keys",
4863
5093
  "jobs",
5094
+ "outbox",
4864
5095
  "auth",
4865
5096
  "migrations",
4866
5097
  "access",
@@ -4892,6 +5123,7 @@ function validateConfig(config) {
4892
5123
  validateAudit(config, add);
4893
5124
  validateLogs(config, add);
4894
5125
  validateCountCache(config, add);
5126
+ validateOutbox(config, add);
4895
5127
  validateMaxPage(config, add);
4896
5128
  validateHttpCache(config, add);
4897
5129
  validateAuth(config, add);
@@ -5541,6 +5773,10 @@ function validateMaxPage(config, add) {
5541
5773
  if (max !== void 0 && !(Number.isInteger(max) && max >= 1))
5542
5774
  add("maxPage", "must be a whole number, 1 or more");
5543
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
+ }
5544
5780
  function validateCountCache(config, add) {
5545
5781
  const seconds = config.countCacheSeconds;
5546
5782
  if (seconds !== void 0 && !(typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0))
@@ -6402,6 +6638,7 @@ async function resolveConfig(input) {
6402
6638
  ...config.apiKeys ? [apiKeysCollection] : [],
6403
6639
  ...config.email ? [emailDeliveriesCollection] : [],
6404
6640
  ...(config.jobs?.length ?? 0) > 0 ? [jobRunsCollection] : [],
6641
+ ...config.outbox ? [outboxCollection] : [],
6405
6642
  databaseBackupsCollection,
6406
6643
  ...config.auth?.rbac ? [rolesCollection] : [],
6407
6644
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
@@ -6490,8 +6727,18 @@ async function createEasyCMS(config, options = {}) {
6490
6727
  logger,
6491
6728
  interactive: options.interactive ?? false
6492
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
+ }
6493
6740
  const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
6494
- 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;
6495
6742
  if (scheduling && options.scheduler !== false) cms.startScheduler();
6496
6743
  if (process.env.NODE_ENV === "production" && !resolved3.auth.setupCode && await cms.auth.hasUsers().catch(() => true) === false)
6497
6744
  logger.warn(
@@ -6531,6 +6778,8 @@ var EasyCMS = class {
6531
6778
  versions;
6532
6779
  webhooks;
6533
6780
  mailer;
6781
+ /** With `outbox: true`: runs after hooks and events from rows saves write. */
6782
+ outbox;
6534
6783
  /** @internal */
6535
6784
  constructor(config, db, logger = consoleLogger, storage = diskStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
6536
6785
  this.config = config;
@@ -6558,6 +6807,7 @@ var EasyCMS = class {
6558
6807
  logger,
6559
6808
  config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
6560
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;
6561
6811
  this.auth = new Auth(this);
6562
6812
  }
6563
6813
  /**
@@ -7427,14 +7677,21 @@ var EasyCMS = class {
7427
7677
  ]);
7428
7678
  }
7429
7679
  const draft = { ...prepared, id: parsed, createdAt: existing.createdAt, updatedAt: now };
7430
- await this.saveVersion(config, collectionParent(collection2), parsed, draft, guard);
7431
- await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
7432
- ...base,
7433
- doc: draft,
7434
- previousDoc: current,
7435
- operation: "update"
7680
+ const outbox2 = this.outboxRow(config, "draft", guard, draft, {
7681
+ docId: parsed,
7682
+ previousDoc: current
7436
7683
  });
7437
- 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
+ }
7438
7695
  await this.audited(config, "draft", draft, current, guard, mode);
7439
7696
  const [out2] = await this.output(config, [draft], guard, {
7440
7697
  ...options,
@@ -7463,7 +7720,18 @@ var EasyCMS = class {
7463
7720
  }
7464
7721
  }
7465
7722
  const data = { ...prepared, createdAt: existing.createdAt, updatedAt: now };
7466
- 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 });
7467
7735
  if (!doc) return null;
7468
7736
  if (moved) await moved.cleanUp();
7469
7737
  if (collection2 === MEDIA_FOLDERS) {
@@ -7473,13 +7741,16 @@ var EasyCMS = class {
7473
7741
  }
7474
7742
  if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
7475
7743
  if (password !== void 0) await this.auth.revokeSessions(parsed);
7476
- await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
7477
- ...base,
7478
- doc,
7479
- previousDoc: current,
7480
- operation: "update"
7481
- });
7482
- 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
+ }
7483
7754
  await this.audited(config, "update", doc, current, guard, mode);
7484
7755
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
7485
7756
  return out;
@@ -7517,7 +7788,8 @@ var EasyCMS = class {
7517
7788
  await this.checkMoveLimit(await this.folders.privacyAfter(parsed, { private: false }));
7518
7789
  await this.folders.release(existing);
7519
7790
  }
7520
- 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 });
7521
7793
  if (collection2 === MEDIA_FOLDERS) {
7522
7794
  this.folders.invalidate();
7523
7795
  const parent = existing.parent ?? null;
@@ -7529,12 +7801,15 @@ var EasyCMS = class {
7529
7801
  for (const job of await this.pendingJobs(collection2, parsed))
7530
7802
  await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
7531
7803
  }
7532
- await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
7533
- ...base,
7534
- id: parsed,
7535
- doc: existing
7536
- });
7537
- 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
+ }
7538
7813
  await this.audited(config, "delete", existing, existing, guard);
7539
7814
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
7540
7815
  return out;
@@ -7691,26 +7966,38 @@ var EasyCMS = class {
7691
7966
  const now = (/* @__PURE__ */ new Date()).toISOString();
7692
7967
  if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
7693
7968
  const draft = { ...prepared, updatedAt: now };
7694
- 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 {
7695
7993
  await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
7696
7994
  ...base,
7697
- doc: draft,
7995
+ doc,
7698
7996
  previousDoc: current,
7699
7997
  operation: "update"
7700
7998
  });
7701
- this.emitChange(config, "draft", draft);
7702
- await this.audited(config, "draft", draft, current, guard, mode);
7703
- return this.readGlobal(config, key, guard, { ...options, draft: true });
7999
+ this.emitChange(config, "update", doc, existing.status);
7704
8000
  }
7705
- const doc = await this.db.updateGlobal({ slug: key, data: { ...prepared, updatedAt: now } });
7706
- await this.saveVersion(config, globalParent(key), 0, doc, guard);
7707
- await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
7708
- ...base,
7709
- doc,
7710
- previousDoc: current,
7711
- operation: "update"
7712
- });
7713
- this.emitChange(config, "update", doc, existing.status);
7714
8001
  await this.audited(config, "update", doc, current, guard, mode);
7715
8002
  return this.readGlobal(config, key, guard, { ...options, draft: true });
7716
8003
  }
@@ -7824,9 +8111,21 @@ var EasyCMS = class {
7824
8111
  }
7825
8112
  };
7826
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
+ }
7827
8125
  async destroy() {
7828
8126
  if (this.schedulerTimer) clearInterval(this.schedulerTimer);
7829
8127
  await this.running;
8128
+ await this.outbox?.flush();
7830
8129
  await this.webhooks.flush();
7831
8130
  await this.mailer.flush();
7832
8131
  await this.db.destroy();
@@ -7873,7 +8172,8 @@ var EasyCMS = class {
7873
8172
  scheduled,
7874
8173
  webhooks: await this.retryWebhooks(now),
7875
8174
  emails: await this.mailer.retry(now),
7876
- jobs: await this.runConfigJobs(now)
8175
+ jobs: await this.runConfigJobs(now),
8176
+ outbox: await this.outbox?.run(now) ?? { ran: 0, failed: 0 }
7877
8177
  };
7878
8178
  const ranAt = (/* @__PURE__ */ new Date()).toISOString();
7879
8179
  await this.db.updateGlobal({ slug: JOBS_RAN, data: { at: ranAt, updatedAt: ranAt } }).catch((error) => this.logger.error(`Jobs: ${error.message}`, { error }));
@@ -8605,18 +8905,20 @@ var EasyCMS = class {
8605
8905
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
8606
8906
  if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
8607
8907
  const now = (/* @__PURE__ */ new Date()).toISOString();
8608
- const doc = await this.db.create({
8609
- collection: collection2,
8610
- data: { ...prepared, createdAt: now, updatedAt: now }
8611
- });
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 });
8612
8911
  if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
8613
8912
  await this.saveVersion(config, collectionParent(collection2), doc.id, doc, guard);
8614
- await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
8615
- ...base,
8616
- doc,
8617
- operation: "create"
8618
- });
8619
- 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
+ }
8620
8922
  await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
8621
8923
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
8622
8924
  return out;
@@ -8674,23 +8976,21 @@ var EasyCMS = class {
8674
8976
  });
8675
8977
  }
8676
8978
  /**
8677
- * `httpCache.purge`: what readers see changed (anything, without drafts; with drafts, a
8678
- * published document saved, published, unpublished or deleted). Not awaited; failures are
8679
- * logged.
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).
8680
8981
  */
8681
- purgePublic(config, isGlobal, event, doc, before) {
8682
- const purge = this.config.httpCache?.purge;
8683
- if (!purge || config.httpCache === false || event === "draft") return;
8982
+ purgeTags(config, event, doc, before) {
8983
+ if (!this.config.httpCache?.purge || config.httpCache === false || event === "draft") return;
8684
8984
  if (config.drafts && doc.status !== "published" && before !== "published") return;
8685
- const tags = isGlobal ? cacheTags.global(config.slug) : cacheTags.document(config.slug, doc.id);
8686
- void Promise.resolve().then(() => purge(tags)).catch(
8687
- (error) => this.logger.error(`httpCache.purge failed: ${error.message}`, { error })
8688
- );
8985
+ return this.isGlobal(config) ? cacheTags.global(config.slug) : cacheTags.document(config.slug, doc.id);
8689
8986
  }
8690
- /** Sends webhook events for a change; `before` is the stored status before it, if any. */
8691
- emitChange(config, event, doc, before) {
8692
- const isGlobal = !this.config.collections.includes(config);
8693
- if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
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 [];
8694
8994
  const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
8695
8995
  id: doc.id,
8696
8996
  ...snapshotOf(config.fields, doc),
@@ -8698,13 +8998,77 @@ var EasyCMS = class {
8698
8998
  updatedAt: doc.updatedAt
8699
8999
  };
8700
9000
  const target = isGlobal ? { global: config.slug } : { collection: config.slug, id: doc.id };
8701
- this.webhooks.emit(event, target, body);
8702
- this.purgePublic(config, isGlobal, event, doc, before);
8703
- if (event === "draft" || event === "delete" || !config.drafts) return;
9001
+ const events = [{ event, target, body }];
9002
+ if (event === "draft" || event === "delete" || !config.drafts) return events;
8704
9003
  const after = doc.status;
8705
- if (after === "published" && before !== "published") this.webhooks.emit("publish", target, body);
9004
+ if (after === "published" && before !== "published")
9005
+ events.push({ event: "publish", target, body });
8706
9006
  if (after !== "published" && before === "published")
8707
- 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);
8708
9072
  }
8709
9073
  /**
8710
9074
  * Writes the audit log entry for a change (`audit`): publishing, unpublishing and restoring
@@ -8735,7 +9099,7 @@ var EasyCMS = class {
8735
9099
  );
8736
9100
  }
8737
9101
  /** Records a version after a save, when the collection or global keeps versions. */
8738
- async saveVersion(config, parent, id, doc, guard) {
9102
+ async saveVersion(config, parent, id, doc, guard, outbox) {
8739
9103
  const max = versionLimit(config);
8740
9104
  if (max === void 0) return;
8741
9105
  await this.versions.save({
@@ -8744,7 +9108,8 @@ var EasyCMS = class {
8744
9108
  status: config.drafts ? doc.status ?? "draft" : null,
8745
9109
  snapshot: snapshotOf(config.fields, doc),
8746
9110
  author: guard.user?.id ?? null,
8747
- max
9111
+ max,
9112
+ outbox
8748
9113
  });
8749
9114
  }
8750
9115
  hookArgs(config, guard) {
@@ -9173,7 +9538,7 @@ function readCursor(cursor, query) {
9173
9538
  // src/rest/admin-jobs.ts
9174
9539
  var has = (config, slug2) => config.collections.some((c) => c.slug === slug2);
9175
9540
  function hasJobsPage(config) {
9176
- return config.jobs.length > 0 || has(config, SCHEDULED_JOBS);
9541
+ return config.jobs.length > 0 || has(config, SCHEDULED_JOBS) || has(config, OUTBOX);
9177
9542
  }
9178
9543
  var text2 = (value) => typeof value === "string" && value !== "" ? value : null;
9179
9544
  async function adminJobs(cms, user) {
@@ -9206,6 +9571,7 @@ async function adminJobs(cms, user) {
9206
9571
  ...has(config, SCHEDULED_JOBS) ? ["scheduled"] : [],
9207
9572
  ...has(config, WEBHOOK_DELIVERIES) ? ["webhooks"] : [],
9208
9573
  ...has(config, EMAIL_DELIVERIES) ? ["emails"] : [],
9574
+ ...has(config, OUTBOX) ? ["outbox"] : [],
9209
9575
  ...config.backups?.frequency ? ["backups"] : [],
9210
9576
  ...config.audit ? ["audit"] : []
9211
9577
  ];
@@ -9214,7 +9580,8 @@ async function adminJobs(cms, user) {
9214
9580
  lastRanAt: await cms.jobsLastRan(),
9215
9581
  builtIn,
9216
9582
  timeZone: config.timeZone ?? null,
9217
- canChange: isSystemAdmin(user)
9583
+ canChange: isSystemAdmin(user),
9584
+ outbox: has(config, OUTBOX) ? await adminOutbox(cms) : null
9218
9585
  };
9219
9586
  if (!has(config, SCHEDULED_JOBS)) return { ...head, scheduled: null };
9220
9587
  const { docs } = await cms.db.find({
@@ -9239,6 +9606,38 @@ async function adminJobs(cms, user) {
9239
9606
  });
9240
9607
  return { ...head, scheduled };
9241
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
+ }
9242
9641
  var TIME_OF_DAY2 = /^([01]\d|2[0-3]):[0-5]\d$/;
9243
9642
  async function updateJob(cms, user, name, body) {
9244
9643
  if (!isSystemAdmin(user)) throw new ForbiddenError();
@@ -9945,7 +10344,7 @@ async function readAdminModule(cms, index) {
9945
10344
  }
9946
10345
 
9947
10346
  // src/version.ts
9948
- var VERSION = true ? "1.11.0" : "0.0.0-dev";
10347
+ var VERSION = true ? "1.12.0" : "0.0.0-dev";
9949
10348
 
9950
10349
  // src/rest/handler.ts
9951
10350
  import { randomUUID as randomUUID4 } from "crypto";
@@ -9955,7 +10354,7 @@ var PER_PAGE = 50;
9955
10354
  var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
9956
10355
  var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
9957
10356
  var isDeliveryKind = (value) => value === "webhook" || value === "email";
9958
- function parse2(text3) {
10357
+ function parse3(text3) {
9959
10358
  try {
9960
10359
  const value = JSON.parse(String(text3 ?? ""));
9961
10360
  return typeof value === "object" && value !== null ? value : {};
@@ -9974,7 +10373,7 @@ function toAdmin2(kind, row) {
9974
10373
  nextAttemptAt: String(row.nextAttemptAt ?? "")
9975
10374
  };
9976
10375
  if (kind === "webhook") {
9977
- const payload = parse2(row.body);
10376
+ const payload = parse3(row.body);
9978
10377
  return {
9979
10378
  ...base,
9980
10379
  kind,
@@ -9986,7 +10385,7 @@ function toAdmin2(kind, row) {
9986
10385
  body: String(row.body ?? "")
9987
10386
  };
9988
10387
  }
9989
- const message2 = parse2(row.message);
10388
+ const message2 = parse3(row.message);
9990
10389
  const to = message2.to;
9991
10390
  return {
9992
10391
  ...base,
@@ -11503,6 +11902,13 @@ async function route(cms, ctx, method, path, options) {
11503
11902
  await cms.audit.record({ action: "jobs.run", target: "jobs", detail: result });
11504
11903
  return { body: result };
11505
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
+ }
11506
11912
  if (third !== void 0 && segments.length === 3) {
11507
11913
  if (method !== "PATCH") throw methodNotAllowed(ctx, "PATCH");
11508
11914
  const { action, detail } = await updateJob(
@@ -201,6 +201,17 @@ interface CreateMigrationResult {
201
201
  readonly file: string;
202
202
  readonly statements: readonly string[];
203
203
  }
204
+ /**
205
+ * A row written with a document, in the same transaction (`outbox: true` in the config): a write
206
+ * that commits leaves it, one that doesn't leaves nothing. Not written when an `update` with
207
+ * `where` doesn't match.
208
+ */
209
+ interface OutboxRow {
210
+ /** The collection the row goes to. */
211
+ readonly collection: string;
212
+ /** The row's fields, given the written document's id (`null` for a global). */
213
+ data(id: ID | null): Record<string, unknown>;
214
+ }
204
215
  /** A connected database bound to one config. Returned by `DatabaseAdapter.init`. */
205
216
  interface Database {
206
217
  find(args: FindArgs): Promise<PaginatedDocs<RawDocument>>;
@@ -231,6 +242,7 @@ interface Database {
231
242
  create(args: {
232
243
  collection: string;
233
244
  data: Record<string, unknown>;
245
+ outbox?: OutboxRow | undefined;
234
246
  }): Promise<RawDocument>;
235
247
  /**
236
248
  * `data` holds the whole document after merging. Runs in one transaction. With `where`, only
@@ -241,12 +253,14 @@ interface Database {
241
253
  collection: string;
242
254
  id: ID;
243
255
  data: Record<string, unknown>;
256
+ outbox?: OutboxRow | undefined;
244
257
  }): Promise<RawDocument>;
245
258
  update(args: {
246
259
  collection: string;
247
260
  id: ID;
248
261
  data: Record<string, unknown>;
249
262
  where: Where;
263
+ outbox?: OutboxRow | undefined;
250
264
  }): Promise<RawDocument | null>;
251
265
  /**
252
266
  * Adds `by` to a top-level number field in one statement, safe against concurrent calls; an
@@ -264,6 +278,7 @@ interface Database {
264
278
  delete(args: {
265
279
  collection: string;
266
280
  id: ID;
281
+ outbox?: OutboxRow | undefined;
267
282
  }): Promise<void>;
268
283
  findGlobal(args: {
269
284
  slug: string;
@@ -271,7 +286,13 @@ interface Database {
271
286
  updateGlobal(args: {
272
287
  slug: string;
273
288
  data: Record<string, unknown>;
289
+ outbox?: OutboxRow | undefined;
274
290
  }): Promise<Record<string, unknown>>;
291
+ /**
292
+ * True when `create`, `update`, `delete` and `updateGlobal` take `outbox`, a row written in
293
+ * the same transaction. Required by `outbox: true` in the config.
294
+ */
295
+ readonly outbox?: boolean | undefined;
275
296
  /** Writes a migration file for the difference between the config and the latest migration. `null` when nothing changed. */
276
297
  createMigration(args: {
277
298
  name: string;
@@ -1778,6 +1799,8 @@ declare class EasyCMS<C extends Config = Config> {
1778
1799
  private readonly versions;
1779
1800
  private readonly webhooks;
1780
1801
  private readonly mailer;
1802
+ /** With `outbox: true`: runs after hooks and events from rows saves write. */
1803
+ private readonly outbox;
1781
1804
  /**
1782
1805
  * The user and context of a request, as the REST API works them out: its session cookie or
1783
1806
  * Bearer token, then `onRequest`. For framework routes and server code that act for a visitor;
@@ -2038,6 +2061,11 @@ declare class EasyCMS<C extends Config = Config> {
2038
2061
  flushEmails(): Promise<void>;
2039
2062
  /** Emails queued until sent, stored in `email-deliveries`. */
2040
2063
  private emailQueue;
2064
+ /**
2065
+ * Waits until the outbox (`outbox: true`) ran what saves left so far, e.g. before a serverless
2066
+ * function returns. What fails stays queued for its retry.
2067
+ */
2068
+ flushOutbox(): Promise<void>;
2041
2069
  destroy(): Promise<void>;
2042
2070
  private schedulerTimer;
2043
2071
  private running;
@@ -2065,6 +2093,11 @@ declare class EasyCMS<C extends Config = Config> {
2065
2093
  ran: number;
2066
2094
  failed: number;
2067
2095
  };
2096
+ /** Outbox entries (`outbox: true`) run, and those that failed (to be retried). */
2097
+ outbox: {
2098
+ ran: number;
2099
+ failed: number;
2100
+ };
2068
2101
  }>;
2069
2102
  private runJobsNow;
2070
2103
  /**
@@ -2184,13 +2217,25 @@ declare class EasyCMS<C extends Config = Config> {
2184
2217
  /** Replaces published documents that have a newer draft with that draft. */
2185
2218
  private withDrafts;
2186
2219
  /**
2187
- * `httpCache.purge`: what readers see changed (anything, without drafts; with drafts, a
2188
- * published document saved, published, unpublished or deleted). Not awaited; failures are
2189
- * logged.
2220
+ * The tags `httpCache.purge` gets for a change: what readers see changed (anything, without
2221
+ * drafts; with drafts, a published document saved, published, unpublished or deleted).
2222
+ */
2223
+ private purgeTags;
2224
+ private isGlobal;
2225
+ /** The webhook events of a change; `before` is the stored status before it, if any. */
2226
+ private changeEvents;
2227
+ /**
2228
+ * Sends webhook events for a change and purges the HTTP cache. Not awaited; purge failures
2229
+ * are logged.
2190
2230
  */
2191
- private purgePublic;
2192
- /** Sends webhook events for a change; `before` is the stored status before it, if any. */
2193
2231
  private emitChange;
2232
+ /**
2233
+ * The outbox row of a save (`outbox: true`): written in its transaction, then run by the outbox
2234
+ * in place of its after hooks and events. `undefined` without the outbox, or with nothing to run.
2235
+ */
2236
+ private outboxRow;
2237
+ /** Runs an outbox entry: what its save would have run right after it, without the outbox. */
2238
+ private runOutbox;
2194
2239
  /**
2195
2240
  * Writes the audit log entry for a change (`audit`): publishing, unpublishing and restoring
2196
2241
  * are told apart from other saves.
@@ -3420,6 +3465,15 @@ interface Config {
3420
3465
  * requests get `400`, pointing to `cursor`. Deep pages are read row by row. Default: no limit.
3421
3466
  */
3422
3467
  readonly maxPage?: number;
3468
+ /**
3469
+ * Runs `afterChange` and `afterDelete` hooks, webhooks and `httpCache.purge` from an outbox:
3470
+ * saving a document also queues them, in the same database transaction, and a worker runs them
3471
+ * after it commits, retrying what fails. A save that succeeds always gets its side effects (at
3472
+ * least once; hooks must be safe to run again), and the response doesn't wait for them. Hooks
3473
+ * then get the user who saved but no `context`. Needs a migration for the `outbox` table.
3474
+ * Default `false`: hooks run in the request, after the save.
3475
+ */
3476
+ readonly outbox?: boolean;
3423
3477
  readonly plugins?: readonly Plugin[];
3424
3478
  }
3425
3479
  /** The config after plugins ran, validation passed and defaults were applied. */
@@ -3451,5 +3505,5 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
3451
3505
  */
3452
3506
  declare function defineConfig<const TConfig extends Config>(config: TConfig): TConfig;
3453
3507
 
3454
- export { ADAPTER_API_VERSION as A, AuditLog as J, Auth as M, ADMIN_ICONS as a, FIELD_TYPES as aD, ForbiddenError as a_, ConfigError as ac, EasyCMS as av, EasyCMSError as aw, PayloadTooLargeError as bC, QueryError as bI, ROLE_OPERATIONS as bJ, Roles as bU, SchemaError as bX, NotFoundError as bs, PLUGIN_API_VERSION as bx, logFormat as c$, SingleSignOn as c3, defineConfig as cA, defineFieldType as cB, definePlugin as cC, diskStorage as cD, hasRows as cE, isAdmin as cF, isHasMany as cG, isSignedIn as cH, isStaff as cI, isSystemAdmin as cJ, jsonLogger as cK, mimeAllowedBy as cL, rowFields as cM, silentLogger as cN, uniqueWithinOf as cO, API_KEYS as cP, API_KEY_GLOBAL_OPERATIONS as cQ, API_KEY_OPERATIONS as cR, DEFAULT_MAX_VERSIONS as cS, DEFAULT_PASSWORD_EMAILS as cT, FIELD_TYPE_BASES as cU, FOLDER_LEVELS as cV, MediaFolders as cW, SSO_COOKIE as cX, codeOfStatus as cY, isJsonLogger as cZ, keyAllows as c_, TooManyRequestsError as cc, UnauthorizedError as ce, ValidationError as cm, WEBHOOK_EVENTS as cq, anyone as cw, consoleEmail as cx, consoleLogger as cy, createEasyCMS as cz };
3455
- export type { BuiltinMediaCollection as $, ApiKeyOperation as B, ApiKeyPermissions as C, ArrayField as D, EmailSetting as E, AuditChange as F, AuditConfig as G, AuditEntry as H, ID as I, AuditPage as K, AuditVerification as L, AuthConfig as N, AuthProvider as O, PaginatedDocs as P, AuthProviderProfile as Q, AuthProviderRequest as R, AuthUser as S, BackupsConfig as T, BeforeChangeHook as U, BeforeDeleteHook as V, BeforeValidateHook as W, Block as X, BlocksField as Y, BooleanField as Z, BuiltinApiKeysCollection as _, GlobalAccess as a$, BuiltinMediaFoldersCollection as a0, BuiltinUsersCollection as a1, BundleHints as a2, CliCommand as a3, CollectionAccess as a4, CollectionAdmin as a5, CollectionConfig as a6, CollectionDocument as a7, CollectionHooks as a8, CollectionListAdmin as a9, Endpoint as aA, EndpointRequest as aB, ErrorCode as aC, Field as aE, FieldAccess as aF, FieldAccessFn as aG, FieldAdmin as aH, FieldError as aI, FieldRule as aJ, FieldType as aK, FieldTypeBase as aL, FieldTypeDefinition as aM, FieldTypeValidateContext as aN, FieldValidate as aO, FieldValidateContext as aP, FieldValue as aQ, FieldsInput as aR, FieldsValue as aS, FilterOptions as aT, FilterOptionsArgs as aU, FindAfterArgs as aV, FindArgs as aW, FindOptions as aX, FolderLevel as aY, FolderPermissions as aZ, CollectionSlug as aa, Config as ab, ConfigIssue as ad, ContainerAdmin as ae, CreateEasyCMSOptions as af, CreateInput as ag, CreateMigrationResult as ah, CursorDocs as ai, CustomField as aj, CustomFieldTypes as ak, DashboardWidget as al, Database as am, DatabaseAdapter as an, DatabaseInitArgs as ao, DatabaseTransfer as ap, DateField as aq, DepthOptions as ar, DirectUpload as as, DiskStorageOptions as at, DocumentOf as au, EmailAdapter as ax, EmailField as ay, EmailMessage as az, Access as b, Session as b$, GlobalConfig as b0, GlobalDocument as b1, GlobalHooks as b2, GlobalInput as b3, GlobalSlug as b4, GroupField as b5, ImageSize as b6, IncrementOptions as b7, InferCollection as b8, InferGlobal as b9, PasswordEmailFn as bA, PasswordLinkOptions as bB, Plugin as bD, PluginInfo as bE, PluginTypes as bF, PreviewTarget as bG, PreviewURL as bH, RawDocument as bK, ReadOptions as bL, RelationshipField as bM, RequestContext as bN, ResolvedConfig as bO, RichTextDocument as bP, RichTextField as bQ, Role as bR, RoleOperation as bS, RolePermissions as bT, RoutesConfig as bV, ScheduledPublish as bW, SchemaMode as bY, SelectField as bZ, SelectOption as b_, JobConfig as ba, JsonField as bb, KeysetPage as bc, Label as bd, LayoutNode as be, LivePreview as bf, LocaleOf as bg, LocalizationConfig as bh, LogDetails as bi, Logger as bj, LoginArgs as bk, LogsConfig as bl, MediaDocument as bm, MediaSize as bn, MembersConfig as bo, MembersSignup as bp, MigrationInfo as bq, NavGroup as br, NumberField as bt, OnRequest as bu, OnRequestArgs as bv, Operation as bw, PasswordEmail as by, PasswordEmailArgs as bz, AccessArgs as c, SidebarPanel as c0, SignupArgs as c1, SignupResult as c2, SlugField as c4, SlugOf as c5, SsoOutcome as c6, SsoProviderRef as c7, StorageAdapter as c8, StoredFile as c9, TextField as ca, TextareaField as cb, TypedPlugin as cd, UncountedDocs as cf, UpdateInput as cg, UpdateOptions as ch, UploadConfig as ci, UploadField as cj, UploadFromURLConfig as ck, UserIdentity as cl, Version as cn, VersionSummary as co, VersionsConfig as cp, WebhookConfig as cr, WebhookEvent as cs, WebhookPayload as ct, Where as cu, WhereOperators as cv, AccessOptions as d, AdminBrand as e, AdminCommand as f, AdminComponent as g, AdminConfig as h, AdminFieldArgs as i, AdminIcon as j, AdminLocale as k, AdminPage as l, AdminRole as m, AdminRoleChange as n, AdminRoleField as o, AdminRoleTarget as p, AdminRoleView as q, AdminRoles as r, AdminSso as s, AdminSwitcher as t, AdminViewAccess as u, AfterChangeHook as v, AfterDeleteHook as w, AfterReadHook as x, ApiKeyContext as y, ApiKeyGlobalOperation as z };
3508
+ export { ADAPTER_API_VERSION as A, AuditLog as J, Auth as M, ADMIN_ICONS as a, FIELD_TYPES as aD, ForbiddenError as a_, ConfigError as ac, EasyCMS as av, EasyCMSError as aw, PayloadTooLargeError as bD, QueryError as bJ, ROLE_OPERATIONS as bK, Roles as bV, SchemaError as bY, NotFoundError as bs, PLUGIN_API_VERSION as by, keyAllows as c$, SingleSignOn as c4, createEasyCMS as cA, defineConfig as cB, defineFieldType as cC, definePlugin as cD, diskStorage as cE, hasRows as cF, isAdmin as cG, isHasMany as cH, isSignedIn as cI, isStaff as cJ, isSystemAdmin as cK, jsonLogger as cL, mimeAllowedBy as cM, rowFields as cN, silentLogger as cO, uniqueWithinOf as cP, API_KEYS as cQ, API_KEY_GLOBAL_OPERATIONS as cR, API_KEY_OPERATIONS as cS, DEFAULT_MAX_VERSIONS as cT, DEFAULT_PASSWORD_EMAILS as cU, FIELD_TYPE_BASES as cV, FOLDER_LEVELS as cW, MediaFolders as cX, SSO_COOKIE as cY, codeOfStatus as cZ, isJsonLogger as c_, TooManyRequestsError as cd, UnauthorizedError as cf, ValidationError as cn, WEBHOOK_EVENTS as cr, anyone as cx, consoleEmail as cy, consoleLogger as cz, logFormat as d0 };
3509
+ export type { BuiltinMediaCollection as $, ApiKeyOperation as B, ApiKeyPermissions as C, ArrayField as D, EmailSetting as E, AuditChange as F, AuditConfig as G, AuditEntry as H, ID as I, AuditPage as K, AuditVerification as L, AuthConfig as N, AuthProvider as O, PaginatedDocs as P, AuthProviderProfile as Q, AuthProviderRequest as R, AuthUser as S, BackupsConfig as T, BeforeChangeHook as U, BeforeDeleteHook as V, BeforeValidateHook as W, Block as X, BlocksField as Y, BooleanField as Z, BuiltinApiKeysCollection as _, GlobalAccess as a$, BuiltinMediaFoldersCollection as a0, BuiltinUsersCollection as a1, BundleHints as a2, CliCommand as a3, CollectionAccess as a4, CollectionAdmin as a5, CollectionConfig as a6, CollectionDocument as a7, CollectionHooks as a8, CollectionListAdmin as a9, Endpoint as aA, EndpointRequest as aB, ErrorCode as aC, Field as aE, FieldAccess as aF, FieldAccessFn as aG, FieldAdmin as aH, FieldError as aI, FieldRule as aJ, FieldType as aK, FieldTypeBase as aL, FieldTypeDefinition as aM, FieldTypeValidateContext as aN, FieldValidate as aO, FieldValidateContext as aP, FieldValue as aQ, FieldsInput as aR, FieldsValue as aS, FilterOptions as aT, FilterOptionsArgs as aU, FindAfterArgs as aV, FindArgs as aW, FindOptions as aX, FolderLevel as aY, FolderPermissions as aZ, CollectionSlug as aa, Config as ab, ConfigIssue as ad, ContainerAdmin as ae, CreateEasyCMSOptions as af, CreateInput as ag, CreateMigrationResult as ah, CursorDocs as ai, CustomField as aj, CustomFieldTypes as ak, DashboardWidget as al, Database as am, DatabaseAdapter as an, DatabaseInitArgs as ao, DatabaseTransfer as ap, DateField as aq, DepthOptions as ar, DirectUpload as as, DiskStorageOptions as at, DocumentOf as au, EmailAdapter as ax, EmailField as ay, EmailMessage as az, Access as b, SelectOption as b$, GlobalConfig as b0, GlobalDocument as b1, GlobalHooks as b2, GlobalInput as b3, GlobalSlug as b4, GroupField as b5, ImageSize as b6, IncrementOptions as b7, InferCollection as b8, InferGlobal as b9, PasswordEmailArgs as bA, PasswordEmailFn as bB, PasswordLinkOptions as bC, Plugin as bE, PluginInfo as bF, PluginTypes as bG, PreviewTarget as bH, PreviewURL as bI, RawDocument as bL, ReadOptions as bM, RelationshipField as bN, RequestContext as bO, ResolvedConfig as bP, RichTextDocument as bQ, RichTextField as bR, Role as bS, RoleOperation as bT, RolePermissions as bU, RoutesConfig as bW, ScheduledPublish as bX, SchemaMode as bZ, SelectField as b_, JobConfig as ba, JsonField as bb, KeysetPage as bc, Label as bd, LayoutNode as be, LivePreview as bf, LocaleOf as bg, LocalizationConfig as bh, LogDetails as bi, Logger as bj, LoginArgs as bk, LogsConfig as bl, MediaDocument as bm, MediaSize as bn, MembersConfig as bo, MembersSignup as bp, MigrationInfo as bq, NavGroup as br, NumberField as bt, OnRequest as bu, OnRequestArgs as bv, Operation as bw, OutboxRow as bx, PasswordEmail as bz, AccessArgs as c, Session as c0, SidebarPanel as c1, SignupArgs as c2, SignupResult as c3, SlugField as c5, SlugOf as c6, SsoOutcome as c7, SsoProviderRef as c8, StorageAdapter as c9, StoredFile as ca, TextField as cb, TextareaField as cc, TypedPlugin as ce, UncountedDocs as cg, UpdateInput as ch, UpdateOptions as ci, UploadConfig as cj, UploadField as ck, UploadFromURLConfig as cl, UserIdentity as cm, Version as co, VersionSummary as cp, VersionsConfig as cq, WebhookConfig as cs, WebhookEvent as ct, WebhookPayload as cu, Where as cv, WhereOperators as cw, AccessOptions as d, AdminBrand as e, AdminCommand as f, AdminComponent as g, AdminConfig as h, AdminFieldArgs as i, AdminIcon as j, AdminLocale as k, AdminPage as l, AdminRole as m, AdminRoleChange as n, AdminRoleField as o, AdminRoleTarget as p, AdminRoleView as q, AdminRoles as r, AdminSso as s, AdminSwitcher as t, AdminViewAccess as u, AfterChangeHook as v, AfterDeleteHook as w, AfterReadHook as x, ApiKeyContext as y, ApiKeyGlobalOperation as z };
@@ -1,4 +1,4 @@
1
- import { I as ID, ab as Config, av as EasyCMS, bh as LocalizationConfig, aE as Field, aI as FieldError, aT as FilterOptions, af as CreateEasyCMSOptions, bO as ResolvedConfig } from './config-n7PtGl4V.js';
1
+ import { I as ID, ab as Config, av as EasyCMS, bh as LocalizationConfig, aE as Field, aI as FieldError, aT as FilterOptions, af as CreateEasyCMSOptions, bP as ResolvedConfig } from './config-CHEEPk1d.js';
2
2
 
3
3
  /** Decrypts a file from `encryptBackup`; throws for another key or a changed file. */
4
4
  declare function decryptBackup(data: Uint8Array, encryptionKey: string): Uint8Array;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { P as PaginatedDocs, I as ID, E as EmailSetting } from './config-n7PtGl4V.js';
2
- export { A as ADAPTER_API_VERSION, a as ADMIN_ICONS, b as Access, c as AccessArgs, d as AccessOptions, e as AdminBrand, f as AdminCommand, g as AdminComponent, h as AdminConfig, i as AdminFieldArgs, j as AdminIcon, k as AdminLocale, l as AdminPage, m as AdminRole, n as AdminRoleChange, o as AdminRoleField, p as AdminRoleTarget, q as AdminRoleView, r as AdminRoles, s as AdminSso, t as AdminSwitcher, u as AdminViewAccess, v as AfterChangeHook, w as AfterDeleteHook, x as AfterReadHook, y as ApiKeyContext, z as ApiKeyGlobalOperation, B as ApiKeyOperation, C as ApiKeyPermissions, D as ArrayField, F as AuditChange, G as AuditConfig, H as AuditEntry, J as AuditLog, K as AuditPage, L as AuditVerification, M as Auth, N as AuthConfig, O as AuthProvider, Q as AuthProviderProfile, R as AuthProviderRequest, S as AuthUser, T as BackupsConfig, U as BeforeChangeHook, V as BeforeDeleteHook, W as BeforeValidateHook, X as Block, Y as BlocksField, Z as BooleanField, _ as BuiltinApiKeysCollection, $ as BuiltinMediaCollection, a0 as BuiltinMediaFoldersCollection, a1 as BuiltinUsersCollection, a2 as BundleHints, a3 as CliCommand, a4 as CollectionAccess, a5 as CollectionAdmin, a6 as CollectionConfig, a7 as CollectionDocument, a8 as CollectionHooks, a9 as CollectionListAdmin, aa as CollectionSlug, ab as Config, ac as ConfigError, ad as ConfigIssue, ae as ContainerAdmin, af as CreateEasyCMSOptions, ag as CreateInput, ah as CreateMigrationResult, ai as CursorDocs, aj as CustomField, ak as CustomFieldTypes, al as DashboardWidget, am as Database, an as DatabaseAdapter, ao as DatabaseInitArgs, ap as DatabaseTransfer, aq as DateField, ar as DepthOptions, as as DirectUpload, at as DiskStorageOptions, au as DocumentOf, av as EasyCMS, aw as EasyCMSError, ax as EmailAdapter, ay as EmailField, az as EmailMessage, aA as Endpoint, aB as EndpointRequest, aC as ErrorCode, aD as FIELD_TYPES, aE as Field, aF as FieldAccess, aG as FieldAccessFn, aH as FieldAdmin, aI as FieldError, aJ as FieldRule, aK as FieldType, aL as FieldTypeBase, aM as FieldTypeDefinition, aN as FieldTypeValidateContext, aO as FieldValidate, aP as FieldValidateContext, aQ as FieldValue, aR as FieldsInput, aS as FieldsValue, aT as FilterOptions, aU as FilterOptionsArgs, aV as FindAfterArgs, aW as FindArgs, aX as FindOptions, aY as FolderLevel, aZ as FolderPermissions, a_ as ForbiddenError, a$ as GlobalAccess, b0 as GlobalConfig, b1 as GlobalDocument, b2 as GlobalHooks, b3 as GlobalInput, b4 as GlobalSlug, b5 as GroupField, b6 as ImageSize, b7 as IncrementOptions, b8 as InferCollection, b9 as InferGlobal, ba as JobConfig, bb as JsonField, bc as KeysetPage, bd as Label, be as LayoutNode, bf as LivePreview, bg as LocaleOf, bh as LocalizationConfig, bi as LogDetails, bj as Logger, bk as LoginArgs, bl as LogsConfig, bm as MediaDocument, bn as MediaSize, bo as MembersConfig, bp as MembersSignup, bq as MigrationInfo, br as NavGroup, bs as NotFoundError, bt as NumberField, bu as OnRequest, bv as OnRequestArgs, bw as Operation, bx as PLUGIN_API_VERSION, by as PasswordEmail, bz as PasswordEmailArgs, bA as PasswordEmailFn, bB as PasswordLinkOptions, bC as PayloadTooLargeError, bD as Plugin, bE as PluginInfo, bF as PluginTypes, bG as PreviewTarget, bH as PreviewURL, bI as QueryError, bJ as ROLE_OPERATIONS, bK as RawDocument, bL as ReadOptions, bM as RelationshipField, bN as RequestContext, bO as ResolvedConfig, bP as RichTextDocument, bQ as RichTextField, bR as Role, bS as RoleOperation, bT as RolePermissions, bU as Roles, bV as RoutesConfig, bW as ScheduledPublish, bX as SchemaError, bY as SchemaMode, bZ as SelectField, b_ as SelectOption, b$ as Session, c0 as SidebarPanel, c1 as SignupArgs, c2 as SignupResult, c3 as SingleSignOn, c4 as SlugField, c5 as SlugOf, c6 as SsoOutcome, c7 as SsoProviderRef, c8 as StorageAdapter, c9 as StoredFile, ca as TextField, cb as TextareaField, cc as TooManyRequestsError, cd as TypedPlugin, ce as UnauthorizedError, cf as UncountedDocs, cg as UpdateInput, ch as UpdateOptions, ci as UploadConfig, cj as UploadField, ck as UploadFromURLConfig, cl as UserIdentity, cm as ValidationError, cn as Version, co as VersionSummary, cp as VersionsConfig, cq as WEBHOOK_EVENTS, cr as WebhookConfig, cs as WebhookEvent, ct as WebhookPayload, cu as Where, cv as WhereOperators, cw as anyone, cx as consoleEmail, cy as consoleLogger, cz as createEasyCMS, cA as defineConfig, cB as defineFieldType, cC as definePlugin, cD as diskStorage, cE as hasRows, cF as isAdmin, cG as isHasMany, cH as isSignedIn, cI as isStaff, cJ as isSystemAdmin, cK as jsonLogger, cL as mimeAllowedBy, cM as rowFields, cN as silentLogger, cO as uniqueWithinOf } from './config-n7PtGl4V.js';
3
- export { A as ADMIN_API_VERSION, a as AdminCollection, b as AdminComponentRef, c as AdminElementProps, d as AdminField, e as AdminGlobal, f as AdminLayoutNode, g as AdminNavGroup, h as AdminNavItem, i as AdminNavNode, j as AdminPageRef, k as AdminPageRoute, l as AdminSchema, m as AdminWidgetRef, S as SetFieldDetail, w as warnDeprecated } from './plugin-C_vknKa2.js';
4
- export { A as AdminBackup, a as AdminBackups, b as ApiHandlerOptions, B as BackupState, L as LoadConfigOptions, R as RestHandler, c as RestHandlerOptions, d as createApiHandler, e as createRestHandler, l as loadConfig, r as resolveConfig, s as sharedEasyCMS, f as slugify } from './handler-DV-1ICVz.js';
1
+ import { P as PaginatedDocs, I as ID, E as EmailSetting } from './config-CHEEPk1d.js';
2
+ export { A as ADAPTER_API_VERSION, a as ADMIN_ICONS, b as Access, c as AccessArgs, d as AccessOptions, e as AdminBrand, f as AdminCommand, g as AdminComponent, h as AdminConfig, i as AdminFieldArgs, j as AdminIcon, k as AdminLocale, l as AdminPage, m as AdminRole, n as AdminRoleChange, o as AdminRoleField, p as AdminRoleTarget, q as AdminRoleView, r as AdminRoles, s as AdminSso, t as AdminSwitcher, u as AdminViewAccess, v as AfterChangeHook, w as AfterDeleteHook, x as AfterReadHook, y as ApiKeyContext, z as ApiKeyGlobalOperation, B as ApiKeyOperation, C as ApiKeyPermissions, D as ArrayField, F as AuditChange, G as AuditConfig, H as AuditEntry, J as AuditLog, K as AuditPage, L as AuditVerification, M as Auth, N as AuthConfig, O as AuthProvider, Q as AuthProviderProfile, R as AuthProviderRequest, S as AuthUser, T as BackupsConfig, U as BeforeChangeHook, V as BeforeDeleteHook, W as BeforeValidateHook, X as Block, Y as BlocksField, Z as BooleanField, _ as BuiltinApiKeysCollection, $ as BuiltinMediaCollection, a0 as BuiltinMediaFoldersCollection, a1 as BuiltinUsersCollection, a2 as BundleHints, a3 as CliCommand, a4 as CollectionAccess, a5 as CollectionAdmin, a6 as CollectionConfig, a7 as CollectionDocument, a8 as CollectionHooks, a9 as CollectionListAdmin, aa as CollectionSlug, ab as Config, ac as ConfigError, ad as ConfigIssue, ae as ContainerAdmin, af as CreateEasyCMSOptions, ag as CreateInput, ah as CreateMigrationResult, ai as CursorDocs, aj as CustomField, ak as CustomFieldTypes, al as DashboardWidget, am as Database, an as DatabaseAdapter, ao as DatabaseInitArgs, ap as DatabaseTransfer, aq as DateField, ar as DepthOptions, as as DirectUpload, at as DiskStorageOptions, au as DocumentOf, av as EasyCMS, aw as EasyCMSError, ax as EmailAdapter, ay as EmailField, az as EmailMessage, aA as Endpoint, aB as EndpointRequest, aC as ErrorCode, aD as FIELD_TYPES, aE as Field, aF as FieldAccess, aG as FieldAccessFn, aH as FieldAdmin, aI as FieldError, aJ as FieldRule, aK as FieldType, aL as FieldTypeBase, aM as FieldTypeDefinition, aN as FieldTypeValidateContext, aO as FieldValidate, aP as FieldValidateContext, aQ as FieldValue, aR as FieldsInput, aS as FieldsValue, aT as FilterOptions, aU as FilterOptionsArgs, aV as FindAfterArgs, aW as FindArgs, aX as FindOptions, aY as FolderLevel, aZ as FolderPermissions, a_ as ForbiddenError, a$ as GlobalAccess, b0 as GlobalConfig, b1 as GlobalDocument, b2 as GlobalHooks, b3 as GlobalInput, b4 as GlobalSlug, b5 as GroupField, b6 as ImageSize, b7 as IncrementOptions, b8 as InferCollection, b9 as InferGlobal, ba as JobConfig, bb as JsonField, bc as KeysetPage, bd as Label, be as LayoutNode, bf as LivePreview, bg as LocaleOf, bh as LocalizationConfig, bi as LogDetails, bj as Logger, bk as LoginArgs, bl as LogsConfig, bm as MediaDocument, bn as MediaSize, bo as MembersConfig, bp as MembersSignup, bq as MigrationInfo, br as NavGroup, bs as NotFoundError, bt as NumberField, bu as OnRequest, bv as OnRequestArgs, bw as Operation, bx as OutboxRow, by as PLUGIN_API_VERSION, bz as PasswordEmail, bA as PasswordEmailArgs, bB as PasswordEmailFn, bC as PasswordLinkOptions, bD as PayloadTooLargeError, bE as Plugin, bF as PluginInfo, bG as PluginTypes, bH as PreviewTarget, bI as PreviewURL, bJ as QueryError, bK as ROLE_OPERATIONS, bL as RawDocument, bM as ReadOptions, bN as RelationshipField, bO as RequestContext, bP as ResolvedConfig, bQ as RichTextDocument, bR as RichTextField, bS as Role, bT as RoleOperation, bU as RolePermissions, bV as Roles, bW as RoutesConfig, bX as ScheduledPublish, bY as SchemaError, bZ as SchemaMode, b_ as SelectField, b$ as SelectOption, c0 as Session, c1 as SidebarPanel, c2 as SignupArgs, c3 as SignupResult, c4 as SingleSignOn, c5 as SlugField, c6 as SlugOf, c7 as SsoOutcome, c8 as SsoProviderRef, c9 as StorageAdapter, ca as StoredFile, cb as TextField, cc as TextareaField, cd as TooManyRequestsError, ce as TypedPlugin, cf as UnauthorizedError, cg as UncountedDocs, ch as UpdateInput, ci as UpdateOptions, cj as UploadConfig, ck as UploadField, cl as UploadFromURLConfig, cm as UserIdentity, cn as ValidationError, co as Version, cp as VersionSummary, cq as VersionsConfig, cr as WEBHOOK_EVENTS, cs as WebhookConfig, ct as WebhookEvent, cu as WebhookPayload, cv as Where, cw as WhereOperators, cx as anyone, cy as consoleEmail, cz as consoleLogger, cA as createEasyCMS, cB as defineConfig, cC as defineFieldType, cD as definePlugin, cE as diskStorage, cF as hasRows, cG as isAdmin, cH as isHasMany, cI as isSignedIn, cJ as isStaff, cK as isSystemAdmin, cL as jsonLogger, cM as mimeAllowedBy, cN as rowFields, cO as silentLogger, cP as uniqueWithinOf } from './config-CHEEPk1d.js';
3
+ export { A as ADMIN_API_VERSION, a as AdminCollection, b as AdminComponentRef, c as AdminElementProps, d as AdminField, e as AdminGlobal, f as AdminLayoutNode, g as AdminNavGroup, h as AdminNavItem, i as AdminNavNode, j as AdminPageRef, k as AdminPageRoute, l as AdminSchema, m as AdminWidgetRef, S as SetFieldDetail, w as warnDeprecated } from './plugin-B8xypoWd.js';
4
+ export { A as AdminBackup, a as AdminBackups, b as ApiHandlerOptions, B as BackupState, L as LoadConfigOptions, R as RestHandler, c as RestHandlerOptions, d as createApiHandler, e as createRestHandler, l as loadConfig, r as resolveConfig, s as sharedEasyCMS, f as slugify } from './handler-adKUq8-U.js';
5
5
  export { FieldCondition, matchesCondition } from './conditions.js';
6
6
 
7
7
  type DeliveryKind = 'webhook' | 'email';
@@ -112,9 +112,30 @@ interface AdminScheduledJob {
112
112
  global?: string;
113
113
  doc: ID;
114
114
  }
115
+ /** An outbox entry (`outbox: true`) that failed: being retried, or given up on. */
116
+ interface AdminOutboxEntry {
117
+ id: ID;
118
+ event: 'create' | 'update' | 'draft' | 'delete';
119
+ /** The document's collection, or the global (`global`), and the document's id. */
120
+ collection?: string;
121
+ global?: string;
122
+ doc: string | null;
123
+ attempts: number;
124
+ /** `pending`: retried at `nextAttemptAt`; `failed`: given up, until someone retries it. */
125
+ state: 'pending' | 'running' | 'failed';
126
+ error: string | null;
127
+ nextAttemptAt: string;
128
+ }
129
+ interface AdminOutbox {
130
+ /** Entries waiting to run (new, or to be retried). */
131
+ waiting: number;
132
+ failed: number;
133
+ /** Entries that failed at least once, latest first (at most 100). */
134
+ entries: AdminOutboxEntry[];
135
+ }
115
136
  /** What Settings → Jobs shows (`GET <api>/admin/jobs`). */
116
137
  /** Work Easy CMS itself does with every run of the jobs, when it is set up. */
117
- type BuiltInJob = 'scheduled' | 'webhooks' | 'emails' | 'backups' | 'audit';
138
+ type BuiltInJob = 'scheduled' | 'webhooks' | 'emails' | 'outbox' | 'backups' | 'audit';
118
139
  interface AdminJobs {
119
140
  jobs: AdminJob[];
120
141
  /** When jobs last ran (the scheduler, the cron endpoint), `null` if never. */
@@ -127,6 +148,8 @@ interface AdminJobs {
127
148
  canChange: boolean;
128
149
  /** `null` when nothing uses `schedule`. */
129
150
  scheduled: AdminScheduledJob[] | null;
151
+ /** `null` without `outbox: true`. */
152
+ outbox: AdminOutbox | null;
130
153
  }
131
154
 
132
155
  interface SearchHit {
@@ -233,4 +256,4 @@ declare const cacheTags: {
233
256
  declare const VERSION: string;
234
257
 
235
258
  export { EmailSetting, ID, PaginatedDocs, VERSION, cacheTags };
236
- export type { AdminAttention, AdminDeliveries, AdminDelivery, AdminEmail, AdminEmailDelivery, AdminJob, AdminJobs, AdminScheduledJob, AdminStatus, AdminWebhookDelivery, BuiltInJob, DeliveryKind, DeliveryState, EmailCheck, SearchHit };
259
+ export type { AdminAttention, AdminDeliveries, AdminDelivery, AdminEmail, AdminEmailDelivery, AdminJob, AdminJobs, AdminOutbox, AdminOutboxEntry, AdminScheduledJob, AdminStatus, AdminWebhookDelivery, BuiltInJob, DeliveryKind, DeliveryState, EmailCheck, SearchHit };
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  sharedEasyCMS,
26
26
  silentLogger,
27
27
  slugify
28
- } from "./chunk-TMO4JTGJ.js";
28
+ } from "./chunk-244WCY6S.js";
29
29
  import {
30
30
  matchesCondition
31
31
  } from "./chunk-U266LV4M.js";
@@ -1,6 +1,6 @@
1
1
  import { Config, Database, EasyCMS, NavGroup, CollectionConfig, ID, RawDocument, Field, ResolvedConfig, ConfigIssue } from './index.js';
2
- export { cP as API_KEYS, cQ as API_KEY_GLOBAL_OPERATIONS, cR as API_KEY_OPERATIONS, cS as DEFAULT_MAX_VERSIONS, cT as DEFAULT_PASSWORD_EMAILS, cU as FIELD_TYPE_BASES, cV as FOLDER_LEVELS, cW as MediaFolders, cX as SSO_COOKIE, cY as codeOfStatus, cE as hasRows, cG as isHasMany, cZ as isJsonLogger, c_ as keyAllows, c$ as logFormat, cL as mimeAllowedBy, cM as rowFields, cO as uniqueWithinOf } from './config-n7PtGl4V.js';
3
- export { C as CONFIG_FILE_NAMES, g as CSRF_COOKIE, h as CSRF_HEADER, D as DEFAULT_ADMIN_PATH, i as DEFAULT_API_PATH, j as DEFAULT_MAX_FILE_SIZE, k as DEFAULT_TOKEN_EXPIRATION, m as applyDefaults, n as applyPlugins, o as createRootEndpointHandler, p as decryptBackup, q as fillMissing, t as findConfigFile, u as forwardedClientIp, v as generateSlugs, w as importConfig, x as mergeForUpdate, y as parseId, z as platformClientIp, E as readCookie, F as validateFields, G as writeBackupFile } from './handler-DV-1ICVz.js';
2
+ export { cQ as API_KEYS, cR as API_KEY_GLOBAL_OPERATIONS, cS as API_KEY_OPERATIONS, cT as DEFAULT_MAX_VERSIONS, cU as DEFAULT_PASSWORD_EMAILS, cV as FIELD_TYPE_BASES, cW as FOLDER_LEVELS, cX as MediaFolders, cY as SSO_COOKIE, cZ as codeOfStatus, cF as hasRows, cH as isHasMany, c_ as isJsonLogger, c$ as keyAllows, d0 as logFormat, cM as mimeAllowedBy, cN as rowFields, cP as uniqueWithinOf } from './config-CHEEPk1d.js';
3
+ export { C as CONFIG_FILE_NAMES, g as CSRF_COOKIE, h as CSRF_HEADER, D as DEFAULT_ADMIN_PATH, i as DEFAULT_API_PATH, j as DEFAULT_MAX_FILE_SIZE, k as DEFAULT_TOKEN_EXPIRATION, m as applyDefaults, n as applyPlugins, o as createRootEndpointHandler, p as decryptBackup, q as fillMissing, t as findConfigFile, u as forwardedClientIp, v as generateSlugs, w as importConfig, x as mergeForUpdate, y as parseId, z as platformClientIp, E as readCookie, F as validateFields, G as writeBackupFile } from './handler-adKUq8-U.js';
4
4
  export { conditionIssues } from './conditions.js';
5
5
 
6
6
  /** The session cookie the REST API sets at login. */
package/dist/internal.js CHANGED
@@ -56,7 +56,7 @@ import {
56
56
  verifyPassword,
57
57
  verifyTurnstile,
58
58
  writeBackupFile
59
- } from "./chunk-TMO4JTGJ.js";
59
+ } from "./chunk-244WCY6S.js";
60
60
  import {
61
61
  conditionIssues
62
62
  } from "./chunk-U266LV4M.js";
@@ -1,4 +1,4 @@
1
- import { bd as Label, aE as Field, I as ID, k as AdminLocale, t as AdminSwitcher, c7 as SsoProviderRef } from './config-n7PtGl4V.js';
1
+ import { bd as Label, aE as Field, I as ID, k as AdminLocale, t as AdminSwitcher, c8 as SsoProviderRef } from './config-CHEEPk1d.js';
2
2
  import { FieldCondition } from './conditions.js';
3
3
 
4
4
  /** A Web Component from an admin module, with its `props` (plain JSON). */
package/dist/plugin.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { a as ADMIN_ICONS, g as AdminComponent, j as AdminIcon, S as AuthUser, a3 as CliCommand, a4 as CollectionAccess, a6 as CollectionConfig, ab as Config, ac as ConfigError, al as DashboardWidget, aw as EasyCMSError, aA as Endpoint, aB as EndpointRequest, aC as ErrorCode, aE as Field, aF as FieldAccess, aI as FieldError, aL as FieldTypeBase, aM as FieldTypeDefinition, aN as FieldTypeValidateContext, a_ as ForbiddenError, b0 as GlobalConfig, I as ID, ba as JobConfig, bd as Label, br as NavGroup, bs as NotFoundError, bu as OnRequest, bv as OnRequestArgs, bx as PLUGIN_API_VERSION, bC as PayloadTooLargeError, bD as Plugin, bE as PluginInfo, bF as PluginTypes, bI as QueryError, bN as RequestContext, cc as TooManyRequestsError, cd as TypedPlugin, ce as UnauthorizedError, cm as ValidationError, cu as Where, cB as defineFieldType, cC as definePlugin } from './config-n7PtGl4V.js';
2
- export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-C_vknKa2.js';
1
+ export { a as ADMIN_ICONS, g as AdminComponent, j as AdminIcon, S as AuthUser, a3 as CliCommand, a4 as CollectionAccess, a6 as CollectionConfig, ab as Config, ac as ConfigError, al as DashboardWidget, aw as EasyCMSError, aA as Endpoint, aB as EndpointRequest, aC as ErrorCode, aE as Field, aF as FieldAccess, aI as FieldError, aL as FieldTypeBase, aM as FieldTypeDefinition, aN as FieldTypeValidateContext, a_ as ForbiddenError, b0 as GlobalConfig, I as ID, ba as JobConfig, bd as Label, br as NavGroup, bs as NotFoundError, bu as OnRequest, bv as OnRequestArgs, by as PLUGIN_API_VERSION, bD as PayloadTooLargeError, bE as Plugin, bF as PluginInfo, bG as PluginTypes, bJ as QueryError, bO as RequestContext, cd as TooManyRequestsError, ce as TypedPlugin, cf as UnauthorizedError, cn as ValidationError, cv as Where, cC as defineFieldType, cD as definePlugin } from './config-CHEEPk1d.js';
2
+ export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-B8xypoWd.js';
3
3
  import './conditions.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",