@easy-cms/core 1.11.0 → 1.13.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 search 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,7 +4647,7 @@ 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 httpCache`);
4650
+ admin httpCache search`);
4440
4651
  var GLOBAL = keys(
4441
4652
  "slug label fields drafts versions schedule preview access hooks scope admin httpCache"
4442
4653
  );
@@ -4656,6 +4867,264 @@ function groupOf(group, ids, fallback) {
4656
4867
  return { id: `label:${JSON.stringify(group)}`, label: group };
4657
4868
  }
4658
4869
 
4870
+ // src/search.ts
4871
+ var SEARCHABLE_TYPES = /* @__PURE__ */ new Set([
4872
+ "text",
4873
+ "textarea",
4874
+ "email",
4875
+ "slug",
4876
+ "select",
4877
+ "richText",
4878
+ "array",
4879
+ "group",
4880
+ "blocks"
4881
+ ]);
4882
+ function fieldAt(fields, path) {
4883
+ const [name, ...rest] = path.split(".");
4884
+ const field = fields.find((f) => f.name === name);
4885
+ if (!field || rest.length === 0) return field;
4886
+ return field.type === "group" ? fieldAt(field.fields, rest.join(".")) : void 0;
4887
+ }
4888
+ function textOf(field, value) {
4889
+ if (value === null || value === void 0) return "";
4890
+ switch (field.type) {
4891
+ case "text":
4892
+ case "textarea":
4893
+ case "email":
4894
+ case "slug":
4895
+ return typeof value === "string" ? value : "";
4896
+ case "select":
4897
+ return [value].flat().filter((v) => typeof v === "string").join(" ");
4898
+ case "richText":
4899
+ return richText(value);
4900
+ case "group":
4901
+ return typeof value === "object" ? fieldsText(field.fields, value) : "";
4902
+ case "array":
4903
+ case "blocks":
4904
+ if (!Array.isArray(value) || !hasRows(field)) return "";
4905
+ return value.map((row) => {
4906
+ const fields = rowFields(field, row);
4907
+ return fields && row && typeof row === "object" ? fieldsText(fields, row) : "";
4908
+ }).filter(Boolean).join("\n");
4909
+ default:
4910
+ return "";
4911
+ }
4912
+ }
4913
+ function fieldsText(fields, data) {
4914
+ return fields.filter((f) => SEARCHABLE_TYPES.has(f.type) && !f.hidden).map((f) => textOf(f, data[f.name])).filter(Boolean).join("\n");
4915
+ }
4916
+ function richText(node) {
4917
+ if (!node || typeof node !== "object") return "";
4918
+ const { text: text3, content } = node;
4919
+ if (typeof text3 === "string") return text3;
4920
+ if (!Array.isArray(content)) return "";
4921
+ const inline = content.some((child) => typeof child?.text === "string");
4922
+ return content.map(richText).filter(Boolean).join(inline ? "" : "\n");
4923
+ }
4924
+ function searchText(fields, doc, paths) {
4925
+ const text3 = {};
4926
+ for (const path of paths) {
4927
+ const field = fieldAt(fields, path);
4928
+ let value = doc;
4929
+ for (const name of path.split("."))
4930
+ value = value && typeof value === "object" ? value[name] : void 0;
4931
+ text3[path] = field ? textOf(field, value) : "";
4932
+ }
4933
+ return text3;
4934
+ }
4935
+ var segmenter = new Intl.Segmenter(void 0, { granularity: "word" });
4936
+ function words(text3) {
4937
+ return [...segmenter.segment(text3.normalize("NFC").toLowerCase())].filter((s) => s.isWordLike).map((s) => s.segment);
4938
+ }
4939
+ function memorySearch() {
4940
+ const entries = /* @__PURE__ */ new Map();
4941
+ const key = (collection2, id, locale) => JSON.stringify([collection2, id, locale]);
4942
+ return {
4943
+ name: "memory",
4944
+ apiVersion: 1,
4945
+ ephemeral: true,
4946
+ async index(list) {
4947
+ for (const e of list) {
4948
+ const fields = Object.values(e.text).map(words);
4949
+ entries.set(key(e.collection, e.id, e.locale), {
4950
+ collection: e.collection,
4951
+ id: e.id,
4952
+ locale: e.locale,
4953
+ fields,
4954
+ joined: fields.map((list2) => list2.join(""))
4955
+ });
4956
+ }
4957
+ },
4958
+ async remove(documents) {
4959
+ const gone = new Set(documents.map((d) => JSON.stringify([d.collection, d.id])));
4960
+ for (const [k, e] of entries)
4961
+ if (gone.has(JSON.stringify([e.collection, e.id]))) entries.delete(k);
4962
+ },
4963
+ async clear(collection2) {
4964
+ for (const [k, e] of entries) if (e.collection === collection2) entries.delete(k);
4965
+ },
4966
+ async search(q) {
4967
+ const terms = [...new Set(words(q.query))];
4968
+ if (terms.length === 0) return { hits: [], total: 0 };
4969
+ const collections = new Set(q.collections);
4970
+ const scored = [];
4971
+ for (const e of entries.values()) {
4972
+ if (!collections.has(e.collection) || q.locale !== null && e.locale !== q.locale) continue;
4973
+ let score = 0;
4974
+ for (const term of terms) {
4975
+ let best = 0;
4976
+ e.fields.forEach((list, i) => {
4977
+ const weight = e.fields.length - i;
4978
+ for (const w of list)
4979
+ if (w === term) best = Math.max(best, weight * 4);
4980
+ else if (w.startsWith(term)) best = Math.max(best, weight * 2);
4981
+ if (best === 0 && e.joined[i]?.includes(term)) best = weight;
4982
+ });
4983
+ if (best === 0) {
4984
+ score = 0;
4985
+ break;
4986
+ }
4987
+ score += best;
4988
+ }
4989
+ if (score > 0) scored.push({ e, score });
4990
+ }
4991
+ scored.sort((a, b) => b.score - a.score);
4992
+ return {
4993
+ hits: scored.slice(q.offset, q.offset + q.limit).map(({ e }) => ({ collection: e.collection, id: e.id })),
4994
+ total: scored.length
4995
+ };
4996
+ },
4997
+ describe: () => [
4998
+ { key: "index", value: "memory" },
4999
+ { key: "entries", value: String(entries.size) }
5000
+ ]
5001
+ };
5002
+ }
5003
+ var SearchIndex = class {
5004
+ constructor(adapter, collections, locales, logger, read2, pages2) {
5005
+ this.adapter = adapter;
5006
+ this.collections = collections;
5007
+ this.locales = locales;
5008
+ this.logger = logger;
5009
+ this.read = read2;
5010
+ this.pages = pages2;
5011
+ }
5012
+ adapter;
5013
+ collections;
5014
+ locales;
5015
+ logger;
5016
+ read;
5017
+ pages;
5018
+ /** Indexing started by saves, which searches wait for. */
5019
+ pending = /* @__PURE__ */ new Set();
5020
+ /** The first build of an `ephemeral` index. */
5021
+ building;
5022
+ /** The collection's `search`, or `undefined` when it isn't searchable. */
5023
+ configOf(collection2) {
5024
+ const config = this.collections.find((c) => c.slug === collection2);
5025
+ return config?.search ? config : void 0;
5026
+ }
5027
+ /** Slugs of the searchable collections. */
5028
+ get searchable() {
5029
+ return this.collections.filter((c) => c.search).map((c) => c.slug);
5030
+ }
5031
+ /** Builds an `ephemeral` index in the background; searches wait for it. */
5032
+ start() {
5033
+ if (!this.adapter.ephemeral || this.searchable.length === 0) return;
5034
+ this.building = this.reindex().then(
5035
+ () => void 0,
5036
+ (error) => this.logger.error(`Search: building the index failed: ${error.message}`, {
5037
+ error
5038
+ })
5039
+ );
5040
+ }
5041
+ /**
5042
+ * Indexes a document as it now is, or removes it when it isn't published (or is gone). Reading
5043
+ * it again, rather than taking what a save had, makes running it twice or late harmless.
5044
+ */
5045
+ async sync(collection2, id) {
5046
+ const config = this.configOf(collection2);
5047
+ if (!config) return;
5048
+ const entries = [];
5049
+ for (const locale of this.locales) {
5050
+ const doc = await this.read(collection2, String(id), locale);
5051
+ if (!doc) {
5052
+ await this.adapter.remove([{ collection: collection2, id: String(id) }]);
5053
+ return;
5054
+ }
5055
+ entries.push(this.entry(config, doc, locale));
5056
+ }
5057
+ await this.adapter.index(entries);
5058
+ }
5059
+ /** `sync` after a save, in the background: failures are logged. */
5060
+ syncLater(collection2, id) {
5061
+ const run = this.sync(collection2, id).catch(
5062
+ (error) => this.logger.error(
5063
+ `Search: indexing ${collection2} ${String(id)} failed: ${error.message}; run \`easy-cms search:reindex\` to repair the index`,
5064
+ { error }
5065
+ )
5066
+ ).finally(() => this.pending.delete(run));
5067
+ this.pending.add(run);
5068
+ }
5069
+ /** Waits for indexing started so far. */
5070
+ async flush() {
5071
+ await this.building;
5072
+ while (this.pending.size > 0) await Promise.all(this.pending);
5073
+ }
5074
+ /** Builds the index again from the database: every collection, or one. */
5075
+ async reindex(collection2) {
5076
+ const slugs = collection2 === void 0 ? this.searchable : [collection2];
5077
+ for (const slug2 of slugs)
5078
+ if (!this.configOf(slug2))
5079
+ throw new QueryError(`"${slug2}" is not searchable: it has no \`search\` in the config`);
5080
+ await this.adapter.setup?.(
5081
+ slugs.map((slug2) => ({ slug: slug2, fields: this.configOf(slug2)?.search.fields ?? [] }))
5082
+ );
5083
+ const done = [];
5084
+ for (const slug2 of slugs) {
5085
+ const config = this.configOf(slug2);
5086
+ await this.adapter.clear(slug2);
5087
+ const ids = /* @__PURE__ */ new Set();
5088
+ for (const locale of this.locales)
5089
+ for await (const docs of this.pages(slug2, locale)) {
5090
+ for (const doc of docs) ids.add(String(doc.id));
5091
+ await this.adapter.index(docs.map((doc) => this.entry(config, doc, locale)));
5092
+ }
5093
+ done.push({ collection: slug2, documents: ids.size });
5094
+ }
5095
+ return done;
5096
+ }
5097
+ /** Hits for a query, as documents the reader may read, best match first. */
5098
+ async search(query, readHits) {
5099
+ await this.flush();
5100
+ const found = await this.adapter.search(query);
5101
+ const byCollection = /* @__PURE__ */ new Map();
5102
+ for (const hit of found.hits)
5103
+ byCollection.set(hit.collection, [...byCollection.get(hit.collection) ?? [], hit.id]);
5104
+ const docs = /* @__PURE__ */ new Map();
5105
+ await Promise.all(
5106
+ [...byCollection].map(async ([collection2, ids]) => {
5107
+ for (const doc of await readHits(collection2, ids))
5108
+ docs.set(JSON.stringify([collection2, String(doc.id)]), doc);
5109
+ })
5110
+ );
5111
+ const hits = [];
5112
+ for (const hit of found.hits) {
5113
+ const doc = docs.get(JSON.stringify([hit.collection, hit.id]));
5114
+ if (doc) hits.push({ collection: hit.collection, doc });
5115
+ }
5116
+ return { hits, total: found.total };
5117
+ }
5118
+ entry(config, doc, locale) {
5119
+ return {
5120
+ collection: config.slug,
5121
+ id: String(doc.id),
5122
+ locale,
5123
+ text: searchText(config.fields, doc, config.search.fields)
5124
+ };
5125
+ }
5126
+ };
5127
+
4659
5128
  // src/webhooks.ts
