@dyrected/core 2.18.1 → 2.19.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/index.cjs CHANGED
@@ -1811,6 +1811,8 @@ __export(index_exports, {
1811
1811
  RESERVED_NAVIGATION_SLUGS: () => RESERVED_NAVIGATION_SLUGS,
1812
1812
  TASK_LOCKS_COLLECTION: () => TASK_LOCKS_COLLECTION,
1813
1813
  TASK_LOCKS_COLLECTION_CONFIG: () => TASK_LOCKS_COLLECTION_CONFIG,
1814
+ TRASH_COLLECTION: () => TRASH_COLLECTION,
1815
+ TRASH_COLLECTION_CONFIG: () => TRASH_COLLECTION_CONFIG,
1814
1816
  UNASSIGNED_GROUP_KEY: () => UNASSIGNED_GROUP_KEY,
1815
1817
  ValidationError: () => ValidationError,
1816
1818
  WORKFLOW_HISTORY_COLLECTION: () => WORKFLOW_HISTORY_COLLECTION,
@@ -1824,6 +1826,7 @@ __export(index_exports, {
1824
1826
  assertValidDeclarativeHooksInConfig: () => assertValidDeclarativeHooksInConfig,
1825
1827
  assertValidNavigationSlugs: () => assertValidNavigationSlugs,
1826
1828
  assertValidPreviewUrlsInConfig: () => assertValidPreviewUrlsInConfig,
1829
+ assertValidTrashInConfig: () => assertValidTrashInConfig,
1827
1830
  availableWorkflowTransitions: () => availableWorkflowTransitions,
1828
1831
  buildSqlOrderBy: () => buildSqlOrderBy,
1829
1832
  canViewWorkflowDraft: () => canViewWorkflowDraft,
@@ -1842,6 +1845,7 @@ __export(index_exports, {
1842
1845
  count: () => count,
1843
1846
  createLifecycleEvent: () => createLifecycleEvent,
1844
1847
  createTaskRunner: () => createTaskRunner,
1848
+ createTrashPurgeTask: () => createTrashPurgeTask,
1845
1849
  createWorkflowDocument: () => createWorkflowDocument,
1846
1850
  defaultValue: () => defaultValue,
1847
1851
  defineAction: () => defineAction,
@@ -1853,6 +1857,8 @@ __export(index_exports, {
1853
1857
  defineCardsView: () => defineCardsView,
1854
1858
  defineCollection: () => defineCollection,
1855
1859
  defineConfig: () => defineConfig,
1860
+ defineCreatedAtField: () => defineCreatedAtField,
1861
+ defineCreatedByField: () => defineCreatedByField,
1856
1862
  defineDateField: () => defineDateField,
1857
1863
  defineDateTimeField: () => defineDateTimeField,
1858
1864
  defineEmailField: () => defineEmailField,
@@ -1872,6 +1878,8 @@ __export(index_exports, {
1872
1878
  defineRadioField: () => defineRadioField,
1873
1879
  defineRelationshipField: () => defineRelationshipField,
1874
1880
  defineRichTextField: () => defineRichTextField,
1881
+ defineRoles: () => defineRoles,
1882
+ defineRolesField: () => defineRolesField,
1875
1883
  defineRowField: () => defineRowField,
1876
1884
  defineSelectField: () => defineSelectField,
1877
1885
  defineTab: () => defineTab,
@@ -1880,6 +1888,8 @@ __export(index_exports, {
1880
1888
  defineTextField: () => defineTextField,
1881
1889
  defineTextareaField: () => defineTextareaField,
1882
1890
  defineTimeField: () => defineTimeField,
1891
+ defineUpdatedAtField: () => defineUpdatedAtField,
1892
+ defineUpdatedByField: () => defineUpdatedByField,
1883
1893
  defineUrlField: () => defineUrlField,
1884
1894
  defineView: () => defineView,
1885
1895
  defineWorkspace: () => defineWorkspace,
@@ -1919,6 +1929,7 @@ __export(index_exports, {
1919
1929
  getAdminRoleForCollection: () => getAdminRoleForCollection,
1920
1930
  getAllowedSitesForUser: () => getAllowedSitesForUser,
1921
1931
  getPublicAdminAuthConfig: () => getPublicAdminAuthConfig,
1932
+ getTrashEntryId: () => getTrashEntryId,
1922
1933
  includes: () => includes,
1923
1934
  initializeWorkflowDocument: () => initializeWorkflowDocument,
1924
1935
  isAICollection: () => isAICollection,
@@ -1962,14 +1973,18 @@ __export(index_exports, {
1962
1973
  resolveAdminAuthCollection: () => resolveAdminAuthCollection,
1963
1974
  resolveAllCollectionViews: () => resolveAllCollectionViews,
1964
1975
  resolveAuthorizedSiteId: () => resolveAuthorizedSiteId,
1976
+ resolveDocumentTitle: () => resolveDocumentTitle,
1965
1977
  resolveIndexName: () => resolveIndexName,
1966
1978
  resolvePrefix: () => resolvePrefix,
1979
+ resolveTrashConfig: () => resolveTrashConfig,
1980
+ restoreDocument: () => restoreDocument,
1967
1981
  round: () => round,
1968
1982
  runCollectionHooks: () => runCollectionHooks,
1969
1983
  sanitizeDocForAI: () => sanitizeDocForAI,
1970
1984
  saveWorkflowDraft: () => saveWorkflowDraft,
1971
1985
  simplePublishingWorkflow: () => simplePublishingWorkflow,
1972
1986
  slugify: () => slugify,
1987
+ softDeleteDocument: () => softDeleteDocument,
1973
1988
  sqrt: () => sqrt,
1974
1989
  startsWith: () => startsWith,
1975
1990
  sum: () => sum,
@@ -2083,1821 +2098,2318 @@ var import_jose = require("jose");
2083
2098
  // src/auth/sessions.ts
2084
2099
  var AUTH_SESSIONS_COLLECTION = "__auth_sessions";
2085
2100
 
2086
- // src/tasks.ts
2087
- var TASK_LOCKS_COLLECTION = "__task_locks";
2088
- var TASK_LOCKS_COLLECTION_CONFIG = {
2089
- slug: TASK_LOCKS_COLLECTION,
2090
- labels: { singular: "Task lock", plural: "Task locks" },
2091
- fields: [
2092
- { name: "name", type: "text", required: true },
2093
- // Numeric and promoted so the lock compare-and-set is a real column comparison.
2094
- { name: "lockedUntil", type: "number", required: true, promoted: true },
2095
- { name: "lockedBy", type: "text" },
2096
- { name: "lastScheduledFor", type: "date" },
2097
- { name: "lastStartedAt", type: "date" },
2098
- { name: "lastFinishedAt", type: "date" },
2099
- { name: "lastStatus", type: "text" },
2100
- { name: "lastError", type: "textarea" }
2101
- ],
2102
- access: {
2103
- read: ({ user }) => !!user?.roles?.includes("admin"),
2104
- create: () => false,
2105
- update: () => false,
2106
- delete: () => false
2107
- },
2108
- admin: { hidden: true }
2109
- };
2110
- var DEFAULT_LOCK_TIMEOUT_SECONDS = 300;
2111
- var DEFAULT_TICK_MS = 3e4;
2112
- var MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
2113
- var DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
2114
- function parseCronField(raw, min2, max2, names, nameOffset, label) {
2115
- const values = /* @__PURE__ */ new Set();
2116
- const toNumber = (token) => {
2117
- const named = names?.indexOf(token.toLowerCase());
2118
- if (named !== void 0 && named >= 0) return named + nameOffset;
2119
- if (!/^\d+$/.test(token)) throw new Error(`Invalid cron ${label} "${token}".`);
2120
- return Number(token);
2121
- };
2122
- for (const part of raw.split(",")) {
2123
- const [rangePart, stepPart] = part.split("/");
2124
- const step = stepPart === void 0 ? 1 : Number(stepPart);
2125
- if (!Number.isInteger(step) || step < 1) throw new Error(`Invalid cron ${label} step "${part}".`);
2126
- let start;
2127
- let end;
2128
- if (rangePart === "*") {
2129
- start = min2;
2130
- end = max2;
2131
- } else if (rangePart.includes("-")) {
2132
- const [a, b] = rangePart.split("-");
2133
- start = toNumber(a);
2134
- end = toNumber(b);
2135
- } else {
2136
- start = toNumber(rangePart);
2137
- end = stepPart === void 0 ? start : max2;
2138
- }
2139
- if (start < min2 || end > max2 || start > end) {
2140
- throw new Error(`Cron ${label} "${part}" is out of range (${min2}-${max2}).`);
2141
- }
2142
- for (let v = start; v <= end; v += step) values.add(v);
2143
- }
2144
- return values;
2101
+ // src/utils/hooks.ts
2102
+ var import_jexl3 = __toESM(require_Jexl(), 1);
2103
+
2104
+ // src/utils/declarative-hooks.ts
2105
+ var import_jexl2 = __toESM(require_Jexl(), 1);
2106
+
2107
+ // src/utils/jexl-helpers.ts
2108
+ var import_jexl = __toESM(require_Jexl(), 1);
2109
+ function slugify(val) {
2110
+ if (val === void 0 || val === null) return "";
2111
+ return String(val).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
2145
2112
  }
2146
- function parseCron(expression) {
2147
- const fields = expression.trim().split(/\s+/);
2148
- if (fields.length !== 5) {
2149
- throw new Error(`Cron expression "${expression}" must have 5 fields (minute hour day-of-month month day-of-week).`);
2150
- }
2151
- const [min2, hour, dom, mon, dow] = fields;
2152
- const daysOfWeek = parseCronField(dow, 0, 7, DAY_NAMES, 0, "day-of-week");
2153
- if (daysOfWeek.has(7)) daysOfWeek.add(0);
2154
- daysOfWeek.delete(7);
2155
- return {
2156
- minutes: parseCronField(min2, 0, 59, void 0, 0, "minute"),
2157
- hours: parseCronField(hour, 0, 23, void 0, 0, "hour"),
2158
- daysOfMonth: parseCronField(dom, 1, 31, void 0, 0, "day-of-month"),
2159
- months: parseCronField(mon, 1, 12, MONTH_NAMES, 1, "month"),
2160
- daysOfWeek,
2161
- dayFieldsBothRestricted: dom !== "*" && dow !== "*"
2162
- };
2113
+ function lower(val) {
2114
+ if (val === void 0 || val === null) return "";
2115
+ return String(val).toLowerCase();
2163
2116
  }
2164
- function dayMatches(schedule, date) {
2165
- const domOk = schedule.daysOfMonth.has(date.getUTCDate());
2166
- const dowOk = schedule.daysOfWeek.has(date.getUTCDay());
2167
- return schedule.dayFieldsBothRestricted ? domOk || dowOk : domOk && dowOk;
2117
+ function upper(val) {
2118
+ if (val === void 0 || val === null) return "";
2119
+ return String(val).toUpperCase();
2168
2120
  }
2169
- function nextCronRun(expression, after) {
2170
- const schedule = typeof expression === "string" ? parseCron(expression) : expression;
2171
- const cursor = new Date(after.getTime());
2172
- cursor.setUTCSeconds(0, 0);
2173
- cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
2174
- const limit = after.getTime() + 5 * 366 * 24 * 60 * 60 * 1e3;
2175
- while (cursor.getTime() <= limit) {
2176
- if (!schedule.months.has(cursor.getUTCMonth() + 1)) {
2177
- cursor.setUTCMonth(cursor.getUTCMonth() + 1, 1);
2178
- cursor.setUTCHours(0, 0, 0, 0);
2179
- continue;
2180
- }
2181
- if (!dayMatches(schedule, cursor)) {
2182
- cursor.setUTCDate(cursor.getUTCDate() + 1);
2183
- cursor.setUTCHours(0, 0, 0, 0);
2184
- continue;
2185
- }
2186
- if (!schedule.hours.has(cursor.getUTCHours())) {
2187
- cursor.setUTCHours(cursor.getUTCHours() + 1, 0, 0, 0);
2188
- continue;
2189
- }
2190
- if (!schedule.minutes.has(cursor.getUTCMinutes())) {
2191
- cursor.setUTCMinutes(cursor.getUTCMinutes() + 1, 0, 0);
2192
- continue;
2193
- }
2194
- return cursor;
2195
- }
2196
- throw new Error(`Cron expression "${typeof expression === "string" ? expression : "schedule"}" never matches.`);
2121
+ function trim(val) {
2122
+ if (val === void 0 || val === null) return "";
2123
+ return String(val).trim();
2197
2124
  }
2198
- function defineTask(task) {
2199
- if (!task.name || !task.name.trim()) throw new Error("A task needs a non-empty name.");
2200
- if (task.cron !== void 0) parseCron(task.cron);
2201
- if (task.lockTimeout !== void 0 && !(task.lockTimeout > 0)) {
2202
- throw new Error(`Task "${task.name}" lockTimeout must be a positive number of seconds.`);
2203
- }
2204
- return task;
2125
+ function capitalize(val) {
2126
+ if (val === void 0 || val === null) return "";
2127
+ const str = String(val).trim();
2128
+ if (!str) return "";
2129
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
2205
2130
  }
2206
- function resolveLogger(config, taskName) {
2207
- const base = config.logger;
2208
- if (base && typeof base.child === "function") return base.child({ component: "tasks", task: taskName });
2209
- const prefix = `[dyrected/tasks] ${taskName}`;
2210
- return {
2211
- info: (obj, msg) => console.info(prefix, msg ?? "", obj),
2212
- warn: (obj, msg) => console.warn(prefix, msg ?? "", obj),
2213
- error: (obj, msg) => console.error(prefix, msg ?? "", obj)
2214
- };
2131
+ function truncate(val, length2, ellipsis = "...") {
2132
+ if (val === void 0 || val === null) return "";
2133
+ const str = String(val);
2134
+ const maxLen = typeof length2 === "number" && length2 > 0 ? length2 : 100;
2135
+ if (str.length <= maxLen) return str;
2136
+ return str.slice(0, maxLen).trim() + String(ellipsis ?? "...");
2215
2137
  }
2216
- function floorToMinute(date) {
2217
- const d = new Date(date.getTime());
2218
- d.setUTCSeconds(0, 0);
2219
- return d;
2138
+ function readingTime(val, wpm = 200) {
2139
+ if (val === void 0 || val === null) return 1;
2140
+ const str = String(val).trim();
2141
+ if (!str) return 1;
2142
+ const words = str.split(/\s+/).filter(Boolean).length;
2143
+ return Math.max(1, Math.ceil(words / (typeof wpm === "number" && wpm > 0 ? wpm : 200)));
2220
2144
  }
2221
- function createTaskRunner(config) {
2222
- const db = config.db;
2223
- if (!db) throw new Error("createTaskRunner requires config.db.");
2224
- const tasks = /* @__PURE__ */ new Map();
2225
- for (const task of config.tasks ?? []) {
2226
- if (tasks.has(task.name)) throw new Error(`Duplicate task name "${task.name}".`);
2227
- tasks.set(task.name, { ...task, schedule: task.cron ? parseCron(task.cron) : void 0 });
2145
+ function wordCount(val) {
2146
+ if (val === void 0 || val === null) return 0;
2147
+ const str = String(val).trim();
2148
+ if (!str) return 0;
2149
+ return str.split(/\s+/).filter(Boolean).length;
2150
+ }
2151
+ function replace(val, search, replacement) {
2152
+ if (val === void 0 || val === null) return "";
2153
+ return String(val).replace(new RegExp(String(search || ""), "g"), String(replacement ?? ""));
2154
+ }
2155
+ function startsWith(str, prefix) {
2156
+ if (str === void 0 || str === null) return false;
2157
+ return String(str).startsWith(String(prefix ?? ""));
2158
+ }
2159
+ function endsWith(str, suffix) {
2160
+ if (str === void 0 || str === null) return false;
2161
+ return String(str).endsWith(String(suffix ?? ""));
2162
+ }
2163
+ function now() {
2164
+ return (/* @__PURE__ */ new Date()).toISOString();
2165
+ }
2166
+ function today() {
2167
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2168
+ }
2169
+ function formatDate(val, style = "short") {
2170
+ if (!val) return "";
2171
+ const d = new Date(val);
2172
+ if (Number.isNaN(d.getTime())) return "";
2173
+ const styleStr = String(style).toLowerCase();
2174
+ if (styleStr === "iso" || styleStr === "date") return d.toISOString().slice(0, 10);
2175
+ if (styleStr === "datetime" || styleStr === "full") return d.toISOString();
2176
+ return d.toLocaleDateString("en-US", {
2177
+ year: "numeric",
2178
+ month: "short",
2179
+ day: "numeric"
2180
+ });
2181
+ }
2182
+ function addDays(val, days) {
2183
+ const d = val ? new Date(val) : /* @__PURE__ */ new Date();
2184
+ if (Number.isNaN(d.getTime())) return "";
2185
+ const daysNum = typeof days === "number" ? days : Number(days) || 0;
2186
+ d.setDate(d.getDate() + daysNum);
2187
+ return d.toISOString();
2188
+ }
2189
+ function diffDays(dateA, dateB) {
2190
+ const dA = dateA ? new Date(dateA) : /* @__PURE__ */ new Date();
2191
+ const dB = dateB ? new Date(dateB) : /* @__PURE__ */ new Date();
2192
+ if (Number.isNaN(dA.getTime()) || Number.isNaN(dB.getTime())) return 0;
2193
+ const diffMs = Math.abs(dA.getTime() - dB.getTime());
2194
+ return Math.floor(diffMs / (1e3 * 60 * 60 * 24));
2195
+ }
2196
+ function isPast(val) {
2197
+ if (!val) return false;
2198
+ const d = new Date(val);
2199
+ if (Number.isNaN(d.getTime())) return false;
2200
+ return d.getTime() < Date.now();
2201
+ }
2202
+ function isFuture(val) {
2203
+ if (!val) return false;
2204
+ const d = new Date(val);
2205
+ if (Number.isNaN(d.getTime())) return false;
2206
+ return d.getTime() > Date.now();
2207
+ }
2208
+ function includes(arrOrStr, item) {
2209
+ if (Array.isArray(arrOrStr)) {
2210
+ return arrOrStr.includes(item);
2228
2211
  }
2229
- const instanceId = globalThis.crypto?.randomUUID?.() ?? `inst_${Date.now()}_${Math.random().toString(36).slice(2)}`;
2230
- let timer;
2231
- let inFlight = Promise.resolve();
2232
- let ticking = false;
2233
- let ready;
2234
- function ensureReady() {
2235
- ready ??= Promise.resolve(db.sync?.([TASK_LOCKS_COLLECTION_CONFIG], [])).then(() => void 0);
2236
- ready.catch(() => {
2237
- ready = void 0;
2238
- });
2239
- return ready;
2212
+ if (typeof arrOrStr === "string") {
2213
+ return arrOrStr.includes(String(item ?? ""));
2240
2214
  }
2241
- async function ensureLockRow(name, now2) {
2242
- await ensureReady();
2243
- const existing = await db.findOne({ collection: TASK_LOCKS_COLLECTION, id: name });
2244
- if (existing) return existing;
2245
- try {
2246
- return await db.create({
2247
- collection: TASK_LOCKS_COLLECTION,
2248
- data: {
2249
- id: name,
2250
- name,
2251
- lockedUntil: 0,
2252
- lockedBy: null,
2253
- lastScheduledFor: floorToMinute(now2).toISOString(),
2254
- lastStartedAt: null,
2255
- lastFinishedAt: null,
2256
- lastStatus: null,
2257
- lastError: null
2258
- }
2259
- });
2260
- } catch (error) {
2261
- const created = await db.findOne({ collection: TASK_LOCKS_COLLECTION, id: name });
2262
- if (created) return created;
2263
- throw error;
2264
- }
2265
- }
2266
- async function execute(task, row, scheduledFor) {
2267
- const started = Date.now();
2268
- const lockMs = (task.lockTimeout ?? DEFAULT_LOCK_TIMEOUT_SECONDS) * 1e3;
2269
- const token = `${instanceId}:${started}`;
2270
- const where = {
2271
- id: { equals: task.name },
2272
- lockedUntil: { less_than: started }
2273
- };
2274
- if (scheduledFor) where.lastScheduledFor = { equals: row.lastScheduledFor };
2275
- const claimed = await db.update({
2276
- collection: TASK_LOCKS_COLLECTION,
2277
- where,
2278
- data: {
2279
- lockedUntil: started + lockMs,
2280
- lockedBy: token,
2281
- lastStartedAt: new Date(started).toISOString(),
2282
- ...scheduledFor ? { lastScheduledFor: scheduledFor.toISOString() } : {}
2283
- }
2284
- });
2285
- if (claimed.affectedRows !== 1) {
2286
- return { name: task.name, status: "skipped", reason: "locked", durationMs: Date.now() - started };
2287
- }
2288
- const logger = resolveLogger(config, task.name);
2289
- const controller = new AbortController();
2290
- let timeout;
2291
- let status = "completed";
2292
- let errorMessage;
2293
- try {
2294
- await Promise.race([
2295
- Promise.resolve(task.run({ db, logger, signal: controller.signal, scheduledFor })),
2296
- new Promise((_, reject) => {
2297
- timeout = setTimeout(() => {
2298
- reject(new Error(`Task exceeded its lockTimeout of ${lockMs / 1e3}s.`));
2299
- controller.abort();
2300
- }, lockMs);
2301
- })
2302
- ]);
2303
- } catch (error) {
2304
- status = "failed";
2305
- errorMessage = error instanceof Error ? error.message : String(error);
2306
- logger.error({ err: error }, "Task failed");
2307
- } finally {
2308
- if (timeout) clearTimeout(timeout);
2309
- }
2310
- try {
2311
- await db.update({
2312
- collection: TASK_LOCKS_COLLECTION,
2313
- where: { id: { equals: task.name }, lockedBy: { equals: token } },
2314
- data: {
2315
- lockedUntil: 0,
2316
- lockedBy: null,
2317
- lastFinishedAt: (/* @__PURE__ */ new Date()).toISOString(),
2318
- lastStatus: status,
2319
- lastError: errorMessage ?? null
2320
- }
2321
- });
2322
- } catch (error) {
2323
- logger.warn({ err: error }, "Could not release task lock; it will expire on its own");
2324
- }
2325
- return { name: task.name, status, error: errorMessage, durationMs: Date.now() - started };
2326
- }
2327
- function track(work) {
2328
- inFlight = inFlight.then(() => work).catch(() => void 0);
2329
- return work;
2330
- }
2331
- const runner = {
2332
- async runTask(name) {
2333
- const task = tasks.get(name);
2334
- if (!task) throw new Error(`Unknown task "${name}".`);
2335
- const row = await ensureLockRow(name, /* @__PURE__ */ new Date());
2336
- return track(execute(task, row, void 0));
2337
- },
2338
- async runDue(now2 = /* @__PURE__ */ new Date()) {
2339
- const results = [];
2340
- for (const task of tasks.values()) {
2341
- if (!task.schedule) continue;
2342
- const row = await ensureLockRow(task.name, now2);
2343
- const reference = new Date(String(row.lastScheduledFor));
2344
- const due = Number.isNaN(reference.getTime()) ? true : nextCronRun(task.schedule, reference).getTime() <= now2.getTime();
2345
- if (!due) {
2346
- results.push({ name: task.name, status: "skipped", reason: "not-due", durationMs: 0 });
2347
- continue;
2348
- }
2349
- results.push(await track(execute(task, row, floorToMinute(now2))));
2350
- }
2351
- return results;
2352
- },
2353
- start(options) {
2354
- if (timer) return;
2355
- const intervalMs = options?.intervalMs ?? DEFAULT_TICK_MS;
2356
- const tick = () => {
2357
- if (ticking) return;
2358
- ticking = true;
2359
- runner.runDue().catch((error) => resolveLogger(config, "runner").error({ err: error }, "Task tick failed")).finally(() => {
2360
- ticking = false;
2361
- });
2362
- };
2363
- timer = setInterval(tick, intervalMs);
2364
- timer.unref?.();
2365
- setTimeout(tick, 0);
2366
- },
2367
- async stop() {
2368
- if (timer) clearInterval(timer);
2369
- timer = void 0;
2370
- await inFlight;
2371
- }
2372
- };
2373
- return runner;
2215
+ return false;
2374
2216
  }
2375
-
2376
- // src/workflows.ts
2377
- var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
2378
- var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
2379
- var EDITOR_CAPABILITIES = ["entry.edit", "entry.submit"];
2380
- var PUBLISHER_CAPABILITIES = ["entry.edit", "entry.submit", "entry.publish", "entry.unpublish"];
2381
- function definePublishingWorkflow(options = {}) {
2382
- const editors = options.editors ?? ["editor"];
2383
- const publishers = options.publishers ?? ["publisher", "admin"];
2384
- return {
2385
- initialState: "draft",
2386
- draftState: "draft",
2387
- states: [
2388
- { name: "draft", label: "Draft", color: "neutral" },
2389
- { name: "in_review", label: "In review", color: "warning" },
2390
- { name: "published", label: "Published", color: "success", published: true }
2391
- ],
2392
- transitions: [
2393
- {
2394
- name: "submit",
2395
- label: "Submit for review",
2396
- from: "draft",
2397
- to: "in_review",
2398
- requiredCapabilities: ["entry.submit"]
2399
- },
2400
- {
2401
- name: "publish",
2402
- label: "Publish",
2403
- from: "in_review",
2404
- to: "published",
2405
- requiredCapabilities: ["entry.publish"]
2406
- },
2407
- {
2408
- name: "reject",
2409
- label: "Request changes",
2410
- from: "in_review",
2411
- to: "draft",
2412
- requiredCapabilities: ["entry.publish"],
2413
- requireComment: true
2414
- },
2415
- {
2416
- name: "unpublish",
2417
- label: "Unpublish",
2418
- from: "published",
2419
- to: "draft",
2420
- requiredCapabilities: ["entry.unpublish"],
2421
- unpublish: true
2422
- }
2423
- ],
2424
- roles: [
2425
- ...editors.map((role) => ({ role, capabilities: [...EDITOR_CAPABILITIES] })),
2426
- ...publishers.map((role) => ({ role, capabilities: [...PUBLISHER_CAPABILITIES] }))
2427
- ]
2428
- };
2217
+ function join(arr, separator = ", ") {
2218
+ if (!Array.isArray(arr)) return "";
2219
+ return arr.filter((item) => item !== void 0 && item !== null).join(String(separator));
2429
2220
  }
2430
- function publishingWorkflow() {
2431
- return definePublishingWorkflow();
2221
+ function first(arr) {
2222
+ if (Array.isArray(arr)) return arr[0] ?? null;
2223
+ return null;
2432
2224
  }
2433
- function simplePublishingWorkflow() {
2434
- return {
2435
- initialState: "draft",
2436
- draftState: "draft",
2437
- states: [
2438
- { name: "draft", label: "Draft", color: "neutral" },
2439
- { name: "published", label: "Published", color: "success", published: true }
2440
- ],
2441
- transitions: [
2442
- { name: "publish", label: "Publish", from: ["draft", "published"], to: "published" },
2443
- { name: "unpublish", label: "Unpublish", from: "published", to: "draft", unpublish: true }
2444
- ]
2445
- // Intentionally no `roles`: this lightweight workflow (synthesized from
2446
- // `drafts: true`) does no capability-based gating. Draft visibility for a
2447
- // no-roles workflow is handled in `canViewWorkflowDraft`.
2448
- };
2225
+ function last(arr) {
2226
+ if (Array.isArray(arr)) return arr[arr.length - 1] ?? null;
2227
+ return null;
2449
2228
  }
2450
- function publicMetadata(meta) {
2451
- const { availableTransitions: _availableTransitions, ...safe } = meta;
2452
- return safe;
2229
+ function compact(arr) {
2230
+ if (!Array.isArray(arr)) return [];
2231
+ return arr.filter((item) => item !== void 0 && item !== null && item !== "");
2453
2232
  }
2454
- function workflowCapabilities(workflow, user) {
2455
- const capabilities = new Set(Array.isArray(user?.capabilities) ? user.capabilities : []);
2456
- const roles = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
2457
- for (const mapping of workflow.roles ?? []) {
2458
- if (roles.includes(mapping.role)) mapping.capabilities.forEach((capability) => capabilities.add(capability));
2459
- }
2460
- return capabilities;
2233
+ function unique(arr) {
2234
+ if (!Array.isArray(arr)) return [];
2235
+ return Array.from(new Set(arr));
2461
2236
  }
2462
- function canViewWorkflowDraft(workflow, user) {
2463
- if (!user) return false;
2464
- const capabilities = workflowCapabilities(workflow, user);
2465
- if (capabilities.has("entry.edit")) return true;
2466
- if (workflow.transitions.some(
2467
- (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
2468
- )) {
2469
- return true;
2470
- }
2471
- if (!workflow.roles || workflow.roles.length === 0) return true;
2472
- return false;
2237
+ function length(val) {
2238
+ if (Array.isArray(val) || typeof val === "string") return val.length;
2239
+ if (val && typeof val === "object") return Object.keys(val).length;
2240
+ return 0;
2473
2241
  }
2474
- function availableWorkflowTransitions(workflow, state, user) {
2475
- const capabilities = workflowCapabilities(workflow, user);
2476
- return workflow.transitions.filter((transition) => {
2477
- const from = Array.isArray(transition.from) ? transition.from : [transition.from];
2478
- return from.includes(state) && (transition.requiredCapabilities ?? []).every((item) => capabilities.has(item));
2479
- });
2242
+ function round(val, decimals = 0) {
2243
+ const num = Number(val);
2244
+ if (Number.isNaN(num)) return 0;
2245
+ const dec = typeof decimals === "number" ? decimals : Number(decimals) || 0;
2246
+ const factor = Math.pow(10, dec);
2247
+ return Math.round(num * factor) / factor;
2480
2248
  }
2481
- function initializeWorkflowDocument(data, workflow) {
2482
- return {
2483
- ...data,
2484
- __workflow: { state: workflow.initialState, revision: 1 }
2485
- };
2249
+ function clamp(val, minVal, maxVal) {
2250
+ const num = Number(val) || 0;
2251
+ const min2 = Number(minVal) || 0;
2252
+ const max2 = Number(maxVal) || 0;
2253
+ return Math.min(Math.max(num, min2), max2);
2486
2254
  }
2487
- function publishedStateName(workflow) {
2488
- return workflow.states.find((state) => state.published)?.name ?? workflow.states[workflow.states.length - 1]?.name ?? workflow.initialState;
2255
+ function ceil(val) {
2256
+ return Math.ceil(Number(val) || 0);
2489
2257
  }
2490
- function materializeWorkflowDocument(doc, workflow, user) {
2491
- const meta = doc.__workflow;
2492
- const publishedState = publishedStateName(workflow);
2493
- if (!meta) {
2494
- const { __published: _legacyPublished, __workflow: _legacyWorkflow, ...working2 } = doc;
2495
- const state = publishedStateName(workflow);
2496
- return {
2497
- ...working2,
2498
- _workflow: {
2499
- state,
2500
- revision: 1,
2501
- availableTransitions: availableWorkflowTransitions(workflow, state, user).map((item) => item.name)
2502
- }
2503
- };
2504
- }
2505
- const { __published, __workflow, ...working } = doc;
2506
- if (!canViewWorkflowDraft(workflow, user)) {
2507
- if (!__published || typeof __published !== "object") {
2508
- if (meta.state === publishedState) {
2509
- return { ...working, _workflow: publicMetadata(meta) };
2510
- }
2511
- return null;
2512
- }
2513
- return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
2514
- }
2515
- return {
2516
- ...working,
2517
- _workflow: {
2518
- ...meta,
2519
- availableTransitions: availableWorkflowTransitions(workflow, meta.state, user).map((item) => item.name)
2520
- }
2521
- };
2258
+ function floor(val) {
2259
+ return Math.floor(Number(val) || 0);
2522
2260
  }
2523
- function eventId() {
2524
- return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
2261
+ function abs(val) {
2262
+ return Math.abs(Number(val) || 0);
2525
2263
  }
2526
- function createLifecycleEvent(args) {
2527
- return {
2528
- id: eventId(),
2529
- name: args.name,
2530
- collection: args.collection,
2531
- documentId: args.documentId,
2532
- occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
2533
- actorId: args.actorId,
2534
- payload: args.payload,
2535
- attempts: 0,
2536
- status: "pending"
2537
- };
2264
+ function min(...args) {
2265
+ const nums = args.flat().map((n) => Number(n) || 0);
2266
+ return nums.length ? Math.min(...nums) : 0;
2538
2267
  }
2539
- async function persistEvent(db, event) {
2540
- await db.create({ collection: LIFECYCLE_EVENTS_COLLECTION, data: event });
2268
+ function max(...args) {
2269
+ const nums = args.flat().map((n) => Number(n) || 0);
2270
+ return nums.length ? Math.max(...nums) : 0;
2541
2271
  }
2542
- async function dispatchLifecycleEvent(config, event) {
2543
- const db = config.db;
2544
- if (!db || !config.events?.handlers.length) return;
2545
- const maxAttempts = config.events.maxAttempts ?? 8;
2546
- const retryDelayMs = config.events.retryDelayMs ?? 1e3;
2547
- const attempts = event.attempts + 1;
2548
- try {
2549
- await db.update({
2550
- collection: LIFECYCLE_EVENTS_COLLECTION,
2551
- id: event.id,
2552
- data: { status: "processing", attempts }
2553
- });
2554
- for (const handler of config.events.handlers) await handler({ ...event, status: "processing", attempts });
2555
- await db.update({
2556
- collection: LIFECYCLE_EVENTS_COLLECTION,
2557
- id: event.id,
2558
- data: { status: "delivered", attempts, deliveredAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null }
2559
- });
2560
- } catch (error) {
2561
- const exhausted = attempts >= maxAttempts;
2562
- await db.update({
2563
- collection: LIFECYCLE_EVENTS_COLLECTION,
2564
- id: event.id,
2565
- data: {
2566
- status: "failed",
2567
- attempts,
2568
- lastError: error instanceof Error ? error.message : String(error),
2569
- nextAttemptAt: exhausted ? null : new Date(Date.now() + retryDelayMs * 2 ** (attempts - 1)).toISOString()
2570
- }
2571
- });
2572
- }
2272
+ function pow(base, exp) {
2273
+ return Math.pow(Number(base) || 0, Number(exp) || 0);
2573
2274
  }
2574
- async function dispatchPendingLifecycleEvents(config, limit = 50) {
2575
- if (!config.db || !config.events?.handlers.length) return 0;
2576
- const maxAttempts = config.events.maxAttempts ?? 8;
2577
- const result = await config.db.find({
2578
- collection: LIFECYCLE_EVENTS_COLLECTION,
2579
- where: { status: { in: ["pending", "failed"] } },
2580
- sort: "occurredAt",
2581
- limit
2582
- });
2583
- const now2 = Date.now();
2584
- const due = result.docs.filter(
2585
- (doc) => Number(doc.attempts ?? 0) < maxAttempts && (!doc.nextAttemptAt || new Date(doc.nextAttemptAt).getTime() <= now2)
2586
- );
2587
- for (const event of due) await dispatchLifecycleEvent(config, event);
2588
- return due.length;
2275
+ function sqrt(val) {
2276
+ return Math.sqrt(Number(val) || 0);
2589
2277
  }
2590
- async function saveWorkflowDraft(args) {
2591
- const { config, collection, id, originalDoc, data, user, beforeCommit } = args;
2592
- const db = config.db;
2593
- const workflow = collection.workflow;
2594
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
2595
- const previous = originalDoc.__workflow;
2596
- const isLegacyPublishedDoc = !previous;
2597
- const baselineRevision = previous?.revision ?? 1;
2598
- const event = createLifecycleEvent({
2599
- name: "revision.created",
2600
- collection: collection.slug,
2601
- documentId: id,
2602
- actorId: user?.sub,
2603
- payload: {
2604
- revision: baselineRevision + 1,
2605
- previousRevision: isLegacyPublishedDoc ? baselineRevision : previous?.revision ?? null
2606
- }
2607
- });
2608
- const doc = await db.transaction(async (tx) => {
2609
- const nextMeta = {
2610
- ...previous ?? {},
2611
- state: isLegacyPublishedDoc ? workflow.draftState ?? workflow.initialState : originalDoc.__published ? workflow.draftState ?? workflow.initialState : previous.state,
2612
- revision: baselineRevision + 1,
2613
- ...isLegacyPublishedDoc ? { publishedRevision: baselineRevision } : {}
2614
- };
2615
- const updateData = {
2616
- ...data,
2617
- __workflow: nextMeta
2618
- };
2619
- if (isLegacyPublishedDoc) {
2620
- const { __workflow: _legacyWorkflow, __published: _legacyPublished, ...publishedSnapshot } = originalDoc;
2621
- updateData.__published = publishedSnapshot;
2622
- }
2623
- const updated = await tx.update({ collection: collection.slug, id, data: updateData });
2624
- await persistEvent(tx, event);
2625
- await beforeCommit?.(tx, updated);
2626
- return updated;
2627
- });
2628
- void dispatchLifecycleEvent(config, event);
2629
- return { doc, event };
2278
+ function sum(arr) {
2279
+ if (!Array.isArray(arr)) return Number(arr) || 0;
2280
+ return arr.reduce((acc, val) => acc + (Number(val) || 0), 0);
2630
2281
  }
2631
- async function createWorkflowDocument(args) {
2632
- const { config, collection, data, user, beforeCommit } = args;
2633
- const db = config.db;
2634
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
2635
- let event;
2636
- const doc = await db.transaction(async (tx) => {
2637
- const created = await tx.create({ collection: collection.slug, data });
2638
- event = createLifecycleEvent({
2639
- name: "revision.created",
2640
- collection: collection.slug,
2641
- documentId: created.id,
2642
- actorId: user?.sub,
2643
- payload: { revision: 1, previousRevision: null }
2644
- });
2645
- await persistEvent(tx, event);
2646
- await beforeCommit?.(tx, created);
2647
- return created;
2648
- });
2649
- void dispatchLifecycleEvent(config, event);
2650
- return { doc, event };
2282
+ function count(val) {
2283
+ if (Array.isArray(val)) return val.length;
2284
+ if (typeof val === "string") return val.length;
2285
+ if (val !== void 0 && val !== null && val !== false) return 1;
2286
+ return 0;
2651
2287
  }
2652
- async function transitionWorkflow(args) {
2653
- const { config, collection, id, transitionName, expectedRevision, comment, input, user, req } = args;
2654
- const db = config.db;
2655
- const workflow = collection.workflow;
2656
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
2657
- const original = await db.findOne({ collection: collection.slug, id });
2658
- if (!original) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
2659
- const meta = original.__workflow;
2660
- if (!meta) throw Object.assign(new Error("Entry has no workflow metadata"), { statusCode: 409 });
2661
- if (expectedRevision !== void 0 && expectedRevision !== meta.revision) {
2662
- throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
2663
- statusCode: 409
2664
- });
2665
- }
2666
- const transition = workflow.transitions.find((item) => item.name === transitionName);
2667
- const fromStates = transition ? Array.isArray(transition.from) ? transition.from : [transition.from] : [];
2668
- if (!transition || !fromStates.includes(meta.state)) {
2669
- throw Object.assign(new Error(`Transition "${transitionName}" is not valid from "${meta.state}".`), {
2670
- statusCode: 409
2671
- });
2672
- }
2673
- if (!availableWorkflowTransitions(workflow, meta.state, user).some((item) => item.name === transition.name)) {
2674
- throw Object.assign(new Error(`You do not have permission to perform "${transition.label}".`), { statusCode: 403 });
2288
+ function defaultValue(val, fallback) {
2289
+ if (val === void 0 || val === null || val === "") return fallback;
2290
+ return val;
2291
+ }
2292
+ function coalesce(...args) {
2293
+ for (const arg of args) {
2294
+ if (arg !== void 0 && arg !== null && arg !== "") return arg;
2675
2295
  }
2676
- if (transition.requireComment && !comment?.trim()) {
2677
- throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
2296
+ return null;
2297
+ }
2298
+ function isEmpty(val) {
2299
+ if (val === void 0 || val === null || val === "") return true;
2300
+ if (Array.isArray(val)) return val.length === 0;
2301
+ if (typeof val === "object") return Object.keys(val).length === 0;
2302
+ return false;
2303
+ }
2304
+ function get(obj, path, fallback = null) {
2305
+ if (obj === void 0 || obj === null || typeof obj !== "object") return fallback;
2306
+ if (typeof path !== "string") return fallback;
2307
+ const parts = path.split(".").filter(Boolean);
2308
+ let curr = obj;
2309
+ for (const part of parts) {
2310
+ if (curr === void 0 || curr === null || typeof curr !== "object") return fallback;
2311
+ curr = curr[part];
2678
2312
  }
2679
- const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, input, req, db };
2680
- for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
2681
- const events = [
2682
- createLifecycleEvent({
2683
- name: "workflow.transitioned",
2684
- collection: collection.slug,
2685
- documentId: id,
2686
- actorId: user?.sub,
2687
- payload: { transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment }
2688
- })
2689
- ];
2690
- const targetState = workflow.states.find((state) => state.name === transition.to);
2691
- if (targetState.published) {
2692
- events.push(
2693
- createLifecycleEvent({
2694
- name: "entry.published",
2695
- collection: collection.slug,
2696
- documentId: id,
2697
- actorId: user?.sub,
2698
- payload: { revision: meta.revision }
2699
- })
2700
- );
2701
- } else if (transition.unpublish) {
2702
- events.push(
2703
- createLifecycleEvent({
2704
- name: "entry.unpublished",
2705
- collection: collection.slug,
2706
- documentId: id,
2707
- actorId: user?.sub,
2708
- payload: { revision: meta.publishedRevision }
2709
- })
2710
- );
2711
- }
2712
- const updated = await db.transaction(async (tx) => {
2713
- const locked = await tx.findOne({ collection: collection.slug, id });
2714
- if (!locked) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
2715
- const lockedMeta = locked.__workflow;
2716
- if (lockedMeta.revision !== meta.revision || lockedMeta.state !== meta.state || expectedRevision !== void 0 && lockedMeta.revision !== expectedRevision) {
2717
- throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
2718
- statusCode: 409
2719
- });
2720
- }
2721
- const now2 = (/* @__PURE__ */ new Date()).toISOString();
2722
- const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
2723
- const nextMeta = {
2724
- ...lockedMeta,
2725
- state: transition.to,
2726
- ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now2, publishedBy: user?.sub } : {},
2727
- ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
2728
- };
2729
- const data = { __workflow: nextMeta };
2730
- if (targetState.published) data.__published = working;
2731
- if (transition.unpublish) data.__published = null;
2732
- const next = await tx.update({ collection: collection.slug, id, data });
2733
- if (transition.onTransition) {
2734
- await transition.onTransition({
2735
- transition,
2736
- from: lockedMeta.state,
2737
- to: transition.to,
2738
- doc: locked,
2739
- input,
2740
- comment,
2741
- user,
2742
- req,
2743
- tx
2744
- });
2745
- }
2746
- await tx.create({
2747
- collection: WORKFLOW_HISTORY_COLLECTION,
2748
- data: {
2749
- collection: collection.slug,
2750
- documentId: id,
2751
- transition: transition.name,
2752
- from: lockedMeta.state,
2753
- to: transition.to,
2754
- revision: lockedMeta.revision,
2755
- comment: comment ?? null,
2756
- actorId: user?.sub ?? null,
2757
- createdAt: now2
2758
- }
2759
- });
2760
- for (const event of events) await persistEvent(tx, event);
2761
- if (collection.audit) {
2762
- await tx.create({
2763
- collection: "__audit",
2764
- data: {
2765
- collection: collection.slug,
2766
- documentId: id,
2767
- operation: "workflow.transition",
2768
- user: user?.sub ?? null,
2769
- timestamp: now2,
2770
- changes: JSON.stringify({
2771
- transition: transition.name,
2772
- from: lockedMeta.state,
2773
- to: transition.to,
2774
- revision: lockedMeta.revision
2775
- })
2776
- }
2777
- });
2313
+ return curr === void 0 || curr === null ? fallback : curr;
2314
+ }
2315
+ function registerJexlHelpers(jexlInstance = import_jexl.default) {
2316
+ const helpers = {
2317
+ // Strings
2318
+ slugify,
2319
+ lower,
2320
+ upper,
2321
+ trim,
2322
+ capitalize,
2323
+ truncate,
2324
+ readingTime,
2325
+ wordCount,
2326
+ replace,
2327
+ startsWith,
2328
+ endsWith,
2329
+ // Dates
2330
+ now,
2331
+ today,
2332
+ formatDate,
2333
+ addDays,
2334
+ diffDays,
2335
+ isPast,
2336
+ isFuture,
2337
+ // Arrays
2338
+ includes,
2339
+ join,
2340
+ first,
2341
+ last,
2342
+ compact,
2343
+ unique,
2344
+ length,
2345
+ // Math
2346
+ round,
2347
+ clamp,
2348
+ ceil,
2349
+ floor,
2350
+ abs,
2351
+ min,
2352
+ max,
2353
+ pow,
2354
+ sqrt,
2355
+ sum,
2356
+ count,
2357
+ // Objects & Logical
2358
+ default: defaultValue,
2359
+ coalesce,
2360
+ isEmpty,
2361
+ get
2362
+ };
2363
+ for (const [name, fn] of Object.entries(helpers)) {
2364
+ try {
2365
+ jexlInstance.addFunction(name, fn);
2366
+ } catch {
2778
2367
  }
2779
- return next;
2780
- });
2781
- for (const event of events) void dispatchLifecycleEvent(config, event);
2782
- for (const hook of workflow.hooks?.afterTransition ?? []) {
2783
2368
  try {
2784
- await hook({ ...hookContext, doc: updated, event: events[0] });
2785
- } catch (error) {
2786
- try {
2787
- const obs = config?.__observability;
2788
- obs?.recordWorkflowHookFailure?.({
2789
- collection: collection.slug,
2790
- transition: transition.name
2791
- });
2792
- const logger = config?.logger;
2793
- if (logger && typeof logger.child === "function") {
2794
- logger.child({ component: "workflow" }).error({
2795
- err: error,
2796
- msg: "afterTransition hook failed",
2797
- collection: collection.slug,
2798
- transition: transition.name,
2799
- documentId: id
2800
- });
2801
- } else if (logger && typeof logger.error === "function") {
2802
- logger.error({
2803
- err: error,
2804
- msg: "afterTransition hook failed",
2805
- collection: collection.slug,
2806
- transition: transition.name,
2807
- documentId: id
2808
- });
2809
- } else {
2810
- console.error("afterTransition hook failed:", error);
2811
- }
2812
- } catch {
2813
- console.error("afterTransition hook failed:", error);
2814
- }
2369
+ jexlInstance.addTransform(name, fn);
2370
+ } catch {
2815
2371
  }
2816
2372
  }
2817
- return updated;
2818
- }
2819
-
2820
- // src/utils/admin-auth.ts
2821
- function getAdminAuthCollection(config) {
2822
- const adminCollection = resolveAdminAuthCollection(config.collections, config.adminAuth?.collectionSlug);
2823
- return adminCollection ?? null;
2824
- }
2825
- function resolveAdminAuthCollection(collections, collectionSlug) {
2826
- const adminsCollection = collections.find((collection) => collection.slug === "__admins");
2827
- if (adminsCollection) return adminsCollection;
2828
- if (collectionSlug) {
2829
- const requestedCollection = collections.find((collection) => collection.slug === collectionSlug);
2830
- if (requestedCollection) return requestedCollection;
2373
+ try {
2374
+ jexlInstance.addBinaryOp("and", 20, (left, right) => Boolean(left && right));
2375
+ jexlInstance.addBinaryOp("or", 10, (left, right) => Boolean(left || right));
2376
+ jexlInstance.addBinaryOp("in", 30, (left, right) => {
2377
+ if (Array.isArray(right)) return right.includes(left);
2378
+ if (typeof right === "string") return right.includes(String(left ?? ""));
2379
+ return false;
2380
+ });
2381
+ } catch {
2831
2382
  }
2832
- return collections.find((collection) => collection.auth);
2833
- }
2834
- function getPublicAdminAuthConfig(adminAuth, collections) {
2835
- const providers = (adminAuth?.providers ?? []).map((provider) => ({
2836
- id: provider.id,
2837
- type: provider.type,
2838
- displayName: provider.displayName || humanizeProviderName(provider.id, provider.type),
2839
- autoRedirect: provider.autoRedirect
2840
- }));
2841
- const resolvedCollectionSlug = collections ? resolveAdminAuthCollection(collections, adminAuth?.collectionSlug)?.slug : adminAuth?.collectionSlug;
2842
- return {
2843
- mode: adminAuth?.mode ?? "local",
2844
- collectionSlug: resolvedCollectionSlug,
2845
- provisioningMode: adminAuth?.provisioningMode,
2846
- providers
2847
- };
2848
2383
  }
2849
- function humanizeProviderName(id, type) {
2850
- const cleaned = id.replace(/[-_]+/g, " ").trim();
2851
- if (!cleaned) return type.toUpperCase();
2852
- return cleaned.replace(/\b\w/g, (char) => char.toUpperCase());
2853
- }
2854
- function getAdminRoleForCollection(collection) {
2855
- if (!collection?.auth) return "admin";
2856
- if (typeof collection.auth === "object" && collection.auth.adminRole) {
2857
- return collection.auth.adminRole;
2384
+ registerJexlHelpers(import_jexl.default);
2385
+ async function evaluateJexl(expression, context = {}) {
2386
+ if (!expression || typeof expression !== "string") return void 0;
2387
+ try {
2388
+ const evalContext = {
2389
+ null: null,
2390
+ undefined: void 0,
2391
+ ...context,
2392
+ math: Math,
2393
+ Math
2394
+ };
2395
+ return await import_jexl.default.eval(expression, evalContext);
2396
+ } catch (_err) {
2397
+ return void 0;
2858
2398
  }
2859
- return "admin";
2860
2399
  }
2861
- function isUserAdmin(user, collection) {
2862
- if (!user) return false;
2863
- const roles = Array.isArray(user.roles) ? user.roles : typeof user.role === "string" ? [user.role] : [];
2864
- if (roles.length === 0) return false;
2865
- const configuredRole = getAdminRoleForCollection(collection);
2866
- if (roles.includes(configuredRole)) return true;
2867
- return roles.includes("admin") || roles.includes("super_admin");
2868
- }
2869
-
2870
- // src/utils/block-references.ts
2871
- function mergeUniqueBlocks(blocks) {
2872
- const seen = /* @__PURE__ */ new Map();
2873
- for (const block of blocks) {
2874
- const existing = seen.get(block.slug);
2875
- if (existing && existing !== block) {
2876
- throw new Error(
2877
- `Duplicate block slug "${block.slug}" found in the reusable block registry. Block slugs must be unique.`
2878
- );
2879
- }
2880
- if (!existing) seen.set(block.slug, block);
2881
- }
2882
- return Array.from(seen.values());
2883
- }
2884
- function buildRegistry(blocks) {
2885
- return new Map(blocks.map((block) => [block.slug, block]));
2886
- }
2887
- function resolveField(field, registry, blockCache) {
2888
- const next = { ...field };
2889
- if (next.fields) {
2890
- next.fields = next.fields.map(
2891
- (child) => resolveField(child, registry, blockCache)
2892
- );
2893
- }
2894
- if (next.type !== "blocks") return next;
2895
- const hasInlineBlocks = Array.isArray(next.blocks) && next.blocks.length > 0;
2896
- const hasReferences = Array.isArray(next.blockReferences) && next.blockReferences.length > 0;
2897
- if (hasInlineBlocks && hasReferences) {
2898
- throw new Error(
2899
- `Blocks field "${next.name ?? "(unnamed)"}" cannot define both "blocks" and "blockReferences". Use one or the other.`
2900
- );
2901
- }
2902
- if (hasReferences) {
2903
- next.blocks = next.blockReferences.map((slug) => {
2904
- const block = registry.get(slug);
2905
- if (!block) {
2906
- throw new Error(
2907
- `Unknown block reference "${slug}" on blocks field "${next.name ?? "(unnamed)"}". Add it to defineConfig({ blocks: [...] }).`
2908
- );
2909
- }
2910
- return resolveBlock(block, registry, blockCache);
2911
- });
2912
- return next;
2913
- }
2914
- if (hasInlineBlocks) {
2915
- next.blocks = next.blocks.map(
2916
- (block) => resolveBlock(block, registry, blockCache)
2917
- );
2918
- }
2919
- return next;
2920
- }
2921
- function resolveBlock(block, registry, blockCache) {
2922
- const cached = blockCache.get(block);
2923
- if (cached) return cached;
2924
- const resolved = {
2925
- ...block,
2926
- fields: []
2927
- };
2928
- blockCache.set(block, resolved);
2929
- resolved.fields = block.fields.map(
2930
- (field) => resolveField(field, registry, blockCache)
2931
- );
2932
- return resolved;
2933
- }
2934
- function resolveFields(fields, registry, blockCache) {
2935
- return fields.map((field) => resolveField(field, registry, blockCache));
2936
- }
2937
- function normalizeSchemaFragment(fragment) {
2938
- const reusableBlocks = mergeUniqueBlocks(fragment.blocks ?? []);
2939
- const registry = buildRegistry(reusableBlocks);
2940
- const blockCache = /* @__PURE__ */ new WeakMap();
2941
- const resolvedBlocks = reusableBlocks.map(
2942
- (block) => resolveBlock(block, registry, blockCache)
2943
- );
2944
- const resolvedCollections = (fragment.collections ?? []).map(
2945
- (collection) => ({
2946
- ...collection,
2947
- fields: resolveFields(collection.fields ?? [], registry, blockCache)
2948
- })
2949
- );
2950
- const resolvedGlobals = (fragment.globals ?? []).map((global) => ({
2951
- ...global,
2952
- fields: resolveFields(global.fields ?? [], registry, blockCache)
2953
- }));
2954
- return {
2955
- ...fragment,
2956
- ...fragment.blocks ? { blocks: resolvedBlocks } : {},
2957
- ...fragment.collections ? { collections: resolvedCollections } : {},
2958
- ...fragment.globals ? { globals: resolvedGlobals } : {}
2959
- };
2400
+ function evaluateJexlSync(expression, context = {}) {
2401
+ if (!expression || typeof expression !== "string") return void 0;
2402
+ try {
2403
+ const evalContext = {
2404
+ null: null,
2405
+ undefined: void 0,
2406
+ ...context,
2407
+ math: Math,
2408
+ Math
2409
+ };
2410
+ return import_jexl.default.evalSync(expression, evalContext);
2411
+ } catch (_err) {
2412
+ return void 0;
2413
+ }
2960
2414
  }
2415
+ var BUILTIN_JEXL_HELPERS = [
2416
+ // Strings
2417
+ "slugify",
2418
+ "lower",
2419
+ "upper",
2420
+ "trim",
2421
+ "capitalize",
2422
+ "truncate",
2423
+ "readingTime",
2424
+ "wordCount",
2425
+ "replace",
2426
+ "startsWith",
2427
+ "endsWith",
2428
+ // Dates
2429
+ "now",
2430
+ "today",
2431
+ "formatDate",
2432
+ "addDays",
2433
+ "diffDays",
2434
+ "isPast",
2435
+ "isFuture",
2436
+ // Arrays
2437
+ "includes",
2438
+ "join",
2439
+ "first",
2440
+ "last",
2441
+ "compact",
2442
+ "unique",
2443
+ "length",
2444
+ // Math
2445
+ "round",
2446
+ "clamp",
2447
+ // Objects & Logical
2448
+ "default",
2449
+ "coalesce",
2450
+ "isEmpty",
2451
+ "get"
2452
+ ];
2961
2453
 
2962
2454
  // src/utils/declarative-hooks.ts
2963
- var import_jexl2 = __toESM(require_Jexl(), 1);
2964
-
2965
- // src/utils/jexl-helpers.ts
2966
- var import_jexl = __toESM(require_Jexl(), 1);
2967
- function slugify(val) {
2968
- if (val === void 0 || val === null) return "";
2969
- return String(val).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
2970
- }
2971
- function lower(val) {
2972
- if (val === void 0 || val === null) return "";
2973
- return String(val).toLowerCase();
2974
- }
2975
- function upper(val) {
2976
- if (val === void 0 || val === null) return "";
2977
- return String(val).toUpperCase();
2978
- }
2979
- function trim(val) {
2980
- if (val === void 0 || val === null) return "";
2981
- return String(val).trim();
2455
+ registerJexlHelpers(import_jexl2.default);
2456
+ var SURFACE_ROOTS = {
2457
+ "collection.beforeRead": ["req", "user", "query", ...BUILTIN_JEXL_HELPERS],
2458
+ "collection.afterRead": ["req", "user", "doc", ...BUILTIN_JEXL_HELPERS],
2459
+ "collection.beforeChange": ["req", "user", "data", "doc", "operation", ...BUILTIN_JEXL_HELPERS],
2460
+ "global.beforeRead": ["req", "user", "query", ...BUILTIN_JEXL_HELPERS],
2461
+ "global.afterRead": ["req", "user", "doc", ...BUILTIN_JEXL_HELPERS],
2462
+ "global.beforeChange": ["req", "user", "data", "doc", "operation", ...BUILTIN_JEXL_HELPERS],
2463
+ "field.beforeChange": ["value", "data", "originalDoc", "user", ...BUILTIN_JEXL_HELPERS],
2464
+ "field.admin.onChange": ["value", "siblingData", "data", ...BUILTIN_JEXL_HELPERS]
2465
+ };
2466
+ var ACCESS_ROOTS = ["user", "req", "doc", "data", "id", "config", ...BUILTIN_JEXL_HELPERS];
2467
+ var ADMIN_CONDITION_RESERVED_ROOTS = ["data", "siblingData", "user", "id", ...BUILTIN_JEXL_HELPERS];
2468
+ var PREVIEW_URL_RESERVED_ROOTS = ["siteUrl", "id", ...BUILTIN_JEXL_HELPERS];
2469
+ var COMPILE_CACHE = /* @__PURE__ */ new Map();
2470
+ function compileExpression(expression) {
2471
+ const cached = COMPILE_CACHE.get(expression);
2472
+ if (cached) return cached;
2473
+ const compiled = import_jexl2.default.compile(expression);
2474
+ const next = { ast: compiled._ast };
2475
+ COMPILE_CACHE.set(expression, next);
2476
+ return next;
2982
2477
  }
2983
- function capitalize(val) {
2984
- if (val === void 0 || val === null) return "";
2985
- const str = String(val).trim();
2986
- if (!str) return "";
2987
- return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
2478
+ function isPlainObject(value) {
2479
+ return !!value && typeof value === "object" && !Array.isArray(value);
2988
2480
  }
2989
- function truncate(val, length2, ellipsis = "...") {
2990
- if (val === void 0 || val === null) return "";
2991
- const str = String(val);
2992
- const maxLen = typeof length2 === "number" && length2 > 0 ? length2 : 100;
2993
- if (str.length <= maxLen) return str;
2994
- return str.slice(0, maxLen).trim() + String(ellipsis ?? "...");
2481
+ function quoteSegment(value) {
2482
+ return `["${value.replace(/"/g, '\\"')}"]`;
2995
2483
  }
2996
- function readingTime(val, wpm = 200) {
2997
- if (val === void 0 || val === null) return 1;
2998
- const str = String(val).trim();
2999
- if (!str) return 1;
3000
- const words = str.split(/\s+/).filter(Boolean).length;
3001
- return Math.max(1, Math.ceil(words / (typeof wpm === "number" && wpm > 0 ? wpm : 200)));
2484
+ function fieldSegment(field, index) {
2485
+ return typeof field.name === "string" && field.name.length > 0 ? `fields${quoteSegment(field.name)}` : `fields[${index}]`;
3002
2486
  }
3003
- function wordCount(val) {
3004
- if (val === void 0 || val === null) return 0;
3005
- const str = String(val).trim();
3006
- if (!str) return 0;
3007
- return str.split(/\s+/).filter(Boolean).length;
2487
+ function blockSegment(block, index) {
2488
+ return typeof block.slug === "string" && block.slug.length > 0 ? `blocks${quoteSegment(block.slug)}` : `blocks[${index}]`;
3008
2489
  }
3009
- function replace(val, search, replacement) {
3010
- if (val === void 0 || val === null) return "";
3011
- return String(val).replace(new RegExp(String(search || ""), "g"), String(replacement ?? ""));
2490
+ function collectionSegment(collection, index) {
2491
+ return typeof collection.slug === "string" && collection.slug.length > 0 ? `collections${quoteSegment(collection.slug)}` : `collections[${index}]`;
3012
2492
  }
3013
- function startsWith(str, prefix) {
3014
- if (str === void 0 || str === null) return false;
3015
- return String(str).startsWith(String(prefix ?? ""));
2493
+ function globalSegment(global, index) {
2494
+ return typeof global.slug === "string" && global.slug.length > 0 ? `globals${quoteSegment(global.slug)}` : `globals[${index}]`;
3016
2495
  }
3017
- function endsWith(str, suffix) {
3018
- if (str === void 0 || str === null) return false;
3019
- return String(str).endsWith(String(suffix ?? ""));
2496
+ function colorize(value, code, enabled) {
2497
+ return enabled ? `\x1B[${code}m${value}\x1B[0m` : value;
3020
2498
  }
3021
- function now() {
3022
- return (/* @__PURE__ */ new Date()).toISOString();
2499
+ function collectRootIdentifiers(node, roots, seen) {
2500
+ if (!node || typeof node !== "object" || seen.has(node)) return;
2501
+ seen.add(node);
2502
+ const candidate = node;
2503
+ if (candidate.type === "Identifier") {
2504
+ if (candidate.from) {
2505
+ collectRootIdentifiers(candidate.from, roots, seen);
2506
+ return;
2507
+ }
2508
+ if (typeof candidate.value === "string" && candidate.value !== "null") {
2509
+ roots.add(candidate.value);
2510
+ }
2511
+ return;
2512
+ }
2513
+ if (Array.isArray(node)) {
2514
+ for (const entry of node) collectRootIdentifiers(entry, roots, seen);
2515
+ return;
2516
+ }
2517
+ for (const value of Object.values(node)) {
2518
+ if (Array.isArray(value)) {
2519
+ for (const entry of value) collectRootIdentifiers(entry, roots, seen);
2520
+ continue;
2521
+ }
2522
+ if (value && typeof value === "object") {
2523
+ collectRootIdentifiers(value, roots, seen);
2524
+ }
2525
+ }
3023
2526
  }
3024
- function today() {
3025
- return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2527
+ function getInvalidRoots(expression, allowedRoots) {
2528
+ const { ast } = compileExpression(expression);
2529
+ const roots = /* @__PURE__ */ new Set();
2530
+ collectRootIdentifiers(ast, roots, /* @__PURE__ */ new Set());
2531
+ const allowed = new Set(allowedRoots);
2532
+ return [...roots].filter((root) => !allowed.has(root));
3026
2533
  }
3027
- function formatDate(val, style = "short") {
3028
- if (!val) return "";
3029
- const d = new Date(val);
3030
- if (Number.isNaN(d.getTime())) return "";
3031
- const styleStr = String(style).toLowerCase();
3032
- if (styleStr === "iso" || styleStr === "date") return d.toISOString().slice(0, 10);
3033
- if (styleStr === "datetime" || styleStr === "full") return d.toISOString();
3034
- return d.toLocaleDateString("en-US", {
3035
- year: "numeric",
3036
- month: "short",
3037
- day: "numeric"
3038
- });
3039
- }
3040
- function addDays(val, days) {
3041
- const d = val ? new Date(val) : /* @__PURE__ */ new Date();
3042
- if (Number.isNaN(d.getTime())) return "";
3043
- const daysNum = typeof days === "number" ? days : Number(days) || 0;
3044
- d.setDate(d.getDate() + daysNum);
3045
- return d.toISOString();
3046
- }
3047
- function diffDays(dateA, dateB) {
3048
- const dA = dateA ? new Date(dateA) : /* @__PURE__ */ new Date();
3049
- const dB = dateB ? new Date(dateB) : /* @__PURE__ */ new Date();
3050
- if (Number.isNaN(dA.getTime()) || Number.isNaN(dB.getTime())) return 0;
3051
- const diffMs = Math.abs(dA.getTime() - dB.getTime());
3052
- return Math.floor(diffMs / (1e3 * 60 * 60 * 24));
3053
- }
3054
- function isPast(val) {
3055
- if (!val) return false;
3056
- const d = new Date(val);
3057
- if (Number.isNaN(d.getTime())) return false;
3058
- return d.getTime() < Date.now();
3059
- }
3060
- function isFuture(val) {
3061
- if (!val) return false;
3062
- const d = new Date(val);
3063
- if (Number.isNaN(d.getTime())) return false;
3064
- return d.getTime() > Date.now();
3065
- }
3066
- function includes(arrOrStr, item) {
3067
- if (Array.isArray(arrOrStr)) {
3068
- return arrOrStr.includes(item);
3069
- }
3070
- if (typeof arrOrStr === "string") {
3071
- return arrOrStr.includes(String(item ?? ""));
2534
+ function validateDeclarativeHookExpression(expression, surface, path) {
2535
+ try {
2536
+ const invalidRoots = getInvalidRoots(expression, SURFACE_ROOTS[surface]);
2537
+ if (invalidRoots.length === 0) return [];
2538
+ return [
2539
+ {
2540
+ path,
2541
+ surface,
2542
+ expression,
2543
+ message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${SURFACE_ROOTS[surface].map((root) => `"${root}"`).join(", ")}`
2544
+ }
2545
+ ];
2546
+ } catch (error) {
2547
+ return [
2548
+ {
2549
+ path,
2550
+ surface,
2551
+ expression,
2552
+ message: `has invalid syntax: ${error.message}`
2553
+ }
2554
+ ];
3072
2555
  }
3073
- return false;
3074
- }
3075
- function join(arr, separator = ", ") {
3076
- if (!Array.isArray(arr)) return "";
3077
- return arr.filter((item) => item !== void 0 && item !== null).join(String(separator));
3078
- }
3079
- function first(arr) {
3080
- if (Array.isArray(arr)) return arr[0] ?? null;
3081
- return null;
3082
- }
3083
- function last(arr) {
3084
- if (Array.isArray(arr)) return arr[arr.length - 1] ?? null;
3085
- return null;
3086
2556
  }
3087
- function compact(arr) {
3088
- if (!Array.isArray(arr)) return [];
3089
- return arr.filter((item) => item !== void 0 && item !== null && item !== "");
2557
+ function validateDeclarativeAccessExpression(expression, surface, path) {
2558
+ try {
2559
+ const invalidRoots = getInvalidRoots(expression, ACCESS_ROOTS);
2560
+ if (invalidRoots.length === 0) return [];
2561
+ return [
2562
+ {
2563
+ path,
2564
+ surface,
2565
+ expression,
2566
+ message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${ACCESS_ROOTS.map((root) => `"${root}"`).join(", ")}`
2567
+ }
2568
+ ];
2569
+ } catch (error) {
2570
+ return [
2571
+ {
2572
+ path,
2573
+ surface,
2574
+ expression,
2575
+ message: `has invalid syntax: ${error.message}`
2576
+ }
2577
+ ];
2578
+ }
3090
2579
  }
3091
- function unique(arr) {
3092
- if (!Array.isArray(arr)) return [];
3093
- return Array.from(new Set(arr));
2580
+ function validateAdminConditionExpression(expression, path, allowedRoots) {
2581
+ try {
2582
+ const invalidRoots = getInvalidRoots(expression, allowedRoots);
2583
+ if (invalidRoots.length === 0) return [];
2584
+ return [
2585
+ {
2586
+ path,
2587
+ surface: "field.admin.condition",
2588
+ expression,
2589
+ message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${allowedRoots.map((root) => `"${root}"`).join(", ")}`
2590
+ }
2591
+ ];
2592
+ } catch (error) {
2593
+ return [
2594
+ {
2595
+ path,
2596
+ surface: "field.admin.condition",
2597
+ expression,
2598
+ message: `has invalid syntax: ${error.message}`
2599
+ }
2600
+ ];
2601
+ }
3094
2602
  }
3095
- function length(val) {
3096
- if (Array.isArray(val) || typeof val === "string") return val.length;
3097
- if (val && typeof val === "object") return Object.keys(val).length;
3098
- return 0;
2603
+ function validatePreviewUrlExpression(expression, path, allowedRoots) {
2604
+ try {
2605
+ const invalidRoots = getInvalidRoots(expression, allowedRoots);
2606
+ if (invalidRoots.length === 0) return [];
2607
+ return [
2608
+ {
2609
+ path,
2610
+ surface: "collection.admin.previewUrl",
2611
+ expression,
2612
+ message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${allowedRoots.map((root) => `"${root}"`).join(", ")}`
2613
+ }
2614
+ ];
2615
+ } catch (error) {
2616
+ return [
2617
+ {
2618
+ path,
2619
+ surface: "collection.admin.previewUrl",
2620
+ expression,
2621
+ message: `has invalid syntax: ${error.message}`
2622
+ }
2623
+ ];
2624
+ }
3099
2625
  }
3100
- function round(val, decimals = 0) {
3101
- const num = Number(val);
3102
- if (Number.isNaN(num)) return 0;
3103
- const dec = typeof decimals === "number" ? decimals : Number(decimals) || 0;
3104
- const factor = Math.pow(10, dec);
3105
- return Math.round(num * factor) / factor;
2626
+ function assertValidDeclarativeHookExpression(expression, surface, path) {
2627
+ const issues = validateDeclarativeHookExpression(expression, surface, path);
2628
+ if (issues.length === 0) return;
2629
+ throw new Error(`Invalid declarative hook at ${path}: ${issues[0].message}`);
3106
2630
  }
3107
- function clamp(val, minVal, maxVal) {
3108
- const num = Number(val) || 0;
3109
- const min2 = Number(minVal) || 0;
3110
- const max2 = Number(maxVal) || 0;
3111
- return Math.min(Math.max(num, min2), max2);
2631
+ function assertValidDeclarativeHookResult(result, surface, path) {
2632
+ if (isPlainObject(result)) return;
2633
+ throw new Error(`Invalid declarative hook result at ${path}: ${surface} expressions must return an object`);
3112
2634
  }
3113
- function ceil(val) {
3114
- return Math.ceil(Number(val) || 0);
2635
+ function collectFieldIssues(fields, path, issues) {
2636
+ fields.forEach((field, index) => {
2637
+ const fieldPath = `${path}.${fieldSegment(field, index)}`;
2638
+ field.hooks?.beforeChange?.forEach((hook, hookIndex) => {
2639
+ if (typeof hook !== "string") return;
2640
+ issues.push(
2641
+ ...validateDeclarativeHookExpression(
2642
+ hook,
2643
+ "field.beforeChange",
2644
+ `${fieldPath}.hooks.beforeChange[${hookIndex}]`
2645
+ )
2646
+ );
2647
+ });
2648
+ field.hooks?.afterRead?.forEach((hook, hookIndex) => {
2649
+ if (typeof hook !== "string") return;
2650
+ issues.push({
2651
+ path: `${fieldPath}.hooks.afterRead[${hookIndex}]`,
2652
+ surface: "unsupported",
2653
+ expression: hook,
2654
+ message: "declarative strings are not supported on field hooks.afterRead"
2655
+ });
2656
+ });
2657
+ if (typeof field.admin?.hooks?.onChange === "string") {
2658
+ issues.push(
2659
+ ...validateDeclarativeHookExpression(
2660
+ field.admin.hooks.onChange,
2661
+ "field.admin.onChange",
2662
+ `${fieldPath}.admin.hooks.onChange`
2663
+ )
2664
+ );
2665
+ }
2666
+ if (typeof field.admin?.hooks?.options === "string") {
2667
+ issues.push({
2668
+ path: `${fieldPath}.admin.hooks.options`,
2669
+ surface: "unsupported",
2670
+ expression: field.admin.hooks.options,
2671
+ message: "declarative strings are not supported on field admin.hooks.options"
2672
+ });
2673
+ }
2674
+ if (Array.isArray(field.fields)) {
2675
+ collectFieldIssues(field.fields, fieldPath, issues);
2676
+ }
2677
+ if (Array.isArray(field.blocks)) {
2678
+ field.blocks.forEach((block, blockIndex) => {
2679
+ if (!Array.isArray(block.fields)) return;
2680
+ collectFieldIssues(block.fields, `${fieldPath}.${blockSegment(block, blockIndex)}`, issues);
2681
+ });
2682
+ }
2683
+ });
3115
2684
  }
3116
- function floor(val) {
3117
- return Math.floor(Number(val) || 0);
2685
+ function collectFieldNames(fields) {
2686
+ return fields.flatMap(
2687
+ (field) => typeof field.name === "string" && field.name.length > 0 ? [field.name] : []
2688
+ );
3118
2689
  }
3119
- function abs(val) {
3120
- return Math.abs(Number(val) || 0);
2690
+ function collectAdminConditionIssues(fields, path, rootFieldNames, issues) {
2691
+ const scopeFieldNames = collectFieldNames(fields);
2692
+ const allowedRoots = [.../* @__PURE__ */ new Set([...ADMIN_CONDITION_RESERVED_ROOTS, ...rootFieldNames, ...scopeFieldNames])];
2693
+ fields.forEach((field, index) => {
2694
+ const fieldPath = `${path}.${fieldSegment(field, index)}`;
2695
+ if (typeof field.admin?.condition === "string") {
2696
+ issues.push(
2697
+ ...validateAdminConditionExpression(
2698
+ field.admin.condition,
2699
+ `${fieldPath}.admin.condition`,
2700
+ allowedRoots
2701
+ )
2702
+ );
2703
+ }
2704
+ if (Array.isArray(field.fields)) {
2705
+ collectAdminConditionIssues(field.fields, fieldPath, rootFieldNames, issues);
2706
+ }
2707
+ if (Array.isArray(field.blocks)) {
2708
+ field.blocks.forEach((block, blockIndex) => {
2709
+ if (!Array.isArray(block.fields)) return;
2710
+ collectAdminConditionIssues(
2711
+ block.fields,
2712
+ `${fieldPath}.${blockSegment(block, blockIndex)}`,
2713
+ rootFieldNames,
2714
+ issues
2715
+ );
2716
+ });
2717
+ }
2718
+ });
3121
2719
  }
3122
- function min(...args) {
3123
- const nums = args.flat().map((n) => Number(n) || 0);
3124
- return nums.length ? Math.min(...nums) : 0;
2720
+ function collectFieldAccessIssues(fields, path, issues) {
2721
+ fields.forEach((field, index) => {
2722
+ const fieldPath = `${path}.${fieldSegment(field, index)}`;
2723
+ if (typeof field.access?.read === "string") {
2724
+ issues.push(...validateDeclarativeAccessExpression(field.access.read, "field.access", `${fieldPath}.access.read`));
2725
+ }
2726
+ if (typeof field.access?.create === "string") {
2727
+ issues.push(
2728
+ ...validateDeclarativeAccessExpression(field.access.create, "field.access", `${fieldPath}.access.create`)
2729
+ );
2730
+ }
2731
+ if (typeof field.access?.update === "string") {
2732
+ issues.push(
2733
+ ...validateDeclarativeAccessExpression(field.access.update, "field.access", `${fieldPath}.access.update`)
2734
+ );
2735
+ }
2736
+ if (Array.isArray(field.fields)) {
2737
+ collectFieldAccessIssues(field.fields, fieldPath, issues);
2738
+ }
2739
+ if (Array.isArray(field.blocks)) {
2740
+ field.blocks.forEach((block, blockIndex) => {
2741
+ if (!Array.isArray(block.fields)) return;
2742
+ collectFieldAccessIssues(
2743
+ block.fields,
2744
+ `${fieldPath}.${blockSegment(block, blockIndex)}`,
2745
+ issues
2746
+ );
2747
+ });
2748
+ }
2749
+ });
3125
2750
  }
3126
- function max(...args) {
3127
- const nums = args.flat().map((n) => Number(n) || 0);
3128
- return nums.length ? Math.max(...nums) : 0;
2751
+ function collectHookIssues(hooks, scope, path, issues) {
2752
+ if (!hooks) return;
2753
+ const supported = {
2754
+ beforeRead: `${scope}.beforeRead`,
2755
+ afterRead: `${scope}.afterRead`,
2756
+ beforeChange: `${scope}.beforeChange`
2757
+ };
2758
+ for (const [key, value] of Object.entries(hooks)) {
2759
+ if (!Array.isArray(value)) continue;
2760
+ value.forEach((hook, index) => {
2761
+ if (typeof hook !== "string") return;
2762
+ const surface = supported[key];
2763
+ if (!surface) {
2764
+ issues.push({
2765
+ path: `${path}.${key}[${index}]`,
2766
+ surface: "unsupported",
2767
+ expression: hook,
2768
+ message: `declarative strings are not supported on ${scope} hooks.${key}`
2769
+ });
2770
+ return;
2771
+ }
2772
+ issues.push(...validateDeclarativeHookExpression(hook, surface, `${path}.${key}[${index}]`));
2773
+ });
2774
+ }
3129
2775
  }
3130
- function pow(base, exp) {
3131
- return Math.pow(Number(base) || 0, Number(exp) || 0);
2776
+ function collectDeclarativeHookValidationIssues(config) {
2777
+ const issues = [];
2778
+ config.blocks?.forEach((block, blockIndex) => {
2779
+ if (!Array.isArray(block.fields)) return;
2780
+ collectFieldIssues(
2781
+ block.fields,
2782
+ blockSegment(block, blockIndex),
2783
+ issues
2784
+ );
2785
+ });
2786
+ config.collections.forEach((collection, collectionIndex) => {
2787
+ const collectionPath = collectionSegment(collection, collectionIndex);
2788
+ collectHookIssues(
2789
+ collection.hooks,
2790
+ "collection",
2791
+ `${collectionPath}.hooks`,
2792
+ issues
2793
+ );
2794
+ collectFieldIssues(collection.fields, collectionPath, issues);
2795
+ });
2796
+ config.globals?.forEach((global, globalIndex) => {
2797
+ const globalPath = globalSegment(global, globalIndex);
2798
+ collectHookIssues(
2799
+ global.hooks,
2800
+ "global",
2801
+ `${globalPath}.hooks`,
2802
+ issues
2803
+ );
2804
+ collectFieldIssues(global.fields, globalPath, issues);
2805
+ });
2806
+ return issues;
3132
2807
  }
3133
- function sqrt(val) {
3134
- return Math.sqrt(Number(val) || 0);
2808
+ function collectDeclarativeAccessValidationIssues(config) {
2809
+ const issues = [];
2810
+ Object.entries(config.accessPolicies ?? {}).forEach(([name, value]) => {
2811
+ if (typeof value !== "string") return;
2812
+ issues.push(
2813
+ ...validateDeclarativeAccessExpression(value, "config.accessPolicy", `accessPolicies.${name}`)
2814
+ );
2815
+ });
2816
+ config.blocks?.forEach((block, blockIndex) => {
2817
+ if (!Array.isArray(block.fields)) return;
2818
+ collectFieldAccessIssues(
2819
+ block.fields,
2820
+ blockSegment(block, blockIndex),
2821
+ issues
2822
+ );
2823
+ });
2824
+ config.collections.forEach((collection, collectionIndex) => {
2825
+ const collectionPath = collectionSegment(collection, collectionIndex);
2826
+ if (typeof collection.access?.read === "string") {
2827
+ issues.push(
2828
+ ...validateDeclarativeAccessExpression(
2829
+ collection.access.read,
2830
+ "collection.access",
2831
+ `${collectionPath}.access.read`
2832
+ )
2833
+ );
2834
+ }
2835
+ if (typeof collection.access?.create === "string") {
2836
+ issues.push(
2837
+ ...validateDeclarativeAccessExpression(
2838
+ collection.access.create,
2839
+ "collection.access",
2840
+ `${collectionPath}.access.create`
2841
+ )
2842
+ );
2843
+ }
2844
+ if (typeof collection.access?.update === "string") {
2845
+ issues.push(
2846
+ ...validateDeclarativeAccessExpression(
2847
+ collection.access.update,
2848
+ "collection.access",
2849
+ `${collectionPath}.access.update`
2850
+ )
2851
+ );
2852
+ }
2853
+ if (typeof collection.access?.delete === "string") {
2854
+ issues.push(
2855
+ ...validateDeclarativeAccessExpression(
2856
+ collection.access.delete,
2857
+ "collection.access",
2858
+ `${collectionPath}.access.delete`
2859
+ )
2860
+ );
2861
+ }
2862
+ if (typeof collection.access?.readAudit === "string") {
2863
+ issues.push(
2864
+ ...validateDeclarativeAccessExpression(
2865
+ collection.access.readAudit,
2866
+ "collection.access",
2867
+ `${collectionPath}.access.readAudit`
2868
+ )
2869
+ );
2870
+ }
2871
+ collectFieldAccessIssues(collection.fields, collectionPath, issues);
2872
+ });
2873
+ config.globals?.forEach((global, globalIndex) => {
2874
+ const globalPath = globalSegment(global, globalIndex);
2875
+ if (typeof global.access?.read === "string") {
2876
+ issues.push(
2877
+ ...validateDeclarativeAccessExpression(global.access.read, "global.access", `${globalPath}.access.read`)
2878
+ );
2879
+ }
2880
+ if (typeof global.access?.update === "string") {
2881
+ issues.push(
2882
+ ...validateDeclarativeAccessExpression(
2883
+ global.access.update,
2884
+ "global.access",
2885
+ `${globalPath}.access.update`
2886
+ )
2887
+ );
2888
+ }
2889
+ collectFieldAccessIssues(global.fields, globalPath, issues);
2890
+ });
2891
+ return issues;
3135
2892
  }
3136
- function sum(arr) {
3137
- if (!Array.isArray(arr)) return Number(arr) || 0;
3138
- return arr.reduce((acc, val) => acc + (Number(val) || 0), 0);
2893
+ function collectAdminConditionValidationIssues(config) {
2894
+ const issues = [];
2895
+ config.blocks?.forEach((block, blockIndex) => {
2896
+ if (!Array.isArray(block.fields)) return;
2897
+ collectAdminConditionIssues(
2898
+ block.fields,
2899
+ blockSegment(block, blockIndex),
2900
+ collectFieldNames(block.fields),
2901
+ issues
2902
+ );
2903
+ });
2904
+ config.collections.forEach((collection, collectionIndex) => {
2905
+ const collectionPath = collectionSegment(collection, collectionIndex);
2906
+ const rootFieldNames = collectFieldNames(collection.fields);
2907
+ collectAdminConditionIssues(collection.fields, collectionPath, rootFieldNames, issues);
2908
+ });
2909
+ config.globals?.forEach((global, globalIndex) => {
2910
+ const globalPath = globalSegment(global, globalIndex);
2911
+ const rootFieldNames = collectFieldNames(global.fields);
2912
+ collectAdminConditionIssues(global.fields, globalPath, rootFieldNames, issues);
2913
+ });
2914
+ return issues;
3139
2915
  }
3140
- function count(val) {
3141
- if (Array.isArray(val)) return val.length;
3142
- if (typeof val === "string") return val.length;
3143
- if (val !== void 0 && val !== null && val !== false) return 1;
3144
- return 0;
2916
+ function collectPreviewUrlValidationIssues(config) {
2917
+ const issues = [];
2918
+ config.collections.forEach((collection, collectionIndex) => {
2919
+ if (typeof collection.admin?.previewUrl !== "string") return;
2920
+ const collectionPath = collectionSegment(collection, collectionIndex);
2921
+ const allowedRoots = [
2922
+ .../* @__PURE__ */ new Set([...PREVIEW_URL_RESERVED_ROOTS, ...collectFieldNames(collection.fields)])
2923
+ ];
2924
+ issues.push(
2925
+ ...validatePreviewUrlExpression(
2926
+ collection.admin.previewUrl,
2927
+ `${collectionPath}.admin.previewUrl`,
2928
+ allowedRoots
2929
+ )
2930
+ );
2931
+ });
2932
+ return issues;
3145
2933
  }
3146
- function defaultValue(val, fallback) {
3147
- if (val === void 0 || val === null || val === "") return fallback;
3148
- return val;
2934
+ function assertValidAdminConditionsInConfig(config, source = "config") {
2935
+ const diagnostics = collectAdminConditionValidationIssues(config).map((issue) => ({
2936
+ severity: "error",
2937
+ source: "adminCondition",
2938
+ path: issue.path,
2939
+ surface: issue.surface,
2940
+ message: issue.message,
2941
+ expression: issue.expression
2942
+ }));
2943
+ if (diagnostics.length === 0) return;
2944
+ throw new ConfigValidationError(source, diagnostics);
3149
2945
  }
3150
- function coalesce(...args) {
3151
- for (const arg of args) {
3152
- if (arg !== void 0 && arg !== null && arg !== "") return arg;
3153
- }
3154
- return null;
2946
+ function assertValidPreviewUrlsInConfig(config, source = "config") {
2947
+ const diagnostics = collectPreviewUrlValidationIssues(config).map((issue) => ({
2948
+ severity: "error",
2949
+ source: "previewUrl",
2950
+ path: issue.path,
2951
+ surface: issue.surface,
2952
+ message: issue.message,
2953
+ expression: issue.expression
2954
+ }));
2955
+ if (diagnostics.length === 0) return;
2956
+ throw new ConfigValidationError(source, diagnostics);
3155
2957
  }
3156
- function isEmpty(val) {
3157
- if (val === void 0 || val === null || val === "") return true;
3158
- if (Array.isArray(val)) return val.length === 0;
3159
- if (typeof val === "object") return Object.keys(val).length === 0;
3160
- return false;
2958
+ function collectConfigDiagnostics(config) {
2959
+ const hookIssues = collectDeclarativeHookValidationIssues(config).map((issue) => ({
2960
+ severity: "error",
2961
+ source: "declarativeHook",
2962
+ path: issue.path,
2963
+ surface: issue.surface,
2964
+ message: issue.message,
2965
+ expression: issue.expression
2966
+ }));
2967
+ const accessIssues = collectDeclarativeAccessValidationIssues(config).map((issue) => ({
2968
+ severity: "error",
2969
+ source: "declarativeAccess",
2970
+ path: issue.path,
2971
+ surface: issue.surface,
2972
+ message: issue.message,
2973
+ expression: issue.expression
2974
+ }));
2975
+ const adminConditionIssues = collectAdminConditionValidationIssues(config).map((issue) => ({
2976
+ severity: "error",
2977
+ source: "adminCondition",
2978
+ path: issue.path,
2979
+ surface: issue.surface,
2980
+ message: issue.message,
2981
+ expression: issue.expression
2982
+ }));
2983
+ const previewUrlIssues = collectPreviewUrlValidationIssues(config).map((issue) => ({
2984
+ severity: "error",
2985
+ source: "previewUrl",
2986
+ path: issue.path,
2987
+ surface: issue.surface,
2988
+ message: issue.message,
2989
+ expression: issue.expression
2990
+ }));
2991
+ return [...hookIssues, ...accessIssues, ...adminConditionIssues, ...previewUrlIssues];
2992
+ }
2993
+ function formatConfigDiagnostics(source, diagnostics, options = {}) {
2994
+ const color = options.color ?? false;
2995
+ const header = `${colorize("Config validation failed", 31, color)} in ${colorize(source, 36, color)}`;
2996
+ const docLink = `${colorize(" Learn how to write declarative rules:", 90, color)} ${colorize("https://docs.dyrected.com/docs/editor-experience/form-and-field-hooks", 34, color)}`;
2997
+ const lines = diagnostics.flatMap((issue, index) => [
2998
+ `${colorize(`${index + 1}.`, 31, color)} ${colorize(issue.path, 1, color)}`,
2999
+ ` ${issue.message}`,
3000
+ ` ${colorize("surface:", 90, color)} ${issue.surface}`
3001
+ ]);
3002
+ return `${header}
3003
+ ${lines.join("\n")}
3004
+
3005
+ ${docLink}`;
3006
+ }
3007
+ var ConfigValidationError = class extends Error {
3008
+ diagnostics;
3009
+ source;
3010
+ constructor(source, diagnostics) {
3011
+ super(formatConfigDiagnostics(source, diagnostics));
3012
+ this.name = "ConfigValidationError";
3013
+ this.source = source;
3014
+ this.diagnostics = diagnostics;
3015
+ }
3016
+ };
3017
+ function isConfigValidationError(error) {
3018
+ return error instanceof ConfigValidationError;
3019
+ }
3020
+ function assertValidDeclarativeHooksInConfig(config, source = "config") {
3021
+ const diagnostics = collectDeclarativeHookValidationIssues(config).map((issue) => ({
3022
+ severity: "error",
3023
+ source: "declarativeHook",
3024
+ path: issue.path,
3025
+ surface: issue.surface,
3026
+ message: issue.message,
3027
+ expression: issue.expression
3028
+ }));
3029
+ if (diagnostics.length === 0) return;
3030
+ throw new ConfigValidationError(source, diagnostics);
3031
+ }
3032
+ function assertValidDeclarativeAccessInConfig(config, source = "config") {
3033
+ const diagnostics = collectDeclarativeAccessValidationIssues(config).map((issue) => ({
3034
+ severity: "error",
3035
+ source: "declarativeAccess",
3036
+ path: issue.path,
3037
+ surface: issue.surface,
3038
+ message: issue.message,
3039
+ expression: issue.expression
3040
+ }));
3041
+ if (diagnostics.length === 0) return;
3042
+ throw new ConfigValidationError(source, diagnostics);
3043
+ }
3044
+
3045
+ // src/utils/hooks.ts
3046
+ async function runCollectionHooks(hooks, args, options = {}) {
3047
+ if (!hooks || hooks.length === 0) {
3048
+ return args.data ?? args.doc ?? args.query ?? void 0;
3049
+ }
3050
+ const payloadKey = args.data !== void 0 ? "data" : args.doc !== void 0 ? "doc" : "query" in args ? "query" : void 0;
3051
+ let currentPayload = payloadKey === void 0 ? void 0 : args[payloadKey];
3052
+ for (const hook of hooks) {
3053
+ try {
3054
+ const hookArgs = {
3055
+ ...args,
3056
+ ...payloadKey !== void 0 ? { [payloadKey]: currentPayload } : {}
3057
+ };
3058
+ const result = typeof hook === "string" ? await (() => {
3059
+ if (options.surface) {
3060
+ assertValidDeclarativeHookExpression(
3061
+ hook,
3062
+ options.surface,
3063
+ options.path ?? options.surface
3064
+ );
3065
+ }
3066
+ return import_jexl3.default.eval(hook, hookArgs);
3067
+ })() : await hook(hookArgs);
3068
+ if (result !== void 0) {
3069
+ if (typeof hook === "string" && options.surface) {
3070
+ assertValidDeclarativeHookResult(
3071
+ result,
3072
+ options.surface,
3073
+ options.path ?? options.surface
3074
+ );
3075
+ }
3076
+ currentPayload = typeof hook === "string" && currentPayload && typeof currentPayload === "object" && !Array.isArray(currentPayload) && result && typeof result === "object" && !Array.isArray(result) ? {
3077
+ ...currentPayload,
3078
+ ...result
3079
+ } : result;
3080
+ }
3081
+ } catch (err) {
3082
+ if (options.isolated) {
3083
+ console.error("Side-effect hook failed after a successful write", err);
3084
+ } else {
3085
+ throw err;
3086
+ }
3087
+ }
3088
+ }
3089
+ return currentPayload;
3090
+ }
3091
+ async function executeFieldBeforeChange(fields, data, originalDoc, user, db, pathPrefix = "") {
3092
+ if (!data || typeof data !== "object") return data;
3093
+ const result = { ...data };
3094
+ for (const field of fields) {
3095
+ if (!field.name) continue;
3096
+ const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
3097
+ const value = result[field.name];
3098
+ const origValue = originalDoc?.[field.name];
3099
+ let updatedValue = value;
3100
+ if (field.hooks?.beforeChange) {
3101
+ for (const hook of field.hooks.beforeChange) {
3102
+ const hookArgs = {
3103
+ value: updatedValue,
3104
+ originalDoc: originalDoc ?? void 0,
3105
+ data: result,
3106
+ user,
3107
+ db
3108
+ };
3109
+ updatedValue = typeof hook === "string" ? await (() => {
3110
+ assertValidDeclarativeHookExpression(
3111
+ hook,
3112
+ "field.beforeChange",
3113
+ fieldPath
3114
+ );
3115
+ return import_jexl3.default.eval(hook, hookArgs);
3116
+ })() : await hook(hookArgs);
3117
+ }
3118
+ result[field.name] = updatedValue;
3119
+ }
3120
+ if (updatedValue !== void 0 && updatedValue !== null) {
3121
+ if (field.type === "object" && field.fields) {
3122
+ result[field.name] = await executeFieldBeforeChange(
3123
+ field.fields,
3124
+ updatedValue,
3125
+ origValue,
3126
+ user,
3127
+ db,
3128
+ fieldPath
3129
+ );
3130
+ } else if (field.type === "array" && field.fields && Array.isArray(updatedValue)) {
3131
+ const arrayResult = [];
3132
+ for (let i = 0; i < updatedValue.length; i++) {
3133
+ const item = updatedValue[i];
3134
+ const origItem = Array.isArray(origValue) ? origValue[i] : null;
3135
+ arrayResult.push(
3136
+ await executeFieldBeforeChange(
3137
+ field.fields,
3138
+ item,
3139
+ origItem,
3140
+ user,
3141
+ db,
3142
+ fieldPath
3143
+ )
3144
+ );
3145
+ }
3146
+ result[field.name] = arrayResult;
3147
+ } else if (field.type === "blocks" && field.blocks && Array.isArray(updatedValue)) {
3148
+ const blocksResult = [];
3149
+ for (let i = 0; i < updatedValue.length; i++) {
3150
+ const blockData = updatedValue[i];
3151
+ const origBlock = Array.isArray(origValue) ? origValue[i] : null;
3152
+ const blockConfig = field.blocks.find(
3153
+ (b) => b.slug === blockData.blockType
3154
+ );
3155
+ if (blockConfig) {
3156
+ blocksResult.push(
3157
+ await executeFieldBeforeChange(
3158
+ blockConfig.fields,
3159
+ blockData,
3160
+ origBlock,
3161
+ user,
3162
+ db,
3163
+ fieldPath
3164
+ )
3165
+ );
3166
+ } else {
3167
+ blocksResult.push(blockData);
3168
+ }
3169
+ }
3170
+ result[field.name] = blocksResult;
3171
+ }
3172
+ }
3173
+ }
3174
+ return result;
3175
+ }
3176
+ async function executeFieldAfterRead(fields, doc, user, db) {
3177
+ if (!doc || typeof doc !== "object") return doc;
3178
+ const result = { ...doc };
3179
+ for (const field of fields) {
3180
+ if (!field.name) continue;
3181
+ const value = result[field.name];
3182
+ let updatedValue = value;
3183
+ if (field.hooks?.afterRead) {
3184
+ for (const hook of field.hooks.afterRead) {
3185
+ const typedHook = hook;
3186
+ const hookArgs = {
3187
+ value: updatedValue,
3188
+ doc: result,
3189
+ user,
3190
+ db
3191
+ };
3192
+ updatedValue = await typedHook(hookArgs);
3193
+ }
3194
+ result[field.name] = updatedValue;
3195
+ }
3196
+ if (updatedValue !== void 0 && updatedValue !== null) {
3197
+ if (field.type === "object" && field.fields) {
3198
+ result[field.name] = await executeFieldAfterRead(
3199
+ field.fields,
3200
+ updatedValue,
3201
+ user,
3202
+ db
3203
+ );
3204
+ } else if (field.type === "array" && field.fields && Array.isArray(updatedValue)) {
3205
+ const arrayResult = [];
3206
+ for (const item of updatedValue) {
3207
+ arrayResult.push(
3208
+ await executeFieldAfterRead(
3209
+ field.fields,
3210
+ item,
3211
+ user,
3212
+ db
3213
+ )
3214
+ );
3215
+ }
3216
+ result[field.name] = arrayResult;
3217
+ } else if (field.type === "blocks" && field.blocks && Array.isArray(updatedValue)) {
3218
+ const blocksResult = [];
3219
+ for (const blockData of updatedValue) {
3220
+ const typedBlock = blockData;
3221
+ const blockConfig = field.blocks.find(
3222
+ (b) => b.slug === typedBlock.blockType
3223
+ );
3224
+ if (blockConfig) {
3225
+ blocksResult.push(
3226
+ await executeFieldAfterRead(
3227
+ blockConfig.fields,
3228
+ typedBlock,
3229
+ user,
3230
+ db
3231
+ )
3232
+ );
3233
+ } else {
3234
+ blocksResult.push(blockData);
3235
+ }
3236
+ }
3237
+ result[field.name] = blocksResult;
3238
+ }
3239
+ }
3240
+ }
3241
+ return result;
3242
+ }
3243
+
3244
+ // src/trash.ts
3245
+ var TRASH_COLLECTION = "__trash";
3246
+ var TRASH_COLLECTION_CONFIG = {
3247
+ slug: TRASH_COLLECTION,
3248
+ labels: { singular: "Trash entry", plural: "Trash entries" },
3249
+ fields: [
3250
+ { name: "collection", type: "text", required: true, promoted: true },
3251
+ { name: "docId", type: "text", required: true, promoted: true },
3252
+ { name: "deletedAt", type: "number", required: true, promoted: true },
3253
+ // epoch ms
3254
+ { name: "purgeAt", type: "number", promoted: true },
3255
+ // epoch ms, null = never
3256
+ { name: "deletedBy", type: "text", promoted: true },
3257
+ { name: "title", type: "text" },
3258
+ // resolved useAsTitle, for listing
3259
+ { name: "snapshot", type: "json", required: true },
3260
+ // the raw stored document
3261
+ { name: "createdAt", type: "date" }
3262
+ // original doc timestamps live in snapshot
3263
+ ],
3264
+ indexes: [
3265
+ { fields: ["purgeAt"] },
3266
+ { fields: ["collection", "docId"] }
3267
+ ],
3268
+ access: {
3269
+ read: () => false,
3270
+ create: () => false,
3271
+ update: () => false,
3272
+ delete: () => false
3273
+ },
3274
+ admin: { hidden: true }
3275
+ };
3276
+ function getTrashEntryId(collection, docId) {
3277
+ return `${collection}:${docId}`;
3278
+ }
3279
+ function resolveTrashConfig(collection, appConfig) {
3280
+ const colTrash = collection.trash;
3281
+ const appTrash = appConfig?.trash;
3282
+ let enabled = true;
3283
+ if (colTrash === false) {
3284
+ enabled = false;
3285
+ } else if (colTrash === true || typeof colTrash === "object" && colTrash !== null) {
3286
+ enabled = true;
3287
+ } else if (appTrash?.enabled === false) {
3288
+ enabled = false;
3289
+ }
3290
+ if (!enabled) {
3291
+ return {
3292
+ enabled: false,
3293
+ retentionDays: null,
3294
+ allowPermanentDelete: true
3295
+ };
3296
+ }
3297
+ let retentionDays = 30;
3298
+ if (typeof colTrash === "object" && colTrash !== null && "retentionDays" in colTrash) {
3299
+ retentionDays = colTrash.retentionDays ?? null;
3300
+ } else if (appTrash?.retentionDays !== void 0) {
3301
+ retentionDays = appTrash.retentionDays ?? null;
3302
+ }
3303
+ const allowPermanentDelete = typeof colTrash === "object" && colTrash !== null && colTrash.allowPermanentDelete !== void 0 ? colTrash.allowPermanentDelete : appTrash?.allowPermanentDelete !== void 0 ? appTrash.allowPermanentDelete : true;
3304
+ return {
3305
+ enabled: true,
3306
+ retentionDays,
3307
+ allowPermanentDelete
3308
+ };
3161
3309
  }
3162
- function get(obj, path, fallback = null) {
3163
- if (obj === void 0 || obj === null || typeof obj !== "object") return fallback;
3164
- if (typeof path !== "string") return fallback;
3165
- const parts = path.split(".").filter(Boolean);
3166
- let curr = obj;
3167
- for (const part of parts) {
3168
- if (curr === void 0 || curr === null || typeof curr !== "object") return fallback;
3169
- curr = curr[part];
3310
+ function logTrashWarning(config, payload) {
3311
+ if (config?.logger && typeof config.logger.warn === "function") {
3312
+ config.logger.warn(payload);
3313
+ } else if (typeof console !== "undefined" && console.warn) {
3314
+ console.warn(`[dyrected] ${payload.msg}`);
3170
3315
  }
3171
- return curr === void 0 || curr === null ? fallback : curr;
3172
3316
  }
3173
- function registerJexlHelpers(jexlInstance = import_jexl.default) {
3174
- const helpers = {
3175
- // Strings
3176
- slugify,
3177
- lower,
3178
- upper,
3179
- trim,
3180
- capitalize,
3181
- truncate,
3182
- readingTime,
3183
- wordCount,
3184
- replace,
3185
- startsWith,
3186
- endsWith,
3187
- // Dates
3188
- now,
3189
- today,
3190
- formatDate,
3191
- addDays,
3192
- diffDays,
3193
- isPast,
3194
- isFuture,
3195
- // Arrays
3196
- includes,
3197
- join,
3198
- first,
3199
- last,
3200
- compact,
3201
- unique,
3202
- length,
3203
- // Math
3204
- round,
3205
- clamp,
3206
- ceil,
3207
- floor,
3208
- abs,
3209
- min,
3210
- max,
3211
- pow,
3212
- sqrt,
3213
- sum,
3214
- count,
3215
- // Objects & Logical
3216
- default: defaultValue,
3217
- coalesce,
3218
- isEmpty,
3219
- get
3220
- };
3221
- for (const [name, fn] of Object.entries(helpers)) {
3222
- try {
3223
- jexlInstance.addFunction(name, fn);
3224
- } catch {
3317
+ function assertValidTrashInConfig(config, source = "config") {
3318
+ if (config.trash) {
3319
+ const { retentionDays } = config.trash;
3320
+ if (retentionDays !== void 0 && retentionDays !== null) {
3321
+ if (!Number.isInteger(retentionDays) || retentionDays < 1) {
3322
+ throw new Error(
3323
+ `Invalid trash retentionDays in ${source}: must be an integer >= 1. Received ${retentionDays}.`
3324
+ );
3325
+ }
3326
+ if (retentionDays > 3650) {
3327
+ logTrashWarning(config, {
3328
+ msg: `Trash retentionDays in ${source} is unusually large (${retentionDays} days > 10 years). Check if value was provided in hours or seconds.`
3329
+ });
3330
+ }
3225
3331
  }
3226
- try {
3227
- jexlInstance.addTransform(name, fn);
3228
- } catch {
3332
+ }
3333
+ for (const col of config.collections || []) {
3334
+ if (col.slug === "trash") {
3335
+ throw new Error(`Collection slug "trash" is reserved by Dyrected for the trash system.`);
3336
+ }
3337
+ const isSystem = col.slug.startsWith("__");
3338
+ if (isSystem && col.trash) {
3339
+ throw new Error(`System collection "${col.slug}" cannot enable trash.`);
3340
+ }
3341
+ if (col.trash === false) {
3342
+ continue;
3343
+ }
3344
+ if (typeof col.trash === "object" && col.trash !== null) {
3345
+ const { retentionDays } = col.trash;
3346
+ if (retentionDays !== void 0 && retentionDays !== null) {
3347
+ if (!Number.isInteger(retentionDays) || retentionDays < 1) {
3348
+ throw new Error(
3349
+ `Invalid trash retentionDays for collection "${col.slug}" in ${source}: must be an integer >= 1. Received ${retentionDays}. Use trash: false for immediate hard delete.`
3350
+ );
3351
+ }
3352
+ if (retentionDays > 3650) {
3353
+ logTrashWarning(config, {
3354
+ msg: `Trash retentionDays for collection "${col.slug}" is unusually large (${retentionDays} days > 10 years). Check if value was provided in hours or seconds.`,
3355
+ collection: col.slug
3356
+ });
3357
+ }
3358
+ }
3229
3359
  }
3230
3360
  }
3231
- try {
3232
- jexlInstance.addBinaryOp("and", 20, (left, right) => Boolean(left && right));
3233
- jexlInstance.addBinaryOp("or", 10, (left, right) => Boolean(left || right));
3234
- jexlInstance.addBinaryOp("in", 30, (left, right) => {
3235
- if (Array.isArray(right)) return right.includes(left);
3236
- if (typeof right === "string") return right.includes(String(left ?? ""));
3237
- return false;
3238
- });
3239
- } catch {
3361
+ for (const glob of config.globals || []) {
3362
+ if (glob.slug === "trash") {
3363
+ throw new Error(`Global slug "trash" is reserved by Dyrected for the trash system.`);
3364
+ }
3240
3365
  }
3241
3366
  }
3242
- registerJexlHelpers(import_jexl.default);
3243
- async function evaluateJexl(expression, context = {}) {
3244
- if (!expression || typeof expression !== "string") return void 0;
3245
- try {
3246
- const evalContext = {
3247
- null: null,
3248
- undefined: void 0,
3249
- ...context,
3250
- math: Math,
3251
- Math
3252
- };
3253
- return await import_jexl.default.eval(expression, evalContext);
3254
- } catch (_err) {
3255
- return void 0;
3367
+ function resolveDocumentTitle(collection, doc) {
3368
+ if (!doc || typeof doc !== "object") return "";
3369
+ const titleField = collection.admin?.useAsTitle || "title";
3370
+ const val = doc[titleField];
3371
+ if (val !== void 0 && val !== null && String(val).trim().length > 0) {
3372
+ return String(val);
3373
+ }
3374
+ if (doc.name !== void 0 && doc.name !== null && String(doc.name).trim().length > 0) {
3375
+ return String(doc.name);
3376
+ }
3377
+ if (doc.id !== void 0 && doc.id !== null) {
3378
+ return String(doc.id);
3256
3379
  }
3380
+ return "";
3257
3381
  }
3258
- function evaluateJexlSync(expression, context = {}) {
3259
- if (!expression || typeof expression !== "string") return void 0;
3260
- try {
3261
- const evalContext = {
3262
- null: null,
3263
- undefined: void 0,
3264
- ...context,
3265
- math: Math,
3266
- Math
3267
- };
3268
- return import_jexl.default.evalSync(expression, evalContext);
3269
- } catch (_err) {
3270
- return void 0;
3382
+ function createTrashPurgeTask(config) {
3383
+ return {
3384
+ name: "dyrected:trash-purge",
3385
+ cron: config.trash?.purge?.cron ?? "0 3 * * *",
3386
+ run: async ({ db, logger, signal }) => {
3387
+ const batchSize = Math.max(1, config.trash?.purge?.batchSize ?? 200);
3388
+ while (true) {
3389
+ if (signal?.aborted) return;
3390
+ const now2 = Date.now();
3391
+ const due = await db.find({
3392
+ collection: TRASH_COLLECTION,
3393
+ where: {
3394
+ purgeAt: { lte: now2, not_equals: null }
3395
+ },
3396
+ limit: batchSize,
3397
+ sort: "purgeAt"
3398
+ });
3399
+ if (!due.docs.length) break;
3400
+ for (const entry of due.docs) {
3401
+ if (signal?.aborted) return;
3402
+ if (entry.purgeAt == null || typeof entry.purgeAt !== "number") continue;
3403
+ try {
3404
+ const colSlug = entry.collection;
3405
+ const colConfig = config.collections.find((c) => c.slug === colSlug);
3406
+ const snapshot = typeof entry.snapshot === "string" ? JSON.parse(entry.snapshot) : entry.snapshot;
3407
+ if (colConfig?.upload && config.storage && snapshot?.filename) {
3408
+ try {
3409
+ await config.storage.delete({ filename: snapshot.filename });
3410
+ if (snapshot.sizes && typeof snapshot.sizes === "object") {
3411
+ for (const size of Object.values(snapshot.sizes)) {
3412
+ if (size?.filename) {
3413
+ await config.storage.delete({ filename: size.filename });
3414
+ }
3415
+ }
3416
+ }
3417
+ } catch (storageErr) {
3418
+ logger.warn({ err: storageErr?.message || storageErr, docId: entry.docId, collection: colSlug }, "Failed to delete storage file on purge");
3419
+ }
3420
+ }
3421
+ if (colConfig?.hooks?.afterDelete) {
3422
+ await runCollectionHooks(
3423
+ colConfig.hooks.afterDelete,
3424
+ {
3425
+ id: entry.docId,
3426
+ doc: snapshot,
3427
+ user: void 0,
3428
+ req: {},
3429
+ db,
3430
+ mode: "permanent"
3431
+ },
3432
+ { isolated: true }
3433
+ );
3434
+ }
3435
+ if (colConfig?.audit) {
3436
+ try {
3437
+ await db.create({
3438
+ collection: "__audit",
3439
+ data: {
3440
+ collection: colSlug,
3441
+ documentId: entry.docId,
3442
+ operation: "purge",
3443
+ user: null,
3444
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3445
+ changes: JSON.stringify({
3446
+ before: snapshot,
3447
+ after: null
3448
+ })
3449
+ }
3450
+ });
3451
+ } catch (auditErr) {
3452
+ logger.warn({ err: auditErr?.message || auditErr, docId: entry.docId, collection: colSlug }, "Failed to write audit log on purge");
3453
+ }
3454
+ }
3455
+ await db.delete({ collection: TRASH_COLLECTION, id: entry.id });
3456
+ } catch (entryErr) {
3457
+ logger.error({ err: entryErr?.message || entryErr, entryId: entry.id }, "Error purging trash entry");
3458
+ }
3459
+ }
3460
+ if (due.docs.length < batchSize) break;
3461
+ }
3462
+ }
3463
+ };
3464
+ }
3465
+ async function softDeleteDocument(options) {
3466
+ const { db, config, collection: collectionSlug, id, deletedBy } = options;
3467
+ const colConfig = config.collections.find((c) => c.slug === collectionSlug);
3468
+ if (!colConfig) throw new Error(`Collection ${collectionSlug} not found`);
3469
+ const doc = await db.findOne({ collection: collectionSlug, id });
3470
+ if (!doc) throw new Error(`Document ${id} in ${collectionSlug} not found`);
3471
+ const resolvedTrash = resolveTrashConfig(colConfig, config);
3472
+ const deletedAt = Date.now();
3473
+ const purgeAt = resolvedTrash.retentionDays !== null && resolvedTrash.retentionDays !== void 0 ? deletedAt + resolvedTrash.retentionDays * 864e5 : null;
3474
+ const title = resolveDocumentTitle(colConfig, doc);
3475
+ const trashId = getTrashEntryId(collectionSlug, id);
3476
+ const trashData = {
3477
+ id: trashId,
3478
+ collection: collectionSlug,
3479
+ docId: id,
3480
+ deletedAt,
3481
+ purgeAt,
3482
+ deletedBy: deletedBy ?? null,
3483
+ title,
3484
+ snapshot: doc,
3485
+ createdAt: doc.createdAt ?? new Date(deletedAt).toISOString()
3486
+ };
3487
+ if (db.transaction) {
3488
+ await db.transaction(async (tx) => {
3489
+ await tx.create({ collection: TRASH_COLLECTION, data: trashData });
3490
+ await tx.delete({ collection: collectionSlug, id });
3491
+ });
3492
+ } else {
3493
+ await db.create({ collection: TRASH_COLLECTION, data: trashData });
3494
+ await db.delete({ collection: collectionSlug, id });
3495
+ }
3496
+ return { id: trashId, purgeAt };
3497
+ }
3498
+ async function restoreDocument(options) {
3499
+ const { db, config, collection: collectionSlug, trashId: param, overrides } = options;
3500
+ const colConfig = config.collections.find((c) => c.slug === collectionSlug);
3501
+ if (!colConfig) throw new Error(`Collection ${collectionSlug} not found`);
3502
+ const trashId = param.includes(":") ? param : getTrashEntryId(collectionSlug, param);
3503
+ const entry = await db.findOne({ collection: TRASH_COLLECTION, id: trashId }) || await db.findOne({ collection: TRASH_COLLECTION, id: param });
3504
+ if (!entry || entry.collection !== collectionSlug) {
3505
+ throw new Error(`Trash entry ${param} not found for collection ${collectionSlug}`);
3506
+ }
3507
+ const snapshot = typeof entry.snapshot === "string" ? JSON.parse(entry.snapshot) : entry.snapshot;
3508
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
3509
+ const docToRestore = {
3510
+ ...snapshot,
3511
+ ...overrides || {},
3512
+ id: entry.docId,
3513
+ createdAt: snapshot.createdAt ?? entry.createdAt ?? now2,
3514
+ updatedAt: now2
3515
+ };
3516
+ if (db.transaction) {
3517
+ await db.transaction(async (tx) => {
3518
+ await tx.create({ collection: collectionSlug, data: docToRestore });
3519
+ await tx.delete({ collection: TRASH_COLLECTION, id: entry.id });
3520
+ });
3521
+ } else {
3522
+ await db.create({ collection: collectionSlug, data: docToRestore });
3523
+ await db.delete({ collection: TRASH_COLLECTION, id: entry.id });
3271
3524
  }
3525
+ return { restoredDoc: docToRestore };
3272
3526
  }
3273
- var BUILTIN_JEXL_HELPERS = [
3274
- // Strings
3275
- "slugify",
3276
- "lower",
3277
- "upper",
3278
- "trim",
3279
- "capitalize",
3280
- "truncate",
3281
- "readingTime",
3282
- "wordCount",
3283
- "replace",
3284
- "startsWith",
3285
- "endsWith",
3286
- // Dates
3287
- "now",
3288
- "today",
3289
- "formatDate",
3290
- "addDays",
3291
- "diffDays",
3292
- "isPast",
3293
- "isFuture",
3294
- // Arrays
3295
- "includes",
3296
- "join",
3297
- "first",
3298
- "last",
3299
- "compact",
3300
- "unique",
3301
- "length",
3302
- // Math
3303
- "round",
3304
- "clamp",
3305
- // Objects & Logical
3306
- "default",
3307
- "coalesce",
3308
- "isEmpty",
3309
- "get"
3310
- ];
3311
3527
 
3312
- // src/utils/declarative-hooks.ts
3313
- registerJexlHelpers(import_jexl2.default);
3314
- var SURFACE_ROOTS = {
3315
- "collection.beforeRead": ["req", "user", "query", ...BUILTIN_JEXL_HELPERS],
3316
- "collection.afterRead": ["req", "user", "doc", ...BUILTIN_JEXL_HELPERS],
3317
- "collection.beforeChange": ["req", "user", "data", "doc", "operation", ...BUILTIN_JEXL_HELPERS],
3318
- "global.beforeRead": ["req", "user", "query", ...BUILTIN_JEXL_HELPERS],
3319
- "global.afterRead": ["req", "user", "doc", ...BUILTIN_JEXL_HELPERS],
3320
- "global.beforeChange": ["req", "user", "data", "doc", "operation", ...BUILTIN_JEXL_HELPERS],
3321
- "field.beforeChange": ["value", "data", "originalDoc", "user", ...BUILTIN_JEXL_HELPERS],
3322
- "field.admin.onChange": ["value", "siblingData", "data", ...BUILTIN_JEXL_HELPERS]
3528
+ // src/tasks.ts
3529
+ var TASK_LOCKS_COLLECTION = "__task_locks";
3530
+ var TASK_LOCKS_COLLECTION_CONFIG = {
3531
+ slug: TASK_LOCKS_COLLECTION,
3532
+ labels: { singular: "Task lock", plural: "Task locks" },
3533
+ fields: [
3534
+ { name: "name", type: "text", required: true },
3535
+ // Numeric and promoted so the lock compare-and-set is a real column comparison.
3536
+ { name: "lockedUntil", type: "number", required: true, promoted: true },
3537
+ { name: "lockedBy", type: "text" },
3538
+ { name: "lastScheduledFor", type: "date" },
3539
+ { name: "lastStartedAt", type: "date" },
3540
+ { name: "lastFinishedAt", type: "date" },
3541
+ { name: "lastStatus", type: "text" },
3542
+ { name: "lastError", type: "textarea" }
3543
+ ],
3544
+ access: {
3545
+ read: ({ user }) => !!user?.roles?.includes("admin"),
3546
+ create: () => false,
3547
+ update: () => false,
3548
+ delete: () => false
3549
+ },
3550
+ admin: { hidden: true }
3323
3551
  };
3324
- var ACCESS_ROOTS = ["user", "req", "doc", "data", "id", "config", ...BUILTIN_JEXL_HELPERS];
3325
- var ADMIN_CONDITION_RESERVED_ROOTS = ["data", "siblingData", "user", "id", ...BUILTIN_JEXL_HELPERS];
3326
- var PREVIEW_URL_RESERVED_ROOTS = ["siteUrl", "id", ...BUILTIN_JEXL_HELPERS];
3327
- var COMPILE_CACHE = /* @__PURE__ */ new Map();
3328
- function compileExpression(expression) {
3329
- const cached = COMPILE_CACHE.get(expression);
3330
- if (cached) return cached;
3331
- const compiled = import_jexl2.default.compile(expression);
3332
- const next = { ast: compiled._ast };
3333
- COMPILE_CACHE.set(expression, next);
3334
- return next;
3335
- }
3336
- function isPlainObject(value) {
3337
- return !!value && typeof value === "object" && !Array.isArray(value);
3552
+ var DEFAULT_LOCK_TIMEOUT_SECONDS = 300;
3553
+ var DEFAULT_TICK_MS = 3e4;
3554
+ var MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
3555
+ var DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
3556
+ function parseCronField(raw, min2, max2, names, nameOffset, label) {
3557
+ const values = /* @__PURE__ */ new Set();
3558
+ const toNumber = (token) => {
3559
+ const named = names?.indexOf(token.toLowerCase());
3560
+ if (named !== void 0 && named >= 0) return named + nameOffset;
3561
+ if (!/^\d+$/.test(token)) throw new Error(`Invalid cron ${label} "${token}".`);
3562
+ return Number(token);
3563
+ };
3564
+ for (const part of raw.split(",")) {
3565
+ const [rangePart, stepPart] = part.split("/");
3566
+ const step = stepPart === void 0 ? 1 : Number(stepPart);
3567
+ if (!Number.isInteger(step) || step < 1) throw new Error(`Invalid cron ${label} step "${part}".`);
3568
+ let start;
3569
+ let end;
3570
+ if (rangePart === "*") {
3571
+ start = min2;
3572
+ end = max2;
3573
+ } else if (rangePart.includes("-")) {
3574
+ const [a, b] = rangePart.split("-");
3575
+ start = toNumber(a);
3576
+ end = toNumber(b);
3577
+ } else {
3578
+ start = toNumber(rangePart);
3579
+ end = stepPart === void 0 ? start : max2;
3580
+ }
3581
+ if (start < min2 || end > max2 || start > end) {
3582
+ throw new Error(`Cron ${label} "${part}" is out of range (${min2}-${max2}).`);
3583
+ }
3584
+ for (let v = start; v <= end; v += step) values.add(v);
3585
+ }
3586
+ return values;
3338
3587
  }
3339
- function quoteSegment(value) {
3340
- return `["${value.replace(/"/g, '\\"')}"]`;
3588
+ function parseCron(expression) {
3589
+ const fields = expression.trim().split(/\s+/);
3590
+ if (fields.length !== 5) {
3591
+ throw new Error(`Cron expression "${expression}" must have 5 fields (minute hour day-of-month month day-of-week).`);
3592
+ }
3593
+ const [min2, hour, dom, mon, dow] = fields;
3594
+ const daysOfWeek = parseCronField(dow, 0, 7, DAY_NAMES, 0, "day-of-week");
3595
+ if (daysOfWeek.has(7)) daysOfWeek.add(0);
3596
+ daysOfWeek.delete(7);
3597
+ return {
3598
+ minutes: parseCronField(min2, 0, 59, void 0, 0, "minute"),
3599
+ hours: parseCronField(hour, 0, 23, void 0, 0, "hour"),
3600
+ daysOfMonth: parseCronField(dom, 1, 31, void 0, 0, "day-of-month"),
3601
+ months: parseCronField(mon, 1, 12, MONTH_NAMES, 1, "month"),
3602
+ daysOfWeek,
3603
+ dayFieldsBothRestricted: dom !== "*" && dow !== "*"
3604
+ };
3341
3605
  }
3342
- function fieldSegment(field, index) {
3343
- return typeof field.name === "string" && field.name.length > 0 ? `fields${quoteSegment(field.name)}` : `fields[${index}]`;
3606
+ function dayMatches(schedule, date) {
3607
+ const domOk = schedule.daysOfMonth.has(date.getUTCDate());
3608
+ const dowOk = schedule.daysOfWeek.has(date.getUTCDay());
3609
+ return schedule.dayFieldsBothRestricted ? domOk || dowOk : domOk && dowOk;
3344
3610
  }
3345
- function blockSegment(block, index) {
3346
- return typeof block.slug === "string" && block.slug.length > 0 ? `blocks${quoteSegment(block.slug)}` : `blocks[${index}]`;
3611
+ function nextCronRun(expression, after) {
3612
+ const schedule = typeof expression === "string" ? parseCron(expression) : expression;
3613
+ const cursor = new Date(after.getTime());
3614
+ cursor.setUTCSeconds(0, 0);
3615
+ cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
3616
+ const limit = after.getTime() + 5 * 366 * 24 * 60 * 60 * 1e3;
3617
+ while (cursor.getTime() <= limit) {
3618
+ if (!schedule.months.has(cursor.getUTCMonth() + 1)) {
3619
+ cursor.setUTCMonth(cursor.getUTCMonth() + 1, 1);
3620
+ cursor.setUTCHours(0, 0, 0, 0);
3621
+ continue;
3622
+ }
3623
+ if (!dayMatches(schedule, cursor)) {
3624
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
3625
+ cursor.setUTCHours(0, 0, 0, 0);
3626
+ continue;
3627
+ }
3628
+ if (!schedule.hours.has(cursor.getUTCHours())) {
3629
+ cursor.setUTCHours(cursor.getUTCHours() + 1, 0, 0, 0);
3630
+ continue;
3631
+ }
3632
+ if (!schedule.minutes.has(cursor.getUTCMinutes())) {
3633
+ cursor.setUTCMinutes(cursor.getUTCMinutes() + 1, 0, 0);
3634
+ continue;
3635
+ }
3636
+ return cursor;
3637
+ }
3638
+ throw new Error(`Cron expression "${typeof expression === "string" ? expression : "schedule"}" never matches.`);
3347
3639
  }
3348
- function collectionSegment(collection, index) {
3349
- return typeof collection.slug === "string" && collection.slug.length > 0 ? `collections${quoteSegment(collection.slug)}` : `collections[${index}]`;
3640
+ function defineTask(task) {
3641
+ if (!task.name || !task.name.trim()) throw new Error("A task needs a non-empty name.");
3642
+ if (task.cron !== void 0) parseCron(task.cron);
3643
+ if (task.lockTimeout !== void 0 && !(task.lockTimeout > 0)) {
3644
+ throw new Error(`Task "${task.name}" lockTimeout must be a positive number of seconds.`);
3645
+ }
3646
+ return task;
3350
3647
  }
3351
- function globalSegment(global, index) {
3352
- return typeof global.slug === "string" && global.slug.length > 0 ? `globals${quoteSegment(global.slug)}` : `globals[${index}]`;
3648
+ function resolveLogger(config, taskName) {
3649
+ const base = config.logger;
3650
+ if (base && typeof base.child === "function") return base.child({ component: "tasks", task: taskName });
3651
+ const prefix = `[dyrected/tasks] ${taskName}`;
3652
+ return {
3653
+ info: (obj, msg) => console.info(prefix, msg ?? "", obj),
3654
+ warn: (obj, msg) => console.warn(prefix, msg ?? "", obj),
3655
+ error: (obj, msg) => console.error(prefix, msg ?? "", obj)
3656
+ };
3353
3657
  }
3354
- function colorize(value, code, enabled) {
3355
- return enabled ? `\x1B[${code}m${value}\x1B[0m` : value;
3658
+ function floorToMinute(date) {
3659
+ const d = new Date(date.getTime());
3660
+ d.setUTCSeconds(0, 0);
3661
+ return d;
3356
3662
  }
3357
- function collectRootIdentifiers(node, roots, seen) {
3358
- if (!node || typeof node !== "object" || seen.has(node)) return;
3359
- seen.add(node);
3360
- const candidate = node;
3361
- if (candidate.type === "Identifier") {
3362
- if (candidate.from) {
3363
- collectRootIdentifiers(candidate.from, roots, seen);
3364
- return;
3663
+ function createTaskRunner(config) {
3664
+ const db = config.db;
3665
+ if (!db) throw new Error("createTaskRunner requires config.db.");
3666
+ const tasks = /* @__PURE__ */ new Map();
3667
+ for (const task of config.tasks ?? []) {
3668
+ if (tasks.has(task.name)) throw new Error(`Duplicate task name "${task.name}".`);
3669
+ tasks.set(task.name, { ...task, schedule: task.cron ? parseCron(task.cron) : void 0 });
3670
+ }
3671
+ const hasRetention = (config.collections ?? []).some((col) => {
3672
+ const resolved = resolveTrashConfig(col, config);
3673
+ return resolved.enabled && resolved.retentionDays !== null && resolved.retentionDays > 0;
3674
+ });
3675
+ if (hasRetention && !tasks.has("dyrected:trash-purge")) {
3676
+ const purgeTask = createTrashPurgeTask(config);
3677
+ tasks.set(purgeTask.name, { ...purgeTask, schedule: purgeTask.cron ? parseCron(purgeTask.cron) : void 0 });
3678
+ }
3679
+ const instanceId = globalThis.crypto?.randomUUID?.() ?? `inst_${Date.now()}_${Math.random().toString(36).slice(2)}`;
3680
+ let timer;
3681
+ let inFlight = Promise.resolve();
3682
+ let ticking = false;
3683
+ let ready;
3684
+ function ensureReady() {
3685
+ ready ??= Promise.resolve(db.sync?.([TASK_LOCKS_COLLECTION_CONFIG], [])).then(() => void 0);
3686
+ ready.catch(() => {
3687
+ ready = void 0;
3688
+ });
3689
+ return ready;
3690
+ }
3691
+ async function ensureLockRow(name, now2) {
3692
+ await ensureReady();
3693
+ const existing = await db.findOne({ collection: TASK_LOCKS_COLLECTION, id: name });
3694
+ if (existing) return existing;
3695
+ try {
3696
+ return await db.create({
3697
+ collection: TASK_LOCKS_COLLECTION,
3698
+ data: {
3699
+ id: name,
3700
+ name,
3701
+ lockedUntil: 0,
3702
+ lockedBy: null,
3703
+ lastScheduledFor: floorToMinute(now2).toISOString(),
3704
+ lastStartedAt: null,
3705
+ lastFinishedAt: null,
3706
+ lastStatus: null,
3707
+ lastError: null
3708
+ }
3709
+ });
3710
+ } catch (error) {
3711
+ const created = await db.findOne({ collection: TASK_LOCKS_COLLECTION, id: name });
3712
+ if (created) return created;
3713
+ throw error;
3365
3714
  }
3366
- if (typeof candidate.value === "string" && candidate.value !== "null") {
3367
- roots.add(candidate.value);
3715
+ }
3716
+ async function execute(task, row, scheduledFor) {
3717
+ const started = Date.now();
3718
+ const lockMs = (task.lockTimeout ?? DEFAULT_LOCK_TIMEOUT_SECONDS) * 1e3;
3719
+ const token = `${instanceId}:${started}`;
3720
+ const where = {
3721
+ id: { equals: task.name },
3722
+ lockedUntil: { less_than: started }
3723
+ };
3724
+ if (scheduledFor) where.lastScheduledFor = { equals: row.lastScheduledFor };
3725
+ const claimed = await db.update({
3726
+ collection: TASK_LOCKS_COLLECTION,
3727
+ where,
3728
+ data: {
3729
+ lockedUntil: started + lockMs,
3730
+ lockedBy: token,
3731
+ lastStartedAt: new Date(started).toISOString(),
3732
+ ...scheduledFor ? { lastScheduledFor: scheduledFor.toISOString() } : {}
3733
+ }
3734
+ });
3735
+ if (claimed.affectedRows !== 1) {
3736
+ return { name: task.name, status: "skipped", reason: "locked", durationMs: Date.now() - started };
3368
3737
  }
3369
- return;
3738
+ const logger = resolveLogger(config, task.name);
3739
+ const controller = new AbortController();
3740
+ let timeout;
3741
+ let status = "completed";
3742
+ let errorMessage;
3743
+ try {
3744
+ await Promise.race([
3745
+ Promise.resolve(task.run({ db, logger, signal: controller.signal, scheduledFor })),
3746
+ new Promise((_, reject) => {
3747
+ timeout = setTimeout(() => {
3748
+ reject(new Error(`Task exceeded its lockTimeout of ${lockMs / 1e3}s.`));
3749
+ controller.abort();
3750
+ }, lockMs);
3751
+ })
3752
+ ]);
3753
+ } catch (error) {
3754
+ status = "failed";
3755
+ errorMessage = error instanceof Error ? error.message : String(error);
3756
+ logger.error({ err: error }, "Task failed");
3757
+ } finally {
3758
+ if (timeout) clearTimeout(timeout);
3759
+ }
3760
+ try {
3761
+ await db.update({
3762
+ collection: TASK_LOCKS_COLLECTION,
3763
+ where: { id: { equals: task.name }, lockedBy: { equals: token } },
3764
+ data: {
3765
+ lockedUntil: 0,
3766
+ lockedBy: null,
3767
+ lastFinishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3768
+ lastStatus: status,
3769
+ lastError: errorMessage ?? null
3770
+ }
3771
+ });
3772
+ } catch (error) {
3773
+ logger.warn({ err: error }, "Could not release task lock; it will expire on its own");
3774
+ }
3775
+ return { name: task.name, status, error: errorMessage, durationMs: Date.now() - started };
3370
3776
  }
3371
- if (Array.isArray(node)) {
3372
- for (const entry of node) collectRootIdentifiers(entry, roots, seen);
3373
- return;
3777
+ function track(work) {
3778
+ inFlight = inFlight.then(() => work).catch(() => void 0);
3779
+ return work;
3374
3780
  }
3375
- for (const value of Object.values(node)) {
3376
- if (Array.isArray(value)) {
3377
- for (const entry of value) collectRootIdentifiers(entry, roots, seen);
3378
- continue;
3379
- }
3380
- if (value && typeof value === "object") {
3381
- collectRootIdentifiers(value, roots, seen);
3781
+ const runner = {
3782
+ async runTask(name) {
3783
+ const task = tasks.get(name);
3784
+ if (!task) throw new Error(`Unknown task "${name}".`);
3785
+ const row = await ensureLockRow(name, /* @__PURE__ */ new Date());
3786
+ return track(execute(task, row, void 0));
3787
+ },
3788
+ async runDue(now2 = /* @__PURE__ */ new Date()) {
3789
+ const results = [];
3790
+ for (const task of tasks.values()) {
3791
+ if (!task.schedule) continue;
3792
+ const row = await ensureLockRow(task.name, now2);
3793
+ const reference = new Date(String(row.lastScheduledFor));
3794
+ const due = Number.isNaN(reference.getTime()) ? true : nextCronRun(task.schedule, reference).getTime() <= now2.getTime();
3795
+ if (!due) {
3796
+ results.push({ name: task.name, status: "skipped", reason: "not-due", durationMs: 0 });
3797
+ continue;
3798
+ }
3799
+ results.push(await track(execute(task, row, floorToMinute(now2))));
3800
+ }
3801
+ return results;
3802
+ },
3803
+ start(options) {
3804
+ if (timer) return;
3805
+ const intervalMs = options?.intervalMs ?? DEFAULT_TICK_MS;
3806
+ const tick = () => {
3807
+ if (ticking) return;
3808
+ ticking = true;
3809
+ runner.runDue().catch((error) => resolveLogger(config, "runner").error({ err: error }, "Task tick failed")).finally(() => {
3810
+ ticking = false;
3811
+ });
3812
+ };
3813
+ timer = setInterval(tick, intervalMs);
3814
+ timer.unref?.();
3815
+ setTimeout(tick, 0);
3816
+ },
3817
+ async stop() {
3818
+ if (timer) clearInterval(timer);
3819
+ timer = void 0;
3820
+ await inFlight;
3821
+ },
3822
+ getTasks() {
3823
+ return Array.from(tasks.values()).map((t) => ({ name: t.name, cron: t.cron }));
3382
3824
  }
3383
- }
3384
- }
3385
- function getInvalidRoots(expression, allowedRoots) {
3386
- const { ast } = compileExpression(expression);
3387
- const roots = /* @__PURE__ */ new Set();
3388
- collectRootIdentifiers(ast, roots, /* @__PURE__ */ new Set());
3389
- const allowed = new Set(allowedRoots);
3390
- return [...roots].filter((root) => !allowed.has(root));
3825
+ };
3826
+ return runner;
3391
3827
  }
3392
- function validateDeclarativeHookExpression(expression, surface, path) {
3393
- try {
3394
- const invalidRoots = getInvalidRoots(expression, SURFACE_ROOTS[surface]);
3395
- if (invalidRoots.length === 0) return [];
3396
- return [
3828
+
3829
+ // src/workflows.ts
3830
+ var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
3831
+ var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
3832
+ var EDITOR_CAPABILITIES = ["entry.edit", "entry.submit"];
3833
+ var PUBLISHER_CAPABILITIES = ["entry.edit", "entry.submit", "entry.publish", "entry.unpublish"];
3834
+ function definePublishingWorkflow(options = {}) {
3835
+ const editors = options.editors ?? ["editor"];
3836
+ const publishers = options.publishers ?? ["publisher", "admin"];
3837
+ return {
3838
+ initialState: "draft",
3839
+ draftState: "draft",
3840
+ states: [
3841
+ { name: "draft", label: "Draft", color: "neutral" },
3842
+ { name: "in_review", label: "In review", color: "warning" },
3843
+ { name: "published", label: "Published", color: "success", published: true }
3844
+ ],
3845
+ transitions: [
3397
3846
  {
3398
- path,
3399
- surface,
3400
- expression,
3401
- message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${SURFACE_ROOTS[surface].map((root) => `"${root}"`).join(", ")}`
3402
- }
3403
- ];
3404
- } catch (error) {
3405
- return [
3847
+ name: "submit",
3848
+ label: "Submit for review",
3849
+ from: "draft",
3850
+ to: "in_review",
3851
+ requiredCapabilities: ["entry.submit"]
3852
+ },
3406
3853
  {
3407
- path,
3408
- surface,
3409
- expression,
3410
- message: `has invalid syntax: ${error.message}`
3411
- }
3412
- ];
3413
- }
3414
- }
3415
- function validateDeclarativeAccessExpression(expression, surface, path) {
3416
- try {
3417
- const invalidRoots = getInvalidRoots(expression, ACCESS_ROOTS);
3418
- if (invalidRoots.length === 0) return [];
3419
- return [
3854
+ name: "publish",
3855
+ label: "Publish",
3856
+ from: "in_review",
3857
+ to: "published",
3858
+ requiredCapabilities: ["entry.publish"]
3859
+ },
3420
3860
  {
3421
- path,
3422
- surface,
3423
- expression,
3424
- message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${ACCESS_ROOTS.map((root) => `"${root}"`).join(", ")}`
3425
- }
3426
- ];
3427
- } catch (error) {
3428
- return [
3861
+ name: "reject",
3862
+ label: "Request changes",
3863
+ from: "in_review",
3864
+ to: "draft",
3865
+ requiredCapabilities: ["entry.publish"],
3866
+ requireComment: true
3867
+ },
3429
3868
  {
3430
- path,
3431
- surface,
3432
- expression,
3433
- message: `has invalid syntax: ${error.message}`
3869
+ name: "unpublish",
3870
+ label: "Unpublish",
3871
+ from: "published",
3872
+ to: "draft",
3873
+ requiredCapabilities: ["entry.unpublish"],
3874
+ unpublish: true
3434
3875
  }
3435
- ];
3436
- }
3876
+ ],
3877
+ roles: [
3878
+ ...editors.map((role) => ({ role, capabilities: [...EDITOR_CAPABILITIES] })),
3879
+ ...publishers.map((role) => ({ role, capabilities: [...PUBLISHER_CAPABILITIES] }))
3880
+ ]
3881
+ };
3437
3882
  }
3438
- function validateAdminConditionExpression(expression, path, allowedRoots) {
3439
- try {
3440
- const invalidRoots = getInvalidRoots(expression, allowedRoots);
3441
- if (invalidRoots.length === 0) return [];
3442
- return [
3443
- {
3444
- path,
3445
- surface: "field.admin.condition",
3446
- expression,
3447
- message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${allowedRoots.map((root) => `"${root}"`).join(", ")}`
3448
- }
3449
- ];
3450
- } catch (error) {
3451
- return [
3452
- {
3453
- path,
3454
- surface: "field.admin.condition",
3455
- expression,
3456
- message: `has invalid syntax: ${error.message}`
3457
- }
3458
- ];
3459
- }
3883
+ function publishingWorkflow() {
3884
+ return definePublishingWorkflow();
3460
3885
  }
3461
- function validatePreviewUrlExpression(expression, path, allowedRoots) {
3462
- try {
3463
- const invalidRoots = getInvalidRoots(expression, allowedRoots);
3464
- if (invalidRoots.length === 0) return [];
3465
- return [
3466
- {
3467
- path,
3468
- surface: "collection.admin.previewUrl",
3469
- expression,
3470
- message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${allowedRoots.map((root) => `"${root}"`).join(", ")}`
3471
- }
3472
- ];
3473
- } catch (error) {
3474
- return [
3475
- {
3476
- path,
3477
- surface: "collection.admin.previewUrl",
3478
- expression,
3479
- message: `has invalid syntax: ${error.message}`
3480
- }
3481
- ];
3482
- }
3886
+ function simplePublishingWorkflow() {
3887
+ return {
3888
+ initialState: "draft",
3889
+ draftState: "draft",
3890
+ states: [
3891
+ { name: "draft", label: "Draft", color: "neutral" },
3892
+ { name: "published", label: "Published", color: "success", published: true }
3893
+ ],
3894
+ transitions: [
3895
+ { name: "publish", label: "Publish", from: ["draft", "published"], to: "published" },
3896
+ { name: "unpublish", label: "Unpublish", from: "published", to: "draft", unpublish: true }
3897
+ ]
3898
+ // Intentionally no `roles`: this lightweight workflow (synthesized from
3899
+ // `drafts: true`) does no capability-based gating. Draft visibility for a
3900
+ // no-roles workflow is handled in `canViewWorkflowDraft`.
3901
+ };
3483
3902
  }
3484
- function assertValidDeclarativeHookExpression(expression, surface, path) {
3485
- const issues = validateDeclarativeHookExpression(expression, surface, path);
3486
- if (issues.length === 0) return;
3487
- throw new Error(`Invalid declarative hook at ${path}: ${issues[0].message}`);
3903
+ function publicMetadata(meta) {
3904
+ const { availableTransitions: _availableTransitions, ...safe } = meta;
3905
+ return safe;
3488
3906
  }
3489
- function assertValidDeclarativeHookResult(result, surface, path) {
3490
- if (isPlainObject(result)) return;
3491
- throw new Error(`Invalid declarative hook result at ${path}: ${surface} expressions must return an object`);
3907
+ function workflowCapabilities(workflow, user) {
3908
+ const capabilities = new Set(Array.isArray(user?.capabilities) ? user.capabilities : []);
3909
+ const roles = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
3910
+ for (const mapping of workflow.roles ?? []) {
3911
+ if (roles.includes(mapping.role)) mapping.capabilities.forEach((capability) => capabilities.add(capability));
3912
+ }
3913
+ return capabilities;
3492
3914
  }
3493
- function collectFieldIssues(fields, path, issues) {
3494
- fields.forEach((field, index) => {
3495
- const fieldPath = `${path}.${fieldSegment(field, index)}`;
3496
- field.hooks?.beforeChange?.forEach((hook, hookIndex) => {
3497
- if (typeof hook !== "string") return;
3498
- issues.push(
3499
- ...validateDeclarativeHookExpression(
3500
- hook,
3501
- "field.beforeChange",
3502
- `${fieldPath}.hooks.beforeChange[${hookIndex}]`
3503
- )
3504
- );
3505
- });
3506
- field.hooks?.afterRead?.forEach((hook, hookIndex) => {
3507
- if (typeof hook !== "string") return;
3508
- issues.push({
3509
- path: `${fieldPath}.hooks.afterRead[${hookIndex}]`,
3510
- surface: "unsupported",
3511
- expression: hook,
3512
- message: "declarative strings are not supported on field hooks.afterRead"
3513
- });
3514
- });
3515
- if (typeof field.admin?.hooks?.onChange === "string") {
3516
- issues.push(
3517
- ...validateDeclarativeHookExpression(
3518
- field.admin.hooks.onChange,
3519
- "field.admin.onChange",
3520
- `${fieldPath}.admin.hooks.onChange`
3521
- )
3522
- );
3523
- }
3524
- if (typeof field.admin?.hooks?.options === "string") {
3525
- issues.push({
3526
- path: `${fieldPath}.admin.hooks.options`,
3527
- surface: "unsupported",
3528
- expression: field.admin.hooks.options,
3529
- message: "declarative strings are not supported on field admin.hooks.options"
3530
- });
3531
- }
3532
- if (Array.isArray(field.fields)) {
3533
- collectFieldIssues(field.fields, fieldPath, issues);
3534
- }
3535
- if (Array.isArray(field.blocks)) {
3536
- field.blocks.forEach((block, blockIndex) => {
3537
- if (!Array.isArray(block.fields)) return;
3538
- collectFieldIssues(block.fields, `${fieldPath}.${blockSegment(block, blockIndex)}`, issues);
3539
- });
3540
- }
3915
+ function canViewWorkflowDraft(workflow, user) {
3916
+ if (!user) return false;
3917
+ const capabilities = workflowCapabilities(workflow, user);
3918
+ if (capabilities.has("entry.edit")) return true;
3919
+ if (workflow.transitions.some(
3920
+ (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
3921
+ )) {
3922
+ return true;
3923
+ }
3924
+ if (!workflow.roles || workflow.roles.length === 0) return true;
3925
+ return false;
3926
+ }
3927
+ function availableWorkflowTransitions(workflow, state, user) {
3928
+ const capabilities = workflowCapabilities(workflow, user);
3929
+ return workflow.transitions.filter((transition) => {
3930
+ const from = Array.isArray(transition.from) ? transition.from : [transition.from];
3931
+ return from.includes(state) && (transition.requiredCapabilities ?? []).every((item) => capabilities.has(item));
3541
3932
  });
3542
3933
  }
3543
- function collectFieldNames(fields) {
3544
- return fields.flatMap(
3545
- (field) => typeof field.name === "string" && field.name.length > 0 ? [field.name] : []
3546
- );
3934
+ function initializeWorkflowDocument(data, workflow) {
3935
+ return {
3936
+ ...data,
3937
+ __workflow: { state: workflow.initialState, revision: 1 }
3938
+ };
3939
+ }
3940
+ function publishedStateName(workflow) {
3941
+ return workflow.states.find((state) => state.published)?.name ?? workflow.states[workflow.states.length - 1]?.name ?? workflow.initialState;
3547
3942
  }
3548
- function collectAdminConditionIssues(fields, path, rootFieldNames, issues) {
3549
- const scopeFieldNames = collectFieldNames(fields);
3550
- const allowedRoots = [.../* @__PURE__ */ new Set([...ADMIN_CONDITION_RESERVED_ROOTS, ...rootFieldNames, ...scopeFieldNames])];
3551
- fields.forEach((field, index) => {
3552
- const fieldPath = `${path}.${fieldSegment(field, index)}`;
3553
- if (typeof field.admin?.condition === "string") {
3554
- issues.push(
3555
- ...validateAdminConditionExpression(
3556
- field.admin.condition,
3557
- `${fieldPath}.admin.condition`,
3558
- allowedRoots
3559
- )
3560
- );
3561
- }
3562
- if (Array.isArray(field.fields)) {
3563
- collectAdminConditionIssues(field.fields, fieldPath, rootFieldNames, issues);
3943
+ function materializeWorkflowDocument(doc, workflow, user) {
3944
+ const meta = doc.__workflow;
3945
+ const publishedState = publishedStateName(workflow);
3946
+ if (!meta) {
3947
+ const { __published: _legacyPublished, __workflow: _legacyWorkflow, ...working2 } = doc;
3948
+ const state = publishedStateName(workflow);
3949
+ return {
3950
+ ...working2,
3951
+ _workflow: {
3952
+ state,
3953
+ revision: 1,
3954
+ availableTransitions: availableWorkflowTransitions(workflow, state, user).map((item) => item.name)
3955
+ }
3956
+ };
3957
+ }
3958
+ const { __published, __workflow, ...working } = doc;
3959
+ if (!canViewWorkflowDraft(workflow, user)) {
3960
+ if (!__published || typeof __published !== "object") {
3961
+ if (meta.state === publishedState) {
3962
+ return { ...working, _workflow: publicMetadata(meta) };
3963
+ }
3964
+ return null;
3564
3965
  }
3565
- if (Array.isArray(field.blocks)) {
3566
- field.blocks.forEach((block, blockIndex) => {
3567
- if (!Array.isArray(block.fields)) return;
3568
- collectAdminConditionIssues(
3569
- block.fields,
3570
- `${fieldPath}.${blockSegment(block, blockIndex)}`,
3571
- rootFieldNames,
3572
- issues
3573
- );
3574
- });
3966
+ return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
3967
+ }
3968
+ return {
3969
+ ...working,
3970
+ _workflow: {
3971
+ ...meta,
3972
+ availableTransitions: availableWorkflowTransitions(workflow, meta.state, user).map((item) => item.name)
3575
3973
  }
3576
- });
3974
+ };
3577
3975
  }
3578
- function collectFieldAccessIssues(fields, path, issues) {
3579
- fields.forEach((field, index) => {
3580
- const fieldPath = `${path}.${fieldSegment(field, index)}`;
3581
- if (typeof field.access?.read === "string") {
3582
- issues.push(...validateDeclarativeAccessExpression(field.access.read, "field.access", `${fieldPath}.access.read`));
3583
- }
3584
- if (typeof field.access?.create === "string") {
3585
- issues.push(
3586
- ...validateDeclarativeAccessExpression(field.access.create, "field.access", `${fieldPath}.access.create`)
3587
- );
3588
- }
3589
- if (typeof field.access?.update === "string") {
3590
- issues.push(
3591
- ...validateDeclarativeAccessExpression(field.access.update, "field.access", `${fieldPath}.access.update`)
3592
- );
3593
- }
3594
- if (Array.isArray(field.fields)) {
3595
- collectFieldAccessIssues(field.fields, fieldPath, issues);
3596
- }
3597
- if (Array.isArray(field.blocks)) {
3598
- field.blocks.forEach((block, blockIndex) => {
3599
- if (!Array.isArray(block.fields)) return;
3600
- collectFieldAccessIssues(
3601
- block.fields,
3602
- `${fieldPath}.${blockSegment(block, blockIndex)}`,
3603
- issues
3604
- );
3605
- });
3606
- }
3607
- });
3976
+ function eventId() {
3977
+ return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
3608
3978
  }
3609
- function collectHookIssues(hooks, scope, path, issues) {
3610
- if (!hooks) return;
3611
- const supported = {
3612
- beforeRead: `${scope}.beforeRead`,
3613
- afterRead: `${scope}.afterRead`,
3614
- beforeChange: `${scope}.beforeChange`
3979
+ function createLifecycleEvent(args) {
3980
+ return {
3981
+ id: eventId(),
3982
+ name: args.name,
3983
+ collection: args.collection,
3984
+ documentId: args.documentId,
3985
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
3986
+ actorId: args.actorId,
3987
+ payload: args.payload,
3988
+ attempts: 0,
3989
+ status: "pending"
3615
3990
  };
3616
- for (const [key, value] of Object.entries(hooks)) {
3617
- if (!Array.isArray(value)) continue;
3618
- value.forEach((hook, index) => {
3619
- if (typeof hook !== "string") return;
3620
- const surface = supported[key];
3621
- if (!surface) {
3622
- issues.push({
3623
- path: `${path}.${key}[${index}]`,
3624
- surface: "unsupported",
3625
- expression: hook,
3626
- message: `declarative strings are not supported on ${scope} hooks.${key}`
3627
- });
3628
- return;
3991
+ }
3992
+ async function persistEvent(db, event) {
3993
+ await db.create({ collection: LIFECYCLE_EVENTS_COLLECTION, data: event });
3994
+ }
3995
+ async function dispatchLifecycleEvent(config, event) {
3996
+ const db = config.db;
3997
+ if (!db || !config.events?.handlers.length) return;
3998
+ const maxAttempts = config.events.maxAttempts ?? 8;
3999
+ const retryDelayMs = config.events.retryDelayMs ?? 1e3;
4000
+ const attempts = event.attempts + 1;
4001
+ try {
4002
+ await db.update({
4003
+ collection: LIFECYCLE_EVENTS_COLLECTION,
4004
+ id: event.id,
4005
+ data: { status: "processing", attempts }
4006
+ });
4007
+ for (const handler of config.events.handlers) await handler({ ...event, status: "processing", attempts });
4008
+ await db.update({
4009
+ collection: LIFECYCLE_EVENTS_COLLECTION,
4010
+ id: event.id,
4011
+ data: { status: "delivered", attempts, deliveredAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null }
4012
+ });
4013
+ } catch (error) {
4014
+ const exhausted = attempts >= maxAttempts;
4015
+ await db.update({
4016
+ collection: LIFECYCLE_EVENTS_COLLECTION,
4017
+ id: event.id,
4018
+ data: {
4019
+ status: "failed",
4020
+ attempts,
4021
+ lastError: error instanceof Error ? error.message : String(error),
4022
+ nextAttemptAt: exhausted ? null : new Date(Date.now() + retryDelayMs * 2 ** (attempts - 1)).toISOString()
3629
4023
  }
3630
- issues.push(...validateDeclarativeHookExpression(hook, surface, `${path}.${key}[${index}]`));
3631
4024
  });
3632
4025
  }
3633
4026
  }
3634
- function collectDeclarativeHookValidationIssues(config) {
3635
- const issues = [];
3636
- config.blocks?.forEach((block, blockIndex) => {
3637
- if (!Array.isArray(block.fields)) return;
3638
- collectFieldIssues(
3639
- block.fields,
3640
- blockSegment(block, blockIndex),
3641
- issues
3642
- );
4027
+ async function dispatchPendingLifecycleEvents(config, limit = 50) {
4028
+ if (!config.db || !config.events?.handlers.length) return 0;
4029
+ const maxAttempts = config.events.maxAttempts ?? 8;
4030
+ const result = await config.db.find({
4031
+ collection: LIFECYCLE_EVENTS_COLLECTION,
4032
+ where: { status: { in: ["pending", "failed"] } },
4033
+ sort: "occurredAt",
4034
+ limit
3643
4035
  });
3644
- config.collections.forEach((collection, collectionIndex) => {
3645
- const collectionPath = collectionSegment(collection, collectionIndex);
3646
- collectHookIssues(
3647
- collection.hooks,
3648
- "collection",
3649
- `${collectionPath}.hooks`,
3650
- issues
3651
- );
3652
- collectFieldIssues(collection.fields, collectionPath, issues);
4036
+ const now2 = Date.now();
4037
+ const due = result.docs.filter(
4038
+ (doc) => Number(doc.attempts ?? 0) < maxAttempts && (!doc.nextAttemptAt || new Date(doc.nextAttemptAt).getTime() <= now2)
4039
+ );
4040
+ for (const event of due) await dispatchLifecycleEvent(config, event);
4041
+ return due.length;
4042
+ }
4043
+ async function saveWorkflowDraft(args) {
4044
+ const { config, collection, id, originalDoc, data, user, beforeCommit } = args;
4045
+ const db = config.db;
4046
+ const workflow = collection.workflow;
4047
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
4048
+ const previous = originalDoc.__workflow;
4049
+ const isLegacyPublishedDoc = !previous;
4050
+ const baselineRevision = previous?.revision ?? 1;
4051
+ const event = createLifecycleEvent({
4052
+ name: "revision.created",
4053
+ collection: collection.slug,
4054
+ documentId: id,
4055
+ actorId: user?.sub,
4056
+ payload: {
4057
+ revision: baselineRevision + 1,
4058
+ previousRevision: isLegacyPublishedDoc ? baselineRevision : previous?.revision ?? null
4059
+ }
3653
4060
  });
3654
- config.globals?.forEach((global, globalIndex) => {
3655
- const globalPath = globalSegment(global, globalIndex);
3656
- collectHookIssues(
3657
- global.hooks,
3658
- "global",
3659
- `${globalPath}.hooks`,
3660
- issues
3661
- );
3662
- collectFieldIssues(global.fields, globalPath, issues);
4061
+ const doc = await db.transaction(async (tx) => {
4062
+ const nextMeta = {
4063
+ ...previous ?? {},
4064
+ state: isLegacyPublishedDoc ? workflow.draftState ?? workflow.initialState : originalDoc.__published ? workflow.draftState ?? workflow.initialState : previous.state,
4065
+ revision: baselineRevision + 1,
4066
+ ...isLegacyPublishedDoc ? { publishedRevision: baselineRevision } : {}
4067
+ };
4068
+ const updateData = {
4069
+ ...data,
4070
+ __workflow: nextMeta
4071
+ };
4072
+ if (isLegacyPublishedDoc) {
4073
+ const { __workflow: _legacyWorkflow, __published: _legacyPublished, ...publishedSnapshot } = originalDoc;
4074
+ updateData.__published = publishedSnapshot;
4075
+ }
4076
+ const updated = await tx.update({ collection: collection.slug, id, data: updateData });
4077
+ await persistEvent(tx, event);
4078
+ await beforeCommit?.(tx, updated);
4079
+ return updated;
4080
+ });
4081
+ void dispatchLifecycleEvent(config, event);
4082
+ return { doc, event };
4083
+ }
4084
+ async function createWorkflowDocument(args) {
4085
+ const { config, collection, data, user, beforeCommit } = args;
4086
+ const db = config.db;
4087
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
4088
+ let event;
4089
+ const doc = await db.transaction(async (tx) => {
4090
+ const created = await tx.create({ collection: collection.slug, data });
4091
+ event = createLifecycleEvent({
4092
+ name: "revision.created",
4093
+ collection: collection.slug,
4094
+ documentId: created.id,
4095
+ actorId: user?.sub,
4096
+ payload: { revision: 1, previousRevision: null }
4097
+ });
4098
+ await persistEvent(tx, event);
4099
+ await beforeCommit?.(tx, created);
4100
+ return created;
3663
4101
  });
3664
- return issues;
4102
+ void dispatchLifecycleEvent(config, event);
4103
+ return { doc, event };
3665
4104
  }
3666
- function collectDeclarativeAccessValidationIssues(config) {
3667
- const issues = [];
3668
- Object.entries(config.accessPolicies ?? {}).forEach(([name, value]) => {
3669
- if (typeof value !== "string") return;
3670
- issues.push(
3671
- ...validateDeclarativeAccessExpression(value, "config.accessPolicy", `accessPolicies.${name}`)
4105
+ async function transitionWorkflow(args) {
4106
+ const { config, collection, id, transitionName, expectedRevision, comment, input, user, req } = args;
4107
+ const db = config.db;
4108
+ const workflow = collection.workflow;
4109
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
4110
+ const original = await db.findOne({ collection: collection.slug, id });
4111
+ if (!original) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
4112
+ const meta = original.__workflow;
4113
+ if (!meta) throw Object.assign(new Error("Entry has no workflow metadata"), { statusCode: 409 });
4114
+ if (expectedRevision !== void 0 && expectedRevision !== meta.revision) {
4115
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
4116
+ statusCode: 409
4117
+ });
4118
+ }
4119
+ const transition = workflow.transitions.find((item) => item.name === transitionName);
4120
+ const fromStates = transition ? Array.isArray(transition.from) ? transition.from : [transition.from] : [];
4121
+ if (!transition || !fromStates.includes(meta.state)) {
4122
+ throw Object.assign(new Error(`Transition "${transitionName}" is not valid from "${meta.state}".`), {
4123
+ statusCode: 409
4124
+ });
4125
+ }
4126
+ if (!availableWorkflowTransitions(workflow, meta.state, user).some((item) => item.name === transition.name)) {
4127
+ throw Object.assign(new Error(`You do not have permission to perform "${transition.label}".`), { statusCode: 403 });
4128
+ }
4129
+ if (transition.requireComment && !comment?.trim()) {
4130
+ throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
4131
+ }
4132
+ const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, input, req, db };
4133
+ for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
4134
+ const events = [
4135
+ createLifecycleEvent({
4136
+ name: "workflow.transitioned",
4137
+ collection: collection.slug,
4138
+ documentId: id,
4139
+ actorId: user?.sub,
4140
+ payload: { transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment }
4141
+ })
4142
+ ];
4143
+ const targetState = workflow.states.find((state) => state.name === transition.to);
4144
+ if (targetState.published) {
4145
+ events.push(
4146
+ createLifecycleEvent({
4147
+ name: "entry.published",
4148
+ collection: collection.slug,
4149
+ documentId: id,
4150
+ actorId: user?.sub,
4151
+ payload: { revision: meta.revision }
4152
+ })
3672
4153
  );
3673
- });
3674
- config.blocks?.forEach((block, blockIndex) => {
3675
- if (!Array.isArray(block.fields)) return;
3676
- collectFieldAccessIssues(
3677
- block.fields,
3678
- blockSegment(block, blockIndex),
3679
- issues
4154
+ } else if (transition.unpublish) {
4155
+ events.push(
4156
+ createLifecycleEvent({
4157
+ name: "entry.unpublished",
4158
+ collection: collection.slug,
4159
+ documentId: id,
4160
+ actorId: user?.sub,
4161
+ payload: { revision: meta.publishedRevision }
4162
+ })
3680
4163
  );
3681
- });
3682
- config.collections.forEach((collection, collectionIndex) => {
3683
- const collectionPath = collectionSegment(collection, collectionIndex);
3684
- if (typeof collection.access?.read === "string") {
3685
- issues.push(
3686
- ...validateDeclarativeAccessExpression(
3687
- collection.access.read,
3688
- "collection.access",
3689
- `${collectionPath}.access.read`
3690
- )
3691
- );
3692
- }
3693
- if (typeof collection.access?.create === "string") {
3694
- issues.push(
3695
- ...validateDeclarativeAccessExpression(
3696
- collection.access.create,
3697
- "collection.access",
3698
- `${collectionPath}.access.create`
3699
- )
3700
- );
3701
- }
3702
- if (typeof collection.access?.update === "string") {
3703
- issues.push(
3704
- ...validateDeclarativeAccessExpression(
3705
- collection.access.update,
3706
- "collection.access",
3707
- `${collectionPath}.access.update`
3708
- )
3709
- );
3710
- }
3711
- if (typeof collection.access?.delete === "string") {
3712
- issues.push(
3713
- ...validateDeclarativeAccessExpression(
3714
- collection.access.delete,
3715
- "collection.access",
3716
- `${collectionPath}.access.delete`
3717
- )
3718
- );
3719
- }
3720
- if (typeof collection.access?.readAudit === "string") {
3721
- issues.push(
3722
- ...validateDeclarativeAccessExpression(
3723
- collection.access.readAudit,
3724
- "collection.access",
3725
- `${collectionPath}.access.readAudit`
3726
- )
3727
- );
4164
+ }
4165
+ const updated = await db.transaction(async (tx) => {
4166
+ const locked = await tx.findOne({ collection: collection.slug, id });
4167
+ if (!locked) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
4168
+ const lockedMeta = locked.__workflow;
4169
+ if (lockedMeta.revision !== meta.revision || lockedMeta.state !== meta.state || expectedRevision !== void 0 && lockedMeta.revision !== expectedRevision) {
4170
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
4171
+ statusCode: 409
4172
+ });
3728
4173
  }
3729
- collectFieldAccessIssues(collection.fields, collectionPath, issues);
3730
- });
3731
- config.globals?.forEach((global, globalIndex) => {
3732
- const globalPath = globalSegment(global, globalIndex);
3733
- if (typeof global.access?.read === "string") {
3734
- issues.push(
3735
- ...validateDeclarativeAccessExpression(global.access.read, "global.access", `${globalPath}.access.read`)
3736
- );
4174
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
4175
+ const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
4176
+ const nextMeta = {
4177
+ ...lockedMeta,
4178
+ state: transition.to,
4179
+ ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now2, publishedBy: user?.sub } : {},
4180
+ ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
4181
+ };
4182
+ const data = { __workflow: nextMeta };
4183
+ if (targetState.published) data.__published = working;
4184
+ if (transition.unpublish) data.__published = null;
4185
+ const next = await tx.update({ collection: collection.slug, id, data });
4186
+ if (transition.onTransition) {
4187
+ await transition.onTransition({
4188
+ transition,
4189
+ from: lockedMeta.state,
4190
+ to: transition.to,
4191
+ doc: locked,
4192
+ input,
4193
+ comment,
4194
+ user,
4195
+ req,
4196
+ tx
4197
+ });
3737
4198
  }
3738
- if (typeof global.access?.update === "string") {
3739
- issues.push(
3740
- ...validateDeclarativeAccessExpression(
3741
- global.access.update,
3742
- "global.access",
3743
- `${globalPath}.access.update`
3744
- )
3745
- );
4199
+ await tx.create({
4200
+ collection: WORKFLOW_HISTORY_COLLECTION,
4201
+ data: {
4202
+ collection: collection.slug,
4203
+ documentId: id,
4204
+ transition: transition.name,
4205
+ from: lockedMeta.state,
4206
+ to: transition.to,
4207
+ revision: lockedMeta.revision,
4208
+ comment: comment ?? null,
4209
+ actorId: user?.sub ?? null,
4210
+ createdAt: now2
4211
+ }
4212
+ });
4213
+ for (const event of events) await persistEvent(tx, event);
4214
+ if (collection.audit) {
4215
+ await tx.create({
4216
+ collection: "__audit",
4217
+ data: {
4218
+ collection: collection.slug,
4219
+ documentId: id,
4220
+ operation: "workflow.transition",
4221
+ user: user?.sub ?? null,
4222
+ timestamp: now2,
4223
+ changes: JSON.stringify({
4224
+ transition: transition.name,
4225
+ from: lockedMeta.state,
4226
+ to: transition.to,
4227
+ revision: lockedMeta.revision
4228
+ })
4229
+ }
4230
+ });
3746
4231
  }
3747
- collectFieldAccessIssues(global.fields, globalPath, issues);
3748
- });
3749
- return issues;
3750
- }
3751
- function collectAdminConditionValidationIssues(config) {
3752
- const issues = [];
3753
- config.blocks?.forEach((block, blockIndex) => {
3754
- if (!Array.isArray(block.fields)) return;
3755
- collectAdminConditionIssues(
3756
- block.fields,
3757
- blockSegment(block, blockIndex),
3758
- collectFieldNames(block.fields),
3759
- issues
3760
- );
3761
- });
3762
- config.collections.forEach((collection, collectionIndex) => {
3763
- const collectionPath = collectionSegment(collection, collectionIndex);
3764
- const rootFieldNames = collectFieldNames(collection.fields);
3765
- collectAdminConditionIssues(collection.fields, collectionPath, rootFieldNames, issues);
3766
- });
3767
- config.globals?.forEach((global, globalIndex) => {
3768
- const globalPath = globalSegment(global, globalIndex);
3769
- const rootFieldNames = collectFieldNames(global.fields);
3770
- collectAdminConditionIssues(global.fields, globalPath, rootFieldNames, issues);
4232
+ return next;
3771
4233
  });
3772
- return issues;
4234
+ for (const event of events) void dispatchLifecycleEvent(config, event);
4235
+ for (const hook of workflow.hooks?.afterTransition ?? []) {
4236
+ try {
4237
+ await hook({ ...hookContext, doc: updated, event: events[0] });
4238
+ } catch (error) {
4239
+ try {
4240
+ const obs = config?.__observability;
4241
+ obs?.recordWorkflowHookFailure?.({
4242
+ collection: collection.slug,
4243
+ transition: transition.name
4244
+ });
4245
+ const logger = config?.logger;
4246
+ if (logger && typeof logger.child === "function") {
4247
+ logger.child({ component: "workflow" }).error({
4248
+ err: error,
4249
+ msg: "afterTransition hook failed",
4250
+ collection: collection.slug,
4251
+ transition: transition.name,
4252
+ documentId: id
4253
+ });
4254
+ } else if (logger && typeof logger.error === "function") {
4255
+ logger.error({
4256
+ err: error,
4257
+ msg: "afterTransition hook failed",
4258
+ collection: collection.slug,
4259
+ transition: transition.name,
4260
+ documentId: id
4261
+ });
4262
+ } else {
4263
+ console.error("afterTransition hook failed:", error);
4264
+ }
4265
+ } catch {
4266
+ console.error("afterTransition hook failed:", error);
4267
+ }
4268
+ }
4269
+ }
4270
+ return updated;
3773
4271
  }
3774
- function collectPreviewUrlValidationIssues(config) {
3775
- const issues = [];
3776
- config.collections.forEach((collection, collectionIndex) => {
3777
- if (typeof collection.admin?.previewUrl !== "string") return;
3778
- const collectionPath = collectionSegment(collection, collectionIndex);
3779
- const allowedRoots = [
3780
- .../* @__PURE__ */ new Set([...PREVIEW_URL_RESERVED_ROOTS, ...collectFieldNames(collection.fields)])
3781
- ];
3782
- issues.push(
3783
- ...validatePreviewUrlExpression(
3784
- collection.admin.previewUrl,
3785
- `${collectionPath}.admin.previewUrl`,
3786
- allowedRoots
3787
- )
3788
- );
3789
- });
3790
- return issues;
4272
+
4273
+ // src/utils/admin-auth.ts
4274
+ function getAdminAuthCollection(config) {
4275
+ const adminCollection = resolveAdminAuthCollection(config.collections, config.adminAuth?.collectionSlug);
4276
+ return adminCollection ?? null;
3791
4277
  }
3792
- function assertValidAdminConditionsInConfig(config, source = "config") {
3793
- const diagnostics = collectAdminConditionValidationIssues(config).map((issue) => ({
3794
- severity: "error",
3795
- source: "adminCondition",
3796
- path: issue.path,
3797
- surface: issue.surface,
3798
- message: issue.message,
3799
- expression: issue.expression
3800
- }));
3801
- if (diagnostics.length === 0) return;
3802
- throw new ConfigValidationError(source, diagnostics);
4278
+ function resolveAdminAuthCollection(collections, collectionSlug) {
4279
+ const adminsCollection = collections.find((collection) => collection.slug === "__admins");
4280
+ if (adminsCollection) return adminsCollection;
4281
+ if (collectionSlug) {
4282
+ const requestedCollection = collections.find((collection) => collection.slug === collectionSlug);
4283
+ if (requestedCollection) return requestedCollection;
4284
+ }
4285
+ return collections.find((collection) => collection.auth);
3803
4286
  }
3804
- function assertValidPreviewUrlsInConfig(config, source = "config") {
3805
- const diagnostics = collectPreviewUrlValidationIssues(config).map((issue) => ({
3806
- severity: "error",
3807
- source: "previewUrl",
3808
- path: issue.path,
3809
- surface: issue.surface,
3810
- message: issue.message,
3811
- expression: issue.expression
4287
+ function getPublicAdminAuthConfig(adminAuth, collections) {
4288
+ const providers = (adminAuth?.providers ?? []).map((provider) => ({
4289
+ id: provider.id,
4290
+ type: provider.type,
4291
+ displayName: provider.displayName || humanizeProviderName(provider.id, provider.type),
4292
+ autoRedirect: provider.autoRedirect
3812
4293
  }));
3813
- if (diagnostics.length === 0) return;
3814
- throw new ConfigValidationError(source, diagnostics);
4294
+ const resolvedCollectionSlug = collections ? resolveAdminAuthCollection(collections, adminAuth?.collectionSlug)?.slug : adminAuth?.collectionSlug;
4295
+ return {
4296
+ mode: adminAuth?.mode ?? "local",
4297
+ collectionSlug: resolvedCollectionSlug,
4298
+ provisioningMode: adminAuth?.provisioningMode,
4299
+ providers
4300
+ };
3815
4301
  }
3816
- function collectConfigDiagnostics(config) {
3817
- const hookIssues = collectDeclarativeHookValidationIssues(config).map((issue) => ({
3818
- severity: "error",
3819
- source: "declarativeHook",
3820
- path: issue.path,
3821
- surface: issue.surface,
3822
- message: issue.message,
3823
- expression: issue.expression
3824
- }));
3825
- const accessIssues = collectDeclarativeAccessValidationIssues(config).map((issue) => ({
3826
- severity: "error",
3827
- source: "declarativeAccess",
3828
- path: issue.path,
3829
- surface: issue.surface,
3830
- message: issue.message,
3831
- expression: issue.expression
3832
- }));
3833
- const adminConditionIssues = collectAdminConditionValidationIssues(config).map((issue) => ({
3834
- severity: "error",
3835
- source: "adminCondition",
3836
- path: issue.path,
3837
- surface: issue.surface,
3838
- message: issue.message,
3839
- expression: issue.expression
3840
- }));
3841
- const previewUrlIssues = collectPreviewUrlValidationIssues(config).map((issue) => ({
3842
- severity: "error",
3843
- source: "previewUrl",
3844
- path: issue.path,
3845
- surface: issue.surface,
3846
- message: issue.message,
3847
- expression: issue.expression
3848
- }));
3849
- return [...hookIssues, ...accessIssues, ...adminConditionIssues, ...previewUrlIssues];
4302
+ function humanizeProviderName(id, type) {
4303
+ const cleaned = id.replace(/[-_]+/g, " ").trim();
4304
+ if (!cleaned) return type.toUpperCase();
4305
+ return cleaned.replace(/\b\w/g, (char) => char.toUpperCase());
4306
+ }
4307
+ function getAdminRoleForCollection(collection) {
4308
+ if (!collection?.auth) return "admin";
4309
+ if (typeof collection.auth === "object" && collection.auth.adminRole) {
4310
+ return collection.auth.adminRole;
4311
+ }
4312
+ return "admin";
4313
+ }
4314
+ function isUserAdmin(user, collection) {
4315
+ if (!user) return false;
4316
+ const roles = Array.isArray(user.roles) ? user.roles : typeof user.role === "string" ? [user.role] : [];
4317
+ if (roles.length === 0) return false;
4318
+ const configuredRole = getAdminRoleForCollection(collection);
4319
+ if (roles.includes(configuredRole)) return true;
4320
+ return roles.includes("admin") || roles.includes("super_admin");
3850
4321
  }
3851
- function formatConfigDiagnostics(source, diagnostics, options = {}) {
3852
- const color = options.color ?? false;
3853
- const header = `${colorize("Config validation failed", 31, color)} in ${colorize(source, 36, color)}`;
3854
- const docLink = `${colorize(" Learn how to write declarative rules:", 90, color)} ${colorize("https://docs.dyrected.com/docs/editor-experience/form-and-field-hooks", 34, color)}`;
3855
- const lines = diagnostics.flatMap((issue, index) => [
3856
- `${colorize(`${index + 1}.`, 31, color)} ${colorize(issue.path, 1, color)}`,
3857
- ` ${issue.message}`,
3858
- ` ${colorize("surface:", 90, color)} ${issue.surface}`
3859
- ]);
3860
- return `${header}
3861
- ${lines.join("\n")}
3862
4322
 
3863
- ${docLink}`;
4323
+ // src/utils/block-references.ts
4324
+ function mergeUniqueBlocks(blocks) {
4325
+ const seen = /* @__PURE__ */ new Map();
4326
+ for (const block of blocks) {
4327
+ const existing = seen.get(block.slug);
4328
+ if (existing && existing !== block) {
4329
+ throw new Error(
4330
+ `Duplicate block slug "${block.slug}" found in the reusable block registry. Block slugs must be unique.`
4331
+ );
4332
+ }
4333
+ if (!existing) seen.set(block.slug, block);
4334
+ }
4335
+ return Array.from(seen.values());
3864
4336
  }
3865
- var ConfigValidationError = class extends Error {
3866
- diagnostics;
3867
- source;
3868
- constructor(source, diagnostics) {
3869
- super(formatConfigDiagnostics(source, diagnostics));
3870
- this.name = "ConfigValidationError";
3871
- this.source = source;
3872
- this.diagnostics = diagnostics;
4337
+ function buildRegistry(blocks) {
4338
+ return new Map(blocks.map((block) => [block.slug, block]));
4339
+ }
4340
+ function resolveField(field, registry, blockCache) {
4341
+ const next = { ...field };
4342
+ if (next.fields) {
4343
+ next.fields = next.fields.map(
4344
+ (child) => resolveField(child, registry, blockCache)
4345
+ );
3873
4346
  }
3874
- };
3875
- function isConfigValidationError(error) {
3876
- return error instanceof ConfigValidationError;
4347
+ if (next.type !== "blocks") return next;
4348
+ const hasInlineBlocks = Array.isArray(next.blocks) && next.blocks.length > 0;
4349
+ const hasReferences = Array.isArray(next.blockReferences) && next.blockReferences.length > 0;
4350
+ if (hasInlineBlocks && hasReferences) {
4351
+ throw new Error(
4352
+ `Blocks field "${next.name ?? "(unnamed)"}" cannot define both "blocks" and "blockReferences". Use one or the other.`
4353
+ );
4354
+ }
4355
+ if (hasReferences) {
4356
+ next.blocks = next.blockReferences.map((slug) => {
4357
+ const block = registry.get(slug);
4358
+ if (!block) {
4359
+ throw new Error(
4360
+ `Unknown block reference "${slug}" on blocks field "${next.name ?? "(unnamed)"}". Add it to defineConfig({ blocks: [...] }).`
4361
+ );
4362
+ }
4363
+ return resolveBlock(block, registry, blockCache);
4364
+ });
4365
+ return next;
4366
+ }
4367
+ if (hasInlineBlocks) {
4368
+ next.blocks = next.blocks.map(
4369
+ (block) => resolveBlock(block, registry, blockCache)
4370
+ );
4371
+ }
4372
+ return next;
3877
4373
  }
3878
- function assertValidDeclarativeHooksInConfig(config, source = "config") {
3879
- const diagnostics = collectDeclarativeHookValidationIssues(config).map((issue) => ({
3880
- severity: "error",
3881
- source: "declarativeHook",
3882
- path: issue.path,
3883
- surface: issue.surface,
3884
- message: issue.message,
3885
- expression: issue.expression
3886
- }));
3887
- if (diagnostics.length === 0) return;
3888
- throw new ConfigValidationError(source, diagnostics);
4374
+ function resolveBlock(block, registry, blockCache) {
4375
+ const cached = blockCache.get(block);
4376
+ if (cached) return cached;
4377
+ const resolved = {
4378
+ ...block,
4379
+ fields: []
4380
+ };
4381
+ blockCache.set(block, resolved);
4382
+ resolved.fields = block.fields.map(
4383
+ (field) => resolveField(field, registry, blockCache)
4384
+ );
4385
+ return resolved;
3889
4386
  }
3890
- function assertValidDeclarativeAccessInConfig(config, source = "config") {
3891
- const diagnostics = collectDeclarativeAccessValidationIssues(config).map((issue) => ({
3892
- severity: "error",
3893
- source: "declarativeAccess",
3894
- path: issue.path,
3895
- surface: issue.surface,
3896
- message: issue.message,
3897
- expression: issue.expression
4387
+ function resolveFields(fields, registry, blockCache) {
4388
+ return fields.map((field) => resolveField(field, registry, blockCache));
4389
+ }
4390
+ function normalizeSchemaFragment(fragment) {
4391
+ const reusableBlocks = mergeUniqueBlocks(fragment.blocks ?? []);
4392
+ const registry = buildRegistry(reusableBlocks);
4393
+ const blockCache = /* @__PURE__ */ new WeakMap();
4394
+ const resolvedBlocks = reusableBlocks.map(
4395
+ (block) => resolveBlock(block, registry, blockCache)
4396
+ );
4397
+ const resolvedCollections = (fragment.collections ?? []).map(
4398
+ (collection) => ({
4399
+ ...collection,
4400
+ fields: resolveFields(collection.fields ?? [], registry, blockCache)
4401
+ })
4402
+ );
4403
+ const resolvedGlobals = (fragment.globals ?? []).map((global) => ({
4404
+ ...global,
4405
+ fields: resolveFields(global.fields ?? [], registry, blockCache)
3898
4406
  }));
3899
- if (diagnostics.length === 0) return;
3900
- throw new ConfigValidationError(source, diagnostics);
4407
+ return {
4408
+ ...fragment,
4409
+ ...fragment.blocks ? { blocks: resolvedBlocks } : {},
4410
+ ...fragment.collections ? { collections: resolvedCollections } : {},
4411
+ ...fragment.globals ? { globals: resolvedGlobals } : {}
4412
+ };
3901
4413
  }
3902
4414
 
3903
4415
  // src/utils/id.ts
@@ -4081,7 +4593,7 @@ var AUDIT_COLLECTION = {
4081
4593
  name: "operation",
4082
4594
  type: "select",
4083
4595
  label: "Operation",
4084
- options: ["create", "update", "delete"],
4596
+ options: ["create", "update", "delete", "trash", "restore", "purge", "trash-empty"],
4085
4597
  required: true
4086
4598
  },
4087
4599
  { name: "user", type: "text", label: "User ID" },
@@ -4175,12 +4687,14 @@ function normalizeConfig(config) {
4175
4687
  assertValidDeclarativeAccessInConfig(schemaAwareConfig, "config");
4176
4688
  assertValidAdminConditionsInConfig(schemaAwareConfig, "config");
4177
4689
  assertValidPreviewUrlsInConfig(schemaAwareConfig, "config");
4690
+ assertValidTrashInConfig(schemaAwareConfig, "config");
4178
4691
  const collections = schemaAwareConfig?.collections || [];
4179
4692
  const globals = schemaAwareConfig?.globals || [];
4180
4693
  const needsAudit = collections.some((col) => col.audit);
4181
4694
  const needsWorkflow = collections.some((col) => col.workflow || col.drafts);
4182
4695
  const needsTasks = (config.tasks?.length ?? 0) > 0;
4183
4696
  const needsAuthSessions = collections.some((col) => !!col.auth);
4697
+ const needsTrash = schemaAwareConfig.trash?.enabled === true || collections.some((col) => resolveTrashConfig(col, schemaAwareConfig).enabled);
4184
4698
  const adminAuthCollectionSlug = getAdminAuthCollection({
4185
4699
  collections,
4186
4700
  adminAuth: schemaAwareConfig.adminAuth
@@ -4418,6 +4932,9 @@ function normalizeConfig(config) {
4418
4932
  if (needsAuthSessions && !normalizedCollections.some((col) => col.slug === AUTH_SESSIONS_COLLECTION)) {
4419
4933
  systemCollections.push(AUTH_SESSIONS_COLLECTION_CONFIG);
4420
4934
  }
4935
+ if (needsTrash && !normalizedCollections.some((col) => col.slug === TRASH_COLLECTION)) {
4936
+ systemCollections.push(TRASH_COLLECTION_CONFIG);
4937
+ }
4421
4938
  return {
4422
4939
  ...schemaAwareConfig,
4423
4940
  collections: [...normalizedCollections, ...systemCollections],
@@ -4780,206 +5297,6 @@ function buildSqlOrderBy(sort, existingColumns, numericFields, dialect) {
4780
5297
  }).join(", ");
4781
5298
  }
4782
5299
 
4783
- // src/utils/hooks.ts
4784
- var import_jexl3 = __toESM(require_Jexl(), 1);
4785
- async function runCollectionHooks(hooks, args, options = {}) {
4786
- if (!hooks || hooks.length === 0) {
4787
- return args.data ?? args.doc ?? args.query ?? void 0;
4788
- }
4789
- const payloadKey = args.data !== void 0 ? "data" : args.doc !== void 0 ? "doc" : "query" in args ? "query" : void 0;
4790
- let currentPayload = payloadKey === void 0 ? void 0 : args[payloadKey];
4791
- for (const hook of hooks) {
4792
- try {
4793
- const hookArgs = {
4794
- ...args,
4795
- ...payloadKey !== void 0 ? { [payloadKey]: currentPayload } : {}
4796
- };
4797
- const result = typeof hook === "string" ? await (() => {
4798
- if (options.surface) {
4799
- assertValidDeclarativeHookExpression(
4800
- hook,
4801
- options.surface,
4802
- options.path ?? options.surface
4803
- );
4804
- }
4805
- return import_jexl3.default.eval(hook, hookArgs);
4806
- })() : await hook(hookArgs);
4807
- if (result !== void 0) {
4808
- if (typeof hook === "string" && options.surface) {
4809
- assertValidDeclarativeHookResult(
4810
- result,
4811
- options.surface,
4812
- options.path ?? options.surface
4813
- );
4814
- }
4815
- currentPayload = typeof hook === "string" && currentPayload && typeof currentPayload === "object" && !Array.isArray(currentPayload) && result && typeof result === "object" && !Array.isArray(result) ? {
4816
- ...currentPayload,
4817
- ...result
4818
- } : result;
4819
- }
4820
- } catch (err) {
4821
- if (options.isolated) {
4822
- console.error("Side-effect hook failed after a successful write", err);
4823
- } else {
4824
- throw err;
4825
- }
4826
- }
4827
- }
4828
- return currentPayload;
4829
- }
4830
- async function executeFieldBeforeChange(fields, data, originalDoc, user, db, pathPrefix = "") {
4831
- if (!data || typeof data !== "object") return data;
4832
- const result = { ...data };
4833
- for (const field of fields) {
4834
- if (!field.name) continue;
4835
- const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
4836
- const value = result[field.name];
4837
- const origValue = originalDoc?.[field.name];
4838
- let updatedValue = value;
4839
- if (field.hooks?.beforeChange) {
4840
- for (const hook of field.hooks.beforeChange) {
4841
- const hookArgs = {
4842
- value: updatedValue,
4843
- originalDoc: originalDoc ?? void 0,
4844
- data: result,
4845
- user,
4846
- db
4847
- };
4848
- updatedValue = typeof hook === "string" ? await (() => {
4849
- assertValidDeclarativeHookExpression(
4850
- hook,
4851
- "field.beforeChange",
4852
- fieldPath
4853
- );
4854
- return import_jexl3.default.eval(hook, hookArgs);
4855
- })() : await hook(hookArgs);
4856
- }
4857
- result[field.name] = updatedValue;
4858
- }
4859
- if (updatedValue !== void 0 && updatedValue !== null) {
4860
- if (field.type === "object" && field.fields) {
4861
- result[field.name] = await executeFieldBeforeChange(
4862
- field.fields,
4863
- updatedValue,
4864
- origValue,
4865
- user,
4866
- db,
4867
- fieldPath
4868
- );
4869
- } else if (field.type === "array" && field.fields && Array.isArray(updatedValue)) {
4870
- const arrayResult = [];
4871
- for (let i = 0; i < updatedValue.length; i++) {
4872
- const item = updatedValue[i];
4873
- const origItem = Array.isArray(origValue) ? origValue[i] : null;
4874
- arrayResult.push(
4875
- await executeFieldBeforeChange(
4876
- field.fields,
4877
- item,
4878
- origItem,
4879
- user,
4880
- db,
4881
- fieldPath
4882
- )
4883
- );
4884
- }
4885
- result[field.name] = arrayResult;
4886
- } else if (field.type === "blocks" && field.blocks && Array.isArray(updatedValue)) {
4887
- const blocksResult = [];
4888
- for (let i = 0; i < updatedValue.length; i++) {
4889
- const blockData = updatedValue[i];
4890
- const origBlock = Array.isArray(origValue) ? origValue[i] : null;
4891
- const blockConfig = field.blocks.find(
4892
- (b) => b.slug === blockData.blockType
4893
- );
4894
- if (blockConfig) {
4895
- blocksResult.push(
4896
- await executeFieldBeforeChange(
4897
- blockConfig.fields,
4898
- blockData,
4899
- origBlock,
4900
- user,
4901
- db,
4902
- fieldPath
4903
- )
4904
- );
4905
- } else {
4906
- blocksResult.push(blockData);
4907
- }
4908
- }
4909
- result[field.name] = blocksResult;
4910
- }
4911
- }
4912
- }
4913
- return result;
4914
- }
4915
- async function executeFieldAfterRead(fields, doc, user, db) {
4916
- if (!doc || typeof doc !== "object") return doc;
4917
- const result = { ...doc };
4918
- for (const field of fields) {
4919
- if (!field.name) continue;
4920
- const value = result[field.name];
4921
- let updatedValue = value;
4922
- if (field.hooks?.afterRead) {
4923
- for (const hook of field.hooks.afterRead) {
4924
- const typedHook = hook;
4925
- const hookArgs = {
4926
- value: updatedValue,
4927
- doc: result,
4928
- user,
4929
- db
4930
- };
4931
- updatedValue = await typedHook(hookArgs);
4932
- }
4933
- result[field.name] = updatedValue;
4934
- }
4935
- if (updatedValue !== void 0 && updatedValue !== null) {
4936
- if (field.type === "object" && field.fields) {
4937
- result[field.name] = await executeFieldAfterRead(
4938
- field.fields,
4939
- updatedValue,
4940
- user,
4941
- db
4942
- );
4943
- } else if (field.type === "array" && field.fields && Array.isArray(updatedValue)) {
4944
- const arrayResult = [];
4945
- for (const item of updatedValue) {
4946
- arrayResult.push(
4947
- await executeFieldAfterRead(
4948
- field.fields,
4949
- item,
4950
- user,
4951
- db
4952
- )
4953
- );
4954
- }
4955
- result[field.name] = arrayResult;
4956
- } else if (field.type === "blocks" && field.blocks && Array.isArray(updatedValue)) {
4957
- const blocksResult = [];
4958
- for (const blockData of updatedValue) {
4959
- const typedBlock = blockData;
4960
- const blockConfig = field.blocks.find(
4961
- (b) => b.slug === typedBlock.blockType
4962
- );
4963
- if (blockConfig) {
4964
- blocksResult.push(
4965
- await executeFieldAfterRead(
4966
- blockConfig.fields,
4967
- typedBlock,
4968
- user,
4969
- db
4970
- )
4971
- );
4972
- } else {
4973
- blocksResult.push(blockData);
4974
- }
4975
- }
4976
- result[field.name] = blocksResult;
4977
- }
4978
- }
4979
- }
4980
- return result;
4981
- }
4982
-
4983
5300
  // src/utils/openapi.ts
4984
5301
  function getCollectionLabels(collection) {
4985
5302
  return collection.labels || { singular: collection.slug, plural: collection.slug };
@@ -7403,10 +7720,12 @@ var RESERVED_NAVIGATION_SLUGS = /* @__PURE__ */ new Set([
7403
7720
  "settings",
7404
7721
  "admin",
7405
7722
  "preferences",
7406
- "auth"
7723
+ "auth",
7724
+ "trash"
7407
7725
  ]);
7408
7726
  function assertValidNavigationSlugs(navigation) {
7409
7727
  for (const item of navigation) {
7728
+ if (item.dashboard || item.trash) continue;
7410
7729
  if (item.slug) {
7411
7730
  const normalizedSlug = item.slug.toLowerCase().trim().replace(/^\//, "");
7412
7731
  if (RESERVED_NAVIGATION_SLUGS.has(normalizedSlug)) {
@@ -7486,6 +7805,21 @@ function compileNavigation(config) {
7486
7805
  });
7487
7806
  continue;
7488
7807
  }
7808
+ if (item.trash) {
7809
+ registerItem(groupName, {
7810
+ id: "trash",
7811
+ type: "trash",
7812
+ slug: "trash",
7813
+ label: item.label || "Trash",
7814
+ icon: item.icon || "Trash2",
7815
+ group: groupName,
7816
+ order: item.order ?? 950,
7817
+ views: [],
7818
+ badge: item.badge,
7819
+ access: item.access
7820
+ });
7821
+ continue;
7822
+ }
7489
7823
  if (item.collection) {
7490
7824
  const col = collectionsBySlug.get(item.collection);
7491
7825
  const combinedViews = [
@@ -7595,6 +7929,23 @@ function compileNavigation(config) {
7595
7929
  views: []
7596
7930
  });
7597
7931
  }
7932
+ const mentionedTrash = explicitNav.some((i) => i.trash || i.slug === "trash");
7933
+ if (!mentionedTrash) {
7934
+ const hasTrash = collections.some(
7935
+ (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, config).enabled
7936
+ );
7937
+ if (hasTrash) {
7938
+ registerItem(void 0, {
7939
+ id: "trash",
7940
+ type: "trash",
7941
+ slug: "trash",
7942
+ label: "Trash",
7943
+ icon: "Trash2",
7944
+ order: 950,
7945
+ views: []
7946
+ });
7947
+ }
7948
+ }
7598
7949
  const sortAndSpliceItems = (items) => {
7599
7950
  const sorted = [...items].sort((a, b) => a.order - b.order);
7600
7951
  const explicitMap = /* @__PURE__ */ new Map();
@@ -7762,6 +8113,133 @@ function defineImageField(config) {
7762
8113
  function defineJoinField(config) {
7763
8114
  return _defineJoinFieldBase(config);
7764
8115
  }
8116
+ function defineCreatedAtField(config) {
8117
+ const type = config?.type ?? "datetime";
8118
+ const name = config?.name ?? "createdAt";
8119
+ return {
8120
+ name,
8121
+ label: "Created At",
8122
+ ...config,
8123
+ type,
8124
+ admin: {
8125
+ readOnly: true,
8126
+ ...config?.admin
8127
+ }
8128
+ };
8129
+ }
8130
+ function defineUpdatedAtField(config) {
8131
+ const type = config?.type ?? "datetime";
8132
+ const name = config?.name ?? "updatedAt";
8133
+ return {
8134
+ name,
8135
+ label: "Updated At",
8136
+ ...config,
8137
+ type,
8138
+ admin: {
8139
+ readOnly: true,
8140
+ ...config?.admin
8141
+ }
8142
+ };
8143
+ }
8144
+ function defineCreatedByField(config) {
8145
+ const name = config?.name ?? "createdBy";
8146
+ if (config?.type === "text") {
8147
+ const { type: _2, ...rest2 } = config;
8148
+ return {
8149
+ name,
8150
+ label: "Created By",
8151
+ ...rest2,
8152
+ type: "text",
8153
+ admin: {
8154
+ readOnly: true,
8155
+ ...rest2?.admin
8156
+ }
8157
+ };
8158
+ }
8159
+ const { type: _, relationTo = "users", ...rest } = config || {};
8160
+ return {
8161
+ name,
8162
+ label: "Created By",
8163
+ ...rest,
8164
+ type: "relationship",
8165
+ relationTo,
8166
+ admin: {
8167
+ readOnly: true,
8168
+ ...rest?.admin
8169
+ },
8170
+ access: {
8171
+ update: () => false,
8172
+ ...rest?.access
8173
+ }
8174
+ };
8175
+ }
8176
+ function defineUpdatedByField(config) {
8177
+ const name = config?.name ?? "updatedBy";
8178
+ if (config?.type === "text") {
8179
+ const { type: _2, ...rest2 } = config;
8180
+ return {
8181
+ name,
8182
+ label: "Updated By",
8183
+ ...rest2,
8184
+ type: "text",
8185
+ admin: {
8186
+ readOnly: true,
8187
+ ...rest2?.admin
8188
+ }
8189
+ };
8190
+ }
8191
+ const { type: _, relationTo = "users", ...rest } = config || {};
8192
+ return {
8193
+ name,
8194
+ label: "Updated By",
8195
+ ...rest,
8196
+ type: "relationship",
8197
+ relationTo,
8198
+ admin: {
8199
+ readOnly: true,
8200
+ ...rest?.admin
8201
+ },
8202
+ access: {
8203
+ update: () => false,
8204
+ ...rest?.access
8205
+ }
8206
+ };
8207
+ }
8208
+ var DEFAULT_ROLES = [
8209
+ { value: "admin", label: "Admin" },
8210
+ { value: "editor", label: "Editor" },
8211
+ { value: "viewer", label: "Viewer" }
8212
+ ];
8213
+ function defineRolesField(config) {
8214
+ const isMultiple = Boolean(config?.multiple || config?.hasMany || config?.type === "multiSelect");
8215
+ const type = isMultiple ? "multiSelect" : "select";
8216
+ const name = config?.name ?? "roles";
8217
+ const label = config?.label ?? (name === "role" ? "Role" : "Roles");
8218
+ const rawRoles = config?.roles ?? config?.options ?? DEFAULT_ROLES;
8219
+ const resolvedOptions = Array.isArray(rawRoles) ? rawRoles.map(
8220
+ (r) => typeof r === "string" ? { value: r, label: r.charAt(0).toUpperCase() + r.slice(1).replace(/[-_]/g, " ") } : r
8221
+ ) : rawRoles;
8222
+ const lastOption = Array.isArray(resolvedOptions) && resolvedOptions.length > 0 ? resolvedOptions[resolvedOptions.length - 1] : void 0;
8223
+ const defaultRoleValue = lastOption ? typeof lastOption === "string" ? lastOption : lastOption.value : "viewer";
8224
+ const defaultValue2 = config?.defaultValue !== void 0 ? config.defaultValue : isMultiple ? [defaultRoleValue] : defaultRoleValue;
8225
+ const adminRoles = Array.isArray(config?.adminRole) ? config.adminRole : typeof config?.adminRole === "string" ? [config.adminRole] : ["admin"];
8226
+ const defaultUpdateAccess = adminRoles.map((r) => `user.role == '${r}' || (user.roles != null && '${r}' in user.roles)`).join(" || ");
8227
+ const { multiple: _, roles: __, adminRole: ___, ...rest } = config || {};
8228
+ return {
8229
+ name,
8230
+ label,
8231
+ defaultValue: defaultValue2,
8232
+ options: resolvedOptions,
8233
+ access: {
8234
+ update: defaultUpdateAccess,
8235
+ ...rest.access
8236
+ },
8237
+ ...rest,
8238
+ type,
8239
+ ...isMultiple ? { hasMany: true } : {}
8240
+ };
8241
+ }
8242
+ var defineRoles = defineRolesField;
7765
8243
  function defineTab(args) {
7766
8244
  return args.fields.map((field) => ({
7767
8245
  ...field,
@@ -7797,6 +8275,8 @@ function defineTab(args) {
7797
8275
  RESERVED_NAVIGATION_SLUGS,
7798
8276
  TASK_LOCKS_COLLECTION,
7799
8277
  TASK_LOCKS_COLLECTION_CONFIG,
8278
+ TRASH_COLLECTION,
8279
+ TRASH_COLLECTION_CONFIG,
7800
8280
  UNASSIGNED_GROUP_KEY,
7801
8281
  ValidationError,
7802
8282
  WORKFLOW_HISTORY_COLLECTION,
@@ -7810,6 +8290,7 @@ function defineTab(args) {
7810
8290
  assertValidDeclarativeHooksInConfig,
7811
8291
  assertValidNavigationSlugs,
7812
8292
  assertValidPreviewUrlsInConfig,
8293
+ assertValidTrashInConfig,
7813
8294
  availableWorkflowTransitions,
7814
8295
  buildSqlOrderBy,
7815
8296
  canViewWorkflowDraft,
@@ -7828,6 +8309,7 @@ function defineTab(args) {
7828
8309
  count,
7829
8310
  createLifecycleEvent,
7830
8311
  createTaskRunner,
8312
+ createTrashPurgeTask,
7831
8313
  createWorkflowDocument,
7832
8314
  defaultValue,
7833
8315
  defineAction,
@@ -7839,6 +8321,8 @@ function defineTab(args) {
7839
8321
  defineCardsView,
7840
8322
  defineCollection,
7841
8323
  defineConfig,
8324
+ defineCreatedAtField,
8325
+ defineCreatedByField,
7842
8326
  defineDateField,
7843
8327
  defineDateTimeField,
7844
8328
  defineEmailField,
@@ -7858,6 +8342,8 @@ function defineTab(args) {
7858
8342
  defineRadioField,
7859
8343
  defineRelationshipField,
7860
8344
  defineRichTextField,
8345
+ defineRoles,
8346
+ defineRolesField,
7861
8347
  defineRowField,
7862
8348
  defineSelectField,
7863
8349
  defineTab,
@@ -7866,6 +8352,8 @@ function defineTab(args) {
7866
8352
  defineTextField,
7867
8353
  defineTextareaField,
7868
8354
  defineTimeField,
8355
+ defineUpdatedAtField,
8356
+ defineUpdatedByField,
7869
8357
  defineUrlField,
7870
8358
  defineView,
7871
8359
  defineWorkspace,
@@ -7905,6 +8393,7 @@ function defineTab(args) {
7905
8393
  getAdminRoleForCollection,
7906
8394
  getAllowedSitesForUser,
7907
8395
  getPublicAdminAuthConfig,
8396
+ getTrashEntryId,
7908
8397
  includes,
7909
8398
  initializeWorkflowDocument,
7910
8399
  isAICollection,
@@ -7948,14 +8437,18 @@ function defineTab(args) {
7948
8437
  resolveAdminAuthCollection,
7949
8438
  resolveAllCollectionViews,
7950
8439
  resolveAuthorizedSiteId,
8440
+ resolveDocumentTitle,
7951
8441
  resolveIndexName,
7952
8442
  resolvePrefix,
8443
+ resolveTrashConfig,
8444
+ restoreDocument,
7953
8445
  round,
7954
8446
  runCollectionHooks,
7955
8447
  sanitizeDocForAI,
7956
8448
  saveWorkflowDraft,
7957
8449
  simplePublishingWorkflow,
7958
8450
  slugify,
8451
+ softDeleteDocument,
7959
8452
  sqrt,
7960
8453
  startsWith,
7961
8454
  sum,