@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.
- package/dist/{chunk-TMO4JTGJ.js → chunk-MHXQUX74.js} +930 -92
- package/dist/{config-n7PtGl4V.d.ts → config-B-bT0hIn.d.ts} +204 -8
- package/dist/{handler-DV-1ICVz.d.ts → handler-COsNEVx0.d.ts} +1 -1
- package/dist/index.d.ts +31 -6
- package/dist/index.js +3 -1
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +1 -1
- package/dist/{plugin-C_vknKa2.d.ts → plugin-CI6r3SK1.d.ts} +1 -1
- package/dist/plugin.d.ts +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
576
|
-
return
|
|
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:
|
|
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:
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
|
|
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
|
-
|
|
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
|
|
8317
|
+
doc,
|
|
7698
8318
|
previousDoc: current,
|
|
7699
8319
|
operation: "update"
|
|
7700
8320
|
});
|
|
7701
|
-
this.emitChange(config, "
|
|
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
|
|
8609
|
-
|
|
8610
|
-
|
|
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
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
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
|
|
8678
|
-
* published document saved, published, unpublished or deleted).
|
|
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
|
-
|
|
8682
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
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
|
-
|
|
8702
|
-
|
|
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")
|
|
9406
|
+
if (after === "published" && before !== "published")
|
|
9407
|
+
events.push({ event: "publish", target, body });
|
|
8706
9408
|
if (after !== "published" && before === "published")
|
|
8707
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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,
|