4660
5129
  import { createHmac as createHmac5, randomUUID as randomUUID2 } from "crypto";
4661
5130
  var WEBHOOK_EVENTS = [
@@ -4694,6 +5163,23 @@ var Webhooks = class {
4694
5163
  this.track(this.deliver(hook, payload));
4695
5164
  }
4696
5165
  }
5166
+ /**
5167
+ * `emit` for the outbox: resolves once each delivery is saved in the queue (sent, without a
5168
+ * queue), so the event can count as done. Throws when saving fails; nothing is sent then.
5169
+ */
5170
+ async emitSaved(event, target, doc) {
5171
+ const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
5172
+ for (const hook of this.hooks) {
5173
+ if (!matches(hook, payload)) continue;
5174
+ if (!this.queue) {
5175
+ await this.deliver(hook, payload);
5176
+ continue;
5177
+ }
5178
+ await new Promise(
5179
+ (saved, failed) => this.track(this.deliver(hook, payload, { saved: () => saved(), failed }))
5180
+ );
5181
+ }
5182
+ }
4697
5183
  /** Waits for deliveries in progress, e.g. before a serverless function returns. */
4698
5184
  async flush() {
4699
5185
  while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
@@ -4717,7 +5203,7 @@ var Webhooks = class {
4717
5203
  const claimed = await this.queue.claim(id, queued, {
4718
5204
  ...queued,
4719
5205
  attempts,
4720
- nextAttemptAt: later2(now, CLAIM2)
5206
+ nextAttemptAt: later3(now, CLAIM2)
4721
5207
  });
4722
5208
  if (!claimed) continue;
4723
5209
  const result = await this.attempt(hook, queued.event, queued.body, queued.delivery);
@@ -4731,7 +5217,7 @@ var Webhooks = class {
4731
5217
  await this.queue.update(id, {
4732
5218
  ...queued,
4733
5219
  attempts,
4734
- nextAttemptAt: later2(now, delay ?? 0),
5220
+ nextAttemptAt: later3(now, delay ?? 0),
4735
5221
  state: delay === void 0 ? "failed" : "pending",
4736
5222
  error: result.error
4737
5223
  });
@@ -4754,7 +5240,7 @@ var Webhooks = class {
4754
5240
  ).finally(() => this.pending.delete(tracked));
4755
5241
  this.pending.add(tracked);
4756
5242
  }
4757
- async deliver(hook, payload) {
5243
+ async deliver(hook, payload, saving) {
4758
5244
  const delivery = randomUUID2();
4759
5245
  const body = JSON.stringify(payload);
4760
5246
  const { event } = payload;
@@ -4769,13 +5255,15 @@ var Webhooks = class {
4769
5255
  error
4770
5256
  });
4771
5257
  const { queue } = this;
4772
- const saved = queue ? this.saving.then(() => queue.add(entry(0, later2(/* @__PURE__ */ new Date(), CLAIM2), null))) : Promise.resolve(void 0);
5258
+ const saved = queue ? this.saving.then(() => queue.add(entry(0, later3(/* @__PURE__ */ new Date(), CLAIM2), null))) : Promise.resolve(void 0);
4773
5259
  this.saving = saved.catch(() => {
4774
5260
  });
4775
5261
  let queued;
4776
5262
  try {
4777
5263
  queued = await saved;
5264
+ saving?.saved();
4778
5265
  } catch (error) {
5266
+ if (saving) return saving.failed(error);
4779
5267
  this.logger.error(`Could not save webhook delivery: ${error.message}`, { error });
4780
5268
  }
4781
5269
  for (let attempt = 0; ; attempt++) {
@@ -4788,7 +5276,7 @@ var Webhooks = class {
4788
5276
  if (delay === void 0) {
4789
5277
  if (queued === void 0 || !this.queue)
4790
5278
  return this.giveUp(hook, event, result.error, attempt + 1);
4791
- const next = later2(/* @__PURE__ */ new Date(), QUEUE_DELAYS2[0]);
5279
+ const next = later3(/* @__PURE__ */ new Date(), QUEUE_DELAYS2[0]);
4792
5280
  await this.queue.update(queued, entry(attempt + 1, next, result.error));
4793
5281
  this.logger.warn(
4794
5282
  `Webhook ${hook.url} failed for ${event} (${result.error}); will retry later`
@@ -4833,7 +5321,7 @@ var Webhooks = class {
4833
5321
  );
4834
5322
  }
4835
5323
  };
4836
- var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
5324
+ var later3 = (from, ms) => new Date(from.getTime() + ms).toISOString();
4837
5325
  var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
4838
5326
  function matches(hook, payload) {
4839
5327
  if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
@@ -4861,6 +5349,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
4861
5349
  "audit-logs",
4862
5350
  "api-keys",
4863
5351
  "jobs",
5352
+ "outbox",
4864
5353
  "auth",
4865
5354
  "migrations",
4866
5355
  "access",
@@ -4892,6 +5381,8 @@ function validateConfig(config) {
4892
5381
  validateAudit(config, add);
4893
5382
  validateLogs(config, add);
4894
5383
  validateCountCache(config, add);
5384
+ validateOutbox(config, add);
5385
+ validateSearch(config, add);
4895
5386
  validateMaxPage(config, add);
4896
5387
  validateHttpCache(config, add);
4897
5388
  validateAuth(config, add);
@@ -5541,6 +6032,53 @@ function validateMaxPage(config, add) {
5541
6032
  if (max !== void 0 && !(Number.isInteger(max) && max >= 1))
5542
6033
  add("maxPage", "must be a whole number, 1 or more");
5543
6034
  }
6035
+ function validateOutbox(config, add) {
6036
+ if (config.outbox !== void 0 && typeof config.outbox !== "boolean")
6037
+ add("outbox", "must be true or false");
6038
+ }
6039
+ function validateSearch(config, add) {
6040
+ const adapter = config.search;
6041
+ if (adapter !== void 0) {
6042
+ const a = adapter;
6043
+ if (typeof a !== "object" || a === null || !["index", "remove", "clear", "search"].every((m) => typeof a[m] === "function"))
6044
+ add("search", "must be a search adapter", "e.g. search: memorySearch()");
6045
+ for (const c of config.collections ?? [])
6046
+ if (c?.slug === "search")
6047
+ add(
6048
+ "collections.search.slug",
6049
+ '"search" is the search route when `search` is set',
6050
+ "choose another slug"
6051
+ );
6052
+ }
6053
+ for (const c of config.collections ?? []) {
6054
+ const search = c?.search;
6055
+ if (search === void 0) continue;
6056
+ const path = `collections.${c.slug}.search`;
6057
+ if (adapter === void 0) {
6058
+ add(path, "needs a search adapter in the config", "e.g. search: memorySearch()");
6059
+ continue;
6060
+ }
6061
+ const fields = search?.fields;
6062
+ if (!Array.isArray(fields) || fields.length === 0) {
6063
+ add(
6064
+ `${path}.fields`,
6065
+ "must list the fields to index",
6066
+ "e.g. search: { fields: ['title', 'body'] }"
6067
+ );
6068
+ continue;
6069
+ }
6070
+ for (const name of fields) {
6071
+ const field = typeof name === "string" ? fieldAt(c.fields ?? [], name) : void 0;
6072
+ if (!field) add(`${path}.fields`, `has no field "${String(name)}"`);
6073
+ else if (!SEARCHABLE_TYPES.has(field.type) || field.hidden)
6074
+ add(
6075
+ `${path}.fields`,
6076
+ `can't index "${name}" (${field.hidden ? "hidden" : field.type})`,
6077
+ "text, textarea, email, slug, select, rich text, array, group and blocks fields hold text"
6078
+ );
6079
+ }
6080
+ }
6081
+ }
5544
6082
  function validateCountCache(config, add) {
5545
6083
  const seconds = config.countCacheSeconds;
5546
6084
  if (seconds !== void 0 && !(typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0))
@@ -6283,6 +6821,7 @@ function validateAdapterVersions(config, add) {
6283
6821
  ["upload.privateStorage", config.upload?.privateStorage],
6284
6822
  ["backups.storage", config.backups?.storage],
6285
6823
  ["email", config.email],
6824
+ ["search", config.search],
6286
6825
  ...(config.auth?.providers ?? []).map(
6287
6826
  (p, i) => [`auth.providers[${i}]`, p]
6288
6827
  )
@@ -6402,6 +6941,7 @@ async function resolveConfig(input) {
6402
6941
  ...config.apiKeys ? [apiKeysCollection] : [],
6403
6942
  ...config.email ? [emailDeliveriesCollection] : [],
6404
6943
  ...(config.jobs?.length ?? 0) > 0 ? [jobRunsCollection] : [],
6944
+ ...config.outbox ? [outboxCollection] : [],
6405
6945
  databaseBackupsCollection,
6406
6946
  ...config.auth?.rbac ? [rolesCollection] : [],
6407
6947
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
@@ -6490,8 +7030,23 @@ async function createEasyCMS(config, options = {}) {
6490
7030
  logger,
6491
7031
  interactive: options.interactive ?? false
6492
7032
  });
7033
+ if (resolved3.outbox && db.outbox !== true) {
7034
+ await db.destroy();
7035
+ throw new ConfigError([
7036
+ {
7037
+ path: "outbox",
7038
+ message: `needs a database adapter that writes outbox rows; "${resolved3.db.name}" doesn't`,
7039
+ hint: "use @easy-cms/db-sqlite or @easy-cms/db-postgres 1.12 or later"
7040
+ }
7041
+ ]);
7042
+ }
6493
7043
  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;
7044
+ if (resolved3.search?.ephemeral && process.env.NODE_ENV === "production")
7045
+ logger.warn(
7046
+ `Search: the "${resolved3.search.name ?? "custom"}" index lives in this process's memory: it is built again at each start, and each instance has its own. Use a search engine adapter for large sites or several instances.`
7047
+ );
7048
+ if (options.scheduler !== false) cms.startSearch();
7049
+ 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
7050
  if (scheduling && options.scheduler !== false) cms.startScheduler();
6496
7051
  if (process.env.NODE_ENV === "production" && !resolved3.auth.setupCode && await cms.auth.hasUsers().catch(() => true) === false)
6497
7052
  logger.warn(
@@ -6531,6 +7086,10 @@ var EasyCMS = class {
6531
7086
  versions;
6532
7087
  webhooks;
6533
7088
  mailer;
7089
+ /** With `outbox: true`: runs after hooks and events from rows saves write. */
7090
+ outbox;
7091
+ /** With `search`: keeps the index in step with published documents. */
7092
+ searchIndex;
6534
7093
  /** @internal */
6535
7094
  constructor(config, db, logger = consoleLogger, storage = diskStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
6536
7095
  this.config = config;
@@ -6558,6 +7117,19 @@ var EasyCMS = class {
6558
7117
  logger,
6559
7118
  config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
6560
7119
  );
7120
+ this.outbox = config.collections.some((c) => c.slug === OUTBOX) ? new Outbox(db, logger, (entry, done, mark) => this.runOutbox(entry, done, mark)) : void 0;
7121
+ this.searchIndex = config.search ? new SearchIndex(
7122
+ config.search,
7123
+ config.collections,
7124
+ config.localization ? config.localization.locales : [null],
7125
+ logger,
7126
+ (collection2, id, locale) => this.findById(collection2, id, {
7127
+ draft: false,
7128
+ depth: 0,
7129
+ ...locale ? { locale } : {}
7130
+ }),
7131
+ (collection2, locale) => this.publishedPages(collection2, locale)
7132
+ ) : void 0;
6561
7133
  this.auth = new Auth(this);
6562
7134
  }
6563
7135
  /**
@@ -7427,14 +7999,21 @@ var EasyCMS = class {
7427
7999
  ]);
7428
8000
  }
7429
8001
  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"
8002
+ const outbox2 = this.outboxRow(config, "draft", guard, draft, {
8003
+ docId: parsed,
8004
+ previousDoc: current
7436
8005
  });
7437
- this.emitChange(config, "draft", draft);
8006
+ await this.saveVersion(config, collectionParent(collection2), parsed, draft, guard, outbox2);
8007
+ if (outbox2) this.outbox?.kick();
8008
+ else {
8009
+ await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
8010
+ ...base,
8011
+ doc: draft,
8012
+ previousDoc: current,
8013
+ operation: "update"
8014
+ });
8015
+ this.emitChange(config, "draft", draft);
8016
+ }
7438
8017
  await this.audited(config, "draft", draft, current, guard, mode);
7439
8018
  const [out2] = await this.output(config, [draft], guard, {
7440
8019
  ...options,
@@ -7463,7 +8042,18 @@ var EasyCMS = class {
7463
8042
  }
7464
8043
  }
7465
8044
  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 });
8045
+ const outbox = this.outboxRow(
8046
+ config,
8047
+ "update",
8048
+ guard,
8049
+ { ...data, id: parsed },
8050
+ {
8051
+ docId: parsed,
8052
+ previousDoc: current,
8053
+ before: existing.status
8054
+ }
8055
+ );
8056
+ 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
8057
  if (!doc) return null;
7468
8058
  if (moved) await moved.cleanUp();
7469
8059
  if (collection2 === MEDIA_FOLDERS) {
@@ -7473,13 +8063,16 @@ var EasyCMS = class {
7473
8063
  }
7474
8064
  if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
7475
8065
  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);
8066
+ if (outbox) this.outbox?.kick();
8067
+ else {
8068
+ await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
8069
+ ...base,
8070
+ doc,
8071
+ previousDoc: current,
8072
+ operation: "update"
8073
+ });
8074
+ this.emitChange(config, "update", doc, existing.status);
8075
+ }
7483
8076
  await this.audited(config, "update", doc, current, guard, mode);
7484
8077
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
7485
8078
  return out;
@@ -7517,7 +8110,8 @@ var EasyCMS = class {
7517
8110
  await this.checkMoveLimit(await this.folders.privacyAfter(parsed, { private: false }));
7518
8111
  await this.folders.release(existing);
7519
8112
  }
7520
- await this.db.delete({ collection: collection2, id: parsed });
8113
+ const outbox = this.outboxRow(config, "delete", guard, existing, { docId: parsed });
8114
+ await this.db.delete({ collection: collection2, id: parsed, outbox });
7521
8115
  if (collection2 === MEDIA_FOLDERS) {
7522
8116
  this.folders.invalidate();
7523
8117
  const parent = existing.parent ?? null;
@@ -7529,12 +8123,15 @@ var EasyCMS = class {
7529
8123
  for (const job of await this.pendingJobs(collection2, parsed))
7530
8124
  await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
7531
8125
  }
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);
8126
+ if (outbox) this.outbox?.kick();
8127
+ else {
8128
+ await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
8129
+ ...base,
8130
+ id: parsed,
8131
+ doc: existing
8132
+ });
8133
+ this.emitChange(config, "delete", existing);
8134
+ }
7538
8135
  await this.audited(config, "delete", existing, existing, guard);
7539
8136
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
7540
8137
  return out;
@@ -7691,26 +8288,38 @@ var EasyCMS = class {
7691
8288
  const now = (/* @__PURE__ */ new Date()).toISOString();
7692
8289
  if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
7693
8290
  const draft = { ...prepared, updatedAt: now };
7694
- await this.saveVersion(config, globalParent(key), 0, draft, guard);
8291
+ const outbox2 = this.outboxRow(config, "draft", guard, draft, { previousDoc: current });
8292
+ await this.saveVersion(config, globalParent(key), 0, draft, guard, outbox2);
8293
+ if (outbox2) this.outbox?.kick();
8294
+ else {
8295
+ await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
8296
+ ...base,
8297
+ doc: draft,
8298
+ previousDoc: current,
8299
+ operation: "update"
8300
+ });
8301
+ this.emitChange(config, "draft", draft);
8302
+ }
8303
+ await this.audited(config, "draft", draft, current, guard, mode);
8304
+ return this.readGlobal(config, key, guard, { ...options, draft: true });
8305
+ }
8306
+ const data = { ...prepared, updatedAt: now };
8307
+ const outbox = this.outboxRow(config, "update", guard, data, {
8308
+ previousDoc: current,
8309
+ before: existing.status
8310
+ });
8311
+ const doc = await this.db.updateGlobal({ slug: key, data, outbox });
8312
+ await this.saveVersion(config, globalParent(key), 0, doc, guard);
8313
+ if (outbox) this.outbox?.kick();
8314
+ else {
7695
8315
  await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
7696
8316
  ...base,
7697
- doc: draft,
8317
+ doc,
7698
8318
  previousDoc: current,
7699
8319
  operation: "update"
7700
8320
  });
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 });
8321
+ this.emitChange(config, "update", doc, existing.status);
7704
8322
  }
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
8323
  await this.audited(config, "update", doc, current, guard, mode);
7715
8324
  return this.readGlobal(config, key, guard, { ...options, draft: true });
7716
8325
  }
@@ -7824,9 +8433,97 @@ var EasyCMS = class {
7824
8433
  }
7825
8434
  };
7826
8435
  }
8436
+ /**
8437
+ * Waits until the outbox (`outbox: true`) ran what saves left so far, e.g. before a serverless
8438
+ * function returns. What fails stays queued for its retry.
8439
+ */
8440
+ flushOutbox() {
8441
+ return this.outbox?.flush() ?? Promise.resolve();
8442
+ }
8443
+ /**
8444
+ * Searches the collections that have `search` with the config's `search` adapter: published
8445
+ * documents, best match first, read with the access options given (pass `overrideAccess: false`
8446
+ * and `user` to apply access rules). `total` counts matches before access rules, so a page can
8447
+ * hold fewer documents than `limit`.
8448
+ */
8449
+ async search(query, options = {}) {
8450
+ const index = this.searchIndex;
8451
+ if (!index) throw new QueryError("Search needs a `search` adapter in the config");
8452
+ const limit = options.limit ?? 10;
8453
+ const page = options.page ?? 1;
8454
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
8455
+ throw new QueryError("limit must be an integer from 1 to 100");
8456
+ if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be an integer from 1");
8457
+ const collections = options.collections ?? index.searchable;
8458
+ for (const slug2 of collections)
8459
+ if (!index.configOf(slug2)) throw new QueryError(`"${slug2}" is not searchable`);
8460
+ const localization = this.config.localization;
8461
+ const asked = options.locale && options.locale !== ALL_LOCALES ? options.locale : void 0;
8462
+ if (asked !== void 0 && localization && !localization.locales.includes(asked))
8463
+ throw new QueryError(`Unknown locale "${asked}"`);
8464
+ const locale = localization ? asked ?? localization.defaultLocale ?? null : null;
8465
+ const text3 = query.trim().slice(0, 200);
8466
+ const empty = { hits: [], total: 0, page, limit, hasNextPage: false };
8467
+ if (!text3 || collections.length === 0) return empty;
8468
+ const { collections: _, limit: __, page: ___, ...read2 } = options;
8469
+ const { hits, total } = await index.search(
8470
+ { query: text3, collections, locale, limit, offset: (page - 1) * limit },
8471
+ async (collection2, ids) => (await this.find(collection2, {
8472
+ ...read2,
8473
+ ...locale ? { locale } : {},
8474
+ where: { id: { in: ids.map((id) => parseId(id) ?? id) } },
8475
+ limit: ids.length,
8476
+ draft: false
8477
+ })).docs
8478
+ );
8479
+ return { hits, total, page, limit, hasNextPage: page * limit < total };
8480
+ }
8481
+ /**
8482
+ * Builds the search index again from the database, for every searchable collection or one:
8483
+ * after adding `search` to a collection, changing its fields, or to repair the index
8484
+ * (`easy-cms search:reindex`).
8485
+ */
8486
+ async reindexSearch(collection2) {
8487
+ if (!this.searchIndex) throw new QueryError("Search needs a `search` adapter in the config");
8488
+ return this.searchIndex.reindex(collection2);
8489
+ }
8490
+ /** @internal Builds an in-memory search index (`ephemeral`) when the CMS starts. */
8491
+ startSearch() {
8492
+ this.searchIndex?.start();
8493
+ }
8494
+ /** Published documents of a collection in a locale, a page at a time, for the search index. */
8495
+ async *publishedPages(collection2, locale) {
8496
+ const options = {
8497
+ draft: false,
8498
+ depth: 0,
8499
+ limit: 100,
8500
+ sort: "id",
8501
+ ...locale ? { locale } : {}
8502
+ };
8503
+ if (this.db.findAfter) {
8504
+ let cursor = "";
8505
+ for (; ; ) {
8506
+ const found = await this.find(collection2, { ...options, cursor });
8507
+ yield found.docs;
8508
+ if (!found.nextCursor) return;
8509
+ cursor = found.nextCursor;
8510
+ }
8511
+ }
8512
+ for (let page = 1; ; page++) {
8513
+ const found = await this.find(collection2, { ...options, page, count: false });
8514
+ yield found.docs;
8515
+ if (!found.hasNextPage) return;
8516
+ }
8517
+ }
8518
+ /** @internal Puts a failed outbox entry back in line (Settings → Jobs). */
8519
+ retryOutbox(id) {
8520
+ return this.outbox?.retry(id) ?? Promise.resolve(false);
8521
+ }
7827
8522
  async destroy() {
7828
8523
  if (this.schedulerTimer) clearInterval(this.schedulerTimer);
7829
8524
  await this.running;
8525
+ await this.outbox?.flush();
8526
+ await this.searchIndex?.flush();
7830
8527
  await this.webhooks.flush();
7831
8528
  await this.mailer.flush();
7832
8529
  await this.db.destroy();
@@ -7873,7 +8570,8 @@ var EasyCMS = class {
7873
8570
  scheduled,
7874
8571
  webhooks: await this.retryWebhooks(now),
7875
8572
  emails: await this.mailer.retry(now),
7876
- jobs: await this.runConfigJobs(now)
8573
+ jobs: await this.runConfigJobs(now),
8574
+ outbox: await this.outbox?.run(now) ?? { ran: 0, failed: 0 }
7877
8575
  };
7878
8576
  const ranAt = (/* @__PURE__ */ new Date()).toISOString();
7879
8577
  await this.db.updateGlobal({ slug: JOBS_RAN, data: { at: ranAt, updatedAt: ranAt } }).catch((error) => this.logger.error(`Jobs: ${error.message}`, { error }));
@@ -8605,18 +9303,20 @@ var EasyCMS = class {
8605
9303
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
8606
9304
  if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
8607
9305
  const now = (/* @__PURE__ */ new Date()).toISOString();
8608
- const doc = await this.db.create({
8609
- collection: collection2,
8610
- data: { ...prepared, createdAt: now, updatedAt: now }
8611
- });
9306
+ const row = { ...prepared, createdAt: now, updatedAt: now };
9307
+ const outbox = this.outboxRow(config, "create", hookGuard ?? guard, row);
9308
+ const doc = await this.db.create({ collection: collection2, data: row, outbox });
8612
9309
  if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
8613
9310
  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);
9311
+ if (outbox) this.outbox?.kick();
9312
+ else {
9313
+ await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
9314
+ ...base,
9315
+ doc,
9316
+ operation: "create"
9317
+ });
9318
+ this.emitChange(config, "create", doc);
9319
+ }
8620
9320
  await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
8621
9321
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
8622
9322
  return out;
@@ -8674,23 +9374,25 @@ var EasyCMS = class {
8674
9374
  });
8675
9375
  }
8676
9376
  /**
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.
9377
+ * The tags `httpCache.purge` gets for a change: what readers see changed (anything, without
9378
+ * drafts; with drafts, a published document saved, published, unpublished or deleted).
8680
9379
  */
8681
- purgePublic(config, isGlobal, event, doc, before) {
8682
- const purge = this.config.httpCache?.purge;
8683
- if (!purge || config.httpCache === false || event === "draft") return;
9380
+ purgeTags(config, event, doc, before) {
9381
+ if (!this.config.httpCache?.purge || config.httpCache === false || event === "draft") return;
8684
9382
  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
- );
9383
+ return this.isGlobal(config) ? cacheTags.global(config.slug) : cacheTags.document(config.slug, doc.id);
8689
9384
  }
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;
9385
+ /** Whether a change may change what the search index holds: a save of the document itself. */
9386
+ searches(config, event) {
9387
+ return event !== "draft" && !this.isGlobal(config) && !!this.searchIndex?.configOf(config.slug);
9388
+ }
9389
+ isGlobal(config) {
9390
+ return !this.config.collections.includes(config);
9391
+ }
9392
+ /** The webhook events of a change; `before` is the stored status before it, if any. */
9393
+ changeEvents(config, event, doc, before) {
9394
+ const isGlobal = this.isGlobal(config);
9395
+ if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return [];
8694
9396
  const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
8695
9397
  id: doc.id,
8696
9398
  ...snapshotOf(config.fields, doc),
@@ -8698,13 +9400,82 @@ var EasyCMS = class {
8698
9400
  updatedAt: doc.updatedAt
8699
9401
  };
8700
9402
  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;
9403
+ const events = [{ event, target, body }];
9404
+ if (event === "draft" || event === "delete" || !config.drafts) return events;
8704
9405
  const after = doc.status;
8705
- if (after === "published" && before !== "published") this.webhooks.emit("publish", target, body);
9406
+ if (after === "published" && before !== "published")
9407
+ events.push({ event: "publish", target, body });
8706
9408
  if (after !== "published" && before === "published")
8707
- this.webhooks.emit("unpublish", target, body);
9409
+ events.push({ event: "unpublish", target, body });
9410
+ return events;
9411
+ }
9412
+ /**
9413
+ * Sends webhook events for a change and purges the HTTP cache. Not awaited; purge failures
9414
+ * are logged.
9415
+ */
9416
+ emitChange(config, event, doc, before) {
9417
+ for (const change of this.changeEvents(config, event, doc, before))
9418
+ this.webhooks.emit(change.event, change.target, change.body);
9419
+ if (this.searches(config, event)) this.searchIndex?.syncLater(config.slug, doc.id);
9420
+ const purge = this.config.httpCache?.purge;
9421
+ const tags = this.purgeTags(config, event, doc, before);
9422
+ if (!purge || !tags) return;
9423
+ void Promise.resolve().then(() => purge(tags)).catch(
9424
+ (error) => this.logger.error(`httpCache.purge failed: ${error.message}`, { error })
9425
+ );
9426
+ }
9427
+ /**
9428
+ * The outbox row of a save (`outbox: true`): written in its transaction, then run by the outbox
9429
+ * in place of its after hooks and events. `undefined` without the outbox, or with nothing to run.
9430
+ */
9431
+ outboxRow(config, event, guard, doc, extra = {}) {
9432
+ if (!this.outbox) return void 0;
9433
+ const hooks = event === "delete" ? config.hooks?.afterDelete : config.hooks?.afterChange;
9434
+ const isGlobal = this.isGlobal(config);
9435
+ const effects = (isGlobal || !INTERNAL_COLLECTIONS.has(config.slug)) && ((this.config.webhooks?.length ?? 0) > 0 || this.config.httpCache?.purge !== void 0);
9436
+ if (!hooks?.length && !effects && !this.searches(config, event)) return void 0;
9437
+ return this.outbox.row({
9438
+ event,
9439
+ target: config.slug,
9440
+ global: isGlobal,
9441
+ docId: extra.docId,
9442
+ before: typeof extra.before === "string" ? extra.before : null,
9443
+ doc,
9444
+ previousDoc: extra.previousDoc,
9445
+ user: guard.user
9446
+ });
9447
+ }
9448
+ /** Runs an outbox entry: what its save would have run right after it, without the outbox. */
9449
+ async runOutbox(entry, done, mark) {
9450
+ const config = entry.global ? this.config.globals.find((g) => g.slug === entry.target) : this.config.collections.find((c) => c.slug === entry.target);
9451
+ if (!config) return;
9452
+ const { doc, event } = entry;
9453
+ if (!done.has("hooks")) {
9454
+ const base = this.hookArgs(config, { enforce: false, user: entry.user, context: {} });
9455
+ const hooks = event === "delete" ? config.hooks?.afterDelete : config.hooks?.afterChange;
9456
+ const args = event === "delete" ? { ...base, id: parseId(entry.docId ?? void 0), doc } : {
9457
+ ...base,
9458
+ doc,
9459
+ ...entry.previousDoc ? { previousDoc: entry.previousDoc } : {},
9460
+ operation: event === "create" ? "create" : "update"
9461
+ };
9462
+ for (const hook of hooks ?? [])
9463
+ await hook(args);
9464
+ if (hooks?.length) await mark("hooks");
9465
+ }
9466
+ if (!done.has("webhooks")) {
9467
+ const changes = this.changeEvents(config, event, doc, entry.before);
9468
+ for (const change of changes)
9469
+ await this.webhooks.emitSaved(change.event, change.target, change.body);
9470
+ if (changes.length > 0 && (this.config.webhooks?.length ?? 0) > 0) await mark("webhooks");
9471
+ }
9472
+ if (!done.has("search") && this.searchIndex && this.searches(config, event)) {
9473
+ await this.searchIndex.sync(config.slug, parseId(entry.docId ?? void 0));
9474
+ await mark("search");
9475
+ }
9476
+ const purge = this.config.httpCache?.purge;
9477
+ const tags = this.purgeTags(config, event, doc, entry.before);
9478
+ if (purge && tags) await purge(tags);
8708
9479
  }
8709
9480
  /**
8710
9481
  * Writes the audit log entry for a change (`audit`): publishing, unpublishing and restoring
@@ -8735,7 +9506,7 @@ var EasyCMS = class {
8735
9506
  );
8736
9507
  }
8737
9508
  /** Records a version after a save, when the collection or global keeps versions. */
8738
- async saveVersion(config, parent, id, doc, guard) {
9509
+ async saveVersion(config, parent, id, doc, guard, outbox) {
8739
9510
  const max = versionLimit(config);
8740
9511
  if (max === void 0) return;
8741
9512
  await this.versions.save({
@@ -8744,7 +9515,8 @@ var EasyCMS = class {
8744
9515
  status: config.drafts ? doc.status ?? "draft" : null,
8745
9516
  snapshot: snapshotOf(config.fields, doc),
8746
9517
  author: guard.user?.id ?? null,
8747
- max
9518
+ max,
9519
+ outbox
8748
9520
  });
8749
9521
  }
8750
9522
  hookArgs(config, guard) {
@@ -9173,7 +9945,7 @@ function readCursor(cursor, query) {
9173
9945
  // src/rest/admin-jobs.ts
9174
9946
  var has = (config, slug2) => config.collections.some((c) => c.slug === slug2);
9175
9947
  function hasJobsPage(config) {
9176
- return config.jobs.length > 0 || has(config, SCHEDULED_JOBS);
9948
+ return config.jobs.length > 0 || has(config, SCHEDULED_JOBS) || has(config, OUTBOX);
9177
9949
  }
9178
9950
  var text2 = (value) => typeof value === "string" && value !== "" ? value : null;
9179
9951
  async function adminJobs(cms, user) {
@@ -9206,6 +9978,7 @@ async function adminJobs(cms, user) {
9206
9978
  ...has(config, SCHEDULED_JOBS) ? ["scheduled"] : [],
9207
9979
  ...has(config, WEBHOOK_DELIVERIES) ? ["webhooks"] : [],
9208
9980
  ...has(config, EMAIL_DELIVERIES) ? ["emails"] : [],
9981
+ ...has(config, OUTBOX) ? ["outbox"] : [],
9209
9982
  ...config.backups?.frequency ? ["backups"] : [],
9210
9983
  ...config.audit ? ["audit"] : []
9211
9984
  ];
@@ -9214,7 +9987,8 @@ async function adminJobs(cms, user) {
9214
9987
  lastRanAt: await cms.jobsLastRan(),
9215
9988
  builtIn,
9216
9989
  timeZone: config.timeZone ?? null,
9217
- canChange: isSystemAdmin(user)
9990
+ canChange: isSystemAdmin(user),
9991
+ outbox: has(config, OUTBOX) ? await adminOutbox(cms) : null
9218
9992
  };
9219
9993
  if (!has(config, SCHEDULED_JOBS)) return { ...head, scheduled: null };
9220
9994
  const { docs } = await cms.db.find({
@@ -9239,6 +10013,38 @@ async function adminJobs(cms, user) {
9239
10013
  });
9240
10014
  return { ...head, scheduled };
9241
10015
  }
10016
+ async function adminOutbox(cms) {
10017
+ const count = (state) => cms.db.count({ collection: OUTBOX, where: { state: { in: state } } });
10018
+ const { docs } = await cms.db.find({
10019
+ collection: OUTBOX,
10020
+ where: { attempts: { gt: 0 } },
10021
+ sort: ["-updatedAt"],
10022
+ limit: 100,
10023
+ page: 1
10024
+ });
10025
+ return {
10026
+ waiting: await count(["pending", "running"]),
10027
+ failed: await count(["failed"]),
10028
+ entries: docs.filter((row) => text2(row.error) !== null).map((row) => ({
10029
+ id: row.id,
10030
+ event: row.event,
10031
+ ...row.global === true ? { global: String(row.target) } : { collection: String(row.target) },
10032
+ doc: text2(row.docId),
10033
+ attempts: Number(row.attempts ?? 0),
10034
+ state: row.state === "failed" || row.state === "running" ? row.state : "pending",
10035
+ error: text2(row.error),
10036
+ nextAttemptAt: String(row.nextAttemptAt)
10037
+ }))
10038
+ };
10039
+ }
10040
+ async function retryOutbox(cms, user, id) {
10041
+ if (!isSystemAdmin(user)) throw new ForbiddenError();
10042
+ if (!has(cms.config, OUTBOX)) throw new NotFoundError(OUTBOX, id);
10043
+ const row = await cms.db.findById({ collection: OUTBOX, id: parseId(id) ?? 0 });
10044
+ if (!row) throw new NotFoundError(OUTBOX, id);
10045
+ if (!await cms.retryOutbox(row.id))
10046
+ throw new EasyCMSError("Only failed entries can be retried", 409);
10047
+ }
9242
10048
  var TIME_OF_DAY2 = /^([01]\d|2[0-3]):[0-5]\d$/;
9243
10049
  async function updateJob(cms, user, name, body) {
9244
10050
  if (!isSystemAdmin(user)) throw new ForbiddenError();
@@ -9945,7 +10751,7 @@ async function readAdminModule(cms, index) {
9945
10751
  }
9946
10752
 
9947
10753
  // src/version.ts
9948
- var VERSION = true ? "1.11.0" : "0.0.0-dev";
10754
+ var VERSION = true ? "1.13.0" : "0.0.0-dev";
9949
10755
 
9950
10756
  // src/rest/handler.ts
9951
10757
  import { randomUUID as randomUUID4 } from "crypto";
@@ -9955,7 +10761,7 @@ var PER_PAGE = 50;
9955
10761
  var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
9956
10762
  var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
9957
10763
  var isDeliveryKind = (value) => value === "webhook" || value === "email";
9958
- function parse2(text3) {
10764
+ function parse3(text3) {
9959
10765
  try {
9960
10766
  const value = JSON.parse(String(text3 ?? ""));
9961
10767
  return typeof value === "object" && value !== null ? value : {};
@@ -9974,7 +10780,7 @@ function toAdmin2(kind, row) {
9974
10780
  nextAttemptAt: String(row.nextAttemptAt ?? "")
9975
10781
  };
9976
10782
  if (kind === "webhook") {
9977
- const payload = parse2(row.body);
10783
+ const payload = parse3(row.body);
9978
10784
  return {
9979
10785
  ...base,
9980
10786
  kind,
@@ -9986,7 +10792,7 @@ function toAdmin2(kind, row) {
9986
10792
  body: String(row.body ?? "")
9987
10793
  };
9988
10794
  }
9989
- const message2 = parse2(row.message);
10795
+ const message2 = parse3(row.message);
9990
10796
  const to = message2.to;
9991
10797
  return {
9992
10798
  ...base,
@@ -10611,9 +11417,17 @@ async function adminSearch(cms, who, q, perCollection = 5) {
10611
11417
  const conditions = [];
10612
11418
  if (title) conditions.push({ [title]: { like: query } });
10613
11419
  if (/^\d+$/.test(query)) conditions.push({ id: { equals: Number(query) } });
10614
- if (conditions.length === 0 || !await readable(cms, c, who)) return [];
11420
+ const indexed = cms.config.search !== void 0 && c.search !== void 0;
11421
+ if (conditions.length === 0 && !indexed || !await readable(cms, c, who)) return [];
10615
11422
  try {
10616
- const found = await cms.find(c.slug, {
11423
+ const ranked = indexed ? (await cms.search(query, {
11424
+ ...who,
11425
+ overrideAccess: false,
11426
+ depth: 0,
11427
+ limit: perCollection,
11428
+ collections: [c.slug]
11429
+ })).hits.map((hit) => hit.doc) : [];
11430
+ const found = conditions.length === 0 ? { docs: [] } : await cms.find(c.slug, {
10617
11431
  ...who,
10618
11432
  overrideAccess: false,
10619
11433
  draft: true,
@@ -10622,7 +11436,9 @@ async function adminSearch(cms, who, q, perCollection = 5) {
10622
11436
  where: conditions.length === 1 ? conditions[0] : { or: conditions },
10623
11437
  sort: "-updatedAt"
10624
11438
  });
10625
- return found.docs.map((doc) => {
11439
+ const seen = /* @__PURE__ */ new Set();
11440
+ const docs = [...ranked, ...found.docs].filter((doc) => !seen.has(doc.id) && seen.add(doc.id)).slice(0, perCollection);
11441
+ return docs.map((doc) => {
10626
11442
  const d = doc;
10627
11443
  const value = title ? d[title] : void 0;
10628
11444
  return {
@@ -10711,6 +11527,7 @@ async function adminStatus(cms) {
10711
11527
  database: config.db.name,
10712
11528
  storage: config.upload.storage?.name ?? "local",
10713
11529
  email: config.email ? config.email.name ?? "custom" : null,
11530
+ search: config.search ? config.search.name ?? "custom" : null,
10714
11531
  plugins: config.installedPlugins.map((p) => ({ ...p })),
10715
11532
  fieldTypes: config.fieldTypes.map((t) => t.name)
10716
11533
  },
@@ -10719,7 +11536,7 @@ async function adminStatus(cms) {
10719
11536
  }
10720
11537
 
10721
11538
  // src/rest/filter-options.ts
10722
- function fieldAt(fields, path) {
11539
+ function fieldAt2(fields, path) {
10723
11540
  let current = fields;
10724
11541
  let found;
10725
11542
  for (const part of path.split(".")) {
@@ -10738,7 +11555,7 @@ async function pickerFilter(cms, url, target, user, context = {}) {
10738
11555
  const dot = spec.indexOf(".");
10739
11556
  const slug2 = dot > 0 ? spec.slice(0, dot) : "";
10740
11557
  const owner = forCollection !== null ? cms.config.collections.find((c) => c.slug === slug2) : cms.config.globals.find((g) => g.slug === slug2);
10741
- const field = owner ? fieldAt(owner.fields, spec.slice(dot + 1)) : void 0;
11558
+ const field = owner ? fieldAt2(owner.fields, spec.slice(dot + 1)) : void 0;
10742
11559
  const to = field?.type === "upload" ? "media" : field?.type === "relationship" ? field.to : null;
10743
11560
  if (!field || field.type !== "relationship" && field.type !== "upload" || to !== target)
10744
11561
  throw new QueryError(`filterFor: no relationship to "${target}" at "${spec}"`);
@@ -11503,6 +12320,13 @@ async function route(cms, ctx, method, path, options) {
11503
12320
  await cms.audit.record({ action: "jobs.run", target: "jobs", detail: result });
11504
12321
  return { body: result };
11505
12322
  }
12323
+ if (third === "outbox" && segments.length === 4) {
12324
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
12325
+ const id2 = segments[3];
12326
+ await retryOutbox(cms, ctx.user, id2);
12327
+ await cms.audit.record({ action: "jobs.outbox.retry", target: "jobs", detail: { id: id2 } });
12328
+ return { body: await adminJobs(cms, ctx.user) };
12329
+ }
11506
12330
  if (third !== void 0 && segments.length === 3) {
11507
12331
  if (method !== "PATCH") throw methodNotAllowed(ctx, "PATCH");
11508
12332
  const { action, detail } = await updateJob(
@@ -11659,6 +12483,19 @@ async function route(cms, ctx, method, path, options) {
11659
12483
  );
11660
12484
  return { status: 201, body: { ...doc, key } };
11661
12485
  }
12486
+ if (first === "search" && second === void 0 && cms.config.search) {
12487
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
12488
+ const q = ctx.url.searchParams;
12489
+ const collections = q.get("collections");
12490
+ return {
12491
+ body: await cms.search(q.get("q") ?? "", {
12492
+ ...access,
12493
+ ...parseDepth(ctx.url),
12494
+ ...pageQuery(q, 10),
12495
+ ...collections ? { collections: collections.split(",").filter(Boolean) } : {}
12496
+ })
12497
+ };
12498
+ }
11662
12499
  const collection2 = first;
11663
12500
  if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
11664
12501
  throw new HttpError(`Unknown collection "${collection2}"`, 404);
@@ -12378,6 +13215,7 @@ export {
12378
13215
  Roles,
12379
13216
  checkRenamedOptions,
12380
13217
  BUILTIN_NAV,
13218
+ memorySearch,
12381
13219
  WEBHOOK_EVENTS,
12382
13220
  MIN_SECRET_LENGTH,
12383
13221
  BUILTIN_COLLECTIONS,